Supabase + Next.js 가이드: 인증·RLS·Storage
이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
목차
Supabase는 Firebase의 오픈소스 대안으로,
PostgreSQL 데이터베이스·인증·스토리지·Edge Functions를 무료로 시작할 수 있는 BaaS(Backend as a Service)입니다. Next.js App Router와 조합하면 별도 백엔드 서버 없이 풀스택 앱을 빠르게 구축할 수 있습니다.
이 가이드는 Supabase 프로젝트 설정부터 클라이언트 분리, 첫 데이터 조회, 인증 흐름,
Row Level Security(RLS), Storage, Edge Functions, 그리고 흔한 함정과 트러블슈팅까지 한 번에 다룹니다.
이 가이드는 Supabase JS v2, Next.js 14/15 App Router 기준입니다. 버전이 다르면 API가 다를 수 있습니다. 최신 변경사항은 supabase.com/docs에서 확인하세요.
Supabase 프로젝트 생성#
supabase.com에서 무료 계정을 만든 뒤 새 프로젝트를 생성합니다.
프로젝트 생성 후 Project Settings → API 메뉴에서 두 값을 확인합니다.
| 항목 | 설명 |
|---|---|
Project URL | Supabase API 엔드포인트 |
anon public key | 클라이언트에서 사용하는 공개 키 |
service_role key | 서버 전용 비밀 키(절대 클라이언트 노출 금지) |
anon 키는 RLS 정책 하에 클라이언트에 노출 가능하지만, service_role 키는 서버 사이드 전용이며 모든 RLS를 우회합니다.
Free Tier 제한(2026년 기준)#
- 데이터베이스: 500 MB
- 스토리지: 1 GB
- 월 활성 사용자: 50,000명
- 7일 비활성 시 일시 정지(다시 접속 시 깨어남)
패키지 설치#
Next.js 프로젝트 루트에서 Supabase 클라이언트와 SSR 헬퍼를 설치합니다.
npm install @supabase/supabase-js @supabase/ssr
@supabase/supabase-js: 핵심 클라이언트 라이브러리@supabase/ssr: Next.js App Router 서버 컴포넌트·미들웨어용 쿠키 처리 헬퍼
환경 변수 설정#
프로젝트 루트에 .env.local 파일을 만들고 앞서 확인한 값을 입력합니다.
# .env.local
NEXT_PUBLIC_SUPABASE_URL=https://xxxx.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
# 서버 전용(절대 NEXT_PUBLIC_ 접두사 붙이지 않기)
SUPABASE_SERVICE_ROLE_KEY=your-service-role-key
주의:
.env.local은.gitignore에 자동 포함되어 있지만 확인 필수.service_role키는 절대 git에 커밋하면 안 됩니다.
NEXT_PUBLIC_ 접두사가 없으면 클라이언트 번들에 포함되지 않아 브라우저에서 undefined가 됩니다. 반대로 비밀 키에 NEXT_PUBLIC_ 붙이면 클라이언트에 노출되니 주의.
Supabase 클라이언트 유틸리티 작성#
App Router에서는 클라이언트 컴포넌트용과 서버 컴포넌트용 클라이언트를 분리해서 사용합니다.
클라이언트 컴포넌트용#
// utils/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);
}
서버 컴포넌트·Route Handler용#
// utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// 서버 컴포넌트에서 쿠키 쓰기는 무시
}
},
},
}
);
}
두 파일로 분리하는 이유는 Next.js App Router가 서버/클라이언트 경계를 엄격히 구분하기 때문입니다. 서버에서 createBrowserClient를 임포트하면 빌드 오류가 발생합니다.
미들웨어용: 세션 갱신#
// middleware.ts
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
let response = NextResponse.next({ request });
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll: () => request.cookies.getAll(),
setAll: (cookiesToSet) => {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
response.cookies.set(name, value, options);
});
},
},
}
);
// 세션 자동 갱신
await supabase.auth.getUser();
return response;
}
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
미들웨어가 모든 요청에서 세션을 갱신해 만료된 토큰 문제를 방지합니다.
첫 번째 데이터 조회: 서버 컴포넌트#
Supabase 대시보드에서 간단한 posts 테이블을 만든 뒤,
서버 컴포넌트에서 데이터를 가져오는 예시입니다.
// app/posts/page.tsx
import { createClient } from "@/utils/supabase/server";
export default async function PostsPage() {
const supabase = await createClient();
const { data: posts, error } = await supabase
.from("posts")
.select("id, title, created_at")
.order("created_at", { ascending: false });
if (error) {
return <p>데이터를 불러오지 못했습니다.</p>;
}
return (
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
서버 컴포넌트에서 데이터를 직접 조회하므로 useEffect + fetch 패턴 없이 깔끔하게 처리됩니다.
클라이언트 컴포넌트에서 실시간 구독#
상태가 자주 변하는 데이터(채팅·알림 등)는 클라이언트 컴포넌트에서 실시간 구독(Realtime)을 활용합니다.
// app/chat/ChatMessages.tsx
"use client";
import { useEffect, useState } from "react";
import { createClient } from "@/utils/supabase/client";
type Message = { id: number; content: string };
export default function ChatMessages() {
const [messages, setMessages] = useState<Message[]>([]);
const supabase = createClient();
useEffect(() => {
// 초기 데이터 로드
supabase
.from("messages")
.select("id, content")
.then(({ data }) => setMessages(data ?? []));
// 실시간 구독
const channel = supabase
.channel("messages")
.on(
"postgres_changes",
{ event: "INSERT", schema: "public", table: "messages" },
(payload) => {
setMessages((prev) => [...prev, payload.new as Message]);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, []);
return (
<ul>
{messages.map((m) => (
<li key={m.id}>{m.content}</li>
))}
</ul>
);
}
인증: Email/Password + OAuth#
이메일·비밀번호 가입#
"use client";
import { createClient } from "@/utils/supabase/client";
export async function signUp(email: string, password: string) {
const supabase = createClient();
const { data, error } = await supabase.auth.signUp({
email,
password,
});
return { data, error };
}
export async function signIn(email: string, password: string) {
const supabase = createClient();
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
return { data, error };
}
export async function signOut() {
const supabase = createClient();
await supabase.auth.signOut();
}
OAuth(Google·GitHub·Kakao)#
const { data, error } = await supabase.auth.signInWithOAuth({
provider: "google",
options: {
redirectTo: `${window.location.origin}/auth/callback`,
},
});
Kakao·Naver 등 한국 OAuth 제공자도 Supabase Dashboard → Authentication → Providers에서 설정 가능합니다.
OAuth 콜백 처리#
// app/auth/callback/route.ts
import { createClient } from "@/utils/supabase/server";
import { NextResponse } from "next/server";
export async function GET(request: Request) {
const { searchParams, origin } = new URL(request.url);
const code = searchParams.get("code");
if (code) {
const supabase = await createClient();
await supabase.auth.exchangeCodeForSession(code);
}
return NextResponse.redirect(`${origin}/`);
}
Row Level Security(RLS): 데이터 보호의 핵심#
Supabase는 기본적으로 모든 테이블에 RLS가 활성화되어 있습니다. 정책을 추가하지 않으면 인증된 사용자도 데이터에 접근할 수 없습니다.
기본 RLS 정책#
Supabase Dashboard → Database → Policies에서 SQL로 정책 작성:
-- 누구나 게시글 읽기 가능
CREATE POLICY "Public read access" ON posts
FOR SELECT USING (true);
-- 작성자만 자신의 글 수정 가능
CREATE POLICY "Users can update own posts" ON posts
FOR UPDATE USING (auth.uid() = user_id);
-- 인증된 사용자만 글 작성 가능
CREATE POLICY "Authenticated users can insert" ON posts
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
-- 작성자만 삭제 가능
CREATE POLICY "Users can delete own posts" ON posts
FOR DELETE USING (auth.uid() = user_id);
auth.uid()는 현재 인증된 사용자의 UUID를 반환합니다.
흔한 RLS 실수#
- 정책 없이 RLS 활성 → 모든 쿼리 빈 결과
- INSERT 정책 누락 → 가입 후 프로필 생성 실패
- 같은 정책 중복 등록 → 의도치 않은 권한
Storage: 파일 업로드·다운로드#
// 업로드
const { data, error } = await supabase.storage
.from("avatars")
.upload(`${userId}/avatar.png`, file);
// Public URL
const { data: urlData } = supabase.storage
.from("avatars")
.getPublicUrl(`${userId}/avatar.png`);
// 다운로드
const { data: downloadData } = await supabase.storage
.from("avatars")
.download(`${userId}/avatar.png`);
Storage도 RLS 정책으로 권한 관리. 버킷별로 정책 작성 가능.
Edge Functions: Deno 기반 서버리스#
// supabase/functions/hello/index.ts
import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
serve(async (req) => {
const { name } = await req.json();
return new Response(JSON.stringify({ message: `Hello ${name}!` }), {
headers: { "Content-Type": "application/json" },
});
});
배포: supabase functions deploy hello. 웹훅·결제 처리·이미지 변환 등에 활용.
흔한 실수와 트러블슈팅#
1. cookies()를 await 없이 사용하기#
Next.js 15부터 cookies()가 Promise를 반환합니다. await cookies()로 호출해야 합니다.
2. 서버/클라이언트 클라이언트 혼용#
서버 컴포넌트에서 createBrowserClient를 임포트하면 window is not defined 오류가 납니다. 반드시 파일별로 분리해서 사용합니다.
3. RLS 미설정으로 빈 결과#
// 데이터가 있는데 빈 배열 반환
const { data } = await supabase.from("posts").select("*");
console.log(data); // []
RLS 정책 누락이 99% 원인. Dashboard에서 SELECT 정책 추가.
4. 환경 변수 이름 오류#
NEXT_PUBLIC_ 접두사가 없으면 클라이언트 번들에 포함되지 않아 브라우저에서 undefined. 반대로 service_role 키에 NEXT_PUBLIC_ 붙이면 보안 사고.
5. select에 컬럼 누락#
// 잘못된 예 — 모든 컬럼 가져오기 (성능 저하·보안 위험)
const { data } = await supabase.from("posts").select("*");
// 권장 — 필요한 컬럼만 명시
const { data } = await supabase.from("posts").select("id, title, created_at");
6. 무한 루프 useEffect#
useEffect(() => {
supabase.from("posts").select("*").then(setData);
// supabase 객체가 매 렌더링마다 새로 생성되어 무한 루프
}, [supabase]);
supabase 클라이언트는 useMemo 또는 컴포넌트 외부에서 한 번만 생성:
const supabase = useMemo(() => createClient(), []);
JWT 토큰과 인증 흐름#
Supabase 인증은 내부적으로 JWT를 사용합니다. 발급된 토큰의 클레임·만료 시각을 확인하고 싶다면 JWT 디코더를 활용해보세요.
인증 관련 포스트: JWT란 무엇인가? 구조와 디코딩 방법
자주 묻는 질문#
Q. Supabase는 정말 무료인가요?
A. Free Tier(500MB DB, 1GB 스토리지, 50,000 MAU)는 평생 무료. Pro($25/월)부터는 7일 비활성 일시정지 없음, 더 많은 자원 제공.
Q. Firebase에서 Supabase로 마이그레이션할 수 있나요?
A. 가능합니다. Supabase는 PostgreSQL 기반이라 데이터 모델 차이(NoSQL → SQL)를 처리해야 하지만,
마이그레이션 도구와 가이드가 잘 갖춰져 있습니다.
Q. RLS 정책 디버깅은 어떻게 하나요?
A. SQL Editor에서 SET ROLE authenticated; 후 쿼리 실행으로 정책 효과 확인 가능. 또는 Dashboard의 "Test policies" 기능 활용.
Q. Supabase와 Vercel 배포 시 주의점은?
A. 환경 변수를 Vercel Dashboard → Project Settings → Environment Variables에 동일하게 등록. NEXT_PUBLIC_ 변수는 빌드 시점에 인라인되므로 변경 후 재배포 필요.
Q. PostgreSQL 직접 접근할 수 있나요?
A. 가능합니다. Project Settings → Database에서 connection string 제공. Prisma·Drizzle·다른 PostgreSQL 도구 모두 호환.
다음 단계#
이 가이드에서는 Supabase 연동의 핵심을 다뤘습니다. 실제 앱 개발에서는 다음 순서로 확장합니다.
- 인증 강화: MFA, Magic Link, Phone Auth. Supabase Auth 공식 문서
- 타입 자동 생성:
npx supabase gen types typescript --project-id xxxx > types.ts - 로컬 개발:
supabase start로 도커 기반 로컬 환경 - 실시간 협업: Presence·Broadcast 채널
JWT 디코더로 토큰 확인하기 → | 정규식 가이드 | Base64 가이드
참고: 이 가이드는 Supabase JS v2, Next.js 14/15 App Router 기준입니다. 각 버전의 최신 변경사항은 공식 문서에서 확인하세요.