금요일

Prisma N+1 쿼리 문제 완벽 해결 가이드

🔍 검색 키워드: Prisma N+1 쿼리 문제, relation 로드 최적화, Prisma include, eager loading, 데이터베이스 쿼리 최적화

Prisma N+1 쿼리 문제 완벽 해결 가이드

1. 증상: N+1 쿼리 문제 발생

API 요청 하나에 데이터베이스 쿼리가 1 + N개 날아간다:

// 문제 있는 코드
const users = await prisma.user.findMany(); // Query 1: 사용자 100명 조회

for (const user of users) {
  user.posts = await prisma.post.findMany({
    where: { authorId: user.id }
  }); // Query 2-101: 각 사용자마다 posts 조회 (100번 반복)
}

// 총 101개의 쿼리 발생! 🚨

// 로그:
// Query 1: SELECT * FROM "User" WHERE 1=1
// Query 2: SELECT * FROM "Post" WHERE "authorId" = 1
// Query 3: SELECT * FROM "Post" WHERE "authorId" = 2
// ...
// Query 101: SELECT * FROM "Post" WHERE "authorId" = 100

API 응답 속도가 매우 느리고, 데이터베이스 CPU 사용률이 급증한다. 동시 사용자가 많아지면 connection pool 고갈로 이어진다.

2. 원인 분석

Lazy Loading의 위험성

Prisma의 기본 동작은 "필요할 때만 로드하는" lazy loading이다. relation을 접근할 때마다 새로운 쿼리를 발생시킨다.

Include 미사용

relation 데이터가 필요한데도 includeselect를 사용하지 않아 N번의 추가 쿼리가 필요하다.

깊은 relation 체인

user.posts.comments.author.profile 같은 깊은 relation 체인은 기하급수적으로 쿼리가 증가한다.

루프 내에서의 데이터베이스 접근

forEach, for-of 루프 내에서 직접 데이터베이스 쿼리를 실행하는 구조.

3. 해결방법

방법 1: Include를 사용한 Eager Loading

// 1단계: 단순 include
const users = await prisma.user.findMany({
  include: {
    posts: true  // 모든 posts를 함께 로드
  }
});
// 총 2개 쿼리:
// Query 1: SELECT * FROM "User"
// Query 2: SELECT * FROM "Post" WHERE "authorId" IN (1, 2, ..., 100)

// 2단계: 조건부 include
const users = await prisma.user.findMany({
  include: {
    posts: {
      where: { published: true },  // published=true인 posts만
      orderBy: { createdAt: 'desc' },
      take: 5  // 최근 5개만
    }
  }
});

// 3단계: 깊은 relation include (2단계까지만 권장)
const users = await prisma.user.findMany({
  include: {
    posts: {
      include: {
        comments: {  // posts의 comments도 로드
          include: {
            author: true  // comments의 author도 로드
          }
        }
      }
    }
  }
});

방법 2: Select를 사용한 필드 최적화

// 필요한 필드만 선택
const users = await prisma.user.findMany({
  select: {
    id: true,
    name: true,
    email: true,
    posts: {
      select: {
        id: true,
        title: true,
        slug: true
      }
    }
  }
});

// 장점:
// 1. 필요 없는 컬럼(password, bio 등)을 제외 → 네트워크 대역폭 절약
// 2. 쿼리 성능 향상
// 3. 클라이언트에 민감한 정보 노출 방지

방법 3: BatchLoad 패턴 (DataLoader)

// dataloader 라이브러리 설치: npm install dataloader

import DataLoader from 'dataloader';

// User의 posts를 배치로 로드하는 DataLoader
const postsByUserIdLoader = new DataLoader(async (userIds) => {
  const postsByUserId = await prisma.post.findMany({
    where: { authorId: { in: userIds } }
  });

  // userIds 순서대로 결과 반환
  return userIds.map(userId =>
    postsByUserId.filter(post => post.authorId === userId)
  );
});

// GraphQL resolver에서 사용
const userResolver = {
  posts: (user) => postsByUserIdLoader.load(user.id)
};

// 효과:
// 100개의 개별 쿼리 대신 1개의 배치 쿼리로 통합

방법 4: 정규화된 API 응답 구조

// 비정규화 응답 (nested - 중복 데이터)
{
  users: [
    {
      id: 1,
      name: 'Alice',
      posts: [
        { id: 101, title: 'Post 1', author: { id: 1, name: 'Alice' } }
      ]
    }
  ]
}

// 정규화 응답 (flat - 효율적)
{
  users: [{ id: 1, name: 'Alice' }],
  posts: [{ id: 101, title: 'Post 1', authorId: 1 }]
}

// Prisma에서 정규화 응답 생성
const [users, posts, comments] = await Promise.all([
  prisma.user.findMany(),
  prisma.post.findMany(),
  prisma.comment.findMany()
]);

const response = {
  users,
  posts,
  comments
};
// 이렇게 하면 클라이언트가 필요한 대로 조합 가능

방법 5: 쿼리 성능 측정 및 디버깅

// .prisma/client 에서 로깅 활성화
const prisma = new PrismaClient({
  log: [
    { emit: 'stdout', level: 'query' },
    { emit: 'stdout', level: 'info' },
    { emit: 'stdout', level: 'warn' },
    { emit: 'stderr', level: 'error' }
  ]
});

// 또는 런타임 환경에서
prisma.$on('query', (e) => {
  console.log(`${e.query} - ${e.duration}ms`);
});

// 성능 프로파일링 (Node.js console.time)
console.time('fetch users with posts');
const users = await prisma.user.findMany({
  include: { posts: true }
});
console.timeEnd('fetch users with posts');
// fetch users with posts: 45.231ms

4. 정리표

상황 해결 방법 쿼리 수 성능 개선도
N+1 발생 include 미사용 1 + N -
단순 관계 include true 2 ⬆⬆⬆
조건 필터 include with where 2 ⬆⬆⬆
깊은 관계 (2단계) 중첩 include 3-4 ⬆⬆⬆
GraphQL 환경 DataLoader 1-2 (배치) ⬆⬆⬆⬆
민감한 필드 select 사용 2 + 대역폭↓ ⬆⬆⬆
매우 깊은 관계 정규화 응답 N (분산) ⬆⬆⬆

핵심: N+1은 Prisma만의 문제가 아니라 ORM의 근본적인 특성. include로 eager loading을 하거나, API 설계 단계부터 정규화된 응답 구조를 고려해야 한다. GraphQL을 사용한다면 DataLoader는 필수다.

Next.js Hydration Mismatch 에러 해결

🔍 검색 키워드: Next.js hydration 불일치, hydration mismatch 에러, Next.js 서버사이드 렌더링, suppressHydrationWarning

Next.js Hydration Mismatch 에러 해결

증상

Warning: Expected server HTML to contain a matching <div> in <div>.
This means the server HTML, the browser's DOM, and the virtual DOM are out of sync.

또는:

Text content did not match. Server: "2026-07-30 14:32" Client: "2026-07-30 14:33"

개발 환경에서 콘솔에 경고가 나타나고, 프로덕션에서 UI가 깜빡거리거나 스타일이 제대로 적용되지 않습니다.

원인

Next.js는 서버에서 HTML을 생성한 후 클라이언트에서 해당 DOM에 이벤트 리스너를 연결(hydration)합니다. 서버와 클라이언트의 렌더링 결과가 다르면 hydration mismatch 발생:

// ❌ 서버와 클라이언트 결과가 다른 경우

export default function Clock() { const now = new Date(); // 서버와 클라이언트 시간 다름 return <div>{now.toLocaleString()}</div>; }

// 서버 렌더링: "2026-07-30 08:00:00 UTC" // 클라이언트 렌더링: "2026-07-30 14:00:00 KST" // → Mismatch!

흔한 원인들: - 시간/날짜 함수 사용 (Date, new Date()) - 난수 생성 (Math.random()) - localStorage/sessionStorage 접근 - 조건부 렌더링이 서버/클라이언트마다 다름 - CSS-in-JS의 서버 스타일과 클라이언트 스타일 불일치

해결방법

1. useEffect 활용 (권장)

클라이언트에서만 렌더링하는 콘텐츠는 마운트 후 렌더링:
import { useState, useEffect } from 'react';

export default function Clock() { const [time, setTime] = useState(null);

useEffect(() => { setTime(new Date().toLocaleString()); }, []); // 클라이언트에서만 실행

// 서버 렌더링: null 또는 placeholder 반환 if (!time) return <div>Loading...</div>; return <div>{time}</div>; }

2. suppressHydrationWarning (간단한 경우)

export default function Counter() {
  return (
    <div suppressHydrationWarning>
      {Math.random()}
    </div>
  );
}

// 또는 특정 속성만 억제 export default function Div() { return ( <div suppressHydrationWarning={true}> Content </div> ); }

3. Dynamic Import with ssr: false

// components/ClientOnlyComponent.tsx
export default function ClientOnly() {
  return <div>{Math.random()}</div>;
}

// pages/index.tsx import dynamic from 'next/dynamic';

const ClientOnlyComponent = dynamic( () => import('@/components/ClientOnlyComponent'), { ssr: false } );

export default function Home() { return ( <div> <h1>Server Content</h1> <ClientOnlyComponent /> {/ 클라이언트에서만 렌더링 /} </div> ); }

4. useLayoutEffect 또는 useId (안정적 ID)

import { useId } from 'react';

export default function Modal() { const id = useId(); // 서버와 클라이언트에서 동일한 ID 생성

return ( <div id={id} data-modal={id}> Modal with stable ID </div> ); }

5. 실전 예제: Dark Mode Toggle

'use client'; // App Router의 경우

import { useState, useEffect } from 'react';

export default function ThemeProvider() { const [isDark, setIsDark] = useState(false); const [isMounted, setIsMounted] = useState(false);

useEffect(() => { // 로컬스토리지에서 테마 읽기 (클라이언트에서만) const saved = localStorage.getItem('theme') === 'dark'; setIsDark(saved); setIsMounted(true); }, []);

// 마운트 전: 기본값 표시 (서버 렌더링과 일치) if (!isMounted) { return <div className="light">Loading...</div>; }

// 마운트 후: 실제 테마 적용 return ( <div className={isDark ? 'dark' : 'light'}> <button onClick={() => setIsDark(!isDark)}> Toggle Theme </button> </div> ); }

6. 조건부 렌더링 안정화

// ❌ hydration mismatch 위험
const isClient = typeof window !== 'undefined';

export default function Component() { return isClient ? <ClientComponent /> : <ServerComponent />; }

// ✅ useEffect 사용 import { useEffect, useState } from 'react';

export default function Component() { const [isClient, setIsClient] = useState(false);

useEffect(() => { setIsClient(true); }, []);

if (!isClient) return <ServerComponent />; return <ClientComponent />; }

정리표

원인 증상 해결책
시간/날짜 함수 "Expected server HTML..." 경고 useEffect에서만 실행
localStorage 접근 서버/클라이언트 값 다름 마운트 후 접근
조건부 렌더링 UI 깜빡임 또는 깨짐 useLayoutEffect 또는 dynamic import
난수 생성 텍스트 불일치 경고 suppressHydrationWarning 또는 useId
CSS-in-JS 불일치 스타일 깜빡임 서버 스타일과 클라이언트 스타일 동기화
핵심: 서버에서 실행되는 코드와 클라이언트에서 실행되는 코드가 동일한 결과를 생성하도록 하거나, 클라이언트에서만 실행되는 로직을 useEffect로 분리하세요.

Nginx 502 Bad Gateway 에러 완벽 해결법