본문 바로가기
실습/리눅스 서버 + 스프링 부트

Mybatis 중복 쿼리 공통화 (<sql>과 <include>) + Interceptor

by 이민우 2024. 9. 7.
728x90
반응형

이번 프로젝트에서는 4중 조인 등 복잡한 쿼리가 많은 탓에 JPA 대신 MyBatis를 채택해서 사용했다.

 

그리고 지금까지 MyBatis를 사용하면서 뭣도 모르고 사용하고 있었다는 사실을 눈치챘다.

 

그것은 바로 지금까지 <ref> 를 사용한 적이 없다는 것이었다.

 

어찌됐던 지금이라도 알았으니 다행이라고 생각하고, 연습 및 재활용을 위해 블로그에 기록을 해놓을까 한다.

 

<include>

코드에서 여러 메소드에 중복된 코드가 존재하면 어떻게 할까?

 

당연한 말이지만 중복된 코드가 여러 군데 존재하면 유지 보수가 어렵다. 만약 해당 코드에서 문제가 발생했을 경우 동일한 모든 코드를 수정해야 하기 때문이다.

 

게다가 쓸데없이 코드가 길어져 가독성도 저하된다.

 

이를 위해 중복된 코드가 발견될 경우 메소드로 만드는 "메소드화' 리팩토링을 하곤 한다.

 

그렇다면 MyBatis xml에서 중복된 쿼리가 계속해서 발생하면 어떻게 할까?

 

예를 들어 아래와 같은 사용자 테이블이 존재한다고 생각해보자.

 

위 사용자 테이블은 여러 서비스에서 가입된 사용자의 통합 테이블이라고 생각하고 만들었다. (물론 실제는 더 복잡하게 외래 테이블도 있고 그러겠지만, 테스트용이니 넘어가자.)

 

그렇다면 A 서비스에서는 해당 테이블에 쿼리를 날릴 때 어떻게 해야할까?

 

당연히 어느 쿼리를 날리던 반드시 SVC_TY_CD가 자신인 것만 날려야 할 것이다.

 

즉, 여러 쿼리에 동일하게 들어가는 쿼리가 존재할 수 밖에 없다.

 

이렇듯 중복된 코드를 한 곳에 모아 메소드화 하는 것처럼 중복된 쿼리를 한 곳에 모으는 것이 바로 <ref>를 사용하는 것이다.

 

이제 위 테이블을 가지고 테스트용 코드를 만들어보자.

 

테스트에 앞서 간단하게 아래와 같은 데이터를 넣어놓았다.

 

일반적인 방법

 

이제 테스트용 프로젝트를 생성해보자.

 

Java는 21로 해서 아래와 같이 프로젝트를 생성했다.

 

application.yml에 아래와 같이 DB 연결 설정 및 mapper 위치를 지정한다.

spring.datasource.url: jdbc:mariadb://localhost:3306/TEST_DB?&characterEncoding=utf8
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
spring.datasource.username=root
spring.datasource.password=1234

mybatis.mapper-locations=classpath*:com/mwlee/mybatistest/mapper/*.xml

 

이제 Mybatis Mapper xml과 인터페이스를 생성한다.

 

원래대로라면 아래와 같은 MyBatis Mapper가 완성될 것이다.

 

UserMapper.xml

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.mwlee.mybatistest.mapper.UserMapper">
	   
	<select id="selectAllUser" resultType="java.util.Map">
		SELECT
			USER_ID,
			USER_NM,
			SVC_TY_CD
		FROM TEST_USER_TABLE
		WHERE
			SVC_TY_CD = 'A'
	</select>
	
	<select id="selectSpecificUser" resultType="java.util.Map">
		SELECT
			USER_ID,
			USER_NM,
			SVC_TY_CD
		FROM TEST_USER_TABLE
		WHERE
			SVC_TY_CD = 'A'
			AND USER_ID=#{userId}
	</select>
	
	<select id="checkIfExistId" resultType="Boolean">
		SELECT
			IF(EXISTS(
				SELECT 1
				FROM TEST_USER_TABLE
				WHERE
					SVC_TY_CD = 'A'
					AND USER_ID=#{userId}
			), TRUE, FALSE)
	</select>
	
</mapper>

 

 

작성한 xml에 따라 Mapper Interface를 생성한다.

 

UserMapper.java

package com.mwlee.mybatistest.mapper;

import java.util.List;
import java.util.Map;

import org.apache.ibatis.annotations.Mapper;
import org.springframework.transaction.annotation.Transactional;

@Mapper
public interface UserMapper {

	@Transactional(readOnly = true)
	public List<Map<String, Object>> selectAllUser();

	@Transactional(readOnly = true)
	public Map<String, Object> selectSpecificUser(String userId);
	
	@Transactional(readOnly = true)
	public Boolean checkIfExistId(String userId);
}

 

 

이제 Test 코드를 작성한 후 실행해보자.

@Autowired UserMapper userMapper;

@Test
void contextLoads() {
		
	// 모든 사용자 검색
	List<Map<String, Object>> userList = userMapper.selectAllUser();
	log.info(userList.toString());

	// 특정 사용자 검색
	String existUser = "ASD";
	Map<String, Object> user = userMapper.selectSpecificUser(existUser);
	log.info(user.toString());
		
	// 특정 ID 사용 여부 확인
	String notExistUser = "ASF";
	Boolean isExist = userMapper.checkIfExistId(notExistUser);
	log.info(isExist.toString());
		
}

 

 

정상적으로 출력이 완료됨을 확인할 수 있다.

 

<include>사용

위 Mapper XML을 보면 아래와 같은 WHERE 조건이 반복되고 있음을 확인할 수 있다.

WHERE
    SVC_TY_CD = 'A'

 

앞서 설명했듯 <ref>는 이와 같이 여러 군데에 걸쳐 동일한 쿼리가 필요할 때 사용할 수 있다.

 

사용 방법은 <sql> 태그를 통해 별도로 쿼리를 작성해놓고, <include> 태그를 이용해 사용하는 것이다.

 

우선 동일한 쿼리를 <sql>을 통해 지정하자.

<sql id="selectAService">
    WHERE SVC_TY_CD = 'A'
</sql>

 

그리고 기존에 사용하던 위치에 <include> 태그를 통해 <sql>을 삽입한다.

<include refid="selectAService"/>

 

위에서 작성한 Mybatis xml을 이와 같이 바꾸면 아래와 같이 변경될 수 있다.

 

UserMapper.java

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.mwlee.mybatistest.mapper.UserMapper">

	<sql id="selectAService">
		WHERE
			SVC_TY_CD = 'A'
	</sql>
	   
	<select id="selectAllUser" resultType="java.util.Map">
		SELECT
			USER_ID,
			USER_NM,
			SVC_TY_CD
		FROM TEST_USER_TABLE
		<include refid="selectAService"/>
	</select>
	
	<select id="selectSpecificUser" resultType="java.util.Map">
		SELECT
			USER_ID,
			USER_NM,
			SVC_TY_CD
		FROM TEST_USER_TABLE
		<include refid="selectAService"/>
			AND USER_ID=#{userId}
	</select>
	
	<select id="checkIfExistId" resultType="Boolean">
		SELECT
			IF(EXISTS(
				SELECT 1
				FROM TEST_USER_TABLE
					<include refid="selectAService"/>
					AND USER_ID=#{userId}
			), TRUE, FALSE)
	</select>
	
</mapper>

 

WHERE 가 거슬린다면 <where> 태그를 사용하자. WHERE 1=1 등을 없앨 수 있다.

 

당연한 말이지만, WHERE 절에서만 사용하는 것이 아니기 때문에,

위의 경우 SELECT하는 컬럼이 동일하기에 이 부분에도 적용이 가능하다.

 

SELECT에도 적용한 경우

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">

<mapper namespace="com.mwlee.mybatistest.mapper.UserMapper">

	<sql id="selectAService">
		WHERE
			SVC_TY_CD = 'A'
	</sql>
	
	<sql id="userColumns">
		USER_ID,
		USER_NM,
		SVC_TY_CD
	</sql>
	
	   
	<select id="selectAllUser" resultType="java.util.Map">
		SELECT
			<include refid="userColumns"/>
		FROM TEST_USER_TABLE
		<include refid="selectAService"/>
	</select>
	
	<select id="selectSpecificUser" resultType="java.util.Map">
		SELECT
			<include refid="userColumns"/>
		FROM TEST_USER_TABLE
		<include refid="selectAService"/>
			AND USER_ID=#{userId}
	</select>
	
	<select id="checkIfExistId" resultType="Boolean">
		SELECT
			IF(EXISTS(
				SELECT 1
				FROM TEST_USER_TABLE
					<include refid="selectAService"/>
					AND USER_ID=#{userId}
			), TRUE, FALSE)
	</select>
	
</mapper>

 

 

 

+) Interceptor

한 김에 가능하면 하나만 더 해보자.

 

위 테이블의 경우 여러 서비스가 공유하는 테이블이라고 했다.

 

그렇다면 확장성이 좋아 A 서비스의 쿼리를 B 서비스에서도 활용할 수 있다.

 

그런데 WHERE SVC_TY_CD = 'A' 가 전부 스태틱하게 'A'로 박혀있으므로, B 서비스에서는 이 쿼리를 가져와서 사용하기 위해 해당 부분을 전부 수정해야 하는 번거로움이 존재한다.

 

이를 해결하려면 어떻게 해야할까? 바로 Interceptor를 이용해 MyBatis 쿼리에 특정 값을 집어넣으면 된다.

 

다음과 같은 인터셉터를 추가하자. 코드에 대한 설명은 주석으로 갈음한다.

 

MyBatisInterceptor.java

package com.mwlee.mybatistest.interceptor;

import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.springframework.stereotype.Component;

import lombok.extern.slf4j.Slf4j;

import java.util.HashMap;
import java.util.Map;
import java.util.Properties;

import org.apache.ibatis.cache.CacheKey;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;

@Intercepts(
		{ 
			// select처럼 조회는 "query"
			@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class }),
			@Signature(type = Executor.class, method = "query", args = { MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class }) ,
			// insert, delete, update 처럼 데이터 변화는 "update"
			@Signature(type = Executor.class, method = "update", args = { MappedStatement.class, Object.class })
		})
@Component
@Slf4j
public class MyBatisInterceptor implements Interceptor {
	
	@Override
	public Object intercept(Invocation invocation) throws Throwable {

        // 메서드 호출 시 전달된 인자들을 가져옴
		Object[] args = invocation.getArgs();
        // MappedStatement 객체는 SQL 매핑 정보에 대한 메타데이터
		MappedStatement ms = (MappedStatement) args[0];
        // 두 번째 인자는 SQL 쿼리에 전달될 파라미터
		Object param = args[1];

		if(param instanceof Map){
			
			// 파라미터를 Map으로 변환
			Map<String,Object> paramMap = (Map<String,Object>) param;
			
			// _service_name 추가
			paramMap.put("_service_name", 'A');

		}
		else if(param == null) {
			// null일 경우 Map 으로 변환 후 service_name 입력
			
			args[1] = new HashMap<>();
			
			// _service_name 추가
			((Map<String, Object>)args[1]).put("_service_name", 'A');
			
		}
		
        // 로그를 통해 수정된 파라미터를 출력
		log.info("Mapper Param : {}", args[1]);

		ms.getBoundSql(args[1]);

		return invocation.proceed();
	}

	@Override
	public Object plugin(Object target) {
		return Plugin.wrap(target, this);
	}

	@Override
	public void setProperties(Properties properties) {
		// Do nothing
	}

}

 

굳이 추가를 하자면, Signature의 args는 아래와 같다.

  • MappedStatement: SQL 문 및 매핑 정보를 담고 있는 객체.
  • Object: SQL 쿼리에 전달되는 파라미터의 타입.
  • RowBounds: 페이징 처리를 위한 쿼리 결과 범위 설정 클래스.
  • ResultHandler: 쿼리 결과를 처리하는 데 사용되는 핸들러 클래스.

이제 Mybatis interface의 String으로 받는 파라미터를 Map으로 수정한다. selectAllUser의 경우 null의 경우를 위 인터셉터에서 사용했으므로 굳이 추가할 필요는 없다.

 

그리고 xml의 'A'를 사전에 설정한 _service_name으로 변경한다.

 

이제 테스트 코드를 변경해서 다시 돌려보면 _service_name 파라미터가 자동 추가되어 동일한 결과가 출력됨을 확인할 수 있다.

@Test
void contextLoads() {
	
	// 모든 사용자 검색
	List<Map<String, Object>> userList = userMapper.selectAllUser();
	log.info(userList.toString());

	// 특정 사용자 검색
	Map<String, Object> param = new HashMap<>();
	param.put("userId", "ASD");
	Map<String, Object> user = userMapper.selectSpecificUser(param);
	log.info(user.toString());
		
	// 특정 ID 사용 여부 확인
	param.put("userId", "ASF");
	Boolean isExist = userMapper.checkIfExistId(param);
	log.info(isExist.toString());
	
}

728x90
반응형