이 글은 누구를 위한 것인가
- 반품 처리를 CS 팀이 수동으로 하고 있어 병목이 생기는 커머스 팀
- 반품 승인 → 배송 → 검수 → 환불 → 재고 복원 파이프라인을 자동화하려는 백엔드 엔지니어
- 마켓플레이스에서 셀러 정산 차감과 반품 환불을 연계하려는 플랫폼 개발자
들어가며
Statista 조사에 따르면 이커머스 반품률은 산업 평균 16~30%에 달한다. 패션 카테고리는 40%를 넘기도 한다. 반품 처리 비용이 원가의 30% 이상을 차지하는 경우도 흔하다.
반품 자동화를 통해 얻는 이점은 두 가지다.
- CS 인력 절감: 단순 반품 승인·환불을 자동화하면 CS는 예외 케이스에 집중할 수 있다.
- 고객 경험 개선: 반품 신청 후 즉시 환불 처리되면 재구매율이 높아진다.
1. 반품 프로세스 전체 흐름
고객 반품 신청
│
▼
[반품 사유 분류] ── 자동 승인 가능 여부 판단
│
├─ 자동 승인 ─→ 반품 레이블 발송 → 수거 예약
│
└─ 수동 검토 ─→ CS 큐 전달
수거 완료
│
▼
[검수 결과 입력]
│
├─ 정상 ─→ 환불 실행 + 재고 복원
│
└─ 불량·훼손 ─→ 부분 환불 or 반려 + 알림
환불 실행
│
├─ 원결제 수단으로 환불
└─ 마켓플레이스: 셀러 정산 차감
2. 반품 사유 분류
반품 사유는 귀책 주체에 따라 처리 방식이 달라진다.
| 사유 | 귀책 | 배송비 | 자동 승인 |
|---|---|---|---|
| 상품 불량·하자 | 판매자 | 판매자 부담 | O |
| 오배송 | 판매자/물류 | 판매자 부담 | O |
| 단순 변심 | 구매자 | 구매자 부담 | 정책에 따라 |
| 사이즈·색상 불일치 | 구매자 | 구매자 부담 | O (미개봉 조건) |
| 배송 지연 | 물류 | 판매자 부담 | O |
사유 코드 설계
enum ReturnReason {
DEFECTIVE = 'DEFECTIVE', // 상품 불량
WRONG_ITEM = 'WRONG_ITEM', // 오배송
CHANGE_OF_MIND = 'CHANGE_OF_MIND', // 단순 변심
SIZE_MISMATCH = 'SIZE_MISMATCH', // 사이즈 불일치
LATE_DELIVERY = 'LATE_DELIVERY', // 배송 지연
DAMAGED_IN_TRANSIT = 'DAMAGED', // 배송 중 파손
}
interface ReturnRequest {
orderId: string;
lineItems: ReturnLineItem[];
reason: ReturnReason;
reasonDetail?: string;
evidenceImageUrls?: string[]; // 증빙 이미지
requestedAt: Date;
}
3. 자동 승인 규칙 엔진
모든 반품을 수동 검토하면 CS 비용이 폭등한다. 규칙 기반 자동 승인 엔진을 설계해 단순 케이스를 자동화한다.
자동 승인 조건 예시
interface AutoApproveRule {
id: string;
priority: number; // 낮을수록 먼저 평가
condition: (ctx: ReturnContext) => boolean;
action: 'APPROVE' | 'REJECT' | 'MANUAL';
}
const rules: AutoApproveRule[] = [
{
id: 'defective-auto-approve',
priority: 1,
condition: (ctx) =>
ctx.reason === ReturnReason.DEFECTIVE &&
ctx.orderAge <= 30 && // 구매 후 30일 이내
ctx.hasEvidenceImages,
action: 'APPROVE',
},
{
id: 'wrong-item-auto-approve',
priority: 2,
condition: (ctx) =>
ctx.reason === ReturnReason.WRONG_ITEM &&
ctx.orderAge <= 7,
action: 'APPROVE',
},
{
id: 'change-of-mind-unopened',
priority: 3,
condition: (ctx) =>
ctx.reason === ReturnReason.CHANGE_OF_MIND &&
ctx.orderAge <= 7 &&
ctx.itemStatus === 'UNOPENED',
action: 'APPROVE',
},
{
id: 'high-risk-manual',
priority: 10,
condition: (ctx) =>
ctx.orderAmount >= 500_000 || // 50만원 이상 고가
ctx.customerReturnCount >= 5, // 반품 상습자
action: 'MANUAL',
},
];
function evaluateAutoApprove(ctx: ReturnContext): 'APPROVE' | 'REJECT' | 'MANUAL' {
const sortedRules = rules.sort((a, b) => a.priority - b.priority);
for (const rule of sortedRules) {
if (rule.condition(ctx)) {
return rule.action;
}
}
return 'MANUAL'; // 기본값: 수동 검토
}
리스크 스코어 통합
단순 규칙에 리스크 스코어를 추가하면 더 정밀하게 자동화 범위를 조절할 수 있다.
function calculateReturnRiskScore(ctx: ReturnContext): number {
let score = 0;
// 고가 상품일수록 리스크 증가
if (ctx.orderAmount > 200_000) score += 20;
if (ctx.orderAmount > 500_000) score += 30;
// 반품 이력이 많을수록 리스크 증가
score += Math.min(ctx.customerReturnCount * 5, 30);
// 증빙 이미지 있으면 리스크 감소
if (ctx.hasEvidenceImages) score -= 15;
// 신뢰도 높은 고객이면 리스크 감소
if (ctx.customerTier === 'GOLD') score -= 10;
if (ctx.customerTier === 'VIP') score -= 20;
return Math.max(0, score);
}
// 스코어 기반 자동 승인 임계값
const AUTO_APPROVE_THRESHOLD = 30;
4. RMA(Return Merchandise Authorization) 설계
RMA는 반품 상품에 추적 가능한 고유 번호를 부여해 물류와 연동하는 체계다.
RMA 상태 머신
REQUESTED → APPROVED → LABEL_ISSUED → IN_TRANSIT
│
RECEIVED ──┘
│
INSPECTING
/ \
PASSED FAILED
│ │
REFUND_PENDING PARTIALLY_REFUNDED
│ │
COMPLETED COMPLETED
enum RMAStatus {
REQUESTED = 'REQUESTED',
APPROVED = 'APPROVED',
LABEL_ISSUED = 'LABEL_ISSUED',
IN_TRANSIT = 'IN_TRANSIT',
RECEIVED = 'RECEIVED',
INSPECTING = 'INSPECTING',
REFUND_PENDING = 'REFUND_PENDING',
COMPLETED = 'COMPLETED',
REJECTED = 'REJECTED',
}
interface RMA {
id: string;
rmaNumber: string; // RMA-2026-00001234
orderId: string;
status: RMAStatus;
returnReason: ReturnReason;
returnShippingLabelUrl?: string;
inspectionResult?: InspectionResult;
refundAmount?: number;
timeline: RMATimelineEvent[];
}
반품 레이블 자동 발급
async function issueReturnLabel(rma: RMA): Promise<string> {
// 물류사 API 연동 (CJ대한통운, 롯데택배 등)
const carrierResponse = await shippingCarrier.createReturnShipment({
rmaNumber: rma.rmaNumber,
pickupAddress: rma.customerAddress,
returnAddress: warehouseAddress,
weight: estimateWeight(rma.lineItems),
});
await rmaRepository.updateStatus(rma.id, {
status: RMAStatus.LABEL_ISSUED,
trackingNumber: carrierResponse.trackingNumber,
labelUrl: carrierResponse.labelPdfUrl,
});
await notificationService.sendReturnLabel(rma.customerId, {
labelUrl: carrierResponse.labelPdfUrl,
trackingNumber: carrierResponse.trackingNumber,
pickupSchedule: carrierResponse.estimatedPickupDate,
});
return carrierResponse.labelPdfUrl;
}
5. 검수 결과 처리
물류 창고에서 상품이 수거·검수된 후 결과를 시스템에 반영한다.
검수 항목
interface InspectionResult {
rmaId: string;
inspector: string;
inspectedAt: Date;
itemCondition: 'BRAND_NEW' | 'GOOD' | 'DAMAGED' | 'UNUSABLE';
isOriginalPackaging: boolean;
allItemsPresent: boolean;
notes?: string;
photos?: string[];
}
async function processInspectionResult(result: InspectionResult): Promise<void> {
const rma = await rmaRepository.findById(result.rmaId);
const refundRate = calculateRefundRate(result);
const refundAmount = rma.originalAmount * refundRate;
if (refundRate === 0) {
// 환불 불가 → 고객에게 상품 재발송 or 폐기 선택
await handleRefundRejection(rma, result);
return;
}
await executeRefund(rma, refundAmount, result);
await restoreInventory(rma, result);
}
function calculateRefundRate(result: InspectionResult): number {
if (!result.allItemsPresent) return 0;
switch (result.itemCondition) {
case 'BRAND_NEW': return 1.0; // 100% 환불
case 'GOOD': return 1.0; // 100% 환불
case 'DAMAGED': return 0.5; // 50% 부분 환불
case 'UNUSABLE': return 0.0; // 환불 거절
}
}
6. 환불 처리 파이프라인
환불은 원결제 수단으로 진행하는 것이 원칙이다.
환불 처리 흐름
async function executeRefund(
rma: RMA,
refundAmount: number,
inspection: InspectionResult
): Promise<void> {
const originalPayment = await paymentRepository.findByOrderId(rma.orderId);
// 결제 수단별 환불 처리
switch (originalPayment.method) {
case 'CARD':
await cardRefundService.refund({
pgTransactionId: originalPayment.pgTxId,
amount: refundAmount,
reason: rma.returnReason,
});
break;
case 'VIRTUAL_ACCOUNT':
// 가상계좌는 계좌 환불 필요
await bankTransferService.refund({
refundAccountNumber: rma.refundBankAccount,
refundAmount,
depositorName: rma.customerName,
});
break;
case 'POINT':
await pointService.restore({
userId: rma.customerId,
points: refundAmount,
reason: `반품 환불: ${rma.rmaNumber}`,
});
break;
}
// 환불 내역 기록 (멱등성 키 사용)
await refundRepository.create({
rmaId: rma.id,
amount: refundAmount,
idempotencyKey: `refund-${rma.id}`,
status: 'COMPLETED',
});
// 이벤트 발행
await eventBus.publish('refund.completed', {
orderId: rma.orderId,
rmaId: rma.id,
refundAmount,
customerId: rma.customerId,
});
}
멱등성 보장
환불은 중복 실행을 방지해야 한다.
async function executeRefundIdempotent(
rma: RMA,
amount: number
): Promise<RefundResult> {
const idempotencyKey = `refund-${rma.id}-${amount}`;
// 이미 처리된 환불 확인
const existing = await refundRepository.findByIdempotencyKey(idempotencyKey);
if (existing) {
return { status: 'ALREADY_PROCESSED', refund: existing };
}
// 분산 락 획득
const lock = await redisLock.acquire(`lock:refund:${rma.id}`, 30_000);
try {
// 락 획득 후 재확인 (double-check locking)
const recheck = await refundRepository.findByIdempotencyKey(idempotencyKey);
if (recheck) return { status: 'ALREADY_PROCESSED', refund: recheck };
const result = await processRefund(rma, amount);
return { status: 'SUCCESS', refund: result };
} finally {
await lock.release();
}
}
7. 재고 복원 로직
반품 상품이 창고로 돌아오면 재고를 복원해야 한다. 단, 검수 결과에 따라 판매 가능 여부가 달라진다.
async function restoreInventory(
rma: RMA,
inspection: InspectionResult
): Promise<void> {
for (const item of rma.lineItems) {
const restoreLocation = determineRestoreLocation(inspection, item);
switch (restoreLocation) {
case 'SALEABLE':
// 판매 가능 재고로 복원
await inventoryService.increaseStock({
skuId: item.skuId,
warehouseId: item.originWarehouseId,
quantity: item.quantity,
reason: 'RETURN_RESTORE',
});
break;
case 'DAMAGED_STOCK':
// 불량 창고로 이동 (별도 처리)
await inventoryService.moveToDamagedBin({
skuId: item.skuId,
quantity: item.quantity,
rmaId: rma.id,
});
break;
case 'DISPOSAL':
// 폐기 처리
await inventoryService.writeOff({
skuId: item.skuId,
quantity: item.quantity,
reason: 'RETURN_UNUSABLE',
});
break;
}
}
}
function determineRestoreLocation(
inspection: InspectionResult,
item: ReturnLineItem
): 'SALEABLE' | 'DAMAGED_STOCK' | 'DISPOSAL' {
if (inspection.itemCondition === 'BRAND_NEW' || inspection.itemCondition === 'GOOD') {
return 'SALEABLE';
}
if (inspection.itemCondition === 'DAMAGED') {
return 'DAMAGED_STOCK';
}
return 'DISPOSAL';
}
8. 마켓플레이스 정산 차감
마켓플레이스에서 반품이 발생하면 셀러 정산에서 차감해야 한다.
async function adjustSellerSettlement(rma: RMA, refundAmount: number): Promise<void> {
const order = await orderRepository.findById(rma.orderId);
// 귀책에 따라 플랫폼이 부담하거나 셀러가 부담
const sellerLiability = calculateSellerLiability(rma.returnReason, order);
if (sellerLiability > 0) {
await settlementService.deductFromNextSettlement({
sellerId: order.sellerId,
amount: sellerLiability,
reason: `반품 환불 차감: ${rma.rmaNumber}`,
rmaId: rma.id,
});
await notificationService.notifySeller(order.sellerId, {
type: 'RETURN_SETTLEMENT_DEDUCTION',
rmaNumber: rma.rmaNumber,
deductionAmount: sellerLiability,
});
}
}
function calculateSellerLiability(
reason: ReturnReason,
order: Order
): number {
// 판매자 귀책이면 판매자 정산에서 차감
const sellerFaultReasons = [
ReturnReason.DEFECTIVE,
ReturnReason.WRONG_ITEM,
];
if (sellerFaultReasons.includes(reason)) {
return order.sellerAmount; // 판매자 수령액 전액 차감
}
// 구매자 변심은 플랫폼이 흡수 (정책에 따라 다름)
return 0;
}
9. 반품 분석 대시보드 지표
자동화와 함께 반품 데이터를 분석해 상품·셀러 품질을 개선한다.
| 지표 | 설명 | 임계값 예시 |
|---|---|---|
| 반품률 (Return Rate) | 반품 건수 / 주문 건수 | 카테고리별 기준 |
| 자동 승인률 | 자동 승인 / 전체 반품 | >70% 목표 |
| 평균 처리 시간 | 신청→환불 완료 | <3 영업일 |
| 불량률 | DEFECTIVE 사유 비율 | <3% 목표 |
| 셀러 반품률 | 특정 셀러의 반품률 | >15% 경고 |
-- 카테고리별 반품률 집계
SELECT
p.category,
COUNT(r.id) AS return_count,
COUNT(o.id) AS order_count,
ROUND(COUNT(r.id) * 100.0 / COUNT(o.id), 2) AS return_rate_pct
FROM orders o
LEFT JOIN rma r ON r.order_id = o.id
JOIN products p ON p.id = o.product_id
WHERE o.created_at >= NOW() - INTERVAL '30 days'
GROUP BY p.category
ORDER BY return_rate_pct DESC;
10. 구현 우선순위 로드맵
| 단계 | 기능 | 효과 |
|---|---|---|
| 1단계 | 반품 신청 UI + RMA 번호 발급 | CS 접수 채널 단일화 |
| 2단계 | 자동 승인 규칙 엔진 | CS 공수 50% 절감 목표 |
| 3단계 | 반품 레이블 자동 발급 | 배송사 연동 자동화 |
| 4단계 | 검수 결과 → 환불 자동 트리거 | 환불 처리 T+1일 이내 |
| 5단계 | 재고 복원 + 셀러 정산 연동 | 재고 정확도 99% 목표 |
마치며
반품 자동화의 핵심은 규칙 엔진으로 단순 케이스를 자동 처리하고, CS는 예외와 분쟁에 집중하는 구조다. 멱등성을 갖춘 환불 파이프라인과 재고 복원 로직을 함께 설계해야 중복 처리와 재고 불일치를 막을 수 있다.
마켓플레이스라면 귀책 주체에 따른 정산 차감 로직까지 포함해야 완전한 반품 자동화라 할 수 있다.