07-performance-n-plus-1 — 성능 & N+1
이 챕터가 답하는 질문: TypeORM은 왜 N+1이 기본값인가? 그리고 어디서 어떻게 명시적으로 끄는가? 한 줄 답 (Pyramid Top): “TypeORM에서 N+1은 버그가 아니라 기본 동작이다 —
relations옵션,leftJoinAndSelect,loadRelationCountAndMap, 쿼리 캐시 중 하나를 의식적으로 꽂아야만 사라진다.”
한 문장 답 (Pyramid Top)
TypeORM은 데코레이터로 메타데이터를 박아 SQL을 늦게 만드는 DataMapper ORM이다 — 그 늦음의 대가가 N+1이라는 기본값이다.
user.posts처럼 관계를 그냥 접근하면 SQL이 N번 나간다. 그것을 막는 길은 네 갈래다 —relations옵션으로 join 한 방을 강제하거나,QueryBuilder로 select column을 명시하거나,cache: true로 반복 호출을 흡수하거나,loadRelationCountAndMap처럼 count 전용 IN 배칭을 쓰는 것. 이 챕터는 그 네 갈래를 발사되는 SQL과 함께 한 층씩 본다.
챕터 지도 (Mermaid)
Why — 왜 이 챕터를 별도로 빼는가
세 가지 잘못된 직관이 거의 모든 TypeORM 성능 사고의 출처다.
| 잘못된 직관 | 실제 | 어디서 다루나 |
|---|---|---|
| ”TypeORM이 알아서 join해줄 거다” | 아니다. relations 옵션을 명시적으로 안 주면 lazy로 N+1이 난다. | 01, 02 |
”eager: true만 켜면 끝이다” | eager는 모든 곳에서 항상 join이 붙어 원치 않는 곳까지 비용을 만든다. | 01 |
”leftJoinAndSelect는 안전한 한 방이다” | deep relation에서는 행 수가 곱셈된다 — 카르테시안 폭발. | 03 |
| ”캐시만 깔면 성능은 해결” | 캐시는 무효화 전략 없이는 폭탄이다. | 04 |
| ”성능 문제는 코드 본 다음에 안다” | 아니다. 로깅을 먼저 켜야 어디서 무엇이 발사되는지 보인다. | 06 |
이 챕터는 N+1의 진단 → 치료 → 예방 → 가시화 네 층으로 나눠 다룬다.
How — 어떻게 읽나
| # | 파일 | 읽는 데 | 핵심 키워드 |
|---|---|---|---|
| 01 | 01-n-plus-1-pattern.mdx | 12분 | eager/lazy 기본값 · find().posts · resolver 없이도 N+1 |
| 02 | 02-relations-option.mdx | 12분 | relations 옵션 · LEFT JOIN 한 방 · selection 최소화 |
| 03 | 03-query-builder-join-and-select.mdx | 14분 | leftJoinAndSelect · 카르테시안 곱 · 엔티티 트리 매핑 |
| 04 | 04-query-cache.mdx | 10분 | cache: true · Redis · clearCache · invalidate |
| 05 | 05-batching-and-loadRelationCountAndMap.mdx | 10분 | loadRelationCountAndMap · WHERE id IN (…) · count-only |
| 06 | 06-logging-and-profiling.mdx | 10분 | logging: [‘query’, ‘slow’, ‘error’] · maxQueryExecutionTime · EXPLAIN |
의존성: 02~05는 01의 정의를 가정한다. 06은 어디서든 먼저 켜는 것이 좋다.
What — 한 페이지 요약 (모든 문서의 핵심 한 줄)
| 문서 | 한 줄 결론 |
|---|---|
| 01 | TypeORM에서 N+1은 기본값이다 — relations/eager/join을 명시하지 않으면 lazy 접근마다 SQL이 새로 나간다. |
| 02 | find 호출에 relations 옵션을 주면 내부적으로 LEFT JOIN 한 방으로 푼다 — 단, 원치 않는 컬럼까지 다 가져온다. |
| 03 | leftJoinAndSelect는 행 수가 곱셈되는 카르테시안 곱을 만든다 — users(100) × posts(50)이면 5000행을 메모리에 적재한다. |
| 04 | cache: true는 같은 쿼리에 대한 결과를 보관한다 — Redis 통합과 invalidate 키가 없으면 stale 데이터의 출처가 된다. |
| 05 | loadRelationCountAndMap은 count 전용 IN 배칭이다 — 자식 내용이 필요 없는 자리에서 가장 싼 길이다. |
| 06 | logging: ['query', 'slow', 'error']와 maxQueryExecutionTime을 켜는 그 한 줄이 모든 디버깅의 출발점이다. |
What-if — 이 챕터를 건너뛰면
01(정의)만 알고02(relations)를 모르면: lazy 접근을 손으로 await 루프에 욱여넣어 N+1을 그대로 재현한다.02(relations)만 알고03(QueryBuilder)을 모르면: 4-depth deep relation을 한 번에 부르고 메모리 폭발로 OOM을 만난다.03까지 알고04(cache)를 모르면: 같은 쿼리를 분당 1000번 발사하면서 DB CPU 100%의 출처를 못 찾는다.04까지 알고05(count map)를 모르면: 댓글 수 하나 보여주려고 모든 댓글 본문까지 가져온다.05까지 알고06(logging)을 모르면: 어디서 무엇이 발사되는지 추측만 하다 엉뚱한 곳을 최적화한다.
Insight — 한 단락 이야기
“TypeORM의 N+1은 ORM 30년 역사가 만든 기본값이다”
Hibernate가 2003년
fetch="lazy"를 디폴트로 정한 그 순간부터 — N+1은 모든 ORM의 운명이 되었다. TypeORM은 그 전통을 그대로 계승한다. 데코레이터로@OneToMany를 박는 순간, 그 관계는 접근될 때까지 SQL을 미루는 약속이 된다. 이것은 버그가 아니라 OOP가 SQL을 다루는 대가다. 2015년 Facebook이 DataLoader로 GraphQL의 N+1을 한 tick 안에서 batch로 흡수했듯, TypeORM은relations/leftJoinAndSelect/loadRelationCountAndMap이라는 세 가지 명시 도구로 그것을 다스린다 — 단, 셋 모두 개발자가 의식적으로 호출해야 한다. TypeORM은 N+1을 없애지 않는다 — 그것을 명시적으로 끄는 도구를 제공할 뿐이다. 이 챕터가 하는 일은 그 명시 도구들이 내부적으로 어떤 SQL을 발사하는지를 6층으로 분해하는 것.
Mermaid 4색 규약
한 단락 요약
TypeORM에서 N+1은 기본값이다(
01). 그것을 끄는 길은 네 갈래 —relations옵션의 암묵적 join(02),leftJoinAndSelect의 명시적 join + 카르테시안 위험(03),cache의 반복 호출 흡수(04),loadRelationCountAndMap의 count-only 배칭(05). 그 모든 것의 출발점은logging: ['query', 'slow']한 줄이다(06). 이 챕터를 끝내면 “왜 DB가 느리지?” 라는 질문 대신 “내 find는 어떤 join을 발사하고, 어떤 카르테시안을 만드는가?” 라는 질문을 던지게 된다. 다음 챕터(08-theory-and-alternatives)는 Prisma와 Drizzle이 같은 문제를 어떻게 다른 추상으로 푸는지를 본다.