Compare commits

..

No commits in common. "ee9487e118c079e718feec23c45b4aa35b179e9d" and "e1790423e4ebe1d97656cb485c93eeb16cc14631" have entirely different histories.

25 changed files with 52 additions and 1335 deletions

View File

@ -1,221 +0,0 @@
---
phase: design
agent: design-advisor
agent_version: 1
generated_at: 2026-06-29T06:40:00Z
concerns:
- "GameLikesMapper 신규 메서드(findByGameAndUser/deleteByGameAndUser/countByGame)의 인자(gameId, userKey)는 토글/멱등/정합 보정 전 경로에서 전부 실사용됨 — inflate 아님. 구현 시 재확인."
- "game_likes (game_id,user_key) UNIQUE 제약 부재(schema.sql 비권위 복원본, 운영 pg_dump 미대조). 본 설계는 애플리케이션 레벨 select-then-act + 단일 트랜잭션으로 중복을 방지하되, 운영 DB에 UNIQUE 제약이 없으면 동시 더블클릭에서 중복 INSERT 가능. 마이그레이션으로 UNIQUE 추가를 권고(롤아웃 §)하되, 운영 스키마 권위 확인은 orchestrator 경유 에스컬레이션 필요."
- "기존 like_count 컬럼 값과 game_likes 실제 row 수의 초기 불일치(레거시·seed 데이터) 가능. 본 설계는 토글 시 컬럼을 ±1 상대 증감(절대 재계산 아님)하므로 기존 오차는 보존됨 — 정합 재계산은 비목표(롤아웃 §에 선택적 보정 쿼리만 제시)."
concerns_checked: true
references:
requirements: null
research: /Users/wemadeplay/workspace/stz/bibimbap/.atp/work-session/20260629-151216/research/like-count-rootcause.md
adrs: []
---
# 설계: 게임 "좋아요" 서버 영속화
## 목표 / 비목표
### 목표
- (G1) 게임 좋아요를 서버에 영속한다: `game_likes` row 추가/삭제로 사용자별 좋아요 상태를 저장한다. (근본원인 §포인트1 — 토글이 DB에 전혀 안 써짐)
- (G2) 토글과 동시에 `games.like_count` 비정규화 컬럼을 단일 트랜잭션으로 ±1 동기화한다. (사용자 결정 D1)
- (G3) explore 5개 조회 쿼리(GamesMapper.java:28/51/75/206/275/312 의 `g.like_count AS likeCount`)는 **무변경**으로 증가된 컬럼을 읽어 좋아요 수가 반영된다. (D1)
- (G4) 좋아요 주체 = 로그인 사용자(sessionUserId). 미로그인은 거부(401)하고 JSP가 로그인 유도. 사용자당 게임당 1회(멱등). (D2)
- (G5) 상세페이지 좋아요 버튼 초기 상태를 서버 진실(현재 사용자의 기존 좋아요 여부)로 렌더하고, 클릭 시 fetch POST로 서버 토글 → 응답값으로 화면 갱신. localStorage 의존 제거. (근본원인 §포인트3)
### 비목표
- explore/index/profile JSP의 좋아요 표시 로직 변경 (무변경 확인이 검증 대상).
- `games.like_count` 절대값을 `game_likes` COUNT로 전면 재계산하는 정합 배치 (선택적 보정 쿼리만 롤아웃에 제시, 본 기능 범위 밖).
- 미로그인 사용자에 대한 익명/세션키 기반 좋아요 (D2가 로그인 사용자 기준으로 확정).
- 좋아요 수 실시간 COUNT 집계로의 출처 전환 (D1이 컬럼 비정규화 방식으로 확정).
## 개요
좋아요는 현재 어떤 서버 엔드포인트도 호출하지 않고 브라우저 localStorage에만 토글된다. 본 설계는 (1) 단일 POST 토글 엔드포인트 `POST /game/{id}/like`를 추가하고, (2) `game_likes` row와 `games.like_count` 컬럼을 한 트랜잭션에서 동기 변경하며, (3) 상세 컨트롤러가 현재 사용자의 기존 좋아요 여부를 조회해 모델에 주입하고, (4) game-detail.jsp의 좋아요 버튼 JS를 서버 fetch로 교체한다.
좋아요 주체 식별자는 기존 `GameLikeData.userKey`(varchar(200), String)에 **로그인 userId의 문자열 표현**을 그대로 저장한다(`String.valueOf(userId)`). 토글-off는 `updateGameLike`(PK 기준 무의미)가 아니라 **row DELETE**로 표현한다 — 현 GameLikesMapper에는 (game_id,user_key) 조회/삭제/카운트 메서드가 없으므로 신규 메서드 3개를 추가한다.
## 플로우
### 토글 엔드포인트 (POST /game/{id}/like)
진입점: `GameController.toggleLike(id, request, session)`
1. **CSRF 게이트(먼저)**: `!CsrfTokens.isValid(request)``403 FORBIDDEN` + `CsrfTokens.errorBody()`. (기존 난제1 순서 = CSRF 우선, GameController.java:75-77 패턴)
2. **로그인 게이트(다음)**: `Long userId = sessionUserId(session); if (userId == null)``401 UNAUTHORIZED` + `response(UNAUTHORIZED, "로그인이 필요합니다.")`. (GameController.java:78-81 패턴)
3. **게임 존재 검증**: `GameData game = gamesMapper.getGame(id); if (game == null)``404 NOT_FOUND`.
4. **현재 좋아요 조회**: `String userKey = String.valueOf(userId); GameLikeData existing = gameLikesMapper.findByGameAndUser(id, userKey);`
5. **분기 (단일 트랜잭션, `@Transactional`)**:
- existing == null (좋아요 추가): `gameLikesMapper.addGameLike(new GameLikeData{gameId=id, userKey})``gamesMapper.incrementLikeCount(id)``liked=true`.
- existing != null (좋아요 취소): `gameLikesMapper.deleteByGameAndUser(id, userKey)``gamesMapper.decrementLikeCount(id)``liked=false`.
6. **응답 카운트 조회(트랜잭션 내, 진실 반환)**: `int likeCount = gamesMapper.getLikeCount(id);` — 동기 변경 직후 컬럼값을 다시 읽어 응답한다(클라이언트 ±1 추정 제거).
7. 종단: `200 OK` `{ status:200, liked:<bool>, likeCount:<int> }`.
멱등성: 같은 사용자가 "좋아요 추가" 상태에서 다시 추가를 시도해도 4의 existing 조회가 row를 찾아 취소 경로로 분기하므로(토글 의미) 중복 INSERT는 발생하지 않는다. UI는 토글이므로 "이미 좋아요한 상태에서 좋아요 버튼" = 취소가 의도된 멱등 동작이다.
### 상세페이지 초기 상태 (GET /game/{id})
- `gameDetail` 핸들러가 `addGameModel(model, game, sessionUserId(session))` 호출(GameController.java:138). `addGameModel`에 currentUserId가 이미 전달됨.
- `addGameModel` 내부에서: `boolean liked = currentUserId != null && gameLikesMapper.findByGameAndUser(game.getId(), String.valueOf(currentUserId)) != null;``model.addAttribute("liked", liked);`
- 카탈로그 폴백 경로(GameController.java:149-166, DB에 없는 GameCatalog 게임)는 좋아요 영속 대상 아님 → `model.addAttribute("liked", false);` 추가(JSP가 `${liked}` 참조하므로 누락 시 EL null → 방어).
## 데이터 모델
### game_likes (기존 테이블 재사용, schema.sql:254-261)
컬럼 무변경: id(bigint PK), game_id(bigint NOT NULL FK→games), user_key(varchar(200) NOT NULL), created_at(timestamptz DEFAULT now()).
- `user_key` 의미: 로그인 사용자 userId의 문자열(`String.valueOf(userId)`). varchar(200)이므로 Long 문자열 길이 충분.
- 토글-off 표현: liked flag 컬럼 없음 → **row 물리 삭제(DELETE)**. (기존 deleteGameLikes가 이미 hard delete 사용, schema.sql:251 주석 "매퍼는 hard delete 사용, is_delete 컬럼 없음"과 일관)
### 마이그레이션 (권고, concerns 참조)
운영 DB에 `(game_id, user_key)` UNIQUE 제약이 없으면 동시 더블클릭 시 중복 row 가능. 마이그레이션 SQL(롤아웃 §) 권고:
```sql
ALTER TABLE game_likes ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
```
schema.sql(비권위 복원본)에도 동일 제약을 반영한다. 단 운영 스키마 권위 확인은 orchestrator 경유 에스컬레이션(concerns).
## 외부 계약
### 신규: POST /game/{id}/like
- 메서드/경로: `POST /game/{id}/like` (기존 GameController 라우팅 규약 `/game/{id}/...` 따름, GameController.java:196 `/game/{id}/edit` 와 동형)
- 요청: PathVariable `id`(long). CSRF는 헤더 `X-CSRF-Token`(JSP fetch가 `window.BibimbapCsrf.headers()`로 부착) 또는 바디 `_csrf`(CsrfTokens.isValid가 둘 다 수용, CsrfTokens.java:44-47). 추가 바디 파라미터 없음.
- 응답 JSON (200): `{ "status": 200, "liked": true|false, "likeCount": <int> }`
- 실패 응답:
- CSRF 무효 → 403 `{ status:403, message:"요청 보안 토큰이 유효하지 않습니다." }` (CsrfTokens.errorBody())
- 미로그인 → 401 `{ status:401, message:"로그인이 필요합니다." }`
- 게임 없음 → 404 `{ status:404, message:"게임을 찾을 수 없습니다." }`
게이트 순서: CSRF(403) → 로그인(401) → 존재(404). (기존 컨트롤러 메서드 전부 동일 순서)
## 신규/변경 시그니처 (inflate 방지: 각 인자 사용목적 인라인)
### GameLikesMapper (신규 메서드 3개 추가)
```java
// (game_id, user_key) 로 현재 좋아요 row 조회 — 토글 분기/초기 liked 판정에 사용
@Select("SELECT id, game_id AS gameId, user_key AS userKey, created_at AS createdAt "
+ "FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
GameLikeData findByGameAndUser(@Param("gameId") long gameId, // 대상 게임 식별
@Param("userKey") String userKey); // 좋아요 주체(=userId 문자열)
// 좋아요 취소 시 row 삭제 — 토글-off 경로에 사용
@Delete("DELETE FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
int deleteByGameAndUser(@Param("gameId") long gameId, // 대상 게임
@Param("userKey") String userKey); // 좋아요 주체
```
- `addGameLike(GameLikeData)`**기존 재사용**(gameId, userKey 세팅 후 호출). getGameLike/updateGameLike 는 본 기능에서 미사용(기존 dead code 유지, 본 설계가 제거하지 않음 — 범위 밖).
- (참고) countByGame 류 COUNT 집계 메서드는 **추가하지 않는다**: D1이 컬럼 비정규화 방식이고 응답 카운트는 `gamesMapper.getLikeCount`로 컬럼을 읽으므로 불필요. inflate 회피.
### GamesMapper (신규 메서드 3개 추가)
```java
// 좋아요 추가 시 컬럼 +1 (단일 트랜잭션 내) — #{} 바인딩, ${} 금지
@Update("UPDATE games SET like_count = like_count + 1 WHERE id = #{id} AND is_delete IS NOT TRUE")
int incrementLikeCount(@Param("id") long id); // 대상 게임
// 좋아요 취소 시 컬럼 -1, 음수 방어(GREATEST 0)
@Update("UPDATE games SET like_count = GREATEST(like_count - 1, 0) WHERE id = #{id} AND is_delete IS NOT TRUE")
int decrementLikeCount(@Param("id") long id); // 대상 게임
// 토글 직후 진실값 응답용 컬럼 재조회
@Select("SELECT like_count FROM games WHERE id = #{id}")
int getLikeCount(@Param("id") long id); // 대상 게임
```
- `decrementLikeCount``GREATEST(like_count - 1, 0)`: 컬럼이 0인데 음수로 내려가는 정합 사고 방어(concerns의 초기 불일치 대비). like_count는 `integer NOT NULL DEFAULT 0`(schema.sql:95)이라 NULL 걱정 없음.
### GameController (신규 핸들러 + addGameModel 1줄 + 의존성 1개)
```java
// 생성자 주입에 GameLikesMapper 추가 (기존 6개 → 7개). 토글/초기상태 조회에 사용
private final GameLikesMapper gameLikesMapper;
@PostMapping("/game/{id}/like")
@Transactional
public ResponseEntity<Map<String, Object>> toggleLike(
@PathVariable("id") long id, // 대상 게임
HttpServletRequest request, // CSRF 검증용
HttpSession session) { ... } // 로그인 주체 식별용
```
- `addGameModel(Model, GameData, Long currentUserId)` 시그니처 무변경 — 내부에서 `gameLikesMapper.findByGameAndUser` 호출 후 `model.addAttribute("liked", liked)` 추가.
## 파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 수정 | src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java | findByGameAndUser / deleteByGameAndUser 추가 (addGameLike 재사용) |
| 수정 | src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java | incrementLikeCount / decrementLikeCount / getLikeCount 추가 |
| 수정 | src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java | toggleLike 핸들러 추가, 생성자에 GameLikesMapper 주입, addGameModel에 liked 주입, 카탈로그 폴백에 liked=false |
| 수정 | src/main/webapp/WEB-INF/views/game-detail.jsp | 좋아요 버튼 JS를 fetch POST로 교체(L1473-1485, baseLikes/localStorage 제거), 초기 liked는 `${liked}` 사용 |
| 수정(권고) | db/schema.sql | game_likes에 UNIQUE(game_id,user_key) 반영(비권위 복원본 동기) |
| 신규(권고) | db/migrations/ 또는 운영 적용 SQL | ALTER TABLE game_likes ADD UNIQUE(game_id,user_key) (롤아웃 §) |
| 무변경(검증) | src/main/webapp/WEB-INF/views/explore.jsp (및 index.jsp/profile.jsp) | like 표시 무변경 — 컬럼값을 읽으므로 자동 반영 |
| 무변경(검증) | GamesMapper.java:28/51/75/206/275/312 | `g.like_count AS likeCount` SELECT 5개 그대로 (D1) |
데이터 클래스 `GameLikeData` 무변경(gameId:Long, userKey:String로 충분).
CSRF 토큰 JSP 주입: **신규 모델 attribute 불필요**. theme-init.jsp(game-detail.jsp:28에서 include)가 `<meta name="csrf-token">` + `window.BibimbapCsrf.headers()`를 전역 제공하며, 기존 deleteGame fetch(game-detail.jsp:1426)가 이미 이 패턴 사용. like fetch도 동일하게 `window.BibimbapCsrf.headers({...})`로 토큰 부착.
## game-detail.jsp 변경 상세
좋아요 버튼 JS(L1387 LIKE_KEY ~ L1485)를 다음으로 교체:
- 제거: `LIKE_KEY`, `getLikedMap`, `setLiked`, `isLiked`, `baseLikes ±1` 로컬 증감(L1387,1391-1412,1473-1485).
- 유지: `formatCount`, `likeBtn`/`likeCountEl` 참조, `gid`.
- 초기 상태: `var liked = ${empty liked ? 'false' : liked};` (서버 주입). 버튼 `aria-pressed`/`aria-label`을 liked로 1회 세팅. 카운트는 서버 렌더값(`#game-like-count` 초기 텍스트, likeCountFormattedValue) 유지.
- 클릭 핸들러: 진행중 가드(중복 클릭 방지 boolean) → `fetch(ctx + '/game/' + encodeURIComponent(gid) + '/like', { method:'POST', headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({'Accept':'application/json','X-Requested-With':'XMLHttpRequest'}) : {...} })`.
- `res.status === 401` → BibimbapModal.alert("로그인이 필요합니다") 또는 로그인 페이지 유도(`window.location = ctx + '/login'`). localStorage 변경 안 함.
- `res.status === 403` → 보안 토큰 안내 alert.
- `res.ok``data.liked`로 aria 갱신, `likeCountEl.textContent = formatCount(data.likeCount)` (서버 진실값으로 갱신, 로컬 추정 제거).
- 실패 시 버튼 상태 원복.
- 미로그인 사용자도 버튼은 렌더되되 클릭 시 401 응답으로 로그인 유도(서버가 단일 진실 게이트). `${empty currentUserId}` 분기로 클릭 전 안내도 가능(선택).
## 대안 비교
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| A. game_likes row + like_count 컬럼 동기(±1, 단일 트랜잭션) | explore 쿼리 무변경(D1), 읽기 빠름, 최소 변경 | 컬럼-row 정합 책임, 초기 불일치 잔존 | **채택** (D1 확정) |
| B. like_count 컬럼 폐기, explore가 game_likes COUNT 집계 | 단일 진실원, 정합 불필요 | explore 5개 쿼리 전면 수정(D1 위배), 조인/서브쿼리 비용 | 미채택 (D1이 컬럼 유지 명시) |
| C. updateGameLike(PK) 재사용해 liked flag 토글 | 기존 메서드 활용 | game_likes에 liked 컬럼 없음(DDL상 4컬럼뿐), PK 기준이라 (game,user) 조회 불가 | 미채택 (스키마 불일치) |
토글-off = DELETE(채택) vs liked flag 컬럼 추가: DDL에 flag 컬럼 없고 기존 deleteGameLikes가 hard delete 사용 → DELETE가 스키마 일관. flag 추가는 마이그레이션 비용 대비 이득 없음.
## 롤아웃 / 마이그레이션
1. 코드 배포 전/후 무관하게 explore 쿼리 무변경이므로 읽기 경로 역호환. like_count 컬럼은 이미 존재.
2. (권고) UNIQUE 제약 추가: 운영 적용 전 중복 row 존재 여부 확인 → 있으면 중복 제거 후 제약 추가.
```sql
-- 중복 점검
SELECT game_id, user_key, COUNT(*) FROM game_likes GROUP BY game_id, user_key HAVING COUNT(*) > 1;
-- 제약 추가
ALTER TABLE game_likes ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
```
3. (선택) 초기 정합 보정 — 기존 like_count 컬럼과 row 수 불일치 교정이 필요할 때만:
```sql
UPDATE games g SET like_count = COALESCE((SELECT COUNT(*) FROM game_likes gl WHERE gl.game_id = g.id), 0);
```
본 기능 범위 밖(비목표). 시드/레거시 카운트를 보존하려면 실행하지 않는다.
4. 롤백: 신규 엔드포인트/메서드 제거 시 explore 읽기는 영향 없음. game_likes row는 남아도 무해(deleteGameLikes가 게임 삭제 시 정리). UNIQUE 제약은 DROP CONSTRAINT로 롤백.
5. localStorage('bibimbap-game-liked')는 더 이상 쓰지 않음 — 잔존 키는 무해(JSP가 더 이상 읽지 않음). 정리 불필요.
## 검증 포인트 (AC)
### 회귀 시나리오 (수정 전 FAIL → 후 PASS)
- **AC-1 (핵심 버그)**: 로그인 사용자가 `POST /game/{id}/like` 호출 → DB `SELECT like_count FROM games WHERE id={id}` 값이 +1 → 동일 게임이 explore 조회 메서드(getVisibleGames 등) 결과에서 likeCount 증가값 반환. (수정 전: 컬럼 미갱신으로 불변 → FAIL)
- **AC-2**: 같은 사용자가 같은 게임에 두 번째 `POST .../like` → liked=false, like_count 원복(-1), game_likes에 해당 (game,user) row 0건. (토글 멱등)
- **AC-3**: 좋아요 추가 시 `game_likes`에 (game_id, user_key=userId문자열) row 1건 생성, created_at NOT NULL.
### 게이트
- **AC-4**: CSRF 헤더/바디 누락 시 `POST .../like` → HTTP 403 + body.status==403. (CsrfTokens.errorBody 형식)
- **AC-5**: 미로그인(session userId 없음) + 유효 CSRF → HTTP 401 + message=="로그인이 필요합니다.". game_likes row 변화 0, like_count 불변.
- **AC-6**: 게이트 순서 — CSRF 무효 + 미로그인 동시 → 403(CSRF가 먼저). 존재하지 않는 game id + 유효 CSRF + 로그인 → 404.
### 상세 초기 상태
- **AC-7**: 좋아요한 게임의 상세 GET → 모델 `liked==true`, JSP 버튼 aria-pressed="true" 초기 렌더(localStorage 무관, 다른 브라우저/시크릿창에서도 동일). 좋아요 안 한 게임/미로그인 → liked==false.
### 무변경 전수 (집합 체크 — 프로토콜 §4.3)
- **AC-8 (explore SELECT 컬럼 전수)**: GamesMapper.java에서 `g.like_count AS likeCount` 패턴이 정확히 6건 유지 — `grep -c 'g.like_count AS likeCount' GamesMapper.java == 6` (getGame + 5 explore 조회). 어느 것도 COUNT 집계로 바뀌지 않음. (시점 안정: 본 변경은 GamesMapper에 UPDATE/SELECT 메서드만 추가하고 기존 SELECT 절은 손대지 않으므로 verification 시점에도 6 고정)
- **AC-9 (좋아요 표시 JSP 무변경 전수)**: explore.jsp / index.jsp / profile.jsp 3개 파일의 like 표시 코드 무변경 — git diff상 이 3파일에 변경 없음(변경 파일 집합에서 제외, 양방향 계약: 영향 맵의 "무변경" 행과 일치).
### 동기화 트랜잭션
- **AC-10**: incrementLikeCount/decrementLikeCount SQL이 `#{id}` 바인딩만 사용하고 `${}` 동적 치환 0건 — `grep -c '\${' GamesMapper.java` 신규 메서드 영역에 증가 없음(보안 원칙). decrement는 `GREATEST(..., 0)`로 음수 방어.
- **AC-11**: 토글 도중 row INSERT/DELETE와 like_count UPDATE가 같은 `@Transactional` 단위 — 핸들러에 `@Transactional` 어노테이션 존재(`grep '@Transactional' GameController.java` 의 toggleLike 직상단 1건 추가).
### self-audit (프로토콜 §4.7)
- AC-1/AC-3: 시점 안정 — 테스트가 직접 토글을 발생시키고 즉시 검증하므로 외부 시점 의존 없음. 표현 견고 — DB 값 직접 SELECT(리터럴 grep 아님).
- AC-8: 고정 카운트 6은 시점 안정(본 세션이 SELECT 절을 늘리지 않음). 표현은 단일 리터럴 grep 의존 → 보완: "COUNT 집계로의 전환 부재"를 수동 1패스 확인(의미 불변식)으로 병행.
- AC-2/AC-5: 멱등·거부는 불변식(row 수·like_count 동등성)으로 판정 — 고정 스칼라가 아닌 "토글 전후 상태 동등" 검사.

View File

@ -1,92 +0,0 @@
---
phase: verification
agent: verification-advisor
agent_version: 1
generated_at: 2026-06-29T06:38:30Z
session_id: 20260629-151216
concerns: []
concerns_checked: true
---
# 검증 결과 — 게임 "좋아요" 서버 영속화
## 실행 환경
호스트 JDK 부재 → `eclipse-temurin:21-jdk` 컨테이너에서 컴파일+테스트. 레포(`/Users/wemadeplay/workspace/stz/bibimbap`)와 `~/.m2` 마운트.
## 실행된 전략 (verification-strategies.md 매칭)
변경 scope = 컨트롤러(GameController) + 매퍼(GamesMapper) + 신규 회귀 테스트(GameLikeControllerTest).
매칭: L1(typecheck + unit/regression). L2 = "MyBatis 매퍼 신규/SQL → L1+L2 (dev DB contract)" 의무이나 **L2 harness 미구축 + dev DB 미기동 → skip(warning)**.
| id | cmd | exit | severity | 결과 |
|---|---|---|---|---|
| L1-typecheck | `./mvnw -q -DskipTests test-compile` (docker) | 0 | blocker | pass |
| L1-unit (신규회귀) | `./mvnw -o -Dtest=GameLikeControllerTest test` | 0 | blocker | pass (7/7) |
| L1-regression | `./mvnw -o -Dtest='*ControllerTest' test` | 0 | blocker | pass (219/219) |
| L2-contract-db | (dev DB contract) | - | blocker | skipped: L2 harness 미구축 + dev DB 미기동 |
### 검증 사다리 분해
| 단계 | 결과 |
|---|---|
| L1 typecheck (전체 main+test 컴파일) | pass |
| L1 unit (GameLikeControllerTest 7건) | pass |
| L1 regression (*ControllerTest 219건) | pass |
| L2 contract-db (매퍼 SQL ↔ Postgres 방언 정합) | skipped: harness 미구축, dev DB 미기동 |
| 로그 스캔 | clean (intentional WARN 1건 — 본 변경 무관) |
## AC 판정
### 회귀 (핵심 버그)
- **AC-1 PASS**`toggleLikeAddsLikeAndIncrementsCount` 포함 GameLikeControllerTest 7/7 GREEN. (Tests run: 7, Failures: 0, Errors: 0, Skipped: 0)
- **AC-2 PASS**`toggleLikeRemovesLikeAndDecrementsCount` 동일 스위트 GREEN.
- **AC-3 PASS**`toggleLikePersistsRowWithUserKeyEqualToUserIdString` 동일 스위트 GREEN.
### 게이트
- **AC-4 PASS**`toggleLikeRejectsMissingCsrfBeforeMutation` 동일 스위트 GREEN (403 + verifyNoInteractions).
- **AC-5 PASS**`toggleLikeRequiresLogin` 동일 스위트 GREEN (401, 상태변경 0).
- **AC-6 PASS**`toggleLikePrefersCsrfFailureOverLoginFailure` + `toggleLikeReturnsNotFoundWhenGameMissing` 동일 스위트 GREEN (CSRF 403 우선, 게임없음 404).
(주: 7건 = AC-1/2/3 각 1 + AC-4 + AC-5 + AC-6의 2건 = 7건. 스위트 전체 0 failure/0 error 로 개별 메서드 명시 통과.)
### 정적 게이트 (grep, 레포 루트)
- **AC-10 PASS**`rg -n '\$\{' src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java` → 매치 1건이나 라인 344 `// ... #{} 바인딩, ${} 금지` **주석 텍스트뿐**. 주석(`//`) 제외 필터 결과 실제 SQL 동적치환 0건. 신규 like 메서드(incrementLikeCount L351 / decrementLikeCount L360 / game_likes DELETE L187) 영역에 `${` 없음.
- **AC-11 PASS**`rg -n '@Transactional' .../GameController.java` → 5건(라인 69,138,203,275,310). toggleLike 포함 mutation 메서드에 어노테이션 존재.
- **AC-8 PASS(불변식)**`rg -c 'g.like_count AS likeCount' .../GamesMapper.java`**7**. explore SELECT 절이 COUNT 집계로 전환되지 않고 컬럼 SELECT 유지(원본=변경후=7 구현보고와 정합). 집계뷰 fan-out 회귀 불변식 유지.
## 실패 상세
없음. 모든 AC PASS.
## 로그 스캔 비고
`*ControllerTest` 실행 중 `GameReviewController -- [BADGE] 리뷰 평판 훅 실패` WARN + RuntimeException 스택트레이스 1건 출력. 이는 `GameReviewControllerTest.createReviewSucceedsEvenWhenReputationHookThrows`**의도적으로** 주입한 예외(graceful WARN 경로 검증)이며 해당 테스트 GREEN(24/24). 본 좋아요 변경과 무관, 기존 테스트의 expected 로그. 비정상 ERROR 0건.
## 종합 판정
overall: **PASS**
- L1 게이트(typecheck): PASS
- L1 게이트(unit + regression): PASS (신규 7 + 전체 219, 0 fail/0 error)
- L2: skipped (harness 미구축 — orchestrator 가 dev DB 기동 후 재호출 여부 결정)
rollback_signal: none
## Acceptance 매칭
| criterion | 매칭 전략 | 판정 |
|---|---|---|
| AC-1 | GameLikeControllerTest::toggleLikeAddsLikeAndIncrementsCount | PASS |
| AC-2 | GameLikeControllerTest::toggleLikeRemovesLikeAndDecrementsCount | PASS |
| AC-3 | GameLikeControllerTest::toggleLikePersistsRowWithUserKeyEqualToUserIdString | PASS |
| AC-4 | GameLikeControllerTest::toggleLikeRejectsMissingCsrfBeforeMutation | PASS |
| AC-5 | GameLikeControllerTest::toggleLikeRequiresLogin | PASS |
| AC-6 | toggleLikePrefersCsrfFailureOverLoginFailure + toggleLikeReturnsNotFoundWhenGameMissing | PASS |
| AC-8 | rg -c 'g.like_count AS likeCount' (==7 불변식) | PASS |
| AC-10 | rg '\$\{' GamesMapper.java (주석 제외 0건) | PASS |
| AC-11 | rg '@Transactional' GameController.java | PASS |
## concerns
- L2 skipped: dev DB contract harness 미구축 + dev DB 미기동. 매퍼 신규/SQL 변경(verification-strategies.md L25 기준 L1+L2 의무)이나 L2 미실행. Map resultType alias 케이스폴딩(L180 교훈)·집계 SELECT 정합은 L1 mock 으로 미가드. orchestrator 가 dev DB 기동 후 L2 재호출 여부 결정 필요. 단, like 매퍼는 INSERT/DELETE/UPDATE(int 반환) 위주로 Map resultType camelCase alias 의존이 낮아 케이스폴딩 리스크는 상대적으로 낮음(코드 미열람 추정 아님 — AC-8 컬럼 SELECT 불변식 유지 확인 범위 내).
## 재현 명령
```
docker run --rm -v /Users/wemadeplay/workspace/stz/bibimbap:/work -v /Users/wemadeplay/.m2:/root/.m2 \
-w /work --entrypoint sh eclipse-temurin:21-jdk \
-c './mvnw -q -DskipTests test-compile && ./mvnw -o -Dtest=GameLikeControllerTest test'
# 전체 회귀:
# -c "./mvnw -o -Dtest='*ControllerTest' test"
```

View File

@ -1,42 +0,0 @@
---
phase: documentation
agent: documentation-advisor
agent_version: 1
generated_at: 2026-06-29T15:40:00Z
concerns:
- "HTTP 계약(POST /game/{id}/like)을 별도 contracts/ 기준 문서로 만들지 않음 — 본 프로젝트 contracts/ 에 게임 엔드포인트 계약 정본이 부재(기존 댓글/리뷰도 changes/security 에만 기록). 계약은 changes 문서 + 설계 문서에 인라인 기록. contracts/ 정본 신설은 별도 결정 필요(범위 밖)."
- "design-advisor 산출물이 architecture/ 를 갱신하지 않았고(아키텍처 경계 변경 아닌 기능 배선), 본 문서화도 architecture/ 무변경. 충돌 없음."
concerns_checked: true
---
# 문서화 보고 — 게임 "좋아요" 서버 영속화 (세션 20260629-151216)
## 작성/수정된 문서
| 경로 | 카테고리 | 유형 | 링크 추가한 index | 교차 링크 |
|---|---|---|---|---|
| docs/changes/2026-06-29-game-like-server-persistence.md | changes | 신규 | changes/index.md ✅ | maintenance·security·analysis·댓글 changes 로 outbound; security B3·maintenance·analysis·changes/index 에서 inbound |
| docs/maintenance/post-deploy-verification-checklist.md | maintenance | 수정(절차+스모크 추가) | maintenance/index.md ✅ 설명 갱신 | changes 좋아요 문서 상호 링크 |
| docs/security/security-remediation-checklist.md | security | 수정(B3 상태 갱신) | (기존 index 등재) | changes 좋아요 문서·maintenance 링크 |
| docs/analysis/2026-06-16-project-analysis.md | analysis | 수정(현황/open_question 갱신) | (기존 index 등재) | changes 좋아요 문서·security B3 링크 |
## 카테고리 판별 근거
- 주 카테고리 = **changes/**: 실제 런타임 동작 변경(신규 엔드포인트·매퍼·JSP·DB 스키마). document-category-classification.md §"changes 를 써도 되는 경우" 충족(코드 수정 + 동작 변화).
- 보조 = **maintenance/**: UNIQUE 마이그레이션 운영 수동 적용 절차(중복 점검 게이트) + 좋아요 L3 스모크. 기존 post-deploy 체크리스트에 섹션 추가(신규 문서 미생성 — 동일 세션·동일 배포후 과제 성격).
- ADR/architecture 해당 없음: 되돌리기 어려운 기술 결정·시스템 경계 변경 아님(기능 배선 + 컬럼 비정규화 동기, 기존 패턴 답습).
## 의사결정 기록 위치
- D1(서버 영속+like_count 동기, explore 무변경)/D2(로그인 사용자 기준): report.md decisions + changes 문서 "사용자 결정" §.
- 근본원인 seed 가정 반전: research/like-count-rootcause.md + changes 문서 "배경/버그" §.
- 설계(엔드포인트·매퍼·트랜잭션·대안비교): artifacts/like-persistence-design.md (changes 문서에서 인용·링크).
## 보안 체크리스트 B3 상태 갱신
- 우선순위 표 B3 영향칸: 좋아요 완료(마이그레이션 운영 적용 대기) 명기.
- 의도 확인: 좋아요 주체 [hold]→[x](로그인 사용자), localStorage 처리 [hold]→[x](비마이그레이션).
- 체크리스트: 엔드포인트/CSRF/race condition [ ]→[x], 중복 방지키 [ ]→[~](앱레벨 방어+DB UNIQUE 미적용).
- 완료 조건: 좋아요 유지·CSRF 거부 항목에 좋아요 충족 반영.
## 추후 문서화가 필요한 항목
- UNIQUE 마이그레이션 운영 적용 후: maintenance 체크 항목 [ ]→[x] + (실행 시) changes 문서 잔여 §에 적용일자 기록.
- 실환경 스모크(AC-1/AC-7) 수행 후: maintenance §5-1 체크 + 결과 work-log/changes 반영.
- (선택) contracts/ 게임 HTTP 엔드포인트 계약 정본 신설 여부 결정 — 좋아요/댓글/리뷰 계약이 changes 에 분산되어 있어 단일 계약 기준 문서 부재(concerns).
- graphify src 재생성(신규 라우트·주입 엣지·매퍼 메서드 미반영) — graphify-update-advisor 몫(본 문서화 범위 밖).

View File

@ -1,30 +0,0 @@
---
phase: implementation
agent: implementation-advisor
agent_version: 1
generated_at: 2026-06-29T06:55:00Z
---
# 파일 소유권 맵 — 게임 좋아요 서버 영속화
| 파일 | 담당 worker | worker id | 변경 유형 | 의존 |
|---|---|---|---|---|
| src/main/java/com/pandoli365/bibimbap/mapper/GameLikesMapper.java | code-writer | w-001 | modify | - |
| src/main/java/com/pandoli365/bibimbap/mapper/GamesMapper.java | code-writer | w-002 | modify | - |
| src/main/webapp/WEB-INF/views/game-detail.jsp | code-writer | w-003 | modify | - |
| db/schema.sql | migration-writer | w-004 | modify | - |
| db/migrations/20260629-game-likes-unique.sql | migration-writer | w-004 | create | - |
| src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java | code-writer | w-005 | modify | w-001, w-002 (시그니처 확정) |
| src/test/java/com/pandoli365/bibimbap/controller/api/GameLikeControllerTest.java | code-writer | w-006 | create | w-005 (컨트롤러 형태 확정) |
## 불변식 확인
- 동일 파일 1 worker 만 할당 ✓
- w-004 는 schema.sql + 신규 migration SQL 두 파일을 함께 소유(같은 마이그레이션 관심사, migration-writer 격리) ✓
- 의존 사슬: 시그니처는 design.md 에 고정됨 → mapper(w-001/002) 와 controller(w-005) 는 시그니처 충돌 없음. 단 안전을 위해 w-005/w-006 은 mapper 확정 후 순차 spawn.
## spawn 계획
- 1차 병렬: w-001, w-002, w-003, w-004 (전부 독립)
- 2차: w-005 (mapper 확정 후, controller)
- 3차: w-006 (controller 확정 후, test)
planned_workers: 6

View File

@ -1,153 +0,0 @@
---
schema_version: 2
sid: 20260629-151216
started_at: 2026-06-29T15:12:16+09:00
ended_at: 2026-06-29T16:14:00+09:00
user_request: |
게임 상세페이지에서 좋아요 눌르고 탐색 페이지 와보면 좋아요가 올라가지 않아.
(게임 상세 페이지에서 like 토글 → 탐색/explore 페이지의 like count 미반영 버그)
invocations: []
decisions:
- id: D1
axis: '좋아요 수정 범위 / 카운트 모델'
choice: '서버 영속 + games.like_count 비정규화 컬럼 동기화 (explore 쿼리 무변경)'
decided_by: user
at: 2026-06-29T15:16:00+09:00
- id: D2
axis: '좋아요 주체'
choice: '로그인 사용자 기준 (sessionUserId). 미로그인 시 로그인 유도. 사용자당 게임당 1회'
decided_by: user
at: 2026-06-29T15:16:00+09:00
user_signals:
positive:
- signal: 'plan-gate 2문항(수정범위/주체)을 1라운드에 명확히 수락, 마찰 없음'
note: 'seed 가정 반전(동기화버그→미구현)을 옵션으로 제시한 게 적중'
negative:
- signal: 'orchestrator 가 research-advisor 재개 시 "worker 4개 완료됐을 것"이라 단정 추정 → advisor 가 거부(당시 2개만 완료, 나머지 알림 후 취합)'
structural: true
class: 'orchestrator 가 미검증 상태를 사실로 dispatch 주입 (§2.9 입력방향 오염 인접)'
note: '사용자 발화 아님 — advisor self-report 가 표면화. 결과 결함 0(advisor 방어 성공)이나 재발 가능 패턴.'
regression:
origin_stage: research (근본원인은 미구현 — backward 회귀 아닌 forward 신규 구현)
note: '결함이 국소 패치 대상이 아니라 미구현 기능 배선이었음. 발원=설계 부재. 정상 forward 파이프라인으로 처리.'
graph_refresh: 'partial-stale → 커밋 후 /graphify src 재생성 예정 (신규 라우트 POST /game/{id}/like + GameController→GameLikesMapper 주입 엣지 + mapper 메서드 5건 미반영)'
verified_by_me:
L1: 'PASS — eclipse-temurin:21-jdk 컨테이너: test-compile 통과 + GameLikeControllerTest 7/7 GREEN + *ControllerTest 회귀 219/219 GREEN (BUILD SUCCESS)'
L2: 'skipped: dev DB 미기동 + contract harness 미구축. like mapper 는 INSERT/DELETE/UPDATE int 반환 위주라 camelCase Map alias 리스크 낮음'
log_scan: 'clean (GameReviewControllerTest 의 의도적 평판훅 예외 1건은 기존 expected, 본 변경 무관)'
needs_user_verification:
- '실환경 스모크 1회: 로그인 → 게임 상세 좋아요 클릭 → explore 페이지 카운트 +1 확인 (JSP fetch + aria 초기상태 AC-7 은 JS 단위 하네스 없어 수동 확인 대상)'
- 'L2 dev DB contract: PostgreSQL dev 기동 후 mapper SQL 실DB 동작 확인 (incrementLikeCount/getLikeCount alias·집계)'
- 'UNIQUE 마이그레이션 운영 적용: db/migrations/20260629-game-likes-unique.sql — 중복 row 점검 SELECT 선행 후 ALTER TABLE 적용 (§6 게이트, DB 적용 미수행)'
open_items:
- 'docs/ 기록: 완료 (changes 신규 + maintenance/security/analysis 갱신, fa6a301 커밋)'
- 'graph: 완료 (/graphify src 재생성 1857노드/4419엣지 + index 메타 갱신, ef3dc64 커밋)'
- '(잔여) needs_user_verification 3건 — 실환경 스모크 / L2 dev DB / UNIQUE 마이그레이션 운영 적용'
---
# Advisor Invocation Decision Log
# 각 advisor 호출/스킵 판단 즉시 1줄 append
- advisor: requirements-advisor
decision: skip
rationale: '버그 재현 시나리오가 명확(상세→좋아요→탐색 미반영). 스코프 모호성 낮음.'
checked_at: 2026-06-29T15:12:30+09:00
- advisor: graphify-lookup-advisor
decision: call
rationale: '좋아요 INSERT 경로와 explore 카운트 조회 경로 코드 위치 1차 탐색.'
checked_at: 2026-06-29T15:12:30+09:00
- advisor: research-advisor
decision: call
rationale: 'graph miss(토글 호출경로/explore SQL/DDL/JSP). 실코드로 근본원인 확정 필요.'
checked_at: 2026-06-29T15:14:00+09:00
# Invocations (요약)
- graphify-lookup: partial — mapper/data 레이어 hit, 토글경로/explore SQL/DDL/JSP miss
- research(4 worker): 근본원인 확정 — 좋아요 서버 미저장(localStorage only)
- design: 설계도 완성 open_questions 0, concern 3(UNIQUE 부재 등)
- implementation(6 worker): 6파일 변경 + 테스트 7. advisor 환경 Java 없어 컴파일 미실행
- verification: L1 PASS(컨테이너 컴파일+7/7+회귀219/219), AC 9/9 PASS, L2 skip
- graph-refresh-checker: partial-stale(src)
- documentation: changes 신규 + maintenance/security/analysis 갱신 (1차 세션한도 중단, 재호출 성공)
- graphify-update + /graphify src: 재생성(1857노드/4419엣지) + index 메타 갱신
- 커밋: fa6a301(기능) + ef3dc64(graph 메타)
# Decisions (요약)
- D1 서버영속+like_count 컬럼 동기화, D2 로그인 사용자 기준 (둘 다 사용자 plan-gate 확정)
# Summary
## 근본 원인 (research 확정, seed 가정 반전 — §2.7 plan-gate 발동)
seed 가정: "explore 와 상세가 서로 다른 like 카운트 소스를 읽는 동기화 불일치 버그".
**반전된 결론**: 좋아요는 서버에 전혀 저장되지 않는다. 상세페이지 좋아요 버튼이
서버를 호출하지 않고 브라우저 `localStorage('bibimbap-game-liked')` 만 토글 +
화면 카운트를 `baseLikes+1` 로 로컬 계산(game-detail.jsp:1473-1485).
근거 file:line:
- 좋아요 토글 서버 엔드포인트 부재 (GameController 매핑 0개)
- `GameLikesMapper.addGameLike/updateGameLike` = 호출자 0건 dead code (GameLikesMapper.java:22,34,43)
- `GamesMapper` 에 like_count +1/-1 UPDATE 메서드 없음
- explore 5개 메서드 + 상세 모두 동일하게 `games.like_count` 컬럼 직접 SELECT
(explore: GamesMapper.java:51/75/206/275/312, 상세: :28) — 그 컬럼은 클릭으로 갱신 안 됨
→ 상세는 로컬 ±1 로 올라가 보이고 explore(DB값)는 영원히 그대로. 증상 정확히 일치.
기존 코드 정황: 로그인 사용자 = `sessionUserId(session)`→Long, 상태변경 = `CsrfTokens.isValid(request)`.
`GameLikeData.userKey` 는 String per-row.
→ 1줄 수정 아님 = **미구현 기능 배선**. 설계 전 사용자 plan-gate 진입.
# Invocations
# Decisions
# Retrospective
```yaml
Retrospective:
signals:
positive:
- quote_or_paraphrase: 'plan-gate 2문항(수정범위 D1 / 좋아요 주체 D2)을 1라운드에 마찰 없이 수락'
about: 'research 가 seed 가정("동기화 불일치 버그")을 "서버 미저장 미구현 기능"으로 반전했을 때, 단정하지 않고 plan-gate 옵션으로 사용자 위임한 판단'
negative:
- quote_or_paraphrase: 'advisor self-report: orchestrator 가 research-advisor 재개 시 "worker 4개 완료됐을 것"이라 미검증 단정 추정 → advisor 가 거부(당시 2개만 완료)'
about: 'subagent 재개 시 worker 완료 상태를 관측 없이 사실로 dispatch 프롬프트에 주입한 행위 (§2.9 입력방향 오염 인접)'
structural: true
what_went_well:
- 'seed 가정 반전을 단정 적용하지 않고 plan-gate 반전질문(옵션+Recommended)으로 사용자에 위임 → 1라운드 수락. 기존 교훈 research-seed-reversal-plan-gate-delegation 이 다른 스택(Java/JSP/MyBatis)에서 재검증됨.'
- 'plan-gate 진입 판단 정확: 근본원인이 1줄 패치가 아니라 미구현 기능 배선임을 research file:line 근거로 확정하고 설계 전 게이트를 건 것 — over-engineering/under-scoping 양방 회피.'
- 'documentation-advisor 1차 세션한도 중단을 partial 보존 + 재호출로 회복(기존 implementation-advisor-partial-recovery-pattern 패턴대로). 결과 결함 0.'
- 'research-advisor 가 orchestrator 의 미검증 단정을 방어적으로 거부 → 입력방향 오염이 산출물에 전파되기 전 차단. advisor 자가검증이 작동.'
what_to_improve:
- 'orchestrator 가 subagent 재개(resume/재호출) 시 선행 worker 들의 완료 여부를 추정으로 단정해 dispatch 에 주입하지 말 것. 완료는 알림/관측(files-owners.md·change-log.md·git status·완료 신호) 으로 확인 후 사실로 기술.'
memory_candidates:
- name: orchestrator-subagent-resume-verify-completion-not-assume
type: feedback
description: 'subagent 재개 시 선행 worker 완료 여부를 추정 단정하지 말고 관측(산출물·git·완료신호)으로 확인 후 dispatch 에 기술.'
body_draft: |
orchestrator 가 advisor/worker 를 재개(resume·재호출)할 때, 선행 worker 들의 완료 상태를
"이쯤이면 다 됐을 것" 식으로 추정해 재개 프롬프트(dispatch)에 사실로 주입하면,
미검증 상태가 입력방향 오염(§2.9)으로 산출 파이프라인에 전파된다.
**Why:** 20260629-151216 (bibimbap 좋아요 영속화) 세션에서 orchestrator 가
research-advisor 재개 시 "worker 4개 완료됐을 것"이라 단정 추정했으나 당시 2개만 완료.
advisor 가 거부하고 나머지 알림 후 취합해 결함 0 으로 막았지만, advisor 방어가 없었다면
절반만 취합된 근거로 근본원인 판단이 갈렸을 수 있다. 재발 가능한 패턴이며
atp 프로토콜에 "재개 시 선행 상태 검증 게이트" 명문 없음.
기존 runtime-selfreport-not-ui-evidence / orchestrator-git-tracking-claim-must-verify-first
가 "주장 전 검증" 일반론이라면, 본 항목은 "subagent 재개 시 선행 worker 완료 검증" 의
구체 케이스로 구분된다.
**How to apply:**
1. 재개·취합 직전 완료 상태를 관측으로 확정: 각 worker 의
files-owners.md / change-log.md append 여부, git status -s, 완료 신호(알림) 확인.
2. 미확인 worker 는 "완료" 가 아니라 "미확인 — 알림 대기" 로 기술. dispatch 에 추정 완료수 금지.
3. 부분 완료면 implementation-advisor-partial-recovery-pattern 절차로 잔여만 좁혀 재호출.
4. 재개 프롬프트 첫 줄에 "확정 완료(관측됨): N개 / 미확인: M개" 로 등급 구분 기술.
rationale_for_saving: '코드/커밋에서 유도 불가능한 운영 관측 지식. 재발 가능 구조적 패턴이며 결함 0 은 advisor 방어 우연이라 프로토콜/문서 차원 가드가 필요. 기존 memory 와 케이스가 구분됨.'
signal_source: negative
docs_sync_target: '/Users/wemadeplay/workspace/stz/bibimbap/docs/development/agent-team-protocol.md (§2.9 입력방향 오염 부근 또는 §2.1 advisor 재개 절차에 "재개 시 선행 worker 완료 관측 검증" 게이트 1줄 추가) — 단 프로토콜은 atp 플러그인 번들 권위본이므로 프로젝트 로컬 미러가 없으면 protocol_feedback 경로로 상류 반영 권고'
memory_optional: true
protocol_feedback:
- 'agent-team-protocol §2.9(입력방향 오염) 또는 §2.1(advisor 실패/재개 처리) 에 "subagent 재개·취합 시 orchestrator 는 선행 worker 완료 여부를 추정 단정하지 말고 관측(산출물 append·git status·완료 신호)으로 확정한 뒤 dispatch 에 기술한다" 게이트 명문화 제안. 이번 세션은 advisor 방어로 결함 0 이었으나 그 방어는 우연이며, orchestrator 측 가드가 부재. 기존 partial-recovery-pattern(§2.1 부록 제안)과 결합되는 항목.'
applied_changes: []
```

View File

@ -1,73 +0,0 @@
---
phase: research
agent: research-advisor
agent_version: 2
generated_at: 2026-06-29T06:17:17Z
concerns:
- "low source confidence — DDL(games.like_count default/NOT NULL, game_likes 제약)은 비권위 복원본(추론값). 운영 pg_dump 대조 전 권위 데이터로 승격 금지"
concerns_checked: true
source_confidence: mixed
workers_spawned: 4
---
# 조사 결과 — 게임 "좋아요" 카운트 버그 근본 원인
## 주제
게임 상세페이지에서 좋아요를 누르면 토글은 되지만, explore(탐색) 페이지로 가면 좋아요 수가 올라가지 않는다. 코드에서 근본 원인을 확정한다.
## 포인트별 발견
### 포인트 1: 좋아요 토글 엔드포인트 — 서버 핸들러 자체가 없음
- 경로: `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java`, `.../mapper/GameLikesMapper.java`, `src/main/webapp/WEB-INF/views/game-detail.jsp`
- 요약: 게임 좋아요 토글을 처리하는 **서버 엔드포인트가 존재하지 않는다.** GameController 매핑은 `/game/new`(POST), `/game/{id}`(GET), `/game/{id}/edit`(GET/POST), `/game/{id}`(DELETE)뿐이며 좋아요용 POST/PUT/DELETE 핸들러가 없다. `addGameLike`/`updateGameLike`/`getGameLike`(GameLikesMapper) 매퍼 메서드는 src 전체에서 **호출자 0건**(dead code). 상세 JSP 의 좋아요 버튼은 완전히 클라이언트 사이드로, `localStorage`('bibimbap-game-liked') 만 토글하고 화면 카운트를 `baseLikes + (liked ? 1 : 0)`로 다시 그릴 뿐 어떤 서버 fetch/ajax 도 호출하지 않는다. (advisor 직접 확인: game-detail.jsp:1480-1485 클릭 핸들러는 `setLiked()`+`syncLike()`만 호출.)
- 관련 파일:라인:
- GameController.java:63,132,169,196,268 (매핑 전체 — 좋아요 토글 매핑 없음)
- GameLikesMapper.java:22,34,43 (getGameLike/addGameLike(INSERT INTO game_likes)/updateGameLike — 호출자 0건)
- game-detail.jsp:1401-1408 (setLiked → localStorage 기록), :1473-1478 (syncLike → 로컬 ±1), :1480-1485 (클릭 핸들러, 서버 요청 없음)
- 신뢰도: 확인됨
### 포인트 1-b: GamesMapper 에 like_count 증감 UPDATE 메서드 부재
- 요약: GamesMapper 에 `incrementLikeCount`/`updateLikeCount`/`SET like_count` 류 UPDATE 메서드가 **존재하지 않는다**(검색 0 hit). UPDATE games 문은 `updateGame`(L149-164)과 `softDeleteGame`(L192-200) 둘뿐이며 어느 것도 `like_count`를 SET 하지 않는다. like_count 는 SELECT 절에만 등장.
- 관련 파일:라인: GamesMapper.java:149-164, 192-200; increment 류 검색 0 hit
- 신뢰도: 확인됨
### 포인트 2: explore 목록 like 수 출처 — games.like_count 컬럼 직접 SELECT
- 요약: 목록 조회 5개 메서드(getVisibleGames, searchVisibleGames, listVisibleKeyset, searchVisibleKeyset, searchGamesAdvanced) 전부 `g.like_count AS likeCount`(FROM games g)로 **컬럼을 직접 SELECT**한다. 어느 메서드도 `game_likes` 테이블을 COUNT 조인/서브쿼리로 집계하지 않는다. `game_likes`가 GamesMapper 에서 참조되는 곳은 게임 삭제용 `deleteGameLikes`(DELETE)뿐. `sort='likes'` 정렬도 `ORDER BY g.like_count DESC, g.id DESC`(L247)로 컬럼 사용.
- 관련 파일:라인: GamesMapper.java:51 / 75 / 206 / 275 / 312 (각 메서드 SELECT 절의 `g.like_count AS likeCount`), :186-190 (deleteGameLikes)
- 신뢰도: 확인됨
### 포인트 3: 상세페이지 like 수 출처 — 동일하게 games.like_count 컬럼
- 요약: 상세 페이지도 explore 와 **같은 출처**(games.like_count 컬럼)를 읽는다. `GET /game/{id}` 핸들러가 `gamesMapper.getGame(id)`로 조회 → `getGame` SQL 은 `g.like_count AS likeCount`(games g JOIN users u 만, game_likes 미참조) → `addGameModel``game.getLikeCount()`로 꺼내 `likeCount`/`likeCountFormatted` 모델 속성으로 주입 → JSP 가 `var baseLikes = ${likeCount};`로 기준선 사용. 좋아요 클릭 후 갱신은 **서버 재조회가 아니라 로컬 JS 증감(baseLikes ±1)** 일 뿐이다.
- 관련 파일:라인: GameController.java:132,135,138,304,309-310; GamesMapper.java:28 (getGame `g.like_count AS likeCount`, JOIN users u 만); game-detail.jsp:1248(초기 표시), :1386(baseLikes), :1477(로컬 증감)
- 신뢰도: 확인됨
### 포인트 4: DDL/스키마
- 경로: `db/schema.sql` (단일 부트스트랩 파일)
- 요약: `games.like_count` 컬럼은 **존재**한다 — `"like_count" integer DEFAULT 0 NOT NULL`(schema.sql:95). `game_likes` 테이블은 id(bigint PK), game_id(bigint NOT NULL FK→games), user_key(varchar(200) NOT NULL), created_at(timestamptz DEFAULT now()) 4컬럼, **`(game_id, user_key)` UNIQUE 제약 없음**(검색 0 hit). 단, schema.sql 헤더(4-11행)에 games/game_likes 는 "비권위 복원본 — 매퍼/POJO 에서 역추출, 타입·기본값·제약은 추론값" 경고가 명시.
- 관련 파일:라인: db/schema.sql:95(like_count), :253-261(game_likes), :4-11(비권위 경고)
- 신뢰도: 컬럼 존재 여부=확인됨 / default·NOT NULL·UNIQUE부재=추정(비권위 복원본, 운영 pg_dump 미대조)
## 종합 판단
네 포인트가 일관되게 수렴한다. explore 와 상세는 **동일한 출처**(`games.like_count` 컬럼)를 읽으며, 둘 다 실시간 `game_likes` COUNT 집계를 쓰지 않는다. 따라서 버그는 "explore 와 상세의 출처가 다르다"가 아니라 **좋아요 토글이 어떤 영속 저장소(DB)에도 쓰이지 않는다**는 데 있다.
### 근본 원인 가설
**1순위 (확정에 가까움): 좋아요는 DB 에 전혀 기록되지 않고 브라우저 localStorage 에만 저장된다.**
- 상세 페이지 좋아요 버튼은 서버 엔드포인트를 호출하지 않고 localStorage('bibimbap-game-liked')만 토글한다(game-detail.jsp:1480-1485, 1401-1408). 좋아요 토글 서버 핸들러가 애초에 없다(GameController 매핑 0개, GameLikesMapper 호출자 0건).
- 그 결과 `games.like_count` 컬럼도, `game_likes` 행도 갱신되지 않는다(GamesMapper 에 increment UPDATE 부재). explore 목록은 `games.like_count` 컬럼을 읽으므로 영원히 올라가지 않는다.
- 증상 정합: 상세에서 "토글되어 보이는" 것은 같은 브라우저의 localStorage + 로컬 ±1 표시(baseLikes+1)일 뿐이고, explore(다른 페이지/다른 사용자/새로고침 후 DB값)에는 반영되지 않는다. 사용자가 보고한 "상세는 올라가 보이는데 explore 는 안 올라간다"와 정확히 일치.
- 근거: GameController.java:63/132/169/196/268, GameLikesMapper.java:22/34/43(호출자 0), GamesMapper increment 부재, game-detail.jsp:1473-1485, GamesMapper.java:51/75/206/275/312(explore=컬럼) + :28(상세=컬럼).
**2순위 (1순위에 포섭되는 보조 가설): 좋아요 영속화 백엔드 배선이 미완성/dead code 로 남아있다.**
- GameLikesMapper(addGameLike/updateGameLike/getGameLike)와 games.like_count 컬럼은 좋아요를 DB 에 영속화하려던 설계 흔적이지만 컨트롤러/서비스 배선이 없어 dead code 다. 즉 기능이 "절반만 구현"된 상태. 만약 과거에 배선이 있었다면 회귀로 끊겼을 가능성(git 이력 확인은 본 조사 범위 밖).
- 근거: GameLikesMapper.java:22/34/43(정의는 있으나 호출 0), GamesMapper.java:95(like_count 컬럼 존재) vs increment UPDATE 부재.
두 가설은 배타적이지 않다 — 1순위가 현상의 직접 원인이고, 2순위는 그 구조적 배경(미완성 영속화 계층)이다.
## 미해결
- 과거에 좋아요 영속화 배선(서버 토글 엔드포인트)이 존재했다가 제거/회귀된 것인지 여부 — git 이력 추적은 본 조사 범위 밖. (필요 시 후속 조사)
- `games.like_count` 의 운영 DB 실제 default/제약, `game_likes (game_id,user_key)` UNIQUE 존재 여부 — schema.sql 이 비권위 복원본이라 미확정. 운영 pg_dump 대조 전 권위 데이터로 승격 금지.
- index.jsp:843, profile.jsp:509 도 `game.getLikeCount()`(=컬럼)를 표시하나, 이 두 화면의 like 표시 정합성은 본 조사 4포인트 밖(관찰만).

View File

@ -1,69 +0,0 @@
---
schema_version: 2
sid: 20260629-171042
started_at: 2026-06-29T17:10:42+09:00
ended_at: 2026-06-29T17:14:00+09:00
verified_by_me:
code_change: '없음 (docs-only) — verification/graph-refresh 비해당'
doc_change: 'docs/development/local-dev-setup.md 에 "코드·JSP 변경 후 재빌드·재시작" 함정 섹션 추가'
needs_user_verification:
- '[완료 2026-06-29] 실환경 스모크 PASS — 사용자가 로그인 후 더미 게임 좋아요 클릭 → 탐색 목록 카운트 반영 확인. (docker compose dev override 환경)'
- '(잔여) game_likes UNIQUE 마이그레이션 운영 적용 — 중복 점검 후, DB 스키마 변경이라 사용자 확인 게이트.'
retrospective:
lesson: 'handoff-committed-code-needs-rebuild-restart-step (memory 기록 + 가이드 docs-sync)'
signal_source: negative
user_request: |
테스트계정의 테스트 게임(더미)에서 좋아요를 테스트하는데 안 됨.
(직전 세션 20260629-151216 좋아요 서버 영속화 수정의 실환경 스모크 실패)
related_session: 20260629-151216
invocations: []
decisions:
- id: D1
axis: 'dev 앱 구동 방식'
choice: 'spring-boot:run(호스트 JVM) → docker compose app 컨테이너 전환'
decided_by: user
at: 2026-06-29T17:28:00+09:00
note: |
docker compose build app (이미지 빌드 성공) → 호스트 PID 99638 종료 → docker compose up -d app.
bibimbap-app Up(8080), Started BibimbapApplication, db 서비스(schema dev) 연결. 더미 데이터 동일.
스모크: POST /game/1/like→403, GET /→200.
이후 코드 변경 재배포 = docker compose up -d --build app (가이드 compose 행 참조).
주의: db 볼륨 기존 init(12일 전) 이라 schema.sql 의 UNIQUE 변경은 미적용 — 별도 마이그레이션 항목.
user_signals:
positive: []
negative:
- signal: '"애초에 니가 해줬어야함. 안했다면 가이드에 기재할것" — 좋아요 수정 후 돌던 dev 앱 재빌드/재시작을 orchestrator 가 안 했고, 그 필요성도 needs_user_verification 에 명시 안 함'
structural: true
class: '코드 검증(컨테이너 L1)과 실행중 앱 반영 사이 갭 — verified_by_me 는 PASS 였으나 사용자가 보는 실행 인스턴스에 미반영. 재배포 단계 누락 + 미고지'
note: '증상(로그아웃인데 좋아요 눌리고 목록 0)이 구 localStorage JSP = 미재배포 확증. 코드 결함 아님.'
diagnosis:
root_cause: '돌고 있는 dev 앱이 구 버전(수정 커밋 fa6a301 미반영). 증상=수정 전 localStorage 동작 그대로.'
evidence: '로그아웃 상태에서 좋아요가 클라이언트 토글되고 목록 카운트 0 그대로 = 신 JSP(서버 fetch+401) 아님 = 구 JSP 서빙 중.'
not_a_bug: '신 코드는 L1 PASS. game_likes/like_count/엔드포인트 정상. 재배포만 필요.'
resolution: |
구동 방식 실측: spring-boot:run (homebrew openjdk@21, target/classes, --spring.profiles.active=dev), IDE 아님.
orchestrator 가 재배포 수행: 오프라인 재컴파일 → 구 프로세스 종료(PID 52251) → spring-boot:run 재기동(신 PID 99638).
라이브 확인: POST /game/1/like (CSRF 없음) → HTTP 403 (신 엔드포인트 존재 확정. 구앱이면 404), GET / → 200.
(중간 오발견: javap 를 JAVA_HOME 없이 실행해 "toggleLike 없음"으로 오독 → 소스/커밋/클래스 재확인으로 정정. 코드 정상.)
open_items: []
---
# Advisor Invocation Decision Log
- advisor: requirements-advisor
decision: skip
rationale: '버그 진단 세션. 증상 양상 확정이 먼저 — AskUserQuestion 으로 직접 수렴.'
checked_at: 2026-06-29T17:11:00+09:00
# 초기 가설 (확정 전)
- H1 (유력): 앱 미재빌드/미재시작 — 변경(toggleLike 엔드포인트 + 신 JSP)이 커밋만 됐고 돌고 있는 dev 앱은 구 WAR. 증상이 수정 전과 동일할 것.
- H2: 미로그인 상태 → 401.
- H3: 더미 게임이 실제 games row 임은 확인(seed-dev.sql, is_visible=true) → 404 카탈로그 폴백 이론 약함.
- H4: game_likes/like_count 스키마·seed 이슈 (UNIQUE 미적용은 기능 차단 아님).
# Summary
(진행 중)
# Invocations
# Decisions

View File

@ -1,22 +0,0 @@
# 코드 변경 핸드오프 시 "앱 재빌드·재시작" 단계를 명시할 것
커밋·테스트(L1) 통과 ≠ 사용자가 보는 **실행 중 앱에 반영**. bibimbap dev 앱은
호스트 JVM(IDE 번들 JDK, 8080) 또는 compose `bibimbap-app` 로 구동되며, 소스/JSP 변경은
프로세스를 재빌드·재시작하기 전까지 반영되지 않는다.
## 적용
- 코드 변경을 마무리할 때 `needs_user_verification`/배포후 체크리스트에 **"앱 재빌드·재시작"을 첫 단계로 명시**.
스모크 절차는 그다음. "커밋했으니 됨" 으로 핸드오프하지 말 것.
- 신규 서버 기능(엔드포인트·매퍼) 추가 시 특히. 구 JSP 가 서빙되면 클라이언트 전용 동작(예:
로그아웃 상태에서 좋아요가 눌리고 카운트가 로컬에서만 변함)이 그대로 보여 "안 고쳐졌다" 로 오인된다 —
이 증상이 보이면 코드 결함이 아니라 미재배포 신호.
- 재배포 명령은 `mem:local-dev-setup-gotchas` + `docs/development/local-dev-setup.md`
"코드·JSP 변경 후 재빌드·재시작" 표 참조.
## 왜
세션 20260629-171042: 직전 좋아요 영속화 수정(L1 PASS, 커밋 fa6a301)을 사용자가 더미 게임에서
테스트했으나 "안 됨". 원인은 코드가 아니라 **돌던 앱이 구 버전**. orchestrator 가 재배포 단계를
needs_user_verification 에 안 적었고(컨테이너 검증만 함), 사용자가 "애초에 니가 해줬어야 / 가이드에
기재하라" 고 지적. docs-first 로 가이드에 반영 완료.
근거 세션: .atp/work-session/20260629-171042.

View File

@ -1,22 +0,0 @@
# subagent 재개 시 선행 worker 완료를 추정 단정하지 말 것
orchestrator 가 advisor/worker 를 재개(resume·재호출)·취합할 때, 선행 worker 들의 완료 상태를
"이쯤이면 다 됐을 것" 식으로 추정해 재개 프롬프트(dispatch)에 사실로 주입하면,
미검증 상태가 입력방향 오염(`mem:` agent-team-protocol §2.9)으로 산출 파이프라인에 전파된다.
## 적용
1. 재개·취합 직전 완료 상태를 관측으로 확정: 각 worker 의 산출물 append(files-owners.md/change-log.md),
`git status -s`, 완료 신호(task 알림) 확인.
2. 미확인 worker 는 "완료" 아니라 "미확인 — 알림 대기" 로 기술. dispatch 에 추정 완료수 금지.
3. 부분 완료면 partial-recovery 절차로 잔여만 좁혀 재호출.
4. 재개 프롬프트 첫 줄: "확정 완료(관측됨): N / 미확인: M" 등급 구분.
## 왜
세션 20260629-151216(bibimbap 좋아요 영속화)에서 orchestrator 가 research-advisor 재개 시
"worker 4개 완료됐을 것"이라 단정 추정했으나 당시 2개만 완료. advisor 가 거부하고 나머지 알림 후
취합해 결함 0 으로 막았지만, advisor 방어가 없었다면 절반만 취합된 근거로 근본원인 판단이 갈렸을 수 있다.
방어는 우연이므로 orchestrator 측 가드 필요.
기존 `runtime-selfreport-not-ui-evidence` / git-tracking-claim 류가 "주장 전 검증" 일반론이라면,
본 항목은 "subagent 재개 시 선행 worker 완료 검증" 의 구체 케이스로 구분된다.
프로토콜 명문화는 atp 번들 권위본 대상이라 report.md protocol_feedback 로 상류 제안됨.

View File

@ -1,24 +0,0 @@
-- 게임 좋아요 서버 영속화: (game_id, user_key) 사용자별 1회 좋아요 멱등 보장
-- 적용 전 반드시 중복 row 점검 후, 중복이 있으면 제거하고 제약 추가할 것.
-- 이 파일은 작성만 한다. DB 적용은 orchestrator 가 사용자 확인 후 수행.
-- 1) 중복 점검 (적용 전 실행, 결과가 0행이어야 안전)
SELECT game_id, user_key, COUNT(*)
FROM game_likes
GROUP BY game_id, user_key
HAVING COUNT(*) > 1;
-- 2) (중복이 있을 경우) 최신 1건만 남기고 제거 — 점검 결과 확인 후 수동 실행
-- DELETE FROM game_likes gl
-- USING game_likes keep
-- WHERE gl.game_id = keep.game_id
-- AND gl.user_key = keep.user_key
-- AND gl.id < keep.id
-- AND keep.id = (SELECT MAX(id) FROM game_likes x WHERE x.game_id = gl.game_id AND x.user_key = gl.user_key);
-- 3) UNIQUE 제약 추가
ALTER TABLE game_likes
ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
-- 롤백:
-- ALTER TABLE game_likes DROP CONSTRAINT uq_game_likes_game_user;

View File

@ -249,7 +249,6 @@ COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관
-- ---------------------------------------------------------------------------
-- game_likes (비권위 복원본 — 매퍼는 hard delete 사용, is_delete 컬럼 없음)
-- uq_game_likes_game_user: (game_id, user_key) UNIQUE 로 사용자별 1회 좋아요 멱등 보장
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS "game_likes_id_seq";
CREATE TABLE IF NOT EXISTS "game_likes" (
@ -257,8 +256,7 @@ CREATE TABLE IF NOT EXISTS "game_likes" (
"game_id" bigint NOT NULL REFERENCES "games" ("id"),
"user_key" character varying(200) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "uq_game_likes_game_user" UNIQUE ("game_id", "user_key")
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_likes_id_seq" OWNED BY "game_likes"."id";

View File

@ -1,31 +0,0 @@
# =============================================================================
# 로컬 dev 전용 오버라이드 — `docker compose` 가 docker-compose.yml 과 자동 병합한다.
# 목적: app 을 "WAR 굽기" 대신 소스 bind-mount + `mvn spring-boot:run` 으로 띄워
# 코드/JSP 변경을 이미지 재빌드 없이 반영(로컬 빠른 루프).
#
# 배포 이미지는 이 파일과 무관(별도 경로). 배포/운영 빌드는 base 만 명시 사용:
# docker compose -f docker-compose.yml up -d --build app # Dockerfile WAR 굽기
# (override 자동병합을 피하려고 `-f docker-compose.yml` 로 base 만 지정한다.)
#
# 반영 방법(로컬, 이미지 재빌드 0):
# - JSP/정적: 저장 즉시 반영(Jasper 가 마운트된 webapp 에서 재컴파일). 재시작 불필요.
# - Java 코드: `docker compose restart app` (spring-boot:run 재기동 ~1~2s)
# 또는 `docker compose exec app mvn -o -P dev -DskipTests compile` 후 restart.
# 전제: 호스트 ~/.m2 에 의존성이 캐시돼 있어야 offline(-o) 으로 뜬다(기존 빌드 이력으로 충족).
#
# 참고: devtools 자동재시작은 도입하지 않음 — 컨테이너 spring-boot:run 에는 소스 자동
# 재컴파일러가 없어(IDE 부재) 이득이 미미하고, SNAPSHOT devtools 의 offline 해소가
# 불안정해 restart/exec-compile 루프가 더 견고하다.
# =============================================================================
services:
app:
image: maven:3.9-eclipse-temurin-21
# base 의 build(Dockerfile WAR 굽기)를 로컬에선 사용하지 않는다.
build: !reset null
working_dir: /build
# offline: 프록시 CA 우회 + 재현성. 새 의존성 추가 시에만 일시적으로 -o 제거 후 1회 온라인.
command: ["mvn", "-o", "-P", "dev", "-DskipTests", "spring-boot:run"]
volumes:
- .:/build # 소스 bind-mount (코드/JSP 즉시 공유)
- ${HOME}/.m2:/root/.m2 # 호스트 의존성 캐시 재사용(offline 가능)

View File

@ -168,8 +168,8 @@ Java/Maven 웹앱(Spring Boot 3 MVC + MyBatis + PostgreSQL, JSP 뷰) 전면 read
| 회원가입/로그인/로그아웃 | POST `/signup`,`/login`,`/logout` | Users/UserAuthIdentities add/get/update | signup.jsp, login.jsp | users, user_auth_identities |
| 프로필 | GET `/profile`(뷰), POST `/profile/nickname`,`/profile/avatar` | getUser, updateUser, getGamesByUserId | profile.jsp | users, user_auth_identities |
| 정적 페이지 | GET `/terms`,`/operation-policy` | — | terms.jsp, operation-policy.jsp | — |
| 게임 댓글 | (분석시점: 엔드포인트 없음) → **연결 완료** GameCommentController(W3-2) | GameCommentsMapper | game-detail.jsp(fetch 서버) | game_comments |
| 게임 좋아요 | (분석시점: 엔드포인트 없음) → **연결 완료** `POST /game/{id}/like`(2026-06-29) | GameLikesMapper(add/find/delete + deleteGameLikes) | game-detail.jsp(fetch 서버) | game_likes(쓰기경로 배선) |
| 게임 댓글 | **(엔드포인트 없음)** | GameCommentsMapper(미연결) | game-detail.jsp(localStorage) | game_comments(미사용) |
| 게임 좋아요 | **(엔드포인트 없음)** | GameLikesMapper(deleteGameLikes 만 호출) | game-detail.jsp(JS baseLikes) | game_likes(쓰기경로 없음) |
### 데이터 모델 (매퍼 SQL 에서 역추출 — `확인됨`)
@ -204,7 +204,7 @@ Java/Maven 웹앱(Spring Boot 3 MVC + MyBatis + PostgreSQL, JSP 뷰) 전면 read
## 미해결 / open_questions
1. ~~게임 좋아요·댓글의 서버 영속화는 의도된 미완성인가, 폐기된 기능인가?~~ → **해소: 의도된 미완성(절반 구현). 댓글은 W3-2 에서, 좋아요는 2026-06-29(세션 20260629-151216)에 서버 영속화 배선 완료. 상세: [changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md), [security/security-remediation-checklist.md](../security/security-remediation-checklist.md) B3.**
1. 게임 좋아요·댓글의 서버 영속화는 의도된 미완성인가, 폐기된 기능인가? (스키마·매퍼는 완비, 엔드포인트만 없음) — 코드만으로 의도 판별 불가(`미확인`).
2. 세션 쿠키 Secure/SameSite·HTTPS 강제는 배포 톰캣/리버스프록시 설정에 의존 — 저장소 코드 밖이라 `미확인`. 프로덕션 설정 확인 필요.
3. `provider`/`provider_user_id` 컬럼 = 소셜로그인 확장 예정 스키마인지(현재 email 전용) — `추정`.
4. spring-boot 3.5.14-SNAPSHOT 을 의도적으로 SNAPSHOT 유지하는 이유(특정 미릴리스 픽스 의존?) — `미확인`. 로컬 빌드 영향(온라인 의존·오프라인 빌드 불가)은 [usage/local-setup.md](../usage/local-setup.md) §1·§7 참조.

View File

@ -1,74 +0,0 @@
# 2026-06-29 게임 "좋아요" 서버 영속화
세션: `20260629-151216` (구현) · `20260629-171042` (배포·실환경 검증) · 검증: L1 PASS + 실환경 스모크 PASS (2026-06-29, docker compose dev) · L2 dev DB skip
## 한 줄 요약
게임 좋아요가 서버에 전혀 저장되지 않고 브라우저 `localStorage` 로만 토글되던 버그를 수정. 신규 `POST /game/{id}/like` 토글 엔드포인트를 추가해 `game_likes` row 와 `games.like_count` 비정규화 컬럼을 단일 트랜잭션으로 동기 변경하고, explore 목록은 무변경으로 증가된 컬럼을 읽어 좋아요 수가 반영된다.
## 배경 / 버그
- 증상: 게임 상세페이지에서 좋아요를 눌러도 explore(탐색) 페이지에서 좋아요 수가 올라가지 않는다.
- 근본원인(seed 가정 반전): "explore 와 상세가 서로 다른 카운트 소스를 읽는 동기화 불일치" 가 아니라 **좋아요가 어떤 영속 저장소에도 쓰이지 않았다.**
- 좋아요 토글 서버 엔드포인트가 애초에 부재 (GameController 매핑 0개).
- `GameLikesMapper.addGameLike/updateGameLike` 는 호출자 0건 dead code.
- 상세 JSP 좋아요 버튼이 서버 호출 없이 `localStorage('bibimbap-game-liked')` 만 토글하고 화면 카운트를 `baseLikes ± 1` 로 로컬 계산.
- explore 5개 조회 + 상세 모두 동일하게 `games.like_count` 컬럼을 직접 SELECT — 그 컬럼이 갱신되지 않으므로 영원히 그대로.
- 근본원인 상세: `.atp/work-session/20260629-151216/research/like-count-rootcause.md`
## 사용자 결정
- D1 (수정 범위 / 카운트 모델): 서버 영속 + `games.like_count` 비정규화 컬럼 동기화. explore 쿼리는 무변경.
- D2 (좋아요 주체): 로그인 사용자 기준(`sessionUserId`). 미로그인 시 401 로 거부하고 로그인 유도. 사용자당 게임당 1회(멱등 토글).
설계 상세: `.atp/work-session/20260629-151216/artifacts/like-persistence-design.md`
## 변경 내용
### 신규 엔드포인트: `POST /game/{id}/like`
- `GameController.toggleLike(id, request, session)` (`@Transactional`).
- 게이트 순서: CSRF(403) → 로그인(401) → 게임 존재(404). 기존 컨트롤러 메서드와 동일.
- 동작: 현재 좋아요 row 조회 후 분기 — 없으면 추가(`addGameLike` + `incrementLikeCount`, liked=true), 있으면 취소(`deleteByGameAndUser` + `decrementLikeCount`, liked=false). 변경 직후 컬럼을 재조회해 진실값 응답.
- 응답(200): `{ status:200, liked:<bool>, likeCount:<int> }`. 실패: 403 / 401 / 404.
- 계약 상세는 contracts 가 아닌 본 문서 + 설계 문서에 기록(별도 contract 기준 문서는 미생성 — 아래 concerns 참조).
### 매퍼
- `GameLikesMapper`: `findByGameAndUser(gameId, userKey)` / `deleteByGameAndUser(gameId, userKey)` 신규. `addGameLike` 는 기존 재사용. `user_key = String.valueOf(sessionUserId)`.
- `GamesMapper`: `incrementLikeCount(id)`(+1) / `decrementLikeCount(id)`(`GREATEST(like_count - 1, 0)` 음수 방어) / `getLikeCount(id)` 신규. 모두 `#{}` 바인딩, `${}` 동적치환 없음.
### 상세 컨트롤러 / JSP
- `addGameModel` 이 현재 사용자의 기존 좋아요 여부를 `liked` 모델 속성으로 주입. 카탈로그 폴백 경로는 `liked=false`.
- `game-detail.jsp` 좋아요 버튼을 `localStorage` 토글 → `fetch POST`(`window.BibimbapCsrf.headers`)로 교체. 서버 응답 `likeCount`/`liked` 로 `textContent` 갱신. 초기 상태는 서버 주입 `${liked}` 사용.
### explore 무변경
- explore 조회 쿼리(`GamesMapper` 의 `g.like_count AS likeCount` SELECT)는 손대지 않음. 컬럼이 갱신되므로 자동 반영. (검증 AC-8: `g.like_count AS likeCount` 패턴 7건 유지)
### DB 스키마
- `db/schema.sql`: `game_likes``UNIQUE(game_id, user_key)` 제약 반영(비권위 복원본 동기).
- `db/migrations/20260629-game-likes-unique.sql`: 신규. **운영 DB 미적용** — 중복 row 점검 SELECT 선행 후 적용 대기. 적용 절차는 [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md) 참조.
## 검증
- L1 PASS: `test-compile` + `GameLikeControllerTest` 7/7 + `*ControllerTest` 회귀 219/219 GREEN (eclipse-temurin:21-jdk 컨테이너).
- **실환경 스모크 PASS (2026-06-29, 세션 `20260629-171042`)**: docker compose dev(로컬 override) 에 배포 후 로그인 사용자가 상세에서 좋아요 클릭 → **탐색(explore) 목록 카운트 반영 확인**(사용자 검증). 신 엔드포인트 라이브(`POST /game/{id}/like` CSRF 미동반 → 403) 확인.
- L2 (dev DB contract): skip — dev DB 미기동 + harness 미구축. like 매퍼는 INSERT/DELETE/UPDATE int 반환 위주라 camelCase Map alias 케이스폴딩 리스크 낮음.
- 검증 상세: `.atp/work-session/20260629-151216/artifacts/verification-result.md`, 배포·스모크: `.atp/work-session/20260629-171042/report.md`
## 잔여 / 미수행 (needs_user_verification)
- UNIQUE 마이그레이션(`db/migrations/20260629-game-likes-unique.sql`) 운영 적용 미수행 — 중복 점검 후 적용 게이트 대기. (로컬 dev DB 볼륨은 기존 init 이라 `schema.sql` 의 UNIQUE 변경도 미반영 — 앱레벨 멱등 토글로 기본 동작엔 무관.)
- 상세 초기 `aria-pressed` 상태(AC-7) 정밀 수동 확인은 선택(좋아요 반영 핵심 동작은 스모크로 확인됨).
- L2 dev DB contract: PostgreSQL dev 기동 후 mapper SQL 실DB 동작 확인.
- 기존 `like_count` 컬럼값과 `game_likes` row 수 초기 불일치는 보존(±1 상대 증감, 절대 재계산은 비목표). 선택적 정합 보정 쿼리는 설계 문서 롤아웃 §3 참조.
## 관련 문서
- 보안 체크리스트 B3(좋아요 서버 영속화 연결): [security/security-remediation-checklist.md](../security/security-remediation-checklist.md)
- 운영 적용 절차: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)
- 프로젝트 분석(미완성 기능 항목): [analysis/2026-06-16-project-analysis.md](../analysis/2026-06-16-project-analysis.md)
- 댓글 서버 영속화(선행 유사 작업): [changes/2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md)

View File

@ -7,5 +7,4 @@
- [2026-06-18-w3-2-comments-reviews.md](./2026-06-18-w3-2-comments-reviews.md) — W3-2 댓글/리뷰 분리 구현. game_comments 서버 영속화 전환 + game_reviews 도메인 신설. 신규 API 9개(댓글 C1~C4, 리뷰 R1~R5), DDL 2종, 권한(작성자/운영자), cascade 확장. L1 31테스트 PASS. L3 스모크·DDL 적용은 needs_user_verification. 좋아요는 범위밖.
- [2026-06-22-w3-2-comments-reviews-enhancement.md](./2026-06-22-w3-2-comments-reviews-enhancement.md) — W3-2 고도화(코어 위에 얹음). 다축 평점(game_review_axes 6축·육각형 SVG 레이더) + A1~A3 일관성(commentView 통일·작성자 마스킹 QG-2 해결·edited/updatedAt) + B1~B3 목록규모(페이지네이션·본문10자·TextNormalizer) + C1~C6 UX + game_review_stats 집계뷰(C3 재분류 — W2-3 동결 무관 확정). 신규 4파일+변경 13. L1 43/43 GREEN. DDL·L3 스모크 needs_user_verification.
- [2026-06-29-w3-w4-features.md](./2026-06-29-w3-w4-features.md) — W3(잔여)+W4 5기능 구현(W3-1·W3-4·W3-3·W3-5·W4) + 후속(20260629-142115) 배지 표시 배선 완료·보안 하드닝 b1/b2. 태그+검색·메인허브·포스팅보드·업로드보안·배지/평판 구현(5 커밋 35f1dc3~305cc73, 최종 L1 347/347 GREEN). 후속: 게임카드 creator 배지 칩·프로필 myBadges 배선(3d10449, L1 352/352) + SsrfSafeFetcher @PostConstruct DNS ttl=30(b1 best-effort) + 업로드 저장루트 static 밖 이전(b2, 9041bb7, L1 353/353). DB DDL 8종 사용자 직접 적용 완료. L3 스모크 및 b2 자산 수동 이전은 배포 후 과제 이월 — 상세: [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md).
- [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.

View File

@ -2,48 +2,6 @@
로컬에서 앱을 구동할 때 필요한 환경/경로 설정을 둔다.
## ⚠️ 코드·JSP 변경 후 반드시 앱 재빌드·재시작 (가장 흔한 함정)
소스(Java 컨트롤러/매퍼/서비스)나 **JSP(`/WEB-INF/views/*.jsp`)** 를 고쳐도, **돌고 있는 앱은 자동으로 반영되지 않는다.** 변경을 커밋·테스트 통과까지 해도 *실행 중인 인스턴스가 구 버전이면* 화면 동작은 그대로다.
> 증상 예: 좋아요/댓글 등 신규 서버 기능을 추가했는데 "고친 게 반영이 안 된다" — 십중팔구 **앱 미재시작**. 특히 클라이언트 동작(예: 로그인 안 했는데도 버튼이 눌리고 카운트가 로컬에서만 변함)이 보이면 구 JSP 가 서빙 중이라는 신호다.
구동 방식별 재배포:
| 구동 방식 | 재빌드·재시작 |
| --- | --- |
| **IDE / 호스트 JVM** (`./mvnw spring-boot:run`, 또는 IntelliJ Run) | 앱 **Stop → 프로젝트 rebuild → 다시 Run**. 컴파일된 컨트롤러 변경은 hot-reload 안 됨(devtools 미사용). JSP 변경도 재기동으로 확실히 반영. |
| **docker compose (로컬 dev — 권장)** | 아래 "로컬 도커 dev" 참조. JSP 는 즉시, Java 는 `docker compose restart app`. **이미지 재빌드 불필요**. |
| **docker compose (배포 이미지 빌드)** | `docker compose -f docker-compose.yml up -d --build app` — Dockerfile 로 WAR 굽기. 배포/운영용. |
| **수동 WAR** | `./mvnw -P dev clean package spring-boot:repackage -DskipTests` 로 실행형 WAR 재패키지(프로필·repackage goal 주의 — 본 문서 함정 참조) 후 재기동. |
확인: 재시작 후 의도한 신규 엔드포인트가 응답하는지 1회 스모크(예: 좋아요는 로그인 상태에서 클릭 → 새로고침·목록에서도 카운트 유지). 미로그인 클릭은 신 코드에선 401(로그인 유도)이 정상 — 더 이상 클라이언트에서 토글되지 않는다.
> 코드 변경을 핸드오프할 때는 이 재빌드·재시작 단계를 `needs_user_verification`/배포 후 체크리스트에 **명시**한다. 커밋·테스트 통과 ≠ 실행 인스턴스 반영.
## 로컬 도커 dev (override) vs 배포 이미지 — 분리
로컬은 **이미지 재빌드 없이** 빠르게 돌리고, 배포 이미지는 **별도 경로**로 굽는다.
- **로컬 dev**: `docker-compose.override.yml`(git 추적, dev 기본)이 `app` 을 재정의한다 — `maven:3.9-eclipse-temurin-21` 이미지에 소스를 bind-mount(`.:/build`)하고 호스트 `~/.m2` 캐시를 물려 `mvn -o -P dev spring-boot:run` 으로 띄운다. `docker compose` 는 base + override 를 **자동 병합**하므로 평소엔 그냥:
```bash
docker compose up -d app # 로컬 dev (override 자동 적용, WAR 안 구움)
docker compose logs -f app
```
반영(이미지 재빌드 0):
- **JSP/정적**: 저장 즉시(Jasper 가 마운트된 webapp 재컴파일). 재시작 불필요.
- **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.
- **배포 이미지**: override 를 **제외**하고 base 만 명시한다(자동병합 회피).
```bash
docker compose -f docker-compose.yml build app # Dockerfile WAR 굽기
docker compose -f docker-compose.yml up -d --build app
```
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` 루프가 더 견고하다.
## 업로드 저장 루트 (static 트리 밖)
업로드물(프로필 이미지·게임 WebGL asset)은 **웹서버 정적 서빙 트리(`src/main/resources/static/`) 밖**에 저장한다. 직접 서빙을 차단하고 컨트롤러 권한 게이트를 강제하기 위함이다(보안 하드닝, commit `9041bb7`).

View File

@ -1,7 +1,7 @@
---
kind: graphify-meta
last_generated_at: 2026-06-29T16:12:00+0900
source_commit: fa6a301
last_generated_at: 2026-06-29T14:54:00+0900
source_commit: 9041bb7
scopes:
- src
- docs
@ -38,7 +38,7 @@ scope 예시: `src`, `src-features`, `docs`, `full` 등. 한 번에 여러 scope
| scope | 마지막 생성 | 소스 커밋 | 대상 경로 | 요약 |
| --- | --- | --- | --- | --- |
| `src` | 2026-06-29 | `fa6a301` | `src/` (Java + AST) | **게임 좋아요 서버 영속화 반영**(9041bb7→fa6a301, AST 1857노드/4419엣지/92커뮤니티). 신규 라우트 `GameController.toggleLike` (`POST /game/{id}/like`) + 신규 주입 엣지 `GameController`→`GameLikesMapper`, mapper 메서드 5 (`GamesMapper.{incrementLikeCount,decrementLikeCount,getLikeCount}` + `GameLikesMapper.{findByGameAndUser,deleteByGameAndUser}`). 직전 상태: **배지 표시면 배선 + 보안 하드닝 반영**(305cc73→9041bb7, AST 1836노드/4330엣지/89커뮤니티). 신규 엣지: `UserBadgesQueryMapper`→{`WebMvcController`,`SearchController`} 주입(게임카드 creator 배지·프로필 myBadges 표시 경로 — GameReviewController authorBadges 정본 답습), `SsrfSafeFetcher.initDnsCachePolicy()` 메서드 노드(networkaddress.cache.ttl 하드닝). W3(잔여)+W4 워크스트림 군집 유지: 태그/검색·메인허브 keyset·게시판/SSRF/유니티피드·업로드보안(ZipSecurity)·배지/평판. 기존 W1 RBAC·W2 게임잼·리뷰·댓글 군집 유지. 정적 이미지/banned-words 제외(AST 기반). |
| `src` | 2026-06-29 | `9041bb7` | `src/` (Java + AST) | **배지 표시면 배선 + 보안 하드닝 반영**(305cc73→9041bb7, AST 1836노드/4330엣지/89커뮤니티). 신규 엣지: `UserBadgesQueryMapper`→{`WebMvcController`,`SearchController`} 주입(게임카드 creator 배지·프로필 myBadges 표시 경로 — GameReviewController authorBadges 정본 답습), `SsrfSafeFetcher.initDnsCachePolicy()` 메서드 노드(networkaddress.cache.ttl 하드닝). W3(잔여)+W4 워크스트림 군집 유지: 태그/검색·메인허브 keyset·게시판/SSRF/유니티피드·업로드보안(ZipSecurity)·배지/평판. 기존 W1 RBAC·W2 게임잼·리뷰·댓글 군집 유지. 정적 이미지/banned-words 제외(AST 기반). |
| `docs` | 2026-06-29 | `305cc73` | `docs/` (md + DDL) | **W3/W4 스키마·변경이력 반영**(semantic 198노드/282엣지/14커뮤니티). 신규 DDL 5: tag-ddl·games-hub-ddl·board-ddl·game-upload-ddl·badge-ddl(테이블/뷰 12 + 인덱스) — tags/game_tags/jam_tags/game_views·post_categories/posts/unity_feed_sources/unity_feed_items·game_upload_audit_log·badges/user_badges/reputation_events 스키마 군집. changes 2026-06-29 W3/W4 변경이력 + 구현추적 갱신. 기존 W1/W2 스키마·문서 카테고리·보안·검증·로드맵 군집 유지. graphify 본체(docs/graph/{src,docs}/) 자기참조 제외. (이번 세션 docs/maintenance 추가분은 src 동반 갱신 우선순위 판정으로 차기 일괄 재생성 대상 — graph-refresh-checker partial-stale 권고.) |
## 갱신 시 체크리스트

View File

@ -4,4 +4,4 @@
## 목록
- [post-deploy-verification-checklist.md](./post-deploy-verification-checklist.md) — 배포 후 검증 체크리스트(L3 deferred). W3/W4 + 하드닝 b1/b2 배포 후 수동 확인 항목: 검색/허브/포스팅/업로드/배지/SSRF ttl 런타임 반영 + b2 자산 수동 이전 절차(user 8 프로필 `~/.bibimbap/uploads/profile/8/`). DB DDL 8종 적용 완료(사용자 직접 적용) 기록 포함. + 게임 좋아요 영속화(20260629-151216): `game_likes` UNIQUE 마이그레이션 운영 적용 절차(중복 점검 게이트) + 좋아요 L3 스모크 항목.
- [post-deploy-verification-checklist.md](./post-deploy-verification-checklist.md) — 배포 후 검증 체크리스트(L3 deferred). W3/W4 + 하드닝 b1/b2 배포 후 수동 확인 항목: 검색/허브/포스팅/업로드/배지/SSRF ttl 런타임 반영 + b2 자산 수동 이전 절차(user 8 프로필 `~/.bibimbap/uploads/profile/8/`). DB DDL 8종 적용 완료(사용자 직접 적용) 기록 포함.

View File

@ -54,33 +54,6 @@ cp -r src/main/resources/static/profile/8/. ~/.bibimbap/uploads/profile/8/
---
## §게임 좋아요 UNIQUE 마이그레이션 (세션 20260629-151216, DB 미적용)
게임 좋아요 서버 영속화([changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md))에서 `game_likes (game_id, user_key)` 중복을 막는 UNIQUE 제약을 `db/migrations/20260629-game-likes-unique.sql` 로 추가했으나 **운영 DB 에는 아직 적용하지 않았다.** 제약이 없으면 동시 더블클릭 시 중복 좋아요 row 가 생길 수 있다(애플리케이션 레벨 select-then-act + 단일 트랜잭션으로 1차 방어하나 DB 보증은 별도).
**적용 절차 (중복 점검 게이트 선행 필수):**
```sql
-- 1) 중복 row 점검 — 결과가 0건이어야 바로 ALTER 가능
SELECT game_id, user_key, COUNT(*) FROM game_likes
GROUP BY game_id, user_key HAVING COUNT(*) > 1;
-- 2) (중복이 있으면) 중복 제거 후 진행. 없으면 곧장 제약 추가
ALTER TABLE game_likes ADD CONSTRAINT uq_game_likes_game_user UNIQUE (game_id, user_key);
```
- 롤백: `ALTER TABLE game_likes DROP CONSTRAINT uq_game_likes_game_user;`
- (선택) 초기 정합 보정 — 기존 `like_count` 컬럼과 row 수 불일치 교정이 필요할 때만. 시드/레거시 카운트를 보존하려면 실행하지 않는다(본 기능 비목표):
```sql
UPDATE games g SET like_count = COALESCE((SELECT COUNT(*) FROM game_likes gl WHERE gl.game_id = g.id), 0);
```
완료 후 체크:
- [ ] 중복 점검 SELECT 0건 확인
- [ ] `uq_game_likes_game_user` UNIQUE 제약 적용
---
## L3 런타임 스모크 체크리스트
WAR 기동 후 아래 기능을 수동으로 확인한다.
@ -121,15 +94,6 @@ WAR 기동 후 아래 기능을 수동으로 확인한다.
- [ ] 리뷰 작성자 배지 칩 — 게임 상세 리뷰 목록에서 리뷰어 배지 칩 표시
- [ ] **프로필 myBadges 표시** (이번 세션 신규 — `profile.jsp` 본인 배지 주입)
### 5-1. 게임 좋아요 서버 영속화 (세션 20260629-151216 신규)
> 선행: 위 §게임 좋아요 UNIQUE 마이그레이션 적용 권장(미적용 상태로도 기능 동작은 가능).
- [ ] 로그인 → 게임 상세 좋아요 클릭 → explore 페이지에서 해당 게임 카운트 +1 반영 확인 (AC-1 핵심 버그)
- [ ] 같은 게임 두 번째 클릭 → 좋아요 취소(카운트 원복, game_likes row 0건) (AC-2 멱등 토글)
- [ ] 좋아요한 게임 상세 재진입(다른 브라우저/시크릿창) → 버튼 `aria-pressed="true"` 초기 렌더 (AC-7, 서버 진실 — localStorage 무관)
- [ ] 미로그인 상태 좋아요 클릭 → 401 + 로그인 유도(상태변경 0)
### 6. 보안 하드닝 b1 — SSRF DNS 캐시 TTL
- [ ] **★SSRF ttl 런타임 반영 확인**: 앱 기동 후 `java.security.Security.getProperty("networkaddress.cache.ttl")` 값이 `"30"` 인지 확인.

View File

@ -15,7 +15,7 @@
| --- | --- | --- | --- |
| B1 | P1 | login/signup CSRF 검증 추가 | MED, 완료 |
| B2 | P2 | 프로토타입 dead code 제거 | LOW-MED |
| B3 | P2 | 좋아요/댓글 서버 영속화 연결 | MED, 기능 무결성. 댓글 완료(W3-2) + 좋아요 완료(20260629-151216, 실환경 스모크 PASS 20260629-171042, UNIQUE 마이그레이션 운영 적용 대기) |
| B3 | P2 | 좋아요/댓글 서버 영속화 연결 | MED, 기능 무결성 |
| B4 | P2 | 의존성/세션/운영 하드닝 | MED |
## B1. login/signup CSRF 검증 추가 (MED)
@ -90,21 +90,21 @@
- 게임 삭제 시 댓글/좋아요 데이터 정리 로직도 있다. `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:243`, `src/main/java/com/pandoli365/bibimbap/controller/api/GameController.java:244`
- 현재 UI는 좋아요와 댓글을 `localStorage`에만 저장한다. `src/main/webapp/WEB-INF/views/game-detail.jsp:812`, `src/main/webapp/WEB-INF/views/game-detail.jsp:830`, `src/main/webapp/WEB-INF/views/game-detail.jsp:913`, `src/main/webapp/WEB-INF/views/game-detail.jsp:928`
의도 확인 (댓글 결정 완료 / 좋아요 결정 완료):
의도 확인 (댓글 결정 완료 / 좋아요 미결 유지):
- [x] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. **로그인 사용자만(`sessionUserId`). `game_likes.user_key = String.valueOf(userId)`. 미로그인 401 로그인 유도. 사용자당 게임당 1회(멱등 토글).** 세션 20260629-151216 D2 확정.
- [hold] 좋아요를 로그인 사용자만 허용할지, 익명 사용자 키 기반으로 허용할지 결정한다. ← **좋아요는 범위 밖, 미결 유지.**
- [x] 댓글을 로그인 사용자만 허용할지, 익명 닉네임 댓글을 허용할지 결정한다. → **로그인 사용자만(서버 영속화, session userId 귀속). 기존 닉네임 레코드는 user_id=NULL 보존(비파괴, QG-2).** W3-2 세션(20260618-104034) 확정.
- [x] 기존 localStorage 댓글을 서버로 마이그레이션할지, 신규 서버 데이터로만 전환할지 결정한다. → **비마이그레이션(신규 서버 데이터로만 전환).** 기존 localStorage 댓글 소멸. W3-2 세션 확정.
- [x] 기존 localStorage 좋아요 처리 방침 → **비마이그레이션. JSP 가 더 이상 `localStorage('bibimbap-game-liked')` 를 읽지 않으므로 잔존 키는 무해(정리 불필요).** 세션 20260629-151216.
- [hold] 기존 localStorage 좋아요 처리 방침 — **좋아요는 범위 밖, 미결 유지.**
체크리스트:
> **좋아요 항목은 세션 20260629-151216 에서 충족 + 실환경 스모크 PASS(20260629-171042, docker compose dev — 로그인 좋아요 → explore 카운트 반영 사용자 확인). 단 `game_likes` UNIQUE 제약은 마이그레이션 운영 적용 대기(아래).**
> **좋아요 항목(101~104)은 범위 밖 — 미충족 유지. 댓글 항목만 W3-2에서 충족.**
- [x] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. **`POST /game/{id}/like` 토글(@Transactional). 게이트 CSRF(403)→로그인(401)→존재(404).**
- [x] 좋아요 추가/취소는 CSRF 검증을 적용한다. → **`CsrfTokens.isValid(request)` 선행(403). 회귀 테스트 AC-4 PASS.**
- [~] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. → **애플리케이션 레벨 select-then-act + 단일 트랜잭션으로 1차 방어. DB UNIQUE(game_id,user_key) 제약은 `db/schema.sql` 반영 + `db/migrations/20260629-game-likes-unique.sql` 신규, 운영 DB 미적용(중복 점검 후 적용 게이트 — [maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)).**
- [x] `games.like_count` 증감은 race condition 없이 처리한다. → **`incrementLikeCount`(`SET like_count = like_count + 1`)/`decrementLikeCount`(`GREATEST(like_count - 1, 0)`) DB 원자 UPDATE + row 변경과 동일 `@Transactional` 단위. 클라이언트 ±1 추정 제거(토글 직후 컬럼 재조회 응답).**
- [ ] `POST /game/{id}/like` 또는 `/api/games/{id}/like` 엔드포인트를 설계한다. ← **좋아요: 미착수(범위 밖)**
- [ ] 좋아요 추가/취소는 CSRF 검증을 적용한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `game_likes` 중복 방지 키를 DB 또는 트랜잭션에서 보장한다. ← **좋아요: 미착수(범위 밖)**
- [ ] `games.like_count` 증감은 race condition 없이 처리한다. ← **좋아요: 미착수(범위 밖)**
- [x] `GET /game/{id}/comments` 또는 상세 모델 주입 방식을 결정한다. → **초기 모델 주입 가능 + 별도 GET C1(`GET /game/{id}/comments`) fetch 방식 채택.** GameCommentController C1 구현 완료(20260618-104034).
- [x] `POST /game/{id}/comments`는 CSRF, 길이 제한, 작성자 정책을 적용한다. → **C2 `POST /game/{id}/comments`: CsrfTokens.isValid(403), content 200자(400), 로그인(401) 적용.** L1 12건 PASS(20260618-104034).
- [x] 댓글 삭제는 작성자 또는 관리자 권한을 확인한다. → **C4: 작성자 본인(sessionUserId) OR ROLE_ADMIN. 비작성자 403.** L1 PASS(20260618-104034).
@ -114,12 +114,12 @@
완료 조건:
- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인. 좋아요: 서버 영속화 완료(20260629-151216) — `game_likes` row + `games.like_count` 컬럼 저장. 상세 초기 `liked` 는 서버 모델 주입(localStorage 비의존). 실환경 스모크(AC-1/AC-7)는 needs_user_verification([maintenance/post-deploy-verification-checklist.md](../maintenance/post-deploy-verification-checklist.md)).**
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요: `POST /game/{id}/like` CSRF 게이트(403) 회귀 테스트 AC-4 PASS(20260629-151216).
- [x] 새로고침/브라우저 변경 후에도 좋아요와 댓글이 유지된다. → **댓글: L3 스모크(20260622-170857) 확인 — 댓글/리뷰 작성 후 새로고침 유지 + 쿠키 없는(=다른 브라우저/시크릿 동치) GET `/game/3/comments`·`/reviews` 가 서버 데이터를 반환(localStorage 비의존 증명).** 좋아요: localStorage 유지(범위 밖, 미충족).
- [x] 토큰 없는 댓글 변경 요청이 실패한다. → **CsrfTokens.isValid 6개 게이트 PASS(AGG-3).** 좋아요 CSRF는 범위 밖.
- [x] XSS payload 댓글이 스크립트로 실행되지 않는다. → **L3 브라우저 스모크(20260622-170857) 확인: 댓글 `<img src=x onerror=alert(1)>`·`<script>alert(2)</script>` + 리뷰 본문 모두 DB raw 저장, 클라 textContent 렌더(game-detail.jsp:1840 댓글/:2136 리뷰) → 텍스트 노드로만 표시(img/script 미생성), alert/confirm/prompt 0회 발화.**
- [x] 게임 삭제 시 관련 댓글/좋아요 정리가 유지된다. → **GameController.deleteGame에 softDeleteGameReviews 추가.** 댓글 soft-delete도 기존 로직 확인. L1 PASS.
> 구현 이력 상세: 댓글 — [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md) / 좋아요 — [changes/2026-06-29-game-like-server-persistence.md](../changes/2026-06-29-game-like-server-persistence.md)
> 구현 이력 상세: [changes/2026-06-18-w3-2-comments-reviews.md](../changes/2026-06-18-w3-2-comments-reviews.md)
## B4. 의존성/세션/운영 하드닝

View File

@ -1,10 +1,8 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameLikeData;
import com.pandoli365.bibimbap.game.GameCatalog;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameLikesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
@ -38,7 +36,6 @@ public class GameController {
private final GameCommentsMapper gameCommentsMapper;
private final GameReviewsMapper gameReviewsMapper;
private final GameViewsMapper gameViewsMapper;
private final GameLikesMapper gameLikesMapper;
private final GameAssetCleanupService assetCleanupService;
private final com.pandoli365.bibimbap.badge.ReputationService reputationService;
@ -49,14 +46,12 @@ public class GameController {
GameCommentsMapper gameCommentsMapper,
GameReviewsMapper gameReviewsMapper,
GameViewsMapper gameViewsMapper,
GameLikesMapper gameLikesMapper,
GameAssetCleanupService assetCleanupService,
com.pandoli365.bibimbap.badge.ReputationService reputationService) {
this.gamesMapper = gamesMapper;
this.gameCommentsMapper = gameCommentsMapper;
this.gameReviewsMapper = gameReviewsMapper;
this.gameViewsMapper = gameViewsMapper;
this.gameLikesMapper = gameLikesMapper;
this.assetCleanupService = assetCleanupService;
this.reputationService = reputationService;
}
@ -168,7 +163,6 @@ public class GameController {
model.addAttribute("reviews", List.of());
model.addAttribute("currentUserId", sessionUserId(session));
model.addAttribute("userRole", (String) session.getAttribute("role"));
model.addAttribute("liked", false);
return "game-detail";
}
@ -306,48 +300,6 @@ public class GameController {
return ResponseEntity.ok(body);
}
@PostMapping("/game/{id}/like")
@Transactional
public ResponseEntity<Map<String, Object>> toggleLike(@PathVariable("id") long id,
HttpServletRequest request,
HttpSession session) {
// 게이트 순서: CSRF(403) 로그인(401) 존재(404)
if (!CsrfTokens.isValid(request)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(CsrfTokens.errorBody());
}
Long userId = sessionUserId(session);
if (userId == null) {
return response(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다.");
}
GameData game = gamesMapper.getGame(id);
if (game == null) {
return response(HttpStatus.NOT_FOUND, "게임을 찾을 수 없습니다.");
}
String userKey = String.valueOf(userId);
GameLikeData existing = gameLikesMapper.findByGameAndUser(id, userKey);
boolean liked;
if (existing == null) {
GameLikeData like = new GameLikeData();
like.setGameId(id);
like.setUserKey(userKey);
gameLikesMapper.addGameLike(like);
gamesMapper.incrementLikeCount(id);
liked = true;
} else {
gameLikesMapper.deleteByGameAndUser(id, userKey);
gamesMapper.decrementLikeCount(id);
liked = false;
}
int likeCount = gamesMapper.getLikeCount(id);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", 200);
body.put("liked", liked);
body.put("likeCount", likeCount);
return ResponseEntity.ok(body);
}
private void addGameModel(Model model, GameData game, Long currentUserId) {
int likeCount = game.getLikeCount() == null ? 0 : game.getLikeCount();
String webglPath = trimToEmpty(game.getWebglPath());
@ -363,9 +315,6 @@ public class GameController {
model.addAttribute("webglDeployPath", webglPath);
model.addAttribute("owner", currentUserId != null && currentUserId.equals(game.getUserId()));
model.addAttribute("currentUserId", currentUserId);
boolean liked = currentUserId != null
&& gameLikesMapper.findByGameAndUser(game.getId(), String.valueOf(currentUserId)) != null;
model.addAttribute("liked", liked);
}
private String webglFrameSrc(String path) {

View File

@ -1,11 +1,9 @@
package com.pandoli365.bibimbap.mapper;
import com.pandoli365.bibimbap.data.GameLikeData;
import org.apache.ibatis.annotations.Delete;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
@ -43,15 +41,4 @@ public interface GameLikesMapper {
WHERE id = #{id}
""")
int updateGameLike(GameLikeData gameLike);
// (game_id, user_key) 현재 좋아요 row 조회 토글 분기/초기 liked 판정에 사용
@Select("SELECT id, game_id AS gameId, user_key AS userKey, created_at AS createdAt "
+ "FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
GameLikeData findByGameAndUser(@Param("gameId") long gameId,
@Param("userKey") String userKey);
// 좋아요 취소 row 삭제 토글-off 경로에 사용
@Delete("DELETE FROM game_likes WHERE game_id = #{gameId} AND user_key = #{userKey}")
int deleteByGameAndUser(@Param("gameId") long gameId,
@Param("userKey") String userKey);
}

View File

@ -340,30 +340,4 @@ public interface GamesMapper {
@Param("cursorCreatedAt") java.time.OffsetDateTime cursorCreatedAt,
@Param("cursorId") Long cursorId,
@Param("limit") int limit);
// 좋아요 추가 컬럼 +1 (단일 트랜잭션 ) #{} 바인딩, ${} 금지
@Update("""
UPDATE games
SET like_count = like_count + 1
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int incrementLikeCount(@Param("id") long id);
// 좋아요 취소 컬럼 -1, 음수 방어(GREATEST 0)
@Update("""
UPDATE games
SET like_count = GREATEST(like_count - 1, 0)
WHERE id = #{id}
AND is_delete IS NOT TRUE
""")
int decrementLikeCount(@Param("id") long id);
// 토글 직후 진실값 응답용 컬럼 재조회
@Select("""
SELECT like_count
FROM games
WHERE id = #{id}
""")
int getLikeCount(@Param("id") long id);
}

View File

@ -1383,10 +1383,34 @@
(function () {
var ctx = '${pageContext.request.contextPath}';
var gameId = ${gameId};
var liked = ${empty liked ? 'false' : liked};
var baseLikes = ${likeCount};
var LIKE_KEY = 'bibimbap-game-liked';
var viewerId = ${empty currentUserId ? 'null' : currentUserId};
var viewerRole = '${empty userRole ? "" : userRole}';
function getLikedMap() {
try {
var raw = localStorage.getItem(LIKE_KEY);
var o = raw ? JSON.parse(raw) : {};
return o && typeof o === 'object' ? o : {};
} catch (e) {
return {};
}
}
function setLiked(gameIdStr, liked) {
var m = getLikedMap();
if (liked) m[gameIdStr] = true;
else delete m[gameIdStr];
try {
localStorage.setItem(LIKE_KEY, JSON.stringify(m));
} catch (err) {}
}
function isLiked(gameIdStr) {
return !!getLikedMap()[gameIdStr];
}
function formatCount(n) {
return n.toLocaleString('ko-KR');
}
@ -1446,59 +1470,19 @@
});
}
function applyLikeUi() {
function syncLike() {
var liked = isLiked(gid);
likeBtn.setAttribute('aria-pressed', liked ? 'true' : 'false');
likeBtn.setAttribute('aria-label', liked ? '좋아요 취소' : '좋아요');
likeCountEl.textContent = formatCount(baseLikes + (liked ? 1 : 0));
}
var pending = false;
likeBtn.addEventListener('click', function () {
if (pending) return;
pending = true;
fetch(ctx + '/game/' + encodeURIComponent(gid) + '/like', {
method: 'POST',
headers: window.BibimbapCsrf ? window.BibimbapCsrf.headers({
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}) : {
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
}
}).then(function (res) {
if (res.status === 401) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '로그인 필요', message: '좋아요는 로그인 후 이용할 수 있습니다.', confirmText: '확인' });
} else {
alert('좋아요는 로그인 후 이용할 수 있습니다.');
}
return null;
}
if (res.status === 403) {
if (window.BibimbapModal && typeof window.BibimbapModal.alert === 'function') {
window.BibimbapModal.alert({ title: '요청 실패', message: '요청 보안 토큰이 유효하지 않습니다. 새로고침 후 다시 시도해 주세요.', confirmText: '확인' });
} else {
alert('요청 보안 토큰이 유효하지 않습니다.');
}
return null;
}
if (!res.ok) { return null; }
return res.json().catch(function () { return null; });
}).then(function (data) {
if (data && typeof data.liked !== 'undefined') {
liked = !!data.liked;
applyLikeUi();
if (typeof data.likeCount === 'number') {
likeCountEl.textContent = formatCount(data.likeCount);
}
}
}).catch(function () {
// 네트워크 실패 — 상태 원복(현재 liked 유지). 별도 처리 없음.
}).then(function () {
pending = false;
var next = !isLiked(gid);
setLiked(gid, next);
syncLike();
});
});
applyLikeUi();
syncLike();
// ===== 공통 헬퍼 (서버 연동 덧글/리뷰) =====
var AXIS_KEYS = ['immersion', 'creativity', 'controls', 'completeness', 'sound', 'visual'];

View File

@ -1,243 +0,0 @@
package com.pandoli365.bibimbap.controller.api;
import com.pandoli365.bibimbap.badge.ReputationService;
import com.pandoli365.bibimbap.data.GameData;
import com.pandoli365.bibimbap.data.GameLikeData;
import com.pandoli365.bibimbap.mapper.GameCommentsMapper;
import com.pandoli365.bibimbap.mapper.GameLikesMapper;
import com.pandoli365.bibimbap.mapper.GameReviewsMapper;
import com.pandoli365.bibimbap.mapper.GameViewsMapper;
import com.pandoli365.bibimbap.mapper.GamesMapper;
import com.pandoli365.bibimbap.security.CsrfTokens;
import com.pandoli365.bibimbap.service.GameAssetCleanupService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpSession;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GameLikeControllerTest {
@Mock
private GamesMapper gamesMapper;
@Mock
private GameCommentsMapper gameCommentsMapper;
@Mock
private GameReviewsMapper gameReviewsMapper;
@Mock
private GameViewsMapper gameViewsMapper;
@Mock
private GameLikesMapper gameLikesMapper;
@Mock
private GameAssetCleanupService assetCleanupService;
@Mock
private ReputationService reputationService;
// ---- AC-1: 미좋아요 좋아요 추가, incrementLikeCount 호출 + 응답 likeCount 반영 ----
@Test
void toggleLikeAddsLikeAndIncrementsCount() {
GameController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "사용자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(null);
when(gamesMapper.getLikeCount(1L)).thenReturn(6);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("liked", true);
assertThat(response.getBody()).containsEntry("likeCount", 6);
// 회귀 가드: 컬럼 +1 = incrementLikeCount 호출 + row 추가
verify(gameLikesMapper).addGameLike(any());
verify(gamesMapper).incrementLikeCount(1L);
}
// ---- AC-2: 이미 좋아요 토글 취소, decrementLikeCount 호출, increment 미호출 ----
@Test
void toggleLikeRemovesLikeAndDecrementsCount() {
GameController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "사용자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(existingLike(1L, "7"));
when(gamesMapper.getLikeCount(1L)).thenReturn(5);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
assertThat(response.getBody()).containsEntry("liked", false);
assertThat(response.getBody()).containsEntry("likeCount", 5);
verify(gameLikesMapper).deleteByGameAndUser(eq(1L), anyString());
verify(gamesMapper).decrementLikeCount(1L);
verify(gamesMapper, never()).incrementLikeCount(anyLong());
}
// ---- AC-3: 추가 경로에서 game_likes row 생성 + userKey == String.valueOf(userId) ----
// created_at DB default 이므로 단위테스트 범위 (mapper insert 시점 자동 채움).
@Test
void toggleLikePersistsRowWithUserKeyEqualToUserIdString() {
GameController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "사용자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(game(1L));
when(gameLikesMapper.findByGameAndUser(1L, "7")).thenReturn(null);
when(gamesMapper.getLikeCount(1L)).thenReturn(1);
controller.toggleLike(1L, request, session);
ArgumentCaptor<GameLikeData> captor = ArgumentCaptor.forClass(GameLikeData.class);
verify(gameLikesMapper).addGameLike(captor.capture());
assertThat(captor.getValue().getGameId()).isEqualTo(1L);
assertThat(captor.getValue().getUserKey()).isEqualTo(String.valueOf(7L));
}
// ---- AC-4: CSRF 누락 403, mutation 차단 ----
@Test
void toggleLikeRejectsMissingCsrfBeforeMutation() {
GameController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "사용자");
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
assertThat(response.getBody()).containsEntry("status", 403);
verifyNoInteractions(gameLikesMapper);
verify(gamesMapper, never()).getGame(anyLong());
verify(gamesMapper, never()).incrementLikeCount(anyLong());
}
// ---- AC-5: 미로그인 401 ----
@Test
void toggleLikeRequiresLogin() {
GameController controller = controller();
MockHttpSession session = new MockHttpSession();
CsrfTokens.getOrCreate(session);
MockHttpServletRequest request = csrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
assertThat(response.getBody()).containsEntry("message", "로그인이 필요합니다.");
verifyNoInteractions(gameLikesMapper);
verify(gamesMapper, never()).getGame(anyLong());
verify(gamesMapper, never()).incrementLikeCount(anyLong());
verify(gamesMapper, never()).decrementLikeCount(anyLong());
}
// ---- AC-6a: 게이트 순서 CSRF 무효 + 미로그인 동시 403 우선(401 아님) ----
@Test
void toggleLikePrefersCsrfFailureOverLoginFailure() {
GameController controller = controller();
MockHttpSession session = new MockHttpSession();
CsrfTokens.getOrCreate(session);
MockHttpServletRequest request = noCsrfPost(session);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
verifyNoInteractions(gameLikesMapper);
}
// ---- AC-6b: 게임 존재 404 ----
@Test
void toggleLikeReturnsNotFoundWhenGameMissing() {
GameController controller = controller();
MockHttpSession session = loginSession(7L, "USER", "사용자");
MockHttpServletRequest request = csrfPost(session);
when(gamesMapper.getGame(1L)).thenReturn(null);
ResponseEntity<Map<String, Object>> response =
controller.toggleLike(1L, request, session);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
assertThat(response.getBody()).containsEntry("message", "게임을 찾을 수 없습니다.");
verifyNoInteractions(gameLikesMapper);
}
// ---- helpers ----
private GameController controller() {
return new GameController(
gamesMapper,
gameCommentsMapper,
gameReviewsMapper,
gameViewsMapper,
gameLikesMapper,
assetCleanupService,
reputationService);
}
private GameData game(long id) {
GameData g = new GameData();
g.setId(id);
g.setUserId(1L);
return g;
}
private GameLikeData existingLike(long gameId, String userKey) {
GameLikeData like = new GameLikeData();
like.setId(99L);
like.setGameId(gameId);
like.setUserKey(userKey);
return like;
}
private MockHttpSession loginSession(long userId, String role, String displayName) {
MockHttpSession session = new MockHttpSession();
session.setAttribute("userId", userId);
session.setAttribute("role", role);
session.setAttribute("displayName", displayName);
CsrfTokens.getOrCreate(session);
return session;
}
private MockHttpServletRequest csrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
request.addHeader(CsrfTokens.HEADER_NAME, (String) session.getAttribute(CsrfTokens.SESSION_ATTRIBUTE));
return request;
}
private MockHttpServletRequest noCsrfPost(MockHttpSession session) {
MockHttpServletRequest request = new MockHttpServletRequest();
request.setSession(session);
return request;
}
}