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 코드와 주석들을 보면 전체적인 흐름을 이해할수 있을 것이다







  • BlogIcon misoboy 2014.12.31 11:59 신고

    안녕하세요. 지나가다 적어주신 코드에 대해 감사히 참고 하여 사용하고 있습니다.
    77 라인정도에 필드 값에 대한 조회해오는 부분이.. 상속관계일경우 값을 가져오지 못하더군요..ㅎㅎ
    재귀함수를 추가하면 되지 않을까 싶어 댓글을 남겼습니다.
    좋은 정보 감사합니다.

    /**
    * 클래스 필드 검색 재귀함수
    * @param paramClass
    * @param propValue
    * @return
    */
    private Field doDeclaredField(Class<? extends Object> paramClass, String propValue){
    Field field = null;
    try {
    /*
    * 해당 객체의 필드를 검색 한다.
    * 존재 하지 않을 경우 NoSuchFieldException 발생
    */
    field = paramClass.getDeclaredField(propValue);
    } catch ( NoSuchFieldException e ){
    // NoSuchFieldException 발생 할경우 상위 클래스를 검색 한다.
    field = doDeclaredField(paramClass.getSuperclass(), propValue);
    }
    return field;
    }

    1. BlogIcon 메이킹러브 2015.01.21 18:31 신고

      안녕하세요..댓글을 지금에서야 봤습니다..죄송해요..
      제가 상속 클래스에 대한 테스트는 진행해보질 않아서..그런 버그가 있을줄은 몰랐네요..지적 감사합니다..

      근데 좀더 보완을 한다면..
      지금 주신 코드도 버그는 있습니다..
      예를 들어 극단적으로 최상위 클래스..즉 Object 클래스까지 올라갔을때도 필드를 못찾았다면 어떻게 될까요?
      저도 원래 댓글에서는 catch에서 무한루프를 돌것 같다고 생각했지만..
      Object 클래스 객채에서 getSuperclass() 메소드를 호출하면 null 이 리턴된다고 하더군요..
      (http://stackoverflow.com/questions/2706069/java-object-superclass)
      그러면 다음번 doDeclaredField 메소드 실행시 paramClass가 null로 들어오기 때문에 paramClass.getDeclaredField(propValue); 부분에서 에러가 발생할겁니다..null인 객체에서 메소드를 실행시킬순 없으니까요..

      지금의 코드는 부모클래스에 반드시 필드가 있다는 전제하에서는 잘 돌겠지만..그렇지 않을 경우엔 최상위 Object 클래스까지 찾은 뒤 그 다음번 함수 호출에서 에러가 발생할 수 있으니 한번 테스트를 진행해보시면 좋을 듯 하네요..즉 필드가 없을 경우에 대한 테스트죠..

      다시 한번 코드 지적 고맙습니다..

  • BlogIcon 아타루 2018.11.05 10:06 신고

    안녕하세요.

    이코드는 어떻게 사용해야 하나요? xml에 따로 추가해야 되나요?
    아니면 JAVA 소스 어디에 넣어야 하는지 궁금합니다.
    systemMapper.xml 방식으로 sql 파싱을 하고있는데
    sql String 출력이 너무 어렵네요 ㅠㅠ 도움 부탁드려요..

    /WEB-INF/spring/mybatis-context.xml 이런 경로에

    <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer">
    <property name="basePackage" value="com.dmc.ad.mapper
    ,com.dmc.ad.common.mapper
    ,com.dmc.ad.system.mapper" />
    </bean>

    이런식으로 쓰고 있습니다.

    1. BlogIcon 메이킹러브 2018.11.05 15:15 신고

      이 코드는 Mybatis plugin 형태로 사용되는거에요..
      mybatis xml 설정파일에서 plugin 태그를 사용할 수 있는데 거기게 다음과 같이 넣으면 됩니다..

      <plugins>
      <plugin interceptor="com.terry.boardprj32.common.MybatisLogInterceptor"/>
      </plugins>

      패키지와 클래스명은 본인이 사용하는거에 맞춰서 넣으시면 되요..
      mybatis 설정 xml 파일에 대한 설명에서 plugin 사용법에 대해 알아보시면 적용가능하실껍니다..

      그러나 요즘은 워낙 query 를 출력해주는 log 라이브러리들이 잘 나와 있어서 굳이 이걸 사용안하셔도 되요..log 라이브러리를 사용하시는 것이 더 좋습니다..

      http://log4jdbc.brunorozendo.com/

      여기 가시면 log4jdbc 사용 방법에 대해 아실수 있을꺼에요..
      대강의 사용방법을 말씀드리면 Spring 에서 DataSource를 만들때 log4jdbc에서 제공하는 클래스로 만들어줍니다..
      그러면 이것이 일종의 proxy 역할을 해서 이 DataSource를 이용하는 모든 SQL문에 대해 출력해주거든요..mybatis의 경우 변수에 값이 binding 된 상태로 출력해줍니다..

트랙백을 확인할 수 있습니다

URL을 배껴둬서 트랙백을 보낼 수 있습니다

다른 카테고리의 글 목록

프로그래밍/Java 카테고리의 포스트 목록을 보여줍니다