설문 조회 API 6개 + 엑셀 다운로드 + 답변 저장까지, 총 8개 엔드포인트에서 발생하던 N+1 / N×M 쿼리를 제거한 과정 정리. 최대 97.4% 쿼리 감소, 최대 33배 응답 속도 개선.

Node.js Sequelize PostgreSQL 성능 최적화


N+1 문제란?

코드를 보면 딱 한 줄이라 별것 아닌 것 같지만, 루프 안에 DB 조회가 들어가는 순간 설문 수만큼 쿼리가 추가로 발생하는 문제다.

const surveys = await Survey.findAll(...)  // 1번

for (const survey of surveys) {
  await Answer.count({ where: { surveyId: survey.id } }) // N번 추가 발생
}
// 설문 50개 → 총 51번 쿼리

1번이면 될 것을 N+1번 DB에 왕복하는 구조다. 네트워크 왕복 비용 + DB 처리 비용이 N배로 쌓이면서 응답 시간이 폭발적으로 늘어난다.


📊 한눈에 보는 결과

조회 API

엔드포인트 쿼리 수 (전 → 후) 응답 시간 (전 → 후) 쿼리 감소율 속도 개선
공개 설문 — 제목 검색 151 → 4 22,196ms → 658ms −97.4% 33.7×
공개 설문 — 목록 153 → 3 12,709ms → 419ms −98.0% 30.3×
내 응답 — 제목 검색 104 → 7 12,550ms → 980ms −93.3% 12.8×
내 응답 — 목록 43 → 6 3,973ms → 561ms −86.0% 7.1×
내 폼 — 제목 검색 22 → 4 1,815ms → 336ms −81.8% 5.4×
내 폼 — 목록 9 → 4 746ms → 1,311ms −55.6% 0.6× ⚠️

쓰기 / 다운로드 API

엔드포인트 쿼리 패턴 (전 → 후) 개선 내용
엑셀 다운로드 N×M → 3번 고정 이중 루프 내 DB 호출 제거
답변 저장 N×M → 3번 고정 루프 전 배치 조회로 전환

⚠️ 내 폼 — 목록만 시간이 늘어난 이유 작성한 설문이 8개로 적은 상황이었다. Answer COUNT를 8번 도는 것보다, 한 번의 JOIN + GROUP BY가 옵티마이저 입장에서 더 무거운 케이스. 데이터가 늘어날수록 JOIN + GROUP BY가 압도적으로 유리해진다. 나머지 5개 엔드포인트 결과가 이를 뒷받침한다. 쿼리 수 자체는 9 → 4로 줄었기 때문에 DB 부하 관점에서는 일관되게 개선됨.


🤔 무엇이 문제였나

FormFlex의 데이터 구조

FormFlex에서 "설문 참여자 수"를 구하려면 단순히 Survey 테이블만 보면 안 된다.

Survey (설문)
  └── Question (질문) [1:N]
        └── Answer (답변) [1:N]  ← 여기서 userId를 COUNT해야 참여자 수가 나옴

Survey 테이블에 참여자 수 컬럼이 없기 때문에 Answer → Question → Survey를 타고 들어가야 하는 구조다.