TECH 으로 돌아가기
TECH HACKER NEWS 오늘 9분 읽기 35 READS

쇼피파이는 왜 재고 예약을 Redis에서 MySQL로 되돌렸나

쇼피파이는 왜 재고 예약을 Redis에서 MySQL로 되돌렸나
SOURCE IMAGE · HACKER NEWS

결제 화면에서 구매자가 '결제 완료'를 누르는 순간, 커머스 플랫폼은 그 상품이 여전히 판매 가능한지를 보장해야 한다. 이 판단이 한쪽으로 틀어지면 두 구매자가 마지막 남은 한 개를 동시에 사게 되어 판매자가 주문을 취소하고 사과 메일을 보내며 지원 비용을 떠안게 된다. 반대로 틀어지면 실제로는 재고가 있는데도 품절이라고 알려 판매자가 성사됐어야 할 거래를 놓친다. 쇼피파이는 이 문제를 결제 처리 동안 재고를 잠깐 잡아두는 '오버셀 방지' 예약 시스템으로 다뤄 왔고, 오랫동안 이 기능은 Redis 위에서 돌아갔다. 2025년 블랙프라이데이 최고 시점에 분당 510만 달러라는 기록적 매출이 발생했고 그 모든 거래가 재고를 건드리는 상황에서, 통합 데이터베이스 전략으로 옮겨가며 던진 질문은 '과연 MySQL이 같은 규모를 감당할 수 있는가'였다.

한 상품 한 행에서, 한 단위 한 행으로

Redis 방식은 상품마다 수량 키를 두고 예약은 DECR, 해제는 INCR로 처리했다. 동시성 자체는 잘 버텼지만 예약 데이터와 재고 원장이 서로 다른 시스템에 흩어져 있었다. 결제가 확정되어 재고를 영구 차감하는 '클레임' 단계에서 MySQL을 갱신하고 Redis를 정리해야 했는데, 이 둘을 하나의 원자적 연산으로 묶을 수 없었다. 순서에 따라 상품은 팔렸는데 원장에서 차감되지 않는 오버셀, 혹은 차감됐는데도 여전히 예약된 것으로 남는 언더셀이 발생할 수 있었다. 게다가 Redis 모델은 다중 로케이션을 인식하지 못했고 별도 클러스터 운영 비용도 있었다.

과거에도 MySQL 전환 시도는 실패했다. 수량 컬럼 하나가 담긴 단일 행은 경합을 감당하지 못했기 때문이다. 돌파구는 MySQL 8의 SKIP LOCKED였다. 상품 하나에 한 행을 두는 대신 판매 가능한 개별 단위마다 한 행을 두는 설계로 바꾼 것이다. 10개 재고는 10개의 행이 되고, 3개를 예약하면 한 트랜잭션 안에서 3개 행을 선택해 옮긴다. SKIP LOCKED는 다른 트랜잭션이 잠근 행을 기다리지 않고 건너뛰어 다른 가용 행을 반환하므로 같은 행을 두고 벌어지는 경합이 크게 줄어든다. 예약과 원장을 같은 데이터베이스에 두면서 두 연산을 ACID 트랜잭션으로 묶었고, Redis 시절 존재하던 오류 유형 자체를 없앴다.

다만 모든 재고를 단위별 행으로 두면 다시 무너진다. 10개 로케이션에 5만 개 재고를 가진 상품이라면 50만 행이 되고 예약 쿼리는 그만큼 느려진다. 그래서 상품·로케이션 조합당 최대 1,000개로 제한된 가용 행 풀을 유지하고, 별도 보충 프로세스가 원장에서 이 풀을 다시 채운다. 1,000이라는 숫자는 순간적 폭주를 흡수할 만큼 크되 테이블을 작게 유지해 SKIP LOCKED 스캔을 빠르게 할 만큼 작게 잡은 값이다. 플래시 세일 중 관측된 상품·로케이션당 최고 예약 속도를 근거로 정했다. 인기 상품의 풀이 순간적으로 비면 예약 경로가 즉석에서 보충을 트리거하되, 락으로 한 번에 하나의 트랜잭션만 보충하게 해 동시 예약들이 몰려 행을 삽입하는 '천둥소리 무리(thundering herd)'를 막는다. 이 경우 해당 예약의 지연은 늘지만, 실제로 재고가 있는 구매자를 돌려보내지 않는다는 정확성은 지켜진다.

락 개수까지 파고든 튜닝

이 규모에서는 인덱스와 기본 키 설계가 곧 락 개수와 처리량을 좌우한다. 초기 프로토타입은 오토인크리먼트 ID를 기본 키로 썼는데, InnoDB 상태를 관찰하니 예약당 행 락이 하나가 아니라 둘씩 잡혔다. WHERE 절에 쓰인 보조 인덱스와 클러스터형 인덱스가 함께 잠긴 것이다. 필터링에 쓰는 컬럼들을 기본 키에 포함하는 복합 기본 키(shop_id, inventory_item_id, inventory_group_id, id)로 바꾸자 행당 락이 하나로 줄었다. 또한 빈 테이블에 SELECT ... FOR UPDATE SKIP LOCKED를 걸면 갭 락이 잡혀 보충 트랜잭션의 삽입을 막고 교착을 유발했는데, 해당 트랜잭션의 격리 수준을 기본값 REPEATABLE READ에서 READ COMMITTED로 낮춰 해결했다. 예약과 클레임이 두 테이블을 다른 순서로 건드려 생기던 교착은 락 획득 순서를 통일해 없앴고, 여러 라인 아이템은 UNION ALL로 묶어 한 번의 왕복으로 가져오도록 배치 처리했다.

진짜 병목은 엔진이 아니라 배관에 있었다

가장 값진 교훈은 데이터베이스 설계가 아니었다. 프로덕션에서 목표에 한참 못 미치는 처리량 천장에 부딪혔는데, 예약 지연 시간(P90 등)은 양호했고 CPU도 여유가 있었으며 쿼리도 이미 최적화된 상태였다. 숫자가 맞지 않았다. 그래서 어떤 비즈니스 프로세스가 데이터베이스 커넥션을 얼마나 오래 붙잡는지에 대한 가시성을 추가했다. 애플리케이션 쪽에서 모든 SQL에 / conn_tag:checkout_completion / 같은 태그를 달고, ProxySQL 계층에서 그 태그를 파싱해 호출자별 커넥션 점유 시간을 집계했다. 어떤 쿼리가 느린지가 아니라 어떤 프로세스가 긴 트랜잭션에 걸쳐 커넥션을 붙잡는지가 즉시 드러났다.

결과는 뜻밖이었다. 예약이 유일한 무거운 사용자가 아니었다. 체크아웃 경로의 다른 부분들이 필요 이상으로 커넥션을 오래 쥐고 있었는데, 처음으로 한계에 부딪힌 쪽이 아니었던 탓에 최적화되지 않은 채 방치돼 있었다. 커넥션은 유한하고 높은 처리량에서는 초당 많은 짧은 트랜잭션이 필요한데, 다른 코드가 커넥션을 오래 쥐면서 이미 고갈 직전이던 풀에 예약이 마지막 지푸라기가 됐던 것이다. 체크아웃 경로 정리로 기본 데이터베이스의 읽기가 50%, 트랜잭션이 33% 줄었고, 수년 전 보수적으로 설정된 뒤 재검토되지 않은 InnoDB 스레드 동시성 값도 여유가 있는 만큼 올렸다. 이 둘이 합쳐져 천장이 사라졌고, 대규모 플래시 세일 중에도 라이터 CPU는 50% 미만, 리더 CPU는 16% 미만을 유지했다.

전환은 스위치를 켜듯 이뤄지지 않았다. 모든 예약을 Redis와 MySQL에 동시에 쓰되 Redis를 진실의 원천으로 두는 '섀도 모드'로 두 시스템을 나란히 운영하며 실제 트래픽에서 MySQL이 올바른 결과를 내는지 검증했다. 두 시스템 모두 살아 있었기에 이관할 예약도 없었다. 만족스러운 수준에 이른 뒤 진실의 원천을 MySQL로 옮겼고, 이중 쓰기 경로를 유지해 문제가 생기면 킬 스위치로 Redis로 되돌릴 수 있게 했다. 롤아웃은 저트래픽 파드부터 최대 거래량 판매자까지 파드 단위로 점진적으로 진행됐다. 한국의 실무자에게 이 사례가 주는 함의는 분명하다. 높은 처리량의 상호 배제를 위해 Redis나 Kafka, 별도 조정 계층을 먼저 떠올린다면, 이미 쓰고 있는 데이터베이스가 충분할 수 있다. 그리고 CPU는 낮은데 대기가 쌓이는 식으로 숫자가 맞지 않을 때, 답은 대개 엔진이 아니라 눈여겨보지 않던 배관에 있다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://shopify.engineering/scaling-inventory-reservations
SHARE
처리 중...