---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-23T16:30:00+09:00
workstream: W3-4-메인페이지(게임 허브)
concerns:
  - "WebMvcController.indexModelAndView 시그니처 확장(query → query+cursor)은 최소 인자로 명세했다. 구현 단계에서 cursor 파싱 헬퍼(parseCursor)의 인자 전부가 실제 사용되는지 재확인 필요(dead parameter → unused 경고 방지, 프로토콜 §11.2)."
  - "GamesMapper 신규 keyset 메서드(listVisibleKeyset/searchVisibleKeyset) 의존은 WebMvcController 가 이미 GamesMapper 를 주입받으므로 신규 빈 0 — 그러나 신규 매퍼 SQL 은 DB-방언 계약(L2) 대상. keyset 3-튜플 비교((sort_order,created_at,id) row-comparison 또는 OR 분해) PostgreSQL 동작은 dev DB contract 로 실측 검증 권장(verification-strategies §33). 신규 매퍼 메서드 추가만이라 BibimbapApplicationTests @MockBean 신규 등록 불요(GamesMapper 기존 등록 재사용)."
  - "진행중 잼 배너는 W2-1 jams 테이블(status/is_visible) 의존 + W3-1 잼 태그 검색 라우트(라우팅 타깃)에 의존한다. 본 설계 작성 시점 W3-1-tags-search-design.md 미존재(병렬 워크스트림) → 배너 클릭 라우트는 §외부계약 '잼 검색 라우트 계약'으로 앵커만 고정. W3-1 이 실제 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택. 배너 도입(단계2)은 W2-1 + W3-1 착지 후 — 단계1(페이징/그리드)은 선착수 독립."
  - "동시 진행 잼 복수(N≥2) 처리는 '최신 1건 배너 + 전체 N건 카운트 라벨'로 확정(아래 D4). N=0 이면 배너 미렌더(기존 동작 회귀 0). jams.is_visible IS NOT FALSE 인 진행중 잼만 카운트 — 비공개 잼 노출 방지."
  - "GamesMapper 에 jam 컬럼 추가 없음(W2-1 D1: games 무변경, 연결은 jam_entries). 따라서 허브 그리드는 잼 출품작을 별도 강조하지 않고 기존 전체 그리드 유지 — '신규 출품작 강조'는 별도 그리드 섹션이 아니라 배너 CTA(잼 검색 라우팅)로만 표현(중복노출 회피, 확정결정)."
concerns_checked: true
self_verification:
  checklist_passed: true
references:
  requirements: docs/work-log/2026-06-23-w2-w4-feature-skeletons.md
  research: .atp/work-session/20260623-104307/research/W2-W4-grounding.md
  adrs:
    - .atp/work-session/20260623-104307/implementation/W2-1-jam-entity-design.md
    - .atp/work-session/20260622-180054/implementation/W1-design.md
    - docs/work-log/2026-06-17-jam-platform-roadmap.md
    - docs/development/verification-strategies.md
---

# 설계: W3-4 — 메인페이지(게임 허브): index.jsp 게임 허브 확장 + keyset 페이징 + 진행중 잼 안내 배너(W3-1 라우팅)

## 목표 / 비목표

### 목표 (FR/NFR 추적 — 골자 W3-4, stale 정정 #4 "index.jsp 잼 노출 없음 → W3-4 유효")
- **G1 게임 허브 확장**: 현 `index.jsp` 전체 게임 카드 그리드를 게임 허브로 확장. 현 정렬(`sort_order ASC, created_at DESC, id DESC`) **그대로 유지**(회귀 0).
- **G2 keyset 페이징(정석)**: 전건 로드(현 `getVisibleGames` 전건)를 **keyset 커서 페이징**으로 전환. 커서 = `(sort_order, created_at, id)` 3-튜플. offset 대비 깊은 페이지 일관성(삽입 시 행 밀림 중복 0, 누락 0). 검색(`q`) 경로도 동일 keyset.
- **G3 진행중 잼 안내 배너(조건부)**: **진행중 잼이 있을 때만** 검색창 아래·그리드 위에 "게임잼 진행 중" 배너 + CTA. 진행중 = `jams.status IN ('RECRUIT','DEV','EVAL')`(W2-1 상태 4값 중 CLOSED 제외) AND `is_visible IS NOT FALSE`.
- **G4 잼 검색 라우팅**: 배너 CTA 클릭 → **W3-1 잼 태그 검색**으로 라우팅(별도 그리드 섹션 아님 — 출품작 중복노출 회피, 확정결정). "신규 출품작 강조"는 배너 CTA 로만 표현.
- **G5 동시 진행 잼 복수 처리**: N≥2 진행중 잼 시 **최신 1건(`created_at DESC`)을 배너 주체 + 전체 N건 카운트 라벨**("외 N-1개 진행 중"). N=0 → 배너 미렌더.
- **G6 단계 착수 분리**: 단계1(G1·G2 허브/페이징)은 W2/W3-1 **무관 독립 선착수**. 단계2(G3·G4·G5 잼 배너)는 W2-1(jams) + W3-1(잼 검색 라우트) 착지 **후**.
- **NFR**: 검색/페이징 파라미터 `#{}` 바인딩(`${}` 금지), 커서/검색어 입력 sanitize, JSP 출력 `HtmlUtils.htmlEscape`, 배너 링크 `textContent`/escape, 비파괴(신규 매퍼 메서드 추가만·games DDL 0변경), 기존 index 동작 회귀 0.

### 비목표 (스코프 밖)
- **잼 엔티티/CRUD/출품**(jams/jam_entries 테이블·매퍼·관리자 콘솔) — **W2-1 소유**. 본 설계는 jams **조회만**(진행중 카운트/최신 1건).
- **잼 태그 검색 화면·태그 스키마·검색 SQL** — **W3-1 소유**. 본 설계는 배너 CTA 가 그 라우트로 **링크만**(계약 앵커).
- **games 스키마 변경**(jam_id/방문수/정렬키 추가) — games 무변경(W2-1 D1 정합). 정렬은 기존 3키 유지.
- **무한스크롤 JS 본체 고도화**(가상 스크롤·prefetch) — 1차는 서버 keyset + "더 보기" 버튼/링크(nextCursor). 가상화는 후속.
- **개인화 추천·인기순 재정렬** — 정렬 변경은 별도. 본 설계는 현 정렬 유지 + 페이징만.
- **잼 출품작 전용 그리드 섹션** — 확정결정으로 **미채택**(중복노출 회피). 배너 라우팅으로 대체.

---

## 개요

bibimbap 의 메인 허브는 `WebMvcController.indexView` → `indexModelAndView(query)`(WebMvcController.java:52-112 직접 확인)가 담당한다. 현재 `gamesMapper.getVisibleGames()`(전건) 또는 검색 시 `searchVisibleGames(query)`(전건)를 `model.games` 로 주입하고 `index.jsp` 가 `games` 리스트를 단일 그리드로 렌더한다(index.jsp:491-537 직접 확인). 정렬은 `sort_order ASC, created_at DESC, id DESC`(GamesMapper.java:60,89 직접 확인). 페이징은 전무(전건 로드). index.jsp 에 잼 노출 0(work-log stale 정정 #4 "안 뒤집힘").

본 설계는 두 가지를 더한다.
1. **keyset 페이징(G2)** — 전건 로드를 커서 기반 페이지로 전환. 커서 = 현 정렬키 3-튜플 `(sort_order, created_at, id)`. 정렬·필터·검색은 그대로 두고 `WHERE` 에 커서 비교 + `LIMIT pageSize+1` 만 추가 → 현 결과 순서 회귀 0, 깊은 페이지 일관성 확보.
2. **진행중 잼 안내 배너(G3~G5)** — `jams.status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE`(W2-1 jams)인 잼을 조회해 N≥1 이면 검색창 아래 배너를 렌더. 배너 CTA 는 **W3-1 잼 태그 검색 라우트**로 라우팅(별도 그리드 아님 — 중복노출 회피).

확정된 정석 결정(전제):
- **정렬 불변 + keyset(D2)**: offset 페이징(행 밀림 중복) 대신 keyset. 현 정렬 3키가 그대로 커서 → 추가 정렬·인덱스 변경 최소. games 무변경.
- **배너 ≠ 그리드 섹션(D3)**: 진행중 잼 출품작을 허브에 두 번째 그리드로 깔면 일반 그리드와 중복노출. 대신 **배너 + CTA → W3-1 검색**으로 단일 진입(확정결정).
- **복수 잼 = 최신 1건 + 카운트(D4)**: 동시 진행 N개 시 배너 본문은 최신 1건, "외 N-1개" 라벨. 잼 목록 전체 노출은 W2-1 `/jams` 가 소유.
- **단계 분리(D5)**: 단계1(허브/페이징)은 jams 미존재여도 독립 동작 → 선착수. 단계2(배너)는 W2-1+W3-1 후. 한 워크스트림이나 착수 게이트가 둘.

가장 까다로운 두 난제 확정:
- **난제1 (검색·비검색 keyset 단일화)**: 비검색(`getVisibleGames`)과 검색(`searchVisibleGames`)이 동일 정렬·동일 커서 의미를 가져야 "더 보기"가 두 경로에서 일관. → 두 신규 매퍼 메서드가 **동일 커서 WHERE 절 + 동일 ORDER BY + 동일 LIMIT 규약**을 공유하고, 컨트롤러는 검색어 유무로만 분기(커서 처리 코드는 공통 헬퍼). 검색 경로도 keyset 으로 통일(검색 결과가 많을 때 동일 일관성).
- **난제2 (배너 의존성 부재 시 동작)**: W2-1 jams 미착지 또는 진행중 잼 0건 시 배너는 **렌더되지 않아야 하고 허브는 정상**이어야 한다(단계1 독립성). → 배너 데이터는 `activeJam`(최신 1건, nullable) + `activeJamCount`(int, 0 가능) 모델 attr 로 주입하되, **JamsMapper 미존재(W2-1 미착지) 단계1 에서는 이 attr 자체를 주입하지 않음**(JSP 가 attr 부재 시 배너 미렌더). 단계2 착지 후 attr 주입 활성화. 즉 JSP 는 `activeJamCount > 0` 일 때만 배너 렌더 → 의존성 부재/0건 모두 안전 회귀 0.

---

## 핵심 결정 요약 (전제 — 재논의 금지)

| 결정 | 확정값 | 본 설계의 구체화 |
|---|---|---|
| D1 허브 확장 | index.jsp = 게임 허브 | 현 그리드 유지 + 페이징 + 조건부 잼 배너. games 무변경 |
| D2 페이징 | keyset 커서 | 커서 = `(sort_order, created_at, id)` 3-튜플. 현 정렬 그대로. offset 기각 |
| D3 잼 노출 | 배너 + CTA(그리드 아님) | 진행중 잼 시 배너 → W3-1 잼 검색 라우팅. 별도 출품작 그리드 미채택(중복노출 회피) |
| D4 복수 잼 | 최신 1건 + 카운트 | `created_at DESC` 최신 1건 배너 + "외 N-1개" 라벨. N=0 → 미렌더 |
| D5 단계 분리 | 단계1 독립 / 단계2 의존 | 단계1(페이징) 선착수, 단계2(배너) = W2-1+W3-1 후 |
| D6 검색 통일 | 검색도 keyset | 비검색·검색 동일 커서 규약(난제1). 컨트롤러는 q 유무만 분기 |
| D7 페이지 진행 | 서버 nextCursor + 더보기 링크 | 1차는 "더 보기"(nextCursor 쿼리). 무한스크롤 JS 는 후속(점진) |

---

## 데이터 모델 (DDL)

> **신규 테이블 0**(확정결정). games(기존, W2-1 무변경) 조회 + jams(W2-1 신규, 본 설계는 조회만). 본 설계가 신규 추가하는 DDL 은 **없다** — keyset 성능 인덱스 1건만 권장(games 무파괴, 멱등).

### games keyset 정렬 인덱스 (권장 — 성능, 신규 테이블/컬럼 아님)
- 현 정렬 `sort_order ASC, created_at DESC, id DESC` 에 대한 keyset seek 효율을 위해 복합 인덱스 1건을 **권장**한다(games 컬럼 변경 0 — 인덱스만 추가, 비파괴 멱등).
- 권위 = 신규 파일 없이 **W2-1 의 docs/jam-ddl.sql 과 별개의 게임 허브 성능 인덱스**는 games 소유라 신설 `docs/games-hub-ddl.sql`(멱등) 또는 기존 games 관리 위치에 추가. 단 **games 는 schema.sql:88-100 비권위 복원본**(grounding R-C) → 인덱스 멱등 추가는 `CREATE INDEX IF NOT EXISTS`.

```sql
-- W3-4 게임 허브 keyset 페이징 성능 인덱스. games 컬럼 변경 0(인덱스만). 멱등.
-- 권위 = docs/games-hub-ddl.sql (apply-local-ddl.sh 글롭 docs/*-ddl.sql 자동 적용) + db/schema.sql 동기 사본.
-- 정렬키 = sort_order ASC, created_at DESC, id DESC (GamesMapper.java:60 와 동일 순서).
CREATE INDEX IF NOT EXISTS "idx_games_visible_keyset"
    ON "games" ("is_visible", "is_delete", "sort_order" ASC, "created_at" DESC, "id" DESC);
```
- **인덱스 없이도 동작**(소규모면 seq scan 도 정확) — 인덱스는 깊은 페이지/대량 시 seek 최적화. 채택 권장하되 keyset 정합의 필수 전제는 아님(정합은 WHERE 비교가 보장).

### jams 조회 (W2-1 소유 — 본 설계는 읽기만)
- W2-1 `jams` 테이블의 `status`(CHECK 'RECRUIT'/'DEV'/'EVAL'/'CLOSED')·`is_visible`·`slug`·`title`·`created_at` 컬럼만 SELECT. 본 설계는 jams DDL 을 **추가/변경하지 않는다**(W2-1 권위 docs/jam-ddl.sql).
- 진행중 잼 정렬·인덱스는 W2-1 의 `idx_jams_visible_keyset`(W2-1-design §데이터모델1, `(is_visible,is_delete,created_at DESC,id DESC)`)을 그대로 소비(추가 인덱스 불요).

---

## 외부 계약 (API)

> 공통: 본 설계의 변경은 **읽기(GET) 뷰만**(허브는 상태변경 없음). 상태변경 API 0 → CSRF 신규 적용 대상 없음(기존 index 도 GET). 검색·커서 파라미터는 매퍼 `#{}` 바인딩 + 컨트롤러 sanitize. 응답은 기존 패턴(JSP 뷰이름 반환 + model attr).

### 401 vs 403 정책 (W1-design 일치)
- 허브(`/`)는 **공개 페이지**(인증 불필요) — 미인증/미인가 분기 없음. 401/403 해당 없음.
- 잼 배너 CTA 가 라우팅하는 W3-1 잼 검색도 공개(읽기) 전제 → 인증 게이트 없음. (W3-1 이 인증 요구하면 W3-1 정책 따름 — 본 설계 무관.)

### 허브 페이지 (뷰 — 변경 대상)
| method | path | 권한 | 응답 |
|---|---|---|---|
| GET | `/` | 공개 | `index` JSP. 첫 페이지 games + `nextCursor` + (단계2)`activeJam`/`activeJamCount` 모델 주입 |
| GET | `/?q={검색어}` | 공개 | `index` JSP. 검색 첫 페이지(keyset) + `nextCursor` |
| GET | `/?cursor={sortOrder}_{createdAtEpochMillis}_{id}` | 공개 | `index` JSP. 다음 페이지(keyset). q 동반 시 검색 다음 페이지 |
| GET | `/?q={검색어}&cursor={...}` | 공개 | 검색 다음 페이지(keyset) |

- **커서 인코딩(확정)**: `cursor = "{sortOrder}_{createdAtEpochMillis}_{id}"`. 3 컴포넌트 `_` 구분. `createdAt` 은 epoch millis(timezone 모호성 제거, OffsetDateTime → toInstant().toEpochMilli()). 파싱 실패/형식 오류 → 첫 페이지로 폴백(throw 금지 — 사용자 입력 신뢰 금지, sanitize). **불투명 토큰(base64) 미채택 근거**: 3 정수/타임스탬프라 평문 디버깅 용이 + 변조해도 정렬·필터가 보호(권한 데이터 아님). 단 파싱은 방어적(NumberFormat catch → 첫 페이지).
- **nextCursor 산정**: `LIMIT pageSize+1` 로 조회 → 결과 size > pageSize 면 `hasNext=true`, (pageSize+1 번째 잘라내고) 마지막 잔류 행의 `(sortOrder, createdAtEpochMillis, id)` 로 nextCursor 생성. size ≤ pageSize 면 nextCursor=null(더보기 미표시).
- **pageSize 확정**: 상수 24(카드 그리드 — 후속 조정 가능, 매직넘버는 컨트롤러 상수 `HUB_PAGE_SIZE`). 1차 고정.

### 잼 검색 라우트 계약 (W3-1 라우팅 타깃 — 앵커, W3-1 이 실제 경로 확정)
> 배너 CTA 의 링크 대상. **W3-1-tags-search-design.md 가 미존재(작성 시점)** → 아래는 라우팅 계약 앵커. W3-1 이 경로/파라미터를 확정하면 그 계약을 단일 출처로 채택(concern 3). 단계2 착수 시 W3-1 확정 경로로 본 링크 1줄을 정렬.
- **계약**: 배너 CTA 는 "진행중 잼의 출품작을 잼 태그로 필터한 검색 결과"로 라우팅한다.
- **앵커 후보(W3-1 확정 전 잠정)**: `GET /jams/{slug}`(W2-1 잼 상세, 출품작 JOIN 노출 — W2-1-design §외부계약 직접 확인) 또는 W3-1 `GET /?q=#{잼태그}` / 전용 잼 검색 경로. **단계2 구현 시 W3-1 확정 경로 1개로 고정**(둘 다 열어두지 않음 — 오픈 질문 회피 위해 폴백 우선순위 확정: W3-1 잼 검색 라우트 존재 시 그것, 미확정이면 W2-1 `/jams/{slug}` 상세로 라우팅).
- **본 설계가 고정하는 것**: 배너는 `activeJam.slug`(또는 W3-1 태그 식별자)를 링크에 담아 라우팅한다는 **구조**. 실제 path 토큰은 단계2 구현에서 W3-1/W2-1 확정값으로 치환.

---

## 인터셉터 / 게이트 연동
- **해당 없음**. 허브(`/`)는 공개 GET, RbacInterceptor `/admin/**` 경로와 무관(InterceptorConfig.java:18-20 직접 확인 — `/admin/**` 만 등록). 본 설계는 인터셉터/게이트 변경 0. W3-1/W2-1 의 게이트는 각 워크스트림 소유.

---

## 시퀀스 (주요 플로우 의사코드)

### S1. 허브 첫 페이지 + keyset 더보기 (단계1 — jams 무관 독립)
```
[공개] GET /
  → WebMvcController.indexView(q=null, cursor=null)
  → indexModelAndView(query=null, cursor=null):
      normalizedQuery = ""             # blank
      cursor 파싱: 없음 → (sortOrder=null, createdAt=null, id=null) 첫 페이지
      rows = gamesMapper.listVisibleKeyset(null, null, null, HUB_PAGE_SIZE+1)
          # WHERE is_visible IS NOT FALSE AND is_delete IS NOT TRUE AND u.is_delete IS NOT TRUE
          #   (커서 null → 커서 비교 절 미적용)
          # ORDER BY sort_order ASC, created_at DESC, id DESC LIMIT pageSize+1
      hasNext = rows.size > HUB_PAGE_SIZE
      if hasNext: last = rows.get(HUB_PAGE_SIZE-1); rows = rows.subList(0, HUB_PAGE_SIZE)
                  nextCursor = last.sortOrder + "_" + last.createdAt.toEpochMilli + "_" + last.id
      else:        nextCursor = null
      model: games=rows, searchQuery="", nextCursor
      (단계2면) model: activeJam, activeJamCount  ← S3
      → "index"

[공개] GET /?cursor=10_1718000000000_57
  → indexModelAndView(query=null, cursor="10_1718000000000_57"):
      parseCursor → (sortOrder=10, createdAtMillis=1718000000000, id=57)
      rows = gamesMapper.listVisibleKeyset(10, instant(1718000000000), 57, pageSize+1)
          # 커서 비교(다음 페이지): 정렬 sort_order ASC, created_at DESC, id DESC 의
          #   "커서 행보다 뒤" = keyset 3-튜플 lexicographic:
          #   sort_order > c.sortOrder
          #   OR (sort_order = c.sortOrder AND created_at < c.createdAt)
          #   OR (sort_order = c.sortOrder AND created_at = c.createdAt AND id < c.id)
      ... (hasNext/nextCursor 동일)
```
- **keyset 비교 방향 논증(난제1)**: 정렬이 혼합 방향(sort_order ASC, created_at DESC, id DESC)이라 단일 row-comparison `(a,b,c) > (...)` 가 안 맞는다 → **OR 분해**(위 3절)로 각 키 방향에 맞춰 비교. dev DB contract 로 실측 검증(concern 2). 동일 sort_order 다수 시 created_at/ id tie-break 으로 중복·누락 0.

### S2. 검색 + keyset (D6 — 비검색과 동일 규약)
```
[공개] GET /?q=플랫폼&cursor=...
  → indexModelAndView(query="플랫폼", cursor=...):
      normalizedQuery = "플랫폼"
      rows = gamesMapper.searchVisibleKeyset(query, cursorSortOrder, cursorCreatedAt, cursorId, pageSize+1)
          # 기존 searchVisibleGames 의 ILIKE 3컬럼 절(name/display_name/creator_note) + 동일 커서 OR 분해 + 동일 ORDER BY/LIMIT
      ... (hasNext/nextCursor 동일, q 도 nextCursor 링크에 보존: /?q=플랫폼&cursor=...)
```
- 컨트롤러 분기는 `normalizedQuery.isBlank()` 하나뿐 — 커서 처리·hasNext·nextCursor 산정은 **공통 헬퍼**(중복 0).

### S3. 진행중 잼 배너 (단계2 — W2-1 jams + W3-1 라우팅 후)
```
indexModelAndView 내 (단계2 활성 시):
  active = jamsMapper.listActive(2)
      # WHERE status IN ('RECRUIT','DEV','EVAL') AND is_visible IS NOT FALSE AND is_delete IS NOT TRUE
      # ORDER BY created_at DESC, id DESC LIMIT 2   (최신 1 + "외 N" 판정용 1 = 2건)
  activeCount = jamsMapper.countActive()
      # 동일 WHERE 의 COUNT(*)  (배너 "외 N-1개" 라벨용 — limit 2 로는 정확 N 모름)
  if activeCount > 0:
      model: activeJam = active.get(0)       # 최신 1건(slug/title)
      model: activeJamCount = activeCount    # 전체 N (JSP 가 N-1 라벨 산정)
  # activeCount == 0 → attr 미주입 → JSP 배너 미렌더(D4)

[JSP index.jsp] search-section 아래, card-grid 위:
  <% Integer activeJamCount = (Integer) request.getAttribute("activeJamCount"); %>
  <% if (activeJamCount != null && activeJamCount > 0) { %>
     배너 렌더: activeJam.title(escape) + (count>1 ? "외 "+(count-1)+"개 진행 중" : "진행 중")
     CTA href = ctx + <W3-1 확정 잼 검색 라우트 with activeJam.slug>   # 라우팅(D3)
  <% } %>
```
- **단계1 안전성(난제2)**: 단계1 에서는 컨트롤러가 `activeJam*` attr 자체를 주입하지 않음 → JSP `activeJamCount == null` → 배너 미렌더 → 허브 정상. JamsMapper 미착지여도 컴파일/런타임 무영향(매퍼 호출 코드는 단계2 에서 추가).

---

## 파일 영향 맵

> 소유권 분할 가이드(implementation-advisor worker 단위 후보 — 단계 분리와 정합):
> **H-MAPPER**(GamesMapper keyset 메서드 + 인덱스 DDL) · **H-CTRL**(WebMvcController keyset/커서 헬퍼) · **H-VIEW1**(index.jsp 더보기/페이징 — 단계1) · **H-JAM**(JamsMapper.listActive/countActive + WebMvc 배너 attr + index.jsp 배너 — 단계2, W2-1+W3-1 후).
> 의존: H-MAPPER → H-CTRL → H-VIEW1 (단계1 완결). H-JAM(단계2)은 W2-1 JamsMapper 존재 + W3-1 라우트 확정 후.

| 변경 유형 | 경로 | 역할 | 소유 / 단계 |
|---|---|---|---|
| 신규 | `docs/games-hub-ddl.sql` | keyset 성능 인덱스(idx_games_visible_keyset). games 컬럼 변경 0(인덱스만, 멱등) | H-MAPPER / 단계1 |
| 수정 | `db/schema.sql` | games 블록 뒤 인덱스 동기 사본(games-hub-ddl 사본). games 컬럼 무변경 | H-MAPPER / 단계1 |
| 수정 | `src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java` | `listVisibleKeyset(...)` + `searchVisibleKeyset(...)` 신규(`#{}`, snake→camel 직접 alias, 커서 OR 분해 + LIMIT). 기존 getVisibleGames/searchVisibleGames 보존(타 호출처 영향 0) | H-MAPPER / 단계1 |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | `indexView`/`indexModelAndView` 에 cursor 파라미터 + keyset 호출 + nextCursor 산정 + parseCursor 헬퍼. 기존 GamesMapper 주입 재사용(신규 빈 0) | H-CTRL / 단계1 |
| 수정 | `src/main/webapp/WEB-INF/views/index.jsp` | card-grid 하단 "더 보기"(nextCursor 링크, q 보존) — 단계1. search-section 아래 진행중 잼 배너 — 단계2 | H-VIEW1(단계1) / H-JAM(단계2) |
| 신규 | `src/main/java/com/pandoli365/bibimbap/mapper/JamsMapper.java` 에 메서드 추가 (또는 W2-1 JamsMapper 가 이미 생성 시 메서드 추가) | `listActive(int limit)` + `countActive()`(진행중 잼 — `#{}`) | H-JAM / 단계2 (W2-1 JamsMapper 소유 조율 — concern) |
| 수정 | `src/main/java/com/pandoli365/bibimbap/controller/WebMvcController.java` | (단계2) JamsMapper 주입 + activeJam/activeJamCount attr 주입. JamsMapper 신규 의존 → @MockBean 확인 | H-JAM / 단계2 |
| 수정 | `src/test/java/com/pandoli365/bibimbap/BibimbapApplicationTests.java` | (단계2만) WebMvcController 가 JamsMapper 주입 시 @MockBean 등록 확인(W2-1 이 이미 등록했으면 재사용). 단계1 은 GamesMapper 기존 등록 재사용 → 신규 등록 불요 | (검증) / 단계2 |
| 수정 | `src/test/.../WebMvcControllerTest.java`(없으면 신규) | keyset 첫/다음 페이지 + nextCursor 산정 + 검색 keyset + 커서 파싱 폴백 + (단계2)배너 attr 조건 단위 | (검증) |

> SSR 호출지점 전수 확인(verification-strategies §영향맵 SSR 포함): `index.jsp` 의 `games`(List<GameData>)·`searchQuery` attr 는 **보존**(타입/이름 불변) → 기존 렌더 루프(index.jsp:500-536) 회귀 0. 신규 `nextCursor`(String, nullable)·`activeJam`/`activeJamCount` 는 **신규 attr** → 기존 소비처 깨짐 0. `GamesMapper.getVisibleGames`/`searchVisibleGames` 기존 메서드는 **보존**(WebMvcController 외 호출처 없음 — rg getVisibleGames 로 단일 확인 권장, 그러나 신규 메서드 추가는 기존 시그니처 무변경이라 안전).

### 신규/변경 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
```java
// GamesMapper (@Mapper, #{} only, snake→camel 직접 alias — 일반매퍼 표준, verification §33)
// 비검색 keyset. 커서 3컴포넌트 null 이면 첫 페이지(WHERE 커서 절 미적용).
List<GameData> listVisibleKeyset(
        Integer cursorSortOrder,                 // 커서 sort_order(첫페이지 null)
        java.time.OffsetDateTime cursorCreatedAt,// 커서 created_at(첫페이지 null)
        Long cursorId,                           // 커서 id tie-break(첫페이지 null)
        int limit)                               // pageSize+1(hasNext 판정)
// 검색 keyset. q + 동일 커서 규약(D6 단일화).
List<GameData> searchVisibleKeyset(
        String query,                            // ILIKE 3컬럼 부분일치(기존 searchVisibleGames 절 재사용)
        Integer cursorSortOrder,                 // (동일)
        java.time.OffsetDateTime cursorCreatedAt,// (동일)
        Long cursorId,                           // (동일)
        int limit)                               // (동일)

// WebMvcController (단계1) — 컨트롤러. cursor 추가, 최소 인자.
ModelAndView indexView(String query,            // ?q= 검색어(nullable)
                       String cursor)           // ?cursor= keyset 커서(nullable, 첫페이지)
// private 헬퍼 — 커서 문자열 파싱(방어적: 형식 오류 → null 반환 = 첫 페이지 폴백).
//   3 컴포넌트만 필요(sortOrder_createdAtMillis_id). 그 외 컨텍스트 불요(최소).
HubCursor parseCursor(String cursor)            // "10_1718..._57" → {sortOrder,createdAt,id} | null
//   HubCursor = 내부 record(Integer sortOrder, OffsetDateTime createdAt, Long id). DTO 추가 1개.

// JamsMapper (단계2 — W2-1 소유 매퍼에 메서드 추가, @Mapper, #{} only)
List<JamData> listActive(int limit)             // 진행중 잼 최신순 limit(배너 최신 1건 + N판정)
int countActive()                               // 진행중 잼 전체 수(배너 "외 N-1개" 라벨)
```
> inflate 마킹(concern 1): `parseCursor` 는 **문자열 1개만** 받아 record 또는 null 을 반환(최소). 컨트롤러/세션 컨텍스트를 미리 받지 말 것 — 파싱은 순수 함수. `HubCursor` record 3필드 전부 매퍼 인자로 전달되므로 dead 필드 위험 낮으나, 구현에서 createdAt 인코딩(epoch millis vs ISO) 확정 후 타입 일치 재확인. `listActive(limit)` 의 limit 은 배너가 최신 1건만 쓰면 2(N≥2 판정용) — 구현에서 countActive 가 N 을 주므로 listActive limit=1 로 축소 가능(최신 1건만 필요) → 구현 시 limit 사용처 재확인(축소 후보).

---

## 대안 비교

| 주제 | 안 | 장점 | 단점 | 채택 |
|---|---|---|---|---|
| 페이징 | (A) keyset(sort_order,created_at,id 커서) | 깊은 페이지 O(log n)+인덱스 seek, 삽입 시 중복/누락 0, 현 정렬 그대로 | 혼합방향 OR 분해 SQL, 커서 파싱 | **채택(D2)** |
| | (B) offset/limit | 단순(LIMIT n OFFSET m) | 깊은 페이지 비용, 게임 추가/sort_order 변경 시 행 밀림 중복·누락 | 기각 |
| | (C) 전건 로드(현행) | 최단 | 게임 누적 증가 시 전송/렌더 비용 선형 증가 | 기각(현행 개선 대상) |
| 잼 노출 | (A) 배너 + CTA → W3-1 검색 | 출품작 중복노출 회피, 단일 진입, 허브 그리드 무변경 | W3-1 라우트 의존 | **채택(D3)** |
| | (B) 진행중 잼 출품작 전용 그리드 섹션 | 즉시 노출 | 일반 그리드와 출품작 중복노출, jam_entries JOIN 조회 추가, 정렬 충돌 | 기각(확정결정) |
| 복수 잼 | (A) 최신 1건 배너 + 카운트 라벨 | 배너 단순, 전체는 /jams 위임 | 최신 외 잼 배너 미노출(목록은 /jams) | **채택(D4)** |
| | (B) 진행중 잼 전부 캐러셀 | 전부 노출 | 배너 비대·캐러셀 JS, 허브 산만 | 기각 |
| 커서 인코딩 | (A) 평문 `sortOrder_millis_id` | 디버깅 용이, 권한데이터 아님(변조 무해) | 형식 노출 | **채택** |
| | (B) base64 불투명 토큰 | 캡슐화 | 디버깅 난해, 이점 없음(권한 0) | 기각 |
| 검색 페이징 | (A) 검색도 keyset 통일(D6) | 일관 더보기, 공통 헬퍼 | 검색 SQL 에 커서 절 추가 | **채택** |
| | (B) 검색은 전건 유지 | 변경 최소 | 비검색만 페이징 = 더보기 동작 불일치 | 기각 |

---

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

### 순서
**단계1 (독립 — W2/W3-1 무관 선착수)**:
1. **인덱스 적용**: `docs/games-hub-ddl.sql`(idx_games_visible_keyset) → `db/apply-local-ddl.sh`(로컬) / 운영 동일 멱등. games 컬럼 0변경 → 회귀 0. **인덱스 미적용이어도 keyset 정합 동작**(seq scan 도 정확) — 성능 최적화만.
2. **매퍼**: GamesMapper.listVisibleKeyset/searchVisibleKeyset 추가(기존 메서드 보존).
3. **컨트롤러**: WebMvcController cursor 처리 + nextCursor. 기존 indexView(q only) 동작 = cursor null 경로 = 첫 페이지(회귀 0).
4. **뷰**: index.jsp "더 보기" 링크(nextCursor, q 보존). nextCursor null 이면 미표시.

**단계2 (W2-1 jams + W3-1 잼 검색 라우트 착지 후)**:
5. **JamsMapper**: listActive/countActive 추가(W2-1 JamsMapper 에 — 소유 조율 concern).
6. **컨트롤러**: WebMvcController 에 JamsMapper 주입 + activeJam/activeJamCount attr. JamsMapper 신규 의존 → BibimbapApplicationTests @MockBean 확인(W2-1 이 이미 등록 시 재사용, full ./mvnw -o test).
7. **뷰**: index.jsp 진행중 잼 배너(activeJamCount>0 조건) + CTA(W3-1 확정 라우트).

### 역호환
- **단계1**: `index` 모델 `games`/`searchQuery` attr 보존 → JSP 렌더 루프 불변. 신규 `nextCursor` 만 추가. cursor 없는 기존 URL(`/`, `/?q=...`) = 첫 페이지(동작 동일, 단 결과가 pageSize 로 제한 — 전건→첫 페이지로 의미 변경되나 "더 보기"로 전건 도달 가능). 기존 검색 URL 호환.
- **단계2**: jams 0건/미착지 시 배너 미렌더 → 단계1 동작 그대로. 배너는 순수 추가(기존 attr 무변경).

### 롤백
- **단계1 롤백**: WebMvcController cursor 분기 제거 → 첫 페이지 매퍼 호출만(또는 기존 getVisibleGames 복귀). 인덱스는 추가 전용이라 잔존 무해(비파괴). index.jsp 더보기 링크 제거.
- **단계2 롤백**: WebMvcController activeJam attr 주입 제거 → JSP 배너 미렌더(activeJamCount null). JamsMapper 메서드는 미사용 잔존 무해. W2-1/W3-1 무영향.

---

## AC 매핑

| AC | 요구(골자 W3-4) | 만족 설계 요소 | 비고 |
|---|---|---|---|
| AC-1 | 허브 = 현 그리드 + 정렬 유지 | listVisibleKeyset ORDER BY sort_order ASC,created_at DESC,id DESC(기존 동일) | D1, D2 |
| AC-2 | keyset 페이징(전건→커서) | listVisibleKeyset 커서 OR 분해 + LIMIT pageSize+1 + nextCursor | D2, S1 |
| AC-3 | 검색도 keyset 일관 | searchVisibleKeyset 동일 커서 규약 + 공통 헬퍼 | D6, S2 |
| AC-4 | 진행중 잼 시에만 배너 | activeJamCount>0 조건 렌더(0/미착지 → 미렌더) | D4, 난제2, S3 |
| AC-5 | 진행중 = RECRUIT/DEV/EVAL | jamsMapper.listActive/countActive WHERE status IN(3값) AND is_visible | G3, S3 |
| AC-6 | 배너 CTA → W3-1 잼 검색 라우팅 | 배너 href = W3-1 확정 잼 검색 라우트(slug) — 그리드 아님 | D3, §잼검색라우트계약 |
| AC-7 | 복수 잼 처리 명시 | 최신 1건 배너 + countActive "외 N-1개" 라벨 | D4, S3 |
| AC-8 | 단계1 독립 선착수 | jams 미착지여도 단계1 동작(attr 미주입) | D5, 난제2 |
| AC-9 | 검색/페이징 SQL `${}` 0 | 신규 매퍼 메서드 `#{}` only | NFR, §파일영향맵 |
| AC-10 | 기존 index 동작 회귀 0 | games/searchQuery attr 보존, 기존 메서드 보존 | §SSR 호출지점 |

---

## 검증 포인트 (verification-advisor 점검 대상)

> L레벨 매핑(verification-strategies): 신규 매퍼 SQL/alias·keyset 커서 비교 = **L1+L2(dev DB contract)**. 컨트롤러 분기/커서 파싱 = **L1**. 허브 렌더/더보기·배너 조건 = **L1+L3 스모크**. 단계2 JamsMapper 신규 의존 = full `./mvnw -o test`(§30) — 단계1 은 GamesMapper 기존 의존 재사용이라 신규 @MockBean 불요.

### 시나리오 검증
- **VP-1 (AC-2 keyset, L1+L2)**: WebMvcControllerTest — 첫 페이지(cursor null) pageSize 행 + nextCursor 산정 / 다음 페이지(cursor) 중복·누락 0 / 마지막 페이지 nextCursor=null. dev DB contract: 혼합방향 OR 분해 커서 비교 실측(동일 sort_order 다수 시 created_at/id tie-break — 경계 데이터로 중복·누락 0 확인).
- **VP-2 (AC-3 검색 keyset, L1+L2)**: 검색 q + cursor 다음 페이지에 q 보존 + ILIKE 3컬럼 절 유지 + 커서 일관. 비검색과 동일 nextCursor 규약.
- **VP-3 (커서 파싱 폴백, L1)**: 형식 오류 cursor("abc", "1_2", "x_y_z") → 첫 페이지 폴백(NumberFormat catch, throw 0). 입력 신뢰 금지.
- **VP-4 (AC-9 SQL `${}` 0, L1)**: 신규 매퍼 메서드 `${` 매치 0.
- **VP-5 (AC-10 회귀, L1+L3)**: 기존 `/`·`/?q=` 렌더 PASS(games/searchQuery attr 동일). index.jsp 렌더 루프 변경 없음(더보기 링크는 그리드 외부 추가).
- **VP-6 (AC-4/5/7 배너 단계2, L1+L3)**: activeJamCount>0 시 배너 렌더 + 최신 1건 title escape + "외 N-1개" 라벨 / activeJamCount=0 또는 attr null 시 미렌더. JamsMapper.listActive WHERE status IN 3값 AND is_visible 확인. L3: W2-1 진행중 잼 시드 후 `/` 에 배너 노출 → CTA 링크가 W3-1 라우트.
- **VP-7 (단계2 contextLoads, L1)**: 단계2 에서 WebMvcController 가 JamsMapper 주입 시 BibimbapApplicationTests @MockBean 등록 후 PASS(§30). W2-1 이 이미 등록했으면 재사용 — 단계1 은 불요.

### 집합 전수 체크 AC (집합 전수 패턴 — 시점·표현 self-audit 적용)
> self-audit(시점): 아래 카운트는 **본 설계가 신규 생성하는 정적 산출물**(매퍼 메서드·진행중 status 값·정렬키)이며 verification 시점까지 본 워크스트림 외 변경 주체 없음(시점 안정). 자기 트리처럼 증가하는 대상 아님. 단 AC-T4(진행중 status 집합)는 W2-1 jams_status_check 와의 **동등성 불변식**으로 앵커 — W2-1 이 status 값을 바꾸면 두 곳이 함께 변해야 하므로 고정 스칼라 대신 동등성으로.
> self-audit(표현): 단일 리터럴 grep 취약성을 피해 정렬키 순서/매퍼 메서드 쌍/status IN 목록 같은 **구조적 불변식**에 앵커. 매퍼 `${` 0건만 리터럴(부재 검증은 리터럴 정당).

- **AC-T1 keyset 매퍼 메서드 전수 2건(비검색+검색) 정합** — GamesMapper 신규 keyset 메서드 = `listVisibleKeyset` + `searchVisibleKeyset` 2개. 검증: 두 메서드 전수 존재 AND **둘의 ORDER BY 절이 동일 토큰**(`sort_order ASC, created_at DESC, id DESC`) AND **둘의 커서 OR 분해 절이 동일**(난제1 단일화 — 한쪽만 바뀌면 더보기 불일치). 수동 판정(두 SQL 본문 diff = ILIKE 절 외 동일). 메서드 추가/삭제 누락을 쌍 정합으로 커버.
- **AC-T2 정렬키 3-튜플 전수 일치** — keyset 정렬키 = `(sort_order ASC, created_at DESC, id DESC)` 3키가 (a)기존 getVisibleGames(GamesMapper.java:60) (b)신규 listVisibleKeyset (c)신규 searchVisibleKeyset (d)커서 인코딩(sortOrder_createdAt_id) (e)idx_games_visible_keyset 5곳 전수 동일 순서·방향. 검증: 5곳 정렬키 순서·방향 수동 대조(불변식 — 한 곳 불일치 시 페이지 경계 깨짐). 정렬 회귀(AC-1)·keyset 정합(AC-2)의 공통 가드.
- **AC-T3 신규 매퍼 `${` 0건** — 신규 keyset 매퍼 2메서드(+단계2 JamsMapper listActive/countActive) 에 `${` 매치 0: `grep -c '\${' GamesMapper.java`(신규 메서드 범위) == 0 (AC-9, `${}` 동적치환 금지). 부재 검증이라 리터럴 정당.
- **AC-T4 진행중 잼 status 집합 = W2-1 CHECK 동등성 불변식** — 배너 진행중 정의 `status IN ('RECRUIT','DEV','EVAL')` 3값은 W2-1 `jams_status_check` 4값(RECRUIT/DEV/EVAL/CLOSED) **에서 CLOSED 만 제외한 정확한 부분집합**. 검증: listActive/countActive WHERE 의 status IN 목록 == {W2-1 status 4값} − {CLOSED}(동등성 불변식 — W2-1 이 status 값 추가/변경 시 진행중 정의도 함께 점검). 고정 스칼라 아닌 W2-1 CHECK 와의 집합 관계로 앵커(시점 안정).
- **AC-T5 단계별 의존 게이트 전수 — 단계1 신규 빈 0 / 단계2 JamsMapper 1** — 단계1 변경(GamesMapper 메서드/WebMvcController cursor/index.jsp 더보기)에 **신규 @MockBean 등록 0**(GamesMapper 기존 등록 재사용) → 단계1 만 머지 시 contextLoads PASS(신규 의존 없음 불변식). 단계2 머지 시 WebMvcController JamsMapper 주입 1건 → @MockBean 등록 확인 후 contextLoads PASS. 검증: 단계1 PR 의 BibimbapApplicationTests diff == 0(@MockBean 추가 없음) AND 단계2 PR 에서 JamsMapper @MockBean 존재. 단계 분리(D5/AC-8) 무결성 + §30 누락 동시 가드.

---

## 잔여 오픈 질문
없음(0). 확정 결정 D1~D7 전제 고정. 두 난제(검색·비검색 keyset 단일화·배너 의존성 부재 안전)는 본 설계가 구체 메커니즘(공통 커서 헬퍼 + attr 미주입 조건 렌더)으로 확정. 복수 잼 처리(최신1+카운트), 커서 인코딩(평문 3컴포넌트), 단계 분리(단계1 독립/단계2 의존)도 확정. 구현 점검 항목(parseCursor inflate·keyset OR 분해 dev DB 실측·W3-1 잼 검색 라우트 확정값 치환·JamsMapper 소유 조율·단계2 @MockBean)은 오픈 질문이 아니라 `concerns` 로 이관.
