데이터베이스 쿼리 최적화: N+1 문제 vs 배치 로딩 vs GraphQL 데이터로더, AI 코딩으로 만든 느린 쿼리 고치기
AI 코딩 도구가 만든 느린 쿼리의 N+1 문제를 진단하고, 배치 로딩과 GraphQL 데이터로더로 단계별 최적화하는 실전 가이드.
AI가 만든 쿼리가 느린 이유
Claude, GitHub Copilot 같은 AI 코딩 도구는 빠르고 편리하지만, 생성된 코드가 항상 효율적인 것은 아니다. 특히 데이터베이스 쿼리에서는 N+1 문제가 자주 발생한다. 이는 1개의 쿼리로 데이터를 가져온 후, 각 행마다 추가 쿼리를 반복 실행하는 패턴이다. 사용자 목록을 조회한 후 각 사용자의 주문 건수를 별도로 조회하는 식이다.
AI 모델은 기본적인 구조는 잘 생성하지만, 성능 최적화는 별도의 고민이 필요하다. 이 글에서는 느린 쿼리를 진단하고, 배치 로딩, 데이터로더 같은 실전 기법으로 단계별 최적화하는 방법을 보여준다.
N+1 문제 진단하기
먼저 문제를 인식해야 해결할 수 있다. Node.js + Express + Prisma 환경에서 사용자와 댓글을 조회하는 코드를 예로 들자.
// ❌ AI가 생성한 코드 (N+1 문제 있음)
const users = await prisma.user.findMany();
const result = [];
for (const user of users) {
const comments = await prisma.comment.findMany({
where: { userId: user.id }
});
result.push({ ...user, comments });
}
사용자가 100명이면, 1번의 사용자 조회 쿼리 + 100번의 댓글 조회 쿼리 = 총 101번 실행된다. 데이터베이스 왕복이 101번이므로 응답 시간이 선형으로 증가한다.
진단 방법:
- 데이터베이스 로그 활성화 (Prisma:
log: ['query']) - 브라우저 개발자 도구 Network 탭에서 API 응답 시간 확인
- 데이터베이스 클라이언트(DBeaver, TablePlus)에서 느린 쿼리 모니터링
방법 1: JOIN을 이용한 배치 로딩
가장 직관적인 해결책은 SQL JOIN을 한 번에 실행하는 것이다.
// ✅ 개선된 코드 (배치 로딩)
const usersWithComments = await prisma.user.findMany({
include: {
comments: true
}
});
Prisma의 include 옵션은 관계 데이터를 한꺼번에 가져와 자동으로 연결해준다. 쿼리 횟수가 사용자 수와 무관하게 소수로 고정된다 (Prisma 버전과 relationLoadStrategy 설정에 따라 JOIN 한 번 또는 테이블별 쿼리 한 번씩으로 실행된다).
SQL 레벨에서 보면:
SELECT u.*, c.* FROM users u
LEFT JOIN comments c ON u.id = c.user_id;
이 방법은 대부분의 경우 충분히 빠르지만, 관계가 깊어지거나 필터링이 복잡하면 데이터 중복이 발생할 수 있다 (예: 한 사용자가 댓글 100개면, 사용자 정보가 100번 반복).
방법 2: 데이터로더 (GraphQL 환경)
GraphQL 서버에서는 DataLoader 패턴을 쓴다. 여러 개의 개별 요청을 모아서 한 번에 배치 처리한다.
const DataLoader = require('dataloader');
// 주의: 예시를 짧게 하려고 전역으로 만들었지만, 실제 서버에서는 요청마다 새로 만들어 GraphQL context에 담아야 한다
const commentLoader = new DataLoader(async (userIds) => {
const comments = await prisma.comment.findMany({
where: { userId: { in: userIds } }
});
// userIds 순서에 맞춰 그룹화
const commentsByUserId = {};
comments.forEach(c => {
if (!commentsByUserId[c.userId]) {
commentsByUserId[c.userId] = [];
}
commentsByUserId[c.userId].push(c);
});
return userIds.map(userId => commentsByUserId[userId] || []);
});
// GraphQL 리졸버에서
const resolvers = {
User: {
comments: (user) => commentLoader.load(user.id)
}
};
DataLoader는 요청을 즉시 실행하지 않고 마이크로태스크 큐에 모은 후, 다음 이벤트 루프 틱에서 한 번에 배치 처리한다. 결과적으로 100명의 사용자를 조회할 때 댓글도 1번의 쿼리로 모두 가져온다.
장점:
- N+1 문제 자동 해결
- GraphQL 리졸버 로직이 깔끔함
- 캐싱 기능 내장
단점:
- GraphQL 환경에서만 효과적
- 요청 컨텍스트마다 새로운 DataLoader 인스턴스 필요
방법 3: 수동 배치 로딩 (REST API)
REST API에서는 보통 필요한 ID를 한곳에서 모아 in 쿼리 한 번으로 직접 가져온다.
// 배치 로딩 헬퍼
async function batchLoadComments(userIds) {
const comments = await prisma.comment.findMany({
where: { userId: { in: userIds } }
});
const map = new Map();
userIds.forEach(id => map.set(id, []));
comments.forEach(c => {
map.get(c.userId).push(c);
});
return userIds.map(id => map.get(id));
}
// 컨트롤러
app.get('/users', async (req, res) => {
const users = await prisma.user.findMany();
const userIds = users.map(u => u.id);
const commentsByUser = await batchLoadComments(userIds);
const result = users.map((user, i) => ({
...user,
comments: commentsByUser[i]
}));
res.json(result);
});
이 방식은 REST API에서도 N+1 문제를 해결하지만, 수동으로 배치 로직을 작성해야 하므로 코드가 길어진다.
성능 비교와 선택 기준
| 방법 | 쿼리 횟수 | 메모리 | 코드 복잡도 | 추천 상황 |
|---|---|---|---|---|
| 배치 로딩 (JOIN) | 1~2 | 중간 | 낮음 | 단순 관계, 대부분의 경우 |
| DataLoader | 1~2 | 낮음 | 중간 | GraphQL, 깊은 중첩 쿼리 |
| 수동 배치 | 1~2 | 낮음 | 높음 | REST API, 복잡한 필터링 |
선택 기준:
- GraphQL: DataLoader 권장
- REST API + 단순 관계:
include또는join사용 - REST API + 복잡한 필터링: 수동 배치 로딩
- 매우 큰 데이터셋: 페이지네이션 + 배치 로딩 조합
최적화 후 검증하기
최적화 후에는 반드시 개선 효과를 측정해야 한다.
// Prisma 클라이언트를 만들 때 쿼리 로그를 켜 둔다
const prisma = new PrismaClient({
log: ['query', 'info']
});
// 성능 측정
console.time('query');
const result = await prisma.user.findMany({
include: { comments: true }
});
console.timeEnd('query');
API 응답 시간과 쿼리 횟수가 눈에 띄게 줄었다면 성공이다. 또한 데이터베이스 연결 풀이 고갈되지 않는지 확인하자. N+1 문제는 동시 요청 시 연결 풀을 빠르게 소진한다.
초보자도 AI 생성 코드의 성능 문제를 찾을 수 있나요?
네, 데이터베이스 로그를 켜면 쿼리 수를 바로 볼 수 있다. Prisma 로그(log: ['query'])를 활성화하고 API 요청을 보내면, 콘솔에 모든 SQL이 출력된다. 예상보다 많은 쿼리가 나타나면 N+1 문제를 의심하자.
데이터로더를 REST API에서도 쓸 수 있나요?
기술적으로는 가능하지만, REST 핸들러에서는 필요한 ID를 한곳에서 모아 in 쿼리 한 번으로 처리할 수 있어 DataLoader의 이점이 줄어든다. REST API에서는 배치 로딩이나 JOIN이 더 간단하다. DataLoader는 GraphQL의 요청 컨텍스트 개념과 잘 맞아서 GraphQL에서 진가를 발휘한다.
페이지네이션과 배치 로딩을 함께 쓸 수 있나요?
당연히 가능하다. 오히려 권장한다. 한 페이지(예: 20개 항목)만 조회한 후 배치 로딩을 적용하면, 메모리 사용량을 줄이면서도 N+1 문제를 해결한다. take, skip 옵션으로 페이지네이션하고, include로 관계를 한 번에 로드하면 된다.
다음 단계
AI 코딩 도구는 빠르고 편리하지만, 생성된 코드의 성능은 항상 검증해야 한다. 특히 데이터베이스 쿼리는 작은 최적화가 큰 성능 차이를 만든다. 위의 방법들을 상황에 맞춰 적용하면, AI가 생성한 느린 쿼리도 빠르게 개선할 수 있다.
만약 팀원들과 최적화 기법을 공유하거나, 실제 프로젝트에서 적용한 사례를 보여주고 싶다면 Vibeollio에 프로젝트를 등록해보자. 다른 개발자들이 바로 실행해보고 피드백을 남길 수 있어, 더 나은 솔루션을 함께 만들어갈 수 있다.
