
데이터 접근 기술에는 아주 다양한 것들이 존재한다. 이를 크게 나누면 SQL Mapper, ORM으로 나뉘는데, SQL Mapper는 개발자가 SQL만 작성하면 해당 SQL의 결과를 객체로 편리하게 매핑해주는 기술로, JDBC를 직접 사용할 때 발생하는 여러가지 중복을 제거하고 기타 편리한 기능을 제공해준다. 반면, ORM은 SQL을 대신 작성하고 처리해준다. JPA로 예를 들면, 개발자는 저장하고 싶은 객체를 마치 자바 컬렉션에 저장하고 조회하듯이 사용하면 ORM이 알아서 DB에 해당 객체를 저장하고 조회해준다.
이 중에서 SQL Mapper와 비슷한 JdbcTemplate에 대해 먼저 알아보자. JdbcTemplate은 템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업을 대신 처리해준다. 개발자는 그저 SQL을 작성하고, 전달할 파라미터를 정의하고, 응답값을 매핑해주기만 하면 된다. 이제 실제 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 java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import javax.sql.DataSource;
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;
@Slf4j
public class JdbcTemplateItemRepositoryV1 implements ItemRepository {
private final JdbcTemplate jdbcTemplate;
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.jdbcTemplate = new JdbcTemplate(dataSource);
}
@Override
public Item save(Item item) {
String sql = "INSERT INTO item (item_name, price, quantity) VALUES (?, ?, ?)";
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.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 = "UDPATE item SET item_name = ?, price = ?, quantity = ? WHERE id = ?";
jdbcTemplate.update(
sql,
updateParam.getItemName(),
updateParam.getPrice(),
updateParam.getQuantity(),
itemId
);
}
@Override
public Optional<Item> findById(Long itemId) {
String sql = "SELECT * FROM item WHERE id = ?";
try {
Item item = jdbcTemplate.queryForObject(sql, itemRowMapper(), itemId);
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 * 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 jdbcTemplate.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은 데이터소스가 필요하기 때문에 데이터소스를 주입 받고 생성자 내부에서 JdbcTemplate을 생성한다. 스프링에서 관례상 이 방법을 많이 사용하고는 한다. 메서드를 하나씩 살펴보자.
save(): 데이터 저장
jdbcTemplate.update(): 데이터를 변경할 때는 update() 메서드를 사용하면 된다. 이 메서드의 반환값은 영향 받은 ROW 수를 정수형으로 반환한다.AUTO INCREMENT 방식을 사용하기 때문에, 개발자가 직접 PK를 지정하지 않고 비워두고 저장하면 된다. DB가 PK인 ID를 대신 생성해줄 것이다.KeyHolder와 connection.prepareStatement(sql, new String[]{"id"})를 사용해서 id를 지정해줌으로써 가져올 수 있다.update(): 데이터를 업데이트
jdbcTemplate.update(): 데이터를 변경할 때도 update() 메서드를 사용하면 된다. 그냥 ?에 바인딩할 파라미터를 순서대로 전달하면 된다. 이 녀석의 반환값 역시 해당 쿼리로 영향을 받은 ROW이지만, 여기서는 WHERE id = ?이라고 지정해줬기 때문에 하나의 ROW만 반환할 것이다.findById(): 데이터를 하나 조회
jdbcTemplate.queryForObject(): 결과 ROW가 하나일 경우에만 사용한다. 여기서 RowMapper가 반환 결과인 ResultSet을 객체로 변환하고, 결과가 없으면 EmptyResultDataAccessException 예외를, 결과가 둘 이상이면 IncorrectResultSizeDataAccessException 예외를 터뜨린다.ItemRepository.findById() 에서 결과가 없을 때 Optional을 반환하도록 해서 결과가 없으면 예외를 잡아 Optional.empty()를 대신 반환하도록 한 것이다.// queryForObject() 인터페이스 정의
@Nullable
public <T> T queryForObject(String sql, RowMapper<T> rowMapper, @Nullable Object... args) throws DataAccessException {
List<T> results = (List)this.query((String)sql, (Object[])args, (ResultSetExtractor)(new RowMapperResultSetExtractor(rowMapper, 1)));
return DataAccessUtils.nullableSingleResult(results);
}findAll(): 데이터를 리스트로 조회jdbcTemplate.query(): 결과가 하나 이상일 때 사용한다. 마찬가지로 RowMapper는 반환 결과인 ResultSet을 객체로 변환한다. 결과가 없으면 빈 컬렉션을 반환한다.
public <T> List<T> query(String sql, RowMapper<T> rowMapper, @Nullable Object... args) throws DataAccessException {
return (List)result(this.query((String)sql, (Object[])args, (ResultSetExtractor)(new RowMapperResultSetExtractor(rowMapper))));
}
itemRowMapper(): DB의 조회 결과를 객체로 변환하지만 위 메서드들 중 findAll()에서 난감한 부분이 있다. 바로 사용자가 검색하는 값에 따라서 실행하는 SQL이 동적으로 달려져야 한다는 것이다. 검색 조건이 없는 경우, 상품명으로 검색하는 경우, 최대 가격으로 검색하는 경우 등등 검색 조건은 굉장히 다양하기 때문에 깊은 고민이 필요하다.
JdbcTemplate을 기본으로 사용하면 아래와 같이 파라미터를 순서대로 바인딩 해줘야 한다.
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
String sql = "UDPATE item SET item_name = ?, price = ?, quantity = ?";
jdbcTemplate.update(
sql,
updateParam.getItemName(),
updateParam.getPrice(),
updateParam.getQuantity(),
itemId
);
}
하지만 사람은 실수를 하기 마련… 실수로 파라미터를 순서에 어긋나게 전달했을 때 문제가 발생한다. 현업에서는 파라미터가 수십개가 될 수도 있기 때문에 수정하면서 이러한 문제가 충분히 발생할 수 있다. 버그 중에서 가장 고치기 힘든 버그는 DB에 데이터가 잘못 들어가는 버그다. 개발할 때는 코드를 몇줄 줄이는 편리함도 물론 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다.
따라서 이런 문제를 보완하기 위해 NamedParameterJdbcTemplate이라는 파라미터 순서가 아닌 파라미터 이름 자체로 바인딩하는 기능을 제공한다. 아래 코드를 보자.
@Slf4j
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
}
@Override
public Item save(Item item) {
String sql = "INSERT INTO item (item_name, price, quantity) VALUES (:itemName, :price, :quantity)";
SqlParameterSource params = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(sql, params, keyHolder);
long key = keyHolder.getKey().longValue();
item.setId(key);
return item;
}
@Override
public void update(Long id, ItemUpdateDto updateParam) {
String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";
SqlParameterSource params = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", id);
jdbcTemplate.update(sql, params);
}
@Override
public Optional<Item> findById(Long id) {
String sql = "SELECT * FROM item WHERE id = :id";
try {
Map<String, Object> params = Map.of("id", id);
Item item = jdbcTemplate.queryForObject(sql, params, 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 * 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 jdbcTemplate.query(sql, param, itemRowMapper());
}
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
}
기존에 ?로 순서대로 바인딩된 파라미터를 넘겨줬던 부분들이 :파라미터이름으로 정확하게 파라미터 이름을 받아 바인딩하고 있는 것을 확인할 수 있다.
@Override
public void update(Long id, ItemUpdateDto updateParam) {
String sql = "UPDATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE id = :id";
SqlParameterSource params = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("id", id);
jdbcTemplate.update(sql, params);
}
파라미터를 전달하기 위해서는 Map처럼 Key로 :파라미터이름으로 지정한 파라미터 이름을, Value에 해당 파라미터 값을 가진 데이터 구조를 만들어서 전달해야 한다. 이렇게 만든 파라미터(params)를 전달하는 것을 볼 수 있다. 물론 update() 메서드처럼 MapSqlParameterSource라는 구현체를 사용해도 된다. 얘는 Map과 유사한데, SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공한다.
@Override
public Item save(Item item) {
String sql = "INSERT INTO item (item_name, price, quantity) VALUES (:itemName, :price, :quantity)";
SqlParameterSource params = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
jdbcTemplate.update(sql, params, keyHolder);
long key = keyHolder.getKey().longValue();
item.setId(key);
return item;
}
또한 위의 save() 메서드처럼 BeanPropertySqlParameterSource 구현체를 사용해도 된다. 얘는 자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. 예를 들어, getItemName(), getPrice()가 있다고 하면 각각 key=itemName, value=상품명 값, key=price, value=가격 값과 같이 데이터를 자동으로 만들어낸다. 하지만 아쉽게도 BeanPropertySqlParameterSource는 항상 사용할 수는 없다.
package hello.itemservice.repository;
import lombok.Data;
@Data
public class ItemUpdateDto {
private String itemName;
private Integer price;
private Integer quantity;
public ItemUpdateDto() {
}
public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
this.itemName = itemName;
this.price = price;
this.quantity = quantity;
}
}
update() 메서드만 보더라도 SQL에 :id를 바인딩해야 하는데, ItemUpdateDto를 보면 itemId가 없다. 따라서 BeanPropertySqlParameterSource 대신에 MapSqlParameterSource를 사용해야 한다.
그리고 JdbcTemplateItemRepositoryV1에서 JdbcTemplateItemRepositoryV2로 코드를 수정할 때 변화된 또 다른 부분이 있다. 바로 BeanPropertyRowMapper를 사용한 것이다.
// JdbcTemplateItemRepositoryV1
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;
};
}
// JdbcTemplateItemRepositoryV2
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
BeanPropertyRowMapper는 ResultSet의 결과를 받아서 자바빈 규약에 맞춰 데이터를 변환한다. 예를 들어, DB에서 조회한 결과가 SELECT id, price라고 한다면 아래와 같은 코드를 작성해준다.
Item item = new Item();
item.setId(rs.getLong("id"));
item.setPrice(rs.getInt("price"));
DB에서 조회한 결과 이름을 바탕으로 setId(), setPrice()처럼 자바빈 프로퍼티 규약에 맞춘 메서드를 호출하는 것이다.
JdbcTemplate에서는 INSERT SQL 쿼리를 직접 작성하지 않도록 SimpleJdbcInsert라는 편리한 기능도 제공한다. 바로 아래 코드를 보자.
@Slf4j
public class JdbcTemplateItemRepositoryV3 implements ItemRepository {
private final NamedParameterJdbcTemplate jdbcTemplate;
private final SimpleJdbcInsert simpleJdbcInsert;
public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
this.jdbcTemplate = new NamedParameterJdbcTemplate(dataSource);
this.simpleJdbcInsert = new SimpleJdbcInsert(dataSource)
.withTableName("item")
.usingGeneratedKeyColumns("id");
}
@Override
public Item save(Item item) {
SqlParameterSource params = new BeanPropertySqlParameterSource(item);
Number key = simpleJdbcInsert.executeAndReturnKey(params);
item.setId(key.longValue());
return item;
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
String sql = "UDPATE item SET item_name = :itemName, price = :price, quantity = :quantity WHERE item_id = :id";
SqlParameterSource params = new MapSqlParameterSource()
.addValue("itemName", updateParam.getItemName())
.addValue("price", updateParam.getPrice())
.addValue("quantity", updateParam.getQuantity())
.addValue("itemId", itemId);
jdbcTemplate.update(sql, params);
}
@Override
public Optional<Item> findById(Long itemId) {
String sql = "SELECT * FROM item WHERE id = :id";
try {
Map<String, Object> params = Map.of("id", itemId);
Item item = jdbcTemplate.queryForObject(sql, params, 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 * 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 jdbcTemplate.query(sql, param, itemRowMapper());
}
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
}
생성자를 보면 의존관계 주입은 dataSource를 받고 내부에서 SimpleJdbcInsert를 생성해서 가지고 있는 것을 볼 수 있다. withTableName()을 통해 데이터를 저장할 테이블명을 지정하고, usingGeneratedKeyColumns()를 통해 Key를 생성하는 PK 컬럼명을 지정한다. 경우에 따라 usingColumns()로 INSERT SQL 쿼리에 사용할 컬럼을 지정할 수도 있다. 보통 특정 값만 저장하고 싶을 때 사용한다.