폼과 검증
본 문서는 폼 구현 수단, 검증 규칙의 위치, 프론트엔드와 서버 검증의 역할 분담을 정의합니다.
1. 구현 수단
Signal Forms(@angular/forms/signals)를 사용합니다. Reactive Forms(FormGroup, FormControl)의 신규 사용은 금지합니다.
Angular 22에서 @publicApi 22.0으로 안정화되었고, 시그널 기반이라 서버 상태와 클라이언트 상태에서 정한 상태 규칙과 흔들림 없이 맞물립니다. Reactive Forms를 쓰면 RxJS와 시그널 사이에 변환 계층이 생깁니다.
@angular/forms/signals/compat는 ControlValueAccessor 기반 서드파티 컴포넌트와 연결할 때만 사용합니다. 사용 시 그 이유를 코드 주석으로 남깁니다. Spartan helm 컨트롤은 Signal Forms와 직접 연결되므로 이 계층이 필요 없습니다.
2. 스키마
검증 규칙은 스키마 하나에 모읍니다. 컴포넌트 여기저기에 흩어진 조건문으로 검증하는 것을 금지합니다.
// pages/assessment-create/model/assessment-form.ts
export const assessmentSchema = schema<AssessmentDraft>((p) => {
required(p.title, { message: '제목을 입력해 주세요.' });
maxLength(p.title, 100);
required(p.siteId);
minDate(p.assessedAt, new Date(2020, 0, 1));
disabled(p.approverId, ({ valueOf }) => !valueOf(p.siteId));
});| 규칙 | 내용 |
|---|---|
| 위치 | 폼을 사용하는 슬라이스의 model 세그먼트 |
| 파일명 | <도메인>-form.ts. 도메인 기반 명명을 따릅니다 |
| 재사용 | 두 화면 이상이 같은 스키마를 쓰면 entities/<도메인>/model/로 승격합니다 |
조건부 활성화(disabled), 표시 여부(hidden), 읽기 전용(readonly)도 스키마에 둡니다. 템플릿에서 [disabled]를 조건식으로 계산하면 규칙이 두 곳에 나뉩니다.
3. 프론트엔드 검증과 서버 검증
중요
프론트엔드 검증은 서버 검증을 대체하지 않습니다
프론트엔드 검증의 목적은 즉시 피드백입니다. 사용자가 제출하기 전에 잘못된 입력을 알려 왕복을 줄입니다. 클라이언트 코드는 조작 가능하므로 검증을 우회한 요청이 서버에 도달할 수 있습니다. 서버가 같은 규칙을 독립적으로 검증해야 하며, 프론트엔드에 검증이 있으니 서버가 느슨해도 된다는 판단은 금지합니다.
3.1 규칙 중복의 처리
같은 규칙이 프론트엔드와 서버 양쪽에 존재하는 것은 정상이며 제거 대상이 아닙니다. 두 검증은 목적이 다릅니다.
| 프론트엔드 | 서버 | |
|---|---|---|
| 목적 | 즉시 피드백 | 데이터 무결성 |
| 신뢰 수준 | 신뢰하지 않음 | 최종 판정 |
| 누락 시 | 사용자 경험 저하 | 데이터 손상 |
중복을 없애려고 프론트엔드 검증을 생략하면 사용자가 제출 후에야 오류를 알게 되고, 서버 검증을 생략하면 데이터가 손상됩니다.
3.2 프론트엔드가 검증하는 것
| 검증 | 프론트엔드 | 서버 |
|---|---|---|
| 필수 입력 | 함 | 함 |
| 형식 (이메일, 전화번호, 날짜) | 함 | 함 |
| 길이·범위 제한 | 함 | 함 |
| 필드 간 관계 (시작일 < 종료일) | 함 | 함 |
| 중복 확인 (아이디 사용 가능 여부) | 서버 조회로 | 함 |
| 권한에 따른 입력 허용 여부 | 하지 않음 | 함 |
| 다른 데이터와의 정합성 | 하지 않음 | 함 |
마지막 두 항목은 프론트엔드가 판정할 근거를 갖고 있지 않습니다. 판정에 필요한 데이터가 화면에 없거나, 있어도 조작 가능하기 때문입니다.
4. 서버 검증 결과의 표시
4.1 제출 시점
제출 응답이 400·422이면 서버가 내려준 필드별 메시지를 해당 필드에 표시합니다. 필드를 특정할 수 없는 오류는 폼 상단에 표시합니다.
서버 응답의 필드명과 폼 필드의 대응은 한 곳에서 처리합니다. 각 화면이 응답을 파싱해 필드를 찾는 코드를 반복하면 응답 형식이 바뀔 때 전부 고쳐야 합니다.
4.2 입력 중 확인
아이디 중복처럼 서버에 물어야 하는 검증은 validateHttp를 사용합니다.
validateHttp(p.loginId, {
request: ({ value }) => value() ? `/api/accounts/check?loginId=${value()}` : undefined,
errors: (res: CheckResponse) => res.available ? undefined : { kind: 'duplicated' },
});
debounce(p.loginId, 'blur');debounce를 함께 지정합니다. 지정하지 않으면 타이핑마다 요청이 나갑니다. 값 확정 시점에만 물어도 되는 검증은 'blur'를 사용합니다.
5. 표시 규칙
| 항목 | 규칙 |
|---|---|
| 오류 표시 시점 | 해당 필드를 벗어난 뒤(touched). 입력 중 즉시 표시하지 않습니다 |
| 오류 위치 | 필드 바로 아래 |
| 메시지 연결 | aria-describedby로 필드와 메시지를 연결합니다 |
| 오류 상태 전달 | aria-invalid를 설정합니다 |
| 제출 실패 시 | 첫 오류 필드로 포커스를 이동합니다 |
| 색 사용 | 색만으로 오류를 표시하지 않습니다. 아이콘과 문구를 함께 둡니다 |
입력 중 즉시 오류를 띄우면 다 입력하기 전부터 빨간 메시지가 뜹니다. 접근성 세부 기준은 접근성이 원본입니다.
6. 제출
| 규칙 | 내용 |
|---|---|
| 중복 제출 차단 | 진행 중에는 제출 버튼을 비활성화합니다 |
| 제출 버튼 상시 활성 | 검증 실패 상태에서도 버튼은 누를 수 있게 둡니다 |
| 성공 후 | 서버 상태를 무효화하고 이동하거나 결과를 표시합니다 |
| 이탈 확인 | 저장하지 않은 변경이 있으면 CanDeactivateFn으로 확인합니다 |
검증에 실패했다고 제출 버튼을 비활성화하는 것을 금지합니다. 사용자는 왜 눌리지 않는지 알 수 없으며, 스크린리더 사용자에게는 버튼이 존재하지 않는 것과 같습니다. 누르면 오류를 보여주고 첫 오류로 포커스를 옮기는 편이 안내가 명확합니다.
제출 성공 후의 서버 상태 무효화는 서버 상태와 클라이언트 상태 3.2절을 따릅니다.
7. 입력 컴포넌트
shared/ui의 입력 컴포넌트는 Signal Forms의 필드와 연결됩니다. 컴포넌트 자체는 검증 규칙을 갖지 않으며 값과 오류 상태를 표시할 뿐입니다.
7.1 필드 조립
컨트롤을 날것의 div로 감싸지 않고 필드 래퍼를 사용합니다. 래퍼가 레이블, 컨트롤 자리, 설명, 오류 표시를 제공하고 검증 상태를 호스트에 반영하므로 5절의 표시 규칙이 자동으로 충족됩니다.
<form [formRoot]="assessmentForm">
<div hlmField>
<label hlmFieldLabel for="title">제목</label>
<input hlmInput id="title" [formField]="assessmentForm.title" />
@for (error of assessmentForm.title().errors(); track error) {
<hlm-field-error [validator]="error.kind">{{ error.message }}</hlm-field-error>
}
</div>
</form><form>에 [formRoot], 컨트롤에 [formField]를 바인딩하고 오류는 필드의 errors() 시그널에서 읽습니다. Reactive Forms의 invalid·touched 플래그를 사용하지 않습니다.
관련된 체크박스나 라디오 묶음은 <fieldset>으로 감싸 하나의 범례와 공통 검증을 갖게 합니다.
7.2 컨트롤 선택
| 입력 성격 | 컨트롤 |
|---|---|
| 자유 텍스트 | input · textarea |
| 접두·접미 요소가 붙는 입력 | input-group |
| 많은 항목 중 하나 | select · combobox · autocomplete |
| 적은 항목(2~7개) 중 하나 | radio-group · toggle-group |
| 참·거짓 | switch · checkbox |
| 수치 범위 | slider |
| 일회용 코드 | input-otp |
선택지가 일곱 개 이하이면 드롭다운보다 toggle-group이나 radio-group을 우선합니다. 항목이 화면에 모두 보여 열어 보지 않아도 선택지를 파악할 수 있습니다.
7.3 도메인 검증의 위치
shared/ui에 도메인 검증을 넣는 것을 금지합니다. shared는 비즈니스 로직이 없는 인프라이기 때문입니다. 사업자번호 형식 검증은 shared/ui/input이 아니라 그것을 사용하는 슬라이스의 스키마에 둡니다.
8. 확인이 필요한 항목
| 항목 | 확인할 내용 | 영향 |
|---|---|---|
validateHttp의 요청 취소 |
값이 빠르게 바뀔 때 이전 요청이 취소되는지 | 취소되지 않으면 경쟁 상태 처리를 직접 해야 합니다 |
9. 금지 사항
| 금지 | 사유 |
|---|---|
| Reactive Forms 신규 사용 | 시그널 흐름과 변환 계층이 생깁니다 |
| 검증 조건을 컴포넌트에 분산 | 규칙 위치가 일의적이지 않게 됩니다 |
템플릿에서 [disabled] 조건 계산 |
규칙이 스키마와 템플릿으로 나뉩니다 |
| 프론트엔드 검증을 근거로 서버 검증 생략 | 클라이언트는 조작 가능합니다 |
| 검증 실패 시 제출 버튼 비활성화 | 이유를 알 수 없고 접근성이 저하됩니다 |
| 입력 중 즉시 오류 표시 | 입력을 마치기 전부터 오류가 보입니다 |
debounce 없는 validateHttp |
타이핑마다 요청이 나갑니다 |
shared/ui에 도메인 검증 포함 |
인프라 계층에 비즈니스 로직이 들어갑니다 |
필드 래퍼 없이 날것의 div로 조립 |
레이블 연결과 오류 표시를 매번 직접 배선하게 됩니다 |
| 오류를 임의 텍스트로 표시 | 표시 형식이 화면마다 달라집니다 |