문제

5,000건짜리 입수 파일 처리 중 3,001번째에서 실패하면 어떻게 해야 하나.

가장 쉬운 답은 “파일 전체를 한 트랜잭션으로 묶고 실패하면 전량 롤백”이다. 깨끗해 보이지만, 이 프로젝트에서 이 답을 일부러 구현해서 깨뜨렸다. 결론부터:

전량 롤백은 데이터만 되돌리는 게 아니라 실패 원인 기록까지 지운다. 그 파일을 다시 넣으면 같은 지점에서 같은 실패를 반복한다.

경계 설계 4가지

작업 전파 속성 이유
Delivery 등록 REQUIRES_NEW (즉시 커밋) messageId UNIQUE 가 중복 실행 차단 장치 — 커밋돼야 다른 트랜잭션에 보인다
검증 실패 기록 REQUIRES_NEW 롤백에 휩쓸리면 안 된다. 실패 기록은 재처리의 입력이다
청크(500건) 적재 REQUIRED 커밋 단위. 건별 커밋은 fsync 비용, 파일 전체는 undo/락 비용
사후 검증 실패 보상 삭제 이미 커밋된 것은 롤백으로 못 되돌린다

보상 삭제가 필요한 구조적 이유

스트리밍 파싱(StAX)이라 헤더의 ReleaseCount 와 실제 개수가 맞는지는 파일 끝까지 읽어야 안다. 그때는 이미 여러 청크가 커밋된 뒤다. 그래서 앨범마다 delivery_id 를 추적 컬럼으로 남기고, 사후 검증 실패 시 “이 파일이 넣은 것만” 지운다. 트랜잭션으로 못 되돌리는 것을 보상 트랜잭션으로 되돌리는 패턴이다.

self-invocation — 선언적 트랜잭션의 1번 지뢰

@Transactional 은 Spring 프록시가 가로채서 동작한다. 같은 클래스 안에서 this.recordFailure() 로 부르면 프록시를 안 거치므로 애노테이션이 조용히 무시된다. REQUIRES_NEW 를 붙여놨는데 실패 기록이 롤백에 휩쓸려 사라지는, 원인 찾기 어려운 버그가 된다.

해결은 트랜잭션 경계를 소유하는 별도 빈(IngestTransactionSupport)을 두는 것. self-injection 이나 AopContext 도 있지만, 별도 클래스가 “여기가 경계다”를 구조로 드러낸다. MyBatis 시절엔 트랜잭션을 한 곳에서 명시적으로 열고 닫아 이 함정을 밟을 일이 적었다 — 선언적 트랜잭션의 편리함은 이런 암묵성과 맞바꾼 것이다.

이 패턴은 프로젝트에서 세 번 재사용됐다 (입수, TTL 정리 배치, HLS 자산 준비).

깨뜨려서 증명 — 반증 테스트 4종

주장만으로는 부족해서, 기각한 설계를 일부러 재현하는 테스트를 만들었다.

시나리오 결과
실패 기록을 REQUIRED 로 + 호출자 롤백 기록 소멸 — “왜 실패했는지 모르는 채 데이터만 없는” 상태
실제 구현(REQUIRES_NEW) + 같은 롤백 기록 생존
청크1 커밋 후 청크2 실패 청크1 잔존(전부도 0도 아님) → 보상 삭제가 마무리
파일 전체 단일 트랜잭션 원자성은 완벽, 실패 기록까지 소멸

네 번째 줄이 이 글의 요지다. “깨끗한 롤백”의 실체는 디버깅 근거의 소실이었다.

상세: ADR-0002