DB - JdbcTemplate 이름 지정 파라미터

박민수·2023년 11월 15일
post-thumbnail

JdbcTemplate 이름 지정 파라미터

JdbcTemplate은 기본적으로 파라미터를 순서대로 바인딩 한다. 예를들어 아래 예제 코드에서는 itemName, price, quantity가 SQL에 있는 ?에 순서대로 바인딩 된다. 따라서 순서만 잘 지키면 문제가 될 것은 없다. 그런데 문제는 변경시점에 발생한다.

String sql = "update item set item_name=?, price=?, quantity=? where id=?";
template.update(sql,
    itemName,
    price,
    quantity,
    itemId);

누군가 다음과 같이 SQL 코드의 순서를 변경했다고 가정해보자. (price 와 quantity 의 순서를 변경했다.) 이렇게 되면 다음과 같은 순서로 데이터가 바인딩 된다. item_name=itemName, quantity=price, price=quantity

String sql = "update item set item_name=?, quantity=?, price=? where id=?";
template.update(sql,
    itemName,
    price,
    quantity,
    itemId);

결과적으로 price 와 quantity 가 바뀌는 매우 심각한 문제가 발생한다. 이럴일이 없을 것 같지만, 실무에서는 파라미터가 10~20개가 넘어가는 일도 아주 많다. 그래서 미래에 필드를 추가하거나, 수정하면서 이런 문제가 충분히 발생할 수 있다.버그 중에서 가장 고치기 힘든 버그는 데이터베이스에 데이터가 잘못 들어가는 버그다. 이것은 코드만 고치는 수준이 아니라 데이터베이스의 데이터를 복구해야 하기 때문에 버그를 해결하는데 들어가는 리소스가 어마어마하다.

실제로 수많은 개발자들이 이 문제로 장애를 내고 퇴근하지 못하는 일이 발생한다. 개발을 할 때는 코드를 몇줄 줄이는 편리함도 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다.이처럼 파라미터를 순서대로 바인딩 하는 것은 편리하기는 하지만, 순서가 맞지 않아서 버그가 발생할 수도 있으므로 주의해서 사용해야 한다.

NamedParameterJdbcTemplate

JdbcTemplate은 이런 문제를 보완하기 위해 NamedParameterJdbcTemplate라는 이름을 지정해서 파라미터를 바인딩 하는 기능을 제공한다.

JdbcTemplateItemRepositoryV2

save() 메서드의 쿼리에서 다음과 같이 ? 대신에 :파라미터이름 을 받는 것을 확인할 수 있다.

@Slf4j
@Repository
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) {
        Integer maxPrice = cond.getMaxPrice();
        String itemName = cond.getItemName();
        
        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 변환 지원
    }
}

추가로 NamedParameterJdbcTemplate은 데이터베이스가 생성해주는 키를 매우 쉽게 조회하는 기능도 제공해준다.

insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)

파라미터 전달 방법

파라미터를 전달하려면 Map 처럼 Key, Value 데이터 구조를 만들어서 전달해야 한다. 여기서 key는 :파라미터이름 으로 지정한 파라미터의 이름이고, value는 해당 파라미터의 값이 된다. 이름 지정 바인딩에서 자주 사용하는 파라미터의 종류는 크게 3가지가 있다.

  1. Map
  2. SqlParameterSource - MapSqlParameterSource
  3. SqlParameterSource - BeanPropertySqlParameterSource

Map

단순히 Map을 사용한다.

Map<String, Object> param = map.of("id", id);
Item item = template.queryForObject(sql, param, itemRowMapper());

MapSqlParameterSource - MapSqlParameterSource

Map과 유사한데 SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공한다. MapSqlParameterSource는 SqlParameterSource 인터페이스의 구현체이다. MapSqlParameterSource는 메서드 체인을 이용한 편리한 사용법도 제공한다.

SqlParameterSource param = new MapSqlParameterSource()
    .addValue("itemName", updateParam.getItemName())
    .addValue("price", updateParam.getPrice())
    .addValue("quantity", updateParam.getQuantity())
    .addValue("id", itemId); //이 부분이 별도로 필요하다.
template.update(sql, param);

SqlParameterSource - BeanPropertySqlParameterSource

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

key=itemName, value=상품명 값
key=price, value=가격 값
SqlParameterSource param = new BeanPropertySqlParameterSource(item);
KeyHolder keyHolder = new GeneratedKeyHolder();
template.update(sql, param, keyHolder);

BeanPropertySqlParameterSource가 많은 것을 자동화 해주기 때문에 가장 좋아보이지만, 항상 사용할 수 있는 것은 아니다. 예를 들어 update() 에서는 SQL에 :id 를 바인딩 해야 하는데, update()에서 사용하는 ItemUpdateDto에는 itemId가 없기 때문에 BeanPropertySqlParameterSource를 사용할 수 없다.

BeanPropertyRowMapper

지난 포스팅의 JdbcTemplateItemRepositoryV1과 비교했을 때, 변화된 부분이 한가지 더 있다. 바로 BeanPropertyRowMapper를 사용한 것 이다.

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); //camel 변환 지원
}

BeanPropertyRowMapper는 ResultSet의 결과를 받아서 자바빈 규약에 맞추어 데이터를 변환한다. 예를 들어 데이터베이스에서 조회한 결과가 select id, price 라고 하면 다음과 같은 코드를 작성해준다. (실제로는 내부에서 리플렉션 같은 기능을 사용한다.)

Item item = new Item();
item.setId(rs.getLong("id"));
item.setPrice(rs.getInt("price"));

데이터베이스에서 조회한 결과의 이름을 기반으로 setId() , setPrice()처럼 자바빈 프로퍼티 규약에 맞춘 메서드를 호출하는 것이다.

별칭

그런데 select item_name의 경우 setItem_name()이라는 메서드가 없기 때문에 이런 경우 개발자가 직접 별칭 as를 사용해서 조회 SQL을 다음과 같이 고쳐야 한다.

select item_name as itemName

관례의 불일치

자바 객체는 카멜( camelCase ) 표기법을 사용한다. itemName 처럼 중간에 낙타 봉이 올라와 있는 표기법이다. 반면에 관계형 데이터베이스에서는 주로 언더스코어를 사용하는 snake_case 표기법을 사용한다. item_name 처럼 중간에 언더스코어를 사용하는 표기법이다. 이 부분을 관례로 많이 사용하다 보니 BeanPropertyRowMapper는 언더스코어 표기법을 카멜로 자동 변환해준다. 따라서 select item_name으로 조회해도 setItemName()에 문제 없이 값이 들어간다.

구성 및 실행

이름 지정 파라미터를 사용하도록 구성하고 실행해보자.

JdbcTemplateV2Config

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
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);
    }
}

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

profile
안녕하세요 백엔드 개발자입니다.

0개의 댓글