Remix에서 Supabase Auth 사용하기

2025-03-02에 씀
Remix 시리즈의 다른 글
  1. Remix Session으로 JWT 토큰 관리하기
  2. Remix 서버 코드를 서버답게 관리하기
  3. Remix에서 Supabase Auth 사용하기

간단한 프로젝트라면, Remix와 Supabase를 활용해 지출되는 비용 없이 쉽고 빠르게 구현할 수 있습니다. 특히 복잡하고 어려운 인증을 쉽게 처리할 수 있습니다.

이 글에서는 Remix 프로젝트와 Supabase 프로젝트를 초기화하고, 인증 처리를 구현하는 방법에 대해 다룹니다.

Remix로 프로젝트 시작하기

create-remix CLI를 사용하면 Remix 프로젝트를 바로 구성할 수 있습니다. 아래 명령어를 입력해서 CLI를 실행할 수 있습니다. (공식 문서)

1npx create-remix@latest

실행하면 아래와 같이 프로젝트가 생성됩니다.

참고로 Remix v3은 react-router v7 버전으로 출시되었습니다. 따라서 create-remix CLI로 설치되는 Remix 버전은 v2입니다.

npm run dev 명령어를 실행하면 http://localhost:5173 에서 로컬 웹에 접근할 수 있습니다.

Supabase 연결하기

Supabase는 Firebase와 유사한 Baas(Backend as a Service, 백엔드형 서비스)로, 보통 백엔드에서 제공하는 여러 가지 서비스를 제공합니다. DB 접근, 사용자 인증, 파일 업로드 등 다양한 서비스를 편리하게 사용할 수 있습니다. 또 서버를 따로 두지 않아도 클라이언트에서 직접 Supabase API를 호출할 수 있고, 언어별 SDK도 제공해서 쉽게 연동할 수 있습니다.

어느정도 무료로 사용 가능한 것도 큰 장점인데, 사용자당 프로젝트를 2개까지 무료로 생성할 수 있고, 데이터베이스 사이즈 500MB, 파일 저장소 1GB까지 무료로 제공해서 유저 수가 많지 않은 사이드 프로젝트에서는 웬만하면 무료로 사용할 수 있습니다.

Supabase Database에 접속해서 새로운 프로젝트를 생성합니다. 프로젝트 생성이 완료되면, Table Editor 메뉴로 들어가 필요한 DB Table을 구성해 줍니다.

Supabase Auth를 사용할 예정이라면 사용자 테이블은 따로 만들지 않아도 됩니다. 좌측의 Authentication 메뉴에 들어가 보면 미리 생성된 Users 테이블을 확인할 수 있습니다.

TypeScript 타입 자동 생성하기

Supabase에서는 프로젝트에 포함된 테이블을 타입으로 변환해 주는 CLI를 제공합니다. 아래 명령어를 터미널에 입력하면 프로젝트에 포함된 테이블의 타입이 자동으로 생성됩니다.

1supabase gen types typescript --project-id {프로젝트 ID} > src/원하는/파일위치.ts

생성된 파일은 아래와 같은 모습입니다.

특정 테이블의 타입을 가져오려면 생성된 타입 파일의 Tables 타입을 사용하면 됩니다. 원하는 테이블 명을 넘겨주면 아래와 같은 타입을 얻을 수 있습니다.

1// 자동 생성된 파일에 Tables 타입이 포함되어 있습니다
2import { Tables } from 'src/types/dto';
3
4type Image = Tables<'images'>;
5// ^? {created_at: string, creator_id: string, height: number, id: number, url: string, width: number}

DB 컬럼명을 snake_case로 지었는데 코드상의 프로퍼티명은 camelCase로 사용하고 싶다면, camelize-ts 와 같은 라이브러리를 활용해 볼 수 있습니다. 타입은 Camelize 타입으로 감싸고, 데이터는 camelize 함수를 통해 변환해 주면 됩니다.

1import camelize, { Camelize } from 'camelize-ts';
2import { Tables } from 'src/types/dto';
3
4type Image = Camelize<Tables<'images'>>;
5
6// 대신, Supabase에서 쿼리한 데이터를 camelize 처리해 주어야 합니다
7const { data } = await client.from('images').select('*').eq(...).single()
8// ^? Tables<'images'>
9
10const camelized = camelize(data);
11// ^? Camelize<Tables<'images'>>
12// == Image

그러면 아래와 같이 타입이 추론됩니다.

보통 데이터는 각 라우트의 loader 함수 내에서 쿼리해오기 때문에, loader 함수에서 응답을 반환하는 시점에 camelize 처리를 해주는 편입니다. 그러면 클라이언트 측 코드에서는 camelCase의 타입과 데이터만 사용할 수 있습니다.

1// app/routes/page.tsx
2export const loader = (async ({ request, params }) => {
3 const { client, headers } = createSupabaseClient(request);
4
5 const id = Number(params.id);
6 if (!id || Number.isNaN(id)) {
7 throw new Response('', { status: 404, headers });
8 }
9
10 const { data } = await client
11 .from('yarns')
12 .select('*, yarn_colors ( * )')
13 .eq('id', id)
14 .single();
15
16 if (!data) {
17 throw new Response('', { status: 404, headers });
18 }
19
20 // 응답 직전에, data를 camelize 처리해 줍니다
21 return json(camelize(data), { headers });
22}) satisfies LoaderFunction;

Supabase 클라이언트 생성

이 글에서는 서버 측에서만 Supabase에 접근할 수 있도록 하려고 합니다. Supabase SDK는 브라우저 환경에서 직접 호출해도 안전하지만, DB에 접근하는 주체를 서버로 제한하는 것이 덜 복잡하기도 하고 나중에 DB 접근 방식을 변경하기에도 용이하니 브라우저에서는 Supabase에 접근하지 않도록 하겠습니다. DB에 접근하는 부분을 쉽게 교체할 수 있는 구조로 만들고 싶다면, 이 글을 참고해 보세요.

클라이언트를 생성하기 전에, Supabase 프로젝트에 접근하기 위해 필요한 키를 env 파일에 등록하겠습니다. Supabase 대시보드 > Project Overview 메뉴에서 프로젝트의 URL과 API Key를 확인할 수 있습니다.

이 키는 데이터베이스 각 테이블에 RLS 설정을 활성화해 두었다면 노출되어도 안전한 키이지만, 만일을 위해 가능한 한 숨겨 두는 것이 안전하니 .env 파일에 아래와 같이 설정해 둡니다. 커밋 전, .gitignore 파일에 .env 파일이 설정되어 있는지, 푸시 전에 깃 커밋에 .env 파일이 추가되지 않았는지 확인해야 합니다.

1SUPABASE_URL= # 위의 Project URL 값 입력
2SUPABASE_ANON_KEY= # API Key (Anon Public) 값 입력

Supabase에 접근하기 위한 클라이언트의 인스턴스 객체를 생성하는 함수를 작성하겠습니다. 이 함수는 서버에서만 호출할 것이므로, 서버에서만 접근할 수 있도록 파일 이름이 .server.ts 로 끝나도록 해줍니다.

1// createClient.server.ts
2import {
3 createServerClient,
4 parseCookieHeader,
5 serializeCookieHeader,
6} from '@supabase/ssr';
7import { SupabaseClient } from '@supabase/supabase-js';
8import { Database } from 'src/types/dto';
9
10export const createSupabaseClient = (
11 request: Request,
12): {
13 client: SupabaseClient<Database>;
14 headers: Headers;
15} => {
16 const headers = new Headers();
17
18 const client = createServerClient(
19 process.env.SUPABASE_URL,
20 process.env.SUPABASE_ANON_KEY,
21 // 따로 인증을 사용하지 않을 것이라면, 해당 옵션 객체 및 headers 반환 값은 생략할 수 있습니다.
22 {
23 cookies: {
24 getAll() {
25 return parseCookieHeader(request.headers.get('Cookie') ?? '');
26 },
27 setAll(cookiesToSet) {
28 cookiesToSet.forEach(({ name, value, options }) =>
29 headers.append(
30 'Set-Cookie',
31 serializeCookieHeader(name, value, options),
32 ),
33 );
34 },
35 },
36 },
37 );
38
39 return { client, headers };
40};

인증을 사용하려면 인증을 통해 생성된 키를 브라우저 쿠키에 저장해 주고, 요청에서 쿠키의 인증 정보를 가져올 수 있어야 합니다. 이를 위해 쿠키를 처리하는 코드를 추가했고, Supabase 클라이언트 인스턴스와 함께 헤더 객체를 반환하도록 했습니다.

Supabase로 인증 구현하기

Supabase는 이메일 인증뿐만 아니라 Apple, Google, GitHub, Kakao까지 다양한 Social Auth도 제공하고 있습니다. 각 프로바이더를 연결하는 방법은 공식 문서에 잘 설명되어 있습니다. 이 글에서는 Password-based Auth만을 다룹니다.

회원가입

회원가입을 위한 form이 있는 페이지가 필요합니다. form 데이터의 유효성 검증 및 변환을 위해, zod, remix-hook-form 라이브러리를 추가로 사용했습니다. 코드를 그대로 사용하시려면 아래 커맨드를 실행해 라이브러리를 설치해야 합니다.

1npm install zod @hookform/resolvers react-hook-form remix-hook-form@5.1.1

remix-hook-form 의 경우 v6부터 react-router v7을 peer-dependency로 가지기 때문에 5.1.1 버전을 지정해서 사용해야 합니다.

1// app/routes/signup.tsx
2import { json } from 'react-router';
3import { useActionData } from '@remix-run/react';
4import { ActionFunction, redirect } from '@remix-run/node';
5import { z } from 'zod';
6import { zodResolver } from '@hookform/resolvers/zod';
7import { getValidatedFormData, useRemixForm } from 'remix-hook-form';
8
9import { createSupabaseClient } from 'src/libs/supabase/createClient.server';
10
11const signupFormScheme = z.object({
12 email: z.string().email(),
13 password: z.string().min(8),
14});
15
16type FormData = z.infer<typeof signupFormScheme>;
17const resolver = zodResolver(signupFormScheme);
18
19export const action: ActionFunction = async ({ request }) => {
20 const { client, headers } = createSupabaseClient(request);
21
22 // form data의 유효성을 검증합니다
23 const {
24 errors,
25 data,
26 receivedValues: defaultValues,
27 } = await getValidatedFormData<FormData>(request, resolver);
28
29 // 유효하지 않은 데이터라면 에러를 반환합니다
30 if (errors) {
31 return json({ errors, defaultValues });
32 }
33
34 // 회원가입을 시도합니다
35 const { error, data: signupData } = await client.auth.signUp({
36 email: data.email,
37 password: data.password,
38 options: {
39 emailRedirectTo: '/welcome',
40 },
41 });
42
43 if (error) {
44 return json(
45 { message: '회원가입 실패' },
46 {
47 headers,
48 },
49 );
50 }
51
52 // 회원가입에 성공했다면 /confirm 페이지로 보냅니다
53 // /confirm 페이지에서는 인증을 위해 이메일을 확인해 달라는 메시지를 보여줍니다
54 return redirect('/confirm', {
55 headers,
56 });
57};
58
59export default function SignUpPage() {
60 const data = useActionData();
61 const {
62 handleSubmit,
63 formState: { errors },
64 register,
65 } = useRemixForm<FormData>({ mode: 'onSubmit', resolver });
66
67 return (
68 // submit 액션이 발생하면, form data를 담아 POST 요청을 보내고, action 함수가 실행됩니다
69 <form method={'post'} onSubmit={handleSubmit}>
70 <label>
71 이메일
72 <input type="email" placeholder={'이메일'} {...register('email')} />
73 {errors.email && <p>{errors.email.message}</p>}
74 </label>
75 <label>
76 <input
77 type="password"
78 placeholder={'비밀번호'}
79 {...register('password')}
80 />
81 {errors.password && <p>{errors.password.message}</p>}
82 </label>
83 {data?.message && <p>{data.message}</p>}
84 <button>가입하기</button>
85 </form>
86 );
87}

client.auth.signUp 실행에 성공하면, 입력된 이메일로 인증 메일이 전송됩니다. 이 메일의 템플릿은 Supabase Dashboard > Authentication > Emails 에서 변경할 수 있습니다. 여기에는 이메일 인증을 처리할 URL로 이동되는 링크가 포함되어 있어야 합니다.

기본적으로는 {{ .ConfirmationURL }} 으로 이동하는 링크가 설정되어 있는데, 이는 Supabase에서 제공하는 URL입니다. 이메일 인증을 처리한 다음, signUp 을 호출할 때 options 값으로 넘긴 emailRedirectTo 경로로 이동시킵니다.

또는, 사이트 내부적으로 이메일 인증을 처리하게 할 수 있습니다. 아래와 같은 링크를 넣어 두면,

1{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email&next=/welcome

실제 메일은 http://localhost:5173/auth/confirm?token_hash=123412341234123412341234&type=email&next=/welcome 와 같은 형태의 링크로 변환됩니다. 이때 .SiteURL 변수는 Supabase Dashboard > Authentication > URL Configuration 에서 변경할 수 있습니다.

사이트에서 내부적으로 이메일 인증을 처리하려면, 이를 위한 loader를 추가해야 합니다. app/routes/auth.confirm.tsx 파일을 추가하고, 이 route에서는 따로 페이지를 렌더링하지 않아도 되니 loader 함수만을 내보내게 해줍니다.

1// app/routes/auth.confirm.tsx
2export async function loader({ request }: LoaderFunctionArgs) {
3 const requestUrl = new URL(request.url);
4 const token_hash = requestUrl.searchParams.get('token_hash');
5 const type = requestUrl.searchParams.get('type') as EmailOtpType | null;
6 const next = requestUrl.searchParams.get('next') || '/';
7
8 const { client, headers } = createSupabaseClient(request);
9
10 if (token_hash && type) {
11 // 이메일 인증 처리
12 const { error } = await client.auth.verifyOtp({
13 type,
14 token_hash,
15 });
16
17 // 잘 수행되었다면, emailRedirectTo 로 전달한 경로로 redirect
18 if (!error) {
19 return redirect(next, { headers });
20 }
21 }
22
23 // 인증 중 문제가 발생했다면, 문제 발생을 알리는 에러 페이지로 이동
24 return redirect('/auth/auth-code-error', { headers });
25}

이제 가입을 축하하기 위한 페이지(/welcome)와 인증 에러 페이지(/auth/auth-code-error)를 만들면 회원 가입을 위한 처리는 완료됐습니다.

회원가입을 시도해 보면, 메일함에 아래와 같은 메일을 받게 됩니다.

Confirm your mail 링크로 들어가서 잠시 기다리면, /welcome 페이지로 이동됩니다. Supabase Dashboard > Authentication > Users 테이블을 확인해 보면, 방금 가입한 사용자가 추가되어 있고, 인증도 완료된 상태인 것을 확인할 수 있습니다.

로그인

가입한 유저가 로그인할 수 있는 페이지를 만들어 보겠습니다. routes에 login.tsx 파일을 추가하고, 로그인을 위한 form을 구성합니다. 로그인을 할 때는 client.auth.signInWithPassword 함수를 호출하고, 성공했다면 메인 페이지로 redirect 되도록 합니다.

1// app/routes/login.tsx
2import { json } from 'react-router';
3import { ActionFunction, LoaderFunction, redirect } from '@remix-run/node';
4import { useActionData } from '@remix-run/react';
5import { z } from 'zod';
6import { zodResolver } from '@hookform/resolvers/zod';
7import { getValidatedFormData, useRemixForm } from 'remix-hook-form';
8
9import { createSupabaseClient } from 'src/libs/supabase/createClient.server';
10
11// form 내의 상태를 zod schema로 정의
12const loginFormScheme = z.object({
13 email: z.string().email(),
14 password: z.string().min(8),
15});
16
17type FormData = z.infer<typeof loginFormScheme>;
18const resolver = zodResolver(loginFormScheme);
19
20// form의 POST 요청을 처리하는 부분
21export const action: ActionFunction = async ({ request }) => {
22 const { client, headers } = createSupabaseClient(request);
23
24 const {
25 errors,
26 data,
27 receivedValues: defaultValues,
28 } = await getValidatedFormData<FormData>(request, resolver);
29
30 // form이 유효하지 않은 값을 보낸 경우 처리
31 if (errors) {
32 return json({ errors, defaultValues });
33 }
34
35 // 로그인 시도
36 const { error } = await client.auth.signInWithPassword({
37 email: data.email,
38 password: data.password,
39 });
40
41 if (error) {
42 return json(
43 { message: '로그인 실패' },
44 {
45 headers,
46 },
47 );
48 }
49
50 // 성공했다면, 메인 페이지로 redirect
51 return redirect('/', {
52 // 이 헤더에는 인증 정보에 Cookie에 세팅하는 내용이 추가되어 있음
53 headers,
54 });
55};
56
57export default function LoginPage() {
58 const data = useActionData();
59 const {
60 handleSubmit,
61 formState: { errors },
62 register,
63 } = useRemixForm<FormData>({ mode: 'onSubmit', resolver });
64 return (
65 <form method={'post'} onSubmit={handleSubmit}>
66 <label>
67 이메일
68 <input type="email" placeholder={'이메일'} {...register('email')} />
69 {errors.email && <p>{errors.email.message}</p>}
70 </label>
71 <label>
72 <input
73 type="password"
74 placeholder={'비밀번호'}
75 {...register('password')}
76 />
77 {errors.password && <p>{errors.password.message}</p>}
78 </label>
79 {data?.message && <p>{data.message}</p>}
80 <button>로그인</button>
81 </form>
82 );
83}

RLS - Row-Level Security

Supabase의 각 테이블에는 보안을 위해 Row-Level Security (RLS) 정책이 걸려 있습니다. 즉, DB에 접근할 때, 접근하는 주체의 Role 혹은 특정 컬럼의 데이터를 확인하여 해당 액션을 수행할 수 있는지를 체크합니다.

예를 들어, 아래 정책은 인증되었으며, 수정하려는 데이터의 creator_id와 User의 uid가 일치하는 사용자 에게만 Update 액션을 허용한 것입니다.

보안을 위해서는 각 테이블마다 적절한 RLS 정책을 추가해 주어야 합니다. 쿼리 작성이 어렵다면 Supabase Assistant 기능을 활용해서 쉽게 원하는 정책을 생성할 수 있습니다. (직접 작성하는 것보다 낫습니다)

그렇다면 인증된 유저인지 확인하기 위해서 Supabase Client를 실행할 때 인증 정보가 전달되어야 할 텐데요, 위에서 만들었던 createSupabaseClient 함수를 사용한다면 따로 인증 처리를 해주지 않아도 됩니다.

로그인을 한 사용자라면 브라우저 쿠키에 인증 관련 정보가 저장됩니다. 이 쿠키는 Remix 서버에 요청을 보낼 때마다 헤더에 포함되어 있는데, createSupabaseClient 를 호출할 때 request에 포함된 header 정보에 접근할 수 있도록 해주었기 때문에, Supabase Client가 알아서 헤더에 있는 인증 정보를 사용하게 됩니다.

1export const loader = (async ({ request, params }) => {
2 // 별도의 설정 없이, 아까 만든 Supabase Client 생성 함수를 사용
3 const { client, headers } = createSupabaseClient(request);
4
5 const id = Number(params.id);
6 if (!id || Number.isNaN(id)) {
7 // ! 에러 응답을 줄 때에도 createSupabaseClient 에서 반환되는 headers를 넘겨주어야 함
8 throw new Response('', { status: 404, headers });
9 }
10
11 const { data } = await client
12 .from('needles')
13 .select('*')
14 .eq('id', id)
15 .single();
16
17 if (!data) {
18 throw new Response('', { status: 404, headers });
19 }
20
21 // ! 정상 응답에서도 createSupabaseClient 에서 반환된 headers를 넘겨주어야 함
22 return json(camelize(data), { headers });
23}) satisfies LoaderFunction;

Foreign key 연결을 위한 public.users 테이블

Supabase Auth에서 관리하는 Users 테이블은 auth Schema의 users 테이블에 존재합니다. 물론 이 테이블을 사용해서도 Foreign key를 지정할 수 있지만, 쿼리 시 join이 어렵습니다.

편의를 위해, public 스키마에 users 테이블을 만들고, auth.users 테이블에 새로운 행이 추가될 때마다 public.users 테이블에도 같은 행을 추가하는 함수를 추가했습니다. 이때, public.users 테이블에는 Foreign key 연결을 위해 필요한 데이터인 idemail 값만을 저장하게 합니다.

Supabase Dashboard > SQL Editor 메뉴로 들어가서, 아래의 쿼리를 실행하면 테이블과 함수가 추가됩니다.

1CREATE TABLE USERS (
2 id uuid references auth.users not null primary key,
3 email text
4);
5create or replace function public.handle_new_user()
6returns trigger as $$
7begin
8 insert into public.users (id, email)
9 values (new.id, new.email);
10 return new;
11end;
12$$ language plpgsql security definer;
13
14create trigger on_new_user
15after insert on auth.users for each row
16execute procedure public.handle_new_user ();

Supabase Dashboard > Table Editor > 테이블 > Edit Table > Foreign keys 에서, 아래와 같이 public.users.id 를 연결할 수 있습니다.

로그아웃

로그아웃을 위한 loader 함수를 추가하여 로그아웃을 구현할 수 있습니다. 해당 route에 GET 요청을 보내면 아래 loader가 실행되어 client.auth.signOut 함수를 호출합니다. 헤더에 저장된 인증 정보를 갱신해 주기 위해, 이때도 응답의 headers에 createSupabaseClient 가 반환한 headers 값을 넘겨주어야 합니다.

1// app/routes/logout.tsx
2import { LoaderFunction, redirect } from '@remix-run/node';
3import { createSupabaseClient } from 'src/libs/supabase/createClient.server';
4
5// 별도 페이지 컴포넌트 없이 loader만 존재
6export const loader: LoaderFunction = async ({ request }) => {
7 const { client, headers } = createSupabaseClient(request);
8
9 await client.auth.signOut({ scope: 'local' });
10
11 // 여기서 보내는 header에는 인증 관련 쿠키를 제거하는 내용이 포함되어 있음
12 return redirect('/login', { headers });
13};

로그인 상태에 따른 redirect 처리

보통 로그인 페이지는 로그인을 하지 않은 유저만 접근할 수 있습니다. 또, 마이페이지 등 로그인이 꼭 필요한 페이지는 로그인하지 않은 유저는 접근할 수 없게 해야 합니다.

이와 같은 redirect 처리는 각 route의 loader 함수에서 처리할 수도 있지만, app/root.tsx 의 loader 함수에서 한 번에 처리할 수도 있습니다.

저는 아래와 같이 redirect 처리를 해줬습니다.

1// app/root.tsx
2const guestOnlyRoutePathNames = ['/login', '/signup', '/auth/confirm'];
3
4export const loader: LoaderFunction = async ({ request }) => {
5 const { client, headers } = createSupabaseClient(request);
6
7 const { data } = await client.auth.getSession();
8
9 const path = new URL(request.url).pathname;
10 const isGuestOnlyRoute = guestOnlyRoutePathNames.includes(path);
11
12 // 게스트 유저인데, 허용되지 않은 페이지에 있을 경우
13 if (!data.session && !isGuestOnlyRoute) {
14 throw redirect('/login', { headers });
15 }
16
17 // 로그인 유저인데, 게스트 페이지에 있을 경우
18 if (data.session && isGuestOnlyRoute) {
19 throw redirect('/', { headers });
20 }
21
22 return new Response('', { headers });
23};
Remix 시리즈의 다른 글
  1. Remix Session으로 JWT 토큰 관리하기
  2. Remix 서버 코드를 서버답게 관리하기
  3. Remix에서 Supabase Auth 사용하기
프로필 사진

조예진

이전 포스트
2024 회고