목록 하나를 읽었을 뿐인데 데이터베이스 호출이 항목 수만큼 더 붙는다면, 화면은 같아도 비용 구조는 전혀 달라져.
한 줄로 말하면
N+1 쿼리는 목록을 가져오는 한 번의 조회(N) 뒤에, 목록의 각 항목을 처리하려고 추가 조회를 항목 수만큼 반복하는 패턴이야. 항목이 1개일 때는 모두 합쳐 2번이면 끝나지만, 항목이 100개면 같은 흐름이 101번의 쿼리로 늘어날 수 있어.
비유로 이해하기
도서관에서 책 목록을 한 번 받아 온 뒤, 책마다 직원에게 “이 책의 저자 정보도 찾아 주세요”라고 따로 부탁하는 장면을 떠올리면 돼. 책이 두 권이면 부탁도 두 번이라 견딜 만하지만, 백 권이면 책을 읽는 시간보다 부탁하고 기다리는 시간이 더 길어질 수 있지.
여기까지가 이해를 돕는 비유야. 실제 데이터베이스에서는 애플리케이션과 데이터베이스 사이의 왕복, 각 쿼리의 실행 계획, 연결·잠금·직렬화 비용이 함께 영향을 준다. 그래서 쿼리 하나가 빠르다는 사실만으로 반복 호출의 비용이 사라지지는 않아.
정확한 정의
전형적인 흐름은 이래.
- 부모 목록을 한 번 조회한다.
- 애플리케이션이 목록의 첫 항목을 순회한다.
- 각 항목의 연관 데이터가 필요할 때 별도 쿼리를 실행한다.
이때 부모 목록을 읽는 쿼리 하나와 항목별 쿼리 N개가 합쳐져 N+1이 돼. 핵심은 숫자 자체가 아니라 조회 횟수가 결과 목록의 크기에 따라 선형으로 늘어나는 제어 흐름이야. ORM을 쓰면 연관 관계를 코드에서 자연스럽게 읽는 순간 이 패턴이 숨어서 생길 수 있어.
해결 방향은 보통 필요한 연관 데이터를 한 번에 가져오도록 조회를 재구성하는 거야. 항목의 식별자를 모아 한 번의 일괄 조회로 가져오거나, 데이터베이스의 조인·사전 로딩 기능을 이용해 왕복 횟수를 줄일 수 있어. 다만 모든 연관 데이터를 무조건 한 번에 가져오면 사용하지 않는 데이터와 큰 결과 집합이 생길 수 있으니, 실제 화면·API가 요구하는 범위에 맞춰야 해.
왜 중요한가
N+1 쿼리는 데이터가 적을 때 쉽게 숨는다. 개발용 데이터에서 목록이 몇 개뿐이면 요청이 여러 번 나가도 응답 시간이 눈에 띄지 않을 수 있어. 하지만 목록 크기와 동시 요청이 커지면 데이터베이스 호출 수가 함께 늘어나고, 호출마다 생기는 왕복과 실행 비용이 누적돼.
이 문제는 현실 크기 테스트 데이터와 같이 봐야 해. lobste.rs의 MariaDB에서 SQLite로 전환하는 과정에서는 첫 배포가 읽기 전용 트래픽만으로 CPU 100%에 도달해 롤백됐고, 원인 가운데 하나가 별도의 N+1 문제였어.1 작은 테스트 데이터로 기능이 맞는지만 확인하면 이런 조회 패턴이 배포 뒤에야 드러날 수 있다는 사례야.
N+1을 줄인 뒤에도 쿼리가 충분히 좋은지는 별도로 확인해야 해. 일괄 조회로 바꿨는데 인덱스를 타지 않거나 필요 이상으로 많은 행을 읽으면 다른 병목이 생긴다. 그래서 호출 횟수, 실제 실행 계획, 데이터 크기별 지연, 동시성에서의 데이터베이스 사용량을 함께 보는 게 중요해.
실제 예시
게시글 목록을 먼저 가져온 뒤 각 게시글의 작성자 이름을 표시한다고 해 보자. 게시글 목록을 읽는 쿼리가 한 번 실행되고, 게시글마다 작성자를 읽는 쿼리가 반복되면 게시글 50개 화면은 최대 51번의 조회 흐름이 된다.
이 흐름은 작성자 정보를 한 번의 조인으로 함께 읽거나, 게시글에 등장한 작성자 ID를 모아 한 번에 가져오는 방식으로 바꿀 수 있어. 어떤 방식이 맞는지는 중복 작성자 수, 반환할 열, 페이지 크기, 캐시 여부에 따라 달라져. 중요한 건 코드가 편해 보이는 연관 객체 접근이 실제로 몇 번의 데이터베이스 호출을 만드는지 확인하는 거야.
헷갈리지 말아야 할 점
- N+1은 쿼리 문법의 이름이 아니야. 애플리케이션의 조회 흐름과 호출 횟수를 가리키는 성능 패턴이야.
- 쿼리 횟수가 많다고 언제나 버그는 아니야. 각 조회가 정말 독립적이고 데이터가 작다면 단순한 구현이 더 나을 수 있어. 문제는 규모가 커질 때 비용이 함께 커지는지야.
- 조인 하나로 바꾸면 항상 끝나는 것도 아니야. 중복 행, 큰 결과 집합, 사용하지 않는 연관 데이터가 생기면 일괄 조회의 비용을 다시 측정해야 해.
- 전체 테이블 스캔과는 다른 문제야. N+1은 조회가 반복되는 문제이고, 전체 테이블 스캔은 한 쿼리가 조건에 맞는 인덱스를 쓰지 못해 많은 행을 훑는 문제야. 둘은 한 요청에서 동시에 나타날 수도 있어.
- ORM 탓으로만 돌릴 수 없어. ORM은 패턴을 숨길 수 있지만, 어떤 데이터를 어떤 시점에 읽을지는 애플리케이션 조회 설계와 검증의 문제야.
관련 문서
- 실제 데이터 규모에서 N+1을 드러내는 조건: 현실 크기 테스트 데이터
- 데이터베이스 마이그레이션에서 함께 드러난 다른 조회 병목: lobste.rs의 MariaDB→SQLite 전환 정리
- 한 쿼리가 인덱스를 쓰지 못해 많은 행을 읽는 문제: 전체 테이블 스캔
남은 질문들
- 주요 ORM은 N+1을 개발·테스트 환경에서 어떤 방식으로 탐지하고 실패시킬까?
- 일괄 조회와 조인 사이의 비용 차이는 데이터 분포와 페이지 크기에 따라 어떻게 달라질까?
- 호출 횟수·실행 계획·지연을 배포 전에 함께 검증하는 가장 작은 테스트 장치는 무엇일까?
댓글