MyBatis 실무 패턴 — 값 바인딩·동적 SQL·1:N 매핑의 경계
MyBatis에서 #{ } 값 바인딩과 ${ } 문자열 치환의 경계를 먼저 잡고, 시퀀스 채번·1:N 컬렉션 매핑·동적 컬럼 SQL을 어떤 책임으로 나눠 쓰는지 정리한다.
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을 받을 PropertyjavaType: 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명 목록이고 row가 Map<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]은 현재 foreach의 column 값을 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하는가”라는 하나의 기준으로 읽을 수 있다.