이 글은 누구를 위한 것인가
- 반품 처리 CS 업무를 자동화하려는 팀
- 반품 상태 관리와 환불 처리를 연동하려는 개발자
- 검수 결과에 따른 재고 복원 로직을 설계하는 팀
들어가며
반품은 이커머스에서 피할 수 없는 비용이다. 수동 처리는 CS 팀의 병목이 된다. 반품 정책을 코드화하고, 자동 승인/거절 규칙을 적용하면 단순 케이스는 사람 없이 처리된다.
이 글은 bluefoxdev.kr의 반품 역물류 자동화 가이드 를 참고하여 작성했습니다.
1. 반품 처리 워크플로우
[반품 상태 머신]
REQUESTED → AUTO_APPROVED → LABEL_ISSUED → IN_TRANSIT
→ MANUAL_REVIEW
→ AUTO_REJECTED
IN_TRANSIT → RECEIVED → INSPECTING → APPROVED → REFUNDED
→ PARTIALLY_APPROVED → PARTIAL_REFUND
→ REJECTED → RETURN_TO_CUSTOMER
[자동 승인 조건]
- 배송 완료 후 7일 이내
- 단순 변심 (카테고리별 허용 여부)
- 이전 반품 이력 없음
- 반품 사유: 불량/오배송
[자동 거절 조건]
- 반품 기간 초과
- 고의적 훼손 의심 (이전 반품 3회↑)
- 디지털 상품
[검수 결과별 환불]
GOOD: 전액 환불
DAMAGED_BY_CUSTOMER: 부분 환불 (훼손 차감)
USED: 환불 거절
DEFECTIVE: 전액 환불 + 물류비 보상
2. 반품 자동화 구현
interface ReturnRequest {
id: string;
orderId: string;
orderItemId: string;
userId: string;
reason: 'DEFECTIVE' | 'WRONG_ITEM' | 'CHANGE_OF_MIND' | 'NOT_AS_DESCRIBED';
requestedAt: Date;
}
class ReturnService {
async processReturnRequest(request: ReturnRequest) {
const order = await db.order.findUnique({
where: { id: request.orderId },
include: { items: true },
});
const decision = await this.autoDecide(request, order!);
await db.returnRequest.update({
where: { id: request.id },
data: { status: decision.status, rejectReason: decision.rejectReason },
});
if (decision.status === 'AUTO_APPROVED') {
await this.issueReturnLabel(request);
await this.notifyCustomer(request.userId, 'RETURN_APPROVED');
} else if (decision.status === 'AUTO_REJECTED') {
await this.notifyCustomer(request.userId, 'RETURN_REJECTED', decision.rejectReason);
} else {
await this.addToManualReview(request.id);
}
}
private async autoDecide(request: ReturnRequest, order: any) {
const daysSinceDelivery = Math.floor(
(Date.now() - new Date(order.deliveredAt).getTime()) / 86400_000
);
if (daysSinceDelivery > 30) {
return { status: 'AUTO_REJECTED', rejectReason: '반품 기간(30일) 초과' };
}
const previousReturns = await db.returnRequest.count({
where: { userId: request.userId, status: { in: ['REFUNDED', 'PARTIAL_REFUND'] } },
});
if (previousReturns >= 3 && request.reason === 'CHANGE_OF_MIND') {
return { status: 'MANUAL_REVIEW' };
}
if (request.reason === 'DEFECTIVE' || request.reason === 'WRONG_ITEM') {
return { status: 'AUTO_APPROVED' };
}
if (request.reason === 'CHANGE_OF_MIND' && daysSinceDelivery <= 7) {
return { status: 'AUTO_APPROVED' };
}
return { status: 'MANUAL_REVIEW' };
}
// 검수 완료 후 환불 처리
async processInspectionResult(returnId: string, result: 'GOOD' | 'DAMAGED_BY_CUSTOMER' | 'DEFECTIVE' | 'USED') {
const returnReq = await db.returnRequest.findUnique({
where: { id: returnId },
include: { orderItem: true },
});
const originalAmount = Number(returnReq!.orderItem.price) * returnReq!.orderItem.quantity;
let refundAmount = 0;
switch (result) {
case 'GOOD':
case 'DEFECTIVE':
refundAmount = originalAmount;
break;
case 'DAMAGED_BY_CUSTOMER':
refundAmount = Math.round(originalAmount * 0.5); // 50% 환불
break;
case 'USED':
refundAmount = 0;
break;
}
if (refundAmount > 0) {
await this.processRefund(returnReq!.orderId, refundAmount);
}
// 재고 복원 (GOOD 또는 DEFECTIVE 처리품은 별도 창고로)
if (result === 'GOOD') {
await this.restoreInventory(returnReq!.orderItem.productId, 1, 'RETURNED_SELLABLE');
} else if (result === 'DEFECTIVE') {
await this.restoreInventory(returnReq!.orderItem.productId, 1, 'DEFECTIVE_STOCK');
}
}
private async processRefund(orderId: string, amount: number) { /* 결제 취소 API 호출 */ }
private async issueReturnLabel(request: ReturnRequest) { /* 반품 운송장 발급 */ }
private async notifyCustomer(userId: string, type: string, reason?: string) { /* 알림 발송 */ }
private async addToManualReview(returnId: string) { /* CS 큐 추가 */ }
private async restoreInventory(productId: string, qty: number, location: string) { /* 재고 복원 */ }
}
const db = {} as any;
마무리
반품 자동화의 핵심은 정책을 코드로 표현하는 것이다. 자동 승인/거절 규칙을 명확히 정의하면 단순 케이스는 즉시 처리되고, 애매한 케이스만 CS가 처리한다. 검수 결과와 환불/재고 복원을 자동 연동하면 처리 오류를 줄일 수 있다.