설문 조회 API 6개 + 엑셀 다운로드 + 답변 저장까지, 총 8개 엔드포인트에서 발생하던 N+1 / N×M 쿼리를 제거한 과정 정리. 최대 97.4% 쿼리 감소, 최대 33배 응답 속도 개선.
Node.js Sequelize PostgreSQL 성능 최적화
코드를 보면 딱 한 줄이라 별것 아닌 것 같지만, 루프 안에 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배로 쌓이면서 응답 시간이 폭발적으로 늘어난다.
| 엔드포인트 | 쿼리 수 (전 → 후) | 응답 시간 (전 → 후) | 쿼리 감소율 | 속도 개선 |
|---|---|---|---|---|
| 공개 설문 — 제목 검색 | 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× ⚠️ |
| 엔드포인트 | 쿼리 패턴 (전 → 후) | 개선 내용 |
|---|---|---|
| 엑셀 다운로드 | N×M → 3번 고정 | 이중 루프 내 DB 호출 제거 |
| 답변 저장 | N×M → 3번 고정 | 루프 전 배치 조회로 전환 |
⚠️ 내 폼 — 목록만 시간이 늘어난 이유 작성한 설문이 8개로 적은 상황이었다.
Answer COUNT를 8번 도는 것보다, 한 번의JOIN + GROUP BY가 옵티마이저 입장에서 더 무거운 케이스. 데이터가 늘어날수록 JOIN + GROUP BY가 압도적으로 유리해진다. 나머지 5개 엔드포인트 결과가 이를 뒷받침한다. 쿼리 수 자체는 9 → 4로 줄었기 때문에 DB 부하 관점에서는 일관되게 개선됨.
FormFlex에서 "설문 참여자 수"를 구하려면 단순히 Survey 테이블만 보면 안 된다.
Survey (설문)
└── Question (질문) [1:N]
└── Answer (답변) [1:N] ← 여기서 userId를 COUNT해야 참여자 수가 나옴
Survey 테이블에 참여자 수 컬럼이 없기 때문에 Answer → Question → Survey를 타고 들어가야 하는 구조다.