회원가입의 흐름을 생각해보기: 입력값의 유효성 검사 (Validation)

앞서, 회원가입의 흐름의 1, 2에 해당하는 페이지를 만들었습니다. 그런대로 모양은 괜찮은 것 같습니다. 그런데 이런 의문이 듭니다. 비밀번호를 6글자 이상 입력하게 하려면 어떻게 해야 할까? 이메일을 2@c 라고만 입력해도 브라우저는 경고를 내뱉지 않았는데 괜찮은 걸까? 닉네임을 5글자 이상으로 강제할 수 있을까?

상상할 수 있는 모든 경우를 다 스스로 상상해서, 그 모든 경우에 대한 로직을 혼자 구현하기는 너무나도 힘이 듭니다. 할 수 없다는 건 아니지만, 굳이 그럴 필요가 있을까요? 지금까지 회원가입을 구현해왔던 모든 사람들이 다 그런 식으로 구현을 해왔던 것은 아닐 것이고, 뭔가 더 편한 방법이 있을 것 같다는 생각이 듭니다.

여기서는 먼저 라이브러리를 사용하지 않았을 때의 Validation을 살짝만 살펴본 후, Zod 라이브러리를 사용하여 이 문제를 편하게 해결해보고자 합니다.

라이브러리 없이 구현해보기

이를테면 닉네임과 이메일, 패스워드를 입력받으면서 라이브러리 없이 if문만을 이용해 유효성 검사를 수행하는 경우를 예로 들어보겠습니다.

// app/register/page.tsx
import React, { useState } from "react";
import { useRouter } from "next/navigation";

const RegisterPage = () => {
  const [username, setUsername] = useState("");
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [errors, setErrors] = useState({});
  const router = useRouter();

  const handleSubmit = async (e) => {
    e.preventDefault();
    // 입력을 마치고 전송 버튼을 눌렀을 때 작동하는 회원가입 함수에서 유효성을 확인합니다
    // 유효성 규칙을 아래에 일일이 정의하고, 이 규칙에 맞지 않을 경우 상황에 맞게 작성한 errors를 추가합니다
    const validationErrors = {}; // 변수 초기화

    if (!username.trim()) {
      validationErrors.username = "유저명은 필수 항목입니다. ";
    } else if (username.length < 3) {
      validationErrors.username = "유저명은 3글자 이상이어야 합니다.";
    }

    if (!email.trim()) {
      validationErrors.email = "이메일은 필수 항목입니다.";
    } else if (!/\S+@\S+\.\S+/.test(email)) { // 정규식을 이용하여 이메일 형식인지 아닌지를 판단합니다
      validationErrors.email = "잘못된 이메일 형식입니다.";
    }

    if (!password.trim()) {
      validationErrors.password = "비밀번호는 필수 항목입니다.";
    } else if (password.length < 6) {
      validationErrors.password = "비밀번호는 6글자 이상이어야 합니다.";
    }

    if (Object.keys(validationErrors).length === 0) {
      // 만약 위에서 error가 0개였다면, 회원 등록을 진행합니다. 
      
      // >>여기에 회원가입용 함수<<< 
      
    } else { // 에러가 존재한다면, 회원가입을 하는 대신 errors에 관련 에러 안내들을 추가해서 보여줄 수 있도록 합니다. 
      setErrors(validationErrors); 
    }
  };

  return (
    <div>
      <h1>Register</h1>
      <form onSubmit={handleSubmit}>
        <div>
          <label htmlFor="username">Username:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={(e) => setUsername(e.target.value)}
          />
          {errors.username && <span className="text-red-500">{errors.username}</span>}
          // 에러가 유저네임에 대해 존재한다면, 빨간색으로 해당하는 에러 메시지를 표시해줍니다. 
        </div>
        //... 이하 이메일과 패스워드에 대해서 동일한 방식으로 작성
        <button type="submit">가입하기</button>
      </form>
    </div>
  );
};

export default RegisterPage;

가능한 한 간단한 형태로 적었지만, 그럼에도 불구하고 벌써 너무나도 번잡하다는 사실을 쉽게 알 수 있습니다. 위의 경우 글자수 체크와 이메일 형식 정도만 규칙으로 넣었지만, 규칙이 많아진다면 어떨까요? 비밀번호에 특수문자가 들어가야 한다거나, 특정 도메인의 이메일만 허용 혹은 거부한다거나, 규칙이 늘어날수록 더더욱 이 코드를 일일이 작성하는 건 힘들어지겠죠. 미처 생각하지 못한 허점을 노려 악용하는 경우도 분명 생길 것이고, 위의 경우 submit 버튼을 눌렀을 때 비로소 체크를 할 수 있다는 것도 썩 매력적이지는 않습니다. TypeScript의 타입 시스템을 가지고 유효성 검사를 하는 방법도 있지만, 어쨌거나 본질적인 문제가 해결되지는 않습니다.

이런 경우를 위해 유효성 검사 전용 라이브러리를 사용합니다. 라이브러리 종류로는 io-ts, class-validator, joi, yup 등이 존재하지만, 여기서는 Zod를 사용하는 경우만을 다뤄보겠습니다.

Zod란

Zod는 강력하고 유연한 유효성 검사 (Validation) 전용 라이브러리입니다. TypeScript와 JavaScript 모두에서 사용 가능합니다. Zod를 사용하면 수동으로 유효성 검사 코드를 작성하는 대신 선언적이고 재사용 가능한 방식으로 데이터 검증을 처리할 수 있고, 에러 내용 표시 등 많은 부분에서 들여야 하는 품이 적어집니다.

npm install zod
import { z } from "zod";

Zod를 통한 회원가입 입력 내용의 Validation

그럼 Zod를 통한 Validation이 어떤 식으로 가능할지 한 번 구현해보도록 하겠습니다. 이번엔,

  1. 확인용 패스워드를 입력받아서 입력받은 패스워드가 서로 일치하는지 확인하고,
  2. 생일을 확인해 18세 미만의 미성년자가 가입할 수 없도록 하는 규칙을 추가할 것입니다.
  3. 또한 이메일을 확인해서, 만약 이메일이 hanmail.net 으로 끝난다면 가입을 거부하는 규칙을 추가해보겠습니다.
  4. 마지막으로, submit을 하고 나서 비로소 알려주는 게 아니라 입력과 동시에 유효성 검사를 하고, 에러를 띄우도록 해보겠습니다.

Zod의 사용 예

import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { z } from "zod"; // zod를 임포트

// zod용 스키마를 작성. 비교적 직관적으로 읽기 쉽게 되어 있음을 알 수 있다 
const schema = z.object({
  username: z.string().min(3, { message: "3글자 이상!" }).max(10, { message: "10글자 이하!" }),
  email: z.string().email({ message: "이메일 형식 X" }).refine((email) => {
    return !email.endsWith("@hanmail.net"), { message: "hanmail.net 은 거부" }
  }),
  password: z.string().min(6, { message: "6글자 이상!" }),
  confirmPassword: z.string(),
  birthdate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, { message: "잘못된 날짜 형식!" }),
}).refine((data) => data.password === data.confirmPassword, {
  message: "패스워드 일치하지 않음!",
  path: ["confirmPassword"],
}).refine((data) => {
  const today = new Date();
  const birthdate = new Date(data.birthdate);
  const age = today.getFullYear() - birthdate.getFullYear();
  return age >= 18;
}, {
  message: "18세 이상이어야 함!",
  path: ["birthdate"],
});

const RegisterPage = () => {
  const [formData, setFormData] = useState({
    username: "",
    email: "",
    password: "",
    confirmPassword: "",
    birthdate: "",
  });
  const [errors, setErrors] = useState({}); // 에러들을 담기 위한 변수
  const [isValid, setIsValid] = useState(false); // Validation이 실패하면 Submit 버튼을 못 누르도록 하기 위한 변수
  const router = useRouter();

  useEffect(() => {
    const validateForm = async () => {
      try {
        await schema.parseAsync(formData);
        setIsValid(true);
      } catch (error) {
        setIsValid(false);
      }
    };

    validateForm();
  }, [formData]); // Validation이 실패하면 isValid 변수를 false로 바꿈

  const handleChange = (e) => { 
    // 폼에 입력이 주어진 순간 유효성 검사를 하고 에러가 있을 경우 메시지를 저장함 
    const { name, value } = e.target;
    setFormData((prevData) => ({
      ...prevData,
      [name]: value,
    }));

    try {
      schema.parse({ ...formData, [name]: value });
      setErrors((prevErrors) => ({
        ...prevErrors,
        [name]: undefined,
      }));
    } catch (error) {
      if (error instanceof z.ZodError) {
        const validationErrors = error.errors.reduce((acc, err) => {
          acc[err.path[0]] = err.message;
          return acc;
        }, {});
        setErrors((prevErrors) => ({
          ...prevErrors,
          ...validationErrors,
        }));
      }
    }
  };

  const handleSubmit = async (e) => {
    e.preventDefault();

    try {
      schema.parse(formData);
      // 이하 회원가입 처리 

    } catch (error) { // Validation이 실패할 경우
      if (error instanceof z.ZodError) {
        const validationErrors = error.errors.reduce((acc, err) => {
          acc[err.path[0]] = err.message;
          return acc;
        }, {});
        setErrors(validationErrors);
      }
    }
  };


  return (
    <div>
      <h1>Register</h1>
      <form onSubmit={handleSubmit}>
         // 위와 똑같으니 생략
        <button type="submit" disabled={!isValid}> // Validation이 실패할 경우 disabled 상태. 클릭 불가능. 
          가입하기
        </button>
      </form>
    </div>
  );
};

export default RegisterPage;

각각의 부분을 자세히 살펴보겠습니다.

  1. Zod 스키마:
    • Zod를 사용하여 회원가입 폼의 유효성 검사 규칙을 정의합니다.
    • 사용자 이름(username), 이메일(email), 비밀번호(password), 비밀번호 확인(confirmPassword), 생년월일(birthdate) 필드에 대한 규칙을 설정합니다.
    • 비밀번호와 비밀번호 확인이 일치하는지 검사하고, 생년월일을 기준으로 18세 이상인지 확인합니다.
  2. RegisterPage 컴포넌트:
    • 상태(state)로 formData (이름, 이메일 등), errors, isValid (제출 버튼 비활성화용) 를 관리합니다.
  3. useEffect 훅:
    • formData의 변경사항을 감지하여 폼의 유효성을 검사합니다.
    • Zod 스키마를 사용하여 formData를 검증하고, 유효성 검사 결과에 따라 isValid 상태를 업데이트합니다.
  4. handleChange 함수:
    • 폼 입력 값의 변경 이벤트를 처리합니다.
    • 변경된 입력 값을 formData 상태에 업데이트합니다.
    • 변경된 입력 값을 기준으로 Zod 스키마를 사용하여 유효성을 검사하고, 에러 메시지를 errors 상태에 저장합니다.
  5. handleSubmit 함수:
    • 폼 제출 이벤트를 처리합니다.
    • Zod 스키마를 사용하여 전체 폼 데이터의 유효성을 검사합니다.
    • 유효성 검사에 실패할 경우 에러 메시지를 errors 상태에 저장합니다.
    • 유효성 검사에 통과할 경우 회원가입 처리를 진행합니다.
  6. JSX:
    • 회원가입 폼을 렌더링합니다.
    • 각 입력 필드에 대해 handleChange 함수를 호출하여 입력 값의 변경을 감지합니다.
    • 각 입력 필드 아래에 해당 필드의 에러 메시지를 표시합니다.
    • 제출 버튼은 isValid 상태에 따라 비활성화됩니다. 유효성 검사에 실패할 경우 버튼이 비활성화되어 클릭할 수 없습니다.

zod를 이용하면 위와 같이 미리 준비되어 있는 각종 유효성 검사 틀들을 사용할 수 있으며, refine() 을 이용하여 나이를 검사하는 등 자신만의 유효성 검사 로직을 삽입할 수도 있어 편리합니다.

입력 칸 (Input) 을 옮길 때 유효성 검사가 실행되는 예 (onBlur)

위의 경우, onChange 를 트리거로 하여 유효성 검사를 하고 있습니다. 따라서 사용자는 칸에 커서를 놓고 타이핑을 시작하자마자 자신에게 띄워지는 경고문을 보게 됩니다.

그런데 타이핑을 하는 중이 아니라, 타이핑이 끝나고 다음 칸으로 넘어갈 때 경고문이 띄워지게 된다면 사용자 경험 측면에서 조금 더 낫지 않을까요?

수정된 전체 코드는 다음과 같습니다. 이 예제의 경우 한 칸을 옮겨갈 때 (특정 칸이 focus를 잃을 때 = onBlur) 마다 항목 이름과 함께 빨간 글씨로 경고문을 출력하게 됩니다.

"use client";

import React, { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { z, ZodError } from "zod";
import Link from "next/link";
import { signIn } from "next-auth/react";

const schema = {
  username: z.string().trim().min(3, { message: "닉네임은 3글자 이상이어야 합니다." }).max(10, { message: "닉네임은 10글자 이하여야 합니다." }),
  email: z.string().trim().email({ message: "올바른 이메일이 아닙니다." }).refine(
    (email) => !email.endsWith("@hanmail.net"),
    { message: "Hanmail은 허용되지 않습니다." }
  ),
  password: z.string().trim().min(6, { message: "비밀번호는 6자 이상이어야 합니다." }),
  confirmPassword: z.string(),
  birthdate: z.string().trim().refine((value) => {
    const today = new Date();
    const birthdate = new Date(value);
    const age = today.getFullYear() - birthdate.getFullYear();
    return age >= 18;
  }, { message: "18세 미만은 가입하실 수 없습니다." }),
};

type FormData = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  birthdate: string;
};

type Errors = {
  username: string;
  email: string;
  password: string;
  confirmPassword: string;
  birthdate: string;
};

const RegisterPage: React.FC = () => {
  const [formData, setFormData] = useState<FormData>({
    username: "",
    email: "",
    password: "",
    confirmPassword: "",
    birthdate: "",
  });
  const [errors, setErrors] = useState<Errors>({
    username: "",
    email: "",
    password: "",
    confirmPassword: "",
    birthdate: "",
  });
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
  const router = useRouter();

  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const { name, value } = e.target;
    setFormData((prevData) => ({ ...prevData, [name]: value }));
  };

  const handleBlur = async (e: React.FocusEvent<HTMLInputElement>) => {
    const { name, value } = e.target;

    try {
      await schema[name as keyof typeof schema].parseAsync(value);
      setErrors((prevErrors) => ({ ...prevErrors, [name]: "" }));
    } catch (error: any) {
      if (error instanceof ZodError) {
        setErrors((prevErrors) => ({
          ...prevErrors,
          [name]: error.errors[0].message,
        }));
      }
    }

    if (name === "confirmPassword") {
      if (value !== formData.password) {
        setErrors((prevErrors) => ({
          ...prevErrors,
          confirmPassword: "비밀번호가 일치하지 않습니다.",
        }));
      } else {
        setErrors((prevErrors) => ({ ...prevErrors, confirmPassword: "" }));
      }
    }
  };

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    try {
      setIsSubmitting(true);

      const validationResults = await Promise.all(
        Object.entries(formData).map(async ([name, value]) => {
          try {
            await schema[name as keyof typeof schema].parseAsync(value);
            return null;
          } catch (error) {
            if (error instanceof ZodError) {
              return error.errors[0].message;
            }
          }
        })
      );

      const validationErrors = validationResults.reduce((acc, error, index) => {
        if (error) {
          const name = Object.keys(formData)[index];
          acc[name as keyof Errors] = error;
        }
        return acc;
      }, {} as Errors);

      if (Object.keys(validationErrors).length > 0) {
        setErrors(validationErrors);
        return;
      }

      // 이하 회원가입 로직이 들어가는 부분


    } catch (error) {
      console.error("회원 등록에 실패했습니다.", error);
    } finally {
      setIsSubmitting(false);
    }
  };

  const isValid = Object.values(errors).every((error) => error === "");

  return (
    <div className="flex flex-col *:font-medium justify-between min-h-screen px-5 p-6 gap-10">
      <div className="mt-48 flex flex-col items-left py-8">
      <h1 className="text-4xl pb-10">가입하기</h1>
      <form onSubmit={handleSubmit} className="flex flex-col gap-3">
        <span className="text-white -mt-1 -mb-3">
          닉네임 {errors.username && errors.username !== "" && (<span className="text-red-500">{errors.username}</span>)}
        </span>
        <div>
          <input
            type="text"
            id="username"
            name="username"
            value={formData.username}
            onChange={handleChange}
            onBlur={handleBlur}
            className="py-0.5 w-full bg-black-100 border-2 border-slate-100 rounded-md text-black"
          />
        </div>

        // 이메일, 비밀번호, 비밀번호 확인, 생일을 입력받는 부분들... 

        <button className="rounded-md bg-slate-100 text-black mt-3 px-2 py-1 disabled:transition-colors disabled:text-slate-100 disabled:bg-gray-500" type="submit" disabled={!isValid || isSubmitting}>
          {isSubmitting ? "처리중..." : "가입하기"}
        </button>
        <div>
          <span className="text-white py-2">이미 계정을 만드셨나요? <Link className="underline" href="/login">로그인</Link></span>
        </div>
      </form>
      </div>
    </div>
  );
};

export default RegisterPage;

이전 코드와 비교했을 때 달라진 부분은 크게 세 가지입니다.

  1. 기존에는 zod 스키마 오브젝트를 통째로 받아서 refine()을 거친 후 한번에 Validation을 진행했습니다. 이는 서로 다른 두 항목의 일치를 확인하기 위함 (비밀번호 일치) 이었는데요, 이렇게 되면 이메일과 이름, 비밀번호 등을 별개로 나눠서 유효성 검사를 진행하기가 힘들었습니다. 이름만 검사하고 싶은데 모든 항목의 검사가 진행돼서, 모든 항목의 에러가 출력되는 식이었던 것입니다. 그래서 위의 경우 각 필드에 대해 Zod의 문자열 검증 메서드를 사용하여 유효성 검사 규칙을 설정합니다. 또 비밀번호 일치를 판단하는 부분을 따로 빼내어 if문으로 검사를 하고 있습니다. 검사가 전부 다 통과하지 않으면, 제출 버튼은 누를 수 없는 상태로 남습니다.

  2. 각각의 인풋에 onBlur 이벤트를 포착해주는 onBlur={} 항목이 들어가고, handleBlur 함수를 통해 검사를 진행한 후 에러를 출력하게 됩니다.

  3. 폼 제출 시 호출되는 handleSubmit 함수에선 모든 필드의 유효성을 검사하기 위해 Promise.all을 사용하여 비동기적으로 검증을 수행합니다. 유효성 검사 결과를 기반으로 에러 메시지를 설정하고, 에러가 없는 경우 회원가입 로직을 처리합니다.

다소 복잡하긴 하지만, 이런 식으로 유효성 검증을 구현하고, 검증 타이밍과 에러 표시 방식을 바꿈으로서 유저 경험을 향상시킬 수 있습니다.

example of the login page and validation

Updated:

Leave a comment