페이지네이션 설계

김소희·2025년 12월 6일

오프셋 vs 커서, 그리고 무한 스크롤

게시판 시스템에서 페이지네이션은 단순한 UI 기능이 아니다. 조회 성능과 확장성을 좌우하는 핵심 설계 요소다. 초기에 잘못 설계하면 나중에 데이터가 쌓였을 때 전면 수정이 불가피하다. 특히 검색 조건, 정렬 기준, SQL Injection 방어를 함께 고려하지 않으면 기능 추가 시마다 코드가 파편화된다.

게시글과 댓글은 데이터 특성이 다르다.
게시글은 특정 페이지로 바로 이동하는 UI가 필요하지만 댓글은 맥락의 흐름을 깨지 않도록 무한 스크롤이 더 자연스럽다. 각각에 맞는 페이지네이션 방식을 선택하고 공통 인터페이스로 일관성을 유지하는 것이 이 글의 목표다.

두 가지 페이지네이션 방식

오프셋 기반 (Offset-based Pagination)

페이지 번호로 데이터를 건너뛰는 방식이다. 게시판처럼 특정 페이지로 바로 이동하는 UI에 적합하다.

SELECT * FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 40  -- 3페이지 (2페이지 건너뜀)

장점:

  • 특정 페이지로 바로 이동 가능
  • 전체 페이지 수 계산 가능
  • 사용자가 현재 위치를 명확히 인식

단점:

  • 데이터가 추가/삭제되면 중복 노출 또는 누락 발생
  • OFFSET이 커질수록 성능 저하 (데이터베이스가 앞 부분을 건너뛰면서도 읽어야 함)
  • 실시간성이 강한 데이터에는 부적합

커서 기반 (Cursor-based Pagination)

마지막으로 본 데이터의 ID를 기준으로 다음 데이터를 가져오는 방식이다. 댓글이나 피드처럼 무한 스크롤 UI에 적합하다.

SELECT * FROM comments
WHERE id < 12345  -- 마지막으로 본 댓글 ID
ORDER BY id DESC
LIMIT 20

장점:

  • 데이터가 추가/삭제되어도 중복이나 누락 없음
  • OFFSET 계산이 없어서 깊은 페이지에서도 성능 일정
  • 실시간 데이터에 적합

단점:

  • 특정 페이지로 바로 이동 불가
  • 전체 페이지 수를 알 수 없음
  • 정렬 기준이 ID가 아니면 구현 복잡도 증가

설계의 핵심 원칙

페이지네이션 설계에서 가장 중요한 것은 무엇이 공통 영역이고 무엇이 도메인 영역인지 명확히 구분하는 것이다.

공통으로 관리하는 것

데이터 종류와 무관하게 페이징 처리 자체에 필요한 논리적 요소다.

  • PageRequest: 오프셋 기반 (게시글용)
  • CursorRequest: 커서 기반 (댓글용)
  • PageResponse: 오프셋 응답
  • CursorResponse: 커서 응답

도메인별로 달라지는 것

데이터 성격에 따라 달라지는 물리적 요소다.

  • 조회 SQL: 테이블과 컬럼이 다르다
  • 결과 DTO: 반환 필드가 다르다
  • 검색 조건: 검색 대상 컬럼이 다르다
  • 정렬 기준: 정렬 가능한 컬럼이 다르다

패키지 구조

commons.pagination
├── PageRequest.java          // 오프셋 기반 (게시글)
├── PageResponse.java
├── CursorRequest.java        // 커서 기반 (댓글)
├── CursorResponse.java
├── SearchCondition.java
├── SortCondition.java
└── SortDirection.java

domain.freeboard
├── FreeboardSortType.java
└── FreeboardSearchType.java

domain.comment
└── CommentCursor.java

오프셋 기반 구현 (게시글)

PageRequest

페이지네이션 요청 객체로 page, size 기반으로 offset 계산을 한다.

package kr.or.kosa.backend.commons.pagination;

import lombok.Getter;

@Getter
public class PageRequest {

    private final int page;
    private final int size;
    private final int offset;

	// 페이지와 사이즈가 1보다 작을 경우 기본값으로 1을 설정하여 오류 방지
    public PageRequest(int page, int size) {
        this.page = Math.max(page, 1);
        this.size = Math.max(size, 1);
        this.offset = (this.page - 1) * this.size;
    }
}

PageResponse

페이지네이션 공통 응답 객체이자 모든 리스트 API의 공통 응답 래퍼

package kr.or.kosa.backend.commons.pagination;

import lombok.Getter;
import java.util.List;

@Getter
public class PageResponse<T> {
    private final List<T> content;
    private final int page;
    private final int size;
    private final long totalCount;
    private final int totalPages;
    private final boolean hasNext;
    private final boolean hasPrevious;

    public PageResponse(
            List<T> content,
            PageRequest pageRequest,
            long totalCount
    ) {
        this.content = content;
        this.page = pageRequest.getPage();
        this.size = pageRequest.getSize();
        this.totalCount = totalCount;
        this.totalPages = calculateTotalPages(totalCount, size);
        this.hasNext = page < totalPages;
        this.hasPrevious = page > 1;
    }

    private int calculateTotalPages(long totalCount, int size) {
        // size가 음수거나 0인 경우는 PageRequest 단계에서 막혔지만, 방어적 코드로 한 번 더 체크
        if (totalCount == 0 || size <= 0) {
            return 0;
        }
        return (int) Math.ceil((double) totalCount / size);
    }
}

게시글 Controller

@Log4j2
@RestController
@RequestMapping("/freeboard")
@RequiredArgsConstructor
public class FreeboardController {

    private final FreeboardService freeboardService;

    // 게시글 목록 조회 (검색/정렬 포함)
    @GetMapping
    public ResponseEntity<ApiResponse<PageResponse<FreeboardListResponseDto>>> getList(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size,
            @RequestParam(defaultValue = "CREATED_AT") String sort,
            @RequestParam(defaultValue = "DESC") SortDirection direction,
            @RequestParam(required = false) String keyword
    ) {
        FreeboardSortType sortType = FreeboardSortType.from(sort);
        PageResponse<FreeboardListResponseDto> response =
                freeboardService.getList(page, size, sortType, direction, keyword);

        return ResponseEntity.ok(ApiResponse.success(response));
    }

게시글 Service

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class FreeboardService {

    private final FreeboardMapper mapper;
    private final ObjectMapper objectMapper;
    private final TagService tagService;
    private final LikeMapper likeMapper;

    // 자유게시판 목록 조회 (검색 + 정렬 + 페이지네이션)
    public PageResponse<FreeboardListResponseDto> getList(
            int page,
            int size,
            FreeboardSortType sortType,
            SortDirection direction,
            String keyword
    ) {
        PageRequest pageRequest = new PageRequest(page, size);
        SearchCondition searchCondition = new SearchCondition(keyword);
        SortCondition sortCondition = new SortCondition(sortType, direction);

        List<FreeboardListResponseDto> boards =
                mapper.findPosts(pageRequest, searchCondition, sortCondition);

        long totalCount = mapper.countPosts(searchCondition);

        return new PageResponse<>(boards, pageRequest, totalCount);
    }
}

게시글 Mapper XML

<!-- 게시글 목록 조회 (서브쿼리로 좋아요/댓글 수 실시간 집계) -->
<select id="findPosts" resultMap="freeboardListResultMap">
  SELECT
  f.FREEBOARD_ID,
  f.USER_ID,
  u.USER_NICKNAME,
  f.FREEBOARD_TITLE,
  f.FREEBOARD_CLICK,
  f.FREEBOARD_REPRESENT_IMAGE,
  f.FREEBOARD_CREATED_AT,
  (SELECT COUNT(*)
  FROM `LIKE` l
  WHERE l.REFERENCE_ID = f.FREEBOARD_ID
  AND l.REFERENCE_TYPE = 'POST_FREEBOARD') AS LIKE_COUNT,
  (SELECT COUNT(*)
  FROM `COMMENT` c
  WHERE c.BOARD_ID = f.FREEBOARD_ID
  AND c.BOARD_TYPE = 'FREEBOARD'
  AND c.IS_DELETED = FALSE) AS COMMENT_COUNT
  FROM FREEBOARD f
  LEFT JOIN USERS u ON f.USER_ID = u.USER_ID
  <where>
    f.FREEBOARD_DELETED_YN = 'N'
    <if test="search.hasKeyword()">
      AND (
      f.FREEBOARD_TITLE LIKE CONCAT('%', #{search.keyword}, '%')
      OR f.FREEBOARD_PLAIN_TEXT LIKE CONCAT('%', #{search.keyword}, '%')
      OR u.USER_NICKNAME LIKE CONCAT('%', #{search.keyword}, '%')
      )
    </if>
  </where>
  ORDER BY
  <choose>
    <when test="sort.column == 'like_count'">
      LIKE_COUNT ${sort.directionSql}
    </when>
    <when test="sort.column == 'comment_count'">
      COMMENT_COUNT ${sort.directionSql}
    </when>
    <when test="sort.column == 'view_count'">
      f.FREEBOARD_CLICK ${sort.directionSql}
    </when>
    <otherwise>
      f.FREEBOARD_CREATED_AT ${sort.directionSql}
    </otherwise>
  </choose>
  LIMIT #{page.size} OFFSET #{page.offset}
</select>

커서 기반 구현 (댓글 무한 스크롤)

CursorRequest

package kr.or.kosa.backend.commons.pagination;

import lombok.Getter;

@Getter
public class CursorRequest {

    private final Long cursor; // 마지막으로 본 데이터의 ID
    private final int size;

    public CursorRequest(Long cursor, int size) {
        this.cursor = cursor;
        this.size = Math.max(size, 1);
    }

    public boolean hasCursor() {
        return cursor != null && cursor > 0;
    }
}

CursorResponse

package kr.or.kosa.backend.commons.pagination;

import lombok.Getter;
import java.util.List;

@Getter
public class CursorResponse<T> {

    private final List<T> content;
    private final Long nextCursor;  // 다음 요청에 사용할 커서
    private final boolean hasNext;
    private final int size;

    public CursorResponse(List<T> content, int requestSize) {
        this.content = content;
        this.size = content.size();
        this.hasNext = content.size() >= requestSize;
        this.nextCursor = hasNext && !content.isEmpty() 
            ? getLastId(content.get(content.size() - 1)) 
            : null;
    }

    private Long getLastId(T item) {
        // 리플렉션 또는 인터페이스를 통해 ID 추출
        // 여기서는 CommentResponseDto가 getId()를 가진다고 가정
        try {
            return (Long) item.getClass().getMethod("getCommentId").invoke(item);
        } catch (Exception e) {
            return null;
        }
    }
}

더 안전한 방법은 인터페이스를 정의하는 것이다.

public interface Identifiable {
    Long getId();
}


@Getter
public class CursorResponse<T extends Identifiable> {

    private final List<T> content;
    private final Long nextCursor; // 다음 요청에 사용할 커서
    private final boolean hasNext;
    private final int size;        // 요청 크기

    public CursorResponse(List<T> content, int requestSize) {
        this.content = content;
        this.size = content.size();
        this.hasNext = content.size() >= requestSize;
        this.nextCursor = hasNext && !content.isEmpty()
                ? content.get(content.size() - 1).getId()
                : null;
    }

}

댓글 DTO

package kr.or.kosa.backend.comment.dto;

import kr.or.kosa.backend.commons.pagination.Identifiable;
import lombok.Getter;
import lombok.Setter;
import java.time.LocalDateTime;

@Getter
@Setter
public class CommentResponseDto implements Identifiable {

    private Long commentId;
    private Long boardId;
    private String boardType;
    private Long userId;
    private String userNickname;
    private String commentContent;
    private Long parentCommentId;
    private Integer likeCount;
    private Boolean isDeleted;
    private LocalDateTime createdAt;

    @Override
    public Long getId() {
        return commentId;
    }
}

댓글 Controller

@RestController
@RequestMapping("/api/comments")
@RequiredArgsConstructor
public class CommentController {

    private final CommentService commentService;

    @GetMapping
    public ResponseEntity<CursorResponse<CommentResponse>> getComments(
            @RequestParam Long boardId,
            @RequestParam String boardType,
            @RequestParam(required = false) Long cursor,
            @RequestParam(defaultValue = "20") int size,
            @RequestAttribute(value = "userId", required = false) Long userId) {
            
        CursorRequest cursorRequest = new CursorRequest(cursor, size);
        CursorResponse<CommentResponse> response =
                commentService.getComments(boardId, boardType, cursorRequest, userId);

        return ResponseEntity.ok(response);
    }
}

댓글 Service

@Slf4j
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class CommentService {

    private final CommentMapper commentMapper;
    private final CodeboardMapper codeBoardMapper;
    private final FreeboardMapper freeboardMapper;
    private final UserMapper userMapper;
    private final NotificationService notificationService;
    private final LikeService likeService;

    @Transactional
    public CommentResponse createComment(CommentCreateRequest request, Long userId) {
        // 게시글 존재 여부 확인
        Long boardAuthorId = getBoardAuthorId(request.boardType(), request.boardId());

        // 대댓글인 경우 부모 댓글 검증
        if (request.parentCommentId() != null) {
            Comment parentComment = commentMapper.selectCommentById(request.parentCommentId());
            if (parentComment == null) {
                throw new CustomBusinessException(CommentErrorCode.PARENT_NOT_FOUND);
            }

            // 대댓글의 대댓글 방지
            if (parentComment.getParentCommentId() != null) {
                throw new CustomBusinessException(CommentErrorCode.DEPTH_LIMIT_EXCEEDED);
            }

            // 부모 댓글 작성자에게 알림 발송
            if (!parentComment.getUserId().equals(userId)) {
                notificationService.sendNotification(
                        parentComment.getUserId(),
                        userId,
                        NotificationType.COMMENT_REPLY,
                        ReferenceType.COMMENT,
                        parentComment.getCommentId()
                );
            }
        }
        // 댓글인 경우 게시글 작성자에게 알림
        else {
            if (!boardAuthorId.equals(userId)) {
                ReferenceType referenceType = switch (request.boardType()) {
                    case "CODEBOARD" -> ReferenceType.POST_CODEBOARD;
                    case "FREEBOARD" -> ReferenceType.POST_FREEBOARD;
                    case "ALGORITHM" -> ReferenceType.POST_ALGORITHM;
                    default -> throw new CustomBusinessException(CommentErrorCode.INVALID_BOARD_TYPE);
                };

                notificationService.sendNotification(
                        boardAuthorId,
                        userId,
                        NotificationType.POST_COMMENT,
                        referenceType,
                        request.boardId()
                );
            }
        }

        Comment comment = Comment.builder()
                .boardId(request.boardId())
                .boardType(request.boardType())
                .parentCommentId(request.parentCommentId())
                .userId(userId)
                .content(request.content())
                .build();

        Long inserted = commentMapper.insertComment(comment);
        if (inserted == 0) {
            throw new CustomBusinessException(CommentErrorCode.INSERT_ERROR);
        }

        // DB에서 다시 조회하여 자동 생성된 필드 값 가져오기
        Comment savedComment = commentMapper.selectCommentById(comment.getCommentId());

        // 사용자 닉네임 조회
        Users user = userMapper.findById(userId);
        String userNickname = user != null ? user.getUserNickname() : null;

        return CommentResponse.builder()
                .commentId(savedComment.getCommentId())
                .boardId(savedComment.getBoardId())
                .boardType(savedComment.getBoardType())
                .parentCommentId(savedComment.getParentCommentId())
                .userId(savedComment.getUserId())
                .userNickname(userNickname)
                .content(savedComment.getContent())
                .likeCount(savedComment.getLikeCount())
                .isLiked(false)
                .isAuthor(savedComment.getUserId().equals(boardAuthorId))
                .isDeleted(savedComment.getIsDeleted())
                .createdAt(savedComment.getCreatedAt())
                .updatedAt(savedComment.getUpdatedAt())
                .build();
    }

    // 커서 기반 댓글 목록 조회 (답글 포함)
    public CursorResponse<CommentResponse> getComments(
            Long boardId,
            String boardType,
            CursorRequest cursor,
            Long userId
    ) {
        // 댓글 + 답글 모두 조회 (SQL에서 한 번에)
        List<CommentResponse> comments = commentMapper.selectCommentsWithReplies(
                boardId,
                boardType,
                cursor,
                userId
        );

        return new CursorResponse<>(comments, cursor.getSize());
    }
}

댓글 Mapper

@Mapper
public interface CommentMapper {

    // 댓글 생성
    Long insertComment(Comment comment);

    // 댓글 조회 (단건)
    Comment selectCommentById(@Param("commentId") Long commentId);

    // 댓글 + 답글 조회 (커서 기반 무한 스크롤)
    List<CommentResponse> selectCommentsWithReplies(
            @Param("boardId") Long boardId,
            @Param("boardType") String boardType,
            @Param("cursor") CursorRequest cursor,
            @Param("userId") Long userId
    );

    // 댓글 수정
    Long updateComment(Comment comment);

    // 댓글 소프트 삭제
    Long deleteComment(@Param("commentId") Long commentId);

    // 특정 사용자의 댓글인지 확인
    boolean existsByCommentIdAndUserId(
            @Param("commentId") Long commentId,
            @Param("userId") Long userId
    );
}

댓글 Mapper XML

<!-- 댓글 + 답글 조회 (커서 기반 무한 스크롤) - MySQL 호환 버전 -->
<select id="selectCommentsWithReplies" resultMap="commentResponseMap">
    SELECT
        c.COMMENT_ID,
        c.BOARD_ID,
        c.BOARD_TYPE,
        c.PARENT_COMMENT_ID,
        c.USER_ID,
        u.USER_NICKNAME,
        c.CONTENT,
        c.IS_DELETED,
        c.CREATED_AT,
        c.UPDATED_AT,
        (SELECT COUNT(*)
         FROM `LIKE` l
         WHERE l.REFERENCE_ID = c.COMMENT_ID
           AND l.REFERENCE_TYPE = 'COMMENT') AS LIKE_COUNT,
        <choose>
            <when test="userId != null">
                EXISTS(
                    SELECT 1
                    FROM `LIKE` l
                    WHERE l.REFERENCE_ID = c.COMMENT_ID
                      AND l.REFERENCE_TYPE = 'COMMENT'
                      AND l.USER_ID = #{userId}
                ) AS IS_LIKED,
                IF(c.USER_ID = #{userId}, TRUE, FALSE) AS IS_AUTHOR
            </when>
            <otherwise>
                FALSE AS IS_LIKED,
                FALSE AS IS_AUTHOR
            </otherwise>
        </choose>
    FROM COMMENT c
    LEFT JOIN USERS u ON c.USER_ID = u.USER_ID
    WHERE c.BOARD_ID = #{boardId}
      AND c.BOARD_TYPE = #{boardType}
      AND c.IS_DELETED = FALSE
      AND (
        -- 댓글만 조회 (첫 로딩 또는 커서 기반)
        c.PARENT_COMMENT_ID IS NULL
        <if test="cursor.hasCursor()">
          AND c.COMMENT_ID &gt; #{cursor.cursor}
        </if>
      )
    ORDER BY c.COMMENT_ID ASC
    LIMIT #{cursor.size}
</select>

커서가 없으면 최신 댓글부터 가져온다. 커서가 있으면 그 이전 댓글을 가져온다. OFFSET이 없으므로 성능이 일정하다.

무한 스크롤 UX: 50개 제한 후 더보기 버튼

무한 스크롤을 그대로 구현하면 사용자가 페이지 하단의 Footer에 영원히 도달할 수 없다는 UX 문제가 발생한다. 실무에서는 일정 개수(50개) 후 더보기 버튼을 노출하는 하이브리드 방식을 사용한다.

프론트엔드 구현 (React 예시)

function CommentList({ boardId, boardType }) {
    const [comments, setComments] = useState([]);
    const [cursor, setCursor] = useState(null);
    const [hasNext, setHasNext] = useState(false);
    const [showButton, setShowButton] = useState(false);
    const [loadCount, setLoadCount] = useState(0);

    const loadComments = async () => {
        const response = await fetch(
            `/api/comments?boardId=${boardId}&boardType=${boardType}` +
            (cursor ? `&cursor=${cursor}` : '') +
            `&size=20`
        );
        const data = await response.json();

        setComments(prev => [...prev, ...data.content]);
        setCursor(data.nextCursor);
        setHasNext(data.hasNext);
        
        const newLoadCount = loadCount + 1;
        setLoadCount(newLoadCount);
        
        // 50개 이상 로드하면 버튼 방식으로 전환
        if (comments.length + data.content.length >= 50) {
            setShowButton(true);
        }
    };

    useEffect(() => {
        loadComments();
    }, []);

    // 무한 스크롤 (50개 미만일 때만)
    useEffect(() => {
        if (showButton) return;

        const observer = new IntersectionObserver(
            entries => {
                if (entries[0].isIntersecting && hasNext) {
                    loadComments();
                }
            },
            { threshold: 1.0 }
        );

        const target = document.querySelector('#scroll-trigger');
        if (target) observer.observe(target);

        return () => observer.disconnect();
    }, [hasNext, showButton]);

    return (
        <div>
            {comments.map(comment => (
                <CommentItem key={comment.commentId} comment={comment} />
            ))}
            
            {!showButton && hasNext && <div id="scroll-trigger" />}
            
            {showButton && hasNext && (
                <button onClick={loadComments}>
                    댓글 더보기 ({comments.length}개 표시됨)
                </button>
            )}
        </div>
    );
}

처음 50개까지는 스크롤만으로 자동 로딩된다. 50개를 넘어가면 더보기 버튼이 나타나고 사용자가 명시적으로 클릭해야 추가 로딩이 된다.

베스트 댓글 + 일반 댓글 혼합 노출

댓글 시스템에서 "베스트 댓글"을 상단에 고정하고 나머지를 최신순으로 보여주는 패턴은 유튜브, 네이버 등에서 사용하는 표준 UX다.

베스트 댓글 DTO

@Getter
@Setter
public class CommentSectionResponseDto {
    private List<CommentResponseDto> bestComments;
    private CursorResponse<CommentResponseDto> recentComments;
}

Service 구현

@Service
@RequiredArgsConstructor
public class CommentService {

    private final CommentMapper commentMapper;

    public CommentSectionResponseDto getCommentsWithBest(
            Long boardId,
            String boardType,
            CursorRequest cursor
    ) {
        // 베스트 댓글 조회 (좋아요 10개 이상, 상위 3개)
        List<CommentResponseDto> bestComments = 
            commentMapper.findBestComments(boardId, boardType, 3, 10);

        // 일반 댓글 조회 (커서 기반)
        List<CommentResponseDto> recentComments = 
            commentMapper.findComments(boardId, boardType, cursor);
        CursorResponse<CommentResponseDto> recentResponse = 
            new CursorResponse<>(recentComments, cursor.getSize());

        CommentSectionResponseDto response = new CommentSectionResponseDto();
        response.setBestComments(bestComments);
        response.setRecentComments(recentResponse);
        
        return response;
    }
}

Mapper XML

<!-- 베스트 댓글 조회 -->
<select id="findBestComments" resultType="kr.or.kosa.backend.comment.dto.CommentResponseDto">
    SELECT
        c.COMMENT_ID,
        c.BOARD_ID,
        c.BOARD_TYPE,
        c.USER_ID,
        u.USER_NICKNAME,
        c.COMMENT_CONTENT,
        c.PARENT_COMMENT_ID,
        c.IS_DELETED,
        c.CREATED_AT,
        (SELECT COUNT(*)
         FROM `LIKE` l
         WHERE l.REFERENCE_ID = c.COMMENT_ID
           AND l.REFERENCE_TYPE = 'COMMENT') AS LIKE_COUNT
    FROM `COMMENT` c
    LEFT JOIN USERS u ON c.USER_ID = u.USER_ID
    WHERE c.BOARD_ID = #{boardId}
      AND c.BOARD_TYPE = #{boardType}
      AND c.PARENT_COMMENT_ID IS NULL
      AND c.IS_DELETED = FALSE
    HAVING LIKE_COUNT >= #{minLikes}
    ORDER BY LIKE_COUNT DESC, c.CREATED_AT DESC
    LIMIT #{limit}
</select>

베스트 댓글은 좋아요 개수 기준으로 정렬하고 일반 댓글은 최신순으로 정렬한다. 두 개의 독립적인 데이터 스트림이다.

딥링크: 특정 댓글로 바로 이동

사용자가 "이 댓글 진짜 웃겨"라며 링크를 공유하는 경우를 처리한다.

https://service.com/posts/1?commentId=999

Controller 확장

@GetMapping
public ResponseEntity<CursorResponse<CommentResponseDto>> getComments(
        @RequestParam Long boardId,
        @RequestParam String boardType,
        @RequestParam(required = false) Long cursor,
        @RequestParam(required = false) Long highlightCommentId,
        @RequestParam(defaultValue = "20") int size
) {
    if (highlightCommentId != null) {
        // 특정 댓글 앵커 모드
        return ResponseEntity.ok(
            commentService.getCommentsWithHighlight(boardId, boardType, highlightCommentId, size)
        );
    }
    
    // 일반 커서 모드
    CursorRequest cursorRequest = new CursorRequest(cursor, size);
    return ResponseEntity.ok(
        commentService.getComments(boardId, boardType, cursorRequest)
    );
}

Service 구현

public CursorResponse<CommentResponseDto> getCommentsWithHighlight(
        Long boardId,
        String boardType,
        Long highlightCommentId,
        int size
) {
    // 하이라이트 댓글 조회
    CommentResponseDto highlight = commentMapper.findById(highlightCommentId);
    
    if (highlight == null) {
        throw new CustomBusinessException(ErrorCode.COMMENT_NOT_FOUND);
    }

    // 하이라이트 댓글 이후의 댓글들 조회
    CursorRequest cursor = new CursorRequest(highlightCommentId, size);
    List<CommentResponseDto> afterComments = 
        commentMapper.findComments(boardId, boardType, cursor);

    // 하이라이트를 맨 앞에 배치
    List<CommentResponseDto> result = new ArrayList<>();
    result.add(highlight);
    result.addAll(afterComments);

    return new CursorResponse<>(result, size);
}

프론트엔드는 highlightCommentId를 받아서 해당 댓글을 스크롤 위치로 이동시키고 하이라이트 효과를 준다.

SQL Injection 방어: 화이트리스트 방식

정렬 기능은 SQL Injection 취약점이 발생하기 쉬운 지점이다.

정렬 방향 Enum

public enum SortDirection {
    ASC("ASC"),
    DESC("DESC");

    private final String sql;

    SortDirection(String sql) {
        this.sql = sql;
    }

    public String getSql() {
        return sql;
    }

    public static SortDirection from(String direction) {
        if (direction == null) {
            return DESC;
        }
        
        for (SortDirection sd : values()) {
            if (sd.name().equalsIgnoreCase(direction)) {
                return sd;
            }
        }
        
        return DESC;
    }
}

정렬 컬럼 Enum

public enum FreeboardSortType {
    CREATED_AT("f.FREEBOARD_CREATED_AT"),
    LIKE_COUNT("LIKE_COUNT"),
    COMMENT_COUNT("COMMENT_COUNT"),
    VIEW_COUNT("f.FREEBOARD_CLICK");

    private final String column;

    FreeboardSortType(String column) {
        this.column = column;
    }

    public String getColumn() {
        return column;
    }

    public static FreeboardSortType from(String sort) {
        if (sort == null) {
            return CREATED_AT;
        }

        for (FreeboardSortType type : values()) {
            if (type.name().equalsIgnoreCase(sort)) {
                return type;
            }
        }

        throw new CustomBusinessException(ErrorCode.INVALID_SORT_TYPE);
    }
}

SortCondition

@Getter
public class SortCondition {

    private final String column;
    private final String directionSql;

    public SortCondition(String safeColumn, SortDirection direction) {
        this.column = safeColumn;
        this.directionSql = direction.getSql();
    }
}

공격자가 ?sort=id; DROP TABLE users-- 를 시도해도 Enum 매칭에 실패해서 예외가 발생한다. SQL 실행 전에 차단된다.

성능 최적화

인덱스 설정

-- 좋아요 서브쿼리 최적화
CREATE INDEX idx_like_reference ON `LIKE` (REFERENCE_TYPE, REFERENCE_ID);

-- 댓글 서브쿼리 최적화
CREATE INDEX idx_comment_board ON `COMMENT` (BOARD_TYPE, BOARD_ID, IS_DELETED);

-- 커서 페이징 최적화
CREATE INDEX idx_comment_cursor ON `COMMENT` (BOARD_ID, BOARD_TYPE, COMMENT_ID DESC);

반정규화 고려 시점

데이터가 10만 건을 넘어가고 서브쿼리 성능이 500ms를 넘으면 반정규화를 고려한다.

ALTER TABLE FREEBOARD ADD COLUMN LIKE_COUNT INT DEFAULT 0;
ALTER TABLE FREEBOARD ADD COLUMN COMMENT_COUNT INT DEFAULT 0;

CREATE INDEX idx_freeboard_like_count ON FREEBOARD(LIKE_COUNT DESC);

좋아요나 댓글이 추가/삭제될 때 카운트를 업데이트한다. 조회 성능은 극대화되지만 쓰기 로직이 복잡해진다.

결론

페이지네이션은 데이터 특성에 따라 다르게 설계해야 한다.

게시글: 오프셋 기반

  • 특정 페이지로 바로 이동
  • 전체 페이지 수 표시
  • 페이지네이션 UI

댓글: 커서 기반

  • 무한 스크롤 (50개 제한)
  • 실시간 데이터 정합성
  • 성능 일정

이 설계의 장점은 명확하다.

  • 유지보수성: 공통 로직이 한곳에 모여 있다
  • 확장성: 새로운 게시판이나 댓글 기능 추가가 쉽다
  • 일관성: 프론트엔드와 백엔드 간의 데이터 규격이 통일된다
  • 안전성: Enum 화이트리스트로 SQL Injection을 원천 차단한다
  • 성능: 각 데이터 특성에 맞는 최적의 방식을 사용한다

처음에는 서브쿼리 방식으로 시작해서 실제 성능 문제가 측정되었을 때 반정규화로 전환하는 것이 합리적이다. 미리 최적화하는 것보다 실제 병목을 찾아서 해결하는 것이 효율적이다.

profile
개발자 소희의 노트

0개의 댓글