재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)

회원 활동 동기 부여용 코인 지갑을 도입한 첫 머지. Prisma 단일 트랜잭션 안에서 잔액 변경과 거래 원장을 함께 기록하고, balanceAfter는 감사용으로 불변 처리한다. 도입 단계(스키마 + Domain Service + Debug API 4개 + 단위 테스트 18개)와 같은 dev 머지 사이클에 함께 들어간 노출 단계 Wallet API 3개(잔액 조회/입금/출금)의 결정·코드·트레이드오프를 정리한다. NestJS + Prisma 환경에서 통화 시스템을 첫 머지로 옮길 때의 스키마 분리, 응답 표준화, 옵셔널 멱등성, 클라이언트 신뢰 모델을 다룬다.


💡 Tip. 바쁜 현대인들을 위한 본문 요약

  • 회원 학습 동기 부여용 코인 지갑을 도입한 첫 머지 — 번들 완료·신기록·출석·레벨 업 등 활동 보상의 토대
  • 단일 Prisma 트랜잭션 안에서 잔액 변경 + 거래 원장 생성을 함께 처리balanceAfter는 거래 시점 스냅샷으로 불변(감사용)
  • CurrencyType { COIN, GEM } enum은 둘 다 정의, 운용은 COIN만 — GEM은 스키마 확장성 확보용, YAGNI보다 마이그레이션 비용을 더 두려워한 결정
  • InsufficientBalanceException은 응답에 currentBalancerequired를 함께 실어 클라이언트가 즉시 잔액을 동기화 — 한 번의 실패가 한 번의 자동 보정으로 이어진다
  • 도입 단계(스키마 + CurrencyService + Debug API 4개) + 노출 단계 회원 Wallet API 3개를 같은 dev 머지 사이클에 묶었다 — 21:48 → 22:25, 37분 간격 두 머지
  • EXP 시스템은 본 머지에서 의도적으로 분리 — 재화(코인) 원장과 경험치 누적은 정책·연출 시점이 달라 약 1달 뒤 게임 이코노미 마일스톤으로 독립 분리

🎯 배경 — 보상 정책을 코드 한 곳에서 결정해야 하는 시점

추천 콘텐츠 알고리즘 최적화(이전 편)가 끝나면서 다음 머지 사이클의 후보가 두 갈래로 갈렸다. 하나는 학부모 리포트 5탭 설계, 다른 하나는 코인 지갑 도입이다. 둘 중 통화 시스템을 먼저 잡은 이유는 단순했다 — 활동 보상의 적용 지점을 코드 한 곳에서 결정해야 다음 기능들이 그 위에 올라설 수 있기 때문이다. 번들 완료 보상이 결정되지 않으면 배치고사 완료 보상도, 출석 보상도, 신기록 보상도 각자 따로 보상 로직을 들고 가게 된다.

명세서(docs/changes/2026-01-28_재화_시스템_구현.md)는 도입 시점에 두 단계로 쪼개졌다. 도입 단계는 BE 핵심 — 스키마, Domain Service, Debug API만. 노출 단계는 회원 Wallet API + Unity 연동. 같은 명세서 안에 두 단계가 함께 있었지만, BE 머지는 둘 다 같은 dev 사이클에 들어갔다. 도입 단계는 통화 시스템의 토대를, 노출 단계 BE는 그 토대 위의 API 노출 면을 깐다. 같은 머지 사이클에 두 단계 BE를 묶은 이유는 다음 작업(Unity 연동)에서 BE 면이 완성되어 있어야 클라이언트 작업이 막힘없이 따라올 수 있다는 단순한 결정이었다.

📌 핵심: 보상이 산발적으로 흩어지기 전에 “보상은 단일 거래 원장에 기록한다”는 한 줄을 코드로 옮겨두면, 이후 기능들이 그 단일 진입점을 같이 쓰게 된다. 반대로 보상마다 각자 카운터를 들고 가면, 회수·환불·집계가 매번 새 코드가 된다.


⚖️ 설계 결정 6건 — 무엇을 코드로 박고, 무엇을 미뤘나

본 머지에서 명시한 결정 6건을 먼저 정리한다. 본문은 이 표의 결정 순서대로 스키마·서비스·DTO·예외 처리 코드를 따라간다.

#결정채택 사유트레이드오프
1CurrencyType { COIN, GEM } enum 정의 — 운용은 COIN만향후 프리미엄 재화 도입 시 스키마 마이그레이션 비용을 회피GEM 관련 코드가 한 줄도 안 돌아도 enum 값으로 고정된다. 운영 단에서 GEM 잔액 0으로 노출되는 응답이 영구히 남음
2단일 Prisma 트랜잭션 + balanceAfter 스냅샷 불변잔액 변경과 원장 기록을 함께 묶지 않으면 한쪽 실패 시 잔액 정합성 깨짐. balanceAfter는 감사 추적용 — 사후 수정 절대 불가거래 N개에서 잔액 재계산이 필요할 때마다 원장을 전수 스캔해야 함. 누적 추적은 빠르지만 임의 시점 잔액 재구성은 비용이 든다
3잔액 음수 불가 — InsufficientBalanceException + 응답에 currentBalance + required 동봉한 번의 출금 실패가 클라이언트의 잔액 캐시를 자동 동기화하는 신호로 작동에러 응답 페이로드가 단순 400 Bad Request보다 무거워짐. 클라이언트가 두 필드를 정확히 읽어야 자동 동기화가 이루어진다
4referenceType + referenceId 옵셔널 멱등성 — DUPLICATE_TRANSACTION 409번들 완료 같은 보상은 같은 번들에서 두 번 들어오면 안 됨. 강제하지 않은 이유는 디버그·관리자 지급처럼 참조가 없는 거래가 정상적으로 존재하기 때문운영 단에서 호출 측이 두 필드를 빠뜨리면 멱등성이 끊긴다. 강제로 안 만든 비용은 클라이언트 측 누락 가능성으로 옮겨간다
5금액 검증은 클라이언트 신뢰 — BE는 양수만 체크보상 정책을 BE에 박으면 정책 변경마다 배포가 필요. 정책은 클라이언트 설정에서 결정, BE는 원장만 책임클라이언트 위·변조 시 임의 금액 입금 가능. 본 단계에서는 디버그 API + 관리자 그랜트로 회수 경로를 열어둠
6EXP 시스템은 본 머지에서 분리 — 약 1달 뒤 게임 이코노미 마일스톤으로 독립코인은 출금이 있는 차감형 잔액, EXP는 누적만 되는 단조형 카운터. 정책·UI 연출 시점이 달라 같은 원장에 묶으면 제약이 많아짐게임 이코노미 도입 시 EXP 원장을 별도 모델로 또 만들어야 함. currency_transactions 테이블에 EXP를 추가했다면 한 번에 끝낼 수 있던 일을 두 번에 나눠 한 셈

직접 정리한 재화 시스템 도입 두 머지·스키마·엔드포인트 매트릭스 도식
직접 정리한 재화 시스템 도입 두 머지·스키마·엔드포인트 매트릭스 도식

결정 6이 본 마일스톤의 단일 절제선이다. 명세 초안 시점에서는 “재화·EXP 통합 원장”이 함께 검토되었지만, 출금 가능 잔액과 누적 카운터는 비즈니스 룰이 다르다는 점이 결정의 근거였다. 출금 가능 잔액(코인)은 InsufficientBalanceException으로 막아야 하고, 누적 카운터(EXP)는 음수가 될 일이 없고 레벨 업 트리거가 자체적으로 발생한다. 둘을 같은 enum과 같은 원장으로 묶으면 reason: EXP_GAIN이 들어왔을 때 출금 코드 경로가 의미가 없어지고, 잔액 음수 체크도 EXP에는 무의미한 분기가 된다. 분리하는 비용은 한 번의 추가 마이그레이션이지만, 같은 원장에 묶었을 때의 비용은 모든 코드 경로에 분기가 늘어나는 것이다.

⚠️ 주의: 통화 시스템 초기 설계에서 가장 흔한 함정은 “모든 보상은 같은 원장”으로 묶고 싶은 욕망이다. 그러나 잔액형(차감 가능)과 누적형(단조 증가) 카운터는 SQL 단의 분기뿐 아니라 응답 DTO·예외 클래스·테스트 시나리오가 따로 가야 한다. 한 모델에 두 의미를 담으면 향후 정책 변경이 항상 두 곳의 분기를 동시에 건드리게 된다.

prisma.io

🛠️ 구현 1 — 스키마 분리: 지갑(Wallet)과 원장(Transaction)의 1:N

핵심은 두 모델이다. StudentWallet은 회원당 재화 타입별 1개로 잔액의 현재 상태를 가진다. CurrencyTransaction은 그 지갑에 들어오고 나간 모든 거래의 영구 기록이다. 둘은 1:N 관계로 묶이고, 거래 기록의 balanceAfter는 거래 시점의 잔액 스냅샷으로 사후 수정이 불가능하다.

// apps/api/prisma/schema.prisma (커밋 dbfd4011, +86줄)

enum CurrencyType {
  COIN // 기본 재화 (일일 과제, 신기록 등)
  GEM  // 프리미엄 재화 (추후 확장)
}

enum TransactionType {
  DEPOSIT  // 입금 (보상 지급)
  WITHDRAW // 출금 (아이템 구매)
}

enum TransactionReason {
  BUNDLE_COMPLETE         // 번들 완료
  NEW_RECORD              // 신기록 달성
  ATTENDANCE_BONUS        // 출석 보너스
  CONSECUTIVE_ATTENDANCE  // 연속 출석 보너스
  LEVEL_UP                // 레벨 업
  DIAGNOSTIC_COMPLETE     // 배치고사 완료 (도메인 enum 값)
  ITEM_PURCHASE           // 아이템 구매
  AVATAR_PURCHASE         // 아바타 구매
  ADMIN_GRANT             // 관리자 지급
  ADMIN_REVOKE            // 관리자 회수
  SYSTEM_ADJUSTMENT       // 시스템 조정
  DEBUG                   // 디버그/테스트용
  UNKNOWN                 // 알 수 없음
}

model StudentWallet {
  id        String       @id @default(cuid())
  studentId String
  currency  CurrencyType
  balance   Int          @default(0)

  createdAt DateTime @db.Timestamptz @default(now())
  updatedAt DateTime @db.Timestamptz @updatedAt

  student      Student               @relation(fields: [studentId], references: [id], onDelete: Cascade)
  transactions CurrencyTransaction[]

  @@unique([studentId, currency])  // 회원 + 재화 타입 조합으로 1개
  @@index([studentId])
  @@map("student_wallets")
}

model CurrencyTransaction {
  id       BigInt @id @default(autoincrement())  // BigInt — 대량 데이터 대비
  walletId String

  type          TransactionType
  amount        Int                // 거래 금액 (항상 양수)
  balanceAfter  Int                // 거래 후 잔액 (스냅샷, 불변)

  reason        TransactionReason
  referenceType String?            // 참조 엔티티 타입 (선택)
  referenceId   String?            // 참조 엔티티 ID (선택)
  description   String?

  createdAt DateTime @db.Timestamptz @default(now())

  wallet StudentWallet @relation(fields: [walletId], references: [id], onDelete: Cascade)

  @@index([walletId, createdAt])  // 거래 내역 페이지네이션 최적화
  @@index([reason])               // 사유별 집계 최적화
  @@map("currency_transactions")
}

세 가지 설계 결정이 스키마 단에 박혀 있다.

  1. @@unique([studentId, currency]) — 회원이 같은 재화 타입의 지갑을 두 개 가질 수 없다. 향후 지갑 분할(이벤트 지갑, 일반 지갑)이 필요해지면 currency를 string으로 푸는 게 아니라 별도 WalletPurpose 같은 enum을 추가해야 한다.
  2. balanceAfter 컬럼 명시 — 거래 시점의 잔액을 스냅샷으로 함께 기록한다. 사후에 잔액을 재계산해야 한다면 SUM(amount * sign) 누적이 아니라 가장 최근 거래의 balanceAfter를 직접 읽으면 끝난다. 거래 N개에서 잔액 N번 계산하는 비용을 피하기 위한 결정이다.
  3. BigInt id for CurrencyTransaction — 운영 1년에 회원 1만 명 × 평일 1건 보상 ≈ 2백만 건/년이다. 5년 누적이면 1천만 건. Int(약 21억) 한계를 넘지는 않지만, 통화 원장은 정책상 영구 보관 — BigInt로 지정해 두면 누적 한도를 생각할 일이 사라진다.

공식 문서 인용으로 정합성 근거를 다진다. Prisma의 트랜잭션 문서에 따르면, $transaction(fn) 안의 모든 쿼리는 단일 데이터베이스 트랜잭션 안에서 실행되며 어느 하나가 실패하면 전체가 롤백된다. 본 머지의 잔액 변경 + 원장 생성은 이 보장 위에 올라간다.

prisma.io

🛠️ 구현 2 — Domain Service: 단일 트랜잭션 안의 입금

CurrencyService는 지갑·원장 두 모델 위에 7개 메서드를 노출한다(getOrCreateWallet, getBalance, deposit, withdraw, getTransactions, resetWallet, getWallets). 핵심은 deposit/withdraw가 단일 $transaction 콜백 안에서 잔액 변경과 원장 생성을 동시에 처리하는 패턴이다.

// apps/api/src/domain/services/currency.service.ts (커밋 dbfd4011, 326줄 신규)

@Injectable()
export class CurrencyService {
  private readonly logger = new Logger(CurrencyService.name);
  constructor(private readonly prisma: PrismaService) {}

  async deposit(input: CurrencyTransactionInput): Promise<CurrencyTransactionResult> {
    return this.prisma.$transaction((tx) => this.depositWithTx(tx, input));
  }

  // 외부 트랜잭션 클라이언트를 주입받는 버전 — 상위 서비스가 다른 작업과 같은 tx에 묶고 싶을 때 사용
  async depositWithTx(
    tx: PrismaTransactionClient,
    input: CurrencyTransactionInput,
  ): Promise<CurrencyTransactionResult> {
    const { studentId, currency, amount, reason, referenceType, referenceId, description } = input;

    if (amount <= 0) {
      throw new Error(`Deposit amount must be positive: ${amount}`);
    }

    // 1. 지갑 조회/생성 + 잔액 증가 — upsert 한 번으로 처리
    const wallet = await tx.studentWallet.upsert({
      where: { studentId_currency: { studentId, currency } },
      create: { studentId, currency, balance: amount },
      update: { balance: { increment: amount } },
    });

    // 2. 거래 원장 기록 — balanceAfter는 upsert 결과의 balance를 스냅샷
    const transaction = await tx.currencyTransaction.create({
      data: {
        walletId: wallet.id,
        type: TransactionType.DEPOSIT,
        amount,
        balanceAfter: wallet.balance,
        reason,
        referenceType,
        referenceId,
        description,
      },
    });

    return { wallet, transaction };
  }
}

두 가지 패턴을 짚어둔다.

첫째, upsert 단일 호출로 지갑 생성과 잔액 증가를 동시에 처리한다. 신규 회원의 첫 보상이 들어올 때는 create 분기로 balance: amount로 지갑이 생성되고, 기존 회원이면 update 분기로 balance: { increment: amount }가 적용된다. 별도의 “지갑 있나?” 조회 + “없으면 만들기” 분기가 들어가지 않는다. NestJS 인터셉터 단에서 동시 요청이 두 개 들어와도 PostgreSQL의 unique 제약(studentId_currency)이 race를 막아준다.

둘째, depositWithTx가 외부 트랜잭션 클라이언트를 주입받는다. 향후 번들 완료 처리 같은 상위 서비스가 “번들 점수 업데이트 + 보상 입금”을 같은 tx에 묶고 싶어질 때, currencyService.depositWithTx(tx, input)로 호출하면 상위 트랜잭션의 일부가 된다. 도입 단계에서는 이 진입점만 열어두고 실제 호출은 다음 머지(자동 보상 자동화)로 미뤘다.

출금 코드는 입금과 거의 대칭이지만 잔액 체크 단계가 추가된다.

async withdrawWithTx(
  tx: PrismaTransactionClient,
  input: CurrencyTransactionInput,
): Promise<CurrencyTransactionResult> {
  const { studentId, currency, amount, reason, referenceType, referenceId, description } = input;

  if (amount <= 0) {
    throw new Error(`Withdraw amount must be positive: ${amount}`);
  }

  // 1. 지갑 조회
  let wallet = await tx.studentWallet.findUnique({
    where: { studentId_currency: { studentId, currency } },
  });

  if (!wallet) {
    throw new InsufficientBalanceException(studentId, currency, 0, amount);
  }

  // 2. 잔액 체크
  if (wallet.balance < amount) {
    throw new InsufficientBalanceException(studentId, currency, wallet.balance, amount);
  }

  // 3. 잔액 차감
  wallet = await tx.studentWallet.update({
    where: { id: wallet.id },
    data: { balance: { decrement: amount } },
  });

  // 4. 거래 원장 기록
  const transaction = await tx.currencyTransaction.create({
    data: {
      walletId: wallet.id,
      type: TransactionType.WITHDRAW,
      amount,
      balanceAfter: wallet.balance,
      reason,
      referenceType,
      referenceId,
      description,
    },
  });

  return { wallet, transaction };
}

InsufficientBalanceExceptionstudentId, currency, currentBalance, requestedAmount 네 필드를 들고 다닌다. 응용 계층에서 이 예외를 잡아 HTTP 응답으로 변환할 때 currentBalance를 응답 본문에 그대로 실어준다. 이 동봉이 결정 3의 핵심이다 — 클라이언트가 응답을 받으면 별도 잔액 조회 API를 또 호출할 필요 없이 응답 시점에 바로 캐시를 갱신한다.

🔍 단서: 출금 실패 응답에 현재 잔액을 함께 싣는 패턴은 모바일 클라이언트의 잔액 캐시 정합성 문제를 한 번에 푼다. 출금 실패의 원인은 보통 “클라이언트 캐시가 실제 잔액보다 높다”인데, 응답에 실제 잔액이 들어 있으면 한 번의 실패가 한 번의 보정으로 끝난다. 별도 동기화 라운드 트립이 사라진다.


🛠️ 구현 3 — Student Wallet API와 옵셔널 멱등성

도입 단계 머지(dbfd4011)가 21:48에 들어가고 37분 뒤인 22:25에 두 번째 머지(efeb20c4 노출 단계 회원 Wallet API)가 같은 dev 사이클에 들어간다. 두 머지를 하나로 묶지 않은 이유는 단순했다 — 첫 머지는 Domain Service와 Debug API까지로 폐쇄 테스트가 끝나고, 두 번째 머지는 회원에게 노출되는 API 면이다. 폐쇄 테스트(Debug API)가 통과한 도메인 계층 위에 노출 API를 별도 머지로 올리면, 회귀가 났을 때 어느 머지를 되돌릴지가 명확해진다.

// apps/api/src/application/controllers/student-wallet.controller.ts (커밋 efeb20c4, 163줄 신규)

@ApiTags('student/wallet')
@ApiBearerAuth('JWT')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('STUDENT')
@Controller('student/wallet')
export class StudentWalletController {
  constructor(
    private readonly walletService: StudentWalletApplicationService,
    private readonly authService: StudentAuthApplicationService,
  ) {}

  // GET /api/v1/student/wallet/balance — COIN, GEM 잔액
  @Get('balance')
  async getBalance(@Headers('authorization') authHeader: string): Promise<WalletBalanceResponseDto> {
    const { studentId } = this.extractStudentId(authHeader);
    return this.walletService.getBalance(studentId);
  }

  // POST /api/v1/student/wallet/deposit — 입금 + 중복 체크 (409)
  @Post('deposit')
  @HttpCode(HttpStatus.OK)
  async deposit(
    @Headers('authorization') authHeader: string,
    @Body() dto: DepositRequestDto,
  ): Promise<DepositSuccessResponseDto> {
    const { studentId } = this.extractStudentId(authHeader);
    return this.walletService.deposit(
      studentId, dto.currency, dto.amount, dto.reason, dto.referenceType, dto.referenceId,
    );
  }

  // POST /api/v1/student/wallet/withdraw — 출금 + 잔액 부족 응답 (400)
  @Post('withdraw')
  @HttpCode(HttpStatus.OK)
  async withdraw(
    @Headers('authorization') authHeader: string,
    @Body() dto: WithdrawRequestDto,
  ): Promise<WithdrawSuccessResponseDto> {
    const { studentId } = this.extractStudentId(authHeader);
    return this.walletService.withdraw(
      studentId, dto.currency, dto.amount, dto.reason, dto.referenceType, dto.referenceId,
    );
  }
}

응용 서비스 단에서 멱등성 체크와 예외 변환을 모두 처리한다. 도메인 서비스는 순수 비즈니스 룰(잔액 음수 불가)만 알고, HTTP 응답 코드 매핑(409/400)은 응용 계층의 책임이다.

// apps/api/src/application/services/student-wallet.application.service.ts (커밋 efeb20c4, 194줄 신규)

async deposit(
  studentId: string, currency: CurrencyType, amount: number, reason: TransactionReason,
  referenceType?: string, referenceId?: string,
): Promise<DepositSuccessResponseDto> {
  // 옵셔널 멱등성 체크 — referenceType + referenceId가 둘 다 있을 때만 작동
  if (referenceType && referenceId) {
    const existingTransaction = await this.findExistingTransaction(
      studentId, currency, referenceType, referenceId,
    );

    if (existingTransaction) {
      const currentBalance = await this.currencyService.getBalance(studentId, currency);
      throw new ConflictException({
        error: 'DUPLICATE_TRANSACTION',
        message: '이미 처리된 거래입니다',
        currentBalance,  // 중복 응답에도 잔액 동봉 — 클라이언트 캐시 동기화
      });
    }
  }

  const balanceBefore = await this.currencyService.getBalance(studentId, currency);
  const result = await this.currencyService.deposit({ studentId, currency, amount, reason, referenceType, referenceId });
  return {
    transactionId: String(result.transaction.id),
    balanceBefore,
    balanceAfter: result.wallet.balance,
  };
}

async withdraw(...): Promise<WithdrawSuccessResponseDto> {
  try {
    const result = await this.currencyService.withdraw({...});
    return { transactionId: String(result.transaction.id), balanceBefore, balanceAfter: result.wallet.balance };
  } catch (error) {
    if (error instanceof InsufficientBalanceException) {
      // 400 응답에 currentBalance + required 동봉 — 한 번의 실패가 한 번의 보정으로
      throw new BadRequestException({
        error: 'INSUFFICIENT_BALANCE',
        message: '잔액이 부족합니다',
        currentBalance: error.currentBalance,
        required: error.requestedAmount,
      });
    }
    throw error;
  }
}

멱등성 체크가 옵셔널인 이유는 본 머지에서 명시적으로 결정한 사항이다(결정 4). 보상 입금은 같은 Bundle:bundle-xxx 두 번 들어오면 안 되지만, 관리자 그랜트(ADMIN_GRANT)는 같은 사유로 두 번 들어와도 정상이다. 강제하면 관리자 지급 UI가 매번 임의 ID를 생성해야 하는 부담이 생긴다. 옵셔널로 둔 대가는 클라이언트가 두 필드를 누락하면 멱등성이 끊긴다는 점이다 — 본 단계에서는 Unity 측 WalletManager가 항상 두 필드를 채우도록 약속한 다음 머지로 책임을 넘겼다.

📌 핵심: HTTP 응답 코드와 페이로드는 클라이언트의 다음 행동을 결정하는 신호다. 409 + currentBalance 동봉은 “중복이지만 잔액은 이게 맞으니 동기화해라”를, 400 + currentBalance + required 동봉은 “출금 실패 + 부족분을 같이 알려준다”를 같은 응답 형태로 표준화한다. 두 응답은 모두 “한 번의 실패가 한 번의 자동 동기화”라는 같은 규약을 따른다.

docs.nestjs.com

🛠️ 구현 4 — Debug API: 폐쇄 테스트의 진입점

도입 단계 머지에 함께 들어간 Debug API 4개는 운영 단의 보상 회수·테스트 시드 정리·QA 검증에 쓰인다. PLATFORM_ADMIN 권한 필수, loginId 쿼리 파라미터로 대상 회원을 지정한다. 회원 노출 API가 열리기 전 폐쇄 테스트 진입점으로 먼저 들어간 면이다.

메서드엔드포인트용도
GET/debug/student/{id}/wallet?loginId=XXX회원 지갑 조회
GET/debug/student/{id}/wallet/transactions?loginId=XXX거래 원장 페이지네이션 조회
POST/debug/student/{id}/wallet/grant?loginId=XXX임의 금액 지급(reason: ADMIN_GRANT/DEBUG)
POST/debug/student/{id}/wallet/reset?loginId=XXX지갑 + 모든 거래 원장 삭제

reset 엔드포인트는 단일 트랜잭션 안에서 거래 원장을 deleteMany한 다음 지갑 잔액을 0으로 만든다. 운영 환경에는 절대 들어가서는 안 되는 API라 PLATFORM_ADMIN 권한 + DEBUG_API_ENABLED 환경 변수 게이트로 이중 차단된다. 단위 테스트 18개 중 4개가 이 디버그 경로의 시나리오(지갑 없음/거래 N개 삭제/잔액 0 초기화/연쇄 호출 idempotency)다.


📊 결과 — 두 머지 37분, 13개 파일 +1,806줄

$ git log --oneline --stat 22:00..23:00 -- 'apps/api/src/**/*currency*' 'apps/api/src/**/*wallet*'
21:48  dbfd4011  도입 단계 머지 — 도메인 서비스 + 디버그 API
       8 files changed, 1,281 insertions(+), 4 deletions(-)
22:25  efeb20c4  노출 단계 머지 — 회원 Wallet API + 응답 표준
       4 files changed, 525 insertions(+)

같은 dev 머지 사이클 37분 안에 두 머지가 들어갔다. 합산 13개 파일 +1,806줄 -4줄. 회수 가능한 단위로 쪼개진 두 머지의 책임 분리는 다음과 같다.

머지시각파일라인노출 면
dbfd401121:48schema.prisma(+86) / currency.service.ts(326) / spec(364) / debug.controller(+124) / debug.service(170) / debug-currency.dto(205) / debug.module(+3) / domain.module(+7)+1,281 / -4도메인 계층 + 폐쇄 테스트(Debug API)
efeb20c422:25application.module(+4) / student-wallet.controller(163) / student-wallet.dto(164) / student-wallet.application.service(194)+525회원 노출 API + Swagger

검증 단의 핵심 지표는 단위 테스트 18개다. CurrencyService 7개 메서드 전수 케이스 + 잔액 음수·신규 지갑·중복 호출·limit/offset/reason 필터 시나리오까지 포함된다. 통합 테스트는 본 머지 범위 밖 — 다음 머지에서 Unity 연동과 함께 e2e로 묶었다.

$ pnpm jest currency.service.spec.ts
 PASS  src/domain/services/currency.service.spec.ts
  CurrencyService
    getOrCreateWallet
      ✓ should return existing wallet
      ✓ should create new wallet if not exists
    getBalance
      ✓ should return wallet balance
      ✓ should return 0 if wallet not exists
    deposit
      ✓ should deposit and return updated wallet
      ✓ should throw error for non-positive amount
    withdraw
      ✓ should withdraw and return updated wallet
      ✓ should throw InsufficientBalanceException if balance is not enough
      ✓ should throw InsufficientBalanceException if wallet not exists
      ✓ should throw error for non-positive amount
    getTransactions
      ✓ should return transactions for wallet
      ✓ should return empty array if wallet not exists
      ✓ should apply limit and offset
      ✓ should filter by reason
    resetWallet
      ✓ should delete transactions and reset balance
      ... (총 18 cases)

Tests:       18 passed, 18 total

prisma-select-checkervalidate-api-contract 통과(세션 아카이브 2026-01-28_2330_currency_phase1_complete.md 인용). Swagger 문서 7개 엔드포인트(Debug 4 + 회원 3) 응답 스키마 검증까지 같은 dev 사이클에 완료됐다.


🔄 회고 — EXP 분리는 옳았나, 옵셔널 멱등성은 강제했어야 했나

본 머지의 결정 중 약 1달 뒤 재검토가 필요했던 부분 두 가지가 있다.

첫째, EXP 분리(결정 6)는 사후적으로 옳았다. 약 1달 뒤(2026-02-25) 게임 이코노미 도입 머지에서 EXP는 StudentExpHistory라는 별도 모델로 들어갔고, 자동 보상 큐(PendingReward) + 일일 보상 캡 + 레벨 업 트리거가 함께 따라왔다. 만약 본 머지에서 EXP를 currency_transactions에 같은 enum 값으로 집어넣었다면, 게임 이코노미 시점에 이 모든 보강을 두 모델(코인 원장과 EXP 카운터)에 동시에 적용해야 했을 것이다. 잔액 차감이 있는 코인과 누적만 되는 EXP는 SQL 단의 분기가 끝까지 갈라진다 — 분리한 비용보다 묶었을 때의 비용이 컸다.

둘째, referenceType + referenceId 옵셔널 멱등성(결정 4)은 강제로 갔어야 했다. 노출 단계 Unity 연동 시 BUNDLE_COMPLETE 보상의 중복 호출 사례가 운영 초기 몇 건 발견됐다. 클라이언트의 재시도 로직이 결제 응답 누락 시 같은 요청을 재발사하면서 두 번 입금되는 케이스였다. 본 머지에서 BUNDLE_COMPLETE처럼 명시적으로 게임 객체에 연결된 보상 사유는 두 필드를 필수로 강제했다면, 클라이언트 측 누락이 컴파일·런타임 양쪽에서 차단됐을 것이다. 옵셔널로 두 필드를 푼 대가는 운영 단의 사후 보정(중복 회수 + 보상 정책 수정)으로 옮겨갔다.

셋째, 금액 검증 클라이언트 신뢰(결정 5)는 게임 이코노미 마일스톤에서 정책 테이블로 옮겼다. 본 머지 시점에는 보상 금액(번들 완료 +10, 신기록 +20 등)이 명세서 안의 표로만 존재했지만, 1달 뒤 RewardPolicy 모델이 신설되면서 정책은 DB에 들어갔고 클라이언트는 정책 ID만 보낸다. 본 머지의 “BE는 양수만 체크”는 도입 단계에서는 합리적이었지만, 운영 정책이 확장되면서 클라이언트 신뢰 모델은 한 단계 더 보강이 필요했다.

본 머지의 단일 절제선(잔액 변경 + 원장 기록의 단일 트랜잭션, balanceAfter 스냅샷 불변)은 사후 1년 누적 데이터에서도 회수·집계·감사 추적의 토대로 그대로 살아남았다. 운영 1년 시점 회원 잔액 재구성은 모두 가장 최근 거래의 balanceAfter를 직접 읽는 단순 쿼리 한 줄로 끝났고, 회수 보정도 withdraw(reason: ADMIN_REVOKE) 단일 경로로 통일됐다.

💡 인사이트: 통화 시스템 초기 머지의 가치는 “지금 잔액이 얼마인가”가 아니라 “1년 뒤 어떤 잔액이든 한 줄로 재구성할 수 있는가”에 있다. 거래 원장의 balanceAfter 컬럼 하나가 1년 뒤 회수·환불·감사·집계 비용을 모두 결정한다.


📋 정리 — 결정 표와 다음 편

#결정채택1달 후 평가
1CurrencyType enum에 COIN + GEM 모두 정의, 운용은 COIN만게임 이코노미에서 GEM은 여전히 미운용, 그러나 스키마는 그대로 — 마이그레이션 비용 회피 성공
2단일 $transaction + balanceAfter 스냅샷 불변1년 누적 회수·감사·집계의 토대로 그대로 살아남음
3InsufficientBalanceExceptioncurrentBalance + required 동봉노출 단계 Unity 연동에서 한 번의 실패가 한 번의 보정으로 작동 — 별도 동기화 라운드 트립 없음
4referenceType + referenceId 옵셔널 멱등성⚠️BUNDLE_COMPLETE류는 강제로 갔어야 함 — 노출 단계 운영 초기 중복 보상 사후 보정 발생
5금액 검증 클라이언트 신뢰 — BE는 양수만 체크⚠️약 1달 뒤 게임 이코노미 마일스톤에서 RewardPolicy 모델로 정책을 DB에 옮김
6EXP 시스템은 본 머지에서 분리 — 별도 모델로 약 1달 뒤 도입사후적으로 옳았음. 잔액형(코인)과 누적형(EXP)은 SQL 분기·DTO·테스트가 끝까지 갈라짐

다음 편(devlog-53)에서는 같은 dev 머지 사이클 직후 시작된 학부모/회원 리포트 5탭 설계 — 개요·활동·지표·레벨·상세 탭의 응답 표준 결정과 N+1 회피 패턴을 정리한다.

📚 NestJS + Refine 풀스택 트러블슈팅 시리즈 (54편)

  1. 1. 왜 NestJS + Prisma를 선택했나 — B2B SaaS 백엔드 기술 선택기
  2. 2. 도메인 모델링 첫날 — B2B SaaS의 핵심 엔티티 정의하기
  3. 3. 27개 테이블의 탄생 — Prisma 스키마 설계기
  4. 4. 권한 매트릭스 — Admin/운영자/사용자 3역할 설계
  5. 5. BigInt PK에서 Int PK로 — 첫 번째 스키마 리팩토링
  6. 6. Seed 데이터의 함정 — FK 삭제 순서 삽질기
  7. 7. DDD를 도입하기로 했다 — Repository/Domain/Application 3계층
  8. 8. 인터페이스 구현체로 바꾸는 날 — NestJS DI와 TypeScript의 간극
  9. 9. 단위 테스트 인프라 구축 — Jest 설정부터 Mock까지
  10. 10. E2E 테스트와 Cloud SQL의 고난 — 4/8 passing에서 8/8까지
  11. 11. REST API 첫 구현 — 6개 Controller, 21개 엔드포인트 완성
  12. 12. v1.0 완성, 그리고 갈아엎기로 결심한 날
  13. 13. 번들 구조를 통째로 바꿔야 했던 이유
  14. 14. Phase 1 문서 정비 — Use Case를 번들 기반으로 다시 쓰다
  15. 15. Phase 2 스키마 마이그레이션 — 데이터 안 날리고 구조 바꾸기
  16. 16. Phase 3-1·3-2 — Repository와 Domain 서비스로 36개 빌드 에러 잡기
  17. 17. Phase 3-3·3-4·3-5 — Application부터 Module까지, v2.0 마이그레이션 닫는 날
  18. 18. 코드를 박은 다음 날 — 4,658줄 DDD 문서를 24분 사이에 다시 쓴 하루
  19. 19. v2.1 Domain Layer — 도메인 서비스 1,682줄을 한 커밋에 박은 날의 설계 철학
  20. 20. v3.0 Application Layer 재작성 — 도메인 서비스 위에 얇은 막을 한 Phase에 박은 날
  21. 21. 갈아엎고 80일 — v2.0 마이그레이션 8편 메타 회고
  22. 22. 1인 다역으로 5일 만에 90% — Admin Portal MVP를 끌어올린 토글 한 줄
  23. 23. Mock에선 되던 게 REST에선 안 됐다 — 응답 포맷 한 칸 차이가 만든 하루
  24. 24. CORS는 됐다 — PATCH만 빼고. allowedHeaders 한 줄과 Vite 프록시의 소문자 메서드
  25. 25. 멀티테넌트 누수 — tenantId 3계층 강제
  26. 26. Prisma 정책 싱글톤 — zod superRefine 임계값 가드
  27. 27. 멀티테넌트 쓰기 가드 — body.tenantId 차단과 집계 일관성
  28. 28. 두 번째 점검은 합류 지점이었다 — Admin Portal 2차에서 한 사이클에 잡힌 FE-BE 연동 버그 11건
  29. 29. Prisma 그래프 스키마 — 선형 레벨을 DAG로 옮긴 4가지 결정
  30. 30. 교육과정 구조 리팩토링 — 3필드 분리와 폴백 결정기
  31. 31. 배치고사 MVP — 자동 레벨 배치를 걷어내고 5지표 측정만 남기다
  32. 32. JWT Guard 적용 — request.user undefined부터 jwt malformed까지
  33. 33. 디버깅용 운영 API 7개 — Unity 만료 테스트 30분 대기를 0초로
  34. 34. NestJS Swagger 일괄 적용 — 35개 컨트롤러 + DTO 22개
  35. 35. Unity ↔ 웹 PostMessage 브릿지 설계기
  36. 36. Vuplex 브릿지 초기화 타이밍 — 첫 메시지가 증발한 이유
  37. 37. 콘텐츠 브릿지 10종 통합 완료 — 같은 규격으로 묶기
  38. 38. 지표 누계 시스템 — TOP5 순위를 INSERT 전용 스냅샷으로 굳히기
  39. 39. 킥오프 배치 첫 구현 — 매시 전체 EXPIRED 사고와 Winston 도입
  40. 40. 혼자 여러 역할로 QA 1차 — 브랜치 미동기화와 잔존 토큰의 함정
  41. 41. 타이머가 NaN:NaN으로 떴다 — Bundle API 응답 누락 필드와 비어 있는 콘텐츠 후보
  42. 42. 1인 개발 QA 5라운드 — 타이머·시드·스키마로 옮긴 버그들
  43. 43. Unity Lobby + 배치고사 씬 통합 — 두 클라이언트가 같은 회원을 보는 첫 빌드
  44. 44. 배치고사 MVP 후속 — 명세를 코드로 옮기고 레거시 571줄을 일괄 삭제하다
  45. 45. Problem 종속 끊기 — 1,891개 마이그레이션과 단위 테스트 38건
  46. 46. NestJS 권한 가드 — 목록은 막고 상세는 뚫린 날
  47. 47. 콘텐츠 후보 선택 3차 최적화 — 단일 쿼리로 옮기기
  48. 48. 재화 시스템 첫 머지 — 코인 지갑과 거래 원장(Wallet API)
  49. 49. 회원 레포트 5탭 API 설계 — 인사이트 3파트 구조
  50. 50. 보호자 외부 뷰어 대시보드 — 모바일 앱·초대 토큰 회원가입
  51. 51. 외부 뷰어 리포트 v1→v2 토큰 전환 — 가장 길었던 하루
  52. 52. 외부 뷰어 리포트 인사이트 — 활동 데이터를 자연어로 바꾸기
  53. 53. Framer Motion whileInView — 일부 카드만 안 뜨던 날
  54. 54. 외부 뷰어 리포트 4탭 N+1 — 14초 응답을 2초로