Compare commits

...

56 Commits

Author SHA1 Message Date
이정수 6154826a82 docs(changes): edit-mode WebGL zip 비필수 표시 육안 확인 완료
admin 소유 게임이 dev 시드에 없어 DB에 임시 row를 직접 INSERT해
/game/{id}/edit 방문 확인 후 즉시 DELETE로 원복(잔존 0건). "게임 이름
*"는 유지, "WebGL zip"은 editMode에서 * 미표시 — 코드 로직과 일치
확인. needs_user_verification 항목 1건 해소.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 15:44:05 +09:00
이정수 64e91e4786 docs(dev): AskUserQuestion 사실전제 grep 선검증 규칙(#4) 등재 + \u 이스케이프 재발 기록
game-register.jsp 세션에서 orchestrator가 CSS 변수 대체 가능성을 grep
없이 옵션 전제로 제시했다가 재확인 왕복이 발생한 사례를 규칙 4로 명문화.
같은 세션에서 AskUserQuestion \u 이스케이프 손타이핑 실수(규칙 2)도
재발했음을 발원 사례에 추가 — 이번엔 스키마 검증에서 걸렸으나 항상
그렇게 걸리지는 않는다는 점을 명시.

세션 20260701-142000 report.md 기록.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 14:39:06 +09:00
이정수 4d0c2caceb docs(graph): full scope 증분 재생성 — 486+251 고스트 중복 추가 제거
graph-refresh-checker partial-stale 판정(db/seed-dev.sql 로직 변경 +
game-detail.jsp 함수 구조 변경) 대응. .jsp는 graphify 스캔 범위 밖이라
game-register.jsp/game-detail.jsp 자체는 그래프에 반영되지 않으나,
db/seed-dev.sql + 이번 세션 신규 docs 10건은 반영. build_merge 병합
과정에서 기존 그래프의 고스트 중복 486 exact + 251 fuzzy 를 추가로
제거(2089→1477 노드).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 14:36:00 +09:00
이정수 86b052813d feat(fe): game-register.jsp 리디자인 누락분 보강 + 재발방지 체크리스트
/game/new 가 2026-06-30 9단위 리디자인 목록에서 빠져 있던 것을 사용자가
지적해 발견. 실사용 스샷 대조 결과 레이아웃/토큰은 이미 sibling(recruit-form
등)과 일치해, 필수(*) 표시 2건 + 미리보기 eyebrow 라벨만 recruit-form.jsp
패턴 재사용으로 보강(단일 JSP, 로직 무변경). 로컬 CSS 토큰 전지역 통합은
sibling 관례와 갈라지므로 이번 세션 범위에서 제외, 별도 과제로 이월.

재발방지: frontend-redesign-coverage-checklist.md 신설(뷰 파일 23개 전수
상태표) + workflow-patterns.md 에 "전 페이지 반영 서술은 실제 파일 전수와
대조 필요" 패턴 등재.

L1 362/362 GREEN. L2 로그인 후 라이트/다크 육안 확인(known JSP stale
렌더링 함정 회피 위해 docker compose restart 후 재대조).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 14:25:47 +09:00
이정수 0205dca0f7 docs(dev): 사용자 의사결정/리포트 컨벤션 4건 workflow-patterns 로 이전 + 재발방지 규칙 등재
3개 독립 리뷰 서브에이전트(감사A/감사B/정책진단)의 수렴 결과에 따라:
- verification-strategies.md 에서 스코프 위반이 명확한 4개 항목(모호한 시각
  결함 다축스캔 선행, 시각 디자인 결정 프리뷰 공동확인, needs_user_verification
  결정분기 구조화, needs_user_verification 이월 시 known pitfall 교차인용)을
  workflow-patterns.md 로 이전 — 전부 "사용자와 어떻게 합의하는가"이지 코드
  정오 검증이 아님.
- design-advisor/orchestrator 운영 규율 성격 항목(SSR 영향맵, scope-fence,
  트레이드오프 확정, multi-worker git diff 교차검증, Test.java 소유태그,
  graphify 스캔범위)은 이번 라운드에서 보류 — ATP 메커니즘 성격이 강해
  별도 판단 필요.
- document-category-classification.md 에 재발방지 규칙 추가: 기존 문서에
  append 하기 전 그 문서의 자기선언 스코프와 먼저 대조하고, 같은 파일 안의
  선례를 정당화 근거로 삼지 않는다.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 12:26:08 +09:00
이정수 15023c0373 docs(dev): freeze 근거확인 + frontend-design fork 위임 패턴도 workflow-patterns 로 이전
verification-strategies.md 에 남아있던 나머지 두 "작업 방식" 패턴
(freeze/동결 분류 판단절차, frontend-design 스킬 fork 위임)을
workflow-patterns.md 로 이전. index.md 목록 설명도 항목 위치에 맞게 정정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 12:19:17 +09:00
이정수 4c54b9db38 docs(dev): 다중 UI 안 Artifact 시각비교 패턴을 verification 대신 workflow-patterns 로 재배치
검증 전략(verification-strategies.md)은 "코드가 옳은가" L1/L2/L3 판정
레지스트리 범위인데, 직전 커밋(810fa21)에서 사용자 합의 수렴 절차(작업
진행 방식)를 그 안에 잘못 등재했다. 신규 docs/development/workflow-patterns.md
로 옮기고 index.md 목록도 정정.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 12:07:51 +09:00
이정수 810fa211a5 docs(dev): 다중 UI 안 Artifact 시각비교 → AskUserQuestion 선택 수렴 패턴 등재
game-detail.jsp 리뷰 레이아웃 세션(20260701-113509)에서 검증된 워크플로우
— 실제 CSS 토큰을 이식한 Artifact 로 후보 N안을 한 화면 비교 제시 →
AskUserQuestion 으로 확정 → 그 후에만 소스 반영 — 를 재사용 가능한
긍정 패턴으로 verification-strategies.md 에 기록하고 index.md 목록에 반영.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 12:05:32 +09:00
이정수 76a118c4e9 chore(work-session): 리뷰 레이아웃 재구성 세션 보고서 기록
/atp:task 세션 20260701-113509 — Artifact 샘플 제시(4안) → 사용자 선택
(C. 타이틀 내부 통합형 + 요약 그래프-only) → game-detail.jsp 반영(0c8da40)
까지의 결정 로그·검증 결과 기록.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 12:01:27 +09:00
이정수 0c8da405e7 feat(review): 요약 패널 그래프 단독화 + 카드 6축 미터바를 제목행으로 이동
- 요약(상단) 패널: 육각 그래프에 이미 축명/점수 라벨이 표시되므로 옆의
  범례(미터바)를 제거하고 그래프만 중앙 배치 (buildRadarLegend 호출 삭제)
- 리뷰 카드: 컴팩트 육각 아이콘 + sr-only 범례를 걷어내고, 닉네임 옆
  제목행에 6축 미니 세로 미터바(game-reviews__title-meter)를 노출 —
  4개 배치 샘플(Artifact)을 사용자가 검토 후 C안(타이틀 내부 통합형) 선택
- buildRadarLegend/.game-reviews__axis-legend*/.game-reviews__card-radar
  등 완전히 죽은 코드 제거

검증: node --check (스크립트 블록, JSP EL 치환) 통과, docker compose app
재기동 후 /game/3 실제 렌더 확인(요약 그래프 중앙 단독, 카드 6곳 모두
제목행 미터바 노출), 콘솔 에러 없음. 자동화 테스트 대상 아님(순수 뷰/JS,
서버 로직·매퍼 변경 없음).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 11:59:24 +09:00
이정수 f086b12142 docs(dev): AskUserQuestion 비ASCII 리터럴 작성 + 복수대상 스코프확인 규약 추가
세션 회고 반영. 두 교훈 모두 이번 세션에서 실제 재질의 왕복 비용을
발생시킨 구조적 결함:
1. 한글을 \u 코드포인트로 수동 타이핑하다 오타로 2회 깨짐 → 리터럴
   UTF-8 작성 규칙 명문화.
2. "각 그래프"(복수) 지적을 미터바 단수로 임의 축소 해석 → 사용자
   정정("내가 말한건 육각 그래프였어") 발생 → 복수 표현 시 전체
   후보 나열 규칙 명문화.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 11:26:31 +09:00
이정수 7185b4a4ba docs(work-session): graphify full 재생성 보류 기록 — fork/Agent 재귀 금지 충돌
graph-refresh-checker partial-stale 판정(buildHexRadar 시그니처 변경) 후
재생성 시도 — fork 위임이 graphify SKILL 의 서브에이전트 필수 병렬
디스패치 요구와 정면충돌해 무산(248k 토큰 소모, 결과 0). 사용자 확인
후 이번 세션은 open_items 로 이관, 다음 구조적 변경 배치에서 처리.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 11:23:13 +09:00
이정수 a6417dd116 fix(review): 6축 육각 레이더에 축 이름 + 점수 라벨 직접 표시
미터바 픽스와 별개로 실제 지적 대상은 레이더 차트였음 — 육각형만으론
어떤 축인지, 꼭지점이 어떤 점수인지 식별 불가. buildHexRadar 에
withLabels 옵션 추가(요약 레이더만 적용, 리뷰카드 소형 레이더는
밀도 문제로 제외)해 각 축 끝에 이름을, 꼭지점 옆에 점수를 SVG
text로 배치. 동심 그리드도 4단→5단으로 바꿔 0~5점 정수 스케일과
1:1 대응시킴.

구현 중 우측 축 라벨이 옆 범례(dl)와 겹치는 문제 발견 — 원인은
범례가 radar wrapper 내부에 append되는 구조라 `.game-reviews__radar`
자체에 gap이 없었던 것. gap 추가로 해결, 라이트/다크 양쪽 스크린샷
재확인.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 10:41:07 +09:00
이정수 93efe21329 fix(review): 6축 미터바에 1~5 눈금선 오버레이 — 바 끝점 점수 가시성 확보
리뷰 종합 미터바가 끝점 위치만으로는 어느 점수인지 판단 불가하다는
지적. AskUserQuestion 4안 중 눈금선 방식 선택 — CSS ::before +
repeating-linear-gradient(mix-blend-mode: overlay)로 20% 간격 5등분
눈금 추가. JS/마크업 변경 없음.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011h6etRXJLx1xdfxmVcHjBg
2026-07-01 10:28:31 +09:00
이정수 7572e86605 docs(dev): SQL data-only 검증레벨 표 등재 + needs_user_verification known-pitfall 교차인용 규약
retrospective-advisor 회고(세션 20260701-100731) 반영:
- "버그 범주 → L 레벨" 표에 'SQL data-only(DML)' 행 추가 — L1 해당없음
  명시 + L2(API/DB 값 일치) 필수. 매 seed/백필 세션마다 즉흥 재정당화
  하던 스킵 근거를 레지스트리에 고정.
- needs_user_verification 이월 시 최근 세션에 docs화된 렌더 관련
  known pitfall(예: JSP stale 렌더링)과 경로가 겹치면 명시 인용하는
  규약 추가. 직전 세션(20260701-093754)의 JSP 즉시반영 실패 교훈이
  후속 세션에 인용 안 된 사례를 근거로 신설, 이번 report.md 에도
  소급 적용.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-07-01 10:16:33 +09:00
이정수 820d47099a fix(seed): 더미 리뷰 6축 스펙 백필 — game_review_axes 누락 보정
/game/3 더미 리뷰 5건이 6축 리뷰 스펙(GameReviewController.AXIS_KEYS)
도입 이전 데이터라 game_review_axes 행이 0개였음. parseAxes 는 6축
전부 필수라 신규 리뷰는 항상 축을 갖는데, 시드 더미만 예외 상태 —
game-detail.jsp 레이더 차트가 더미 리뷰에 빈 값으로 렌더됐다.

- db/seed-dev.sql: 리뷰 5건 각각 6축(immersion/creativity/controls/
  completeness/sound/visual) INSERT 추가. 평균 반올림이 기존 rating과
  일치하도록 설계 + 리뷰 본문 뉘앙스 반영. 멱등(NOT EXISTS 가드),
  구버전 seed 재실행 시에도 axis 백필되도록 처리.
- 라이브 dev DB(game_id=3)에 즉시 재적용해 백필 완료.

검증: seed-dev.sql 재실행 에러 0 / GET /game/3/reviews 200, 6건 전부
axes 6키 채움 / GET /game/3 200, 레이더 마크업 서빙 확인.
graph-refresh-checker: fresh(재생성 불필요, DML-only 변경).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-07-01 10:13:42 +09:00
이정수 e3890b3c6f docs(dev): JSP 즉시반영 실패 사례 + 검증 방법 정정
카드 여백 수정 세션에서 JSP CSS 수정이 "저장 즉시 반영" 문서 설명과
달리 컴파일 캐시로 반영 안 되는 사례 발견. 브라우저 스크린샷만으로
검증한 1차 확인이 stale 렌더링을 보고 false pass를 냄(사용자가
재캡처 스샷 대조로 발견). docker compose restart app으로 해소.

향후 JSP 수정 검증 시 curl로 서빙 CSS/마크업을 파일과 직접 대조하는
단계를 local-dev-setup.md 함정 항목으로 추가.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bn6vdHrUHqVzzUfC9da6fS
2026-07-01 09:55:51 +09:00
이정수 6e221d2ae6 fix(redesign): 카드 body 여백 재조정 — 고정 margin 대신 하단 앵커링
직전 커밋(b11546a)에서 늘린 여백이 과도하다는 사용자 피드백.
단순 롤백 대신 frontend-design 원칙 적용:
- .card__likes margin-top 0.45rem(고정) → auto (flex 하단 앵커링)
  제목 1줄/2줄 무관하게 좋아요 줄이 항상 카드 바닥에 정렬
- .card__body padding 0.7·0.75·0.75 → 0.65·0.75·0.7rem 소폭 축소

런타임 스모크: docker compose 컨테이너 기동 상태에서 GET / 200,
브라우저 fork로 라이트/다크 시각 확인 PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bn6vdHrUHqVzzUfC9da6fS
2026-07-01 09:47:31 +09:00
이정수 6b24386623 docs(dev): 시각 프리뷰 공동확인 + 정적 html charset 교훈
세션 20260701-083240 회고 반영.
- 시각 디자인 결정은 앱 정적경로(/css/) 프리뷰 서빙으로 사용자·에이전트 공동 확인
  (텍스트 diff 예측 한계). 결정 후 프리뷰 삭제.
- 정적 .html(DefaultServlet)도 meta charset 필수 — §299 의 정적파일 짝.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-07-01 09:34:26 +09:00
이정수 b11546a7af fix(redesign): 게임 카드 위계·여백 강화 (변경안 B)
시각 프리뷰 비교 후 사용자 선택(B). 카드 body 위계 또렷하게.
- .card__game-name 13px/600 → 15px/700, line-height 1.35→1.25 (제목 지배력↑)
- .card__likes 600/--text → 500/--text-muted, margin-top 0.15→0.45rem (stat 약화·분리)
  + 좋아요 카운트 <b> 래핑 → --accent 골드 강조 (숫자 시선)
- .card__body padding 0.5·0.625·0.625 → 0.7·0.75·0.75, gap 0.2→0.15rem

런타임 스모크: 재기동 후 GET / 200, 라이트/다크 육안 PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-07-01 09:33:36 +09:00
이정수 661e570069 chore(graph): 클린 풀 재생성 메타 갱신 + 타세션 work-session 리포트 추적
- docs/graph/index.md: source_commit 360078a→20789a2, 2089노드/4719엣지/128커뮤니티,
  고스트 중복(452 exact+244 fuzzy) 제거. "재생성 요청 중" 해소.
- ADR-0010: 직전 세션들(103443/105459/143405/160000/170024) 미커밋 work-session
  리포트 추적 편입(housekeeping, 코드 변경 없음).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 19:24:58 +09:00
이정수 20789a2922 docs(changes): UI 다축평가 비율·가시성 수정 사례 추가
세션 20260630-175023 을 changes 사례로 문서화. 다축 멀티에이전트
평가(6 에이전트)로 모호 시각어휘를 13건 결함으로 확장한 패턴 기록.
verification-strategies.md 교훈과 양방향 교차링크. graphify-out/ gitignore.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 19:24:08 +09:00
이정수 5158a1a55a chore(atp): 세션 20260630-175023 ended_at 기록
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 18:29:18 +09:00
이정수 c688aa67d8 docs(dev): 모호 시각어휘 다축 스캔 선행 교훈 + 세션 회고
세션 20260630-175023 회고 반영.
- verification-strategies.md: "모호 시각 결함 어휘는 단일 결정축 협소화
  전 다축 스캔 선행" 레슨 추가 + §4.4 item5 선행게이트 강화 권고.
- work-session 보고서 + 회고 섹션.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 18:29:10 +09:00
이정수 9489365fa2 fix(redesign): 카드 4:3 비율·검색바 정렬·다크 고스트버튼 가시성 외 13건
직전 리디자인에서 미해결된 2건(이미지#2 카드 비율, 이미지#3 필터 초기화
가시성)을 다축 UI/UX 평가로 13건 결함으로 확장해 일괄 수정.

비율/레이아웃:
- .card__media aspect-ratio 4/5→4/3 + .card__img object-position:center
  (가로형 웹게임 스크린샷 cover 크롭 ~55%→~25% 완화)
- .search-section 풀폭화 + .search-stack 컬럼 wrapper 신설로 검색행과
  상세검색 패널 우측 끝선 정렬(검색바 "혼자 짧음" 해소)
- .card-grid 고정 N열 → auto-fill(minmax 11rem)로 소량 카드 좌측쏠림 완화
- recruit .recruit-hero__titles max-width·검색 입력 max-width 48rem로 폭 균형

가시성/대비:
- .btn-ghost color: var(--color-on-accent)→var(--color-text)
  (다크 #3A2E15 on #222018 ≈1.1:1 → ≈13:1, WCAG AA 충족)
- 다크 --color-border 0.08→0.20(비텍스트 3:1), .sort-label 대비·위계 보강
- .btn:disabled 하드코딩색 → 토큰화

일관성/위계:
- recruit eyebrow mono/uppercase 통일, 빈상태 시 히어로 CTA 보조강등
  (화면당 골드 CTA 1개), .empty-state 패딩 clamp화

런타임 스모크: bibimbap-app 재기동 후 / + /recruit 200,
라이트/다크 양 화면 육안 검증 PASS. errer.jsp(JSTL) 무관·현재 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 18:24:56 +09:00
이정수 cf1e7cea62 fix(redesign): 골드 토큰 통일·클래스 충돌 정리
리디자인 검토에서 발견된 디자인 일관성 결함 수정.

- bibimbap.css: --color-accent #D4A853→#e8a54b(light+dark),
  --color-accent-press #C79A40→#d8973a. 앱 전역 브랜드 골드
  (#e8a54b, header/footer/modal/26파일)와 어긋나 모든 페이지에
  골드 2종이 공존하던 문제 해소. + .btn/.chip/.link/a focus-visible 링.
- profile.jsp: 페이지-로컬 .profile-* 를 source of truth 채택,
  합집합으로 붙은 중복 bibimbap 유틸(btn/avatar/thumb/meta/game-row
  /profile-head/card/section-*) 제거 → override 모호성 소멸.
- recruit-form.jsp: bibimbap 와 충돌하던 로컬 클래스(field/form-grid
  /req/count/preview-card/form-actions) rf- prefix rename. ID·JS 무변경.
- index.jsp: 중복 <link bibimbap.css> 제거(theme-init 가 전역 로드).

L1 mvnw package exit 0. L2 dev bind-mount 라이브 스모크 /·/login
/signup/terms/recruit/posts/error HTTP 200 + JSP 에러 0. 인증 게이트
페이지(recruit-form/profile) 렌더는 needs_user_verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 17:46:32 +09:00
이정수 d379a1c353 docs(dev): 정적 include jspf pageEncoding 교훈 추가
브라우저 리뷰에서 빈상태 파편 mojibake 발견(fix a4d163d). 정적
include 파편은 부모 pageEncoding 미상속 → 자체 UTF-8 선언 필수.
JSP 검증 섹션에 재현성 교훈 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:56:00 +09:00
이정수 a4d163db5e fix(redesign): 빈상태 파편 UTF-8 인코딩 깨짐 수정
posts-empty/recruit-empty.jspf가 <%@ include file %>(정적 include)로
삽입되는데 파편에 pageEncoding 미선언 → Jasper가 기본 ISO-8859-1로
읽어 한글 mojibake. 정적 include는 파일별 인코딩이 독립 결정되므로
각 파편에 <%@ page pageEncoding="UTF-8" %> 추가. 브라우저 스모크로
빈상태 한글 정상 렌더 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:53:18 +09:00
이정수 8d52b1082f docs(dev): JSP 변경 런타임 스모크 의무 교훈 추가
회고 교훈 jsp-runtime-smoke-gate 반영. JSP 사전컴파일 미설정이라
WAR 빌드 PASS가 EL/taglib 오류를 은닉 → JSP 변경 시 런타임 스모크
의무 + 디자인 이식 stray taglib grep 체크. 근거: errer.jsp JSTL
taglib 결함(세션 20260630-160000).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:44:57 +09:00
이정수 f87aeee34b docs(changes): 비주얼 레이어 리디자인 변경 로그
bibimbap.css 신설 + 9단위 JSP 비주얼 이식 결과·하드제약 준수·
L1/L2 검증·errer JSTL taglib 결함 수정·needs_user_verification 기록.
docs/changes/index.md 링크 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:42:42 +09:00
이정수 8c86e8c4e1 docs(graph): index.md 메타 갱신 — source_commit 360078a
리디자인 비주얼 레이어 반영. 뷰 fragment 2종(posts-empty/recruit-empty
.jspf)·공유 bibimbap.css·docs 신규 노드 + posts-list/recruit-list
include 엣지 2건 반영. last_generated_at·Scopes 표 갱신.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:40:16 +09:00
이정수 360078a369 fix(redesign): errer.jsp 미사용 JSTL taglib 제거
errer 비주얼 이식 시 디자인서 딸려온 javax JSTL taglib
(http://java.sun.com/jsp/jstl/core) 1줄이 stray로 남아 Jakarta EE
환경에서 미해소 → /error 500(JasperException). 본문은 순수 scriptlet
분기라 JSTL 미사용 → 해당 taglib 제거. 로컬 재기동 스모크로 /error 200 확인.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:36:59 +09:00
이정수 239d33c218 feat(redesign): 모집 작성 필수표시/글자수/sticky 미리보기
recruit-form.jsp에 .req 필수표시(4곳), summary 글자수 카운터
(.count/#sc, 독립 oninput), .preview-card sticky 외형 추가.
제출은 기존 AJAX(fetch /recruit/new)+BibimbapCsrf.headers·필드명
10종(projectName 등) 보존, render() 미리보기 IIFE 무손실.
디자인 name=title·/recruit·CSRF누락 form 미채택.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:26:59 +09:00
이정수 e998847139 feat(redesign): 프로필 헤더/내 게임 행 비주얼 레이어
profile.jsp에 .profile-head·.game-row·.avatar·.card 외형 클래스 적용.
모델은 기존 scriptlet(session attr·myGames getName/getThumbnailUrl) 보존,
디자인 ${user.*} EL 미채택. submitNickname/uploadAvatar AJAX +
BibimbapCsrf.headers·JS참조 id 불변. 미존재 라우트(공개토글
/games/{id}/visibility, /profile/edit)는 비노출.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:25:02 +09:00
이정수 d618087d0b feat(redesign): 에러페이지 empty-state 외형 + 코드 분기
errer.jsp에 디자인 SVG 아이콘·.empty-state 외형 이식,
403/404/기타 3분기 확장. status_code scriptlet 코드읽기 방식과
isErrorPage·requestUri/message 상세 블록 보존(errorData EL 미채택).
뷰명 errer 유지.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:22:09 +09:00
이정수 29182a3124 feat(redesign): 약관 sticky 목차 + 조항 카드 구조
terms.jsp에 .terms grid + .terms-toc sticky 목차(앵커 #a1~#a10)
신규 추가, h2→h3 변환·section id 부여. 조항 본문 텍스트는 무손실
보존(전문 유지), 구조 래핑만. 자체 head/include 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:22:09 +09:00
이정수 f266aeef9b feat(redesign): 로그인/회원가입 비주얼 마크업 이식
비번 보기 토글(.pw-field/.pw-toggle), 강도막대(.pw-meter),
일치표시(.form-ok), 에러 슬롯(.form-error) DOM/클래스 추가.
제출은 기존 AJAX(fetch /login·/signup)+CSRF 이중방어(_csrf hidden
+ BibimbapCsrf.headers) 그대로 보존, 필드 name·id 불변.
토글/강도/일치는 인라인 onclick/oninput 별도 스코프 — submit 무간섭.
/password/reset 링크는 라우트 미존재로 비노출.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:18:19 +09:00
이정수 5f0652edda feat(redesign): 홈 히어로/정렬칩/게임 그리드 비주얼 레이어
index.jsp에 히어로 섹션·sort-bar 칩·game-grid/game-card 외형 이식.
모델 EL은 기존 scriptlet(getName/getCreator/getThumbnailUrl) 보존,
디자인 title/author·/games/new 미채택. 정렬칩은 SearchController가
바인딩하는 sort 파라미터(latest/likes/views) 사용. 기존 head·검색폼·
include 전부 보존.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:13:52 +09:00
이정수 45fdfb640e feat(redesign): 빈상태 파편 2종 + 목록 페이지 include
fragments/posts-empty.jspf·recruit-empty.jspf 신설(.empty-state).
CTA href 실제 라우트로 교정(/posts/new·/recruit/new 단수).
posts-list·recruit-list 빈 분기에 include. recruit-list는 서버
초기 빈상태=파편 / 클라필터 0건=기존 #recruit-empty div 역할 분리,
필터 JS 무수정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:13:52 +09:00
이정수 0390da6ae8 feat(redesign): 공유 bibimbap.css 도입 + theme-init 단일 link
리디자인 산출물 css를 src/main/webapp/css/bibimbap.css로 신설.
폰트는 system fallback 보강(외부 CDN 0, Pretendard 로컬 우선).
다크 오버라이드 블록 추가 — header.jsp data-theme=dark 토큰 차용해
body 컴포넌트와 header/footer 다크모드 정합.
theme-init.jsp(23개 페이지 공통 include)에 link 1줄 추가.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 16:07:25 +09:00
이정수 74e7ea1a32 docs(redesign): 클로드 디자인 산출물 반영 검토 + 새 세션용 핸드오프 프롬프트
외부 디자인 툴 산출물(리디자인 zip: 7 JSP + bibimbap.css + 3 jspf + error.jsp)을
전수 검토하고 우리 실제 코드와 design↔reality 매핑을 확정. 코드 변경 없음(검토 세션).

핵심: 산출물은 라우트(/games 복수 vs 실제 /game 단수), 폼 제출(classic POST vs
JSON API+AJAX), CSRF(전무 vs CsrfTokens/_csrf/window.BibimbapCsrf), contextPath
(${ctx} vs ${pageContext.request.contextPath}), include(common/ 가정 vs 평면),
에러(web.xml/error.jsp vs errer 뷰), css 디렉토리(없음) 가 모두 불일치 →
파일 통째 교체 금지, 공유 bibimbap.css 신설 + 비주얼 레이어 이식으로 결론.

산출물: artifacts/handoff-prompt.md (새 세션 작업 프롬프트 + 매핑표 + 하드 제약 + 순서).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 15:52:54 +09:00
이정수 925eb73a62 test(seed): 관리자 테스트 계정 추가 (admin@bibimbap.local)
기존 tester 계정은 USER 용으로 유지하고,
admin@bibimbap.local / test1234! (role=ADMIN) 를 seed-dev.sql 에 추가.
screenshot-guide.md 계정 표를 용도·role 포함 2행으로 확장.
teardown 은 %@bibimbap.local 전체 삭제라 수정 불필요.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 14:36:31 +09:00
이정수 aa55524191 docs(screenshot): 인증 흐름 추가 변경 기록 및 교차 링크
- docs/changes/2026-06-30-screenshot-guide-auth.md 신규 작성
- docs/changes/index.md 링크 추가
- docs/development/screenshot-guide.md 변경 이력 섹션 추가
- .atp/work-session/20260630-141415/ 세션 보고서 포함

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 14:32:15 +09:00
이정수 872f51c91c docs(screenshot): 테스트 계정 정보 + 전체 실행 명령 추가
tester@bibimbap.local / test1234! — seed-dev.sql user3

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 14:29:11 +09:00
이정수 6e6d6b2b46 fix(docs): 로그인 nav 감지 expect_navigation으로 교체 + 오류 stdout 출력
wait_for_url lambda → expect_navigation 컨텍스트 매니저로 교체.
로그인 실패 오류 메시지를 stderr 아닌 stdout에도 출력.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 14:26:07 +09:00
이정수 5dd639fd7a feat(docs): 스크린샷 가이드 — 인증 후 세부 화면 캡처 추가
로그인 전 7장만 캡처하던 스크립트를 확장:
- SCREENSHOT_EMAIL/PASSWORD env var로 인증 세션 획득
- 로그인 후 홈·프로필·게시글폼·모집폼 캡처
- game-detail 리뷰·댓글 composer hidden 제거 후 캡처
- AJAX 로그인 흐름(fetch + window.location) wait_for_url 대응

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0162BaZnrbiYgWc9JJMZ9Czb
2026-06-30 14:22:20 +09:00
이정수 9098325e7c docs(dev): Playwright 헤드리스 스크린샷 가이드 추가
- screenshot-guide.py: 로컬 Chrome 헤드리스로 7개 주요 페이지 캡처
- 출력 경로: /tmp/bibimbap-screenshots/ (Claude Design 연동용)
- index.md 링크 추가

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017mhnv7hDExHaXUmbaYAxZd
2026-06-30 14:10:34 +09:00
이정수 5eb6f3950e chore(atp): work-session 20260630-123347 보고서 2026-06-30 12:36:58 +09:00
이정수 a499fd9f33 docs(guidelines): 확실성 우선 원칙 추가 — 빠름보다 검증된 경로 선택 2026-06-30 12:36:30 +09:00
이정수 3726ce54f2 docs(retro): 세션 20260630-113258 회고 교훈 3건 docs 반영
- CLAUDE.md: 정적 CSS/JS Tomcat DefaultServlet 서빙 경로 규약 추가
- security-remediation-checklist: B5 recruit-form CSRF 폴백 갭 (P0) 추가
- verification-strategies: graphify 스캔 범위 JSP/CSS/JS 미포함 교훈 추가
- report.md: ended_at 기록

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 12:11:04 +09:00
이정수 770d3922e2 docs(graph): full scope graphify 재생성 — frontend 분석 + PostsMapper 반영 (5e0f76b)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 12:06:00 +09:00
이정수 5e0f76b7c1 docs(analysis): JSP 프론트엔드 컴포넌트 중복·유지보수 검토
- CSS 디자인 토큰 23개 파일 중복, JS 유틸 8중복,
  recruit-form CSRF 폴백 갭, 날짜 함수 편재 발견
- 권고: global.css 중앙화(P0), bibimbap-utils.js(P1),
  recruit-form CSRF 수정(P0-보안), bibimbap-date.js(P2)
- work-session 아티팩트: research + design recommendations 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 11:53:15 +09:00
이정수 327fe1df25 test(posts): null cursor 회귀 테스트 추가 (168671b)
listPublishedKeyset 에 cursor=null·categoryId=null 전달 시
posts-list 뷰 정상 반환 검증.
isNull() matcher 로 null 파라미터가 매퍼에 실제 전달됨을 명시.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 11:31:23 +09:00
이정수 db816e10fc docs(dev): MyBatis null 캐스트 규약 + docker-compose override --build 함정 기록
verification-strategies.md: MyBatis annotation SQL에서 nullable 파라미터를
#{p} IS NULL 조건으로 사용 시 ::bigint/::timestamptz 명시 캐스트 필수 규약 추가.
컴파일·L1 MockBean 테스트로 탐지 불가 — 코드리뷰 rg 체크 항목 포함.

local-dev-setup.md: docker-compose.override.yml 존재 시 `docker compose up --build`
(base 명시 없이)는 override 의 build: !reset null 로 무효화된다는 경고 추가.
로컬 Java 변경 반영은 `docker compose restart app` 으로 충분함을 명시.

근거: 세션 20260630-105459 포스팅 500 버그 수정 회고.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 11:23:24 +09:00
이정수 168671bc14 fix(posts): PostgreSQL untyped NULL 파라미터 500 + category_id UPDATE 누락
listPublishedKeyset SQL에서 #{categoryId}/#{cursorCreatedAt} IS NULL 조건 사용 시
PostgreSQL이 바인드 파라미터 타입을 결정하지 못해
PSQLException: could not determine data type of parameter $1 → 500 발생.
파라미터에 ::bigint / ::timestamptz 명시 캐스트 추가로 수정.

update() SQL에서 category_id = #{categoryId} 컬럼 누락 — 카테고리 변경이
DB에 반영되지 않는 데이터 정합성 버그 함께 수정.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K3FeMrbtxfTScjrwUukyHD
2026-06-30 11:16:52 +09:00
이정수 8a3eea54ce chore(dep): NVD API key 환경변수 연동 profile 추가
NVD_API_KEY 환경변수 존재 시 nvd-api-key 프로파일 활성화,
dependency-check에 키 전달 → rate limit 완화 (5 → 50 req/30s).
환경변수 없으면 프로파일 비활성 — 기존 동작 유지.
2026-06-30 10:44:55 +09:00
68 changed files with 5236 additions and 457 deletions

View File

@ -0,0 +1,56 @@
# ATP Session Report
schema_version: "2"
sid: 20260630-103443
user_request: |
dependency-check:update-only 실행 시 NVD retry 경고 반복, 설치가 안 된다고 판단
started_at: 2026-06-30T10:34:43+09:00
## Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: 요청 명확 — NVD download 왜 느린지 + 설정 방법
checked_at: 2026-06-30T10:34:43+09:00
- advisor: research-advisor
decision: skip
rationale: NVD API key 설정법은 공개 문서 기준 이미 알려진 사항
checked_at: 2026-06-30T10:34:43+09:00
- advisor: design-advisor
decision: skip
rationale: 코드 변경 없음, 설정 옵션 2가지 제시
checked_at: 2026-06-30T10:34:43+09:00
- advisor: implementation-advisor
decision: skip
rationale: pom.xml 수정 여부 사용자 선택 후 마이크로 편집
checked_at: 2026-06-30T10:34:43+09:00
- advisor: verification-advisor
decision: call
rationale: pom.xml 수정 시 빌드 검증 필요 (현재 코드 변경 없어 skip)
checked_at: 2026-06-30T10:34:43+09:00
## Diagnosis
- 프로세스 PID 89764 현재 실행 중 (9:20AM 시작, 1시간 10분째)
- DB 위치: ~/.m2/repository/org/owasp/dependency-check-data/11.0/odc.mv.db (80MB)
- 현재 다운로드 중: ~6% (20,000 / 361,769 records)
- 원인: NVD API 키 없음 → rate limit 5 req/30s → retry 반복
- 결론: 실패가 아니라 극도로 느린 정상 동작
## Summary
NVD API Key 미설정으로 rate limiting 발생. 키 없이 완료까지 예상 1~3시간.
키 획득 후 30배 빠른 다운로드 가능.
## Invocations: []
## verified_by_me
- (코드 변경 없음 — 검증 skip)
## needs_user_verification
- NVD API Key 발급 (사용자 직접)
- update-only 재실행 확인
## graph_refresh
skip: no code change
## open_items: []
ended_at: TBD

View File

@ -0,0 +1,71 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: 2026-06-30T02:18:20Z
concerns: []
concerns_checked: true
---
# 검증 결과
## Acceptance Criteria (입력 받은 그대로 인용)
1. `GET /posts` 응답 200 반환
2. 앱 로그에 `PSQLException: could not determine data type of parameter` 에러 없음
3. PostsMapper.java `listPublishedKeyset` SQL에 `::bigint`, `::timestamptz` 캐스트 존재
4. PostsMapper.java `update()` SQL에 `category_id = #{categoryId}` 포함
## 실행된 전략
변경 scope: `src/main/java/com/pandoli365/bibimbap/mapper/PostsMapper.java` (MyBatis 매퍼 SQL 변경)
전략 규칙 매핑: MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 → L1 + L2 (dev DB contract) + 런타임 스모크
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| L1-unit | `./mvnw -o test` | 0 | blocker | pass |
| L2-runtime-smoke | `curl -s -o /dev/null -w "%{http_code}" http://localhost:8080/posts` | 0 | blocker | pass |
| L2-log-scan | `docker logs bibimbap-app --since=5m \| grep -i "PSQLException\|could not determine"` | 1(no match) | blocker | pass |
| L1-code-grep | `grep -n "::bigint\|::timestamptz\|category_id = " PostsMapper.java` | 0 | blocker | pass |
### L1 단계 분해
| 단계 | 결과 |
|---|---|
| L1 컴파일 (javac 121 소스) | pass |
| L1 unit+regression (361 tests) | pass — Tests run: 361, Failures: 0, Errors: 0, Skipped: 0 |
| L2 contract (dev DB, GET /posts) | pass — HTTP 200 |
| L2 로그 스캔 (PSQLException) | clean — 0건 |
## 실패 상세 (해당 시)
없음.
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| AC1: GET /posts 응답 200 | L2 런타임 스모크 (`curl -w "%{http_code}"` → `200`) | pass |
| AC2: 로그에 PSQLException 없음 | L2 로그 스캔 (`docker logs \| grep` → 0건) | pass |
| AC3: listPublishedKeyset에 `::bigint`, `::timestamptz` 캐스트 존재 | L1 코드 grep (line 44: `#{categoryId}::bigint`, line 46: `#{cursorCreatedAt}::timestamptz`, line 47: `#{cursorId}::bigint`) | pass |
| AC4: update() SQL에 `category_id = #{categoryId}` 포함 | L1 코드 grep (line 122: `SET category_id = #{categoryId},`) | pass |
## 코드 grep 상세 (AC3/AC4)
```
44: AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
46: #{cursorCreatedAt}::timestamptz IS NULL
47: OR (p.created_at, p.id) < (#{cursorCreatedAt}::timestamptz, #{cursorId}::bigint)
122: SET category_id = #{categoryId},
```
- AC3 `::bigint`: line 44, 47 확인
- AC3 `::timestamptz`: line 46, 47 확인
- AC4 `category_id = #{categoryId}`: line 122 (`SET category_id = #{categoryId},`) 확인
## 종합 판정
```
overall: pass
rollback_signal: none
```

View File

@ -0,0 +1,175 @@
---
schema_version: "2"
sid: "20260630-105459"
started_at: "2026-06-30T10:54:59"
ended_at: "2026-06-30T11:32:00"
user_request: "포스팅 메뉴 진입시 500 Error 발생해"
Summary: |
PostsMapper.listPublishedKeyset SQL에서 null 파라미터를 타입 없이 PostgreSQL에 전달해
PSQLException: could not determine data type of parameter $1 → 500 발생.
::bigint / ::timestamptz 명시 캐스트 추가로 수정.
update() SQL category_id 누락 버그 함께 수정. GET /posts 200 확인.
Invocations:
- agent: research-advisor
result: "포스팅 Controller/Mapper 구조 파악 + 500 원인 후보 목록 작성"
- agent: verification-advisor
result: "AC 4항목 전체 pass (HTTP 200, PSQLException 0건, SQL 캐스트 확인, category_id 확인)"
Decisions:
- "PSQLException $1 타입 미결정 → ::bigint / ::timestamptz 캐스트로 수정 (테이블 미존재 가설은 DDL 멱등 적용 후 NOTICE already-exists로 기각)"
- "update SQL category_id 누락 → 동일 파일 수정에 포함"
verified_by_me:
- "L1: compile pass (spring-boot:run 재시작 컴파일)"
- "L2: GET /posts → HTTP 200"
- "log scan: PSQLException 0건 (clean)"
needs_user_verification: "(없음)"
graph_refresh: "skip: no-graphify"
user_signals:
positive: []
negative: []
open_items:
- "회귀 테스트 미추가 (버그 수정 규약 미준수): PostControllerTest에 'null cursor 파라미터로 GET /posts 200' 시나리오 추가 필요. 후속 세션에서 처리."
commit: "168671b"
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — 포스팅 메뉴 진입 시 500 에러. 추가 요구사항 분해 불필요.'
checked_at: "2026-06-30T10:54:59"
- advisor: graphify-lookup-advisor
decision: skip
rationale: 'graphify add-on 없음. research-advisor 직접 호출.'
checked_at: "2026-06-30T10:54:59"
- advisor: design-advisor
decision: skip
rationale: '원인 확정 후 수정 경로 단일 — 파라미터 캐스트 + SQL 컬럼 추가. 설계 분기 없음.'
checked_at: "2026-06-30T11:20:00"
- advisor: implementation-advisor
decision: skip
rationale: '단일 파일 2-hunk 수정. orchestrator 직접 수행.'
checked_at: "2026-06-30T11:20:00"
---
Retrospective:
signals:
positive: []
negative: []
what_went_well:
- "초기 가설(테이블 미존재) DDL 멱등 적용 → NOTICE already-exists 로 빠르게 기각하고,
앱 로그 확인으로 실제 원인(PSQLException $1 타입 미결정)을 2단계 이내에 특정했다.
'가설 → DDL 검증 → 로그 확인' 순서가 500 버그 디버깅 효율 측면에서 유효했음."
- "단일 파일 2-hunk 수정(캐스트 추가 + category_id 누락)에 implementation-advisor를 거치지 않고
orchestrator가 직접 수행 — 불필요한 위임 없이 세션 시간을 절약."
- "두 번째 버그(update SQL category_id 누락)를 동일 파일 수정에 포함해 커밋 경계를 깔끔하게 유지."
what_to_improve:
- "PostsMapper.listPublishedKeyset 는 '#{param}::cast IS NULL' 패턴으로 null을 처리하는 반면,
JamsMapper·GamesMapper의 keyset 쿼리는 '<if test=\"... != null\">' 동적 XML 분기로 null을 회피한다.
두 패턴이 혼재하며, '::cast IS NULL' 패턴은 PostgreSQL이 prepared statement $1의 타입을 추론할 때
null 리터럴을 타입 미지정으로 전달하면 PSQLException이 발생한다는 함정이 있다.
이 패턴을 사용하는 매퍼는 반드시 명시 캐스트(::bigint / ::timestamptz 등)가 있어야 하며,
docs에 규약으로 기록하지 않으면 신규 매퍼 작성 시 동일 패턴 재발이 높다."
- "버그 재현 테스트(회귀 테스트) 없이 커밋됐다. verification-strategies.md 에는
'버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다'고 명시돼 있으나 이번 세션은 적용하지 않았다."
- "docker-compose.override.yml 존재 시 '도커 이미지 재빌드가 필요한 것 아닌가' 라는 혼선이 발생했다
(회고 포인트 3). override가 base image를 교체하기 때문에 docker compose up --build 는 override 환경에서
이미지 rebuild 후에도 override가 그 이미지를 다시 무시한다. local-dev-setup.md에 이 함정을
명시적 경고로 추가하지 않으면 재발 가능."
memory_candidates:
- name: mybatis-postgres-null-param-explicit-cast
type: feedback
description: "MyBatis + PostgreSQL에서 nullable 파라미터를 '#{p}::type IS NULL' 패턴으로 쓸 때 명시 캐스트 필수 — 미지정 시 PSQLException $N 타입 미결정"
body_draft: |
## Why
PostgreSQL은 prepared statement에서 null 리터럴의 타입을 추론할 수 없다.
MyBatis가 null 파라미터를 바인딩하면 $N의 타입이 결정되지 않아
`PSQLException: could not determine data type of parameter $1` 이 발생한다.
## 패턴 및 규약
nullable Long/OffsetDateTime 파라미터를 '#{p} IS NULL' 조건으로 쓸 때:
- **필수**: `#{p}::bigint`, `#{p}::timestamptz` 등 명시 캐스트 추가
- **대안**: `<if test="p != null">` 동적 XML 분기로 null 케이스 분리
현재 bibimbap 매퍼에는 두 패턴이 혼재함:
- PostsMapper.listPublishedKeyset → '::cast IS NULL' 패턴 (캐스트 추가로 수정됨 commit 168671b)
- JamsMapper.listVisibleKeyset, GamesMapper.listVisibleKeyset/searchVisibleKeyset → '<if test>' 분기 패턴
신규 keyset 페이징 매퍼 작성 시 '<if test>' 분기를 우선 권장.
'::cast IS NULL' 패턴을 쓴다면 명시 캐스트 누락 여부를 code review에서 확인한다.
## How to apply
1. nullable 파라미터를 IS NULL 조건으로 쓰는 MyBatis SQL을 rg로 전수 확인:
`rg '#{[^}]+}\s+IS\s+NULL' src/main/java --type java`
2. 각 라인에 `::type` 캐스트가 없으면 추가.
3. 또는 해당 조건 블록을 `<if test="p != null">` 으로 재작성.
rationale_for_saving: "동일 패턴 재발 가능성 높음 — 신규 페이징 매퍼 추가 시마다 잠재적으로 발생. 코드에서 유도 불가(컴파일 시점에 탐지 안 됨, L1 단위테스트도 @MockBean으로 회피)."
signal_source: observation
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md"
memory_optional: true
- name: docker-compose-override-masks-build
type: feedback
description: "docker-compose.override.yml 존재 시 'docker compose up --build' 로 Dockerfile 이미지를 새로 구워도 override가 base image를 교체하므로 빌드가 로컬 dev에 반영되지 않는다"
body_draft: |
## Why
`docker compose up --build app` 은 base `docker-compose.yml``build:` 지시를 실행해
Dockerfile로 WAR를 굽는다. 그러나 `docker-compose.override.yml`이 존재하면
override가 `app` 서비스의 image/command/volumes를 재정의하므로
방금 구운 이미지는 override에 의해 무시된다.
## 규약
- 로컬 dev: `docker compose up -d app` (--build 불필요/무효)
코드 변경 반영: `docker compose restart app`
- 배포 이미지 빌드: override를 제외하고 base만 명시
`docker compose -f docker-compose.yml up -d --build app`
## How to apply
`--build` 를 쓰기 전에 `docker-compose.override.yml` 존재 여부 확인:
```bash
ls docker-compose.override.yml
```
존재하면 로컬 dev 경로(`restart`)를 사용하고,
Dockerfile 이미지가 필요한 경우 `-f docker-compose.yml` 로 override를 명시적으로 제외한다.
rationale_for_saving: "override 존재 사실 자체는 local-dev-setup.md에 있으나 '--build 무효' 함정이 명시되지 않아 재발 가능. 코드에서 유도 불가."
signal_source: observation
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/local-dev-setup.md"
memory_optional: true
- name: bug-fix-regression-test-required
type: feedback
description: "버그 수정 커밋에 재현 테스트 포함 의무(verification-strategies.md 기존 규약) — 이번 세션에서 미적용"
body_draft: |
## Why
verification-strategies.md §회귀 테스트 의무:
"버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다.
revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다."
이번 세션(20260630-105459)에서 PSQLException 재현 테스트를 추가하지 않았다.
MyBatis + PostgreSQL 통합 계층이라 L1 단위테스트만으로는 재현이 어렵지만,
최소한 PostControllerTest에 "null cursor 파라미터로 GET /posts 200" 시나리오를 추가해야 함.
## How to apply
버그 수정 세션 종료 전 체크:
1. 버그 재현 조건(null cursor)을 트리거하는 테스트가 존재하는가?
2. 없으면 unit 레벨(mock) 테스트라도 추가 — 매퍼 호출 시 null 파라미터 경로 포함.
3. open_items에 "회귀 테스트 추가" 를 명시하고 후속 세션에서 처리.
rationale_for_saving: "기존 규약이 있음에도 이번 세션에서 미준수. open_items에도 없음 — 다음 세션이 이를 모르면 영구 누락 가능."
signal_source: observation
docs_sync_target: null
memory_optional: false
protocol_feedback:
- "버그 수정 세션에서 verification-advisor가 회귀 테스트 존재 여부를 AC 항목으로 체크하지 않았다.
verification-advisor 체크리스트에 '버그 수정 커밋 시 재현 테스트 존재 여부 확인' 항목을 추가 권고.
(verification-strategies.md §회귀 테스트 의무 조항이 verification-advisor의 실행 체크리스트에 연결되지 않음 — 구조적 갭.)"
- "연구 후보 가설을 DDL로 검증하는 과정(already-exists NOTICE로 기각)은 효율적이었지만,
프로토콜상 '초기 가설 검증 우선순위: 앱 로그 → DDL 검증' 순서를 명시하는 규약이 없다.
버그 디버깅 세션 체크리스트에 '먼저 앱 로그/에러 스택 확인 후 외부 가설(DB 스키마 등) 검증' 순서를 추가하면
DDL 적용 같은 부작용이 있는 검증 단계를 줄일 수 있다."
applied_changes: []

View File

@ -0,0 +1,190 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-30T11:10:00+09:00
concerns:
- "PostsMapper.update() SQL에 category_id 누락 — 카테고리 변경이 DB에 반영되지 않음 (버그, 500 직접 원인은 아님)"
- "PostController의 editForm/update/delete 핸들러가 getPublished()를 사용해 DRAFT 포스트 조회 불가 — DRAFT 저장 후 재편집 시 null → redirect:/posts 또는 404 (500 간접 원인 후보)"
- "posts-detail.jsp에서 post attribute null 체크 없이 post.getTitle() 등 직접 접근 — post가 null이면 NullPointerException → 500"
- "PostAdminController에 인증/권한 체크 부재 — Spring Security 미사용 환경에서 /admin/** RbacInterceptor 보호 범위 확인 필요"
source_confidence: high
workers_spawned: 4
---
# 조사 결과
## 주제
bibimbap 프로젝트 포스팅 메뉴 진입 시 500 Error 원인 조사
---
## 포인트별 발견
### 포인트 1: 포스팅 Controller URL 매핑
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostController.java`
- 신뢰도: 확인됨
| HTTP | 경로 | 핸들러 메서드 | 반환 |
|------|------|-------------|------|
| GET | `/posts` | `list` | View: `posts-list` |
| GET | `/posts/{id}` | `detail` | View: `posts-detail` (null이면 redirect:/posts) |
| GET | `/posts/new` | `createForm` | View: `posts-form` (미인증 시 redirect:/login) |
| POST | `/posts` | `create` | JSON ResponseEntity |
| GET | `/posts/{id}/edit` | `editForm` | View: `posts-form` (미인증 시 redirect:/login) |
| POST | `/posts/{id}` | `update` | JSON ResponseEntity |
| POST | `/posts/{id}/delete` | `delete` | JSON ResponseEntity |
- 경로: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/java/com/pandoli365/bibimbap/controller/PostAdminController.java`
- 신뢰도: 확인됨
| HTTP | 경로 | 핸들러 메서드 |
|------|------|-------------|
| GET | `/admin/post-categories` | `postCategoriesPage` → View: `admin-post-categories` |
| POST | `/admin/post-categories` | `createCategory` |
| POST | `/admin/post-categories/{id}` | `updateCategory` |
| POST | `/admin/post-categories/{id}/delete` | `deleteCategory` |
---
### 포인트 2: Service / Mapper 체인
- 신뢰도: 확인됨
**PostController 의존 Bean:**
- `PostsMapper` (annotation 기반 MyBatis) — `listPublishedKeyset`, `getPublished`, `insert`, `update`, `softDelete`
- `PostCategoriesMapper``listActive`, `getActive`, `getById`, `insert`, `update`, `delete`, `countPostsByCategory`
- `PostMarkdownService` — CommonMark 파싱 + Jsoup basicWithImages safelist sanitize
- `OgPreviewService` — SSRF-safe HTTP fetch + OG 메타 파싱, 실패 시 graceful empty
**별도 PostService/PostServiceImpl 없음.** 비즈니스 로직이 Controller에 직접 집약. XML Mapper 없음, 전부 `@Select`/`@Insert`/`@Update` annotation.
---
### 포인트 3: 500 에러 원인 후보 (가능성 순)
#### [1순위 — 가장 유력] posts-detail.jsp NPE (확인됨)
- 파일: `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp`
- `PostData post = (PostData) request.getAttribute("post");` 이후 null 체크 없이 `post.getTitle()`, `post.getId()` 등 직접 접근
- **`post` attribute가 null인 상태로 JSP가 렌더링되면 NullPointerException → 500**
- 발생 경로: `PostController.detail()``postsMapper.getPublished(id)`를 호출해 null을 받으면 `redirect:/posts`로 분기하므로 Controller 레벨에서는 방어됨. 그러나 다른 진입 경로(forward, include, 테스트 호출)에서 post attribute 누락 시 노출 가능.
#### [2순위] DRAFT 포스트 조회 시 null 처리 흐름 (확인됨)
- 파일: `PostController.java` line 167, 206, 252
- `editForm`, `update`, `delete` 핸들러 모두 `postsMapper.getPublished(id)` 사용
- `getPublished` SQL: `WHERE p.status = 'PUBLISHED' AND p.is_delete = false`
- **DRAFT 상태로 저장된 포스트 ID로 editForm 접근 시 → null → `if (post == null) return "redirect:/posts"`** — 500이 아닌 redirect지만, JS fetch로 update/delete 호출 시 null → NPE 가능성:
- `PostController.update()` line 206: `existing.setCategoryId(categoryId)``existing`이 null이면 NPE → 500
- `PostController.delete()` line 252: `postsMapper.softDelete(id)` 호출 전 null 체크 여부 추가 확인 필요
#### [3순위] PostsMapper.update() — category_id 컬럼 누락 (확인됨)
- 파일: `PostsMapper.java` line 120-135
- UPDATE SQL 대상 컬럼: `title, body_markdown, body_sanitized_html, link_url, og_*, status, updated_at`
- **`category_id = #{categoryId}` 누락** → 카테고리 변경이 DB에 반영되지 않음
- 500 직접 원인은 아니나, 카테고리 변경 요청 시 데이터 정합성 손상
#### [4순위] PostsMapper.insert() 직후 ID null 체크 (확인됨)
- `PostController.create()` line 149: `if (post.getId() == null)` 체크 후 500 응답
- DB INSERT 실패(constraint violation, DB 연결 오류 등) 시 MyBatis 예외 → Spring이 500으로 변환
- 포스트 생성 시 `category_id` FK 참조 실패(비활성 카테고리 등) 가능
#### [5순위] Bean 주입 / 빌드 이슈 (확인됨 — 현재 정상)
- Spring Boot 3.5.16, MyBatis Spring Boot 3.0.5: 호환 정상
- 모든 Bean 생성자 주입, `@Mapper` 어노테이션 정상
- JSTL 미사용(scriptlet 방식), tomcat-embed-jasper compile scope: JSP 렌더링 정상
- ViewResolver: prefix=`/WEB-INF/views/`, suffix=`.jsp` 정상
- **기동 실패 유발 구조적 결함 없음**
#### [6순위] PostAdminController 권한 체크 부재 (추정)
- `PostController``gate.has(session, "POST_WRITE")` 명시 검사
- `PostAdminController`에는 `PermissionGate` 주입 자체 없음
- `InterceptorConfig`에서 `/admin/**``RbacInterceptor` 등록 확인됨 — 필터 레벨 보호 존재
- `/admin/jams/**`는 제외(addExcludePatterns). `/admin/post-categories`는 포함 여부 추가 확인 필요
- 500 직접 원인 아님; 인증 우회 시 NPE 가능성
---
### 포인트 4: 관련 JSP 파일 경로
- 신뢰도: 확인됨
| JSP | 절대 경로 |
|-----|----------|
| 포스트 목록 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-list.jsp` |
| 포스트 상세 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-detail.jsp` |
| 포스트 작성/수정 폼 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/posts-form.jsp` |
| 관리자 카테고리 | `/Users/wemadeplay/workspace/stz/bibimbap/src/main/webapp/WEB-INF/views/admin-post-categories.jsp` |
**admin-post-categories.jsp 주의사항:**
- `var BASE = '/admin/post-categories';` — context path 미포함 하드코딩
- 다른 JSP들은 `<%= ctx %>` 변수(contextPath 동적 주입)를 사용하는 것과 비교해 inconsistent
- context root가 `/` 이외로 배포 시 404 발생
---
### 포인트 5: 최근 git 변경 사항 (포스팅 관련)
- 신뢰도: 확인됨
| 커밋 해시 | 메시지 |
|---------|--------|
| `6047a39` | `feat(board): W3-3 포스팅 보드 — 공지/블로그 + OG 미리보기(SSRF 방어) + 유니티 피드 감시` |
- 포스팅 기능이 이 단일 커밋(32파일, 5327줄 추가)으로 초기 도입됨 (2026-06-29)
- **후속 수정 커밋 없음** — 초기 도입 이후 한 번도 수정되지 않은 상태
- 이전 세션 로그에서 발견된 PKIX 빌드 오류(`spring-boot-dependencies:3.5.14-SNAPSHOT`)는 `e51ab01` 커밋으로 해소됨 (현재 정식 버전 사용)
---
## 종합 판단
**"포스팅 메뉴 진입 시 500"의 가장 유력한 원인은 `posts-detail.jsp`의 null 체크 부재(NPE)다.**
구체적 시나리오:
1. `GET /posts/{id}` 요청 → `PostController.detail()``postsMapper.getPublished(id)` 호출
2. `id`가 DRAFT 상태이거나 삭제된 포스트이거나 존재하지 않으면 → `null` 반환 → `redirect:/posts`로 처리됨 (Controller 레벨 방어 있음)
3. 그러나 **"포스팅 메뉴 진입"이 `GET /posts` (목록)라면**: `posts-list.jsp`로 렌더링되며 `postCategoriesMapper.listActive()`가 빈 결과를 반환할 경우 JSP 루프는 정상 처리. 별도 NPE 없음.
4. **만약 카테고리가 하나도 없는 상태에서 `createForm`(`GET /posts/new`) 진입 시**: `postCategoriesMapper.listActive()`가 빈 리스트를 반환하더라도 JSTL/scriptlet 루프이므로 NPE 없음.
따라서 **정확한 500 재현 경로는 다음 중 하나**:
- `GET /posts/{존재하지 않거나 DRAFT인 id}` → Controller null 체크 → redirect (500 아님)
- `POST /posts/{DRAFT id}` (JS fetch update) → `existing = postsMapper.getPublished(id)` → null → `existing.setCategoryId()`**NPE → 500** ← **가장 유력**
- `posts-detail.jsp` 직접 forward/include 시 post attribute 누락 → NPE → 500
**권위 격상 전 검증 필요 항목:**
- `PostController.update()` line 206에서 `existing`이 null일 때의 실제 분기 코드 (null 체크가 있는지 여부) — worker 탐색에서 null 체크 코드가 보이지 않아 NPE 유력하나, 해당 라인 전후 코드 직접 확인 권장
---
## 영향 파일 목록
| 파일 | 역할 | 이슈 |
|------|------|------|
| `src/main/java/.../controller/PostController.java` | 메인 Controller | DRAFT 조회 불가, update NPE 후보 |
| `src/main/java/.../controller/PostAdminController.java` | 관리자 Controller | 권한 체크 부재 |
| `src/main/java/.../mapper/PostsMapper.java` | MyBatis Mapper | update SQL category_id 누락 |
| `src/main/java/.../mapper/PostCategoriesMapper.java` | 카테고리 Mapper | 정상 |
| `src/main/java/.../service/PostMarkdownService.java` | 마크다운 렌더링 | 정상 |
| `src/main/java/.../service/OgPreviewService.java` | OG 미리보기 | 정상 |
| `src/main/webapp/WEB-INF/views/posts-detail.jsp` | 상세 JSP | NPE 위험 (null 체크 없는 post 접근) |
| `src/main/webapp/WEB-INF/views/posts-list.jsp` | 목록 JSP | 정상 |
| `src/main/webapp/WEB-INF/views/posts-form.jsp` | 작성/수정 JSP | 정상 |
| `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | 관리자 JSP | context path 하드코딩 주의 |
---
## 미해결 (open_questions)
1. **`PostController.update()` line 206 전후 null 체크 존재 여부**: `existing`이 null일 때 NPE로 이어지는지, 아니면 별도 분기가 있는지 해당 라인 직접 확인 필요. (현재 탐색에서는 null 체크 코드가 보이지 않아 NPE 유력 추정)
2. **정확한 "포스팅 메뉴 진입" URL**: `/posts` (목록), `/posts/{id}` (상세), `/posts/new` (작성폼), `/admin/post-categories` 중 어느 URL에서 500이 발생하는지 재현 경로 미확인
3. **DB 스키마 상태**: `post_categories` 테이블에 활성(is_active=true) 카테고리가 존재하는지 — 카테고리 없는 상태에서 create/edit 폼의 카테고리 드롭다운이 빈 상태로 표시되고 categoryId=null 제출 → validate() line 305 `categoryId == null` 조건으로 BadRequest 반환 (500 아님)
4. **RbacInterceptor `/admin/post-categories` 보호 여부**: `addExcludePatterns`에서 `/admin/jams/**` 외 추가 제외 경로 상세 확인 필요
5. **애플리케이션 로그 스택트레이스**: 실제 500 발생 시의 예외 클래스와 라인 넘버 — 현재 로그 파일에서 포스팅 관련 스택트레이스 미발견

View File

@ -0,0 +1,542 @@
---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-30T03:15:00Z
concerns:
- "admin-console.jsp, admin-jam-list.jsp JS 내부의 post() 함수 구현 세부가 admin-post-categories / admin-unity-feeds와 정확히 동일한지 코드 레벨 비교 필요. 통합 시 파라미터 처리 분기가 예상치 않게 동작 변경될 수 있음. 구현 advisor가 통합 전 3-way diff 수행 권고."
- "header.jsp 내 .site-header CSS 변수 블록(--header-bg 등)은 global.css 의 토큰 범위와 겹치지 않는 컴포넌트 전용 변수이므로 중앙화 대상에서 제외. 구현 시 헷갈리지 않도록 명칭 분리 유지 확인 요망."
concerns_checked: true
references:
requirements: "(인라인 입력 — 이 문서의 ## 맥락 섹션)"
research: ".atp/work-session/20260630-113258/research/frontend-analysis.md"
adrs: []
---
# 프론트엔드 컴포넌트 개선 권고안
## 목표 / 비목표
### 목표
- FR-1: CSS 디자인 토큰 중복 제거 → 23개 파일 대신 단일 파일 변경으로 전역 반영
- FR-2: JS 공용 유틸리티(notify/openModal/post/handleResult/handleError) 중복 선언 제거
- FR-3: BibimbapModal 호출 패턴을 단일 방식으로 표준화
- FR-4: recruit-form의 CSRF 토큰 폴백 갭 수정 (보안)
- FR-5: 날짜 포맷 함수를 공용 파일로 추출하여 복사 중복 방지
### 비목표
- Webpack/Vite 등 빌드 도구 도입 (별도 의사결정 사항)
- CSS-in-JS, PostCSS 파이프라인 도입
- JSP 템플릿 엔진 교체 또는 React/Vue 마이그레이션
- 기존 HTML/JSP 구조 및 클래스명 리팩터링 (CSS 토큰만 외부화)
- header.jsp·footer.jsp·modal.jsp 기존 구현 변경
- 서버사이드 날짜 포매팅 도입
---
## 요약 (우선순위 매트릭스)
| 이슈 | 영향도 | 구현 비용 | 우선순위 |
|---|---|---|---|
| CSS 토큰 23개 파일 중복 | 높음 (유지보수 23배 비용) | 낮음 (파일 추가 + include 삽입) | P0 |
| 폼 제출 CSRF 갭 (recruit-form) | 높음 (보안) | 낮음 (10줄 이내 수정) | P0 — 보안 |
| JS 공용 유틸리티 분리 | 중간 (동작 불일치 방지) | 중간 (파일 작성 + 기존 인라인 제거) | P1 |
| 모달 패턴 표준화 | 중간 (신규 페이지 기준 제공) | 중간 (패턴 A/B 대체 + admin 2곳 수정) | P1 |
| 날짜 포맷 유틸리티 모듈 | 낮음 (예방적 분리) | 낮음 (파일 추출) | P2 |
---
## 권고 1 — CSS 토큰 중앙화 (P0)
### 접근
`src/main/webapp/css/global.css` 정적 파일을 신규 생성한다. 이 파일에 라이트/다크 테마 CSS 변수 블록, `body` 기본 스타일, `safe-area` padding 믹스인 클래스, `.admin-btn` 컴포넌트를 선언한다.
각 JSP의 `<head>` 최상단에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/global.css">` 를 삽입한다. 이 link는 `theme-init.jsp` `<jsp:include>` 직후에 위치시켜 FOUC(Flash of Unstyled Content)를 방지한다.
Spring Boot WAR에서 `src/main/webapp/` 하위 정적 파일은 Servlet 컨테이너(Tomcat)가 직접 서빙한다. `UploadResourceConfig``addResourceHandlers``/profile/**` 전용이므로 `/css/**` 는 별도 등록 없이 Tomcat 기본 DefaultServlet이 처리한다. `src/main/resources/static/` 경로는 필요하지 않으며 신규 생성도 불필요하다.
### 파일 경로
| 역할 | 경로 |
|---|---|
| 신규 — 전역 CSS | `src/main/webapp/css/global.css` |
| 수정 대상 — 인라인 토큰 제거 | `src/main/webapp/WEB-INF/views/*.jsp` (23개) |
### global.css 선언 내용 (구체 명세)
```
/* 1. 라이트 테마 토큰 */
html {
color-scheme: light;
--surface: #faf8f5;
--card-bg: #fff;
--text: #1a1a1a;
--text-muted: #5c5c5c;
--accent: #e8a54b;
--accent-soft: rgba(232, 165, 75, 0.16);
--border: rgba(0, 0, 0, 0.08);
--shadow: rgba(0, 0, 0, 0.06);
--field-bg: #fff;
--button-text: #1a1a1a;
}
/* 2. 다크 테마 토큰 */
html[data-theme="dark"] {
color-scheme: dark;
--surface: #121212;
--card-bg: #1e1e1e;
--text: #ece8e1;
--text-muted: #a39e96;
--border: rgba(255, 255, 255, 0.1);
--shadow: rgba(0, 0, 0, 0.35);
--field-bg: #181818;
--button-text: #1a1a1a;
}
/* 3. body 기본 스타일 */
body {
margin: 0;
min-height: 100vh;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif;
background: var(--surface);
color: var(--text);
}
/* 4. admin-btn 컴포넌트 (admin 4개 파일 중복 제거) */
.admin-btn {
min-height: 2.25rem;
padding: 0 0.85rem;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--card-bg);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
font-weight: 800;
cursor: pointer;
}
.admin-btn:hover { border-color: rgba(232, 165, 75, 0.45); }
.admin-btn--primary { border-color: transparent; background: var(--accent); color: var(--button-text); }
.admin-btn--danger { border-color: rgba(200, 60, 60, 0.45); color: #c83c3c; }
```
**포함하지 않는 것**: 각 JSP 고유 레이아웃 클래스(`.admin-page`, `.form-page`, `.auth-main` 등), `max-width` 값(파일마다 48/72/78rem으로 다름), 페이지 전용 컴포넌트 CSS. 이들은 계속 인라인으로 유지한다.
**`--danger` 토큰**: `admin-unity-feeds.jsp`에만 있는 `--danger: #c83c3c / #ef7878` 변수는 global.css에 추가하지 않는다. 해당 파일이 유일한 사용처이므로 인라인 유지가 맞다.
**`--accent-soft` 미선언 파일 처리**: `login.jsp``profile.jsp``--accent-soft`를 선언하지 않는다. global.css에 전역 선언 후 이 두 파일의 인라인 블록에서 해당 변수가 없어도 문제 없다 (사용 위치가 없거나 다른 변수로 대체됨). 구현 시 각 파일의 실제 사용처를 확인하고 미사용이면 그냥 삭제, 사용이면 global.css 선언이 적용되므로 정상.
### 마이그레이션 경로 (단계별)
**Phase 1 — 파일 생성 + 1개 JSP 검증 (위험 최소)**
1. `src/main/webapp/css/global.css` 작성
2. `login.jsp` 1개에만 `<link>` 태그 추가 + 인라인 토큰 블록 제거
3. 브라우저에서 라이트/다크 테마 전환 육안 검증
4. 문제 없으면 Phase 2 진행
**Phase 2 — admin 4개 파일 적용 (`.admin-btn` 중복 제거 포함)**
1. `admin-post-categories.jsp`, `admin-unity-feeds.jsp`, `admin-console.jsp`, `admin-jam-list.jsp``<link>` 추가
2. 각 파일의 인라인 `html {}`, `html[data-theme="dark"] {}`, `body {}`, `.admin-btn*` 블록 제거
3. admin 기능(등록/수정/삭제) 동작 검증
**Phase 3 — 나머지 19개 JSP 일괄 적용**
1. 남은 파일에 `<link>` 추가 + 인라인 토큰 제거
2. 각 페이지 라이트/다크 테마 시각 검증
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| 일부 JSP가 인라인으로 토큰 값을 오버라이드하고 있다면 global.css 도입 후 이전과 동일 동작 | 낮음 (토큰 값 동일 확인됨) | Phase 1 검증 후 진행 |
| 특정 파일의 인라인 `html {}` 에 global.css에 없는 추가 변수 존재 (예: `--radius`, `--webgl-bg`) | 있음 (game-detail.jsp 확인) | 파일 전용 변수는 인라인 유지 — 중앙화 대상은 공통 8+4개 토큰만 |
| Tomcat DefaultServlet이 `/css/` 경로를 차단하는 보안 필터 존재 | 매우 낮음 | `/images/logo.png` 가 이미 동일 방식으로 서빙 중 — 검증됨 |
| FOUC: CSS 로드 전 테마 변수 미적용 깜빡임 | 낮음 | `theme-init.jsp``<head>` 최상단에서 `data-theme` 속성을 먼저 설정하고 있으므로 CSS 변수 로드 타이밍이 맞음 |
---
## 권고 2 — JS 공용 유틸리티 분리 (P1)
### 접근
`src/main/webapp/js/bibimbap-utils.js` 를 신규 생성하고 admin 4개 파일의 공통 함수(`post`, `handleResult`, `handleError`)를 이 파일로 추출한다. 이 파일은 `window.BibimbapUtils` 네임스페이스에 노출한다.
`notify()``openModal()` 은 title이 파일마다 달라 단순 통합이 불가능하므로 별도 전략을 쓴다(권고 3에서 다룸).
각 JSP에서는 `</body>` 직전에 `<script src="${pageContext.request.contextPath}/js/bibimbap-utils.js"></script>` 를 삽입하고, 기존 인라인 함수 선언을 제거한다.
### 파일 경로 및 API 계약 (함수 시그니처)
**신규 파일**: `src/main/webapp/js/bibimbap-utils.js`
```javascript
window.BibimbapUtils = (function () {
/**
* CSRF 토큰을 포함한 application/x-www-form-urlencoded POST 요청을 전송한다.
*
* @param {string} url - 요청 대상 URL
* @param {URLSearchParams|null} params - 요청 바디 파라미터 (null 이면 빈 URLSearchParams 사용)
* @param {string} csrfToken - X-CSRF-Token 헤더에 설정할 토큰 값
* @returns {Promise<Response>}
*/
function post(url, params, csrfToken) {
// ...
}
/**
* fetch Response가 ok이면 location.reload(), 아니면 오류 메시지를 콜백으로 전달한다.
*
* @param {Response} res - fetch가 반환한 Response 객체
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
*/
function handleResult(res, onError) {
// ...
}
/**
* 네트워크/예외 오류 발생 시 오류 메시지를 콜백으로 전달한다.
*
* @param {function(string)} onError - 오류 메시지 문자열을 받는 콜백
* @returns {function} catch 핸들러로 사용 가능한 함수
*/
function makeErrorHandler(onError) {
// ...
}
return { post: post, handleResult: handleResult, makeErrorHandler: makeErrorHandler };
})();
```
**시그니처 결정 이유**:
- `post(url, params, csrfToken)`: 기존 admin 4개 파일의 `post()` 는 클로저로 `CSRF_TOKEN` 변수를 캡처하는 방식이었다. 전역 파일로 추출하면 클로저 캡처가 불가능하므로 `csrfToken`을 명시 파라미터로 받는다. 호출부가 항상 토큰을 직접 넘기므로 의도가 코드에 드러나고 타입 검사도 명확해진다.
- `handleResult(res, onError)`: 기존 구현은 `notify()`를 직접 호출했다. `notify()`는 파일마다 title이 달라 전역화 불가능하므로, 대신 오류 메시지 문자열을 콜백으로 전달하는 방식으로 변경한다. 호출부가 메시지를 받아 직접 표시하므로 유연성과 분리가 동시에 달성된다.
- `makeErrorHandler(onError)`: 기존 `handleError()`는 매직 문자열 `'요청 중 오류가 발생했습니다.'` 를 하드코딩했다. 유틸리티 레벨에서 특정 언어 문자열을 결정하지 않도록 콜백으로 위임한다.
**inflate 경고**: `post()` 의 세 번째 파라미터 `csrfToken`이 항상 사용되는지 구현 시 확인 필요. 만약 호출부 중 하나라도 빈 문자열을 넘기는 패턴이 발견되면 `BibimbapCsrf.token()` 통합을 고려한다 (concerns 참조).
### 마이그레이션 경로
1. `bibimbap-utils.js` 작성 (3개 함수 포함)
2. `admin-post-categories.jsp` 에 script 태그 추가 + 인라인 `post`, `handleResult`, `handleError` 함수 제거. `BibimbapUtils.post(...)`, `BibimbapUtils.handleResult(...)`, `BibimbapUtils.makeErrorHandler(...)` 로 호출부 수정
3. 기능 검증 후 나머지 admin 3개 파일에 동일 적용
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 | 중간 | 통합 전 4개 파일 `post()` 구현 3-way diff 수행 (concerns 등록) |
| `handleResult` 시그니처 변경으로 기존 호출부 수정 필요 | 확실 | 마이그레이션 범위를 admin 4개로 한정, 변경이 넓지 않음 |
---
## 권고 3 — 모달 사용 패턴 표준화 (P1)
### 접근
**표준 패턴 채택**: `BibimbapModal` API (`window.BibimbapModal.alert / .confirm / .prompt`) 를 직접 호출하는 인라인 패턴을 표준으로 정한다. 기존 헬퍼 래퍼(`openModal()`, `notify()`)는 더 이상 신규 작성하지 않는다.
**패턴 A (openModal 래퍼) 처리**: login/signup/game-register/profile 4개 파일의 `openModal()` 로컬 함수를 제거하고 호출부를 `window.BibimbapModal.alert({...})` 직접 호출로 대체한다. `BibimbapModal` 미존재 폴백은 `else { alert(message); }` 로 단순화한다.
**패턴 B (notify 래퍼) 처리**: admin 4개 파일의 `notify()` 를 제거한다. 권고 2의 `BibimbapUtils.handleResult(res, onError)` 에서 `onError` 콜백이 `BibimbapModal.alert` 를 직접 호출하도록 호출부를 작성한다. 이렇게 하면 `notify()` 래퍼 없이도 동일 효과를 얻는다.
**패턴 C (인라인 직접 체크) 처리**: posts-form/recruit-form/game-detail 은 이미 인라인이므로 현재 형태 유지. 다만 `game-detail.jsp``notifyError()` / `confirmAction()` 헬퍼(라인 1553-1561)는 파일 전용이므로 제거하지 않는다. 단일 파일에서의 로컬 헬퍼는 허용.
**window.confirm 직접 사용 수정 (보안/일관성)**:
- `admin-post-categories.jsp:323``window.confirm()``BibimbapModal.confirm({...})` 으로 변경
- `admin-unity-feeds.jsp:401``window.confirm()``BibimbapModal.confirm({...})` 으로 변경
이 두 곳은 삭제 확인 흐름이므로 `onConfirm` 콜백에 실제 삭제 로직을 이동한다.
### 표준 패턴 예시 (참고용)
```javascript
// 알림 (확인 버튼만)
if (window.BibimbapModal) {
window.BibimbapModal.alert({ title: '제목', message: '내용', confirmText: '확인' });
} else {
alert('내용');
}
// 확인/취소 대화
if (window.BibimbapModal) {
window.BibimbapModal.confirm({
title: '삭제 확인',
message: '삭제하시겠습니까?',
onConfirm: function () { /* 삭제 로직 */ }
});
} else {
if (window.confirm('삭제하시겠습니까?')) { /* 삭제 로직 */ }
}
```
### 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 수정 — openModal 제거 | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 패턴 A → 직접 호출 |
| 수정 — notify 제거 | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | 패턴 B → 콜백 방식 |
| 수정 — window.confirm 교체 | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 사용 |
| 유지 — 변경 없음 | game-detail.jsp (notifyError/confirmAction), posts-form.jsp, recruit-form.jsp | 파일 전용 헬퍼 허용 |
### 마이그레이션 경로
1. `admin-post-categories.jsp` + `admin-unity-feeds.jsp``window.confirm` 2곳 먼저 수정 (보안 갭과 연결)
2. login/signup openModal 제거 + 직접 호출 전환
3. game-register/profile 동일 처리
4. admin 4개 파일 notify 제거 (권고 2와 동시 진행)
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| BibimbapModal이 modal.jsp 로드 전에 JS가 실행되는 경우 | 낮음 (header.jsp가 modal.jsp를 항상 include) | 변경 없이 현 구조 유지 |
| window.confirm 삭제 시 콜백 이동 누락으로 삭제 로직 미실행 | 중간 | 각 파일 수정 후 삭제 기능 수동 검증 |
---
## 권고 4 — 폼 제출 유틸리티 + CSRF 갭 수정 (P0 — 보안)
### 접근
**즉시 수정 (CSRF 갭)**: `recruit-form.jsp` 의 fetch 호출부에서 `BibimbapCsrf` 미존재 폴백(라인 390-394)이 CSRF 토큰을 전혀 포함하지 않는 문제를 수정한다. 두 가지 방법 중 **방법 A를 채택**한다.
**방법 A (채택)**: 폴백 브랜치에 `hidden input`에서 추출한 토큰을 직접 삽입한다.
```javascript
// recruit-form.jsp 수정안
var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || '';
fetch(form.action, {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRF-Token': csrfToken // 갭 수정
},
body: body
})
```
**전제**: `recruit-form.jsp``<input type="hidden" name="_csrf" value="...">` hidden input을 추가해야 한다. 현재 `recruit-form.jsp` 에는 이 hidden input이 없다. 추가하면 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다.
**방법 B (미채택)**: `theme-init.jsp``BibimbapCsrf` 를 항상 신뢰하여 폴백 분기 자체를 제거. 단, `BibimbapCsrf` 미존재 케이스를 완전히 제거하면 `theme-init.jsp` 가 로드 실패 시 CSRF 토큰이 아예 없어지는 더 큰 갭이 생긴다. 따라서 채택하지 않는다.
**401 리다이렉트 불일치 수정**: `recruit-form.jsp` 의 401 처리를 `posts-form.jsp` 와 동일하게 `redirectLogin` 함수로 분리한다.
**성공 시 폴백 불일치 수정**: `recruit-form.jsp:419-421``alert(...)` 호출을 `posts-form.jsp:260-261` 패턴에 맞게 `alert` 없이 `go()` 만 호출하도록 변경한다.
### 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 수정 (보안) | recruit-form.jsp | hidden _csrf input 추가 + 폴백 브랜치 토큰 삽입 |
| 수정 (일관성) | recruit-form.jsp | 401 처리 redirectLogin 함수 분리 |
| 수정 (일관성) | recruit-form.jsp | 성공 폴백 alert 제거 |
| 변경 없음 | posts-form.jsp | 현행 유지 (기준 파일) |
### 마이그레이션 경로
1. `recruit-form.jsp``<input type="hidden" name="_csrf" value="...">` 추가 (JSP EL 또는 request attribute 사용)
2. JS 폴백 브랜치에 `X-CSRF-Token` 헤더 추가
3. 401 처리 함수 분리
4. 성공 폴백 통일
5. 실제 폼 제출(등록) + 401 시나리오(로그아웃 후 제출) 수동 검증
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| hidden _csrf input 추가 시 서버 컨트롤러가 기대하는 파라미터명 불일치 | 낮음 | posts-form.jsp와 동일 파라미터명 `_csrf` 사용 |
| BibimbapCsrf가 항상 존재한다고 가정하고 폴백을 제거하고 싶은 유혹 | 중간 | 방법 B 미채택 이유 참조 — 폴백 브랜치 유지 |
---
## 권고 5 — 날짜 포맷 유틸리티 모듈 (P2)
### 접근
`game-detail.jsp` 인라인의 `fmtAbsolute`, `fmtRelative`, `buildTimeEl` 세 함수를 `src/main/webapp/js/bibimbap-date.js` 로 추출하고 `window.BibimbapDate` 네임스페이스에 노출한다.
`game-detail.jsp``<script src=".../js/bibimbap-date.js"></script>` 를 추가하고 기존 인라인 선언을 제거한다. 다른 detail 페이지(posts-detail, recruit-detail, jam-detail)에서 날짜 포맷이 필요해질 때 이 파일을 include하면 된다 — 현재는 필요하지 않으므로 강제 적용하지 않는다.
### 파일 경로 및 API 계약 (함수 시그니처)
**신규 파일**: `src/main/webapp/js/bibimbap-date.js`
```javascript
window.BibimbapDate = (function () {
/**
* ISO 8601 문자열을 ko-KR 로케일 절대 날짜/시각 문자열로 변환한다.
*
* @param {string} iso - ISO 8601 날짜 문자열
* @returns {string} - "2026. 6. 30. 오전 10:00:00" 형식, 파싱 실패 시 빈 문자열
*/
function fmtAbsolute(iso) { ... }
/**
* ISO 8601 문자열을 상대 시각 문자열로 변환한다 (7일 이내: "n분/시간/일 전", 초과: 절대).
*
* @param {string} iso - ISO 8601 날짜 문자열
* @returns {string} - "3시간 전" 또는 절대 날짜, 파싱 실패 시 빈 문자열
*/
function fmtRelative(iso) { ... }
/**
* <time> 요소를 생성한다. 수정된 항목이면 "(수정됨)" 뱃지를 DocumentFragment로 감싸 반환한다.
*
* @param {string} iso - ISO 8601 날짜 문자열
* @param {boolean} edited - true이면 "(수정됨)" 뱃지 추가
* @returns {HTMLElement|DocumentFragment}
*/
function buildTimeEl(iso, edited) { ... }
return { fmtAbsolute: fmtAbsolute, fmtRelative: fmtRelative, buildTimeEl: buildTimeEl };
})();
```
### 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 신규 | src/main/webapp/js/bibimbap-date.js | 날짜 포맷 유틸리티 |
| 수정 | src/main/webapp/WEB-INF/views/game-detail.jsp | script 태그 추가 + 인라인 3개 함수 제거 + `BibimbapDate.` 프리픽스 추가 |
### 마이그레이션 경로
1. `bibimbap-date.js` 작성 (기존 game-detail.jsp 인라인 코드 그대로 이식)
2. `game-detail.jsp` 에 script 태그 추가
3. 호출부 3곳(`buildTimeEl` 2건, `fmtAbsolute/fmtRelative` 직접 사용 1건)을 `BibimbapDate.buildTimeEl(...)` 등으로 수정
4. 인라인 함수 선언 3개 제거
5. game-detail 댓글/리뷰 날짜 표시 수동 검증
### 위험 평가
| 위험 | 가능성 | 대응 |
|---|---|---|
| game-detail.jsp가 매우 큰 파일(2200줄+)이므로 인라인 선언 제거 시 위치 파악 실수 | 중간 | 라인 번호 명시: fmtAbsolute 1516, fmtRelative 1521, buildTimeEl 1538 |
| 다른 파일에서 동일 함수명을 전역 선언하는 경우 충돌 | 없음 (조사 결과 game-detail.jsp 단독 선언 확인됨) | 추가 확인 불필요 |
---
## 전체 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 신규 | src/main/webapp/css/global.css | 디자인 토큰 + body + admin-btn |
| 신규 | src/main/webapp/js/bibimbap-utils.js | post / handleResult / makeErrorHandler |
| 신규 | src/main/webapp/js/bibimbap-date.js | 날짜 포맷 3개 함수 |
| 수정 (인라인 CSS 제거 + link 추가) | src/main/webapp/WEB-INF/views/*.jsp (23개) | global.css 연동 |
| 수정 (script 추가 + 인라인 JS 제거) | admin-console.jsp, admin-jam-list.jsp, admin-post-categories.jsp, admin-unity-feeds.jsp | bibimbap-utils.js 연동 |
| 수정 (script 추가 + 인라인 제거) | game-detail.jsp | bibimbap-date.js 연동 |
| 수정 (openModal 제거) | login.jsp, signup.jsp, game-register.jsp, profile.jsp | 모달 패턴 표준화 |
| 수정 (window.confirm 교체) | admin-post-categories.jsp:323, admin-unity-feeds.jsp:401 | BibimbapModal.confirm 전환 |
| 수정 (CSRF 갭 — 보안) | recruit-form.jsp | hidden input 추가 + 폴백 헤더 수정 |
---
## 대안 비교
### CSS 중앙화 방안
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| A: `src/main/webapp/css/global.css` | 빌드 도구 불필요, Tomcat 즉시 서빙 | 캐시 버스팅 수동 관리 필요 | **채택** |
| B: `theme-init.jsp``<style>` 블록 추가 | include 메커니즘 재사용 | JSP 응답마다 CSS가 HTML에 인라인 삽입됨 — 캐싱 불가, 오히려 더 많은 바이트 전송 | 미채택 |
| C: `src/main/resources/static/css/global.css` | Spring Boot 자동 서빙 | 해당 경로가 현재 미존재이고 WAR 배포 시 classpath static과 webapp static 혼용이 복잡해짐 | 미채택 |
### JS 유틸리티 배포 방안
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| A: `src/main/webapp/js/` 정적 파일 | 빌드 불필요, 직접 서빙 | 캐시 버스팅 수동 관리 필요 | **채택** |
| B: JSP include 파일 (`<jsp:include>`) | 기존 include 패턴 일관성 | JS를 JSP로 서빙하면 컨텐츠 타입이 `text/html`로 설정될 위험, 브라우저 모듈 캐싱 불가 | 미채택 |
---
## 구현 순서 권고
```
1. [P0-보안] recruit-form.jsp CSRF 갭 수정 — 단독 작업, 위험 낮음
2. [P0] global.css 작성 + login.jsp 1개 검증 — Phase 1
3. [P0] admin 4개 파일 global.css 적용 — Phase 2
4. [P0] 나머지 19개 JSP global.css 적용 — Phase 3
5. [P1] bibimbap-utils.js 작성 — admin 파일 수정 전제
6. [P1] admin 4개 파일 bibimbap-utils.js 연동 — 5 완료 후
7. [P1] window.confirm 2곳 BibimbapModal.confirm 전환 — 6과 동시 가능
8. [P1] openModal 패턴 4개 파일 표준화 — 7 완료 후
9. [P2] bibimbap-date.js 추출 + game-detail.jsp 연동 — 독립 작업
```
---
## 롤아웃 / 마이그레이션
**역호환**: 이 권고안의 모든 변경은 순수 정적 파일 추가 + JSP 인라인 제거이므로 서버사이드 Java 코드, MyBatis, API 계약에 영향이 없다. 롤백은 추가한 `<link>`, `<script>` 태그를 되돌리고 인라인 블록을 복원하면 된다.
**캐시 버스팅**: 빌드 시스템이 없으므로 쿼리 스트링 버전 파라미터(`?v=20260630`)를 link/script 태그에 수동으로 붙인다. 초기 배포 시 한 번 붙이고, CSS/JS 변경 시마다 날짜를 업데이트하는 것으로 충분하다.
**배포 단위**: 정적 파일 추가는 WAR 재배포가 필요하다. 단계별 적용이지만 배포 자체는 1회로 묶어 진행해도 된다.
**롤백 경로**:
1. `global.css` 추가 후 이슈 발생 시 → `<link>` 태그 제거 + git revert
2. `bibimbap-utils.js` 이후 이슈 발생 시 → script 태그 제거 + 인라인 함수 복원
---
## 검증 포인트
아래 AC는 verification-advisor가 점검한다.
**AC-1 (P0)**: `src/main/webapp/css/global.css` 파일 존재 확인.
**AC-2 (P0)**: global.css가 라이트/다크 테마 HTML을 열었을 때 각각 올바른 배경색(`--surface: #faf8f5` / `#121212`)이 적용되는지 브라우저 확인.
**AC-3 (P0)**: JSP 23개 파일에 `<link rel="stylesheet" ... /css/global.css>` 포함 전수 확인.
```
grep -rl 'global\.css' src/main/webapp/WEB-INF/views/ | wc -l == 23
```
*시점 안정성 주의*: 이 카운트는 구현이 완료된 직후 측정해야 한다. 구현 중간에 측정하면 미완료 파일이 포함돼 FAIL로 나올 수 있다. verification은 구현 완료 후 1회 측정한다.
**AC-4 (P0-보안)**: `recruit-form.jsp``<input type="hidden" name="_csrf"` 가 존재하고, JS 폴백 브랜치에 `'X-CSRF-Token': csrfToken` 이 포함되어 있음 확인.
```
grep -c 'X-CSRF-Token' src/main/webapp/WEB-INF/views/recruit-form.jsp >= 1
```
**AC-5 (P1)**: `src/main/webapp/js/bibimbap-utils.js` 파일 존재 + `window.BibimbapUtils` 노출 확인.
**AC-6 (P1)**: admin 4개 파일에 인라인 `function post(` 선언이 제거되었음 확인.
```
grep -rl 'function post(' src/main/webapp/WEB-INF/views/admin-*.jsp | wc -l == 0
```
**AC-7 (P1)**: `admin-post-categories.jsp:323``admin-unity-feeds.jsp:401` 위치에 `window.confirm(` 이 더 이상 없음 확인.
```
grep -c 'window\.confirm' src/main/webapp/WEB-INF/views/admin-post-categories.jsp == 0
grep -c 'window\.confirm' src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp == 0
```
**AC-8 (P1)**: login/signup/game-register/profile 4개 파일에 `function openModal(` 선언이 제거되었음 확인 (전수 4건 → 0건).
```
grep -rl 'function openModal(' src/main/webapp/WEB-INF/views/ | wc -l == 0
```
**AC-9 (P2)**: `src/main/webapp/js/bibimbap-date.js` 파일 존재 + `window.BibimbapDate` 노출 확인.
**AC-10 (P2)**: `game-detail.jsp` 인라인에 `function fmtAbsolute(`, `function fmtRelative(`, `function buildTimeEl(` 3개 선언이 모두 제거되었음 확인 (전수 3건 → 0건).
```
grep -c 'function fmtAbsolute\|function fmtRelative\|function buildTimeEl' src/main/webapp/WEB-INF/views/game-detail.jsp == 0
```
**AC-11 (전체)**: 신규 정적 파일 전수 3개 존재 확인.
```
ls src/main/webapp/css/global.css src/main/webapp/js/bibimbap-utils.js src/main/webapp/js/bibimbap-date.js
```

View File

@ -0,0 +1,32 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-30T04:30:00Z
concerns:
- "theme-init.jsp, header.jsp, footer.jsp 내 CSS 변수 선언 여부가 미탐색 상태로 남아 있음. global.css 도입 전 이중 선언 여부 확인 권장."
- "BibimbapCsrf 실제 구현체(별도 JS 파일) 미탐색. headers() 폴백 동작 미확인 상태로 권고 4(CSRF 갭 수정) 구현 전 확인 필요."
- "admin 4개 파일의 post() 구현이 실제로 동일한지 정밀 3-way diff 미수행. bibimbap-utils.js 통합 전 수행 권장."
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/analysis/2026-06-30-frontend-component-review.md` | analysis | 신규 생성 | `docs/analysis/index.md` | `docs/analysis/2026-06-16-project-analysis.md` (관련 기존 분석 섹션에서 참조) |
| `docs/analysis/index.md` | analysis | 기존 수정 (링크 추가) | — | — |
## 의사결정 기록 위치
- 프론트엔드 컴포넌트 개선 권고(CSS 중앙화 방안 A/B/C 비교, JS 유틸리티 배포 방안 A/B 비교, CSRF 갭 수정 방법 A/B 비교)는 design-advisor 산출물(`20260630-113258/artifacts/frontend-design-recommendations.md`)에 기록되어 있으며, 이 분석 문서에서 결론을 인용.
- 되돌리기 어려운 아키텍처 결정(ADR)은 이번 작업 범위에 없음 (정적 파일 추가는 가역적 변경).
## 추후 문서화가 필요한 항목
- 권고 1(global.css) 구현 완료 후: `docs/changes/` 에 런타임 동작 변경(CSS 서빙 경로 추가) 이력 기록
- 권고 4(recruit-form CSRF 갭 수정) 구현 완료 후: `docs/changes/` 에 보안 수정 이력 기록, `docs/security/security-remediation-checklist.md` 해당 항목 완료 표시
- 권고 2(bibimbap-utils.js) + 권고 3(모달 표준화) 구현 완료 후: JS 공용 파일 API 계약이 확정되면 `docs/contracts/` 또는 `docs/architecture/` 에 프론트엔드 유틸리티 레이어 설계 기록 고려
- 구현 전 3-way diff 수행 결과는 `docs/analysis/` 또는 work-log에 기록 권장

View File

@ -0,0 +1,80 @@
---
schema_version: "2"
sid: "20260630-113258"
started_at: "2026-06-30T11:32:58"
ended_at: "2026-06-30T12:10:51"
user_request: >
/frontend-design:frontend-design 프론트 코드들 컴포넌트별로 매번 새로
구현하는건 없는지 유지 보수, 확장 차원에서 부족한건 없는지 검토하자
Summary: |
JSP 프론트엔드 26개 파일 전수 검토. CSS 토큰 23개 파일 중복(5,969줄),
JS 유틸 함수 8중복, BibimbapModal 패턴 3종 혼존,
recruit-form CSRF 폴백 갭(보안), 날짜 함수 편재 확인.
5개 권고안(P0×2, P1×2, P2×1) + AC 11개 + 문서화 완료.
Invocations:
- graphify-lookup-advisor: miss (JSP 범위 미포함)
- research-advisor: 5개 탐색 A-E 완료
- design-advisor: 권고안 + 파일 영향 맵 + AC 산출
- documentation-advisor: docs/analysis/ 기록
Decisions:
- CSS 중앙화: src/main/webapp/css/global.css (Tomcat DefaultServlet 서빙)
- JS 유틸: src/main/webapp/js/bibimbap-utils.js (window.BibimbapUtils)
- 날짜 유틸: src/main/webapp/js/bibimbap-date.js (window.BibimbapDate)
- CSRF 갭: recruit-form hidden input + 폴백 헤더 추가
- 빌드 도구 도입: 비목표 (별도 결정 사항)
verified_by_me:
- 코드 변경 0건 — 검토 전용 세션
- L1/L2: skip (코드 변경 없음)
needs_user_verification:
- 권고 구현 시 Phase 1 (login.jsp 1개 먼저 검증) 진행 권장
- recruit-form CSRF 수정은 P0 보안 이슈 — 우선 구현 권고
graph_refresh:
judgment: partial-stale
action: full scope 재생성 완료 (src/ + docs/)
result: 1996노드/4626엣지/104 communities, 12.5x token reduction
source_commit: 5e0f76b
generated_at: 2026-06-30T12:05:29+0900
user_signals:
positive: []
negative: []
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — JSP 프론트엔드 컴포넌트 중복/유지보수/확장성 검토'
checked_at: "2026-06-30T11:33:00"
- advisor: graphify-lookup-advisor
decision: call
rationale: '기존 그래프 산출물에 프론트엔드 분석 캐시 있을 수 있음'
checked_at: "2026-06-30T11:33:00"
result: 'miss — JSP/JS/CSS 파일이 graphify 스캔 범위 외'
- advisor: research-advisor
decision: call
rationale: 'graphify miss → 실제 JSP 탐색 필요. 중복 함수/CSS/패턴 발굴'
checked_at: "2026-06-30T11:35:00"
result: '5개 탐색 완료. CSS 23파일 중복, JS 헬퍼 8중복, CSRF 보안갭, 날짜함수 편재 확인'
- advisor: design-advisor
decision: call
rationale: '발견 사항 기반 개선 권고안 구조화 필요'
checked_at: "2026-06-30T11:50:00"
result: '5개 권고안 + 파일 영향 맵 + AC 11개 산출 완료'
- advisor: implementation-advisor
decision: skip
rationale: '검토 전용 태스크 — 구현은 사용자 결정 후 별도 세션'
checked_at: "2026-06-30T12:00:00"
- advisor: verification-advisor
decision: skip
rationale: '코드 변경 0건 — AC는 설계 문서에 기록됨'
checked_at: "2026-06-30T12:00:00"
- advisor: documentation-advisor
decision: call
rationale: '분석 결과를 docs/analysis/ 에 영구 기록'
checked_at: "2026-06-30T12:00:00"

View File

@ -0,0 +1,268 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-30T02:41:09Z
concerns: []
source_confidence: high
workers_spawned: 5
---
# 프론트엔드 중복/이슈 분석
## 주제
JSP 기반 프론트엔드 코드에서 컴포넌트 중복 구현, 유지보수·확장성 이슈 조사
---
## 확정 중복 패턴 (이미 파악된 사실)
### JS 함수 4중 중복 — admin 4개 파일
| 함수 | 파일 | 영향도 |
|---|---|---|
| `notify(message)` | admin-console, admin-jam-list, admin-post-categories, admin-unity-feeds 각각 선언 | Medium |
| `post(url, params)` | 동일 4개 파일. 구현이 약간씩 다름 (params 처리, _csrf 추가 여부) | High |
| `handleResult(res)`, `handleError()` | 동일 4개 파일 동일 패턴 | Medium |
신뢰도: 확인됨 (입력 사실 보존)
### BibimbapModal.alert 체크 인라인 반복
- `window.BibimbapModal && typeof window.BibimbapModal.alert === 'function'` 패턴이 15개 이상 파일에 인라인 반복
- 신뢰도: 확인됨 (입력 사실 보존, 탐색 E에서 구체 위치 확인)
### CSRF 토큰 추출 방식 불일치
- admin-console/jam-scoring/jam-detail: `request.getAttribute("csrfToken")` rawCsrf 패턴
- admin-post-categories/admin-unity-feeds: `(String) request.getAttribute("csrfToken")` 캐스팅
- signup/login: `CsrfTokens.getOrCreate(request.getSession())` 직접 호출
- JS에서 일부는 `<meta name="_csrf">` 읽기, 일부는 `<%= csrfTokenJs %>` 직접 출력
- 신뢰도: 확인됨 (입력 사실 보존)
---
## CSS 중복 (탐색 A)
### 규모 요약
- 전체 27개 JSP 파일 중 26개에 인라인 `<style>` 블록 존재 (theme-init.jsp 제외)
- 누적 인라인 CSS: **5,969줄**
- 범위: footer.jsp 76줄 ~ game-detail.jsp 1,173줄
- 신뢰도: 확인됨
### 중복 패턴 1위 — `html {}` 라이트 테마 CSS 변수 선언 (23개 파일)
핵심 8개 색상 토큰(`--surface: #faf8f5`, `--card-bg: #fff`, `--text: #1a1a1a`, `--text-muted`, `--accent: #e8a54b`, `--border`, `--shadow`, `--accent-soft`)이 23개 파일에 동일 값으로 복사됨.
- 대표 위치: `admin-console.jsp:38`, `jam-list.jsp:21`, `login.jsp:12`
- 신뢰도: 확인됨
### 중복 패턴 2위 — `html[data-theme="dark"] {}` 다크 테마 CSS 변수 선언 (23개 파일)
다크 팔레트(`#121212`, `#1e1e1e`, `#ece8e1`, `#a39e96`, `rgba(255,255,255,0.1)`)가 23개 파일에 동일 반복.
- 대표 위치: `admin-console.jsp:51`, `posts-list.jsp:41`, `recruit-list.jsp:34`
- 신뢰도: 확인됨
### 중복 패턴 3위 — `body {}` 기본 리셋 + 폰트 + 배경 (23개 파일)
`margin:0; min-height:100vh; font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans KR", sans-serif; background:var(--surface); color:var(--text);` 블록이 23개 파일에 동일 반복.
- 대표 위치: `admin-console.jsp:62`, `jam-list.jsp:41`, `terms.jsp:29`
- 신뢰도: 확인됨
### 중복 패턴 4위 — 페이지 컨테이너 `safe-area` padding 패턴 (25개 파일)
`padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right));` 가 거의 모든 페이지에 반복. max-width 값만 48/56/64/72/78rem으로 파일별 상이.
- 대표 위치: `admin-console.jsp:70-72`, `posts-list.jsp:58-60`, `recruit-list.jsp:52-54`
- 신뢰도: 확인됨
### 중복 패턴 5위 — `.admin-btn` / `.admin-btn--primary` / `.admin-btn--danger` (4개 admin 파일)
버튼 컴포넌트 CSS 20줄 이상이 4개 admin 파일에 동일 복사.
- 위치: `admin-console.jsp:171-194`, `admin-jam-list.jsp:167-190`, `admin-post-categories.jsp:100`, `admin-unity-feeds.jsp:108`
- 신뢰도: 확인됨
### 중복 패턴 6위 — `.detail-button` / `.detail-actions` / `.detail-section` (2개 파일)
- `jam-detail.jsp:128`, `recruit-detail.jsp:116` — 거의 동일 (color 값 1곳만 다름: `#1a1a1a` vs `var(--button-text)`)
- 신뢰도: 확인됨
### CSS Grid 리스트 래퍼 패턴 (posts·recruit 동일)
- `posts-list.jsp:119-123``.posts-grid { display:grid; grid-template-columns:repeat(3, minmax(0,1fr)); gap:1rem; }`
- `recruit-list.jsp:143-147``.recruit-grid { ... }` 동일 구조
- 신뢰도: 확인됨
---
## 리스트 페이지 패턴 (탐색 B)
### 카드 HTML 구조 비교
| 항목 | posts-list.jsp | recruit-list.jsp | jam-list.jsp |
|---|---|---|---|
| 래퍼 클래스 | `.posts-grid` (section) | `.recruit-grid` (section) | `.jam-grid` (div) |
| Grid 열 | `repeat(3, minmax(0,1fr))` | `repeat(3, minmax(0,1fr))` | `repeat(auto-fill, minmax(16rem,1fr))` |
| 카드 태그 | `<a class="post-card">` | `<a class="recruit-card">` | `<a class="jam-card">` |
| 이미지 | 있음 (16/9 aspect-ratio) | 없음 | 없음 |
| 데이터 속성 | 없음 | `data-role`, `data-type`, `data-search` | 없음 |
신뢰도: 확인됨
### 검색/필터 방식 비교
- **posts-list.jsp** — 검색 UI 없음. 카테고리 탭(`<nav class="posts-tabs">`)은 `<a>` 링크 방식으로 서버 재요청(`?categoryId=`). JS 없음.
- **recruit-list.jsp**`<input type="search" id="recruit-search">` (라인 261) + 역할·참여형태 `<button class="filter-chip">` (aria-pressed 토글). `applyFilters()` 함수가 `card.hidden`으로 클라이언트 필터 (라인 328-340). 검색 텍스트는 서버가 `data-search` 속성에 pre-encode.
- **jam-list.jsp** — 검색/필터 모두 없음.
- 신뢰도: 확인됨
### 페이지네이션 방식 비교
- **posts-list.jsp** — 커서 기반 "더보기" 링크. 서버 Java가 `cursorCreatedAt`, `cursorId` 파라미터를 URL로 조립 (라인 263-270). JS 없음.
- **recruit-list.jsp** — 페이지네이션 없음. 전체 목록 한 번에 렌더.
- **jam-list.jsp** — 커서 기반 "더보기" 링크. 서버 Java가 `cursor` 파라미터 URL 조립 (라인 177-180). JS 없음.
- 신뢰도: 확인됨
### 공통 중복 블록
- CSS 변수 + body 스타일 블록: 세 파일 모두 동일 (상위 CSS 중복 패턴과 동일)
- 히어로 섹션 flex 레이아웃: posts·recruit 동일 (`align-items:flex-end; justify-content:space-between; gap:1rem`)
- 쓰기 버튼 스타일: posts·recruit 동일 (`min-height:2.75rem; border-radius:10px; background:var(--accent)`)
- 카드 hover 효과: posts·recruit 동일, jam은 transform 없이 border-color만
- empty-state 박스: 세 파일 유사 (border-style만 dashed vs solid 차이)
- 반응형 미디어 쿼리(3열→2열→1열): posts·recruit 동일 (@media 900px/640px)
- `request.getContextPath()` + 목록 attribute 캐스팅 패턴: 세 파일 공통
- `<jsp:include page="/WEB-INF/views/header.jsp"/>` / `footer.jsp`: 세 파일 동일
- 신뢰도: 확인됨
---
## 폼 페이지 패턴 (탐색 C)
### 공통 골격 (거의 복사-붙여넣기 수준)
두 파일은 fetch 기반 비동기 폼 제출, `checkValidity()` 검증, `BibimbapModal.alert` 성공/실패 처리, `BibimbapCsrf` CSRF 헤더 주입이라는 동일한 골격을 공유.
| 항목 | posts-form.jsp | recruit-form.jsp |
|---|---|---|
| body 구성 | `new URLSearchParams(new FormData(form))` (라인 232) | 동일 (라인 383) |
| Content-Type | `application/x-www-form-urlencoded;charset=UTF-8` (라인 225) | 동일 (라인 387) |
| 클라이언트 검증 | `form.checkValidity()` + `form.reportValidity()` (라인 219-222) | 동일 (라인 378-381) |
| res.ok 판정 | `if (!res.ok) { var error = new Error(...); error.status = res.status; throw error; }` (라인 241-246) | 동일 (라인 400-404) |
| 성공 리다이렉트 | `window.location.href = ctx + (data.location \|\| '/posts')` (라인 250) | `window.location.href = '<%= ctx %>' + (data.location \|\| '/recruit')` (라인 409) |
신뢰도: 확인됨
### 차별화 부분 (다른 점)
1. **CSRF 처리 방식 불일치 (High)**
- `posts-form.jsp:169``<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">` hidden input 존재 + JS 변수 이중 포함 (`posts-form.jsp:211`, `229`)
- `recruit-form.jsp:386-394` — hidden CSRF input 없음. `BibimbapCsrf` 미존재 시 fallback 헤더에 CSRF 토큰이 전혀 포함되지 않는 보안 갭
- 신뢰도: 확인됨
2. **성공 시 모달 없을 때 분기 차이**
- `posts-form.jsp:260-261``} else { go(); }` (alert 없음)
- `recruit-form.jsp:419-421``} else { alert('팀원 모집글이 등록되었습니다.'); go(); }`
- 신뢰도: 확인됨
3. **401 리다이렉트 처리 패턴 불일치**
- `posts-form.jsp:264-268``redirectLogin` 함수 분리, `onConfirm`에서 호출
- `recruit-form.jsp:430-431, 437-439` — 모달 `onConfirm`과 else 분기 양쪽에 각각 인라인 중복 작성
- 신뢰도: 확인됨
4. **수정 모드**: `posts-form.jsp:9-10, 215``mode`, `postId` 로 등록/수정 URL 분기. `recruit-form.jsp`는 수정 모드 분기 없음.
5. **실시간 미리보기**: `recruit-form.jsp:340-373``pairs` 배열 + `render()` + input/change 이벤트로 우측 preview-card 갱신. posts-form에는 없음.
6. **제출 버튼 disabled 처리**: 두 파일 모두 없음. fetch 중 비활성화 처리 미구현.
---
## 날짜 함수 (탐색 D)
### 발견 요약
- **날짜/시간 포맷 JavaScript 함수가 선언된 파일**: `game-detail.jsp` 단 하나
- 나머지 6개 파일(posts-detail, recruit-detail, jam-detail, posts-list, jam-list, recruit-list)에는 날짜 포맷 JS 함수가 전혀 없음
- 신뢰도: 확인됨
### game-detail.jsp 날짜 함수 (라인 1516-1551)
| 함수 | 위치 | 구현 방식 |
|---|---|---|
| `fmtAbsolute(iso)` | `game-detail.jsp:1516-1519` | `new Date(iso).toLocaleString('ko-KR')` |
| `fmtRelative(iso)` | `game-detail.jsp:1521-1536` | 경과 시간 직접 산술 계산 (60/3600/86400초 분기) |
| `buildTimeEl(iso, edited)` | `game-detail.jsp:1538-1551` | 두 함수 합성, `<time>` 요소 + "(수정됨)" 뱃지 반환 |
- `Intl.RelativeTimeFormat` / `Intl.DateTimeFormat` 직접 사용: 조사 범위 7개 파일 전체에서 **0 hit**
- 신뢰도: 확인됨
### 사용 위치
- `game-detail.jsp:1878``buildTimeEl` 사용 (댓글 항목)
- `game-detail.jsp:2183-2186``fmtAbsolute`/`fmtRelative` 직접 사용 (리뷰 항목)
- 신뢰도: 확인됨
### 서버사이드 날짜 포매팅
- 조사 범위 내 Java Date 포매팅으로 화면 출력하는 경우: 0건
- `posts-list.jsp:6-7,19``OffsetDateTime` import하지만 커서 URL 생성에만 사용 (화면 출력 아님)
- `jam-detail.jsp:442``OffsetDateTime.now().isAfter(...)` 서버사이드 분기 조건 (화면 포맷 출력 아님)
- 신뢰도: 확인됨
### 구조적 위험
`fmtAbsolute`/`fmtRelative`/`buildTimeEl`이 game-detail.jsp 인라인 스크립트에만 존재 → 다른 detail 페이지가 동일 기능이 필요할 때 복사 중복 발생 구조.
---
## 모달 패턴 (탐색 E)
### BibimbapModal API (modal.jsp:174-238)
| 메서드 | 시그니처 | 설명 |
|---|---|---|
| `alert(options)` | `{ title, message, confirmText, onConfirm }` | 취소 버튼 없음, 확인 버튼만 |
| `confirm(options)` | `{ title, message, confirmText, cancelText, onConfirm, onCancel }` | 확인+취소 양쪽 콜백 |
| `prompt(options)` | `{ title, message, label, value, placeholder, maxLength, confirmText, cancelText, onConfirm(inputValue), onCancel }` | 텍스트 입력 필드 포함 |
- 구현 방식: 단일 `<div id="site-modal">` 재사용, 콜백 기반 (Promise 아님), Enter=확인/Escape=취소 키보드 처리, 포커스 복귀
- 신뢰도: 확인됨
### 모달 사용 패턴 분류 (12개 JSP, modal.jsp 제외)
**패턴 A — `openModal()` 헬퍼 래퍼 (4개 파일, High 중복)**
`login.jsp`, `signup.jsp`, `game-register.jsp`, `profile.jsp`가 동일 시그니처의 `openModal(title, message, confirmText, onConfirm)` 로컬 함수를 각자 정의.
- 내부 BibimbapModal 체크 후 폴백 `alert(message)` + 동기 `onConfirm()` 호출
- 위치: `login.jsp:256`, `signup.jsp:269`, `game-register.jsp:490`, `profile.jsp:539`
- 신뢰도: 확인됨
**패턴 B — `notify()` 헬퍼 래퍼 (4개 admin 파일, High 중복)**
`admin-console.jsp`, `admin-jam-list.jsp`, `admin-post-categories.jsp`, `admin-unity-feeds.jsp``notify(message)` 로컬 함수를 각자 정의 (alert 전용, title은 하드코딩된 페이지명).
- 위치: `admin-jam-list.jsp:389`, `admin-console.jsp:401`, `admin-post-categories.jsp:251`, `admin-unity-feeds.jsp:305`
- 신뢰도: 확인됨
**패턴 C — 인라인 직접 체크 (3개 파일)**
`recruit-form.jsp`, `posts-form.jsp`, `game-detail.jsp`가 호출 지점마다 인라인으로 체크.
- `recruit-form.jsp:411, 424`, `posts-form.jsp:252, 269`, `game-detail.jsp:1425, 1439, 1474, 1482, 1553, 1558`
- 신뢰도: 확인됨
**특이 케이스 — game-detail.jsp 혼용**
`game-detail.jsp:1553-1561``notifyError()`, `confirmAction()` 헬퍼를 별도 정의하면서, 동일 파일 `1425-1449`에 인라인 체크도 병존. 단일 파일 내 두 패턴 혼재.
- 신뢰도: 확인됨
### window.confirm/alert 직접 사용 (BibimbapModal 없이)
| 종류 | 위치 | 비고 |
|---|---|---|
| `window.confirm()` 직접 호출 (체크 없음) | `admin-post-categories.jsp:323` | 삭제 확인 대화상자 |
| `window.confirm()` 직접 호출 (체크 없음) | `admin-unity-feeds.jsp:401` | 삭제 확인 대화상자 |
| `window.alert()` 폴백 (else 브랜치) | 전 파일의 BibimbapModal 없을 때 else 브랜치 | 정상 폴백 |
| `window.prompt()` 폴백 | `profile.jsp:686` | BibimbapModal.prompt 실패 시 |
- `admin-post-categories.jsp``admin-unity-feeds.jsp`는 알림에는 `notify()` 헬퍼(BibimbapModal 체크)를 쓰면서 삭제 확인에는 `window.confirm()`을 직접 사용하여 동일 파일 내 불일치 존재
- 신뢰도: 확인됨
---
## 종합 판단
### 상위 패턴
1. **CSS 인라인 토큰 중복이 가장 심각**: 라이트/다크 테마 CSS 변수 블록과 `body` 기본 스타일이 23개 파일에 동일하게 복사됨. 디자인 토큰 변경 시 23개 파일을 모두 수정해야 하는 유지보수 고비용 구조. `theme-init.jsp`가 존재하나 CSS 변수는 여기에 없고 JS 테마 초기화만 담당하는 것으로 보임.
2. **JS 유틸리티 함수 인라인 중복 이중 구조**: `notify()` (admin 4개), `openModal()` (일반 4개), `post(url, params)` (admin 4개) 등이 페이지마다 별도 선언됨. 특히 `post()` 함수는 파일마다 구현이 미묘하게 달라 동작 불일치 위험 내재.
3. **BibimbapModal 체크 패턴 3가지 혼존**: 헬퍼 래퍼 A, 헬퍼 래퍼 B, 인라인 직접 체크 C가 서로 다른 파일에 혼재. 일관성 없고 신규 페이지 작성 시 어떤 패턴을 따라야 할지 기준 없음.
4. **폼 제출 골격 복사**: posts-form과 recruit-form이 fetch 제출 코드를 거의 복사 수준으로 공유하면서 CSRF 처리 방식에 불일치 존재 (recruit-form의 BibimbapCsrf 미존재 시 CSRF 토큰 누락 위험).
5. **날짜 포맷 함수 편재**: game-detail.jsp에만 날짜 함수가 존재하며 다른 detail 페이지는 날짜 렌더링 자체가 없거나 JS 미사용. 추후 다른 페이지에서 날짜 포맷이 필요해지면 복사 중복 발생 구조.
### 충돌/갭
- CSS 변수 선언이 파일마다 포함하는 변수 수가 8~14개로 약간씩 달라, 특정 파일에서 일부 변수가 미선언된 상태일 수 있음.
- `admin-post-categories.jsp``admin-unity-feeds.jsp`는 알림에는 BibimbapModal 체크를 쓰면서 삭제 확인은 `window.confirm()` 직접 사용 — 같은 파일 내 불일치.
- `jam-detail.jsp:128``recruit-detail.jsp:116``.detail-button--primary color` 값 불일치 (`#1a1a1a` vs `var(--button-text)`) — 디자인 일관성 갭.
---
## 미발견 / 조사 불충분 항목
- `theme-init.jsp` 내부 구현 미탐색 — JS 테마 초기화 방식과 CSS 변수 일원화 가능성 미확인
- `header.jsp`, `footer.jsp`에서 이미 CSS 변수를 선언하는지 여부 미탐색 — 이중 선언 여부 불명
- `modal.jsp`가 실제로 어느 레이아웃 include 경로를 통해 로드되는지 미탐색 — BibimbapModal 미존재 상황의 실제 발생 가능성 미확인
- game-detail.jsp 외 admin-* 파일들의 날짜 렌더링 패턴 미탐색
- `BibimbapCsrf` 실제 구현체(별도 JS 파일) 미탐색 — `headers()` 메서드 폴백 동작 미확인
- posts-form.jsp의 `post()` 함수와 admin 4개 파일의 `post()` 함수가 실제로 다른 구현인지 정밀 비교 미수행

View File

@ -0,0 +1,46 @@
# ATP Session Report
schema_version: v2
sid: 20260630-123347
user_request: "가이드라인에 작업에있어서 빠르고 쉬운길보단 느리더라도 확실한 길로 가게끔 문구 추가하자"
started_at: 2026-06-30T12:33:47
## Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — "CLAUDE.md 작업 원칙에 확실한 경로 우선 원칙 추가"'
checked_at: 2026-06-30T12:33:47
- advisor: research-advisor
decision: skip
rationale: '대상 파일(CLAUDE.md) 이미 확인 완료. 외부 조사 불필요.'
checked_at: 2026-06-30T12:33:47
- advisor: design-advisor
decision: skip
rationale: '단일 bullet point 추가 — 파일 영향 맵 자명, 설계 산출 불필요'
checked_at: 2026-06-30T12:33:47
- advisor: implementation-advisor
decision: skip
rationale: '마이크로 편집(1줄 추가) — orchestrator 직접 수행 허용'
checked_at: 2026-06-30T12:33:47
## Invocations: []
## Summary
CLAUDE.md 작업 원칙 섹션에 "확실한 경로 우선" 원칙 1줄 추가.
commit: a499fd9
## verified_by_me
- L1: skip (docs-only, 코드 변경 0)
- L2: skip (동일 사유)
- graph_refresh: skip (코드 변경 없음)
## needs_user_verification
(없음)
## open_items
- .atp/work-session/{20260630-103443,20260630-105459}/ untracked — 이전 세션 산출물, 이번 범위 외
## user_signals
positive: []
negative: []
ended_at: 2026-06-30T12:34:00

View File

@ -0,0 +1,28 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: "2026-06-30T15:00:00+09:00"
concerns: []
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-30-screenshot-guide-auth.md` | changes | 신규 | `docs/changes/index.md` | `docs/development/screenshot-guide.md` → 변경 이력 섹션 |
| `docs/changes/index.md` | changes | 수정(링크 추가) | — | — |
| `docs/development/screenshot-guide.md` | development | 수정(교차 링크 추가) | — | changes 문서로 역링크 |
| `.atp/work-session/20260630-141415/documentation.md` | work-session | 신규 | — | — |
## 의사결정 기록 위치
- 카테고리 판별: 스크린샷 가이드 도구의 기능 확장은 런타임 동작 변경에 해당 → `docs/changes/`
- `docs/development/screenshot-guide.md` 자체가 이미 업데이트 완료 상태이므로 중복 작성 없이 교차 링크만 추가
## 추후 문서화가 필요한 항목
- 인증 모드 스크린샷 실제 실행 결과(14장 생성) 확인 후 `needs_user_verification` 항목 닫기

View File

@ -0,0 +1,59 @@
# ATP Work Session Report
schema_version: "2"
sid: "20260630-141415"
started_at: "2026-06-30T14:14:15"
ended_at: "2026-06-30T14:20:00"
## User Request
cmux 캡처 권한 허용 + cmux 재시작 후 스크린샷 가이드 재검토.
- 문제: 리뷰 작성, 댓글 작성, 로그인 전/후 차이 스크린샷 불가했음
- 스크린샷 위치: /tmp/bibimbap-screenshots/ (7장)
- 재실행: python3 docs/development/screenshot-guide.py
- 문서: docs/development/screenshot-guide.md
## Summary
screenshot-guide.py 확장 — 비인증 7장 → 인증 포함 최대 14장.
로그인 AJAX 흐름 대응, 리뷰/댓글 composer hidden 강제 노출 전략 적용.
SCREENSHOT_EMAIL/PASSWORD env var로 실행 분기.
## Advisor Invocation Decision Log
```yaml
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — 세부 화면 상태 캡처 방법 조사 + 구현'
checked_at: "2026-06-30T14:14:30"
- advisor: research-advisor
decision: call
rationale: '현재 7장 내용 파악 + 대안 캡처 방법(Playwright 인증 세션, 직접 URL 이동 등) 조사 필요'
checked_at: "2026-06-30T14:14:30"
- advisor: design-advisor
decision: skip
rationale: 'Playwright 로그인 → 세션 → hidden 요소 JS 노출 경로 확정적. 파일 하나 수정'
checked_at: "2026-06-30T14:16:00"
- advisor: implementation-advisor
decision: skip
rationale: '단일 파일 수정(screenshot-guide.py) + 문서 업데이트. orchestrator 직접 수행'
checked_at: "2026-06-30T14:16:00"
```
## Invocations: []
## Decisions: []
## verified_by_me:
- L1: 비인증 모드 실행 통과 (7장 생성 확인)
- L2: 인증 모드 — SCREENSHOT_EMAIL/PASSWORD 미제공으로 skip (테스트 계정 필요)
## needs_user_verification:
- SCREENSHOT_EMAIL=<계정> SCREENSHOT_PASSWORD=<pw> python3 docs/development/screenshot-guide.py 로 인증 모드 실행 후 08~14번 파일 생성 확인
## graph_refresh: skip: no-graphify (docs-only 변경, src/ 미수정)
## open_items: []
## user_signals:
positive: []
negative: []

View File

@ -0,0 +1,62 @@
# ATP Session Report
schema_version: v2
sid: 20260630-143405
started_at: 2026-06-30T14:34:05
ended_at: ~
user_request: "테스트용 계정 하나 더 파고 기록하자. 기존 계정은 일반 사용자용, 하나는 관리자 테스트용"
## Summary
관리자 테스트 계정(admin@bibimbap.local / test1234! / role=ADMIN)을 seed-dev.sql에 추가.
screenshot-guide.md 계정 표를 용도·role 포함 2행으로 확장. commit: 925eb73
## Advisor Invocation Decision Log
```yaml
- advisor: requirements-advisor
decision: skip
rationale: 요청 명확 — 관리자 계정 생성 + 기록, 스펙 불명 없음
checked_at: 2026-06-30T14:34:05
- advisor: research-advisor
decision: skip
rationale: seed-dev.sql + rbac-ddl.sql + screenshot-guide 직접 확인으로 충분
checked_at: 2026-06-30T14:36:00
- advisor: design-advisor
decision: skip
rationale: 파일 영향 맵 확정(2파일), 신규 로직 없음, 마이크로 편집
checked_at: 2026-06-30T14:36:00
- advisor: implementation-advisor
decision: skip
rationale: orchestrator 직접 수행(2파일 이내 마이크로 편집)
checked_at: 2026-06-30T14:36:00
- advisor: verification-advisor
decision: skip
rationale: SQL 변경 없음(DDL 아닌 seed), 코드 변경 0줄 — 문서+데이터 파일만
checked_at: 2026-06-30T14:40:00
- advisor: documentation-advisor
decision: skip
rationale: screenshot-guide.md 직접 수정으로 기록 완료
checked_at: 2026-06-30T14:40:00
```
## Invocations: []
## Decisions: []
## verified_by_me
- L1: skip (src/ 코드 변경 없음 — seed SQL + 문서 파일만)
- 로그 스캔: clean
## needs_user_verification
seed 적용 후 실제 DB에서 로그인 확인:
```
docker exec -i bibimbap-db psql -U bibimbap -d bibimbap -v ON_ERROR_STOP=1 < db/seed-dev.sql
# 이후 http://localhost:8080/ 에서 admin@bibimbap.local / test1234! 로 로그인
```
## graph_refresh: skip (no-graphify)
## open_items: []
## user_signals:
positive: []
negative: []
ended_at: 2026-06-30T14:41:00

View File

@ -0,0 +1,83 @@
# 작업용 프롬프트 — 클로드 디자인 리디자인 산출물 반영 (새 세션용)
> 이 문서는 검토 세션(sid 20260630-153323)의 산출물이다. **새 세션에 아래 "프롬프트" 블록을 그대로 붙여넣어** 실제 구현을 진행한다. 코드 변경은 새 세션에서 한다.
---
## 0. 한 줄 요약
외부 클로드 디자인 툴이 만든 리디자인 산출물(`Bibimbap 커뮤니티 웹사이트 리디자인.zip` — 7 JSP + `bibimbap.css` + 3 jspf + error.jsp)을 우리 프로젝트에 반영한다. **단, 파일 통째 교체가 아니라 "공유 `bibimbap.css` 신설 + 디자인의 클래스/구조 개선을 기존 JSP에 입히는 비주얼 레이어 방식"** 으로 한다. 산출물의 라우트·CSRF·include·모델명은 전부 우리 실제와 다르므로 그대로 쓰면 깨진다.
## 1. 산출물 위치
- zip: `/Users/wemadeplay/Downloads/Bibimbap 커뮤니티 웹사이트 리디자인.zip`
- 압축 해제본(검토 세션이 풀어둠): `<scratchpad>/redesign/jsp/...` (새 세션에선 zip을 다시 풀어 참조)
- 구성: `jsp/css/bibimbap.css`(15KB, 핵심), `jsp/01-home.jsp` `02-login` `03-signup` `06-game-detail` `07-terms` `08-profile` `10-recruit-form`, `jsp/error/error.jsp`, `jsp/fragments/{posts-empty,recruit-empty,review-comment-form}.jspf`. (preview.html은 zip에 없음)
## 2. 디자인 산출물이 우리 현실과 다른 점 (그대로 쓰면 안 되는 이유)
| 항목 | 디자인 가정 | 우리 실제 (근거) |
|---|---|---|
| context path | `${ctx}` | `${pageContext.request.contextPath}` (`header.jsp:197`) — `${ctx}` 미정의 |
| header include | `/WEB-INF/views/common/header.jsp` + header가 `<html><head>`를 연다고 가정 | `/WEB-INF/views/header.jsp` (common/ 없음). header.jsp는 doctype/head 없이 **nav 마크업 + 자체 style만**(`header.jsp:1-20`). 각 페이지가 자체 `<!DOCTYPE><html><head>` 보유 |
| 폼 제출 | classic `<form method=post action=/login>` | 로그인·회원가입·로그아웃·리뷰·모집은 **JSON API + JS AJAX 제출**. login.jsp는 `window.BibimbapCsrf`로 제출(`login.jsp:286`) |
| CSRF | 토큰 전무 | `CsrfTokens.getOrCreate(session)` → 폼은 hidden `<input name="_csrf">`(`login.jsp:211,224`), AJAX는 `window.BibimbapCsrf.headers()` 헤더(`theme-init.jsp:19-27`). **상태변경 전부 필수** |
| 게임 라우트 | `/games`, `/games/{id}` (복수) | `/game/{id}` (**단수**, `GameController.java:136`). 게임목록 전용 `/games` 라우트 없음 — 홈 `/`가 목록(`WebMvcController:70`) |
| 리뷰/댓글 | 폼 POST `/games/{id}/reviews` | JSON API `/game/{id}/reviews` (GET/POST/PUT/DELETE, `GameReviewController:73~`) — JS로 소비 |
| 모집 작성 | POST `/recruit` | `/recruit/new` POST(JSON, location 반환, `RecruitController:49`). 기존 recruit-form.jsp도 AJAX |
| 프로필/비번찾기 | `/profile`, `/password/reset` | `/profile` 존재(`/{pageName}`→profile.jsp). **`/password/reset` 라우트 없음** → 디자인의 "비밀번호 찾기" 링크는 기능 미존재 |
| 에러 페이지 | `error/error.jsp` + web.xml `<error-page>` | **web.xml 없음**(Spring Boot WAR). `/error`→뷰 **`errer`**(오타 파일명 그대로, `WebMvcController:47-55`), 모델 `${statusCode}` |
| CSS | 외부 `bibimbap.css` link 가정 | css/js 디렉토리 **미존재**. 현재 스타일은 각 페이지/header.jsp 인라인 `<style>` |
| 폰트 | Pretendard·JetBrains Mono (css엔 @import 없음) | 프로젝트 제약 "외부 라이브러리 0" → CDN 금지. 미설정 시 system 폰트로 degrade |
ALLOWED_PAGES(제너릭 페이지 서버) = `error, login, profile, signup, terms, operation-policy, ""` (`WebMvcController` 내). game-detail/recruit/posts는 전용 컨트롤러.
## 3. 결정된 접근 (사용자 승인됨)
- CSS: **공유 `bibimbap.css` 신설**`src/main/webapp/css/bibimbap.css` (Tomcat DefaultServlet 서빙, CLAUDE.md 정책).
- 파일 통째 교체 **금지**. 특히 `game-detail.jsp`(90KB, WebGL 로더·6축 평가·리뷰 CRUD·CSRF 내장) — 디자인 06은 단순 iframe 목업이라 통째 교체 시 기능 소실. **CSS 클래스/구조 개선만 이식**.
- 범위: 우선순위 일부부터(아래 5절 순서). 1차 검증 후 확장.
## 4. 하드 제약 (위반 금지)
1. 모든 상태변경(폼/AJAX)에 CSRF 적용 — 기존 `_csrf` hidden 또는 `window.BibimbapCsrf` 패턴 재사용. 디자인 폼엔 없으니 반드시 추가.
2. `${ctx}``${pageContext.request.contextPath}` 치환 (또는 각 페이지 head에 `<c:set var="ctx" value="${pageContext.request.contextPath}"/>` 1줄 추가 후 유지 — 택1, 일관).
3. include 경로 `common/` 제거 → `/WEB-INF/views/header.jsp`, `footer.jsp`. 디자인의 `<jsp:include header>`(head를 연다는 가정)는 **버리고**, 기존 페이지의 자체 head 구조 유지.
4. 외부 라이브러리/CDN 0. 폰트 CDN 추가 금지(system 폰트 fallback 허용, 또는 로컬 폰트 별도 합의).
5. MyBatis `#{}` 바인딩, JSP 출력 escape(`HtmlUtils.htmlEscape`/JSTL), 클라 렌더 `textContent` — 기존 보안 원칙 유지.
6. 모델 속성명은 디자인 placeholder(`${games}`,`${game.title}`,`${myGames}`,`${reviews}` 등)가 아니라 **각 페이지 기존 JSP가 실제 쓰는 이름**을 사용 — 구현 전 해당 JSP에서 확인.
## 5. 권장 작업 순서 (단위별 커밋)
1. **`bibimbap.css` 도입** (최저위험·최고가치): `src/main/webapp/css/bibimbap.css` 생성(zip의 css 복사, 폰트 줄은 system fallback로 조정). 전 페이지 head에서 로드되도록 **`theme-init.jsp`(각 페이지 head에 include되는지 먼저 확인) 또는 각 페이지 head**에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">` 1줄. header.jsp/기존 인라인 `<style>`과 클래스 충돌 점검.
2. **Empty-state 파편 3종**: `posts-empty.jspf`·`recruit-empty.jspf`를 `/WEB-INF/views/fragments/`에 두고 `posts-list.jsp`/`recruit-list.jsp`의 빈 분기에 include. 라우트는 실제값(`/posts/new`, `/recruit/new`)으로 수정.
3. **홈(index.jsp)**: 히어로 + 정렬칩 + 등록유도 카드. 모델/라우트는 index.jsp 실제값(홈 `/`, 검색파라미터 `q`)에 맞춤.
4. **로그인/회원가입**: 디자인의 pw-toggle·강도막대·일치표시·에러슬롯 **마크업/클래스만** 기존 login.jsp/signup.jsp에 이식. 제출은 기존 AJAX+`window.BibimbapCsrf` 유지. `/password/reset` 링크는 라우트 신설 합의 전까지 비노출.
5. **약관(terms.jsp)**: sticky 목차+조항 카드 구조 적용. 본문은 기존 terms.jsp 전문 유지(디자인 전문으로 덮어쓰기 전 차이 확인).
6. **프로필(profile.jsp)**: 헤더+내 게임 행+공개토글. 공개토글 action은 실제 게임 visibility API(`/game/{id}` 계열, GameController 확인)로, CSRF 포함.
7. **게임상세(game-detail.jsp)**: ⚠️ 통째 교체 금지. detail-meta 한 줄·16:9 player·review-cta·별점 CSS만 이식, 기존 WebGL/6축/리뷰 로직·CSRF 보존.
8. **errer.jsp**: 디자인 error/error.jsp의 empty-state 403/404 마크업을 `errer.jsp`에 이식, `${statusCode}` 분기. 파일명·뷰명 `errer` 유지(컨트롤러 매핑).
9. **모집 작성(recruit-form.jsp)**: 글자수·필수표시·sticky 라이브 미리보기 이식. 제출은 `/recruit/new` POST + 기존 AJAX/CSRF.
## 6. 검증 (코드 변경 시 필수)
- L1: WAR 빌드(`./mvnw -q -DskipTests package` 또는 프로젝트 표준) 성공 + JSP EL/JSTL 문법 오류 0.
- L2: 로컬 기동 후 각 변경 화면 스모크 — 로그인/회원가입 제출 동작(CSRF 통과), 게임상세 WebGL 플레이/리뷰 작성, 모집 작성 제출. `docs/development/verification-strategies.md` 기준.
- 변경 후 `graph-refresh-checker` + docs 반영(docs-first).
---
## ▶▶ 새 세션에 붙여넣을 프롬프트
```
/atp:task 클로드 디자인 리디자인 산출물(zip: /Users/wemadeplay/Downloads/Bibimbap 커뮤니티 웹사이트 리디자인.zip)을
우리 프로젝트에 반영한다. 접근은 "파일 통째 교체가 아니라, 공유 src/main/webapp/css/bibimbap.css 신설 +
디자인의 클래스/구조 개선을 기존 WEB-INF/views JSP에 입히는 비주얼 레이어 방식".
검토 세션 핸드오프 문서를 먼저 읽어라: .atp/work-session/20260630-153323/artifacts/handoff-prompt.md
(디자인↔현실 불일치 표, 하드 제약, 작업 순서가 거기 정리돼 있다.)
하드 제약: (1) 모든 상태변경에 CSRF(_csrf hidden 또는 window.BibimbapCsrf) (2) ${ctx} 금지→
${pageContext.request.contextPath} (3) include는 /WEB-INF/views/header.jsp (common/ 없음), 각 페이지
자체 head 유지 (4) 외부 라이브러리/CDN 0 (5) game-detail.jsp 통째 교체 금지(WebGL/6축/리뷰 로직 보존)
(6) 모델 속성명은 디자인 placeholder가 아니라 각 기존 JSP 실제값 사용.
순서: 1) bibimbap.css 도입+head link 2) empty-state 파편 3종 3) 홈 4) 로그인/회원가입(마크업만, 제출은
기존 AJAX 유지) 5) 약관 6) 프로필 7) 게임상세(이식만) 8) errer.jsp(파일명 유지) 9) 모집작성.
각 단위 커밋 + 변경 화면 빌드/스모크 검증.
```

View File

@ -0,0 +1,53 @@
# ATP Work Session Report
schema_version: 2.3.0
sid: 20260630-153323
started_at: 2026-06-30T15:33:23
user_request: |
클로드 디자인 툴 산출물(Bibimbap 커뮤니티 웹사이트 리디자인.zip — 7 JSP + bibimbap.css + 3 jspf + error.jsp)을
우리 프로젝트에 반영. 단 이번 세션은 "검토만" 하고, 실제 작업은 새 세션에서 진행할 수 있도록
"작업용 프롬프트"를 출력한다. (CSS는 공유 bibimbap.css 신설 방향)
ended_at: 2026-06-30T15:40
## Summary
zip(7 JSP + bibimbap.css + 3 jspf + error.jsp) 전수 검토 + 우리 실제 코드와 design↔reality 매핑 완료.
핵심 발견: 라우트(/games 복수 vs 실제 /game 단수), 폼 제출(classic POST vs JSON API+AJAX),
CSRF(전무 vs CsrfTokens/_csrf/window.BibimbapCsrf), contextPath(${ctx} vs ${pageContext.request.contextPath}),
include(common/ 가정 vs 평면), 에러(web.xml/error.jsp vs errer 뷰), css 디렉토리(없음) 모두 불일치.
→ 결론: 파일 통째 교체 금지, 공유 bibimbap.css 신설 + 비주얼 레이어 이식. 산출물 artifacts/handoff-prompt.md. 코드 변경 0.
## Invocations
- research-advisor: stopped (워커 결과 미수신 루프) → orchestrator 직접 rg/Read 탐색으로 대체. read-only라 충돌 없음.
## verified_by_me
- L1/L2: N/A (코드 변경 0 — 검토+문서 산출만)
## needs_user_verification
- 새 세션에서 실제 구현 진행(handoff-prompt.md의 ▶ 프롬프트). 구현 시 L1 빌드 + L2 스모크 필수.
## graph_refresh
skip: 코드 변경 0
## project_gate
skip: no code change
## open_items
- .atp/work-session/20260630-153323/ (이 세션 산출물, 미커밋) — 커밋 여부 사용자 결정 대기.
- 무관 잔여: 이전 work-session 3건, graphify-out/.
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '사용자가 산출물/CSS방식/범위(검토만+프롬프트)를 AskUserQuestion으로 확정 — 스코프 명확.'
checked_at: 2026-06-30T15:33
- advisor: graphify-lookup-advisor
decision: skip
rationale: '매핑 정확도 위해 실제 코드 직접 대조가 필요. research-advisor 직접 사용.'
checked_at: 2026-06-30T15:34
- advisor: research-advisor
decision: call
rationale: '7개 화면의 우리 실제 컨트롤러 라우트·모델 속성명·CSRF·include·정적 서빙을 매핑해야 프롬프트가 정확.'
checked_at: 2026-06-30T15:34
- advisor: design/implementation/verification-advisor
decision: skip
rationale: '이번 세션 코드 변경 0(검토+프롬프트 산출만). 실제 구현은 새 세션.'
checked_at: 2026-06-30T15:34

View File

@ -0,0 +1,29 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-30T17:00:00+09:00
concerns: []
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-30-visual-redesign-bibimbap-css.md` | changes | 신규 | `docs/changes/index.md` | `docs/changes/2026-06-29-b2-b4-fe-hardening.md` (선행 FE 작업) |
| `docs/changes/index.md` | changes | 수정(링크 추가) | — | — |
## 의사결정 기록 위치
- 카테고리 판별: 런타임 동작 변경(CSS 신설·JSP 마크업 이식) → `docs/changes/`
- ADR 미생성: 비주얼 레이어 이식은 되돌리기 어려운 기술·아키텍처 결정 수준이 아님 (CSS + 마크업 클래스 추가 수준)
- 세션 보고서: `.atp/work-session/20260630-160000/report.md` (decisions D1~D4, needs_user_verification 6건 포함)
## 추후 문서화가 필요한 항목
- needs_user_verification 결과 확인 후 이상 없으면 별도 문서 불필요. 결함 발견 시 `docs/analysis/` 또는 새 changes 항목으로 기록.
- 다크모드 토큰 전략(bibimbap.css 구조)이 아키텍처 결정으로 고착된다면 `docs/adr/` 추가 검토.
- `NumberFormatException "write"` 선재 버그는 범위 밖 — 별도 세션에서 `docs/analysis/` 기록 권장.

View File

@ -0,0 +1,12 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-30T16:30:00+09:00
---
# 파일 소유권 맵
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| src/main/webapp/WEB-INF/views/profile.jsp | implementation-advisor (직접) | - | modify | - |

View File

@ -0,0 +1,36 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-30T16:30:00+09:00
concerns: []
concerns_checked: true
workers_spawned: 0
planned_workers: 1
actual_workers: 0
---
# 구현 보고 — 단위 6: 프로필 비주얼 이식
## 변경 목록
| 파일 | worker | 결과 요약 |
|---|---|---|
| src/main/webapp/WEB-INF/views/profile.jsp | advisor 직접 | bibimbap.css 토큰 클래스 7곳 추가, AJAX/scriptlet/id 전부 보존 |
## Bash 단계 (advisor 직접)
- grep 검증 6종 → 전항목 통과
## 설계와의 차이
### planned_workers > actual_workers 전환 사유
파일 1개, 변경 유형 class 추가(기계적 6-8개 edit) → 파일 수 1 + 예상 변경 줄수 < 20 advisor 직접 실행 선택. worker spawn 오버헤드 불필요.
### 디자인 08-profile.jsp 대비 이식/비이식 처리
- **이식**: `.section-eyebrow`(heading eyebrow), `.section-title`(heading title), `.profile-head`(summary wrapper 병행), `.avatar`(avatar div 병행), `.game-row`(game 행 병행), `.meta`(game body 병행), `.card`(games 패널 병행), `.btn .btn-primary`/`.btn .btn-ghost`(버튼/수정 링크 병행)
- **비이식(비노출)**: 공개/비공개 토글 `<form action=".../visibility">` — 엔드포인트 `/games/{id}/visibility` 미존재 확인(grep 무수확), `/profile/edit` — 라우트 미존재 확인, `${user.*}` EL — scriptlet 변수 보존
- **보존 확인**: `submitNickname()`/`uploadAvatar()` 함수, `window.BibimbapCsrf.headers()` 두 곳, `id="profile-avatar-img"`/`id="profile-avatar-initial"`, `getName()`/`getThumbnailUrl()` accessor, `rawThumbUrl.startsWith("/") ? ctx+rawThumbUrl : rawThumbUrl` 경로 처리
## Verification 을 위한 힌트
- acceptance criteria: 보존 grep 6종 전항목 통과(advisor 직접 확인 완료)
- 영향받는 테스트: 프로필 페이지 E2E (profile 렌더/닉네임변경/아바타업로드)
- 비주얼 확인: bibimbap.css 토큰 클래스가 기존 자체 CSS와 충돌 없이 병행 적용되는지 브라우저 확인 필요

View File

@ -0,0 +1,136 @@
---
schema_version: 2
sid: 20260630-160000
resumed_from: null
started_at: 2026-06-30T16:00:00+09:00
ended_at: 2026-06-30T17:20:00+09:00
branch: feat/v2
user_request: |
클로드 디자인 리디자인 산출물(zip)을 비주얼 레이어 방식으로 반영.
공유 src/main/webapp/css/bibimbap.css 신설 + 디자인 클래스/구조 개선을
기존 WEB-INF/views JSP에 입힘. 파일 통째 교체 금지.
하드 제약 6종(CSRF/contextPath/header include/CDN0/game-detail 보존/실모델명).
순서 9단계, 각 단위 커밋 + 빌드/스모크 검증.
---
# Summary
클로드 디자인 리디자인 산출물을 "비주얼 레이어" 방식으로 9단위 반영 완료.
공유 bibimbap.css 신설(다크 오버라이드 포함) + theme-init 단일 link로 전 페이지
적용. 빈상태 파편 2종, 홈/로그인/회원가입/약관/프로필/모집작성에 디자인 클래스·
마크업만 이식하고 모델명·CSRF·AJAX·include·자체 head는 전부 기존값 보존.
game-detail은 이미 동일 토큰 구현 상태라 no-op. errer는 외형만 이식(scriptlet 유지).
하드 제약 6종 전부 준수(디자인 form/EL/common·/ctx·미존재 라우트 미채택).
커밋 8개(단위별) + L2 스모크가 errer JSTL taglib 결함 1건 포착·수정.
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '핸드오프 문서가 요구·제약·순서를 확정. 오픈질문 없음.'
checked_at: 2026-06-30T16:00:30+09:00
- advisor: graphify-lookup-advisor
decision: skip
rationale: '실제 JSP 파일 직접 읽기가 graph 인덱스보다 정확(모델명/CSRF 근거 필요). research 직행.'
checked_at: 2026-06-30T16:00:30+09:00
- advisor: research-advisor
decision: call
rationale: '9개 페이지 각각 실모델명/CSRF/head구조 + 디자인 산출물 클래스 매핑 추출 필요. parallel-explorer 분산.'
checked_at: 2026-06-30T16:00:30+09:00
# Invocations
- research-advisor (sonnet): 디자인↔실제 페이지별 매핑 spec. 5 parallel-explorer. source_confidence mixed.
- implementation-advisor ×6 (sonnet): 단위1 css / 단위2+3 빈상태+홈 / 단위4 인증 / 단위5+8 약관+errer / 단위6 프로필 / 단위9 모집 / 단위7 game-detail(no-op). 파일소유권 충돌 0.
- verification-advisor (sonnet): L1 WAR 빌드 PASS, css WAR 포함 PASS, JSP 사전컴파일 미설정→런타임 검증 필요 안내.
- graph-refresh-checker (sonnet): partial-stale 판정.
- graphify-update-advisor (sonnet): full scope 메타 갱신.
# Decisions
- D1 (auto): errer.jsp 기존 scriptlet statusCode 읽기 유지. 디자인 errorData EL 미채택 — 외형(SVG/empty-state)만 이식. 근거: 기존 동작 검증됨, errorData 노출 미확인(concern).
- D2 (auto): 미존재 라우트(/games/{id}/visibility, /profile/edit, /password/reset) 요소 비노출. 근거: 백엔드 부재, 비주얼 레이어 범위 밖, 핸드오프 제약.
- D3 (auto): 모든 디자인 form 태그 통째 이식 금지. body 내부 마크업/클래스만 추출, 기존 head/include/AJAX/CSRF 보존.
- D4 (사용자 확인): bibimbap.css 다크모드 토큰 처리.
# user_signals
positive:
- '다크모드/재기동 결정 질문에 한 번에 추천안 수락 — 계획 가시화·결정 게이트가 마찰 없이 통과.'
negative: []
# verified_by_me
- 'L1: WAR 빌드 ./mvnw -q -DskipTests package exit 0 (PASS)'
- 'L1: bibimbap.css WAR 포함 target/.../css/bibimbap.css (PASS)'
- 'L2: 로컬 dev 컨테이너(spring-boot:run) 재기동 후 공개 페이지 JSP 컴파일/렌더 스모크 — /, /login, /signup, /terms, /posts, /recruit, /error 전부 200'
- 'L2: 마커 검증 — home css link+hero, login pw-toggle, signup pw-meter, terms toc, error empty-state 전부 서빙 확인'
- 'L2 결함 포착·수정: errer.jsp stray javax JSTL taglib → /error 500 → taglib 제거 → 200 (fix 커밋 360078a). 회귀: 동일 advisor 산출 sibling(terms)는 200 정상.'
- '로그 스캔: 재기동 후 신규 JasperException 0건 (07:35:52 잔존분은 재기동 전)'
# browser_review (후속 — 사용자 요청 "브라우저 실제 적용본 검토")
- 검토 페이지(8): 홈(다크+라이트 토글 양쪽 정합), /login(pw-toggle·비번찾기 미노출 확인), /signup(강도막대·약관체크), /terms(sticky TOC+조항본문), /posts·/recruit(빈상태), /error(empty-state+버튼), /game/3.
- game-detail 실동작 확인: WebGL 16:9 플레이어 로드, 6축 레이더 SVG(몰입성/창의성/조작성/완성도/사운드/비주얼), 리뷰 AJAX 목록·평점 3.8(6)·로그인 게이트·정렬칩 — WebGL/6축/리뷰 보존 확정(unit7 no-op 정당).
- **결함 2건째 포착·수정**: posts-empty/recruit-empty.jspf 정적 include 인코딩 미상속 → 빈상태 한글 mojibake. 각 파편 pageEncoding=UTF-8 추가로 해소(fix a4d163d), 브라우저 재확인 정상. 교훈 docs 반영(d379a1c).
- L1 재검: docker 컨테이너(temurin-21) 내 mvn package PASS(호스트 Java 미설치라 컨테이너 빌드).
# needs_user_verification
- '로그인 화면 인터랙티브 제출: 실제 email/password 로그인 AJAX(_csrf + BibimbapCsrf.headers 이중방어) 성공 이동, 회원가입 제출→modal→/login. (마크업 렌더·제출코드 무손실 grep 확인했으나 브라우저 세션 제출 1회 권장. 테스트계정 admin@bibimbap.local)'
- '프로필: 닉네임/아바타 변경 AJAX 동작(로그인 세션 필요).'
- '게임상세: WebGL 플레이·6축 리뷰 작성/수정/삭제·덧글 CRUD (변경 없음=no-op이나 회귀 확인 권장).'
- '모집작성: /recruit/new 제출 성공 + 라이브 미리보기(로그인 세션 필요).'
- '다크모드 토글 시 body 컴포넌트 ↔ header/footer 색 정합 육안 확인.'
- '참고(범위 밖·선재): 로그 NumberFormatException "write" — 숫자 @RequestParam에 비숫자 유입. 본 작업(비주얼·컨트롤러 무수정)과 무관, 별도 추적 권장.'
# graph_refresh
partial-stale → graphify-update-advisor가 full scope 메타 갱신(docs/graph/index.md, source_commit 360078a). 신규 노드 6건(뷰 fragment 2 + css + docs 3) + include 엣지 2건 반영. 커밋 8c86e8c. (코어 의존성 구조는 무변경.)
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "다크모드/재기동 결정 질문에 한 번에 추천안 수락"
about: 결정 게이트(D4 다크모드 토큰 처리)를 계획 가시화 후 질문 → 1라운드 수락. 마찰 0.
negative: []
what_went_well:
- "research 단계에서 핸드오프 매핑 spec(디자인↔실제 모델명/CSRF/라우트)을 선행 추출해 구현 6단위 전반의 불일치 차단 — 하드 제약 6종 전부 준수."
- "L2 스모크(spring-boot:run 재기동 후 공개 페이지 전수)가 WAR 빌드 PASS에도 숨은 errer.jsp JSTL taglib 결함을 포착·fix 커밋까지 완결."
- "단위별 커밋 8개 — 롤백 경계 명확, 결함 fix 커밋(360078a)이 구분 가능."
what_to_improve:
- "JSP 사전컴파일 미설정 → WAR 빌드 PASS가 JSP EL/taglib 런타임 오류를 은닉. 비주얼 레이어 작업에서 디자인 산출물의 JSTL taglib이 stray로 유입되는 패턴이 실제 발생했으므로, 'JSP 변경은 빌드만으로 불충분 — 런타임 스모크 의무' 규약이 없다."
memory_candidates:
- name: jsp-runtime-smoke-gate
type: feedback
description: "JSP 변경(비주얼 레이어 포함)은 WAR 빌드 PASS만으로 충분하지 않음 — 런타임 스모크를 의무 검증 레벨로 명시해야 함."
body_draft: |
### JSP 변경은 빌드 PASS만으로 불충분 — 런타임 스모크 의무
이 프로젝트는 JSP 사전컴파일이 미설정(Tomcat이 런타임에 컴파일)되어 있다.
결과적으로 `./mvnw package`가 exit 0이어도 JSP EL 표현식·taglib 선언 오류는
런타임에만 발현한다. 비주얼 레이어 작업에서 디자인 산출물의 stray JSTL taglib이
유입되는 패턴이 실증됐다(errer.jsp → /error 500, fix: 360078a).
**Why**: WAR 빌드는 JSP를 파일로만 패키징하고 컴파일하지 않는다. L1 빌드 GREEN은
"JSP 구문이 유효하다"는 보증이 아니다.
**How to apply**:
- JSP 파일이 변경된 모든 세션에서 verification 단계에 "런타임 스모크" 항목을
명시(spring-boot:run 재기동 후 변경된 JSP 경로 200 확인).
- 비주얼 레이어 작업은 디자인 산출물에서 stray taglib(`<%@ taglib ...%>`)이
유입되었는지 grep 전수 확인을 implementation 체크리스트에 포함한다:
`rg '<%@\s*taglib' src/main/webapp/WEB-INF/views/`
- 사전컴파일 도입(maven-jasper-plugin)은 별도 open item으로 관리한다.
> 근거: 세션 20260630-160000 — errer.jsp javax.servlet.jsp.jstl taglib stray 유입
> → /error 500 런타임 발현. L2 스모크에서 포착·수정.
rationale_for_saving: "재현성 있음 — JSP 파일이 변경되는 모든 세션에서 동일 위험. verification-strategies.md에 동일 항목 없음."
signal_source: observation
docs_sync_target: "/Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md"
memory_optional: true
protocol_feedback: []
applied_changes:
- "jsp-runtime-smoke-gate 교훈을 verification-strategies.md에 신규 §추가 (커밋 8d52b10). docs-first 단독 마감 — memory_optional이므로 memory 강제 안 함."
```
# open_items
- '.atp/work-session/* 다수 미추적(이번 20260630-160000 포함, 선재 세션들도). 소스 변경은 전부 커밋됨. 정책상 work-session 추적 기본이나 본 세션 산출 외 선재분은 범위 밖 — 추적 여부 사용자 판단.'

View File

@ -0,0 +1,180 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-30T16:00:00+09:00
concerns:
- "low source confidence — errer.jsp의 error-page 매핑(web.xml/ErrorConfig) 및 pageContext.errorData 노출 가능 여부 미확인. error/error.jsp의 ${pageContext.errorData.statusCode} 바인딩이 현 Tomcat/Spring 구성에서 동작하는지 검증 전 권위 데이터(시드·계약)로 승격 금지."
- "디자인 산출물 다수가 include 경로 '/WEB-INF/views/common/header.jsp'를 참조하나 현 프로젝트는 'common/' 서브디렉터리가 없음(header.jsp는 /WEB-INF/views/ 직하). 이식 시 경로 전부 교정 필요 — 디자인 마크업을 '있는 그대로' 복사 금지."
concerns_checked: true
source_confidence: mixed
workers_spawned: 5
---
# 조사 결과 — 비주얼 레이어 이식 mapping-spec
## 주제
디자인 산출물(외부 클로드 디자인 툴 결과물)을 기존 JSP에 "CSS 클래스/마크업 구조만" 이식하기 위한 페이지별 (a)모델 속성명 (b)CSRF 패턴 (c)head/include 구조 (d)제출 방식 (e)안전 이식 가능 블록 추출.
근거: 각 항목 file:line 인용은 parallel-explorer 5개(G1~G5)가 전문 읽기로 확인. 라우트/필드 바인딩은 advisor가 controller 직접 grep으로 교차검증.
---
## 공유 인프라 (G1) — 확인됨
### theme-init.jsp (32줄)
- 독립 `<head>` 조각. `<link>`/`<style>` 없음. 포함: `<meta name="csrf-token">`(line 7) + `<script>`(테마 FOUC 방지 + `window.BibimbapCsrf` 전역 등록, line 19-30).
- **`window.BibimbapCsrf`는 여기서 정의됨** — 모든 AJAX CSRF 헤더의 출처. (확인됨)
- views 디렉터리 27개 JSP 중 공유조각 4개를 제외한 **23개 전원이 theme-init.jsp를 include**, 미포함 0건. (확인됨, grep)
### header.jsp (250줄)
- 자체 `<style>`(14-194). header 전용 CSS 변수는 `.site-header` 스코프에서만 선언. BEM 클래스(`.site-header__*`) 일관. (확인됨)
- contextPath = `${pageContext.request.contextPath}` (line 197). **`${ctx}` 별칭 정의 없음.** (확인됨)
- nav = `<nav class="site-header__nav">` + 플랫 `<a class="site-header__nav-link">`, ul 없음. (line 201-203)
- `window.BibimbapCsrf` 정의 없음(theme-init 소유). modal.jsp include(line 231).
### footer.jsp (96줄)
- 자체 `<style>`(2-77), script 없음. `.site-footer__*` 클래스. 링크색 `#e8a54b` 하드코딩(line 43) — bibimbap.css `--color-accent: #D4A853`와 불일치(토큰 정합성 관찰). (확인됨)
### bibimbap.css (352줄) — 디자인 산출물
- 상단 주석(1-5): "외부 라이브러리 없음·순수 CSS". **@import / @font-face / CDN url() 없음.** 폰트는 로컬 `'Pretendard Variable'` 의존. (확인됨)
- `:root` 변수 24개(8-41): `--color-*`, `--radius-*`, `--font-sans/mono`. **다크 모드 오버라이드 블록 없음(라이트 고정 토큰).** (확인됨)
- 레이아웃 클래스: `.page .btn .btn-primary .field .input .textarea .card .empty-state .game-grid .game-card .profile-head .avatar .terms .terms-toc .terms-body .form-grid .form-actions` 등.
- **`.site-header*`/`.site-footer*`를 정의하지 않음 → header/footer와 직접 클래스 충돌 없음.** (확인됨)
- 주석 line 4 예시는 `${ctx}/css/bibimbap.css` 사용 — 현 코드베이스 패턴(`${pageContext.request.contextPath}`)과 다름. link 작성 시 교정 필요.
---
## G2 홈 + 빈상태
### index.jsp (기존)
- 게임 모델: `GameData` — accessor `getName()`/`getCreator()`/`getThumbnailUrl()` (확인됨, GameData.java:40/48/80). **→ EL은 `${game.name}` `${game.creator}` (디자인의 `${game.title}`/`g.author`는 불일치).**
- 자체 head 보유 + theme-init/header/footer include.
- 게임 등록 라우트 = `/game/new` **단수** (확인됨, WebMvcController.java:62 / GameController.java:67). 디자인의 `/games/new` 복수는 불일치.
### recruit-list.jsp / posts-list.jsp (기존)
- `/recruit/new` (RecruitController.java:41/49) · `/posts/new` (PostController.java:99) **둘 다 실재 확인됨.** 디자인 CTA href는 이 값으로 교정.
- 빈 상태 분기 위치에 디자인 fragments(recruit-empty.jspf, posts-empty.jspf) include 가능 — 단, fragments는 디자인 클래스(`.empty-state`)를 쓰므로 bibimbap.css 도입이 선행돼야 시각 정상.
### 01-home.jsp + fragments 3종 (디자인)
- **이식 가능**: 히어로 섹션, 정렬칩, `.game-card`/`.game-grid` 외형 클래스, `.empty-state` fragment 마크업.
- **이식 금지/교정**: `${game.title}`→`${game.name}`, `${game.author}`→`${game.creator}`, `/games/new`→`/game/new`. 모델 바인딩 이름을 디자인 그대로 쓰면 빈값 렌더.
---
## G3 인증 (login / signup)
### login.jsp (기존) — 확인됨
- 제출: form action="…/login" 선언되나 submit 인터셉트 후 **AJAX fetch POST /login** (line 272-273), 성공 시 `location.href=ctx+'/'`.
- CSRF **이중 방어**: hidden `<input name="_csrf">`(line 224) + `window.BibimbapCsrf.headers()`(line 286-294).
- 필드 name: `email`(227), `password`(231), `remember`(235).
- 에러 표시: 인라인 DOM 슬롯 없음 → `window.BibimbapModal.alert()` 호출.
- 자체 head 보유(4-6) + theme-init(9)/header(208).
### signup.jsp (기존) — 확인됨
- 제출: **AJAX fetch POST /signup**, 성공 시 BibimbapModal.alert 후 /login 이동.
- CSRF 이중 방어 동일(hidden `_csrf` line 222 + headers line 300-306).
- 필드 name: `displayName`(227), `email`(231), `password`(236), `passwordConfirm`(240), `termsAccepted`(244), `provider`(hidden 223), `providerUserId`(hidden 224, submit 직전 email 복사 line 293).
- **클라이언트 비밀번호 일치 검증 JS 없음**(checkValidity만, line 285-293).
### 02-login.jsp / 03-signup.jsp (디자인)
- 자체 head **없음**(fragment). include가 `common/header.jsp`(불일치 경로).
- **CSRF hidden input 전무 + submit 인터셉트 JS 전무** → native POST. **form 태그 통째 이식 금지** (CSRF 누락·AJAX 로직 상실).
- 약관 체크박스 name=`agree`(03-signup line 55) — 서버 기대값 `termsAccepted`와 불일치 → 그대로 이식 시 약관 동의 유실.
- `providerUserId`/`provider` hidden 없음 → 기존 signup 필수 hidden 누락.
- **이식 가능(비주얼 전용)**: `.pw-field`+`.pw-toggle` 마크업·인라인 onclick(02-login 29-33), `.pw-meter` data-level 강도막대+oninput(03-signup 34-36), `#pw-ok` 일치표시+oninput(43-45, id를 기존 필드 id에 맞춰 교정), `.form-error`/`.form-ok`/`.help` 슬롯.
- **이식 금지**: form 태그/action/method, common/ include, `name=agree`, native POST.
- `/password/reset` 링크(02-login line 27): 기존 login.jsp에 없음. **라우트 미존재(grep 무수확) → 링크 비노출 권고.** (확인됨: 컨트롤러에 매핑 없음)
---
## G4 약관 + 프로필
### terms.jsp (기존) — 확인됨
- 조항 본문 **전량 하드코딩**(모델 바인딩 없음). `<article class="policy-doc"> > <section class="policy-section">` + `<h2>`+`<ol><li>` (99-191).
- 자체 head 보유(4-94, 인라인 style 포함) + theme-init(7)/header(97)/footer(193).
### 07-terms.jsp (디자인)
- sticky 목차 `<nav class="terms-toc">`(17-29, 신규 블록), 조항 `<div class="terms-body"> <section id="aN"> <h3><p><br>`(32-94). 자체 head 없음, common/ include.
- **이식 가능**: `.terms`/`.terms-toc`/`.terms-body` 래퍼·TOC(신규 추가). **마크업 변환 필요**: `<section class="policy-section">+<ol><li>``<section id="aN">+<p>`, `<h2>`→`<h3>`.
- **이식 금지/교정**: 자체 head 미보유분은 기존 head 유지, common/ 경로 교정.
### profile.jsp (기존) — 확인됨
- 모델: session attr `displayName`/`email`/`avatarUrl`(17-19), request attr `myGames`(27-28)/`myBadges`(31-36). **scriptlet 방식 — 디자인의 EL `${user}` 객체와 다름.**
- CSRF: hidden input 없음, **`window.BibimbapCsrf.headers()` 헤더 주입만**(634-645, 699-708).
- AJAX 함수: `submitNickname()`(619, POST /profile/nickname), `uploadAvatar()`(692, POST /profile/avatar).
- 썸네일 경로: `rawThumbUrl.startsWith("/") ? ctx+rawThumbUrl : rawThumbUrl`(491-493).
- **공개/비공개 토글 UI·API가 기존엔 전무.** (확인됨)
### 08-profile.jsp (디자인)
- `.profile-head` 헤더(17-24), `.game-row`+`.thumb`+`.meta`(39-58), 공개토글 `<form action="${ctx}/games/${g.id}/visibility"> <label class="switch"><input type=checkbox onchange="this.form.submit()">`(46-53).
- **공개토글 엔드포인트 `/games/{id}/visibility` 현 컨트롤러에 미존재**(grep 무수확; 유사한 `/admin/jams/{jamId}/visibility`만 존재 — 동명 경로이나 별개 엔티티/권한, 혼동 금지). 이 토글은 **백엔드 신설 없이는 비주얼만 이식 불가** → 비주얼 레이어 범위 밖. (확인됨)
- `/profile/edit` 링크(08-profile line 23): 기존 인라인 편집(닉네임/아바타)만 있고 라우트 미존재(grep 무수확). 비노출 또는 백엔드 결정 필요.
- **이식 가능**: `.profile-head`·`.game-row` 외형(단 EL `${user.*}`→scriptlet 변수 교정, `${myGames}` 게임 필드는 `getName()`/`getThumbnailUrl()` 사용).
- **이식 금지(보존 필수)**: `submitNickname()`/`uploadAvatar()` fetch + `window.BibimbapCsrf` 헤더, `id="profile-avatar-img"`/`profile-avatar-initial` (JS 참조), `/games/.../visibility` 토글(백엔드 부재).
---
## G5 게임상세 + 모집 + 에러
### game-detail.jsp (기존, 2385줄) — 블록 매핑 (확인됨)
| 블록 | line | 보존필수 |
|---|---|---|
| scriptlet 모델바인딩(XSS escape) | 1-22 | 필수 |
| head + 인라인 CSS(토큰 40여개) | 23-1203 | CSS 교체가능 |
| body+header include | 1204-1205 | 이식가능 |
| topbar(뒤로/owner액션) | 1206-1218 | 이식가능(클래스만) |
| 메타카드(제목/번호/제작자/좋아요) | 1220-1256 | 이식가능(id 유지) |
| **WebGL iframe** | 1258-1276 | **필수(sandbox/allow/scriptlet src 불변)** |
| 제작자 한마디 패널 | 1279-1296 | 이식가능 |
| **리뷰 패널 마크업** | 1298-1347 | **필수(id 20여개 JS참조)** |
| **덧글 패널 마크업** | 1349-1381 | **필수(id JS참조)** |
| **IIFE 스크립트(CRUD/CSRF/6축레이더/별점)** | 1387-2382 | **필수 전체보존** |
- CSRF: `baseHeaders()`/`formHeaders()` 공통 헬퍼가 `window.BibimbapCsrf.headers()` 중앙화(1562-1569).
- 리뷰 CRUD: `reviewsUrl = ctx+'/game/'+gid+'/reviews'`(2021), GET/POST/PUT/DELETE(2306/2375/2236). 덧글: `commentsUrl=…/comments`(1800), POST/PUT/DELETE(1977/1845/1901).
- 6축: `AXIS_KEYS=[immersion,creativity,controls,completeness,sound,visual]`(1508), `buildHexRadar()`(1607-1702) SVG 동적생성, `buildStarRadioGroup()`(1731-1786).
- 보존필수 id 목록: `game-review-composer/form/axes/stars/list`, `game-reviews-summary-panel/radar/empty/toolbar/more`, `game-comment-form/input/list/composer/login-gate`, `game-comments-toolbar/more`, `game-like-btn/count`.
### 06-game-detail.jsp (디자인)
- 자체 head 없음, EL `${game.*}`, WebGL iframe **sandbox 속성 없음**(보안 후퇴 — 절대 이 형태로 교체 금지), 6축은 `<div class="help">6축 평가 포함</div>` 플레이스홀더(빌더 없음), 리뷰는 `<c:forEach>` 서버렌더(기존 AJAX 아님), 좋아요는 native form POST.
- **결론: game-detail은 통째 교체 절대 금지. 표층 CSS 클래스 토큰 교체와 topbar/메타카드/제작자패널 외형만 선별 이식. 디자인의 WebGL·6축·리뷰 구현은 모두 기능 후퇴이므로 버린다.**
### recruit-form.jsp (기존, 450줄) — 확인됨
- 제출: `action="…/recruit/new"` novalidate + **AJAX fetch POST**(376-395), `window.BibimbapCsrf.headers()`.
- 필드 name(controller @RequestParam로 교차확인): `projectName`(260) `genre`(264) `summary`(268) `role`(272) `status`(280) `type`(289) `period`(298) `team`(302) `contact`(306) `description`(310). (RecruitController.java:52-61에서 동일 name 수신 확인됨)
- 글자수 카운터 JS 없음(maxlength만). 미리보기 `render()` IIFE(338-446).
### 10-recruit-form.jsp (디자인)
- native form POST `action="${ctx}/recruit"`(line 18, **CSRF 없음·엔드포인트 불일치 — 기존은 /recruit/new**), 필수표시 `<span class="req">*</span>`, 글자수카운터 summary만 인라인 oninput(33-35), 필드 `name="title"`(22)는 서버 기대 `projectName`**불일치**(확인됨).
- **이식 가능**: `.req` 필수표시, `.count`/`#sc` 글자수 마크업+인라인 oninput(독립적), `.form-page`/`.field` 외형.
- **이식 금지**: form action/method(엔드포인트 /recruit ≠ /recruit/new, CSRF 누락), `name="title"`(→projectName 유지), AJAX submit·미리보기 render() 보존.
### errer.jsp (기존, 232줄) — 확인됨 / error/error.jsp (디자인) — 미확인 부분 존재
- errer.jsp: `isErrorPage=true`, **`jakarta.servlet.error.status_code` scriptlet**(190)으로 코드 읽음, `${statusCode}`/`${status}` EL 미사용. statusCode!=null 단일 체크(203-208), 코드별 분기 없음. requestUri/message 상세 블록 보유.
- error/error.jsp(디자인): `${pageContext.errorData.statusCode}`→`<c:set var=code>`(19) + `<c:choose>` 403/otherwise(404) 분기(27/44). 상세정보 블록 없음.
- **미확인**: web.xml/ErrorConfig의 error-page 매핑 위치(grep 무수확), `pageContext.errorData`가 현 구성에서 노출되는지. → 디자인 EL 바인딩 채택 전 검증 필요. concern 등록함.
- **이식 가능**: SVG 아이콘/버튼/`<c:choose>` 분기 외형. **주의**: 코드 바인딩 방식 변경(scriptlet→errorData EL)은 동작 검증 후. 상세정보(requestUri/message) 노출은 정책 결정 사항.
---
## 종합 판단
### bibimbap.css 도입 지점 결론 (단일 지점 추천)
**theme-init.jsp에 `<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">` 1줄 추가가 유일한 단일 지점이다.** 23개 전 페이지가 예외 없이 theme-init을 include하므로 전역 적용된다(확인됨). header/footer는 include 체인 중간이라 단일 지점 불가. CSS 자산은 `src/main/webapp/css/`에 두면 Tomcat DefaultServlet이 서빙(CLAUDE.md 정책 부합). 단 디자인 주석의 `${ctx}` 별칭은 현 코드에 없으므로 `${pageContext.request.contextPath}`로 작성.
### CSS 클래스 네임스페이스 충돌 요약
- bibimbap.css는 `.site-header*`/`.site-footer*`를 정의하지 않음 → **header/footer 인라인 스타일과 클래스 직접 충돌 없음.**
- 잠재 충돌: **다크 모드 이중 체계.** header/footer는 `html[data-theme="dark"] .site-*` 자체 변수, bibimbap.css `:root`는 다크 오버라이드 없는 라이트 고정 토큰. 도입 후 body 컴포넌트(라이트 고정) vs header/footer(다크 대응)가 공존 → 다크 모드에서 body 영역이 라이트로 남는 시각 불일치 가능. (확인됨, 디자인 의도 확인 필요)
- 색상 토큰 불일치: footer `#e8a54b` vs bibimbap.css `--color-accent #D4A853`.
### 횡단 패턴 (확인됨)
1. **모든 디자인 산출물이 자체 head 미보유 + `common/` include 경로 사용** → 기존 자체-head 구조에 "그대로" 붙일 수 없음. body 내부 마크업만 추출해 기존 head/include 유지한 페이지에 이식.
2. **모든 디자인 form이 CSRF hidden·AJAX 로직 미보유(native POST)** → 어떤 form 태그도 통째 이식 금지. 제출 로직(AJAX+BibimbapCsrf)은 전 페이지 보존 필수.
3. **모델 바인딩 이름 불일치 다발**: 디자인 EL(`game.title`/`author`/`user`/`/games/new`/`/recruit`/`name=title`/`name=agree`) vs 기존(`name`/`creator`/scriptlet session/`/game/new`/`/recruit/new`/`projectName`/`termsAccepted`). 이름 그대로 이식 시 빈 렌더·필드 유실.
## 미해결
- errer.jsp error-page 매핑 위치 및 `pageContext.errorData` 노출 가능 여부 (web.xml/Java config 미확인 — concern 등록). 디자인 error EL 채택 전 검증 필요.
- `/games/{id}/visibility`(프로필 공개토글), `/profile/edit`, `/password/reset` 라우트 부재(grep 확인) → 디자인이 제안하나 백엔드 신설 없이는 비주얼 이식 무의미. 비주얼 레이어 범위 밖 — 노출 정책은 design/orchestrator 결정 사항.
- bibimbap.css 도입 후 다크 모드 body 토큰 처리(라이트 고정) — 디자인 의도 확인 필요(조사 범위 밖, 설계 결정).

View File

@ -0,0 +1,67 @@
---
schema_version: 1
sid: 20260630-170024
user_request: "/frontend-design 디자인 변경 및 추가했는데 검토해줘 — feat(redesign) 커밋 시리즈 검토 (read-only)"
started_at: 2026-06-30T17:00:24
mode: review-only (코드 변경 없음, orchestrator 직접 수행)
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확(=리디자인 커밋 검토). 빌드할 신규 기능 없음.'
checked_at: 2026-06-30T17:01
- advisor: design/implementation-advisor
decision: skip
rationale: 'review-only. 코드 작성 0. 빌드 체인 불필요.'
checked_at: 2026-06-30T17:01
- advisor: verification/graphify/retrospective
decision: skip
rationale: '코드 변경 0줄 → graph-refresh-checker 트리거 미충족. 검증 대상 산출물 없음.'
checked_at: 2026-06-30T17:30
# Summary
feat(redesign) 시리즈(0390da6~a4d163d) 검토. 13개 src 파일 +807/-223.
공유 bibimbap.css(384L) 신설 + JSP 9단위 비주얼 레이어 이식.
보안/인코딩/CSRF/CDN 하드제약은 준수. 핵심 문제는 **디자인 일관성** — 공유 토큰 시스템과 기존 페이지-로컬 토큰 시스템 2종이 병존.
# Findings (severity desc)
- HIGH(coherence): bibimbap.css `--color-accent:#D4A853` ≠ 앱 전역 브랜드 골드 `#e8a54b`(header/footer/modal/26파일). 모든 페이지에 골드 2종 공존. 다크 override는 `--color-accent-strong:#e8a54b`만 일치시켜 모순 노출. unit-1 "공유 css 전 페이지 공통" 목표와 상충.
- MED: 클래스 합집합 override 모호 — profile.jsp `class="profile-button btn btn-primary"` 등. bibimbap `.btn-primary`(외부 link) vs 페이지-로컬 `.profile-button--primary`(`<style>`) 동일 specificity, source order(페이지 style 후행)로 로컬 승 → 추가한 btn 클래스 일부 inert/예측불가 혼합.
- MED: recruit-form.jsp 가 공유 시스템 미채택 — 전체 인라인 `<style>` 병렬 토큰(`--accent:#e8a54b` 등) + bibimbap 클래스명 shadow(.field/.form-grid/.req/.count/.preview-card/.form-actions). 가장 이질적.
- LOW: index.jsp bibimbap.css 이중 로드(직접 `<link>` + theme-init include).
- LOW: 인라인 핸들러(login onclick / signup·summary oninput) — CSP 비친화 + recruit-form addEventListener 패턴과 혼재.
- LOW: 전역 `:focus-visible` 링 없음(링크/chip/버튼). input 은 focus ring 보유 ✓.
- LOW: Pretendard/JetBrains Mono 폰트스택만 — @font-face 없음. 미설치 환경 silent fallback → 디자인 의도 display 개성 소실(단 CDN-0 제약상 트레이드오프).
# Positives
- XSS 안전: recruit-form 미리보기 `textContent` ✓, index `HtmlUtils.htmlEscape`(thumbUrl/jamTitle/searchQuery) ✓, 신규 raw user-data EL 출력 0.
- CSRF 보존 ✓ (`BibimbapCsrf.headers` + meta csrf-token).
- CDN 0 ✓. 빈상태 fragment 깔끔 + pageEncoding 수정 완료 ✓.
- 다크 토큰 header.jsp 출처 명시 ✓. 반응형 breakpoint 존재 ✓.
- 커스텀 empty-state SVG = 가장 distinctive 한 시그니처 요소.
# verified_by_me
- 검토 단계: 13파일 diff + bibimbap.css + 2 fragment + theme-init 전수 read.
- 구현 후 L1: `./mvnw -DskipTests package` exit 0 PASS. WAR 내 css/bibimbap.css 포함 확인.
- 구현 후 L2: dev 컨테이너 소스 bind-mount(`.:/build`)로 변경 라이브. 스모크
`/` `/login` `/signup` `/terms` `/recruit` `/posts` `/error` HTTP 200 + JSP 컴파일 에러 0.
서빙 `/css/bibimbap.css` accent `#e8a54b` 확인.
- 로그 스캔: JSP 예외 마커 0건 clean.
# needs_user_verification
- `/recruit/new`(302, 로그인 필요): 인증 세션에서 rf- rename 후 폼 레이아웃·라이브 미리보기 정상 렌더.
- `/profile`(302, 로그인 필요): 인증 세션에서 버튼/카드 외형(중복 클래스 제거 후) + 골드 정합.
- 다크모드 토글 시 전 페이지 골드 단일(#e8a54b) 육안 정합.
# open_items
- `.atp/work-session/20260630-170024/report.md`(본 파일) 미커밋 — 기존 work-session 디렉토리들도 untracked 상태와 일관.
# graph_refresh
- partial-stale (구조 시그널 0 — css 토큰 스냅샷만 약 stale). graph-refresh-checker 권고대로
단독 재생성 생략(토폴로지 무변경, 비용 부당), 다음 구조변경 사이클에 흡수.
# commit
- cf1e7ce fix(redesign): 골드 토큰 통일·클래스 충돌 정리 (4 src + 2 docs)
ended_at: 2026-06-30T17:45

View File

@ -0,0 +1,26 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-30T18:20:00+09:00
concerns: []
concerns_checked: true
---
# 문서화 보고
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| `docs/changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md` | changes | 신규 | `docs/changes/index.md` (최신 항목 1줄) | 선행 2건(visual-redesign, redesign-token-coherence) + verification-strategies.md 레슨 (양방향) |
| `docs/changes/index.md` | changes | 수정 | — | 신규 changes 문서 링크 추가 |
| `docs/development/verification-strategies.md` | development | 수정 | — | 신규 changes 문서로 back-link 추가(L304 레슨 근거 줄) |
## 의사결정 기록 위치
- 런타임 동작(JSP/CSS 시각) 변경 → `docs/changes/` (런타임 동작 변경 기준). 사례(work-log/case) 성격이나 사용자 요청대로 기존 2026-06-30-*.md 형식에 맞춰 changes/ 에 기록.
- 진단 휴리스틱(다축 스캔 선행) 교훈은 이미 `docs/development/verification-strategies.md` L304 에 본 세션(20260630-175023) 근거로 등록되어 있었음 → 중복 생성 없이 changes 문서에서 상호참조 + 역링크만 추가.
## 추후 문서화가 필요한 항목
- ADR 부재: "고스트 버튼에 골드 accent 토큰 재사용" 설계 결함은 이번에 `--color-text` 로 정정됐으나 토큰 사용 규약(어느 토큰을 어느 컴포넌트에)이 별도 문서화되어 있지 않음. 토큰 거버넌스가 반복되면 architecture/ 또는 ADR 후보.
- needs_user_verification 2건(로그인 상태 스모크 / 실게임 가로 썸네일 4:3 크롭)은 changes 문서에 명시 — 사용자 확인 후 maintenance/post-deploy 체크리스트 반영 여부 검토 가능.
- graph_refresh partial-stale(report.md open_items): docs/changes 노드 backlog + source_commit stale — 다음 구조변경 시 /graphify full. 본 문서 작업 산출 아님(graphify-update-advisor 몫).

View File

@ -0,0 +1,27 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-30T17:50:23+09:00
---
# 파일 소유권 맵
| 파일 | 담당 | 변경 유형 | 관련 fix | 의존 |
|---|---|---|---|---|
| src/main/webapp/WEB-INF/views/index.jsp | advisor 직접 | modify | #1 #3 #4 #13 | - |
| src/main/webapp/css/bibimbap.css | advisor 직접 | modify | #2 #6 #7 #11 #12 | - |
| src/main/webapp/WEB-INF/views/recruit-list.jsp | advisor 직접 | modify | #5 #8 #9 #10 | - |
| src/main/webapp/WEB-INF/views/fragments/recruit-empty.jspf | (변경 없음) | none | #9 (이미 충족) | - |
## planned_workers vs actual
- planned_workers(code-writer): 3 (파일당 1)
- actual_workers: 0 (advisor 직접)
- 전환 근거: 편집 파일 3개 < 8 임계, 변경 추정 < 50줄, 전부 소규모 CSS/속성 조정으로
병렬화 이득 없음 + JSP UTF-8 보존 사고 이력으로 advisor 직접 정밀 편집이 더 안전.
- recruit-empty.jspf 는 이미 .btn-primary(골드) + .btn-ghost(보조) 구조라 #9 충족 → 무편집(인코딩 보존).
## 충돌 방지 불변식
- 각 파일은 단일 편집 주체(advisor)만 접근 → 충돌 없음.
- 토큰 계열 분리: index.jsp/recruit-list.jsp 로컬 토큰(--accent/--text/--border 등),
bibimbap.css 는 --color-* 만. #2 .btn-ghost 는 bibimbap.css 소속 → var(--color-text) 정확.

View File

@ -0,0 +1,171 @@
---
schema_version: 2
sid: 20260630-175023
started_at: 2026-06-30T17:50:23
user_request: |
/frontend-design 두 시각 버그 수정 (이전 작업서 미해결):
- Image #2 (메인 게임 허브): 게임 카드 썸네일 "비율" 문제
- Image #3 (팀원 모집 빈 상태): "가시성" 떨어지는 케이스
Invocations: []
Summary: ''
Decisions: []
user_signals:
positive: []
negative:
- quote: "이전 작업에서 안고쳐졌어"
structural: false
note: "직전 리디자인 세션이 두 케이스 미해결. 발원 단계 재검 필요(§2.6)."
regression: ''
graph_refresh: ''
open_items: []
verified_by_me: []
needs_user_verification: []
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements/design/implementation
decision: skip
rationale: '2건 모두 국소 CSS 수정(토큰 오용·종횡비 1값). 이슈2 원인 확정, 이슈1 결정축 단일. orchestrator 직접+plan게이트(§1 마이크로 예외, §5.0).'
checked_at: 2026-06-30T17:52
- advisor: verification
decision: call(self)
rationale: 'CSS 변경 → 런타임 스모크 의무(직전 교훈). 다크모드 대비·카드 비율 육안 확인.'
checked_at: 2026-06-30T17:52
- advisor: implementation
decision: call
rationale: '결정 후 범위=전체13건+카드4:3, 4개 파일(index.jsp/bibimbap.css/recruit-list.jsp/recruit-empty.jspf). 다중파일+일부 마크업변경(#4#9#10) → 파일소유권 분할 필요. 종합 산출 concrete_change 확정적.'
checked_at: 2026-06-30T18:00
---
## Summary
사용자 지적 2건(이미지#2 비율 / 이미지#3 가시성, 직전 세션 미해결)을 다축 멀티에이전트 평가로 13건 결함으로 확장 → 카드 종횡비 4:3 + 전체 13건 적용. 런타임 스모크 라이트/다크 양 화면 PASS.
## Invocations
- cavecrew-investigator: 두 화면 JSP/CSS 위치 file:line 확정
- Workflow(ui-multiaxis-eval, 6 agents): 관찰1 → 다축평가4(레이아웃비율·카드종횡비·대비가시성·타이포간격) → 종합1 → 13 fixes + 1 open_decision
- implementation-advisor: 13건+4:3 구현(3파일, recruit-empty.jspf 무편집)
- (self) verification: 정적 L1 + 런타임 L2 스모크
## Decisions
- 카드 종횡비 **4:3** (사용자 선택; 16:9/4:5유지 대안 제시)
- 적용 범위 **전체 13건** (사용자 선택)
- #4 픽셀 margin 대신 `.search-stack` 컬럼 wrapper (advisor 조정 — 버튼 텍스트 가변 대응 결정적)
- #8 공용 클래스 대신 로컬 시각스펙 일치 (index/bibimbap 토큰계열 분리 제약 + font-mono 토큰 부재)
## verified_by_me
- L1 정적: index.jsp div 18/18·recruit 6/6 균형, scriptlet 균형, U+FFFD 0, stray taglib 0(§293 적신호 없음)
- L2 런타임 스모크(PASS): bibimbap-app(mvn spring-boot:run) 재기동 → GET / + /recruit = 200 → 브라우저 육안 라이트+다크 양 화면.
- 이미지#2(비율): 검색바 풀폭 정렬(#1), 카드 4:3(#13 object-position) — 해소 확인
- 이미지#3(가시성): 필터 초기화 흰글자+보더 명확(#2, zoom 확정) — 해소 확인
- 부수: #6 정렬칩 보더·#7 정렬라벨·#8 eyebrow 통일·#9 빈상태 CTA 강등·#10 히어로폭·#11 패널패딩·푸터 비잘림 육안 확인
## needs_user_verification
- 로그인 상태 1회 스모크: 본 검증은 로그아웃 상태만. (a) 메인 '신규 게임 개시' 버튼 노출 시 #4 search-stack 우측끝선 정렬, (b) 모집글 ≥1건 시 #9 히어로 골드 CTA 유지.
- 실게임 가로 썸네일 업로드 시 4:3 cover 크롭 인상 최종 확인(더미는 폴백 로고만 노출).
## regression
- 발원 단계: 직전 리디자인 세션(cf1e7ce 등)이 두 케이스 미해결. 본 세션이 다축 재검으로 표면화+수정. 토큰 오용(.btn-ghost on-accent) = 골드버튼 토큰을 고스트에 재사용한 설계 결함 → --color-text 로 정정.
## graph_refresh
partial-stale — 내 변경(src view/css)은 include/import/route 위상 0델타 = src fresh. partial 사유는 직전 세션들의 docs/changes 노드 2건 신규(pre-existing) + 직전 graphify 미반영(index.md "재생성 요청 중"이나 source_commit 360078a 고정). 내 세션 구조 무영향 → src 재생성 불요. docs 노드 backlog 는 open_items 로 이월(다음 구조변경 시 일괄 /graphify full).
## open_items
- [pre-existing] docs/graph 재생성 backlog: 직전 세션 docs/changes 노드 미반영 + source_commit stale(360078a→cf1e7ce). 다음 구조변경 작업 시 /graphify full + index.md frontmatter/Scopes 표 갱신. (본 세션 산출 아님)
- [untracked, 타세션] .atp/work-session/{20260630-103443,105459,143405,160000,170024}/ + graphify-out/ : 직전 세션들 미커밋 잔여. 본 작업 단위 외 — 미관여.
- [stale-log] 07:35Z JasperException(/error JSTL) = 내 재기동(09:19Z) 이전 옛 라인, errer.jsp 현재 clean, /error 현재 200. 회귀 아님.
## user_signals (보강)
negative:
- quote: "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧잖아"
structural: true
note: "첫 AskUserQuestion 이 '카드 종횡비' 단일 축만 제시 → 사용자가 레이아웃 비율(검색바 정렬) 축을 직접 추가. §4.4 item5(표현·레이아웃 축 누락) 재현. '비율' 모호 어휘를 카드 썸네일로 조기 협소화한 것이 원인."
- quote: "(지시) 실측을 메인이 하지말고 서브 에이전트 다축평가로"
structural: false
note: "검증/조사를 orchestrator 가 직접 수행하려던 흐름을 사용자가 멀티에이전트로 교정. §1 직접수행 금지 원칙 부합 방향."
positive: []
---
## Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "(사용자 지시 후) 다축 멀티에이전트 평가가 13건+open_decision 으로 한 번에 수렴, 추가 재지시 없이 구현 진입 수락"
about: "관찰1→4축 병렬평가(레이아웃비율·카드종횡비·대비가시성·타이포간격)→종합1 토폴로지로 협소화된 입력을 다축으로 복원한 패턴 — 사용자 교정 후 재호출 없이 진행됨(비자명한 판단이 검증된 케이스)"
- quote_or_paraphrase: "런타임 스모크(재기동+200+육안 라이트/다크) PASS 로 직전 세션 미해결분이 표면화·해소됨"
about: "JSP 변경에 §291(런타임 스모크 의무) 교훈을 준수해 빌드 PASS 만으로 끝내지 않고 L2 육안 검증까지 수행 — 직전 세션 미해결 케이스가 이 단계에서 비로소 검증됨"
negative:
- quote_or_paraphrase: "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧잖아"
about: "'비율' 모호 어휘를 카드 종횡비 단일 축으로 조기 협소화 → 첫 AskUserQuestion 옵션이 레이아웃 비율(검색바 정렬) 축을 누락. 사용자가 직접 교정"
structural: true
- quote_or_paraphrase: "실측을 메인이 하지말고 서브에이전트 다축평가로"
about: "orchestrator 가 실측/검증을 직접 수행하려던 흐름을 사용자가 멀티에이전트 위임으로 교정 (§1 직접수행 금지 방향과 정합)"
structural: false
what_went_well:
- "사용자 교정 직후 ui-multiaxis-eval 워크플로(6 agents)로 전환해 누락된 레이아웃 비율 축을 포함한 13건 결함 + 1 open_decision 으로 구조화 — 협소화 맹점을 다축 병렬 스캔으로 구조적으로 보완"
- "JSP/CSS 변경에 §291 런타임 스모크 의무 준수: 재기동 → GET / + /recruit 200 → 라이트/다크 양화면 육안 확인까지 수행. 빌드 PASS 를 검증 완료로 오인하지 않음"
- "카드 종횡비·적용 범위를 AskUserQuestion 으로 사용자 선택에 위임하고 비자명 조정(#4 search-stack wrapper, #8 로컬 시각스펙)은 advisor 근거와 함께 결정"
what_to_improve:
- "[structural] '비율'·'가시성' 같은 모호한 시각 결함 어휘를 받았을 때, 결정축(카드 종횡비)으로 협소화한 채 AskUserQuestion 옵션을 설계해 인접 축(레이아웃 비율=검색바 정렬)을 누락. 협소화는 옵션 설계 *이전* 단계에서 일어나 §4.4 item5(표현·레이아웃 축 누락 주의) 가이드가 적용될 시점을 지나쳐 있었다 → 다축 스캔을 옵션 설계 *앞* 으로 강제하는 게이트 필요"
- "orchestrator 가 실측을 직접 하려 한 점 — 단발이나, 검증/조사 직접수행 회피 원칙을 결정 시점에 환기하는 장치 보강 여지"
memory_candidates:
- name: visual-defect-vocab-multiaxis-scan-before-narrowing
type: feedback
description: "모호한 시각 결함 어휘는 단일 결정축으로 협소화하기 전에 다축 스캔을 선행한다"
body_draft: |
## What
'비율', '가시성 떨어진다', '어색하다' 같이 진단축이 미지정된 시각 결함 어휘를 받으면,
가장 떠오르는 단일 축(예: '비율'→카드 종횡비)으로 협소화한 채 수정/AskUserQuestion 옵션을 설계하지 않는다.
먼저 다축 스캔(레이아웃 비율·요소 종횡비·대비/가시성·타이포/간격 등)을 1회 선행해
어휘가 어느 축들을 가리킬 수 있는지 후보를 펼친 뒤 옵션을 구성한다.
## Why
- 협소화는 옵션 *설계 이전* 단계에서 일어난다. 옵션을 다 만든 뒤에 "표현·레이아웃 축이 빠지지 않았나"
점검하는 §4.4 item5 식 사후 가드는 이미 협소화된 프레임 안에서 돌아 누락을 못 잡는다.
- 시각 결함은 데이터/기능 결함과 달리 사용자가 축을 명시하지 않고 인상 어휘로 표현하는 경향이 강하다.
- 세션 20260630-175023: '비율'을 카드 종횡비로 협소화 → 첫 옵션이 검색바 정렬(레이아웃 비율) 축 누락
→ 사용자가 "검색바도 혼자 짧다"로 직접 교정. 직후 다축 멀티에이전트 평가로 전환하자 누락 축 복원 + 13건 표면화.
## How to apply
1. 시각 결함 어휘 수신 시 결정축 확정 전, 최소 4축(레이아웃 비율 / 요소 종횡비 / 대비·가시성 / 타이포·간격) 스캔.
2. 스캔에서 2축 이상 후보가 잡히면 AskUserQuestion 옵션·수정 범위에 그 축들을 모두 반영.
3. 결함 표면이 화면 전체에 걸치면 orchestrator 직접 협소 수정 대신 ui-multiaxis-eval(관찰1→N축 병렬→종합1) 위임을 우선 고려.
rationale_for_saving: "재현성 있는 구조적 맹점(§4.4 item5 가 있는데도 발생). 코드/커밋에서 유도 불가한 진단 휴리스틱. frontend-design 류 작업에서 반복 가능"
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
- name: ui-multiaxis-eval-recovers-narrowing-blindspot
type: feedback
description: "화면 전반 시각 결함은 다축 멀티에이전트 평가(관찰1→N축 병렬→종합1) 위임이 협소화 맹점을 구조적으로 보완"
body_draft: |
## What
모호하거나 광범위한 시각 결함은 orchestrator 직접 협소 수정보다, 다축 멀티에이전트 평가
(관찰1 → 축별 병렬 평가 N → 종합1) 토폴로지로 위임하면 단일 시점 협소화 맹점을 구조적으로 메운다.
## Why
- 단일 에이전트(또는 orchestrator)는 입력 어휘에 시선이 쏠려 인접 축을 놓친다.
- 축을 분리해 병렬 평가하면 각 축이 독립적으로 결함을 보고 → 종합 단계에서 누락 없는 합집합 산출.
- 세션 20260630-175023: 사용자 교정 직후 ui-multiaxis-eval(6 agents) 전환 → 입력 2건이 13건 + open_decision 으로 확장,
누락됐던 레이아웃 비율 축 복원. 사용자가 추가 재지시 없이 구현 진입 수락(검증된 긍정 패턴).
## How to apply
- 시각 결함이 1요소가 아니라 화면/페이지 단위에 걸치거나 어휘가 모호하면 다축 평가 워크플로 위임.
- orchestrator 가 실측·축평가를 직접 수행하려는 충동을 게이트(§1 직접수행 회피)로 차단.
rationale_for_saving: "비자명한 토폴로지 선택이 사용자 수락으로 검증됨. 재현 가능한 작업 패턴이며 코드에서 유도 불가"
signal_source: positive
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: true
protocol_feedback:
- "[structural — 외부 ATP 번들 §4.4 item5 대상] §4.4 item5(가시성·표현·레이아웃 축은 데이터 축에 시선 쏠려 빠지기 쉬움)는 '옵션을 만든 뒤 빠진 축 점검' 형태의 사후 가드라, 어휘 협소화가 *옵션 설계 이전* 에 일어난 이번 케이스에서 적용 시점을 지나쳤다. item5 를 'AskUserQuestion 옵션 설계 전, 모호한 시각/표현 결함 어휘는 다축 스캔(레이아웃·종횡비·대비·타이포) 선행' 이라는 *선행 게이트* 로 강화 권고. 진단축이 미지정된 인상 어휘('비율/가시성/어색')를 트리거로 명시."
- "[non-structural] orchestrator 가 실측/축평가를 직접 수행하려는 흐름은 §1 직접수행 회피 원칙과 충돌. 시각 결함이 화면 단위로 걸칠 때 '직접 수정 vs 다축 위임' 판단을 advisor invocation decision log 의 명시 분기로 두는 보강 여지(이번엔 사용자 교정으로 흡수, 단발이라 structural 아님)."
applied_changes: []
```
## ended_at
2026-06-30T18:10 (KST)

View File

@ -0,0 +1,64 @@
---
schema_version: 2
sid: 20260701-083240
started_at: 2026-07-01T08:32:40
user_request: |
"[Image] 게임 카드는 다시 봐야겠는데" — 직전 세션 4:3 적용 후 게임 카드 재검토.
이미지는 더미(fallback) 상태: 4:3 다크 미디어 + 중앙 흰 로고박스.
Invocations: []
Summary: ''
Decisions: []
user_signals:
positive: []
negative: []
graph_refresh: ''
open_items: []
verified_by_me: []
needs_user_verification: []
---
# Advisor Invocation Decision Log
- advisor: requirements
decision: skip(직접)
rationale: '"다시 봐야겠는데" 열린 요청 — 단독 협소화 금지(직전 교훈). 계획게이트로 축 합의 선행.'
checked_at: 2026-07-01T08:33
- advisor: verification
decision: call(self)
rationale: 'JSP 변경 → §291 런타임 스모크. 재기동+육안 라이트/다크.'
checked_at: 2026-07-01T09:35
---
## Summary
게임 카드 재검토(사용자 축 선택: 위계·여백). 텍스트 diff 로는 예측 어렵다는 피드백 → 실제 카드 CSS·토큰으로 현재/A/B 시각 프리뷰를 앱 정적경로(/css/)에 서빙해 사용자·에이전트 공동 확인. 사용자 B 선택 → index.jsp 적용 → 런타임 스모크 PASS.
## Decisions
- 축: "카드 전반 위계·여백" (사용자 multiSelect 선택; fallback/비율/배경 미선택)
- 변경안 **B** 채택 (제목 15px·700, 좋아요 muted+숫자 골드, 여백↑) — 시각 프리뷰 비교 후 사용자 선택
- 프리뷰 검증 방식: private SendUserFile(텍스트 한계) 대신 앱 정적경로 서빙 → 새로고침 공동 확인 (사용자 제안)
## verified_by_me
- L2 런타임 스모크(PASS): bibimbap-app 재기동 → GET / 200 (좋아요 <b> 마크업 반영) → 브라우저 육안 라이트+다크.
- 카드 제목 15px·700 지배력↑, 좋아요 숫자 골드 강조, 여백↑ — zoom 확정.
- 정적 프리뷰 mojibake 발견·수정: 서버가 .html 에 charset 미부착 → <meta charset=utf-8> 추가로 해소(§299 정적파일 확장 케이스).
## needs_user_verification
- 실게임 가로 썸네일 업로드 시 4:3 + B 위계 최종 인상(더미는 폴백 로고).
- fallback 로고 "작게" 는 의도된 placeholder 신호(브랜드 로고≠게임아트, 흰박스 글레어 회피). 빈 인상 완화 원하면 별도 "fallback 표현" 축 작업(로고확대/모노그램/배경틴트) — 사용자 이번 미선택.
## graph_refresh
skip: no-scope-change — 변경=index.jsp 카드 CSS값+<b> 마크업, JSP/CSS 정적자산은 graph 스캔 외(§285). 그래프 30분 전 클린 풀 재생성(20789a2). 구조 0델타.
## user_signals
positive:
- quote: "나도 B가 괜찮아보이네"
note: "시각 프리뷰 공동 확인 후 1라운드 수락. 프리뷰-in-page 접근이 결정 마찰 낮춤."
negative:
- quote: "(프리뷰 한글 mojibake 노출)"
structural: false
note: "정적 프리뷰 .html 에 meta charset 누락 → 사용자 화면서 mojibake. 사용자가 스샷으로 표면화. 단발(§299 는 .jspf만 명시, 정적 .html 서빙은 사각). meta charset 추가로 즉시 해소."
## ended_at
2026-07-01T09:36 (KST)

View File

@ -0,0 +1,92 @@
---
schema_version: 1
sid: 20260701-093754
started_at: 2026-07-01T09:37:54+09:00
ended_at: 2026-07-01T10:15:00+09:00
user_request: |
이전 세션에서 UI작업을 계속하고있었는데 메인 화면에서 게임 목록 카드 내부 패딩이 너무 높은거 같아.
(스크린샷: 카드 body 내 크리에이터~좋아요 사이 여백이 과도해 보임)
invocations: []
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요구가 명확 (특정 카드 여백 축소), 스크린샷으로 문제 지점 확인됨'
checked_at: 2026-07-01T09:38:00+09:00
- advisor: design-advisor
decision: skip
rationale: '단일 파일 CSS 수치 조정, 마이크로 편집 스킵 기준 충족 — orchestrator 직접 수행. 다만 §5.0 계획 가시성 의무는 AskUserQuestion으로 충족'
checked_at: 2026-07-01T09:39:00+09:00
- advisor: implementation-advisor
decision: skip
rationale: '단일 파일(index.jsp) 2줄 CSS 변경, worker 분산 불필요'
checked_at: 2026-07-01T09:39:00+09:00
- advisor: verification-advisor
decision: skip (직접 수행)
rationale: '전용 테스트 스위트 없는 순수 CSS 변경 — 대신 orchestrator가 직접 taglib grep + curl 런타임 스모크 + 브라우저 fork 시각 확인 수행 (verification-strategies.md 런타임 스모크 규정 준수)'
checked_at: 2026-07-01T09:55:00+09:00
- advisor: documentation-advisor
decision: skip
rationale: '이전 커밋(b11546a)의 후속 미세 조정, 별도 ADR/설계 문서 불필요 — 커밋 메시지로 충분'
checked_at: 2026-07-01T10:10:00+09:00
- advisor: graph-refresh-checker
decision: call
rationale: '프로토콜 §9-4: 코드 변경 1줄이라도 있으면 예외 없이 실행 (atp-graphify addon 활성 상태)'
checked_at: 2026-07-01T10:12:00+09:00
- advisor: retrospective-advisor
decision: skip
rationale: '구조적 부정 시그널 발생(1차 검증 false pass)했으나 근본원인 파악 + docs-first 정정(local-dev-setup.md 함정 항목 추가)을 orchestrator가 즉시 직접 수행 완료 — 별도 회고 에이전트 호출 없이도 재현성 있는 교훈이 docs에 반영됨. 규모상 정식 회고 오버킬로 판단.'
checked_at: 2026-07-01T10:40:00+09:00
# Summary
메인 화면 게임 카드(`.card__body`, `.card__likes`) 내부 여백이 직전 커밋(b11546a)에서 과도하게 커져 답답하다는 사용자 지적. 사용자가 단순 롤백이 아닌 개선을 요청해 `/frontend-design` 스킬 원칙(의도된 여백 vs 매직넘버)을 적용, `.card__likes`를 고정 `margin-top: 0.45rem` 대신 `margin: auto 0 0`으로 전환해 카드 하단에 앵커링하고, `.card__body` padding을 0.7/0.75/0.75 → 0.65/0.75/0.7rem으로 소폭 축소.
# Invocations
- Skill(frontend-design): 카드 위계·여백 재설계 원칙 참조, "고정 margin 대신 flex auto-margin으로 하단 앵커링" 방향 도출
- Agent(fork, 브라우저 확인): http://localhost:8080/ 라이트/다크 스크린샷 확인 — 좋아요 줄 하단 앵커링 정상 동작, 어색한 여백 해소 확인
- Agent(atp-graphify:graph-refresh-checker): partial-stale 판정이지만 변경분이 JSP 인라인 CSS 수치뿐(§285 스캔 범위 외) → 재생성 불필요 결론
# Decisions
- 크리에이터~좋아요 간 여백을 고정 rem값으로 줄이는 대신 `margin-top: auto`로 전환 — 제목 1줄/2줄 케이스에 관계없이 좋아요가 항상 카드 하단에 정렬되는 구조적 개선 (사용자 요청: "롤백말고 개선을 고려해줘")
# Files changed
- `src/main/webapp/WEB-INF/views/index.jsp`
- `.card__body` padding: `0.7rem 0.75rem 0.75rem``0.65rem 0.75rem 0.7rem`
- `.card__likes` margin: `0.45rem 0 0``auto 0 0`
# verified_by_me
- L1: 해당 없음 (JSP 정적 CSS 수치, 컴파일 대상 아님) — 대신 `rg '<%@\s*taglib'` 로 taglib 오염 없음 확인
- **1차 검증 오류(false pass) 발견 및 정정**: 최초 브라우저 fork 확인은 "정상 반영"으로 보고했으나, `curl http://localhost:8080/ | grep`으로 실제 서빙 CSS를 파일과 직접 대조하니 옛 값(`padding 0.7rem 0.75rem 0.75rem`, `margin 0.45rem 0 0`)이 그대로 서빙되고 있었음 — JSP "저장 즉시 반영"이 이 환경에서 깨져 있었고 브라우저 fork는 그 stale 렌더링을 본 것. 사용자가 재캡처 스샷으로 "왜 여전히 안 줄었냐"고 질문해 발견.
- 원인: 컴파일된 `index_jsp.class` mtime(00:30) < 소스 mtime(00:44) Jasper 재컴파일 미발생. `docker compose restart app` 강제 재기동 `curl` 재대조 (`0.65rem 0.75rem 0.7rem`, `margin auto 0 0`) 정상 서빙 확인.
- 런타임 스모크(재기동 후): `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/` = 200, 서빙 CSS = 파일과 일치
- 시각 확인(재기동 후 재검증): 브라우저 fork hard reload — 크리에이터~좋아요 간 여백 타이트하게 좁혀짐 확인, 어색한 빈 공간 해소
- 로그 스캔: clean (재기동 정상, 에러 없음)
- **문서 정정**: `docs/development/local-dev-setup.md`에 "JSP 즉시반영 실패 사례" 함정 항목 추가 — 향후 JSP 수정 시 브라우저 스크린샷만 믿지 말고 `curl` 대조 선행 권고
# needs_user_verification
- 현재 DB에 더미 게임 카드 1개뿐 — 여러 카드가 그리드에 나란히 있을 때(제목 1줄 vs 2줄 혼재) 좋아요 위치가 실제로 균일하게 정렬되는지는 실사용 데이터로 재확인 필요
# graph_refresh
partial-stale (3커밋 누적) 판정이지만 이번 변경분은 JSP 인라인 CSS 수치뿐이라 §285 스캔 범위 외 → 재생성 스킵. 구조적 변경(라우트/매퍼/서비스) 발생 시 한 번에 full scope 재생성 권고.
# user_signals
positive:
- "롤백말고 개선을 고려해줘 /frontend-design 스킬 참고해서" — 단순 되돌리기보다 근본 개선을 원하는 명시적 방향성 제시, 결과에 대한 반대 신호 없음
negative:
- quote: "더미가 통과하는 이유가뭐야?"
context: "1차 검증(브라우저 fork)이 '정상 반영'으로 보고했으나 실제로는 JSP 재컴파일 미발생으로 stale 렌더링을 본 false pass였음. 사용자가 재캡처 스샷으로 여백이 그대로임을 지적하며 검증 신뢰성에 의문 제기."
structural: true
# open_items
(없음 — 변경 전량 커밋 대상)

View File

@ -0,0 +1,107 @@
# Work Session Report
schema_version: 2
## user_request
> http://localhost:8080/game/3 예시 데이터 최신 스펙에 맞춰서 변경하자.
## Summary
`/game/3` 더미 리뷰 5건(seed-dev.sql 유래, id 1~5)이 6축 리뷰 스펙(GameReviewController.AXIS_KEYS: immersion/creativity/controls/completeness/sound/visual) 도입 이전 데이터라 `game_review_axes` 행이 0개였음. 앱 불변식상 리뷰 작성 시 6축 전부 필수(parseAxes 는 하나라도 누락 시 null→400)인데 더미 데이터만 예외 상태 — `/game/3` 레이더 차트가 더미 리뷰에 대해 빈 값 렌더. `db/seed-dev.sql` 에 axis INSERT 추가 + 라이브 dev DB(game_id=3, review id 1~5) 백필로 정합화.
## Invocations
[]
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청("예시 데이터 최신 스펙에 맞춰서 변경") 자체는 모호했으나 orchestrator 직접 조사(Serena+psql)로 6축 리뷰 스펙 불일치를 구체적 근거(file:line, DB row 비교)로 확정 — AskUserQuestion 으로 해석 확인 후 requirements-advisor 생략'
checked_at: 2026-07-01T10:10:00+09:00
- advisor: research-advisor
decision: skip
rationale: 'orchestrator 가 GameReviewController.java/game-detail.jsp/DB 스키마를 직접 조회해 6축 스펙과 더미데이터 불일치를 확정 — 조사 스코프가 좁고 이미 완료됨'
checked_at: 2026-07-01T10:10:00+09:00
- advisor: design-advisor
decision: skip
rationale: '파일영향맵(db/seed-dev.sql 1개)·계약(game_review_axes 스키마)·시퀀스(seed sql 갱신→라이브 DB 백필→검증)가 이미 확정적 — 마이크로 편집 예외(SKILL §5.1) 적용'
checked_at: 2026-07-01T10:10:00+09:00
- advisor: implementation-advisor
decision: skip
rationale: '단일 파일 + 이미 확정된 axis 값 설계 — orchestrator 직접 구현 (마이크로 편집 예외)'
checked_at: 2026-07-01T10:10:00+09:00
- advisor: verification-advisor
decision: skip
rationale: '코드 변경 없음(SQL data-only) — verification-strategies.md 기준 L1(빌드) 불필요 대상. orchestrator 가 직접 L2 스모크(/game/3 재조회 + DB row 확인)로 대체, 근거는 report.md verified_by_me 섹션에 기록'
checked_at: 2026-07-01T10:10:00+09:00
## Decisions
- 6축 axis 값은 각 더미 리뷰의 기존 `rating` 값과 평균(반올림)이 일치하도록 설계 + 리뷰 본문 뉘앙스 반영(예: BGM 칭찬 리뷰는 sound 축 높게).
- `is_rating_manual` 은 기존값(false) 유지 — 이미 axes 평균으로 rating 이 결정된 것과 일관.
## verified_by_me
- L1: 해당 없음 (코드 변경 없음, SQL data-only — 빌드 영향 없음)
- L2: `docker exec -i bibimbap-db psql ... < db/seed-dev.sql` 재실행 → 멱등 확인 (dummy_reviews=6, 에러 0)
- L2: `SELECT ... game_review_axes` 조인 확인 — game_id=3 리뷰 6건 전부 axis_rows=6, avg round = 기존 rating 과 일치 (1:4.667→5, 2:4.0→4, 3:3.0→3, 4:4.667→5, 5:2.167→2)
- L2: `GET /game/3/reviews` HTTP 200 — 응답 JSON 6건 전부 `axes` 필드 6키 채워짐 확인
- L2: `GET /game/3` HTTP 200, 레이더 마크업(`game-axis-radar`) 서빙 확인
- 로그 스캔: clean (에러 없음)
## needs_user_verification
- 브라우저에서 `/game/3` 리뷰 탭 열어 레이더 차트가 더미 리뷰 5건 모두에 대해 시각적으로 채워지는지 육안 확인 (API 데이터는 확인했으나 SVG 렌더 자체는 미확인)
- **known pitfall**: `docs/development/local-dev-setup.md` "JSP 저장 즉시반영 실패" — 직전 세션(20260701-093754)에서 발견됨. 같은 JSP 렌더 경로(game-detail.jsp)라 브라우저 화면만 보고 판단하지 말고 `curl http://localhost:8080/game/3/reviews` 로 axes 값이 최신인지 먼저 대조, 불일치 시 `docker compose restart app` 후 재확인.
## open_items
(없음)
## graph_refresh
fresh → 후속 없음 (graph-refresh-checker 판정: db/seed-dev.sql 은 DDL 아닌 DML, full scope(src/+docs/) 대상 경로 밖 — 재생성 불필요)
## user_signals
- positive: ["AskUserQuestion 에서 '진행 (추천)' 1회 수락 — 조사 근거 제시 방식에 별다른 지적 없음"]
- negative: []
started_at: 2026-07-01T10:07:31+09:00
ended_at: 2026-07-01T10:16:00+09:00
## Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "AskUserQuestion '진행 (추천)' 1회 수락, 별다른 지적 없음"
about: "orchestrator 직접 조사(Serena+psql)로 모호 요청을 구체적 불일치(axis 행 0개)로 grounding 한 뒤 계획을 1회 확인받은 흐름"
negative: []
what_went_well:
- "모호한 '예시 데이터 최신 스펙 반영' 요청을 requirements-advisor 호출 대신 orchestrator 직접 조사(코드 file:line + DB row 비교)로 반증 가능한 사실을 먼저 확정한 뒤 AskUserQuestion 1회로 해석을 고정 — 재질의 없이 1라운드 수렴. §304(모호 어휘는 협소화 전 스캔 선행) 원칙과 동일 계열의 적용."
- "verified_by_me 에 psql 조인 결과(axis_rows=6, avg round 일치)와 API 응답 필드까지 구체 수치로 기록 — L1 부재를 '근거 없이 스킵'이 아니라 대체 근거로 메움."
- "needs_user_verification 에 'API 데이터는 확인했으나 SVG 렌더 자체는 미확인'을 명시적으로 남겨, 값 확인(L1/L2 수준)과 실제 화면 반영(L3) 을 섞지 않고 분리 — §221 원칙과 정합."
what_to_improve:
- "verification-strategies.md 의 '버그 범주 → L 레벨' 표에 'SQL data-only(DML, 스키마/코드 무변경)' 행이 없어, 이번 스킵 rationale('L1 해당없음 + L2로 대체')이 매 세션 즉흥 판단에 의존한다. 다음에 유사 data-only 세션이 오면 동일 논리를 처음부터 다시 정당화해야 한다."
- "직전 세션(20260701-093754, 종료 10:15:00)에서 'JSP 저장 후 Jasper 재컴파일 지연으로 브라우저가 stale 렌더링을 정상으로 오판'하는 구조적 함정이 방금 발견·docs화(local-dev-setup.md)되었는데, 이번 세션(10:16:00 종료, 4분 뒤)은 레이더 차트(JSP 렌더 대상)가 관련된 변경임에도 verified_by_me 에 그 함정에 대한 교차 확인(curl 대조 등)이나 참조가 없다. 브라우저 육안 확인을 needs_user_verification 으로 넘긴 것 자체는 적절하나, 넘길 때 '직전 세션에 발견된 JSP stale 렌더링 함정을 함께 확인하라'는 힌트가 없어 다음 세션/사용자가 같은 함정을 다시 밟을 여지가 있다."
memory_candidates:
- name: verification-data-only-sql-change-l-level
type: project
description: "SQL data-only(DML) 변경의 검증 레벨 공백을 검증 레지스트리 표에 명시"
body_draft: |
Why: 코드/스키마 변경 없이 seed/백필 SQL(DML)만 바뀌는 세션에서 L1(빌드/단위테스트)이 원천적으로 해당 없다. 이 케이스가 verification-strategies.md 의 "버그 범주 → 의무 레벨" 표에 없어 매번 orchestrator 가 즉흥적으로 "L1 스킵 + L2 대체"를 재정당화하고 있다.
How to apply: 표에 아래 행을 추가한다.
| SQL data-only 변경(DML: seed/백필, 스키마·코드 무변경) | L1 해당없음(명시) + L2(대상 API/DB 조회로 값 일치 확인) 필수 |
추가로 "레이더 차트/뷰 등 JSP 렌더 대상에 걸치는 data 변경은 L2(API/DB 값 확인)만으로 끝내지 말고, JSP stale 렌더링 함정(local-dev-setup.md)을 needs_user_verification 에 교차 언급"하는 문구를 각주로 첨부.
rationale_for_saving: "재현 가능한 패턴(향후 seed/백필류 세션마다 반복 발생) + 기존 표에서 유도 불가(현재 표에 해당 행 없음) + 기존 memory/문서와 중복 아님."
signal_source: observation
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: false
- name: needs-user-verification-cross-reference-known-pitfall
type: project
description: "needs_user_verification 항목이 최근 세션에서 발견된 구조적 함정(예: JSP stale 렌더링)과 겹치면 그 함정을 명시적으로 인용"
body_draft: |
Why: 직전 세션(20260701-093754)에서 'JSP 저장 후 Jasper 재컴파일 지연 → 브라우저 fork 가 stale 렌더링을 정상으로 오판'하는 구조적 negative 시그널이 발견되어 local-dev-setup.md 에 즉시 반영됐다. 4분 뒤 종료된 후속 세션이 같은 렌더 대상(JSP 레이더 차트)에 대해 브라우저 육안 확인을 needs_user_verification 으로 이월하면서도 그 함정을 인용하지 않아, 다음 세션/사용자가 curl 대조 없이 브라우저만 보고 재차 false pass 를 낼 위험이 남았다.
How to apply: needs_user_verification 작성 시, 최근 N세션 이내(work-session 디렉토리 최신 3~5개) docs 반영된 구조적 함정 중 이번 변경 대상과 렌더링 경로가 겹치는 것이 있으면 "known pitfall: <문서 링크>, curl 대조 선행 권고" 를 항목에 병기한다.
rationale_for_saving: "재발 가능(JSP 프로젝트 특성상 반복) + 기존 §217(결정 분기 구조화) 원칙의 자연스러운 확장이나 명문화는 안 돼있음."
signal_source: observation
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/verification-strategies.md
memory_optional: false
protocol_feedback:
- "verification-advisor skip 근거로 'SQL data-only, L1 불필요' 를 orchestrator 가 매 세션 재판단하는 대신, verification-strategies.md 표에 해당 범주를 등재하면 판단 비용과 일관성 문제가 해소된다 (memory_candidates 항목 1과 동일 제안, structural 아님 — 단순 표 공백)."
applied_changes: []
```

View File

@ -0,0 +1,187 @@
# Work Session Report
schema_version: 2
sid: 20260701-102212
user_request: >
/frontend-design 리뷰 종합 항목 그래프(막대 미터 + 육각 레이더)에서
각 그래프 끝이 어떤 점수인지 직관적으로 알 수 없음 — 가시성 확보 전략 필요.
대상: src/main/webapp/WEB-INF/views/game-detail.jsp (game-reviews__axis-meter,
buildRadarLegend, 라인 ~1097-1119, ~1704-1726)
Invocations: []
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청 명확 — 대상 UI(6축 미터바) 특정, 문제(끝점 스케일 무맥락) 명확'
checked_at: 2026-07-01T10:22:12+09:00
- advisor: research-advisor
decision: skip
rationale: '대상 파일/구조 이미 grep 으로 확정 — 외부 자료 불요'
checked_at: 2026-07-01T10:22:12+09:00
- advisor: design-advisor
decision: skip
rationale: '단일 이산 결정축(시각 전략 선택) — AskUserQuestion 으로 orchestrator 직접 제시, 마이크로 스코프(1파일 CSS/JS)'
checked_at: 2026-07-01T10:22:12+09:00
- advisor: implementation-advisor
decision: skip
rationale: '영향 파일 1개(game-detail.jsp), 변경 범위 CSS 룰 + JS 함수 1개 확정적 — orchestrator 직접 구현'
checked_at: 2026-07-01T10:22:12+09:00
## Summary
사용자 지적: 리뷰 종합 6축 미터바(막대) 끝점이 어느 점수인지 눈금/기준선이 없어 직관적으로 안 보임.
AskUserQuestion 으로 시각전략 4안 제시 → "눈금선 추가" 선택.
game-detail.jsp `.game-reviews__axis-meter``::before` overlay(repeating-linear-gradient, mix-blend-mode: overlay) 추가해 1~5 등분 눈금(20/40/60/80/100%) 표시. JS/마크업 변경 없음, CSS 1블록.
## Invocations
- (advisor 전량 skip — decision log 참조) orchestrator 직접: 코드탐색(grep) → AskUserQuestion(시각전략 4안) → CSS 구현 → JSP 즉시반영 stale 확인(curl 대조, known-pitfall 재현) → docker compose restart app → curl 200 + 재대조 일치 확인 → 브라우저 라이트/다크 테마 각각 zoom 스크린샷으로 눈금-점수 정합 확인(조작성=3 → 3번째 눈금에서 정확히 끝남).
## Decisions
- 시각전략: 4안(눈금선/라벨이동/색상코드/눈금+만점강조) 중 "눈금선 추가"를 사용자가 선택 — CSS 배경 오버레이만으로 구현 가능해 스코프 최소.
- advisor 전량 skip: 영향 파일 1개, 변경 CSS 1블록, 결정축 단일 이산 → §5.0 은 AskUserQuestion 으로 충족(ExitPlanMode 불필요), 구현은 마이크로 스코프로 orchestrator 직접.
## verified_by_me
- L1: 해당없음 (Java 변경 0 — 컴파일/단위테스트 대상 아님)
- L2: 해당없음 (외부 의존 없음, 순수 CSS)
- JSP 런타임 스모크 (verification-strategies.md JSP 사전컴파일 미설정 함정 규약 준수): curl 로 `/game/3` 서빙 CSS 대조 → 최초 stale 확인(known-pitfall 재현) → `docker compose restart app` → 재대조 일치 + `http_code=200` 확인
- 브라우저 실제 렌더링: 라이트/다크 테마 각각 zoom 스크린샷으로 눈금-점수 시각 정합 확인
- 로그 스캔: clean (앱 재기동 로그 오류 없음, 컨테이너 정상 Started)
## needs_user_verification
(없음)
## open_items
(없음)
## graph_refresh
fresh (graph-refresh-checker 판정) — CSS-only 변경, 구조적 시그널 0, index.md §285 스캔범위 외 확인. 재생성 불필요.
## Summary (추가 — 육각 레이더 가시성)
사용자 정정: 앞선 미터바 수정과 별개로 원 지적은 "육각 레이더" 자체였음.
추가 지적: 육각형만으론 어떤 축인지도 식별 불가.
AskUserQuestion 2회 (1회는 한글 인코딩 손상으로 재질문) 후 "축 이름 + 점수 둘 다 표시" 선택.
## Decisions (추가)
- buildHexRadar(scores, cx, cy, R, withLabels) 5번째 파라미터 추가 — summary 레이더만 true, 리뷰카드 소형 레이더는 기존대로 라벨 없음(caption 밀도 문제로 스코프 제외, 사용자 확인 없이 orchestrator 판단 — 필요시 후속 피드백으로).
- 동심 그리드 4단 → 5단 전환(1/5..5/5) — 0~5점 스케일과 정수 격자 1:1 대응 (미터바 눈금 판단과 동일 원리).
- 각 축 끝에 축 이름(AXIS_LABELS_KO) SVG text 배치, 각 데이터 꼭지점 옆에 점수 SVG text 배치.
- 최초 구현 시 우측 라벨(창의성/조작성/사운드)이 우측 dl 범례와 겹침 발견(zoom 스크린샷) → 원인: buildRadarLegend가 radar wrapper div 내부(svg 옆)에 append되는 구조라 panel gap이 아닌 `.game-reviews__radar` 자체 gap 부재가 원인. `.game-reviews__radar { gap: 1.75rem }` 추가로 해결(sr-only 카드 레이더는 position:absolute라 영향 없음 확인). 라벨 배치 ratio도 1.22→1.14로 낮춰 상/하단 라벨 패널 경계 잘림 해소.
## verified_by_me (추가)
- 라이트/다크 테마 각각 zoom 스크린샷 2라운드(1차 겹침 발견 → 원인 수정 → 2차 재확인 겹침 없음, 라벨 6개 전부 판독 가능)
- 리뷰카드(소형) 레이더 find 로 6개 확인 — 라벨 미부착 유지, 레이아웃 무변경 회귀 없음
- JSP 런타임 스모크: 매 수정 후 docker compose restart app → curl 200 + grep 대조
## graph_refresh (최종)
partial-stale (2차 판정, a6417dd 기준) — game-detail.jsp buildHexRadar 시그니처 변경.
재생성 시도: fork 로 위임했으나 graphify SKILL Step 3B(서브에이전트 필수 병렬 디스패치) 요구와
fork 하드룰(Agent 재귀 호출 금지)이 정면 충돌 — 248k 토큰 소모 후 무산.
사용자 확인 후 이번 세션은 재생성 보류, open_items 로 이관.
## open_items
- graphify full scope partial-stale 상태 유지 (source_commit 은 여전히 20789a2, HEAD 는 a6417dd).
다음 구조적 변경(신규 함수/모듈/스키마 등) 배치 시 orchestrator 메인 스레드에서 직접
/graphify 실행(서브에이전트 다수 병렬 Agent 호출) 하거나 Workflow 툴(사용자 명시 opt-in)로
처리 — fork 위임은 Agent 재귀 금지 룰 때문에 불가함을 확인(교훈).
## Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "눈금선 추가 방식이 좋다"
about: 미터바 시각전략 AskUserQuestion(4안, 추천 표시 + preview mockup) 1라운드 즉시 선택
- quote_or_paraphrase: "축 이름 + 점수 둘 다 표시해줘"
about: 레이더 라벨 전략 AskUserQuestion 1라운드 즉시 선택 (한글 인코딩 재질문 이후)
- quote_or_paraphrase: (암묵) 재호출 없이 완료 승인
about: JSP 즉시반영 known-pitfall 을 curl 대조 → stale 확인 → docker restart → 재대조로 스스로 재현·검증
negative:
- quote_or_paraphrase: "한글이 죄다깨져서 뭐라하는지 모르겟어 다시 질문해봐"
about: AskUserQuestion 호출 시 한글 텍스트를 \u 유니코드 이스케이프로 수동 작성하다 오타로 2회 깨짐
structural: true
- quote_or_paraphrase: "미터바도 좋은데 내가말한건 육각 그래프였어"
about: 최초 요청의 "각 그래프"(복수)를 미터바 단수로 좁혀 해석 → AskUserQuestion 옵션도 좁은 스코프로만 구성
structural: true
- quote_or_paraphrase: (사용자 확인 후 보류 승인, 그 이전 248k 토큰 무산)
about: fork 에이전트에게 graphify 서브에이전트 병렬 디스패치(Agent 재귀 필요) 위임 시도 — fork 하드룰과 충돌
structural: true
what_went_well:
- AskUserQuestion 옵션 설계(추천 표시 + preview mockup)가 시각전략 결정에서 1라운드 즉시 수락으로 이어짐 (미터바·레이더 양쪽 모두)
- JSP 즉시반영 안 되는 known-pitfall 을 false pass 없이 curl 대조 → stale 확인 → docker restart → 재대조 순서로 자가 재현·검증
- 영향 파일 1개·결정축 단일 이산 스코프를 정확히 판정해 advisor 전량 skip 후 orchestrator 직접 처리 — 오버엔지니어링 없이 마이크로 스코프 유지
what_to_improve:
- AskUserQuestion 등 도구 호출에 한글/비ASCII 텍스트를 넣을 때 \u 유니코드 이스케이프를 손으로 타이핑해 2회 깨짐 — 리터럴 UTF-8 문자로 작성해야 함
- 이미지+텍스트 요청에서 "각 그래프"(복수)라는 표현을 단수(미터바)로 좁혀 해석 후 AskUserQuestion 옵션도 그 좁은 스코프로만 구성 — 최초 스코프 확인 단계에서 대상 전체 목록을 먼저 명시해야 했음
- fork 서브에이전트에게 Agent 재귀가 필요한 작업(graphify 서브에이전트 병렬 디스패치)을 위임 시도 → fork 하드룰과 충돌해 248k 토큰 소모 후 무산. 위임 전 대상 작업의 툴 제약(Agent 재귀 요구 여부)을 먼저 확인해야 함
memory_candidates:
- name: askuserquestion-non-ascii-literal-not-escape
type: feedback
description: AskUserQuestion 등 도구 호출에 한글/비ASCII 텍스트를 넣을 때 \u 유니코드 이스케이프 수동 타이핑 금지 — 리터럴 UTF-8 문자로 작성
body_draft: |
Why: AskUserQuestion 호출 파라미터에 한글을 \u 이스케이프 시퀀스로 손으로 타이핑하면
오타(자릿수 누락/오기) 로 렌더링이 깨져 사용자가 옵션을 읽을 수 없게 된다. 같은 세션에서
2회 발생 — "한글이 죄다깨져서 뭐라하는지 모르겟어" 로 재질문 유발, 왕복 비용 발생.
How to apply: 도구 호출 파라미터에 비ASCII 텍스트가 필요하면 항상 리터럴 UTF-8 문자로
직접 작성한다. \u 이스케이프를 수동으로 조립하지 않는다 (인코딩은 도구/런타임이 처리할
영역이지 수기 작성 대상이 아님).
rationale_for_saving: 재현 가능한 도구 사용 패턴 결함 — 코드나 git log 로 유도 불가, 관찰로만 드러남. 프로젝트 특정이 아니라 도구 사용 습관이라 프로젝트 문서(agent-output-conventions.md, AskUserQuestion 제시문 규약 문서)와 사용자 전역 설정 양쪽에 적용 가치.
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
memory_optional: true
- name: fork-agent-recursion-precheck-before-delegation
type: feedback
description: fork 서브에이전트에게 위임 전, 대상 작업이 Agent 툴(서브에이전트 재귀 디스패치) 을 요구하는지 먼저 확인 — fork 는 Agent 재귀 금지 하드룰 적용
body_draft: |
Why: graphify 전체 파이프라인(Step 3B: 서브에이전트 필수 병렬 디스패치) 을 fork 에이전트에게
위임했으나, fork 는 Agent 재귀 호출이 금지된 하드룰 적용 대상이라 정면 충돌 — 248k 토큰을
소모한 뒤에야 무산이 확인됨. 위임 전 확인했다면 즉시 회피 가능했던 낭비.
How to apply: 어떤 작업(스킬/파이프라인)을 fork 서브에이전트에게 위임하기 전에, 그 작업이
내부적으로 Agent 툴 호출(서브에이전트 추가 디스패치)을 필수로 요구하는지 먼저 확인한다.
요구한다면 fork 위임이 불가하므로 orchestrator 메인 스레드에서 직접 실행하거나 사용자
명시 opt-in 경로(예: Workflow 툴)로 전환한다.
rationale_for_saving: 재발 가능(구조적) — fork 의 툴 제약은 프로토콜 레벨 규약이라 매 위임 시점에 재확인해야 함. 유사 위임 판단이 반복될 것으로 예상.
signal_source: negative
docs_sync_target: null
memory_optional: true
conflicts_with: null
- name: plural-target-phrase-scope-confirm-before-narrowing
type: feedback
description: 이미지+텍스트 사용자 요청에서 "각 그래프" 등 복수 대상 표현이 있으면 임의로 단수 스코프로 좁히지 말고, 확인 질문에 대상 전체 목록을 먼저 명시
body_draft: |
Why: 사용자 최초 요청("리뷰 종합 항목 그래프(막대 미터 + 육각 레이더)에서 각 그래프 끝이...")
에 두 그래프 유형이 병기돼 있었는데도, orchestrator 가 "각 그래프"를 미터바 단수로
좁혀 해석하고 AskUserQuestion 옵션도 그 좁은 스코프로만 구성함. 미터바 수정 완료 후
"미터바도 좋은데 내가말한건 육각 그래프였어" 로 정정 발생 — 작업을 두 배로 나눠 처리하게 됨.
How to apply: 사용자 요청(특히 이미지 첨부)에 복수형 대상 표현("각 ~", "그래프들", "여기저기")
이 있으면, 대상이 하나로 좁혀지는지 확신이 없는 한 확인 질문(AskUserQuestion 등)에 발견된
대상 전체 목록을 먼저 명시하고 스코프를 선택하게 한다. 임의로 대표 사례 하나만 골라
진행하지 않는다.
rationale_for_saving: 재발 가능 — 이미지+텍스트 혼합 요청에서 복수 대상 지시어는 반복될 패턴. 사용자 원 발화 재검토로만 드러나는 교훈이라 코드/커밋으로 유도 불가.
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
memory_optional: true
- name: askuserquestion-recommended-preview-mockup-effective
type: feedback
description: 시각전략 등 이산 선택 AskUserQuestion 에서 "추천 표시 + preview mockup" 병기 옵션 설계가 1라운드 즉시 수락으로 이어짐
body_draft: |
Why: 미터바·레이더 시각전략 두 차례 모두 AskUserQuestion 에 추천 옵션 표시 + 각 옵션의
시각 결과 preview(mockup) 를 병기하자 사용자가 재질문 없이 1라운드에 선택. 비자명한
선택이었으나(4안 중 택1, 시각 결과를 텍스트만으론 가늠하기 어려움) 검증된 패턴.
How to apply: UI/시각 관련 이산 선택을 사용자에게 물을 때는 옵션 설명 텍스트만으로
끝내지 않고, 가능하면 추천 표시 + 결과 미리보기(mockup/스케치) 를 함께 제시한다.
rationale_for_saving: 비자명한 판단(일반적으로 텍스트 설명만으로 충분하다 여길 수 있는 상황에서 preview 를 추가 투자)이 검증된 성공 사례 — 재현 가치 있음.
signal_source: positive
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
memory_optional: true
protocol_feedback:
- fork 에게 위임 가능한 작업의 범위를 판별할 때, "대상 스킬/파이프라인이 Agent 툴(서브에이전트 재귀 디스패치)을 필수로 요구하는가" 를 위임 전 체크리스트 항목으로 명문화할 것을 제안 (예: platform-adapters.md 또는 fork 위임 경로 문서에 "위임 전 툴 제약 프리체크" 항목 추가). 이번 세션은 사후 확인(248k 토큰 소모 후)이라 손실이 컸음.
- AskUserQuestion 등 도구 호출 파라미터에 비ASCII 텍스트를 다뤄야 하는 경우의 작성 규칙(리터럴 UTF-8, \u escape 수동 조립 금지)을 agent-output-conventions.md 류 문서에 명문화할 것을 제안 — 현재는 "제시문 풀어쓰기" 규칙만 있고 인코딩 안전성 규칙은 부재.
applied_changes: []
```
## memory_candidates 처리
docs 반영 완료(같은 커밋): docs/development/agent-output-conventions.md 에 규칙 2(비ASCII 리터럴 작성, \u 수동 이스케이프 금지), 규칙 3(복수 대상 지적 시 스코프 임의 축소 금지) 추가.
protocol_feedback(fork 위임 전 Agent 재귀 요구 여부 프리체크 명문화) 는 atp 번들 직접 수정 권한 밖 — open_items 에 이미 기록된 교훈으로 충분, 별도 반영처 없음.
memory(전역) 갱신: 보류 — 사용자 memory 활성 설정 미확인, docs 단독 마감.
ended_at: 2026-07-01T11:26:20+09:00

View File

@ -0,0 +1,81 @@
---
schema_version: 2
sid: 20260701-113509
started_at: 2026-07-01T11:35:09+09:00
ended_at: 2026-07-01T12:05:00+09:00
user_request: >
게임상세 리뷰 영역 재구성: (1) 상단 종합점수는 육각 그래프만으로 충분 —
옆에 있는 6축 미터바 범례 제거. (2) 미터바는 개별 리뷰 카드의 타이틀
노출부(닉네임/배지/시간 행 부근)로 이동. 여러 샘플을 먼저 만들어
보여주고 그중 하나를 사용자가 선택하게 할 것 (코드 반영은 아직 아님 —
1단계는 샘플 제시 + 선택 수렴).
resumed_from:
Invocations: []
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '요청이 명확함(대상 영역·의도·산출 형태 모두 사용자가 스크린샷+문장으로 명시). 오픈 퀘스천 없음.'
checked_at: 2026-07-01T11:35:09+09:00
- advisor: research-advisor
decision: skip
rationale: 'game-detail.jsp 내 buildHexRadar/buildRadarLegend/renderSummary/buildReviewItem 위치를 orchestrator가 직접 grep+Read로 확인 완료. graphify 미도입 프로젝트(no-graphify) — lookup 생략.'
checked_at: 2026-07-01T11:35:09+09:00
- advisor: design-advisor
decision: skip
rationale: '1단계 산출물이 확정 설계가 아니라 사용자 선택을 위한 시각 샘플(Artifact)이므로 파일영향맵/계약을 먼저 고정하는 design-advisor 단계가 부적합. frontend-design 스킬 가이드로 orchestrator가 직접 샘플 HTML 구성 — 사용자가 명시적으로 /frontend-design:frontend-design 을 인자로 지정.'
checked_at: 2026-07-01T11:35:09+09:00
- advisor: implementation-advisor
decision: skip
rationale: '1단계는 실제 소스(game-detail.jsp) 변경 없음 — Artifact 샘플 제시만. 사용자가 안을 고르면 후속 세션/턴에서 implementation-advisor 재판단.'
checked_at: 2026-07-01T11:35:09+09:00
- advisor: verification-advisor
decision: skip
rationale: '코드 변경 0건(샘플은 scratchpad HTML, 소스 트리 밖) — L1/L2 검증 대상 없음.'
checked_at: 2026-07-01T11:35:09+09:00
# Summary
game-detail.jsp 의 리뷰 요약 패널(`buildHexRadar`+`buildRadarLegend`, L2318-2332)과 리뷰 카드(`buildReviewItem`, L2203-2301, 카드 미터바는 현재 `sr-only`)를 확인. 요청 반영 방향(요약=그래프only, 카드=미터바 노출)을 검증하기 위한 시각 샘플 HTML을 Artifact 로 발행:
https://claude.ai/code/artifact/fa684bfc-8c46-442c-adca-a8c83032eb85
- Section 1: 요약 패널 현재(그래프+범례) vs 제안(그래프만) 비교
- Section 2: 카드 제목행 미터바 배치 4안 — A 인라인 축약형 / B 2열 그리드형 / C 타이틀 내부 통합형 / D 토글 상세형
프로젝트 실제 다크테마 토큰(--surface/--accent 등, game-detail.jsp L31-54)을 그대로 이식해 실물감 있게 재현.
**2단계(구현, 사용자 확정 후)**: 사용자가 "C. 타이틀 내부 통합형" + "요약 그래프-only 확정"을 선택. game-detail.jsp 실제 반영 완료(커밋 0c8da40):
- `renderSummary()`: 요약 라디오 뒤 `buildRadarLegend` 호출 제거, `.game-reviews__summary-panel``justify-content: center` 추가
- `buildReviewItem()`: 컴팩트 육각 아이콘(`cardRadar`/`radarWrap`) 블록 삭제, 신설 `buildTitleMeter(r.axes)` 를 닉네임/배지 뒤·시간 앞에 삽입 — axes 데이터 없으면 `null` 반환해 아무것도 렌더하지 않음
- `buildRadarLegend` 함수 및 관련 CSS(`.game-reviews__axis-legend*`, `.game-reviews__axis-meter*`, `.game-reviews__card-radar*`, `game-meter-grow` 키프레임) 완전 삭제 — 호출부가 모두 사라져 죽은 코드였음
- 신규 CSS `.game-reviews__title-meter`(+ `.stick`) 및 `game-title-meter-grow` 키프레임(세로 성장 애니메이션, `prefers-reduced-motion` 대응) 추가
- 접근성: `buildTitleMeter` 컨테이너에 `role="img"` + `aria-label="6축 평가: ..."` 로 시각+스크린리더 동시 충족(§6.6, 기존 sr-only 표 대체)
# Decisions
- 코드 변경 없이 먼저 샘플 제시 → 사용자 선택 수렴 (요청 원문 "여러 샘플 구성해서 볼 수 있게" 를 그대로 1단계 산출물 정의로 채택)
- 상단 요약은 단일안(그래프만)만 제시 — 축 이름·점수가 이미 그래프 라벨로 표시되어(직전 커밋 a6417dd) 대안 여지가 낮다고 판단, 대신 before/after 비교로 근거 명시
- 카드 배치는 4안 제시 — "타이틀 노출부" 라는 표현의 해석 폭이 넓어(제목행 자체 vs 제목행 바로 아래) 대표 패턴을 넓게 커버
# Invocations
(orchestrator 직접 수행 — advisor 호출 없음)
# verified_by_me
- L1: skip — 이번 변경은 Java/매퍼/컨트롤러 미변경(순수 JSP 뷰 + 인라인 JS/CSS), 프로젝트에 JS 단위테스트 하네스 없음(verification-strategies.md 범주표에 해당 항목 없음)
- 대체 검증: `node --check`(JSP EL `${...}` 치환 후) 로 `<script>` 블록 구문 오류 없음 확인
- 수동 런타임 스모크: `docker compose restart app``http://localhost:8080/game/3`(리뷰 6건 보유 더미 게임) 실브라우저 렌더 확인 — 요약 패널 그래프 단독 중앙 배치, 리뷰 카드 6건 모두 제목행에 6축 미니 미터바 노출, 콘솔 에러 0건(read_console_messages)
- L2: 해당없음(외부 서비스/DB 계약 변경 없음)
# open_items
(없음) — 소스 변경 커밋 완료(0c8da40), 잔여 미커밋 변경은 본 report.md 자체(work-session 산출물)뿐이며 별도 chore 커밋 예정
# needs_user_verification
(없음) — 라이트/다크 테마 모두 `/game/3` 실브라우저로 대비·레이아웃 확인 완료
# graph_refresh
skip: no-graphify
# retrospective
skip: 1개 파일(game-detail.jsp) 범위의 유이(有二) 결정(샘플 4안 중 1택 + 요약 그래프-only)로 끝난 소규모 세션 — 마이크로 편집 예외 경로(advisor 전체 스킵)를 그대로 이어 retrospective-advisor 호출도 생략. 유효 발견 없으면 docs 반영 대상 없음.
user_signals:
positive:
- "AskUserQuestion 1회로 C안 + 요약 그래프-only 확정 즉시 수락 — 재작업 요청 없음"
negative: []

View File

@ -0,0 +1,118 @@
---
schema_version: 2
sid: 20260701-142000
started_at: 2026-07-01T14:20:00+09:00
ended_at: 2026-07-01T14:45:00+09:00
branch: feat/v2
user_request: |
/frontend-design:frontend-design http://localhost:8080/game/new 게임 신규 등록 구간도 리디자인
대상자였어야 했는데 누락되었어. 다음에 누락 안되게끔 문서에 추가해두고 여기 디자인 들어가자.
---
# Summary
`/game/new`(game-register.jsp)가 2026-06-30 9단위 리디자인 목록에서 빠져 있던 것을 발견·보강.
실제 로그인 스샷 대조 결과 레이아웃/토큰은 이미 sibling(recruit-form.jsp 등)과 일치했음 —
필수(*) 표시 2건 + 미리보기 eyebrow 라벨만 recruit-form.jsp 패턴 재사용으로 추가.
재발방지로 `frontend-redesign-coverage-checklist.md` 신설 + `workflow-patterns.md` 등재.
graph-refresh-checker partial-stale 판정 → full scope 증분 재생성 완료.
# Invocations
- [research 없음 — orchestrator 직접 grep/Read] docs/changes, docs/analysis 확인해 9단위 리디자인 대상 목록과 game-register.jsp 부재 확인
- [orchestrator 직접] claude-in-chrome 으로 로그인 후 `/game/new`, `/recruit/new`, `/` 실사용 스샷 대조
- [AskUserQuestion ×2] 리디자인 범위(A vs B) → B 선택 → 전제 오류 발견 후 재확인 → A안으로 확정
- [orchestrator 직접] game-register.jsp 마크업/CSS 2건 추가 (.game-req, .game-preview__label)
- [atp-graphify:graph-refresh-checker] 커밋 86b0528 이후 staleness 판정 → partial-stale
- [general-purpose (graphify semantic extraction)] 11개 변경 파일 재추출
# Decisions
- 로컬 CSS 토큰(`--surface/--accent/--text` 등)은 bibimbap.css(`--color-*`)와 네이밍이 달라
대체 불가함을 확인 → 이 파일만 단독 전환하면 sibling 관례와 갈라지므로 범위 제외, 별도 과제로 이월.
- 필수(*) 표시·미리보기 라벨은 `recruit-form.jsp``.rf-req`/`.rf-preview__label` 패턴을
클래스명만 바꿔 재사용 — 새 디자인 언어 도입 없음.
- WebGL zip 필수(*) 표시는 `editMode`가 아닐 때만 노출(기존 required 로직과 일치).
# user_signals
positive:
- "A안만 진행 (권장)" 선택 — 두 라운드 모두 재질의 없이 1라운드 수렴.
negative:
- structural: true
signal: "(명시적 지적 발화는 없었으나) orchestrator가 B안 프레이밍 시 bibimbap.css 변수 네이밍을
실제로 grep 확인하지 않고 '공유 CSS로 전환 가능'을 전제로 옵션을 제시 → 사용자가 B안을 고른 뒤
실제 구현 단계에서 grep 검증 결과 전제가 틀렸음을 발견, 재질의로 정정해야 했음. 재발방지: 옵션을
제시하기 전에 grep/Read로 전제 사실을 먼저 확인한다."
# verified_by_me
- L1: `docker compose exec app mvn -o test` — 362/362 PASS
- L1: `rg '<%@ taglib'` game-register.jsp — 0건(stray taglib 없음)
- L2: 로그인(admin@bibimbap.local) 후 `/game/new` 라이트/다크 육안 확인 — 필수(*) 2건, 미리보기 라벨 렌더 확인
- known pitfall 회피: JSP 저장 즉시반영 stale 렌더링 함정 — `docker compose restart app` 후 재대조
# needs_user_verification
- WebGL zip 실제 업로드 → 등록 제출 전체 플로우 회귀 확인 (마크업/CSS만 변경이라 로직 영향 없음으로 판단하되 실제 제출 미실행)
# 추가 검증 (사용자 요청, 세션 종료 후속)
`editMode=true` 경로 확인 완료. admin(user_id=9) 소유 게임이 dev 시드에 없어(0개) DB에 임시 row를 직접 INSERT(`is_visible=false`) → `/game/8/edit` 방문 육안 확인 → 즉시 DELETE로 원복(잔존 0건 확인). 결과: "게임 이름 *" 유지, "WebGL zip"은 `*` 미표시 + "기존 WebGL 유지" 문구 — 코드 로직과 일치.
# graph_refresh
partial-stale → full scope 증분 재생성 완료(1477 노드 / 4503 엣지 / 76 커뮤니티, 고스트 중복 486 exact+251 fuzzy 추가 제거). `docs/graph/index.md` 갱신 커밋 `4d0c2ca`.
# open_items
(없음 — git status clean)
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: "A안만 진행 (권장)"
about: AskUserQuestion 두 라운드 모두 재질의 없이 1라운드 수렴 — 재확인 이후 재구성된 옵션 제시가 자기완결적이었음
negative:
- quote_or_paraphrase: "(명시적 발화 없음 — orchestrator 자가 포착) B안 프레이밍 시 bibimbap.css 변수 네이밍 전제를 grep 없이 '공유 CSS로 전환 가능'으로 제시했다가, 구현 단계 grep 검증에서 전제가 틀렸음을 발견해 재확인 라운드가 필요했음"
about: "AskUserQuestion 옵션(B안) 구성 시 핵심 전제(CSS 변수 네이밍 대체 가능성)를 사실 확인 없이 서술"
structural: true
what_went_well:
- "리디자인 대상 목록과 실제 파일 전수를 대조해 game-register.jsp 누락을 찾아낸 뒤, 재발방지용 커버리지 체크리스트(frontend-redesign-coverage-checklist.md)를 신설하고 workflow-patterns.md에 즉시 등재 — 이번 세션 안에서 교훈이 문서화까지 닫힘."
- "재확인 후 A안으로 좁힌 다음에는 옵션이 자기완결적이어서 1라운드 수렴(재질의 0) — agent-output-conventions.md 규칙 1(제시문 풀어쓰기)이 실제로 작동한 사례."
- "recruit-form.jsp 패턴을 클래스명만 바꿔 재사용해 새 디자인 언어를 도입하지 않은 보수적 선택 — sibling 관례 유지 우선."
what_to_improve:
- "AskUserQuestion 옵션(특히 '기존 인프라/토큰으로 대체 가능' 류 기술적 전제를 포함하는 옵션)을 구성하기 전에, 그 전제를 뒷받침하는 사실을 grep/Read로 먼저 확인한다. 이번 세션은 전제가 틀린 옵션(B안)을 사용자가 고른 뒤에야 구현 단계에서 오류가 드러나 재확인 왕복이 발생했다 — 결과적으로 A안으로 정정되었으나, 발견이 늦었던 것은 순전히 운(구현 착수 전 grep을 돌린 시점)이었다. 옵션에 전제가 실려 있다면 제시 전 검증이 필수다."
memory_candidates:
- name: askuserquestion-premise-verify-before-present
type: feedback
description: "AskUserQuestion 옵션에 기술적 전제(예: 기존 리소스로 대체 가능)가 실려 있으면, 옵션을 제시하기 전에 grep/Read로 그 전제를 먼저 확인한다."
body_draft: |
Why: AskUserQuestion 옵션 문구가 "A는 이렇게, B는 공유 리소스로 전환 가능"처럼 사실 주장을 포함할 때,
그 주장을 검증 없이 제시하면 사용자는 검증된 사실로 오인하고 선택한다. 사용자가 그 옵션을 고른 뒤
구현 착수 시점에야 grep 등으로 전제 오류가 발견되면, 이미 사용자 의사결정이 한 번 소비된 뒤라
재확인 왕복(선택 무효화 → 재질의 → 재선택)이 발생한다. 결정 제시문의 신뢰도는 문구의 매끄러움이
아니라 그 안의 사실 주장의 검증 여부로 결정된다.
How to apply:
1. 옵션 문구를 작성하기 전, 그 문구가 의존하는 사실 주장(공유 가능성, 네이밍 일치, 기존 구현 존재 등)을 나열한다.
2. 나열된 주장 각각을 grep/Read로 실제 확인한다 — "~일 것 같다"로 제시하지 않는다.
3. 확인이 안 되거나 시간이 없으면, 옵션 문구에서 그 주장을 빼거나 "미확인, 구현 착수 시 재검증 필요"로 명시한다.
4. 이미 틀린 전제로 옵션을 제시해 선택까지 받은 경우, 구현 착수 전 첫 검증 단계에서 걸러지도록
옵션 확정 직후 "선택 근거가 된 전제 재검증"을 착수 first-step으로 둔다(이번 세션처럼 늦게라도
구현 착수 전에 걸러지면 피해가 제한된다).
rationale_for_saving: >
agent-output-conventions.md 규칙 1(제시문 풀어쓰기)은 "어떻게 보여주는가"를 다루지만, 이번 사례는
"보여주기 전에 사실을 확인했는가"라는 선행 단계의 결함이라 규칙 1과는 구별되는 별개 규칙이다.
재현 가능(다른 프로젝트/CSS 리팩터링·API 계약 대체 가능성 등 "전환 가능" 류 전제를 포함하는
모든 AskUserQuestion 상황에 적용됨)하고 기존 문서에 없는 관찰이라 후보로 남긴다.
signal_source: negative
docs_sync_target: /Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-output-conventions.md
memory_optional: true
protocol_feedback:
- "agent-output-conventions.md 규칙 1(제시문 풀어쓰기)은 옵션의 '표현 형식'만 규율한다. 이번 사례처럼 옵션에 실린 '사실 주장'의 사전 검증 의무는 별도 규칙(예: 규칙 4)으로 명문화할 필요가 있다 — 제시문이 아무리 풀어써졌어도(규칙 1 충족) 그 안의 사실이 틀렸으면 재질의 왕복은 똑같이 발생한다. 두 결함은 독립적이므로 문서에서도 별도 항목으로 분리하는 게 재발방지에 유리하다."
applied_changes: []
```

3
.gitignore vendored
View File

@ -42,3 +42,6 @@ certs/*.pem
### Test static resources ###
src/main/resources/static/
# graphify 산출 본체(재생성 가능) — 메타는 docs/graph/index.md 만 커밋
graphify-out/

View File

@ -8,6 +8,7 @@
- 화면은 `/WEB-INF/views/*.jsp`를 사용한다.
- DB 접근은 annotation 기반 MyBatis mapper를 사용한다.
- 업로드된 프로필 이미지는 `/profile/**`, 게임 WebGL asset은 `/game/{gameUuid}/**`로 제공된다.
- 정적 CSS/JS 파일은 `src/main/webapp/css/`, `src/main/webapp/js/`에 두면 Tomcat DefaultServlet이 직접 서빙한다. `src/main/resources/static/`은 WAR에서 불필요하다.
## 작업 원칙
@ -18,6 +19,7 @@
- 문서-only 분석 요청에서는 `src/``pom.xml`을 수정하지 않는다.
- 보안 발견 사항은 `docs/analysis/``file:line` 근거와 함께 기록한다.
- 보안 개선 작업은 `docs/security/security-remediation-checklist.md`의 완료 조건을 기준으로 분리한다.
- 방법 선택 시 빠름보다 확실성을 우선한다. 검증되지 않은 지름길은 피한다.
## 보안 원칙

View File

@ -2,8 +2,10 @@
-- bibimbap dev seed — 리뷰/댓글(W3-2) 기능 테스트용 더미 상황
-- =============================================================================
-- 목적: 새로 추가된 게임 리뷰/댓글 기능을 실제로 테스트할 수 있는 더미 데이터.
-- 1) 로그인 가능한 테스트 계정 1개 (직접 로그인 → 리뷰/댓글 작성 테스트용)
-- 2) 표시되는 더미 게임 1개 (테스트 계정 소유)
-- 1) 로그인 가능한 테스트 계정 2개
-- - tester@bibimbap.local (role=USER — 일반 사용자 기능 테스트용)
-- - admin@bibimbap.local (role=ADMIN — 관리자 기능 테스트용)
-- 2) 표시되는 더미 게임 1개 (tester 계정 소유)
-- 3) 이미 등록된 더미 리뷰 5개 (각자 다른 더미 유저 — game당 user 1리뷰 제약 때문)
-- + 보너스: 더미 댓글 3개
--
@ -14,7 +16,9 @@
-- 적용 (호스트 psql, 포트 5433):
-- PGPASSWORD=change_me_local_dev psql -h localhost -p 5433 -U bibimbap -d bibimbap -f db/seed-dev.sql
--
-- 로그인 자격: tester@bibimbap.local / test1234! (provider='email')
-- 로그인 자격:
-- tester@bibimbap.local / test1234! (role=USER, provider='email')
-- admin@bibimbap.local / test1234! (role=ADMIN, provider='email')
-- password_hash 는 UserController.hashPassword 와 동일 알고리즘으로 사전 생성:
-- PBKDF2WithHmacSHA256 / 210000 iter / 256-bit key / 16-byte salt,
-- 포맷 = pbkdf2_sha256$<iter>$<base64 salt>$<base64 hash>.
@ -27,14 +31,16 @@ SET search_path TO dev;
DO $$
DECLARE
v_tester_id bigint;
v_admin_id bigint;
v_game_id bigint;
v_uid bigint;
v_review_id bigint;
-- test1234! 의 사전 생성 해시 (위 알고리즘과 동일).
c_pw_hash text := 'pbkdf2_sha256$210000$QJ5MV+dx3aECELuuh3ibIA==$/59CO6JWXXGXWW0hzqpnZwu89qirDozP0jlq3JzEwZw=';
r RECORD;
BEGIN
-- -------------------------------------------------------------------------
-- 1) 로그인 테스트 계정 (users + email identity)
-- 1a) 일반 사용자 테스트 계정 (role=USER)
-- -------------------------------------------------------------------------
SELECT id INTO v_tester_id
FROM users
@ -58,6 +64,35 @@ BEGIN
(v_tester_id, 'email', 'tester@bibimbap.local', 'tester@bibimbap.local', c_pw_hash, '테스터');
END IF;
-- -------------------------------------------------------------------------
-- 1b) 관리자 테스트 계정 (role=ADMIN)
-- -------------------------------------------------------------------------
SELECT id INTO v_admin_id
FROM users
WHERE canonical_email = 'admin@bibimbap.local' AND is_delete IS NOT TRUE
LIMIT 1;
IF v_admin_id IS NULL THEN
INSERT INTO users (display_name, canonical_email, role, status)
VALUES ('어드민', 'admin@bibimbap.local', 'ADMIN', 'ACTIVE')
RETURNING id INTO v_admin_id;
ELSE
-- 기존 계정 role 이 ADMIN 이 아닌 경우 보정 (재실행 안전)
UPDATE users SET role = 'ADMIN', permissions_epoch = permissions_epoch + 1
WHERE id = v_admin_id AND role <> 'ADMIN';
END IF;
IF NOT EXISTS (
SELECT 1 FROM user_auth_identities
WHERE provider = 'email' AND provider_user_id = 'admin@bibimbap.local'
AND is_delete IS NOT TRUE
) THEN
INSERT INTO user_auth_identities
(user_id, provider, provider_user_id, email, password_hash, display_name)
VALUES
(v_admin_id, 'email', 'admin@bibimbap.local', 'admin@bibimbap.local', c_pw_hash, '어드민');
END IF;
-- -------------------------------------------------------------------------
-- 2) 더미 게임 (테스트 계정 소유, 목록/상세에 표시되도록 visible)
-- webgl_path / thumbnail_url 은 NULL — game-detail.jsp 가 빈 src 로 안전 처리.
@ -85,12 +120,13 @@ BEGIN
-- -------------------------------------------------------------------------
FOR r IN
SELECT * FROM (VALUES
('김플레이', 'reviewer1@bibimbap.local', 5, '그래픽이 깔끔하고 조작감이 좋아요. 가볍게 즐기기 딱 좋습니다. 추천!', 5),
('이도전', 'reviewer2@bibimbap.local', 4, '아이디어가 신선했습니다. 난이도 밸런스만 조금 더 다듬으면 완벽할 듯해요.', 4),
('박캐주얼', 'reviewer3@bibimbap.local', 3, '무난하게 즐길 만한 게임. 다만 후반부가 살짝 반복적으로 느껴졌어요.', 3),
('최열정', 'reviewer4@bibimbap.local', 5, '시간 가는 줄 모르고 플레이했네요. BGM이 특히 인상적이었습니다!', 2),
('정라이트', 'reviewer5@bibimbap.local', 2, '초반 튜토리얼이 불친절해서 적응이 조금 어려웠어요. 보완되면 좋겠습니다.', 1)
) AS t(nickname, email, rating, body, days_ago)
-- nickname, email, rating, body, days_ago, immersion, creativity, controls, completeness, sound, visual (6축 평균 반올림 = rating)
('김플레이', 'reviewer1@bibimbap.local', 5, '그래픽이 깔끔하고 조작감이 좋아요. 가볍게 즐기기 딱 좋습니다. 추천!', 5, 5, 4, 5, 5, 4, 5),
('이도전', 'reviewer2@bibimbap.local', 4, '아이디어가 신선했습니다. 난이도 밸런스만 조금 더 다듬으면 완벽할 듯해요.', 4, 4, 5, 3, 4, 4, 4),
('박캐주얼', 'reviewer3@bibimbap.local', 3, '무난하게 즐길 만한 게임. 다만 후반부가 살짝 반복적으로 느껴졌어요.', 3, 3, 3, 2, 3, 4, 3),
('최열정', 'reviewer4@bibimbap.local', 5, '시간 가는 줄 모르고 플레이했네요. BGM이 특히 인상적이었습니다!', 2, 5, 4, 4, 5, 5, 5),
('정라이트', 'reviewer5@bibimbap.local', 2, '초반 튜토리얼이 불친절해서 적응이 조금 어려웠어요. 보완되면 좋겠습니다.', 1, 2, 3, 1, 2, 3, 2)
) AS t(nickname, email, rating, body, days_ago, immersion, creativity, controls, completeness, sound, visual)
LOOP
SELECT id INTO v_uid
FROM users
@ -103,16 +139,30 @@ BEGIN
RETURNING id INTO v_uid;
END IF;
IF NOT EXISTS (
SELECT 1 FROM game_reviews
SELECT id INTO v_review_id
FROM game_reviews
WHERE game_id = v_game_id AND user_id = v_uid AND is_delete IS NOT TRUE
) THEN
LIMIT 1;
IF v_review_id IS NULL THEN
INSERT INTO game_reviews (game_id, user_id, rating, body, created_at, updated_at)
VALUES (
v_game_id, v_uid, r.rating, r.body,
now() - make_interval(days => r.days_ago),
now() - make_interval(days => r.days_ago)
);
)
RETURNING id INTO v_review_id;
END IF;
-- 6축 리뷰 스펙(GameReviewController.AXIS_KEYS) 백필: 구버전 seed(축 없음) 재실행 시에도 채움.
IF NOT EXISTS (SELECT 1 FROM game_review_axes WHERE review_id = v_review_id) THEN
INSERT INTO game_review_axes (review_id, axis_key, score) VALUES
(v_review_id, 'immersion', r.immersion),
(v_review_id, 'creativity', r.creativity),
(v_review_id, 'controls', r.controls),
(v_review_id, 'completeness', r.completeness),
(v_review_id, 'sound', r.sound),
(v_review_id, 'visual', r.visual);
END IF;
END LOOP;
@ -146,7 +196,7 @@ BEGIN
END IF;
END LOOP;
RAISE NOTICE 'seed-dev 완료: tester_id=%, game_id=%', v_tester_id, v_game_id;
RAISE NOTICE 'seed-dev 완료: tester_id=%, admin_id=%, game_id=%', v_tester_id, v_admin_id, v_game_id;
END $$;
-- 적용 결과 확인용 요약 (psql 실행 시 출력).

View File

@ -0,0 +1,261 @@
---
kind: analysis
title: 프론트엔드 컴포넌트 중복/유지보수·확장성 검토 (2026-06-30)
description: JSP 기반 프론트엔드에서 발견된 CSS 토큰 23개 파일 중복, JS 유틸리티 함수 중복, BibimbapModal 호출 패턴 혼존, recruit-form CSRF 폴백 갭(보안), 날짜 포맷 함수 편재를 분석하고 권고 구현 순서를 기록한다.
perspective: neutral
valid_starting_point_for: 프론트엔드 중복 제거 작업 착수, CSRF 갭 수정(보안 P0), CSS/JS 공용 파일 설계
owner: art
stability: snapshot
last_reviewed: 2026-06-30
source: work-session 20260630-113258 (research-advisor frontend-analysis.md, design-advisor frontend-design-recommendations.md) 에서 distill
superseded_note: 후속 결정으로 전제가 바뀌면 이 줄을 갱신한다 — 현재 없음
---
# 프론트엔드 컴포넌트 중복/유지보수·확장성 검토 (2026-06-30)
> 조사 범위: 27개 JSP 파일 전수 직접 읽기. 모든 사실은 코드 직접 확인(`확인됨`). 미탐색 항목은 [미발견/조사 불충분](#미발견--조사-불충분-항목)에 명시.
>
> 출처: work-session `20260630-113258``research/frontend-analysis.md` (research-advisor) + `artifacts/frontend-design-recommendations.md` (design-advisor).
## 개요
JSP 기반 프론트엔드에서 5개 유형의 중복/이슈가 확인됐다. 가장 심각한 것은 라이트/다크 테마 CSS 변수 블록과 `body` 기본 스타일이 23개 파일에 동일하게 복사된 구조이며, 보안 측면에서는 `recruit-form.jsp`의 CSRF 폴백 분기에서 토큰이 전혀 포함되지 않는 갭이 존재한다.
전체 27개 JSP 중 26개에 인라인 `<style>` 블록이 존재하며, 누적 인라인 CSS는 5,969줄이다.
## 발견 이슈 요약 (우선순위 매트릭스)
| 이슈 | 영향도 | 구현 비용 | 우선순위 |
|---|---|---|---|
| CSS 토큰 23개 파일 중복 | 높음 (유지보수 23배 비용) | 낮음 (파일 추가 + include 삽입) | P0 |
| 폼 제출 CSRF 갭 (recruit-form) | 높음 (보안) | 낮음 (10줄 이내 수정) | P0 — 보안 |
| JS 공용 유틸리티 분리 | 중간 (동작 불일치 방지) | 중간 (파일 작성 + 기존 인라인 제거) | P1 |
| 모달 패턴 표준화 | 중간 (신규 페이지 기준 제공) | 중간 (패턴 A/B 대체 + admin 2곳 수정) | P1 |
| 날짜 포맷 유틸리티 모듈 | 낮음 (예방적 분리) | 낮음 (파일 추출) | P2 |
## 이슈별 상세
### CSS 디자인 토큰 23개 파일 중복 (P0)
#### 현황
전체 27개 JSP 중 26개에 인라인 `<style>` 블록이 존재한다 (`theme-init.jsp` 제외). 다음 3개 블록이 23개 파일에 동일하게 복사된다.
| 중복 패턴 | 반복 파일 수 | 대표 위치 |
|---|---|---|
| `html {}` 라이트 테마 CSS 변수 선언 (8개 색상 토큰) | 23개 | `admin-console.jsp:38`, `jam-list.jsp:21`, `login.jsp:12` |
| `html[data-theme="dark"] {}` 다크 테마 CSS 변수 선언 | 23개 | `admin-console.jsp:51`, `posts-list.jsp:41`, `recruit-list.jsp:34` |
| `body {}` 기본 리셋 + 폰트 + 배경 | 23개 | `admin-console.jsp:62`, `jam-list.jsp:41`, `terms.jsp:29` |
핵심 공통 토큰: `--surface`, `--card-bg`, `--text`, `--text-muted`, `--accent`, `--accent-soft`, `--border`, `--shadow` (라이트) / `#121212`, `#1e1e1e`, `#ece8e1`, `#a39e96`, `rgba(255,255,255,0.1)` (다크).
추가 중복: `.admin-btn` / `.admin-btn--primary` / `.admin-btn--danger` 컴포넌트 CSS 20줄 이상이 4개 admin 파일에 동일 복사됨 (`admin-console.jsp:171-194`, `admin-jam-list.jsp:167-190`, `admin-post-categories.jsp:100`, `admin-unity-feeds.jsp:108`).
`safe-area` padding 패턴(`padding: 1.5rem max(1rem, env(safe-area-inset-left)) 3rem max(1rem, env(safe-area-inset-right))`)은 25개 파일에 반복되나, `max-width` 값은 48/56/64/72/78rem으로 파일별 상이하다.
#### 충돌/갭
- CSS 변수 선언이 파일마다 포함하는 변수 수가 8~14개로 약간씩 달라, 특정 파일에서 일부 변수가 미선언된 상태일 수 있다.
- `jam-detail.jsp:128``recruit-detail.jsp:116``.detail-button--primary color` 값 불일치 (`#1a1a1a` vs `var(--button-text)`).
#### 권고 (design-advisor)
`src/main/webapp/css/global.css` 정적 파일을 신규 생성하여 공통 토큰, `body` 기본 스타일, `.admin-btn` 컴포넌트를 선언한다. 각 JSP `<head>``<link rel="stylesheet" href="${pageContext.request.contextPath}/css/global.css">` 를 삽입하고 인라인 중복 블록을 제거한다.
**포함 대상**: 공통 8+4개 토큰, `body` 기본 스타일, `.admin-btn*` 컴포넌트.
**포함하지 않는 것**: 각 JSP 고유 레이아웃 클래스, `max-width` 값(파일마다 다름), `--danger` 변수(`admin-unity-feeds.jsp` 단독 사용), 페이지 전용 컴포넌트 CSS.
**배포 경로**: `src/main/webapp/` 하위 정적 파일은 Tomcat DefaultServlet이 직접 서빙한다. `src/main/resources/static/` 경로 불필요.
**마이그레이션 3단계**: Phase 1(파일 생성 + login.jsp 1개 검증) → Phase 2(admin 4개 파일) → Phase 3(나머지 19개 JSP 일괄).
**위험**: `game-detail.jsp`처럼 `--radius`, `--webgl-bg` 등 파일 전용 변수가 있는 경우 해당 변수는 인라인 유지. FOUC는 `theme-init.jsp``<head>` 최상단에서 `data-theme` 속성을 먼저 설정하므로 낮음.
---
### JS 유틸리티 함수 중복 (P1)
#### 현황
admin 4개 파일(`admin-console.jsp`, `admin-jam-list.jsp`, `admin-post-categories.jsp`, `admin-unity-feeds.jsp`)에 동일 패턴의 함수가 각각 선언된다.
| 함수 | 파일 | 영향도 |
|---|---|---|
| `notify(message)` | admin 4개 파일 각각 선언 | Medium |
| `post(url, params)` | 동일 4개 파일. 구현이 약간씩 다름 (params 처리, _csrf 추가 여부) | High |
| `handleResult(res)`, `handleError()` | 동일 4개 파일 동일 패턴 | Medium |
특히 `post()` 함수는 파일마다 구현이 미묘하게 달라 동작 불일치 위험이 내재한다.
#### 권고 (design-advisor)
`src/main/webapp/js/bibimbap-utils.js` 신규 생성. `window.BibimbapUtils` 네임스페이스에 `post(url, params, csrfToken)`, `handleResult(res, onError)`, `makeErrorHandler(onError)` 를 노출한다.
**시그니처 변경 이유**:
- `post(url, params, csrfToken)`: 기존 클로저 캡처 방식 대신 토큰을 명시 파라미터로 받는다.
- `handleResult(res, onError)`: 기존의 `notify()` 직접 호출 대신 오류 메시지 콜백으로 위임한다 (title이 파일마다 달라 전역화 불가).
- `makeErrorHandler(onError)`: 매직 문자열 하드코딩 제거.
**`notify()` / `openModal()` 통합 불가**: title이 파일마다 달라 단순 통합이 불가능하므로 권고 3(모달 표준화)에서 별도 처리.
**위험**: admin 파일마다 `post()` 구현이 미묘하게 달라 통합 시 동작 변경 가능. 통합 전 4개 파일 3-way diff 수행 권고.
---
### BibimbapModal 호출 패턴 혼존 (P1)
#### 현황
`BibimbapModal` API는 `modal.jsp:174-238`에 단일 `<div id="site-modal">` 재사용 방식으로 구현되어 있으며, `alert`, `confirm`, `prompt` 메서드를 제공한다. 그러나 호출 패턴이 3가지로 혼존한다.
**패턴 A — `openModal()` 헬퍼 래퍼 (4개 파일)**
`login.jsp:256`, `signup.jsp:269`, `game-register.jsp:490`, `profile.jsp:539` 가 동일 시그니처의 `openModal(title, message, confirmText, onConfirm)` 로컬 함수를 각자 정의.
**패턴 B — `notify()` 헬퍼 래퍼 (4개 admin 파일)**
`admin-jam-list.jsp:389`, `admin-console.jsp:401`, `admin-post-categories.jsp:251`, `admin-unity-feeds.jsp:305``notify(message)` 로컬 함수를 각자 정의 (alert 전용, title 하드코딩).
**패턴 C — 인라인 직접 체크 (3개 파일)**
`recruit-form.jsp:411, 424`, `posts-form.jsp:252, 269`, `game-detail.jsp:1425, 1439, 1474, 1482, 1553, 1558` 가 호출 지점마다 인라인으로 체크.
**특이 케이스**: `game-detail.jsp:1553-1561``notifyError()`, `confirmAction()` 헬퍼를 별도 정의하면서, 동일 파일 `1425-1449`에 인라인 체크도 병존하여 단일 파일 내 두 패턴이 혼재한다.
**`window.confirm` 직접 사용**: `admin-post-categories.jsp:323``admin-unity-feeds.jsp:401`에서 삭제 확인 대화상자에 `window.confirm()`을 직접 사용. 알림에는 `notify()` 헬퍼(BibimbapModal 체크)를 쓰면서 삭제 확인에는 직접 사용하는 동일 파일 내 불일치.
#### 권고 (design-advisor)
**표준 패턴**: `window.BibimbapModal.alert / .confirm / .prompt` 직접 호출. 기존 헬퍼 래퍼는 신규 작성 금지.
- 패턴 A: login/signup/game-register/profile 4개 파일의 `openModal()` 제거 → 직접 호출로 대체.
- 패턴 B: admin 4개 파일의 `notify()` 제거 → 권고 2의 `BibimbapUtils.handleResult(res, onError)``onError` 콜백에서 직접 호출.
- 패턴 C: posts-form/recruit-form/game-detail 현재 형태 유지. `game-detail.jsp``notifyError()` / `confirmAction()` 파일 전용 헬퍼는 제거하지 않는다 (단일 파일 로컬 헬퍼 허용).
- `window.confirm` 2곳을 `BibimbapModal.confirm({...})` 으로 교체.
---
### recruit-form CSRF 폴백 갭 (P0 — 보안)
#### 현황
`posts-form.jsp``recruit-form.jsp`는 fetch 기반 비동기 폼 제출, `checkValidity()` 검증, `BibimbapModal.alert` 처리, `BibimbapCsrf` CSRF 헤더 주입이라는 동일 골격을 공유하지만, CSRF 처리에 불일치가 존재한다.
- `posts-form.jsp:169``<input type="hidden" name="_csrf" value="<%= csrfTokenHtml %>">` hidden input 존재 + JS 변수 이중 포함 (`posts-form.jsp:211, 229`).
- `recruit-form.jsp:386-394` — hidden CSRF input 없음. `BibimbapCsrf` 미존재 시 폴백 헤더에 CSRF 토큰이 전혀 포함되지 않는 보안 갭.
추가 불일치:
| 항목 | posts-form.jsp | recruit-form.jsp |
|---|---|---|
| 성공 시 폴백 | `} else { go(); }` (alert 없음) | `} else { alert('팀원 모집글이 등록되었습니다.'); go(); }` |
| 401 처리 | `redirectLogin` 함수 분리, `onConfirm`에서 호출 | 모달 `onConfirm`과 else 분기 양쪽에 인라인 중복 |
| 수정 모드 | `mode`, `postId` 로 등록/수정 URL 분기 있음 | 수정 모드 분기 없음 |
#### 권고 (design-advisor)
**즉시 수정 (방법 A 채택)**: `recruit-form.jsp``<input type="hidden" name="_csrf" value="...">` hidden input 추가 + JS 폴백 분기에 `X-CSRF-Token: csrfToken` 헤더 삽입.
```javascript
// 수정안 요지
var csrfToken = (document.querySelector('input[name="_csrf"]') || {}).value || '';
// ... BibimbapCsrf 미존재 시 폴백 헤더에 'X-CSRF-Token': csrfToken 추가
```
hidden input 추가 시 `new FormData(form)` 이 자동으로 `_csrf` 파라미터를 포함하므로 서버사이드 form 파라미터 검증도 함께 강화된다.
**방법 B (미채택)**: BibimbapCsrf를 항상 신뢰하여 폴백 분기 자체 제거 — BibimbapCsrf 로드 실패 시 더 큰 갭이 생기므로 채택하지 않는다.
**추가 수정**: 401 처리를 `redirectLogin` 함수로 분리, 성공 폴백 alert 제거.
---
### 날짜 포맷 함수 편재 (P2)
#### 현황
날짜/시간 포맷 JavaScript 함수가 `game-detail.jsp` 단 하나의 파일에만 존재한다. 조사 범위 7개 파일(`posts-detail`, `recruit-detail`, `jam-detail`, `posts-list`, `jam-list`, `recruit-list`, `game-detail`) 중 나머지 6개에는 날짜 포맷 JS 함수가 전혀 없다.
| 함수 | 위치 | 구현 방식 |
|---|---|---|
| `fmtAbsolute(iso)` | `game-detail.jsp:1516-1519` | `new Date(iso).toLocaleString('ko-KR')` |
| `fmtRelative(iso)` | `game-detail.jsp:1521-1536` | 경과 시간 직접 산술 계산 (60/3600/86400초 분기) |
| `buildTimeEl(iso, edited)` | `game-detail.jsp:1538-1551` | 두 함수 합성, `<time>` 요소 + "(수정됨)" 뱃지 반환 |
사용 위치: `game-detail.jsp:1878` (댓글), `game-detail.jsp:2183-2186` (리뷰).
`Intl.RelativeTimeFormat` / `Intl.DateTimeFormat` 직접 사용은 조사 범위 7개 파일 전체에서 0 hit.
**구조적 위험**: 다른 detail 페이지가 동일 기능이 필요해지면 복사 중복 발생 구조.
#### 권고 (design-advisor)
`src/main/webapp/js/bibimbap-date.js` 신규 생성. `window.BibimbapDate` 네임스페이스에 3개 함수 노출. `game-detail.jsp`는 script 태그 추가 + 인라인 선언 제거 + `BibimbapDate.` 프리픽스 적용.
현재 다른 detail 페이지에서 날짜 포맷이 필요하지 않으므로 강제 적용하지 않는다. 필요 시 include.
---
## 권고 구현 순서
```
1. [P0-보안] recruit-form.jsp CSRF 갭 수정 — 단독 작업, 위험 낮음
2. [P0] global.css 작성 + login.jsp 1개 검증 — Phase 1
3. [P0] admin 4개 파일 global.css 적용 — Phase 2
4. [P0] 나머지 19개 JSP global.css 적용 — Phase 3
5. [P1] bibimbap-utils.js 작성 — admin 파일 수정 전제
6. [P1] admin 4개 파일 bibimbap-utils.js 연동 — 5 완료 후
7. [P1] window.confirm 2곳 BibimbapModal.confirm 전환 — 6과 동시 가능
8. [P1] openModal 패턴 4개 파일 표준화 — 7 완료 후
9. [P2] bibimbap-date.js 추출 + game-detail.jsp 연동 — 독립 작업
```
**검증 기준(AC)**: 구현 완료 후 design-advisor 산출물의 AC-1~AC-11 항목을 기준으로 verification-advisor가 점검한다.
**롤백 경로**: 추가한 `<link>`, `<script>` 태그를 되돌리고 인라인 블록 복원. 서버사이드 Java 코드, MyBatis, API 계약에 영향 없음.
**캐시 버스팅**: 빌드 시스템 없음 → 쿼리 스트링 버전 파라미터(`?v=20260630`) 수동 관리.
---
## 미발견 / 조사 불충분 항목
- `theme-init.jsp` 내부 구현 미탐색 — JS 테마 초기화 방식과 CSS 변수 일원화 가능성 미확인
- `header.jsp`, `footer.jsp`에서 이미 CSS 변수를 선언하는지 여부 미탐색 — 이중 선언 여부 불명
- `modal.jsp`가 실제로 어느 레이아웃 include 경로를 통해 로드되는지 미탐색 — BibimbapModal 미존재 상황의 실제 발생 가능성 미확인
- `game-detail.jsp` 외 admin-* 파일들의 날짜 렌더링 패턴 미탐색
- `BibimbapCsrf` 실제 구현체(별도 JS 파일) 미탐색 — `headers()` 메서드 폴백 동작 미확인
- `posts-form.jsp``post()` 함수와 admin 4개 파일의 `post()` 함수가 실제로 다른 구현인지 정밀 비교 미수행
---
## 관련 파일
### 분석 대상 (주요)
| 파일 | 이슈 |
|---|---|
| `src/main/webapp/WEB-INF/views/recruit-form.jsp` | CSRF 폴백 갭 (P0-보안), 401 처리 불일치 |
| `src/main/webapp/WEB-INF/views/posts-form.jsp` | CSRF 기준 파일 (변경 없음) |
| `src/main/webapp/WEB-INF/views/admin-console.jsp` | CSS 토큰 중복, JS 함수 중복, notify 패턴 |
| `src/main/webapp/WEB-INF/views/admin-jam-list.jsp` | CSS 토큰 중복, JS 함수 중복, notify 패턴 |
| `src/main/webapp/WEB-INF/views/admin-post-categories.jsp` | CSS 토큰 중복, JS 함수 중복, window.confirm 직접 사용 |
| `src/main/webapp/WEB-INF/views/admin-unity-feeds.jsp` | CSS 토큰 중복, JS 함수 중복, window.confirm 직접 사용 |
| `src/main/webapp/WEB-INF/views/game-detail.jsp` | 날짜 함수 편재, 모달 패턴 혼재 |
| `src/main/webapp/WEB-INF/views/login.jsp` | openModal 패턴 A |
| `src/main/webapp/WEB-INF/views/signup.jsp` | openModal 패턴 A |
| `src/main/webapp/WEB-INF/views/game-register.jsp` | openModal 패턴 A |
| `src/main/webapp/WEB-INF/views/profile.jsp` | openModal 패턴 A |
### 신규 생성 예정 (권고안)
| 파일 | 역할 |
|---|---|
| `src/main/webapp/css/global.css` | 디자인 토큰 + body 기본 스타일 + admin-btn 컴포넌트 |
| `src/main/webapp/js/bibimbap-utils.js` | post / handleResult / makeErrorHandler 공용 유틸리티 |
| `src/main/webapp/js/bibimbap-date.js` | fmtAbsolute / fmtRelative / buildTimeEl 날짜 포맷 유틸리티 |
### 관련 기존 분석
- [2026-06-16-project-analysis.md](./2026-06-16-project-analysis.md) — bibimbap 전면 코드 분석. D2 보안 섹션에서 CSRF 더블서밋 기초 확인. 이 문서의 recruit-form CSRF 갭은 해당 분석 이후 발견된 추가 이슈다.

View File

@ -5,3 +5,4 @@
## 목록
- [2026-06-16-project-analysis.md](./2026-06-16-project-analysis.md) — bibimbap 전면 코드 분석(D1 아키텍처/D2 보안/D3 품질·기술부채/D4 도메인). 보안 기초는 견고(SQLi 0·PBKDF2·CSRF 더블서밋)하나 프로토타입 잔재 dead code·미완성 기능·서비스 레이어 부재가 주요 부채. `perspective: neutral`, work-session 20260616-111711 distill.
- [2026-06-30-frontend-component-review.md](./2026-06-30-frontend-component-review.md) — JSP 프론트엔드 컴포넌트 중복/유지보수·확장성 검토. CSS 토큰 23개 파일 중복(P0)·JS 유틸리티 함수 중복(P1)·BibimbapModal 호출 패턴 혼존(P1)·recruit-form CSRF 폴백 갭(P0-보안)·날짜 포맷 함수 편재(P2). work-session 20260630-113258 distill.

View File

@ -0,0 +1,38 @@
# 2026-06-30 리디자인 후속 — 토큰 통일·클래스 충돌 정리
**세션**: sid 20260630-170024
**브랜치**: feat/v2
**선행**: [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) 검토에서 발견된 일관성 결함 수정
## 배경
리디자인 비주얼 레이어 검토 결과 디자인 일관성 결함 발견 — 공유 `bibimbap.css`
앱 전역 브랜드 골드 `#e8a54b`(header/footer/modal/26파일) 와 다른 `#D4A853`
도입해 모든 페이지에 골드 2종이 공존했다. 부수적으로 클래스 합집합 override 모호성과
shadowing, 이중 CSS 로드가 있었다.
## 변경
| 항목 | 파일 | 내용 |
|---|---|---|
| accent 통일 | `css/bibimbap.css` | `--color-accent` `#D4A853→#e8a54b`(light+dark), `--color-accent-press` `#C79A40→#d8973a`. 앱 전역 브랜드 골드로 단일화. |
| focus-visible | `css/bibimbap.css` | `.btn`/`.chip`/`.link`/`a` 키보드 포커스 링 추가(마우스 클릭 시 미표시). |
| 클래스 충돌 제거 | `views/profile.jsp` | 페이지-로컬 `.profile-*` 를 source of truth 채택, 합집합으로 붙은 중복 bibimbap 유틸(`btn`/`avatar`/`thumb`/`meta`/`game-row`/`profile-head`/`card`/`section-*`) 제거 → override 모호성 소멸. |
| shadowing 해소 | `views/recruit-form.jsp` | bibimbap 와 충돌하던 로컬 클래스명(`field`/`form-grid`/`req`/`count`/`preview-card`/`form-actions`) `rf-` prefix rename. ID·JS·accent 무변경(이미 브랜드 골드 사용). |
| 이중 로드 제거 | `views/index.jsp` | 직접 `<link bibimbap.css>` 제거(theme-init 가 이미 전역 로드). |
인라인 핸들러(login/signup `oninput`·`onclick`)는 기능 정상·외부화 시 회귀위험 → **유지**(수용).
## 검증
| 레벨 | 결과 |
|---|---|
| L1 — WAR 빌드 | `./mvnw -q -DskipTests package` exit 0 PASS. WAR 내 `css/bibimbap.css` 포함 확인. |
| L2 — 런타임 스모크 | dev 컨테이너 소스 bind-mount(`.:/build`)로 변경 즉시 라이브. `/` `/login` `/signup` `/terms` `/recruit` `/posts` `/error` HTTP 200 + JSP 컴파일 에러 0. 서빙 css accent `#e8a54b` 확인. |
| L2 — 인증 게이트 | `/recruit/new`·`/profile` HTTP 302(로그인 필요) → JSP 렌더 미확인. |
## needs_user_verification
- `/recruit/new`(모집 폼): 로그인 세션에서 rf- rename 후 레이아웃·라이브 미리보기 정상 렌더.
- `/profile`: 로그인 세션에서 버튼/카드 외형(중복 클래스 제거 후) + 골드 정합.
- 다크모드 토글 시 전 페이지 골드 단일(`#e8a54b`) 육안 정합.

View File

@ -0,0 +1,63 @@
# 2026-06-30 스크린샷 가이드 — 인증 후 세부 화면 캡처 추가
**세션**: sid 20260630-141415
**브랜치**: feat/v2
**커밋**: `5dd639f`, `6e6d6b2`, `872f51c`
## 요약
`docs/development/screenshot-guide.py`를 전체 재작성해 인증 흐름을 추가했다.
기존 비인증 정적 페이지 7장 캡처에서 인증 후 세부 화면 7장을 추가로 캡처할 수 있게 됐다.
---
## 변경 전후 비교
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 캡처 장수 | 7장 (비인증 정적 페이지) | 최대 14장 (비인증 7 + 인증 후 7) |
| 로그인 흐름 | 없음 | AJAX fetch + `expect_navigation` |
| 실행 분기 | 단일 | `SCREENSHOT_EMAIL` / `SCREENSHOT_PASSWORD` env var 존재 여부 |
| 리뷰/댓글 composer | 캡처 불가 (`hidden` 상태) | 5초 대기 후 `removeAttribute('hidden')` 폴백 |
| 테스트 계정 | 미문서화 | `tester@bibimbap.local` / `test1234!` (seed-dev.sql user3) |
---
## 추가된 인증 후 캡처 목록
| 파일 | 화면 |
|---|---|
| `08-home-loggedin.png` | 홈 (로그인 후 — 내비게이션 변경) |
| `09-profile.png` | 프로필 페이지 |
| `10-posts-form.png` | 게시글 작성 폼 |
| `11-recruit-form.png` | 모집 작성 폼 |
| `12-game-detail-loggedin.png` | 게임 상세 (로그인 후 전체 뷰) |
| `13-game-review-form.png` | 리뷰 작성 폼 (노출 상태) |
| `14-game-comment-form.png` | 댓글 작성 폼 (노출 상태, 섹션 포커스) |
---
## 구현 상세
### 로그인 인증 흐름
Playwright AJAX fetch 방식으로 `/auth/login` 엔드포인트에 `application/x-www-form-urlencoded` POST 후 `expect_navigation`으로 리다이렉트 완료를 감지한다. 기존 `wait_for_navigation()`은 타이밍 경합이 있어 `expect_navigation` 컨텍스트 매니저로 교체했다.
### hidden 요소 강제 노출 전략
리뷰/댓글 composer는 서버 API 응답(로그인 상태, 기존 리뷰 여부) 이후 JS가 `hidden` 속성을 제거한다. 스크립트는 최대 5초 자연 노출을 대기하고, 실패하면 `page.evaluate("el => el.removeAttribute('hidden')")` 폴백으로 강제 노출해 캡처한다.
---
## 수정된 파일
| 파일 | 변경 유형 |
|---|---|
| `docs/development/screenshot-guide.py` | 전체 재작성 |
| `docs/development/screenshot-guide.md` | 업데이트 (테스트 계정, 전체 실행 명령 추가) |
---
## 관련 문서
- [docs/development/screenshot-guide.md](../development/screenshot-guide.md) — 실행 방법 및 전체 캡처 목록

View File

@ -0,0 +1,64 @@
# 2026-06-30 게임 카드 비율·모집 가시성 — 다축 평가 후 13건 시각 결함 수정
**세션**: sid 20260630-175023
**브랜치**: feat/v2
**선행**: [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) / [2026-06-30-redesign-token-coherence-fix.md](./2026-06-30-redesign-token-coherence-fix.md) — 리디자인(cf1e7ce 등) 미해결 결함 2건의 재검·수정
## 배경
직전 리디자인 세션이 마감했음에도 사용자가 지적한 시각 결함 2건이 남아 있었다.
- **이미지 #2 (메인 게임 허브)**: 게임 카드 썸네일 "비율" 문제.
- **이미지 #3 (팀원 모집 빈 상태)**: "가시성" 떨어지는 케이스.
"비율"을 카드 종횡비 단일 축으로 조기 협소화해 첫 `AskUserQuestion` 옵션을 설계했으나,
사용자가 "이미지 미리보기뿐 아니라 상단에 검색바도 혼자 짧다"로 **레이아웃 비율 축**을 직접 교정.
이를 받아 **multi-axis 멀티에이전트 평가**(관찰 1 → 레이아웃비율·카드종횡비·대비가시성·타이포간격
4축 병렬 → 종합 1, 총 6 에이전트)로 전환해 13건 결함 + 1 결정필요(카드 종횡비)로 복원했다.
사용자 결정: **카드 4:3** + **전체 13건** 적용.
## 변경 (13건)
| 파일 | 항목 | 내용 |
|---|---|---|
| `views/index.jsp` | #1 검색바 풀폭 | `.search-section` 풀폭 + `.search-stack` wrapper(검색행/상세검색을 우측 끝선 정렬, 픽셀 margin 대신 컬럼 wrapper로 버튼 텍스트 가변 대응) |
| `views/index.jsp` | 카드 그리드 | `.card-grid` `auto-fill` |
| `views/index.jsp` | #13 카드 종횡비 | `.card__media` `aspect-ratio` `4/5→4/3`, `.card__img` `object-position:center` |
| `css/bibimbap.css` | #2 고스트 버튼 대비 | `.btn-ghost` `color` `on-accent→text`(다크 `#3A2E15` on `#222018` ≈1.1:1 → `--color-text` 적용 ≈13:1) |
| `css/bibimbap.css` | 다크 보더 가시성 | 다크 `--color-border` `0.08→0.20` |
| `css/bibimbap.css` | #6·#7 정렬칩/라벨 | `.sort-label` 대비·위계 정리, 정렬칩 보더 |
| `css/bibimbap.css` | #11 빈상태 패딩 | `.empty-state` `padding` `clamp` |
| `css/bibimbap.css` | disabled 토큰화 | `.btn:disabled` 토큰화 |
| `views/recruit-list.jsp` | #10 히어로 폭 | `.recruit-hero__titles` `max-width` |
| `views/recruit-list.jsp` | #8 eyebrow 통일 | eyebrow mono 통일(공용 클래스 대신 로컬 시각스펙 일치 — index/bibimbap 토큰계열 분리 + font-mono 토큰 부재) |
| `views/recruit-list.jsp` | #9 빈상태 CTA 강등 | 빈상태 시 `.recruit-write` 조건부 ghost(골드 CTA 1개로 위계 정리) |
| `views/recruit-list.jsp` | 검색 폭 | 검색 `max-width` `48rem` |
조정 사항: #4 는 픽셀 margin 대신 `.search-stack` 컬럼 wrapper(버튼 텍스트 가변 대응 결정적),
#8 은 공용 클래스 대신 로컬 시각스펙 일치(토큰계열 분리 제약). `recruit-empty.jspf` 는 무편집.
## 검증
| 레벨 | 결과 |
|---|---|
| L1 — 정적 | `index.jsp` div 18/18·`recruit` 6/6 균형, scriptlet 균형, U+FFFD 0, stray taglib 0(적신호 없음) |
| L2 — 런타임 스모크 | bibimbap-app(`mvn spring-boot:run`) 재기동 → GET `/` + `/recruit` = 200 → 브라우저 육안 **라이트+다크 양 화면** |
| L2 — 결함 해소 | 이미지 #2(비율): 검색바 풀폭 정렬 + 카드 4:3(`object-position`) 해소. 이미지 #3(가시성): 필터 초기화 흰글자+보더 명확(zoom 확정) 해소. 부수 #6·#7·#8·#9·#10·#11·푸터 비잘림 육안 확인 |
`error.jsp` JSTL 로그(07:35Z JasperException)는 재기동(09:19Z) 이전의 stale 라인 — 현재 `/error` 200, 회귀 아님.
## 커밋
- `9489365` — 수정 13건
- `c688aa6` — 교훈 + 회고
## needs_user_verification
- 로그인 상태 1회 스모크(본 검증은 로그아웃 상태만): (a) 메인 '신규 게임 개시' 버튼 노출 시 #4 `search-stack` 우측 끝선 정렬, (b) 모집글 ≥1건 시 #9 히어로 골드 CTA 유지.
- 실게임 가로 썸네일 업로드 시 4:3 cover 크롭 인상 최종 확인(더미는 폴백 로고만 노출).
## 교훈 연결
[development/verification-strategies.md](../development/verification-strategies.md) `### 모호한 시각 결함 어휘는
단일 결정축 협소화 전 다축 스캔 선행` 신규 레슨으로 등록(근거: 본 세션). 외부 ATP 번들 §4.4 item5
(가시성·표현·레이아웃 축 누락 주의)를 **선행 게이트**(옵션 설계 전 다축 스캔)로 강화 권고와 상호참조.

View File

@ -0,0 +1,68 @@
# 2026-06-30 클로드 디자인 리디자인 — 비주얼 레이어 9단위 반영
**세션**: sid 20260630-160000
**브랜치**: feat/v2
**커밋**: `0390da6` ~ `360078a` (단위별 8개) + graph 메타 `8c86e8c`
## 요약
클로드 디자인 리디자인 산출물을 "비주얼 레이어" 방식으로 전 페이지에 반영했다.
파일 통째 교체 없이 마크업·클래스만 이식하고 모델명·CSRF·AJAX·include·자체 head는 기존값을 전부 보존했다.
---
## 변경 범위
| 단위 | 내용 |
|---|---|
| 1 — CSS 신설 | `src/main/webapp/css/bibimbap.css` 신설 (다크 오버라이드 포함). `WEB-INF/views/theme-init.jsp` 단일 link 태그로 23개 전 페이지 공통 적용. |
| 2 — 빈 상태 fragment | `fragments/posts-empty.jspf`, `fragments/recruit-empty.jspf` 신설. `posts-list.jsp`, `recruit-list.jsp` 에서 include. |
| 3 — 홈 | `index.jsp` — 히어로 섹션·정렬 칩·그리드 레이아웃 클래스 이식. |
| 4 — 인증 | `login.jsp` — pw-toggle·에러 슬롯. `signup.jsp` — 강도 막대·일치 표시. |
| 5 — 약관 | `terms.jsp` — sticky TOC 구조 이식. |
| 6 — 프로필 | `profile.jsp``.profile-head`·`.game-row` 클래스 이식. |
| 7 — 게임 상세 | `game-detail.jsp` — 이미 동일 토큰 구현 확인 → **no-op**. |
| 8 — 에러 | `errer.jsp` — empty-state 외형(SVG/아이콘) 이식. 기존 scriptlet `statusCode` 읽기 유지. |
| 9 — 모집 작성 | `recruit-form.jsp` — 필수 표시·글자수 카운터·sticky 미리보기 이식. |
---
## 하드 제약 준수
| 제약 | 처리 |
|---|---|
| 모델명 보존 | 디자인 EL(`${errorData}` 등) 미채택. 기존 `${game.title}`, `${ctx}` 등 유지. |
| CSRF 이중방어 | `_csrf` hidden + `BibimbapCsrf.headers` AJAX 헤더 — 기존 코드 무수정 보존. |
| include 경로 | `/WEB-INF/views/header.jsp` 등 기존 include 경로 전부 유지. |
| CDN 0 | 외부 CDN 참조 추가 없음. |
| 미존재 라우트 비노출 | `/games/{id}/visibility`, `/profile/edit`, `/password/reset` UI 요소 미채택. |
| game-detail 보존 | 이미 동일 토큰 구현 상태 확인 후 no-op 처리. |
---
## 검증 결과
| 레벨 | 결과 |
|---|---|
| L1 — WAR 빌드 | `./mvnw -q -DskipTests package` exit 0 PASS |
| L1 — CSS WAR 포함 | `target/.../css/bibimbap.css` 존재 확인 PASS |
| L2 — 스모크 | 로컬 dev 재기동 후 `/`, `/login`, `/signup`, `/terms`, `/posts`, `/recruit`, `/error` 전부 HTTP 200 |
| L2 — 결함 수정 | `errer.jsp` stray `javax` JSTL taglib → `/error` 500 → taglib 제거 → 200 (fix 커밋 `360078a`) |
| L2 — 마커 검증 | home css link+hero, login pw-toggle, signup pw-meter, terms toc, error empty-state 서빙 확인 |
---
## needs_user_verification (브라우저 세션 필요)
- 로그인 인터랙티브 제출: AJAX(`_csrf` + `BibimbapCsrf.headers`) 성공·리다이렉트. 테스트 계정 `admin@bibimbap.local`.
- 회원가입 제출 → modal → `/login` 리다이렉트.
- 프로필: 닉네임·아바타 변경 AJAX (로그인 세션 필요).
- 게임 상세: WebGL 플레이·6축 리뷰·댓글 CRUD (no-op이나 회귀 확인 권장).
- 모집 작성: `/recruit/new` 제출 성공 + 라이브 미리보기.
- 다크모드 토글 시 body ↔ header/footer 색 정합 육안 확인.
---
## 관련 문서
- [docs/changes/2026-06-29-b2-b4-fe-hardening.md](./2026-06-29-b2-b4-fe-hardening.md) — FE 다크모드 대비·세션 하드닝 (선행 작업)

View File

@ -0,0 +1,45 @@
# 2026-07-01 game-register.jsp 리디자인 누락분 보강
**세션**: `/atp:task``frontend-design` 스킬 경유
**브랜치**: feat/v2
## 배경
`/game/new`(신규 게임 등록, `game-register.jsp`)가 2026-06-30 9단위 리디자인([2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md)) 목록에서 빠져 있었다는 사용자 지적으로 발견. 실사용 로그인 스샷으로 대조한 결과 카드 레이아웃·사이드 미리보기·색상 토큰(accent `#e8a54b` 등)은 이미 sibling 페이지(`recruit-form.jsp` 등)와 일치했다 — 즉 시각적으로 크게 뒤처진 상태는 아니었고, 누락은 주로 **추적 문서의 공백**이었다.
## 변경 내용
`game-register.jsp` 1파일, 마크업·CSS 추가만 (신규 파일 0):
- 필수 입력 표시(`*`) 추가 — 게임 이름(항상), WebGL zip(`editMode`가 아닐 때만, 수정 시엔 zip 선택이 선택사항이라는 기존 로직 반영)
- 미리보기 패널에 "미리보기" eyebrow 라벨 추가 (`aria-label`은 기존에도 있었으나 시각적 라벨이 없었음)
- 두 항목 모두 `recruit-form.jsp``.rf-req` / `.rf-preview__label` 패턴을 클래스명만 `.game-req` / `.game-preview__label`로 바꿔 그대로 재사용 — 새 디자인 언어 도입 없음
**로컬 CSS 변수 정리는 범위 제외**: `game-register.jsp`가 쓰는 `--surface/--accent/--text` 등은 `bibimbap.css`(`--color-*` 네이밍)와 다른 변수라 대체가 안 되고, 이 로컬 토큰 관례는 `recruit-form.jsp`·`game-detail.jsp` 등 이미 리디자인된 sibling 페이지도 동일하게 갖고 있는 의도적 패턴([analysis/2026-06-30-frontend-component-review.md](../analysis/2026-06-30-frontend-component-review.md) P0 "CSS 토큰 23개 파일 중복"). 이 파일만 단독 전환하면 sibling과 관례가 갈라지므로, 전지역 통합은 별도 세션으로 이월.
## 재발방지
`docs/development/frontend-redesign-coverage-checklist.md` 신설 — 뷰 파일(fragment 제외 23개) 전수 대비 리디자인 반영 상태를 표로 추적. `docs/development/workflow-patterns.md`에 "'전 페이지 반영' 서술은 실제 파일 전수와 대조 필요" 패턴 등재.
## 검증 결과
| 레벨 | 결과 |
|---|---|
| L1 | `docker compose exec app mvn -o test` — 362/362 PASS |
| L1 | 정적 grep `<%@ taglib` — 0건(stray taglib 없음) |
| L2 | 로그인(`admin@bibimbap.local`) 후 `/game/new` 육안 확인 — 필수(*) 2건 + "미리보기" 라벨 렌더 확인, 라이트/다크 양 테마 |
| known pitfall | JSP 저장 즉시반영 불신 원칙 적용 — `docker compose restart app` 후 재대조로 stale 렌더링 배제 |
## needs_user_verification
- WebGL zip 실제 업로드 → 등록 제출 전체 플로우(파일 업로드 API 호출 포함) 회귀 확인 — 이번 변경은 마크업/CSS만이라 로직 영향 없음으로 판단하나 실제 제출은 미실행.
## 추가 검증 (edit-mode)
`editMode=true` 경로 확인 완료 — admin(`user_id=9`) 소유 임시 게임 row를 dev DB에 직접 삽입(`INSERT ... games`, `is_visible=false`)해 `/game/8/edit` 방문 후 육안 확인, 확인 후 즉시 `DELETE`로 원복(잔존 0건, `select count(*) from games where user_id=9` → 0). 결과: "게임 이름 *"는 유지(항상 필수), "WebGL zip"은 `*` 미표시(기존 WebGL 유지 문구와 함께 비필수로 정상 렌더) — 코드 로직(`<% if (!editMode) %>`)과 일치.
## 관련 문서
- [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md)
- [../development/frontend-redesign-coverage-checklist.md](../development/frontend-redesign-coverage-checklist.md)
- [../development/workflow-patterns.md](../development/workflow-patterns.md)

View File

@ -10,3 +10,8 @@
- [2026-06-29-game-like-server-persistence.md](./2026-06-29-game-like-server-persistence.md) — 게임 "좋아요" 서버 영속화. 좋아요가 서버에 안 써지고 localStorage 로만 토글되던 버그 수정. 신규 `POST /game/{id}/like` 토글(@Transactional, CSRF→로그인→존재 게이트) + game_likes row/`games.like_count` 컬럼 단일 트랜잭션 ±1 동기 + 상세 JSP fetch 전환 + explore 무변경(컬럼 자동반영). L1 PASS(GameLikeControllerTest 7/7 + 회귀 219/219), L2 dev DB skip. UNIQUE 마이그레이션(20260629-game-likes-unique.sql) 운영 적용·실환경 스모크는 needs_user_verification — 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md). 좋아요 항목으로 보안 B3 부분 충족.
- [2026-06-24-w2-jam-platform.md](./2026-06-24-w2-jam-platform.md) — W2 게임잼 워크스트림 전체(W2-1~6) 구현. 잼 엔티티/라이프사이클(jams/jam_teams/jam_team_members/jam_entries/jam_status_log) + 심사위원 역할(jam_judges 잼스코프) + 평가 동결 스키마(jam_criteria/jam_scores/jam_votes/jam_awards + jam_score_stats VIEW, 평가단위 (jam_id,game_id) 활성 자연키) + 심사 평가(3중게이트 UPSERT 가중집계) + 인기투표(1인1표 UNIQUE·종료후 공개) + 시상 집계(3트랙+가중 GRAND·CLOSED 확정 멱등). GAME_JAM_MANAGE 첫 enforcement 연결·잼스코프 isJudge·평가기간 게이트·CSRF 전수. 6 커밋 ccf1e42~a74bf74. 최종 L1 190/190 GREEN(회귀 0), L2 dev DB contract 전 PASS(격리 throwaway DB). dev DB 마이그레이션·L3 스모크 needs_user_verification.
- [2026-06-29-b2-b4-fe-hardening.md](./2026-06-29-b2-b4-fe-hardening.md) — B2 프로토타입 dead code 제거(abstracts 4파일·header.jspf·GameCatalog 삭제, 없는 게임 ID = HTTP 404) + B4 의존성/세션 하드닝(Spring Boot 3.5.16 GA·snapshot repo 제거·쿠키 HttpOnly/SameSite·로그 WARN·OWASP DC plugin 비bind·profile properties 신규) + FE 다크모드 대비·검색 접이식·터치타깃. mvn test 361 PASS.
- [2026-06-30-screenshot-guide-auth.md](./2026-06-30-screenshot-guide-auth.md) — 스크린샷 가이드 인증 흐름 추가. 비인증 7장 → 인증 포함 최대 14장. Playwright AJAX 로그인(expect_navigation) + SCREENSHOT_EMAIL/PASSWORD env var 분기 + 리뷰/댓글 composer hidden 강제 노출 폴백.
- [2026-06-30-redesign-token-coherence-fix.md](./2026-06-30-redesign-token-coherence-fix.md) — 리디자인 후속 일관성 수정. 공유 bibimbap.css accent 를 앱 전역 브랜드 골드로 통일(`#D4A853→#e8a54b`, light+dark) + focus-visible 링 + profile.jsp 중복 유틸 클래스 제거(override 모호성 소멸) + recruit-form.jsp 충돌 클래스 `rf-` rename(shadowing 해소) + index.jsp 이중 CSS 로드 제거. L1 빌드 PASS, L2 스모크 7페이지 200(JSP 에러 0). 인증 게이트 페이지(recruit-form/profile) 렌더는 needs_user_verification.
- [2026-06-30-visual-redesign-bibimbap-css.md](./2026-06-30-visual-redesign-bibimbap-css.md) — 클로드 디자인 리디자인 비주얼 레이어 9단위 반영. bibimbap.css 신설 + theme-init 공통 link + 빈상태 fragment 2종 + 홈/인증/약관/프로필/에러/모집 마크업·클래스 이식. game-detail no-op. 커밋 8개, L2 스모크 전 페이지 200(errer JSTL taglib 결함 포착·수정). 인터랙티브 제출·다크모드는 needs_user_verification.
- [2026-06-30-ui-multiaxis-ratio-visibility-fix.md](./2026-06-30-ui-multiaxis-ratio-visibility-fix.md) — 리디자인 미해결 시각 결함 2건(이미지#2 게임 카드 "비율" / 이미지#3 모집 빈상태 "가시성") 수정. "비율"을 카드 종횡비 단일 축으로 조기 협소화 → 사용자가 검색바 정렬(레이아웃 비율) 축 직접 교정 → multi-axis 멀티에이전트 평가(관찰1→레이아웃비율·카드종횡비·대비가시성·타이포간격 4축 병렬→종합1, 6 에이전트)로 13건 결함+카드 종횡비 결정 복원. 사용자 결정: 카드 4:3 + 전체 13건(index.jsp search-stack·card 4:3·object-position / bibimbap.css btn-ghost 대비·다크 보더·정렬칩·empty padding / recruit-list.jsp 히어로폭·eyebrow·빈상태 CTA 강등). L1 정적 + L2 런타임 스모크 라이트/다크 양화면 PASS. 커밋 9489365·c688aa6. 교훈: [verification-strategies.md](../development/verification-strategies.md) "모호한 시각 결함 어휘는 단일 결정축 협소화 전 다축 스캔 선행". 로그인 상태 스모크·실게임 가로 썸네일 4:3 크롭은 needs_user_verification.
- [2026-07-01-game-register-redesign-parity.md](./2026-07-01-game-register-redesign-parity.md) — `/game/new`(game-register.jsp)가 9단위 리디자인 목록에서 빠졌던 것을 사용자 지적으로 발견·보강. 실사용 스샷 대조 결과 레이아웃/토큰은 이미 sibling과 일치 — 필수(*) 표시 2건 + 미리보기 eyebrow 라벨만 `recruit-form.jsp` 패턴 재사용으로 추가(단일 JSP, 로직 무변경). 로컬 CSS 토큰 전지역 통합은 별도 과제로 이월. 재발방지로 `frontend-redesign-coverage-checklist.md` 신설 + workflow-patterns 등재. L1 362/362 PASS, L2 로그인 후 라이트/다크 육안 확인.

View File

@ -36,3 +36,40 @@ source_session: 20260617-174635
W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다.
> 관련(다른 레포): ATP 번들의 출력 스타일/압축 규약에 동 예외를 명문화하자는 protocol_feedback 가 세션 회고에 기록됨. 적용 *대상* 축은 다르지만 "압축 적용 경계" 교훈은 `caveman-bundle-compression-roi-ceiling`(번들 정적 압축 ROI 천장)과 같은 계열.
## 규칙 2 — 도구 호출에 넣는 비ASCII(한글 등) 텍스트는 리터럴로 작성, `\u` 수동 이스케이프 금지
`AskUserQuestion` 등 JSON 파라미터에 한글을 넣을 때 `\uXXXX` 코드포인트를 손으로 타이핑하지 않는다. 코드포인트 하나라도 오타 나면 렌더링 시 깨진 문자로 나타나고, 에이전트 자신은 도구 호출 스키마상 오류를 감지하지 못한 채 그대로 사용자에게 전달된다. 사용자에게는 "질문 자체가 뭔지 모르겠다"로 나타나 재질의 왕복이 발생한다.
- 항상 실제 UTF-8 문자를 그대로 입력한다(복붙 아님, 정상 타이핑).
- 결과물이 의심되면 보내기 전에 스스로 읽어 자연스러운 한글인지 확인한다.
### 발원 사례
세션 `20260701-102212``AskUserQuestion` 옵션 라벨/설명에 한글을 `\u` 이스케이프로 수동 작성하다 코드포인트 오타 2회 발생. 사용자: "육각 마범롘 다이었 끝점(궁벀 불망 지겁)..." 같은 깨진 텍스트를 받고 "한글이 죄다깨져서 뭐라하는지 모르겟어 다시 질문해봐"로 지적. 리터럴 문자로 재작성하자 즉시 정상 렌더링.
> **재발 확인** — 세션 `20260701-142000`: 동일 실수가 다시 발생, 첫 `AskUserQuestion` 호출이 `InputValidationError`(params type mismatch)로 즉시 거부됐다(다행히 사용자에게 깨진 텍스트가 전달되기 전에 스키마 단계에서 걸림). 원인은 동일 — 질문/옵션 텍스트를 타이핑하는 과정에서 글자가 오염됨. **툴 스키마 오류로 걸리지 않는 경우도 있으므로**(오타가 유효한 JSON 문자열이면 스키마는 통과하고 사용자에게 그대로 전달됨 — 첫 발원 사례가 그 경우), 스키마 통과 여부에 의존하지 말고 발신 전 자체 검토를 실제로 수행한다.
## 규칙 4 — `AskUserQuestion` 옵션에 실린 기술적 전제는 제시 전에 grep/Read로 확인한다
옵션 문구가 "기존 리소스로 대체 가능", "이미 동일한 패턴", "네이밍이 일치" 같은 **사실 주장**을 포함할 때, 그 주장을 검증 없이 제시하면 사용자는 검증된 사실로 오인하고 선택한다. 사용자가 그 옵션을 고른 뒤 구현 착수 시점에야 grep 등으로 전제 오류가 발견되면, 이미 사용자 의사결정이 한 번 소비된 뒤라 재확인 왕복(선택 무효화 → 재질의 → 재선택)이 발생한다.
규칙 1(제시문 풀어쓰기)과는 독립된 결함이다 — 문구가 아무리 풀어써졌어도(규칙 1 충족) 그 안의 사실이 틀렸으면 재질의 왕복은 똑같이 발생한다.
절차:
1. 옵션 문구를 작성하기 전, 그 문구가 의존하는 사실 주장(공유 가능성, 네이밍 일치, 기존 구현 존재 등)을 나열한다.
2. 나열된 주장 각각을 grep/Read로 실제 확인한다 — "~일 것 같다"로 제시하지 않는다.
3. 확인이 안 되거나 시간이 없으면, 옵션 문구에서 그 주장을 빼거나 "미확인, 구현 착수 시 재검증 필요"로 명시한다.
### 발원 사례
세션 `20260701-142000` — game-register.jsp CSS 토큰 정리 범위를 묻는 `AskUserQuestion`에서 orchestrator가 "bibimbap.css 공유 변수로 전환 가능"을 grep 없이 전제로 제시(B안). 사용자가 B안을 선택했으나, 구현 착수 전 grep 확인 결과 `bibimbap.css`(`--color-*` 네이밍)가 game-register.jsp가 쓰는 변수(`--surface/--accent/--text` 등)를 대체하지 못함이 드러나 재확인 라운드가 필요했다. 구현 착수 전 단계에서 걸러져 실제 코드 오염은 없었으나, 발견 시점이 빨랐던 것은 우연이었다.
## 규칙 3 — 사용자 지적에 복수 대상 표현이 있으면 스코프를 임의로 좁히지 말고 대상 목록을 먼저 확인
이미지·스크린샷을 동반한 지적에 "각 그래프", "이것들" 처럼 복수/포괄 표현이 쓰이면, 그 중 하나(가장 먼저 눈에 띄는 요소)로 스코프를 임의 축소해 확인 질문(`AskUserQuestion` 등)을 구성하지 않는다. 확인 질문 자체에 "지적 대상이 A/B/C 중 무엇인지"를 먼저 명시하거나, 최소한 확인 질문의 배경 문장에 전체 후보를 나열해 사용자가 스코프 오판을 즉시 정정할 수 있게 한다.
### 발원 사례
세션 `20260701-102212` — 사용자가 스크린샷과 함께 "각 그래프의 끝이 어떤 점수인지 알 수 없다"고 지적(화면엔 막대 미터바 6개 + 육각 레이더 1개, 즉 "그래프"가 최소 2종류 혼재). orchestrator 가 스코프를 막대 미터바로 좁혀 시각전략 확인 질문을 구성·구현·커밋까지 마쳤으나, 사용자가 "미터바도 좋은데 내가말한건 육각 그래프였어"로 정정. 최초 확인 질문에 "미터바/레이더 둘 다 대상인지, 어느 쪽인지"를 먼저 물었다면 방향 전환 비용(재구현+재검증 사이클)을 피할 수 있었다.

View File

@ -65,3 +65,5 @@
- 문서 동기화만 했다면 원칙적으로 `changes/` 를 만들지 말고, 대상 기준 문서를 직접 갱신한다.
- 장애 수정이 있었더라도 핵심 가치가 장애 원인과 대응 이력 보존이라면 `issues/` 를 우선하고, 필요할 때만 관련 `changes/` 를 추가한다.
- ADR 에 해당하는 결정이면 `changes/` 대신 `adr/` 에 불변 레코드로 남긴다.
- **기존 문서에 append 하기 전에는 그 문서 자신의 스코프 선언과 먼저 대조한다.** 카테고리(`development/` 등)가 맞다는 것만으로 그 카테고리의 "비슷해 보이는" 기존 파일에 바로 적지 않는다. 대상 파일 맨 앞에 "이 파일은 X만 다룬다" 류의 스코프 선언이 있으면, 추가하려는 내용이 그 선언에 부합하는지 확인하고, 부합하지 않으면 새 파일(또는 스코프가 맞는 다른 기존 파일)로 분리한다. 같은 파일 안에 이미 스코프를 벗어난 선례가 있다는 사실은 정당화 근거가 되지 않는다 — 선례 자체가 과거의 같은 실수일 수 있다.
- 사례: `verification-strategies.md`("`verification-advisor` 가 읽는 단일 레지스트리 — 검증 전략만 대상")에 작업 진행 방식(사용자 의사결정 수렴 절차 등)을 "구조적 교훈"이라는 이름으로 반복 혼입 → `workflow-patterns.md` 신설 후 재분류(세션 20260701-113509 계열). index.md 의 카테고리 설명 문구가 실제 파일 스코프보다 넓게 서술돼 있으면 그 문구부터가 오분류 신호일 수 있다 — 문구를 믿기 전에 대상 파일 원문 스코프 선언을 직접 읽는다.

View File

@ -0,0 +1,47 @@
# Frontend 리디자인 대상 커버리지 체크리스트
`WEB-INF/views/*.jsp` 전체(fragment 제외 23개)를 대상으로, 클로드 디자인 리디자인의 "마크업·클래스 이식" 단위 반영 여부를 추적한다. 새 뷰 파일이 추가되면 이 표에 행을 추가한다 — 추가하지 않으면 다음 리디자인 세션이 `docs/changes/`의 과거 커밋만 보고 전체 목록을 재구성하다가 파일을 놓친다(재발 사례: `game-register.jsp` 누락, 아래 참고).
## 사용 규칙
- **리디자인류 세션 착수 시**: `find src/main/webapp/WEB-INF/views -maxdepth 1 -name "*.jsp"` 로 실제 파일 목록을 뽑아 이 표와 **행 개수 대조**부터 한다. 표 갱신 없이 과거 `docs/changes/*.md` 서술만 참고하면 이 표에 없는 신규 파일이 통째로 빠진다.
- **세션 종료 시**: 이번 세션에서 다룬 파일의 상태 열을 갱신하고, 다룬 근거 커밋/문서 링크를 채운다.
- 상태 값: `완료` / `no-op(토큰 일치 확인)` / `미착수` / `제외(관리자·저빈도)`
## 상태표
| 파일 | 상태 | 근거 |
|---|---|---|
| `index.jsp` | 완료 | [2026-06-30-visual-redesign-bibimbap-css.md](../changes/2026-06-30-visual-redesign-bibimbap-css.md) |
| `login.jsp` | 완료 | 위 문서 단위4 |
| `signup.jsp` | 완료 | 위 문서 단위4 |
| `terms.jsp` | 완료 | 위 문서 단위5 |
| `profile.jsp` | 완료 | 위 문서 단위6 + [2026-06-30-redesign-token-coherence-fix.md](../changes/2026-06-30-redesign-token-coherence-fix.md) |
| `game-detail.jsp` | no-op(토큰 일치 확인) | 위 문서 단위7 |
| `errer.jsp` | 완료 | 위 문서 단위8 |
| `recruit-form.jsp` | 완료 | 위 문서 단위9 + token-coherence-fix |
| `posts-list.jsp` | 완료 | 위 문서 단위2(빈상태 fragment) |
| `recruit-list.jsp` | 완료 | 위 문서 단위2(빈상태 fragment) + [2026-06-30-ui-multiaxis-ratio-visibility-fix.md](../changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md) |
| `game-register.jsp` | 완료 | **9단위 리스트에서 누락 확인 후 처리(20260701)**`/game/new` 신규 게임 등록. 사용자 지적으로 발견. 로그인 후 실사용 스샷 대조 결과 카드/사이드미리보기 레이아웃·토큰은 이미 sibling(recruit-form 등)과 일치 — 차이 2건만 보강: 필수(*) 표시(게임 이름, WebGL zip — editMode 시 zip 비필수 반영) + 미리보기 패널 "미리보기" eyebrow 라벨. `recruit-form.jsp``.rf-req`/`.rf-preview__label` 패턴 그대로 재사용, 로컬 CSS 변수 관례는 sibling과 동일하게 유지(전지역 통합은 [analysis/2026-06-30-frontend-component-review.md](../analysis/2026-06-30-frontend-component-review.md) P0 별도 과제). |
| `posts-detail.jsp` | 미착수 | — |
| `posts-form.jsp` | 미착수 | — |
| `recruit-detail.jsp` | 미착수 | — |
| `jam-list.jsp` | 미착수 | — |
| `jam-detail.jsp` | 미착수 | — |
| `jam-results.jsp` | 미착수 | — |
| `jam-scoring.jsp` | 제외(관리자·저빈도) | 심사위원 전용 화면. 재검토 시 이 행만 상태 변경 |
| `operation-policy.jsp` | 미착수 | — |
| `admin-console.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
| `admin-jam-list.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
| `admin-post-categories.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
| `admin-unity-feeds.jsp` | 제외(관리자·저빈도) | 관리자 전용 |
> fragment/공통 include(`header.jsp`, `footer.jsp`, `modal.jsp`, `theme-init.jsp`)는 이 표 대상에서 제외 — 별도 트래킹 불필요(공통 include는 상위 페이지가 완료되면 자동 반영).
## 재발방지 규칙
리디자인 세션의 "전 페이지 반영" 서술은 **작업 당시 작성자가 열거한 목록**을 의미할 뿐, 실제 뷰 파일 전체를 의미하지 않을 수 있다. 다음 리디자인/일관성 점검 세션은:
1. 이 표를 먼저 읽고 `미착수` 행을 스코프 후보로 제시한다(자동으로 전부 착수하지 않음 — 우선순위는 사용자 확인).
2. `find` 결과 행 수와 이 표의 행 수가 다르면(신규 뷰 파일 추가/삭제) 표를 먼저 동기화하고 진행한다.
3. 신규 뷰 파일을 추가하는 세션은 **같은 커밋에** 이 표에 행을 추가한다(상태: `미착수`).

View File

@ -4,10 +4,13 @@
## 목록
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, freeze 분류 근거 확인, frontend-design fork 패턴) 포함.
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, 매퍼 alias 케이스폴딩, 보안 vacuous PASS 회피) 포함.
- [workflow-patterns.md](./workflow-patterns.md) — 검증(테스트 통과)과 무관한 **작업 진행 방식** 패턴. freeze 분류 근거 확인, frontend-design fork 위임, 다중 UI 안 Artifact 시각비교→AskUserQuestion 선택 수렴 등.
- [frontend-redesign-coverage-checklist.md](./frontend-redesign-coverage-checklist.md) — 뷰 파일(`WEB-INF/views/*.jsp`) 전수 대비 리디자인 반영 상태표. 리디자인/일관성 세션 착수 전 이 표와 실제 파일 목록을 대조해 누락 방지.
- [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
- [local-dev-setup.md](./local-dev-setup.md) — 로컬 구동 환경 설정. 업로드 저장 루트(`~/.bibimbap/uploads`, static 트리 밖) 경로 규약 · @Value 기본값 유지 근거 · 자산 이전 이력 · SSRF DNS 캐시 운영 참고.
- [local-dev-setup.md](./local-dev-setup.md)
- [screenshot-guide.md](./screenshot-guide.md) — Playwright 헤드리스 스크린샷. `python3 docs/development/screenshot-guide.py``/tmp/bibimbap-screenshots/*.png`. Claude Design 등 외부 디자인 툴 연동용. — 로컬 구동 환경 설정. 업로드 저장 루트(`~/.bibimbap/uploads`, static 트리 밖) 경로 규약 · @Value 기본값 유지 근거 · 자산 이전 이력 · SSRF DNS 캐시 운영 참고.
> atp 플러그인 번들 레퍼런스(`agent-team-protocol.md`, `agent-catalog.md`, `documentation-guidelines.md`, `search-tool-matrix.md`)는 플러그인 캐시에 있으며 이 프로젝트로 복사되지 않는다. 에이전트가 `${CLAUDE_PLUGIN_ROOT}/docs/...` 로 직접 참조한다.

View File

@ -31,7 +31,7 @@
docker compose logs -f app
```
반영(이미지 재빌드 0):
- **JSP/정적**: 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). 재시작 불필요.
- **JSP/정적**: 원칙상 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). **단, 반영 안 되는 사례 확인됨** (함정 참조 — 편집 후 `curl` 로 서빙 값이 옛 값이면 즉시반영을 신뢰 말고 `docker compose restart app` 으로 강제 확인).
- **Java 코드**: `docker compose restart app` (spring-boot:run 재기동 ~1~2s). 또는 `docker compose exec app mvn -o -P dev -DskipTests compile` 후 restart.
- 전제: 호스트 `~/.m2` 에 의존성 캐시 존재(offline `-o`). **새 의존성 추가 시**: override 의 `-o` 를 일시 제거해 1회 온라인 받거나, 호스트에서 `./mvnw -P dev dependency:go-offline` 후 다시 offline.
@ -53,10 +53,24 @@
docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기
docker compose -f docker-compose.yml up -d --build app
```
> **⚠ 함정**: `docker compose up --build app`(`-f` 없이)을 로컬 dev 환경에서 실행하면
> override 가 `build: !reset null` 로 base 의 Dockerfile 빌드를 비활성화하므로
> **이미지를 새로 구워도 override 가 그 이미지를 무시**한다.
> 로컬 dev 에서 Dockerfile 재빌드가 필요한 경우는 거의 없으나, 필요하다면 반드시
> `-f docker-compose.yml` 로 override 를 명시적으로 제외한다.
> 로컬 Java 코드 변경 반영은 `docker compose restart app` 으로 충분하다.
>
> 근거: 세션 20260630-105459 — `docker compose up --build` 가 "No services to build" 반환.
Dockerfile 은 멀티스테이지(WAR repackage → `eclipse-temurin:21-jre` + `java -jar`)로 운영과 동일한 산출물을 만든다(프록시 CA·`-P dev`·repackage goal 은 본 문서 함정 참조).
> devtools 자동재시작은 도입하지 않았다 — 컨테이너 `spring-boot:run` 에는 소스 자동 재컴파일러가 없어(IDE 부재) 이득이 작고, `3.5.x-SNAPSHOT` devtools 의 offline 해소가 불안정했다. `restart`/`exec compile` 루프가 더 견고하다.
> **⚠ 함정**: JSP "저장 즉시 반영"이 실패한 사례 확인됨 — CSS 수치 2줄 수정 후 `curl` 로 서빙 값을 대조했더니 옛 값 그대로였다(수정 파일 mtime 은 최신인데 컴파일된 `index_jsp.class` 는 그 이전 시각). 브라우저 fork 로 "정상 반영"이라 잘못 보고한 원인도 이 stale 렌더링을 봤기 때문(false pass). `docker compose restart app` 으로 강제 재기동하니 즉시 해소됨 — 근본 원인은 미확인(Jasper 재검사 간격 또는 Docker Desktop bind-mount mtime 전파 지연 추정)이나, **재현성 있는 회피책은 확정**: JSP 수정 직후엔 "즉시 반영"을 신뢰하지 말고 `curl` 로 실제 서빙 CSS/마크업을 수정값과 대조 → 불일치 시 `docker compose restart app` 후 재대조. 브라우저 스크린샷만으로 검증하면 이 stale 상태를 놓칠 수 있다.
>
> 근거: 세션 20260701-093754.
## 업로드 저장 루트 (static 트리 밖)
업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`).

View File

@ -0,0 +1,80 @@
# 페이지 스크린샷 가이드
앱 실행 중 각 페이지를 파일로 저장하는 방법. Claude Design 등 외부 도구에 전달할 때 사용.
## 전제 조건
- 앱 실행 중 (`docker compose up`)
- Python 3 + playwright 설치됨
```bash
pip3 install playwright
# 브라우저 다운로드 불필요 — 로컬 Chrome 사용
```
## 테스트 계정
| 용도 | 이메일 | 비밀번호 | role |
|---|---|---|---|
| 일반 사용자 | `tester@bibimbap.local` | `test1234!` | USER |
| 관리자 | `admin@bibimbap.local` | `test1234!` | ADMIN |
seed: `db/seed-dev.sql`
## 실행
### 비인증 페이지만 (로그인 불필요)
```bash
python3 docs/development/screenshot-guide.py
```
7장 캡처: 홈(비로그인), 로그인, 회원가입, 게시글 목록, 모집 목록, 게임 상세(비로그인), 이용약관
### 전체 캡처 (로그인 포함 세부 화면)
```bash
# 일반 사용자로 캡처
SCREENSHOT_EMAIL=tester@bibimbap.local SCREENSHOT_PASSWORD='test1234!' \
python3 docs/development/screenshot-guide.py
# 관리자로 캡처 (관리자 전용 UI 확인 시)
SCREENSHOT_EMAIL=admin@bibimbap.local SCREENSHOT_PASSWORD='test1234!' \
python3 docs/development/screenshot-guide.py
```
추가 7장 캡처:
| 파일 | 화면 |
|---|---|
| `08-home-loggedin.png` | 홈 (로그인 후 — 내비게이션 변경) |
| `09-profile.png` | 프로필 페이지 |
| `10-posts-form.png` | 게시글 작성 폼 |
| `11-recruit-form.png` | 모집 작성 폼 |
| `12-game-detail-loggedin.png` | 게임 상세 (로그인 후 전체 뷰) |
| `13-game-review-form.png` | 리뷰 작성 폼 (노출 상태) |
| `14-game-comment-form.png` | 댓글 작성 폼 (노출 상태, 섹션 포커스) |
## 캡처 전략
**비인증 → 인증 비교**: 01·06번(비인증)과 08·12번(인증) 쌍으로 로그인 전후 차이를 비교할 수 있다.
**폼 상태 강제 노출**: 리뷰/댓글 composer는 서버 API 응답(로그인 상태, 기존 리뷰 여부) 후 JS가 제어한다. 스크립트는 3초 내 자연 노출을 기다리고, 실패하면 `removeAttribute('hidden')`으로 강제 노출해 캡처한다. 실제 동작과 동일한 HTML 구조를 캡처한다.
## 출력 위치
```
/tmp/bibimbap-screenshots/*.png
```
재부팅 시 삭제됨. 영구 저장 필요 시 `OUT_DIR` 변수 변경.
## 주의
- 헤드리스 모드는 다크 테마 미적용 (시스템 prefers-color-scheme 무시)
- Claude Design에 넘길 때 "다크 테마로 리디자인" 프롬프트에 명시
- 로그인 실패(잘못된 계정 정보) 시 인증 페이지는 건너뛰고 비인증 7장만 저장됨
## 변경 이력
- [2026-06-30 인증 흐름 추가](../changes/2026-06-30-screenshot-guide-auth.md) — 비인증 7장 → 최대 14장, Playwright 로그인 + hidden 폴백 전략

View File

@ -0,0 +1,135 @@
"""
bibimbap 스크린샷 캡처 스크립트
비인증 페이지 + 인증 세부 화면 상태 모두 캡처.
사용법:
# 비인증 페이지만
python3 docs/development/screenshot-guide.py
# 인증 포함 전체 (로그인 계정 필요)
SCREENSHOT_EMAIL=user@example.com SCREENSHOT_PASSWORD=pw \
python3 docs/development/screenshot-guide.py
"""
import os
import sys
from playwright.sync_api import sync_playwright
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
OUT_DIR = "/tmp/bibimbap-screenshots"
BASE_URL = "http://localhost:8080"
EMAIL = os.environ.get("SCREENSHOT_EMAIL", "")
PASSWORD = os.environ.get("SCREENSHOT_PASSWORD", "")
# 비인증 페이지
ANON_PAGES = [
("/", "01-home-anon.png"),
("/login", "02-login.png"),
("/signup", "03-signup.png"),
("/posts", "04-posts-list.png"),
("/recruit", "05-recruit-list.png"),
("/game/3", "06-game-detail-anon.png"),
("/terms", "07-terms.png"),
]
os.makedirs(OUT_DIR, exist_ok=True)
def capture(page, path, filename, *, full_page=True):
page.goto(f"{BASE_URL}{path}", wait_until="networkidle")
page.screenshot(path=os.path.join(OUT_DIR, filename), full_page=full_page)
print(f"{filename}")
def do_login(page):
"""로그인 폼 제출 후 세션 획득. CSRF 토큰은 폼에서 추출."""
page.goto(f"{BASE_URL}/login", wait_until="networkidle")
page.fill("input[name='email']", EMAIL)
page.fill("input[name='password']", PASSWORD)
with page.expect_navigation(timeout=10000):
page.click("button[type='submit']")
if "/login" in page.url:
raise RuntimeError(f"로그인 실패 — 현재 URL: {page.url}")
def capture_review_form(page):
"""게임 상세 — 리뷰 작성 폼 노출 상태."""
page.goto(f"{BASE_URL}/game/3", wait_until="networkidle")
# JS가 로그인 상태 확인 후 composer를 보여줄 때까지 대기 (최대 3초)
try:
page.wait_for_function(
"document.getElementById('game-review-composer') && "
"!document.getElementById('game-review-composer').hasAttribute('hidden')",
timeout=5000,
)
except Exception:
# JS가 숨긴 채로 두면 강제 노출 (스크린샷 전용)
page.evaluate(
"document.getElementById('game-review-composer')?.removeAttribute('hidden')"
)
page.screenshot(
path=os.path.join(OUT_DIR, "13-game-review-form.png"), full_page=False
)
print(" ✓ 13-game-review-form.png")
def capture_comment_form(page):
"""게임 상세 — 댓글 작성 폼 노출 상태."""
page.goto(f"{BASE_URL}/game/3", wait_until="networkidle")
try:
page.wait_for_function(
"document.getElementById('game-comment-composer') && "
"!document.getElementById('game-comment-composer').hasAttribute('hidden')",
timeout=5000,
)
except Exception:
page.evaluate(
"document.getElementById('game-comment-composer')?.removeAttribute('hidden')"
)
# 댓글 섹션으로 스크롤
page.evaluate(
"document.getElementById('game-comment-composer')?.scrollIntoView({block:'center'})"
)
page.screenshot(
path=os.path.join(OUT_DIR, "14-game-comment-form.png"), full_page=False
)
print(" ✓ 14-game-comment-form.png")
with sync_playwright() as p:
browser = p.chromium.launch(
executable_path=CHROME,
headless=True,
args=["--no-sandbox"],
)
page = browser.new_page(viewport={"width": 1400, "height": 900})
print("── 비인증 페이지 ──────────────────────────────")
for path, filename in ANON_PAGES:
capture(page, path, filename)
if EMAIL and PASSWORD:
print("\n── 로그인 후 세부 화면 ─────────────────────────")
try:
do_login(page)
print(" ✓ 로그인 성공")
capture(page, "/", "08-home-loggedin.png")
capture(page, "/profile", "09-profile.png")
capture(page, "/posts/new", "10-posts-form.png")
capture(page, "/recruit/new", "11-recruit-form.png")
capture(page, "/game/3", "12-game-detail-loggedin.png")
capture_review_form(page)
capture_comment_form(page)
except Exception as e:
print(f" ✗ 오류: {e}")
print(" → 인증 페이지 캡처 건너뜀", file=sys.stderr)
else:
print("\n※ SCREENSHOT_EMAIL / SCREENSHOT_PASSWORD 미설정 — 비인증 페이지만 캡처됨")
print(" 전체 캡처: SCREENSHOT_EMAIL=... SCREENSHOT_PASSWORD=... python3 docs/development/screenshot-guide.py")
browser.close()
print(f"\n완료: {OUT_DIR}/")

View File

@ -25,6 +25,7 @@
| MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) |
| 인프라 설정 (container/env/compose) | L1 + 수동 스모크 |
| HTTP 상태코드/예외 매핑 변경 (404/403 등, 전역 `@ExceptionHandler` 경유) | L1 + 런타임 스모크 |
| SQL data-only 변경 (DML: seed/백필, 스키마·코드 무변경) | L1 해당없음(명시) + L2(대상 API/DB 조회로 값 일치 확인) |
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
@ -147,29 +148,6 @@ strategies:
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그.
### freeze/동결 분류는 근거 문서 확인 선행
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
절차:
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기.
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
### (긍정 패턴) frontend-design 스킬 fork 위임
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
조건:
- L1 전체 GREEN 확인 후 진입.
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
### 신규 `*Test.java` 는 "(검증)" 소유태그여도 구현 산출물
design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음).
@ -212,12 +190,6 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
> 근거: W3-3 SSRF — 설계 concern 이 "IP핀닝 또는 connect후 peer검증" 을 열어둠 → 1차가 IP핀닝(Host override) 선택, HTTPS SNI 깨짐 + restricted-header 미검증. fix1 에서 option-b(hostname-connect)로 전환.
### needs_user_verification 은 "미완료 목록" 이 아니라 "결정 분기" 단위로 구조화
세션 종료 시 `needs_user_verification` 을 단순 잔여 작업 나열로 적으면, 다음 세션이 각 항목마다 "어떻게 할까요" 재질의로 시작한다. 대신 각 항목을 **사용자가 한 번에 고를 수 있는 결정 분기**(예: 직접 적용 / 지금 구현 / 배포 후 이월 / 추가 하드닝)로 미리 구조화하면, 다음 세션 착수 시 결정이 1라운드에 수렴하고 재질의가 0이 된다. 항목마다 "기본 가정값 + 영향 범위" 를 병기한다(§2.2 open_questions 규약과 정합).
> 근거: 세션 20260629-142115 — 직전 세션이 needs 4건을 결정 분기로 명시 → 사용자 4건 일괄 결정, 재질의 0, 모든 분기 첫 라운드 수렴.
### 런타임/환경 의존 보장은 L1(값 확인)과 L3(실반영) 레이어로 분리
설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")``InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다.
@ -226,6 +198,36 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
---
### MyBatis + PostgreSQL nullable 파라미터 명시 캐스트 규약
MyBatis annotation SQL(`@Select`)에서 nullable 파라미터를 `#{p} IS NULL` 조건으로 쓸 때, PostgreSQL은 prepared statement `$N`의 타입을 추론하지 못해 `PSQLException: could not determine data type of parameter $1` → 500이 발생한다. 컴파일·L1 단위테스트(MockBean)로는 탐지되지 않는다.
**규약:**
```sql
-- ❌ 잘못된 패턴 — null 전달 시 PSQLException
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
-- ✅ 올바른 패턴 — 명시 캐스트
AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
```
nullable 파라미터 타입별 캐스트:
| Java 타입 | PostgreSQL 캐스트 |
|---|---|
| `Long` / `Integer` | `::bigint` / `::int` |
| `OffsetDateTime` / `LocalDateTime` | `::timestamptz` / `::timestamp` |
| `String` | `::text` |
**대안**: `<if test="p != null">` 동적 XML 분기로 null 케이스를 조건 자체에서 제거(JamsMapper/GamesMapper keyset 패턴). 신규 keyset 페이징 매퍼 작성 시 이 패턴을 우선 권장한다.
**신규 매퍼 코드리뷰 체크**: `rg '#{[^}]+}\s+IS\s+NULL' src/main/java --type java` 로 타입 캐스트 없는 IS NULL 조건을 전수 확인한다.
> 근거: 세션 20260630-105459 — `PostsMapper.listPublishedKeyset` `#{categoryId} IS NULL` 캐스트 누락 → `GET /posts` 500.
---
## 프로토콜 개선 권고 (외부 번들 — 미적용)
아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
@ -239,6 +241,7 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
- **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS.
- **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭.
- **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택.
- **§4.4 item5 를 *선행 게이트* 로 강화**: 현재 item5(가시성·표현·레이아웃 축 누락 점검)는 옵션 *작성 후* 사후 점검이라, "비율/가시성 떨어진다" 같은 **모호 어휘를 옵션 설계 이전에 단일 축으로 협소화**하면 가드가 작동할 시점을 이미 지난다. 모호 시각 결함 어휘 수신 시 옵션 축 확정 *전에* 다축 스캔(레이아웃비율·종횡비·대비가시성·타이포 최소 4축)을 의무화하는 선행 게이트로 승격 권고. 근거: 세션 20260630-175023 — "비율"을 카드 종횡비 단일 축으로 조기 협소화 → 사용자가 "검색바도 혼자 짧다"로 레이아웃 비율 축 직접 추가.
### HTTP 상태/예외 매핑 변경은 런타임 스모크 의무 (L1 통과로 충분치 않음)
@ -251,3 +254,27 @@ SSRF·zip-slip·경로 boundary·sanitize 같은 보안핵심은 verification-ad
implementation-advisor 가 여러 code-writer worker 로 파일을 분산 수정한 뒤 반환하는 완료 summary 는 worker 상태 추적 혼선으로 **실제 변경과 불일치**할 수 있다("Worker C만 완료" 보고했으나 실제 4파일 적용 등). orchestrator 는 advisor summary 를 그대로 신뢰하지 말고 `git diff --stat` / 대상 파일 grep 으로 **실변경을 교차검증**한 뒤 다음 단계로 진행한다.
> 근거: B2·B4·FE 세션(20260629) — FE implementation-advisor 가 불완전 summary 반환, git diff 로 4파일 적용 확인 후 진행. profile 미변경은 "입력 컨트롤 없음=정상"으로 판별.
### graphify 스캔 범위 — JSP/CSS/JS 정적 자산 미포함
graphify(`/graphify src/ docs/`)는 `src/main/webapp/WEB-INF/views/*.jsp`, `src/main/webapp/css/`, `src/main/webapp/js/` 등 정적 자산을 스캔 범위에 포함하지 않는다. 프론트엔드 분석 태스크에서 graphify-lookup-advisor가 miss를 반환하는 것은 예상 결과다 — research-advisor 직접 탐색으로 대체한다.
> 근거: 세션 20260630-113258 — graphify-lookup miss 즉시 확인, research-advisor로 전환해 JSP 26개 전수 탐색 성공.
### JSP 변경은 WAR 빌드 PASS만으로 불충분 — 런타임 스모크 의무
이 프로젝트는 JSP 사전컴파일이 미설정(Tomcat이 첫 요청 시 런타임 컴파일)이라, `./mvnw package` exit 0 이어도 JSP EL 표현식·taglib 선언 오류는 **런타임에만 발현**한다. WAR 빌드는 JSP를 파일로 패키징만 하고 컴파일하지 않으므로 L1 GREEN 은 "JSP 구문 유효" 보증이 아니다. JSP 파일이 변경된 모든 세션은 verification 단계에 **런타임 스모크**(앱 기동/재기동 후 변경 JSP 경로 `curl -w '%{http_code}'` 200 확인)를 명시한다. 특히 외부 디자인 산출물의 마크업을 이식할 때 stray taglib/EL 이 딸려오기 쉬우므로 implementation 체크리스트에 grep 전수 확인을 포함한다: `rg '<%@\s*taglib' src/main/webapp/WEB-INF/views/` (이 프로젝트 뷰는 JSTL 미사용·scriptlet 전략 — taglib 출현 자체가 적신호). §273(예외매핑) 의 일반화 버전이다. JSP 사전컴파일 플러그인(jspc) 도입은 별도 open item.
> 근거: 세션 20260630-160000 — 비주얼 리디자인 이식 중 errer.jsp에 디자인서 딸려온 구 javax JSTL taglib(`http://java.sun.com/jsp/jstl/core`)이 stray 유입 → mvn package PASS·다른 페이지 200 이나 `/error` 만 런타임 500(JasperException, Jakarta 미해소). L2 스모크에서 포착 → 미사용 taglib 제거로 200 (fix 360078a).
### 정적 include 파편(.jspf)은 자체 `pageEncoding` 필수 — 부모 인코딩 미상속
`<%@ include file="x.jspf" %>`(정적/translation-time include)로 삽입되는 파편은 **부모 JSP의 `pageEncoding`을 상속하지 않는다.** JSP 스펙상 정적 include 세그먼트는 파일별로 인코딩이 독립 결정되며, 파편에 page 지시자가 없으면 기본 ISO-8859-1로 읽힌다. UTF-8 한글 바이트를 가진 파편이 부모(UTF-8)에 include 돼도 Jasper가 Latin-1로 읽어 **mojibake**(`ì ì§ ë...`)가 된다. 본문 텍스트가 있는 .jspf는 첫 줄에 `<%@ page pageEncoding="UTF-8" %>`를 둔다(같은 값이면 부모와 충돌 없음). 런타임 스모크에서만 발현하므로 브라우저 육안 확인이 필요하다. (참고: `<jsp:include>` 런타임 include는 별도 translation unit이라 자체 contentType으로 정상 — theme-init/header/footer가 그 경로.)
> 근거: 세션 20260630-160000 후속 브라우저 리뷰 — posts-empty/recruit-empty.jspf(UTF-8)가 정적 include 되며 빈상태 한글 전부 mojibake. 빌드 PASS·페이지 200이라 빌드/문자열검증으론 미포착, 브라우저에서 발견 → 각 파편에 pageEncoding 추가로 해소 (fix a4d163d).
### 정적 `.html`(DefaultServlet 서빙)도 `<meta charset="utf-8">` 필수 — §299 의 정적파일 짝
§299 는 `.jspf` 정적 include 의 `pageEncoding` 을 다루지만, **DefaultServlet 이 서빙하는 순수 `.html`** 도 동일 mojibake 함정이 있다. Tomcat 은 정적 `.html``Content-Type: text/html`(charset 없음)만 붙이므로, 파일에 `<meta charset="utf-8">` 가 없으면 브라우저가 Latin-1 로 추정해 UTF-8 한글이 깨진다. 앱 정적경로에 두는 프리뷰·정적 페이지는 `<head>``<meta charset="utf-8">` 를 반드시 포함한다.
> 근거: 세션 20260701-083240 — 카드 프리뷰 정적 html 을 `/css/` 서빙했으나 meta charset 누락으로 전체 한글 mojibake, 사용자 스샷으로 표면화 → meta 추가로 즉시 해소. 변경 상세: [changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md](../changes/2026-06-30-ui-multiaxis-ratio-visibility-fix.md).

View File

@ -0,0 +1,76 @@
# Workflow Patterns — 작업 진행 방식
`verification-strategies.md` 는 "코드가 옳은가"(L1/L2/L3 검증)를 다룬다. 이 문서는 그와 무관하게 **사람과 어떻게 합의하며 작업을 진행하는가** — 결정 수렴 절차, 산출물 제시 방식 — 를 다룬다. 재사용 가능한 것만 등재한다.
## freeze/동결 분류는 근거 문서 확인 선행
어떤 변경을 "동결 영역 해제·고위험 게이트"로 분류하기 전에, 동결 범위를 정의한 **근거 문서(ADR·roadmap·work-log)를 줄 번호까지 직접 확인**한다. 표면적 유사성("평점 집계" 등)만으로 freeze 인접 추론 금지.
절차:
1. 동결 선언 근거 문서를 실제로 열어 동결 범위 정의를 줄 번호로 확인.
2. 변경 대상 테이블/뷰/심볼이 그 범위에 **명시적으로** 포함되는지 판단.
3. 포함 확인 시만 §6 게이트 표기.
> 근거: W3-2 고도화 세션(20260622) — `game_review_stats` 집계뷰가 phantom 고위험 게이트로 오분류 → `roadmap:63/201/202` 직접 확인으로 일반 DDL 정정.
## (긍정 패턴) frontend-design 스킬 fork 위임
production-grade UI(SVG·a11y·다중 JS 인터랙션 포함)를 구현할 때, `frontend-design` 스킬을 **fork(컨텍스트 상속)로 서브에이전트에 위임**하면 스킬 호출 + 단일파일 폴리시 + 프리뷰 render-verify 를 컨텍스트 오염 없이 수행 가능하다. 검증된 패턴.
조건:
- L1 전체 GREEN 확인 후 진입.
- 단일파일 폴리시: JSP 1파일 안에서 완결(신규 파일 0).
- 가드레일 명시: 기존 JS 로직 보존 / a11y / BE API 계약 무변경 / 외부 JS 라이브러리·CDN 도입 금지.
- 산출물에 프리뷰 HTML 포함(`artifacts/`).
> 근거: W3-2 고도화 세션(20260622) — 육각형 SVG 레이더·6축 radiogroup·C1~C6 를 fork 위임으로 단일 JSP 파일 승격 + L1 43/43 GREEN 유지.
## (긍정 패턴) 다중 UI 안 Artifact 시각비교 → AskUserQuestion 선택 수렴
레이아웃·배치처럼 "여러 방식이 다 타당한" UI 결정은, 구현 전에 실제 프로젝트 CSS 토큰(색상·폰트·기존 클래스명)을 그대로 이식한 정적 Artifact(HTML)로 후보 N안을 한 화면에 나란히 렌더해 사용자가 비교하게 하고, `AskUserQuestion` 으로 확정받은 뒤에만 소스에 반영한다. `/task` §5.0 계획가시성 의무를 산문 대신 **눈으로 보는 산출물**로 충족하는 변형.
절차:
1. 실제 프로젝트 색상/폰트/컴포넌트 클래스를 `grep`/`Read` 로 확인해 Artifact 에 그대로 이식 — 가짜 톤 금지(재현 신뢰도가 선택 신뢰도를 결정).
2. 결정축이 여러 개면(예: "요약 영역" + "카드 영역") 섹션을 분리하고 각 섹션 안에서만 후보를 나열 — 축을 섞으면 조합폭발.
3. 후보에 A/B/C/D 같은 안정적 레터를 부여해 이후 `AskUserQuestion` 옵션 라벨과 1:1 대응 — 참조 혼동 방지.
4. Artifact 자체의 라디오/버튼 선택은 눈요기일 뿐 상태를 orchestrator 로 되돌리지 못한다 — 실제 확정은 반드시 `AskUserQuestion`(같은 레터 옵션)으로 받는다.
5. 확정 전 단계는 소스 변경 0건이므로 verification-advisor/L1 게이트 대상이 아니다 — §9 종료조건 미적용, 세션은 사용자 응답 대기로 열어둔 채 진행.
> 근거: 세션 20260701-113509 — game-detail.jsp 리뷰 영역(요약 그래프 vs 그래프+범례, 카드 미터바 배치 4안)을 Artifact 로 제시 → `AskUserQuestion` 1회로 즉시 확정(재작업 요청 0) → 반영 커밋 `0c8da40`.
## (긍정 패턴) 시각 디자인 결정은 텍스트 diff 대신 앱 정적경로 프리뷰로 공동 확인
CSS 위계·여백·색 같은 시각 변경은 before→after **텍스트 표**로 제시해도 사용자가 결과를 예측하기 어렵다. 실제 토큰·CSS 로 렌더한 정적 프리뷰(현재/변경안 나란히, 라이트·다크·상태별)를 만들어 **앱의 화이트리스트 정적경로**(이 프로젝트: `src/main/webapp/css/`)에 두면 `:8080/css/<preview>.html` 로 서빙되어(재시작 불요, DefaultServlet 직접 서빙) 사용자는 브라우저 새로고침으로, 에이전트는 동일 URL 스크린샷으로 **공동 확인**한다. private 파일 전송(SendUserFile)은 사용자가 텍스트로만 볼 수 있는 경우가 있어 시각 비교엔 부적합. 결정 후 프리뷰 파일은 삭제(커밋 금지). 주의: top-level 경로(`/preview.html`)는 보안필터가 302 리다이렉트하므로 화이트리스트 정적 prefix 아래 둔다.
위 "다중 UI 안 Artifact 시각비교" 패턴의 자매 변형이다 — Artifact 는 별도 호스팅 URL, 이 패턴은 **앱 자체 정적경로**를 프리뷰 서버로 재사용한다. 앱 정적경로가 화이트리스트로 이미 열려 있고 재시작 없이 서빙 가능할 때 이 변형이 더 빠르다.
> 근거: 세션 20260701-083240 — 카드 위계 변경안 A/B 를 텍스트 표로 제시하자 "어떻게 변할지 예측 어렵다" 피드백 → 앱 `/css/` 프리뷰 서빙으로 전환, 사용자가 B 를 1라운드 수락.
## 모호한 시각 결함 어휘는 단일 결정축 협소화 전 다축 스캔 선행
"비율이 떨어진다 / 가시성이 떨어진다" 같은 **모호한 시각 결함 어휘**는 결함의 위치를 한정하지 않는다. 이를 곧장 단일 결정축(예: "비율" → 카드 종횡비)으로 좁혀 `AskUserQuestion` 을 던지면, 같은 어휘가 가리키던 다른 축(레이아웃 비율·정렬·대비·표현 총량)이 옵션 공간에서 누락된다. 모호 시각 어휘 수신 시 옵션 축을 확정하기 **전에** 다축 스캔(최소 레이아웃비율·종횡비·대비가시성·타이포간격 4축)을 선행한다. 스크린샷이 있으면 관찰 1 에이전트 → 축별 병렬 평가 → 종합의 multi-axis 위임(`ui-multiaxis-eval` 패턴)이 협소화 맹점을 구조적으로 메운다. UI/UX 변경 검증은 위 "시각 디자인 결정 프리뷰 공동확인" 패턴과 함께, **라이트/다크 양 테마 × 영향 화면 전수** 육안으로 수행한다(토큰 대비·종횡비는 테마별로 다르게 발현).
> 근거: 세션 20260630-175023 — 사용자 지적 "비율/가시성" 2건을 카드 종횡비 단일 축으로 협소화 → 사용자가 "검색바도 혼자 짧다"로 레이아웃 비율 축 직접 추가. 이후 multi-axis 위임 평가(6 에이전트)가 13건 결함으로 복원, 라이트/다크 양화면 스모크로 검증.
## "전 페이지 반영" 서술은 실제 파일 전수와 대조 필요 — 리디자인 대상 체크리스트 선행
"전 페이지 리디자인" 같은 세션 요약은 **작업 당시 작성자가 열거한 목록**을 뜻할 뿐, `find`로 뽑은 실제 뷰 파일 전수와 항상 일치하지 않는다. 과거 `docs/changes/*.md` 서술만 신뢰하고 다음 세션을 시작하면, 그 목록에 없던 파일(신규 추가분·의도적 스코프 제외분 모두 포함)이 조용히 계속 빠진 채로 남는다.
절차:
1. 리디자인/시각 일관성류 작업 착수 전 `docs/development/frontend-redesign-coverage-checklist.md` 를 읽고 `find src/main/webapp/WEB-INF/views -maxdepth 1 -name "*.jsp"` 실제 파일 수와 표 행 수를 대조한다.
2. 불일치(신규 파일 미등재) 시 표를 먼저 동기화한 뒤 진행한다.
3. 신규 뷰 파일을 추가하는 세션은 같은 커밋으로 그 표에 행을 추가한다(상태 `미착수`) — 다음 리디자인 세션이 처음부터 전수 파악하지 않아도 되게 한다.
> 근거: 세션 20260701 — `game-register.jsp`(`/game/new`) 가 2026-06-30 9단위 리디자인 서술에 없어 사용자가 직접 지적할 때까지 누락 상태로 남음. 체크리스트 부재가 원인.
## needs_user_verification 은 "미완료 목록" 이 아니라 "결정 분기" 단위로 구조화
세션 종료 시 `needs_user_verification` 을 단순 잔여 작업 나열로 적으면, 다음 세션이 각 항목마다 "어떻게 할까요" 재질의로 시작한다. 대신 각 항목을 **사용자가 한 번에 고를 수 있는 결정 분기**(예: 직접 적용 / 지금 구현 / 배포 후 이월 / 추가 하드닝)로 미리 구조화하면, 다음 세션 착수 시 결정이 1라운드에 수렴하고 재질의가 0이 된다. 항목마다 "기본 가정값 + 영향 범위" 를 병기한다(§2.2 open_questions 규약과 정합).
> 근거: 세션 20260629-142115 — 직전 세션이 needs 4건을 결정 분기로 명시 → 사용자 4건 일괄 결정, 재질의 0, 모든 분기 첫 라운드 수렴.
## `needs_user_verification` 이월 시 최근 세션의 렌더 관련 known pitfall 교차 인용
`needs_user_verification` 으로 브라우저 육안 확인을 이월할 때, 변경 대상이 최근 세션에서 docs 화된 렌더링 함정과 같은 경로(JSP/정적 asset)를 공유하면 그 문서를 명시적으로 인용한다. 단순히 "브라우저에서 육안 확인" 이라고만 적으면, 다음 세션/사용자가 이미 알려진 함정(예: JSP stale 렌더링 — `local-dev-setup.md` §JSP/정적)을 다시 밟고도 브라우저 화면만 보고 오탐(false pass) 할 수 있다. 작성 규칙: 최근 work-session 3~5개 이내 docs 반영된 구조적 함정 중 렌더 경로가 겹치는 것이 있으면 `needs_user_verification` 항목에 "known pitfall: `<문서 링크>` — curl 로 서빙값 대조 선행 권고" 를 병기한다.
> 근거: 세션 20260701-100731 — game-detail.jsp 레이더 차트 데이터(6축 axis) 백필 세션. 직전 세션(20260701-093754, 4분 전 종료)이 방금 "JSP 저장 즉시반영 실패 → 브라우저가 stale 렌더링을 정상으로 오판" 함정을 `local-dev-setup.md` 에 반영했음에도, 같은 렌더 경로(JSP)를 다루는 후속 세션의 `needs_user_verification` 이 이를 인용하지 않아 재발 위험을 남김(retrospective-advisor 포착).

View File

@ -1,7 +1,7 @@
---
kind: graphify-meta
last_generated_at: 2026-06-30T00:00:00+0900
source_commit: 2c48184
last_generated_at: 2026-07-01T14:35:00+0900
source_commit: 86b0528
scopes:
- full
---
@ -39,7 +39,7 @@ scope 예시: `full`, `src`, `docs`, `src-features` 등.
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
| --- | --- | --- | --- | --- |
| `full` | 2026-06-30 | `2c48184` | `src/` + `docs/` | **src+docs 통합** (merge-graphs, 2038노드/4685엣지). 기존 `src` scope(AST 1840노드/4403엣지, Java 클래스·메서드·주입엣지) + `docs` scope(semantic 198노드/282엣지, 설계문서·DDL·변경이력)를 단일 그래프로 통합. src↔docs 교차 탐색 가능. 직전 src 상태: B2 dead code 제거 + 없는 게임 404 반영 (31dc2ff). 직전 docs 상태: W3/W4 스키마·변경이력 반영 (305cc73). |
| `full` | 2026-07-01 | `86b0528` | `src/` + `docs/` | **incremental update** — 18커밋분 변경(11 파일: db/seed-dev.sql + docs 10건) 재추출 후 `build_merge` 병합 시 고스트 중복 486 exact + 251 fuzzy 추가 제거(2089→1477 노드, 4503 엣지, 76 커뮤니티). game-register.jsp 리디자인 보강·frontend-redesign-coverage-checklist·workflow-patterns 신규 노드 반영. JSP/CSS 정적자산은 여전히 스캔 범위 외. |
## 갱신 시 체크리스트

View File

@ -181,3 +181,20 @@
- [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다.
- [ ] XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다.
- [ ] 권한: 게임 수정/삭제, 댓글 삭제, 프로필 변경은 소유자 또는 로그인 조건을 확인한다.
## B5. recruit-form.jsp CSRF 폴백 갭 (P0-보안)
근거:
- `BibimbapCsrf` 미존재(비로그인 등) 시 fetch 폴백 브랜치에서 CSRF 토큰이 헤더에 포함되지 않는다. `src/main/webapp/WEB-INF/views/recruit-form.jsp:386-394`
- hidden `_csrf` input 부재 — form submit fallback도 보호 없음.
체크리스트:
- [ ] `recruit-form.jsp``<input type="hidden" name="_csrf" value="...">` 추가한다.
- [ ] fetch 폴백 브랜치 headers에 `'X-CSRF-Token': csrfToken` 추가한다.
- [ ] 수정 후 CSRF 없는 POST가 거부되는 회귀 테스트를 추가한다.
완료 조건:
- [ ] `BibimbapCsrf` 미존재 시에도 recruit-form POST에 CSRF 토큰이 전송된다.
- [ ] 토큰 없는 요청이 403으로 거부된다.
> 발견: 세션 20260630-113258 JSP 프론트엔드 컴포넌트 검토.

19
pom.xml
View File

@ -294,6 +294,25 @@
</plugins>
</build>
</profile>
<profile>
<id>nvd-api-key</id>
<activation>
<property>
<name>env.NVD_API_KEY</name>
</property>
</activation>
<build>
<plugins>
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<configuration>
<nvdApiKey>${env.NVD_API_KEY}</nvdApiKey>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>

View File

@ -41,10 +41,10 @@ public interface PostsMapper {
JOIN post_categories c ON c.id = p.category_id
WHERE p.status = 'PUBLISHED'
AND p.is_delete = false
AND (#{categoryId} IS NULL OR p.category_id = #{categoryId})
AND (#{categoryId}::bigint IS NULL OR p.category_id = #{categoryId}::bigint)
AND (
#{cursorCreatedAt} IS NULL
OR (p.created_at, p.id) < (#{cursorCreatedAt}, #{cursorId})
#{cursorCreatedAt}::timestamptz IS NULL
OR (p.created_at, p.id) < (#{cursorCreatedAt}::timestamptz, #{cursorId}::bigint)
)
ORDER BY p.created_at DESC, p.id DESC
LIMIT #{limit}
@ -119,7 +119,8 @@ public interface PostsMapper {
@Update("""
UPDATE posts
SET title = #{title},
SET category_id = #{categoryId},
title = #{title},
body_markdown = #{bodyMarkdown},
body_sanitized_html = #{bodySanitizedHtml},
link_url = #{linkUrl},

View File

@ -67,81 +67,42 @@
align-items: center;
justify-content: center;
}
.error-panel {
width: min(100%, 38rem);
padding: 2rem;
box-sizing: border-box;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
box-shadow: 0 2px 8px var(--card-shadow);
/* empty-state */
.empty-state {
text-align: center;
padding: 48px 24px;
max-width: 480px;
margin: 0 auto;
}
.error-panel__media {
width: 8rem;
height: 8rem;
margin: 0 auto 1.5rem;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(160deg, var(--card-media-1) 0%, var(--card-media-2) 45%, var(--card-media-3) 100%);
}
.error-panel__logo {
width: 5rem;
height: auto;
object-fit: contain;
opacity: 0.9;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
}
html[data-theme="dark"] .error-panel__logo {
opacity: 0.94;
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}
.error-panel__eyebrow {
margin: 0 0 0.5rem;
font-size: 0.8125rem;
.empty-state svg { display: block; margin: 0 auto; }
.empty-state .code {
display: block;
font-size: 12px;
font-weight: 700;
letter-spacing: .1em;
color: var(--accent);
letter-spacing: 0.02em;
margin-top: 16px;
}
.error-panel__title {
margin: 0;
font-size: clamp(1.75rem, 4vw, 2.5rem);
line-height: 1.2;
letter-spacing: 0;
.empty-state h3 {
font-size: 1.35rem;
font-weight: 800;
color: var(--text);
margin: 8px 0 0;
}
.error-panel__description {
max-width: 28rem;
margin: 1rem auto 0;
.empty-state p {
color: var(--text-muted);
font-size: 0.9375rem;
line-height: 1.65;
color: var(--text-muted);
margin: 12px 0 0;
}
.error-panel__meta {
margin: 1rem auto 0;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.025);
font-size: 0.8125rem;
line-height: 1.5;
text-align: left;
word-break: break-word;
}
html[data-theme="dark"] .error-panel__meta {
background: rgba(255, 255, 255, 0.04);
}
.error-panel__actions {
.empty-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.625rem;
margin-top: 1.5rem;
gap: 10px;
margin-top: 24px;
}
.error-panel__button {
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
@ -157,29 +118,42 @@
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.error-panel__button--primary {
.btn:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 12px var(--card-shadow);
}
.btn:active { transform: scale(0.98); }
.btn-primary {
border-color: transparent;
color: var(--button-text);
background: var(--accent);
}
.error-panel__button:hover {
border-color: rgba(232, 165, 75, 0.45);
box-shadow: 0 4px 12px var(--card-shadow);
.btn-ghost {
border-color: var(--border);
background: transparent;
}
.error-panel__button:active {
transform: scale(0.98);
/* error meta detail */
.error-meta {
margin: 1rem auto 0;
padding: 0.75rem 1rem;
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-muted);
background: rgba(0, 0, 0, 0.025);
font-size: 0.8125rem;
line-height: 1.5;
text-align: left;
word-break: break-word;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
html[data-theme="dark"] .error-meta {
background: rgba(255, 255, 255, 0.04);
}
@media (max-width: 480px) {
.page-main {
align-items: flex-start;
padding-top: 1.5rem;
}
.error-panel {
padding: 1.5rem 1rem;
}
.error-panel__button {
width: 100%;
}
.page-main { align-items: flex-start; padding-top: 1.5rem; }
.btn { width: 100%; }
}
</style>
</head>
@ -193,25 +167,53 @@
if (message == null || String.valueOf(message).isBlank()) {
message = exception != null ? exception.getMessage() : null;
}
int code = (statusCode != null) ? (Integer) statusCode : 0;
%>
<main class="page-main">
<section class="error-panel" aria-labelledby="error-title">
<div class="error-panel__media" aria-hidden="true">
<img class="error-panel__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
<div class="empty-state">
<% if (code == 403) { %>
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
<rect x="24" y="38" width="38" height="30" rx="6" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
<path d="M31 38v-7a12 12 0 0 1 24 0v7" fill="none" stroke="#D4A853" stroke-width="2.5"/>
<circle cx="43" cy="50" r="4" fill="#D4A853"/>
<line x1="43" y1="54" x2="43" y2="60" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="code">ERROR 403</span>
<h3>로그인이 필요한 페이지예요</h3>
<p>이 작업은 로그인한 회원만 가능합니다. 로그인 후 다시 시도해 주세요.</p>
<div class="empty-actions">
<a class="btn btn-primary" href="<%= ctx %>/login">로그인</a>
<a class="btn btn-ghost" href="<%= ctx %>/">홈으로</a>
</div>
<% } else if (code == 404) { %>
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
<circle cx="40" cy="40" r="20" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
<line x1="54" y1="54" x2="68" y2="68" stroke="#D4A853" stroke-width="3" stroke-linecap="round"/>
<line x1="33" y1="40" x2="47" y2="40" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
</svg>
<span class="code">ERROR 404</span>
<h3>페이지를 찾을 수 없어요</h3>
<p>주소가 바뀌었거나 삭제된 페이지일 수 있어요. 주소를 확인하거나 홈으로 돌아가 주세요.</p>
<div class="empty-actions">
<a class="btn btn-primary" href="<%= ctx %>/">홈으로</a>
<a class="btn btn-ghost" href="javascript:history.back()">이전 페이지</a>
</div>
<p class="error-panel__eyebrow">
<% if (statusCode != null) { %>
ERROR <%= statusCode %>
<% } else { %>
ERROR
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true">
<circle cx="43" cy="43" r="24" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
<line x1="43" y1="32" x2="43" y2="46" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="43" cy="53" r="2" fill="#D4A853"/>
</svg>
<span class="code"><% if (statusCode != null) { %>ERROR <%= statusCode %><% } else { %>ERROR<% } %></span>
<h3>페이지를 불러오지 못했습니다</h3>
<p>요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도하거나 홈으로 돌아가 다른 메뉴를 이용해 주세요.</p>
<div class="empty-actions">
<a class="btn btn-primary" href="<%= ctx %>/">홈으로 이동</a>
<a class="btn btn-ghost" href="javascript:history.back()">이전 페이지</a>
</div>
<% } %>
</p>
<h1 class="error-panel__title" id="error-title">페이지를 불러오지 못했습니다</h1>
<p class="error-panel__description">
요청을 처리하는 중 문제가 발생했습니다. 잠시 후 다시 시도하거나 홈으로 돌아가 다른 메뉴를 이용해 주세요.
</p>
<% if (requestUri != null || message != null) { %>
<div class="error-panel__meta" aria-label="오류 상세 정보">
<div class="error-meta" aria-label="오류 상세 정보">
<% if (requestUri != null) { %>
<div>요청 경로: <%= htmlEscape(requestUri) %></div>
<% } %>
@ -220,11 +222,7 @@
<% } %>
</div>
<% } %>
<div class="error-panel__actions">
<a class="error-panel__button error-panel__button--primary" href="<%= ctx %>/">홈으로 이동</a>
<a class="error-panel__button" href="javascript:history.back()">이전 페이지</a>
</div>
</section>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
</body>

View File

@ -0,0 +1,21 @@
<%@ page pageEncoding="UTF-8" %>
<%-- posts-empty.jspf — 포스팅 목록 빈 상태 파편
사용: posts-list.jsp의 posts.isEmpty() 분기에서 include
필요: bibimbap.css (.empty-state 클래스)
--%>
<div class="empty-state">
<svg width="84" height="84" viewBox="0 0 84 84" fill="none" aria-hidden="true">
<rect x="20" y="13" width="44" height="57" rx="9" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
<line x1="30" y1="30" x2="54" y2="30" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
<line x1="30" y1="40" x2="54" y2="40" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
<line x1="30" y1="50" x2="45" y2="50" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="59" cy="57" r="13" fill="#D4A853"/>
<line x1="59" y1="51" x2="59" y2="63" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
<line x1="53" y1="57" x2="65" y2="57" stroke="#fff" stroke-width="2.6" stroke-linecap="round"/>
</svg>
<h3>아직 등록된 포스트가 없어요</h3>
<p>개발 일지, 팁, 질문 무엇이든 좋아요. 커뮤니티의 첫 글을 남겨보세요.</p>
<div class="empty-actions">
<a class="btn btn-primary" href="${pageContext.request.contextPath}/posts/new"> 글쓰기</a>
</div>
</div>

View File

@ -0,0 +1,22 @@
<%@ page pageEncoding="UTF-8" %>
<%-- recruit-empty.jspf — 팀원 모집 목록 빈 상태 파편
사용: recruit-list.jsp의 recruitPosts.isEmpty() 분기에서 include
필요: bibimbap.css (.empty-state 클래스)
--%>
<div class="empty-state">
<svg width="92" height="84" viewBox="0 0 92 84" fill="none" aria-hidden="true">
<circle cx="34" cy="29" r="11" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5"/>
<path d="M16 66c0-10 8-18 18-18s18 8 18 18" fill="#FBEFD6" stroke="#D4A853" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="62" cy="33" r="8.5" fill="#fff" stroke="#E2C589" stroke-width="2.5"/>
<path d="M49 63c0-8 6-14 13-14s13 6 13 14" fill="none" stroke="#E2C589" stroke-width="2.5" stroke-linecap="round"/>
<circle cx="74" cy="20" r="11" fill="#D4A853"/>
<line x1="74" y1="15" x2="74" y2="25" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
<line x1="69" y1="20" x2="79" y2="20" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
</svg>
<h3>아직 올라온 모집글이 없어요</h3>
<p>함께 만들 기획·아트·프로그래머를 찾고 있다면, 첫 모집글을 올려 팀을 시작해 보세요.</p>
<div class="empty-actions">
<a class="btn btn-primary" href="${pageContext.request.contextPath}/recruit/new"> 모집글 작성</a>
<a class="btn btn-ghost" href="${pageContext.request.contextPath}/recruit">필터 초기화</a>
</div>
</div>

View File

@ -998,6 +998,7 @@
margin: 0 0 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 1.25rem;
padding: 1rem 1.25rem;
background:
@ -1018,6 +1019,7 @@
.game-reviews__radar {
display: inline-flex;
flex-shrink: 0;
gap: 1.75rem;
}
.game-reviews__radar svg {
display: block;
@ -1056,6 +1058,18 @@
stroke-width: 1.5;
animation: game-radar-fade 0.7s ease both;
}
.game-reviews__radar .game-axis-axislabel {
font-size: 11px;
font-weight: 600;
fill: var(--text-muted);
}
.game-reviews__radar .game-axis-scorelabel {
font-size: 10px;
font-weight: 700;
fill: var(--accent);
font-variant-numeric: tabular-nums;
animation: game-radar-fade 0.7s ease both;
}
@keyframes game-radar-pop {
from { opacity: 0; transform: scale(0.55); }
to { opacity: 1; transform: scale(1); }
@ -1068,61 +1082,38 @@
font-size: 0.75rem;
color: var(--text-muted);
}
.game-reviews__axis-legend {
margin: 0;
flex: 1;
min-width: 11.5rem;
display: grid;
gap: 0.4rem 0;
font-size: 0.75rem;
color: var(--text-muted);
.game-reviews__title-meter {
display: inline-flex;
align-items: flex-end;
gap: 0.15rem;
height: 0.95rem;
}
.game-reviews__axis-legend-row {
display: grid;
grid-template-columns: 3.4rem 1fr 1.15rem;
align-items: center;
gap: 0.55rem;
}
.game-reviews__axis-legend dt {
font-weight: 600;
color: var(--text);
}
.game-reviews__axis-legend dd {
margin: 0;
font-variant-numeric: tabular-nums;
font-weight: 700;
text-align: right;
color: var(--accent);
}
.game-reviews__axis-meter {
height: 0.4rem;
border-radius: 999px;
.game-reviews__title-meter .stick {
position: relative;
width: 0.2rem;
height: 100%;
border-radius: 2px;
background: var(--border);
overflow: hidden;
}
.game-reviews__axis-meter span {
display: block;
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, #f0c978, var(--accent));
animation: game-meter-grow 0.7s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: left;
.game-reviews__title-meter .stick span {
position: absolute;
bottom: 0;
left: 0;
right: 0;
border-radius: 2px;
background: linear-gradient(180deg, #f0c978, var(--accent));
animation: game-title-meter-grow 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
transform-origin: bottom;
}
@keyframes game-meter-grow {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
@keyframes game-title-meter-grow {
from { transform: scaleY(0); }
to { transform: scaleY(1); }
}
@media (prefers-reduced-motion: reduce) {
.game-reviews__radar .game-axis-poly,
.game-reviews__radar .game-axis-dot,
.game-reviews__axis-meter span { animation: none; }
}
.game-reviews__card-radar {
margin-top: 0.6rem;
display: inline-flex;
}
.game-reviews__card-radar svg {
display: block;
.game-reviews__title-meter .stick span { animation: none; }
}
/* ── sort 토글 / 더보기 ──────────────────────────────── */
@ -1604,7 +1595,7 @@
// ===== 육각형 레이더 SVG (요약/카드 공용) =====
// scores: AXIS_KEYS 순 6값(1~5, null/0=데이터 없음). cx/cy/R 좌표.
var hexSeq = 0; // svg 별 그라디언트 id 고유화
function buildHexRadar(scores, cx, cy, R) {
function buildHexRadar(scores, cx, cy, R, withLabels) {
var canvas = cx * 2; // cx==cy 가정. 요약 200 / 카드 120
var svg = document.createElementNS(SVG_NS, 'svg');
svg.setAttribute('viewBox', '0 0 ' + canvas + ' ' + canvas);
@ -1650,16 +1641,18 @@
defs.appendChild(grad);
svg.appendChild(defs);
// 동심 그리드 (4단계, 외곽 강조)
for (var level = 1; level <= 4; level++) {
// 동심 그리드 (5단계 = 1~5점 정수 등급과 1:1 대응, 외곽 강조)
for (var level = 1; level <= 5; level++) {
var grid = document.createElementNS(SVG_NS, 'polygon');
grid.setAttribute('class', 'game-axis-grid' + (level === 4 ? ' game-axis-grid--outer' : ''));
grid.setAttribute('class', 'game-axis-grid' + (level === 5 ? ' game-axis-grid--outer' : ''));
(function (lv) {
grid.setAttribute('points', polyPoints(function () { return lv / 4; }));
grid.setAttribute('points', polyPoints(function () { return lv / 5; }));
})(level);
svg.appendChild(grid);
}
// 축선
// 축선 + (withLabels 시) 축 이름 라벨 — 육각형만으론 어떤 축인지 식별 불가(§6.6)
// 6축 순서상 위치 고정: 0=위, 1=우상, 2=우하, 3=아래, 4=좌하, 5=좌상
var AXIS_LABEL_ANCHOR = ['middle', 'start', 'start', 'middle', 'end', 'end'];
AXIS_KEYS.forEach(function (k, i) {
var line = document.createElementNS(SVG_NS, 'line');
line.setAttribute('class', 'game-axis-line');
@ -1669,6 +1662,18 @@
line.setAttribute('x2', p.x.toFixed(2));
line.setAttribute('y2', p.y.toFixed(2));
svg.appendChild(line);
if (withLabels) {
var lp = ptOnAxis(i, 1.14);
var label = document.createElementNS(SVG_NS, 'text');
label.setAttribute('class', 'game-axis-axislabel');
label.setAttribute('x', lp.x.toFixed(2));
label.setAttribute('y', lp.y.toFixed(2));
label.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
label.setAttribute('dominant-baseline', i === 0 ? 'text-after-edge' : (i === 3 ? 'text-before-edge' : 'middle'));
label.textContent = AXIS_LABELS_KO[k];
svg.appendChild(label);
}
});
// score 폴리곤 + 꼭지점 점 (데이터 있을 때만)
if (hasData) {
@ -1679,13 +1684,27 @@
svg.appendChild(poly);
AXIS_KEYS.forEach(function (k, i) {
if (nums[i] <= 0) return;
var p = ptOnAxis(i, nums[i] / 5);
var ratio = nums[i] / 5;
var p = ptOnAxis(i, ratio);
var dot = document.createElementNS(SVG_NS, 'circle');
dot.setAttribute('class', 'game-axis-dot');
dot.setAttribute('cx', p.x.toFixed(2));
dot.setAttribute('cy', p.y.toFixed(2));
dot.setAttribute('r', Math.max(1.6, R * 0.05).toFixed(2));
svg.appendChild(dot);
if (withLabels) {
// 꼭지점에서 살짝 바깥으로 띄워 점 위에 겹치지 않게 배치
var sp = ptOnAxis(i, ratio + 14 / R);
var score = document.createElementNS(SVG_NS, 'text');
score.setAttribute('class', 'game-axis-scorelabel');
score.setAttribute('x', sp.x.toFixed(2));
score.setAttribute('y', sp.y.toFixed(2));
score.setAttribute('text-anchor', AXIS_LABEL_ANCHOR[i]);
score.setAttribute('dominant-baseline', 'middle');
score.textContent = String(nums[i]);
svg.appendChild(score);
}
});
}
@ -1701,28 +1720,29 @@
return { svg: svg, nums: nums, hasData: hasData };
}
// 시각 보조 <dl> (축명/미터바/점수) — 폴리곤만으로 종료 금지(§6.6)
function buildRadarLegend(nums, hasData) {
var dl = document.createElement('dl');
dl.className = 'game-reviews__axis-legend';
AXIS_KEYS.forEach(function (k, i) {
var row = document.createElement('div');
row.className = 'game-reviews__axis-legend-row';
var dt = document.createElement('dt');
dt.textContent = AXIS_LABELS_KO[k];
var meter = document.createElement('div');
meter.className = 'game-reviews__axis-meter';
var bar = document.createElement('span');
bar.style.width = (hasData ? (nums[i] / 5 * 100) : 0) + '%';
meter.appendChild(bar);
var dd = document.createElement('dd');
dd.textContent = hasData ? String(nums[i]) : '';
row.appendChild(dt);
row.appendChild(meter);
row.appendChild(dd);
dl.appendChild(row);
// 리뷰 카드 제목행 삽입용 6축 미니 미터바 — axes 없으면 null(§6.6: 그래프는 요약에만, 카드는 이 미터바로 대체)
function buildTitleMeter(axes) {
var nums = AXIS_KEYS.map(function (k) {
var v = axes ? Number(axes[k]) : 0;
return (isFinite(v) && v > 0) ? v : 0;
});
return dl;
var hasData = nums.some(function (v) { return v > 0; });
if (!hasData) return null;
var wrap = document.createElement('div');
wrap.className = 'game-reviews__title-meter';
wrap.setAttribute('role', 'img');
wrap.setAttribute('aria-label', '6축 평가: ' + AXIS_KEYS.map(function (k, i) {
return AXIS_LABELS_KO[k] + ' ' + nums[i];
}).join(', '));
nums.forEach(function (v) {
var stick = document.createElement('span');
stick.className = 'stick';
var fill = document.createElement('span');
fill.style.height = (v / 5 * 100) + '%';
stick.appendChild(fill);
wrap.appendChild(stick);
});
return wrap;
}
// ===== 별점 radiogroup 완전 패턴 (C6) =====
@ -2179,6 +2199,9 @@
mb.textContent = '내 리뷰';
head.appendChild(mb);
}
// 6축 미니 미터바 — 제목행 노출(§6.6: 요약 그래프와 별개로 카드 단위 시각화)
var titleMeter = buildTitleMeter(r.axes);
if (titleMeter) head.appendChild(titleMeter);
var t = document.createElement('time');
t.className = 'game-reviews__time';
t.dateTime = (r.updatedAt || r.createdAt) || '';
@ -2200,25 +2223,6 @@
li.appendChild(p);
}
// 카드 컴팩트 육각형 (6축). axes null/누락 시 "데이터 없음"
var cardScores = AXIS_KEYS.map(function (k) { return r.axes ? Number(r.axes[k]) || 0 : 0; });
var cardRadar = buildHexRadar(cardScores, 60, 60, 44);
var radarWrap = document.createElement('div');
radarWrap.className = 'game-reviews__radar game-reviews__card-radar';
if (cardRadar.hasData) {
radarWrap.appendChild(cardRadar.svg);
// §6.6: 폴리곤만으로 종료 금지 — 시각/스크린리더 보조 표 동반(시각적으로 숨김)
var legend = buildRadarLegend(cardRadar.nums, true);
legend.className += ' sr-only';
radarWrap.appendChild(legend);
} else {
var none = document.createElement('span');
none.className = 'game-reviews__radar-empty';
none.textContent = '6축 평가 데이터 없음';
radarWrap.appendChild(none);
}
li.appendChild(radarWrap);
if (canActOn(r.userId)) {
var foot = document.createElement('div');
foot.className = 'game-reviews__footer';
@ -2263,11 +2267,10 @@
if (rSummaryPanel && rSummaryRadar) {
rSummaryRadar.innerHTML = '';
var scores = AXIS_KEYS.map(function (k) { return summary.axes ? Number(summary.axes[k]) || 0 : 0; });
var radar = buildHexRadar(scores, 100, 100, 80);
var radar = buildHexRadar(scores, 100, 100, 80, true);
rSummaryPanel.hidden = false;
if (radar.hasData) {
rSummaryRadar.appendChild(radar.svg);
rSummaryRadar.appendChild(buildRadarLegend(radar.nums, true));
} else {
var none = document.createElement('span');
none.className = 'game-reviews__radar-empty';

View File

@ -205,6 +205,18 @@
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.25);
}
.game-req {
color: var(--accent);
font-weight: 900;
margin-left: 0.15em;
}
.game-preview__label {
margin: 0;
padding: 0.875rem 1rem 0;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 900;
}
.game-check {
display: flex;
align-items: center;
@ -362,7 +374,7 @@
<form class="game-form" action="<%= formAction %>" method="post" id="game-register-form" data-edit-mode="<%= editMode %>">
<div class="game-form__grid">
<div class="game-field game-field--full">
<label class="game-field__label" for="game-name">게임 이름</label>
<label class="game-field__label" for="game-name">게임 이름 <span class="game-req">*</span></label>
<input class="game-field__input" type="text" id="game-name" name="name" maxlength="80" autocomplete="off" value="<%= editGameName %>" required />
</div>
<div class="game-field">
@ -370,7 +382,7 @@
<input class="game-field__input" type="url" id="game-git-url" name="gitUrl" placeholder="https://example.com/repository" autocomplete="url" value="<%= editGitUrl %>" />
</div>
<div class="game-field">
<label class="game-field__label" for="game-webgl-zip">WebGL zip</label>
<label class="game-field__label" for="game-webgl-zip">WebGL zip <% if (!editMode) { %><span class="game-req">*</span><% } %></label>
<label class="game-file" for="game-webgl-zip">
<span class="game-file__action">zip 선택</span>
<span class="game-file__name" id="game-webgl-file-name"><%= editMode && !editWebglPath.isBlank() ? "기존 WebGL 유지" : "선택된 파일 없음" %></span>
@ -413,6 +425,7 @@
</section>
<aside class="game-preview" aria-label="미리보기">
<p class="game-preview__label">미리보기</p>
<div class="game-preview__media">
<% if (editThumbnailUrl.isBlank()) { %>
<img id="preview-thumb" alt="" hidden />

View File

@ -171,6 +171,7 @@
}
.home-action-button {
flex-shrink: 0;
align-self: flex-start;
min-height: 3rem;
padding: 0 0.875rem 0 0.625rem;
border: 1px solid rgba(232, 165, 75, 0.32);
@ -222,8 +223,15 @@
/* 검색 */
.search-section {
width: min(100%, 48rem);
margin: 0 auto 1.25rem;
width: 100%;
margin: 0 0 1.25rem;
}
/* 검색 폼 + 상세검색 패널을 한 컬럼으로 묶어 우측 끝선 정렬(#4) */
.search-stack {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.search-form {
flex: 1;
@ -506,10 +514,10 @@
transform: scale(0.98);
}
/* 카드 그리드: 모바일 2열 → 태블릿 3열 → 데스크톱 4~5열 */
/* 카드 그리드: 콘텐츠 적응형(소량 카드 좌측 쏠림/우측 거대공백 완화) */
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 0.625rem;
justify-content: center;
}
@ -520,20 +528,9 @@
}
@media (min-width: 640px) {
.card-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 1rem;
}
}
@media (min-width: 900px) {
.card-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 1200px) {
.card-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
a.card {
display: flex;
@ -560,11 +557,11 @@
outline: 2px solid var(--accent);
outline-offset: 2px;
}
/* 가로:세로 = 4:5 (포스터/썸네일에 흔한 비율, 3:5보다 덜 길쭉해 모바일 그리드에 균형 있음) */
/* 가로:세로 = 4:3 (가로형 썸네일 — 카드 높이 축소로 그리드 균형·스캔성 개선) */
.card__media {
position: relative;
width: 100%;
aspect-ratio: 4 / 5;
aspect-ratio: 4 / 3;
background: var(--card-media-2);
}
.card__index {
@ -591,6 +588,7 @@
height: 100%;
display: block;
object-fit: cover;
object-position: center;
}
/* 이미지 없음: 로고만 중앙 */
.card__media-empty {
@ -605,7 +603,7 @@
.card__logo-fallback {
width: min(52%, 7.5rem);
height: auto;
max-height: 42%;
max-height: 50%;
object-fit: contain;
opacity: 0.88;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.12));
@ -615,18 +613,18 @@
filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}
.card__body {
padding: 0.5rem 0.625rem 0.625rem;
padding: 0.65rem 0.75rem 0.7rem;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
gap: 0.15rem;
}
.card__game-name {
margin: 0;
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
font-size: 0.9375rem;
font-weight: 700;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@ -644,13 +642,17 @@
overflow: hidden;
}
.card__likes {
margin: 0.15rem 0 0;
margin: auto 0 0;
font-size: 0.6875rem;
font-weight: 600;
color: var(--text);
font-weight: 500;
color: var(--text-muted);
letter-spacing: -0.01em;
font-variant-numeric: tabular-nums;
}
.card__likes b {
color: var(--accent);
font-weight: 700;
}
.card__badges {
display: flex;
flex-wrap: wrap;
@ -762,9 +764,17 @@
</head>
<body>
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="page-main">
<main class="page-main page">
<%-- 히어로: 서비스 소개 --%>
<p class="section-eyebrow">INDIE GAME HUB</p>
<h1 class="section-title" style="font-size:1.875rem">직접 만든 인디 게임을 올리고, 함께 즐기세요</h1>
<p class="section-desc">유니티 개발자들이 만든 웹게임을 플레이하고 리뷰를 남길 수 있어요.</p>
<section class="search-section" aria-label="게임·제작자 검색">
<%
boolean advOpen = !searchKeyword.isBlank() || !searchCreator.isBlank() || !"latest".equals(searchSort) || !selectedTagSlugs.isEmpty();
%>
<div class="home-toolbar">
<div class="search-stack">
<form class="search-form" role="search" action="<%= ctx %>/" method="get">
<div class="search-form__field">
<label class="search-form__label" for="q">게임·제작자 검색</label>
@ -772,21 +782,6 @@
</div>
<button class="search-form__submit" type="submit">검색</button>
</form>
<% if (loggedIn) { %>
<a class="home-action-button" href="<%= ctx %>/game/new">
<span class="home-action-button__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
</span>
<span>신규 게임 개시</span>
</a>
<% } %>
</div>
<%
boolean advOpen = !searchKeyword.isBlank() || !searchCreator.isBlank() || !"latest".equals(searchSort) || !selectedTagSlugs.isEmpty();
%>
<details class="advanced-search"<%= advOpen ? " open" : "" %>>
<summary class="advanced-search__summary">상세 검색</summary>
<form class="advanced-search-form" role="search" action="<%= ctx %>/games/search" method="get">
@ -847,8 +842,28 @@
</div>
<div class="search-history__chips" id="search-history-chips" role="list"></div>
</div>
</div><!-- /.search-stack -->
<% if (loggedIn) { %>
<a class="home-action-button" href="<%= ctx %>/game/new">
<span class="home-action-button__icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 5v14"/>
<path d="M5 12h14"/>
</svg>
</span>
<span>신규 게임 개시</span>
</a>
<% } %>
</div><!-- /.home-toolbar -->
</section>
<%-- 정렬 칩 --%>
<div class="sort-bar">
<span class="sort-label">정렬</span>
<a class="chip <%= (searchSort == null || searchSort.isBlank() || "latest".equals(searchSort)) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=latest">최신순</a>
<a class="chip <%= "likes".equals(searchSort) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=likes">좋아요순</a>
<a class="chip <%= "views".equals(searchSort) ? "chip--on" : "" %>" href="<%= ctx %>/?sort=views">방문순</a>
</div>
<% if (showJamBanner) {
String jamTitle = HtmlUtils.htmlEscape(activeJam.getTitle() == null || activeJam.getTitle().isBlank() ? "게임잼" : activeJam.getTitle());
String jamSlugParam = java.net.URLEncoder.encode(activeJam.getSlug(), java.nio.charset.StandardCharsets.UTF_8);
@ -866,7 +881,7 @@
</section>
<% } %>
<section class="card-grid" aria-label="추천 목록">
<section class="card-grid game-grid" aria-label="추천 목록">
<% if (games.isEmpty()) { %>
<% if (searching) { %>
<div class="home-empty">"<%= escapedSearchQuery %>"에 대한 검색 결과가 없습니다.</div>
@ -892,7 +907,7 @@
thumbUrl = HtmlUtils.htmlEscape(thumbUrl);
}
%>
<a class="card" href="<%= ctx %>/game/<%= game.getId() %>" aria-labelledby="<%= titleId %>">
<a class="card game-card" href="<%= ctx %>/game/<%= game.getId() %>" aria-labelledby="<%= titleId %>">
<div class="card__media">
<span class="card__index" aria-hidden="true">#<%= game.getId() %></span>
<% if (hasImage) { %>
@ -918,7 +933,7 @@
<% } %>
</div>
<% } %>
<p class="card__likes">좋아요 <%= String.format("%,d", likeCount) %></p>
<p class="card__likes">좋아요 <b><%= String.format("%,d", likeCount) %></b></p>
</div>
</a>
<%

View File

@ -202,6 +202,30 @@
gap: 0.625rem;
}
}
.pw-field {
position: relative;
display: flex;
align-items: center;
}
.pw-field .auth-field__input {
padding-right: 4rem;
flex: 1;
}
.pw-toggle {
position: absolute;
right: 0.75rem;
background: none;
border: none;
color: var(--text-muted);
font-size: 0.8125rem;
cursor: pointer;
padding: 0 0.25rem;
}
.form-error {
color: #d32f2f;
font-size: 0.8125rem;
margin: 0;
}
</style>
</head>
<body>
@ -228,7 +252,11 @@
</div>
<div class="auth-field">
<label class="auth-field__label" for="login-password">비밀번호</label>
<div class="pw-field">
<input class="auth-field__input" type="password" id="login-password" name="password" autocomplete="current-password" required />
<button type="button" class="pw-toggle"
onclick="var p=document.getElementById('login-password');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'보기':'숨기기'">보기</button>
</div>
</div>
<div class="auth-row">
<label class="auth-check">
@ -236,6 +264,7 @@
<span>로그인 유지</span>
</label>
</div>
<div class="form-error" id="login-form-error" hidden></div>
<button class="auth-button auth-button--primary" type="submit">로그인</button>
</form>

View File

@ -240,7 +240,7 @@
</nav>
<% if (posts.isEmpty()) { %>
<div class="posts-empty">아직 등록된 포스트가 없습니다.</div>
<%@ include file="fragments/posts-empty.jspf" %>
<% } else { %>
<section class="posts-grid" aria-label="포스트 목록">
<% for (PostData post : posts) { %>

View File

@ -66,7 +66,7 @@
align-items: start;
}
.recruit-form,
.preview-card {
.rf-preview {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--card-bg);
@ -75,26 +75,26 @@
.recruit-form {
padding: 1.35rem;
}
.form-grid {
.rf-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.9rem;
}
.field {
.rf-field {
display: grid;
gap: 0.4rem;
}
.field--full {
.rf-field--full {
grid-column: 1 / -1;
}
.field label {
.rf-field label {
color: var(--text);
font-size: 0.8125rem;
font-weight: 900;
}
.field input,
.field select,
.field textarea {
.rf-field input,
.rf-field select,
.rf-field textarea {
width: 100%;
box-sizing: border-box;
border: 1px solid var(--border);
@ -104,7 +104,7 @@
font: inherit;
font-size: 0.9375rem;
}
.field select {
.rf-field select {
appearance: none;
cursor: pointer;
padding-right: 3rem;
@ -115,38 +115,38 @@
background-position: 0 0, right 0.82rem center;
background-size: 100% 100%, 1.05rem 1.05rem;
}
.field select::-ms-expand {
.rf-field select::-ms-expand {
display: none;
}
.field select:hover {
.rf-field select:hover {
border-color: rgba(232, 165, 75, 0.45);
}
.field select option {
.rf-field select option {
background: var(--field-bg);
color: var(--text);
}
.field input,
.field select {
.rf-field input,
.rf-field select {
height: 2.85rem;
padding: 0 0.8rem;
}
.field select {
.rf-field select {
padding-right: 3rem;
}
.field textarea {
.rf-field textarea {
min-height: 8rem;
padding: 0.8rem;
line-height: 1.6;
resize: vertical;
}
.field input:focus,
.field select:focus,
.field textarea:focus {
.rf-field input:focus,
.rf-field select:focus,
.rf-field textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(232, 165, 75, 0.22);
}
.form-actions {
.rf-actions {
margin-top: 1rem;
display: flex;
justify-content: flex-end;
@ -172,18 +172,34 @@
background: var(--accent);
color: #1a1a1a;
}
.preview-card {
.rf-req {
color: var(--accent);
font-weight: 900;
margin-left: 0.15em;
}
.rf-field-head {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.rf-count {
font-size: 0.75rem;
color: var(--text-muted);
font-weight: 400;
white-space: nowrap;
}
.rf-preview {
position: sticky;
top: 5rem;
padding: 1.1rem;
}
.preview-card__label {
.rf-preview__label {
margin: 0 0 0.65rem;
color: var(--text-muted);
font-size: 0.75rem;
font-weight: 900;
}
.preview-card__role {
.rf-preview__role {
display: inline-flex;
align-items: center;
min-height: 1.85rem;
@ -195,21 +211,21 @@
font-size: 0.75rem;
font-weight: 900;
}
.preview-card h2 {
.rf-preview h2 {
margin: 0;
font-size: 1.2rem;
line-height: 1.35;
letter-spacing: 0;
word-break: break-word;
}
.preview-card__line {
.rf-preview__line {
margin: 0.55rem 0 0;
color: var(--text-muted);
font-size: 0.875rem;
line-height: 1.6;
word-break: break-word;
}
.preview-card__meta {
.rf-preview__meta {
margin: 1rem 0 0;
padding: 0;
list-style: none;
@ -218,22 +234,22 @@
color: var(--text-muted);
font-size: 0.8125rem;
}
.preview-card__meta strong {
.rf-preview__meta strong {
color: var(--text);
}
@media (max-width: 860px) {
.form-layout {
grid-template-columns: 1fr;
}
.preview-card {
.rf-preview {
position: static;
}
}
@media (max-width: 560px) {
.form-grid {
.rf-grid {
grid-template-columns: 1fr;
}
.form-actions {
.rf-actions {
flex-direction: column-reverse;
}
.form-button {
@ -254,28 +270,31 @@
<div class="form-layout">
<section class="recruit-form" aria-label="팀원 모집글 입력">
<form id="recruit-form" action="<%= ctx %>/recruit/new" method="post" novalidate>
<div class="form-grid">
<div class="field">
<label for="project-name">프로젝트명</label>
<div class="rf-grid">
<div class="rf-field">
<label for="project-name">프로젝트명 <span class="rf-req">*</span></label>
<input id="project-name" name="projectName" type="text" maxlength="80" placeholder="예: 달빛 정거장" required>
</div>
<div class="field">
<div class="rf-field">
<label for="genre">게임 장르</label>
<input id="genre" name="genre" type="text" maxlength="60" placeholder="예: 2D 어드벤처">
</div>
<div class="field field--full">
<label for="summary">한 줄 소개</label>
<input id="summary" name="summary" type="text" maxlength="120" placeholder="프로젝트를 한 문장으로 소개해 주세요." required>
<div class="rf-field rf-field--full">
<div class="rf-field-head">
<label for="summary">한 줄 소개 <span class="rf-req">*</span></label><span class="rf-count"><span id="sc">0</span> / 120</span>
</div>
<div class="field">
<label for="role">모집 역할</label>
<input id="summary" name="summary" type="text" maxlength="120" placeholder="프로젝트를 한 문장으로 소개해 주세요." required
oninput="document.getElementById('sc').textContent=this.value.length">
</div>
<div class="rf-field">
<label for="role">모집 역할 <span class="rf-req">*</span></label>
<select id="role" name="role">
<option selected>기획</option>
<option>아트</option>
<option>프로그래머</option>
</select>
</div>
<div class="field">
<div class="rf-field">
<label for="status">진행 상태</label>
<select id="status" name="status">
<option selected>아이디어 단계</option>
@ -284,7 +303,7 @@
<option>출시 준비 중</option>
</select>
</div>
<div class="field">
<div class="rf-field">
<label for="type">참여 방식</label>
<select id="type" name="type">
<option selected>취미</option>
@ -293,36 +312,36 @@
<option>게임잼</option>
</select>
</div>
<div class="field">
<div class="rf-field">
<label for="period">예상 기간</label>
<input id="period" name="period" type="text" maxlength="40" placeholder="예: 약 3개월">
</div>
<div class="field">
<div class="rf-field">
<label for="team">현재 팀 구성</label>
<input id="team" name="team" type="text" maxlength="80" placeholder="예: 기획 1명, 프로그래머 1명">
</div>
<div class="field">
<label for="contact">연락 방법</label>
<div class="rf-field">
<label for="contact">연락 방법 <span class="rf-req">*</span></label>
<input id="contact" name="contact" type="text" maxlength="100" placeholder="이메일, 디스코드, 오픈채팅 등" required>
</div>
<div class="field field--full">
<div class="rf-field rf-field--full">
<label for="description">상세 설명</label>
<textarea id="description" name="description" maxlength="1200" placeholder="프로젝트 소개, 원하는 역할, 작업 방식 등을 적어 주세요."></textarea>
</div>
</div>
<div class="form-actions">
<div class="rf-actions">
<a class="form-button" href="<%= ctx %>/recruit">취소</a>
<button class="form-button form-button--primary" type="submit">미리보기 완료</button>
<button class="form-button form-button--primary" type="submit">모집글 등록</button>
</div>
</form>
</section>
<aside class="preview-card" aria-label="모집글 미리보기">
<p class="preview-card__label">미리보기</p>
<span class="preview-card__role" id="preview-role">기획 모집</span>
<aside class="rf-preview" aria-label="모집글 미리보기">
<p class="rf-preview__label">미리보기</p>
<span class="rf-preview__role" id="preview-role">기획 모집</span>
<h2 id="preview-name">프로젝트 이름</h2>
<p class="preview-card__line" id="preview-summary">한 줄 소개가 여기에 표시됩니다.</p>
<ul class="preview-card__meta">
<p class="rf-preview__line" id="preview-summary">한 줄 소개가 여기에 표시됩니다.</p>
<ul class="rf-preview__meta">
<li><strong>장르</strong> <span id="preview-genre">장르 미정</span></li>
<li><strong>상태</strong> <span id="preview-status">아이디어 단계</span></li>
<li><strong>참여</strong> <span id="preview-type">취미</span></li>
@ -330,7 +349,7 @@
<li><strong>팀</strong> <span id="preview-team">팀 구성 미정</span></li>
<li><strong>연락</strong> <span id="preview-contact">연락 방법 미정</span></li>
</ul>
<p class="preview-card__line" id="preview-description">상세 설명이 여기에 표시됩니다.</p>
<p class="rf-preview__line" id="preview-description">상세 설명이 여기에 표시됩니다.</p>
</aside>
</div>
</main>

View File

@ -60,11 +60,17 @@
gap: 1rem;
margin-bottom: 1rem;
}
.recruit-hero__titles {
max-width: 40rem;
}
.recruit-hero__eyebrow {
margin: 0 0 0.35rem;
color: var(--accent);
font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
font-size: 0.75rem;
font-weight: 900;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.recruit-hero h1 {
margin: 0;
@ -92,6 +98,13 @@
text-decoration: none;
white-space: nowrap;
}
/* 빈 상태(목록 0건)일 때 히어로 CTA 를 보조 스타일로 강등 — 화면당 골드 채움 CTA 1개(#9) */
.recruit-write--ghost {
background: transparent;
border: 1px solid var(--border);
color: var(--text);
font-weight: 700;
}
.recruit-toolbar {
margin-bottom: 1.25rem;
display: grid;
@ -100,6 +113,7 @@
.recruit-search {
display: flex;
gap: 0.5rem;
max-width: 48rem;
}
.recruit-search input {
width: 100%;
@ -248,12 +262,12 @@
<jsp:include page="/WEB-INF/views/header.jsp"/>
<main class="recruit-page">
<section class="recruit-hero" aria-labelledby="recruit-title">
<div>
<div class="recruit-hero__titles">
<p class="recruit-hero__eyebrow">INDIE GAME TEAM</p>
<h1 id="recruit-title">함께 만들 팀원을 찾는 공간</h1>
<p>기획, 아트, 프로그래머가 작은 프로젝트부터 출시 목표 팀까지 가볍게 만나는 게시판입니다.</p>
</div>
<a class="recruit-write" href="<%= ctx %>/recruit/new">모집글 작성</a>
<a class="recruit-write<%= recruitPosts.isEmpty() ? " recruit-write--ghost" : "" %>" href="<%= ctx %>/recruit/new">모집글 작성</a>
</section>
<section class="recruit-toolbar" aria-label="모집글 검색 및 필터">
@ -315,7 +329,10 @@
}
%>
</section>
<div class="recruit-empty <%= recruitPosts.isEmpty() ? "is-visible" : "" %>" id="recruit-empty">아직 올라온 팀원 모집글이 없습니다.</div>
<% if (recruitPosts.isEmpty()) { %>
<%@ include file="fragments/recruit-empty.jspf" %>
<% } %>
<div class="recruit-empty" id="recruit-empty">아직 올라온 팀원 모집글이 없습니다.</div>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>
<script>

View File

@ -200,6 +200,37 @@
grid-template-columns: 1fr;
}
}
.pw-meter {
display: flex;
gap: 4px;
height: 4px;
margin-top: 6px;
}
.pw-meter span {
flex: 1;
border-radius: 2px;
background: var(--border);
transition: background 0.2s;
}
.pw-meter[data-level="1"] span:nth-child(1) { background: #e53935; }
.pw-meter[data-level="2"] span:nth-child(1),
.pw-meter[data-level="2"] span:nth-child(2) { background: #fb8c00; }
.pw-meter[data-level="3"] span { background: #43a047; }
.form-ok {
color: #43a047;
font-size: 0.8125rem;
margin: 4px 0 0;
}
.form-error {
color: #d32f2f;
font-size: 0.8125rem;
margin: 0;
}
.help {
font-size: 0.8125rem;
color: var(--text-muted);
margin: 4px 0 0;
}
</style>
</head>
<body>
@ -233,11 +264,16 @@
<div class="auth-grid">
<div class="auth-field">
<label class="auth-field__label" for="signup-password">비밀번호</label>
<input class="auth-field__input" type="password" id="signup-password" name="password" autocomplete="new-password" minlength="8" required />
<input class="auth-field__input" type="password" id="signup-password" name="password" autocomplete="new-password" minlength="8" required
oninput="document.getElementById('signup-pw-meter').dataset.level=this.value.length>=10?3:this.value.length>=8?2:this.value?1:0" />
<div class="pw-meter" id="signup-pw-meter" data-level="0"><span></span><span></span><span></span></div>
<p class="help">8자 이상, 영문·숫자 조합을 권장합니다</p>
</div>
<div class="auth-field">
<label class="auth-field__label" for="signup-password-confirm">비밀번호 확인</label>
<input class="auth-field__input" type="password" id="signup-password-confirm" name="passwordConfirm" autocomplete="new-password" minlength="8" required />
<input class="auth-field__input" type="password" id="signup-password-confirm" name="passwordConfirm" autocomplete="new-password" minlength="8" required
oninput="document.getElementById('signup-pw-ok').hidden=!this.value||this.value!==document.getElementById('signup-password').value" />
<p class="form-ok" id="signup-pw-ok" hidden>✓ 비밀번호가 일치합니다</p>
</div>
</div>
<label class="auth-check">
@ -247,6 +283,7 @@
<a class="auth-link" href="<%= ctx %>/operation-policy" target="_blank" rel="noopener noreferrer">운영정책</a>에 동의합니다.
</span>
</label>
<div class="form-error" id="signup-form-error" hidden></div>
<button class="auth-button auth-button--primary" type="submit">회원가입</button>
</form>

View File

@ -64,32 +64,68 @@
.policy-doc__body {
padding: 1.35rem;
}
.policy-section + .policy-section {
margin-top: 1.55rem;
padding-top: 1.55rem;
border-top: 1px solid var(--border);
/* terms layout */
.terms {
display: grid;
grid-template-columns: 200px 1fr;
gap: 28px;
}
.policy-section h2 {
margin: 0 0 0.7rem;
.terms-toc {
position: sticky;
top: 24px;
align-self: start;
border-right: 1px solid var(--border);
padding-right: 8px;
}
.terms-toc .toc-title {
font-size: 11px;
letter-spacing: .12em;
color: var(--text-muted);
margin-bottom: 10px;
text-transform: uppercase;
}
.terms-toc a {
display: block;
font-size: 13.5px;
color: var(--text-muted);
padding: 7px 0 7px 14px;
text-decoration: none;
}
.terms-toc a:hover { color: var(--text); }
.terms-toc a.on {
color: var(--accent);
font-weight: 700;
border-left: 2px solid var(--accent);
padding-left: 12px;
}
.terms-body section {
padding: 18px 0;
border-bottom: 1px solid var(--border);
scroll-margin-top: 24px;
}
.terms-body section:last-child { border-bottom: none; }
.terms-body h3 {
font-size: 1.05rem;
line-height: 1.35;
font-weight: 800;
color: var(--text);
margin: 0 0 0.7rem;
}
.policy-section p,
.policy-section li {
.terms-body p,
.terms-body li {
color: var(--text-muted);
font-size: 0.925rem;
line-height: 1.75;
}
.policy-section p {
margin: 0.45rem 0 0;
}
.policy-section ul,
.policy-section ol {
.terms-body p { margin: 0.45rem 0 0; }
.terms-body ul,
.terms-body ol {
margin: 0.6rem 0 0;
padding-left: 1.2rem;
}
.policy-section strong {
color: var(--text);
.terms-body strong { color: var(--text); }
@media (max-width: 720px) {
.terms { grid-template-columns: 1fr; }
.terms-toc { position: static; border-right: none; }
}
</style>
</head>
@ -102,43 +138,62 @@
<p>시행일: 2026년 4월 16일</p>
</header>
<div class="policy-doc__body">
<section class="policy-section">
<h2>제1조 (목적)</h2>
<div class="terms">
<%-- 목차 (앵커 링크) --%>
<nav class="terms-toc">
<div class="toc-title">목차</div>
<a class="on" href="#a1">제1조 목적</a>
<a href="#a2">제2조 용어의 정의</a>
<a href="#a3">제3조 약관의 게시와 개정</a>
<a href="#a4">제4조 서비스 제공 및 변경</a>
<a href="#a5">제5조 회원가입 및 계정 관리</a>
<a href="#a6">제6조 이용자의 의무</a>
<a href="#a7">제7조 게시물의 저작권</a>
<a href="#a8">제8조 이용 제한 및 해지</a>
<a href="#a9">제9조 책임 제한</a>
<a href="#a10">제10조 준거법 및 관할법원</a>
</nav>
<%-- 본문 (조항) --%>
<div class="terms-body">
<section id="a1">
<h3>제1조 (목적)</h3>
<p>본 약관은 개인 개발자(이하 "운영자")가 제공하는 웹 서비스 '비빔밥'(이하 "서비스")의 이용 조건 및 절차, 이용자와 운영자의 권리, 의무 및 책임 사항을 규정함을 목적으로 합니다.</p>
</section>
<section class="policy-section">
<h2>제2조 (용어의 정의)</h2>
<section id="a2">
<h3>제2조 (용어의 정의)</h3>
<ol>
<li>"서비스"란 운영자가 개발한 웹게임을 게재하고 이용자들이 소통할 수 있는 커뮤니티 공간을 제공하는 웹사이트를 의미합니다.</li>
<li>"이용자"란 본 약관에 따라 운영자가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.</li>
<li>"회원"이란 서비스에 접속하여 본 약관에 동의하고 계정을 생성하여 서비스를 이용하는 자를 말합니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제3조 (약관의 게시와 개정)</h2>
<section id="a3">
<h3>제3조 (약관의 게시와 개정)</h3>
<ol>
<li>운영자는 본 약관의 내용을 이용자가 쉽게 알 수 있도록 서비스 초기 화면에 게시합니다.</li>
<li>운영자는 관련 법령을 위배하지 않는 범위에서 본 약관을 개정할 수 있습니다.</li>
<li>약관이 개정될 경우 적용 일자 및 개정 사유를 명시하여 현행 약관과 함께 서비스 내에 공지합니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제4조 (서비스의 제공 및 변경)</h2>
<section id="a4">
<h3>제4조 (서비스의 제공 및 변경)</h3>
<ol>
<li>서비스는 웹게임 게재 및 커뮤니티 기능을 기본으로 제공하며, 운영자의 판단에 따라 새로운 기능을 추가하거나 기존 기능을 변경할 수 있습니다.</li>
<li>본 서비스는 이용자에게 무료로 제공됩니다. 다만, 서비스 운영상 필요한 경우 일부 기능을 유료화하거나 광고를 게재할 수 있으며, 이 경우 사전에 공지합니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제5조 (회원가입 및 계정 관리)</h2>
<section id="a5">
<h3>제5조 (회원가입 및 계정 관리)</h3>
<ol>
<li>이용자는 운영자가 정한 가입 양식에 따라 회원정보를 기입함으로써 회원가입을 신청합니다.</li>
<li>모든 회원은 반드시 본인의 정보를 제공하여야 하며, 타인의 정보를 도용할 경우 서비스 이용 제한 및 관련 법령에 따른 처벌을 받을 수 있습니다.</li>
<li>회원은 자신의 계정과 비밀번호를 관리할 책임이 있으며, 이를 제3자가 이용하도록 하여서는 안 됩니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제6조 (이용자의 의무 및 금지 행위)</h2>
<section id="a6">
<h3>제6조 (이용자의 의무 및 금지 행위)</h3>
<p>이용자는 서비스 이용 시 다음 각호의 행위를 하여서는 안 됩니다.</p>
<ol>
<li>서비스의 안정적인 운영을 방해할 목적으로 하는 데이터 마이닝, 크롤링, 또는 비정상적인 접근 행위</li>
@ -148,46 +203,49 @@
<li>기타 관계 법령 및 운영정책에 위배되는 행위</li>
</ol>
</section>
<section class="policy-section">
<h2>제7조 (게시물의 저작권)</h2>
<section id="a7">
<h3>제7조 (게시물의 저작권)</h3>
<ol>
<li>회원이 서비스 내에 게시한 게시물의 저작권은 해당 게시물의 저작자에게 귀속됩니다.</li>
<li>운영자는 서비스의 홍보 및 운영 목적으로 회원의 게시물을 노출하거나 복제, 수정하여 사용할 수 있습니다.</li>
<li>회원은 서비스에 저작권 문제가 있는 게시물을 올려서는 안 되며, 이로 인해 발생하는 법적 책임은 게시자 본인에게 있습니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제8조 (서비스 이용 제한 및 해지)</h2>
<section id="a8">
<h3>제8조 (서비스 이용 제한 및 해지)</h3>
<ol>
<li>회원은 언제든지 서비스 내 설정 메뉴를 통해 회원 탈퇴를 신청할 수 있습니다.</li>
<li>운영자는 회원이 제6조의 금지 행위를 한 경우, 사전 통보 없이 서비스 이용을 일시 정지하거나 계정을 삭제할 수 있습니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제9조 (책임 제한)</h2>
<section id="a9">
<h3>제9조 (책임 제한)</h3>
<ol>
<li>운영자는 천재지변, 서버 점검, 네트워크 장애 등 불가항력적인 사유로 서비스를 제공할 수 없는 경우 책임이 면제됩니다.</li>
<li>운영자는 이용자 간 또는 이용자와 제3자 상호 간에 서비스를 매개로 하여 발생한 분쟁에 대해 개입할 의무가 없으며 이로 인한 손해를 배상할 책임이 없습니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>제10조 (준거법 및 관할법원)</h2>
<section id="a10">
<h3>제10조 (준거법 및 관할법원)</h3>
<ol>
<li>본 약관과 관련하여 발생한 분쟁에 대해서는 대한민국 법을 준거법으로 합니다.</li>
<li>서비스 이용으로 발생한 분쟁에 대해 소송이 제기될 경우 <strong>서울중앙지방법원</strong>을 전속 관할법원으로 합니다.</li>
</ol>
</section>
<section class="policy-section">
<h2>부칙</h2>
<section>
<h3>부칙</h3>
<p>본 약관은 2026년 4월 16일부터 시행됩니다.</p>
</section>
<section class="policy-section">
<h2>문의처</h2>
<section>
<h3>문의처</h3>
<ul>
<li>이메일: artbiit@naver.com</li>
</ul>
</section>
</div>
</div>
</div>
</article>
</main>
<jsp:include page="/WEB-INF/views/footer.jsp"/>

View File

@ -5,6 +5,7 @@
String themeCsrfToken = CsrfTokens.getOrCreate(request.getSession());
%>
<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(themeCsrfToken) %>">
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">
<script>
(function () {
try {

View File

@ -0,0 +1,394 @@
/* ============================================================
bibimbap 공통 스타일
외부 라이브러리 없음 · 순수 CSS · JSP 전역 적용용
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/bibimbap.css">
============================================================ */
/* ---------- 1. 디자인 토큰 ---------- */
:root {
/* 색상 */
--color-bg: #F5F0E8; /* 페이지 배경 */
--color-card: #FFFFFF; /* 카드 표면 */
--color-field-bg: #FBF8F2; /* 입력란 배경 */
--color-accent: #e8a54b; /* 골든 액센트(주요 버튼) — 브랜드 골드 통일 */
--color-accent-strong: #C2912F; /* 링크·강조 텍스트 */
--color-accent-soft: #FBEFD6; /* 액센트 옅은 배경(뱃지) */
--color-accent-press: #d8973a; /* 버튼 hover/active */
--color-text: #241F18; /* 본문 텍스트 */
--color-text-muted: #6E6555; /* 보조 텍스트 */
--color-text-faint: #9A8F7C; /* placeholder·메타 */
--color-on-accent: #3A2E15; /* 골든 버튼 위 글자색 */
--color-border: #E7DECE; /* 기본 보더 */
--color-border-soft: #EDE4D4; /* 구분선 */
--color-danger: #B24A2C; /* 에러 텍스트 */
--color-danger-bg: #FBE6DF; /* 에러 배경 */
--color-ok: #4E7A48; /* 성공 텍스트 */
--color-ok-bg: #E4F0E0; /* 성공 배경 */
/* 모양 */
--radius-sm: 8px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 18px;
--radius-pill: 999px;
/* 타이포 */
--font-sans: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Apple SD Gothic Neo", "Malgun Gothic", sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* ---------- 1-D. 다크 모드 토큰 오버라이드 ---------- */
/* 출처: header.jsp html[data-theme="dark"] .site-header 블록
--header-bg: #1a1a1a --color-bg
--header-text: #f5f0e8 --color-text
--header-muted: rgba(245,240,232,.65) --color-text-muted / --color-text-faint
--header-border: rgba(255,255,255,.06) --color-border / --color-border-soft
card/field-bg: header-bg 보다 소폭 밝은 표면
accent: 라이트와 동일 유지 */
html[data-theme="dark"] {
--color-bg: #1a1a1a; /* header --header-bg */
--color-card: #222018; /* 표면 — header-bg 보다 살짝 밝음 */
--color-field-bg: #1e1c14; /* 입력란 — bg 보다 살짝 어두움 */
--color-text: #f5f0e8; /* header --header-text */
--color-text-muted: rgba(245, 240, 232, 0.65); /* header --header-muted */
--color-text-faint: rgba(245, 240, 232, 0.40); /* muted 보다 더 희미 */
--color-on-accent: #3A2E15; /* 골든 버튼 위 글자 — 라이트와 동일 */
--color-border: rgba(255, 255, 255, 0.20); /* 비텍스트 대비 3:1 확보(WCAG 1.4.11) */
--color-border-soft: rgba(255, 255, 255, 0.06); /* header --header-border */
--color-accent: #e8a54b; /* 브랜드 골드 — 라이트와 동일 */
--color-accent-strong: #e8a54b; /* header --accent (다크에서 밝게) */
--color-accent-soft: rgba(212, 168, 83, 0.15); /* 옅은 골든 배경 */
--color-accent-press: #d8973a; /* 라이트와 동일 */
--color-danger: #e07050; /* 다크에서 가독 보강 */
--color-danger-bg: rgba(178, 74, 44, 0.18);
--color-ok: #6aad60; /* 다크에서 가독 보강 */
--color-ok-bg: rgba(78, 122, 72, 0.18);
}
/* ---------- 2. 베이스 ---------- */
body {
margin: 0;
background: var(--color-bg);
font-family: var(--font-sans);
color: var(--color-text);
-webkit-font-smoothing: antialiased;
}
*, *::before, *::after { box-sizing: border-box; }
::selection { background: #F1D79B; }
/* 키보드 포커스 링 (마우스 클릭 시엔 미표시) */
.btn:focus-visible,
.chip:focus-visible,
.link:focus-visible,
a:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
border-radius: var(--radius-sm);
}
/* main 영역 공통 컨테이너(선택) */
.page { max-width: 1120px; margin: 0 auto; padding: 48px 24px; }
.page--center { display: flex; justify-content: center; }
/* ---------- 3. 버튼 ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font: inherit;
font-weight: 700;
font-size: 15px;
line-height: 1;
padding: 13px 24px;
border-radius: var(--radius-md);
border: 1px solid transparent;
text-decoration: none;
cursor: pointer;
transition: background .12s ease, border-color .12s ease;
}
.btn-primary {
background: var(--color-accent);
color: var(--color-on-accent);
border-color: var(--color-accent);
}
.btn-primary:hover { background: var(--color-accent-press); border-color: var(--color-accent-press); }
.btn-ghost {
background: var(--color-card);
color: var(--color-text);
border-color: var(--color-border);
}
.btn-ghost:hover { background: var(--color-field-bg); }
.btn--block { width: 100%; }
.btn--lg { padding: 15px 28px; font-size: 16px; }
/* 비활성 (입력 전 등) */
.btn:disabled,
.btn[disabled],
button:disabled {
background: var(--color-field-bg);
color: var(--color-text-faint);
border-color: var(--color-border);
cursor: not-allowed;
}
/* ---------- 4. 폼 요소 ---------- */
.field { margin-top: 16px; }
.field:first-child { margin-top: 0; }
.field-head {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 6px;
}
.label { font-weight: 600; font-size: 13px; color: var(--color-text-muted); }
.req { color: var(--color-danger); }
.count { font-family: var(--font-mono); font-size: 12px; color: var(--color-text-faint); }
.input,
.textarea {
width: 100%;
font: inherit;
color: var(--color-text);
background: var(--color-field-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 12px 14px;
}
.textarea { min-height: 100px; resize: vertical; line-height: 1.6; }
.input::placeholder,
.textarea::placeholder { color: var(--color-text-faint); }
.input:focus,
.textarea:focus {
outline: none;
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
/* 도움말 / 검증 메시지 */
.help { font-size: 12px; color: var(--color-text-faint); margin: 6px 0 0; }
.form-error {
font-size: 12.5px;
color: var(--color-danger);
background: var(--color-danger-bg);
border-radius: var(--radius-sm);
padding: 8px 12px;
margin: 10px 0 0;
}
.form-ok {
font-size: 12px;
color: var(--color-ok);
margin: 6px 0 0;
}
/* ---------- 5. 비밀번호 필드 (보기 토글) ---------- */
.pw-field {
display: flex;
align-items: center;
background: var(--color-field-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 11px 14px;
}
.pw-field:focus-within {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.pw-field input {
flex: 1;
border: none;
background: none;
outline: none;
font: inherit;
color: var(--color-text);
padding: 0;
}
.pw-toggle {
border: none;
background: none;
font: inherit;
font-size: 13px;
color: var(--color-text-faint);
cursor: pointer;
padding: 0 0 0 10px;
}
.pw-toggle:hover { color: var(--color-text-muted); }
/* 비밀번호 강도 막대 — input의 data-level(0~3)에 반응 */
.pw-meter { display: flex; gap: 6px; margin-top: 8px; }
.pw-meter span { flex: 1; height: 5px; border-radius: 3px; background: #E2D9C8; }
.pw-meter[data-level="1"] span:nth-child(1) { background: var(--color-danger); }
.pw-meter[data-level="2"] span:nth-child(-n+2) { background: var(--color-accent); }
.pw-meter[data-level="3"] span { background: var(--color-ok); }
/* ---------- 6. 칩 (정렬·필터) ---------- */
.chip {
display: inline-flex;
align-items: center;
font-size: 13px;
color: var(--color-text-muted);
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-pill);
padding: 6px 14px;
text-decoration: none;
cursor: pointer;
}
.chip:hover { background: var(--color-field-bg); }
.chip--on {
font-weight: 700;
color: var(--color-on-accent);
background: #F1D79B;
border-color: #F1D79B;
}
/* ---------- 7. 카드 ---------- */
.card {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: 30px;
}
/* ---------- 8. Empty / Error State ---------- */
.empty-state {
background: var(--color-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
padding: clamp(40px, 6vw, 64px) 32px;
text-align: center;
}
.empty-state svg { display: block; margin: 0 auto; }
.empty-state .code {
display: inline-block;
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: .16em;
color: var(--color-accent-strong);
margin-top: 18px;
}
.empty-state h3 {
font-size: 20px;
font-weight: 800;
color: var(--color-text);
margin: 18px 0 0;
}
.empty-state .code + h3 { margin-top: 8px; }
.empty-state p {
color: var(--color-text-muted);
margin: 8px auto 0;
max-width: 400px;
line-height: 1.6;
}
.empty-actions {
display: flex;
gap: 10px;
margin-top: 22px;
justify-content: center;
flex-wrap: wrap;
}
/* ---------- 9. 보조 링크 ---------- */
.link { color: var(--color-accent-strong); text-decoration: none; font-weight: 600; }
.link:hover { text-decoration: underline; }
.link-quiet { color: var(--color-text-faint); text-decoration: underline; }
/* ============================================================
화면별 레이아웃 컴포넌트 ( / 게임상세 / 약관 / 프로필 / )
============================================================ */
/* ---------- 10. 페이지 헤더(섹션 제목) ---------- */
.section-eyebrow { font-family: var(--font-mono); font-size: 12px; letter-spacing: .16em; text-transform: uppercase; color: var(--color-accent-strong); }
.section-title { font-size: 28px; font-weight: 800; letter-spacing: -.02em; margin: 6px 0 0; color: var(--color-text); }
.section-desc { color: var(--color-text-muted); margin: 8px 0 0; line-height: 1.6; }
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
/* ---------- 11. 홈 검색 / 정렬 / 게임 그리드 ---------- */
.search-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 20px; }
.search-row .input { flex: 1; min-width: 240px; }
.sort-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 16px; }
.sort-label { font-size: 12px; font-weight: 700; letter-spacing: .02em; color: var(--color-text-muted); margin-right: 4px; }
.game-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
.game-card { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); overflow: hidden; text-decoration: none; color: inherit; display: block; }
.game-card:hover { box-shadow: 0 6px 20px rgba(0,0,0,.06); }
.game-card .thumb { position: relative; height: 140px; background: linear-gradient(135deg, #EDE3D2, #E2D4BC); display: flex; align-items: center; justify-content: center; }
.game-card .thumb .num { position: absolute; top: 10px; left: 10px; font-family: var(--font-mono); font-size: 11px; background: #fff; border-radius: 6px; padding: 3px 8px; color: var(--color-on-accent); }
.game-card .body { padding: 14px; }
.game-card .body .name { font-weight: 700; }
.game-card .body .sub { font-size: 13px; color: var(--color-text-faint); margin-top: 3px; }
.empty-card { border: 1px dashed #CBBE9E; border-radius: var(--radius-lg); display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 24px; color: #9A8B6E; min-height: 206px; text-decoration: none; }
.empty-card .plus { font-size: 30px; line-height: 1; }
.empty-card .t { font-weight: 700; color: #7A6B4D; margin-top: 8px; }
.empty-card .s { font-size: 12.5px; margin-top: 4px; }
/* ---------- 12. 게임 상세 ---------- */
.detail-meta { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; margin-top: 12px; font-size: 14px; color: var(--color-text-muted); }
.detail-meta .sep { color: #D8CDB8; }
.detail-meta .muted { color: var(--color-text-faint); }
.badge-soft { background: var(--color-accent-soft); color: #A9781F; border-radius: var(--radius-sm); padding: 6px 12px; font-weight: 600; }
.player { position: relative; aspect-ratio: 16 / 9; background: #15110C; border-radius: var(--radius-lg); display: flex; align-items: center; justify-content: center; overflow: hidden; margin-top: 16px; }
.play-btn { background: var(--color-accent); color: var(--color-on-accent); font-weight: 700; border: none; border-radius: var(--radius-pill); padding: 13px 28px; font-size: 15px; cursor: pointer; }
.player .tag { position: absolute; top: 12px; right: 12px; font-family: var(--font-mono); font-size: 11px; background: rgba(255,255,255,.15); color: #fff; border-radius: 6px; padding: 4px 9px; }
.player .fs { position: absolute; bottom: 12px; right: 12px; font-size: 12px; background: rgba(255,255,255,.14); color: #fff; border-radius: var(--radius-sm); padding: 6px 12px; cursor: pointer; }
.review-cta { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 24px; text-align: center; margin-top: 16px; }
.review-cta strong { color: var(--color-text); }
.review-cta p { font-size: 13.5px; color: var(--color-text-muted); margin: 4px 0 0; }
/* ---------- 13. 별점 입력 (순수 CSS hover, JS 없음) ---------- */
.rating { display: inline-flex; flex-direction: row-reverse; }
.rating input { position: absolute; opacity: 0; pointer-events: none; }
.rating label { font-size: 28px; line-height: 1; color: #E2D9C8; cursor: pointer; padding: 0 1px; }
.rating label:hover,
.rating label:hover ~ label,
.rating input:checked ~ label { color: var(--color-accent); }
/* ---------- 14. 이용약관 (목차 + 본문) ---------- */
.terms { display: grid; grid-template-columns: 200px 1fr; gap: 28px; }
.terms-toc { position: sticky; top: 24px; align-self: start; border-right: 1px solid var(--color-border-soft); padding-right: 8px; }
.terms-toc .toc-title { font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; color: var(--color-text-faint); margin-bottom: 10px; }
.terms-toc a { display: block; font-size: 13.5px; color: var(--color-text-muted); padding: 7px 0 7px 14px; text-decoration: none; }
.terms-toc a:hover { color: var(--color-text); }
.terms-toc a.on { color: var(--color-on-accent); font-weight: 700; border-left: 2px solid var(--color-accent); padding-left: 12px; }
.terms-body section { padding: 18px 0; border-bottom: 1px solid var(--color-border-soft); scroll-margin-top: 24px; }
.terms-body section:last-child { border-bottom: none; }
.terms-body h3 { font-size: 17px; font-weight: 800; color: var(--color-text); margin: 0; }
.terms-body p { margin: 8px 0 0; line-height: 1.7; color: #4A4234; font-size: 14px; }
@media (max-width: 720px) { .terms { grid-template-columns: 1fr; } .terms-toc { position: static; border-right: none; } }
/* ---------- 15. 프로필 ---------- */
.profile-head { background: var(--color-card); border: 1px solid var(--color-border); border-radius: var(--radius-lg); padding: 22px; display: flex; align-items: center; gap: 18px; }
.avatar { width: 64px; height: 64px; border-radius: 16px; background: var(--color-accent-soft); display: flex; align-items: center; justify-content: center; font-size: 24px; font-weight: 800; color: var(--color-accent-strong); flex: 0 0 auto; }
.game-row { display: flex; align-items: center; gap: 14px; padding-top: 16px; margin-top: 16px; border-top: 1px solid var(--color-border-soft); }
.game-row .meta { flex: 1; }
.game-row .meta .sub { display: block; font-size: 12.5px; color: var(--color-text-faint); margin-top: 2px; }
/* 공개/비공개 토글 (체크박스 기반, JS 없음) */
.switch { display: inline-flex; align-items: center; gap: 7px; font-size: 13px; color: var(--color-text-faint); font-weight: 600; cursor: pointer; }
.switch input { position: absolute; opacity: 0; }
.switch .track { width: 34px; height: 18px; border-radius: var(--radius-pill); background: #E2D9C8; position: relative; transition: background .15s ease; }
.switch .track::after { content: ""; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: #fff; transition: left .15s ease; }
.switch input:checked + .track { background: var(--color-ok); }
.switch input:checked + .track::after { left: 18px; }
.switch .state-on { display: none; }
.switch input:checked ~ .state-on { display: inline; color: var(--color-ok); }
.switch input:checked ~ .state-off { display: none; }
/* ---------- 16. 라이브 미리보기 (모집글 작성) ---------- */
.preview-card { position: sticky; top: 24px; align-self: start; }
.form-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--color-border-soft); }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 560px) { .form-grid { grid-template-columns: 1fr; } }

View File

@ -29,6 +29,7 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@ -232,6 +233,21 @@ class PostControllerTest {
assertThat(model.getAttribute("nextCursorId")).isNull();
}
/** 회귀(168671b): cursor=null·categoryId=null → PSQLException 없이 "posts-list" 반환. */
@Test
void list_nullCursorAndCategory_returnsPostsView() {
PostController controller = controller();
when(postsMapper.listPublishedKeyset(isNull(), isNull(), isNull(), eq(PAGE_SIZE + 1)))
.thenReturn(List.of());
lenient().when(postCategoriesMapper.listActive()).thenReturn(List.of());
Model model = new ExtendedModelMap();
String view = controller.list(null, null, null, model);
assertThat(view).isEqualTo("posts-list");
assertThat(model.getAttribute("hasNext")).isEqualTo(false);
}
// ==== helpers ====
private PostController controller() {