이 글은 누구를 위한 것인가
- 기프트카드/디지털 상품권 발급 시스템을 구축하려는 팀
- 잔액 부분 사용, 유효기간, 환불 로직을 설계하는 개발자
- 복수 기프트카드 합산 결제를 구현하려는 팀
들어가며
기프트카드는 단순해 보이지만 부분 사용, 잔액 이월, 환불 시 크레딧 반환, 복수 카드 합산 등 엣지케이스가 많다. 분산 환경에서 이중 사용(double spend)을 막으려면 DB 낙관적 잠금이 필수다.
이 글은 bluefoxdev.kr의 기프트카드 시스템 설계 가이드 를 참고하여 작성했습니다.
1. 기프트카드 시스템 아키텍처
[기프트카드 상태 머신]
ISSUED → ACTIVATED → PARTIALLY_USED → EXHAUSTED
↘ EXPIRED
↘ CANCELLED → REFUNDED
[코드 생성 전략]
CSPRNG → Base32 → 4자리씩 하이픈
예: ABCD-EFGH-JKLM-NPQR (16자)
보안 원칙:
- crypto.randomBytes (CSPRNG) 필수
- 순차/예측 가능한 코드 금지
- Luhn 체크섬 디지트 추가
[이중 사용 방지]
DB: version 컬럼 낙관적 잠금
DB: (gift_card_id, order_id) 유니크 제약
Redis: SET NX PX 분산 락 (선택)
[잔액 처리]
불변 트랜잭션 이력 + 캐시된 currentBalance
변경 시 항상 GiftCardTransaction 생성
2. 기프트카드 구현
import crypto from 'crypto';
class GiftCardService {
generateCode(): string {
const bytes = crypto.randomBytes(10);
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let code = '';
for (let i = 0; i < 16; i++) {
code += chars[bytes[i % 10] % 32];
if (i > 0 && i % 4 === 3 && i < 15) code += '-';
}
return code;
}
async issue(params: {
amount: number;
currency?: string;
expiresInDays?: number;
issuedTo?: string;
}) {
const code = this.generateCode();
const expiresAt = params.expiresInDays
? new Date(Date.now() + params.expiresInDays * 86400_000)
: null;
return db.giftCard.create({
data: {
code,
initialAmount: params.amount,
currentBalance: params.amount,
currency: params.currency ?? 'KRW',
status: 'ISSUED',
expiresAt,
issuedTo: params.issuedTo,
},
});
}
// 낙관적 잠금으로 이중 사용 방지
async redeem(params: { code: string; amount: number; orderId: string }) {
for (let attempt = 0; attempt < 3; attempt++) {
const card = await db.giftCard.findUnique({ where: { code: params.code } });
if (!card) return { success: false, message: '유효하지 않은 코드' };
if (card.expiresAt && card.expiresAt < new Date())
return { success: false, message: '만료된 기프트카드' };
if (Number(card.currentBalance) < params.amount)
return { success: false, message: '잔액 부족', balance: Number(card.currentBalance) };
const newBalance = Number(card.currentBalance) - params.amount;
try {
await db.$transaction([
db.giftCard.update({
where: { id: card.id, version: card.version }, // 낙관적 잠금
data: {
currentBalance: newBalance,
status: newBalance === 0 ? 'EXHAUSTED' : 'PARTIALLY_USED',
version: { increment: 1 },
},
}),
db.giftCardTransaction.create({
data: {
giftCardId: card.id,
orderId: params.orderId,
amount: -params.amount,
type: 'REDEMPTION',
},
}),
]);
return { success: true, remainingBalance: newBalance };
} catch (err: any) {
if (err.code === 'P2025' && attempt < 2) continue; // 버전 충돌 재시도
throw err;
}
}
throw new Error('동시 요청 충돌. 다시 시도하세요.');
}
// 복수 기프트카드 합산 사용
async redeemMultiple(codes: string[], totalAmount: number, orderId: string) {
const cards = await Promise.all(codes.map(c => this.getCardInfo(c)));
const available = cards.reduce((s, c) => s + c.balance, 0);
if (available < totalAmount) throw new Error(`합산 잔액 부족: ${available}원`);
let remaining = totalAmount;
for (const card of cards) {
if (remaining <= 0) break;
const use = Math.min(card.balance, remaining);
await this.redeem({ code: card.code, amount: use, orderId });
remaining -= use;
}
}
private async getCardInfo(code: string) {
const card = await db.giftCard.findUniqueOrThrow({ where: { code } });
if (card.expiresAt && card.expiresAt < new Date()) throw new Error(`만료: ${code}`);
return { code, balance: Number(card.currentBalance) };
}
}
// 주문 취소 시 기프트카드 환불
async function refundGiftCard(orderId: string) {
const txs = await db.giftCardTransaction.findMany({
where: { orderId, type: 'REDEMPTION' },
});
for (const tx of txs) {
await db.$transaction([
db.giftCard.update({
where: { id: tx.giftCardId },
data: { currentBalance: { increment: Math.abs(Number(tx.amount)) }, status: 'ACTIVATED' },
}),
db.giftCardTransaction.create({
data: { giftCardId: tx.giftCardId, orderId, amount: Math.abs(Number(tx.amount)), type: 'REFUND' },
}),
]);
}
}
const db = {} as any;
마무리
기프트카드의 핵심은 이중 사용 방지와 정확한 잔액 추적이다. (giftCardId, orderId) 유니크 제약으로 DB 레벨 이중 사용을 막고, version 낙관적 잠금으로 동시 요청을 처리한다. 잔액 변경은 항상 불변 트랜잭션 이력으로 기록해 환불과 감사 추적을 보장한다.