Next.js 14 + Supabase로 Server-Side Auth 적용하기

[리팩토링 #1] Supabase와 Server-Side Auth 적용하기


Table Of Contents


0. Awesome Resume Builder 프로젝트란?


취준을 시작하면서 이력서를 쓰게 되었는데, 마음에 드는 템플릿이 별로 없었다😇

가장 마음에 들었던 건 Awesome CV 양식이었는데, Latex로 쉽게 수정 가능하지만 한글 폰트 적용이 마음대로 안 되거나, 규칙이 엄격하다는 등의 불편한 점이 있었다. 스크립트를 수정하면 어떻게든 될 것 같았으나 Latex를 잘 몰라서 하나 고치면 다른 데서 에러가 생기더라😭

그래서 아예 비슷한 양식의 이력서를 노코드로 작성하면 pdf로 뽑아주는 서비스를 만들었다. 아래처럼 텍스트만 채우면 미리 지정된 양식으로 pdf 이력서를 뽑아준다!

문제는 당시에 "완성된 pdf 이력서 뽑기"가 급해서 서비스의 코드 품질이나 사용성 같은 건 전혀 고려하지 않았다는 점이다.

screenshot

프로젝트의 스크린샷. UX적으로 고치고 싶은 부분이 많다.

그래서 지금부터 코드를 조금씩 고쳐 보려고 한다.

1. SSR


오늘은 SSR 적용하기부터 시작하려고 한다.

본 프로젝트는 Next.js를 사용하고 있었음에도 불구하고 SSR이 적용되지 않았다. 이 부분에서 가장 거슬리던 점은

problem

이렇게 페이지에 처음 들어가거나 새로고침을 할 때마다 데이터가 fetch되는 과정이 다 보인다는 점이다.

이렇게 layout shift가 많으면 일단 보기에 좋지 않고, 버튼을 잘못 클릭하게 할 수 있으므로 좋지 않다.

Next.js Server Component 사용하기

전에 Next.js를 썼을 때는 GetStaticProps로 데이터를 fetch해왔는데, 이제는 GetStaticProps는 안 쓴단다... 머쓱

대신에 13부터 Server Component 안에서 데이터를 fetch할 수 있다.

useEffect, useState 제거하기

기존 코드에서는

const [resumeData, setResumenData] = useState<Resume[] | null>(null); ... useEffect(() => { getResumes().then((res: Resume[] | null) => { if (res) setResumenData(res); }); }, []);

처럼 useEffect를 이용해서 초기 렌더링 이후 데이터를 fetch해왔다.

이제는 서버 컴포넌트를 이용할 예정이므로 파일 맨 위에서 "use client;"를 지워주고, useState, useEffect같은 hook도 지워준다.

const resumeList = await getResumeList();

처럼 바로 데이터를 받아오면 된다.

결과

result

새로고침을 해도 layout shift가 일어나지 않는다🥰

2. Authentication & Authorization


다음으로는 Authentication & Authorization 부분을 수정하고자 한다.

기존에는 유저가 로그인할 때, redux-persist로 user id를 local storage에 저장했다. 그리고 local storage에 uid가 있다면 서비스 메인 화면을, 아니라면 로그인 화면을 보여준다.

const isSignedIn = useAppSelector((state) => state.userReducer.is_signed_in_resume_builder) === "true"; ... {isSignedIn && <MainPage />} {!isSignedIn && <SignInPage />}

이 부분을 서버로 넘겨보자. 그리고 로그인 페이지를 /login으로 옮기겠다.

Next.js Docs에서는 Authentication 관련 튜토리얼을 제공한다.

또한, Supabase Docs에서 Next.js로 세션을 관리하는 튜토리얼이 있다. 해당 튜토리얼을 이용해서 Server-Side Auth를 적용해보자.


라이브러리 설치

우선 Supabase에서 Server-Side Auth를 처리하기 위해서 만든 라이브러리 @supabase/ssr를 설치한다.

npm install @supabase/ssr

Supabase Client 만들기

Next.js 앱에서 Supabase에 접속하기 위해서는 const client = createClient()처럼 Supabase client를 만들게 된다. 그런데 Server Component와 Client Component에서는 다른 client를 사용해야 한다.

  • Client Component client
    • Client Compoenent에서 호출하기 때문에 브라우저에서 실행된다.
  • Server Component client
    • Server Compoenent, Server Action, Route Handler 등 서버에서 실행된다.

따라서 client를 생성하기 위한 createClient 함수도 각각 정의해야 한다. 튜토리얼에서는 utils/supabase 디렉토리를 만들고, 그 안에 utils/supabase/client.tsutils/supabase/server.ts를 각각 작성한다.

@supabase/ssr 라이브러리 안에 구현된 createBrowserClient, createServerClient 함수를 한번 감싸서 createClient라는 함수로 내보내게 되는데, Client Component client는 기존 코드와 크게 다르지 않지만, Server Component client는 조금 다르다.

// utils/supabase/server.ts import { createServerClient, type CookieOptions } from '@supabase/ssr' import { cookies } from 'next/headers' export function createClient() { const cookieStore = cookies() return createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return cookieStore.get(name)?.value }, set(name: string, value: string, options: CookieOptions) { ... }, ... }, } ) }

createServerClient의 3번째 인자로 coockies object를 넘겨주고 있는 것을 확인할 수 있다.

cookie object는 Supabase client의 쿠키 접근 방법을 정의한다. @supabase/ssr 라이브러리가 특정 프레임워크에 의존하지 않기 위해서 이렇게 구현했다는 듯. Next.js가 아니라 다른 프레임워크를 사용한다면 cookies object만 수정하면 된다.

참고: What does the cookies object do?

Next.js middleware 설정하기

Server Component에서 쿠키를 작성할 수 없으므로(Server Action, Middleware, Route Handler에서는 가능하다) 세션을 관리하기 위해서는 middleware를 만들어야 한다.

Next.js의 Middleware

Next.js app에서 Middleware는 모든 route에서 실행된다. Request에 기반해서, response를 재작성하거나, 리디렉션하거나, request를 수정하는 등의 작업이 가능하다.

참고 : Next.js docs Middleware

root 디렉토리에 middleware.ts(또는 .js)라는 이름으로 만들어 사용하는 것이 규칙이므로 해당 파일을 만들어주자.

// 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: [ /* * Match all request paths except for the ones starting with: * - _next/static (static files) * - _next/image (image optimization files) * - favicon.ico (favicon file) * Feel free to modify this pattern to include more paths. */ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], }

실제로 하는 역할은 @/utils/supabase/middleware 파일의 updateSession 함수에 정의되어 있다.

Matcher

updateSession의 구현을 보기 전에, config = { matcher: [] } 부분을 잠시 보고 넘어가자.

matcher는 Middleware가 특정 path에서만 실행되도록 해준다. (Next.js docs)

아래처럼 설정하면 /about과 /dashboard 아래 모든 경로에 대해서 Middleware가 실행된다.

export const config = { matcher: ['/about/:path*', '/dashboard/:path*'], }

튜토리얼은 아래처럼 설정해줬는데, 아래 경로들을 제외하고 모든 경로에 대해서 Middleware를 실행하라는 뜻이 된다.

  • _next/static (static files)
  • _next/image (image optimization files)
  • favicon.ico (favicon file)
  • svg, png, jpg, jpeg, gif, webp 확장자로 끝나는 파일
export const config = { matcher: [ '/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)', ], }

updateSession()

// @/utils/supabase/middleware import { createServerClient, type CookieOptions } from '@supabase/ssr' import { NextResponse, type NextRequest } from 'next/server' export async function updateSession(request: NextRequest) { let response = NextResponse.next({ request: { headers: request.headers, }, }) const supabase = createServerClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, { cookies: { get(name: string) { return request.cookies.get(name)?.value }, set(name: string, value: string, options: CookieOptions) { request.cookies.set({ name, value, ...options, }) response = NextResponse.next({ request: { headers: request.headers, }, }) response.cookies.set({ name, value, ...options, }) }, ... }, } ) await supabase.auth.getUser() return response }

위에서 Server client를 만들듯이 supabase client를 만든다. 이 함수가 하는 일은 튜토리얼에서 설명해줬다.

  • Refreshing the Auth token with the call to supabase.auth.getUser.
  • Passing the refreshed Auth token to Server Components through request.cookies.set, so they don't attempt to refresh the same token themselves.
  • Passing the refreshed Auth token to the browser, so it replaces the old token. This is done with response.cookies.set.

supabase.auth.getUser()를 호출하면서 Auth token을 refresh하고, supabase client의 cookies object에 정의된 대로 요청과 응답의 쿠키를 설정해 refresh된 토큰을 저장한다.

  • Middleware에서 request.cookies.set으로 refresh된 토큰을 저장해주기 때문에, Server Component에서 토큰을 refresh할 필요가 없어진다.
  • response.cookies.set으로 응답에도 refresh된 토큰을 넣어주어 이전 토큰을 대체하도록 한다.

로그인 Server Action 구현하기

현재 사용하고 있는 로그인 페이지는 onclick 이벤트 핸들러 안에 로그인 로직이 들어가 있다.

Next.js 14에서는 Server Action이라는 기능이 정식 도입되었다. Server Action은 서버에서 실행되는 비동기 함수인데, form 데이터를 다루는 데에 주로 사용된다.

사실 큰 장점은 잘 모르겠는데 튜토리얼에서 Server Action을 사용하고, 나도 <form>을 제대로 다뤄 본 적이 없어서 사용해보려고 한다.

/login 페이지를 새로 만들어주자.

// app/login/page.tsx import { login } 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> </form> ) }

Server Action은 <form> 요소의 action 속성을 이용하여 호출할 수 있다. (Server Action은 꼭 form에서만 사용해야 하는 것은 아니고, 밖에서도 사용할 수 있다)

여기에서는 <button formAction={login}/>처럼 login 함수를 넘겨줬다.

같은 디렉토리 내에 app/login/action.ts 파일을 만들고, 여기에 로그인 Server Action을 정의한다.

// app/login/action.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 = createClient() const data = { email: formData.get('email'), password: formData.get('password'), } const { error } = await supabase.auth.signInWithPassword(data) if (error) { // 에러 처리 redirect('/error') } revalidatePath('/', 'layout') redirect('/') }
  • Server Action을 사용하려면 파일 맨 위에 'use server' 지시어를 써줘야 한다.
  • 서버에서 실행되는 함수이기 때문에 Supabase client도 @/utils/supabase/server에서 createClient를 가져와서 사용해야 한다.
  • supabase.auth.signInWithPassword(data)를 호출해서 로그인을 시도하고, 에러가 발생하면 처리한다. 여기에서는 /error 페이지로 리디렉션 처리했다.
  • revalidatePath('/', 'layout')를 호출하면 / 라우트에 대해서 기존에 캐시된 데이터를 무효화하고, 새로 방문될 때 다시 생성될 수 있도록 한다.

참고