Shopify가 재고 예약 시스템을 Redis에서 MySQL로 옮긴 이유와 과정
출처: Shopify Engineering (Emilie Noel)
원문: https://shopify.engineering/scaling-inventory-reservations
작성자: 팔복소프트-김팔복
한눈에 보기
- Shopify가 결제 중 재고를 임시로 잡아두는 '재고 예약' 시스템을 Redis에서 MySQL로 전환했고, 2025년 블랙프라이데이의 피크 트래픽(분당 510만 달러 매출)을 무리 없이 처리했습니다.
- 핵심 설계는 MySQL 8의
SKIP LOCKED를 활용해 "재고 1개당 1행" 구조로 만들되, 아이템·위치 조합당 최대 1,000행으로 풀(pool)을 제한하는 방식입니다. - Redis와 MySQL 원장이 분리되어 있던 기존 구조에서는 예약과 차감을 하나의 트랜잭션으로 묶을 수 없어 오버셀/언더셀 위험이 있었는데, 같은 DB로 합치면서 ACID 보장으로 이 문제를 근본적으로 해결했습니다.
- 최적화 과정에서 발견한 진짜 병목은 쿼리나 CPU가 아니라 DB 커넥션 점유였습니다. SQL에 호출자 태그를 달아 ProxySQL에서 집계하는 방식으로 원인을 찾아냈습니다.
- 전환은 Redis와 MySQL에 동시에 쓰는 '섀도우 모드'로 검증한 뒤, 킬 스위치를 준비한 상태에서 점진적으로 진행했습니다.
주요 내용
왜 Redis를 걷어냈나
Shopify의 오버셀 방지 시스템은 두 가지 동작으로 이루어집니다. 결제가 시작되면 해당 수량을 몇 분간 잡아두는 예약(Reserve), 결제가 성공하면 재고 원장에서 실제로 수량을 빼는 **확정(Claim)**입니다. 이 과정이 느리거나 틀리면 같은 마지막 재고를 두 명이 사가는 오버셀, 혹은 재고가 있는데 품절로 안내하는 언더셀이 발생합니다.
기존에는 예약을 Redis에서 처리했습니다. 아이템마다 수량 키를 두고 DECR/INCR로 관리하는 방식인데, 동시성 자체는 문제가 없었지만 구조적인 약점이 있었습니다. 예약 데이터는 Redis에, 재고 원장은 MySQL에 있다 보니 확정 단계에서 두 시스템을 각각 업데이트해야 했고, 이 둘을 하나의 원자적 작업으로 묶을 수 없었습니다. 처리 순서에 따라 결제는 됐는데 재고가 차감되지 않거나, 그 반대 상황이 생길 수 있는 구조였습니다. 여기에 멀티 로케이션 재고를 인식하지 못한다는 점, 별도 클러스터 운영 부담까지 더해지면서, 예약을 원장과 같은 MySQL로 옮기는 방향을 택했습니다.
SKIP LOCKED 기반 설계: 재고 1개 = 1행
과거에도 MySQL 전환 시도는 있었지만, 아이템당 1행에 수량 컬럼을 두는 방식은 락 경합 때문에 실패했습니다. 이번에는 접근을 바꿨습니다. 판매 가능한 재고 단위 하나당 행 하나를 두는 것입니다. 재고가 10개면 10행이 있고, 3개를 예약하면 트랜잭션 하나에서 3행을 선택해 옮깁니다.
이 구조를 스케일 가능하게 만드는 것이 SELECT ... FOR UPDATE SKIP LOCKED입니다. 다른 트랜잭션이 이미 잠근 행은 건너뛰고 다른 가용 행을 가져오기 때문에, 같은 행을 두고 기다리는 일이 없어 경합이 크게 줄어듭니다.
다만 모든 재고를 행으로 펼치면 문제가 됩니다. 10개 위치에 5만 개 재고가 있는 아이템이면 50만 행이 되고, 스캔이 느려집니다. 그래서 아이템·위치 조합당 최대 1,000행의 가용 풀만 유지하고, 예약이 풀에서 행을 소비하면 별도 보충 프로세스가 원장에서 다시 채우는 구조를 만들었습니다. 1,000이라는 숫자는 플래시 세일 시 관측된 피크 예약률을 기준으로, 버스트를 버틸 만큼 크면서도 테이블이 비대해져 쿼리 성능이 떨어지지 않는 선에서 정한 값입니다.
극단적인 플래시 세일로 풀이 일시적으로 바닥나면 예약 경로에서 인라인 보충이 실행됩니다. 이때 락으로 한 트랜잭션만 보충을 수행하고 나머지는 완료를 기다리게 해서, 여러 트랜잭션이 동시에 행을 밀어 넣는 thundering herd를 방지합니다. 해당 예약의 지연은 늘어나지만, 실제로 재고가 있는 구매자가 품절 안내를 받는 일은 없습니다.
스케일을 위해 내린 기술적 결정들
복합 기본 키로 행당 락 수 줄이기. 초기 프로토타입은 auto-increment ID를 기본 키로 썼는데, SHOW ENGINE INNODB STATUS로 락 동작을 관찰해 보니 예약 하나에 행 락이 2개씩 잡혔습니다. WHERE 절에 쓰이는 세컨더리 인덱스와 클러스터드 인덱스(PK)를 모두 잠그기 때문입니다. 필터링에 쓰는 컬럼들을 포함한 복합 기본 키 (shop_id, inventory_item_id, inventory_group_id, id)로 바꿔 행당 락을 1개로 줄였습니다. 이 규모에서는 인덱스와 PK 설계가 락 수와 처리량에 직접 영향을 줍니다.
READ COMMITTED로 갭 락 회피. 보충이 필요한 빈 테이블에 SKIP LOCKED 쿼리를 실행하면 supremum 의사 레코드를 포함한 갭 락이 잡혀서, 보충 트랜잭션의 INSERT를 막고 데드락으로 이어질 수 있었습니다. 해당 트랜잭션의 격리 수준을 MySQL 기본값인 REPEATABLE READ 대신 READ COMMITTED로 낮춰 해결했습니다. 이 코드베이스에서 비기본 격리 수준을 쓴 첫 사례라, 트랜잭션별 격리 수준 설정을 위한 프레임워크 지원도 조금 추가했다고 합니다.
일관된 락 순서로 데드락 제거. 예약과 확정이 두 테이블을 서로 다른 순서로 건드리면서 데드락이 발생했습니다. 두 경로가 같은 순서로 락을 잡도록 표준화(예약은 항상 units 테이블 DELETE 후 reserved_quantities INSERT, 확정은 reserved_quantities만 접근)해서 순환 대기를 원천적으로 없앴습니다.
UNION ALL 배칭. 여러 상품이 담긴 장바구니는 UNION ALL로 예약 쿼리를 묶어 DB 왕복을 한 번으로 줄였고, 부하 상황에서 지연 시간 개선에 도움이 됐습니다.
진짜 병목은 쿼리가 아니라 커넥션이었다
프로덕션에서는 목표치보다 한참 낮은 지점에서 처리량이 막혔습니다. 이상한 점은 P90 지연도 괜찮고, CPU도 여유가 있고, 쿼리도 이미 최적화된 상태였다는 것입니다. 부하 테스트에서 관찰된 증상은 MySQL 스레드 큐잉, 큐잉된 작업 실행 시 CPU 스파이크, 그리고 ProxySQL 레이어에서의 백엔드 커넥션 고갈이었습니다.
문제는 커넥션이 고갈됐다는 사실만으로는 누가 잡고 있는지 알 수 없다는 점이었습니다. 그래서 애플리케이션에서 모든 SQL에 /* conn_tag:checkout_completion */ 같은 주석 태그로 비즈니스 프로세스를 표시하고, ProxySQL에서 이 태그를 파싱해 호출자별 커넥션 점유 시간을 집계하도록 만들었습니다. 느린 쿼리가 아니라, 긴 트랜잭션에 걸쳐 커넥션을 오래 쥐고 있는 프로세스가 무엇인지가 바로 드러났습니다.
결과는 예상 밖이었습니다. 예약 시스템만이 아니라 결제 경로의 다른 코드들이 커넥션을 필요 이상으로 오래 잡고 있었습니다. 지금까지 한계에 먼저 부딪힌 적이 없어 최적화되지 않았던 코드들입니다. 커넥션은 유한한 자원이라, 다른 코드가 커넥션을 오래 쥐고 있으면 풀이 이미 고갈 직전 상태가 되고, 예약은 그 위에 얹힌 마지막 한 방울이었던 셈입니다.
결제 경로를 정리해 주 데이터베이스의 읽기 50%, 트랜잭션 33%를 제거했습니다. 또 수년 전에 보수적으로 설정된 뒤 재검토된 적 없던 InnoDB 스레드 동시성 설정도 워크로드에 맞게 상향했습니다. 이 두 가지를 합쳐 처리량 한계가 사라졌고, 대규모 플래시 세일에서도 writer CPU 50% 미만, reader CPU 16% 미만으로 여유 있게 운영되고 있습니다.
전환 방식: 섀도우 모드와 킬 스위치
Redis에서 MySQL로 한 번에 스위치를 넘기지 않았습니다. 모든 예약을 두 시스템에 동시에 기록하되 Redis를 source of truth로 유지하는 섀도우 모드를 먼저 운영하면서, 실제 프로덕션 트래픽에서 MySQL이 올바른 결과와 성능 요건을 만족하는지 비교 검증했습니다. 두 시스템이 모두 살아 있으니 진행 중인 예약을 마이그레이션할 필요도 없었습니다.
검증이 끝난 뒤 source of truth를 MySQL로 전환했고, 문제가 생기면 킬 스위치로 즉시 Redis로 되돌릴 수 있도록 이중 쓰기 경로를 계속 유지했습니다. 롤아웃은 트래픽이 적은 pod부터 시작해 최대 규모 머천트까지 점진적으로 진행했습니다.
알아둘 점
이 글에서 강조하는 교훈은 두 가지입니다. 첫째, 오래된 결정을 다시 검토하라는 것. 5년 전에는 불가능했던 워크로드가 SKIP LOCKED 같은 새 기능 덕에 가능해질 수 있고, 스레드 제한 같은 설정값도 워크로드가 바뀌면 다시 확인할 가치가 있습니다. 둘째, 작게 시작해서 직접 관찰하라는 것. Rails 같은 전체 프레임워크 없이 작은 Ruby 스크립트와 MySQL만으로 만든 프로토타입, 그리고 터미널에서 락 동작을 직접 들여다보는 단순한 도구가 이론보다 많은 것을 알려줬다고 합니다.
또 하나 흥미로운 관점은, 이 프로젝트의 목표가 예약을 "빠르게" 만드는 것이 아니라 "좋은 이웃"으로 만드는 것이었다는 점입니다. 예약은 장바구니, 결제, 주문 생성과 데이터베이스를 공유하기 때문에, 커넥션을 고갈시키거나 락을 오래 쥐는 시스템은 나머지 전부를 위협합니다. 기준은 다른 워크로드의 DB 건강을 해치지 않으면서 처리량을 유지하는 것이었습니다.
정리
고처리량 상호 배제(mutual exclusion)가 필요할 때 반사적으로 Redis나 별도 코디네이션 레이어를 찾기 전에, 이미 쓰고 있는 데이터베이스로 충분한지 먼저 검토해볼 만합니다. SKIP LOCKED 같은 기능과 신중한 스키마·락 설계로 MySQL이 예전에는 전용 인프라가 필요하다고 여겨졌던 워크로드를 감당할 수 있게 됐습니다. 그리고 CPU는 여유로운데 큐잉이 생기는 것처럼 숫자가 안 맞을 때는, 문제의 답이 쿼리 최적화가 아니라 커넥션 점유 같은 '배관'에 있을 수 있다는 점도 기억해둘 만합니다.
- MySQL
- Redis
- 데이터베이스
- SKIP LOCKED
- Shopify
아직 댓글이 없습니다.