패키지 구조 표준은 infra(DB와 맞닿는 클래스가 사는 계층)를 package-private으로 막는다. 그렇게 막으면 Service(업무 로직을 처리하는 계층의 클래스)는 infra 안의 클래스(예: Entity, DB 테이블과 짝을 이루는 클래스)를 import 할 수 없다. 표준 글은 같은 자리에서, 영속성 예외(DB에 저장하다 난 예외)를 Repository 구현체(DB 저장을 실제로 하는 infra 안의 클래스)에서 번역(다른 예외로 바꿔 던지기)해야 하는 이유를 이렇게 적었다.
이 구조에서는 영속성 예외를 구현체에서 번역하지 않으면 코드가 안 된다
— Service가 DataIntegrityViolationException을 잡는 코드를 쓸 수 없다.
즉 Entity가 막히듯 영속성 예외도 막혀서, 구현체가 번역해 주지 않으면 Service는 잡을 길이 없다는 말이다. 「쓸 수 없다」는 강한 말이다. DataIntegrityViolationException은 DB의 제약(같은 값을 두 번 못 넣게 막는 unique 제약 등)을 어겼을 때 스프링이 던지는 예외다. 그런데 이 예외 클래스는 infra 안에 있는 것이 아니라 Spring 라이브러리(spring-tx)에 public으로 들어 있다. infra를 막는다고 이 클래스까지 막히는지, 표준 글은 그 연결을 설명하지 않았다. 그래서 정말 못 잡는지 재봤다.
주장 — 번역하지 않으면 Service가 중복 키 예외를 잡을 수 없다
영속성 예외(중복 키)는 Repository 구현체가 번역하지 않으면 Service 코드에서 잡을 수 없다.
「중복 키」는 같은 값이 두 번 들어가면 안 되는 칸에 이미 있는 값을 또 넣는 것이다. Repository는 DB에 저장하고 꺼내는 창구이고, 「Repository 구현체」는 그 창구의 실제 코드를 담은 클래스다. 근거 종류는 1(기술 사실)이다.
반증 조건 — 한 경우라도 Service의 catch가 잡으면 반증이다
판정은 셋 중 하나다. refuted는 주장이 틀렸다(반증됐다), supported는 주장이 맞았다(받쳐졌다), inconclusive는 실험으로 가릴 수 없었다는 뜻이다. 아래 「두 환경」은 Spring Boot 버전 둘이고, (가)·(나)는 flush 시점을 다르게 한 두 경우다 — 둘 다 다음 섹션 「설계」에서 정한다. 「DB에 붙지 못한다」는 DB에 접속하지 못했다는 말이다.
- refuted — 두 환경 중 하나라도 (가)나 (나) 중 한 경우에서 Service의 catch가
DataIntegrityViolationException을 잡는다 - supported — 두 환경 모두 두 경우 다 못 잡는다
- inconclusive — 중복 저장인데 예외가 아예 나지 않거나, DB에 붙지 못한다
설계 — flush 시점을 두 가지로 나눠 본다
unique 제약이 있는 테이블에 같은 키를 두 번 저장한다. Service의 @Transactional 메서드는 try { ... } catch (DataIntegrityViolationException e) 모양이고, flush 시점만 다르게 두 번 돌린다. 여기서 트랜잭션은 여러 DB 작업을 하나로 묶어 전부 성공하거나 전부 되돌리는 단위이고, (가)·(나)는 그 트랜잭션이 끝나기 전에 flush 하느냐 끝난 뒤(커밋 시점, 묶은 작업을 DB에 확정하는 순간)에 flush 하느냐로 가른 두 경우다.
실험에 쓴 Service 코드는 이렇다.
@Transactional
public void flushCase(String code) { // (가)
try {
repository.saveAndFlush(new Thing(code));
Marks.INSIDE.put("flush", "no-exception");
} catch (DataIntegrityViolationException e) {
Marks.INSIDE.put("flush", "caught:" + e.getClass().getSimpleName());
}
}
@Transactional
public void commitCase(String code) { // (나)
try {
repository.save(new Thing(code));
Marks.INSIDE.put("commit", "no-exception");
} catch (DataIntegrityViolationException e) {
Marks.INSIDE.put("commit", "caught:" + e.getClass().getSimpleName());
}
}
repository는 이 Service 클래스의 필드다. 타입은JpaRepository를 이어받은 인터페이스ThingRepository이고, Spring이 만든 구현체를 생성자로 넣어 준다(아래에서 다시 설명한다)@Transactional이 붙어 있어서 메서드에 들어갈 때 트랜잭션이 열리고, 메서드가 끝날 때(즉 try 블록을 다 빠져나온 뒤) 커밋된다Thing은code칸에 unique 제약이 걸린 테이블 하나에 대응하는 Entity다saveAndFlush와save는 둘 다JpaRepository가 주는 메서드다. 아래에서 다시 설명한다- try 안이 예외 없이 끝나면
Marks(결과를 메서드 밖으로 나르는 기록장)에no-exception을 적는다 - catch가 잡으면
caught:와 잡은 예외의 클래스 이름을 적는다. 메서드가 나중에 예외로 끝나도 이 기록은 남는다
(가) saveAndFlush 저장 직후 DB로 보낸다 → try 블록 안에서 터진다
(나) save 트랜잭션이 끝날 때 flush 된다 → try 블록을 이미 빠져나온 뒤다
(나)에서 트랜잭션이 끝나는 때는 @Transactional 메서드가 끝나는 때다. 그래서 커밋과 함께 일어나는 flush는 try 블록 바깥에서 일어난다.
순서는 셋이다. 먼저 같은 Service의 다른 @Transactional 메서드(seed, 안에서 repository.saveAndFlush(new Thing(code)) 한 줄만 부른다)로 같은 키(dup)를 한 번 저장해 커밋해 둔다. @Transactional 메서드를 하나 부를 때마다 트랜잭션이 하나 열리고 닫히므로, 메서드를 따로 부르는 것이 곧 트랜잭션을 따로 여는 것이다. 그다음 (가)를, 그다음 (나)를 각각 다른 트랜잭션에서 돌린다. 둘 다 이미 있는 dup을 또 넣으려는 것이라 중복 키가 된다.
기록하는 것은 셋이다. Service의 catch가 잡았는지, 메서드 밖으로 나온 예외의 클래스, 셋을 다 돌린 뒤 테이블에 남은 행(저장된 데이터 한 건) 수. 환경은 Spring Boot 3.5.16과 4.1.1이고, DB는 이 컴퓨터의 로컬 PostgreSQL 17이다. 실험 전용 데이터베이스를 만들고 끝나면 지웠다.
Repository는 Spring Data JPA의 JpaRepository를 그대로 썼다. 이 선택이 결과를 좌우한다. Spring Data JPA는 JpaRepository 인터페이스만 선언해 두면 구현 코드를 직접 짜지 않아도 Spring이 알아서 구현체(SimpleJpaRepository)를 만들어 끼워 준다. 그 구현체에는 @Repository(이 클래스를 Spring이 관리하는 객체로 등록하는 표시 중 하나)가 붙어 있어 프록시가 JPA 쪽 예외(JPA를 실제로 돌리는 Hibernate가 던진 예외. 그 클래스 이름은 이 실험이 기록하지 않았다)를 Spring의 DataAccessException(DB 기술이 무엇이든 한 계열로 묶어 주는 Spring의 상위 예외) 계열로 이미 번역한다. 이어지는 순서는 이렇다. Spring Data JPA는 설정 단계에서 PersistenceExceptionTranslationPostProcessor라는 부품을 등록하고, 이 부품이 @Repository가 붙은 객체마다 프록시를 씌운다. 그 프록시가 메서드에서 나오는 예외를 가로채 번역한다(저장소에 받아 둔 Spring Data JPA · Spring 원본 코드의 클래스 설명에서 확인했다). 그래서 이 실험이 재는 것은 「Spring Data JPA를 그대로 쓸 때 Service가 잡을 수 있는가」다. 「번역하지 않는 자체 구현체를 쓸 때」는 재지 않았다.
@GeneratedValue는 IDENTITY(DB가 저장하는 바로 그 순간에 PK 값을 자동으로 매기는 방식)가 아니라 SEQUENCE(DB가 번호표를 따로 떼어 두고 저장 전에 미리 값을 받아 오는 방식)로 두었다. IDENTITY면 save()가 식별자를 받으려고 그 자리에서 INSERT를 날려 (나) 커밋 시점 flush라는 경우 자체가 만들어지지 않는다. 여기서 식별자는 PK 값과 같은 말이다. Hibernate는 식별자를 자동 생성하게 해 둔 Entity를 저장하는(persist) 그 순간에 식별자 값을 만들어 넣는다. 그런데 IDENTITY는 DB가 행을 넣는 순간에 번호를 매기므로, 행을 실제로 넣기 전에는 식별자를 알 수 없다. 그래서 save() 자리에서 INSERT가 나간다(두 문장 다 Hibernate 6.6 사용자 가이드의 식별자 장에 있다). SEQUENCE는 번호를 미리 받아 올 수 있어서 INSERT를 커밋 때까지 미룰 수 있다.
결과 — (가)는 잡혔고 (나)는 못 잡았다
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 (같음)
한 줄씩 따라가면, 맨 앞의 PROBE는 실험 프로그램이 결과 줄 앞에 붙이는 표식이다. 실행 스크립트가 많은 로그 가운데 이 낱말로 시작하는 줄만 골라 결과로 쓴다. boot=3.5.16은 이 줄을 만든 Spring Boot 버전이다. flush_inside=caught:DataIntegrityViolationException은 (가) saveAndFlush 방식에서 try 블록 안의 catch가 DataIntegrityViolationException을 잡았다는 뜻이고, 그 뒤 flush_escaped=UnexpectedRollbackException은 그런데도 메서드 밖으로는 UnexpectedRollbackException이 새어 나갔다는 뜻이다. commit_inside=no-exception은 (나) save 방식에서 try 블록 안에서는 아무 예외도 나지 않았다는 뜻이고, commit_escaped=DataIntegrityViolationException<-PSQLException은 커밋 시점에 DataIntegrityViolationException이 터졌는데 그 원인(<-로 표시)은 PostgreSQL 드라이버가 던진 PSQLException이라는 뜻이다. 예외는 다른 예외를 원인으로 품고 다시 던져질 수 있고, <- 오른쪽은 그렇게 품고 품은 사슬의 맨 안쪽 예외다. rows=1은 첫 저장 · (가) · (나)를 다 돌린 뒤 테이블에 남은 행 수다. 4.1.1도 같은 값이 나와 (같음)으로 줄였다.
판정은 refuted다. saveAndFlush로 flush를 당기면 Service의 catch가 DataIntegrityViolationException을 그대로 잡는다. 우리가 손으로 번역하는 코드를 한 줄도 두지 않았는데 잡혔다 — Spring Data JPA 프록시가 대신 번역해 준 것이다. 두 Boot 버전이 같았다.
예상과 달랐던 점 — 잡히기는 하는데 잡아도 소용이 없다
(가)에서 catch는 성공했는데, 메서드 밖으로는 UnexpectedRollbackException이 나왔다. 예외를 잡아 삼켰지만 트랜잭션에는 이미 롤백 표시가 박혀서(「이 트랜잭션은 이미 되돌리기로 정해졌다」는 표시), 커밋 단계에서 다시 터진 것이다.
표시를 박은 것은 내 catch보다 먼저 지나간 Repository 쪽이다. Spring Data JPA 구현체(SimpleJpaRepository)의 saveAndFlush에도 @Transactional이 붙어 있어서, 이 메서드는 새 트랜잭션을 열지 않고 Service가 연 트랜잭션에 참여한다. 참여한 메서드가 예외로 끝나면 Spring은 바깥 트랜잭션 전체에 롤백 표시를 남기고, 바깥 메서드가 커밋하려 할 때 그 표시를 보고 커밋 대신 롤백하며 UnexpectedRollbackException을 던진다. 표시가 박히는 것은 예외가 saveAndFlush를 빠져나오는 순간, 즉 내 catch에 닿기 전이다. 그래서 catch가 잡았을 때는 이미 늦었다. 이 동작은 Spring 트랜잭션 처리의 원본 코드(AbstractPlatformTransactionManager 클래스)에서 확인했다. 다만 내가 읽은 것은 이 저장소에 받아 둔 아직 개발 중인 Spring 7.1 코드라, 실험의 두 Boot 버전이 실제로 쓴 Spring과 같은 판은 아니다. 두 Boot 버전의 실측 결과가 이 설명과 맞는다는 것까지가 확인한 범위다.
마지막 행 수는 1이다 — 첫 저장만 남았다. 첫 저장은 따로 연 트랜잭션에서 이미 커밋됐고, (가)와 (나)가 넣으려던 두 번째 dup은 어느 쪽도 DB에 남지 않았기 때문이다.
(나)는 이유가 다르다. 예외가 try 블록을 빠져나온 뒤인 커밋 시점에 터지므로 catch가 있을 자리가 아니다. 밖으로 나온 것은 DataIntegrityViolationException이고 그 최종 원인은 PSQLException이었다.
그래서 표준 글의 문장은 이렇게 갈린다. 둘째 줄은 표준 글에 적힌 문장이 아니다. 「잡을 수 있다」를 읽으면 「잡아서 하던 일을 이어가 커밋까지 할 수 있다」로 받아들이기 쉬운데, 실측이 그건 아니라고 보여 줘서 같이 적었다. 「트랜잭션을 살린다」는 롤백되지 않고 커밋까지 가게 한다는 뜻이다.
| 표준 글의 문장 | 실측 |
|---|---|
| Service가 잡는 코드를 쓸 수 없다 | 틀렸다. Spring Data JPA를 쓰면 (가)에서 잡힌다 |
| 잡아서 일을 이어갈 수 있다 | 글에 없던 말이지만, (가)에서도 트랜잭션은 못 살린다 |
| 커밋 시점 flush면 잡을 자리가 없다 | 맞다. (나)가 그렇다 |
「못 잡는다」는 틀렸고 「잡아도 트랜잭션은 못 살린다」가 실제 모양이다. 규칙이 원하던 결론(번역해서 이 프로그램의 업무 예외로 바꿔 다루자)에는 오히려 더 단단한 근거지만, 지금 적힌 문장은 사실이 아니다. 「더 단단한 근거」라고 한 것은 이런 뜻이다 — Service에서 DataIntegrityViolationException을 잡아 하던 일을 이어가는 길은 쓸모가 없으니, Service에 그 catch를 둘 이유가 없다. 번역이 트랜잭션을 살려 준다는 뜻은 아니다. 이 실험은 번역을 직접 하는 구현체를 재지 않았으므로, 번역을 해도 롤백 표시가 그대로 박히는지는 이 글로 알 수 없다.
이 결과가 표준에 남기는 것 — 근거를 「못 잡는다」에서 「잡아도 소용없다」로 바꿀지 정한다
규칙(구현체에서 번역한다)은 이 실험이 판정하지 않는다. 판정된 것은 근거 문장이다.
규칙은 바로 고치지 않는다. 표준 글의 해당 원칙 밑에 > 반증: <실험 요약> — [증명](이 글 링크) 모양의 인용 한 줄을 달고 kind: 모순 이슈(GitHub 이슈에 붙이는 라벨이다)에서 근거 문장을 다시 쓸지 정한다. UnexpectedRollbackException을 어디서 다루는지는 표준에 아직 없다 — 이 실험이 새로 드러낸 빈자리다.
정리
saveAndFlush면 Service의 catch가DataIntegrityViolationException을 잡는다. 우리가 쓴 번역 코드는 없다 — Spring Data JPA 프록시가 번역한다- 잡아도 트랜잭션은 롤백 표시가 박혀
UnexpectedRollbackException으로 끝난다 save후 커밋 시점 flush면 예외가 try 밖에서 터져 잡을 자리가 없다- 두 Boot 버전과 PostgreSQL 17에서 같은 결과였다. 다른 DB는 재지 않았다
- 재지 않은 것 — 번역하지 않는 자체 Repository 구현체를 썼을 때. 표준 글의 문장은 그쪽을 전제로 읽을 여지가 있다
자신만의 철학을 만들어가는 중입니다.
댓글남기기