---
term: 'N+1 쿼리 문제'
seoTitle: 'N+1 쿼리 문제: 원인, 탐지, 해결 방법'
headline: 'N+1 쿼리 문제란?'
slug: n-plus-one-query-problem
category: api-realtime
shortDefinition: 'N+1 쿼리 문제는 N개의 레코드를 가져올 때 레코드마다 쿼리가 하나씩 더 실행되어, 한두 번이면 될 왕복이 N+1번으로 늘어나는 패턴입니다.'
relatedTerms:
  - relational-queries-document-databases
  - graphql-vs-rest
  - overfetching-underfetching
  - api-payload-optimization
  - database-index
contrastsWith:
  - overfetching-underfetching
faq:
  - question: 'N+1 쿼리 문제를 쉽게 설명하면 무엇입니까?'
    answer: '하나의 쿼리로 N개 레코드의 목록을 가져온 뒤, 코드가 관련 데이터를 위해 레코드마다 쿼리를 하나씩 더 보내는 것입니다. 게시물 100개가 쿼리 101개가 됩니다. 각 쿼리는 개별적으로 빠르며, 바로 그 때문에 문제가 숨어 있습니다. 페이지가 수백 번의 왕복을 일으키기 전까지는 누구도 경계할 만큼 느린 것이 없습니다.'
  - question: 'N+1 쿼리는 왜 발생합니까?'
    answer: '기본값으로 설정된 지연 로딩 때문입니다. ORM과 SDK는 post.author처럼 관계를 객체 속성처럼 탐색할 수 있게 해 주고, 그 속성에 접근하는 순간 쿼리를 투명하게 실행합니다. 이 접근을 N개의 결과를 도는 루프 안에 넣으면, 자신도 모르게 N개의 쿼리를 작성한 셈이 됩니다. 데이터 접근을 편하게 만든 추상화가 왕복도 보이지 않게 만든 것입니다.'
  - question: 'N+1 문제는 어떻게 해결합니까?'
    answer: '네 가지 표준 탈출구가 있으며, 상황에 따라 가장 알맞은 것을 고릅니다. 쿼리에 관계를 포함하도록 미리 지정하는 즉시 로딩, 하나의 문장으로 둘을 함께 가져오는 조인, N개의 외래 키를 모아 포함 조건(contained-in) 쿼리 하나로 가져오는 배칭, 그리고 자동으로 배칭하는 요청 범위 로더입니다. 넷 모두 N+1번의 왕복을 한두 번으로 줄입니다.'
  - question: 'N+1은 실제로 얼마나 느립니까?'
    answer: '곱해 보면 알 수 있습니다. 왕복 한 번은 쿼리가 실행되기도 전에 1–5밀리초가 들므로, 100개 행에 5 ms씩이면 배칭된 쿼리 한 번(약 10 ms)에 비해 0.5초가 더 걸립니다. 공개된 실제 수정 사례를 보면 페이지가 약 1.4초에서 0.16초로 줄고, API 엔드포인트가 30배 빨라졌습니다. 이 계산은 페이지 크기에 비례해 선형으로 나빠지며, 네트워크를 거치면 파국적으로 나빠집니다.'
  - question: 'GraphQL에서 N+1 문제가 특히 흔한 이유는 무엇입니까?'
    answer: '리졸버가 필드마다, 객체마다 실행되기 때문입니다. 게시물과 작성자를 함께 요청하는 쿼리는 posts 리졸버를 한 번, author 리졸버를 N번 실행합니다. ORM 루프가 서버 측에서 다시 태어난 것입니다. 정석적인 해법은 로더 패턴입니다. 실행 중에 작성자 ID를 모아 배칭된 조회를 한 번 보내고, 그 결과를 해당 요청 안에서 메모이제이션하는 요청 범위 배처입니다.'
  - question: 'N+1 쿼리는 어떻게 탐지합니까?'
    answer: '느린 쿼리 하나가 아니라 빠르고 동일한 쿼리 여러 개를 찾아야 합니다. 그것이 N+1의 특징입니다. ORM 디버그 로그에는 매개변수만 다른 같은 문장이 반복해서 나타나고, 슬로 쿼리 로그는 각 쿼리가 빠르기 때문에 이를 전혀 잡아내지 못하며, APM 도구는 이 스팬 패턴을 명시적으로 표시합니다. 이를 일찍 잡아내는 습관은 페이지 렌더링 한 번의 쿼리 로그를 읽고 개수를 세는 것입니다.'
  - question: 'N+1 문제는 문서 데이터베이스와 REST API에서도 발생합니까?'
    answer: '데이터에 관계가 있는 곳이라면 어디서나 발생합니다. 문서 저장소에서는 참조된 문서마다 find를 한 번씩 실행하는 형태이며, include 메커니즘, 배칭된 포함 조건 쿼리, 임베딩으로 해결합니다. REST에서는 목록 엔드포인트 하나에 항목마다 HTTP 호출이 하나씩 더해지는 형태로, 네트워크 지연 시간이 쿼리 지연 시간을 압도하므로 데이터베이스 버전보다 더 심각합니다. 복합 엔드포인트, 배치 엔드포인트, 쿼리 언어는 상당 부분 이 문제를 없애기 위해 존재합니다.'
  - question: '지연 로딩은 항상 잘못된 것입니까?'
    answer: '아닙니다. 루프 안에서 잘못된 것입니다. 관련 데이터가 드물게 필요할 때는 지연 로딩이 정확히 옳습니다. N개 전부를 즉시 로딩하는 대신, 그 데이터가 필요한 한 레코드에서만 비용을 치르면 됩니다. 핵심은 화면마다 어떤 접근 패턴을 갖는지 아는 것입니다. 항상 필요한 관계는 즉시 로딩하고, 드물게 필요한 관계는 지연 로딩하며, 루프 안에 있는 것은 모두 점검합니다.'
codeLanguages: [javascript, dart, swift, kotlin]
externalAuthorities:
  - name: 'DataLoader pattern — graphql-js documentation'
    url: 'https://www.graphql-js.org/docs/n1-dataloader/'
  - name: 'Solving the N+1 problem for GraphQL through batching — Shopify Engineering'
    url: 'https://shopify.engineering/solving-the-n-1-problem-for-graphql-through-batching'
  - name: 'N+1 queries — Sentry performance issue documentation'
    url: 'https://docs.sentry.io/product/issues/issue-details/performance-issues/n-one-queries/'
  - name: 'SDK query documentation (include)'
    url: 'https://docs.parseplatform.org/js/guide/#relational-data'
cta:
  title: '다른 곳에서 100번 보낼 요청을 한 번으로'
  text: 'Back4app SDK에서는 해결책이 기본 관용구입니다. include()가 관계를 같은 요청에서 서버 측 배칭으로 가져오고, Pointer가 조인 비용을 낮게 유지합니다. 코드베이스에 N+1 루프가 존재할 이유가 처음부터 없습니다.'
  linkText: '무료로 시작하기'
  linkUrl: 'https://www.back4app.com/signup'
author: 'Back4app Engineering'
publishedDate: '2026-10-30'
translationKey: n-plus-one-query-problem
---

**N+1 쿼리 문제는 N개의 레코드를 가져올 때 레코드마다 쿼리가 하나씩 더 실행되어, 한두 번이면 될 왕복이 N+1번으로 늘어나는 패턴입니다.** 데이터 기반 애플리케이션에서 가장 흔한 심각한 성능 버그이자, 가장 잘 위장된 버그입니다. 개별 쿼리는 모두 빠르고, 코드는 완벽하게 읽히며, 페이지도 잘 동작합니다. 실제 규모의 데이터가 들어오기 전까지는 말입니다.

## 핵심 요약

| 질문 | 답변 |
| --- | --- |
| 형태 | 목록 쿼리 1개 + 관계 쿼리 N개 = N+1번 왕복 |
| 원인 | 루프 안에서 건드린 지연 로딩 – 반복마다 보이지 않는 쿼리 |
| 특징 | *빠르고 동일한* 쿼리 다수 – 슬로 쿼리 로그에는 절대 잡히지 않음 |
| 탈출구 | 즉시 로딩 · 조인 · 배칭된 IN · 요청 범위 로더 |
| 증폭 요인 | 왕복 지연 시간 × 페이지 크기 – 네트워크를 거치면 치명적 |

## 코드로 보는 버그

```javascript
// 쿼리 1개: 게시물 100개 조회
const posts = await postRepo.findRecent(100);

for (const post of posts) {
  // 게시물마다 +1 쿼리 — post.author는 속성처럼 보이지만
  // 실제로는 지연 로딩: SELECT * FROM users WHERE id = ?
  render(post.title, (await post.author).name);
}
// 쿼리 로그: 페이지 렌더링 한 번에 1 + 100 = 101번 왕복
```

SDK로 표현한 해결책은 이렇습니다. 관계를 미리 선언하면 플랫폼이 같은 요청에서 함께 가져옵니다:

**JavaScript:**

```javascript
// JavaScript / Node.js — Back4app JS SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
const query = new Parse.Query('Comment');
query.equalTo('post', post);
query.include('author');                    // eager-load the pointer
const comments = await query.find();        // one round trip, total

comments.forEach((c) =>
  render(c.get('text'), c.get('author').get('username')) // already loaded
);
```

**Flutter:**

```dart
// Flutter / Dart — Back4app Flutter SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
final query = QueryBuilder<ParseObject>(ParseObject('Comment'))
  ..whereEqualTo('post', post.toPointer())
  ..includeObject(['author']);              // eager-load the pointer
final response = await query.query();       // one round trip, total
```

**Swift:**

```swift
// iOS / Swift — Back4app Swift SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
let query = Comment.query("post" == post)
  .include("author")                        // eager-load the pointer
query.find { result in
  if case .success(let comments) = result { // one round trip, total
    comments.forEach { render($0.text, $0.author?.username) }
  }
}
```

**Kotlin:**

```kotlin
// Android / Kotlin — Back4app Android SDK
// The fix: fetch the relation in the same request — 1 query, not N+1
val query = ParseQuery.getQuery<ParseObject>("Comment")
query.whereEqualTo("post", post)
query.include("author")                     // eager-load the pointer
query.findInBackground { comments, e ->     // one round trip, total
  if (e == null) comments.forEach {
    render(it.getString("text"), it.getParseObject("author")?.getString("username"))
  }
}
```

## 곱해 보는 비용

설명 글들이 건너뛰는 계산입니다. 총 시간 ≈ 목록 쿼리 + (N × 왕복 지연 시간):

| 페이지 크기 N | 쿼리당 1 ms | 쿼리당 5 ms | 배칭(쿼리 1–2개) |
| --- | --- | --- | --- |
| 10 | ~11 ms | ~55 ms | ~10 ms |
| 100 | ~101 ms | ~505 ms | ~12 ms |
| 1,000 | ~1.0 s | ~5.0 s | ~20 ms |

공개된 수정 사례도 이 계산과 일치합니다. 페이지가 1.4 s에서 0.16 s로 줄고, 엔드포인트가 30배 이상 빨라졌습니다. 기억해 둘 만한 따름정리가 두 가지 있습니다. **페이지네이션은 N에 상한을 둡니다.** 제한된 페이지 크기는 진짜 해결책을 적용하기 전에도 영향 범위를 제한합니다. 그리고 **네트워크 버전은 더 나쁩니다.** N번의 호출 하나하나가 로컬 쿼리가 아니라 HTTP 요청이라면, 한 자릿수 밀리초가 아니라 수십 밀리초를 곱해야 합니다.

## 네 가지 탈출구

```mermaid
flowchart LR
  accTitle: N+1 폭포수와 배칭된 조회의 비교
  accDescr: N+1 패턴은 목록 쿼리 하나를 보낸 뒤 레코드마다 쿼리를 하나씩 순차적으로 보내고, 배칭 패턴은 목록 쿼리와 함께 관련 레코드 전체를 한 번에 가져오는 쿼리 하나만 보냅니다.
  subgraph P["N+1 폭포수"]
    a["목록 쿼리"] --> b["레코드마다 쿼리<br/>× N, 하나씩 차례로"]
  end
  subgraph B["배칭"]
    c["목록 쿼리"] --> d["모든 관계를 위한 쿼리 단 하나<br/>WHERE id IN (…)"]
  end
  P -.->|"해결"| B
```

| 해결책 | 방법 | 이럴 때 선택 | 주의할 점 |
| --- | --- | --- | --- |
| 즉시 로딩 / include | 쿼리에 관계를 선언 | 화면에 그 관계가 항상 필요할 때 | 과도한 include는 페이로드를 비대하게 만듦 |
| 조인 | 하나의 문장으로 둘 다 가져옴 | 관계형 엔진, 리포트 형태의 읽기 | 넓은 조인에서의 행 폭증 |
| 배칭된 IN | 키를 모아 한 번에 조회 | 어떤 스택이든, 직접 짠 코드라도 | 왕복 1번 추가(문제없음) |
| 요청 범위 로더 | 실행 중 자동 배칭 | GraphQL 리졸버, 계층화된 코드 | 전역이 아니라 요청 단위여야 함 |

마지막 행은 GraphQL의 유명한 사례입니다. 리졸버는 부모 객체마다 실행되어 서버 측에서 루프를 재현하며, [로더 패턴](https://www.graphql-js.org/docs/n1-dataloader/)(한 틱 동안 키를 모으고, 한 번에 가져오고, 요청 단위로 메모이제이션)이 [업계 표준 해법](https://shopify.engineering/solving-the-n-1-problem-for-graphql-through-batching)입니다. 여기에는 미묘한 규칙이 하나 따라붙습니다. 로더는 *요청 범위*여야 합니다. 전역 로더는 인가 버그를 품은 낡은 캐시가 됩니다.

## 탐지: 빠른 쿼리 찾기

N+1의 특징은 일반적인 성능 작업과 정반대입니다. 느린 쿼리 하나가 아니라 **빠르고 동일한 쿼리 여러 개**를 찾아야 합니다. 그래서 흔히 쓰는 도구인 슬로 쿼리 로그는 이를 [결코 보지 못합니다](https://docs.sentry.io/product/issues/issue-details/performance-issues/n-one-queries/). 효과가 있는 방법은 다음과 같습니다. ORM 디버그 로그(렌더링 한 번에 같은 문장이 N개의 다른 매개변수로 반복됨), 동일하고 짧은 스팬이 폭포수처럼 이어지는 모습이 뚜렷이 보이는 APM 스팬 뷰, 그리고 가장 저렴한 방법인 개발 환경에서 페이지 로드 한 번의 쿼리 수를 세는 것입니다. 이때 머릿속에 기준선을 두어야 합니다. 목록 페이지의 쿼리 수는 행 수의 배수가 아니라 한 자릿수여야 합니다. 쓰기 쪽의 쌍둥이도 점검할 가치가 있습니다. 항목마다 insert를 실행하는 루프는 쓰기의 N+1이며, 벌크 작업으로 해결합니다.

## 일반적인 사용 사례

- **작성자, 소유자, 상태를 함께 보여 주는 목록 화면** – 본래의 서식지입니다. 사람과 항목을 연결하는 모든 피드, 받은편지함, 테이블이 여기에 해당합니다.
- **GraphQL API** – 중첩된 목록 필드는 로더가 생기기 전까지 구조적으로 N+1입니다.
- **문서 데이터베이스** – 참조된 문서마다 find를 한 번씩 실행합니다. [모델링 규칙](/glossary/ko/relational-queries-document-databases/)에 따라 include, 배칭된 IN, 임베딩으로 해결합니다.
- **마이크로서비스 팬아웃** – 서비스 A에서 목록을 받고, 항목마다 서비스 B에 HTTP 호출을 한 번씩 보냅니다. 복합 엔드포인트와 BFF는 이를 끝내기 위해 존재합니다.
- **백그라운드 작업** – 10,000개 레코드를 레코드당 쿼리 두 개씩으로 처리하는 루프가 조용히 몇 시간을 잡아먹습니다.

## 지연 로딩 vs. 즉시 로딩: 결정 매트릭스

| 즉시 로딩해야 하는 경우… | 지연 로딩을 유지해도 되는 경우… |
| --- | --- |
| 관계가 모든 행에 렌더링됨 | 관계가 클릭 뒤에 있음 |
| 루프 자체가 접근 패턴 | 한 번에 한 레코드씩 접근 |
| N이 페이지 크기 이상 | N이 아주 작다는 것이 보장됨 |
| 지연 시간이 사용자에게 드러남 | 백그라운드 작업이라 지연을 감수할 수 있음 |
| 바로 이곳에서 이 버그를 막 고쳤음 | 추측이 아니라 측정을 거쳤음 |

그리고 어떤 매트릭스보다 오래 남는 상시 점검 규칙이 있습니다. **루프 안에서 건드리는 모든 관계는 쿼리 로그가 무죄를 증명하기 전까지 유죄입니다.**

## 한계와 트레이드오프

- **즉시 로딩은 과잉 교정이 될 수 있습니다.** 무거운 관계를 어디서나 포함하면 N+1을 비대한 페이로드와 넓은 조인으로 맞바꾸게 됩니다. 그래프 전체가 아니라 화면이 렌더링하는 것만 포함해야 합니다.
- **조인에도 나름의 절벽이 있습니다.** 일대다 조인은 자식마다 부모 행을 중복시킵니다. 팬아웃이 크면 쿼리 두 번으로 배칭하는 조회가 단일 조인보다 낫습니다.
- **로더는 구현 복잡도를 더합니다.** 요청 범위 지정, 요청 내 캐시 무효화, 배칭 윈도는 실제로 작성해야 하는 코드이며, 자동 배칭의 대가입니다.
- **프레임워크는 조용히 문제를 다시 들여옵니다.** 직렬화기, 템플릿 헬퍼, "필드 하나만 더" 식의 리뷰가 고쳐 둔 페이지를 퇴행시킵니다. 쿼리 수 점검은 기억이 아니라 CI에 있어야 합니다.
- **해결은 전역이 아니라 경로 단위입니다.** N+1은 접근 패턴의 버그이며, 새 화면이 생길 때마다 같은 질문을 다시 던져야 합니다.

## Back4app에서의 N+1

Back4app은 관리형 데이터베이스, 자동 생성되는 REST 및 GraphQL API, 인증, 파일 스토리지, Cloud Code 서버리스 함수를 결합한 오픈소스 Backend as a Service(BaaS) 플랫폼입니다. Back4app의 SDK는 해결책을 사후 조치가 아니라 기본 관용구로 만듭니다. 관계는 타입이 지정된 Pointer이고, [`include()`](https://docs.parseplatform.org/js/guide/#relational-data)(위의 코드 탭)는 관계를 같은 요청에서 서버 측 배칭으로 가져옵니다. GraphQL API는 필드별 팬아웃 없이 중첩 쿼리를 해석하고, 페이지네이션 기본값이 N이 커지기 전에 상한을 둡니다. N+1을 일으키는 루프는 자연스럽게 작성될 여지가 없습니다. 이것이 가장 좋은 종류의 해결책, 즉 아무도 기억할 필요가 없는 해결책입니다.
