본문 바로가기

프로그래밍/Spring

Spring-Mybatis 연동시 오류를 일으키는 SQL 쿼리문을 받아보자(1)

Spring와 Mybatis를 연동하는 어플리케이션을 개발하면서 SQL문 실행시 오류가 발생할 경우 이 SQL문 자체를 얻고 싶을때가 있다. 어플리케이션을 개발할때가 가장 대표적인 상황일것이고, 개발중인 상황이 아니라하더라도 운영중에서 사용자가 입력한 데이터가 쿼리와 결합해서 어떻게 실행이 되길래 오류를 일으키게 되는건지 그 이유를 알기 위해 요구될 수도 있다. 운영중이든 개발중이든 SQL문에 대한 요구는 항상 있게 마련이고, 그래서 이러한 요구에 대한 반영 형태중 가장 많이 사용되는 예가 아마 관련 SQL문을 log로 남기는 형태일 것이다. log로 남기는 것은 개발자가 별도로 구현을 하지 않아도 log4jdbc 같은 좋은 log 라이브러리가 나와있어서 이를 이용하면 원하는 결과를 얻어낼 수 있다.

 

그러나 log로 남겨야 하는 경우가 아닐 경우는 어떻게 할까? 예를 들어 SQL문 실행시 오류가 발생하면 관리자에게 이메일로 관련된 SQL문을 보내야 한다고 한다면...? log4jdbc 라이브러리에 SQL을 얻어오는 getter 메소드가 있다면 좋겠는데 API 문서를 봐도 찾을수가 없었다. 그리고 설사 이러한 메소드가 있다 해도 이 요구사항을 만족시키기 위해 log4jdbc를 사용하는 것도 설득력이 떨어지는 부분이 있다. 만약 요구사항에 log로도 남겨야 한다면 log4jdbc는 탁월한 선택이 될 수 있겠지만 log로 남겨야 하는 요구사항이 없을 경우 단지 SQL문을 얻기 위해 log4jdbc 라이브러리를 사용하는 것은 좀 그러했다. 그리고 log4jdbc 라이브러리를 사용한 사람들은 알고 있겠지만 기존의 사용하는 DataSource가 아닌 log4jdbc에서 제공하는 DataSource 클래스로 기존의 DataSource를 한번 감싸서 진행하게 된다. 이걸 한번 더 감싼다고 퍼포먼스에 문제가 있을거라 보지는 않지만 간혹 프로젝트에 나가 일하다보면 이런걸로도 문제를 삼는 개발자가 있기 마련이다. 그래서 이런 꼬투리(?)를 잡히지 않게 하기 위해서라도 조금은 고민해볼 필요가 있었다. 그래서 이번에 총 2개의 글로 이 주제에 대해 다루려고 한다. 글은 SQL 실행 오류가 발생했을 경우에 대해서만 다루려고 하지만 사용되는 기술은 SQL 실행 오류가 발생하지 않더라도 사용이 가능하기 때문에 응용의 폭은 넓다고 볼 수 있다. 이번 글에서는 Spring과 Mybatis 연동시 예외가 발생되는 원리에 대해 설명을 하고 다음 글에서 이러한 예외가 발생했을때 발생되는 예외에 SQL문을 같이 넣어서 보내는 방법에 대해 설명하도록 하겠다. 이 2개의 글에서 인용되는 Source는 github에 올라와 있으니 참조할 사람은 전체 소스를 참조하려면 github에서 끌어오면 된다.

 

앞에서 잠깐 바로 언급했지만 오류를 일으키는 SQL문을 예외를 통해서 받을려고 한다. 이렇게 처리한 이유는 Spring으로 작업한 사람들은 알겠지만 오류가 발생할 경우 예외를 던지게 하고 이러한 예외만 전담해서 처리하는 클래스를 별도로 두어서 하는 방식을 택하는지라 기존의 Spring이 구현하고 있는 컨셉을 바꾸고 싶지는 않았다. 이것에 대한 구체적인 내용은 다음 글에서 언급하도록 하기로 하고 일단 큰 구조는 Mybatis 작업시 SQL문 처리 관련으로 예외가 던져질때 문제의 SQL문 자체를 같이 넣어서 예외를 던진다고 이해하고 넘어가자.

 

던져지는 예외를 통해 SQL문을 전달한다고 얘기했기 때문에 이 시점에서 Spring와 Mybatis 연동시 예외가 던져지는 구조에 대해 이해할 필요가 있다. Spring과 Mybatis 연동시 SQL문을 이용한 작업의 핵심이 되는 클래스는 SqlSessionTemplate 클래스이다. Mybatis가 SQL문을 이용한 작업을 할때 SqlSession 인터페이스가 구현된 클래스로 작업을 하게 되는데 Mybatis는 SqlSession 인터페이스가 구현된 클래스로 DefaultSqlSession 클래스를 제공해준다. 그러나 이 클래스는 thread-safe한 구조가 아니기 때문에 multi thread 환경에서는 사용시 문제가 있는 클래스이다. 이에 반해 SqlSessionTemplate 클래스는 SqlSession 인터페이스가 구현되어 있고 Spring에 의해 관리되어지며 thread-safe한 구조이기 때문에 multi thread 환경에서도 안심하고 작업할 수 있는 클래스이다. Spring에 의해 관리되어진다는 것은 이 클래스 객체의 생성부터 소멸까지 Spring이 관리한다는 것을 의미하며 Spring에 의해 관리되어지기 때문에 트랜잭션 또한 Spring이 제어하는 트랜잭션 하에서 움직임을 뜻한다. 

 

이렇게 SqlSessionTemplate 클래스가 차지하는 비중은 크다고 볼 수 있는데 이 클래스 소스를 보면 우리가 흔히 Mybatis 작업을 하면서 사용하게 되는 select, insert, delete, update 메소드를 볼 수 있다.그도 그럴것이 이 메소드들은 SqlSession 인터페이스에 있는 메소드이기 때문에 SqlSessionTemplate 클래스 입장에서는 이 메소드들을 구현해야 하는 상황이다. 근데 이 메소드들이 구현된 소스를 보면 한결같이 사용되는 멤버변수가 있다. 다음은 SqlSessionTemplate의 insert 메소드 소스이다.

 

public int insert(String statement, Object parameter) {
    return this.sqlSessionProxy.insert(statement, parameter);
}

 

우리가 insert문을 사용할때 보면 해당 Insert문이 정의되어 있는 statsment id와 insert문에 값으로 들어갈 값들이 들어있는 클래스 객체를 파라미터로 넘겨야 한다. 그걸로 유추해보면 String statement 파라미터는 statement id, Object parameter는 insert문에 값으로 들어갈 값들이 들어있는 클래스 객체임을 알 수 있다. 그리고 this는 SqlSessionTemplate 클래스 자체를 의미하기 때문에 위에서 말한 멤버변수는 sqlSessionProxy임을 알 수 있다. 그리고 sqlSessionProxy가 다시 insert 메소드를 사용하고 있다. 그러면 sqlSessionProxy 멤버변수가 SqlSessionTemplate 클래스 안에서 어떻게 초기화되고 있는지를 보자. 다음은 SqlSessionTemplate 클래스에서 sqlSessionProxy 멤버변수가 선언되어 있는 부분과 이를 초기화하는 생성자 부분의 코드이다.

 

private final SqlSession sqlSessionProxy;

public SqlSessionTemplate(SqlSessionFactory sqlSessionFactory, ExecutorType executorType,
      PersistenceExceptionTranslator exceptionTranslator) {

    notNull(sqlSessionFactory, "Property 'sqlSessionFactory' is required");
    notNull(executorType, "Property 'executorType' is required");

    this.sqlSessionFactory = sqlSessionFactory;
    this.executorType = executorType;
    this.exceptionTranslator = exceptionTranslator;
    this.sqlSessionProxy = (SqlSession) newProxyInstance(
        SqlSessionFactory.class.getClassLoader(),
        new Class[] { SqlSession.class },
        new SqlSessionInterceptor());
}

 

위의 코드에서 보다시피 sqlSessionProxy 멤버변수는 SqlSession 인터페이스 타입으로 final이 걸려있다(이 부분은 지금 당장은 기억할 필요는 없지만 두번째 글에서 실제 구현하는 내용을 설명할때 알아두어야 할 필요가 있다)  SqlSessionTemplate 클래스에서는 3개의 생성자가 있는데 첫번재 생성자는 두번째 생성자에 default 값을 파라미터로 넘기면서 사용하고 있고, 두번째 생성자는 세번째 생성자에 default 값을 파라미터로 넘기면서 사용하고 있기 때문에 실제적인 작업은 세번째 생성자를 보면 된다. 여기서 봐야 할 부분은 sqlSessionProxy 변수를 설정하는 부분이다. java에서 제공하는 Proxy 클래스의 newProxyInstance 메소드를 이용해서 프록시 형태의 객체를 생성하고 있다. 파라미터로 받는 것중 SqlSessionFactory 타입 파라미터는 Spring과 Mybatis 연동시 Bean으로 만들어야 하는 SqlSessionFacotory  Bean이 설정된다. 이 부분은 SqlSessionTemplate을 Bean으로 만들때 보면 생성자로 SqlSessionFactory Bean을 받아서 넣는 부분이 있는데 이 SqlSessionFactory Bean 하나만 넣는 생성자가 위에서 잠깐 언급했던 SqlSessionTemplate 클래스의 첫번째 생성자이다. 이 sqlSessionProxy 변수를 생성할때 기억해야 할 것이 SqlSessionInterceptor 클래스 객체를 넣는 부분이다. 이 클래스가 바로 우리가 알아두어야 할 핵심부분이기 때문이다. SqlSessionInterceptor 클래스에 대한 설명은 밑에서 할 것이기 때문에 지금은 일단 이 클래스가 알아두어야 할 목표라는 정도만 이 시점에서 기억해두자. 이렇게 생성자를 거치면 sqlSessionProxy 변수는 Proxy 형태이지만 실질적으로는 DefaultSqlSession 클래스 객체가 설정된다. 그래서 위에서 insert 메소드를 실행하는것은 바꿔말하면 프록시 형태로 생성된 DefaultSqlSession 클래스 객체의 insert 메소드가 실행된다고 보면 된다.

 

자 지금부터 약간은 생각밖의 전개가 벌어지게 된다. 위에서 언급했지만 sqlSessionProxy 변수는 프록시 형태로 생성된다고 했다. 여기서 두루뭉실하게 프록시 형태라고 했지만 조금 더 구체적으로 얘기하면 이 객체는 SqlSessionFactory 클래스가 만들어준 SqlSession 인터페이스가 구현된 객체이며 그 객체의 메소드에 대한 실행은 SqlSessionInterceptor 클래스 객체가 대신 실행하는 그런 객체라고 보면 된다. 즉 객체의 생성과 실행이 직접적인 생성과 실행이 아닌 간접적인 생성과 실행이라고 이해하면 된다. 그래서 SqlSessionFactory 타입 bean이 만든 DefaultSqlSession 클래스 객체를 SqlSessionInterceptor 클래스 객체가 대신 실행해주는 그런 상황이 벌어진다고 보면 된다. sqlSessionProxy가 내부적으로는 DefaultSqlSession 객체를 가지고 있지만 그 실행은 다른곳에서 이루어진다고 이해하면 DefaultSqlSession 클래스의 insert 메소드가 살제 이루어지는 곳은 SqlSessionInterceptor 클래스에서 실행된다고 보면 된다. 이제 SqlSessionInterceptor 클래스 소스를 보자. 이 클래스는 SqlSessionTemplate의 내부 클래스로 선언되어 있다.

 

private class SqlSessionInterceptor implements InvocationHandler {
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
      SqlSession sqlSession = getSqlSession(
          SqlSessionTemplate.this.sqlSessionFactory,
          SqlSessionTemplate.this.executorType,
          SqlSessionTemplate.this.exceptionTranslator);
      try {
        Object result = method.invoke(sqlSession, args);
        if (!isSqlSessionTransactional(sqlSession, SqlSessionTemplate.this.sqlSessionFactory)) {
          // force commit even on non-dirty sessions because some databases require
          // a commit/rollback before calling close()
          sqlSession.commit(true);
        }
        return result;
      } catch (Throwable t) {
        Throwable unwrapped = unwrapThrowable(t);
        if (SqlSessionTemplate.this.exceptionTranslator != null && unwrapped instanceof PersistenceException) {
          // release the connection to avoid a deadlock if the translator is no loaded. See issue #22
          closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
          sqlSession = null;
          Throwable translated = SqlSessionTemplate.this.exceptionTranslator.translateExceptionIfPossible((PersistenceException) unwrapped);
          if (translated != null) {
            unwrapped = translated;
          }
        }
        throw unwrapped;
      } finally {
        if (sqlSession != null) {
          closeSqlSession(sqlSession, SqlSessionTemplate.this.sqlSessionFactory);
        }
      }
    }
  }

 

SqlSessionInterceptor 클래스는 InvocationHandler 인터페이스를 구현하고 있는데 이것은 Proxy 객체를 실행하는 클래스를 만들때 반드시 따라야 한다. InvocationHandler 인터페이스에 있는 invoke 메소드가 실행이 되면서 invoke 메소드의 파라미터로 넘어간 Proxy 객체와 메소드 정보, 그리고 파라미터 정보를 이용해서 Proxy 객체의 메소드가 실행이 된다. 그러나 여거 소스를 보면 Proxy 객체로 넘어간 SqlSession 인퍼테이스가 구현되어 있는 DefaultSqlSession 객체의 Proxy 객체를 이용해서 메소드를 실행하는게 아니라 getSqlSession 메소드를 이용해서 다시 SqlSession 인터페이스를 구현한 객체를 구하고 있다. 그리고  파라미터로 받은 Method 클래스 객체를 통해서 SqlSession 인터페이스에 선언된 메소드를 실행하고 있다. 이 시점에서 SQL에 대한 실행이 발생하게 되며 여기서 문제가 생기면 예외가 발생하게 된다. 그리고 실행에 대해 문제가 없으면 SqlSession이 별도 트랜잭션 관리를 받는지 확인을 해서 별도 트랜잭션 관리를 받지 않으면 commit하게 된다. 여기서는 Spring이 트랜잭션을 관리하기 때문에 SqlSession이 별도 트랜잭션 관리를 받으므로 commit을 SqlSessionInterceptor 클래스에서 하지 않는다. 그리고 메소드 실행 결과를 return 하게 된다.

 

지금까지는 정상적인 실행 과정에 대해 설명을 했고 이제 위에서 잠깐 언급했던 예외가 발생된 상황에 대해 설명하겠다. 예외가 발생되면 catch 블럭에 가게 된다. 이때 발생되는 예외는 SQL 관련 예외(ex : SQLException)가 발생되는게 아니라 Proxy로 인해서 발생되는 예외이기 때문에 InvocationTargetException이 발생한다. 즉 예외 또한 Proxy 같이 한번 감싸진 형태로 예외가 던져진다. 그래서 실제 SQL 관련 예외 객체를 얻어야 하는 작업을 거쳐야 하는데 그 작업을 해주는 메소드가 unwrapThrowable 메소드이다. 이 메소드가 InvocationTargetException 클래스 객체 안에 있는 실제 예외 객체를 return 해준다. SqlSession 인터페이스의 insert 메소드를 실행하는 과정에서 예외가 발생했다면 이 unwrapThrowable 메소드를 통해 return 되는 예외 클래스 객체는 mybatis에서 정의된 PersistenceException 클래스 객체이다. 이렇게 받은 mybatis 예외를 이용해서 하는 작업이 있는데 바로 Spring에서 정의된 예외 클래스로 변환하는 작업이다. Spring 입장에서는 mybatis 뿐만 아니라 Database 작업과 관련된 라이브러리를 사용할 수 있다. Native JDBC를 사용할 수도 있고 Hibernate, Eclipselink 등의 ORM과도 연동될 수 있다. 이럴때 똑같은 insert 작없을 하는데도 Native JDBC에서는 SQLException을 받아서 처리하고, Hibernate나 Eclipselink 에서는 거기에서 정의된 예외를 던지게끔 하면 Spring을 이용해서 개발할때 연동되는 라이브러리에 따라 예외를 받는 catch문을 다르게 써야 하기 때문에 개발하는데 있어서 불편함이 있게 된다. 무엇을 연동하든 간에 Spring에서는 동일한 예외 클래스 객체를 던지게끔 해준다면 개발자는 그만큼 개발하기 편한 면이 있다. 그래서 Spring에서는 각 Database 연동 관련 라이브러리가 던지는 예외들을 Spring이 정의한 예외로 변환하는 일종의 변환기 역할을 하는 클래스가 있다. 이러한 클래스를 Spring 또는 Spring과 연동하는 Database 관련 라이브러리가 제공하는데 이 클래스는 Spring의 PersistenceExceptionTranslator 인터페이스를 구현해야 한다. 우리는 mybatis를 연동하고 있으니 mybatis 입장에서 보자면 이 변환기 역할을 위해 mybatis가 제공하는 클래스인 MybatisExceptionTranslator 클래스 객체가 설정이 된다. 다시 코드로 돌아가서 이런 변환기가 설정되어 있는지, 그리고 현재 받은 예외가 mybatis의 PersistenceException 클래스 또는 그것의 하위 클래스인지를 체크해서 이를 만족하면 SqlSession 인터페이스를 구현한 객체를 닫고(close), 객체를 null 값으로 설정한뒤 설정되어 있는 예외 변환기를 이용해 Mybatis 예외를 Spring 예외로 바꾼다. 이 시점에서 Spring의 DataAccessException 클래스의 하위 클래스 예외 객체가 생성이 된다. 그런 뒤에 이 변환된 예외로 던져지게 된다. 만약 변환기가 설정이 안되어 있으면 위에서 언급했던 InvocationTargetException 예외 클래스 객체에서 꺼냈던 mybatis 예외 객체를 던져주게 된다.

 

지금까지 우리가 아무 생각없이 SqlSesion 인터페이스에서 정의한 select, insert, delete, update  등의 메소드를 실행할때 SqlSessionTemplate 클래스 객체에서 어떤 과정으로 실행이 되는지, 그리고 예외가 발생했을때 이를 어떻게 처리하는지에 대해 설명했다. 앞으로 우리가 다룰 내용과 비교하면 지금까지의 내용이 너무 길수도 있는데 이러한 지식을 알아야 앞으로 우리가 작업할 내용을 하는데 있어 도움이 될꺼란 생각으로 설명했다. 다음에는 실제로 기존에 이렇게 구현되어 있는 부분을 SQL문이 포함된 예외가 던져지게끔 작업하는 내용에 대해 설명하겠다.