04 — 격리 수준

질문: 트랜잭션의 강도를 정하는 4단계 격리는 각각 무엇을 막고 무엇을 허용하며, PostgreSQL과 MySQL이 다른 기본값을 갖는 이유는 무엇인가? 한 줄 답: SQL 표준의 4단계는 3가지 동시성 이상 현상(dirty read · non-repeatable read · phantom read)을 단계적으로 막는다. PostgreSQL은 READ COMMITTED가 기본, MySQL InnoDB는 REPEATABLE READ가 기본 — 같은 단어가 다른 의미를 가진다.


Why — 3가지 동시성 이상 현상이 시작

격리 수준을 이해하려면 그것이 막으려는 현상을 먼저 알아야 한다.

1. Dirty Read (더티 리드)

T1: BEGIN
T1: UPDATE balance = 100 WHERE id = 1
T2: BEGIN
T2: SELECT balance WHERE id = 1  -- 100을 본다 (아직 commit 안 됨!)
T1: ROLLBACK  -- T1의 변경은 사라졌는데 T2는 그걸 봤음

아직 commit되지 않은 데이터를 다른 트랜잭션이 본다.

2. Non-Repeatable Read (반복 불가능한 읽기)

T1: BEGIN
T1: SELECT balance WHERE id = 1  -- 100
T2: BEGIN; UPDATE balance = 200 WHERE id = 1; COMMIT
T1: SELECT balance WHERE id = 1  -- 200 — 같은 트랜잭션 안에서 값이 바뀜!
T1: COMMIT

같은 row를 한 트랜잭션 안에서 두 번 읽었는데 값이 다르다.

3. Phantom Read (팬텀 리드)

T1: BEGIN
T1: SELECT count(*) FROM orders WHERE user_id = 1  -- 5
T2: BEGIN; INSERT INTO orders (user_id) VALUES (1); COMMIT
T1: SELECT count(*) FROM orders WHERE user_id = 1  -- 6 — 새 row가 끼어들었음!
T1: COMMIT

같은 조건의 집합을 두 번 읽었는데 새 row가 추가/삭제됐다.


How — SQL 표준의 4단계

수준Dirty ReadNon-RepeatablePhantom동시성
READ UNCOMMITTED⚠️ 허용⚠️ 허용⚠️ 허용최고
READ COMMITTED✅ 차단⚠️ 허용⚠️ 허용높음
REPEATABLE READ✅ 차단✅ 차단⚠️ 허용 (SQL 표준)중간
SERIALIZABLE✅ 차단✅ 차단✅ 차단최저

What — TypeORM에서의 지정

// 방법 1: dataSource.transaction의 첫 인자
await AppDataSource.transaction('SERIALIZABLE', async (manager) => {
  // ...
});
 
// 방법 2: QueryRunner.startTransaction의 인자
const qr = AppDataSource.createQueryRunner();
await qr.connect();
await qr.startTransaction('REPEATABLE READ');
try {
  // ...
  await qr.commitTransaction();
} finally {
  await qr.release();
}
 
// 방법 3: typeorm-transactional 데코레이터 옵션
@Transactional({ isolationLevel: IsolationLevel.SERIALIZABLE })
async transfer() { /* ... */ }

TypeORM이 받는 문자열 리터럴은 4개 + READ UNCOMMITTED:

type IsolationLevel =
  | 'READ UNCOMMITTED'
  | 'READ COMMITTED'
  | 'REPEATABLE READ'
  | 'SERIALIZABLE';

내부적으로는 *해당 DB의 SET TRANSACTION ISOLATION LEVEL*을 BEGIN 직후에 보낸다.


What — PostgreSQL vs MySQL의 다른 기본값

DB기본 격리 수준비고
PostgreSQLREAD COMMITTEDREAD UNCOMMITTED 지정 시 자동으로 READ COMMITTED로 승격
MySQL InnoDBREPEATABLE READgap locking으로 phantom도 사실상 차단
MS SQL ServerREAD COMMITTEDsnapshot 옵션 있음
OracleREAD COMMITTEDSERIALIZABLE도 가능
SQLiteSERIALIZABLE (사실상)write가 DB 전체를 lock하므로 다른 동시 트랜잭션 불가

이 차이가 production에서 큰 함정이다 — dev에서 MySQL을 썼다가 prod에서 PostgreSQL로 가면 같은 코드가 다른 격리에서 동작한다.


What — PostgreSQL의 진짜 동작

PostgreSQL은 MVCC(Multi-Version Concurrency Control)로 격리를 구현한다. 각 트랜잭션은 자기만의 스냅샷을 본다.

수준PostgreSQL의 실제 동작
READ UNCOMMITTED내부적으로 READ COMMITTED로 승격 — 더티 리드 발생 불가
READ COMMITTED각 *문장(statement)*이 자기 시점의 스냅샷을 본다
REPEATABLE READ트랜잭션 시작 시점의 스냅샷을 트랜잭션 끝까지 유지. phantom도 사실상 차단 (SQL 표준 초과)
SERIALIZABLEREPEATABLE READ + Serializable Snapshot Isolation (SSI). write skew도 차단

핵심 함정:

// PostgreSQL · REPEATABLE READ
await AppDataSource.transaction('REPEATABLE READ', async (manager) => {
  await manager.update(Account, { id: 1 }, { balance: 100 });
  // 다른 트랜잭션이 같은 row를 동시에 업데이트하면 → "could not serialize access" 에러
});

PostgreSQL의 REPEATABLE READ는 충돌 시 한쪽이 자동 ROLLBACK된다. 앱은 retry 로직을 준비해야 한다.

async function withRetry<T>(fn: () => Promise<T>, max = 3): Promise<T> {
  for (let i = 0; i < max; i++) {
    try {
      return await fn();
    } catch (err: any) {
      if (err.code === '40001' /* serialization_failure */) continue;
      throw err;
    }
  }
  throw new Error('Max retries exceeded');
}

What — MySQL InnoDB의 진짜 동작

MySQL InnoDB도 MVCC를 쓰지만 동작 방식이 다르다.

수준InnoDB의 실제 동작
READ UNCOMMITTEDdirty read 허용 (PostgreSQL과 달리 진짜로 동작)
READ COMMITTED각 문장이 자기 시점의 스냅샷. gap lock 없음
REPEATABLE READ (default)트랜잭션 시작 시점의 스냅샷. 그러나 write 시 gap lock으로 phantom도 차단
SERIALIZABLE모든 plain SELECTSELECT … LOCK IN SHARE MODE로 변환

핵심 함정 — Gap Lock:

-- InnoDB · REPEATABLE READ (default)
T1: BEGIN
T1: SELECT * FROM orders WHERE user_id = 1 FOR UPDATE  -- gap lock 획득
T2: INSERT INTO orders (user_id) VALUES (1)  -- ⏳ T1의 gap lock에 막혀 대기
T1: COMMIT  -- T2가 진행됨

MySQL의 gap lock은 생산성에 영향을 준다. batch insert서로 다른 트랜잭션끼리 데드락을 만드는 일이 흔하다.


What — 격리 수준 선택 가이드

유스케이스권장 수준비고
단순 CRUDREAD COMMITTEDDB 기본을 따른다 (PG 기본)
잔액 이체 / 카운터REPEATABLE READ또는 SELECT FOR UPDATE
좌석/재고 예약SERIALIZABLEretry 필수
보고서 / 분석READ COMMITTEDstaleness 허용
감사 로그READ COMMITTEDappend-only

What-if — 비관적 락 vs 격리 수준

격리 수준만으로 부족할 때 명시적 락을 쓴다.

// PostgreSQL: SELECT ... FOR UPDATE
await AppDataSource.transaction(async (manager) => {
  const account = await manager
    .createQueryBuilder(Account, 'a')
    .where('a.id = :id', { id: '1' })
    .setLock('pessimistic_write') // FOR UPDATE
    .getOne();
 
  // 이 트랜잭션이 끝날 때까지 다른 트랜잭션의 update/lock 차단
  account.balance -= 100;
  await manager.save(account);
});

TypeORM의 lock 모드:

setLock 인자SQL의미
'pessimistic_read'FOR SHARE (PG) / LOCK IN SHARE MODE (MySQL)다른 read 허용, write 차단
'pessimistic_write'FOR UPDATE다른 read·write 모두 차단
'pessimistic_partial_write'FOR UPDATE SKIP LOCKED (PG 9.5+)잠긴 row는 건너뜀 — 큐 패턴
'pessimistic_write_or_fail'FOR UPDATE NOWAIT잠긴 row 있으면 즉시 에러
'for_no_key_update'FOR NO KEY UPDATE (PG)FK 검사용 부분 락
'optimistic'@VersionColumn 기반version 컬럼으로 stale write 감지

핵심 선택 가이드:

  • 충돌이 흔하다 (잔액·재고) → pessimistic_write + READ COMMITTED.
  • 충돌이 드물다 (사용자 프로필 업데이트) → optimistic + READ COMMITTED.
  • 큐 워커가 row를 하나씩 가져간다pessimistic_partial_write (SKIP LOCKED).

What-if — 낙관적 락의 메커니즘

@Entity()
class Order {
  @PrimaryGeneratedColumn('uuid')
  id: string;
 
  @Column()
  status: string;
 
  @VersionColumn()
  version: number; // ← 자동 증가
}
const order = await orderRepo.findOne({ where: { id } });
order.status = 'paid';
await orderRepo.save(order);
// UPDATE order SET status = 'paid', version = version + 1
//   WHERE id = ? AND version = ?
// → version이 일치하지 않으면 0 rows affected
// → TypeORM이 OptimisticLockVersionMismatchError 던짐

언제 낙관적:

  • 충돌 빈도 < 5%.
  • 사용자가 오래 들고 있는 폼을 저장할 때 (다른 사용자가 그 사이에 바꿨을 수 있음).
  • 분산 시스템에서 락 전파가 비싸거나 불가능할 때.

Insight — 기본값이 다르다가 만드는 문제

가장 흔한 production 사고:

  • dev MySQL에서 짠 코드가 prod PostgreSQL에서 동시성 버그를 일으킨다.
  • ORM이 같은 표현(@Transaction, transaction())을 쓰니 DB가 같아 보인다는 착각.
  • 사고가 드물게 발생 → 데이터가 천천히 손상.

팀 규칙:

  1. 명시적으로 격리 수준을 지정한다 — transaction('READ COMMITTED', cb).
  2. dev DB와 prod DB를 같게 한다 — 둘 다 PostgreSQL, 또는 둘 다 MySQL.
  3. 동시성 테스트를 CI에 추가한다 — 같은 트랜잭션 시나리오를 병렬로 실행.

흥미로운 이야기 — SERIALIZABLE이 진짜로 등장한 건 2008년

SQL 표준은 1992년에 4단계 격리를 정의했지만 — 진정한 의미의 SERIALIZABLE은 오랫동안 두 가지 방식 중 하나로만 구현됐다:

  1. 2PL (Two-Phase Locking) — 모든 read/write에 락. 동시성 거의 0.
  2. 자칭 SERIALIZABLE — 실제로는 SNAPSHOT 정도.

2008년 PostgreSQL 9.1이 *Serializable Snapshot Isolation (SSI)*을 도입했다. 읽고 쓰기 의존성 그래프를 동시에 추적하면서 사이클이 생기는 순간만 한쪽을 abort. 락 없이 진짜 SERIALIZABLE을 달성한 첫 mainstream DB였다.

이게 왜 PostgreSQL의 SERIALIZABLE이 retry 가능 에러를 던지는지에 대한 답이다 — 락이 아니라 그래프 검사이기 때문. 충돌이 나중에 발견된다.


요약 (Pyramid Top 재정렬)

4단계 격리는 3가지 동시성 이상을 단계적으로 막는다.

  1. **READ COMMITTED**가 대부분 OLTP의 정답 — PostgreSQL의 기본.
  2. REPEATABLE READ — 같은 row를 여러 번 읽을 때. PG에서는 phantom도 사실상 차단.
  3. SERIALIZABLE조건 + 쓰기가 같이 있는 경우(좌석 예약). retry 로직 필수.
  4. PG와 MySQL의 기본값이 다르다. dev/prod의 DB를 같게 하는 게 가장 안전하다.
  5. 격리 수준만으로 부족하면 setLock — 비관적/낙관적 락을 명시.

다음 문서는 이 트랜잭션을 실제로 들고 다니는 객체QueryRunner의 내부를 파헤친다.

다음: 05 — QueryRunner 심화 — 연결 한 개를 통째로 들고 있는 객체.