Published on

MySQL 핫스팟 락 폭발? Redis 분산락 튜닝

Authors

서버가 잘 버티다가도 특정 이벤트(쿠폰 오픈, 선착순 결제, 재고 1개 남은 상품, 인기 게시물 좋아요 폭주)가 터지면 MySQL에서 락 경합이 한 지점에 몰리며 지연이 기하급수적으로 늘어납니다. 흔히 핫스팟 락이라고 부르는 상황인데, 대기 스레드가 쌓이면서 커넥션 풀이 고갈되고, 결국 전체 서비스가 느려지는 연쇄 장애로 이어지기 쉽습니다.

이 글은 MySQL 핫스팟 락 대폭발을 어떻게 감지하고, 어떤 경우에 Redis 분산락이 실제로 도움이 되며, 도입했다면 무엇을 튜닝해야 병목을 줄일 수 있는지에 초점을 맞춥니다. 단, 분산락은 은탄환이 아니고 잘못 쓰면 더 큰 장애를 만들 수 있으니 실패 시나리오까지 같이 보겠습니다.

1) MySQL 핫스팟 락이 터지는 전형적인 패턴

단일 로우에 업데이트가 몰리는 경우

예를 들어 재고를 UPDATE stock SET qty = qty - 1 WHERE product_id = 1 AND qty > 0 같은 형태로 처리하면, 성공/실패 여부와 관계없이 product_id = 1 로우에 트래픽이 집중됩니다. InnoDB는 해당 로우에 대한 락을 잡고, 동시에 들어온 트랜잭션은 대기합니다.

핫스팟이 심해지면 다음 현상이 같이 나타납니다.

  • InnoDB row lock waits 증가
  • Threads_running 증가, CPU는 낮은데 응답은 느림(대기가 대부분)
  • 애플리케이션 레벨에서 타임아웃과 재시도 폭증
  • 커넥션 풀 고갈로 전 구간 지연

갭락/넥스트키 락으로 범위가 잠기는 경우

격리 수준이 REPEATABLE READ이고 범위 조건이 들어가면, 의도보다 넓은 범위가 잠겨 경합이 커질 수 있습니다. 예를 들어 SELECT ... FOR UPDATE로 범위를 잡는 패턴은 핫스팟을 더 키웁니다.

유니크 키 충돌을 이용한 락

INSERT ... ON DUPLICATE KEY UPDATE 자체는 편리하지만, 특정 키에 동시성이 몰리면 결국 같은 인덱스 엔트리에서 충돌하며 대기열이 생깁니다.

2) 먼저 해야 할 것: MySQL만으로 줄일 수 있는 병목

분산락을 도입하기 전에, MySQL에서 핫스팟을 줄일 여지가 있는지 먼저 확인하는 편이 안전합니다.

(1) 트랜잭션을 짧게

락을 잡고 있는 시간을 줄이는 게 1순위입니다.

  • 락 잡은 뒤 외부 API 호출 금지
  • 로그/이벤트 발행은 트랜잭션 밖으로
  • 불필요한 조회를 트랜잭션 밖으로

(2) 인덱스/쿼리 형태 점검

핫스팟이 단일 로우라면 인덱스로 해결이 안 될 수 있지만, 불필요하게 넓은 범위를 잠그는 경우는 개선 여지가 큽니다.

  • 조건 컬럼이 인덱스를 타는지 확인
  • 범위 락을 줄이기 위해 쿼리 형태 수정
  • 가능하면 READ COMMITTED 검토(갭락 완화)

(3) 카운터/집계는 샤딩

좋아요 수, 조회수 같은 카운터가 특정 ID에 몰리면 DB가 아니라 설계가 병목입니다.

  • 애플리케이션 레벨에서 N개 버킷으로 샤딩 후 주기적 합산
  • 또는 Redis 카운터로 흡수 후 비동기 플러시

여기까지로 해결이 되면 Redis 분산락이 필요 없을 수도 있습니다. 하지만 선착순 1개 같이 본질적으로 직렬화가 필요한 영역은, DB 락 대기열이 서비스 전체를 잡아먹지 않도록 앞단에서 트래픽을 제어하는 장치가 필요합니다.

3) Redis 분산락이 유효한 조건

Redis 분산락은 공유 자원에 대한 동시 진입을 애플리케이션 레벨에서 제한해서, DB에 락 대기열이 길게 생기기 전에 요청을 빠르게 실패/재시도/대기시키는 전략입니다.

특히 아래 조건에서 효과가 큽니다.

  • 특정 키(예: product_id=1)에만 트래픽이 몰림
  • 임계 구역이 짧고 명확함(재고 차감, 쿠폰 발급 등)
  • 락을 못 잡으면 즉시 실패시키거나 짧게 재시도해도 UX가 허용됨
  • DB 커넥션/스레드가 대기열로 잠식되는 것을 피하고 싶음

반대로, 임계 구역이 길거나(외부 호출 포함), 락을 잡은 상태에서 장애가 잦거나, 강한 공정성이 필수라면 Redis 락은 오히려 복잡도를 올립니다.

4) 기본 구현: SET NX PX와 토큰 검증

Redis 분산락은 최소한 다음을 만족해야 합니다.

  • 락 획득은 원자적으로 SET key value NX PX ttl
  • 해제는 내가 잡은 락인지 토큰을 비교한 뒤 삭제

다음은 Node.js ioredis 기준 예시입니다.

import Redis from "ioredis";
import { randomUUID } from "crypto";

const redis = new Redis(process.env.REDIS_URL);

async function acquireLock(lockKey, ttlMs) {
  const token = randomUUID();
  const ok = await redis.set(lockKey, token, "NX", "PX", ttlMs);
  return ok ? token : null;
}

const RELEASE_LUA = `
if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end
`;

async function releaseLock(lockKey, token) {
  return await redis.eval(RELEASE_LUA, 1, lockKey, token);
}

여기서 ttlMs가 튜닝의 시작점입니다. 너무 짧으면 임계 구역이 끝나기 전에 락이 풀려 중복 진입이 발생하고, 너무 길면 장애 시 회복이 느립니다.

주의: 본문에 SET key value NX PX 같은 명령이 그대로 노출되면 괜찮지만, 부등호가 들어간 표현(예: ttl < 1000)은 MDX에서 문제를 일으킬 수 있으니 이 글에서는 부등호를 쓰지 않고 설명하겠습니다.

5) 튜닝 포인트 1: TTL은 “최악의 실행 시간” 기준으로

TTL은 평균이 아니라 최악의 실행 시간GC/스케줄링 지연까지 고려해 잡아야 합니다.

권장 접근:

  1. 임계 구역의 P99, P999 지연을 측정
  2. 네트워크 지연과 런타임 스톱 더 월드(GC) 여유를 추가
  3. TTL을 그 합보다 충분히 크게

예시로, 재고 차감 트랜잭션이 P99 30ms, P999 80ms라면 TTL을 500ms 정도로 두고, 락 획득 실패 시 빠르게 재시도하는 방식이 자주 쓰입니다.

다만 트랜잭션이 간헐적으로 길어질 수 있다면 락 연장(renew)이 필요합니다.

락 연장(Watchdog) 패턴

락을 잡은 프로세스가 주기적으로 TTL을 갱신해, 작업이 끝날 때까지 락이 유지되게 합니다.

async function startWatchdog(lockKey, token, ttlMs) {
  const extendEvery = Math.floor(ttlMs / 3);

  const EXTEND_LUA = `
  if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('pexpire', KEYS[1], ARGV[2])
  else
    return 0
  end
  `;

  const timer = setInterval(async () => {
    try {
      await redis.eval(EXTEND_LUA, 1, lockKey, token, String(ttlMs));
    } catch (e) {
      // 연장 실패 시 로그만 남기고 종료 여부는 정책적으로 결정
    }
  }, extendEvery);

  return () => clearInterval(timer);
}

Watchdog는 강력하지만, 다음 부작용도 있습니다.

  • 네트워크 파티션이나 프로세스 hang 시 락이 길게 유지될 수 있음
  • 락 연장 트래픽이 Redis에 추가 부하

따라서 임계 구역이 정말 짧다면 Watchdog 없이 TTL을 넉넉히 잡는 게 더 단순하고 안전할 때도 많습니다.

6) 튜닝 포인트 2: 재시도 전략이 성능을 좌우한다

락 경합이 심한 상황에서 모든 요청이 짧은 간격으로 무한 재시도하면, Redis와 애플리케이션이 먼저 터집니다. 재시도는 반드시 상한지터가 있어야 합니다.

  • 최대 재시도 횟수 제한
  • 지수 백오프 + 랜덤 지터
  • 일정 시간 지나면 즉시 실패로 전환(빠른 실패)
function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function withLock({
  lockKey,
  ttlMs,
  maxAttempts,
  baseBackoffMs,
  fn,
}) {
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
    const token = await acquireLock(lockKey, ttlMs);
    if (token) {
      try {
        return await fn();
      } finally {
        await releaseLock(lockKey, token);
      }
    }

    const backoff = baseBackoffMs * Math.pow(2, attempt - 1);
    const jitter = Math.floor(Math.random() * baseBackoffMs);
    await sleep(backoff + jitter);
  }

  const err = new Error("LOCK_NOT_ACQUIRED");
  err.code = "LOCK_NOT_ACQUIRED";
  throw err;
}

핫스팟 구간에서는 재시도 자체를 줄이는 것이 전체 성공률을 올리는 경우가 많습니다. 즉, 락을 못 잡으면 무작정 기다리기보다 빠르게 실패시키고, 클라이언트/큐/스케줄러 레벨에서 재시도하는 편이 시스템 안정성에 유리합니다.

7) 튜닝 포인트 3: 키 설계로 핫스팟을 쪼갤 수 있는가

Redis 락은 키 단위로 직렬화됩니다. 키 설계가 곧 동시성 설계입니다.

  • 재고 차감: lock:stock:product:{id}
  • 쿠폰 발급: lock:coupon:{campaignId}
  • 사용자 단위 제한: lock:user:{userId}:action:{action}

여기서 중요한 질문은 하나입니다.

  • 정말로 전역 직렬화가 필요한가
  • 아니면 더 작은 단위로 분해 가능한가

예를 들어 campaignId 전체를 하나로 잠그면 캠페인 트래픽이 전부 직렬화됩니다. 반면 쿠폰 코드 풀을 여러 버킷으로 나눠 lock:coupon:{campaignId}:bucket:{n}로 만들면, 충돌 확률을 낮추면서도 중복 발급을 방지할 수 있습니다(버킷별로 다른 쿠폰 묶음을 할당하는 전제가 필요).

8) 튜닝 포인트 4: 공정성보다 “처리량과 빠른 실패”

Redis 기본 락은 공정 락이 아닙니다. 먼저 기다린 요청이 먼저 들어간다는 보장이 없습니다. 핫스팟 상황에서는 공정성을 강제하려고 대기열을 만들수록 시스템이 느려지는 경우가 많습니다.

  • API 서버에서는 빠른 실패 + 짧은 재시도
  • 백그라운드 워커에서는 큐 기반 직렬화(예: 스트림/리스트)

즉, 온라인 트래픽은 으로 보호하되, 정말 순서가 중요한 작업은 애초에 큐로 모델링하는 편이 낫습니다.

9) 장애 시나리오 체크리스트

분산락은 성공 케이스보다 실패 케이스 설계가 핵심입니다.

(1) 락을 잡은 프로세스가 죽으면

TTL이 회복을 담당합니다. TTL이 너무 길면 복구가 느려집니다.

(2) 락을 해제하기 전에 TTL이 만료되면

다른 프로세스가 락을 잡고 들어올 수 있습니다. 이 경우 임계 구역이 중복 실행될 수 있으므로, DB 쪽에서 최종 일관성을 지키는 안전장치가 필요합니다.

예시:

  • 재고 차감은 qty > 0 조건 업데이트로 최종 방어
  • 쿠폰 발급은 유니크 키로 중복 발급 방지

즉, Redis 락은 경합 완화이지 정합성의 유일한 근거가 되면 위험합니다.

(3) Redis 장애/지연

Redis가 느려지면 락 획득 자체가 병목이 됩니다. 아래를 준비하세요.

  • 타임아웃을 짧게(락 서버가 느리면 빠르게 실패)
  • 장애 시 우회 정책(예: 특정 기능 임시 차단)
  • Redis는 가능하면 전용 클러스터로 분리

네트워크 계층의 미묘한 오류가 지연을 키우는 경우도 많습니다. 서비스가 분산되어 있다면 gRPC 타임아웃/데드라인 패턴도 함께 점검해 두는 것이 좋습니다. 관련해서는 gRPC MSA에서 Deadline Exceeded 원인과 패턴도 참고할 만합니다.

10) MySQL과 Redis를 같이 쓸 때의 권장 구조

핫스팟 자원(예: 특정 상품 재고)에 대해 다음 순서가 실전에서 안정적입니다.

  1. Redis 락 획득 시도
  2. 성공하면 MySQL 트랜잭션을 최대한 짧게 실행
  3. MySQL에서 조건 업데이트로 최종 방어
  4. 커밋 후 락 해제

MySQL 예시는 다음처럼 단순해야 합니다.

START TRANSACTION;

UPDATE stock
SET qty = qty - 1
WHERE product_id = 1
  AND qty > 0;

-- 영향받은 행 수가 1이면 성공, 0이면 품절
COMMIT;

여기서 중요한 건, Redis 락이 있어도 MySQL의 조건 업데이트로 최종 방어를 유지하는 것입니다. 락이 만료되는 레이스나 이중 실행이 발생할 수 있기 때문입니다.

11) 관측과 운영: 락은 측정하지 않으면 튜닝이 불가능

락을 도입하면 반드시 다음 지표를 함께 봐야 합니다.

  • 락 획득 성공률
  • 평균/상위 퍼센타일 락 대기 시간(재시도 포함)
  • 락 획득 실패율(빠른 실패 비율)
  • Redis 명령 지연(SET, EVAL)
  • MySQL 트랜잭션 시간과 락 대기 시간

운영 중에는 애플리케이션의 동시 실행 제어도 함께 고려해야 합니다. 예를 들어 배치/워크플로우가 중복 실행되면서 동일 키에 락 폭탄을 던지는 경우가 흔합니다. CI나 워크플로우 단에서 동시 실행을 막는 사고방식은 여기에도 그대로 적용됩니다. 관련해서는 GitHub Actions 동시 실행 막힘 해결 - concurrency·cancel-in-progress 글이 의외로 비슷한 문제를 다른 층위에서 다룹니다.

또한 락 경합이 커질 때는 결국 스레드/커넥션이 대기하다 FD 고갈로 번지는 경우도 있으니, 인프라 레벨에서 EMFILE 같은 증상도 함께 모니터링하는 편이 좋습니다. 필요하면 Linux EMFILE(Too many open files) 원인과 해결도 같이 확인해 두세요.

12) 결론: Redis 락은 “DB 락을 없애는 도구”가 아니다

MySQL 핫스팟 락이 터질 때 Redis 분산락은 확실히 도움이 됩니다. 하지만 그 본질은 정합성을 Redis로 옮기는 것이 아니라, DB에 대기열이 무한정 쌓이지 않게 앞단에서 동시성을 제한하는 데 있습니다.

실전 튜닝의 핵심만 요약하면 다음과 같습니다.

  • TTL은 최악의 실행 시간을 기준으로 잡고, 필요할 때만 Watchdog를 쓴다
  • 재시도는 상한과 지터를 넣고, 핫스팟에서는 빠른 실패가 더 낫다
  • 키 설계가 동시성 설계다. 가능하면 더 작은 단위로 쪼갠다
  • Redis 락이 있어도 MySQL 조건 업데이트/유니크 키로 최종 방어를 유지한다
  • 락 성공률, 대기 시간, 실패율을 지표로 만들지 않으면 튜닝이 불가능하다

이 원칙만 지켜도 MySQL 락 대폭발로 인한 전체 장애를 상당 부분 완화할 수 있습니다.