백엔드에서 데드락은 "가끔 나는 현상"이 아니다. 트래픽이 올라가고, 동일 로직이 컨슈머 워커 N대에서 병렬로 돌고, 트랜잭션이 살짝 길어지기 시작하면 숨어 있던 락 충돌이 한꺼번에 터진다. 그리고 그 시점은 대부분 프로모션, 쿠폰 발급, 알림 발송 같은 "돈과 고객 경험이 걸린 순간"이다.
CJ OliveYoung 같은 이커머스 백엔드에서 실제로 터지는 장애의 패턴을 보면 이렇다. 주문 완료 → 알림톡 발송 이벤트를 SQS에 넣는다 → 컨슈머 여러 대가 같은 테이블(notification_dispatch, order_notification_log)을 업데이트한다 → 데드락이 수십 개씩 발생한다 → 컨슈머가 재시도를 퍼붓는다 → HikariCP 커넥션 풀이 말라버린다 → 본 서비스 API까지 5xx가 터진다. 이 연쇄 반응을 겪어본 사람은 "데드락은 격리된 DB 이슈"라는 말을 못 한다.
시니어 백엔드 면접에서 "주문 처리 중 데드락이 발생하고 있어요. 어떻게 접근하시겠습니까"라는 질문은 거의 항상 나온다. 이때 기대하는 답은 "재시도하면 됩니다"가 아니다. 락 레벨을 읽어내고, SHOW ENGINE INNODB STATUS 로그를 해독하고, 원인을 설계 단계까지 되짚어가는 능력이다. 이 문서는 그 능력을 재현 가능한 수준으로 정리한다.
SELECT ... LOCK IN SHARE MODE 또는 외래키 참조 확인 시 획득. 다른 트랜잭션의 S는 허용, X는 차단.UPDATE, DELETE, SELECT ... FOR UPDATE에서 획득. S/X 모두 차단.단순해 보이지만, 이 조합에서 "FK 제약이 걸린 INSERT는 부모 테이블에 S 락을 건다"는 사실을 놓치면 데드락 로그를 절대 못 읽는다.
REPEATABLE READ(RR) 격리 수준에서 InnoDB가 쓰는 핵심 락 단위다.
예를 들어 인덱스에 user_id = 10, 20, 30 레코드가 있을 때 SELECT ... WHERE user_id BETWEEN 15 AND 25 FOR UPDATE를 실행하면 InnoDB는 20 레코드뿐 아니라 (10, 20]과 (20, 30] Gap까지 락을 건다. 이 때문에 다른 트랜잭션이 user_id = 22를 INSERT하려 하면 Gap Lock에 걸려 대기한다.
테이블 레벨의 "선언용" 락이다. "나는 이 테이블의 어딘가에 S/X 락을 걸 계획이다"를 알리는 용도. 실제 레코드 락과는 충돌하지 않지만, LOCK TABLES나 DDL과 충돌한다. 로그에 IX, IS가 보이면 "아 테이블 레벨 의도 락이구나" 정도로 읽고 넘어간다.
innodb_autoinc_lock_mode에 따라 동작이 바뀐다. 기본값(2, consecutive)은 대부분 가볍지만, INSERT ... SELECT나 벌크 INSERT에서 긴 락이 생길 수 있다.데드락은 두 개 이상의 트랜잭션이 서로가 들고 있는 락을 기다려서 영원히 풀리지 않는 상태다. InnoDB는 대기 그래프(wait-for graph)를 주기적으로 검사해 사이클을 발견하면 한 트랜잭션을 희생자로 골라 롤백시킨다.
-- 1. 최근 발생한 데드락 로그 (가장 중요)
SHOW ENGINE INNODB STATUS;
-- 출력 중 "LATEST DETECTED DEADLOCK" 섹션이 핵심
-- 2. 현재 실행 중인 트랜잭션
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
trx_query, trx_rows_locked, trx_rows_modified,
trx_isolation_level
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- 3. 지금 이 순간 걸려 있는 락 (MySQL 8 기준)
SELECT ENGINE_TRANSACTION_ID AS trx_id,
OBJECT_SCHEMA, OBJECT_NAME, INDEX_NAME,
LOCK_TYPE, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks;
-- 4. 락 대기 관계
SELECT REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx,
BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx
FROM performance_schema.data_lock_waits;
운영 환경에서는 innodb_print_all_deadlocks = ON으로 해두면 발생 즉시 에러 로그로 떨어져 추적이 쉬워진다.
아래는 실전에서 자주 보는 로그 형태다. 이걸 한 줄씩 해독할 줄 알아야 한다.
LATEST DETECTED DEADLOCK
------------------------
2026-04-17 03:14:21 0x7f9a
*** (1) TRANSACTION:
TRANSACTION 4821993, ACTIVE 0 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 88123, OS thread handle ...
UPDATE notification_dispatch
SET status = 'SENT'
WHERE dispatch_id = 120451;
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 512 page no 41 n bits 144 index PRIMARY
of table `oy`.`notification_dispatch` trx id 4821993 lock_mode X locks rec but not gap
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 512 page no 41 n bits 144 index idx_order_id
of table `oy`.`notification_dispatch` trx id 4821993 lock_mode X waiting
*** (2) TRANSACTION:
TRANSACTION 4821994, ACTIVE 0 sec starting index read
UPDATE notification_dispatch
SET retry_count = retry_count + 1
WHERE order_id = 998877;
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 512 page no 41 n bits 144 index idx_order_id
of table `oy`.`notification_dispatch` trx id 4821994 lock_mode X
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 512 page no 41 n bits 144 index PRIMARY
of table `oy`.`notification_dispatch` trx id 4821994 lock_mode X waiting
*** WE ROLL BACK TRANSACTION (2)
해독 포인트:
PRIMARY, idx_order_id 같은 값. "어느 인덱스에서 락 충돌이 일어나는지"가 로그의 핵심.lock_mode X locks rec but not gap: Record Lock만 (RC 격리 수준이거나 unique index 조회). lock_mode X만 있으면 Next-Key Lock.undo log 크기, 즉 롤백 비용이 더 작은 트랜잭션을 희생자로 고른다. 큰 벌크 UPDATE는 살아남고, 짧은 UPDATE가 죽는 경향이 있다.-- 트랜잭션 A
BEGIN;
UPDATE account SET balance = balance - 1000 WHERE id = 1;
UPDATE account SET balance = balance + 1000 WHERE id = 2;
-- 트랜잭션 B
BEGIN;
UPDATE account SET balance = balance - 500 WHERE id = 2;
UPDATE account SET balance = balance + 500 WHERE id = 1;
A는 1→2, B는 2→1. 둘이 동시에 돌면 데드락. 해결: 항상 MIN(id), MAX(id) 순서로 정렬해서 락을 건다.
부모 테이블을 참조하는 FK가 있는 자식 테이블에 INSERT할 때 InnoDB는 부모 레코드에 S 락을 건다. 부모를 같은 순간에 UPDATE(X 락)하려는 다른 트랜잭션이 있으면 데드락.
-- 컨슈머 A
SELECT * FROM outbox WHERE status = 'PENDING' LIMIT 10 FOR UPDATE SKIP LOCKED;
-- 컨슈머 B
UPDATE outbox SET status = 'DONE' WHERE id IN (...);
SKIP LOCKED가 없으면 Gap Lock이 넓게 잡히고, B의 UPDATE가 Gap Lock과 충돌한다. MySQL 8에서 FOR UPDATE SKIP LOCKED는 컨슈머 패턴의 기본기다.
innodb_autoinc_lock_mode = 1 (consecutive)에서 벌크 INSERT 두 개가 동시에 돌면 AUTO-INC 락 대기가 길어진다. 8.0 기본은 2(interleaved)라 대부분 문제없지만, statement-based replication 쓰는 환경은 여전히 조심해야 한다.
-- 세션 A (RR)
SELECT * FROM coupon WHERE user_id = 100 FOR UPDATE;
-- 이 시점 user_id = 100인 행이 없으면 Gap Lock이 걸림
-- 세션 B
INSERT INTO coupon(user_id, code) VALUES (100, 'X');
-- Insert Intention Lock이 세션 A의 Gap Lock에 막혀 대기
-- 세션 A
INSERT INTO coupon(user_id, code) VALUES (100, 'Y');
-- 세션 B의 Insert Intention Lock이 Gap을 점유 → 데드락
이 패턴은 RR 격리 + unique index 중복 체크 후 INSERT 코드에서 끔찍하게 자주 터진다. 해결은 (1) 격리 수준을 READ COMMITTED로 낮추거나 (2) INSERT ... ON DUPLICATE KEY UPDATE로 원자화하거나 (3) unique 인덱스만 믿고 예외를 잡아 처리하는 것.
컨슈머 환경의 본질은 "같은 로직이 N대에서 동시에 돈다"는 것이다. 테스트 환경에서 멀쩡하던 코드가 운영에서 죽는 이유의 90%가 이것.
@Transactional
public void dispatch(Long orderId) {
NotificationLog log = repo.findByOrderId(orderId).orElse(null);
if (log != null && log.isSent()) return;
if (log == null) {
log = new NotificationLog(orderId);
repo.save(log); // INSERT
}
sender.send(orderId); // 외부 API
log.markSent(); // UPDATE
}
문제:
findByOrderId → save는 원자적이지 않다. 두 컨슈머가 동시에 들어오면 둘 다 null을 보고 둘 다 INSERT한다.DuplicateKeyException을 먹는다. 없으면 중복 발송.order_id에 unique 인덱스를 걸고, INSERT ... ON DUPLICATE KEY UPDATE나 "먼저 insert 시도 → duplicate면 update"로 원자화한다.
INSERT INTO notification_log (order_id, status, created_at)
VALUES (?, 'PENDING', NOW())
ON DUPLICATE KEY UPDATE created_at = created_at;
큐 테이블 패턴에서는 이게 거의 정답이다.
SELECT id FROM outbox
WHERE status = 'PENDING'
ORDER BY id
LIMIT 50
FOR UPDATE SKIP LOCKED;
컨슈머 단위로 orderId를 키로 분산락을 걸어 동일 주문은 한 번에 한 컨슈머만 처리하게 만든다. 단, 이건 "락 순서 뒤집힘"을 해결하지 못하므로 DB 설계와 함께 간다.
트랜잭션 안에서 외부 HTTP 호출을 하지 않는다. DB 상태 변경만 트랜잭션 안에 두고, 발송은 트랜잭션 커밋 후 이벤트 리스너/아웃박스 패턴으로 분리한다.
@Version으로 충분하다.HikariCP maximumPoolSize = 20인 서비스에서 긴 트랜잭션 + 데드락 재시도가 결합되면 이런 일이 일어난다.
t=0s : 컨슈머 10대 각자 트랜잭션 시작 (커넥션 10개 점유)
t=1s : 데드락 발생, 한 쪽 롤백, 재시도
t=2s : 재시도 트랜잭션이 같은 락을 또 기다림
t=3s : 컨슈머 추가 10대 가세 (커넥션 20개 점유, 풀 고갈)
t=5s : 본 서비스 API 요청이 커넥션을 못 받아 타임아웃
t=8s : 헬스체크 실패로 인스턴스 순환 재시작
방어책:
connectionTimeout을 짧게(예: 3s) 잡아 빠르게 실패.@Transactional(timeout = 3))과 innodb_lock_wait_timeout(기본 50s → 5~10s로 낮춤)을 정렬.Spring은 데드락을 DeadlockLoserDataAccessException(DataAccessException 계열)으로 감싼다. 재시도는 트랜잭션 밖에서 해야 한다. 트랜잭션 내부에서 재시도하면 같은 트랜잭션이 이미 롤백 표시된 상태라 의미가 없다.
@Service
public class NotificationService {
@Retryable(
retryFor = { DeadlockLoserDataAccessException.class,
CannotAcquireLockException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 50, multiplier = 2.0, random = true)
)
public void dispatch(Long orderId) {
txTemplate.execute(status -> {
doDispatch(orderId);
return null;
});
}
@Recover
public void recover(DeadlockLoserDataAccessException e, Long orderId) {
deadLetterQueue.send(orderId, e.getMessage());
}
}
포인트:
@Retryable은 @Transactional을 감싸는 바깥 레이어에 둔다.random = true를 준다. 동시에 재시도하는 컨슈머가 또 부딪힐 수 있다.Docker로 MySQL 8 띄우고 두 세션으로 재현한다.
docker run --name mysql8 -e MYSQL_ROOT_PASSWORD=root \
-e MYSQL_DATABASE=labs -p 3306:3306 -d mysql:8.0 \
--innodb_print_all_deadlocks=ON \
--transaction_isolation=REPEATABLE-READ
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance BIGINT NOT NULL
) ENGINE=InnoDB;
INSERT INTO account VALUES (1, 10000), (2, 10000);
두 개의 mysql 클라이언트 세션을 연다.
세션 A:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 1; -- X lock on id=1
-- 여기서 멈춤
세션 B:
BEGIN;
UPDATE account SET balance = balance - 100 WHERE id = 2; -- X lock on id=2
UPDATE account SET balance = balance + 100 WHERE id = 1; -- 대기 (A가 id=1 락 소유)
세션 A로 돌아와서:
UPDATE account SET balance = balance + 100 WHERE id = 2;
-- ERROR 1213 (40001): Deadlock found when trying to get lock;
-- try restarting transaction
곧바로:
SHOW ENGINE INNODB STATUS\G
LATEST DETECTED DEADLOCK 블록을 읽어 둘의 HOLDS / WAITING FOR 패턴이 정확히 엇갈리는 것을 확인한다.
CREATE TABLE coupon (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT NOT NULL,
code VARCHAR(32) NOT NULL,
UNIQUE KEY uk_user (user_id, code)
) ENGINE=InnoDB;
세션 A:
BEGIN;
SELECT * FROM coupon WHERE user_id = 500 FOR UPDATE; -- 결과 0건, Gap Lock 획득
세션 B:
BEGIN;
SELECT * FROM coupon WHERE user_id = 501 FOR UPDATE; -- 결과 0건, 다른 Gap Lock
INSERT INTO coupon(user_id, code) VALUES (500, 'B'); -- A의 Gap에 막혀 대기
세션 A:
INSERT INTO coupon(user_id, code) VALUES (501, 'A'); -- B의 Gap에 막혀 데드락
해결안을 비교해본다.
INSERT 둘 다 성공하거나 unique 제약 위반만 난다.INSERT ... ON DUPLICATE KEY UPDATE를 쓰면: SELECT FOR UPDATE가 필요 없어지고 Gap Lock 자체를 피한다.나쁜 버전
@Transactional
public void processOrder(Long orderId, Long userId, List<Long> itemIds) {
Order order = orderRepo.findById(orderId).orElseThrow();
for (Long itemId : itemIds) {
Stock stock = stockRepo.findByIdForUpdate(itemId);
stock.decrease(1);
}
emailClient.sendConfirmation(userId); // 외부 I/O
order.markPaid();
}
문제:
itemIds가 호출마다 순서가 다르면 락 순서가 달라져 데드락.findByIdForUpdate가 보조 인덱스를 타면 Next-Key Lock이 불필요하게 넓게 걸림.개선된 버전
public void processOrder(Long orderId, Long userId, List<Long> itemIds) {
List<Long> sorted = itemIds.stream().sorted().toList(); // 락 순서 고정
txTemplate.execute(status -> {
Order order = orderRepo.findById(orderId).orElseThrow();
for (Long itemId : sorted) {
int updated = stockRepo.decreaseIfAvailable(itemId, 1); // 단일 UPDATE
if (updated == 0) throw new OutOfStockException(itemId);
}
order.markPaid();
eventPublisher.publishAfterCommit(new OrderPaidEvent(orderId, userId));
return null;
});
}
stockRepo.decreaseIfAvailable:
UPDATE stock SET qty = qty - :n
WHERE item_id = :itemId AND qty >= :n;
개선 포인트:
itemId 오름차순으로 고정.SELECT FOR UPDATE 대신 조건부 UPDATE로 락 구간 단축.@TransactionalEventListener(AFTER_COMMIT)로 분리.innodb_lock_wait_timeout을 짧게 세팅해 커넥션 고갈 방지.Deadlock found 카운트.SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK, innodb_print_all_deadlocks=ON으로 남는 에러 로그. 가능하면 5분치 이상.innodb_trx, data_locks, data_lock_waits로 그 순간 어떤 락이 걸려 있었는지 확인.k6, jmeter)로 돌려 데드락 수가 0에 수렴하는지 확인. 수정 전/후 에러 카운트를 그래프로 붙인다.slot팀에서 "DB 유니크 키 기반 동시성 제어"와 "복합 인덱스 튜닝으로 락 범위 축소"를 해본 경험은 이 주제와 정확히 맞물린다. 면접에서 이렇게 연결한다.
UNIQUE(user_id, event_id)에 올리고 INSERT ... ON DUPLICATE KEY UPDATE로 원자화했습니다. Gap Lock으로 발생하던 데드락이 사라졌습니다."(status, updated_at) 인덱스를 만들어 컨슈머 조회가 풀 스캔 대신 인덱스 레인지를 타도록 바꿨습니다. Next-Key Lock 범위가 좁아져 경합이 줄었습니다."이런 구체 수치(예: "데드락 분당 20건 → 0건", "p99 레이턴시 800ms → 120ms")까지 준비하면 시니어 톤이 완성된다.
질문: "주문 처리에서 데드락이 계속 나고 있어요. 어떻게 분석하고 해결하시겠어요?"
답변 구조 (STAR + 기술 디테일)
innodb_print_all_deadlocks을 켜고, SHOW ENGINE INNODB STATUS의 LATEST DETECTED DEADLOCK 블록을 수집합니다. performance_schema.data_locks로 실시간 락 상태도 봅니다."FOR UPDATE SKIP LOCKED나 분산락으로 경합을 줄이고, 외부 I/O는 트랜잭션 밖으로 뺍니다."DeadlockLoserDataAccessException에 대해 지수 백오프 + 지터로 최대 3회 재시도, 실패 시 DLQ. 재시도는 반드시 트랜잭션 외부에서 합니다."innodb_lock_wait_timeout, 트랜잭션 타임아웃을 같이 조정합니다. 한 가지만 만지면 다른 곳이 터집니다."여기에 본인 경험("slot팀에서 유니크 키로 중복 발급 데드락을 없앴다", "복합 인덱스로 Next-Key Lock 범위를 좁혀 경합을 70% 줄였다")을 한 문장 얹으면 바로 시니어 톤이다.
LATEST DETECTED DEADLOCK 블록을 보고 TRX1/TRX2의 HOLDS / WAITING FOR 인덱스를 짚어낼 수 있다.performance_schema.data_locks와 data_lock_waits를 조인해 현재 대기 관계를 뽑는 쿼리를 쓸 수 있다.SELECT FOR UPDATE SKIP LOCKED를 언제 쓰는지, 왜 쓰는지 말할 수 있다.@Transactional 배치 순서를 실수 없이 그릴 수 있다.innodb_lock_wait_timeout, @Transactional(timeout)을 함께 설계할 수 있다.