Spring + DB

MyBatis

cwchoiit 2023. 12. 6. 11:43
728x90
반응형
SMALL

이번에는 MyBatis라는 기술을 이용해서 Spring에서 Database와 통신하는 법을 알아보자.

 

이 역시 나는 사용하지 않을것 같다. 왜냐하면 이 MyBatis를 사용하기엔 Querydsl과 Spring Data JPA가 너무 강력하기 때문이다.

그래도 공부를 한 이유는 왜 이 MyBatis가 게임체인저가 되지 못하고 JPA, Querydsl이 나왔을까?에 초점을 두었다.

 

728x90
SMALL

 

이전 포스팅인 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 &lt;= #{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 &lt;= #{maxPrice}
        </if>
    </where>
</select>

 

너무 간단하다. 작성하는 코드도 몇 줄 안된다. 다른건 볼 것 없고 <where></where> 여기만 보면 되는데, 일단 <where> 태그가 마법같은 일들을 3개나 해준다.

 

1. 안에 <if> 태그의 조건이 만족하는 게 없으면 WHERE절을 무시해버린다. 

2. 안에 <if> 태그의 조건이 하나라도 만족하면 WHERE절을 만들어준다.

3. WHERE절의 첫번째로 들어오는 문장에 "AND"가 있으면 지워준다.

 

이 세 조건을 딱 보고 <where> 태그를 보면 모든게 다 이해가 될 것인데 다만 한가지 아쉬운 점은 xml파일이기 때문에 <, > 기호가 그대로 태그로 인식이 된다. 이 문제를 해결하려면 이 기호를 &lt;로 변환해줘야 한다. 

 

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는 순서도 다 맞아야한다
    }
}

 

728x90
반응형
LIST

'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