Compare commits

..

No commits in common. "6154826a82bdb8e10dc01babe1073b9b39255f33" and "fa9a888c5b25a202afbae390f972a97d7c2c96cd" have entirely different histories.

68 changed files with 457 additions and 5236 deletions

View File

@ -1,56 +0,0 @@
# 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

@ -1,71 +0,0 @@
---
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

@ -1,175 +0,0 @@
---
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

@ -1,190 +0,0 @@
---
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

@ -1,542 +0,0 @@
---
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

@ -1,32 +0,0 @@
---
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

@ -1,80 +0,0 @@
---
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

@ -1,268 +0,0 @@
---
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

@ -1,46 +0,0 @@
# 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

@ -1,28 +0,0 @@
---
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

@ -1,59 +0,0 @@
# 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

@ -1,62 +0,0 @@
# 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

@ -1,83 +0,0 @@
# 작업용 프롬프트 — 클로드 디자인 리디자인 산출물 반영 (새 세션용)
> 이 문서는 검토 세션(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

@ -1,53 +0,0 @@
# 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

@ -1,29 +0,0 @@
---
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

@ -1,12 +0,0 @@
---
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

@ -1,36 +0,0 @@
---
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

@ -1,136 +0,0 @@
---
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

@ -1,180 +0,0 @@
---
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

@ -1,67 +0,0 @@
---
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

@ -1,26 +0,0 @@
---
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

@ -1,27 +0,0 @@
---
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

@ -1,171 +0,0 @@
---
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

@ -1,64 +0,0 @@
---
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

@ -1,92 +0,0 @@
---
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

@ -1,107 +0,0 @@
# 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

@ -1,187 +0,0 @@
# 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

@ -1,81 +0,0 @@
---
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

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

View File

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

View File

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

View File

@ -1,261 +0,0 @@
---
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,4 +5,3 @@
## 목록 ## 목록
- [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-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

@ -1,38 +0,0 @@
# 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

@ -1,63 +0,0 @@
# 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

@ -1,64 +0,0 @@
# 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

@ -1,68 +0,0 @@
# 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

@ -1,45 +0,0 @@
# 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,8 +10,3 @@
- [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-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-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-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,40 +36,3 @@ source_session: 20260617-174635
W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다. W3 골자 합의 세션(`20260617-174635`)에서 orchestrator 가 W3-1 마무리 질문을 ID 약어(`C3`/`C6`/`(가)`)와 표로 과압축해 제시 → 사용자: "너무 축약적이라 W3-1에 대한 질문이 뭔질 모르겠어". 배경+선택지+권장 풀어쓰기로 전환하니 즉시 매끄럽게 답변. 첫 제시부터 풀어쓰기를 기본값으로 삼았다면 1회 왕복 비용이 없었다.
> 관련(다른 레포): ATP 번들의 출력 스타일/압축 규약에 동 예외를 명문화하자는 protocol_feedback 가 세션 회고에 기록됨. 적용 *대상* 축은 다르지만 "압축 적용 경계" 교훈은 `caveman-bundle-compression-roi-ceiling`(번들 정적 압축 ROI 천장)과 같은 계열. > 관련(다른 레포): 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,5 +65,3 @@
- 문서 동기화만 했다면 원칙적으로 `changes/` 를 만들지 말고, 대상 기준 문서를 직접 갱신한다. - 문서 동기화만 했다면 원칙적으로 `changes/` 를 만들지 말고, 대상 기준 문서를 직접 갱신한다.
- 장애 수정이 있었더라도 핵심 가치가 장애 원인과 대응 이력 보존이라면 `issues/` 를 우선하고, 필요할 때만 관련 `changes/` 를 추가한다. - 장애 수정이 있었더라도 핵심 가치가 장애 원인과 대응 이력 보존이라면 `issues/` 를 우선하고, 필요할 때만 관련 `changes/` 를 추가한다.
- ADR 에 해당하는 결정이면 `changes/` 대신 `adr/` 에 불변 레코드로 남긴다. - ADR 에 해당하는 결정이면 `changes/` 대신 `adr/` 에 불변 레코드로 남긴다.
- **기존 문서에 append 하기 전에는 그 문서 자신의 스코프 선언과 먼저 대조한다.** 카테고리(`development/` 등)가 맞다는 것만으로 그 카테고리의 "비슷해 보이는" 기존 파일에 바로 적지 않는다. 대상 파일 맨 앞에 "이 파일은 X만 다룬다" 류의 스코프 선언이 있으면, 추가하려는 내용이 그 선언에 부합하는지 확인하고, 부합하지 않으면 새 파일(또는 스코프가 맞는 다른 기존 파일)로 분리한다. 같은 파일 안에 이미 스코프를 벗어난 선례가 있다는 사실은 정당화 근거가 되지 않는다 — 선례 자체가 과거의 같은 실수일 수 있다.
- 사례: `verification-strategies.md`("`verification-advisor` 가 읽는 단일 레지스트리 — 검증 전략만 대상")에 작업 진행 방식(사용자 의사결정 수렴 절차 등)을 "구조적 교훈"이라는 이름으로 반복 혼입 → `workflow-patterns.md` 신설 후 재분류(세션 20260701-113509 계열). index.md 의 카테고리 설명 문구가 실제 파일 스코프보다 넓게 서술돼 있으면 그 문구부터가 오분류 신호일 수 있다 — 문구를 믿기 전에 대상 파일 원문 스코프 선언을 직접 읽는다.

View File

@ -1,47 +0,0 @@
# 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,13 +4,10 @@
## 목록 ## 목록
- [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, 매퍼 alias 케이스폴딩, 보안 vacuous PASS 회피) 포함. - [verification-strategies.md](./verification-strategies.md) — `verification-advisor` 가 읽는 검증 전략 레지스트리 (프로젝트별 `cmd` 를 채워 사용). 설계·테스트 단계 구조적 교훈(SSR 영향맵, fixture 전수 감사, freeze 분류 근거 확인, frontend-design fork 패턴) 포함.
- [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) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리) - [document-category-classification.md](./document-category-classification.md) — 카테고리 분류 기준 (불필요한 카테고리는 프로젝트에 맞게 정리)
- [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정 - [agent-output-conventions.md](./agent-output-conventions.md) — 에이전트 출력 규약. 사용자 대면 의사결정 제시문엔 압축 비적용(배경+선택지+권장 풀어쓰기), 압축은 내부 산출물 한정
- [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본. - [git-workflow.md](./git-workflow.md) — 브랜치 분류(메인스트림 vs 비-메인스트림) · 비-메인스트림 브랜치 커밋 표준 승인 · push 명시 요청 한정 · Conventional Commits + `Co-Authored-By` 트레일러 규약. CLAUDE.md '작업 원칙' 커밋 정책의 정본.
- [local-dev-setup.md](./local-dev-setup.md) - [local-dev-setup.md](./local-dev-setup.md) — 로컬 구동 환경 설정. 업로드 저장 루트(`~/.bibimbap/uploads`, static 트리 밖) 경로 규약 · @Value 기본값 유지 근거 · 자산 이전 이력 · SSRF DNS 캐시 운영 참고.
- [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/...` 로 직접 참조한다. > 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 docker compose logs -f app
``` ```
반영(이미지 재빌드 0): 반영(이미지 재빌드 0):
- **JSP/정적**: 원칙상 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). **단, 반영 안 되는 사례 확인됨** (함정 참조 — 편집 후 `curl` 로 서빙 값이 옛 값이면 즉시반영을 신뢰 말고 `docker compose restart app` 으로 강제 확인). - **JSP/정적**: 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). 재시작 불필요.
- **Java 코드**: `docker compose restart app` (spring-boot:run 재기동 ~1~2s). 또는 `docker compose exec app mvn -o -P dev -DskipTests compile` 후 restart. - **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. - 전제: 호스트 `~/.m2` 에 의존성 캐시 존재(offline `-o`). **새 의존성 추가 시**: override 의 `-o` 를 일시 제거해 1회 온라인 받거나, 호스트에서 `./mvnw -P dev dependency:go-offline` 후 다시 offline.
@ -53,24 +53,10 @@
docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기 docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기
docker compose -f docker-compose.yml up -d --build app 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 은 본 문서 함정 참조). 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` 루프가 더 견고하다. > 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 트리 밖) ## 업로드 저장 루트 (static 트리 밖)
업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`). 업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`).

View File

@ -1,80 +0,0 @@
# 페이지 스크린샷 가이드
앱 실행 중 각 페이지를 파일로 저장하는 방법. 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

@ -1,135 +0,0 @@
"""
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,7 +25,6 @@
| MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) | | MyBatis 매퍼 신규/SQL alias·집계 뷰 정의·변경 (DB-방언 계약) | L1 + L2 (dev DB contract) |
| 인프라 설정 (container/env/compose) | L1 + 수동 스모크 | | 인프라 설정 (container/env/compose) | L1 + 수동 스모크 |
| HTTP 상태코드/예외 매핑 변경 (404/403 등, 전역 `@ExceptionHandler` 경유) | L1 + 런타임 스모크 | | HTTP 상태코드/예외 매핑 변경 (404/403 등, 전역 `@ExceptionHandler` 경유) | L1 + 런타임 스모크 |
| SQL data-only 변경 (DML: seed/백필, 스키마·코드 무변경) | L1 해당없음(명시) + L2(대상 API/DB 조회로 값 일치 확인) |
**회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다. **회귀 테스트 의무**: 버그 수정 커밋은 해당 버그를 재현하는 테스트를 같이 포함한다. revert 시 테스트가 실패하고, 수정 후엔 통과해야 한다.
@ -148,6 +147,29 @@ strategies:
> 근거: W3-2 고도화 세션(20260622) — B2(본문 10자)·6축 필수 계약 도입 시 기존 fixture 8건이 미갱신되어 L1 RED. 컨트롤러는 무버그. > 근거: 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` 는 "(검증)" 소유태그여도 구현 산출물 ### 신규 `*Test.java` 는 "(검증)" 소유태그여도 구현 산출물
design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음). design 파일 영향맵에서 신규 테스트 파일이 owner 칸에 `(검증)` 으로 표시되더라도, **테스트 작성은 implementation 단계의 산출물**이다. `verification-advisor` 는 테스트를 **실행만** 하며 Write 권한이 없다. `(검증)` 은 "검증 관련 파일"이라는 용도 라벨일 뿐 작성 주체가 아니다. implementation 단계에서 파일 영향맵의 신규 `*Test.java` 를 전수 작성해야 하며, 빠뜨리면 verification 단계에서 시나리오 AC(VP) 가 "미커버"로 떨어진다(테스트가 없으니 실행할 게 없음).
@ -190,6 +212,12 @@ 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)로 전환. > 근거: 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(실반영) 레이어로 분리 ### 런타임/환경 의존 보장은 L1(값 확인)과 L3(실반영) 레이어로 분리
설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")``InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다. 설정값이 "코드에서 세팅되는가" 와 "런타임에 실제 반영되는가" 가 다른 레이어인 경우(예: `Security.setProperty("networkaddress.cache.ttl")``InetAddressCachePolicy` lazy init 경합으로 best-effort), 단위검증으로 **값 설정만** 확인하고 **실제 런타임 반영은 L3 스모크**로 분리 배정한다. best-effort 한계와 결정적 정본(예: JVM `$JAVA_HOME/conf/security/java.security`)을 코드 주석·체크리스트에 함께 명시해, "값=설정됨" 을 "동작=보장됨" 으로 오인하지 않게 한다.
@ -198,36 +226,6 @@ 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` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다. 아래 항목은 ATP 플러그인 번들(`~/.claude` 전역) 대상이다. 본 프로젝트 파일에서 직접 수정하지 않고 기록만 한다.
@ -241,7 +239,6 @@ nullable 파라미터 타입별 캐스트:
- **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS. - **verification-advisor 체크리스트**: 보안 거부 단위테스트는 정상경로 성공(present) 테스트와 쌍으로 차별 입증(distinguishing assertion) 확인 조항. 근거: W3-3 vacuous PASS.
- **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭. - **orchestrator scope-fence 조항(구조적)**: 단일기능 표시면이 선행기능 컨트롤러에 의존할 때 cross-feature display wiring fence 예외 선언 또는 deferral 분리. 근거: W4 배지 표시 AC 갭.
- **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택. - **design-advisor 조항**: 트레이드오프 concern 은 1차 권장안 + 조건부 전환 기준까지 선결정으로 좁힘. 근거: W3-3 SSRF IP핀닝 오선택.
- **§4.4 item5 를 *선행 게이트* 로 강화**: 현재 item5(가시성·표현·레이아웃 축 누락 점검)는 옵션 *작성 후* 사후 점검이라, "비율/가시성 떨어진다" 같은 **모호 어휘를 옵션 설계 이전에 단일 축으로 협소화**하면 가드가 작동할 시점을 이미 지난다. 모호 시각 결함 어휘 수신 시 옵션 축 확정 *전에* 다축 스캔(레이아웃비율·종횡비·대비가시성·타이포 최소 4축)을 의무화하는 선행 게이트로 승격 권고. 근거: 세션 20260630-175023 — "비율"을 카드 종횡비 단일 축으로 조기 협소화 → 사용자가 "검색바도 혼자 짧다"로 레이아웃 비율 축 직접 추가.
### HTTP 상태/예외 매핑 변경은 런타임 스모크 의무 (L1 통과로 충분치 않음) ### HTTP 상태/예외 매핑 변경은 런타임 스모크 의무 (L1 통과로 충분치 않음)
@ -254,27 +251,3 @@ nullable 파라미터 타입별 캐스트:
implementation-advisor 가 여러 code-writer worker 로 파일을 분산 수정한 뒤 반환하는 완료 summary 는 worker 상태 추적 혼선으로 **실제 변경과 불일치**할 수 있다("Worker C만 완료" 보고했으나 실제 4파일 적용 등). orchestrator 는 advisor summary 를 그대로 신뢰하지 말고 `git diff --stat` / 대상 파일 grep 으로 **실변경을 교차검증**한 뒤 다음 단계로 진행한다. 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 미변경은 "입력 컨트롤 없음=정상"으로 판별. > 근거: 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

@ -1,76 +0,0 @@
# 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 kind: graphify-meta
last_generated_at: 2026-07-01T14:35:00+0900 last_generated_at: 2026-06-30T00:00:00+0900
source_commit: 86b0528 source_commit: 2c48184
scopes: scopes:
- full - full
--- ---
@ -39,7 +39,7 @@ scope 예시: `full`, `src`, `docs`, `src-features` 등.
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 | | scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
| --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- |
| `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 정적자산은 여전히 스캔 범위 외. | | `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). |
## 갱신 시 체크리스트 ## 갱신 시 체크리스트

View File

@ -181,20 +181,3 @@
- [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다. - [ ] 업로드: `../`, 절대 경로, nested zip, 과대 zip을 거부한다.
- [ ] XSS: 게임 이름, 제작자 한마디, 댓글, 검색어가 HTML로 실행되지 않는다. - [ ] 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,25 +294,6 @@
</plugins> </plugins>
</build> </build>
</profile> </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> </profiles>
</project> </project>

View File

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

View File

@ -67,42 +67,81 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
} }
/* empty-state */ .error-panel {
.empty-state { 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);
text-align: center; text-align: center;
padding: 48px 24px;
max-width: 480px;
margin: 0 auto;
} }
.empty-state svg { display: block; margin: 0 auto; } .error-panel__media {
.empty-state .code { width: 8rem;
display: block; height: 8rem;
font-size: 12px; 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;
font-weight: 700; font-weight: 700;
letter-spacing: .1em;
color: var(--accent); color: var(--accent);
margin-top: 16px; letter-spacing: 0.02em;
} }
.empty-state h3 { .error-panel__title {
font-size: 1.35rem; margin: 0;
font-weight: 800; font-size: clamp(1.75rem, 4vw, 2.5rem);
line-height: 1.2;
letter-spacing: 0;
color: var(--text); color: var(--text);
margin: 8px 0 0;
} }
.empty-state p { .error-panel__description {
color: var(--text-muted); max-width: 28rem;
margin: 1rem auto 0;
font-size: 0.9375rem; font-size: 0.9375rem;
line-height: 1.65; line-height: 1.65;
margin: 12px 0 0; color: var(--text-muted);
} }
.empty-actions { .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 {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; justify-content: center;
gap: 10px; gap: 0.625rem;
margin-top: 24px; margin-top: 1.5rem;
} }
.btn { .error-panel__button {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -118,42 +157,29 @@
cursor: pointer; cursor: pointer;
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
} }
.btn:hover { .error-panel__button--primary {
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; border-color: transparent;
color: var(--button-text); color: var(--button-text);
background: var(--accent); background: var(--accent);
} }
.btn-ghost { .error-panel__button:hover {
border-color: var(--border); border-color: rgba(232, 165, 75, 0.45);
background: transparent; box-shadow: 0 4px 12px var(--card-shadow);
} }
/* error meta detail */ .error-panel__button:active {
.error-meta { transform: scale(0.98);
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) { @media (max-width: 480px) {
.page-main { align-items: flex-start; padding-top: 1.5rem; } .page-main {
.btn { width: 100%; } align-items: flex-start;
padding-top: 1.5rem;
}
.error-panel {
padding: 1.5rem 1rem;
}
.error-panel__button {
width: 100%;
}
} }
</style> </style>
</head> </head>
@ -167,53 +193,25 @@
if (message == null || String.valueOf(message).isBlank()) { if (message == null || String.valueOf(message).isBlank()) {
message = exception != null ? exception.getMessage() : null; message = exception != null ? exception.getMessage() : null;
} }
int code = (statusCode != null) ? (Integer) statusCode : 0;
%> %>
<main class="page-main"> <main class="page-main">
<div class="empty-state"> <section class="error-panel" aria-labelledby="error-title">
<% if (code == 403) { %> <div class="error-panel__media" aria-hidden="true">
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true"> <img class="error-panel__logo" src="<%= ctx %>/images/logo.png" alt="" width="120" height="120" />
<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> </div>
<p class="error-panel__eyebrow">
<% if (statusCode != null) { %>
ERROR <%= statusCode %>
<% } else { %> <% } else { %>
<svg width="86" height="86" viewBox="0 0 86 86" fill="none" aria-hidden="true"> ERROR
<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) { %> <% if (requestUri != null || message != null) { %>
<div class="error-meta" aria-label="오류 상세 정보"> <div class="error-panel__meta" aria-label="오류 상세 정보">
<% if (requestUri != null) { %> <% if (requestUri != null) { %>
<div>요청 경로: <%= htmlEscape(requestUri) %></div> <div>요청 경로: <%= htmlEscape(requestUri) %></div>
<% } %> <% } %>
@ -222,7 +220,11 @@
<% } %> <% } %>
</div> </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> </div>
</section>
</main> </main>
<jsp:include page="/WEB-INF/views/footer.jsp"/> <jsp:include page="/WEB-INF/views/footer.jsp"/>
</body> </body>

View File

@ -1,21 +0,0 @@
<%@ 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

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

View File

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

View File

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

View File

@ -202,30 +202,6 @@
gap: 0.625rem; 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> </style>
</head> </head>
<body> <body>
@ -252,11 +228,7 @@
</div> </div>
<div class="auth-field"> <div class="auth-field">
<label class="auth-field__label" for="login-password">비밀번호</label> <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 /> <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>
<div class="auth-row"> <div class="auth-row">
<label class="auth-check"> <label class="auth-check">
@ -264,7 +236,6 @@
<span>로그인 유지</span> <span>로그인 유지</span>
</label> </label>
</div> </div>
<div class="form-error" id="login-form-error" hidden></div>
<button class="auth-button auth-button--primary" type="submit">로그인</button> <button class="auth-button auth-button--primary" type="submit">로그인</button>
</form> </form>

View File

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

View File

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

View File

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

View File

@ -200,37 +200,6 @@
grid-template-columns: 1fr; 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> </style>
</head> </head>
<body> <body>
@ -264,16 +233,11 @@
<div class="auth-grid"> <div class="auth-grid">
<div class="auth-field"> <div class="auth-field">
<label class="auth-field__label" for="signup-password">비밀번호</label> <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>
<div class="auth-field"> <div class="auth-field">
<label class="auth-field__label" for="signup-password-confirm">비밀번호 확인</label> <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>
</div> </div>
<label class="auth-check"> <label class="auth-check">
@ -283,7 +247,6 @@
<a class="auth-link" href="<%= ctx %>/operation-policy" target="_blank" rel="noopener noreferrer">운영정책</a>에 동의합니다. <a class="auth-link" href="<%= ctx %>/operation-policy" target="_blank" rel="noopener noreferrer">운영정책</a>에 동의합니다.
</span> </span>
</label> </label>
<div class="form-error" id="signup-form-error" hidden></div>
<button class="auth-button auth-button--primary" type="submit">회원가입</button> <button class="auth-button auth-button--primary" type="submit">회원가입</button>
</form> </form>

View File

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

View File

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

View File

@ -1,394 +0,0 @@
/* ============================================================
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,7 +29,6 @@ import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@ -233,21 +232,6 @@ class PostControllerTest {
assertThat(model.getAttribute("nextCursorId")).isNull(); 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 ==== // ==== helpers ====
private PostController controller() { private PostController controller() {