이번에는 MyBatis라는 기술을 이용해서 Database와 통신하는 법을 알아보자.
이 역시 나는 사용하지 않을것 같다. 왜냐하면 이 MyBatis를 사용하기엔 Querydsl과 Spring Data JPA가 너무 강력하기 때문이다.
그래도 공부를 한 이유는 왜 이 MyBatis가 게임체인저가 되지 못하고 JPA, Querydsl이 나왔을까?에 초점을 두었다.
이전 포스팅인 JdbcTemplate은 다 좋은데 동적 쿼리를 만들어내기가 쉽지만은 않았다.
MyBatis는 동적 쿼리를 훨씬 간단하게 작성할 수 있다. 그리고 한가지 좋은 점은 눈으로 SQL문을 보기가 좀 더 간결하고 직관적이다. 왜냐하면 xml 파일로 SQL문을 작성하기 때문이다.
라이브러리 다운로드
우선 MyBatis를 다운받자.
build.gradle
//MyBatis
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.0'
JdbcTemplate과 한가지 다른 점은 MyBatis는 스프링 부트가 공식적으로 지원하는 라이브러리가 아니다. 그래서 현재 사용중인 스프링의 버전과 가장 적합한 버전을 찾아주지 않기 때문에 버전 명시가 필요하다.
이렇게 라이브러리를 다운받으면 다음과 같이 외부 라이브러리 리스트에 Mybatis가 노출된다.
- 근데 한가지 눈이 가는 부분이 있다. 빨간 표시해둔 `ibatis`? 이건 뭐지?
- 예전 이름이 ibatis고 지금은 mybatis로 변경된 것이다.
- 그 이름이 그대로 남아있을 뿐이고 그냥 mybatis라고 생각하면 된다.
- 그리고 라이브러리 보면, spring-boot-starter라는 이름과 autoconfigure 라는 이름의 라이브러리도 들어와 있는데 이거 친숙하다. 스프링 부트가 라이브러리 만들어낼 때 이런 이름을 사용하는데 mybatis는 스프링 부트에서 공식적으로 지원하는 라이브러리는 아니다. 근데 이름이 왜 이럴까? mybatis에서 직접 만든 라이브러리다. 스프링 부트와 사용할 때 사용자들에게 편리하게 설정을 해주도록 mybatis에서 직접 만들어서 넣어준 라이브러리다.
application.yaml
mybatis:
type-aliases-package: hello.itemservice.domain
configuration:
map-underscore-to-camel-case: true
logging:
level:
hello.itemservice.repository.mybatis: trace
- mybatis를 사용하려면 간단한 설정을 해줘야 한다. 이건 필수는 아니고 설정하면 편하다.
- 우선, type-aliases-package는 mybatis를 사용할 때 SELECT절로 가져오는 데이터를 객체로 바인딩할 때 어디에 있는 객체를 나타내는지 등을 작성하는 속성이다. 진행하면서 한번 더 이 부분에 대해 설명하겠다.
- configuration.map-underscore-to-camel-case는 데이터베이스에서는 관례가 (_)를 사용하다 보니, 객체와 매핑할 때 alias를 사용해야 하는 불편함이 있다. 객체는 item_name 이 아니라 itemName으로 표기하니까. 그래서 SELECT절에서도 이렇게 사용해야 했다.
`select item_name as itemName`
- 그리고 객체로 매핑할 때 해당 필드를 찾아서 값을 넣어주는 방식으로 진행됐는데, 이렇게 configuration.map-underscore-to-camel-case 속성을 true로 설정해주면 자동으로 (_)를 camelCase로 변환해준다.
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라는 애노테이션이 필요한데, 이를 붙여야만 Mybatis 스프링 연동 모듈(아까 위에서 봤던 autoconfigure 라이브러리)이 스프링이 시작할 때 이 @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 속성은 데이터베이스가 키를 자동으로 생성해주는 IDENTITY 전략일 때 사용한다. 이 값을 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"이다. 원래는 이것도 풀 패키지 명을 작성해줘야 하는데 위에서 작성한 application.yaml 파일을 기억하는가? type-alias-package를 작성해줬기 때문에 그 하위에 있는 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개나 해준다.
- 안에 <if> 태그의 조건이 만족하는 게 없으면 WHERE절을 무시해버린다.
- 안에 <if> 태그의 조건이 하나라도 만족하면 WHERE절을 만들어준다.
- 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는 순서도 다 맞아야한다
}
}
MyBatis 분석
사용법은 굉장히 간단했고, 궁금한 부분은 Mapper 인터페이스만 만들었을 뿐인데 구현체를 어떻게 만들었을까?에 대한 고민을 할 차례다.
ItemMapper
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 updateParam);
Optional<Item> findById(Long id);
List<Item> findAll(ItemSearchCond itemSearchCond);
}
아까 위에서도 살짝 얘기했지만, Mybatis 스프링 연동 모듈에서 저 @Mapper 애노테이션이 달려있는 것들을 다 찾아서, 프록시를 만들고 그 프록시를 스프링 컨테이너에 빈으로 등록해준다. 이 과정에서 정말 여러가지 기술이 사용된다.
- 애노테이션 프로세싱
- 리플렉션
- JDK 동적 프록시
- 등등
이 세가지 내용 모두 내 블로그에서 다룬 내용들이다. 궁금하면 참고하면 좋을듯하다. 여튼 그림으로 보면 다음과 같다.
- 애플리케이션 로딩 시점에, Mybatis 스프링 연동 모듈은 @Mapper가 붙어있는 인터페이스를 조사한다.
- 해당 인터페이스가 발견되면 JDK 동적 프록시 기술을 사용해서 ItemMapper 인터페이스의 구현체를 만든다.
- 생성된 구현체를 스프링 빈으로 등록한다.
이렇게 만들어진 Mapper의 구현체(프록시)는 심지어 예외 변환까지 스프링 데이터 접근 예외 추상화인 DataAccessException에 맞게 변환해서 반환해준다. JdbcTemplate이 제공하는 예외 변환 기능을 여기서도 제공한다고 이해하면 된다.
'Spring + Database' 카테고리의 다른 글
[Renewal] 데이터 접근 기술에 대한 고민 두가지 (2) | 2024.12.08 |
---|---|
[Renewal] Spring Data JPA (4) | 2024.12.07 |
[Renewal] JdbcTemplate (8) | 2024.12.06 |
[Renewal] 테스트 시 데이터베이스 연동 (0) | 2024.12.05 |
[Renewal] 예외2, 스프링의 데이터접근 예외 추상화 (3) | 2024.12.05 |