200,000개의 데이터베이스 행이 있을 때 Next.js 성능
Source: Dev.to
대부분의 Next.js 튜토리얼은 10개의 게시물로 블로그를 만드는 방법을 보여줍니다. 실제 애플리케이션은 수십만 개의 레코드를 가지고 있습니다. 데이터베이스가 작지 않을 때 실제로 중요한 것이 무엇인지 살펴보겠습니다.
문제
저는 최근에 200,000개가 넘는 제품 목록을 보유한 마켓플레이스에서 작업했습니다. 튜토리얼과 데모에서 제공되는 표준 패턴은 그 규모에서는 금방 무너지기 때문에, 아래에 나오는 대부분은 우리가 반응성을 유지하기 위해 그 과정에서 알아낸 내용입니다.
데이터베이스 쿼리가 React보다 더 중요합니다
이것은 명백해 보이지만 나는 계속 무시되는 것을 본다: 데이터베이스가 병목 현상이며, React가 아니다.
Postgres 쿼리가 3 초가 걸린다면, 아무리 React를 최적화해도 도움이 되지 않는다. 먼저 쿼리를 수정하라.
인덱스는 선택 사항이 아닙니다
필터링하거나 정렬하는 모든 컬럼은 인덱스가 필요합니다. 끝.
// schema.prisma – add index for search
model Product {
id Int @id @default(autoincrement())
name String
@@index([name])
}
텍스트 검색을 위해 우리는 Prisma의 필터링과 PostgreSQL GIN trigram 인덱스를 사용했습니다. 인덱스는 마이그레이션에 포함되며, Prisma가 쿼리 레이어를 처리합니다.
- 인덱스가 없을 경우: 이름으로 200 k 행을 검색하면 ≈ 4 seconds.
- 인덱스가 있을 경우: ≈ 45 ms.
사용하지 마세요 인덱스가 없는 컬럼에
contains를 사용하지 마세요, 진행 스피너를 보는 것을 즐기지 않는다면.
Pagination, Not Infinite Scroll (Usually)
Infinite scroll는 트렌디하지만 함정이기도 합니다. 사용자가 스크롤할 때마다 더 많은 데이터를 가져오고, 메모리에 보관한 뒤 리스트를 다시 렌더링합니다. 약 500개 정도가 되면 브라우저가 느려지고 메모리 사용량이 급증합니다.
대신 cursor‑based pagination을 사용하세요:
// Get 20 products after this cursor
const products = await db.product.findMany({
take: 20,
skip: 1,
cursor: { id: lastProductId },
orderBy: { createdAt: 'desc' },
});
- 사용자는 한 번에 20개의 아이템을 받아볼 수 있고, 앞뒤로 페이지를 이동할 수 있으며, 브라우저가 10 000개의 DOM 노드를 보유해서 죽지 않습니다.
서버 컴포넌트는 당신의 친구
우리에게 가장 효과적이었던 패턴은 다음과 같습니다:
- Server components는 페이지 레이아웃, 헤딩, 메타데이터, 필터 라벨 및 요청마다 변하지 않는 모든 정적 콘텐츠에 사용합니다.
- Client component는 검색어, 필터, 페이지네이션 및 정렬에 따라 변하는 제품 그리드에 사용합니다.
// app/products/page.tsx – Server Component
export default function ProductsPage() {
return (
카드 찾아보기
200,000장이 넘는 포켓몬, MTG, 유희왕 등 다양한 카드.
// components/ProductBrowser.tsx – Client Component
'use client';
import { useState } from 'react';
import { useProducts } from '@/hooks/useProducts';
import { SearchBar } from '@/components/SearchBar';
import { FilterSidebar } from '@/components/FilterSidebar';
import { ProductGrid } from '@/components/ProductGrid';
import { Pagination } from '@/components/Pagination';
export function ProductBrowser() {
const [filters, setFilters] = useState(defaultFilters);
const { data, isLoading } = useProducts(filters);
return (
setFilters(f => ({ ...f, query: q }))} />
);
}
사용자는 페이지 구조와 정적 콘텐츠를 즉시 확인할 수 있으며, 제품 목록은 로드되는 동안 표시됩니다. 이러한 분리 덕분에 서버 컴포넌트 출력은 모든 방문자에게 동일하게 적극적으로 캐시될 수 있고, 클라이언트 컴포넌트만이 요청당 작업을 수행합니다.
N+1 쿼리 방지
전형적인 실수:
// Bad: N+1 query
const products = await db.product.findMany();
for (const product of products) {
product.seller = await db.user.findUnique({
where: { id: product.sellerId },
});
}
제품을 가져오기 위해 1개의 쿼리를 실행하고, 판매자를 위해 N개의 쿼리를 추가로 실행했습니다. 제품이 100개라면 총 101번의 왕복이 됩니다.
더 나은 방법: include 사용 (또는 조인):
// Good: 1 query
const products = await db.product.findMany({
include: { seller: true },
});
Prisma에서는 include가 내부적으로 조인을 수행하므로—한 번의 쿼리로 훨씬 빠릅니다.
Source: …
캐싱 전략
자주 변경되지 않는 데이터는 캐시합니다. 이 프로젝트에서는 Redis를 사용합니다:
- 검색 결과 – 5 분 동안 캐시
- 판매자 프로필 – 1 시간 동안 캐시
- 카테고리 목록 – 1 일 동안 캐시
import Redis from 'ioredis';
const redis = new Redis();
export async function getCachedProducts(category: string) {
const cacheKey = `products:${category}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
const products = await db.product.findMany({
where: { category },
take: 20,
});
await redis.setex(cacheKey, 300, JSON.stringify(products)); // 5 min TTL
return products;
}
이렇게 하면 재방문자에 대한 데이터베이스 부하를 80 % 이상 줄일 수 있습니다.
이미지 최적화
200 k 개가 넘는 제품 이미지가 있는 경우, 개별 파일이 작더라도 전체 해상도 PNG를 제공하면 대역폭이 크게 소모됩니다. Next.js Image 컴포넌트가 이를 자동으로 처리합니다:
import Image from 'next/image';
Next.js는 다음을 수행합니다:
- 적절한 크기의 WebP/AVIF 변형을 제공한다.
- 화면에 보이지 않는 이미지를 지연 로드한다.
- 자산을 캐시하고 CDN 최적화를 적용한다.
TL;DR
- 필터/정렬하는 모든 항목에 인덱스를 적용한다.
- 무한 스크롤이 아닌 페이지네이션을 사용한다.
- 정적 마크업을 위해 서버 컴포넌트를 활용한다.
include/조인을 사용해 N+1 쿼리를 방지한다.- 읽기 위주 데이터는 캐시한다 (Redis가 훌륭하게 작동한다).
- 이미지 최적화는 Next.js에 맡긴다.
이 패턴들을 적용하면 200 k 행 데이터셋도 10 포스트 블로그만큼 빠르게 느껴집니다.
지원되는 경우 WebP/AVIF 제공
- 디스플레이 크기에 맞게 이미지 크기 조정
- 화면 아래에 있는 이미지 지연 로드
- 최적화된 버전 캐시
next/image를 모든 곳에 사용하기만 해도 페이지 무게가 2 MB에서 400 KB로 감소했습니다.
느린 쿼리를 위한 스트리밍
때때로 쿼리가 단순히 느릴 때가 있습니다 (복잡한 조인, 집계 등). 전체 페이지를 차단하는 대신, 느린 부분을 스트리밍합니다.
import { Suspense } from 'react';
export default function Page() {
return (
}>
);
}
async function SlowProductList() {
const products = await someSlowQuery();
return ;
}
헤더와 푸터는 즉시 렌더링됩니다. 제품 목록은 준비가 되면 스트리밍되어, 사용자는 빈 페이지를 바라보는 대신 빠르게 무언가를 볼 수 있습니다.
Measure Everything
추측하지 말고 측정하세요.
우리는 자체 호스팅을 하고 있기 때문에 다음을 사용합니다:
- Prisma query logging을 통해 느린 쿼리를 포착합니다 (이걸 더 일관되게 해야 할 수도 있습니다)
- Redis monitoring을 통해 캐시 적중률을 추적합니다 (이것도 제대로 설정해야 합니다)
자체 호스팅된 Next.js 앱의 경우, 추가로 다음을 고려하세요:
- Prisma의
log: ['query']옵션을 사용해 느린 쿼리를 표면에 드러내기 - Redis
INFO통계를 통해 적중/실패 비율 확인 - 서버‑사이드 성능 모니터링 (New Relic, Datadog, 혹은 간단한 Express 미들웨어 로깅)
- 배포 파이프라인에 Lighthouse CI 적용
페이지가 느릴 경우, 다음을 확인하세요:
- 데이터베이스 쿼리가 느린가? (Prisma 로그 /
pg_stat_statements) - 캐시가 누락됐는가? (Redis 적중률)
- 너무 많은 JavaScript를 전송하고 있는가? (Next.js 번들 분석기)
보통은 #1이 원인입니다.
실제로 변화를 만든 요인
가장 큰 성능 향상을 만든 요소는 다음과 같습니다:
- Database indexes – 쿼리 시간을 초에서 밀리초로 단축
- Redis caching – 데이터베이스 접근을 80 % 감소
- Server Components – 클라이언트 JavaScript 감소, 초기 렌더링 속도 향상
- Image optimization – 페이지 용량이 5배 감소
나머지는 미미했습니다. 이 네 가지에 집중하면 충분합니다.
결론
큰 데이터셋은 튜토리얼에서 배운 패턴을 깨뜨립니다. 해결 방법은 복잡하지 않지만, 데이터 흐름을 다르게 생각해야 합니다.
- Database first → 데이터베이스 우선
- Cache aggressively → 캐시를 적극적으로 사용
- Ship less JavaScript → JavaScript를 덜 배포
그게 전부입니다.
비슷한 것을 만들었고 확장이 필요하신가요? 👉 morley.media
원본은 kira.morley.media에 게시되었습니다