Supabase Auth 소셜 로그인 완전 가이드 — Google·GitHub OAuth와 RLS 연동 3개월 회고

목차

AuthApiError: redirect_to_mismatch
  at SupabaseAuthClient._handleProviderSignIn
{
  code: 'redirect_to_mismatch',
  redirect_to:    'http://localhost:3000/auth/callback',
  registered_uri: 'http://localhost:5173/auth/callback',
  provider: 'google'
}

이처럼, Supabase Auth 소셜 로그인을 처음 붙이던 날 가장 먼저 본 에러다. 프론트 dev 서버는 Vite(5173), 새로 만든 Next.js 콜백 라우트는 3000, Google Cloud Console에 등록한 URI는 5173. 어느 쪽 포트가 정답인지부터 헷갈렸다. 프론트엔드만 4년 하다 백엔드로 옮긴 지 2년차, 사이드 프로젝트 인증을 혼자 짜야 하는 상황이었다. 3개월짜리 B2C 베타가 끝난 지금, 그 시점부터 RLS 연동까지의 흐름을 시간순으로 풀어본다.

프로젝트 배경과 Supabase Auth를 택한 이유

즉, 후보는 셋이었다. Firebase Auth + Firestore, Auth0 + 직접 DB, Supabase Auth + Postgres RLS. 평가 기준은 두 가지로 좁혔다. 첫째, 백엔드 전환자 입장에서 "어디까지가 SQL로 풀리는가". 둘째, 무료 한도가 베타 3개월을 버틸 수 있는가.

옵션 무료 MAU DB 권한 통합 학습 곡선
Firebase Auth + Firestore 무제한(스팟적 과금) Security Rules 별도 작성 낮음
Auth0 + 직접 DB 7,500 없음 (직접 구현) 중간
Supabase Auth + Postgres RLS 50,000 RLS로 SQL 안에서 처리 중간

그러나, (가격은 2026년 6월 기준이며 Supabase Pro 요금제는 별도 확인이 필요하다.)

Firebase는 Firestore가 NoSQL이라 관계형 모델링이 답답했다. Auth0는 무료 한도가 빨리 차고, DB 권한은 어차피 백엔드에서 다시 짜야 했다. Supabase는 인증 사용자(auth.users)가 Postgres 테이블로 잡히고, RLS 정책에서 auth.uid()를 그대로 참조할 수 있다는 점이 결정적이었다. "SQL 안에서 권한이 풀린다"는 게 프론트 출신에겐 신선했다.

Google OAuth — 첫 주를 통째로 날린 Redirect URI

따라서, 도입부에 붙인 에러가 정확히 이 단계에서 나왔다. Supabase Auth 소셜 로그인의 첫 관문은 항상 Redirect URI다. 정확히는 세 군데에 등록된 URI가 전부 정확한 역할로 맞아야 한다.

일치시켜야 하는 3개의 URI

그러나, 첫째, Google Cloud Console의 OAuth 클라이언트에 등록하는 "승인된 리디렉션 URI". 둘째, Supabase Dashboard → Authentication → URL Configuration → Site URL과 Redirect URLs. 셋째, 클라이언트 코드에서 signInWithOAuth({ options: { redirectTo } })에 넘기는 값.

내가 헤맨 지점은 첫째였다. Supabase는 자체 콜백 엔드포인트(https://<project>.supabase.co/auth/v1/callback)를 갖고 있고, Google에 등록할 URI는 내 사이트가 아니라 이 Supabase URL이어야 한다. 내 앱 URL은 Supabase가 인증 처리 후 사용자를 보낼 곳일 뿐이다. 공식 문서에는 한 줄로 적혀 있는데, 프론트만 하다 온 사람에겐 직관에 어긋난다. "내 사이트로 직접 돌아와야지" 하는 생각이 먼저 든다.

환경별로 갈리는 지점

그러나, 로컬, 스테이징, 프로덕션 세 환경을 다 등록해야 했다. Supabase는 Redirect URLs에 와일드카드(https://*.vercel.app/**)를 지원해서 PR 프리뷰 배포까지 커버되더라. 이건 공식 문서를 꼼꼼히 안 봤으면 매 프리뷰마다 막혔을 거다.

const { data, error } = await supabase.auth.signInWithOAuth({
  provider: 'google',
  options: {
    redirectTo: `${window.location.origin}/auth/callback`,
    queryParams: {
      access_type: 'offline', // refresh_token을 받으려면 필수
      prompt: 'consent',
    },
  },
})

access_type: 'offline'을 빼먹으면 refresh_token이 안 내려온다. 한 시간 뒤 세션이 끊기고도 한참 헤맸다. prompt: 'consent'는 사용자가 두 번째 로그인부터 동의 화면을 다시 보지 않게 해주지만, 동시에 refresh_token을 매번 새로 발급한다는 부수효과가 있다. 베타 단계엔 이게 더 안전했다.

GitHub OAuth — 의외로 30분 만에 끝났다

Google에서 일주일을 까먹어놓고 GitHub은 첫 시도에 됐다. 차이는 두 가지다.

실제로, GitHub OAuth Apps는 등록 화면이 단순하다. Homepage URL, Authorization callback URL 두 칸이 전부다. callback URL에 Supabase 콜백 주소를 넣고, Client ID와 Secret을 Supabase Dashboard의 Provider 설정에 붙여 넣으면 끝이다. scope는 기본값(read:user, user:email)으로 충분했다. 이메일이 public이 아닌 사용자도 user:email이 있으면 primary email을 받아온다.

또한, Google처럼 별도 consent screen 검수가 없어서 개발 단계 마찰이 거의 없었다. 백엔드 전환자 입장에서 비교하자면, GitHub은 "OAuth 명세대로만 따라가면 동작하는" 모범생이고 Google은 "자기네 정책 레이어가 위에 한 겹 더 올라간" 형태다.

실서비스에 Google OAuth를 붙일 때 scope에 `email`과 `profile`만 요청하면 OAuth consent screen이 “외부(External)” 상태에서도 별도 검수 없이 100명까지 사용 가능하다. `drive.readonly` 같은 sensitive scope를 추가하는 순간 검수 절차(평균 2~6주)로 들어간다. 소셜 로그인만 쓸 거라면 sensitive scope는 절대 추가하지 마라.

세션 관리 — 프론트 출신의 착각

예를 들어, 이 구간이 가장 오래 걸렸다. 프론트 시절엔 "세션이라는 게 결국 localStorage에 토큰 박아두는 것 아니냐"고 생각했다. Supabase JS SDK도 브라우저 기본 동작은 비슷하다. auth.getSession()을 부르면 localStorage에서 토큰을 꺼내준다.

SSR에서 깨진 가정

Next.js App Router로 SSR을 섞으면서 문제가 시작됐다. 서버 컴포넌트에서 getSession()을 부르면 localStorage가 없으니 항상 null이 떨어진다. 클라이언트는 로그인 상태인데 서버는 anonymous로 본다. RLS가 auth.uid()를 못 읽으니 모든 쿼리가 빈 결과를 돌려준다. 화면에 데이터가 안 나오는데 에러는 안 뜨는, 디버깅하기 까다로운 상태가 된다.

즉, 해결책은 @supabase/ssr 패키지(예전 auth-helpers-nextjs의 후속, 2024년 4월 GA)다. 쿠키 기반으로 세션을 동기화한다. 핵심은 서버용 클라이언트와 브라우저용 클라이언트를 분리해 만든다는 점이다.

// utils/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { cookies } from 'next/headers'

export function createClient() {
  const cookieStore = cookies()
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name) {
          return cookieStore.get(name)?.value
        },
        // set, remove는 Route Handler/Server Action에서만 동작한다
      },
    }
  )
}

그런데, 서버 컴포넌트에서 쿠키를 set하려고 하면 Next.js가 에러를 던진다. 이걸 알기 전엔 미들웨어에서 토큰 갱신을 처리해야 한다는 사실조차 몰랐다.

미들웨어에서 토큰 갱신

즉, Supabase access token은 기본 1시간 만료다. 만료되면 refresh token으로 갱신해야 한다. App Router에서는 미들웨어(middleware.ts)가 가장 안전한 갱신 지점이다. 모든 요청 앞단에서 쿠키를 점검하고, 만료 임박이면 새 토큰을 받아 응답 쿠키를 다시 쓴다.

// middleware.ts
import type { NextRequest } from 'next/server'
import { updateSession } from '@/utils/supabase/middleware'

export async function middleware(request: NextRequest) {
  return await updateSession(request)
}

export const config = {
  matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

반면, 이 패턴은 Supabase 공식 가이드(Next.js Server-Side Auth, 2024년 이후 권장)에 정리되어 있다. 안 따랐다면 30분짜리 세션을 새로고침마다 다시 만들었을 거다. 프론트 시절엔 "토큰 갱신은 axios interceptor에서 하지" 정도로 끝났는데, 풀스택에서는 갱신 지점이 미들웨어로 이동한다는 사실 자체가 새로웠다.

RLS 연동 — 인증의 진짜 본체

여기까지 오면 로그인은 된다. 사용자가 화면을 보고 있다. 데이터를 안 보여주면 의미가 없다. Supabase의 진짜 가치는 인증된 사용자의 uid를 SQL 안에서 그대로 권한 검사에 쓴다는 점이다. 이 구간을 깊게 안 판 게 3개월 프로젝트에서 가장 후회되는 부분이다.

기본 정책 — 본인 데이터만

-- 본인 글만 읽는다
create policy "users read own posts"
on posts for select
using (auth.uid() = author_id);

-- 본인만 글을 쓴다
create policy "users insert own posts"
on posts for insert
with check (auth.uid() = author_id);

auth.uid()는 JWT 안의 sub 클레임을 읽어온다. 클라이언트가 보낸 토큰이 유효하면 이 값이 들어오고, anonymous면 null이다. RLS가 켜진 테이블에 정책이 없으면 모든 행이 막힌다. 이걸 모르고 RLS만 켜놓고 "왜 빈 배열이 오지" 하면서 또 반나절을 보냈다.

가장 자주 실수한 부분

author_id 컬럼 기본값을 auth.uid()로 잡지 않고 클라이언트에서 직접 넣게 두면, 악의적인 사용자가 다른 사람의 id를 넣어 글을 쓸 수도 있다. RLS의 with check이 막아주긴 하지만, 컬럼 기본값으로 한 번 더 막는 게 안전하다.

alter table posts
  alter column author_id set default auth.uid();

그래서, 이 한 줄을 안 넣었더라면 베타 사용자 중 한 명이 호기심에 다른 사람 id를 넣어보고 "어, 에러는 안 뜨네" 하면서 이슈를 올렸을 가능성이 충분히 있다. 인증과 권한은 다른 레이어다. 로그인이 됐다고 권한이 자동으로 따라오지 않는다.

조인 테이블에서의 RLS

한편, 팀(teams)과 멤버(team_members) 구조에서 "팀에 속한 사용자만 팀 데이터를 본다"를 표현하려면 정책 안에서 서브쿼리를 쓴다.

create policy "team members read team"
on teams for select
using (
  exists (
    select 1 from team_members
    where team_members.team_id = teams.id
      and team_members.user_id = auth.uid()
  )
);

그러나, 처음엔 "매번 서브쿼리가 도는 것 아닌가" 싶었는데 explain analyze로 보면 정상적인 인덱스 스캔으로 풀린다(전제: team_members.user_id에 인덱스가 있을 것). Postgres가 정책을 쿼리 플랜에 통합하기 때문에 N+1처럼 비싸지지 않는다. 프론트에서는 클라이언트 단 권한 가드를 짜면서 "이거 누가 우회하면 어쩌지" 하는 불안이 항상 있었다. RLS는 그 불안을 데이터베이스 레이어에서 끝내준다.

중간에 터진 것 — 같은 이메일, 다른 provider

베타 오픈 2주차에 사용자 한 명이 GitHub로 가입한 뒤 다음 방문에서 Google로 로그인했다. 이메일은 같다. 그런데 auth.users에 새로운 row가 생기고, 이전 글이 다 사라진 것처럼 보였다. 슬랙으로 "내가 쓴 글 어디 갔어요?" 메시지가 왔다.

Supabase는 기본적으로 provider별로 별개의 user를 만든다. "같은 이메일이면 자동으로 합쳐 주겠지" 같은 기대가 통하지 않는다. 2024년 후반에 추가된 Identity Linking 기능(linkIdentity API)이 있긴 한데, 우리 프로젝트 시점엔 베타였고 안 썼다. 대신 가입 화면에 "처음 로그인할 때 사용한 provider로만 다시 로그인하세요"라고 안내를 박았다. 우아하지 않지만 작동은 한다.

(이게 SaaS 인증에서 가장 자주 새는 구멍인 것 같다. 사용자는 자기가 어떤 provider로 가입했는지 절대 기억하지 못한다.)

다시 짠다면 바꿀 것

3개월이 끝나고 돌아보니 시간 분배가 이상했다. OAuth 설정에 일주일, RLS 정책에 사흘. 비중이 거꾸로였어야 했다. 인증 자체는 한 번 설정하면 끝나는 일이고, RLS는 데이터 모델이 커질수록 계속 손이 가는 영역이다.

즉, 다음 프로젝트에서는 순서를 이렇게 잡을 생각이다.

  1. RLS 정책부터 SQL로 설계한다. 인증 붙이기 전에 "어떤 row를 누가 볼 수 있는지"를 정책으로 표현해두면, 나중에 OAuth가 붙었을 때 데이터가 자동으로 풀린다.
  2. 로컬 Supabase CLI로 마이그레이션과 정책을 코드로 관리한다. Dashboard에서 손으로 만든 정책은 운영 단계에서 되돌리기 까다롭다.
  3. @supabase/ssr 패키지를 처음부터 도입한다. 옛날 auth-helpers 시절 글이 검색에 많이 잡혀서 시간을 까먹었다. 2024년 5월 이후 자료만 참고하는 게 안전하다.

특히, 공식 자료는 Supabase Auth 문서의 Social Login 섹션과 supabase/auth GitHub 저장소의 Discussion이 가장 정확하다. 블로그 글은 버전이 안 맞을 가능성이 항상 있다.

그래서, 개인적으로는 Firebase의 매끄러움이 여전히 그립지만, RLS가 주는 "데이터베이스 안에서 권한이 끝난다"는 감각을 한 번 맛보면 인증을 또 별도 서비스에 두기 어려운 것 같다.

관련 글