MyBatis 3에서 foreach로 Apache Commons Lang Pair 컬렉션을 순회할 때 루프 변수 item이 String으로 바뀌고 There is no getter for property named 'left' 예외가 발생할 수 있습니다. 근본 원인은 Pair에 getLeft가 없어서가 아니라 Pair가 Map.Entry를 구현하기 때문입니다. MyBatis는 left/key를 index에, right/value를 item에 바인딩합니다.
List<Pair<String, String>>를 전달하고 Pair의 right 값이 문자열이라면, 이후 #{pair.left}에 접근할 때 MyBatis는 실제로 그 문자열의 left 속성을 해석하게 됩니다.
이 예외가 실제로 의미하는 바는 다음과 같습니다:
There is no getter for property named 'left'
in 'class java.lang.String'
문제는 Pair#getLeft()가 Java Bean 명세를 따르지 않아서가 아니라, 이 지점에 도달했을 때 pair라는 변수가 이미 Pair가 아니라는 점입니다.
문제 재현 방법
Mapper가 사용자와 역할의 관계 목록을 받는다고 가정해 보겠습니다:
int batchInsert(
@Param("pairs")
List<Pair<String, String>> pairs
);
호출할 때는 다음을 전달합니다:
List<Pair<String, String>> pairs = List.of(
Pair.of("1001", "admin"),
Pair.of("1002", "editor")
);
XML에서는 left, right로 두 값을 직접 읽습니다.
직관적으로는 매 반복에서 pair가 각각 다음과 같아야 합니다:
Pair("1001", "admin")
Pair("1002", "editor")
하지만 MyBatis가 실제로 바인딩하는 값은 이와 다릅니다.
Apache Commons Pair는 Map.Entry이기도 하다
여기서 사용하는 org.apache.commons.lang3.tuple.Pair는 getLeft()와 getRight()를 제공하는 동시에 Map.Entry<L, R>를 구현합니다. Apache Commons Lang의 공식 문서에도 key는 left, value는 right라는 대응 관계가 명시되어 있습니다.
Pair.left == Map.Entry.key
Pair.right == Map.Entry.value
따라서 다음 네 가지 호출은 두 그룹으로 나눌 수 있습니다:
pair.getLeft(); // 等价于 getKey()
pair.getRight(); // 等价于 getValue()
Pair를 단순히 일반 객체로 속성 해석기에 넘겼다면 left와 right를 모두 정상적으로 읽을 수 있었습니다. 실제로 동작을 바꾼 것은 Pair가 Map.Entry를 구현한다는 사실입니다.
MyBatis foreach는 Map.Entry를 펼쳐 처리한다
MyBatis의 <foreach> 공식 문서는 두 가지 바인딩 방식을 설명합니다.
- 일반
Iterable이나 배열을 순회할 때index는 현재 순번이고item은 현재 요소입니다. Map이나Map.Entry컬렉션을 순회할 때index는 entry의 key이고item은 entry의 value입니다.
현재 ForEachSqlNode의 처리 로직은 다음 의사 코드로 단순화할 수 있습니다:
원래 이 분기는 Map 순회를 더 자연스럽게 만들었습니다. 예를 들면 다음과 같습니다:
<foreach collection="users" index="userId" item="user">
#{userId}, #{user.name}
</foreach>
users가 Map<String, User>라면 key는 userId로, value는 user로 들어갑니다.
문제는 Apache Commons Pair도 element instanceof Map.Entry 조건을 만족한다는 점입니다.
item은 왜 String으로 바뀌는가
다음 데이터를 예로 들어 보겠습니다:
Pair.of("1001", "admin")
<foreach>에 들어간 뒤 변수 바인딩은 다음과 같이 바뀝니다:
| Pair의 값 | Map.Entry에서의 의미 | foreach 변수 |
|---|---|---|
left = "1001" | entry.getKey() | index |
right = "admin" | entry.getValue() | item |
XML에서 item의 이름을 pair로 지정하면 전체 과정은 다음과 같습니다:
MyBatis는 이어서 속성 접근 메커니즘으로 String의 left getter를 찾습니다. 당연히 존재하지 않으므로 예외에 class java.lang.String이 나타납니다.
이 타입 정보가 중요합니다. 현재 속성 해석 대상이 Pair 자체가 아니라 Pair의 right 값임을 뜻합니다. right가 Long이라면 예외에 Long이 나타날 수 있고, right가 다른 업무 객체라면 MyBatis는 그 객체 자체를 해석 대상으로 삼습니다.
방안 1: index와 item 직접 사용하기
이미 MyBatis가 Map.Entry 의미에 따라 Pair를 분리했으므로, 최소한의 변경은 분리된 두 변수를 그대로 사용하는 것입니다:
이때 다음과 같이 됩니다:
Pair.left → foreach.index → left
Pair.right → foreach.item → right
여기서 index는 더 이상 0, 1, 2 같은 List 인덱스가 아니라 Map.Entry#getKey()가 반환한 객체라는 점에 주의해야 합니다.
이 방법은 변경 범위가 작고 Pair를 Mapper 근처에서만 임시로 사용하는 경우에 적합합니다. 다만 XML을 읽는 개발자는 MyBatis의 Map.Entry 특수한 의미 방식을 알아야 합니다. 그렇지 않으면 index="left"는 여전히 역설적으로 느껴질 수 있습니다.
방안 2: 명시적인 파라미터 객체 사용하기
이 데이터에 안정적인 업무 의미가 있다면 Pair가 Mapper 경계를 넘어가지 않도록 하는 편이 좋습니다.
예를 들어 사용자-역할 관계를 다음과 같은 명시적인 파라미터 객체로 정의할 수 있습니다:
Mapper 파라미터는 다음과 같이 변경합니다:
int batchInsert(
@Param("rows")
List<UserRoleRow> rows
);
XML도 일반적인 객체 속성 접근 방식으로 돌아갑니다:
이렇게 하면 Map.Entry 분기를 피할 수 있을 뿐 아니라, 파라미터 자체에도 업무 의미가 부여됩니다. userId, roleId는 보통 left, right보다 이해하기 쉬우며, 필드 타입이나 검증 규칙이 바뀌어도 유지보수하기 쉽습니다.
상위 코드에서 당분간 Pair를 유지해야 한다면 Mapper에 넘기기 전에 한 번 변환하면 됩니다:
List<UserRoleRow> rows = pairs.stream()
.map(pair -> new UserRoleRow(pair.getLeft(), pair.getRight()))
.toList();
Pair라는 이름의 모든 타입에서 발생하는 것은 아닙니다
판단 기준은 타입 이름이 Pair인지 여부가 아니라, 런타임 요소가 Map.Entry를 구현하는지입니다.
Apache Commons Lang의 Pair, ImmutablePair, MutablePair는 모두 이 분기로 들어갑니다. 뒤의 두 타입은 Pair를 상속하기 때문입니다. 다른 라이브러리에서 제공하는 두 값짜리 튜플 타입이 Map.Entry를 구현하지 않은 경우에는 일반 요소로 처리되어 item에 바인딩됩니다.
따라서 이런 문제를 진단할 때는泛型 선언만 확인하는 것보다, 실제 요소 타입과 그 타입이 구현하는 인터페이스를 확인하는 편이 더 중요합니다.
no getter 예외가 나면 실제 타입부터 확인하세요
앞으로 다음과 같은 예외를 다시 보게 되면:
There is no getter for property named 'xxx'
in 'class java.lang.String'
전달한 파라미터가 분명히 복잡한 객체여도 다음 순서로 확인하면 됩니다:
- 먼저 Mapper 메서드 시그니처만 보지 말고, 예외에 표시된 실제 타입을 확인합니다.
<foreach>의item과index가 각각 무엇을 바인딩하는지 확인합니다.- 컬렉션 요소가
Map.Entry를 구현하는지 확인합니다. - 그다음 getter가 누락된 것인지, 속성 해석 대상 자체가 바뀐 것인지 판단합니다.
이번 문제의 핵심적인 흐름은 한 문장으로 압축할 수 있습니다:
Apache Commons Pair 实现 Map.Entry
→ MyBatis foreach 按 key/value 展开
→ right 被绑定为 item
→ pair.left 实际变成 String.left
따라서 이는 Pair getter의 호환성 문제가 아니라, 합리적인 두 인터페이스 설계가 겹치면서 비교적 잘 드러나지 않은 의미 충돌이 생긴 것입니다.