(https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3o7rvss8aobgndqlgxwi.png)
고객이 오프라인 매장에 들어와 제품의 마지막 개를 구매했다고 상상해 보세요. 동시에 다른 고객이 웹사이트에서 정확히 그 제품을 주문합니다.
실제 매장에서는 재고가 0개로 표시되지만, 이커머스 웹사이트는 여전히 1개가 사용 가능하다고 보여줍니다.
온라인 주문은 확정되고, 이제 같은 제품을 기대하는 고객이 두 명 생겼습니다.
이것이 바로 과매도(overselling)이며, 온라인과 오프라인 채널 모두를 통해 판매하는 비즈니스에게 가장 흔한 재고 관리 문제 중 하나입니다.
문제는 반드시 부정확한 재고 데이터가 아닙니다. 종종 동기화 문제입니다. 즉, 두 시스템이 변경 사항을 조정할 신뢰할 수 있는 방법 없이 재고를 업데이트하거나 읽습니다.
POS와 이커머스 플랫폼 간에 일관성을 유지하는 실시간 재고 동기화 시스템을 구축하는 방법을 살펴보겠습니다.
재고 동기화가 어려운 이유
한 비즈니스는 오프라인 매장 판매를 위해 POS 시스템을, 온라인 주문을 위해 이커머스 플랫폼을, 제품 가용성을 관리하기 위해 재고 서비스를 사용할 수 있습니다.
각 시스템은 독립적으로 재고 이벤트를 생성할 수 있습니다.
예를 들어:
- 계산원이 오프라인 매장에서 제품을 판매합니다.
- 고객이 온라인으로 주문합니다.
- 온라인 주문이 취소됩니다.
- 반품된 제품이 재고로 추가됩니다.
- 창고가 다른 지점으로 재고를 이체합니다.
만약 이러한 변경 사항들이 올바르게 동기화되지 않으면, 각 판매 채널은 서로 다른 재고 수량을 표시할 수 있습니다.
간단한 구현 방식은 모든 거래 후에 API 호출을 통해 재고를 업데이트하는 것입니다. 하지만 네트워크 지연, 실패한 요청, 동시 구매, 중복 이벤트 등은 여전히 불일치를 야기할 수 있습니다.
핵심 원칙은 판매 채널이 고객에게 제품을 약속하기 전에 재고가 안전하게 업데이트되어야 한다는 것입니다.
1. 중앙 재고 서비스 구축하기
POS 시스템과 이커머스 시스템이 독립적인 재고 수량을 유지하도록 허용하는 대신, 중앙 재고 서비스를 도입해야 합니다.
두 시스템 모두 이 서비스에서 재고 정보를 읽어오고 변경 사항을 전송합니다. 간소화된 아키텍처는 다음과 같습니다:
POS 업데이트 스토어프론트 업데이트
재고 데이터베이스가 재고 수량에 대한 권위 있는 출처(authoritative source)가 됩니다. POS와 스토어프론트는 표시를 위해 로컬 또는 캐시된 사본을 유지할 수 있지만, 최종 단위 판매 가능 여부를 독립적으로 결정해서는 안 됩니다.
작은 시스템의 경우, 잘 설계된 백엔드와 관계형 데이터베이스만으로 충분할 수 있습니다. 더 큰 시스템의 경우, 비동기 이벤트 처리가 필요해질 때 Kafka나 RabbitMQ 같은 메시지 브로커를 도입할 수 있습니다.
중요한 부분은 서비스의 개수가 아닙니다. 재고 변경을 수락하고, 검증하며, 기록하는 신뢰할 수 있는 프로세스를 갖추는 것입니다.
2. 원자적(Atomic) 데이터베이스 업데이트 사용하기
과잉 판매(overselling)를 방지하는 가장 중요한 보호 장치 중 하나는 원자적인 재고 업데이트입니다.
만약 제품에 단 하나의 단위만 남아 있다고 가정해 봅시다. 두 고객이 거의 동시에 이 제품을 구매하려고 시도합니다.
위험한 구현 방식은 다음과 같습니다:
const product = await getProduct(productId);
if (product.stock > 0) {
await updateStock(productId, product.stock - 1);
}
두 요청 모두 재고 수량 1을 읽어올 수 있습니다. 두 요청 모두 조건을 통과하고 동일한 단위를 판매하려고 시도합니다.
더 안전한 접근 방식은 재고 확인과 감소를 단일 데이터베이스 작업으로 수행하는 것입니다.
예를 들어, PostgreSQL을 사용하는 경우:
UPDATE inventory
SET available_quantity = available_quantity - 1
WHERE product_id = $1
AND location_id = $2
AND available_quantity > 0
RETURNING available_quantity;
만약 쿼리가 행(row)을 반환하면, 재고 감소가 성공한 것입니다. 만약 아무 행도 반환하지 않으면, 해당 위치에서 제품이 품절된 것입니다.
데이터베이스는 업데이트를 원자적으로(atomically) 평가하여, 두 개의 동시 요청이 동일한 최종 재고 수량을 감소시키는 것을 방지합니다.
하지만 여러 품목으로 구성된 주문의 경우 추가적인 보호 장치가 필요합니다. 여러 제품을 함께 예약해야 한다면, 데이터베이스 트랜잭션(database transaction)을 사용하고, 만약 어떤 항목이라도 이용할 수 없다면 전체 예약을 롤백(roll back)해야 합니다.
3. 물리적 재고와 예약된 재고 분리하기
흔한 실수는 창고의 모든 단위를 즉시 판매 가능한 것으로 취급하는 것입니다.
다음 수량을 가진 제품을 고려해 봅시다:
- 물리적 재고(Physical stock): 20 단위
- 온라인 주문용 예약 단위(Units reserved for online orders): 4 단위
- 판매 가능 단위(Units available for sale): 16 단위
기본 계산은 다음과 같습니다:
판매 가능 재고 = 물리적 재고 - 예약된 재고
더 발전된 시스템은 또한 손상품, 안전 재고(safety stock), 품질 관리 보류 물품, 특정 채널에 할당된 재고 등을 고려할 수 있습니다.
온라인 고객이 주문을 하면, 시스템은 재고가 이용 가능한지 확인하기 전에 필요한 단위를 예약해야 합니다.
일반적인 주문 생명주기(order lifecycle)는 다음과 같습니다:
- 고객이 장바구니에 제품을 추가합니다.
- 결제 서비스(checkout service)가 재고 예약을 요청합니다.
- 재고 서비스(inventory service)가 이용 가능 여부를 확인하고 단위를 예약합니다.
- 결제 프로세스가 완료됩니다.
- 예약이 확정된 판매로 전환됩니다.
- 결제가 실패하거나 예약이 만료되면, 해당 단위는 해제됩니다.
장바구니 내용은 일반적으로 무기한 재고를 예약해서는 안 됩니다. 그렇지 않으면, 장바구니를 버린(abandoned carts) 경우 실제 구매자에게 제품을 이용할 수 없게 만들 수 있습니다.
예약 만료, 결제 재시도, 취소 처리 등은 설계의 명시적인 부분이 되어야 합니다.
4. 이벤트를 통해 변경 사항 동기화하기
재고 데이터베이스를 업데이트한다고 해서 연결된 모든 판매 채널이 자동으로 업데이트되는 것은 아닙니다.
POS 시스템은 새로운 수량을 즉시 필요로 할 수 있는 반면, 전자상거래(e-commerce) 스토어는 웹소켓(WebSocket) 연결, 서버 전송 이벤트(server-sent events), 또는 메시지 기반의 업데이트 프로세스를 통해 업데이트를 받을 수 있습니다.
재고 이벤트는 다음과 같은 형태일 수 있습니다:
{
"event_id": "evt_12345",
"event_type": "inventory.updated",
"product_id": "SKU-1001",
"location_id": "STORE-01",
"available_quantity": 7,
"version": 42
}
사용 가능한 기술로는 다음과 같은 것들이 있습니다:
- RabbitMQ: 적절하게 구성할 경우, 내구성 있는 메시지 전송 및 작업 큐에 유용합니다.
- Apache Kafka: 대량의 이벤트 스트림과 재처리 가능한 이벤트 기록(event histories)에 유용합니다.
- WebSockets: 연결된 대시보드와 애플리케이션으로 재고 변경 사항을 푸시하는 데 유용합니다.
이러한 도구들은 서로 다른 문제를 해결합니다. WebSockets는 클라이언트에게 업데이트를 전달하고; 내구성 있는 메시지 브로커(message broker)는 서비스가 이벤트를 신뢰성 있게 처리하도록 돕습니다. 어느 것도 데이터베이스 수준의 재고 보호 기능을 대체해서는 안 됩니다.
듀얼-라이트 문제(Dual-Write Problem) 방지하기
또 다른 미묘한 실패 사례가 있습니다.
재고 데이터베이스는 판매를 성공적으로 기록했지만, 해당 이벤트를 발행하기 전에 애플리케이션이 충돌하는 경우입니다. 재고는 데이터베이스에 올바르게 기록되었지만, 연결된 시스템들은 업데이트를 전혀 받지 못합니다.
트랜잭션 아웃박스 패턴(transactional outbox pattern)은 이 문제를 해결하는 데 도움이 됩니다.
하나의 데이터베이스 트랜잭션 내에서 재고 변경 사항과 이벤트 기록을 모두 아웃박스 테이블(outbox table)에 작성합니다. 백그라운드 워커(background worker)가 보류 중인 아웃박스 기록들을 메시지 브로커로 발행하고 처리된 것으로 표시합니다.
만약 워커가 충돌하더라도, 재발행을 시도할 수 있습니다.
재발행은 중복 메시지를 초래할 수 있으므로, 소비자(consumer) 역시 **멱등성(idempotent)**을 갖추어야 합니다. 즉, 동일한 이벤트를 여러 번 처리한다고 해서 재고 변경이 반복적으로 적용되어서는 안 됩니다.
5. 재고 이벤트 멱등하게 만들기
분산 시스템은 메시지가 모든 소비자에게 정확히 한 번(exactly once) 전달됨을 항상 보장할 수 없습니다.
브로커나 워커가 원래 시도가 성공했더라도 시간 초과 후 메시지를 재시도할 수 있습니다.
만약 모든 전송이 다시 재고를 감소시킨다면, 재고는 부정확해집니다. 한 가지 해결책은 모든 이벤트에 고유 ID를 할당하고 어떤 이벤트를 이미 처리했는지 기록하는 것입니다.
예를 들어:
CREATE TABLE processed_inventory_events (
event_id TEXT PRIMARY KEY,
processed_at TIMESTAMPTZ DEFAULT NOW()
);
트랜잭션 내에서 컨슈머는 이벤트 ID를 삽입하려고 시도합니다. 만약 ID가 이미 존재한다면, 해당 이벤트는 처리된 것이므로 동일한 비즈니스 작업을 다시 적용해서는 안 됩니다.
중복 제거 기록과 그에 상응하는 재고 영향은 트랜잭션으로 처리되어야 합니다. 그렇지 않으면 두 작업 사이에 충돌이 발생하더라도 여전히 불일치가 생길 수 있습니다.
또한, 무작정 반복적인 절대 업데이트를 적용하기보다는 특정 작업이나 재고 버전을 나타내는 이벤트를 선호해야 합니다.
6. 여러 매장 및 창고 처리
여러 지점을 운영하는 비즈니스의 경우, 단일 제품이 서로 다른 위치에서 다른 재고 수준을 가질 수 있습니다.
예를 들어:
Location Physical Stock Reserved Available
Surat Store 12 3 9
Ahmedabad Store 8 2 6
...
제품의 글로벌 재고 수량만으로는 특정 매장이 주문을 이행할 수 있는지 판단하기에 충분하지 않습니다.
재고 모델은 제품과 위치별로 재고를 식별해야 하며, 필요에 따라 예약 및 재고 상태를 위한 추가 필드를 포함해야 합니다.
또한 이행(fulfillment) 규칙도 정의해야 합니다. 온라인 주문이 가장 가까운 매장을 사용해야 할까요, 중앙 창고를 사용해야 할까요, 아니면 가장 빠르게 이행할 수 있는 위치를 사용해야 할까요?
여러 채널이 동일한 물리적 재고를 공유하는 경우, 재고 할당 규칙은 한 채널이 다른 채널이 필요로 하는 모든 단위를 소비하는 것을 방지하는 데 도움이 될 수 있습니다.
수요가 높은 제품의 경우, 비즈니스는 안전 버퍼(safety buffer)를 유지하거나 특정 채널에 제한된 수량을 할당할 수도 있습니다.
7. 동기화 지연 및 실패한 이벤트 모니터링
실시간 동기화라는 것이 모든 업데이트가 즉시 도착한다는 것을 의미하지는 않습니다.
데이터베이스는 재고 변경을 즉시 커밋(commit)할 수 있지만, 다운스트림 스토어프론트(storefront)가 해당 이벤트를 받는 데 몇백 밀리초 또는 그 이상이 걸릴 수도 있습니다.
API 응답 시간만 측정하는 대신 전체 프로세스를 모니터링하세요.
유용한 지표에는 다음이 포함됩니다:
- 재고 업데이트 전파 지연 시간 (Inventory update propagation latency).
- 실패했거나 반복적으로 재시도된 이벤트 수.
- 가장 오래된 보류 중인 아웃박스 레코드의 연령 (Age of the oldest pending outbox record).
- 재고 부족으로 거부된 예약 건수.
- 권위 있는 재고(authoritative inventory)와 채널 수준 예측 간의 차이점.
- 과매도 사고 발생 빈도 (Frequency of overselling incidents).
동기화 지연 시간에 대한 실용적인 서비스 수준 목표(Service-Level Objectives, SLOs)를 설정하고 이를 위반할 경우 경고 알림을 구현해야 합니다.
또한 주기적으로 파생된 재고 뷰와 권위 있는 데이터베이스를 비교하는 조정 작업(reconciliation jobs)을 구현해야 합니다. 조정은 안전망일 뿐이며, 원자적 재고 업데이트(atomic inventory updates)를 대체할 수는 없습니다.
8. 동시 구매 상황에서 시스템 테스트하기
동기화 메커니즘은 정상적인 사용 중에는 완벽하게 작동하지만 플래시 세일(flash sale)과 같은 상황에서는 실패할 수 있습니다.
다음과 같은 시나리오에 대한 자동화된 테스트를 생성하세요:
- 두 명의 고객이 마지막 남은 재고를 동시에 구매하는 경우.
- 온라인 결제 중 오프라인 판매가 발생하는 경우.
- 다운스트림 알림(downstream notification)이 실패하는 동안 결제가 성공하는 경우.
- 동일한 이벤트가 한 번 이상 전달되는 경우.
- 결제가 처리되는 동안 예약이 만료되는 경우.
- 매장이 네트워크 연결을 잃었다가 나중에 다시 연결하는 경우.
예를 들어, 100개의 동시 요청이 단 10개의 재고만 있는 제품 구매를 시도할 경우, 테스트 기간 동안 다른 재고 변경이 발생하지 않는다고 가정하면 시스템은 최대 10건의 예약만을 수락해야 합니다.
부하 테스트(load testing)를 사용하여 경합 상태(contention), 데이터베이스 성능, 이벤트 처리 지연 시간 및 복구 동작을 측정하세요.
마지막 생각
과매도를 방지하는 것은 단순히 제품 수량을 더 자주 새로고침하는 문제가 아닙니다. 일관된 재고 모델, 원자적 재고 업데이트, 예약 관리, 신뢰할 수 있는 이벤트 전달 및 잘 정의된 복구 메커니즘이 필요합니다.
많은 소매업체에게 가장 실용적인 시작점은 PostgreSQL을 백엔드로 사용하고, 원자적(atomic) 재고 연산과 트랜잭션 아웃박스(transactional outbox)를 갖춘 중앙 재고 서비스입니다. 이후 실시간 알림 기능을 통해 POS 대시보드와 이커머스 스토어프론트가 최종 재고 결정에 대한 책임 없이도 업데이트될 수 있습니다.
비즈니스가 성장함에 따라 메시지 브로커(message brokers)를 도입하고, 더욱 정교한 주문 처리 규칙(fulfilment rules)과 위치별 재고 할당을 추가할 수 있습니다.
목표는 간단합니다. 고객이 오프라인 매장에서 구매하든 온라인 스토어프론트에서 구매하든, 시스템은 해당 제품이 실제로 판매될 수 있는지에 대해 하나의 신뢰할 수 있는 결정을 내려야 합니다.