bibimbap/.atp/work-session/20260623-104307/implementation/W3-4-main-hub-design.md

347 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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` 로 이관.