co-cherry
게시글 목록 API 구현하기 (페이징 · 검색, N+1) 본문
페이징
게시글 목록 조회 API를 구현하면 이와 같은 형태로 작성하게 된다.
List<Post> posts = postRepository.findAll();
로컬에서 데이터 10개 정도로 테스트할 때는 문제 없이 잘 동작하지만, 게시글이 10,000개 정도 있다고 가정해보자.
이 API를 호출하는 순간,
- 데이터베이스는 10,000개의 행을 읽어서 메모리에 올린다
- 애플리케이션은 10,000개의 Post 객체를 생성한다
- 네트워크를 통해 수 MB의 JSON 데이터가 전송된다
- 프론트엔드는 10,000개의 게시글을 한꺼번에 렌더링하려 시도한다
우리의 서버 메모리는 낭비되고 응답은 느려지며 멈춘 듯한 화면을 보게 될 것이다.
이 문제에 대한 해결책이 Paging이다.
'한번에 모든 데이터를 보여 줄 필요가 없다면 필요한 만큼만 잘라서 보여주자' 가 핵심 아이디어이다.
Spring Data JPA 페이징의 구현
Spring Data JPA에 페이징 기능이 내장되어 있으므로 이를 이용하면 된다.
먼저, 페이징 정보를 담은 DTO를 생성한다.
응답 시, 표시할 게시글들의 데이터 목록들을 List로 묶고 Page<T> 에서 제공하는 페이지 메타 정보들도 함께 리턴한다.
@Getter
@AllArgsConstructor
public class PostListResponseDto {
private List<PostDetailResponseDto> posts; // 페이지 데이터 목록
private Integer currentPage; // 현재 페이지 번호 (0부터 시작)
private Integer totalPages; // 전체 페이지 수
private Long totalElements; // 전체 데이터 수
private Integer listSize; // 이번 페이지에 실제 담긴 데이터 수
private Boolean isFirst; // 첫번째 페이지 여부
private Boolean isLast; // 마지막 페이지 여부
private Boolean hasNext; // 다음 페이지 존재 여부
private Boolean hasPrevious; // 이전 페이지 존재 여부
public static PostListResponseDto from(Page<Post> page) {
List<PostDetailResponseDto> posts = page.getContent().stream()
.map(post -> new PostDetailResponseDto(
post.getId(),
post.getTitle(),
post.getContent(),
post.getUser().getNickname(),
post.getCreatedAt()
)).toList();
return new PostListResponseDto(
posts,
page.getNumber(), // currentPage
page.getTotalPages(), // totalPages
page.getTotalElements(), // totalElements
page.getNumberOfElements(), // listSize
page.isFirst(), // isFirst
page.isLast(), // isLast
page.hasNext(), // hasNext
page.hasPrevious() // hasPrevious
);
}
}
Page<T>
Spring Data JPA가 제공하는 페이징 결과를 담는 컨테이너 객체
제공하는 메서드 (Count 쿼리 결과)
| 메서드 | 반환 타입 | 설명 |
| getTotalElements() | long | 조건에 맞는 전체 데이터 수 |
| getTotalPages() | int | 전체 페이지 수 |
제공하는 메서드 (이번 페이지의 정보)
| 메서드 | 반환 타입 | 설명 |
| getContent() | List<T> | 이번 페이지의 실제 데이터 목록 |
| getNumber() | int | 현재 페이지 번호 (0부터 시작) |
| getSize() | int | 요청한 페이지의 크기 |
| getNumberOfElements() | int | 이번 페이지에 실제로 담긴 데이터 수 |
| hasContent() | boolean | 데이터가 하나라도 있는가 |
| isFirst() | boolean | 첫번째 페이지인가 |
| isLast() | boolean | 마지막 페이지인가 |
| hasNext() | boolean | 다음 페이지가 존재하는가 |
| hasPrevious() | boolean | 이전 페이지가 존재하는가 |
| getSort() | Sort | 적용된 정렬 정보 |
Q. getSize() 와 getNumberOfElements() 의 차이는?
getSize()는 요청한 페이지 크기이고, getNumberOfElements()는 실제로 담긴 데이터 수다.
전체 데이터가 27개이고 size=10일 때,
마지막 페이지(page=2)는 요청한 크기는 10이지만, 실제로 남은 데이터가 7개이므로 두 값이 달라진다.
page=0 → getSize()=10, getNumberOfElements()=10
page=1 → getSize()=10, getNumberOfElements()=10
page=2 → getSize()=10, getNumberOfElements()=7 ← 마지막 페이지
그 다음, Service 로직을 작성한다.
@Transactional(readOnly = true)
public PostListResponseDto getPosts(int page, int size, String sortBy) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
Page<Post> postPage = postRepository.findAll(pageable);
return PostListResponseDto.from(postPage);
}
Pageable
페이지 요청 정보를 담는 인터페이스, PageRequest.of()로 구현체를 만들어 사용
'몇 번째 페이지(page), 몇 개씩(size), 어떤 순서로(sort)'를 묶어서 Repository에 넘겨 주는 역할
- page 몇 번째 페이지(0부터 시작)
- size 한 페이지에 몇 개
- Sort.by(DESC | ASC, sortBy) 정렬 조건 객체 생성, sortBy는 정렬의 기준이 될 컬럼명
pageable 조건에 맞게 DB에서 조회를 해 Spring Data JPA가 LIMIT, OFFSET, ORDER BY SQL을 자동으로 생성 후 결과를 DTO로 변환해 리턴한다.
마지막으로 Controller를 작성한다.
size와 sortBy를 외부에서 받아오므로 값이 없을 것을 대비해 defaultValue를 설정했다.
@Operation(summary = "게시글 목록 조회", description = "페이징/정렬을 지원합니다.")
@GetMapping("/lists")
public ApiResponse<PostListResponseDto> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy
) {
return ApiResponse.onSuccess(GeneralSuccessCode.OK, postService.getPosts(page, size, sortBy));
}
코드를 실행해보면 이와 같은 응답을 받아볼 수 있다.

동적 검색 조건 처리(Keyword, 날짜 범위)
동적 쿼리
실행 시점에 조건에 따라 WHERE 절이 동적으로 구성되는 쿼리
구현 방법
| 방식 | 장점 | 단점 |
| JPQL | 간단한 쿼리에 적합 | 문자열 기반, 컴파일 체크 불가 |
| Specification | 표준 JPA 스펙 | 코드 가독성 떨어짐 |
| QueryDSL | 타입 안전, 직관적 | 초기 설정 필요 |
이번에는 JPQL과 QueryDSL로 구현하고 비교해보겠다.
JPQL(Java Persistence Query Language)
SQL을 추상화한 객체 지향 쿼리 언어
- 테이블이 아닌 엔티티 객체를 대상으로 쿼리 작성
- SQL과 문법이 유사하나 DB 독립적
-- SQL (테이블 기준)
SELECT * FROM post WHERE title LIKE '%Spring%'
-- JPQL (엔티티 기준)
SELECT p FROM Post p WHERE p.title LIKE '%Spring%'
JPQL을 통해 구현하기 위해 PostRepository에 아래와 같이 searchPosts 메서드를 작성하자.
- @Query 직접 쿼리를 작성할 때 사용하는 어노테이션
- value 실제 데이터를 가져오는 쿼리, SQL 대신 JPQL으로 사용
- :keyword 파라미터 바인딩 자리 표시자, @Param("keyword")로 연결된 실제 값이 런타임에 삽입
- countQuery page 반환 시, 전체 데이터 수를 구하기 위한 별도 쿼리
public interface PostRepository extends JpaRepository<Post, Long> {
@Query(
value = "SELECT p FROM Post p JOIN FETCH p.user " +
"WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) " +
"AND (:startDate IS NULL OR p.createdAt >= :startDate) " +
"AND (:endDate IS NULL OR p.createdAt <= :endDate)",
countQuery = "SELECT COUNT(p) FROM Post p " +
"WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) " +
"AND (:startDate IS NULL OR p.createdAt >= :startDate) " +
"AND (:endDate IS NULL OR p.createdAt <= :endDate)"
)
Page<Post> searchPosts(
@Param("keyword") String keyword,
@Param("startDate") LocalDateTime startDate,
@Param("endDate") LocalDateTime endDate,
Pageable pageable
);
}
다음, 위에서 작성한 서비스를 keyword와 date 파라미터를 추가해 검색 조건을 추가하자.
이전에는 findAll()로 전체 조회하던 것을 keyword, date 조건을 추가해 searchPosts 메서드를 이용해 조회하는 것을 볼 수 있다.
@Transactional(readOnly = true)
public PostListResponseDto getPosts(int page, int size, String sortBy,
String keyword,
LocalDateTime startDate, LocalDateTime endDate) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
Page<Post> postPage = postRepository.searchPosts(keyword, startDate, endDate, pageable);
return PostListResponseDto.from(postPage);
}
마지막으로 컨트롤러에도 똑같이 조건을 넣어 수정한다.
@Operation(summary = "게시글 목록 조회", description = "페이징/정렬을 지원합니다.")
@GetMapping("/lists")
public ApiResponse<PostListResponseDto> getPosts(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) LocalDateTime startDate,
@RequestParam(required = false) LocalDateTime endDate
) {
return ApiResponse.onSuccess(GeneralSuccessCode.OK,
postService.getPosts(page, size, sortBy, keyword, startDate, endDate));
}
테스트를 해보면, 먼저 조건 없이 조회해보자. 전체 글이 조회되는 것을 볼 수 있다.

그 다음, "동적" 이라는 키워드를 넣고 조회해보자.
Curl 부분에 keyword=%EB%8F%99%EC%A0%81 가 추가된 것을 볼 수 있다.

마지막으로, 4월 만을 기준으로 조회해보자.
Curl 부분에 startDate=2026-04-01T00%3A00%3A00&endDate=2026-05-01T00%3A00%3A00 이 추가된 것을 볼 수 있다.

QueryDSL
타입 안전한(Type-Safe) 쿼리 빌더
- Java 코드로 쿼리를 작성해 컴파일 시점에 오류를 검출한다.
- Q 클래스 엔티티를 표현하는 Java 클래스(쿼리용), 엔티티 meta 모델을 자동 생성
먼저, QueryDSL을 사용하려면 의존성을 추가해줘야 한다.
나의 경우, Spring Boot 3.5.11 버전을 사용하므로 이에 맞는 의존성을 추가했다. 자신의 버전에 유의해서 사용하자.

의존성 추가 후, 빌드하면 아래와 같이 Q 클래스가 생긴 것을 볼 수 있다.
그 다음, QueryslConfig 파일을 생성해 아래와 같이 작성한다.
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
EntityManager
JPA와 DB가 소통할 때 사용하는 핵심 객체
JPAQueryFactory
EntityManager를 받아 QueryDSL 문법으로 쿼리를 만들고 실행할 수 있게 해주는 객체
// JPAQueryFactory 없이 (JPQL 방식)
@Query("SELECT p FROM Post p WHERE p.title LIKE %:keyword%")
// JPAQueryFactory 있으면 (QueryDSL 방식)
queryFactory
.selectFrom(post)
.where(post.title.contains(keyword))
.fetch();
그 다음, PostRepositoryCustom 인터페이스를 생성해 QueryDSL 구현 코드를 생성한다.
기존 CRUD는 JpaRepsitory에서, 복잡한 QueryDSL 쿼리는 PostRepositoryCustom에서 하도록 분리하기 위해 따로 선언한다.
public interface PostRepositoryCustom {
Page<Post> searchPostsQueryDsl(
String keyword,
LocalDateTime startDate,
LocalDateTime endDate,
Pageable pageable
);
}
PostRepository에 PostRepositoryCustom 인터페이스 상속을 추가한다. (다중 상속 가능)
public interface PostRepository extends JpaRepository<Post, Long>, PostRepositoryCustom {
그 다음, 인터페이스의 구현체인 PostRepositoryImpl을 작성한다.
- goe(Greater Or Equal) >=, QueryDSL의 비교 메서드
- loe(Less Or Equal) <=, QueryDSL의 비교 메서드
- contains QueryDSL의 문자열 검색 메서드
@RequiredArgsConstructor
public class PostRepositoryImpl implements PostRepositoryCustom {
private final JPAQueryFactory queryFactory;
private final QPost post = QPost.post;
@Override
public Page<Post> searchPostsQueryDsl(String keyword, LocalDateTime startDate,
LocalDateTime endDate, Pageable pageable) {
BooleanBuilder builder = new BooleanBuilder();
if (keyword != null) {
builder.and(post.title.contains(keyword)
.or(post.content.contains(keyword)));
}
if (startDate != null) {
builder.and(post.createdAt.goe(startDate));
}
if (endDate != null) {
builder.and(post.createdAt.loe(endDate));
}
List<Post> posts = queryFactory
.selectFrom(post)
.join(post.user).fetchJoin()
.where(builder)
.orderBy(post.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(post.count())
.from(post)
.where(builder)
.fetchOne();
return new PageImpl<>(posts, pageable, total);
}
}
BooleanBuilder
조건을 동적으로 조합하는 객체
if 문으로 조건이 있을 때만 .and() 로 추가
쿼리 조립 부분
SQL을 문자열로 쓰지 않고 메서드 체이닝으로 조립하는 것이 QueryDSL의 핵심적인 특징이다.
queryFactory
.selectFrom(post) // SELECT * FROM post
.join(post.user).fetchJoin() // JOIN FETCH (N+1 방지)
.where(builder) // 동적 조건 적용
.orderBy(post.createdAt.desc()) // ORDER BY
.offset(pageable.getOffset()) // 몇 번째부터
.limit(pageable.getPageSize()) // 몇 개까지
.fetch(); // 실행 후 List로 반환
최종적으로 목록, 페이지 정보, 전체 수를 합쳐 Page<Post>로 만들어 반환한다.
마지막으로 서비스와 컨트롤러에 searchPostsQueryDsl을 사용하는 방식으로 메서드와 엔드 포인트를 추가한다.
@Transactional(readOnly = true)
public PostListResponseDto getPostsQueryDsl(int page, int size, String sortBy,
String keyword,
LocalDateTime startDate, LocalDateTime endDate) {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy));
Page<Post> postPage = postRepository.searchPostsQueryDsl(keyword, startDate, endDate, pageable);
return PostListResponseDto.from(postPage);
}
@GetMapping("/lists/querydsl")
public ApiResponse<PostListResponseDto> getPostsQueryDsl(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) LocalDateTime startDate,
@RequestParam(required = false) LocalDateTime endDate
) {
return ApiResponse.onSuccess(GeneralSuccessCode.OK,
postService.getPostsQueryDsl(page, size, sortBy, keyword, startDate, endDate));
}
전체 조회부터 테스트 해보자.

그 다음, "동적" 이라는 키워드를 넣고 조회해보자.
Curl 부분에 keyword=%EB%8F%99%EC%A0%81 가 추가된 것을 볼 수 있다.

마지막으로, 4월 만을 기준으로 조회해보자.
Curl 부분에 startDate=2026-04-01T00%3A00%3A00&endDate=2026-05-01T00%3A00%3A00 이 추가된 것을 볼 수 있다.

얼핏 보면 이전과 같아 보이지만, Curl 부분을 보면 이전 API가 아닌 querydsl API임을 볼 수 있다.
JPQL VS QueryDSL
검색 조건 처리 (JPQL) - 조건을 문자열로 작성
@Query(
value = "SELECT p FROM Post p JOIN FETCH p.user " +
"WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) " +
"AND (:startDate IS NULL OR p.createdAt >= :startDate) " +
"AND (:endDate IS NULL OR p.createdAt <= :endDate)",
countQuery = "SELECT COUNT(p) FROM Post p " +
"WHERE (:keyword IS NULL OR p.title LIKE %:keyword% OR p.content LIKE %:keyword%) " +
"AND (:startDate IS NULL OR p.createdAt >= :startDate) " +
"AND (:endDate IS NULL OR p.createdAt <= :endDate)"
)
Page<Post> searchPosts(@Param("keyword") String keyword, ...);
검색 조건 처리 (QueryDSL) - 조건을 코드로 조합
BooleanBuilder builder = new BooleanBuilder();
if (keyword != null) {
builder.and(post.title.contains(keyword)
.or(post.content.contains(keyword)));
}
if (startDate != null) {
builder.and(post.createdAt.goe(startDate));
}
if (endDate != null) {
builder.and(post.createdAt.loe(endDate));
}
List<Post> posts = queryFactory
.selectFrom(post)
.join(post.user).fetchJoin()
.where(builder)
.orderBy(post.createdAt.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Long total = queryFactory
.select(post.count())
.from(post)
.where(builder)
.fetchOne();
return new PageImpl<>(posts, pageable, total);
| JPQL | QueryDSL | |
| 작성 방식 | 문자열로 작성 | 자바 코드로 작성 |
| 오타 발견 시점 | 실행해야 알 수 있음 | IDE가 바로 알려줌 |
| 동적 조건 처리 | :param IS NULL 트릭 사용 | if문으로 자연스럽게 추가/제거 |
| 가독성 | 조건이 늘수록 문자열이 길어짐 | 조건이 늘어도 코드가 깔끔함 |
| 초기 설정 | 별도 설정 없음 | 의존성, Q클래스, 빈 등록 필요 |
| 적합한 상황 | 조건이 단순한 경우 | 조건이 많고 복잡한 경우 |
N + 1 문제
1번의 쿼리로 N개의 데이터를 조회했을 때, 각 데이터에 연관된 데이터를 가져오기 위해 추가로 N번의 쿼리가 실행되는 문제
만약, 게시글 100개를 조회하면 총 101번의 쿼리가 실행된다.
발생 원인
JPA의 기본 Fetch 전략이 LAZY(지연 로딩)이기 때문이다.
LAZY 로딩은 처음에는 연관 객체를 가져오지 않다가, 실제로 접근하는 순간 DB에 쿼리를 날린다.
*그렇다고 해서 EAGER 을 사용하더라도 똑같이 N+1이 발생한다. "언제" 쿼리를 날리느냐의 차이일 뿐...
@ManyToOne(fetch = FetchType.LAZY)
private User user;
내가 작성한 코드 중에 작성자의 닉네임을 가져오는 코드가 있는데 이를 보면 N+1의 문제가 발생함을 알 수 있다.
- postRepository.findAll() → SELECT * FROM post 로 Post 전체 조회 (1)
- .map(post -> post.getUser() → 각 Post마다 SELECT * FROM users WHERE user_id=? 조회 (N)
.getNickname())
postRepository.findAll()
.stream()
.map(post -> post.getUser()
.getNickname())
해결 방안
1. Fetch Join (outer join fetching)
SQL의 JOIN을 사용해 연관 데이터를 처음부터 한 번에 가져오는 방법
N+1이 발생하는 이유는 post를 먼저 가져오고, user를 나중에 개별 조회하기 때문이다.
따라서, Fetch Join은 처음부터 post와 user를 JOIN해서 한 번의 쿼리로 가져오기 때문에 추가 쿼리가 발생하지 않는다.
SELECT * FROM post
JOIN users ON users.user_id = post.user_id
@Query("SELECT p FROM Post p JOIN FETCH p.user")
List<Post> findAllWithUser();
2. Batch Fetching
연관 데이터를 개별 쿼리가 아닌 IN절로 묶어 한 번에 가져오는 방법
기존에는 user를 한 명씩 개별 조회했다면, Batch Fetching은 필요한 user의 id를 모아서 IN절로 한 번에 조회한다.
SELECT * FROM users WHERE user_id IN (1, 2, 3, ...)
batch_fetch_size: 100은 한 번에 최대 100개의 id를 IN절에 묶어 조회한다는 의미이다.
데이터가 250개라면 100개씩 나눠 총 3번에 걸쳐 실행된다.
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
3. Subselect Fetching
연관 데이터를 서브 쿼리로 한 번에 가져오는 방법
IN절에 직접 id를 넣는 대신, 처음 실행한 쿼리를 서브 쿼리로 재사용한다.
처음 post를 조회한 쿼리를 서브 쿼리로 재활용해 관련된 user를 한 번에 가져오기 때문에 추가 쿼리가 1번만 발생한다.
SELECT * FROM post
SELECT * FROM users
WHERE user_id IN (SELECT user_id FROM post)
엔티티에 직접 SUBSELECT를 설정한다.
size 제한 없이 전체를 한 번에 처리하지만, 서브 쿼리가 복잡할수록 성능이 떨어질 수 있다는 단점이 있다.
@ManyToOne
@Fetch(FetchMode.SUBSELECT)
private User user;
[Spring Boot] 페이징 기능 구현하기
페이징 구현하기스프링 부트에서 페이징(Paging)을 구현하는 방법은 Spring Data JPA의 Pageable과 Page 인터페이스를 활용하는 것이다. Page 인터페이스를 사용하면 페이징 정보를 쉽게 가져올 수 있다. g
itconquest.tistory.com
[Springboot] QueryDSL의 이해와 동적쿼리 적용
Spring Data JPA의 가장 큰 장점은 간편함입니다. 기본적인 CRUD 메서드, 쿼리 메서드를 사용해서 엔티티의 필드와 연관된 데이터를 쉽게 가져올 수 있습니다. >기본적인 CRUD 메서드는 굳이 Repository에
velog.io
https://ksh-coding.tistory.com/146#0.%20%EB%93%A4%EC%96%B4%EA%B0%80%EA%B8%B0%20%EC%A0%84-1
[JPA] JPA N+1 문제 및 근본적인 원인에 대한 개인적인 고찰
0. 들어가기 전JPA를 사용하면서 발생하는 N+1 문제는 널리 알려져 있고, JPA를 사용하다보면 제법 자주 만나게 됩니다.그래서 N+1 문제를 다룬 블로그나 다른 레퍼런스들이 상당히 많습니다.저 또
ksh-coding.tistory.com
'Springboot' 카테고리의 다른 글
| @Transactional과 DB 인덱스로 Spring Boot 애플리케이션 성능 최적화하기 (1) | 2026.05.15 |
|---|---|
| 회원가입 및 로그인 구현 (0) | 2026.04.28 |
| 게시판 CRUD (0) | 2026.03.30 |
| ERD + DB + JPA 시작 (0) | 2026.03.24 |
| 예외 처리와 Validation (1) | 2026.03.13 |
