Guide to learning the Spring Framework Transaction

🗓️

스프링 트랜잭션 가이드

스프링의 @Transactional 애노테이션 하나면 트랜잭션을 쉽게 다룰 수 있다고 생각했는데, 실무에서 쓰다 보니 생각보다 고려할 게 많았다.
프록시가 어떻게 동작하는지, 전파 규칙은 어떻게 설정해야 하는지, JPA와는 어떻게 맞물리는지 등등…
그래서 트랜잭션의 기본 개념부터 스프링이 내부적으로 어떻게 처리하는지까지, 실무에서 마주쳤던 내용들을 정리해봤다.


스프링 트랜잭션의 본질

개념과 목적

트랜잭션은 데이터베이스에서 데이터의 일관성과 원자성(Atomicity) 을 보장하기 위한 최소 작업 단위다. 스프링은 이 트랜잭션 개념을 추상화해서, 개발자가 데이터베이스 종류나 기술(JPA, JDBC, MyBatis 등)에 관계없이 일관된 방식으로 트랜잭션을 다룰 수 있게 해준다.

스프링의 @Transactional은 데이터의 무결성(Integrity)와 정합성(Consistency) 를 보장하기 위한 기술이다. Propagation 방식, Isolation 수준, 롤백 정책, 트랜잭션 타임아웃, 읽기 전용 트랜잭션 여부 같은 트랜잭션 속성을 지원해 트랜잭션 커스터마이징이 가능하다. AOP 프록시를 통해 작동한다. 트랜잭션이 적용된 메서드가 호출되면 프록시가 가로채서 begin → execute → commit/rollback 과정을 자동으로 처리해준다.

@Transactional
public void serviceLogic() {
  // DB 연산
}
  • 진입 시: begin() 호출 → 커넥션 획득 및 트랜잭션 시작
  • 정상 종료 시: commit()
  • 예외 발생 시: rollback()

트레이드오프

이 접근 방식의 장점은 일관성 있고 선언적인 트랜잭션 제어라는 점이다. 하지만 프록시 기반이라는 제약도 함께 따라온다.
즉, “AOP 프록시가 가로채지 못하는 호출(자기 자신 내부 호출)”은 트랜잭션이 전혀 적용되지 않는다는 한계가 있다.


적용 경계와 함정

핵심 아이디어

스프링 트랜잭션의 적용 여부는 “프록시가 호출을 가로챌 수 있는가”로 결정된다.
그래서 같은 클래스 내부에서 메서드를 호출하면 프록시를 거치지 않아 트랜잭션이 적용되지 않는다.

케이스작동 여부설명
외부에서 public 메서드 호출프록시가 호출을 감지
같은 클래스 내부 메서드 호출self-invocation으로 프록시 우회
private/protected 메서드프록시 적용 불가
AspectJ 모드 사용바이트코드 위빙 기반 적용

해결책

  1. 메서드를 별도 빈으로 분리 (가장 명확한 방법)
  2. 같은 빈의 자기 자신 프록시를 주입(self-injection)
  3. 프록시 대신 AspectJ 모드로 전환 (단, 설정이 복잡해짐)

트레이드오프

프록시 기반 방식은 단순하지만 “적용 경계가 호출 방식에 의존”한다는 특징이 있다.
AspectJ는 정확하지만, 빌드 타임 위빙과 디버깅 복잡도라는 비용을 치러야 한다.


전파(Propagation) 완전 정리

개념과 목적

트랜잭션 전파는 이미 존재하는 트랜잭션이 있을 때, 새로운 트랜잭션을 어떻게 처리할 것인가를 결정한다.
즉, “기존 트랜잭션에 참여할지”, “새로 시작할지”, “아예 트랜잭션 없이 실행할지”를 정의하는 것이다.

전파의미주요 사용처트레이드오프
REQUIRED있으면 참여, 없으면 새로 시작대부분의 서비스 로직단일 커밋이지만 내부 실패가 전체에 영향
REQUIRES_NEW항상 새 트랜잭션 시작. 기존 트랜잭션 일시 중단로그/감사/아웃박스분리 안정성은 높지만 성능/락 경합 증가
SUPPORTS있으면 참여, 없으면 비트랜잭션 없이 실행조회 전용일관성은 낮지만 성능 향상
MANDATORY반드시 기존 트랜잭션 필요. 없으면 예외서브 모듈강제성은 높지만 유연성 감소
NOT_SUPPORTED트랜잭션 없이 실행. 기존 트랜잭션 일시 중단외부 API, 대용량 조회정합성은 낮지만 부하 감소
NEVER트랜잭션 없이 실행. 트랜잭션 있으면 예외정책 강제 상황실무에서 거의 사용 안 함
NESTEDsavepoint 기반 부분 롤백.JDBC 수준 처리드라이버 지원 필요, JPA와 제한적 호환

실무에서는 REQUIRED(기본)와 REQUIRES_NEW가 90% 이상을 차지한다.


격리 수준(Isolation Level)과 MySQL 현실

목적

트랜잭션의 격리 수준은 동시성(concurrency)정합성(consistency)의 균형을 조정한다.
즉, “얼마나 다른 트랜잭션의 변경을 볼 수 있게 허용할 것인가”를 결정하는 것이다.

수준의미주요 문제 방지성능
READ UNCOMMITTED커밋 안 된 데이터도 읽음없음최고
READ COMMITTED커밋된 데이터만 읽음Dirty Read높음
REPEATABLE READ (MySQL 기본)동일 쿼리 결과 반복Non-repeatable Read중간
SERIALIZABLE완전 직렬화Phantom Read낮음

트레이드오프

  • 낮은 수준 → 동시성은 높지만 정합성 낮음
  • 높은 수준 → 정합성은 높지만 락 경합 증가

일반 웹 애플리케이션에서는 대부분 REPEATABLE READ 또는 READ COMMITTED가 현실적인 균형점이다.


readOnly와 flush 동작

목적

@Transactional(readOnly = true)는 쓰기가 발생하지 않는 트랜잭션에서 Hibernate/JDBC 최적화 힌트를 제공한다.
이는 DB의 실행 계획 최적화와 flush 최소화를 목표로 한다.

동작

  • Hibernate: flush 모드를 DELAY로 전환해 SQL flush를 최소화
  • JDBC: 드라이버에 read-only 힌트를 줄 수 있음(무시하는 DB도 있음)

트레이드오프

  • 이 옵션은 성능 최적화 힌트이지 쓰기 방지 장치가 아니다.
  • 쓰기를 시도하면 대부분의 DB가 허용한다.
  • 강제적인 쓰기 금지가 필요하다면 DB 권한이나 CQRS 분리로 통제해야 한다.

예외와 롤백 규칙

개념

스프링은 예외 유형에 따라 자동으로 롤백 여부를 판단한다.

  • RuntimeException, Error: 자동 롤백
  • Checked Exception: 기본 커밋
@Transactional(rollbackFor = Exception.class)
public void process() throws Exception { ... }

트레이드오프

자동 롤백 정책은 직관적이지만, “비즈니스 예외가 체크 예외로 정의된 경우” 혼란을 줄 수 있다.
명시적으로 rollbackFor를 사용하는 걸 권장한다. 또한 예외를 catch해서 삼키면 롤백이 안 되기 때문에, 명시적으로 setRollbackOnly() 호출이 필요하다.


JPA 영속성 컨텍스트 심층 분석

목적

영속성 컨텍스트는 트랜잭션 안에서 엔티티 상태를 추적하고 일관성을 보장하기 위한 JPA의 핵심 메커니즘이다.
SQL을 직접 다루지 않고 객체 단위로 데이터 변경을 처리할 수 있다.

주요 특징

  1. 1차 캐시 — 동일 PK 엔티티는 한 트랜잭션 내에서 동일 객체
  2. 더티체킹 — 엔티티 변경 시 자동으로 update SQL 생성
  3. 쓰기 지연(write-behind) — flush 시점에 모아서 SQL 실행
  4. flush 시점
    • 커밋 시 자동
    • JPQL 실행 전
    • 명시적 em.flush()

FlushMode

@PersistenceContext EntityManager em;
// 기본: AUTO (쿼리 전에 자동 flush)
em.setFlushMode(FlushModeType.COMMIT); // 커밋 때만 flush → 읽기 많은 서비스에서 불필요한 flush 감소

COMMIT 모드는 읽기 많은 서비스에서 성능 최적화가 가능하지만, flush가 지연되어 중간 읽기 쿼리에서 변경이 반영되지 않는 트레이드오프가 있다.

트레이드오프

  • 영속성 컨텍스트는 데이터 정합성을 유지하지만, 대량 데이터 처리 시 메모리 부담과 flush 타이밍 제어가 어렵다.
  • 따라서 주기적인 flush/clear 전략이 필수다.

컬렉션/연관관계

  • 기본은 LAZY. N+1 문제는 페치 조인/배치 사이즈/DTO 투사로 해결할 수 있다.
-- N+1 회피: 페치조인
select o from Order o join fetch o.member where o.id in :ids
  • cascade = CascadeType.ALL과 orphanRemoval = true는 집합 경계를 명확히 알고 사용해야 한다.
    실수로 컬렉션을 교체하면 일괄 삭제/삽입이 발생할 수 있다.

OptimisticLock, PessimisticLock

  • 낙관적 락: @Version 필드 추가 → 충돌 시 OptimisticLockException 발생
  • 비관적 락:
em.find(Order.class, id, LockModeType.PESSIMISTIC_WRITE); // SELECT ... FOR UPDATE
  • 트랜잭션 경계 안에서만 의미가 있다. 경계 밖에서의 지연로딩/락은 불가능하다.

아웃박스 패턴 (DB와 메시징의 원자성)

개념

트랜잭션과 메시징 시스템(Kafka, MQ 등)은 서로 다른 리소스이므로 분산 트랜잭션(2PC) 없이는 원자적 커밋이 불가능하다.
이를 해결하기 위한 전략이 Outbox Pattern이다.

원리

  1. 비즈니스 데이터와 outbox 이벤트를 같은 트랜잭션으로 저장
  2. 별도의 퍼블리셔가 outbox 테이블을 읽어서 메시지 발행
  3. 성공 시 status 변경, 실패 시 재시도 또는 DLQ로 이동

트레이드오프

  • 장점: 완전한 원자성 보장, 외부 메시지 누락 방지
  • 단점: 지연 전송(latency), 추가 테이블 관리 필요
  • 대안: Debezium CDC 기반 이벤트 전송 (binlog 모니터링)

스키마 예시

CREATE TABLE outbox_event (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  aggregate_type VARCHAR(100) NOT NULL,
  aggregate_id   VARCHAR(64)  NOT NULL,
  event_type     VARCHAR(100) NOT NULL,
  payload        JSON         NOT NULL,
  headers        JSON         NULL,
  created_at     DATETIME(6)  NOT NULL,
  available_at   DATETIME(6)  NOT NULL,
  status         ENUM('PENDING','SENT','FAILED') NOT NULL DEFAULT 'PENDING',
  trace_id       VARCHAR(64),
  UNIQUE KEY uk_dedupe (aggregate_type, aggregate_id, event_type, created_at)
);
CREATE INDEX ix_outbox_available ON outbox_event(status, available_at, id);

쓰기 측(Service) – 도메인과 outbox를 하나의 트랜잭션으로

@Service
@RequiredArgsConstructor
public class PaymentService {
  private final PaymentRepository paymentRepo;
  private final OutboxRepository outboxRepo;

  @Transactional
  public void approve(PaymentCommand cmd) {
    Payment p = paymentRepo.save(Payment.create(cmd));
    outboxRepo.save(OutboxEvent.of(
      "Payment", String.valueOf(p.getId()), "PaymentApproved",
      jsonOf(p), now(), "PENDING", traceId()
    ));
    // 여기까지 하나의 REQUIRED 트랜잭션 → DB에 원자 커밋
  }
}

퍼블리셔(폴링 방식) – SKIP LOCKED로 경쟁자 회피

@Component
@RequiredArgsConstructor
public class OutboxPublisher {
  private final JdbcTemplate jdbc;
  private final MessageBus bus;

  // 짧은 트랜잭션, 작은 배치
  @Transactional
  @Scheduled(fixedDelay = 200) // 필요에 맞게 조정
  public void publishBatch() {
    List<OutboxEvent> batch = jdbc.query("""
      SELECT * FROM outbox_event
       WHERE status='PENDING' AND available_at <= NOW()
       ORDER BY id
       LIMIT 100
       FOR UPDATE SKIP LOCKED
    """, mapper);

    for (OutboxEvent e : batch) {
      try {
        bus.publish(e.getEventType(), e.getPayload()); // 외부 I/O: 트랜잭션 밖으로
        jdbc.update("UPDATE outbox_event SET status='SENT' WHERE id=?", e.getId());
      } catch (Exception ex) {
        jdbc.update("UPDATE outbox_event SET status='FAILED' WHERE id=?", e.getId());
      }
    }
  }
}
  • FOR UPDATE SKIP LOCKED멀티 인스턴스 동시 퍼블리셔에서도 레코드 중복 없이 처리 가능
  • 퍼블리시 I/O는 DB 트랜잭션 밖에서 수행(트랜잭션은 레코드 잠금 범위만 짧게)

CDC(Debezium) 대안

  • 폴링 대신 binlog 기반 CDC로 Outbox 테이블 변경을 스트림으로 내보내면 지연과 부하를 줄일 수 있음
  • 여전히 “쓰기 트랜잭션에 outbox 레코드 포함” 원칙은 동일

실패/중복/순서 전략

  • 멱등성: 소비자 쪽에서 eventId 또는 복합 키로 중복 제거
  • 재시도: FAILED → 지수 백오프 재시도, 일정 횟수 초과 시 DLQ 테이블로 분리
  • 순서: 파티션 키를 aggregate_id로 지정(카프카)하여 동일 집합 내 순서 보장
  • 지연 발행: available_at으로 “나중에 발행” 스케줄링 가능

REQUIRES_NEW와의 관계

  • 아웃박스 레코드는 본 트랜잭션과 함께 커밋(REQUIRED)
  • 퍼블리셔는 별도 트랜잭션에서 처리(스케줄러 or 리스너)
  • “본 트랜잭션이 롤백돼도 로그는 남겨야 한다” 같은 경우는 로그성만 REQUIRES_NEW 사용 이벤트 자체는 outbox에 함께 넣는 게 정석

afterCommit 훅(경량)

  • 진짜 메시징이 아니라 내부 작업이면 TransactionSynchronizationManager.registerSynchronization(...)
    커밋 후 실행(AfterCommit)을 걸 수 있다. 단, 외부 시스템과의 내구성은 아웃박스가 더 안전하다.

실전 패턴 및 코드 예시

기본 서비스 트랜잭션

  • 도메인 로직은 REQUIRED
  • 외부 호출(로그 등)은 REQUIRES_NEW
@Transactional
public void placeOrder() {
  orderRepo.save(order);
  ledgerService.record(order); // 같은 트랜잭션
}

자주 발생하는 오작동 케이스

self-invocation

@Service
@RequiredArgsConstructor
public class BillingService {
  private final InvoiceRepository repo;

  // 외부에서 이 메서드 호출 → 프록시가 감지, 트랜잭션 시작
  public void outer() {
    // 내부 호출: 프록시 우회 → @Transactional 미적용
    inner(); // ❌ 트랜잭션 없음
  }

  @Transactional
  public void inner() {
    repo.save(new Invoice(...)); // 실제로는 오토커밋으로 처리됨
  }
}

프록시 미적용으로 트랜잭션 누락

@Service
@RequiredArgsConstructor
public class BillingFacade {
  private final BillingWorker worker;
  public void outer() { worker.inner(); } // ✅ 프록시 경유
}

@Service
public class BillingWorker {
  @Transactional
  public void inner() { /* ... */ }
}
@Service
@RequiredArgsConstructor
public class BillingService {
  private final BillingService self; // 같은 타입이지만 프록시가 주입되도록 구성
  public void outer() { self.inner(); } // ✅ 프록시 경유
  @Transactional public void inner() { /* ... */ }
}

✅ 해결: 자기 프록시 주입 또는 분리된 빈 사용

예외 삼키기

@Transactional
public void importFile(Path path) {
  try {
    parseAndSave(path); // IOException 발생
  } catch (IOException e) {
    log.warn("ignored", e); // ❌ 예외를 먹어버려서 롤백 트리거 안 됨
  }
}

catch 후 로그만 찍으면 롤백 안 됨

@Transactional(rollbackFor = Exception.class)
public void importFile(Path path) throws IOException {
  parseAndSave(path); // 예외를 밖으로 던짐 → 롤백
}

✅ rollbackFor 명시 또는 setRollbackOnly() 사용

전체 롤백

@Service
@RequiredArgsConstructor
public class OrderService {
  private final HistoryService history;

  @Transactional
  public void placeOrder() {
    // REQUIRED → REQUIRED: 하나의 트랜잭션
    history.writeHistory(); // 안에서 setRollbackOnly() 호출
    // 여기서 정상 리턴해도 마지막 커밋 시 전체 롤백됨 (UnexpectedRollbackException)
  }
}

@Service
public class HistoryService {
  @Transactional // REQUIRED
  public void writeHistory() {
    // 에러 상황 감지
    TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
  }
}

내부 메서드에서 setRollbackOnly()로 전체가 롤백됨

@Service
public class HistoryService {
  @Transactional(propagation = REQUIRES_NEW)
  public void writeHistory() { /* ... */ } // 바깥과 운명 분리
}

✅ 의도대로 분리하고 싶다면 REQUIRES_NEW 사용

트랜잭션 밖에서 지연 로딩

@GetMapping("/member/{id}")
public MemberDto get(@PathVariable Long id) {
  Member m = service.find(id); // 서비스 @Transactional(readOnly)
  return new MemberDto(m.getName(), m.getOrders().size()); // ❌ 컨트롤러에서 LAZY 터짐
}

LazyInitializationException 발생

@Transactional(readOnly = true)
public MemberDto findDto(Long id) {
  return em.createQuery("""
     select new com.acme.MemberDto(m.name, size(m.orders))
     from Member m where m.id = :id
  """, MemberDto.class).setParameter("id", id).getSingleResult();
}

✅ 서비스 경계 내에서 필요한 연관 로딩(페치 조인/DTO 투사)을 끝내고 반환

벌크 업데이트

@Transactional
public void deprecate() {
  em.createQuery("update Product p set p.active=false").executeUpdate(); // 벌크
  Product p = em.find(Product.class, 1L);
  p.isActive(); // ❌ 1차 캐시 내용과 DB 불일치 가능
}

flush 미처리 → 1차 캐시와 DB 불일치 ✅ em.clear() 필수

교착/리소스 경합

  • 같은 로우를 바깥 트랜잭션과 안쪽 REQUIRES_NEW가 순서 다르게 잡으면 교착 위험 증가
  • 반드시 잠금 순서/접근 키를 정하고, REQUIRES_NEW는 “로그/감사/아웃박스” 등으로 최소화

트랜잭션 테스트 전략

  • @Transactional 테스트는 기본적으로 롤백
  • @Commit으로 실제 커밋 확인 가능
  • 대규모 테스트에서는 “트랜잭션 없는” 통합 테스트로 성능 확보

트레이드오프

롤백 테스트는 깔끔하지만, DB의 실제 상태 변화를 보지 못한다. 통합 환경에서는 실제 커밋 흐름을 검증하는 테스트도 필요하다.


N+1 문제

  • N+1은 “연쇄적인 JOIN” 때문이 아니다. 오히려 JOIN을 안 해서 생긴다. 부모 N개를 한 번에 가져온 뒤,
    각 부모의 연관 엔티티를 LAZY 로딩하면서 N번 추가 SELECT가 발생하는 패턴이다
  • fetch join은 JPQL의 join fetch로 한 쿼리에서 연관을 묶어서 가져오는 기법이고,
  • LAZY/EAGER는 연관 필드의 페치 타입 설정이다. 서로 대체 관계가 아니다.
  • LAZY로 바꾼다고 “해결”되는 게 아니라, 접근 시점에 N+1이 그대로 발생한다(접근하지 않으면 쿼리가 안 나갈 뿐).

해결방안

  • N+1 문제: 리스트(부모) 1번 조회 + 각 요소의 연관을 지연로딩으로 N번 추가 조회 → 총 N+1번 쿼리
  • 주로 컬렉션(@OneToMany)이나 다대일/일대일(@ManyToOne, @OneToOne) 접근 시 발생

해결 가이드

  1. 쿼리 시점에 필요한 연관을 한 방에 가져오기
    • JPQL: select p from Post p join fetch p.author (to-one에 특히 유효)
    • Spring Data: @EntityGraph(attributePaths = "author")
  2. 배치 페치(Batch Fetch)로 N을 묶어서 줄이기
    • Hibernate: hibernate.default_batch_fetch_size=100 또는 연관에 @BatchSize(size=100)
    • 효과: LAZY 접근이 여러 개일 때 IN (…)으로 묶어서 적은 쿼리 수로 해결
  3. to-many(fetch join) 주의
    • join fetch p.comments는 페이징과 상성이 안 좋다(중복/메모리 폭증)
    • 보편적인 패턴: 두 단계 조회
      • ① 부모 ID만 페이지로 조회
      • ② 그 ID들로 필요한 연관을 IN 배치 조회
    • 대안: DTO 프로젝션으로 필요한 컬럼만 정확히 뽑기
  4. EAGER 피하기
    • JPA 기본이 @ManyToOne, @OneToOne EAGER라서 은근히 N+1을 유발한다. 가능하면 LAZY로 명시하고,
      필요할 때만 fetch join/EntityGraph로 당겨오기
  5. 캐시/쿼리 튜닝
    • 2차 캐시로 읽기 많은 연관은 완화할 수 있지만, 근본 해결은 아니다. 인덱스/조인 조건도 점검해야 한다