23P by GN⁺ | ★ favorite | 댓글 2개
  • 재고 예약 시스템은 결제 처리 중 동일 상품이 두 번 판매되는 오버셀을 방지하는 핵심 인프라로, Shopify는 수년간 Redis 기반으로 운영해왔음
  • MySQL 8의 SKIP LOCKED 기능을 활용해 아이템당 수량 컬럼 대신 판매 단위당 1개 행 구조로 재설계, Redis 없이도 고성능 처리 달성
  • 복합 기본 키, READ COMMITTED 격리 수준, 일관된 잠금 순서, UNION ALL 배치 처리 등 MySQL 최적화 기법을 조합해 락 경합과 데드락을 해소
  • 실제 병목은 예약 쿼리가 아닌 커넥션 점유에 있었으며, 체크아웃 경로 전체를 계측해 DB 읽기 50%, 트랜잭션 33% 감소 달성
  • 2025년 블랙프라이데이 피크 기준 분당 $510만 매출을 처리하면서 writer CPU 50% 미만, reader CPU 16% 미만을 유지하며 목표 처방량 초과 달성

배경: 오버셀 방지 시스템의 요구사항

  • 체크아웃 완료 시점에 재고가 실제로 남아있음을 보장하는 오버셀 방지(Oversell Protection) 시스템이 필요
    • Reserve: 결제 시작 시 수 분간 해당 아이템을 임시 잠금
    • Claim: 결제 완료 시 재고 원장에서 수량을 영구 차감
  • 두 방향 모두에서 오류 허용 불가
    • 잘못되면 동일 상품을 두 명이 구매하거나, 재고가 있음에도 품절 처리되어 매출 손실 발생
  • 규모 요건: Shopify는 미국 이커머스의 14% 이상을 담당하며, 2025년 블랙프라이데이에는 전년 대비 11% 증가한 분당 $510만 매출 기록
  • 다중 위치 재고(Multi-location inventory), ACID 보장, 고성능 처리량, 정확성 우선이 핵심 요건

기존 Redis 모델의 한계

  • Redis에서 각 아이템은 수량 키를 가지며, 예약은 DECR, 해제는 INCR로 처리
  • 핵심 문제: 예약 데이터(Redis)와 재고 원장(MySQL)이 서로 다른 시스템에 존재
    • Claim 단계에서 MySQL 업데이트와 Redis 정리를 단일 원자적 트랜잭션으로 묶을 수 없었음
    • 실행 순서에 따라 오버셀(상품 판매됐으나 원장 미차감) 또는 언더셀(원장 차감됐으나 여전히 예약 상태) 발생 가능
  • 다중 위치 재고 인식 기능 부재, 별도 Redis 클러스터 운영 비용 부담

핵심 해법: SKIP LOCKED 기반 MySQL 재설계

기본 구조: 단위당 1행(One Row Per Unit)

  • 아이템당 수량 컬럼 대신 판매 가능 단위당 1개 행 구조 채택
    • 재고 10개짜리 아이템 → 10개 행; 3개 예약 시 단일 트랜잭션에서 3개 행을 선택·이동
  • 예약과 재고 원장을 동일 MySQL DB에 두어 reserve와 claim을 ACID 트랜잭션으로 처리, Redis에서 발생하던 버그 유형 제거
  • SKIP LOCKED: 다른 트랜잭션이 잠근 행은 건너뛰고 가용 행을 즉시 반환 → 동일 행 대기 없이 경합 감소

풀(Pool) 크기 제한: 위치별 최대 1,000행

  • 아이템/위치 조합당 가용 행을 최대 1,000개로 제한해 테이블 크기와 스캔 성능 유지
    • 예: 50,000개 재고 × 10개 위치 = 500,000행이 되는 상황 방지
  • 풀 소진 시 인라인 보충(replenishment) 트리거; 단일 트랜잭션만 보충하도록 락을 걸어 다수 트랜잭션이 동시에 행을 삽입하는 thundering herd 방지
  • 풀이 완전히 비는 경우 해당 예약에만 지연 발생, 실제 재고가 있는 구매자가 품절 처리되는 일은 없음

핵심 기술 결정 4가지

1. 복합 기본 키로 락 수 절감

  • 초기 프로토타입에서 오토인크리먼트 ID를 기본 키로 사용 시, InnoDB가 보조 인덱스와 클러스터드 인덱스 양쪽을 잠가 예약당 2개 행 락 발생
  • shop_id, inventory_item_id, inventory_group_id, id로 구성된 복합 기본 키 적용 → 필터 컬럼이 기본 키에 포함되어 락이 1개로 감소
  • 초당 수천 건 예약 환경에서 인덱스·기본 키 설계가 락 수와 처리량에 직접 영향

2. READ COMMITTED로 갭 락 제거

  • 빈 테이블에 SELECT ... FOR UPDATE SKIP LOCKED 실행 시 갭 락(supremum 포함) 발생, 보충 트랜잭션의 INSERT를 차단하고 데드락 유발
  • 격리 수준을 MySQL 기본값인 REPEATABLE READ에서 READ COMMITTED 로 변경 → 갭 락 발생 방식이 달라져 보충 트랜잭션이 정상 진행
  • 해당 코드베이스 최초의 비기본 격리 수준 적용으로, 트랜잭션별 격리 수준 설정을 위한 소규모 프레임워크 지원 필요

3. 일관된 락 순서로 데드락 방지

  • reserve와 claim이 두 테이블을 다른 순서로 접근해 데드락 발생
    • reserve: reserved_quantities INSERT → reservation_units DELETE
    • claim: reserved_quantities DELETE
  • 해결책: reserve가 항상 units 테이블 DELETE 먼저, reserved_quantities INSERT 나중으로 순서 표준화 → 순환 대기(circular wait) 제거

4. UNION ALL 배치로 라운드트립 감소

  • 장바구니에 여러 라인 아이템이 있을 때 UNION ALL로 예약 쿼리를 단일 라운드트립으로 배치 처리
  • 총 라운드트립 감소로 부하 상황에서 레이턴시 개선

실제 병목: 쿼리가 아닌 커넥션 점유

문제 발견 과정

  • 운영 환경에서 목표 처리량 이하에서 천장에 도달, P90 레이턴시는 양호, CPU는 최대 미만, 쿼리도 최적화 완료 상태
  • 부하 테스트에서 관찰된 증상:
    • MySQL 내 스레드 큐잉
    • 큐에 쌓인 작업 실행 시 CPU 급등
    • ProxySQL 레이어에서 MySQL 백엔드 커넥션 고갈

커넥션 가시성 확보

  • 애플리케이션 레이어: 모든 SQL 구문에 /* conn_tag:checkout_completion */ 형태의 비즈니스 프로세스 식별 주석 추가
  • ProxySQL 레이어: 태그 파싱 및 호출자별 커넥션 점유 시간 집계 추가
  • 결과: 어떤 프로세스가 얼마나 오래 커넥션을 점유하는지 즉시 파악 가능

발견 내용과 해결

  • 예약 외 체크아웃 경로의 다른 코드들이 커넥션을 필요 이상으로 길게 점유 중이었음
    • 이들이 먼저 한계에 도달하지 않아 최적화 대상에서 누락됐던 코드
  • 체크아웃 경로 정리 결과: 프라이머리 DB 읽기 50% 감소, 트랜잭션 33% 감소
  • 수년 전 보수적으로 설정된 후 재검토하지 않았던 InnoDB 스레드 동시성 설정 조정으로 추가 병목 제거
  • 개선 후 고볼륨 플래시 세일 기준: writer CPU 50% 미만, reader CPU 16% 미만 유지

전환 방식: Shadow Mode

  • Redis에서 MySQL로 즉시 전환하지 않고, Shadow Mode 방식으로 두 시스템 병렬 운영
    • 모든 예약을 Redis와 MySQL 양쪽에 동시 기록, Redis가 source of truth 유지
    • 실제 운영 트래픽에서 MySQL의 정확성과 성능을 병렬 검증
  • 인플라이트 예약 마이그레이션 없이 전환 가능 (양쪽 시스템이 동시에 살아있었으므로)
  • MySQL로 source of truth 전환 후에도 킬 스위치 유지, 이중 쓰기 경로를 통해 Redis가 항상 최신 상태 보존
  • 롤아웃은 낮은 트래픽 파드부터 최고 볼륨 머천트까지 점진적으로 파드 단위 진행

교훈

1. 오래된 결정을 재검토할 것

  • 5년 전에는 불가능했던 MySQL 활용이 SKIP LOCKED 같은 신규 기능으로 현재는 가능
  • 스레드 한도 등 "경험칙" 설정은 워크로드와 하드웨어가 변화하면 재검토 필요
  • CPU는 낮은데 큐잉이 발생한다면 반드시 원인을 파야 함

2. 작게 시작하고 관찰할 것

  • 풀 Rails 프레임워크 없이 소규모 Ruby 스크립트와 MySQL로 최소 프로토타입 구성
  • 두 번째 터미널에서 락 동작을 직접 관찰하는 방식이 이론보다 더 많은 것을 가르쳐줌
  • 커넥션 점유 계측 패턴 (앱 레이어 태그 + 프록시 집계)은 구현이 간단하고 즉시 실행 가능

댓글과 토론

오랜만에 진짜 개발같은 글이 올라오군요