패키지 구조 표준은 infra를 package-private으로 막으면서, 영속성 예외를 구현체에서 번역해야 하는 이유를 이렇게 적었다.
이 구조에서는 영속성 예외를 구현체에서 번역하지 않으면 코드가 안 된다
— Service가 DataIntegrityViolationException을 잡는 코드를 쓸 수 없다.
「쓸 수 없다」는 강한 말이다. 정말 못 잡는지 재봤다.
주장 — 번역하지 않으면 Service가 중복 키 예외를 잡을 수 없다
영속성 예외(중복 키)는 Repository 구현체가 번역하지 않으면 Service 코드에서 잡을 수 없다.
근거 종류는 1(기술 사실)이다.
반증 조건 — 한 경우라도 Service의 catch가 잡으면 반증이다
- refuted — 두 환경 중 하나라도 (가)나 (나) 중 한 경우에서 Service의 catch가
DataIntegrityViolationException을 잡는다 - supported — 두 환경 모두 두 경우 다 못 잡는다
- inconclusive — 중복 저장인데 예외가 아예 나지 않거나, DB에 붙지 못한다
설계 — flush 시점을 두 가지로 나눠 본다
unique 제약이 있는 테이블에 같은 키를 두 번 저장한다. Service의 @Transactional 메서드는 try { ... } catch (DataIntegrityViolationException e) 모양이고, flush 시점만 다르게 두 번 돌린다.
(가) saveAndFlush 저장 직후 DB로 보낸다 → try 블록 안에서 터진다
(나) save 트랜잭션이 끝날 때 flush 된다 → try 블록을 이미 빠져나온 뒤다
기록하는 것은 셋이다. Service의 catch가 잡았는지, 메서드 밖으로 나온 예외의 클래스, 마지막 행 수. 환경은 Spring Boot 3.5.16과 4.1.1이고, DB는 이 컴퓨터의 로컬 PostgreSQL 17이다. 실험 전용 데이터베이스를 만들고 끝나면 지웠다.
Repository는 Spring Data JPA의 JpaRepository를 그대로 썼다. 이 선택이 결과를 좌우한다. SimpleJpaRepository에는 @Repository가 붙어 있어 프록시가 JPA 예외를 Spring의 DataAccessException 계열로 이미 번역한다. 그래서 이 실험이 재는 것은 「Spring Data JPA를 그대로 쓸 때 Service가 잡을 수 있는가」다. 「번역하지 않는 자체 구현체를 쓸 때」는 재지 않았다.
@GeneratedValue는 IDENTITY가 아니라 SEQUENCE로 두었다. IDENTITY면 save()가 식별자를 받으려고 그 자리에서 INSERT를 날려 (나) 커밋 시점 flush라는 경우 자체가 만들어지지 않는다.
결과 — (가)는 잡혔고 (나)는 못 잡았다
PROBE boot=3.5.16 flush_inside=caught:DataIntegrityViolationException flush_escaped=UnexpectedRollbackException
commit_inside=no-exception commit_escaped=DataIntegrityViolationException<-PSQLException rows=1
PROBE boot=4.1.1 (같음)
판정은 refuted다. saveAndFlush로 flush를 당기면 Service의 catch가 DataIntegrityViolationException을 그대로 잡는다. 우리가 손으로 번역하는 코드를 한 줄도 두지 않았는데 잡혔다 — Spring Data JPA 프록시가 대신 번역해 준 것이다. 두 Boot 버전이 같았다.
예상과 달랐던 점 — 잡히기는 하는데 잡아도 소용이 없다
(가)에서 catch는 성공했는데, 메서드 밖으로는 UnexpectedRollbackException이 나왔다. 예외를 잡아 삼켰지만 트랜잭션에는 이미 롤백 표시가 박혀서, 커밋 단계에서 다시 터진 것이다. 마지막 행 수는 1이다 — 첫 저장만 남았다.
(나)는 이유가 다르다. 예외가 try 블록을 빠져나온 뒤인 커밋 시점에 터지므로 catch가 있을 자리가 아니다. 밖으로 나온 것은 DataIntegrityViolationException이고 그 최종 원인은 PSQLException이었다.
그래서 표준 글의 문장은 이렇게 갈린다.
| 표준 글의 문장 | 실측 |
|---|---|
| Service가 잡는 코드를 쓸 수 없다 | 틀렸다. Spring Data JPA를 쓰면 (가)에서 잡힌다 |
| 잡아서 일을 이어갈 수 있다 | 글에 없던 말이지만, (가)에서도 트랜잭션은 못 살린다 |
| 커밋 시점 flush면 잡을 자리가 없다 | 맞다. (나)가 그렇다 |
「못 잡는다」는 틀렸고 「잡아도 트랜잭션은 못 살린다」가 실제 모양이다. 규칙이 원하던 결론(번역해서 도메인 예외로 다루자)에는 오히려 더 단단한 근거지만, 지금 적힌 문장은 사실이 아니다.
이 결과가 표준에 남기는 것 — 근거를 「못 잡는다」에서 「잡아도 소용없다」로 바꿀지 정한다
규칙(구현체에서 번역한다)은 이 실험이 판정하지 않는다. 판정된 것은 근거 문장이다.
규칙은 바로 고치지 않는다. 표준 글에 > 반증: 줄을 달고 kind: 모순 이슈에서 근거 문장을 다시 쓸지 정한다. UnexpectedRollbackException을 어디서 다루는지는 표준에 아직 없다 — 이 실험이 새로 드러낸 빈자리다.
정리
saveAndFlush면 Service의 catch가DataIntegrityViolationException을 잡는다. 우리가 쓴 번역 코드는 없다 — Spring Data JPA 프록시가 번역한다- 잡아도 트랜잭션은 롤백 표시가 박혀
UnexpectedRollbackException으로 끝난다 save후 커밋 시점 flush면 예외가 try 밖에서 터져 잡을 자리가 없다- 두 Boot 버전과 PostgreSQL 17에서 같은 결과였다. 다른 DB는 재지 않았다
- 재지 않은 것 — 번역하지 않는 자체 Repository 구현체를 썼을 때. 표준 글의 문장은 그쪽을 전제로 읽을 여지가 있다
자신만의 철학을 만들어가는 중입니다.
댓글남기기