선불전자지급수단 시스템을 만들 때, 한도관리와 원장 설계에서 놓치기 쉬운 것들
핵심 요약: 선불전자지급수단 시스템의 한도 체계(무기명 50만원/기명 200만원), 이중기장 원장 설계, 이벤트 소싱 기반 거래 이력 관리, 멱등성 키를 활용한 중복 결제 방어, 환불 시스템의 법적 요구사항까지 실제 구현 관점에서 정리해요.
선불전자지급수단 시스템을 처음 설계할 때, 가장 먼저 결정해야 하는 건 무엇일까요?
이전 글에서 전자금융거래법의 전체 그림을 다뤘어요. 이번에는 그중에서도 선불전자지급수단 시스템을 실제로 만들 때 놓치기 쉬운 기술적 포인트를 살펴볼게요.
처음에는 "충전하고 결제하면 끝 아닌가?"라고 생각했어요. 하지만 실제로 만들어 보니 현실은 기대와 달랐어요. 한도 체계가 실명확인 여부에 따라 분기되고, 원장은 단순 잔액 컬럼이 아니라 이중기장으로 설계해야 하고, 결제 API는 네트워크 장애 시 중복 차감을 방어해야 하고, 환불은 법적으로 잔액 전부를 돌려줘야 하는 의무가 있어요.
원장 설계는 복식부기와 같아요. 돈이 한쪽에서 빠지면 반드시 다른 쪽에서 들어와야 하고, 이 균형이 깨지면 시스템 전체를 신뢰할 수 없게 돼요. 이 글에서는 충전→결제→환불 전체 플로우를 시스템 설계 관점에서 하나씩 풀어볼게요.
선불전자지급수단과 전자화폐는 어떻게 다른가요
시스템 설계 관점에서 선불전자지급수단과 전자화폐는 구현 범위가 완전히 달라요.
전자금융거래법 제2조 14호는 선불전자지급수단을 "이전 가능한 금전적 가치가 전자적 방법으로 저장되어 발행된 증표로서, 발행인 외의 제3자로부터 재화 또는 용역을 구입하고 그 대가를 지급하는 데 사용되는 것"으로 정의해요. 카카오페이 머니, 네이버페이 포인트가 여기에 해당해요.
전자화폐(제2조 15호)는 여기에 추가 요건 5가지를 모두 충족해야 해요.
| 구분 | 선불전자지급수단 | 전자화폐 |
|---|---|---|
| 사용처 | 가맹점 한정 | 대통령령 기준 이상 지역·가맹점 |
| 환금성 | 환불 의무 (조건부) | 현금 교환 보장 (무조건) |
| 발행 가치 | 할인/프로모션 발행 가능 | 현금과 동일 가치로만 발행 |
| 업종 범위 | 제한 없음 | 5개 업종 이상 |
| 자본금 | 20억원 (등록) | 50억원 (허가) |
| 오프라인 지원 | 선택 | 사실상 필수 |
시스템 설계에서 이 차이가 중요한 이유는 구현 범위 때문이에요. 전자화폐는 오프라인 결제 단말 연동, 실시간 환전 시스템, 범용 가맹점 네트워크까지 구축해야 해요. 선불전자지급수단은 온라인 가맹점 결제와 충전/환불 시스템만으로 시작할 수 있어요.
실무에서 자주 보는 실수는 "우리 서비스는 전자화폐에 가까운 것 아닌가?"라는 판단 착오예요. 국내에서 전자화폐 허가를 받은 사례는 거의 없어요. 대부분의 간편결제 서비스는 선불전자지급수단으로 등록하고, 가맹점 확대로 사용처를 넓히는 전략을 택해요.
한도는 어떻게 설계하나요
한도 관리의 핵심은 실명확인 여부에 따른 분기 처리와 실시간 한도 차감/복원 로직이에요.
전자금융거래법 시행령 별표 3에서 정하는 발행권면 최고한도는 이래요.
| 구분 | 무기명식 (실명확인 없음) | 기명식 (실명확인 완료) |
|---|---|---|
| 선불전자지급수단 | 50만원 | 200만원 |
| 전자화폐 | 5만원 | 200만원 |
여기서 "발행권면 최고한도"는 이용자의 보유한도가 아니라, 각 선불전자지급수단마다 저장할 수 있는 최대 금전적 가치를 의미해요. 금융위원회·금융감독원의 유권해석에 따르면, 이 한도는 인별·발행사별·포인트 종류별 기준이 아니라 개별 선불전자지급수단 단위로 적용돼요.
시스템에서 한도를 구현할 때 고려해야 할 계층은 3가지예요.
// 한도 검증 서비스 예시
interface LimitCheckRequest {
userId: string;
instrumentId: string; // 개별 선불전자지급수단 식별자
amount: number;
verificationType: 'ANONYMOUS' | 'VERIFIED'; // 실명확인 여부
transactionType: 'CHARGE' | 'PAYMENT' | 'TRANSFER';
}
interface LimitPolicy {
maxBalance: number; // 발행권면 최고한도
dailyChargeLimit: number; // 1일 충전 한도
monthlyChargeLimit: number; // 1월 충전 한도
perTransactionLimit: number; // 1회 결제 한도
}
function getLimitPolicy(verificationType: string): LimitPolicy {
if (verificationType === 'ANONYMOUS') {
return {
maxBalance: 500_000, // 50만원
dailyChargeLimit: 500_000,
monthlyChargeLimit: 500_000,
perTransactionLimit: 500_000,
};
}
return {
maxBalance: 2_000_000, // 200만원
dailyChargeLimit: 2_000_000,
monthlyChargeLimit: 2_000_000,
perTransactionLimit: 2_000_000,
};
}
운영하면서 계속 느낀 건, 한도 검증은 반드시 충전 시점과 결제 시점 양쪽에서 수행해야 한다는 거예요. 충전 시에는 잔액 + 충전액이 발행권면 최고한도를 초과하는지 확인하고, 결제 시에는 1회/1일/1월 이용한도를 확인해요.
한도 초과 시 장애가 발생하는 대표적인 케이스는 동시성 문제예요. 두 개의 충전 요청이 거의 동시에 들어오면, 각각은 한도를 통과하지만 합산하면 초과하는 상황이 생겨요. 이를 방어하려면 Redis의 원자적 연산(INCRBY + 조건부 롤백)이나 데이터베이스의 SELECT FOR UPDATE를 사용해야 해요.
충전 시스템에서 정합성을 보장하는 방법
충전의 핵심은 외부 결제 수단(계좌이체, 카드)에서 돈을 받은 뒤 내부 원장에 잔액을 반영하는 과정의 원자성이에요.
충전 플로우는 크게 3단계로 나뉘어요.
여기서 주의할 점은 Persist-Before-Call 패턴이에요. 외부 PG에 결제를 요청하기 전에 반드시 내부 원장에 PENDING 상태를 먼저 기록해요. 서버가 PG 호출 후 응답을 받기 전에 크래시되더라도, PENDING 레코드가 남아 있으면 정산 배치에서 불일치를 감지하고 보정할 수 있어요.
실수하기 쉬운 부분이 있어요. 충전 금액에 할인이나 프로모션 보너스가 포함되는 경우예요. 이용자가 10만원을 충전하면서 1만원 보너스를 받으면, 원장에는 11만원이 기록돼요. 그런데 2024년 9월 시행된 개정법에 따르면, 이 보너스 금액도 선불충전금 별도관리 대상에 포함돼요. 원장 설계 시 "이용자 충전액"과 "프로모션 부여액"을 분리 추적해야 별도관리 비율 산정이 가능해요.
-- 충전 이벤트 테이블 (append-only)
CREATE TABLE charge_events (
event_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
instrument_id UUID NOT NULL,
user_id UUID NOT NULL,
amount BIGINT NOT NULL, -- 실제 충전액 (원 단위)
bonus_amount BIGINT NOT NULL DEFAULT 0, -- 프로모션 보너스
source_type VARCHAR(20) NOT NULL, -- BANK_TRANSFER, CARD, etc.
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(10) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
-- 한도 검증: 현재 잔액 + 충전액 + 보너스 <= 발행권면 최고한도
-- 잔액은 journal_entries의 SUM으로 계산 (저장된 balance 컬럼 사용 금지)
충전 정합성을 보장하는 마지막 방어선은 정산 배치(Reconciliation)예요. 매일 외부 PG의 거래 내역과 내부 원장을 대조해서, PENDING 상태로 남아 있는 건이나 금액 불일치를 감지해요. 운영 초기에는 수동 확인이 필요하지만, 거래량이 늘면 자동 보정 로직을 추가하게 돼요.
결제 처리에서 멱등성과 원장 설계
결제의 핵심은 "한 번만 차감"을 보장하는 것이에요. 네트워크 장애로 클라이언트가 재시도하더라도 이중 차감이 발생하면 안 돼요.
멱등성 키(Idempotency Key)는 이 문제를 해결하는 표준 패턴이에요. 클라이언트가 결제 요청마다 고유한 키를 생성해서 전달하고, 서버는 동일한 키로 들어온 요청에 대해 항상 같은 응답을 반환해요.
// 멱등성 키 처리 흐름
interface PaymentRequest {
idempotencyKey: string; // 클라이언트 생성 (UUIDv4)
instrumentId: string; // 선불전자지급수단 ID
merchantId: string; // 가맹점 ID
amount: number; // 결제 금액 (원 단위, 정수)
bodyFingerprint: string; // 요청 본문 해시 (변조 감지)
}
async function processPayment(req: PaymentRequest): Promise<PaymentResponse> {
// 1. 멱등성 키 확인 (Redis)
const cached = await redis.get(`idem:${req.idempotencyKey}`);
if (cached) {
// 동일 키 + 다른 본문 = 변조 시도 → 거부
if (cached.fingerprint !== req.bodyFingerprint) {
throw new ConflictError('Idempotency key reused with different payload');
}
return cached.response; // 캐시된 응답 반환
}
// 2. 분산 락 획득 (동시 재시도 방어)
const lock = await redis.set(
`lock:${req.idempotencyKey}`, 'LOCKED', 'NX', 'EX', 30
);
if (!lock) throw new RetryableError('Concurrent request in progress');
// 3. 원장에 PENDING 기록 (Persist-Before-Call)
const intentId = await ledger.createPaymentIntent(req);
// 4. 잔액 차감 (이중기장)
await ledger.postDoubleEntry({
debit: { accountId: req.instrumentId, amount: req.amount },
credit: { accountId: req.merchantId, amount: req.amount },
reference: intentId,
});
// 5. 응답 캐시 (24시간 TTL)
const response = { intentId, status: 'COMPLETED', amount: req.amount };
await redis.setex(`idem:${req.idempotencyKey}`, 86400, {
fingerprint: req.bodyFingerprint,
response,
});
return response;
}
여기서 주의할 점이 몇 가지 있어요.
멱등성 키는 반드시 클라이언트가 생성해야 해요. 서버가 생성하면 재시도할 때마다 새 키가 발급되어 중복 방어가 무력화돼요. 키의 TTL은 24시간이 업계 표준이에요. 모바일 앱이 2시간 뒤에 재시도하는 경우까지 커버하려면 TTL이 가장 긴 재시도 경로보다 길어야 해요.
원장의 이중기장(Double-Entry Bookkeeping)은 모든 결제를 차변(Debit)과 대변(Credit) 쌍으로 기록하는 방식이에요. 이용자 계정에서 빠진 금액은 반드시 가맹점 계정에 들어가야 하고, 전체 원장의 차변 합계와 대변 합계는 항상 같아야 해요.
-- 이중기장 원장 테이블
CREATE TABLE journal_entries (
entry_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL, -- 거래 묶음 식별자
account_id UUID NOT NULL, -- 계정 (이용자 or 가맹점 or 플랫폼)
entry_type VARCHAR(6) NOT NULL CHECK (entry_type IN ('DEBIT', 'CREDIT')),
amount BIGINT NOT NULL CHECK (amount > 0), -- 항상 양수
currency VARCHAR(3) NOT NULL DEFAULT 'KRW',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- 핵심 제약: 하나의 transaction_id 내에서 DEBIT 합 = CREDIT 합
-- 이 검증은 INSERT 전에 애플리케이션 레벨에서 수행하되,
-- 정산 배치에서도 전수 검증
CREATE INDEX idx_journal_account ON journal_entries(account_id, created_at);
CREATE INDEX idx_journal_transaction ON journal_entries(transaction_id);
-- 잔액 조회: 저장된 balance 컬럼이 아닌 SUM으로 계산
-- (materialized view로 캐시하되, 원본은 항상 journal_entries)
SELECT
account_id,
SUM(CASE WHEN entry_type = 'CREDIT' THEN amount ELSE 0 END) -
SUM(CASE WHEN entry_type = 'DEBIT' THEN amount ELSE 0 END) AS balance
FROM journal_entries
WHERE account_id = $1
GROUP BY account_id;
원인을 추적한 끝에 발견한 건, 잔액을 별도 컬럼에 저장하면 동시성 문제가 생긴다는 거예요. 두 결제가 동시에 잔액을 읽고 각각 차감하면 최종 잔액이 틀어져요. journal_entries의 SUM으로 잔액을 계산하면 이 문제가 원천 차단돼요. 성능이 걱정되면 materialized view를 두되, 정산 시 원본과 대조하는 구조가 안전해요.
환불 요청이 들어왔을 때 시스템은 어떻게 동작해야 하나요
환불의 핵심은 법적 의무와 시스템 설계의 교차점이에요. 전자금융거래법 시행령 제11조의2는 선불전자지급수단의 환불 의무를 규정하고 있어요.
이용자가 환불을 요청하면, 발행자는 잔액 전부를 환급해야 해요. 다만 예외사유가 있어요.
| 환불 조건 | 의무 | 시스템 구현 |
|---|---|---|
| 잔액 60% 이상 사용 후 환불 요청 | 잔액 전부 환급 | 잔액 조회 → 전액 이체 |
| 천재지변, 서비스 중단 등 | 잔액 전부 환급 | 동일 |
| 유효기간 만료 (잔액 80% 이상 미사용) | 잔액의 90% 환급 | 잔액 × 0.9 계산 |
| 발행일로부터 7일 이내 | 전액 환급 (수수료 공제 불가) | 발행일 검증 로직 |
시스템 설계에서 환불은 결제의 역방향 이중기장이에요. 가맹점 계정에서 차감하고 이용자 계정에 입금하는 게 아니라, 별도의 "환불 계정"을 경유해요.
여기서 한 발짝 더 나아가야 한다고 생각해요. 환불 시스템에서 가장 놓치기 쉬운 건 "부분 환불"과 "전체 환불"의 구분이에요. 선불전자지급수단의 법적 환불 의무는 잔액 전부에 대한 것이지만, 개별 결제 건에 대한 부분 환불(가맹점 반품 등)은 별도 로직이에요.
부분 환불은 원래 결제의 역분개(Reversal Entry)로 처리해요. 원래 결제에서 이용자→가맹점으로 이동한 금액 중 일부를 가맹점→이용자로 되돌리는 이중기장을 추가하는 거예요. 이때 원래 journal_entry를 수정하는 게 아니라, 새로운 reversal entry를 append해요. 원장은 절대 수정하지 않고 추가만 해요.
// 환불 처리 예시
interface RefundRequest {
originalTransactionId: string; // 원래 결제 건
refundAmount: number; // 환불 금액 (부분 환불 가능)
reason: 'CUSTOMER_REQUEST' | 'MERCHANT_CANCEL' | 'LEGAL_OBLIGATION';
idempotencyKey: string;
}
async function processRefund(req: RefundRequest): Promise<RefundResponse> {
// 1. 원래 거래 조회 및 검증
const original = await ledger.getTransaction(req.originalTransactionId);
if (!original || original.status !== 'COMPLETED') {
throw new InvalidRefundError('Original transaction not found or not completed');
}
// 2. 환불 가능 금액 검증 (이미 환불된 금액 차감)
const alreadyRefunded = await ledger.getRefundedAmount(req.originalTransactionId);
if (req.refundAmount > original.amount - alreadyRefunded) {
throw new ExceedsRefundableError('Refund exceeds remaining refundable amount');
}
// 3. 역분개 이중기장 (가맹점 → 이용자)
await ledger.postDoubleEntry({
debit: { accountId: original.merchantId, amount: req.refundAmount },
credit: { accountId: original.userId, amount: req.refundAmount },
reference: `REFUND:${req.originalTransactionId}`,
type: 'REVERSAL',
});
// 4. 실제 자금 이체 (이용자 계좌로)
if (req.reason === 'LEGAL_OBLIGATION') {
await bankTransfer.execute({
toAccount: original.userBankAccount,
amount: req.refundAmount,
});
}
return { status: 'REFUNDED', amount: req.refundAmount };
}
운영하면서 계속 느낀 건, 환불 SLA가 법적으로 정해져 있다는 거예요. 전자금융거래법상 환불 요청 접수 후 영업일 기준 10일 이내에 처리해야 해요. 시스템에서는 환불 요청 큐를 모니터링하고, SLA 초과 임박 건에 대해 알림을 발송하는 로직이 필요해요.
분실/도난 신고 처리 플로우
분실/도난 대응은 이용자 보호와 부정 사용 방지의 균형이에요.
전자금융거래법 시행령 제9조는 선불전자지급수단의 분실/도난 시 책임 범위를 규정해요. 이용자가 분실/도난 사실을 발행자에게 통지한 시점을 기준으로, 통지 전 발생한 손해는 약정에 따라 이용자가 부담할 수 있지만, 통지 후 발생한 손해는 발행자가 부담해요.
시스템에서 구현해야 할 플로우는 이래요.
여기서 주의할 점은 "통지 시점"의 정확한 기록이에요. 이 timestamp가 손해 책임 분기의 기준이 되기 때문에, 밀리초 단위로 정확하게 기록하고 위변조가 불가능한 형태로 저장해야 해요. 이벤트 소싱 패턴을 사용하면 이 요구사항을 자연스럽게 충족할 수 있어요.
실무에서 자주 보는 실수는 "이용 정지"를 단순 플래그로 구현하는 거예요. is_suspended = true로 바꾸면 끝이라고 생각하지만, 정지 시점에 이미 진행 중이던 결제(PENDING 상태)가 있을 수 있어요. 이 건들에 대한 처리 정책도 미리 정해둬야 해요.
이벤트 소싱으로 거래 이력 관리하기
이벤트 소싱의 핵심은 "현재 상태"를 저장하는 대신 "상태를 만든 모든 이벤트"를 저장하는 거예요.
선불전자지급수단 시스템에서 이벤트 소싱이 특히 적합한 이유가 있어요. 금융감독원 검사 시 "이 잔액이 어떻게 만들어졌는지" 전체 이력을 증명해야 하거든요. 이벤트 소싱은 모든 변경 이력을 불변(immutable)으로 보관하기 때문에, 감사 추적(Audit Trail)이 자연스럽게 구현돼요.
// 이벤트 타입 정의
type WalletEvent =
| { type: 'CHARGED'; amount: number; source: string; idempotencyKey: string; }
| { type: 'PAID'; amount: number; merchantId: string; idempotencyKey: string; }
| { type: 'REFUNDED'; amount: number; originalTxId: string; reason: string; }
| { type: 'SUSPENDED'; reason: 'LOST' | 'STOLEN' | 'FRAUD'; reportedAt: string; }
| { type: 'RESUMED'; verifiedAt: string; }
| { type: 'LIMIT_UPGRADED'; from: number; to: number; verificationMethod: string; };
// 이벤트 스토어 (append-only)
interface StoredEvent {
eventId: string; // UUIDv7 (시간 순서 보장)
instrumentId: string; // 선불전자지급수단 ID
eventType: string;
payload: WalletEvent;
occurredAt: string; // ISO 8601
version: number; // 낙관적 동시성 제어
}
// 현재 상태는 이벤트를 순서대로 적용해서 계산
function rebuildState(events: StoredEvent[]): WalletState {
return events.reduce((state, event) => {
switch (event.payload.type) {
case 'CHARGED':
return { ...state, balance: state.balance + event.payload.amount };
case 'PAID':
return { ...state, balance: state.balance - event.payload.amount };
case 'REFUNDED':
return { ...state, balance: state.balance + event.payload.amount };
case 'SUSPENDED':
return { ...state, status: 'SUSPENDED', suspendedAt: event.payload.reportedAt };
case 'RESUMED':
return { ...state, status: 'ACTIVE' };
default:
return state;
}
}, { balance: 0, status: 'ACTIVE' } as WalletState);
}
이벤트 소싱에서 성능 문제가 생기는 지점은 이벤트가 수천 건 이상 쌓였을 때 잔액 조회예요. 매번 전체 이벤트를 replay하면 느려지거든요. 이를 해결하는 패턴이 스냅샷(Snapshot)이에요.
일정 주기(예: 100건마다)로 현재 상태의 스냅샷을 저장하고, 조회 시에는 마지막 스냅샷 이후의 이벤트만 replay해요. 스냅샷은 캐시일 뿐이고, 원본 이벤트가 진실의 원천(Source of Truth)이에요. 정산 배치에서는 스냅샷을 무시하고 전체 이벤트를 replay해서 정합성을 검증해요.
이벤트 소싱과 이중기장 원장을 결합하면 이런 구조가 돼요.
결국 이벤트 소싱은 "왜 이 잔액인지"를 언제든 설명할 수 있는 시스템을 만드는 거예요. 금융 시스템에서 이건 선택이 아니라 필수예요.
전체 아키텍처 정리
지금까지 다룬 내용을 하나의 아키텍처로 정리하면 이래요.
이 구조에서 가장 중요한 원칙은 "원장은 절대 수정하지 않는다"예요. 모든 변경은 새로운 이벤트와 journal entry의 추가로만 이루어져요. 잘못된 거래가 있으면 역분개 entry를 추가하지, 기존 entry를 삭제하거나 수정하지 않아요.
에이핀은 전자금융 시스템의 원장 설계부터 한도 관리, 정산 배치까지 전체 아키텍처를 함께 설계하고 있어요. 규제 요건을 시스템 설계로 옮기는 과정에서 놓치기 쉬운 포인트들을 실무 경험을 바탕으로 짚어드려요.
핵심 요약
- 선불전자지급수단과 전자화폐는 법적 정의와 시스템 구현 범위가 완전히 달라요. 대부분의 간편결제 서비스는 선불전자지급수단으로 등록해요.
- 발행권면 최고한도는 무기명식 50만원, 기명식 200만원이에요. 한도 검증은 충전 시점과 결제 시점 양쪽에서 수행해야 해요.
- 원장은 이중기장(Double-Entry)으로 설계하고, 잔액은 journal entries의 SUM으로 계산해요. 별도 balance 컬럼에 의존하면 동시성 문제가 생겨요.
- 멱등성 키는 클라이언트가 생성하고, 서버는 Redis에 24시간 TTL로 캐시해요. body fingerprint로 변조를 감지해요.
- 환불은 법적으로 잔액 전부 환급이 원칙이에요. 시스템에서는 역분개 entry를 append하는 방식으로 처리해요.
- 이벤트 소싱은 감사 추적과 잔액 증명을 자연스럽게 해결해요. 스냅샷으로 성능 문제를 보완해요.
FAQ
Q. 선불전자지급수단의 발행권면 최고한도는 얼마인가요?
전자금융거래법 시행령 별표 3에 따르면, 무기명식(실명확인 없이 발행)은 50만원, 기명식(실명확인 완료)은 200만원이에요. 이 한도는 이용자 인별 기준이 아니라 개별 선불전자지급수단 단위로 적용돼요. 즉, 하나의 선불전자지급수단에 저장할 수 있는 최대 금전적 가치를 의미해요.
Q. 실명확인 없이 발행할 수 있는 선불전자지급수단의 한도는 얼마인가요?
50만원이에요. 실명확인 없이 발행하는 무기명식 선불전자지급수단은 발행권면 최고한도가 50만원으로 제한돼요. 실명확인을 완료하면 200만원까지 확대할 수 있어요. 시스템에서는 이용자의 실명확인 상태에 따라 한도 정책을 분기 처리해야 해요.
Q. 선불전자지급수단 환불 시스템에서 반드시 구현해야 하는 기능은 무엇인가요?
전자금융거래법 시행령 제11조의2에 따라, 이용자가 환불을 요청하면 잔액 전부를 환급해야 해요. 시스템에서는 환불 사유 검증, 잔액 계산, 환불 금액 산정(유효기간 만료 시 90%), 이중기장 역분개 기록, 외부 계좌 이체, SLA 모니터링(영업일 10일 이내)을 구현해야 해요.
Q. 선불전자지급수단과 전자화폐의 시스템 설계 차이는 무엇인가요?
가장 큰 차이는 구현 범위예요. 선불전자지급수단은 온라인 가맹점 결제와 충전/환불 시스템으로 시작할 수 있지만, 전자화폐는 오프라인 결제 단말 연동, 실시간 환전 시스템, 범용 가맹점 네트워크까지 구축해야 해요. 자본금도 선불전자지급수단은 20억원(등록), 전자화폐는 50억원(허가)으로 차이가 커요.
Q. 결제 API에서 멱등성 키는 왜 클라이언트가 생성해야 하나요?
서버가 멱등성 키를 생성하면, 네트워크 장애로 클라이언트가 응답을 받지 못했을 때 재시도 시 새로운 키가 발급돼요. 그러면 서버는 이전 요청과 재시도를 구분할 수 없어서 이중 차감이 발생해요. 클라이언트가 키를 생성하면 동일한 논리적 요청에 대해 항상 같은 키를 보내므로, 서버가 중복을 감지할 수 있어요.
