# Shopify, 재고 예약 시스템을 Redis에서 MySQL로 교체

> Clean Markdown view of GeekNews topic #30006. Use the original source for factual precision when an external source URL is present.

## Metadata

- GeekNews HTML: [https://news.hada.io/topic?id=30006](https://news.hada.io/topic?id=30006)
- GeekNews Markdown: [https://news.hada.io/topic/30006.md](https://news.hada.io/topic/30006.md)
- Type: GN+
- Author: [neo](https://news.hada.io/@neo)
- Published: 2026-05-30T09:34:02+09:00
- Updated: 2026-05-30T09:34:02+09:00
- Original source: [shopify.engineering](https://shopify.engineering/scaling-inventory-reservations)
- Points: 6
- Comments: 1

## Topic Body

- **재고 예약 시스템**은 결제 처리 중 동일 상품이 두 번 판매되는 오버셀을 방지하는 핵심 인프라로, 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로 최소 프로토타입 구성  
- 두 번째 터미널에서 락 동작을 직접 관찰하는 방식이 이론보다 더 많은 것을 가르쳐줌  
- **커넥션 점유 계측 패턴** (앱 레이어 태그 + 프록시 집계)은 구현이 간단하고 즉시 실행 가능

## Comments



### Comment 58612

- Author: hso2341
- Created: 2026-05-30T14:14:57+09:00
- Points: 1

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