**PostgreSQL 쿼리 성능 20배 개선하기 (Part 1) - 문제 정의와 성능 분석**
시리즈 소개: 프로덕션 환경에서 PostgreSQL 데이터베이스 성능을 최적화한 실전 사례를 5개 파트로 나눠 공유합니다.
- Part 1: 문제 정의와 성능 분석
Part 2: 최적화 전략 수립
Part 3: Dashboard 쿼리 최적화 (21배 개선)
Part 4: 검색과 JOIN 최적화 (22배, 19배 개선)
Part 5: 모니터링과 결과
1. 서론: 왜 데이터베이스 최적화가 필요했는가
Referread 서비스 소개
Referread 는 AI 기반 콘텐츠 리서치 플랫폼입니다. 사용자는 브랜드와 토픽을 입력하면, AI가 SEO 최적화된 콘텐츠 아이디어와 키워드를 자동으로 생성해줍니다.
기술 스택:
Frontend: React + TypeScript + Vite
Backend: Supabase (PostgreSQL + Edge Functions)
Workflow: n8n
Deployment: Google Cloud Storage + CDN
문제 상황
서비스를 운영하면서 다음과 같은 문제들이 발생했습니다:
- Dashboard 로딩 지연
- 프로젝트가 100개 이상일 때 3초 이상 소요
- 사용자가 “느리다”는 피드백 증가
- Admin 검색 응답 지연
- 검색어를 입력할 때마다 0.5초 이상 대기
- 타이핑할 때마다 버벅임
- SavedTopics 페이지 무한 로딩
- 데이터가 많을 때 로딩이 끝나지 않음
- 네트워크 탭에서 250KB 이상의 JSON 응답 확인
최적화 목표
사용자 경험 개선: 모든 페이지 로딩 1초 이내
서버 리소스 절약: Database CPU 사용률 20%
확장성 확보: 프로젝트 1,000개 이상에서도 빠른 응답
2. 데이터베이스 구조 분석
도메인 모델
```plain text Brand (브랜드) └── Project (리서치 프로젝트) ├── Topic (생성된 콘텐츠 주제) ├── Related Keywords (연관 키워드) ├── Expanded Outlines (상세 아웃라인) └── Fan Out Logs (분석 로그)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
### **주요 테이블 스키마**
**projects 테이블**:
```sql
CREATE TABLE projects (
id UUID PRIMARY KEY,
user_id TEXT NOT NULL, -- 사용자 ID
tenant_id UUID, -- 조직 ID
brand TEXT, -- 브랜드명
topic TEXT, -- 주제
hide BOOLEAN, -- 숨김 여부
is_dev BOOLEAN, -- 개발 환경 플래그
status TEXT, -- 상태
created_at TIMESTAMPTZ, -- 생성 시간
updated_at TIMESTAMPTZ -- 수정 시간
);
topics 테이블:
1
2
3
4
5
6
7
8
CREATE TABLE topics (
id UUID PRIMARY KEY,
project_id UUID NOT NULL, -- 프로젝트 FK
main_keyword TEXT, -- 메인 키워드
is_selected BOOLEAN, -- 저장 여부
is_deleted BOOLEAN, -- 소프트 삭제
created_at TIMESTAMPTZ -- 생성 시간
);
related_keywords 테이블:
1
2
3
4
5
6
CREATE TABLE related_keywords (
id UUID PRIMARY KEY,
project_id UUID NOT NULL, -- 프로젝트 FK
keyword TEXT, -- 키워드
google_msv INTEGER -- Google 월간 검색량
);
페이지별 쿼리 패턴 분석
Dashboard (높은 접근 빈도)
프로젝트 리스트 조회:
1
2
3
4
5
SELECT * FROM projects
WHERE user_id = 'user-123'
AND (hide IS NULL OR hide = false)
AND is_dev = false
ORDER BY created_at DESC;
브랜드 리스트 조회:
1
2
3
4
SELECT * FROM brands
WHERE user_id = 'user-123'
AND is_dev = false
ORDER BY updated_at DESC;
AdminDashboard (검색 + 페이지네이션)
검색 쿼리:
1
2
3
4
5
SELECT * FROM projects
WHERE is_dev = false
AND (topic ILIKE '%AI%' OR brand ILIKE '%AI%') -- ⚠️ 성능 문제!
ORDER BY created_at DESC
LIMIT 50 OFFSET 0;
문제점:
ILIKE '%keyword%'패턴은 인덱스를 활용할 수 없음항상 전체 테이블 스캔 (Seq Scan)
프로젝트 5,000개일 때 500ms 이상 소요
ResearchResults (JOIN 빈번)
토픽 조회:
1
2
3
4
5
6
SELECT t.*, p.region
FROM topics t
JOIN projects p ON t.project_id = p.id
WHERE t.project_id = 'project-abc'
AND (t.is_deleted IS NULL OR t.is_deleted = false)
ORDER BY t.created_at ASC;
관련 키워드 조회:
1
2
3
SELECT * FROM related_keywords
WHERE project_id = 'project-abc'
ORDER BY google_msv DESC NULLS LAST;
SavedTopics
저장된 토픽 조회:
1
2
3
4
5
SELECT t.*, p.brand, p.region, p.user_id, p.hide
FROM topics t
JOIN projects p ON t.project_id = p.id
WHERE t.is_selected = true
AND p.user_id = 'user-123';
문제점 (line 113-115):
1
2
3
4
// ⚠️ 클라이언트 사이드 필터링!
const filteredData = (data || []).filter(
(t) => !(t.projects as Record<string, unknown>)?.hide
);
서버에서
hide = true인 데이터까지 모두 가져옴클라이언트에서 필터링 → 불필요한 네트워크 데이터 전송
1,000개 중 700개를 버리는 상황 발생
3. 성능 병목 지점 찾기: EXPLAIN ANALYZE 활용법
EXPLAIN ANALYZE란?
PostgreSQL에서 제공하는 쿼리 실행 계획 분석 도구입니다. 쿼리가 어떻게 실행되는지, 어디서 시간이 오래 걸리는지 정확히 알려줍니다.
기본 사용법:
1
2
3
4
5
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT * FROM projects
WHERE user_id = 'user-123'
ORDER BY created_at DESC;
옵션 설명:
ANALYZE: 실제로 쿼리를 실행하고 결과 측정BUFFERS: 메모리/디스크 I/O 정보 표시VERBOSE: 컬럼 정보 등 상세 출력
EXPLAIN 결과 해석하기
예시 1: Seq Scan (나쁨)
1
2
3
4
5
6
Seq Scan on projects (cost=0.00..150.00 rows=5000 width=200)
(actual time=0.123..345.678 rows=50 loops=1)
Filter: (user_id = 'user-123' AND (hide IS NULL OR hide = false))
Rows Removed by Filter: 4850
Planning Time: 0.5 ms
Execution Time: 387.2 m
문제점:
Seq Scan: 테이블 전체를 처음부터 끝까지 읽음Rows Removed by Filter: 4850: 5,000개 중 4,850개를 읽었다가 버림Execution Time: 387.2 ms: 사용자 체감 지연
예시 2: Index Scan (좋음)
1
2
3
4
5
6
7
8
Index Scan using idx_projects_user_hide_dev_created on projects
(cost=0.15..8.50 rows=50 width=200)
(actual time=0.012..15.234 rows=50 loops=1)
Index Cond: (user_id = 'user-123' AND is_dev = false)
Filter: (hide IS NULL OR hide = false)
Rows Removed by Filter: 0
Planning Time: 0.3 ms
Execution Time: 18.1 ms
개선 효과:
Index Scan: 인덱스를 사용해 필요한 행만 직접 접근Rows Removed by Filter: 0: 불필요한 행을 읽지 않음Execution Time: 18.1 ms: 387ms → 18ms (21배 개선)
주요 지표 해석
실전 분석: Dashboard 쿼리
최적화 전:
1
2
3
4
5
6
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM projects
WHERE user_id = '018d1234-5678-9abc-def0-123456789abc'
AND (hide IS NULL OR hide = false)
AND is_dev = false
ORDER BY created_at DESC;
결과:
plain text Gather Merge (cost=5000.00..6000.00 rows=50 width=200) (actual time=345.123..387.456 rows=50 loops=1) Workers Planned: 2 Workers Launched: 2 -> Sort (cost=4000.00..4100.00 rows=25 width=200) (actual time=340.567..340.789 rows=17 loops=3) Sort Key: created_at DESC Sort Method: quicksort Memory: 25kB Worker 0: Sort Method: quicksort Memory: 25kB Worker 1: Sort Method: quicksort Memory: 25kB -> Parallel Seq Scan on projects (cost=0.00..3500.00 rows=25 width=200) (actual time=0.123..335.678 rows=17 loops=3) Filter: (user_id = '018d1234-...' AND (hide IS NULL OR hide = false) AND is_dev = false) Rows Removed by Filter: 1617 Buffers: shared hit=2000 read=500 Planning Time: 1.234 ms Execution Time: 387.891 ms
핵심 문제:
Parallel Seq Scan: 병렬로 전체 테이블을 읽음Rows Removed by Filter: 1617 × 3 workers = 4,851: 대부분의 행을 버림Execution Time: 387.891 ms: 사용자 체감 지연
왜 인덱스를 사용하지 않았을까?
인덱스가 아예 없음 (아직 생성 전)
PostgreSQL은 인덱스가 없으면 무조건 Seq Scan
4. 다음 단계: 최적화 전략 수립
이제 문제를 정확히 파악했으니, Part 2에서는 구체적인 최적화 전략을 수립합니다:
- ANALYZE로 통계 정보 업데이트
- 쿼리 플래너가 정확한 판단을 내리도록 돕기
- 전략적 인덱스 설계
- 어떤 컬럼에 인덱스를 만들 것인가?
복합 인덱스 vs 단일 인덱스
Partial Index로 인덱스 크기 80% 절감
- 쿼리 리팩토링
- 클라이언트 필터링 → 서버 필터링
- ILIKE → Full Text Search