결제 정보를 저장하는 메서드가 마지막 로그까지 출력한 뒤 예외를 반환한다고 하자. 외부 승인 응답은 받았는데 로컬 결제 내역은 없다. 마지막으로 실행한 비즈니스 코드부터 조사하면 저장 실패가 발생한 시점을 놓칠 수 있다.
이 글의 질문은 서비스의 마지막 로그 뒤에서 JSON 저장이 실패하는 경계는 어디인가다. Hibernate 6.5의 JSON 매핑을 예로 들어 저장 요청, flush, commit을 나눠 진단한다. 외부 승인과 로컬 저장은 별개의 성공 조건으로 다룬다.
save 다음 로그가 증명하는 것
먼저 저장 경계를 짧은 코드로 확인한다.
@Transactional
fun recordPayment(command: PaymentCommand) {
val payment = Payment.from(command)
paymentRepository.save(payment)
log.info("payment save requested: {}", command.requestId)
}
이 로그는 저장소 호출 이후 코드가 진행됐다는 사실을 보여준다. 모든 SQL 실행과 커밋 성공을 증명하지는 않는다. 식별자 생성 전략이나 쿼리 실행에 따라 SQL이 더 일찍 나갈 수도 있으므로 save가 언제나 SQL을 지연한다고 단정하지 않는다.
위 메서드는 kotlin-spring 플러그인이 적용된 @Service 빈 내부의 코드이며, 다른 빈에서 트랜잭션 프록시를 통해 호출하는 조건이다. Kotlin의 클래스와 메서드는 기본적으로 final이므로 클래스 기반 프록시를 사용한다면 플러그인이나 명시적인 open 설정이 필요하다. Spring의 Kotlin 지원 안내를 확인한다.
Hibernate는 영속성 컨텍스트의 변경을 flush로 DB에 동기화한다. 기본 AUTO 모드에서는 커밋 전뿐 아니라 관련 쿼리 전에 flush할 수도 있다. 따라서 실패 위치는 메서드 본문, 명시적 flush, 트랜잭션 종료 가운데 어디든 될 수 있다. Hibernate 6.5의 flush 설명이 이 구분의 기준이다.
외부 호출이 앞에 있다면 실패 경계를 다음처럼 놓고 읽는다.
명시적 flush를 추가하면 일부 매핑·SQL 오류를 서비스 메서드 안으로 당길 수 있다. 다만 flush가 성공해도 이후 제약 검사나 연결 문제로 커밋이 실패할 수 있다. 그림의 저장 요청 로그와 최종 커밋은 같은 확인 지점이 아니다.
가장 안쪽 예외에서 타입 계약을 복원한다
TransactionSystemException은 트랜잭션 종료 실패를 감싼 예외일 수 있다. 이 이름만 보고 트랜잭션 설정을 바꾸기보다 원인 체인을 끝까지 읽는다. JSON 직렬화 예외나 ClassCastException이 있다면 필드에서 DB까지 어떤 타입이 이동하는지 적는다.
| 경계 | 확인할 내용 |
|---|---|
| 엔티티 필드 | 객체·문자열·Map의 선언 타입, 값 타입과 nullable 여부 |
| 변환기 | AttributeConverter가 반환하는 타입과 자동 적용 여부 |
| Hibernate | JSON JDBC 타입 지정과 FormatMapper |
| JDBC·DB | 바인딩 타입, 드라이버 버전, 실제 컬럼 타입 |
JSON 문자열을 만드는 변환기와 객체를 JSON으로 직렬화하는 매핑을 겹치면 책임이 불분명해진다. 이것이 모든 JSON 예외의 원인이라는 뜻은 아니다. 어느 단계가 객체를 기대하고 어느 단계가 문자열을 반환하는지 실제 설정과 스택에서 확인해야 한다.
Hibernate의 기본 JSON 매핑을 선택한다면 다음처럼 객체와 JSON 컬럼의 계약을 한곳에서 표현할 수 있다. 앞부분은 필드 접근 방식을 사용하는 Payment 엔티티 내부의 필드 조각이며, 뒤의 data class는 JSON 값의 타입이다.
// Payment 엔티티 내부
@field:JdbcTypeCode(SqlTypes.JSON)
@field:Column(name = "provider_metadata", columnDefinition = "jsonb")
var providerMetadata: ProviderMetadata? = null
data class ProviderMetadata(
val provider: String,
val transactionId: String,
val status: String
)
@JdbcTypeCode(SqlTypes.JSON)은 해당 필드에 JSON 매핑을 지정한다. columnDefinition만으로 직렬화 계약을 정했다고 보지 않는다. Hibernate 6.5는 JSON 라이브러리를 감지하고 필요하면 hibernate.type.json_format_mapper로 매퍼를 지정하도록 설명한다. 실제 프로젝트 버전과 의존성에 같은 동작이 있는지 확인한 뒤 적용한다. JSON 매핑 공식 설명.
@field:는 어노테이션을 JVM 필드에 붙이는 Kotlin use-site target이다. 엔티티의 @Id 등도 같은 접근 방식으로 맞춘다. 엔티티에는 JPA가 사용할 기본 생성자가 필요하므로 kotlin-jpa의 no-arg 지원을 검토하고, 엔티티의 open 설정도 점검한다.
providerMetadata가 null일 수 있다는 선언과 그 안의 세 문자열이 필수라는 선언은 다른 계약이다. Jackson 기반 FormatMapper라면 실제로 쓰는 ObjectMapper에 Kotlin 모듈이 등록돼 생성자와 nullability를 해석하는지 확인한다. Map<String, Any?>로 바꾸면 값의 구조와 숫자 타입을 검증할 책임도 남는다.
레거시 변환기를 유지할 이유가 있다면 제거부터 하지 않는다. 기존 데이터와 다른 필드에 미치는 범위를 확인하고 변환기 방식 또는 Hibernate JSON 방식 중 하나의 계약으로 정리한다. 결제 상태나 거래 ID처럼 검색·고유성·대사에 사용하는 값은 관계형 컬럼에 두고, 추가 응답 정보만 JSON에 두는 선택도 가능하다.
직렬화 테스트를 실제 DB 왕복으로 확장한다
객체를 JSON 문자열로 만드는 테스트만으로 JDBC 바인딩과 jsonb 컬럼 호환성을 확인할 수는 없다. PostgreSQL을 사용하는 통합 테스트에서 저장, flush, 영속성 컨텍스트 초기화, 재조회를 연결한다.
entityManager.persist(payment)
entityManager.flush()
val paymentId = requireNotNull(payment.id)
entityManager.clear()
val loaded = requireNotNull(entityManager.find(Payment::class.java, paymentId))
val metadata = requireNotNull(loaded.providerMetadata)
assertThat(metadata.transactionId).isEqualTo("tx-example")
flush는 SQL 반영을 시도하고 clear는 메모리에 남은 객체 대신 DB에서 읽도록 한다. 테스트용 엔티티와 DB 구성은 별도로 준비한다. 이 예제는 실행 결과가 아니라 확인 절차다. 자동 롤백되는 테스트는 실제 커밋 성공을 검증하는 테스트와 구분한다.
왕복 사례에는 null, 기존 JSON 형식, 새 필드가 추가된 객체, 알 수 없는 필드도 넣는다. 매핑을 바꾸는 시점에는 새 객체 저장보다 이미 저장된 문서를 다시 읽는 경로에서 호환성이 깨질 수 있다.
외부 승인에는 로컬 롤백이 전파되지 않는다
JSON 매핑을 고쳐도 이미 승인된 외부 결제는 별도 문제로 남는다. 로컬 트랜잭션 롤백은 상대 결제사의 승인을 취소하지 않는다. 외부 요청 ID와 거래 ID를 로컬 작업에 연결해 승인 조회, 저장 재시도, 취소 또는 수동 대사 중 어느 복구가 가능한지 정해야 한다.
로그에는 승인 응답 수신 시각, 로컬 저장 시도, 최종 트랜잭션 결과를 같은 요청 ID로 연결한다. 원문 결제 응답을 그대로 남기기보다 조사에 필요한 식별자와 비민감 상태를 고른다. 예외를 잡아 성공 응답으로 바꾸는 방법은 저장 누락을 숨길 수 있으므로 복구 상태와 함께 설계한다.
이 진단 순서는 메서드 본문 뒤에서 저장 예외가 드러나고 JSON 관련 원인이 함께 있을 때 유효하다. 다음 조사에서는 실제 Hibernate 버전, 가장 안쪽 예외, 엔티티 필드와 DB 컬럼 타입을 먼저 맞춘다. 그다음 flush와 commit의 결과를 구분해야 매핑 수정과 결제 복구를 각각 판단할 수 있다.
