33 KiB
| phase | agent | agent_version | generated_at | concerns | concerns_checked | references | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| design | design-advisor | 1 | 2026-06-18T12:10:00+09:00 |
|
true |
|
설계: W3-2 게임 댓글/리뷰 분리 (서버 영속화 + 리뷰 신설)
목표 / 비목표
목표
- (G1) 게임 상세 댓글을 localStorage → 서버 영속화로 전환. 작성자 = 로그인 사용자(user_id 귀속). content 200자 제한.
- (G2) 게임당 1회 리뷰(별점 1~5 + 서술 평가) 도메인 신설. 수정 시 "수정됨" 마커 + 이력 보존.
- (G3) 댓글/리뷰 수정·삭제 권한 =
작성자 본인 OR 운영자급 role. 운영자 분기는 단위테스트로 검증 가능. - (G4) 게임 soft-delete 시 game_reviews 동반 정리(cascade) 추가.
- (G5) 보안 체크리스트 line 101~117(댓글/리뷰 해당분) 동시 충족: CSRF 게이트 / 길이 제한 / 작성자·운영자 삭제 / 서버 escape·textContent / 서버 응답 기준 렌더 / 새로고침·브라우저변경 영속.
- (G6) DDL 3종 산출(schema.sql 블록, docs 권위 파일, idempotent ALTER).
비목표
- 좋아요(localStorage) 변경 — 범위 밖, 미변경.
- 평점 집계 컬럼/뷰/트리거 신설 — 범위 밖(W2-3 동결 계약 보호). rating 공급원까지만.
- 다축(육각형) 평점 — 후속. DDL 은 단일 rating 컬럼으로 확정하되 향후 축 테이블 분리 여지를 주석으로만 남김.
- 기존 localStorage 댓글 마이그레이션 — 비마이그레이션 확정.
- UI/JSP/CSS 비주얼 구현 — 별도
/frontend-design위임. 본 설계는 API 계약 + escape 규약 + textContent 규약 + data 형태만 명시. - 운영자(ADMIN) 사용자 부여 경로 — W1 연결, 범위 밖(상수만 정의).
개요
프로젝트는 controller→mapper 직결(서비스 계층 없음), 커스텀 HttpSession 인증 + 커스텀 CsrfTokens(Spring Security 부재), MyBatis annotation + 명시 시퀀스 PostgreSQL, soft-delete(is_delete) 규약이다(research 종합). W3-2 는 이 패턴을 그대로 따라 ① 기존 orphan 인 GameCommentsMapper/game_comments 를 서버 API 로 연결(user_id 컬럼 nullable 추가)하고, ② 신규 game_reviews 도메인(테이블·POJO·Mapper·컨트롤러)을 recruit_posts 권위 스타일로 추가한다.
표준 상태변경 시퀀스는 RecruitController.createRecruitPost(RecruitController.java:49-143)를 골격으로, 작성자/운영자 권한 분기는 GameController(GameController.java:183-185, :239-241)를 합성한다. 신규 운영자 분기(isOperator)는 session attr role(UserController.java:508)을 ROLE_ADMIN 상수와 비교한다.
플로우
F1. 댓글 작성 (POST /game/{id}/comments)
- 진입:
@Transactional CsrfTokens.isValid(request)실패 → 403CsrfTokens.errorBody()sessionUserId(session)null → 401{status:401,message:"로그인이 필요합니다."}- 게임 존재 확인
gamesMapper.getGame(id)null → 404 - content
trimToNull→ null 또는length()>200→ 400"덧글은 200자 이내로 입력해 주세요." - POJO 세팅(gameId, userId, content; nickname = session displayName 스냅샷) →
gameCommentsMapper.addGameComment - 생성 id null → 500
- 종단: 200 JSON
{status:200, message, commentId, gameId, content, authorName, userId, createdAt}(프론트가 textContent 로 즉시 append 가능한 data 형태)
F2. 댓글 목록 조회
- 초기 진입은 model 주입:
GameController.gameDetail(:103-129)의 DB 게임 분기(:104-109)에서addGameModel이후model.addAttribute("comments", gameCommentsMapper.listGameComments(id))추가. 정적 GameCatalog 폴백 분기(:111-128)는 DB 미존재 게임이므로 빈 리스트(model.addAttribute("comments", List.of())). - 작성·수정·삭제 이후 갱신은 fetch GET API:
GET /game/{id}/comments→ JSON 배열. (완전 서버화 = 초기 model 주입 + mutation 이후 fetch 재조회 혼합. 명시 확정.)
F3. 댓글 수정 (PUT /game/{id}/comments/{commentId})
1~3. (F1 과 동일: Transactional → CSRF → 로그인)
4. gameCommentsMapper.getGameComment(commentId) null → 404. gameId 불일치 → 404(경로 위변조 방지).
5. 권한: userId.equals(comment.getUserId()) || isOperator(role) 거짓 → 403 "작성자만 수정할 수 있습니다." (단, comment.userId == null 인 레거시 닉네임 댓글은 작성자 매칭 불가 → 운영자만 수정 가능)
6. content 200자 검증(F1-5 동일)
7. gameCommentsMapper.editGameComment (content 갱신, is_delete/deleted_at 비변경)
8. 200 JSON
F4. 댓글 삭제 (DELETE /game/{id}/comments/{commentId})
1~5. (F3 과 동일 권한 분기)
6. gameCommentsMapper.softDeleteGameComment(commentId) (is_delete=true, deleted_at=now())
7. 200 JSON {status:200, message}
F5. 리뷰 작성 (POST /game/{id}/reviews)
1~4. (Transactional → CSRF → 로그인 → 게임 존재)
5. rating: Integer 파싱 실패 또는 <1 || >5 → 400 "별점은 1~5 사이로 선택해 주세요."
6. body(서술) trimToEmpty → length()>1000 → 400 (recruit description 1200 대비 보수적 1000자)
7. 게임당 1회: gameReviewsMapper.getActiveReviewByGameAndUser(gameId, userId) 존재 → 409 "이미 이 게임에 리뷰를 작성하셨습니다." (앱레벨 선검사 + DB partial UNIQUE 가 2차 방어)
8. gameReviewsMapper.addGameReview (DB UNIQUE 위반 시 DataIntegrityViolation → ApiExceptionControllerAdvice 가 처리하거나 사전 409 가 차단)
9. 생성 id null → 500
10. 200 JSON {status:200, message, reviewId, gameId, rating, body, authorName, userId, edited:false, createdAt, updatedAt}
F6. 리뷰 수정 (PUT /game/{id}/reviews/{reviewId}) — "수정됨" + 이력 보존
1~3. (Transactional → CSRF → 로그인)
4. gameReviewsMapper.getGameReview(reviewId) null → 404. gameId 불일치 → 404.
5. 권한: userId.equals(review.getUserId()) || isOperator(role) 거짓 → 403.
6. rating/body 검증(F5-5,6 동일)
7. 이력 보존(in-row 채택, 근거는 데이터 모델 절): 수정 전 updated_at = now() 갱신. created_at 불변. → edited 판별식 updated_at > created_at. (별도 history 테이블 미채택 — 노출은 "수정됨" 마커만, 이력 열람권한 이월.)
8. gameReviewsMapper.editGameReview (rating, body, updated_at=now() 갱신)
9. 200 JSON (edited:true)
F7. 리뷰 조회
- 목록:
GET /game/{id}/reviews→ JSON 배열 (또는 게임 상세 model 주입reviews). 댓글과 동일하게 초기 model 주입 + mutation 후 fetch 혼합. - 단건:
GET /game/{id}/reviews/{reviewId}→ JSON 단건.
F8. 리뷰 삭제 (DELETE /game/{id}/reviews/{reviewId})
1~5. (F6 권한 분기 동일)
6. gameReviewsMapper.softDeleteGameReview(reviewId) (is_delete=true, deleted_at=now())
7. 200 JSON
F9. 게임 삭제 cascade (기존 deleteGame 확장)
GameController.deleteGame(:243-245) 의 cascade 블록에 한 줄 추가:
gamesMapper.softDeleteGameComments(id); // 기존
gamesMapper.softDeleteGameReviews(id); // 신규 추가 (review = soft-delete 채택, comments 미러)
gamesMapper.deleteGameLikes(id); // 기존 (좋아요 미변경)
gamesMapper.softDeleteGame(id); // 기존
데이터 모델
D1. game_comments 변경 (비파괴, QG-2)
- 추가 컬럼:
user_id bigint NULL REFERENCES "users"("id")— 작성자 귀속. NULL 허용(기존 레코드/레거시 닉네임 댓글 보존, 파괴적 마이그레이션 없음). - 기존 컬럼(id/game_id/nickname/content/created_at/deleted_at/is_delete) 전부 유지.
- content 200자 제약 권위 = 앱레벨(컨트롤러 검증). DB CHECK 미적용. 근거: ① 기존 컬럼 타입이
text(길이 무제한)이고 운영 DB 실제 타입이 비권위(schema.sql:8-11)라 DB CHECK 추가가 기존 레코드와 충돌 위험, ② recruit_posts 도 길이는 앱레벨 검증(RecruitController.java:80-114)으로만 처리하고 DB CHECK 는 enum 값(role/participation_type)에만 사용하는 선례. → 200자는 앱레벨 권위, DB 는 무제한 text 유지. - FK 는
ON DELETE CASCADE미사용(앱레벨 cascade 규약). user soft-delete 시 댓글 user_id 는 dangling 가능하나 조회는 user JOIN 없이 nickname 스냅샷으로 표시(아래).
댓글 작성자 표시명 전략
- INSERT 시
nickname에 작성 시점 session displayName 스냅샷을 저장(기존 nickname 컬럼 재사용). 동시에user_id저장. - 목록 조회 SELECT 는 users JOIN 없이
nickname을 그대로 표시명으로 사용 → 레거시 NULL user_id 댓글(기존 '익명' 닉네임)도 동일 경로로 표시. JOIN 부재로 user soft-delete dangling 무영향.
D2. game_reviews 신규 (recruit_posts 권위 스타일)
| 컬럼 | 타입 | 제약 | 역할 |
|---|---|---|---|
| id | bigint | PK, DEFAULT nextval('game_reviews_id_seq') | 리뷰 고유 ID |
| game_id | bigint | NOT NULL, FK→games(id) | 대상 게임 |
| user_id | bigint | NOT NULL, FK→users(id) | 작성자 (리뷰는 댓글과 달리 로그인 필수 → NOT NULL) |
| rating | smallint | NOT NULL, CHECK(rating BETWEEN 1 AND 5) | 별점 5점 단일 (향후 다축은 별도 game_review_axes 테이블 분리 — 주석으로만) |
| body | text | NULL | 서술 평가 (앱레벨 1000자 제한, DB 무제한) |
| created_at | timestamptz | DEFAULT now() NOT NULL | 작성 시각 |
| updated_at | timestamptz | DEFAULT now() NOT NULL | 마지막 수정 시각 |
| deleted_at | timestamptz | NULL | soft-delete 시각 |
| is_delete | boolean | DEFAULT false NOT NULL | soft-delete 플래그 |
- 게임당 1회 제약: partial UNIQUE INDEX
CREATE UNIQUE INDEX ux_game_reviews_game_user_active ON game_reviews (game_id, user_id) WHERE is_delete IS NOT TRUE;(security-hardening 의 active-unique 선례ux_user_auth_identities_..._active와 동형. soft-delete 된 리뷰는 제약 제외 → 삭제 후 재작성 허용.) - "수정됨" 판별 = in-row
updated_at > created_at(별도 boolean/edit_count 컬럼 불요). 채택 근거: ① 기존 테이블 전부 created_at/updated_at 쌍 보유(games/recruit_posts), ② 추가 컬럼 0개로 inflate 회피, ③ 노출 요구가 "수정됨" 마커 단일이라 횟수 불필요. - 이력 보존 = in-row 채택(별도 history 테이블 미생성). 근거: 노출 요구는 "수정됨" 마커만이고 이력 열람 권한은 이월(범위 밖). history 테이블은 열람 UI·권한이 정해질 때(후속) 신설하는 편이 inflate 회피에 부합. 현재는
updated_at으로 "수정 발생 사실"만 보존. (history 가 필요해지면 game_review_history 를 후속 추가 — 본 설계의 in-row 결정과 충돌하지 않음.) - FK
ON DELETE CASCADE미사용(앱레벨 cascade). 게임 삭제 시softDeleteGameReviews(gameId)로 정리(F9). - 인덱스:
CREATE INDEX idx_game_reviews_game ON game_reviews (game_id) WHERE is_delete = false;(목록 조회 + 후속 집계 SELECT 의 game_id 필터용. 집계 컬럼/뷰는 만들지 않음 — W2-3 동결 보호.)
D3. POJO (data/GameReviewData.java 신규)
필드: Long id, Long gameId, Long userId, Integer rating, String body, OffsetDateTime createdAt, OffsetDateTime updatedAt, OffsetDateTime deletedAt, String authorName(목록 JOIN alias, 비영속), Boolean edited(updated_at > created_at SELECT 계산 alias, 비영속).
- GameCommentData 변경:
Long userId필드 추가(나머지 기존 유지).
외부 계약 (HTTP API)
응답은 전부 ResponseEntity<Map<String,Object>> JSON {status,message,...}. 조회 GET 은 게임 상세 진입 시 model 주입 + 별도 fetch GET 은 JSON 배열.
댓글 (5 엔드포인트)
| # | 메서드 | URL | 요청 | 성공 | 에러 |
|---|---|---|---|---|---|
| C1 | GET | /game/{id}/comments | — | 200 {status,comments:[{commentId,gameId,authorName,userId,content,createdAt}]} |
404(게임없음) |
| C2 | POST | /game/{id}/comments | body: content(form param) |
200 {status,commentId,gameId,authorName,userId,content,createdAt} |
403 CSRF / 401 / 404 / 400(공백·201자+) |
| C3 | PUT | /game/{id}/comments/{commentId} | content |
200 {status,commentId,content} |
403 CSRF / 401 / 404 / 403(권한) / 400 |
| C4 | DELETE | /game/{id}/comments/{commentId} | — | 200 {status,message} |
403 CSRF / 401 / 404 / 403(권한) |
(C2 의 200자 거부: content.length() > 200 → 400. 201자 입력은 거부.)
리뷰 (6 엔드포인트)
| # | 메서드 | URL | 요청 | 성공 | 에러 |
|---|---|---|---|---|---|
| R1 | GET | /game/{id}/reviews | — | 200 {status,reviews:[{reviewId,gameId,authorName,userId,rating,body,edited,createdAt,updatedAt}]} |
404 |
| R2 | GET | /game/{id}/reviews/{reviewId} | — | 200 {status,review:{...}} |
404 |
| R3 | POST | /game/{id}/reviews | rating(1~5), body |
200 {status,reviewId,...,edited:false} |
403 CSRF / 401 / 404 / 400(rating·body) / 409(중복) |
| R4 | PUT | /game/{id}/reviews/{reviewId} | rating, body |
200 {status,reviewId,...,edited:true} |
403 CSRF / 401 / 404 / 403(권한) / 400 |
| R5 | DELETE | /game/{id}/reviews/{reviewId} | — | 200 {status,message} |
403 CSRF / 401 / 404 / 403(권한) |
엔드포인트 총합 = 댓글 4 + 리뷰 5 = 9 mutation/fetch 엔드포인트 (C1/R1/R2 GET 3, C2~C4 댓글 mutation 3, R3~R5 리뷰 mutation 3). AC-AGG-1 에서 전수 검증.
컨트롤러 배치
- 댓글: 신규
controller/api/GameCommentController.java(game_comments 도메인 전담; GameController 비대화 회피). - 리뷰: 신규
controller/api/GameReviewController.java. - 두 컨트롤러 모두 RecruitController/GameController 와 동일한 private 헬퍼(
sessionUserId,trimToNull,trimToEmpty,response) 복붙 패턴 유지(프로젝트 기존 관행 — 3곳 복붙 선례). 새 추상화/서비스 계층 도입 금지(범위 밖, inflate 회피).
권한 모델
// 운영자 role 상수 (신규). UserController.ROLE_USER="USER"(:43) 와 동일 위치 관행.
public static final String ROLE_ADMIN = "ADMIN"; // 운영자 role 값. 실제 부여자 없음(W1 연결).
// isOperator: 인자는 role 문자열 1개만 — 최소 인자 원칙. (inflate 회피)
private boolean isOperator(String role) { // role: session attr "role" 스냅샷 (UserController.java:508)
return ROLE_ADMIN.equals(role);
}
// 권한 합성: 작성자 본인 OR 운영자
// authorUserId 는 댓글/리뷰의 user_id (댓글은 NULL 가능 → 레거시는 작성자 매칭 불가, 운영자만)
private boolean canModify(Long currentUserId, Long authorUserId, String role) {
// currentUserId: 현재 로그인 사용자 (sessionUserId)
// authorUserId: 대상 글 작성자 user_id
// role: 현재 사용자 role (운영자 분기용)
return (authorUserId != null && authorUserId.equals(currentUserId)) || isOperator(role);
}
- role 획득:
(String) session.getAttribute("role")(로그인 시UserController.java:508저장). 비로그인은 4번 단계(sessionUserId null)에서 이미 401 차단되므로 role 은 로그인 사용자 한정. - 비로그인 → 401. 권한없음 → 403
"작성자만 수정/삭제할 수 있습니다.". - GameController:183-185(수정)/239-241(삭제) 작성자 패턴에 isOperator OR 분기를 합성한 것이 canModify.
게임 삭제 cascade
- GamesMapper 신규 메서드 (comments soft-delete 미러):
@Update("""
UPDATE game_reviews
SET is_delete = true, deleted_at = COALESCE(deleted_at, now())
WHERE game_id = #{gameId} AND is_delete IS NOT TRUE
""")
int softDeleteGameReviews(@Param("gameId") long gameId);
- GameController.deleteGame cascade 블록(
:243-245)에gamesMapper.softDeleteGameReviews(id);한 줄 추가(F9 순서). 좋아요는 미변경.
파일 영향 맵
| 변경 유형 | 경로 | 역할 |
|---|---|---|
| 변경 | db/schema.sql |
dev 블록에 game_comments user_id 컬럼 추가 + game_reviews CREATE 블록 추가 |
| 신규 | docs/game-reviews-ddl.sql |
game_reviews 권위 DDL (recruit-posts-ddl.sql 선례) |
| 신규 | docs/game-reviews-ddl.sql 동봉 또는 별도 docs/game-comments-user-id-ddl.sql |
기존 DB 적용용 idempotent ALTER (game_comments user_id + game_reviews) — security-hardening-ddl.sql DO 패턴 |
| 변경 | src/main/java/.../data/GameCommentData.java |
Long userId 필드 + getter/setter 추가 |
| 신규 | src/main/java/.../data/GameReviewData.java |
리뷰 POJO |
| 변경 | src/main/java/.../mapper/GameCommentsMapper.java |
listGameComments / editGameComment / softDeleteGameComment 추가, getGameComment/addGameComment SELECT·INSERT 에 user_id 반영 |
| 신규 | src/main/java/.../mapper/GameReviewsMapper.java |
리뷰 CRUD 매퍼 |
| 변경 | src/main/java/.../mapper/GamesMapper.java |
softDeleteGameReviews 추가 |
| 신규 | src/main/java/.../controller/api/GameCommentController.java |
댓글 API (C1~C4) |
| 신규 | src/main/java/.../controller/api/GameReviewController.java |
리뷰 API (R1~R5), ROLE_ADMIN 상수·isOperator·canModify |
| 변경 | src/main/java/.../controller/api/GameController.java |
gameDetail(:107) model 에 comments/reviews 주입 + deleteGame(:243) cascade 한 줄 |
| 변경(위임) | src/main/webapp/WEB-INF/views/game-detail.jsp |
댓글 localStorage JS(:807-1009 중 댓글부) → fetch 교체, 리뷰 위젯 추가 — /frontend-design 위임 |
| 신규(test) | src/test/.../GameCommentControllerTest.java |
CSRF/200자/권한/운영자 분기 |
| 신규(test) | src/test/.../GameReviewControllerTest.java |
게임당1회/rating/수정됨/운영자 분기 |
DDL 3종 골격
① db/schema.sql (dev 블록 내 game_comments 직후 추가)
-- game_comments user_id 컬럼 (W3-2: 작성자 귀속, nullable 비파괴)
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint REFERENCES "users"("id");
-- ---------------------------------------------------------------------------
-- game_reviews (권위 DDL — docs/game-reviews-ddl.sql 와 동일. W3-2 신규)
-- ---------------------------------------------------------------------------
CREATE SEQUENCE IF NOT EXISTS "game_reviews_id_seq";
CREATE TABLE IF NOT EXISTS "game_reviews" (
"id" bigint DEFAULT nextval('game_reviews_id_seq'::regclass) NOT NULL,
"game_id" bigint NOT NULL REFERENCES "games"("id"),
"user_id" bigint NOT NULL REFERENCES "users"("id"),
"rating" smallint NOT NULL,
"body" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
"is_delete" boolean DEFAULT false NOT NULL,
PRIMARY KEY ("id"),
CONSTRAINT "game_reviews_rating_check" CHECK ("rating" BETWEEN 1 AND 5)
);
ALTER SEQUENCE "game_reviews_id_seq" OWNED BY "game_reviews"."id";
CREATE UNIQUE INDEX IF NOT EXISTS "ux_game_reviews_game_user_active"
ON "game_reviews" ("game_id", "user_id") WHERE "is_delete" IS NOT TRUE;
CREATE INDEX IF NOT EXISTS "idx_game_reviews_game"
ON "game_reviews" ("game_id") WHERE "is_delete" = false;
-- 향후 다축(육각형) 확장 시: rating 유지 + game_review_axes(review_id, axis, score) 별도 테이블 분리. 집계 컬럼/뷰는 W2-3 동결 — 신설 금지.
② docs/game-reviews-ddl.sql (권위 파일) — 위 game_reviews CREATE + 시퀀스 + UNIQUE/일반 인덱스 + FK/CHECK 를 DO idempotent 블록(recruit-posts-ddl.sql 형식) + COMMENT ON COLUMN 전 컬럼. rating/body/edited 의미 주석.
③ docs/game-reviews-ddl.sql 하단 또는 동봉 ALTER 섹션 (기존 DB 적용용 idempotent)
-- game_comments user_id (멱등)
ALTER TABLE "game_comments" ADD COLUMN IF NOT EXISTS "user_id" bigint;
DO $$ BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname='game_comments_user_id_fkey') THEN
ALTER TABLE "game_comments" ADD CONSTRAINT "game_comments_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id");
END IF;
END $$;
-- game_reviews: CREATE SEQUENCE/TABLE IF NOT EXISTS + DO $$ FK/CHECK + CREATE UNIQUE INDEX IF NOT EXISTS (위 ① 와 동일, 멱등 보장)
신규 시그니처 (inflate 방지 — 각 인자 사용 목적 인라인)
GameCommentsMapper (변경)
List<GameCommentData> listGameComments(long gameId); // gameId: 목록 필터 (is_delete IS NOT TRUE)
int editGameComment(GameCommentData c); // c: id+content (content 만 갱신)
int softDeleteGameComment(long id); // id: 대상 댓글 (is_delete=true, deleted_at=now())
// getGameComment: SELECT 에 user_id AS userId 추가. addGameComment: INSERT 에 user_id 추가.
- listGameComments SELECT:
id AS commentId? → id, game_id AS gameId, nickname AS authorName, user_id AS userId, content, created_at AS createdAt FROM game_comments WHERE game_id=#{gameId} AND is_delete IS NOT TRUE ORDER BY created_at ASC, id ASC. (users JOIN 없음 — nickname 스냅샷 표시.)
GameReviewsMapper (신규)
GameReviewData getGameReview(long id); // id: 단건/권한확인
List<GameReviewData> listGameReviews(long gameId); // gameId: 목록 필터
GameReviewData getActiveReviewByGameAndUser(@Param("gameId") long gameId, @Param("userId") long userId); // 게임당1회 선검사
int addGameReview(GameReviewData r); // r: gameId/userId/rating/body
int editGameReview(GameReviewData r); // r: id/rating/body (updated_at=now() SET 문 명시)
int softDeleteGameReview(long id); // id: is_delete=true
- list/get SELECT 의 edited alias:
(updated_at > created_at) AS edited. authorName: users JOINu.display_name AS authorName(리뷰는 user_id NOT NULL 이므로 JOIN 안전,u.is_delete IS NOT TRUE필터 — soft-delete 사용자 리뷰는 목록서 누락 가능, 허용).
isOperator / canModify
- 위 권한 모델 절 참조. inflate 재확인 대상(concerns 마킹): isOperator(role) 단일 인자, canModify(currentUserId, authorUserId, role) 3인자 — 전부 본문에서 사용. 구현 시 unused 발생하면 즉시 제거.
보안 (체크리스트 line 101~117 댓글/리뷰 해당분 충족 매핑)
| 체크리스트 line | 충족 방식 |
|---|---|
105 GET /game/{id}/comments 또는 모델 주입 결정 |
F2: 초기 model 주입 + mutation 후 fetch GET(C1) 혼합 — 확정 |
| 106 POST 댓글 CSRF/길이/작성자 | F1: CsrfTokens.isValid + 200자 앱레벨 + user_id 귀속 |
| 107 댓글 삭제 작성자/관리자 | F4 canModify(작성자 OR isOperator) |
| 108 서버 댓글도 escape/textContent | 서버 렌더 시 HtmlUtils.htmlEscape(JSP), 클라이언트 동적삽입 textContent (research 포인트9 규약 유지) |
| 110 localStorage UI 서버 응답 기준 교체 | F1~F4 fetch + 초기 model 주입으로 localStorage 댓글부 제거(위임: frontend-design) |
| 114 새로고침/브라우저변경 영속 | 서버 DB 영속(localStorage 비의존) |
| 115 토큰 없는 변경 실패 | 전 mutation 첫 게이트 CsrfTokens.isValid → 403 |
| 116 XSS payload 미실행 | escape/textContent (108) |
| 117 게임 삭제 시 정리 | F9 softDeleteGameComments(기존) + softDeleteGameReviews(신규) |
- 좋아요 관련 line(101~104)은 범위 밖 — 본 설계 미해결(좋아요 미변경 명시).
프론트엔드 경계 (/frontend-design 위임)
본 설계가 제공하는 위임 입력:
- 소비 API 계약: 위 댓글 C1~C4 / 리뷰 R1~R5 표(URL·메서드·요청 param·응답 JSON 형태).
- CSRF 규약: 모든 mutation 요청에
BibimbapCsrf.headers()(theme-init.jsp:19-31) 또는_csrf파라미터. 메타csrf-token전역 제공됨. - escape 규약: 서버 렌더(JSP scriptlet
<%= HtmlUtils.htmlEscape(...) %>); 클라이언트 동적 삽입은textContent(innerHTML 금지, listEl 초기화innerHTML=''만 허용). - 필요 data 형태: 댓글
{commentId,authorName,userId,content,createdAt}; 리뷰{reviewId,authorName,userId,rating,body,edited,createdAt,updatedAt}.edited:true일 때 "수정됨" 마커 표시. 별점 위젯은 rating 1~5 정수 송수신. - 소유자/권한 힌트: 응답의
userId와 로그인 사용자(owner모델 또는 별도 노출) 비교로 수정·삭제 버튼 노출 결정(서버가 최종 권한 재검증).
위임 경계(본 설계 비포함): 실제 JSP 마크업, CSS, 별점 위젯 비주얼/인터랙션, "수정됨" 마커 위치·스타일.
대안 비교
| 결정 | 채택 | 대안 | 채택 근거 |
|---|---|---|---|
| 댓글/리뷰 분리 | 2 테이블 (game_comments 변경 + game_reviews 신규) | 단일 테이블 type 컬럼 | rating/edited 등 리뷰 전용 컬럼이 댓글에 dead → 사용자 plan gate 확정 |
| content 200자 권위 | 앱레벨 검증 | DB CHECK | 기존 text 컬럼·비권위 운영타입·recruit 선례(길이는 앱레벨) |
| "수정됨" 판별 | in-row updated_at>created_at | boolean edited / edit_count | 추가 컬럼 0개, 기존 created/updated 쌍 활용, 노출요구 마커 단일 |
| 이력 보존 | in-row(updated_at) | 별도 game_review_history | 이력 열람 권한 이월(범위 밖) → 후속 신설이 inflate 회피 |
| 게임당 1회 | partial UNIQUE WHERE is_delete IS NOT TRUE | 전체 UNIQUE | soft-delete 후 재작성 허용 + security-hardening active-unique 선례 |
| 댓글 작성자 표시 | nickname 스냅샷(JOIN 없음) | users JOIN display_name | 레거시 NULL user_id·user soft-delete dangling 무영향 |
| 리뷰 cascade | soft-delete | hard-delete | comments 미러(soft) 일관성 |
롤아웃 / 마이그레이션
- DDL 선적용: 기존 DB → idempotent ALTER(③) 실행 (game_comments user_id 추가 + game_reviews 생성). 신규 환경은 schema.sql(①)로 자동. 둘 다
IF NOT EXISTS/DO $$멱등. - 역호환: game_comments user_id NULL 허용 → 기존 레코드 무손상. orphan 이던 GameCommentsMapper 가 컨트롤러 연결돼도 기존 SELECT(getGameComment) 컬럼 추가는 alias 만 늘어 호환.
- 배포 순서: DDL → 백엔드(매퍼/컨트롤러) → 프론트(/frontend-design). 프론트 미배포 상태에서도 백엔드 API 는 독립 동작(기존 localStorage UI 가 잠시 공존 가능, 비파괴).
- 롤백: 컨트롤러/매퍼 revert 시 game_reviews 테이블은 잔존(데이터 무손실). game_comments user_id 컬럼은 nullable 이라 revert 후에도 무해. 파괴적 DROP 불요.
- localStorage 댓글: 비마이그레이션(확정). 사용자 브라우저 로컬 데이터는 방치(자연 소멸).
검증 포인트 (AC)
§4.3 전수 체크 + §4.7 self-audit(시점 안정성·표현 견고성) 적용.
기능 AC (원 요청 매핑)
- AC-1 (댓글 CRUD): 로그인 사용자가 C2 작성 → C1 목록에 등장, C3 수정 반영, C4 삭제 후 C1 에서 누락. 단위/통합 테스트 4건. (시점: 테스트 내 자족 — 안정.)
- AC-2 (비작성자 불가 + 운영자 예외): 사용자 A 작성 댓글을 사용자 B(role=USER)가 C3/C4 → 403. 사용자 C(role=ADMIN)는 C3/C4 → 200. 단위테스트로 isOperator 분기 검증. (운영자 부여자 없음 → 테스트에서 session role="ADMIN" 주입.)
- AC-3 (리뷰 게임당 1회): 동일 user+game R3 두 번째 → 409. soft-delete(R5) 후 R3 재작성 → 200 (partial UNIQUE 검증).
- AC-4 (수정 시 "수정됨" + 이력 보존): R3 직후 응답
edited:false(updated_at==created_at). R4 후 R1/R2 응답edited:true(updated_at>created_at). created_at 불변 확인. - AC-5 (댓글 201자 거부): C2/C3 에 201자 content → 400. 200자 경계 → 200 (off-by-one 경계 테스트).
- AC-6 (rating 범위): R3/R4 rating 0 또는 6 → 400. 1·5 경계 → 200.
- AC-7 (CSRF 없는 상태변경 실패): C2/C3/C4/R3/R4/R5 를 X-CSRF-Token·_csrf 없이 호출 → 전부 403. (표현: 헤더/파라미터 둘 다 부재 케이스. UserControllerCsrfTest 선례 형식.)
- AC-8 (XSS payload 미실행): content/body 에
<script>payload 저장 → JSP 서버 렌더는<script>escape, 클라이언트 textContent 는 문자열 표시. 스크립트 미실행. (검증: 렌더 출력에 raw<script>부재 — escape 확인.) - AC-9 (영속성): C2/R3 후 새 세션/브라우저로 C1/R1 조회 → 데이터 잔존(DB 영속, localStorage 비의존).
- AC-10 (게임 삭제 cascade): deleteGame 후 해당 game_id 의 game_comments·game_reviews 전부 is_delete=true. softDeleteGameReviews 호출 검증.
집합 전수 AC (§4.3)
- AC-AGG-1 (엔드포인트 전수 9건): 댓글 컨트롤러 매핑 4건(@GetMapping C1 + @PostMapping C2 + @PutMapping C3 + @DeleteMapping C4) + 리뷰 컨트롤러 매핑 5건(R1 R2 GET + R3 POST + R4 PUT + R5 DELETE) == 9.
- 검증(불변식, 시점 안정):
grep -cE '@(Get|Post|Put|Delete)Mapping' src/main/java/.../GameCommentController.java== 4 AND... GameReviewController.java== 5. (두 파일 합 9. 컨트롤러 파일 한정 → 다른 매핑 오염 없음.) - §4.7 시점: 두 컨트롤러는 본 작업 산출물이며 구현 완료 시점에 고정 — verification 시점에도 동일. 안정.
- §4.7 표현: 매핑 애너테이션은 Spring 고정 토큰(동의 표현 없음) → 리터럴 grep 견고. 단 메서드 수가 아니라 매핑 애너테이션 수를 센다(헬퍼 메서드 오카운트 방지).
- 검증(불변식, 시점 안정):
- AC-AGG-2 (DDL 3종 산출 전수): ① schema.sql 에
game_reviewsCREATE TABLE 블록 존재(grep -c 'CREATE TABLE IF NOT EXISTS "game_reviews"' db/schema.sql>= 1) + game_comments user_id ADD COLUMN 존재. ②docs/game-reviews-ddl.sql파일 존재(ls). ③ idempotent ALTER 섹션(②파일 내ADD COLUMN IF NOT EXISTS "user_id"+DO $$블록 존재). 3종 모두 충족. - AC-AGG-3 (CSRF 게이트 전수): 댓글/리뷰 mutation 6개 핸들러(C2 C3 C4 R3 R4 R5) 전부 진입부
CsrfTokens.isValid(request)호출.grep -c 'CsrfTokens.isValid' (두 컨트롤러)== 6. (시점: 산출물 고정. 표현: 메서드명 고정 토큰.)
§4.7 self-audit 1패스 결과
- AC-AGG-1/AGG-3: 카운트 대상이 본 작업이 만드는 신규 파일이라 verification 시점까지 값 불변(자기 트리 증가 대상 아님 — 20260616 AC-3 류 함정 회피). 고정 스칼라 허용. 표현은 Spring/메서드 고정 토큰이라 동의표현 누락 위험 없음.
- AC-8(XSS): 단일 리터럴 grep 의존 회피 — "raw
<script>부재 AND escape 출력 존재"의 의미 불변식 + 수동 렌더 확인 병행(escape 함수가<로 바꾸는지). 표현 견고성 확보. - AC-3/AC-4: 테스트 내 자족 데이터로 시점 독립.
미해결 / 에스컬레이션
- 없음(전 결정 확정). 단 frontmatter
concerns4건은 동결영역 비침범 확인 기록 + inflate 재확인 마킹 + W1 의존으로, 차단 이슈가 아니라 구현/검증 시 점검 포인트. orchestrator 반환만 하고 요구사항 수정은 하지 않음.