고객사 업무를 하다 보니 MyBatis를 만지는 일이 늘었다. SQL 매핑 프레임워크의 흐름은 JPA와 Hibernate 쪽으로 기운 지 오래지만, 한국에서는 아직 MyBatis 비율이 높다. 정부 표준 프레임워크가 MyBatis를 채택한 게 한몫했다고 본다.

이 글에서는 MyBatis로 SQL을 동적으로 만들 때 쓰는 <bind> 태그를 예제 세 개로 정리한다. 미리 말하면 앞의 두 예제는 쿼리만으로도 풀 수 있는 걸 번거롭게 만든 느낌이 있다. 실무에서 쓸모가 있는 건 세 번째다.

bind는 SQL 앞에서 변수를 하나 만든다

<select id="getUsersByAge" resultType="User">
    <bind name="maxAge" value="age + 5" />
    SELECT * FROM users
    WHERE age BETWEEN #{age} AND #{maxAge}
</select>

<bind>가 maxAge라는 변수를 만든다. 파라미터로 받은 age에 5를 더해 다섯 살 많은 사람까지 조회한다. 솔직히 이 예제는 #{age} + 5로 끝낼 일을 코드만 길게 만든 쪽에 가깝다. 그래도 방향은 보인다. 한 번 만든 변수를 쿼리 안에서 여러 번 재사용할 수 있고, 계산 로직을 SQL 본문 밖으로 빼니 쿼리도 읽기 쉬워진다.

LIKE 패턴은 bind로 한 번만 만든다

<select id="searchProducts" resultType="Product">
    <bind name="searchPattern" value="'%' + keyword + '%'" />
    SELECT * FROM products
    WHERE name LIKE #{searchPattern}
       OR description LIKE #{searchPattern}
</select>

검색 키워드 앞뒤에 %를 붙여 searchPattern을 만들고, 부분 일치 검색 두 곳에 같이 쓴다. 여기서 봐야 할 점은 <bind>의 value는 SQL이 실행되기 전에 Java 쪽에서 먼저 평가된다는 것이다. 문자열 연산만 되는 게 아니라 Java 코드를 부를 수 있다는 뜻이고, 세 번째 예제가 이걸 쓴다.

Enum 메서드의 반환값을 SQL 안으로 끌어온다

AWS Savings Plans 구매 유형을 다루는 쿼리다. Savings Plans 적용 대상일 때 특정 구매 유형 코드를 조회에서 빼야 했다.

<sql id="autospFilterSp">
    <bind name="purchaseCdListIncludedInSp" value="@com.a.b.c.common.constant.d.EnumPurchaseType@getPurchaseCdListIncludedInSp()"/>
    AND IF(ATSP.SP_YN = 'Y', RCM.OFFR_TYP_NM NOT IN (<foreach collection="purchaseCdListIncludedInSp" item="item" separator=",">#{item}</foreach>), TRUE)
</sql>

구매 유형 코드는 이미 Enum에 정의되어 있다.

@Getter
public enum CmmtOptiSpPurchaseType {
    /** ComputeSavingsPlans */
    COMPUTE("ComputeSavingsPlans", "Compute",
            "AmazonEC2",
            "AmazonECS",
            "AmazonEKS",
            "AWSLambda"
    ),
    /** EC2InstanceSavingsPlans */
    EC2_INSTANCE("EC2InstanceSavingsPlans", "EC2 Instance",
            "AmazonEC2"
    ),
    /** SageMakerSavingsPlans */
    SAGEMAKER("SageMakerSavingsPlans", "SageMaker",
            "AmazonSageMaker"
    )

    /** 코드 */
    private final String cd;
    /** 이름 */
    private final String nm;
    /** 서비스 코드 집합 */
    private final Set<String> svcCdSet;
    
    CmmtOptiSpPurchaseType(String cd, String nm, String... svcCdArray) {
        this.cd = cd;
        this.nm = nm;
        this.svcCdSet = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(svcCdArray)));
    }
    
    ...생략...
    
    // SQL에서 제외할 구매 유형 코드 목록을 돌려준다
    public static List<String> getPurchaseCdListIncludedInSp() {
        return Arrays.asList(EnumPurchaseType.COMPUTE.getCd(), EnumPurchaseType.EC2_INSTANCE.getCd());
    }
}

흐름은 두 단계다.

  1. @패키지.클래스@메서드() 문법으로 com.a.b.c.common.constant.d 패키지의 EnumPurchaseType에서 getPurchaseCdListIncludedInSp를 실행하고, 반환값을 purchaseCdListIncludedInSp 변수에 담는다. 반환값은 ["ComputeSavingsPlans", "EC2InstanceSavingsPlans"]다.

  2. 그 리스트를 <foreach>로 풀어 NOT IN (...) 절을 만든다.

그러면 이런 SQL이 나온다.

AND IF(ATSP.AUTOSP_YN = 'Y', RCM.OFFR_TYP_NM NOT IN ('ComputeSavingsPlans', 'EC2InstanceSavingsPlans'), TRUE)

#{item}으로 넣었으니 실제로는 ? 자리에 두 값이 바인딩된다. 위 SQL은 값이 들어간 모습을 풀어 쓴 것이다.

<bind>의 가치는 이미 있는 Enum과 메서드를 SQL이 그대로 가져다 쓰게 하는 데 있다. 제외할 코드 두 개를 쿼리에 문자열로 적었다면, 구매 유형이 바뀔 때 Enum과 매퍼 XML을 둘 다 고쳐야 한다. 지금은 Enum 한 곳만 고치면 된다.