--- id: db-n-plus-one title: N+1 쿼리 문제 — 감지와 해결 category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [database, orm, n-plus-one, performance, vibe-coding] tech_stack: { language: "SQL / Prisma / TypeORM / Sequelize", applicable_to: ["Backend"] } applied_in: [] aliases: [eager loading, dataloader, batch loading, JOIN vs IN] --- # N+1 쿼리 문제 > 1번 쿼리로 N건 가져오고, 각 건마다 추가 쿼리 N번 = N+1. ORM 의 lazy loading 디폴트가 주범. **감지(쿼리 로깅) → JOIN / IN / DataLoader** 3가지 해결책. ## 📖 핵심 개념 N+1 발생 패턴: ```ts const users = await db.users.findMany(); // 1 query → N rows for (const user of users) { user.orders = await db.orders.findMany({ where: { userId: user.id } }); // N queries } // 총 N+1 queries ``` 해결: 1. **JOIN** (Prisma `include`, TypeORM `leftJoinAndSelect`) 2. **IN list** (`WHERE userId IN (...)` 한 번에) 3. **DataLoader** (request-scoped batch + cache) ## 💻 코드 패턴 ### 1. Prisma include ```ts const users = await prisma.user.findMany({ include: { orders: true }, // JOIN 또는 별도 IN — Prisma 자동 }); // 2 queries 총 ``` ### 2. 직접 batch (raw SQL or query builder) ```ts const users = await db.users.findMany({ where: { active: true } }); const userIds = users.map(u => u.id); const orders = await db.orders.findMany({ where: { userId: { in: userIds } } }); // 그룹화 const ordersByUser = new Map(); for (const o of orders) { if (!ordersByUser.has(o.userId)) ordersByUser.set(o.userId, []); ordersByUser.get(o.userId)!.push(o); } const result = users.map(u => ({ ...u, orders: ordersByUser.get(u.id) ?? [] })); ``` ### 3. DataLoader — GraphQL / 복잡 그래프 ```ts import DataLoader from 'dataloader'; const orderLoader = new DataLoader(async (userIds: readonly string[]) => { const orders = await db.orders.findMany({ where: { userId: { in: [...userIds] } } }); const map = groupBy(orders, o => o.userId); return userIds.map(id => map.get(id) ?? []); }); // 어디서나 const myOrders = await orderLoader.load(user.id); // 같은 tick 의 모든 .load 호출이 한 batch 로 합쳐짐 ``` GraphQL resolver 안에서 매 user.orders 가 호출되어도 1 query 로 통합. ### 4. 감지 — 쿼리 카운트 ```ts // dev 미들웨어 — 한 요청 당 쿼리 수 로깅 let count = 0; prisma.$on('query', () => count++); app.use((req, res, next) => { count = 0; res.on('finish', () => { if (count > 20) log.warn('high query count', { url: req.url, count }); }); next(); }); ``` ## 🤔 의사결정 기준 | 상황 | 해결 | |---|---| | 단순 1:N (User → Orders) | `include` / IN batch | | 깊은 그래프 (User → Posts → Comments → Author) | DataLoader | | GraphQL | DataLoader 거의 필수 | | 매우 큰 IN (>10k items) | window / chunk 또는 join | | read replica 활용 | batched read | ## ❌ 안티패턴 - **for loop 안에서 await db.findOne**: 정확히 N+1 패턴. - **Promise.all([...users.map(u => fetchOrders(u.id))])**: 동시 N 쿼리. 빠르지만 DB 부하. - **JOIN 으로 모두 해결 시도**: Cartesian product. User 1 + Orders 100 + Items 1000 = 100,000 행 반환. - **Prisma include 무한 nested**: 응답 크기 폭발. 필요한 것만. - **DataLoader 인스턴스 공유**: cache 가 영구 → stale. request-scoped (req 마다 새로). - **DataLoader 가 빈 결과를 undefined**: 항상 input 길이와 같은 array 반환. null 또는 빈 array. - **쿼리 로그 미설정**: 발견조차 못 함. dev 항상 on. ## 🤖 LLM 활용 힌트 - "loop 안 await db query 금지. include / IN / DataLoader 중 선택" 강제. - GraphQL = DataLoader request-scoped 패턴. ## 🔗 관련 문서 - [[DB_Index_Strategy]] - [[DB_Connection_Pool]]