중복 Shopify Webhook 이벤트 처리 (왜 반드시 해야 하는가)
I’m happy to translate the article for you, but I need the actual text you’d like translated. Could you please paste the content (or the portion you want translated) here? I’ll keep the source link, formatting, and any code blocks exactly as they are while translating the rest into Korean.
문제: 중복 Shopify 웹훅 이벤트
Shopify는 웹훅에 대해 최소 한 번 전달을 보장하며, 정확히 한 번은 보장하지 않습니다.
엔드포인트가 5 초 이내에 2xx 상태 코드로 응답하지 않으면, Shopify는 48 시간 동안 최대 19회 웹훅을 재시도합니다. 이로 인해 동일한 이벤트가 여러 번 전달될 수 있습니다.
중복 발생 일반 원인
| 원인 | 설명 |
|---|---|
| 서버 응답 지연 | Shopify가 타임아웃되고 재시도합니다. |
| 요청 중 네트워크 타임아웃 | 요청이 핸들러에 완전히 도달하지 못합니다. |
| 처리 중 서버 재시작 | 진행 중인 요청이 손실되어 재시도가 발생합니다. |
| 큐 컨슈머 충돌 | 작업이 다시 큐에 넣어져 재처리됩니다. |
즉각적인 완화: 빠른 응답 및 비동기 처리
웹훅 핸들러 내부에서 무거운 작업을 절대 수행하지 마세요. 빠르게 응답하고 페이로드를 백그라운드 큐에 넘기세요.
// Express.js example
app.post('/webhooks/orders-paid', async (req, res) => {
// Immediate 2xx response for Shopify
res.status(200).send('OK');
// Enqueue the work for asynchronous processing
await queue.push({ topic: 'orders/paid', payload: req.body });
});
중복 제거 전략
1. 안정적인 중복 제거 키 사용
사용하지 마세요 X-Shopify-Webhook-Id (재시도 시 변경됩니다).
대신, 웹훅 토픽과 리소스 ID를 조합해 키를 만들면 재시도 시에도 일정하게 유지됩니다.
const dedupKey = `orders/paid:${payload.id}`; // stable across retries
2. Redis를 이용한 빠른 검사
// Check if the event was already seen
const alreadySeen = await redis.get(dedupKey);
if (alreadySeen) {
console.log('Duplicate detected, skipping:', dedupKey);
return;
}
// Mark as seen for the next 24 hours
await redis.setex(dedupKey, 86400, '1'); // TTL = 24 h
3. 데이터베이스에 영구적인 대체 방안
Redis가 다운될 수 있습니다; 기본 데이터스토어에 고유 제약을 두면 신뢰할 수 있는 최후 방어선이 됩니다.
-- PostgreSQL table for processed webhook events
CREATE TABLE processed_webhook_events (
dedup_key VARCHAR(255) UNIQUE NOT NULL,
processed_at TIMESTAMP DEFAULT NOW()
);
// Attempt to insert the dedup key atomically
const result = await db.raw(`
INSERT INTO processed_webhook_events (dedup_key)
VALUES (?)
ON CONFLICT (dedup_key) DO NOTHING
RETURNING id
`, [dedupKey]);
if (result.rows.length === 0) {
// Row already existed → duplicate
return;
}
ON CONFLICT DO NOTHING 절은 다수의 동시 요청이 있더라도 첫 번째 요청만 성공하도록 보장합니다.
멱등 비즈니스 로직
재고 업데이트
증감 연산에 의존하지 마세요; 웹훅이 두 번 처리될 경우 문제가 발생합니다.
// ❌ Bad – not idempotent
await db.inventory.decrement({ quantity: 5 });
// ✅ Good – idempotent (set absolute quantity)
await db.inventory.update({ quantity: newAbsoluteValue });
이벤트별 위험 및 해결 방안
| 이벤트 | 위험 | 해결 방안 |
|---|---|---|
orders/paid | 이중 이행 | 중복 방지를 위한 DB 고유 제약조건 |
inventory_levels/update | 잘못된 재고 수량 | 증감이 아닌 절대값 사용 |
refunds/create | 이중 환불 | 발행 전 환불 ID 확인 |
customers/create | 중복 계정 | 이메일 고유성 강제 |
견고한 웹훅 핸들러 체크리스트
- 5 초 이내에 Shopify에 응답 (2xx 상태)
- 비동기 큐에 처리 작업을 오프로드
- 중복 방지 키 =
topic + resource ID - 진입점에서 Redis 조회 수행
- 백업용 DB 고유 제약조건 설정
- 재고 업데이트 시 절대값 사용
- 중복 요청을 동시에 발생시켜 부하 테스트 수행
전체 패턴 개요
- Fast ACK → Shopify에 2xx 응답을 보냅니다.
- Queue → 백그라운드 작업이 비즈니스 로직을 처리합니다.
- Redis dedup → 중복을 빠르게 감지합니다.
- DB unique constraint → 최종 일관성을 보장합니다.
- Idempotent operations → 여러 번 실행해도 안전합니다.
이러한 두 가지 보호 계층(인‑메모리 캐시 + 영구적 고유성)을 아이디포턴트 비즈니스 로직과 함께 구현하면, 실제 Shopify 통합 환경에서 중복 처리 문제의 대부분을 제거할 수 있습니다.