개발/프로젝트

react-hook-form, zod를 활용하여 회원정보 수정 페이지 제작하기

xuwon 2025. 1. 4. 01:17

전에 회원가입, 로그인 페이지를 만들 때는 직접 유효성 검사 코드를 작성했었는데,
이번에는 react-hook-formzod를 활용해서 진행했다.

 

react-hook-form

React에서 폼을 효율적으로 관리하기 위한 라이브러리.

 

1. 폼 상태 관리
- useState 없이 폼 상태를 관리하여 성능 최적화

2. 간편한 유효성 검사
- zod, Yup 등의 스키마를 통해 유효성 검사 가능

3. 최소한의 리렌더링
- 입력 필드마다 독립적으로 상태를 관리하여 불필요한 리렌더링 방지!

4. 타입스크립트 지원

 

react-hook-form 핵심 개념

// register
// 입력 필드를 react-hook-form에 등록!
<input {...register('email', { required: '이메일을 입력해주세요.' })} />

// handleSubmit
// 폼 제출 시 데이터를 검증!
// 제출 시 실행할 콜백 함수 호출
<form onSubmit={handleSubmit(onSubmit)}>

// formState
// 폼 상태와 유효성 검사 에러 관리
const { errors, isSubmitting } = formState;

// reset
// 폼 상태 초기화
reset({ email: '', password: '' });



zod

TypeScript-first 데이터 유효성 검사 및 스키마 선언 라이브러리.

 

1. 스키마 선언
- 다양한 데이터 타입 지원

2. 유효성 검사
- 입력 데이터가 정의한 스키마에 맞는지 검사
- 유효하지 않은 경우 상세한 에러 메시지 제공

3. 타입 추론
- 스키마를 기반으로 TypeScript 타입을 자동 생성 (z.infer)

 


우선 회원정보 수정 페이지에는 비밀번호 변경 폼, 비밀번호를 제외한 유저 정보 변경 폼, 회원 탈퇴 버튼

이렇게 구성이 되어 있다.

 

한 페이지에 작성하면 내용이 많아지기 때문에, 비밀번호 변경 폼 컴포넌트, 유저 정보 변경 폼 컴포넌트로 분리하여 작성하였다.
(스키마, 타입 파일도 함께 분리하였다!)

 


비밀번호 변경 폼 컴포넌트


zod를 사용하여 유효성 검사를 위한 스키마를 먼저 정의해 주었다.

import { z } from 'zod';

export const passwordChangeSchema = z
  .object({
    password: z
      .string()
      .nonempty('새로운 비밀번호를 입력해 주세요.')
      .trim()
      .max(12, '비밀번호는 6자 이상, 12자 이하로 입력 가능합니다.')
      .min(6, '비밀번호는 6자 이상, 12자 이하로 입력 가능합니다.')
      .regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{6,12}$/, {
        message: `비밀번호는 영문, 숫자, 특수문자를 포함하여야 합니다.`,
      }),
    confirmPassword: z.string(),
  })
  .refine(data => data.password === data.confirmPassword, {
    message: '비밀번호가 일치하지 않습니다.',
    path: ['confirmPassword'],
  });

export type PasswordChangeSchema = z.infer<typeof passwordChangeSchema>;

z.object({...})password, confirmPassword 필드를 가진 객체를 선언했다.
이 두가지 필드에 대해 유효성 검사를 하겠다는 의미이다.

password: z
  .string() // 문자열 값이어야 함
  .nonempty('새로운 비밀번호를 입력해 주세요.') // 빈 문자열 X
  .trim() // 입력값 양쪽의 공백 제거
  .max(12, '비밀번호는 6자 이상, 12자 이하로 입력 가능합니다.') // 최대 12자
  .min(6, '비밀번호는 6자 이상, 12자 이하로 입력 가능합니다.') // 최소 6자
  .regex(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{6,12}$/, {
    message: `비밀번호는 영문, 숫자, 특수문자를 포함하여야 합니다.`,
  })

먼저 password 필드 스키마이다.

1. 필수 입력
2. 6자 이상, 12자 이하
3. 영문, 숫자, 특수문자 포함

이에 대해 유효성 검사를 할 예정이다.

 

regex(...) 는 정규식을 사용하여 특정 패턴을 강제하는 것이다.

^(?=.*[A-Za-z])(?=.*\d)(?=.*[$@$!%*#?&])[A-Za-z\d$@$!%*#?&]{6,12}$

// ^: 문자열 시작.
// (?=.*[A-Za-z]): 적어도 하나 이상의 영문자를 포함해야 함.
// (?=.*\d): 적어도 하나 이상의 숫자를 포함해야 함.
// (?=.*[$@$!%*#?&]): 적어도 하나 이상의 특수문자를 포함해야 함.
// [A-Za-z\d$@$!%*#?&]{6,12}: 영문, 숫자, 특수문자로 구성된 6~12자의 문자열.
// $: 문자열 끝.

 

다음으로는 confirmPassword 스키마이다.

confirmPassword: z.string() // 문자열

 

비밀번호 확인 필드는 입력한 비밀번호와 일치하는지만 확인하면 된다.

.refine(data => data.password === data.confirmPassword, {
  message: '비밀번호가 일치하지 않습니다.',
  path: ['confirmPassword'],
});

refine은 커스텀 검증 로직을 추가할 수 있는 메서드이다.
여기서는 passwordconfirmPassword가 일치하는지를 조건으로 추가했다.

여기서 data는 유효성 검사 대상 객체를 의미한다. (password, confirmPassword 필드 포함)

두 필드의 값이 일치하지 않다면 표시할 에러 메시지를 작성해주고, 어느 필드에 에러 메시지를 표시할 것인지 지정하면 된다.
나는 confirmPassword 필드로 지정했다.

 

export type PasswordChangeSchema = z.infer<typeof passwordChangeSchema>;

이건 zod 스키마를 기반으로 TypeScript 타입을 생성해주는 코드다!

type PasswordChangeSchema = {
  password: string;
  confirmPassword: string;
};

요렇게 생성된다.

 

이제 비밀번호 변경 폼 컴포넌트를 보자.

'use client';

import { useForm } from 'react-hook-form';
import { passwordChangeSchema, PasswordChangeSchema } from '../schema/passwordSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import Button from '@/components/common/buttons/Button';
import Input from '@/components/common/inputs/Input';
import { useEffect, useState } from 'react';
import clsx from 'clsx';
import { UserDataType } from '../types';

const PasswordForm = () => {
  const [isPasswordChangeActive, setIsPasswordChangeAcitve] = useState<boolean>(false);

  const [userData, setUserData] = useState<UserDataType>({
    // 임시 데이터
    nickname: 'jjangs',
    password: 'qwer1234',
    age: 123,
    gender: 'male',
    birthday: new Date('1997-12-14'),
  });

  // 확인용
  useEffect(() => {
    console.log(userData);
  }, [userData]);

  // zod
  const {
    register, // 연결하여 유효성 검사 진행
    handleSubmit, // 폼 제출 시 실행
    formState: { errors, isValid },
    reset,
  } = useForm<PasswordChangeSchema>({
    resolver: zodResolver(passwordChangeSchema),
    mode: 'onBlur',
    defaultValues: {
      password: '',
      confirmPassword: '',
    },
  });

  const onSubmit = (data: PasswordChangeSchema) => {
    setUserData(prev => ({
      ...prev,
      ['password']: data.password,
    }));

    reset({ password: '', confirmPassword: '' });
  };

  return (
    <div
      className={clsx(
        'flex flex-col justify-between px-7 py-5 w-96 h-52 rounded-2xl border border-black bg-fadedGreen',
        'transition-all duration-200',
        isPasswordChangeActive && 'h-[350px]',
      )}
    >
      <p className="self-start text-xl">비밀번호 변경</p>
      {isPasswordChangeActive && (
        <div className="flex flex-col gap-6">
          <div className="flex flex-col gap-2">
            <Input
              id="password"
              type="password"
              label="새 비밀번호"
              labelColor="darkerGray"
              variant="eye"
              placeholder="새 비밀번호 입력"
              className="self-start w-80"
              {...register('password')}
            />
            {errors.password && <p className="px-3 text-xs text-red">{errors.password.message}</p>}
          </div>
          <div className="flex flex-col gap-2">
            <Input
              id="confirmpassword"
              type="password"
              variant="eye"
              label="새 비밀번호 확인"
              labelColor="darkerGray"
              placeholder="새 비밀번호 확인"
              className="self-start w-80"
              {...register('confirmPassword')}
            />
            {errors.confirmPassword && (
              <p className="relative px-3 text-xs text-red">{errors.confirmPassword.message}</p>
            )}
          </div>
        </div>
      )}

      <div className="flex gap-2 self-end text-darkerGray">
        {isPasswordChangeActive && (
          <Button
            label="취소"
            size={'sm'}
            type="button"
            onClick={() => {
              reset({ password: '', confirmPassword: '' }); // 상태 초기화
              setIsPasswordChangeAcitve(false); // 상태 업데이트를 비동기로 처리
            }}
            className="bg-lighterGray text-darkerGray"
          />
        )}
        <Button
          label={isPasswordChangeActive ? '저장' : '변경'}
          size={'sm'}
          type="button"
          onClick={() => {
            if (isPasswordChangeActive) {
              handleSubmit(onSubmit)();
              if (isValid) {
                setIsPasswordChangeAcitve(false);
              }
            } else {
              setIsPasswordChangeAcitve(true);
            }
          }}
        />
      </div>
    </div>
  );
};

export default PasswordForm;

 

만들어 놓은 zod 스키마를 사용하기 위해 react-hook-form을 설정하였다.

const {
  register, 
  handleSubmit, 
  formState: { errors, isValid },
  reset,
} = useForm<PasswordChangeSchema>({
  resolver: zodResolver(passwordChangeSchema), // zod 스키마와 연결하여 유효성 검사
  mode: 'onBlur', // focus를 벗어날 때 유효성 검사 진행
  defaultValues: { // 기본 값
    password: '',
    confirmPassword: '',
  },
});

input에 연결할 때는 {...register('password')} 이런 식으로 연결해 주면 된다.

그리고 폼 제출 시 실행되는 콜백 함수도 정의해 주었다.

const onSubmit = (data: PasswordChangeSchema) => {
  setUserData(prev => ({
    ...prev,
    ['password']: data.password,
  }));

  reset({ password: '', confirmPassword: '' });
};

여기서 data는 폼의 데이터이다.

제출된 데이터를 받아서 userData(임시 데이터)의 password로 업데이트 해주면 된다!

그리고 reset으로 폼의 입력값까지 초기화 해주었다.

 

<Button
  label={isPasswordChangeActive ? '저장' : '변경'}
  size={'sm'}
  type="button"
  onClick={() => {
    if (isPasswordChangeActive) {
      handleSubmit(onSubmit)();
      if (isValid) {
        setIsPasswordChangeAcitve(false);
      }
    } else {
      setIsPasswordChangeAcitve(true);
    }
  }}
/>

이 버튼은 변경 버튼과 저장 버튼을 동시에 수행하고 있다.
(form 태그를 사용하지 않은 이유는 form 태그 사용 시 안에 있는 button 태그는 자동으로 typesubmit이 되기 때문이다.)

그래서 지금이 변경 상태인지, 아닌지 (isPasswordChangeActive)를 확인해서 제출을 제어해 주었다.
isValid는 유효성 검사가 완료되었는지를 확인해주는 값이다.

 

이렇게 하면 비밀번호 변경 폼 구현 끝!


유저 정보 변경 폼 컴포넌트


이것도 먼저 zod 스키마를 작성해 주었다.

닉네임에 관해서만 유효성 검사를 해주면 되기 때문에, 비교적 간단한 스키마이다.

import { z } from 'zod';

export const nicknameChangeSchema = z.object({
  nickname: z.string().min(3, '닉네임은 3글자 이상, 12글자 이하로 입력 가능합니다.'),
});

export type NicknameChangeSchema = z.infer<typeof nicknameChangeSchema>;

 

하지만 유효성 검사가 필요한 필드가 있고, 아닌 필드가 있고...
또 어떤 값은 변경이 될 수도 있고 안 될 수도 있고...

등...

내 기준에서 조금 까다로웠다. ㅜㅜ

'use client';

import { useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { nicknameChangeSchema, NicknameChangeSchema } from '../schema/nicknameSchema';
import { zodResolver } from '@hookform/resolvers/zod';
import dayjs from 'dayjs';
import Select from '@/components/common/select/Select';
import Button from '@/components/common/buttons/Button';
import Input from '@/components/common/inputs/Input';
import { UserDataType } from '../types';

const UserDataForm = () => {
  const [userData, setUserData] = useState<UserDataType>({
    // 임시 데이터
    nickname: 'jjangs',
    password: 'qwer1234',
    age: 123,
    gender: 'male',
    birthday: new Date('1997-12-14'),
  });

  // 확인용
  useEffect(() => {
    console.log(userData);
  }, [userData]);

  const genderOptions: { value: 'female' | 'male' | 'none'; label: string }[] = [
    { value: 'female', label: '여성' },
    { value: 'male', label: '남성' },
    { value: 'none', label: '선택안함' },
  ];

  const [newGender, setNewGender] = useState(userData.gender); // 초기값 설정

  const handleGenderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setNewGender(event.target.value as 'female' | 'male' | 'none'); // 선택된 값 업데이트
  };

  const [newBirthday, setNewBirthday] = useState<Date | null>(null); // 새로운 생년월일 관리
  const [isUserDataChangeActive, setIsUserDataChangeActive] = useState<boolean>(false);

  const {
    register, // 연결하여 유효성 검사 진행
    formState: { errors },
    getValues,
    handleSubmit,
    reset,
  } = useForm<NicknameChangeSchema>({
    resolver: zodResolver(nicknameChangeSchema),
    mode: 'onBlur',
    defaultValues: {
      nickname: userData.nickname, // 초기값 설정
    },
  });

  const handleSave = (data: NicknameChangeSchema) => {
    // 모든 데이터를 한 번에 업데이트
    const updatedData: UserDataType = {
      ...userData,
      nickname: data.nickname, // 닉네임은 유효성 검사를 통과한 데이터로 업데이트
      gender: newGender, // 상태로 관리 중인 성별
      birthday: newBirthday || userData.birthday, // 상태로 관리 중인 생년월일
    };

    setUserData(updatedData);
    setIsUserDataChangeActive(false);

    // React Hook Form 필드 초기화
    reset({ nickname: updatedData.nickname });
  };

  const handleSaveWithoutNickname = () => {
    // 닉네임 변경 없이 다른 데이터만 업데이트
    const updatedData: UserDataType = {
      ...userData,
      gender: newGender,
      birthday: newBirthday || userData.birthday,
    };

    setUserData(updatedData);
    setIsUserDataChangeActive(false);
  };

  const divStyle = `flex flex-col gap-3`;

  return (
    <div className="flex flex-col justify-between px-7 py-5 w-96 h-96 rounded-2xl border border-black bg-fadedGreen">
      <div className={divStyle}>
        <p className="text-xl">닉네임</p>
        {isUserDataChangeActive ? (
          <Input
            id="nickname"
            variant="default"
            className="self-start w-80"
            placeholder={userData.nickname}
            {...register('nickname')}
          />
        ) : (
          <p className="text-darkerGray">{userData.nickname}</p>
        )}
        {isUserDataChangeActive && errors.nickname && (
          <p className="px-3 pb-3 text-xs text-red">{errors.nickname.message}</p>
        )}
      </div>
      <div className={divStyle}>
        <p className="text-xl">생년월일</p>
        {isUserDataChangeActive ? (
          <Select
            setSelectedDate={setNewBirthday}
            optional={false}
            defaultDate={userData.birthday}
          />
        ) : (
          <p className="text-darkerGray">{dayjs(userData.birthday).format('YYYY-MM-DD')}</p>
        )}
      </div>

      <div className={divStyle}>
        <p className="text-xl">성별</p>
        {isUserDataChangeActive ? (
          <div className="flex gap-7 px-3">
            {genderOptions.map(genderOption => {
              return (
                <label
                  key={genderOption.value}
                  className="flex items-center space-x-2 pb-5 accent-darkGray"
                >
                  <input
                    type="radio"
                    name="gender"
                    value={genderOption.value}
                    className="w-3"
                    onChange={handleGenderChange}
                    checked={newGender === genderOption.value} // 기본값 설정
                  />
                  <span className="h-4 text-sm text-darkGray align-middle">
                    {genderOption.label}
                  </span>
                </label>
              );
            })}
          </div>
        ) : (
          <p className="text-darkerGray">
            {userData.gender == 'male'
              ? '남성'
              : userData.gender == 'female'
              ? '여성'
              : '선택 안 함'}
          </p>
        )}
      </div>

      <div className="flex gap-2 self-end text-darkerGray">
        {isUserDataChangeActive ? (
          <Button
            label="취소"
            type="button"
            onClick={() => {
              setIsUserDataChangeActive(false); // 수정 취소
              reset({
                nickname: userData.nickname, // 초기값 원래대로
              });
            }}
            className="bg-lighterGray text-darkerGray"
          />
        ) : null}
        <Button
          label={isUserDataChangeActive ? '저장' : '변경'}
          type="button"
          onClick={() => {
            if (isUserDataChangeActive) {
              const newNickname = getValues('nickname');
              if (newNickname !== userData.nickname) {
                // 닉네임 변경 시
                handleSubmit(handleSave)();
              } else {
                // 닉네임 변경 없이 다른 데이터만 업데이트
                handleSaveWithoutNickname();
              }
            } else {
              setIsUserDataChangeActive(true);
            }
          }}
        />
      </div>
    </div>
  );
};

export default UserDataForm;


기본적으로 상태 관리

const [isUserDataChangeActive, setIsUserDataChangeActive] = useState<boolean>(false);
const [newBirthday, setNewBirthday] = useState<Date | null>(null);
const [newGender, setNewGender] = useState(userData.gender);

닉네임은 react-hook-formgetValues로 받아오면 되기 때문에 따로 상태로 관리하지 않는다.

 

newGender에서 좀 헷갈렸다.

데이터에는 'male', 'female', 'none'으로 받아오는데,
보여주는 건 '남성', '여성', '선택 안 함'으로 보여줘야 했기 때문에 ㅠㅠ

처음에는 이것도 다 삼항연산자 써서 해결했는데, gpt가 새로운 걸 알려줬다.

const genderOptions: { value: 'female' | 'male' | 'none'; label: string }[] = [
  { value: 'female', label: '여성' },
  { value: 'male', label: '남성' },
  { value: 'none', label: '선택안함' },
];

const handleGenderChange = (event: React.ChangeEvent<HTMLInputElement>) => {
  setNewGender(event.target.value as 'female' | 'male' | 'none'); // 선택된 값 업데이트
};

이렇게 value와 그에 따른 label을 만들었다.
그래서 data에는 value를 넣되, 화면에 렌더링 할 땐 label을 사용하여 넣으면 되니 편했다.

 

그리고 이게 최선이 맞는지는 모르겠으나... 나는 닉네임을 저장하는 핸들러 함수와 그 외 정보를 저장하는 핸들러 함수를 분리했다.

const handleSave = (data: NicknameChangeSchema) => {
  const updatedData: UserDataType = {
    ...userData,
    nickname: data.nickname, // 닉네임 업데이트
    gender: newGender,       // 성별 업데이트
    birthday: newBirthday || userData.birthday, // 생년월일 업데이트
  };

  setUserData(updatedData); // 사용자 데이터 업데이트
  setIsUserDataChangeActive(false); // 수정 모드 종료
  reset({ nickname: updatedData.nickname }); // 입력값 초기화
};

그리고 UserDataTypetype으로 가진 updatedData을 만들어서, 나머지 데이터는 그대로 두고 바뀐 부분들만 교체하는 식으로 작성했다.

닉네임을 save한다는 건 닉네임이 바뀌었다는 이야기이므로, data.nickname을 넣어주었고,
gender의 경우 radio 버튼 그룹에서 체크하는대로 newGender가 업데이트 되고, 기본값은 userData.gender이므로 그대로 업데이트.
birthday의 경우 newBirthday가 있다면 업데이트를 하고, 아니면 기존에 받아온 데이터의 birthday로.

그리고 닉네임 입력값을 초기화 해주면 된다.

 

const handleSaveWithoutNickname = () => {
  const updatedData: UserDataType = {
    ...userData,
    gender: newGender,
    birthday: newBirthday || userData.birthday,
  };

  setUserData(updatedData); // 사용자 데이터 업데이트
  setIsUserDataChangeActive(false); // 수정 모드 종료
};

닉네임 변경이 없을 경우의 핸들러 함수이다.

나머지 로직은 똑같다.

 

<Button
  label={isUserDataChangeActive ? '저장' : '변경'}
  type="button"
  onClick={() => {
    if (isUserDataChangeActive) {
      const newNickname = getValues('nickname');
      if (newNickname !== userData.nickname) {
        // 닉네임 변경 시
        handleSubmit(handleSave)();
      } else {
        // 닉네임 변경 없이 다른 데이터만 업데이트
        handleSaveWithoutNickname();
      }
    } else {
      setIsUserDataChangeActive(true);
    }
  }}
/>

getValuesnickname 필드에 입력되는 값을 관리한 이유는, 핸들러 함수에 스키마 타입만 넣을 수가 없어서 그랬다...
한꺼번에 정보를 업데이트 해야 해서 ㅠㅠ

그래서 newNickname으로 nickname 필드에 입력되는 값을 받아와서, 이 값이 있을 경우에 닉네임 변경 핸들러 함수를,
이 값이 기존 유저의 nickname과 다르다면? 닉네임 제외한 정보 변경 핸들러 함수를 호출하였다.
(기본값이 userData.nickname으로 되어있기 때문)


이렇게 해주면 회원정보 수정 폼 페이지 완성~

회원 탈퇴 버튼은 레이어팝업 띄우는 거라 생략