이 글은 누구를 위한 것인가
- "품절입니다" 페이지에서 사용자가 그냥 떠나는 것을 막고 싶은 팀
- 재입고 알림으로 구매 전환을 회복하고 싶은 이커머스 PM
- 알림 시스템을 이메일·푸시·SMS 멀티채널로 구축하려는 개발자
들어가며
품절 상품 페이지는 이탈 페이지가 아니다. 웨이팅 등록을 받고, 재입고 시 즉시 알려주면 열성 고객을 놓치지 않을 수 있다. 실제로 재입고 알림의 구매 전환율은 일반 마케팅 이메일보다 5-10배 높다.
이 글은 bluefoxdev.kr의 이커머스 리텐션 전략 을 참고하여 작성했습니다.
1. 시스템 구조
[재입고 알림 플로우]
품절 상품 페이지
→ 사용자: "재입고 알림 받기" 등록
→ DB: waitlist 테이블에 저장
재고 업데이트 이벤트 (입고, 반품)
→ Inventory Service → 이벤트 발행
→ Alert Service 구독
→ 웨이팅 목록 조회
→ 채널별 알림 발송
발송 우선순위:
1. 등록 순서 (선착순)
2. VIP 고객 우선 (멤버십 등급)
3. 알림 채널: 앱 푸시 → 이메일 → SMS
2. DB 스키마 및 API
CREATE TABLE stock_waitlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id),
product_id UUID NOT NULL,
variant_id UUID, -- 특정 옵션 (색상, 사이즈)
notification_channels TEXT[] DEFAULT ARRAY['email'], -- 'push', 'email', 'sms'
created_at TIMESTAMPTZ DEFAULT NOW(),
notified_at TIMESTAMPTZ, -- 알림 발송 시간
purchased_at TIMESTAMPTZ, -- 구매 완료 시간
status VARCHAR(20) DEFAULT 'waiting', -- waiting, notified, purchased, cancelled
UNIQUE(user_id, product_id, variant_id)
);
CREATE INDEX idx_waitlist_product ON stock_waitlist(product_id, variant_id, status);
from fastapi import APIRouter, Depends
from datetime import datetime
router = APIRouter(prefix="/waitlist")
@router.post("/{product_id}")
async def join_waitlist(
product_id: str,
variant_id: str | None = None,
channels: list[str] = ["email"],
user_id: str = Depends(get_current_user),
):
"""재입고 알림 등록"""
# 이미 등록됐는지 확인
existing = await db.fetchone(
"SELECT id FROM stock_waitlist WHERE user_id=$1 AND product_id=$2 AND variant_id=$3 AND status='waiting'",
user_id, product_id, variant_id
)
if existing:
return {"status": "already_registered"}
await db.execute(
"INSERT INTO stock_waitlist (user_id, product_id, variant_id, notification_channels) VALUES ($1,$2,$3,$4)",
user_id, product_id, variant_id, channels
)
# 현재 대기 순번 반환
position = await db.fetchval(
"SELECT COUNT(*) FROM stock_waitlist WHERE product_id=$1 AND variant_id=$2 AND status='waiting' AND created_at < NOW()",
product_id, variant_id
)
return {"status": "registered", "position": position + 1}
async def notify_waitlist(product_id: str, variant_id: str | None, quantity: int):
"""재입고 시 웨이팅 사용자 알림 발송"""
# 등록 순서대로 조회 (수량만큼만)
waiters = await db.fetch("""
SELECT w.*, u.email, u.push_token, u.phone
FROM stock_waitlist w
JOIN users u ON u.id = w.user_id
WHERE w.product_id = $1 AND w.variant_id = $2 AND w.status = 'waiting'
ORDER BY w.created_at
LIMIT $3
""", product_id, variant_id, quantity * 3) # 여유분 * 3 (전환율 30% 가정)
for waiter in waiters:
channels = waiter["notification_channels"]
if "push" in channels and waiter["push_token"]:
await send_push(waiter["push_token"], {
"title": "재입고 알림!",
"body": "기다리시던 상품이 돌아왔습니다. 지금 바로 구매하세요!",
"data": {"product_id": product_id, "action": "restock"}
})
if "email" in channels:
await send_email(waiter["email"], "restock_alert", {
"product_id": product_id,
"purchase_url": f"https://myshop.com/products/{product_id}?ref=restock"
})
await db.execute(
"UPDATE stock_waitlist SET status='notified', notified_at=$1 WHERE id=$2",
datetime.utcnow(), waiter["id"]
)
3. 수요 예측 연동
def estimate_restock_demand(product_id: str) -> dict:
"""웨이팅 목록으로 재고 수요 예측"""
waitlist_count = db.fetchval(
"SELECT COUNT(*) FROM stock_waitlist WHERE product_id=$1 AND status='waiting'",
product_id
)
# 알림 → 구매 전환율 (과거 데이터 기반)
historical_cvr = 0.35 # 35% 전환 가정
estimated_demand = int(waitlist_count * historical_cvr * 1.2) # 20% 여유
return {
"waitlist_count": waitlist_count,
"estimated_purchase": estimated_demand,
"recommended_restock_qty": estimated_demand,
}
마무리
재입고 알림은 "이미 사고 싶었던 사람"에게 보내는 알림이다. 마케팅 이메일과 달리 사용자가 직접 요청한 알림이기 때문에 오픈율과 전환율이 비교할 수 없이 높다. 웨이팅 목록은 수요 예측 데이터로도 활용할 수 있어 재입고 수량 결정에도 도움이 된다.