데이터 접근 기술- 스프링 JdbcTemplate
JdbcTemplate 장단점
장점
- spring-jdbc 라이브러리(스프링으로 jdbc를 사용할 때 기본으로 사용되는 라이브러리)에 포함되어 있어 별도의 복잡한 설정 없이 사용할 수 있는 설정의 편리함을 제공합니다.
- 템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업(커넥션 획득, statement를 준비하고 실행, 결과를 반복하도록 루프 실행, 트랜잭션 다루기 위한 커넥션 동기화, 예외 발생 시 스프링 예외 변환기 실행)을 대신 처리해줍니다.
단점
- 동적 SQL을 다루기가 어렵습니다.
JdbcTemplate 주요 기능
- JdbcTemplate: 순서 기반 파라미터 바인딩을 지원합니다.
- NamedParameterJdbcTemplate: 이름 기반 파라미터 바인딩을 지원합니다. (권장)
- SimpleJdbcInsert: INSERT SQL을 편리하게 사용할 수 있습니다.
- SimpleJdbcCall: 스토어드 프로시저를 편리하게 호출할 수 있습니다.
JdbcTemplate 적용
의존성 추가(build.gradle)
//JdbcTemplate 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'
로그 추가
JdbcTemplate이 실행하는 SQL 로그를 확인하려면 application.properties에 다음을 추가하면 됩니다.
#jdbcTemplate sql log
logging.level.org.springframework.jdbc=debug
JdbcTemplate를 사용해서 메모리에 저장하던 데이터를 H2 데이터베이스에 저장합니다. ItemRepository 인터페이스를 기반으로 JdbcTemplate을 사용하는 새로운 구현체를 개발합니다. 스프링 JdbcTemplate 사용 방법은 공식 메뉴얼을 참고하시면 됩니다.
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.util.StringUtils;
import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* JdbcTemplate
*/
@Slf4j
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 {
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;
});
}
}
- this.template = new JdbcTemplate(dataSource): JdbcTemplate은 데이터소스가 필요합니다. 생성자를 보면 dataSource를 의존 관계 주입 받고 생성자 내부에서 JdbcTemplate를 생성합니다. 스프링에서는 JdbcTemplate를 사용할 때 관례상 이 방법을 많이 사용하지만, JdbcTemplate을 스프링 빈으로 직접 등록하고 주입받아도 됩니다.
- template.update(): 데이터를 변경할 때는 update()를 사용합니다. INSERT, UPDATE, DELETE SQL에 사용하며, 반환 값은 int로 영향 받은 로우 수입니다.
- KeyHolder와 connection.prepareStatement(sql, new String[]{"id"})를 사용해서 id를 지정해주면 INSERT 쿼리 실행 이후에 데이터베이스에서 생성된 ID 값을 조회할 수 있습니다.
- template.queryForObject(): 결과 로우가 하나일 때 사용합니다. RowMapper는 데이터베이스의 반환 결과인 ResultSet을 객체로 변환합니다. 결과가 없으면 EmptyResultDataAccessException 예외를, 결과가 둘 이상이면 IncorrectResultSizeDataAccessException 예외가 발생합니다.
- template.query(): 결과가 하나 이상일 때 사용합니다. 결과가 없으면 빈 컬렉션을 반환합니다.
신규 구현체로 적용하기 위해 신규 Config 파일을 생성하고, ItemServiceApplication에 설정된 Config 클래스를 변경합니다. 이후 포스팅부터 해당 내용은 skip 하겠습니다.
package hello.itemservice.config;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV1;
import hello.itemservice.repository.memory.MemoryItemRepository;
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);
}
}
//@Import(MemoryConfig.class)
@Import(JdbcTemplateV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {...}
NamedParameterJdbcTemplate
JdbcTemplate을 기본으로 사용하면 파라미터를 순서대로 바인딩 합니다. 아래 코드에서 itemName, price, quantity는 SQL에 있는 ? 순서대로 바인딩 됩니다. 파라미터를 순서대로 바인딩 하는 것은 편리하지만, 순서가 맞지 않아 버그가 발생할 수도 있으니 주의해서 사용해야 합니다.
String sql = "update item set item_name=?, price=?, quantity=? where id=?";
template.update(sql,
itemName,
price,
quantity,
itemId);
JdbcTemplate은 이런 문제를 보완하기 위해 NamedParameterJdbcTemplate라는 이름을 지정해서 파라미터를 바인딩 하는 기능을 제공합니다.
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.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;
/**
* NamedParameterJdbcTemplate
* SqlParameterSource
* - BeanPropertySqlParameterSource
* - MapSqlParameterSource
* Map
*
* BeanPropertyRowMapper
*
*/
@Slf4j
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {
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() {
return BeanPropertyRowMapper.newInstance(Item.class); //camel 변환 지원
}
}
- SQL에서 ? 대신 :파라미터이름을 사용합니다.
- 파라미터를 전달하려면 Map처럼 Key, Value 데이터 구조를 만들어서 전달해야 합니다.
- 이름 지정 바인딩에서 자주 사용하는 파라미터의 종류는 크게 3가지(Map, MapSqlParameterSource, BeanPropertySqlParameterSource)가 있습니다.
Map
java.util.Map입니다.
// findById() 코드
Map<String, Object> param = Map.of("id", id);
Item item = template.queryForObject(sql, param, itemRowMapper());
MapSqlParameterSource
Map과 유사하나 SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공합니다. SqlParameterSource 인터페이스의 구현체로 메서드 체인을 통해 편리한 사용법도 제공합니다.
// update() 코드
SqlParameterSource param = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", itemId); //이 부분이 별도로 필요하다.
template.update(sql, param);
BeanPropertySqlParameterSource
SqlParameterSource 인터페이스의 구현체로, 자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성합니다. 예를 들어 getItemName(), getPrice()가 있으면 다음과 같은 데이터를 자동으로 만들어냅니다.
- key=itemName, value=상품명 값
- key=price, value=가격 값
// save() 코드
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);
BeanPropertyRowMapper
BeanPropertyRowMapper는 ResultSet의 결과를 받아서 자바빈 규약에 맞추어 데이터를 변환합니다. 데이터베이스에서 조회한 결과 이름을 기반으로 setId(), setPrice()처럼 자바빈 프로퍼티 규약에 맞춘 메서드를 호출하는 것입니다.
// [AS-IS]
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;
});
}
// [TO-BE]
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class); //camel 변환 지원
}
SimpleJdbcInsert와 SimpleJdbcCall에 대한 사용 방법은 스프링 공식 메뉴얼을, 해당 프로젝트에 SimpleJdbcInsert 적용 코드는 github repository를 참고하시면 됩니다.
[참고 정보]