🔍 검색 키워드: 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 데이터가 필요한데도 include나 select를 사용하지 않아 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는 필수다.
댓글 없음:
댓글 쓰기