도입
AI 채팅 플랫폼에서 재화 시스템을 설계할 때, 가장 까다로운 문제는 "AI가 실패하면 어떻게 할 것인가"다.
Plit은 사용자가 메시지를 보낼 때마다 행동력(Energy)을 차감하고, 행동력이 부족하면 젬(Gem)을 자동 소비해 행동력을 충전한다. 문제는 그 다음이다. Gemini나 Claude 같은 외부 LLM API는 타임아웃, 콘텐츠 필터 차단, 네트워크 오류 등 다양한 이유로 실패할 수 있다. 이미 차감한 재화를 안전하게 돌려주지 못하면 — 사용자는 돈을 잃고, 플랫폼은 신뢰를 잃는다.
핵심 질문: 차감은 했는데 AI 응답이 실패하면, 행동력과 젬을 어떻게 안전하게 원복하는가?
단순해 보이지만, 이중 재화(행동력 + 젬)가 연쇄적으로 엮여 있고, 환불 자체가 실패할 수도 있으며, 동시 요청으로 인한 레이스 컨디션까지 고려하면 설계는 급격히 복잡해진다. 이 글에서는 Plit이 채택한 선차감-환불(pre-deduct/refund) 패턴과 그 구현을 기록한다.
핵심 1: 이중 재화 시스템 개요
Plit의 재화는 두 축으로 구성된다.
행동력(Energy) — 매일 30씩 지급되는 소비 재화. 메시지 1회당 2 소비. 다음 날로 이월되는 상한은 10이다.
젬(Gem) — 실제 결제로 충전하는 프리미엄 재화. 행동력이 부족하면 20젬을 소비해 행동력 10을 자동 충전한다.
비용 정책은 백엔드 상수 파일에서만 관리한다.
// energy.constants.ts — 행동력 정책 (단일 소스)
export const ENERGY_CONSTANTS = {
DAILY_ALLOWANCE: 30, // 일일 지급
MAX_CARRYOVER: 10, // 이월 상한
AUTO_CHARGE_AMOUNT: 10, // 젬으로 충전 시 행동력
COST_PER_ACTION: 2, // 메시지당 소비
} as const;
// gem-costs.ts — 젬 소비 단가 (단일 소스)
export const GEM_COST: Partial<Record<GemTransactionReason, number>> = {
ENERGY_CHARGE: 20, // 행동력 자동 충전
AFFINITY_BOOST: 5, // 호감도 부스터
STORY_UNLOCK: 100, // 스토리 해금
PREMIUM_CHARACTER_UNLOCK: 50, // 프리미엄 캐릭터 해금
TALK_ROOM_UNLOCK: 50, // 토크룸 해금
EXTENDED_MEMORY: 30, // 확장 메모리
};
프론트엔드는 이 값을 하드코딩하지 않는다. GET /api/v1/energy/policies와 GET /api/v1/gems/policies API로 정책을 조회하고, 1시간 캐시한다. 백엔드 상수만 수정하면 클라이언트 배포 없이 정책이 반영된다.
이 구조에서 재화 흐름은 연쇄적이다: 메시지 전송 → 행동력 차감 → (부족 시) 젬 자동 소비 → 행동력 충전 → AI 파이프라인 실행. 어느 단계에서든 실패하면, 이전 단계의 차감을 되돌려야 한다.
핵심 2: 선차감-환불 패턴
Plit의 메시지 전송 흐름은 "차감 먼저, 실패 시 환불" 패턴을 따른다.

왜 선차감인가? 후차감(AI 성공 후 차감)은 레이스 컨디션에 취약하다. 사용자가 행동력 2를 남긴 채 메시지를 두 번 동시에 보내면, 두 요청 모두 "2 >= 2이니 차감 가능"으로 판단하고 AI 파이프라인을 실행한다. 결과적으로 행동력이 마이너스가 되거나, 한쪽 응답이 무료로 제공된다.
선차감은 이 문제를 원천 차단한다. 행동력 차감 시점에 Redis 분산 락과 원자적 SQL을 조합해, 잔액이 부족하면 차감 자체가 실패한다.
// energy.service.ts — 행동력 소비 (Redis 락 + 원자적 차감)
async consumeEnergy(userId: string) {
// 분산 락: 동일 사용자의 동시 차감 방지
const lockKey = `energy:consume:${userId}`;
const locked = await this.redis.set(lockKey, '1', { ex: 5, nx: true });
if (!locked) {
throw new HttpException('처리 중입니다', HttpStatus.TOO_MANY_REQUESTS);
}
try {
let wallet = await this.getOrCreateEnergy(userId);
// 잔액 충분 → 원자적 차감 (WHERE balance >= cost)
if (wallet.balance >= cost) {
const result = await this.prisma.$queryRawUnsafe<{ balance: number }[]>(
`UPDATE energy_wallets
SET balance = balance - $1, updated_at = NOW()
WHERE user_id = $2 AND balance >= $1
RETURNING balance`,
cost, userId,
);
if (result.length > 0) {
return { energy: result[0].balance, gemCharged: false };
}
}
// 잔액 부족 → 젬 20개 자동 소비 → 행동력 10 충전
await this.gemsService.consume(userId, GemTransactionReason.ENERGY_CHARGE);
const chargedBalance = wallet.balance + ENERGY_CONSTANTS.AUTO_CHARGE_AMOUNT - cost;
await this.prisma.energyWallet.update({
where: { userId },
data: { balance: chargedBalance },
});
return { energy: chargedBalance, gemCharged: true };
} finally {
await this.redis.del(lockKey); // 락 해제
}
}
핵심은 세 가지다.
- Redis NX 락 —
SET lockKey 1 EX 5 NX로 5초 TTL 분산 락을 건다. 같은 사용자의 동시 요청은429 TOO_MANY_REQUESTS로 차단된다. - 원자적 SQL —
UPDATE ... WHERE balance >= $1 RETURNING balance로 잔액 확인과 차감을 단일 쿼리에서 처리한다. 앱 레벨의 read-then-write 레이스 컨디션이 불가능하다. - 연쇄 차감 추적 —
gemCharged: boolean플래그를 반환해, 나중에 환불할 때 젬도 함께 환불할지 결정한다.
핵심 3: 환불 조건 분기
AI 파이프라인 실패 시 환불 범위는 실패 지점에 따라 달라진다. ChatController.sendMessage()의 try-catch 구조가 이를 처리한다.
케이스 1: 입력 필터 차단
사용자 메시지가 블랙리스트 키워드에 걸리면, AI 파이프라인이 실행되기 전에 차단된다. 이미 차감한 행동력(+ 연쇄 소비된 젬)을 전액 환불한다.
// chat.controller.ts — 입력 필터 차단 시 환불 (BIL-016)
if (inputCheck.flagged) {
let energyRefunded = false;
let gemRefunded = false;
if (energyResult) {
try {
await this.energyService.refundEnergy(userId, ENERGY_CONSTANTS.COST_PER_ACTION);
energyRefunded = true;
// 젬 자동 충전이 있었다면 젬도 환불
if (energyResult.gemCharged) {
await this.gemsService.refund(
userId, GemTransactionReason.ENERGY_CHARGE, sessionId,
);
gemRefunded = true;
}
} catch (refundError) {
this.logger.error(`Input filter refund failed for user ${userId}:`, refundError);
}
}
// SSE 에러 이벤트에 환불 결과 포함
res.write(`event: error\ndata: ${JSON.stringify({
message: energyRefunded
? gemRefunded
? '부적절한 내용이 포함되어 있어 전송할 수 없습니다. 행동력과 젬이 환불되었습니다.'
: '부적절한 내용이 포함되어 있어 전송할 수 없습니다. 행동력이 환불되었습니다.'
: '부적절한 내용이 포함되어 있어 전송할 수 없습니다.',
code: 'INPUT_FILTER_BLOCKED',
energyRefunded,
gemRefunded,
})}\n\n`);
}
케이스 2: AI 파이프라인 실패
타임아웃, API 오류, JSON 파싱 실패 등으로 AI 응답 생성 자체가 실패한 경우. 최외곽 catch 블록에서 동일한 환불 로직을 실행한다.
// chat.controller.ts — 파이프라인 실패 시 환불
catch (error) {
this.logger.error(`Pipeline error for session ${sessionId}:`, error);
let energyRefunded = false;
let gemRefunded = false;
if (energyResult) {
try {
await this.energyService.refundEnergy(userId, ENERGY_CONSTANTS.COST_PER_ACTION);
energyRefunded = true;
if (energyResult.gemCharged) {
await this.gemsService.refund(userId, GemTransactionReason.ENERGY_CHARGE, sessionId);
gemRefunded = true;
}
} catch (refundError) {
this.logger.error(`Refund failed for user ${userId}:`, refundError);
}
}
}
케이스 3: 젬 부족
행동력도 부족하고 젬도 부족한 상태. consumeEnergy() 내부에서 gemsService.consume()이 402 PAYMENT_REQUIRED를 던진다. 이 경우 차감 자체가 일어나지 않았으므로 — 환불할 것이 없다. 프론트엔드에 INSUFFICIENT_GEMS 코드를 전달해 젬 충전 UI로 유도한다.
케이스 4: 부분 성공
파이프라인이 1개 이상의 나레이션/대사를 스트리밍한 뒤 뒤늦게 실패한 경우는 어떻게 되는가? 현재 Plit에서는 runStream()이 스트리밍 콜백을 통해 개별 항목을 전송하되, 최종 결과(PipelineResult)를 반환하지 못하면 catch 블록으로 떨어진다. 이 경우에도 전액 환불된다 — 부분적으로 스트리밍된 텍스트는 사용자에게 보였지만, 호감도/감정/메모리 등 상태 갱신은 이루어지지 않았기 때문에 과금하지 않는 것이 합리적이다.
핵심 4: 멱등 환불과 referenceId
젬 환불에서 referenceId는 멱등성의 핵심이다. gemsService.refund() 호출 시 sessionId를 referenceId로 전달한다.
// gems.service.ts — 환불 (트랜잭션 내 원자적 처리)
async refund(userId: string, reason: GemTransactionReason, referenceId?: string) {
const cost = GEM_COST[reason]; // 20 (ENERGY_CHARGE)
return this.prisma.$transaction(async (tx) => {
const wallet = await tx.gemWallet.findUnique({ where: { userId } });
// 원자적 잔액 복원
const result = await tx.$queryRawUnsafe<{ balance: number }[]>(
`UPDATE gem_wallets SET balance = balance + $1, updated_at = NOW()
WHERE user_id = $2 RETURNING balance`,
cost, userId,
);
// 환불 트랜잭션 기록 (referenceId로 추적)
await tx.gemTransaction.create({
data: {
walletId: wallet.id,
type: GemTransactionType.REFUND,
amount: cost,
balanceAfter: result[0].balance,
reason: GemTransactionReason.REFUND,
referenceId, // sessionId — 어떤 세션에서 발생한 환불인지 추적
description: 'AI 응답 실패로 인한 행동력 충전 환불',
},
});
return { balance: result[0].balance };
});
}
referenceId의 역할은 두 가지다.
- 추적성 — 환불이 어떤 세션에서, 어떤 실패로 인해 발생했는지 젬 트랜잭션 내역에서 확인할 수 있다. 관리자 대시보드에서 비정상적인 환불 패턴(특정 세션에서 반복 환불 등)을 감지할 수 있다.
- 감사 로그 —
consume→refund쌍이 동일한referenceId를 공유하므로, 소비와 환불을 1:1로 매칭할 수 있다.
환불 자체가 실패하면 어떻게 되는가? DB 커넥션 오류, 데드락 등으로 refund()가 예외를 던지면 — catch 블록에서 this.logger.error()로 기록된다. Sentry의 글로벌 에러 필터(SentryGlobalFilter)가 이 에러를 캡처해 알림을 발생시킨다. 현재 구조에서는 자동 재시도 없이 로그 + Sentry 알림으로 수동 개입을 트리거하는 방식이다.
// chat.controller.ts — 환불 실패 시 로깅 (Sentry가 자동 캡처)
} catch (refundError) {
this.logger.error(`Refund failed for user ${userId}:`, refundError);
// SentryGlobalFilter가 500 에러로 캡처 → 알림 발생
}
이것이 최선인가? 완전한 멱등 환불을 구현하려면 referenceId 기반 중복 검사 (SELECT EXISTS)를 추가하고, 환불 실패 시 지수 백오프 재시도를 큐에 넣는 방식이 이상적이다. 현재는 발생 빈도가 극히 낮은 엣지 케이스여서 수동 대응으로 충분하지만, 사용자 규모가 커지면 개선이 필요한 영역이다.
핵심 5: SSE 에러 이벤트와 프론트엔드 연동
Plit의 채팅은 POST-based SSE로 동작한다. 에러도 SSE 이벤트로 전달되며, 환불 여부를 플래그로 포함한다.
// SSE 에러 이벤트 페이로드
{
message: 'AI 응답 생성에 실패했습니다. 행동력과 젬이 환불되었습니다.',
code: 'AI_GENERATION_FAILED', // | 'INPUT_FILTER_BLOCKED' | 'INSUFFICIENT_GEMS'
energyRefunded: true,
gemRefunded: true,
}
프론트엔드는 code와 환불 플래그 조합으로 UI를 분기한다.
| code | energyRefunded | gemRefunded | UI 동작 |
|---|---|---|---|
INSUFFICIENT_GEMS | — | — | 젬 충전 바텀시트 노출 |
INPUT_FILTER_BLOCKED | true | false | "부적절한 내용" 토스트 + 행동력 복원 표시 |
INPUT_FILTER_BLOCKED | true | true | "부적절한 내용" 토스트 + 행동력/젬 복원 표시 |
AI_GENERATION_FAILED | true | false | 재시도 버튼 + 행동력 복원 표시 |
AI_GENERATION_FAILED | true | true | 재시도 버튼 + 행동력/젬 복원 표시 |
AI_GENERATION_FAILED | false | false | 일반 에러 토스트 (환불 실패 — 고객지원 안내) |
마지막 행이 중요하다. 환불 자체가 실패한 경우(energyRefunded: false), 프론트엔드는 "잠시 후 다시 시도해주세요" 대신 고객지원 연결을 안내해야 한다. 동시에 Sentry 알림으로 운영팀이 수동 환불을 처리한다.
정리
AI 채팅 플랫폼에서 재화 안전성을 설계하며 적용한 핵심 판단을 정리한다.
-
선차감이 후차감보다 안전하다. 후차감은 "AI 성공 확인 → 차감" 사이의 레이스 컨디션을 막기 어렵다. 선차감 + 실패 시 환불이 더 단순하고 견고하다.
-
원자적 차감은 앱 레벨이 아니라 DB 레벨에서.
UPDATE ... WHERE balance >= cost RETURNING balance한 줄이SELECT → 비교 → UPDATE세 줄보다 안전하다. -
분산 락은 비관적 동시성 제어의 첫 번째 방어선이다. Redis
SET NX로 같은 사용자의 동시 차감을 막고, 원자적 SQL로 DB 레벨 일관성을 보장하는 이중 방어가 필요하다. -
연쇄 재화는 환불도 연쇄적으로. 행동력 부족 → 젬 소비 → 행동력 충전이 일어났다면, 환불 시에도 행동력 환불 + 젬 환불을 순차적으로 처리해야 한다.
gemCharged플래그가 이 판단의 근거다. -
환불 실패는 CRITICAL이다. 차감은 성공했는데 환불이 실패하면 사용자가 재화를 잃는다. 환불 실패 시 Sentry 알림 + 수동 개입 경로가 반드시 필요하다. 규모가 커지면 데드레터 큐 기반 자동 재시도로 발전시켜야 한다.
-
에러 이벤트에 환불 결과를 포함하라. 프론트엔드가 "환불됨" vs "환불 실패"를 구분해야 적절한 UX를 제공할 수 있다. 단순히 "오류가 발생했습니다"만 보여주면 사용자 불안은 해소되지 않는다.
-
비용 정책은 백엔드 상수가 단일 소스다. 프론트엔드 하드코딩은 정책 변경 시 배포 의존성을 만들고, 클라이언트-서버 불일치 버그를 유발한다. API로 정책을 제공하고, 프론트엔드는 캐시해서 쓴다.