[Supabase] 초기 세팅 & Authentication 이메일 로그인 구현

windowook·2024년 10월 15일
post-thumbnail

🌱 소개

Supabase는 오픈 소스 백엔드 서비스로 실시간 데이터베이스, 인증, 스토리지, 서버리스 기능 등을
손쉽게 통합할 수 있도록 도와주는 플랫폼입니다. Firebase와 유사한 기능을 제공하지만 PostgreSQL 데이터베이스를 기반으로 한다는 점에서 차별화됩니다. Firebase는 NoSQL이므로 Supabase가 더 정교한 RDB의 특징을 가지고 있죠. 비용 면에서도 Supabase가 훨씬 저렴합니다.

Firebase

Supabase

그래서 해외에서는 이미 Firebase의 대체재로써 Supabase가 핫하게 사용되고 있고, 국내에서도 프론트엔드 개발자들이 풀스택 개발을 위해 Supabase를 이용하는 비율이 계속 높아지는 중입니다.

단점이 있다면 비교적 런칭한지 얼마되지 않은 플랫폼이다보니 커뮤니티가 아직 Firebase보다 작습니다.
또 지원하는 기능도 더 적고 연동되는 서비스도 더 적습니다. 그리고 문서의 한글 번역도 지원하지 않습니다.

그럼에도 불구하고 Supabase가 주는 이점은 많습니다. Supabase는 설정이 쉽고, 간단한 SQL 지식만 있어도 개발자들이 매우 쉽게 사용할 수 있습니다. 그리고 오픈 소스 특성상 코드나 기능을 자유롭게 수정하거나 확장할 수 있고, 이를 통해 신속한 프로토타입 개발이나 확장이 용이한 백엔드 역할을 해줍니다.

아래는 Supabase에서 지원하는 기능입니다.

Database

강력한 오픈 소스 데이터베이스인 PostgreSQL을 기반으로 하여 확장성과 성능이 뛰어납니다.

Authentication

다양한 인증 방식을 제공하며, 유저의 권한을 세부적으로 설정할 수 있습니다.

Storage

파일 업로드 및 관리를 위한 스토리지 기능을 제공하며, 이를 간편하게 API로 사용할 수 있습니다.

Edge Functions

클라우드 함수(FaaS), 즉 서버리스 함수를 사용하여 서버를 직접 관리하지 않고도 로직을 처리할 수 있습니다.

Realtime

실시간 업데이트와 반영을 지원하여, 앱에서 실시간 대화를 구현하기 편리합니다. 현재 개발 중인 프로젝트에 DB, Authentication, Storage를 이용 중입니다. 이번 글에서는 Authentication 기능을 사용해서 유저의 이메일 회원가입과 로그인을 어떻게 구현하는지 설명하겠습니다.

후반부 코드 구현 설명을 위해 미리 얘기하면 코드 기반은 NextJS 14의 앱 라우터이며, 타입스크립트를 사용 중입니다. 만약 코드를 따라서 구현하신다면 필요한 라이브러리는 Tanstack Query v5MUI입니다.

🌱 프로젝트 생성과 환경 변수 세팅

https://supabase.com/

일단 Supabase에 SignIn을 클릭하여 계정이 없다면 새로 만들고, 프로젝트를 생성해야 합니다. 계정을 생성하면 초기 닉네임으로 Organization이 만들어지는데, 여러 개의 프로젝트를 관리하는 조직을 의미합니다. 계정을 생성했다면 New Project를 클릭하여 새 프로젝트를 생성하는 페이지로 이동합니다.

프로젝트명은 실제로 본인이 개발 중인 프로젝트명과 맞추거나 적절한 걸로 하면 됩니다. DB password의 경우 'Generate a password'를 누르면 자동으로 영문 대소문자와 숫자를 조합한 강력한 패스워드를 생성해줍니다. 생성된 패스워드는 복사해서 내 로컬 프로젝트의 .env.local에 NEXT_SUPABASE_DB_PASSWORD로 저장해줘야 합니다.

이제 나머지 Supabase의 서버 클라이언트를 사용하기 위한 키들을 환경 변수에 저장해야 합니다.

  • Supabase URL, Supabase anon, Supabase service role

Project Settings로 들어가서 CONFIGURATION 파트의 API로 들어갑니다.

그럼 URL, anon, service role을 모두 확인할 수 있습니다. 값들을 환경 변수에 저장해주면 됩니다. service role과 DB 패스워드는 PUBLIC을 절대 사용하면 안 됩니다. 클라이언트에서 노출은 위험합니다.

NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
NEXT_SUPABASE_SERVICE_ROLE=
NEXT_SUPABASE_DB_PASSWORD=

🌱 Authentication

왼쪽 사이드바에서 Authentication 탭을 클릭하여 페이지를 이동하면 이런 서브 사이드바가 활성화 되어있습니다.

회원가입 인증이 이루어지는 과정

앱에 계정이 없는 유저가 회원가입 페이지에서 정보를 입력 후 '가입하기 '버튼을 누르면 클라이언트에서 Supabase로 요청을 보냅니다. 그럼 Supabase에서 6자리 인증 코드가 담긴 메일을 유저가 입력한 이메일로 보내줍니다. 메일에 담긴 인증 코드를 동일하게 입력하면 회원가입이 완료됩니다.

이메일 템플릿 설정

CONFIGURATION 파트의 Email Templates를 클릭합니다.

그럼 이런 화면이 보이는 페이지로 이동하게 됩니다. Subject Heading은 메일 제목입니다. 제목을 적고 아래로 내려갑시다.

Message Body는 메일 본문에 담길 내용을 적어주는 곳입니다. 마크다운을 적용할 수 있기 때문에 style 속성을 부여해서 폰트 크기와 컬러 등을 설정해 줄 수 있습니다.

여기서 꼭 넣어줘야 하는 내용은 위에 Message Variables라는 상자의 변수들입니다. 가장 위에 있는 ConfirmationURL은 링크를 보내는 방식입니다. 말 그대로 보낸 링크로 접속하면 회원가입 인증이 완료됩니다. 이 방식을 사용해도 상관없지만, 인증 코드를 사용하는 방식이 보안적인 면에서 더 안전하기 때문에
저는 두 번째 방식인 Token을 사용했습니다. 링크 인증은 이메일의 링크가 제3자에게 유출될 경우, 누구든지 그 링크를 클릭해 해당 계정에 접근할 수 있습니다. 이메일이 해킹되거나 링크가 의도치 않게 공유되는 상황에서 위험하겠죠.

반면, 6자리 인증 코드는 유저가 직접 해당 코드를 입력해야 하기 때문에 링크 자체를 유출할 위험이 없고 인증 코드의 경우 요청한 인증 1회에만 적용되는 일회용 코드라서 안전합니다. 그리고 이메일을 확인하고 코드를 입력하는 과정이 일종의 복잡성이 추가된 장치이기도 하죠. 아래는 이메일 템플릿을 적용하고 나면 실제 유저가 회원가입 요청시 받게 되는 메일의 모습입니다.

URL 지정

이메일 템플릿을 만들어줬고, 이제 URL 설정을 해야합니다. 카카오, 구글, 네이버 로그인을 구현할 때 관리 콘솔에서 기본 도메인과 리다이렉트 URI를 저장해줬듯이 Site URL에는 기본 도메인을, Redirect URLs에는 리다이렉트시킬 URL을 지정해주면 됩니다. 여기서 주목할 부분은 리다이렉트 URI는 직접 엔드포인트를 특정하여 지정하지 않고 와일드 카드로 설정해놓으면 됩니다. 와일드 카드인 '**'는 엔드포인트의 depth 수와 상관없이 어떤 것이든 다 가능하게 설정해놓는 것입니다.

🌱 코드 구현

이제 회원가입, 로그인, 이메일과 비밀번호를 입력하는 폼, auth-provider, 세션 교환을 위한 로직 등을 구현해주어야 합니다.

components/signup.tsx

'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { Card, Button, Typography, TextField } from '@mui/material';
import { createBrowserSupabaseClient } from 'utils/supabase/client';
import UserForm from './user-form';

export default function Signup({ setView }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const [otp, setOtp] = useState('');
  const [confirmationRequired, setConfirmationRequired] = useState(false);

  // client 컴포넌트에서만 사용 가능
  const supabase = createBrowserSupabaseClient();

  const signupMutation = useMutation({
    mutationFn: async () => {
      const { data, error } = await supabase.auth.signUp({
        email,
        password,
        options: {
          emailRedirectTo: 'http://localhost:3000/signup/confirm',
        },
      });

      if (error) alert(error.message);

      if (data) setConfirmationRequired(true);
    },
  });

  const verifyOtpMutation = useMutation({
    mutationFn: async () => {
      const { data, error } = await supabase.auth.verifyOtp({
        type: 'signup',
        email,
        token: otp,
      });

      if (error) alert(error.message);

      if (data) setConfirmationRequired(true);
    },
  });
  
  return (
    <Card className="p-5 rounded-xl bg-white shadow-mainShadow">
      <Typography className="text-center text-3xl font-bold font-dpixel">
        회원가입
      </Typography>
      <form className="w-80 max-w-screen-lg sm:w-96 flex flex-col gap-4">
        {confirmationRequired ? (
          <div className="flex flex-col gap-6">
            <Typography className="text-xl font-dpixel">인증 코드</Typography>
            <TextField
              value={otp}
              onChange={(e) => setOtp(e.target.value)}
              label="6자리 인증 코드를 입력하세요"
              variant="outlined"
              type="text"
              className="p-2"
            />
          </div>
        ) : (
          <UserForm
            email={email}
            password={password}
            setEmail={setEmail}
            setPassword={setPassword}
          />
        )}
        <Button
          className="bg-main font-dpixel text-white hover:bg-opacity-70"
          fullWidth
          onClick={() => {
            if (confirmationRequired) verifyOtpMutation.mutate();
            else signupMutation.mutate();
          }}
          onLoad={
            confirmationRequired
              ? verifyOtpMutation.isPending
              : signupMutation.isPending
          }
          disabled={
            confirmationRequired
              ? verifyOtpMutation.isPending
              : signupMutation.isPending
          }
        >
          {confirmationRequired ? '인증 코드 확인' : '가입하기'}
        </Button>
        <Typography
          color="gray"
          className="text-center font-dpixel flex items-center justify-center"
        >
          이미 계정이 있으신가요?{' '}
          <Button
            onClick={() => setView('SIGNIN')}
            className="font-bold font-dpixel"
          >
            로그인하기
          </Button>
        </Typography>
      </form>
    </Card>
  );
}

components/signin.tsx

'use client';

import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { useUserStore } from 'utils/store';
import { signInWithKakao } from 'utils/supabase/signinKakao';
import { createBrowserSupabaseClient } from 'utils/supabase/client';
import { Card, Button, Typography } from '@mui/material';
import UserForm from './user-form';

export default function SignIn({ setView }) {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const supabase = createBrowserSupabaseClient();
  const setUserId = useUserStore((state) => state.setUserId);

  const signinMutation = useMutation({
    mutationFn: async () => {
      const { data, error } = await supabase.auth.signInWithPassword({
        email,
        password,
      });

      if (error) alert(error.message);

      if (data) {
        setUserId(data?.user?.id);
      }
    },
  });

  return (
    <Card className="p-5 rounded-xl bg-white shadow-mainShadow">
      <Typography className="text-center text-3xl font-bold font-dpixel">
        로그인
      </Typography>
      <form className="w-80 max-w-screen-lg sm:w-96 flex flex-col gap-4">
        <UserForm
          email={email}
          password={password}
          setEmail={setEmail}
          setPassword={setPassword}
        />
        <Button
          className="bg-main font-dpixel text-white hover:bg-opacity-70"
          fullWidth
          onClick={() => {
            signinMutation.mutate();
          }}
          onLoad={signinMutation.isPending}
          disabled={signinMutation.isPending}
        >
          접속하기
        </Button>
        <Button
          className="bg-yellow-500 font-dpixel text-white hover:bg-opacity-70"
          fullWidth
          onClick={() => signInWithKakao()}
        >
          카카오 로그인
        </Button>
        <Typography
          color="gray"
          className="text-center font-dpixel flex items-center justify-center"
        >
          계정이 없으신가요?{' '}
          <Button onClick={() => setView('SIGNUP')}>
            <span className="font-bold font-dpixel">회원가입</span>
          </Button>
        </Typography>
      </form>
    </Card>
  );
}

components/user-form.tsx

import { TextField, Typography } from '@mui/material';

export default function UserForm({ email, password, setEmail, setPassword }) {
  return (
    <div className="mb-1 flex flex-col gap-6">
      <Typography className="text-lg font-dpixel">이메일</Typography>
      <TextField
        variant="outlined"
        value={email}
        color="secondary"
        onChange={(e) => setEmail(e.target.value)}
        label="아이디@주소"
        className="border-gray-400 active:border-main p-2"
      />
      <Typography className="text-lg font-dpixel">비밀번호</Typography>
      <TextField
        variant="outlined"
        value={password}
        color="secondary"
        onChange={(e) => setPassword(e.target.value)}
        type="password"
        label="********"
        className="border-gray-400 active:border-main p-2"
      />
    </div>
  );
}

config/auth-provider.tsx 추가

Supabase에서 세션 유지를 위한 액세스 토큰을 가지고 있습니다.
액세스 토큰이 만료되거나 유효하지 않으면 세션을 만료시키고 다시 로그인 하도록 만드는거죠.

"use client";

import { usePathname, useRouter } from "next/navigation";
import { useEffect } from "react";
import { createBrowserSupabaseClient } from "utils/supabase/client";

export default function AuthProvider({ accessToken, children }) {
  const supabase = createBrowserSupabaseClient();
  const router = useRouter();

  useEffect(() => {
    const {
      data: { subscription: authListner },
    } = supabase.auth.onAuthStateChange((event, session) => {
      if (session?.access_token !== accessToken) {
        router.refresh();
      }
    });

    return () => {
      authListner.unsubscribe();
    };
  }, [accessToken, supabase, router]);

  return children;
}

app/layout.tsx

export default async function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  const supabase = await createServerSupabaseClient();

  const {
    data: { session },
  } = await supabase.auth.getSession();

  return (
    <html lang="en">
      <head>
        <link
          rel="stylesheet"
          href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.2.1/css/all.min.css"
          integrity="sha512-MV7K8+y+gLIBoVD59lQIYicR65iaqukzvf/nwasF0nqhPay5w/9lJmVM2hMDcnK1OnMGCdVK+iQrJ7lzPJQd1w=="
          crossOrigin="anonymous"
          referrerPolicy="no-referrer"
        />
      </head>
      <body>
        <ReactQueryClientProvider>
          <AuthProvider accessToken={session?.access_token}>
            {session?.user ? (
                {children}
            ) : (
              <Auth />
            )}
          </AuthProvider>
        </ReactQueryClientProvider>
      </body>
    </html>
  );
}

utils/supabase/server.ts, client.ts

Supabase는 브라우저 클라이언트, 서버 클라이언트를 분리해서 사용하도록 함수를 제공합니다.

https://supabase.com/docs/guides/auth/server-side/creating-a-client?queryGroups=environment&environment=server

BrowserClient는 앱 라우터의 클라이언트 컴포넌트에서 사용할 수 있는 함수들을 지원하고, ServerClient는 서버 컴포넌트에서 서버 사이드로 처리해야할 로직들을 사용하는 함수들을 지원합니다. Authentication의 경우 Supabase에 요청할 때 BrowserClient가 필요하고, 인증 코드를 처리하고 토큰과 세션 교환을 위해서 ServerClient도 필요하므로 사용할 수 있도록 utils 디렉토리에 클라이언트 생성 함수를 만들어줍니다.

server.ts

'use server';

import { createServerClient, type CookieOptions } from '@supabase/ssr';
import { cookies } from 'next/headers';
import { Database } from 'types_db';

export const createServerSupabaseClient = async (
  cookieStore: ReturnType<typeof cookies> = cookies(),
  admin: boolean = false
) => {
  return createServerClient<Database>(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    admin
      ? process.env.NEXT_SUPABASE_SERVICE_ROLE!
      : process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
    {
      cookies: {
        get(name: string) {
          return cookieStore.get(name)?.value;
        },
        set(name: string, value: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value, ...options });
          } catch (error) {
            // The `set` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
        remove(name: string, options: CookieOptions) {
          try {
            cookieStore.set({ name, value: '', ...options });
          } catch (error) {
            // The `delete` method was called from a Server Component.
            // This can be ignored if you have middleware refreshing
            // user sessions.
          }
        },
      },
    }
  );
};

export const createServerSupabaseAdminClient = async (
  cookieStore: ReturnType<typeof cookies> = cookies()
) => {
  return createServerSupabaseClient(cookieStore, true);
};

client.ts

'use client';

import { createBrowserClient } from '@supabase/ssr';

export const createBrowserSupabaseClient = () =>
  createBrowserClient(
    process.env.NEXT_PUBLIC_SUPABASE_URL!,
    process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
  );

app/signup/confirm/route.ts 추가

실제로 유저가 회원가입의 인증 과정을 완료하면 리다이렉트 URL의 쿼리스트링으로 담겨있는 code를 서버 사이드에서 추출하여 Supabase에서 제공하는 로그인 세션과 교환하는 로직을 구현해야 합니다.

저는 signUp 함수에 emailRedirectTo: 'http://localhost:3000/signup/confirm'라고 설정을 해뒀기 때문에 이 엔드포인트에 맞춰서 app 디렉토리 안에 route.ts를 생성해줬습니다. 다르게 설정하면 그 엔드포인트에 맞춰주면 됩니다.

import { NextResponse } from 'next/server';
import { createServerSupabaseClient } from 'utils/supabase/server';

export async function GET(request: Request) {
  const requestUrl = new URL(request.url);
  const code = requestUrl.searchParams.get('code');

  if (code) {
    const supabase = await createServerSupabaseClient();
    await supabase.auth.exchangeCodeForSession(code);
  }

  return NextResponse.redirect(requestUrl.origin);
}

이렇게 설정과 프로젝트 내 로직 구현을 마치면 회원가입 인증 성공 시 자동으로 로그인까지 됩니다. Supabase는 한글 번역은 지원하지 않지만 그래도 문서화가 잘 되어있는 편이라 막히는 부분이 있으면 공식 문서와 함께 GPT의 도움을 받으면 해결하기 쉬울 것이라고 생각합니다.

다음 시간에는 Supabase를 카카오 Oauth와 연동한 카카오 소셜 로그인 구현을 어떻게 하는지 알아보겠습니다.

profile
안녕하세요

0개의 댓글