GraphQL N+1 쿼리 문제 해결: 프로덕션 완벽 가이드
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은 유연한 만큼 함정도 많습니다.
- 필드별 Resolver: 필드 하나하나마다 resolver 함수가 따로 돌아갑니다.
- 클라이언트 주도: 클라이언트가 원하는 대로 중첩된 필드를 요청할 수 있습니다.
- 중첩 구조: 연관 데이터를 타고 들어가기 쉬워서 쿼리가 기하급수적으로 늘어날 수 있습니다.
// 전통적인 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)
// 업데이트된 resolverconst 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 핵심 원칙
- 요청마다 새로 만들기: DataLoader 인스턴스는 절대 요청 간에 공유하면 안 됩니다 (캐시 오염 방지).
- 순서 지키기: 배치 함수는 반드시 입력된 키의 순서대로 결과를 반환해야 합니다.
- 없는 데이터 처리: 데이터가 없으면
null이나 빈 배열을 리턴해야 합니다. - 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 uLEFT 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 문제는 충분히 예측하고 예방하고 해결할 수 있습니다. 핵심만 정리하면:
- 연관 데이터 불러올 땐 무조건 DataLoader
- Loader는 매 요청마다 새로 생성, 절대 공유 금지
- 실전 수준 데이터로 테스트 (최소 100개 이상)
- 쿼리 개수 모니터링하고 알람 설정
효과는 확실합니다. 우리는 8.5초 → 0.28초로 줄였습니다. 이게 느린 API와 빠른 API의 차이고, 불평하는 유저와 만족하는 유저의 차이입니다.