이 글은 누구를 위한 것인가
- 셀러·크리에이터·파트너에게 정산금을 지급하는 플랫폼
- "출금이 왜 안 되나요" 문의가 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번이다. 스키마를 바꾸지 않고 화면 문구만 손봐도, 같은 사용자가 여러 번 문의하는 패턴이 눈에 띄게 줄어든다.