본문 바로가기
IT/JAVA&SpringBoot

Spring Boot 3 PageHelper 완벽 가이드: 페이징 처리의 정석 (MyBatis 필수 설정)

by twofootdog 2025. 12. 17.
반응형

수천만 건의 데이터를 다루는 환경에서 효율적인 페이징(Pagination) 처리는 서버 비용 절감과 사용자 경험(UX) 향상에 직접적인 영향을 미칩니다. Java 기반의 엔터프라이즈 환경, 특히 Spring Boot 3 MyBatis를 사용하는 프로젝트에서 가장 강력하고 검증된 페이징 솔루션인 PageHelper의 설정부터 실무 적용 방법까지 상세하게 정리했습니다.

 

 


1. 개발 환경 및 필수 라이브러리 설정 (Gradle 기준)

2025년 현재, Java 생태계의 표준인 JDK 17 이상과 Spring Boot 3.x 버전을 기준으로 설명합니다.

과거 Spring Boot 2.x 시절의 설정을 그대로 가져올 경우 javax와 jakarta 패키지 충돌 문제로 인해 심각한 오류가 발생할 수 있습니다.

가장 먼저 build.gradle 파일에 정확한 의존성을 추가해야 합니다. Spring Boot 3와의 호환성이 완벽하게 검증된 PageHelper Spring Boot Starter 2.x 버전을 사용하는 것이 핵심입니다.

 

build.gradle : 

dependencies {
    // Spring Boot 3.x 호환성을 위해 반드시 1.4.6 이상 또는 2.x 버전을 사용해야 합니다.
    // 2025년 기준 최신 안정화 버전인 2.1.1 사용을 권장합니다.
    implementation 'com.github.pagehelper:pagehelper-spring-boot-starter:2.1.1'

    // MyBatis 연동을 위한 필수 의존성
    implementation 'org.mybatis.spring.boot:mybatis-spring-boot-starter:3.0.3'
    
    // DB 드라이버 (MySQL/MariaDB 예시)
    runtimeOnly 'com.mysql:mysql-connector-j'
    
    // 코드 간소화를 위한 Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
}

 

 

위 설정에서 pagehelper-spring-boot-starter:2.1.1은 Spring Boot 3의 자동 설정(Auto Configuration)을 완벽하게 지원하며, 비동기 Count 쿼리 등 최신 기능을 포함하고 있습니다.   

 

 


2. application.yml 핵심 설정 전략

application.yml (또는 application.properties) 파일에서 PageHelper의 동작 방식을 제어해야 합니다. 아래 설정은 실무에서 가장 안정적으로 동작하는 검증된 구성입니다. 

 

application.yml : 

spring:
  datasource:
    # DB 연결 정보 (환경 변수 분리 권장)
    url: jdbc:mysql://localhost:3306/blog_db?useSSL=false&allowPublicKeyRetrieval=true
    username: ${DB_USER}
    password: ${DB_PASS}
    driver-class-name: com.mysql.cj.jdbc.Driver

# MyBatis 설정
mybatis:
  mapper-locations: classpath:mapper/*.xml  # XML 매퍼 위치
  configuration:
    map-underscore-to-camel-case: true # 스네이크 케이스 -> 카멜 케이스 자동 변환

# (핵심) PageHelper 튜닝 설정
pagehelper:
  # 1. DB 방언(Dialect) 명시
  # 자동 감지 기능이 있지만, 클라우드 환경 등에서의 오작동 방지를 위해 명시하는 것이 안전합니다.
  helper-dialect: mysql 	# oracle, postgresql 등
  
  # 2. 합리적인 페이징 (Reasonable)
  # true 설정 시: pageNum <= 0 이면 1페이지, pageNum > 전체페이지 면 마지막 페이지를 반환합니다.
  # 잘못된 파라미터 요청에도 빈 화면 대신 유효한 데이터를 보여주어 UX를 방어합니다.
  reasonable: true
  
  # 3. 메서드 인자 지원
  # Mapper 인터페이스에서 파라미터로 PageNum, PageSize를 넘길 수 있게 합니다.
  support-methods-arguments: true
  
  # 4. 파라미터 매핑(여기서는 총 레코드 수를 계산하는 SQL 쿼리를 지정합니다)
  params: count=countSql
  
  # 5. 초기 구동 시 로그 배너 비활성화 (운영 환경 로그 깔끔하게 유지)
  banner: false

 

특히 reasonable: true 옵션은 사용자가 URL을 임의로 조작하여 존재하지 않는 페이지를 요청했을 때, 시스템이 에러를 뱉는 대신 마지막 페이지의 데이터를 보여줌으로써 자연스러운 사용자 경험을 유도하는 중요한 설정입니다.   


3. Java 설정 및 구현: Controller부터 Mapper까지

이제 실제 코드로 페이징을 구현하는 단계입니다. Spring Boot의 전형적인 레이어드 아키텍처(Layered Architecture) 안에서 PageHelper가 어떻게 녹아드는지 확인합니다.

3-1) DTO (Data Transfer Object) 정의

데이터를 운반할 객체입니다. Lombok을 활용하여 불변 객체 패턴으로 설계합니다.

 

Java : 

package com.richblogger.blog.model;

import lombok.Builder;
import lombok.Getter;
import lombok.ToString;
import java.time.LocalDateTime;

@Getter
@Builder
@ToString
public class PostDto {
    private Long id;
    private String title;
    private String content;
    private String writer;
    private int viewCount;
    private LocalDateTime createdAt;
}

 

3-2) Mapper Interface (Repository)

SQL 쿼리와 매핑되는 인터페이스입니다. 여기서 주목할 점은 페이징을 위한 별도의 limit, offset 파라미터를 받지 않는다는 것입니다. PageHelper가 실행 시점에 쿼리를 가로채어 자동으로 페이징 구문을 주입하기 때문입니다. 

 

Java : 

package com.richblogger.blog.mapper;

import com.richblogger.blog.model.PostDto;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
import java.util.List;

@Mapper
public interface PostMapper {
    /**
     * 전체 게시글 조회
     * PageHelper가 이 메서드 호출 시 실행되는 SQL을 인터셉트하여 
     * LIMIT?,? 구문을 자동으로 추가합니다.
     */
    @Select("SELECT * FROM posts ORDER BY id DESC")
    List<PostDto> findAll();
}



3-3) Service Layer (비즈니스 로직)

가장 중요한 부분입니다. PageHelper.startPage() 메서드는 ThreadLocal을 사용합니다. 즉, 이 메서드가 호출된 직후의 첫 번째 MyBatis 조회 쿼리에만 페이징이 적용됩니다. 따라서 startPage 호출과 쿼리 실행 사이에는 다른 로직이 개입되지 않도록 주의해야 합니다.   

 

Java : 

package com.richblogger.blog.service;

import com.github.pagehelper.PageHelper;
import com.github.pagehelper.PageInfo;
import com.richblogger.blog.mapper.PostMapper;
import com.richblogger.blog.model.PostDto;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostMapper postMapper;

    @Transactional(readOnly = true)
    public PageInfo<PostDto> getPostList(int pageNum, int pageSize) {
        // 1. 페이징 시작 선언
        // 파라미터: (현재 페이지 번호, 페이지 당 개수)
        PageHelper.startPage(pageNum, pageSize);
        
        // 2. 데이터 조회
        // 겉보기엔 전체 데이터를 가져오는 것 같지만, 실제 실행되는 쿼리는
        // 'SELECT count(0)...' 과 'SELECT... LIMIT...' 두 개가 실행됩니다.
        List<PostDto> posts = postMapper.findAll();
        
        // 3. PageInfo 객체로 포장
        // 조회된 데이터(List)와 페이징 정보(전체 페이지 수, 이전/다음 페이지 유무 등)를 
        // 포함하여 반환합니다. 생성자의 두 번째 인자는 네비게이션 버튼 개수입니다.
        return new PageInfo<>(posts, 10);
    }
}



3-4) Controller Layer (API 엔드포인트)

클라이언트의 요청을 받아 Service를 호출하고 결과를 반환합니다.

Java : 

package com.richblogger.blog.controller;

import com.github.pagehelper.PageInfo;
import com.richblogger.blog.model.PostDto;
import com.richblogger.blog.service.PostService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @GetMapping("/api/posts")
    public PageInfo<PostDto> getPosts(
            @RequestParam(defaultValue = "1") int page,
            @RequestParam(defaultValue = "10") int size) {
        
        // 깔끔하게 PageInfo 객체 자체를 반환하면 
        // JSON으로 직렬화되어 클라이언트에게 전달됩니다.
        return postService.getPostList(page, size);
    }
}


4. 주의사항

4-1) Count 쿼리의 함정과 최적화 (The Count Query Trap)

PageHelper는 페이징을 위해 자동으로 전체 데이터 개수를 세는 SELECT count(0) FROM... 쿼리를 먼저 실행합니다. 데이터가 수천 건일 때는 문제가 없지만, 수백만, 수천만 건이 넘어가면 이 count 쿼리 자체가 엄청난 부하를 일으키며 성능의 병목(Bottleneck)이 됩니다.

PageHelper는 이를 위해 커스텀 Count 쿼리 기능을 제공합니다. 복잡한 조인(Join)이 포함된 목록 조회 쿼리에서 count를 구할 때는 조인을 제거하거나 인덱스를 타는 단순한 쿼리로 대체할 수 있습니다.

 

사용법: 원본 쿼리 ID가 selectPosts라면, selectPosts_COUNT라는 ID로 쿼리를 하나 더 만듭니다. PageHelper는 이를 자동으로 감지하여 원본 쿼리를 변형하는 대신, 우리가 작성한 최적화된 SQL을 실행합니다.

SQL Mapper XML : 

<select id="selectPosts" resultType="BlogPost">
    SELECT p.*, u.username, c.category_name 
    FROM blog_post p
    LEFT JOIN users u ON p.user_id = u.id
    LEFT JOIN category c ON p.category_id = c.id
    WHERE p.status = 'PUBLISHED'
</select>

<select id="selectPosts_COUNT" resultType="Long">
    SELECT count(p.id) FROM blog_post p WHERE p.status = 'PUBLISHED'
</select>

 

이렇게 하면 목록 조회 속도는 유지하면서 전체 건수 조회 성능을 수십 배 향상시킬 수 있습니다.

 

4-2) 대용량 페이징 (Deep Pagination) 문제 해결

사용자가 "마지막 페이지" 버튼을 눌러서 page=100000을 요청한다면 어떻게 될까요? 데이터베이스는 OFFSET 1000000 LIMIT 10을 수행하기 위해 앞의 100만 건의 데이터를 읽고 버려야 합니다. 이를 'Deep Pagination' 문제라고 하며, DB CPU 사용률을 급증시키는 주범입니다.

이때는 PageHelper에만 의존하지 말고, No-Offset 방식(마지막 조회된 ID를 조건으로 거는 방식, 예: WHERE id < lastSeenId)으로 전환하거나, 커버링 인덱스(Covering Index)를 활용한 서브쿼리 튜닝이 필요합니다. PageHelper는 훌륭한 도구이지만, 데이터베이스의 물리적 한계를 마법처럼 해결해주지는 못한다는 점을 항상 명심하세요.

 

No-Offset 방식에서는 '몇 페이지(pageNumber)'가 아니라 '마지막으로 본 ID(lastSeenId)'가 핵심입니다.

PageHelper의 자동 LIMIT/OFFSET 기능을 쓰지 않고, 직접 SQL의 WHERE 절을 제어합니다. lastId가 없는 경우(첫 페이지)와 있는 경우(두 번째 페이지 이후)를 동적 쿼리로 처리합니다.

XML : 
<mapper namespace="com.richblogger.blog.mapper.PostMapper">

    <select id="findByNoOffset" resultType="com.richblogger.blog.model.PostDto">
        SELECT * 
        FROM posts
        WHERE 1=1
        <if test="lastSeenId!= null">
            AND id &lt; #{lastSeenId}
        </if>
        ORDER BY id DESC
        LIMIT #{pageSize}
    </select>

</mapper>

주의: &lt;는 < (less than)의 XML 이스케이프 문자입니다.

 
Java(Mapper Interface) : 
@Mapper
public interface PostMapper {
    // PageHelper 없이 직접 파라미터로 제어합니다.
    List<PostDto> findByNoOffset(@Param("lasSeentId") Long lasSeentId, @Param("pageSize") int pageSize);
}

 

 

No-Offset 방식은 주로 '무한 스크롤(Infinite Scroll)' UI(모바일 기기에서 목록 조회)에서 사용됩니다. 기존 PageInfo 대신 Slice 형태의 응답을 구성하는 것이 좋습니다.

Java(Service Layer : 무한 스크롤 로직) : 

@Service
@RequiredArgsConstructor
public class PostService {

    private final PostMapper postMapper;

    /**
     * No-Offset 방식 조회
     * @param lastSeenId 클라이언트가 가지고 있는 마지막 게시글 ID (첫 요청 시 null)
     * @param size 조회할 개수
     */
    @Transactional(readOnly = true)
    public List<PostDto> getPostsInfinite(Long lastSeenId, int size) {
        // PageHelper.startPage()를 호출하지 않습니다! 
        // 직접 작성한 고성능 SQL을 실행합니다.
        return postMapper.findByNoOffset(lastSeenId, size);
    }
}

 

성능 비교 (1,000만 건 기준)

방식 쿼리 예시 100만 번째 페이지 조회 속도
기존 PageHelper LIMIT 10000000, 10 약 3~5초 (느림) 
No-Offset WHERE id < 5000 AND... LIMIT 10 약 0.01초 (매우 빠름)

 

 

4-3) Spring Boot 3 마이그레이션 이슈

Spring Boot 3.0 이상으로 넘어오면서 기존 javax.servlet.* 패키지가 jakarta.servlet.*으로 변경되었습니다. 만약 PageHelper의 구버전(1.4.6 미만)을 사용할 경우 ClassNotFoundException이나 NoClassDefFoundError 같은 런타임 오류가 발생할 수 있습니다.

반드시 위에서 언급한 2.x.x 버전의 스타터를 사용하여 이러한 호환성 문제를 사전에 차단해야 합니다. 또한 프로젝트 내에 javax 의존성이 남아있는지 Gradle의 dependencies 태스크를 통해 꼼꼼히 확인하는 과정이 필요합니다.   

 


참고 자료

  1. PageHelper 공식 GitHub 저장소: 소스 코드와 이슈 트래커
  2. (https://mvnrepository.com/artifact/com.github.pagehelper/pagehelper-spring-boot-starter): 버전별 변경 사항과 다운로드 통계
  3. (https://spring.io/projects/spring-boot): Spring Boot의 시스템 요구사항 및 호환성 가이드
  4. (https://mybatis.org/spring-boot-starter/mybatis-spring-boot-autoconfigure/): MyBatis와 Spring Boot의 통합 설정에 대한 상세 문서
  5. (https://www.baeldung.com/spring-boot-yaml-vs-properties): Spring Boot 설정 파일 관리 및 페이징 기초에 대한 좋은 튜토리얼
반응형

댓글