실제 증상
본사와 해외법인 시스템 사이에서 주문 데이터를 등록하는 Service는 여러 DB 작업과 외부 API 호출을 하나의 업무 흐름으로 처리했다. 중간 단계가 실패하면 전체 작업이 취소돼야 했지만 일부 데이터만 반영되거나 실패가 정상 처리처럼 끝나는 현상이 있었다.
확인해야 할 경계는 첫 DB 저장, 외부 REST API 호출, 응답 검증, 후속 DB 상태 반영이었다. 화면이나 호출자는 실패 응답을 받았더라도 앞 단계의 로컬 데이터가 남을 수 있었기 때문에, 단순히 API 예외가 발생했다는 사실만으로는 Rollback 여부를 설명할 수 없었다.
처음 확인한 트랜잭션 범위
문제의 메서드는 Spring 프록시를 통해 호출되는 @Transactional Service 메서드였다. 트랜잭션은 이 메서드 진입 전에 시작되고, 프록시가 메서드의 정상 반환 또는 예외 전파를 확인한 뒤 Commit과 Rollback을 결정한다.
트랜잭션 내부의 try-catch가 예외를 잡은 뒤 로그만 남기고 메서드를 정상 종료했다. Spring의 트랜잭션 경계에서는 Rollback 대상 예외가 밖으로 전달되지 않았기 때문에 해당 호출을 성공한 흐름으로 판단할 수 있었다.
수정 전 코드
아래 코드는 실제 구조를 단순화한 형태다. 핵심은 @Transactional 메서드 안에서 예외를 잡은 뒤 정상 반환한 점이다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
@Transactional
public void registerOrder() {
try {
saveLocalData();
callExternalApi();
updateStatus();
} catch (Exception e) {
log.error("registration failed", e);
// 예외가 사라져 트랜잭션이 정상 종료될 수 있다.
}
}로그에는 실패가 남지만 트랜잭션 프록시가 관찰한 결과는 정상 반환이다. 이 경우 앞에서 실행된 로컬 DB 변경은 Commit될 수 있다.
Controller의 catch와 혼동하지 않기
Controller에서 예외를 catch하면 무조건 Rollback되지 않는다는 설명은 정확하지 않다. 트랜잭션 Service가 예외를 밖으로 던지고 그 예외가 트랜잭션 프록시를 먼저 통과했다면, Spring은 이미 Rollback 여부를 판단한 뒤 Controller로 예외를 전달한다. Controller가 이후에 사용자 응답을 만들기 위해 catch하는 것은 Service 내부에서 예외를 삼키는 것과 다르다.
이 사례의 문제는 호출자가 예외를 처리했다는 사실이 아니라, 트랜잭션이 선언된 프록시 내부 메서드가 예외를 정상 반환으로 바꿨다는 데 있었다.
RuntimeException과 checked exception
Spring의 기본 Rollback 규칙에서는 RuntimeException과 Error가 프록시 밖으로 전달되면 Rollback 대상이 된다. 반면 checked exception은 기본 설정만으로는 Rollback되지 않는다.
- 원인을 보존한
RuntimeException으로 재전파한다. - checked exception을 그대로 사용해야 한다면
@Transactional(rollbackFor = Exception.class)처럼 Rollback 정책을 명시한다. - 어떤 방식을 선택하든 예외가 트랜잭션 프록시까지 전달돼야 한다.
rollbackFor를 선언해도 메서드 내부에서 예외를 catch하고 정상 반환하면 적용할 예외 자체가 프록시에 전달되지 않는다.
수정 후 코드
예외를 기록하되 트랜잭션 경계 밖으로 다시 전달하도록 변경했다. 외부 API가 HTTP 200을 반환해도 업무 실패 응답일 수 있으므로, 통신 성공과 업무 성공도 구분해 실패를 예외 흐름으로 연결했다.
아래 코드는 문제 해결 구조를 설명하기 위한 pseudocode이며 실제 클래스·테이블·설정 이름과는 무관합니다.
@Transactional
public void registerOrder() {
try {
saveLocalData();
ApiResult result = callExternalApi();
if (!result.isBusinessSuccess()) {
throw new OrderRegistrationException("external business failure");
}
updateStatus();
} catch (Exception e) {
log.error("registration failed", e);
throw new OrderRegistrationException("order registration failed", e);
}
}
public class OrderRegistrationException extends RuntimeException {
public OrderRegistrationException(String message) {
super(message);
}
public OrderRegistrationException(String message, Throwable cause) {
super(message, cause);
}
}예시는 공개를 위해 이름과 응답 구조를 일반화했다. 수정의 기준은 다음과 같다.
- 예외를 catch해야 한다면 원인 예외를
cause로 보존해 다시 던진다. - 복구 가능한 예외만 그 자리에서 처리하고, Rollback이 필요한 실패는 프록시 밖으로 전달한다.
- HTTP 상태와 별도로 응답 본문의 업무 성공 여부를 검증한 뒤 후속 DB 상태를 반영한다.
- 별도 저장이 필요한 실패 이력은 본 트랜잭션과 의도적으로 분리한다.
HTTP 200과 업무 성공을 분리한 이유
외부 API가 HTTP 200을 반환해도 응답 본문에 실패 코드나 처리 불가 결과가 들어 있을 수 있다. 통신 성공만 보고 후속 상태를 완료로 변경하면 외부 시스템에는 주문이 등록되지 않았는데 로컬 DB에는 성공으로 남을 수 있다.
따라서 응답 본문의 업무 결과를 먼저 검증하고, 성공이 확인된 경우에만 다음 DB 상태를 반영했다. 통신 예외와 업무 실패 응답은 발생 지점은 다르지만, 현재 로컬 작업을 Commit해서는 안 된다는 점에서는 같은 실패 흐름으로 연결했다.
DB Transaction이 되돌릴 수 없는 범위
로컬 DB의 @Transactional은 외부 REST API가 이미 만든 결과까지 자동으로 취소하지 못한다. API 호출이 성공한 뒤 후속 DB 갱신이 실패하면 로컬 변경은 Rollback되더라도 외부 시스템의 등록 결과는 남을 수 있다.
그래서 예외 재전파만으로 전체 분산 작업의 원자성이 해결됐다고 보지 않았다. 다음 항목은 별도로 설계해야 한다.
- 외부 호출 전에 필요한 로컬 상태를 어디까지 저장할지
- HTTP 200이 아닌 업무 성공 응답을 어떤 조건으로 확정할지
- 같은 요청을 재시도할 때 외부 중복 등록을 어떻게 막을지
- 외부 성공 후 로컬 실패가 발생했을 때 조회·재처리 또는 보상 처리가 가능한지
- 요청 식별자와 처리 상태를 남겨 운영자가 양쪽 결과를 대조할 수 있는지
검증
- 첫 DB 반영 후 외부 API 실패
- API 성공 후 후속 DB 반영 실패
- 업무 실패 응답과 통신 예외
- 여러 주문을 처리할 때 특정 주문만 실패하는 경우
각 조건에서 로컬 데이터가 의도치 않게 부분 Commit되지 않는지, 실패가 트랜잭션 프록시와 호출자에게 전달되는지, 외부 결과와 대조해 재처리 가능한 상태가 남는지를 확인했다. 특히 API 성공 후 후속 DB 실패는 외부 결과까지 자동 복구됐다고 판단하지 않고 별도 확인 대상으로 유지했다.
적용 결과
API 실패와 트랜잭션 예외가 정상 반환으로 바뀌지 않도록 수정해, 검증한 실패 조건에서 로컬 DB 변경이 Rollback되는 구조를 확보했다. HTTP 성공과 업무 성공도 분리해 해외법인 주문·상태 연계 과정의 잘못된 후속 상태 반영 가능성을 줄였다.
정리
try-catch는 예외를 안전하게 처리한다는 뜻이 아니다. 트랜잭션 안에서는 예외가 프록시까지 전달되는지와 어떤 예외를 Rollback 대상으로 삼는지가 데이터 일관성을 결정한다. 동시에 로컬 DB Transaction과 외부 API의 부수효과는 같은 경계가 아니므로, 재시도·중복 방지·보상 가능성까지 별도로 다뤄야 한다.