반품/역물류 자동화: 반품 승인부터 환불, 재고 복원까지

이커머스

반품 자동화역물류환불 처리이커머스물류

이 글은 누구를 위한 것인가

  • 반품 처리 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가 처리한다. 검수 결과와 환불/재고 복원을 자동 연동하면 처리 오류를 줄일 수 있다.