Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
증상: 집 100채 목록 페이지가 600ms 걸려요. 그런데 DB 로그를 보면 쿼리 하나는 0.4ms밖에 안 돼요. '빠른 쿼리 101개 = 느린 페이지'가 N+1의 전형적인 모습이에요. 느린 쿼리를 찾지 말고, 쿼리 '개수'부터 세는 게 첫 단서입니다.
✅ Fix: 느린 페이지 하나를 골라, 그 요청 한 번 동안 실행된 쿼리 개수를 세세요. 행 수(100)와 쿼리 수(101)가 거의 같으면 N+1입니다.
🔧 도구:Postgres log (log_min_duration_statement=0)APM 요청 트레이스
LOG: duration: 0.41 ms SELECT * FROM offers WHERE home_id = 101 LOG: duration: 0.39 ms SELECT * FROM offers WHERE home_id = 102 LOG: duration: 0.40 ms SELECT * FROM offers WHERE home_id = 103 -- ... same shape 97 more times. Only the id changes.
🗣 영어로 말해“Each query is fast, but the page is slow, so I count the queries first.”
checking microphone…
- 2
원인: 코드가 1) 집 100채를 한 번에 가져온 뒤, 2) for문 안에서 집마다 오퍼를 따로 물어봐요. 그래서 1 + 100 = 101번 왕복. 왕복 한 번이 1ms면 101ms, 네트워크가 5ms면 500ms가 돼요. ORM의 lazy loading이 이 반복문을 코드에서 안 보이게 숨겨놓는 게 함정입니다.
✅ Fix: 반복문 안에서 `home.offers`처럼 연관 데이터를 건드리는 줄을 찾으세요. 그 줄이 돌 때마다 쿼리가 한 번씩 나갑니다.
🔧 도구:ORM 쿼리 로그 (debug/echo 모드)
SELECT id, address FROM homes ORDER BY created_at DESC LIMIT 100; -- 1 query -- then the app loop runs this 100 times, once per home: SELECT * FROM offers WHERE home_id = $1;
🗣 영어로 말해“The ORM hides a loop: one list query, then one query per row.”
checking microphone…
- 3
눈으로 확인: pg_stat_statements에서 '호출 수는 엄청 많은데(48,210번) 평균은 0.4ms로 빠른' 쿼리를 찾으세요. 이게 N+1의 지문이에요. 자식 쿼리 calls(48,210)가 부모 쿼리 calls(482)의 딱 100배 — 페이지당 100번씩 불린 거죠. 느린 쿼리 Top 10만 보면 절대 안 잡혀요. 하나하나는 빠르니까요.
✅ Fix: calls 내림차순으로 정렬하세요. 1위 쿼리의 calls가 그 부모 목록 쿼리의 약 100배면 N+1 확정입니다.
🔧 도구:pg_stat_statements
SELECT calls, round(mean_exec_time, 2) AS ms, query FROM pg_stat_statements ORDER BY calls DESC LIMIT 2; calls | ms | query -------+------+------------------------------------------ 48210 | 0.41 | SELECT * FROM offers WHERE home_id = $1 482 | 1.20 | SELECT id, address FROM homes ...
🗣 영어로 말해“In pg_stat_statements, N-plus-one shows up as huge call counts with tiny mean times.”
checking microphone…
- 4
기본 해결: 101번 왕복을 2번으로 줄여요. 집 100채의 id를 배열로 모아 `WHERE home_id = ANY(...)` 한 방에 오퍼를 다 가져온 뒤, 앱에서 home_id별로 묶으면 끝. ORM이면 eager loading(include / joinedload / prefetch_related) 한 줄이에요. 결과: 600ms → 15ms.
⚖️ Trade-off: JOIN은 집 정보가 오퍼 개수만큼 복제돼 전송량이 늘어요. 한 집에 오퍼가 많으면 ANY() 두-쿼리 방식이 더 가볍습니다.
✅ Fix: ORM이면 eager loading 옵션 한 줄. 직접 SQL이면 id를 모아 ANY()로 한 번에 조회하고 앱에서 그룹핑하세요.
🔧 도구:ORM eager loadingANY()/IN 배치 조회
-- before: 100 round-trips. after: 1. SELECT * FROM offers WHERE home_id = ANY('{101,102, ... ,200}'::bigint[]); -- or eager-load with a join: SELECT h.id, h.address, o.id AS offer_id, o.amount FROM homes h LEFT JOIN offers o ON o.home_id = h.id;🗣 영어로 말해“I batch the hundred lookups into one query with an ANY or a join.”
checking microphone…
- 5
대규모 해결: 배치 쿼리도 인덱스가 없으면 오퍼 200만 건을 전부 훑어요(Seq Scan, 1.8초). `offers(home_id)` 인덱스를 만들면 Index Scan으로 380건만 읽어 2ms. 트래픽이 더 크면 DataLoader처럼 한 요청 안의 조회를 자동으로 묶고, 목록 화면에는 오퍼 '개수'만 비정규화 컬럼으로 두는 방법도 있어요.
⚖️ Trade-off: 인덱스는 INSERT/UPDATE마다 유지 비용이 붙고, 비정규화 카운트는 갱신 로직이 어긋나면 숫자가 틀어질 수 있어요.
✅ Fix: EXPLAIN ANALYZE로 Seq Scan인지 확인 → home_id 인덱스를 CONCURRENTLY로 무중단 추가 → 그래도 부족하면 DataLoader 배칭이나 카운트 비정규화로 갑니다.
🔧 도구:EXPLAIN ANALYZECREATE INDEX CONCURRENTLYDataLoader 패턴
CREATE INDEX CONCURRENTLY idx_offers_home_id ON offers (home_id); EXPLAIN ANALYZE SELECT * FROM offers WHERE home_id = ANY('{...}'); -- before: Seq Scan on offers (actual time=0.10..1840.55 rows=380) -- Rows Removed by Filter: 1999620 -- after: Index Scan using idx_offers_home_id on offers -- (actual time=0.05..2.10 rows=380)🗣 영어로 말해“At scale, the batch query needs an index, or it scans two million rows.”
checking microphone…
- 6
트레이드오프 정리: eager loading을 무조건 다 켜면 안 쓰는 데이터까지 끌어와 메모리를 낭비해요(over-fetching). JOIN은 행 복제, 캐시·비정규화는 신선도 문제. 그래서 규칙은 하나로 단순하게 — '요청 하나당 쿼리 수는 행 수와 무관하게 거의 상수(1~3개)'. 측정으로 잡힌 느린 경로만 고치세요.
⚖️ Trade-off: 모든 경로를 eager로 바꾸면 다른 화면이 괜히 무거워져요. 측정에 걸린 경로만 고치는 게 비용 대비 최선입니다.
✅ Fix: 수정 후 pg_stat_statements를 리셋하고 같은 페이지를 다시 돌리세요. 자식 쿼리 calls가 부모와 1:1이면 성공입니다.
🔧 도구:pg_stat_statements_reset()쿼리 수 회귀 체크
-- guardrail after the fix: re-measure the call ratio SELECT pg_stat_statements_reset(); -- run the page a few times, then: SELECT calls, query FROM pg_stat_statements WHERE query LIKE '%offers%' ORDER BY calls DESC; -- healthy: offers batch calls ~= homes list calls (1:1, not 100:1)
🗣 영어로 말해“My rule: queries per request should stay constant, not grow with the row count.”
checking microphone…
6단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.