초기 데이터 1,000건:
목록 조회 빠름
운영 데이터 100,000건:
검색/필터/정렬 느려짐
운영 데이터 1,000,000건:
count, join, like, export에서 병목 발생
관리자 목록 로딩 지연
상담 처리 속도 저하
API timeout
DB CPU 증가
전체 서비스 응답 지연
엑셀 다운로드 실패
장애 원인 추적 어려움
1. 느린 API 확인
2. 실제 SQL 확인
3. EXPLAIN으로 실행 계획 확인
4. 병목 원인 파악
5. 인덱스/쿼리/응답 구조 개선
6. 개선 전후 비교
어떤 API가 느린가?
어떤 query parameter에서 느린가?
데이터가 몇 건인가?
where 조건은 무엇인가?
orderBy는 무엇인가?
include/join이 많은가?
count가 느린가?
프론트 렌더링이 느린 것은 아닌가?
EXPLAIN
SELECT *
FROM consults
WHERE status = 'NEW'
ORDER BY created_at DESC
LIMIT 20;
EXPLAIN은 예상 실행 계획을 보여줍니다.EXPLAIN ANALYZE는 실제로 쿼리를 실행하고 실제 시간을 보여줍니다.EXPLAIN ANALYZE
SELECT *
FROM consults
WHERE status = 'NEW'
ORDER BY created_at DESC
LIMIT 20;
EXPLAIN ANALYZE는 실제 쿼리를 실행함
SELECT는 괜찮지만 UPDATE/DELETE에는 매우 주의
운영 DB에서 무거운 쿼리에 바로 실행하지 않기
가능하면 staging/복제 환경에서 먼저 확인
EXPLAIN ANALYZE는 강력하지만 실제 실행된다는 점을 잊으면 안 됩니다.| 용어 | 의미 | 해석 |
|---|---|---|
| Seq Scan | 순차 스캔 | 테이블 전체를 훑음 |
| Index Scan | 인덱스를 사용해 조회 | 인덱스 활용 |
| Bitmap Index Scan | 인덱스로 후보를 찾음 | 많은 row 후보 |
| Sort | 정렬 작업 | orderBy 비용 |
| Nested Loop | 반복 join | 소량 데이터에 적합 |
| Hash Join | 해시 기반 join | 대량 join에 사용 |
| Filter | 조건 필터링 | 스캔 후 걸러냄 |
| Rows Removed by Filter | 필터로 제거된 row | 낭비 확인 |
| Planning Time | 실행 계획 생성 시간 | 보통 짧음 |
| Execution Time | 실제 실행 시간 | 핵심 지표 |
작은 테이블:
Seq Scan이 더 빠를 수 있음
큰 테이블:
자주 조회하는 조건에서 Seq Scan이면 문제 가능
결론:
테이블 크기와 쿼리 패턴 기준으로 판단
SELECT *
FROM consults
WHERE status = 'NEW'
ORDER BY created_at DESC
LIMIT 20 OFFSET 0;
status 조건에 인덱스 없음
created_at 정렬에 인덱스 없음
SELECT *로 불필요한 컬럼 조회
데이터가 많아지면 정렬 비용 증가
CREATE INDEX idx_consults_status_created_at_id
ON consults (status, created_at DESC, id DESC);
SELECT id, customer_name, phone_masked, status, product_id, created_at
FROM consults
WHERE status = 'NEW'
ORDER BY created_at DESC, id DESC
LIMIT 20;
SELECT *보다 필요한 컬럼만 가져오는 것이 좋습니다.consults 전체 row 확인
↓
status = NEW인 row만 필터
↓
created_at 기준 정렬
↓
상위 20건 반환
문제:
데이터가 많을수록 느림
정렬 비용 큼
DB CPU 사용 증가
idx_consults_status_created_at_id 사용
↓
status = NEW인 최신 데이터부터 접근
↓
20건만 빠르게 반환
장점:
필터와 정렬을 인덱스로 처리 가능
LIMIT과 잘 맞음
관리자 최신 목록 조회에 유리
status + createdAt, source + createdAt, productId + createdAt 조합이 자주 후보가 됩니다.CREATE INDEX idx_consults_status_created_at_id
ON consults (status, created_at DESC, id DESC);
where에서 자주 고정되는 값:
앞쪽
범위 검색/정렬에 쓰는 값:
뒤쪽
동일값 보조 정렬:
id
(status, created_at DESC, id DESC)
status:
NEW, CALLED 같은 필터
created_at:
최신순 정렬과 날짜 범위
id:
같은 created_at에서 안정적 정렬
(status, created_at)과 (created_at, status)는 다름
쿼리 패턴에 따라 효율이 달라짐
모든 필터 조합마다 인덱스를 만들면 안 됨
consults row insert
↓
테이블에 데이터 추가
↓
각 인덱스도 업데이트
↓
인덱스가 많을수록 쓰기 비용 증가
쓰기 성능 저하
저장공간 증가
migration 시간 증가
DB 유지보수 비용 증가
실제 사용되지 않는 인덱스 누적
자주 사용되는 쿼리인가?
데이터가 충분히 많은 테이블인가?
where/orderBy에 자주 쓰이는가?
EXPLAIN에서 병목이 확인됐는가?
기존 인덱스로 커버되지 않는가?
SELECT *는 모든 컬럼을 가져오는 방식입니다.SELECT *
FROM consults
ORDER BY created_at DESC
LIMIT 20;
문제:
상담 메모까지 가져올 수 있음
긴 JSON 컬럼까지 가져올 수 있음
개인정보 노출 범위 증가
응답 payload 증가
프론트 렌더링 부담 증가
SELECT id, customer_name, phone_masked, status, product_id, source, created_at
FROM consults
ORDER BY created_at DESC, id DESC
LIMIT 20;
const consults = await prisma.consult.findMany({
select: {
id: true,
customerName: true,
phoneMasked: true,
status: true,
source: true,
createdAt: true,
product: {
select: {
id: true,
modelName: true,
},
},
},
});
LIKE '%keyword%'나 Prisma contains 검색은 편하지만 대량 데이터에서 느려질 수 있습니다.SELECT *
FROM consults
WHERE customer_name ILIKE '%준%'
ORDER BY created_at DESC
LIMIT 20;
문제:
앞쪽 wildcard 때문에 일반 인덱스 사용 어려움
데이터가 많으면 전체 스캔 가능성
OR 조건과 함께 쓰면 더 무거워짐
정확 검색 가능한 컬럼 분리
전화번호 정규화 컬럼 사용
이름 초성/검색용 컬럼 고려
PostgreSQL trigram index 검토
검색 전문 엔진은 나중 단계에서 검토
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_consults_customer_name_trgm
ON consults
USING gin (customer_name gin_trgm_ops);
phoneEncrypted:
원본 전화번호 암호화
phoneMasked:
010****5678
phoneNormalized:
01012345678
phoneLast4:
5678
SELECT id, customer_name, phone_masked, status
FROM consults
WHERE phone_normalized = '01012345678';
또는:
SELECT id, customer_name, phone_masked, status
FROM consults
WHERE phone_last4 = '5678'
ORDER BY created_at DESC
LIMIT 20;
CREATE INDEX idx_consults_phone_normalized
ON consults (phone_normalized);
CREATE INDEX idx_consults_phone_last4_created_at
ON consults (phone_last4, created_at DESC);
전화번호 원본 로그 출력 금지
검색어도 로그에 남기지 않기
전화번호 컬럼 접근 권한 제한
암호화하면 contains 검색 어려움
total과 totalPages를 보여주려면 count 쿼리가 필요합니다.const [items, total] = await prisma.$transaction([
prisma.consult.findMany({
where,
take: limit,
skip,
orderBy,
select,
}),
prisma.consult.count({
where,
}),
]);
where 조건이 복잡함
OR 조건이 많음
contains 검색 포함
join 조건 포함
인덱스를 못 탐
대량 테이블 전체 count
자주 쓰는 필터에 인덱스 추가
검색 조건 단순화
정확한 total이 꼭 필요한지 검토
로그성 화면은 hasNextPage 방식 고려
집계 테이블 또는 materialized view 검토
SELECT id, customer_name, status, created_at
FROM consults
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;
문제:
앞의 100,000건을 건너뛰는 비용 발생
뒤 페이지로 갈수록 느려짐
대량 데이터에서 비효율적
cursor pagination
날짜 범위 필터 강제
최근 3개월 기본 조회
아카이브 분리
엑셀 다운로드는 ExportJob으로 분리
일반 상담 목록:
offset으로 시작 가능
몇 년치 전체 데이터:
기본 날짜 필터 적용
대량 로그:
cursor pagination 추천
무한 스크롤:
cursor pagination 추천
consults
join products
join product_options
join admin_users
join lead_sources
join latest_status_history
join notification_logs
문제:
쿼리 복잡도 증가
중복 row 발생 가능
정렬/페이지네이션 꼬임
응답 payload 증가
목록에 꼭 필요한 relation만 join
상세 정보는 상세 API에서 조회
마지막 이력은 별도 API 또는 요약 컬럼 고려
집계/최근 상태는 원본 테이블에 denormalize 고려
consults에 저장:
productNameSnapshot
carrierSnapshot
planNameSnapshot
장점:
목록에서 product join 없이 표시 가능
상담 당시 조건 보존
상담 목록 20건 조회
↓
각 상담마다 statusHistories 최신 1건 조회
↓
추가 쿼리 20번
consults에 lastStatusChangedAt 저장
consults에 lastHandledByAdminId 저장
상세에서만 이력 조회
별도 materialized view 사용
consults
- status
- lastStatusChangedAt
- lastHandledByAdminId
consults.status, lastStatusChangedAt, lastHandledByAdminId, 이력 테이블을 함께 업데이트할 수 있습니다.Seq Scan인가 Index Scan인가?
예상 rows와 실제 rows 차이가 큰가?
Sort가 큰 비용을 차지하는가?
Rows Removed by Filter가 많은가?
Execution Time이 얼마나 걸리는가?
Join 방식이 적절한가?
사용하려던 인덱스를 실제로 쓰는가?
많은 row를 읽고 대부분 버림
↓
where 조건에 맞는 인덱스가 없을 가능성
정렬 대상 row가 많음
↓
orderBy에 맞는 인덱스 검토
DB 통계가 실제 데이터 분포를 잘못 예측
↓
ANALYZE 필요 가능성
ANALYZE consults;
VACUUM ANALYZE consults;
ANALYZE:
통계 정보 갱신
VACUUM:
죽은 row 정리
VACUUM ANALYZE:
정리 + 통계 갱신
운영에서 무거운 작업이 될 수 있음
대량 변경 후 성능 문제 시 검토
RDS autovacuum 설정 확인
무조건 수동으로 자주 실행할 필요는 없음
CREATE INDEX idx_products_active_created_at
ON products (created_at DESC)
WHERE deleted_at IS NULL;
CREATE INDEX idx_consults_active_status_created_at
ON consults (status, created_at DESC)
WHERE deleted_at IS NULL;
인덱스 크기 감소
자주 조회하는 활성 데이터에 집중
Soft Delete 조건과 잘 맞음
쿼리 where 조건이 partial index 조건과 맞아야 함
Prisma schema에서 직접 표현이 제한될 수 있음
raw SQL migration 필요할 수 있음
deleted_at IS NULL 조건이 있어야 인덱스를 잘 활용합니다.INCLUDE 컬럼을 사용할 수 있습니다.CREATE INDEX idx_consults_status_created_at_include
ON consults (status, created_at DESC, id DESC)
INCLUDE (customer_name, phone_masked, source);
검색/정렬:
status, created_at, id
결과 표시:
customer_name, phone_masked, source
일부 경우 테이블 접근을 줄일 수 있음
인덱스 크기 증가
쓰기 비용 증가
자주 쓰는 핵심 목록에만 적용
처음부터 남용하지 않기
CREATE MATERIALIZED VIEW daily_consult_stats AS
SELECT
DATE(created_at) AS date,
source,
status,
COUNT(*) AS count
FROM consults
GROUP BY DATE(created_at), source, status;
SELECT *
FROM daily_consult_stats
WHERE date >= '2026-08-01';
REFRESH MATERIALIZED VIEW daily_consult_stats;
유입 source별 일자 통계
상품별 상담 신청 통계
관리자별 처리량 통계
대시보드 집계
실시간 데이터가 아님
refresh 전략 필요
동시 refresh 주의
초기에는 일반 query/집계 테이블로 충분할 수 있음
log_min_duration_statement:
특정 ms 이상 걸린 쿼리 로그 기록
예:
1000ms 이상 쿼리 기록
어떤 쿼리가 실제로 느린지 확인
배포 후 느린 쿼리 증가 여부 확인
관리자 목록/엑셀/통계 병목 파악
로그에 개인정보/쿼리 파라미터가 포함될 수 있음
로그 저장 비용 증가 가능
운영 설정 변경 전 영향 확인
const prisma = new PrismaClient({
log: ['query', 'error', 'warn'],
});
운영에서 모든 query 로그 출력은 위험
개인정보/검색어 노출 가능
로그량 급증 가능
개발/스테이징에서 분석용으로 사용
findMany가 어떤 SQL로 변환되는가?
include가 join인지 별도 query인지?
count가 별도로 실행되는가?
where/orderBy가 예상대로 들어가는가?
limit/offset이 적용되는가?
Execution Time
DB query time
API response time
CPU 사용률
응답 payload 크기
rows scanned
rows returned
# 쿼리 최적화 기록
## 대상 API
-
## 기존 문제
-
## 기존 쿼리
```sql
* 이런 기록은 나중에 경력기술서에도 성과로 쓰기 좋습니다.
* “관리자 목록 성능 개선”은 숫자가 있으면 훨씬 강한 성과가 됩니다.
---
### ✅ 24. 관리자 목록 성능 개선 우선순위
#### ➕ 24-1. 1순위: 응답 데이터 줄이기
```txt id="perf-priority-1"
SELECT * 제거
select 최소화
목록/상세 API 분리
불필요한 include 제거
createdAt
status + createdAt
productId + createdAt
source + createdAt
phoneNormalized
전화번호 정규화
contains 검색 줄이기
날짜 범위 기본값 제공
OR 조건 단순화
limit 최대값 제한
큰 offset 방지
대량 로그 cursor pagination
엑셀 ExportJob 분리
집계 테이블
materialized view
대시보드 캐싱
비동기 통계 생성
EXPLAIN 없이 인덱스 추가
모든 컬럼에 인덱스 추가
SELECT * 그대로 사용
목록 API에서 상세 relation 모두 include
전화번호/이름 contains 검색 남발
count 쿼리 비용 무시
offset이 큰 페이지를 방치
엑셀 다운로드를 목록 API로 처리
프론트 렌더링 병목을 DB 문제로 착각
운영 DB에서 무거운 EXPLAIN ANALYZE 실행
운영에서 where 없는 update/delete
느린 쿼리 해결한다고 무작정 DB 스펙만 올림
인덱스 추가 후 효과 측정 안 함
쿼리 로그에 개인정보 남김
SELECT *를 피하는가?select로 필요한 필드만 가져오는가?createdAt + id처럼 안정적인가?limit 최대값이 있는가?NestJS + Prisma + PostgreSQL 기반 관리자 상담 목록 조회가 느려져서 쿼리 최적화를 하고 싶어.
서비스 상황:
1. 상담 데이터가 계속 쌓이고 있음
2. 관리자 목록은 page/limit 기반 offset pagination을 사용함
3. 주요 필터는 status, productId, source, createdAt 범위임
4. 주요 검색은 phoneNormalized, phoneLast4, customerName keyword임
5. 기본 정렬은 createdAt desc, id desc임
6. 목록에서는 상품명, 통신사, 전화번호 마스킹, 상태, 유입 source, 생성일만 보여주면 됨
7. 상세 정보와 상태 변경 이력은 별도 API에서 조회하고 싶음
8. 엑셀 다운로드는 ExportJob으로 분리할 예정임
9. Prisma에서 실제 SQL과 EXPLAIN 결과를 확인하려고 함
요청:
- 느린 쿼리 분석 순서
- Prisma에서 실제 SQL 확인 방법
- EXPLAIN에서 봐야 할 핵심 항목
- 상담 목록에 필요한 index 후보
- 복합 인덱스 컬럼 순서 추천
- SELECT * / include 줄이는 방법
- contains 검색 최적화 방향
- count 쿼리 최적화 기준
- offset pagination 한계와 cursor 전환 기준
- 성능 개선 전후 기록 템플릿
을 실무 기준으로 정리해줘.
EXPLAIN 없이 인덱스부터 추가하라고 하지 않는가?
SELECT *와 과한 include 문제를 지적하는가?
where/orderBy 기준으로 복합 인덱스를 제안하는가?
인덱스가 쓰기 비용을 늘릴 수 있음을 설명하는가?
전화번호 검색과 개인정보 보안을 함께 고려하는가?
count 쿼리와 offset pagination 한계를 설명하는가?
엑셀 다운로드를 목록 API와 분리하는가?
개선 전후 측정을 강조하는가?
운영 DB에서 무거운 EXPLAIN ANALYZE를 조심하라고 하는가?
EXPLAIN은 예상 실행 계획을 보여주고, EXPLAIN ANALYZE는 실제 쿼리를 실행해 시간을 보여주므로 운영 DB에서는 주의해서 사용해야 합니다.status + created_at + id, product_id + created_at, source + created_at, phone_normalized 같은 인덱스가 후보가 될 수 있습니다.SELECT *를 피하고, Prisma select로 필요한 필드만 가져오며, 상세 정보와 이력은 상세 API로 분리하는 것이 좋습니다.LIKE '%keyword%'나 Prisma contains 검색은 대량 데이터에서 느려질 수 있어 전화번호 정규화 컬럼, 정확 검색, trigram index 등을 상황에 따라 고려해야 합니다.