2024. 8. 16. 15:35ㆍ개인 공부
페이지네이션이란?
일반적인 경우 많은 양의 데이터를 조회할 때 한 번에 가져오지 않고 페이지로 쪼개서 가져온다.
이를 페이지네이션(Pagination)이라고 하며 특정한 정렬 기준에 따라, 지정된 갯수의 데이터를 가져오는 것이다.
페이지네이션은 대표적으로 다음과 같이 두 가지 방식으로 처리된다.
- 오프셋 기반 페이지네이션 (Offset-Based Pagination)
- 커서 기반 페이지네이션 (Cursor-Based Pagination)
각각의 특징과 장단점을 살펴보고, 커서 기반 페이지네이션으로 타임라인 기능을 구현한 경험을 작성하겠습니다.
오프셋 기반 페이지네이션
- LIMIT, OFFSET 쿼리를 사용해 '페이지' 단위로 구분하여 응답하도록 구현한다.
SELECT * FROM feed ORDER BY feed_id DESC LIMIT 20 OFFSET 40;
- MYSQL 기준으로 간단히 뒤에 LIMIT, OFFSET 을 붙여 건너 뛸 row의 숫자(오프셋)와 가져올 행의 개수(LIMIT)을 걸 수 있다.
- Spring Data JPA 에서는 Pageable, PageRequest 를 사용해 페이징을 처리할 수 있다.
Offset 페이지네이션은 LIMIT, OFFSET 쿼리를 통해 원하는 데이터를 가져온다.
Offset 기반 페이지네이션의 문제점
페이지를 요청하는 사이에, 데이터가 추가/삭제 된다면 중복 데이터가 노출된다.
- 유저가 1페이지 게시글을 보고 있는 사이에, 다른 게시글이 5개가 올라왔다. 유저는 1페이지를 다 보고 2페이지를 눌렀다.
- 유저는 이미 1페이지에서 봤던 게시글 중 마지막 5개를 2페이지에서 다시 만나게 된다.
데이터가 올라오는 도중에 이런 일을 겪어본 적이 있을 것이다.
이런 경우에는 오프셋 기반 페이지네이션으로 구현되었음을 짐작할 수 있다.
OFFSET 쿼리의 성능 이슈
- 임시로 쿼리의 모든 값들을 전부 만들어놓은 후, 지정된 offset 만큼 건너 뛰어 limit 만큼 잘라낸다.
- offset이 작은 수라면 크게 문제가 되지 않지만 row 수가 많은 경우 offset 값이 올라갈 수록 쿼리의 퍼포먼스는 떨어지게 된다.
이처럼 offset 기반 페이지네이션은 성능 면에서 한계를 갖는데, 그럼 쓰지 않는 게 좋을까?
상황에 따라 다르다.
- 데이터의 변화가 거의 없어 중복 데이터가 노출될 염려가 없는 경우, 중복되어 노출되어도 크게 문제가 되지 않는 경우
- row의 수가 그렇게 많지 않아 특별한 퍼포먼스의 걱정이 없는 경우
- 페이지 번호가 명시된 경우 : 사용자가 특정 페이지로 이동할 수 있게 할 수 있게 함
이와 같은 경우에는 오프셋 기반의 페이지네이션을 사용해도 괜찮다.
특히 페이지 번호가 있는 경우에는 Offset 기반 페이징을 주로 사용한다.
커서 기반 페이지네이션
커서 기반 페이지네이션은, 우리가 원하는 데이터가 어떤 데이터의 다음에 있다는 것에 집중한다.
"row를 스킵하는 것이 아니라 해당 row 다음꺼부터 n 개 주세요".
따라서 속도가 오프셋 기반보다 훨씬 빠르다.
기본 아이디어는 id(커서) 기준으로 다음 번 데이터를 조회하는 것이다.
다음 프로젝트 예시를 통해 살펴보자.
플리커 화면
내가 수많은 사람들을 팔로우 하고 있고, 인스타그램처럼 팔로우들의 게시글(피드)을 최신순으로 볼 수 있는 기능을 구현하려고 한다.
타임라인(Timeline) 기능
- 내가 팔로우하는 사람들의 게시글을 보는 기능 (like 인스타그램)
- 최신순으로, 현재로부터 과거 1주일치만 보여준다.
- 무한스크롤
인스타그램처럼 내가 팔로우 하고 있는 사람들의 게시글을 최신순으로 정렬해서 보여주는, 타임라인 기능을 개발하려고 한다.
코드
@RestController
@RequestMapping("/api/timeline")
@RequiredArgsConstructor
public class TimelineController {
private final TimelineService timelineService;
@GetMapping
public List<SocialPost> getTimeline(@RequestParam(required = false) Integer lastSeenId,
@RequestParam(defaultValue = "20") int limit) {
if (limit > 30) {
throw new BadRequestException();
}
int userId = SecurityContextHolderUtils.getUserId();
return timelineService.getUserTimeline(userId, lastSeenId, limit);
}
}
lastSeenId 가 커서 역할을 한다.
limit = 20으로 설정해주고 20번째를 넘어가면 API 를 호출, 이때는 마지막 게시글 id 인 lastSeenId를 요청과 함께 넘겨주어야 한다.
@Service
@RequiredArgsConstructor
public class TimelineService {
private final FollowRepository followRepository;
private final FeedRepository feedRepository;
public List<SocialPost> getUserTimeline(int userId, Integer lastSeenId, int limit) {
List<Integer> followingIds = followRepository.getFollowingIds(userId);
List<SocialFeed> feeds;
Pageable pageable = PageRequest.of(0, limit);
if (lastSeenId == null) {
feeds = feedRepository.findInitialFeedsByFollowings(followingIds, pageable);
} else { // Cursor 기반 추가 피드 조회
feeds = feedRepository.findFeedsByFollowingsAfter(followingIds, lastSeenId, pageable);
}
return feeds.stream()
.map(feed -> new SocialPost(
feed, feedRepository.countLikes(feed.getFeedId())))
.collect(Collectors.toList());
}
}
public interface FeedJpaRepository extends JpaRepository<SocialFeed, Integer> {
@Query("SELECT f FROM SocialFeed f JOIN FETCH f.user WHERE f.user.userId IN :followingIds AND f.feedId < :lastSeenId ORDER BY f.feedId DESC")
List<SocialFeed> findFeedsByFollowingsAfter(@Param("followingIds") List<Integer> followerIds, @Param("lastSeenId") int lastSeenId, Pageable pageable);
@Query("SELECT f FROM SocialFeed f JOIN FETCH f.user WHERE f.user.userId IN :followingIds ORDER BY f.feedId DESC")
List<SocialFeed> findInitialFeedsByFollowings(@Param("followingIds") List<Integer> followerIds, Pageable pageable);
}
가장 처음에는 lastSeenId = null 인 경우로, 초기 데이터를 불러온다. 이 때는 그냥 LIMIT을 통해서만 불러오게 된다.
그 후 추가적인 데이터는 lastSeenId와 함께 가져온다.
Spring DATA JPA에서 Pageable 은 JPQL에서 저절로 LIMIT, OFFSET 쿼리를 만들어준다.
Cursor : feed_id (피드 테이블의 PK) 를 사용해 인덱스가 걸려 있으므로 빠르게 찾을 수 있다.
해당 값을 빠르게 찾아 Limit 만큼 가져오기 때문에 속도가 매우 빠르다.
findFeedsByFollowingsAfter() 메서드에서 발생하는 쿼리는 다음과 같다.
SELECT f.*, u.* FROM SocialFeed f JOIN User u ON f.user_id = u.user_id
WHERE f.user_id IN (1, 2, 3) AND f.feed_id < 100
ORDER BY f.feed_id DESC LIMIT 20;
feed_id 는 feed 테이블의 PK이고, 따라서 최근에 만들어진 데이터일수록 feed_id 값도 크다.
ORDER BY feed_id 로 최신순으로 데이터를 정렬할 수 있고
feed_id < {lastSeenId} 로 다음 데이터를 Limit 만큼 가져올 수 있다.
WHERE 절을 보면 IN 으로 팔로우들의 id (내가 팔로우한 사람들의 id 리스트) 가 들어간다.
EXPLAIN 실행 계획 확인
WHERE user_id IN (1, 2, 3)
AND feed_id < 94133
ORDER BY feed_id DESC
LIMIT 20;
'possible_keys' 와 'key' 에 나타나는 인덱스는 해당 쿼리에서 실제로 사용 가능한 인덱스와 사용된 인덱스를 나타낸다.
해당 쿼리에서 실제로 user_id 컬럼에 걸린 인덱스가 사용되었다.
MySQL 옵티마이저가 PK(feed_id)를 인덱스로 사용하지 않고 user_id 컬럼에 걸려있는 인덱스만 사용했다.
팔로우들이 작성한 feed 를 가져와 최신 순(feed_id) 내림차순 정렬하므로
WHERE 절에는 팔로우 들의 id 리스트가 들어간다. 따라서 user_id 인덱스만 사용한 것으로 추정!
타임라인 API 수행 결과
맨 처음 불러올 때에는 lastSeenId 를 넣지 않고 20개를 그냥 불러온다.
가장 마지막 피드 id = 636 을 넣어서 불러오자.
feed_id = 636 다음 행부터 잘 불러오는 것을 확인할 수 있다.
결론
페이지네이션의 대표적인 2가지 방법을 학습하고 서비스 성격에 따라 적용해보았다.
프로젝트에서, 내 팔로우들의 피드 정보를 가져오는 타임라인 기능은 무한스크롤로 구현하려고 했으므로 Cursor 기반으로 구현했다.
커서를 'lastSeenId' 로 클라이언트가 서버에 요청하게 했고, 피드 테이블의 PK 인 feed_id 로 정했다.
또한 팔로우들의 id 리스트를 기반으로 탐색하므로 피드 테이블의 user_id 컬럼에 인덱스를 걸었고 EXPLAIN 을 통해 해당 인덱스를 사용하는 것을 확인했다.
'개인 공부' 카테고리의 다른 글
웹 소켓 & SSE(Server Sent Events) (0) | 2024.06.10 |
---|---|
aws-cli 로 AWS 접속하기 (1) | 2024.06.07 |