---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-18T11:05:00+09:00
concerns:
- "동결/권위 영역: db/schema.sql 의 game_comments/game_likes/games/users 는 '비권위 복원본'(타입 추론). game_reviews 신규 DDL 작성 시 인접 타입을 권위로 신뢰 금지 — recruit_posts(권위) 스타일을 따를 것."
- "현존 게임 상세의 댓글/좋아요는 100% 클라이언트 localStorage 다(서버 미연동). W3-2 가 '분리'가 아니라 사실상 '서버 영속화 신설'을 포함함 — 요구사항 advisor 전제(이미 서버 댓글이 있다는 가정)가 있다면 깨짐."
- "JSP 두 종 header 혼재: 활성 header 는 views/header.jsp(커스텀 세션·BibimbapCsrf). jsp/fragments/header.jspf 는 Spring Security taglib(sec:authorize, _csrf.parameterName) 기반 미사용 잔재 — 신규 작업에서 절대 참조 금지."
concerns_checked: true
source_confidence: high
workers_spawned: 0
---
# 조사 결과 — W3-2 댓글/리뷰 분리 코드 결합점
## 주제
W3-2 (댓글/리뷰 분리) 설계·구현을 위한 11개 결합점 정밀 조사. 모든 발견은 file:line 근거. 본 산출물은 전부 프로젝트 내부 코드 1차 확인(확인됨). 외부 자료 미사용 → source_confidence: high.
> 조사 방식: parallel-explorer worker 미사용. 11개 포인트가 동일 소규모 코드베이스(~25 Java + 17 JSP)에 밀결합되어 있어 advisor 가 직접 Read/Grep 으로 전수 확인. 모든 항목 1차 출처 직접 확인됨.
---
## 포인트별 발견
### 포인트 1: 댓글 도메인 CRUD 전체 [확인됨]
- **Mapper**: `mapper/GameCommentsMapper.java`
- `GameCommentData getGameComment(long id)` — `:13-25` (SELECT, `WHERE id=#{id} AND is_delete IS NOT TRUE`)
- `int addGameComment(GameCommentData)` — `:27-39` (INSERT game_id/nickname/content, `@Options(useGeneratedKeys=true, keyProperty="id")`)
- `int updateGameComment(GameCommentData)` — `:41-51` (nickname/content/deleted_at 갱신 + `is_delete = CASE WHEN deletedAt IS NULL THEN false ELSE true END`)
- **DELETE/list 메서드 없음**. game_id 별 목록 조회 메서드도 없음(soft-delete 일괄은 GamesMapper 에 있음 — 포인트 8).
- **POJO**: `data/GameCommentData.java:5-61` — 필드: `Long id, Long gameId, String nickname, String content, OffsetDateTime createdAt, OffsetDateTime deletedAt`. (userId 없음 — 작성자 식별 컬럼 부재, nickname 만 있음)
- **컬럼**(db/schema.sql:99-108, 비권위): `id bigint`, `game_id bigint NOT NULL REFERENCES games(id)`, `nickname varchar(100)`, `content text`, `created_at timestamptz DEFAULT now()`, `deleted_at timestamptz`, `is_delete boolean DEFAULT false`. (요청서가 언급한 6컬럼 + is_delete 7개 전부 일치)
- **컨트롤러 엔드포인트**: **존재하지 않음**. `GameCommentsMapper` 를 주입/호출하는 컨트롤러 없음(rg 확인). 즉 서버측 댓글 작성/조회/삭제 HTTP API 가 전무.
- **서비스 계층**: 없음(프로젝트 전체가 controller→mapper 직결 구조).
- **작성/조회/삭제 흐름**: 현재 댓글은 서버 미연동. game-detail.jsp 가 localStorage 로만 처리(포인트 2). GameCommentsMapper 는 사실상 orphan(유일 사용처: 게임 삭제 cascade 의 `GamesMapper.softDeleteGameComments`, 포인트 8).
### 포인트 2: 게임 상세 페이지 [확인됨]
- **핸들러**: `controller/api/GameController.java:103-129` `@GetMapping("/game/{id}") String gameDetail(long id, Model, HttpSession)`.
- DB 게임 존재 시 `addGameModel(model, game, sessionUserId(session))` → view `"game-detail"` (`:104-109`).
- DB miss 시 `GameCatalog` 정적 폴백(`:111-128`).
- **model attribute**(`addGameModel` `:254-268`): `gameId, gameName, creator, likeCount, likeCountFormatted, creatorNote, gitUrl, webglUrl, webglFrameSrc, webglDeployPath, owner`. `owner = currentUserId != null && currentUserId.equals(game.getUserId())` (`:267`).
- **game-detail.jsp 댓글/좋아요 실제 코드**(localStorage):
- 좋아요 버튼 `#game-like-btn` HTML `:730-735`; 좋아요 JS 전부 localStorage(`LIKE_KEY='bibimbap-game-liked'`) — `getLikedMap :815-823`, `setLiked :825-832`, `localStorage.setItem :830`, 클릭 핸들러 `:904-908` (서버 POST/DELETE 없음).
- 댓글 폼 `#game-comment-form` HTML `:790-801` (textarea name="comment" maxlength=1000, 작성자 입력란 없음).
- 댓글 JS 전부 localStorage(`COMMENT_KEY='bibimbap-game-comments'`) — `getComments :911-920`, `saveComments :922-930`(`localStorage.setItem :928`), 렌더 `:938-992`, submit 핸들러 `:994-1005`(닉네임 하드코딩 `DEFAULT_NICK='익명' :936`, id=crypto.randomUUID).
- 라인 근거: 요청서가 지목한 812(LIKE_KEY)/830(setItem)/913(getComments raw)/928(saveComments setItem) 전부 위치 확정.
### 포인트 3: 로그인/세션 인증 패턴 [확인됨]
- **인증 방식**: Spring Security 아님. **커스텀 HttpSession attribute** 기반.
- **로그인 컨트롤러**: `controller/api/UserController.java` — `@PostMapping("/login") :122-168`, `/signup :65-120`, `/logout :170-179`. 로그인 페이지(GET)는 `WebMvcController.mainView` switch `case "login" :75-80`.
- **세션 저장**(`UserController.saveLoginSession :502-525`): `session.setAttribute("userId", user.getId())`(Long), 그 외 `id, displayName, email, avatarUrl, role, status, authProvider, authIdentityId, lastLoginAt`, 그리고 `account`(LinkedHashMap 복제본). 로그인 시 `request.changeSessionId() :160`(세션 고정 방어).
- **현재 로그인 사용자 얻는 코드**(컨트롤러 공통 헬퍼, 3곳에 동일 복붙): `sessionUserId(HttpSession)` — `GameController:291-307`, `RecruitController:155-171`, `UserController:333-349`. `session.getAttribute("userId")` 를 Number/String→Long 변환, 없으면 null. (WebMvcController 는 `:118-127` 변형 — null 대신 `IllegalStateException` throw.)
- **UserData 필드 전체**(`data/UserData.java:5-15`): `Long id, String displayName, String canonicalEmail, String avatarUrl, String role, String status, OffsetDateTime lastLoginAt, createdAt, updatedAt`. **id 타입 Long, role 타입 String**(기본값 "USER", UserController:43 `ROLE_USER="USER"`). 운영자 role 명칭은 코드상 미정의(USER 만 발급됨) — 포인트 6 참조.
- **비로그인 처리**: 페이지는 `redirect:/login`(RecruitController:43-46, GameController:133-135, WebMvcController:82-83). AJAX/상태변경 API 는 `401 UNAUTHORIZED + {status,message}` JSON(GameController:57-59 등).
### 포인트 4: CSRF 현황 (설계 핵심 제약) [확인됨]
- **spring-security 의존**: **없음**. pom.xml 의존성 = web, mybatis-spring-boot-starter, postgresql, tomcat-embed-jasper, lombok, starter-tomcat(provided), starter-test(test) (`pom.xml:53-87`). starter-security 부재.
- **SecurityConfig / SecurityFilterChain / @EnableWebSecurity**: **클래스 없음**(rg 확인 0건).
- **CSRF 인프라**: **커스텀 자체 구현 존재** — `security/CsrfTokens.java`:
- `SESSION_ATTRIBUTE="csrfToken" :12`, `HEADER_NAME="X-CSRF-Token" :13`.
- `getOrCreate(HttpSession) :20-33`(세션에 토큰 발급/재사용, Base64 32바이트).
- `isValid(HttpServletRequest) :35-49` — 헤더 `X-CSRF-Token` 우선, 없으면 파라미터 `_csrf`(`:46`). 세션 토큰과 `.equals` 비교.
- `errorBody() :51-56` → `{status:403, message:"요청 보안 토큰이 유효하지 않습니다."}`.
- **기존 POST/AJAX 의 토큰 전달 방식**:
- **뷰 노출**: `theme-init.jsp:5-7` 가 `` 출력. 모든 페이지가 theme-init.jsp 를 include 하므로 메타 토큰이 전역 제공됨.
- **JS 헬퍼**: theme-init.jsp:19-31 `window.BibimbapCsrf` = `{token():meta 읽기, headers(extra):extra+{'X-CSRF-Token':token}}`.
- **form hidden**: login.jsp:221 ``(signup.jsp 동일).
- **AJAX 사용례**: login.jsp:283 `BibimbapCsrf.headers(...)`, game-detail.jsp:850(삭제) 동일 패턴.
- **서버 검증례**: 모든 상태변경 핸들러 진입부 `if(!CsrfTokens.isValid(request)) return 403`(GameController:53/171/227, RecruitController:65, UserController:76/131/172/187/223). 테스트: `test/.../UserControllerCsrfTest.java`.
- **설계 함의**: 신규 댓글/리뷰 POST/DELETE 는 **반드시 `CsrfTokens.isValid(request)` 게이트 + 클라이언트 `BibimbapCsrf.headers()` 사용**. 새 CSRF 인프라 신설 불필요(재사용).
### 포인트 5: DDL/스키마 적용 방식 [확인됨]
- **부트스트랩**: `db/schema.sql` 가 전체 스키마. flyway/liquibase **없음**(docs/usage/local-setup.md:139 "flyway/liquibase 가 없다").
- **적용법**:
- Docker: db 컨테이너 최초 기동 시 `db/schema.sql` 이 `docker-entrypoint-initdb.d` 로 자동 1회 실행(dev 스키마 채움, live 는 빈 스키마). 재적용은 `docker compose down -v` 후 재기동(local-setup.md:149).
- 호스트 로컬 PG: `psql -f db/schema.sql` 수동(local-setup.md:150).
- **권위 패턴**: `docs/recruit-posts-ddl.sql`(권위) 가 신규 테이블 표준 스타일. `docs/security-hardening-ddl.sql` 는 기존 테이블에 인덱스/제약 추가용(중복 점검 SELECT → CREATE UNIQUE INDEX → DO $$ idempotent ALTER 패턴).
- **DbUpdateQueryGenerator**: 테스트 `test/.../DbUpdateQueryGeneratorTest.java` + `test/db/dev-to-live-update.sql` 존재(surefire 에서 제외됨, pom.xml:130-133). dev→live 스키마 동기화 SQL 생성 용도로 보임 — **마이그레이션 자동화 도구 아님**(테스트성).
- **신규 테이블(game_reviews) 추가 절차**: ① `db/schema.sql` 에 `SET search_path TO dev` 블록 내 CREATE TABLE 추가, ② 권위 DDL 파일을 docs/ 에 별도 작성(recruit-posts-ddl.sql 선례), ③ 기존 DB 적용용 idempotent ALTER 스크립트(security-hardening-ddl.sql 선례). 파일 위치: `db/schema.sql` + `docs/*-ddl.sql`.
### 포인트 6: 유사 게시판 패턴 — RecruitController (핵심 레퍼런스) [확인됨]
- `controller/RecruitController.java` + `mapper/RecruitPostsMapper.java`.
- **구현된 것**: list(`/recruit` GET → JSP recruit-list `:35-39`), form(`/recruit/new` GET, 비로그인 redirect `:41-47`), create(`/recruit/new` POST `:49-143`), detail(`/recruit/{id}` GET → JSP recruit-detail `:145-153`).
- **create 패턴(댓글/리뷰가 그대로 따를 표준)**:
1. `@Transactional` (`:50`)
2. CSRF 우선 검증 `if(!CsrfTokens.isValid(request)) 403` (`:65-67`)
3. 로그인 검증 `userId = sessionUserId(session); if null → 401` (`:68-71`)
4. trimToNull/trimToEmpty 정규화 + 길이/허용값(Set.contains) 검증, 위반 시 `400 BAD_REQUEST` (`:73-114`)
5. data POJO 세팅 후 mapper.add, 생성 id null 체크 → `500` (`:132-135`)
6. 성공 응답 = **JSON** `{status:200, message, recruitPostId, location:"/recruit/{id}"}` (`:137-142`)
- **응답 형식**: 상태변경(POST/DELETE)은 `ResponseEntity