자바 백엔드 입문 43편 — @Transactional의 원리

2026-05-16자바 백엔드 입문

자바 백엔드 입문 43편. Phase 6 Data Access 마무리. @Transactional 한 줄이 어떻게 메서드를 트랜잭션으로 감싸는지, 전파·격리수준·롤백 룰을 송금 영수증 비유로 풀어쓴 학습 노트.

📚 자바 백엔드 입문 · 43편 — @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은 프록시 안 됨
}

@Transactionalpublic 메서드에만 적용. 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() 명시.

🎯 readOnly 옵션 — 성능 hint

조회만 하는 메서드는 @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 어노테이션으로 처리

시리즈 다른 편 (앞뒤 글 모음)

이전 글:

다음 글:

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!