
N+1 문제 완벽 가이드 | SQL 반복 쿼리가 서버를 죽이는 이유와 해결법
SQL 튜닝 · 쿼리 최적화 · 데이터베이스 성능
안녕하세요! 오늘은 데이터베이스 성능 이야기를 할 때 절대 빠지지 않는 단골 주제,
'N+1 문제'에 대해 파헤쳐 보겠습니다.
JPA, Django ORM, ActiveRecord 같은 ORM 기술을 배울 때 한 번쯤 마주치는 이 문제의 본질은,
애플리케이션의 반복문 안에서 SQL 쿼리가 어떻게 호출되는가에 있습니다.
오늘은 복잡한 프레임워크 이야기는 잠시 접어두고, 순수 SQL 관점에서
N+1 문제가 무엇이고, 어떻게 해결할 수 있는지 초급 개발자도 이해할 수 있도록 쉽게 설명해 드리겠습니다.
N+1 문제란 무엇인가?
N+1 문제란, 1번의 쿼리로 N개의 부모 데이터를 가져온 뒤, 그 N개의 데이터 각각에 대해 자식 데이터를 가져오기 위해 추가 쿼리가 N번 실행되는 현상을 말합니다. 결과적으로 의도한 쿼리는 1번인데, 실제로 데이터베이스에는 1 + N번의 쿼리가 날아갑니다.
💡 예시 시나리오
쇼핑몰 서비스에서 "모든 고객(Customer)과 각 고객의 주문(Order) 내역"을 화면에 출력해야 합니다. 고객이 100명이라면, 데이터베이스와 애플리케이션 사이에 몇 번의 대화가 오갈까요?
직관적으로는 "쿼리 2~3번이면 되지 않나?" 싶지만, 잘못 짠 코드는 101번의 쿼리를 날리기도 합니다. 이것이 N+1 문제의 핵심입니다. 데이터가 100건일 때는 느린 정도지만, 10,000건이 되면 서버가 응답 불능 상태에 빠질 수도 있습니다.
N+1 문제가 발생하는 코드 패턴
개발자가 애플리케이션 코드에서 아래처럼 로직을 작성했다고 해봅시다. "모든 고객을 조회하고, 루프를 돌면서 각 고객의 주문을 조회한다." 굉장히 자연스럽게 느껴지는 코드지만, 데이터베이스 입장에서는 다음과 같은 쿼리 폭탄이 쏟아집니다.
-- ❌ N+1 문제 발생 패턴
-- [STEP 1] 모든 고객을 조회 (1번 실행)
SELECT id, name FROM Customer;
-- 결과: 고객 100명 반환 (N = 100)
-- [STEP 2] 애플리케이션 루프를 돌며 각 고객의 주문 조회 (N번 실행)
SELECT * FROM Order WHERE customer_id = 1;
SELECT * FROM Order WHERE customer_id = 2;
SELECT * FROM Order WHERE customer_id = 3;
...중략...
SELECT * FROM Order WHERE customer_id = 100;
-- 결과: 총 101번의 쿼리 실행 (1 + 100)
⚠️ 왜 이게 문제인가요?
데이터베이스와 애플리케이션 서버는 네트워크로 연결되어 있습니다. 쿼리를 1번 실행할 때마다 네트워크 왕복 통신(Round Trip)이 1번 발생합니다. 고객 100명이라면 101번의 네트워크 통신이 발생하고, 1만 명이라면 10,001번이 됩니다. 데이터가 늘어날수록 성능은 선형(Linear)으로 급격히 저하됩니다.
해결 방법 1 — JOIN으로 한 방에 해결 (Fetch Join)
가장 직관적인 해결책은 테이블을 JOIN하여 처음부터 부모와 자식 데이터를 한 번의 쿼리로 모두 가져오는 것입니다. JPA에서는 이를 'Fetch Join'이라고 부르기도 합니다.
-- ✅ 해결 방법 1: JOIN 사용 (101번 → 1번으로 단축)
SELECT
c.id AS customer_id,
c.name AS customer_name,
o.id AS order_id,
o.product_name
FROM
Customer c
LEFT JOIN
Order o ON c.id = o.customer_id;
-- 결과: 네트워크 통신 단 1번으로 모든 데이터 조회 완료
✔ 장점
- 네트워크 통신이 단 1번만 발생하므로 속도가 매우 빠릅니다.
- SQL 표준 문법이라 어떤 데이터베이스에서도 사용할 수 있습니다.
⚠ 주의할 점
📌 데이터 중복(뻥튀기) 현상 주의
1:N 관계를 JOIN하면 카테시안 곱(Cartesian Product)이 발생합니다. 예를 들어 고객 1명이 주문 5개를 했다면, 결과에 해당 고객 정보가 5줄로 중복되어 나타납니다. 따라서 애플리케이션 단에서 데이터를 그룹화하는 후처리가 필요하고, 페이징(Paging) 처리 시에는 정확한 페이지 계산이 어려워지는 단점이 있습니다.
해결 방법 2 — IN 절 배치(Batch) 조회
실무에서 가장 권장되는 방식입니다. 쿼리를 딱 1번으로 줄이진 않지만,
2번의 쿼리만으로 N+1 문제를 깔끔하게 해결합니다.
아이디어는 간단합니다. 먼저 부모 ID 목록을 수집한 뒤, IN 절에 한꺼번에 넣어서 자식 데이터를 일괄 조회하는 것입니다.
-- ✅ 해결 방법 2: IN 절 배치 조회 (101번 → 2번으로 단축)
-- [STEP 1] 고객 목록 조회 (1번)
SELECT id, name FROM Customer;
-- (애플리케이션: 결과에서 id 값만 추출 → [1, 2, 3, ..., 100])
-- [STEP 2] 추출한 ID 리스트로 주문 일괄 조회 (1번)
SELECT * FROM Order
WHERE customer_id IN (1, 2, 3, ..., 100);
-- 결과: 총 2번의 쿼리로 모든 데이터 조회 완료
✅ IN 절 배치 조회의 장점
- JOIN 대비 데이터 중복(뻥튀기) 없음 — 각 쿼리 결과가 깔끔합니다.
- 페이징(Paging) 처리에 매우 유리 — 부모 쿼리에
LIMIT/OFFSET을 정확히 적용할 수 있습니다. - ORM에서
@BatchSize(JPA),prefetch_related(Django) 설정 시 내부적으로 이 패턴이 자동 생성됩니다.
두 방법 비교 및 선택 기준
| 구분 | JOIN (Fetch Join) | IN 절 배치 조회 |
|---|---|---|
| 쿼리 횟수 | 1번 | 2번 |
| 데이터 중복 | 있음 (1:N 관계 시) | 없음 |
| 페이징 처리 | 주의 필요 | 안전하게 가능 |
| 추천 상황 | 단순 출력, 단건 조회 | 목록 화면, 페이징 포함 |
정답은 없습니다. 두 방법 모두 N+1 문제를 해결하지만, 페이징이 포함된 목록 API라면 IN 절 배치 조회를, 단건 상세 조회처럼 데이터 중복이 괜찮은 경우라면 JOIN을 선택하세요.
마무리 — SQL 관점에서 생각하는 습관
N+1 문제는 애플리케이션의 편의성(반복문)만 생각하고 데이터베이스와의 통신 비용을 고려하지 않았을 때 발생하는 대표적인 성능 안티 패턴입니다. 개발을 하다 보면 무의식적으로 반복문 안에서 DB 조회를 하는 코드를 작성하기 쉽습니다.
📝 핵심 요약
- N+1 문제: 루프 안에서 각 부모 데이터별로 자식 쿼리를 날릴 때 발생 (1 + N번 실행)
- JOIN 해결: 두 테이블을 묶어서 한 번에 조회 (데이터 중복 후처리 필요)
- IN 절 해결: 부모 ID를 모아 IN 조건으로 자식 데이터를 일괄 조회 (페이징에 유리)
코드를 짤 때마다 스스로에게 질문해 보세요. "내가 작성한 이 코드가 데이터베이스에 몇 번의 쿼리로 번역될까?" 이 습관 하나가 서비스의 성능을 몇 배씩 끌어올리는 밑거름이 됩니다. SQL 튜닝과 쿼리 최적화의 첫걸음은 거창한 기술이 아니라, 이런 작은 시각의 전환에서 시작됩니다. 😊
CoLife
20년 차 현직 개발자 · code & life 운영
실무에서 마주친 진짜 개발 이야기를 씁니다.
💬 여러분의 경험을 나눠주세요!
실무에서 N+1 문제로 고생했던 경험이 있으신가요?
어떤 방법으로 해결하셨는지 댓글로 알려주시면 함께 이야기 나눠요! 👇
'Code > SQL' 카테고리의 다른 글
| SQL 인젝션(SQL Injection)이란? 실제 뚫리는 PHP 코드로 이해하기 (0) | 2026.04.01 |
|---|