제육's 휘발성 코딩
article thumbnail
반응형

JdbcTemplate

JDBC를 편리하게 사용할 수 있도록 도와주는 JdbcTemplate에 대해서 알아보자.

장점

  • JdbcTemplate은 spring-jdbc 라이브러리에 포함되어 있으며, 별도의 복잡한 설정 없이 바로 사용할 수 있다.
  • 템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 대신 처리해준다.
    • 커넥션 획득 및 종료 , statement 준비 및 실행, 결과 반복 루프 작업, 커넥션 동기화, 스프링 예외 변환 등

단점

  • 동적 SQL을 해결하기 어렵다.

 

JdbcTemplate 설정

build.gradle

//JdbcTemplate 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
//H2 데이터베이스 추가
runtimeOnly 'com.h2database:h2'
  • jdbc 라이브러리와 사용할 데이터베이스 라이브러리만 의존성 주입을 하면 설정은 끝난다.

 

JdbcTemplate 적용 1 - 기본

메모리에 저장하던 데이터를 데이터베이스에 저장해보자.

 

JdbcTemplateItemRepositoryV1

package hello.itemservice.repository.jdbctemplate;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

@Slf4j
@Repository
public class JdbcTemplateItemRepositoryV1 implements ItemRepository {

  private final JdbcTemplate template;

  public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
    this.template = new JdbcTemplate(dataSource);
  }

  @Override
  public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (?, ?, ?)";
    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(connection -> {
      // 자동 증가 키
      PreparedStatement ps = connection.prepareStatement(sql, new String[]{"id"});
      ps.setString(1, item.getItemName());
      ps.setInt(2, item.getPrice());
      ps.setInt(3, item.getQuantity());
      return ps;
    }, keyHolder);

    long key = keyHolder.getKey().longValue();
    item.setId(key);
    return item;
  }

  @Override
  public void update(Long itemId, ItemUpdateDto updateParam) {
    String sql = "update item set item_name=?, price=?, quantity=? where id=?";
    template.update(sql,
                    updateParam.getItemName(),
                    updateParam.getPrice(),
                    updateParam.getQuantity(),
                    itemId);
  }

  @Override
  public Optional<Item> findById(Long id) {
    String sql = "select id, item_name, price, quantity from item where id = ?";

    try {
      // queryForObject 는 결과가 없으면 EmptyResultDataAccessException 예외 발생
      Item item = template.queryForObject(sql, itemRowMapper(), id);
      return Optional.of(item);
    } catch (EmptyResultDataAccessException e) {
      return Optional.empty();
    }
  }

  @Override
  public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    String sql = "select id, item_name, price, quantity from item";

    if (StringUtils.hasText(itemName) || maxPrice != null) {
      sql += " where";
    }

    boolean andFlag = false;
    List<Object> param = new ArrayList<>();
    if (StringUtils.hasText(itemName)) {
      sql += " item_name like concat('%',?,'%')";
      param.add(itemName);
      andFlag = true;
    }

    if (maxPrice != null) {
      if (andFlag) {
        sql += " and";
      }
      sql += " price <= ?";
      param.add(maxPrice);
    }

    log.info("sql={}", sql);
    return template.query(sql, itemRowMapper(), param.toArray());
  }

  private RowMapper<Item> itemRowMapper() {
    return (rs, rowNum) -> {
      Item item = new Item();
      item.setId(rs.getLong("id"));
      item.setItemName(rs.getString("item_name"));
      item.setPrice(rs.getInt("price"));
      item.setQuantity(rs.getInt("quantity"));
      return item;
    };
  }
}
  • JdbcTemplate의 생성자를 보면 dataSource를 의존 관계로 주입받고 있다. JdbcTemplate에선 데이터소스가 필요하기 때문에 스프링에서 JdbcTemplate을 사용할 때 관례상으로 이 방법을 사용한다.
  • save() 메서드를 보면 KeyHolder가 보인다. 이는 대리 키인 id를 자동 증가하기 위한 기능으로 가볍게 보자. 참고로 자동 생성에는 SimpleJdbcInsert 라는 더 편리한 기능도 있다.
  • itemRowMapper는 데이터베이스 조회 결과를 객체로 변환할 때 사용한다. JDBC를 사용할 때 ResultSet 기능을 수행한다고 보면 된다.

 

JdbcTemplate 적용 2 - 동적 쿼리 문제

findAll() 메서드를 보면 SQL이 동적으로 실행되기 위해 설정이 복잡한 것을 볼 수 있다. 아래 예시와 같이 조건이 없는 경우, 상품명만 검색, 최대 가격만 검색, 모두 검색 등 총 4가지의 경우가 있다.

select id, item_name, price, quantity from item
  • 검색 조건이 없는 경우
select id, item_name, price, quantity from item
   where item_name like concat('%',?,'%')
and price <= ?
  • 상품명, 최대 가격 모두 검색

결과적으로 4가지 상황에 따른 SQL을 동적으로 생성해야 한다. 간단한 동적 쿼리문에도 다음과 같이 복잡한 구현을 해야된다는 단점이 존재한다.

 

JdbcTemplate 적용 3 - 구성과 실행

JdbcTemplateV1Config

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV1;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class JdbcTemplateV1Config {

  private final DataSource dataSource;

  @Bean
  public ItemService itemService() {
    return new ItemServiceV1(itemRepository());
  }

  @Bean
  public ItemRepository itemRepository() {
    return new JdbcTemplateItemRepositoryV1(dataSource);
  }

}
  • ItemRepository의 구현체를 JdbcTemplate로 변경하는 설정 파일을 생성하자.

ItemServiceApplication

//@Import(MemoryConfig.class)
@Import(JdbcTemplateV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

  public static void main(String[] args) {
    SpringApplication.run(ItemServiceApplication.class, args);
  }

  @Bean
  @Profile("local")
  public TestDataInit testDataInit(ItemRepository itemRepository) {
    return new TestDataInit(itemRepository);
  }
}
  • 컨트롤러만 스캔하기 위한 설정이 되어있으므로, 생성한 설정파일을 임포트하도록 변경하자.

application.properties

spring.profiles.active=local
spring.datasource.url = jdbc:h2:tcp://localhost/~/db2
spring.datasource.username = sa
#jdbcTemplate sql log
logging.level.org.springframework.jdbc=debug
  • 사용할 datasource를 application.properties에 등록해주자. datasource란 커넥션 획득을 위한 여러 커넥션 풀 등을 추상화해준 인터페이스다.
  • 설정만 하면 스프링 부트가 자동으로 커넥션 풀과 datasource, 트랜잭션 매니저를 생성해서 스프링 빈으로 자동 등록해준다.
  • jdbcTemplate에서 로깅을 위한 설정이 있다. 테스트 단계에서 로깅을 원한다면 별도의 테스트 디렉토리에 있는 application.properties에 추가 설정이 필요하다.

 

image

  • 다음과 같이 메모리 디비에서 데이터베이스를 적용시키고 CRUD가 정상 동작하는 것을 확인할 수 있다.

 

JdbcTemplate - 이름 지정 파라미터 1

JdbcTemplate에서 쿼리문에 ?가 들어가는 파라미터에는 순서대로 바인딩 한다. 순서대로 값을 정확히 넣는다면 문제가 발생하지 않지만, 순서가 다를 경우 큰 장애로 발생할 수 있다. 이런 문제를 보완하기 위해 JdbcTemplate은 NamedParameterJdbcTemplate 이라는 이름을 지정해서 파라미터를 바인딩하는 기능을 제공한다.

JdbcTemplateItemRepositoryV2

package hello.itemservice.repository.jdbctemplate;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

@Slf4j
@Repository
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {

  //    private final JdbcTemplate template;
  private final NamedParameterJdbcTemplate template;

  public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
    this.template = new NamedParameterJdbcTemplate(dataSource);
  }

  @Override
  public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";

    SqlParameterSource param = new BeanPropertySqlParameterSource(item);
    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(sql, param, keyHolder);

    Long key = keyHolder.getKey().longValue();
    item.setId(key);
    return item;
  }

  @Override
  public void update(Long itemId, ItemUpdateDto updateParam) {
    String sql = "update item set item_name = :itemName, price = :price, quantity = :quantity where id = :id";

    SqlParameterSource param = new MapSqlParameterSource()
      .addValue("itemName", updateParam.getItemName())
      .addValue("price", updateParam.getPrice())
      .addValue("quantity", updateParam.getQuantity())
      .addValue("id", itemId);
    template.update(sql, param);
  }

  @Override
  public Optional<Item> findById(Long id) {
    String sql = "select id, item_name, price, quantity from item where id = :id";

    try {
      Map<String, Object> param = Map.of("id", id);
      Item item = template.queryForObject(sql, param, itemRowMapper());
      return Optional.of(item);
    } catch (EmptyResultDataAccessException e) {
      return Optional.empty();
    }
  }

  @Override
  public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

    SqlParameterSource param = new BeanPropertySqlParameterSource(cond);

    String sql = "select id, item_name, price, quantity from item";

    if (StringUtils.hasText(itemName) || maxPrice != null) {
      sql += " where";
    }

    boolean andFlag = false;
    if (StringUtils.hasText(itemName)) {
      sql += " item_name like concat('%',:itemName,'%')";
      andFlag = true;
    }
    if (maxPrice != null) {
      if (andFlag) {
        sql += " and";
      }
      sql += " price <= :maxPrice";
    }


    log.info("sql={}", sql);
    return template.query(sql, param, itemRowMapper());
  }

  private RowMapper<Item> itemRowMapper() {
    // camelCase 변환 지원
    return BeanPropertyRowMapper.newInstance(Item.class);
  }
}
  • NamedParameterJdbcTemplate를 사용한 버전이다. JdbcTemplate과 마찬가지로 내부에 dataSource가 필요하므로 생성자에서 넣어주었다. 이전 버전과 다른 점은 ?:value로 지정해주는 것을 볼 수 있다.
  • itemRowMapper() 를 보면 BeanPropertiyRowMapper를 사용해서 객체로 반환해주는 기능을 간단하게 사용할 수 있는 것을 볼 수 있다.

 

JdbcTemplate 이름 지정 파라미터 - 2

버전 1을 보면 파라미터를 전달하기 위해 Map.of 등을 사용한 것을 볼 수 있다. 그 이유는 이름 지정 파라미터는 파라미터를 전달할 때 key, value 구조를 만들어서 전달해야 하기 때문이다. 여기서의 value가 곧 :value 로 명시되는 파라미터 값이다.

이름 지정 바인딩 구조에선 Map, MapSqlParameterSource, BeanPropertySqlParameterSource 등 크게 3가지를 사용한다.

 

Map

Map<String, Object> param = Map.of("id", id);
Item item = template.queryForObject(sql, param, itemRowMapper());
  • findById() 메서드를 보면 다음과 같이 단순히 Map을 사용해서 구현한 것을 볼 수 있다.

MapSqlParameterSource

@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
  String sql = "update item set item_name = :itemName, price = :price, quantity = :quantity where id = :id";

  SqlParameterSource param = new MapSqlParameterSource()
    .addValue("itemName", updateParam.getItemName())
    .addValue("price", updateParam.getPrice())
    .addValue("quantity", updateParam.getQuantity())
    .addValue("id", itemId);
  template.update(sql, param);
}
  • SqlParameterSource 인터페이스의 구현체로, Map과 유사하지만 .addValue 와 같이 메서드 체이닝이 가능하다.

BeanPropertySqlParameterSource

SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);
  • 자바 빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성해준다. 즉 가장 편리한 방법이다. item에 있는 모든 속성들을 key, value로 만들어준다.
  • 단, 항상 사용할 수 있는 것이 아니다. ItemUpdateDto 에선 itemId가 없는 것과 같이 현재 사용할 객체에 원하는 속성이 없다면 대신 MapSqlParameterSource를 사용한다.

BeanPropertyRowMapper

/*
     private RowMapper<Item> itemRowMapper() {
        return (rs, rowNum) -> {
            Item item = new Item();
            item.setId(rs.getLong("id"));
            item.setItemName(rs.getString("item_name"));
            item.setPrice(rs.getInt("price"));
            item.setQuantity(rs.getInt("quantity"));
            return item;
     }; 
   }
*/

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); //camel 변환 지원
}
  • BeanPropertyRowMapper는 ResultSet의 결과를 받아서 데이터를 변환 해주며, 스네이크 표기법을 카멜 케이스로 자동 변환해준다.

JdbcTemplate - 이름 지정 파라미터 3

이름 지정 파라미터로 변경한 내용을 적용하고 실행해보자.

JdbcTemplateV2Config

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV1;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV2;
import hello.itemservice.service.ItemService;
import hello.itemservice.service.ItemServiceV1;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import javax.sql.DataSource;

@Configuration
@RequiredArgsConstructor
public class JdbcTemplateV2Config {

  private final DataSource dataSource;

  @Bean
  public ItemService itemService() {
    return new ItemServiceV1(itemRepository());
  }

  @Bean
  public ItemRepository itemRepository() {
    return new JdbcTemplateItemRepositoryV2(dataSource);
  }

}
  • JdbcTemplateItemRepositoryV1을 V2로 변경하여 빈으로 등록하자.

ItemServiceApplication

//@Import(MemoryConfig.class)
//@Import(JdbcTemplateV1Config.class)
@Import(JdbcTemplateV2Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

  public static void main(String[] args) {
    SpringApplication.run(ItemServiceApplication.class, args);
  }

  @Bean
  @Profile("local")
  public TestDataInit testDataInit(ItemRepository itemRepository) {
    return new TestDataInit(itemRepository);
  }
}
  • 애플리케이션 설정에도 V2로 변경하고 실행해보면 정상 동작하는 것을 확인할 수 있다.

 

JdbcTemplate - SimpleJdbcInsert

JdbcTemplate은 INSERT SQL을 직접 작성하지 않아도 되도록 SimpleJdbcInsert라는 기능을 제공한다.

@Slf4j
@Repository
public class JdbcTemplateItemRepositoryV3 implements ItemRepository {

  private final NamedParameterJdbcTemplate template;
  private final SimpleJdbcInsert jdbcInsert;

  public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
    this.template = new NamedParameterJdbcTemplate(dataSource);
    this.jdbcInsert = new SimpleJdbcInsert(dataSource)
      .withTableName("item")
      .usingGeneratedKeyColumns("id");
    //                .usingColumns("item_name", "price", "quantity") 생략 가능

  }

  @Override
  public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";

    SqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);
    item.setId(key.longValue());
    return item;
  }
}
  • SimpleJdbcInsert 는 다음과 같은 기능을 제공한다.
    • withTableName : 데이터를 저장할 테이블 명을 지정한다.
    • usingGeneratedKeyColumns : 키를 생성하는 PK 컬럼명 지정
    • usingColumns : INSERT SQL에 사용할 컬럼을 지정. 특정 값만 저장하고 싶을 때 사용하며, 생략하면 생성 시점에 모든 속성을 메타데이터에서 가져와서 사용한다.
  • save() 메서드를 보면 jdbcInsert.executeAndReturnKey(param)을 사용해서 대리키 시퀀스 생성을 자동으로 해주어 간단하게 설정할 수 있는 것을 볼 수 있다.

 

JdbcTemplate 정리

스프링 JdbcTemplate 공식 매뉴얼 : https://docs.spring.io/spring-framework/docs/current/reference/html/data-access.html#jdbc-JdbcTemplate

 

JdbcTemplate : 순서 기반 파라미터 바인딩 지원

NamedParameterJdbcTemplate : 이름 기반 파라미터 바인딩 지원 (권장)

SimpleJdbcInsert : INSERT SQL을 편리하게 사용할 수 있는 기능 지원

SimpleJdbcCall : 스토어드 프로시저를 편리하게 호출할 수 있도록 지원

 

조회

단건 조회

// 숫자 조회
int rowCount = jdbcTemplate.queryForObject("select count(*) from t_actor",
                                           Integer.class);

// 숫자 조회, 파라미터 바인딩
int countOfActorsNamedJoe = jdbcTemplate.queryForObject(
  "select count(*) from t_actor where first_name = ?", Integer.class,
  "Joe");

// 문자 조회
String lastName = jdbcTemplate.queryForObject(
  "select last_name from t_actor where id = ?",
  String.class, 1212L);

// 객체 조회
Actor actor = jdbcTemplate.queryForObject(
  "select first_name, last_name from t_actor where id = ?",
  (resultSet, rowNum) -> {
    Actor newActor = new Actor();
    newActor.setFirstName(resultSet.getString("first_name"));
    newActor.setLastName(resultSet.getString("last_name"));
    return newActor;
  }, 1212L);
  • 객체 한 개를 조회 시 결과를 객체로 매핑해야 하므로 RowMapper 를 사용해야 한다.

목록 조회

// 객체 조회 - 
private final RowMapper<Actor> actorRowMapper = (resultSet, rowNum) -> {
  Actor actor = new Actor();
  actor.setFirstName(resultSet.getString("first_name"));
  actor.setLastName(resultSet.getString("last_name"));
  return actor;
};

public List<Actor> findAllActors() {
  return this.jdbcTemplate.query("select first_name, last_name from t_actor",
                                 actorRowMapper);
  • 여러 로우를 조회할 때는 query() 를 사용해서 리스트로 반환한다.

변경 (INSERT, UPDATE, DELETE)

// INSERT
jdbcTemplate.update(
  "insert into t_actor (first_name, last_name) values (?, ?)",
  "Leonor", "Watling");

// UPDATE
jdbcTemplate.update(
  "update t_actor set last_name = ? where id = ?",
  "Banjo", 5276L);

// DELETE
jdbcTemplate.update(
  "delete from t_actor where id = ?",
  Long.valueOf(actorId));
  • 데이터를 변경할 때는 jdbcTemplate.update()를 사용한다. 반환 값은 결과 값으로 나온 row 수를 반환한다.

기타 기능

// DDL
jdbcTemplate.execute("create table mytable (id integer, name varchar(100))");

// 스토어드 프로시저 호출
jdbcTemplate.update(
  "call SUPPORT.REFRESH_ACTORS_SUMMARY(?)",
  Long.valueOf(unionId));
  • DDL, 또는 스토어드 프로시저 호출과 같이 임의의 SQL을 실행할 때는 execute() 를 사용한다.

REFERENCES

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-db-2/

반응형
profile

제육's 휘발성 코딩

@sasca37

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요! 맞구독은 언제나 환영입니다^^