---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T10:43:07+09:00
concerns:
  - "TAG_MANAGE 신규 권한키 vs 기존 CONTENT_MODERATE 재사용: §5.2/§13 에서 CONTENT_MODERATE 재사용으로 확정했으나, 향후 태그 전담 운영자 분리 요구가 생기면 PermissionKeys 에 TAG_MANAGE 추가 필요 — 구현 시 권한 게이트 호출부가 단일 상수 참조인지 재확인."
  - "searchVisibleGames 확장 시그니처(SearchCriteria): 파라미터(keyword, creator, tags, tagMode, tagCount, sort, jamSlug)가 구현에서 전부 실제 사용되는지 재확인 필요 — 특히 jamSlug 는 잼 검색 경로에서만 사용되므로 단일 진입점 통합 시 null 분기가 dead path 가 되지 않는지, tagCount 는 AND 모드에서만 참조되므로 OR/미선택 시 unused 가 되지 않는지 점검."
  - "신규 매퍼 메서드 시그니처 inflate 점검: GameTagsMapper.listGameIdsByTags(tagSlugs, tagMode, tagCount), GameViewsMapper.existsRecentView(gameId, viewerKey, sinceTs) 의 인자가 구현에서 전부 사용되는지 재확인 — tagMode 분기를 매퍼 내부 <choose> 로 흡수하면 tagCount 가 OR 경로에서 dead 가 됨. 매퍼를 AND/OR 두 메서드로 분리하는 편이 dead parameter 를 줄일 수 있음(구현 advisor 판단)."
  - "검색 결과 행 매핑 타입(SearchRow vs GameData 확장): §8 에서 GameData 에 viewCount/avgRating/reviewCount 필드 추가 방향을 제안하나, 기존 GameData 소비처(목록·상세 JSP)에서 신규 필드가 null 로 남는 경로가 생기면 NPE/표시 누락 위험 — 구현 시 GameData 확장 vs 전용 SearchRow 중 소비처 영향 최소안으로 확정."
concerns_checked: true
references:
  requirements: .atp/work-session/20260623-104307/requirements.md
  research: null
  adrs:
    - docs/development/agent-team-protocol.md
    - docs/rbac-ddl.sql
---

# 설계: W3-1 게임 태그 + 검색 확장

## ① 목표 / 비목표

### 목표 (FR)
- **FR-1 태그 도메인 도입**: 게임·잼에 부착 가능한 통합 태그 체계를 제공한다. 운영자 사전정의 태그(활성)와 사용자 생성 태그(pending→승인) 하이브리드를 지원한다.
- **FR-2 검색 확장**: 기존 `searchVisibleGames`(이름/제작자/노트 ILIKE) 를 ① 태그 다중 필터(AND/OR) ② 제작자(개발자) 검색 ③ 정렬키(최신/좋아요/방문수/리뷰수/평점/태그일치도) 로 확장한다.
- **FR-3 방문수 정렬**: 방문 로그(`game_views`) + 비정규화 카운터(`games.view_count`) 기반 방문수 정렬을 제공한다. 동일 viewer 24h 윈도 dedupe.
- **FR-4 리뷰 정렬**: `game_review_stats` VIEW 를 LEFT JOIN 하여 리뷰수(`review_count`)·평점(`avg_rating`) 정렬을 제공한다.
- **FR-5 태그 관리 API**: 태그 생성(사용자/운영자)·승인·비활성 API 를 권한 게이트 + CSRF 로 보호한다.
- **FR-6 잼 태그 검색 라우트**: 진행중 잼 배너에서 링크할 단일 검색 라우트(잼 출품작 한정)를 단일 출처로 확정한다. (W3-4 가 이 계약을 채택)

### 목표 (NFR)
- **NFR-1 정규화**: 통합 `tags` 1테이블 + 용도구분(`tag_type`) + 조인 테이블(`game_tags`/`jam_tags`)로 N:M 정규화. 다대다 중복 방지 UNIQUE.
- **NFR-2 멱등 DDL**: `docs/tag-ddl.sql` 신규 — `CREATE TABLE IF NOT EXISTS` / `CREATE UNIQUE INDEX IF NOT EXISTS` / `ALTER ... ADD COLUMN IF NOT EXISTS` / `DO $$` guard. `apply-local-ddl.sh` 알파벳순·`search_path=dev` 멱등 적용. `schema.sql` 동기 사본 갱신.
- **NFR-3 보안**: 태그 입력 sanitize(XSS/주입) + 금칙어 필터 + 길이/화이트리스트 제약. 검색어는 `#{}` 바인딩 + ILIKE 파라미터화(`${}` 금지). 태그 관리 권한 게이트 + CSRF.
- **NFR-4 확장성**: 태그 타입(`GAME`/`JAM`/`COMMON`)·정렬키·검색 모드를 enum/CHECK 로 모델링하여 후속 도메인(post 등) 확장 시 스키마 변경 최소화.

### 비목표
- 태그 자동 추천/연관 태그 그래프 (추후 별도 워크).
- 검색 형태소 분석·전문(full-text) 검색 엔진 도입 — 본 워크는 ILIKE + 인덱스 범위. (검색량 증가 시 별도 ADR)
- 태그별 통계 대시보드 / 트렌딩 태그 — 비목표.
- 잼 도메인 자체 구현(W2-1 소관). 본 설계는 `jams`/`jam_entries` 를 **참조만** 한다.
- post(게시판) 태그 — `tag_type='COMMON'`/`'POST'` 확장 여지만 두고 본 워크 스코프 외.

## ② 개요

현재 게임 검색은 `GamesMapper.searchVisibleGames`(GamesMapper.java:85-87)가 `name`·`users.display_name`·`creator_note` 3컬럼에 ILIKE 부분일치를 거는 단일 자유텍스트 검색이다. 정렬은 목록 기본(`getVisibleGames`, GamesMapper.java:62)의 `sort_order ASC, created_at DESC, id DESC` 고정이며, 사용자가 좋아요·방문수·리뷰·평점 기준으로 탐색할 수단이 없다. `games` 테이블에는 태그·방문수 컬럼이 존재하지 않는다.

이 설계는 (a) 통합 태그 도메인을 신규 도입하고, (b) 검색을 태그 필터 + 제작자 검색 + 다중 정렬키로 확장하며, (c) 방문수 집계 인프라(`game_views` 로그 + `games.view_count` 비정규화)와 (d) 리뷰 정렬(`game_review_stats` VIEW 소비)을 연결한다. 또한 인접 워크 W2-1(잼) 및 W3-4(진행중 잼 배너)와의 계약으로 **잼 태그 검색 라우트를 단일 출처로 확정**한다.

설계 정석 기준: 정규화(통합 1테이블 + 조인) · 확장성(타입/정렬키 enum 화) · 보안(입력 sanitize, 파라미터 바인딩, 권한 게이트) 우선. 단순 카운터 단축이나 태그 문자열 컬럼 같은 비정규화 over-engineering 회피는 명시적으로 배제(아래 ③ 핵심결정 근거).

## ③ 핵심 결정 요약표

| # | 결정 | 채택안 | 근거 | 기각안 |
|---|---|---|---|---|
| D1 | 태그 저장 모델 | 통합 `tags` 1테이블 + `tag_type` 구분 + `game_tags`/`jam_tags` 조인 | 정규화·N:M·도메인 확장 시 스키마 안정. 태그명 검색/관리 단일 출처 | 도메인별 태그 테이블 분리(중복·관리 분산), `games.tags` 텍스트 컬럼(검색·정규화 불가) |
| D2 | 태그 생성 정책 | 운영자 사전정의(`is_active=true`) + 사용자 생성(pending→승인) 하이브리드 | 초기 카탈로그 보장 + 사용자 확장. 무분별 태그 난립을 승인 흐름으로 차단 | 운영자 전용(확장성↓), 무승인 자유생성(스팸·중복·XSS 위험) |
| D3 | 태그 입력 검증 | 길이 2~20 + 화이트리스트(한/영/숫자/하이픈) + 금칙어 사전 + sanitize | XSS/주입 차단, 표기 정규화(slug). 금칙어 출처는 §아래 명시 | 자유 입력(보안·정규화 실패) |
| D4 | 태그 관리 권한 | 기존 `CONTENT_MODERATE` 재사용 (PermissionGate.has) | W1 권한 인프라 재사용, 신규 키 도입 최소화. 전담 분리 요구 시 TAG_MANAGE 추가(concern 기록) | 신규 `TAG_MANAGE` 키 즉시 도입(인프라 변경 비용↑, 현 시점 불필요) |
| D5 | 방문수 집계 | `game_views` 로그(viewer_key, 24h dedupe) + `games.view_count` 비정규화 카운터 | dedupe 가능·감사 추적 가능. 정렬은 비정규화 컬럼으로 O(1) | 단순 `view_count++`(중복·어뷰징·감사불가), 로그 only(정렬 시 매번 COUNT 집계 비용) |
| D6 | 리뷰 정렬 소스 | `game_review_stats` VIEW LEFT JOIN | 읽기전용 집계 VIEW 재사용, 6축은 표시전용·정렬은 `avg_rating`/`review_count` 단일 | 매 쿼리 reviews 재집계(중복·성능) |
| D7 | 다중 태그 모드 | 쿼리 파라미터 `tagMode=and\|or` (기본 and) | 사용자가 교집합/합집합 선택. AND=전부 보유, OR=하나라도 보유 | 고정 AND(유연성↓) |
| D8 | NULL 정렬 | 평점/리뷰수 NULL `NULLS LAST` | 미평가 게임이 상위 점유 방지 | 기본 NULLS FIRST(UX 저해) |
| D9 | 잼 태그 검색 라우트 | `GET /games/search?jam={jamSlug}` 파라미터 통합(전용 경로 아님) | 단일 검색 진입점 유지, `jam_entries` 조인으로 출품작 한정. W3-4 가 이 계약 채택 | 전용 경로 `/jams/{slug}/games`(검색 로직 중복) |

## ④ 데이터 모델

### 4.1 신규 DDL — `docs/tag-ddl.sql` (전문, 멱등)

> 적용: `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 을 알파벳순으로 `search_path=dev` 멱등 적용. `schema.sql` 에 동기 사본 반영(아래 4.3). 선례: W1 `docs/rbac-ddl.sql`.

```sql
-- docs/tag-ddl.sql
-- W3-1 태그 + 검색 확장. 멱등(IF NOT EXISTS / DO $$ guard). search_path=dev.

-- 1) 통합 태그 마스터
CREATE TABLE IF NOT EXISTS tags (
    id          BIGSERIAL PRIMARY KEY,
    name        VARCHAR(20)  NOT NULL,          -- 표시명. 길이 2~20 (앱 검증 + CHECK)
    slug        VARCHAR(40)  NOT NULL,          -- 정규화 키(소문자/하이픈). 검색·URL 안정
    tag_type    VARCHAR(10)  NOT NULL,          -- 'GAME' | 'JAM' | 'COMMON'
    is_active   BOOLEAN      NOT NULL DEFAULT FALSE, -- 운영자 사전정의=true, 사용자 생성=false(pending)
    created_by  BIGINT       NULL,              -- FK users.id (운영자 시드는 NULL 허용)
    created_at  TIMESTAMP    NOT NULL DEFAULT now()
);

-- 태그명 길이·타입 CHECK (멱등: DO $$ guard 로 중복 추가 방지)
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_name_len') THEN
        ALTER TABLE tags ADD CONSTRAINT chk_tags_name_len
            CHECK (char_length(name) BETWEEN 2 AND 20);
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'chk_tags_type') THEN
        ALTER TABLE tags ADD CONSTRAINT chk_tags_type
            CHECK (tag_type IN ('GAME', 'JAM', 'COMMON'));
    END IF;
END $$;

-- created_by FK (users 존재 전제. 멱등 guard)
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_tags_created_by') THEN
        ALTER TABLE tags ADD CONSTRAINT fk_tags_created_by
            FOREIGN KEY (created_by) REFERENCES users(id);
    END IF;
END $$;

-- 태그명/slug unique (활성 여부 무관 전역 유일 — 중복 생성 차단)
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_slug ON tags (slug);
CREATE UNIQUE INDEX IF NOT EXISTS uq_tags_name ON tags (name);
-- 타입+활성 필터 인덱스(태그 목록/자동완성 조회)
CREATE INDEX IF NOT EXISTS idx_tags_type_active ON tags (tag_type, is_active);

-- 2) 게임-태그 조인 (N:M)
CREATE TABLE IF NOT EXISTS game_tags (
    id         BIGSERIAL PRIMARY KEY,
    game_id    BIGINT    NOT NULL,             -- FK games.id
    tag_id     BIGINT    NOT NULL,             -- FK tags.id
    created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_game_tags ON game_tags (game_id, tag_id);
CREATE INDEX IF NOT EXISTS idx_game_tags_tag ON game_tags (tag_id);   -- 태그→게임 역검색
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_game') THEN
        ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_game
            FOREIGN KEY (game_id) REFERENCES games(id);
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_tags_tag') THEN
        ALTER TABLE game_tags ADD CONSTRAINT fk_game_tags_tag
            FOREIGN KEY (tag_id) REFERENCES tags(id);
    END IF;
END $$;

-- 3) 잼-태그 조인 (N:M, jams 는 W2-1 소관)
CREATE TABLE IF NOT EXISTS jam_tags (
    id         BIGSERIAL PRIMARY KEY,
    jam_id     BIGINT    NOT NULL,             -- FK jams.id
    tag_id     BIGINT    NOT NULL,             -- FK tags.id
    created_at TIMESTAMP NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS uq_jam_tags ON jam_tags (jam_id, tag_id);
CREATE INDEX IF NOT EXISTS idx_jam_tags_tag ON jam_tags (tag_id);
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_jam') THEN
        ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_jam
            FOREIGN KEY (jam_id) REFERENCES jams(id);
    END IF;
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_jam_tags_tag') THEN
        ALTER TABLE jam_tags ADD CONSTRAINT fk_jam_tags_tag
            FOREIGN KEY (tag_id) REFERENCES tags(id);
    END IF;
END $$;

-- 4) 방문 로그 (dedupe 가능)
CREATE TABLE IF NOT EXISTS game_views (
    id         BIGSERIAL    PRIMARY KEY,
    game_id    BIGINT       NOT NULL,          -- FK games.id
    viewer_key VARCHAR(200) NOT NULL,          -- 세션/해시 식별자(로그인=userId, 비로그인=익명 해시)
    viewed_at  TIMESTAMP    NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_game_views_game ON game_views (game_id);
-- 24h dedupe 조회용(game_id + viewer_key + 시간범위)
CREATE INDEX IF NOT EXISTS idx_game_views_dedupe ON game_views (game_id, viewer_key, viewed_at);
DO $$
BEGIN
    IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_game_views_game') THEN
        ALTER TABLE game_views ADD CONSTRAINT fk_game_views_game
            FOREIGN KEY (game_id) REFERENCES games(id);
    END IF;
END $$;

-- 5) games 비정규화 방문수 카운터 (기존 테이블 변경: ADD COLUMN IF NOT EXISTS)
ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count INTEGER NOT NULL DEFAULT 0;
-- 방문수 정렬 인덱스
CREATE INDEX IF NOT EXISTS idx_games_view_count ON games (view_count);
```

### 4.2 금칙어 사전 출처 (D3)

- 금칙어 필터는 애플리케이션 레이어에서 적용한다(DDL 외). 출처는 `src/main/resources/banned-words.txt`(신규, 1줄 1단어, 소문자 비교) 를 단일 출처로 한다. 태그 sanitize 단계(§part2 §8 검증 흐름)에서 slug 화 후 부분/완전 매치 검사. (구현 advisor 가 시드 목록 확정 — 본 설계는 위치·매칭 규칙만 확정)

### 4.3 `schema.sql` 동기 위치

- `schema.sql` 은 DDL 권위(`docs/tag-ddl.sql`)의 동기 사본. 위 4.1 의 5개 블록(tags / game_tags / jam_tags / game_views / games.view_count ALTER)을 동일 멱등 형태로 추가한다.
- 추가 지점: 기존 `games` 정의 블록 직후(view_count ALTER) + `users`/`games`/`jams` 정의 이후(FK 의존 순서 보장). 적용 순서는 `apply-local-ddl.sh` 알파벳순에 의존하지 않도록 모든 블록이 멱등·FK guard 처리됨.

## ⑤ 외부 계약 (API)

> 규약: 읽기=JSP 뷰이름 반환, 쓰기=`ResponseEntity<JSON>`(RecruitController 패턴). 상태변경=`CsrfTokens.isValid` 검증. 태그 관리 쓰기=`PermissionGate.has(session, CONTENT_MODERATE)` 게이트.

### 5.1 검색 API (읽기)

```
GET /games/search
```

| 파라미터 | 타입 | 용도 | 기본값 |
|---|---|---|---|
| `keyword` | String? | 자유텍스트 — name/display_name/creator_note ILIKE | null |
| `creator` | String? | 제작자(개발자) 검색 — users.display_name ILIKE 한정 | null |
| `tags` | String? | 태그 slug CSV (예: `rpg,horror`) | null |
| `tagMode` | String | 다중 태그 결합 `and`\|`or` | `and` |
| `sort` | String | 정렬키 `latest`\|`likes`\|`views`\|`reviews`\|`rating`\|`relevance` | `latest` (태그 선택 시 `relevance` 가중 허용) |
| `jam` | String? | 잼 slug — 지정 시 jam_entries 조인으로 해당 잼 출품작 한정 (W3-4 타깃) | null |

- 응답: 검색 결과 JSP 뷰(`game-search` 또는 기존 목록 뷰 재사용) + model 에 결과 리스트/페이징/선택 태그.
- 보안: 모든 텍스트 파라미터는 MyBatis `#{}` 바인딩 + ILIKE. `${}` 동적 치환 금지. `tags` CSV 는 서버에서 slug 화이트리스트 검증 후 `foreach` 바인딩.

### 5.2 태그 관리 API (쓰기 — 권한 게이트 + CSRF)

| Method | Path | 권한 | 용도 | 요청 | 응답 |
|---|---|---|---|---|---|
| `POST` | `/tags` | 로그인(일반) | 사용자 태그 생성(pending: is_active=false) | `{name, tagType}` + CSRF | `ResponseEntity<{id, name, slug, isActive:false}>` |
| `POST` | `/admin/tags` | `CONTENT_MODERATE` | 운영자 태그 생성(즉시 활성) | `{name, tagType}` + CSRF | `ResponseEntity<{id, ..., isActive:true}>` |
| `POST` | `/admin/tags/{id}/approve` | `CONTENT_MODERATE` | pending 태그 승인(is_active=true) | CSRF | `ResponseEntity<{id, isActive:true}>` |
| `POST` | `/admin/tags/{id}/deactivate` | `CONTENT_MODERATE` | 태그 비활성(is_active=false) | CSRF | `ResponseEntity<{id, isActive:false}>` |
| `POST` | `/games/{gameId}/tags` | 게임 소유자 또는 `CONTENT_MODERATE` | 게임에 태그 부착(game_tags upsert) | `{tagIds[]}` + CSRF | `ResponseEntity<{gameId, tagIds[]}>` |
| `GET` | `/tags` | 공개 | 활성 태그 목록/자동완성(tag_type 필터) | `?type=GAME&q=` | `ResponseEntity<List<{id,name,slug}>>` |

- 사용자 생성(`POST /tags`)은 sanitize + 금칙어 + 길이/화이트리스트 검증 통과 시에만 pending 저장. 검증 실패는 400.
- 모든 상태변경(`POST`)은 `CsrfTokens.isValid` 선검증. 실패 403.
- 권한 결정(D4): 기존 `PermissionKeys.CONTENT_MODERATE` 재사용. (TAG_MANAGE 신규 도입은 concern 기록 — 향후 분리 시)

### 5.3 잼 태그 검색 라우트 — 단일 출처 확정 (W3-4 타깃)

```
GET /games/search?jam={jamSlug}&tags={tagSlugCsv}&tagMode=and&sort=latest
```

- **확정 계약**: 잼 검색은 별도 경로가 아니라 `GET /games/search` 의 `jam` 파라미터로 통합한다. `jam` 지정 시 `jam_entries`(W2-1: `jam_id` FK, `game_id` FK) 조인으로 해당 잼 출품작만 결과에 포함한다.
- W3-4(진행중 잼 배너)는 배너 링크 타깃을 `/games/search?jam={진행중잼.slug}` 로 구성한다. 추가로 잼별 추천 태그를 붙이려면 `&tags=` 를 부착(jam_tags 에서 조회).
- `jamSlug` 미해석/존재하지 않을 경우 빈 결과 + 안내(비-에러).

## ⑥ 검색 쿼리 설계

> MyBatis @Mapper, `#{}` 바인딩 전용, `${}` 금지. 동적 절은 `<script>`/`<if>`/`<foreach>` 로 구성. `game_review_stats` VIEW 는 camelCase alias 큰따옴표 필요(`"avgRating"`) — 단, 정렬/필터에 쓰는 컬럼은 VIEW 의 원본 컬럼명(`avg_rating`, `review_count`) 으로 참조하고 별칭은 select projection 에서만 사용.

### 6.1 확장 쿼리 골격 (searchVisibleGames 확장)

```sql
SELECT g.id, g.user_id, g.name, g.creator_note, g.git_url, g.webgl_path,
       g.thumbnail_url, g.like_count, g.is_visible, g.sort_order,
       g.created_at, g.updated_at, g.view_count,
       u.display_name AS "displayName",
       grs.avg_rating  AS "avgRating",      -- VIEW: case-folding 회피 큰따옴표
       grs.review_count AS "reviewCount"
FROM games g
JOIN users u ON u.id = g.user_id
LEFT JOIN game_review_stats grs ON grs.game_id = g.id
-- [잼 필터] jam 지정 시에만
<if test="jamSlug != null">
  JOIN jam_entries je ON je.game_id = g.id
  JOIN jams j ON j.id = je.jam_id AND j.slug = #{jamSlug}
</if>
WHERE g.is_visible IS NOT FALSE
  AND g.is_delete IS NOT TRUE
  -- [자유텍스트] keyword
  <if test="keyword != null">
    AND ( g.name ILIKE ('%' || #{keyword} || '%')
       OR u.display_name ILIKE ('%' || #{keyword} || '%')
       OR g.creator_note ILIKE ('%' || #{keyword} || '%') )
  </if>
  -- [제작자] creator
  <if test="creator != null">
    AND u.display_name ILIKE ('%' || #{creator} || '%')
  </if>
  -- [태그 AND] 선택 태그 전부 보유: 매칭 distinct count == 선택 수
  <if test="tags != null and tagMode == 'and'">
    AND g.id IN (
      SELECT gt.game_id FROM game_tags gt
      JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
      WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
      GROUP BY gt.game_id
      HAVING COUNT(DISTINCT t.slug) = #{tagCount}
    )
  </if>
  -- [태그 OR] 하나라도 보유
  <if test="tags != null and tagMode == 'or'">
    AND g.id IN (
      SELECT gt.game_id FROM game_tags gt
      JOIN tags t ON t.id = gt.tag_id AND t.is_active = TRUE
      WHERE t.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
    )
  </if>
ORDER BY
  /* 정렬키별 ORDER BY — §6.2 */
```

### 6.2 정렬키별 ORDER BY (동률 2차키 `id DESC` 공통)

| `sort` | ORDER BY 절 | 비고 |
|---|---|---|
| `latest` (기본) | `g.sort_order ASC, g.created_at DESC, g.id DESC` | 목록 기본과 동일 |
| `likes` | `g.like_count DESC, g.id DESC` | |
| `views` | `g.view_count DESC, g.id DESC` | 비정규화 컬럼 |
| `reviews` | `grs.review_count DESC NULLS LAST, g.id DESC` | VIEW LEFT JOIN, 미평가 NULLS LAST |
| `rating` | `grs.avg_rating DESC NULLS LAST, g.id DESC` | overall 단일 평점 |
| `relevance` | 태그 일치도 가중(아래) `DESC, g.created_at DESC, g.id DESC` | 태그 선택 시 허용 |

- **태그 일치도 가중(relevance)**: 태그 선택 시 매칭 태그 수를 점수로 사용.
  ```sql
  ORDER BY (
    SELECT COUNT(*) FROM game_tags gt2
    JOIN tags t2 ON t2.id = gt2.tag_id AND t2.is_active = TRUE
    WHERE gt2.game_id = g.id
      AND t2.slug IN <foreach item="s" collection="tags" open="(" separator="," close=")">#{s}</foreach>
  ) DESC, g.created_at DESC, g.id DESC
  ```
- `sort` 는 서버에서 enum 화이트리스트로 매핑(미허용 값 → `latest` fallback). MyBatis `<choose>` 분기로 ORDER BY 조립 — **컬럼/방향은 매퍼 내부 고정 문자열**(사용자 입력이 `${}` 로 흘러들지 않음).
- `tagCount` 는 서버가 `tags` CSV 길이에서 산출해 바인딩(AND 모드 HAVING 비교용).

## ⑦ 시퀀스

### 7.1 태그 검색 플로우
1. 진입: `GET /games/search?keyword=&creator=&tags=&tagMode=&sort=&jam=` (Controller).
2. 분기 — 파라미터 정규화: `tags` CSV → slug 리스트(화이트리스트 검증, 비활성/미존재 slug 제거), `sort`/`tagMode` enum 매핑(미허용→fallback), `tagCount` 산출.
3. 분기 — `jam` 존재 시 jams.slug 해석. 미존재 → 빈 결과 반환.
4. 매퍼 호출: `searchVisibleGames(SearchCriteria)` — §6 동적 쿼리. `#{}` 바인딩, ILIKE.
5. VIEW LEFT JOIN 으로 reviewCount/avgRating projection, ORDER BY 정렬키 적용.
6. 종단: 결과 리스트 + 선택 태그 + 페이징을 model 에 담아 검색 JSP 뷰 반환.

### 7.2 태그 생성 → 승인 플로우
1. 사용자 진입: `POST /tags {name, tagType}` + CSRF.
2. CSRF 검증(`CsrfTokens.isValid`) 실패 → 403.
3. sanitize: trim/소문자 slug 화 → 길이 2~20 검사 → 화이트리스트(한/영/숫자/하이픈) 검사 → 금칙어 사전(`banned-words.txt`) 매치 검사. 위반 → 400.
4. 중복 검사: `tags.slug`/`name` UNIQUE — 이미 존재 시 기존 태그 반환(또는 409).
5. 저장: `is_active=false`(pending), `created_by=session.userId`. → pending 응답.
6. 운영자 진입: `POST /admin/tags/{id}/approve` + CSRF → `PermissionGate.has(session, CONTENT_MODERATE)` 게이트. 미충족 → 403.
7. 승인: `is_active=true` 갱신. 종단: 활성 태그 검색·부착 가능.
8. (운영자 직접 생성 `POST /admin/tags` 는 3~4 검증 후 즉시 `is_active=true` 저장 — 단계 5~7 단축.)

### 7.3 방문수 기록 플로우 (24h dedupe)
1. 진입: 게임 상세/플레이 조회 시 viewer_key 산출(로그인=`userId` 문자열, 비로그인=익명 식별 해시 — 세션/쿠키 기반).
2. dedupe 조회: `game_views` 에서 `game_id == ? AND viewer_key == ? AND viewed_at >= now() - interval '24 hours'` 존재 여부.
3. 분기 — 이미 존재: 기록·증분 스킵(중복 방문).
4. 분기 — 미존재: `game_views` INSERT(game_id, viewer_key, now()) + `games.view_count` `UPDATE ... SET view_count = view_count + 1 WHERE id = ?`.
5. 종단: 방문수 정렬은 비정규화 `games.view_count` 를 O(1) 로 소비(§6.2 `views`).
6. 주의: dedupe 조회 + 증분은 동일 트랜잭션. 동시성은 view_count 가 정확 카운터가 아닌 정렬용 근사이므로 행 락 최소화(over-engineering 회피).

## ⑧ 파일 영향 맵

> 규약 추적: 신규 매퍼는 모두 `@Mapper` + `#{}` 바인딩(§NFR-3). 신규 함수 시그니처는 최소 인자 원칙(빈 칸 인라인 주석 = inflate 신호 → 제거). inflate 위험 시그니처는 frontmatter concerns 에 마킹됨.

### 8.1 변경 맵

| 변경 유형 | 경로 | 역할 | 소유권 |
|---|---|---|---|
| 신규 | `docs/tag-ddl.sql` | tags/game_tags/jam_tags/game_views 정의 + games.view_count ALTER (멱등, §4.1 전문) | U-TAG-SCHEMA |
| 수정 | `schema.sql` | 위 5블록 동기 사본(§4.3 지점·순서) | U-TAG-SCHEMA |
| 신규 | `TagsMapper.java` (@Mapper) | tags CRUD/승인/목록 | U-TAG-DOMAIN |
| 신규 | `GameTagsMapper.java` (@Mapper) | game_tags attach/detach/listByGame + 태그→게임ID 역검색 | U-TAG-DOMAIN |
| 신규 | `JamTagsMapper.java` (@Mapper) | jam_tags attach/listByJam (잼 태그 부착·조회. jams 는 W2-1 참조) | U-TAG-DOMAIN |
| 신규 | `GameViewsMapper.java` (@Mapper) | game_views dedupe 조회/INSERT + view_count 증분 | U-VIEW |
| 신규 | `TagData.java` (POJO) | tags 행 매핑(id/name/slug/tagType/isActive/createdBy/createdAt) | U-TAG-DOMAIN |
| 수정 | `GameData.java` (POJO) | 검색 projection 신규 필드(viewCount/avgRating/reviewCount) 추가 — 단 소비처 영향 점검(concern 4) | U-SEARCH |
| 수정 | `GamesMapper.java` | `searchGamesAdvanced(SearchCriteria)` **신규 메서드**(기존 `searchVisibleGames` 시그니처 비변경 → 호출처 갱신 0). VIEW LEFT JOIN + 태그/제작자/정렬키/jam 동적절(§6) | U-SEARCH |
| 신규 | `SearchController.java` | `GET /games/search` — 파라미터 정규화·매퍼 호출·검색 JSP 반환(§7.1) | U-SEARCH |
| 신규 | `TagController.java` | 태그 CRUD/승인 API(§5.2) — sanitize·금칙어·권한게이트·CSRF | U-TAG-ADMIN |
| 신규 | `TagSanitizer.java` (util) | 태그 입력 검증·slug 화·금칙어 매치(§8.3 시그니처) | U-TAG-ADMIN |
| 신규 | `src/main/resources/banned-words.txt` | 금칙어 사전(1줄 1단어, 소문자) — §4.2 단일 출처 | U-TAG-ADMIN |
| 수정 | 게임 상세 조회 컨트롤러 (기존, GameController 추정) | view 기록 훅 — `GameViewsMapper` dedupe 호출(§7.3) | U-VIEW |
| 수정 | 검색/목록 JSP (index.jsp 또는 신규 `game-search.jsp`) | 태그 필터 UI(선택 태그 chip)·정렬키 드롭다운·제작자 검색 입력 | U-JSP |

### 8.2 SSR 호출지점 영향

- **`searchVisibleGames` 기존 호출처**: 신규 `searchGamesAdvanced` 를 **별도 메서드로 추가**하므로 기존 시그니처/호출처(있다면 검색 컨트롤러 1개소) 변경은 0. 기존 자유텍스트 검색 동작 회귀 없음(§10).
- **index.jsp 검색 attr 보존**: 기존 검색 폼이 model 에 의존하는 attr(검색어/결과 리스트)는 신규 `SearchController` 에서 동일 이름으로 채워 보존. 신규 attr(선택 태그·정렬키·태그 목록)는 추가만(기존 제거 0).
- **GameData 확장 영향**: 신규 필드 추가 시 기존 목록/상세 JSP 가 EL 로 신규 필드를 참조하지 않으므로 표시 회귀 0. 단 mapper resultType 매핑에서 신규 컬럼이 비-검색 경로(getVisibleGames 등)에선 미채워짐 → null 허용 박스 타입(`Integer`/`Double`)으로 선언(concern 4 와 연계).

### 8.3 신규 함수 시그니처 (최소 인자, 인라인 주석 의무)

```java
// GamesMapper (신규 메서드)
List<GameData> searchGamesAdvanced(SearchCriteria c); // c: 검색 조건 묶음(아래). 단일 파라미터로 동적절 바인딩

// SearchCriteria (검색 조건 DTO — 매퍼 동적 SQL 바인딩용)
//   keyword   : 자유텍스트 ILIKE 대상(null=미적용)
//   creator   : 제작자 display_name ILIKE(null=미적용)
//   tags      : 화이트리스트 통과 slug 리스트(빈/null=태그필터 미적용)
//   tagMode   : "and" | "or" (tags 비었으면 미참조)
//   tagCount  : tags.size() (AND 모드 HAVING 비교 전용 — OR/미선택 시 미참조, concern 2)
//   sort      : 화이트리스트 정렬키(미허용→"latest" 서버 fallback 후 전달)
//   jamSlug   : 잼 출품작 한정(null=전체. concern 2)

// GameTagsMapper — AND/OR 분리로 dead param 회피(concern 3 채택안)
List<Long> listGameIdsByTagsAll(List<String> tagSlugs, int tagCount); // 전부 보유(AND): HAVING count==tagCount
List<Long> listGameIdsByTagsAny(List<String> tagSlugs);               // 하나라도 보유(OR)
void attachTags(long gameId, List<Long> tagIds); // 게임에 태그 N개 부착(upsert)
List<TagData> listByGame(long gameId);           // 게임의 태그 목록
void detach(long gameId, long tagId);            // 단건 해제

// GameViewsMapper
boolean existsRecentView(long gameId, String viewerKey); // 24h 윈도 dedupe 조회(sinceTs 는 SQL 내 now()-interval 로 고정 → 인자 불필요, inflate 회피)
void insertView(long gameId, String viewerKey);          // 방문 로그 INSERT
void incrementViewCount(long gameId);                    // games.view_count += 1

// TagsMapper
long insertTag(TagData t);                  // pending/active 생성(isActive 는 t 에 포함)
void approve(long id);                      // is_active=true
void deactivate(long id);                   // is_active=false
List<TagData> listActiveByType(String tagType, String q); // 활성 태그 목록/자동완성(q=null 전체)
TagData findBySlug(String slug);            // 중복 검사/해석

// TagSanitizer (util) — 최소 인자. 결과는 검증 통과 slug 또는 예외/Optional
String toSlug(String rawName);              // trim·소문자·정규화(검증은 별도 호출자가 결합)
boolean isAllowed(String slug);             // 길이 2~20 + 화이트리스트 + 금칙어 미매치 통합 판정
```

> inflate 차단 메모: `GameTagsMapper` 의 태그 검색을 AND/OR 단일 메서드(`listGameIdsByTags(slugs, mode, count)`)로 통합하면 OR 경로에서 `count` 가 dead parameter 가 된다 → 두 메서드 분리안 채택(concern 3). `existsRecentView` 의 `sinceTs` 는 SQL 내 `now() - interval '24 hours'` 고정이라 인자에서 제거.

## ⑨ 대안 비교

| 결정축 | 채택안 | 장점 | 단점 | 기각안 | 기각 근거 |
|---|---|---|---|---|---|
| 태그 테이블 | 통합 `tags` 1테이블 + `tag_type` (D1) | 정규화·태그명 단일출처·도메인 확장 안정 | 타입 필터 항상 동반 | 용도별 분리(`game_tags_master`/`jam_tags_master`) | 중복 스키마·관리 분산·검색 단일출처 상실 |
| 방문수 | 로그(`game_views`) + 비정규화(`view_count`) (D5) | dedupe·감사 추적 + 정렬 O(1) | 테이블 1개 + 컬럼 1개 추가 | (a) 단순 `view_count++` (b) 로그 only 매번 COUNT | (a) 중복·어뷰징·감사불가 (b) 정렬마다 집계 비용 |
| 태그 관리 | 운영자 사전정의 + 사용자 pending→승인 하이브리드 (D2) | 초기 카탈로그 + 사용자 확장 + 난립 차단 | 승인 워크플로 필요 | (a) 운영자 전용 (b) 사용자 자유생성 | (a) 확장성↓ (b) 스팸·중복·XSS |
| 정렬 | 정렬키별 고정 ORDER BY + 동률 2차키 `id DESC` (D8) | 결정적 페이징·단순·예측가능 | relevance 는 서브쿼리 점수 | 단일 가중점수(likes·views·rating 혼합) | 가중치 튜닝 불명확·예측 불가·over-engineering |
| 다중 태그 | 파라미터 `tagMode=and\|or` (D7) | 사용자 교집합/합집합 선택 | 분기 2경로 | 고정 AND | 유연성↓(OR 탐색 불가) |
| 잼 검색 라우트 | `/games/search?jam=` 파라미터 통합 (D9) | 검색 진입점 단일·로직 1벌 | 컨트롤러 분기 1개 | 전용 `/jams/{slug}/games` | 검색 로직 중복·W3-4 계약 2벌 |
| 검색 행 타입 | GameData 확장(null 허용 박스 필드) | 매퍼 1벌·기존 매핑 재사용 | 비-검색 경로 null | 전용 `SearchRow` | 매핑 중복·소비처 분기 — 단 소비처 영향 시 재고(concern 4) |

## ⑩ 롤아웃 / 마이그레이션

### 10.1 적용 순서
1. `docs/tag-ddl.sql` 추가 → `apply-local-ddl.sh` 가 `docs/*-ddl.sql` 알파벳순 멱등 적용(`rbac-ddl.sql` < `tag-ddl.sql` 순서 무관 — 모든 블록 FK guard).
2. `schema.sql` 동기 사본 갱신(§4.3) — 신규 환경 부트스트랩 일관성.
3. 매퍼/POJO/컨트롤러/JSP 구현(소유권 분할 U-* 순서 무관, U-TAG-SCHEMA → U-TAG-DOMAIN → {U-SEARCH, U-TAG-ADMIN, U-VIEW} → U-JSP 의존순 권장).

### 10.2 역호환 (비파괴)
- `games.view_count` 는 `ADD COLUMN ... NOT NULL DEFAULT 0` — 기존 행은 자동 0 채움(비파괴, 백필 불필요).
- 신규 테이블(tags/game_tags/jam_tags/game_views)은 **추가 전용** — 기존 스키마 변경/삭제 0.
- 기존 검색 동작 회귀 0: 태그 미선택·정렬 미지정 시 `latest`(목록 기본과 동일) + keyword-only 경로 = 현 `searchVisibleGames` 동작과 동치. 신규 `searchGamesAdvanced` 는 별도 메서드라 기존 호출처 무영향.

### 10.3 롤백 경로
- 코드 롤백: 신규 컨트롤러/매퍼/JSP 변경 revert 시 기존 검색 즉시 복원(기존 메서드 미변경).
- 스키마 롤백: 신규 테이블·컬럼은 추가 전용이라 drop 없이 잔존해도 무해(읽지 않으면 영향 0). 강제 롤백 시 `DROP TABLE IF EXISTS` + `ALTER ... DROP COLUMN IF EXISTS view_count` (수동, 데이터 폐기 동반 — 운영 합의 전제).

## ⑪ AC 매핑

| FR (part1) | 만족 설계요소 | 검증(§12 VP) |
|---|---|---|
| FR-1 태그 도메인 | §4.1 tags/game_tags/jam_tags + TagData/매퍼(§8) | VP-L1(매퍼 단위), VP-L2(DDL 방언 contract) |
| FR-2 검색 확장(태그/제작자/정렬키) | §6 동적쿼리 + searchGamesAdvanced(§8.3) | VP-L1+L2(쿼리), AC-정렬키 전수 |
| FR-3 방문수 정렬 | §4.1 game_views + view_count, §7.3 dedupe, §6.2 views | VP-L1(GameViewsMapper), AC-dedupe 멱등 |
| FR-4 리뷰 정렬 | §6.1 game_review_stats LEFT JOIN, §6.2 reviews/rating | VP-L2(VIEW 별칭 contract) |
| FR-5 태그 관리 API | §5.2 + TagController + TagSanitizer(§8.3) | VP-보안(sanitize/금칙어/권한/CSRF) |
| FR-6 잼 태그 검색 라우트 | §5.3 `/games/search?jam=` 단일 출처 + jam_entries 조인 | VP-L1(jam 분기), W3-4 계약 일치 |

## ⑫ 검증 포인트

> L레벨 규약(프로토콜): L1=빌드/단위(`./mvnw -o test`), L2=DB-방언 contract(dev DB 실제 쿼리 실행), L3=통합/E2E. @MockBean 사용은 프로토콜 §30 준수.

### 12.1 L레벨 매핑
- **VP-L1 (단위)**: 신규 매퍼(TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper) + TagSanitizer 단위. `./mvnw -o test` full 실행(컴파일·기존 회귀 포함). 컨트롤러는 `@MockBean` 매퍼 주입(§30).
- **VP-L2 (DB-방언 contract)**: 검색 쿼리(searchGamesAdvanced)·태그 역검색(AND/OR)·view_count 증분을 **dev DB 실제 실행**으로 contract 검증 — game_review_stats VIEW 별칭(`"avgRating"`/`"reviewCount"`) case-folding, `NULLS LAST`, `ILIKE` Postgres 방언이 H2/mock 으로 검출 안 되는 회귀 게이트.
- **VP-L1 (sanitize)**: TagSanitizer 길이(2~20 경계)·화이트리스트(한/영/숫자/하이픈 외 거부)·금칙어 매치 단위 테스트.

### 12.2 집합 전수 체크 AC

> self-audit(시점 안정·표현 견고성): 아래 AC 는 모두 **검색 코드 자체가 verification 시점까지 변하지 않는 고정 산출물**을 대상으로 하며(자기 work-session 트리 아님), 단일 리터럴 grep 의존을 피해 의미 불변식 + 수동 판정을 병기한다.

- **AC-1 정렬키 전수 6건**: 정렬키 화이트리스트가 `latest/likes/views/reviews/rating/relevance` 전수 6건 매핑 — 정렬 매핑 소스(매퍼 `<choose>` 또는 컨트롤러 enum)에서 6개 키 각각 ORDER BY 절이 존재. 검증: 정렬키 enum/상수 정의에서 `grep -c` 한 case 수 == 6 **AND** 각 키별 L2 쿼리 실행이 정렬 적용 결과를 반환(의미 불변식: 미허용 키는 latest 와 동일 결과). 단일 grep 만으로 판정하지 않고 L2 실행 결과와 교차.
- **AC-2 태그 타입 전수 3건**: `tag_type` CHECK 가 `GAME/JAM/COMMON` 전수 3건 — `docs/tag-ddl.sql` 의 `chk_tags_type` CHECK IN 절 항목 수 == 3 **AND** schema.sql 동기 사본 동일. 검증: DDL 의 `CHECK (tag_type IN (...))` 항목 수 == 3 (구조 불변식: tag-ddl.sql 과 schema.sql 두 출처의 IN 목록 동등).
- **AC-3 다중태그 모드 전수 2건**: `tagMode` 가 `and`/`or` 전수 2건 — 매퍼 동적절에 AND 경로(HAVING count==tagCount)와 OR 경로(IN) 둘 다 존재 **AND** L2 실행으로 AND 결과 ⊆ OR 결과(불변식: 동일 태그셋에서 AND 매칭 게임은 OR 매칭의 부분집합). 단일 grep 회피 — 부분집합 불변식으로 판정.
- **AC-4 신규 매퍼 전수 4개**: TagsMapper/GameTagsMapper/JamTagsMapper/GameViewsMapper 4개 파일 존재 + 각 `@Mapper` 어노테이션 + `#{}` 바인딩만 사용. 검증: glob 매치 4건 **AND** 각 매퍼 `${` 동적치환 0건.
- **AC-5 DDL 블록 전수 5건**: `docs/tag-ddl.sql` 에 tags/game_tags/jam_tags/game_views 테이블 + games.view_count ALTER 전수 5블록 — `CREATE TABLE IF NOT EXISTS` 3건 + `ALTER TABLE games ADD COLUMN IF NOT EXISTS view_count` 1건 + game_views CREATE 1건. 검증: `CREATE TABLE IF NOT EXISTS` 매치 4건(tags/game_tags/jam_tags/game_views) + view_count ALTER 1건 == 5 **AND** schema.sql 동기 사본에 동일 5블록 존재(두 출처 동등 불변식).

### 12.3 보안 VP
- **태그 sanitize/금칙어**: 사용자 생성 입력이 길이/화이트리스트/금칙어 검증을 통과해야만 pending 저장(§7.2 step3). XSS 페이로드(`<script>` 등)·금칙어 입력 → 400 거부 테스트.
- **권한 게이트**: `/admin/tags/**` 4개 엔드포인트(생성/승인/비활성, §5.2)가 `PermissionGate.has(session, CONTENT_MODERATE)` 통과 필수. 미권한 세션 → 403.
- **CSRF**: 모든 상태변경 `POST`(태그 생성·승인·비활성·부착) `CsrfTokens.isValid` 선검증, 미검증 → 403.
- **`#{}` 바인딩 / `${}` 0건**: 검색·태그 쿼리 전 매퍼에서 `${` 동적치환 0건(`grep -c '\${' <매퍼들>` == 0). 정렬키/tagMode 는 매퍼 내부 고정 문자열 분기(`<choose>`)로 조립 — 사용자 입력이 `${}` 로 흐르지 않음(구조 불변식).

## ⑬ 잔여 오픈 질문

**오픈 질문 수: 0.** 모든 설계 결정은 D1~D9(§3) + §4~§8 에서 확정됨. 아래는 오픈 질문이 아니라 **구현 단계 점검 항목으로 concerns 에 이관**된 것이다.

- **권한키 확정(D4)**: 정석 후보는 ① 신규 `TAG_MANAGE` 도입(권한 분리·최소권한) ② 기존 `CONTENT_MODERATE` 재사용(W1 인프라 재사용·신규 키 비용 회피) 중 **② CONTENT_MODERATE 재사용으로 확정**한다. 근거: 현 시점 태그 모더레이션은 콘텐츠 모더레이션과 운영 주체가 동일하고(별도 태그 전담 운영자 미존재), W1 권한 인프라를 즉시 재사용하면 PermissionKeys/RBAC seed 변경 0. 향후 태그 전담 운영자 분리 요구 발생 시 `TAG_MANAGE` 추가 — 이는 오픈 질문이 아니라 미래 트리거 조건이 명확한 확장 포인트이며 concerns[0] 에 이관됨.
- **SearchCriteria/매퍼 시그니처 inflate**: §8.3 에서 최소 인자로 설계하고 AND/OR 매퍼 분리로 dead parameter 를 사전 차단. 구현 advisor 가 unused 진단 게이트(프로토콜 §11.2)에서 `tagCount`/`jamSlug` dead path 여부 최종 확인 — concerns[1][2] 이관.
- **검색 행 매핑 타입**: GameData 확장 vs SearchRow — 소비처 영향 최소안으로 구현 단계 확정. concerns[3] 이관.
