본문 바로가기
카테고리 없음

새벽에 쌓인 데드락(deadlock) 로그를 추적한 밤

by 테크구루스 2026. 7. 14.

새벽에 쌓인 데드락(deadlock) 로그를 추적한 밤의 기록으로, 데드락의 원인과 해결을 정리한 글입니다.

새벽에 쌓인 데드락(deadlock) 로그를 추적한 밤
새벽에 쌓인 데드락(deadlock) 로그를 추적한 밤

데드락은 왜 생기는가

데드락을 처음 제대로 마주친 건 결제 정산 배치를 담당하던 시절이었습니다. 개념 자체는 학부 운영체제 시간에 배운 그 순환 대기(circular wait)와 똑같습니다. 두 트랜잭션이 서로가 쥔 자원을 기다리며 영원히 풀리지 않는 매듭에 빠지는 상황이죠. 다만 교과서의 다이어그램과 새벽 3시의 실제 로그는 체감이 많이 다릅니다.

가장 단순한 형태를 코드로 그려 보겠습니다. 계좌 A와 계좌 B 사이에서 잔액을 옮기는 두 트랜잭션이 동시에 실행된다고 해 보죠. 하나는 A에서 B로, 다른 하나는 B에서 A로 옮깁니다.

-- 트랜잭션 1: A -> B 이체
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';  -- A 행 락 획득
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';  -- B 행 락 대기

-- 트랜잭션 2: B -> A 이체 (동시 실행)
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 'B';  -- B 행 락 획득
UPDATE accounts SET balance = balance + 100 WHERE id = 'A';  -- A 행 락 대기

UPDATE 문은 그 행에 배타적 row lock을 겁니다. 트랜잭션 1은 A 행의 락을 이미 쥔 채 B 행의 락을 기다리고, 트랜잭션 2는 B 행의 락을 쥔 채 A 행의 락을 기다립니다. 서로가 상대방이 놓아 줘야만 진행할 수 있는데 아무도 먼저 놓지 않죠. 이것이 순환 대기입니다.

 

 

핵심은 자원을 잡는 순서가 엇갈렸다는 점입니다. 만약 두 트랜잭션이 모두 "id가 작은 행부터" 잡았다면, 둘 다 A를 먼저 원하니 한쪽은 A 락을 얻고 다른 쪽은 A 앞에서 얌전히 기다리다가, 앞 트랜잭션이 끝나면 순서대로 진행됩니다. 데드락은 자원이 부족해서가 아니라 접근 순서가 어긋나서 생기는 문제죠. 이 사실을 몸으로 이해하기까지 저는 꽤 여러 밤을 태웠습니다. 처음엔 "동시성이 높아서 그런가" 하고 커넥션 풀만 만지작거렸는데, 그건 증상도 원인도 아니었습니다.

DB는 어떻게 감지하고 끊는가

여기서 다행인 점은, 관계형 데이터베이스가 이 매듭을 스스로 알아채고 끊어 준다는 사실입니다. 만약 감지 기능이 없다면 두 트랜잭션은 커넥션을 쥔 채 영원히 멈춰 있을 테고, 뒤따르는 요청까지 줄줄이 밀려 서비스가 통째로 마비되겠죠.

대부분의 DB 엔진은 내부에 wait-for graph를 유지합니다. "트랜잭션 T1이 T2를 기다린다"를 방향이 있는 화살표로 그린 그래프죠. 새로운 락 대기가 발생할 때마다 엔진은 이 그래프에 화살표를 추가하고, 사이클(cycle)이 생겼는지 검사합니다. T1 → T2 → T1처럼 화살표가 원을 그리면 그게 곧 순환 대기, 즉 데드락입니다.

 

동시성 데드락 감지
동시성 데드락 감지

사이클을 찾으면 엔진은 그중 하나를 victim으로 지목해 강제로 롤백시킵니다. 어느 쪽을 희생시킬지는 엔진마다 기준이 다른데, 보통은 되돌리는 비용이 가장 작은 트랜잭션(변경한 행이 적거나 로그가 짧은 쪽)을 고릅니다. victim이 롤백되면 그가 쥐고 있던 락이 풀리고, 남은 트랜잭션은 기다리던 락을 얻어 정상적으로 진행되죠. 매듭의 한쪽 실을 잘라 전체를 살리는 셈입니다.

애플리케이션 입장에서 victim이 되면 명확한 오류로 되돌아옵니다. 제가 새벽 로그에서 마주친 건 PostgreSQL이었고, 메시지는 이랬습니다.

ERROR: deadlock detected
DETAIL: Process 18342 waits for ShareLock on transaction 90211;
        blocked by process 18355.
        Process 18355 waits for ShareLock on transaction 90210;
        blocked by process 18342.
HINT: See server log for query details.
SQLSTATE: 40P01

여기서 중요한 부분은 SQLSTATE 40P01입니다. MySQL(InnoDB)이라면 에러 코드 1213, 40001로 나타나죠. 이 코드를 안다는 건 실무에서 결정적입니다. 데드락은 논리적 버그가 아니라 일시적이고 재현되는 충돌이라, 같은 요청을 잠시 뒤 다시 시도하면 대개 성공하기 때문입니다. 즉 이 오류는 "코드가 틀렸다"가 아니라 "지금 타이밍이 겹쳤으니 다시 와 달라"에 가깝습니다. 이 성질을 어떻게 다루느냐가 다음 이야기의 핵심이죠.

락 순서를 맞추고, 재시도를 붙이기까지

문제의 배치는 새벽 2시부터 4시 사이에 정산 데이터를 몰아 처리했습니다. 어느 날부터 이 시간대에 deadlock detected가 하루 수십 건씩 쌓이기 시작했죠. 처음엔 건수가 적어 재시도 없이도 넘어갔지만, 거래량이 늘면서 정산 실패 알림이 새벽마다 울렸습니다. 저는 이걸 잡겠다고 며칠을 로그와 씨름했습니다.

첫 단추는 어떤 두 쿼리가 엇갈렸는지 특정하는 일이었습니다. PostgreSQL의 log_lock_waits = on을 켜고 deadlock_timeout을 확인한 뒤, 서버 로그에서 victim이 실행하던 SQL을 뽑아냈습니다. 범인은 두 경로였습니다.

  • 정산 반영 로직: UPDATE accounts ... WHERE id = :from 다음에 WHERE id = :to
  • 수수료 회수 로직: UPDATE accounts ... WHERE id = :to 다음에 WHERE id = :from

같은 두 행을 건드리는데 두 경로가 정반대 순서UPDATE를 날리고 있었습니다. 소제목1의 A/B 이체 예시가 실제 코드에서 그대로 벌어진 것이죠. 원인을 확인한 순간의 허탈함이 아직도 기억납니다. 동시성 튜닝이 아니라 그냥 순서 문제였으니까요.

해결은 두 겹으로 갔습니다. 첫째, 락 획득 순서를 전역으로 통일했습니다. 어느 경로든 여러 계좌를 건드릴 때는 반드시 id를 정렬해 작은 값부터 잠그도록 강제했습니다.

# 계좌 id를 정렬해 항상 같은 순서로 락 획득
ids = sorted([from_id, to_id])
for account_id in ids:
    cursor.execute(
        "UPDATE accounts SET ... WHERE id = %s", (account_id,)
    )

정렬이라는 사소한 규칙 하나로 순환 대기의 고리 자체가 끊어졌습니다. 모든 트랜잭션이 같은 방향으로만 자원을 잡으니 원이 그려질 수 없으니까요.

 

락순서 통일
락순서 통일

 

둘째, 그럼에도 남을 수 있는 우발적 충돌을 위해 재시도(retry)에 exponential backoff를 붙였습니다. 락 순서를 아무리 통일해도 인덱스 갱신이나 외래 키 검증 과정에서 예기치 못한 락이 얽힐 여지는 남기 때문입니다.

import random, time

for attempt in range(3):        # 최대 세 번 시도
    try:
        transfer(conn, from_id, to_id, amount)
        break
    except DeadlockError:       # SQLSTATE 40P01 / 40001 감지
        if attempt == 2:
            raise
        # 지수 backoff에 지터 추가
        time.sleep((2 ** attempt) * 0.1 + random.random() * 0.05)

재시도에서 놓치기 쉬운 부분이 두 가지 있습니다. 하나는 트랜잭션 전체를 다시 시작해야 한다는 점입니다. victim이 되면 그 트랜잭션의 모든 변경이 롤백되므로, 마지막 쿼리만 다시 던지면 안 되고 BEGIN부터 새로 감아야 하죠. 다른 하나는 지터(jitter)입니다. 여러 요청이 정확히 같은 간격으로 재시도하면 다시 나란히 충돌하니, 무작위 지연을 조금 섞어 시차를 벌려 줘야 합니다.

두 조치를 배포한 다음 날 새벽, 저는 로그를 열어 두고 기다렸습니다. 하루 수십 건이던 deadlock detected가 0건으로 떨어졌고, 드물게 backoff 재시도가 한두 번 기록될 뿐이었습니다. 개인적으로 이 경험이 남긴 교훈은 분명합니다. 데드락은 동시성을 줄여서가 아니라 순서를 통일해서 없애는 문제이고, 재시도는 그 위에 얹는 안전망이라는 것이죠. 지금도 여러 행을 함께 잠그는 코드를 볼 때면 저는 반사적으로 "이 락들, 순서가 정해져 있나요"부터 묻습니다.

 

2026.07.04 - [분류 전체보기] - 재고가 마이너스가 된 날, 트랜잭션 격리 수준을 배웠습니다