자바 백엔드 입문 43편. Phase 6 Data Access 마무리. @Transactional 한 줄이 어떻게 메서드를 트랜잭션으로 감싸는지, 전파·격리수준·롤백 룰을 송금 영수증 비유로 풀어쓴 학습 노트.
이 글은 자바 백엔드 입문 시리즈 59편 중 43편이에요. Phase 6 Data Access의 마지막 글입니다. 42편에서 JdbcTemplate으로 SQL을 다뤘다면, 이번 43편은 그 SQL들을 "한 단위 작업으로 묶어 모두 성공하거나 모두 롤백하는" 트랜잭션 처리 어노테이션의 원리.
@Transactional이 어렵게 들리는 이유
@Transactional 한 줄만 박으면 트랜잭션이 시작·커밋·롤백 다 자동. "이게 어떻게 그렇게 마법처럼 동작하지?" 가 안 잡혀요. 또 Propagation·Isolation 같은 옵션이 7가지·4가지로 많아서 "기본값 두면 되나" 가 혼란.
이 글에서는 송금 영수증 비유로 풀어요. 트랜잭션 = "여러 계좌 이체를 한 영수증으로 묶은 것". 모두 성공이면 영수증 발급(커밋), 한 단계라도 실패면 영수증 폐기 + 이전 상태로 되돌리기(롤백). 끝까지 따라오시면 트랜잭션의 핵심 원리 + 자주 만나는 함정 5개가 한 그림에 들어와요.
트랜잭션 — ACID 4가지 보장
데이터베이스 트랜잭션의 표준 정의는 ACID.
| 글자 | 뜻 | 한 줄 |
|---|---|---|
| A | Atomicity (원자성) | 모두 성공 또는 모두 실패 |
| C | Consistency (일관성) | 항상 유효한 상태 유지 |
| I | Isolation (격리성) | 동시 실행 트랜잭션 간 영향 차단 |
| D | Durability (지속성) | 커밋된 결과는 영구 보존 |
비유 — A는 "영수증에 박힌 모든 항목이 다 처리되거나 다 취소", C는 "잔고가 음수 안 되도록 일관성 유지", I는 "내가 송금하는 도중에 다른 손님이 같은 계좌를 못 본다", D는 "커밋된 송금은 영원히 기록".
@Transactional 한 줄이 이 ACID를 자동 보장해줘요.
트랜잭션 없는 코드 — 무엇이 깨지나
송금 시나리오 — "A 계좌에서 1만원 출금 + B 계좌에 1만원 입금". 트랜잭션 없으면.
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepo;
public void transfer(Long from, Long to, int amount) {
accountRepo.withdraw(from, amount); // 1단계 — 출금 완료
// ❌ 여기서 NullPointerException 터지면?
accountRepo.deposit(to, amount); // 2단계 — 입금 X
}
}
1단계 출금만 됐는데 2단계 입금 전에 예외 터지면 — A 계좌에서만 1만원 사라지고 B 계좌에 안 들어감. 데이터 일관성 폭발.
해결책이 @Transactional.
@Service
@RequiredArgsConstructor
public class TransferService {
private final AccountRepository accountRepo;
@Transactional // ← 한 줄
public void transfer(Long from, Long to, int amount) {
accountRepo.withdraw(from, amount);
accountRepo.deposit(to, amount);
}
}
@Transactional 박힌 메서드는 — 전체가 한 트랜잭션. 메서드 끝까지 무사히 도달하면 자동 커밋, 도중에 예외 터지면 자동 롤백. "부분 출금" 상태가 절대 안 만들어져요.
@Transactional이 어떻게 동작하나 — AOP 프록시
23편 AOP 에서 다룬 그림 그대로. Spring이 @Transactional 박힌 클래스 대신 트랜잭션 처리 프록시 객체를 자동 생성해 컨테이너에 등록.
[클라이언트] → [TransferService 프록시] → [실제 TransferService]
↑
여기서 자동 처리:
1. 메서드 호출 직전 → 트랜잭션 시작
2. 실제 메서드 실행
3. 정상 종료 → 커밋
4. 예외 발생 → 롤백
우리가 호출하는 게 사실은 프록시. 프록시가 "트랜잭션 시작·커밋·롤백" 을 자동 처리한 뒤 실제 메서드를 호출해줘요.
Propagation — 트랜잭션 전파 7가지
@Transactional 메서드에서 다른 @Transactional 메서드를 호출하면 — 두 트랜잭션이 어떻게 결합할지 정해야 해요. Propagation 옵션 7가지.
| 옵션 | 동작 | 사용 |
|---|---|---|
| REQUIRED (기본값) | 기존 트랜잭션이 있으면 참여, 없으면 새로 시작 | 99% (기본) |
| REQUIRES_NEW | 기존 트랜잭션 중단 + 새 트랜잭션 시작 | 로그·감사 (실패해도 별개) |
| NESTED | 기존 트랜잭션 안의 savepoint로 중첩 | 드물게 |
| SUPPORTS | 기존 트랜잭션 있으면 참여, 없으면 트랜잭션 X | 드물게 |
| NOT_SUPPORTED | 기존 트랜잭션 일시 중단 | 매우 드물게 |
| MANDATORY | 기존 트랜잭션 필수, 없으면 예외 | 드물게 |
| NEVER | 기존 트랜잭션 있으면 예외 | 거의 안 씀 |
REQUIRED 가 기본값이라 90% 시나리오 처리. "이미 트랜잭션 안에 있으면 그대로 참여, 없으면 새로 시작". 가장 자연스러운 동작.
가끔 만나는 두 번째 옵션 = REQUIRES_NEW. "이 메서드는 다른 트랜잭션과 별개로 처리해야 함" 시나리오에. 예 — 감사 로그 저장.
@Service
public class OrderService {
@Transactional
public Order placeOrder(...) {
Order order = save(...);
try {
auditLogService.log(order); // ← 이 안이 REQUIRES_NEW
} catch (Exception e) {
// 감사 로그 실패해도 주문은 계속
}
return order;
}
}
@Service
public class AuditLogService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(Order order) {
// 별도 트랜잭션 — 실패해도 주문에 영향 X
}
}
Isolation — 격리수준 4단계
여러 트랜잭션이 동시 실행될 때 "서로의 변경사항이 어디까지 보이나" 가 격리수준.
| 단계 | 막는 문제 |
|---|---|
| READ_UNCOMMITTED | 가장 약한 격리. Dirty Read 가능 |
| READ_COMMITTED | Dirty Read 차단. 대부분 DB 기본값 |
| REPEATABLE_READ | Non-repeatable Read 차단. MySQL 기본 |
| SERIALIZABLE | 가장 강한 격리. Phantom Read 차단. 성능 떨어짐 |
실무에서는 DEFAULT (DB 기본값) 유지가 99%. 보통 PostgreSQL은 READ_COMMITTED, MySQL은 REPEATABLE_READ가 기본. 격리수준을 직접 만지는 건 매우 드문 시나리오.
롤백 룰 — RuntimeException만 자동 롤백
기본 동작 — @Transactional 은 RuntimeException(Unchecked)만 자동 롤백. Checked Exception(Exception 상속, IOException 등)은 롤백 X.
@Transactional
public void doSomething() throws IOException {
repository.save(...);
if (someCondition) {
throw new IOException(".."); // 롤백 안 됨!
}
}
이건 함정이에요. 모든 예외를 롤백시키려면 옵션 명시:
@Transactional(rollbackFor = Exception.class) // 모든 예외 롤백
public void doSomething() throws IOException { ... }
@Transactional(noRollbackFor = NoStockException.class) // 특정 예외만 롤백 X
public void doSomething() { ... }
33편 에서 "비즈니스 예외는 RuntimeException 상속이 표준" 이라고 한 이유 중 하나가 이 롤백 룰. RuntimeException으로 만들면 자동 롤백 받아요.
@Transactional 자주 만나는 함정 3가지
1. 자기 호출 함정
23편 AOP 에서 다룬 그림 그대로 — 같은 클래스 안 메서드 자기 호출 시 트랜잭션이 안 열려요.
@Service
public class OrderService {
public void create(...) {
save(); // ❌ 같은 클래스 메서드 호출 — 프록시 안 거침
}
@Transactional // 트랜잭션 안 열림!
public void save() { ... }
}
해결 — 다른 클래스로 분리하거나, AopContext.currentProxy() 사용(비권장).
2. private 메서드에 박기
@Service
public class OrderService {
public void create(...) { save(); }
@Transactional
private void save() { ... } // ❌ private은 프록시 안 됨
}
@Transactional 은 public 메서드에만 적용. private 메서드에 박으면 동작 안 함 (Spring 4.0+ 룰).
3. 예외 처리로 묻기
@Transactional
public void doSomething() {
try {
repository.save(...);
someOperation(); // 여기서 예외 터짐
} catch (Exception e) {
log.error("실패", e);
// ❌ 예외를 흡수 — Spring이 모르고 커밋
}
}
try-catch로 예외를 흡수하면 — Spring이 "정상 종료" 로 판단해서 커밋. 원하는 게 "실패해도 롤백 안 함" 이라면 OK지만, 보통은 함정. 진짜 롤백시키려면 TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() 명시.
조회만 하는 메서드는 @Transactional(readOnly = true) 박아두세요. JPA/Hibernate에 "쓰기 안 함" 힌트를 주어 더티 체크 비활성화 등 최적화 가능. 작은 차이지만 회사 시스템에선 누적 효과 큼.
@Transactional 사용 위치 — 서비스 레이어가 표준
@Transactional 을 어디 박을까? 거의 항상 서비스 레이어 메서드.
@Repository
public class OrderRepository {
// 트랜잭션 X — 단순 DB 호출
public Order save(Order order) { ... }
}
@Service
public class OrderService {
@Transactional // ← 여기 박기
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(...);
paymentService.charge(order); // 트랜잭션 안에서 결제도 처리
notificationService.send(order); // 알림도
return order;
}
}
@RestController
public class OrderController {
// 트랜잭션 X
@PostMapping
public Order create(@RequestBody @Valid OrderRequest req) {
return orderService.placeOrder(req);
}
}
서비스 레이어 = 비즈니스 단위 작업의 경계. 컨트롤러는 HTTP 처리만, Repository는 DB 호출만, 서비스가 "여러 작업을 한 단위 트랜잭션으로 묶기" 담당.
한 줄 정리 — @Transactional = 메서드를 트랜잭션으로 감싸는 AOP. 기본 Propagation REQUIRED 99% 사용. RuntimeException만 자동 롤백. 자기 호출·private·예외 흡수 3가지 함정 주의.
시험 직전 한 번 더 — @Transactional 입문자가 매번 헷갈리는 것
@Transactional= 메서드를 트랜잭션으로 감싸는 AOP 어노테이션- 동작 원리 = Spring AOP 프록시
- ACID = Atomicity·Consistency·Isolation·Durability
- 메서드 시작 → 트랜잭션 시작, 정상 종료 → 커밋, 예외 → 롤백
- Propagation 7가지 = REQUIRED(기본)·REQUIRES_NEW·NESTED·SUPPORTS·NOT_SUPPORTED·MANDATORY·NEVER
- 99% 시나리오 = REQUIRED
- REQUIRES_NEW = 감사 로그·이메일 같은 "독립 트랜잭션"
- Isolation 4단계 = READ_UNCOMMITTED·READ_COMMITTED·REPEATABLE_READ·SERIALIZABLE
- 99% 시나리오 = DB 기본값 유지 (DEFAULT)
- 롤백 룰 = RuntimeException만 자동, Checked Exception은 롤백 X
- 모든 예외 롤백 =
@Transactional(rollbackFor = Exception.class) - 비즈니스 예외는 RuntimeException 상속 표준
- 자기 호출 함정 = 같은 클래스 안 메서드 호출 시 프록시 안 거침
- 해결 = 다른 클래스로 분리
- private 메서드 함정 =
@Transactional안 먹힘 (Spring 4.0+) - 예외 흡수 함정 = try-catch로 예외 묻으면 커밋됨
- 명시적 롤백 =
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly() readOnly = true= 조회 메서드 최적화 힌트- 사용 위치 = 서비스 레이어 표준
- 컨트롤러·Repository에
@Transactional박지 X - 트랜잭션 매니저 = Spring Boot가 자동 등록 (
PlatformTransactionManager) - JPA·JDBC·JTA 다 같은
@Transactional어노테이션으로 처리
시리즈 다른 편 (앞뒤 글 모음)
이전 글:
- 38편 — Spring ApplicationEvent @EventListener
- 39편 — Spring @Async CompletableFuture 비동기
- 40편 — Spring WebClient RestClient HTTP 클라이언트
- 41편 — JDBC와 DataSource
- 42편 — JdbcTemplate으로 SQL 다루기
다음 글: