본문 바로가기
▼ 코딩 공부하기/▼▼ 백엔드

[백엔드 필수] N+1 문제, 아직도 반복문으로 쿼리 날리시나요? (Prisma 최적화)

by mdeeno 2026. 1. 15.
반응형

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를 먼저 떠올리자.

이 글이 도움되셨다면 좋아요와 구독 부탁드립니다! 🙇‍♂️

반응형