pile·
백엔드·네이버페이네이버페이·

똑같은 코드인데 왜 안 돼? Spring JDBC 컨버터 미궁 탈출기

Spring Data JDBC에서 LocalDate·LocalDateTime을 커스텀 컨버터로 처리하려 했는데, LocalDateTime은 되고 LocalDate만 Oracle 기본 DATE 형식으로 저장되며 어긋난 원인을 추적한 디버깅 회고다. 범인은 Spring JDBC 내부의 StatementCreatorUtils.setParameterValue()가 Java 타입별로 다른 경로를 타며 LocalDate를 먼저 가로채 커스텀 컨버터를 무시한 것이었다. LocalDate를 감싼 CustomLocalDate 래퍼 클래스로 우회해 해결했다.

핵심 포인트
  • 같은 방식으로 등록한 커스텀 컨버터인데 LocalDateTime은 동작하고 LocalDate만 Oracle DATE 형식으로 저장되는 불일치가 문제였다.
  • StatementCreatorUtils.setParameterValue()가 instanceof로 타입을 먼저 판별해, LocalDate는 커스텀 컨버터를 거치지 않고 ps.setDate()로 바로 처리됐다.
  • setDate·setTimestamp·setString은 JDBC 드라이버에 서로 다른 타입 힌트를 줘, setDate는 VARCHAR 컬럼에서 오류를, setTimestamp는 Oracle의 자동 문자열 변환으로 우연히 동작했다.
  • LocalDateTime이 컨버터를 탄 것처럼 보인 건 실제로는 Oracle JDBC 드라이버의 TIMESTAMP에서 VARCHAR 자동 변환 덕분이지 컨버터가 동작한 게 아니었다.
  • 최종 해결은 LocalDate를 감싼 CustomLocalDate 래퍼로, Spring JDBC가 특별 취급하지 않아 커스텀 컨버터가 정상 적용된다.
상세 정리
  • 문제 상황: Spring Data JDBC에 LocalDate·LocalDateTime용 커스텀 컨버터를 각각 등록했는데 LocalDate만 의도한 저장이 안 되고 Oracle 기본 DATE로 들어갔다.
  • 1차 오해: LocalDateTime은 잘 되니 컨버터 설정 자체는 맞다고 보고 LocalDate 쪽 설정만 의심했으나 원인이 아니었다.
  • 근본 원인: Spring JDBC의 StatementCreatorUtils.setParameterValue()가 값의 Java 타입에 따라 분기하는데, LocalDate가 instanceof 체크에서 먼저 걸려 컨버터를 건너뛰고 ps.setDate()를 호출했다.
  • 타입 힌트 차이: setDate()는 DATE 타입 의미라 Oracle이 DATE로 변환하려다 VARCHAR 컬럼에서 오류가 났다.
  • setTimestamp의 착시: LocalDateTime은 setTimestamp() 경로를 타고 Oracle이 TIMESTAMP를 문자열로 자동 변환해줘 결과적으로 저장돼, 컨버터가 동작한 것처럼 보였을 뿐이다.
  • 시도1 @DateTimeFormat: 웹 요청 파라미터 바인딩용 어노테이션이라 JDBC 저장 경로에는 효과가 없었다.
  • 시도2 JdbcTemplate 직접 사용: 가능은 하지만 Spring Data JDBC의 편의성을 포기해야 해 부적절했다.
  • 시도3 String 필드 변환: Entity 필드를 String으로 두고 변환 메서드를 붙이면 엔티티의 의미가 모호해졌다.
  • 최종 해결: data class CustomLocalDate(val value: LocalDate) 래퍼를 만들고 WritingConverter·ReadingConverter를 쌍으로 등록했다.
  • 동작 원리: Spring JDBC는 CustomLocalDate를 내장 타입으로 특별 취급하지 않으므로 setParameterValue의 우선순위 분기를 우회해 커스텀 컨버터가 적용된다.
  • 이점: Spring JDBC 우선순위 우회, 타입 안정성, Spring Data JDBC 호환 유지, 확장 가능성, 명시적 의도 전달을 얻는다.
  • 실무 주의: 읽기·쓰기 컨버터를 반드시 쌍으로 등록하고, 테스트 코드로 실제 저장 형식을 확인하며, DB 벤더별 타입 변환 차이를 고려해야 한다.
왜 읽나Spring Data JDBC 커스텀 컨버터가 특정 타입에서만 무시돼 원인 모를 저장 오류를 겪는 백엔드 개발자에게 내부 동작과 우회법을 짚어주는 레퍼런스.
네이버페이
네이버페이 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 백엔드·github-engGitHub Engineering·

    조기 종료를 없애야 벡터화된다 — 메모리 속도 소스 코드 케이스 폴딩

    GitHub의 코드 검색 엔진 Blackbird는 480TB 이상의 소스 코드를 인덱싱하기 전 모든 바이트에 case folding을 적용한다. 이 글은 Rust로 구현한 case folding을 메모리 대역폭 한계(45+ GiB/s)까지 끌어올린 두 가지 반직관적 최적화를 상세히 다룬다. 핵심은 루프 조기 종료(break) 제거로 LLVM 벡터화를 유도하고, UTF-8을 디코딩하지 않고 바이트 공간 산술만으로 fold를 수행하는 것이다.

    #rust#unicode#simd+2
  2. 백엔드·여기어때 (GC컴퍼니)여기어때 (GC컴퍼니)·

    트랜잭션 스크립트에서 숙소 메타 + 가격 계산 모듈로 — 전시 아키텍처 개선기 (2/3)

    여기어때 전시개발팀이 숙소 상세(PDP) API를 해부한 결과, 코드상으로는 DB 호출 3번처럼 보이던 요청이 실제로는 MongoDB $lookup 체인으로 컬렉션을 19회 접근하는 구조였다. 이 트랜잭션 스크립트 방식의 핵심 문제는 "aggregation이 I/O를 가린다"는 점으로, 독립적인 쿼리 10개가 단일 파이프라인에 직렬화되어 병렬화 기회를 잃고, 가격 때문에 거의 안 바뀌는 이미지까지 매 요청마다 읽어야 하는 읽기 증폭이 발생했다. V3에서는 "조회 시점 조립"을 "쓰기 시점 사전 조립"으로 전환하고, 화면별로 복제되던 가격 계산 로직을 goodsprice 단일 모듈로 수렴했다. 4개 API(PLP/PDP/RDP/ILP)의 반복 마이그레이션은 Claude Code skill로 절차를 고정하고 쉐도잉 + 동일성 검증으로 안전망을 마련하는 방식으로 진행됐다.

    #architecture#migration#caching+2