트랜잭션 ACID 원칙과 격리 수준 완벽 가이드

트랜잭션 ACID 원칙과 격리 수준 완벽 가이드

데이터베이스에서 트랜잭션(Transaction)은 하나의 논리적인 작업 단위다. “계좌 이체”를 생각해보면, A 계좌에서 출금하고 B 계좌에 입금하는 두 연산은 반드시 함께 성공하거나 함께 실패해야 한다. 출금만 되고 입금이 안 되면 돈이 사라진다.

트랜잭션은 단순히 “여러 쿼리를 묶는 것”이 아니다. 트랜잭션은 데이터베이스 상태를 안전하게 전이시키는 메커니즘이다. 현재 상태(State A)에서 목표 상태(State B)로 전이하는 동안, 시스템 장애가 나든 다른 사용자가 동시에 접근하든, 데이터의 정합성이 깨지지 않도록 보장하는 것이 트랜잭션의 역할이다.

이 글은 트랜잭션이 보장해야 하는 ACID 원칙의 각 속성이 왜 필요하고 어떤 메커니즘으로 구현되는지, 트랜잭션 격리 수준이 해결하는 문제와 각 수준의 내부 동작 원리, MVCC와 락 기반 동시성 제어의 차이와 협력 관계, 그리고 Spring에서의 실무 적용까지를 다룬다.


1. ACID란 무엇인가

ACID는 트랜잭션이 안전하게 수행되기 위해 보장해야 하는 네 가지 속성이다. 1983년 Andreas Reuter와 Theo Härder가 정의한 이 개념은 이후 관계형 데이터베이스의 설계 근간이 되었다. 네 속성은 독립된 개념이 아니라, 서로를 보완하며 데이터 무결성을 지킨다.

1.1 원자성 (Atomicity) — “전부 아니면 전무”

트랜잭션에 포함된 모든 연산은 전부 성공하거나, 전부 실패해야 한다. 부분 성공은 없다.

1
2
3
4
5
-- 계좌 이체: 두 연산은 반드시 함께 성공해야 한다
BEGIN TRANSACTION;
    UPDATE account SET balance = balance - 100000 WHERE id = 'A';  -- 출금
    UPDATE account SET balance = balance + 100000 WHERE id = 'B';  -- 입금
COMMIT;

만약 입금 쿼리에서 오류가 발생하면, 출금도 없었던 일이 되어야 한다. 이것이 원자성이다.

원자성의 구현: Undo 로그와 롤백 세그먼트

DBMS는 원자성을 Undo 로그로 구현한다. 트랜잭션이 데이터를 변경할 때마다 변경 전 값(Before Image)을 Undo 로그에 기록한다.

1
2
3
4
5
6
[트랜잭션 T1의 Undo 로그]
시점 1: UPDATE account SET balance = 400000 WHERE id = 'A'
        → Undo: { table=account, id='A', column=balance, old_value=500000 }

시점 2: UPDATE account SET balance = 400000 WHERE id = 'B'
        → Undo: { table=account, id='B', column=balance, old_value=300000 }

트랜잭션이 롤백되면 Undo 로그를 역순으로 적용하여 원래 상태로 복원한다.

1
2
3
4
[롤백 실행]
1. account B의 balance를 300000으로 복원 (Undo 항목 2 적용)
2. account A의 balance를 500000으로 복원 (Undo 항목 1 적용)
→ 모든 변경이 취소되어 트랜잭션 시작 전 상태로 돌아감

여기서 중요한 점이 있다. Undo 로그 자체도 디스크에 기록되어야 한다. Undo 로그가 메모리에만 있는 상태에서 시스템이 크래시되면, 복구 시점에 롤백이 불가능해지기 때문이다. 이것은 뒤에서 다룰 WAL(Write-Ahead Logging)과 연결된다.

MySQL InnoDB의 Undo 로그 구조

InnoDB에서 Undo 로그는 단순한 로그가 아니라 버전 체인을 구성한다. 각 행은 자신의 이전 버전을 가리키는 포인터(DB_ROLL_PTR)를 가지고 있고, Undo 로그를 따라가면 과거 버전을 복원할 수 있다. 이 구조는 원자성뿐 아니라 MVCC(Multi-Version Concurrency Control)의 기반이 되기도 한다.

1
2
3
4
5
6
7
[행의 현재 상태]
{ id='A', balance=400000, DB_TRX_ID=100, DB_ROLL_PTR=→ }
                                                        ↓
[Undo 로그 체인]
{ id='A', balance=500000, DB_TRX_ID=90, DB_ROLL_PTR=→ }
                                                       ↓
{ id='A', balance=300000, DB_TRX_ID=80, DB_ROLL_PTR=NULL }

트랜잭션 100이 롤백되면, DB_ROLL_PTR을 따라 이전 버전(balance=500000)으로 복원된다. 그리고 이 Undo 로그 체인은 REPEATABLE READ 격리 수준에서 “과거 시점의 데이터를 읽는” 데도 활용된다. 하나의 메커니즘이 두 가지 목적(원자성 + 격리성)을 동시에 서빙하는 것이다.

Java/Spring에서의 원자성

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepository.findById(fromId)
        .orElseThrow(() -> new AccountNotFoundException(fromId));
    Account to = accountRepository.findById(toId)
        .orElseThrow(() -> new AccountNotFoundException(toId));

    from.withdraw(amount);  // 잔액 부족이면 InsufficientBalanceException
    to.deposit(amount);

    // 메서드가 정상 종료하면 COMMIT
    // RuntimeException이 발생하면 ROLLBACK — 출금도 취소됨
}

Spring의 @Transactional은 AOP 프록시를 통해 동작한다. 메서드 호출 전에 트랜잭션을 시작하고, 정상 종료하면 커밋, 예외가 발생하면 롤백한다.

주의: Spring의 롤백 규칙은 직관과 다를 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
// 기본: RuntimeException(Unchecked)과 Error만 롤백
// Checked Exception은 롤백하지 않는다!
@Transactional
public void method() throws IOException {
    // IOException(Checked)이 발생해도 커밋된다!
}

// Checked Exception에도 롤백하려면 명시적으로 지정
@Transactional(rollbackFor = Exception.class)
public void method() throws IOException {
    // 이제 IOException에도 롤백된다
}

이 설계의 이유는 Spring이 Checked Exception을 “복구 가능한 비즈니스 예외”로 간주하기 때문이다. 하지만 실무에서는 rollbackFor = Exception.class를 기본으로 지정하는 팀이 많다. Checked Exception이 발생했는데 커밋되는 것은 대부분의 경우 원하는 동작이 아니기 때문이다.


1.2 일관성 (Consistency) — “규칙을 어기지 않는다”

트랜잭션 실행 전후로 데이터베이스는 항상 일관된 상태를 유지해야 한다. 정의된 규칙(제약 조건, 트리거, 비즈니스 규칙 등)을 위반하는 상태로는 절대 전이하지 않는다.

일관성은 ACID 중 가장 이해하기 어려운 속성이다. 다른 세 속성은 DBMS가 기술적으로 보장하는 것이지만, 일관성은 “무엇이 올바른 상태인가”를 개발자가 정의하고, DBMS와 애플리케이션이 함께 지키는 것이다.

일관성의 세 가지 층위

1단계 — DB 스키마 레벨 일관성: 제약 조건이 보장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
-- PRIMARY KEY: 중복된 식별자가 존재하면 안 된다
-- FOREIGN KEY: 존재하지 않는 팀에 회원을 넣을 수 없다
-- UNIQUE: 같은 이메일로 두 번 가입할 수 없다
-- CHECK: 잔액은 음수가 될 수 없다
-- NOT NULL: 이름은 반드시 있어야 한다

ALTER TABLE account ADD CONSTRAINT chk_balance CHECK (balance >= 0);

BEGIN TRANSACTION;
    UPDATE account SET balance = balance - 200000 WHERE id = 'A';
    -- 잔액이 100000인 상태에서 실행하면: balance = -100000
    -- CHECK 제약 위반 → 트랜잭션 실패 → 롤백
COMMIT;

2단계 — 비즈니스 로직 레벨 일관성: 애플리케이션 코드가 보장한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Transactional
public void placeOrder(Order order) {
    // 비즈니스 규칙: 재고보다 많이 주문할 수 없다
    for (OrderItem item : order.getItems()) {
        Product product = productRepository.findById(item.getProductId())
            .orElseThrow();
        if (product.getStock() < item.getQuantity()) {
            throw new InsufficientStockException("재고 부족: " + product.getName());
        }
        product.decreaseStock(item.getQuantity());
    }

    // 비즈니스 규칙: 주문 총액은 항목 합계와 일치해야 한다
    BigDecimal calculatedTotal = order.getItems().stream()
        .map(item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())))
        .reduce(BigDecimal.ZERO, BigDecimal::add);

    if (calculatedTotal.compareTo(order.getTotalAmount()) != 0) {
        throw new OrderAmountMismatchException("주문 총액 불일치");
    }

    orderRepository.save(order);
}

3단계 — 크로스 시스템 일관성: 분산 환경에서의 일관성.

마이크로서비스 환경에서는 하나의 비즈니스 트랜잭션이 여러 서비스에 걸칠 수 있다. 주문 서비스에서 주문을 생성하고, 결제 서비스에서 결제를 처리하고, 재고 서비스에서 재고를 차감해야 한다. 단일 DB 트랜잭션으로 묶을 수 없으므로, Saga 패턴이나 이벤트 기반 보상 트랜잭션으로 최종적 일관성(Eventual Consistency)을 구현한다.

1
2
3
4
5
6
7
8
[Saga 패턴 — Choreography 방식]
1. 주문 서비스: 주문 생성 → "주문 생성됨" 이벤트 발행
2. 결제 서비스: 이벤트 수신 → 결제 처리 → "결제 완료" 이벤트 발행
3. 재고 서비스: 이벤트 수신 → 재고 차감

결제 실패 시:
2. 결제 서비스: "결제 실패" 이벤트 발행
→ 주문 서비스: 이벤트 수신 → 주문 취소 (보상 트랜잭션)

이처럼 일관성은 DBMS 혼자서 보장하는 것이 아니다. 스키마 설계, 애플리케이션 로직, 시스템 아키텍처가 모두 참여하여 만드는 속성이다.


1.3 격리성 (Isolation) — “다른 트랜잭션이 끼어들지 못한다”

동시에 실행되는 트랜잭션들이 서로의 중간 상태를 볼 수 없어야 한다. 각 트랜잭션은 마치 혼자서 실행되는 것처럼 동작해야 한다.

완벽한 격리의 이상향은 직렬성(Serializability)이다. 모든 트랜잭션이 하나씩 순서대로 실행되었을 때와 동일한 결과를 보장하는 것이다. 그러나 현실에서 수천 개의 동시 트랜잭션을 직렬로 실행하면 성능이 참담해진다.

그래서 DBMS는 격리성의 수준을 제공한다. 격리 수준이 낮을수록 성능은 좋지만 이상 현상이 발생할 수 있고, 높을수록 안전하지만 동시성이 떨어진다. 이 트레이드오프가 이 글의 핵심 주제인 격리 수준(Isolation Level)이다. 이에 대해서는 2장 이하에서 상세히 다룬다.


1.4 지속성 (Durability) — “커밋되면 영원히 남는다”

트랜잭션이 성공적으로 커밋되면, 그 결과는 시스템 장애(정전, 크래시 등)가 발생해도 영구적으로 보존된다.

WAL(Write-Ahead Logging)의 동작 원리

커밋의 의미는 “변경 결과가 디스크에 완전히 기록되었다”가 아니다. 모든 변경을 즉시 데이터 파일에 쓰면 랜덤 I/O가 폭발한다. DBMS는 이 문제를 WAL(Write-Ahead Logging)로 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
[WAL 동작 흐름]

1. 트랜잭션이 데이터를 변경한다
   → 변경 내용은 메모리(Buffer Pool)에만 반영된다
   → 변경 내용을 Redo 로그(WAL)에 기록한다

2. COMMIT 요청이 들어온다
   → Redo 로그를 디스크에 fsync한다 (순차 쓰기 — 빠르다)
   → fsync 완료 후 "COMMIT 성공" 응답을 반환한다
   → 이 시점에서 데이터 파일은 아직 업데이트되지 않았을 수 있다!

3. 백그라운드에서 Checkpoint가 발생한다
   → Buffer Pool의 Dirty Page를 데이터 파일에 비동기로 쓴다
   → 이것은 COMMIT 이후 언제든 일어날 수 있다

핵심 아이디어는 이것이다.

  • 로그 쓰기는 순차 I/O(Sequential I/O)이므로 매우 빠르다
  • 데이터 파일 쓰기는 랜덤 I/O(Random I/O)이므로 느리다
  • 로그만 디스크에 안전하게 기록되면, 데이터 파일 반영은 나중에 해도 된다
  • 시스템이 크래시되면 로그를 재생(Replay)하여 커밋된 데이터를 복구한다

크래시 복구(Crash Recovery) 과정

시스템이 예기치 않게 종료된 후 재시작할 때, DBMS는 다음 과정을 거친다.

1
2
3
4
5
6
7
8
9
10
11
[InnoDB Crash Recovery]

1단계 — Redo (재실행)
  Redo 로그를 처음부터 끝까지 재생한다.
  커밋된 트랜잭션의 변경 중 데이터 파일에 반영되지 않은 것을 복원한다.
  → "커밋됐는데 디스크에 없는 것"을 살린다

2단계 — Undo (롤백)
  Undo 로그를 사용하여, 크래시 시점에 진행 중이던(커밋되지 않은)
  트랜잭션의 변경을 되돌린다.
  → "커밋 안 됐는데 디스크에 반영된 것"을 지운다

이 두 단계를 거치면 데이터베이스는 마지막 커밋 시점의 일관된 상태로 정확히 복원된다. Redo가 지속성을, Undo가 원자성을 보장하는 셈이다.

fsync와 성능 트레이드오프

innodb_flush_log_at_trx_commit 설정은 Redo 로그의 디스크 동기화 시점을 결정한다.

동작 지속성 성능
1 (기본) 매 커밋마다 fsync 완벽 보장 가장 느림
2 매 커밋마다 OS 버퍼에 쓰고, fsync는 1초마다 OS 크래시 시 최대 1초 유실 중간
0 1초마다 로그를 쓰고 fsync DB 크래시 시 최대 1초 유실 가장 빠름

값이 1이면 매 커밋마다 디스크 동기화가 일어나므로 안전하지만 느리다. 값이 0이나 2이면 최대 1초분의 커밋된 트랜잭션이 유실될 수 있지만 성능은 좋다. 금융 시스템에서는 반드시 1, 로그 데이터나 세션 데이터처럼 유실이 허용되는 곳에서는 2를 사용하기도 한다.


1.5 ACID 속성 간의 관계

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
원자성 ─── 트랜잭션의 "전부 또는 전무"를 보장한다
  │          Undo 로그로 구현
  │          롤백 시 Undo 로그를 역순 적용
  │
일관성 ─── 트랜잭션 전후로 규칙이 유지됨을 보장한다
  │          원자성 + 격리성 + 비즈니스 로직이 함께 만든다
  │          유일하게 "개발자의 참여"가 필수인 속성
  │
격리성 ─── 동시 실행 트랜잭션 간 간섭을 방지한다
  │          MVCC + Lock으로 구현
  │          Undo 로그의 버전 체인이 MVCC의 기반
  │
지속성 ─── 커밋된 결과의 영구 보존을 보장한다
             WAL(Redo 로그)로 구현
             순차 쓰기로 성능과 안전성을 동시에 확보

주목할 점은 Undo 로그가 원자성과 격리성 모두에 사용된다는 것이다. 원자성 관점에서는 롤백을 위한 “되돌리기 정보”이고, 격리성(MVCC) 관점에서는 “과거 버전 데이터”다. 하나의 물리적 구조가 두 가지 논리적 목적을 서빙하는 것이다.

일관성은 다른 세 속성의 결과물에 가깝다. 원자성이 부분 실패를 막고, 격리성이 동시성 간섭을 막고, 지속성이 커밋 결과를 보존할 때, 비로소 일관성이 유지된다.


2. 트랜잭션 동시성 문제: 격리 수준이 필요한 이유

현실의 데이터베이스에는 수십~수백 개의 트랜잭션이 동시에 실행된다. 격리성을 완벽하게 보장하려면 트랜잭션을 하나씩 순서대로 실행하면 되지만, 이러면 동시성이 1이 된다. 쇼핑몰에서 주문 하나가 끝나야 다음 주문을 처리할 수 있다면 서비스가 불가능하다.

그래서 DBMS는 격리성의 수준을 제공한다. 격리 수준을 이해하려면 먼저 어떤 이상 현상(Anomaly)이 존재하는지를 알아야 한다.

2.1 Dirty Read — 존재한 적 없는 데이터를 읽는다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
시간 →

트랜잭션 A                            트랜잭션 B
─────────                           ─────────
BEGIN
UPDATE account SET balance = 0
WHERE id = 'A'
(원래 balance = 1,000,000)
                                    BEGIN
                                    SELECT balance FROM account
                                    WHERE id = 'A'
                                    → 0 (커밋되지 않은 값을 읽음!)

                                    -- 잔액이 0이므로 "잔액 부족" 처리를 한다
                                    -- 실제로는 100만원이 있는 계좌인데!
ROLLBACK
(balance는 원래 1,000,000으로 복원)
                                    → 트랜잭션 B는 한 번도 존재한 적 없는
                                      값(0)을 기반으로 잘못된 결정을 내렸다

Dirty Read는 가장 심각한 이상 현상이다. 커밋되지 않은 데이터는 언제든 롤백될 수 있으므로, 그 데이터를 기반으로 한 모든 결정은 무효가 된다. “존재한 적 없는 현실”을 기반으로 판단한 셈이다.

2.2 Non-Repeatable Read — 같은 질문에 다른 답이 돌아온다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
시간 →

트랜잭션 A                            트랜잭션 B
─────────                           ─────────
BEGIN
SELECT balance FROM account
WHERE id = 'A'
→ 1,000,000
                                    BEGIN
                                    UPDATE account SET balance = 500,000
                                    WHERE id = 'A'
                                    COMMIT
-- 같은 쿼리를 다시 실행
SELECT balance FROM account
WHERE id = 'A'
→ 500,000
-- 같은 트랜잭션 안인데 결과가 달라졌다!

이것이 왜 문제인가? 다음 시나리오를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Transactional
public void processBonus(Long accountId) {
    Account account = accountRepository.findById(accountId).orElseThrow();
    BigDecimal balance = account.getBalance();

    // 조건 1: 잔액이 100만 이상이면 VIP 보너스 적용
    if (balance.compareTo(BigDecimal.valueOf(1_000_000)) >= 0) {
        // 여기까지 통과했다...

        // 그런데 이 사이에 다른 트랜잭션이 잔액을 50만으로 변경하고 커밋!

        // 다시 조회하면 50만인데, 이미 VIP 조건을 통과해버렸다
        applyVipBonus(account);  // 잘못된 보너스 적용!
    }
}

“읽고 → 판단하고 → 행동하는” 패턴에서 Non-Repeatable Read는 판단의 전제가 무너지는 문제를 만든다.

2.3 Phantom Read — 유령 행이 나타나거나 사라진다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
시간 →

트랜잭션 A                            트랜잭션 B
─────────                           ─────────
BEGIN
SELECT COUNT(*) FROM order
WHERE status = 'PENDING'
→ 5건

                                    BEGIN
                                    INSERT INTO order (status, ...)
                                    VALUES ('PENDING', ...)
                                    COMMIT

SELECT COUNT(*) FROM order
WHERE status = 'PENDING'
→ 6건 (유령처럼 행이 하나 늘었다!)

Non-Repeatable Read와의 차이를 명확히 해야 한다.

  • Non-Repeatable Read: 기존에 읽었던 행의 값이 바뀌는 것 (UPDATE/DELETE)
  • Phantom Read: 조건에 맞는 행의 집합 자체가 바뀌는 것 (INSERT/DELETE)

이 구분이 중요한 이유는 방어 메커니즘이 다르기 때문이다. Non-Repeatable Read는 읽은 행에 대한 락이나 스냅샷으로 방어할 수 있지만, Phantom Read는 “아직 존재하지 않는 행”에 대한 것이므로 범위(range)에 대한 락이나 스냅샷이 필요하다.

2.4 SQL 표준이 정의하지 않은 추가 이상 현상

SQL 표준은 위 세 가지만 정의하지만, 실제로는 더 많은 이상 현상이 존재한다.

Lost Update (갱신 분실): 두 트랜잭션이 같은 데이터를 동시에 수정하면 하나의 수정이 사라진다.

1
2
3
4
5
6
7
8
9
트랜잭션 A                         트랜잭션 B
─────────                        ─────────
READ balance → 100만
                                 READ balance → 100만
UPDATE balance = 100만 + 10만
= 110만
                                 UPDATE balance = 100만 + 20만
                                 = 120만
→ A의 +10만이 사라졌다! (결과는 120만이어야 하는데 130만이 맞다)

Write Skew (쓰기 스큐): 두 트랜잭션이 각각 다른 행을 수정하지만, 전체적으로 보면 일관성이 깨진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
제약: 당직 의사가 최소 1명은 있어야 한다
현재: 의사 A(당직), 의사 B(당직)

트랜잭션 1                         트랜잭션 2
─────────                        ─────────
SELECT COUNT(*) FROM doctor      SELECT COUNT(*) FROM doctor
WHERE on_call = true → 2         WHERE on_call = true → 2

-- 2명이니까 1명 빠져도 OK         -- 2명이니까 1명 빠져도 OK
UPDATE doctor SET on_call=false  UPDATE doctor SET on_call=false
WHERE name = 'A'                 WHERE name = 'B'

→ 결과: 당직 의사 0명! 제약 위반!

Write Skew는 SERIALIZABLE이 아닌 격리 수준에서는 방지할 수 없다. REPEATABLE READ에서도 발생한다(각 트랜잭션이 수정하는 행이 다르므로 행 레벨 락으로는 잡을 수 없다).


3. 네 가지 격리 수준

SQL 표준(SQL-92)은 네 가지 격리 수준을 정의한다. 아래로 갈수록 격리가 강해지고 안전해지지만, 동시성 처리량은 줄어든다.

3.1 READ UNCOMMITTED — 격리 없음

커밋되지 않은 데이터도 읽을 수 있다. Dirty Read, Non-Repeatable Read, Phantom Read가 모두 발생한다.

1
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;

실무에서는 거의 사용하지 않는다. 유일한 사용 사례는 정확성이 중요하지 않은 대략적 통계 조회다. 예를 들어 “현재 활성 세션 수가 대략 몇 개인가” 같은 모니터링 쿼리에서, 락 대기 없이 빠르게 결과를 얻고 싶을 때 사용할 수 있다. 그러나 대부분의 DBMS에서는 READ COMMITTED도 MVCC 덕분에 읽기 시 락을 잡지 않으므로, READ UNCOMMITTED의 성능 이점은 미미하다.

3.2 READ COMMITTED — 커밋된 것만 읽는다

커밋된 데이터만 읽을 수 있다. Dirty Read를 방지한다. 그러나 Non-Repeatable Read와 Phantom Read는 여전히 발생한다.

1
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

Oracle, PostgreSQL의 기본 격리 수준이다.

READ COMMITTED의 MVCC 동작: “문장 단위 스냅샷”

MVCC 기반 DBMS에서 READ COMMITTED는 각 SQL 문(Statement)이 실행되는 시점의 스냅샷을 읽는다. 같은 트랜잭션 안에서도 SELECT를 실행할 때마다 새로운 스냅샷을 잡는다.

1
2
3
4
5
6
7
8
9
10
11
시간축: T1 ────── T2 ────── T3 ────── T4

트랜잭션 A (READ COMMITTED)
  T1: BEGIN
  T2: SELECT balance → 100만 (T2 시점 스냅샷)
  T4: SELECT balance → 50만  (T4 시점 스냅샷, B의 커밋이 반영됨)

트랜잭션 B
  T2: BEGIN
  T3: UPDATE balance = 50만
  T3: COMMIT

T2의 SELECT와 T4의 SELECT가 서로 다른 스냅샷을 사용하므로 결과가 달라진다. 이것이 Non-Repeatable Read다.

READ COMMITTED에서 락은 어떻게 동작하는가

InnoDB에서 READ COMMITTED의 락 동작에는 중요한 차이가 있다.

1
2
-- READ COMMITTED에서
SELECT * FROM member WHERE age > 20 FOR UPDATE;

이 쿼리가 인덱스를 통해 age가 21, 25, 30인 행을 찾았다면, 그 세 행에만 Record Lock이 걸린다. 행 사이의 간격(Gap)에는 락을 걸지 않는다. 따라서 다른 트랜잭션이 age=22인 행을 INSERT할 수 있다 → Phantom Read 가능.

반면 REPEATABLE READ에서는 Gap Lock이 걸려서 age 20~30 사이의 INSERT가 차단된다.

3.3 REPEATABLE READ — 트랜잭션 시작 시점의 세계를 본다

트랜잭션이 시작된 시점의 데이터를 일관되게 읽는다. Dirty Read와 Non-Repeatable Read를 방지한다.

1
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

MySQL(InnoDB)의 기본 격리 수준이다.

REPEATABLE READ의 MVCC 동작: “트랜잭션 단위 스냅샷”

READ COMMITTED가 “문장 단위 스냅샷”이라면, REPEATABLE READ는 “트랜잭션 단위 스냅샷”이다. 트랜잭션의 첫 번째 읽기가 수행될 때 스냅샷이 생성되고, 트랜잭션이 끝날 때까지 그 스냅샷을 유지한다.

1
2
3
4
5
6
7
8
9
10
11
시간축: T1 ────── T2 ────── T3 ────── T4

트랜잭션 A (REPEATABLE READ)
  T1: BEGIN
  T2: SELECT balance → 100만 (스냅샷 생성: T2 시점)
  T4: SELECT balance → 100만 (여전히 T2 시점 스냅샷!)

트랜잭션 B
  T2: BEGIN
  T3: UPDATE balance = 50만
  T3: COMMIT

트랜잭션 B가 값을 바꾸고 커밋했지만, 트랜잭션 A는 자신의 스냅샷(T2 시점)만 보기 때문에 항상 같은 값을 읽는다.

MVCC의 가시성 판단 규칙

InnoDB가 “이 버전을 이 트랜잭션이 볼 수 있는가”를 판단하는 규칙은 다음과 같다.

각 트랜잭션은 시작 시 ReadView를 생성한다. ReadView에는 다음 정보가 포함된다.

1
2
3
4
5
6
ReadView = {
    creator_trx_id: 이 ReadView를 생성한 트랜잭션 ID,
    m_ids: ReadView 생성 시점에 활성(ACTIVE) 상태인 트랜잭션 ID 목록,
    min_trx_id: m_ids 중 최솟값,
    max_trx_id: 다음에 할당될 트랜잭션 ID (현재 시스템의 최대 + 1)
}

행의 DB_TRX_ID(이 행을 마지막으로 수정한 트랜잭션)를 다음 규칙으로 판단한다.

1
2
3
4
5
6
7
8
9
10
11
12
1. DB_TRX_ID < min_trx_id
   → ReadView 생성 전에 이미 커밋된 트랜잭션 → 보임 ✅

2. DB_TRX_ID >= max_trx_id
   → ReadView 생성 이후에 시작된 트랜잭션 → 안 보임 ❌

3. min_trx_id <= DB_TRX_ID < max_trx_id
   → m_ids에 포함되어 있으면: 아직 커밋 안 된 트랜잭션 → 안 보임 ❌
   → m_ids에 포함되어 있지 않으면: 이미 커밋된 트랜잭션 → 보임 ✅

4. DB_TRX_ID == creator_trx_id
   → 자기 자신의 변경 → 보임 ✅

READ COMMITTED: 매 SELECT 문마다 새로운 ReadView를 생성한다. REPEATABLE READ: 첫 번째 SELECT에서 ReadView를 생성하고, 트랜잭션 내내 재사용한다.

이 차이 하나가 두 격리 수준의 동작 차이를 만든다.

MySQL InnoDB의 Gap Lock과 Next-Key Lock

InnoDB는 REPEATABLE READ에서 Gap LockNext-Key Lock을 사용하여 Phantom Read를 방지한다. 이것은 SQL 표준보다 더 강한 보장이다.

1
2
3
4
5
6
7
8
9
10
11
인덱스 상태: ... | 10 | 20 | 30 | ...

SELECT * FROM member WHERE age BETWEEN 15 AND 25 FOR UPDATE;

InnoDB가 거는 락:
1. Record Lock: age=20 행에 X Lock
2. Gap Lock: (10, 20) 간격에 Gap Lock
3. Gap Lock: (20, 30) 간격에 Gap Lock

→ 이 간격에 새로운 행(예: age=17, age=22)을 INSERT하려면 대기해야 한다
→ Phantom Read 방지!

Next-Key Lock = Record Lock + Gap Lock이다. InnoDB에서 REPEATABLE READ의 FOR UPDATEFOR SHARE는 기본적으로 Next-Key Lock을 사용한다.

그러나 주의할 점이 있다. 일반 SELECT(잠금 없는 읽기)는 Gap Lock을 사용하지 않는다. 일반 SELECT는 MVCC 스냅샷을 읽으므로 Phantom Read가 “보이지 않을 뿐”이지, 실제로 다른 트랜잭션의 INSERT를 차단하지는 않는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 이 둘의 동작은 완전히 다르다

// 1. 일반 SELECT: MVCC 스냅샷 읽기 (락 없음)
@Query("SELECT m FROM Member m WHERE m.age > 20")
List<Member> findByAgeGreaterThan(int age);
// → 다른 트랜잭션의 INSERT를 차단하지 않음
// → 스냅샷 기준으로 읽으므로 Phantom이 "안 보일 뿐"

// 2. FOR UPDATE: Next-Key Lock (실제로 INSERT 차단)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Member m WHERE m.age > 20")
List<Member> findByAgeGreaterThanForUpdate(int age);
// → Gap Lock으로 INSERT 자체를 차단

3.4 SERIALIZABLE — 완벽한 격리

트랜잭션을 완전히 순차적으로 실행한 것과 같은 결과를 보장한다. 모든 이상 현상을 방지한다. Write Skew도 방지된다.

1
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

구현 방식은 DBMS마다 다르다.

MySQL InnoDB: 모든 일반 SELECT를 암시적으로 SELECT ... FOR SHARE로 변환한다. 읽기에도 공유 락이 걸리므로, 읽기-쓰기 충돌이 발생하면 대기한다.

1
2
3
4
5
-- SERIALIZABLE에서는 이 쿼리가
SELECT * FROM account WHERE id = 'A';

-- 실제로는 이렇게 동작한다
SELECT * FROM account WHERE id = 'A' FOR SHARE;

PostgreSQL: SSI(Serializable Snapshot Isolation)를 사용한다. 기본적으로 MVCC 스냅샷을 사용하되, 트랜잭션 간의 읽기-쓰기 의존성을 추적한다. 직렬 불가능한 스케줄이 감지되면 한쪽 트랜잭션을 중단시키고 재시도를 요구한다.

1
2
3
4
5
PostgreSQL SSI:
1. 트랜잭션들이 MVCC로 자유롭게 실행된다 (락 대기 적음)
2. 커밋 시점에 의존성 그래프를 검사한다
3. 직렬화 불가능한 패턴이 감지되면 → serialization_failure 에러
4. 애플리케이션이 해당 트랜잭션을 재시도해야 한다

PostgreSQL의 SSI 방식은 MySQL의 락 기반 방식보다 동시성이 높지만, 애플리케이션에서 재시도 로직을 구현해야 하는 부담이 있다.


4. 격리 수준 비교 정리

격리 수준 Dirty Read Non-Repeatable Read Phantom Read Lost Update Write Skew
READ UNCOMMITTED O O O O O
READ COMMITTED X O O O O
REPEATABLE READ X X O (MySQL: 대부분 X) X (MVCC) O
SERIALIZABLE X X X X X

O = 발생 가능, X = 방지

MySQL InnoDB의 REPEATABLE READ는 Gap Lock 덕분에 Phantom Read를 대부분 방지하므로, 실질적으로 READ COMMITTED와 SERIALIZABLE 사이의 넓은 영역을 커버한다. 이것이 MySQL이 REPEATABLE READ를 기본으로 채택한 이유이기도 하다.


5. 각 격리 수준의 장단점과 선택 기준

5.1 READ UNCOMMITTED

장점:

  • 락 대기가 전혀 없으므로 이론적으로 가장 빠르다
  • MVCC 환경에서 다른 격리 수준도 읽기 시 락을 잡지 않으므로 실질적 성능 차이는 미미

단점:

  • Dirty Read 발생 — 한 번도 커밋된 적 없는 데이터를 기반으로 결정이 내려질 수 있다
  • 데이터 정합성을 전혀 보장하지 못한다

선택해야 하는 경우:

1
2
거의 없다. MVCC 기반 DBMS에서는 READ COMMITTED도 읽기 시 락을 잡지 않으므로
성능 이점이 없다. "대략적인 모니터링 수치"를 확인하는 극히 제한된 경우에만 고려.

5.2 READ COMMITTED

장점:

  • Dirty Read를 완전히 방지한다 — 커밋된 데이터만 보장
  • 문장 단위 스냅샷이므로 다른 트랜잭션의 최신 커밋 결과를 즉시 반영한다
  • 락 경합이 REPEATABLE READ보다 적다 — Gap Lock이 없으므로 INSERT가 덜 차단된다
  • Oracle, PostgreSQL의 기본값이므로 해당 DBMS에 최적화되어 있다

단점:

  • Non-Repeatable Read 발생 — 같은 트랜잭션 안에서 동일 데이터를 두 번 읽으면 다른 값을 볼 수 있다
  • Phantom Read 발생 — 같은 조건으로 조회해도 결과 행 수가 달라질 수 있다
  • “읽고 → 판단하고 → 행동하는” 패턴에서 판단의 전제가 무너질 수 있다

선택해야 하는 경우:

1
2
3
4
✅ Oracle, PostgreSQL 환경에서 대부분의 웹 애플리케이션
✅ 최신 데이터를 보는 것이 더 중요한 경우 (SNS 피드, 실시간 대시보드)
✅ INSERT가 빈번하여 Gap Lock으로 인한 대기를 피하고 싶은 경우
✅ 트랜잭션이 짧고, 같은 데이터를 두 번 읽을 일이 적은 경우

주의해야 하는 시나리오:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// READ COMMITTED에서 위험한 패턴
@Transactional(isolation = Isolation.READ_COMMITTED)
public void applyDiscount(Long productId) {
    Product product = productRepository.findById(productId).orElseThrow();

    if (product.getPrice() > 10000) {
        // 이 사이에 다른 트랜잭션이 price를 5000으로 변경하고 커밋!
        // 하지만 이 트랜잭션은 아직 위에서 읽은 값(> 10000)을 기반으로 판단 중

        product.applyDiscount(0.1);  // 10% 할인 적용
        // 실제로는 이미 5000원인 상품에 할인이 적용됨
    }
}
// → REPEATABLE READ에서는 스냅샷이 고정되므로 이 문제가 달라진다
// → 단, 정확한 해결은 비관적 락(FOR UPDATE)이 필요할 수 있다

5.3 REPEATABLE READ

장점:

  • Non-Repeatable Read를 방지한다 — 트랜잭션 내에서 일관된 읽기를 보장한다
  • MySQL InnoDB에서는 Gap Lock으로 Phantom Read도 대부분 방지한다
  • 트랜잭션 단위 스냅샷이므로 정산, 집계 같은 일관된 읽기가 필요한 작업에 적합하다
  • MySQL의 기본값이므로 InnoDB에 최적화되어 있다

단점:

  • Gap Lock으로 인한 INSERT 대기가 발생할 수 있다 — 동시 삽입이 많은 워크로드에서 병목
  • Long Transaction에서 Undo 로그가 커질 수 있다 — 스냅샷을 유지하기 위해 과거 버전을 보관해야 하므로
  • Write Skew는 방지하지 못한다

선택해야 하는 경우:

1
2
3
✅ MySQL 환경에서 대부분의 웹 애플리케이션 (기본값)
✅ 트랜잭션 내에서 같은 데이터를 여러 번 읽어야 하는 경우 (정산, 집계)
✅ "읽고 → 판단하고 → 행동하는" 패턴에서 일관된 판단이 필요한 경우

주의해야 하는 시나리오:

1
2
3
4
5
6
7
8
9
10
11
// REPEATABLE READ에서도 주의: 쓰기 스큐(Write Skew)는 방지되지 않는다
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void leaveDuty(Long doctorId) {
    long onCallCount = doctorRepository.countByOnCall(true);
    if (onCallCount >= 2) {
        // 이 시점에 다른 트랜잭션도 동일 조건으로 통과할 수 있다!
        Doctor doctor = doctorRepository.findById(doctorId).orElseThrow();
        doctor.leaveOnCall();
    }
    // → 두 트랜잭션이 동시에 통과하면 당직 의사가 0명이 될 수 있다
}
1
2
3
4
5
6
7
8
9
10
// 해결: 비관적 락으로 직렬화
@Transactional
public void leaveDuty(Long doctorId) {
    // FOR UPDATE로 on_call = true인 행들을 잠근다
    long onCallCount = doctorRepository.countOnCallForUpdate();
    if (onCallCount >= 2) {
        Doctor doctor = doctorRepository.findById(doctorId).orElseThrow();
        doctor.leaveOnCall();
    }
}

5.4 SERIALIZABLE

장점:

  • 모든 이상 현상(Dirty Read, Non-Repeatable Read, Phantom Read, Write Skew)을 방지한다
  • 트랜잭션의 실행 결과가 직렬 스케줄과 동일함을 보장한다
  • 데이터 정합성을 가장 강력하게 보장한다

단점:

  • 동시성이 가장 크게 떨어진다 — MySQL에서는 모든 읽기에 공유 락이 걸린다
  • 락 경합과 데드락 가능성이 가장 높다
  • PostgreSQL SSI에서는 재시도 로직이 필수다 (직렬화 실패 시 애플리케이션이 재시도해야 함)
  • 성능 오버헤드가 크므로 모든 트랜잭션에 적용하면 서비스 처리량이 급격히 떨어진다

선택해야 하는 경우:

1
2
3
4
5
6
7
✅ Write Skew가 절대 허용되지 않는 금융 핵심 로직
✅ 규제 준수를 위해 완벽한 직렬성이 필요한 경우
✅ 데이터 양이 적고 트랜잭션이 짧은 특정 로직에 한정하여 사용

❌ 대부분의 웹 애플리케이션 (과도한 성능 비용)
❌ 읽기가 빈번한 워크로드 (불필요한 공유 락)
❌ 전체 서비스에 일괄 적용 (서비스 처리량 급감)

5.5 격리 수준 선택 의사결정 트리

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
어떤 DBMS를 사용하는가?
│
├── MySQL (InnoDB)
│    │
│    └── 기본값 REPEATABLE READ를 유지한다
│         │
│         ├── 동시 INSERT가 매우 빈번하여 Gap Lock이 병목인가?
│         │    └── 해당 트랜잭션만 READ COMMITTED로 변경 검토
│         │
│         └── Write Skew 방지가 필요한가?
│              └── SERIALIZABLE 또는 비관적 락으로 해결
│
├── PostgreSQL
│    │
│    └── 기본값 READ COMMITTED를 유지한다
│         │
│         ├── 트랜잭션 내 일관된 읽기가 필요한가?
│         │    └── REPEATABLE READ로 변경
│         │
│         └── Write Skew 방지가 필요한가?
│              └── SERIALIZABLE (SSI) + 재시도 로직
│
└── Oracle
     │
     └── 기본값 READ COMMITTED를 유지한다
          │
          └── 일관된 읽기가 필요한가?
               └── SERIALIZABLE (Oracle의 SERIALIZABLE은 SSI 기반)

6. 락(Lock) 기반 동시성 제어

MVCC만으로는 쓰기-쓰기 충돌을 해결할 수 없다. 두 트랜잭션이 동시에 같은 행을 수정하려 할 때는 이 필요하다.

5.1 락의 종류

1
2
3
4
5
6
7
8
9
10
11
12
[InnoDB의 락 계층]

테이블 레벨
├── IS (Intention Shared): "하위에 S Lock을 걸 의향이 있다"
├── IX (Intention Exclusive): "하위에 X Lock을 걸 의향이 있다"
└── AUTO-INC Lock: AUTO_INCREMENT 값 할당 시 사용

행(Row) 레벨
├── Record Lock: 인덱스 레코드에 거는 락
├── Gap Lock: 인덱스 레코드 사이의 간격에 거는 락
├── Next-Key Lock: Record Lock + Gap Lock
└── Insert Intention Lock: INSERT를 위한 Gap Lock의 특수 형태

Intention Lock은 행 레벨 락과 테이블 레벨 락의 충돌을 효율적으로 판단하기 위한 것이다. 예를 들어 한 트랜잭션이 테이블 전체에 LOCK TABLE을 걸려고 할 때, 테이블의 모든 행을 검사하지 않고 IS/IX 락의 존재만 확인하면 된다.

5.2 공유 락과 배타 락

  S Lock 보유 X Lock 보유
S Lock 요청 허용 대기
X Lock 요청 대기 대기
1
2
3
4
5
-- 공유 락: 읽기 보호. 다른 트랜잭션의 수정을 막으면서 읽기
SELECT * FROM account WHERE id = 'A' FOR SHARE;

-- 배타 락: 쓰기 보호. 읽기와 수정 모두 막으면서 읽기
SELECT * FROM account WHERE id = 'A' FOR UPDATE;

5.3 비관적 락 vs 낙관적 락

비관적 락 (Pessimistic Lock): “충돌이 날 것이다”라고 가정하고, 미리 DB 레벨에서 락을 건다.

1
2
3
4
5
6
7
8
9
10
11
12
13
public interface ProductRepository extends JpaRepository<Product, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT p FROM Product p WHERE p.id = :id")
    Optional<Product> findByIdForUpdate(@Param("id") Long id);
}

@Transactional
public void decreaseStock(Long productId, int quantity) {
    Product product = productRepository.findByIdForUpdate(productId)
        .orElseThrow();
    product.decreaseStock(quantity);
    // 트랜잭션이 끝날 때까지 다른 트랜잭션은 이 행을 수정할 수 없다
}

비관적 락의 장점은 확실하다는 것이다. 락을 잡은 동안 다른 트랜잭션은 대기한다. 단점은 동시성이 떨어지고, 데드락의 위험이 있다는 것이다.

낙관적 락 (Optimistic Lock): “충돌은 드물 것이다”라고 가정하고, 커밋 시점에 충돌을 감지한다. DB 레벨 락이 아니라 애플리케이션 레벨의 버전 비교로 구현한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;

    @Version
    private Long version;  // JPA가 자동으로 관리하는 버전 컬럼

    private int stock;

    public void decreaseStock(int quantity) {
        if (this.stock < quantity) {
            throw new InsufficientStockException();
        }
        this.stock -= quantity;
    }
}

JPA가 @Version이 붙은 엔티티를 UPDATE할 때 자동으로 버전을 비교한다.

1
2
3
4
5
6
7
8
-- JPA가 생성하는 SQL
UPDATE product
SET stock = 95, version = 2
WHERE id = 1 AND version = 1;

-- 다른 트랜잭션이 먼저 수정해서 version이 2가 되어 있다면
-- WHERE 조건에 걸리지 않아 0 rows affected
-- → JPA가 OptimisticLockException을 던진다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 낙관적 락 실패 시 재시도 패턴
@Service
public class ProductService {

    @Retryable(
        retryFor = OptimisticLockException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100)
    )
    @Transactional
    public void decreaseStock(Long productId, int quantity) {
        Product product = productRepository.findById(productId).orElseThrow();
        product.decreaseStock(quantity);
    }
}
기준 비관적 락 낙관적 락
충돌 빈도 높을 때 적합 낮을 때 적합
구현 위치 DB 레벨 (SELECT FOR UPDATE) 애플리케이션 레벨 (@Version)
동시성 락 대기로 인해 제한됨 높음 (실패 시 재시도)
데드락 가능 불가능
대표 사례 좌석 예약, 재고 차감, 포인트 사용 게시글 수정, 설정 변경
장점 데이터 정합성 확실 동시성 높음
단점 동시성 제한, 데드락 위험 충돌 시 재시도 비용

6. 데드락(Deadlock)

격리 수준과 락을 다루면 반드시 마주치는 문제가 데드락이다.

6.1 데드락이 발생하는 메커니즘

1
2
3
4
5
6
7
8
9
10
11
12
13
트랜잭션 A                           트랜잭션 B
─────────                          ─────────
BEGIN                              BEGIN

UPDATE account SET balance = ...   UPDATE account SET balance = ...
WHERE id = 1                       WHERE id = 2
→ id=1에 X Lock 획득               → id=2에 X Lock 획득

UPDATE account SET balance = ...   UPDATE account SET balance = ...
WHERE id = 2                       WHERE id = 1
→ id=2의 X Lock 대기...            → id=1의 X Lock 대기...

→ A는 B를 기다리고, B는 A를 기다린다 → 영원히 진행 불가 → 데드락!

6.2 DBMS의 데드락 감지

InnoDB는 Wait-for Graph(대기 그래프)를 유지한다. 트랜잭션 간의 대기 관계를 방향 그래프로 추적하고, 이 그래프에서 사이클이 감지되면 데드락으로 판단한다.

1
2
3
4
5
6
7
Wait-for Graph:
  T1 → T2 → T1  (사이클 발생!)

InnoDB의 대응:
1. 사이클에 참여한 트랜잭션 중 하나를 선택하여 롤백한다
2. 선택 기준: Undo 로그 양이 적은 트랜잭션 (롤백 비용이 적은 쪽)
3. 롤백된 트랜잭션은 ERROR 1213 (ER_LOCK_DEADLOCK) 에러를 받는다

InnoDB의 innodb_deadlock_detect 설정이 ON(기본)이면 즉시 감지한다. OFF로 설정하면 innodb_lock_wait_timeout(기본 50초)까지 대기한 후 타임아웃으로 처리한다.

6.3 데드락 방지 전략

전략 1: 락 획득 순서를 일관되게 유지한다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 항상 ID가 작은 쪽부터 락을 획득한다
    Long firstId = Math.min(fromId, toId);
    Long secondId = Math.max(fromId, toId);

    Account first = accountRepository.findByIdForUpdate(firstId);
    Account second = accountRepository.findByIdForUpdate(secondId);

    if (fromId.equals(firstId)) {
        first.withdraw(amount);
        second.deposit(amount);
    } else {
        second.withdraw(amount);
        first.deposit(amount);
    }
}

전략 2: 트랜잭션을 작게 유지한다

락을 잡고 있는 시간이 짧을수록 데드락 확률이 낮아진다. 트랜잭션 안에서 외부 API 호출, 파일 I/O 등 느린 작업을 수행하지 않는다.

전략 3: 데드락 발생 시 재시도한다

1
2
3
4
5
6
7
8
9
@Retryable(
    retryFor = DeadlockLoserDataAccessException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 50, multiplier = 2)
)
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    // 데드락으로 롤백되면 자동 재시도
}

7. Spring @Transactional 심화

7.1 격리 수준 설정

1
2
3
4
@Transactional(isolation = Isolation.READ_COMMITTED)
public void businessMethod() {
    // READ COMMITTED 수준으로 실행
}

Spring이 제공하는 격리 수준 옵션은 다음과 같다.

1
2
3
4
5
6
7
public enum Isolation {
    DEFAULT(-1),           // DB 기본 격리 수준 사용 (권장)
    READ_UNCOMMITTED(1),
    READ_COMMITTED(2),     // Oracle, PostgreSQL 기본
    REPEATABLE_READ(4),    // MySQL 기본
    SERIALIZABLE(8);
}

DEFAULT를 사용하는 것이 일반적이다. DB의 기본 격리 수준은 해당 DBMS에 최적화되어 있다. 특별한 이유가 없다면 변경하지 않는다.

격리 수준을 코드에서 명시적으로 지정하는 경우는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
// 정산/집계: 트랜잭션 내 일관된 읽기가 반드시 필요
@Transactional(isolation = Isolation.REPEATABLE_READ, readOnly = true)
public BigDecimal calculateDailyRevenue(LocalDate date) {
    // 정산 도중 데이터가 바뀌면 안 된다
}

// PostgreSQL에서 금융 핵심 로직
@Transactional(isolation = Isolation.SERIALIZABLE)
public void processSettlement() {
    // Write Skew까지 방지해야 하는 경우
}

7.2 전파(Propagation)

트랜잭션 전파는 “이미 트랜잭션이 진행 중일 때 새 트랜잭션 요청이 오면 어떻게 처리할 것인가”를 결정한다.

전파 옵션 기존 트랜잭션 있을 때 없을 때 사용 사례
REQUIRED (기본) 기존에 참여 새로 시작 대부분의 서비스 메서드
REQUIRES_NEW 새로 시작 (기존 일시 중단) 새로 시작 로그 저장, 감사 기록
NESTED 중첩 트랜잭션 (세이브포인트) 새로 시작 부분 롤백이 필요한 경우
SUPPORTS 기존에 참여 트랜잭션 없이 실행  
NOT_SUPPORTED 기존 일시 중단 트랜잭션 없이 실행  
MANDATORY 기존에 참여 예외 발생  
NEVER 예외 발생 트랜잭션 없이 실행  

REQUIRES_NEW의 실무 활용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Service
public class OrderService {
    private final OrderRepository orderRepository;
    private final AuditService auditService;

    @Transactional
    public void cancelOrder(Long orderId) {
        Order order = orderRepository.findById(orderId).orElseThrow();
        order.cancel();

        // 감사 로그는 주문 취소가 실패해도 남겨야 한다
        auditService.log("ORDER_CANCELLED", orderId);
    }
}

@Service
public class AuditService {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void log(String action, Long targetId) {
        // 별도의 트랜잭션으로 실행
        // 외부 트랜잭션이 롤백되어도 이 로그는 커밋된다
        auditRepository.save(new AuditLog(action, targetId));
    }
}

REQUIRES_NEW는 기존 트랜잭션과 완전히 독립된 새 트랜잭션을 시작한다. 기존 트랜잭션이 롤백되어도 REQUIRES_NEW 트랜잭션은 영향을 받지 않는다. 감사 로그, 이벤트 기록 등 “메인 로직의 성공/실패와 무관하게 반드시 남겨야 하는 기록”에 사용한다.

7.3 @Transactional 동작 원리와 주의사항

Spring의 @TransactionalAOP 프록시를 통해 동작한다.

1
2
3
4
5
클라이언트 → [프록시] → [실제 빈]
             │
             ├── 메서드 호출 전: 트랜잭션 시작
             ├── 메서드 실행 위임
             └── 메서드 호출 후: 커밋 또는 롤백

이 프록시 기반 구조 때문에 발생하는 대표적인 함정이 있다.

함정 1: 같은 클래스 내부 호출은 프록시를 거치지 않는다

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Service
public class OrderService {

    public void processOrder(Long orderId) {
        // this.validateAndSave()는 프록시를 거치지 않는다!
        // 따라서 @Transactional이 무시된다!
        this.validateAndSave(orderId);
    }

    @Transactional
    public void validateAndSave(Long orderId) {
        // 이 메서드는 외부에서 직접 호출해야 트랜잭션이 적용된다
    }
}

해결 방법은 여러 가지다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 방법 1: 별도 Bean으로 분리 (권장)
@Service
public class OrderService {
    private final OrderValidator orderValidator;

    public void processOrder(Long orderId) {
        orderValidator.validateAndSave(orderId);  // 프록시를 거친다
    }
}

@Service
public class OrderValidator {
    @Transactional
    public void validateAndSave(Long orderId) { }
}

// 방법 2: Self-injection
@Service
public class OrderService {
    @Lazy
    @Autowired
    private OrderService self;

    public void processOrder(Long orderId) {
        self.validateAndSave(orderId);  // 프록시를 거친다
    }

    @Transactional
    public void validateAndSave(Long orderId) { }
}

함정 2: readOnly의 효과를 정확히 이해해야 한다

1
2
3
4
@Transactional(readOnly = true)
public List<Order> findOrders(Long memberId) {
    return orderRepository.findByMemberId(memberId);
}

readOnly = true가 주는 이점은 다음과 같다.

  • JPA/Hibernate: FlushMode를 MANUAL로 설정하여 변경 감지(Dirty Checking)를 생략한다. 스냅샷 비교 비용이 절약된다.
  • JDBC: 일부 드라이버가 Connection에 readOnly 힌트를 전달하여 DB 레벨 최적화를 유도한다.
  • MySQL: Replication 환경에서 읽기 쿼리를 Replica(Slave)로 라우팅하는 설정과 연동할 수 있다.
  • 의도 명시: “이 메서드는 데이터를 변경하지 않는다”는 것을 코드로 표현한다.

주의: readOnly = true라고 해서 실제로 쓰기가 차단되는 것은 아니다. Hibernate는 flush를 생략하지만, Native Query로 UPDATE를 실행하면 그대로 반영된다.

함정 3: 예외 발생 시점과 롤백의 관계

1
2
3
4
5
6
7
8
9
10
11
12
13
@Transactional
public void createOrder(OrderRequest request) {
    Order order = new Order(request);
    orderRepository.save(order);
    // 여기서 예외가 발생하면 save도 롤백된다 — 원자성

    paymentService.pay(order.getTotalAmount());
    // 여기서 예외가 발생하면 save도 롤백된다 — 원자성

    notificationService.notify(order);
    // 여기서 예외가 발생하면 save도 pay도 롤백된다
    // → 결제는 외부 시스템이므로 실제로는 보상 트랜잭션이 필요하다!
}

트랜잭션 안에서 외부 시스템(PG사 결제, 이메일 발송 등)을 호출할 때는 주의가 필요하다. 트랜잭션이 롤백되어도 외부 시스템의 상태는 되돌릴 수 없다. 이런 경우에는 이벤트 기반 처리Saga 패턴을 고려해야 한다.


8. 실무 격리 수준 선택 가이드

대부분의 웹 애플리케이션

1
2
3
DB 기본 격리 수준을 사용한다 (Isolation.DEFAULT)
- MySQL → REPEATABLE READ
- PostgreSQL, Oracle → READ COMMITTED

상황별 전략

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
일반 CRUD
└── DB 기본 격리 수준 + @Transactional

동시 수정이 빈번한 데이터 (재고, 좌석, 포인트)
└── 비관적 락 (@Lock(PESSIMISTIC_WRITE))
    + 데드락 방지를 위한 락 순서 규칙

동시 수정이 드문 데이터 (게시글, 프로필, 설정)
└── 낙관적 락 (@Version)
    + 충돌 시 재시도 로직

트랜잭션 내 일관된 읽기가 필요한 경우 (정산, 집계, 리포트)
└── REPEATABLE READ + readOnly

절대적 정합성이 요구되는 경우 (금융 핵심 로직)
└── SERIALIZABLE (PostgreSQL의 SSI 활용)
    + 재시도 로직 필수

분산 환경에서의 데이터 일관성
└── Saga 패턴 또는 이벤트 기반 보상 트랜잭션
    + Eventual Consistency 수용

체크리스트

1
2
3
4
5
6
7
✅ 모든 @Transactional 메서드에서 예외 시 롤백 동작을 이해하고 있는가?
✅ Checked Exception에 대한 롤백 정책을 명시적으로 지정했는가?
✅ readOnly를 적절히 사용하고 있는가?
✅ 같은 클래스 내부 호출로 인한 프록시 우회 문제가 없는가?
✅ 트랜잭션 안에서 외부 시스템 호출을 하고 있지 않은가?
✅ 비관적 락 사용 시 데드락 방지 전략이 있는가?
✅ 낙관적 락 사용 시 재시도 로직이 있는가?

결론

ACID는 트랜잭션의 안전성을 보장하는 네 가지 기둥이고, 각각은 명확한 구현 메커니즘을 가진다.

원자성은 Undo 로그로, 지속성은 Redo 로그(WAL)로, 격리성은 MVCC와 Lock으로 구현된다. 일관성은 다른 세 속성과 개발자의 비즈니스 로직이 함께 만드는 결과물이다. 특히 Undo 로그가 원자성(롤백)과 격리성(MVCC 버전 체인)을 동시에 지원한다는 점은 DBMS 내부 설계의 핵심적인 통찰이다.

격리 수준은 “안전할수록 좋다”가 아니라, “필요한 만큼만 격리한다”가 올바른 접근이다. READ COMMITTED는 Dirty Read를 막고, REPEATABLE READ는 Non-Repeatable Read를 추가로 막고, SERIALIZABLE은 모든 이상 현상을 막는다. 각 수준은 성능과 안전성의 트레이드오프이며, 비즈니스 요구에 맞는 균형점을 찾아야 한다.

MySQL InnoDB의 REPEATABLE READ가 Gap Lock으로 Phantom Read까지 방지한다는 사실, PostgreSQL의 SERIALIZABLE이 SSI로 높은 동시성과 완전한 격리를 양립한다는 사실은 “SQL 표준의 정의”와 “실제 DBMS의 구현”이 다를 수 있다는 중요한 교훈이다. 격리 수준의 이론뿐 아니라, 사용하는 DBMS의 구체적인 동작을 이해해야 실무에서 올바른 판단을 내릴 수 있다.