infra
Platform

모듈 맵

[Database] 트랜잭션 격리 수준(Isolation Level)과 이상 현상 제어

0 / 37 완료

펼치기
0 / 37 완료0%

데이터베이스 설계 & 튜닝 · 32 / 37

[Database] 트랜잭션 격리 수준(Isolation Level)과 이상 현상 제어

READ COMMITTED vs REPEATABLE READ — 동시 요청이 많아질 때 발생하는 데이터 불일치의 원인과 격리 수준 선택 기준

🚨INCIDENT ALERT
HIGH

동시에 실행되는 요청이 같은 데이터를 읽고 쓰면 눈에 보이지 않는 경쟁 조건이 생깁니다. 트랜잭션 격리 수준을 모르면 테스트에서는 통과한 로직이 운영에서 중복 처리나 잔액 오류를 냅니다. 격리 수준별 현상을 이해하면 필요한 만큼만 강하게 잠글 수 있습니다.

이번 챕터에서 배울 것

격리 수준을 외우는 게 목표가 아닙니다. "언제 어떤 버그가 생기고, 어떻게 방지하는가"를 이해하면 됩니다.

  • 1READ UNCOMMITTED부터 SERIALIZABLE까지 4가지 격리 수준을 구분할 수 있다
  • 2더티 리드, 비반복 읽기, 팬텀 리드가 발생하는 조건을 설명할 수 있다
  • 3MySQL InnoDB와 PostgreSQL의 기본 격리 수준 차이와 실무 영향을 파악할 수 있다
  • 4SELECT FOR UPDATE로 읽기에 잠금을 거는 실전 패턴을 적용할 수 있다
  • 5서비스 유형에 맞춰 적절한 격리 수준을 선택할 수 있다

트랜잭션 격리 수준 — 더티 리드, 팬텀 리드, 동시성 제어

동시 요청이 10개에서 1,000개로 늘어나는 순간, 지금까지 없던 버그가 나타납니다. 잔액이 음수가 되거나, 방금 INSERT한 데이터가 조회되지 않거나, 같은 쿼리가 두 번 다른 결과를 돌려줍니다. 트랜잭션 격리 수준은 이 문제들이 언제, 왜 발생하는지 정의하는 규칙입니다.


실습 환경 준비

💡개념

4가지 격리 수준과 발생 가능한 문제

재고 차감 트랜잭션이 두 개 동시에 실행됩니다. 둘 다 현재 재고를 읽고, 둘 다 1씩 차감합니다. 결과적으로 재고가 2 줄어야 하는데 1만 줄어들었습니다. 두 트랜잭션이 서로의 변경을 보지 못한 채 동시에 같은 값을 읽었기 때문입니다. 격리 수준은 이런 동시성 문제를 얼마나 방지할지를 결정합니다. 높을수록 안전하지만 성능이 떨어지고, 낮을수록 빠르지만 이상 현상이 생깁니다.

격리 수준은 "동시에 실행 중인 트랜잭션이 서로의 변경을 얼마나 볼 수 있는가"를 결정합니다. 높을수록 안전하지만 성능이 떨어집니다. 아래 표에서 어느 수준에서 어떤 문제가 방지되는지 한눈에 볼 수 있습니다.

격리 수준더티 리드비반복 읽기팬텀 리드비고
READ UNCOMMITTED발생발생발생
READ COMMITTED방지발생발생PostgreSQL 기본
REPEATABLE READ방지방지발생MySQL InnoDB 기본
SERIALIZABLE방지방지방지

더티 리드 (Dirty Read): 아직 커밋되지 않은 다른 트랜잭션의 변경을 읽음 비반복 읽기 (Non-Repeatable Read): 같은 행을 두 번 SELECT했을 때 다른 트랜잭션의 COMMIT으로 결과가 달라짐 팬텀 리드 (Phantom Read): 같은 WHERE 조건으로 두 번 SELECT했을 때 결과 행 수가 달라짐

SQL
-- 현재 격리 수준 확인
-- PostgreSQL
SHOW transaction_isolation;

-- MySQL
SELECT @@transaction_isolation;

-- 세션 단위로 격리 수준 변경
-- PostgreSQL
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

-- MySQL
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
OUTPUT
실행 완료 또는 조회 결과가 표시됩니다.
🔍실행 후 확인할 것
  • 읽기 순서현재 격리 수준 확인(SELECT @@transaction_isolation) → 이상 현상 발생 여부 확인 → 락 대기 여부 순으로 점검합니다.
  • 격리 수준별 판단 기준READ UNCOMMITTED: 프로덕션 절대 사용 금지(Dirty Read 발생) / READ COMMITTED: 일반 OLTP에 적합(PostgreSQL 기본) / REPEATABLE READ: 장기 트랜잭션·정산 배치 권장(MySQL 기본) / SERIALIZABLE: 금융·결제 시스템 전용(성능 희생)
  • 조합 해석 — MVCC 히스토리격리 수준 REPEATABLE READ + 긴 트랜잭션(수십 분 이상) → MVCC 버전 히스토리 누적 → ibdata1(MySQL) 또는 dead tuple(PostgreSQL) 증가 → 주기적 커밋 또는 배치 분할 필요.
  • 조합 해석 — SERIALIZABLE 경합SERIALIZABLE + 높은 동시 접속(100 TPS 이상) → 경합 급증 → 처리량 저하 및 직렬화 실패 에러(SQLSTATE 40001 serialization_failure, 'could not serialize access'). 데드락(40P01)과는 다른 코드이며, 앱에서 40001은 재시도로 처리. 대부분 REPEATABLE READ + SELECT FOR UPDATE로 대체 가능.
  • 락 대기강한 격리 수준에서 대기나 데드락 가능성이 생기는지 점검합니다. pg_blocking_pids() 또는 SHOW ENGINE INNODB STATUS로 락 보유 현황을 확인합니다.

트랜잭션이 도는 동안 DB 안에서 — MVCC 단계별 메커니즘

💡개념

트랜잭션 하나가 도는 동안 무슨 일이 — BEGIN부터 COMMIT까지 MVCC 5단계

격리 수준 표는 '어느 수준에서 무슨 이상현상이 막히나'를 알려주지만, 왜 그렇게 되는지는 트랜잭션 한 건이 DB 안에서 도는 방식을 봐야 이해됩니다. 그 핵심 장치가 MVCC입니다 — 값을 덮어쓰지 않고 '버전'으로 쌓아 두고, 각 트랜잭션에 '보이는 스냅샷'을 정해 줍니다. BEGIN부터 COMMIT까지의 이 흐름을 따라가면, 더티/비반복/팬텀 리드가 각 수준에서 왜 생기거나 막히는지 표를 외우지 않고 설명할 수 있습니다.

TEXT
BEGIN;
   │
   ① 트랜잭션 시작: 트랜잭션 ID(xid) 부여 + '내가 볼 스냅샷' 결정
   │      스냅샷 = 이 시점에 이미 커밋돼 있는 버전들의 경계선
   │
   ② 변경(UPDATE/DELETE): 기존 행을 그 자리서 덮어쓰지 않음
   │      옛 버전은 그대로 두고 '새 버전'을 추가(내 xid로 표시)
   │      아직 COMMIT 전 → 다른 트랜잭션에는 안 보임
   │
   ③ 읽기(SELECT): 스냅샷 규칙에 따라 '보여야 하는 버전'만 읽음
   │      READ COMMITTED  : 문장마다 '그 순간' 최신 커밋 스냅샷을 새로 잡음
   │      REPEATABLE READ : 트랜잭션 시작 시점 스냅샷을 끝까지 유지
   │
   ④ COMMIT: 내가 만든 새 버전이 '커밋됨'으로 확정 → 이후 시작하는 트랜잭션에 보임
   │      ROLLBACK: 새 버전을 버림 → 옛 버전이 계속 유효(되돌림)
   │
   ⑤ 뒷정리: 아무 스냅샷에도 안 보이게 된 옛(죽은) 버전을 VACUUM/purge가 회수
   ▼

격리 수준별로 ③에서 어떤 스냅샷을 보고, 그래서 어떤 이상현상이 생기거나 막히나:

격리 수준③에서 보는 스냅샷이상현상 — 왜
READ UNCOMMITTED커밋 안 된 새 버전까지 봄(표준상) — 실제 PostgreSQL은 RC로 승격, InnoDB도 거의 안 씀더티 리드 발생 — ②의 미커밋 새 버전을 읽어서
READ COMMITTED매 문장마다 최신 커밋 스냅샷을 새로 잡음더티 막힘(커밋된 것만 봄), 비반복 읽기 발생 — 두 SELECT 사이의 COMMIT이 다음 문장 스냅샷에 반영돼서
REPEATABLE READ트랜잭션 시작 스냅샷을 끝까지 고정비반복 읽기까지 막힘 — 끝까지 같은 스냅샷. (팬텀은 표준상 가능하나 InnoDB·PostgreSQL은 상당 부분 방지)
SERIALIZABLE스냅샷 + 직렬화 충돌 감지팬텀까지 막힘 — 직렬 실행과 같은 결과 보장. 충돌 시 40001로 실패시키고 재시도 유도

락 vs MVCC. 전통적 잠금 기반은 '읽는 동안 못 쓰게, 쓰는 동안 못 읽게' 막아 이상현상을 없애지만 동시성을 죽입니다. MVCC는 읽기가 스냅샷을 보므로(③) 읽기-쓰기가 서로를 막지 않고도 더티·비반복 리드를 없앱니다 — 이게 MVCC의 이점입니다. 다만 한 가지 구멍이 있습니다: '읽은 값을 근거로 다시 쓰는'(재고·잔액 차감) 패턴에서는 두 트랜잭션이 같은 옛 스냅샷을 읽고 각자 차감해 lost update(갱신 유실)가 날 수 있습니다. 스냅샷 읽기만으론 이걸 못 막으므로, 이때는 SELECT ... FOR UPDATE로 그 행에 명시적 잠금을 걸어 두 번째 트랜잭션을 기다리게 해야 합니다 — MVCC 위에 락을 얹는 지점입니다.

그래서 격리 수준을 고른다는 건 사실상 '③에서 어떤 스냅샷을 보게 할까'를 정하는 일입니다. 이상현상이 보이면 그 수준의 스냅샷 규칙을 떠올리고, 잔액이 음수가 되는 lost update가 보이면 ④ 이전에 명시적 락이 빠졌는지를 점검하면 됩니다.


💡개념

READ COMMITTED vs REPEATABLE READ — 실제로 뭐가 다른가

같은 트랜잭션 내에서 SELECT balance FROM accounts WHERE id = 1을 두 번 실행할 때:

READ COMMITTED vs REPEATABLE READ — 실제로 뭐가 다른가확대

SQL
-- [터미널 A] 트랜잭션 시작
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- → 10000 반환

-- [터미널 B] 이 시점에 다른 트랜잭션이 업데이트하고 커밋
UPDATE accounts SET balance = 5000 WHERE id = 1;
COMMIT;

-- [터미널 A] 같은 쿼리를 다시 실행
SELECT balance FROM accounts WHERE id = 1;
-- READ COMMITTED → 5000  (다른 트랜잭션의 COMMIT이 즉시 반영)
-- REPEATABLE READ → 10000  (트랜잭션 시작 시점 스냅샷 유지)

실무 영향: 서비스 성격에 따라 적절한 격리 수준이 다릅니다.

상황권장 격리 수준
일반 웹 서비스 CRUDREAD COMMITTED
재고·잔액 차감REPEATABLE READ + SELECT FOR UPDATE
정산·회계 배치REPEATABLE READ
순서 보장이 절대적인 금융SERIALIZABLE (드물게)
SQL
-- 재고 차감 안전 패턴 (SELECT FOR UPDATE)
BEGIN;

-- 행 잠금: 다른 트랜잭션이 이 행을 수정하면 여기서 대기
SELECT stock FROM products WHERE id = 42 FOR UPDATE;

-- 재고 충분할 때만 차감
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;

COMMIT;

💡개념

MVCC — 잠금 없이 동시성 확보하는 원리

읽기 트랜잭션이 많은 테이블에서 쓰기가 발생하면 잠금 경합이 생깁니다. 읽기가 쓰기를 막거나, 쓰기가 읽기를 막으면 동시 요청이 많아질수록 성능이 떨어집니다. 그런데 실제 PostgreSQL은 읽기와 쓰기가 서로를 차단하지 않습니다. MVCC 덕분에 읽기는 항상 일관된 스냅샷을 보고, 쓰기는 동시에 진행됩니다. 이 원리를 알면 잠금 없이도 일관성을 확보하는 이유를 이해할 수 있습니다.

MVCC — 잠금 없이 동시성 확보하는 원리확대

PostgreSQL과 MySQL InnoDB는 MVCC(Multi-Version Concurrency Control)로 읽기-쓰기 충돌을 줄입니다.

트랜잭션 A (읽기)                트랜잭션 B (쓰기)
-----
BEGIN;                           BEGIN;
SELECT balance → 10000           UPDATE balance = 5000;
(스냅샷에서 읽음,                 (새 버전 생성,
 B의 변경과 충돌 없음)             A의 읽기를 차단 안 함)
                                 COMMIT;
SELECT balance → 10000           (구 버전은 VACUUM이
(여전히 스냅샷 값)                 나중에 정리)
COMMIT;

MVCC 덕분에 읽기는 쓰기를 막지 않고, 쓰기는 읽기를 막지 않습니다. 배치 집계 쿼리가 실행 중이어도 OLTP 트랜잭션이 차단되지 않는 이유가 이것입니다.


1비반복 읽기(non-repeatable read)를 두 터미널로 직접 재현

터미널 두 개를 띄워, 같은 행을 한 트랜잭션이 두 번 읽는 사이 다른 트랜잭션이 COMMIT하면 값이 달라지는지 확인합니다. 그다음 격리 수준만 REPEATABLE READ로 바꿔 같은 시나리오에서 값이 고정되는지 비교합니다.

SQL
-- 터미널 A: READ COMMITTED — 비반복 읽기 발생
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 10000

-- (여기서 터미널 B: UPDATE accounts SET balance = 5000 WHERE id = 1; COMMIT;)

SELECT balance FROM accounts WHERE id = 1;  -- 5000 (같은 트랜잭션인데 달라짐!)
COMMIT;

-- 터미널 A: REPEATABLE READ — 스냅샷 고정으로 방지
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- 10000
-- (터미널 B에서 동일하게 UPDATE 후 COMMIT)
SELECT balance FROM accounts WHERE id = 1;  -- 10000 (스냅샷 유지)
COMMIT;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; SELECT balance FROM accounts WHERE id = 1;
🔍실행 후 확인할 것
  • READ COMMITTED 블록에서 두 번째 SELECT가 5000으로 바뀌었는지 본다 — 바뀌면 비반복 읽기가 재현된 것(같은 트랜잭션 안에서 같은 쿼리가 다른 값을 반환)
  • REPEATABLE READ 블록에서 두 번째 SELECT가 여전히 10000인지 확인 — 그대로면 트랜잭션 시작 시점 스냅샷이 유지된 것(MVCC). 5000으로 바뀌면 격리 수준이 적용 안 된 것
  • 터미널 B의 UPDATE가 끝나도 COMMIT을 안 하면 A는 옛 값을 본다 — '변경 후 COMMIT까지' 했는지가 재현 조건이다
  • MySQL 기본값은 REPEATABLE READ, PostgreSQL 기본값은 READ COMMITTED — SHOW transaction_isolation 으로 현재 세션 기본값을 먼저 확인한다

SERIALIZABLE에서 직렬화 실패

로컬 터미널
# 증상: SERIALIZABLE 격리 수준에서 UPDATE 시 에러
ERROR:  could not serialize access due to concurrent update
DETAIL:  Process 1234 updated row in relation "accounts" while process 5678 was reading it.

# 원인: 두 트랜잭션이 직렬 실행 불가능한 순서로 같은 데이터에 접근

# 해결: 애플리케이션 레벨에서 재시도(Retry) 로직 구현
# SERIALIZABLE은 실패 트랜잭션을 자동으로 롤백하므로
# BEGIN ... COMMIT 블록을 재시도해야 함

# 일반 서비스에서는 SERIALIZABLE 대신 REPEATABLE READ + FOR UPDATE가 실용적

동시 요청으로 인한 데이터 불일치

SQL
-- 증상: 재고 1개인데 동시 요청 2개가 모두 차감에 성공

-- 원인: 잠금 없이 읽고 쓰는 패턴
SELECT stock FROM products WHERE id = 42;  -- 1 읽음
-- (동시 요청도 1을 읽고 차감 성공)
UPDATE products SET stock = stock - 1 WHERE id = 42;  -- 0으로

-- 해결: SELECT FOR UPDATE로 읽기 시점에 잠금
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- 두 번째 요청은 이 지점에서 대기
UPDATE products SET stock = stock - 1 WHERE id = 42 AND stock > 0;
COMMIT;
-- 두 번째 요청이 실행되면 stock=0이므로 UPDATE가 0행 영향

심화 — MVCC의 청구서: 오래된 스냅샷이 청소를 막는다

💡개념

심화: 죽지 않는 옛 버전 — 오래된 스냅샷 하나가 DB 전체를 붓게 한다

앞에서 MVCC가 "옛 버전을 남겨 읽기와 쓰기를 분리한다"고 배웠습니다. 그런데 그 옛 버전은 공짜가 아닙니다. 누군가는 치워야 하고, 그 청소를 방해하는 것이 바로 '오래 열린 트랜잭션'입니다. 격리 수준 표를 넘어, MVCC가 실제로 어떤 청구서를 남기는지 봐야 운영 장애를 예방할 수 있습니다.

  • VACUUM은 '아직 누가 볼지 모르는' 버전은 못 지운다: PostgreSQL의 VACUUM(오토배큠 포함)은 현재 살아 있는 가장 오래된 스냅샷의 경계선(xmin 지평선)보다 이전에 죽은 튜플만 회수합니다. 그 경계보다 최근에 죽은 튜플은 "아직 누군가의 스냅샷에 보일 수 있으니" 남겨 둡니다.
  • 오래 열린 트랜잭션이 경계선을 붙든다: 몇 시간째 이어지는 리포트 쿼리, 또는 커밋을 잊고 떠 있는 idle in transaction 세션은 자기 스냅샷의 xmin을 계속 붙들어 이 경계선을 과거에 못 박습니다. 그 뒤로 아무리 UPDATE·DELETE가 일어나 죽은 튜플이 쌓여도 VACUUM이 회수하지 못합니다.
  • 한 세션이 DB 전체를 붓게 한다: 무서운 점은 이 경계가 그 트랜잭션이 건드린 테이블만이 아니라 사실상 DB 전반에 적용된다는 것입니다. 전혀 무관한 테이블조차 dead tuple이 쌓여 부풀고(bloat), 인덱스가 커지며, 같은 SELECT가 점점 더 많은 페이지를 읽어 느려집니다. 잠금이 아니라 '청소를 막는' 문제라 조용히 전체가 느려집니다.
  • 끝까지 방치하면 wraparound: 회수되지 못한 옛 트랜잭션이 계속 쌓이면 결국 트랜잭션 ID 소진(wraparound) 경고가 뜨고, 최악의 경우 쓰기를 막는 보호 모드로 들어갑니다. 즉 '오래된 스냅샷 하나'는 성능 문제를 넘어 가용성 문제로 번질 수 있습니다.

그래서 성숙한 팀은 idle_in_transaction_session_timeout으로 잊힌 트랜잭션을 자동 종료시키고, 장시간 집계는 읽기 복제본으로 보내며, ORM·커넥션 풀이 작업 후 즉시 커밋하는지 점검합니다. MVCC의 혜택을 누리려면 그 청구서를 제때 갚아야 합니다.

상황: 새 배치 기능을 배포한 뒤로 응답 시간이 매일 조금씩 늘어납니다. 특정 쿼리 하나가 아니라 서비스 전반이 느려졌고, 모니터링을 보면 여러 테이블의 dead tuple(n_dead_tup)이 계속 증가합니다. autovacuum은 멈추지 않고 도는데도 청소가 안 됩니다.

원인: 새 배치가 트랜잭션을 열고(BEGIN) 무거운 작업을 하는 동안, 중간의 외부 API 호출이 느려 세션이 오래 idle in transaction 상태로 떠 있었습니다. 이 세션이 잡은 옛 스냅샷의 xmin이 VACUUM 회수 경계선을 과거에 붙들어, 그 시점 이후 죽은 튜플을 DB 전반에서 회수하지 못했습니다. autovacuum은 부지런히 돌지만 "지울 수 있는 게 없어서" 헛돕니다.

진단: SELECT pid, state, xact_start, query FROM pg_stat_activity ORDER BY xact_start;로 xact_start가 유독 오래된 세션과 state가 idle in transaction인 세션을 찾습니다. pg_stat_activity의 backend_xmin이 가장 오래된 백엔드가 범인입니다. pg_stat_user_tables에서 n_dead_tup이 회수 안 되고 쌓이는지 함께 확인합니다.

해결: 급하면 그 롱 트랜잭션을 커밋·종료(정당하면 완료를 기다리고, 방치된 것이면 terminate)해 경계선을 전진시키면 다음 VACUUM이 밀린 dead tuple을 회수합니다. 재발 방지가 핵심입니다 — idle_in_transaction_session_timeout을 걸어 잊힌 트랜잭션을 자동 종료하고, 트랜잭션 안에서 외부 API 호출·긴 대기를 하지 않으며(트랜잭션은 짧게), 장시간 집계는 읽기 복제본으로 분리합니다. bloat가 이미 심하면 한적한 시간에 VACUUM 또는 pg_repack으로 공간을 정리합니다.


💼
실무 맥락
현업 패턴

실무에서 격리 수준을 다루는 순간

서비스별 권고 격리 수준: 서비스 유형별로 기본 격리 수준을 설정하는 예시입니다.

SQL
-- 대부분의 웹 서비스 (READ COMMITTED, PostgreSQL 기본)
-- 소셜 피드, 게시판, 상품 조회 → 잠금 없이 최신 데이터 빠르게 읽기

-- 결제/재고 서비스 (REPEATABLE READ + FOR UPDATE)
-- 티켓 예약, 재고 차감, 포인트 사용

-- 정산 배치 (REPEATABLE READ, MySQL InnoDB 기본)
-- 월말 정산, 통계 집계 → 집계 중 데이터 변경으로 인한 불일치 방지

-- MySQL에서 격리 수준 확인
SELECT @@global.transaction_isolation;   -- 전역 기본값
SELECT @@session.transaction_isolation;  -- 현재 세션

-- PostgreSQL에서 특정 트랜잭션만 격리 수준 변경
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
-- ... 중요한 집계 쿼리
COMMIT;

ORM 사용 시 주의점: ORM은 격리 수준을 추상화하지만 내부적으로는 동일한 DB 트랜잭션이 실행됩니다.

Python
# Django: 트랜잭션 격리 수준 설정
DATABASES = {
    'default': {
        'OPTIONS': {
            'isolation_level': 'read committed',  # 명시적 설정
        }
    }
}

# Spring: @Transactional에 격리 수준 지정
@Transactional(isolation = Isolation.REPEATABLE_READ)
public void processPayment(Long accountId, int amount) {
    Account account = accountRepository.findByIdForUpdate(accountId);
    account.deduct(amount);
}

MySQL과 PostgreSQL의 기본 격리 수준이 다르므로, 두 DB를 모두 지원하는 서비스는 명시적으로 격리 수준을 설정해야 합니다.

💡개념

데드락 — 진단과 회피 패턴

두 트랜잭션이 서로 상대방의 잠금이 풀리기를 기다리다가 영원히 진행되지 못하는 상태가 데드락입니다. 주문 서비스에서 A 트랜잭션이 orders → order_items 순으로 잠금을 잡고, B 트랜잭션이 order_items → orders 순으로 잠금을 잡으면 서로를 기다리다가 DB가 한쪽을 강제 롤백합니다. 애플리케이션 로그에 갑작스러운 트랜잭션 실패가 보이면 데드락을 의심해야 합니다. 진단 방법과 회피 패턴을 알면 데드락은 예방 가능한 문제입니다.

데드락 발생과 회피 — TX1이 id=1 잠금 후 id=2 대기, TX2가 id=2 잠금 후 id=1 대기하면 순환 대기로 데드락(DB가 한쪽 자동 롤백). 회피: 일관된 잠금 순서(항상 작은 id부터)·SELECT FOR UPDATE로 미리 잠금·짧은 트랜잭션. SHOW ENGINE INNODB STATUS로 진단확대

데드락 발생 원리

트랜잭션 A                          트랜잭션 B
-----
BEGIN;                               BEGIN;
UPDATE orders SET ... WHERE id=1;    UPDATE order_items SET ... WHERE id=10;
-- orders 행 잠금 획득                -- order_items 행 잠금 획득

UPDATE order_items SET ... WHERE id=10;  UPDATE orders SET ... WHERE id=1;
-- order_items 잠금 대기 (B가 보유)      -- orders 잠금 대기 (A가 보유)

-- 서로를 기다리다 DB가 데드락 감지 → 한쪽을 롤백

PostgreSQL 데드락 진단

SQL
-- 현재 대기 중인 잠금 확인
SELECT
    blocked.pid          AS blocked_pid,
    blocked.query        AS blocked_query,
    blocking.pid         AS blocking_pid,
    blocking.query       AS blocking_query
FROM pg_stat_activity AS blocked
JOIN pg_stat_activity AS blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

-- 데드락 발생 시 로그 확인 (postgresql.conf)
-- log_lock_waits = on
-- deadlock_timeout = 1s  (1초 이상 잠금 대기 시 로그)

MySQL 데드락 진단

위험 명령어서버 재시작 없이 즉시 전체 MySQL 인스턴스에 적용됩니다

MySQL 전역 설정 변경

안전한 실행 조건: my.cnf에도 동일하게 추가해야 재시작 후에도 유지됩니다

실행 전 반드시 확인

  • 운영 DB가 아닌 테스트 환경에서 먼저 검증했는가
  • 변경 내용을 팀과 공유했는가
  • 로그 파일 디스크 용량이 충분한가
SET GLOBAL innodb_print_all_deadlocks = ON

위 항목을 모두 확인한 후 복사할 수 있습니다

SQL
-- 가장 최근 데드락 상세 정보 확인
SHOW ENGINE INNODB STATUS\G
-- "LATEST DETECTED DEADLOCK" 섹션에서
-- 어떤 트랜잭션이 어떤 행의 잠금을 가지고 있었는지 확인

-- 데드락 로그 자동 기록 활성화
SET GLOBAL innodb_print_all_deadlocks = ON;
-- /var/log/mysql/error.log 에 기록됨

데드락 회피 3원칙

① 잠금 획득 순서를 일관되게 유지 모든 트랜잭션이 항상 같은 순서로 테이블/행을 잠그도록 코드 규칙을 만듭니다.

Python
# 나쁜 예: 트랜잭션마다 잠금 순서가 다름
# 트랜잭션 A: account_a 잠금 → account_b 잠금
# 트랜잭션 B: account_b 잠금 → account_a 잠금

# 좋은 예: 항상 id 오름차순으로 잠금
def transfer(from_id, to_id, amount):
    first_id, second_id = sorted([from_id, to_id])
    # first_id 행 잠금 → second_id 행 잠금 (일관된 순서)
    with transaction():
        first = Account.objects.select_for_update().get(id=first_id)
        second = Account.objects.select_for_update().get(id=second_id)

② 트랜잭션을 짧게 유지 잠금을 오래 보유할수록 데드락 가능성이 높아집니다. 트랜잭션 안에서 외부 API 호출, 파일 I/O, 긴 연산을 하지 않습니다.

③ 데드락 재시도 로직 구현 데드락은 완전히 제거하기 어렵습니다. 애플리케이션에서 데드락 에러(ERROR 1213 MySQL, ERROR 40P01 PostgreSQL)를 감지해 자동 재시도하는 것이 현실적입니다.

Python
from sqlalchemy.exc import OperationalError
import time

def execute_with_retry(session, func, max_retries=3):
    for attempt in range(max_retries):
        try:
            return func(session)
        except OperationalError as e:
            if 'deadlock' in str(e).lower() and attempt < max_retries - 1:
                session.rollback()
                time.sleep(0.1 * (attempt + 1))  # 점진적 대기
                continue
            raise

명령어·구문 빠른 참조

이 모듈에서 다룬 격리 수준·잠금 관련 구문을 실전 조합과 함께 모았습니다. "예" 열의 조합을 그대로 써도 됩니다.

구문/명령용도
SHOW transaction_isolation현재 격리 수준 확인(PostgreSQL)SHOW transaction_isolation;read committed
SELECT @@transaction_isolation현재 격리 수준 확인(MySQL)세션 SELECT @@session.transaction_isolation; / 전역 @@global.transaction_isolation
SET TRANSACTION ISOLATION LEVEL격리 수준 변경(PostgreSQL)SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SET SESSION TRANSACTION ISOLATION LEVEL세션 격리 변경(MySQL)SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN ... COMMIT트랜잭션 시작·확정격리 실습은 터미널 2개로 동시 재현: BEGIN; ... COMMIT;
BEGIN + SET TRANSACTION ...특정 트랜잭션만 격리 상향BEGIN; SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; ... COMMIT;
SELECT ... FOR UPDATE조회 행 잠금(재고·잔액)SELECT stock FROM products WHERE id=42 FOR UPDATE;UPDATE ... WHERE id=42 AND stock>0
pg_blocking_pids()누가 누구를 막는지(PostgreSQL)... ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
SHOW ENGINE INNODB STATUS최근 데드락 상세(MySQL)SHOW ENGINE INNODB STATUS\G → "LATEST DETECTED DEADLOCK" 섹션
SET GLOBAL innodb_print_all_deadlocks데드락 로그 자동 기록(MySQL)SET GLOBAL innodb_print_all_deadlocks = ON; (my.cnf에도 추가)
idle_in_transaction_session_timeout잊힌 트랜잭션 자동 종료오래된 idle in transaction이 VACUUM 경계선을 붙들 때 설정

관련 모듈로 더 깊이:

다음 모듈에서는 낙관적 락(Optimistic Lock)과 비관적 락의 차이, 그리고 동시성 충돌 제어 전략을 다룹니다.

지식 확인

퀴즈 — 8문제

Q1

PostgreSQL의 기본 격리 수준은 READ COMMITTED인데, 같은 트랜잭션 내에서 SELECT를 두 번 실행했을 때 결과가 다를 수 있다. 이 현상은?

Q2

결제 서비스에서 '잔액 조회 → 잔액 차감' 두 쿼리 사이에 다른 요청이 같은 잔액을 동시에 차감해 잔액이 음수가 됐다. 이를 방지하는 가장 적절한 방법은?

Q3

MySQL(InnoDB) 기본 격리 수준이 REPEATABLE READ인데 PostgreSQL은 READ COMMITTED다. 같은 코드가 두 DB에서 다르게 동작할 수 있는 시나리오는?

Q4

대용량 배치 작업(100만 행 집계)이 긴 시간 동안 실행 중일 때 다른 OLTP 트랜잭션을 막지 않으려면 어떤 전략이 맞는가?

Q5

트랜잭션 격리 수준을 높이면 발생하는 trade-off는?

Q6

PostgreSQL·MySQL(InnoDB)이 읽기와 쓰기가 서로를 거의 막지 않으면서도 일관된 스냅샷을 보장하는 핵심 기법인 MVCC는 어떻게 동작하나?

Q7

[심화] MVCC는 옛 행 버전을 남겼다가 VACUUM으로 회수한다. 그런데 커밋도 롤백도 안 한 채 몇 시간째 'idle in transaction'으로 떠 있는 세션 하나가 DB 전체의 쿼리를 서서히 느리게 만들 수 있는 이유는?

Q8

[심화] 특정 배포 이후 DB 전체가 서서히 느려지고, autovacuum은 쉬지 않고 도는데 여러 테이블의 dead tuple(n_dead_tup)이 줄기는커녕 계속 늘어난다. 원인을 짚고 해결할 가장 적절한 순서는?

0 / 8 답변

🧪 실습으로 확인하기

트랜잭션 락·데드락 — 격리수준·lock_timeout·SKIP LOCKED

고급

데드락을 재현하고, 행 접근 순서 고정으로 예방한다. lock_timeout으로 무한 대기를 막고, SELECT FOR UPDATE NOWAIT/SKIP LOCKED로 경합 회피 패턴을 익힌다.

45📋 3단계💻 직접 환경
실습 시작하기 →

이것도 배워보세요