데드락

English

두 트랜잭션이 서로가 쥔 락을 기다리며 영원히 멈추는 상태입니다.

흔히 "동시성이 높으면 나는 것"으로 뭉뚱그리는데, 실제로는 네 가지 조건이 전부 동시에 성립해야만 납니다. 그래서 대응도 "데드락을 없앤다"가 아니라 "넷 중 하나를 깬다" 가 됩니다.

네 가지 조건

조건
상호 배제락 하나는 동시에 한 쪽만 가집니다
Hold and wait자원을 쥔 채로 다른 자원을 기다립니다
비선점남이 강제로 뺏을 수 없습니다
순환 대기A→B→C→…→A 순으로 서로 기다립니다

앞의 셋은 락이라는 도구의 성질에 가깝습니다. 락인데 여럿이 동시에 가질 수 있다면 그건 락이 아니고, 아무나 뺏어갈 수 있어도 락이 아닙니다.

그래서 실무에서 실제로 깨는 건 대개 네 번째, 순환 대기입니다.

무엇을 깰 것인가

깨는 조건방법
순환 대기락 순서를 고정합니다. 가장 흔한 답입니다
Hold and wait필요한 락을 한 번에 다 잡거나, 못 잡으면 쥔 걸 다 놓습니다
비선점타임아웃. 강제로 뺏는 것과 같은 효과가 납니다
상호 배제애초에 락이 필요 없게 만듭니다. 불변 데이터, 파티셔닝

교과서에는 예방·회피·탐지·회복 네 전략이 나오지만, 회피(은행원 알고리즘)는 사실상 이론입니다. 요청마다 "이걸 내주면 데드락이 나나"를 계산하는 비용을 실무에서 감당하기 어렵습니다.

애플리케이션은 예방하고, DB는 탐지합니다

같은 문제인데 층마다 다른 전략을 고릅니다. 이유가 있습니다.

전략
애플리케이션 코드예방 — 락 순서를 코드로 고정어떤 순서로 잠글지 내가 다 통제할 수 있습니다
DB탐지 + 회복 — 걸리면 하나 죽임어떤 트랜잭션이 어떤 순서로 락을 요청할지 미리 알 수 없습니다

DB는 임의의 애플리케이션이 임의의 순서로 던지는 쿼리를 받습니다. 순서를 강제할 방법이 없으니 예방을 포기하고, 대신 걸린 걸 빨리 찾아 끊는 쪽을 택합니다.

DB는 어떻게 찾아내나

wait-for 그래프를 만듭니다. 노드는 트랜잭션, 엣지는 "누가 누구를 기다리는가"입니다. 여기서 순환이 생기면 데드락입니다. 결국 사이클 탐지라 DFS 한 번입니다.

찾으면 희생양을 하나 골라 강제로 롤백해서 순환을 끊습니다.

DB탐지 시점희생양 선정
MySQL (InnoDB)락 대기가 걸릴 때마다 즉시 검사undo 로그가 가장 적은, 즉 롤백이 제일 싼 트랜잭션
PostgreSQLdeadlock_timeout(기본 1초) 대기 검사보통 나중에 락을 요청한 쪽

즉시 정확하게 볼 것인가, 늦게 싸게 볼 것인가의 선택입니다. MySQL은 매번 검사 비용을 내고 빨리 반응합니다. PostgreSQL은 "1초 안에 그냥 풀릴 수도 있잖아"라며 기다렸다가 봅니다. 대부분의 락 대기는 실제로 금방 풀리니 나쁜 내기가 아닙니다.

같은 형태의 선택이 캐시 무효화나 헬스체크 주기에서도 반복됩니다.

어느 쿼리가 락인가

여기가 실무에서 제일 자주 어긋나는 지점입니다. LOCK이라고 쓴 적 없는 문장이 락입니다.

문장
UPDATE / DELETE배타 락. 명시하지 않아도 자동으로 걸립니다
SELECT ... FOR UPDATE배타 락. 의도적으로 거는 경우
SELECT ... FOR SHARE공유 락. 함께 읽되 쓰기는 막습니다
그냥 SELECT없음

마지막 줄이 MVCC(다중 버전 동시성 제어) 덕분입니다. 행을 수정할 때 기존 값을 지우지 않고 버전을 남기니, 읽는 쪽은 자기 트랜잭션이 시작한 시점의 버전을 보면 됩니다. 읽기가 쓰기를 막지 않고, 쓰기가 읽기를 막지 않습니다. 그래서 조회 트래픽은 락 경쟁에 끼지 않습니다.

그리고 한 가지 더 있습니다. 걸린 락은 커밋이나 롤백까지 안 풀립니다. 2단계 락킹 규칙입니다. 트랜잭션이 도는 동안에는 락을 모으기만 하고, 끝날 때 한꺼번에 놓습니다.

트랜잭션이 길수록 락을 쥐고 있는 시간이 길어지고, 데드락이 날 수 있는 창도 그만큼 넓어집니다. "트랜잭션은 짧게"라는 조언의 근거가 이것입니다.

얼마나 자주 나나 — 두 종류를 갈라야 합니다

종류빈도
우연히 겹친 것 — 아무 두 행이 마침 역순으로 잠김드뭅니다
설계가 어긋난 것 — 두 코드 경로가 항상 반대 순서로 잠금드물지 않습니다

두 번째는 확률의 문제가 아닙니다. 순서가 코드에 박혀 있으니 트래픽만 충분하면 시간문제입니다.

-- API A: 이체
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 행 1 잠금
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 행 2 대기

-- API B: 환불. 다른 사람이 짰고, 순서가 반대입니다
UPDATE accounts SET balance = balance + 100 WHERE id = 2;  -- 행 2 잠금
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- 행 1 대기

여기에 핫 로우가 끼면 확률이 확 올라갑니다. 정산 계좌, 수수료 계좌, 인기 가맹점 계좌처럼 모든 거래가 거쳐 가는 행 말입니다. 일반 사용자끼리의 이체는 겹칠 일이 드물어도, 초당 수백 건이 지나가는 행이라면 순서가 반대인 코드가 하나만 있어도 꾸준히 터집니다.

처방은 순서를 코드로 강제하는 것입니다

-- 두 id 중 작은 쪽부터 잠급니다
UPDATE accounts SET balance = balance - :amt WHERE id = LEAST(:from, :to);
UPDATE accounts SET balance = balance + :amt WHERE id = GREATEST(:from, :to);

애플리케이션에서 if (from > to) swap(from, to)로 정렬한 뒤 항상 같은 순서로 실행해도 됩니다. 어느 API든 이 규칙만 지키면 순환 자체가 만들어지지 않습니다.

현실에서는 여기에 하나를 더 얹습니다. 데드락 에러가 나면 재시도합니다. 막는 것과 흡수하는 것은 배타적이지 않고, 우연히 겹치는 경우까지 순서 규칙으로 다 없앨 수는 없습니다.

이 버그가 리뷰에서만 걸리는 이유

그래서 누군가 "이 UPDATE 순서, 저쪽 API랑 반대 아닌가요"라고 물어야 걸립니다. 코드 리뷰가 스타일 지적이 아니라 테스트가 구조적으로 닿을 수 없는 자리를 맡는 순간이 이런 때입니다.

흔한 오해

격리 수준이 높아야 나는 게 아닙니다. READ COMMITTED에서도 납니다. 데드락은 격리 수준이 아니라 락 범위의 문제입니다. 락 범위가 넓을수록 잦아지고, 그래서 MySQL의 REPEATABLE READ가 갭 락 때문에 READ COMMITTED보다 데드락이 잦습니다.

PostgreSQL의 SERIALIZABLE에서 나는 건 데드락이 아닐 수 있습니다. 이 구현은 락이 아니라 충돌 감지(SSI)라서, 대기 대신 serialization failure를 던집니다. 증상은 비슷해 보여도 원인과 대처가 다릅니다.

한 종류의 락만 써도 납니다. 문제는 락의 종류가 아니라 인스턴스가 둘 이상이고 순서가 어긋나는 것입니다. 극단적으로는 재진입 불가 뮤텍스를 같은 스레드가 두 번 잠그면 혼자서도 멈춥니다. 길이가 1인 순환입니다.


락 범위와 격리 수준의 관계는 트랜잭션 격리 수준에 정리해 두었습니다. 트랜잭션을 짧게 유지하는 문제는 커넥션 풀 쪽과도 이어집니다. 락을 오래 쥐면 커넥션도 오래 잡고 있게 됩니다.