내 데이터베이스는 "정확"했습니다. 296배 느리기도 했습니다.
Postgres 데이터베이스에 89개의 외래 키가 있지만 인덱스는 0개라는 것을 발견했습니다 —밀리초 쿼리가 843ms 악몽으로 변했고, 알파 런칭을 거의 죽일 뻔했습니다.
2025년 10월 25일. 제 SaaS 애플리케이션이 드디어 기능적으로 완성되었습니다. 9개의 주요 기능, 멀티테넌트 아키텍처, 점진적 학습—모든 것이 작동했습니다. 하지만 느렸습니다. 정말 느렸습니다.
대시보드 쿼리가 기어가고 있었습니다. 즉시 로드되어야 하는 목록이 몇 초씩 걸리고 있었습니다. 알파 테스터들이 "사이트 망가진 건가요?"라고 묻기 시작했습니다.
성능만 걱정한 것이 아닙니다. 생존이 걱정이었습니다.
이틀 동안 쿼리를 최적화하고, RLS 정책을 재작성하고, 캐싱을 추가했습니다. 아무 효과가 없었습니다. 좌절감이 쌓이고 있었습니다—알파 런칭 5일 전이었고 기술적으로 올바른 데이터베이스가 왜 이렇게 느린지 알 수 없었습니다.
그런 다음 속이 철렁하게 만든 진단 쿼리 하나를 실행했습니다.
89개의 외래 키. 인덱스 0개.
이것은 아무도 알려주지 않는 Postgres "기능"의 이야기—알파 런칭을 거의 죽일 뻔한 2주—그리고 모든 것을 구한 4분짜리 수정입니다.
위험한 상황: 느림은 곧 죽음
연구에 따르면 100ms의 지연 시간마다 전환율의 1%가 줄어듭니다. 2-3초의 로드 시간으로, 저는 나쁜 경험을 제공하는 것이 아니라—알파의 성패를 좌우할 첫인상을 파괴하고 있었습니다.
즉각적인 위험:
- 알파 테스터들이 플랫폼이 추천할 만큼 안정적인지 의문
- 신뢰를 준 초기 사용자들과의 신뢰성 상실
- 이것이 전체 런칭까지 지속된다면: 업계 연구는 3초 로드 시간에서 40% 이탈률을 시사
- "유망하다"와 "망가진 것 같다"의 차이
- 데이터베이스 CPU 80-90% (비싸고 점점 악화)
- 런칭 5일 전, 완전히 막힘
2주의 실제 비용:
- 40시간 이상 느린 쿼리 디버깅, 코드 프로파일링, RLS 정책 탓하기
- 1주 지연된 런칭 (내부 마감일 놓침)
- 기능을 개발할 대신 잃어버린 기회
- 초기 테스터들과의 신뢰성 피해
기술 부채가 비즈니스 부채가 되고 있었습니다. 그리고 이유를 몰랐습니다.
---
조사: 느린 쿼리 추적
10월 말까지, 제 플랫폼은 실질적인 복잡성을 가지고 있었습니다: 30개 이상의 테이블, 80개 이상의 RLS 정책, 수백 개의 외래 키 관계. 즉시여야 하는 간단한 작업이 고통스럽게 느렸습니다—목록 로드, 대시보드 데이터 가져오기, org\_id로 필터링.
원인을 찾기 위해 pg\_stat\_statements로 시작했습니다:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```
모든 느린 쿼리는 같은 패턴이었습니다—외래 키로 필터링:
```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```
즉시여야 합니다. 해당 컬럼이 있었습니다. RLS 정책도 있었습니다. 왜 느린 걸까요?
실행 계획을 확인했습니다:
```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```
결과를 보고 속이 꺼졌습니다:
```
Seq Scan on user_data (cost=0.00..2847.23 rows=15 width=1024)
Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```
순차 스캔. Postgres가 테이블의 모든 행을 읽고 나서 필터링하고 있었습니다. 밀리초가 걸려야 하는 쿼리에서.
하지만 org\_id와 resource\_id에 외래 키가 있었습니다. 인덱스가 있어야 하지 않나요?
---
깨달음: 외래 키는 자동 인덱스를 만들지 않는다
그날 어렵게 배운 것은 다음과 같습니다:
Postgres는 PRIMARY KEY와 UNIQUE 제약 조건에 대해 자동으로 인덱스를 생성합니다.
Postgres는 외래 키에 대해 자동으로 인덱스를 생성하지 않습니다.
한 번 더 말씀드리겠습니다, 이 하나의 오해가 제 인생의 2주를 소비했기 때문입니다:
외래 키 ≠ 인덱스
다음과 같이 작성할 때:
```sql
CREATE TABLE user_data (
id UUID PRIMARY KEY, -- ✅ 자동 인덱스
org_id UUID REFERENCES organizations(id), -- ❌ 인덱스 안 됨!
resource_id UUID REFERENCES resources(id), -- ❌ 인덱스 안 됨!
created_at TIMESTAMPTZ
);
```
Postgres는 외래 키 제약 조건(참조 무결성)을 생성하지만, org\_id나 resource\_id에 대한 인덱스는 **생성하지 않습니다**.
왜? Postgres는 데이터를 어떻게 쿼리할지 가정할 수 없기 때문입니다. 외래 키로 필터링하지 않을 수도 있습니다. 항상 특정 방향으로 조인할 수도 있습니다. 그래서 결정을 여러분에게 맡깁니다.
문제? 그 결정을 해야 한다는 것을 몰랐습니다. "외래 키"가 "쿼리를 위해 인덱스됨"을 의미한다고 가정했습니다. 그렇지 않습니다.
---
진단: 얼마나 심각했나
인덱스가 없는 모든 외래 키를 찾는 쿼리를 작성했습니다:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
결과가 화물 열차처럼 덮쳤습니다:
89행.
32개 테이블에 걸친 89개의 외래 키. 인덱스 0개.
모든 조인, 모든 필터, 모든 RLS 정책 검사가 전체 테이블 스캔을 하고 있었습니다. 모든 것이 느린 것은 당연했습니다.
---
수정: 인덱스 아포칼립스
2025년 10월 25일. 오전 7시 51분. 무엇을 해야 하는지 알았습니다.
하나의 마이그레이션. 89개의 인덱스. "인덱스 아포칼립스"라고 불렀습니다.
```sql
-- 인덱스 아포칼립스 마이그레이션
-- 목적: 모든 외래 키 컬럼에 인덱스를 추가하여 성능 수정
-- 조직 범위 테이블
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);
-- 리소스 범위 테이블
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);
-- 일반적인 쿼리 패턴을 위한 다중 컬럼 인덱스
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);
-- 소프트 삭제 쿼리
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);
-- 총: 32개 테이블에 89개의 인덱스
```
마이그레이션을 실행했습니다. 상황이 더 나빠질까 궁금하며 엔터 키 위에서 손이 잠시 멈췄습니다. T.T
마이그레이션 시간: 4분.
---
결과: 재앙에서 런칭 준비 완료로
이전 (인덱스 없음)
```sql
Seq Scan on user_data
Execution Time: 843.271 ms
Rows Removed by Filter: 12,834
```
이후 (인덱스 있음)
```sql
Index Scan using idx_user_data_org_resource
Execution Time: 2.847 ms
```
843ms → 2.8ms
단일 쿼리에서 296배 더 빠릅니다.
실제 영향:
- 대시보드 로드: 2-3초 → 120ms
- 리소스 목록: 1초 이상 → 45ms
- 데이터 쿼리: 850ms → 12ms
평균 개선: 20-40배 더 빠름. 여러 외래 키가 있는 복잡한 조인은? 100배 더 빠름.
차이는 밤낮이었습니다. 플랫폼은 "이거 망가진 건가요?"에서 "와, 빠르네요!"로 바뀌었습니다. :D
---
이것이 멀티테넌트 SaaS에 중요한 이유
Row-Level Security를 사용하는 멀티테넌트 SaaS를 구축하고 있다면, 이것은 절대적으로 중요합니다.
RLS 정책은 모든 쿼리에서 실행됩니다:
```sql
CREATE POLICY resources_org_isolation ON resources
USING (org_id = get_user_org_id());
```
org\_id에 인덱스가 없으면, 이 정책은 모든 쿼리에서 순차 스캔을 강제합니다. 간단한 SELECT 문도 고통스럽게 느려집니다.
비용: 인덱스 없는 멀티테넌트 격리 = 비싼 인프라 + 느린 사용자 경험 = 이탈.
비즈니스 교훈: 성능 최적화 없는 보안 기능은 실제로 안전하지 않습니다—사용자들이 보안을 경험하기 전에 떠나버릴 것이기 때문입니다.
---
패턴: 무엇에 인덱스를 추가할 것인가
이 비싼 교훈 후, 모든 새 테이블에 대한 제 체크리스트입니다:
항상 인덱스를 추가하세요:
1. 외래 키 컬럼 - 하나도 빠짐없이
2. RLS 정책의 컬럼 - 특히 멀티테넌트 앱의 org\_id
3. WHERE 절의 컬럼 - 자주 필터링하면 인덱스 추가
4. ORDER BY의 컬럼 - 인덱스 없는 정렬은 성능을 죽임
다중 컬럼 인덱스를 고려하세요:
```sql
-- 대상: WHERE org_id = X AND resource_id = Y
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```
과도한 인덱스는 피하세요: 각 인덱스는 저장 공간, 쓰기 성능, 유지 보수 비용이 듭니다. 경험 법칙: WHERE/JOIN/ORDER BY에 자주 나타나면 인덱스 추가. 그렇지 않으면 추가하지 마세요.
---
지금 당장 데이터베이스를 확인하는 방법
이 진단 쿼리를 실행하세요:
```sql
SELECT
c.conrelid::regclass AS table_name,
a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
AND NOT EXISTS (
SELECT 1 FROM pg_index i
WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
);
```
결과가 나오면, 누락된 인덱스가 있는 것입니다.
그런 다음 느린 쿼리를 찾으세요:
```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;
```
느린 쿼리에 EXPLAIN ANALYZE를 사용하여 순차 스캔이 발생하는지 확인하세요.
---
교훈
1. Postgres의 가정은 위험하다
외래 키가 인덱스되어 있다고 가정했습니다. 그렇지 않습니다. 항상 확인하세요.
2. RLS에는 인덱스가 필요하다
Row-Level Security는 적절한 인덱스 없이는 쓸모없습니다—사실 쓸모없는 것보다 나쁩니다. 모든 쿼리에 오버헤드를 추가합니다.
3. 멀티테넌트 = 어디서나 org_id에 인덱스
멀티테넌트 아키텍처에서 org\_id는 거의 모든 쿼리에 나타납니다. 모든 테이블에 인덱스하세요. 예외 없이.
4. 성능은 비즈니스 문제다
페이지가 3초 걸리면 사용자들은 SQL이 기술적으로 올바르다는 것에 관심이 없습니다. 빠른 쿼리 = 더 나은 전환 = 매출.
5. 인덱스는 일찍 추가하라
빈 테이블에 인덱스를 추가하는 것은 즉시입니다. 수백만 행이 있는 테이블에 추가하는 것은 몇 시간이 걸리고 테이블을 잠급니다. 초기 개발 중에 하세요.
6. 모든 것을 측정하라
전후로 EXPLAIN ANALYZE를 사용하세요. 데이터로 개선을 증명하세요.
---
체크리스트
새로 생성하는 모든 테이블에 대해:
```sql
CREATE TABLE new_table (
id UUID PRIMARY KEY,
org_id UUID REFERENCES organizations(id),
resource_id UUID REFERENCES resources(id),
created_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ
);
-- ✅ 외래 키에 인덱스
CREATE INDEX idx_new_table_org_id ON new_table(org_id);
CREATE INDEX idx_new_table_resource_id ON new_table(resource_id);
-- ✅ RLS 컬럼 + 일반 필터에 인덱스
CREATE INDEX idx_new_table_org_archived ON new_table(org_id, archived_at);
-- ✅ 정렬 컬럼에 인덱스
CREATE INDEX idx_new_table_created ON new_table(created_at DESC);
-- ✅ RLS 정책 생성
CREATE POLICY new_table_org_isolation ON new_table
FOR ALL TO authenticated
USING (org_id = get_user_org_id());
```
---
마무리
89개의 누락된 인덱스. 2주간의 디버깅. 모두를 찾아낸 하나의 진단 쿼리. 수정에 4분.
아이러니한 점? Postgres는 이것을 진단하는 모든 도구를 제공합니다. 저는 어디를 봐야 하는지 몰랐을 뿐입니다. 이제 제가 만드는 모든 테이블은 즉시 인덱스됩니다—외래 키, RLS 컬럼, 정렬 필드, 모든 것.
89개의 인덱스를 추가한 4분짜리 마이그레이션이 향후 몇 주간의 최적화 작업을 절약하고 런칭 재앙을 방지했습니다.
창업자에게: 성능은 엔지니어링 문제만이 아닙니다. 전환 문제, 유지 문제, 신뢰성 문제입니다. 사용하기엔 너무 느린 "기술적으로 올바른" 데이터베이스? 고객을 잃고 있는 것입니다. 성능에 일찍 투자하세요.
엔지니어에게: Postgres는 외래 키를 자동 인덱스하지 않습니다. 하지만 여러분은 해야 합니다. 사용자들(그리고 데이터베이스 CPU)이 감사할 것입니다.
"기술적으로 올바르지만 고통스럽게 느린" 순간을 데이터베이스에서 경험한 적이 있으신가요? 수정은 무엇이었고—찾는 데 얼마나 걸렸나요?
감사합니다,
Chandler
STRAŦUM 아키텍처 시리즈: 이 성능 위기는 2일차에 멀티테넌시 구축하기에서 시작하여, 67일차에 완전한 스키마 재구축으로 이어지고, 잃어버린 내비게이션 컨텍스트로 인한 31개의 빈 화면 수정을 포함한 멀티테넌시 퍼즐의 마지막 조각이었습니다.
---
Postgres로 멀티테넌트 SaaS를 구축하고 계신가요? 사용자들이 알아채기 전에 지금 데이터베이스를 확인하세요. 제가 어렵게 배운 교훈을 여러분은 배우지 않아도 됩니다.
https://stratum.chandlernguyen.com/request-invitation에서 알파 액세스를 요청하세요
---
"작동한다"와 "빠르게 작동한다"는 다른 목표라는 것을 여전히 배우고 있습니다—그리고 하나만이 출시됩니다.
---





