
결제 시스템 설계: 돈이 오가는 코드의 무게
결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.

결제 API 연동이 끝이 아니었다. 중복 결제 방지, 환불 처리, 멱등성까지 고려하니 결제 시스템이 왜 어려운지 뼈저리게 느꼈다.
왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

ChatGPT는 질문에 답하지만, AI Agent는 스스로 계획하고 도구를 사용해 작업을 완료한다. 이 차이가 왜 중요한지 정리했다.

직접 가기 껄끄러울 때 프록시가 대신 갔다 옵니다. 내 정체를 숨기려면 Forward Proxy, 서버를 보호하려면 Reverse Proxy. 같은 대리인인데 누구 편이냐가 다릅니다.

왜 넷플릭스는 멀쩡한 서버를 랜덤하게 꺼버릴까요? 시스템의 약점을 찾기 위해 고의로 장애를 주입하는 카오스 엔지니어링의 철학과 실천 방법(GameDay)을 소개합니다.

유저가 두 번 결제됐다는 메일을 보냈다. 5만 원 짜리 상품인데, 카드에서 10만 원이 빠져나갔다고. 로그를 뒤졌다. 주문은 하나인데 결제 기록은 두 개였다. "결제하기" 버튼을 두 번 눌렀고, 내 코드는 그 두 번을 모두 받아들였다.
그날 밤 잠을 못 잤다. 환불 처리하면서 깨달았다. 결제 API 붙이는 건 시작일 뿐이었구나. 돈이 오가는 코드는 다르다. 클릭 이벤트가 중복으로 들어와도, 네트워크가 끊겨도, 서버가 재시작되어도, 돈은 정확히 한 번만 움직여야 한다.
결제 시스템을 만들면서 배운 건 기술이 아니었다. 책임의 무게였다. 이 코드는 누군가의 월급이고, 창업자의 운영비고, 할머니의 손주 선물값이다. 버그가 나면 신뢰가 무너진다. 그래서 결제 시스템은 어렵다.
처음엔 생각했다. Stripe이든 Toss Payments든 SDK 갖다 쓰면 되지 않나? 문서 보고 charge() 함수 호출하면 끝 아닌가?
아니었다. 결제는 상태 기계(State Machine)다. 주문이 생기고, 결제 의도가 만들어지고, 실제 청구가 일어나고, 확인되고, 때로는 환불된다. 각 단계마다 실패할 수 있고, 각 실패마다 다른 처리가 필요하다.
// 순진했던 초기 코드
async function processPayment(orderId: string, amount: number) {
const result = await paymentClient.charge({
amount,
orderId,
});
if (result.success) {
await db.updateOrder(orderId, { status: 'paid' });
}
return result;
}
이 코드의 문제점을 세어보니 최소 다섯 가지였다:
charge()는 성공했는데 updateOrder()가 실패하면? 돈은 빠져나갔는데 주문 상태는 미결제다결제 시스템은 단순한 CRUD가 아니다. 오케스트라의 지휘자처럼 여러 시스템을 조율하고, 각 악기(주문, 재고, 결제사, 알림)가 정확한 타이밍에 연주하도록 만들어야 한다.
중복 결제 문제의 해답은 멱등성(Idempotency)이었다. 수학 용어인데, 간단히 말하면 "같은 연산을 여러 번 해도 결과는 한 번 한 것과 같다"는 뜻이다.
결제에서는 멱등성 키(Idempotency Key)로 구현한다. 각 결제 요청마다 고유한 키를 만들고, 같은 키로 다시 요청이 오면 새로 결제하지 않고 이전 결과를 돌려준다.
// 멱등성이 적용된 결제 플로우
async function createPayment(orderId: string, amount: number) {
// 멱등성 키는 주문 ID 기반으로 생성
const idempotencyKey = `payment_${orderId}_${Date.now()}`;
// 먼저 DB에 결제 의도(intent) 생성
const paymentIntent = await db.createPaymentIntent({
orderId,
amount,
idempotencyKey,
status: 'pending',
});
// 이미 처리된 적이 있는지 확인
const existing = await db.findPaymentByIdempotencyKey(idempotencyKey);
if (existing) {
return existing; // 중복 요청은 기존 결과 반환
}
try {
// 실제 결제사 API 호출
const result = await paymentClient.charge({
amount,
idempotencyKey, // Stripe, Toss 모두 지원
metadata: { orderId, paymentIntentId: paymentIntent.id },
});
// 결제 성공 시 상태 업데이트
await db.updatePaymentIntent(paymentIntent.id, {
status: 'processing',
externalId: result.id,
});
return result;
} catch (error) {
// 실패해도 기록은 남긴다
await db.updatePaymentIntent(paymentIntent.id, {
status: 'failed',
errorMessage: error.message,
});
throw error;
}
}
멱등성 키는 영수증 번호와 비슷하다. 카페에서 주문하고 영수증을 받았는데, 커피가 안 나와서 다시 카운터에 갔다고 해보자. 영수증을 보여주면 같은 주문으로 커피를 두 번 만들지 않는다. 이미 진행 중이라고 말해주거나, 완성된 커피를 준다. 돈은 한 번만 받는다.
결제는 여러 상태를 거친다. 각 상태 전환마다 무슨 일이 일어날 수 있는지, 어디서 실패할 수 있는지 명확히 정의해야 한다.
type PaymentStatus =
| 'pending' // 결제 의도 생성됨
| 'processing' // 결제사에 요청 전송
| 'requires_action' // 3D Secure 등 추가 인증 필요
| 'completed' // 결제 완료
| 'failed' // 결제 실패
| 'refunding' // 환불 진행 중
| 'refunded' // 환불 완료
| 'partial_refunded'; // 부분 환불
interface PaymentStateMachine {
current: PaymentStatus;
allowedTransitions: Record<PaymentStatus, PaymentStatus[]>;
}
const paymentStateMachine: PaymentStateMachine['allowedTransitions'] = {
pending: ['processing', 'failed'],
processing: ['requires_action', 'completed', 'failed'],
requires_action: ['processing', 'failed'],
completed: ['refunding'],
failed: ['pending'], // 재시도 허용
refunding: ['refunded', 'partial_refunded', 'failed'],
refunded: [], // 최종 상태
partial_refunded: ['refunding'], // 추가 환불 가능
};
function canTransition(from: PaymentStatus, to: PaymentStatus): boolean {
return paymentStateMachine[from].includes(to);
}
상태 기계를 명확히 하니 무엇을 기록해야 하는지도 분명해졌다. 모든 상태 전환을 이벤트로 남긴다:
interface PaymentEvent {
id: string;
paymentId: string;
type: 'status_changed' | 'webhook_received' | 'refund_requested';
fromStatus: PaymentStatus | null;
toStatus: PaymentStatus;
timestamp: Date;
metadata: Record<string, any>;
userId?: string;
}
// 상태 변경 시 항상 이벤트 기록
async function transitionPaymentStatus(
paymentId: string,
toStatus: PaymentStatus,
metadata: Record<string, any> = {}
) {
const payment = await db.getPayment(paymentId);
if (!canTransition(payment.status, toStatus)) {
throw new Error(
`Invalid transition: ${payment.status} -> ${toStatus}`
);
}
await db.transaction(async (tx) => {
// 상태 업데이트
await tx.updatePayment(paymentId, { status: toStatus });
// 이벤트 기록
await tx.createPaymentEvent({
paymentId,
type: 'status_changed',
fromStatus: payment.status,
toStatus,
timestamp: new Date(),
metadata,
});
});
}
이게 비행기의 블랙박스다. 뭔가 잘못됐을 때, 어느 시점에 무슨 일이 일어났는지 정확히 재구성할 수 있다. 유저가 "결제했는데 안 됐어요"라고 하면, 이벤트 로그를 보고 정확히 어디서 막혔는지 알 수 있다.
결제는 비동기다. 카드사 승인이 늦을 수도 있고, 3D Secure 인증에 시간이 걸릴 수도 있다. 결제사는 결과를 웹훅(Webhook)으로 알려준다.
문제는 웹훅이 신뢰할 수 없다는 것이다. 네트워크가 끊길 수 있고, 우리 서버가 재시작될 수 있고, 똑같은 웹훅이 여러 번 올 수 있다.
// 웹훅 핸들러: 멱등성이 핵심
async function handlePaymentWebhook(
payload: WebhookPayload,
signature: string
) {
// 1. 서명 검증 (중요! 위조 방지)
const isValid = verifyWebhookSignature(payload, signature);
if (!isValid) {
throw new Error('Invalid webhook signature');
}
// 2. 웹훅 이벤트 ID로 중복 체크
const eventId = payload.id;
const existing = await db.findWebhookEvent(eventId);
if (existing) {
// 이미 처리했으면 200 OK 반환 (재시도 중단)
return { status: 'already_processed' };
}
// 3. 웹훅 이벤트 기록 (멱등성 보장)
await db.createWebhookEvent({
id: eventId,
type: payload.type,
data: payload.data,
processedAt: new Date(),
});
// 4. 이벤트 타입별 처리
switch (payload.type) {
case 'payment.completed':
await handlePaymentCompleted(payload.data);
break;
case 'payment.failed':
await handlePaymentFailed(payload.data);
break;
case 'refund.completed':
await handleRefundCompleted(payload.data);
break;
}
return { status: 'processed' };
}
async function handlePaymentCompleted(data: any) {
const paymentId = data.paymentId;
await db.transaction(async (tx) => {
// 결제 상태 업데이트
await transitionPaymentStatus(paymentId, 'completed', {
source: 'webhook',
externalId: data.id,
});
// 주문 완료 처리
const payment = await tx.getPayment(paymentId);
await tx.updateOrder(payment.orderId, {
status: 'paid',
paidAt: new Date(),
});
// 재고 차감, 이메일 전송 등
await fulfillOrder(payment.orderId);
});
}
웹훅 재시도 로직도 중요하다. Stripe는 실패하면 최대 3일간 재시도한다. 우리도 비슷하게 구현할 수 있다:
// 웹훅 재시도를 위한 큐
async function enqueueWebhookRetry(
webhookUrl: string,
payload: any,
attempt: number = 0
) {
const maxAttempts = 5;
const backoffMs = Math.pow(2, attempt) * 1000; // 지수 백오프
if (attempt >= maxAttempts) {
// 최종 실패: 알림 발송
await notifyWebhookFailure(webhookUrl, payload);
return;
}
setTimeout(async () => {
try {
await sendWebhook(webhookUrl, payload);
} catch (error) {
// 실패하면 재시도
await enqueueWebhookRetry(webhookUrl, payload, attempt + 1);
}
}, backoffMs);
}
웹훅은 우편 배달과 같다. 편지(이벤트)를 보냈는데 집에 아무도 없으면(서버 다운), 나중에 다시 온다. 몇 번 시도해도 안 되면 우체국에 보관한다(Dead Letter Queue). 그리고 중요한 건, 같은 편지를 여러 번 받아도 한 번만 읽은 것처럼 처리해야 한다(멱등성).
환불은 결제보다 복잡할 수 있다. 전액 환불, 부분 환불, 환불 수수료, 환불 기한 등을 고려해야 한다.
interface RefundRequest {
paymentId: string;
amount?: number; // undefined면 전액 환불
reason: 'requested_by_customer' | 'fraudulent' | 'duplicate' | 'other';
metadata?: Record<string, any>;
}
async function refundPayment(request: RefundRequest) {
const payment = await db.getPayment(request.paymentId);
// 환불 가능 상태 체크
if (payment.status !== 'completed') {
throw new Error('Only completed payments can be refunded');
}
// 환불 금액 검증
const refundAmount = request.amount ?? payment.amount;
const alreadyRefunded = await db.getTotalRefunded(request.paymentId);
if (alreadyRefunded + refundAmount > payment.amount) {
throw new Error('Refund amount exceeds payment amount');
}
// 환불 의도 생성
const refund = await db.createRefund({
paymentId: request.paymentId,
amount: refundAmount,
reason: request.reason,
status: 'pending',
});
try {
// 결제사 API 호출
const result = await paymentClient.refund({
paymentId: payment.externalId,
amount: refundAmount,
reason: request.reason,
idempotencyKey: `refund_${refund.id}`,
});
// 환불 진행 상태로 변경
await transitionPaymentStatus(
request.paymentId,
refundAmount === payment.amount ? 'refunding' : 'partial_refunded',
{ refundId: refund.id }
);
await db.updateRefund(refund.id, {
status: 'processing',
externalId: result.id,
});
return refund;
} catch (error) {
await db.updateRefund(refund.id, {
status: 'failed',
errorMessage: error.message,
});
throw error;
}
}
환불도 비동기로 처리되고, 웹훅으로 결과를 받는다. 그리고 중요한 건 환불도 정합성(Reconciliation)을 맞춰야 한다는 것이다.
결제 시스템에서 가장 무서운 건 "우리 DB에는 결제가 성공했는데, 실제로는 돈이 안 들어온 경우" 또는 그 반대다.
정합성(Reconciliation)은 우리 DB와 결제사의 기록을 주기적으로 대조하는 과정이다:
async function reconcilePayments(date: Date) {
// 1. 해당 날짜의 우리 결제 기록 가져오기
const ourPayments = await db.getPaymentsByDate(date);
// 2. 결제사 기록 가져오기
const theirPayments = await paymentClient.listPayments({
created: { gte: date, lt: addDays(date, 1) },
});
// 3. 매칭
const ourIds = new Set(ourPayments.map(p => p.externalId));
const theirIds = new Set(theirPayments.map(p => p.id));
// 우리한테만 있는 것 (결제사에는 없음)
const onlyInOurDB = ourPayments.filter(
p => !theirIds.has(p.externalId)
);
// 결제사에만 있는 것 (우리 DB에 없음)
const onlyInPaymentProvider = theirPayments.filter(
p => !ourIds.has(p.id)
);
// 4. 불일치 항목 리포트
const report = {
date,
totalOurs: ourPayments.length,
totalTheirs: theirPayments.length,
mismatches: {
onlyInOurDB,
onlyInPaymentProvider,
},
};
// 5. 알림 발송
if (onlyInOurDB.length > 0 || onlyInPaymentProvider.length > 0) {
await alertFinanceTeam(report);
}
return report;
}
// 매일 자정에 전날 정합성 체크
cron.schedule('0 0 * * *', async () => {
const yesterday = subDays(new Date(), 1);
await reconcilePayments(yesterday);
});
정합성은 은행의 결산과 같다. 장부상 잔액과 실제 금고의 돈이 같은지 확인한다. 안 맞으면 어디서 빠져나갔는지, 기록이 누락됐는지 찾아야 한다.
PCI DSS(Payment Card Industry Data Security Standard)는 카드 결제를 다루는 모든 시스템이 따라야 하는 보안 표준이다. 핵심은 간단하다: 카드 번호를 우리 서버에 저장하지 마라.
대신 토큰화(Tokenization)를 쓴다:
// 프론트엔드에서 직접 결제사로 카드 정보 전송
// (우리 서버를 거치지 않음)
const stripe = Stripe('pk_live_...');
const cardElement = elements.create('card');
// 토큰 생성
const { token } = await stripe.createToken(cardElement);
// 우리 서버에는 토큰만 전달
const response = await fetch('/api/payments', {
method: 'POST',
body: JSON.stringify({
orderId: 'order_123',
paymentToken: token.id, // 카드 번호가 아니라 토큰
}),
});
서버는 카드 번호를 보지 못한다. 토큰만 받아서 결제사에 전달한다:
async function chargeWithToken(orderId: string, token: string) {
const order = await db.getOrder(orderId);
const result = await paymentClient.charge({
amount: order.amount,
source: token, // 토큰 사용
metadata: { orderId },
});
// 결제 결과만 저장 (카드 정보는 없음)
await db.createPayment({
orderId,
amount: order.amount,
externalId: result.id,
last4: result.card.last4, // 마지막 4자리만
brand: result.card.brand, // Visa, MasterCard 등
});
}
이게 대리 운전이다. 우리가 직접 차(카드 정보)를 만지지 않는다. 전문 기사(결제사)에게 맡기고, 우리는 "A에서 B로 가주세요(결제해주세요)"라고만 한다.
결제 시스템을 만들면서 기술을 많이 배웠다. 멱등성, 상태 기계, 웹훅, 정합성. 하지만 진짜 배운 건 책임의 무게였다.
버튼을 눌렀는데 두 번 결제된 유저. 환불이 늦어져서 항의 메일을 보낸 고객. 정합성이 안 맞아서 며칠 밤을 새운 팀원. 이 모든 게 내 코드 때문이었다.
돈이 오가는 코드는 다르다. 테스트를 더 많이 쓰게 된다. 로그를 더 자세히 남긴다. 엣지 케이스를 더 고민한다. "이 정도면 되겠지"가 아니라 "이게 정말 안전한가"를 묻게 된다.
결제 시스템은 기술 문제가 아니라 신뢰 문제다. 유저가 우리를 믿고 카드 번호를 입력한다. 그 신뢰를 지키는 게 개발자의 일이다. 그래서 결제 코드는 무겁다. 무거워야 한다.