스프링 DB 2편(데이터 접근 활용 기술)

Ch02. 스프링 JdbcTemplate - JdbcTemplate(이름 지정 파라미터)

webmaster 2022. 6. 25. 15:04
728x90

누군가 SQL 코드의 순서를 변경하게 된다면 어떻게 될까 => ( price와 quantity의 순서를 변경했다.)

Before

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

After

String sql = "update item set item_name=?, quantity=?, price=? where id=?";
template.update(sql,
 itemName,
 price,
 quantity,
 itemId);
  • 결과적으로 price 와 quantity 가 바뀌는 매우 심각한 문제가 발생한다. 이런 일이 없을 것 같지만, 실무에서는 파라미터가 10~20개가 넘어가는 일도 아주 많다. 그래서 미래에 필드를 추가하거나, 수정하면서 이런 문제가 충분히 발생할 수 있다.
  • 버그 중에서 가장 고치기 힘든 버그는 데이터베이스에 데이터가 잘못 들어가는 버그다. 이것은 코드만 고치는 수준이 아니라 데이터베이스의 데이터를 복구해야 하기 때문에 버그를 해결하는데 들어가는 리소스가 어마어마하다.
  • 개발을 할 때는 코드를 몇줄 줄이는 편리함도 중요하지만, 모호함을 제거해서 코드를 명확하게 만드는 것이 유지보수 관점에서 매우 중요하다.
  • 이처럼 파라미터를 순서대로 바인딩하는 것은 편리하기는 하지만, 순서가 맞지 않아서 버그가 발생할 수도 있으므로 주의해서 사용해야 한다

이름 지정 바인딩

전체 코드

/**
 * NamedParameterJdbcTemplate
 * SqlParameterSource
 * - BeanPropertySqlParameterSource
 * - MapSqlParameterSource
 * Map
 *
 * BeanPropertyRowMapper
 *
 */
@Slf4j
public class JdbcTemplateItemRepositoryV2 implements ItemRepository {

    //private final JdbcTemplate template;
    private final NamedParameterJdbcTemplate template; //이름기반 JDBCTemplate

    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)";

        //item 이름으로 파라미터 생성
        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); //cammel 변환 지원
    }
}
  • JdbcTemplateItemRepositoryV2 는 ItemRepository 인터페이스를 구현했다.
  • this.template = new NamedParameterJdbcTemplate(dataSource)
    • NamedParameterJdbcTemplate 도 내부에 dataSource 가 필요하다.
    • JdbcTemplateItemRepositoryV2 생성자를 보면 의존관계주입은 dataSource를 받고 내부에서 NamedParameterJdbcTemplate을 생성해서 가지고 있다. 스프링에서는 JdbcTemplate 관련 기능을 사용할 때 관례상 이 방법을 많이 사용한다.
    • 물론 NamedParameterJdbcTemplate 을 스프링 빈으로 직접 등록하고 주입받아도 된다

Save()

@Override
public Item save(Item item) {
    String sql = "insert into item(item_name, price, quantity) "
        + "values (:itemName, :price, :quantity)";

    //item 이름으로 파라미터 생성
    SqlParameterSource param = new BeanPropertySqlParameterSource(item);

    KeyHolder keyHolder = new GeneratedKeyHolder();
    template.update(sql, param, keyHolder);

    long key = keyHolder.getKey().longValue();
    item.setId(key);
    return item;
}
  • SQL에서 다음과 같이 ? 대신에 :파라미터 이름을 받는 것을 확인할 수 있다.
  • 추가로 NamedParameterJdbcTemplate은 데이터베이스가 생성해주는 키를 매우 쉽게 조회하는 기능도 제공해준다

이름 지정 파라미터 종류

파라미터를 전달하려면 Map 처럼 key , value 데이터 구조를 만들어서 전달해야 한다. 여기서 key는 :파 리히터 이름으로 지정한, 파라미터의 이름이고 , value는 해당 파라미터의 값이 된다.

크게 3가지 종류가 있다

  • Map
  • SqlParameterSource
    • MapSqlParameterSource
    • BeanPropertySqlParameterSource

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();
    }
}
  • 단순히 Map을 전달해 주면 된다.

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);
}
  • Map 과 유사한데, SQL 타입을 지정할 수 있는 등 SQL에 좀 더 특화된 기능을 제공한다.
  • SqlParameterSource 인터페이스의 구현체이다.
  • MapSqlParameterSource는 메서드 체인을 통해 편리한 사용법도 제공한다.

BeanPropertySqlParameterSource

save(), findAll() 코드에서 확인할 수 있다.

@Override
public Item save(Item item) {
    String sql = "insert into item(item_name, price, quantity) "
        + "values (:itemName, :price, :quantity)";

    //item 이름으로 파라미터 생성
    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 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());
}
  • JAVA 빈 프로퍼티 규약을 통해서 자동으로 파라미터 객체를 생성한다.
    • 예) ( getXxx() -> xxx, getItemName() -> itemName )
    • 예를 들어서 getItemName() , getPrice()가 있으면 다음과 같은 데이터를 자동으로 만들어낸다
  • SqlParameterSource 인터페이스의 구현체이다.
  • 여기서 보면 BeanPropertySqlParameterSource 가 많은 것을 자동화해주기 때문에 가장 좋아 보이지만, BeanPropertySqlParameterSource를 항상 사용할 수 있는 것은 아니다.
  • 예를 들어서 update() 에서는 SQL에 :id를 바인딩해야 하는데, update()에서 사용하는 ItemUpdateDto 에는 itemId 가 없다. 따라서 BeanPropertySqlParameterSource를 사용할 수 없고, 대신에 MapSqlParameterSource를 사용했다

BeanPropertyRowMapper

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); //cammel 변환 지원
}
  • BeanPropertyRowMapper 는 ResultSet의 결과를 받아서 JAVA 빈 규약에 맞추어 데이터를 변환한다.

예를 들어서 데이터베이스에서 조회한 결과가 select id, price 라고 하면 다음과 같은 코드를 작성해준다. (실제로는 리플렉션 같은 기능을 사용한다.)

Item item = new Item();
item.setId(rs.getLong("id"));
item.setPrice(rs.getInt("price"));
  • 데이터베이스에서 조회한 결과 이름을 기반으로 setId() , setPrice() 처럼 JAVA 빈 프로퍼티 규약에 맞춘 메서드를 호출하는 것이다.

별칭

그런데 select item_name 의 경우 setItem_name()이라는 메서드가 없기 때문에 골치가 아프다. 이런 경우 개발자가 조회 SQL을 다음과 같이 고치면 된다.

select item_name as itemName

별칭 as 를 사용해서 SQL 조회 결과의 이름을 변경하는 것이다. 실제로 이 방법은 자주 사용된다. 특히 데이터베이스 컬럼 이름과 객체 이름이 완전히 다를 때 문제를 해결할 수 있다. 예를 들어서 데이터베이스에는 member_name이라고 되어 있는데 객체에 username이라고 되어 있다면 다음과 같이 해결할 수 있다.

select member_name as username

이렇게 데이터베이스 컬럼 이름과 객체의 이름이 다를 때 별칭( as )을 사용해서 문제를 많이 해결한다. JdbcTemplate 은 물론이고, MyBatis 같은 기술에서도 자주 사용된다.

관례의 불일치

JAVA 객체는 카멜( camelCase ) 표기법을 사용한다. itemName처럼 중간에 낙타 봉이 올라와 있는 표기법이다.

반면에 관계형 데이터베이스에서는 주로 언더스코어를 사용하는 snake_case 표기법을 사용한다. item_name처럼 중간에 언더스코어를 사용하는 표기법이다.

이 부분을 관례로 많이 사용하다 보니 BeanPropertyRowMapper 는 언더스코어 표기법을 카멜로 자동 변환해준다.

따라서 select item_name으로 조회해도 setItemName()에 문제없이 값이 들어간다.

정리하면 snake_case 는 자동으로 해결되니 그냥 두면 되고, 컬럼 이름과 객체 이름이 완전히 다른 경우에는 조회 SQL에서 별칭을 사용하면 된다.

구성과 실행

JdbcTemplateV2Config

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

}
  • 앞서 개발한 JdbcTemplateItemRepositoryV2를 사용하도록 스프링 빈에 등록한다.

ItemServiceApplication

//@Import(MemoryConfig.class)
//@Import(JdbcTemplateV1Config.class)
@Import(JdbcTemplateV2Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
	...
}
  • JdbcTemplateV2Config.class를 설정으로 사용하도록 변경되었다.
    • @Import(JdbcTemplateV1Config.class) @Import(JdbcTemplateV2Config.class)
728x90