Spring + DB

JdbcTemplate

cwchoiit 2023. 12. 6. 10:20
728x90
반응형
SMALL
728x90
SMALL

Spring과 데이터베이스를 연동할 때 사용되는 기술 중 빠지지 않고 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다.

우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제들을 해결해 준다. 예를 들면 트랜잭션을 시작하고 종료하는 코드 작성이나 반복적인 커넥션 후처리와 같은 것들.

 

나는 개인적으로는 JdbcTemplate을 사용하지 않고 Spring Data JPA와 Querydsl을 같이 사용하는 방식을 선호한다. 그러나, 이 JdbcTemplate은 알아둘 만한 가치가 있다고 생각해서 기록하고자 한다.

 

 

라이브러리 다운로드

우선, JdbcTemplate 라이브러리를 받아야 한다. 

 

build.gradle

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

 

build.gradle 파일에서 dependencies 추가하는 부분에 위 한 줄을 넣어주고 빌드를 다시 해주면 라이브러리를 내려받는다.

버전 명시는 따로 할 필요 없다. 스프링의 현재 버전과 가장 호환이 잘 되는 버전을 알아서 선택해서 내려받아준다.

 

 

인터페이스 구현

이제 JdbcTemplate을 이용해서 DB와의 커뮤니케이션을 위한 인터페이스를 만들어야 한다.

이렇게 인터페이스와 구현체를 분리해서 추상화하면 추후 DB 접근 기술에 변경이 생겨도 비즈니스 로직에서의 코드 변경을 최소화할 수 있고 유지보수에 유리해진다.

 

ItemRepository.java

package hello.itemservice.repository;

import hello.itemservice.domain.Item;

import java.util.List;
import java.util.Optional;

public interface ItemRepository {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond cond);

}

 

우선 구현할 메소드는 4개이다. save(), update(), findById(), findAll().

여기서 짚고 넘어가야 할 건 update(Long itemId, ItemUpdateDto updateParam), findAll(ItemSearchCond cond) 메소드의 파라미터인 DTO 클래스들이다. 이 DTO 클래스의 위치를 두고 고민을 할 때가 있는데 DTO 클래스는 어떤 패키지에 있어야 할까?

딱 이것만 기억하기로 했다. 저 DTO 클래스의 사용하는 마지막 레벨이 어디인가?

만약, 저 DTO 클래스를 사용하는 마지막 레벨이 리포지토리 레벨이면 리포지토리 패키지에 클래스를 만들면 된다. 그게 아니라 만약 서비스 레벨이면 서비스 패키지에 클래스를 만들면 된다.

 

즉, 의존성 주입에 Circular dependency injection이 일어나지 않으면 된다. 만약 리포지토리에서 사용하는 DTO를 서비스 패키지에 만들어 두었다면 순환 의존성 주입 문제가 발생한다. 왜냐하면 컨트롤러 -> 서비스 -> 리포지토리 레벨로 호출이 되는데 서비스가 리포지토리를 호출하면서 의존 관계가 생기는데 리포지토리는 다시 서비스에게 의존해야 하는 (서비스 레벨에 DTO 클래스가 있으므로) 의존 관계 문제가 생긴다. 

 

그러니까 결국 DTO는 마지막으로 사용하는 레벨이 어디인가를 고려해서 패키지 위치를 결정하면 된다.

 

DTO

ItemUpdateDto.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemUpdateDto {
    private String itemName;
    private Integer price;
    private Integer quantity;

    public ItemUpdateDto() {
    }

    public ItemUpdateDto(String itemName, Integer price, Integer quantity) {
        this.itemName = itemName;
        this.price = price;
        this.quantity = quantity;
    }
}

 

ItemSearchCond.java

package hello.itemservice.repository;

import lombok.Data;

@Data
public class ItemSearchCond {

    private String itemName;
    private Integer maxPrice;

    public ItemSearchCond() {
    }

    public ItemSearchCond(String itemName, Integer maxPrice) {
        this.itemName = itemName;
        this.maxPrice = maxPrice;
    }
}

 

ItemRepository 구현체

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.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.core.simple.SimpleJdbcInsert;
import org.springframework.util.StringUtils;

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


@Slf4j
public class JdbcTemplateItemRepository 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");
    }

    @Override
    public Item save(Item item) {
        BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
        Number key = jdbcInsert.executeAndReturnKey(param);

        item.setId(key.longValue());
        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();
        }
    }

    private RowMapper<Item> itemRowMapper() {
        return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
    }

    @Override
    public List<Item> findAll(ItemSearchCond cond) {
        String itemName = cond.getItemName();
        Integer maxPrice = cond.getMaxPrice();

        BeanPropertySqlParameterSource 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 final NamedParameterJdbcTemplate template;
private final SimpleJdbcInsert jdbcInsert;

 

NamedParameterJdbcTemplate 클래스는 JdbcTemplate을 사용하지만, 파라미터를 순서에 맞게 작성해야하는 불편함을 해결하기 위해 이름에 따른 파라미터 전달을 가능하게 해주는 NamedParameterJdbcTemplate를 사용했다.

 

SimpleJdbcInsert는 INSERT 쿼리를 좀 더 간단하게 사용할 수 있게 도와주는 클래스라고 보면 된다. 그러니까 JdbcTemplate을 사용할 때 INSERT 쿼리 작성을 하지 않아도 되고, PK를 auto generated key로 설정한 경우 생성한 새로운 레코드의 키를 KeyHolder에 담고 돌려주고 하는 번거로운 작업을 대신해준다.

 

public JdbcTemplateItemRepositoryV3(DataSource dataSource) {
    this.template = new NamedParameterJdbcTemplate(dataSource);
    this.jdbcInsert = new SimpleJdbcInsert(dataSource)
            .withTableName("item")
            .usingGeneratedKeyColumns("id");
}

 

생성자 부분을 보자. 우선 DataSource를 파라미터로 받아 NamedParameterJdbcTemplate()과 SimpleJdbcInsert()에 각각 넣어준다. SimpleJdbcInsert는 어떤 테이블을 사용하는지 알려주기 위해 withTableName()에 "item"이라는 테이블을 넣어주었고, usingGeneratedKeyColums()에는 "id"를 넣어주었다. 이건 기본키 자동 생성 옵션으로 테이블을 만들었다면 그 키 이름을 알려주어야 하기 때문에 작성했다.

 

@Override
public Item save(Item item) {
    BeanPropertySqlParameterSource param = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(param);

    item.setId(key.longValue());
    return item;
}

 

save(Item item) 메소드를 확인해 보자. 이 메소드는 새로운 Item 레코드 하나를 추가할 때 사용된다. 여기서 위에서 말한 SimpleJdbcInsert가 사용될 거고 전달해 주는 파라미터는 Item을 생성할 때 필요한 파라미터 (itemName, price, quantity)가 전달된다. 근데 그때 사용되는 파라미터는 BeanPropertySqlParameterSource라는 클래스인데 이 클래스에 item 객체를 넘기면 이 item 객체가 가지고 있는 필드값을 그대로 파라미터에 필드 이름을 기준으로 알아서 넣어준다. 즉, 사실 저 두 줄은 다음과 같다.

String sql = "INSERT INTO item(item_name, price, quantity) values (:itemName, :price, :quantity)";

 

INSERT SQL문에 :itemName, :price, :quantity에 각각 필드값이 들어간다.

그리고 SimpleJdbcInsert.executeAndReturnKey() 메소드의 반환값은 INSERT문으로 생성된 새로운 레코드의 키를 반환한다.

 

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

 

update() 메소드를 보자. UPDATE SQL문을 작성하고 그 작성한 SQL에 필요한 파라미터를 SqlParameterSource를 이용해서 넘겨준다. 보기만 해도 딱 간단하고 명료하다. addValue()로 key, value 값을 차례대로 넣어주면 된다.

 

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

 

이번엔 findById() 메소드이다. 여기서도 마찬가지로 SQL문을 작성해 주고 그 SQL에 필요한 파라미터를 넘겨주면 되는데, 여기서는 다른 방법을 사용해 봤다. 물론 위에서 사용한 SqlParameterSource를 사용해도 된다. 

딱 한 개의 파라미터가 필요하니까 HashMap을 만들 필요 없이 바로 Map.of("id", id)로 파라미터를 만들어주고 넘겨주면 된다.

 

이때, itemRowMapper()를 호출하는데 이는 SELECT SQL문을 날려서 반환되는 레코드를 Item 객체로 변환해 주는 메서드이다.

private RowMapper<Item> itemRowMapper() {
    return BeanPropertyRowMapper.newInstance(Item.class); // snake_case를 camelCase로 변환해주는 작업도 해줌
}

 

아주 간단하다. BeanPropertyRowMapper로 변환하고자 하는 클래스를 넘겨주면 된다. 이 녀석이 반환된 ResultSet의 값을 이용해 item 객체를 만들어준다. 그리고 이 한 줄의 코드는 아래 코드를 축약했다고 보면 된다.

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

 

@Override
public List<Item> findAll(ItemSearchCond cond) {
    String itemName = cond.getItemName();
    Integer maxPrice = cond.getMaxPrice();

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

 

이제 findAll() 메소드를 보자. 이 부분이 JdbcTemplate의 단점이라고 생각하면 된다. 즉, 동적 쿼리를 만들어내기 쉽지 않다는 것.

우선, BeanPropertySqlParameterSource를 생성해 파라미터 바인딩을 해준다. ItemSearchCond를 넘겼을 때 이 객체가 가지고 있는 필드 이름을 통해 파라미터 바인딩을 해줄 것이다. 

if (StringUtils.hasText(itemName) || maxPrice != null) {
    sql += " WHERE";
}

 

이 부분에서 itemName이나 maxPrice가 있다면 위에 만들어놓은 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";
}

 

이 부분에서 itemName과 maxPrice를 각각 구분하여 WHERE절에 조건을 넣어주는데, 이제 SQL문에 각각 조건을 추가하면 된다. 근데 이게 여간 귀찮은 게 아니다. 문자열마다 공백도 신경 써야 하고, 있는지 없는지 판단해야 하는 조건문이나 AND를 추가하고 말고까지 다 생각해야 하니 이런 부분에서 JdbcTemplate의 단점이 드러난다고 볼 수 있다. 

 

JdbcTemplateConfig

package hello.itemservice.config;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepository;
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 JdbcTemplateV3Config {

    private final DataSource dataSource;

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

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

 

물론, 이 Config 파일을 만들지 않아도 스프링의 도움을 받아서 컴포넌트 스캔으로 할 수 있는데 위에서 만든 모든 클래스가 다 컴포넌트 스캔을 하지 않았기 때문에 직접 빈으로 등록하고 의존관계를 주입해줘야 한다.

 

그러나 DataSource 같은 경우, application.yml 파일에 작성해 놓으면 스프링이 알아서 DataSource를 주입해 준다. 그래서 Lombok의 도움을 받아 @RequiredArgsConstructor를 사용해 편리하게 DataSource를 가져올 수 있다. 

 

SpringBootApplication

package hello.itemservice;

import hello.itemservice.config.*;
import hello.itemservice.repository.ItemRepository;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Profile;


@Import(JdbcTemplateV3Config .class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {

	public static void main(String[] args) {
		SpringApplication.run(ItemServiceApplication.class, args);
	}
}

 

@Import 애노테이션으로 Config 파일을 Import 한 이유는 컴포넌트 스캔의 패키지가 해당 파일을 포함하지 않기 때문이다. 

이렇게 Config 파일을 애플리케이션을 띄울 때, Import 해서 스프링 부트를 띄우면 된다.

 

 

ItemService

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;

import java.util.List;
import java.util.Optional;

public interface ItemService {

    Item save(Item item);

    void update(Long itemId, ItemUpdateDto updateParam);

    Optional<Item> findById(Long id);

    List<Item> findItems(ItemSearchCond itemSearch);
}

 

ItemService 구현체

package hello.itemservice.service;

import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import java.util.List;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class ItemServiceV1 implements ItemService {

    private final ItemRepository itemRepository;

    @Override
    public Item save(Item item) {
        return itemRepository.save(item);
    }

    @Override
    public void update(Long itemId, ItemUpdateDto updateParam) {
        itemRepository.update(itemId, updateParam);
    }

    @Override
    public Optional<Item> findById(Long id) {
        return itemRepository.findById(id);
    }

    @Override
    public List<Item> findItems(ItemSearchCond cond) {
        return itemRepository.findAll(cond);
    }
}

 

구현체는 리포지토리의 위임만 하고 있다. 사실상 이렇게 위임만 하는 경우 서비스가 필요 없을 수 있다 프로젝트에 따라. 그러나 구조를 좀 체계적으로 만들기 위해 서비스까지 작성했다. 이제 실제로 이 코드를 수행해 보자.

 

 

테스트 코드

package hello.itemservice.domain;

import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import hello.itemservice.repository.memory.MemoryItemRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.annotation.Commit;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionStatus;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.DefaultTransactionAttribute;

import java.util.List;

import static org.assertj.core.api.Assertions.assertThat;

@Transactional
@SpringBootTest
class ItemRepositoryTest {

    @Autowired
    ItemRepository itemRepository;

    @Test
    void save() {
        //given
        Item item = new Item("itemA", 10000, 10);

        //when
        Item savedItem = itemRepository.save(item);

        //then
        Item findItem = itemRepository.findById(item.getId()).get();
        assertThat(findItem).isEqualTo(savedItem);
    }

    @Test
    void updateItem() {
        //given
        Item item = new Item("item1", 10000, 10);
        Item savedItem = itemRepository.save(item);
        Long itemId = savedItem.getId();

        //when
        ItemUpdateDto updateParam = new ItemUpdateDto("item2", 20000, 30);
        itemRepository.update(itemId, updateParam);

        //then
        Item findItem = itemRepository.findById(itemId).get();
        assertThat(findItem.getItemName()).isEqualTo(updateParam.getItemName());
        assertThat(findItem.getPrice()).isEqualTo(updateParam.getPrice());
        assertThat(findItem.getQuantity()).isEqualTo(updateParam.getQuantity());
    }

    @Test
    void findItems() {
        //given
        Item item1 = new Item("itemA-1", 10000, 10);
        Item item2 = new Item("itemA-2", 20000, 20);
        Item item3 = new Item("itemB-1", 30000, 30);

        itemRepository.save(item1);
        itemRepository.save(item2);
        itemRepository.save(item3);

        //둘 다 없음 검증
        test(null, null, item1, item2, item3);
        test("", null, item1, item2, item3);

        //itemName 검증
        test("itemA", null, item1, item2);
        test("temA", null, item1, item2);
        test("itemB", null, item3);

        //maxPrice 검증
        test(null, 10000, item1);

        //둘 다 있음 검증
        test("itemA", 10000, item1);
    }

    void test(String itemName, Integer maxPrice, Item... items) {
        List<Item> result = itemRepository.findAll(new ItemSearchCond(itemName, maxPrice));
        assertThat(result).containsExactly(items); // containsExactly는 순서도 다 맞아야한다
    }
}

 

 

728x90
반응형
LIST

'Spring + DB' 카테고리의 다른 글

Index란? (DB)  (0) 2024.04.05
선언적 트랜잭션(@Transactional) 내부 호출 주의  (2) 2023.12.07
MyBatis  (4) 2023.12.06
Transaction, Auto Commit, Rollback, Lock  (0) 2023.11.30
[Spring/Spring Data JPA] @Transactional  (0) 2023.11.12