200,000개의 데이터베이스 행이 있을 때 Next.js 성능

발행: (2026년 2월 10일 오전 04:24 GMT+9)
12 분 소요
원문: Dev.to

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 적용

페이지가 느릴 경우, 다음을 확인하세요:

  1. 데이터베이스 쿼리가 느린가? (Prisma 로그 / pg_stat_statements)
  2. 캐시가 누락됐는가? (Redis 적중률)
  3. 너무 많은 JavaScript를 전송하고 있는가? (Next.js 번들 분석기)

보통은 #1이 원인입니다.

실제로 변화를 만든 요인

가장 큰 성능 향상을 만든 요소는 다음과 같습니다:

  • Database indexes – 쿼리 시간을 초에서 밀리초로 단축
  • Redis caching – 데이터베이스 접근을 80 % 감소
  • Server Components – 클라이언트 JavaScript 감소, 초기 렌더링 속도 향상
  • Image optimization – 페이지 용량이 5배 감소

나머지는 미미했습니다. 이 네 가지에 집중하면 충분합니다.

결론

큰 데이터셋은 튜토리얼에서 배운 패턴을 깨뜨립니다. 해결 방법은 복잡하지 않지만, 데이터 흐름을 다르게 생각해야 합니다.

  • Database first데이터베이스 우선
  • Cache aggressively캐시를 적극적으로 사용
  • Ship less JavaScriptJavaScript를 덜 배포

그게 전부입니다.

비슷한 것을 만들었고 확장이 필요하신가요? 👉 morley.media

원본은 kira.morley.media에 게시되었습니다

Back to Blog

관련 글

더 보기 »