- Published on
MySQL InnoDB 데드락 1213 재현·원인·해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 로그에 ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction 이 찍히면, 많은 팀이 “가끔 나는 현상”으로만 취급하고 재시도 로직으로 덮어버립니다. 하지만 1213은 단순 운이 아니라 잠금 순서, 인덱스, 접근 패턴이 만들어낸 구조적 결과인 경우가 많습니다. 이 글에서는 InnoDB 데드락을 재현 가능한 형태로 만들고, SHOW ENGINE INNODB STATUS 로 원인을 읽는 법, 그리고 재발을 줄이는 설계/쿼리/운영 대책을 정리합니다.
운영 트랜잭션 장애는 “재시도”만으로 끝내기보다, 분산 트랜잭션/보상 관점까지 고려하면 더 안전합니다. 트랜잭션 실패가 비즈니스에 어떤 보상 로직을 요구하는지에 대해서는 Saga 패턴 보상 트랜잭션 실패 원인 6가지도 함께 참고하면 좋습니다.
1) 1213 데드락이란: 교착 상태와 InnoDB의 선택
데드락은 두(또는 그 이상) 트랜잭션이 서로가 가진 락을 기다리며 순환 대기에 빠진 상태입니다.
- 트랜잭션 A: 리소스 1 락 보유, 리소스 2 락 대기
- 트랜잭션 B: 리소스 2 락 보유, 리소스 1 락 대기
InnoDB는 이런 상황을 감지하면 둘 중 하나를 희생자(victim)로 롤백시키고, 나머지는 진행시킵니다. 이때 희생자 쪽에서 흔히 보는 에러가 1213입니다.
중요 포인트는 다음입니다.
- 1213은 “DB가 죽었다”가 아니라 “당신의 트랜잭션 하나가 롤백되었다”입니다.
- 따라서 애플리케이션은 idempotent(멱등)한 재시도 또는 보상 트랜잭션을 고려해야 합니다.
2) 가장 흔한 재현 패턴: 같은 테이블을 다른 순서로 업데이트
데드락을 가장 쉽게 재현하는 방법은 두 행을 서로 반대 순서로 업데이트하는 것입니다.
2.1 재현용 테이블
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance INT NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB;
INSERT INTO accounts (id, balance) VALUES (1, 1000), (2, 1000);
2.2 세션 1
-- SESSION 1
SET autocommit = 0;
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- 일부러 타이밍을 벌려 교착을 만들기
DO SLEEP(2);
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
2.3 세션 2
-- SESSION 2
SET autocommit = 0;
START TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE id = 2;
DO SLEEP(2);
UPDATE accounts SET balance = balance + 100 WHERE id = 1;
COMMIT;
두 세션을 거의 동시에 실행하면, 한쪽이 1213으로 롤백될 가능성이 큽니다.
핵심은 “업데이트 대상이 같아서”가 아니라 락 획득 순서가 서로 반대라는 점입니다.
3) SHOW ENGINE INNODB STATUS 로 데드락 읽기
데드락이 발생한 직후 아래를 실행합니다.
SHOW ENGINE INNODB STATUS\G
출력에서 LATEST DETECTED DEADLOCK 섹션을 찾으면 다음 정보가 나옵니다.
- 어떤 트랜잭션들이 충돌했는지
- 각 트랜잭션이 보유한 락과 기다리는 락
- 어떤 인덱스/레코드(또는 갭)에서 락이 걸렸는지
- 어떤 쿼리가 실행 중이었는지
실무 팁:
- 데드락은 “마지막 1건”만 저장되는 경우가 많습니다. 운영에서는
performance_schema기반의events_transactions_*또는 DB 로그 수집으로 보강하는 편이 안전합니다. - 재현 환경에서는 데드락 직후 곧바로 확인해야 정보가 남습니다.
4) 원인 해부: InnoDB 락의 3가지 함정
1213의 원인은 단순히 “동시성”이 아니라 InnoDB의 락 모델과 쿼리 설계가 결합된 결과입니다.
4.1 락 순서 불일치 (가장 흔함)
앞서 재현한 것처럼, 같은 리소스를 업데이트하더라도 항상 같은 순서로 락을 잡으면 데드락 확률이 크게 줄어듭니다.
- 나쁜 예:
id=1후id=2와id=2후id=1이 섞임 - 좋은 예: 항상 작은
id부터 업데이트
4.2 인덱스 부재로 인한 “의도치 않은 범위 락”
WHERE 조건에 적절한 인덱스가 없으면, InnoDB는 더 많은 레코드를 스캔하며 락을 잡을 수 있습니다. 그 결과 데드락/락 대기 모두 증가합니다.
예를 들어 다음 쿼리를 생각해봅니다.
UPDATE orders
SET status = 'PAID'
WHERE user_id = 10 AND status = 'PENDING';
여기서 (user_id, status) 복합 인덱스가 없으면, 불필요한 스캔이 늘고 락 범위가 커질 수 있습니다.
권장:
CREATE INDEX idx_orders_user_status ON orders (user_id, status);
4.3 REPEATABLE READ에서의 갭 락/넥스트키 락
MySQL InnoDB 기본 격리수준인 REPEATABLE READ 에서는 팬텀 리드를 막기 위해 갭 락(gap lock), 넥스트키 락(next-key lock) 이 등장합니다. 이 락은 “존재하지 않는 범위”까지 잠그기 때문에, 개발자가 예상한 것보다 충돌면적이 커집니다.
특히 다음 패턴이 위험합니다.
- 범위 조건:
WHERE created_at BETWEEN ... - 정렬 + 제한:
ORDER BY ... LIMIT ...로 특정 행을 “선점”하려는 패턴 - 유니크하지 않은 인덱스 조건으로
SELECT ... FOR UPDATE
예시:
START TRANSACTION;
SELECT id FROM coupons
WHERE user_id = 10 AND used = 0
ORDER BY id
LIMIT 1
FOR UPDATE;
여기서 인덱스 설계가 애매하면, “딱 1장만 잠그는” 의도와 달리 더 넓은 범위를 락으로 감쌀 수 있습니다.
5) 해결 전략: 재시도만 하지 말고 구조를 바꾸기
해결책은 크게 4계층으로 나눌 수 있습니다.
- 쿼리/트랜잭션 설계
- 인덱스/스키마
- 격리수준/락 힌트
- 애플리케이션 재시도/보상
5.1 락 획득 순서를 강제하라
여러 행/여러 테이블을 업데이트한다면 “항상 같은 순서”를 팀 규칙으로 만드세요.
- 동일 테이블 다건 업데이트: PK 오름차순으로
- 여러 테이블 업데이트: 예를 들어
users후orders후payments처럼 고정
다건 업데이트의 경우, 애플리케이션에서 정렬 후 순서대로 처리하거나, SQL에서 정렬을 활용합니다.
-- 예: 두 계좌를 항상 작은 id부터 잠그기
START TRANSACTION;
SELECT id FROM accounts
WHERE id IN (1, 2)
ORDER BY id
FOR UPDATE;
-- 이후 업데이트
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;
SELECT ... FOR UPDATE 로 “잠금 순서”를 먼저 맞춰두면 데드락 확률이 크게 줄어듭니다.
5.2 트랜잭션을 짧게: 락 보유 시간을 줄여라
데드락은 락을 오래 잡을수록 유리(?)해집니다. 다음을 점검하세요.
- 트랜잭션 안에서 외부 API 호출, 파일 I/O, 긴 계산을 수행하지 않는지
- 불필요한 사용자 입력 대기(웹 요청 처리) 구간이 트랜잭션 안에 들어가 있지 않은지
- ORM이 의도치 않게 트랜잭션 범위를 넓히지 않는지
5.3 인덱스를 “락을 줄이는 도구”로 바라보기
인덱스는 단지 성능만이 아니라 락 범위를 축소합니다.
체크리스트:
- 업데이트/삭제 쿼리의
WHERE조건이 인덱스를 타는가 (EXPLAIN확인) SELECT ... FOR UPDATE조건 컬럼이 적절히 인덱스화되어 있는가- 복합 인덱스의 컬럼 순서가 실제 조건과 맞는가
EXPLAIN UPDATE orders
SET status = 'PAID'
WHERE user_id = 10 AND status = 'PENDING';
type 이 ALL 이거나 rows 가 과도하게 크면, 락 범위도 커질 가능성이 높습니다.
5.4 격리수준 조정: 필요한 곳에만 적용
갭 락이 문제의 핵심이라면, 일부 트랜잭션에 한해 READ COMMITTED 로 낮춰 충돌을 줄이는 선택지가 있습니다.
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 작업 수행
COMMIT;
주의:
- 격리수준 변경은 “정합성 요구사항”과 맞교환입니다.
- 팬텀 리드가 비즈니스적으로 문제가 되는지 먼저 합의해야 합니다.
5.5 애플리케이션 레벨: 지수 백오프 재시도 + 멱등성
InnoDB가 희생자를 롤백시키는 것은 정상 동작이므로, 애플리케이션은 1213을 재시도 가능한 오류로 분류하는 경우가 많습니다. 다만 재시도는 반드시 멱등성을 갖춰야 합니다.
Node.js 예시(의사 코드):
async function withRetry(txFn, max = 5) {
let delay = 30;
for (let i = 0; i < max; i++) {
try {
return await txFn();
} catch (e) {
const code = e?.errno || e?.code;
const msg = String(e?.message || "");
const deadlock = code === 1213 || msg.includes("Deadlock found");
if (!deadlock || i === max - 1) throw e;
await new Promise(r => setTimeout(r, delay));
delay *= 2;
}
}
}
실무 팁:
- 재시도는 “즉시 무한 재시도”가 아니라 최대 횟수 + 지수 백오프로 제한하세요.
- 결제/포인트/쿠폰 같은 작업은 요청 키(예:
request_id) 기반으로 중복 실행 방지 테이블을 두는 방식이 안전합니다.
6) 운영에서의 진단 루틴: 재현이 안 될 때 무엇을 볼까
운영에서는 데드락이 “가끔”만 나고 재현이 어려운 경우가 많습니다. 아래 순서로 접근하면 원인에 빨리 닿습니다.
6.1 데드락 로그 수집과 상관관계
SHOW ENGINE INNODB STATUS의 데드락 섹션을 주기적으로 수집(또는 발생 시점에 자동 수집)- 애플리케이션 로그에 트랜잭션 경계(시작/커밋/롤백), 요청 ID, 핵심 파라미터를 남김
분산 환경에서 “한 요청이 여러 컴포넌트를 건드리며 실패”하는 유형은 장애가 연쇄로 커질 수 있습니다. 이런 연쇄 장애를 차단하는 관점은 systemd 서비스 재시작 무한루프 - 원인과 차단법처럼 “실패를 제어 가능한 형태로 만들기”와 결이 같습니다.
6.2 핫스팟 탐지
데드락은 보통 다음 중 하나의 핫스팟에서 반복됩니다.
- 특정 사용자/특정 상품 등 동일 키에 트래픽 집중
- “마지막 1개 가져오기” 같은 선점 로직
- 상태 전이 테이블(예: 주문 상태)에서 같은 행을 여러 워커가 경쟁
핫스팟이 확인되면, DB만이 아니라 작업 큐/샤딩/락 전략까지 확장해서 봐야 합니다.
6.3 대기 락 관측
가능하면 performance_schema 의 락/대기 이벤트를 켜고, 어떤 SQL이 오래 기다리는지 봅니다. (환경에 따라 오버헤드가 있어 운영 정책에 맞게 조정)
7) 자주 쓰는 해결 패턴 3가지
패턴 A: “선점 후 처리”를 명시적으로 만들기
여러 워커가 같은 작업을 가져가 처리한다면 다음처럼 “가져갈 행을 잠그고” 처리합니다.
START TRANSACTION;
SELECT id
FROM jobs
WHERE status = 'READY'
ORDER BY id
LIMIT 1
FOR UPDATE;
UPDATE jobs SET status = 'RUNNING' WHERE id = ?;
COMMIT;
여기서도 인덱스가 중요합니다.
CREATE INDEX idx_jobs_status_id ON jobs (status, id);
패턴 B: 상태 전이는 단일 행에 모으되, 조건 업데이트로 경쟁을 줄이기
낙관적 형태로, 조건을 만족할 때만 업데이트되게 만들고 영향 행 수로 성공 여부를 판단합니다.
UPDATE orders
SET status = 'PAID'
WHERE id = 123 AND status = 'PENDING';
이 방식은 데드락 자체를 “0”으로 만들지는 못해도, 불필요한 락 확장을 줄이고 애플리케이션 로직을 단순화합니다.
패턴 C: 유니크 키로 멱등성 확보
예: 결제 요청마다 request_id 를 발급하고 유니크로 강제합니다.
CREATE TABLE payment_requests (
request_id CHAR(36) PRIMARY KEY,
order_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB;
요청 처리 시 먼저 삽입을 시도하고, 중복이면 이미 처리된 것으로 간주합니다.
8) 정리: 1213을 줄이는 체크리스트
- 여러 행/테이블을 업데이트한다면 락 순서를 표준화했다
- 트랜잭션 내부에서 느린 작업(외부 호출, 대기, 대량 처리)을 제거했다
WHERE조건과SELECT ... FOR UPDATE에 필요한 인덱스를 갖췄다EXPLAIN으로 스캔 범위를 확인했다- 갭 락이 문제면 일부 트랜잭션에
READ COMMITTED적용을 검토했다 - 1213은 제한된 재시도(지수 백오프)로 처리하고, 멱등성 키로 중복 실행을 막았다
- 운영에서 데드락 정보를 수집하고 요청 ID로 상관관계를 추적한다
데드락은 “없애는” 것보다 “충돌면적을 줄이고, 실패를 통제 가능한 형태로 만드는” 것이 현실적인 목표입니다. 재현 스크립트로 패턴을 고정한 뒤, 인덱스와 락 순서부터 하나씩 바꾸어 보면 1213은 눈에 띄게 줄어듭니다.