이번 포스팅에서 작성해 볼 내용은 `StudyWithMe` 프로젝트에서 페이징 방식을
`Offset` -> `Cursor` 방식으로 변경하면서 사용한 방법과 성능 차이에 관해 포스팅 하려 한다.
본격적인 시작에 앞서, `Pagination`, `Offset`, `Cursor` 방식에 대해 간략하게 알아보고 가자.
만약, 관련 페이지네이션 지식을 전부 안다고 하면 넘어가도 된다.
페이지네이션? (Pagination, 페이징)
만약, 우리가 웹 페이지를 로드할 때, 동시에 모든 데이터를 다 불러와서 처리한다면 가장 먼저 걱정되는 건 무엇일까?
그건 당연히 성능이다. 그렇기에 페이지네이션은 거의 필연적으로 구현해야 하는 기능이다.
즉, 페이지네이션이란?
`특정한 정렬 기준 + 필요한 개수`의 조건에 맞춰 데이터를 가져올 수 있게 해주는 기능을 말한다.
보통 필요한 개수를 지정한 뒤 상황에 맞춰 정렬기준이 조건에 추가되는데,
이러한 조건을 맞춰 데이터를 가져오는 것을 페이지네이션, 줄여서 페이징이라고 한다.
위와 같은 페이지네이션을 처리하는 방법으로는 크게
- 오프셋 기반 페이지네이션(offset-based pagination)
- 커서 기반 페이지네이션(cursor-based pagination)
이 두 가지가 존재한다.
Offset-Paging(offset-based pagination)
`MySQL` 기준 `LIMIT` 쿼리를 사용하면 간단하게 구현이 가능하다.
N개의 데이터를 page - 1 만큼 Skip하고 그 다음부터 n개의 데이터를 요청하는 식이다.
SELECT * FROM users ORDER BY id DESC LIMIT 5,10; // 5개 skip후 10개 요청
SELECT * FROM users ORDER BY id DESC LIMIT 15 OFFSET 10;
SELECT * FROM users ORDER BY id DESC LIMIT 25 OFFSET 10; .....
위 처럼 비교적 구현하고 사용하기에 간단한 것이 `Offset-Paging` 방식이다.
하지만, `Offset-Paging` 방식에는 몇 가지 문제점이 존재한다.
1. CREATE 시 데이터 중복 출력의 문제
유저 1이 1페이지에 접속해서 5개의 데이터(`ㄱ~ㅁ`)를 확인했다고 가정하자.
이후, 유저2가 새 데이터 `ㅂ`와 `ㅅ`를 새롭게 등록했다면?
`ㄹ, ㅁ`를 중복해서 보게 된다.
만약, 동시에 많은 유저 접속해 새로운 데이터를 초당 10개씩 생성하는 서비스라면?
- 1페이지에서 봤던 10개의 데이터를 고대로 다시 보게 된다.
즉, 각각의 페이지를 요청하는 사이에 데이터의 변화가 있다면 중복 데이터가 노출되는 문제가 발생한다.
2. DELETE 시 데이터 누락의 문제
비슷하게 유저가 1페이지에 접속해서 5개의 데이터(`ㄱ~ㅁ`)를 확인하고,
이후 관리자가 `ㄱ, ㄴ`의 데이터를 삭제했다면?
사용자는 새로고침을 하지 않는 이상 2페이지에 있던 `a`, `b`의 데이터는 볼 수 없다.
3. row 개수에 따른 성능 문제
데이터는 정렬 기준(`ORDER BY`)에 따라 row의 순서가 바뀌게 된다.
DB는 모든 경우에 따른 rownum을 갖고 있지 않기 때문에, 해당 row가 몇 번째 순서를 갖는지 알지 못한다.
요청한 데이터를 바로 조회할 수 없다.
=> 즉, 이전의 데이터를 모두 조회 후 그 `ResultSet`에서 offset을 조건으로 잘라내는 방식이다.
이는 row의 수가 많아지고 offset 값이 올라갈수록 쿼리의 퍼포먼스가 떨어지는 문제를 발생시킨다.
offset vs cursor 페이징의 시간 복잡성 비교
- 오프셋 페이징: O(N), O(offset + limit)으로 offset이 커질수록 시간증가해 UX 감소
- 커서 페이징: O(1), O(limit)으로 항상 일정
그렇다면 Cursor 방식은 뭐가 다를까?
Cursor-Paging(cursor-based pagination)
클라이언트가 가져간 마지막 row의 순서상 다름 row들을 n개 요청/응답하게 구현
- 주로 무한 스크롤이나 더보기를 구현할 때 사용된다.(SNS)
- `어떤 데이터의 다음`에 있다는데에 집중
커서 페이징의 구현
- `cursor`가 가리키는 것 다음부터 n개의 데이터를 요청하는 방식이다.
이때, `cursor`에는 고유값이 껴있어야 한다.
이외에도 `cursor` 사용 시 주의해야할 점이 있다.
- 클라이언트가 `ORDER BY`에 걸려있는 모든 필드를 알아야하며,
매 페이지 요청시마다 이 값들을 전부 보내야 한다. - 그렇기에 WHERE 절에 걸리는 조건들을 활용해 고유한 값인 `cursor(커서)`를 만들어야 한다.
그럼 `StudyWithMe`는 어떻게 코드를 구현했을까?
초기
초기에는 `Offset-based pagination` 방식을 채택을 했다.
StudyRoomQueryService(offset)
package com.jj.swm.domain.studyroom.service;
...
import static com.jj.swm.domain.studyroom.entity.QStudyRoom.studyRoom;
@Service
@RequiredArgsConstructor
public class StudyRoomQueryService {
private final StudyRoomRepository studyRoomRepository;
private final StudyRoomBookmarkRepository studyRoomBookmarkRepository;
@Value("${studyroom.page.size}")
private int studyRoomPageSize;
@Transactional(readOnly = true)
public PageResponse<GetStudyRoomResponse> getStudyRooms(
GetStudyRoomCondition condition,
int page,
UUID userId
) {
Pageable pageable = createPageable(condition.getSortCriteria(), page);
Page<StudyRoom> studyRooms = studyRoomRepository.findAllWithPagination(studyRoomPageSize, pageable, condition);
if(studyRooms.isEmpty()) {
return PageResponse.of(List.of(), false);
}
List<Long> studyRoomIds = studyRooms.stream()
.map(StudyRoom::getId)
.toList();
Map<Long, Long> bookmarkIdByStudyRoomId
= mappingStudyRoomBookmarkAndUser(studyRoomIds, userId);
PageResponse<GetStudyRoomResponse> pageResponse = PageResponse.of(
studyRooms,
studyRoom -> GetStudyRoomResponse.of(
studyRoom,
bookmarkIdByStudyRoomId.getOrDefault(studyRoom.getId(), null)
)
);
return pageResponse;
}
private Map<Long, Long> mappingStudyRoomBookmarkAndUser(List<Long> studyRoomIds, UUID userId) {
return userId != null
? studyRoomBookmarkRepository.findAllByUserIdAndStudyRoomIds(userId, studyRoomIds)
.stream()
.collect(Collectors.toMap(StudyRoomBookmarkInfo::getId, StudyRoomBookmarkInfo::getStudyRoomId))
: Collections.emptyMap();
}
private Sort.Order createSortOrder(SortCriteria sortCriteria) {
switch (sortCriteria) {
case STARS:
return new Sort.Order(Sort.Direction.DESC, "averageRating"); // 예: 별점 기준 내림차순
case LIKE:
return new Sort.Order(Sort.Direction.DESC, "likeCount"); // 예: 좋아요 수 기준 내림차순
case REVIEW:
return new Sort.Order(Sort.Direction.DESC, "reviewCount"); // 예: 리뷰 수 기준 내림차순
case PRICE:
return new Sort.Order(Sort.Direction.ASC, "entireMinPricePerHour"); // 예: 가격 기준 오름차순
default:
throw new IllegalArgumentException("Invalid SortCriteria");
}
}
public Pageable createPageable(SortCriteria sortCriteria, int page) {
Sort.Order sortOrder = createSortOrder(sortCriteria); // 정렬 조건을 가져옴
Sort sort = Sort.by(sortOrder); // Sort 객체 생성
return PageRequest.of(page, studyRoomPageSize, sort); // Pageable에 Sort 적용
}
}
CustomStudyRoomRepositoryImpl(offset)
@RequiredArgsConstructor
public class CustomStudyRoomRepositoryImpl implements CustomStudyRoomRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public Page<StudyRoom> findAllWithPagination(int pageSize, Pageable pageable, GetStudyRoomCondition condition) {
// 기본 조건에 대한 Querydsl 검색
List<StudyRoom> results = jpaQueryFactory.selectFrom(studyRoom)
.where(
studyRoomTitleContains(condition.getTitle()),
studyRoomHeadcountGoe(condition.getHeadCount()),
studyRoomPriceBetween(condition.getMinPricePerHour(), condition.getMaxPricePerHour()),
studyRoomOptionsContains(condition.getOptions())
)
.orderBy(createOrderSpecifiers(pageable.getSort()))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// 전체 개수 조회
long total = jpaQueryFactory.select(studyRoom.count())
.from(studyRoom)
.where(
studyRoomTitleContains(condition.getTitle()),
studyRoomHeadcountGoe(condition.getHeadCount()),
studyRoomPriceBetween(condition.getMinPricePerHour(), condition.getMaxPricePerHour()),
studyRoomOptionsContains(condition.getOptions())
)
.fetchOne();
// Page 객체 반환
return new PageImpl<>(results, pageable, total);
}
private BooleanBuilder studyRoomTitleContains(String title) {
return this.nullSafeBuilder(() -> studyRoom.title.contains(title));
}
private BooleanBuilder studyRoomHeadcountGoe(int headCount) {
return this.nullSafeBuilder(() -> studyRoom.entireMaxHeadcount.goe(headCount));
}
private BooleanBuilder studyRoomPriceBetween(int minPricePerHour, int maxPricePerHour) {
return this.nullSafeBuilder(() -> studyRoom.entireMinPricePerHour.between(minPricePerHour, maxPricePerHour));
}
private BooleanBuilder studyRoomOptionsContains(List<StudyRoomOption> options) {
return options == null || options.isEmpty()
? null
: this.nullSafeBuilder(() -> studyRoom.optionInfos.any().option.in(options));
}
private BooleanBuilder nullSafeBuilder(Supplier<BooleanExpression> f) {
try {
return new BooleanBuilder(f.get());
} catch (IllegalArgumentException | NullPointerException e) {
return null;
}
}
private OrderSpecifier<?>[] createOrderSpecifiers(Sort sort) {
// Sort가 비어있다면 기본값을 사용할 수도 있습니다.
if (sort.isEmpty()) {
return new OrderSpecifier<?>[0]; // 정렬 조건이 없다면 빈 배열 반환
}
List<OrderSpecifier<?>> orderSpecifiers = new ArrayList<>();
for (Sort.Order order : sort) {
// 각 Sort.Order에 대해 OrderSpecifier 생성
OrderSpecifier<?> orderSpecifier;
String fieldName = order.getProperty(); // 정렬 기준이 될 필드 이름
// fieldName에 맞는 QStudyRoom 필드를 사용하여 OrderSpecifier를 생성
switch (fieldName) {
case "averageRating":
orderSpecifier = order.getDirection() == Sort.Direction.ASC
? new OrderSpecifier<>(Order.ASC, studyRoom.averageRating)
: new OrderSpecifier<>(Order.DESC, studyRoom.averageRating);
break;
case "likeCount":
orderSpecifier = order.getDirection() == Sort.Direction.ASC
? new OrderSpecifier<>(Order.ASC, studyRoom.likeCount)
: new OrderSpecifier<>(Order.DESC, studyRoom.likeCount);
break;
case "reviewCount":
orderSpecifier = order.getDirection() == Sort.Direction.ASC
? new OrderSpecifier<>(Order.ASC, studyRoom.reviewCount)
: new OrderSpecifier<>(Order.DESC, studyRoom.reviewCount);
break;
case "entireMinPricePerHour":
orderSpecifier = order.getDirection() == Sort.Direction.ASC
? new OrderSpecifier<>(Order.ASC, studyRoom.entireMinPricePerHour)
: new OrderSpecifier<>(Order.DESC, studyRoom.entireMinPricePerHour);
break;
default:
throw new IllegalArgumentException("Invalid sort property: " + fieldName);
}
orderSpecifiers.add(orderSpecifier);
}
// 생성된 OrderSpecifier 배열 반환
return orderSpecifiers.toArray(new OrderSpecifier[0]);
}
}
초기에는 이렇게 `Offset` 기반으로 구현을 했다.
하지만, 우리가 제작하고 싶었던 서비스의 스터디 룸 조회 과정은 무한 스크롤 방식이 좀 더 적합했다.
그렇다고 무턱대고 `Cursor`를 적용하는 것은 좀 아니라는 생각이 들어
한번 테스트를 진행해봤다.
아래와 같이 초기 데이터를 넣어주었다.(init.sql을 사용하면 좋겠지만.. `Flyway`를 사용중이기에..)
StudyRoomDataInitializer
총 10000개의 데이터를 삽입
package com.jj.swm.domain.studyroom;
import org.springframework.boot.CommandLineRunner;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import java.time.LocalTime;
import java.util.Random;
import java.util.UUID;
import java.util.stream.IntStream;
@Component
public class StudyRoomDataInitializer implements CommandLineRunner {
private final JdbcTemplate jdbcTemplate;
public StudyRoomDataInitializer(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public void run(String... args) throws Exception {
String sql = "INSERT INTO study_room (" +
"title, subtitle, introduce, notice, thumbnail, opening_time, closing_time, guideline, " +
"address, detail_address, region, locality, reference_url, phone_number, like_count, " +
"review_count, average_rating, min_reserve_time, entire_min_headcount, entire_max_headcount, " +
"entire_min_price_per_hour, entire_max_price_per_hour, deleted_at, user_id, created_at, updated_at" +
") VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())";
Random random = new Random();
UUID userId = UUID.fromString("d554b429-366f-4d8e-929d-bb5479623eb9");
IntStream.rangeClosed(1, 10000).forEach(id -> {
String region = switch (id % 5) {
case 0 -> "Region A";
case 1 -> "Region B";
case 2 -> "Region C";
case 3 -> "Region D";
default -> "Region E";
};
String locality = switch (id % 3) {
case 0 -> "Locality X";
case 1 -> "Locality Y";
default -> "Locality Z";
};
String phoneNumber = String.format("010-%04d-%04d",
random.nextInt(9000) + 1000,
random.nextInt(9000) + 1000);
jdbcTemplate.update(sql,
"Study Room " + id, // title
"Subtitle " + id, // subtitle
"This is the introduction for study room " + id, // introduce
"Notice for study room " + id, // notice
"https://example.com/thumbnail/" + id + ".jpg", // thumbnail
LocalTime.MIN, // opening_time
LocalTime.MAX, // closing_time
"Guideline for study room " + id, // guideline
"Address " + id, // address
"Detail Address " + id, // detail_address
region, // region
locality, // locality
"https://example.com/" + id, // reference_url
phoneNumber, // phone_number
random.nextInt(1000), // like_count
random.nextInt(500), // review_count
Math.round(random.nextDouble() * 5 * 100) / 100.0, // average_rating
1, // min_reserve_time
random.nextInt(10) + 1, // entire_min_headcount
random.nextInt(50) + 10, // entire_max_headcount
random.nextInt(1000) + 10000, // entire_min_price_per_hour
random.nextInt(2000) + 20000, // entire_max_price_per_hour
null, // deleted_at
userId // user_id
);
});
System.out.println("Inserted 10,000 study room entries.");
}
}
관련 K6 Script(studyroom-rps-test.js)
랜덤한 정렬 기준으로 30초 간 지속적으로 조회 요청.
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 300 }, // 300 VUs로 증가
{ duration: '10s', target: 200 }, // 200 VUs로 감소
{ duration: '10s', target: 200 }, // 유지
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이내에 응답
http_req_failed: ['rate<0.01'], // 에러율 1% 미만
},
};
const baseUrl = 'http://localhost:8080/api/v1/studyroom/pagination';
function getRandomSortCriteria() {
const criteria = ['STARS', 'LIKE', 'REVIEW', 'PRICE'];
return criteria[Math.floor(Math.random() * criteria.length)];
}
let sortCriteria = getRandomSortCriteria();
function getRandomQueryParams() {
return {
headCount: Math.floor(Math.random() * 10) + 1, // 1 to 10
minPricePerHour: Math.random() > 0.5 ? Math.floor(Math.random() * 1000) + 10000 : undefined, // Optional
maxPricePerHour: Math.random() > 0.5 ? Math.floor(Math.random() * 2000) + 20000 : undefined, // Optional
sortCriteria,
page: Math.floor(Math.random() * 1001) + 1,
};
}
export default function () {
const params = getRandomQueryParams();
const queryString = Object.entries(params)
.filter(([_, value]) => value !== undefined) // 필터링하여 정의되지 않은 값 제거
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
const url = `${baseUrl}?${queryString}`;
const response = http.get(url);
check(response, {
'status is 200': (r) => r.status === 200,
'response contains valid data': (r) => r.json() && r.json().length >= 0,
});
sleep(1);
}
위처럼 K6 테스트를 작성하고 한 번 실행을 해보았다.
k6 run studyroom-rps-test.js
결과
와우.. 1초..?(음 분명히 맞게 테스트한 것 같은데 생각보다 평균 응답 시간이 높아서 놀랐다.)
`offset-based-pagination`의 성능 테스트를 경험하고 과연,
`cursor-based-pagination`는 어떨까 하고 코드를 다시 변경해보았다.
Cursor 기반으로 코드 변경
Cursor를 사용할 때 (정렬 값, 스터디 룸 ID)으로 정렬했고
API 요청 시 최근 정렬 값, 스터디 룸 ID 값을 받아 Cursor 적용 시 데이터가 엉키는 문제를 해결했다.
정렬 값
- STARS
- 이용 후기 평점
- LIKE
- 좋아요 수
- REVIEW
- 리뷰 수
- Price
- 가격 순
StudyRoomQueryService(Cursor)
@Service
@RequiredArgsConstructor
public class StudyRoomQueryService {
private final StudyRoomRepository studyRoomRepository;
private final StudyRoomBookmarkRepository studyRoomBookmarkRepository;
@Value("${studyroom.page.size}")
private int studyRoomPageSize;
@Transactional(readOnly = true)
public PageResponse<GetStudyRoomResponse> getStudyRooms(
GetStudyRoomCondition condition,
UUID userId
) {
List<StudyRoom> studyRooms
= studyRoomRepository.findAllWithPaginationAndCondition(studyRoomPageSize + 1, condition);
if(studyRooms.isEmpty()) {
return PageResponse.of(List.of(), false);
}
boolean hasNext = studyRooms.size() > studyRoomPageSize;
List<StudyRoom> pagedStudyRooms = hasNext ? studyRooms.subList(0, studyRoomPageSize) : studyRooms;
List<Long> studyRoomIds = pagedStudyRooms.stream()
.map(StudyRoom::getId)
.toList();
Map<Long, Long> bookmarkIdByStudyRoomId
= mappingStudyRoomBookmarkAndUser(studyRoomIds, userId);
List<GetStudyRoomResponse> responses = pagedStudyRooms.stream()
.map(studyRoom -> GetStudyRoomResponse.of(
studyRoom,
bookmarkIdByStudyRoomId.getOrDefault(studyRoom.getId(), null)))
.toList();
return PageResponse.of(responses, hasNext);
}
private Map<Long, Long> mappingStudyRoomBookmarkAndUser(List<Long> studyRoomIds, UUID userId) {
return userId != null
? studyRoomBookmarkRepository.findAllByUserIdAndStudyRoomIds(userId, studyRoomIds)
.stream()
.collect(Collectors.toMap(StudyRoomBookmarkInfo::getId, StudyRoomBookmarkInfo::getStudyRoomId))
: Collections.emptyMap();
}
}
CustomStudyRoomRepositoryImpl(Cursor)
@RequiredArgsConstructor
public class CustomStudyRoomRepositoryImpl implements CustomStudyRoomRepository {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<StudyRoom> findAllWithPaginationAndCondition(int pageSize, GetStudyRoomCondition condition) {
return jpaQueryFactory.selectFrom(studyRoom)
.where(
studyRoomTitleContains(condition.getTitle()),
studyRoomHeadcountGoe(condition.getHeadCount()),
studyRoomPriceBetween(condition.getMinPricePerHour(), condition.getMaxPricePerHour()),
studyRoomOptionsContains(condition.getOptions()),
createSortPredicate(condition)
)
.orderBy(createOrderSpecifier(condition.getSortCriteria()))
.limit(pageSize)
.fetch();
}
private BooleanBuilder studyRoomTitleContains(String title) {
return this.nullSafeBuilder(() -> studyRoom.title.contains(title));
}
private BooleanBuilder studyRoomHeadcountGoe(int headCount) {
return this.nullSafeBuilder(() -> studyRoom.entireMaxHeadcount.goe(headCount));
}
private BooleanBuilder studyRoomPriceBetween(int minPricePerHour, int maxPricePerHour) {
return this.nullSafeBuilder(() -> studyRoom.entireMinPricePerHour.between(minPricePerHour, maxPricePerHour));
}
private BooleanBuilder studyRoomOptionsContains(List<StudyRoomOption> options) {
return options == null || options.isEmpty()
? null
: this.nullSafeBuilder(() -> studyRoom.optionInfos.any().option.in(options));
}
private BooleanBuilder createSortPredicate(GetStudyRoomCondition condition) {
Integer lastSortValue = condition.getLastSortValue();
Long lastStudyRoomId = condition.getLastStudyRoomId();
SortCriteria sortCriteria = condition.getSortCriteria();
Double lastAverageRating = condition.getLastAverageRatingValue();
return switch (sortCriteria) {
case STARS -> this.nullSafeBuilder(() -> studyRoom.averageRating.lt(lastAverageRating)
.or(studyRoom.averageRating.eq(lastAverageRating).and(studyRoom.id.lt(lastStudyRoomId))));
case LIKE -> this.nullSafeBuilder(() -> studyRoom.likeCount.lt(lastSortValue)
.or(studyRoom.likeCount.eq(lastSortValue).and(studyRoom.id.lt(lastStudyRoomId))));
case REVIEW -> this.nullSafeBuilder(() -> studyRoom.reviewCount.lt(lastSortValue)
.or(studyRoom.reviewCount.eq(lastSortValue).and(studyRoom.id.lt(lastStudyRoomId))));
case PRICE -> this.nullSafeBuilder(() -> studyRoom.entireMinPricePerHour.gt(lastSortValue)
.or(studyRoom.entireMinPricePerHour.eq(lastSortValue).and(studyRoom.id.lt(lastStudyRoomId))));
default -> this.nullSafeBuilder(() -> studyRoom.id.lt(lastStudyRoomId));
};
}
private OrderSpecifier<?>[] createOrderSpecifier(SortCriteria sortCriteria) {
return switch (sortCriteria) {
case STARS -> new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.DESC, studyRoom.averageRating),
new OrderSpecifier<>(Order.DESC, studyRoom.id),
};
case LIKE -> new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.DESC, studyRoom.likeCount),
new OrderSpecifier<>(Order.DESC, studyRoom.id),
};
case REVIEW -> new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.DESC, studyRoom.reviewCount),
new OrderSpecifier<>(Order.DESC, studyRoom.id),
};
case PRICE -> new OrderSpecifier<?>[]{
new OrderSpecifier<>(Order.ASC, studyRoom.entireMinPricePerHour),
new OrderSpecifier<>(Order.DESC, studyRoom.id),
};
};
}
private BooleanBuilder nullSafeBuilder(Supplier<BooleanExpression> f) {
try {
return new BooleanBuilder(f.get());
} catch (IllegalArgumentException | NullPointerException e) {
return null;
}
}
}
관련 K6 Script(studyroom-rps-test-cursor.js)
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '10s', target: 300 }, // 300 VUs로 증가
{ duration: '10s', target: 200 }, // 200 VUs로 감소
{ duration: '10s', target: 200 }, // 유지
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% 요청이 500ms 이내에 응답
http_req_failed: ['rate<0.01'], // 에러율 1% 미만
},
};
const baseUrl = 'http://localhost:8080/api/v1/studyroom';
function getRandomSortCriteria() {
const criteria = ['STARS', 'LIKE', 'REVIEW', 'PRICE'];
return criteria[Math.floor(Math.random() * criteria.length)];
}
// 전역 변수로 Cursor 값을 유지
let sortCriteria = getRandomSortCriteria();
let lastSortValue = undefined;
let lastAverageRatingValue = undefined;
let lastStudyRoomId = undefined;
export default function () {
// Cursor 값을 기반으로 쿼리 파라미터 생성
const params = {
headCount: Math.floor(Math.random() * 10) + 1, // 1 to 10
minPricePerHour: Math.random() > 0.5 ? Math.floor(Math.random() * 10000) + 1000 : undefined, // Optional
maxPricePerHour: Math.random() > 0.5 ? Math.floor(Math.random() * 10000) + 2000 : undefined, // Optional
sortCriteria,
lastStudyRoomId,
};
// SortCriteria에 따라 Cursor 값 추가
if (sortCriteria === 'STARS') {
params.lastAverageRatingValue = lastAverageRatingValue;
} else {
params.lastSortValue = lastSortValue;
}
// QueryString 생성
const queryString = Object.entries(params)
.filter(([_, value]) => value !== undefined) // 필터링하여 정의되지 않은 값 제거
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join('&');
const url = `${baseUrl}?${queryString}`;
// 요청 전송
const response = http.get(url);
// 응답 검사 및 Cursor 값 업데이트
check(response, {
'status is 200': (r) => r.status === 200,
'response contains valid data': (r) => r.json() && r.json().length >= 0,
});
const responseData = response.json();
if (responseData && responseData.length > 0) {
// 마지막 데이터의 Cursor 값 갱신
const lastItem = responseData[responseData.length - 1];
lastStudyRoomId = lastItem.id;
if (sortCriteria === 'STARS') {
lastAverageRatingValue = lastItem.averageRating;
} else {
lastSortValue = lastItem.sortValue;
}
} else {
// 응답이 비어있거나 Cursor 값이 0이면 정렬 기준 변경
if (sortCriteria === 'STARS' && (!lastAverageRatingValue || lastAverageRatingValue === 0)) {
sortCriteria = getRandomSortCriteria();
lastAverageRatingValue = undefined;
lastSortValue = undefined;
lastStudyRoomId = undefined;
} else if (sortCriteria !== 'STARS' && (!lastSortValue || lastSortValue === 0)) {
sortCriteria = getRandomSortCriteria();
lastAverageRatingValue = undefined;
lastSortValue = undefined;
lastStudyRoomId = undefined;
}
}
sleep(1);
}
Cursor K6 테스트에서는 마지막 정렬 값이 0인 경우나 NULL인 경우 새롭게 정렬 조건을 뽑아서 다시 요청하도록 코드를 작성했다.
결과
엥? `44.71ms`..? 이렇게 차이가 난다고..?(내가 잘못 테스트 한건가..)
(`1s -> 44.71ms..?`)
그러면.. 위 상태에서 인덱스까지 적용하면 어떻게 되는거지..?
인덱스 추가
CREATE INDEX idx_like_count_id ON study_room (like_count DESC, id DESC);
CREATE INDEX idx_review_count_id ON study_room (review_count DESC, id DESC);
CREATE INDEX idx_average_rating_id ON study_room (average_rating DESC, id DESC);
CREATE INDEX idx_entire_min_price_per_hour_id ON study_room (entire_min_price_per_hour ASC, id DESC);
다시 테스트...
결과
`44.71ms -> 9.27ms`로 또 한층 더 속도가 빨라졌다..
너무나도 `offset-based pagination`과 `cursor-based pagination`의 차이가 많이 나 이것저것 알아보았다.
조사해본 내용
`Offset`이 클 경우(예: 10,000개 이상), 데이터를 건너뛰는 비용이 증가하면서
1초 이상의 평균 응답 시간이 발생하는 것은 흔한 현상이라고 한다.
반면 `Cursor` 방식은 인덱스를 잘 활용하면 `44ms`처럼 대폭 단축되는 것이 가능하다고 한다.
오.. 충분히 흔한 결과구나..!!
하지만! 무조건 Cursor가 해답은 아니라는 사실을 명심해야 한다.
차이점을 바로 알고 넘어가자
1. Offset-based pagination으로도 성능 향상이 가능하다.
offset 기반에서도 `deferred join`, `covering index`, `complex index`를 사용하면 충분히 성능 향상이 가능하다.
2. 인덱스 적용이 무조건적인 해답이 될 순 없다.
위에서 우리는 `Cursor` 성능을 높이기 위해 인덱스를 적용했는데
인덱스가 추가됨에 따라 `CREATE/INSERT/UPDATE` 작업의 비용이 높아질 수 있다.
- 이건 offset 기반에서도 똑같이 적용되는 내용이긴 하다.
3. UX가 중요하지 않거나 일반 유저를 위한 리스트가 아닌 경우 offset이 더 나을 수 있다.(관리자 페이지 등)
관리자 페이지와 같이 UX가 그렇게 중요하지 않은 경우 그냥 간편하게 `offset`을 사용하는게 더 편하다.
4. 애초에 row 수가 적어서 퍼포먼스 걱정이 필요 없는 경우, 마지막 페이지에 갈 이유가 없는 경우
row 수가 적다면 offset 기반으로도 충분하다.
Cursor를 사용하더라도 위의 사실을 확실하게 아는 것이 좋다.
(현재 StudyWithMe에서 사용중인 PostgreSQL은 상대적으로 덜 영향이 갈 수 있다.(참고))
마무리하며
`StudyWithMe`에서는 현재 `Cursor`를 사용하고 있다.
"너네 Row 수가 그렇게 많이 생길 것 같냐?" 라고 한다면 할말은 없다.
하지만, 무한 스크롤 방식에서 `Cursor`가 유리하며,
추후 Row가 많아질 경우에 대비도 가능하다.
(이전 포스팅에서는 수백 ~ 수천건의 트래픽만 나와도 감지덕지라며 그냥 Update API의 Response 네트워크 비용은 상관없다는 듯이 말해놓고..)
- 물론 저속 네트워크가 아닌 고속 네트워크니까 엄연하게 다르긴 하다.
암튼, 정리하자면 `offset-based pagination`의 성능과 `cursor-based pagination`의 성능을 확실히 알아보았고
장단점에 관해서도 나름 철저하게 알아보았다.
이 글을 참고하는 사람들에게 도움이 되었으면 한다.
'프로젝트 > StudyWithMe' 카테고리의 다른 글
[StudyWithMe] 프로젝트에서의 쿼리 고민 일기(계속 추가 예정) (0) | 2024.12.10 |
---|---|
[StudyWithMe] 멀티 스레드에서 트랜잭션 작업 간 정보 불일치 문제 (0) | 2024.12.08 |
[StudyWithMe] 스터디 룸 UPDATE API 작성 시 했던 고민들 (0) | 2024.12.06 |
[StudyWithMe] Flyway의 다양한 활용과 조심 (0) | 2024.12.06 |
[StudyWithMe] PostgreSQL을 처음 접해보며 (0) | 2024.12.06 |