프로젝트에서 홈 화면에 띄울 피드들을 조회하는 API를 담당하게 되었다.
이를 구현하는 과정에서 페이지네이션과 인덱스에 대해 많이 고민하고 배울 수 있었기 때문에 해당 내용들을 정리해보고자 한다.
💡 개발 환경
- JDK 17
- Springboot 3.4.5 (Gradle)
- MySQL 0.8.3 (Local)
💡 요구 사항
1. 정렬
- 최신순 or 가격낮은순 으로 선택 정렬 가능
2. 필터링
- 현재 상태가 OPEN인 것만 보기
- 키워드 기반 검색 (Feed 제목 / 가게 이름으로 검색 가능)
- 선택한 카테고리의 피드만 보기 (한식, 중식, 일식 등)
3. 페이지네이션
- 한 페이지에 8개씩 조회 가능
해당 API에서는 조회를 위해 고려해야 하는 조건들이 많기 때문에, 정적 쿼리를 사용한다면 모든 경우의 수를 고려하기 위해 총 2 ^ 4 = 16개의 쿼리를 작성해야 했다...
따라서 이러한 번거로움을 방지하기 위해 동적으로 쿼리를 작성할 수 있는 QueryDSL을 사용하기로 결정했다.
이제 페이지네이션에 대해서도 알아보자.
📄 페이지네이션 (Pagination)
페이지네이션이란, 많은 데이터를 여러 페이지로 나누어 일부씩 보여주는 기능을 말한다.
특히 데이터의 양이 많을 경우, 전체를 한 번에 조회하면 네트워크에 부담이 생겨 성능 저하를 초래할 수 있다. 또한 사용자 입장에서도 원하는 정보를 찾기 어려워지므로 이러한 페이지네이션이 필요해지게 된다.
일반적인 페이지네이션 구현 방식으로는 크게 2가지가 존재한다.
📌 offset
전체 데이터 중에서 offset 부터 limit 개 만큼의 데이터만 반환하는 방식이다.
여기서 offset은 조회를 시작할 기준점, limit은 조회할 결과의 개수를 의미한다.
[ 예시 쿼리 ]
SELECT *
FROM feed
ORDER BY id DESC
LIMIT 10 OFFSET 10;
[ 특징 ]
이처럼 offset은 단순히 앞에서 몇번째 데이터부터 가져올지를 지정하는 방식이기 때문에, ( 원하는 페이지 번호 * 페이지당 항목 수 ) 로 계산하여 원하는 페이지로 자유롭게 이동할 수 있다는 장점이 있다.

이러한 특성 때문에 위와 같이 자유로운 페이지 이동이 가능한 페이지네이션의 경우 대부분 offset 방식을 사용한다고 한다.
[ 한계 ]
그러나 원하는 위치까지 도달하기 위해서는 항상 처음부터 데이터를 순차적으로 읽어야 한다는 한계가 존재한다. 예를 들어, 1000번째부터 10개의 데이터를 읽어오고자 한다면 총 1010개의 데이터를 조회하고, 필요없는 1000개의 데이터는 버린 후 남은 10개만 반환하게 되는 것이다.
따라서 전체 데이터의 개수가 매우 많은 경우 뒷 페이지를 가져오고자 한다면 성능 저하가 발생할 수 있다.
📌 no offset ( = cursor-based pagination)
no offset은 페이지네이션에서 offset을 사용하지 않고, 이전에 조회한 마지막 데이터의 고유값을 기준으로 다음 페이지를 가져오는 방식이다. 이러한 특징으로 인해 커서 기반 페이지네이션이라 불리기도 한다.
[ 예시 쿼리 ]
SELECT *
FROM feed
WHERE id < 100 # cursor 기준값
ORDER BY id DESC
LIMIT 10;
[ 특징 ]
위치와 관계없이 기준값(커서)를 통해 바로 시작 지점을 찾아 limit 개수만큼 데이터를 가져오기 때문에, offset 방식처럼 앞부분을 스캔할 필요가 없어 조회 속도가 일정하고 빠르다는 특징이 있다. 이러한 특징을 살리기 위해 커서로 사용하는 컬럼에 인덱스가 설정하기도 한다.
다음 페이지의 존재 유무를 확인해야 하는 경우에는 limit을 +1개 만큼 더 크게 설정하고, 실제 결과에서는 제외한 채 반환하는 방법이 흔히 사용된다고 한다.
[ 한계 ]
커서를 설정하기 위한 기준값이 명확하고 고유하지 않으면 예기치 못한 결과가 발생할 수 있다.
특히 정렬 기준이 복잡할 경우 커서 조건을 구성하는 로직도 복잡해져 구현이 어려워진다.
또한, 커서 기반 방식은 특정 위치의 데이터를 기준으로 다음 페이지를 조회하기 때문에, 임의의 페이지로 이동하려면 중간 데이터의 기준값을 알고 있어야 한다. 그렇지 않으면 원하는 페이지에 바로 접근하기 어렵다.
마지막으로, 데이터의 삽입/삭제가 잦은 경우에는 커서의 기준값이 항상 유효하지 않을 수도 있다.
limit이 10인 경우, 2페이지를 가져온다면 기준을 10으로 잡고 id가 11~20인 데이터들을 가져올 수 있다. 그러나 만약 2번 데이터가 삭제되어 버린다면, id=11인 데이터라 하더라도 실제로 11번째 데이터를 의미하지 않게 되어버린다.

이러한 한계들로 인해, no offset 방식은 주로 임의의 페이지 이동이 필요 없는 더보기나 무한 스크롤을 구현할 때 사용된다.
⚖️ offset vs no offset
장기적으로 데이터가 많아질 경우를 고려하면, 성능 측면에서는 no-offset 방식이 더 효율적이라 볼 수 있다.
그러나 현재 서비스에서는 초기 피드에 유사한 콘텐츠가 반복적으로 노출되는 경향이 있어, 새로운 정보를 찾고자 하는 사용자 입장에서는 원하는 위치로 자유롭게 이동할 수 있는 기능이 필요하다고 판단하였다.
이에 따라 프로젝트에서는 최종적으로 offset 방식을 채택하게 되었다.
✨ 구상 1 : QueryDSL을 통한 offset 페이지네이션 적용 (DB 수정 X)
먼저, dependency를 설정해준다.
✅ build.gradle
// 맨 위에
buildscript {
ext {
queryDslVersion = "5.0.0"
}
}
// dependency
implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta'
annotationProcessor "com.querydsl:querydsl-apt:${dependencyManagement.importedProperties['querydsl.version']}:jakarta"
annotationProcessor "jakarta.annotation:jakarta.annotation-api"
annotationProcessor "jakarta.persistence:jakarta.persistence-api"
// 맨 밑에
def generated = 'src/main/generated'
tasks.withType(JavaCompile) {
options.getGeneratedSourceOutputDirectory().set(file(generated))
}
sourceSets {
main.java.srcDirs += [ generated ]
}
clean {
delete file(generated)
}
generated 부분은 Q클래스가 생성될 경로를 설정하는 역할을 한다.
다음으로, JPAQueryFactory를 Bean으로 등록해 사용하기 위해 Config 파일을 설정해준다.
✅ QueryDslConfig
@Configuration
public class QueryDslConfig {
@PersistenceContext
private EntityManager entityManager;
@Bean
public JPAQueryFactory jpaQueryFactory() {
return new JPAQueryFactory(entityManager);
}
}
서비스의 의존성을 줄이기 위해 FeedRepository에서 CustomRepository를 상속 받아 사용하였다.
✅ FeedRepository
public interface FeedRepository extends JpaRepository<Feed, Long>, CustomFeedRepository {
}
✅ CustomFeedRepository
public interface CustomFeedRepository {
List<Feed> findPageByFiltering(Pageable pageable, boolean isAvailable, SortType sortType, Category category, String keyword);
}
✅ CustomFeedRepositoryImpl
@Repository
@RequiredArgsConstructor
public class CustomFeedRepositoryImpl implements CustomFeedRepository {
private final JPAQueryFactory jpaQueryFactory;
/**
* Feed 필터링 적용 전체 검색
*/
@Override
public List<Feed> findAllByFiltering(Pageable pageable, Long lastFeedId, boolean isAvailable, SortType sortType, Category category, String keyword) {
return jpaQueryFactory
.selectFrom(feed)
.leftJoin(feed.store, store)
.where(
isOpen(isAvailable),
categoryEq(category),
keywordContains(keyword)
)
.orderBy(getSortTypeSpecifier(sortType))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
}
// 정렬 순서 설정
private OrderSpecifier[] getSortTypeSpecifier(SortType sortType) {
if (sortType == PRICE_ASC) { // PRICE_ASC
return new OrderSpecifier[]{
new OrderSpecifier<>(Order.ASC, QFeed.feed.price),
new OrderSpecifier<>(Order.DESC, QFeed.feed.id)
};
} else { // RECENT
return new OrderSpecifier[]{new OrderSpecifier<>(Order.DESC, QFeed.feed.id)};
}
}
// 신청 가능 여부 필터링
private BooleanExpression isOpen(boolean isAvailable) {
return isAvailable ? feed.status.in(Status.OPEN, Status.UPCOMING) : null;
}
// 카테고리 필터링
private BooleanExpression categoryEq(Category category) {
return category != null ? feed.category.eq(category) : null;
}
// 키워드 필터링
private BooleanExpression keywordContains(String keyword) {
if (keyword == null || keyword.isBlank()) return null;
return feed.title.contains(keyword)
.or(feed.store.name.contains(keyword));
}
}
id에 대해서는 auto_increment 전략을 택하고 있었기 때문에, 중복 위험이 있는 created_at이 아닌 id를 기준으로 정렬하도록 설정하였다.
위의 코드를 실행해본 결과, 동적 쿼리를 적용한 조회 자체는 잘 되는 모습을 확인할 수 있었다.
❓no offset 기반 페이지 점프 기능 고안하기
그러나 offset의 특성 상 맨 마지막 페이지로 이동할 경우 성능이 저하되는 문제를 피할 수는 없었다.
이에 따라 no-offset을 적용하면서도 페이지 점프를 수행할 수 있는 방법에 대해 생각해보게 되었다.
[ 구상 ]
no offset을 적용하지 못한 가장 큰 이유는, 페이지 건너뛰기 시 커서의 기준값을 구할 수 없기 때문이었다.
이를 해결하기 위해 이동할 수 있는 n개의 페이지 각각에 대한 커서값을 FE에 함께 전달하는 방법을 떠올렸다.
예를 들어, 하나의 페이지에서 이동할 수 있는 페이지 수가 10개이고, 현재 페이지가 5p라면, 1 ~ 10p 각각에 대한 기준 커서값을 배열로 담아 전달하는 것이다. 이후 FE에서는 클릭된 페이지 번호에 따라 기준값을 보내주고, 이를 기반으로 DB에서 limit 개수만큼 조회하여 반환하는 방식을 구상하였다.

비록 각각의 커서를 계산하기 위해 매 조회 시 검사해야하는 row의 개수가 늘어나게 되지만, 1p나 1000p나 큰 성능 차이가 발생하지 않아 성능을 평준화시키는 것이 가능하다는 점을 노렸다. 또한, 각 페이지에 8개의 피드만을 보여주기로 하였으므로, 10p 기준으로 약 73개 (8 * 9 + 1)의 데이터만 조회하면 되므로 성능상 큰 문제가 되지 않을 것이라 판단하였다.
여기까지 생각한 후, 실제로 구현하기 전에 다른 팀원들에게 의견을 물어보았다.
그러나 회의 중 발견한 문제들로 인해 해당 아이디어는 반려하게 되었다.
[ 실패 원인 ]
가장 큰 원인은 자유로운 이동이 가능하다는 것을 제외한 no offset의 한계를 해결하지 못했기 때문이라 볼 수 있다.
당시 언급되었던 문제들은 아래와 같다.
- 삽입/삭제로 인한 데이터 오류
- 커서로 지정해둔 값이 삭제되었을 경우 발생하는 오류
- 커서 설정 기준이 불분명함 (필터링/기준을 모두 고려한 커서 설정이 어려움)
짧은 프로젝트 기간 내에 이러한 문제를 모두 해결한 대책을 고안하기에는 한계가 있다고 판단하여, offset을 사용하되 인덱스를 통한 성능 최적화를 노리자는 결론을 내리게 되었다.
✨ index를 통한 성능 최적화
먼저, DB에 약 35000개의 데이터를 삽입하고, 기존 방식에서의 수행 시간을 측정하였다.


📌 성능
- 최신순 : 약 50-60ms
- 가격순 : 약 100-120ms
📌 문제 분석
앞서 언급하였듯이, 최신순에서는 PK인 id를 기준으로 정렬하여 값을 가져오고 있다.
PK는 실제 테이블의 정렬 기준이 되는 클러스터드 인덱스로, 데이터 자체가 PK 기준으로 디스크 상에 저장되어 있다는 것을 의미한다. 따라서 PK를 기준으로 정렬하거나 범위 조회를 수행할 경우, 추가적인 정렬 연산이나 별도의 주소 탐색 없이 데이터를 바로 읽어올 수 있어 조회 성능이 향상된다.
반면, 가격순은 미리 생성된 인덱스가 따로 존재하지 않아 매번 전체 데이터를 정렬한 후 조회를 수행해야 하기 때문에 비교적 긴 시간이 소요되는 것이다.
이처럼 정렬 종류에 따른 조회 시간 편차가 발생하는 것이 어색하게 느껴져 price 기준 정렬을 위한 인덱스를 추가하여 성능을 최적화하기로 결정하였다.
🏷️인덱스 생성
쿼리문으로 생성하는 방법도 있지만, 당시에는 각자 로컬 DB로 개발을 진행하고 있었기 때문에 공통된 환경을 보장하기 위해 코드 상으로 인덱스를 추가하기로 하였다.
price 순에서는 가격이 낮은 순으로 정렬하되, 동일 가격일 경우에는 최신인 것이 우선도를 가지므로 price와 id를 함께 index로 사용하기로 하였다.
해당 정보를 Feed 엔티티의 테이블 어노테이션에 추가하여 인덱스를 설정해주었다.
@Table(name = "feed", indexes = {
@Index(name = "idx_price_id", columnList = "price, id")
})
실제로 인덱스가 생성되었는지 확인하기 위해 아래 쿼리를 날려보면
SHOW INDEX FROM feed;
아래처럼 인덱스가 잘 생성된 모습을 확인할 수 있다!

그러나 이후 최신순/가격순으로 조회 요청을 다시 날려본 결과,
성능 자체는 이전에 비해 크게 달라진 것이 없다는 사실을 확인할 수 있었다......
⚙️쿼리 실행 계획 확인
문제를 찾기 위해 SQL문을 뽑아서 쿼리 실행 계획을 돌려보았다.

결과를 확인해보니 예상대로 전혀 index를 타지 않고 있었다....ㅎㅎ
인덱스는 인덱싱된 컬럼의 값과 그에 해당하는 실제 데이터를 찾기 위한 포인터의 쌍으로 구성되어 있다.
즉, 인덱싱된 컬럼을 조회한 후 원하는 데이터가 그곳에 없다면, 포인터를 기반으로 실제 데이터로 한번 더 이동하여 원하는 데이터를 찾아오는 것이다.
앞서 언급하였듯이 PK는 자체적으로 모든 정보를 가지고 있는 클러스터드 인덱스이므로 따로 2차적으로 데이터를 가져오는 과정이 필요하지 않다. 따라서 MySQL 옵티마이저는 offset이 얼마나 커지더라도 항상 인덱스를 타는 것이 가장 빠르다 판단하게 된다.
이러한 이유로 인해 id를 기반으로 한 최신순에서는 인덱스를 잘 타는 것이다.
그러나 가격순처럼 넌클러스터드 인덱스를 사용하는 경우, offset이 너무 크면 옵티마이저가 굳이 인덱스를 사용해 매번 데이터를 한번 더 가져오는 것보다 테이블 자체를 재정렬한 후 조회를 수행하는 것이 더 효율적이라 판단하여 인덱스를 타지 않는 경우도 있다고 한다.
→ 옵티마이저에 대해 조금 더 공부해보기.....
왜 넌클러스터드 인덱스에서는 offset이 커지면 인덱스를 타지 않는 것일까?
(디스크나 캐시 등의 문제인지, 쿼리의 문제인지... 실제로 어떻게 동작되는건지 더 찾아보기)
DB가 실제로 이런 원리로 동작하는지 궁금해져 offset만 일부 수정한 채 쿼리 실행 계획을 다시 확인해 보았다.
✅ 5p인 경우

✅ 3410p인 경우

정말 offset이 커지니 인덱스를 타지 않는다..!
이외에도 다른 문제들이 있는 것 같아 여러모로 찾아본 결과, 문제의 원인을 크게 두 가지로 정리할 수 있었다.
- Store와 조인 후 정렬을 수행
- 기존 index가 조인 결과 테이블의 포인터 정보를 가지고 있다고 보장할 수 없으므로, index를 타지 못하는 것으로 보임
- 결과에 대한 store 정보만 있으면 되므로, 원하는 Feed 정보를 먼저 가져온 후 join을 수행하도록 수정
- Feed의 전체 정보를 조회
- 매번 Feed의 전체 정보를 보기 위한 2차 조회가 발생하여 옵티마이저가 index를 사용하지 않는 것으로 보임
- index 자체에 존재하는 정보인 Feed.id만을 List로 받아온 후, 이와 일치하는 Feed 정보를 가져오도록 수정
위와 같은 수정 내용을 반영하여 SQL문을 수정하였다.
실제에서는 WHERE절도 포함될 수 있기 때문에 이 경우에도 index를 타는지 확인하기 위해 임시로 추가해주었다.
EXPLAIN SELECT *
FROM (
SELECT f.id
FROM feed f
WHERE f.status = 'OPEN'
ORDER BY f.price ASC, f.id DESC
LIMIT 3410, 10
) AS sub
JOIN feed f ON f.id = sub.id
JOIN store s ON f.store_id = s.id;
위 SQL문의 쿼리 실행 계획을 다시 확인해 보았더니

비록 단계는 늘어났지만, 인덱스를 잘 타는 모습을 볼 수 있었다!
💻코드 작성
잘 타는 모습을 확인했으니 이제 QueryDSL 코드를 작성해보자
@Override
public List<Feed> findAllByFiltering(Pageable pageable, boolean isAvailable, SortType sortType, Category category, String keyword) {
// 서브쿼리로 ID 먼저 추출
List<Long> feedIds = jpaQueryFactory
.select(feed.id)
.from(feed)
.where(
isOpen(isAvailable),
categoryEq(category),
keywordContains(keyword)
)
.orderBy(getSortTypeSpecifier(sortType))
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
// feedId 기반 전체 데이터 조회
return jpaQueryFactory
.selectFrom(feed)
.leftJoin(feed.store, store).fetchJoin()
.where(feed.id.in(feedIds))
.orderBy(getSortTypeSpecifier(sortType))
.fetch();
}
QueryDSL은 JPQL을 기반으로 동작하는데, JPQL 자체가 FROM절에서의 서브 쿼리를 지원하지 않기 때문에 QueryDSL에서도 사용할 수 없다고 한다.
이에 따라 id를 조회하는 쿼리를 먼저 수행하고, 그 결과를 기반으로 조인을 수행하는 쿼리를 날려 총 2개의 쿼리문을 사용하게 되었다.
최종적으로 수정된 코드를 기반으로 동일한 요청을 날려보았다.


📌 성능
- 최신순 : 약 20-30ms
- 가격순 : 약 30-40ms
기존의 시간 편차가 사라지고 sortType와 무관하게 비슷한 성능을 보이는 모습을 확인할 수 있다.
또한, 조회 방식의 변경으로 인해 최신순의 조회 시간까지도 감소시킬 수 있었다.
PK는 새로 만든 인덱스(넌클러스터드)와 달리 주소를 타고 데이터를 가져올 필요가 없으므로 살짝 더 빠르게 나오는 것 같다.
🛠️ 해결하지 못한 문제들
- IN절을 사용할 경우, 가져온 값들에 대한 순서를 보장하지 않기 때문에 페이지 내부에서의 정렬이 깨지는 문제가 발생하였다. 페이지 당 8개이므로 성능면에서 큰 문제가 되지 않을 것이라 판단하여 현재는 서비스 단에서 재정렬을 수행한 후 값을 반환하고 있는데 DB단에서 해결할 수 있는 방법을 찾아보고 싶다.
- QueryDSL의 한계로 인해 단순 조회에 쿼리가 2번이나 나가고 있다.
- 총 몇 개의 page가 존재하는지를 FE에게 알려주기 위해 COUNT(*)을 사용하고 있지만, 매번 전체 page를 읽는 것이 비효율적이게 느껴졌다. 따로 FeedCount를 관리하는 방법도 있다고 하는데 Feed가 추가될 때마다 갱신을 해주면 동시성이나 서버 부하 문제가 발생할 수도 있을 것 같다는 생각이 든다... 엄청 많은 데이터를 관리하는 회사에서는 어떤 식으로 관리하는지에 대해 찾아보고 싶다.
📝 추가 개념 간단 정리 (공부 필요)
📌 커버링 인덱스
- 정의 : 쿼리를 충족시키는데 필요한 모든 데이터를 갖고 있는 인덱스
- 한계 : 인덱스에 포함된 컬럼이 너무 많은 경우 메모리나 인덱스 재정렬 시 효율이 저하될 수 있음
+) index를 타지 않는 문제를 해결하기 위해 커버링 인덱스 사용하는 방법도 고려해보았지만, 쿼리에서 필요한 정보가 너무 많아 거의 Feed 전체를 가져오는 것과 크게 다르지 않은 것 같아 일단 보류함
📌 클러스터드 인덱스 vs 넌클러스터드 인덱스
- 클러스터드 인덱스 (Clustered Index)
: 지정된 컬럼에 대해 테이블을 물리적으로 재배열하는 인덱스
: 테이블 당 1개만 존재할 수 있음
- 넌클러스터드 인덱스 (Non-clustered Index)
: 인덱스와 실제 데이터가 따로 저장되는 구조의 인덱스
DB에 관한 내용들은 개발 과정에 바로 적용시킬 수 있어서 더 재미있는 것 같다. 아직은 아는게 많이 없지만 이번 프로젝트를 기회로 쿼리 최적화나 DB 구조 등에 대해 더 많이 공부해봐야겠다...
'프로젝트 > JUKSOON' 카테고리의 다른 글
| [JUKSOON] JDBC를 활용한 Bulk Insert 구현 (4) | 2025.05.21 |
|---|