[REST API] 9편. Post 도메인 고급 조회 구현: 복합 조건 검색과 Keyset Pagination
9편. Post 도메인 고급 조회 구현: 복합 조건 검색과 Keyset Pagination
📚 목차
1. 고성능 조회를 위한 데이터 모델 및 인덱스 설계
2. 복합 조건 검색과 범위 필터링 구현
3. Keyset Pagination 기반 고성능 페이지네이션
4. 집계 컬럼 기반 정렬과 응답 최적화 전략

📂 [GitHub 코드 보러가기] : https://github.com/cericube/nodejs-practice-lab/tree/main/fastify-api-rest
1. 고성능 조회를 위한 데이터 모델 및 인덱스 설계
조회 성능을 개선하려고 할 때, 보통은 Service나 Repository 코드를 먼저 떠올리게 됩니다.
하지만 실제로 여러 프로젝트를 경험해 보면, 성능의 상당 부분은 코드보다 데이터 모델과 인덱스 설계에서 결정되는 경우가 많습니다.
특히 게시판처럼 리스트 조회, 정렬, 검색이 반복되는 구조에서는 초기에 어떤 방향으로 설계하느냐가 이후 확장성과 성능을 크게 좌우합니다
🔷 집계 캐시 컬럼 (Denormalization)
조회 시 계산하기보다, 변경 시점에 반영하는 구조를 선택합니다.
일반적으로 댓글 수나 좋아요 수는 COUNT 쿼리를 통해 계산할 수 있습니다.
SELECT COUNT(*) FROM replies WHERE post_id = ?
이 방식은 단건 조회에서는 크게 문제가 되지 않지만, 리스트 조회로 넘어가면 상황이 달라집니다.
예를 들어 게시글 20개를 조회하면서 각각의 댓글 수를 계산하면, 게시글 조회 외에도 추가적인 COUNT 쿼리가 반복적으로 실행됩니다.
그래서 이번 구현에서는 조회 시 계산하는 대신, Post 모델에 집계 값을 저장하는 방식을 선택했습니다.
model Post {
id Int @id @default(autoincrement())
// 집계 캐시 컬럼
viewCount Int @default(0)
likeCount Int @default(0)
replyCount Int @default(0)
}
이 구조에서는 데이터가 변경되는 시점에 값을 업데이트합니다.
await prisma.post.update({
where: { id: postId },
data: {
replyCount: { increment: 1 },
},
});
이 방식은 다음과 같은 상황에서는 비교적 안정적인 선택이라고 판단합니다.
▸ 리스트 조회가 빈번한 경우
▸ 정렬 기준으로 집계 데이터가 필요한 경우
▸ 실시간 정확도보다 조회 성능이 더 중요한 경우
🔷 정렬 최적화 인덱스 (Index Scan)
DB가 데이터를 정렬하지 않도록, 인덱스를 통해 정렬된 상태로 읽게 만드는 것을 목표로 합니다.
정렬 쿼리는 겉보기에는 단순하지만, 인덱스가 없는 경우 내부적으로는 비용이 큰 작업이 됩니다.
SELECT * FROM posts ORDER BY view_count DESC;
이 경우 DB는 데이터를 모두 읽은 뒤 정렬을 수행하게 되고, 데이터가 많아질수록 이 비용은 빠르게 증가합니다.
그래서 이번 구조에서는 정렬 기준을 인덱스에 미리 반영하는 방향으로 설계했습니다.
@@index([published, viewCount(sort: Desc), id(sort: Desc)])
이 인덱스는 단순히 컬럼을 나열한 것이 아니라, 쿼리 패턴을 기준으로 순서를 구성했습니다.
▸ published → 필터 조건
▸ viewCount → 정렬 기준
▸ id → 동일 값에 대한 순서 보장 (tie-breaker)
이렇게 구성하면 다음과 같은 쿼리가
SELECT *
FROM posts
WHERE published = true
ORDER BY view_count DESC, id DESC
LIMIT 10;
별도의 정렬 없이 인덱스를 따라 읽기만 하는 구조(Index Scan)로 동작하게 됩니다.
🔷 Keyset Pagination과 인덱스 설계
Offset 기반 페이지네이션 대신, 위치 기반 조회(Keyset)를 사용했습니다.
일반적인 Offset 방식은 다음과 같습니다.
LIMIT 10 OFFSET 100000
이 방식은 구현이 단순하다는 장점이 있지만, 뒤로 갈수록 앞의 데이터를 계속 읽어야 하기 때문에 성능이 점점 느려지는 문제가 있습니다.
그래서 이번 구현에서는 Cursor 기반(Keyset) Pagination을 적용했습니다.
SELECT *
FROM posts
WHERE published = true
AND (view_count < 100 OR (view_count = 100 AND id < 50))
ORDER BY view_count DESC, id DESC
LIMIT 10;
이 방식에서는
▸ 이전 페이지의 마지막 위치를 기준으로
▸ 그 다음 데이터부터 조회하게 됩니다
특히 id를 함께 사용하는 이유는 정렬 값이 동일한 경우 순서를 보장하기 위함입니다.
(view_count = 100 AND id < 50)
이 구조는 인덱스 설계와 함께 사용할 때 의도한 성능을 제대로 발휘할 수 있습니다.
@@index([published, viewCount(sort: Desc), id(sort: Desc)])
🔷인덱스 설계 패턴 정리
이번 Post 모델에서는 각 조회 패턴에 맞춰 인덱스를 분리해서 구성했습니다.
// 특정 사용자가 작성한 글을 최신순으로 조회
@@index([authorId, id(sort: Desc)])
// 서비스에 공개된 모든 글을 최신순으로 조회
@@index([published, id(sort: Desc)])
// 조회수가 높은 글을 우선 노출 (동률 시 최신순)
@@index([published, viewCount(sort: Desc), id(sort: Desc)])
// 좋아요 수 기준 인기 글 정렬
@@index([published, likeCount(sort: Desc), id(sort: Desc)])
// 댓글 수 기준으로 활발한 글 우선 노출
@@index([published, replyCount(sort: Desc), id(sort: Desc)])
2. 복합 조건 검색과 범위 필터링 구현
단순한 WHERE 조건을 사용하는 수준을 넘어서, 요청 조건에 따라 동적으로 조합되는 검색 구조를 반영합니다.
🔷 기본 구조: 동적 WHERE 조합
먼저 Repository에서는 filter / ranges를 분리합니다.
type searchFilterBase = {
authorId?: number;
status?: 'published' | 'draft';
keyword?: string;
titleOnly?: boolean;
};
type searchFilterRange = {
viewCount?: { min?: number; max?: number };
likeCount?: { min?: number; max?: number };
createdAt?: { from?: string; to?: string };
};
이 데이터를 기반으로 WHERE 조건을 동적으로 구성합니다.
private buildSelectOptions(
filter: searchFilterBase,
ranges: searchFilterRange,
): Prisma.PostWhereInput {
const where: Prisma.PostWhereInput = {};
// 기본 필터
if (filter.authorId) where.authorId = filter.authorId;
if (filter.status !== undefined) {
where.published = filter.status === 'draft' ? false : true;
}
// 범위 필터
if (ranges.viewCount) {
where.viewCount = applyRange(ranges.viewCount);
}
// 날짜 필터
if (ranges.createdAt) {
where.createdAt = {
...(ranges.createdAt.from && { gte: new Date(ranges.createdAt.from) }),
...(ranges.createdAt.to && { lte: new Date(ranges.createdAt.to) }),
};
}
return where;
}
이 구조를 사용하면:
▸ 조건이 없는 경우 → WHERE 없음
▸ 조건이 있는 경우 → 자동으로 조합
즉, 동적 검색 API를 자연스럽게 구성할 수 있습니다
🔷 Range Filter 패턴
범위 필터는 대부분 다음 형태를 따릅니다
WHERE view_count >= 10 AND view_count <= 100
이 구조는
▸ 인덱스를 활용할 수 있음
▸ 범위 스캔 (Index Range Scan) 가능
이를 코드로 일반화하면
const applyRange = (data?: { min?: number; max?: number }) => {
const filter: Prisma.IntFilter = {};
if (data?.min !== undefined) filter.gte = data.min;
if (data?.max !== undefined) filter.lte = data.max;
return filter;
};
사용 예
if (ranges.viewCount) {
where.viewCount = applyRange(ranges.viewCount);
}
🔷 키워드 검색 (텍스트 검색)
if (filter.keyword) {
const searchCondition = {
contains: filter.keyword,
mode: Prisma.QueryMode.insensitive,
};
if (filter.titleOnly ?? true) {
where.title = searchCondition;
} else {
where.OR = [
{ title: searchCondition },
{ content: searchCondition },
];
}
}
WHERE title ILIKE '%keyword%'
또는
WHERE title ILIKE '%keyword%' OR content ILIKE '%keyword%'
📌 한계점
이 방식은 구현이 간단하지만 중요한 단점이 있습니다.
▸ 인덱스 사용 불가
▸ Full Table Scan 발생
LIKE '%keyword%'
데이터가 증가하면 다음을 고려할 수 있습니다
1) PostgreSQL Full Text Search
to_tsvector(title || ' ' || content) @@ to_tsquery('keyword')
2) 검색 엔진 도입
▸Elasticsearch : 기본 기능은 무료지만, 특정 고급 기능은 유료 라이선스(SSL)를 구매해야 함
▸OpenSearch : AWS가 주도하는 완전 무료, 완전 오픈 소스 검색 엔진
3. Keyset Pagination 기반 고성능 페이지네이션
post 도메인은 전통적인 Offset 방식 대신 Cursor 기반(Keyset) Pagination을 적용했습니다.
이 방식이 항상 정답이라고 보기는 어렵지만, 데이터가 많아지고 페이지 이동이 빈번한 환경에서는 성능과 안정성 측면에서 더 적합한 선택이라고 판단합니다.
🔷 Offset 방식의 한계
SELECT *
FROM posts
ORDER BY id DESC
LIMIT 10 OFFSET 100000;
# 또는 Primsa 기준:
await prisma.post.findMany({
orderBy: { id: 'desc' },
skip: 100000,
take: 10,
});
✔️ Offset 방식은 데이터를 “건너뛰는 방식”입니다.
예를 들어 OFFSET 100000 LIMIT 10이라면,
1. 앞의 100,000건을 먼저 읽는다
2. 그 데이터를 모두 버린다
3. 이후 10건만 결과로 반환한다
즉, 우리가 원하는 것은 10건이지만 실제로는 100,010건을 처리하게 됩니다.
✔️ 인덱스가 있어도 해결 안 되는 이유
인덱스는 “데이터를 찾는 것”은 빠르게 해줍니다. 하지만 “앞 데이터를 건너뛰는 비용”은 줄여주지 못합니다.
그래서 Offset 방식은 구조적으로 비효율을 가지고 있습니다.
🔷 Keyset Pagination 접근 방식
이전 페이지의 마지막 데이터를 기준으로 다음 데이터를 조회합니다.
SELECT *
FROM posts
WHERE view_count < 100
ORDER BY view_count DESC
LIMIT 10;
▸ 마지막으로 조회한 데이터 기준으로 그 다음 범위의 데이터를 조회
▸ 앞 데이터를 다시 읽지 않음
▸ 필요한 데이터만 가져옴
✔️ Prisma 구현 예시
const posts = await this.prisma.post.findMany({
where: { AND: [where, keysetCondition] },
orderBy,
take: take + 1,
});
▸ where → 일반 필터 조건
▸ keysetCondition → 커서 기반 조건
▸ orderBy → 정렬 기준
▸ take + 1 → 다음 페이지 존재 여부 판단
🔷 Keyset 조건 구성 (Tie-breaker 중심)
Keyset Pagination은 단순히 “이후 데이터 조회”가 아니라 정렬된 순서에서 정확한 다음 위치를 찾는 기술입니다.
Keyset Pagination은 다음 조건을 반드시 만족해야 합니다:
▸ 정렬 기준이 명확해야 한다
▸ 정렬 기준은 고유값까지 포함해야 한다
▸ WHERE 조건은 ORDER BY와 동일한 구조여야 한다
1. 단순 조건의 한계
처음에는 보통 다음과 같이 구현합니다.
▸ 마지막으로 조회한 viewCount보다 작은 데이터만 가져옵니다.
{ viewCount: { lt: value } }
문제 발생
id viewCount
101 100
99 100 ← 마지막 데이터
87 100
70 99
이 상태에서 다음 페이지를 조회하면
▸ viewCount = 100인 데이터는 모두 제외됨
▸ id = 87 데이터가 조회되지 않음
WHERE view_count < 100
▸ 데이터 누락 발생
▸ 페이지 이동 시 데이터 흐름이 끊김
▸ 사용자 입장에서 일관성이 깨짐
2. 해결 방법: Tie-breaker
viewCount는 다음과 같은 특성을 가집니다:
▸ 동일 값이 매우 많음
▸ 단독으로는 순서를 결정할 수 없음
즉, 정렬이 불완전한 상태 입니다.
이를 위해 정렬 기준을 확장합니다.
ORDER BY view_count DESC, id DESC
▸ 1차 정렬: viewCount
▸ 2차 정렬: id
✔️ 왜 id인가?
▸ 고유값 (Primary Key)
▸ 항상 순서를 결정할 수 있음
즉, 모든 데이터의 순서가 완전히 고정됩니다.
3. Keyset 조건의 완성
정렬 기준이 확장되면 조건도 동일한 구조로 확장해야 합니다.
▸ 더 작은 viewCount는 모두 포함
▸ 같은 viewCount에서는 id 기준으로 위치 결정
return {
OR: [
{ viewCount: { lt: value } },
{
viewCount: value,
id: { lt: id },
},
],
};
# SQL문
WHERE
view_count < 100
OR (view_count = 100 AND id < 99)
4. 타이브레이커의 역할
"동일 값 내부에서 순서를 유지한다"
▸ 동일 값 그룹 내 정확한 위치 유지
▸ 데이터 누락 방지
▸ 데이터 중복 방지
▸ 페이지 이동의 일관성 확보
🔷 인덱스와의 관계
Keyset 방식은 단독으로는 의미가 없고 인덱스 설계와 함께 사용할 때 효과가 극대화됩니다
동작 방
▸ 인덱스에서 바로 위치 탐색
▸ 범위 스캔 시작
▸ 필요한 데이터만 읽음
@@index([published, viewCount(sort: Desc), id(sort: Desc)])
실행 쿼리 예시
SELECT *
FROM posts
WHERE published = true
AND (
view_count < 100
OR (view_count = 100 AND id < 50)
)
ORDER BY view_count DESC, id DESC
LIMIT 10;
🔷 take + 1 전략
보통 페이지네이션을 구현할 때는 다음과 같은 정보를 함께 내려주고 싶어집니다.
▸ 현재 페이지 데이터
▸ 다음 페이지 존재 여부 (hasNextPage)
▸ 다음 요청에 사용할 커서 (nextCursor)
Keyset Pagination의 핵심 목적은 “전체 페이지 수를 계산하는 것”보다 “다음 페이지로 빠르게 이어지는 것”에 더 가깝습니다.
따라서 COUNT(*) 대신, 요청한 개수보다 1개 더 조회하는 방식, 즉 take + 1 전략을 반영했습니다.
✔️ 구현 코드
const posts = await prisma.post.findMany({
where: { AND: [where, keysetCondition] },
orderBy,
take: take + 1,
});
예를 들어 사용자가 10개를 요청했다면 실제로는 11개를 조회합니다.
▸ 11개가 조회되면 → 뒤에 데이터가 더 있다는 뜻
▸ 10개 이하가 조회되면 → 현재 페이지가 마지막이라는 뜻
즉, 별도의 집계 쿼리 없이도 다음 페이지 존재 여부를 판단할 수 있습니다.
✔️ 처리 방식
const hasNextPage = posts.length > take;
const result = hasNextPage ? posts.slice(0, take) : posts;
1. posts.length > take
사용자가 요청한 개수보다 실제 조회된 개수가 더 많다면,
마지막 1건은 “실제 응답 데이터”가 아니라 다음 페이지 존재 여부를 판단하기 위한 확인용 데이터라고 볼 수 있습니다.
2. slice(0, take)
만약 11개를 가져왔다면, 그중 마지막 1개는 응답에 포함하지 않고 잘라냅니다.
실제로 클라이언트에게는 요청한 수만큼만 내려주면 되기 때문입니다.
✔️ 예시로 이해해 보기
예를 들어 최신순으로 게시글 10개를 조회한다고 가정해 보겠습니다.
const take = 10;
경우 1. 실제 조회 결과가 11개인 경우
posts.length === 11
▸ hasNextPage = true
▸ 응답 데이터는 앞의 10개만 사용
▸ 마지막 11번째 데이터는 “다음 페이지가 존재한다”는 신호 역할
즉, 현재 페이지는 10개를 보여주고 다음 요청에 사용할 커서를 함께 내려줄 수 있습니다.
경우 2. 실제 조회 결과가 7개인 경우
posts.length === 7
▸ hasNextPage = false
▸ 응답 데이터는 7개 그대로 사용
▸ 추가 데이터가 없으므로 nextCursor도 내려주지 않음
즉, 현재 페이지가 마지막 페이지라는 것을 자연스럽게 알 수 있습니다.
✔️ Cursor 생성과의 연결
다음 페이지가 존재한다면, 그다음 요청에서 사용할 커서(cursor)도 함께 만들어야 합니다.
구현에서는 마지막 응답 데이터 기준으로 커서를 생성했습니다.
function buildPostCursor(sort: string, post: PostRow) {
return {
id: post.id,
...(sort === 'mostViewed' && { value: post.viewCount }),
};
}
1. id
모든 정렬에서 공통적으로 사용하는 고유 식별자입니다.
동일한 정렬 값이 있을 때 마지막 순서를 고정해 주는 역할을 합니다.
2. value
정렬 기준이 수치 컬럼일 때 함께 전달합니다.
예를 들어:
▸ 조회수순 → viewCount
▸ 좋아요순 → likeCount
▸ 댓글순 → replyCount
이 값이 필요한 이유는 다음 페이지 조회 시 현재 위치를 정확하게 재현해야 하기 때문입니다.
예를 들어 조회수순 정렬이라면
커서는 단순히 id만으로는 충분하지 않고, “마지막 게시글의 조회수가 몇이었는지”도 함께 알아야 다음 조건을 정확히 만들 수 있습니다.
return {
OR: [
{ viewCount: { lt: value } },
{
viewCount: value,
id: { lt: id },
},
],
};
✔️ 응답 구조와의 연결
실제 서비스 응답은 대략 이런 형태가 됩니다.
return {
posts: resultPosts.map((post) => toPostListResponse(post)),
hasNextPage,
...(hasNextPage && { nextCursor: buildPostCursor(page.sort, lastPost) }),
};
▸ posts → 현재 페이지 데이터
▸ hasNextPage → 더 불러올 수 있는지 여부
▸ nextCursor → 다음 요청 기준점
특히 nextCursor를 hasNextPage === true일 때만 포함시키는 방식은 응답 의미도 명확하고, 클라이언트 입장에서도 사용하기 편합니다.
4. 집계 컬럼 기반 정렬과 응답 최적화 전략
조회 성능은 DB → 서버 → 네트워크 → 클라이언트까지 이어지는 전체 흐름의 문제입니다.
🔷 조회 목적에 맞는 Select 설계
일반적인 ORM 사용 패턴에서는 모델의 전체 필드를 조회하는 경우가 많습니다.
// ❌ 비효율적인 전체 조회
await prisma.post.findMany();
하지만 실제 서비스의 리스트 화면을 분석해 보면 다음과 같은 특징이 있습니다:
▸ 본문(content)이나 내부 관리용 메타데이터는 거의 사용되지 않습니다.
▸ 작성자 이름이나 댓글 수 같은 특정 관계 데이터만 선별적으로 필요합니다.
따라서 이번 구조 설계에서는 조회 목적에 맞게 select를 명확히 분리하여 정의하였습니다.
✔️ 구현 예시 (postListSelect)
const postListSelect = {
id: true,
title: true,
published: true,
author: {
select: {
id: true,
displayName: true,
},
},
createdAt: true,
viewCount: true,
likeCount: true,
replyCount: true,
};
1. 데이터 다이어트 (불필요한 데이터 제거)
▸ content나 무거운 파일 정보, 메타데이터를 제외하고 필요한 필드만 조회합니다.
▸ 응답 패킷 크기 감소: 네트워크 전송 속도가 향상됩니다.
▸ 직렬화 비용 절감: 서버에서 JSON으로 변환하는 연산 부하가 줄어듭니다.
2. 효율적인 관계 데이터 조회
▸ author: { select: { ... } } 구문을 통해 관계 데이터를 한 번에 가져옵니다.
▸ Join 최적화: 별도의 추가 쿼리 없이 DB 수준에서 Join 처리됩니다.
▸ N+1 문제 방지: 리스트 조회 시 각 항목마다 작성자 정보를 따로 쿼리하는 비효율을 원천 차단합니다.
3. 집계(Aggregation) 컬럼의 즉시 활용
▸ viewCount, likeCount, replyCount 등 리스트 정렬과 노출에 필요한 집계 데이터를 포함합니다.
▸ 계산 오버헤드 제거: 클라이언트나 서버 앱 단에서 별도 계산 없이 즉시 렌더링이 가능합니다.
▸ 정렬 효율성: DB 레벨에서 해당 컬럼을 기준으로 정렬된 결과를 바로 받아볼 수 있습니다.
🔷 DTO 변환: 데이터 구조를 통제하는 단계
Repository(또는 DB)에서 가져온 로우 데이터를 클라이언트에 그대로 반환하지 않고, Service 레이어에서 의도에 맞게 가공(Mapping)하여 전달하는 구조를 채택했습니다.
✔️ 구현 예시 (toPostListResponse)
function toPostListResponse(post: PostRow) {
return {
id: post.id,
title: post.title,
published: post.published,
// 1. 직렬화 제어
createdAt: post.createdAt.toISOString(),
viewCount: post.viewCount,
likeCount: post.likeCount,
replyCount: post.replyCount,
// 2. 계층 구조 및 필드명 재정의
author: {
id: post.author.id,
name: post.author.displayName, // displayName -> name으로 매핑
},
};
}
📌 왜 번거로운 변환 과정을 거치는가?
처음에는 "DB 결과를 그대로 return하면 코드가 더 간결하지 않을까?"라는 의문이 생길 수 있습니다.
1. DB 컬럼 구조를 외부에 노출하지 않습니다.
▸ 이는 시스템의 보안성을 높일 뿐만 아니라, 내부 로직이 변경되더라도 외부 인터페이스를 유지할 수 있게 해줍니다.
2. 서비스가 성장할수록 다음과 같은 상황에 직면하게 됩니다.
▸ DB의 변화: 성능 최적화나 정규화를 위해 DB 구조는 수시로 변경될 수 있습니다.
▸ API의 일관성: DB가 바뀌더라도 API는 하위 호환성을 위해 최대한 안정적으로 유지되어야 합니다.
▸ 클라이언트의 기대: 프론트엔드는 데이터가 갑자기 사라지거나 이름이 바뀌는 것에 매우 민감합니다
🔷 집계 컬럼 기반 정렬과 응답 구성
보통 댓글 수나 좋아요 수를 가져올 때 실시간으로 COUNT 연산을 수행하곤 합니다.
하지만 이번 설계에서는 이를 모델의 정적 컬럼(viewCount, likeCount, replyCount)으로 구성하여 시스템 부하를 최소화했습니다.
✔️ 기존 방식: 실시간 집계 (Runtime Aggregation)
데이터를 조회할 때마다 관계 테이블을 조인하고 그룹화하여 계산합니다.
SELECT p.*, COUNT(r.id) AS replyCount
FROM posts p
LEFT JOIN replies r ON r.post_id = p.id
GROUP BY p.id
ORDER BY replyCount DESC;
문제점: 데이터가 늘어날수록 JOIN과 GROUP BY 비용이 기하급수적으로 상승하며, 정렬(Order By) 시 인덱스를 타지 못해 성능 병목이 발생합니다.
✔️ 현재 구조: 비정규화 집계 (Denormalized Columns)
이벤트(댓글 작성 등) 발생 시점에 컬럼 값을 업데이트하고, 조회 시에는 필드를 그대로 읽습니다.
// Prisma 예시
const posts = await prisma.post.findMany({
orderBy: [
{ replyCount: 'desc' }, // 즉시 정렬 가능
{ id: 'desc' }
],
});
효과: 추가적인 JOIN이나 계산 연산이 전혀 없습니다. 단일 테이블 스캔만으로 정렬된 결과를 즉시 반환합니다.
replyCount, likeCount와 같은 필드에 인덱스를 생성해두면, 수백만 건의 데이터에서도 상위 인기 게시물을 초고속으로 추출할 수 있습니다.
복합 정렬: replyCount가 같을 경우 id 순으로 정렬하는 등 다중 조건 정렬도 매우 가볍게 처리됩니다.
🔷 Keyset Pagination과 응답 구조
데이터 양이 많아질수록 느려지는 Offset(Skip/Take) 방식 대신, 마지막으로 조회한 데이터의 위치(Cursor)를 기준으로 다음 페이지를 찾는 방식을 사용합니다.
이를 위해 응답 구조 역시 다음 페이지 여부와 커서 정보를 포함하도록 설계했습니다.
✔️ 구현 로직 및 예시
// 1. 요청한 개수(take)보다 하나 더 조회하여 다음 페이지 존재 여부 확인
const posts = await repository.selectMany({
take: take + 1,
...
});
const hasNextPage = posts.length > take;
// 2. 'hasNextPage' 확인용으로 가져온 마지막 데이터는 실제 응답에서 제외
const items = hasNextPage ? posts.slice(0, -1) : posts;
// 3. 정렬 조건에 따른 동적 커서 생성
function buildPostCursor(sort, lastPost) {
return {
id: lastPost.id,
...(sort === 'mostViewed' && { value: lastPost.viewCount }),
...(sort === 'mostLiked' && { value: lastPost.likeCount }),
...(sort === 'mostReplied' && { value: lastPost.replyCount }),
};
}
1. 고비용 COUNT 쿼리 제거
▸ 전체 게시글 수를 계산하는 SELECT COUNT(*)는 데이터가 많아질수록 DB에 큰 부담을 줍니다.
▸ "전체 개수" 대신 "다음 페이지 존재 여부"만 판단함으로써 쿼리 성능을 비약적으로 향상시켰습니다.
2. 네트워크 및 메모리 최적화
▸ take + 1 조회를 통해 서버 내부에서 다음 페이지 여부를 미리 확인합니다.
▸ 클라이언트에게는 정확히 필요한 take만큼의 데이터만 전달하며, 불필요한 추가 요청 없이 매끄러운 UX(무한 스크롤 등)를 제공합니다.
3. 정렬 기준과 커서의 일치 (Deterministic Cursor)
▸ 단순 id뿐만 아니라 정렬의 기준이 되는 값(viewCount, likeCount 등)을 커서에 포함합니다.
▸ 데이터가 실시간으로 추가/삭제되어도 페이지가 건너뛰어지거나 중복 노출되는 현상을 방지합니다.
▸ 앞에서 설계한 집계 컬럼 덕분에 별도의 연산 없이 커서 값을 즉시 추출할 수 있습니다.
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.