공부/Spring

데이터 접근 기술 - MyBatis

Stair 2025. 6. 4. 09:45
반응형

MyBatis 소개

MyBatis는 앞서 설명한 JdbcTemplate보다 더 많은 기능을 제공하는 SQL Mapper이다. 

기본적으로 JdbcTemplate이 제공하는 대부분의 기능을 제공한다.

JdbcTemplate와 비교해서 MyBatis의 가장 매력적인 점은 SQL을 XML에 편리하게 작성할 수 있고, 또 동적 쿼리를 매우 편리하게 작성할 수 있다는 점이다.

 

먼저 SQL이 여러줄에 걸쳐 있을 때 둘을 비교해보자.

JDBCTemplate - SQL 여러줄

String sql = "update item " +
    "set item_name=:itemName, price=:price, quantity=:quantity " +
    "where id=:id";

 

MyBatis - SQL 여러줄

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

 

MyBatis는 XML에 작성하기 때문에 라인이 길어져도 문자 더하기에 대한 불편함이 없다.

 

다음으로 상품을 검색하는 로직을 통해 동적 쿼리를 비교해보자.

 

JdbcTemplate - 동적 쿼리

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

 

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>

 

 

JdbcTemplate은 자바 코드로 직접 동적 쿼리를 작성해야 한다. 반면 MyBatis는 동적 쿼리를 매우 편리하게 작성할 수 있는 다양한 기능들을 제공해준다.

 

설정의 장단점

JdbcTemplate은 스프링에 내장된 기능이고, 별도의 설정 없이 사용할 수 있다는 장점이 있다. 반면에 MyBatis는 약간의 설정이 필요하다.

 

정리

프로젝트에서 동적 쿼리와 복잡한 쿼리가 많다면 MyBatis를 사용하고, 단순한 쿼리들이 많으면 JdbcTemplate을 선택해서 사용하면 된다. 물론 둘을 함께 사용해도 된다. 하지만 MyBatis를  선택했다면 그것으로 충분하다.

 

 

 

MyBatis 설정

mybatis-spring-boot-starter 라이브러리를 사용하면 MyBatis를 스프링과 통합하고, 설정도 아주 간단히 할 수 있다.

mybatis-spring-boot-starter 라이브러리를 사용해서 간단히 설정하는 방법을 알아보자.

 

build.gradle에 다음 의존 관계를 추가한다.

//MyBatis 추가
implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'

- 참고로 뒤에 버전 정보가 붙는 이유는 스프링 부트가 버전을 관리해주는 공식 라이브러리가 아니기 때문이다. 스프링 부트가 버전을 관리해주는 경우 버전 정보를 붙이지 않아도 최적의 버전을 자동으로 찾아준다.

 

implementation을 해주면 다음과 같은 라이브러리가 추가된다.

- mybatis-spring-boot-starter : MyBatis를 스프링 부트에서 편리하게 사용할 수 있게 시작하는 라이브러리

- mybatis-spring-boot-autoconfigure : MyBatis와 스프링 부트 설정 라이브러리

- mybatis-spring : MyBatis와 스프링을 연동하는 라이브러리

- mybatis : MyBatis 라이브러리

 

라이브러리 추가는 완료되었으므로 application.propertis에 설정을 추가하자.

 

*주의 : 웹 애플리케이션을 실행하는 main, 테스트를 실앻아는 test 각 위치의 application.propertis를 모두 수정해주어야 한다. 설정을 변경해도 반영이 안된다면 이 부분을 꼭 확인하자.

#MyBatis
mybatis.type-aliases-package=hello.itemservice.domain
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.itemservice.repository.mybatis=trace

- mybatis.type-aliases-package

    - 마이바티으세어 타입 정보를 사용할 때는 패키지 이름을 적어주어야 하는데, 여기에 명시하면 패키지 이름을 생략할 수 있다.

    - 지정한 패키지와 그 하위 패키지가 자동으로 인식된다.

    - 여러 위치를 지정하려면 ' , ', ' ; '로 구분하면 된다.

- mybatis.configuration.map-underscore-to-camel-case

    - JdbcTemplate의 BeanPropertyRowMapper 처럼 언더바를 카멜로 자동 변경해주는 기능을 활성화 한다.

- logging.level.hello.itemservice.repository.mybatis=trace

    - MyBatis에서 실행되는 쿼리 로그를 확인할 수 있다.

 

관례의 불일치

자바 객체에는 주로 카멜(camelCase)표기법을 사용한다. itemName처럼 중간단어의 첫글자를 대문자로 적는 표기법이다.

반면 관계형 데이터베이스에서는 주로 언더스코어를 사용하는 스네이크(snake_case)표기법을 사용한다. item_name처럼 중간에 언더스코어를 사용하는 표기법이다.

이렇게 관례로 많이 사용하다보니, map-underscore-to-camel-case 기능을 활성화하면 언더스코어 표기법을 카멜로 자동변환 해준다. 따라서 DB에서 select item_name으로 조회해도 객체의 itemName(setItemName())속석에 값이 정상 입력된다.

정리하면 해당 옵션을 켜면 snake_case는 자동으로 해결되기 때문에 그냥 두어도 괜찮고, 컬럼 이름과 객체 이름이 완전히 다른 경우에는 조회 SQL에서 별칭을 사용하면 된다.

 

ex)

- DB select item_name

- 객체 name

 

별칭을 통한 해결 방안 : select item_name as name

 

 

 

MyBatis 적용1 - 기본

이제부터 본격적으로 MyBatis를 사용하여 데이터베이스에 데이터를 저장해보자.

XML에 작성한다는 점을 제외하고는 JDBC 반복을 줄여준다는 점에서 JdbcTemplate과 거의 유사하다.

 

ItemMapper 인터페이스를 만들어보자.

@Mapper
public interface ItemMapper {

    void save(Item item);

    void update(@Param("id") Long id, @Param("updateParam") ItemUpdateDto updateDto);

    Optional<Item> findById(Long id);

    List<Item> findAll(ItemSearchCond itemSearch);
}

- 마이바티스 매핑 XML을 호출해주는 매퍼 인터페이스이다.

- 이 인터페이스에는 @Mapper 애노테이션을 붙여주어야 한다. 그래야 MyBatis에서 인식할 수 있다.

- 이 인터페이스의 메서드를 호출하면 다음에 보이는 xml의 해당 SQL을 실행하고 결과를 돌려준다.

 

이제 같은 위치에 실행할 SQL이 있는 XML 매핑 파일을 만들어주면 된다.

참고로 자바 코드가 아니기 때문에 java 패키지 밑이 아닌 src/main/resources 하위에 만들어야 한다.

패키지 이름은 java쪽의 패키지와 동일하게 맞추어주어야 한다.

 

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},
            price3=#{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>

- namespace : 앞서 만든 매퍼 인터페이스를 지정하면 된다.

- 경로와 파일 이름을 맞추어 주어야 한다.

 

 

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

- insert SQL은 <insert>를 사용하면 된다.

- id에는 매퍼 인터페이스에 설정한 메서드 이름을 지정하면 된다. 여기서는 메서드 이름이 save()이므로 save로 지정해주도록 한다.

- 파라미터는 #{} 문법을 사용하면 된다. 그리고 매퍼에서 넘긴 객체의 프로퍼티 이름을 적어주면 된다.

- #{} 문법을 사용하면 PreparedStatement를 사용한다. JDBC의 ?를 치환한다 생각하면 된다.

- useGeneratedKeys는 데이터베이스가 키를 생성하주는 IDENTITY전략일 때 사용한다. keyPropertyt는 생성되는 키의 속성 이름을 지정한다. Insert가 끝나면 item객체의 id속성에 생성된 값이 입력된다.

 

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

- Update SQL은 <update>를 사용하며8ㄴ 된다.

- 여기서는 파라미터가 Long id, ItemUpdateDto updatePara으로 2개이다. 파라미터가 1개만 있으면 @Param을 지정하지 않아도 되지만, 파라미터가 2개 이상이면 @Param으로 이름을 지정해서 파라미터를 구분해야 한다.

 

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

- Select SQL은 <select>를 사용하면 된다.

- resultType은 반환 타입을 명시하면 된다. 여기서는 결과를 Item 객체에 매핑한다.

    - 앞서 application.properties에 mybatis.type-aliases-package=hello.itemservice.domain 속성을 지정한 덕분에 모든 패키지 명을 다 적지는 않아도 된다. 그렇지 않으면 모든 패키지 명을 다 적어야 한다.

    - JdbcTemplate의 BeanPropertyRowMapper처럼 SELECT SQL의 결과를 편리하게 객체로 바로 변환해준다.

    - mybatis.configuration.map-underscore-to-camel-case=true 속성을 지정한 덕분에 언더스코어를 카멜 표기법으로 자동으로 처리해준다(item_name -> itemName)

- 자바 코드에서 반환 객체가 하나이면 Item, Optional<Item>과 같이 사용하면 되고, 반환 객체가 하나 이상이면 컬렉션을 사용하면 된다. 컬렉션은 주로 List를 사용한다.

 

<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>

- MyBatis는 <where>, <if>같은 동적 쿼리 문법을 통해 편리한 동적 쿼리를 지원한다.

- <if>는 해당 조건이 만족하면 구문을 추가한다.

- <where>은 적절하게 where 문장을 만들어준다.

    - 예제에서 <if>가 모두 실패하게 되면 SQL where를 만들지 않는다.

    - 예제에서 <if>가 하나라도 성공하면 처음 나타나는 and를 where로 변환해준다.

 

XML 특수 문자

가격을 비교하는 조건을 확인해보자.

and price $lt;= #{maxPrice}

여기를 보면 <=를 사용하지 않고 &lt;=를 사용한 것을 확인할 수 있다. 그 이유는 XML에서는 데이터 영역에 <, > 같은 특수 문자를 사용할 수 없기 때문이다. XML에서 Tag를 시작하거나 종료할 때 <, >와 같은 특수 문자를 사용하기 때문이다.

다른 해결 방안으로는 XML에서 지원하는 CDATA 구문 문법을 사용하는 것이다. 이 구문 안에서는 특수문자를 사용할 수 있다. 대신 이 구문 안에서는 XML TAG가 단순 문자로 인식되기 때문에 <if>, <where>등이 적용되지 않는다.

<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">
            <![CDATA[ and price <= #{maxPrice} ]]>
        </if>
    </where>
</select>

 

 

 

MyBatis 적용2 - 설정과 실행

** 위처럼 코드를 작성하고 코드를 진행시켰더니 바로 미친 에러가 발생하여 삽질을 거의 한시간동안이나 하였다**

Caused by: org.springframework.beans.factory.NoSuchBeanDefinitionException: 
No qualifying bean of type 'hello.itemservice.repository.mybatis.ItemMapper' available

 

뭐 빈을 찾을 수 없다.해서 MapperScan을 사용해보고, Bean 설정도 다시 해보았지만, 근본적인 문제는 이쪽이 아닌 myBatis 버전 문제였다.

build.gradle에

    //MyBatis 추가
//  implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:2.2.2'

mybatis의 버전을 낮추어서 다시 진행했더니 정상적으로 코드가 돌아간다.

 

@Slf4j
@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {

    private final ItemMapper itemMapper;

    @Override
    public Item save(Item item) {
        log.info("itemMapper class={}", itemMapper.getClass());
        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를 구현해서 MyBatisItemRepository를 만들자.

- MyBatisItemRepository는 단순히 ItemMapper에 기능을 위임한다.

 

@Configuration
@RequiredArgsConstructor
//@MapperScan("hello.itemservice.repository.mybatis") // Mapper 인터페이스들이 있는 패키지 경로
public class MyBatisConfig {

    private final ItemMapper itemMapper;

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

    @Bean
    public ItemRepository itemRepository() {
        return new MyBatisItemRepository(itemMapper);
    }
}

- MyBatisConfig는 ItemMapper를 주입받고, 필요한 의존관계를 만든다.

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

- @Import(MyBatisConfig.class) : 앞서 설정한 MyBatisConfig.class를 사용하도록 설정하였다.

 

테스트를 실행해본 후 애플리케이션을 실행하여 정상 동작하는지 확인해보자.

 

 

 

MyBatis 적용3 - 분석

생각해보면 지금까지 진행한 ItemMapper 매퍼 인터페이스는 구현체가 없다.

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

 

이 부분은 MyBatis 스프링 연동 모듈에서 자동으로 처리해주는데 다음과 같다.

 

1. 애플리케이션 로딩 시점에 MyBatis 스프링 연동 모듈은 @Mapper가 붙어있는 인터페이스를 조사한다.

2. 해당 인터페이스가 발견되면 동적 프록시 기술을 사용해서 ItemMapper 인터페이스의 구현체를 만든다.

3. 생성된 구현체를 스프링 빈으로 등록한다.

 

로그를 추가해서 ItemMapper클래스를 출력해보자.

@Slf4j
@Repository
@RequiredArgsConstructor
public class MyBatisItemRepository implements ItemRepository {

    private final ItemMapper itemMapper;

    @Override
    public Item save(Item item) {
        log.info("itemMapper class={}", itemMapper.getClass());
        itemMapper.save(item);
        return item;
    }

 

itemMapper class=class jdk.proxy3.$Proxy69

출력해보면 JDK 동적 프록시가 적용된 것을 확인할 수 있다.

 

정리

- 매퍼 구현체 덕분에 마이바티스를 스프링에 편리하게 통합해서 사용할 수 있다.

- 매퍼 구현체를 사용하면 스프링 예외 추상화도 함께 적용된다.

- 마이바티스 스프링 연동 모듈이 많은 부분을 자동으로 설정해주는데, 데이터베이스 커넥션, 트랜잭션과 관련된 기능도 마이바티스와 함께 연동하고, 동기화해준다.

 

 

 

MyBatis 기능 정리1 - 동적 쿼리

MyBatis에서 자주 사용하는 주요 기능을 공식 메뉴얼이 제공하는 예제를 통해 간단하게 정리해보자.

 

동적 SQL

마이바티스가 제공하는 최고의 기능이자 마이바티스를 사용하는 이유는 바로 동적 SQL 기능 때문이다.

동적 쿼리를 위해 제공되는 기능은 다음과 같다.

- if

- choose (when, otherwise)

- trim (where, set)

- foreach

 

공식 메뉴얼에서 제공하는 예제를 통해 동적 SQL을 알아보자.

 

if

<select id="findActiveBlogWithTitleLike" resultType="Blog">
    SELECT * FROM BLOG
    WHERE state = ‘ACTIVE’
    <if test="title != null">
        AND title like #{title}
    </if>
</select>

- 해당 조건에 따라 값을 추가할지 말지 판단한다.

- 내부의 문법은 OGNL을 사용한다.

* OGNL(Object-Graph Navigation Language) : 자바 객체의 프로퍼티에 접근하고 조작하기 위해 사용되는 표현언어

 

choose, when, otherwise

<select id="findActiveBlogLike" resultType="Blog">
    SELECT * FROM BLOG WHERE state = ‘ACTIVE’
    <choose>
        <when test="title != null">
            AND title like #{title}
        </when>
        <when test="author != null and author.name != null">
            AND author_name like #{author.name}
        </when>
        <otherwise>
            AND featured = 1
        </otherwise>
    </choose>
</select>

- 자바의 switch 구문과 유사한 구문도 확인할 수 있다.

 

trim, where, set

<select id="findActiveBlogLike" resultType="Blog">
    SELECT * FROM BLOG
    WHERE
    <if test="state != null">
        state = #{state}
    </if>
    <if test="title != null">
        AND title like #{title}
    </if>
    <if test="author != null and author.name != null">
        AND author_name like #{author.name}
    </if>
</select>

 

--> 위 예제의 문제점은 문장을 모두 만족하지 않거나, title만 만족할 때 문제가 발생한다.

 

결국 where 문을 언제 넣어야 할지 상황에 따라서 동적으로 달라지는 문제가 있다.

<where>를 사용하면 이런 문제를 해결할 수 있다.

 

<where>사용

<select id="findActiveBlogLike" resultType="Blog">
    SELECT * FROM BLOG
    <where>
        <if test="state != null">
            state = #{state}
        </if>
        <if test="title != null">
            AND title like #{title}
        </if>
        <if test="author != null and author.name != null">
            AND author_name like #{author.name}
        </if>
    </where>
</select>

<where>는 문장이 없으면 where를 추가하지 않는다. 문장이 있으면 where를 추가한다. 만약 and가 먼저 시작되면 and를 지운다.

 

반응형