과연 Prisma에서도 N+1이 발생할까

장달진·2025년 2월 20일

N+1이란?


ORM은 개발 생산성을 향상시키지만, SQL 쿼리 실행 방법을 완벽하게 제어할 수 없다.
따라서 의도치 않은 문제가 발생할 수 있는데, 그중 하나가 Overfetching과 Underfetching이다.

Overfetching
필요 이상의 데이터를 가져오는 현상입니다. 예를 들어, 사용자 이름과 이메일만 필요한 상황에서 사용자 테이블의 모든 컬럼을 가져오는 경우가 Overfetching에 해당합니다. Overfetching은 불필요한 네트워크 트래픽을 유발하고, 메모리 낭비를 초래할 수 있습니다.

Underfetching
필요한 데이터를 모두 가져오지 못하는 현상입니다. 예를 들어, 사용자 이름과 함께 사용자가 작성한 게시글 목록을 가져와야 하는데, 사용자 정보만 가져온 후 게시글 목록을 가져오기 위해 추가적인 쿼리를 실행하는 경우가 Underfetching에 해당합니다. Underfetching은 추가적인 데이터베이스 접근을 유발하여 성능 저하를 초래할 수 있습니다.

Lazy Loading
Lazy Loading은 엔티티가 실제로 사용될 때까지 연관된 엔티티의 로딩을 지연시키는 방식입니다. 예를 들어, 사용자를 조회할 때 사용자의 게시글 목록은 사용자가 실제로 게시글 목록에 접근할 때까지 로딩하지 않습니다.

  • 장점
    1. 불필요한 데이터 로딩을 줄여 성능을 향상시킬 수 있습니다.
    2. 메모리 사용량을 줄일 수 있습니다.
  • 단점:
    1. 필요한 시점에 추가적인 쿼리가 발생하여 N+1 문제를 유발할 수 있습니다.
    2. 애플리케이션 로직이 복잡해질 수 있습니다.

Eager Loading
Eager Loading은 엔티티를 조회할 때 연관된 엔티티를 함께 로딩하는 방식입니다. 예를 들어, 사용자를 조회할 때 사용자의 게시글 목록을 함께 로딩합니다.

  • 장점
    1. N+1 문제를 해결할 수 있습니다.
    2. 애플리케이션 로직을 단순화할 수 있습니다.
  • 단점
    1. 불필요한 데이터 로딩이 발생하여 성능 저하를 유발할 수 있습니다
    2. 메모리 사용량이 증가할 수 있습니다.

사실 프리즈마를 사용하면서 기본 설정이 Join으로 걸려있기도 하고 대부분 select/include를 같이 사용하기에 N+1에 대한 문제를 생각하지 않고 코딩해왔는데 인위적으로 만들어보고 싶어졌다 🤔

schema.prisma


generator client {
  provider        = "prisma-client-js"
  output          = "./generated"
  previewFeatures = ["relationJoins"]
}

datasource db {
  provider = "sqlite"
  url      = "file:../db/schema.db"
}

model User {
  id Int @id @default(autoincrement())

  name String
  age  Int

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt
  Post      Post[]

  @@index([age])
}

model Post {
  id Int @id @default(autoincrement())

  userId Int

  title   String
  content String

  createdAt DateTime @default(now())
  updatedAt DateTime @default(now()) @updatedAt

  user User @relation(fields: [userId], references: [id])
}

SQLite에서 Prisma


createManyAndReturn() / updateManyAndReturn()를 보고 언제 생겼지 했는데

This feature is available in Prisma ORM version 5.14.0 and later for PostgreSQL, CockroachDB and SQLite

생긴지 좀 된거였는데 MySQL에서 사용하면 여러가지가 안 되는 것을 깨달았다.
기본 예제로 PostgreSQL을 제공하는 이유가 어지간한 기능은 다 사용할 수 있어서라고 생각된다. 사이드 프로젝트를 하게 되면 ORM을 선택할 때 어떤 DB를 사용할 것인가도 고려사항에 넣어야겠다. 🤔

테스트 데이터 생성


async function createData() {
  const uids = Array.from({ length: 10 }, () => randomUUID());

  //User 생성
  await prismaClient.user.createMany({
    data: uids.map((uid, i) => ({
      name: `name_${i + 1}`,
      age: i,
    })),
  });
  const users = await prismaClient.user.findMany({
    where: { uid: { in: uids } },
  });

  //Post 생성
  await prismaClient.post.createMany({
    data: users
      .map((user) =>
        Array.from({ length: 10 }, (_, i) => ({
          userId: user.id,
          title: `${user.id} - title ${i + 1}`,
          content: `${user.id} - content ${i + 1}`,
        })),
      )
      .flatMap((data) => data),
  });
}

MySQL에서는 RETURNING을 사용할 수 없으니 uid를 추가하여 처리했다

find 메소드


schema.prisma에 previewFeatures = ["relationJoins"]를 추가한 이유는 relationLoadStrategy: 'query'를 하면 N+1을 유도할 수 있지 않을까 했다.

async function find() {
  const users = await prismaClient.user.findMany({
    relationLoadStrategy: 'query',
    include: {
      Post: true,
    },
  });

  console.log(JSON.stringify(users, null, 2));
}

실행시켜보면

{
  timestamp: 2025-02-20T06:47:00.504Z,
  query: 'SELECT `velog`.`User`.`id`, `velog`.`User`.`uid`, `velog`.`User`.`name`, `velog`.`User`.`age`, `velog`.`User`.`createdAt`, `velog`.`User`.`updatedAt` FROM `velog`.`User` WHERE 1=1',
  params: '[]',
  duration: 3,
  target: 'quaint::connector::metrics'
}
{
  timestamp: 2025-02-20T06:47:00.505Z,
  query: 'SELECT `velog`.`Post`.`id`, `velog`.`Post`.`uid`, `velog`.`Post`.`userId`, `velog`.`Post`.`title`, `velog`.`Post`.`content`, `velog`.`Post`.`createdAt`, `velog`.`Post`.`updatedAt` FROM `velog`.`Post` WHERE `velog`.`Post`.`userId` IN (?,?,?,?,?,?,?,?,?,?)',
  params: '[1,2,3,4,5,6,7,8,9,10]',
  duration: 0,
  target: 'quaint::connector::metrics'
}

JOIN을 하지 않고 나누어서 쿼리를 요청하지만 IN을 사용해서 N+1이 발생하지는 않는다.

GraphQL + Fluent API


Prisma Query Optimization

Solving the n+1 problem

The n+1 problem occurs when you loop through the results of a query and perform one additional query per result, resulting in n number of queries plus the original (n+1). This is a common problem with ORMs, particularly in combination with GraphQL, because it is not always immediately obvious that your code is generating inefficient queries.

GraphQL에서 주로 발생할 수 있다고 되어있다. 이를 위해 Dataloader가 탑재되어 있고 일정 시간(tick)동안 발생한 쿼리를 모아서 처리한다. 아직은 findUnique만 지원한다고 되어있어서 post목록을 구할 때

  const posts = await prismaClient.user.findUnique({ where: { id: 1 } }).Post();

이렇게 역전해서 해야한다고 한다. 또는 relationLoadStrategy: "join"로 하면 된다고 한다.

결론


N+1에 대한 문제가 존재한다는 것은 알고 있었는데 전직장에서 TypeORM할때는 QueryBuilder로 Join을 썼고, 현직장에서는 Prisma만 써왔으니까 발생한 적이 없어 완전한 관심밖이였는데 정말 오랜만에 찾아보았다. 면접때 이런 것도 대답못했다니 떨어질만했다 😇

소스코드


달진이네 Github

profile
아무것도 모르는 개발자

0개의 댓글