마이바티스 스프링 부트 통합 및 플러그인 메커니즘 심층 분석

마이바티스 스프링 부트 통합 및 플러그인 메커니즘

1. 서론

마이바티스(MyBatis)는 매우 유연한 지속성 프레임워크로, JDBC를 내부적으로 캡슐화하여 개발자가 SQL 문 자체에만 집중할 수 있게 합니다. 드라이버 로딩, 연결 생성, Statement 생성 등 복잡한 과정에 신경 쓸 필요가 없습니다. 풍부한 설정 옵션과 강력한 SQL 매핑 기능을 제공할 뿐만 아니라, 플러그인 메커니즘을 지원하여 SQL 실행 라이프사이클에 맞춰 사용자 정의 로직을 삽입할 수 있습니다. 본 문서에서는 스프링 부트 프로젝트에서 마이바티스를 통합하는 방법과 마이바티스 플러그인 메커니즘의 원리와 실제 적용 사례를 상세히 분석합니다.

2. 스프링 부트에서 마이바티스 통합

2.1 데이터베이스 준비

본 예제에서는 MySQL 5.7 데이터베이스를 사용합니다.

create database if not exists mybatis_integration;

use mybatis_integration;

create table member(
    id int unsigned primary key auto_increment comment '식별자',
    name varchar(100) comment '이름',
    age tinyint unsigned comment '나이',
    gender tinyint unsigned comment '성별, 1:남성, 2:여성',
    phone varchar(11) comment '전화번호'
) comment '회원 정보 테이블';

insert into member(id, name, age, gender, phone) VALUES (null,'양백응왕',55,'1','18800000001');
insert into member(id, name, age, gender, phone) VALUES (null,'금모사왕',45,'1','18800000002');
insert into member(id, name, age, gender, phone) VALUES (null,'청응왕',38,'1','18800000003');
insert into member(id, name, age, gender, phone) VALUES (null,'자산용왕',42,'2','18800000004');
insert into member(id, name, age, gender, phone) VALUES (null,'광명좌사',37,'1','18800000005');
insert into member(id, name, age, gender, phone) VALUES (null,'광명우사',48,'1','18800000006');

2.2 의존성 설정

필요한 Maven 의존성을 pom.xml에 추가합니다. 부모 프로젝트는 스프링 부트 부모 프로젝트를 상속받습니다. MySQL 드라이버, 마이바티스 스타터, 웹 스타터, Lombok 애너테이션이 포함됩니다.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <artifactId>spring-boot-starter-parent</artifactId>
        <groupId>org.springframework.boot</groupId>
        <version>2.7.10</version>
    </parent>
    <groupId>com.example</groupId>
    <artifactId>mybatis-integration</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>MyBatisIntegration</name>
    <description>스프링 부트와 마이바티스 통합 예제</description>
    <properties>
        <java.version>1.8</java.version>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>
    <dependencies>
        <!-- 마이바티스 코어 라이브러리 -->
        <dependency>
            <groupId>org.mybatis</groupId>
            <artifactId>mybatis</artifactId>
            <version>3.4.6</version>
        </dependency>
        <!-- 스프링 부트 웹 스타터 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <!-- 스프링 부트 테스트 스타터 -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- JUnit 테스팅 프레임워크 -->
        <dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <scope>test</scope>
        </dependency>
        <!-- Lombok 애너테이션 -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.20</version>
        </dependency>
        <!-- 마이바티스 스프링 부트 스타터 -->
        <dependency>
            <groupId>org.mybatis.spring.boot</groupId>
            <artifactId>mybatis-spring-boot-starter</artifactId>
            <version>2.1.3</version>
        </dependency>
        <!-- MySQL 커넥터 -->
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>5.1.38</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.8.1</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                    <encoding>UTF-8</encoding>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

2.3 애플리케이션 설정

application.yml 파일에서 데이터베이스 연결 정보를 설정합니다. 포트 번호는 8081로 지정합니다.

spring:
  datasource:
    driver-class-name: com.mysql.jdbc.Driver
    url: jdbc:mysql://localhost:3306/mybatis_integration?useSSL=false&serverTimezone=UTC
    username: root
    password: password
server:
  port: 8081

mybatis:
  # 마이바티스 전역 설정 파일 경로
  config-location: classpath:mybatis-config.xml
# 개발 환경에서 콘솔 로그 출력
logging:
  level:
    com.example.mybatisintegration.mapper:
      debug

2.4 계층별 구현

마이바티스는 XML 설정과 애너테이션 방식 두 가지로 데이터베이스 작업을 구현할 수 있습니다. 먼저 두 방식에서 공통으로 사용되는 코드를 살펴보겠습니다.

2.4.1 도메인 모델

데이터베이스 테이블의 필드를 매핑할 엔티티 클래스를 생성합니다.

package com.example.mybatisintegration.model;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

@Data
@NoArgsConstructor // 매개변수 없는 생성자
@AllArgsConstructor // 모든 매개변수를 받는 생성자
public class Member {

    private Integer id;
    private String name;
    private Short age;
    private Short gender;
    private String phone;

}

2.4.2 서비스 계층

서비스 인터페이스를 정의합니다.

package com.example.mybatisintegration.service;

import com.example.mybatisintegration.model.Member;

import java.util.List;

public interface MemberService {

    Member fetchMemberById(Integer id);
    
    List<Member> fetchAllMembers();
    
    String removeMemberById(Integer id);
    
    String registerMember(Member member);
    
    String modifyMember(Member member);
    
}

서비스 구현체를 작성합니다.

package com.example.mybatisintegration.service.impl;

import com.example.mybatisintegration.mapper.MemberMapper;
import com.example.mybatisintegration.model.Member;
import com.example.mybatisintegration.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;

@Service
public class MemberServiceImpl implements MemberService {

    @Autowired
    private MemberMapper memberMapper;

    @Override
    public Member fetchMemberById(Integer id) {
        return memberMapper.fetchById(id);
    }

    @Override
    public List<Member> fetchAllMembers() {
        return memberMapper.fetchAll();
    }

    @Override
    public String removeMemberById(Integer id) {
        memberMapper.deleteById(id);
        return "삭제 완료";
    }

    @Override
    public String registerMember(Member member) {
        memberMapper.insertMember(member);
        return "등록 완료";
    }

    @Override
    public String modifyMember(Member member) {
        memberMapper.updateMember(member);
        return "수정 완료";
    }
}

2.4.3 컨트롤러 계층

컨트롤러에서 서비스를 주입받아 API 엔드포인트를 구현합니다.

package com.example.mybatisintegration.controller;

import com.example.mybatisintegration.model.Member;
import com.example.mybatisintegration.service.MemberService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping("/api/members")
public class MemberController {
    @Autowired
    private MemberService memberService;
    
    @GetMapping("/{id}")
    public Member fetchMember(@PathVariable Integer id){
        return memberService.fetchMemberById(id);
    }
    
    @GetMapping
    public List<Member> fetchAllMembers() {
        return memberService.fetchAllMembers();
    }
    
    @PostMapping
    public String registerMember(@RequestBody Member member){
        return memberService.registerMember(member);
    }
    
    @DeleteMapping("/{id}")
    public String removeMember(@PathVariable Integer id){
        return memberService.removeMemberById(id);
    }
    
    @PutMapping
    public String modifyMember(@RequestBody Member member){
        return memberService.modifyMember(member);
    }
}

2.4.4 애너테이션 기반 매퍼

애너테이션을 사용한 매퍼 인터페이스입니다.

package com.example.mybatisintegration.mapper;

import com.example.mybatisintegration.model.Member;
import org.apache.ibatis.annotations.*;
import java.util.List;

@Mapper
public interface MemberMapper {

	@Select("select * from member where id = #{id}")
    Member fetchById(Integer id);
    
    @Select("select * from member")
    List<Member> fetchAll();

    @Delete("delete from member where id = #{id}")
    void deleteById(Integer id);

    @Insert("insert into member(name,age,gender,phone) values(#{name},#{age},#{gender},#{phone})")
    void insertMember(Member member);

    @Update("update member set name = #{name},age = #{age},gender = #{gender},phone = #{phone} where id = #{id}")
    void updateMember(Member member);
}

2.4.5 XML 기반 매퍼

XML 설정을 사용한 방식입니다. 매퍼 인터페이스와 XML 매핑 파일이 필요합니다.

매퍼 인터페이스:

package com.example.mybatisintegration.mapper;

import com.example.mybatisintegration.model.Member;
import org.apache.ibatis.annotations.*;
import java.util.List;

@Mapper
public interface MemberMapper {

    Member fetchById(Integer id);

    List<Member> fetchAll();

    void deleteById(Integer id);

    void insertMember(Member member);

    void updateMember(Member member);
}

XML 매핑 파일:

<?xml version="1.0" encoding="UTF-8" ?>

<mapper namespace="com.example.mybatisintegration.mapper.MemberMapper">

    <select id="fetchById" resultType="com.example.mybatisintegration.model.Member">
        select * from member where id = #{id}
    </select>

    <select id="fetchAll" resultType="com.example.mybatisintegration.model.Member">
        select * from member
    </select>

    <delete id="deleteById" parameterType="int">
        delete from member where id = #{id}
    </delete>

    <insert id="insertMember" parameterType="com.example.mybatisintegration.model.Member">
        insert into member(name,age,gender,phone) values(#{name},#{age},#{gender},#{phone})
    </insert>

    <update id="updateMember" parameterType="com.example.mybatisintegration.model.Member">
        update member set name = #{name},age = #{age},gender = #{gender},phone = #{phone} where id = #{id}
    </update>
</mapper>

2.4.6 전역 설정 파일

mybatis-config.xml 파일에서 전역 설정을 정의합니다.

<?xml version="1.0" encoding="UTF-8" ?>

<configuration>
	
	<!-- 설정 옵션 -->
    <settings>
        <!-- 지연 로딩 전역 스위치 -->
        <setting name="lazyLoadingEnabled" value="true"/>
        <!-- 지연 로딩 시 모든 연관 속성 로드 -->
        <setting name="aggressiveLazyLoading" value="false"/>
        <!-- 단일 SQL 다중 결과 집합 허용 -->
        <setting name="multipleResultSetsEnabled" value="true"/>
        <!-- 열 레이블 대신 열 이름 사용 -->
        <setting name="useColumnLabel" value="true"/>
        <!-- JDBC 생성 키 값 지원 -->
        <setting name="useGeneratedKeys" value="true"/>
        <!-- 기본 실행기 유형 설정 -->
        <setting name="defaultExecutorType" value="SIMPLE"/>
        <!-- 쿼리 타임아웃 설정 -->
        <setting name="defaultStatementTimeout" value="25"/>
        <!-- 자동 카멜 케이스 매핑 -->
        <setting name="mapUnderscoreToCamelCase" value="true"/>
    </settings>
	
	<!-- 환경 설정 -->
    <environments default="development">
        <environment id="development">
            <transactionManager type="JDBC"/>
            <dataSource type="POOLED">
                <property name="driver" value="com.mysql.jdbc.Driver"/>
                <property name="url" value="jdbc:mysql://localhost:3306/mybatis_integration?useSSL=false&serverTimezone=UTC"/>
                <property name="username" value="root"/>
                <property name="password" value="password"/>
            </dataSource>
        </environment>
    </environments>
	
	!-- 매퍼 파일 -->
    <mappers>
        <mapper resource="mapper/MemberMapper.xml"/>
    </mappers>

</configuration>

참고: 전역 설정 파일은 생략하고 application.yml에 모든 설정을 포함할 수 있습니다. 이때 로딩 순서는 다음과 같습니다: mybatis-config.xml → application.yml의 마이바티스 설정 → 자바 설정(Java Config). 우선순위는 로딩 순서와 반대입니다.

3. 마이바티스 플러그인 메커니즘

3.1 플러그 개요

대부분의 오픈소스 프레임워크는 개발자가 기능을 확장할 수 있도록 확장점을 제공합니다. 예를 들어 스프링 프레임워크의 `BeanPostProcessor` 인터페이스는 개발자가 객체 초기화 전후에 작업을 수행할 수 있게 합니다. 마이바티스도 확장점을 제공하며, 이를 플러그인 메커니즘이라고 부릅니다. 본질적으로 플러그인은 JDK 동적 프록시와 책임 연쇄 패턴(Chain of Responsibility)의 결합체입니다.

마이바티스가 제공하는 주요 확장점은 다음과 같습니다:

  • Executor: SQL 실행기 (update, query, commit,rollback 메서드)
  • StatementHandler: SQL 구문 생성기 (prepare, parameterize, batch, update, query 등 메서드)
  • ParameterHandler: 파라미터 처리기 (getParameterObject, setParameters 등 메서드)
  • ResultSetHandler: 결과 집합 처리기 (handleResultSets, handleOutputParameters 등 메서드)

3.2 플러그 구현 단계

마이바티스 플러그을 구현하는 주요 단계는 다음과 같습니다:

  1. Interceptor 인터페이스 구현
  2. @Intercepts@Signature 애너테이션을 사용하여 플러그인 지점 정의
  3. 마이바티스 전역 설정 파일에서 플러그인 등록

3.2.1 Interceptor 인터페이스 구현

먼저 org.apache.ibatis.plugin.Interceptor 인터페이스를 구현해야 합니다:

import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;

import java.util.Properties;

@Intercepts({
    @Signature(type = StatementHandler.class, method = "prepare", args = {Connection.class, Integer.class})
})
public class SamplePlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // 대상 메서드 실행 전 로직
        Object result = invocation.proceed();
        // 대상 메서드 실행 후 로직
        return result;
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 플러그인 속성 설정
    }
}

위 코드에서 @Intercepts 애너테이션은 플러그인으로 지정할 클래스를 표시하고, 플러그인이 가로챌 메서드를 지정합니다. intercept 메서드는 플러그인의 핵심 로직이며, plugin 메서드는 대상 객체의 프록시를 생성하는 데 사용됩니다.

3.2.2 플러그인 등록

마이바티스 설정 파일(mybatis-config.xml)에서 플러그인을 등록합니다:

<?xml version="1.0" encoding="UTF-8" ?>

<configuration>
    <plugins>
        <plugin interceptor="com.example.plugin.QueryTimePlugin">
            <property name="threshold" value="1000"/>
        </plugin>
    </plugins>
</configuration>

3.3 커스텀 플러그 예제

3.3.1 쿼리 실행 시간 측정 플러그

실제 플러그인 예제로 SQL 쿼리 실행 시간을 측정하는 방법을 살펴보겠습니다. 이 플러그은 JDBC 연결 생성과 프리파레드 스테이트먼트 생성 시간도 포함합니다.

3.3.1.1 쿼리 실행 시간 측정 플러그 구현
package com.example.mybatisintegration.plugin;

import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.*;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.Properties;

/**
 * 쿼리 실행 시간을 측정하는 플러그
 */
@Intercepts({
        @Signature(
                type = Executor.class,
                method = "update",
                args = {MappedStatement.class, Object.class}),
        @Signature(
                type = Executor.class,
                method = "query",
                args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class})
})
public class QueryTimePlugin implements Interceptor {
    private static final Logger logger = LoggerFactory.getLogger(QueryTimePlugin.class);

    // 느린 쿼리 임계값(밀리초)
    private long slowQueryThreshold = 1000;
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        // SQL 실행 관련 정보 가져오기
        MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
        Object parameter = invocation.getArgs()[1];

        String sqlId = mappedStatement.getId();
        long startTime = System.currentTimeMillis();

        try {
            // 원본 메서드 실행
            return invocation.proceed();
        } finally {
            long costTime = System.currentTimeMillis() - startTime;
            // 로그 기록
            if (costTime > slowQueryThreshold) {
                logger.warn("느린 쿼리 실행 시간: {}ms > {}ms, SQL ID: {}",
                        costTime, slowQueryThreshold, sqlId);
            } else {
                logger.debug("쿼리 실행 시간: {}ms, SQL ID: {}", costTime, sqlId);
            }
        }
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 설정에서 임계값 읽기
        String threshold = properties.getProperty("threshold");
        if (threshold != null) {
            this.slowQueryThreshold = Long.parseLong(threshold);
        }
    }
}
3.3.1.2 쿼리 실행 시간 측정 플러그 등록

전역 설정 파일에 플러그을 등록합니다:

<?xml version="1.0" encoding="UTF-8" ?>

<configuration>
    <plugins>
        <plugin interceptor="com.example.mybatisintegration.plugin.QueryTimePlugin">
            <property name="threshold" value="500"/>
        </plugin>
    </plugins>
</configuration>

3.3.2 쿼리 결과 암호화 플러그

쿼리 결과에서 전화번호 필드를 MD5로 암호화하는 플러그을 구현해 보겠습니다.

3.3.2.1 암호화 유틸리티 클래스
package com.example.mybatisintegration.util;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * 다양한 해시 알고리즘을 제공하는 유틸리티 클래스
 */
public class CryptoUtil {
    private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray();

    /**
     * 문자열의 MD5 해시값 계산
     * @param input 입력 문자열
     * @return 32자 소문자 MD5 해시값
     */
    public static String md5(String input) {
        return digest(input, "MD5");
    }

    /**
     * 문자열의 SHA-256 해시값 계산
     * @param input 입력 문자열
     * @return 64자 소문자 SHA-256 해시값
     */
    public static String sha256(String input) {
        return digest(input, "SHA-256");
    }

    /**
     * 일반 해시 계산 메서드
     * @param input 입력 문자열
     * @param algorithm 알고리즘 이름
     * @return 해시 문자열
     */
    private static String digest(String input, String algorithm) {
        try {
            MessageDigest md = MessageDigest.getInstance(algorithm);
            byte[] bytes = md.digest(input.getBytes(StandardCharsets.UTF_8));
            return bytesToHex(bytes);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 바이트 배열을 16진수 문자열로 변환
     * @param bytes 바이트 배열
     * @return 16진수 문자열
     */
    private static String bytesToHex(byte[] bytes) {
        char[] hexChars = new char[bytes.length * 2];
        for (int i = 0; i < bytes.length; i++) {
            int v = bytes[i] & 0xFF;
            hexChars[i * 2] = HEX_CHARS[v >>> 4];
            hexChars[i * 2 + 1] = HEX_CHARS[v & 0x0F];
        }
        return new String(hexChars);
    }
}
3.3.2.2 결과 집합 처리기 구현
package com.example.mybatisintegration.plugin;

import com.example.mybatisintegration.model.Member;
import com.example.mybatisintegration.util.CryptoUtil;
import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.Signature;

import java.sql.Statement;
import java.util.List;
import java.util.Properties;

/**
 * 결과 집합을 암호화하는 처리기
 */
public class EncryptingResultSetHandler implements ResultSetHandler {
    private final ResultSetHandler originalHandler;

    public EncryptingResultSetHandler(ResultSetHandler originalHandler) {
        this.originalHandler = originalHandler;
    }

    @Override
    public List<Object> handleResultSets(Statement stmt) throws Exception {
        // 원본 처리기로 결과 집합 처리
        List<Object> results = this.originalHandler.handleResultSets(stmt);

        // 결과 집합에서 전화번호 필드 암호화
        if (results instanceof List) {
            for (Object item : results) {
                if (item instanceof Member) {
                    Member member = (Member) item;
                    String encryptedPhone = CryptoUtil.md5(member.getPhone());
                    member.setPhone(encryptedPhone);
                }
            }
        }

        return results;
    }
    
    // 다른 메서드들은 기본 구현 사용
    @Override
    public void handleOutputParameters(java.sql.CallableStatement cs) throws Exception {
        originalHandler.handleOutputParameters(cs);
    }
}
3.3.2.3 결과 집합 처리 플러그 구현
package com.example.mybatisintegration.plugin;

import org.apache.ibatis.executor.resultset.ResultSetHandler;
import org.apache.ibatis.plugin.*;
import org.springframework.stereotype.Component;

import java.sql.Statement;
import java.util.Properties;

/**
 * 결과 집합 처리를 가로채는 플러그
 */
@Component
@Intercepts({@Signature(type = ResultSetHandler.class, method = "handleResultSets", args = {Statement.class})})
public class ResultSetEncryptionPlugin implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Statement stmt = (Statement) invocation.getArgs()[0];
        
        // 원본 ResultSetHandler 가져오기
        ResultSetHandler originalHandler = (ResultSetHandler) invocation.getTarget();
        
        // 암호화 처리기 생성
        EncryptingResultSetHandler encryptingHandler = new EncryptingResultSetHandler(originalHandler);
        
        // 암호화 처리기로 결과 집합 처리
        return encryptingHandler.handleResultSets(stmt);
    }

    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
        // 플러그 속성 설정 (필요 시)
    }
}
3.3.2.4 결과 암호화 플러그 등록

전역 설정 파일에 플러그을 등록합니다:

<?xml version="1.0" encoding="UTF-8" ?>

<configuration>
    <plugins>
        <plugin interceptor="com.example.mybatisintegration.plugin.ResultSetEncryptionPlugin"/>
    </plugins>
</configuration>

3.4 플러그 메커니즘 소스 코드 분석

플러그 메커니즘의 원리와 구현 방법을 이해한 후, 소스 코드를 깊이 있게 분석해 보겠습니다. 분석 과정에서 다음 세 가지 질문을 중심으로 진행합니다:

  1. 객체는 어떻게 인스턴스화되는가?
  2. 플러그 인스턴스 객체가 어떻게 인터셉터 체인에 추가되는가?
  3. 컴포넌트 객체의 프록시 객체가 어떻게 생성되는가?

3.4.1 플러그 설정 정보 로딩 및 파싱

플러그을 정의한 후 마이바티스에 어떻게 전달할까요? 전역 설정 파일에 등록합니다. 이에 해당하는 파싱 코드는 XMLConfigBuilder#pluginsElement에서 발생합니다:

private void pluginElement(XNode parent) throws Exception {
  if (parent != null) {
    for (XNode child : parent.getChildren()) {
      // 인터셉터 클래스 가져오기
      String interceptorClass = child.getStringAttribute("interceptor");
      // 설정된 속성 Properties로 가져오기
      Properties properties = child.getChildrenAsProperties();
      // 설정 파일에 지정된 플러그 클래스의 전체 이름으로 리플렉션을 통해 초기화
      Interceptor interceptorInstance = (Interceptor) resolveClass(interceptorClass).getDeclaredConstructor().newInstance();
      // 속성을 Interceptor 객체에 설정
      interceptorInstance.setProperties(properties);
      // 설정 클래스의 InterceptorChain에 추가
      configuration.addInterceptor(interceptorInstance);
    }
  }
}

다음과 같은 작업을 수행합니다:

  1. plugins 태그 아래 각 plugin 태그를 순회합니다
  2. 클래스 정보를 기반으로 Interceptor 객체를 생성합니다
  3. 3. setProperties 메서드를 호출하여 속성을 설정합니다
    1. Interceptor 객체를 Configuration 클래스의 InterceptorChain에 추가합니다

    3.4.2 프록시 객체 생성

    이전에 언급했듯이, 플러그 메커니즘은 마이바티스의 네 가지 주요 컴포넌트의 메서드를 가로챌 수 있습니다. 이제 구체적으로 메서드 가로채기를 통해 프록시 객체가 생성되는 방법을 살펴보겠습니다.

    Executor 프록시 객체(Configuration#newExecutor):

    public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
      executorType = executorType == null ? defaultExecutorType : executorType;
      Executor executor;
      if (ExecutorType.BATCH == executorType) {
        executor = new BatchExecutor(this, transaction);
      } else if (ExecutorType.REUSE == executorType) {
        executor = new ReuseExecutor(this, transaction);
      } else {
        executor = new SimpleExecutor(this, transaction);
      }
      if (cacheEnabled) {
        executor = new CachingExecutor(executor);
      }
      // Executor 프록시 객체 생성 로직
      return (Executor) interceptorChain.pluginAll(executor);
    }

    ParameterHandler 프록시 객체(Configuration#newParameterHandler):

    public ParameterHandler newParameterHandler(MappedStatement mappedStatement, Object parameterObject,
        BoundSql boundSql) {
      // ParameterHandler 생성
      ParameterHandler parameterHandler = mappedStatement.getLang().createParameterHandler(mappedStatement,
          parameterObject, boundSql);
      // ParameterHandler 프록시 객체 생성 로직
      return (ParameterHandler) interceptorChain.pluginAll(parameterHandler);
    }

    ResultSetHandler 프록시 객체(Configuration#newResultSetHandler):

    public ResultSetHandler newResultSetHandler(Executor executor, MappedStatement mappedStatement, RowBounds rowBounds,
        ParameterHandler parameterHandler, ResultHandler resultHandler, BoundSql boundSql) {
      ResultSetHandler resultSetHandler = new DefaultResultSetHandler(executor, mappedStatement, parameterHandler,
          resultHandler, boundSql, rowBounds);
      // ResultSetHandler 프록시 객체 생성 로직
      return (ResultSetHandler) interceptorChain.pluginAll(resultSetHandler);
    }

    StatementHandler 프록시 객체(Configuration#newStatementHandler):

    public StatementHandler newStatementHandler(Executor executor, MappedStatement mappedStatement,
        Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, BoundSql boundSql) {
      // 라우팅 기능을 가진 StatementHandler 생성, 
      // MappedStatement의 StatementType에 따라 해당 StatementHandler 생성
      StatementHandler statementHandler = new RoutingStatementHandler(executor, mappedStatement, parameterObject,
          rowBounds, resultHandler, boundSql);
      return (StatementHandler) interceptorChain.pluginAll(statementHandler);
    }

    소스 코드를 살펴보면 모든 프록시 객체 생성이 InterceptorChain#pluginAll 메서드를 통해 이루어지는 것을 알 수 있습니다. InterceptorChain#pluginAll 내부는 Interceptor#plugin 메서드를 순회하여 프록시 객체를 생성하고, 생성된 프록시 객체를 다시 target에 할당합니다. 여러 개의 인터셉터가 있는 경우, 생성된 프록시 객체가 다른 프록시 객체에 의해 프록시되어 프록시 체인을 형성합니다. 실행 시 모든 인터셉터의 가로채기 로직 코드가 순차적으로 실행됩니다.

    // org.apache.ibatis.plugin.InterceptorChain
    public Object pluginAll(Object target) {
    	for (Interceptor interceptor : interceptors) {
    		target = interceptor.plugin(target);
    	}
    	return target;
    }
    
    // org.apache.ibatis.plugin.Interceptor
    @Override
    public Object plugin(Object target) {
        return Plugin.wrap(target, this);
    }
    
    public static Object wrap(Object target, Interceptor interceptor) {
        // 1. 해당 인터셉터가 가로챌 모든 인터페이스 및 해당 메서드 파싱
        Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
        Class<?> type = target.getClass();
        // 2. 대상 객체가 구현한 모든 가로채기 대상 인터페이스 가져오기
        Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
        // 3. 대상 객체가 가로채기 대상 인터페이스를 구현한 경우 프록시 객체 생성 및 반환
        if (interfaces.length > 0) {
            // JDK 동적 프록시 방식으로 구현
          return Proxy.newProxyInstance(
              type.getClassLoader(),
              interfaces,
              new Plugin(target, interceptor, signatureMap));
        }
        // 대상 객체가 가로채기 대상 인터페이스를 구현하지 않은 경우 원본 객체 반환
        return target;
    
    }

    3.4.3 가로채기 로직 실행

    마이바티스 프레임워크에서 Executor, ParameterHandler, ResultSetHandler 및 StatementHandler의 메서드를 실행할 때 실제로는 프록시 객체의 해당 메서드를 실행합니다. 따라서 메서드 실행은 실제로 InvocationHandler#invoke 메서드(Plugin 클래스가 InvocationHandler 인터페이스를 구현)를 호출하는 것입니다. 다음은 Plugin#invoke 메서드입니다:

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    	try {
    		Set<Method> methods = signatureMap.get(method.getDeclaringClass());
    		if (methods != null && methods.contains(method)) {
    			return interceptor.intercept(new Invocation(target, method, args));
    		}
    	    return method.invoke(target, args);
    	} catch (Exception e) {
    	    throw ExceptionUtil.unwrapThrowable(e);
    	}
    }

    주의: 동일한 컴포넌트 객체의 동일한 메서드를 여러 인터셉터가 가로챌 수 있습니다. 설정 파일에서 앞에 있는 인터셉터가 먼저 프록시되지만, 실행 시에는 가장 바깥쪽 인터셉터가 먼저 실행됩니다. 즉, 포장 순서와 실행 순서는 반대입니다.

    3.5 플러그 메커니즘의 적용 시나리오 및 주의사항

    3.5.1 적용 시나리오

    • SQL 로깅: SQL 문장 및 실행 시간을 기록하여 디버깅과 최적화에 도움을 줍니다.
    • 파라미터 검증 및 수정: SQL 실행 전 파라미터를 검증하고 수정하여 데이터의 정확성과 보안을 보장합니다.
    • 쿼리 결과 처리: 쿼리 결과를 처리하여 데이터 마스킹, 형식 변환 등을 수행합니다.
    • 성능 모니터링: SQL 실행 시간, 실행 횟수 등을 모니터링하여 시스템 성능을 최적화하는 데 도움을 줍니다.

    3.5.2 주의사항

    • 플러그 구현은 간결하고 효율적이어야 하며, 불필요한 성능 오버헤드를 추가하지 않도록 합니다.
    • 플러그 구성은 합리적으로 해야 하며, 과도한 사용으로 인해 코드 복잡도가 증가하지 않도록 합니다.
    • 플러그 실행 순서는 설정 파일의 순서에 따라 결정되므로 필요에 따라 순서를 조정할 수 있습니다.

    4. 결론

    본 문서에서는 스프링 부트 프로젝트에서 마이바티스를 통합하는 방법과 마이바티스 플러그 메커니즘의 원리와 실제 적용 사례를 상세히 분석했습니다. 마이바티스 플러그 메커니즘은 SQL 실행의 각 단계에 사용자 정의 로직을 삽입할 수 있는 유연한 방식을 제공하여 마이바티스의 확장성을 크게 향상시킵니다. 본 문서에서는 플러그 메커니즘의 소스 코드를 다루었으며, 향후 문서에서는 마이바티스의 핵심 소스 코드를 상세히 분석하고 핵심 컴포넌트의 역할과 실행 시점을 분석할 예정입니다.

태그: 마이바티스 스프링부트 JDBC SQL매핑 동적프록시

6월 28일 20:05에 게시됨