MyBatis 실행 과정 상세 분석
MyBatis를 사용하는 개발자라면 누구나 알지만, MyBatis의 SQL 실행 흐름에 대해 깊이 이해하고 있는 사람은 많지 않습니다. 본 기사를 통해 다음 내용을 학습할 수 있습니다:
- Mapper 인터페이스와 매핑 파일의 바인딩 방식
- MyBatis에서 SQL 문장 실행 흐름
- 사용자 정의 파라미터 설정 핸들러(typeHandler) 구현 방법
- 사용자 정의 결과 집합 핸들러(typeHandler) 구현 방법
본 기사는 MyBatis 3.5.5 버전 소스 코드를 기반으로 작성되었습니다.
MyBatis 기본 실행 흐름
MyBatis에서 프로그래밍 방식으로 데이터를 조회하는 기본적인 코드는 다음과 같습니다:
SqlSession session = sqlSessionFactory.openSession();
UserMapper userMapper = session.getMapper(UserMapper.class);
List<LwUser> userList = userMapper.listUserByUserName("홀로 늑대 1호");
첫 번째 줄은 SqlSession 객체를 얻는 과정이며, 이전 기사에서 분석했습니다. 두 번째 줄은 UserMapper 인터페이스를 얻는 과정이며, 세 번째 줄은 전체 조회 문장 실행 과정을 구현합니다. 이제 두 번째와 세 번째 단계를 자세히 분석해 보겠습니다.
Mapper 인터페이스 획득 (getMapper)
두 번째 단계는 SqlSession 객체를 통해 Mapper 인터페이스를 얻는 과정입니다. 이 흐름은 비교적 간단합니다. session.getMapper 메서드 호출 후 실행 시퀀스 다이어그램은 다음과 같습니다:
- getMapper 호출 후, Configuration 객체에서 Mapper 객체를 얻습니다. 프로젝트 시작 시 Mapper 인터페이스가 로드되어 Configuration 객체에 저장됩니다.
- Configuration 객체의 MapperRegistry 속성을 통해 getMapper 메서드를 계속 호출합니다.
- 타입에 따라 MapperRegistry 객체의 knownMappers에서 현재 타입에 해당하는 프록시 팩토리 클래스를 얻고, 프록시 팩토리 클래스를 통해 해당 Mapper의 프록시 클래스를 생성합니다.
- 최종적으로 인터페이스에 해당하는 프록시 클래스인 MapperProxy 객체를 얻습니다. MapperProxy는 InvocationHandler를 구현하며 JDK 동적 프록시를 사용합니다.
이제 MapperRegistry 객체의 HashMap 속성 knownMappers에 데이터가 저장되는 시점에 대한 질문이 있습니다.
Mapper 인터페이스와 매핑 파일의 바인딩 시점
Mapper 인터페이스와 매핑 파일은 mybatis-config 설정 파일을 로드할 때 저장됩니다. 실행 시퀀스 다이어그램은 다음과 같습니다:
- 먼저 SqlSessionFactoryBuilder 메서드의 build() 메서드를 수동으로 호출합니다:
- 그런 다음 XMLConfigBuilder 객체를 생성하고 parse 메서드를 호출합니다:
- 그런 다음 parseConfiguration을 호출하여 설정 파일을 계속 구문 분석합니다. 여기에는 전역 설정 파일의 최상위 노드를 각각 구문 분석하며, 다른 것은 잠시 보지 않고 mappers 노드를 직접 살펴보겠습니다.
- mappersElement를 계속 호출하여 mappers 파일을 구문 분석합니다. 이 메서드는 mappers 노드 구성을 4가지 방식으로 구분하며, 빨간색 상자의 두 가지 방식은 직접 구성된 XML 매핑 파일이고, 파란색 상자의 두 가지 방식은 직접 구성된 Mapper 인터페이스를 구문 분석합니다. 여기에서도 알 수 있듯이, 어떤 방식으로 구성하든 MyBatis는 최종적으로 XML 매핑 파일과 Mapper 인터페이스를 연결합니다.
- 두 번째와 세 번째 방식(직접 XML 매핑 파일을 구성하는 구문 분석 방식)을 살펴보겠습니다. XMLMapperBuilder 객체를 생성하고 parse 메서드를 호출합니다. 하지만 여기에는 문제가 있습니다. 다중 상속이나 다중 종속성이 있는 경우 여기서 완전히 구문 분석되지 않을 수 있습니다. 예를 들어 세 개의 매핑 파일이 서로 종속되어 있다면, if 내부(최악의 경우)에서는 1개만 로드할 수 있고, 실패 2개가 발생합니다. 그런 다음 아래 if 외부 코드를 통해 다시 1개만 로드할 수 있고, 1개가 실패합니다(아래 코드에서는 1번만 처리되며, 다시 실패하면 추가로 처리되지 않음): 물론 이것은 나중에 실행 시 계속 순회하여 전체적으로 구문 분석됩니다. 하지만 서로 참조하는 것은 구문 분석 실패 오류를 유발하므로 개발 과정에서 순환 종속성을 피해야 합니다.
- 매핑 파일 구문 분석 후 bindMapperForNamespace 메서드를 호출하여 Mapper 인터페이스와 매핑 파일을 바인딩합니다:
- Configuration 객체의 addMapper를 호출합니다.
- Configuration 객체의 속성 MapperRegistry 내부 addMapper 메서드를 호출합니다. 이 메서드는 Mapper 인터페이스를 knownMappers에 추가하는 것입니다. 따라서 위의 getMapper에서 직접 얻을 수 있습니다: 여기까지 Mapper 인터페이스와 XML 매핑 파일의 바인딩을 완료했습니다.
- 위의 빨간색 상자 코드를 주목하세요. parse 메서드가 다시 한 번 호출됩니다. 이 parse 메서드는 주로 주석을 구문 분석하는 데 사용됩니다. 예를 들어 다음 문장:
@Select("select * from lw_user")
List<LwUser> listAllUser();
따라서 이 메서드는 @Select 등 주석을 구문 분석합니다. 주목할 점은 parse 메서드 내에서 XML 매핑 파일을 다시 한 번 구문 분석한다는 것입니다. 위에서 언급했듯이 mappers 노드에는 4가지 구성 방식이 있으며, 그중 두 가지는 Mapper 인터페이스를 구성하며, Mapper 인터페이스를 구성하면 addMapper 인터페이스를 먼저 호출하고 매핑 파일을 구문 분석하지 않으므로 주석 구문 분석 메서드 parse 내에서 XML 매핑 파일을 다시 한 번 구문 분석해야 합니다. 구문 분석이 완료된 후 Mapper 인터페이스의 메서드를 구문 분석하고 각 메서드의 전체 정규화된 클래스 이름을 key로 Configuration의 mappedStatements 속성에 저장합니다.
동일한 value가 2번 저장된다는 점에 주목하세요. 하나는 전체 정규화된 이름을 key로 사용하고, 다른 하나는 메서드 이름(SQL 문장의 id)만을 key로 사용합니다. 따라서 최종 mappedStatements는 다음과 같습니다: 사실 인터페이스 방식을 통해 프로그래밍하는 경우 getStatement 시 전체 정규화된 이름을 기준으로 가져오므로 중복이 있어도 영향을 받지 않습니다. 이렇게 하는 이유는 이전 버전의 사용법과의 호환성을 위해 필요하기 때문입니다. 즉, 인터페이스를 통해가 아니라 메서드 이름을 직접 사용하여 쿼리하는 것입니다:
session.selectList("com.lonelyWolf.mybatis.mapper.UserMapper.listAllUser");
여기서 shortName이 중복되지 않으면 다음과 같이 약식으로 쿼리할 수 있습니다:
session.selectList("listAllUser");
하지만 약식으로 쿼리하면 shortName이 중복되면 다음과 같은 예외가 발생합니다: 이 예외는 StrickMap의 get 메서드에서 발생합니다:
SQL 실행 흐름 분석
위에서 언급했듯이, 얻은 Mapper 인터페이스는 실제로 프록시 객체로 래핑됩니다. 따라서 조회 문장을 실행하면 프록시 객체 메서드를 실행하는 것입니다. 이제 Mapper 인터페이스의 프록시 객체인 MapperProxy를 기반으로 조회 흐름을 분석해 보겠습니다.
전체 SQL 실행 흐름은 두 가지 주요 단계로 나눌 수 있습니다:
- SQL 찾기
- SQL 문장 실행
SQL 찾기
먼저 SQL 문장을 찾는 실행 시퀀스 다이어그램을 살펴보겠습니다:
- 프록시 패턴을 이해하는 사람은 호출된 객체의 메서드가 실행된 후 실제로 프록시 객체의 invoke 메서드를 실행한다는 것을 알 것입니다.
- 여기서 Object 클래스의 메서드를 호출하지 않았으므로 else를 통해 진행됩니다. else에서는 MapperProxy 내부 클래스 MapperMethodInvoker의 cachedInvoker 메서드를 계속 호출합니다. 여기에는 우리가 default 메서드인지 여부를 판단하는 로직이 있습니다. JDK 1.8에서 인터페이스에 새로운 default 메서드를 추가할 수 있으며, default 메서드는 추상 메서드가 아니므로 특별한 처리가 필요합니다(처음에는 캐시에서 가져오며, 캐시 관련 지식은 여기서는 다루지 않고 나중에 별도로 분석하겠습니다).
- 다음으로 MapperMethod 객체를 생성합니다. 이 객체는 Mapper 인터페이스의 해당 메서드 정보와 해당 SQL 문장 정보를 캡슐화합니다: 여기서 실행할 SQL 문장, 요청 파라미터, 메서드 반환 값이 모두 MapperMethod 객체로 구문 분석 및 캡슐화되며, 이후 SQL 문장 실행 준비를 시작할 수 있습니다.
SQL 문장 실행
먼저 SQL 문장 실행의 실행 시퀀스 다이어그램을 살펴보겠습니다:
- 위의 흐름을 계속 진행하여 execute 메서드로 들어갑니다:
- 여기서는 문장 유형과 반환 값 유형에 따라 실행 방식을 결정합니다. 여기서 반환 값은 컬렉션이므로 executeForMany 메서드로 진입합니다:
- 여기서는 먼저 저장된 파라미터를 변환한 후, 다시 SqlSession 객체로 돌아가 selectList 메서드를 계속 호출합니다:
- 다음으로 Execute에게 흐름을 위임하여 query 메서드를 실행하고, 최종적으로 queryFromDatabase 메서드를 호출합니다:
- 여기에 도달하면 드디어 본론으로 들어갑니다. 보통 do로 시작하는 메서드는 실제 작업을 수행하는 것입니다. Spring에서도 많은 곳에서 이러한 명명 방식을 사용합니다: 위의 SQL 문장은 아직 플레이스홀더 방식이며 파라미터가 설정되지 않았으므로, return 위의 줄에서 prepareStatement 메서드를 호출하여 Statement 객체를 생성할 때 파라미터를 설정하고 플레이스홀더를 대체합니다. 파라미터 설정 방법은 잠시 건너뛰고, 흐름 실행이 완료된 후 파라미터 매핑과 결과 집합 매핑을 별도로 분석하겠습니다.
- PreparedStatementHandler 객체의 query 메서드로 계속 진입합니다. 이 단계는 jdbc 작업 객체 PreparedStatement의 execute 메서드를 호출하는 것을 볼 수 있으며, 마지막 단계는 결과 집합을 변환하고 반환하는 것입니다. 여기까지 전체 SQL 문장 실행 흐름 분석이 완료됩니다. 중간에 일부 파라미터 저장 및 변환은 깊이 다루지 않았습니다. 파라미터 변환은 핵심이 아니며, 전체 데이터 흐름을 명확히 이해하면 우리도 자체 구현 방식을 가질 수 있습니다. 저장된 후 다시 구문 분석하여 읽을 수 있으면 됩니다.
파라미터 매핑
이제 쿼리 실행 전 파라미터가 어떻게 설정되는지 살펴보겠습니다. 먼저 prepareStatement 메서드로 들어가 보겠습니다: 최종적으로 StatementHandler의 parameterize를 호출하여 파라미터를 설정하는 것을 발견할 수 있습니다. 다음 단계에서는 코드 길이를 줄이기 위해 단계별로 점진적으로 들어가지 않고, 직접 파라미터 설정 메서드로 들어가겠습니다: 위의 BaseTypeHandler는 추상 클래스이며 setNonNullParameter는 구현되지 않았으며, 모두 하위 클래스에게 위임됩니다. 각 하위 클래스는 데이터베이스의 한 유형에 해당합니다. 아래 그림은 기본 하위 클래스인 StringTypeHandler입니다. 별다른 로직이 없으며 파라미터를 설정하는 것만 수행합니다. String에서는 jdbc의 setString 메서드를 호출하고, int인 경우 setInt 메서드를 호출합니다. 이러한 하위 클래스를 보면 이전에 읽었던 MyBatis 파라미터 구성에 대해 명확하게 알 수 있습니다. 이러한 하위 클래스는 시스템에서 기본 제공하는 일부 typeHandler입니다. 이러한 기본 typeHandler는 기본적으로 등록되어 Java 객체와 바인딩됩니다: MyBatis에서 기본 데이터 유형의 매핑을 제공하므로 SQL을 작성할 때 파라미터 매핑 관계를 생략할 수 있으며, 다음과 같이 직접 사용할 수 있습니다. 시스템은 파라미터의 유형에 따라 적절한 typeHander를 자동으로 선택할 수 있습니다:
select user_id, user_name from lw_user where user_name=#{userName}
위의 문장은 다음과 동일합니다:
select user_id, user_name from lw_user where user_name=#{userName, jdbcType=VARCHAR}
또는 직접 typeHandler를 지정할 수 있습니다:
select user_id, user_name from lw_user where user_name=#{userName, jdbcType=VARCHAR, typeHandler=org.apache.ibatis.type.IntegerTypeHandler}
여기서 typeHandler를 구성했으므로 기본 매핑을 읽지 않고 구성된 typeHandler를 우선적으로 사용합니다. 유형이 일치하지 않으면 바로 오류가 발생합니다: 여기까지 읽으면 많은 사람이 자체 typeHandler를 정의한 후 자체 클래스로 구성할 수 있다는 것을 알게 되었습니다. 다음으로 자체 typeHandler를 정의하는 방법을 살펴보겠습니다.
사용자 정의 typeHandler
사용자 정의 typeHandler는 BaseTypeHandler 인터페이스를 구현해야 합니다. BaseTypeHandler에는 4개의 메서드가 있으며, 결과 집합 매핑을 포함하여 코드 길이를 줄이기 위해 여기에 작성되지 않았습니다:
package com.lonelyWolf.mybatis.typeHandler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class CustomTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement preparedStatement, int index, String param, JdbcType jdbcType) throws SQLException {
System.out.println("사용자 정의 typeHandler가 활성화되었습니다");
preparedStatement.setString(index, param);
}
@Override
public String getNullableResult(ResultSet resultSet, String columnName) throws SQLException {
System.out.println("columnName을 기반으로 결과를 가져옵니다->사용자 정의 typeHandler가 활성화되었습니다");
return resultSet.getString(columnName);
}
@Override
public String getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException {
System.out.println("columnIndex를 기반으로 결과를 가져옵니다->사용자 정의 typeHandler가 활성화되었습니다");
return resultSet.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement callableStatement, int columnIndex) throws SQLException {
return callableStatement.getString(columnIndex);
}
}
그런 다음 위의 조회 문장을 수정합니다:
select user_id, user_name from lw_user where user_name=#{userName, jdbcType=VARCHAR, typeHandler=com.lonelyWolf.mybatis.typeHandler.CustomTypeHandler}
실행하면 사용자 정의 typeHandler가 활성화됩니다:
결과 집합 매핑
이제 결과 집합 매핑을 살펴보겠습니다. 위의 SQL 실행 흐름의 마지막 메서드로 돌아가겠습니다:
resultSetHandler.handleResultSets(ps)
결과 집합 매핑의 로직은 비교적 복잡합니다. 매우 많은 경우를 고려해야 하기 때문입니다. 여기서는 각 세부 사항을 깊이 다루지 않고, 직접 결과 집합을 구문 분석하는 코드로 들어가겠습니다. 아래 5개 코드 조각은 간단하지만 완전한 구문 분석 흐름입니다: 위의 코드 조각에서도 볼 수 있듯이, 실제 결과 집합 구문 분석은 매우 복잡합니다. 이전 기사에서 소개한 복잡한 조회와 같이, 하나의 조회가 다른 조회를 계속 중첩할 수 있으며, 지연 로딩과 같은 복잡한 기능도 처리해야 하므로 로직 분기가 많습니다. 하지만 어떻게 처리하든 최종적으로는 위의 기본 흐름을 따르며, 최종적으로 typeHandler를 호출하여 조회된 결과를 얻습니다.
예상대로입니다. 이것은 위에서 매핑된 파라미터의 typeHandler입니다. typeHandler에는 파라미터 설정 메서드뿐만 아니라 결과 집합 가져오기 메서드도 포함되어 있습니다(위에서 파라미터 설정 시 생략됨).
사용자 정의 typeHandler 결과 집합
따라서 위의 MyTypeHandler 예제를 사용하여 값을 가져오는 메서드를 다시 작성해 보겠습니다(파라미터 설정 메서드는 생략됨):
package com.lonelyWolf.mybatis.typeHandler;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import java.sql.CallableStatement;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
public class CustomResultTypeHandler extends BaseTypeHandler<String> {
@Override
public void setNonNullParameter(PreparedStatement preparedStatement, int index, String param, JdbcType jdbcType) throws SQLException {
System.out.println("파라미터 설정->사용자 정의 typeHandler가 활성화되었습니다");
preparedStatement.setString(index, param);
}
@Override
public String getNullableResult(ResultSet resultSet, String columnName) throws SQLException {
System.out.println("columnName을 기반으로 결과를 가져옵니다->사용자 정의 typeHandler가 활성화되었습니다");
return resultSet.getString(columnName);
}
@Override
public String getNullableResult(ResultSet resultSet, int columnIndex) throws SQLException {
System.out.println("columnIndex를 기반으로 결과를 가져옵니다->사용자 정의 typeHandler가 활성화되었습니다");
return resultSet.getString(columnIndex);
}
@Override
public String getNullableResult(CallableStatement callableStatement, int columnIndex) throws SQLException {
return callableStatement.getString(columnIndex);
}
}
Mapper 매핑 파일 구성을 수정합니다:
<resultMap id="CustomUserResultMap" type="lwUser">
<result column="user_id" property="userId" jdbcType="VARCHAR" typeHandler="com.lonelyWolf.mybatis.typeHandler.CustomResultTypeHandler" />
<result column="user_name" property="userName" jdbcType="VARCHAR" />
</resultMap>
<select id="listUserByUserName" parameterType="String" resultMap="CustomUserResultMap">
select user_id, user_name from lw_user where user_name=#{userName, jdbcType=VARCHAR, typeHandler=com.lonelyWolf.mybatis.typeHandler.CustomResultTypeHandler}
</select>
실행 후 출력은 다음과 같습니다: 속성에 하나만 구성했으므로 한 번만 출력됩니다.
작업 흐름도
위에서 코드의 흐름을 설명했지만, 여러 번 돌아가면서 혼란스러울 수 있습니다. 따라서 MyBatis 주요 작업 흐름을 더 명확하게 보여주기 위해 주요 객체 간의 흐름도를 그려보겠습니다: 위의 작업 흐름도에서 SqlSession 아래에는 4대 객체가 있으며, 이 4대 객체도 매우 중요합니다. 나중에 인터셉터를 학습할 때 이 4대 객체를 대상으로 인터셉트합니다. 이 4대 객체의 자세한 내용은 다음 기사에서 분석하겠습니다.
요약
본 기사는 주로 MyBatis의 SQL 실행 흐름을 분석했습니다. 분석 과정에서 사용자 정의 typeHandler를 통해 사용자 정의 파라미터 매핑과 결과 집합 매핑을 구현하는 방법을 예시로 설명했습니다. 하지만 MyBatis에서 제공하는 기본 매핑은 대부분의 요구 사항을 충족할 수 있습니다. 일부 속성에 특별한 처리가 필요한 경우 사용자 정의 typeHandler를 사용하여 구현할 수 있습니다. 본 기사를 이해했다면 다음과 같은 내용을 명확히 이해했을 것입니다:
- Mapper 인터페이스와 매핑 파일의 바인딩 방식
- MyBatis에서 SQL 문장 실행 흐름
- 사용자 정의 MyBatis 파라미터 설정 핸들러(typeHandler)
- 사용자 정의 MyBatis 결과 집합 핸들러(typeHandler)
물론 많은 세부 사항은 언급되지 않았으며, 소스 코드를 읽을 때 모든 코드 줄을 이해할 필요는 없습니다. 예를 들어 약간 복잡한 비즈니스 시스템의 경우, 우리가 프로젝트 개발자라 할지라도 특정 모듈을 담당하지 않는다면 모든 코드 줄의 의미를 명확히 이해하기 어려울 수 있습니다. 따라서 MyBatis 및 기타 프레임워크의 소스 코드도 마찬가지입니다. 먼저 전체적인 흐름과 설계 사상을 파악하고, 특정 구현 세부 사항에 관심이 있다면 추가로 깊이 있게 학습하는 것이 중요합니다.