Files
sam-docs/plans/usage-react-request.md
김보곤 f0dfaec1f7 docs: [plans] 이용현황 요청서 프론트엔드 피드백 6건 반영
- 파일 경로에서 react/ 접두사 제거
- SubscriptionManagement.tsx + SubscriptionClient.tsx 모두 수정 대상 명시
- 모든 URL을 buildApiUrl() 사용으로 변경
- use server 파일에서 window.open 불가 안내 추가
- HttpOnly 쿠키 + API Proxy 패턴 준수 명시
- toast 메시지 정합성 수정 (handleExportData)
- 체크리스트에 프로젝트 규칙 준수 항목 4개 추가
2026-03-18 14:29:04 +09:00

19 KiB

이용현황(구독관리 통합) React 구현 요청서

작성일: 2026-03-18 상태: API 구현 완료, React 구현 대기 대상: sam/react 메뉴 위치: 사이드바 이용현황 (/usage) 수정 이력: 2026-03-18 프론트엔드 피드백 6건 반영


1. 개요

1.1 목적

기존 구독관리(/subscription)와 이용현황(/usage)을 하나의 페이지로 통합한다. 구독 정보, 리소스 사용량(사용자/저장공간), AI 토큰 사용량을 한 화면에 표시한다.

1.2 변경 요약

항목 변경 전 변경 후
/usage EmptyPage (미구현) 통합 이용현황 페이지
/subscription SubscriptionManagement /usage로 리다이렉트
메뉴 구독관리 + 이용현황 2개 이용현황 1개로 통합
API 호출 수 (api_calls) 하드코딩 10,000 제거 → AI 토큰으로 대체
AI 토큰 없음 신규 추가 (한도/비용/모델별)
저장공간 한도 10GB 100GB

1.3 API 상태

API 엔드포인트 상태
구독 정보 GET /api/v1/subscriptions/current 기존 유지
사용량 조회 GET /api/v1/subscriptions/usage 개선 완료
구독 취소 POST /api/v1/subscriptions/{id}/cancel 기존 유지
데이터 내보내기 POST /api/v1/subscriptions/export 기존 유지

2. API 응답 구조 (변경된 부분)

2.1 GET /api/v1/subscriptions/usage

{
  "success": true,
  "data": {
    "users": {
      "used": 24,
      "limit": 10,
      "percentage": 240.0
    },
    "storage": {
      "used": 22808833,
      "used_formatted": "21.75 MB",
      "limit": 107374182400,
      "limit_formatted": "100 GB",
      "percentage": 0.0
    },
    "ai_tokens": {
      "period": "2026-03",
      "total_requests": 156,
      "total_tokens": 620000,
      "prompt_tokens": 412000,
      "completion_tokens": 208000,
      "limit": 1000000,
      "percentage": 62.0,
      "cost_usd": 0.95,
      "cost_krw": 1234,
      "warning_threshold": 80,
      "is_over_limit": false,
      "by_model": [
        {
          "model": "gemini-2.0-flash",
          "requests": 120,
          "total_tokens": 496000,
          "cost_krw": 987
        },
        {
          "model": "claude-3-haiku",
          "requests": 36,
          "total_tokens": 124000,
          "cost_krw": 247
        }
      ]
    },
    "subscription": {
      "plan": "스탠다드",
      "monthly_fee": 79000,
      "status": "active",
      "started_at": "2026-01-01",
      "ended_at": "2026-12-31",
      "remaining_days": 288
    }
  }
}

2.2 변경점 (기존 대비)

필드 변경
api_calls 삭제됨
ai_tokens 신규 추가
ai_tokens.limit 월별 토큰 한도 (테넌트별, 기본 100만)
ai_tokens.percentage 한도 대비 사용율
ai_tokens.warning_threshold 경고 기준 (80%)
ai_tokens.is_over_limit 한도 초과 여부
ai_tokens.by_model 모델별 사용량 내역
subscription.plan Plan 테이블의 name (한글)
subscription.monthly_fee Plan 테이블의 price (원화)
subscription.started_at start_datestarted_at 변경
subscription.ended_at next_billingended_at 변경
storage.limit 기본값 10GB → 100GB

3. TypeScript 타입 정의

3.1 UsageApiData 타입 (변경)

// 기존 api_calls 제거, ai_tokens 추가
export interface UsageApiData {
  users: {
    used: number;
    limit: number;
    percentage: number;
  };
  storage: {
    used: number;
    used_formatted: string;
    limit: number;
    limit_formatted: string;
    percentage: number;
  };
  ai_tokens: {
    period: string;              // "2026-03"
    total_requests: number;
    total_tokens: number;
    prompt_tokens: number;
    completion_tokens: number;
    limit: number;               // 월별 한도 (기본 100만)
    percentage: number;          // 한도 대비 사용율
    cost_usd: number;
    cost_krw: number;
    warning_threshold: number;   // 경고 기준 (80)
    is_over_limit: boolean;
    by_model: AiTokenByModel[];
  };
  subscription: {
    plan: string | null;         // "스탠다드", "프로페셔널" 등
    monthly_fee: number;         // 원화 금액
    status: string;
    started_at: string | null;
    ended_at: string | null;
    remaining_days: number | null;
  };
}

export interface AiTokenByModel {
  model: string;          // "gemini-2.0-flash"
  requests: number;
  total_tokens: number;
  cost_krw: number;
}

3.2 SubscriptionInfo 타입 (변경)

export interface SubscriptionInfo {
  // 구독
  id?: number;
  plan: string | null;
  planName: string;
  monthlyFee: number;
  status: SubscriptionStatus;
  startedAt: string | null;
  endedAt: string | null;
  remainingDays: number | null;

  // 사용자
  userCount: number;
  userLimit: number;

  // 저장공간
  storageUsed: number;
  storageLimit: number;
  storageUsedFormatted: string;
  storageLimitFormatted: string;
  storagePercentage: number;

  // AI 토큰 (신규)
  aiTokens: {
    period: string;
    totalTokens: number;
    limit: number;
    percentage: number;
    costKrw: number;
    warningThreshold: number;
    isOverLimit: boolean;
    byModel: AiTokenByModel[];
  };
}

4. 화면 구성

4.1 전체 레이아웃

이용현황
┌─────────────────────────────────────────────────────────────┐
│                                                              │
│  [섹션 1] 구독 정보                                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │ 요금제        │  │ 구독 상태     │  │ 구독 금액     │       │
│  │ 스탠다드      │  │ ● 활성       │  │ ₩79,000/월   │       │
│  │              │  │              │  │              │       │
│  │ 시작: 01-01  │  │ 남은일: 288일│  │ 종료: 12-31  │       │
│  └──────────────┘  └──────────────┘  └──────────────┘       │
│                                                              │
│  [섹션 2] 리소스 사용량                                      │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 사용자                                            │       │
│  │ ████████████████████████░░░░░░  24 / 10명         │       │
│  │                                                    │       │
│  │ 저장 공간                                          │       │
│  │ █░░░░░░░░░░░░░░░░░░░░░░░░░░░  21.75 MB / 100 GB  │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
│  [섹션 3] AI 토큰 사용량 — 2026년 3월                        │
│  ┌──────────────────────────────────────────────────┐       │
│  │ ██████████████████░░░░░░░░░░░  620,000 / 1,000,000│      │
│  │                                        62.0%      │       │
│  │                                                    │       │
│  │  총 비용: ₩1,234                                   │       │
│  │                                                    │       │
│  │  모델별 사용량:                                     │       │
│  │  ┌────────────────────┬────────┬──────┬───────┐   │       │
│  │  │ 모델               │ 호출수 │ 토큰  │ 비용  │   │       │
│  │  ├────────────────────┼────────┼──────┼───────┤   │       │
│  │  │ gemini-2.0-flash   │ 120    │ 496K │ ₩987  │   │       │
│  │  │ claude-3-haiku     │ 36     │ 124K │ ₩247  │   │       │
│  │  └────────────────────┴────────┴──────┴───────┘   │       │
│  │                                                    │       │
│  │  ※ 기본 제공: 월 100만 토큰. 초과 시 실비 과금     │       │
│  │  ※ 매월 1일 리셋, 잔여 토큰 이월 불가              │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
│  [섹션 4] 서비스 관리                                        │
│  ┌──────────────────────────────────────────────────┐       │
│  │  [ 자료 내보내기 ]                [ 서비스 해지 ]  │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

4.2 Progress Bar 색상 규칙

사용율 색상 비고
0~60% bg-blue-500 (기본) 정상
60~80% bg-yellow-500 (주의)
80~100% bg-orange-500 (경고) warning_threshold 기준
100%+ bg-red-500 (초과) is_over_limit: true

4.3 AI 토큰 경고 표시

// percentage >= warning_threshold (80%)이면 경고 배지 표시
{aiTokens.percentage >= aiTokens.warningThreshold && (
  <Badge variant="warning">
    기본 제공량의 {aiTokens.percentage}% 사용 
  </Badge>
)}

// is_over_limit이면 초과 배지 표시
{aiTokens.isOverLimit && (
  <Badge variant="destructive">
    한도 초과  초과분 실비 과금
  </Badge>
)}

4.4 구독 정보가 없는 경우

subscription.plan이 null이면 구독 카드 영역에 안내 메시지 표시:

구독 정보가 없습니다. 관리자에게 문의하세요.

5. 컴포넌트 구조

5.1 파일 구조 및 수정 대상

src/app/[locale]/(protected)/
├── usage/page.tsx                          ← 신규: 이용현황 페이지
└── subscription/page.tsx                   ← 수정: /usage로 리다이렉트

src/components/settings/SubscriptionManagement/
├── types.ts                                ← 수정: api_calls 제거, ai_tokens 추가
├── actions.ts                              ← 수정: 모든 URL을 buildApiUrl() 사용
├── utils.ts                                ← 수정: transformApiToFrontend에 AI 토큰 매핑
├── SubscriptionManagement.tsx              ← 수정: AI 토큰 섹션 추가, api_calls 제거, toast 메시지 수정
├── SubscriptionClient.tsx                  ← 수정: AI 토큰 섹션 추가 (SubscriptionManagement.tsx와 동기화)
└── index.ts                                ← 기존 유지

주의: SubscriptionManagement.tsxSubscriptionClient.tsx 모두 수정 대상이다. 두 파일에 동일한 handleExportData 로직이 있으며, 주석에 "SubscriptionManagement.tsx 사용 권장"이 있다. 통합 시 하나로 정리하는 것을 권장한다.

5.2 Server Actions 변경

필수: 모든 URL 조립에 buildApiUrl() 사용. ${API_URL}/api/v1/... 직접 조합 금지.

// actions.ts
'use server';

import { buildApiUrl } from '@/lib/api/query-params';
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';

// 사용량 조회
export async function getUsage(): Promise<ActionResult<UsageApiData>> {
  return executeServerAction({
    url: buildApiUrl('/api/v1/subscriptions/usage'),
    errorMessage: '사용량 정보를 불러오는데 실패했습니다.',
  });
}

// 구독 정보 조회
export async function getCurrentSubscription(): Promise<ActionResult<SubscriptionApiData>> {
  return executeServerAction({
    url: buildApiUrl('/api/v1/subscriptions/current'),
    errorMessage: '구독 정보를 불러오는데 실패했습니다.',
  });
}

// 구독 취소
export async function cancelSubscription(
  id: number,
  reason?: string
): Promise<ActionResult> {
  return executeServerAction({
    url: buildApiUrl(`/api/v1/subscriptions/${id}/cancel`),
    method: 'POST',
    body: { reason },
    errorMessage: '구독 취소에 실패했습니다.',
  });
}

// 데이터 내보내기 요청
export async function requestDataExport(
  exportType: string = 'all'
): Promise<ActionResult<{ id: number; status: string }>> {
  return executeServerAction({
    url: buildApiUrl('/api/v1/subscriptions/export'),
    method: 'POST',
    body: { export_type: exportType },
    errorMessage: '내보내기 요청에 실패했습니다.',
  });
}

5.3 자료 내보내기 다운로드 처리

주의: actions.ts'use server' 파일이므로 window.open() 등 브라우저 API 사용 불가. 이 프로젝트는 HttpOnly 쿠키 + Next.js API Proxy 패턴을 사용하므로, Authorization: Bearer 직접 전달도 불가.

다운로드 URL 조립은 클라이언트 컴포넌트에서 처리:

// SubscriptionManagement.tsx (클라이언트 컴포넌트) 내부에서 처리
const handleExportData = async () => {
  const result = await requestDataExport('all');
  if (result.success) {
    // toast 메시지 수정 (기존 '완료되면 알림을 보내드립니다.' 대체)
    toast.success('자료 내보내기를 요청했습니다. 완료 시 알림으로 안내드립니다.');
  }
};

// 다운로드가 필요한 경우 Next.js API proxy 경유
// /api/proxy/subscriptions/export/{id}/download 형태로 호출

5.4 /usage/page.tsx (신규)

'use client';

import { useEffect, useState } from 'react';
import { getSubscriptionData } from '@/components/settings/SubscriptionManagement/actions';
import type { SubscriptionInfo } from '@/components/settings/SubscriptionManagement/types';
import { SubscriptionManagement } from '@/components/settings/SubscriptionManagement';

export default function UsagePage() {
  const [data, setData] = useState<SubscriptionInfo | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    getSubscriptionData()
      .then(result => setData(result.data))
      .finally(() => setIsLoading(false));
  }, []);

  if (isLoading) return <UsageSkeleton />;
  return <SubscriptionManagement data={data} />;
}

5.5 /subscription/page.tsx (리다이렉트)

'use client';

import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

export default function SubscriptionRedirect() {
  const router = useRouter();
  useEffect(() => {
    router.replace('/usage');
  }, [router]);
  return null;
}

6. 포맷팅 유틸

// 토큰 수 포맷: 1,000,000 → "1,000K" 또는 "1M"
export function formatTokenCount(tokens: number): string {
  if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
  if (tokens >= 1_000) return `${Math.round(tokens / 1_000).toLocaleString()}K`;
  return tokens.toLocaleString();
}

// 원화 포맷: 1234 → "₩1,234"
export function formatKrw(amount: number): string {
  return `₩${Math.round(amount).toLocaleString()}`;
}

// 월 표시: "2026-03" → "2026년 3월"
export function formatPeriod(period: string): string {
  const [year, month] = period.split('-');
  return `${year}${parseInt(month)}월`;
}

7. 제거 대상

기존 코드에서 제거하거나 대체할 항목:

파일 제거 대상 사유
types.ts api_calls 인터페이스 ai_tokens로 대체
types.ts apiCallsUsed, apiCallsLimit 제거
SubscriptionManagement.tsx API 호출 수 Progress Bar AI 토큰 Progress Bar로 대체
SubscriptionManagement.tsx '완료되면 알림을 보내드립니다.' toast '자료 내보내기를 요청했습니다. 완료 시 알림으로 안내드립니다.'로 변경
SubscriptionClient.tsx handleExportData 중복 로직 SubscriptionManagement.tsx로 통합 권장
utils.ts apiCallsUsed/Limit 변환 aiTokens 변환으로 대체
actions.ts ${API_URL}/... 직접 조합 buildApiUrl() 사용으로 변경

8. 체크리스트

타입/데이터

  • types.tsUsageApiData에서 api_calls 제거, ai_tokens + subscription 변경
  • utils.tstransformApiToFrontend()에 AI 토큰 매핑 추가
  • actions.ts — 모든 URL을 buildApiUrl() 사용으로 변경

UI 컴포넌트

  • SubscriptionManagement.tsx — AI 토큰 섹션 추가, API 호출 수 섹션 제거
  • SubscriptionManagement.tsx — 내보내기 toast 메시지 수정
  • SubscriptionClient.tsxSubscriptionManagement.tsx와 기능 통합 정리
  • Progress Bar — 사용율별 색상 변경 (060 파랑, 6080 노랑, 80~100 주황, 100+ 빨강)
  • by_model 테이블 — 모델별 호출수/토큰/비용 표시
  • 한도 초과 경고 배지 — is_over_limit/warning_threshold 기반
  • 구독 정보 null 처리 (미가입 테넌트)
  • 로딩 스켈레톤 표시

페이지/라우팅

  • /usage/page.tsx 신규 생성
  • /subscription/page.tsx/usage 리다이렉트

프로젝트 규칙 준수

  • buildApiUrl() 필수 사용 (${API_URL} 직접 조합 금지)
  • 'use server' 파일에서 브라우저 API (window.open 등) 사용 금지
  • 다운로드는 Next.js API Proxy (/api/proxy/...) 경유 또는 서버 액션에서 처리
  • Authorization: Bearer 직접 전달 금지 (HttpOnly 쿠키 패턴)

관련 문서

문서 경로
통합 계획 (내부) plans/usage-subscription-unification.md
과금 정책 rules/customer-pricing.md (6장 사용량 기반 추가 과금)
AI 토큰 기능 features/ai/README.md
AI 토큰 API 명세 frontend/api-specs/ai-token-usage-api.md

최종 업데이트: 2026-03-18