이번 3주차에서는 저번 주차에 진행했던 NextJS TODO 프로젝트에 인증(로그인/회원가입) 기능과 유저 관리에 대한 기능을 추가하였습니다.
회원 가입 및 로그인을 구현하고 유저 별 페이지 접근 및 수정 권한 부여와 유저의 정보를 수정하는 기능을 구현하였습니다.
Supabase Docs
인증 절차는 supabase docs의 Password-based Auth를 따라 구현하였습니다.
async function signUpNewUser() {
const { data, error } = await supabase.auth.signUp({
email: 'example@email.com',
password: 'example-password',
options: {
emailRedirectTo: 'https://example.com/welcome',
},
})
}
email
: 사용자의 이메일password
: 사용자의 비밀번호emailRedirectTo
: 사용자가 이메일 인증(확인)에 대한 이메일의 링크를 클릭했을 때 리다이렉트 되는 경로data
: 유저 정보와 세션 정보를 반환error
: signUp 시 문제가 있을 때 반환하는 오류'use client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
/** 컴포넌트 import (생략) */
...
const formSchema = z.object({
user_name: z.string().min(1, {
message: '1글자 이상의 이름이 필요합니다.',
}),
email: z.string().email({
message: '이메일 형식이 아닙니다.',
}),
password: z.string().min(6, {
message: '6글자 이상의 비밀번호가 필요합니다.',
}),
});
function JoinPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { user_name: '', email: '', password: '' },
});
const { joinUser } = useAuth();
return (
<div className="w-full h-full flex justify-center items-center">
<Form {...form}>
<form
onSubmit={form.handleSubmit(joinUser)}
className="h-fit px-8 py-4 bg-neutral-50 rounded-xl flex flex-col items-center justify-center gap-2 shadow-md"
>
<h3 className="text-2xl font-bold mb-4">회원가입</h3>
<FormField
control={form.control}
name="user_name"
render={({ field }) => (
<FormItem>
<FormLabel>이름</FormLabel>
<FormControl>
<Input placeholder="사용할 이름을 입력하세요." {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="이메일" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="6자 이상의 비밀번호"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full mt-4">
회원가입
</Button>
<Link href="/login" className="text-xs text-gray-500 my-2 underline">
로그인하러 가기
</Link>
</form>
</Form>
</div>
);
}
export default JoinPage;
form은 shadcn이 제공하는 Form 컴포넌트를 사용하였습니다.
const formSchema = z.object({
user_name: z.string().min(1, {
message: '1글자 이상의 이름이 필요합니다.',
}),
email: z.string().email({
message: '이메일 형식이 아닙니다.',
}),
password: z.string().min(6, {
message: '6글자 이상의 비밀번호가 필요합니다.',
}),
});
zod 라이브러리를 이용하여 form의 타입을 지정하고 유효성 검사까지 가능하도록 구현하였습니다.
user_name
: 사용자가 서비스에서 사용할 이름email
: 사용자의 이메일. zod의 email 메서드를 이용하여 이메일 형식을 갖는지 확인합니다.password
: 사용자의 비밀번호. 6자 이상의 필수 조건const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { user_name: '', email: '', password: '' },
});
위의 코드는 앞서 선언한 formSchema의 타입을 이용하여 form을 사용하도록 하는 코드입니다. zodResolver로 formSchema 형태에 맞게 유효성 검사를 할 수 있게 하고 기본 값은 모두 빈 문자열로 지정했습니다.
useForm을 이용하면 form을 제출했을 때 갖는 formData를 다루기 쉬워집니다. form의 이름을 key로 갖고 입력 값을 value로 갖는 객체를 매개변수로 받아옵니다. 이 formData를 이용하여 supabase.auth.signUp을 실행합니다.
'use client';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { zodResolver } from '@hookform/resolvers/zod';
...
const formSchema = z.object({
email: z.string().email({
message: '이메일 형식이 아닙니다.',
}),
password: z.string().min(6, {
message: '6자 이상의 비밀번호를 입력하세요.',
}),
});
function LoginPage() {
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: { email: '', password: '' },
});
const { logInUser, logInWithKakao } = useAuth();
return (
<div className="w-full h-full flex flex-col justify-center items-center">
<div className="w-72 h-fit px-8 py-4 bg-neutral-50 rounded-xl flex flex-col items-center justify-center gap-2 shadow-md">
<Form {...form}>
<form
onSubmit={form.handleSubmit(logInUser)}
className="w-56 flex flex-col items-center justify-center"
>
<h3 className="text-2xl font-bold mb-4">로그인</h3>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="이메일" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="비밀번호" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full mt-4">
로그인
</Button>
<Separator />
</form>
</Form>
<div className="w-56 flex flex-col items-center justify-center">
<button
className="bg-yellow-300 w-full rounded py-2 text-sm"
onClick={logInWithKakao}
>
카카오 로그인하기
</button>
<ResetPassword />
<Link href="/join" className="text-xs text-gray-500 my-2 underline">
계정이 없으신가요? 회원 가입하기
</Link>
</div>
</div>
</div>
);
}
export default LoginPage;
로직은 회원가입과 비슷합니다. zod를 이용하여 formSchema를 지정하고 유효성 검사를 진행 합니다.
async function signInWithEmail() {
const { data, error } = await supabase.auth.signInWithPassword({
email: 'example@email.com',
password: 'example-password',
})
}
email과 password를 기반으로 로그인하는 기능입니다.
data
: 로그인 성공 시 user 데이터와 session 데이터 반환error
: 로그인 실패 시 반환하는 에러사용자가 입력한 이메일이 존재하지 않는 경우와 비밀번호를 잘못 입력한 경우에 대한 에러를 나누어 처리했습니다.
const findUserByEmail = async (email: string) => {
try {
const { data, error } = await adminSupabase.auth.admin.listUsers();
if (data && data.users) {
const user = data.users.find((user) => user.email === email);
return user;
}
if (error) {
return undefined;
}
} catch (error) {
console.error(error);
}
};
Supabase에는 유저가 이메일이나 비밀번호를 잘못 입력하는 경우, invalid_credentials
로 하나로 표현하기 때문에 에러를 나누기 위해서는 별도의 로직이 필요합니다. findUserByEmail
은 authentication.users 테이블의 유저 중 email이 일치하는 유저를 반환하는 함수입니다. 유저가 존재하는 경우, 해당 유저를 반환하고 존재하지 않는 경우에는 undefined를 반환하도록 했습니다. 이 기능은 admin 기능을 필요로 하므로 service role key를 supabase에서 가져와 Supabase client를 생성하고 사용해야 합니다.
Supabase.auth.signInWithPassword
실패 시findUserByEmail
을 이용하여 유저 찾기회원가입과 로그인에 성공하면 여러 컴포넌트에서 유저의 데이터를 사용하기 위해 전역 상태로 관리하거나 스토리지를 사용해야 합니다. Jotai persistence를 이용하여 로컬 스토리지에 유저 데이터(필요한 부분만)를 저장하고 활용하도록 하였습니다. 그리고 미들웨어에서도 로그인 여부를 확인하기 위해 쿠키에 유저 정보를 저장했습니다.
await supabase.auth.resetPasswordForEmail('hello@example.com', {
redirectTo: 'http://example.com/account/update-password',
})
비밀번호는 resetPasswordForEmail
를 이용하여 변경할 수 있습니다. 유저의 이메일로 비밀번호 변경 페이지 링크가 전송이 되고 그 링크를 누르면 redirectTo
에 명시한 경로로 이동하여 비밀번호 변경이 가능합니다.
const { data, error } = await supabase.auth.updateUser({
password: newPassword,
});
유저가 비밀번호를 입력하면 password를 updateUser
를 이용하여 업데이트 하면 됩니다.
const deleteUser = async () => {
if (!userInfo) return;
try {
const { error: todoError } = await supabase
.from('todos')
.delete()
.eq('author_id', userInfo.id);
const { error: userError } = await adminSupabase.auth.admin.deleteUser(
userInfo.id
);
if (todoError) {
toast({
variant: 'destructive',
title: '회원 탈퇴 시 TODO 삭제에 실패했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
if (userError) {
toast({
variant: 'destructive',
title: '회원 탈퇴에 실패했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
toast({
title: '회원 탈퇴 되었습니다.',
});
document.cookie = `user=; path=/; max-age=0;`;
setUserInfo(null);
router.replace('/');
} catch (error) {
console.error(error);
}
};
회원 탈퇴는 deleteUser
를 사용해야 하는데 이 기능도 admin 기능이므로 supabase admin client를 사용합니다. 회원 탈퇴 시, 회원이 작성했던 todo를 삭제해야 하므로 todo 삭제를 먼저 진행하고 탈퇴하도록 했습니다.
const updateUser = async (inputData: { user_name: string }) => {
try {
const { data, error } = await supabase.auth.updateUser({
data: inputData,
});
if (data) {
toast({
title: '프로필이 업데이트 되었습니다.',
});
fetchUser();
}
if (error) {
toast({
title: '프로필 업데이트에 실패했습니다.',
description: '개발자 도구 창을 확인하세요.',
});
}
} catch (error) {
console.error(error);
}
};
updateUser
는 유저로부터 수정할 내용을 입력 받아 supabase.auth.updateUser 메서드에 수정할 값을 전달하면 됩니다.
권한이 없는 유저의 접근을 막기 위해 Protected Route를 구현해야 합니다. 자기가 작성한 todo를 남이 마음대로 수정하거나 삭제하는 일이 없어야 하기 때문입니다. 따라서 권한이 없는 사람이 페이지에 접근하려 하면 다른 곳으로 이동시키는 기능이 필요합니다.
nextjs의 middleware를 이용하면 화면의 깜빡임 없이 유저를 이동시키고 렌더링할 수 있습니다.
middleware는 서버 사이드에서 실행되며 클라이언트에 응답을 돌려주기 전에 실행됩니다. 따라서 화면에 출력 되기 전에 유저를 라우팅 시켜주므로 화면의 깜빡임 없이 화면을 전환할 수 있습니다.
import { type NextRequest, NextResponse } from 'next/server';
export function middleware(request: NextRequest) {
// 쿠키로 로그인 여부 확인
const user = request.cookies.get('user');
// 로그인 상태일 때 메인 페이지나 로그인 페이지에 접근하면 boards 페이지로 리다이렉트
if (
(request.nextUrl.pathname === '/join' && user) ||
(request.nextUrl.pathname === '/login' && user) ||
(request.nextUrl.pathname === '/' && user)
) {
return NextResponse.redirect(new URL('/boards', request.url));
}
// 로그인 상태가 아닐 경우, 콘텐츠 페이지 접근 불가 -> 로그인 페이지 리다이렉트
if (request.nextUrl.pathname.startsWith('/boards') && !user) {
return NextResponse.redirect(new URL('/login', request.url));
}
}
// 미들웨어가 적용될 경로 설정
export const config = {
matcher: ['/', '/join', '/login', '/boards/:path*'],
};
로그인 상태일 때, 메인 페이지나 로그인 페이지에 접근하면 boards 페이지로 리다이렉트 합니다. 그리고 로그인 상태가 아닐 경우, 콘텐츠 페이지에 접근하지 못하게 해야 하므로 로그인 페이지 리다이렉트합니다.
로그인 상태는 cookie에 저장된 정보를 확인합니다.
상태는 페이지를 새로고침 하는 경우 초기화 됩니다. 그러나 새로고침해도 초기화되지 않는 상태를 사용해야하는 경우가 생길 수 있습니다. 이를 위해 Jotai에서는 persistence(영속성)을 가진 상태를 사용할 수 있는 훅을 제공합니다.
Jotai Persistence는 상태를 웹 스토리지에 저장하여 새로고침을 해도 상태가 초기화 되지 않도록 합니다.
jotai/utils
에서 제공하는 atomWithStorage
를 이용하여 상태를 영구적으로 저장할 수 있습니다. 이 프로젝트에서는 유저 정보를 로컬스토리지에 저장하여 로그인 정보를 유지했습니다.
middleware는 요청을 가로채어 응답을 돌려보내기 전에 실행되는 중간 처리기 역할을 합니다. 특정 페이지에 접근하기 전에 인증 여부를 확인하거나, URL을 분석해서 리다이렉션 처리를 할 때 사용할 수 있습니다.
클라이언트 측 코드 실행 없이, 서버에서 요청을 처리하기 때문에 더 안전하고 효율적입니다. 서버 측에서 실행되기 때문에 클라이언트 측의 리소스를 사용하지 않도록 유념해야 합니다.
middleware에서 유저를 리다이렉트 할 때,
NextResponse.redirect(new URL('/boards', request.url));
구문을 사용하였습니다.
new URL('/boards', request.url)
코드가 의미하는 것이 무엇인지, 두 번째 인자가 의미하는 것이 무엇인지 궁금하여 URL 함수에 대해 공부해보았습니다.
URL 함수의 두 번째 인자는 base 주소를 의미합니다. 리다이렉션의 기준 주소가 됩니다.
let b = new URL("https://developer.mozilla.org");
new URL("en-US/docs", b); // => 'https://developer.mozilla.org/en-US/docs'
첫 번째 인자가 상대 경로로 전달되었으므로 base url인 https://developer.mozilla.org
를 기준으로 절대 경로를 생성합니다. 따라서 https://developer.mozilla.org/en-US/docs
가 생성됩니다.
new URL("/en-US/docs", "https://developer.mozilla.org/fr-FR/toto");
// => 'https://developer.mozilla.org/en-US/docs'
첫 번째 인자가 절대 경로로 전달되었으므로 base url이 무시됩니다. 따라서 /fr-Fr/toto
는 무시되고 https://developer.mozilla.org/en-US/docs
가 생성됩니다.
본 후기는 [유데미x스나이퍼팩토리] 프론트엔드 프로젝트 캠프 과정(B-log) 리뷰로 작성 되었습니다.