N+1 문제
N+1 Query Problem
연관 데이터 조회 시 쿼리 폭발. 조인으로 해결.
N+1 Query Problem
연관 데이터 조회 시 쿼리 폭발. 조인으로 해결.
N+1 문제는 ORM(Object-Relational Mapping) 사용 시 발생하는 대표적인 성능 이슈입니다. 부모 엔티티 목록을 조회하는 1개 쿼리 후, 각 부모의 자식 데이터를 조회하기 위해 N개의 추가 쿼리가 발생합니다. 예: 게시글 10개를 조회하면 1개 쿼리, 각 게시글의 댓글을 가져오면 10개 추가 쿼리 = 총 11개 쿼리.
N+1 문제가 위험한 이유는 데이터 양에 비례해 쿼리 수가 폭발적으로 증가하기 때문입니다. 100개의 게시글이면 101개 쿼리, 1000개면 1001개 쿼리가 됩니다. 각 쿼리마다 네트워크 왕복, 커넥션 획득, SQL 파싱 오버헤드가 발생하여 응답 시간이 급격히 늘어납니다.
해결 방법 1 - Eager Loading (즉시 로딩): JOIN을 사용해 부모와 자식을 한 번에 가져옵니다. JPA의 fetch join, Django의 select_related/prefetch_related, Prisma의 include 등이 있습니다. 쿼리 수가 1~2개로 줄지만, 필요 없는 데이터도 가져올 수 있습니다.
해결 방법 2 - DataLoader 패턴: GraphQL에서 주로 사용하며, 여러 요청을 배치(batch)로 모아서 한 번에 처리합니다. IN 절을 사용한 단일 쿼리로 변환됩니다. 해결 방법 3 - 쿼리 최적화: 필요한 필드만 선택(projection), 서브쿼리 활용, 적절한 인덱싱으로 쿼리 효율을 높입니다.
// N+1 문제 예시 (TypeORM)
// ❌ N+1 문제 발생 코드
async function getPostsWithComments() {
const posts = await postRepository.find(); // 1개 쿼리: SELECT * FROM posts
for (const post of posts) {
// N개 쿼리: SELECT * FROM comments WHERE post_id = ?
const comments = await commentRepository.find({
where: { postId: post.id }
});
post.comments = comments;
}
// 총 N+1개 쿼리 발생!
return posts;
}
// ✅ 해결 1: Eager Loading (JOIN)
async function getPostsWithCommentsOptimized() {
// 1개 쿼리: SELECT ... FROM posts LEFT JOIN comments ON ...
const posts = await postRepository.find({
relations: ['comments'] // 또는 relations: { comments: true }
});
return posts;
}
// ✅ 해결 2: QueryBuilder로 Fetch Join
async function getPostsWithFetchJoin() {
const posts = await postRepository
.createQueryBuilder('post')
.leftJoinAndSelect('post.comments', 'comment')
.leftJoinAndSelect('post.author', 'author')
.where('post.published = :published', { published: true })
.getMany();
return posts;
}
// ✅ 해결 3: DataLoader 패턴 (GraphQL)
import DataLoader from 'dataloader';
// 배치 함수: 여러 ID를 한 번에 조회
const commentLoader = new DataLoader(async (postIds: number[]) => {
// 1개 쿼리: SELECT * FROM comments WHERE post_id IN (1, 2, 3, ...)
const comments = await commentRepository.find({
where: { postId: In(postIds) }
});
// postId별로 그룹화하여 반환
const commentsByPostId = new Map();
for (const comment of comments) {
const existing = commentsByPostId.get(comment.postId) || [];
commentsByPostId.set(comment.postId, [...existing, comment]);
}
return postIds.map(id => commentsByPostId.get(id) || []);
});
// GraphQL Resolver에서 사용
const resolvers = {
Post: {
comments: (post, _, { loaders }) => loaders.commentLoader.load(post.id)
}
};
// ✅ Prisma에서의 해결
const posts = await prisma.post.findMany({
include: {
comments: true, // Eager loading
author: {
select: { name: true, email: true } // 필요한 필드만
}
}
});
// ✅ Django에서의 해결
# select_related: FK 관계 (1:1, N:1) - JOIN 사용
posts = Post.objects.select_related('author').all()
# prefetch_related: Reverse FK, M:M 관계 - 별도 쿼리 + 메모리 조인
posts = Post.objects.prefetch_related('comments').all()
성능 디버깅에서:
"목록 페이지가 5초나 걸려요." - "쿼리 로그 확인해보니 200개 넘게 나가네요. Lazy Loading 때문에 N+1 발생한 거예요. relations 옵션 추가하거나 QueryBuilder로 fetch join 하면 쿼리 2개로 줄일 수 있어요."
기술 면접에서:
"N+1 문제 해결 경험이 있나요?" - "ORM 로깅 켜서 쿼리 수를 모니터링했어요. Eager Loading으로 해결했는데, 모든 연관관계를 무조건 로딩하면 over-fetching 문제가 생겨서, 화면별로 필요한 관계만 선택적으로 로딩하도록 개선했습니다."
코드 리뷰에서:
"for 문 안에서 DB 조회하는 코드가 있네요. 데이터 많아지면 문제 될 수 있으니, IN 절로 배치 조회하거나 JOIN으로 바꾸는 게 좋겠어요. 지금은 10개라 괜찮아 보여도 나중에 1000개 되면 장애 납니다."