이커머스 선물하기(Gift Order) 기능 설계: UX 패턴부터 백엔드 아키텍처까지

커머스

선물하기Gift Order이커머스 설계UX 패턴백엔드 아키텍처

이 글은 누구를 위한 것인가

  • 선물하기 기능을 처음 도입하거나 개선하려는 이커머스 백엔드 개발자
  • 선물 주문 UX 플로우를 기획하는 PM과 프로덕트 디자이너
  • 카카오선물하기·네이버선물하기 같은 선물 전용 플랫폼의 구조가 궁금한 개발자

들어가며

선물하기 기능은 겉보기에 단순해 보인다. "다른 사람에게 배송하면 되는 것 아닌가?" 하지만 실제 구현은 일반 주문보다 훨씬 복잡하다. 수신자가 배송지를 직접 입력해야 하고, 수락/거절 플로우가 있고, 결제 시점과 배송 확정 시점이 분리된다. 이 과정에서 재고 확보, 상태 머신 설계, 만료 처리 등 다양한 문제가 얽힌다.

카카오선물하기가 1조 원 규모로 성장한 배경에는 이 복잡한 선물 주문 경험을 매끄럽게 만든 UX 설계가 있다. 네이버쇼핑, 쿠팡, 무신사도 선물하기 탭을 별도로 운영하며 선물 특화 경험을 제공하고 있다.

이 글에서는 선물하기 기능의 UX 플로우 설계부터 백엔드 상태 머신, API 구조까지 실무에서 바로 적용할 수 있는 수준으로 다룬다.

이 글은 bluefoxdev.kr의 이커머스 주문 설계 패턴 을 참고하고, 선물 주문 특화 관점에서 확장하여 작성했습니다.


1. 선물하기 UX의 핵심 플로우

1.1 발신자 플로우 (구매자)

일반 주문과 선물하기의 UX 분기는 상품 상세 또는 장바구니 단계에서 시작된다.

[상품 상세]
  ├── 구매하기 → 일반 주문 플로우
  └── 선물하기 → 선물 주문 플로우
        ├── 수신자 연락처 입력 (전화번호 또는 카카오/네이버 ID)
        ├── 선물 메시지 작성 (선택)
        ├── 선물 포장 옵션 선택 (선택)
        ├── 결제 진행 (발신자가 결제)
        └── 선물 링크/알림 발송

발신자가 배송지를 모르는 경우가 대부분이다. 따라서 결제 시점에 배송지를 확정하지 않는 것이 선물하기의 핵심 설계 원칙이다.

1.2 수신자 플로우

수신자는 선물 알림(카카오톡, SMS, 앱 푸시)을 받고 링크를 통해 진입한다.

[선물 알림 수신]
  └── 선물 확인 페이지 진입
        ├── 선물 수락
        │     ├── 배송지 입력
        │     ├── 추가 옵션 선택 (색상/사이즈가 미결정인 경우)
        │     └── 수락 완료 → 배송 시작
        └── 선물 거절
              └── 발신자에게 환불 처리

1.3 수락 기한과 만료 처리

수신자가 수락하지 않으면 재고가 묶이는 문제가 발생한다. 일반적으로 7~30일의 수락 기한을 설정하고, 만료 시 자동 환불 처리한다.

이벤트처리 방식
선물 발송재고 임시 확보 (soft reserve)
수락 완료재고 확정, 배송 시작
거절재고 해제, 즉시 환불
기한 만료재고 해제, 자동 환불

2. 주문 상태 머신 설계

2.1 선물 주문의 상태 정의

일반 주문(PENDING → PAID → SHIPPING → DELIVERED)과 달리, 선물 주문은 중간에 수신자의 수락 단계가 끼어든다.

GIFT_CREATED        ← 발신자 결제 완료, 선물 링크 발송
    ↓
GIFT_SENT           ← 수신자에게 알림 발송 완료
    ↓
GIFT_ACCEPTED       ← 수신자 수락, 배송지 입력 완료
    ↓
ORDER_CONFIRMED     ← 일반 주문 상태 머신으로 합류
    ↓
SHIPPING → DELIVERED

(분기)
GIFT_SENT → GIFT_REJECTED  → REFUND_COMPLETED
GIFT_SENT → GIFT_EXPIRED   → REFUND_COMPLETED

2.2 상태 전이 구현 (TypeScript)

type GiftOrderStatus =
  | 'GIFT_CREATED'
  | 'GIFT_SENT'
  | 'GIFT_ACCEPTED'
  | 'GIFT_REJECTED'
  | 'GIFT_EXPIRED'
  | 'ORDER_CONFIRMED'
  | 'REFUND_COMPLETED';

const VALID_TRANSITIONS: Record<GiftOrderStatus, GiftOrderStatus[]> = {
  GIFT_CREATED: ['GIFT_SENT'],
  GIFT_SENT: ['GIFT_ACCEPTED', 'GIFT_REJECTED', 'GIFT_EXPIRED'],
  GIFT_ACCEPTED: ['ORDER_CONFIRMED'],
  GIFT_REJECTED: ['REFUND_COMPLETED'],
  GIFT_EXPIRED: ['REFUND_COMPLETED'],
  ORDER_CONFIRMED: [],
  REFUND_COMPLETED: [],
};

function transitionGiftOrder(
  current: GiftOrderStatus,
  next: GiftOrderStatus
): boolean {
  const allowed = VALID_TRANSITIONS[current];
  if (!allowed.includes(next)) {
    throw new Error(`Invalid transition: ${current} → ${next}`);
  }
  return true;
}

3. 배송지 처리 전략

3.1 배송지 입력 타이밍

선물 주문에서 배송지 입력은 수신자 수락 시점으로 미뤄야 한다. 발신자가 수신자의 주소를 아는 경우에만 예외적으로 발신자가 입력하도록 옵션을 제공할 수 있다.

interface GiftOrder {
  id: string;
  senderId: string;
  receiverPhone: string;
  giftToken: string;       // 수신자 접근 토큰
  expiresAt: Date;
  items: OrderItem[];
  shippingAddress?: Address; // 수락 전까지 null
  status: GiftOrderStatus;
  message?: string;
  wrappingOption?: WrappingType;
}

3.2 수신자 인증 처리

선물 링크는 고유 토큰 기반으로 생성한다. 토큰은 충분한 엔트로피를 가져야 하고, HTTPS를 통해서만 접근 가능해야 한다.

import { randomBytes } from 'crypto';

function generateGiftToken(): string {
  return randomBytes(32).toString('hex'); // 64자 16진수
}

// 링크: https://shop.example.com/gift/{giftToken}
// 토큰으로 수신자 검증 후 배송지 입력 폼 제공

4. 재고 관리와 결제 처리

4.1 Soft Reserve vs Hard Reserve

선물 발송 시점에 재고를 어떻게 확보할지가 핵심이다.

방식Soft ReserveHard Reserve
재고 차감 시점수락 완료 시선물 발송 시
재고 노출수락 전에도 구매 가능 표시구매 불가 표시
장점재고 효율성 높음재고 보장 확실
단점수락 시 품절 가능재고 묶임
적합한 경우재고 충분한 상품한정판·인기 상품

대부분의 상품은 Soft Reserve를 쓰되, 수락 시 품절이면 대체 옵션을 제공한다.

4.2 환불 처리 자동화

거절/만료 시 환불은 원래 결제 수단으로 즉시 처리해야 한다.

async function processGiftExpiry(giftOrderId: string): Promise<void> {
  const giftOrder = await GiftOrderRepository.findById(giftOrderId);
  
  if (giftOrder.status !== 'GIFT_SENT') return;
  if (new Date() < giftOrder.expiresAt) return;
  
  await db.transaction(async (trx) => {
    // 재고 해제
    await InventoryService.releaseReservation(giftOrder.items, trx);
    
    // 환불 처리
    await PaymentService.refund({
      paymentId: giftOrder.paymentId,
      reason: 'GIFT_EXPIRED',
    });
    
    // 상태 업데이트
    await GiftOrderRepository.updateStatus(
      giftOrderId,
      'GIFT_EXPIRED',
      trx
    );
    
    // 발신자 알림
    await NotificationService.notifySender(giftOrder.senderId, {
      type: 'GIFT_EXPIRED',
      giftOrderId,
    });
  });
}

5. 선물 메시지와 포장 옵션 설계

5.1 메시지 카드 시스템

선물 메시지는 단순 텍스트 이상의 경험을 제공할 수 있다.

  • 텍스트 메시지: 기본 제공 (최대 200자)
  • 카드 템플릿: 생일/기념일/감사 등 이벤트별 템플릿
  • 음성/영상 메시지: 고급 옵션 (추가 비용 가능)
interface GiftMessage {
  type: 'TEXT' | 'TEMPLATE' | 'VOICE';
  content: string;
  templateId?: string;    // TEMPLATE 타입 시
  mediaUrl?: string;      // VOICE 타입 시
  senderName: string;     // 발신자 표시명
}

5.2 포장 옵션 상품화

포장 옵션은 별도 SKU로 관리하거나 주문에 부가 서비스로 추가한다. 리본 포장, 선물 박스, 에코백 등을 옵션으로 제공하면 객단가를 높일 수 있다.


마무리: 핵심 정리

선물하기 기능 설계의 핵심은 세 가지다.

  1. 배송지 결정 분리: 발신자 결제 → 수신자 수락 → 배송지 입력의 단계 분리
  2. 상태 머신 명확화: GIFT_ 접두사 상태와 일반 ORDER 상태의 명확한 구분
  3. 만료 자동화: 스케줄러로 만료 처리와 자동 환불을 보장

선물하기는 단순한 배송지 변경 기능이 아니라 완전히 다른 주문 플로우다. 처음 설계 시 이 원칙을 명확히 하면 나중에 생기는 복잡성을 크게 줄일 수 있다.