36 KiB
| phase | agent | agent_version | generated_at | workstream | concerns | concerns_checked | self_verification | references | |||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| design | design-advisor | 1 | 2026-06-23T16:30:00+09:00 | W3-4-메인페이지(게임 허브) |
|
true |
|
|
설계: 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 제외) ANDis_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 "안 뒤집힘").
본 설계는 두 가지를 더한다.
- keyset 페이징(G2) — 전건 로드를 커서 기반 페이지로 전환. 커서 = 현 정렬키 3-튜플
(sort_order, created_at, id). 정렬·필터·검색은 그대로 두고WHERE에 커서 비교 +LIMIT pageSize+1만 추가 → 현 결과 순서 회귀 0, 깊은 페이지 일관성 확보. - 진행중 잼 안내 배너(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.
-- 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-1GET /?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 자체를 주입하지 않음 → JSPactiveJamCount == 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)·searchQueryattr 는 보존(타입/이름 불변) → 기존 렌더 루프(index.jsp:500-536) 회귀 0. 신규nextCursor(String, nullable)·activeJam/activeJamCount는 신규 attr → 기존 소비처 깨짐 0.GamesMapper.getVisibleGames/searchVisibleGames기존 메서드는 보존(WebMvcController 외 호출처 없음 — rg getVisibleGames 로 단일 확인 권장, 그러나 신규 메서드 추가는 기존 시그니처 무변경이라 안전).
신규/변경 함수 시그니처 (최소 인자 + 인라인 사용목적 — inflate 방지)
// 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 을 반환(최소). 컨트롤러/세션 컨텍스트를 미리 받지 말 것 — 파싱은 순수 함수.HubCursorrecord 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 무관 선착수):
- 인덱스 적용:
docs/games-hub-ddl.sql(idx_games_visible_keyset) →db/apply-local-ddl.sh(로컬) / 운영 동일 멱등. games 컬럼 0변경 → 회귀 0. 인덱스 미적용이어도 keyset 정합 동작(seq scan 도 정확) — 성능 최적화만. - 매퍼: GamesMapper.listVisibleKeyset/searchVisibleKeyset 추가(기존 메서드 보존).
- 컨트롤러: WebMvcController cursor 처리 + nextCursor. 기존 indexView(q only) 동작 = cursor null 경로 = 첫 페이지(회귀 0).
- 뷰: 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/searchQueryattr 보존 → 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+searchVisibleKeyset2개. 검증: 두 메서드 전수 존재 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-1jams_status_check4값(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 로 이관.