콘텐츠로 이동

트랜잭션 (Transaction)


트랜잭션이란? 왜 필요한가

문제 상황

계좌 이체 시나리오: - A가 B에게 2000원 이체 - A 계좌 -2000원 → 성공 - B 계좌 +2000원 → 서버 오류 발생!

A에서는 2000원이 빠져나갔는데 B에는 입금이 안 된 상황이 발생한다. 이런 문제를 해결하기 위해 트랜잭션이 필요하다.

트랜잭션 정의

트랜잭션: 하나의 논리적인 작업 단위. 해당 작업 단위는 모두 성공하거나 모두 실패해야 한다.

  • 커밋(Commit): 모든 작업이 성공했을 때 DB에 반영
  • 롤백(Rollback): 작업 중 하나라도 실패했을 때 이전 상태로 되돌림

ACID 원칙

원칙 이름 설명
A 원자성 (Atomicity) 트랜잭션 내 작업은 모두 성공하거나 모두 실패
C 일관성 (Consistency) 트랜잭션 전후로 DB 무결성 조건이 만족되어야 함
I 격리성 (Isolation) 동시 실행 트랜잭션이 서로 영향을 주지 않아야 함
D 지속성 (Durability) 커밋된 트랜잭션의 결과는 영구적으로 저장

원자성 (Atomicity)

계좌 이체 트랜잭션:
  ① A -2000원  ─┐
  ② B +2000원  ─┘ 두 작업 모두 성공 → COMMIT
                   둘 중 하나 실패  → ROLLBACK (모두 취소)

격리성 (Isolation)

격리성을 완벽히 보장하려면 동시 처리 성능이 크게 떨어진다. 그래서 ANSI 표준은 4단계 격리 수준을 정의한다. → 격리 수준 참고


자동 커밋 vs 수동 커밋

자동 커밋 (Auto Commit) — 기본값

con.setAutoCommit(true); // 기본값

// 아래 각 SQL 실행 시마다 자동으로 커밋됨
executeQuery("UPDATE member SET money=...");  // 즉시 커밋
executeQuery("UPDATE member SET money=...");  // 즉시 커밋
// 두 작업을 하나의 트랜잭션으로 묶을 수 없음!

수동 커밋 (Manual Commit)

con.setAutoCommit(false); // 수동 커밋 모드

try {
    executeQuery("UPDATE member SET money = money - 2000 WHERE member_id = 'memberA'");
    executeQuery("UPDATE member SET money = money + 2000 WHERE member_id = 'memberB'");

    con.commit(); // 모두 성공 → 커밋
} catch (Exception e) {
    con.rollback(); // 실패 → 롤백
}

수동 커밋 모드로 변경하는 것 = 트랜잭션 시작


DB 연결 구조와 트랜잭션

같은 커넥션을 사용해야 하는 이유

DB 트랜잭션은 하나의 커넥션(세션) 안에서 처리된다.

[애플리케이션]
   Service 계층
      ├── Repository A (SQL 1 실행)
      └── Repository B (SQL 2 실행)

[DB 서버]
   세션 1 (커넥션 1) ← SQL 1
   세션 2 (커넥션 2) ← SQL 2
   → 서로 다른 세션이므로 하나의 트랜잭션으로 묶을 수 없음!

따라서 하나의 트랜잭션으로 묶으려면 같은 커넥션을 Repository A와 B에서 공유해야 한다.

순수 JDBC 방식의 문제점

// 서비스 계층에서 커넥션을 직접 파라미터로 전달해야 하는 문제
public void accountTransfer(String fromId, String toId, int money) throws SQLException {
    Connection con = dataSource.getConnection();
    try {
        con.setAutoCommit(false);

        // 커넥션을 파라미터로 넘겨야 같은 커넥션 사용 가능
        memberRepository.update(con, fromId, money - 2000);
        memberRepository.update(con, toId, money + 2000);

        con.commit();
    } catch (Exception e) {
        con.rollback();
        throw new IllegalStateException(e);
    } finally {
        release(con);
    }
}

문제: 서비스 계층이 JDBC(기술)에 의존하게 됨. 나중에 JPA로 교체하면 서비스 코드도 변경해야 함.


스프링 트랜잭션 추상화

PlatformTransactionManager

스프링은 트랜잭션 기술(JDBC, JPA, Hibernate 등)을 추상화한 인터페이스를 제공한다.

public interface PlatformTransactionManager extends TransactionManager {
    TransactionStatus getTransaction(@Nullable TransactionDefinition definition);
    void commit(TransactionStatus status);
    void rollback(TransactionStatus status);
}
[서비스 계층]
    ↓ PlatformTransactionManager (인터페이스)
    ├── DataSourceTransactionManager  (JDBC, MyBatis용)
    ├── JpaTransactionManager         (JPA용)
    ├── HibernateTransactionManager   (Hibernate용)
    └── JtaTransactionManager         (분산 트랜잭션)

서비스 계층은 PlatformTransactionManager에만 의존하므로 기술이 바뀌어도 코드 변경 없음.

스프링 부트 자동 등록

# Spring Boot는 datasource 설정이 있으면 자동으로
# DataSourceTransactionManager를 빈으로 등록
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/testdb

JPA 사용 시에는 JpaTransactionManager가 자동 등록된다.


트랜잭션 동기화 (TransactionSynchronizationManager)

역할

스프링은 트랜잭션 동기화 매니저를 통해 커넥션을 스레드 로컬(ThreadLocal)에 보관한다.

[트랜잭션 매니저]
    1. DataSource에서 커넥션 획득
    2. 커넥션에 setAutoCommit(false)
    3. 커넥션을 트랜잭션 동기화 매니저에 보관

[트랜잭션 동기화 매니저]
    ThreadLocal<Map<DataSource, Connection>>
    → 스레드마다 독립적인 커넥션 저장

[Repository]
    DataSourceUtils.getConnection(dataSource)
    → 동기화 매니저에서 현재 스레드의 커넥션 꺼내서 사용
    → 없으면 새 커넥션 생성
// DataSourceUtils.getConnection() 내부 동작
public static Connection getConnection(DataSource dataSource) {
    // 1. 트랜잭션 동기화 매니저에서 현재 스레드의 커넥션 조회
    // 2. 있으면 그 커넥션 반환 (같은 트랜잭션)
    // 3. 없으면 새 커넥션 생성
}

덕분에 서비스에서 파라미터로 커넥션을 넘기지 않아도 된다!


@Transactional 사용법

기본 사용

@Service
@Slf4j
public class MemberService {

    private final MemberRepository memberRepository;

    public MemberService(MemberRepository memberRepository) {
        this.memberRepository = memberRepository;
    }

    @Transactional
    public void accountTransfer(String fromId, String toId, int money) {
        Member fromMember = memberRepository.findById(fromId);
        Member toMember = memberRepository.findById(toId);

        memberRepository.update(fromId, fromMember.getMoney() - money);
        validation(toMember); // 예외 발생 시 자동 롤백
        memberRepository.update(toId, toMember.getMoney() + money);
    }

    private void validation(Member toMember) {
        if (toMember.getMemberId().equals("ex")) {
            throw new IllegalStateException("이체 중 예외 발생");
        }
    }
}

동작 원리 (AOP 프록시)

[클라이언트]
    ↓ 호출
[MemberService$$EnhancerBySpringCGLIB] ← 스프링이 만든 프록시 객체
    ↓ 트랜잭션 시작 (getTransaction)
[MemberService] ← 실제 객체 호출
    ↓ 작업 완료
[프록시]
    ↓ 성공 시 commit(), 예외 시 rollback()

주요 옵션

propagation (전파)

@Transactional(propagation = Propagation.REQUIRED) // 기본값
옵션 설명
REQUIRED 기본값. 기존 트랜잭션 있으면 참여, 없으면 새로 생성
REQUIRES_NEW 항상 새 트랜잭션 생성. 기존 트랜잭션 잠시 보류
SUPPORTS 트랜잭션 있으면 참여, 없으면 비트랜잭션 실행
NOT_SUPPORTED 트랜잭션 없이 실행. 기존 트랜잭션 보류
MANDATORY 반드시 기존 트랜잭션 있어야 함. 없으면 예외
NEVER 트랜잭션 없이 실행. 기존 트랜잭션 있으면 예외
NESTED 중첩 트랜잭션 (savepoint 사용)

isolation (격리 수준)

@Transactional(isolation = Isolation.DEFAULT)           // DB 기본값 사용
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
@Transactional(isolation = Isolation.READ_COMMITTED)
@Transactional(isolation = Isolation.REPEATABLE_READ)
@Transactional(isolation = Isolation.SERIALIZABLE)

readOnly

@Transactional(readOnly = true) // 읽기 전용 트랜잭션
  • JPA: Dirty Checking(변경 감지) 스킵 → 성능 향상
  • DB 드라이버: 읽기 전용 최적화 가능
  • 명시적으로 쓰기 불가 표시 → 코드 의도 명확

rollbackFor

// 기본: 런타임 예외, Error만 롤백
// 체크 예외는 기본적으로 롤백하지 않음!
@Transactional(rollbackFor = Exception.class) // 모든 예외에 롤백
@Transactional(rollbackFor = {SQLException.class, DataException.class})

// 특정 예외는 롤백하지 않음
@Transactional(noRollbackFor = IllegalArgumentException.class)

timeout

@Transactional(timeout = 10) // 10초 초과 시 롤백

트랜잭션 전파 (Propagation) 상세

REQUIRED (기본값)

[외부 트랜잭션 시작]
[내부 메서드 호출 - @Transactional]
    → 기존 트랜잭션에 참여 (같은 물리 트랜잭션)
외부 커밋 → 전체 커밋
외부 롤백 → 전체 롤백
내부 롤백 → rollback-only 마킹 → 외부도 롤백 (UnexpectedRollbackException)
@Transactional
public void outer() {
    inner(); // 같은 트랜잭션 참여
    // inner()에서 예외 발생해서 rollback-only 설정되면
    // outer()에서 커밋해도 UnexpectedRollbackException 발생!
}

@Transactional
public void inner() {
    // ...
}

REQUIRES_NEW

[외부 트랜잭션]
[내부 메서드 - REQUIRES_NEW]
    → 외부 트랜잭션 잠시 중단
    → 새 트랜잭션 시작 (독립적)
    → 내부 커밋/롤백은 외부에 영향 없음
    → 내부 완료 후 외부 트랜잭션 재개
@Transactional
public void outer() {
    // ...
    inner(); // 별도 트랜잭션으로 실행
    // inner()가 롤백되어도 outer()는 계속 진행 가능
}

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void inner() {
    // 독립적인 트랜잭션
}

주의: REQUIRES_NEW는 커넥션을 2개 사용하므로 풀 크기를 고려해야 함.

NESTED (중첩 트랜잭션)

@Transactional(propagation = Propagation.NESTED)
public void nestedMethod() {
    // 외부 트랜잭션의 savepoint 생성
    // 내부 롤백 → savepoint까지만 롤백 (외부 영향 없음)
    // 외부 롤백 → 내부 포함 전체 롤백
}

JDBC의 savepoint를 사용. JPA에서는 지원하지 않음.


주의사항

1. 같은 클래스 내 메서드 호출 문제

@Service
public class MemberService {

    public void callMethod() {
        // this.internalMethod() 호출 → 프록시를 거치지 않음!
        // @Transactional 동작 안 함!
        internalMethod();
    }

    @Transactional
    public void internalMethod() {
        // 트랜잭션 적용 안 됨!
    }
}

이유: 스프링 트랜잭션은 AOP 프록시 기반. 같은 클래스 내 메서드 호출은 프록시를 거치지 않음.

해결책: - 별도 클래스로 분리 (권장) - @Transactional 대상 메서드를 직접 외부에서 호출

2. public 메서드에만 동작

// 이런 코드는 @Transactional이 동작하지 않음
@Transactional
private void privateMethod() { ... }

@Transactional
protected void protectedMethod() { ... }

3. 초기화 코드에서 트랜잭션

@PostConstruct
@Transactional
public void init() {
    // @PostConstruct와 @Transactional을 함께 쓰면 트랜잭션 적용 안 됨
    // 스프링 초기화 시점에 AOP가 아직 준비되지 않았을 수 있음
}

// 해결책: ApplicationReadyEvent 사용
@EventListener(ApplicationReadyEvent.class)
@Transactional
public void init() {
    // 모든 스프링 초기화 완료 후 실행
}

4. 체크 예외는 기본적으로 롤백하지 않음

@Transactional
public void method() throws Exception {
    // Exception(체크 예외) 던져도 기본적으로 롤백 안 됨!
    throw new Exception("체크 예외");
}

// 해결책
@Transactional(rollbackFor = Exception.class)
public void method() throws Exception {
    throw new Exception("이제 롤백됨");
}

전체 흐름 다이어그램

클라이언트 요청
[트랜잭션 AOP 프록시]
    ↓ 트랜잭션 시작 (getTransaction)
[트랜잭션 매니저]
    ↓ 커넥션 획득 + setAutoCommit(false)
[DataSource (커넥션 풀)]
    ↓ 커넥션 획득 + 동기화 매니저에 저장
[트랜잭션 동기화 매니저 (ThreadLocal)]
[서비스 비즈니스 로직 실행]
[Repository]
    ↓ DataSourceUtils.getConnection() → 동기화 매니저에서 커넥션 꺼냄
[DB 작업 수행]
[트랜잭션 AOP 프록시]
    ↓ 성공: commit() / 실패: rollback()
[응답 반환]