DB에 JOIN을 맡기면 '일을 떠넘기는' 걸까?
N+1 쿼리와 데이터 지역성
난이도: ★★★☆☆ (JOIN을 집합 연산으로 보는 감각이 선행 — 별도 학습 필요) 연관 노트: 상태 없는 서버로 CRM 지표 만들기
핵심 요약
- N+1은 id마다 개별 쿼리를 직접 날린다 → 네트워크 왕복이 N번, 쿼리 실행 전 붙는 고정비(플래닝·자원할당)도 N번.
- 단일 JOIN은 루프로 조회하는 게 아니라 id 집합을 통째로 넘겨 엔진 내부에서 해시 매칭 1패스로 끝낸다 → 왕복 1번, 고정비 1번.
- “매칭 일의 총량은 같으니 누가 하든 똑같다”는 틀렸다. 왕복 횟수·쿼리 고정비·알고리즘(N개 개별 조회 vs 1개 해시 조인)이 달라서 총 비용 자체가 다르다.
왜 헷갈렸나
두 가지로 막혔다.
- “결국 누가 하냐일 뿐, 매칭 계산량은 같은 거 아냐?” — 매칭 연산량만 보고 왕복·고정비를 안 셌다.
- “id를 N개 받았으면 각 id마다 조회(N회)는 필수 아냐?” — 이게 N+1 함정의 정체다. JOIN을 “루프 돌려 하나씩 찾는 것”으로 상상해서 나온 오해. id 집합을 한 쿼리에 통째로 넘길 수 있다는 걸 놓쳤다.
메커니즘
핵심은 JOIN은 루프가 아니라 집합 연산이라는 것. SQL은 선언형이라 “이 두 집합을 이 키로 맞춰줘”만 말하고, 어떻게 매칭할지는 엔진이 정한다(보통 해시 조인).
N+1 (나쁨) — 왕복 N번 + 고정비 N번
for id in ids:
SELECT ... WHERE key = id ← 매번 네트워크 왕복 + 쿼리 플래닝
배치/JOIN (좋음) — 왕복 1번 + 고정비 1번
SELECT ... WHERE key IN (id1..idN) ← 한 쿼리
또는 SELECT ... FROM a JOIN b ON a.k=b.k
해시 조인 내부 동작:
[id 집합] 해시테이블 = {101→행, 102→행, 103→행}
101 102 103 → 다른 쪽을 1패스 훑으며 O(1)로 probe
→ 엔진 메모리에서 끝. 네트워크 왕복 없음.
N+1은 이 해시 조인을 네트워크 너머에서, 그것도 제일 느린 nested-loop 방식으로 손수 재구현하는 꼴이다. “연산을 데이터 있는 곳(엔진)으로 보내는” 대신 “데이터를 매번 연산 쪽으로 끌어오는” 것 → 데이터 지역성(data locality)을 버린 셈.
해결 패턴
-- ❌ N+1
-- for id of ids: query(`... WHERE lead_id = ${id}`)
-- ✅ 집합을 한 번에
SELECT ... WHERE lead_id IN UNNEST(@ids) -- 파라미터로 배열 전달
-- 또는 원본 목록 테이블과 직접 JOIN
SELECT ... FROM leads l JOIN meta m ON m.lead_id = l.id
다음에 이 상황을 만나면
루프 안에 쿼리/네트워크 호출이 보이면 → 즉시 의심. “이 반복을 하나의 집합 연산으로 접을 수 있나?”
- 루프 안
await query(...)/fetch(...)있는가? - 반복 대상(id들)을 한 쿼리의
IN/JOIN으로 넘길 수 있는가? - 특히 OLAP/데이터웨어하우스(쿼리당 고정비 큼)에서는 N+1이 훨씬 치명적 — 우선 접기
커넥팅 닷
← 선행 개념 (이걸 알아야 이해된다)
- SQL JOIN = 집합 연산 — “루프로 하나씩”이 아니라 “두 집합을 키로 매칭”. 이 감각이 없으면 “N회 조회는 필수”라는 착각에서 못 벗어난다.
- 네트워크 왕복(RTT)과 지연 — N+1 비용의 본질이 계산이 아니라 I/O 왕복임을 이해하는 토대.
→ 확장 개념 (여기서 더 나아가면)
- 해시 조인 vs 중첩 루프 조인 — 왜 엔진 내부 매칭이 N번 개별 조회보다 빠른지 알고리즘 레벨.
- DataLoader 패턴 — 애플리케이션에서 N+1을 배치로 접는 대표 구현(GraphQL 등).
↔ 같은 원리가 적용되는 곳
- 상태 없는 서버로 CRM 지표 만들기 — 계산을 데이터(웨어하우스) 쪽에서 끝내는 같은 사고.
- “연산을 데이터로 보낼까, 데이터를 연산으로 가져올까” — Spark data locality, DB의 predicate/join pushdown이 다 같은 원리.
참고
- Use The Index, Luke — Nested Loops / Hash Join — 조인 알고리즘별 비용 감각