icon

안동민 개발노트

10장 : 트랜잭션

트랜잭션 실무 패턴


이론적인 ACID를 이해했다면, 실무에서 트랜잭션을 어떻게 관리하는지 알아야 합니다. 서비스 레이어의 트랜잭션 관리부터 분산 환경까지, 올바른 트랜잭션 설계가 서비스 안정성의 핵심입니다.


서비스 레이어의 트랜잭션 관리

애플리케이션에서 트랜잭션은 보통 서비스 레이어에서 관리합니다. 비즈니스 로직의 단위가 곧 트랜잭션의 단위입니다.

트랜잭션 경계
Controller → Service → Repository

                └→ 트랜잭션 경계

하나의 서비스 메서드 = 하나의 트랜잭션
  주문 생성
    1. 재고 확인
    2. 재고 차감
    3. 주문 생성
    4. 결제 처리
    → 하나라도 실패하면 전부 ROLLBACK
Spring 트랜잭션 관리 예시
@Service
public class OrderService {

    @Transactional  // 메서드 전체가 하나의 트랜잭션
    public Order createOrder(Long userId, List<OrderItemDto> items) {
        // 1. 재고 확인 및 차감
        for (OrderItemDto item : items) {
            productRepository.decreaseStock(
                item.getProductId(), item.getQuantity()
            );
        }

        // 2. 주문 생성
        Order order = orderRepository.save(new Order(userId));

        // 3. 주문 상세 생성
        for (OrderItemDto item : items) {
            orderItemRepository.save(new OrderItem(order.getId(), item));
        }

        return order;
        // 정상 종료 → 자동 COMMIT
        // 예외 발생 → 자동 ROLLBACK (RuntimeException 기본)
    }
}
트랜잭션 경계를 Controller가 아닌 Service에 두는 이유
Controller
  → HTTP 요청/응답 처리 담당
  → 하나의 요청이 여러 서비스를 호출할 수 있음
  → 트랜잭션 범위를 정확히 제어하기 어려움

Service
  → 비즈니스 로직 단위 = 트랜잭션 단위
  → 관심사 분리: Controller는 웹, Service는 비즈니스
  → 테스트 용이: 서비스 메서드 단위 테스트 가능

Repository
  → 너무 작은 단위 (단일 SQL)
  → 여러 Repository 연산을 하나의 트랜잭션으로 묶을 수 없음

트랜잭션 범위의 원칙

원칙설명이유
짧게 유지트랜잭션이 길면 락 점유 시간 증가동시성 저하, 데드락 위험
외부 호출 제외HTTP 호출, 파일 I/O는 트랜잭션 밖에서외부 응답 지연 시 락 장기 보유
읽기 전용 분리조회만 하는 트랜잭션은 readOnly=true데이터 변경 방지, 최적화 힌트
필요한 곳에만모든 메서드에 트랜잭션을 걸지 말 것불필요한 오버헤드 방지
예외 처리 주의checked 예외 시 롤백 정책 확인프레임워크별 기본 동작 상이
안티 패턴: 트랜잭션 안에서 외부 호출
[나쁜 예]
@Transactional
public void processOrder(Order order) {
    orderRepository.save(order);        // DB 업데이트
    paymentApi.charge(order.getTotal()); // 외부 HTTP 호출 (3초+)
    inventoryRepository.decrease(...);   // DB 업데이트
}
→ 외부 API 응답 대기 동안 트랜잭션 유지
→ 락 장기 점유 → 다른 트랜잭션 대기 → 성능 저하

[좋은 예]
@Transactional
public Order createOrder(Order order) {
    orderRepository.save(order);
    inventoryRepository.decrease(...);
    return order;  // 트랜잭션 종료
}

// 트랜잭션 밖에서 외부 호출
public void processOrder(Order order) {
    Order saved = createOrder(order);
    paymentApi.charge(saved.getTotal());  // 실패 시 보상 트랜잭션
}

트랜잭션 전파 (Propagation)

중첩 메서드 호출 시 트랜잭션을 어떻게 전파할지 결정합니다. Spring에서 가장 중요한 설정 중 하나입니다.

Spring 트랜잭션 전파 속성
REQUIRED (기본값)
  현재 트랜잭션이 있으면 참여, 없으면 새로 생성
  → 대부분의 경우 이것으로 충분

REQUIRES_NEW
  항상 새 트랜잭션 생성 (기존 트랜잭션 일시 중단)
  → 로그 기록, 알림 전송 등 독립적 작업

NESTED
  현재 트랜잭션 내에 중첩 트랜잭션 (SAVEPOINT 활용)
  → 중첩 트랜잭션 롤백해도 부모는 유지

SUPPORTS
  현재 트랜잭션이 있으면 참여, 없으면 트랜잭션 없이 실행

NOT_SUPPORTED
  트랜잭션 없이 실행 (기존 트랜잭션 일시 중단)

MANDATORY
  현재 트랜잭션이 반드시 있어야 함, 없으면 예외

NEVER
  트랜잭션이 있으면 예외 발생
전파 속성 활용 예시
@Service
public class OrderService {

    @Transactional
    public void createOrder(OrderDto dto) {
        Order order = orderRepository.save(dto.toEntity());

        // 주문 로그는 주문 롤백과 무관하게 항상 저장
        auditService.log(order);  // REQUIRES_NEW

        // 포인트 적립은 실패해도 주문은 유지
        pointService.addPoints(order);  // NESTED or try-catch
    }
}

@Service
public class AuditService {

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(Order order) {
        auditRepository.save(new AuditLog(order));
        // 독립 트랜잭션: 주문이 롤백되어도 로그는 커밋됨
    }
}
전파 속성 선택 가이드
                    트랜잭션 필요?
                    /          \
                  Yes           No
                  /               \
         기존 트랜잭션에          SUPPORTS 또는
         참여해도 되나?           NOT_SUPPORTED
              /     \
            Yes      No
            /          \
       REQUIRED     REQUIRES_NEW

읽기 전용 트랜잭션

조회만 수행하는 메서드에 readOnly=true를 지정하면 DBMS가 내부 최적화를 수행할 수 있습니다.

읽기 전용 트랜잭션
@Transactional(readOnly = true)
public List<Order> getOrdersByUser(Long userId) {
    return orderRepository.findByUserId(userId);
}
readOnly=true의 효과
JPA/Hibernate
  → 영속성 컨텍스트의 변경 감지(Dirty Checking) 생략
  → 스냅샷 저장 생략 → 메모리 절약
  → flush 생략 → 성능 향상

MySQL
  → InnoDB에서 읽기 전용 트랜잭션 최적화
  → Undo Log 생성 최소화

Oracle
  → READ ONLY 트랜잭션 → Undo 데이터 접근만 (일관된 읽기)
  → DML 시도 시 에러 발생

Spring Data JPA
  → @QueryHints로 Hibernate 최적화 힌트 전달
  → Master-Slave 구조에서 Slave로 라우팅 가능

분산 트랜잭션과 2PC

마이크로서비스 환경에서는 여러 서비스가 각각의 데이터베이스를 가집니다. 하나의 비즈니스 로직이 여러 DB에 걸치면 분산 트랜잭션이 필요합니다.

2PC (Two-Phase Commit)
코디네이터(Coordinator)가 참여자(Participant)들을 조율

Phase 1 — 준비(Prepare)
  코디네이터 → 참여자A: "커밋 준비됐나?"
  코디네이터 → 참여자B: "커밋 준비됐나?"
  참여자A → 코디네이터: "준비 완료 (YES)"
  참여자B → 코디네이터: "준비 완료 (YES)"

Phase 2 — 커밋(Commit)
  코디네이터 → 참여자A: "커밋해라"
  코디네이터 → 참여자B: "커밋해라"

한 참여자라도 "준비 실패 (NO)"면
  코디네이터 → 전체: "롤백해라"
2PC 문제점
1. 블로킹 문제
  → 코디네이터 장애 시 참여자들이 무기한 대기
  → Prepare 완료 후 Commit/Rollback 명령을 못 받으면 락 유지

2. 단일 장애점 (Single Point of Failure)
  → 코디네이터가 죽으면 전체 시스템 영향

3. 성능 저하
  → 모든 참여자가 준비 완료할 때까지 대기
  → 네트워크 왕복 2회 필요

4. 확장 한계
  → 참여자 수 증가 시 지연 시간 선형 증가

개선: 3PC (Three-Phase Commit)
  → Pre-Commit 단계 추가로 블로킹 문제 완화
  → 그러나 여전히 복잡하고 실무에서 잘 사용하지 않음

Saga 패턴

Saga 패턴은 분산 트랜잭션의 대안입니다. 각 서비스가 로컬 트랜잭션을 실행하고, 실패 시 보상 트랜잭션(Compensating Transaction)을 실행합니다.

Saga 패턴 — 주문 처리
정상 흐름
  주문 서비스: 주문 생성         →
  결제 서비스: 결제 처리         →
  재고 서비스: 재고 차감         →
  배송 서비스: 배송 접수

실패 시 보상 (역순)
  배송 서비스: 배송 접수 실패!
  재고 서비스: 재고 복구 (보상)  ←
  결제 서비스: 결제 취소 (보상)  ←
  주문 서비스: 주문 취소 (보상)  ←

Choreography vs Orchestration

Choreography 방식
각 서비스가 이벤트를 발행하고 구독하여 자율적으로 동작

주문서비스 ──주문생성 이벤트──→ 결제서비스
결제서비스 ──결제완료 이벤트──→ 재고서비스
재고서비스 ──차감완료 이벤트──→ 배송서비스

장점: 느슨한 결합, 서비스 독립성
단점: 흐름 추적 어려움, 디버깅 복잡
적합: 단순한 워크플로우 (3~4단계)
Orchestration 방식
중앙 오케스트레이터가 전체 흐름을 제어

         ┌─────────────────┐
         │   Orchestrator  │
         │  (Order Saga)   │
         └──┬──┬──┬──┬─────┘
            │  │  │  │
         ┌──┘  │  │  └──┐
         ▼     ▼  ▼     ▼
       주문   결제 재고  배송

장점: 흐름 명확, 디버깅 용이
단점: 오케스트레이터 = 단일 장애점
적합: 복잡한 워크플로우 (5단계 이상)
비교2PCSaga
일관성강한 일관성 (ACID)최종적 일관성 (Eventual)
성능락 대기로 느림비동기로 빠름
복잡도코디네이터 필요보상 로직 필요
격리성완전한 격리중간 상태 노출 가능
적합 환경단일 DB 또는 소수 DB마이크로서비스

Outbox 패턴

Saga 패턴에서 메시지 발행과 DB 업데이트의 원자성을 보장하는 패턴입니다.

Outbox 패턴
문제
  @Transactional
  void createOrder(order) {
    orderRepo.save(order);         // 1. DB 저장 성공
    messageQueue.send(orderEvent); // 2. 메시지 발행 실패!
  }
  → DB에는 저장, 이벤트는 미발행 → 불일치

해결: Outbox 테이블
  @Transactional
  void createOrder(order) {
    orderRepo.save(order);                    // 1. 주문 저장
    outboxRepo.save(new OutboxEvent(order));  // 2. 아웃박스 저장
  }
  // 별도 프로세스가 outbox 테이블을 폴링하여 메시지 발행
  // 발행 완료 후 outbox 레코드 삭제

┌────────────┐    ┌──────────────┐    ┌────────────┐
│ Orders     │    │ Outbox       │    │ Message    │
│ 테이블     │←───│ 테이블       │───→│ Broker     │
│            │같은│              │폴링│            │
│            │트랜│              │    │            │
└────────────┘잭션└──────────────┘    └────────────┘

멱등성 (Idempotency)

분산 환경에서 메시지가 중복 전달될 수 있으므로, 같은 요청을 여러 번 처리해도 결과가 같아야 합니다.

멱등성 보장 패턴
1. 고유 키 활용
  INSERT INTO payments (payment_id, amount)
  VALUES ('pay-uuid-001', 50000)
  ON DUPLICATE KEY UPDATE amount = amount;
  → payment_id가 같으면 무시

2. 상태 확인 후 처리
  SELECT status FROM orders WHERE id = 'order-001';
  → 이미 '결제완료'면 스킵

3. 멱등성 키 (Idempotency Key)
  HTTP 헤더: Idempotency-Key: uuid-12345
  → 서버가 이 키로 중복 요청 감지
멱등한 연산 vs 비멱등한 연산
멱등한 연산 (안전)
  * SET balance = 50000     → 몇 번 실행해도 50000
  * DELETE WHERE id = 1     → 이미 삭제되어도 에러 없음
  * PUT /users/1 {name: "A"} → 항상 같은 결과

비멱등한 연산 (위험)
  * UPDATE balance = balance + 1000  → 실행할 때마다 증가
  * INSERT INTO orders (...)          → 매번 새 행 생성
  * POST /orders {item: "A"}          → 매번 새 주문 생성

→ 비멱등 연산을 멱등하게 만드는 것이 설계의 핵심

낙관적 vs 비관적 동시성 제어

트랜잭션이 동시에 같은 데이터를 수정할 때의 충돌 처리 전략입니다.

비관적 잠금 (Pessimistic Locking)
SELECT * FROM products WHERE id = 1 FOR UPDATE;
-- 다른 트랜잭션은 이 행을 수정/읽기 불가 (잠금 해제까지)
UPDATE products SET stock = stock - 1 WHERE id = 1;
COMMIT;

적합: 충돌이 빈번한 경우
  → 재고 차감 (동시 주문이 많은 상품)
  → 계좌 이체 (잔액 변경)
단점: 동시성 저하, 데드락 위험
낙관적 잠금 (Optimistic Locking)
1. 읽기: SELECT stock, version FROM products WHERE id = 1;
   → stock=10, version=5

2. 수정: UPDATE products
         SET stock = 9, version = 6
         WHERE id = 1 AND version = 5;
   → 영향 받은 행 = 1이면 성공
   → 영향 받은 행 = 0이면 충돌 (다른 트랜잭션이 먼저 수정)
   → 충돌 시 재시도 또는 에러 반환

적합: 충돌이 드문 경우
  → 게시글 수정 (동시 편집이 드문 경우)
  → 설정 변경
장점: 락 없이 동시성 유지
JPA 낙관적 잠금
@Entity
public class Product {
    @Id
    private Long id;
    private int stock;

    @Version  // 자동으로 version 관리
    private int version;
}

// 충돌 시 OptimisticLockException 발생
// → 재조회 후 재시도
전략방식충돌 처리적합 상황
비관적SELECT FOR UPDATE사전 방지충돌이 빈번한 경우
낙관적버전 컬럼 비교사후 감지충돌이 드문 경우

배치 처리의 트랜잭션

대량 데이터를 처리하는 배치 작업은 적절한 단위로 커밋해야 합니다.

배치 트랜잭션 패턴
[나쁜 예] 전체를 하나의 트랜잭션으로
  BEGIN;
  1,000,000건 INSERT/UPDATE
  COMMIT;
  → Undo/Redo 로그 폭증, 메모리 부족
  → 중간 실패 시 전체 ROLLBACK (한참 걸림)

[좋은 예] N건 단위로 분할 커밋
  FOR each batch (1000건)
    BEGIN;
    1,000건 처리
    COMMIT;
  END FOR
  → Undo/Redo 적정 수준 유지
  → 실패 시 해당 배치만 재처리

배치 크기 가이드
  INSERT: 1,000 ~ 10,000건 단위
  UPDATE: 500 ~ 5,000건 단위
  DELETE: 1,000 ~ 5,000건 단위
  → DBMS, 데이터 크기, 인덱스 수에 따라 조정
Spring Batch 트랜잭션
ItemReader → ItemProcessor → ItemWriter

                         chunk 단위 커밋

chunk-size=1000
  1,000건 읽기 → 처리 → 쓰기 → COMMIT
  1,000건 읽기 → 처리 → 쓰기 → COMMIT
  ...
  → 실패 시 해당 chunk만 ROLLBACK 후 재시도

트랜잭션 실무 체크리스트

트랜잭션 설계 체크리스트
□ 트랜잭션 경계가 서비스 레이어에 있는가?
□ 트랜잭션 내에 외부 API 호출이 없는가?
□ 읽기 전용 메서드에 readOnly=true를 설정했는가?
□ 트랜잭션 전파 속성이 적절한가?
□ 장기 트랜잭션 (30초 이상) 가능성은 없는가?
□ 배치 처리 시 적절한 단위로 커밋하고 있는가?
□ 예외 처리 시 롤백 정책을 확인했는가?
□ 분산 환경에서 보상 트랜잭션이 정의되어 있는가?
□ 멱등성이 보장되는가?
□ 데드락 방지를 위해 테이블 접근 순서를 통일했는가?

정리

트랜잭션 실무에서 가장 중요한 것은 적절한 범위 설정입니다. 트랜잭션을 너무 넓게 잡으면 동시성이 떨어지고, 너무 좁게 잡으면 데이터 정합성이 깨집니다.

단일 DB 환경에서는 서비스 레이어에서 @Transactional로 관리하고, 전파 속성으로 중첩 호출을 제어합니다. 분산 환경에서는 2PC 대신 Saga 패턴이 표준이며, Outbox 패턴으로 메시지 원자성을, 멱등성으로 중복 처리를 보장합니다.

낙관적/비관적 잠금은 충돌 빈도에 따라 선택합니다. 대부분의 웹 애플리케이션은 읽기가 쓰기보다 압도적으로 많으므로 낙관적 잠금이 기본 선택이고, 금융이나 재고 관리처럼 충돌이 빈번한 경우에 비관적 잠금을 사용합니다.

시험에서는 2PC의 두 단계(Prepare와 Commit)와 블로킹 문제, Saga 패턴의 보상 트랜잭션 개념, Choreography와 Orchestration의 차이, 낙관적 잠금과 비관적 잠금의 구분이 빈출됩니다.

다음 장에서는 동시성 문제의 구체적인 유형과 해결책인 동시성 제어를 다루겠습니다.

목차