# GGTI 서비스 개발 통합 설계서

본 문서는 GGTI를 "리그오브레전드 플레이 성향 AI 분석 플랫폼"으로 운영 가능한 수준까지 구현하기 위한 기준 문서다. 현재 MVP는 League of Legends 전용으로 설계하되, 데이터 모델과 서비스 경계는 추후 다른 게임을 연결할 수 있도록 확장형 구조를 사용한다.

## 1. 전체 시스템 아키텍처

### 목표

- Riot Match 및 Timeline 기반 행동 패턴 분석
- GGTI 성향 유형 생성
- AI 자연어 분석 및 공유 카드 생성
- Redis, Docker, AWS 없이 개인 온프레미스 Linux 서버에서 운영
- GitHub Actions + SSH + PM2 + Nginx 기반 자동배포

### 구성

```mermaid
flowchart LR
  User["User Browser"] --> Nginx["Nginx Reverse Proxy"]
  Nginx --> Front["frontend / Next.js / PM2 ggti-front"]
  Nginx --> Back["backend / NestJS / PM2 ggti-back"]
  Back --> MySQL["MySQL 8"]
  Back --> Riot["Riot API"]
  Back --> VLLM["External vLLM Gateway"]
  Batch["batch / NestJS Cron / PM2 ggti-batch"] --> MySQL
  Batch --> Riot
  Batch --> VLLM
  GitHub["GitHub Actions"] --> SSH["SSH Deploy"]
  SSH --> Front
  SSH --> Back
  SSH --> Batch
```

### 서비스 경계

- `frontend`: SEO, 검색 UX, 결과 UX, 공유 UX, AdSense, OpenGraph
- `backend`: Riot API 연동, 사용자 검색, 분석 조회, 듀오 분석, AI 호출, 인증, DB API
- `batch`: 경기 수집, Timeline 수집, 성향 계산, 통계 집계, AI 설명 사전 생성
- `MySQL`: 운영 데이터, 집계 데이터, AI 캐시, 배치 작업 상태 저장
- `vLLM Gateway`: api_key/secret_key 기반 토큰 발급 후 Bearer 인증으로 AI 생성

### 요청 흐름

1. 사용자가 Riot ID + Tag로 검색한다.
2. backend가 `riot_accounts`, `riot_summoners`를 조회하고 없거나 만료되면 Riot API에서 갱신한다.
3. backend가 최근 분석 결과가 있으면 즉시 반환한다.
4. 분석 결과가 없거나 오래되면 `analysis_requests`를 생성하고 match 수집을 트리거한다.
5. batch가 match/timeline을 수집하고 feature를 계산한다.
6. batch 또는 backend가 GGTI 유형과 AI 설명을 생성하고 `player_analysis_snapshots`에 저장한다.
7. frontend는 결과 페이지와 공유 페이지를 렌더링한다.

## 2. Repository 구조

총 3개 repository를 독립 배포 단위로 운영한다.

```text
ggti-frontend
ggti-backend
ggti-batch
```

### 공통 브랜치 전략

- `main`: 운영 배포 브랜치
- `develop`: 통합 개발 브랜치
- `feature/*`: 기능 개발
- `hotfix/*`: 긴급 수정

### 공통 환경 파일

- `.env.example`: 커밋 가능
- `.env`: 서버에만 존재, 커밋 금지
- GitHub Secrets: `SERVER_HOST`, `SERVER_USER`, `SERVER_SSH_KEY`, `SERVER_PORT`

## 3. 디렉토리 구조

### frontend

```text
ggti-frontend/
  app/
    layout.tsx
    page.tsx
    search/page.tsx
    result/[snapshotId]/page.tsx
    share/[shareKey]/page.tsx
    duo/page.tsx
    season/page.tsx
    robots.ts
    sitemap.ts
    api/og/[shareKey]/route.ts
  components/
    ads/AdSenseSlot.tsx
    cards/GgtiResultCard.tsx
    cards/ShareCardPreview.tsx
    charts/TraitRadarChart.tsx
    common/Header.tsx
    common/Footer.tsx
    search/RiotSearchForm.tsx
  features/
    analysis/
    duo/
    seo/
  lib/
    api/client.ts
    api/types.ts
    seo/metadata.ts
    utils/format.ts
  public/
    icons/
    og-default.png
  styles/
    globals.css
  ecosystem.config.js
  next.config.ts
  tailwind.config.ts
  package.json
```

### backend

```text
ggti-backend/
  src/
    main.ts
    app.module.ts
    common/
      decorators/
      filters/http-exception.filter.ts
      interceptors/response.interceptor.ts
      pipes/validation.pipe.ts
      types/api-response.ts
    config/
      env.validation.ts
      configuration.ts
    database/
      prisma.service.ts
      mysql.module.ts
    auth/
      auth.module.ts
      api-key.guard.ts
    riot/
      riot.module.ts
      riot-account.service.ts
      riot-match.service.ts
      riot-rate-limit.service.ts
      dto/search-riot-user.dto.ts
    analysis/
      analysis.module.ts
      analysis.controller.ts
      analysis.service.ts
      ggti-score.service.ts
      dto/create-analysis.dto.ts
      dto/analysis-response.dto.ts
    duo/
      duo.module.ts
      duo.controller.ts
      duo.service.ts
      dto/create-duo-analysis.dto.ts
    ai/
      ai.module.ts
      vllm-auth.service.ts
      vllm-client.service.ts
      prompt-builder.service.ts
      dto/chat-completion.dto.ts
    share/
      share.module.ts
      share.controller.ts
      share.service.ts
    health/
      health.controller.ts
  prisma/
    schema.prisma
    migrations/
  ecosystem.config.js
  package.json
```

### batch

```text
ggti-batch/
  src/
    main.ts
    app.module.ts
    common/
      logger/batch-logger.service.ts
      lock/mysql-lock.service.ts
    config/
      env.validation.ts
      configuration.ts
    database/
      prisma.service.ts
    riot/
      riot.module.ts
      riot-account.service.ts
      riot-match-collector.service.ts
      riot-timeline-collector.service.ts
      riot-rate-limit.service.ts
    jobs/
      jobs.module.ts
      match-collect.job.ts
      timeline-collect.job.ts
      feature-aggregate.job.ts
      ggti-analysis.job.ts
      ai-cache.job.ts
      stale-request-cleanup.job.ts
    analysis/
      feature-extractor.service.ts
      ggti-score.service.ts
      trait-normalizer.service.ts
    ai/
      vllm-auth.service.ts
      vllm-client.service.ts
      prompt-builder.service.ts
  prisma/
    schema.prisma
  ecosystem.config.js
  package.json
```

## 4. ERD

```mermaid
erDiagram
  games ||--o{ riot_accounts : has
  riot_accounts ||--o{ riot_summoners : has
  riot_accounts ||--o{ participant_matches : plays
  matches ||--o{ participant_matches : includes
  matches ||--o{ match_timelines : has
  participant_matches ||--o{ participant_timeline_features : has
  riot_accounts ||--o{ player_analysis_snapshots : owns
  player_analysis_snapshots ||--o{ ai_analysis_cache : has
  player_analysis_snapshots ||--o{ share_cards : has
  riot_accounts ||--o{ duo_analysis_results : player_a
  riot_accounts ||--o{ duo_analysis_results : player_b
  batch_jobs ||--o{ batch_job_runs : has

  games {
    bigint id PK
    varchar code UK
    varchar name
    tinyint is_active
  }

  riot_accounts {
    bigint id PK
    bigint game_id FK
    varchar puuid UK
    varchar game_name
    varchar tag_line
    datetime last_synced_at
  }

  riot_summoners {
    bigint id PK
    bigint riot_account_id FK
    varchar summoner_id UK
    varchar account_id
    varchar region
    int profile_icon_id
    bigint summoner_level
  }

  matches {
    bigint id PK
    bigint game_id FK
    varchar match_id UK
    varchar region
    varchar queue_id
    datetime game_start_at
    int game_duration_sec
  }

  participant_matches {
    bigint id PK
    bigint match_id FK
    bigint riot_account_id FK
    int participant_id
    varchar champion_name
    varchar lane
    tinyint win
  }

  match_timelines {
    bigint id PK
    bigint match_id FK
    json timeline_json
    datetime collected_at
  }

  participant_timeline_features {
    bigint id PK
    bigint participant_match_id FK
    decimal aggression_score
    decimal stability_score
    decimal roaming_score
    decimal vision_score
    decimal objective_score
    decimal growth_score
    decimal fight_join_score
  }

  player_analysis_snapshots {
    bigint id PK
    bigint riot_account_id FK
    varchar ggti_type
    int sample_match_count
    json trait_scores
    json feature_summary
    datetime analyzed_at
  }
```

## 5. DB 테이블 설계

MySQL 8 기준이며 `utf8mb4`, `InnoDB`, 시간 컬럼은 UTC 저장을 기본으로 한다.

### 핵심 테이블

```sql
CREATE TABLE games (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  code VARCHAR(32) NOT NULL,
  name VARCHAR(100) NOT NULL,
  is_active TINYINT(1) NOT NULL DEFAULT 1,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_games_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE riot_accounts (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  game_id BIGINT UNSIGNED NOT NULL,
  puuid VARCHAR(128) NOT NULL,
  game_name VARCHAR(64) NOT NULL,
  tag_line VARCHAR(16) NOT NULL,
  last_synced_at DATETIME(3) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_riot_accounts_puuid (puuid),
  KEY ix_riot_accounts_name_tag (game_name, tag_line),
  CONSTRAINT fk_riot_accounts_game FOREIGN KEY (game_id) REFERENCES games(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE riot_summoners (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  riot_account_id BIGINT UNSIGNED NOT NULL,
  summoner_id VARCHAR(128) NOT NULL,
  account_id VARCHAR(128) NOT NULL,
  region VARCHAR(16) NOT NULL,
  profile_icon_id INT NULL,
  summoner_level BIGINT NULL,
  revision_date BIGINT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_riot_summoners_summoner_id (summoner_id),
  UNIQUE KEY uk_riot_summoners_account_region (riot_account_id, region),
  KEY ix_riot_summoners_region (region),
  CONSTRAINT fk_riot_summoners_account FOREIGN KEY (riot_account_id) REFERENCES riot_accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE matches (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  game_id BIGINT UNSIGNED NOT NULL,
  match_id VARCHAR(64) NOT NULL,
  region VARCHAR(16) NOT NULL,
  queue_id INT NOT NULL,
  season_id VARCHAR(32) NULL,
  game_version VARCHAR(32) NULL,
  game_start_at DATETIME(3) NOT NULL,
  game_duration_sec INT NOT NULL,
  raw_json JSON NOT NULL,
  collected_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_matches_match_id (match_id),
  KEY ix_matches_region_start (region, game_start_at),
  KEY ix_matches_queue_start (queue_id, game_start_at),
  CONSTRAINT fk_matches_game FOREIGN KEY (game_id) REFERENCES games(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE participant_matches (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  match_id BIGINT UNSIGNED NOT NULL,
  riot_account_id BIGINT UNSIGNED NOT NULL,
  participant_id INT NOT NULL,
  team_id INT NOT NULL,
  champion_id INT NOT NULL,
  champion_name VARCHAR(64) NOT NULL,
  lane VARCHAR(32) NULL,
  role VARCHAR(32) NULL,
  win TINYINT(1) NOT NULL,
  kills INT NOT NULL DEFAULT 0,
  deaths INT NOT NULL DEFAULT 0,
  assists INT NOT NULL DEFAULT 0,
  gold_earned INT NOT NULL DEFAULT 0,
  total_damage_to_champions INT NOT NULL DEFAULT 0,
  damage_taken INT NOT NULL DEFAULT 0,
  vision_score INT NOT NULL DEFAULT 0,
  wards_placed INT NOT NULL DEFAULT 0,
  wards_killed INT NOT NULL DEFAULT 0,
  control_wards_bought INT NOT NULL DEFAULT 0,
  neutral_minions_killed INT NOT NULL DEFAULT 0,
  total_minions_killed INT NOT NULL DEFAULT 0,
  kill_participation DECIMAL(6,4) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_participant_match (match_id, participant_id),
  UNIQUE KEY uk_participant_account_match (riot_account_id, match_id),
  KEY ix_participant_account_created (riot_account_id, id),
  KEY ix_participant_champion (champion_id),
  CONSTRAINT fk_pm_match FOREIGN KEY (match_id) REFERENCES matches(id),
  CONSTRAINT fk_pm_account FOREIGN KEY (riot_account_id) REFERENCES riot_accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### Timeline 저장 구조

`timeline_json`은 원본 보존용으로 저장하고, 검색/분석에 필요한 값은 별도 feature 테이블로 정규화한다. Redis가 없으므로 원본 JSON을 매번 파싱하지 않도록 batch에서 feature를 사전 계산한다.

```sql
CREATE TABLE match_timelines (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  match_id BIGINT UNSIGNED NOT NULL,
  timeline_json JSON NOT NULL,
  frame_count INT NOT NULL,
  collected_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_match_timelines_match (match_id),
  CONSTRAINT fk_timelines_match FOREIGN KEY (match_id) REFERENCES matches(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE participant_timeline_features (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  participant_match_id BIGINT UNSIGNED NOT NULL,
  early_death_count INT NOT NULL DEFAULT 0,
  solo_death_count INT NOT NULL DEFAULT 0,
  death_near_objective_count INT NOT NULL DEFAULT 0,
  roam_event_count INT NOT NULL DEFAULT 0,
  objective_join_count INT NOT NULL DEFAULT 0,
  teamfight_join_count INT NOT NULL DEFAULT 0,
  first_10m_cs INT NOT NULL DEFAULT 0,
  first_10m_gold INT NOT NULL DEFAULT 0,
  gold_diff_15 INT NULL,
  xp_diff_15 INT NULL,
  aggression_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  stability_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  roaming_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  vision_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  objective_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  growth_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  fight_join_score DECIMAL(7,4) NOT NULL DEFAULT 0,
  calculated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_ptf_participant (participant_match_id),
  KEY ix_ptf_scores (aggression_score, stability_score, roaming_score),
  CONSTRAINT fk_ptf_pm FOREIGN KEY (participant_match_id) REFERENCES participant_matches(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 분석/AI/공유 테이블

```sql
CREATE TABLE player_analysis_snapshots (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  riot_account_id BIGINT UNSIGNED NOT NULL,
  season_id VARCHAR(32) NULL,
  ggti_type VARCHAR(32) NOT NULL,
  sample_match_count INT NOT NULL,
  primary_champion_name VARCHAR(64) NULL,
  trait_scores JSON NOT NULL,
  feature_summary JSON NOT NULL,
  ai_summary TEXT NULL,
  ai_detail TEXT NULL,
  analyzed_at DATETIME(3) NOT NULL,
  expires_at DATETIME(3) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  KEY ix_pas_account_analyzed (riot_account_id, analyzed_at),
  KEY ix_pas_type (ggti_type),
  CONSTRAINT fk_pas_account FOREIGN KEY (riot_account_id) REFERENCES riot_accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE ai_analysis_cache (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  snapshot_id BIGINT UNSIGNED NOT NULL,
  prompt_hash CHAR(64) NOT NULL,
  model VARCHAR(64) NOT NULL,
  purpose VARCHAR(32) NOT NULL,
  prompt_json JSON NOT NULL,
  response_json JSON NOT NULL,
  content TEXT NOT NULL,
  latency_ms INT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_ai_cache_prompt (prompt_hash, model, purpose),
  KEY ix_ai_cache_snapshot (snapshot_id),
  CONSTRAINT fk_ai_cache_snapshot FOREIGN KEY (snapshot_id) REFERENCES player_analysis_snapshots(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE share_cards (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  snapshot_id BIGINT UNSIGNED NOT NULL,
  share_key VARCHAR(64) NOT NULL,
  title VARCHAR(120) NOT NULL,
  description VARCHAR(255) NOT NULL,
  image_url VARCHAR(500) NULL,
  view_count BIGINT NOT NULL DEFAULT 0,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_share_cards_key (share_key),
  KEY ix_share_cards_snapshot (snapshot_id),
  CONSTRAINT fk_share_cards_snapshot FOREIGN KEY (snapshot_id) REFERENCES player_analysis_snapshots(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE duo_analysis_results (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  player_a_account_id BIGINT UNSIGNED NOT NULL,
  player_b_account_id BIGINT UNSIGNED NOT NULL,
  player_a_snapshot_id BIGINT UNSIGNED NULL,
  player_b_snapshot_id BIGINT UNSIGNED NULL,
  compatibility_score INT NOT NULL,
  relation_type VARCHAR(64) NOT NULL,
  trait_diff_json JSON NOT NULL,
  ai_summary TEXT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  KEY ix_duo_players (player_a_account_id, player_b_account_id, created_at),
  CONSTRAINT fk_duo_player_a FOREIGN KEY (player_a_account_id) REFERENCES riot_accounts(id),
  CONSTRAINT fk_duo_player_b FOREIGN KEY (player_b_account_id) REFERENCES riot_accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 운영/배치 테이블

```sql
CREATE TABLE analysis_requests (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  riot_account_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(32) NOT NULL,
  requested_match_count INT NOT NULL DEFAULT 20,
  error_code VARCHAR(64) NULL,
  error_message VARCHAR(500) NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  started_at DATETIME(3) NULL,
  finished_at DATETIME(3) NULL,
  PRIMARY KEY (id),
  KEY ix_analysis_requests_status (status, created_at),
  KEY ix_analysis_requests_account (riot_account_id, created_at),
  CONSTRAINT fk_ar_account FOREIGN KEY (riot_account_id) REFERENCES riot_accounts(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE riot_api_logs (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  endpoint VARCHAR(128) NOT NULL,
  region VARCHAR(16) NULL,
  status_code INT NOT NULL,
  rate_limit_type VARCHAR(32) NULL,
  retry_after_sec INT NULL,
  latency_ms INT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  KEY ix_riot_logs_endpoint_time (endpoint, created_at),
  KEY ix_riot_logs_status_time (status_code, created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE batch_jobs (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  job_name VARCHAR(100) NOT NULL,
  enabled TINYINT(1) NOT NULL DEFAULT 1,
  lock_key VARCHAR(128) NOT NULL,
  created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  PRIMARY KEY (id),
  UNIQUE KEY uk_batch_jobs_name (job_name),
  UNIQUE KEY uk_batch_jobs_lock (lock_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE batch_job_runs (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  job_id BIGINT UNSIGNED NOT NULL,
  status VARCHAR(32) NOT NULL,
  processed_count INT NOT NULL DEFAULT 0,
  failed_count INT NOT NULL DEFAULT 0,
  error_message TEXT NULL,
  started_at DATETIME(3) NOT NULL,
  finished_at DATETIME(3) NULL,
  PRIMARY KEY (id),
  KEY ix_batch_runs_job_started (job_id, started_at),
  CONSTRAINT fk_batch_runs_job FOREIGN KEY (job_id) REFERENCES batch_jobs(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```

### 인덱스 전략

- Match 중복 방지: `matches.match_id` unique
- 사용자별 최근 경기 조회: `participant_matches(riot_account_id, match_id)` 및 `player_analysis_snapshots(riot_account_id, analyzed_at)`
- 공유 페이지 조회: `share_cards.share_key` unique
- AI 재생성 방지: `ai_analysis_cache(prompt_hash, model, purpose)` unique
- 배치 폴링: `analysis_requests(status, created_at)`
- Riot API 장애 분석: `riot_api_logs(endpoint, created_at)`, `riot_api_logs(status_code, created_at)`

## 6. API 명세

### 공통 응답

```ts
type ApiResponse<T> = {
  success: boolean;
  data: T | null;
  error: {
    code: string;
    message: string;
    detail?: unknown;
  } | null;
  meta?: {
    requestId: string;
    pagination?: {
      page: number;
      limit: number;
      total: number;
    };
  };
};
```

### Error Code

- `VALIDATION_ERROR`
- `RIOT_ACCOUNT_NOT_FOUND`
- `RIOT_RATE_LIMITED`
- `MATCH_COLLECT_PENDING`
- `ANALYSIS_NOT_READY`
- `AI_GATEWAY_TIMEOUT`
- `AI_GATEWAY_UNAUTHORIZED`
- `INTERNAL_ERROR`

### Riot 사용자 조회

```http
POST /api/v1/riot/search
Content-Type: application/json

{
  "gameName": "Hide on bush",
  "tagLine": "KR1",
  "region": "KR"
}
```

응답:

```json
{
  "success": true,
  "data": {
    "puuid": "encrypted-puuid",
    "gameName": "Hide on bush",
    "tagLine": "KR1",
    "summoner": {
      "summonerId": "encrypted-summoner-id",
      "profileIconId": 1,
      "summonerLevel": 100
    }
  },
  "error": null
}
```

DTO:

```ts
export class SearchRiotUserDto {
  @IsString()
  @Length(2, 64)
  gameName: string;

  @IsString()
  @Length(2, 16)
  tagLine: string;

  @IsIn(['KR', 'NA1', 'EUW1', 'EUN1', 'JP1'])
  region: string;
}
```

### 분석 요청

```http
POST /api/v1/analysis

{
  "gameName": "Hide on bush",
  "tagLine": "KR1",
  "region": "KR",
  "matchCount": 20
}
```

응답:

```json
{
  "success": true,
  "data": {
    "requestId": 1001,
    "status": "PENDING",
    "latestSnapshotId": null
  },
  "error": null
}
```

### 분석 결과 조회

```http
GET /api/v1/analysis/snapshots/:snapshotId
```

응답:

```json
{
  "success": true,
  "data": {
    "snapshotId": 501,
    "ggtiType": "ROAM-CARRY",
    "sampleMatchCount": 20,
    "primaryChampionName": "Ahri",
    "traitScores": {
      "aggression": 78,
      "stability": 52,
      "roaming": 84,
      "vision": 61,
      "objective": 70,
      "growth": 66,
      "fightJoin": 81
    },
    "aiSummary": "초반 합류와 교전 설계가 강한 로밍형 캐리 플레이어입니다.",
    "aiDetail": "최근 경기에서 라인 고정 성장보다 교전 타이밍에 먼저 움직이는 경향이 강합니다.",
    "shareKey": "g_8F3aBc"
  },
  "error": null
}
```

### 요청 상태 조회

```http
GET /api/v1/analysis/requests/:requestId
```

상태:

- `PENDING`
- `COLLECTING_MATCHES`
- `COLLECTING_TIMELINES`
- `CALCULATING`
- `GENERATING_AI`
- `DONE`
- `FAILED`

### 듀오 분석

```http
POST /api/v1/duo

{
  "playerA": {
    "gameName": "Hide on bush",
    "tagLine": "KR1",
    "region": "KR"
  },
  "playerB": {
    "gameName": "Duo Player",
    "tagLine": "KR1",
    "region": "KR"
  }
}
```

응답:

```json
{
  "success": true,
  "data": {
    "compatibilityScore": 82,
    "relationType": "교전 설계자 + 마무리 캐리",
    "summary": "한 명이 먼저 각을 만들고 다른 한 명이 성장 후 마무리하는 조합입니다.",
    "traitDiff": {
      "aggressionGap": 12,
      "roamingGap": 4,
      "objectiveGap": 9
    }
  },
  "error": null
}
```

### 공유 페이지 데이터

```http
GET /api/v1/share/:shareKey
```

### Pagination 규칙

- query: `page`, `limit`
- `limit` 최대 100
- 정렬은 화이트리스트 기반: `createdAt`, `analyzedAt`

## 7. Batch 구조

### Cron Job

| Job | 주기 | 역할 |
| --- | --- | --- |
| `match-collect.job` | 1분 | `analysis_requests` PENDING 요청 처리 |
| `timeline-collect.job` | 1분 | Timeline 미수집 match 처리 |
| `feature-aggregate.job` | 3분 | Timeline feature 계산 |
| `ggti-analysis.job` | 3분 | GGTI 점수 및 유형 생성 |
| `ai-cache.job` | 5분 | AI 설명 미생성 snapshot 처리 |
| `stale-request-cleanup.job` | 10분 | 오래된 실패/중단 요청 정리 |

### 중복 실행 방지

Redis를 사용하지 않으므로 MySQL advisory lock을 사용한다.

```sql
SELECT GET_LOCK('ggti:job:match-collect', 0);
SELECT RELEASE_LOCK('ggti:job:match-collect');
```

NestJS 구현 방향:

```ts
async runWithLock(lockKey: string, task: () => Promise<void>) {
  const locked = await this.mysqlLockService.acquire(lockKey, 0);
  if (!locked) return;

  try {
    await task();
  } finally {
    await this.mysqlLockService.release(lockKey);
  }
}
```

### Match 수집 정책

- 기본 최근 20경기
- MVP에서는 ranked solo/duo, ranked flex, normal draft를 우선 지원
- `matches.match_id` unique로 중복 저장 방지
- 이미 저장된 match는 raw_json 재요청하지 않음
- `participant_matches`는 match 저장 직후 upsert

### Riot Rate Limit 대응

- HTTP 429 발생 시 `Retry-After`를 읽고 job run을 조기 종료
- endpoint/region 단위 로그 저장
- batch는 한 번에 너무 많은 요청을 하지 않고 request 단위 chunk 처리
- 사용자 실시간 요청은 수집 예약만 만들고 heavy collection은 batch가 처리

## 8. AI Prompt 구조

AI는 임의 문구 생성기가 아니라 feature 기반 설명 생성기로 제한한다. 프롬프트에는 정규화된 점수, 최근 경기 요약, 주요 챔피언, 행동 이벤트를 넣고 출력 형식을 JSON으로 고정한다.

### System Prompt

```text
너는 리그오브레전드 플레이 분석 AI다.
사용자의 실제 Match/Timeline 기반 feature만 근거로 분석한다.
없는 사실을 만들지 않는다.
표현은 공유 가능한 MBTI 테스트 결과처럼 간결하고 인간적으로 작성한다.
비난, 욕설, 과도한 단정은 금지한다.
반드시 지정된 JSON 형식으로만 응답한다.
```

### User Prompt 구조

```json
{
  "task": "generate_player_style_analysis",
  "locale": "ko-KR",
  "modelPolicy": {
    "doNotInvent": true,
    "useOnlyProvidedFeatures": true,
    "tone": "game_style_card"
  },
  "player": {
    "gameName": "Hide on bush",
    "region": "KR",
    "sampleMatchCount": 20,
    "primaryChampions": ["Ahri", "LeBlanc", "Sylas"]
  },
  "ggti": {
    "type": "ROAM-CARRY",
    "traitScores": {
      "aggression": 78,
      "stability": 52,
      "roaming": 84,
      "vision": 61,
      "objective": 70,
      "growth": 66,
      "fightJoin": 81
    }
  },
  "matchBehaviorSummary": {
    "earlyDeathsAvg": 0.35,
    "roamEventsAvg": 4.2,
    "objectiveJoinRate": 0.71,
    "teamfightJoinRate": 0.81,
    "goldDiff15Avg": 240,
    "soloDeathRate": 0.18
  },
  "outputFormat": {
    "summary": "string, max 80 chars",
    "detail": "string, 2-4 sentences",
    "strengths": ["string", "max 3 items"],
    "habits": ["string", "max 3 items"],
    "duoTip": "string, max 120 chars",
    "cardTitle": "string, max 32 chars",
    "cardSubtitle": "string, max 60 chars"
  }
}
```

### AI 응답 JSON

```json
{
  "summary": "초반 합류로 판을 흔드는 로밍형 캐리입니다.",
  "detail": "최근 경기에서 라인에 오래 머무르기보다 교전 타이밍에 먼저 움직이는 경향이 강합니다. 오브젝트 전 합류율이 높아 팀의 흐름을 앞당기는 장점이 있습니다.",
  "strengths": ["빠른 합류", "교전 참여", "오브젝트 전환"],
  "habits": ["불리한 시야에서 먼저 들어가는 장면이 있음", "성장보다 합류를 우선하는 편"],
  "duoTip": "라인을 안정적으로 받아줄 성장형 듀오와 잘 맞습니다.",
  "cardTitle": "로밍 설계자",
  "cardSubtitle": "먼저 움직여 게임의 박자를 바꾸는 타입"
}
```

## 9. vLLM Gateway 연동 구조

### 환경변수

backend와 batch 모두 동일한 환경변수를 사용한다.

```env
VLLM_BASE_URL=
VLLM_API_KEY=
VLLM_SECRET_KEY=
VLLM_ACCESS_TOKEN=
VLLM_REFRESH_TOKEN=
VLLM_MODEL=qwen2.5-32b-gptq-int4
VLLM_TIMEOUT_MS=20000
```

### Endpoint

- `POST /api/external/llm/token`
- `POST /api/external/llm/refresh`
- `POST /api/external/llm/revoke`
- `POST /api/external/llm/v1/chat/completions`
- `POST /api/external/llm/v1/completions`

### VllmAuthService

```ts
type VllmTokenSet = {
  accessToken: string;
  refreshToken: string;
  expiresAt?: Date;
};

@Injectable()
export class VllmAuthService {
  private tokenSet: VllmTokenSet | null = null;

  async getAccessToken(): Promise<string> {
    if (this.tokenSet?.accessToken && !this.isExpiringSoon(this.tokenSet)) {
      return this.tokenSet.accessToken;
    }

    return this.issueToken();
  }

  async issueToken(): Promise<string> {
    const res = await this.http.post('/api/external/llm/token', {
      api_key: this.config.vllm.apiKey,
      secret_key: this.config.vllm.secretKey,
    });

    this.tokenSet = {
      accessToken: res.data.access_token,
      refreshToken: res.data.refresh_token,
      expiresAt: this.resolveExpiresAt(res.data.expires_in),
    };

    return this.tokenSet.accessToken;
  }

  async refreshToken(): Promise<string> {
    if (!this.tokenSet?.refreshToken) {
      return this.issueToken();
    }

    const res = await this.http.post('/api/external/llm/refresh', {
      refresh_token: this.tokenSet.refreshToken,
    });

    this.tokenSet = {
      accessToken: res.data.access_token,
      refreshToken: res.data.refresh_token ?? this.tokenSet.refreshToken,
      expiresAt: this.resolveExpiresAt(res.data.expires_in),
    };

    return this.tokenSet.accessToken;
  }
}
```

### VllmClientService

```ts
@Injectable()
export class VllmClientService {
  async chat(messages: Array<{ role: string; content: string }>) {
    const token = await this.auth.getAccessToken();

    try {
      return await this.requestChat(token, messages);
    } catch (error) {
      if (this.isUnauthorized(error)) {
        const refreshed = await this.auth.refreshToken();
        return this.requestChat(refreshed, messages);
      }

      throw error;
    }
  }

  private async requestChat(accessToken: string, messages: ChatMessage[]) {
    return this.http.post(
      '/api/external/llm/v1/chat/completions',
      {
        model: 'qwen2.5-32b-gptq-int4',
        messages,
        temperature: 0.2,
      },
      {
        headers: { Authorization: `Bearer ${accessToken}` },
        timeout: 20000,
      },
    );
  }
}
```

## 10. Access Token 갱신 구조

### 기본 원칙

- 서버 시작 시 `.env`의 `VLLM_ACCESS_TOKEN`, `VLLM_REFRESH_TOKEN`을 초기값으로 사용할 수 있다.
- 단, 운영 중 토큰은 메모리에서 관리하고 401 발생 시 refresh한다.
- refresh 실패 시 api_key/secret_key로 token을 재발급한다.
- 여러 요청이 동시에 401을 만나면 refresh storm이 발생할 수 있으므로 in-flight promise로 단일 refresh만 수행한다.

### 갱신 플로우

```mermaid
sequenceDiagram
  participant S as Service
  participant A as VllmAuthService
  participant V as vLLM Gateway

  S->>A: getAccessToken()
  A-->>S: accessToken
  S->>V: chat completion
  V-->>S: 401
  S->>A: refreshToken()
  A->>V: POST /refresh
  alt refresh success
    V-->>A: new token
    A-->>S: accessToken
    S->>V: retry once
  else refresh failed
    A->>V: POST /token
    V-->>A: new token
    A-->>S: accessToken
    S->>V: retry once
  end
```

### Retry 정책

- 401: refresh 후 1회 재시도
- 408/429/5xx: exponential backoff로 최대 2회
- timeout: batch는 재시도, backend 실시간 요청은 빠르게 fallback
- AI 실패 시 feature 기반 기본 문구를 반환하고 `ai_analysis_cache`는 생성하지 않음

## 11. GitHub Actions 자동배포 설정

각 repo에 `.github/workflows/deploy.yml`을 둔다. Docker는 사용하지 않는다.

### frontend deploy.yml

```yaml
name: Deploy Frontend

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            set -e
            cd /var/www/ggti/frontend
            git pull origin main
            npm install
            npm run build
            pm2 restart ggti-front
            pm2 save
```

### backend deploy.yml

```yaml
name: Deploy Backend

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            set -e
            cd /var/www/ggti/backend
            git pull origin main
            npm install
            npm run build
            npm run db:migrate
            pm2 restart ggti-back
            pm2 save
```

### batch deploy.yml

```yaml
name: Deploy Batch

on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Deploy via SSH
        uses: appleboy/ssh-action@v1.0.3
        with:
          host: ${{ secrets.SERVER_HOST }}
          username: ${{ secrets.SERVER_USER }}
          key: ${{ secrets.SERVER_SSH_KEY }}
          port: ${{ secrets.SERVER_PORT }}
          script: |
            set -e
            cd /var/www/ggti/batch
            git pull origin main
            npm install
            npm run build
            pm2 restart ggti-batch
            pm2 save
```

## 12. PM2 ecosystem.config.js

### frontend

```js
module.exports = {
  apps: [
    {
      name: 'ggti-front',
      cwd: '/var/www/ggti/frontend',
      script: 'node_modules/next/dist/bin/next',
      args: 'start -p 3000',
      instances: 1,
      exec_mode: 'fork',
      env: {
        NODE_ENV: 'production',
      },
      max_memory_restart: '512M',
    },
  ],
};
```

### backend

```js
module.exports = {
  apps: [
    {
      name: 'ggti-back',
      cwd: '/var/www/ggti/backend',
      script: 'dist/main.js',
      instances: 1,
      exec_mode: 'fork',
      env: {
        NODE_ENV: 'production',
        PORT: 4000,
      },
      max_memory_restart: '768M',
    },
  ],
};
```

### batch

```js
module.exports = {
  apps: [
    {
      name: 'ggti-batch',
      cwd: '/var/www/ggti/batch',
      script: 'dist/main.js',
      instances: 1,
      exec_mode: 'fork',
      env: {
        NODE_ENV: 'production',
      },
      max_memory_restart: '768M',
    },
  ],
};
```

## 13. Nginx 설정 예시

```nginx
server {
  listen 80;
  server_name ggti.example.com www.ggti.example.com;

  location /.well-known/acme-challenge/ {
    root /var/www/certbot;
  }

  location / {
    return 301 https://$host$request_uri;
  }
}

server {
  listen 443 ssl http2;
  server_name ggti.example.com www.ggti.example.com;

  ssl_certificate /etc/letsencrypt/live/ggti.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/ggti.example.com/privkey.pem;

  gzip on;
  gzip_comp_level 5;
  gzip_min_length 1024;
  gzip_types text/plain text/css application/json application/javascript application/xml image/svg+xml;

  client_max_body_size 2m;

  location /api/ {
    proxy_pass http://127.0.0.1:4000/api/;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 60s;
    proxy_send_timeout 60s;
  }

  location /_next/static/ {
    proxy_pass http://127.0.0.1:3000;
    proxy_cache_valid 200 30d;
    add_header Cache-Control "public, max-age=31536000, immutable";
  }

  location ~* \.(png|jpg|jpeg|gif|webp|svg|ico|css|js)$ {
    proxy_pass http://127.0.0.1:3000;
    add_header Cache-Control "public, max-age=604800";
  }

  location / {
    proxy_pass http://127.0.0.1:3000;
    proxy_http_version 1.1;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 60s;
  }
}
```

## 14. SEO 전략

### 핵심 키워드

- 롤 성향 테스트
- GGTI
- 듀오 궁합 테스트
- 롤 플레이 유형
- 리그오브레전드 성향 분석

### 페이지별 전략

- 홈: "롤 성향 테스트 GGTI" 중심 랜딩
- 결과 페이지: noindex 또는 제한적 index. 개인 데이터 페이지는 공유 의도가 있을 때만 index 고려
- 공유 페이지: OpenGraph 최적화, 짧은 설명, 대표 유형 키워드 포함
- 듀오 분석 페이지: "듀오 궁합 테스트" 키워드
- 시즌 회고 페이지: "롤 시즌 회고", "내 플레이 스타일" 키워드

### Next.js 구현

- `app/sitemap.ts`: 정적 페이지 + 공개 공유 카드 일부
- `app/robots.ts`: API, 내부 결과 polling 경로 차단
- Dynamic Metadata:
  - `title`: `${ggtiType} | ${gameName}님의 GGTI 결과`
  - `description`: AI summary 또는 card subtitle
  - `openGraph.images`: `/api/og/[shareKey]`
- Twitter Card: `summary_large_image`
- canonical URL 통일

## 15. AdSense 전략

### 위치

- 결과 페이지: 분석 카드 하단 1개, 상세 분석 중간 1개
- 상세 페이지: 챔피언/습관 섹션 사이 1개
- 공유 페이지: UX 방해 최소화를 위해 하단 1개

### 원칙

- 첫 화면 핵심 결과 위에는 광고 배치하지 않음
- CLS 방지를 위해 광고 영역 높이 예약
- 모바일에서는 카드 사이 광고 간격 확보
- 분석 생성 중 로딩 화면에는 광고를 과도하게 넣지 않음

### 컴포넌트

```tsx
export function AdSenseSlot({ slot }: { slot: string }) {
  if (process.env.NEXT_PUBLIC_ADSENSE_ENABLED !== 'true') return null;

  return (
    <ins
      className="adsbygoogle block min-h-[120px]"
      data-ad-client={process.env.NEXT_PUBLIC_ADSENSE_CLIENT}
      data-ad-slot={slot}
      data-ad-format="auto"
      data-full-width-responsive="true"
    />
  );
}
```

## 16. MVP 우선순위

### Phase 1: 운영 가능한 핵심 검색/분석

- Riot ID + Tag 검색
- Account/Summoner 저장
- 최근 20경기 match 수집
- participant stat 저장
- 기본 GGTI 점수 계산
- 결과 페이지 표시

### Phase 2: Timeline 기반 성향 고도화

- Timeline 원본 저장
- death/roam/objective/teamfight feature 계산
- GGTI 유형 확정 로직 개선
- 대표 챔피언/라인 분석

### Phase 3: AI 설명과 공유 UX

- vLLM Gateway 토큰 발급/갱신
- AI prompt builder
- AI cache 저장
- 공유 카드 및 OpenGraph 이미지
- SEO sitemap/robots

### Phase 4: 듀오 궁합

- 두 사용자 분석 snapshot 비교
- 궁합 점수 계산
- AI 듀오 설명 생성
- 공유 가능한 듀오 결과 페이지

### Phase 5: 운영 안정화

- Rate limit 제어
- batch job 모니터링
- 장애 로그/재처리
- GitHub Actions 자동배포
- Nginx SSL 운영

## 17. 향후 확장성 전략

### 다중 게임 확장

현재 LoL 전용이지만 `games` 테이블과 repository 경계는 다중 게임을 전제로 한다.

- `games.code`: `LOL`, `VALORANT`, `TFT` 등
- account 계층은 게임별 provider account로 확장
- match/timeline은 공통 추상 모델 + 게임별 raw JSON 보존
- feature extractor는 게임별 strategy로 분리

```ts
export interface GameFeatureExtractor {
  gameCode: string;
  extractMatchFeatures(input: RawMatchInput): Promise<FeatureResult>;
  calculateTraits(features: FeatureResult[]): Promise<TraitScores>;
}
```

### AI 확장

- model name은 env로 분리
- prompt version을 `ai_analysis_cache.prompt_json`에 저장
- prompt hash로 재생성 비용 절감
- AI 실패 시 rule-based fallback 유지

### 데이터 확장

- 대량 데이터 증가 시 `matches`는 `game_start_at` 기준 월 단위 파티셔닝 검토
- `match_timelines.timeline_json`은 장기적으로 별도 archive DB 또는 파일 스토리지로 분리 가능
- 현재 Redis를 쓰지 않으므로 hot query는 summary table로 해결

### 운영 확장

- PM2 cluster는 backend에만 단계적으로 적용 가능
- batch는 중복 실행 위험 때문에 기본 1 instance 유지
- 서버 증설 시 MySQL advisory lock으로 batch 중복 방지 유지
- Nginx upstream으로 frontend/backend 포트 분리 확장 가능

## GGTI 성향 계산 기준

### Trait

| Trait | 설명 | 주요 feature |
| --- | --- | --- |
| aggression | 공격성 | damage, kill participation, fight join, early skirmish |
| stability | 안정성 | death count inverse, solo death inverse, gold diff stability |
| roaming | 로밍 | lane departure, side/objective movement, assist timing |
| vision | 시야 기여 | vision score, wards placed/killed, control wards |
| objective | 오브젝트 집중도 | dragon/baron/herald join, objective-near events |
| growth | 성장 성향 | cs, gold, xp diff, farming consistency |
| fightJoin | 교전 참여율 | teamfight proximity, KP, assist distribution |

### Type Mapping

| Type | 조건 예시 |
| --- | --- |
| ROAM | roaming >= 75 |
| FARM | growth >= 75 and fightJoin < 65 |
| SHOT | objective >= 75 and vision >= 60 |
| SAFE | stability >= 75 and aggression < 65 |
| LIMIT | aggression >= 80 and stability < 55 |
| CARRY | aggression >= 70 and growth >= 70 |
| BAIT | teamfightJoin >= 70 and damageTaken high |
| TILT | soloDeath high and stability < 45 |

복합 유형은 상위 2개 trait/type을 결합한다. 예: `ROAM-CARRY`, `SAFE-SHOT`.

## 운영 환경변수

### frontend

```env
NEXT_PUBLIC_API_BASE_URL=https://ggti.example.com/api
NEXT_PUBLIC_SITE_URL=https://ggti.example.com
NEXT_PUBLIC_ADSENSE_ENABLED=false
NEXT_PUBLIC_ADSENSE_CLIENT=
```

### backend

```env
NODE_ENV=production
PORT=4000
DATABASE_URL=mysql://user:password@127.0.0.1:3306/ggti
RIOT_API_KEY=
RIOT_REGION_DEFAULT=KR
VLLM_BASE_URL=
VLLM_API_KEY=
VLLM_SECRET_KEY=
VLLM_ACCESS_TOKEN=
VLLM_REFRESH_TOKEN=
VLLM_MODEL=qwen2.5-32b-gptq-int4
VLLM_TIMEOUT_MS=20000
```

### batch

```env
NODE_ENV=production
DATABASE_URL=mysql://user:password@127.0.0.1:3306/ggti
RIOT_API_KEY=
VLLM_BASE_URL=
VLLM_API_KEY=
VLLM_SECRET_KEY=
VLLM_ACCESS_TOKEN=
VLLM_REFRESH_TOKEN=
VLLM_MODEL=qwen2.5-32b-gptq-int4
VLLM_TIMEOUT_MS=30000
BATCH_MATCH_COLLECT_LIMIT=20
BATCH_TIMELINE_COLLECT_LIMIT=20
```

## 구현 시 기술 선택

- ORM: Prisma 또는 TypeORM 중 하나 선택. NestJS + MySQL 운영성과 migration 명확성을 위해 Prisma 권장
- Validation: `class-validator`, `class-transformer`
- HTTP: NestJS `HttpModule` + axios timeout/retry wrapper
- Cron: `@nestjs/schedule`
- Logging: JSON logger. 최소 `requestId`, `jobRunId`, `riotEndpoint`, `latencyMs` 포함
- 테스트: service unit test + e2e smoke test

## 초기 개발 순서

1. `ggti-backend` NestJS 프로젝트 생성, DB schema/migration 작성
2. Riot Account/Summoner/Match API client 구현
3. `ggti-batch` Match 수집 job 구현
4. Match participant 기반 1차 GGTI 점수 계산
5. `ggti-frontend` 검색/결과 UI 구현
6. Timeline 수집 및 feature extractor 구현
7. vLLM Gateway auth/client/prompt/cache 구현
8. 공유 페이지/OG/SEO 구현
9. GitHub Actions/PM2/Nginx 운영 배포 적용

