이번에는 MyBatis라는 기술을 이용해서 Spring에서 Database와 통신하는 법을 알아보자.
이 역시 나는 사용하지 않을것 같다. 왜냐하면 이 MyBatis를 사용하기엔 Querydsl과 Spring Data JPA가 너무 강력하기 때문이다.
그래도 공부를 한 이유는 왜 이 MyBatis가 게임체인저가 되지 못하고 JPA, Querydsl이 나왔을까?에 초점을 두었다.
이전 포스팅인 JdbcTemplate은 다 좋은데 동적 쿼리를 만들어내기가 쉽지만은 않았다.
https://cwchoiit.tistory.com/71
JdbcTemplate
Spring과 데이터베이스를 연동할 때 사용되는 기술 중 빠지지 않고 잘 사용되는 기술인 JdbcTemplate을 사용하는 법을 정리하고자 한다. 우선, JdbcTemplate은 기존 JDBC 기술을 직접 사용할 때 겪는 문제
cwchoiit.tistory.com
MyBatis는 동적 쿼리를 훨씬 간단하게 작성할 수 있다. 그리고 한가지 좋은 점은 눈으로 SQL문을 보기가 좀 더 간결하고 직관적이다. 왜냐하면 xml 파일로 SQL문을 작성하기 때문이다.
라이브러리 다운로드
우선 MyBatis를 다운받자.
build.gradle
//MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
JdbcTemplate과 한가지 다른 점은 MyBatis는 스프링이 공식적으로 지원하는 라이브러리가 아니다. 그래서 현재 사용중인 스프링의 버전과 가장 적합한 버전을 찾아주지 않기 때문에 버전 명시가 필요하다.
Mapper
package hello.itemservice.repository.mybatis;
import hello.itemservice.domain.Item;
import hello.itemservice.repository.ItemSearchCond;
import hello.itemservice.repository.ItemUpdateDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Optional;
@Mapper
public interface ItemMapper {
void save(Item item);
void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateDto);
List<Item> findAll(ItemSearchCond itemSearchCond);
Optional<Item> findById(Long id);
}
MyBatis는 @Mapper라는 애노테이션이 필요한데, 이를 붙여야만 스프링이 시작할 때 이 @Mapper가 있는 곳을 다 찾아서 인터페이스의 구현체를 내가 만든 xml 파일을 바라보며 직접 만들어주기 때문이다. 그리고 어떤건 @Param이 있고 어떤건 없는데 이는 두 개 이상인 경우 @Param을 붙여야한다.
ItemMapper.xml
이제 실제 위 인터페이스의 구현체를 만들어야 하는데 그건 자바 파일로 만드는게 아니고 xml 파일로 만들어야 한다.
그리고 이 xml 파일의 경로는 임의로 정할수 있는게 아니고 반드시 인터페이스와 같은 패키지 경로와 일치해야한다. (물론 변경할 순 있다)
그래서 위 ItemMapper 인터페이스의 패키지 경로인 hello.itemservice.repository.mybatis를 src/main/resources 하위에 만들고 ItemMapper.xml 파일을 만들자.
src/main/resources/hello/itemservice/repository/mybatis/ItemMapper.xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "- //mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.itemservice.repository.mybatis.ItemMapper">
<insert id="save" useGeneratedKeys="true" keyProperty="id">
insert into item (item_name, price, quantity)
values (#{itemName}, #{price}, #{quantity})
</insert>
<update id="update">
update item
set item_name=#{updateParam.itemName},
price=#{updateParam.price},
quantity=#{updateParam.quantity}
where id = #{id}
</update>
<select id="findById" resultType="Item">
select id, item_name, price, quantity
from item
where id = #{id}
</select>
<select id="findAll" resultType="Item">
select id, item_name, price, quantity
from item
<where>
<if test="itemName != null and itemName != ''">
and item_name like concat('%', #{itemName}, '%')
</if>
<if test="maxPrice != null">
and price <= #{maxPrice}
</if>
</where>
</select>
</mapper>
우선 직관적으로 어떤 태그가 SELECT문, UPDATE문, INSERT문인지 알 것 같다. 태그 이름이 그렇게 알려주니까.
그리고 속성으로 id를 작성하는데 이 id에 작성하는 값은 실제 내가 작성한 ItemMapper 인터페이스의 메소드 명으로 설정하면 된다.
하나하나 뜯어보자.
<insert id="save" useGeneratedKeys="true" keyProperty="id">
insert into item (item_name, price, quantity)
values (#{itemName}, #{price}, #{quantity})
</insert>
save() 메소드와 매핑될 SQL문이다. useGeneratedKeys 속성은 내가 generated key를 설정했으면 true로 해주면 된다. 이 값을 true로 했으면 keyProperty를 설정해야 한다. keyProperty는 기본키의 컬럼명이다.
이제 파라미터를 넘겨받을 땐 #{} 문법을 사용한다. 이 안에 던질 데이터를 넣어주면 된다. save(Item item) 메소드의 이 item이 가지는 필드명이다. save(Item item) 메소드는 파라미터가 하나이기 때문에 바로 그 안에 필드에 접근할 수 있다. 그래서 #{item.itemName}이 아니고 #{itemName}으로 작성하면 된다.
<update id="update">
update item
set item_name=#{updateParam.itemName},
price=#{updateParam.price},
quantity=#{updateParam.quantity}
where id = #{id}
</update>
진짜 보기 편하게 생겼다. SQL문이 바로바로 보인다. 자 이제 update()를 보면 간단하다. 그냥 UPDATE 쿼리문을 작성하면 된다. 그리고 이 update() 메소드는 파라미터가 두개였기 때문에 @Param() 애노테이션에 괄호안에 작성한 값으로 파라미터를 바인딩해야 한다.
@Param("id")는 id로 @Param("updateParam")은 updateParam.xxx로.
<select id="findById" resultType="Item">
select id, item_name, price, quantity
from item
where id = #{id}
</select>
이제 SELECT문이다. 이 또한 보기가 너무 좋긴 하다. 더 말할게 없다. resultType은 이 SELECT문이 반환하는 객체 타입을 작성해주면 된다. "Item"이다.
이제 JdbcTemplate에서 느꼈던 단점인 동적 쿼리를 이 MyBatis가 어떻게 해결해주는지 확인해보자.
<select id="findAll" resultType="Item">
select id, item_name, price, quantity
from item
<where>
<if test="itemName != null and itemName != ''">
and item_name like concat('%', #{itemName}, '%')
</if>
<if test="maxPrice != null">
and price <= #{maxPrice}
</if>
</where>
</select>
너무 간단하다. 작성하는 코드도 몇 줄 안된다. 다른건 볼 것 없고 <where></where> 여기만 보면 되는데, 일단 <where> 태그가 마법같은 일들을 3개나 해준다.
1. 안에 <if> 태그의 조건이 만족하는 게 없으면 WHERE절을 무시해버린다.
2. 안에 <if> 태그의 조건이 하나라도 만족하면 WHERE절을 만들어준다.
3. WHERE절의 첫번째로 들어오는 문장에 "AND"가 있으면 지워준다.
이 세 조건을 딱 보고 <where> 태그를 보면 모든게 다 이해가 될 것인데 다만 한가지 아쉬운 점은 xml파일이기 때문에 <, > 기호가 그대로 태그로 인식이 된다. 이 문제를 해결하려면 이 기호를 <로 변환해줘야 한다.
ItemRepository 구현체
package hello.itemservice.repository.mybatis;
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.Repository;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {
private final ItemMapper itemMapper;
@Override
public Item save(Item item) {
itemMapper.save(item);
return item;
}
@Override
public void update(Long itemId, ItemUpdateDto updateParam) {
itemMapper.update(itemId, updateParam);
}
@Override
public Optional<Item> findById(Long id) {
return itemMapper.findById(id);
}
@Override
public List<Item> findAll(ItemSearchCond cond) {
return itemMapper.findAll(cond);
}
}
여기서는 ItemRepository 구현체는 ItemMapper의 메소드를 가지고 위임만 해준다.
DTO
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;
}
}
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;
}
}
Configuration
package hello.itemservice.config;
import hello.itemservice.repository.ItemRepository;
import hello.itemservice.repository.jdbctemplate.JdbcTemplateItemRepositoryV3;
import hello.itemservice.repository.mybatis.ItemMapper;
import hello.itemservice.repository.mybatis.MyBatisItemRepository;
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 MyBatisConfig {
private final ItemMapper itemMapper;
@Bean
public ItemService itemService() {
return new ItemServiceV1(itemRepository());
}
@Bean
public ItemRepository itemRepository() {
return new MyBatisItemRepository(itemMapper);
}
}
이 Configuration에서 한가지 짚고 넘어갈 건, DataSource 관련 코드가 한 줄도 없다. 이는 application.yml 파일에 정의한 DataSource 설정값들을 스프링이 알아서 ItemMapper랑 매핑해준다. 그래서 정의할 필요가 없다.
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(MyBatisConfig.class)
@SpringBootApplication(scanBasePackages = "hello.itemservice.web")
public class ItemServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ItemServiceApplication.class, args);
}
}
여기서 MyBatisConfig를 컴포넌트 스캔을 통해 스프링이 알아서 등록하게 해줄 수 있지만, 이 예제에서는 컴포넌트 스캔 대상에 제외했기 떄문에 Config 파일을 임포트해서 스프링이 띄워질 때 빈으로 등록해줘야 한다.
테스트 코드
이제 실행해보자. 😊
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.AfterEach;
import org.junit.jupiter.api.BeforeEach;
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는 순서도 다 맞아야한다
}
}
'Spring + DB' 카테고리의 다른 글
Index란? (DB) (0) | 2024.04.05 |
---|---|
선언적 트랜잭션(@Transactional) 내부 호출 주의 (2) | 2023.12.07 |
JdbcTemplate (0) | 2023.12.06 |
Transaction, Auto Commit, Rollback, Lock (0) | 2023.11.30 |
[Spring/Spring Data JPA] @Transactional (0) | 2023.11.12 |