---
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 동등성)으로 판정 — 고정 스칼라가 아닌 "토글 전후 상태 동등" 검사.
