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은 유연한 만큼 함정도 많습니다.
- 필드별 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)
// 업데이트된 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 핵심 원칙
- 요청마다 새로 만들기: 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 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 문제는 충분히 예측하고 예방하고 해결할 수 있습니다. 핵심만 정리하면:
- 연관 데이터 불러올 땐 무조건 DataLoader
- Loader는 매 요청마다 새로 생성, 절대 공유 금지
- 실전 수준 데이터로 테스트 (최소 100개 이상)
- 쿼리 개수 모니터링하고 알람 설정
효과는 확실합니다. 우리는 8.5초 → 0.28초로 줄였습니다. 이게 느린 API와 빠른 API의 차이고, 불평하는 유저와 만족하는 유저의 차이입니다.