Loading the catalog…
Loading the catalog…
재고 차감에 여러가지 고민을 했던 저로써는 Shopify가 Redis로 돌리던 재고 예약을 MySQL로 옮기고도 Black Friday를 버텼다는 엔지니어링 글 을 재밌게 읽었습니다. 해당 글을 읽고 정리해봤습니다. 🧐 Redis를 걷어낸 이유 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 클러스터 운영비용도 추가됩니다. 기존 동작 방식 예시 초기 값 MySQL 원장 : 1개 Redis 예약 가능 수량 : 1개 user A 의 결제 시작 user A 가 결제 시작하면 Redis 에서 reserve 를 합니다. Redis 에서 DECR 명령 MySQL 원장 : 1개 Redis 예약 가능 수량 : 0개 아직 결제가 확정되지 않았기 때문에 일단 먼저 Redis 에서만 DECR 명령을 통해 "재고 확보"를 합니다. 결제 확정 (Claim) MySQL 원장 : 1 -> 0개 Redis에 있던 임시 예약을 제거 MySQL, Redis 를 동시에 다루기 때문에 한 트랜잭션에서 처리할 수 없습니다. 즉, 순차적으로 처리하면 두 저장소 간에 상태 차이가 있을 수 밖에 없습니다. 그 사이 새로운 요청이 들어오거나 하면 undersell, oversell 이 발생할 수도 있습니다. 이어서 한 트랜잭션으로 묶지 못하기 때문에 실패 시 롤백도 자연스럽게 되지 못합니다. 🧱 아이템당 1행에서 재고 1개당 1행으로 아이템당 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 은 인덱스 끝을 나타내는 가상 레코드. 락이 걸리는 방법과 동작방식 by ChatGPT 보충이 필요할 만큼 가용 행이 비었을 때 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가 직접 쓴 것이라 다른 환경에서 그대로 나온다고 볼 수는 없습니다. 🔗 Ref https://shopify.engineering/scaling-inventory-reservations https://gist.github.com/CourtneySymons/cb5ecbe86331047aae166d5b1f1d555c https://gist.github.com/CourtneySymons/9f7ce64765a02fc417a6a85c68a48322 https://dev.37signals.com/introducing-solid-queue/ https://dev.mysql.com/doc/refman/8.4/en/innodb-locking.html https://dev.mysql.com/doc/refman/8.4/en/innodb-transaction-isolation-levels.html https://dev.mysql.com/doc/refman/8.4/en/innodb-locking-reads.html https://dev.mysql.com/blog-archive/mysql-8-0-1-using-skip-locked-and-nowait-to-handle-hot-rows/ https://jahfer.com/posts/innodb-locks/ https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing [^1] 다중 위치( multi-location ) 재고 : 같은 상품의 재고가 여러 물리적 위치에 나뉘어 있는 경우 예를 들어, 서울 창고에는 3개, 부산창고에는 5개, 대구창고에는 2개 있어서 총 재고가 10개가 있는 경우입니다. [^2] Solid Queue : 37signals에서 개발한 Ruby on Rails용 데이터베이스 기반 Active Job 백엔드 아댑터입니다. Redis 인프라 없이도 RDBMS 만으로도 백그라운드 작업을 효율적으로 처리할 수 있도록 설계되었습니다. SELECT ... FOR UPDATE SKIP LOCKED 활용하여 여러 워커가 동시에 작동하더라도 락(Lock) 충돌이나 블로킹 없이 안전하고 빠르게 작업을 가져갑니다.
What RADAR observed and classified to build this opportunity. It is what the source published, not a verification that the offer is still active.
재고 예약을 Redis에서 MySQL로 옮긴 글 정리. 재고 차감에 여러가지 고민을 했던 저로써는 Shopify가 Redis로 돌리던 재고 예약을 MySQL로 옮기고도 Black Friday를 버텼다는 엔지니어링 글 을 재밌게 읽었습니다. 해당 글을 읽고 정리해봤습니다. 🧐 Redis를 걷어낸 이유 E-commerce 구현할 때는 "재고 확보" 개념이 중요합니다. 재고가 없는데 주문이 되면 사과 메일을 보내야 하고, 재고가 있는데 품절이라고 하면 팔 수 있던 걸 놓칩니다. Black Friday 2025 피크에 Shopify 머천트 매출이 분당 5.1M 달러였고, 그 거래마다 재고를 확인합니다. 재고 예약은 두 동작으로 나뉩니다. 동작 시점 하는 일 Reserve 결제 시작 재고를 수 분 동안 잡아 둠…
Open source