# Supabase 로그인이 새로고침에 풀릴 때 — 원인 4가지를 순서대로 짚는 진단 체크리스트 Supabase 세션이 새로고침에 풀리는 원인 4가지. persistSession, SSR 쿠키, Invalid Refresh Token. - source: https://polroute.com/posts/supabase-session-persistence/ - category: 트러블슈팅 - published: 2026-09-12 --- 구글이나 카카오 로그인을 붙이고 나면 거의 모두가 한 번은 겪습니다. 로그인은 분명히 됐는데 새로고침하면 로그아웃되어 있습니다. 에러 메시지는 안 뜨거나, 떠도 `Invalid Refresh Token: Session Expired` 한 줄이라 원인을 안 알려줍니다. 이 문제는 원인이 하나가 아닙니다. 남의 해결법을 복사해 붙여도 안 고쳐지니 순서대로 짚어야 합니다. ## 증상부터 세 가지로 나눕니다 같아 보이지만 원인이 다릅니다. - **로컬은 되는데 배포하면 풀린다** → 쿠키 도메인, Site URL, 또는 서버 렌더링 경로 문제입니다. - **새로고침에서만 풀린다** → 세션이 저장 자체가 안 되고 있습니다. 진단 1·2번입니다. - **한참 쓰다가 일정 시간 후 풀린다** → 토큰 갱신 문제입니다. 진단 4번과 마지막 섹션입니다. 어디에 해당하는지 정하고 들어가면 두세 단계를 건너뛸 수 있습니다. ## 진단 1 — 클라이언트를 매번 새로 만들고 있지 않은가 가장 흔합니다. 컴포넌트 안에서 `createClient()`를 호출하면 렌더마다 새 인스턴스가 생깁니다. ```ts // 잘못된 패턴 — 렌더마다 새 클라이언트 export default function Page() { const supabase = createClient(url, anonKey); // ... } ``` 인스턴스가 여러 개면 같은 저장소의 토큰을 두고 서로 갱신을 시도합니다. 한쪽이 리프레시 토큰을 쓰는 순간 다른 쪽이 들고 있던 토큰은 무효가 됩니다. 그 인스턴스가 다음 요청을 하면 세션이 날아갑니다. 제 프로젝트에서는 이걸 아예 싱글턴으로 막아뒀습니다. ```ts let client: SupabaseClient | null = null; export function getSupabaseBrowser(): SupabaseClient { if (client) return client; client = createClient(url, anonKey, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true }, }); return client; } ``` `@supabase/ssr`의 `createBrowserClient`를 쓴다면 이미 내부적으로 싱글턴이라 따로 감쌀 필요는 없습니다. 문제는 `@supabase/supabase-js`의 `createClient`를 직접 부를 때입니다. ## 진단 2 — persistSession이 덮어써지지 않았는가 AI가 짜준 코드에서 자주 나오는 패턴입니다. 클라이언트를 자동으로 만들어주는 헬퍼가 끼어들면서 세션 유지 옵션을 통째로 덮어씁니다. 로그인은 되는데 저장이 안 되니 새로고침 한 번에 끝납니다. 옵션을 명시적으로 박아주면 해결됩니다. ```ts createClient(url, anonKey, { auth: { persistSession: true, autoRefreshToken: true, detectSessionInUrl: true, }, }); ``` 검토 없이 받은 코드가 이런 식으로 조용한 버그를 만듭니다. [바이브 코딩이 원래 무슨 뜻이었는지](/posts/what-is-vibe-coding/) 짚은 글에서도 같은 얘기를 했는데, 돌아가는 것과 맞는 것은 다릅니다. ## 진단 3 — 서버가 쿠키를 못 읽고 있지 않은가 SSR에서는 세션이 localStorage가 아니라 쿠키에 있습니다. 서버 컴포넌트가 그 쿠키를 못 읽으면 로그인 안 한 사용자로 취급합니다. 공식 문서도 여기서 못을 박습니다. > Never trust `supabase.auth.getSession()` inside server code. It isn't guaranteed to revalidate the Auth token. `getSession()`은 쿠키에 든 값을 그대로 돌려줄 뿐 서명을 검증하지 않습니다. 서버에서 권한 판단에 쓰면 안 됩니다. 대신 `getClaims()`를 씁니다. 예전에는 `getUser()`를 권했지만 지금 공식 가이드는 `getClaims()`입니다. 비대칭 키를 쓰는 프로젝트라면 공개키로 JWT 서명을 로컬에서 검증하니 네트워크 왕복이 없습니다. 사용자가 방금 차단됐는지까지 즉시 알아야 하는 경우에만 `getUser()`를 쓰면 됩니다. `createServerClient`의 쿠키 어댑터도 확인하세요. `getAll`이 요청 쿠키를 읽고 `setAll`이 응답에 다시 써야 합니다. `setAll`을 비워두면 갱신된 토큰이 브라우저로 돌아가지 않아서 토큰 수명이 끝나는 시점에 로그아웃됩니다. ## 진단 4 — Site URL과 Redirect URL이 배포 도메인과 맞는가 로컬만 되고 배포하면 안 되는 경우 대부분 여기입니다. Supabase 대시보드의 Authentication 설정에서 Site URL이 `localhost:3000`으로 남아 있으면 배포 도메인에서 콜백이 돌아올 자리가 없습니다. [구글 로그인](/posts/claude-code-google-social-login/)과 [카카오 로그인](/posts/claude-code-kakao-social-login/) 설정 글에서 리디렉션 URI를 다뤘는데, 도메인을 붙이거나 바꿀 때 이 두 값을 같이 고쳐야 합니다. ## Invalid Refresh Token은 이제 버그가 아닙니다 `Invalid Refresh Token: Session Expired`는 오랫동안 유명한 미해결 이슈였습니다. 로컬에서는 멀쩡한데 프로덕션에서만 재현돼서 더 악명이 높았고요. 지금은 결론이 났습니다. `supabase/ssr` 이슈 #91이 2026년 8월에 닫혔고 원인은 레이스 컨디션이었습니다. `GoTrueClient` 생성자가 백그라운드로 초기화를 돌리면서 미들웨어가 시작한 토큰 갱신과 동시에 갱신을 시도한 겁니다. 고치는 방법은 `createServerClient`에 `skipAutoInitialize: true`를 넣어 생성 시점의 초기화를 막는 겁니다. `@supabase/ssr@0.8.1`부터 정식 배포에 들어가 있습니다. 이 에러를 보고 있다면 버전부터 확인하세요. 0.8.1보다 낮으면 올리는 게 답입니다. ```bash npm install @supabase/ssr@latest @supabase/supabase-js@latest ``` ## 그래도 안 풀린다면 같은 이슈 스레드에서 나온 지적이 진단을 막는 진짜 이유일 수 있습니다. 미들웨어 예제 코드가 대개 이렇게 생겼습니다. ```ts const { data: { user } } = await supabase.auth.getUser(); ``` `error`를 안 받습니다. 그래서 **토큰이 진짜 만료된 경우와 쿠키 쓰기가 실패한 경우가 똑같아 보입니다.** 둘을 구분하려면 에러를 받아서 분기해야 합니다. ```ts const { data: { user }, error } = await supabase.auth.getUser(); if (error) { console.error('[auth]', error.name, error.message); } ``` 이걸 켜두면 다음 재현 때 어느 쪽인지 바로 나옵니다. 에러를 조용히 삼키면 만료된 세션을 로그인된 것으로 취급해서 보호된 경로의 리디렉션이 아예 안 걸리기도 합니다. 세션 만료 시간을 1시간 같은 값으로 직접 줄여둔 경우라면 그 에러는 버그가 아니라 정상 동작입니다. 대시보드의 세션 설정도 같이 확인하세요.