재고 차감에 여러가지 고민을 했던 저로써는 Shopify가 Redis로 돌리던 재고 예약을 MySQL로 옮기고도 Black Friday를 버텼다는 엔지니어링 글을 재밌게 읽었습니다. 해당 글을 읽고 정리해봤습니다.
E-commerce 구현할 때는 "재고 확보" 개념이 중요합니다. 재고가 없는데 주문이 되면 사과 메일을 보내야 하고, 재고가 있는데 품절이라고 하면 팔 수 있던 걸 놓칩니다. Black Friday 2025 피크에 Shopify 머천트 매출이 분당 5.1M 달러였고, 그 거래마다 재고를 확인합니다.
재고 예약은 두 동작으로 나뉩니다.
| 동작 | 시점 | 하는 일 |
|---|---|---|
| Reserve | 결제 시작 | 재고를 수 분 동안 잡아 둠 |
| Claim | 결제 성공 | 재고 원장(source of truth)에서 수량을 영구 차감 |
아이템마다 수량 키를 두고 reserve는 DECR, release는 INCR입니다. Redis는 이걸 잘 처리했습니다. 걸림돌은 예약은 Redis에, 원장은 MySQL에 있다는 점이었습니다.
📌 Reserve, Release
Reserve : 재고를 임시로 확보/예약하는 것 (재고 10개 -> 9개)
Release : 예약했던 재고를 해제/반납하는 것 (재고 9개 -> 10개)
Claim에서 문제가 생깁니다. MySQL 원장을 차감하는 일과 Redis 예약을 지우는 일을 한 번에 묶을 수 없습니다. 어느 쪽을 먼저 하느냐에 따라 팔렸는데 원장이 안 줄거나(oversell), 원장은 줄었는데 예약이 남아 있습니다(undersell). 다중 위치(multi-location) 재고[^1]를 모르는 구조였고, Redis 클러스터 운영비용도 추가됩니다.
기존 동작 방식 예시
1. 초기 값
MySQL 원장 : 1개
Redis 예약 가능 수량 : 1개
reserve 를 합니다.MySQL 원장 : 1개
Redis 예약 가능 수량 : 0개
아직 결제가 확정되지 않았기 때문에 일단 먼저 Redis 에서만 DECR 명령을 통해 "재고 확보"를 합니다.
MySQL 원장 : 1 -> 0개
Redis에 있던 임시 예약을 제거
MySQL, Redis 를 동시에 다루기 때문에 한 트랜잭션에서 처리할 수 없습니다. 즉, 순차적으로 처리하면 두 저장소 간에 상태 차이가 있을 수 밖에 없습니다. 그 사이 새로운 요청이 들어오거나 하면 undersell, oversell 이 발생할 수도 있습니다. 이어서 한 트랜잭션으로 묶지 못하기 때문에 실패 시 롤백도 자연스럽게 되지 못합니다.
아이템당 1행에 quantity 컬럼을 두는 모델이었습니다. 인기 상품이면 모든 예약 요청이 같은 행 하나의 락을 기다립니다.
3 명의 유저가 (거의) 동시에 A 상품을 3개씩 재고 확보 요청한다고 가정해보면, 아래와 같이 동작합니다. 테이블에는 아래와 같이 들어갈 것입니다.
| item | 재고 |
|---|---|
| A | 10 |
유저들이 순차적으로 기다리고, 락을 걸고, 커밋되고, 반복됩니다.
user1 : A 상품 행 LOCK -----> COMMIT
user2 : ...... WAIT .......... A 상품 행 LOCK -----> COMMIT
user3 : ...... WAIT ..................................... A 상품 행 LOCK
그래서 Shopify 는 발상을 바꿔서 재고 1개를 1행으로 만들었습니다. 재고가 10개면 10행이고, 3개를 예약하면 한 트랜잭션에서 3행을 골라 옮깁니다. 여기에 SKIP LOCKED가 붙습니다. 잠긴 행은 기다리지 않고 결과에서 빼 버리는 옵션이라(MySQL 8.4 매뉴얼), 동시에 들어온 체크아웃이 서로 다른 행을 가져갑니다.
| id | item |
|---|---|
| 1 | A |
| 2 | A |
| 3 | A |
| 4 | A |
| 5 | A |
| 6 | A |
| 7 | A |
| 8 | A |
| 9 | A |
| 10 | A |
user1 : id 1,2,3 LOCK
user2 : (SKIP LOCKED 에 의해) id 4,5,6 LOCK
user3 : id 7,8,9 LOCK
이 발상은 37signals의 Solid Queue[^2]에서 가져왔다고 합니다. Solid Queue도 Resque(Redis) 대신 DB를 택했고, 워커들이 FOR UPDATE SKIP LOCKED로 서로 안 기다리고 잡을 가져갑니다(Introducing Solid Queue). Shopify는 이 구조를 재고 예약에 적용해, 작업(Job) 대신 재고 1개를 하나의 행으로 만들고 여러 체크아웃이 서로 다른 재고 행을 나눠서 예약하도록 했습니다.
Shopify가 올린 reserve 흐름입니다(gist). 트랜잭션은 READ COMMITTED로 시작합니다.
-- MySQL 8.0.1+
BEGIN;
SELECT id
FROM available_units
WHERE shop_id = ?
AND inventory_item_id = ?
AND inventory_group_id = ?
ORDER BY shop_id ASC, inventory_item_id ASC, inventory_group_id ASC, id ASC
LIMIT 3
FOR UPDATE SKIP LOCKED;
INSERT INTO reserved_units (unit_id, cart_token, expires_at, ...);
DELETE FROM available_units
WHERE (shop_id, inventory_item_id, inventory_group_id, id) IN (?, ?, ?, ?);
COMMIT;
📌 SQL 흐름
재고가 5만 개인 아이템이 위치 10곳에 있으면 50만 행입니다. 예약 쿼리가 그만큼을 훑어야 하니 느려집니다. 그래서 item/location 조합마다 가용 행을 최대 1,000개만 두고, 예약이 행을 다 써 버리면 보충 프로세스가 원장에서 다시 채웁니다. 1,000은 플래시 세일 때 관측한 item/location별 피크 예약률을 기준으로 잡은 값이라고 합니다. 버스트를 받아낼 만큼은 크고, 스캔이 빠를 만큼은 작아야 합니다.
풀이 바닥나면 reserve 에서 바로 보충합니다. 이때 락을 잡아 한 트랜잭션만 보충하고, 같은 아이템의 다른 예약은 그게 끝나길 기다립니다. 다들 한꺼번에 INSERT하려고 달려드는 thundering herd를 막으려는 장치입니다. 즉, 풀이 비어있는 경우 첫 예약은 락을 걸고 보충을 합니다. 그 예약 하나는 느려지지만, 재고가 있는데 구매자가 돌아가는 일은 없습니다.

프로토타입은 Rails 없이 작은 Ruby 스크립트와 MySQL만으로 만들고, 다른 터미널에서 락을 들여다보며 고쳤다고 합니다.
1. auto-increment PK에서는 예약 1건에 행 락이 2개
처음에는 id를 auto-increment PK로 사용했습니다.
PRIMARY KEY (id)
하지만 실제 예약 쿼리는 id가 아니라 아래 컬럼들로 재고를 찾습니다.
shop_id
inventory_item_id
inventory_group_id
즉, MySQL은 먼저 이 컬럼들로 구성된 보조 인덱스에서 재고를 찾고, 그곳에 저장된 id를 이용해 다시 PK(clustered index) 로 실제 행을 찾아가야 했습니다.
WHERE 조건
↓
보조 인덱스 🔒
↓
PK(id)로 실제 행 조회
↓
clustered index 🔒
FOR UPDATE가 붙어 있기 때문에 이 과정에서 보조 인덱스 레코드와 PK 레코드가 각각 잠겼습니다.
결과적으로 재고 1개를 예약하는데 인덱스 락이 2개 필요했던 것입니다.
InnoDB의 행 락은 실제로는 "행 자체"가 아니라 인덱스 레코드에 걸리기 때문입니다. 따라서 어떤 인덱스를 통해 행을 찾느냐에 따라 필요한 락 수도 달라질 수 있습니다.
이를 해결하기 위해 예약 쿼리에서 항상 사용하는 컬럼들을 PK에 포함시켰습니다.
PRIMARY KEY (
shop_id,
inventory_item_id,
inventory_group_id,
id
)
이제 예약 쿼리가 별도의 보조 인덱스를 거치지 않고 PK를 바로 사용해 재고 행을 찾을 수 있습니다.
WHERE 조건
↓
복합 PK 🔒
따라서 Shopify가 관찰한 락 수가
기존: 재고 1개 → 락 2개
변경: 재고 1개 → 락 1개
로 줄었습니다.
핵심은 AUTO_INCREMENT 자체가 문제였던 것이 아니라, 예약할 때 사용하는 검색 조건과 PK 구조가 서로 맞지 않았다는 점입니다.
재고 예약이 초당 대량으로 발생하는 환경에서는 이런 차이가 누적되기 때문에, PK와 인덱스 설계가 락 경합과 처리량에 직접적인 영향을 줍니다.
2. 빈 테이블에서 잡히는 gap lock
💡 용어 정리
예를 들어 인덱스 값이 10.....20......30....∞ 이라면,
Record Lock 은 특정 값 (ex. 20) 에 Lock 을 거는 것,
Gap Lock 은 사이에 있는 값 (10 과 20 사이에 새 값) INSERT 하는 것을 Lock 거는 것,
Supremum 은 인덱스 끝을 나타내는 가상 레코드.
보충이 필요할 만큼 가용 행이 비었을 때 SELECT ... FOR UPDATE SKIP LOCKED를 실행하면 gap lock이 잡혔습니다. supremum에도 걸려 있었고, 이 락이 보충 트랜잭션의 INSERT를 막아 데드락까지 갈 수 있었습니다. 빈 테이블인데 무슨 락이 잡히는지가 처음에는 이상합니다.
InnoDB는 인덱스 레코드 사이의 빈 구간도 잠급니다. 인덱스에 10, 11, 13, 20이 있을 때 next-key lock이 덮는 구간은 이렇습니다.
(-inf, 10]
(10, 11]
(11, 13]
(13, 20]
(20, +inf)
맨 아래 구간이 20 뒤의 gap과 supremum(어떤 값보다도 큰 가상 레코드)입니다. 여기가 잠기면 더 큰 키의 INSERT는 전부 막힙니다. Jahfer Husain의 글에는 가장 큰 author_id 행을 갱신했더니 그 뒤로 INSERT가 다 막히는 예제가 있고, Shopify도 이 글을 링크해 뒀습니다.
기본 격리 수준인 REPEATABLE READ는 검색과 인덱스 스캔에 next-key lock을 씁니다. READ COMMITTED에서는 잠금 읽기가 인덱스 레코드만 잠그고 gap은 안 잠급니다. gap 락은 외래 키 검사와 중복 키 검사에만 남습니다(Transaction Isolation Levels). 그래서 이 트랜잭션만 READ COMMITTED로 내렸습니다. 빈 범위에는 잠글 레코드가 없으니 검색 범위 전체가 gap 하나로 잡힌 것 같습니다.
기본값이 아닌 격리 수준을 쓴 건 이 코드베이스에서 처음이라, 트랜잭션별로 격리 수준을 지정하는 프레임워크 지원을 조금 붙여야 했다고 합니다.
READ COMMITTED는 같은 범위를 다시 읽으면 새 행이 보일 수 있는데, 이 흐름은 읽은 행을 다시 읽지 않고 바로 옮기니 문제 될 일이 적어 보입니다.
3. 두 테이블을 서로 다른 순서로 잠그면 데드락
reserve는 reserved_quantities에 INSERT한 다음 reservation_units에서 DELETE했고, claim은 reserved_quantities에서 DELETE했습니다. 두 트랜잭션이 테이블을 반대 순서로 잠그다가 서로를 기다렸습니다.
reserve가 units 쪽 DELETE를 먼저, reserved_quantities 쪽 INSERT를 나중에 하도록 순서를 고정했습니다. claim은 reserved_quantities만 건드리니 이제 잠그는 순서가 같아서 순환 대기가 생기지 않습니다.
📌 데드락이 발생하는 이유
서로 다른 트랜잭션이 같은 자원을 다른 순서로 락을 걸면 생길 수 있는 문제입니다.
예를 들어, T1, T2 라는 트랜잭션에서 A,B 라는 자원(테이블)에 락을 거는 상황에서
T1 : A -> B T2 : A -> B위와 같이 락을 걸면 T1 이 A 를 잡고 있는 순간 T2 는 A 에 접근을 못하므로 대기하기 때문에 데드락이 발생할 수 없습니다.
하지만,
T1 : A -> B T2 : B -> A위와 같은 순서로 락을 건다면, T1 이 A 를 잡고 있을 때 T2 가 B 를 잡아버리면 T1 이 다음 B로 넘어갈 때 T2 때문에 접근을 못하고 T2 역시 T1 때문에 A 에 접근을 못하는 데드락이 발생합니다. 즉, 순환구조가 일어날 때 데드락이 발생합니다.
4. UNION ALL로 왕복 줄이기
DB 왕복도 비용입니다. 카트에 라인 아이템이 여러 개면 UNION ALL로 필요한 행을 한 번에 가져옵니다(gist).
-- MySQL 8.0.1+
(SELECT id, inventory_item_id, inventory_group_id
FROM available_units
WHERE shop_id = 1 AND inventory_item_id = 100 AND inventory_group_id = 1
ORDER BY shop_id, inventory_item_id, inventory_group_id, id
LIMIT 2 FOR UPDATE SKIP LOCKED)
UNION ALL
(SELECT id, inventory_item_id, inventory_group_id
FROM available_units
WHERE shop_id = 1 AND inventory_item_id = 200 AND inventory_group_id = 1
ORDER BY shop_id, inventory_item_id, inventory_group_id, id
LIMIT 5 FOR UPDATE SKIP LOCKED)
쿼리와 락을 다 손봤는데도 프로덕션 처리량은 목표보다 한참 아래에서 막혔습니다. P90 지연은 괜찮았고 CPU도 남았습니다. 부하 테스트를 돌리면 MySQL에 스레드가 쌓였고, 쌓인 작업이 풀릴 때 CPU가 튀었고, ProxySQL 쪽에서는 MySQL 백엔드 커넥션이 바닥났습니다.
여러 체크아웃의 예약을 한 SKIP LOCKED 쿼리로 묶어 커넥션을 아끼는 방법도 해 봤습니다. 부하 테스트에서는 효과가 있었지만 복잡해지기만 했습니다. 읽기 일부를 replica로 옮겨 봐도 계산이 안 맞았다고 합니다.
커넥션이 모자란다는 것만으로는 누가 쥐고 있는지 모릅니다. 그래서 앱에서 모든 SQL에 어떤 업무인지 알려 주는 주석을 붙이고, ProxySQL이 그 태그를 읽어 업무별로 커넥션을 쥐고 있던 시간을 합산하게 했습니다.
/* conn_tag:checkout_completion */ SELECT ...
쿼리가 빠르든 느리든 상관없이, 커넥션을 오래 쥐는 업무가 그대로 드러났습니다. 예약이 특별히 무거웠던 건 아닙니다. 체크아웃 경로의 다른 코드들이 커넥션을 필요 이상으로 오래 잡고 있었고, 예약은 이미 바닥이 보이던 풀에 마지막으로 얹힌 부하였습니다.
체크아웃 path를 정리해서 primary DB의 읽기를 50%, 트랜잭션을 33% 덜어냈습니다. 수년 전에 보수적으로 잡아 둔 채 그대로였던 innodb_thread_concurrency도 지금 워크로드에 맞게 올렸습니다. 플래시 세일 중에도 writer CPU는 50%, reader CPU는 16%를 넘지 않았다고 합니다.
저장소를 바꾸는 이유는 보통 성능일 거라고 생각했는데, 여기서는 속도는 문제가 아니었고 원장과 원자적으로 묶을 수 없다는 구조가 문제였습니다. 또한, Solid Queue 라는 해결방식도 매우 인상적이었습니다.
Redis
DECR로 재고확보하는 기존 방식이 틀린 선택은 아닙니다. 대부분의 상황에서는 충분히 유용합니다. 게다가 리팩토링 수치에 대해서는 Shopify가 직접 쓴 것이라 다른 환경에서 그대로 나온다고 볼 수는 없습니다.
[^1] 다중 위치(multi-location) 재고 : 같은 상품의 재고가 여러 물리적 위치에 나뉘어 있는 경우
예를 들어, 서울 창고에는 3개, 부산창고에는 5개, 대구창고에는 2개 있어서 총 재고가 10개가 있는 경우입니다.
[^2] Solid Queue : 37signals에서 개발한 Ruby on Rails용 데이터베이스 기반 Active Job 백엔드 아댑터입니다. Redis 인프라 없이도 RDBMS 만으로도 백그라운드 작업을 효율적으로 처리할 수 있도록 설계되었습니다. SELECT ... FOR UPDATE SKIP LOCKED 활용하여 여러 워커가 동시에 작동하더라도 락(Lock) 충돌이나 블로킹 없이 안전하고 빠르게 작업을 가져갑니다.