5. 트랜잭션 격리수준·락·MVCC, 동시성 이상현상
난이도 중모범답안 — 트랜잭션 격리수준·락·MVCC, 동시성 이상현상
난이도: 중
핵심 답변
격리수준과 이상현상
| 격리수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 가능 | 가능 | 가능 |
| READ COMMITTED | 차단 | 가능 | 가능 |
| REPEATABLE READ | 차단 | 차단 | 가능(표준)* |
| SERIALIZABLE | 차단 | 차단 | 차단 |
- Dirty Read: 커밋되지 않은 다른 트랜잭션의 변경을 읽음.
- Non-repeatable Read: 같은 행을 트랜잭션 내에서 두 번 읽었는데 값이 달라짐(중간에 다른 트랜잭션이 커밋).
- Phantom Read: 같은 조건의 범위 쿼리를 두 번 했는데 결과 행 집합이 달라짐(다른 트랜잭션이 행 삽입/삭제).
- (*) MySQL InnoDB의 REPEATABLE READ는 갭 락/넥스트키 락으로 팬텀을 상당 부분 막는 등 구현체마다 표준과 차이가 있다.
비관적 vs 낙관적 락
- 비관적 락: 먼저 락을 잡고 작업("충돌이 잦을 것이다" 가정).
SELECT ... FOR UPDATE가 대표. 경합이 잦고 트랜잭션이 짧을 때 유리. - 낙관적 락: 락 없이 진행하고 커밋 시점에 충돌(버전/타임스탬프)을 검사해 충돌이면 재시도("충돌이 드물 것이다" 가정). 경합이 드물고 읽기 위주일 때 유리.
MVCC: 행을 in-place로 덮어쓰지 않고 버전(스냅샷)을 남긴다. 각 트랜잭션은 자신이 시작한 시점의 일관된 스냅샷을 읽으므로, 읽기는 다른 트랜잭션의 쓰기 락을 기다리지 않는다("읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않는다"). 단 쓰기끼리는 여전히 충돌하고, write skew 같은 직렬화 이상은 남는다.
깊이 있는 설명 (왜, 트레이드오프)
격리수준은 "정합성 vs 성능"의 다이얼이다. 수준이 높을수록 보이는 이상현상이 줄지만, 락 범위가 넓어지고(또는 직렬화 검증 비용이 늘고) 동시성이 떨어진다. 그래서 무조건 SERIALIZABLE을 쓰지 않고, 정합성이 critical한 트랜잭션만 선택적으로 올리거나 명시적 락을 건다.
MVCC가 막지 못하는 것: write skew. 두 트랜잭션이 각각 스냅샷을 읽고(서로의 미커밋 변경을 못 봄) 서로 다른 행을 갱신하는데, 합쳐 보면 불변식이 깨지는 경우다. 예: "당직 의사는 최소 1명"이라는 제약에서 두 의사가 동시에 자기 스냅샷을 보고 "다른 한 명이 있으니 나는 빠져도 되겠다"며 동시에 빠지면 0명이 된다. 스냅샷 격리(MySQL/Postgres RR)에서도 발생하며, SERIALIZABLE(또는 Postgres의 SSI, 명시적 락, 충돌 행에 대한 물리화)로만 막힌다. 면접에서 "REPEATABLE READ면 모든 동시성 문제가 해결되나요?"에 "아니오, write skew가 남는다"라고 답할 수 있으면 강하다.
FOR UPDATE의 본질. SELECT ... FOR UPDATE는 읽은 행에 배타적(쓰기) 락을 걸어 다른 트랜잭션이 그 행을 수정/잠금하지 못하게 한다. 즉 MVCC의 비잠금 읽기를 의도적으로 잠금 읽기로 바꿔 "읽고 → 검증하고 → 갱신"하는 read-modify-write를 안전하게 직렬화하는 도구다. 비관적 락에 해당한다.
낙관적 락의 재시도 비용. 버전 컬럼을 두고 UPDATE ... SET ..., version=version+1 WHERE id=? AND version=?로 갱신한 뒤 영향 행 수가 0이면 누군가 먼저 바꾼 것이므로 재시도한다. 경합이 심하면 재시도 폭주(라이브락 유사)로 오히려 비관적 락보다 느려진다. 경합 빈도가 선택의 핵심.
응용/실무 연결 (게임서버에서)
(a) READ COMMITTED에서의 double-sell 인터리빙
- T1(구매자A):
SELECT status FROM item WHERE id=10→ 'ON_SALE' 읽음. - T2(구매자B):
SELECT status FROM item WHERE id=10→ 'ON_SALE' 읽음(아직 아무도 커밋 안 함). - T1: 골드 차감,
UPDATE item SET status='SOLD', owner=A→ 커밋. - T2: (이미 ON_SALE으로 봤으므로) 골드 차감,
UPDATE item SET status='SOLD', owner=B→ 커밋. → 같은 매물이 두 명에게 팔리고, 마지막 쓰기가 이김(lost update). READ COMMITTED는 "읽은 값이 그 사이 안 바뀐다"를 보장하지 않으므로 이 read-then-write 패턴을 막지 못한다.
(b) 세 가지 해법 적용
- 격리수준 상향(SERIALIZABLE): 거래를 직렬화해 double-sell을 막지만, 거래소 전체 처리량이 크게 떨어지고 데드락/직렬화 실패 재시도가 늘어난다. 거래소처럼 동시성이 중요한 곳엔 과하다.
- 비관적 락(FOR UPDATE):
SELECT status FROM item WHERE id=10 FOR UPDATE로 매물 행을 잠근 뒤 status를 확인하고 갱신. 위 인터리빙에서 T2는 T1이 커밋할 때까지 대기 후 'SOLD'를 보고 실패 처리. 명확하고 견고하다. - 낙관적 락(조건부 UPDATE):
UPDATE item SET status='SOLD', owner=A WHERE id=10 AND status='ON_SALE'로 갱신하고 영향 행 수가 1이면 성공, 0이면 "이미 팔림" 처리. 락 없이 단일 원자적 UPDATE로 double-sell을 차단한다. - 선택: 거래소는 한 매물에 짧은 read-modify-write가 몰리는 구조다. "조건부 UPDATE(낙관적) + 영향 행 수 검사"가 가장 깔끔하다 — 락 대기 없이 원자적으로 승자를 1명만 정하고, 골드 차감/소유권 이전은 그 트랜잭션 안에서 함께 커밋한다. 다만 골드 차감도
WHERE gold >= price조건부로 묶어야 동시에 안전하다.
(c) 핫 로우 완화
- 대기열 직렬화: 핫 매물 구매를 단일 워커/파티션으로 라우팅해 순차 처리(락 경합 자체를 없앰).
- 빠른 실패 + 재시도 백오프: 조건부 UPDATE 실패 시 즉시 "품절" 응답해 락을 오래 잡지 않게.
- 재고 분할(스톡 샤딩): 수량이 많은 동일 아이템이면 재고를 N개 버킷으로 쪼개 경합을 분산.
- 인메모리 선점: Redis 원자 연산(
DECR/SET NX)으로 먼저 "예약"을 선점해 DB 트랜잭션 진입자를 1명으로 줄이고, DB는 최종 정합성 가드로.
흔한 오답·함정
- "REPEATABLE READ면 모든 동시성 문제 해결": write skew, (표준상) 팬텀이 남는다.
- ACID의 Isolation을 SERIALIZABLE과 동일시: Isolation은 수준 선택이 가능한 스펙트럼이다.
SELECT만으로 안전하다고 가정: 일반 SELECT는 락을 안 걸어(MVCC 비잠금 읽기) read-then-write 레이스를 막지 못한다.FOR UPDATE또는 조건부 UPDATE가 필요.- 낙관적 락을 고경합에 사용: 재시도 폭주로 처리량 붕괴.
- MVCC를 "락이 전혀 없다"로 오해: 쓰기-쓰기 충돌과 명시적 락은 여전히 존재한다.
꼬리질문 대비
-
Q: Phantom Read와 Non-repeatable Read의 차이는? A: Non-repeatable은 "같은 행"의 값이 달라지는 것, Phantom은 "같은 조건의 행 집합"에 행이 생기거나 사라지는 것. 그래서 팬텀 방지는 범위 락(갭 락/넥스트키 락)이 필요하다.
-
Q: 데드락은 왜 생기고 어떻게 처리하나요? A: 두 트랜잭션이 서로가 잡은 락을 엇갈려 기다릴 때 발생. DB가 사이클을 감지해 한쪽을 희생(rollback)시키며, 앱은 잠금 순서 통일·짧은 트랜잭션·재시도 로직으로 완화한다.
-
Q: 낙관적 락의 version 컬럼 대신 무엇을 쓸 수 있나요? A: 마지막 수정 타임스탬프, 또는 갱신할 모든 컬럼을 WHERE에 넣어 "내가 읽은 값 그대로일 때만 갱신"하는 방식. 핵심은 커밋 시점 충돌 검출.