댓글 · 대댓글 시스템 설계

김소희·2025년 11월 28일

댓글 기능을 추가하는 과정에서도 예상보다 많은 설계적 고민이 필요했다.

댓글·대댓글 구조를 어떻게 제한할지,
삭제 방식을 어떻게 운영할지,
조회 성능을 어떻게 확보할지 등
사용자 경험과 시스템 안정성 사이의 균형을 끊임없이 고민해야 했다.

특히 댓글은 태그보다 훨씬 더 많은 상호작용이 발생하는 영역이기 때문에,
작은 선택 하나가 전체 사용자 흐름과 커뮤니티 문화까지 좌우할 수 있었다.
그렇기 때문에 단순 CRUD 기능을 넘어,
건전한 댓글 환경을 만들기 위해 어떤 구조와 정책을 채택할지 깊이 있는 고민이 필요했다.

주요 요구사항

유저는 게시글에 댓글을 달 수 있고, 댓글에는 대댓글까지 작성할 수 있지만 그 이상 깊어지지 않도록 제한하고자 했다.
댓글 계층이 너무 깊어지면 UI도 복잡해지고, 사용성 역시 떨어지기 때문이다.

삭제 방식 역시 단순히 완전 삭제하는 것이 가장 편하지만, 악의적인 사용자가 광고성 댓글을 남겼다가 즉시 삭제하는 식으로 어뷰징하는 문제나, 댓글은 지워졌는데 대댓글만 남아 UI 구조가 어색해지는 불편함을 해결하고 싶었다. 또한 인스타그램처럼 다른 유저 아이디를 @로 언급하면 알림이 가는 기능도 고려했지만, 친구가 아닌 사람들에게 받는 알람이 피로감을 줄 수 있다고 판단해 ‘대댓글이 달렸을 때만’ 알림을 보내는 방식으로 결정했다.

댓글은 최대 3000자로 제한하고(네이버와 동일), 댓글에도 좋아요를 한 번 누를 수 있게 하며, 댓글 수가 많아지면 페이징 처리하도록 정의했다.


첫 번째 고민: 테이블 구조 선택

댓글과 대댓글을 저장하는 방식은 여러 가지가 있다.

  • 첫 번째는 단일 테이블 + 셀프 조인 방식이다. COMMENT 테이블 하나에 PARENT_COMMENT_ID를 두고, 댓글이면 NULL, 대댓글이면 부모 댓글의 ID를 넣는 구조다. 구현이 단순하고 확장성이 좋지만, 깊이를 2단계로 제한하는 제약을 DB 레벨에서 걸기 어렵다는 단점이 있다.

  • 두 번째는 테이블 분리 방식으로 COMMENT와 REPLY를 완전히 분리하는 것이다. 태그 시스템에서 사용했던 구조와 유사하며, 자연스럽게 깊이가 제한되고 NULL 처리가 줄어든다. 하지만 공통 로직이 중복되거나 LIKE, 알림, 통계 등 여러 기능에서 COMMENT_ID와 REPLY_ID를 모두 고려해야 하므로 전체 시스템 복잡도가 증가한다.

  • 세 번째는 단일 테이블 + DEPTH 컬럼 방식이다. DEPTH 컬럼을 두어 댓글은 0, 대댓글은 1로 저장하고, CHECK (DEPTH IN (0, 1)) 제약조건을 걸어 대댓글의 대댓글을 원천 차단하는 방식이다.

대형 서비스들의 실제 구조

유튜브는 단일 테이블에 PARENT_ID를 사용하는 구조를 채택했다. 깊이 제한은 애플리케이션 레벨에서 처리한다. 대댓글의 대댓글 작성 버튼 자체를 UI에서 제공하지 않는 방식이다. 이렇게 하면 데이터베이스는 유연하게 유지하면서도 사용자에게는 명확한 제약을 제공한다.

네이버 블로그도 유사한 방식이다. COMMENT 테이블 하나에 PARENT_COMMENT_ID 컬럼을 두고, 프론트엔드에서 대댓글의 답글 버튼을 숨긴다. 백엔드에서는 if (parentComment.getParentId() != null) throw new BusinessException() 같은 검증 로직으로 2차 방어를 한다.

인스타그램은 조금 다르게 접근한다. 댓글에 답글 개념이 있지만 실제로는 멘션 방식이다. @username으로 언급하면 해당 사용자에게 알림이 가지만, 데이터 구조상으로는 모두 평등한 댓글이다. 답글처럼 보이는 것은 UI의 착시 효과다.

나의 요구사항을 고려하면 단일 테이블 + 애플리케이션 레벨 제약 방식이 가장 현실적이다.
테이블을 분리하면 COMMENT_LIKE 테이블을 만들 때 COMMENT_ID와 REPLY_ID 중 어느 것을 참조할지 고민해야 한다. 알림 테이블도 마찬가지다. 통계 쿼리를 작성할 때도 항상 UNION이 필요하다. 공통 로직을 추상화하려면 인터페이스나 상속 구조를 만들어야 하는데, 이는 오히려 복잡도를 높인다.
반면 단일 테이블로 설계하면 모든 것이 단순해진다. 좋아요는 COMMENT_ID 하나만 참조하면 되고, 알림도 COMMENT_ID 하나만 저장하면 된다. 대댓글 개수를 세는 것도 간단하다.

UI에서는 대댓글의 답글 버튼을 숨기고, 백엔드에서는 parentComment.getParentCommentId()가 null인지 확인해 2단계 제한을 보장한다.


두 번째 고민: 삭제 처리 방식

댓글을 삭제할 때 실제 레코드를 즉시 삭제할지, 아니면 소프트 딜리트를 적용할지 결정해야 했다.
소프트 딜리트는 COMMENT 테이블에 IS_DELETED 플래그를 두고, 이를 변경하는 방식으로 삭제를 처리한다. 레코드를 남겨두기 때문에 통계 데이터가 보존되고, 복구가 가능하며, 어떤 서비스에서도 일관된 방식으로 활용할 수 있다는 장점이 있다. 대신 조회 시 WHERE IS_DELETED = false 조건을 항상 포함해야 한다.

유튜브 vs 네이버 비교

구분유튜브네이버
댓글 삭제대댓글까지 모두 즉시 삭제“삭제된 댓글입니다” 표시
대댓글만 삭제해당 대댓글만 삭제해당 대댓글만 삭제
마지막 대댓글 삭제-부모 댓글도 UI에서 사라짐
복구불가능 (하드 딜리트)불가능 (소프트 딜리트)
장점UI가 깔끔함, 단순함댓글 흐름(맥락) 유지
단점대댓글 작성자 권리 침해“삭제된 댓글”이 쌓임

네이버가 소프트 딜리트를 선택한 이유

네이버는 댓글 구조와 커뮤니티 운영 특성 때문에 소프트 딜리트를 선택했다.

첫째, 댓글 흐름(맥락)을 보존하기 위해서다.
댓글–대댓글–추가 대댓글로 이어지는 긴 구조에서 중간 댓글이 완전히 삭제되면 전체 대화 흐름이 무너진다. 네이버 뉴스·카페처럼 토론 중심 플랫폼에서는 이러한 맥락 유지가 매우 중요하다.

둘째, 악용 방지를 위해서다.
유튜브처럼 완전 삭제가 가능하면 문제가 되는 댓글을 남겼다가 불리해지면 바로 지워버리는 식의 책임 회피가 쉬워진다. “공격 → 비난 → 즉시 삭제 → 증거 없음” 같은 패턴을 막을 필요가 있다.

셋째, 법적 분쟁 대응 때문이다.
명예훼손, 스토킹, 사기 등 다양한 신고·법적 요청이 들어올 수 있는 구조에서 댓글 흔적이 완전히 사라지면 사후 대응이 불가능해진다. 네이버는 개인 중심 플랫폼인 유튜브보다 훨씬 많은 법적 요청을 받기 때문에 기록을 일정 부분이라도 유지하는 것이 필수적이다.

또한 소프트 딜리트는 “삭제된 상태이지만 존재했다”는 흔적을 남기기 때문에 악성 유저가 자신의 행동을 단순 삭제로 감출 수 없다는 장점도 있다.

나의 선택

이 프로젝트에서는 삭제된 댓글임을 UI에 표시하고, 대댓글이 함께 삭제되는 경우에는 댓글과 대댓글 UI를 모두 숨기는 방식을 선택했다. 즉, UI에서는 더 이상 보이지 않지만 DB에는 기록이 남아 있는 소프트 딜리트 구조다. 관리자가 실수로 삭제된 댓글을 복구해야 할 때도 대응할 수 있도록 설계했다.

다만 소프트 딜리트된 레코드를 무기한 보관하면 데이터가 불필요하게 쌓이므로,
삭제 후 30일이 지나면 하드 딜리트로 완전히 제거하는 정책을 추가했다.

조건이 많아져 분기 로직이 복잡해질 수 있지만,
건전한 댓글 문화를 유지하고 악성 사용자를 억제하기 위해 필요한 선택이라고 판단했다.

또한 댓글을 삭제할 때는 단순히 댓글 레코드만 처리하지 않고,
해당 댓글을 참조하던 모든 연관 데이터(좋아요, 알림)를 먼저 정리한 후 삭제를 수행하도록 했다.

댓글이 소프트 딜리트되더라도 좋아요나 알림 데이터가 그대로 남아 있으면,
UI나 집계 로직에서 이미 삭제된 댓글을 참조하는 불일치 상태가 발생할 수 있다.
이를 방지하기 위해 댓글 삭제 트랜잭션 내에서 좋아요와 알림을 함께 제거하여
데이터 정합성을 우선적으로 보장했다.

이 과정은 하나의 트랜잭션으로 묶어 처리되며,
중간 단계에서 오류가 발생할 경우 모든 변경 사항이 롤백되도록 구성했다.
그 결과, 댓글·좋아요·알림 간의 참조 무결성이 항상 유지되며,
삭제 상태에서도 예측 가능한 데이터 흐름을 확보할 수 있었다.


세 번째 고민: 정렬과 조회 최적화

댓글 목록을 조회할 때 가장 큰 문제는 N+1 쿼리 이슈다. 댓글 각각에 대해 대댓글을 개별적으로 조회하면 댓글 수만큼 쿼리가 발생해 성능이 크게 떨어질 수 있다. 이를 피하기 위해 태그 시스템에서와 동일하게 IN 쿼리 기반 조회 방식을 적용했다.

먼저 부모 댓글 목록을 한 번에 조회한 뒤, 해당 댓글들의 ID를 모두 모아 한 번의 IN 쿼리로 대댓글 전체를 조회한다. 이 구조를 사용하면 게시글 하나를 조회할 때 댓글 조회 1번, 대댓글 조회 1번, 총 2번의 쿼리만으로 전체 댓글 구조를 불러올 수 있다.

정렬은 기본적으로 CREATED_AT 기준으로 처리했다. “최신순 보기” 같은 단순 정렬 옵션만 제공해도 사용자 입장에서는 충분히 직관적이고, 코드 리뷰 같은 글 특성상 시간 흐름이 가장 중요한 정보이기 때문이다.

이 방식의 가장 큰 장점은 댓글이 100개, 대댓글이 500개처럼 양이 많아져도 쿼리는 오직 2번만 실행된다는 점이다. 데이터가 많을수록 이런 구조적인 접근이 성능 최적화에 큰 도움이 된다.

N+1 쿼리 해결 코드

@Transactional(readOnly = true)
public List<CommentWithRepliesResponse> getCommentsByBoard(Long boardId, String boardType) {

    // 1. 댓글만 조회 (PARENT_COMMENT_ID IS NULL)
    List<Comment> comments = commentRepository.findByBoardIdAndBoardTypeAndParentCommentIdIsNull(
        boardId, boardType
    );
    
    if (comments.isEmpty()) {
        return Collections.emptyList();
    }
    
    // 2. 모든 댓글 ID 수집
    List<Long> commentIds = comments.stream()
        .map(Comment::getCommentId)
        .collect(Collectors.toList());
    
    // 3. 대댓글 한 번에 조회 (IN 쿼리)
    List<Comment> replies = commentRepository.findByParentCommentIdIn(commentIds);
    
    // 4. 댓글별로 대댓글 그룹핑
    Map<Long, List<Comment>> repliesByParentId = replies.stream()
        .collect(Collectors.groupingBy(Comment::getParentCommentId));
    
    // 5. 응답 조립
    return comments.stream()
        .map(comment -> CommentWithRepliesResponse.of(
            comment, 
            repliesByParentId.getOrDefault(comment.getCommentId(), Collections.emptyList())
        ))
        .collect(Collectors.toList());
}

네 번째 고민: 작성자 표시와 권한

댓글 작성자가 게시글 작성자일 경우 구분해서 보여주는 UI가 필요할 수 있다. 이를 위해 COMMENT 테이블에 IS_AUTHOR 플래그를 둘 수도 있고, 조회 시 BOARD.USER_ID와 COMMENT.USER_ID를 비교하여 동적으로 판단할 수도 있다.

수정·삭제 권한 역시 중요하다. 본인만 자신의 댓글을 수정·삭제할 수 있어야 하며, 관리자는 모든 댓글을 관리할 수 있는 권한을 가진다.

다섯 번째 고민: 순서와 페이징 처리

코드 리뷰 성격의 댓글 구조에서는 문맥 유지가 무엇보다 중요하다. 일반적으로 코드 리뷰는 코드 설명 → 리뷰 → 수정 → 재리뷰 → 추가 의견 같은 순서를 갖기 때문에, 최신 댓글이 위로 올라오는 구조보다는 오래된 댓글부터 위에 위치하고, 최신 댓글이 아래로 쌓이는 방식(네이버식)이 적합하다. Stack Overflow, GitHub Issues, Jira 같은 실제 개발자 도구들도 같은 정렬 방식을 사용하고 있다.

다만 최신 논의를 빠르게 보기 위한 편의를 위해 “최신순 보기” 기능도 함께 제공하도록 했다.

댓글이 많아질 것을 고려해 커서 기반 페이징을 도입했다. CREATED_AT + COMMENT_ID 기준의 커서를 사용해 안정적인 페이지 이동이 가능하며, 대댓글은 댓글 단위로 ‘답글 더보기’ 버튼을 제공하는 방식도 고려했다.

여덟 번째 고민: 스팸과 어뷰징 방지

같은 사용자가 짧은 시간 안에 여러 댓글을 반복적으로 작성하는 것을 방지하기 위해 Redis로 COMMENT:USER:{userId}:LAST_COMMENT_TIME을 저장해 일정 시간 이내에는 추가 댓글 작성이 제한되도록 설계할 수 있다. 욕설 필터링은 작성 시점 검사나 신고 시스템을 통한 사후 처리 방식으로 대응한다.

profile
개발자 소희의 노트

0개의 댓글