[Next.js] Supabase로 로그인 및 회원가입 구현하기

룽지·2024년 10월 22일

Next.js로 진행하고 있는 SNS 프로젝트는 백엔드 없이 개발해야 하기 때문에 Supabase를 사용하여 로그인 및 회원가입을 구현해본다. Supabase는 공식문서가 잘 정리되어 있기 때문에 보고 따라만 해도 잘 동작한다. 차근차근 하나씩 살펴보면서 이해해보려고 한다.

Next.js에서 회원 관리 앱을 만드는 내용을 담은 공식문서에서는 회원가입 후 로그인을 진행한 후에 계정 페이지로 넘어가서 해당 user의 계정 정보를 보여준다. 회원가입과 로그인 폼을 동시에 진행하면서 버튼만 다르게 설정되어 있고, 계정 페이지를 보여줄 경우에는 profile이라는 별도의 데이터베이스를 생성한 후에 시작해야 한다. 그리고 회원가입은 이메일 인증 절차를 통해서 진행된다. 이메일에 온 url을 통해서 가입한 웹의 도메인으로 리디렉트되어 회원가입이 성공함과 동시에 로그인이 된다. account의 페이지를 별도로 만들지 않고 로그인 성공 시, user의 email만 보이게 설정했다.


1. Supabase 설치 및 초기 설정

각자 프로젝트에 env를 설정하고 다음과 같은 라이브러리들을 설치한다.

env 설정

supabse를 사용하기 위해서는 API URLanon key가 필요하다.

먼저 supabse에서 각자의 organization에서 project를 생성해서 진행한다.

그리고 여기서 Settings > API > API Settings에서 해당 값들을 복사해 다음 .env.local 또는 .env 파일에 설정해준다.

  • env 파일 설정
NEXT_PUBLIC_SUPABASE_URL=YOUR_SUPABASE_URL
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_SUPABASE_ANON_KEY

supabase-js 설치

npm install @supabase/supabase-js

  • supabase의 JavaScript 클라이언트 라이브러리
  • supabase 클라이언트를 초기화하고 API 호출하기 위해서 설치한다.

@supabase/ssr 설치

npm install @supabase/ssr

  • Next.js 서버에서 인증을 구현하는 데 필요한 기능을 제공한다.
  • 쿠키를 통한 세션 관리를 통해 사용자의 로그인 상태를 유지하고 인증 정보를 관리한다.

서버 측 인증 흐름

  • 사용자가 로그인 요청을 하면, Next.js 서버는 Supabase의 인증 API를 호출하여 사용자를 인증한다.
  • 인증이 성공하면, Next.js 서버는 사용자의 세션을 쿠키에 저장하고, 클라이언트에 세션 정보를 전달한다.
  • 이후 사용자는 쿠키를 통해 인증된 상태로 Next.js 애플리케이션에 접근할 수 있다.

결론적으로, Supabase는 인증 로직을 제공하는 백엔드 역할을 하고, Next.js는 이 인증 로직을 호출하고 세션을 관리하는 프레임워크 역할을 한다.


2. Next.js에 Supabase Server-Side Auth 설정하기

Supabase 클라이언트의 두 가지 유형

  1. Client Component client

    • 브라우저에서 실행되는 클라이언트 컴포넌트에 Supabase에 접근하는 데 사용
  2. Server Component client

    • 서버 컴포넌트, 서버 액션 및 라우터 핸들러와 같은 서버에서만 실행되는 부분에서 Supabase에 접근하는 데 사용
    • 서버에서 직접 Supabase와 상호작용할 때 적합

클라이언트를 생성하기 위해 utiils/supabase 폴더에 필수 유틸리티 파일을 정리한다.

  • utils/supabase/client.ts
import { createBrowserClient } from '@supabase/ssr'

export function createClient() {
  // 프로젝트의 자격 증명을 사용하여 브라우저에서 Supabase 클라이언트를 생성하세요.
  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 function createClient() {
  const cookieStore = cookies()

  // 새로 구성된 쿠키로 서버의 Supabase 클라이언트를 생성합니다.
  // 이는 사용자의 세션을 유지하는 데 사용될 수 있습니다.
  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 {
            // `setAll` 메소드는 서버 컴포넌트에서 호출되었습니다.
			// 사용자의 세션을 새로 고치는 미들웨어가 있는 경우,
			// 이 부분은 무시할 수 있습니다.
          }
        },
      },
    }
  )
}

3. Next.js 미들웨어 서버 설정

서버 컴포넌트는 쿠키를 쓸 수 없기 때문에, 만료된 인증 토큰을 새로 고치고 저장하기 위해 미들웨어가 필요하다.

  1. 인증 토큰 새로 고침 : supabase.auth.getUser
    • 페이지와 사용자 데이터를 보호하기 위해서 사용한다.
    • getUser는 매번 Supabase Auth 서버에 요청을 보내 인증 토큰을 재검증하므로 안전하게 신뢰할 수 있다.
    • supabase.auth.getSession()는 인증 토큰을 재검증할 수 없기 때문에 미들웨어와 같은 서버 코드 내에서는 위험하다.
  2. 서버 컴포넌트에 새로 고친 인증 토큰 전달 : request.cookies.set
  3. 브라우저에 새로 고친 인증 토큰 전달 : response.cookies.set

미들웨어가 Supabase에 접근하는 경로에서만 실행되도록 매처(matcher)를 추가할 수도 있다.

middleware 파일 생성

  • 프로젝트 루트 아래 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)
          )
        },
      },
    }
  )

  // 인증 토큰 새로고침
  await supabase.auth.getUser()

  return supabaseResponse
}

4. Login page 설정

로그인과 회원가입 form

  • 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>
    </form>
  )
}
  • 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 = createClient()

  // 편의를 위한 타입 캐스팅
  // 실제로는 입력값을 검증해야 한다.
  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 = createClient()

  
  // 편의를 위한 타입 캐스팅
  // 실제로는 입력값을 검증해야 한다.
  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('/')
}

이메일 템플릿 변경

서버 측 인증 흐름을 지원하도록 이메일 템플릿을 변경해야 한다.

이메일 템플릿 페이지로 이동한다.

{{ .ConfirmationURL }}에서 {{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email로 변경한다.

5. endpoint 설정

SSR 환경에서 작업하고 있기 때문에 token_hash를 세션으로 교환하는 역할을 하는 서버 엔드포인트를 생성해야 한다.

1. Supabase Auth 서버에서 전송된 코드 가져오기
- `token_hash` 쿼리 매개변수를 사용

2. 코드를 세션으로 교환
- 쿠키에 세션을 저장

3.사용자를 계정 페이지로 리디렉션
  • app/auth/confirm/route.ts
// upabase에서 지원하는 이메일 OTP(One-Time Password) 유형
import { type EmailOtpType } from '@supabase/supabase-js'
import { type NextRequest, NextResponse } from 'next/server'

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

// /auth/confirm 경로에 대한 GET 요청을 처리하는 핸들러 생성한다.
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 = '/' // 리디렉션할 경로

  // secret token을 포함하지 않은 리디렉션 링크를 생성한다.
  const redirectTo = request.nextUrl.clone()
  redirectTo.pathname = next
  redirectTo.searchParams.delete('token_hash')
  redirectTo.searchParams.delete('type')

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

    const { error } = await supabase.auth.verifyOtp({
      type,
      token_hash,
    })
    if (!error) {
      redirectTo.searchParams.delete('next')
      return NextResponse.redirect(redirectTo)
    }
  }

  // 사용자를 오류 페이지로 리디렉션하고 일부 지침을 제공한다
  redirectTo.pathname = '/error'
  return NextResponse.redirect(redirectTo)
}

6. 로그아웃 및 로그인 확인 page 설정

Signout

  • app/auth/signout/route.ts
import { createClient } from '@/utils/supabase/server'
import { revalidatePath } from 'next/cache'
import { type NextRequest, NextResponse } from 'next/server'

export async function POST(req: NextRequest) {
  const supabase = createClient()

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

  if (user) {
    await supabase.auth.signOut()
  }

  revalidatePath('/', 'layout')
  return NextResponse.redirect(new URL('/login', req.url), {
    status: 302,
  })
}

로그인 성공 여부 확인

private page를 생성한 후에 / 경로에 위치한 페이지에 추가하여 로그인 성공 시, 해당 유저의 이메일을 보여준다. 유저가 로그인 상태가 아니라면 login page로 리디렉션되도록 추가했다. 그리고 해당 페이지에서 로그아웃도 할 수 있다.

  • app/private/page.tsx
import { redirect } from "next/navigation";

import { createClient } from "@/utils/supabase/server";

export default async function PrivatePage() {
  const supabase = createClient();

  const { data, error } = await supabase.auth.getUser();
  if (error || !data?.user) {
    redirect("/login");
  }

  return (
    <div>
      <p>Hello {data.user.email}</p>
      <form action="/auth/signout" method="post">
        <button className="button block" type="submit">
          Sign out
        </button>
      </form>
    </div>
  );
}
  • src/page.tsx
import PrivatePage from "./private/page";

const Homepage = () => {
  return (
    <div>
      <PrivatePage />
    </div>
  );
};

export default Homepage;

7. 로그인 및 회원가입 기능 확인하기


8. 회원가입 시 사용자 데이터 확장하기

SNS 프로젝트에서는 Users라는 테이블에 다음과 데이터로 구성해서 관리하려고 했다.

그런데 Supabase에서는 Auth에서 Users라는 테이블을 통해 관리하고 있고, 위와 동일하게 회원가입할 때 id와 password만 전달하기 때문에 다른 정보들을 어떻게 추가할 수 있을지 고민이 됐다.

Supabase에서는 user 정보를 관리할 별도의 테이블을 생성해서 개인적으로 API로 접근할 수 있게 따로 관리하라고 한다.

Supabase에서의 Auth

Supabase에서는 보안을 위해 Auth 스키마는 노출하고 있지 않다고 한다. 그래서 API를 통해 사용자 데이터를 접근하기 위해서는 자신의 사용자 테이블을 별도 생성하여 사용해야 한다.

따라서 사용자가 회원가입할 때마다 public.profiles 테이블을 업데이트해야 한다. 그러기 위해서는 트리거를 설정해야 하며, 트리거가 실패하면 회원가입을 실패할 수 있다.

profiles 테이블 생성

Supabase에서 SQL Editor에 들어가 sql문을 복사해서 query를 실행한다. qurey문에는 회원가입할 때 profiles 테이블에 접근할 수 있도록 트리거를 포함하고 있다.

  • 실행할 sql
-- Create a table for public profiles
CREATE TABLE
  profiles (
    id UUID REFERENCES auth.users NOT NULL PRIMARY KEY,
    email TEXT NOT NULL, -- 회원가입 시 받아오는 이메일
    nickname TEXT UNIQUE, -- 트리거를 통해 받아오는 닉네임
    profile_image TEXT, -- 프로필 이미지 URL (회원가입 시 받지 않음)
    created_at TIMESTAMP WITH TIME ZONE DEFAULT timezone ('utc'::TEXT, NOW()), -- 처음 생성 시 자동 생성
    updated_at TIMESTAMP WITH TIME ZONE -- 마지막 업데이트 시간
  );

-- Set up Row Level Security (RLS)
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;

CREATE POLICY "Public profiles are viewable by everyone." ON profiles FOR
SELECT
  USING (TRUE);

CREATE POLICY "Users can insert their own profile." ON profiles FOR INSERT
WITH
  CHECK (
    (
      SELECT
        auth.uid ()
    ) = id
  );

CREATE POLICY "Users can update own profile." ON profiles
FOR UPDATE
  USING (
    (
      SELECT
        auth.uid ()
    ) = id
  );

-- This trigger automatically creates a profile entry when a new user signs up via Supabase Auth.
CREATE FUNCTION public.handle_new_user () RETURNS TRIGGER
SET
  search_path = '' AS $$
BEGIN
  INSERT INTO public.profiles (id, email, nickname, profile_image)
  VALUES (new.id, new.email, new.raw_user_meta_data->>'nickname', NULL);  -- 프로필 이미지는 NULL로 설정
  RETURN new;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;

CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users FOR EACH ROW
EXECUTE PROCEDURE public.handle_new_user ();

Supabase SQL Editor

run으로 실행하면 다음과 같은 pfofiles 테이블이 생성된다.

signup 함수 추가

앞에서 app/login/actions.ts에 작성한 signup 함수에 다음 내용을 추가하여 데이터가 트리거될 수 있도록 한다.

const data = {
    email: formData.get("email") as string,
    password: formData.get("password") as string,
    options: {
      data: {
        nickname: formData.get("nickname") as string,
      },
    },
  };

Reference.
supabase Docs : Build a User Management App with Next.js
supabase Docs : Setting up Server-Side Auth for Next.js
supabase Docs : User Management
Next.js 서버 사이드 Auth 설정

0개의 댓글