Project
통신사 B2B SaaS 어드민
Work
백오피스 대시보드·통계·가입자 조회 화면에서 200만 회원 규모의 데이터를 처리
역할
풀스택 개발
기간
2025.11 ~ 2026.03
스택
JavaScript, Node.js, Express, Sequalize, Postgresql
백오피스 대시보드·통계·가입자 조회 화면에서 200만 회원 규모의 데이터를 처리하는 과정에 심각한 성능 저하가 발생했습니다. CROSS JOIN + 상관 서브쿼리 구조, 인덱스 부재, 순차 쿼리 실행 등 복합적인 원인을 EXPLAIN ANALYZE로 특정하고 쿼리 구조를 재설계하여 가입자 통계 API 6.6분 → 4초, 대시보드 조회 API 1.9분 → 1초, 가입자 조회 API 11초 → 0.2초로 개선했습니다.
1. 가입자 통계 API 성능 개선
블로그 — 3일 동안 쿼리 튜닝만 한 스토리 (1)
문제 상황
가입자 통계 화면 진입 시 6.6분 무응답 발생 (200만 회원 기준)
원인 분석
- CROSS JOIN + 상관 서브쿼리 구조로 3,700만 회 서브쿼리 실행
- 전체 유저(200만) × 날짜(2일) CROSS JOIN 후, 각 행마다 상관 서브쿼리 2개 실행
EXPLAIN ANALYZE결과:loops=7,963,720(43만 유저 기준)
- 날짜별 순차 루프로 DB 쿼리 약 40회 순차 실행 (화면 진입 시 API 2회 호출 × 날짜당 ~20회)
DATE(created_at + INTERVAL '9 hours')함수 적용으로 인덱스 무력화 → 풀스캔 4회- 항상 빈 결과를 반환하는 죽은 CTE 5개
해결 방식
- 스냅샷 + LATERAL: 기준일 총이용자를 1회 스냅샷으로 확보 → 이후는 변경 유저만 LATERAL로 조회
- O(전체유저 × 날짜) → O(변경유저 × 날짜) 로 스케일링 특성 변경
- 상관 서브쿼리 3,700만 회 → 0회
- 날짜 범위 조건 전환:
DATE(컬럼)→created_at >= ... AND created_at < ...으로 인덱스 활용 - 연속 날짜 그룹화: 누락 날짜를 연속 구간별로 묶어 SQL 호출 최소화
- Batch INSERT: 날짜별 개별 INSERT →
multi-row INSERT ... ON CONFLICT1회
결과
기존: 6.6분 (200만 회원)
개선: 4초
향상률: 99% 개선
2. 대시보드 Summary API 성능 개선
블로그 — 3일 동안 쿼리 튜닝만 한 스토리 (2)
문제 상황
백오피스 대시보드 진입 시 1.9분 무응답 발생 (200만 회원 기준)
원인 분석
- 267줄 단일 CTE 쿼리에서 전체 유저 200만 명 대상 상관 서브쿼리 수백만 회 실행
user_status_histories전체 이력을 뒤져 현재 가입 상태를 계산 →users.join_type에 이미 저장된 값- 30일 유지 고객 계산도 동일하게 이력에서 재계산 →
users.updated_at으로 대체 가능
- 서로 의존성 없는 5개 쿼리를 순차
await로 실행 (총 응답시간 = 각 쿼리 시간의 합) - 월별 누적 통계를 매번 전체 유저 × 5개월 CROSS JOIN으로 계산 →
join_statistics_daily캐시 테이블에 이미 데이터 존재
해결 방식
users테이블 직접 조회: 현재 상태(join_type), 30일 유지(updated_at) → 상관 서브쿼리 수백만 회 → 0회- 오늘 범위만 스캔: 오늘 상태 변경 이벤트는
user_status_histories의 오늘 범위(~12,000건)만 조회 - 캐시 테이블 활용: 월별 통계 →
join_statistics_daily캐시 조회로 전환 Promise.all2레벨 병렬화: 5개 쿼리 순차 → 최대 9개 동시 실행 (커넥션 풀 크기 확인 후 적용)
결과
기존: 1.9분 (200만 회원 기준)
개선: 1초
향상률: 99% 개선
3. 가입자 조회 API 성능 개선
블로그 — 3일 동안 쿼리 튜닝만 한 스토리 (3)
문제 상황
가입자 관리 화면(페이지당 10건) 진입 시 11초 소요 (200만 회원 기준)
원인 분석
- UNION ALL 전체를 서브쿼리로 감싼 뒤 외부에서 LIMIT 적용 → PostgreSQL이 LIMIT을 안쪽으로 push-down 불가, 전체 집계 후 10건만 반환
- 데이터 쿼리 + COUNT 쿼리를 동일한 UNION ALL + GROUP BY로 순차 2회 실행
(user_id, after_status, created_at)복합 인덱스 부재 → LEFT JOIN + EXISTS 서브쿼리마다 이력 전체 스캔LEFT JOIN + GROUP BY + MAX(CASE WHEN)패턴으로 이력 전체가 JOIN 후 팽창 → GROUP BY로 축소
해결 방식
- 복합 인덱스 추가:
(user_id, after_status, created_at DESC)—CONCURRENTLY옵션으로 서비스 중단 없이 적용 Promise.all병렬화: 데이터 쿼리 + COUNT 쿼리 동시 실행- LATERAL JOIN 전환:
LEFT JOIN + GROUP BY→LEFT JOIN LATERAL로 유저당 필요한 값만 직접 조회 (행 팽창 제거)
결과
기존: 11초 (200만 회원 기준)
개선: 264ms
향상률: 97% 개선
결과
통계 API 성능 99% 개선, 대시보드 API 성능 99% 개선, 가입자 조회 API 97% 개선