Published on

MySQL InnoDB 데드락 폭증, Redis 분산락 튜닝

Authors

서버가 멀쩡한데도 MySQL InnoDB 데드락이 특정 시간대에 폭증하면, 애플리케이션 레벨에서의 동시성 제어가 DB의 락 모델과 충돌하고 있다는 신호인 경우가 많습니다. 특히 SELECT ... FOR UPDATE 기반의 비즈니스 로직, 재고/쿠폰/포인트 같은 단일 리소스 갱신, 또는 동일 키에 대한 중복 요청이 짧은 시간에 몰리는 패턴에서 자주 발생합니다.

이 글에서는 (1) InnoDB 데드락을 재현·분석하는 방법, (2) DB 튜닝만으로 해결이 안 되는 케이스에서 Redis 분산락으로 경합을 외부로 이동시키는 접근, (3) “분산락을 넣었는데도 여전히 느리거나 장애가 나는” 상황에서의 튜닝 포인트를 다룹니다.

관련해서 데드락 분석 자체는 아래 글도 함께 보면 좋습니다.

또한 데드락 폭증은 종종 커넥션 풀 고갈로 2차 장애로 번지기도 합니다.

데드락 폭증의 전형적인 패턴

패턴 1: 동일 리소스에 대한 “교차 업데이트”

예를 들어 주문 생성 시 inventorycoupon을 업데이트하는데, 요청 A는 inventory를 먼저 잠그고 coupon을 잠그는 반면 요청 B는 반대로 coupon을 먼저 잠그고 inventory를 잠그면 교착이 쉽게 생깁니다.

  • 트랜잭션 락 획득 순서가 요청마다 달라짐
  • 복합 인덱스 부재로 인해 레인지 락 또는 넓은 갭 락이 발생
  • 트랜잭션이 길어 락 보유 시간이 길어짐

패턴 2: SELECT ... FOR UPDATE가 “생각보다 넓게” 잠금

인덱스가 부정확하거나 조건이 모호하면, 단일 row를 잠글 의도였는데 범위가 잠겨 경합이 커집니다. InnoDB의 넥스트 키 락 특성상, 레인지 조건과 결합될 때 특히 체감이 큽니다.

패턴 3: 재시도 로직이 데드락을 증폭

데드락은 MySQL이 한 트랜잭션을 롤백시키면서 해소하지만, 애플리케이션이 즉시 재시도하면 같은 타이밍에 같은 경합이 반복되어 “폭증”처럼 보일 수 있습니다.

  • 재시도 지수 백오프가 없음
  • 모든 요청이 동일한 TTL 윈도우에 몰림
  • 실패한 요청이 다시 DB를 때림

1단계: InnoDB 데드락을 “증거 기반”으로 분석하기

SHOW ENGINE INNODB STATUS로 최근 데드락 확인

운영에서 가장 빠른 단서입니다.

SHOW ENGINE INNODB STATUS\G

여기서 확인할 핵심은 아래입니다.

  • 어떤 쿼가 어떤 인덱스를 잡고 있었는지
  • 어떤 락을 기다리다가 데드락이 됐는지
  • 락 순서가 교차됐는지

운영에서는 로그로 남기기 어렵기 때문에, MySQL 설정에서 데드락 로그를 더 잘 남기도록 하는 것도 중요합니다.

performance_schema로 락 대기 관측

MySQL 8 기준으로는 performance_schema의 락 관련 테이블이 유용합니다.

SELECT *
FROM performance_schema.data_locks
ORDER BY ENGINE_LOCK_ID;

SELECT *
FROM performance_schema.data_lock_waits;

이걸로 “누가 누구를 기다리는지”를 시점 단위로 볼 수 있어, 재현이 어려운 간헐적 데드락에 특히 도움이 됩니다.

DB 튜닝만으로 해결이 어려운 경우

DB 관점에서의 정석은 다음입니다.

  • 트랜잭션을 짧게
  • 락 획득 순서를 통일
  • 인덱스를 올바르게 구성해 잠금 범위를 최소화
  • 필요하면 격리 수준을 재검토

하지만 다음 조건이 겹치면 DB 튜닝만으로는 “폭증”을 막기 어렵습니다.

  • 동일 키에 요청이 폭주하는 핫 키가 존재
  • 트래픽이 버스트 형태로 몰림
  • 비즈니스 상 직렬화가 자연스러운 작업(예: 사용자별 정산, 단일 쿠폰 소진)

이때는 “DB에 들어가기 전에” 요청을 직렬화하거나, 최소한 락 경합을 줄이는 외부 조절 장치가 필요합니다. 여기서 Redis 분산락이 등장합니다.

Redis 분산락의 목표: DB 락 경합을 외부로 이동

핵심 아이디어는 단순합니다.

  • DB 트랜잭션으로만 직렬화를 달성하려 하면, 락 경합이 곧바로 데드락과 대기열 폭증으로 이어짐
  • Redis에서 짧은 락으로 “동일 리소스에 대한 동시 진입”을 제한하면, DB에는 직렬화된 요청만 들어가므로 데드락 가능성이 급감

중요한 점은, Redis 락은 만능이 아니라 “핫 키 직렬화”에 특히 효과적이라는 것입니다.

구현 전략 1: 단일 키 락 + 짧은 임계구역

락 키 설계

리소스 단위로 키를 나눕니다.

  • 사용자 단위: lock:user:{userId}
  • 상품 재고 단위: lock:inventory:{productId}
  • 쿠폰 단위: lock:coupon:{couponId}

락 키가 너무 넓으면 병렬성이 죽고, 너무 좁으면 데드락 유사 문제가 남습니다.

안전한 락 획득: SET key value NX PX ttl

Redis에서 가장 흔한 패턴입니다.

SET lock:inventory:42 8f3a1c2e-1c0a-4d1a-9a2a-6b0c4b4f9c3e NX PX 3000
  • value는 반드시 랜덤 토큰(소유자 식별)
  • PX는 락 자동 만료(프로세스 죽었을 때 영구 락 방지)

락 해제는 Lua로 원자성 보장

락 소유자가 맞을 때만 삭제해야 합니다.

-- unlock.lua
if redis.call('GET', KEYS[1]) == ARGV[1] then
  return redis.call('DEL', KEYS[1])
else
  return 0
end

호출 예시는 다음과 같습니다.

EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:inventory:42 8f3a1c2e-1c0a-4d1a-9a2a-6b0c4b4f9c3e

구현 전략 2: 스핀락은 금물, 대기 전략을 튜닝

분산락을 넣고 나서 흔히 생기는 문제는 “락 대기 때문에 응답이 느려졌다”입니다. 특히 아래처럼 무한 루프로 SET NX를 때리면 Redis와 애플리케이션이 같이 터집니다.

나쁜 예: 바쁜 대기

while not acquired:
  acquired = tryLock()

권장: 제한된 재시도 + 지수 백오프 + 지터

maxAttempts = 10
baseSleepMs = 20
for attempt in 1..maxAttempts:
  if tryLock(ttlMs=3000):
    return success
  sleepMs = min(500, baseSleepMs * (2^(attempt-1)))
  sleepMs = sleepMs + random(0, 30)
  sleep(sleepMs)
return fail

포인트는 다음입니다.

  • 재시도 상한을 둬서 tail latency를 제한
  • 지수 백오프로 Redis QPS 폭발 방지
  • 지터로 동시 재시도 동기화 방지

구현 전략 3: 락 TTL은 “DB 트랜잭션 시간”보다 길게, 그러나 과하게 길지 않게

TTL을 너무 짧게 잡으면 락이 만료된 뒤 다른 요청이 들어와 임계구역이 겹칠 수 있습니다. 반대로 TTL이 너무 길면 장애 시 락이 오래 유지되어 가용성이 떨어집니다.

권장 접근:

  • 임계구역에서 실행되는 DB 트랜잭션 p99 시간을 측정
  • TTL을 p99 * 2 + 네트워크 여유 정도로 설정
  • 트랜잭션이 길어질 수 있는 경로(외부 API 호출 등)는 임계구역 밖으로 이동

실무에서는 “락을 잡고 외부 API를 호출”하는 순간 TTL 산정이 어려워지며, 락이 사실상 세마포어처럼 남용됩니다. 임계구역은 반드시 DB 갱신의 최소 구간으로 좁히는 것이 중요합니다.

구현 전략 4: 다중 리소스 락이 필요하면 락 순서를 강제

예: 재고와 쿠폰을 동시에 잠가야 한다면, 항상 정렬된 순서로 락을 잡아야 합니다.

  • productIdcouponId를 합쳐 키를 만들거나
  • 두 락을 잡되, 키 문자열을 정렬해 항상 같은 순서로 획득
keys = ["lock:inventory:42", "lock:coupon:7"]
keys.sort()
acquire(keys[0])
acquire(keys[1])

이 방식은 DB 데드락에서 “락 순서 통일”과 동일한 원리로, 분산락에서도 중요합니다.

Redlock을 써야 할까?

단일 Redis 인스턴스(또는 단일 클러스터 엔드포인트)를 락 저장소로 쓰는 경우가 많습니다. 이때의 트레이드오프는 명확합니다.

  • 장점: 구현 단순, 성능 좋음, 실무에서 충분히 효과적인 경우 많음
  • 단점: Redis 장애 시 락 시스템이 영향을 받음

Redlock은 여러 Redis 노드에 락을 분산해 합의를 보는 접근이지만, 네트워크 지연과 운영 복잡도가 늘고, “정확히 어떤 장애 모델을 방어하는가”에 대한 합의가 팀 내에 없으면 오히려 위험해질 수 있습니다.

현실적인 권장안:

  • 목표가 “데드락 폭증 완화 및 DB 안정화”라면 단일 Redis 기반으로 시작
  • Redis는 HA(예: managed Redis, sentinel, cluster)로 가용성을 확보
  • 락 실패 시의 폴백 정책을 명확히 정의(예: 409 응답, 큐잉, 재시도 제한)

장애/성능 튜닝 체크리스트

1) 락 실패를 정상 흐름으로 취급하기

락 획득 실패는 예외가 아니라 “혼잡 신호”입니다.

  • API라면 HTTP 409 또는 HTTP 429로 빠르게 반환
  • 백그라운드 작업이면 큐로 넘기고 지연 처리

2) 락 대기 시간과 DB 시간 분리 계측

반드시 다음 지표를 분리해 봐야 합니다.

  • Redis 락 획득까지 걸린 시간
  • 락 보유 시간
  • DB 트랜잭션 시간
  • 데드락 발생 수, 롤백 수

락을 넣었는데 느리다면, 느린 원인이 DB인지 락 대기인지부터 갈라야 합니다.

3) 커넥션 풀 고갈로 번지지 않게 방어

락을 잡기 전에 DB 커넥션을 점유하면, 락 대기 동안 커넥션이 묶여 풀 고갈이 빨리 옵니다.

  • “락 획득 후” DB 트랜잭션을 시작
  • 트랜잭션 내부에서의 대기 작업 제거

커넥션 풀 관점의 진단은 아래 글을 참고하세요.

4) 핫 키는 샤딩하거나 작업 모델을 바꾸기

Redis 락은 핫 키를 직렬화하지만, 핫 키 자체가 너무 뜨거우면 처리량이 1개 worker 수준으로 떨어집니다.

  • 사용자 단위 직렬화가 필요하면 “사용자별 큐”로 전환
  • 재고 차감은 이벤트 소싱 또는 비동기 확정 모델 고려
  • 단일 row에 누적 업데이트가 많으면 집계 테이블을 분리

예시 아키텍처: 쿠폰 발급 데드락 폭증을 Redis 락으로 완화

기존 흐름(문제)

  1. 트랜잭션 시작
  2. 쿠폰 row를 SELECT ... FOR UPDATE
  3. 발급 가능 수량 확인
  4. 사용자 발급 이력 insert
  5. 쿠폰 수량 update
  6. 커밋

동시 요청이 많으면 2번과 5번에서 교차 락/인덱스 범위 락이 겹치며 데드락이 튀기 시작합니다.

개선 흐름(락으로 직렬화)

  1. Redis에서 lock:coupon:{couponId} 획득
  2. 트랜잭션 시작
  3. 필요한 row를 최소 범위로 업데이트
  4. 커밋
  5. Redis 락 해제

이때 DB 쿼 자체도 함께 정리해야 효과가 큽니다.

  • 조건절이 인덱스를 정확히 타는지 확인
  • 발급 이력 테이블에 유니크 키로 중복 방지(예: (coupon_id, user_id))
  • 중복 요청은 DB에서 INSERT ... ON DUPLICATE KEY 같은 멱등 처리

마무리: “DB 데드락 해결”과 “분산락 도입”의 역할 분담

Redis 분산락은 InnoDB 데드락의 근본 원인을 완전히 제거하는 만병통치약이 아니라, 경합을 제어해 DB가 감당 가능한 형태로 트래픽을 정렬하는 도구입니다. 따라서 다음 순서가 가장 안전합니다.

  1. InnoDB 데드락 로그로 실제 교착 원인을 특정
  2. 트랜잭션 축소, 인덱스 보강, 락 순서 통일 같은 DB 기본기를 먼저 적용
  3. 그럼에도 핫 키 경합이 남으면 Redis 분산락으로 “진입 제어”를 추가
  4. 락 대기 전략, TTL, 계측, 폴백 정책까지 포함해 운영 가능한 수준으로 튜닝

데드락이 “가끔”이 아니라 “폭증”으로 보일 때는, 대부분 시스템이 이미 혼잡 상태이며 재시도와 대기가 연쇄적으로 증폭되고 있습니다. Redis 락을 넣는 목적은 단지 락을 하나 더 추가하는 게 아니라, 혼잡을 제어하고 실패를 예측 가능하게 만드는 데 있다는 점을 잊지 않는 것이 핵심입니다.