Supabase 서버 사이드 인증과 SSR 헬퍼 패키지 이해하기

2026.07.20·약 16분
요약
Supabase AuthNext.js 같은 서버 사이드 렌더링 환경에서 사용할 때는 브라우저의 로그인 상태만 믿기 어렵습니다. 서버 요청마다 쿠키에서 세션을 읽고, 필요하면 토큰을 갱신하며, 서버 컴포넌트와 라우트 핸들러에서 같은 인증 상태를 확인해야 합니다. 이때 @supabase/ssr 헬퍼 패키지는 쿠키 기반 세션 처리와 서버용 Supabase 클라이언트 생성을 단순하게 만들어 줍니다.

Supabase 서버 사이드 인증이 필요한 이유

브라우저 쿠키 서버 Supabase Auth 세션 전달 흐름

Supabase Auth를 처음 붙일 때는 보통 브라우저에서 로그인하고, 클라이언트 컴포넌트에서 getUsergetSession을 호출하는 방식으로 시작합니다. 이 방식은 사용자 버튼을 바꾸거나, 로그인 후 화면 일부를 제어하는 수준에서는 충분해 보입니다. 하지만 페이지를 서버에서 렌더링하거나, 서버에서 사용자별 데이터를 미리 조회해야 하는 순간부터 문제가 달라집니다.

브라우저에서는 로그인되어 있는데 서버 렌더링 시점에는 사용자를 모르는 상황이 자주 생깁니다. 서버는 브라우저의 메모리 상태를 직접 읽을 수 없고, 클라이언트에서 만든 Supabase 인스턴스도 공유하지 않습니다. 서버가 알 수 있는 것은 요청에 포함된 헤더와 쿠키입니다. 따라서 SSR 인증의 핵심은 로그인 상태를 서버 요청 안에서 재구성할 수 있어야 한다는 점입니다.

클라이언트 인증만으로 부족한 상황

예를 들어 로그인한 사용자만 접근할 수 있는 대시보드가 있다고 가정해 보겠습니다. 클라이언트에서만 인증을 확인하면 서버는 일단 빈 페이지나 공통 페이지를 보내고, 브라우저에서 인증 확인이 끝난 뒤 화면을 바꿉니다. 이 과정에서 깜빡임이 생기고, 보호되어야 할 데이터를 서버에서 미리 가져오기도 어렵습니다.

반대로 서버에서 현재 사용자를 알 수 있다면 처음부터 로그인 사용자에게 맞는 HTML을 만들 수 있습니다. profiles 테이블에서 사용자 프로필을 조회하거나, 권한에 따라 리다이렉트하거나, 조직별 데이터를 렌더링 전에 필터링할 수 있습니다. 이런 흐름은 단순한 UI 상태 관리가 아니라 요청 단위의 인증 판단입니다.

SSR에서 세션이 필요한 대표 사례

  • 로그인 사용자만 접근 가능한 dashboard 페이지를 서버에서 보호해야 할 때
  • 서버 컴포넌트에서 user.id를 기준으로 사용자별 데이터를 조회해야 할 때
  • middleware.ts에서 만료 직전의 세션을 갱신하고 라우팅을 제어해야 할 때
  • route.ts 또는 server action에서 현재 사용자를 확인한 뒤 데이터 변경을 허용해야 할 때

Supabase 인증 세션이 동작하는 방식

Supabase Auth의 세션은 기본적으로 액세스 토큰과 리프레시 토큰을 중심으로 동작합니다. access_token은 현재 사용자를 증명하고 리소스 접근에 사용됩니다. refresh_tokenaccess_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의 요청은 서로 다른 쿠키를 가지고 들어옵니다. 서버용 클라이언트는 이 요청 컨텍스트에 묶여 있어야 하므로, 일반적으로 요청이 들어올 때마다 새로 생성합니다.

import { createServerClient } from '@supabase/ssr'

export function createClient(cookieStore: {
  getAll: () => { name: string; value: string }[]
  setAll: (cookies: { name: string; value: string; options?: unknown }[]) => void
}) {
  return createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          cookieStore.setAll(cookiesToSet)
        },
      },
    }
  )
}

위 예시는 구조를 보여주기 위한 단순화된 형태입니다. 실제 Next.js 프로젝트에서는 cookies, NextRequest, NextResponse처럼 실행 위치에 맞는 API를 사용합니다. 특히 App Router의 cookies 처리 방식과 미들웨어의 응답 쿠키 반영 패턴은 Next.js와 Supabase 공식 문서의 최신 예시를 기준으로 발행 전에 다시 확인하는 것이 좋습니다.

쿠키 읽기와 쓰기를 헬퍼가 처리하는 방식

@supabase/ssr이 모든 인증 판단을 대신하는 것은 아닙니다. 대신 인증 판단에 필요한 쿠키 입출력 연결을 정리해 줍니다. 서버에서 auth.getUser를 호출하면 클라이언트는 쿠키의 세션 정보를 바탕으로 현재 사용자를 확인합니다. 세션 갱신이 필요하면 갱신된 쿠키를 응답에 반영할 수 있도록 도와줍니다.

그래서 SSR 헬퍼 패키지를 쓰는 목적은 코드를 짧게 만드는 데만 있지 않습니다. 더 중요한 목적은 서버 요청, 쿠키, 토큰 갱신, 사용자 확인 흐름을 일관된 방식으로 묶는 것입니다. 이 일관성이 없으면 어떤 페이지에서는 로그인으로 보이고, 어떤 API에서는 비로그인으로 보이는 문제가 발생합니다.

실제 프로젝트에서 배치하는 위치

Next.js 프로젝트에서 미들웨어 서버 페이지 API 라우트 인증 확인 구조

실무 프로젝트에서는 Supabase 클라이언트를 아무 파일에서나 즉석으로 만들기보다 실행 환경별로 분리하는 편이 좋습니다. 브라우저용 클라이언트, 서버 컴포넌트용 클라이언트, 미들웨어용 클라이언트는 쿠키를 다루는 방식이 다르기 때문입니다. 예를 들어 utils/supabase/client.ts, utils/supabase/server.ts, utils/supabase/middleware.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>
}

이 패턴의 장점은 보호가 렌더링 전에 이루어진다는 것입니다. 사용자가 볼 수 없는 페이지를 잠깐 보여준 뒤 숨기는 방식이 아니라, 서버가 처음부터 접근 가능 여부를 판단합니다.

미들웨어

middleware.ts는 세션 갱신과 라우팅 제어를 처리하기 좋은 위치입니다. 모든 요청에서 무거운 데이터 조회를 수행하는 곳은 아니지만, 만료된 세션을 갱신하거나 보호된 경로 접근을 확인하는 용도로 유용합니다. 특히 NextResponse에 갱신된 쿠키를 반영해야 하므로 미들웨어 전용 헬퍼를 분리해 두는 것이 좋습니다.

미들웨어에서 중요한 점은 요청 객체와 응답 객체의 쿠키 상태를 함께 관리하는 것입니다. Supabase가 새 토큰을 발급받았는데 응답에 쿠키를 실어 보내지 않으면 브라우저는 갱신된 세션을 유지하지 못합니다. 이런 누락은 간헐적인 로그아웃처럼 보이는 문제로 이어질 수 있습니다.

API 라우트와 서버 액션

route.tsserver action에서도 서버용 Supabase 클라이언트를 사용해야 합니다. 클라이언트에서 사용자 ID를 전달받아 신뢰하는 방식은 피해야 합니다. 요청을 보낸 사용자가 누구인지는 서버에서 auth.getUser로 다시 확인하고, 그 결과를 기준으로 데이터 변경을 허용해야 합니다.

예를 들어 게시글 작성 API라면 요청 본문에 들어온 author_id를 그대로 믿지 말고, 서버에서 확인한 user.id를 사용해 저장하는 편이 안전합니다. 이렇게 해야 다른 사용자의 ID를 임의로 넣어 데이터를 조작하는 문제를 줄일 수 있습니다.

자주 발생하는 실수

SSR 인증에서 가장 흔한 실수는 클라이언트용 Supabase 인스턴스를 서버 코드에서 그대로 사용하려는 것입니다. 브라우저용 클라이언트는 브라우저 저장소와 클라이언트 실행 환경을 전제로 합니다. 서버 컴포넌트나 라우트 핸들러에서는 요청 쿠키를 기준으로 작동하는 서버용 클라이언트가 필요합니다.

서버와 클라이언트 클라이언트를 혼동하는 문제

createBrowserClientcreateServerClient는 같은 Supabase 프로젝트에 연결되더라도 실행 위치가 다릅니다. 클라이언트 컴포넌트에서는 createBrowserClient를 사용하고, 서버 컴포넌트에서는 createServerClient 기반의 헬퍼를 사용해야 합니다. 이 구분이 흐려지면 서버에서 세션이 비어 있거나, 빌드 시점에 브라우저 전용 API를 참조하는 오류가 발생할 수 있습니다.

세션 갱신을 빠뜨리는 문제

또 다른 실수는 세션 갱신 흐름을 미들웨어나 서버 응답에 제대로 연결하지 않는 것입니다. access_token은 만료될 수 있으므로 갱신된 쿠키가 NextResponse에 반영되어야 다음 요청에서도 같은 인증 상태가 이어집니다.

이 연결이 빠지면 서버 로그에서는 auth.getUser가 성공한 것처럼 보여도 브라우저에는 오래된 쿠키가 남아 간헐적인 로그아웃처럼 보일 수 있습니다.

환경 변수와 쿠키 설정 문제

NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEY는 브라우저와 서버 양쪽에서 사용할 수 있는 공개 설정입니다. 반면 서비스 역할 키처럼 권한이 강한 값은 클라이언트 번들에 포함되면 안 됩니다. 서버 사이드 인증을 붙이는 과정에서 환경 변수를 정리할 때 이 경계를 반드시 지켜야 합니다.

로컬 개발과 배포 환경의 도메인이 다를 때 쿠키 설정도 확인해야 합니다. httphttps, 도메인, 서브도메인, 리다이렉트 URL 설정이 어긋나면 로그인은 성공했는데 세션 쿠키가 기대한 요청에 포함되지 않을 수 있습니다. 이런 문제는 코드보다 배포 설정에서 발생하는 경우가 많습니다.

정리와 다음 단계

Supabase Auth를 SSR 환경에서 안전하게 사용하려면 클라이언트의 로그인 상태와 서버의 요청 상태를 분리해서 생각해야 합니다. 브라우저에서 로그인했다는 사실만으로 서버가 자동으로 사용자를 아는 것은 아닙니다. 서버는 요청에 포함된 쿠키를 읽고, 그 쿠키를 바탕으로 세션을 확인하며, 필요하면 갱신된 쿠키를 응답에 반영해야 합니다.

@supabase/ssr은 이 흐름을 직접 구현할 때 생기는 반복과 실수를 줄여 줍니다. 서버 컴포넌트, 미들웨어, API 라우트, 서버 액션에서 각각 요청 단위의 Supabase 클라이언트를 만들고, auth.getUser를 기준으로 사용자를 확인하는 구조를 잡으면 인증 판단이 훨씬 일관됩니다.

실무에서는 먼저 클라이언트용 파일과 서버용 파일을 분리하고, 보호된 페이지에서 서버 렌더링 전에 사용자를 확인하는 패턴부터 적용하는 것이 좋습니다. 그다음 미들웨어에서 세션 갱신을 안정화하고, 서버 액션이나 API 라우트에서 클라이언트가 보낸 사용자 식별자를 신뢰하지 않는 방식으로 확장하면 됩니다.

결론적으로 SSR 헬퍼 패키지는 선택적인 편의 도구라기보다, 서버와 브라우저가 같은 인증 상태를 안전하게 공유하기 위한 구조적 도구에 가깝습니다. Supabase를 단순 로그인 기능으로만 쓰는 단계에서는 크게 느껴지지 않지만, 사용자별 데이터 조회, 권한 제어, 서버 액션, Row Level Security까지 이어지는 프로젝트라면 초기에 이 구조를 잡아 두는 편이 유지보수에 유리합니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기