빌드노트
JdbcTemplate queryForObject는 0행을 null로 돌려주지 않는다
JdbcTemplate의 0행은 예외이고 SQL NULL 한 행은 null이다. Spring JDBC/H2에서 네 결과를 실제 비교하고, 0행만 처리하는 방법과 행 부재·NULL을 구별하는 목록 조회를 설명한다.
JdbcTemplate.queryForObject로 값을 조회한 뒤 null인지 검사하는 코드가 있어도, 조건에 맞는 행이 없으면 그 검사까지 도달하지 않는다. 0행은 EmptyResultDataAccessException, 1행의 SQL NULL은 Java의 null로 전달되기 때문이다. “조회한 행이 없다”와 “조회한 행의 열 값이 NULL이다”는 서로 다른 결과다.
여기서는 단일 열을 String.class로 읽는 queryForObject(sql, String.class, 인자)를 다룬다. 이 오버로드는 결과가 정확히 1행·1열이기를 요구한다. 공식 문서의 nullable 반환 설명은 SQL NULL에 해당하며, 0행도 null로 반환한다는 뜻이 아니다. Spring Framework 6.2.12 JdbcTemplate 문서 (새 탭에서 열림)
별도 H2 메모리 DB에 합성 데이터를 넣고 실행한 결과는 다음과 같았다. zero는 없는 조건, null은 NULL 한 행, value는 문자열 한 행, many는 문자열 두 행을 조회한다. 아래는 실행 로그의 해당 부분이다.
direct/zero => org.springframework.dao.EmptyResultDataAccessException expected=1 actual=0
direct/null => null
direct/value => ready
direct/many => org.springframework.dao.IncorrectResultSizeDataAccessException expected=1 actual=2
따라서 예외 메시지의 expected 1, actual 0을 봤다면 반환값에 기본 문자열을 대입하는 코드부터 고칠 일이 아니다. 없는 행을 허용하는 조회인지 먼저 정해야 한다. 반드시 한 행이 있어야 하는 조회라면 예외를 유지하고 조건·데이터를 확인한다. 두 행이 나온 경우도 첫 값만 가져오면 되는지, 단일 행이라는 전제가 깨진 것인지 판단해야 한다.
없는 행만 허용할 때는 예외 타입을 좁힌다
없는 행과 NULL 열을 모두 “값 없음”으로 취급해도 되는 조회라면, 0행 예외만 잡아 null로 바꿀 수 있다. 다음 메서드는 기존 JdbcTemplate 필드 jdbc를 사용하며, EmptyResultDataAccessException은 org.springframework.dao에서 가져온다. sql은 애플리케이션이 정한 단일 열 SELECT이고 bucket은 그 쿼리의 바인딩 값이다.
String findOrNull(String sql, String bucket) {
try {
return jdbc.queryForObject(sql, String.class, bucket);
} catch (EmptyResultDataAccessException e) {
return null;
}
}
이 메서드로 같은 네 조건을 실행했을 때 0행과 NULL 1행은 모두 null, 값 1행은 ready였다. 2행은 여전히 IncorrectResultSizeDataAccessException이었다. 존재하지 않는 열을 SELECT하는 조건도 따로 실행했고, BadSqlGrammarException이 그대로 전달됐다. 없는 행을 처리하면서 복수행과 SQL 오류까지 숨기지는 않은 것이다.
여기서 IncorrectResultSizeDataAccessException 전체를 잡아 null로 바꾸면 범위가 달라진다. EmptyResultDataAccessException이 그 하위 타입이므로 0행뿐 아니라 2행 오류까지 같은 처리에 들어간다. DataAccessException 전체를 잡는 방식은 SQL 실행 오류까지 포함하므로, 단순한 미조회 처리로 쓰기에는 더 넓다. 0행 예외의 상속 관계 (새 탭에서 열림), 결과 크기 예외 문서 (새 탭에서 열림)
Optional.ofNullable(jdbc.queryForObject(...))로 감싸는 것만으로도 해결되지 않는다. 실제 실행에서 0행은 Optional을 만들기 전에 예외가 났다. 위 메서드의 반환값을 Optional로 감싸면 0행과 NULL 1행이 모두 Optional.empty가 됐다. 두 상태를 합쳐도 되는 계약에서만 그 표현을 선택한다.
행 없음과 NULL을 구분해야 한다면 목록을 읽는다
예를 들어 행 자체가 없는 상태와, 행은 있지만 메모가 미작성인 상태를 다르게 처리해야 한다면 앞 메서드의 null 반환으로는 부족하다. 같은 SQL을 queryForList로 읽으면 행 수를 별도로 확인할 수 있다. 단일 열을 String.class로 읽는 목록 조회는 결과 행마다 요소 하나를 만든다. 단일 열 queryForList 문서 (새 탭에서 열림)
다음 코드는 java.util.List와 org.springframework.dao.IncorrectResultSizeDataAccessException을 사용한다. 결과가 최대 한 행이어야 한다는 조건도 유지하려고 크기를 검사한다.
List<String> rows = jdbc.queryForList(sql, String.class, bucket);
if (rows.size() > 1) {
throw new IncorrectResultSizeDataAccessException(1, rows.size());
}
같은 데이터에서 목록 조회 자체의 출력은 다음과 같았다.
list/zero => size=0 values=[]
list/null => size=1 values=[null]
list/value => size=1 values=[ready]
list/many => size=2 values=[first, second]
rows.isEmpty()이면 행이 없다. 크기가 1이면 행이 있고, 그때 rows.get(0)이 null인지 확인하면 된다. 빈 목록과 null 요소 하나가 있는 목록은 다르다. 위 크기 검사 뒤에도 이 차이는 남아 있다. 반대로 복수행이 정상인 조회라면 단일 행 검사를 둘 이유 없이 목록을 그대로 처리하면 된다.
같은 조건을 내 테스트에서 확인하려면
관찰 환경은 Windows, OpenJDK 17.0.2, Spring JDBC 6.2.12, H2 2.3.232였다. 재현하려면 JDK 17 이상인 별도 테스트 프로젝트에 org.springframework:spring-jdbc:6.2.12와 com.h2database:h2:2.3.232를 의존성으로 준비한다. 아래 코드는 새 테스트 메서드 안에서 메모리 DB와 조회 대상을 만든다. 두 클래스는 각각 org.springframework.jdbc.core.JdbcTemplate, org.springframework.jdbc.datasource.DriverManagerDataSource다.
var jdbc = new JdbcTemplate(new DriverManagerDataSource(
"jdbc:h2:mem:qfo_lab;DB_CLOSE_DELAY=-1", "sa", ""));
jdbc.execute("create table lab_item (id int primary key, bucket varchar(20), note varchar(30))");
jdbc.update("insert into lab_item values (1, 'null', NULL), (2, 'value', 'ready'), "
+ "(3, 'many', 'first'), (4, 'many', 'second')");
String sql = "select note from lab_item where bucket = ? order by id";
이름 있는 H2 메모리 DB를 사용했고, 연결을 닫았다 다시 열어도 같은 JVM 안에서 데이터를 유지하도록 DB_CLOSE_DELAY=-1을 붙였다. 외부 DB나 서버 포트는 사용하지 않았다. H2 메모리 DB 문서 (새 탭에서 열림)
다음 호출의 마지막 인자를 "zero", "null", "value", "many"로 바꿔 각 조건을 따로 확인한다. 0행과 2행은 예외가 예상되므로 한 테스트에서 네 호출을 연속 실행하면 첫 예외에서 중단된다.
jdbc.queryForObject(sql, String.class, "zero");
직접 호출의 결과는 앞의 네 줄과 같아야 한다. 다르게 나오면 같은 SELECT와 바인딩 값으로 반환된 행 수부터 확인한다. 행은 하나인데 매핑 오류가 나면, 이 글의 행 수 문제와 구분해 선택 열 수와 요청한 Java 타입을 확인한다. 확인 후에는 같은 테스트의 finally에서 jdbc.execute("SHUTDOWN")으로 메모리 DB를 닫는다.
이번 실행은 단일 문자열 열과 기본 매핑에 한정했다. 커스텀 RowMapper의 null 반환, 다른 JDBC 드라이버, 운영 데이터와 동시 변경은 검증하지 않았다.