반품·환불 처리 자동화 전략 — 규칙 엔진·재고 복원·정산까지

커머스

반품환불RMA자동화재고 복원

이 글은 누구를 위한 것인가

  • 반품 처리를 CS 팀이 수동으로 하고 있어 병목이 생기는 커머스 팀
  • 반품 승인 → 배송 → 검수 → 환불 → 재고 복원 파이프라인을 자동화하려는 백엔드 엔지니어
  • 마켓플레이스에서 셀러 정산 차감과 반품 환불을 연계하려는 플랫폼 개발자

들어가며

Statista 조사에 따르면 이커머스 반품률은 산업 평균 16~30%에 달한다. 패션 카테고리는 40%를 넘기도 한다. 반품 처리 비용이 원가의 30% 이상을 차지하는 경우도 흔하다.

반품 자동화를 통해 얻는 이점은 두 가지다.

  1. CS 인력 절감: 단순 반품 승인·환불을 자동화하면 CS는 예외 케이스에 집중할 수 있다.
  2. 고객 경험 개선: 반품 신청 후 즉시 환불 처리되면 재구매율이 높아진다.

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 상태 머신

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는 예외와 분쟁에 집중하는 구조다. 멱등성을 갖춘 환불 파이프라인과 재고 복원 로직을 함께 설계해야 중복 처리와 재고 불일치를 막을 수 있다.

마켓플레이스라면 귀책 주체에 따른 정산 차감 로직까지 포함해야 완전한 반품 자동화라 할 수 있다.