N+1을 한 번 고쳐 보면 다음부터는 같은 패턴으로 또 만난다. 같은 SaaS를 만들면서 구독 목록, 업무요청 목록, 이슈 목록에서 각각 N+1을 잡았는데, 발견하는 신호는 매번 똑같았고 처방은 관계의 모양에 따라 셋으로 갈렸다.
발견하는 신호는 하나다
세 곳 모두 코드에서 먼저 냄새가 났다. DB 엔티티를 API 응답 모양으로 바꾸는 변환 함수(toItem)가 async인데, 그걸 목록 순회에 쓴다.
return Promise.all(items.map((x) => this.toItem(x))); // toItem이 asynctoItem은 조회한 엔티티 한 건을 클라이언트에 내려줄 형태(예: SubscriptionItem)로 가공하는 함수다. 이게 async라는 건 안에서 await할 일(대개 DB 조회)이 있다는 뜻이고, 목록을 돌면 항목 수만큼 쿼리가 난다.
여기서 Promise.all이 함정을 하나 더 깐다. 그건 쿼리들을 병렬로 쏠 뿐 개수를 줄이지 않는다. 병렬이라 지연이 안 쌓여 응답은 빨라 보이는데, 로그를 켜면 같은 SELECT가 50번 찍힌다. N+1은 보통 “느려서” 들키는데, 이건 안 느려서 안 들킨다. 그러다 트래픽이 늘면 50개가 같은 순간 DB로 쏟아져 응답 지연이 아니라 커넥션 풀 고갈로 터진다. 빠른 게 가장 위험한 상태다. 그래서 코드의 신호를 먼저 읽는 게 중요하다.
신호는 같지만, 고치는 법은 무엇을 가져오려다 N+1이 났는지에 따라 다르다.
반사적 답은 JOIN — 다대일일 때만
구독 목록의 toItem은 항목마다 담당자(manager)를 findOne으로 따로 조회하고 있었다. 다대일이고 목록에서 늘 보여줘야 하는 관계. 이건 목록 쿼리에서 JOIN으로 같이 끌어오면 끝난다.
const subs = await this.subscriptionRepository .createQueryBuilder("sub") .leftJoinAndSelect("sub.manager", "manager") .leftJoinAndSelect("manager.user", "managerUser") .where("sub.companyId = :companyId", { companyId }) .getMany();
return subs.map((s) => this.toItemSync(s)); // 이미 로드된 manager만 읽는다manager를 미리 붙이면 변환 함수는 더 DB를 안 친다. async toItem이 sync toItemSync로 바뀌는 게 그 신호다. 51 → 1.
여기까지는 N+1의 교과서적 처방이다. 진짜 문제는 이 JOIN이 기본값처럼 쓰일 때 생긴다.
일대다에 JOIN을 붙이면 N+1이 행 증식으로 바뀐다
JOIN이 답인 건 부모에 자식 하나가 매달리는 다대일일 때다. 일대다를 leftJoinAndSelect하면 부모 행이 자식 수만큼 곱해진다. 이슈 1건에 댓글 50개면 조회 결과가 50행으로 불어나고, ORM이 메모리에서 그걸 다시 이슈 하나로 합친다. 댓글 수만 필요한데 댓글 50개를 이슈마다 실어 나르는 셈이고, LIMIT을 걸면 곱해진 행 수가 어긋나 페이징까지 깨진다.
N+1을 없애려다 행 증식으로 문제를 옮긴 꼴이다. 그래서 JOIN을 반사적으로 붙이기 전에 관계의 모양을 본다. 선택적으로 매달리는 자식인지, 일대다인지, 아니면 개수만 필요한지. 모양마다 처방이 다르다.
선택적이거나 일대다면 — 배치 IN + Map
업무요청 목록이 그랬다. 이체상세는 카테고리가 ‘이체’인 항목에만 있고, 그나마 마스터 템플릿 ID로 우회 참조한다. 전부 JOIN하면 필요 없는 행까지 끌고 오고, 분기 조건이 쿼리에 엉킨다. 그래서 필요한 ID만 모아 한 번에 가져오고, 메모리에서 맞췄다.
const transferLookupIds = raw .filter((r) => r.wr_category === "이체") .map((r) => r.wr_master_template_id || r.wr_id);
const transferDetails = await this.transferDetailRepo.find({ where: { workRequestId: In([...new Set(transferLookupIds)]) }, select: ["workRequestId", "amount", "destinationBankName"],});
const transferMap = new Map(transferDetails.map((t) => [t.workRequestId, t]));// 순회는 메모리에서: transferMap.get(id)쿼리는 항상 2번. 목록 1, 자식 1. 항목이 10건이든 500건이든 2번이다. Set으로 중복 ID를 접고, Map으로 O(1) 매칭한다.
개수·합계만 필요하면 — GROUP BY
앞에서 “이슈에 댓글을 JOIN하지 말라”던 그 경우다. 이슈 목록에 댓글 수를 띄우는데, 흔한 실수는 댓글을 다 가져와 .length를 세는 것이다. 본문은 필요 없고 숫자만 필요한데 자식 행을 통째로 끌어오는 꼴이다. 개수는 DB가 세게 둔다.
const commentCounts = await this.commentRepo .createQueryBuilder("c") .select("c.issueId", "issueId") .addSelect("COUNT(*)", "cnt") .where("c.issueId IN (:...ids)", { ids }) .andWhere("c.deletedAt IS NULL") .groupBy("c.issueId") .getRawMany<{ issueId: string; cnt: string }>();
const countMap = new Map(commentCounts.map((r) => [r.issueId, parseInt(r.cnt, 10)]));쿼리 1번에 {issueId, cnt}만 돌려받는다. 댓글이 이슈당 수백 개여도 네트워크로 오는 건 이슈 수만큼의 숫자뿐이다.
그래서
N+1은 “JOIN 붙이면 끝”이 아니다. JOIN은 다대일에 맞는 한 가지 도구일 뿐, 일대다에 들이대면 N+1을 행 증식으로 바꿔 문제를 자리만 옮긴다. 발견은 async 변환 함수가 목록을 도는 코드 한 줄에서 늘 똑같이 시작하지만, 처방은 무엇을 가져오려 했는지(부모냐, 선택적 자식이냐, 개수냐)를 묻고 나서야 갈린다.