Published on

MySQL 8.0 InnoDB 데드락 원인추적·해결 실전

Authors

서버가 멀쩡해 보이는데도 애플리케이션에서 간헐적으로 Deadlock found when trying to get lock; try restarting transaction가 튀어나오면, 대부분은 “운이 나빴다”가 아니라 잠금 순서/범위가 설계상 충돌하고 있다는 신호입니다. MySQL 8.0의 InnoDB는 데드락을 감지하면 한 트랜잭션을 희생(롤백)시켜 진행을 보장하지만, 그 희생이 사용자 요청 실패/재시도 폭증/큐 적체로 이어지면 장애가 됩니다.

이 글은 MySQL 8.0 기준으로 데드락을 재현 → 원인 추적 → 해결까지 실무 흐름대로 정리합니다. (운영 환경이 쿠버네티스/EKS라면, 증상 관측과 트래픽/리소스 이슈가 섞일 수 있으니 인프라 측 점검은 별도로 병행하세요. 예: EKS Pod가 Pending(Insufficient memory)일 때 점검법)

데드락의 본질: “서로가 가진 락을 서로 기다리는 상태”

InnoDB 데드락은 흔히 다음 두 축에서 발생합니다.

  1. 락 획득 순서가 트랜잭션마다 다름
  • 트랜잭션 A: (row1) 잠금 → (row2) 잠금
  • 트랜잭션 B: (row2) 잠금 → (row1) 잠금
  1. 락 범위가 생각보다 넓음
  • 인덱스가 없거나, 조건이 비선택적이면 InnoDB가 더 많은 레코드/갭을 잠글 수 있습니다.
  • REPEATABLE READ(기본)에서는 팬텀 방지를 위해 **Next-Key Lock(레코드+갭)**이 걸리며, 이것이 데드락 확률을 올립니다.

핵심은 “왜 저 트랜잭션이 저 락을 잡았는가?”를 정확히 파악하는 것입니다.

1단계: 데드락 로그 확보 (가장 빠른 단서)

SHOW ENGINE INNODB STATUS

데드락 직후라면 아래가 1순위입니다.

SHOW ENGINE INNODB STATUS\G

출력의 LATEST DETECTED DEADLOCK 섹션을 보면:

  • 어떤 트랜잭션(스레드/쿼리)이
  • 어떤 인덱스의 어떤 레코드(또는 gap)를
  • 어떤 모드로(X, S, gap, insert intention 등) 잡고/기다렸는지 나옵니다.

주의: 이 정보는 “마지막 데드락 1건”만 유지됩니다. 빈번한 데드락이면 덮어씌워져 원인 분석이 어려워집니다.

innodb_print_all_deadlocks 활성화

운영에서 원인 추적이 필요하면(로그 양 감수 가능할 때) 모든 데드락을 에러 로그로 남기게 할 수 있습니다.

SET GLOBAL innodb_print_all_deadlocks = ON;
  • MySQL 8.0에서는 error log(파일/테이블)로 남습니다.
  • Kubernetes 환경이면 로그 수집 파이프라인(Fluent Bit 등)에서 필터링/샘플링을 고려하세요.

2단계: “어떤 락을 잡았는지”를 메타데이터로 확인

MySQL 8.0에서는 performance_schema를 통해 현재 락 대기/락 보유 상태를 비교적 명확히 볼 수 있습니다(설정에 따라 일부 계측이 제한될 수 있음).

현재 대기 중인 트랜잭션/락 조회

SELECT
  r.trx_id                AS waiting_trx_id,
  r.trx_mysql_thread_id   AS waiting_thread,
  r.trx_query             AS waiting_query,
  b.trx_id                AS blocking_trx_id,
  b.trx_mysql_thread_id   AS blocking_thread,
  b.trx_query             AS blocking_query
FROM information_schema.innodb_lock_waits w
JOIN information_schema.innodb_trx r ON w.requesting_trx_id = r.trx_id
JOIN information_schema.innodb_trx b ON w.blocking_trx_id   = b.trx_id\G
  • 데드락은 감지 즉시 한쪽이 롤백되므로 “순간”에 지나갈 수 있습니다.
  • 따라서 상시 모니터링/샘플링(1~5초 간격)으로 잠금 대기 패턴을 먼저 잡고, 데드락 로그와 대조하는 방식이 실무에서 유효합니다.

performance_schema로 InnoDB 잠금 대기 확인(가능한 경우)

환경에 따라 아래 테이블이 도움이 됩니다.

SELECT *
FROM performance_schema.data_lock_waits\G;

SELECT *
FROM performance_schema.data_locks
WHERE ENGINE = 'INNODB'\G;

여기서 INDEX_NAME, LOCK_TYPE, LOCK_MODE, LOCK_STATUS를 보면 “왜 범위가 커졌는지(인덱스/조건 문제)”를 역추적하기 좋습니다.

3단계: 재현 가능한 최소 예제로 축소하기

데드락은 “동시성 버그”라서 재현이 어려운 경우가 많습니다. 하지만 락 순서가 엇갈리는 케이스는 의도적으로 재현해 원인을 명확히 만들 수 있습니다.

예제: 서로 다른 순서로 업데이트

테이블/데이터 준비:

CREATE TABLE accounts (
  id BIGINT PRIMARY KEY,
  balance INT NOT NULL
) ENGINE=InnoDB;

INSERT INTO accounts VALUES (1, 100), (2, 100);

세션 A:

START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE id = 1;
-- 여기서 멈춰두고
UPDATE accounts SET balance = balance + 10 WHERE id = 2;
COMMIT;

세션 B:

START TRANSACTION;
UPDATE accounts SET balance = balance - 10 WHERE id = 2;
-- 여기서 멈춰두고
UPDATE accounts SET balance = balance + 10 WHERE id = 1;
COMMIT;

두 세션이 각각 첫 번째 UPDATE를 실행한 뒤 두 번째 UPDATE로 넘어가면 데드락이 발생할 수 있습니다.

교훈: 같은 논리 작업(이체)이라도 항상 같은 순서로 잠금을 잡게 만들면 데드락 확률이 급감합니다.

4단계: MySQL 8.0에서 흔한 데드락 패턴 6가지

1) 인덱스 부재/비효율로 인한 과도한 잠금(범위 잠금)

  • 조건절에 인덱스가 없으면 InnoDB는 많은 레코드를 스캔하고 그 과정에서 더 많은 잠금을 잡습니다.
  • 특히 UPDATE ... WHERE non_indexed_column = ?는 데드락/락 대기의 단골입니다.

점검:

EXPLAIN ANALYZE
UPDATE orders
SET status = 'PAID'
WHERE user_id = 123 AND status = 'PENDING';

해결:

  • WHERE 조건을 잘 타는 복합 인덱스 추가
  • 불필요한 범위 조건 축소

예:

CREATE INDEX idx_orders_user_status ON orders(user_id, status);

2) REPEATABLE READ의 Next-Key Lock로 인한 갭 락 충돌

기본 격리수준 REPEATABLE READ에서 범위 조건/정렬/인덱스 스캔은 갭 락을 유발할 수 있습니다.

해결 옵션:

  • 트랜잭션 성격상 팬텀을 허용 가능하면 READ COMMITTED로 전환 고려
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- ...
COMMIT;

주의:

  • 격리수준 변경은 일관성 모델에 영향을 줍니다. “데드락 줄이기”만 보고 전역 변경하는 건 위험합니다.

3) SELECT ... FOR UPDATE / LOCK IN SHARE MODE의 남용

SELECT ... FOR UPDATE는 읽기처럼 보이지만 쓰기 락(X) 또는 강한 잠금을 유발합니다.

  • 정말로 “곧 업데이트할 행”만 잠그는지 확인
  • 범위가 넓은 조건에 FOR UPDATE를 붙이면 데드락/대기 폭증

4) 외래키(FK)로 인한 잠금 전파

부모/자식 테이블에 FK가 걸려 있으면:

  • 부모 삭제/업데이트 시 자식 검사로 인해 자식 인덱스/레코드 잠금
  • 자식 insert/update 시 부모 존재 검사로 부모 잠금

해결:

  • FK 컬럼에 인덱스가 적절히 있는지 확인(특히 자식 테이블 FK 컬럼)
  • 트랜잭션에서 부모/자식 접근 순서를 통일

5) 유니크 인덱스 + 동시 INSERT(Insert Intention) 충돌

동시 insert가 유니크 충돌/갭 락과 섞이면 데드락이 발생할 수 있습니다.

해결:

  • 키 생성 전략(예: UUID vs auto_increment)과 인덱스 설계를 재검토
  • “중복이면 무시”가 목적이면 INSERT ... ON DUPLICATE KEY UPDATE를 사용할 때도 락 범위를 확인

6) 긴 트랜잭션(트랜잭션 스코프 과대)

  • 트랜잭션이 길면 그만큼 락 보유 시간이 길어져 데드락 확률이 올라갑니다.
  • 특히 애플리케이션에서 네트워크 호출/외부 API 호출을 트랜잭션 안에 넣는 경우가 위험합니다.

해결:

5단계: 해결 전략(우선순위대로)

아래는 실무에서 효과가 큰 순서입니다.

1) 잠금 순서 고정(가장 강력하고 단순)

여러 행/테이블을 갱신한다면 항상 같은 정렬 기준으로 처리합니다.

예: 두 계정 이체라면 min(id) -> max(id) 순으로 업데이트.

-- 의사코드
-- from_id, to_id 중 작은 id를 먼저 잠근다
SELECT id FROM accounts WHERE id IN (?, ?) ORDER BY id FOR UPDATE;
-- 이후 업데이트

2) 인덱스로 “잠금 범위”를 줄이기

  • 데드락 로그에서 특정 인덱스/레코드가 반복적으로 등장하면, 해당 쿼리의 실행계획을 확인해 스캔 범위를 줄입니다.
  • 복합 인덱스는 (조건절의 등가 조건 → 범위 조건 → 정렬/커버링) 순으로 검토합니다.

3) 트랜잭션을 짧게, 한 번에 하나의 책임만

  • 애플리케이션 레벨에서 “트랜잭션 시작~커밋” 사이에 불필요한 로직 제거
  • 배치/동기화 작업은 청크 단위 커밋

4) 격리수준/락 힌트는 ‘마지막 카드’로 신중히

  • READ COMMITTED는 데드락을 줄이는 데 도움이 되는 경우가 많지만, 일관성 요구사항과 트레이드오프가 있습니다.
  • NOWAIT, SKIP LOCKED(MySQL 8.0)로 락 대기 자체를 회피하는 설계도 가능합니다(큐/잡 워커에서 유용).
-- 작업 큐에서 잠긴 행은 건너뛰고 처리
SELECT *
FROM job_queue
WHERE status = 'READY'
ORDER BY id
LIMIT 10
FOR UPDATE SKIP LOCKED;

5) 재시도 정책(반드시 필요하지만, 이것만으로는 부족)

InnoDB는 데드락 시 한쪽을 롤백시키므로 애플리케이션은 재시도를 해야 합니다.

권장:

  • 지수 백오프 + 지터
  • 재시도 횟수 제한
  • 멱등성 보장(특히 결제/포인트)

예: (의사코드)

for attempt in range(1, 6):
    try:
        begin()
        do_db_work()
        commit()
        break
    except DeadlockError:
        rollback()
        sleep(backoff_with_jitter(attempt))

하지만 재시도는 “증상 완화”일 뿐, 데드락이 잦다면 앞의 1~3번(순서/인덱스/트랜잭션 스코프)에서 근본 원인을 제거해야 합니다.

운영 체크리스트: 데드락을 ‘사후 분석 가능’하게 만들기

  • innodb_print_all_deadlocks=ON을 일시적으로 켜고 로그를 수집한다.
  • 데드락 발생 시점의 쿼리/스레드/호스트를 애플리케이션 로그(트레이스ID)와 연결한다.
  • EXPLAIN ANALYZE로 문제 쿼리의 스캔 범위와 실제 읽은 row 수를 확인한다.
  • 동일 엔드포인트에서 동시성이 과도하게 높아지는지(배치/재시도 폭풍 포함) 확인한다.
  • DB 락 이슈가 아니라 상위 계층의 타임아웃/프록시 설정이 오류를 증폭시키는지 점검한다(예: 스트리밍/장시간 요청에서의 타임아웃은 별도 튜닝 포인트가 있음. 참고: LLM SSE 스트리밍 499 502 급증과 응답 끊김을 잡는 프록시 튜닝 체크리스트).

결론: “데드락은 버그 리포트”다

MySQL 8.0 InnoDB 데드락은 단순히 재시도하면 끝나는 이벤트가 아니라, 락 순서/락 범위/트랜잭션 스코프 중 하나가 설계와 어긋났다는 신호입니다.

  1. SHOW ENGINE INNODB STATUS와 에러 로그로 “무엇이 무엇을 기다렸는지”를 확보하고
  2. performance_schema/innodb_lock_waits로 잠금 구조를 확인한 뒤
  3. 인덱스와 쿼리, 트랜잭션 경계를 조정해 락 범위를 줄이고 순서를 고정하면

대부분의 데드락은 “간헐적 장애”에서 “무시 가능한 이벤트” 수준으로 떨어뜨릴 수 있습니다.

원하시면 실제 데드락 로그( LATEST DETECTED DEADLOCK 블록) 한 건을 익명화해서 붙여주시면, 해당 로그를 기준으로 정확히 어떤 인덱스/쿼리/락 모드가 원인인지와 **가장 비용 대비 효과 좋은 수정안(인덱스/쿼리 변경/순서 고정)**을 같이 제안해드릴게요.