[백엔드 입문 #5] @Transactional의 동작과 롤백 범위
지난 글에서 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 상태 흐름이 코드로 어떻게 지켜지는지 볼 예정이다.