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 Read | Non-Repeatable | Phantom | 동시성 |
|---|---|---|---|---|
| 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 | 기본 격리 수준 | 비고 |
|---|---|---|
| PostgreSQL | READ COMMITTED | READ UNCOMMITTED 지정 시 자동으로 READ COMMITTED로 승격 |
| MySQL InnoDB | REPEATABLE READ | gap locking으로 phantom도 사실상 차단 |
| MS SQL Server | READ COMMITTED | snapshot 옵션 있음 |
| Oracle | READ COMMITTED | SERIALIZABLE도 가능 |
| SQLite | SERIALIZABLE (사실상) | 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 표준 초과) |
| SERIALIZABLE | REPEATABLE 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 UNCOMMITTED | dirty read 허용 (PostgreSQL과 달리 진짜로 동작) |
| READ COMMITTED | 각 문장이 자기 시점의 스냅샷. gap lock 없음 |
| REPEATABLE READ (default) | 트랜잭션 시작 시점의 스냅샷. 그러나 write 시 gap lock으로 phantom도 차단 |
| SERIALIZABLE | 모든 plain SELECT가 SELECT … 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 — 격리 수준 선택 가이드
| 유스케이스 | 권장 수준 | 비고 |
|---|---|---|
| 단순 CRUD | READ COMMITTED | DB 기본을 따른다 (PG 기본) |
| 잔액 이체 / 카운터 | REPEATABLE READ | 또는 SELECT FOR UPDATE |
| 좌석/재고 예약 | SERIALIZABLE | retry 필수 |
| 보고서 / 분석 | READ COMMITTED | staleness 허용 |
| 감사 로그 | READ COMMITTED | append-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가 같아 보인다는 착각. - 사고가 드물게 발생 → 데이터가 천천히 손상.
팀 규칙:
- 명시적으로 격리 수준을 지정한다 —
transaction('READ COMMITTED', cb). - dev DB와 prod DB를 같게 한다 — 둘 다 PostgreSQL, 또는 둘 다 MySQL.
- 동시성 테스트를 CI에 추가한다 — 같은 트랜잭션 시나리오를 병렬로 실행.
흥미로운 이야기 — SERIALIZABLE이 진짜로 등장한 건 2008년
SQL 표준은 1992년에 4단계 격리를 정의했지만 — 진정한 의미의 SERIALIZABLE은 오랫동안 두 가지 방식 중 하나로만 구현됐다:
- 2PL (Two-Phase Locking) — 모든 read/write에 락. 동시성 거의 0.
- 자칭 SERIALIZABLE — 실제로는 SNAPSHOT 정도.
2008년 PostgreSQL 9.1이 *Serializable Snapshot Isolation (SSI)*을 도입했다. 읽고 쓰기 의존성 그래프를 동시에 추적하면서 사이클이 생기는 순간만 한쪽을 abort. 락 없이 진짜 SERIALIZABLE을 달성한 첫 mainstream DB였다.
이게 왜 PostgreSQL의 SERIALIZABLE이 retry 가능 에러를 던지는지에 대한 답이다 — 락이 아니라 그래프 검사이기 때문. 충돌이 나중에 발견된다.
요약 (Pyramid Top 재정렬)
4단계 격리는 3가지 동시성 이상을 단계적으로 막는다.
- **
READ COMMITTED**가 대부분 OLTP의 정답 — PostgreSQL의 기본.REPEATABLE READ— 같은 row를 여러 번 읽을 때. PG에서는 phantom도 사실상 차단.SERIALIZABLE— 조건 + 쓰기가 같이 있는 경우(좌석 예약). retry 로직 필수.- PG와 MySQL의 기본값이 다르다. dev/prod의 DB를 같게 하는 게 가장 안전하다.
- 격리 수준만으로 부족하면
setLock— 비관적/낙관적 락을 명시.다음 문서는 이 트랜잭션을 실제로 들고 다니는 객체인
QueryRunner의 내부를 파헤친다.
다음: 05 — QueryRunner 심화 — 연결 한 개를 통째로 들고 있는 객체.