무한스크롤 기능을 구현하면서 기존 백엔드 응답 구조를 pageNum, pageRows 기반의 표준화된 형태로 정리해야 했다. 그 과정에서 현재 프로젝트의 페이지네이션이 어떻게 공통 처리되고 있는지 전체 흐름을 정리했다.
1. 왜 공통 처리가 필요했는가
대부분의 목록 API에서 페이징이 사용되고 있었다.
각 API에서 반복적으로 수행되는 작업은 다음과 같았다.
- offset 계산
- 전체 개수 조회
- totalPages 계산
- 기본값 보정
- 빈 결과 처리
이러한 로직이 개별 Service에 흩어져 있지 않고, 공통 유틸리티를 통해 처리되고 있었다.
이를 통해 응답 형식과 계산 방식이 일관되게 유지되고 있었다.
2. 전체 구조
Controller → Service → Mapper → SQL
↓ ↓ ↓ ↓
Request PagingUtil PageResult OFFSET/LIMIT
각 계층의 역할은 아래와 같이 분리되어 있다.
- Controller: 요청 파라미터를 수신
- Service: PagingUtil을 통해 페이징 공통 로직 처리
- Mapper: 데이터 목록, 전체 개수 조회
- SQL: 실제 OFFSET/LIMIT 적용
3. 공통 응답 객체: PageResult
페이징 응답은 PageResult 객체로 반환되고 있었다.
public class PageResult<E> extends ArrayList<E> {
private int pageNum;
private int pageRows;
private int totalPages;
private long totalCount;
}
ArrayList 를 상속하여 리스트처럼 사용할 수 있도록 구성되어 있었다. 리스트 데이터와 함께 페이징 메타 정보가 포함되는 구조였다.
4. 요청 표준화: BaseRequest 인터페이스
페이징 요청 객체들은 공통적으로 BaseRequest 인터페이스를 사용해 동일한 타입으로 페이징 파라미터를 처리하고 있었다. 각 도메인별 Request 객체는 BaseRequest를 확장한 뒤, 도메인에 필요한 필드를 추가하는 구조였다.
public interface BaseRequest {
int getPageNum();
void setPageNum(int pageNum);
int getPageRows();
void setPageRows(int pageRows);
int getOffset();
void setOffset(int offset);
}
5. Service 레벨: PagingUtil 동작 방식
Service에서는 PagingUtil.of() 메서드를 통해 페이징을 처리하고 있었다. 여기서 중요한 점은 Service는 직접 로직을 수행하지 않고, 실행 함수만 전달한다는 것이다. 즉, 실제 실행은 PagingUtil.of() 내부에서 제어된다.
public PageResult<EventResponse> selectEventList(EventListRequest request) {
return PagingUtil.of(
request,
() -> eventMapper.selectEventCount(request),
() -> eventMapper.selectEventList(request)
);
}
동작 순서는 다음과 같았다.
- offset 계산
- 전체 개수 조회
- 데이터 조회
- PageResult에 메타 정보 설정
offset 계산은 다음과 같이 이루어지고 있었다. 기본값을 세팅하고 offset을 계산하는 로직이 이 메서드를 통해 일괄적으로 수행되고 있었다.
public class PagingUtil {
public static <T> PageResult<T> of(
BaseRequest request,
IntSupplier totalCountSupplier, // 전체 개수 조회 함수
Supplier<PageResult<T>> dataSupplier) { // 데이터 조회 함수
// 1. 페이징 파라미터 검증 및 offset 계산
applyOffset(request);
// 2. 전체 개수 조회
int totalCount = totalCountSupplier.getAsInt();
// 3. 데이터 조회 및 PageResult 생성
if (totalCount > 0) {
PageResult<T> result = dataSupplier.get();
result.setPageNum(request.getPageNum());
result.setPageRows(request.getPageRows());
result.setTotalCount(totalCount);
result.setTotalPages((int) Math.ceil((double) totalCount / request.getPageRows()));
return result;
}
return new PageResult<>(); // 데이터가 없을 경우 빈 PageResult 반환
}
private static void applyOffset(BaseRequest request) {
int pageNum = request.getPageNum() > 0 ? request.getPageNum() : 1;
int pageRows = request.getPageRows() > 0 ? request.getPageRows() : 10;
int offset = (pageNum - 1) * pageRows;
request.setPageNum(pageNum);
request.setPageRows(pageRows);
request.setOffset(offset);
}
}
6. Mapper와 SQL
Mapper에는 다음 두 가지 메서드가 정의되어 있었다.
PageResult<EventResponse> selectEventList(EventListRequest request);
int selectEventCount(EventListRequest request);
SQL에서는 다음과 같이 OFFSET과 LIMIT이 사용되고 있었다.
OFFSET #{offset} LIMIT #{pageRows}
selectEventList와 selectEventCount의 WHERE 조건은 동일하게 유지되고 있었다.
ORDER BY 이후에 OFFSET과 LIMIT이 적용되는 구조였다.
느낀점
페이지네이션은 공통 유틸리티를 통해 처리되고 있었고, 계층별 역할도 비교적 명확하게 나뉘어 있었다. 그 덕분에 다른 API에서도 응답 형식을 페이지네이션 구조로 변경해야 할 때, 기존 패턴을 그대로 적용하면 되었기 때문에 큰 부담 없이 수정할 수 있었다.
특히 설계적인 부분이 이미 정리되어 있었기 때문에, AI를 활용해 구조를 분석하고 필요한 부분만 보완하는 방식으로 작업 시간을 단축할 수 있었다. 공통 처리 지점이 명확하다 보니, 변경 범위를 예측하기도 수월했다.
왜 Supplier 구조를 사용했을까?
IntSupplier totalCountSupplier
Supplier<PageResult<T>> dataSupplier
Supplier<T>는 Java 8에서 도입된 함수형 인터페이스로, 값을 직접 넘기는 것이 아니라 값을 만들어내는 방법을 전달하는 구조다. 아래 코드를 보면, 여기서 전달되는 것은 실행 결과가 아니라 실행 함수 자체다.
PagingUtil.of(
request,
() -> eventMapper.selectEventCount(request),
() -> eventMapper.selectEventList(request)
);
처음에는 단순히 int 형태의 count 값을 Service에서 계산해 넘기면 되지 않을까 생각했다. 굳이 Supplier로 실행 함수를 전달할 필요가 있을지 의문이 들었다.
계속 구조를 들여다보니, PagingUtil은 단순히 계산을 도와주는 유틸리티가 아니라, 실행 순서를 통제하는 역할을 하고 있었다는 것을 깨달을 수 있었다. count를 먼저 실행한 뒤, 그 결과가 0일 경우 목록 조회를 아예 실행하지 않도록 제어하고 있었다. 결과적으로 불필요한 DB 접근을 줄일 수 있었고, 모든 API에서 동일한 실행 흐름을 보장하고 있었다.
'Dev Log' 카테고리의 다른 글
| 날씨 기능 간단한 거 아냐? 공공 API 연동하기 삽질 과정 (0) | 2026.03.15 |
|---|---|
| 엑셀 업로드부터 DB 저장까지의 전체 프로세스 (0) | 2026.03.08 |
| 파일 업로드: “썸네일/메인 이미지” 업로드에서 두 필드가 동기화되는 버그 트러블슈팅 (1) | 2026.02.22 |
| Java 컬렉션 프레임워크 정리: List, Set, Map, HashMap (0) | 2026.02.15 |
| 사용자 추천 기능은 어떻게 테이블을 설계해야 할까 (0) | 2026.02.08 |