🗄️ 데이터베이스

N+1 문제

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개 되면 장애 납니다."

⚠️ 주의사항

🔗 관련 용어

ORM Join Index GraphQL Query Optimization

📚 더 배우기

📄 Prisma - Query Optimization 📄 TypeORM - Eager and Lazy Relations 📄 DataLoader - Batching and Caching