1. 왜 페이지네이션이 필요한가?
조회 API는 단순해 보이지만 실제로는 까다롭다. 리뷰가 200개 있을 때 한 번에 다 불러오면 엄청난 렉이 발생하기 때문에 끊어서 보여주는 페이징이 필수다.
조회 API를 만들 때 두 가지를 고려해야 한다.
- 정보량이 많은데 어떻게 가져오지?
- 페이징을 어떻게 적용하지?
순수 SQL로도 가능하지만 Spring Data JPA에서는 Page와 Slice 를 활용한다.
2. Pageable
페이지네이션 요청 정보를 담는 인터페이스. 아래 정보를 가지고 있다.
- 가져올 데이터 수 (limit)
- 현재 페이지 번호 (offset)
- 정렬 방법 (order by)
실제 요청 시에는 Pageable의 구현체인 PageRequest 객체를 만들어서 Repository에 넘긴다.
3. Page vs Slice
Slice (상위)
└── Page (하위, Slice 상속)
| 비교 | Page | Slice |
|---|---|---|
| 총 데이터 수 | 있음 (count 쿼리 추가 발생) | 없음 |
| 다음 데이터 존재 여부 | 있음 | 있음 |
| 추가 쿼리 | count 쿼리 나감 | N+1개 요청으로 다음 존재 여부만 확인 |
| 적합한 페이지네이션 | 오프셋 기반 | 커서 기반 |
Slice의 다음 데이터 확인 방식: 요청한 개수보다 1개 더(N+1개) 조회해서, N+1개가 오면 다음 데이터 있음 / 아니면 없음으로 판단.
4. 오프셋 기반 페이지네이션
페이지 번호로 데이터를 끊어서 가져오는 방식. Page 를 사용한다.
SELECT * FROM mission
ORDER BY mission_id DESC
LIMIT 10 OFFSET 0;
구현 흐름
Controller에서 Query Parameter로 pageSize, pageNumber, sort를 받고, Service에서 PageRequest로 묶어서 Repository에 넘긴다.
PageRequest.of(pageNumber, pageSize, Sort.by(sort).descending())
응답 커스터마이징
Page 객체를 그대로 반환하면 프론트엔드에서 불필요한 정보까지 노출된다. 필요한 필드만 추려서 커스텀 응답 DTO로 감싸야 한다.
// 페이지네이션 응답 DTO 예시
public class PageResponse<T> {
private List<T> content;
private int totalPage;
private long totalElements;
private boolean isFirst;
private boolean isLast;
}
5. 커서 기반 페이지네이션
페이지 번호 대신 마지막으로 조회한 데이터의 기준값(커서) 으로 다음 데이터를 가져오는 방식. Slice 를 사용한다.
-- 커서 없을 때 (첫 조회)
SELECT * FROM mission
WHERE store_id = ?
ORDER BY id DESC
LIMIT 3;
-- 커서 있을 때 (예: 커서 = 10)
SELECT * FROM mission
WHERE store_id = ? AND id < 10
ORDER BY id DESC
LIMIT 3;
커서 구조
워크북에서는 query:ID 형태로 커서를 구성했다. 예: id:10
앞부분은 조회 기준 종류, 뒷부분은 실제 커서 값.
구현 흐름
PageRequest만들기- 커서가 있으면 → 커서 파싱 후
where id < ?조건 추가해서 조회 - 커서가 없으면 → 조건 없이 첫 페이지 조회
- 다음 커서 계산해서 응답 DTO에 포함
응답 DTO 구성
public class CursorResponse<T> {
private List<T> content;
private boolean hasNext;
private String nextCursor;
private int size;
}
6. 오프셋 vs 커서 비교 정리
| 비교 | 오프셋 기반 | 커서 기반 |
|---|---|---|
| JPA 타입 | Page | Slice |
| 총 데이터 수 제공 | O | X |
| 데이터 중복/누락 위험 | 있음 (데이터 변경 시) | 없음 |
| 구현 복잡도 | 낮음 | 높음 |
| 적합한 UI | 페이지 번호 UI | 무한 스크롤 |
7. 저번 주차 복습 - API 제작 흐름
7주차 앞부분에서 가게 미션 생성/조회 API를 만들면서 전체 흐름을 복습했다. 순서를 기억해두면 좋다.
- DTO 제작 - 요청/응답 형태 정의
- SuccessCode/ErrorCode 정의 - 상태코드 선언
- Controller 제작 - 엔드포인트, DTO 연결
- Converter 제작 - 엔티티 ↔ DTO 변환 (빌더 패턴)
- Exception 정의 - 도메인별 예외 클래스
- Service 제작 - 비즈니스 로직
- Repository 제작 - 메서드명 기반 or
@Query쿼리
Repository 메서드명 예시:
// FK가 있는 연관관계에서 store의 id를 where절에 넣어 조회
List<Mission> findByStoreId(Long storeId);
8. 검증 어노테이션
요청 데이터를 그대로 수용하면 null이나 잘못된 값이 들어올 수 있다. 검증 어노테이션으로 Controller 또는 Service 단에서 미리 걸러낼 수 있다.
주요 검증 어노테이션 (DTO 필드에 붙임)
| 어노테이션 | 의미 |
|---|---|
@NotNull | null 불가 |
@NotBlank | null, 빈 문자열, 공백 불가 (String 전용) |
@Size(min=, max=) | 길이/크기 범위 지정 |
@Pattern(regexp=) | 정규식 패턴 일치 여부 |
@Min, @Max | 최솟값/최댓값 지정 |
@Future | 현재 시각 이후여야 함 |
사용 방법
DTO 필드에 어노테이션을 붙이고, Controller에서 해당 DTO 파라미터에 @Valid를 추가하면 된다.
// DTO
public class MissionCreateRequest {
@NotNull(message = "마감일은 필수입니다.")
private LocalDate deadline;
@Min(value = 1, message = "포인트는 1 이상이어야 합니다.")
private Integer point;
}
// Controller
public ResponseEntity<?> createMission(@Valid @RequestBody MissionCreateRequest request) { ... }
검증 실패 시 MethodArgumentNotValidException이 발생한다.
예외 처리
GeneralExceptionAdvice에 해당 예외 핸들러를 추가해서 어떤 필드가 왜 실패했는지 응답으로 내려준다.
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<?> handleValidException(MethodArgumentNotValidException e) {
Map<String, String> errors = new HashMap<>();
e.getBindingResult().getFieldErrors().forEach(error ->
errors.put(error.getField(), error.getDefaultMessage())
);
// errors를 result에 담아 응답
}
응답 예시:
{
"isSuccess": false,
"code": "BAD_REQUEST",
"result": {
"deadline": "마감일은 필수입니다.",
"point": "포인트는 1 이상이어야 합니다."
}
}
핵심 키워드
- Page vs Slice 내부 동작 차이 심화
- Java Stream API -
.map(),.filter(),.toList()vs.collect(Collectors.toList()) - 객체 그래프 탐색 - 연관관계를 통해 객체를 탐색하는 개념
- @Valid vs @Validated - 두 어노테이션의 차이점
