Q&A 답변 댓글 시스템: 새 테이블 없이 depth 하나로 해결한 이야기
문제: 답변에 말을 걸 수가 없다
FullStackFamily의 Q&A 게시판은 Stack Overflow를 참고해서 만들었습니다. 질문 올리고, 답변 달고, 채택하고. 기본 흐름은 잘 돌아갑니다.
그런데 한 가지가 빠져 있었습니다. 답변에 댓글을 달 수 없습니다.
"좋은 답변인데 한 가지만 더 여쭤볼게요"라고 하고 싶은데, 방법이 없습니다. 새 답변을 또 쓰자니 맥락이 끊기고, 질문 본문을 수정하자니 이상하고.
Stack Overflow에서는 답변 아래에 짧은 댓글을 달 수 있습니다. "이 부분 좀 더 설명해주세요", "코드에 오타가 있네요", "이렇게 바꾸니까 됩니다 감사합니다" 같은 가벼운 대화. 그게 있어야 Q&A가 Q&A답게 돌아갑니다.
설계: 새 테이블을 만들까, 기존 구조를 확장할까
처음 떠오른 선택지가 두 가지였습니다.
방법 A: 새 테이블 (answer_comment) → 깔끔한 분리 → 새 엔티티, 새 리포지토리, 새 서비스, 새 컨트롤러, 새 API... → 기존 댓글과 거의 동일한 CRUD를 또 만들어야 함 방법 B: 기존 unified_comment 테이블 활용 → 이미 parentId/depth 계층 구조가 있음 → depth=0 = 답변, depth=1 = 답변 댓글 → 새 테이블 0개, 새 API 0개 → 설정 변경 + 프론트엔드 UI 추가만으로 가능
방법 B를 골랐습니다.
기존 unified_comment 테이블에는 이미 parent_id와 depth 컬럼이 있습니다. 일반 게시판에서 댓글-대댓글 구조에 쓰이는 필드인데, Q&A 게시판에서는 enableReplies: false로 설정해서 막아둔 상태였습니다. 이걸 풀기만 하면 됩니다.
다만 한 가지 제약을 걸어야 합니다. 일반 게시판에서는 댓글의 댓글의 댓글도 허용하지만, Q&A에서는 답변(depth=0) 아래 댓글(depth=1)까지만 허용해야 합니다. 댓글의 댓글이 달리기 시작하면 Stack Overflow가 아니라 Reddit이 되어버리니까요.
Q&A 게시판 구조: 질문 (UnifiedPost) ├── 답변 A (depth=0, parent=null) │ ├── 댓글 1 (depth=1, parent=A) ← 허용 │ ├── 댓글 2 (depth=1, parent=A) ← 허용 │ └── 댓글 2의 대댓글 (depth=2) ← 차단! ├── 답변 B (depth=0, parent=null) │ └── 댓글 3 (depth=1, parent=B) ← 허용 └── 답변 C (depth=0, parent=null) └── (댓글 없음)
백엔드: 코드 5줄로 끝난 변경
백엔드에서 바꾼 건 딱 두 곳입니다.
1. Q&A 보드 설정 열기
// BoardFeatures.forQnA() .enableReplies(true) // false → true
2. depth 제한 추가
기존 createComment() 메서드에 3줄 추가:
if (board.getFeatures().isQnAMode() && parent.getDepth() >= 1) { throw new BusinessException( "Q&A 답변의 댓글에는 대댓글을 작성할 수 없습니다."); }
끝입니다. 새 API 엔드포인트 없고, 새 DTO 없고, 새 서비스 메서드 없습니다.
댓글 생성 API(POST /api/comments)에 parentId로 답변의 ID를 넘기면 depth=1 댓글이 만들어집니다. 수정, 삭제, 이미지 업로드도 기존 API 그대로. 알림도 기존 ReplyCreatedEvent가 자동으로 답변 작성자에게 알림을 보내고, 경험치도 COMMENT_CREATE로 10 EXP가 지급됩니다.
변경 전후 비교: 새 테이블 새 API 새 서비스 코드 변경 방법 A (새 테이블) 1개 6개 1개 ~500줄 방법 B (depth 활용) 0개 0개 0개 5줄 + UI
물론 이건 기존 아키텍처가 계층 구조를 이미 지원하고 있었기 때문에 가능한 일입니다. UnifiedComment의 parentId/depth/path 시스템이 없었다면 이야기가 달라졌겠죠. 처음에 댓글 구조를 잡을 때 계층을 넣어둔 게 여기서 빛을 봤습니다.
프론트엔드: 컴팩트한 댓글 UI
Q&A 페이지에서 답변이 5개 있고 각 답변에 댓글이 없는 상태를 상상해보세요. 답변마다 아래에 빈 댓글 영역이 크게 차지하면 답변 간 거리가 멀어져서 스크롤이 끝없이 늘어납니다.
Stack Overflow는 이걸 잘 해결했는데, 핵심은 "댓글이 없을 때 공간을 거의 차지하지 않는 것"입니다.
댓글이 없을 때: ┌──────────────────────────────────┐ │ 답변 본문... │ │ 수정 삭제 │ ├──────────────────────────────────┤ │ 댓글 추가 │ ← 한 줄짜리 링크 └──────────────────────────────────┘ 댓글이 있을 때: ┌──────────────────────────────────┐ │ 답변 본문... │ │ 수정 삭제 │ ├──────────────────────────────────┤ │ Lv.3 홍길동 · 감사합니다! 3분 전 │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ Lv.1 김영희 · 추가 질문... 1h │ │ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│ │ ┌────────────────────────────┐ │ │ │ 댓글 추가... 등록 │ │ ← 클릭하면 펼쳐짐 │ └────────────────────────────┘ │ └──────────────────────────────────┘
댓글이 0개면 "댓글 추가" 텍스트 한 줄만 보이고, 클릭하면 입력 폼이 펼쳐집니다. 답변 5개가 쭉 나열되어도 댓글 없는 답변들 사이에 불필요한 여백이 생기지 않습니다.
댓글이 5개 이상이면 처음 3개만 보여주고 "2개 더 보기" 버튼을 넣었습니다. 15개씩 달린 댓글이 답변을 가리는 것보다는 나은 선택입니다.
스크롤 점프 버그와 로컬 상태 관리
구현 후 실제로 댓글을 달아보니 문제가 생겼습니다.
댓글을 등록하면 화면이 맨 위로 올라갑니다.
원인은 이렇습니다.
기존 흐름: 1. 댓글 등록 API 호출 2. onSuccess → onRefresh() 호출 3. onRefresh = fetchComments → GET /api/comments/post/{id} 4. setComments(서버 응답) → 전체 댓글 트리 교체 5. React가 댓글 영역 전체를 다시 렌더링 6. 스크롤 위치 초기화 → 화면이 위로 점프
처음에는 window.scrollY를 저장했다가 복원하는 방법을 시도했는데, 동작하지 않았습니다. 렌더링 타이밍이 예측 불가능하기도 하고, 근본적으로 "전체를 다시 그리는" 구조 자체가 문제였습니다.
답은 간단합니다. 댓글 하나 추가했을 뿐인데 전체를 다시 가져올 필요가 없습니다.
변경 후 흐름: 1. 댓글 등록 API 호출 2. API 응답에서 생성된 댓글 객체를 받음 3. 로컬 state에 해당 댓글만 추가 4. React가 추가된 댓글 1개만 렌더링 5. 나머지 DOM은 그대로 → 스크롤 유지
AnswerCommentList가 localComments라는 내부 상태를 관리합니다. props로 받은 초기 댓글 목록에서 시작하되, 생성/수정/삭제는 로컬에서 처리합니다.
// 댓글 생성 → 로컬 상태에 추가 const handleCommentCreated = (comment: Comment) => { setLocalComments((prev) => [...prev, comment]) } // 댓글 수정 → 해당 항목만 갱신 const handleCommentUpdated = (id, content, images) => { setLocalComments((prev) => prev.map((c) => c.id === id ? { ...c, content, images } : c) ) } // 댓글 삭제 → 삭제 표시만 const handleCommentDeleted = (id) => { setLocalComments((prev) => prev.map((c) => c.id === id ? { ...c, isDeleted: true } : c) ) }
AnswerCommentForm도 onSuccess 대신 onCommentCreated(comment)를 받도록 바꿨습니다. API 응답에서 생성된 댓글 객체를 꺼내서 부모에게 그대로 넘기는 방식입니다.
| 동작 | 변경 전 | 변경 후 |
|---|---|---|
| 댓글 생성 | API 호출 → 전체 재조회 | API 호출 → 로컬 추가 |
| 댓글 수정 | API 호출 → 전체 재조회 | API 호출 → 해당 항목 갱신 |
| 댓글 삭제 | API 호출 → 전체 재조회 | API 호출 → 삭제 표시 |
| 스크롤 | 맨 위로 점프 | 그대로 유지 |
| API 호출 수 | 2회 (CUD + 목록 재조회) | 1회 (CUD만) |
덤으로 API 호출 횟수도 절반으로 줄었습니다. 댓글 하나 달 때마다 목록 재조회가 사라졌으니까요.
전체 구조 요약
Q&A 답변 댓글의 데이터 흐름을 정리하면 이렇습니다.
CommentSectionWrapper (API 호출, 전체 상태) └── CommentSection (답변/댓글 그룹핑) ├── CommentItem (답변 A, depth=0) │ └── AnswerCommentList (로컬 상태 관리) │ ├── AnswerCommentItem (댓글 1) │ ├── AnswerCommentItem (댓글 2) │ └── AnswerCommentForm → onCommentCreated │ ├── CommentItem (답변 B, depth=0) │ └── AnswerCommentList │ └── AnswerCommentForm │ └── AnswerForm (새 답변 작성)
CommentSection에서 서버에서 받은 댓글 배열을 두 그룹으로 나눕니다:
// depth=0만 추출 → 답변 목록 const answers = comments.filter((c) => c.depth === 0) // depth=1을 parentId 기준으로 그룹핑 → 답변별 댓글 Map const answerCommentsMap = groupCommentsByAnswer(comments) // Map<answerId, Comment[]>
각 답변 아래에 해당 답변의 댓글만 AnswerCommentList로 넘기고, 그 안에서 로컬 상태로 CRUD를 처리합니다.
변경 파일 정리
| 구분 | 파일 | 변경 내용 |
|---|---|---|
| BE | BoardFeatures.java | enableReplies: true (1줄) |
| BE | UnifiedCommentService.java | Q&A depth 제한 (3줄) |
| BE | V93_001__enable_replies...sql | 기존 QNA 보드 설정 업데이트 |
| FE | AnswerCommentForm.tsx | 신규 - 컴팩트 댓글 작성 폼 |
| FE | AnswerCommentList.tsx | 신규 - 댓글 목록 + 로컬 상태 관리 |
| FE | CommentSection.tsx | Q&A 모드 답변/댓글 그룹핑 |
| FE | utils.ts | groupCommentsByAnswer() 추가 |
| Test | 백엔드 2개, 프론트엔드 2개 | depth 제한, UI 렌더링 검증 |
백엔드 변경이 실질적으로 4줄이라는 게 이번 작업에서 가장 만족스러운 부분입니다.
마무리
새 기능이 필요할 때 "새 테이블, 새 API"부터 떠올리기 쉬운데, 기존 시스템을 먼저 살펴보면 설정 변경만으로 해결되는 경우가 있습니다. 이번이 딱 그런 케이스였습니다.
그리고 onSuccess → refetch 패턴은 구현이 간단하지만, 스크롤 위치가 중요한 UI에서는 사용자 경험을 해칩니다. 댓글 하나 달았는데 화면이 위로 뛰면, 기능은 동작하지만 경험은 깨진 겁니다. 로컬 상태 관리가 조금 더 손이 가지만, 사용자 입장에서는 차이가 큽니다.
다음으로는 답변 댓글에 투표 기능을 붙이거나, 알림을 좀 더 세분화할 계획입니다.

댓글
댓글을 작성하려면 이 필요합니다.