블로그로 돌아가기

AI 파이프라인 실패 시 결제 안전성 설계: 선차감-환불 패턴과 멱등 트랜잭션

BillingNestJSSentryIdempotencyError Handling

도입

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/policiesGET /api/v1/gems/policies API로 정책을 조회하고, 1시간 캐시한다. 백엔드 상수만 수정하면 클라이언트 배포 없이 정책이 반영된다.

이 구조에서 재화 흐름은 연쇄적이다: 메시지 전송 → 행동력 차감 → (부족 시) 젬 자동 소비 → 행동력 충전 → AI 파이프라인 실행. 어느 단계에서든 실패하면, 이전 단계의 차감을 되돌려야 한다.


핵심 2: 선차감-환불 패턴

Plit의 메시지 전송 흐름은 "차감 먼저, 실패 시 환불" 패턴을 따른다.

선차감-환불 흐름도 — 행동력 차감에서 입력 필터, AI 파이프라인, 성공/실패 분기까지

왜 선차감인가? 후차감(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); // 락 해제
  }
}

핵심은 세 가지다.

  1. Redis NX 락SET lockKey 1 EX 5 NX로 5초 TTL 분산 락을 건다. 같은 사용자의 동시 요청은 429 TOO_MANY_REQUESTS로 차단된다.
  2. 원자적 SQLUPDATE ... WHERE balance >= $1 RETURNING balance로 잔액 확인과 차감을 단일 쿼리에서 처리한다. 앱 레벨의 read-then-write 레이스 컨디션이 불가능하다.
  3. 연쇄 차감 추적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() 호출 시 sessionIdreferenceId로 전달한다.

// 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의 역할은 두 가지다.

  1. 추적성 — 환불이 어떤 세션에서, 어떤 실패로 인해 발생했는지 젬 트랜잭션 내역에서 확인할 수 있다. 관리자 대시보드에서 비정상적인 환불 패턴(특정 세션에서 반복 환불 등)을 감지할 수 있다.
  2. 감사 로그consumerefund 쌍이 동일한 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를 분기한다.

codeenergyRefundedgemRefundedUI 동작
INSUFFICIENT_GEMS젬 충전 바텀시트 노출
INPUT_FILTER_BLOCKEDtruefalse"부적절한 내용" 토스트 + 행동력 복원 표시
INPUT_FILTER_BLOCKEDtruetrue"부적절한 내용" 토스트 + 행동력/젬 복원 표시
AI_GENERATION_FAILEDtruefalse재시도 버튼 + 행동력 복원 표시
AI_GENERATION_FAILEDtruetrue재시도 버튼 + 행동력/젬 복원 표시
AI_GENERATION_FAILEDfalsefalse일반 에러 토스트 (환불 실패 — 고객지원 안내)

마지막 행이 중요하다. 환불 자체가 실패한 경우(energyRefunded: false), 프론트엔드는 "잠시 후 다시 시도해주세요" 대신 고객지원 연결을 안내해야 한다. 동시에 Sentry 알림으로 운영팀이 수동 환불을 처리한다.


정리

AI 채팅 플랫폼에서 재화 안전성을 설계하며 적용한 핵심 판단을 정리한다.

  1. 선차감이 후차감보다 안전하다. 후차감은 "AI 성공 확인 → 차감" 사이의 레이스 컨디션을 막기 어렵다. 선차감 + 실패 시 환불이 더 단순하고 견고하다.

  2. 원자적 차감은 앱 레벨이 아니라 DB 레벨에서. UPDATE ... WHERE balance >= cost RETURNING balance 한 줄이 SELECT → 비교 → UPDATE 세 줄보다 안전하다.

  3. 분산 락은 비관적 동시성 제어의 첫 번째 방어선이다. Redis SET NX로 같은 사용자의 동시 차감을 막고, 원자적 SQL로 DB 레벨 일관성을 보장하는 이중 방어가 필요하다.

  4. 연쇄 재화는 환불도 연쇄적으로. 행동력 부족 → 젬 소비 → 행동력 충전이 일어났다면, 환불 시에도 행동력 환불 + 젬 환불을 순차적으로 처리해야 한다. gemCharged 플래그가 이 판단의 근거다.

  5. 환불 실패는 CRITICAL이다. 차감은 성공했는데 환불이 실패하면 사용자가 재화를 잃는다. 환불 실패 시 Sentry 알림 + 수동 개입 경로가 반드시 필요하다. 규모가 커지면 데드레터 큐 기반 자동 재시도로 발전시켜야 한다.

  6. 에러 이벤트에 환불 결과를 포함하라. 프론트엔드가 "환불됨" vs "환불 실패"를 구분해야 적절한 UX를 제공할 수 있다. 단순히 "오류가 발생했습니다"만 보여주면 사용자 불안은 해소되지 않는다.

  7. 비용 정책은 백엔드 상수가 단일 소스다. 프론트엔드 하드코딩은 정책 변경 시 배포 의존성을 만들고, 클라이언트-서버 불일치 버그를 유발한다. API로 정책을 제공하고, 프론트엔드는 캐시해서 쓴다.