Spring/MyBatis

MyBatis-Spring-Boot-Starter > 멀티 모듈 프로젝트에서 다양한 방식으로 사용해보기

Krevis 2024. 4. 18. 09:20

 

동기

새 Spring Boot 프로젝트를 만들어 MyBatis-Spring-Boot-Starter을 적용할 때는 쉬운데, Spring MVC 프로젝트를 부트로 전환할 때 기본기가 부족해서 그런지 잘 되지 않아 학습 프로젝트를 만들어가며 이해해보려고 한다

 

 

사용 기술

  • Gradle
  • Java 8
  • Spring Boot 2
    • 여기서는 2.7.18 사용
  • H2 DB
    • 내장 DB를 사용해 테스트
  • mybatis-spring-boot-autoconfigure

 

 

mybatis-spring-boot-autoconfigure 적용

https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure

 

사용 버전에 따라 MyBatis-Spring, Spring Boot 버전이 결정되니 적절히 선택

 

여기서는 부트 2.7 지원을 위해 2.3 버전을 사용한다

https://mvnrepository.com/artifact/org.mybatis.spring.boot/mybatis-spring-boot-autoconfigure

 

기능

  • 보일러플레이트를 거의 0으로 줄일 수 있다
  • 더 적은 XML 설정

설치

build.gradle

...
dependencies {
    ...

    implementation "org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.2"
}
...

빠른 설정

스프링과 함께 MyBatis를 사용하려면 최소 1개의 SqlSessionFactory와 최소 1개의 Mapper 인터페이스만 있으면 된다

 

MyBatis-Spring-Boot-Starter가 해주는 것

  • 스프링 컨텍스트에 빈으로 등록된 DataSource 자동 감지
  • SqlSessionFactoryBean을 사용해 SqlSessionFactory 인스턴스를 생성하고 빈으로 등록
  • SqlSessionFactory에서 SqlSessionTemplate 인스턴스를 생성하고 빈으로 등록
  • 마이바티스 매퍼를 자동 검사하여 SqlSessionTemplate에 연결하고 스프링 컨텍스트에 등록하여 빈에 주입할 수 있게 함
    • 스프링 빈에서 SqlSession 타입으로 DI 받을 수 있다

 

 

멀티 모듈 프로젝트 만들기

프로젝트

  • shared 모듈
    • 각 모듈에서 공통으로 사용할 것들이 위치
      • 공통으로 사용될 도메인 모델, 매퍼 인터페이스 등이 위치
  • app 모듈
    • 스프링 부트 웹 애플리케이션

 

 

app 모듈 내 매퍼 XML 파일 사용 테스트

app 모듈 내에 매퍼 인터페이스와 매퍼 XML을 만들어 사용해보자

 

우선 User 테이블을 만들고 데이터를 넣어보자

 

app/src/main/resources 아래에 아래 파일들을 만든다

 

schema.sql

create table "User" (
  seq bigint auto_increment,
  name varchar(10),
  primary key (seq)
);

H2 DB에서 user가 키워드이기 때문에 H2 문법인 쌍따옴표로 감쌈

 

data.sql

insert into "User" (name) values ('사용자1');

 

User.java

package learn.shared.user.domain.model;

import lombok.Getter;

@Getter
@ToString
public class User {

    private Long seq;

    private String name;
}

 

AppUserMapper.java

package learn.app.user.domain.repository;

import learn.shared.user.domain.model.User;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AppUserMapper {

    User findById(long seq);
}

 

app/src/main/resoruces/mapper/AppUserMapper.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="learn.app.user.domain.repository.AppUserMapper">

  <select id="findById" resultType="User">
    select seq
         , name
    from "User"
    where seq = #{seq}
  </select>
</mapper>

 

AppUserMapperTest.java

package learn.shared.user.domain.repository;

import learn.app.AppApplication;
import learn.app.user.domain.repository.AppUserMapper;
import learn.shared.user.domain.model.User;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.context.ApplicationContext;

@SpringBootTest(classes = AppApplication.class)
class AppUserMapperTest {

    @Autowired
    ApplicationContext applicationContext;

    @Autowired
    AppUserMapper appUserMapper;

    @Test
    void printSpringBeans() {
        for (String beanDefinitionName : applicationContext.getBeanDefinitionNames()) {
            System.out.println(beanDefinitionName);
        }
        /**
         * dataSource
         * sqlSessionFactory
         * sqlSessionTemplate
         * appUserMapper
         */
    }

    @Test
    void findById() {
        User user = appUserMapper.findById(1);
        System.out.println(user);
    }
}

 

findById 테스트는 실패했다

org.apache.ibatis.binding.BindingException: Invalid bound statement (not found): learn.app.user.domain.repository.AppUserMapper.findById

 

appUserMapper 빈은 등록되었지만 XML에서 SQL Statement를 찾지 못한 것 같다

 

기본적으로 XML 매퍼 파일의 경로는 클래스 패스 아래 mapper 폴더에서 .xml 확장자를 찾는데, 아직 잘 모르겠지만 어떤 이유로 정상적으로 읽지 못한 것 같다

 

application.yml 파일에 마이바티스 설정을 추가해보자

mybatis:
  mapper-locations: classpath*:/mapper/**/*.xml

 

위와 같이 Ant 패턴을 사용할 수 있다

 

테스트를 재수행하니 에러 메시지가 바뀌었다

Caused by: org.apache.ibatis.type.TypeException: Could not resolve type alias 'User'.  Cause: java.lang.ClassNotFoundException: Cannot find class: User

 

User라는 타입 별칭을 찾을 수 없다는 에러인데, 매퍼 XML 파일에 resultType 값을 FQCN으로 설정하지 않았었다. 사실 안 되는 게 당연한 거다. 가장 간단히 해결하기 위해서는 FQCN으로 설정하면 될 것이다. 하지만 이런 방식은 사양한다

 

application.yml

mybatis:
  ...
  type-aliases-package: learn.shared.user.domain.model

위와 같이 타입 별칭을 찾을 패키지를 설정할 수 있다. 앤트 패턴은 지원하지 않는다

 

이제 테스트가 성공하게 된다

 

 

모듈 간 매퍼 사용 테스트

현시점에 app 모듈에서 shared 모듈의 매퍼를 사용할 수 있을까?

 

아쉽게도 불가하다. 기본적으로 @SpringBootApplication 애너테이션이 붙은 클래스가 있는 패키지 하위의 빈만 자동 스캔 대상이기 때문에 shared 모듈의 매퍼는 스캔되지 않는다. 이를 위해 @MapperScan을 사용할 수 있다

 

AppMyBatisConfig.java

package learn.app.config.db;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@MapperScan(basePackages = "learn.shared.*.domain.repository")
public class AppMyBatisConfig {

}

 

basePackages 값은 위처럼 앤트 패턴 사용 가능하다. 배열이기 때문에 문자열 배열로 설정 가능하다 (또는 하나의 문자열 안에서 콤마, 세미콜론으로 복수 설정도 가능하다)

 

learn.app 패키지내 매퍼는 설정하지 않아도 스캔되었다 (스프링 빈 자동 스캔으로 검색이 되는 듯?)

 

 

SqlSession 인스턴스를 사용하는 DAO 사용하기

레거시 프로젝트의 경우 매퍼를 쓰는 것보다 이렇게 사용하는 경우가 많았다

 

AppUserDao.java

package learn.app.user.domain.repository;

import learn.shared.user.domain.model.User;
import lombok.RequiredArgsConstructor;
import org.apache.ibatis.session.SqlSession;
import org.springframework.stereotype.Repository;

@Repository
@RequiredArgsConstructor
public class AppUserDao {

    private final SqlSession sqlSession;

    public User findById(long seq) {

        String statement = getClass().getCanonicalName() + ".findById";

        return sqlSession.selectOne(statement, seq);
    }
}

 

app/src/main/resoruces/mapper/AppUserDao.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="learn.app.user.domain.repository.AppUserDao">

  <select id="findById" resultType="User">
    select seq
         , name
    from "User"
    where seq = #{seq}
  </select>
</mapper>

 

 

SqlSessionDaoSupport는 뭘까?

SqlSession을 제공하는 추상 지원 클래스다

 

보통 추가 코드를 필요로 하지 않기 때문에 MapperFactoryBean의 사용이 선호되는데, DAO내에서 마이바티스와 무관한 작업을 하거나 구체(Concrete) 클래스를 필요로 할 때 유용하다고 한다

 

getSqlSession() 메서드를 호출하여 SqlSessionTemplate 인스턴스를 얻을 수 있다

 

SqlSessionDaoSupport는 SqlSessionFactory를 필요로 하기 때문에, SqlSessionDaoSupport를 상속받은 클래스는 SqlSessionFactory를 DI해줘야 한다

 

AppUserDao.java

@Repository
public class AppUserDao extends SqlSessionDaoSupport {

    @Override
    @Autowired
    public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
        super.setSqlSessionFactory(sqlSessionFactory);
    }
...

 

DAO마다 위 코드를 추가해야하고 매퍼가 아닌 SqlSession을 사용하면 statement를 직접 입력해야하는 번거로움이 있다. 알다사피 문자열 타이핑은 버그를 유발하기 딱 좋다. 이를 자동화해보자

 

SqlSessionDaoSupport를 상속받는 추상 클래스를 만든다

 

CustomDaoSupport.java

package learn.shared.config.mybatis;

import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.support.SqlSessionDaoSupport;
import org.springframework.beans.factory.annotation.Autowired;

public abstract class CustomDaoSupport extends SqlSessionDaoSupport {

    @Override
    @Autowired
    public void setSqlSessionFactory(SqlSessionFactory sqlSessionFactory) {
        super.setSqlSessionFactory(sqlSessionFactory);
    }

    public String getStatement() {
        String fqcn = getClass().getCanonicalName();
        String methodName = Thread.currentThread().getStackTrace()[2].getMethodName();

        return fqcn + "." + methodName;
    }
}

 

 

AppUserDao.java

package learn.app.user.domain.repository;

import learn.shared.config.mybatis.CustomSqlSessionDaoSupport;
import learn.shared.user.domain.model.User;
import org.springframework.stereotype.Repository;

@Repository
public class AppUserDao extends CustomDaoSupport {

    public User findById(long seq) {

        return getSqlSession().selectOne(getStatement(), seq);
    }
}

DAO 코드가 훨씬 간결해졌다

 

 

SqlSessionTemplate을 2개 이상 사용하기 위해 직접 빈으로 등록해야 한다면?

https://mybatis.org/spring/sqlsession.html

 

마이바티스에서는 SqlSession을 만들기 위해 SqlSessionFactory를 사용한다

 

SqlSessionTemplate은 SqlSession을 구현한다. 위에서 사용한 SqlSession의 구현체는 SqlSessionTemplate이다. 다형성을 위해 SqlSession 참조변수를 사용한 것 뿐이다

 

AppMyBatisConfig.java

...
    @Bean
    public SqlSessionTemplate sqlSession(SqlSessionFactory sqlSessionFactory) {

        return new SqlSessionTemplate(sqlSessionFactory);
    }
...

위와 같이 SqlSessionTemplate 빈을 직접 등록할 수 있다

 

SqlSessionTemplate 클래스의 생성자는 3개이다

 

실행자 유형

  • SIMPLE (기본)
  • REUSE
  • BATCH

 

이를 활용하면 실행자 유형이 다른 SqlSessionTemplate을 만들어 필요에 따라 선택해 사용할 수 있을 것이다

 

 

2개 이상의 DB를 사용해야 한다면?

우선 DataSource를 여러 개 설정해야 한다

 

application.yml

spring:
  datasource:
    hikari:
      user:
        driver-class-name: org.h2.Driver
        jdbc-url: jdbc:h2:mem:user-db
        username: sa
        password:
      order:
        driver-class-name: org.h2.Driver
        jdbc-url: jdbc:h2:mem:order-db
        username: sa
        password:

 

사용자 DB와 주문 DB가 있다고 가정

스프링 부트는 기본적으로 DBCP로 HikariCP를 사용하기 때문에 그에 맞게 설정을 변경한다

기존에 추가했던 mybatis 설정은 제거하자(더이상 설정이 적용되지 않음)

 

SharedDbConfig.java

package learn.shared.config.db;

import javax.sql.DataSource;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.jdbc.DataSourceBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;

@Configuration
public class SharedDbConfig {

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.user")
    public DataSource dataSourceForUser() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    @ConfigurationProperties(prefix = "spring.datasource.hikari.order")
    public DataSource dataSourceForOrder() {
        return DataSourceBuilder.create().build();
    }

    @Bean
    public DataSourceTransactionManager transactionManagerForUser(DataSource dataSourceForUser) { // 같은 클래스 내에 빈이 있기 때문에 @Qualifier 불필요

        return new DataSourceTransactionManager(dataSourceForUser);
    }

    @Bean
    public DataSourceTransactionManager transactionManagerForOrder(DataSource dataSourceForOrder) {

        return new DataSourceTransactionManager(dataSourceForOrder);
    }
}

 

데이터 소스별로 트랜잭션을 관리해야 하므로 트랜잭션 매니저도 각각 설정해야 한다

 

AppConfig.java

package learn.app.config;

import learn.shared.config.db.SharedDbConfig;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import(SharedDbConfig.class)
public class AppConfig {

}

 

app 모듈에서 shared 모듈의 설정을 가져오기 위해 설정 파일을 별도로 만듦

 

데이터 소스가 잘 설정되었는지 테스트 해보자

 

DataSourceTest.java

package learn.shared;

import static org.assertj.core.api.Assertions.assertThat;

import com.zaxxer.hikari.HikariDataSource;
import learn.app.AppApplication;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest(classes = AppApplication.class)
class DataSourceTest {

    @Autowired
    HikariDataSource dataSourceForUser;

    @Autowired
    HikariDataSource dataSourceForOrder;

    @Test
    void multiDataSources() {
        assertThat(dataSourceForUser.getJdbcUrl()).isEqualTo("jdbc:h2:mem:user-db");
        
        assertThat(dataSourceForOrder.getJdbcUrl()).isEqualTo("jdbc:h2:mem:order-db");
    }
}

 

이제 주문 도메인을 만들어보자

 

Order.java

package learn.shared.order.domain.model;

import java.time.LocalDateTime;
import lombok.Getter;
import lombok.ToString;

@Getter
@ToString
public class Order {

    private Long seq;

    private String name;

    private LocalDateTime createdAt;
}

 

AppOrderMapper.java

package learn.app.order.domain.repository;

import java.util.List;
import learn.shared.order.domain.model.Order;
import org.apache.ibatis.annotations.Mapper;

@Mapper
public interface AppOrderMapper {

    List<Order> findAll();
}

 

app/src/main/resoruces/mybatis/mapper/order/AppOrderMapper.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="learn.app.order.domain.repository.AppOrderMapper">

  <select id="findAll" resultType="Order">
    select seq
         , name
         , createdAt
    from "Order"
  </select>
</mapper>

order도 키워드 이기 때문에 쌍따옴표로 감쌌다

 

이제 준비는 다 되었다

 

내장DB를 사용하면 애플리케이션 실행 시 기본적으로 schema.sql, data.sql을 자동으로 실행해주는데, 이제 DB가 2개이다 보니 별도로 설정해줘야 한다

 

app/src/main/resources에 init-sql라는 폴더를 만들자. 이름은 아무 상관이 없다

 

app/src/main/resources에 init-sql/order-schema.sql

create table "Order" (
  seq bigint auto_increment,
  name varchar(10),
  createdAt timestamp,
  primary key (seq)
);

 

app/src/main/resources에 init-sql/order-data.sql

insert into "Order" (name, createdAt) values ('주문1', CURRENT_TIMESTAMP());
insert into "Order" (name, createdAt) values ('주문2', CURRENT_TIMESTAMP());

 

DataSourceInitializerConfig.java

package learn.app.config.db;

import javax.sql.DataSource;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.datasource.init.DataSourceInitializer;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;

@Configuration
public class DataSourceInitializerConfig {

    @Bean
    public DataSourceInitializer dataSourceInitializerForUser(@Qualifier("dataSourceForUser") DataSource dataSource) {

        ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator();
        databasePopulator.addScript(new ClassPathResource("init-sql/user-schema.sql"));
        databasePopulator.addScript(new ClassPathResource("init-sql/user-data.sql"));

        DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDataSource(dataSource);
        initializer.setDatabasePopulator(databasePopulator);
        return initializer;
    }

    @Bean
    public DataSourceInitializer dataSourceInitializerForOrder(@Qualifier("dataSourceForOrder") DataSource dataSource) {

        ResourceDatabasePopulator databasePopulator = new ResourceDatabasePopulator();
        databasePopulator.addScript(new ClassPathResource("init-sql/order-schema.sql"));
        databasePopulator.addScript(new ClassPathResource("init-sql/order-data.sql"));

        DataSourceInitializer initializer = new DataSourceInitializer();
        initializer.setDataSource(dataSource);
        initializer.setDatabasePopulator(databasePopulator);
        return initializer;
    }
}

 

이제 DB별로 스크립트가 나뉘어 실행된다

 

이제 하나의 문제가 남았다. CustomDaoSupprot에 SqlSessionFactory 빈을 주입해야하는데 2개의 타입이 존재하여 DI가 실패한다. 따라서 DB별 SqlSessionFactory 프라퍼티를 가지도록 수정이 필요하다

 

CustomDaoSupport.java

...
public abstract class CustomDaoSupport extends DaoSupport {

    private SqlSessionFactory sqlSessionFactoryForUser;

    private SqlSessionFactory sqlSessionFactoryForOrder;

    @Resource(name = "sqlSessionFactoryForUser")
    public void setSqlSessionFactoryForUser(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactoryForUser = sqlSessionFactory;
    }

    @Resource(name = "sqlSessionFactoryForOrder")
    public void setSqlSessionFactoryForOrder(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactoryForOrder = sqlSessionFactory;
    }
...

 

테스트를 실행하면 아래 에러가 발생한다

Caused by: java.lang.IllegalArgumentException: Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required

 

SqlSessionDaoSupport 클래스의 checkDaoConfig() 메서드를 확인해보면 아래와 같이 되어 있다

notNull(this.sqlSessionTemplate, "Property 'sqlSessionFactory' or 'sqlSessionTemplate' are required");

 

여기서는 자동 구성된 기본 빈을 사용하지 않기 때문에 해당 프라퍼티의 널 확인을 무시하도록 하자

 

CustomDaoSupport.java

...
    @Override
    protected void checkDaoConfig() {
        // Nothing to do
    }
...

 

그리고 SqlSessionDaoSupport 클래스는 데이터 소스가 하나인 경우의 코드이므로 그에 따른 getSqlSessionFactory(), getSqlSession() 메서드를 제공한다. 따라서 CustomDaoSupport를 상속받은 클래스가 그 메서드들을 사용하면 NPE가 발생할 것이다

 

이쯤 되니 SqlSessionDaoSupport 클래스를 쓰면 안 될 거 같다. 이를 참고해 CustomDaoSupport를 변경해야겠다. SqlSessionDaoSupport 클래스는 DaoSupport 클래스를 상속받고 있다. 이 클래스를 상속받아 CustomDaoSupport를 바꿔보자

 

CustomDaoSupport.java

package learn.shared.config.mybatis;

import javax.annotation.Resource;
import lombok.Getter;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.session.SqlSessionFactory;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.dao.support.DaoSupport;
import org.springframework.util.Assert;

public abstract class CustomDaoSupport extends DaoSupport {

    private SqlSessionFactory sqlSessionFactoryForUser;

    private SqlSessionFactory sqlSessionFactoryForOrder;

    @Getter
    private SqlSession sqlSessionForUser;

    @Getter
    private SqlSession sqlSessionForOrder;

    @Resource(name = "sqlSessionFactoryForUser")
    public void setSqlSessionFactoryForUser(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactoryForUser = sqlSessionFactory;
    }

    @Resource(name = "sqlSessionFactoryForOrder")
    public void setSqlSessionFactoryForOrder(SqlSessionFactory sqlSessionFactory) {
        this.sqlSessionFactoryForOrder = sqlSessionFactory;
    }

    public String getStatement() {
        String fqcn = getClass().getCanonicalName();
        String methodName = Thread.currentThread().getStackTrace()[2].getMethodName();

        return fqcn + "." + methodName;
    }

    @Override
    protected void checkDaoConfig() {
        Assert.notNull(sqlSessionFactoryForUser, "Property 'sqlSessionFactoryForUser' is required");
        Assert.notNull(sqlSessionFactoryForOrder, "Property 'sqlSessionFactoryForOrder' is required");
    }

    @Override
    protected void initDao() {
        sqlSessionForUser = new SqlSessionTemplate(sqlSessionFactoryForUser);
        sqlSessionForOrder = new SqlSessionTemplate(sqlSessionFactoryForOrder);
    }
}

 

AppUserDao.java

package learn.app.user.domain.repository;

import learn.shared.config.mybatis.CustomDaoSupport;
import learn.shared.user.domain.model.User;
import org.springframework.stereotype.Repository;

@Repository
public class AppUserDao extends CustomDaoSupport {

    public User findById(long seq) {

        return getSqlSessionForUser().selectOne(getStatement(), seq);
    }
}

AppUserDao도 도메인에 맞는 SqlSession을 가져와서 사용하도록 수정했다

 

 

전체 코드

https://github.com/venzersiz/learn-mybatis-spring-boot-autoconfigure

 

 

참고