서론
현재 Vinyler 프로젝트에서는 사용자가 찜한 음반의 리스트를 조회 할 경우, 모든 데이터들을 한 번에 조회해서 가져오는 방식을 사용하고 있다. 그러나 이러한 방식은 찜한 음반들이 많아질수록 메모리 사용량이 많아짐으로 인해 서버 리소스가 낭비되고, 이는 JOIN 등의 쿼리를 사용하는 경우 쿼리 처리의 부하가 증가하게 된다.
예를 들어, 수백~수만 개의 찜한 음반들을 DTO로 매핑하고 직렬화하면, 메모리 사용량이 크게 증가하게 된다. 이로 인해 조회 작업의 시간이 늘어나게 되어 응답이 지연되는 상황이 발생할 수 있다.
현재는 다음과 같이 JOIN FETCH를 사용하여 사용자가 찜한 데이터들을 조회해오고 있다.
그런데 페이징 없이 모든 Like를 조회해오면 JOIN FETCH는 일단 모든 조인을 수행하고 결과를 메모리에 적재하기 때문에 성능에 악영향을 끼칠 수 있다.
@Query("SELECT l FROM Like l JOIN FETCH l.vinyl v WHERE l.user = :user")
List<Like> findByUser(User user);
참고: JOIN FETCH + 페이징은 정상적으로 동작하지 않음
JOIN FETCH: 연관된 엔티티를 한 번의 쿼리로 모두 로딩(N+1 문제 해결을 위한 방법)
ex) SELECT m FROM Member m JOIN FETCH m.team => Member와 Team을 한번에 가져오는 쿼리
JPA는 JOIN FETCH 시 페이징을 데이터베이스 쿼리 레벨이 아니라 메모리에서 적용하려고 한다.
1. JOIN FETCH를 하면 연관된 row 수가 늘어난다.
ex: Member 1명 + Team 3명 → 결과 row가 3줄로 중복
2. 위의 상태에서 LIMIT 10 OFFSET 0 적용되면, 진짜 10명(Member) 이 아닌 10줄(row) 기준으로 잘림
=> 결과적으로 중복된 엔티티나 일부만 나오는 문제가 발생
3. JPA는 2번과 같은 문제를 방지하기 위해 쿼리 전체 결과를 메모리에 올린 뒤, 중복 제거 후 페이징을 메모리에서 수행
그 결과, 페이징 처리를 하여 Slice나 Page를 리턴하더라도 DB에서 페이징을 적용하지 않고, 메모리 페이징을 수행한다.
그리하여 위와 같은 상황에서의 문제를 방지하기 위해 페이징 방식을 적용하기로 결정하였다. 현재 진행하는 프로젝트는 iOS 앱과 같이 개발하고 있기에 모바일 환경에서 일반적으로 쓰이는 UI 방식인 무한 스크롤 방식의 페이징을 적용하기로 하였다.
페이징 기법
페이징 기법으로는 대표적으로 크게 2가지가 있는데 다음과 같다.
1. 오프셋 기반 페이징
2. 커서 기반 페이징
페이징 기법은 클라이언트가 한 번에 모든 데이터를 불러오지 않고, 원하는 시점에 전체 데이터 중의 일부를 요청하였을 때, 요청받은 만큼의 데이터를 클라이언트로 반환해주는 방식이다.
오프셋 기반 페이징
오프셋 기반 페이징은 쿼리 실행 시 Limit과 Offset을 이용해 특정 범위의 데이터를 조회하는 기법이다.
SELECT * FROM reviews
ORDER BY created_at DESC
LIMIT 10 OFFSET 20;
생성일 기준 내림차순으로 정렬하여 최신순으로 정리된 데이터들에서 20번째 데이터부터 10개의 데이터를 가져온다. 예를 들어 쿼리 파라미터 식으로 표현한다면 page=2&size=10과 같다.
JPA에서 기본적으로 제공해주는 Pageable을 사용하여 손쉽게 구현할 수 있다는 장점이 있지만, 데이터 누락/중복 발생 가능으로 인하여 무한 스크롤과 같은 모바일 UI 환경에는 적합하지 않다.
클라이언트에 페이지 수가 표시되어 있고, 직접적으로 페이지를 선택하여 조회할 수 있는 웹 환경에 좀 더 적합한 기법이다.
커서 기반 페이징
이전 요청의 마지막 데이터의 필드값(예: id, createdAt)을 커서로 넘기고, 서버는 이 커서를 기준으로 데이터를 조회하는 방식이다. ("어디서부터 가져올 지"를 기준으로 데이터 조회하는 방식)
아래 예시를 보면 Limit과 Where 절을 사용하여 데이터를 조회한다.
Offset 없이 인덱스를 기반으로 탐색하기에 전체 페이지 수를 알 수 없는 경우의 모바일 환경에 적합하다. (이를 통해 무한 스크롤 구현이 가능하다)
SELECT * FROM reviews
WHERE id < :lastReviewId
ORDER BY id DESC
LIMIT 10;
lastReviewId는 클라이언트가 가장 최근에 본 마지막 리뷰 데이터의 id번호로, 이 커서를 기준으로 그 이후의 데이터들을 요청할 수 있다.
id < :lastReviewId : 현재 보고 있는 리뷰보다 이전 id를 조회한다. (내림차순 정렬 상태에서)
LIMIT 10 : 10개의 결과를 반환한다.
이후에 그 다음의 데이터들을 요청할 때에도, 갱신된 커서 번호를 보내어 계속적으로 마지막 데이터 이후의 데이터들을 가져올 수 있다. 그리하여 클라이언트에서는 아래 이미지와 같이 스크롤을 통해 추가적으로 데이터들을 계속 요청하여 가져올 수 있다.
결론
이번 포스팅에서는 2가지 대표적인 페이징 기법의 개념을 정리해보고 현재 진행중인 'Vinyler' 프로젝트에 적합한 기법을 선택하는 과정을 정리하였다. 다음 포스팅에서는 커서 기반의 페이징 기법을 통해 Spring Boot에서 무한 스크롤을 구현하는 과정을 정리해보고자 한다.
Flutter에서 쉽게 무한 스크롤을 만들어보자
Flutter 패키지 flat_list
medium.com
'Project > Vinyler' 카테고리의 다른 글
[Vinyler] 3. 리스트 조회 시 무한 스크롤 구현(페이징 기법-2) (0) | 2025.06.30 |
---|---|
[Vinyler] 1. 개요 (0) | 2025.02.19 |