빙응의 공부 블로그
[UniP]무한 페이징 기능 구현 및 성능 개선 본문
이 글은 무한 페이징에 대해 다루겠습니다.
📝배경
이번 졸업 작품 프로젝트에서 메인서버를 맡아 개발하게 되었습니다.
그 중 핵심 기능은 파티를 무한 페이지으로 가져오는 요구사항이 있어 작성하게 되었습니다.
무한 페이징은 정확히 SQL의 페이징 기능을 이용해서 구현하고 무한 스크롤 페이징은 프론트엔드 단에서 구연합니다.
그렇기에 백엔드인 저는 페이징 기능만 구하면 되지만 여러가지 생각을 해보아야만 합니다.
- 인덱스를 이용한 쿼리 튜닝
- OFFSET 구조에 대한 생각
📝 인덱스를 이용한 쿼리 튜닝
페이징을 구연하기 전에 페이징 처리에 필요한 테이블에 대한 인덱스를 먼저 구현해주어야 합니다.
그 이유는 쿼리 성능 최적화와 효율적인 데이터 조회를 위해서입니다.
페이징에서 인덱스를 사용해야하는 자세한 이유는 다음과 같습니다.
- 빠른 데이터 검색
- 페이징을 할때는 데이터베이스에서 특정 범위의 데이터를 가져옵니다. 이때 인덱스가 없다면 데이터베이스는 전체 테이블을 순차적으로 검색해야하므로 쿼리 성능이 급격히 떨어집니다.
- 테이블을 순차적으로 검색한다는 것은 Full Table Scan을 한다음 조건에 맞는 데이터를 추출하게 됩니다.
- 쿼리 성능을 일정하게 유지
- 페이징을 할 때 인덱스를 사용하지 않으면, 조건에 따라 조회 성능이 달라질 수 있습니다
저희 프로젝트는 오직 파티타입에 대해서 조회를 진행하며 파티타입을 지정하지 않을 시 ID 순서대로 보여줍니다.
또한 모집이 종료된 파티에 한에서도 검색을 하지 않기에 이 부분을 인덱스를 만들어줘야 합니다.
@Table(
indexes = { // 무한 페이징을 위한 인덱스 적용, 파티타입, 종료 유무
@Index(name = "idx_party_category_status", columnList = "partyType, isClosed")
}
)
public class Party {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private long id;
private String title;
private String content;
private int partyLimit;
private int peopleCount;
private LocalDateTime startTime;
private LocalDateTime endTime;
private PartyType partyType;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id")
private Member member;
private boolean isClosed;
}
해당 컬럼들로 복합 인덱스를 만든 이유는
저희 서비스에서 파티를 찾을 때 기본으로 검색하는 조건보다는 원하는 코스에 따라 조회하는게 더 많다고 생각했습니다.
📝 페이징 구현
해당 페이징 구현은 페이징 + DTO 조회를 해야하기에 QueryDSL을 사용하겠습니다.
해당 페이징은 기본적인 페이징을 구현한 것입니다.
@Override
public List<PartyResponseDto> getPartyPage(PartyType partyType, int pageNo) {
BooleanBuilder conditions = createMainPartyConditions(partyType);
return queryFactory
.select(Projections.constructor(PartyResponseDto.class,
party.id,
member.name,
member.profile_image,
party.title,
party.partyType,
party.partyLimit,
party.peopleCount,
party.startTime,
party.endTime
))
.from(party)
.join(party.member, member)
.where(conditions)
.offset(pageNo * 10L)
.limit(10)
.fetch();
}
해당처럼 조회가 잘 되는 것을 확인했습니다.
가벼운 환경에서는 페이징에서 offset 방식을 직관적이고 간단하게 구현할 수 있습니다.
그러나 실시간 데이터가 변하는 환경과 대용량 데이터 환경에서는 문제가 발생할 수 있습니다.
- 실시간 데이터가 변하는 환경에서 offSet페이지 로직은 데이터 중복이 올 수 있습니다.
- offset 방식은 큰 페이지 번호가 생길 경우 성능 이슈가 일어날 수 있습니다.
📌 실시간 데이터 환경의 문제
- OFFSET 방식에서는 페이지를 계산할 때 기준으로 특정 OFFSET 값에 해당하는 레코드를 조회합니다.
- 그러나 페이지를 넘길때 데이터가 삽입, 삭제가 일어나는 경우 페이지가 밀려 중복 데이터가 조회될 수 있습니다.
📌 OFFSET 방식의 성능 이슈
- OFFSET 방식은 페이지 번호가 커질수록 성능이 저하되는 경향이 있습니다.
- 왜냐하면 페이지 번호가 1,000,000이면 DB는 처음부터 1,000,000개의 레코드를 건너뛰고 결과를 가져오기 때문입니다.
🧷 문제 해결 방법
문제를 해결하는 방법은 조사한 바로는 크게 2가지가 있습니다.
- 기존 OFFSET 방식 틀 안에서 개선하는 방법
- 디퍼드 조인(Deferred Join) : 먼저 필요한 키 값만을 조회한 후, 해당 키를 기반으로 실제 데이터를 조회하는 방식입니다. 이를 통해 불필요한 데이터 로드를 줄여 성능을 개선할 수 있습니다.
- 커버링 인덱스 : 조회하려는 컬럼들을 모두 포함하는 인덱스를 생성하여, 인덱스만으로 데이터를 조회할 수 있게 합니다. 이를 통해 디스크 I/O를 줄이고 조회 성능을 향상시킬 수 있습니다.
- Cursor Pagination 방식 : id 기준으로 데이터를 가져오는 방식
📌 디퍼드 조인(Deferred Join) 보류
디퍼드 조인은 먼저 해당되는 ID를 조회한 후, 해당 ID들을 기반으로 IN 연산을 사용하여 데이터를 조회하는 방식입니다.
이 방식은 OFFSET 방식의 성능을 일부 개선할 수 있지만, 여전히 한계가 존재합니다.
-- Step 1: ID 목록 조회
SELECT id
FROM users
ORDER BY id
LIMIT 10 OFFSET 10000;
-- Step 2: 실제 데이터 조회
SELECT *
FROM users
WHERE id IN (위에서 조회한 ID 목록);
디퍼드 조인의 한계점
- OFFSET의 고질적인 단점(건너뛰기 스캔)이 여전히 존재 → OFFSET이 커질수록 불필요한 데이터 스캔이 많아짐
- 두 번의 쿼리 실행이 필요 → ID 조회 후 다시 데이터를 가져와야 하므로 추가적인 부하 발생
- IN 연산자 사용으로 인한 성능 저하 가능성 → ID 개수가 많을수록 IN 연산 성능이 저하될 수 있음
📌 커버링 인덱스 실패
커버링 인덱스는 인덱스만으로 필요한 데이터를 조회할 수 있도록 구성하여, 디스크 I/O를 줄이고 성능을 최적화하는 방식입니다.
그러나, 우리 프로젝트에서는 해당 페이지 연산에서 파티의 다양한 정보들을 가져와야 하므로, 모든 데이터를 인덱스에 저장해야 합니다.
커버링 인덱스 적용이 어려운 이유
- 가져와야 할 데이터가 너무 많음 → 인덱스 크기가 너무 커져 메모리 사용량 증가 및 인덱스 유지 비용 증가
- 실제 쿼리 성능 저하 가능성 → 너무 많은 컬럼을 포함한 인덱스는 오히려 성능을 저하시킬 수 있음
📌 Cursor Pagination 채택
Cursor Pagination 방식은 무한 스크롤 페이징에서 대표적인 해결 방법 중 하나입니다.
쉽게 설명하면 마지막으로 검색된 객체의 ID를 유지하면서 다음 페이지를 가져오는 방식입니다.
Cursor Pagination의 장점
- 건너뛰기 스캔(Offset Scan)이 없음 → OFFSET 방식보다 훨씬 빠름
- 인덱스 활용 극대화 → WHERE id > ? 조건을 통해 효율적인 범위 검색 수행
- 대용량 데이터 처리에 유리 → 페이지 번호가 커져도 조회 속도가 일정하게 유지됨
한계점
❌ 비즈니스 특성상 OFFSET을 사용해야 하는 경우 적용 불가능
- 예를 들어, 고정된 페이지 번호 기반 페이징이 필요한 경우(1, 2, 3, 4)
- 정렬 기준이 ID가 아닌 경우(예: 동적 정렬, 복합 정렬 등)
📝 Cursor Pagination 방식
Cursor Pagination 방식은 무한 페이징에서 자주 사용하며
마지막으로 검색된 객체 ID를 기준으로 다음 페이지를 가져옵니다.
그렇다면 왜 Cursor 방식이 더 빠를까요?
아래는 기존 페이징의 쿼리입니다.
SELECT *
FROM items
WHERE 조건문
ORDER BY id DESC
OFFSET 페이지번호
LIMIT 페이지사이즈
이와 같은 형태의 페이징 쿼리가 뒤로갈수록 느린 이유는 앞에서 읽었던 행을 다시 읽기 때문입니다.
문제점
- ORDER BY로 정렬한 후 OFFSET을 적용해 페이지 번호에 맞는 데이터를 찾습니다.
- 그렇기에 페이지 번호가 100이라면 처음부터 100개의 레코드를 건너뛰기 때문입니다.
SELECT *
FROM items
WHERE <조건문>
AND id < :lastId
ORDER BY id DESC
LIMIT :size;
해당 쿼리는 Cursor 방식의 쿼리입니다.
개선된 점
- OFFSET을 제거하고 id 기준으로 데이터를 가져오는 방식은 중복 데이터를 방지하고 ,성능을 개선할 수 있습니다.
- id를 기준으로 페이지네이션을 하게 되며, 이전 페이지에서 조회한 데이터 이후부터 다음 데이터를 조회할 수 있기 때문에, 앞에서 읽었던 행을 다시 읽을 필요가 없습니다.
- ORDER BY 정렬은 인덱스가 존재한다면 오버헤드가 거의 없고 안정성을 보장합니다.
📝 구현 코드
@Override
public List<PartyResponseDto> getPartyPage(PartyType partyType, Long lastId, int size) {
BooleanBuilder conditions = createMainPartyConditions(partyType, lastId);
return queryFactory
.select(Projections.constructor(PartyResponseDto.class,
party.id,
member.name,
member.profileImage,
party.title,
party.partyType,
party.partyLimit,
party.peopleCount,
party.startTime,
party.endTime
))
.from(party)
.join(party.member, member)
.where(conditions)
.orderBy(party.id.desc()) //커서 페이징은 안정적이고 예측가능한 페이징을 위해 정렬을 추가
.limit(size)
.fetch();
}
private BooleanBuilder createMainPartyConditions(PartyType partyType, Long lastId) {
BooleanBuilder builder = new BooleanBuilder();
builder.and(party.isClosed.isFalse()); // 종료되지 않은 파티만 조회
if (partyType != null) {
builder.and(party.partyType.eq(partyType)); // 파티 타입 조건 추가
}
builder.and(lastId != null ? party.id.gt(lastId) : party.id.gt(0)); // lastId 조건 추가
return builder;
}
📌 테스트로 검증하기
1,000,000건의 데이터를 가지고 가운데를 조회해보겠습니다.
1번 페이지 조회
첫번째 페이지 조회는 둘 다 비슷하게 나왔습니다.
중간값 페이지 조회(500,000)
옵셋 페이징은 1.3초 노 옵셋 페이징은 0.3초가 나왔습니다.
마지막 페이지 조회(1,000,000)
마지막 페이지 조회는 옵셋 페이징이 3초 노 옵셋 페이징이 0.5초가 나왔습니다.
결과를 확인해봅시다.
page | Offest | Cursor |
1번 조회 | 63ms | 47ms |
500,000번 조회 | 1393ms | 299ms |
1,000,000번 조회 | 3020ms | 593ms |
확실히 다이나믹한 차이가 나는 것을 알 수 있습니다.
✔ 참고
1. 커버링 인덱스 (기본 지식 / WHERE / GROUP BY)
1. 커버링 인덱스 (기본 지식 / WHERE / GROUP BY)
일반적으로 인덱스를 설계한다고하면 WHERE절에 대한 인덱스 설계를 이야기하지만 사실 WHERE뿐만 아니라 쿼리 전체에 대해 인덱스 설계가 필요합니다. 인덱스의 전반적인 내용은 이전 포스팅을
jojoldu.tistory.com
1. 페이징 성능 개선하기 - No Offset 사용하기
1. 페이징 성능 개선하기 - No Offset 사용하기
일반적인 웹 서비스에서 페이징은 아주 흔하게 사용되는 기능입니다. 그래서 웹 백엔드 개발자분들은 기본적인 구현 방법을 다들 필수로 익히시는데요. 다만, 그렇게 기초적인 페이징 구현 방
jojoldu.tistory.com
'Project > UniP' 카테고리의 다른 글
[UniP]MySQL InnoDB 동시성 문제 해결하기 (1) | 2025.01.03 |
---|---|
[UniP] Real MySQL로 배우는 무한 페이징 & 복합 인덱스 설계 전략 (0) | 2025.01.01 |
[UniP] 인증 메일 발송 비동기 처리하기 (1) | 2024.11.11 |
[UniP]QueryDSL로 조회 최적화하기 (0) | 2024.11.07 |
[UniP]Redis를 통한 RefreshToken 관리하기 (0) | 2024.11.06 |