앵귤러 중급 강좌 #1 Reactive Forms와 폼 검증

8 분 소요

기초 강좌 #7에서 HttpClient까지 다루며 작은 앵귤러 앱의 핵심 도구를 한 번씩 살펴봤습니다. 폼 입력은 그동안 [(ngModel)]로 짧게 처리해 왔습니다. 그런데 실무 폼에 들어가면 곧 한계를 만납니다. 검증 규칙이 여러 개 겹치고, 에러 메시지를 필드별로 다르게 보여줘야 하고, 태그 입력처럼 항목이 동적으로 늘어나는 폼이 나오기 시작합니다. 이때 진짜 무기를 꺼낼 차례입니다.

앵귤러 중급 강좌의 첫 글에서는 그 무기인 Reactive Forms를 정리합니다. 모델을 코드로 직접 정의해 폼을 다루는 방식이라, 큰 폼,복잡한 검증,동적 폼에 모두 잘 맞습니다.

두 가지 폼 방식 — 한 줄 비교 #

앵귤러는 폼을 다루는 두 가지 공식 방식을 제공합니다.

  • Template-driven Forms — 템플릿에 [(ngModel)]을 붙이고 앵귤러가 뒤에서 모델을 만들어줍니다. 작은 폼,간단한 검증에 가볍습니다.
  • Reactive Forms — TypeScript 코드에서 FormGroup,FormControl로 모델을 직접 만들고, 템플릿이 그 모델에 연결됩니다. 모델이 명시적이라 큰 폼,동적 폼,테스트에 강합니다.

이번 글은 Reactive Forms에 집중하고, Template-driven은 다음 글 #2에서 따로 다루겠습니다. 한쪽만 골라야 하는 건 아니지만, 실무 프로젝트라면 Reactive를 기본으로 잡고 가는 편이 안전합니다.

ReactiveFormsModule 셋업 #

Standalone 컴포넌트에서는 ReactiveFormsModule을 컴포넌트의 imports 배열에 직접 추가합니다. 모듈을 따로 만들 필요가 없습니다.

src/app/signup-form.component.ts
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 {}
노트
예전 NgModule 방식에서는 AppModuleimportsReactiveFormsModule을 한 번만 등록했습니다. Standalone 시대에는 사용하는 컴포넌트마다 필요한 모듈을 직접 import합니다. 처음에는 번거로워 보이지만, 컴포넌트가 자기 의존성을 스스로 들고 다닌다는 점이 큰 장점입니다.

FormControl과 FormGroup — 가장 기본 단위 #

Reactive Forms의 출발점은 두 클래스입니다.

  • FormControl — 입력 필드 하나의 상태(값,검증,에러,dirty/touched 등)를 들고 있는 단위
  • FormGroup — 여러 FormControl을 이름으로 묶어 하나의 폼으로 다루는 단위

가장 단순하게는 FormControl 하나로도 시작할 수 있습니다.

단일 입력
import { FormControl } from '@angular/forms';

name = new FormControl('');

하지만 실무 폼은 거의 항상 여러 필드를 묶기 때문에 FormGroup을 자주 씁니다. 가독성을 위해 헬퍼인 FormBuilder를 쓰는 패턴이 표준입니다.

src/app/signup-form.component.ts
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으로 이름을 매칭합니다.

src/app/signup-form.component.html
<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/formsValidators에는 자주 쓰는 검증기가 미리 들어 있습니다.

  • 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 #

FormControlFormGroup은 단순히 값만 들고 있는 게 아닙니다. 사용자와의 상호작용 상태도 같이 추적합니다.

  • 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을 풀어쓰는 게 길어지면, 컴포넌트에 게터를 만드는 패턴이 흔합니다.

getter로 짧게
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 객체를 돌려주는 함수입니다.

src/app/validators/forbidden-name.validator.ts
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')]],

조금 더 실전적인 예제로 두 필드의 값이 같은지 확인하는 그룹 단위 검증기를 봅시다. 비밀번호 확인 입력에 자주 쓰는 패턴입니다.

src/app/validators/match.validator.ts
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 };
  };
}
FormGroup 자체에 거는 검증기
form = this.fb.group(
  {
    password: ['', [Validators.required, Validators.minLength(8)]],
    passwordConfirm: ['', Validators.required],
  },
  { validators: matchValidator('password', 'passwordConfirm') },
);

이렇게 그룹 자체에 거는 검증기는 form.errors?.['mismatch']로 읽어 화면에 표시할 수 있습니다.

FormArray — 동적 항목 #

폼 항목 수가 미리 정해지지 않을 때, 예컨대 태그를 자유롭게 추가/삭제하는 입력에는 FormArray를 씁니다. 이름이 Array라서 짐작 가능하듯, 컨트롤들을 인덱스로 다루는 컬렉션입니다.

src/app/tag-form.component.ts
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과 인덱스 매칭으로 풀어줍니다.

src/app/tag-form.component.html
<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)을 반환하는 비동기 검증기를 씁니다.

src/app/validators/email-taken.validator.ts
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.pendingtrue가 되니, 화면에 “확인 중…” 같은 표시를 띄울 수 있습니다.

마무리 #

이번 글에서는 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와 비교해보면 두 방식 중 어디에 무엇을 쓰면 되는지 감이 잡히실 것입니다. 중급 강좌의 본격적인 시작입니다 — 천천히 따라와 주세요.

X