← 블로그 목록
데이터베이스쿼리 최적화N+1 문제성능 튜닝디버깅

데이터베이스 쿼리 최적화: N+1 문제 vs 배치 로딩 vs GraphQL 데이터로더, AI 코딩으로 만든 느린 쿼리 고치기

AI 코딩 도구가 만든 느린 쿼리의 N+1 문제를 진단하고, 배치 로딩과 GraphQL 데이터로더로 단계별 최적화하는 실전 가이드.

Vibeollio 팀-

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에 프로젝트를 등록해보자. 다른 개발자들이 바로 실행해보고 피드백을 남길 수 있어, 더 나은 솔루션을 함께 만들어갈 수 있다.