Next.js와 Supabase를 10만 사용자 규모로 확장하기 전에 알았으면 좋았던 7가지
출처: Dev.to
100K 사용자를 위한 Next.js + Supabase 확장 전에 알았으면 좋았던 7가지
6개월 전, 우리는 Next.js와 Supabase로 SaaS를 출시했습니다. MVP에 딱 맞는 스택이었죠: 빠른 개발, 훌륭한 개발자 경험, 그리고 바로 동작했습니다.
그런데 10K 사용자를 맞이했고, 이어 50K, 100K까지 성장했습니다.
소규모에서는 완벽히 작동하던 것들이 부서지기 시작했습니다. 50ms 걸리던 DB 쿼리가 5초가 되었고, Supabase 비용은 월 $25에서 $800으로 폭등했습니다. 사용자들은 페이지 로딩이 느리다며 불평했습니다.
내가 미리 알았으면 좋았던 점
1. RLS를 개발 단계에서 건너뛰었다
“출시 전에 추가하겠지” 라고 생각했죠.
출시일이 다가와 모든 테이블에 RLS를 활성화했지만, 앱은 47곳에서 부서졌습니다.
쿼리는 빈 배열을 반환했고, 삽입은 권한 오류로 실패했습니다. 우리는 12시간을 들여 RLS 정책을 고쳤고, 그 사이 사용자는 기다려야 했습니다.
다르게 할 점
- 첫날부터 RLS를 활성화하세요. 테이블을 만들 때 바로 정책을 작성합니다.
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
user_id UUID REFERENCES auth.users(id)
);
-- 바로 RLS 활성화
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
-- 나중이 아니라 지금 정책 작성
CREATE POLICY "Users can view own posts"
ON posts FOR SELECT
USING (auth.uid() = user_id);
RLS가 활성화된 상태에서 테스트하면, 프로덕션에서도 그대로 동작합니다.
2. “필요할 때 인덱스를 추가하겠다”는 말은 위험합니다
우리는 3일 차에 인덱스가 필요했습니다.
게시물 피드 쿼리는 10K 게시물에 도달하면서 50ms에서 8초로 늘어났고, 사용자들은 불평했습니다. 우리는 트래픽이 급증하는 중에 급히 인덱스를 추가했습니다.
const { data } = await supabase
.from('posts')
.select('*, profiles(*)')
.eq('published', true)
.order('created_at', { ascending: false })
.limit(20)
해결책
CREATE INDEX posts_published_created_at_idx
ON posts(published, created_at DESC)
WHERE published = true;
쿼리 시간은 12ms로 감소했습니다.
다르게 할 점
필터하거나 정렬에 사용하는 모든 컬럼에 인덱스를 추가하세요.
-- 필터 컬럼
CREATE INDEX posts_user_id_idx ON posts(user_id);
CREATE INDEX posts_published_idx ON posts(published);
-- 정렬 컬럼
CREATE INDEX posts_created_at_idx ON posts(created_at DESC);
-- 흔히 쓰이는 복합 인덱스
CREATE INDEX posts_user_published_idx ON posts(user_id, published);
인덱스는 저렴하지만, 느린 쿼리는 비용이 많이 듭니다.
3. N+1 쿼리 문제
사용자 대시보드가 47개의 별도 쿼리를 실행했습니다.
- 사용자 하나당 게시물 하나, 댓글 수 하나, 좋아요 수 하나 등…
페이지 로드 시간: 4.2초
// ❌ N+1 쿼리 지옥
const { data: posts } = await supabase
.from('posts')
.select('*')
.eq('user_id', userId)
for (const post of posts) {
const { count: commentCount } = await supabase
.from('comments')
.select('*', { count: 'exact', head: true })
.eq('post_id', post.id)
const { count: likeCount } = await supabase
.from('likes')
.select('*', { count: 'exact', head: true })
.eq('post_id', post.id)
}
해결책
// ✅ 조인 하나로 해결
const { data: posts } = await supabase
.from('posts')
.select(`
*,
comments(count),
likes(count)
`)
.eq('user_id', userId)
페이지 로드 시간: 180ms
다르게 할 점
Supabase의 조인 문법을 활용해 관련 데이터를 한 번에 가져오세요. 루프 안에서 쿼리를 반복한다면 잘못된 접근입니다.
4. 클라이언트 컴포넌트 남용
React에서 배운 대로 모든 것을 클라이언트 컴포넌트로 만들었습니다.
번들 크기: 847KB → 첫 번째 의미 있는 페인트(FCP): 3.1초
// ❌ 클라이언트 컴포넌트에서 데이터 패치
'use client'
export default function PostsPage() {
const [posts, setPosts] = useState([])
useEffect(() => {
async function fetchPosts() {
const { data } = await supabase.from('posts').select('*')
setPosts(data)
}
fetchPosts()
}, [])
return {/* render posts */}
}
해결책
// ✅ 서버 컴포넌트
export default async function PostsPage() {
const supabase = await createClient()
const { data: posts } = await supabase.from('posts').select('*')
return /* render posts */
}
번들 크기: 124KB → FCP: 0.8초
다르게 할 점
기본은 서버 컴포넌트를 사용하고, 인터랙션, 브라우저 API, 훅이 필요할 때만 클라이언트 컴포넌트를 도입하세요. 데이터를 서버에서 가져와 HTML을 클라이언트에 전달하면 사용자 경험이 크게 개선됩니다.
5. 캐시 없이 매번 DB 호출
모든 페이지 로드가 DB를 직접 호출했습니다. 결과적으로 Supabase 비용이 $800/월에 달했습니다.
// ❌ 캐시 없음
export default async function PostPage({ params }) {
const { data: post } = await supabase
.from('posts')
.select('*')
.eq('id', params.id)
.single()
return {post.title}
}
해결책
// ✅ 캐시 적용
export const revalidate = 3600 // 1시간
export default async function PostPage({ params }) {
const { data: post } = await supabase
.from('posts')
.select('*')
.eq('id', params.id)
.single()
return {post.title}
}
쿼리 수가 94% 감소했고, 비용은 $180/월이 되었습니다.
다르게 할 점
실시간이 필요 없는 데이터는 모두 캐시하세요.
| 데이터 종류 | 캐시 기간 |
|---|---|
| 블로그 포스트 | 1시간 |
| 사용자 프로필 | 5분 |
| 정적 콘텐츠 | 24시간 |
| 개인화 데이터 | 캐시 없음 |
데이터가 변경될 때는 revalidatePath()를 사용해 캐시를 무효화합니다.
6. 연결 제한 초과
50K 사용자가 되면서 “too many connections” 오류가 발생했습니다.
Supabase 연결 제한:
- 무료 플랜: 60개
- Pro 플랜: 200개
우리는 요청당 새 연결을 열고 있었습니다.
// ❌ 요청당 새 연결
export default async function handler(req, res) {
const supabase = createClient() // 새로운 연결
const { data } = await supabase.from('posts').select('*')
res.json(data)
}
해결책
Supabase 내장 연결 풀링을 사용하고, 데이터베이스 설정에서 트랜잭션 풀링을 활성화합니다. 서버리스 환경에서는 풀러 URL을 지정합니다.
// 서버리스용 풀러 사용
const supabase = createClient(
process.env.SUPABASE_URL,
process.env.SUPABASE_ANON_KEY,
{
db: {
schema: 'public',
},
global: {
headers: { 'x-connection-pooler': 'true' },
},
}
)
다르게 할 점
첫날부터 연결 풀링을 활성화하고, Supabase 대시보드에서 연결 사용량을 모니터링하세요. 제한에 다다르기 전에 플랜을 업그레이드합니다.
7. 마이그레이션 없이 스키마 직접 수정
Supabase Studio에서 직접 스키마를 바꾸고, 마이그레이션이나 버전 관리를 하지 않았습니다. 스테이징 배포 시 실제 프로덕션 스키마를 알 수 없었고, 테이블을 수동으로 재생성하면서 컬럼을 놓치고 인덱스를 빼먹어 스테이징이 깨졌습니다.
다르게 할 점
처음부터 마이그레이션을 사용하세요.
# 마이그레이션 생성
npx supabase migration new add_posts_table
# SQL 작성 (supabase/migrations/20260314_add_posts_table.sql)
# 로컬에서 적용
npx supabase db reset
# 프로덕션에 푸시
npx supabase db push
스키마 변경이 모두 버전 관리되므로, 언제든지 데이터베이스를 처음부터 재현하고 여러 환경에 자신 있게 배포할 수 있습니다. 마이그레이션은 부담이 아니라 보험입니다.
결론
Next.js와 Supabase는 충분히 확장 가능합니다. 하지만 다음을 반드시 지키세요.
- 첫날부터 RLS 활성화
- 초기 단계에서 인덱스 추가
- N+1