Next.js App Router의 Supabase 인증은 @supabase/ssr로 브라우저·서버 클라이언트를 분리하고 쿠키를 통해 세션을 전달합니다. 현재 패턴에서는 publishable key, 요청별 서버 클라이언트, proxy.ts 세션 갱신, 인증 경로의 캐시 방지가 핵심입니다.- Supabase 서버 사이드 인증이 필요한 이유
- Supabase 인증 세션이 동작하는 방식
- SSR 헬퍼 패키지의 역할
- 실제 프로젝트에서 배치하는 위치
- 자주 발생하는 실수
- 정리와 다음 단계
- 현재 SSR 구현 체크리스트
- 공식 문서
Supabase 서버 사이드 인증이 필요한 이유

학습 목표·선수 지식: Next.js App Router의 서버/클라이언트 컴포넌트와 기본 로그인을 알고 있다는 전제에서, 요청 쿠키가 사용자 검증과 세션 갱신을 거쳐 응답으로 돌아가는 경로를 추적합니다. 예제 코드는 SSR 경계 설명용이며 로그인 앱 전체를 제공하는 실습은 아닙니다.
Supabase Auth를 처음 붙일 때는 보통 브라우저에서 로그인하고, 클라이언트 컴포넌트에서 getUser나 getSession을 호출하는 방식으로 시작합니다. 이 방식은 사용자 버튼을 바꾸거나, 로그인 후 화면 일부를 제어하는 수준에서는 충분해 보입니다. 하지만 페이지를 서버에서 렌더링하거나, 서버에서 사용자별 데이터를 미리 조회해야 하는 순간부터 문제가 달라집니다.
브라우저에서는 로그인되어 있는데 서버 렌더링 시점에는 사용자를 모르는 상황이 자주 생깁니다. 서버는 브라우저의 메모리 상태를 직접 읽을 수 없고, 클라이언트에서 만든 Supabase 인스턴스도 공유하지 않습니다. 서버가 알 수 있는 것은 요청에 포함된 헤더와 쿠키입니다. 따라서 SSR 인증의 핵심은 로그인 상태를 서버 요청 안에서 재구성할 수 있어야 한다는 점입니다.
클라이언트 인증만으로 부족한 상황
예를 들어 로그인한 사용자만 접근할 수 있는 대시보드가 있다고 가정해 보겠습니다. 클라이언트에서만 인증을 확인하면 서버는 일단 빈 페이지나 공통 페이지를 보내고, 브라우저에서 인증 확인이 끝난 뒤 화면을 바꿉니다. 이 과정에서 깜빡임이 생기고, 보호되어야 할 데이터를 서버에서 미리 가져오기도 어렵습니다.
반대로 서버에서 현재 사용자를 알 수 있다면 처음부터 로그인 사용자에게 맞는 HTML을 만들 수 있습니다. profiles 테이블에서 사용자 프로필을 조회하거나, 권한에 따라 리다이렉트하거나, 조직별 데이터를 렌더링 전에 필터링할 수 있습니다. 이런 흐름은 단순한 UI 상태 관리가 아니라 요청 단위의 인증 판단입니다.
SSR에서 세션이 필요한 대표 사례
- 로그인 사용자만 접근 가능한
dashboard페이지를 서버에서 보호해야 할 때 - 서버 컴포넌트에서
user.id를 기준으로 사용자별 데이터를 조회해야 할 때 proxy.ts에서 만료 직전의 세션을 갱신하고 라우팅을 제어해야 할 때route.ts또는server action에서 현재 사용자를 확인한 뒤 데이터 변경을 허용해야 할 때
Supabase 인증 세션이 동작하는 방식
Supabase Auth의 세션은 기본적으로 액세스 토큰과 리프레시 토큰을 중심으로 동작합니다. access_token은 현재 사용자를 증명하고 리소스 접근에 사용됩니다. refresh_token은 access_token이 만료되었을 때 새 토큰을 발급받는 데 사용됩니다. 클라이언트 앱만 있을 때는 이 상태가 브라우저 저장소를 중심으로 관리될 수 있지만, SSR에서는 서버도 같은 상태를 읽어야 합니다.
서버가 세션을 읽으려면 토큰 정보가 요청에 포함되어야 합니다. 그래서 @supabase/ssr 흐름에서는 쿠키가 중요해집니다. 쿠키는 브라우저가 서버로 요청을 보낼 때 자동으로 함께 전송되므로, 서버는 해당 요청의 쿠키를 읽어 현재 사용자의 인증 상태를 확인할 수 있습니다.
액세스 토큰과 리프레시 토큰의 역할
access_token은 짧은 수명을 가지는 것이 일반적입니다. 짧은 수명은 보안상 유리하지만, 서버 렌더링에서는 요청 중간에 토큰이 만료되거나 만료에 가까운 상태를 처리해야 합니다. 이때 refresh_token을 사용해 세션을 갱신하고, 갱신된 토큰을 다시 쿠키에 반영해야 다음 요청도 정상적으로 인증됩니다.
이 과정을 직접 구현하면 쿠키 읽기, 쿠키 쓰기, 요청별 클라이언트 생성, 토큰 갱신 타이밍, 응답 객체 반영을 모두 신경 써야 합니다. 작은 예제에서는 단순해 보여도 실제 서비스에서는 라우트가 늘어나고 서버 컴포넌트, 미들웨어, API 라우트가 섞이면서 실수가 생기기 쉽습니다.
쿠키 기반 세션 관리의 의미
쿠키 기반 세션 관리는 서버와 브라우저가 같은 인증 상태를 공유하기 위한 약속에 가깝습니다. 브라우저에서 로그인하면 세션 정보가 쿠키에 반영되고, 다음 서버 요청은 그 쿠키를 포함합니다. 서버는 쿠키를 읽어 Supabase에 현재 사용자를 확인하고, 필요하면 갱신된 쿠키를 응답에 실어 돌려보냅니다.
중요한 점은 서버가 전역 세션을 들고 있지 않다는 것입니다. 서버는 각 요청마다 들어온 쿠키를 기준으로 사용자를 판단합니다. 그래서 서버용 Supabase 클라이언트도 요청마다 만들어야 하며, 이 클라이언트는 해당 요청의 쿠키 접근 방식을 알고 있어야 합니다.
SSR 헬퍼 패키지의 역할
@supabase/ssr은 SSR 환경에서 Supabase 클라이언트를 만들 때 필요한 쿠키 연동을 도와주는 패키지입니다. 예전에는 프레임워크별 헬퍼가 따로 언급되었지만, 현재는 SSR 전반에서 사용할 수 있는 방향으로 @supabase/ssr을 중심에 두는 흐름이 일반적입니다. 핵심은 클라이언트용 인스턴스와 서버용 인스턴스를 구분하고, 서버 인스턴스에는 요청 쿠키를 연결하는 것입니다.
브라우저에서는 createBrowserClient를 사용해 클라이언트 컴포넌트용 Supabase 클라이언트를 만들 수 있습니다. 서버에서는 createServerClient를 사용해 현재 요청의 쿠키를 읽고 쓸 수 있는 Supabase 클라이언트를 만듭니다. 이름은 비슷하지만 역할은 다릅니다.
서버 요청마다 Supabase 클라이언트를 만드는 이유
서버에서 Supabase 클라이언트를 전역으로 하나만 만들어 두면 요청별 쿠키를 제대로 반영하기 어렵습니다. 사용자 A의 요청과 사용자 B의 요청은 서로 다른 쿠키를 가지고 들어옵니다. 서버용 클라이언트는 이 요청 컨텍스트에 묶여 있어야 하므로, 일반적으로 요청이 들어올 때마다 새로 생성합니다.
// 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_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll()
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options)
})
} catch {
// Server Components cannot write cookies.
// The session-refresh proxy must update request and response cookies.
}
},
},
}
)
}
이 파일을 utils/supabase/server.ts에 저장하면 아래 대시보드의 await createClient()와 연결됩니다. 서버 컴포넌트에서는 쿠키를 쓸 수 없으므로 setAll의 예외 처리는 세션 갱신을 완료했다는 뜻이 아닙니다. 공식 SSR 설정의 브라우저 클라이언트와 세션 갱신 Proxy도 함께 구성하고, 갱신된 쿠키가 요청과 응답 양쪽에 전달되는지 확인하세요. 이 글의 두 코드만으로 로그인·갱신 전체가 완성되지는 않습니다.
쿠키 읽기와 쓰기를 헬퍼가 처리하는 방식
@supabase/ssr이 모든 인증 판단을 대신하는 것은 아닙니다. 대신 인증 판단에 필요한 쿠키 입출력 연결을 정리해 줍니다. 서버에서 auth.getUser를 호출하면 클라이언트는 쿠키의 세션 정보를 바탕으로 현재 사용자를 확인합니다. 세션 갱신이 필요하면 갱신된 쿠키를 응답에 반영할 수 있도록 도와줍니다.
그래서 SSR 헬퍼 패키지를 쓰는 목적은 코드를 짧게 만드는 데만 있지 않습니다. 더 중요한 목적은 서버 요청, 쿠키, 토큰 갱신, 사용자 확인 흐름을 일관된 방식으로 묶는 것입니다. 이 일관성이 없으면 어떤 페이지에서는 로그인으로 보이고, 어떤 API에서는 비로그인으로 보이는 문제가 발생합니다.
실제 프로젝트에서 배치하는 위치

실무 프로젝트에서는 Supabase 클라이언트를 아무 파일에서나 즉석으로 만들기보다 실행 환경별로 분리하는 편이 좋습니다. 브라우저용 클라이언트, 서버 컴포넌트용 클라이언트, 미들웨어용 클라이언트는 쿠키를 다루는 방식이 다르기 때문입니다. 예를 들어 utils/supabase/client.ts, utils/supabase/server.ts, utils/supabase/proxy.ts처럼 역할을 나누면 코드의 의도가 분명해집니다.
서버 컴포넌트 또는 서버 페이지
app/dashboard/page.tsx 같은 서버 페이지에서는 렌더링 전에 현재 사용자를 확인할 수 있습니다. 여기서 auth.getUser를 호출하고 사용자가 없으면 redirect로 로그인 페이지에 보낼 수 있습니다. 사용자가 있으면 user.id를 기준으로 profiles나 업무 데이터를 조회합니다.
import { redirect } from 'next/navigation'
import { createClient } from '@/utils/supabase/server'
export default async function DashboardPage() {
const supabase = await createClient()
const { data, error } = await supabase.auth.getUser()
if (error || !data.user) {
redirect('/login')
}
const { data: profile } = await supabase
.from('profiles')
.select('*')
.eq('id', data.user.id)
.single()
return <main>{profile?.display_name}</main>
}
이 패턴의 장점은 보호가 렌더링 전에 이루어진다는 것입니다. 사용자가 볼 수 없는 페이지를 잠깐 보여준 뒤 숨기는 방식이 아니라, 서버가 처음부터 접근 가능 여부를 판단합니다.
미들웨어
proxy.ts는 세션 갱신과 라우팅 제어를 처리하기 좋은 위치입니다. 모든 요청에서 무거운 데이터 조회를 수행하는 곳은 아니지만, 만료된 세션을 갱신하거나 보호된 경로 접근을 확인하는 용도로 유용합니다. 특히 NextResponse에 갱신된 쿠키를 반영해야 하므로 미들웨어 전용 헬퍼를 분리해 두는 것이 좋습니다.
미들웨어에서 중요한 점은 요청 객체와 응답 객체의 쿠키 상태를 함께 관리하는 것입니다. Supabase가 새 토큰을 발급받았는데 응답에 쿠키를 실어 보내지 않으면 브라우저는 갱신된 세션을 유지하지 못합니다. 이런 누락은 간헐적인 로그아웃처럼 보이는 문제로 이어질 수 있습니다.
API 라우트와 서버 액션
route.ts나 server action에서도 서버용 Supabase 클라이언트를 사용해야 합니다. 클라이언트에서 사용자 ID를 전달받아 신뢰하는 방식은 피해야 합니다. 요청을 보낸 사용자가 누구인지는 서버에서 auth.getUser로 다시 확인하고, 그 결과를 기준으로 데이터 변경을 허용해야 합니다.
예를 들어 게시글 작성 API라면 요청 본문에 들어온 author_id를 그대로 믿지 말고, 서버에서 확인한 user.id를 사용해 저장하는 편이 안전합니다. 이렇게 해야 다른 사용자의 ID를 임의로 넣어 데이터를 조작하는 문제를 줄일 수 있습니다.
자주 발생하는 실수
SSR 인증에서 가장 흔한 실수는 클라이언트용 Supabase 인스턴스를 서버 코드에서 그대로 사용하려는 것입니다. 브라우저용 클라이언트는 브라우저 저장소와 클라이언트 실행 환경을 전제로 합니다. 서버 컴포넌트나 라우트 핸들러에서는 요청 쿠키를 기준으로 작동하는 서버용 클라이언트가 필요합니다.
서버와 클라이언트 클라이언트를 혼동하는 문제
createBrowserClient와 createServerClient는 같은 Supabase 프로젝트에 연결되더라도 실행 위치가 다릅니다. 클라이언트 컴포넌트에서는 createBrowserClient를 사용하고, 서버 컴포넌트에서는 createServerClient 기반의 헬퍼를 사용해야 합니다. 이 구분이 흐려지면 서버에서 세션이 비어 있거나, 빌드 시점에 브라우저 전용 API를 참조하는 오류가 발생할 수 있습니다.
세션 갱신을 빠뜨리는 문제
또 다른 실수는 세션 갱신 흐름을 미들웨어나 서버 응답에 제대로 연결하지 않는 것입니다. access_token은 만료될 수 있으므로 갱신된 쿠키가 NextResponse에 반영되어야 다음 요청에서도 같은 인증 상태가 이어집니다.
이 연결이 빠지면 서버 로그에서는 auth.getUser가 성공한 것처럼 보여도 브라우저에는 오래된 쿠키가 남아 간헐적인 로그아웃처럼 보일 수 있습니다.
환경 변수와 쿠키 설정 문제
NEXT_PUBLIC_SUPABASE_URL과 NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY는 브라우저와 서버 양쪽에서 사용할 수 있는 공개 설정입니다. 반면 서비스 역할 키처럼 권한이 강한 값은 클라이언트 번들에 포함되면 안 됩니다. 서버 사이드 인증을 붙이는 과정에서 환경 변수를 정리할 때 이 경계를 반드시 지켜야 합니다.
로컬 개발과 배포 환경의 도메인이 다를 때 쿠키 설정도 확인해야 합니다. http와 https, 도메인, 서브도메인, 리다이렉트 URL 설정이 어긋나면 로그인은 성공했는데 세션 쿠키가 기대한 요청에 포함되지 않을 수 있습니다. 이런 문제는 코드보다 배포 설정에서 발생하는 경우가 많습니다.
정리와 다음 단계
Supabase Auth를 SSR 환경에서 안전하게 사용하려면 클라이언트의 로그인 상태와 서버의 요청 상태를 분리해서 생각해야 합니다. 브라우저에서 로그인했다는 사실만으로 서버가 자동으로 사용자를 아는 것은 아닙니다. 서버는 요청에 포함된 쿠키를 읽고, 그 쿠키를 바탕으로 세션을 확인하며, 필요하면 갱신된 쿠키를 응답에 반영해야 합니다.
@supabase/ssr은 이 흐름을 직접 구현할 때 생기는 반복과 실수를 줄여 줍니다. 서버 컴포넌트, 미들웨어, API 라우트, 서버 액션에서 각각 요청 단위의 Supabase 클라이언트를 만들고, auth.getUser를 기준으로 사용자를 확인하는 구조를 잡으면 인증 판단이 훨씬 일관됩니다.
실무에서는 먼저 클라이언트용 파일과 서버용 파일을 분리하고, 보호된 페이지에서 서버 렌더링 전에 사용자를 확인하는 패턴부터 적용하는 것이 좋습니다. 그다음 미들웨어에서 세션 갱신을 안정화하고, 서버 액션이나 API 라우트에서 클라이언트가 보낸 사용자 식별자를 신뢰하지 않는 방식으로 확장하면 됩니다.
결론적으로 SSR 헬퍼 패키지는 선택적인 편의 도구라기보다, 서버와 브라우저가 같은 인증 상태를 안전하게 공유하기 위한 구조적 도구에 가깝습니다. Supabase를 단순 로그인 기능으로만 쓰는 단계에서는 크게 느껴지지 않지만, 사용자별 데이터 조회, 권한 제어, 서버 액션, Row Level Security까지 이어지는 프로젝트라면 초기에 이 구조를 잡아 두는 편이 유지보수에 유리합니다.
현재 SSR 구현 체크리스트
- 브라우저에서는
createBrowserClient, 서버에서는 요청마다createServerClient를 생성합니다. - 환경 변수는
NEXT_PUBLIC_SUPABASE_URL과NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY를 사용합니다. proxy.ts에서 만료된 인증 토큰을 갱신하고 변경된 쿠키를 응답에 반영합니다.- 보호된 서버 로직에서는 검증된 인증 결과와
RLS를 기준으로 권한을 판단합니다. - 인증 응답은 공개 캐시나 ISR과 섞지 말고
private, no-store정책을 검토합니다.
기존 @supabase/auth-helpers-nextjs 예제는 이전 세대 패턴입니다. 새 구현은 @supabase/ssr 기준으로 시작하고, Next.js 버전에 맞는 쿠키 API를 사용하세요.
로그인은 되는데 새로고침하면 풀릴 때: 쿠키를 따라 추적하기
브라우저 로그인 직후 버튼만 바뀌었는데 새로고침하면 비로그인으로 돌아온다면, 먼저 저장 방식부터 확인합니다. 기본 @supabase/supabase-js의 브라우저 저장소 기반 클라이언트와 @supabase/ssr의 쿠키 기반 클라이언트를 섞으면 서버는 브라우저에만 있는 상태를 읽을 수 없습니다. utils/supabase/client.ts는 createBrowserClient, utils/supabase/server.ts는 현재 요청의 cookies를 읽는 createServerClient로 나눕니다.
| 파일·검사 위치 | 역할 | 빠뜨렸을 때 |
|---|---|---|
| utils/supabase/client.ts | 브라우저 로그인 요청을 쿠키 기반 클라이언트로 실행 | UI에는 로그인 표시가 있지만 서버 요청에 세션이 없음 |
| utils/supabase/server.ts | await cookies()로 요청별 클라이언트 생성 | 다른 요청의 상태를 공유하거나 사용자 조회 실패 |
| utils/supabase/proxy.ts | 갱신된 쿠키를 요청과 응답 양쪽에 반영 | 이번 요청 또는 다음 요청 중 하나만 세션이 오래됨 |
| proxy.ts | updateSession의 응답을 그대로 반환 | 새 응답을 만들어 Set-Cookie를 잃어버림 |
Next.js 16 계열의 파일명은 proxy.ts입니다. 이전 버전의 middleware.ts 예제와 섞지 말고 설치된 Next.js 버전에 맞춰 진입점 이름을 선택하세요. src/app 구조를 사용하면 src/proxy.ts와 src/utils/supabase 아래로 일관되게 옮기고, 경로 별칭도 같은 기준으로 맞춥니다.
인증 함수는 반환 목적이 다릅니다. getClaims()는 JWT 검증 기반 신원 확인에, getUser()는 Auth 서버의 최신 사용자 레코드가 필요할 때 사용합니다. getSession()의 쿠키에서 읽은 user 객체만으로 서버 권한을 판단하지 않습니다. getClaims가 성공해도 삭제·수정 가능한 행은 RLS에서 다시 제한해야 합니다. 이 글의 getUser 예제는 네트워크 검증을 포함한 사용자 조회 사례입니다.
개발 프로젝트의 별도 브라우저 두 개로 A/B를 로그인한 뒤 /dashboard를 직접 열고 새로고침하세요. Network에서는 쿠키 이름과 Set-Cookie 존재 여부만 확인하고 토큰 값은 기록하지 않습니다. 갱신 후 다음 요청에도 같은 계정이 유지되는지, 로그아웃 후 보호 페이지가 로그인으로 이동하는지 확인합니다. 사용자 응답을 공용 CDN 캐시에 넣으면 계정 간 데이터·세션 혼합이 생길 수 있으므로 인증 경로의 공유 캐시도 별도로 점검합니다.
여기서 수행한 검수는 공식 API와 파일 책임의 대조입니다. 실제 토큰 만료·갱신, 쿠키 옵션, 프록시 실행, 두 계정의 CDN 응답 검사는 운영 인증 환경 없이 재현하지 않았습니다. 두 계정 CRUD/RLS 실습의 격리 결과와 이 요청 경계 검사를 함께 기록해야 인증 과정을 마칠 수 있습니다.
공식 문서 확인일: . supabase.com 공식 자료 1 · nextjs.org 공식 자료 2
공식 문서
같이 읽으면 좋은 글
과정 마무리 실습
사용자별 메모 테이블과 본인 행만 접근하는 권한 규칙을 설계하세요.
완료 기준: 익명·소유자·다른 사용자에게 허용할 동작을 표로 확인하고 테스트 프로젝트에서 검증합니다.
이어서 공부할 과정: 테스트 첫 글
이 글이 도움이 되었나요?
Supabase 학습 순서
필수 4개 · 전체 5개
읽음 기록 관리
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.