ORM(Object-Relational Mapping)을 처음 사용할 때 가장 흔하게 겪는 성능 이슈,
바로 N+1 문제입니다.
"로컬에서는 잘 돌아갔는데, 데이터가 쌓이니까 API가 너무 느려요!"라고 한다면 90%는 이 문제입니다.
오늘은 이 N+1 문제가 정확히 무엇인지 개념을 잡고,
Prisma 환경에서 실수하기 쉬운 나쁜 코드(Bad)와 이를 최적화한 좋은 코드(Good)를 비교해서 보여드리겠습니다.
1. N+1 문제란?
1번의 쿼리로 해결할 수 있는 일을, 데이터의 개수(N)만큼 추가 쿼리를 날려서
총 N+1번의 쿼리를 실행하는 비효율적인 상황
예를 들어, 게시글(Post) 목록을 가져오면서 각 글을 쓴 작성자(User) 정보를 함께 보여줘야 한다고 가정해 봅시다.
- 1번의 쿼리 : 게시글 10개를 가져옵니다.
SELECT * FROM Post LIMIT 10 - N번의 쿼리 (문제 발생!) :
가져온 게시글 10개를 반복문(Loop)으로 돌면서, 각 게시글마다 작성자 정보를 조회합니다.SELECT * FROM User WHERE id = ?
게시글이 10개면 10번, 1,000개면 1,000번의 추가 쿼리가 발생합니다. 결국 DB에 과도한 부하를 주고 응답 속도를 치명적으로 느리게 만듭니다.
2. 문제 상황 가정 (Schema)
User(사용자)와 Post(게시글)가 1:N 관계라고 가정합니다.
model User {
id Int @id @default(autoincrement())
name String
posts Post[]
}
model Post {
id Int @id @default(autoincrement())
title String
authorId Int
author User @relation(fields: [authorId], references: [id])
}
3. 코드 비교 (Bad vs Good)
Prisma에서 이 문제를 어떻게 발생시키는지, 그리고 어떻게 해결하는지 비교해 보겠습니다.
❌ (Bad) 반복문 안에서 await 사용
절대 피해야 할 패턴입니다. 반복문이 돌 때마다 DB를 계속 호출합니다.
async function getUsersWithPostsBad() {
// 1. 모든 유저 조회 (쿼리 1번)
const users = await prisma.user.findMany();
// 2. 각 유저마다 게시글을 별도로 조회 (최악의 경우!)
// 유저가 N명이면 쿼리 N번 추가 발생
const usersWithPosts = await Promise.all(
users.map(async (user) => {
const posts = await prisma.post.findMany({
where: { authorId: user.id },
});
return { ...user, posts };
})
);
return usersWithPosts;
}
✅ (Good) include 옵션 사용 (Eager Loading)
Prisma의 include를 사용하면 내부적으로 최적화된 조인(Join) 혹은 배치 쿼리를 사용하여 데이터를 단 한 번(혹은 최소 횟수)에 가져옵니다.
async function getUsersWithPostsGood() {
// 한 번의 쿼리로 유저와 게시글을 모두 가져옴
const users = await prisma.user.findMany({
include: {
posts: true, // 연관된 posts 테이블의 데이터를 함께 로드
},
});
return users;
}
4. 전체 코드 (복사/붙여넣기용)
아래 코드는 바로 테스트해 보실 수 있도록 서비스 로직 형태로 정리한 전체 코드입니다.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export class UserService {
/**
* [권장] N+1 문제를 해결한 방식
* include를 사용하여 User와 연관된 Post를 한 번에 가져옵니다.
*/
async getUsersWithPosts() {
try {
const users = await prisma.user.findMany({
// 핵심: include를 사용하여 연관 데이터를 Eager Loading 함
include: {
posts: true,
},
// Tip: 특정 필드만 필요하다면 select 중첩 가능
/*
include: {
posts: {
select: { title: true }
}
}
*/
});
return users;
} catch (error) {
console.error("Error fetching users:", error);
throw error;
}
}
}
// 실행 예시
async function main() {
const userService = new UserService();
const data = await userService.getUsersWithPosts();
console.log(JSON.stringify(data, null, 2));
}
main()
.catch(e => console.error(e))
.finally(async () => await prisma.$disconnect());
💡 3줄 요약
1. 반복문(Loop) 안에서 쿼리(findMany 등)를 날리면 성능 지옥(N+1)이 열린다.
2. Prisma는 include 옵션을 쓰면 알아서 한 방에 가져온다.
3. 관계형 데이터를 가져올 땐 무조건 include를 먼저 떠올리자.
이 글이 도움되셨다면 좋아요와 구독 부탁드립니다! 🙇♂️
'▼ 코딩 공부하기 > ▼▼ 백엔드' 카테고리의 다른 글
| [기술면접] CSR, SSR, SSG 완벽 정리 & 왜 아직도 RDBMS를 쓸까? (0) | 2026.01.18 |
|---|---|
| 왜 카카오와 네이버는 RDBMS를 쓸까? 관계형 DB의 핵심 완벽 정리 (0) | 2026.01.18 |