회사 SaaS를 오류 점검하다가 AI가 작성한 코드에서 구독·업무요청·이슈 목록의 N+1 문제를 발견했다.
N+1은 목록을 한 번 불러온 뒤(쿼리 1번), 그 목록의 항목마다 연관 데이터를 다시 조회하는(항목이 N개면 N번) 패턴이다. 항목이 늘어날수록 쿼리도 따라 늘어나는데, 화면에 필요한 값은 대개 한 번에 모아 올 수 있는 것들이다.
목록을 돌며 부른 async 변환 함수
세 곳 모두 DB 엔티티를 API 응답 모양으로 바꾸는 변환 함수(toItem)가 있었다. 조회한 엔티티 한 건을 클라이언트에 내려줄 형태로 가공하는 함수인데, async라는 건 안에서 await할 일이 있다는 뜻이고 대개 그 일은 DB 조회다. 그 async 함수를 목록의 항목마다 부르고 있었다.
private async toItem(sub: CompanySubscription) { // 구독 항목마다 여기서 담당자 조회 쿼리가 1번씩 const manager = sub.managerId ? await this.employeeRepo.findOne({ where: { id: sub.managerId } }) : null // ...}
return Promise.all(subs.map((s) => this.toItem(s))) // 항목 수만큼 쿼리목록을 돌면 항목 수만큼 쿼리가 난다.
목록이 50건이라면 병렬이라 지연이 안 쌓여 응답은 빨라 보인다. 그러나 로그를 켜면 같은 SELECT가 50번 찍힌다. N+1은 보통 응답이 느려져서 들키는데, 병렬로 쏘면 그 신호가 쉽게 보이지 않는다. Promise.all이 오히려 그 신호를 숨기고 있다.
쿼리가 무거울 때
구독에서 본 N+1은 업무요청·이슈 목록에도 똑같이 있었다. 원인은 같은데 고치는 방법은 달랐고, 무엇을 얼마나 가져오느냐에 따라 JOIN·집계·배치로 나뉘었다.
JOIN으로 한 번에 가져오기
구독 목록에서는 toItem이 항목마다 담당자를 따로 조회하고 있었다. 목록 쿼리에서 JOIN으로 같이 끌어오면 N+1문제가 해결된다.
const subs = await this.subscriptionRepo .createQueryBuilder("sub") .leftJoinAndSelect("sub.managers", "managers") .leftJoinAndSelect("managers.user", "managerUser") .where("sub.companyId = :companyId", { companyId }) .getMany()
return subs.map((s) => this.mapToItemSync(s)) // 이미 로드된 managers만 읽는다manager를 미리 붙이면 변환 함수 안에서 따로 조회할 게 없다. 안의 await가 사라지니 async일 이유도 없어져 동기 함수(mapToItemSync)가 되는데, 그게 목록당 쿼리가 사라졌다는 신호다. 구독이 50건이면 51번이던 쿼리가 1번이 된다.
여기까지는 N+1의 정석적인 해결 방법이다.
일대다에 JOIN을 붙이면 생기는 행 증식
leftJoinAndSelect로 일대다를 붙이면 부모 행이 자식 수만큼 곱해진다.
이슈 1건에 댓글 50개가 달려 있으면 조회 결과가 50행으로 불어난다. ORM이 그 50행을 다시 이슈 하나로 합쳐주긴 하지만, 정작 화면에 필요한 건 댓글 수 하나인데 본문 50개가 통째로 딸려 온다.
이 때문에 페이징에 문제가 발생한다. LIMIT 20은 부모 20건이 아니라 곱해진 행 20개를 자른다. TypeORM 문서에 이 동작을 두고 경고가 붙어 있다.
이슈 1건에 댓글 50개를 leftJoinAndSelect 하면
이슈 A · 댓글 1 이슈 A · 댓글 2 이슈 A · … ← 같은 이슈가 댓글 수만큼 반복된 50행 이슈 A · 댓글 50
LIMIT 20 → 이 50행 중 앞 20행을 자른다 = 실제로 담긴 이슈는 A 하나뿐take and skip may look like we are using limit and offset, but they aren’t. limit and offset may not work as you expect once you have more complicated queries with joins or subqueries. Using take and skip will prevent those issues.
— Select using Query Builder, TypeORM Documentation
take는 부모 ID를 먼저 뽑는 쿼리를 따로 돌리고 그 ID들로 본 쿼리를 거는 식이라 행 증식의 영향을 안 받는다. 다만 그건 페이징을 구하는 것이지 실어 나르는 양을 줄이는 건 아니라서, 댓글 50개가 이슈마다 같이 실려 오는 문제는 그대로다.
행 증식만 놓고 보면 일대다에는 JOIN을 붙이면 안 될 것 같다. 그런데 앞의 구독 담당자는 leftJoinAndSelect로 붙였는데 문제가 없었다. 담당자도 구독에 대해 일대다인데 말이다.
@OneToMany(() => CompanySubscriptionManager, (mgr) => mgr.subscription, { cascade: true })managers!: CompanySubscriptionManager[];| 구독 담당자 | 이슈 댓글 | |
|---|---|---|
| 자식이 몇 개 매달리나 | 몇 개 | 수백 |
| 목록에서 자식을 다 쓰나 | 이름을 전부 띄운다 | 개수만 쓴다 |
LIMIT이 걸리나 | 없다(전체 조회) | 걸린다 |
담당자가 둘이면 행이 그만큼 늘어나는데, 어차피 다 보여줄 거라 그 두 배는 낭비가 아니다.
반대로 이슈 댓글은 세 칸이 다 반대라 수백 배로 불어나고, 정작 쓰는 건 숫자 하나고, 페이징에도 문제가 생긴다.
결국 JOIN을 붙일지는 일대다냐 아니냐로 정해지지 않는다. 자식이 몇 배로 불어나는지, 그걸 목록에서 다 쓰는지가 정한다.
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}만 돌려받는다. 댓글이 이슈당 수백 개여도 네트워크로 오는 건 이슈 수만큼의 숫자뿐이다.
배치 IN으로 모아 Map에 담기
업무요청 목록은 JOIN도 항목별 조회도 맞지 않는 경우였다. 이체상세는 카테고리가 ‘이체’인 항목에만 있고, 그나마 마스터 템플릿 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)에 매칭한다.
쿼리를 너무 자주 부를 때
이번엔 몇 번 부르는지가 문제다.
함수가 아니라 호출부에서 쏟아진 경우
이번에는 함수가 이미 옳게 짜여 있는데도 쿼리가 쏟아졌다.
일일 알림 배치의 디스패치 서비스는 배치 조회를 정확히 하고 있었다.
const employees = await this.employeeRepo.find({ where: { companyId, ... } });const connections = await this.userMessengerRepo.find({ where: { userId: In(userIds), ... } });
// 설정 일괄 조회 (N+1 방지)const settings = await this.notifSettingRepo.find({ where: { employeeId: In(employeeIds) } });문제는 호출부였다.
for (const doc of documents) { // 만료 "행" 단위 루프 await this.dispatch.sendToCompanyByCategory(doc.companyId, 'docExpire', ...); await this.dispatch.sendToCompanyAdvisorsByCategory(doc.companyId, 'docExpire', ...); await this.saveInAppNotifsForCompany(doc.companyId, ...);}한 회사가 문서 10건을 같은 날 만료시키면 동일한 직원·메신저 연동·알림 설정을 10번 다시 읽는다. 서비스 내부의 배치 조회는 완벽했지만, 호출부가 행 단위로 부르는 순간 그 배치는 통째로 무의미해진다.
배치 헬퍼가 몇 쿼리를 쓰는가는 그 헬퍼가 결정하지만, 몇 번 불리는가는 호출부가 결정한다. 후자가 앞의 이득을 전부 지울 수 있다.
호출 대상이 회사나 사용자 단위로 한 번 해석하면 되는 것(수신자·권한·설정)인데 루프 안에서 부르고 있다면 사실상 이 문제가 그대로 난다. 앞의 세 해결방법이 “무엇을 가져오는가”에 주목하여 문제를 해결했다면 이건 “몇 번 부르는가”에 집중해서 해결해야 하는 문제다.
수신자 해석과 발송의 분리
기준은 “수신자 해석(resolve)“과 “발송(send)“의 분리다.
// 1) 해석: 회사당 1회면 충분한 것들 (회사명 + 직원 + 연동 + 설정)async resolveCompanyTargets(companyId, category, roles?): Promise<NotifyTargets>
// 2) 발송: 이미 해석된 대상에게 보낸다 (쿼리 0)async sendToTargets(targets, message, buttons?): Promise<void>
// 3) 기존 공개 메서드는 시그니처·동작 그대로 = resolve + send 조합async sendToCompanyByCategory(...) { const targets = await this.resolveCompanyTargets(...); await this.sendToTargets(targets, message, buttons);}호출부는 만료 행을 companyId로 group by 한 뒤 회사당 1회만 해석하고 메시지만 반복 발송한다.
기존 공개 메서드를 그대로 둬야 했다. 배치 밖의 다른 호출처가 많은데 시그니처를 바꾸면 회귀 표면이 커진다.
새 API를 추가하고 기존 메서드를 그 조합으로 재작성하면, 이번 최적화가 건드리는 코드는 배치 호출부로 한정된다.
같은 패턴이 연차 자동적립 배치에도 있었다. 직원 한 명당 무조건 트랜잭션을 열고 그 안에서 원장을 조회해 “이미 부여됨”이면 건너뛰는 구조라, 트랜잭션이 매일 회사 수 × 직원 수만큼 열렸다. 부여가 실제로 일어나는 날은 직원당 1년에 며칠뿐인데도 그랬다. 부여 대상 판정을 DB 접근이 없는 순수 함수로 뽑고 기존 부여분을 In() 배치로 확인한 뒤 진짜 부여 대상만 트랜잭션에 넣으니, 직원 50명에 부여 0건인 날 기준으로 트랜잭션 50개가 쿼리 2회로 줄었다.
다만 트랜잭션 밖의 사전 판정은 최적화일 뿐 동시성 방어를 대체하지 않는다. 겹쳐 도는 두 실행이 둘 다 “아직 부여 안 됨”을 읽고 각자 부여할 수 있어서, 트랜잭션 안의 멱등 재확인과 occurrence_key UNIQUE는 그대로 둔다(그 경위는 위 글에 적었다).
쿼리 수를 세는 가드
통합 테스트에서 TypeORM 로거를 갈아끼우면 실제 MySQL 쿼리를 셀 수 있다. 계약은 “쿼리 수가 행 수가 아니라 회사 수에 비례한다”이고, 고치기 전 구현은 문서 1건에 5쿼리·10건에 50쿼리로 선형 증가했지만 새 구현은 행 수와 무관하게 평평하다. 두 방향을 다 재야 ‘가드 통과’가 ‘구조 유지’를 뜻한다.
db.dataSource.logger = { logQuery: (q) => { if (collecting) collected.push(q); }, ... };
// 계약: 쿼리 수는 행 수가 아니라 회사 수에 비례해야 한다expect(withTenRows - withOneRow).toBeLessThanOrEqual(2);이 잡이 매 배포마다 실 MySQL로 돌아, 루프 안에서 resolve를 다시 부르는 커밋은 배포 전에 게이트에서 걸린다.
가드가 이미 짜인 코드를 배포 때 잡는다면, 애초에 안 짜이게 하는 쪽도 뒀다. 같은 N+1이 AI가 짠 코드에서 여러 번 반복됐으니, 이 사례들을 코딩 규칙으로 정리해 새 코드가 같은 패턴을 다시 만들지 않게 했다.
정리
N+1을 보면 JOIN부터 떠올리기 쉽지만, 위와 같이 4개의 사례로 나눌 수 있다. 쿼리 하나가 무거우면 자식이 몇 배로 불어나는지와 그걸 다 쓰는지에 따라 JOIN·집계·배치 중에 골랐다. 자주 부르는 쪽은 다른 문제였다. 배치를 옳게 한 함수도 호출부가 루프에서 잘못 부르면 소용 없는 경우가 발생하기 마련이다.
이 사례들이 N+1의 전부는 아니다. 목록을 돌거나 배치를 부르는 코드는 어디서든 같은 함정을 낼 수 있어서, 리뷰할 때마다 쿼리가 항목 수를 따라 늘지 않는지는 항상 확인해야 한다.