BackEnd/Spring DB

데이터 접근 기술- 스프링 JdbcTemplate

hanseom 2023. 2. 1. 06:56
반응형

JdbcTemplate 장단점

장점

  • spring-jdbc 라이브러리(스프링으로 jdbc를 사용할 때 기본으로 사용되는 라이브러리)에 포함되어 있어 별도의 복잡한 설정 없이 사용할 수 있는 설정의 편리함을 제공합니다.
  •  템플릿 콜백 패턴을 사용해서 JDBC를 직접 사용할 때 발생하는 대부분의 반복 작업(커넥션 획득, statement를 준비하고 실행, 결과를 반복하도록 루프 실행, 트랜잭션 다루기 위한 커넥션 동기화, 예외 발생 시 스프링 예외 변환기 실행)을 대신 처리해줍니다.

 

단점

  • 동적 SQL을 다루기가 어렵습니다.

 

JdbcTemplate 주요 기능

  • JdbcTemplate: 순서 기반 파라미터 바인딩을 지원합니다.
  • NamedParameterJdbcTemplate: 이름 기반 파라미터 바인딩을 지원합니다. (권장)
  • SimpleJdbcInsert: INSERT SQL을 편리하게 사용할 수 있습니다.
  • SimpleJdbcCall: 스토어드 프로시저를 편리하게 호출할 수 있습니다.

 

JdbcTemplate 적용

의존성 추가(build.gradle)

//JdbcTemplate 추가
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

 

로그 추가

  JdbcTemplate이 실행하는 SQL 로그를 확인하려면 application.properties에 다음을 추가하면 됩니다.

#jdbcTemplate sql log
    logging.level.org.springframework.jdbc=debug

 

  JdbcTemplate를 사용해서 메모리에 저장하던 데이터를 H2 데이터베이스에 저장합니다. ItemRepository 인터페이스를 기반으로 JdbcTemplate을 사용하는 새로운 구현체를 개발합니다. 스프링 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 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;

import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

/**
 * JdbcTemplate
 */
@Slf4j
public class JdbcTemplateItemRepositoryV1 implements ItemRepository {

    private final JdbcTemplate template;

    public JdbcTemplateItemRepositoryV1(DataSource dataSource) {
        this.template = new JdbcTemplate(dataSource);
    }

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

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

    @Override
    public Optional<Item> findById(Long id) {
        String sql = "select id, item_name, price, quantity from item where id = ?";
        try {
            Item item = template.queryForObject(sql, itemRowMapper(), id);
            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 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);
        }

        log.info("sql={}", sql);
        return template.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;
        });
    }
}
  • this.template = new JdbcTemplate(dataSource): JdbcTemplate은 데이터소스가 필요합니다. 생성자를 보면 dataSource를 의존 관계 주입 받고 생성자 내부에서 JdbcTemplate를 생성합니다. 스프링에서는 JdbcTemplate를 사용할 때 관례상 이 방법을 많이 사용하지만, JdbcTemplate을 스프링 빈으로 직접 등록하고 주입받아도 됩니다.
  • template.update(): 데이터를 변경할 때는 update()를 사용합니다. INSERT, UPDATE, DELETE SQL에 사용하며, 반환 값은 int로 영향 받은 로우 수입니다.
  • KeyHolder와 connection.prepareStatement(sql, new String[]{"id"})를 사용해서 id를 지정해주면 INSERT 쿼리 실행 이후에 데이터베이스에서 생성된 ID 값을 조회할 수 있습니다.
  • template.queryForObject(): 결과 로우가 하나일 때 사용합니다. RowMapper는 데이터베이스의 반환 결과인 ResultSet을 객체로 변환합니다. 결과가 없으면 EmptyResultDataAccessException 예외를, 결과가 둘 이상이면 IncorrectResultSizeDataAccessException 예외가 발생합니다.
  • template.query(): 결과가 하나 이상일 때 사용합니다. 결과가 없으면 빈 컬렉션을 반환합니다.

 

  신규 구현체로 적용하기 위해 신규 Config 파일을 생성하고, ItemServiceApplication에 설정된 Config 클래스를 변경합니다. 이후 포스팅부터 해당 내용은 skip 하겠습니다.

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV1;
import hello.itemservice.repository.memory.MemoryItemRepository;
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 JdbcTemplateV1Config {

    private final DataSource dataSource;

    @Bean
    public ItemService itemService() {
        return new ItemServiceV1(itemRepository());
    }

    @Bean
    public ItemRepository itemRepository() {
        return new JdbcTemplateItemRepositoryV1(dataSource);
    }

}
//@Import(MemoryConfig.class)
@Import(JdbcTemplateV1Config.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {...}

 

NamedParameterJdbcTemplate

  JdbcTemplate을 기본으로 사용하면 파라미터를 순서대로 바인딩 합니다. 아래 코드에서 itemName, price, quantity는 SQL에 있는 ? 순서대로 바인딩 됩니다. 파라미터를 순서대로 바인딩 하는 것은 편리하지만, 순서가 맞지 않아 버그가 발생할 수도 있으니 주의해서 사용해야 합니다.

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

 

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

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 lombok.extern.slf4j.Slf4j;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.BeanPropertySqlParameterSource;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.util.StringUtils;

import javax.sql.DataSource;
import java.sql.PreparedStatement;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
 * NamedParameterJdbcTemplate
 * SqlParameterSource
 * - BeanPropertySqlParameterSource
 * - MapSqlParameterSource
 * Map
 *
 * BeanPropertyRowMapper
 *
 */
@Slf4j
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) {
        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); //camel 변환 지원
    }
}
  • SQL에서 ? 대신 :파라미터이름을 사용합니다.
  • 파라미터를 전달하려면 Map처럼 Key, Value 데이터 구조를 만들어서 전달해야 합니다.
  • 이름 지정 바인딩에서 자주 사용하는 파라미터의 종류는 크게 3가지(Map, MapSqlParameterSource, BeanPropertySqlParameterSource)가 있습니다.

 

Map

  java.util.Map입니다.

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

 

MapSqlParameterSource

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

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

 

BeanPropertySqlParameterSource

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

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

 

BeanPropertyRowMapper

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

    // [AS-IS]
    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;
        });
    }

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

 

  SimpleJdbcInsertSimpleJdbcCall에 대한 사용 방법은 스프링 공식 메뉴얼을, 해당 프로젝트에 SimpleJdbcInsert 적용 코드는 github repository를 참고하시면 됩니다.

 

[참고 정보]

스프링 DB 2편 - 데이터 접근 핵심 원리

전체 소스코드

반응형