요근래 회사에서 특이한 이슈를 겪어 기록을 남겨두려고 한다. 사실 정확한 원인은 규명하지 못했는데, 최소한의 해결책을 찾은 상태이다.
현재 내가 개발하고 있는 환경은 다음과 같다.
DB: Oracle
개발프레임워크: mybatis, spring boot
여기서 부가적인 설명을 하자면, mybatis의 executor-type은 batch이며 repository를 작성할때는 어노테이션 방식으로 작성을 하고 있다.
본 문서에서 나오는 코드나 에러등은 운영 데이터가 아닌 재현한 내용임을 밝힙니다.
최근에 Oracle에서 Date type을 사용하고, java type으로는 LocalDateTime을 사용하는 필드를 특정 조건에 따라 null 혹은 LocalDateTime.now()로 데이터를 넣어줘야하는(INSERT) 요구사항이 생겼다.
예를 들어 다음과 같은 Dto를 INSERT하는 요구사항이었다.
public class MailDto {
private final Long mailNo;
private final Long userNo;
private final LocalDateTime sendDt; (요구사항에서 null 혹은 현재시간을 넣어줘야하는 필드)
...
}
기존에는 INSERT mapper에서 sendDt는 추가해주지 않았기 때문에 Oracle의 default값인 null이 인스턴스에 들어갔다.
이런 값을 명시적으로 Dto의 sendDt라는 필드에 따라 값을 넣어주다보니 아래와 같은 에러가 발생했다.
Cause: org.apache.ibatis.type.TypeException: Error setting null parameter. Most JDBC drivers require that the JdbcType must be specified for all nullable parameters. Cause: java.sql.SQLException: 부적합한 열 유형
null값을 허용한다는 명시적표현없이는 그냥 넣을 수 없다는 의미였으며, 명시적으로 표현하기위해 다음과 같이 코드를 수정했다.
/* AS-IS: sendDt값을 INSERT하되 null을 명시적으로 허용하지 않음 */
@Insert({"INSERT INTO MAIL ",
"(SEND_DT, USER_NO) ",
"VALUES",
"(#{sendDt}, #{userNo})"}
@Options(useGeneratedKeys = true, keyProperty = "mailNo", keyColumn = "mail_no")
long save(MailDto mailDto);
/* TO-BE: JdbcType을 명시해 null이나 해당 type으로 값이 들어올 수 있다고 표현 */
@Insert({"INSERT INTO MAIL ",
"(SEND_DT, USER_NO) ",
"VALUES",
"(#{sendDt, jdbcType=DATE}, #{userNo})"}
@Options(useGeneratedKeys = true, keyProperty = "mailNo", keyColumn = "mail_no")
long save(MailDto mailDto);
위의 TO-BE처럼 작성하고 나니, 특정 LocalDateTime 필드의 값이 null이여도 문제없이 MAIL테이블 로우가 생성되었다.
그런데 다른 개발건을 진행하면서, 이와 관련된 오류를 겪게 되었다.
대량건의 MAIL테이블 로우를 INSERT하면서 Numeric OverFlow에러가 발생한 것이다.
org.mybatis.spring.MyBatisSystemException: nested exception is org.apache.ibatis.executor.ExecutorException: Error getting generated key or setting result to parameter object. Cause: org.apache.ibatis.executor.result.ResultMapException: Error attempting to get column #1 from result set. Cause: java.sql.SQLException: Numeric Overflow
resultSet의 첫번째 column의 값이 Overflow되었다는 내용이었다.
Caused by: java.sql.SQLException: Numeric Overflow
at oracle.jdbc.driver.NumberCommonAccessor.throwOverflow(NumberCommonAccessor.java:4136)
at oracle.jdbc.driver.NumberCommonAccessor.getLong(NumberCommonAccessor.java:634)
at oracle.jdbc.driver.GeneratedStatement.getLong(GeneratedStatement.java:206)
at oracle.jdbc.driver.GeneratedScrollableResultSet.getLong(GeneratedScrollableResultSet.java:259)
at com.zaxxer.hikari.pool.HikariProxyResultSet.getLong(HikariProxyResultSet.java)
at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:44)
at org.apache.ibatis.type.LongTypeHandler.getNullableResult(LongTypeHandler.java:26)
at org.apache.ibatis.type.BaseTypeHandler.getResult(BaseTypeHandler.java:90)
그리고 에러로그의 nested한 부분을 보면 위와 같은 로그가 있었다. 내용을 보면 Long의 값이 Overflow되었다... 라는 내용이었다.
이 로그를 봤을 때는 크게 이해가 되지 않았다. 이유는 해당 Dto의 Long 필드의 값이 Long의 범위를 넘지 않는 값들이였기 때문이었다.
실제로 어느부분에서 예외가 발생했는지 알기위해 로그에서 내가 작성한 코드와 제일 밀접한 부분을 찾아보았다. 그런데 전혀 예상하지 못한곳에서 문제가 발생했다는 것을 알 수 있었다.
at com.sun.proxy.$Proxy84.flushBatchedStatements(Unknown Source)
위의 로그는 package org.apache.ibatis.annotations.Flush를 사용하는 메소드로 부터 남겨지는 로그인데, @Flush
를 추가한 메소드는 Batch mode의 mybatis에서 preparedStatement들을 일괄처리하는 기능을 가지게 된다.
해당 로그를 보니 의구심이 들었다. "어차피 Long값은 Overflow되는 것이 없는데,, 혹시 Batch mode쪽에 이슈가 있는게 아닐까"라는 생각을 하게되었다.
곧바로 mybatis의 executor-type을 각각 REUSE
, SIMPLE
로 변경하고 테스트를 해보았다. 예상대로 NumericOverflow는 발생하지 않았다!!
하지만 mybatis의 flush기능의 디버깅을 해보아도,, 어디서 사용하는 Long값이며 왜 Overflow가 되는지는 알아내지 못했다.
원인을 찾을 수 없어 해결방안을 찾으려는 노력을 하기 시작했다.
이 해결방안은 mybatis의 batch mode에 이슈가 있고, 원인을 정확히 알 수 없으나 해결은 해야겠다는 취지에서 진행되었다.
해결을 위해 최근 MailDto와 관련된 수정사항을 모두 뒤지고, 작은 단위로 롤백을 진행했다.
그러다 이전에 jdbcType을 명시하며 INSERT쿼리에 추가했던 sendDt필드를 쿼리에서 제거했더니, 해당 이슈가 발생하지 않았다..
mybatis에서 뱉어줬던 에러는 Long의 overflow였으므로 이해는 가지 않았지만 INSERT쿼리에 추가했던 sendDt필드를 쿼리에서 제거하면 괜찮다는 부분에서 아이디어를 얻어 다음과 같은 시도를 해보았다.
/* AS-IS: JdbcType을 명시해 null이나 해당 type으로 값이 들어올 수 있다고 표현 */
@Insert({"INSERT INTO MAIL ",
"(SEND_DT, USER_NO) ",
"VALUES",
"(#{sendDt, jdbcType=DATE}, #{userNo})"}
@Options(useGeneratedKeys = true, keyProperty = "mailNo", keyColumn = "mail_no")
long save(MailDto mailDto);
/* TO-BE: sendDt의 값이 null인 경우, 사용하지 않는 동적쿼리 */
@Insert({"<script>",
"INSERT INTO MAIL",
"(USER_NO)",
"<if test='sendDt != null'>, SEND_DT</if>)",
"VALUES",
"(#{userNo}",
"<if test='sendDt != null'>, #{sendDt}</if>)"}
@Options(useGeneratedKeys = true, keyProperty = "mailNo", keyColumn = "mail_no")
long save(MailDto mailDto);
신기하게도 에러가 발생하지 않았다. 왜인지 이해가 가지않지만, 배치모드에서 flush를 할때 LocalDateTime -> Date의 handling에서 Long을 거치는게 아닌가,, 라는 생각을 했다.
이후로는 null의 값을 허용할때는 되도록 동적쿼리를 쓰거나 쿼리를 분리해야겠다는 생각을 하게된 이슈였다.