개발자로 후회없는 삶 살기

spring PART.MyBatis 본문

[백엔드]/[spring | 학습기록]

spring PART.MyBatis

몽이장쥰 2023. 5. 15. 13:30

서론

※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.

https://www.inflearn.com/roadmaps/373

 

우아한형제들 최연소 기술이사 김영한의 스프링 완전 정복 - 인프런 | 로드맵

Spring, MVC 스킬을 학습할 수 있는 개발 · 프로그래밍 로드맵을 인프런에서 만나보세요.

www.inflearn.com:443

 

본론

- MyBatis 소개

탬플릿보다 더 많은 기능을 제공하는 sql 맵퍼로 탬플릿이 제공하는 반복 작업등을 다 해결해줍니다. 더 매력적인 것은 xml을 써서 sql을 작성할 수 있고 동적 쿼리를 매우 편리하게 해결할 수 있는 점입니다.

 

1. sql

xml을 구현체처럼 작성해서 sql을 여러 줄 작성할 때 문법 오류를 없앱니다.

 

2. 동적쿼리

탬플릿은 개발자가 상황에 따라 경우의 수를 다 따져서 sql을 만들어야 했고 매우 복잡했습니다. Mybatis는 where if 등 xml 태그가 활용되어서 이전보다는 훨씬 편리하게 작성할 수 있습니다.

 

-> 정리

하고 있는 프로젝트에서 동적 쿼리를 쓰고 복잡한 쿼리가 많다면 Mybatis를 쓰고 단순한 쿼리가 많으면 탬플릿을 사용하면 됩니다. ORM을 사용하다가 직접 sql을 써야할 때 탬플릿을 사용하면되고 동적 쿼리가 복잡하면 Mybatis를 같이 쓰면 됩니다.

 

- 설정

그래들과 프로퍼티스에 라이브러리를 추가해야 합니다. 패키지를 Mybatis에서 자동으로 인식하게 하기 위한 설정을 하고 db와 자바 관례 불일치를 맞춰주는 설정을 하고 로깅을 레포에 있는 Mybatis를 남깁니다. 지금 이 프로퍼티스는 어플을 띄울 때만 설정이 먹습니다. 따라서 테스트의 프로퍼티스에도 넣어야합니다.

 

-> type aliases

xml에서 find를 보면 반환 타입을 원래는 패키지 명까지 다 적어야하는데 기본 패키지를 생략할 수 있습니다. 패키지 추가는 ','로 구분하면 됩니다.

 

- MyBatis 기본 적용

Mybatis를 사용해서 db에 데이터를 저장하겠습니다. xml을 사용하는 것 외에는 코드를 줄이는 게 탬플릿과 유사합니다.

 

-> 레포지토리

레포에 하위 패키지로 MyBatis를 만듭니다. 여기에 인터를 만들어야합니다. 보통 Mapper라고 하고 ItemMapper 인터를 만들고 @Mapper를 붙여야합니다.

 

@Mapper
public interface ItemMapper {
    void save(Item item);
    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateDto);
    List<Item> findAll(ItemSearchCond itemSearch);
    Optional<Item> findById(Long id);
}

이 인터페이스를 xml에서 부릅니다. crud 메서드를 이 인터페이스에 정의합니다. 파라미터가 하나 이상이면 @param을 붙여야합니다. 객체의 경우 파라미터 하나라서 내부에 필드를 @Param 안 붙여도 다 쓸 수 있습니다.

 

-> 설명

이게 Mybatis매핑 xml을 호출하는 매퍼 인터로 @Mapper를 호출해야 Mybatis에서 인식할 수 있습니다. 

 

이 인터의 메서드를 호출하면 xml의 id에 맞게 호출됩니다. 인터페이스라서 구현체가 있어야하는데 구현체는 자동으로 만들어집니다. 이제 같은 위치에 실행할 SQL이 있는 XML 매핑 파일을 만들어야합니다.

 

-> xml

자바 코드가 아니라서 resources에 넣어야하고 만든 ItemMapper와 위치를 맞춰야합니다. hello.itemservice.repository.mybatis 패키지를 만들고 그 안에 xml을 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">

설정 정보를 주고 namespace를 줘야하는데 맵퍼 인터페이스를 주고 그러면 xml이 "아 저기랑 연결됐구나"하고 연동이 됩니다.

 

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insert into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

// 탬플릿
@Override
public Item save(Item item) {
    String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";
    SqlParameterSource parma = new BeanPropertySqlParameterSource(item);
    Number key = jdbcInsert.executeAndReturnKey(parma);
    item.setId(key.longValue());
    return item;
}

insert, update, select 등 태그가 있고 이것을 사용해서 ItemMapper의 메서드가 호출됐을 때 원하는 crud 동작을 합니다. 이 태그 안에 쿼리를 작성해야합니다. save 태그 내부에 원래 save에 있던 sql을 쓰면 됩니다. (save를 포함한 모든 태그 내부에 원래 있던 sql을 적을 것 입니다.) 자바 코드라면 sql이 길어지면 공백(' ')등 신경을 써야하는데 여기서는 길이가 길어도 그냥 편하게 엔터쳐도 됩니다.

 

> 파라미터를 #으로 주는데 이 파라미터가 맵퍼의 매개변수인 Item 객체의 필드에 바인딩되어서 getItemName, getPrice로 꺼내올 수 있습니다. 탬플릿에서도 레포에서 save의 매개변수로 들어온 item의 필드 값을 sql에 get으로 파라미터 맵핑하는데 mybatis도 xml에서 동일하게 동작합니다.

 

+ id도 자동 증가 설정을 줘야합니다. 인터페이스만 작성하고 이렇게 하면 xml에서 탬플릿 구현체에서 했던 sql 작성, 파라미터 바인딩, 자동 키 생성을 다 깔끔히 해결해 줍니다. 쿼리만 작성했는데 연동할 거 연동하고 패키지 지정을 다 해놔서 이게 가능합니다.

 

<update id="update">
    update item
    set item_name=#{updateParam.itemName},
        #{updateParam.price},
        #{updateParam.quantity}
    where id = #{id}
</update>

update도 쿼리를 작성합니다. 이 경우는 파라미터가 2개라서 @Param에 있는 것으로 updateParam.(DTO 필드) 이렇게 하면 됩니다. 보면 그냥 쿼리를 작성하고 #으로 파라미터 지정하면 끝입니다. 사실 탬플릿에서도 sql, 파라미터 지정, rs 맵퍼만 만들면 됐기에 똑같습니다.

 

<select id="findById" resultType="Item">
    select id, item_name, price, quantity
    from item
    where id = #{id}
</select>

// 탬플릿
@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());

findById도 쿼리를 작성하면 되고 얘는 반환값이 있어서 resultType을 줘야합니다. 패키지 aliases를 해줘서 패키지 생략하고 Item만 적으면 됩니다. rs도 자동으로 해서 바로 Item 객체를 반환해줍니다.

 

<select id="findAll" resultType="Item">
    select id, item_name, price, quantity
    from item
    <where>
        <if test="itemName != null || itemName != ''">
            and item_name like concat('%', #{itemName}, '%')
        </if>
        <if test="maxPrice != null">
            and price &lt;= #{maxPrice}
        </if>
    </where>
</select>

findAll도 쿼리를 작성하고 반환을 여러개 하지만 똑같이 반환타임을 Item으로 적습니다. 그 다음에 동적쿼리를 작성합니다. where문도 동적으로 되어야하며 어느 경우에는 where가 없어야합니다. 아무 조건이 안 들어오면 where가 없어야합니다. <where>로 동적으로 where를 주고 if로 상품명이 null과 공백이 아니면 상품명에 like로 부분 문자열 문법을 넣어 조건 검색합니다.

 

또 가격은 정수형이라서 공백은 생각하지 않아도 되고 null만 아니면 작거나 같은 값을 조건 검색하게 합니다. 여기서 <=이 xml 문법이 충돌을 일으켜서 lt('<')로 바꿔야하고 =은 그냥 쓰면 됩니다. 이전에 동적 쿼리 부분이 자바 코드로 작성할 때보다 훨씬 간편해집니다.

 

> 자바 코드로는 where를 만드는 것부터 and를 붙일지 말지까지 다 동적으로 변화해서 생각할 게 많았는데 이것을 xml에서는 where부터 if까지 태그로 해결할 수 있어서 간단해집니다.

 

※ 자바코드

1) 조건에 상품명이 있거나 가격이 있으면(null이 아니면) where를 붙이고 아니면 안 붙임
2) 상품명을 먼저 조건 검사하고 있으면 부분 검사하고 가격도 있을 것을 대비하여 andFlag를 T
3) 가격도 조건이 있으면 and를 붙이고 조건을 sql에 더함

 

이렇게 sql을 완성하고 template.query(sql) 합니다.

 

- 설명

1. save

String sql = "insert into item (item_name, price, quantity) values (:itemName, :price, :quantity)";

insert하고 id에 인터페이스에 작성한 메서드 이름을 지정합니다. 파라미터는 #{}문법을 쓰고 jdbc의 ?를 치환하는 것입니다. 치환하고 sql을 실행해서 실제로 DB에 저장합니다. useGenerate를 쓰면 파라미터로 받은 item 객체의 id 속성에 값이 입력됩니다.

2. update

파라미터가 여러개면 @Param을 지정하여 파라미터를 구분해야합니다.

3. findById

반환타입을 지정해야하고 BeanPropertyRowMapper처럼 db rs 반환 결과를 바로 객체로 바꿔줍니다. 객체생성하고 rs로 값 가져오는 것을 자동화 해줍니다. 카멜표기법도 프로퍼티스에 써서 자동 처리해줍니다.

4. findAll

Mybatis는 where, if 같은 동적 쿼리 문법을 통해 편리한 동적 쿼리를 지원합니다. if는 해당 조건이 만족하면 구문을 추가하고 where는 적절하게 where 문장을 만들어줍니다.

적절하게란? if가 다 실패하면 where을 안 만들고 하나라도 성공하면 if의 'and'를 where로 변환해줍니다. 두번째만 성공하면 and를 where로 바꿔주고 둘 다 성공하면 첫번째 and만 바꾸고 두번째 and는 놔둡니다. 이렇게 해서 동적쿼리에 대한 고민을 줄여줍니다.

 

- 실행

-> 레포

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

ItemMapper는 xml에 접근하기 위한 것 뿐이고 레포 역할과 레포 구현체가 아닙니다. 레포 구현체를 만들어야하고 레포에서 Mapper를 사용합니다. ItemMapper를 주입받습니다. 스프링이 ItemMapper의 구현체를 만드는데 xml과 연동하는 구현체입니다. Mapper의 구현체는 레포가 아니고 지금 만드는 것이 진짜 레포이며 Mapper 구현체를 의존관계 주입받습니다.

레포는 그냥 이 Mapper를 호출하면 되는 것입니다. 파라미터 그대로 넘겨주면 됩니다. save의 경우 Mapper의 save가 반환값이 void인데 매개변수를 레포에서 item을 mapper에 넣어줘서 mapper에서 저장하고 item에 id 넣어줘서 그 item을 반환합니다. 이 레포지토리는 mapper에 위임하는 기능만 합니다.

 

-> 설정

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

config를 새로운 레포를 빈 등록하도록 바꿉니다. 의존관계 주입을 ItemMapper를 받습니다. app의 import도 바꿔줍니다. test를 돌려보면 임베디드 db로 실행합니다.

 

- Mybatis 분석

Mapper 인터를 만들었으니 구현체가 필요합니다. 그런데 구현체가 없는데 주입을 하고 사용하였습니다. 동작한 방법을 보겠습니다.

 

-> 그림

1. 연동 모듈이 뜰 때 @Mapper가 붙은 인터를 뒤집니다. item 맵퍼를 찾았습니다.
2. 이를 기반으로 동적 프록시 ItemMapper 인터를 구현한 객체를 생성을 합니다. 
3. 실제 객체를 만들고 이것을 스프링 빈으로 등록하는 것입니다. 

 

이 구현체 내부에 xml에 있는 것을 호출하는 로직이 들어있습니다. 따라서 우리가 주입받은 건 연동 모듈이 만든 객체이고 레포에서 그 객체의 xml을 접근하는 메서드를 호출하는 것입니다. xml에 탬플릿의 기능인 sql, 파라미터 바인딩, rs 처리가 다 있는데 레포에서 구현체를 호출하고 구현체에서 xml을 호출하는 것입니다.

 

레포에서 주입받은 itemMapper의 class를 찍어보면 프록시 클래스가 나옵니다. 연동 모듈이 ItemMapper 구현체를 만들고 등록한 것입니다.

 

- 매퍼 구현체

연동 모듈이 만들어 주는 객체 덕분에 개발자는 Mapper 인터를 만드는 것만으로도 편리하게 xml의 데이터를 찾아서 호출합니다.

 

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

원래라면 개발자가 직접 Mybatis 레포에서 xml에 접근해서 호출하는 로직을 짜야하는데 구현체를 만들어주니 개발자는 레포에서 구현체에 위임하는 코드만 하면 됩니다. 또한 구현체는 스프링 제공 예외인 DataAccessExcetpion에 맞게 예외 변환도 해줍니다.

 

throw sqlExceptionTranslator.translate("insert", sql, e);

구현체에서 xml에 접근해서 sql을 실행했는데 발생하는 모든 SQL 예외를 스프링 예외에 맞게 변환해서 던집니다. 구현체가 xml을 호출해주니 구현체가 실레포지토리라고 보면 db 예외가 발생했을 때 translate를 해주는 것입니다. 또한 탬플릿에서 배웠던 커낵션, 트랜잭션, 동기화 매니저까지 다 연동 모듈이 만들어줍니다. 그냥 구현체를 레포지토리라고 보면됩니다. 

 

-> DataAccessExcetpion 예제

<insert id="save" useGeneratedKeys="true" keyProperty="id">
    insertqqq into item (item_name, price, quantity)
    values (#{itemName}, #{price}, #{quantity})
</insert>

xml의 insert 태그에 문법을 insertqqq라고 하면

 

 스프링 예외 계층에 맞는 BadGrammer 예외가 터집니다. 따라서 이것이 Mapper 구현체를 호출한 MyBatis 레포까지 전달될 것이고 이것을 서비스나 was로 던져 처리하면 됩니다.

 

- mybatis 기능 정리
-> 동적 쿼리

마이바티스가 제공하는 최고의 기능이자 Mybatis를 사용하는 이유가 바로 동적 sql 기능 때문입니다. 동적 쿼리를 위해 제공되는 기능은 다음과 같습니다.

 

1. if

해당 조건에 따라 값을 추가할지 말지 판단합니다. where에 state라는 조건이 이미 하나 있는 경우 조건을 만족하면 태그 사이의 'AND title'을 추가합니다.

 

2. choose, when, otherwise

자바 switch문과 비슷합니다.

 

3. where

왼쪽처럼 작성하면 where만 있어서 문법 오류가 뜹니다. 이를 해결하기 위해 <where> 태그를 사용합니다.

 

4. foreach

컬랙션을 반복 처리할 때 사용합니다. where in (1, 2, 3, 4, 5) 문법에서 파라미터로 List를 전달할 때 사용합니다.

 

-> 어노테이션 SQL 작성

xml 대신에 sql을 작성할 수 있습니다. xml에 한 것과 동일하게 동작하며 MyBatis레포에서 ItemMapper 프록시 구현체의 메서드를 호출하면 됩니다. 동적 쿼리가 안되어서 간단한 경우에는 이렇게 사용할 수도 있지만 Mybatis는 xml에 사용하는게 메리트라서 잘 사용하지는 않습니다. 또한 xml과 중복되게 작성하면 안 됩니다.

 

-> 문자열 대체

#{}는 pstmt로 파라미터 바인딩을 합니다. 때로는 파라미터가 아닌 문자열 그대로를 처리하고 싶은데 $를 사용합니다. 사용할 때는 mapper 구현체를 사용하는 레포에서 인자로 원하는 문자열을 넣으면 됩니다. 하지만 sql 인젝션 공격을 받을 수 있으므로 가급적 사용하면 안됩니다.

 

-> sql 조각 재사용

findId랑 findAll이 중복입니다. 이럴 때 코드 조각을 include를 할 수 있습니다.

Comments