Prisma의 과도한 Join이 프로세스를 죽였을 때 쿼리를 분해한 방법

필요한 관계를 ORM의 include에 모두 넣어 한 번의 query로 완성된 응답을 만들었습니다. 코드 한 줄로 풍부한 객체 graph를 얻을 수 있었지만 여러 to-many 관계의 cardinality가 곱해지자 SQL 반환 행이 폭증했고, 이어서 객체 조립에 필요한 메모리도 함께 늘었습니다. 작은 개발 데이터에서는 드러나지 않던 이 문제가 운영 환경에서는 실제 panic으로 이어졌습니다.

To-many 관계가 곱해지며 메모리가 무너졌다

여러 one-to-many 관계를 한 쿼리에서 join하면서 결과 행 수가 각 관계의 개수만큼 곱해졌습니다. SQL 응답과 Prisma의 객체 조립 과정에서 메모리가 급증했고 작은 개발 데이터에서는 재현되지 않았습니다. include를 제거하면 호출부가 기대하는 응답 형태가 바뀌기 때문에 성능 문제를 고치면서 API 계약도 지켜야 했습니다.

Query 수보다 먼저 정하는 결과 집합 상한

문제의 핵심은 SQL 한 번이라는 숫자가 아니라 join 뒤 만들어지는 중간 행의 크기였습니다. root 개수에 여러 to-many 관계의 평균 cardinality를 곱해 최악의 반환 행을 추정했습니다. 이 값이 상한을 넘으면 관계를 나눠 조회하도록 기준을 코드 리뷰 체크에 넣었습니다.

  • 논리적 객체 수가 아니라 실제 SQL row 수와 전송 byte를 측정합니다.
  • cardinality가 큰 관계는 부모 조회와 별도 batch 조회로 분리합니다.
  • 분해 후에도 최종 DTO 형태와 정렬 규칙은 유지합니다.

Root 조회와 관계 Batch의 분리

Prisma query log와 PostgreSQL EXPLAIN으로 어떤 관계 조합이 행 폭증을 만드는지 확인했습니다. 먼저 부모 식별자와 작은 one-to-one 관계만 조회했습니다.

큰 자식 컬렉션은 부모 ID 목록으로 IN batch 조회하고 메모리에서 Map으로 그룹화했습니다. 필요한 컬럼만 select하고 각 관계의 정렬을 쿼리에서 명시했습니다.

부모 수가 클 때는 ID 목록도 chunk로 나누고 최종 DTO 조립 뒤 중간 배열 참조를 해제했습니다. 회귀 테스트는 기존 응답과 필드·순서를 비교했습니다.

응답 형태를 유지하며 조립한 코드

const roots = await prisma.project.findMany({
  where, select: { id: true, name: true }, take: 200, orderBy: { id: 'asc' }
});
const ids = roots.map(x => x.id);
const [models, datasets] = await Promise.all([
  prisma.model.findMany({ where: { projectId: { in: ids } }, select: modelFields }),
  prisma.dataset.findMany({ where: { projectId: { in: ids } }, select: datasetFields }),
]);
const modelsByProject = Map.groupBy(models, x => x.projectId);
return roots.map(root => ({
  ...root, models: modelsByProject.get(root.id) ?? [],
  datasets: datasetsByProject.get(root.id) ?? [],
}));

행 수와 Peak RSS를 함께 보는 회귀

쿼리 분해 뒤에는 실행 시간만 비교하지 않고 데이터베이스 반환 행과 Node.js peak RSS를 함께 측정했습니다. query 수는 늘었지만 각 결과 집합의 크기가 제한되면서 객체 조립 중의 메모리 급증이 사라졌습니다. 테스트 데이터도 작은 fixture 대신 관계 수가 실제 분포의 상단에 가까운 경우를 포함했습니다.

  • 여러 to-many relation의 Cartesian multiplication으로 전송 행이 폭증하는 문제.
  • Promise.all에 관계 쿼리를 무제한 넣어 DB pool을 고갈시키는 문제.
  • 분해한 쿼리 사이 데이터가 바뀌어 완전한 snapshot 일관성이 필요한 API에서 혼합 상태가 되는 문제.
  • 운영과 유사한 관계 cardinality의 fixture로 메모리와 시간을 측정합니다.
  • 쿼리 수 증가가 N+1로 변하지 않고 고정된 batch 수인지 확인합니다.
  • DB 시간뿐 아니라 Node.js RSS와 event loop delay도 함께 관찰합니다.
  • DB 실행 시간뿐 아니라 반환 행 수, payload byte, Node RSS와 event-loop lag를 기록합니다.
  • EXPLAIN ANALYZE로 각 IN query의 index 사용을 확인합니다.
  • 요청당 root 상한과 relation 상한을 API 계약에 넣고 초과는 pagination합니다.

안정성과 조립 비용의 교환

하나의 거대한 join은 몇 개의 예측 가능한 batch 쿼리로 바뀌었고 peak memory가 크게 줄었습니다. 쿼리 횟수는 늘었지만 각 쿼리의 결과 크기가 통제되어 전체 지연 시간과 프로세스 안정성이 개선됐습니다.

Batch 조회는 관계별 정렬과 누락 항목 조립을 애플리케이션이 책임져야 합니다. 작은 관계까지 무조건 분해하지 않고 실제 cardinality와 메모리 profile을 기준으로 경계를 정했습니다.

ORM 성능을 Query 개수로만 보지 않기

처음에는 쿼리 수가 적으면 효율적이라고 판단했습니다. 여러 to-many 관계가 한 결과 집합에서 곱해지자 데이터베이스가 반환한 행과 Node.js가 조립한 객체가 모두 예상보다 커졌습니다.

부모를 제한하고 관계를 batch로 읽는 방식은 SQL 호출을 몇 번 늘렸지만 각 단계의 최대 크기를 계산할 수 있게 했습니다. ORM 성능을 볼 때는 쿼리 개수만 세지 말고 반환 행 수, 전송량과 peak RSS를 함께 봐야 합니다.

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다