Mybatis Dynamic SQL 를 써보았다

Mybatis Dynamic SQL 를 써보았다

[패스트캠퍼스X야놀자: 백엔드 부트캠프 1기]

·

5 min read

두번째 미션이 주어졌다.
KakaoAPI에서 검색한 도서 정보를 가져와 DB에 저장 후 목록을 출력하는 내용이다

단순 JDBC로 구현할려다가 간만에 SqlMapper 가 써보고 싶어져서 mybatis를 찾게 되었다.

문서탐방을 하면서 알게 된 건데, 생각이상으로 많이 발전되어 있었다.
ORM(hibernate)를 인식하는 것 같았다(주관입니다).

MyBatis Dynamic SQL – MyBatis Dynamic SQL

mybatis/mybatis-dynamic-sql: SQL DSL (Domain Specific Language) for Kotlin and Java. Supports rendering for MyBatis or Spring JDBC Templates (github.com)

요약하면 Mybatis도 Generator, DSL 등의 지원으로 QueryDSL 처럼 Type-Safe 한 코드를 작성할 수 있게 되었다.

mybatis에서 예제코드도 제공해준다
mybatis-dynamic-sql/src/test/java/examples/animal/data at master · mybatis/mybatis-dynamic-sql (github.com)

준비

build.gradle

dependencies {
    implementation 'org.mybatis:mybatis:+'
    implementation 'org.mybatis.dynamic-sql:mybatis-dynamic-sql:+'
    implementation 'org.mybatis.generator:mybatis-generator-core:+'
    implementation 'jakarta.annotation:jakarta.annotation-api:+'
    implementation 'mysql:mysql-connector-java:+'
}

Gradle 프로젝트로 수행할 것이라 Mybatis 위주로 불러왔다. Generator 가 있어서 jakarta 도 가져왔다.

generatorConfig.xml

<!DOCTYPE generatorConfiguration PUBLIC
        "-//mybatis.org//DTD MyBatis Generator Configuration 1.0//EN"
        "http://mybatis.org/dtd/mybatis-generator-config_1_0.dtd">
<generatorConfiguration>
    <context id="KDT_Y_BE_Java_Assignment2" targetRuntime="MyBatis3DynamicSql">
        <commentGenerator>
            <property name="suppressAllComments" value="true"/>
        </commentGenerator>
        <jdbcConnection driverClass="com.mysql.cj.jdbc.Driver"
                        connectionURL="[JDBC url]"
                        userId="[DB username]"
                        password="[DB password]"/><javaModelGenerator targetPackage="fc.assignment.domain.model" targetProject="src/main/java"/>
        <javaClientGenerator targetPackage="fc.assignment.domain.mapper" targetProject="src/main/java"
                             type="ANNOTATEDMAPPER"/><table schema="defaultdb" tableName="Book"/>
    </context>
</generatorConfiguration>

Querydsl이 gradle 세팅 후 build 를 통해 자동으로 생성하듯이 Mybatis도 지원한다. 예제 자료는 XML이라. Gradle로 스크립트 짜서 XML 파일을 실행하도록 구현할 수 있어 보인다.

MyBatisGeneratorExecutor

import org.mybatis.generator.api.MyBatisGenerator;
import org.mybatis.generator.config.xml.ConfigurationParser;
import org.mybatis.generator.internal.DefaultShellCallback;
​
import java.io.File;
import java.util.ArrayList;
​
public class MyBatisGeneratorExecutor {
    public static void main(final String[] args) throws Exception {
        final var warnings = new ArrayList<String>();
        final var configuration = new ConfigurationParser(warnings)
                .parseConfiguration(new File("src/main/resources/generatorConfig.xml"));
        final var defaultShellCallback = new DefaultShellCallback(true);
​
        final var myBatisGenerator = new MyBatisGenerator(configuration, defaultShellCallback, warnings);
        myBatisGenerator.generate(null);
    }
}

예제 코드를 참고해서 작성해봤다. 이코드를 실행하면 jOOQ 처럼 자동으로 DB에 접근하여 generatorConfig.xml 에서 지정한 테이블을 확인 후 자동으로 자바 코드를 생성해준다.

생성되는 파일은 아래와 같다

  • Table

  • TableDynamicSqlSupport

  • TableMapper

엔티티? 라고 해야 하나. 저거를 record 타입으로 별도로 관리하려 했는데, 코드가 실행이 안되었다. 로그를 살펴보니 Mybatis가 내부적으로 getter/setter를 사용했었다. 스샷 찍어둘껄...껄껄

DynamicSqlSupport는 우리가 ORM에서 자주보던 QClass역할(jOOQ에서는 JClass)과 비슷하다 . 칼럼 지정할때 쓰인다.

Mapper 는 인터페이스 형태로 제공되며, 우리가 주로 사용될 쿼리문들이 자동으로 생성된다. Mybatis3에서 쓰던 @Select, @Update 와 같은 애너테이션도 지원해서 TableMapperCustom 이런식으로 인터페이스 만들어서 상속받으면 된다.

이런기능이 있었다니... DBA분들이 잘 설계해놓은 db를 토대로 리뉴얼 해야 할 때 도움이 되지 않을까 싶다.

DAO

Mybatis 와 동일하게 SqlSessionFactory 를 이용해서 구현할 수 있다. 아래 코드처럼 프로그램 시작시 쿼리문을 주입해서 쓸 수 있다. 가장 눈에 띄는 것은 뭐... 요런거? type-safe 하게 작성할 수 있게 되었다는 점이다.

SelectStatementProvider selectStatement = select(BookMapper.selectList)
          .from(book)
          .limit(10)
          .build()
          .render(RenderingStrategies.MYBATIS3);
package fc.assignment.domain;
​
import fc.assignment.domain.mapper.BookMapper;
import fc.assignment.domain.model.Book;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.apache.ibatis.jdbc.ScriptRunner;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.apache.ibatis.session.SqlSessionFactoryBuilder;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.mybatis.dynamic.sql.render.RenderingStrategies;
import org.mybatis.dynamic.sql.select.render.SelectStatementProvider;
​
import java.io.InputStream;
import java.io.InputStreamReader;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.List;
​
import static fc.assignment.domain.mapper.BookDynamicSqlSupport.book;
import static org.mybatis.dynamic.sql.SqlBuilder.select;
​
public class BookRepository {
​
    private final String MYSQL_URL = "jdbc:" + System.getenv("MYSQL_URL");
    private final String MYSQL_USERNAME = System.getenv("MYSQL_USERNAME");
    private final String MYSQL_PASSWORD = System.getenv("MYSQL_PASSWORD");
    private final SqlSessionFactory sqlSessionFactory;
​
    {
        InputStream inputStream = getClass().getClassLoader().getResourceAsStream("initBook.sql");
        assert inputStream != null;
​
        try (Connection connection = DriverManager.getConnection(MYSQL_URL, MYSQL_USERNAME, MYSQL_PASSWORD)) {
            new ScriptRunner(connection).runScript(new InputStreamReader(inputStream));
        } catch (SQLException e) {
            throw new RuntimeException("테이블 생성 쿼리문 수행 중 오류가 발생했습니다", e);
        }
​
        String JDBC_DRIVER = "com.mysql.cj.jdbc.Driver";
        UnpooledDataSource ds = new UnpooledDataSource(JDBC_DRIVER, MYSQL_URL, MYSQL_USERNAME, MYSQL_PASSWORD);
        Environment environment = new Environment("test", new JdbcTransactionFactory(), ds);
        Configuration config = new Configuration(environment);
        config.addMapper(BookMapper.class);
        sqlSessionFactory = new SqlSessionFactoryBuilder().build(config);
    }
​
    public List<Book> insertBooksAndObtainStoredBookInfo(List<Book> books) {
        insertBooks(books);
​
        return selectBooks();
    }
​
    private List<Book> selectBooks() {
        List<Book> books;
        try (SqlSession sqlSession = sqlSessionFactory.openSession(true)) {
            SelectStatementProvider selectStatement = select(BookMapper.selectList)
                    .from(book)
                    .limit(10)
                    .build()
                    .render(RenderingStrategies.MYBATIS3);
​
            books = sqlSession.getMapper(BookMapper.class)
                    .selectMany(selectStatement);
        }
​
        return books;
    }
​
    private void insertBooks(List<Book> books) {
        try (SqlSession sqlSession = sqlSessionFactory.openSession(true)) {
            sqlSession.getMapper(BookMapper.class)
                    .insertMultiple(books);
        }
    }
}

Mybatis 가 전통성이 있다보니 윈도우 함수, 서브 쿼리 등 풍부하게 지원한다. Querydsl 에서는 위의 기능에 제한이 있어 SqlQueryFactorySQLExpressions 를 이용해야 한다. 그렇다. 위의 클래스는 JPAQueryFactory가 아니다.

때문에 네이티브 쿼리나 JdbcTemplate, blaze-persistence 를 사용해야 한다.
Querydsl 기여자분이 blazebit 에서 활동하시는 것 같았다.
querydsl 이 2021년 이후 패치가 멈춘 이유가 이건가?

jwgmeligmeyling (Jan-Willem Gmelig Meyling) (github.com)
간단한 질문이 있어 질문 남깁니다. - 인프런 | 질문 & 답변 (inflearn.com)

쿼리가 잘 수행되었다.

마치며

뭐... 이렇게 Mybatis로도 이정도 까지 할수 있게 되었다! 라고 말은 하긴 했는데, 개발자 준비하시는 분들은 sql Mapper 이야기를 꺼내면

에에엥?? 아직도 그런거 쓰세요? ORM이 편한데...😱
오래된 데다가 XML 써야 되잖아요.🤔

라고 대화한 것을 들은 적이 있었다.

하지만 우리가 개발에 투입되는 곳은 신규 플랫폼을 개발하지 않는 이상 기 서비스를 유지보수, 기능 추가 등 부분적으로 진행될 것이고 DB도 이전 DBA가 잘 설계해놓은 것을 활용할 가능성이 크다. 필자도 xml은 선호하지 않는다.

ORM은 만능이 아니다. 필요에 따라 ORM, SQL Mapper 둘다 쓸 일이 있다고 생각한다.

근데... jOOQ 로 다 할 수 있지 않나?