앵귤러 중급 강좌 #1 Reactive Forms와 폼 검증
기초 강좌 #7에서 HttpClient까지 다루며 작은 앵귤러 앱의 핵심 도구를 한 번씩 살펴봤습니다. 폼 입력은 그동안 [(ngModel)]로 짧게 처리해 왔습니다. 그런데 실무 폼에 들어가면 곧 한계를 만납니다. 검증 규칙이 여러 개 겹치고, 에러 메시지를 필드별로 다르게 보여줘야 하고, 태그 입력처럼 항목이 동적으로 늘어나는 폼이 나오기 시작합니다. 이때 진짜 무기를 꺼낼 차례입니다.
앵귤러 중급 강좌의 첫 글에서는 그 무기인 Reactive Forms를 정리합니다. 모델을 코드로 직접 정의해 폼을 다루는 방식이라, 큰 폼,복잡한 검증,동적 폼에 모두 잘 맞습니다.
두 가지 폼 방식 — 한 줄 비교 #
앵귤러는 폼을 다루는 두 가지 공식 방식을 제공합니다.
- Template-driven Forms — 템플릿에
[(ngModel)]을 붙이고 앵귤러가 뒤에서 모델을 만들어줍니다. 작은 폼,간단한 검증에 가볍습니다. - Reactive Forms — TypeScript 코드에서
FormGroup,FormControl로 모델을 직접 만들고, 템플릿이 그 모델에 연결됩니다. 모델이 명시적이라 큰 폼,동적 폼,테스트에 강합니다.
이번 글은 Reactive Forms에 집중하고, Template-driven은 다음 글 #2에서 따로 다루겠습니다. 한쪽만 골라야 하는 건 아니지만, 실무 프로젝트라면 Reactive를 기본으로 잡고 가는 편이 안전합니다.
ReactiveFormsModule 셋업 #
Standalone 컴포넌트에서는 ReactiveFormsModule을 컴포넌트의 imports 배열에 직접 추가합니다. 모듈을 따로 만들 필요가 없습니다.
import { Component } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {}AppModule의 imports에 ReactiveFormsModule을 한 번만 등록했습니다. Standalone 시대에는 사용하는 컴포넌트마다 필요한 모듈을 직접 import합니다. 처음에는 번거로워 보이지만, 컴포넌트가 자기 의존성을 스스로 들고 다닌다는 점이 큰 장점입니다.FormControl과 FormGroup — 가장 기본 단위 #
Reactive Forms의 출발점은 두 클래스입니다.
FormControl— 입력 필드 하나의 상태(값,검증,에러,dirty/touched 등)를 들고 있는 단위FormGroup— 여러FormControl을 이름으로 묶어 하나의 폼으로 다루는 단위
가장 단순하게는 FormControl 하나로도 시작할 수 있습니다.
import { FormControl } from '@angular/forms';
name = new FormControl('');하지만 실무 폼은 거의 항상 여러 필드를 묶기 때문에 FormGroup을 자주 씁니다. 가독성을 위해 헬퍼인 FormBuilder를 쓰는 패턴이 표준입니다.
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-signup-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: ['', [Validators.required, Validators.minLength(8)]],
});
}fb.group의 각 항목은 [초깃값, 검증기들] 형식의 배열입니다. 이 한 덩어리만 보면 폼의 구조와 규칙이 모두 드러납니다. 모델이 코드로 명시되어 있다는 게 Reactive Forms의 핵심 매력입니다.
템플릿과 연결하기 #
템플릿은 그 모델에 연결만 해주면 됩니다. [formGroup]으로 그룹을 묶고, 각 입력에는 formControlName으로 이름을 매칭합니다.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<label>
이메일
<input type="email" formControlName="email" />
</label>
<label>
비밀번호
<input type="password" formControlName="password" />
</label>
<button type="submit" [disabled]="form.invalid">가입</button>
</form>onSubmit() {
if (this.form.invalid) return;
console.log('가입 정보:', this.form.value);
}form.value로 전체 값을 한 번에 꺼내고, form.invalid로 제출 버튼을 막습니다. 컴포넌트 코드와 템플릿이 같은 모델을 공유하니, 어느 쪽이든 폼 상태를 자유롭게 다룰 수 있습니다.
검증 (Validators) #
@angular/forms의 Validators에는 자주 쓰는 검증기가 미리 들어 있습니다.
Validators.required— 비어 있지 않을 것Validators.minLength(n)/Validators.maxLength(n)— 길이 제한Validators.email— 이메일 형식Validators.pattern(regex)— 정규식 매칭Validators.min(n)/Validators.max(n)— 숫자 범위
여러 검증기를 한 필드에 걸려면 배열로 넘기면 끝입니다. (옛 자료에 자주 보이는 Validators.compose([...])는 사실 같은 일을 하는 헬퍼라 굳이 쓰지 않아도 됩니다.)
form = this.fb.group({
email: ['', [Validators.required, Validators.email]],
password: [
'',
[
Validators.required,
Validators.minLength(8),
Validators.pattern(/(?=.*[A-Z])(?=.*\d)/), // 대문자 1+ 숫자 1+
],
],
});폼 상태 읽기 — touched / dirty / valid / errors #
FormControl과 FormGroup은 단순히 값만 들고 있는 게 아닙니다. 사용자와의 상호작용 상태도 같이 추적합니다.
touched— 사용자가 한 번이라도 포커스를 줬다 뺐는지 (blur 발생 후true)dirty— 사용자가 값을 한 번이라도 바꿨는지valid/invalid— 검증 통과 여부errors— 어떤 검증기가 실패했는지 객체로 ({ required: true },{ minlength: { ... } }등)
이를 조합해 “사용자가 손댄 후에만 에러 메시지를 띄우는” 자연스러운 UX를 만들 수 있습니다.
<label>
이메일
<input type="email" formControlName="email" />
@if (form.controls.email.touched && form.controls.email.invalid) {
@if (form.controls.email.errors?.['required']) {
<small class="error">이메일을 입력하세요.</small>
} @else if (form.controls.email.errors?.['email']) {
<small class="error">이메일 형식이 올바르지 않습니다.</small>
}
}
</label>매번 form.controls.email을 풀어쓰는 게 길어지면, 컴포넌트에 게터를 만드는 패턴이 흔합니다.
get email() { return this.form.controls.email; }@if (email.touched && email.errors?.['required']) {
<small class="error">이메일을 입력하세요.</small>
}touched) 보여주는 편이 자연스럽습니다. 페이지를 열자마자 빨간 메시지가 깔리면 사용자에게 잔소리하는 느낌이 들기 때문입니다. 단, 제출 시점에는 markAllAsTouched()로 모든 필드를 한번에 touched로 만들어 누락 항목을 한꺼번에 알려주는 패턴이 자주 쓰입니다.커스텀 Validator #
내장 검증기로 안 되는 규칙은 직접 만들면 됩니다. Validator는 결국 하나의 함수입니다 — AbstractControl을 받아서, 통과하면 null, 실패하면 ValidationErrors 객체를 돌려주는 함수입니다.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function forbiddenName(forbidden: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
const value = (control.value ?? '').toString().toLowerCase();
return value.includes(forbidden.toLowerCase())
? { forbiddenName: { forbidden } }
: null;
};
}함수가 또 다른 함수를 반환하는 모양이 어색해 보일 수 있는데, 이렇게 한 단계 감싸면 외부 인자(forbidden)를 받는 검증기를 만들 수 있습니다. 사용은 다른 검증기와 똑같습니다.
nickname: ['', [Validators.required, forbiddenName('admin')]],조금 더 실전적인 예제로 두 필드의 값이 같은지 확인하는 그룹 단위 검증기를 봅시다. 비밀번호 확인 입력에 자주 쓰는 패턴입니다.
import { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
export function matchValidator(a: string, b: string): ValidatorFn {
return (group: AbstractControl): ValidationErrors | null => {
const valueA = group.get(a)?.value;
const valueB = group.get(b)?.value;
return valueA === valueB ? null : { mismatch: true };
};
}form = this.fb.group(
{
password: ['', [Validators.required, Validators.minLength(8)]],
passwordConfirm: ['', Validators.required],
},
{ validators: matchValidator('password', 'passwordConfirm') },
);이렇게 그룹 자체에 거는 검증기는 form.errors?.['mismatch']로 읽어 화면에 표시할 수 있습니다.
FormArray — 동적 항목 #
폼 항목 수가 미리 정해지지 않을 때, 예컨대 태그를 자유롭게 추가/삭제하는 입력에는 FormArray를 씁니다. 이름이 Array라서 짐작 가능하듯, 컨트롤들을 인덱스로 다루는 컬렉션입니다.
import { Component, inject } from '@angular/core';
import {
FormArray,
FormBuilder,
FormControl,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
@Component({
selector: 'app-tag-form',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl: './tag-form.component.html',
})
export class TagFormComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
title: ['', Validators.required],
tags: this.fb.array<FormControl<string>>([]),
});
get tags() {
return this.form.controls.tags;
}
addTag() {
this.tags.push(this.fb.control('', { nonNullable: true }));
}
removeTag(i: number) {
this.tags.removeAt(i);
}
}템플릿에서는 formArrayName과 인덱스 매칭으로 풀어줍니다.
<form [formGroup]="form">
<input formControlName="title" placeholder="제목" />
<div formArrayName="tags">
@for (tag of tags.controls; track $index; let i = $index) {
<div>
<input [formControlName]="i" placeholder="태그" />
<button type="button" (click)="removeTag(i)">삭제</button>
</div>
}
</div>
<button type="button" (click)="addTag()">+ 태그 추가</button>
</form>addTag로 새 컨트롤을 push하고, removeTag로 인덱스를 빼냅니다. 검증기는 FormControl마다 따로 걸 수도 있고, FormArray 자체에 걸어 “최소 한 개 이상” 같은 규칙을 만들 수도 있습니다.
비동기 검증 (AsyncValidator) #
이메일 중복 체크처럼 서버에 물어봐야 결과를 알 수 있는 검증은 동기 함수로는 풀리지 않습니다. 이때는 Observable(또는 Promise)을 반환하는 비동기 검증기를 씁니다.
import { AsyncValidatorFn, AbstractControl } from '@angular/forms';
import { debounceTime, map, switchMap, of, take } from 'rxjs';
import { UserService } from '../user.service';
export function emailTakenValidator(users: UserService): AsyncValidatorFn {
return (control: AbstractControl) =>
of(control.value).pipe(
debounceTime(300),
switchMap(value =>
value ? users.checkEmailTaken(value) : of(false),
),
map(taken => (taken ? { emailTaken: true } : null)),
take(1),
);
}email: [
'',
{
validators: [Validators.required, Validators.email],
asyncValidators: [emailTakenValidator(inject(UserService))],
updateOn: 'blur',
},
],비동기 검증은 사용자가 한 글자 칠 때마다 서버를 두드리지 않도록 debounceTime을 두거나, updateOn: 'blur'로 포커스를 뗄 때만 검증을 트리거하는 식으로 호출 빈도를 낮추는 게 일반적입니다. 검증 진행 중에는 control.pending이 true가 되니, 화면에 “확인 중…” 같은 표시를 띄울 수 있습니다.
마무리 #
이번 글에서는 Reactive Forms의 핵심을 한 번 살펴봤습니다.
ReactiveFormsModule을 standalone 컴포넌트의imports에 추가FormBuilder.group({...})로 모델을 코드에 명시- 템플릿은
[formGroup]+formControlName으로 모델에 연결 Validators는 배열로 합쳐서 한 필드에 여러 개touched+errors로 자연스러운 에러 메시지 UX- 커스텀 Validator는
(control) => ValidationErrors | null시그니처의 함수 - 동적 항목은
FormArray, 서버 의존 검증은AsyncValidator
다음 글 #2 Template-driven Forms에서는 짧은 폼이나 간단한 화면에 잘 어울리는 Template-driven 방식을 정리합니다. Reactive와 비교해보면 두 방식 중 어디에 무엇을 쓰면 되는지 감이 잡히실 것입니다. 중급 강좌의 본격적인 시작입니다 — 천천히 따라와 주세요.