기본적인 폼 유효성 검사 구현
필수값·비밀번호 규칙·필드 일치 검사를 구현하고 입력·포커스 이탈·제출 시점에 맞춰 오류 메시지를 표시합니다.
유효성 검사는 입력 오류를 빠르게 알려 사용자 경험을 높이고 불필요한 요청을 줄이는 데 유용합니다. 다만 브라우저의 검사는 우회할 수 있으므로, 보안과 데이터 무결성의 최종 판단은 서버가 내립니다. 이 글에서는 클라이언트 측 유효성 검사가 왜 필요한지와, 이를 구현하는 기본 방법을 정리합니다.
앞 글의 커스텀 훅 제출 흐름과 이어 보면, values는 입력이고 errors는 그 입력을 검사한 결과를 보여 주는 값이라는 차이가 보입니다. 이 글의 SimpleValidationForm은 이름과 이메일을 렌더하는 완성 예제이고, 그 뒤의 비밀번호 코드들은 어디에 무엇을 더할지 보여 주는 일부 코드입니다. 새 필드를 실제로 조작하려면 state 훅을 컴포넌트의 최상위에 두고, 입력 JSX와 처리 함수, 제출 검사까지 함께 연결해야 합니다.
유효성 검사의 중요성
유효성 검사는 데이터의 형식을 확인하는 데서 그치지 않고 여러 역할을 합니다. 사용자가 폼을 제출하기 전에 오류를 미리 알려 주므로, 서버와의 통신이나 페이지 새로고침 없이 바로 피드백을 줄 수 있고 사용자의 답답함도 줄어듭니다. 저장하기 전에 형식 오류를 발견하도록 도와 데이터의 품질도 높여 주지만, 실제 데이터 무결성은 서버의 검증과 데이터베이스의 제약 조건이 보장해야 합니다. 잘못된 요청을 서버에 닿기 전에 걸러 내므로 서버의 처리 부담도 줄어듭니다.
그러나 클라이언트의 검사는 보안 장치가 아닙니다. 공격을 막으려면 서버의 검증과 매개변수화된 쿼리, 출력 문맥에 맞는 인코딩처럼 공격의 유형에 맞는 방어를 따로 적용해야 합니다. 정리하면, 클라이언트 측 유효성 검사는 사용자 경험을 위해 브라우저에서 바로 이루어지며 빠른 피드백을 주지만 쉽게 우회할 수 있어 보안 목적으로는 충분하지 않습니다. 서버 측 유효성 검사는 데이터가 서버에 도착했을 때 최종적으로 이루어지는 검사로, 보안과 데이터 무결성을 위해 꼭 필요하며 클라이언트 측 검사와 함께 써야 합니다. 이 글에서는 주로 클라이언트 측 유효성 검사에 집중합니다.
기본적인 유효성 검사 구현 패턴
가장 기본적인 유효성 검사는 useState로 폼 필드와 오류 메시지를 함께 관리하는 것입니다. 필드마다 값을 담는 state와 그 필드의 오류 문구를 담는 state를 한 쌍으로 두고, 입력이 바뀔 때와 제출할 때 같은 규칙으로 오류 문구를 정합니다. 오류 문구가 빈 문자열이면 그 필드는 통과한 것입니다. 아래에서는 필수 입력, 여러 규칙을 차례로 거치는 비밀번호, 다른 필드와 비교하는 비밀번호 확인의 순서로 이 패턴을 넓혀 갑니다.
필수 필드 검사
가장 흔한 유효성 검사는 필드가 비어 있는지 확인하는 것입니다. 다음 컴포넌트는 JSX가 들어 있으므로 .jsx로 저장합니다.
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에서 마지막으로 모든 필드를 다시 검사해 제출할지를 정합니다. 제출 처리 함수는 방금 바꾼 오류 상태가 아니라 지역 변수 valid로 제출 여부를 판단한다는 점도 눈여겨보세요. setter는 다음 렌더를 예약할 뿐이라, 같은 함수 안에서 오류 상태를 다시 읽어도 아직 이전 값이기 때문입니다.
이메일의 includes('@')는 연습용의 약한 규칙입니다. 또한 type="email"인 입력의 브라우저 형식 검사와 이 자바스크립트 검사는 서로 다른 층이므로, '@'처럼 자바스크립트 조건만 참인 문자열은 브라우저의 기본 검사에서 먼저 막혀 handleSubmit까지 오지 않습니다. 서버가 주소의 소유를 확인하거나 계정이 있는지 확인하는 일도 이 규칙으로 구현된 것이 아닙니다.
복잡한 규칙 검사: 비밀번호 유효성
비밀번호는 길이 제한이나 특수 문자 포함처럼 여러 규칙을 가질 수 있습니다. 다음은 SimpleValidationForm에 더할 비밀번호 검사의 일부 코드입니다.
// ... 기존 코드 ...
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;
}
// ...이처럼 여러 규칙을 적용할 때는 검증 함수를 하나로 떼어 내 onChange와 handleSubmit에서 함께 쓸 수 있습니다. 제출할 때는 현재 password로 지역 검증값을 다시 계산하고, 그 값을 오류 상태의 저장과 제출 여부의 판정에 함께 씁니다.
validatePassword는 모든 오류를 모으지 않고 처음 실패한 규칙 하나만 돌려줍니다. 그래서 Ab1!는 길이가 4라서 길이 오류, abcdefgh는 길이 8을 통과한 뒤 대문자 오류, Abcdefgh는 숫자 오류이고, Abcdef1!는 빈 문자열을 돌려줍니다. 이 규칙의 조합은 여러 검사를 연습하려는 예이며 인증 서비스가 권장하는 보안 정책 전체가 아니므로, 실제 서비스의 길이와 금지값, 저장, 복구 정책은 따로 정해야 합니다.
필드 간 상호 의존성 검사: 비밀번호 확인
비밀번호 확인 필드는 비밀번호 필드와 값이 같은지 확인해야 하는, 다른 필드에 기대는 검사입니다. 아래 코드의 handleConfirmPasswordChange는 방금 입력된 value를 state에 저장하는 동시에, 아직 갱신 전인 confirmPassword 대신 이 value를 password와 비교합니다. setter를 부른 직후에도 이번 렌더링의 confirmPassword는 이전 값이기 때문입니다.
// ... 기존 코드 ...
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('');
}
// ...이 경우에는 confirmPassword의 onChange 처리 함수나 handleSubmit에서 password 상태의 값을 읽어 비교합니다.
비밀번호와 확인 필드 검증 흐름 — 상태 추적
두 값 Abcdef1! 일치 뒤 password만 Abcdef2!로 고치면 확인 오류 표시는 아직 비어도 현재 값은 불일치한다. 재확인/submit의 지역 검증 결과로 값과 표시의 시간 차이를 보여 줄 가치가 크다.
React · dependent fields · current values
필드 규칙과 필드 간 관계를 분리한다
validatePassword는 입력 변경과 제출에 같은 규칙 순서를 씁니다. 확인 입력 변경은 현재 두 값을 비교하고, 제출은 저장 오류 대신 현재 값으로 두 결과를 다시 계산합니다.
비밀번호 규칙 검사
validatePassword(value)가 규칙을 정해진 순서로 확인하고 첫 오류 메시지를 반환합니다.
규칙 위반 표시
입력 변경에서 계산한 첫 오류를 상태에 둡니다.
현재 두 값 비교
password === confirmPassword를 확인 입력 변경 시 계산합니다. password는 비교에 필요한 의존 값입니다.
불일치 표시
확인 입력 변경 시 현재 두 값이 다르면 오류 메시지를 설정합니다. 이 코드에서는 password 변경만으로 이 오류를 자동 갱신하지 않습니다.
제출 시 재검사
현재 비밀번호 규칙과 확인값 일치를 다시 계산합니다.
이 예제에서 password를 다시 바꾼 뒤의 확인 오류는 확인 입력 변경 또는 제출 시 재검사됩니다. 두 필드 변경 때 즉시 동기화하려면 별도 검사를 추가해야 합니다. 상태 설정은 다음 렌더를 예약합니다. 제출 여부는 방금 저장한 오류 상태가 아니라 현재 입력에서 계산한 지역 결과로 정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 마지막 장면은 confirmPassword=Abcdef1!를 그대로 둔 채 submit하는 선택 추적입니다. confirm을 Abcdef2!로 고쳐 일치시키는 입력은 별도 대안입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | validatePassword는 입력 변경과 제출에 같은 규칙 순서를 씁니다. 확인 입력 변경은 현재 두 값을 비교하고, 제출은 저장 오류 대신 현재 값으로 두 결과를 다시 계산합니다. |
| 전체 관계·조건 설명 | password→자체 규칙→passwordError, confirmPassword와 의존 password→두 값 일치→확인 오류의 두 입력 경로를 유지한다. password와 confirmPassword는 각각 제출 재검사에도 전달된다. 원래 도표의 노드 일곱 개와 간선 일곱 개를 유지하며 상태 박스들의 세로 배치가 순서대로 실행된다는 뜻은 아니다. |
| 원도표 설명 | validatePassword(value)가 규칙을 정해진 순서로 확인하고 첫 오류 메시지를 반환합니다. |
| 원도표 설명 | password === confirmPassword를 확인 입력 변경 시 계산합니다. password는 비교에 필요한 의존 값입니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 두 값 일치 | 두 값: Abcdef1! / confirm 오류: 빈 문자열 | password와 confirmPassword는 Abcdef1!로 같다 | 오류 문구는 현재 검사 결과의 표시다 |
| password만 수정 | password: Abcdef2! / confirm: Abcdef1! | password는 Abcdef2!, confirm은 Abcdef1!이고 confirm 오류는 아직 비어 있을 수 있다 | 원 handler는 비밀번호 자체의 규칙만 갱신한다 |
| 확인 값 유지 · submit | 현재 값: Abcdef2! / Abcdef1! / 지역 검증·요청: 불일치 · 차단 | password Abcdef2!와 confirm Abcdef1!를 submit 시 재검사해 불일치 요청을 차단한다 | 기존 표시 오류가 비었다는 이유로 현재 제출을 허용하지 않는다 |
비밀번호와 확인 필드 검증 흐름 — 상태 추적
두 값 Abcdef1! 일치 뒤 password만 Abcdef2!로 고치면 확인 오류 표시는 아직 비어도 현재 값은 불일치한다. 재확인/submit의 지역 검증 결과로 값과 표시의 시간 차이를 보여 줄 가치가 크다.
React · dependent fields · current values
필드 규칙과 필드 간 관계를 분리한다
validatePassword는 입력 변경과 제출에 같은 규칙 순서를 씁니다. 확인 입력 변경은 현재 두 값을 비교하고, 제출은 저장 오류 대신 현재 값으로 두 결과를 다시 계산합니다.
비밀번호 규칙 검사
validatePassword(value)가 규칙을 정해진 순서로 확인하고 첫 오류 메시지를 반환합니다.
규칙 위반 표시
입력 변경에서 계산한 첫 오류를 상태에 둡니다.
현재 두 값 비교
password === confirmPassword를 확인 입력 변경 시 계산합니다. password는 비교에 필요한 의존 값입니다.
불일치 표시
확인 입력 변경 시 현재 두 값이 다르면 오류 메시지를 설정합니다. 이 코드에서는 password 변경만으로 이 오류를 자동 갱신하지 않습니다.
제출 시 재검사
현재 비밀번호 규칙과 확인값 일치를 다시 계산합니다.
이 예제에서 password를 다시 바꾼 뒤의 확인 오류는 확인 입력 변경 또는 제출 시 재검사됩니다. 두 필드 변경 때 즉시 동기화하려면 별도 검사를 추가해야 합니다. 상태 설정은 다음 렌더를 예약합니다. 제출 여부는 방금 저장한 오류 상태가 아니라 현재 입력에서 계산한 지역 결과로 정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 마지막 장면은 confirmPassword=Abcdef1!를 그대로 둔 채 submit하는 선택 추적입니다. confirm을 Abcdef2!로 고쳐 일치시키는 입력은 별도 대안입니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | validatePassword는 입력 변경과 제출에 같은 규칙 순서를 씁니다. 확인 입력 변경은 현재 두 값을 비교하고, 제출은 저장 오류 대신 현재 값으로 두 결과를 다시 계산합니다. |
| 전체 관계·조건 설명 | password→자체 규칙→passwordError, confirmPassword와 의존 password→두 값 일치→확인 오류의 두 입력 경로를 유지한다. password와 confirmPassword는 각각 제출 재검사에도 전달된다. 원래 도표의 노드 일곱 개와 간선 일곱 개를 유지하며 상태 박스들의 세로 배치가 순서대로 실행된다는 뜻은 아니다. |
| 원도표 설명 | validatePassword(value)가 규칙을 정해진 순서로 확인하고 첫 오류 메시지를 반환합니다. |
| 원도표 설명 | password === confirmPassword를 확인 입력 변경 시 계산합니다. password는 비교에 필요한 의존 값입니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 두 값 일치 | 두 값: Abcdef1! / confirm 오류: 빈 문자열 | password와 confirmPassword는 Abcdef1!로 같다 | 오류 문구는 현재 검사 결과의 표시다 |
| password만 수정 | password: Abcdef2! / confirm: Abcdef1! | password는 Abcdef2!, confirm은 Abcdef1!이고 confirm 오류는 아직 비어 있을 수 있다 | 원 handler는 비밀번호 자체의 규칙만 갱신한다 |
| 확인 값 유지 · submit | 현재 값: Abcdef2! / Abcdef1! / 지역 검증·요청: 불일치 · 차단 | password Abcdef2!와 confirm Abcdef1!를 submit 시 재검사해 불일치 요청을 차단한다 | 기존 표시 오류가 비었다는 이유로 현재 제출을 허용하지 않는다 |
위 그림은 비밀번호 자체의 규칙과 두 필드 사이의 관계를 따로 검사하는 흐름을 보여 줍니다. 그림의 요약처럼 validatePassword는 입력 변경과 제출에 같은 규칙 순서를 쓰고, 확인 입력이 바뀌면 현재 두 값을 비교하며, 제출할 때는 저장된 오류 대신 현재 값으로 두 결과를 다시 계산합니다. 비밀번호를 입력하면 validatePassword(value)가 첫 오류나 빈 문자열을 passwordError에 두고, 확인 값을 입력하면 password === confirmPassword의 결과로 confirmPasswordError를 정하며, 이때 password는 비교에 필요한 의존 값으로 읽힙니다. handleSubmit은 현재 두 값으로 두 결과를 다시 계산하며, 오류 상태는 화면에 보여 줄 결과일 뿐 제출 여부는 지역 검증 결과로 정합니다.
그림 아래의 설명이 짚듯, 이 코드에서는 비밀번호만 바꾸면 확인 오류가 저절로 갱신되지 않고, 확인 입력을 다시 바꾸거나 제출할 때 다시 검사됩니다. 두 필드 가운데 어느 쪽이 바뀌어도 곧바로 맞추려면 비밀번호의 변경 처리 함수에도 일치 검사를 더해야 합니다. 그림의 칸들이 세로로 놓인 순서가 실행 순서를 뜻하지는 않습니다.
예를 들어 비밀번호와 확인에 Abcdef1!를 넣어 일치시킨 뒤 비밀번호만 Abcdef2!로 고치면 두 값이 달라집니다. handlePasswordChange는 비밀번호 자체의 규칙만 저장하므로 confirmPasswordError는 바로 갱신되지 않고, 확인 입력을 다시 바꾸거나 제출할 때 현재 두 값을 비교합니다. 그래서 화면의 오류가 비어 있었다는 이유만으로 제출해도 된다고 판단하지 않습니다.
유효성 검사 메시지 표시
유효성 검사의 결과는 사용자에게 분명하고 빠르게 알려야 합니다. 가장 일반적인 방법은 각 입력 필드 아래에 오류 메시지를 직접 보여 주는 인라인 오류 메시지이며, 오류가 있는 필드의 테두리를 빨간색으로 바꾸는 것처럼 시각적인 힌트도 줍니다. 폼에 오류가 하나라도 있으면 제출 버튼을 비활성화해 잘못된 데이터를 제출하지 못하게 할 수도 있습니다.
// 컴포넌트를 렌더링할 때 현재 입력값에서 파생합니다.
const formValid =
username.trim() !== '' &&
email.includes('@') &&
validatePassword(password) === '' &&
password === confirmPassword;
return (
// ...
<button type="submit" className="button" disabled={!formValid}>
제출
</button>
// ...
);formValid는 다른 상태에서 완전히 계산할 수 있는 값이므로, 별도의 상태와 useEffect로 맞추지 않고 렌더할 때 계산합니다(React 문서의 Effect가 필요하지 않을 수도 있습니다). 훅은 컴포넌트의 최상위에서만 부를 수 있으며 이벤트 처리 함수 안에 둘 수 없습니다. 버튼을 비활성화하더라도 handleSubmit은 현재 값으로 같은 규칙을 다시 검사해야 하고, 클라이언트의 상태와 상관없이 서버도 요청을 따로 검증합니다.
이 일부 코드를 앞의 완성 예제에 붙일 때는 원래의 const [formValid, setFormValid] 선언과 setFormValid(valid) 호출을 지우고, 모든 입력 state와 validatePassword를 정의한 컴포넌트의 return 바로 앞에 파생값 const formValid 하나만 둡니다. 그대로 두면 같은 이름이 두 번 선언됩니다. 비밀번호와 확인 입력도 value, onChange, label까지 연결해야 하며, 일부 코드의 // ...가 빠진 화면을 대신 만들어 주지는 않습니다.
사용자 경험을 고려한 유효성 검사 시점
폼 검증 시점과 서버 신뢰 경계 — 상태 추적
change/blur/submit의 현재 값·피드백·제출 검사 시점이 다르며 유효 요청도 서버에서 독립 검증한다. 빈 폼에서 바로 제출하는 분기까지 전체 모델을 보존한다.
React · validation timing · trust boundary
빠른 피드백과 최종 판정의 경계를 나눈다
change·blur는 피드백, submit은 현재 값을 검사합니다. 브라우저 검증은 우회돼 서버가 보안·무결성을 판정합니다.
폼 준비
초기 값을 보여 줍니다. 아직 입력하지 않은 빈 폼을 제출해도 현재 값을 전체 검사합니다.
입력 중
현재 값을 갱신하고 필드 규칙을 확인합니다.
필드 피드백
오류를 가까이 보여 주고 변경 때 다시 확인합니다.
제출 검사
입력 중·피드백 상태 어디서 제출하든 현재 값을 전체 검사합니다. 실패하면 오류를 표시하고 요청을 막으며, 통과한 요청만 서버로 보냅니다.
서버 최종 판정
클라이언트 통과 여부와 별개로 보안과 데이터 무결성을 다시 확인합니다.
change·blur·submit은 검사 시점을 정합니다. 제출 핸들러는 현재 값을 다시 검사해 상태 갱신 순서와 무관하게 요청 여부를 정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | change·blur는 피드백, submit은 현재 값을 검사합니다. 브라우저 검증은 우회될 수 있으므로 서버가 보안·무결성을 판정합니다. |
| 전체 관계·조건 설명 | 폼 준비→입력 중→필드 피드백, 피드백→입력 수정의 관계를 유지한다. 준비·입력 중·피드백 어디서든 submit은 현재 값 검사로 가고 실패는 피드백, 통과는 서버의 독립 판정으로 간다. 기본 예제는 서버 요청이 없는 관찰 코드이고 서버 노드는 실제 서비스의 확장 경계다. |
| 원도표 설명 | 초기 값을 보여 줍니다. 아직 입력하지 않은 빈 폼을 제출해도 현재 값 전체를 검사합니다. |
| 원도표 설명 | 입력 중·피드백 상태 어디서 제출하든 현재 값 전체를 검사합니다. 실패하면 오류를 표시하고 요청을 막으며, 통과한 요청만 서버로 보냅니다. |
| 원도표 설명 | change·blur·submit은 검사 시점을 정합니다. 제출 핸들러는 현재 값을 다시 검사해 상태 갱신 순서와 무관하게 요청 여부를 정합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 빈 값의 submit | 값: 현재 snapshot / 검사: 전체 규칙 | 입력 중이거나 blur 전이어도 submit은 현재 값을 검사한다 | blur가 선행되어야 한다는 가정을 두지 않는다 |
| 피드백과 교정 | 요청: 차단 / UI: 가까운 필드 피드백 | invalid라면 요청 없이 오류를 표시하고 change로 고친다 | 입력의 표시와 다음 제출의 검사는 서로 다른 시점이다 |
| server 검증 | client: valid / server: 독립적인 최종 판정 | valid인 클라이언트 검사 뒤 요청은 서버 업무 규칙을 다시 통과해야 한다 | 브라우저 검사는 신뢰 경계를 대신하지 않는다 |
폼 검증 시점과 서버 신뢰 경계 — 상태 추적
change/blur/submit의 현재 값·피드백·제출 검사 시점이 다르며 유효 요청도 서버에서 독립 검증한다. 빈 폼에서 바로 제출하는 분기까지 전체 모델을 보존한다.
React · validation timing · trust boundary
빠른 피드백과 최종 판정의 경계를 나눈다
change·blur는 피드백, submit은 현재 값을 검사합니다. 브라우저 검증은 우회돼 서버가 보안·무결성을 판정합니다.
폼 준비
초기 값을 보여 줍니다. 아직 입력하지 않은 빈 폼을 제출해도 현재 값을 전체 검사합니다.
입력 중
현재 값을 갱신하고 필드 규칙을 확인합니다.
필드 피드백
오류를 가까이 보여 주고 변경 때 다시 확인합니다.
제출 검사
입력 중·피드백 상태 어디서 제출하든 현재 값을 전체 검사합니다. 실패하면 오류를 표시하고 요청을 막으며, 통과한 요청만 서버로 보냅니다.
서버 최종 판정
클라이언트 통과 여부와 별개로 보안과 데이터 무결성을 다시 확인합니다.
change·blur·submit은 검사 시점을 정합니다. 제출 핸들러는 현재 값을 다시 검사해 상태 갱신 순서와 무관하게 요청 여부를 정합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
- 원래 본문의 구현과 도표가 설명한 설계 보강을 분리한다. 서사에 포함한 확장 동작은 assumptions에 명시하고 이미 구현된 안전장치로 주장하지 않는다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 설명 | change·blur는 피드백, submit은 현재 값을 검사합니다. 브라우저 검증은 우회될 수 있으므로 서버가 보안·무결성을 판정합니다. |
| 전체 관계·조건 설명 | 폼 준비→입력 중→필드 피드백, 피드백→입력 수정의 관계를 유지한다. 준비·입력 중·피드백 어디서든 submit은 현재 값 검사로 가고 실패는 피드백, 통과는 서버의 독립 판정으로 간다. 기본 예제는 서버 요청이 없는 관찰 코드이고 서버 노드는 실제 서비스의 확장 경계다. |
| 원도표 설명 | 초기 값을 보여 줍니다. 아직 입력하지 않은 빈 폼을 제출해도 현재 값 전체를 검사합니다. |
| 원도표 설명 | 입력 중·피드백 상태 어디서 제출하든 현재 값 전체를 검사합니다. 실패하면 오류를 표시하고 요청을 막으며, 통과한 요청만 서버로 보냅니다. |
| 원도표 설명 | change·blur·submit은 검사 시점을 정합니다. 제출 핸들러는 현재 값을 다시 검사해 상태 갱신 순서와 무관하게 요청 여부를 정합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 빈 값의 submit | 값: 현재 snapshot / 검사: 전체 규칙 | 입력 중이거나 blur 전이어도 submit은 현재 값을 검사한다 | blur가 선행되어야 한다는 가정을 두지 않는다 |
| 피드백과 교정 | 요청: 차단 / UI: 가까운 필드 피드백 | invalid라면 요청 없이 오류를 표시하고 change로 고친다 | 입력의 표시와 다음 제출의 검사는 서로 다른 시점이다 |
| server 검증 | client: valid / server: 독립적인 최종 판정 | valid인 클라이언트 검사 뒤 요청은 서버 업무 규칙을 다시 통과해야 한다 | 브라우저 검사는 신뢰 경계를 대신하지 않는다 |
위 그림은 폼 검증의 다섯 상태와 change, blur, submit에 따른 전이로, 빠른 피드백과 최종 판정을 나누어 보여 줍니다. 그림의 요약처럼 change와 blur는 피드백을 주고, submit은 현재 값을 검사하며, 브라우저의 검증은 우회될 수 있으므로 보안과 무결성은 서버가 판정합니다. 초기 값과 빈 오류를 가진 폼 준비 상태에서 입력을 시작하면 입력 중이 되어 현재 필드 값을 갱신하고, change나 blur에서 규칙의 결과를 필드 가까이에 보여 주며, 다시 고치면 입력 중으로 돌아갑니다.
폼 준비와 입력 중, 필드 피드백 가운데 어느 상태에서 제출하든 현재 값으로 전체를 다시 계산하며, 아직 입력하지 않은 빈 폼을 제출해도 검사합니다. 실패하면 요청을 막고 오류를 보여 주는 필드 피드백으로 돌아가고, 통과한 요청만 서버로 보내 업무 규칙과 무결성을 다시 확인합니다. 그림의 캡션이 밝히듯 이 글의 기본 예제는 서버 요청이 없는 실습 코드이며, 서버 칸은 실제 서비스로 넓힐 때의 단계입니다.
유효성 검사를 언제 할지도 중요한 UX 고려 사항입니다. onChange(입력 중)는 사용자가 입력하는 동안 바로 오류를 보여 주는 즉각적인 피드백이지만, 너무 민감하면 한 글자를 입력하자마자 오류가 뜨는 것처럼 사용자를 방해할 수 있습니다. onBlur(필드가 포커스를 잃을 때)는 사용자가 필드의 입력을 마치고 다른 곳으로 옮겨 갔을 때 검사하므로 덜 방해되지만, 그 전에 이미 제출을 시도할 수도 있습니다. onSubmit(폼 제출 시)은 제출 버튼을 눌렀을 때 현재 값으로 모든 필드를 다시 검사하는 브라우저 쪽의 마지막 관문이며, 이를 통과한 요청도 서버에서 따로 검증해야 합니다.
흔히 권하는 방식은 사용자가 필드를 벗어날 때(onBlur) 그 필드를 검사해 오류 메시지를 보여 주고, 폼을 제출할 때(onSubmit) 모든 필드를 검사해 유효하지 않은 필드에 오류를 보여 주며 제출을 막는 것입니다. 이 방식은 빠른 피드백과 제출 직전의 최종 검사 사이의 균형을 맞춥니다.
더 깊이: 브라우저의 제약 조건 검사와 함께 쓰기
HTML 표준은 required, type="email", minLength, pattern 같은 속성으로 적은 규칙을 브라우저가 검사하는 제약 조건 검사(constraint validation)를 정의합니다. 각 입력의 validity 객체는 valueMissing, typeMismatch, patternMismatch, tooShort처럼 어떤 규칙이 실패했는지를 알려 줍니다. checkValidity()는 결과를 돌려주면서 실패한 요소에 invalid 이벤트를 보내고, reportValidity()는 같은 이벤트를 보낸 뒤 그 이벤트가 취소되지 않으면 문제를 사용자에게 알리는 브라우저의 기본 표시까지 띄웁니다. 비밀번호 확인처럼 속성으로 나타낼 수 없는 규칙은 setCustomValidity('비밀번호가 일치하지 않습니다.')로 이 체계에 넣을 수 있는데, 빈 문자열을 넘겨야 그 오류가 지워진다는 점에 주의합니다(HTML 표준의 제약 조건 검사).
이 글처럼 자바스크립트로 오류를 보여 주려면 <form noValidate>로 제출할 때의 브라우저 검사를 끌 수 있습니다. 표준에 따르면 이 속성은 제출하는 동안 폼을 검사하지 않게 할 뿐이므로, 브라우저가 판단한 validity의 결과는 여전히 읽어 자바스크립트 규칙과 합칠 수 있습니다. 또 CSS 선택자 명세의 :user-invalid는 사용자가 값을 바꾸거나 폼을 제출하려 하는 등 입력과 충분히 상호작용한 뒤에야 유효하지 않은 입력과 맞으므로, 한 번도 건드리지 않은 빈 필드까지 처음부터 빨갛게 표시하는 문제를 CSS 쪽에서 줄이는 데 쓸 수 있습니다.
useReducer와 커스텀 훅으로 유효성 검사
앞의 두 글에서 다룬 useReducer나 커스텀 훅(useForm)은 유효성 검사 로직을 한곳에 모아 관리하는 데 효과적입니다. useReducer라면 리듀서 안에서 필드의 상태가 바뀔 때 유효성을 검사하고 오류 메시지의 상태도 함께 갱신하도록 로직을 더할 수 있습니다. 커스텀 훅이라면 폼 상태 커스텀 훅과 라이브러리의 useForm처럼 유효성 검사 함수를 훅의 인자로 받아 handleSubmit 안에서 부르고 오류를 처리하는 방식이 가장 일반적입니다. 이런 방식은 코드의 중복을 줄이고 폼 로직을 한곳에 모아 유지보수를 쉽게 합니다.
정리
- 클라이언트 측 유효성 검사는 빠른 피드백과 불필요한 요청의 감소를 위한 것이며, 보안과 데이터 무결성의 최종 판단은 서버의 검증이 맡습니다.
- 필수 필드와 비밀번호 규칙, 필드 사이의 일치는 각각의 검사로 나누고, 같은 검증 함수를 입력 변경과 제출에서 함께 씁니다.
- 제출 여부는 방금 저장한 오류 상태가 아니라 현재 값으로 계산한 지역 결과로 정합니다.
formValid처럼 다른 상태에서 계산할 수 있는 값은 state와 Effect로 맞추지 않고 렌더할 때 계산합니다.onBlur에서 필드를,onSubmit에서 전체를 검사하는 방식이 피드백과 방해 사이의 균형을 맞춥니다.
연습 문제
1. 지역 결과로 제출 판정하기
비밀번호와 확인 입력까지 연결한 SimpleValidationForm에서, 사용자 이름이 공백 세 칸, 이메일이 [email protected], 비밀번호와 확인이 Abcdef1!라면 제출될까요? 이어서 이름을 Mina로 바꾸고 확인은 그대로 둔 채 비밀번호만 Abcdef2!로 바꾼 뒤 제출하면, 비밀번호 자체의 규칙과 두 필드의 관계 가운데 무엇이 실패할까요? 비밀번호와 확인이 둘 다 abcdefgh라면 둘이 같다는 이유만으로 제출할 수 있을까요?
해설 보기
공백뿐인 이름은 trim()하면 빈 값이므로 이름 오류가 생겨 제출을 막습니다. 이름을 Mina로 바꾸고 비밀번호가 Abcdef2!, 확인이 Abcdef1!라면 비밀번호 자체의 규칙은 통과하지만 일치 검사가 실패합니다. abcdefgh는 길이 8은 통과하지만 처음 실패하는 대문자 규칙의 오류가 있으므로, 확인 값이 같아도 제출하지 않습니다.
비밀번호의 오류와 확인의 오류를 현재 값에서 계산해 지역 valid에 함께 반영하며, setter를 부른 직후의 이전 오류나 이전 제출의 formValid 상태를 읽어 결론을 내리지 않습니다. 실제 화면에서는 입력의 id와 메시지의 id를 aria-describedby로 잇고 해당 필드에 aria-invalid를 표시해 색 말고도 단서를 주며, 버튼만 잠그면 무엇을 고칠지 알 수 없으므로 메시지를 입력 가까이에 보여 줍니다.
2. 비밀번호 규칙의 첫 실패
validatePassword에 ABCDEFG1!, Abcdefg1, Abc1!를 차례로 넣으면 각각 어떤 메시지를 돌려줄까요?
해설 보기
ABCDEFG1!는 아홉 자이고 대문자가 있지만 소문자가 없으므로 “비밀번호는 최소 하나의 소문자를 포함해야 합니다.”를 돌려줍니다. Abcdefg1은 길이와 대문자, 소문자, 숫자는 통과하지만 허용한 특수 문자가 없으므로 특수 문자 오류를 돌려줍니다. Abc1!는 다섯 자라서 다른 규칙을 보기 전에 “비밀번호는 최소 8자 이상이어야 합니다.”를 돌려줍니다.
다음 글 회원가입 폼 만들기에서는 React Hook Form과 Yup으로 필드 규칙을 공유하고, 접근 가능한 오류 표시와 제출 잠금, 서버 오류 복구를 갖춘 회원가입 폼을 만들어 9장을 마무리합니다.