Mybatis는 여전히 많이 사용되는 프레임워크이다. 해외의 근황이 어떠한지는 모르나, 아직도 국내에서는 Mybatis가 주류라고 생각한다.
JPA가 러닝커브를 다 견디면 좋다고들 하지만, 복잡한 쿼리를 작성하기 편해서 통계작업은 Mybatis로 하는 게 주류다.
JPA가 SQL의 풀 스펙을 구현하지 않기도 하고 복잡한 통계 쿼리를 작성하려고 하면 디버깅 시간이 두 배로 들어서 그럴 시간에 차라리 jdbcTemplate로 raw 쿼리를 날리는 게 빠를 수도 있다.
Mybatis는 자주 사용되는 반면 SQL 로그를 남기는 라이브러리들은 하나같이 유지보수가 끊긴 지 오래라 불안하다.
이럴 때 Mybatis가 제공해주는 Interceptor
기능을 사용해볼 수 있다.
Interceptor
는 Mybatis의 근본이 되는 Executor
의 실행 과정을 가로채서 추가적인 동작을 가능하게 하는 플러그인이다.
주로 SQL 로그를 남기거나 paging 처리를 할 때 많이 사용된다.
다음과 같은 환경으로 진행한다.
- JDK 1.8
- Spring Boot 2.7.9 (8을 지원하는 Latest 버전)
- mybatis-spring-boot-starter 2.3.0
- h2 db
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()
}
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())
}
}
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()
}
이 포스팅의 주인공이다. 찬찬히 설명하겠다.
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
}
}
}
Signature
를 이용하여 Executor
실행 과정 중 끼어들 대상을 정의한다. update
와 query
를 모두 지정하였기 때문에 모든 쿼리에서 작동한다.
끼어들어서 할 행동을 정의한다. 이 포스팅에서는 쿼리 콘솔에서 디버깅하기 용이하게 PreparedStatement
로 변환된 SQL에서 다시 파라미터를 집어넣은 SQL을 로그로 남겨주는 로직을 호출하였다.
boundSql
과 configuration
을 사용해 PreparedStatement
에서 ? 로 매핑된 부분을 실제 값으로 넣어준다.
실제로 쿼리를 동작시켜야하니 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