문제: 부가 작업이 실패하면 본 작업까지 사라진다
LCA 승인 기능에 부가 작업이 둘 붙어 있다.
- baseline 저장 — 승인 시점의 지표를 스냅샷으로 남김
- 할당 스냅샷 — 같은 성격
둘 다 승인의 전제조건이 아니다. 없어도 승인은 유효하다. 그런데 이게 실패하면 승인 자체가 500으로 끝나고 상태가 되돌아갔다.
- 실측 조건 — 수집월 파싱 실패(
DateTimeParseException) - 결과 — 승인이 200이 아닌 500,
approval_state가REVIEW로 원복
1차 진단: catch 범위가 좁았다
코드를 보니 원인이 명확해 보였다.
try {
baselineService.capture(...);
} catch (BusinessLogicException e) { // 이것만 잡는다
log.warn(...);
}
- 잡는 것 —
BusinessLogicException(우리가 정의한 업무 예외) - 실제로 난 것 —
DateTimeParseException(JDK 런타임 예외) - 결과 — catch를 빠져나가 승인 트랜잭션 전체를 롤백
조치는 단순했다.
-
catch범위를RuntimeException으로 확대 - 같은 패턴이 있던 다른 서비스도 동일 적용
-
requestReview/reopen에서 catch 없이 무방비로 호출하던 곳도 감쌈
회귀 테스트 4건을 붙였고, 수정 전 코드로 되돌리면 실제로 실패하는 것까지 확인했다. 테스트가 결함을 잡는지 역검증한 것이다.
여기까지는 교과서적이다.
그런데 여전히 500이었다
E2E로 재검증했더니 그대로였다. 예외를 분명히 잡고 있는데 승인이 롤백된다.
원인은 예외 전파가 아니었다.
원인은 예외 전파가 아니라 트랜잭션 오염이었다.
진짜 원인: rollback-only 마크
문제의 메서드는 이랬다.
@Transactional(readOnly = true)
public BigDecimal getGwpTotalForView(...) { ... }
@Transactional의 기본 전파 속성은 REQUIRED다. 이미 트랜잭션이 있으면 새로 만들지 않고 그 트랜잭션에 참여한다. readOnly = true도 마찬가지다 — 읽기 전용이라고 별도 트랜잭션이 되지는 않는다.
그래서 이런 일이 벌어진다.
- 승인 트랜잭션이 열린다
- 그 안에서
getGwpTotalForView가 같은 트랜잭션에 참여한다 - 여기서 예외가 난다
- Spring이 그 트랜잭션에
rollback-only마크를 찍는다 - 호출자가 예외를
catch한다 — 예외는 잡혔다 - 승인 로직이 정상 진행되고 커밋을 시도한다
- 트랜잭션 매니저가 rollback-only 마크를 보고
UnexpectedRollbackException을 던진다 - 승인이 통째로 사라진다
커밋 메시지에 이렇게 적었다.
여기서 예외가 나면 그 트랜잭션이 rollback-only 로 표시되고, 호출자가 예외를 잡아도 커밋 시점에
UnexpectedRollbackException이 터져 승인이 통째로 사라진다. 잡는 것만으로는 막을 수 없다.
catch는 예외의 전파를 막지, 트랜잭션에 이미 찍힌 마크를 지우지 못한다. 이 둘을 같은 것으로 생각하는 게 함정이다.
해법: 트랜잭션을 분리한다
부가 작업을 별도 트랜잭션에서 돌려야 한다. 그래야 거기서 예외가 나도 본 트랜잭션에 마크가 안 찍힌다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
public String readBaseline(...) { ... }
REQUIRES_NEW는 기존 트랜잭션을 잠시 보류하고 새 트랜잭션을 연다. 두 트랜잭션은 서로 독립이라, 안쪽이 롤백돼도 바깥은 멀쩡하다.
여기에 함정이 하나 더 있다.
REQUIRES_NEW는 프록시를 거쳐야 적용되므로 같은 클래스 메서드 분리로는 무효 — 별도 빈으로 분리한다.
Spring의 @Transactional은 프록시로 동작한다. 같은 클래스 안에서 메서드를 나눠 직접 호출하면 프록시를 거치지 않아 애너테이션이 아무 효과가 없다. 메서드만 분리하면 고친 것 같지만 실제로는 그대로다.
그래서 LcaApprovalBaselineReader라는 별도 빈을 만들었다. 프로젝트에 같은 이유로 빈을 분리한 선례가 이미 있어서 그 패턴을 따랐다.
경계를 어떻게 나눴는지도 중요하다.
| 작업 | 트랜잭션 |
|---|---|
| 지표 조회 + JSON 직렬화 | REQUIRES_NEW (실패해도 격리) |
엔티티 갱신 (saveApprovedBaseline) |
호출자 트랜잭션 그대로 |
실패할 수 있는 부분만 떼어냈다. 엔티티 갱신까지 별도 트랜잭션으로 보내면 승인과 baseline 저장의 원자성이 깨진다. 격리하려는 건 “위험한 조회”지 “저장”이 아니다.
검증
E2E로 실증했다.
- 조건 — 수집월
'2026-12'(파싱 실패를 유발하는 값) - 결과 — 승인 200,
approval_state = APPROVED,approved_baseline = NULL - 의미 — 부가 작업만 실패하고 승인은 유지
- 전체 시나리오 11건 통과, 정상 경로에서 baseline 저장도 확인
approved_baseline이 NULL이라는 게 핵심이다. 부가 작업은 실패했고 그 사실이 데이터에 정직하게 남았다. 승인은 성공했다. 원하던 동작 그대로다.
회귀를 막는 테스트
이 수정은 되돌리기 쉽다. 누군가 “빈이 왜 이렇게 잘게 쪼개져 있지, 합치자”고 하면 조용히 원상복구된다. 메서드를 합쳐도 컴파일되고 대부분의 테스트도 통과한다.
그래서 REQUIRES_NEW가 유지되는지 검사하는 테스트를 넣었다.
REQUIRES_NEW 가 유지되는지 검사하는 테스트 추가(합치면 회귀한다).
구조 자체를 고정하는 테스트다. 기능 테스트가 아니라 “이 분리에는 이유가 있다”를 코드로 못 박은 것에 가깝다. 주석으로 적어두면 리팩터링할 때 안 읽힌다.
남는 교훈
이번 작업의 시간 배분은 이랬다.
| 단계 | 진단 | 결과 |
|---|---|---|
| 1 | catch 범위가 좁다 | 맞는 지적이지만 원인은 아니었다 |
| 2 | 트랜잭션 오염 | 진짜 원인 |
1단계는 틀린 수정이 아니었다. BusinessLogicException만 잡던 건 실제로 문제였고, 그 커밋은 지금도 살아 있다. 다만 그것만으로는 증상이 안 사라졌다.
이런 상황에서 위험한 건 “고쳤는데 왜 그대로지” 라는 전제를 안고 다음 단서를 찾는 것이다. 실제로는 고친 게 맞고, 원인이 하나 더 있었을 뿐이다. 증상이 하나라고 원인도 하나라는 보장은 없다.
정리하면 Spring 트랜잭션에서 이 세 가지는 서로 다르다.
- 예외를 잡았다 — 전파를 막았다. 트랜잭션 상태와는 무관하다
-
트랜잭션이 오염됐다 — rollback-only 마크.
catch로 못 지운다 -
트랜잭션을 분리했다 —
REQUIRES_NEW. 그리고 프록시를 거쳐야 실제로 걸린다
특히 세 번째의 단서 조항이 조용하다. 애너테이션을 붙였는데 프록시를 안 거치면 에러 없이 무시된다. 앞의 rollback-only도, 이 프록시 우회도, 공통점은 아무도 소리를 내지 않는다는 것이다.