Theme:

두 트랜잭션이 서로가 들고 있는 락을 기다리며 영원히 멈춰 있다면, 데이터베이스는 이 교착 상태를 어떻게 풀어낼까요?

데드락이란

데드락(Deadlock)은 둘 이상의 트랜잭션이 서로가 보유한 락을 기다리면서 어느 쪽도 진행할 수 없는 상태입니다.

PLAINTEXT
트랜잭션 A: row 1 락 획득 → row 2 락 대기
트랜잭션 B: row 2 락 획득 → row 1 락 대기
→ 양쪽 모두 영원히 대기 (데드락)

InnoDB는 이런 상황을 자동으로 감지하고 해결하지만, 빈번한 데드락은 성능 저하의 원인이 됩니다.

데드락의 네 가지 필요 조건

운영체제에서 배우는 데드락의 네 가지 조건은 MySQL에도 그대로 적용됩니다.

  1. 상호 배제 (Mutual Exclusion): 하나의 행에 대한 배타적 락은 한 트랜잭션만 보유할 수 있습니다
  2. 점유와 대기 (Hold and Wait): 이미 락을 보유한 상태에서 추가 락을 요청합니다
  3. 비선점 (No Preemption): 다른 트랜잭션의 락을 강제로 빼앗을 수 없습니다
  4. 순환 대기 (Circular Wait): A→B→A 또는 A→B→C→A 형태로 대기 관계가 순환합니다

네 가지 조건이 모두 만족되어야 데드락이 발생합니다. 하나라도 깨면 데드락은 일어나지 않습니다.

왜 MySQL에서 데드락이 잘 발생하는가

갭 락과 넥스트 키 락의 영향

InnoDB의 REPEATABLE READ 격리 수준에서는 갭 락(Gap Lock)이 사용됩니다. 갭 락은 인덱스 레코드 사이의 "빈 공간"에도 락을 걸기 때문에, 실제 존재하지 않는 행에 대해서도 충돌이 발생합니다.

SQL
-- 트랜잭션 A
SELECT * FROM orders WHERE order_id BETWEEN 10 AND 20 FOR UPDATE;
-- order_id 10~20 범위의 갭에 락이 걸림

-- 트랜잭션 B
INSERT INTO orders (order_id, ...) VALUES (15, ...);
-- 같은 갭에 INSERT하려고 하면 대기

인덱스가 없을 때

적절한 인덱스가 없으면 InnoDB는 테이블의 모든 행을 스캔하면서 락을 걸어야 합니다. 락 범위가 넓어지면 충돌 확률도 높아집니다.

InnoDB의 데드락 감지 — Wait-for 그래프

InnoDB는 Wait-for 그래프를 사용하여 데드락을 감지합니다.

동작 원리

  1. 트랜잭션이 락을 요청할 때마다 Wait-for 그래프에 간선이 추가됩니다
  2. 그래프에서 **사이클(Cycle)**이 발견되면 데드락으로 판단합니다
  3. 사이클의 트랜잭션 중 하나를 Victim으로 선택하여 롤백합니다
PLAINTEXT
Wait-for 그래프:
TRX_A --대기--> TRX_B --대기--> TRX_A  (사이클 발견!)

Victim 선택 기준

InnoDB는 롤백 비용이 가장 적은 트랜잭션을 Victim으로 선택합니다. 비용은 Undo 로그의 양으로 추정합니다. 즉, 변경한 행이 적은 트랜잭션이 롤백 대상이 됩니다.

innodb_deadlock_detect 옵션

SQL
-- 데드락 감지 비활성화 (MySQL 8.0+)
SET GLOBAL innodb_deadlock_detect = OFF;

데드락 감지 자체도 비용이 있습니다. 동시 트랜잭션이 수백 개 이상인 고부하 환경에서는 감지 알고리즘이 오히려 병목이 될 수 있습니다.

감지를 끄면 데드락 상태의 트랜잭션은 innodb_lock_wait_timeout(기본 50초)까지 대기한 후 타임아웃 에러를 받습니다.

SQL
-- 락 대기 타임아웃 설정
SET GLOBAL innodb_lock_wait_timeout = 10;  -- 10초로 단축

데드락 발생 확인

SHOW ENGINE INNODB STATUS

가장 최근 발생한 데드락 정보를 확인할 수 있습니다.

SQL
SHOW ENGINE INNODB STATUS\G

출력의 LATEST DETECTED DEADLOCK 섹션에서 확인할 수 있는 정보는 다음과 같습니다.

  • 데드락에 관여한 트랜잭션 목록
  • 각 트랜잭션이 보유/대기 중인 락
  • 어떤 SQL 문이 실행 중이었는지
  • 어떤 트랜잭션이 롤백되었는지

innodb_print_all_deadlocks

모든 데드락을 에러 로그에 기록합니다.

SQL
SET GLOBAL innodb_print_all_deadlocks = ON;

운영 환경에서 데드락 패턴을 분석할 때 유용합니다.

Performance Schema

SQL
-- 데드락 발생 횟수 확인
SELECT COUNT_STAR
FROM performance_schema.events_errors_summary_global_by_error
WHERE ERROR_NAME = 'ER_LOCK_DEADLOCK';

데드락 재현 예제

두 세션에서 아래 순서대로 실행하면 데드락이 발생합니다.

SQL
-- 준비
CREATE TABLE accounts (
    id INT PRIMARY KEY,
    balance INT NOT NULL
);
INSERT INTO accounts VALUES (1, 1000), (2, 2000);
SQL
-- 세션 A                          -- 세션 B
BEGIN;                              BEGIN;
UPDATE accounts                     UPDATE accounts
SET balance = balance - 100         SET balance = balance - 200
WHERE id = 1;                       WHERE id = 2;
-- id=1 X락 획득                    -- id=2 X락 획득

UPDATE accounts                     UPDATE accounts
SET balance = balance + 100         SET balance = balance + 200
WHERE id = 2;                       WHERE id = 1;
-- id=2 대기 (B가 보유)             -- id=1 대기 (A가 보유)
-- → 데드락 발생!

InnoDB가 감지하면 한쪽에 다음 에러가 반환됩니다.

PLAINTEXT
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction

데드락 예방 전략

1. 리소스 접근 순서 통일

가장 효과적인 예방법입니다. 모든 트랜잭션이 같은 순서로 행에 접근하면 순환 대기가 발생하지 않습니다.

SQL
-- 나쁜 예: 순서가 제각각
-- TRX A: UPDATE accounts WHERE id=1 → UPDATE accounts WHERE id=2
-- TRX B: UPDATE accounts WHERE id=2 → UPDATE accounts WHERE id=1

-- 좋은 예: 항상 id 오름차순으로 접근
-- TRX A: UPDATE accounts WHERE id=1 → UPDATE accounts WHERE id=2
-- TRX B: UPDATE accounts WHERE id=1 → UPDATE accounts WHERE id=2

2. 트랜잭션을 짧게 유지

트랜잭션이 길수록 락을 오래 보유하고, 다른 트랜잭션과 충돌할 확률이 높아집니다.

SQL
-- 나쁜 예: 트랜잭션 안에서 외부 API 호출
BEGIN;
UPDATE orders SET status = 'processing' WHERE id = 100;
-- 여기서 외부 결제 API 호출 (수 초 소요)
UPDATE orders SET status = 'completed' WHERE id = 100;
COMMIT;

-- 좋은 예: 외부 호출은 트랜잭션 밖에서
-- 1) 외부 결제 API 호출
-- 2) 결과 확인 후 짧은 트랜잭션으로 상태 변경
BEGIN;
UPDATE orders SET status = 'completed' WHERE id = 100;
COMMIT;

3. 적절한 인덱스 사용

인덱스가 없으면 풀 테이블 스캔이 발생하고, 필요 이상으로 많은 행에 락이 걸립니다.

SQL
-- 인덱스 없이 실행하면 모든 행에 락이 걸릴 수 있음
UPDATE orders SET status = 'shipped' WHERE customer_id = 42;

-- customer_id에 인덱스가 있으면 해당 행만 정확히 잠금
CREATE INDEX idx_customer ON orders(customer_id);

4. 락 타임아웃 조절

데드락이 아닌 일반적인 락 대기도 너무 길면 문제가 됩니다.

SQL
-- 세션 레벨에서 타임아웃 단축
SET innodb_lock_wait_timeout = 5;

5. 애플리케이션 레벨 재시도

데드락은 완전히 없앨 수 없으므로, 애플리케이션에서 재시도 로직을 구현해야 합니다.

JAVA
int maxRetries = 3;
for (int i = 0; i < maxRetries; i++) {
    try {
        transferMoney(fromId, toId, amount);
        break; // 성공하면 루프 탈출
    } catch (DeadlockException e) {
        if (i == maxRetries - 1) throw e;
        Thread.sleep(100 * (i + 1)); // 점진적 대기
    }
}

격리 수준에 따른 데드락 빈도

격리 수준갭 락데드락 빈도
READ COMMITTED사용 안 함상대적으로 낮음
REPEATABLE READ사용상대적으로 높음

READ COMMITTED에서는 갭 락이 사용되지 않으므로 데드락 발생 확률이 줄어듭니다. 다만 Phantom Read가 발생할 수 있으므로 비즈니스 요구사항에 따라 선택해야 합니다.

정리

  • 데드락은 두 트랜잭션이 서로의 락을 기다리며 교착 상태에 빠지는 현상입니다
  • InnoDB는 Wait-for 그래프로 데드락을 감지하고, 비용이 적은 트랜잭션을 롤백합니다
  • 리소스 접근 순서 통일이 가장 효과적인 예방법입니다
  • 트랜잭션을 짧게 유지하고, 적절한 인덱스를 사용하며, 애플리케이션에서 재시도 로직을 구현하는 것이 실무에서의 핵심입니다
댓글 로딩 중...