본문으로 건너뛰기
목록으로

DB 트랜잭션 격리 수준 — 이상 현상부터 DB별 구현·스프링 적용까지

Johny Cho
Software Engineer @ Kurly

여러 트랜잭션이 동시에 같은 데이터를 읽고 쓰면 서로 간섭해 이상한 결과가 나옵니다. 완전히 격리(한 줄로 줄 세워 실행)하면 안전하지만 동시성이 떨어져 느립니다. 그래서 DB는 "어디까지 간섭을 허용할지"를 격리 수준(isolation level) 이라는 몇 단계로 구분합니다 — 격리 수준이 높을수록 안전하지만 동시성은 떨어집니다. ACID의 I(격리성)에 해당합니다. 이 글은 이상 현상 → 표준 4단계 → DB별 구현 → 스프링 적용 순서로 살펴봅니다.

격리가 약하면 생기는 이상 현상

세 가지가 대표적입니다. 각각 "무엇이 문제인지"를 두 트랜잭션의 타임라인으로 봅니다.

① Dirty Read — 커밋 안 된 값을 읽음

PlantUML 코드
@startuml
title Dirty Read — 커밋 안 된 값을 읽음
participant "트랜잭션 A" as A
participant "재고 행 (100)" as DB
participant "트랜잭션 B" as B
A -> DB : UPDATE 재고 = 90 (커밋 전)
B -> DB : SELECT 재고 → 90 읽음
A -> DB : ROLLBACK (재고 다시 100)
note over B : B는 존재한 적 없는 90을 읽음 = dirty read
@enduml

PlantUML Dirty Read 시퀀스

커밋되지 않아 언제든 되돌려질 수 있는 값을 읽는 것이 dirty read입니다.

② Non-repeatable Read — 같은 행을 두 번 읽었는데 값이 바뀜

PlantUML 코드
@startuml
title Non-repeatable Read — 두 번 읽었는데 값이 바뀜
participant "트랜잭션 A" as A
participant "잔액 행" as DB
participant "트랜잭션 B" as B
A -> DB : SELECT 잔액 → 1000
B -> DB : UPDATE 잔액 = 500
B -> DB : COMMIT
A -> DB : SELECT 잔액 → 500
note over A : 같은 행을 다시 읽었더니 값이 달라짐
@enduml

PlantUML Non-repeatable Read 시퀀스

한 트랜잭션 안에서 같은 행을 두 번 읽었는데, 그 사이 다른 트랜잭션의 수정이 커밋돼 값이 달라지는 경우입니다.

③ Phantom Read — 같은 조건인데 행 수가 바뀜

PlantUML 코드
@startuml
title Phantom Read — 같은 조건인데 행 수가 바뀜
participant "트랜잭션 A" as A
participant "orders 테이블" as DB
participant "트랜잭션 B" as B
A -> DB : SELECT count(*) amount>100 → 3건
B -> DB : INSERT 새 주문 (amount 150)
B -> DB : COMMIT
A -> DB : SELECT count(*) amount>100 → 4건
note over A : 같은 조건인데 없던 행이 나타남
@enduml

PlantUML Phantom Read 시퀀스

non-repeatable read가 한 행의 값이 바뀌는 문제라면, phantom read는 조건에 맞는 행의 집합이 바뀌는(새 행이 끼어드는) 문제입니다.

SQL 표준 4단계

격리 수준이 높아질수록 위 현상을 더 많이 막지만, 그만큼 동시성은 떨어집니다. SQL 표준은 4단계로 정의합니다.

격리 수준Dirty ReadNon-repeatable ReadPhantom Read
READ UNCOMMITTED가능가능가능
READ COMMITTED차단가능가능
REPEATABLE READ차단차단가능(표준상)
SERIALIZABLE차단차단차단
격리 수준이 높을수록(표에서 아래로 갈수록) 안전하지만 잠금·검증 비용이 커져 동시 처리량이 줄어듭니다 — "얼마나 안전해야 하나"와 "얼마나 빨라야 하나"의 트레이드오프입니다.

구현은 DB마다 다르다

여기서 핵심 오해가 하나 있습니다. 같은 이름의 격리 수준이라도 DB마다 실제 막아 주는 범위가 다릅니다.

  • MySQL (InnoDB)
    • 기본: REPEATABLE READ
    • REPEATABLE READ에서 next-key 락(gap 락) 으로 새 행이 끼어드는 것까지 막아 phantom read도 차단합니다(표준 초과).
    • READ COMMITTED로 낮추면 gap 락이 없어 phantom이 다시 생길 수 있습니다.
  • PostgreSQL
    • 기본: READ COMMITTED
    • REPEATABLE READ스냅샷 격리(Snapshot Isolation)phantom read도 막습니다(표준 최소치 초과).
    • READ UNCOMMITTED를 요청해도 내부적으론 READ COMMITTED로 동작 → 실제로는 3단계.
    • SERIALIZABLESSI(Serializable Snapshot Isolation)입니다.
  • Oracle
    • 기본: READ COMMITTED
    • 전용 REPEATABLE READ·READ UNCOMMITTED 레벨이 없습니다.
    • SERIALIZABLE(스냅샷 기반)과 READ ONLY(읽기 전용) 모드를 제공합니다.
DB기본 격리REPEATABLE READSERIALIZABLE
MySQL(InnoDB)REPEATABLE READ있음 — next-key 락으로 phantom까지 차단있음(잠금 기반)
PostgreSQLREAD COMMITTED있음 — 스냅샷 격리, phantom 차단SSI
OracleREAD COMMITTED없음(전용 레벨 X)있음(스냅샷)

그래서 "REPEATABLE READ로 맞췄다"는 말은 DB를 밝히지 않으면 정확하지 않습니다 — MySQL의 RR과 Oracle(RR 없음)은 동작이 아예 다릅니다. (근거: PostgreSQL 문서 · MySQL 문서 · Oracle 문서)

DB별 라이선스·성격 차이는 MySQL·PostgreSQL·Oracle 비교에서 다뤘습니다.

REPEATABLE READ는 데드락이 더 잘 생길까 — MySQL의 gap 락

MySQL InnoDB에서는 그렇습니다. REPEATABLE READ가 phantom을 막으려고 쓰는 gap(next-key) 락이 잠금 대기·데드락을 늘립니다. RR은 "행"만이 아니라 "그 행 앞의 빈 구간(gap)" 까지 잠가 새 행이 끼어드는 것을 막는데, 구간을 잠그다 보니 충돌 범위가 넓어집니다.

여기서 중요한 점 하나. gap 락은 특정 값이 아니라, 인접한 두 인덱스 레코드 사이의 "구간" 전체에 걸립니다. 예를 들어 테이블에 id = 10id = 20만 있으면 그 사이 10~20이 하나의 gap이고, 이 구간 안의 어떤 값(15든 17이든)으로 없는 행을 잠그면 모두 같은 gap 락을 잡습니다.

문제가 되는 조합은 gap 락 + INSERT입니다. gap 락끼리는 서로 공존할 수 있지만, INSERT가 잡는 insert intention 락은 남이 보유한 gap 락과 충돌해 대기합니다.

-- (테이블엔 id = 10, 20만 있음 → 그 사이 10~20이 하나의 gap)
-- 세션 A -- 세션 B
BEGIN; BEGIN;
SELECT * FROM t WHERE id = 15 FOR UPDATE; SELECT * FROM t WHERE id = 17 FOR UPDATE;
-- 15는 (10,20) gap → 그 gap에 락 -- 17도 같은 (10,20) gap → 같은 gap에 락(공존)
INSERT INTO t(id) VALUES (15); INSERT INTO t(id) VALUES (17);
-- B가 보유한 gap 락 때문에 대기 -- A가 보유한 gap 락 때문에 대기 → 데드락!

즉 15와 17은 값은 다르지만 둘 다 같은 gap(10~20) 안이라 같은 gap 락을 공유합니다. 그래서 각자 그 gap에 INSERT하려는 순간 서로의 gap 락을 기다려 데드락이 됩니다.

PlantUML 코드
@startuml
title MySQL gap 락 데드락
participant "세션 A" as A
participant "id 10~20 구간 (빈 gap)" as G
participant "세션 B" as B
A -> G : SELECT id=15 FOR UPDATE (없음) → gap 락
B -> G : SELECT id=17 FOR UPDATE (없음) → gap 락 (공존)
A -> G : INSERT id=15 → B의 gap 락 대기
B -> G : INSERT id=17 → A의 gap 락 대기
note over A, B : 서로의 gap 락을 기다림 → 데드락 (한쪽 롤백)
@enduml

PlantUML MySQL gap 락 데드락

READ COMMITTED로 낮추면 검색·인덱스 스캔에서 gap 락이 비활성화(외래키·중복키 검사에만 사용)되어, 이런 gap 데드락이 크게 줄어듭니다. 대신 phantom read가 다시 가능해지는 트레이드오프가 있습니다.

  • 잠금 대기·데드락이 자주 생긴다 → READ COMMITTED를 검토(단, phantom 재등장 감안). 실제로 gap 락 부담을 줄이려 RC로 운영하는 팀이 많습니다.
  • RR을 유지한다면 → 트랜잭션이 여러 행을 항상 같은 순서로 접근하고, 범위 조건보다 유니크 키 동등 조건을 써 gap 경합을 줄입니다.
  • 이건 MySQL InnoDB 특유입니다 — PostgreSQL·Oracle은 스냅샷 MVCC라 gap 락이 없어 이 데드락은 없습니다(대신 SERIALIZABLE에서는 직렬화 실패가 나 재시도로 처리). 근거: InnoDB Locking.

접근 순서·인덱스 때문에 생기는 또 다른 데드락은 세컨더리 인덱스와 데드락에서 다뤘습니다.

스프링에서 격리 수준이 로직에 미치는 영향

스프링에서는 @Transactional(isolation = ...) 으로 격리 수준을 지정합니다. 같은 로직도 격리 수준에 따라 결과가 달라질 수 있습니다. 잔액을 한 트랜잭션 안에서 두 번 조회하는 예를 보겠습니다.

@Service
@RequiredArgsConstructor
public class BalanceService {

private final AccountRepository accountRepository;

// READ COMMITTED — 매 조회가 "그 시점의 최신 커밋"을 본다
@Transactional(isolation = Isolation.READ_COMMITTED)
public boolean isStable(Long id) {
int first = accountRepository.findBalance(id); // 예: 1000
heavyWork(); // 이 사이 다른 Tx가 500으로 UPDATE·COMMIT
int second = accountRepository.findBalance(id); // 500 ← 값이 달라짐
return first == second; // false (non-repeatable read)
}

// REPEATABLE READ — 트랜잭션 시작 시점의 스냅샷을 끝까지 유지
@Transactional(isolation = Isolation.REPEATABLE_READ)
public boolean isStableRR(Long id) {
int first = accountRepository.findBalance(id); // 1000
heavyWork(); // 다른 Tx가 커밋해도
int second = accountRepository.findBalance(id); // 1000 ← 그대로
return first == second; // true
}
}
READ COMMITTED에서는 한 트랜잭션 안에서도 조회할 때마다 값이 달라질 수 있어, "읽은 값이 트랜잭션 내내 유지된다"고 가정한 로직(집계·검증·조건 분기)이 깨질 수 있습니다.

주의할 점 하나 — 스프링 기본값은 "DB 기본값"이다

격리 수준을 지정하지 않으면 Isolation.DEFAULT이고, 이는 연결된 DB의 기본값을 그대로 따릅니다.

@Transactional  // = @Transactional(isolation = Isolation.DEFAULT) = DB 기본값
public boolean report(Long id) { ... }

그래서 똑같은 코드가 DB에 따라 다르게 동작합니다 — MySQL(기본 REPEATABLE READ) 에서는 두 조회가 일치하지만, PostgreSQL·Oracle(기본 READ COMMITTED) 에서는 흔들릴 수 있습니다. 격리 수준에 의존하는 로직이라면 명시적으로 지정해 DB가 바뀌어도 같은 동작을 보장해야 합니다.

REPEATABLE READ도 못 막는 것 — Lost Update

격리 수준을 높여도 "읽고 → 계산 → 쓰기" 패턴의 경합은 막지 못합니다.

// REPEATABLE READ라도 아래 경합(lost update)은 그대로 발생
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void withdraw(Long id, int amount) {
int balance = accountRepository.findBalance(id); // 1000 (스냅샷)
// 그 사이 다른 Tx가 300을 인출·커밋 → 실제 잔액은 700
accountRepository.updateBalance(id, balance - amount); // 1000 - 200 = 800 으로 덮어씀
// → 다른 Tx의 300 인출이 사라짐 (lost update)
}

스냅샷은 "읽은 값"을 고정할 뿐, 그 값을 근거로 한 쓰기가 남의 쓰기를 덮어쓰는 것은 막지 않습니다. 이때는 잠금이 필요합니다.

// 해결 1) 비관적 락 — 조회 시점에 행을 잠가 다른 Tx를 대기시킨다 (SELECT ... FOR UPDATE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select a from Account a where a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);
// 해결 2) 낙관적 락 — @Version으로 충돌을 감지해 재시도한다
@Entity
class Account {
@Version
private long version; // 갱신 시 버전이 다르면 예외 → 잡아서 재시도
}

격리 수준은 "읽기 일관성"을 다루고, 동시 갱신 경합은 잠금(비관적·낙관적) 으로 따로 막아야 합니다. 잠금이 얽혀 생기는 데드락은 인덱스와 데드락에서 다뤘습니다.

실무에서는 어떻게 정하나

  • 대부분의 웹 서비스는 기본값(READ COMMITTED 또는 REPEATABLE READ)으로 충분합니다. 무작정 SERIALIZABLE로 올리면 잠금·재시도가 늘어 처리량이 떨어집니다.
  • 한 트랜잭션 내내 읽은 값이 고정돼야 하는 로직(집계 리포트, 여러 번 참조하는 검증)은 REPEATABLE READ가 안전합니다.
  • 금전·재고처럼 불변식이 깨지면 안 되는 갱신은 격리 수준만 믿지 말고 명시적 잠금(FOR UPDATE)이나 낙관적 락(@Version), 또는 원자적 UPDATE(SET stock = stock - 1 WHERE stock >= 1)로 처리합니다.

정리

  • 격리 수준은 dirty·non-repeatable·phantom read를 어디까지 막을지의 단계입니다(READ UNCOMMITTED → SERIALIZABLE).
  • 같은 이름이라도 DB마다 다릅니다 — MySQL(기본 RR·phantom까지 차단), PostgreSQL(기본 RC·RR은 스냅샷), Oracle(기본 RC·RR 없음).
  • 스프링 @Transactional의 기본은 DB 기본값을 따르므로, 격리에 의존하는 로직은 수준을 명시한다.
  • 격리 수준으로 읽기 일관성을 잡고, 동시 갱신 경합(lost update) 은 잠금으로 따로 막는다.