출금 보류를 사유 코드로 설계하기 — CS 문의를 절반으로 줄이는 구조

이커머스

정산출금상태 머신CS 자동화오류 설계

이 글은 누구를 위한 것인가

  • 셀러·크리에이터·파트너에게 정산금을 지급하는 플랫폼
  • "출금이 왜 안 되나요" 문의가 CS 티켓의 상당 비중을 차지하는 팀
  • 지급 실패 처리가 여러 곳에 흩어져 원인 추적이 어려운 경우

들어가며

정산·출금 기능을 붙이면 반드시 따라오는 CS 유형이 있다.

  "출금 신청했는데 며칠째 안 들어와요"
  "출금 버튼이 눌리지 않아요"
  "지난번엔 됐는데 이번엔 안 돼요"

이 문의들의 공통점은 사용자가 원인을 모른다는 것이고, 더 나쁜 것은 CS 담당자도 화면만 봐서는 모른다는 것이다. 결국 개발자에게 에스컬레이션되고, 개발자가 로그를 뒤져 답을 찾는다. 티켓 하나에 30분이 든다.

원인은 대개 하나다. 보류 상태가 불리언이거나, 사유가 자유 텍스트로 기록되어 있다.

-- 흔히 보는 스키마
ALTER TABLE payouts ADD COLUMN is_held BOOLEAN DEFAULT false;
ALTER TABLE payouts ADD COLUMN hold_note TEXT;   -- "본인인증 필요함" "계좌 오류?"

hold_note가 자유 텍스트인 순간, 집계도 자동 응답도 불가능해진다.


1. 보류는 상태가 아니라 사유의 집합이다

첫 번째 설계 결정은 하나의 지급 건에 보류 사유가 여러 개일 수 있다는 것을 받아들이는 것이다.

  출금 신청
    ├─ 본인인증 미완료          (KYC_REQUIRED)
    ├─ 계좌 예금주 불일치       (ACCOUNT_NAME_MISMATCH)
    └─ 반품 정산 대기 3건       (PENDING_RETURN_ADJUSTMENT)

  → 하나만 해결해서는 출금되지 않는다.
     "본인인증 하세요"라고만 안내하면 사용자는 인증 후 다시 막힌다.

이게 "지난번엔 됐는데 이번엔 안 돼요"의 정체다. 사유가 여러 개인데 하나씩만 알려주면, 사용자는 문제를 해결할 때마다 새 벽에 부딪힌다.

CREATE TABLE payout_holds (
  id           BIGSERIAL PRIMARY KEY,
  payout_id    BIGINT NOT NULL REFERENCES payouts(id),
  reason_code  TEXT NOT NULL,           -- 열거형. 자유 텍스트 아님
  severity     TEXT NOT NULL,           -- 'blocking' | 'warning'
  detail       JSONB,                   -- 코드별 구조화된 부가 정보
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  resolved_at  TIMESTAMPTZ,
  resolved_by  TEXT                     -- 'auto' | 'user' | 'admin:{id}'
);

CREATE INDEX ON payout_holds (payout_id) WHERE resolved_at IS NULL;
CREATE INDEX ON payout_holds (reason_code, created_at DESC) WHERE resolved_at IS NULL;

두 번째 인덱스가 운영에서 값을 한다. **"지금 어떤 사유로 몇 건이 막혀 있는가"**를 즉시 볼 수 있고, 이게 CS 인력 배치의 근거가 된다.


2. 사유 코드 설계 원칙

코드를 아무렇게나 늘리면 나중에 정리할 수 없다. 세 가지 원칙을 먼저 정한다.

[1] 누가 해결하는가로 1차 분류

  USER_*    사용자가 조치해야 함        → 안내 + 액션 버튼
  SYSTEM_*  시간이 지나면 자동 해소     → 예상 시각 안내
  ADMIN_*   내부 검토 필요             → 담당 큐로
  EXTERNAL_* 외부(은행·PG) 응답 대기   → 재시도 정책 적용

[2] 코드는 안정적으로, 문구는 유연하게

  코드는 한 번 정하면 바꾸지 않는다 (집계 연속성)
  사용자 문구는 언제든 바꾼다 (별도 테이블 또는 i18n)

[3] 코드 하나 = 해결 방법 하나

  "정보 부족"처럼 뭉뚱그린 코드는 만들지 않는다
  → 그 코드를 받은 CS는 여전히 무엇을 안내할지 모른다

두 번째 원칙이 실무에서 자주 깨진다. 코드에 문구를 그대로 쓰면(reason_code = '본인인증이 필요합니다') 문구를 다듬는 순간 과거 데이터와 집계가 끊긴다.

// 코드 → 사용자 문구 매핑은 코드 밖에
const HOLD_MESSAGES: Record<string, HoldMessage> = {
  USER_KYC_REQUIRED: {
    title: '본인인증이 필요합니다',
    body: '출금 전 1회 본인인증이 필요합니다. 인증은 보통 2~3분 걸립니다.',
    action: { label: '본인인증 하기', href: '/settings/verify' },
  },
  USER_ACCOUNT_NAME_MISMATCH: {
    title: '계좌 예금주가 일치하지 않습니다',
    body: '등록하신 계좌의 예금주명이 회원 정보와 다릅니다. 본인 명의 계좌만 등록할 수 있습니다.',
    action: { label: '계좌 변경', href: '/settings/bank' },
  },
  SYSTEM_PENDING_RETURN_ADJUSTMENT: {
    title: '반품 정산 확정 대기 중',
    body: '진행 중인 반품 {count}건이 확정된 후 출금할 수 있습니다.',
    // 액션 없음 — 사용자가 할 일이 없다
  },
  EXTERNAL_BANK_UNAVAILABLE: {
    title: '은행 점검 중',
    body: '{bank} 점검이 {until}에 종료됩니다. 이후 자동으로 재시도됩니다.',
  },
};

액션이 없는 코드에는 액션 버튼을 만들지 않는다. 할 일이 없는데 버튼이 있으면 사용자는 누르고, 아무 일도 안 일어나고, 다시 CS로 온다.


3. 사용자에게는 전부 보여준다

사유가 3개면 3개를 다 보여준다. 하나씩 알려주는 방식은 사용자 경험이 나쁠 뿐 아니라 CS 문의 횟수를 사유 개수만큼 곱한다.

  ⚠ 출금할 수 없습니다 — 아래 3가지를 확인해 주세요

  ① 본인인증이 필요합니다                    [본인인증 하기]
     출금 전 1회 본인인증이 필요합니다.

  ② 계좌 예금주가 일치하지 않습니다          [계좌 변경]
     본인 명의 계좌만 등록할 수 있습니다.

  ③ 반품 정산 확정 대기 중
     진행 중인 반품 3건이 확정된 후 출금할 수 있습니다.
     예상 완료: 2026-08-12

해결 순서를 사용자가 정할 수 있게 두는 것이 좋다. 강제로 순차 진행시키면 ③번처럼 기다리는 것밖에 방법이 없는 항목이 앞에 오면 아무것도 못 한다. 다만 시각적으로는 사용자가 지금 할 수 있는 것(액션 버튼이 있는 것)을 위로 정렬한다.

예상 완료 시각을 넣는 것이 문의 감소에 특히 효과가 크다. "대기 중"만 있으면 사용자는 매일 확인하고 문의하지만, "8월 12일 예상"이 있으면 그때까지 기다린다.

출금 지연이 왜 사용자에게 유독 민감한 문제가 되는지는 도메인을 가리지 않는다. 카지노게임.kr의 출금 문제 정리는 정보 제공 관점에서 지연·미지급의 원인 유형을 나눠 설명하는데, 여기서 열거하는 원인들이 대부분 사업자가 사전에 안내할 수 있었던 항목이라는 점이 시사적이다. 본인확인 절차, 계좌 정보 불일치, 내부 검토 대기 — 전부 출금 신청 전에 알릴 수 있는 것들이다. 사유 코드 체계를 만드는 실질적 이유가 여기에 있다.


4. 자동 해소 — 사람이 손대지 않아도 되는 것들

사유별로 해소 조건을 코드로 명시하면 대부분은 자동으로 풀린다.

type HoldResolver = {
  code: string;
  check: (payout: Payout) => Promise<boolean>;
  interval: string;
};

const RESOLVERS: HoldResolver[] = [
  {
    code: 'USER_KYC_REQUIRED',
    check: async (p) => (await getUser(p.userId)).kycStatus === 'verified',
    interval: '1m',        // 사용자가 방금 인증했을 수 있으므로 자주
  },
  {
    code: 'SYSTEM_PENDING_RETURN_ADJUSTMENT',
    check: async (p) => (await countOpenReturns(p.userId)) === 0,
    interval: '1h',
  },
  {
    code: 'EXTERNAL_BANK_UNAVAILABLE',
    check: async (p) => (await bankStatus(p.bankCode)) === 'available',
    interval: '10m',
  },
];

interval을 사유별로 다르게 두는 것이 포인트다. 사용자가 방금 조치했을 가능성이 높은 항목(KYC)은 자주 확인해야 체감이 좋고, 시간이 오래 걸리는 항목(반품 확정)을 1분마다 확인하는 건 낭비다.

[해소 후 처리]

  모든 blocking 사유 해소  →  지급 재시도 큐에 투입
  일부만 해소              →  남은 사유를 사용자에게 갱신 표시
                              (알림은 보내지 않음 — 아직 출금 안 되므로)
  마지막 사유 해소         →  "출금 가능합니다" 알림 발송  ★

마지막 줄이 중요하다. 사용자에게 알림을 보내는 시점은 "모든 사유가 해소됐을 때" 한 번뿐이어야 한다. 사유 하나 풀릴 때마다 알림을 보내면 스팸이 되고, 사용자는 알림을 보고 들어왔다가 여전히 막혀 있어 더 화가 난다.


5. 외부 실패는 재시도 정책으로

EXTERNAL_* 계열은 다른 처리가 필요하다. 은행·PG 응답 실패는 일시적인 것과 영구적인 것이 섞여 있다.

const EXTERNAL_POLICY: Record<string, RetryPolicy> = {
  // 일시적 — 재시도
  BANK_TIMEOUT:        { retry: true, backoff: 'exponential', maxAttempts: 6 },
  BANK_MAINTENANCE:    { retry: true, backoff: 'fixed:30m', maxAttempts: 48 },
  RATE_LIMITED:        { retry: true, backoff: 'exponential', maxAttempts: 8 },

  // 영구적 — 재시도 금지, 사용자 조치 필요
  ACCOUNT_NOT_FOUND:   { retry: false, escalateTo: 'USER_ACCOUNT_INVALID' },
  ACCOUNT_CLOSED:      { retry: false, escalateTo: 'USER_ACCOUNT_INVALID' },
  NAME_MISMATCH:       { retry: false, escalateTo: 'USER_ACCOUNT_NAME_MISMATCH' },
};

영구 실패를 재시도하면 안 되는 이유는 명확하다. 계좌가 해지된 건 100번 시도해도 안 되고, 그 사이 사용자는 아무 안내도 못 받는다. escalateTo로 사용자 조치 사유로 전환해야 한다.

지급 API의 상태 모델과 실패 유형 구분은 Stripe의 payouts 문서가 실무 기준으로 참고할 만하다. 특히 지급 실패가 언제 최종인지를 어떻게 정의하는지 보면, 자체 구현 시 놓치는 상태가 무엇인지 드러난다.

멱등성도 필수다. 재시도가 이중 지급이 되면 회수가 매우 어렵다.

await paymentProvider.createPayout({
  amount, destination,
  idempotencyKey: `payout:${payout.id}:${payout.attemptSeq}`,
});

attemptSeq를 키에 넣는 이유는, 의도한 재시도와 의도치 않은 중복 요청을 구분하기 위해서다. 같은 시도의 네트워크 재전송은 같은 키(중복 방지), 정책에 따른 새 시도는 다른 키(실제 재시도)가 된다. 멱등 키의 보관 기간과 요청 본문 불일치 시 동작은 멱등 요청 문서에 규정돼 있다.


6. API 오류 형식을 표준화한다

출금 관련 API가 여러 클라이언트(웹·앱·파트너 API)에서 호출되면, 오류 응답 형식이 제각각인 것이 CS 부담으로 돌아온다.

// RFC 7807 형식
{
  "type": "https://api.example.com/errors/payout-held",
  "title": "출금할 수 없습니다",
  "status": 409,
  "detail": "3가지 확인이 필요합니다.",
  "instance": "/payouts/1029",
  "holds": [
    {
      "code": "USER_KYC_REQUIRED",
      "severity": "blocking",
      "title": "본인인증이 필요합니다",
      "action": { "label": "본인인증 하기", "href": "/settings/verify" }
    },
    {
      "code": "SYSTEM_PENDING_RETURN_ADJUSTMENT",
      "severity": "blocking",
      "title": "반품 정산 확정 대기 중",
      "estimatedResolveAt": "2026-08-12T00:00:00Z"
    }
  ]
}

RFC 7807(Problem Details)을 쓰면 클라이언트가 type으로 분기하고 holds 배열을 그대로 렌더링할 수 있다. 각 클라이언트가 자체적으로 문구를 만들지 않게 되는 것이 실질적 이득이다. 문구를 세 곳에서 관리하면 반드시 어긋난다.


7. 운영 지표 — CS 감소를 측정한다

[일간 대시보드]

  사유 코드별 미해소 건수      어디가 막혀 있는가
  사유별 평균 해소 소요 시간    어느 안내가 불충분한가
  사유별 CS 문의 수            안내가 실패한 지점
  자동 해소 비율               사람 개입이 얼마나 줄었는가
  건당 사유 개수 분포          평균 2개 이상이면 온보딩 문제

[알림]

  특정 코드 급증 (전일 대비 3배)   → 시스템 변경 부작용 의심
  EXTERNAL_* 지속 실패             → 외부 장애
  ADMIN_* 큐 적체                  → 인력 배치 필요

"사유별 CS 문의 수"가 가장 실용적이다. 미해소 건수가 많은데 문의가 적은 사유는 안내가 잘 되고 있는 것이고, 건수는 적은데 문의가 많은 사유는 문구가 실패하고 있는 것이다. 후자를 우선 고친다.

건당 사유 개수 분포도 유용하다. 평균이 2개를 넘으면, 출금 시점이 아니라 가입·정산 계정 등록 시점에 미리 걸러야 할 것들이 뒤로 밀린 상태다. 출금은 사용자가 가장 예민한 순간이므로, 확인 가능한 것은 그 전에 처리하는 편이 낫다.


8. 정리

  1. 보류는 불리언이 아니라 사유 코드의 집합
  2. 코드는 '누가 해결하는가'로 분류 (USER/SYSTEM/ADMIN/EXTERNAL)
  3. 코드는 고정, 사용자 문구는 분리
  4. 사유는 전부 보여준다 — 하나씩 알려주면 문의가 곱해진다
  5. 예상 완료 시각을 넣으면 문의가 크게 준다
  6. 자동 해소 조건을 코드로 명시, 확인 주기는 사유별로
  7. 알림은 마지막 사유가 풀렸을 때 한 번만
  8. 외부 실패는 일시/영구를 구분해 재시도 (멱등 키 필수)

가장 효과가 빠른 건 4번과 5번이다. 스키마를 바꾸지 않고 화면 문구만 손봐도, 같은 사용자가 여러 번 문의하는 패턴이 눈에 띄게 줄어든다.