N+1 쿼리 문제: 원인과 탐지, JOIN·IN 로딩으로 고치기
들어가며
글 목록 API 를 만들었습니다. 개발 환경에서는 금방 응답했는데, 운영에서 한 페이지를 100개로 늘리자 눈에 띄게 느려집니다. 그런데 슬로 쿼리 로그를 열어 보면 아무것도 없습니다.
쿼리 하나하나는 빠른데 횟수가 많을 때 이런 일이 생깁니다. 이번 글에서는 대표적인 사례인 N+1 쿼리가 왜 생기는지, 어떻게 찾는지, JOIN 로딩과 IN 로딩으로 어떻게 고치는지 정리합니다.
1. N+1 쿼리란
N+1 쿼리는 목록을 1번 읽은 뒤 항목마다 연관 데이터를 1번씩 더 조회하는 문제입니다. 글 100개와 작성자 이름을 보여 주는 화면이라면 데이터베이스에는 이런 쿼리가 나갑니다.
SELECT id, title, author_id FROM posts ORDER BY id DESC LIMIT 100;
SELECT id, name FROM users WHERE id = 7;
SELECT id, name FROM users WHERE id = 12;
-- 글마다 한 번씩 반복목록 조회 1번에 항목 수 N번이 더해져 N+1 이라고 부릅니다. 순서로 보면 1+N 이지만 이름은 굳어졌습니다.
N+1 은 장 볼 물건을 하나 살 때마다 집에 다녀오는 일과 같습니다.
2. ORM 에서 눈에 안 띄는 이유: 지연 로딩
SQL 을 직접 쓰면 반복문 안의 쿼리가 바로 보입니다. ORM 을 쓰면 그렇지 않습니다. 지연 로딩(lazy loading) 은 연관 데이터에 처음 접근하는 순간 쿼리를 보내는 방식이라, 코드에는 속성을 읽는 한 줄만 남습니다.
stmt = select(Post).order_by(Post.id.desc()).limit(100)
for post in session.scalars(stmt):
print(post.author.name) # 여기서 SELECT 가 나갑니다지연 로딩 자체가 잘못된 것은 아닙니다. 글 한 건을 보여 줄 때는 필요한 만큼만 읽으니 오히려 유리합니다. 문제는 이 접근이 반복문 안에 들어갈 때 생깁니다.
3. 왜 느려지나: 쿼리가 아니라 왕복
쿼리를 보내고 결과를 받는 데는 네트워크 왕복이 듭니다. 왕복 한 번에 1ms 가 걸린다고 어림하면, 글 목록 1번과 작성자 100번을 합쳐 101ms 입니다. 같은 데이터를 두 번의 쿼리로 묶으면 행을 더 읽는 시간을 합쳐도 3ms 안팎입니다.
한 번에 장바구니에 담아 오면 집과 가게를 오가는 길이 사라집니다.
쿼리 하나는 빨라도 횟수만큼 시간이 쌓입니다.
작성자를 IN 조건 한 번으로 읽습니다.
데이터베이스가 멀리 있어 왕복이 길어지면 차이는 그만큼 커집니다. 요청 하나가 커넥션을 쥐고 있는 시간도 길어지므로, 동시 요청이 많을 때는 커넥션 풀이 먼저 바닥날 수 있습니다.
4. 찾는 법: 같은 모양의 반복
N+1 은 쿼리 로그에서 값만 다르고 모양이 같은 쿼리가 줄지어 나오는 패턴으로 보입니다. 개발 환경에서 쿼리 로그를 켜거나, 운영에서는 APM 으로 요청당 쿼리 수를 확인합니다.
재발을 막으려면 테스트에서 요청당 쿼리 수를 재는 방법이 확실합니다. 데이터를 늘려도 쿼리 수가 그대로여야 합니다.
from sqlalchemy import event
def count_queries(engine, action):
statements = []
def record(conn, cursor, statement, params, context, many):
statements.append(statement)
event.listen(engine, "before_cursor_execute", record)
try:
action()
finally:
event.remove(engine, "before_cursor_execute", record)
return len(statements)
def test_post_list_query_count(client, engine, make_posts):
make_posts(10)
small = count_queries(engine, lambda: client.get("/posts?limit=100"))
make_posts(90)
large = count_queries(engine, lambda: client.get("/posts?limit=100"))
assert small == large5. 고치는 법: JOIN 로딩과 IN 로딩
해결의 방향은 하나입니다. 연관 데이터를 한 번에 묶어서 읽습니다. 묶는 방법은 크게 두 가지입니다.
JOIN 로딩
한 쿼리에서 조인으로 함께 읽습니다. 왕복은 1번입니다.
SELECT p.id, p.title, u.name
FROM posts p
JOIN users u ON u.id = p.author_id
ORDER BY p.id DESC
LIMIT 100;글과 작성자처럼 다대일 관계면 행 수가 늘지 않아 단순합니다. 반대로 글과 댓글처럼 일대다 관계면 글 하나가 댓글 수만큼 여러 행으로 늘어납니다. 이때 LIMIT 은 글이 아니라 행 기준으로 잘리므로 원하는 개수가 나오지 않습니다.
IN 로딩
목록의 ID 를 모아 IN 조건으로 한 번 더 읽습니다. 왕복은 2번이고 행이 늘지 않습니다.
stmt = (
select(Post)
.order_by(Post.id.desc())
.limit(100)
.options(selectinload(Post.author))
)SELECT id, title, author_id FROM posts ORDER BY id DESC LIMIT 100;
SELECT id, name FROM users WHERE id IN (7, 12, 31);ID 가 아주 많으면 IN 목록이 길어지므로 일정 크기로 나눠 보냅니다. 이름만 다를 뿐 다른 ORM 에도 같은 도구가 있습니다. Django 의 select_related 는 JOIN, prefetch_related 는 별도 쿼리 방식이고, Hibernate 는 fetch join 과 배치 크기 설정을 씁니다.
JOIN 은 한 봉투에 다 담기, IN 은 목록을 들고 한 번 더 다녀오기입니다.
| 로딩 방식 | 왕복 | 행 증가 | LIMIT | 맞는 곳 |
|---|---|---|---|---|
| 지연 로딩lazy | 1+N | 없음 | 단건 조회 | |
| JOIN 로딩joined | 1 | 일대다 | 일대다면 행 기준으로 잘림 | 다대일 |
| IN 로딩selectin | 2 | 없음 | 일대다 |
묶어 읽으면 왕복은 줄지만 한 번에 읽는 행과 메모리는 늘어납니다. 일대다 관계를 여러 개 JOIN 하면 행이 곱으로 불어나므로, 이런 경우에는 IN 로딩으로 나누는 편이 안전합니다.
헷갈리기 쉬운 점
- 쿼리가 느려서 생기는 문제라는 오해. 쿼리 하나는 빠를 수 있고 횟수가 문제입니다. 그래서 슬로 쿼리 로그에는 잘 잡히지 않습니다.
- 인덱스를 걸면 해결된다는 오해. 인덱스는 쿼리 하나를 빠르게 할 뿐 왕복 수는 그대로입니다.
- 항상 정확히 N번 나간다는 오해. ORM 은 같은 세션에서 이미 읽은 객체를 다시 조회하지 않는 경우가 많습니다. 작성자가 몇 명뿐인 개발 데이터에서는 조용하다가, 작성자가 제각각인 운영 데이터에서 드러나는 이유입니다.
- 즉시 로딩을 기본값으로 두면 된다는 오해. 필요 없는 화면까지 연관 데이터를 읽게 됩니다. JPA 의
@ManyToOne은 기본이 즉시 로딩이지만, JPQL 로 목록을 읽으면 연관 엔티티 조회가 따로 나가 N+1 이 그대로 생길 수 있습니다.
면접에서 자주 받는 질문
JOIN 로딩과 IN 로딩은 어떻게 고르나요?
다대일이면 행이 늘지 않으니 JOIN 이 단순합니다. 일대다면 행이 늘어 LIMIT 이 어긋나므로 IN 로딩이 안전합니다. ID 가 아주 많으면 IN 목록을 나눠 보냅니다.
N+1 을 배포 전에 잡으려면 어떻게 하나요?
테스트에서 요청당 쿼리 수를 확인합니다. 데이터를 늘려도 쿼리 수가 그대로인지 보는 방식입니다. 운영에서는 APM 이나 쿼리 로그로 요청당 쿼리 수를 계속 지켜봅니다.
GraphQL 에서 N+1 이 잘 생기는 이유는요?
필드마다 리졸버가 따로 실행되기 때문입니다. 글 목록의 작성자 필드 리졸버가 글마다 한 번씩 호출되어 그대로 N번 조회가 됩니다. 보통 DataLoader 로 한 번의 실행 안에서 모인 키를 묶어 조회합니다.
정리
- N+1 은 목록 1번에 항목마다 1번씩 더 조회해 왕복이 N에 비례해 쌓이는 문제입니다.
- ORM 의 지연 로딩이 반복문 안에 들어갈 때 잘 생기고, 쿼리 로그의 같은 모양 반복으로 찾습니다.
- 다대일은 JOIN 로딩, 일대다는 IN 로딩으로 묶되 한 번에 읽는 양이 늘어나는 점을 함께 봅니다.
목록 화면이 느리면 쿼리 하나의 속도보다 횟수를 먼저 세어 보세요. 하나씩 왕복하지 말고, 한 번에 묶어 오면 됩니다.