본문 바로가기
프론트엔드/Supabase + Jotai

Supabase Auth Server-Side Rendering

by 학습하는 청년 2025. 1. 17.

최종 수정 : 25.1.17

공식문서를 번역한 내용입니다.


1. Server-Side Rendering

SSR이 Supabase Auth와 함께 동작하는 방법

 

SSR 프레임워크는 클라이언트 번들 크기와 실행 시간을 줄이기 위해 렌더링과 데이터 가져오기를 서버로 이동시킨다.

 

Supabase Auth는 SSR과 완벽하게 호환된다. 로컬 스토리지 대신 쿠키(cookie)에 사용자 세션(session)을 저장하기 위해 Supabase 클라이언트의 설정을 약간 수정할 필요가 있다. Supabase 클라이언트를 설정한 후, How-To 가이드에 있는 모든 플로우(flow)의 지침을 따라가면 된다.

암시적 플로우와 다를 경우에는 PKCE 플로우 지침을 따르라. 차이점이 언급되지 않을 경우에는, 신경 쓰지 않아도 된다.

 

# @supabase/ssr

우리는 Supabase 클라이언트 설정을 최대한 간단하게 만들기 위해  @supabase/ssr 패키지를 개발했다.이 패키지는 현재 베타 버전이다. 사용을 권장하지만 API가 아직 불안정하고 후에 호환성이 깨지는 변경사항이 이을 수 있다는 점을 주의하라.

현재 Auth Helpers 패키지를 사용하고 있다면, 관련 문서는 아직 이용 가능하지만, 새로운 @supabase/ssr 패키지로 마이그레이션 하는 것을 권장한다.

2. Setting up Serser-Side Auth for Next.js

Next.js는 App Router와 Pages Router 두 가지 방식이 있다. 두 방법 모두 Server-Side Auth을 설정 할 수 있다. 같은 애플리케이션 안에서 두 방법을 함께 사용하는 것도 가능하다.

 

# App Router

ⓛ Supabase 패키지 설치 

@suabase/supabase-js 패키지와 도우미 @supabase/ssr 패키지를 설치하라.

pnpm add @supabase/supabase-js @supabase/ssr

 

② 환경 변수를 설정하라.

프로젝트 루트 폴더에 .env.local 파일을 생성하라.

// .enc.local
// 꺽쇠는 지우고 저 자리에 URL과 ANON_KEY를 입력하면 된다.
NEXT_PUBLIC_SUPABASE_URL=<your_supabase_project_url>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<your_supabase_anon_key>

 

③ Supabase 클라이언트 생성을 위한 유틸 함수를 작성하라.

Next.js app에서 Supabase에 접근하기 위해서는, Supabase 클라이언트의 2개의 타입이 필요하다.

// utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr';
export function createClient() {
  return createBrowserClient (
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  )
}

// 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_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return cookieStore.getAll()
        },
        setAll(cookiesToSet) {
          try {
            cookiesToSet.forEach(({ name, value, options }) =>
              cookieStore.set(name, value, options)
            )
          } catch {
            // The `setAll` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  )
}

//
  1. Client Component
    ☞ 브라우저에서 동작하는 Client Component로부터 Supabase에 접근하기 위한 클라이언트

  2. Server Component
    ☞ 서버에서만 실행되는 Server Components, Server Actions, Route Handlers에서 Supabase에 접근하기 위한 클라이언트

각각의 클라이언트의 타입을 위한 파일을 갖는 utils/supabase 폴터를 생성하고 각 팡일에 필요한 유틸리티 함수를 복사하라.

 

Q. 'cookies' 객체는 무엇을 하나요?

cookies 객체는 Supabase 클라이언트가 쿠키에 어떻게 접근하여 사용자 세션 데이터를 읽고 쓸 수 있는지 알려준다. @supabase/ssr 프레임워크에 구애받지 않도록 만들기 위해, 쿠키 메서드들은 하드코딩 되어 있지 않다. 이런 유틸 함수들은 Next.js를 위해 @supabase/ssr의 쿠키 처리 방식을 조정한다.

서버 클라이언트의 set과 remove 메서드에는 에러 핸들러가 필요한데, Nest.js는 서버 컴포넌트에서 쿠키를 설정할 때 에러를 발생시키기 때문이다. 다음 단계에서 새로고침된 쿠키를 저장소에 쓰기 위한 미들웨어를 설정할 것이므로, 이 에러는 무시해도 괜찮다.

쿠키의 기본 이름은 sb-<project_ref>-auth-token 이다.

 

Q. 모든 라우트마다 새로운 클라이언트를 생성해야 하나?

그렇다! Supabase 클라이언트 생성은 가벼운 작업이다.

서버에서는 기본적으로 fetch 호출을 구성한다. 재구성해야 한다. 서버로 오는 모든 호출에 대한 fetch 호출을 새로 구성해야 하는데, 이는요청으로부터 쿠키가 필요하기 때문이다.

클라이언트에서는 createBrowserClient가 이미 싱클톤 패턴을 사용하고 있어서, createClient 함수를 몇 번 호출하든 단 하나의 인스턴스만 생성한다.

 

④ 미들웨어 연결하기

middleware.ts 파일을 프로젝트 루트에 생성하라.

 

서버 클라이언트는 쿠키를 작성할 수 없기 때문에, 만료된 인증 토큰을 새로고침하고 저장하기 위한 미들웨어가 필요하다.

 

미들웨어의 역할은 다음과 같다.

  1. 인증 토큰 새로고침 (supabase.auth.getUser을 호출함으로써)
  2. 서버 컴포넌트가 같은 토큰을 다시 새로고침하지 않도록, 새로고침된 인증 토큰을 서버 컴포넌트에 전달한다. 이는 request.cookies.set에 의해 수행된다.
  3. 기존 토큰을 대체하기 위해, 새로고침된 인증 토큰을 브라우저에 전달한다. 이는 response.cookies.set에 의해 수행된다.

middleware 코드를 앱에 복사하라.

Supabase를 사용하지 않는 라우트에서는 미들웨어가 실행되지 않도록 매처(matcher)를 추가하라.

페이지 보호 시 주의하라.
☞ 서버는 쿠키에서 사용자 세션을 가져오는데, 이는 누구나 위조할 수 있다.
☞ 페이지와 사용자 데이터 보호를 위해 항상 supabase.auth.getUser()를 사용하라.
☞ 미들웨어와 같은 서버 코드에서는 절대 supabase.auth.getSession()을 신뢰하지 마라. 인증 토큰의 재검증이 보장되지 않는다.
☞ getUser()는 매번 Supabase 인증 서버에 요청을 보내 인증 토큰을 재검증하기 때문에 신뢰하 수 있다.
// 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 (파비콘 파일)
	  * 필요한 경우 더 많은 경로를 포함하도록 이 패턴을 수정하세요.
	  */
    '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
  ],
}

// utils/supabase/middleware.ts
import { createServerClient } from '@supabase/ssr'
import { NextResponse, type NextRequest } from 'next/server'

export async function updateSession(request: NextRequest) {
  let supabaseResponse = NextResponse.next({
    request,
  })

  const supabase = createServerClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        getAll() {
          return request.cookies.getAll()
        },
        setAll(cookiesToSet) {
          cookiesToSet.forEach(({ name, value, options }) => request.cookies.set(name, value))
          supabaseResponse = NextResponse.next({
            request,
          })
          cookiesToSet.forEach(({ name, value, options }) =>
            supabaseResponse.cookies.set(name, value, options)
          )
        },
      },
    }
  )

// createServerClient와 supabase.auth.getUser() 사이에 코드를 작성하지 마세요.
// 간단한 실수로도 사용자가 무작위로 로그아웃되는 문제를 디버깅하기가 매우 어려워질 수 있습니다.
// 중요: auth.getUser()를 제거하지 마세요

  const {
    data: { user },
  } = await supabase.auth.getUser()

  if (
    !user &&
    !request.nextUrl.pathname.startsWith('/login') &&
    !request.nextUrl.pathname.startsWith('/auth')
  ) {
    // no user, potentially respond by redirecting the user to the login page
    const url = request.nextUrl.clone()
    url.pathname = '/login'
    return NextResponse.redirect(url)
  }

// 중요: supabaseResponse 객체를 반드시 그대로 반환해야 합니다.
// NextResponse.next()로 새로운 response 객체를 생성하는 경우 다음 사항을 반드시 지켜주세요:
// 1. 다음과 같이 request를 전달하세요:
// const myNewResponse = NextResponse.next({ request })

// 2. 다음과 같이 쿠키를 복사하세요:
// myNewResponse.cookies.setAll(supabaseResponse.cookies.getAll())

// 3. myNewResponse 객체를 필요에 맞게 수정하되, 쿠키는 변경하지 마세요!

// 4. 마지막으로:
// return myNewResponse

// 이 과정을 지키지 않으면 브라우저와 서버의 동기화가 깨져
// 사용자 세션이 예기치 않게 종료될 수 있습니다!

  return supabaseResponse
}

 

⑤ 로그인 페이지를 생성하라.

앱의 로그인 페이지를 만들어라. 서버 액션을 사용하여 Supabase 회원가입 함수를 호출하라.

 

Supabase가 액션에서 호출되므로, @utils/supabase/server.ts에 정의된 클라이언트를 사용하라.

cookies는 Supabase  호출 전에 호출되며, 이는 fetch 호출이 Next.js의 캐싱 대상에서 제외되도록한다. 이는 인증된 데이터를 가져올 때 사용자가 자신의 데이터에만 접근할 수 있도록 보장하기 때문에 중요하다.
데이터 캐싱 제외에 대해 자세히 알아보려면 Next.js 문서를 참조하라.
// app/login/page.tsx
import { login, signup } from './actions';

export default function LoginPage() {
  return (
    <form>
      <label htmlFor="email">Email:</label>
      <input id="email" name="email" type="email" required />
      <label htmlFor="password">Password:</label>
      <input id="password" name="password" type="password" required />
      <button formAction={login}>Log in</button>
      <button formAction={signup}>Sign up</button>
    </from>
  )
}

// app/login/actions.ts
'use server'

import { revalidatePath } from 'next/cache'
import { redirect } from 'next/navigation'

import { createClient } from '@/utils/supabase/server'

export async function login(formData: FormData) {
  const supabase = await createClient()

  // type-casting here for convenience
  // in practice, you should validate your inputs
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signInWithPassword(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}

export async function signup(formData: FormData) {
  const supabase = await createClient()

  // type-casting here for convenience
  // in practice, you should validate your inputs
  const data = {
    email: formData.get('email') as string,
    password: formData.get('password') as string,
  }

  const { error } = await supabase.auth.signUp(data)

  if (error) {
    redirect('/error')
  }

  revalidatePath('/', 'layout')
  redirect('/')
}

// app/error/page.tsx
'use client';

export default functiom ErrorPage() {
  return <p>Sorry, something went wrong</p>
}

 

⑥ 인증 확인 경로 변경하기

이메일 확인 기능이 켜져 있다면(기본값), 새로운 사용자는 회원가입 후 확인 이메일을 받게 된다.

 

서버 사이드 인증 플로우를 지원하도록 이메일 템플릿을 변경하라.

 

대시보드의 Auth templates 페이지로 이동하라. Confirm signup 템플릿에서 {{ .ConfirmationURL }}을 {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=signup으로 변경하라.

 

⑦ 인증 확인을 위한 라우트 핸들러 생성하기

auth/confirm 라우트 핸들러를 생성하라. 사용자가 확인 이메일의 링크를 클릭하면, 보안 코드를 인증 토큰으로 교환한다.

 

이는 라우트 핸들러이므로, @/utils/supabase/server.ts의 Supabase 클라이언트를 사용하라.

import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest } from 'next/server'

import { createClient } from '@/utils/supabase/server'
import { redirect } from 'next/navigation'

export async function GET(request: NextRequest) {
  const { searchParams } = new URL(request.url)
  const token_hash = searchParams.get('token_hash')
  const type = searchParams.get('type') as EmailOtpType | null
  const next = searchParams.get('next') ?? '/'

  if (token_hash && type) {
    const supabase = await createClient()

    const { error } = await supabase.auth.verifyOtp({
      type,
      token_hash,
    })
    if (!error) {
      // redirect user to specified redirect URL or root of app
      redirect(next)
    }
  }

  // redirect the user to an error page with some instructions
  redirect('/error')
}

 

⑧ 서버 컴포넌트에서 사용자 정보 접근

서버 컴포넌트는 쿠키를 읽을 수 있으므로, 인증 상태와 사용자 정보를 가져올 수 있다.

 

서버 컴포넌트에서 Supabase를 호출하므로, @/utils/supabase/server.ts에서 생성된 클라이언트를 사용하라.

 

로그인한 사용자만 접근할 수 있는 private 페이지를 만들어라. 이 페이지는 사용자의 이메일을 표시한다.

페이지 보호 시 주의하라.
☞ 서버는 쿠키에서 사용자 세션을 가져오는데, 이는 누구나 위조할 수 있다.
☞ 페이지와 사용자 데이터 보호를 위해 항상 supabase.auth.getUser()를 사용하라.
☞ 서버 컴포넌트에서 supabase.auth.getSession()을 절대로 신뢰하지 마라. 인증 토큰의 재검증이 보장되지 않는다.
☞ getUser()는 매번 Supabase 인증 서버에 요청을 보내 인증 토큰을 재검증하기 때문에 신뢰하 수 있다.
inport { redirect } from 'next/navigation';
import { createClient } from '@/utils/supabase/server';

export default async function PrivatePage() {
  const supabase = await createClient();
  
  const { data, error } = await supabase.auth.getUser();
  if (error || !data?.user) {
    redirect('/login');
  }
  
  return <>Hello {data.user.email}</p>
}

축하합니다!

끝났다! 지금까지 성공적으로 해온 것을 정리하면

  1. 서버 액션에서 Supabase 호출
  2. 서버 컴포넌트에서 Supabase 호출
  3. 클라이언트 컴포넌트에서 Supabase를 호출하기 위한 클라이언트 유틸리티 설정. 실시간 구독 설정과 구독 설정과 같이 클라이언트 컴포넌트에서 Supabase를 호출해야 사용할 수 있다.
  4. Supabase 인증 세션을 자동으로 새로고침하는 미들웨어 설정

이제 클라이언트나 서버 코드에서 모든 Supabase 기능을 사용할 수 있다.

댓글