NestJS + Prisma에서 N+1 쿼리 문제 해결하기

📚 NestJS 실전 트러블슈팅 시리즈 (12편)

NestJS + Prisma에서 API가 갑자기 느려졌다면 N+1 쿼리를 의심하세요. 650개 쿼리를 2개로 줄인 실전 해결기와 예방법을 정리했어요.


API 응답이 갑자기 3–5초씩 걸리기 시작했다. 평소 200ms면 끝나던 API가.

로그를 켜보니 쿼리가 650개 넘게 찍히고 있었다. NestJS + Prisma 환경에서 만나는 고전적인 N+1 쿼리 문제였다.


🔍 증상: 갑자기 느려진 API

🔍 증상: 갑자기 느려진 API 도중 예상치 못한 문제 발견
🔍 증상: 갑자기 느려진 API 도중 예상치 못한 문제 발견

통계 API를 만들고 있었다. 레벨별로 콘텐츠를 조회하고, 각 콘텐츠의 시도 기록을 집계하는 로직이었다.

코드는 직관적이었다. 근데 직관적인 게 함정이었다.

핵심: N+1 문제는 코드를 보면 “자연스럽게” 느껴진다. 그게 바로 왜 위험한지 이유다.

로컬 환경에서는 테스트 데이터가 20–30건 수준이라 전혀 티가 안 났다. 스테이징 배포 후 실데이터(레벨 31개, 콘텐츠 수백 건)가 붙으니 바로 터졌다 💀

❌ 문제의 코드

// ❌ 루프마다 쿼리 — 직관적이지만 치명적
// 레벨 31개를 순회하면서 매번 DB를 때린다
for (const level of levels) {           // 31개 레벨
  const contents = await prisma.content.findMany({
    where: { levelId: level.id }

...

  });
  for (const content of contents) {     // 레벨당 ~20개
    const attempts = await prisma.attempt.findMany({

...

      where: { contentId: content.id }
    });
    // 집계 로직...

...

  }
}

31개 레벨 × 20개 콘텐츠 = 620+ 쿼리. 여기에 레벨 조회 31개를 더하면 650개가 훌쩍 넘는다.

쿼리 하나당 평균 5ms라고 쳐도 650개면 벌써 3.25초다. 거기에 네트워크 오버헤드까지 더해지니 체감은 5초 이상이었다.


🔎 탐색: 처음엔 DB 인덱스 문제인 줄 알았다

처음 의심한 건 인덱스였다. levelId, contentId 컬럼에 인덱스가 빠져 있나 확인했다. Prisma 스키마를 보니 @relation으로 FK가 걸려 있어서 인덱스는 이미 있었다.

그다음 의심한 건 PostgreSQL slow query log. pg_stat_statements를 확인했는데, 개별 쿼리 실행 시간은 2–5ms로 빠르다. 쿼리 하나하나가 느린 게 아니라, 쿼리 자체가 너무 많았다.

Prisma 쿼리 로그를 켜보니 답이 바로 나왔다.

prisma:query SELECT ... FROM "Content" WHERE "levelId" = $1
prisma:query SELECT ... FROM "Content" WHERE "levelId" = $1
prisma:query SELECT ... FROM "Content" WHERE "levelId" = $1
... (31번 반복)
prisma:query SELECT ... FROM "Attempt" WHERE "contentId" = $1
prisma:query SELECT ... FROM "Attempt" WHERE "contentId" = $1
... (620번 반복)

N+1 문제는 ORM을 쓰면 거의 반드시 마주친다. Prisma도 예외가 없다.

주의: JPA에는 @BatchSize, @EntityGraph 같은 자동 해결 도구가 있다. Prisma에는 없다. 직접 풀어야 한다.

N+1이 무서운 진짜 이유

단순히 느리다는 문제만이 아니다. DB 커넥션 풀을 순식간에 고갈시킨다.

NestJS 기본 Prisma 설정에서 커넥션 풀은 보통 10개 내외다. 요청 하나가 650개 쿼리를 날리는 상황에서 동시 요청 10개가 들어오면? DB가 버티지 못하고 타임아웃이 터지기 시작한다. 단순 성능 저하를 넘어서 서비스 장애로 번질 수 있다.


🛠️ 해결 1: 사전 조회 + Map 패턴

가장 범용적인 해결법이다. 루프 밖에서 한 번에 전부 조회하고, Map으로 그룹핑한 뒤, 루프 안에서는 Map 조회만 한다.

DB 쿼리가 N번 → 2번으로 줄어든다. (부모 목록 1번 + 자식 전체 1번)

❌ Before — 루프마다 쿼리 (650회)

for (const level of levels) {
  const contents = await prisma.content.findMany({
    where: { levelId: level.id }

...

  });
  for (const content of contents) {
    const attempts = await prisma.attempt.findMany({

...

      where: { contentId: content.id }
    });
    // 집계 로직...

...

  }
}

✅ After — 사전 조회 + Map (2회)

// ✅ 핵심: IN 절로 한 번에 조회 → Map으로 O(1) 접근
// 650개 쿼리가 2개로 줄어드는 마법

// 1. 한 번에 전부 조회 — DB 왕복을 최소화
const allContents = await prisma.content.findMany({
  where: { levelId: { in: levels.map(l => l.id) } }

...

});

const allAttempts = await prisma.attempt.findMany({
  where: { contentId: { in: allContents.map(c => c.id) } }
});

// 2. Map으로 그룹핑 — 이후 루프에서 O(1) 접근 보장
const contentsByLevel = new Map<string, Content[]>();
for (const content of allContents) {

...

  const group = contentsByLevel.get(content.levelId) ?? [];
  group.push(content);
  contentsByLevel.set(content.levelId, group);

...

}

const attemptsByContent = new Map<string, Attempt[]>();
for (const attempt of allAttempts) {
  const group = attemptsByContent.get(attempt.contentId) ?? [];

...

  group.push(attempt);
  attemptsByContent.set(attempt.contentId, group);
}

// 3. 루프에서는 Map만 참조 (DB 쿼리 0)
for (const level of levels) {
  const contents = contentsByLevel.get(level.id) ?? [];

...

  for (const content of contents) {
    const attempts = attemptsByContent.get(content.id) ?? [];
    // 집계 로직...

...

  }
}

650개 쿼리가 2개로 줄었다. 응답 시간도 200ms로 복귀 🚀

팁: Map 생성 비용은 무시해도 된다. 수천 건 데이터를 JS 메모리에서 순회하는 건 마이크로초 단위다. DB 쿼리 1번(수 ms~수십 ms)과 비교 자체가 안 된다.

실제 적용 사례

실무에서 이 패턴을 적용한 케이스를 보면 숫자 차이가 극명하다. 유저 목록(500명) + 각 유저의 최근 주문 조회 API를 최적화했을 때, 기존 501쿼리 / 평균 응답 2,800ms에서 2쿼리 / 평균 응답 95ms로 떨어졌다. 응답 시간 97% 단축이다.


🛠️ 해결 2: groupBy로 집계 쿼리 통합

통계나 집계가 목적이라면 groupBy가 더 깔끔하다. 사전 조회 + Map 패턴은 개별 레코드가 필요할 때 쓰고, 집계가 목적이면 DB에 맡기는 게 맞다.

JS에서 수천 건 reduce를 돌리는 것보다 DB 집계 쿼리 한 방이 훨씬 빠르다. DB는 집계에 최적화된 인덱스와 실행 계획을 갖고 있다.

❌ Before — 콘텐츠마다 개별 조회 후 JS 집계

for (const content of contents) {
  const attempts = await prisma.attempt.findMany({
    where: { contentId: content.id }

...

  });
  const total = attempts.length;
  const avg = attempts.reduce((s,

...

a) => s + a.score, 0) / total;
  // ...
}

✅ After — groupBy 1쿼리로 집계

// ✅ DB가 집계를 해주니 JS reduce 불필요
// PostgreSQL의 GROUP BY + AVG/COUNT는 인덱스 스캔으로 최적화됨
const stats = await prisma.attempt.groupBy({
  by: ['contentId'],
  where: { contentId: { in: contents.map(c => c.id) } },

...

  _count: { id: true },
  _avg: { score: true },
  _max: { score: true },

...

});

// Map으로 변환해서 O(1) 접근
const statsByContent = new Map(stats.map(s => [s.contentId, s]));

for (const content of contents) {
  const stat = statsByContent.get(content.id);
  const total = stat?._count.id ?? 0;

...

  const avg = stat?._avg.score ?? 0;
  // ...
}

DB가 집계를 해주니 JS에서 reduce를 돌릴 필요도 없고, 전송되는 데이터양도 확 줄어든다.

팁: groupBy_count, _avg, _sum, _min, _max를 동시에 지원한다. 여러 집계가 필요할 때 쿼리 여러 번 날릴 필요 없이 한 방에 처리할 수 있어요.


⚡ 해결 3: Prisma의 include/select 활용

관계가 Prisma 스키마에 정의돼 있다면 include로 한 방에 가져올 수도 있다. Prisma가 내부적으로 JOIN을 처리해준다.

⚠️ include — 가능하지만 주의 필요

// ⚠️ include는 편하지만, 중첩이 깊어지면 메모리 폭발 위험
// Prisma는 JOIN이 아니라 별도 SELECT 후 앱에서 조인하는 방식
const levels = await prisma.level.findMany({
  include: {
    contents: {

...

      include: {
        attempts: true  // 주의: 데이터 많으면 메모리 폭발
      }

...

    }
  }
});

이건 데이터가 적을 때만 유효하다. 시도 기록이 수만 건이면 include로 전체 로드했다가 메모리가 터질 수 있다.

Prisma include는 JOIN이 아니라 별도 SELECT 후 메모리 조인 방식으로 동작하는 경우가 많다. 공식 문서에서 “Prisma Client does not join tables in the database” 라고 명시하고 있다. 즉, 데이터가 많을수록 메모리 부담이 그대로 앱 서버에 쌓인다.

✅ 권장: select로 필요한 필드만 골라서

// ✅ select로 필요한 컬럼만 — 네트워크 전송량과 메모리 모두 절약
// _count는 실제 레코드를 가져오지 않고 숫자만 반환
const levels = await prisma.level.findMany({
  select: {
    id: true,

...

    name: true,
    contents: {
      select: {

...

        id: true,
        title: true,
        _count: {

...

          select: { attempts: true }  // 시도 건수만 숫자로
        }
      }

...

    }
  }
});

include는 관계된 레코드의 모든 컬럼을 가져오지만, select는 필요한 것만 골라서 네트워크 전송량과 메모리 사용량 모두 줄어든다.

핵심: 집계가 목적이면 _countselect 안에서 쓰는 게 최선이다. 시도 기록 전체를 가져와서 JS에서 세는 건 비효율적이에요.

include vs select vs groupBy 비교

방식쿼리 수메모리 사용적합한 상황
include (중첩)2–3회높음소량 관계 데이터 전체 필요 시
select + _count1–2회낮음특정 필드 + 카운트만 필요 시
groupBy1회매우 낮음순수 집계/통계 목적
사전 조회 + Map2–3회중간관계 없는 엔티티 간 조회

🔎 예방: N+1 탐지 방법

🔎 예방: N+1 탐지 방법 예방 체크리스트를 만들고 뿌듯한 표정
🔎 예방: N+1 탐지 방법 예방 체크리스트를 만들고 뿌듯한 표정

코드 리뷰에서 미리 잡는 게 가장 좋다. 패턴을 알면 눈에 바로 보인다.

탐지 체크리스트

N+1이 의심될 때 체크할 항목들이다.

  • for / map / forEach 루프 안에 await prisma. 가 있는가?
  • 있으면 무조건 사전 조회 + Map 패턴으로 교체한다
  • 통계·집계 API라면 groupBy 먼저 검토
  • 관계 데이터 조회라면 include + select 조합 고려
  • 응답 시간 1초 초과 시 Prisma 쿼리 로그 즉시 활성화

❌ 이런 코드가 보이면 N+1이다

// 패턴 1: for 루프 안 쿼리 — 가장 전형적인 형태
for (const item of items) {
  const data = await prisma.something.findMany({

...

    where: { itemId: item.id }
  });
}

// 패턴 2: map + Promise.all — 겉보기만 병렬, 쿼리 수는 동일
// 동시 실행이라 속도는 조금 빠르지만 커넥션 풀 부담은 그대로
const results = await Promise.all(

...

  items.map(item =>
    prisma.something.findMany({ where: { itemId: item.id } })
  )

...

);

주의: Promise.all로 감싸면 병렬 실행이라 빨라 보이지만, DB 입장에서는 쿼리가 N개 동시에 날아오는 거다. 커넥션 풀을 순식간에 잡아먹는다. N+1을 해결한 게 아니에요.

✅ Prisma 쿼리 로그로 현장 탐지

// prisma.ts — 개발 환경에서만 쿼리 로그 활성화
// 프로덕션에서 전체 로그를 켜면 그 자체가 성능 이슈
export const prisma = new PrismaClient({
  log: process.env.NODE_ENV === 'development'

...

    ? ['query', 'info', 'warn', 'error']
    : ['warn', 'error'],
});

터미널에 쿼리가 수십 줄 찍히면 N+1 확정이다. 쿼리 로그를 켜고 API 한 번 호출해보면 바로 보인다.

팁: Prisma Studio나 Prisma Pulse를 쓰면 쿼리를 시각적으로 추적할 수 있다. 로그 파싱이 익숙하지 않을 때 편해요.

NestJS에서 전역 쿼리 카운터 붙이기

쿼리 수를 숫자로 추적하고 싶으면 Prisma 미들웨어를 활용하면 된다.

// prisma.service.ts (NestJS)
// 요청당 쿼리 카운트가 100을 넘으면 즉시 리팩토링 대상
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {

...

  async onModuleInit() {
    await this.$connect();

    if (process.env.NODE_ENV === 'development') {
      let queryCount = 0;

      this.$use(async (params, next) => {
        queryCount++;
        const before = Date.now();

...

        const result = await next(params);
        const after = Date.now();

        console.log(
          `[Prisma] Query #${queryCount}: ${params.model}.${params.action} — ${after - before}ms`
        );

        return result;
      });
    }

...

  }
}

API 요청 하나에 쿼리 카운터가 100을 넘는 순간, 바로 리팩토링 대상이다.


✅ 정리

상황❌ 안티패턴✅ 권장 패턴
루프 내 조회for 안에서 findMany사전 조회 + Map
집계/통계JS reduce 반복groupBy 1쿼리
관계 데이터 (소량)루프 + 개별 조회include / select
관계 데이터 (대량)include 전체 로드groupBy + Map
병렬 처리 착각Promise.all + 개별 쿼리사전 조회 후 Map 참조
탐지체감으로 “느린데…”쿼리 로그 + 코드 리뷰

N+1은 알면 쉽고, 모르면 며칠 삽질한다. Prisma는 JPA처럼 자동 해결이 없으니 루프 안에 await prisma가 보이면 바로 의심하는 습관으로 잡아야 한다 ✨