mybatis parameter console output
기존의 log4j를 걷어내고 spring boot + logback을 사용하게 되었는데 logback 설정으로 myBatis 쿼리를 콘솔에 찍는 기능이 있었다. 그러나 log4j의 sqlonly처럼 파라미터가 매핑 되어 있지 않고 ‘?’로 출력 되었다. 콘솔을 그대로 긁어 쿼리를 실행시키고 싶었던 나는 콘솔에만 파라미터를 매핑하여 출력하는 방법을 구색했고 마이바티스 인터셉터를 이용하여 출력하면 되겠다는 생각을 했다.
우선 인터셉터가 무엇인지 간략히 설명을 해보자.
말그대로 동작을 하기 전에 가로채는 것을 말한다. URL에서 Controller로 요청을 할 때 사이에서 동작될 클래스를 말하는 것이며, 레이어별로 인터셉터를 지정할 수 있다.
이번에 활용한 것은 MyBatis Interceptor이며 서비스에서 SQL Mapper를 효출 할 때 개발자가 xml에 작성한 쿼리를 가로채어 동작하는 클래스이다.
@Signature 어노테이션을 분석해보자면
종류 | 설명 |
---|---|
type | Executer라는 인터페이스는 Mybatis의 XML 파일에 작성된 SQL을 실행한다. 해당 인터페이스 내부를 보면 각 mybatis method 시그니처 정보를 볼 수 있다 |
method | insert or update or delete가 실행되면 Executer의 update 라는 메소드를 호출하며 select는 query 라는 메소드를 호출한다. |
args | mapper에 작성된 method를 호출할때 전달된 파라미터이다. 공통적으로 MapperStatement라는 객체가 Object[] 타입의 인덱스 0번에 필수로 저장된다. 여기에 XML 메타정보가 담겨있다. |
처음 구현 계획은 boundSql로 sql을 갖고 오고, 파라미터의 개수만큼 포문을 돌려 순서대로 문자 ‘?’을 replace하려고 했다.
문제는 mybatis에서 foreach로 동적쿼리를 만들면 ParameterMapping에서 ‘__frch_item_0’ 같은 파라미터가 생성되어 적재되었다. 이걸 어쩌지 하면서 불타는 구글링을 한 끝에 이를 예외처리 해놓은 포스팅을 찾을 수 있었고(맨 하단에 출처 기재) 그 코드를 기반으로 더 편하게 볼 수 있도록 약간의 수정을 했다.
import lombok.extern.slf4j.Slf4j;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ParameterMapping;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.codehaus.jackson.JsonProcessingException;
import org.springframework.util.ReflectionUtils;
import java.lang.reflect.Field;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.function.BiConsumer;
/**
* MybatisLogInterceptor.java
* query console 출력 클래스
* @author yuna706
* @since 2023.02.17
*/
@Slf4j
@Intercepts(value = {
@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 MybatisLogInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object paramObj = invocation.getArgs()[1];
MappedStatement statement = (MappedStatement)invocation.getArgs()[0];
try {
BoundSql boundSql = statement.getBoundSql(paramObj);
String paramSql = getParamBindSQL(boundSql);
log.debug(statement.getResource());
log.debug(statement.getId());
log.debug("sql: \n {}", paramSql);
return invocation.proceed();
}catch (NoSuchFieldException nsf){
return invocation.proceed();
}
}
// 파라미터 sql 바인딩 처리
public String getParamBindSQL(BoundSql boundSql) throws NoSuchFieldException, SecurityException, IllegalArgumentException, IllegalAccessException, JsonProcessingException {
Object parameterObject = boundSql.getParameterObject();
StringBuilder sqlStringBuilder = new StringBuilder(boundSql.getSql());
// stringBuilder 파라미터 replace 처리
BiConsumer<StringBuilder, Object> sqlObjectReplace = (sqlSb, value) -> {
int questionIdx = sqlSb.indexOf("?");
if(questionIdx == -1) {
return;
}
if(value == null) {
sqlSb.replace(questionIdx, questionIdx + 1, "null /**P*/");
} else if(value instanceof String || value instanceof LocalDate || value instanceof LocalDateTime || value instanceof Enum<?>) {
sqlSb.replace(questionIdx, questionIdx + 1, "'" + (value != null ? value.toString() : "") + "' /**P*/");
} else {
sqlSb.replace(questionIdx, questionIdx + 1, value.toString() + " /**P*/");
}
};
if(parameterObject == null) {
sqlObjectReplace.accept(sqlStringBuilder, null);
} else {
if(parameterObject instanceof Integer || parameterObject instanceof Long || parameterObject instanceof Float || parameterObject instanceof Double || parameterObject instanceof String) {
sqlObjectReplace.accept(sqlStringBuilder, parameterObject);
} else if(parameterObject instanceof Map) {
Map paramterObjectMap = (Map)parameterObject;
List<ParameterMapping> paramMappings = boundSql.getParameterMappings();
for (ParameterMapping parameterMapping : paramMappings) {
String propertyKey = parameterMapping.getProperty();
try {
Object paramValue = null;
if(boundSql.hasAdditionalParameter(propertyKey)) {
// 동적 SQL로 인해 __frch_item_0 같은 파라미터가 생성되어 적재됨, additionalParameter로 획득
paramValue = boundSql.getAdditionalParameter(propertyKey);
} else {
paramValue = paramterObjectMap.get(propertyKey);
}
sqlObjectReplace.accept(sqlStringBuilder, paramValue);
} catch (Exception e) {
sqlObjectReplace.accept(sqlStringBuilder, "[cannot binding : " + propertyKey+ "]");
}
}
} else {
List<ParameterMapping> paramMappings = boundSql.getParameterMappings();
Class< ? extends Object> paramClass = parameterObject.getClass();
for (ParameterMapping parameterMapping : paramMappings) {
String propertyKey = parameterMapping.getProperty();
try {
Object paramValue = null;
if(boundSql.hasAdditionalParameter(propertyKey)) {
// 동적 SQL로 인해 __frch_item_0 같은 파라미터가 생성되어 적재됨, additionalParameter로 획득
paramValue = boundSql.getAdditionalParameter(propertyKey);
} else {
Field field = ReflectionUtils.findField(paramClass, propertyKey);
field.setAccessible(true);
paramValue = field.get(parameterObject);
}
sqlObjectReplace.accept(sqlStringBuilder, paramValue);
} catch (Exception e) {
sqlObjectReplace.accept(sqlStringBuilder, "[cannot binding : " + propertyKey+ "]");
}
}
}
}
return sqlStringBuilder.toString().replaceAll("([\\r\\n\\s]){2,}([\\r\\n])+","\n");
}
}
인터셉터를 이용하여 실행 될 쿼리를 가로채고, 해당 쿼리에 ‘?’로 매핑되어 있는 파라미터를 순차적으로 매핑하였다.
그 과정에서 BiConsumer를 사용하였고 DTO를 사용해도 순차적으로 탐색할 수 있는 경우를 고려할 수 있게 되었다. 인터셉터에 대한 막연한 이해도가 있었지만 활용을 해보며 더 깊이 알게 된 것 같아 뿌듯하다.
참고
[Mybatis] - Mybatis Interceptor (tistory.com)
개발자(開發者) a developer :: mybatis 파라미터 바인딩 쿼리문 - boundsql __frch 처리 (tistory.com)