
댓글 좋아요, "누가 눌렀는지" 보여주기: N+1 없이 구현하기

좋아요 숫자만으로는 부족하다
FullStackFamily에 좋아요 기능은 진작부터 있었습니다. 하트 누르면 숫자 올라가고, 다시 누르면 내려가고. 별 문제 없이 잘 돌아갔는데, 쓰다 보니 한 가지가 자꾸 걸렸습니다.
"이 댓글에 좋아요 3개인데... 누가 눌렀지?"
숫자 3만 덩그러니 있으면 묘하게 궁금하거든요. 교육 커뮤니티다 보니 "강사님이 내 댓글에 좋아요를 눌러줬구나" 같은 걸 알 수 있으면 수강생 입장에서 꽤 기분 좋을 겁니다. 페이스북에서 친구 이름 보이면 괜히 기분 좋은 것처럼요.
그래서 두 가지를 만들었습니다.
- 댓글 옆에 "김성박 외 2명" 프리뷰
- 숫자 클릭하면 전체 목록 모달
전체 구조
┌─────────────────────────────────────────────┐ │ ♥ 김성박 외 2명 ← 프리뷰 (인라인) │ │ ↓ 클릭 │ │ ┌──────────────────────┐ │ │ │ 좋아요한 사람들 3 │ ← 모달 │ │ │ [avatar] 김성박 Lv.27│ │ │ │ [avatar] 홍길동 Lv.15│ │ │ │ [avatar] 이영희 Lv.8 │ │ │ └──────────────────────┘ │ └─────────────────────────────────────────────┘
프리뷰는 최근 좋아요 1명의 이름만 보여주고, 나머지는 "외 N명"으로 요약합니다. 전체가 궁금하면 클릭해서 모달을 열면 되고요.
첫 번째 접근: 컴포넌트마다 개별 API 호출
처음엔 단순하게 갔습니다. 각 댓글의 VoteButtons 컴포넌트가 마운트될 때 자기 댓글의 좋아요 사용자를 개별 조회하는 방식이었는데요.
댓글 1의 VoteButtons → GET /api/comments/1/voters 댓글 2의 VoteButtons → GET /api/comments/2/voters 댓글 3의 VoteButtons → GET /api/comments/3/voters ... 댓글 20의 VoteButtons → GET /api/comments/20/voters
각 컴포넌트가 자기 데이터를 알아서 가져오니 코드는 깔끔합니다. 문제는 댓글이 20개면 API가 21번 호출된다는 거죠 (댓글 목록 1회 + 프리뷰 20회). 네트워크 탭을 열어보면 요청이 줄줄이 쏟아집니다.
GET /api/posts/123/full ← 게시글 + 댓글 목록 GET /api/comments/1/voters ← N+1 시작 GET /api/comments/2/voters GET /api/comments/3/voters ... ← 브라우저가 바쁘다 GET /api/comments/20/voters
프론트엔드 버전 N+1 문제입니다.
두 번째 접근: 서버에서 배치 로드
방향은 금방 정해졌습니다. "프리뷰에 필요한 데이터를 서버에서 한번에 내려보내자."
댓글 목록을 조회할 때, 각 댓글의 최근 좋아요 사용자 1명을 배치로 같이 내려보내면 끝입니다.
기존: 댓글 목록 API (1회) + 프리뷰 API (N회) = 1 + N 개선: 댓글 목록 API (1회, 프리뷰 포함) = 1
백엔드: SQL 2회로 모든 댓글의 프리뷰 생성
핵심은 buildVoterPreviewMap() 메서드입니다.
댓글 20개의 ID: [1, 2, 3, ..., 20] │ ▼ SQL 1회: SELECT * FROM comment_vote WHERE comment_id IN (1,2,...,20) AND vote_type = 'UP' ORDER BY created_at DESC │ ▼ 댓글별 그룹핑 → 각 댓글의 최근 1명만 추출 │ ▼ SQL 2회: SELECT * FROM users WHERE id IN (추출된 사용자 ID들) │ ▼ 결과: { 댓글1: [김성박], 댓글5: [홍길동], ... }
댓글이 100개든 SQL은 딱 2번입니다. IN 절 쿼리 1회, 사용자 정보 조회 1회.
프론트엔드: 클라이언트 fetch 제거
VoteButtons 컴포넌트에서 useEffect로 개별 API를 호출하던 코드를 전부 삭제했습니다. 이제 부모에서 내려주는 recentVoters prop만 씁니다.
// 변경 전: 컴포넌트 내부에서 API 호출 useEffect(() => { fetchVoters(commentId) // N+1! }, [commentId]) // 변경 후: 서버에서 배치로 받은 데이터 사용 <VoteButtons recentVoters={comment.recentVoters} showPreview />
프리뷰 텍스트의 표시 규칙
페이스북 스타일을 참고하되, 몇 가지 규칙을 정했습니다.
| 좋아요 수 | 표시 | 이유 |
|---|---|---|
| 0 | (표시 안 함) | 0은 굳이 보여줄 필요 없음 |
| 1 | 1 | 1명일 때 이름만 나오면 어색함 |
| 2 | 김성박 외 1명 | 이름 + 나머지 수 |
| 5 | 김성박 외 4명 | 동일 패턴 |
| 100+ | 김성박 외 99명 | 서버에서 1명만 내려오므로 처리 동일 |
1명일 때 "김성박"이라고만 쓰면 좀 어색합니다. 숫자 "1"이 더 자연스럽고요. 2명부터 이름이 나오면 "오, 누가 눌렀지?" 하는 호기심이 생깁니다.
좋아요 목록 모달
숫자나 "외 N명" 텍스트를 클릭하면 전체 목록을 보여주는 모달이 뜹니다.
┌──────────────────────────┐ │ 좋아요한 사람들 7 │ │──────────────────────────│ │ [avatar] 김성박 Lv.27 │ │ 2분 전 │ │ [avatar] 홍길동 Lv.15 │ │ 1시간 전 │ │ [avatar] 이영희 Lv.8 │ │ 3시간 전 │ │ ─ 더 보기 ─ │ └──────────────────────────┘
이 모달은 열릴 때만 API를 호출합니다. 프리뷰와 달리 전체 목록은 필요할 때만 가져오는 거죠. 20명 단위 페이지네이션이라 좋아요가 수백 개여도 괜찮습니다.
맥락에 따라 달라지는 좋아요 UI
같은 VoteButtons 컴포넌트인데, 쓰이는 곳에 따라 모드가 다릅니다.
일반 댓글 (CommentItem): ♥ 김성박 외 2명 ← showPreview 모드 하트 옆에 이름 프리뷰, 클릭하면 모달 게시글 상세 (BasePostDetail): [♥ 3] ← pill 모드 (기본) 둥근 테두리 안에 하트+숫자, 숫자 클릭하면 모달 QnA 답변 댓글 (AnswerCommentItem): ♡ 1 ← showPreview + 숫자만 답변 대댓글은 공간이 좁아서 숫자만 표시
이걸 가능하게 하는 게 showPreview + recentVoters prop 조합입니다. QnA 답변 댓글처럼 공간이 빠듯한 곳에서는 recentVoters를 안 넘기면 이름 없이 숫자만 나옵니다.
"좋아요 누르면 화면이 위로 올라가요"
구현 중 재밌는 버그가 하나 터졌습니다.
QnA 답변의 대댓글에서 좋아요를 누르면 페이지가 맨 위로 확 올라갑니다. 처음엔 폼 submit 문제인가 싶었는데, 아니었습니다.
원인은 이거였습니다.
좋아요 클릭 → onVote(commentId) 호출 → commentApi.voteComment() 호출 → refetch() ← 범인 → 게시글 전체 데이터 다시 조회 → 컴포넌트 트리 재구성 → 스크롤 위치 초기화
refetch()가 PostFullDetail 전체를 다시 불러오면서 React가 컴포넌트 트리를 재구성하고, 브라우저 스크롤이 초기화되는 거였습니다.
해결은 간단합니다. 로컬 상태만 업데이트하면 됩니다.
좋아요 클릭 → commentApi.voteComment() 호출 → 응답: { voteCount: 4, myVote: 'UP' } → setLocalComments로 해당 댓글만 업데이트 → 스크롤 유지, 화면 깜빡임 없음
AnswerCommentList는 이미 댓글 생성/수정/삭제를 로컬 상태로 관리하고 있었거든요. 좋아요도 같은 패턴을 적용했을 뿐인데, API 호출이 2회에서 1회로 줄고 스크롤 문제도 사라졌습니다.
성능 비교 정리
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| 프리뷰 API 호출 | 댓글 N개 → N회 | 0회 (서버 배치) |
| 프리뷰 SQL | N회 | 2회 (IN 쿼리) |
| 좋아요 토글 | voteComment + refetch (2회) | voteComment (1회) |
| 좋아요 후 화면 | 전체 리렌더 + 스크롤 초기화 | 해당 댓글만 업데이트 |
| 좋아요 목록 | 없음 | Lazy 모달 (필요시 조회) |
| 좋아요 알림 | 없음 | @Async 이벤트 (응답 지연 없음) |
"강경미님이 회원님의 댓글을 좋아합니다"
프리뷰와 모달을 만들고 나니 한 가지가 빠져 있었습니다. 좋아요를 눌러도 상대방이 그 글을 다시 방문하지 않으면 모른다는 거죠. 댓글 알림은 진작부터 있었는데, 좋아요 알림은 없었습니다.
기존 알림 시스템이 이벤트 기반이라 추가는 생각보다 간단했습니다.
좋아요 클릭 → vote() 트랜잭션 실행 → eventPublisher.publishEvent(CommentVotedEvent) → vote() 즉시 응답 반환 ↓ (별도 스레드, @Async) → NotificationEventListener → DB에 알림 저장 → 프론트에 전달 (120초 폴링)
핵심은 @Async입니다. 알림 처리가 별도 스레드에서 돌아가니까 좋아요 API 응답 속도에 영향이 없고, 알림 저장이 실패해도 좋아요 트랜잭션은 정상 커밋됩니다.
알림을 안 보내는 경우
모든 좋아요에 알림을 보내면 스팸이 됩니다. 세 가지 경우를 걸렀습니다.
| 상황 | 알림 | 이유 |
|---|---|---|
| 새로운 UP 투표 | 보냄 | 유일하게 의미 있는 시점 |
| 좋아요 취소 | 안 보냄 | "좋아요 취소했습니다" 알림은 불쾌함 |
| 자기 댓글에 좋아요 | 안 보냄 | 자기 자신에게 알림은 무의미 |
게시글 좋아요도 같은 패턴입니다. PostVotedEvent를 하나 더 만들고 UnifiedPostService.vote()에서 발행하면 끝.
MySQL ENUM의 함정
구현은 금방 됐는데, 배포하니까 알림이 안 왔습니다. 로그를 보니 Data truncated for column 'type' 에러.
원인은 notification 테이블의 type 컬럼이 MySQL ENUM 타입이었기 때문입니다. Java enum에 COMMENT_VOTED를 추가해도 DB ENUM에는 그 값이 없으니까 INSERT가 실패한 거죠.
Java enum: COMMENT_ON_POST, ..., COMMENT_VOTED ← 추가됨 MySQL ENUM: COMMENT_ON_POST, ... ← 여기엔 없음 → Data truncated!
ALTER TABLE notification MODIFY COLUMN type ENUM(..., 'COMMENT_VOTED', 'POST_VOTED')로 해결했지만, 근본적으로는 ENUM 대신 VARCHAR를 쓰는 게 맞습니다. VARCHAR면 Java 코드만 바꾸면 DB 변경 없이 동작하니까요. 다른 테이블들은 이미 columnDefinition = "varchar(30)" 규칙을 따르고 있었는데, notification 테이블이 그 규칙 이전에 만들어진 거였습니다.
이 기능에 기대하는 것
기술 이야기를 많이 했는데, 왜 이 기능을 만들었냐 하면 결국 커뮤니티 분위기 때문입니다.
"김성박 외 2명"이라는 텍스트 하나가 "아, 사람들이 내 댓글을 읽고 있구나"라는 피드백을 줍니다. 숫자 3보다 이름이 보이는 게 훨씬 와닿거든요. 수강생이 올린 질문에 강사가 좋아요를 누르면, 그건 단순한 숫자 +1이 아니라 "강사님이 봤다"는 신호입니다.
그리고 누가 눌렀는지 보이면, 자기도 눌러보고 싶어지는 효과가 있습니다. "홍길동님도 눌렀네, 나도 눌러야지." 좋아요 목록 모달에는 프로필 이미지와 레벨 뱃지까지 보이니까, 커뮤니티 안에서 서로 누군지 인식하는 데도 도움이 됩니다.
알림이 아니라 댓글 바로 옆에 보이는 정보라서, 게시글을 다시 방문할 때마다 자연스럽게 확인하게 되는 것도 괜찮은 점입니다. "지난번엔 2명이었는데 5명으로 늘었네" 같은 소소한 변화가 다음 글을 쓰게 만드는 동기가 되거든요.
마무리
기능 자체는 별거 아닙니다. 좋아요 옆에 이름 하나 보여주고, 클릭하면 목록 나오고, 누르면 알림 가고. 그런데 "성능 문제 없이" 붙이려니 생각보다 과정이 길었습니다.
N+1을 서버 배치 로드로 잡고, 스크롤 버그를 로컬 상태 업데이트로 해결하고, 같은 컴포넌트를 QnA 대댓글에서는 숫자만 보여주는 식으로 맥락별 분기를 넣고, 알림까지 이벤트 기반으로 연결하고. 작은 기능 하나인데 백엔드-프론트엔드-DB를 오가는 판단이 여러 번 필요했습니다.
그리고 MySQL ENUM 컬럼 때문에 한번 삽질한 건 좋은 교훈이었습니다. enum 값 추가는 Java 코드만의 문제가 아닙니다.






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