1. 개요
N+1 문제는 JPA 글 에서 @EntityGraph 로 어떻게 해결하는지 이미 다뤘다. 그 글은 JPA 안에서의 해결책이었고, 이번 글은 조금 다른 층위에서 본다. N+1 문제는 SQL 문법이 잘못돼서 생기는 게 아니다. SQL 자체는 아무 잘못이 없다. 문제는 “DB 를 몇 번 왕복하느냐” 라는 호출 방식에 있다.
2. 핵심 내용
2-1. N+1 을 만드는 쿼리들은 문법적으로 다 정상이다
주문 20건을 가져온 뒤 각 주문의 사용자 이름을 알고 싶다고 하자. N+1 이 발생하면 이런 쿼리가 21번 나간다.
SELECT * FROM orders LIMIT 20; -- 1번
SELECT * FROM users WHERE id = 1; -- 이후 20번,
SELECT * FROM users WHERE id = 2; -- 주문마다
-- ... -- 하나씩
SELECT * FROM users WHERE id = 20;이 21개의 쿼리를 하나씩 떼어놓고 보면 전부 정상적인 SQL 이다. 문법 오류도 없고, 각 쿼리 자체의 실행 계획도 인덱스만 있으면 빠르다. 문제는 쿼리 하나하나가 아니라 “같은 목적을 위해 21번 왕복했다” 는 점이다. 쿼리 한 번마다 네트워크 왕복(round trip)이 발생하고, 그 지연 시간이 쌓인다.
2-2. ORM 이 왜 이걸 만드나
JPA 같은 ORM 은 연관 관계를 기본적으로 지연 로딩(Lazy Loading)으로 다룬다. order.getUser() 처럼 실제로 그 값을 쓰는 코드가 실행되는 시점에야 조용히 쿼리를 하나 더 날린다. 코드만 보면 컬렉션을 순회하며 필드에 접근하는 평범한 루프인데, 그 루프 한 바퀴마다 DB 쿼리가 하나씩 숨어서 나간다. 개발자가 쿼리를 몇 번 보내는지 직접 눈으로 셀 일이 없다 보니, 로그를 켜보기 전까지는 문제를 알아채기 어렵다.
즉 N+1 은 “SQL 을 잘못 짜서” 생기는 게 아니라 “SQL 을 보낼지 말지를 호출하는 코드가 매 반복마다 결정하기 때문” 에 생긴다. SQL 입장에서는 그냥 시키는 대로 21번 실행했을 뿐이다.
2-3. SQL 은 원래 한 번에 가져오는 걸 잘한다
SQL 관점에서 이 문제의 해법은 단순하다. 21번 나눠 보낼 요청을 한 번(또는 두 번)으로 합치면 된다.
-- 방법 1: JOIN 으로 한 번에
SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
LIMIT 20;
-- 방법 2: IN 절로 한 번에 모아서
SELECT * FROM users WHERE id IN (1, 2, 3, /* ... */ 20);JOIN 을 쓰면 정말 한 방에 끝나고, IN 절로 모아 보내면 두 번으로 끝난다. 둘 다 SQL 이 원래부터 잘하던 일이다. JPA 의 @EntityGraph, fetch join, 배치 사이즈(batch size) 설정은 결국 “쿼리를 반복해서 하나씩 보내지 말고, SQL 한 번에 모아서 보내라” 고 ORM 에게 지시하는 방법일 뿐이다. SQL 을 고친 게 아니라 SQL 을 호출하는 패턴을 고친 것이다. 문제의 이름은 N+1 이지만, 범인은 SQL 이 아니라 호출부다.
3. 마무리
요약
- N+1 로 발생하는 쿼리 하나하나는 문법적으로 정상이다. 문제는 왕복 횟수다.
- ORM 의 지연 로딩은 반복문 안에서 필드에 접근할 때마다 조용히 쿼리를 하나씩 추가한다.
- SQL 은 원래 JOIN 이나 IN 절로 여러 건을 한 번에 가져오는 걸 잘한다.
- fetch join, @EntityGraph, 배치 사이즈는 SQL 자체가 아니라 SQL 을 호출하는 방식을 고치는 도구다.
다음은 정규화와 반정규화 이다.