Post

[Next.js 2/6] 로그인/회원가입 폼 만들기 (react-hook-form + zod)

#nextjs #react-hook-form #zod #auth #education

[Next.js 2/6] 로그인/회원가입 폼 만들기 (react-hook-form + zod)

이번 편에서는 실제 교육 현장에서 가장 많이 쓰는 조합인 react-hook-form + zod로 인증 폼을 완성합니다.

학습 목표

  • 폼 상태 관리와 스키마 검증을 분리해서 이해한다.
  • 로그인/회원가입 폼을 복붙 가능한 수준으로 만든다.
  • 백엔드 에러를 사용자 메시지로 표현한다.

1) 패키지 설치

npm i react-hook-form zod @hookform/resolvers

2) 스키마 정의

src/lib/validators/auth.ts

import { z } from 'zod';

export const loginSchema = z.object({
  email: z.string().email('올바른 이메일 형식이 아닙니다.'),
  password: z.string().min(8, '비밀번호는 8자 이상이어야 합니다.')
});

export const signupSchema = loginSchema.extend({
  name: z.string().min(2, '이름은 2자 이상이어야 합니다.')
});

export type LoginForm = z.infer<typeof loginSchema>;
export type SignupForm = z.infer<typeof signupSchema>;

3) 로그인 페이지

src/app/(auth)/login/page.tsx

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { loginSchema, type LoginForm } from '@/lib/validators/auth';

export default function LoginPage() {
  const router = useRouter();
  const [serverError, setServerError] = useState('');

  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<LoginForm>({
    resolver: zodResolver(loginSchema)
  });

  const onSubmit = async (values: LoginForm) => {
    setServerError('');
    const res = await fetch('/api/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values)
    });

    if (!res.ok) {
      setServerError('이메일 또는 비밀번호를 확인해 주세요.');
      return;
    }

    router.push('/todos');
  };

  return (
    <main style=>
      <h1>로그인</h1>
      <form onSubmit={handleSubmit(onSubmit)} style=>
        <input placeholder="email" {...register('email')} />
        {errors.email && <p>{errors.email.message}</p>}

        <input type="password" placeholder="password" {...register('password')} />
        {errors.password && <p>{errors.password.message}</p>}

        {serverError && <p style=>{serverError}</p>}

        <button disabled={isSubmitting}>로그인</button>
      </form>
    </main>
  );
}

4) 회원가입 페이지

src/app/(auth)/signup/page.tsx

'use client';

import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { signupSchema, type SignupForm } from '@/lib/validators/auth';

export default function SignupPage() {
  const router = useRouter();
  const [serverError, setServerError] = useState('');

  const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<SignupForm>({
    resolver: zodResolver(signupSchema)
  });

  const onSubmit = async (values: SignupForm) => {
    setServerError('');
    const res = await fetch('/api/auth/signup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(values)
    });

    if (!res.ok) {
      setServerError('회원가입에 실패했습니다. 이미 사용 중인 이메일일 수 있습니다.');
      return;
    }

    router.push('/login');
  };

  return (
    <main style=>
      <h1>회원가입</h1>
      <form onSubmit={handleSubmit(onSubmit)} style=>
        <input placeholder="name" {...register('name')} />
        {errors.name && <p>{errors.name.message}</p>}

        <input placeholder="email" {...register('email')} />
        {errors.email && <p>{errors.email.message}</p>}

        <input type="password" placeholder="password" {...register('password')} />
        {errors.password && <p>{errors.password.message}</p>}

        {serverError && <p style=>{serverError}</p>}

        <button disabled={isSubmitting}>가입하기</button>
      </form>
    </main>
  );
}

과제

  • 필수: 비밀번호 확인(confirm password) 필드 추가
  • 도전: 폼 에러 메시지 컴포넌트 공통화

댓글