서론
spring.data.web.pageable.one-indexed-parameters=true 옵션을 설정하면 페이지 번호가 1부터 시작합니다. 그렇다면 @PageableDefault의 page 값도 1로 설정해야 할까요? 결론부터 말씀드리면 아닙니다. 이 글에서는 Spring 소스 코드를 직접 분석하며 왜 그런지 정확히 알아보겠습니다.
one-indexed-parameters 옵션이란?
Spring Data의 Pageable은 기본적으로 0-based 인덱스를 사용합니다. 첫 번째 페이지는 page=0입니다. 하지만 프론트엔드나 API 클라이언트 입장에서는 page=1이 첫 페이지인 것이 더 직관적일 수 있습니다.
이 문제를 해결하기 위해 Spring Boot는 다음 설정을 제공합니다:
spring:
data:
web:
pageable:
one-indexed-parameters: true
이 옵션을 활성화하면 클라이언트가 ?page=1로 요청했을 때 첫 번째 페이지를 반환합니다. 내부적으로 Spring이 요청 파라미터에서 1을 빼서 0-based 인덱스로 변환해주기 때문입니다.
그런데 여기서 흔한 착각이 발생합니다. “1-indexed 옵션을 켰으니
@PageableDefault(page = 1)로 설정해야 첫 페이지가 기본값이 되겠지?”
이 생각은 틀렸습니다. 왜 그런지 Spring Data Commons 소스 코드로 확인해보겠습니다.
소스 코드로 보는 동작 원리
먼저 전체 흐름을 다이어그램으로 살펴보겠습니다.

다이어그램에서 볼 수 있듯이, -1 변환은 오직 요청 파라미터가 존재할 때만 적용됩니다. @PageableDefault를 통한 기본값은 변환 없이 그대로 사용됩니다.
이제 실제 코드를 살펴보겠습니다. 핵심은 PageableHandlerMethodArgumentResolverSupport 클래스에 있습니다. 이 클래스가 HTTP 요청에서 Pageable 객체를 생성하는 역할을 담당합니다.
요청 파라미터 파싱: parseAndApplyBoundaries 메서드
private Optional<Integer> parseAndApplyBoundaries(@Nullable String parameter, int upper, boolean shiftIndex) {
if (!StringUtils.hasText(parameter)) {
return Optional.empty(); // 파라미터가 없으면 빈 Optional 반환
}
try {
int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0);
return Optional.of(parsed < 0 ? 0 : Math.min(parsed, upper));
} catch (NumberFormatException e) {
return Optional.of(0);
}
}
핵심 로직은 이 한 줄입니다:
int parsed = Integer.parseInt(parameter) - (oneIndexedParameters && shiftIndex ? 1 : 0);
oneIndexedParameters가 true이고 shiftIndex가 true일 때만 파싱된 값에서 1을 뺍니다. 즉, URL 요청 파라미터(?page=1)를 파싱할 때만 -1 변환이 적용됩니다.
기본값 처리: getPageable 메서드
protected Pageable getPageable(MethodParameter methodParameter, @Nullable String pageString,
@Nullable String pageSizeString) {
Optional<Pageable> defaultOrFallback = getDefaultFromAnnotationOrFallback(methodParameter).toOptional();
Optional<Integer> page = parseAndApplyBoundaries(pageString, Integer.MAX_VALUE, true);
// ...
int p = page
.orElseGet(() -> defaultOrFallback.map(Pageable::getPageNumber).orElseThrow(IllegalStateException::new));
요청 파라미터가 없으면 page는 Optional.empty()가 됩니다. 이 경우 defaultOrFallback에서 페이지 번호를 가져오는데, 여기서 중요한 점은 defaultOrFallback.map(Pageable::getPageNumber)에서 가져온 값은 변환 없이 그대로 사용된다는 것입니다.
그렇다면 defaultOrFallback은 어디서 만들어질까요? getDefaultFromAnnotationOrFallback(methodParameter) 메서드 내부에서 @PageableDefault 어노테이션이 있는지 확인하고, 있으면 getDefaultPageRequestFrom()을 호출해서 Pageable 객체를 생성합니다.
@PageableDefault 처리: getDefaultPageRequestFrom 메서드
private static Pageable getDefaultPageRequestFrom(MethodParameter parameter,
MergedAnnotation<PageableDefault> defaults) {
int defaultPageNumber = defaults.getInt("page"); // 어노테이션 값 그대로 가져옴
int defaultPageSize = defaults.getInt("size");
// ...
return PageRequest.of(defaultPageNumber, defaultPageSize, ...); // 변환 없이 그대로 사용!
}
따라서 @PageableDefault(page = 1)로 설정하면 defaultPageNumber는 1이 되고, 이 값이 변환 없이 그대로 PageRequest.of(1, ...)에 전달됩니다. 결과적으로 두 번째 페이지가 기본값이 되어버립니다.
핵심 정리:
one-indexed-parameters옵션은 오직parseAndApplyBoundaries메서드에서 요청 파라미터를 파싱할 때만 적용됩니다.@PageableDefault나fallbackPageable설정값에는 전혀 영향을 주지 않습니다.
테스트 코드로 검증
Spring Data Commons의 테스트 코드에서도 이 동작을 확인할 수 있습니다.
oneIndexedParametersDefaultsIndexOutOfRange 테스트
@Test
void oneIndexedParametersDefaultsIndexOutOfRange() {
var resolver = getResolver();
resolver.setOneIndexedParameters(true);
var request = new MockHttpServletRequest();
request.addParameter("page", "0"); // 1-indexed 모드에서 page=0 요청
var result = resolver.resolveArgument(supportedMethodParameter, null,
new ServletWebRequest(request), null);
assertThat(result.getPageNumber()).isEqualTo(0); // 결과는 0 (첫 페이지)
}
이 테스트는 one-indexed-parameters: true 상태에서 page=0을 요청하면 어떻게 되는지 검증합니다. 계산 결과는 0 - 1 = -1이지만, 음수는 0으로 보정되어 첫 페이지가 반환됩니다.
직접 검증해보기
다음 컨트롤러로 직접 테스트해볼 수 있습니다:
@RestController
public class PageTestController {
@GetMapping("/test")
public Map<String, Object> test(
@PageableDefault(page = 0, size = 10) Pageable pageable) {
return Map.of(
"pageNumber", pageable.getPageNumber(),
"pageSize", pageable.getPageSize()
);
}
}
one-indexed-parameters: true 설정 후:
| 요청 | pageNumber 결과 |
|---|---|
/test (파라미터 없음) | 0 (첫 페이지) ✅ |
/test?page=1 | 0 (첫 페이지) ✅ |
/test?page=2 | 1 (두 번째 페이지) ✅ |
만약 @PageableDefault(page = 1)로 설정했다면, 파라미터 없이 요청할 때 pageNumber가 1이 되어 두 번째 페이지가 기본값이 되어버립니다.
올바른 사용법 정리
✅ 올바른 설정
@GetMapping("/items")
public Page<Item> getItems(
@PageableDefault(page = 0, size = 20) Pageable pageable) {
return itemRepository.findAll(pageable);
}
❌ 잘못된 설정
@GetMapping("/items")
public Page<Item> getItems(
@PageableDefault(page = 1, size = 20) Pageable pageable) { // page=1은 두 번째 페이지!
return itemRepository.findAll(pageable);
}
정리표
| 설정 위치 | one-indexed-parameters 영향 | 올바른 값 |
|---|---|---|
URL 파라미터 (?page=1) | ✅ 적용됨 (-1 변환) | 1 = 첫 페이지 |
@PageableDefault(page = X) | ❌ 적용 안됨 | 0 = 첫 페이지 |
fallbackPageable 설정 | ❌ 적용 안됨 | 0 = 첫 페이지 |
주의:
Page객체의getNumber()메서드도 항상 0-based 값을 반환합니다. 클라이언트에게 1-based 페이지 번호를 응답하려면page.getNumber() + 1로 변환해야 합니다.
결론
one-indexed-parameters: true 옵션은 클라이언트와의 인터페이스(요청 파라미터)에만 영향을 줍니다. Spring 내부에서는 언제나 0-based 인덱스로 동작하며, @PageableDefault나 코드에서 직접 생성하는 PageRequest는 이 옵션과 무관하게 0부터 시작합니다.
이 동작이 의도된 것임은 Spring Data 메인테이너 Oliver Drotbohm의 GitHub 코멘트에서도 확인할 수 있습니다:
“Internally we always work with zero-indexed Pageable instances.”
설정의 편의성을 위해 도입된 옵션이지만, 내부 동작 원리를 정확히 이해하지 않으면 예상치 못한 버그로 이어질 수 있습니다. 소스 코드를 직접 확인하는 습관이 이런 함정을 피하는 가장 확실한 방법입니다.