GraphQL을 프로덕션에 도입하고 2주 만에 DB CPU가 100%를 찍었습니다. 원인은 단 하나의 '순진한' 프론트엔드 요청이었습니다. "유저 목록과 그들의 최신 게시글을 보여주세요." 이 간단한 요구사항이 내부적으로는 수천 번의 쿼리를 유발하고 있었습니다. 이 글은 GraphQL N+1 문제를 해결하고, 무거운 쿼리로부터 서버를 보호하는 쿼리 복잡도 제한 설정을 다룹니다.
N+1 문제의 해부와 DataLoader 패턴
REST API와 달리 GraphQL은 각 필드가 독립적인 리졸버(Resolver) 함수에 의해 처리됩니다. 이는 유연성을 주지만, 백엔드 성능에는 치명적인 함정이 될 수 있습니다. 예를 들어, 100명의 유저를 조회(1번)하고 각 유저의 게시글을 조회(100번)하면 총 101번의 DB 쿼리가 발생합니다. 데이터셋이 커질수록 성능은 기하급수적으로 저하됩니다.
실전 해결책: DataLoader 적용 코드
Facebook(Meta)에서 개발한 DataLoader 라이브러리는 배칭(Batching)을 통해 이 문제를 해결합니다. 이벤트 루프의 한 틱(Tick) 동안 발생하는 모든 개별 ID 요청을 모아 단 한 번의 DB 쿼리(`WHERE IN (...)`)로 처리합니다. 다음은 Node.js 환경에서 API 최적화를 위해 작성한 실제 코드입니다.
// 1. DataLoader 인스턴스 생성 (Context 내에서 요청당 하나씩 생성 권장)
const DataLoader = require('dataloader');
const { User, Post } = require('./models'); // ORM 예시
// Batch Function: ID 배열을 받아 데이터 배열을 반환해야 함 (순서 보장 필수)
const batchPostsByUserIds = async (userIds) => {
// 실제 실행되는 쿼리: SELECT * FROM posts WHERE user_id IN (1, 2, 3...)
const posts = await Post.findAll({
where: {
userId: { [Op.in]: userIds }
}
});
// DataLoader 규칙: 입력된 key(userIds)의 순서와 결과 배열의 순서가 정확히 일치해야 함
const postsMap = {};
posts.forEach(post => {
if (!postsMap[post.userId]) postsMap[post.userId] = [];
postsMap[post.userId].push(post);
});
return userIds.map(id => postsMap[id] || []);
};
const postLoader = new DataLoader(batchPostsByUserIds);
// 2. 리졸버(Resolver)에서 사용
const resolvers = {
User: {
posts: (parent, args, context) => {
// 개별 쿼리 대신 loader.load() 호출
// N번 호출되어도 내부적으로는 1번의 쿼리로 배칭됨
return postLoader.load(parent.id);
}
}
};
악성 쿼리 방어: 쿼리 복잡도(Query Complexity) 제한
N+1 문제가 해결되어도, 중첩된 깊은 쿼리(Deeply Nested Query) 공격은 여전히 서버를 위협합니다. 예를 들어 `author { posts { author { posts ... } } }`와 같은 재귀적 요청은 서버 메모리를 고갈시킵니다. 이를 막기 위해 쿼리 복잡도 분석을 도입해야 합니다.
`graphql-query-complexity` 라이브러리를 사용하여 스키마 기반의 비용(Cost) 산정을 자동화할 수 있습니다.
const { getComplexity, simpleEstimator } = require('graphql-query-complexity');
// Apollo Server 설정 예시
const server = new ApolloServer({
schema,
plugins: [{
requestDidStart: () => ({
didResolveOperation({ request, document }) {
const complexity = getComplexity({
schema,
operationName: request.operationName,
query: document,
variables: request.variables,
estimators: [
// 기본 필드는 1점, 리스트 등 무거운 필드는 가중치 부여
simpleEstimator({ defaultComplexity: 1 })
],
});
// 허용된 복잡도(예: 100)를 초과하면 에러 발생
if (complexity > 100) {
throw new Error(`Query is too complex: ${complexity}. Maximum allowed is 100`);
}
console.log(`Used query complexity: ${complexity}`);
},
}),
}],
});
| 최적화 항목 | 적용 전 (Before) | 적용 후 (After) |
|---|---|---|
| DB 요청 수 (1000명 유저) | 1001회 (N+1) | 2회 (Batching) |
| 응답 속도 (Latency) | 2500ms+ | 120ms |
| 보안 (DoS 방어) | 취약 (무한 깊이 허용) | 안전 (복잡도 100 제한) |
Conclusion
GraphQL은 강력하지만, 책임감 있는 설계가 필요합니다. DataLoader 패턴은 선택이 아닌 필수이며, 쿼리 복잡도 제한은 프로덕션 레벨의 안정성을 보장하는 안전벨트입니다. 지금 당장 서버 로그를 확인해보십시오. 의미 없는 중복 쿼리가 DB를 괴롭히고 있다면, 위 코드를 즉시 적용해야 합니다.
Post a Comment