Supabase로 Google OAuth 연동하기
해당 문서는 React SPA + Vite 환경을 기준으로 작성합니다.
Supabase Auth는 클라이언트 Javascript만로 Google OAuth를 구현할 수 있어 SPA 환경에 적합해 채택했습니다.
Google OAuth 2.0 동작 원리
구현하기 전 OAuth 로그인 시 내부적으로 어떤 일이 일어나는지 이해해야 합니다.
PKCE란?
SPA는 소스코드가 브라우저에 그대로 노출되는 공개 클라이언트입니다. 전통적인 Authrization Code 방식은 서버가 client_secret을 보유하고 Code -> Token 교환을 처리하는데, SPA에서는 client_secret을 안전하게 숨길 수 없습니다.
PKCE(Rroof Key. for Code Exchange)는 client_secret 없이도 Authorization Code 탈취 공격을 방어하기 위한 확장 스펙입니다. Supabase JS SDK v2부터는 signInWithOAuth() 가 PKCE를 기본값으로 적용합니다.
PKCE가 안전한 이유
code_verifier는 클라이언트의 localStorage에만 존재하므로, 공격자가 Authrization Code를 중간에 탈취하더라도 code_verifier를 알 수 없어 Token 교환이 불가합니다.
Supabase SDK가 code_verifier 생성, localStorage 저장, 콜백 시 복원까지 모두 자동으로 처리하므로 코드 상에 별도의 추가적은 필요없습니다.
구현하기
1. Supabase 클라이언트 초기화
// src/lib/supabase.ts
import { createClient } from '@supabase/supabase-js';
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL!,
import.meta.env.VITE_SUPABASE_ANON_KEY!,
{
auth: {
persistSession: true, // localStorage에 세션 유지
autoRefreshToken: true, // Access Token 만료 전 자동 갱신
},
},
);SUPABASE_URL : Supabase Dashboard > Project Settings > Data API
SUPABASE_ANON_KEY : Supabase Dashboard > Project Settings > API Keys
Vite를 사용할 경우 반드시 Vite_ 접두사를 붙여야 Vite 클라이언트 번들에 포함시켜 줍니다.
단, service_role 키에 절대 Vite_ 접두사를 붙이면 클라이언트에 키가 노출되어 RLS 관련 보안에 문제가 생길 수 있습니다.
2. useAuth 훅 구현
세션 상태는 전역에서 자주 참조되므로 커스텀 훅으로 관리합니다.
// src/hooks/useAuth.ts
import type { Session } from '@supabase/supabase-js';
import { useEffect, useState } from 'react';
import { supabase } from '../lib/supabase';
export default function useAuth() {
const [session, setSession] = useState<Session | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
// 1. 초기 마운트 시 localStorage에 저장된 세션 복원
const loadSession = async () => {
try {
const {
data: { session },
error,
} = await supabase.auth.getSession();
if (error) throw error;
setSession(session);
} catch (error) {
console.error('Error loading session:', error);
} finally {
setLoading(false);
}
};
loadSession();
// 2. 로그인/로그아웃/토큰 갱신 이벤트 구독
// 탭을 여러 개 열었을 때도 모든 탭의 세션이 동기화됨
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((_event, session) => setSession(session));
// 3. 컴포넌트 언마운트 시 구독 해제 (메모리 누수 방지)
return () => subscription.unsubscribe();
}, []);
// Google OAuth 로그인
const login = async () => {
const { error } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: {
redirectTo: `${window.location.origin}/auth/callback`,
scopes: 'openid email profile',
},
});
if (error) console.error('Error logging in:', error);
};
// 로그아웃
const logout = async () => {
const { error } = await supabase.auth.signOut();
if (error) console.error('Error logging out:', error);
};
return { session, loading, login, logout, user: session?.user };
}
getSession() 을 한 번 호출하는 것으로 유저의 인증 상태 변화에 반응할 수 없습니다. onAuthStateChnage는 유저의 인증 상태 변화를 구독하여 세션이 변경될 때 마다 React 상태를 자동으로 갱신합니다.
3. OAuth 콜백
Google 로그인 완료 후 브라우저는 redirectTo로 지정한 URL(`/auth/callback`)로 돌아옵니다.
PKCE 흐름에서는 Supabase가 콜백 URL에 ?code=... 형태의 쿼리 파라미터로 Authorization Code를 전달합니다. 기존의 Implicit 방식과는 차이가 있습니다. Supabase SDK가 이 Code를 감지하고 localStorage에 저장해둔 code_verifier와 함께 Supabase 서버로 전달해 Token을 교환합니다.
// src/page/AuthCallbackPage.tsx
import { useEffect } from 'react';
import { supabase } from '../lib/supabase';
import { useNavigate } from 'react-router-dom';
export default function AuthCallbackPage() {
const navigate = useNavigate();
useEffect(() => {
// Supabase가 URL 해시(#access_token=...)를 자동으로 파싱하고
// SIGNED_IN 이벤트를 발생시킵니다
const {
data: { subscription },
} = supabase.auth.onAuthStateChange((event, session) => {
if (event === 'SIGNED_IN' && session) {
navigate('/'); // 로그인 완료 → 메인 페이지로 이동
}
});
return () => subscription.unsubscribe();
}, [navigate]);
return <div>로그인 처리 중...</div>;
}PKCE의 모든 작업은 Supabase SDK가 처리하므로, 우리는 onAuthStateChange로 SIGNED_IN 이벤트를 전달 받아 라우팅만 처리하면 됩니다.
4. 라우트 보호
인증 상태에 따라 접근 가능한 페이지를 분리합니다.
// src/router.tsx
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
// 인증이 필요한 라우트: 미인증 시 /login으로 리다이렉트
function ProtectedRoute() {
const { session, loading } = useAuth();
if (loading) return <div>로딩 중...</div>; // 세션 확인 중 — 섣불리 리다이렉트 금지
if (!session) return <Navigate to="/login" replace />;
return <Outlet />;
}
// 비인증 전용 라우트: 이미 로그인된 사용자가 /login에 접근하면 /로 리다이렉트
function PublicRoute() {
const { session, loading } = useAuth();
if (loading) return <div>로딩 중...</div>;
if (session) return <Navigate to="/" replace />;
return <Outlet />;
}
export const router = createBrowserRouter([
{
element: <PublicRoute />,
children: [{ path: '/login', element: <LoginPage /> }],
},
{
element: <ProtectedRoute />,
children: [
{
path: '/',
element: <AppLayout />,
children: [
{ index: true, element: <MainPage /> },
],
},
],
},
{
path: '/auth/callback',
element: <AuthCallbackPage />,
},
]);페이지 로드 시 일시적으로 session이 null 일 수 있기 때문에 반드시 loading 상태를 확인해야 합니다.