22 KiB
22 KiB
| phase | agent | agent_version | generated_at | concerns | concerns_checked | source_confidence | workers_spawned | |||
|---|---|---|---|---|---|---|---|---|---|---|
| research | research-advisor | 2 | 2026-06-18T11:05:00+09:00 |
|
true | high | 0 |
조사 결과 — W3-2 댓글/리뷰 분리 코드 결합점
주제
W3-2 (댓글/리뷰 분리) 설계·구현을 위한 11개 결합점 정밀 조사. 모든 발견은 file:line 근거. 본 산출물은 전부 프로젝트 내부 코드 1차 확인(확인됨). 외부 자료 미사용 → source_confidence: high.
조사 방식: parallel-explorer worker 미사용. 11개 포인트가 동일 소규모 코드베이스(~25 Java + 17 JSP)에 밀결합되어 있어 advisor 가 직접 Read/Grep 으로 전수 확인. 모든 항목 1차 출처 직접 확인됨.
포인트별 발견
포인트 1: 댓글 도메인 CRUD 전체 [확인됨]
- Mapper:
mapper/GameCommentsMapper.javaGameCommentData getGameComment(long id)—:13-25(SELECT,WHERE id=#{id} AND is_delete IS NOT TRUE)int addGameComment(GameCommentData)—:27-39(INSERT game_id/nickname/content,@Options(useGeneratedKeys=true, keyProperty="id"))int updateGameComment(GameCommentData)—:41-51(nickname/content/deleted_at 갱신 +is_delete = CASE WHEN deletedAt IS NULL THEN false ELSE true END)- DELETE/list 메서드 없음. game_id 별 목록 조회 메서드도 없음(soft-delete 일괄은 GamesMapper 에 있음 — 포인트 8).
- POJO:
data/GameCommentData.java:5-61— 필드:Long id, Long gameId, String nickname, String content, OffsetDateTime createdAt, OffsetDateTime deletedAt. (userId 없음 — 작성자 식별 컬럼 부재, nickname 만 있음) - 컬럼(db/schema.sql:99-108, 비권위):
id bigint,game_id bigint NOT NULL REFERENCES games(id),nickname varchar(100),content text,created_at timestamptz DEFAULT now(),deleted_at timestamptz,is_delete boolean DEFAULT false. (요청서가 언급한 6컬럼 + is_delete 7개 전부 일치) - 컨트롤러 엔드포인트: 존재하지 않음.
GameCommentsMapper를 주입/호출하는 컨트롤러 없음(rg 확인). 즉 서버측 댓글 작성/조회/삭제 HTTP API 가 전무. - 서비스 계층: 없음(프로젝트 전체가 controller→mapper 직결 구조).
- 작성/조회/삭제 흐름: 현재 댓글은 서버 미연동. game-detail.jsp 가 localStorage 로만 처리(포인트 2). GameCommentsMapper 는 사실상 orphan(유일 사용처: 게임 삭제 cascade 의
GamesMapper.softDeleteGameComments, 포인트 8).
포인트 2: 게임 상세 페이지 [확인됨]
- 핸들러:
controller/api/GameController.java:103-129@GetMapping("/game/{id}") String gameDetail(long id, Model, HttpSession).- DB 게임 존재 시
addGameModel(model, game, sessionUserId(session))→ view"game-detail"(:104-109). - DB miss 시
GameCatalog정적 폴백(:111-128).
- DB 게임 존재 시
- model attribute(
addGameModel:254-268):gameId, gameName, creator, likeCount, likeCountFormatted, creatorNote, gitUrl, webglUrl, webglFrameSrc, webglDeployPath, owner.owner = currentUserId != null && currentUserId.equals(game.getUserId())(:267). - game-detail.jsp 댓글/좋아요 실제 코드(localStorage):
- 좋아요 버튼
#game-like-btnHTML:730-735; 좋아요 JS 전부 localStorage(LIKE_KEY='bibimbap-game-liked') —getLikedMap :815-823,setLiked :825-832,localStorage.setItem :830, 클릭 핸들러:904-908(서버 POST/DELETE 없음). - 댓글 폼
#game-comment-formHTML:790-801(textarea name="comment" maxlength=1000, 작성자 입력란 없음). - 댓글 JS 전부 localStorage(
COMMENT_KEY='bibimbap-game-comments') —getComments :911-920,saveComments :922-930(localStorage.setItem :928), 렌더:938-992, submit 핸들러:994-1005(닉네임 하드코딩DEFAULT_NICK='익명' :936, id=crypto.randomUUID). - 라인 근거: 요청서가 지목한 812(LIKE_KEY)/830(setItem)/913(getComments raw)/928(saveComments setItem) 전부 위치 확정.
- 좋아요 버튼
포인트 3: 로그인/세션 인증 패턴 [확인됨]
- 인증 방식: Spring Security 아님. 커스텀 HttpSession attribute 기반.
- 로그인 컨트롤러:
controller/api/UserController.java—@PostMapping("/login") :122-168,/signup :65-120,/logout :170-179. 로그인 페이지(GET)는WebMvcController.mainViewswitchcase "login" :75-80. - 세션 저장(
UserController.saveLoginSession :502-525):session.setAttribute("userId", user.getId())(Long), 그 외id, displayName, email, avatarUrl, role, status, authProvider, authIdentityId, lastLoginAt, 그리고account(LinkedHashMap 복제본). 로그인 시request.changeSessionId() :160(세션 고정 방어). - 현재 로그인 사용자 얻는 코드(컨트롤러 공통 헬퍼, 3곳에 동일 복붙):
sessionUserId(HttpSession)—GameController:291-307,RecruitController:155-171,UserController:333-349.session.getAttribute("userId")를 Number/String→Long 변환, 없으면 null. (WebMvcController 는:118-127변형 — null 대신IllegalStateExceptionthrow.) - UserData 필드 전체(
data/UserData.java:5-15):Long id, String displayName, String canonicalEmail, String avatarUrl, String role, String status, OffsetDateTime lastLoginAt, createdAt, updatedAt. id 타입 Long, role 타입 String(기본값 "USER", UserController:43ROLE_USER="USER"). 운영자 role 명칭은 코드상 미정의(USER 만 발급됨) — 포인트 6 참조. - 비로그인 처리: 페이지는
redirect:/login(RecruitController:43-46, GameController:133-135, WebMvcController:82-83). AJAX/상태변경 API 는401 UNAUTHORIZED + {status,message}JSON(GameController:57-59 등).
포인트 4: CSRF 현황 (설계 핵심 제약) [확인됨]
- spring-security 의존: 없음. pom.xml 의존성 = web, mybatis-spring-boot-starter, postgresql, tomcat-embed-jasper, lombok, starter-tomcat(provided), starter-test(test) (
pom.xml:53-87). starter-security 부재. - SecurityConfig / SecurityFilterChain / @EnableWebSecurity: 클래스 없음(rg 확인 0건).
- CSRF 인프라: 커스텀 자체 구현 존재 —
security/CsrfTokens.java:SESSION_ATTRIBUTE="csrfToken" :12,HEADER_NAME="X-CSRF-Token" :13.getOrCreate(HttpSession) :20-33(세션에 토큰 발급/재사용, Base64 32바이트).isValid(HttpServletRequest) :35-49— 헤더X-CSRF-Token우선, 없으면 파라미터_csrf(:46). 세션 토큰과.equals비교.errorBody() :51-56→{status:403, message:"요청 보안 토큰이 유효하지 않습니다."}.
- 기존 POST/AJAX 의 토큰 전달 방식:
- 뷰 노출:
theme-init.jsp:5-7가<meta name="csrf-token" content="<%= HtmlUtils.htmlEscape(CsrfTokens.getOrCreate(session)) %>">출력. 모든 페이지가 theme-init.jsp 를 include 하므로 메타 토큰이 전역 제공됨. - JS 헬퍼: theme-init.jsp:19-31
window.BibimbapCsrf={token():meta 읽기, headers(extra):extra+{'X-CSRF-Token':token}}. - form hidden: login.jsp:221
<input type="hidden" name="_csrf" value="<%= csrfToken %>">(signup.jsp 동일). - AJAX 사용례: login.jsp:283
BibimbapCsrf.headers(...), game-detail.jsp:850(삭제) 동일 패턴. - 서버 검증례: 모든 상태변경 핸들러 진입부
if(!CsrfTokens.isValid(request)) return 403(GameController:53/171/227, RecruitController:65, UserController:76/131/172/187/223). 테스트:test/.../UserControllerCsrfTest.java.
- 뷰 노출:
- 설계 함의: 신규 댓글/리뷰 POST/DELETE 는 반드시
CsrfTokens.isValid(request)게이트 + 클라이언트BibimbapCsrf.headers()사용. 새 CSRF 인프라 신설 불필요(재사용).
포인트 5: DDL/스키마 적용 방식 [확인됨]
- 부트스트랩:
db/schema.sql가 전체 스키마. flyway/liquibase 없음(docs/usage/local-setup.md:139 "flyway/liquibase 가 없다"). - 적용법:
- Docker: db 컨테이너 최초 기동 시
db/schema.sql이docker-entrypoint-initdb.d로 자동 1회 실행(dev 스키마 채움, live 는 빈 스키마). 재적용은docker compose down -v후 재기동(local-setup.md:149). - 호스트 로컬 PG:
psql -f db/schema.sql수동(local-setup.md:150).
- Docker: db 컨테이너 최초 기동 시
- 권위 패턴:
docs/recruit-posts-ddl.sql(권위) 가 신규 테이블 표준 스타일.docs/security-hardening-ddl.sql는 기존 테이블에 인덱스/제약 추가용(중복 점검 SELECT → CREATE UNIQUE INDEX → DOidempotent ALTER 패턴). - DbUpdateQueryGenerator: 테스트
test/.../DbUpdateQueryGeneratorTest.java+test/db/dev-to-live-update.sql존재(surefire 에서 제외됨, pom.xml:130-133). dev→live 스키마 동기화 SQL 생성 용도로 보임 — 마이그레이션 자동화 도구 아님(테스트성). - 신규 테이블(game_reviews) 추가 절차: ①
db/schema.sql에SET search_path TO dev블록 내 CREATE TABLE 추가, ② 권위 DDL 파일을 docs/ 에 별도 작성(recruit-posts-ddl.sql 선례), ③ 기존 DB 적용용 idempotent ALTER 스크립트(security-hardening-ddl.sql 선례). 파일 위치:db/schema.sql+docs/*-ddl.sql.
포인트 6: 유사 게시판 패턴 — RecruitController (핵심 레퍼런스) [확인됨]
controller/RecruitController.java+mapper/RecruitPostsMapper.java.- 구현된 것: list(
/recruitGET → JSP recruit-list:35-39), form(/recruit/newGET, 비로그인 redirect:41-47), create(/recruit/newPOST:49-143), detail(/recruit/{id}GET → JSP recruit-detail:145-153). - create 패턴(댓글/리뷰가 그대로 따를 표준):
@Transactional(:50)- CSRF 우선 검증
if(!CsrfTokens.isValid(request)) 403(:65-67) - 로그인 검증
userId = sessionUserId(session); if null → 401(:68-71) - trimToNull/trimToEmpty 정규화 + 길이/허용값(Set.contains) 검증, 위반 시
400 BAD_REQUEST(:73-114) - data POJO 세팅 후 mapper.add, 생성 id null 체크 →
500(:132-135) - 성공 응답 = JSON
{status:200, message, recruitPostId, location:"/recruit/{id}"}(:137-142)
- 응답 형식: 상태변경(POST/DELETE)은
ResponseEntity<Map<String,Object>>JSON. 조회(GET)는 view name String(JSP). redirect 는 비로그인 폼 접근시만. - 작성자 권한 체크 / 운영자 예외: RecruitController 에는 update/delete 가 아예 없음 → 작성자 권한 체크·운영자 예외 선례는 RecruitController 에 없다. 권한 체크 표준 선례는 GameController 다:
if(!userId.equals(existing.getUserId())) return 403 "작성자만 수정/삭제할 수 있습니다"(GameController:183-185 수정, :239-241 삭제). 운영자(admin) 예외 분기는 코드 전체에 없음(role 비교 분기 부재). → W3-2 가 운영자 삭제를 요구하면 신설 영역(role="ADMIN" 등 명칭도 미정의, 포인트 3). - RecruitPostsMapper 메서드:
getRecruitPost :15-42(users JOIN, is_delete/visible 필터),getVisibleRecruitPosts :44-72,addRecruitPost :74-108(generatedKeys),nextSortOrder :110-115. update/delete 매퍼 없음.
포인트 7: 좋아요 패턴 (참고) [확인됨]
mapper/GameLikesMapper.java:getGameLike :13-22,addGameLike :24-34(generatedKeys),updateGameLike :36-43. 삭제/중복방지/카운트 매퍼 없음.data/GameLikeData.java:5-43:Long id, Long gameId, String userKey, OffsetDateTime createdAt. (좋아요 주체 식별이userKeyString — userId FK 아님)- 컬럼(db/schema.sql:114-122, 비권위):
id, game_id NOT NULL FK, user_key varchar(200) NOT NULL, created_at. is_delete 없음(hard delete 설계, schema.sql:112 주석 명시). - 영속화/중복방지 현황: GameLikesMapper 를 호출하는 컨트롤러 없음(rg 0건). 좋아요도 댓글과 동일하게 서버 미연동 → game-detail.jsp localStorage(
LIKE_KEY)만. 중복방지는 DB UNIQUE 제약 없음(schema 상). 즉 likes 도 orphan mapper. - 카운트 출처:
games.like_count(GamesMapper.getGame:27like_count AS likeCount) 정적 컬럼 — 실시간 game_likes COUNT 아님.
포인트 8: 게임 삭제 cascade [확인됨]
GameController.deleteGame :222-252(@DeleteMapping("/game/{id}"),@Transactional).- 순서(
:243-245):gamesMapper.softDeleteGameComments(id)→gamesMapper.deleteGameLikes(id)→gamesMapper.softDeleteGame(id).
- 순서(
- cascade 매퍼(GamesMapper):
softDeleteGameComments(long gameId) :165-173—UPDATE game_comments SET is_delete=true, deleted_at=COALESCE(deleted_at,now()) WHERE game_id=#{gameId} AND is_delete IS NOT TRUE(soft delete)deleteGameLikes(long gameId) :175-179—DELETE FROM game_likes WHERE game_id=#{gameId}(hard delete)softDeleteGame(long id) :181-189—UPDATE games SET is_delete=true, updated_at=now()
- game_reviews 동일 정리 패턴: review 가 soft-delete(is_delete 컬럼) 채택 시 →
softDeleteGameReviews(gameId)(comments 미러). hard-delete 채택 시 →deleteGameReviews(gameId)(likes 미러). 그리고deleteGame :243-245에 호출 한 줄 추가가 정확한 결합점.
포인트 9: JSP escape 패턴 [확인됨]
- 서버 렌더 출력:
org.springframework.web.util.HtmlUtils.htmlEscape(...)를 scriptlet<%= %>안에서 사용이 지배적 패턴.- recruit-detail.jsp: 전 사용자 데이터
<%= HtmlUtils.htmlEscape(role/projectName/summary/contact/description...) %>(:219-288). - game-detail.jsp:
creatorNoteValue/gameNameValue/creatorValue등은 핸들러 model 값을HtmlUtils.htmlEscape로 미리 감싼 변수로 출력(:9likeCountFormattedValue 등, 본문:703/721/771). - header.jsp:11 avatarUrl
HtmlUtils.htmlEscape.
- recruit-detail.jsp: 전 사용자 데이터
- JSTL escape:
<c:out value='${q}'/>(header.jspf:7 — 단 header.jspf 는 미사용 잔재). 활성 뷰는 scriptlet 방식. - 클라이언트 렌더: game-detail.jsp 댓글 렌더는
textContent사용(:951 av.textContent,:958 nickEl.textContent,:969 p.textContent) — innerHTML 은 초기화용listEl.innerHTML=''(:942)만. → 신규 서버연동 댓글/리뷰 클라이언트 렌더도 textContent 규약 유지 필수(CLAUDE.md 보안원칙 일치). - 신규 review JSP 출력 규약: 사용자 입력(평점 코멘트 등) 서버 렌더 시
HtmlUtils.htmlEscape, 클라이언트 동적 삽입 시textContent.
포인트 10: MyBatis annotation 규약 [확인됨]
- annotation-only(XML 매퍼 없음).
@Mapper인터페이스 +@Select/@Insert/@Update/@Delete인라인 SQL(Java text block"""). - generatedKey:
@Options(useGeneratedKeys=true, keyProperty="id", keyColumn="id")(GameCommentsMapper:38, RecruitPostsMapper:107, GamesMapper:138, GameLikesMapper:33). - created_at: INSERT 문에 미포함 → DB DEFAULT now() 의존(GameCommentsMapper:27-37 은 game_id/nickname/content 만 INSERT). updated_at 갱신은 UPDATE 문에서
updated_at=now()명시(GamesMapper:159). - 바인딩: 전부
#{}(예:#{id},#{gameId}).${}동적 치환 사용처 없음(검색 0건). 다중 인자는@Param("name")(GamesMapper:91/115, RecruitPostsMapper:42). 컬럼 alias 는snake_case AS camelCase로 POJO 매핑. - soft-delete 조회 규약: 모든 SELECT 가
is_delete IS NOT TRUE필터 + users JOIN 시u.is_delete IS NOT TRUE.
포인트 11: DB 종류·문법 [확인됨]
- DB: PostgreSQL. driver
org.postgresql.Driver, urljdbc:postgresql://localhost:5433/bibimbap?currentSchema=dev(src/main/resources/dev/db.properties). Docker:jdbc:postgresql://db:5432/...?currentSchema=${APP_SCHEMA:-dev}(docker-compose.yml:40). pom.xml:64-67 postgresql runtime. - 스키마 분리: dev / live 두 PostgreSQL schema(search_path). schema.sql:21-24
CREATE SCHEMA dev/live; SET search_path TO dev. - DDL 문법 관례(db/schema.sql, recruit-posts-ddl.sql): SERIAL/BIGSERIAL 미사용 → 명시적
CREATE SEQUENCE+bigint DEFAULT nextval('..._id_seq'::regclass)+ALTER SEQUENCE ... OWNED BY. 타임스탬프timestamp with time zone(=timestamptz, OffsetDateTime 매핑). booleanis_delete DEFAULT false NOT NULL. FKbigint REFERENCES "table"("id"). ON DELETE CASCADE 미사용(앱레벨 cascade, 포인트 8). CHECK 제약 사용례 recruit_posts(schema.sql:156-161). 인덱스CREATE INDEX IF NOT EXISTS ... WHERE(partial index). - id 타입: 전 테이블
bigint(POJO Long). like_count/sort_order 만integer.
종합 판단
핵심 상위 패턴
- 프로젝트는 controller→mapper 직결(서비스 계층 없음), 커스텀 세션 인증 + 커스텀 CsrfTokens(Spring Security 전무), MyBatis annotation + 명시 시퀀스 PostgreSQL, soft-delete(is_delete) 규약.
- 상태변경 API 표준 시퀀스:
@Transactional→ CSRF 검증 → 로그인 검증 → 정규화/검증 → mapper → JSON 응답.
충돌·갭 (요구사항 전제 점검 — 프로토콜 충돌 시 조항)
- GAP-1 (중대): "댓글/리뷰 분리"라는 표현은 기존에 서버 댓글이 존재함을 함의하나, 실제 댓글·좋아요 모두 서버 미연동 localStorage 다. GameCommentsMapper/GameLikesMapper 는 orphan(삭제 cascade 외 미사용). 따라서 W3-2 는 분리 이전에 댓글 서버 영속화 + 리뷰 신설 두 가지를 포함. 요구사항 advisor 가 '댓글은 이미 서버에 있고 리뷰만 떼낸다'를 전제했다면 깨짐.
- GAP-2: game_comments 에 작성자 식별 컬럼 없음(nickname 만, userId FK 부재). 로그인 사용자 귀속·작성자 권한 삭제를 원하면 user_id 컬럼 신설 필요.
- GAP-3: 운영자(admin) 예외 로직이 코드 전체에 부재. role 은 "USER"만 발급되며 ADMIN 명칭·분기 미정의. 운영자 댓글/리뷰 삭제 요구 시 전부 신설.
- GAP-4: game_reviews 테이블·POJO·매퍼·DDL 전무(docs work-log 도 "review 테이블 없음" 명시). 평점 축(단일/다축) 미정 — work-log:C6 에서 W3-2 설계 종속으로 남겨둠.
권위 격상 전 검증 필요 항목
- 없음(전 항목 1차 코드 확인). 단, db/schema.sql 의 game_comments/game_likes/games/users 타입·길이·기본값은 schema.sql 자체가 '비권위 추론값'으로 선언(주석 :4-11). 신규 game_reviews DDL 의 인접 타입을 이들에서 복사할 때는 recruit_posts(권위) 스타일을 기준으로 삼을 것. (source_confidence 는 '코드가 이렇게 되어있다'는 사실에 대해 high. '운영 DB 실제 타입'은 schema.sql 스스로 미확인 선언.)
설계 입력 요약 (a 재사용 / b 신설)
(a) 재사용할 현존 패턴
- CSRF:
CsrfTokens.isValid(request)게이트 + 뷰BibimbapCsrf.headers()/메타csrf-token+ hidden_csrf. (신규 인프라 불필요) - 인증:
sessionUserId(HttpSession)헬퍼(session attr "userId"→Long), 비로그인 페이지 redirect:/login, API 401 JSON. - 컨트롤러 골격: RecruitController.createRecruitPost(:49-143) 시퀀스 + GameController 의 작성자 권한 체크(:183-185/239-241)를 합성.
- 응답 형식:
ResponseEntity<Map<String,Object>>{status,message,...,location}(조회는 JSP view name). - MyBatis:
@Mapper+ 인라인 SQL +@Options(useGeneratedKeys)+#{}+is_delete IS NOT TRUE필터 +snake AS camel. - 삭제 cascade: GameController.deleteGame(:243-245) 에 review 정리 한 줄 추가.
- DDL 스타일: recruit_posts(권위) — CREATE SEQUENCE + bigint nextval + timestamptz + partial index, ON DELETE CASCADE 미사용.
- JSP escape: 서버
HtmlUtils.htmlEscape, 클라이언트textContent. - 스키마 적용: db/schema.sql + docs/*-ddl.sql(권위) + idempotent ALTER(security-hardening 선례).
(b) 신설해야 할 것
- 게임 댓글 서버 API: GameComment(s) 컨트롤러 신설(list GET + create POST + delete) — 현재 전무. localStorage JS(game-detail.jsp:807-1009) → fetch 기반으로 교체.
- GameCommentsMapper 확장: game_id 별 목록 SELECT, (soft)delete 메서드 — 현재 없음.
- game_reviews 도메인 전체: 테이블 DDL(db/schema.sql + docs/game-reviews-ddl.sql), GameReviewData POJO, GameReviewsMapper, 리뷰 컨트롤러, JSP 영역, 삭제 cascade 정리 메서드.
- 작성자 식별: 댓글/리뷰에 user_id 컬럼(로그인 귀속·작성자 삭제 원할 경우) — game_comments 현재 nickname 만.
- (요구 시) 운영자 예외: role 명칭 정의 + 권한 분기 — 현재 전무.
- (설계 종속) 평점 모델: 단일 평점 vs 다축 — W3-2 설계에서 확정 필요(work-log C6).
미해결
- 운영 DB 실제 스키마: game_comments/game_likes/games 타입·제약은 schema.sql 가 비권위 선언. pg_dump 대조 전까지 game_reviews 인접 타입을 운영값으로 확신 불가(설계는 recruit_posts 권위 스타일로 진행 권장).
- 평점 데이터 모델: 단일 점수/다축/리뷰수 정렬 의미 — W3-2 요구·설계 결정 사항(조사 범위 밖).
- 운영자 role 명칭: ADMIN 등 명칭과 부여 경로 미정(코드상 USER 만 존재).
- 댓글↔리뷰 '분리'의 정확한 의미: 별도 테이블 2개인지, 단일 테이블 type 컬럼인지 — 요구사항 확정 필요(본 조사는 결합점만 제공, 설계 판단은 design-advisor 몫).