
iBATIS 기반의 레거시 시스템을 MyBatis로 전환하는 작업을 진행하면서 예상하지 못했던 오류를 하나 만났다.
SQL도 정상적으로 실행되고 대부분의 데이터는 문제없이 조회되는데,
특정 데이터로 화면에 진입할 때만 MyBatis로 전환한 서버에서 페이지가 열리지 않는 현상이었다.
더 흥미로웠던 점은 같은 데이터로 기존 iBATIS 기반 STG에서는 정상적으로 화면이 열린다는 것이었다.
AS-IS STG (iBATIS)
→ 정상
TO-BE STG (MyBatis)
→ 특정 데이터 조회 시 NPE 발생
→ 페이지 진입 실패
처음에는 SQL 변환 과정에서 문제가 생겼거나 ResultMap이 잘못 변환된 것으로 생각했다.
하지만 원인을 따라가 보니 SQL 문법의 문제가 아니었다.
iBATIS와 MyBatis가 DB의 NULL 값을 Java 객체에 매핑하는 방식의 차이가 원인이었다.
그리고 이 문제를 해결하면서 MyBatis의 다음 설정까지 확인하게 되었다.
<setting name="callSettersOnNulls" value="true"/>
이번 글에서는 단순히 이 설정의 사용법을 정리하는 것이 아니라,
실제 iBATIS → MyBatis 전환 과정에서 어떤 문제가 발생했고 왜 이 설정이 필요하다고 판단했는지 정리해보려고 한다.
1. 시작은 특정 데이터에서만 발생하는 NPE였다.
문제가 발생한 코드는 대략 다음과 같은 형태였다.
for (ContentStatusVO vo : list) {
if (vo.getStkhUserNm().equals("-") || vo.getStkhUserNm().isEmpty()) {
if (vo.getAdminUserNm().isEmpty()) {
vo.setFrstNm("-");
} else {
vo.setFrstNm(vo.getAdminUserNm());
}
}
}
특정 데이터를 조회했을 때 stkhUserNm이 null인 상태로 남아 있었고,
이후 equals()나 isEmpty()와 같이 객체가 존재한다는 것을 전제로 하는 메서드를 호출하면서 NullPointerException이 발생했다.
vo.getStkhUserNm().equals("-"); // null이면 NPE
vo.getStkhUserNm().isEmpty(); // null이면 NPE
실제 코드에서는 ||의 왼쪽에 있는 equals()가 먼저 평가되므로 해당 지점에서 NPE가 발생한다.
단순히 Java 코드에 null 체크를 추가하면 당장의 오류는 막을 수 있다.
하지만 이번 작업은 기존 iBATIS 기반 시스템을 MyBatis로 전환하는 작업이었다.
그리고 동일한 데이터가 AS-IS iBATIS 환경에서는 정상적으로 동작하고, TO-BE MyBatis 환경에서만 오류가 발생하고 있었다.
따라서 null 방어 코드를 추가하기 전에 먼저 다음 의문을 확인하기로 했다.
같은 DB 데이터를 조회하는데 왜 iBATIS에서는 정상이고 MyBatis에서는 해당 필드가 null로 남는 것일까?
2. VO의 setter는 단순한 값 대입이 아니었다.
문제가 발생한 VO를 확인해보니 개인정보 암·복호화를 위해 setter 내부에서 추가적인 처리를 하고 있었다.
public void setEncStkhUserNm(String encStkhUserNm) throws AStoreException {
this.encStkhUserNm = encStkhUserNm;
this.stkhUserNm = DkmsUtil.decrypted(encStkhUserNm);
}
DB에서는 암호화된 값을 조회하고, 해당 값이 encStkhUserNm에 매핑되면 setter 내부에서 복호화를 수행하여
실제 사용하는 stkhUserNm까지 설정하는 구조였다.
DB 암호화 값
↓
setEncStkhUserNm(암호화 값)
↓
DkmsUtil.decrypted(...)
↓
stkhUserNm = 복호화된 값
여기서 DkmsUtil.decrypted()의 NULL 처리도 확인했다.
public static String decrypted(String cipherText) throws AStoreException {
if (StringUtil.isEmpty(cipherText)) {
return "";
}
// 복호화 로직
}
StringUtil.isEmpty()는 null도 빈 값으로 판단하고 있었다.
public static boolean isEmpty(String str) {
return (str == null || str.trim().length() == 0);
}
즉 DB에서 암호화된 값이 NULL이더라도 setter만 호출된다면 기존 로직상 다음과 같이 처리되어야 한다.
setEncStkhUserNm(null)
↓
DkmsUtil.decrypted(null)
↓
""
↓
stkhUserNm = ""
그런데 MyBatis 전환 환경에서는 stkhUserNm이 ""가 아니라 null로 남아 있었다.
이 시점부터 복호화 로직 자체보다 NULL인 경우 setter가 실제로 호출되고 있는지를 확인하기 시작했다.
3. MyBatis는 조회 결과가 NULL이면 setter를 호출할까?
MyBatis의 selectList()는 SQL만 실행하고 끝나는 것이 아니다.
조회된 ResultSet을 VO로 매핑하는 과정까지 수행한 후 완성된 객체를 반환한다.
SQL 실행
↓
ResultSet 조회
↓
VO 생성
↓
ResultMap에 따라 Property 매핑
↓
VO Setter 호출
↓
List 반환
따라서 문제를 다음과 같이 좁힐 수 있었다.
DB 컬럼 = NULL
iBATIS
→ setter(null) 호출?
→ 기존 로직 정상
MyBatis
→ setter(null) 호출?
→ 현재는 평문 필드가 null
MyBatis 내부에서 NULL에 대한 setter 호출을 어떻게 처리하는지 확인했다.
4. MyBatis의 callSettersOnNulls
MyBatis Configuration에는 다음과 같은 필드가 존재한다.
protected boolean callSettersOnNulls;
별도의 초기값이 지정되어 있지 않은 primitive boolean이므로 기본적으로 false 상태가 된다.
그리고 실제 ResultSet → Java 객체 매핑을 담당하는 DefaultResultSetHandler에서는
다음 조건을 통해 setter 호출 여부를 결정한다.
if (value != null || (configuration.isCallSettersOnNulls() && !metaObject.getSetterType(property).isPrimitive())) {
metaObject.setValue(property, value);
}
이 코드에서 원인을 확인할 수 있었다.
DB 조회 결과가 NULL이고 기본 설정을 사용하는 경우
value == null
callSettersOnNulls == false
이므로 metaObject.setValue()가 실행되지 않는다.
즉,
DB NULL
↓
MyBatis Result Mapping
↓
value = null
↓
callSettersOnNulls = false
↓
VO setter 호출 생략
↓
복호화 로직도 실행되지 않음
↓
stkhUserNm = null
이 된다.
여기서 중요한 점은 MyBatis가 NULL을 VO에 넣어서 문제가 발생한 것이 아니라,
NULL인 경우 setter 호출 자체를 생략했다는 것이다.
5. 그렇다면 기존 iBATIS에서는 왜 정상 동작했을까?
MyBatis의 동작을 확인한 뒤에는 AS-IS인 iBATIS의 Result Mapping 과정도 확인했다.
단순히 "iBATIS는 원래 된다"라고 가정하기보다는 실제 소스의 호출 흐름을 따라가 보았다.
큰 흐름은 다음과 같았다.
queryForList()
↓
MappedStatement
↓
ResultMap
↓
JavaBeanDataExchange
↓
AccessPlan
↓
JavaBean Property Setter 호출
JavaBean 매핑 과정에서 조회된 값이 AccessPlan으로 전달되고, property를 설정하는 과정에서 setter가 호출된다.
확인한 setter 처리 로직은 개념적으로 다음과 같은 구조였다.
for (int i = 0; i < propertyNames.length; i++) {
arg[0] = values[i];
setters[i].invoke(object, arg);
}
MyBatis에서 확인했던 것처럼 NULL이라는 이유로 setter 호출 자체를 건너뛰는 조건이 없었다.
따라서 기존 iBATIS에서는 다음 흐름이 가능했다.
DB NULL
↓
setEncStkhUserNm(null)
↓
DkmsUtil.decrypted(null)
↓
""
↓
stkhUserNm = ""
결국 AS-IS와 TO-BE의 차이는 다음과 같았다.
[iBATIS]
DB NULL
→ setter(null)
→ 기존 setter 내부 로직 실행
→ stkhUserNm = ""
[MyBatis 기본 설정]
DB NULL
→ setter 호출 생략
→ 기존 setter 내부 로직 실행 안 됨
→ stkhUserNm = null
SQL 변환의 문제가 아니라 프레임워크 교체로 인해 NULL에 대한 JavaBean 매핑 동작이 달라진 것이었다.
6. 기존 동작을 맞추기 위해 callSettersOnNulls=true 적용
원인이 확인되었으므로 MyBatis 설정에 다음 값을 추가했다.
<settings>
<setting name="callSettersOnNulls" value="true"/>
</settings>
이 설정의 역할은 NULL을 빈 문자열로 변환하는 것이 아니다.
정확히는, DB 조회 결과가 NULL이어도 해당 JavaBean의 setter를 호출하도록 한다.
설정 적용 후에는 다음과 같이 동작한다.
DB NULL
↓
callSettersOnNulls=true
↓
setEncStkhUserNm(null)
↓
기존 VO의 복호화 로직 실행
↓
DkmsUtil.decrypted(null)
↓
""
↓
stkhUserNm = ""
즉 NULL → "" 변환은 callSettersOnNulls의 기능이 아니라 기존 DkmsUtil의 NULL 처리 로직이다.
callSettersOnNulls는 그 기존 로직이 실행될 수 있도록 NULL인 경우에도 setter를 호출해주는 역할을 한다.
설정 적용 후 동일한 오류를 로컬에서 다시 테스트했고, 기존에 발생하던 NPE가 사라지는 것을 확인했다.
7. 개별 TypeHandler 처리와 공통 설정 중 무엇이 맞을까?
분석 과정에서 동일한 현상을 확인한 다른 개발자는
문제가 발생한 ResultMap에 NULL 처리용 TypeHandler를 지정하는 방식으로 조치하고 있었다.
<result property="encCprtAthrNm" column="ENC_CPRT_ATHR_NM" typeHandler="nullStrEmpty"/>
이 방법은 TypeHandler에서 NULL을 ""로 변환하기 때문에 해당 ResultMap의 문제를 해결할 수 있다.
DB NULL
↓
TypeHandler
↓
""
↓
setter("")
하지만 iBATIS → MyBatis 전체 전환이라는 관점에서는 한 가지 문제가 있었다.
문제가 발견될 때마다 각각의 ResultMap에 TypeHandler를 추가해야 한다는 점이다.
또한 기존 iBATIS의 동작은 NULL을 먼저 빈 문자열로 바꾸는 방식이 아니라
NULL
→ setter(null)
→ 기존 setter 내부에서 NULL 처리
이 방식이었다.
따라서 특정 Mapper를 개별적으로 보정하는 것보다 MyBatis의 NULL setter 정책 자체를
기존 iBATIS와 동일하게 맞추는 방향이 이번 전환의 목적에 더 적합하다고 판단했다.
8. TypeHandler를 제거하고 다시 검증
공통 설정을 적용한 뒤 이미 nullStrEmpty TypeHandler로 수정했던 부분에서도 TypeHandler를 제거하고 다시 테스트했다.
즉, 개별 ResultMap의 typeHandler="nullStrEmpty"를 제거하고,
<setting name="callSettersOnNulls" value="true"/>만 적용한 상태로 동일한 기능을 확인했다.
결과는 정상 동작이었다.
이를 통해 특정 SQL에 대한 우회 조치 없이도 공통 Configuration 설정을 통해
기존 setter의 NULL 처리 로직이 정상적으로 실행되고 있음을 확인할 수 있었다.
9. 이번 전환에서 배운 점
iBATIS → MyBatis 전환을 시작하면 보통 먼저 다음과 같은 변경을 떠올리게 된다.
SqlMapClient → SqlSession
queryForList → selectList
queryForObject → selectOne
iterate → foreach
resultClass → resultType
하지만 실제 레거시 시스템을 전환하면서 느낀 것은 API와 XML 문법을 바꾸는 것만으로 전환이 끝나는 것은 아니라는 점이다.
이번 문제에서도 SQL은 정상적으로 실행됐고 대부분의 데이터는 문제없이 조회됐다.
문제는 다음 조건이 동시에 존재했을 때 나타났다.
NULL 데이터
+
Custom Setter
+
Setter 내부 암·복호화/후처리
+
iBATIS와 MyBatis의 NULL Mapping 정책 차이
특히 레거시 시스템에서는 애플리케이션 코드가 오랜 기간 특정 프레임워크의 기본 동작에 암묵적으로 의존하고 있을 수 있다.
프레임워크를 교체한다면 단순히 "새 프레임워크에서 실행되는가?"뿐 아니라,
"기존 프레임워크와 동일한 입력에 대해 동일한 애플리케이션 동작을 보장하는가?"도 확인해야 한다.
이번 callSettersOnNulls 역시 단순한 MyBatis 옵션 하나를 추가한 작업이라기보다,
iBATIS가 제공하던 기존 Result Mapping 동작을 확인하고 MyBatis에서도 그 동작 계약을 유지하기 위한 호환성 설정이었다.
10. 마무리
처음에는 특정 화면에서 발생하는 단순한 NPE로 시작했다.
하지만 AS-IS에서는 정상이고 TO-BE에서만 발생한다는 점에서 출발해
양쪽 프레임워크의 Result Mapping 과정을 확인하면서 원인을 찾을 수 있었다.
특정 데이터에서 NPE 발생
↓
AS-IS / TO-BE 동작 비교
↓
DB NULL 확인
↓
VO Custom Setter 확인
↓
MyBatis NULL Setter 정책 확인
↓
iBATIS JavaBean Mapping 동작 확인
↓
callSettersOnNulls=true 적용
↓
개별 TypeHandler 제거
↓
재검증 및 정상 동작 확인
최종적으로 추가된 설정은 한 줄이다.
<setting name="callSettersOnNulls" value="true"/>
하지만 이번 작업에서 중요했던 것은 설정 한 줄을 찾은 것보다 왜 기존 시스템에서는 정상이고
전환한 시스템에서는 실패하는지를 끝까지 확인한 과정이었다.
iBATIS → MyBatis 전환을 진행한다면 API나 Mapper 문법뿐만 아니라
NULL Result Mapping과 Custom Setter의 동작 차이도 확인할 필요가 있다.
특히 setter 내부에서 암·복호화, 기본값 설정, 다른 필드 변경 등의 후처리를 수행하고 있다면
callSettersOnNulls 설정 여부가 기존 시스템과의 동작 호환성에 영향을 줄 수 있다.
