-
Shopify는 왜 수년간 쓰던 Redis를 버리고 MySQL SKIP LOCKED로 돌아섰을까?ARCHIVE/DATABASE 2026. 9. 20. 07:56반응형
참고: 본 글은 Shopify Engineering 팀의 아키텍처 전환 사례("We replaced Redis with MySQL for inventory reservations—and it scaled", 2026)를 기반으로 핵심 엔지니어링 통찰을 정리한 글입니다.
📌 한 줄 요약
체크아웃 오버셀(과판매) 방지를 위한 재고 예약을 수년간 Redis로 운영하다가, 예약과 재고 원장 간의 이중 시스템·비원자적 Claim 문제를 근본적으로 해결하기 위해 MySQL 8
SKIP LOCKED+ 단위당 1행 + 가용 풀 상한 구조로 재설계했습니다. 그리고 이 과정에서 밝혀진 진짜 병목은 쿼리 성능이 아닌 커넥션 점유 시간이었습니다.
1. 배경: 분리된 이중 시스템의 한계
이커머스 결제 흐름에서 재고 처리는 통상 2단계로 나뉩니다:
- Reserve (예약): 고객이 결제를 시작할 때 수 분간 재고를 임시 홀드.
- Claim (확정): 결제가 성공하면 재고 원장에서 영구 차감.
기존 Redis 모델의 문제점
Shopify는 오랜 기간 Redis의
INCR/DECR명령어로 빠른 동시성 처리를 해왔습니다. 하지만 구조적인 문제가 있었습니다:
- 예약(Redis)과 원장(MySQL)의 분리: 두 저장소 간의 상태 동기화가 원자적이지 않아, Claim 순서에 따라 오버셀(Over-sell)이나 언더셀(Under-sell)이 발생할 위험이 상존했습니다.
- 다중 창고/위치 인식의 복잡성: 창고별 재고 파티셔닝이 어려웠습니다.
- 운영 복잡도: 대규모 분산 Redis 클러스터를 별도로 관리하고 고가용성을 유지하는 운영 비용이 컸습니다.
2. 핵심 아키텍처: MySQL로의 회귀와 재설계
Shopify 팀은 MySQL 8의 최신 기능과 과감한 데이터 모델링을 결합하여 이 문제를 RDBMS 단일 원장 안으로 끌어들였습니다.
① 단위당 1행(1 Row per Unit) + SKIP LOCKED
보통 재고 테이블은 아이템별로
quantity정수 컬럼을 두고 차감합니다. 하지만 대량 트래픽이 몰리면 동일한 단일 행에 락 경합이 집중됩니다.Shopify는 이를 뒤집어 판매 가능한 수량 1개당 테이블의 1행(Row)을 할당했습니다:
- 예를 들어 3개를 예약한다면, 단일 트랜잭션에서 3개의 행을 선택하여 예약 상태로 전환합니다.
-SELECT ... FOR UPDATE SKIP LOCKED: 이미 다른 트랜잭션이 잠근 행은 대기하지 않고 즉시 건너뛰어(Skip) 가용한 행만 반환하므로, 락 경합으로 인한 대기 시간이 획기적으로 줄어듭니다.② 가용 풀 상한 (Pool Capping: 1,000행)
만약 재고가 10만 개라고 해서 10만 개의 행을 미리 다 펼쳐둘 수는 없습니다.
- 아이템 및 위치당 최대 1,000행까지만 가용 행 풀(Pool)로 유지합니다.
- 풀의 행이 소진되면 인라인 보충(Replenishment) 로직이 실행되며, 단일 보충 락을 두어 수많은 요청이 동시에 보충 쿼리를 날리는 Thundering Herd 문제를 완화했습니다.
3. 세부 기술 결정 4가지
- 복합 기본키 (Composite Primary Key):
* PK를(shop_id, inventory_item_id, inventory_group_id, id)로 구성했습니다.
* 단순 Auto-Increment PK를 쓸 때 발생하던 예약당 2개의 행 락을 1개로 줄였습니다. - 격리 수준을
READ COMMITTED로 조정:
* 풀이 비어있을 때SKIP LOCKED를 수행하면Repeatable Read에서는 갭 락(Supremum Gap Lock)이 발생해 다른 트랜잭션의 보충INSERT를 차단하는 문제가 발생했습니다.
* 이를 해결하기 위해 코드베이스 최초로 해당 경로에READ COMMITTED격리 수준을 적용했습니다. - 일관된 락 획득 순서:
* Reserve 작업은 가용 행을DELETE한 뒤 Reserved 테이블에INSERT하고, Claim 작업은 Reserved 행만 다루도록 하여 트랜잭션 간 순환 대기(Deadlock)를 원천 차단했습니다. UNION ALL배치 처리:
* 장바구니에 여러 품목이 들어있을 때 다품목 예약을 단 한 번의 DB 라운드트립으로 처리했습니다.
4. 진짜 병목은 쿼리가 아니라 '커넥션 점유'였다
MySQL로 전환하고 P90 레이턴시와 CPU, 쿼리를 최적화했음에도 대규모 부하 테스트에서 처리량 천장에 부딪혔습니다. 병목의 원인은 쿼리 실행 속도가 아니라 스레드 큐와 ProxySQL 커넥션 풀 고갈이었습니다.
Shopify 팀의 해결 방식:
- 애플리케이션 SQL에/* conn_tag:checkout_completion */과 같은 태그 주석을 삽입.
- ProxySQL에서 호출 경로별 커넥션 점유 시간을 정밀 집계.
- 분석 결과, 재고 예약 경로가 아니라 결제 완료와 관련된 다른 레거시 경로가 커넥션을 불필요하게 오래 쥐고 있었음이 밝혀졌습니다.
- 해당 경로를 리팩토링하여 프라이머리 읽기 50% 감소, 트랜잭션 33% 감소를 달성했고 오래된 InnoDB Thread Concurrency 설정을 재조정했습니다.
- 결과적으로 블랙프라이데이급 플래시 세일 부하에서도 Writer DB CPU 50% 미만, Reader DB CPU 16% 미만으로 안정적으로 방어해냈습니다.
5. 무중단 전환: Shadow Mode (이중 쓰기)
수억 달러의 결제가 일어나는 핵심 경로인 만큼 점진적 롤아웃을 적용했습니다:
- Shadow Mode: Redis와 MySQL에 동시에 이중 기록(Dual Write)하되, 실제 판단 기준(Source of Truth)은 Redis로 유지하며 정합성을 수주일간 검증.
- 검증 완료 후 킬 스위치(Kill-switch)를 구비하고 파드(Pod) 단위로 MySQL을 Source of Truth로 점진 전환.
💡 엔지니어링 교훈
- 기술의 진화가 아키텍처의 선택지를 바꾼다:
5년 전에는 RDBMS로 불가능했던 초고동시성 워크로드도SKIP LOCKED같은 새로운 기능과 영리한 모델링(단위당 1행 + 풀 상한)을 통해 가능해질 수 있습니다. - CPU가 널널한데 큐가 쌓인다면 전체 커넥션 수명을 봐라:
쿼리 자체의 실행 시간이 1ms여도 트랜잭션 전후로 커넥션을 잡고 있는 시간이 길면 커넥션 풀이 말라버립니다. - 좋은 시스템은 이웃에게 안전한 시스템이다:
핵심 기능은 단지 자기 자신만 빠른 것이 아니라, 같은 데이터베이스를 공유하는 다른 결제 경로의 리소스를 고갈시키지 않도록 안전한 이웃(Good Citizen)이 되어야 합니다.
반응형'ARCHIVE > DATABASE' 카테고리의 다른 글
데이터베이스에서 수행되는 작업의 단위인 트랜젝션(transaction)의 개요 (0) 2018.08.28 직렬화가능성(serializability)을 보장하기 위한 동시성 제어의 개요 (0) 2018.08.25 데이터 무결성 보장을 위한 데이터 회복의 개요 (0) 2018.08.25 데이터 독립성 (0) 2018.08.24 오라클 DB 에서 트리거로, UPDATE CASCADE 흉내내기 (0) 2016.09.18