포스트

MyBatis 실무 패턴 — 값 바인딩·동적 SQL·1:N 매핑의 경계

MyBatis에서 #{ } 값 바인딩과 ${ } 문자열 치환의 경계를 먼저 잡고, 시퀀스 채번·1:N 컬렉션 매핑·동적 컬럼 SQL을 어떤 책임으로 나눠 쓰는지 정리한다.

MyBatis 실무 패턴 — 값 바인딩·동적 SQL·1:N 매핑의 경계

MyBatis를 쓰다 보면 서로 다른 문제들이 모두 XML Mapper 안에서 해결되기 때문에 한 덩어리처럼 보인다. 하지만 실제로는 역할이 다르다.

1
2
3
4
5
6
7
8
9
10
11
SQL에 "값"을 넣는다
→ #{} Parameter Binding

SQL의 "구조"를 바꾼다
→ ${} / foreach / if 같은 Dynamic SQL

조회 결과의 "객체 구조"를 만든다
→ resultMap / collection

INSERT 전후의 "보조 SQL"을 실행한다
→ selectKey

이 경계를 잡아두면 ${}#{}를 헷갈리거나, 동적 SQL과 Result Mapping을 같은 문제로 보는 일이 줄어든다. 이 글은 실무에서 자주 만나는 네 패턴을 이 역할 기준으로 정리한다.

먼저 가장 중요한 경계 — #{}${}

둘 다 XML 안에 값을 넣는 것처럼 보이지만 처리 방식이 다르다.

표현역할SQL 관점대표 용도
#{value}Parameter Binding? Placeholder로 전달조건값, INSERT 값
${identifier}문자열 치환SQL Text 자체를 변경Table명, Column명처럼 Binding할 수 없는 SQL 구조

예를 들어 값 비교는 #{}를 사용한다.

1
2
3
SELECT *
FROM tbl_test
WHERE id = #{id}

DB Driver에는 개념적으로 다음처럼 전달된다.

1
2
3
SELECT *
FROM tbl_test
WHERE id = ?

반대로 Column명은 Prepared Statement의 값 Parameter로 Binding할 수 없다.

1
2
SELECT ${column}
FROM tbl_test

${column}은 SQL 문자열 자체를 바꾼다. 그래서 사용자 입력을 그대로 넣으면 SQL Injection 경로가 된다.

1
2
3
4
5
6
값
→ #{}

Table명 / Column명처럼 SQL 구조
→ ${}
→ 반드시 허용 목록으로 제한

이 구분이 뒤의 동적 Column 패턴까지 이어지는 가장 중요한 전제다.

Sequence 채번 — INSERT 전 보조 SQL 실행

Oracle Sequence처럼 INSERT 전에 PK 값을 확보해야 할 때 <selectKey>를 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<mapper namespace="io.test.TestDao">
    <insert id="save">
        <selectKey
            keyProperty="identifierSequence"
            resultType="java.lang.Integer"
            order="BEFORE">
            SELECT SEQUENCE_GENERATOR.NEXTVAL FROM dual
        </selectKey>

        INSERT INTO tbl_test (id, title, description)
        VALUES (#{identifierSequence}, #{title}, #{description})
    </insert>
</mapper>

흐름은 단순하다.

1
2
3
4
5
6
7
Mapper 호출
   ↓
selectKey 실행
   ↓
결과를 keyProperty에 저장
   ↓
INSERT에서 #{}로 사용

order="BEFORE"는 INSERT 전에 값을 확보하고, AFTER는 INSERT 뒤에 보조 조회를 수행한다. 어떤 방식을 쓸지는 DB가 Key를 생성하는 방식에 맞춘다.

keyProperty에 지정한 Property는 전달 객체에서 실제로 값을 받을 수 있어야 한다.

1:N 컬렉션 매핑 — 행 집합을 객체 그래프로 조립

JOIN 결과는 관계형 관점에서는 여러 행이다.

1
2
3
team 1 | member A
team 1 | member B
team 1 | member C

Application에서는 이를 다음처럼 받고 싶을 수 있다.

1
2
3
4
5
6
7
Team
├─ id
├─ title
└─ members
   ├─ Member A
   ├─ Member B
   └─ Member C

이 역할을 resultMap<collection>이 담당한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<resultMap id="teamResultMap" type="io.test.Team">
    <id property="id" column="id"/>
    <result property="title" column="title"/>
    <result property="description" column="description"/>

    <collection
        property="members"
        javaType="java.util.ArrayList"
        ofType="io.test.Member">
        <id property="id" column="member_id"/>
        <result property="name" column="member_name"/>
    </collection>
</resultMap>

<select id="selectTeams" resultMap="teamResultMap">
    SELECT
        t.id,
        t.title,
        t.description,
        m.id   AS member_id,
        m.name AS member_name
    FROM tbl_team t
    LEFT JOIN tbl_member m
      ON t.id = m.team_id
</select>

여기서 <id> Mapping이 중요하다. MyBatis가 어떤 행들이 같은 부모·자식 객체를 가리키는지 식별하는 데 사용되므로, 단순히 모든 Column을 <result>로 나열하는 것보다 ID를 명확히 지정하는 편이 좋다.

핵심 속성은 다음 세 개다.

  • property: 부모 객체에서 Collection을 받을 Property
  • javaType: Collection 구현 타입
  • ofType: Collection 원소 타입

<collection>은 JOIN을 수행하는 기능이 아니라 이미 조회된 행 집합을 객체 구조로 조립하는 Mapping 기능이다.

동적 Column SQL — 구조와 값을 분리한다

CSV Header나 외부 Metadata에 따라 INSERT Column 목록이 런타임에 정해지는 경우가 있다.

이때 두 종류의 동적 요소를 분리해야 한다.

1
2
3
4
5
6
7
Column명
→ SQL 구조
→ ${}

각 Column의 값
→ Parameter 값
→ #{}

예를 들어 header가 허용된 Column명 목록이고 rowMap<String, Object>라면 다음처럼 구성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<insert id="save">
    INSERT INTO ${tableName}

    <foreach
        item="column"
        collection="header"
        open="("
        separator=","
        close=")">
        ${column}
    </foreach>

    VALUES

    <foreach
        item="column"
        collection="header"
        open="("
        separator=","
        close=")">
        #{row[column]}
    </foreach>
</insert>

row[column]은 현재 foreachcolumn 값을 Key로 사용해 Map의 값을 꺼내 Binding하는 형태다.

하지만 이 패턴에서 더 중요한 것은 문법보다 식별자 검증이다.

1
2
3
4
5
6
7
외부 입력
  ↓
허용 Table / Column 목록과 대조
  ↓
검증된 Identifier만 ${} 사용
  ↓
실제 값은 #{} Binding

예를 들어 다음처럼 서버가 허용 목록을 소유하도록 한다.

1
2
3
4
5
Set<String> allowedColumns = Set.of("name", "age", "email");

if (!allowedColumns.containsAll(header)) {
    throw new IllegalArgumentException("unsupported column");
}

${tableName}도 동일하다. Table명 역시 값 Parameter가 아니므로 문자열 치환이 필요하지만, 호출자가 자유 문자열을 넘기게 두지 않고 Application이 알고 있는 대상 중에서만 선택하도록 제한한다.

네 패턴을 한 번에 보면

1
2
3
4
5
6
7
8
9
10
11
12
13
14
MyBatis Mapper
│
├─ SQL 값
│   └─ #{}
│
├─ SQL 구조
│   ├─ ${}
│   └─ foreach / if / choose
│
├─ 결과 객체 구조
│   └─ resultMap / collection
│
└─ Statement 주변 보조 작업
    └─ selectKey

처음에는 전부 “Mapper XML 문법”으로 보이지만, 실제로는 서로 다른 책임을 가진 기능이다.

정리

MyBatis 실무에서 가장 먼저 기억할 것은 개별 Tag보다 경계다.

1
2
3
4
값은 #{}
구조는 필요한 경우 ${}
결과 조립은 resultMap
Statement 전후 보조 SQL은 selectKey

특히 ${}는 편리한 동적 Binding 문법이 아니라 SQL 문자열 생성 기능에 가깝다. Table명·Column명처럼 정말 SQL 구조를 바꿔야 할 때만 사용하고, Identifier는 Application의 허용 목록으로 제한한다.

이 경계를 잡아두면 MyBatis의 Dynamic SQL도 “문자열을 어디까지 만들고, 값은 어디서 Binding하는가”라는 하나의 기준으로 읽을 수 있다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.