"1주일 만에 서비스를 중단하라고요?"
토이 프로젝트를 런칭하고 커뮤니티에 홍보했는데 반응이 꽤 좋았습니다. 가입자가 100명, 500명 늘어나는 걸 보며 흐뭇해하고 있었죠.
그런데 어느 날 아침, Supabase에서 무시무시한 메일이 왔습니다.
"Your project has exceeded its database disk size limit. Changing your project to read-only."
확인해보니 DB 크기가 500MB를 넘어서 읽기 전용(Read-only) 모드가 되어버렸습니다. 사용자들은 "로그인이 안 돼요", "글이 안 써져요"라고 아우성이었고, 서비스는 사실상 셧다운되었습니다. "아니, 고작 텍스트 쪼가리 저장하는데 500MB를 다 썼다고?"
처음엔 뭐가 이해가 안 갔나? (텍스트 데이터의 무게)
저는 users 테이블에 이메일, 이름 저장하고, posts 테이블에 글 좀 저장한 게 전부였습니다.
텍스트 데이터가 아무리 많아봤자 몇 킬로바이트(KB) 수준일 텐데, 500MB가 찰 리가 없다고 생각했습니다.
"데이터베이스가 거짓말하는 거 아냐? 로그 파일 같은 게 쌓인 건가?"
하지만 Storage Usage를 분석해보니 범인은 엉뚱한 곳에 있었습니다.
- Index: 데이터를 빨리 찾으려고 건 인덱스가 데이터보다 더 클 때가 있습니다.
- Toast Table: 긴 텍스트(예: 블로그 본문)는 압축해서 따로 저장하는데, 여기가 거대했습니다.
- WAL (Write Ahead Log): 트랜잭션 로그가 며칠 치 쌓여 있었습니다.
무엇보다 "데이터베이스는 데이터를 지워도 용량이 바로 줄어들지 않는다(Vacuum)"는 사실을 몰랐습니다.
어떤 포인트에서 이해가 됐나? (이사 박스 비유)
이 상황을 "이사 박스 정리"에 비유하니 이해가 됐습니다.
- INSERT: 박스에 물건을 차곡차곡 넣습니다.
- DELETE: 박스 안의 물건을 뺍니다. 하지만 박스 공간(Disk Space)은 그대로 남아있습니다. 빈 박스가 창고(Disk) 자리를 차지하고 있는 겁니다.
- VACUUM FULL: 빈 박스를 찌그러뜨려서 버리고, 물건들을 새 박스에 꽉꽉 채워 넣는 대청소 작업입니다. 이래야 비로소 공간이 줍니다.
저는 데이터를 지우기만 했지, 빈 박스를 치우는(Vacuum) 법을 몰랐던 겁니다.
해결 과정 - 무료 플랜에서 살아남기
Supabase Free Tier (500MB)는 작아 보이지만, 최적화만 잘하면 꽤 오래 쓸 수 있습니다.
1단계 - 진범 찾기 (용량 분석 쿼리)
누가 용량을 차지하는지 확인해야 합니다. SQL Editor에서 실행하세요.
SELECT
schema_name,
relname,
pg_size_pretty(table_size) AS size,
pg_size_pretty(indexes_size) AS index_size,
pg_size_pretty(total_size) AS total_size
FROM (
SELECT
pg_catalog.pg_namespace.nspname AS schema_name,
relname,
pg_relation_size(pg_catalog.pg_class.oid) AS table_size,
pg_total_relation_size(pg_catalog.pg_class.oid) - pg_relation_size(pg_catalog.pg_class.oid) AS indexes_size,
pg_total_relation_size(pg_catalog.pg_class.oid) AS total_size
FROM pg_catalog.pg_class
JOIN pg_catalog.pg_namespace ON relnamespace = pg_catalog.pg_namespace.oid
) t
ORDER BY total_size DESC;
제 경우엔 logs 테이블(제가 만든)이 범인이었습니다.
"아, console.log 대신 DB에 로그를 다 때려 박았더니..."
2단계 - Vacuum 실행
데이터를 대량으로 지운(DELETE) 후에는 반드시 VACUUM을 해줘야 합니다.
Supabase 대시보드에서는 안 되고, SQL이나 외부 접속으로 해야 합니다.
-- 간단한 청소 (서비스 중단 X, 공간 회수 적음)
VACUUM users;
-- 대청소 (서비스 테이블 잠김 Lock, 공간 확실히 회수)
VACUUM FULL users;
⚠️ 주의: VACUUM FULL은 테이블을 잠급니다(Lock). 사용자가 없는 새벽에 하세요.
3단계 - 인덱스 다이어트
"혹시 몰라서" 걸어둔 인덱스가 디스크를 엄청 먹습니다. 안 쓰는 인덱스를 찾아서 지우세요.
DROP INDEX IF EXISTS idx_users_last_login;
4단계 - Point-in-Time Recovery (PITR) 끄기
Supabase 설정에서 PITR(시점 복구)을 켜면 변경 로그(WAL)가 엄청나게 쌓입니다. 무료 플랜이나 초기 프로젝트라면 굳이 PITR가 필요 없을 수 있습니다. Project Settings -> Database -> Backups에서 확인하세요.
깊이 파고들기 - Compute (CPU) 제한
디스크 말고 CPU (Instance Size)도 문제입니다. Micro 인스턴스(무료)는 CPU 2개, RAM 1GB입니다. 동시 접속자가 조금만 몰려도 CPU가 100%를 찍고 DB가 뻗습니다.
이때 가장 큰 원인은 "N+1 Query"입니다. ORM(Prisma 등)을 잘못 써서, 사용자 1명이 들어왔는데 쿼리가 100번 날아가는 경우입니다. Supabase Dashboard -> Database -> Query Performance 탭을 보시면, 가장 시간을 많이 잡아먹는(Time Consuming) 쿼리와 가장 많이 호출된(Most frequently called) 쿼리가 나옵니다. 이걸 잡지 않으면, $25 내고 Pro 플랜으로 업그레이드해도 금방 또 터집니다.
Application: Log 다이어트
많은 분이 "요청 로그"를 requests 테이블 같은 곳에 쌓습니다.
이건 DB에 넣지 말고, Supabase Log Drains나 SaaS (Sentry, LogFlare)를 쓰세요.
RDBMS는 로그 저장소가 아닙니다. 비싼 SSD 공간 낭비입니다.
Connection Pooling (Supavisor) 더 알아보기
Next.js 같은 서버리스 환경(Vercel)은 요청이 올 때마다 300ms 만에 떴다가 사라집니다. 이때마다 DB 연결(Connection)을 맺고 끊으면, DB CPU가 연결 처리하느라 폭발합니다. "Too many clients" 에러가 뜹니다.
Supavisor (내장 풀링) 사용
Supabase는 포트 6543(Transaction Mode)과 5432(Session Mode)를 제공합니다. 서버리스 함수에서는 반드시 6543 포트를 써야 합니다.
# .env
# ❌ Session Mode (5432) - 서버리스에서 쓰면 터짐
DATABASE_URL="postgres://postgres:pw@db.bit.supabase.co:5432/postgres"
# ✅ Transaction Mode (6543) - 수천 개의 연결을 재사용
DATABASE_URL="postgres://postgres:pw@db.bit.supabase.co:6543/postgres"
이걸 안 바꾸면 Prisma가 연결을 다 먹어치워서 무료 플랜 한도를 순식간에 넘깁니다.
8. Case Study: 바이럴이 터지면? ("Reddit Hug of Death")
토이 프로젝트가 레딧이나 해커뉴스에 올라가서 트래픽이 수십~수백 배 폭증하는 사례가 있다. "Reddit Hug of Death"라고 불린다. 무료 플랜에서 이 상황을 맞으면 어떻게 될까?
전형적인 상황
- 동시 접속자 10명 -> 3,000명 급증.
- CPU 100% 찍고 DB 멈춤.
- 사용자는 "Connection Timed Out"만 봄.
이런 상황에서 쓸 수 있는 긴급 조치를 정리해봤다.
긴급 조치 (소방수 모드)
- Read Replica? (X): 무료 플랜에선 불가능.
- 캐싱 (O): 가장 빠른 해결책은 DB를 안 가는 것이다.
- Next.js의
revalidate: 60(ISR)을 켜면, 똑같은 페이지는 1분 동안 DB 조회 없이 캐시 된 HTML을 뿌린다.
- Next.js의
- Realtime 끄기: 수천 명한테 동시에 Broadcast 하는 건 CPU를 엄청 먹는다. 일단 끈다.
결과
DB CPU가 20%로 뚝 떨어진다. "DB가 아파하면, DB 앞단(CDN/Cache)에서 막아줘라." 이것이 고트래픽의 진리다.
9. FAQ: 언제 돈을 내야 하나요?
Q: Pro Plan($25)으로 넘어가야 할 타이밍은? A:
- Disk 500MB가 데이터(순수 텍스트)로만 꽉 찼을 때 (로그/인덱스 정리 후에도).
- Daily Backup이 필수적일 만큼 데이터가 중요해졌을 때.
- Cold Start 없이 항상 빠릿한 DB 성능이 필요할 때 (Pro는 더 좋은 인스턴스를 줍니다).
Q: Storage(이미지) 용량도 포함인가요?
Storage 최적화 (WebP & Resizing) 파헤치기
DB 용량은 텍스트지만, Storage 용량(1GB)은 이미지/파일입니다. 1GB도 금방 찹니다. 사용자가 10MB짜리 원본 사진을 올리면 100장 만에 끝납니다.
해결책: 엣지 펑션으로 리사이징 Supabase Storage는 이미지 변환 기능(Image Transformation)을 제공합니다. 하지만 이건 Pro 플랜부터입니다.
무료 플랜러의 생존법: "클라이언트에서 줄여서 올려라."
image_compression 같은 라이브러리를 써서, 업로드 전에 브라우저/앱에서 1000px로 리사이징하고 압축해서 올리세요. 10MB -> 200KB로 줄어듭니다.
11. Case Study: Realtime Quota 폭발 (동시 접속자 200명 제한)
무료 플랜의 가장 치명적인 약점은 Realtime 동시 접속자 200명 제한입니다. 채팅 앱을 만들었는데 201번째 사람은 접속이 안 됩니다.
해결책: Polling으로 전환 모든 기능에 Realtime을 쓰지 마세요.
- 알림: FCM (푸시 알림)으로 대체
- 피드 갱신: "새 글이 있습니다" 버튼 (Pull-to-Refresh)
- 채팅: 여기만 Realtime 쓰거나, 접속자가 많으면 3초 간격 Polling으로 자동 전환하도록 코딩하세요.
12. Architecture: Supavisor의 비밀 (PGBouncer)
무료 플랜에서 가장 이해가 안 되는 부분이 "Connection Limit"입니다. Supabase는 기본적으로 Supavisor라는 Connection Pooler를 제공합니다.
- Direct Connection (5432): 서버 1대당 연결 1개. (Max 60개 제한)
- Transaction/Session Pooler (6543): 수천 개의 연결을 받아주고, 실제 DB한테는 몇 개만 열어줌.
서버리스(Next.js, Lambda)를 쓴다면 선택이 아니라 필수입니다.
Prisma나 Drizzle ORM 설정에서 ?pgbouncer=true 같은 옵션이 필요한지 꼭 확인하세요.
이거 하나로 "CPU 100%" 문제를 90% 해결할 수 있습니다.