Files
sam-docs/plans/ai-token-usage-service-migration.md
김보곤 00f54275e8 docs: [ai] AI 토큰사용량 서비스 이관 기획서 작성
- MNG→API+React 이관 기획 (설정 > 토큰사용량)
- GCS→R2 저장소 비용 추적 차이점 정리
- API 엔드포인트, 서비스, FormRequest 설계
- React 컴포넌트, Server Actions, 타입 정의
2026-03-18 09:21:46 +09:00

24 KiB

서비스 AI 토큰사용량 기능 기획

작성일: 2026-03-18 상태: 기획 대상: sam/api + sam/react 원본: sam/mng system/ai-token-usage 메뉴


1. 개요

1.1 목적

MNG(백오피스)의 AI 토큰사용량 조회 기능을 서비스(API+React)의 설정 > 토큰사용량 메뉴로 이관한다. 테넌트 사용자가 자기 테넌트의 AI 사용량과 비용을 직접 확인할 수 있도록 한다.

1.2 핵심 차이점 (MNG vs 서비스)

항목 MNG (백오피스) 서비스 (API+React)
사용자 시스템 관리자 테넌트 사용자
테넌트 필터 전체 테넌트 조회 가능 자기 테넌트만 조회 (자동 필터)
저장소 비용 GCS (Google Cloud Storage) R2 (Cloudflare R2)
단가 관리 모달에서 직접 편집 조회만 (단가 변경은 MNG에서)
UI Blade + React (HTMX) Next.js (SSR)
인증 세션 기반 Sanctum 토큰 기반

1.3 저장소 정책 차이

MNG는 음성녹음/회의록에 **GCS(Google Cloud Storage)**를 사용하지만, 서비스(API)는 파일 저장에 **R2(Cloudflare R2)**를 사용한다.

항목 MNG (GCS) 서비스 (R2)
저장소 Google Cloud Storage Cloudflare R2 (S3 호환)
과금 단위 Class A $0.005/1000건 + $0.02/GB/월 Class A $0.0045/M건 + $0.015/GB/월
비용 추적 메서드 saveGcsStorageUsage() saveR2StorageUsage() (신규)
pricing provider google-gcs cloudflare-r2 (신규)
STT Google Speech-to-Text 서비스에서 STT 미사용 (해당 없음)

2. sam/api 기획

2.1 API 엔드포인트

GET  /api/v1/settings/ai-token-usage          # 토큰 사용량 목록 + 통계
GET  /api/v1/settings/ai-token-usage/pricing   # 단가 설정 조회

단가 수정은 MNG 전용이므로 서비스 API에는 PUT 엔드포인트를 두지 않는다.

2.2 라우트 등록

// routes/api/v1/common.php — Settings 그룹 내부에 추가
Route::prefix('settings')->group(function () {
    // ... 기존 설정 라우트 ...

    // AI 토큰 사용량
    Route::get('/ai-token-usage', [AiTokenUsageController::class, 'index'])
        ->name('v1.settings.ai-token-usage.index');
    Route::get('/ai-token-usage/pricing', [AiTokenUsageController::class, 'pricing'])
        ->name('v1.settings.ai-token-usage.pricing');
});

2.3 컨트롤러

파일: app/Http/Controllers/Api/V1/AiTokenUsageController.php

class AiTokenUsageController extends Controller
{
    public function __construct(
        private AiTokenUsageService $service
    ) {}

    // GET /api/v1/settings/ai-token-usage
    public function index(AiTokenUsageListRequest $request)
    {
        return ApiResponse::handle(fn () =>
            $this->service->list($request->validated())
        );
    }

    // GET /api/v1/settings/ai-token-usage/pricing
    public function pricing()
    {
        return ApiResponse::handle(fn () =>
            $this->service->getPricing()
        );
    }
}

2.4 서비스

파일: app/Services/AiTokenUsageService.php

class AiTokenUsageService extends Service
{
    /**
     * 토큰 사용량 목록 + 통계
     * - tenant_id는 Service 부모 클래스의 tenantId()로 자동 적용
     * - 테넌트 필터 없음 (자기 테넌트만)
     */
    public function list(array $params): array
    {
        // 1. 사용량 목록 (페이지네이션)
        $query = AiTokenUsage::where('tenant_id', $this->tenantId())
            ->when($params['start_date'] ?? null, fn ($q, $d) => $q->where('created_at', '>=', $d))
            ->when($params['end_date'] ?? null, fn ($q, $d) => $q->where('created_at', '<=', $d . ' 23:59:59'))
            ->when($params['menu_name'] ?? null, fn ($q, $m) => $q->where('menu_name', $m))
            ->with('creator:id,name')
            ->orderByDesc('created_at');

        $items = $query->paginate($params['per_page'] ?? 20);

        // 2. 통계 (동일 필터 조건, 전체 집계)
        $statsQuery = AiTokenUsage::where('tenant_id', $this->tenantId())
            ->when($params['start_date'] ?? null, fn ($q, $d) => $q->where('created_at', '>=', $d))
            ->when($params['end_date'] ?? null, fn ($q, $d) => $q->where('created_at', '<=', $d . ' 23:59:59'))
            ->when($params['menu_name'] ?? null, fn ($q, $m) => $q->where('menu_name', $m));

        $stats = [
            'total_count' => $statsQuery->count(),
            'total_prompt_tokens' => (int) $statsQuery->sum('prompt_tokens'),
            'total_completion_tokens' => (int) $statsQuery->sum('completion_tokens'),
            'total_total_tokens' => (int) $statsQuery->sum('total_tokens'),
            'total_cost_usd' => (float) $statsQuery->sum('cost_usd'),
            'total_cost_krw' => (float) $statsQuery->sum('cost_krw'),
        ];

        // 3. 메뉴명 필터 옵션 (해당 테넌트의 고유 menu_name 목록)
        $menuNames = AiTokenUsage::where('tenant_id', $this->tenantId())
            ->select('menu_name')
            ->distinct()
            ->orderBy('menu_name')
            ->pluck('menu_name');

        return [
            'items' => $items,
            'stats' => $stats,
            'menu_names' => $menuNames,
        ];
    }

    /**
     * 단가 설정 조회 (읽기 전용)
     */
    public function getPricing(): array
    {
        $configs = AiPricingConfig::where('is_active', true)
            ->orderBy('provider')
            ->get();

        return [
            'pricing' => $configs,
            'exchange_rate' => AiPricingConfig::getExchangeRate(),
        ];
    }
}

2.5 FormRequest

파일: app/Http/Requests/V1/AiTokenUsageListRequest.php

class AiTokenUsageListRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'start_date' => 'nullable|date',
            'end_date' => 'nullable|date|after_or_equal:start_date',
            'menu_name' => 'nullable|string|max:100',
            'per_page' => 'nullable|integer|min:10|max:100',
            'page' => 'nullable|integer|min:1',
        ];
    }
}

2.6 모델 (기존 활용)

ai_token_usages, ai_pricing_configs 테이블은 이미 API에서 마이그레이션 관리 중이다. 모델도 app/Models/Tenants/AiTokenUsage.php, AiPricingConfig.php에 존재한다.

추가 필요 없음 — 기존 모델을 그대로 사용한다.

2.7 AiTokenHelper 이관

MNG의 AiTokenHelper를 API에도 동일하게 구현하되, GCS 대신 R2 저장소 비용 추적 메서드를 추가한다.

파일: app/Helpers/AiTokenHelper.php

기존 MNG와 동일한 메서드 + R2 전용 메서드:

메서드 출처 서비스 적용
saveGeminiUsage() MNG 동일 AI 리포트 등 Gemini 호출 시
saveClaudeUsage() MNG 동일 Claude 호출 시
saveR2StorageUsage() 신규 R2 파일 업로드 시
saveSttUsage() MNG 동일 서비스에서 STT 사용 시 (현재 미사용)

R2 저장소 비용 계산 공식:

public static function saveR2StorageUsage(string $menuName, int $fileSizeBytes): void
{
    // Cloudflare R2 요금 (2026년 기준)
    // Class A (PUT/POST): $0.0045 / 1,000,000건 → 건당 $0.0000000045
    // Storage: $0.015 / GB / 월
    $pricing = AiPricingConfig::getActivePricing('cloudflare-r2');
    $unitPrice = $pricing ? (float) $pricing->unit_price : 0.0045;

    $operationCost = $unitPrice / 1_000_000;  // 1건당 오퍼레이션 비용
    $fileSizeGB = $fileSizeBytes / (1024 * 1024 * 1024);
    $storageCost = $fileSizeGB * 0.015 * (30 / 30);  // 월간 보관 기준

    $costUsd = $operationCost + $storageCost;

    self::save('cloudflare-r2', $menuName, $fileSizeBytes, 0, $fileSizeBytes,
        $costUsd / max($fileSizeBytes, 1), 0);
}

2.8 AiPricingConfig 시드 데이터 추가

cloudflare-r2 provider를 ai_pricing_configs 테이블에 추가한다.

INSERT INTO ai_pricing_configs
(provider, model_name, input_price_per_million, output_price_per_million,
 unit_price, unit_description, exchange_rate, is_active, description)
VALUES
('cloudflare-r2', 'cloud-storage', 0, 0,
 0.0045, 'per 1,000,000 Class A operations', 1400, 1,
 'Cloudflare R2 Storage (S3 compatible)');

tinker 또는 마이그레이션으로 추가. 시더 실행 금지 원칙에 따라 tinker 사용 권장.

2.9 API 응답 형식

GET /api/v1/settings/ai-token-usage

{
  "success": true,
  "data": {
    "items": {
      "data": [
        {
          "id": 1,
          "model": "gemini-2.0-flash",
          "menu_name": "AI리포트-일간",
          "prompt_tokens": 2500,
          "completion_tokens": 800,
          "total_tokens": 3300,
          "cost_usd": "0.001320",
          "cost_krw": "1.85",
          "request_id": "uuid-...",
          "created_by": 5,
          "creator": { "id": 5, "name": "홍길동" },
          "created_at": "2026-03-18T10:30:00"
        }
      ],
      "current_page": 1,
      "last_page": 3,
      "per_page": 20,
      "total": 45
    },
    "stats": {
      "total_count": 45,
      "total_prompt_tokens": 125000,
      "total_completion_tokens": 38000,
      "total_total_tokens": 163000,
      "total_cost_usd": 0.065200,
      "total_cost_krw": 91.28
    },
    "menu_names": [
      "AI리포트-일간",
      "AI리포트-주간",
      "AI리포트-월간",
      "명함OCR"
    ]
  }
}

GET /api/v1/settings/ai-token-usage/pricing

{
  "success": true,
  "data": {
    "pricing": [
      {
        "provider": "gemini",
        "model_name": "gemini-2.0-flash",
        "input_price_per_million": "0.1000",
        "output_price_per_million": "0.4000",
        "unit_price": null,
        "exchange_rate": "1400.00",
        "is_active": true
      },
      {
        "provider": "claude",
        "model_name": "claude-3-haiku",
        "input_price_per_million": "0.2500",
        "output_price_per_million": "1.2500",
        "unit_price": null,
        "exchange_rate": "1400.00",
        "is_active": true
      },
      {
        "provider": "cloudflare-r2",
        "model_name": "cloud-storage",
        "input_price_per_million": null,
        "output_price_per_million": null,
        "unit_price": "0.004500",
        "unit_description": "per 1,000,000 Class A operations",
        "exchange_rate": "1400.00",
        "is_active": true
      }
    ],
    "exchange_rate": 1400.0
  }
}

2.10 파일 목록 (sam/api)

상태 파일 설명
신규 app/Http/Controllers/Api/V1/AiTokenUsageController.php 컨트롤러
신규 app/Services/AiTokenUsageService.php 서비스
신규 app/Http/Requests/V1/AiTokenUsageListRequest.php FormRequest
신규 app/Helpers/AiTokenHelper.php 토큰 사용량 기록 헬퍼
수정 routes/api/v1/common.php 라우트 추가
기존 app/Models/Tenants/AiTokenUsage.php 모델 (수정 불필요)
기존 app/Models/Tenants/AiPricingConfig.php 모델 (수정 불필요)

3. sam/react 기획

3.1 메뉴 위치

설정 (Settings)
├── 계좌관리
├── 권한관리
├── 직급관리
├── 직위관리
├── ...
└── 토큰사용량      ← 신규 추가

3.2 페이지 구조

라우트: /settings/ai-token-usage

/settings/ai-token-usage/page.tsx           → AiTokenUsagePage 컴포넌트 호출
/components/settings/AiTokenUsage/
├── index.tsx           → 메인 컴포넌트
├── actions.ts          → Server Actions (API 호출)
├── types.ts            → TypeScript 타입 정의
├── AiTokenUsageConfig.ts  → 상수, 카테고리 매핑
└── PricingModal.tsx    → 단가 조회 모달

3.3 화면 구성

┌─────────────────────────────────────────────────────────┐
│  AI 토큰 사용량                            [단가 확인]  │
├─────────────────────────────────────────────────────────┤
│                                                         │
│  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐    │
│  │ 호출수│  │ 입력  │  │ 출력  │  │ 비용  │  │ 비용  │    │
│  │ 45   │  │ 125K │  │ 38K  │  │$0.065│  │ 91원 │    │
│  │      │  │토큰  │  │토큰  │  │(USD) │  │(KRW) │    │
│  └──────┘  └──────┘  └──────┘  └──────┘  └──────┘    │
│                                                         │
│  시작일 [____] 종료일 [____] 메뉴 [▼____] [조회][초기화]│
│                                                         │
│  ┌─────┬──────┬──────┬──────┬──────┬──────┬──────┐    │
│  │ No. │ 일시 │카테고리│ 메뉴 │ 모델 │ 토큰 │ 비용 │    │
│  ├─────┼──────┼──────┼──────┼──────┼──────┼──────┤    │
│  │  1  │03-18│AI리포트│일간  │gemini│3,300 │$0.001│    │
│  │  2  │03-17│명함OCR│인식  │gemini│1,200 │$0.000│    │
│  │ ... │ ... │  ... │ ... │ ... │ ... │ ... │    │
│  └─────┴──────┴──────┴──────┴──────┴──────┴──────┘    │
│                                                         │
│  ◀ 1 2 3 ▶                           20건 / 총 45건   │
└─────────────────────────────────────────────────────────┘

3.4 컴포넌트 설계

3.4.1 메인 컴포넌트 (index.tsx)

UniversalListPage 패턴을 따르되, 통계 카드 + 필터 + 테이블을 포함한다.

// 통계 카드 5개
computeStats: (data, totalCount, stats) => [
  { label: '총 호출 수', value: stats.total_count, icon: Activity },
  { label: '입력 토큰', value: formatNumber(stats.total_prompt_tokens), icon: ArrowUpRight },
  { label: '출력 토큰', value: formatNumber(stats.total_completion_tokens), icon: ArrowDownRight },
  { label: '총 비용 (USD)', value: formatUsd(stats.total_cost_usd), icon: DollarSign },
  { label: '총 비용 (KRW)', value: formatKrw(stats.total_cost_krw), icon: Coins },
]

3.4.2 테이블 컬럼

columns: [
  { key: 'no', label: 'No.', className: 'text-center w-[50px]' },
  { key: 'created_at', label: '사용일시', className: 'min-w-[140px]' },
  { key: 'category', label: '카테고리', className: 'min-w-[100px]' },
  { key: 'menu_name', label: '호출메뉴', className: 'min-w-[120px]' },
  { key: 'model', label: '모델', className: 'min-w-[140px]' },
  { key: 'prompt_tokens', label: '입력토큰', className: 'text-right min-w-[90px]' },
  { key: 'completion_tokens', label: '출력토큰', className: 'text-right min-w-[90px]' },
  { key: 'total_tokens', label: '전체토큰', className: 'text-right min-w-[90px]' },
  { key: 'cost_usd', label: '비용(USD)', className: 'text-right min-w-[90px]' },
  { key: 'cost_krw', label: '비용(KRW)', className: 'text-right min-w-[90px]' },
  { key: 'creator', label: '사용자', className: 'min-w-[80px]' },
]

3.4.3 필터 (서버 사이드)

// 서버 사이드 필터링 — API에 쿼리 파라미터로 전달
interface AiTokenUsageFilters {
  start_date?: string;   // YYYY-MM-DD
  end_date?: string;     // YYYY-MM-DD
  menu_name?: string;    // 드롭다운 선택
  per_page?: number;     // 기본 20
  page?: number;
}

MNG와 달리 테넌트 필터 없음 — API가 tenantId()로 자동 필터링한다.

3.4.4 카테고리 매핑 (AiTokenUsageConfig.ts)

// MNG와 동일한 카테고리 분류 로직
export function getCategory(menuName: string): string {
  if (!menuName) return '-';
  if (menuName.startsWith('AI리포트')) return 'AI리포트';
  if (menuName.includes('명함')) return '명함OCR';
  if (menuName.includes('사업자등록증')) return 'OCR';
  if (menuName.startsWith('파일업로드')) return '파일저장소';
  return menuName;
}

// 포맷팅 유틸
export const formatNumber = (n: number) => n.toLocaleString('ko-KR');
export const formatUsd = (n: number) => `$${n.toFixed(4)}`;
export const formatKrw = (n: number) => `${Math.round(n).toLocaleString('ko-KR')}원`;

3.4.5 Server Actions (actions.ts)

'use server';

import { executeServerAction } from '@/lib/api/execute-server-action';
import { buildApiUrl } from '@/lib/api/build-api-url';

export async function getAiTokenUsageList(params: AiTokenUsageFilters) {
  return executeServerAction({
    url: buildApiUrl('/api/v1/settings/ai-token-usage', params),
    errorMessage: 'AI 토큰 사용량 조회에 실패했습니다.',
  });
}

export async function getAiTokenUsagePricing() {
  return executeServerAction({
    url: buildApiUrl('/api/v1/settings/ai-token-usage/pricing'),
    errorMessage: 'AI 단가 조회에 실패했습니다.',
  });
}

3.4.6 단가 조회 모달 (PricingModal.tsx)

MNG의 단가 설정 모달과 동일한 UI이지만 읽기 전용:

┌─────────────────────────────────────────────┐
│  AI 단가 설정 (읽기 전용)           [닫기]  │
├─────────────────────────────────────────────┤
│                                             │
│  ■ AI 토큰 단가                             │
│  ┌───────┬──────┬──────────┬──────────┐    │
│  │Provider│ 모델 │입력($/1M)│출력($/1M)│    │
│  ├───────┼──────┼──────────┼──────────┤    │
│  │Gemini │flash │  $0.10   │  $0.40   │    │
│  │Claude │haiku │  $0.25   │  $1.25   │    │
│  └───────┴──────┴──────────┴──────────┘    │
│                                             │
│  ■ 저장소/서비스 단가                       │
│  ┌───────┬─────────┬────────┐              │
│  │Provider│ 단위가격 │ 단위   │              │
│  ├───────┼─────────┼────────┤              │
│  │R2     │ $0.0045 │1M 작업 │              │
│  └───────┴─────────┴────────┘              │
│                                             │
│  환율: 1 USD = 1,400 KRW                   │
│                                             │
│  ※ 단가 변경은 관리자에게 문의하세요        │
└─────────────────────────────────────────────┘

3.5 타입 정의 (types.ts)

export interface AiTokenUsageItem {
  id: number;
  model: string;
  menu_name: string;
  prompt_tokens: number;
  completion_tokens: number;
  total_tokens: number;
  cost_usd: string;
  cost_krw: string;
  request_id: string;
  created_by: number;
  creator?: { id: number; name: string };
  created_at: string;
}

export interface AiTokenUsageStats {
  total_count: number;
  total_prompt_tokens: number;
  total_completion_tokens: number;
  total_total_tokens: number;
  total_cost_usd: number;
  total_cost_krw: number;
}

export interface AiPricingItem {
  provider: string;
  model_name: string;
  input_price_per_million: string | null;
  output_price_per_million: string | null;
  unit_price: string | null;
  unit_description: string | null;
  exchange_rate: string;
  is_active: boolean;
}

3.6 파일 목록 (sam/react)

상태 파일 설명
신규 src/app/[locale]/(protected)/settings/ai-token-usage/page.tsx 페이지
신규 src/components/settings/AiTokenUsage/index.tsx 메인 컴포넌트
신규 src/components/settings/AiTokenUsage/actions.ts Server Actions
신규 src/components/settings/AiTokenUsage/types.ts 타입 정의
신규 src/components/settings/AiTokenUsage/AiTokenUsageConfig.ts 상수/유틸
신규 src/components/settings/AiTokenUsage/PricingModal.tsx 단가 조회 모달

4. DB 변경

4.1 테이블 변경 없음

ai_token_usages, ai_pricing_configs 테이블은 이미 존재하며 변경 불필요.

4.2 데이터 추가

ai_pricing_configs 테이블에 cloudflare-r2 provider 레코드 1건 추가:

docker exec sam-api-1 php artisan tinker --execute="
\App\Models\Tenants\AiPricingConfig::create([
    'provider' => 'cloudflare-r2',
    'model_name' => 'cloud-storage',
    'input_price_per_million' => 0,
    'output_price_per_million' => 0,
    'unit_price' => 0.0045,
    'unit_description' => 'per 1,000,000 Class A operations',
    'exchange_rate' => 1400,
    'is_active' => true,
    'description' => 'Cloudflare R2 Storage (S3 compatible)',
]);
"

4.3 AiPricingConfig 캐시 키 확장

AiPricingConfig::clearCache()cloudflare-r2 provider 추가:

// 기존: ['gemini', 'claude', 'google-stt', 'google-gcs']
// 변경: ['gemini', 'claude', 'google-stt', 'google-gcs', 'cloudflare-r2']

5. 메뉴 등록

5.1 React 메뉴 (서비스)

서비스 메뉴 DB에 등록 (tinker 사용):

# 설정 부모 메뉴 ID 조회 후 하위에 추가
# (React 메뉴 체계는 별도 확인 필요)

6. MNG vs 서비스 기능 비교

기능 MNG 서비스 비고
사용량 목록 조회
통계 카드 (6종) (5종) 서비스: 전체토큰 카드 제외 (간결화)
기간 필터
테넌트 필터 서비스: 자기 테넌트 자동 필터
메뉴명 필터
카테고리 분류 동일 로직
녹음시간 표시 서비스: STT 미사용
단가 설정 편집 서비스: 조회만 (MNG에서 관리)
단가 조회
GCS 비용 추적 서비스: R2 비용 추적
R2 비용 추적 신규
페이지네이션
사용자명 표시 creator 관계 로드

7. 구현 순서

Phase 1: API (sam/api)

  1. AiTokenHelper 생성 (MNG 코드 기반 + saveR2StorageUsage 추가)
  2. AiTokenUsageService 생성
  3. AiTokenUsageListRequest 생성
  4. AiTokenUsageController 생성
  5. common.php 라우트 등록
  6. ai_pricing_configscloudflare-r2 데이터 추가 (tinker)
  7. Swagger 문서 작성

Phase 2: React (sam/react)

  1. 타입 정의 (types.ts)
  2. 설정/유틸 (AiTokenUsageConfig.ts)
  3. Server Actions (actions.ts)
  4. 단가 조회 모달 (PricingModal.tsx)
  5. 메인 컴포넌트 (index.tsx)
  6. 페이지 파일 (page.tsx)
  7. 메뉴 등록 (DB tinker)

관련 문서


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