본문 바로가기

프로그래밍/Java

Mybatis의 Plug-In을 이용한 SQL문 파라미터 바인딩 로그 출력..

Mybatis에서는 Plug-In을 이용하여 Mybatis가 쿼리를 실행하는 시점에 간섭하여 사용자가 정의한 별도 작업을 진행할수 있다. 예를 들면 쿼리가 실행되기 전 또는 실행된 후에 해당 쿼리가 몇번 실행됐는지 그 실행횟수를 업데이트하는 그런 예를 들수 있다. 이 글에서는 쿼리를 실행하기 전에 로그에 파라미터가 바인딩된 쿼리 로그를 출력하는 Plug-In을 설명하고자 한다. 이 글에서는 Mybatis Plug-In에 대한 구체적인 설명은 하지 않는다. 다만 이 글에서 보여주는 Source의 주석으로 관련 내용을 설명했으니 참고하기 바란다. log4jsql이나 log4jdbc같은 괜찮은 로그툴이 있으나 굳이 이것을 만든것은 로그의 출력형태가 반드시 로그 파일 형태로만 갈수는 없기 때문이다. DB에 기록할수도 있고 별도 로직이 들어가야 하는 상황이 발생할 수도 있는데 이럴 경우엔 자신이 직접 이런 로그 기록하는 Plug-In을 구현해야 하기 때문이다. 이 소스에서 로그를 출력하는데 있어 사용한 라이브러리는 SLF4j 라이브러리를 사용했으나 다른 로그 라이브러리를 사용하고 있다면 해당 라이브러리에 맞춰 로그 출력 부분을 바꾸어주면 된다. 일단 전체 Source는 다음과 같다

(첨부파일 : 

MybatisLogInterceptor.java
다운로드

)

 

package com.terry.boardprj32.common;

import java.lang.reflect.Field;
import java.sql.Statement;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
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.Plugin;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

@Intercepts({
    @Signature(type=StatementHandler.class, method="update", args={Statement.class})
    , @Signature(type=StatementHandler.class, method="query", args={Statement.class, ResultHandler.class})
})
public class MybatisLogInterceptor implements Interceptor {

    private Logger logger = LoggerFactory.getLogger(this.getClass());
    
    @Override
    public Object intercept(Invocation invocation) throws Throwable {
	// TODO Auto-generated method stub
        StatementHandler handler = (StatementHandler)invocation.getTarget();
        
        BoundSql boundSql = handler.getBoundSql();
        
        // 쿼리문을 가져온다(이 상태에서의 쿼리는 값이 들어갈 부분에 ?가 있다)
        String sql = boundSql.getSql();
        
        // 쿼리실행시 맵핑되는 파라미터를 구한다
        Object param = handler.getParameterHandler().getParameterObject();
        
        if(param == null){				// 파라미터가 아무것도 없을 경우
            sql = sql.replaceFirst("\\?", "''");
        }else{						// 해당 파라미터의 클래스가 Integer, Long, Float, Double 클래스일 경우
            if(param instanceof Integer || param instanceof Long || param instanceof Float || param instanceof Double){
                sql = sql.replaceFirst("\\?", param.toString());
            }else if(param instanceof String){	// 해당 파라미터의 클래스가 String 일 경우(이 경우는 앞뒤에 '(홑따옴표)를 붙여야해서 별도 처리
                sql = sql.replaceFirst("\\?", "'" + param + "'");
            }else if(param instanceof Map){		// 해당 파라미터가 Map 일 경우
        	
        	// 쿼리의 ?와 매핑되는 실제 값들의 정보가 들어있는 ParameterMapping 객체가 들어간 List 객체로 return이 된다.
        	// 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	// 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	List<ParameterMapping> paramMapping = boundSql.getParameterMappings();	
        	
        	for(ParameterMapping mapping : paramMapping){
        	    String propValue = mapping.getProperty();		// 파라미터로 넘긴 Map의 key 값이 들어오게 된다
        	    Object value = ((Map) param).get(propValue);	// 넘겨받은 key 값을 이용해 실제 값을 꺼낸다
        	    if(value instanceof String){			// SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
        	        sql = sql.replaceFirst("\\?", "'" + value + "'");
        	    }else{
        	        sql = sql.replaceFirst("\\?", value.toString());
        	    }
        	    
        	}
            }else{					// 해당 파라미터가 사용자 정의 클래스일 경우
        	
        	 // 쿼리의 ?와 매핑되는 실제 값들이 List 객체로 return이 된다.
        	 // 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	 // 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	List<ParameterMapping> paramMapping = boundSql.getParameterMappings();
        	
        	Class<? extends Object> paramClass = param.getClass();

        	for(ParameterMapping mapping : paramMapping){
        	    String propValue = mapping.getProperty();			// 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수명
        	    Field field = paramClass.getDeclaredField(propValue);	// 관련 멤버변수 Field 객체 얻어옴
        	    field.setAccessible(true);					// 멤버변수의 접근자가 private일 경우 reflection을 이용하여 값을 해당 멤버변수의 값을 가져오기 위해 별도로 셋팅
        	    Class<?> javaType = mapping.getJavaType();			// 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수의 타입
        	    
        	    if(String.class == javaType){				// SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
        	        sql = sql.replaceFirst("\\?", "'" + field.get(param) + "'");
        	    }else{
        	        sql = sql.replaceFirst("\\?", field.get(param).toString());
        	    }
        	    
        	}
            }
            
        }
         
        logger.debug("=====================================================================");
        logger.debug("sql : {}", sql);
        logger.debug("=====================================================================");
        
        return invocation.proceed(); // 쿼리 실행
    }

    @Override
    public Object plugin(Object target) {
	// TODO Auto-generated method stub
	return Plugin.wrap(target, this);
    }

    @Override
    public void setProperties(Properties properties) {
	// TODO Auto-generated method stub

    }

}

 

Source안에 설명을 넣어놔서 Source를 보는데 불편함이 있을수 있으나 말하고자 하는 핵심은 사실 몇가지 안된다. 우리가 쿼리를 로그로 출력할때 알아야 할것은 2가지다.

 

1. 쿼리를 어디서 가져올수 있는가?

2. 쿼리에서 사용하는 파라미터의 값들은 어디서 가져올수 있는가?

 

첫번째 질문인 쿼리를 어디서 가져올수 있는가에 대한 답은 의외로 쉽다. invoke 함수의 파라미터로 넘어오는 Invocation 객체를 StatementHandler 객체로 캐스팅 한 뒤에 StatementHandler에서 제공하는 BoundSql 객체를 통해 다음과 같이 얻어올수 있다

 

        StatementHandler handler = (StatementHandler)invocation.getTarget();
        BoundSql boundSql = handler.getBoundSql();
        // 쿼리문을 가져온다(이 상태에서의 쿼리는 값이 들어갈 부분에 ?가 있다)
        String sql = boundSql.getSql();

 

그러면 이제 2번째 질문은 쿼리에서 사용하는 파라미터를 가져오는 부분이다. 이 부분을 찾느라 시간이 걸렸는데 mybatis에서 제공하는 자바 클래스의 javadoc 문서를 찾을수가 없었다. 메뉴얼 성격의 문서는 있는데 정작 클래스 관련 레퍼런스 문서를 찾을수가 없었다. 그래서 구글링을 통해 관련 내용을 검색하여 구현한뒤에 디버깅 모드로 일일이 추적해서 찾았다. 일단 쿼리에서 사용한 파라미터를 가져오는 것은 쉽다. 다음과 같이 하면 된다

 

        
        // 쿼리실행시 맵핑되는 파라미터를 구한다
        Object param = handler.getParameterHandler().getParameterObject();

 

그러나 이렇게 넘겨받은 파라미터는 Object 클래스 객체이기 때문에 어떤 클래스가 넘어온건지 알 수가 없다. 쿼리에서 사용한 파라미터가 1개 이상임을 감안한다면 넘겨받은 파라미터는 단순한 String 객체나 int 형 값일수도 있고, Map 객체일수도 있으며 프로그래머가 만든 별도 클래스의 객체일수도 있다. 그렇기 때문에 파라미터로 넘겨받은 객체가 어떤 클래스인지를 확인해야 할 필요가 있는 것이다. Java의 Reflection을 활용하면 어떤 클래스인지를 확인할 수 있다. 그래서 이 코드에서는 따로 기입을 안했으나 이 코드를 만드는 과정에서는 Reflection을 활용해서 클래스가 무엇인지를 확인한후에 instanceof 를 이용한 if 문으로 분기문을 만들었다

 

그러면 이렇게 파라미터로 넘겨받은 객체의 타입을 확인한 다음엔 넘겨받은 객체 안에 있는 실제 값을 쿼리에 매핑해줘야 한다. primitive 타입(int, long등)을 넘겨주었을 경우엔 Wrapper 클래스로 던져주기 때문에 파라미터 값으로 int 형을 주면 파라미터 객체의 클래스는 Integer 타입이 된다. 이것을 생각하고 보길 바란다. String이나 Integer, Long 같은 Wrapper 클래스일 경우엔 파라미터는 1개만 들어왔다는 것을 의미하지만 Map 일 경우나 프로그래머가 만근 별도 클래스일 경우엔 파라미터가 여러개가 있다는 것으로 생각할수가 있다. 1개만 있을땐 단순하다. toString 메소드를 실행하면 String 형태로 파라미터로 사용된 실제 값을 얻을수 있기 때문에 이것을 바로 쿼리 안에 있는 1개뿐인 ? 대신에 실제 값으로 replace 하면 되니까..그러나 Map이나 별도 클래스일 경우엔? Map 일땐 값이 들어가 있는 key를 알아야 key를 이용해서 값을 읽을수 있을테고 별도 클래스일 경우엔 변수명을 알아야 Reflection을 이용해서 꺼내올수 있을텐데.. 

 

바로 이럴때 Mybatis에서는 파라미터 값들을 읽어올수 있는 정보를 준다. BoundSql 클래스는 쿼리에서 사용되는 파라미터들의 정보가 들어있는 List 객체를 넘겨주는 함수인 getParameterMappings() 메소드를 제공해준다. Mybatis에서는 파라미터 정보를 제공하는 ParameterMapping 클래스가 있다. 이 클래스를 보면 해당 파라미터에 대한 여러가지 정보를 제공해주는데 그 중엔 파라미터의 타입(JavaType, JdbcType)과 파라미터 값을 읽어올수 있는 근거(파라미터로 넘겨받은 객체의 타입이 Map 일 경우엔 값을 읽어올수 있는 key, 별도 클래스일 경우엔 관련 변수명)를 제공해준다. 쿼리에서 여러개의 파라미터를 사용할 경우 물음표(?)가 여러개 있을것이다. 이럴때 해당 물음표에 어떤 파라미터로 매핑되어야 하는지가 List 객체에 있다. List 객체의 0번째에 있는 ParameterMapping 객체가 쿼리에서 보이는 첫번째 물음표와 매핑이 되고 List 객체의 1번째에 있는 ParameterMapping 객체가 쿼리에서 보이는 두번째 물음표와 매핑이 된다. 이런 식으로 쿼리에서 사용하는 물음표와 List 객체 안에 있는 ParameterMapping 객체를 매핑시킨다.

 

파라미터로 넘겨받은 객체가 Map일 때를 보자. Map 일 경우엔 key를 알아야 해당 key에 대한 값을 읽어올수 있다. ParameterMapping 클래스에서 제공하는 getProperty 메소드를 통해 Map에서 사용한 key 값을 읽어올수 있다. 그래서 다음과 같이 쿼리에 매핑하게 된다

 

        
             }else if(param instanceof Map){		// 해당 파라미터가 Map 일 경우
        	
        	    // 쿼리의 ?와 매핑되는 실제 값들의 정보가 들어있는 ParameterMapping 객체가 들어간 List 객체로 return이 된다.
        	    // 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	    // 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	    List<ParameterMapping> paramMapping = boundSql.getParameterMappings();	
        	
        	    for(ParameterMapping mapping : paramMapping){
        	        String propValue = mapping.getProperty();		// 파라미터로 넘긴 Map의 key 값이 들어오게 된다
        	        Object value = ((Map) param).get(propValue);	// 넘겨받은 key 값을 이용해 실제 값을 꺼낸다
        	        if(value instanceof String){			// SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
        	           sql = sql.replaceFirst("\\?", "'" + value + "'");
        	        }else{
        	           sql = sql.replaceFirst("\\?", value.toString());
        	        }
        	    }
            }

 

그러면 별도 클래스일땐 어떻게 하는가? 별도 클래스일 경우엔 ParameterMapping 클래스에서 제공하는 getProperty 메소드를 통해 값이 들어 있는 별도 클래스 내부의 멤버변수명을 읽어올수 있다. 이렇게 읽어온 멤버변수명을 Java의 Reflection을 이용해 해당 변수에 저장되어 있는 값을 읽어와 쿼리에 매핑하게 된다

 

        
             }else{					// 해당 파라미터가 사용자 정의 클래스일 경우
        	
        	   // 쿼리의 ?와 매핑되는 실제 값들이 List 객체로 return이 된다.
        	   // 이때 List 객체의 0번째 순서에 있는 ParameterMapping 객체가 쿼리의 첫번째 ?와 매핑이 된다
        	   // 이런 식으로 쿼리의 ?과 ParameterMapping 객체들을 Mapping 한다
        	   List<ParameterMapping> paramMapping = boundSql.getParameterMappings();
        	
        	   Class<? extends Object> paramClass = param.getClass();

        	   for(ParameterMapping mapping : paramMapping){
        	       String propValue = mapping.getProperty();			// 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수명
        	       Field field = paramClass.getDeclaredField(propValue);	// 관련 멤버변수 Field 객체 얻어옴
        	       field.setAccessible(true);					// 멤버변수의 접근자가 private일 경우 reflection을 이용하여 값을 해당 멤버변수의 값을 가져오기 위해 별도로 셋팅
        	       Class<?> javaType = mapping.getJavaType();			// 해당 파라미터로 넘겨받은 사용자 정의 클래스 객체의 멤버변수의 타입
        	    
        	       if(String.class == javaType){				// SQL의 ? 대신에 실제 값을 넣는다. 이때 String 일 경우는 '를 붙여야 하기땜에 별도 처리
        	           sql = sql.replaceFirst("\\?", "'" + field.get(param) + "'");
        	       }else{
        	           sql = sql.replaceFirst("\\?", field.get(param).toString());
        	       }
        	   }
            }

 

지금까지 설명한 내용을 보고 위에 있는 전체 Source 코드와 주석들을 보면 전체적인 흐름을 이해할수 있을 것이다