데드락
두 트랜잭션이 서로가 쥔 락을 기다리며 영원히 멈추는 상태입니다.
흔히 "동시성이 높으면 나는 것"으로 뭉뚱그리는데, 실제로는 네 가지 조건이 전부 동시에 성립해야만 납니다. 그래서 대응도 "데드락을 없앤다"가 아니라 "넷 중 하나를 깬다" 가 됩니다.
네 가지 조건
| 조건 | 뜻 |
|---|---|
| 상호 배제 | 락 하나는 동시에 한 쪽만 가집니다 |
| Hold and wait | 자원을 쥔 채로 다른 자원을 기다립니다 |
| 비선점 | 남이 강제로 뺏을 수 없습니다 |
| 순환 대기 | A→B→C→…→A 순으로 서로 기다립니다 |
앞의 셋은 락이라는 도구의 성질에 가깝습니다. 락인데 여럿이 동시에 가질 수 있다면 그건 락이 아니고, 아무나 뺏어갈 수 있어도 락이 아닙니다.
그래서 실무에서 실제로 깨는 건 대개 네 번째, 순환 대기입니다.
무엇을 깰 것인가
| 깨는 조건 | 방법 |
|---|---|
| 순환 대기 | 락 순서를 고정합니다. 가장 흔한 답입니다 |
| Hold and wait | 필요한 락을 한 번에 다 잡거나, 못 잡으면 쥔 걸 다 놓습니다 |
| 비선점 | 타임아웃. 강제로 뺏는 것과 같은 효과가 납니다 |
| 상호 배제 | 애초에 락이 필요 없게 만듭니다. 불변 데이터, 파티셔닝 |
교과서에는 예방·회피·탐지·회복 네 전략이 나오지만, 회피(은행원 알고리즘)는 사실상 이론입니다. 요청마다 "이걸 내주면 데드락이 나나"를 계산하는 비용을 실무에서 감당하기 어렵습니다.
애플리케이션은 예방하고, DB는 탐지합니다
같은 문제인데 층마다 다른 전략을 고릅니다. 이유가 있습니다.
| 층 | 전략 | 왜 |
|---|---|---|
| 애플리케이션 코드 | 예방 — 락 순서를 코드로 고정 | 어떤 순서로 잠글지 내가 다 통제할 수 있습니다 |
| DB | 탐지 + 회복 — 걸리면 하나 죽임 | 어떤 트랜잭션이 어떤 순서로 락을 요청할지 미리 알 수 없습니다 |
DB는 임의의 애플리케이션이 임의의 순서로 던지는 쿼리를 받습니다. 순서를 강제할 방법이 없으니 예방을 포기하고, 대신 걸린 걸 빨리 찾아 끊는 쪽을 택합니다.
DB는 어떻게 찾아내나
wait-for 그래프를 만듭니다. 노드는 트랜잭션, 엣지는 "누가 누구를 기다리는가"입니다. 여기서 순환이 생기면 데드락입니다. 결국 사이클 탐지라 DFS 한 번입니다.
찾으면 희생양을 하나 골라 강제로 롤백해서 순환을 끊습니다.
| DB | 탐지 시점 | 희생양 선정 |
|---|---|---|
| MySQL (InnoDB) | 락 대기가 걸릴 때마다 즉시 검사 | undo 로그가 가장 적은, 즉 롤백이 제일 싼 트랜잭션 |
| PostgreSQL | deadlock_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든 이 규칙만 지키면 순환 자체가 만들어지지 않습니다.
현실에서는 여기에 하나를 더 얹습니다. 데드락 에러가 나면 재시도합니다. 막는 것과 흡수하는 것은 배타적이지 않고, 우연히 겹치는 경우까지 순서 규칙으로 다 없앨 수는 없습니다.
이 버그가 리뷰에서만 걸리는 이유
- 로컬에서 재현되지 않습니다. 두 방향의 요청이 동시에 들어와야 납니다
- 단위 테스트가 못 잡습니다. API A도 정상이고 API B도 정상입니다. 둘 다 맞는데 같이 있으면 틀립니다
- 정적 분석도 어렵습니다. 문법이 틀린 게 아니라 두 파일 사이의 순서 약속이 깨진 것입니다
그래서 누군가 "이 UPDATE 순서, 저쪽 API랑 반대 아닌가요"라고 물어야 걸립니다. 코드 리뷰가 스타일 지적이 아니라 테스트가 구조적으로 닿을 수 없는 자리를 맡는 순간이 이런 때입니다.
흔한 오해
격리 수준이 높아야 나는 게 아닙니다. READ COMMITTED에서도 납니다. 데드락은 격리 수준이 아니라 락 범위의 문제입니다. 락 범위가 넓을수록 잦아지고, 그래서 MySQL의 REPEATABLE READ가 갭 락 때문에 READ COMMITTED보다 데드락이 잦습니다.
PostgreSQL의 SERIALIZABLE에서 나는 건 데드락이 아닐 수 있습니다. 이 구현은 락이 아니라 충돌 감지(SSI)라서, 대기 대신 serialization failure를 던집니다. 증상은 비슷해 보여도 원인과 대처가 다릅니다.
한 종류의 락만 써도 납니다. 문제는 락의 종류가 아니라 인스턴스가 둘 이상이고 순서가 어긋나는 것입니다. 극단적으로는 재진입 불가 뮤텍스를 같은 스레드가 두 번 잠그면 혼자서도 멈춥니다. 길이가 1인 순환입니다.
락 범위와 격리 수준의 관계는 트랜잭션 격리 수준에 정리해 두었습니다. 트랜잭션을 짧게 유지하는 문제는 커넥션 풀 쪽과도 이어집니다. 락을 오래 쥐면 커넥션도 오래 잡고 있게 됩니다.