yup.oneOf() null 이 왜 들어가야 하는가?

Rosevillage·2025년 3월 6일
post-thumbnail

이 글에서 말하는 내용과 테스트 결과는 모두 제어컴포넌트를 기반으로 한다.

react-hook-form + yup을 사용하면서 비밀번호 재검증 form을 구현한 적이 있다면 다음과 같은 코드가 익숙할 것이다.

yup.object({
  password: yup
    .string()
    .min(6, '비밀번호는 최소 6자리 이상이어야 합니다.')
    .required('비밀번호를 입력하세요.'),
  confirmPassword: yup
    .string()
    .oneOf([yup.ref('password'), null], '비밀번호가 일치하지 않습니다.')
    .required('비밀번호를 한번 더 입력하세요.'),
});

"yup 비밀번호 확인" 같은 검색을 해보면 흔하게 보이는 예시 코드이다. 이를 처음 접했거나, 처음 접했을때를 생각해보면 이런 궁금증이 들었을 것이다.

oneOf()에 배열로 값을 넣으면 입력값이랑 같은지 판별하는구나
근데 null은 왜 들어가 있는거지?

위와 같은 궁금증 때문에 왜 넣어야 하는지 찾아보게 되면, null이 confiemPassword값이 입력되지 않았을때 경고를 방지하기 위해 존재한다는 정보를 쉽게 접할 수 있다.

null이 꼭 필요할까?

필자는 위의 정보에 의구심이 들었다. 왜냐하면 required를 통해 이미 빈 값에 대한 검사를 진행하고 있다고 생각했기 때문이다. 빈값을 허용하기 위해라는 말과는 상반되어 보인다.

다음과 같은 환경에서 null이 실제 결과에 미치는 영향을 테스트해보았다.
react + tpyescript + react-hook-form + yup + mui

const schema = yup.object({
  password: yup
    .string()
    .min(6, '비밀번호는 최소 6자리 이상이어야 합니다.')
    .required('비밀번호를 입력하세요.'),
  confirmPassword: yup
    .string()
    .nullable()
    .oneOf([yup.ref('password'), null], '비밀번호가 일치하지 않습니다.')
    .required('비밀번호를 한번 더 입력하세요.'),
});

type FormValues = yup.InferType<typeof schema>;

const SignUpForm = () => {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm<FormValues>({
    mode: 'all',
    resolver: yupResolver(schema),
    defaultValues: {
      password: '',
      confirmPassword: '',
    },
  });

  const onSubmit = (data: SignUpFormInputs) => {
    console.log('회원가입 정보:', data);
  };

  return (
    <Container maxWidth="xs">
      <Typography variant="h5" sx={{ mb: 3, textAlign: 'center' }}>
        회원가입
      </Typography>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="password"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              label="비밀번호"
              type="password"
              fullWidth
              margin="normal"
              error={!!errors.password}
              helperText={errors.password?.message}
            />
          )}
        />
        <Controller
          name="confirmPassword"
          control={control}
          render={({ field }) => (
            <TextField
              {...field}
              label="비밀번호 확인"
              type="password"
              fullWidth
              margin="normal"
              error={!!errors.confirmPassword}
              helperText={errors.confirmPassword?.message}
            />
          )}
        />
        <Button
          type="submit"
          variant="contained"
          color="primary"
          fullWidth
          sx={{ mt: 2 }}
        >
          가입하기
        </Button>
      </form>
    </Container>
  );
};

schema에 추가된 메서드가 있지만 oneOf()에 null을 넣기위한 조치로 보면 된다.

  • null을 포함한 결과는 다음과 같다.
  • null을 포함하지 않은 결과는 다음과 같다. oneOf()에서 null을 빼고, nullable을 제거

두 방식은 다른점이 없이 동작한다. 즉 confirm의 빈 값을 위해 null을 넣으나, 넣지 않느나 유효성 검사의 결과에는 영향을 미치지 않는다는 것이다.

여기서 required를 빼봤자 위의 gif에서 보이는 비밀번호를 한번 더 입력하세요. 만 안나타나는 수준이니 최소한 비밀번호 재입력 폼에서는 oneOf()에 null을 넣을 이유가 없다.

결론

회원가입 form과 같은 input 기반의 재확인 UI(password, confirmPassword) 를 구현할 때, yup.oneOf()에 null을 추가해도 별다른 결과를 얻을 수 없기에 oneOf()에는 일치시켜야 하는 값만 넣는 것을 추천한다.


나름 소스코드도 들여다 봤지만 yup+yupResolver+useForm에 대한 이해가 아직 부족해서, required의 error를 oneOf가 덮어버리는 이유를 알아내진 못했다. 추후 알아내게 되면 내용을 추가해보겠다.


여담

password, confirmPassword 검증 묶기

테스트를 하며 이것저것 만져보다가 꽤 괜찮은 방법을 발견할것 같아서 공유해본다.

혹시 confirmPassword 필드의 유효성 검증이 password 필드의 값에 따라 실시간으로 최신화 되기를 바래서 watch, useEffect를 사용하는 사람이 있다면 이 방식이 도움이 되리라 생각한다.

우선 useForm에서 trigger 함수를 가져와야 한다.

trigger는 인자로 전달된 key(string | string[])를 통해 특정 필드 혹은 폼 전체의 검증을 유발하는 함수이다.

  const {
    control,
    handleSubmit,
    formState: { errors },
    trigger,
  } = useForm<SignUpFormInputs>({
    mode: 'all',
    resolver: yupResolver(schema),
    defaultValues: {
      password: '',
      confirmPassword: '',
    },
  });

이 trigger를 password input의 onChange에서 호출하여, password가 변경될 때 해당값에 의존하는 confirmPassword를 실시간으로 검증할 수 있게 된다.

<Controller
  name="password"
  control={control}
  render={({ field }) => (
    <TextField
      {...field}
      label="비밀번호"
      type="password"
      error={!!errors.password}
      helperText={errors.password?.message}
      onChange={(e) => {
        field.onChange(e);
        trigger('confirmPassword');
      }}
    />
  )}
/>

gif 의 마지막 부분을 보면 password 필드가 빈값이 되자 confirmPassword도 error message가 변하는 것을 볼 수 있다.

0개의 댓글