본문으로 건너뛰기

안동민 개발노트

본문 시작

기본적인 폼 유효성 검사 구현

필수값·비밀번호 규칙·필드 일치 검사를 구현하고 입력·포커스 이탈·제출 시점에 맞춰 오류 메시지를 표시합니다.

유효성 검사는 입력 오류를 빠르게 알려 사용자 경험을 높이고, 불필요한 요청을 줄이는 데 유용합니다. 브라우저 검사는 우회할 수 있으므로 보안과 데이터 무결성의 최종 책임은 서버에 있습니다.

이 장에서는 클라이언트 측 유효성 검사의 중요성과, 이를 구현하는 기본 방법을 함께 정리합니다.


유효성 검사의 중요성

유효성 검사는 단순히 데이터의 형식을 확인하는 것을 넘어, 다음과 같은 여러 중요한 역할을 합니다.

  • 사용자 경험 개선 (UX): 사용자가 폼을 제출하기 전에 오류를 미리 알려줌으로써, 불필요한 서버 통신과 페이지 새로고침 없이 즉각적인 피드백을 제공합니다. 이는 사용자 frustation을 줄이고 더 나은 인터랙션을 가능하게 합니다.
  • 데이터 품질 안내: 저장 전에 형식 오류를 발견하도록 돕습니다. 실제 데이터 무결성은 서버 검증과 데이터베이스 제약 조건으로 보장해야 합니다.
  • 보안 경계 구분: 클라이언트 검사는 보안 경계가 아닙니다. 서버 검증과 매개변수화된 쿼리, 출력 문맥에 맞는 인코딩처럼 공격 유형에 맞는 방어를 별도로 적용해야 합니다.
  • 서버 부하 감소: 잘못된 요청이 서버에 도달하기 전에 클라이언트에서 걸러내어 서버의 처리 부담을 줄입니다.
클라이언트 측 vs 서버 측 유효성 검사
  • 클라이언트 측 유효성 검사: 사용자 경험을 위해 브라우저에서 즉시 이루어집니다. 빠른 피드백을 제공하지만, 사용자가 쉽게 우회할 수 있으므로 보안 목적으로는 충분하지 않습니다.
  • 서버 측 유효성 검사: 데이터가 서버에 도달했을 때 최종적으로 이루어지는 검사입니다. 보안 및 데이터 무결성 측면에서 필수적이며, 클라이언트 측 검사와 병행되어야 합니다.

이번 장에서는 주로 클라이언트 측 유효성 검사에 초점을 맞추겠습니다.


기본적인 유효성 검사 구현 패턴

가장 기본적인 유효성 검사는 useState를 사용하여 폼 필드와 에러 메시지를 관리하는 것입니다.

필수 필드 검사

가장 흔한 유효성 검사 중 하나는 필드가 비어 있는지 확인하는 것입니다.

src/components/SimpleValidationForm.js
import React, { useState } from 'react';

function SimpleValidationForm() {
  const [username, setUsername] = useState('');
  const [email, setEmail] = useState('');
  const [usernameError, setUsernameError] = useState('');
  const [emailError, setEmailError] = useState('');
  const [formValid, setFormValid] = useState(false); // 폼 전체 유효성 상태

  // 입력 변경 핸들러
  const handleUsernameChange = (e) => {
    setUsername(e.target.value);
    // 실시간 유효성 검사 (선택 사항)
    if (e.target.value.trim() === '') {
      setUsernameError('사용자 이름은 필수입니다.');
    } else {
      setUsernameError('');
    }
  };

  const handleEmailChange = (e) => {
    setEmail(e.target.value);
    // 실시간 유효성 검사 (선택 사항)
    if (!e.target.value.includes('@')) {
      setEmailError('유효한 이메일 주소를 입력해주세요.');
    } else {
      setEmailError('');
    }
  };

  // 폼 제출 핸들러
  const handleSubmit = (e) => {
    e.preventDefault();

    let valid = true;
    // 제출 시 최종 유효성 검사
    if (username.trim() === '') {
      setUsernameError('사용자 이름은 필수입니다.');
      valid = false;
    } else {
      setUsernameError('');
    }

    if (!email.includes('@')) {
      setEmailError('유효한 이메일 주소를 입력해주세요.');
      valid = false;
    } else {
      setEmailError('');
    }

    if (valid) {
      console.log('폼 데이터 유효:', { username, email });
      alert(`제출 완료!\n사용자 이름: ${username}\n이메일: ${email}`);
      // 폼 초기화
      setUsername('');
      setEmail('');
    } else {
      console.log('폼 데이터 유효하지 않음.');
      alert('폼 입력값을 확인해주세요.');
    }
    setFormValid(valid); // 폼 전체 유효성 상태 업데이트
  };

  return (
    <div style={{ maxWidth: '500px', margin: '30px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 10px rgba(0,0,0,0.05)', backgroundColor: '#fff' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '30px' }}>기본적인 유효성 검사</h2>
      <form onSubmit={handleSubmit}>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="username" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>사용자 이름:</label>
          <input
            type="text"
            id="username"
            value={username}
            onChange={handleUsernameChange}
            style={{ width: '100%', padding: '10px', border: usernameError ? '1px solid red' : '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
          {usernameError && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{usernameError}</p>}
        </div>
        <div style={{ marginBottom: '15px' }}>
          <label htmlFor="email" style={{ display: 'block', marginBottom: '5px', fontWeight: 'bold' }}>이메일:</label>
          <input
            type="email"
            id="email"
            value={email}
            onChange={handleEmailChange}
            style={{ width: '100%', padding: '10px', border: emailError ? '1px solid red' : '1px solid #ccc', borderRadius: '4px', boxSizing: 'border-box' }}
          />
          {emailError && <p style={{ color: 'red', fontSize: '0.8em', marginTop: '5px' }}>{emailError}</p>}
        </div>
        <button type="submit" className="button" style={{ width: '100%', padding: '12px', fontSize: '1.1em' }}>
          제출
        </button>
      </form>
    </div>
  );
}

export default SimpleValidationForm;

이 예시에서는 각 입력 필드에 대한 상태(username, email)와 에러 메시지 상태(usernameError, emailError)를 별도로 관리합니다.

onChange 이벤트에서 실시간으로 유효성을 검사하고, handleSubmit에서 최종적으로 모든 필드를 다시 검사하여 제출 여부를 결정합니다.

복잡한 규칙 검사: 비밀번호 유효성

비밀번호는 길이 제한, 특수 문자 포함 등 여러 복잡한 규칙을 가질 수 있습니다.

src/components/SimpleValidationForm.js (이전 코드에 추가 또는 별도 컴포넌트로 분리)
// ... 기존 코드 ...

const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState('');

const validatePassword = (value) => {
  let error = '';
  if (value.length < 8) {
    error = '비밀번호는 최소 8자 이상이어야 합니다.';
  } else if (!/[A-Z]/.test(value)) {
    error = '비밀번호는 최소 하나의 대문자를 포함해야 합니다.';
  } else if (!/[a-z]/.test(value)) {
    error = '비밀번호는 최소 하나의 소문자를 포함해야 합니다.';
  } else if (!/[0-9]/.test(value)) {
    error = '비밀번호는 최소 하나의 숫자를 포함해야 합니다.';
  } else if (!/[!@#$%^&*()]/.test(value)) { // 예시: 특정 특수 문자 허용
    error = '비밀번호는 최소 하나의 특수 문자(!@#$%^&*())를 포함해야 합니다.';
  }
  return error;
};

const handlePasswordChange = (e) => {
  const value = e.target.value;
  setPassword(value);
  setPasswordError(validatePassword(value));
};

// ... handleSubmit 함수 내에서 password 유효성 검사 추가 ...
let valid = true;
// ... 기존 유효성 검사 ...
const passwordValidationError = validatePassword(password);
setPasswordError(passwordValidationError);
if (passwordValidationError) {
  valid = false;
}
// ...

이처럼 여러 규칙을 적용할 때는 검증 함수를 하나로 분리해 onChangehandleSubmit에서 재사용할 수 있습니다. 제출 시에는 현재 password로 지역 검증값을 다시 계산하고, 그 값을 에러 상태 저장과 제출 여부 판정에 함께 사용합니다.

필드 간 상호 의존성 검사: 비밀번호 확인

비밀번호 확인 필드는 비밀번호 필드와 값이 일치하는지 확인해야 하는 상호 의존성을 가집니다.

src/components/SimpleValidationForm.js (이전 코드에 추가)
// ... 기존 코드 ...

const [confirmPassword, setConfirmPassword] = useState('');
const [confirmPasswordError, setConfirmPasswordError] = useState('');

const handleConfirmPasswordChange = (e) => {
  const value = e.target.value;
  setConfirmPassword(value);
  // 실시간 검사: 비밀번호와 일치하는지
  if (password !== value) {
    setConfirmPasswordError('비밀번호가 일치하지 않습니다.');
  } else {
    setConfirmPasswordError('');
  }
};

// ... handleSubmit 함수 내에서 confirmPassword 유효성 검사 추가 ...
let valid = true;
// ... 기존 유효성 검사 ...
if (password !== confirmPassword) {
  setConfirmPasswordError('비밀번호가 일치하지 않습니다.');
  valid = false;
} else {
  setConfirmPasswordError('');
}
// ...

이 경우, confirmPasswordonChange 핸들러나 handleSubmit 함수에서 password 상태의 값을 참조하여 비교 검사를 수행합니다.

현재 password와 confirmPassword 값을 각각 읽어 비밀번호 규칙과 두 필드 일치 여부를 검사하고, 제출 시 두 검증을 다시 계산하는 흐름

React · dependent fields · current values

필드 규칙과 필드 간 관계를 분리한다

validatePassword는 입력 변경과 제출에 같은 규칙 순서를 씁니다. 확인은 현재 두 값을 비교하고, 제출은 저장 오류 대신 현재 값으로 두 결과를 다시 계산합니다.

비밀번호 규칙과 확인 필드 의존성 검증 흐름 현재 password는 규칙 검사와 일치 검사에 각각 들어가고 confirmPassword는 일치 검사에 들어갑니다. 두 검사는 독립적으로 표시 결과를 갱신하며 제출 시 현재 두 값에서 다시 계산됩니다. password 입력 handlePasswordChange confirmPassword 입력 onChange validatePassword(password) 필드 자체 규칙을 독립 계산 password === confirmPassword 현재 두 값의 관계를 독립 계산 passwordError 갱신 빈 문자열 또는 첫 규칙 오류 confirmPasswordError 갱신 빈 문자열 또는 불일치 오류 handleSubmit 현재 두 값으로 모두 재계산 오류 상태는 표시 결과이고 제출 여부는 같은 이벤트에서 계산한 현재 결과로 정한다
password

비밀번호 규칙 검사

validatePassword(value)가 규칙을 정해진 순서로 확인하고 첫 오류 메시지를 반환합니다.

passwordError

규칙 위반 표시

입력 변경에서 계산한 첫 오류를 상태에 둡니다.

dependent values

현재 두 값 비교

password === confirmPassword

confirmPasswordError

불일치 표시

현재 두 값이 다르면 확인 필드 오류 메시지를 설정합니다.

submit

제출 시 재검사

현재 비밀번호 규칙과 확인값 일치를 다시 계산합니다.

상태 설정은 다음 렌더를 예약합니다. 제출 여부는 방금 저장한 오류 상태가 아니라 현재 입력에서 계산한 지역 결과로 정합니다.


유효성 검사 메시지 표시

유효성 검사 결과를 사용자에게 명확하고 즉각적으로 피드백하는 것이 중요합니다.

  • 인라인 에러 메시지: 각 입력 필드 아래에 에러 메시지를 직접 표시하는 것이 가장 일반적입니다.
  • 스타일 변경: 에러가 있는 필드의 테두리 색상을 빨간색으로 변경하는 등 시각적인 힌트를 제공합니다.
  • 폼 제출 버튼 비활성화: 폼에 유효성 에러가 하나라도 있다면 제출 버튼을 비활성화하여 사용자가 잘못된 데이터를 제출하는 것을 막을 수 있습니다.
// 컴포넌트를 렌더링할 때 현재 입력값에서 파생합니다.
const formValid =
  username.trim() !== '' &&
  email.includes('@') &&
  validatePassword(password) === '' &&
  password === confirmPassword;

return (
  // ...
  <button type="submit" className="button" disabled={!formValid}>
    제출
  </button>
  // ...
);

formValid는 다른 상태에서 완전히 계산할 수 있는 값이므로 별도 상태와 useEffect로 동기화하지 않습니다. Hook은 컴포넌트 최상위에서만 호출해야 하며 이벤트 핸들러 안에 둘 수 없습니다. 버튼을 비활성화하더라도 handleSubmit은 현재 값으로 같은 규칙을 다시 검사해야 합니다. 클라이언트 상태와 무관하게 서버도 요청을 독립적으로 검증합니다.


사용자 경험을 고려한 유효성 검사 시점

입력 준비, 입력 중, 필드 피드백, 제출 검사, 서버 최종 판정의 다섯 상태와 change, blur, submit 전이로 보는 폼 검증 시점

React · validation timing · trust boundary

빠른 피드백과 최종 판정의 경계를 나눈다

change·blur는 피드백, submit은 현재 값을 검사합니다. 브라우저 검증은 우회돼 서버가 보안·무결성을 판정합니다.

폼 검증의 다섯 상태와 일곱 전이 폼 준비에서 입력 중으로 시작하고 필드 피드백과 수정 과정을 오가며, 제출 시 현재 값을 전체 검사합니다. 통과한 요청만 서버 신뢰 경계로 보내 최종 검증합니다. SERVER TRUST BOUNDARY change / 입력 시작 change · blur change / 수정 submit / 전체 검사 submit / 현재 값 invalid / 요청 차단 valid / 요청 전송 폼 준비 초기 값과 빈 오류 입력 중 현재 필드 값 갱신 필드 피드백 규칙 결과를 가까이 표시 제출 검사 현재 폼 값을 다시 계산 서버 최종 판정 업무 규칙·무결성 확인 클라이언트 검사는 빠른 안내와 요청 차단을 돕지만 서버 검증을 대신하지 않는다
ready

폼 준비

초기 값을 보여 주고 첫 입력을 기다립니다.

change

입력 중

현재 값을 갱신하고 필드 규칙을 확인합니다.

change · blur

필드 피드백

오류를 가까이 보여 주고 변경 때 다시 확인합니다.

submit

제출 검사

현재 규칙을 다시 계산해 실패하면 요청을 막습니다.

server

서버 최종 판정

클라이언트 통과 여부와 별개로 보안과 데이터 무결성을 다시 확인합니다.

change·blur·submit은 검사 시점을 정합니다. 제출 핸들러는 현재 값을 다시 검사해 상태 갱신 순서와 무관하게 요청 여부를 정합니다.

유효성 검사를 언제 수행할 것인가도 중요한 UX 고려 사항입니다.

  • onChange (입력 중): 사용자가 타이핑하는 동안 실시간으로 오류를 표시합니다. 즉각적인 피드백을 제공하지만, 너무 민감하면 사용자를 방해할 수 있습니다 (예: 한 글자 입력하자마자 에러).
  • onBlur (필드 포커스 잃을 때): 사용자가 필드 입력을 마치고 다른 곳을 클릭했을 때 검사합니다. onChange보다 덜 방해적이지만, onBlur 전에 이미 제출을 시도할 수도 있습니다.
  • onSubmit (폼 제출 시): 폼 제출 버튼을 클릭했을 때 현재 값으로 모든 필드를 다시 검사하는 브라우저 측 최종 관문입니다. 통과한 요청도 서버에서 독립적으로 검증해야 합니다.
권장 패턴

onBlur 시 검사: 사용자가 필드를 벗어났을 때 해당 필드의 유효성 검사를 수행하고 에러 메시지를 표시합니다.

onSubmit 시 모든 필드 검사: 폼 제출 시 모든 필드를 검사하고, 유효하지 않은 필드에는 에러 메시지를 표시하여 제출을 막습니다.

이 패턴은 실시간 피드백과 최종 제출 유효성 검사의 균형을 맞춥니다.


useReducer와 커스텀 훅으로 유효성 검사

앞서 2장에서 다루었던 useReducer 또는 커스텀 훅(useForm)은 유효성 검사 로직을 통합하여 관리하는 데 매우 효과적입니다.

  • useReducer의 경우: 리듀서 함수 내에서 각 필드의 상태 변화에 따라 유효성 검사를 수행하고, 에러 메시지 상태도 함께 업데이트하도록 로직을 추가할 수 있습니다.
  • 커스텀 훅 (useForm): 유효성 검사 로직 자체를 훅의 인자로 받아들이고, handleSubmit 내부에서 이 유효성 검사 함수를 호출하여 에러를 처리하는 방식이 가장 일반적입니다. (2장의 useForm 예시 참고)

이러한 접근 방식은 코드 중복을 줄이고, 폼 로직의 응집성을 높여 유지보수성을 향상시킵니다.


9장 3절 기본적인 폼 유효성 검사 구현은 여기까지입니다.

이 장에서는 폼 유효성 검사의 중요성을 이해하고, 클라이언트 측 유효성 검사의 기본적인 구현 패턴을 살펴보았습니다.

필수 필드, 복잡한 규칙, 필드 간 상호 의존성을 검사하는 방법을 배우고, 사용자 경험을 고려하여 에러 메시지를 표시하는 방법과 유효성 검사 시점을 결정하는 원칙에 대해서도 알아보았습니다.