Mybatis Interceptor 등록하기

Click·2023년 3월 20일
0

1. 서론

Mybatis는 여전히 많이 사용되는 프레임워크이다. 해외의 근황이 어떠한지는 모르나, 아직도 국내에서는 Mybatis가 주류라고 생각한다.
JPA가 러닝커브를 다 견디면 좋다고들 하지만, 복잡한 쿼리를 작성하기 편해서 통계작업은 Mybatis로 하는 게 주류다.
JPA가 SQL의 풀 스펙을 구현하지 않기도 하고 복잡한 통계 쿼리를 작성하려고 하면 디버깅 시간이 두 배로 들어서 그럴 시간에 차라리 jdbcTemplate로 raw 쿼리를 날리는 게 빠를 수도 있다.

Mybatis는 자주 사용되는 반면 SQL 로그를 남기는 라이브러리들은 하나같이 유지보수가 끊긴 지 오래라 불안하다.
이럴 때 Mybatis가 제공해주는 Interceptor 기능을 사용해볼 수 있다.

Interceptor는 Mybatis의 근본이 되는 Executor의 실행 과정을 가로채서 추가적인 동작을 가능하게 하는 플러그인이다.
주로 SQL 로그를 남기거나 paging 처리를 할 때 많이 사용된다.

2. 환경설정

다음과 같은 환경으로 진행한다.

  • JDK 1.8
  • Spring Boot 2.7.9 (8을 지원하는 Latest 버전)
  • mybatis-spring-boot-starter 2.3.0
  • h2 db

1. build.gradle.kts

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.7.9"
    id("io.spring.dependency-management") version "1.0.15.RELEASE"
    kotlin("jvm") version "1.6.21"
    kotlin("plugin.spring") version "1.6.21"
}

group = "com.example"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
    implementation("org.mybatis.spring.boot:mybatis-spring-boot-starter:2.3.0")
    runtimeOnly("com.h2database:h2")
    testImplementation("org.springframework.boot:spring-boot-starter-test")
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

2. Mybatis Java Config

XML 설정을 사용해도 무방하고 개인적으로 Mybatis 설정파일은 XML을 사용하는 게 더 편리하고 자유도가 높다고 생각한다.
현업에서는 XML 설정을 권장한다.
추후 등장할 주인공인 SqlLoggerInterceptor 인스턴스를 등록해주지 않으면 SQL 로그가 동작하지 않는다

@Configuration
@MapperScan(basePackageClasses = [MemberMapper::class])
class MybatisConfig {

    @Bean
    fun sqlSessionFactory(dataSource: DataSource) : SqlSessionFactoryBean = SqlSessionFactoryBean().apply {
        this.setDataSource(dataSource)
        this.setPlugins(SqlLoggerInterceptor())
    }
}

3. 구현

1. Mapper

Interface 기반 Mapper를 선택하고 Java Config를 선택한 김에 SQL도 DSL로 작성해보았다.
역시나 현업에서는 Intellij의 MybatisX라는 걸출한 플러그인 덕에 SQL은 XML로 작성하는 편이 더 좋다.
Kotlin의 내장 String Validation 기능을 사용하기 편한 정도의 장점밖에 느껴지지 않는다.

@Mapper
interface MemberMapper {
    // Java SQL builder
    @SelectProvider(MemberMapperSQL::class, method = "findMembersByName")
    fun findMembersByName(findMemberByNameSpec: FindMemberByNameSpec) : List<Member>
}

class MemberMapperSQL {
    fun findMembersByName(findMemberByNameSpec: FindMemberByNameSpec) =
        // Java DSL to Kotlin DSL
        object : SQL() {
        init {
            SELECT("id", "member_name")
            FROM("SCOTT.MEMBER")
            if (findMemberByNameSpec.memberName?.isNotBlank() == true) {
                WHERE("member_name like #{memberName} || '%'")
            }
            ORDER_BY("id")
        }
    }.toString()
}

2. Interceptor

이 포스팅의 주인공이다. 찬찬히 설명하겠다.

package com.example.mybatisexample.config


import org.apache.ibatis.cache.CacheKey
import org.apache.ibatis.executor.Executor
import org.apache.ibatis.mapping.BoundSql
import org.apache.ibatis.mapping.MappedStatement
import org.apache.ibatis.plugin.*
import org.apache.ibatis.session.Configuration
import org.apache.ibatis.session.ResultHandler
import org.apache.ibatis.session.RowBounds
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import java.lang.reflect.InvocationTargetException
import java.text.DateFormat
import java.util.*


@Intercepts(
    Signature(type = Executor::class, method = "update", args = [MappedStatement::class, Any::class]),
    Signature(
        type = Executor::class,
        method = "query",
        args = [MappedStatement::class, Any::class, RowBounds::class, ResultHandler::class, CacheKey::class, BoundSql::class]
    ),
    Signature(
        type = Executor::class,
        method = "query",
        args = [MappedStatement::class, Any::class, RowBounds::class, ResultHandler::class]
    )
)
/**
 * Mybatis SQL Log 남기기용 Interceptor
 * MappedStatement에서 update, query method를 intercept하여 로그를 남긴다
 */
class SqlLoggerInterceptor : Interceptor {
    private val logger: Logger = LoggerFactory.getLogger(SqlLoggerInterceptor::class.java)

    @Throws(Throwable::class)
    override fun intercept(invocation: Invocation): Any {
        val mappedStatement = invocation.args[0] as MappedStatement
        val parameter: Any? = if (invocation.args.size > 1) {
            invocation.args[1]
        }
        else {
            null
        }
        val sqlId = mappedStatement.id

        val boundSql = mappedStatement.getBoundSql(parameter)
        val configuration = mappedStatement.configuration
        var returnValue: Any? = null
        logger.info("/*---------------Mapper Map ID: {}[begin]---------------*/", sqlId)
        val sql = genSql(configuration, boundSql)
        logger.info("==> sql:\n {}\n/*{}*/", sql, sqlId)
        val start = System.currentTimeMillis()
        try {
            returnValue = invocation.proceed()
        }
        catch (e: InvocationTargetException) {
            e.printStackTrace()
        }
        catch (e: IllegalAccessException) {
            e.printStackTrace()
        }
        val end = System.currentTimeMillis()
        val time = end - start
        logger.info("<== sql END:{} ms", time)
        return returnValue!!
    }

    override fun plugin(target: Any): Any {
        return Plugin.wrap(target, this)
    }

    override fun setProperties(properties: Properties) {
        println(properties)
    }

    companion object {
        private fun getParameterValue(obj: Any?): String {
            val value: String = when (obj) {
                is String -> {
                    "'$obj'"
                }

                is Date -> {
                    val formatter = DateFormat.getDateTimeInstance(DateFormat.DEFAULT, DateFormat.DEFAULT, Locale.KOREA)
                    "'" + formatter.format(obj) + "'"
                }

                else -> {
                    obj?.toString() ?: ""
                }
            }
            return value
        }

        fun genSql(configuration: Configuration, boundSql: BoundSql): String {
            // 쿼리실행시 맵핑되는 파라미터를 구한다
            val parameterObject = boundSql.parameterObject
            val parameterMappings = boundSql.parameterMappings
            // 쿼리문을 가져온다(이 상태에서의 쿼리는 값이 들어갈 부분에 ?가 있다)
            var sql = boundSql.sql
            if (parameterMappings.size > 0 && parameterObject != null) {
                val typeHandlerRegistry = configuration.typeHandlerRegistry
                if (typeHandlerRegistry.hasTypeHandler(parameterObject.javaClass)) {
                    sql =
                        sql.replaceFirst("\\?".toRegex(), getParameterValue(parameterObject))
                }
                else {
                    val metaObject = configuration.newMetaObject(parameterObject)
                    for (parameterMapping in parameterMappings) {
                        val propertyName = parameterMapping.property
                        if (metaObject.hasGetter(propertyName)) {
                            val obj = metaObject.getValue(propertyName)
                            sql = sql.replaceFirst("\\?".toRegex(), getParameterValue(obj))
                        }
                        else if (boundSql.hasAdditionalParameter(propertyName)) {
                            val obj = boundSql.getAdditionalParameter(propertyName)
                            sql = sql.replaceFirst("\\?".toRegex(), getParameterValue(obj))
                        }
                    }
                }
            }
            return sql
        }
    }
}

@Intercepts

Signature 를 이용하여 Executor 실행 과정 중 끼어들 대상을 정의한다. updatequery를 모두 지정하였기 때문에 모든 쿼리에서 작동한다.

override fun intercept(invocation: Invocation): Any

끼어들어서 할 행동을 정의한다. 이 포스팅에서는 쿼리 콘솔에서 디버깅하기 용이하게 PreparedStatement로 변환된 SQL에서 다시 파라미터를 집어넣은 SQL을 로그로 남겨주는 로직을 호출하였다.

fun genSQL(configuration: Configuration, boundSql: BoundSql): String

boundSqlconfiguration을 사용해 PreparedStatement에서 ? 로 매핑된 부분을 실제 값으로 넣어준다.

3. Service & Test

실제로 쿼리를 동작시켜야하니 SpringBootTest로 환경을 모두 셋업한다.

@Service
class MemberService(
    private val memberMapper: MemberMapper
) {

    suspend fun findMembersByName(findMemberByNameSpec: FindMemberByNameSpec) : List<Member> {
        return memberMapper.findMembersByName(findMemberByNameSpec)
    }
}

@SpringBootTest
internal class MemberServiceTest(
    @Qualifier("memberService")
    private val memberService: MemberService
) {

    @Test
    fun findMembersByName() {
        val memberList = runBlocking { memberService.findMembersByName(FindMemberByNameSpec("tiger")) }
        val expectedList = listOf(Member(id = 1, memberName = "tiger"), Member(id = 3, memberName = "tiger king"))
        Assertions.assertArrayEquals(memberList.toTypedArray(), expectedList.toTypedArray())



    }
}

테스트 로그

2023-03-20 23:25:52.147  INFO 10416 --- [er @coroutine#1] c.e.m.config.SqlLoggerInterceptor        : /*---------------Mapper Map ID: com.example.mybatisexample.mapper.MemberMapper.findMembersByName[begin]---------------*/
2023-03-20 23:25:52.147  INFO 10416 --- [er @coroutine#1] c.e.m.config.SqlLoggerInterceptor        : ==> sql:
 SELECT id, member_name
FROM SCOTT.MEMBER
WHERE (member_name like 'tiger' || '%')
ORDER BY id
/*com.example.mybatisexample.mapper.MemberMapper.findMembersByName*/
2023-03-20 23:25:52.171  INFO 10416 --- [er @coroutine#1] c.e.m.config.SqlLoggerInterceptor        : <== sql END:23 ms
2023-03-20 23:25:52.184  INFO 10416 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown initiated...
2023-03-20 23:25:52.185  INFO 10416 --- [ionShutdownHook] com.zaxxer.hikari.HikariDataSource       : HikariPool-1 - Shutdown completed.
BUILD SUCCESSFUL in 4s
4 actionable tasks: 3 executed, 1 up-to-date
오후 11:25:52: Execution finished ':test --tests "com.example.mybatisexample.service.MemberServiceTest.findMembersByName"'.

SELECT id, member_name
FROM SCOTT.MEMBER
WHERE (member_name like 'tiger' || '%')
ORDER BY id

파라미터가 매핑된 SQL이 로그에 남는 걸 확인할 수 있다.

전체 코드 링크: https://github.com/Clickin/mybatis-interceptor-example

profile
갈려나가는 개발자

0개의 댓글

관련 채용 정보