트랜잭션 (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)
격리성 (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 (전파)
| 옵션 | 설명 |
|---|---|
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
- JPA: Dirty Checking(변경 감지) 스킵 → 성능 향상
- DB 드라이버: 읽기 전용 최적화 가능
- 명시적으로 쓰기 불가 표시 → 코드 의도 명확
rollbackFor
// 기본: 런타임 예외, Error만 롤백
// 체크 예외는 기본적으로 롤백하지 않음!
@Transactional(rollbackFor = Exception.class) // 모든 예외에 롤백
@Transactional(rollbackFor = {SQLException.class, DataException.class})
// 특정 예외는 롤백하지 않음
@Transactional(noRollbackFor = IllegalArgumentException.class)
timeout
트랜잭션 전파 (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()
[응답 반환]