이전 포스팅에 이어 서버에서 커서 기반 페이징 기법을 통하여 클라이언트 쪽에서 무한 스크롤 방식으로 데이터를 요청할 수 있도록 처리하는 과정을 정리해보고자 한다.
Pageable
일반적으로 페이징 처리를 할 경우, JPA에서 제공하는 Pageable이나 Slice 를 쓴다.
클라이언트가 page와 size를 전달해야 한다. 일반적으로 page = 0부터 시작한다.
Page 쿼리는 다음과 같이 실행된다.
SELECT COUNT(*) FROM review WHERE vinyl_id = ?;
SELECT * FROM review WHERE vinyl_id = ? LIMIT ? OFFSET ?;
- 전체 데이터의 개수를 알려주기 위해 COUNT 쿼리를 실행한다.
- 총 데이터 개수와 총 페이지 개수를 반환해준다. 이를 통해 전체 "100개 페이지 중 3번재 페이지 보기"와 같은 정확한 페이징을 처리할 수 있게 해준다.
응답 형태 예시
{
"content": [ { ... }, { ... }, ... ],
"totalElements": 100,
"totalPages": 10,
"number": 0,
"size": 10,
"last": false,
"first": true
}
Slice
SELECT * FROM review WHERE vinyl_id = ? LIMIT ? OFFSET ?;
Pageable 쿼리와 달리 COUNT(*) 쿼리가 빠지면서 DB 부하가 줄고 응답 속도가 개선될 수 있다. (hasNext 여부만 판단)
무한 스크롤을 구현할 경우, 데이터의 total count가 필요없고, 필요할 때마다 데이터를 불러오기에
Slice는 성능 최적화, 불필요한 데이터 제거 등을 제공하기 때문에 무한 스크롤 구현에 더 적합하다.
응답 형태 예시
{
"content": [
{ "id": 1, "writer": "A", "comment": "great" },
{ "id": 2, "writer": "B", "comment": "nice" }
],
"number": 0,
"size": 2,
"hasNext": true,
"first": true,
"last": false
}
Repository
사용자의 찜한 음반 리스트를 가져오려고 한다.
처음에는 다음과 같이 구현하려고 했다. 그런데 이 부분에는 JOIN FETCH와 관련된 크리티컬한 문제가 발생할 수 있다. JPA가 Slice나 Page의 페이징을 위해 LIMIT, OFFSET을 사용해야 하는데, JOIN FETCH는 페치 조인을 하면서 결과가 N+1로 부풀어져 버릴 수 있기 때문이다. 그럴 경우, 전체 결과의 개수나 hasNext 등이 잘못된 값으로 나올 수 있다. 이와 관련된 문제는 이전 포스팅에서도 설명한 바 있다.
/**
* JOIN FETCH는 연관된 Vinyl을 즉시 로딩하지만
* Vinyl 내부에 @OneToMany 관계가 여러 개 (videos, images, likes, ...) 있다면,
* 쿼리 결과가 Like 기준으로 중복 발생 → 페이징 쿼리가 정확하지 않음 (예: 중복 때문에 page size보다 적거나 많아짐)
* JOIN FETCH l.vinyl은 한 Like당 Vinyl을 끌어오게 되는데, Slice 또는 Page에서는 내부적으로 LIMIT을 걸기 때문에 이와 호환되지않는다
*/
@Query("SELECT l FROM Like l JOIN FETCH l.vinyl v WHERE l.user = :user")
Slice<Like> findByUserWithCursor(User user, @Param("cursorId") Long cursorId, Pageable pageable);
Like 테이블과 JOIN FETCH를 하는 Vinyl 테이블에는 videos, images, like 등등의 @OneToMany 관계가 많이 걸려있어서 쿼리의 결과가 부정확해질 가능성이 높았기에 다른 방법을 조사해보았다.
@EntityGraph 사용 (JPA가 JOIN FETCH 대체)
엔티티를 조회하면서 Lazy로 선언된 연관 필드를 특정 조건에서만 즉시 로딩하게 해준다. 그러나 쿼리 제어를 Hibernate가 대신 하게 되면서 제어가 어려워진다는 단점이 있다.
@EntityGraph(attributePaths = {"vinyl"})
@Query("SELECT l FROM Like l
WHERE l.user = :user
AND (:cursorId IS NULL OR l.id < :cursorId)
ORDER BY l.id DESC
""")
Slice<Like> findByUserWithCursor(User user, @Param("cursorId") Long cursorId, Pageable pageable);
구조는 유지한 채 페치 전략을 수정하는 방법인데 @EntityGraph(attributePaths = {"vinyl"})는 내부적으로 JOIN FETCH와 유사하나 Slice와도 호환이 된다는 장점이 있다. 그러나 이러한 방법도 앞서 설명한 JOIN FETCH의 단점과 같이 Vinyl 테이블에 @OneToMany 관계가 많이 걸려있을 경우, 같은 문제가 발생할 수 있기 때문이다. (vinyl은 fetch되지만, 내부의 images, tracklist, artists 등은 여전히 LAZY이고, 조회 시마다 추가 쿼리가 발생함)
DTO Projection
DTO Projection은 JPA 에서 복잡한 연관관계가 있는 엔티티에서 필요한 일부 필드만 선별적으로 조회하여 성능을 최적화시키는 방법이다. JPQL이나 네이티브 쿼리에서 직접 원하는 필드만 골라 인터페이스/클래스 기반 DTO로 매핑하는 방식이다. 연관된 Lazy 필드 강제 초기화 없이 필요 데이터만 가져온다는 것이 장점이다. (OneToMany 관계를 억지로 FETCH하지 않는다.) 이 방법은 JOIN은 가능하지만 FETCH하지 않기 때문에 Slice 사용이 가능하고, 앞서 말한 N+1 문제도 방지할 수 있다. 그러나 앞서 필요한 필드들을 먼저 정의해줘야 한다는 번거로움이 있다.
(다만 Entity 등이 변경될 경우에는 Projection과 쿼리가 깨질 가능성이 크다)
public interface LikeVinylProjection {
Long getLikeId();
Long getVinylId();
Long getDiscogsId();
String getTitle();
String getArtistsSort();
Long getLikesCount();
String getNotes();
String getStatus();
String getUri();
String getReleasedFormatted();
}
@Query("""
SELECT
l.likeId as likeId,
v.vinylId as vinylId,
v.discogsId as discogsId,
v.title as title,
v.artistsSort as artistsSort,
v.likesCount as likesCount,
v.notes as notes,
v.status as status,
v.uri as uri,
v.releasedFormatted as releasedFormatted
FROM Like l
JOIN l.vinyl v
WHERE l.user = :user
AND (:cursorId IS NULL OR l.likeId < :cursorId)
ORDER BY l.likeId DESC
""")
List<LikeVinylProjection> findVinylsLikedByUserWithCursor(User user, @Param("cursorId") Long cursorId, Pageable pageable);
Service
앞서 DTO Projection을 활용하여 가져온 사용자 별 찜한 음반 리스트를 페이징 처리 하기 위해 Service 메서드는 다음과 같이 구성했다.
@Transactional
public SliceResponse getVinylsLikedByUser(Long userId, User currentUser, Long cursorId, int size) {
// 커서 페이징 size + 1 (+1로 다음 페이지(hasNext) 존재 여부 판단)
Pageable pageable = PageRequest.of(0, size + 1);
var userEntity = getUserEntity(userId);
// DTO Projection 활용하여 가져온 사용자 별 찜한 음반 리스트
List<LikeVinylProjection> results = likeRepository.findVinylsLikedByUserWithCursor(userEntity, cursorId, pageable);
// 결과가 size보다 많다는 의미
boolean hasNext = results.size() > size;
// 마지막 1개를 제외한 size개만 클라이언트에 응답 => subList(0, size)를 이용해 앞쪽 size개만 자름
List<LikeVinylProjection> contents = hasNext ? results.subList(0, size) : results;
// 필요한 연관 관계 직접 조회 및 VinylDto로 구성 (유저가 찜한 음반 목록 조회)
List<VinylDto> vinylDtos = contents.stream()
.map(projection -> {
Vinyl vinyl = vinylRepository.findById(projection.getVinylId())
.orElseThrow(() -> new VinylNotFoundException(projection.getVinylId()));
return VinylDto.of(vinyl);
}).toList();
// 다음 요청에서 커서로 사용할 ID = 클라이언트에 반환해준 마지막 요소의 ID
Long nextCursorId = hasNext ? contents.get(contents.size() - 1).getLikeId() : null;
SliceImpl<VinylDto> sliceContents = new SliceImpl<>(vinylDtos, PageRequest.of(0, size), hasNext);
return new SliceResponse<>(sliceContents, nextCursorId);
}
Slice 방식의 커서 기반 페이징에서 다음 페이지가 존재하는지(hasNext)를 판별하는 부분이다.
Pageable pageable = PageRequest.of(0, size + 1);
var userEntity = getUserEntity(userId);
// DTO Projection 활용하여 가져온 사용자 별 찜한 음반 리스트
List<LikeVinylProjection> results = likeRepository.findVinylsLikedByUserWithCursor(userEntity, cursorId, pageable);
// 결과가 size보다 많다는 의미
boolean hasNext = results.size() > size;
- size는 한 호출 당 몇 개씩 조회해오냐는 페이징 단위 값이다. 여기서는 size의 기본값을 10으로 설정하였다
- size + 1개(=11개)를 조회해서, 사용자가 요청한 size보다 하나 더 많이 가져온다 (다음 페이지 존재 여부를 확인하기 위해! => 맨 마지막 값은 다음 요청 때 커서 ID로 사용)
- 추가된 사이즈가 프로젝션 결과(갱신되는 커서ID 이후의 값들 반환)의 사이즈보다 작다면, 다음 페이지가 있다는 뜻이다 (hasNext = true)
- 프로젝션 결과의 사이즈보다 크다면, 지금이 마지막 페이지이다 (hasNext = false)
기존의 SliceImpl<VinylDto> 형태로 반환하면 아래와 같이 불필요한 정보들도 반환되기에 SliceResponse 객체도 만들어서 응답을 좀 더 깔끔하게 정리하였다.
{
"content": [
{
"vinylId": 123,
"discogsId": 45678,
"title": "Abbey Road",
"artistsSort": "The Beatles",
"likesCount": 120,
"status": "active",
"uri": "/release/45678",
"notes": "Classic album",
"releasedFormatted": "1969-09-26",
"tracklist": [...],
"images": [...],
"formats": [...],
"videos": [...],
"artists": [...]
},
...
],
"pageable": {...},
"last": false,
"size": 5,
"number": 0,
"sort": {...},
"first": true,
"numberOfElements": 5,
"empty": false
}
SliceResponse
@Getter
public class SliceResponse<T> {
// 현재 페이지의 데이터
private final List<T> content;
// 다음 페이지 여부
private final boolean hasNext;
// 다음 페이지 요청용 커서 (마지막 요소의 ID)
private final Long nextCursorId;
private final int size;
public SliceResponse(Slice<T> sliceContent, Long lastCursorId) {
this.content = sliceContent.getContent();
this.hasNext = sliceContent.hasNext();
this.nextCursorId = lastCursorId;
this.size = sliceContent.getSize();
}
}
Controller
/**
* 유저별 찜한 음반 리스트 조회
*/
@GetMapping("/{userId}/liked")
@Operation(description = "유저별 찜한 Vinyl 리스트 조회")
public ResponseEntity<SliceResponse<VinylDto>> getVinylsLikedByUser(@PathVariable Long userId,
@AuthenticationPrincipal UserDetailsImpl userDetails,
@RequestParam(required=false) Long cursorId,
@RequestParam(defaultValue = "10") int size) {
// 특정 유저의 찜한 Vinyl 리스트를 관리자 또는 팔로우한 사용자가 조회할 수 있는 API
var response = userService.getVinylsLikedByUser(userId, userDetails.getUser(), cursorId, size);
return ResponseEntity.ok(response);
}
결론
이번 포스팅에서는 Slice를 사용한 커서 기반 페이징 기법을 직접 적용해보는 과정을 정리해보았다. 현재 개발하고 있는 애플리케이션에서는 테이블뷰 내용 끝 하단으로 스크롤할 때마다 마지막의 커서ID를 가지고 갱신을 요청하는 방식으로 구현할 수 있을 듯 하다.
'Project > Vinyler' 카테고리의 다른 글
[Vinyler] 2. 리스트 조회 시 무한 스크롤 구현(페이징 기법-1) (0) | 2025.06.27 |
---|---|
[Vinyler] 1. 개요 (0) | 2025.02.19 |