RSS듀오랩스
데이터베이스 설계

N+1 쿼리 문제와 해결 방법

작성자
듀오랩스 대표·8분 읽기

거래처 목록 화면에 거래처별 미수금을 한 칸 더 보여 주기로 했다고 해 보겠습니다. 코드는 자연스럽게 이렇게 나옵니다.

const partners = await db.partner.findMany({ where: { companyId } });
for (const p of partners) {
  const due = await db.voucher.aggregate({ where: { partnerId: p.id, settled: false }, _sum: { total: true } });
  p.due = due._sum.total ?? 0;
}

거래처가 열 곳인 개발 환경에서는 즉시 뜹니다. 거래처가 800 곳인 회사에서는 이 화면 한 번에 쿼리가 801 번 나갑니다.

ORM 이 알아서 최적화한다는 생각

ORM 을 쓰면 SQL 을 직접 쓰지 않으니, 데이터를 어떻게 가져올지도 ORM 이 알아서 효율적으로 정해 줄 거라고 기대하기 쉽습니다. 코드에는 쿼리가 두 줄뿐이라 느릴 이유도 보이지 않습니다.

ORM 은 받은 호출을 그대로 쿼리로 바꿉니다. 반복문 안의 aggregate 한 줄은 반복 횟수만큼 실행되고, ORM 은 그 호출들이 같은 목록에서 나왔다는 것을 알지 못합니다. 목록 한 번(1)과 행마다 한 번(N)이라서 N+1 문제라고 부릅니다. Prisma 문서의 정의도 같습니다.

The n+1 problem occurs when looping through query results and performing one additional query per result.

쿼리 하나는 빠른데 화면이 느린 이유

N+1 은 쿼리 하나하나를 보면 문제가 없어서 잘 보이지 않습니다. 인덱스가 있으면 거래처 한 곳의 미수금 합계는 금방 나옵니다. 느린 것은 쿼리가 아니라 왕복입니다.

쿼리 한 번은 앱 서버에서 데이터베이스로 요청을 보내고 결과를 받는 네트워크 왕복을 포함합니다. 같은 기계 안이면 짧고, 앱과 데이터베이스가 다른 리전에 있으면 왕복 하나가 길어집니다. 800 번의 왕복은 쿼리 실행 시간과 상관없이 왕복 시간의 800 배입니다. 개발 환경에서 안 보이던 느림이 운영에서 드러나는 이유가 여기 있습니다. 개발 환경은 행이 적고 데이터베이스가 가깝습니다.

데이터베이스 연결도 문제입니다. 요청 하나가 800 번의 쿼리를 차례로 보내는 동안 연결 하나를 오래 붙잡습니다. 여러 사용자가 동시에 이 화면을 열면 연결 풀이 빠르게 찹니다.

관계를 한 번에 불러오기

행마다 연관 데이터를 가져오는 경우라면, ORM 에 관계를 한 번에 불러오라고 알려 주면 됩니다.

const orders = await db.salesOrder.findMany({
  where: { companyId },
  include: { partner: true },
});

include 를 쓰면 수주마다 거래처를 따로 조회하지 않습니다. Prisma 는 이것을 데이터베이스 조인 한 번이나 테이블당 쿼리 한 번으로 처리합니다. 어느 방식을 쓸지는 relationLoadStrategy 옵션으로 고를 수 있습니다. 어느 쪽이든 쿼리 수가 수주 수에 비례하지 않습니다.

집계는 GROUP BY 로 한 번에

처음의 미수금은 연관 행이 아니라 합계라서 include 로 풀리지 않습니다. 거래처마다 합계를 따로 구하지 말고, 거래처별로 묶은 합계를 한 번에 받습니다.

const dues = await db.voucher.groupBy({
  by: ["partnerId"],
  where: { companyId, settled: false },
  _sum: { total: true },
});
const dueOf = new Map(dues.map((d) => [d.partnerId, d._sum.total ?? 0]));
const partners = await db.partner.findMany({ where: { companyId } });
for (const p of partners) p.due = dueOf.get(p.id) ?? 0;

쿼리는 두 번입니다. 거래처가 800 곳이든 8,000 곳이든 두 번입니다. SQL 로는 GROUP BY "partnerId" 한 문장이고, 합계를 구하는 일은 데이터베이스가 한 번에 합니다.

이 모양을 기억해 두면 N+1 을 고치는 대부분의 경우에 쓸 수 있습니다. 반복문 안의 조회를 반복문 밖으로 꺼내 한 번에 받고, 메모리의 맵으로 짝을 맞춘다. 반복문 안에는 조회가 아니라 맵 조회만 남습니다.

쓰기에도 있는 N+1

같은 문제가 저장에서도 생깁니다. 데이터를 이관하거나 대량으로 고칠 때, 행마다 찾고 행마다 고치는 코드가 흔히 나옵니다.

for (const v of vouchers) {
  const sh = await db.shipment.findFirst({ where: { no: v.refNo } });
  if (sh) await db.voucher.update({ where: { id: v.id }, data: { shipmentId: sh.id } });
}

전표가 700 건이면 조회 700 번, 저장 700 번입니다. 한 번 돌리고 끝나는 이관 스크립트라면 이 정도는 감수할 만하다고 봅니다. 읽기 쉽고, 틀렸을 때 어느 행에서 틀렸는지 찾기 쉽습니다. 사용자가 누르는 버튼 뒤에 이 모양이 들어가면 이야기가 다릅니다. 그때는 출하를 한 번에 조회해 맵으로 만들고, 가능하면 SQL 한 문장의 UPDATE ... FROM 으로 묶습니다.

찾는 방법은 쿼리 수를 세는 것

N+1 은 코드 리뷰로 찾기 어렵습니다. 반복문 안의 호출이 여러 함수를 건너 숨어 있는 경우가 많기 때문입니다. 목록의 행마다 부르는 표시 함수 안에 조회가 들어 있으면, 호출하는 쪽에서는 반복문 안에 조회가 있는지 보이지 않습니다.

그래서 저는 느린지를 보기 전에 화면 하나가 쿼리를 몇 번 보내는지를 먼저 세는 편이 맞다고 봅니다. ORM 의 쿼리 로그를 켜고 화면을 한 번 열어 보면 됩니다. 같은 모양의 쿼리가 목록 행 수만큼 반복되면 그것이 N+1 입니다. 개발 환경의 데이터가 적어도 반복되는 모양은 보이기 때문에, 운영에서 느려지기 전에 찾을 수 있습니다.

자동 묶음에 기대지 않는 이유

Prisma 문서에는 같은 틱에 호출된 findUnique 를 자동으로 묶어 주는 기능이 소개돼 있습니다. GraphQL 처럼 필드마다 따로 해석되는 구조에서 특히 쓸모 있다고 설명합니다.

이 기능은 조건이 맞을 때만 동작합니다. findUnique 여야 하고, 같은 틱에 호출돼야 합니다. 앞의 예처럼 for 반복문 안에서 await 로 하나씩 기다리면 호출이 서로 다른 틱에 흩어집니다. findFirstaggregate 도 해당하지 않습니다. 그래서 저는 이 기능을 N+1 의 해법이 아니라, 구조상 어쩔 수 없는 곳에서 덤으로 얻는 최적화로 보는 편입니다. 목록 화면의 데이터는 처음부터 한 번에 받는 모양으로 짜는 것이 확실합니다.

여기까지가 확실한 부분

include, groupBy, 자동 묶음의 동작은 Prisma 문서 기준이고, 다른 ORM 은 이름과 기본 동작이 다릅니다. 왕복 시간은 앱과 데이터베이스의 거리와 연결 방식에 따라 크게 달라서, 이 글은 특정 밀리초를 제시하지 않았습니다. 실제 영향은 쿼리 로그로 횟수를 세고 운영에 가까운 환경에서 재 보는 것이 정확합니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.