[JDBC] JDBC Template 이해

dondonee·2023년 12월 19일
0
post-thumbnail

JDBC Template 이해

JDBC Template


JDBC

JDBC(Java Database Connectivity)는 애플리케이션과 데이터베이스 연결 동작을 표준화한 자바 API 인터페이스이다.

일반적으로 JDBC를 통한 SQL 쿼리 실행은 다음 다섯 단계로 이루어진다 :

  1. Conncection 생성 (DB 연결 확보)
  2. Statement 생성 (쿼리문 준비)
  3. Statement 실행 (쿼리 실행)
  4. ResultSet 처리 (결과 처리)
  5. Conncection 종료 (DB 연결 종료)

그런데 순수 JDBC를 통한 데이터베이스 연결은 중복이 너무 많다. 커넥션을 확보하고, 결과가 여러 개일 경우 루프를 통해 결과를 가져오고, 커넥션을 동기화하고, 리소스를 종료하는 과정은 매번 반복된다.

JDBC Template

JDBC 템플릿은 이러한 반복되는 코드를 줄여주기 위해 등장했다. 스프링 JDBC 라이브러리(spring-jdbc)에 포함되어 있어 복잡한 설정 없이 사용 가능하다는 장점도 있다. 반면 동적 쿼리 생성이 어렵기 때문에 이후에 MyBatis 등 SQL Mapper 기술이 등장했다는 한계가 있다.



예제 소개


도메인 및 기능

  • Item 객체 : ID(id), 상품명(itemName), 가격(price), 수량(quantity)
    • id는 identity(auto increment) 방식 - DB에서 자동 생성
  • 상품 등록, 상품 수정 기능
    • 상품 수정 DTO(ItemUpdateDto): itemName, price, quantity
  • 상품 검색 기능: 상품명 및 가격제한 조건으로 검색 가능
    • 상품 조건 DTO(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);
 }
}
  • JDBC 템플릿은 DataSource가 필요하다.
  • 데이터를 변경할 때는 update() 사용 - 영향 받은 로우 수를 int로 반환
  • 하나의 로우를 조회할 때는 queryForObject() 사용
  • 여러 로우를 조회할 때는 query() 사용 - 결과를 리스트로 반환
    • 로우를 객체로 받아와야 한다면 RowMapper 사용

save()

@Override
public Item save(Item item) {}

저장한 item을 반환해야 하는데 id는 데이터베이스에서 자동 생성하기 때문에 저장이 완료된 이후에야 id 값을 조회할 수 있다.

따라서 INSERT 쿼리 실행 후 생성된 id 값을 가져올 수 있도록 KeyHolder라는 객체를 사용해야 하는데 사용이 복잡하고, SimpleJdbcInsert라는 더 편리한 방법으로 가져올 수 있다. 아래에서 설명하겠다.


update()

@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()를 사용하면 된다.
    • INSERT , UPDATE , DELETE 쿼리에 사용한다.
    • 반환 값은 int인데, 영향 받은 로우 수를 반환한다.
    • 파라미터(?)는 순서대로 바인딩한다.

findById()

@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 예외가 발생한다.

findAll()

@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()은 상품명과 가격제한이라는 두 가지 조건에 맞는 결과를 찾아오기 위해 동적 쿼리 코드가 상당히 복잡해졌다. 다음과 같은 네 가지 경우를 고려해야 하기 때문이다 :

  1. 검색조건 없음
  2. 상품명 검색
  3. 가격제한 검색
  4. 상품명 & 가격제한 검색

파라미터 뿐 아니라 경우의 수에 따라 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을 사용한다.


(1) Map - findById()

@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으로 전달했다. 나머지는 이전과 같다.


(2) MapSqlParameterSource - update()

@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);
}

MapSqlParameterSourceSqlParameterSource 인터페이스의 구현체로, Map과 비슷하나 타입 지정 등 SQL에 특화되었다. 메서드 체인도 사용 가능하다.

플레이스홀더의 이름을 itemName, price, quantity로 지정하고 이름을 사용하여 addValue로 파라미터 바인딩을 해주었다.


(3) BeanPropertySqlParameterSource - findAll()

@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());
}

BeanPropertySqlParameterSourceSqlParameterSource 인터페이스의 구현체로, 자바빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다. 예를 들어 getItemName(), getPrice()가 있으면 다음과 같은 키-값 쌍을 만들어낸다.

  • key=itemName, value=값, key=price, value=값

BeanPropertySqlParameterSource는 값을 자동으로 바인딩해 주기 때문에 편리하지만 항상 사용할 수 있는 것은 아니다. 예를 들어 update()에서는 쿼리에 :id를 바인딩해야 하는데 ItemUpdateDto에는 id가 없다.


RowMapper

데이터베이스의 반환 결과인 ResultSet을 객체로 변환하는 RowMapperBeanPropertyRowMapper를 사용하면 자동화할 수 있다.

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이다.


자동 생성 PK 값 가져오기

@Override
public Item save(Item item) {}

작성한 인터페이스에 따르면 저장 쿼리를 실행한 후 저장한 item을 반환해야 한다. 그러나 item 객체의 id는 애플리케이션이 아닌 데이터베이스에서 자동 생성하기 때문에 저장이 완료된 이후에야 id 값을 조회할 수 있다는 문제가 있다.

save() - KeyHolder 사용

@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을 반환한다.


save() - JdbcInsert 사용

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 등 동적 쿼리 생성을 도와주는 다른 기술을 사용하는 것이 좋다.


🔗 Reference

0개의 댓글