본문으로 건너뛰기
GraphQL N+1 쿼리 문제 해결 - 성능 최적화 가이드
Table of Contents

GraphQL N+1 쿼리 문제는 프로덕션에서 가장 골치 아픈 성능 이슈 중 하나입니다. 개발할 때는 잘 모르고 지나가다가, 서비스가 커지면서 API가 버티지 못하고 무너지는 경우가 많죠. 이 글에서는 N+1 문제가 뭔지부터 시작해서, 왜 생기는지, 그리고 실전에서 어떻게 해결하는지까지 모두 알려드립니다.

N+1 쿼리 문제란 무엇인가?

N+1 문제는 간단합니다. N개 항목을 조회하는 쿼리 1번을 실행한 뒤, 각 항목마다 추가 데이터를 가져오려고 N번 더 쿼리를 날리는 현상입니다. 1~2번의 쿼리로 충분한 일을 N+1번이나 반복하게 되어 DB 부하를 일으킵니다.

실제 사례

다음 쿼리를 볼까요? 겉보기엔 아무 문제 없어 보입니다.

query GetUsersWithPosts {
  users {
    id
    name
    posts {
      title
      content
    }
  }
}

하지만 기본 resolver로 구현하면 이런 일이 벌어집니다.

// 단계 1: 모든 사용자 조회 (1개 쿼리)
SELECT * FROM users;
// 100명의 사용자 반환

// 단계 2: 각 사용자마다 게시글 조회 (100개 쿼리!)
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
SELECT * FROM posts WHERE user_id = 3;
// ... 97개 쿼리 더
SELECT * FROM posts WHERE user_id = 100;

총 쿼리 수: 101개 (1 + 100)

사용자가 만 명이면? 쿼리가 무려 10,001번 날아갑니다. 이런 구조로는 도저히 스케일이 안 나옵니다.

왜 GraphQL에서 더 심각한가?

REST API는 엔드포인트별로 최적화된 쿼리를 작성하기 쉽지만, GraphQL은 유연한 만큼 함정도 많습니다.

  1. 필드별 Resolver: 필드 하나하나마다 resolver 함수가 따로 돌아갑니다.
  2. 클라이언트 주도: 클라이언트가 원하는 대로 중첩된 필드를 요청할 수 있습니다.
  3. 중첩 구조: 연관 데이터를 타고 들어가기 쉬워서 쿼리가 기하급수적으로 늘어날 수 있습니다.
// 전통적인 resolver - 문제가 되는 부분
const resolvers = {
  Query: {
    users: async () => {
      return await db.users.findAll()
    }
  },
  User: {
    // 이 resolver가 각 사용자마다 한 번씩 실행됩니다!
    posts: async (user) => {
      return await db.posts.findMany({ userId: user.id })
    }
  }
}

개발할 때 테스트 유저 5~10명으로는 아무 문제 없습니다. 그런데 실서비스에 사용자가 만 명만 넘어가도? DB가 바로 뻗어버리죠.

실제 서비스에서 얼마나 심각한가?

우리 서비스에 N+1 문제가 터졌을 때 실측한 수치를 보여드리겠습니다.

최적화 전

  • 요청 시간: 평균 3,200ms
  • 요청당 데이터베이스 쿼리 수: 847개
  • P95 지연 시간: 8,500ms
  • 데이터베이스 CPU: 평균 78%
  • 실패한 요청: 2.3% (타임아웃 오류)

DataLoader 구현 후

  • 요청 시간: 평균 124ms (96% 개선)
  • 요청당 데이터베이스 쿼리 수: 4개 (99.5% 감소)
  • P95 지연 시간: 280ms (97% 개선)
  • 데이터베이스 CPU: 평균 12%
  • 실패한 요청: 0.02%

이게 바로 대규모 서비스가 살아남느냐 죽느냐를 가르는 차이입니다.

해결책 1: DataLoader - 정석 해법

Facebook이 만든 DataLoader는 GraphQL 서버에서 배칭과 캐싱을 할 때 사실상 표준으로 쓰입니다.

DataLoader 작동 원리

DataLoader는 요청 하나가 처리되는 동안 여러 건의 로드 작업을 모아뒀다가, 한 방에 배치로 처리합니다.

const DataLoader = require('dataloader')

// 배치 로딩 함수 생성
const batchLoadPosts = async (userIds) => {
  console.log('Batching posts for users:', userIds)
  // 모든 사용자를 위한 단일 쿼리!
  const posts = await db.posts.findMany({
    where: {
      userId: { in: userIds }
    }
  })

  // userId별로 게시글 그룹화
  const postsByUserId = {}
  posts.forEach(post => {
    if (!postsByUserId[post.userId]) {
      postsByUserId[post.userId] = []
    }
    postsByUserId[post.userId].push(post)
  })

  // userIds와 동일한 순서로 게시글 반환 (중요!)
  return userIds.map(userId => postsByUserId[userId] || [])
}

// DataLoader 인스턴스 생성
const postsLoader = new DataLoader(batchLoadPosts)

// 업데이트된 resolver
const resolvers = {
  Query: {
    users: async () => {
      return await db.users.findAll()
    }
  },
  User: {
    posts: async (user, args, context) => {
      // DataLoader 사용 - 자동으로 배칭됩니다!
      return context.loaders.postsLoader.load(user.id)
    }
  }
}

Apollo Server에서 DataLoader 설정하기

const { ApolloServer } = require('@apollo/server')
const DataLoader = require('dataloader')

const server = new ApolloServer({
  typeDefs,
  resolvers,
})

// 요청마다 새로운 loader 생성
app.use('/graphql', async (req, res) => {
  const context = {
    loaders: {
      postsLoader: new DataLoader(batchLoadPosts),
      commentsLoader: new DataLoader(batchLoadComments),
      authorsLoader: new DataLoader(batchLoadAuthors),
    },
    req,
    res,
  }

  // Context와 함께 GraphQL 요청 처리
  await server.executeOperation({ query: req.body.query }, { contextValue: context })
})

DataLoader 핵심 원칙

  1. 요청마다 새로 만들기: DataLoader 인스턴스는 절대 요청 간에 공유하면 안 됩니다 (캐시 오염 방지).
  2. 순서 지키기: 배치 함수는 반드시 입력된 키의 순서대로 결과를 반환해야 합니다.
  3. 없는 데이터 처리: 데이터가 없으면 null이나 빈 배열을 리턴해야 합니다.
  4. Mutation 후 캐시 비우기: 데이터 변경 후엔 loader 캐시를 초기화해야 합니다.

해결책 2: 쿼리 자체를 개선하기

배칭보다 SQL을 잘 짜는 게 답일 때도 있습니다.

관계형 데이터에 SQL JOIN 사용하기

const resolvers = {
  Query: {
    users: async () => {
      // JOIN을 사용한 단일 쿼리
      const usersWithPosts = await db.query(`
        SELECT
          u.id as user_id,
          u.name,
          u.email,
          p.id as post_id,
          p.title,
          p.content
        FROM users u
        LEFT JOIN posts p ON u.id = p.user_id
      `)

      // 중첩 구조로 변환
      return transformToNested(usersWithPosts)
    }
  }
}

쿼리 성능 비교

-- N+1 문제 (100명의 사용자에 대해 101개 쿼리)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
-- ... 98개 쿼리 더

-- DataLoader 사용 (2개 쿼리)
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ..., 100);

-- JOIN 사용 (1개 쿼리)
SELECT u.*, p.*
FROM users u
LEFT JOIN posts p ON u.id = p.user_id;

해결책 3: 애초에 안 생기게 스키마 짜기

가장 좋은 해결책은 문제가 생기지 않게 만드는 것입니다. 스키마를 설계할 때부터 N+1이 안 생기게 만드세요.

총 개수 필드 추가

클라이언트가 배열을 불러와서 길이를 세게 하지 말고, 카운트 필드를 스키마에 추가하세요.

type User {
  id: ID!
  name: String!
  posts: [Post!]!
  # 이것을 추가하세요!
  postsCount: Int!
}
User: {
  postsCount: async (user, args, context) => {
    // 효율적인 COUNT 쿼리, 배칭 가능
    return context.loaders.postsCountLoader.load(user.id)
  }
}

프로덕션 환경에서 N+1 문제 감지하기

1. Apollo Studio 통합

Apollo Studio는 N+1 패턴을 자동으로 감지하고 리포팅해줍니다.

2. 커스텀 로깅 플러그인

쿼리 개수가 일정 수준을 넘으면 경고를 보내는 플러그인을 만드세요.

if (queries.length > 50) {
  await sendAlert({
    type: 'N+1_DETECTED',
    operation: requestContext.operationName,
    count: queries.length
  })
}

3. 데이터베이스 쿼리 로깅

Slow Query 로그를 활성화하여 시간이 오래 걸리는 쿼리를 잡으세요.

정리하며

GraphQL N+1 문제는 충분히 예측하고 예방하고 해결할 수 있습니다. 핵심만 정리하면:

  1. 연관 데이터 불러올 땐 무조건 DataLoader
  2. Loader는 매 요청마다 새로 생성, 절대 공유 금지
  3. 실전 수준 데이터로 테스트 (최소 100개 이상)
  4. 쿼리 개수 모니터링하고 알람 설정

효과는 확실합니다. 우리는 8.5초 → 0.28초로 줄였습니다. 이게 느린 API와 빠른 API의 차이고, 불평하는 유저와 만족하는 유저의 차이입니다.

이 글 공유하기:
My avatar

글을 마치며

이 글이 도움이 되었기를 바랍니다. 궁금한 점이나 의견이 있다면 댓글로 남겨주세요.

더 많은 기술 인사이트와 개발 경험을 공유하고 있으니, 다른 포스트도 확인해보세요.

유럽살며 여행하며 코딩하는 노마드의 여정을 함께 나누며, 함께 성장하는 개발자 커뮤니티를 만들어가요! 🚀


관련 포스트

# OpenTelemetry 분산 추적 완벽 가이드: 프로덕션 장애를 3배 빠르게 해결하는 방법

게시:

마이크로서비스 환경에서 발생하는 간헐적 지연과 장애를 빠르게 해결하는 분산 추적(Distributed Tracing) 전략을 다룹니다. OpenTelemetry + Jaeger를 활용한 실시간 트레이싱, 2025년 Google Cloud Telemetry API 통합, ML 기반 지능형 샘플링, 그리고 프로덕션 환경에서 검증된 관측성(Observability) 구축 방법까지 모두 포함합니다.

읽기

# Connection Pool 고갈 완벽 디버깅 가이드: The Silent Killer 잡기

게시:

프로덕션 환경에서 발생하는 데이터베이스 Connection Pool 고갈 문제를 완벽하게 해결하는 실전 가이드입니다. HikariCP, Sequelize, ADO.NET 등 주요 프레임워크별 디버깅 전략, 연결 누수 탐지 방법, 그리고 2025년 최신 모니터링 및 예방 기법까지 모두 다룹니다.

읽기