Supabase + Next.js 실전: @supabase/ssr 클라이언트 설정과 RLS가 핵심인 이유
이 글의 핵심
Firebase에서 Supabase로 옮기면 문서·컬렉션 대신 Postgres 테이블로 데이터를 설계해야 해서 처음에는 헷갈리기 쉽습니다. 가장 중요한 건 RLS로, anon 키를 쓰는 클라이언트는 정책이 없으면 데이터를 그대로 노출합니다. 스토리지 정책을 따로 잡아야 하는 이유와 마이그레이션 파일로 스키마를 관리하는 습관까지 짚습니다.
Firebase에서 Supabase로 옮기면 처음에는 꽤 헷갈립니다. 인증·실시간·스토리지라는 구성은 비슷해 보이지만 데이터 모델이 완전히 다르기 때문입니다. Firestore는 문서와 컬렉션이고 Supabase는 그냥 PostgreSQL이라서, 비정규화된 문서를 여러 번 읽어 앱에서 합치던 방식 대신 “필요한 데이터를 쿼리 한 번으로 가져오는” 방식으로 생각을 바꿔야 합니다. 저는 이쪽이 오히려 편하다고 봅니다. 조인, 트랜잭션, 외래 키와 CHECK 제약이 처음부터 들어가 있어서, 데이터 정합성을 앱 코드로 억지로 맞추다가 나중에 스키마를 갈아엎는 일이 줄어듭니다.
그리고 Supabase를 쓸 때 정말 핵심은 RLS(Row Level Security) 설정입니다. SDK로 from('posts') 쿼리를 날릴 때, “이 사용자는 이 행만 볼 수 있다”는 규칙을 앱 서버 코드에 흩어 두지 말고 DB 정책으로 박아 두는 것이 안전합니다. 이유는 구조에 있습니다. 브라우저에서 쓰는 anon 키는 이름 그대로 공개 키라서 번들에서 누구나 꺼낼 수 있고, 그 키만 있으면 앱을 거치지 않고 https://<project>.supabase.co/rest/v1/posts를 curl로 직접 호출할 수 있습니다. RLS가 꺼진 테이블은 이 요청에 전체 행을 그대로 돌려줍니다. 그래서 테이블을 만들면 가장 먼저 RLS를 켜고 정책부터 짜는 순서를 권합니다. 저는 “Supabase = Postgres + RLS”라고 외워 둡니다.
Supabase는 2020년에 나온 Firebase 대안으로, 인증·DB·스토리지·리얼타임·엣지 함수가 한 대시보드에 모여 있습니다. 각 기능은 기존 오픈소스를 조합한 것입니다. REST API는 PostgREST, 인증은 GoTrue 계열 서버, 실시간은 Elixir로 만든 Realtime 서버가 담당합니다. 그래서 Docker로 셀프호스팅이 가능하고, Google 생태계 종속이 부담스러운 팀이 많이 선택합니다. 프로젝트는 supabase.com에서 만들고, 지역은 사용자와 가까운 곳(한국이라면 Seoul)을 고르면 됩니다. 지역은 나중에 바꿀 수 없어서 처음에 신중하게 골라야 합니다. 로컬 개발은 Supabase CLI(npx supabase 또는 패키지 매니저로 설치) 후 supabase init → supabase start로 로컬 스택을 띄우며, Docker가 필요합니다. 참고로 npm i -g supabase 전역 설치는 지원되지 않으니 npx나 Homebrew/Scoop을 쓰는 편이 맞습니다.
Next.js 클라이언트 설정
Next.js에서는 @supabase/supabase-js와 @supabase/ssr를 함께 쓰는 것이 표준입니다. .env.local에는 NEXT_PUBLIC_SUPABASE_URL과 NEXT_PUBLIC_SUPABASE_ANON_KEY만 넣어도 시작할 수 있습니다(최근 프로젝트는 같은 역할의 sb_publishable_... 형식 키를 발급하기도 합니다). service_role 키는 RLS를 우회하는 관리자 키이므로 절대 NEXT_PUBLIC_ 접두사를 붙이면 안 됩니다. 붙이는 순간 클라이언트 번들에 포함됩니다.
클라이언트를 서버용과 브라우저용으로 나누는 이유는 세션 저장 위치 때문입니다. @supabase/ssr는 세션을 쿠키에 저장하는데, 서버 컴포넌트·라우트 핸들러·미들웨어는 Next.js의 cookies() API로 그 쿠키를 읽고 써야 하고, 브라우저는 document.cookie를 직접 다룹니다. 두 환경의 쿠키 접근 방식이 달라서 팩토리 함수를 따로 두는 것입니다.
클라이언트 예시(서버용)
// lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr';
import { cookies } from 'next/headers';
export async function createClient() {
const cookieStore = await cookies(); // Next.js 15부터 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 {
// 서버 컴포넌트에서는 쿠키를 쓸 수 없음 — 미들웨어가 세션 갱신을 담당하면 무시해도 됨
}
},
},
}
);
}
예전 문서에는 get/set/remove 세 메서드를 넘기는 방식이 나오는데, 현재 @supabase/ssr는 getAll/setAll 방식을 권장하고 이전 방식은 deprecated입니다. 세션 토큰이 커지면 쿠키가 sb-...-auth-token.0, .1처럼 여러 조각으로 나뉘는데, 개별 get/set 방식은 이 조각을 일부만 갱신해 세션이 깨지는 문제가 있었기 때문입니다. setAll을 try/catch로 감싼 것은 서버 컴포넌트가 렌더링 중에 쿠키를 쓸 수 없어서입니다. 이 경우 Cookies can only be modified in a Server Action or Route Handler 에러가 나므로, 토큰 갱신은 미들웨어에서 처리하고 서버 컴포넌트에서는 무시하도록 합니다.
브라우저
// lib/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!
);
}
createBrowserClient는 내부적으로 싱글턴을 돌려주므로 컴포넌트마다 호출해도 연결이 여러 개 생기지 않습니다. 반대로 서버용 클라이언트는 요청마다 쿠키가 다르기 때문에 모듈 최상단에서 한 번 만들어 재사용하면 안 되고, 요청을 처리할 때마다 createClient()를 새로 호출해야 합니다. 이것을 전역으로 캐싱하면 다른 사용자의 세션이 섞이는 심각한 버그가 됩니다.
인증
인증은 signUp / signInWithPassword / signInWithOAuth / signOut / getUser 정도로 대부분 해결되고, onAuthStateChange를 쓰면 브라우저에서 로그인·로그아웃·토큰 갱신 이벤트를 받아 UI를 갱신하기 쉽습니다. Firebase에서 왔다면 “Auth UID가 이제 Postgres auth.users.id이고, 내 테이블의 user_id 외래 키가 그것을 가리킨다”고 생각하면 이해가 빠릅니다.
서버 쪽에서 흔히 하는 실수는 getSession()으로 사용자를 확인하는 것입니다. getSession()은 쿠키에 저장된 세션을 그대로 읽어 올 뿐 토큰을 인증 서버에 검증하지 않기 때문에, 조작된 쿠키도 통과할 수 있습니다. 권한 판단이 필요한 서버 코드에서는 인증 서버에 토큰을 확인하는 getUser()(또는 서명을 검증하는 getClaims())를 써야 합니다. 또 미들웨어에서 세션을 갱신하지 않으면 액세스 토큰(기본 1시간)이 만료된 뒤 서버 컴포넌트에서는 로그아웃 상태로 보이는데 브라우저에서는 로그인 상태로 보이는 불일치가 생깁니다. 공식 예제의 middleware.ts를 그대로 가져와 두는 것이 가장 확실합니다.
데이터베이스와 RLS
DB는 PostgREST가 테이블을 REST API로 노출하고, SDK의 쿼리 빌더가 그 API를 호출하는 구조입니다. insert·select·update·delete는 익숙한 체이닝 문법이고, RLS가 켜져 있으면 정책이 먼저 행을 걸러 냅니다. 그러니 eq('user_id', user.id) 같은 필터만 앱 코드에 의존하지 말고, 진짜 규칙은 RLS에 써 두는 편이 낫습니다. 서버 코드에서 필터를 빠뜨려도 DB가 막아 줍니다. 앱 쪽 필터는 성능과 가독성을 위해 같이 두면 됩니다.
RLS 기본 정책 예시
ALTER TABLE posts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Anyone can view posts"
ON posts FOR SELECT
USING (true);
CREATE POLICY "Users can insert own posts"
ON posts FOR INSERT
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own posts"
ON posts FOR UPDATE
USING (auth.uid() = user_id);
CREATE POLICY "Users can delete own posts"
ON posts FOR DELETE
USING (auth.uid() = user_id);
앞에서 말한 핵심이 바로 이것입니다. SELECT는 열어 두고 쓰기는 auth.uid()에 묶는 방식입니다. USING은 “기존 행 중 어떤 행에 접근할 수 있는가”, WITH CHECK는 “새로 쓰거나 바뀐 결과 행이 조건을 만족하는가”를 검사합니다. UPDATE 정책에 WITH CHECK를 생략하면 PostgreSQL은 USING 조건을 결과 행에도 그대로 적용하므로, 위 정책으로도 user_id를 다른 사람 ID로 바꾸는 요청은 거부됩니다. 명시적으로 쓰고 싶다면 WITH CHECK (auth.uid() = user_id)를 추가하면 됩니다.
RLS를 처음 켰을 때 가장 자주 만나는 증상은 에러가 아니라 빈 배열입니다. SELECT 정책이 없으면 쿼리는 성공하고 data: []를 돌려주기 때문에, 버그를 찾느라 앱 코드를 한참 뒤지게 됩니다. INSERT에서 정책을 통과하지 못하면 new row violates row-level security policy for table "posts" 에러가 납니다. 저도 처음에는 insert 뒤에 .select()를 붙였다가 이 에러를 봤는데, INSERT 정책은 있었지만 방금 넣은 행을 돌려받으려면 SELECT 정책도 통과해야 한다는 점을 몰랐기 때문이었습니다.
프로덕션에서는 USING (true)로 모두 열어 둔 SELECT도 조심해야 합니다. 공개 글과 비공개 글이 한 테이블에 있다면 USING (is_public OR auth.uid() = user_id)처럼 컬럼과 정책을 함께 써서 나누는 것이 일반적입니다. 성능 면에서는 정책 조건에 쓰는 user_id에 인덱스를 걸고, auth.uid()를 (select auth.uid())로 감싸 두면 행마다 함수가 다시 평가되지 않아 큰 테이블에서 눈에 띄게 빨라집니다.
실시간 구독
실시간은 채널을 만들고 postgres_changes로 테이블 이벤트를 받으면 됩니다. Firestore의 snapshot 리스너와 비슷한 느낌이지만, 밑에서는 Postgres의 논리 복제(WAL)를 읽어 변경을 전달하는 방식입니다.
const channel = supabase
.channel('posts')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'posts' },
(payload) => console.log(payload)
)
.subscribe();
이 코드를 넣었는데 이벤트가 하나도 안 온다면 대부분 두 가지 원인입니다. 첫째, 테이블이 supabase_realtime 퍼블리케이션에 추가되어 있지 않은 경우입니다. 대시보드의 Replication 설정에서 테이블을 켜거나 ALTER PUBLICATION supabase_realtime ADD TABLE posts;를 실행해야 합니다. 둘째, RLS 때문입니다. 실시간 이벤트도 구독한 사용자의 권한으로 SELECT 정책을 통과한 행만 전달되므로, 정책이 없으면 조용히 아무것도 오지 않습니다. 또 컴포넌트가 언마운트될 때 supabase.removeChannel(channel)을 호출하지 않으면 React 개발 모드의 이중 마운트와 겹쳐 같은 이벤트를 두 번씩 받는 일이 생깁니다. 변경 이벤트는 행마다 권한 검사를 하기 때문에 구독자가 많은 테이블에서는 부하가 커지는데, 채팅처럼 빈도가 높은 기능은 DB 변경 구독 대신 Broadcast 채널을 쓰는 것이 공식 권장입니다.
스토리지
스토리지는 버킷을 하나 만들고 upload 후 getPublicUrl로 URL을 얻으면 됩니다. 여기서 놓치기 쉬운 점은 스토리지 권한이 테이블 RLS와 별개로 storage.objects 테이블의 정책으로 관리된다는 것입니다. 버킷을 public으로 만들면 URL을 아는 누구나 파일을 읽을 수 있으므로 “파일 URL만 숨기면 된다”는 생각은 보안이 아닙니다. 사용자별 파일이라면 private 버킷에 (storage.foldername(name))[1] = auth.uid()::text 같은 정책으로 경로 첫 폴더를 사용자 ID로 묶고, 읽을 때는 createSignedUrl로 만료 시간이 있는 URL을 발급하는 방식이 표준입니다. 업로드가 new row violates row-level security policy 에러로 실패한다면 버킷 정책에 INSERT가 없는 것입니다.
Edge Functions
Edge Functions는 Deno 런타임에서 돌아가며 supabase functions new → 로컬 supabase functions serve → supabase functions deploy 흐름으로 개발합니다. 결제 처리, 외부 웹훅 수신, 이메일 발송처럼 비밀 키를 절대 클라이언트에 내려보내면 안 되는 작업에 적합합니다. 비밀 값은 supabase secrets set STRIPE_KEY=...로 등록하고 함수 안에서 Deno.env.get()으로 읽습니다. 기본적으로 함수 호출에는 JWT 검증이 걸려 있어서, Stripe 웹훅처럼 외부 서비스가 호출하는 함수는 --no-verify-jwt로 배포하고 대신 웹훅 서명을 직접 검증해야 합니다. 이 설정을 모르고 배포하면 외부 호출이 전부 401로 떨어집니다. 단순한 CRUD라면 Edge Function을 만들기보다 RLS와 SDK 쿼리로 해결하는 편이 코드도 적고 지연도 짧습니다.
Supabase와 Firebase, 어느 쪽인가
기능표를 나란히 놓고 비교할 단계는 지났다고 보고 한 줄로 말하면, SQL·오픈소스·셀프호스팅이 중요하면 Supabase, 모바일 SDK의 오프라인 동기화와 Google 생태계 연동이 우선이면 Firebase가 무난한 경우가 많습니다. Firestore에서 여러 컬렉션에 데이터를 복제해 가며 억지로 표현하던 “관계”를 외래 키와 RLS로 풀 수 있을 때 Supabase로 옮기는 효과가 가장 큽니다. 반대로 Firestore의 오프라인 캐시와 자동 동기화에 크게 의존하는 모바일 앱이라면 Supabase에는 같은 수준의 기본 기능이 없어서 직접 구현해야 할 부분이 늘어납니다.
마지막으로, 무료 티어로 샌드박스를 만들되 대시보드에서 테이블을 클릭으로 만들지 말고 supabase migration new로 supabase/migrations 폴더에 SQL 파일을 쌓는 습관을 들이면 운영이 훨씬 편해집니다. 대시보드에서만 바꾼 스키마와 정책은 스테이징·프로덕션에 똑같이 재현하기 어렵고, 누가 언제 정책을 바꿨는지도 남지 않습니다. 이미 대시보드로 만들었다면 supabase db diff로 변경을 마이그레이션 파일로 뽑아낼 수 있습니다. 문서는 supabase.com/docs가 잘 정리되어 있습니다. 그리고 다시 강조하지만, RLS가 먼저입니다.
시작은 supabase.com에서 프로젝트 하나 만들고, RLS를 켜고 정책부터. SDK 이야기는 그다음입니다.
같이 보면 좋은 글
- Drizzle ORM 실전
- Turso(libSQL) 시작하기
- tRPC: End-to-End 타입 안전 API, 미들웨어·Zod·React Query 통합과 REST 비교
- Remix 프레임워크