- Published on
MySQL InnoDB 1205·1213 잠금대기 원인별 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버가 멀쩡한데도 특정 API만 간헐적으로 느려지고, 결국 ERROR 1205 (HY000): Lock wait timeout exceeded 또는 ERROR 1213 (40001): Deadlock found로 터지는 경우가 있습니다. 이 둘은 모두 InnoDB 잠금 경합의 결과지만, 성격과 대응이 다릅니다.
- 1205: “기다렸는데도 잠금이 풀리지 않았다” (타임아웃)
- 1213: “서로가 서로를 기다리는 순환이 생겼다” (데드락) → InnoDB가 한 트랜잭션을 강제로 롤백
이 글은 증상을 ‘원인별’로 쪼개서, 어떤 SQL/패턴이 어떤 오류를 만들고 어떻게 고치면 되는지 실전 기준으로 정리합니다.
> 참고: DB 잠금 문제는 분산 시스템의 중복 처리/재시도 설계와도 연결됩니다. 결제/주문 같은 도메인이라면 MSA 사가 실패로 중복결제 터질 때 Outbox로 막기처럼 “재시도해도 안전한 처리”를 함께 고려하는 게 좋습니다.
1) 1205 vs 1213: 무엇이 다를까?
1205 (Lock wait timeout exceeded)
- 어떤 트랜잭션이 레코드/갭/넥스트키 락 등을 잡고 오래 유지
- 다른 트랜잭션이 해당 락이 풀리길 기다리다가
innodb_lock_wait_timeout을 초과 - 주로 긴 트랜잭션, 인덱스 미사용으로 인한 광범위 락, 대량 업데이트, 외래키 검사에서 많이 발생
1213 (Deadlock found)
- 트랜잭션 A가 가진 락을 B가 기다리고, B가 가진 락을 A가 기다리는 순환 대기
- InnoDB는 감지 후 “피해가 적어 보이는” 트랜잭션 하나를 롤백
- 애플리케이션은 보통 재시도가 필요 (단, 재시도가 안전하도록 설계되어야 함)
2) 즉시 확인할 것: 잠금/대기/데드락 증거 수집
(1) 현재 누가 누구를 막고 있는지 보기
MySQL 8.0 기준으로는 performance_schema가 가장 유용합니다.
-- 대기 중인 락(누가 기다리는지)
SELECT
r.trx_id waiting_trx_id,
r.trx_mysql_thread_id waiting_thread,
r.trx_query waiting_query,
b.trx_id blocking_trx_id,
b.trx_mysql_thread_id blocking_thread,
b.trx_query blocking_query,
l.lock_table,
l.lock_index,
l.lock_type,
l.lock_mode
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
JOIN information_schema.innodb_locks l ON w.requested_lock_id = l.lock_id;
> 주의: information_schema.innodb_* 뷰는 버전/설정에 따라 비활성화되거나 대체될 수 있습니다. MySQL 8.0에서는 performance_schema.data_locks, performance_schema.data_lock_waits를 쓰는 구성이 더 일반적입니다.
(2) 마지막 데드락 상세 보기
1213은 원인 파악에 SHOW ENGINE INNODB STATUS가 거의 필수입니다.
SHOW ENGINE INNODB STATUS\G
출력 중 LATEST DETECTED DEADLOCK 섹션에:
- 충돌한 두 트랜잭션의 SQL
- 어떤 인덱스/레코드에 어떤 락을 걸었는지
- 어떤 트랜잭션이 롤백되었는지
가 상세히 나옵니다.
(3) “긴 트랜잭션” 찾기
긴 트랜잭션은 1205/1213 모두의 공통 원인입니다.
SELECT
trx_id,
trx_mysql_thread_id,
trx_started,
TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS trx_age_sec,
trx_state,
trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
3) 1205 원인별 해결
원인 A: 긴 트랜잭션(커밋/롤백이 늦음)
전형적인 패턴
- 트랜잭션 안에서 외부 API 호출
- 트랜잭션 안에서 대량 루프 처리
- 사용자 입력 대기(웹 요청이 길게 유지)
SELECT ... FOR UPDATE후 비즈니스 로직이 오래 걸림
해결
- 트랜잭션 범위를 최소화: “락이 필요한 순간”에만 시작
- 외부 통신/파일 I/O는 트랜잭션 밖으로 이동
- 배치성 업데이트는 작은 청크로 나눠 커밋
-- 나쁜 예: 락 잡아놓고 오래 처리
START TRANSACTION;
SELECT * FROM orders WHERE id = 123 FOR UPDATE;
-- 여기서 외부 결제 승인 API 호출(수 초~수십 초)
UPDATE orders SET status='PAID' WHERE id = 123;
COMMIT;
-- 개선: 결제 승인(외부) -> DB 반영(짧은 트랜잭션)
-- 1) 외부 승인
-- 2) 승인 결과를 짧게 반영
START TRANSACTION;
UPDATE orders SET status='PAID' WHERE id = 123 AND status='PENDING';
COMMIT;
AND status='PENDING' 같은 조건은 **중복 처리 방지(멱등성)**에도 도움이 됩니다.
원인 B: 인덱스 미사용으로 “너무 많은 행”을 잠금
전형적인 패턴
UPDATE ... WHERE non_indexed_column = ...SELECT ... FOR UPDATE가 풀스캔을 유발- 조인 업데이트에서 드라이빙 테이블 선택이 비효율적
InnoDB는 조건을 만족하는 레코드를 찾는 과정에서 잠금 범위가 커질 수 있습니다(특히 REPEATABLE READ에서 넥스트키 락).
진단
EXPLAIN UPDATE users SET last_login = NOW() WHERE email = 'a@b.com';
type=ALL(풀스캔) 또는rows가 과도하면 위험 신호
해결
- 조건절 컬럼에 적절한 인덱스 추가
- 불필요한 범위 조건/함수 적용 제거
CREATE INDEX idx_users_email ON users(email);
UPDATE users
SET last_login = NOW()
WHERE email = 'a@b.com';
원인 C: 갭 락/넥스트키 락으로 인한 대기(특히 REPEATABLE READ)
전형적인 패턴
- 존재 여부 확인 후 INSERT
- 범위 조건으로
SELECT ... FOR UPDATE수행
-- 세션1
START TRANSACTION;
SELECT * FROM coupons
WHERE code BETWEEN 'A100' AND 'A200'
FOR UPDATE;
-- 범위에 갭/넥스트키 락이 걸릴 수 있음
-- 세션2
INSERT INTO coupons(code) VALUES('A150'); -- 대기/타임아웃 가능
해결 옵션
- 가능하면 “정확히 한 건”을 잠그도록 유니크 키를 사용
- 범위 잠금이 필요 없다면 트랜잭션 격리수준을 조정 검토(신중)
- 애플리케이션 로직을
INSERT ... ON DUPLICATE KEY UPDATE로 단순화
-- code에 UNIQUE 인덱스가 있다고 가정
INSERT INTO coupons(code, used)
VALUES('A150', 0)
ON DUPLICATE KEY UPDATE used = used;
원인 D: 외래키(FK)로 인한 잠금 전파
부모/자식 테이블에 FK가 있으면:
- 부모 삭제/업데이트 시 자식 검사로 인해 자식 인덱스 레코드를 잠글 수 있고
- 자식 INSERT/UPDATE 시 부모 존재 확인으로 부모 레코드를 참조합니다.
해결
- FK 컬럼에 인덱스가 반드시 있어야 함(자식 테이블)
- 대량 삭제는 자식부터 순서대로
ON DELETE CASCADE가 오히려 락 범위를 키울 수도 있어 워크로드에 맞게 선택
-- 자식 테이블의 FK 컬럼 인덱스 확인/추가
CREATE INDEX idx_order_items_order_id ON order_items(order_id);
원인 E: innodb_lock_wait_timeout만 늘려서 “숨기는” 경우
innodb_lock_wait_timeout을 늘리면 1205는 줄어들 수 있지만, 근본 원인(긴 트랜잭션/인덱스 문제)이 남아 있으면 지연이 더 길어져 장애가 커질 수 있습니다.
SHOW VARIABLES LIKE 'innodb_lock_wait_timeout';
-- 필요 시 세션 단위로만 조정 권장
SET SESSION innodb_lock_wait_timeout = 10;
운영에서는 “늘리기”보다:
- 트랜잭션 짧게
- 인덱스 맞추기
- 충돌이 잦은 핫스팟 업데이트 구조 변경
이 우선입니다.
4) 1213(데드락) 원인별 해결
원인 A: 서로 다른 순서로 같은 자원을 잠금(가장 흔함)
재현 예시
-- 트랜잭션1
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
-- 트랜잭션2(반대 순서)
START TRANSACTION;
UPDATE accounts SET balance = balance - 50 WHERE id = 2;
UPDATE accounts SET balance = balance + 50 WHERE id = 1;
COMMIT;
서로가 상대가 잡은 행을 기다리면서 데드락이 발생할 수 있습니다.
해결
- 항상 동일한 순서로 잠그기(예: id 오름차순)
-- 두 계좌 이체 시, 작은 id부터 업데이트
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = LEAST(1,2);
UPDATE accounts SET balance = balance + 100 WHERE id = GREATEST(1,2);
COMMIT;
(실제로는 금액 증감 방향이 달라 위 예시는 단순화되어 있습니다. 핵심은 “락 순서 고정”입니다.)
원인 B: 유니크 인덱스 경합 + 동시 INSERT/UPDATE
INSERT ... ON DUPLICATE KEY UPDATE나 유니크 키를 두고 동시에 업서트할 때, 인덱스 레벨에서 교착이 생길 수 있습니다.
해결
- 충돌이 잦은 키(예: 특정 사용자/상품)에 대해 요청을 직렬화(애플리케이션 레벨)
- 가능하면 “한 행에 모든 것을 몰아넣는” 카운터/집계 테이블을 샤딩
- 재시도 로직 구현(1213은 재시도 가치가 큼)
원인 C: 보조 인덱스/클러스터드 인덱스 락 순서 차이
InnoDB는 업데이트 시:
- 보조 인덱스 엔트리
- 클러스터드(PK) 레코드
등 여러 구조를 건드리며, 트랜잭션마다 접근 경로가 달라지면 교착 가능성이 올라갑니다.
해결
- 업데이트/조회 쿼리의 실행 계획을 안정화(인덱스 정비)
- 불필요한 보조 인덱스 제거(쓰기 비용 및 락 포인트 감소)
- 동일한 조건/인덱스를 타도록 쿼리 통일
원인 D: 대량 배치 작업과 온라인 트래픽의 충돌
배치가 많은 행을 업데이트하면서 온라인 트래픽의 짧은 트랜잭션과 충돌하면 1213/1205 모두 증가합니다.
해결
- 배치를 작은 단위로 쪼개고 커밋
- 오프피크 시간대 실행
- 배치 범위를 PK 기준으로 나누어 예측 가능하게 진행
-- PK 범위로 청크 업데이트
UPDATE events
SET processed = 1
WHERE id > ? AND id <= ?;
5) 애플리케이션 레벨 처방: 재시도, 멱등성, 타임아웃
1213은 “정상적인 상황”으로 보고 재시도 설계
데드락은 완전히 제거하기 어렵고, InnoDB가 감지/해결(롤백)해주기 때문에 재시도가 가장 현실적인 대응입니다.
- 재시도 대상:
1213, 경우에 따라1205도 제한적으로 - 재시도 횟수: 2~5회 정도(서비스 특성에 따라)
- 백오프: 지터 포함 지수 백오프 권장
- 전제: 같은 요청을 여러 번 실행해도 안전해야 함(멱등성)
(예시) Node.js에서 1213/1205 재시도
import mysql from 'mysql2/promise';
const RETRYABLE = new Set([1213, 1205]);
export async function withRetry(fn, { max = 4 } = {}) {
let attempt = 0;
while (true) {
try {
return await fn();
} catch (e) {
const errno = e?.errno;
attempt++;
if (!RETRYABLE.has(errno) || attempt > max) throw e;
const base = 50 * Math.pow(2, attempt - 1);
const jitter = Math.floor(Math.random() * 30);
const sleepMs = base + jitter;
await new Promise(r => setTimeout(r, sleepMs));
}
}
}
// 사용 예
// await withRetry(() => conn.execute('UPDATE ...'));
> 재시도는 DB를 더 때릴 수 있으니, “락 순서 고정/인덱스 개선/트랜잭션 축소” 같은 근본 개선과 함께 적용해야 합니다.
6) 운영 체크리스트(원인별 빠른 분기)
증상: 1205가 많다
- 긴 트랜잭션이 있는가? (
innodb_trx로 age 확인) - 인덱스 없이 UPDATE/SELECT FOR UPDATE가 있는가? (EXPLAIN)
- 범위 잠금이 필요한가? (REPEATABLE READ + 범위 조건)
- 배치가 온라인 트래픽을 막고 있나? (청크/오프피크)
증상: 1213이 많다
- 같은 테이블/레코드를 서로 다른 순서로 업데이트하나?
- 업서트 충돌이 잦은 핫키가 있나?
- 쿼리 경로가 제각각(인덱스 혼재)인가?
- 데드락 로그에 반복되는 패턴이 있나? (
SHOW ENGINE INNODB STATUS)
7) “고쳐도 또 생기는” 구조적 원인과 개선 방향
잠금 문제는 종종 “DB 설정”이 아니라 데이터 모델/쓰기 패턴에서 옵니다.
- 하나의 행에 카운터를 몰아넣는 구조(핫스팟) → 샤딩/버퍼링/비동기 집계
- 주문/결제처럼 재시도가 필수인 도메인 → 멱등 키, Outbox, 상태 전이(조건부 업데이트)
- 트랜잭션이 길어지는 아키텍처 → 외부 의존성 분리, 트랜잭션 경계 재설계
DB 잠금은 “한 번의 튜닝”으로 끝나기보다, 트래픽이 커질수록 다시 튀어나옵니다. 그래서 재현 가능한 원인 분류 + 관측(락/트랜잭션) + 안전한 재시도의 3박자가 중요합니다.
8) 마무리: 가장 효과가 큰 5가지 처방
- 트랜잭션을 짧게(외부 호출/긴 로직을 트랜잭션 밖으로)
- UPDATE/SELECT FOR UPDATE 조건에 인덱스 맞추기
- 동일 자원은 항상 동일한 순서로 잠그기(데드락 급감)
- 배치는 청크 처리 + 오프피크 + PK 범위
- 1213은 재시도(지터 백오프) + 멱등성 보장
원하시면 SHOW ENGINE INNODB STATUS의 데드락 로그를 익명화해서 붙여주시면, 어떤 인덱스/쿼리/락 순서가 문제인지 로그 기반으로 원인까지 짚어 구체적인 수정안을 제안해 드릴게요.