회원이 1,000명인 게시판을 떠올려 보자. 한 화면에 1,000명을 다 띄우지 않고 10명씩 끊어 「1페이지, 2페이지」로 보여 준다. 이렇게 나눠 가져오는 것이 페이징이다. 화면 아래에 「총 150건」이나 페이지 번호 버튼을 그리려면 이번 페이지의 10명과 조건에 맞는 전체 건수 두 가지가 필요하다.

MyBatis로 이걸 만들 때 흔히 이렇게 짠다.

// 데이터 조회 (1번)
List<User> userList = userMapper.selectUserList(offset, pageSize);

// 전체 개수 조회 (1번)
int totalCount = userMapper.selectUserCount();

return new PageResponse(userList, totalCount);
  • userMapper는 MyBatis의 Mapper 인터페이스 객체다. 메서드 하나를 부를 때마다 짝이 된 SQL 하나가 DB(데이터베이스)로 간다. 아래 「방식 1」에서 그 짝(XML)을 보여 준다
  • offset은 앞에서 몇 건을 건너뛸지, pageSize는 한 페이지에 몇 건을 담을지다. 1페이지면 0건을 건너뛰고 10건, 2페이지면 10건을 건너뛰고 10건이다
  • selectUserList가 이번 페이지의 목록을, selectUserCount가 전체 건수를 가져온다
  • PageResponse는 목록과 건수를 한 상자에 담아 화면 쪽에 돌려주려고 내가 만든 DTO다. 필드는 아래 「응답 JSON」 모양과 같다
  • User는 회원 한 명을 담는 클래스다. 아래 코드부터는 같은 역할을 UserDTO라는 이름으로 쓴다. 필드는 id, name, email, status, createdAt 다섯 개다

메서드를 두 번 불렀으니 데이터베이스에 2번 접근한다. 나는 이게 최선인지가 궁금했다. 이 글은 그 두 번을 한 번으로 줄이는 방법, 그리고 전체 건수를 아예 안 세는 방법을 코드와 함께 비교한다.


목록과 개수를 따로 묻는 기존 방식은 같은 일을 두 번 한다

쿼리를 두 번 보내면 왕복과 조건 처리가 두 번씩 든다

자바 메서드 두 번은 DB에 이런 쿼리 두 개가 된다.

쿼리 1: SELECT * FROM users LIMIT 10 OFFSET 0;
쿼리 2: SELECT COUNT(*) FROM users;
  • 쿼리 1은 users 테이블에서 모든 컬럼(*)을 가져오되, LIMIT · OFFSET으로 앞에서 0건을 건너뛰고 10건만 가져온다
  • 쿼리 2는 조건에 맞는 행이 몇 개인지만 센다. 이게 COUNT 쿼리다

여기서 「왕복」은 내 자바 프로그램이 DB로 쿼리를 보내고 결과가 돌아올 때까지의 한 바퀴다. 쿼리가 둘이면 이 바퀴를 두 번 돈다.

단순해 보이지만 숨은 비용들:

  • WHERE 조건이 2번 평가됨: 「이 행이 조건에 맞나」를 행마다 따지는 일을 두 쿼리가 각각 한다
  • 인덱스 스캔 2회: 인덱스를 따라가며 맞는 행을 찾는 일을 각 쿼리가 따로 한다
  • 쿼리 파싱/컴파일 2회: DB가 받은 SQL 글자를 읽어 어떻게 실행할지 정하는 준비 작업을 두 쿼리 문자열에 각각 한다
  • 트랜잭션/락 오버헤드: 두 쿼리가 각자 독립적으로 실행되므로, DB가 쿼리 하나를 실행할 때마다 붙이는 시작과 마무리 처리도 두 번이다
  • 데이터 정합성: 목록과 건수가 서로 맞아야 하는데 어긋날 수 있다. 쿼리 1이 끝난 직후 다른 사용자가 회원 가입을 하면, 쿼리 2는 그 한 명까지 센다. 목록은 가입 전 기준이고 건수는 가입 후 기준이 된다

특히 복잡한 WHERE 조건이 있을 때 이 비용은 더 커진다. 같은 복잡한 조건을 두 번 따지기 때문이다.


세 가지 방식은 쿼리 수와 전체 건수 제공 여부가 다르다

방식 1: 기존 방식은 목록 쿼리와 개수 쿼리를 따로 둔다

MyBatis는 SQL을 자바 코드가 아니라 XML 파일에 적는다. 이 파일이 Mapper XML이다. 자바 쪽에는 메서드 선언만 있는 인터페이스를 둔다.

// 자바 쪽 — 구현 클래스는 내가 쓰지 않는다. MyBatis가 만든다
package com.example;

public interface UserMapper {
    List<UserDTO> selectUserList(@Param("status") String status,
                                 @Param("keyword") String keyword,
                                 @Param("offset") int offset,
                                 @Param("pageSize") int pageSize);

    int selectUserCount(@Param("status") String status,
                        @Param("keyword") String keyword);
}
<!-- XML 쪽 — 아래 select 태그들은 이 mapper 태그 안에 들어간다 -->
<mapper namespace="com.example.UserMapper">
  ...
</mapper>

XML의 namespace에 인터페이스의 전체 이름(패키지 이름까지 붙인 com.example.UserMapper)을 적고 <select> 태그의 id를 메서드 이름과 같게 두면, 그 메서드를 부를 때 이 SQL이 나간다. 인자 앞의 @Param("status")는 애너테이션이다. 여기서는 MyBatis가 이 표시를 읽고 「이 값을 status라는 이름표로 XML에 넘긴다」로 처리한다.

아래가 select 태그들이다.

MyBatis Mapper XML:

<!-- 데이터 조회 -->
<select id="selectUserList" parameterType="map" resultType="UserDTO">
  SELECT id, name, email, status, created_at
  FROM users
  WHERE status = #{status}
  <if test="keyword != null">
    AND name LIKE CONCAT('%', #{keyword}, '%')
  </if>
  ORDER BY id DESC
  LIMIT #{pageSize} OFFSET #{offset}
</select>

<!-- 전체 개수 조회 -->
<select id="selectUserCount" parameterType="map" resultType="int">
  SELECT COUNT(*) 
  FROM users
  WHERE status = #{status}
  <if test="keyword != null">
    AND name LIKE CONCAT('%', #{keyword}, '%')
  </if>
</select>

한 줄씩 따라가면 이렇다.

  • <!-- ... -->는 XML의 주석이다. 실행되지 않는다
  • id="selectUserList"는 짝이 될 자바 메서드 이름이다. parameterType="map"은 자바에서 넘어온 값들을 이름표가 붙은 묶음(map)으로 받는다는 뜻이고, resultType="UserDTO"는 결과 한 행을 UserDTO 객체 하나에 담으라는 뜻이다. MyBatis는 결과의 컬럼 이름과 클래스의 필드 이름을 대소문자 구분 없이 맞춰서 값을 넣는다
  • FROM users는 users 테이블에서 가져온다는 뜻이다
  • #{status}는 자바에서 넘긴 status 값이 들어갈 자리다. MyBatis는 이 자리를 ?로 바꾼 SQL을 만들고 값은 따로 실어 보낸다. 값을 SQL 글자에 직접 이어 붙이지 않으니, 사용자가 넣은 검색어가 SQL 명령으로 바뀌어 실행되는 SQL 주입이 막힌다
  • <if test="keyword != null">는 SQL이 만들어질 때 한 번 따진다. 검색어가 있으면 안쪽 AND ... 줄이 SQL에 붙고, 없으면 그 줄이 통째로 빠진다
  • name LIKE CONCAT('%', #{keyword}, '%')는 이름에 검색어가 들어 있는 행을 고른다. CONCAT은 글자를 이어 붙여 %홍% 같은 모양을 만들고, LIKE에서 %는 「글자 몇 개든(0개 포함)」을 뜻한다. 그래서 「홍」이 이름 어디에든 들어 있으면 맞는다
  • ORDER BY id DESC는 ORDER BY로 id가 큰 것부터(내림차순, DESC) 줄 세운다. 최근에 가입한 회원이 먼저 온다
  • LIMIT #{pageSize} OFFSET #{offset}은 offset건을 건너뛰고 pageSize건을 가져온다
  • 두 번째 <select>는 같은 조건으로 개수만 센다. 결과가 숫자 하나라서 resultType="int"다

Java Service:

public PageResponse<UserDTO> getUserPage(String status, String keyword, int page, int pageSize) {
    int offset = (page - 1) * pageSize;
    
    List<UserDTO> list = userMapper.selectUserList(status, keyword, offset, pageSize);
    int totalCount = userMapper.selectUserCount(status, keyword);  // 2번 쿼리
    
    return new PageResponse<>(list, totalCount, page, pageSize);
}

Service는 화면 요청을 받아 실제 일을 처리하는 클래스이고, Mapper를 불러 DB 일을 시킨다.

  • int offset = (page - 1) * pageSize; — 페이지 번호를 건너뛸 건수로 바꾼다. 3페이지, 10건씩이면 (3 - 1) * 10 = 20건을 건너뛴다
  • 다음 두 줄이 Mapper 메서드를 두 번 부른다. 곧 쿼리 두 개다
  • 마지막 줄이 목록 · 건수 · 페이지 번호 · 페이지 크기를 한 상자에 담아 돌려준다. PageResponse의 건수 칸은 long이라고 보면 된다. int를 넘기면 자바가 알아서 long으로 넓혀 넣는다

도입부 코드와 달리 여기서는 인자가 네 개다. 도입부는 모양만 보이려고 줄인 것이고, 검색 조건(status, keyword)까지 넘기는 이쪽이 실제 모양이다. 인자가 둘 이상이면 MyBatis는 그 값들을 이름표 묶음(map)에 담아 XML로 넘긴다. 이름표는 위 인터페이스처럼 @Param으로 정해 둔다. 그래야 XML의 #{status}가 그 값을 찾는다.

장점 단점
로직이 간단하고 이해하기 쉬움 DB 왕복 2회
대부분의 프레임워크에서 지원 WHERE 조건 2회 평가
코드 복잡도 낮음 쿼리 파싱 오버헤드
  데이터 정합성 위험

「대부분의 프레임워크에서 지원」에서 프레임워크는 Spring처럼 프로그램의 뼈대를 대 주는 큰 라이브러리다. 이 말은 목록 쿼리와 COUNT 쿼리를 따로 보내는 모양이 가장 흔해서, 페이징을 도와주는 도구들이 대개 이 모양을 기본으로 삼는다는 뜻이다.


방식 2: Window 함수는 목록 쿼리 하나에 전체 건수를 붙여 온다 (추천)

Window 함수는 행 수를 줄이지 않고 각 행 옆에 계산 결과를 붙여 주는 SQL 함수다. 이 글에서 필요한 건 딱 하나, 목록 10건 각각의 옆에 「전체 건수」를 붙여 받는 것이다. 그러면 개수를 묻는 쿼리를 따로 보낼 필요가 없다.

기본 개념:

-- 일반 COUNT: 그룹으로 집계 (행 감소)
SELECT status, COUNT(*) as total FROM users GROUP BY status;

-- Window 함수 COUNT: 각 행에 전체 개수 추가 (행 유지)
SELECT id, name, status, COUNT(*) OVER() as total FROM users;
  • 첫 줄은 GROUP BY로 상태가 같은 회원끼리 묶어 묶음마다 한 줄로 줄인다. 회원이 1,000명이어도 상태가 세 가지면 결과는 세 줄이다. as total은 계산한 값의 컬럼 이름을 total로 붙이는 것이다
  • 둘째 줄은 회원 1,000명이 1,000줄 그대로 나오고, 줄마다 total 칸에 1,000이 붙는다. OVER()의 괄호가 비어 있으면 MariaDB 공식 문서 말대로 「결과 전체」를 범위로 센다

MyBatis Mapper XML:

<select id="selectUserPageWithTotal" parameterType="map" resultType="UserPageDTO">
  SELECT 
    id, name, email, status, created_at,
    COUNT(*) OVER() as totalCount
  FROM users
  WHERE status = #{status}
  <if test="keyword != null">
    AND name LIKE CONCAT('%', #{keyword}, '%')
  </if>
  ORDER BY id DESC
  LIMIT #{pageSize} OFFSET #{offset}
</select>

방식 1의 목록 쿼리와 거의 같고, COUNT(*) OVER() as totalCount 한 줄만 늘었다. 이 값은 LIMIT으로 10건을 자르기 전, WHERE를 통과한 행 전체를 센 값이다. 그래서 10건만 돌아와도 각 행의 totalCount에는 150 같은 전체 건수가 들어 있다. 결과는 UserPageDTO에 담긴다.

Java DTO:

public class UserPageDTO {
    private Long id;
    private String name;
    private String email;
    private String status;
    private LocalDateTime createdAt;
    private Long totalCount;  // Window 함수에서 온 값
    
    // getter, setter...
}
  • 필드 하나가 결과 컬럼 하나다. SQL에서 as totalCount로 이름을 붙였기 때문에 totalCount 필드에 들어간다
  • created_at은 이름이 createdAt과 다르다(밑줄이 있다). MyBatis 설정 파일의 <settings>에 mapUnderscoreToCamelCase를 true로 켜야 밑줄을 빼고 맞춰 준다(camelCase는 createdAt처럼 단어마다 첫 글자를 대문자로 이어 쓰는 이름 짓기다). 이 설정은 기본값이 꺼짐이라, 켜지 않으면 createdAt은 비어 있게 된다. 방식 1의 UserDTO도 created_at을 받으니 같은 설정이 필요하다
  • LocalDateTime은 날짜와 시각을 함께 담는 자바 클래스다
  • Long은 기본형 long을 객체로 감싼 래퍼 클래스다

Java Service:

public PageResponse<UserDTO> getUserPage(String status, String keyword, int page, int pageSize) {
    int offset = (page - 1) * pageSize;
    
    List<UserPageDTO> result = userMapper.selectUserPageWithTotal(status, keyword, offset, pageSize);
    
    // 첫 번째 행에서 totalCount 추출
    long totalCount = result.isEmpty() ? 0 : result.get(0).getTotalCount();
    
    // DTO를 응답 DTO로 변환
    List<UserDTO> list = result.stream()
        .map(dto -> new UserDTO(
            dto.getId(), 
            dto.getName(), 
            dto.getEmail(),
            dto.getStatus(),
            dto.getCreatedAt()
        ))
        .collect(Collectors.toList());
    
    return new PageResponse<>(list, totalCount, page, pageSize);
}
  • Mapper 메서드를 한 번만 부른다. 쿼리도 하나다
  • long totalCount = result.isEmpty() ? 0 : ... — 삼항 연산자다. 결과가 비었으면 0, 아니면 첫 행의 totalCount를 쓴다. 모든 행에 같은 값이 붙어 있으니 첫 행 하나만 보면 된다. 비었을 때를 먼저 거르는 건 빈 목록에서 get(0)을 하면 예외가 나기 때문이다. Long으로 받은 값은 long 변수에 넣을 때 자바가 알아서 풀어 준다
  • 다음 덩어리는 UserPageDTO 목록을 UserDTO 목록으로 바꾼다. 화면 쪽은 회원 한 명마다 totalCount를 받을 필요가 없어서, 그 칸을 뺀 UserDTO로 옮겨 담는다. stream()은 스트림으로 목록을 하나씩 흘려보내고, map(dto -> new UserDTO(...))는 람다로 「dto 하나를 받아 UserDTO 하나를 만든다」를 적은 것이며, collect(Collectors.toList())는 그 결과를 다시 리스트로 모은다. for 문으로 하나씩 옮겨 담는 것과 같은 일이다
  • 마지막 줄은 방식 1과 같다

응답 JSON:

{
  "data": [
    {"id": 1, "name": "홍길동", "email": "hong@example.com"},
    {"id": 2, "name": "김철수", "email": "kim@example.com"},
    ...
  ],
  "totalCount": 150,
  "pageNumber": 1,
  "pageSize": 10
}

PageResponse 객체가 화면 쪽(브라우저나 앱)으로 나갈 때는 이런 JSON 글자로 바뀐다. 이 바꾸는 일은 Spring이 대신 한다. PageResponse의 필드 이름이 data, totalCount, pageNumber, pageSize라고 보면 된다. 가운데 ...는 실제 문법이 아니라 나머지 8건을 줄였다는 표시다. 회원 한 명도 status, createdAt을 빼고 세 칸만 적었다. 지면을 아끼려고 줄인 것이고 실제로는 다섯 칸이 다 나간다.

장점 단점
DB 왕복 1회 totalCount가 각 행에 중복
WHERE 조건 1회 평가 DTO 복잡도 증가
쿼리 파싱 1회 MariaDB 10.2+ 필요
데이터 정합성 우수 매퍼 로직 복잡도 증가

「DTO 복잡도 증가」는 totalCount 칸이 붙은 UserPageDTO를 따로 만들고 UserDTO로 옮겨 담는 단계가 하나 늘어난다는 뜻이다. 「매퍼 로직 복잡도 증가」는 SQL에 Window 함수 줄이 들어가고, 첫 행에서 건수를 꺼내는 코드가 생긴다는 뜻이다. 「데이터 정합성 우수」는 목록과 건수가 한 쿼리에서 같이 나오므로 그 사이에 누가 가입해서 어긋나는 일이 없다는 뜻이다.

MariaDB Window 함수 지원: MariaDB 10.2.0부터 지원


방식 3: 다음 페이지가 있는지만 보는 방식은 전체 건수를 세지 않는다 (무한 스크롤에 맞다)

전체 개수를 정확히 알 필요가 없다면, 다음 페이지 존재 여부만 판단하는 방식도 있다. 무한 스크롤 화면이 그렇다. 화면을 끝까지 내리면 다음 묶음을 불러올 뿐 「총 몇 건」은 안 그린다.

아이디어: 10건이 필요하면 11건을 달라고 한다. 11건이 오면 뒤에 더 있다는 뜻이고, 10건 이하가 오면 이게 마지막이다.

이 방식을 이 글에서는 「Cursor 방식」이라고 불렀다. 커서는 「마지막으로 본 행 다음부터」를 가리키는 값인데, 커서 페이징도 다음이 있는지를 이 +1 요령으로 알아낸다. 다만 아래 예제는 이해하기 쉽게 OFFSET과 page를 그대로 두었다. 실제 커서 페이징은 OFFSET 자리에 「마지막으로 본 행의 정렬 값과 id」를 조건으로 넣는다. 그러면 page 번호가 사라지고, 그래서 아래 표의 「페이지 점프 불가능」이 생긴다. 커서를 어떻게 만들고 주고받는지는 페이징 전략 표준 원칙 3에 있다.

MyBatis Mapper XML:

<select id="selectUserPageWithCursor" parameterType="map" resultType="UserDTO">
  SELECT id, name, email, status, created_at
  FROM users
  WHERE status = #{status}
  <if test="keyword != null">
    AND name LIKE CONCAT('%', #{keyword}, '%')
  </if>
  ORDER BY id DESC
  LIMIT #{pageSize}  <!-- Service가 이미 pageSize + 1을 넘긴다 -->
  OFFSET #{offset}
</select>

방식 1의 목록 쿼리와 같다. 달라지는 건 pageSize 자리에 들어오는 값뿐이다. 아래 Service가 pageSize + 1을 넘기므로, 10건씩이면 여기에는 11이 들어온다. XML에서 또 1을 더하지 않는다.

Java Service:

public CursorPageResponse<UserDTO> getUserPage(String status, String keyword, int page, int pageSize) {
    int offset = (page - 1) * pageSize;
    
    // LIMIT을 pageSize + 1로 설정
    List<UserDTO> result = userMapper.selectUserPageWithCursor(
        status, keyword, offset, pageSize + 1
    );
    
    // 다음 페이지 존재 여부 판단
    boolean hasNextPage = result.size() > pageSize;
    
    // pageSize개만 응답
    List<UserDTO> pageList = result.size() > pageSize 
        ? result.subList(0, pageSize) 
        : result;
    
    return new CursorPageResponse<>(pageList, hasNextPage, page, pageSize);
}
  • offset 계산은 방식 1과 같다
  • Mapper에 pageSize + 1(10건씩이면 11)을 넘긴다
  • hasNextPage는 받은 건수가 10보다 많으면, 곧 11건이 왔으면 true다
  • 11건이 왔으면 subList(0, pageSize)로 앞에서 10건만 잘라 낸다. subList(0, 10)은 0번부터 9번까지의 원소를 담은 리스트를 돌려준다. 10건 이하가 왔으면 그대로 쓴다. 11번째는 「뒤에 더 있다」를 알려 주는 용도라 화면에 보내지 않는다
  • CursorPageResponse는 totalCount 대신 hasNextPage를 담는 응답 DTO다. 모양은 아래 JSON과 같다

응답 JSON:

{
  "data": [
    {"id": 1, "name": "홍길동", "email": "hong@example.com"},
    {"id": 2, "name": "김철수", "email": "kim@example.com"},
    ...
    (10개만 포함)
  ],
  "hasNextPage": true,
  "pageNumber": 1,
  "pageSize": 10
}

...와 (10개만 포함)은 앞 JSON처럼 설명을 위해 적은 것이지 JSON 문법이 아니다. totalCount가 없고 hasNextPage가 있다. 화면은 이 값이 true면 스크롤을 끝까지 내렸을 때 다음 묶음을 부른다.

장점 단점
DB 쿼리 1회 전체 개수 정보 제공 불가
데이터 중복 없음 페이지 점프 불가능
네트워크 효율 최고 “총 150건 중 30-40건 보기” 표시 불가
무한스크롤에 최적 응답 구조 변경 필요

「네트워크 효율 최고」에서 네트워크는 DB와 내 프로그램, 내 프로그램과 화면 사이를 잇는 통신선이다. 그 선으로 실어 보내는 데이터가 가장 적다는 뜻이다. 방식 2처럼 행마다 totalCount가 붙지 않고, 방식 1처럼 개수 쿼리를 따로 보내지도 않는다.


세 방식을 한 표로 견주면 화면이 무엇을 요구하느냐로 갈린다

항목 기존 방식 Window 함수 Cursor 방식
DB 쿼리 수 2개 1개 (나음) 1개 (나음)
WHERE 평가 2회 1회 (나음) 1회 (나음)
데이터 중복 없음 있음 (totalCount) 없음
전체 개수 표시 가능 가능 불가능
페이지 점프 가능 가능 불가능
무한스크롤 비효율 비효율 최적
코드 복잡도 낮음 중간 낮음
마리아DB 지원 모든 버전 10.2+ 모든 버전

무한 스크롤에서 기존 방식과 Window 함수가 「비효율」인 까닭은, 무한 스크롤 화면은 전체 건수를 그리지 않는데 두 방식은 그 건수를 매번 세기 때문이다. 쓰지 않을 값을 계산하는 셈이다.


Window 함수가 비용을 줄이는 곳은 WHERE 평가와 왕복 횟수다

Window 함수는 같은 조건 처리를 한 번으로 합친다

아래는 두 방식에서 DB가 하는 일을 순서대로 적은 것이다. 이 순서는 내가 쿼리 모양에서 읽어 낸 흐름이다. WHERE로 행을 고르고, 정렬하고, 마지막에 LIMIT으로 자른다. Window 함수는 WHERE 다음에 계산된다고 MariaDB 문서에 적혀 있다. 그리고 COUNT가 LIMIT보다 뒤에 돌았다면 totalCount는 150이 아니라 10이 되었을 것이다. 전체 건수가 나오는 건 COUNT가 LIMIT보다 먼저 돌기 때문이다. 「WHERE 조건 적용 → 인덱스 스캔」은 앞뒤로 따로 일어나는 두 일이 아니라, WHERE 조건으로 인덱스를 따라가며 맞는 행을 찾는 한 덩어리를 두 이름으로 적은 것이다.

기존 방식:

쿼리 1: WHERE 조건 적용 → 인덱스 스캔 → 정렬 → LIMIT/OFFSET
쿼리 2: WHERE 조건 적용 → 인덱스 스캔 → COUNT
─────────────────────────────────────────────────
비용: WHERE 2회, 인덱스 스캔 2회, 쿼리 파싱 2회

Window 함수 방식:

쿼리 1: WHERE 조건 적용 → 인덱스 스캔 → 정렬 → COUNT() OVER() → LIMIT/OFFSET
─────────────────────────────────────────────────────────────
비용: WHERE 1회, 인덱스 스캔 1회, 쿼리 파싱 1회

개선 효과:

  • WHERE 조건 평가 횟수 50% 감소
  • 인덱스 스캔 횟수 50% 감소
  • DB 왕복 횟수 50% 감소
  • 쿼리 파싱 오버헤드 50% 감소

이 네 줄은 시간을 재 본 값이 아니라 횟수를 센 값이다(2회가 1회가 됐다). 실제로 얼마나 빨라지는지는 재 보지 않았다. 그리고 Window 함수도 WHERE를 통과한 행을 전부 센다는 점은 같다. 아래 「주의사항」에 그 이야기를 적었다.


페이지 번호로 바로 뛰어야 하면 Window 함수, 다음으로만 가면 Cursor 방식을 쓴다

기준은 임의의 페이지로 바로 뛸 수 있어야 하는가 하나다

기준은 하나다 — 사용자가 임의의 페이지로 바로 뛸 수 있어야 하는가.

  • Window 함수(offset) 방식: 5페이지에서 27페이지로 바로 가는 버튼이 있는 화면
  • Cursor 방식: 다음으로만 이어 가는 화면 (무한 스크롤, 「더 보기」)

「offset 방식」은 페이지 번호를 OFFSET(건너뛸 건수)으로 바꿔서 원하는 페이지를 가져오는 방식을 통틀어 부르는 이름이다. 방식 1도 여기에 들고, 그중 쿼리가 하나인 Window 함수를 고른다는 뜻이다. 방식 3의 예제도 OFFSET을 쓰지만, 앞에서 말했듯 실제 커서 페이징에서는 OFFSET이 사라진다.

점프는 page 번호가 있어야만 가능하고 커서는 원리적으로 못 한다. 커서는 「마지막으로 본 행 다음부터」만 말할 수 있어서 「27페이지」를 표현할 수단이 없다. 그래서 이 기준은 우연히 맞는 게 아니라 정의상 맞다.

「관리자 페이지면 offset, 모바일 앱이면 커서」로 가르지 않는다. 그 기준은 대체로 맞아 보이지만 관리자 화면에 무한 스크롤이 하나 생기는 순간 깨진다. 「관리자」는 권한 개념이지 페이징 특성이 아니라서, 그 기준과 페이징 타입(응답을 PageResponse처럼 전체 건수가 있는 타입으로 주는가, CursorPageResponse처럼 다음 여부만 있는 타입으로 주는가)이 우연히 맞아 있었을 뿐이다. 「전체 건수를 화면에 표시하는가」도 기준이 못 된다 — 「총 1,234건」만 띄우고 스크롤은 무한인 화면이 성립하기 때문이다.

버린 안들과 그 근거는 페이징 전략 표준에 정리되어 있다.

위 기준은 offset이냐 커서냐를 가른다. 아래는 그 기준으로 offset이 정해진 다음, Window 함수로 옮기지 않고 방식 1을 그대로 두는 경우다.

기존 방식 유지:

  • 마리아DB 10.2 미만 버전 (Window 함수를 못 쓴다)
  • 높은 유지보수 용이성이 최우선 (쿼리가 가장 단순해 누가 봐도 고치기 쉬운 것이 성능보다 중요할 때)
  • 기존 코드와 맞물려 있어 바꾸기 어려울 때

기존 방식에서 Window 함수로 옮길 때는 둘을 나란히 두고 결과를 견준다

기존 메서드를 지우지 않고 새 메서드를 더한다

1. 점진적 마이그레이션(한 번에 다 바꾸지 않고 조금씩 옮기기):

// 기존 메서드는 유지
userMapper.selectUserList(...);
userMapper.selectUserCount(...);

// 새로운 메서드 추가
userMapper.selectUserPageWithTotal(...);

기존 두 메서드를 지우지 않고 새 메서드를 옆에 더한다. 새 화면부터 새 메서드를 쓰고, 문제가 없으면 옛 화면도 옮긴다. (...)는 인자를 줄여 적은 표시다.

2. 두 방식의 결과가 같은지 테스트로 확인한다:

@Test
public void testWindowFunctionPagination() {
    // Window 함수 결과와 기존 방식 결과가 동일한지 검증
    List<UserPageDTO> windowResult = userMapper.selectUserPageWithTotal(...);
    List<UserDTO> legacyList = userMapper.selectUserList(...);
    int legacyCount = userMapper.selectUserCount(...);
    
    assertEquals(windowResult.size(), legacyList.size());
    assertEquals(windowResult.get(0).getTotalCount(), legacyCount);
}
  • @Test는 JUnit에게 「이 메서드가 테스트 하나다」라고 알리는 표시다. 빌드 도구(Gradle · Maven처럼 코드를 컴파일하고 테스트를 돌려 주는 프로그램)가 이 메서드를 찾아 실행한다
  • 같은 조건으로 새 방식과 옛 방식을 둘 다 부른다
  • assertEquals는 어서션이다. 두 값이 다르면 테스트가 실패로 끝난다. 첫 줄은 목록 건수가, 둘째 줄은 전체 건수가 같은지 본다
  • 이 테스트는 실제 DB에 쿼리를 보낸다. DB에 조건에 맞는 데이터가 있어야 get(0)이 성립한다

3. 성능 모니터링(걸린 시간을 기록해 지켜보기):

long startTime = System.currentTimeMillis();
List<UserPageDTO> result = userMapper.selectUserPageWithTotal(...);
long duration = System.currentTimeMillis() - startTime;

logger.info("Window function pagination took: {}ms", duration);
  • 쿼리 전후의 시각(밀리초)을 재서 그 차이를 duration에 담는다
  • logger는 Logger 객체이고, info로 기록을 남긴다. {} 자리에 뒤의 duration 값이 들어간다. 「took: 35ms」처럼 찍힌다
  • 이 코드는 한 번 잰 값을 남길 뿐이다. 옮기기 전후의 기록을 쌓아 견주어야 빨라졌는지 알 수 있다

Window 함수는 범위를 잘못 주면 다른 값을 세고 LIMIT 전에 전부 센다

PARTITION BY를 넣으면 전체 건수가 아니라 묶음별 건수가 된다

OVER()의 괄호 안에 PARTITION BY 컬럼을 적으면 MariaDB 문서 말대로 「묶음마다 계산을 새로 시작」한다. 그 컬럼 값이 같은 행끼리 따로 센다는 뜻이다.

-- 잘못된 사용
SELECT id, status, 
       COUNT(*) OVER(PARTITION BY status) as statusCount  -- 각 상태별 개수
FROM users
LIMIT 10;

-- 올바른 사용
SELECT id, status,
       COUNT(*) OVER() as totalCount  -- 전체 개수
FROM users
WHERE status = 'active'
LIMIT 10;
  • 첫 쿼리는 active 회원 행에는 active 인원수가, inactive 회원 행에는 inactive 인원수가 붙는다. 첫 행의 값을 「전체 건수」로 쓰면, 첫 행이 어느 상태냐에 따라 엉뚱한 숫자가 화면에 나간다
  • 둘째 쿼리는 원하는 조건을 WHERE에 적고 OVER()를 비워 둔다. 그러면 WHERE를 통과한 행 전체를 센 값이 모든 행에 똑같이 붙는다

COUNT는 LIMIT으로 자르기 전에 조건에 맞는 행을 전부 센다

-- Window 함수가 LIMIT 전에 실행되므로
-- WHERE를 통과한 전체 행의 COUNT를 계산한 후 LIMIT 적용
-- 대용량 테이블에서는 주의 필요

10건만 돌려받아도 DB는 WHERE에 맞는 행을 끝까지 다 센다. 조건에 맞는 행이 수백만 건이면 그만큼 센다. 이건 방식 1의 COUNT 쿼리도 똑같이 하는 일이라 방식 2가 더 나빠지는 건 아니지만, 「쿼리가 하나라서 가볍다」고 오해하면 안 된다. 행이 아주 많은 테이블이면 전체 건수가 정말 필요한 화면인지부터 다시 본다.


지금 쿼리 두 개를 쓰고 있다면 화면 종류에 따라 옮길 곳이 정해진다

페이지 버튼 화면은 Window 함수로, 무한 스크롤은 Cursor 방식으로 옮긴다

지금 기존 방식(2개 쿼리)을 쓰고 있다면:

  1. 즉시 전환 권장: Window 함수 방식 — 위 기준대로 페이지 번호로 바로 뛰는 화면일 때다
    • 개발 노력: 중간 수준
    • 성능 개선: 명확함 (WHERE 평가 · 왕복 횟수가 반으로 준다는 뜻이다. 걸리는 시간은 재 보지 않았다)
    • MariaDB 10.2 이상 필요 (확인 후 적용)
  2. 모바일 무한스크롤: Cursor 방식으로 별도 구현
    • 개발 노력: 낮음
    • 네트워크 효율: 최고
    • 기존 코드와 공존 가능
  3. 점진적 마이그레이션: 1번을 어떻게 옮기는가다. 「즉시」는 미루지 말고 시작하라는 뜻이고, 옮기는 일 자체는 이렇게 조금씩 한다
    • 새로운 기능부터 Window 함수 적용
    • 기존 코드는 고장 난 것만 고치고 새로 손대지 않는다
    • 테스트를 통해 동등성 검증

정리

페이징 처리는 사소해 보이지만 극도로 자주 사용되는 기능이다. 작은 개선이 모여 전체 시스템 성능에 큰 영향을 미친다.

프로젝트 특성에 맞게 고르되, 성능 개선과 코드 복잡도의 균형을 본다.


다음 읽을거리


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기