Deadlock found when trying to get lock; try restarting transaction
이 로그를 처음 보면 당황스럽다. 어제까지 멀쩡했고, 코드도 안 바꿨는데, 트래픽이 좀 늘었더니 하루에 몇 번씩 뜬다. 재시도하면 대개 성공한다는 것도 이상하다. 될 거면 처음부터 되지, 왜 한 번 실패하고 다시 하면 되는가.
앞 글에서 격리 수준이 “어디까지 눈감아줄지”의 눈금이라고 했다. 그 눈금을 실제로 지키는 방법 중 하나가 락이다. 그리고 데드락은 그 방법이 청구하는 대가다.
두 가지 락
락은 크게 둘로 나뉜다. 읽을 때 거는 공유락(S) 과 쓸 때 거는 배타락(X) 이다.
이름 그대로다. 공유락끼리는 사이좋게 공존한다. 여러 트랜잭션이 같은 행을 동시에 읽어도 아무 문제 없다. 읽기는 데이터를 바꾸지 않으니까. 하지만 누군가 쓰려고 하면 얘기가 달라진다.
| 상대가 S를 갖고 있을 때 | 상대가 X를 갖고 있을 때 | |
|---|---|---|
| S 요청 | 허용 | 대기 |
| X 요청 | 대기 | 대기 |
표에서 “허용”은 한 칸뿐이다. 읽기-읽기 말고는 전부 기다린다는 뜻이다. 이게 락 방식의 본질이고, 뒤에 나올 모든 문제의 출발점이기도 하다.
무엇을 잠그나
락에는 “어떤 락이냐” 말고 축이 하나 더 있다. 무엇을 잠그느냐다.
지금까지 “행을 잠근다”고 했지만, 잠글 수 있는 크기는 여러 가지다.
| 크기 | 잠그면 | 대가 |
|---|---|---|
| 행 | 그 행만 못 건드린다 | 락을 많이 만들어야 한다 |
| 테이블 | 그 테이블 전체가 묶인다 | 관리는 싸고, 남을 다 세운다 |
크게 잠글수록 관리할 락의 개수는 줄지만 남을 더 많이 막는다. 작게 잠글수록 남을 덜 막지만 락 자체가 많아진다. 동시성과 관리 비용을 맞바꾸는 것이고, 그래서 대부분의 DB는 평소에 행 단위로 잠근다.
여기서 상식과 어긋나는 지점이 하나 있다. 한 트랜잭션이 너무 많은 행을 잠그면, DB가 그걸 테이블 락 하나로 바꿔버리는 경우가 있다. 백만 건짜리 UPDATE를 하겠다고 행 락 백만 개를 들고 있느니 테이블을 통째로 잠그는 게 싸기 때문이다.
문제는 그 순간 아무 관계 없는 다른 요청까지 전부 멈춘다는 것이다. “행 단위로 잠그니까 남한테 영향 없겠지” 하고 대량 갱신을 돌렸다가 서비스 전체가 멎는 사고가 여기서 나온다. 대량 작업을 끊어서 돌리라는 조언의 이유 중 하나다.
이 승급이 언제 일어나는지, 아니면 아예 안 일어나는지는 제품마다 다르다. 대량 갱신 전에 확인할 것은 하나다 - “이 DB는 락을 몇 개까지 들고 있다가 언제 테이블 락으로 바꾸나.”
락은 언제 풀리나
여기서 헷갈리기 쉬운 지점이 있다. 락은 쓰고 나서 바로 풀리지 않는다.
만약 UPDATE 직후에 락을 풀어버리면, 아직 커밋도 안 한 값을 다른 트랜잭션이 읽어갈 수 있다. 그게 바로 더티 리드다.
언제 풀어야 하는가를 정한 규칙에 이름이 있다. 2단계 잠금(2PL, Two-Phase Locking) 이다.
트랜잭션이 락을 얻는 구간과 푸는 구간을 딱 둘로 나누고, 한 번 풀기 시작하면 다시는 못 얻게 한다. 이 규칙만 지키면 동시에 실행해도 결과가 순서대로 실행한 것과 같아진다. 직렬성이 보장된다는 뜻이다.
그런데 2PL만으로는 더티 리드를 못 막는다. “한 번 풀기 시작하면 다시 못 얻는다”만 지키면 되니, 커밋 전에 풀어도 규칙 위반이 아니다. 그 틈에 남이 읽어가면 그게 더티 리드다.
그래서 실무에서 쓰이는 건 한 단계 더 엄격한 Strict 2PL이다. 수축 단계를 아예 커밋 시점으로 몰아버린다. 트랜잭션이 끝날 때까지 배타락을 안 놓는다. 읽기 락까지 커밋 때 놓을지는 격리 수준과 제품이 정하는데, 어느 쪽이든 쓰기 락은 끝까지 쥔다는 게 이 규칙의 뼈대다. 대부분의 DBMS가 하는 게 이것이고, 앞에서 “락은 쓰고 나서 바로 안 풀린다”고 한 게 바로 이 규칙이다.
이게 “트랜잭션을 짧게 유지하라”는 조언의 진짜 이유다. 트랜잭션 안에서 외부 API를 호출하거나 파일을 올리면, 그 몇 초 동안 락을 쥔 채로 있는 것이다. 그동안 같은 행을 건드리려는 모두가 줄을 선다.
없는 행을 잠근다
여기까지는 있는 행을 잠갔다. 그런데 격리 수준 글이 마지막에 하나를 남겨뒀다. 팬텀이다.
조건에 맞는 게 3건이라 3건을 잠갔는데, 그사이 4번째가 새로 들어온다. 없던 행이니 잠글 수가 없었다. 그 글이 “범위 자체를 잠가야 한다”고 끝낸 게 이 이야기다.
그래서 나온 게 행과 행 사이의 빈틈을 잠그는 것이다.
id: 10 20 30
● ▨ ● ▨ ●
↑ ↑
이 틈에 아무도 못 들어온다WHERE id BETWEEN 10 AND 30으로 잠그면, 10·20·30이라는 행만 잠그는 게 아니라 그 사이의 빈 자리까지 잠근다. 그러면 누가 15를 새로 넣으려 해도 대기한다. 없는 행을 미리 잠근 셈이다.
이걸로 팬텀이 막힌다. 앞 글의 표에서 “REPEATABLE READ는 팬텀을 허용한다”고 했는데, 실제 제품이 표보다 엄격했던 이유 중 하나가 이것이다.
대신 대가가 있다. 잠긴 게 행이 아니라 범위라서, 그 범위에 들어올 수 있는 모든 삽입이 막힌다. 남이 넣으려던 값이 우연히 내 조건 범위 안이면 그냥 기다리게 된다. 조건이 넓을수록 더 많은 사람이 걸린다.
그리고 이 방식은 범위를 인덱스로 훑을 때만 제대로 좁혀진다. 인덱스가 없어 테이블을 전부 훑으면 잠기는 범위도 그만큼 커진다. 뒤에서 볼 “인덱스가 동시성에도 영향을 준다”는 이야기가 여기서도 나온다.
이런 락은 제품마다 이름과 동작이 다르다(MySQL InnoDB의 갭 락·넥스트키 락이 대표적이고, PostgreSQL은 다른 방식으로 팬텀을 다룬다). 문서에서 찾아볼 키워드는 “gap lock”·“next-key lock”·“predicate lock”이다. 이름이 달라도 목적은 하나다 - 아직 없는 행을 미리 막는 것.
그래서 데드락
락을 쥔 채로 다른 락을 기다린다. 상대도 마찬가지다. 서로가 서로를 기다린다.
T1은 B가 풀리기를 기다린다. B는 T2가 커밋해야 풀린다. T2는 A가 풀리기를 기다린다. A는 T1이 커밋해야 풀린다. 순환이다.
이걸 그림으로 그리면 데드락의 정체가 한눈에 보인다. 누가 누구를 기다리는지 화살표로 그렸을 때 고리가 생기면 그게 데드락이다.
DB는 실제로 이 그래프를 그린다. 대기 그래프(wait-for graph) 라고 부른다. 어떤 트랜잭션이 락을 못 얻고 대기에 들어가는 순간 고리가 생겼는지 따져보고, 있으면 데드락이라고 판정한다.
모든 DB가 이렇게 찾아내는 건 아니다. 고리를 찾는 대신 일정 시간 기다려도 락을 못 얻으면 포기시키는 방식도 있고, MySQL InnoDB처럼 탐지 기능을 꺼두고 타임아웃에 맡길 수 있는 경우도 있다. 동시 트랜잭션이 아주 많으면 고리를 찾는 일 자체가 부담이라서다. 둘의 차이는 에러가 즉시 오느냐, 기다린 만큼 늦게 오느냐로 나타난다.
누가 죽는가
고리를 찾았다고 해서 DBMS가 화해를 붙일 수는 없다. 한쪽을 죽여야 나머지가 진행한다. 그래서 하나를 골라 강제로 롤백시킨다. 이 희생자를 victim이라고 부른다.
고르는 기준은 DBMS마다 다르지만 대체로 되돌리기 싼 쪽이 죽는다. 지금까지 한 일이 적은 트랜잭션이다. 죽은 쪽 애플리케이션에는 앞서 본 그 에러가 날아간다.
재시도하면 대개 성공하는 이유가 여기 있다. 데드락은 코드가 틀려서가 아니라 타이밍이 겹쳐서 생긴다. 다시 하면 그 타이밍이 안 겹칠 확률이 높다.
그렇다고 “재시도 로직만 넣으면 된다”는 결론으로 가면 곤란하다. 재시도는 증상 완화지 원인 제거가 아니다. 트래픽이 늘면 데드락 빈도도 같이 늘고, 재시도가 재시도를 부르는 구간에 들어가면 그때부터는 손쓰기 어렵다.
줄이는 방법
데드락은 순환이 있어야 생긴다. 고리를 못 만들게 하면 안 생긴다.
접근 순서를 통일하는 게 가장 확실하다. 모든 트랜잭션이 A → B 순서로만 잠근다면, T1이 A를 쥐었을 때 T2는 A에서 기다린다. T2는 B를 아직 안 쥐었으니 T1은 B를 문제없이 가져간다. 고리가 안 생긴다.
코드로는 이런 식이다. 계좌 이체처럼 두 행을 동시에 건드리는 곳에서 특히 효과가 크다.
-- 나쁨: 보내는 사람 → 받는 사람 순서라 방향이 반대인 이체와 엇갈린다
UPDATE accounts SET balance = balance - 100 WHERE id = :from;
UPDATE accounts SET balance = balance + 100 WHERE id = :to;
-- 좋음: 두 문장을 id 오름차순으로 실행한다.
-- 앱에서 (:from, -100)과 (:to, +100)을 id로 정렬해 넘긴다
-- → :id1 < :id2, 부호는 어느 쪽이 보내느냐에 따라 딸려간다.
UPDATE accounts SET balance = balance + :delta1 WHERE id = :id1;
UPDATE accounts SET balance = balance + :delta2 WHERE id = :id2;바뀐 건 순서뿐이다. A→B 이체든 B→A 이체든 둘 다 작은 id부터 잠근다. 방향이 반대인 두 이체가 동시에 들어와도 고리가 안 생긴다.
정렬을 SQL 한 문장에 밀어 넣고 싶어지는데(WHERE id IN (:from, :to) ORDER BY id), 그러면 두 행에 같은 값이 들어가서 이체가 아니라 양쪽 다 입금이 된다. UPDATE ... ORDER BY는 MySQL 전용이기도 하다. 정렬은 애플리케이션이 한다.
두 번째는 잠그는 범위를 좁히는 것이다. 여기서 인덱스가 등장한다.
WHERE status = 'open'으로 UPDATE를 하는데 status에 인덱스가 없으면, DB는 조건에 맞는 행을 찾기 위해 테이블을 전부 훑는다. 그리고 MySQL InnoDB에서는 훑는 과정에서 지나가는 행마다 락을 건다. 최종적으로 조건에 맞는 게 3건뿐이어도, 그걸 알아내려고 만 건을 잠근 뒤다.
이건 제품마다 다르다. InnoDB는 스캔하며 지나간 행에 락을 걸고 나중에 일부를 푸는 구조라 위 상황이 그대로 벌어진다. PostgreSQL은 실제로 갱신되는 행에만 락을 남기므로 “만 건을 잠근다”는 일이 없다. 다만 전부 훑느라 느린 것과, 그 사이 다른 트랜잭션과 부딪힐 창이 넓어지는 것은 어느 쪽이든 같다.
인덱스가 있으면 3건만 정확히 집어서 잠근다. 잠그는 행이 줄면 남과 겹칠 확률도 줄고, 데드락도 준다. 인덱스는 조회 성능 이야기로만 알려져 있지만, 동시성에도 그만큼 영향을 준다.
정리하면 이렇다.
- 트랜잭션을 짧게. 특히 안에서 외부 호출을 하지 않는다.
- 여러 행을 잠글 때는 항상 같은 순서로.
- 인덱스로 잠금 범위를 좁힌다.
- 그래도 남는 데드락에 대비해 재시도를 둔다. 다만 재시도는 마지막 방어선이다.
락 방식의 한계
여기까지가 잠가서 지키는 방식이다. 정확하지만 비싸다. 표에서 봤듯이 읽기와 쓰기가 서로를 막는다. 누군가 긴 리포트 쿼리를 돌리면 그동안 그 데이터를 쓰려는 트랜잭션이 전부 멈춘다. 반대도 마찬가지다.
읽기가 압도적으로 많은 서비스에서 이건 견디기 어렵다. 그래서 다른 접근이 나왔다. 잠그는 대신, 각자에게 다른 버전을 보여주는 것이다.
다음 글은 MVCC다. 쓰는 사람은 새 버전을 만들고 읽는 사람은 예전 버전을 계속 본다. 그러면 읽기가 쓰기를 안 막는다. 대신 그 버전들을 어딘가 쌓아둬야 하고, 언젠가 치워야 한다.