35 KiB
| phase | agent | agent_version | generated_at | concerns | references | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| design | design-advisor | 1 | 2026-06-22T00:00:00+09:00 |
|
|
설계: W3-2 댓글/리뷰 고도화 (확정 구현 설계도)
목표 / 비목표
목표 (FINAL SPEC 추적 가능)
- A1 댓글 응답 commentView 단일 통일 (POST·PUT·list 전부 동일 스키마, POST는 insert 후 재조회).
- A2 댓글 작성자명 하이브리드 (LEFT JOIN users + CASE 마스킹). QG-2(레거시 user_id NULL) 자동 해결.
- A3 댓글 edited+updatedAt (game_comments.updated_at 멱등 ALTER + 매퍼/모델 갱신).
- B1 페이지네이션 offset/limit 20건 "더보기" + sort 토글 (화이트리스트 enum→고정 SQL,
${}금지). - B2 리뷰 본문 필수 최소 10자(trim 후). 댓글은 현행 유지.
- B3 본문 정규화 (제어문자 strip + 외곽 trim, 댓글·리뷰 공통 유틸).
- 다축 평점 game_review_axes 6축(1~5 전 축 필수) + overall 자동평균/수동덮어쓰기(is_rating_manual).
- C3 game_review_stats 읽기전용 집계뷰 (avg_rating, review_count, 6축 평균). 클라 평균계산 폐기.
- C1 submit 잠금+로딩 / C2 상대시각 / C4 글자수 카운터 / C5 완전소멸 유지 / C6 radiogroup 완전 패턴.
- 육각형 레이더 인라인 SVG 자체구현 (요약 + 개별 리뷰 카드), a11y 텍스트 대체.
비목표 (스코프 밖 — 명시)
- 신고·숨김 (W1 운영자 role 선행).
- 리뷰 이력 테이블 (in-row 마커만).
- 좋아요 서버화 (별도 관리).
- W2-3 잼 평가(심사/투표/시상) 스키마 — 본 설계 무관(C3 정정으로 명확히 분리).
- 댓글 최소 글자수 (현행: 상한 200자만).
C3 재분류 (직전 report 오분류 교정 — 설계 결정)
직전 report.md 는 C3(game_review_stats 평균별점 뷰)를 "W2-3 동결 해제·고위험·§6 게이트"로 표기했으나 오분류다.
- 근거 (
docs/work-log/2026-06-17-jam-platform-roadmap.md)::63— "W2-3 범위 = 잼 평가만. 댓글/리뷰 스키마 자체는 W3에서 설계(동결 묶음 아님).":201— "S4D 평가 통합설계 → W2-3 범위 축소: 잼 평가(심사/투표/시상) 스키마만 동결. 댓글/리뷰 스키마는 W3-2에서 별도 설계.":202— "S4a 댓글/리뷰 분리 → W3-2 일반기능으로 재분류: 잼 평가 동결묶음에서 분리."
- 결론:
game_review_stats=game_reviews(W3-2 테이블) 위 읽기전용 집계뷰 신설 = 일반 DDL. §6 파괴적 게이트 없음. orchestrator 사용자 재확인 경로 불필요. - 부수 조치:
docs/game-reviews-ddl.sql:63주석("집계 컬럼/뷰는 신설하지 않음 (W2-3 동결 보호)") 및db/schema.sql:136-137("집계 컬럼/뷰는 W2-3 동결 — 신설 금지") 은 본 설계로 무효화 → 구현 시 갱신 대상(파일 영향 맵 참조).
개요
기존 W3-2 코어(댓글/리뷰 CRUD, 2026-06-18 커밋)를 보존하며 그 위에 일관성(A)·규모(B)·다축평점·UX(C)를 얹는다. 백엔드는 매퍼 시그니처 확장(offset/limit/sort, axes 저장) + 신규 axes 매퍼/DDL, 프론트는 game-detail.jsp 단일 파일의 댓글·리뷰 JS 블록 재작성(육각형 SVG, radiogroup, 페이지네이션, 서버집계 표시)이다. DDL은 game-reviews-ddl.sql 에 멱등 append + db/schema.sql 동기화한다. 전 변경은 31건 회귀 테스트를 깨지 않는 것을 1차 게이트로 한다.
플로우
리뷰 작성 (POST /game/{id}/reviews) — 다축 확장
진입: CSRF(403) → 로그인(401) → 게임존재(404)
→ rating 파싱(수동 overall, optional) + axes 6값 파싱(전 축 1~5 필수, 누락/범위외 400)
→ body 정규화(B3) + 최소 10자 검사(B2, 400)
→ 게임당 1회 검사(409)
→ overall 결정:
유저가 overall 직접선택 O → rating=선택값, is_rating_manual=true
유저가 overall 직접선택 X → rating=round(6축 평균), is_rating_manual=false
→ [TX] addGameReview(rating, body, is_rating_manual) → review.id 획득
→ addReviewAxes(review.id, 6행) ← axes 매퍼
→ getGameReview(review.id) 재조회 → reviewView(+axes) 반환
종단: 200 reviewView
리뷰 수정 (PUT) — axes 재저장
진입: CSRF → 로그인 → 리뷰존재+게임일치(404) → 권한(canModify, 403)
→ rating/axes/body 검증(작성과 동일)
→ [TX] editGameReview(rating, body, is_rating_manual, updated_at=now())
→ deleteReviewAxes(reviewId) ← 기존 6행 제거
→ addReviewAxes(reviewId, 6행) ← 신규 6행
→ getGameReview 재조회 → reviewView 반환
목록 조회 (GET /game/{id}/reviews?page&sort) — 페이지네이션
진입: 게임존재(404)
→ page(default 0)·sort(default newest, 화이트리스트 검증→고정 enum) 파싱
→ offset = page*20, limit=21 (hasMore 판정용 +1 조회)
→ listGameReviews(gameId, offset, limit, sortEnum) ← 21건 조회
→ 각 리뷰에 axes 6행 batch 조회·매핑 (listReviewAxesByReviewIds)
→ hasMore = (조회건수 > 20) → 21번째 잘라 20건 반환
→ game_review_stats 1행 조회(요약) → summary 포함
종단: { status, reviews:[reviewView...], hasMore, summary:{avgRating,reviewCount,axes{}} }
댓글 작성 (POST /game/{id}/comments) — A1 통일
진입: CSRF → 로그인 → 게임존재
→ content 정규화(B3) + 1~200자 검사
→ nickname 스냅샷 = sessionDisplayName (유지)
→ addGameComment → comment.id 획득
→ getGameComment(comment.id) 재조회(LEFT JOIN authorName/edited/updatedAt) ← A1: 재조회 패턴
→ commentView 반환
종단: 200 commentView (createReview 패턴 대칭)
데이터 모델
2.1 game_review_axes (신규 테이블)
| 컬럼 | 타입 | 제약 | 비고 |
|---|---|---|---|
| id | bigint | PK, seq | game_review_axes_id_seq |
| review_id | bigint | NOT NULL, FK→game_reviews(id) | |
| axis_key | varchar(20) | NOT NULL, CHECK(6종) | 영문 snake |
| score | smallint | NOT NULL, CHECK(1~5) | |
| UNIQUE(review_id, axis_key) | 리뷰당 축별 1행 | ||
| INDEX(review_id) | 조회 |
- axis_key 6종 (순서 고정 — 육각형 축 인덱스 0~5):
immersion(몰입성),creativity(창의성),controls(조작성),completeness(완성도),sound(사운드),visual(비주얼). - 리뷰당 정확히 6행 (전 축 필수). DB는 UNIQUE+CHECK로 보장, 앱이 6행 누락 없이 insert 책임.
2.2 game_comments.updated_at (멱등 ALTER)
timestamptz DEFAULT now() NOT NULL.edited = updated_at > created_at(리뷰 대칭).- 기존 행은 ALTER 시 default now() 적용 → created_at != updated_at 가능성 → 기존 행 보정:
UPDATE ... SET updated_at = created_at WHERE updated_at IS NULL불필요(NOT NULL default). 대신 ALTER 직후UPDATE game_comments SET updated_at = created_at WHERE updated_at > created_at1회로 기존 댓글이 "수정됨" 오표시되지 않게 정렬 (멱등 — 재실행 시 영향 없음, 신규 행은 INSERT가 created/updated 동시 now()).
2.3 game_reviews.is_rating_manual (멱등 ALTER)
boolean DEFAULT false NOT NULL. true=유저 직접선택 overall, false=6축 자동평균.
2.4 game_review_stats (VIEW 신규)
game_reviews(is_delete IS NOT TRUE)⨝game_review_axes집계.- 컬럼:
game_id, avg_rating numeric, review_count bigint, avg_immersion numeric, avg_creativity numeric, avg_controls numeric, avg_completeness numeric, avg_sound numeric, avg_visual numeric. - 클라 평균계산(updateSummary, JSP:1513-1522) 폐기 공급원.
외부 계약 (API)
3.1 commentView 스키마 (A1 통일 — POST·PUT·list 동일)
{
"commentId": 100,
"gameId": 1,
"authorName": "표시명 또는 (탈퇴한 사용자) 또는 스냅샷닉",
"userId": 7, // nullable (레거시)
"content": "...",
"createdAt": "ISO-8601",
"edited": false, // updated_at > created_at
"updatedAt": "ISO-8601" // A3 신규
}
- 변화: POST 응답이 flat(commentId/gameId/authorName/userId/content) → commentView 전체. PUT 응답이 부분(commentId/content) → commentView 전체. createdAt/edited/updatedAt 신규 노출.
status,message는 POST/PUT 응답에 commentView 와 병합(createReview 패턴 그대로).
3.2 reviewView 스키마 (axes 추가)
기존 reviewView(reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt) + 신규:
{
"...": "기존 필드 유지",
"ratingManual": false, // is_rating_manual
"axes": { "immersion":4,"creativity":5,"controls":3,"completeness":4,"sound":2,"visual":5 }
}
3.3 GET list 응답 형태 (페이지네이션)
- 댓글:
{ status, comments:[commentView...], hasMore } - 리뷰:
{ status, reviews:[reviewView...], hasMore, summary }summary= game_review_stats 1행 →{ avgRating, reviewCount, axes:{immersion..visual} }(review_count=0 이면 summary=null).
- 요청 파라미터:
?page=<int≥0, default 0>&sort=<enum, default 토글기본값>. - hasMore 방식 채택: total count 대신
limit+1조회 후 초과 여부. 근거 = 추가 COUNT 쿼리 회피, "더보기" UI는 total 불요.
3.4 sort enum 값 정의
- 댓글:
oldest(기본),newest. - 리뷰:
newest(기본),rating_desc,rating_asc. - 미지정/미허용 값 → 기본값으로 fallback (400 던지지 않음 — UX 관대).
sort 화이트리스트 매핑 표 (§4 — ${} 금지 안전성)
매퍼는 enum 분기 → 고정 SQL 절 방식. 동적 ${} SQL 치환 절대 사용 안 함. 컨트롤러가 String sort 를 화이트리스트 enum 으로 변환(미스매치=기본값) 후, 매퍼는 MyBatis <choose>(XML) 또는 annotation @SelectProvider 의 if-분기로 고정 문자열 상수 선택.
채택 방식: 현 코드가 annotation 매퍼(@Select 텍스트블록)이므로 일관성 위해 컨트롤러에서 sort enum 결정 + 매퍼 메서드를 sort별로 분리하지 않고, @SelectProvider 로 전환하여 ORDER BY 절만 상수 분기. (대안: 매퍼 메서드 N개 분리 — 중복 과다로 비채택.)
| sort enum | 대상 | ORDER BY 고정 SQL 절 (상수, 사용자 입력 미포함) |
|---|---|---|
oldest (댓글) |
comments | ORDER BY created_at ASC, id ASC |
newest (댓글) |
comments | ORDER BY created_at DESC, id DESC |
newest (리뷰) |
reviews | ORDER BY r.created_at DESC, r.id DESC |
rating_desc (리뷰) |
reviews | ORDER BY r.rating DESC, r.created_at DESC, r.id DESC |
rating_asc (리뷰) |
reviews | ORDER BY r.rating ASC, r.created_at DESC, r.id DESC |
- 안전성 근거: ORDER BY 문자열은 5개 컴파일타임 상수 중 하나로만 결정. 사용자 입력 String 은 enum 매칭(
switch/Map)에만 사용되고 SQL 텍스트에 절대 보간되지 않음 → SQL injection 면역. offset/limit 은#{}파라미터 바인딩 유지. - tiebreaker: 별점순은 동점 시 created_at DESC, id DESC 로 결정적 정렬(페이지네이션 안정성 — offset 중복/누락 방지).
다축 시퀀스 (§5)
5.1 overall 자동/수동 계산 위치 = 서버
- 클라(JS)는 6축 점수 + (선택적) overall 수동선택값만 전송.
- 서버가 권위 계산: overall 미전송/빈값 →
rating = Math.round(평균(6축)),is_rating_manual=false. overall 전송 →rating=전송값(1~5 검증),is_rating_manual=true. - 근거: 클라 계산은 변조 가능 + C3 서버집계 정합. round 정책 = 정수 반올림(HALF_UP, Java
Math.round는 .5 올림 — 일관).
5.2 axes 재저장 전략 = delete-all + insert-6 (updateReview)
editGameReviewTX 내:deleteReviewAxes(reviewId)→addReviewAxes(reviewId, List<6>).- 근거: upsert(ON CONFLICT) 대비 매퍼 단순. 6행 고정이라 성능 동일 수준. UNIQUE(review_id,axis_key) 와 무관(전삭제 후 삽입).
5.3 트랜잭션 순서 (createReview)
@Transactional
1. addGameReview(rating, body, is_rating_manual) → useGeneratedKeys → review.id
2. addReviewAxes(review.id, [6 axis rows]) → 6행 batch insert
(실패 시 전체 롤백 — review+axes 원자성)
3. getGameReview(review.id) + listReviewAxes(review.id) → reviewView 조립
5.4 axes 매퍼 = GameReviewAxesMapper 신규 (단일 책임)
신규 인터페이스. 시그니처(인자 사용목적 인라인 주석 — inflate 방지):
@Mapper
public interface GameReviewAxesMapper {
// 1리뷰 6축 일괄 저장. List 1건당 review_id/axis_key/score 사용.
int addReviewAxes(@Param("reviewId") long reviewId, // FK 대상 리뷰
@Param("axes") List<ReviewAxisRow> axes); // 6행(axis_key+score)
// 수정 시 기존 축 전삭제(이후 add 재삽입).
int deleteReviewAxes(@Param("reviewId") long reviewId); // 대상 리뷰
// 목록 화면 N리뷰 축 batch 조회(N+1 회피).
List<ReviewAxisRow> listAxesByReviewIds(@Param("reviewIds") List<Long> reviewIds); // 페이지 리뷰 id들
}
ReviewAxisRow={ Long reviewId; String axisKey; Integer score; }(신규 POJO 또는 GameReviewData 내 정적 중첩). axes batch insert SQL은<foreach>(XML) 또는 @InsertProvider.#{}바인딩 유지.- 최소 인자 원칙 적용: gameId 등 불필요 컨텍스트 미수신. 구현에서 확장 필요 시 추가.
SVG 육각형 좌표 공식 (§6)
6.1 계산 위치 = JS 런타임 (game-detail.jsp 인라인)
- 근거: 개별 리뷰 N건 동적 렌더(buildReviewItem) + 요약 1건. 기존 buildStarDisplay 가 JS DOM 생성 idiom → 일관. JSTL 컴파일타임은 동적 N건과 부적합.
6.2 viewBox / 기하 상수
- 요약(큰) 육각형:
viewBox="0 0 200 200", 중심cx=100, cy=100, 반지름R=80. - 개별 리뷰 카드(컴팩트):
viewBox="0 0 120 120",cx=60, cy=60,R=44. (페이지당 20건 DOM 고려.) - 함수는 cx/cy/R 파라미터화하여 공용 1함수로.
6.3 6 꼭지점(축 끝) 좌표 공식
- 축 i(0~5, §2.1 axis_key 순서와 1:1): 각도
θ_i = -90° + 60°*i(12시 방향 시작, 시계방향).angle_rad = (-90 + 60*i) * Math.PI / 180 axisX_i = cx + R * Math.cos(angle_rad) axisY_i = cy + R * Math.sin(angle_rad) - 배경 그리드(외곽 육각형) = 위 6점 polygon. 단계 그리드 = R0.25, R0.5, R*0.75, R 4단계 동심 육각형(
scale = level/4곱).
6.4 score 폴리곤 점 공식
- 축 i 점수
s_i(1~5) → 반지름 비율ratio_i = s_i / 5:ptX_i = cx + R * (s_i/5) * Math.cos(angle_rad_i) ptY_i = cy + R * (s_i/5) * Math.sin(angle_rad_i) <polygon points="ptX_0,ptY_0 ptX_1,ptY_1 ... ptX_5,ptY_5" />(fill 반투명 + stroke).
6.5 렌더 함수 시그니처 (inflate 방지 — 인자 사용목적 명시)
// 6축 점수 배열을 육각형 SVG 엘리먼트로. 요약/카드 공용.
function buildHexRadar(scores /* [6] axis순 점수배열, score폴리곤 */,
cx /* 중심x, 좌표기준 */,
cy /* 중심y, 좌표기준 */,
R /* 반지름, 스케일 */) { ... return <svg> }
- 최소 인자. 라벨 텍스트/색상은 함수 내부 상수(AXIS_LABELS_KO) 참조 → 인자 미부풀림.
6.6 a11y (SVG 시각요소 텍스트 대체 — 필수)
<svg role="img" aria-label="6축 평가: 몰입성 4, 창의성 5, 조작성 3, 완성도 4, 사운드 2, 비주얼 5">.- 추가로 시각적 보조: 각 축 점수를 visually-hidden 표 또는 인접
<dl>(축명/점수) 동반. 폴리곤만으로 종료 금지(FINAL SPEC).
a11y radiogroup 스펙 (§7 — C6)
현 별점위젯(JSP:1043-1049)은 role=radiogroup/role=radio/aria-checked 마크업은 있으나 키보드 핸들러 없음(click만, JSP:1553-1556). roving tabindex 도 없음. C6 = 완전 패턴 신규 추가.
7.1 roving tabindex
- radiogroup 내 단일 tabstop: 선택된 radio
tabindex="0", 나머지tabindex="-1". 미선택 시 첫 radiotabindex="0". - 화살표 이동 시 tabindex 이전(
-1)→대상(0) +.focus().
7.2 키 핸들러 (keydown)
| 키 | 동작 |
|---|---|
ArrowRight, ArrowUp |
다음 별점(+1, max 5에서 정지 또는 wrap — 정지 채택, LTR 별점 직관) |
ArrowLeft, ArrowDown |
이전 별점(-1, min 1에서 정지) |
Home |
1점 |
End |
5점 |
Space/Enter |
현재 focus radio 선택 확정 |
- 화살표는 이동+즉시선택(setRating) 동시 — radiogroup 표준.
preventDefault()로 스크롤 방지.
7.3 aria 속성
- 그룹
role="radiogroup" aria-labelledby="<라벨id>"(축별 라벨 id 6개). - 각
role="radio" aria-checked="true|false", 선택만aria-checked=true.
7.4 6위젯 공통화
- 별점위젯 생성을 JS 함수
buildStarRadioGroup(axisKey, labelId)로 추출 → 6축 입력 위젯 생성에 재사용. 각 위젯 독립 selectedScore 상태(객체axisScores[axisKey]). 기존 단일 overall 위젯도 동일 함수로 통일(overall은 별도 또는 자동표시). - 함수 시그니처(인자 사용목적):
// 1개 축 별점 radiogroup DOM 생성 + 키/클릭 핸들러 바인딩. function buildStarRadioGroup(axisKey /* axisScores 키 + 상태귀속 */, labelText /* 라벨/aria-label 텍스트 */) { ... }
B3 정규화 유틸 위치 (§8)
- 현황: 공용
util패키지 없음(grep 확인). trimToNull/trimToEmpty 가 GameCommentController·GameReviewController·GameController 에 private 중복. - 채택: 신규
com.pandoli365.bibimbap.util.TextNormalizer(static util 클래스). 기존 private trim 메서드는 이번 스코프에선 미통합(회귀 리스크 — 댓글/리뷰 컨트롤러만 호출 추가). 통합 리팩터는 비목표. - 시그니처 (인자 사용목적 인라인):
public final class TextNormalizer { private TextNormalizer() {} // 저장 위생: 제어문자(탭\t·개행\n\r 제외) 제거 + 외곽 trim. // 내부 연속공백/줄바꿈 보존. null→null. public static String normalize(String raw /* 원문, 정규화 대상 */) { ... } } - 규칙:
\t(U+0009),\n(U+000A),\r(U+000D) 외의 C0/C1 제어문자(U+0000~U+001F, U+007F~U+009F) 제거 → 외곽strip(). 내부 공백 미축약. - 적용: createComment/updateComment content, createReview/updateReview body 에 검증 전 호출(정규화 후 길이 검사).
파일 영향 맵 (§1 파일 소유권 + worker 분할)
worker 분할은 동시수정 충돌 회피 기준. game-detail.jsp 는 단일 파일이라 1 worker 전담.
| 변경 유형 | 경로 | 역할 | worker |
|---|---|---|---|
| 신규 | db/.../docs/game-reviews-ddl.sql (append) |
axes 테이블+updated_at ALTER+is_rating_manual ALTER+stats VIEW 멱등 블록. :63 보수주석 갱신 | W-DDL |
| 변경 | db/schema.sql |
위 DDL 동기화(:136-137 주석 갱신) | W-DDL |
| 신규 | src/.../util/TextNormalizer.java |
B3 정규화 static util | W-BE |
| 신규 | src/.../mapper/GameReviewAxesMapper.java |
axes add/delete/listByReviewIds | W-BE |
| 신규 | src/.../data/ReviewAxisRow.java (또는 GameReviewData 중첩) |
axes 행 POJO | W-BE |
| 변경 | src/.../data/GameCommentData.java |
updatedAt + edited(비영속) 필드 추가 | W-BE |
| 변경 | src/.../data/GameReviewData.java |
ratingManual + axes(Map/List 비영속) 필드 추가 | W-BE |
| 변경 | src/.../mapper/GameCommentsMapper.java |
A2 LEFT JOIN+CASE, A3 updated_at/edited select, B1 offset/limit/sort, editGameComment updated_at | W-BE |
| 변경 | src/.../mapper/GameReviewsMapper.java |
B1 offset/limit/sort, is_rating_manual select/insert/update, edit 시 axes 무관(별 매퍼) | W-BE |
| 신규 | src/.../mapper/GameReviewStatsMapper.java (또는 GameReviewsMapper 내 1메서드) |
game_review_stats 1행 조회 | W-BE |
| 변경 | src/.../controller/api/GameCommentController.java |
A1 재조회·commentView, B1 page/sort, B3 normalize | W-BE |
| 변경 | src/.../controller/api/GameReviewController.java |
다축 검증/저장, overall 자동·수동, B2 10자, B3, B1 page/sort, summary | W-BE |
| 변경 | src/main/webapp/WEB-INF/views/game-detail.jsp |
육각형 SVG, C1·C2·C4·C6, 페이지네이션·sort UI, 서버집계 표시, 6축 입력, A1 commentView 렌더 | W-FE |
| 변경 | src/test/.../GameCommentControllerTest.java |
A1·A2·B3 신규 + 기존 12 유지 | W-TEST |
| 변경 | src/test/.../GameReviewControllerTest.java |
다축·B2·sort 신규 + 기존 13 유지 | W-TEST |
| (무변경 확인) | src/test/.../BibimbapApplicationTests.java |
@MockBean 신규 매퍼(GameReviewAxesMapper 등) 추가 필요 시만 | W-TEST |
충돌 회피: W-BE 가 매퍼/컨트롤러/data/util 동시 소유(상호 의존 — 단일 worker 권장). W-FE 는 jsp 단독. W-TEST 는 BE 완료 후 진행(시그니처 의존). W-DDL 독립 선행 가능.
BibimbapApplicationTests 주의: 신규 @Mapper 빈(GameReviewAxesMapper, GameReviewStatsMapper)이 컨트롤러 생성자 주입되면 contextLoads 가 빈을 못 찾아 실패 → @MockBean 추가 필수. 이 추가는 31건 회귀의 ApplicationTests 1건이 깨지지 않도록 하는 필수 조치.
DDL 전문 (§2 — game-reviews-ddl.sql append 블록)
아래를 docs/game-reviews-ddl.sql 끝에 append. 전부 IF NOT EXISTS / DO 멱등. db/schema.sql 에도 동일 정의 동기화.
-- ===========================================================================
-- W3-2 고도화: 다축 평점(game_review_axes) + 댓글 updated_at + is_rating_manual
-- + game_review_stats 집계뷰
-- C3 재분류: game_review_stats 는 W3-2 일반 읽기전용 집계뷰. W2-3 잼 평가 동결과 무관
-- (roadmap.md:63,201,202). 아래 위 'idx_game_reviews_game' 주석의 "집계뷰 미신설"
-- 보수 표기는 본 블록으로 갱신됨.
-- ===========================================================================
-- 1) game_comments.updated_at (A3)
ALTER TABLE "game_comments"
ADD COLUMN IF NOT EXISTS "updated_at" timestamp with time zone DEFAULT now() NOT NULL;
-- 기존 댓글이 '수정됨' 오표시되지 않도록 정렬(멱등: 이미 정렬된 행엔 무영향)
UPDATE "game_comments" SET "updated_at" = "created_at" WHERE "updated_at" > "created_at";
COMMENT ON COLUMN "game_comments"."updated_at" IS '덧글 마지막 수정 시각. updated_at > created_at 이면 수정됨(리뷰 대칭)';
-- 2) game_reviews.is_rating_manual (overall 출처 구분)
ALTER TABLE "game_reviews"
ADD COLUMN IF NOT EXISTS "is_rating_manual" boolean DEFAULT false NOT NULL;
COMMENT ON COLUMN "game_reviews"."is_rating_manual" IS 'true=유저 직접선택 overall, false=6축 자동평균';
-- 3) game_review_axes (다축 평점, 리뷰당 6행)
CREATE SEQUENCE IF NOT EXISTS "game_review_axes_id_seq";
CREATE TABLE IF NOT EXISTS "game_review_axes" (
"id" bigint DEFAULT nextval('game_review_axes_id_seq'::regclass) NOT NULL,
"review_id" bigint NOT NULL,
"axis_key" character varying(20) NOT NULL,
"score" smallint NOT NULL,
PRIMARY KEY ("id")
);
ALTER SEQUENCE "game_review_axes_id_seq" OWNED BY "game_review_axes"."id";
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_review_id_fkey') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_review_id_fkey"
FOREIGN KEY ("review_id") REFERENCES "game_reviews" ("id");
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_score_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_score_check" CHECK ("score" BETWEEN 1 AND 5);
END IF;
END
$$;
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'game_review_axes_axis_key_check') THEN
ALTER TABLE "game_review_axes"
ADD CONSTRAINT "game_review_axes_axis_key_check"
CHECK ("axis_key" IN ('immersion','creativity','controls','completeness','sound','visual'));
END IF;
END
$$;
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_review_axes_review_axis"
ON "game_review_axes" ("review_id", "axis_key");
CREATE INDEX IF NOT EXISTS "idx_game_review_axes_review"
ON "game_review_axes" ("review_id");
COMMENT ON TABLE "game_review_axes" IS '리뷰 다축 평점(6축, 리뷰당 6행). axis_key 6종 각 1~5';
COMMENT ON COLUMN "game_review_axes"."axis_key" IS '몰입성 immersion/창의성 creativity/조작성 controls/완성도 completeness/사운드 sound/비주얼 visual';
-- 4) game_review_stats (읽기전용 집계뷰 — 클라 평균계산 폐기 공급원)
CREATE OR REPLACE VIEW "game_review_stats" AS
SELECT
r."game_id" AS "game_id",
ROUND(AVG(r."rating")::numeric, 1) AS "avg_rating",
COUNT(*) AS "review_count",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='immersion'),1) AS "avg_immersion",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='creativity'),1) AS "avg_creativity",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='controls'),1) AS "avg_controls",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='completeness'),1) AS "avg_completeness",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='sound'),1) AS "avg_sound",
ROUND(AVG(a."score") FILTER (WHERE a."axis_key"='visual'),1) AS "avg_visual"
FROM "game_reviews" r
LEFT JOIN "game_review_axes" a ON a."review_id" = r."id"
WHERE r."is_delete" IS NOT TRUE
GROUP BY r."game_id";
COMMENT ON VIEW "game_review_stats" IS 'W3-2 일반 집계뷰(W2-3 동결 무관). 게임별 평균별점·리뷰수·6축평균';
db/schema.sql 동기화 판단 = 동기화 필수
- 근거: schema.sql 은 dev 컨테이너 부트스트랩(line 16-18, docker-entrypoint-initdb.d 자동실행). axes 테이블/뷰/컬럼 없으면 dev 환경 매퍼 실행 실패. game_reviews 가 schema.sql:117-135 에 이미 정의되어 있으므로 동일 위치에 위 4블록 append +
:136-137동결 주석을 C3 갱신 주석으로 교체.
대안 비교 (§ 선택적)
| 안 | 장점 | 단점 | 채택? |
|---|---|---|---|
| axes 매퍼 신규 vs GameReviewsMapper 확장 | 단일책임/리뷰매퍼 비대화 방지 | 클래스 1개 추가 | 신규 채택 |
| hasMore(limit+1) vs total count | COUNT 쿼리 회피 | total 미표시 | hasMore 채택 |
| sort: @SelectProvider 분기 vs 메서드 N분리 | ORDER BY만 상수분기, 메서드 1개 | provider 클래스 추가 | provider 채택 |
| overall 계산 서버 vs 클라 | 변조방지·C3정합 | round 1회 서버부담(무시가능) | 서버 채택 |
| axes 재저장 delete+insert vs upsert | 매퍼 단순 | 6 DEL+6 INS | delete+insert 채택 |
| SVG 좌표 JS vs JSTL | 동적 N건 일관 | 클라 계산 | JS 채택 |
| is_rating_manual 채택 vs 미채택 | 자동/수동 출처 구분 | 1컬럼 | 채택(concerns 재확인) |
롤아웃 / 마이그레이션
- 순서: DDL 멱등 적용(dev: schema.sql 재부트 또는 game-reviews-ddl.sql 수동 실행) → BE 빌드/단위테스트 → FE → L3 스모크.
- 역호환:
- 기존 game_reviews 행은 axes 0행 상태 → game_review_stats 의 6축 평균 NULL(LEFT JOIN). 프론트는 NULL 축을 "데이터 없음"/0 처리. overall avg_rating 은 정상(rating 컬럼 기반).
- 기존 댓글: updated_at default now() + 정렬 UPDATE 로 edited=false 유지.
- is_rating_manual default false → 기존 리뷰는 자동평균 취급(축 없어도 rating 표시 영향 없음).
- 롤백 경로: 신규 객체(axes 테이블/stats 뷰/2 컬럼)는 비파괴(ADD/CREATE). 코드 롤백 시 DB 객체 잔존해도 무해(미참조). 뷰는
DROP VIEW IF EXISTS game_review_stats, 컬럼은 보존(비파괴) 권장. - 기존 리뷰 axes 백필: 본 스코프 비대상(NULL 허용). 백필 필요 시 별도.
검증 포인트 (verification-advisor 점검 acceptance criteria)
L1 단위 테스트 (회귀 + 신규)
- AC-1: 기존 31건 회귀 PASS —
./mvnw test결과 GameCommentControllerTest(12) + GameReviewControllerTest(13) + UserControllerCsrfTest(5) + BibimbapApplicationTests(1) 전부 GREEN. (DbUpdateQueryGeneratorTest 1건은 W3-2 무관, 영향 없음 확인.) - AC-2: BibimbapApplicationTests 의 @MockBean 집합이 컨트롤러 생성자 의존 매퍼 전수를 커버 — 신규 매퍼(GameReviewAxesMapper, GameReviewStatsMapper 등) 추가 시 contextLoads PASS.
- AC-3: 신규 단위테스트 추가건 PASS (목록 §9).
L1 안전성 (sort 화이트리스트 — 집합 전수)
- AC-4: sort enum 매핑 전수 5건 보존 — 매퍼/provider 의 ORDER BY 분기가 §4 표의 5개 enum(
oldest,newest(댓글),newest,rating_desc,rating_asc(리뷰))을 전수 커버. 검증:grep -c 'ORDER BY' <provider 또는 매퍼 소스>>= 5 AND 각 분기 SQL에 사용자 입력 변수 보간(${) 0건 —grep -c '\${' <매퍼소스>== 0 (전 매퍼 통틀어 W3-2 변경분). - AC-5: SQL injection 면역 — 전 신규/변경 매퍼에서
${동적치환 0건 (grep -rc '\${' src/main/java/.../mapper/Game*Mapper.java합 == 0). offset/limit/sort 전부#{}또는 enum 상수.
L1 다축 (집합 전수 — 6축)
- AC-6: axis_key 집합 전수 6건 일치 — DDL CHECK·뷰 FILTER·앱 enum·JSP 라벨 4곳이 동일 6키(
immersion,creativity,controls,completeness,sound,visual). 검증:grep -c "'immersion'\|'creativity'\|'controls'\|'completeness'\|'sound'\|'visual'"패턴이 game-reviews-ddl.sql 의 axis_key CHECK 절에 6개 전수, stats VIEW FILTER 에 6개 전수. - AC-7: createReview 가 6축 미만 입력 시 400 (전 축 필수). 단위테스트로 5축 입력 거부.
- AC-8: overall 자동평균 — 6축 [4,5,3,4,2,5] 입력·overall 미전송 시 rating=round(23/6=3.83)=4, is_rating_manual=false. overall=2 전송 시 rating=2, is_rating_manual=true.
L1 기타 신규
- AC-9: B2 — 리뷰 본문 trim 후 9자 거부(400), 10자 통과. 댓글은 최소제한 없음(기존 유지).
- AC-10: B3 — TextNormalizer.normalize("a<>b\tc") == "ab\tc"(NUL 제거, 탭 보존), 외곽 trim, 내부 공백 보존.
- AC-11: A1 — POST/PUT 댓글 응답이 commentView 전체 키(commentId,gameId,authorName,userId,content,createdAt,edited,updatedAt) 포함.
- AC-12: A2 마스킹 — 매퍼 SQL CASE 3분기 존재(
u.id IS NULL→nickname,u.is_delete→'(탈퇴한 사용자)', else→display_name). LEFT JOIN 확인. - AC-13: A3 — editGameComment 가 updated_at=now() 설정, nickname 미덮어씀.
L3 브라우저 스모크 체크리스트
- AC-14: 별점위젯 키보드 — Tab 으로 위젯 1회 진입(roving), ←→↑↓ 점수 이동, Home=1/End=5, 선택 시 aria-checked 토글. 6축 위젯 각각 독립 동작.
- AC-15: 육각형 레이더 — 요약 SVG(6축 평균) + 개별 리뷰 카드 컴팩트 SVG 렌더.
<svg role="img" aria-label>6축 점수 텍스트 대체 존재. - AC-16: 페이지네이션 — 21건 이상 리뷰/댓글 시 "더보기" 노출, 클릭 시 다음 20건 append, hasMore=false 면 버튼 숨김. sort 토글 동작(댓글 oldest↔newest, 리뷰 newest/rating_desc/rating_asc).
- AC-17: 서버집계 표시 — 요약 평균 소수1자리, review_count=0 시 "아직 평가 없음". 클라 평균계산(updateSummary 구버전) 잔존 0 —
grep -c 'list.reduce' game-detail.jsp의 평균계산 블록 제거 확인. - AC-18: C1 submit 잠금 — 제출 중 버튼 disabled, 응답 후 해제(연타 차단). C4 글자수 카운터 실시간(댓글 n/200, 리뷰 n/1000 + 최소10자 안내). C2 상대시각 "n분 전" + title 절대시각.
- AC-19: DDL 적용 — game_review_axes·game_review_stats·game_comments.updated_at·game_reviews.is_rating_manual 존재 (psql
\d또는 information_schema).
AC 정식화 self-audit (시점·표현 — 프로토콜 §4.7)
- 시점 안정성: AC-1 의 31건은 현재 세션이 W-TEST 로 테스트를 추가하므로 verification 시점엔 31+α건이 됨. → 표현 교정: "기존 31건이 전부 PASS(삭제·실패 0)" 불변식으로 측정, 신규는 AC-3 별도. 자기 트리 고정카운트 함정 회피.
- 표현 견고성: AC-5/AC-17 의
grep -c는 리터럴 의존 —${는 SQL injection 신호로 안정적(동의표현 없음, 견고). AC-17 'list.reduce' 는 구현이 다른 메서드명 쓸 수 있어 fragile → "클라 평균계산 로직 부재"를 수동 코드리뷰로 보강(grep 은 보조). AC-6 axis_key 는 6키 고정 리터럴 — DDL/뷰/앱 동의표현 없이 정확히 이 6문자열만 유효(견고).
verification 실행 명령 (L1)
- 빌드툴 = Maven(pom.xml 확인, build.gradle 없음). 래퍼
./mvnw. - L1:
./mvnw -q test(전체) 또는./mvnw -q test -Dtest=GameCommentControllerTest,GameReviewControllerTest,UserControllerCsrfTest,BibimbapApplicationTests(회귀 31건 집중). - 컴파일만:
./mvnw -q test-compile.