JDBC(Java Database Connectivity)는 애플리케이션과 데이터베이스 연결 동작을 표준화한 자바 API 인터페이스이다.
일반적으로 JDBC를 통한 SQL 쿼리 실행은 다음 다섯 단계로 이루어진다 :
그런데 순수 JDBC를 통한 데이터베이스 연결은 중복이 너무 많다. 커넥션을 확보하고, 결과가 여러 개일 경우 루프를 통해 결과를 가져오고, 커넥션을 동기화하고, 리소스를 종료하는 과정은 매번 반복된다.
JDBC 템플릿은 이러한 반복되는 코드를 줄여주기 위해 등장했다. 스프링 JDBC 라이브러리(spring-jdbc
)에 포함되어 있어 복잡한 설정 없이 사용 가능하다는 장점도 있다. 반면 동적 쿼리 생성이 어렵기 때문에 이후에 MyBatis 등 SQL Mapper 기술이 등장했다는 한계가 있다.
Item
객체 : ID(id
), 상품명(itemName
), 가격(price
), 수량(quantity
)id
는 identity(auto increment) 방식 - DB에서 자동 생성ItemUpdateDto
): itemName
, price
, quantity
ItemSearchCond
): itemName
, maxPrice
public interface ItemRepository {
Item save(Item item);
void update(Long itemId, ItemUpdateDto updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond cond);
}
save()
: 저장update()
: 수정 (상품 ID값(itemId
)은 ItemUpdateDto
와 분리했음)findById()
: 상품 ID에 맞는 객체 1건 조회findById()
: 검색 조건(ItemSearchCond
)에 맞는 복수 객체를 리스트로 조회@Slf4j
@Repository
public class JdbcTemplateItemRepositoryV1 implements ItemRepository {
private final JdbcTemplate template;
public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
this.template = new JdbcTemplate(dataSource);
}
}
DataSource
가 필요하다.update()
사용 - 영향 받은 로우 수를 int
로 반환queryForObject()
사용query()
사용 - 결과를 리스트로 반환RowMapper
사용@Override
public Item save(Item item) {}
저장한 item
을 반환해야 하는데 id
는 데이터베이스에서 자동 생성하기 때문에 저장이 완료된 이후에야 id
값을 조회할 수 있다.
따라서 INSERT
쿼리 실행 후 생성된 id
값을 가져올 수 있도록 KeyHolder
라는 객체를 사용해야 하는데 사용이 복잡하고, SimpleJdbcInsert
라는 더 편리한 방법으로 가져올 수 있다. 아래에서 설명하겠다.
@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);
}
template.update()
: 데이터를 변경할 때는 update()
를 사용하면 된다.int
인데, 영향 받은 로우 수를 반환한다.?
)는 순서대로 바인딩한다.@Override
public Optional<Item> findById(Long id) {
String sql = "select id, item_name, price, quantity where id=?";
try {
Item item = template.queryForObject(sql, itemRowMapper(), id);
return Optional.of(item); //값이 존재하면 옵셔널로 감싸서 반환, 없으면 NullPointerException 발생
} catch (EmptyResultDataAccessException e) {
return Optional.empty(); //회원이 없으면 빈 옵셔널 반환
}
}
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;
};
}
template.queryForObject()
: 결과 로우가 하나일 때 사용한다.RowMapper
는 데이터베이스의 반환 결과인 ResultSet
을 객체로 변환한다.EmptyResultDataAccessException
예외가 발생한다.IncorrectResultSizeDataAccessException
예외가 발생한다.@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);
}
return template.query(sql, itemRowMapper(),param.toArray());
}
template.query()
: 결과가 하나 이상일 때 사용한다.RowMapper
는 데이터베이스의 반환 결과인 ResultSet
을 객체로 변환한다.findAll()
은 상품명과 가격제한이라는 두 가지 조건에 맞는 결과를 찾아오기 위해 동적 쿼리 코드가 상당히 복잡해졌다. 다음과 같은 네 가지 경우를 고려해야 하기 때문이다 :
파라미터 뿐 아니라 경우의 수에 따라 where
, and
등의 키워드를 넣어야 할 지도 조정해주어야 한다. 실무에서는 훨씬 복잡한 쿼리를 사용하기 때문에 오류가 발생할 확률이 매우 높다.
이렇듯 동적 쿼리 생성이 어려운 것이 JDBC의 치명적 단점이다.
@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);
}
지금까지와 같이 JdbcTemplate
을 사용하면 파라미터 바인딩을 할 때 순서대로 지정한다. 이러한 방식은 데이터가 잘못 입력될 여지가 많다. 실무에서는 파라미터가 10개 이상 되는 경우도 많고 도중에 컬럼이 추가되거나 하면 충분히 문제가 생길 수 있다. 같은 타입의 컬럼끼리는 더욱 그렇다.
버그 중에서 가장 복구가 힘든 버그는 데이터베이스에 데이터가 잘못 입력되는 것이다. 코드를 줄이는 것도 중요하지만 유지보수를 위해 코드의 모호함을 제거하는 것이 더욱 중요하다.
String sql = "insert into item (item_name, price, quantity) " +
"values (:itemName, :price, :quantity)";
위와 같이 쿼리의 파라미터 부분에 이름을 지정하여 이름으로 바인딩을 할 수 있다. 순서에 따라 바인딩을 하는 것보다 좋은 방법이다.
파라미터를 전달하려면 Map
과 같은 키-값 구조로 데이터를 전달해야 한다. 이름 지정 바인딩에서 파라미터 전달에 사용되는 객체는 크게 세가지가 있다 :
1. Map
2. SqlParameterSource
- MapSqlParameterSource
3. SqlParameterSource
- BeanPropertySqlParameterSource
@Slf4j
@Repository
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {
private final NamedParameterJdbcTemplate template;
public JdbcTemplateItemRepositoryV2(DataSource dataSource) {
this.template = new NamedParameterJdbcTemplate(dataSource);
}
}
파라미터 바인딩을 이름으로 할 수 있도록 JdbcTemplate
대신 NamedParameterJdbcTemplate
을 사용한다.
@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();
}
}
쿼리에 사용된 id
플레이스홀더 이름을 id
로 하고 파라미터를 Map
으로 전달했다. 나머지는 이전과 같다.
@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);
}
MapSqlParameterSource
는 SqlParameterSource
인터페이스의 구현체로, Map
과 비슷하나 타입 지정 등 SQL에 특화되었다. 메서드 체인도 사용 가능하다.
플레이스홀더의 이름을 itemName
, price
, quantity
로 지정하고 이름을 사용하여 addValue
로 파라미터 바인딩을 해주었다.
@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());
}
BeanPropertySqlParameterSource
는 SqlParameterSource
인터페이스의 구현체로, 자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. 예를 들어 getItemName()
, getPrice()
가 있으면 다음과 같은 키-값 쌍을 만들어낸다.
key=itemName, value=값
, key=price, value=값
BeanPropertySqlParameterSource
는 값을 자동으로 바인딩해 주기 때문에 편리하지만 항상 사용할 수 있는 것은 아니다. 예를 들어 update()
에서는 쿼리에 :id
를 바인딩해야 하는데 ItemUpdateDto
에는 id
가 없다.
데이터베이스의 반환 결과인 ResultSet
을 객체로 변환하는 RowMapper
도 BeanPropertyRowMapper
를 사용하면 자동화할 수 있다.
private RowMapper<Item> itemRowMapper() {
return BeanPropertyRowMapper.newInstance(Item.class);
}
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;
};
}
자바 객체는 관례적으로 카멜 표기법(camelCase)을 사용하고 관계형 데이터베이스는 언더스코어(_)로 연결하는 스네이크 표기법(snake_case)을 사용한다. BeanPropertyRowMapper
는 이러한 차이를 보정해주기 때문에 select item_name
로 조회해도 결과값을 객체의 itemName
필드로 잘 바인딩해준다.
만약 객체 필드명과 데이터베이스의 컬럼명이 완전히 다른 경우에는 별칭을 사용하면 된다. select name as itemName
처럼 별칭을 지정하는 as
키워드를 사용하면 된다.
name
으로 조회하고 바인딩 대상 필드는 itemName
이다.@Override
public Item save(Item item) {}
작성한 인터페이스에 따르면 저장 쿼리를 실행한 후 저장한 item
을 반환해야 한다. 그러나 item
객체의 id
는 애플리케이션이 아닌 데이터베이스에서 자동 생성하기 때문에 저장이 완료된 이후에야 id
값을 조회할 수 있다는 문제가 있다.
@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;
}
따라서 위와 같이 KeyHolder
를 통해 id
값을 받아올 keyHolder
를 생성해야 한다. 쿼리가 실행되고 id
값이 반환되면 이 값을 다시 item
에 설정해준다. 이제 메서드는 완성된 item
을 반환한다.
SimpleJdbcInsert
를 사용하면 insert
쿼리를 직접 작성하지 않아도 되고 KeyHolder
를 사용하지 않고도 자동 생성 PK 값(ID)도 간편하게 가져올 수 있다.
@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) {
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
Number key = jdbcInsert.executeAndReturnKey(param); //간단하게 실행
item.setId(key.longValue());
return item;
}
}
withTableName
: 데이터를 저장할 테이블 명을 지정한다.usingGeneratedKeyColumns
: key를 생성하는 PK 컬럼 명을 지정한다.usingColumns
: INSERT SQL에 사용할 컬럼을 지정한다. 특정 값만 저장하고 싶을 때 사용한다. 생략할 수 있다.DEBUG 39424 --- [ main] o.s.jdbc.core.simple.SimpleJdbcInsert :
Compiled insert object: insert string is [INSERT INTO item (ITEM_NAME, PRICE,
QUANTITY) VALUES(?, ?, ?)]
```
JDBC Template은 순수 JDBC를 썼을 때의 반복되는 코드를 상당 부분 해결해준다. JDBC 기본 라이브러리에 포함되어있기 때문에 설정과 사용이 편리하다는 장점이 있다. JPA에서 직접 쿼리를 작성해야 할 때 함께 사용되기도 한다.
그러나 JDBC는 동적 쿼리 생성이 어렵기 때문에 이후에 등장한 MyBatis 등 SQL Mapper 기술이나 Querydsl 등 동적 쿼리 생성을 도와주는 다른 기술을 사용하는 것이 좋다.