지난 글에서 JPA로 테이블과 객체를 연결하는 법을 정리했다. 그동안 계속 등장했지만 설명을 미뤄 둔 어노테이션이 하나 있다. 거의 모든 UseCase 메서드 위에 붙어 있던 @Transactional 이다.

글에 나오는 코드는 이해를 돕기 위해 일부 단순화했다.


왜 필요할까?

피처 플래그를 켜고 끄는 코드다.

@Transactional
public void execute(ChangeStatusFeatureFlagCommand command) {
    FeatureFlag featureFlag = findFeatureFlag.findFeatureFlag(id)
        .orElseThrow(() -> new NotFoundFeatureFlagException(id));
    featureFlag.changeStatus(status, userId);                        // ① 상태 변경

    FeatureFlagHistory history = new FeatureFlagHistory(featureFlag);
    loadFeatureFlagHistory.loadFeatureFlagHistory(history);          // ② 이력 저장
}

DB 작업이 두 개다. ①은 성공하고 ②에서 에러가 나면? 트랜잭션이 없으면 플래그는 켜졌는데 누가 켰는지 기록은 없는 상태가 된다.

WMS(창고관리시스템)에서는 더 심각하다. 출고를 처리할 때 “재고 차감”과 “출고 상태를 SHIP으로 변경”이 같이 일어나야 하는데, 재고만 빠지고 출고 상태가 그대로면 물건은 사라졌는데 아직 안 보낸 주문이 생긴다.

트랜잭션은 여러 DB 작업을 하나로 묶어서 전부 성공하거나 전부 취소되게 한다.

용어 뜻
커밋(commit) 묶은 작업을 DB에 확정
롤백(rollback) 묶은 작업을 전부 없던 일로

프론트로 비유하면: React Query의 낙관적 업데이트

useMutation({
  onMutate: () => { /* 화면 먼저 바꿈 */ return snapshot; },
  onError: (err, vars, snapshot) => { /* 실패하면 snapshot으로 되돌림 */ },
});

“일단 바꾸고, 실패하면 되돌린다”는 생각은 같다. 차이는 트랜잭션이 커밋되기 전까지 다른 사용자에게 바뀐 내용이 보이지 않는다는 점이다. 낙관적 업데이트는 실패하기 전에도 화면에 이미 반영돼 있다.


① @Transactional이 실제로 하는 일

어노테이션 한 줄이 메서드를 이렇게 감싼다.

// Spring이 만들어 주는 코드 (개념)
트랜잭션 시작;
try {
    execute(command);      // 원래 메서드
    커밋;                   // 끝까지 오면 확정
} catch (RuntimeException e) {
    롤백;                   // 예외가 나면 전부 취소
    throw e;               // 예외는 그대로 위로 던짐
}

3편의 DI와 연결된다. Controller가 주입받는 UseCase는 사실 원본이 아니라, Spring이 이 try/catch로 감싼 대리 객체(프록시) 다. 프론트의 고차 함수와 같은 원리다.

const withTransaction = (fn) => async (...args) => {
  begin();
  try { const r = await fn(...args); commit(); return r; }
  catch (e) { rollback(); throw e; }
};

② “save를 안 불러도 저장된다”는 정확히 언제일까?

1편에서 미뤄 뒀던 질문의 답은 커밋 직전이다.

트랜잭션 시작
  │
  ├─ findFeatureFlag(id)        → SELECT, JPA가 원본 스냅샷 보관 {status: OFF}
  ├─ changeStatus("ON")         → Java 객체만 바뀜. DB는 아직 그대로
  ├─ loadFeatureFlagHistory()   → 이력 INSERT
  │
  ├─ (커밋 직전) JPA가 스냅샷과 비교: status OFF → ON 바뀜!
  │              → UPDATE feature_flag SET feature_flag_status = 'ON' ...
  │
커밋 ✅

changeStatus()를 부른 순간에는 DB가 바뀌지 않는다. 메서드가 끝날 때 한꺼번에 반영된다.


③ 롤백되는 예외, 안 되는 예외

예외 종류 롤백?
RuntimeException 계열 (unchecked) ✅ 롤백
Exception 계열 (checked) ❌ 롤백 안 하고 커밋

checked 예외는 메서드 선언에 throws를 반드시 적어야 하는 예외다. 이건 롤백되지 않는다는 게 유명한 함정이라고 한다.

우리 프로젝트는 그 걱정을 덜 수 있게 만들어져 있었다.

public class ActiveFeatureFlagException extends AppRuleException { ... }
public class AppRuleException extends AppException { ... }
public class AppException extends RuntimeException { ... }   // ← 결국 RuntimeException

도메인 규칙 예외가 전부 RuntimeException을 상속하니까, 규칙을 어겨서 던진 예외는 항상 롤백된다.


④ 조회에는 readOnly = true

@Transactional(readOnly = true)
public GetAllFeatureFlagResult execute() { ... }

“이 메서드는 읽기만 한다”는 표시다. JPA는 ②의 스냅샷 보관과 비교를 생략해서 조금 더 가벼워지고, 실수로 객체를 바꿔도 DB에 반영되지 않는다. 코드를 보면 조회(Get...)는 전부 readOnly = true, 변경(Change..., Delete..., Register...)은 그냥 @Transactional이었다.


⑤ 주의: 트랜잭션은 DB만 되돌린다

롤백은 우리 DB에 보낸 SQL만 취소한다. 그 밖에서 일어난 일은 되돌리지 못한다.

  • 택배사 API를 호출해서 송장 번호를 발급받음
  • 슬랙이나 알림톡 전송
  • 다른 서버에 요청

보낸 이메일을 취소할 수 없는 것처럼, 이런 건 롤백돼도 이미 일어난 일이다.


퀴즈 풀어 보기

문제 1: 이력 저장에서 예외가 나면 플래그 상태는?

맨 위의 ChangeStatusFeatureFlag에서 OFF → ON으로 바꾸다가 ② 이력 저장에서 예외가 났다. DB의 플래그 상태는?

@Transactional 때문에 다 롤백돼서 OFF로 남는다고 답했고, 맞았다.

다만 더 정확히 말하면, 이 경우에는 되돌릴 UPDATE가 애초에 실행되지 않았다.

① changeStatus("ON")       → Java 객체만 바뀜. DB는 아직 OFF
② 이력 저장 중 예외 💥
   → 커밋 직전 변경 감지 단계까지 가지 못함 → UPDATE 자체가 실행 안 됨
   → 이미 실행된 SQL이 있었다면 그것도 롤백

결과는 같지만, 이 원리를 알면 changeStatus() 바로 다음 줄에서 DB를 조회해 봤을 때 아직 OFF로 나오는 이유도 이해된다.

문제 2: 택배사 API가 끼어 있으면?

@Transactional
public void ship(...) {
    stock.decrease(qty);                  // ① 재고 차감 (DB)
    courierApi.issueInvoice(outbound);    // ② 택배사 API로 송장 발급 (외부)
    outbound.changeStatus(SHIP);          // ③ 출고 상태 변경 (DB) → 여기서 예외!
}

이번에도 다 롤백된다고 답했는데, 절반만 맞았다. ②가 함정이었다.

단계 결과 이유
① 재고 차감 ✅ 롤백됨 같은 트랜잭션 안의 DB 작업
② 송장 발급 ❌ 그대로 남음 택배사 서버에서 이미 일어난 일
③ 출고 상태 변경 ✅ 롤백됨 (애초에 반영 안 됨) 예외가 난 지점

택배사 API 호출은 이미 HTTP 요청을 보내고 응답까지 받은 상태다. 택배사 시스템에는 송장이 만들어져 있고, 우리 서버가 롤백해도 택배사는 그 사실을 모른다.

우리 DB:   재고 그대로, 출고 상태 그대로  → "아직 출고 안 함"
택배사:    송장 번호 발급 완료          → "곧 택배 보낼 예정"

택배 기사가 물건을 가지러 왔는데 창고에서는 보낼 게 없다고 하거나, 다시 출고 처리를 하면 송장이 두 번 발급될 수 있다. 프론트로 비유하면 결제 API는 성공했는데 그 뒤 우리 서버 저장이 실패해서 돈은 나갔는데 주문은 없는 상황이다.

그래서 실무에서는

① 외부 호출을 DB 작업 뒤로 미룬다

@Transactional
public void ship(...) {
    stock.decrease(qty);
    outbound.changeStatus(SHIP);
}   // 커밋 성공

// 커밋이 확실히 끝난 뒤에 송장 발급
courierApi.issueInvoice(outbound);

DB가 실패하면 송장 발급까지 가지 않는다. 반대로 송장 발급이 실패하면 DB는 이미 SHIP이니까 “송장 재발급” 처리를 따로 해야 한다. 어느 쪽이 실패해도 복구할 수 있는 쪽으로 순서를 정하는 게 핵심이다.

② 실패하면 취소 요청을 보낸다 (보상 처리)

try {
    ...
} catch (Exception e) {
    courierApi.cancelInvoice(invoiceNo);   // 발급했던 송장을 직접 취소
    throw e;
}

DB 롤백처럼 자동으로 되지 않으니, 되돌리는 코드를 직접 짠다.


정리

개념 한 줄 요약
트랜잭션 여러 DB 작업을 묶어서 전부 성공 또는 전부 취소
@Transactional 메서드를 “시작 → 커밋 / 예외 시 롤백”으로 감쌈 (프록시)
변경 감지 시점 커밋 직전에 바뀐 필드를 UPDATE
롤백 조건 RuntimeException이면 롤백
readOnly = true 조회 전용. 변경 감지 생략
한계 DB 밖의 일(외부 API, 알림)은 되돌리지 못함

이번 회차에서 가장 크게 남은 건 “롤백은 우리 DB 안에서만 통한다” 는 것이다. 앞으로 외부 연동 코드를 읽을 때 “이 호출이 트랜잭션 안에 있나? 실패하면 어떻게 되지?”를 먼저 보게 될 것 같다.

다음 글에서는 WMS의 핵심인 출고(Outbound) 도메인 모델을 열어서, WAIT → PICK → PACK → SHIP → DONE 상태 흐름이 코드로 어떻게 지켜지는지 볼 예정이다.