설문 조회 API 6개에서 발생하던 N+1 쿼리를 제거해 최대 97.4% 쿼리 감소, 최대 33배 응답 속도 개선을 달성한 과정 정리.
Node.js Sequelize PostgreSQL 성능 최적화
| 엔드포인트 | 쿼리 수 (전 → 후) | 응답 시간 (전 → 후) | 쿼리 감소율 | 속도 개선 |
|---|---|---|---|---|
| 공개 설문 — 제목 검색 | 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× ⚠️ |
⚠️ 마지막 케이스만 시간이 늘어난 이유
작성한 설문이 8개로 적은 상황이었음.
Answer COUNT를 8번 도는 것보다, 한 번의JOIN + GROUP BY가 옵티마이저 입장에서 더 무거운 작업이 되는 케이스. 데이터가 늘어날수록(N이 커질수록) 후자가 압도적으로 유리해짐. 다른 5개 엔드포인트 결과가 이를 뒷받침. 쿼리 수 자체는 9 → 4로 줄었기 때문에 DB 부하 관점에서는 일관되게 개선된 결과.
FormFlex의 설문 목록 API는 응답마다 각 설문의 참여자 수와 내 참여 여부를 함께 내려줘야 했음. 처음 코드는 이런 패턴이었음:
// 1) 설문 목록을 한 번에 조회
const surveys = await Survey.findAll({ where: { userId } });
// 2) 설문마다 루프를 돌면서 추가 쿼리 실행 ← 여기가 N+1
for (const survey of surveys) {
const attendCount = await Answer.count({ /* surveyId 기준 */ });
const myAnswer = await Answer.findOne({ /* userId, surveyId */ });
}
설문이 50개면 1 + 50 × 2 = 101번의 쿼리가 발생. 실측에서 공개 설문 검색 API는 한 번 호출에 151번의 쿼리, 22초의 응답 시간이 걸리는 상황이었음.
Survey 테이블에는 참여자 수 컬럼이 없음. Answer → Question → Survey를 타고 들어가야만 알 수 있는 구조.Survey.findOne까지 추가되어 3N + 1 패턴이 됨.findOne을 1개의 findAll({ where: { id: [...] } })로 대체