심화 가이드
폼

반응형 폼

반응형 폼은 시간이 지나면서 값이 바뀌는 폼 입력을 모델 중심으로 처리하는 방식입니다. 이 문서에서는 기본 폼 컨트롤을 생성하고 갱신하는 방법, 여러 컨트롤을 그룹으로 묶는 방법, 폼 값의 유효성을 검사하는 방법, 실행 중에 컨트롤을 추가하거나 제거할 수 있는 동적 폼을 만드는 방법을 설명합니다.

반응형 폼 개요

반응형 폼은 특정 시점의 폼 상태를 명시적이고 불변인 방식으로 관리합니다. 폼 상태가 바뀔 때마다 새로운 상태가 반환되기 때문에 상태가 바뀌는 동안에도 모델의 무결성이 유지됩니다. 반응형 폼은 옵저버블 스트림을 기반으로 구성되며, 폼 입력과 값은 동기적으로 접근할 수 있는 스트림으로 제공됩니다.

반응형 폼은 데이터를 요청할 때 일관되고 예측 가능한 데이터를 보장하기 때문에 테스트하기도 간편합니다. 이 스트림의 소비자는 데이터를 안전하게 다룰 수 있습니다.

반응형 폼은 템플릿 기반 폼과 몇 가지 차이가 있습니다. 반응형 폼에서는 데이터 모델에 동기적으로 접근하며, 옵저버블 연산자로 불변성을 유지하고, 옵저버블 스트림으로 변화를 추적합니다.

템플릿 기반 폼에서는 템플릿에서 데이터에 직접 접근해서 수정할 수 있습니다. 하지만 템플릿에 포함된 디렉티브와 변경 가능한 데이터를 활용해서 변화를 비동기적으로 추적하기 때문에 반응형 폼만큼 명시적이지 않습니다. 두 방식을 자세하게 비교한 내용은 폼 개요 문서를 참고하세요.

기본 폼 컨트롤 추가하기

폼 컨트롤을 사용하려면 다음 세 단계를 진행합니다.

  1. 새 컴포넌트를 생성하고 반응형 폼 모듈을 등록합니다. 이 모듈에는 반응형 폼을 사용하는 데 필요한 디렉티브가 선언되어 있습니다.
  2. 새 FormControl 인스턴스를 생성합니다.
  3. 템플릿에 FormControl을 등록합니다.

그런 다음 템플릿에 컴포넌트를 추가하면 폼을 표시할 수 있습니다.

다음 예제에서는 폼 컨트롤 하나를 추가하는 방법을 알아봅니다. 사용자가 입력 필드에 이름을 입력하면 이 입력값을 받아서 폼 컨트롤 엘리먼트의 현재 값을 표시하는 예제입니다.

  1. 새 컴포넌트를 생성하고 ReactiveFormsModule 불러오기

    CLI 명령 ng generate component로 프로젝트에 컴포넌트를 생성하세요. 그리고 @angular/forms 패키지에서 ReactiveFormsModule을 불러와 컴포넌트의 imports 배열에 추가하세요.

    name-editor.component.ts (일부)

    import {FormControl, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-name-editor',
      templateUrl: './name-editor.component.html',
      styleUrls: ['./name-editor.component.css'],
      imports: [ReactiveFormsModule],
    })
    export class NameEditorComponent {
  2. FormControl 인스턴스 선언하기

    FormControl 생성자로 초기값을 지정하세요. 이 예제에서는 빈 문자열로 지정합니다. 컴포넌트 클래스에 컨트롤을 생성하면 폼 입력 상태에 바로 접근해서 변화를 감지하고, 상태를 갱신하고, 유효성을 검사할 수 있습니다.

    name-editor.component.ts

    import {Component} from '@angular/core';
    
    import {FormControl, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-name-editor',
      templateUrl: './name-editor.component.html',
      styleUrls: ['./name-editor.component.css'],
      imports: [ReactiveFormsModule],
    })
    export class NameEditorComponent {
      name = new FormControl('');
    }
  3. 템플릿에 컨트롤 등록하기

    컴포넌트 클래스에 컨트롤을 생성했으면 템플릿의 폼 컨트롤 엘리먼트와 연결해야 합니다. FormControlDirective가 제공하는 formControl 바인딩으로 템플릿에 폼 컨트롤을 연결하세요. FormControlDirective도 ReactiveFormsModule에 포함되어 있습니다.

    name-editor.component.html

    <label for="name">Name: </label>
    <input id="name" type="text" [formControl]="name" />

    템플릿 바인딩 문법을 사용하면 템플릿의 name 입력 엘리먼트에 폼 컨트롤이 등록됩니다. 폼 컨트롤과 DOM 엘리먼트는 서로 정보를 주고받기 때문에 모델의 변화는 뷰에 반영되고, 뷰의 변화는 모델에 반영됩니다.

  4. 컴포넌트 표시하기

    템플릿에 <app-name-editor> 컴포넌트를 추가하면 name 프로퍼티에 할당한 FormControl이 표시됩니다.

    app.component.html (이름 편집기)

    <app-name-editor />

폼 컨트롤 값 표시하기

값은 다음과 같은 방법으로 표시할 수 있습니다.

  • valueChanges 옵저버블을 사용하면 템플릿에서는 AsyncPipe로, 컴포넌트 클래스에서는 subscribe() 메서드로 폼 값의 변화를 감지할 수 있습니다.
  • value 프로퍼티를 사용하면 현재 값의 스냅샷을 가져올 수 있습니다.

다음 예제는 템플릿에서 문자열 바인딩으로 현재 값을 표시하는 방법입니다.

name-editor.component.html (컨트롤 값)

<p>Value: {{ name.value }}</p>

폼 컨트롤 엘리먼트를 갱신하면 표시되는 값도 바뀝니다.

반응형 폼은 각 인스턴스의 프로퍼티와 메서드로 해당 컨트롤에 관한 정보를 제공합니다. 기반 클래스인 AbstractControl의 프로퍼티와 메서드를 사용하면 폼 상태를 제어하고, 입력 유효성 검사를 처리하면서 메시지를 표시할 시점을 결정할 수 있습니다.

FormControl의 다른 프로퍼티와 메서드는 API 레퍼런스를 참고하세요.

폼 컨트롤 값 교체하기

반응형 폼에는 컨트롤 값을 코드로 변경하는 메서드가 있기 때문에 사용자 입력 없이도 유연하게 값을 갱신할 수 있습니다. 폼 컨트롤 인스턴스가 제공하는 setValue() 메서드는 폼 컨트롤의 값을 갱신하고, 전달한 값의 구조가 컨트롤 구조와 일치하는지 검사합니다. 예를 들어 백엔드 API나 서비스에서 폼 데이터를 가져왔다면, setValue() 메서드로 기존 값을 완전히 교체해서 컨트롤을 새 값으로 갱신할 수 있습니다.

다음 예제는 setValue() 메서드로 컨트롤 값을 Nancy로 갱신하는 메서드를 컴포넌트 클래스에 추가합니다.

name-editor.component.ts (값 갱신)

updateName() {
  this.name.setValue('Nancy');
}

이름을 갱신해 볼 수 있도록 템플릿에 버튼을 추가하세요. Update Name 버튼을 클릭하면 폼 컨트롤 엘리먼트에 입력되는 값이 현재 값으로 반영됩니다.

name-editor.component.html (값 갱신)

<button type="button" (click)="updateName()">Update Name</button>

폼 모델은 컨트롤 값의 원본 데이터를 관리합니다. 따라서 버튼을 클릭하면 컴포넌트 클래스에서 입력값을 변경하면서 현재 값을 덮어씁니다.

참고: 이 예제에서는 컨트롤 하나를 사용합니다. 폼 그룹이나 폼 배열 인스턴스에서 setValue() 메서드를 사용할 때는 전달하는 값이 그룹이나 배열의 구조와 일치해야 합니다.

폼 컨트롤 그룹으로 묶기

폼에는 일반적으로 서로 관련된 여러 컨트롤이 있습니다. 반응형 폼은 관련된 컨트롤 여러 개를 하나의 입력 폼으로 묶는 두 가지 방식을 제공합니다.

폼 그룹 설명
폼 그룹 함께 관리할 고정된 컨트롤 묶음으로 폼을 구성합니다. 폼 그룹의 기본 사용법은 이 섹션에서 설명합니다. 폼 그룹을 중첩하면 더 복잡한 폼을 만들 수도 있습니다.
폼 배열 실행 중에 컨트롤을 추가하거나 제거할 수 있는 동적 폼을 구성합니다. 폼 배열을 중첩하면 더 복잡한 폼을 만들 수도 있습니다. 이 방식은 동적 폼 만들기를 참고하세요.

폼 컨트롤 인스턴스로 입력 필드 하나를 제어하는 것처럼, 폼 그룹 인스턴스는 폼 컨트롤 인스턴스를 묶은 그룹(예: 폼)의 상태를 추적합니다. 폼 그룹 인스턴스에 속한 각 컨트롤은 폼 그룹을 생성할 때 지정한 이름으로 추적합니다. 다음 예제는 폼 컨트롤 인스턴스 여러 개를 하나의 그룹으로 관리하는 방법입니다.

ProfileEditor 컴포넌트를 생성하고 @angular/forms 패키지에서 FormGroup과 FormControl 클래스를 불러오세요.

ng generate component ProfileEditor

profile-editor.component.ts (불러오기)

import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';

@Component({
  selector: 'app-profile-editor',
  templateUrl: './profile-editor.component.html',
  styleUrls: ['./profile-editor.component.css'],
  imports: [ReactiveFormsModule],
})
export class ProfileEditorComponent {

다음 단계를 따라 컴포넌트에 폼 그룹을 추가하세요.

  1. FormGroup 인스턴스를 생성합니다.
  2. FormGroup 모델과 뷰를 연결합니다.
  3. 폼 데이터를 저장합니다.

  1. FormGroup 인스턴스 생성하기

    컴포넌트 클래스에 profileForm 프로퍼티를 생성하고 새 폼 그룹 인스턴스를 할당하세요. 폼 그룹을 초기화하려면 각 컨트롤의 이름을 키로, 해당 컨트롤을 값으로 지정한 객체를 생성자에 전달합니다.

    프로필 폼에는 firstName과 lastName이라는 이름의 폼 컨트롤 인스턴스 두 개를 추가하세요.

    profile-editor.component.ts (폼 그룹)

    import {Component} from '@angular/core';
    import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-profile-editor',
      templateUrl: './profile-editor.component.html',
      styleUrls: ['./profile-editor.component.css'],
      imports: [ReactiveFormsModule],
    })
    export class ProfileEditorComponent {
      profileForm = new FormGroup({
        firstName: new FormControl(''),
        lastName: new FormControl(''),
      });
    }

    이제 개별 폼 컨트롤이 하나의 그룹으로 묶였습니다. FormGroup 인스턴스는 그룹에 속한 각 컨트롤의 값을 하나의 객체로 모아서 모델 값으로 제공합니다. 폼 그룹 인스턴스는 폼 컨트롤 인스턴스와 마찬가지로 value, untouched 같은 프로퍼티와 setValue() 같은 메서드를 제공합니다.

  2. FormGroup 모델과 뷰 연결하기

    폼 그룹은 각 컨트롤의 상태와 변화를 추적합니다. 따라서 컨트롤 하나가 바뀌면 부모 컨트롤도 새로운 상태나 값의 변화를 방출합니다. 그룹의 모델은 그룹에 속한 컨트롤을 기반으로 유지됩니다. 모델을 정의했으면 템플릿을 수정해서 모델이 뷰에 반영되도록 해야 합니다.

    profile-editor.component.html (템플릿 폼 그룹)

    <form [formGroup]="profileForm">
      <label for="first-name">First Name: </label>
      <input id="first-name" type="text" formControlName="firstName" />
    
      <label for="last-name">Last Name: </label>
      <input id="last-name" type="text" formControlName="lastName" />
    
    </form>

    폼 그룹이 여러 컨트롤을 묶는 것처럼, profileForm FormGroup은 FormGroup 디렉티브로 form 엘리먼트에 바인딩됩니다. 이렇게 하면 모델과 입력 엘리먼트가 있는 폼 사이에서 정보를 주고받는 연결 계층이 만들어집니다. FormControlName 디렉티브가 제공하는 formControlName 입력 프로퍼티는 각 입력 엘리먼트를 FormGroup에 정의된 폼 컨트롤과 연결합니다. 폼 컨트롤은 각자의 엘리먼트와 정보를 주고받습니다. 그리고 모델 값의 원본 데이터를 관리하는 폼 그룹 인스턴스에도 변화를 전달합니다.

  3. 폼 데이터 저장하기

    ProfileEditor 컴포넌트는 사용자 입력을 받지만, 실제 애플리케이션에서는 폼 값을 가져와 컴포넌트 밖에서 추가로 처리할 수 있어야 합니다. FormGroup 디렉티브는 form 엘리먼트가 발생시키는 submit 이벤트를 감지하고, 콜백 함수를 바인딩할 수 있는 ngSubmit 이벤트를 발생시킵니다. form 태그에 ngSubmit 이벤트 리스너를 추가하고 onSubmit() 메서드를 콜백으로 연결하세요.

    profile-editor.component.html (제출 이벤트)

    <form [formGroup]="profileForm" (ngSubmit)="onSubmit()">

    ProfileEditor 컴포넌트의 onSubmit() 메서드는 profileForm의 현재 값을 가져옵니다. output()을 사용하면 폼을 캡슐화하면서 컴포넌트 밖으로 폼 값을 전달할 수 있습니다. 다음 예제에서는 console.warn으로 브라우저 콘솔에 메시지를 출력합니다.

    profile-editor.component.ts (제출 메서드)

    onSubmit() {
      // TODO: Use output() with form value
      console.warn(this.profileForm.value);
    }

    submit 이벤트는 form 태그에서 발생하는 기본 DOM 이벤트입니다. 타입이 submit인 버튼을 클릭하면 이 이벤트가 발생합니다. 따라서 사용자는 Enter 키를 눌러 작성한 폼을 제출할 수도 있습니다.

    폼을 제출할 수 있도록 폼 아래쪽에 button 엘리먼트로 버튼을 추가하세요.

    profile-editor.component.html (제출 버튼)

    <p>Complete the form to enable button.</p>
    <button type="submit" [disabled]="!profileForm.valid">Submit</button>

    위 코드의 버튼에는 disabled 바인딩도 연결되어 있어 profileForm이 유효하지 않으면 버튼이 비활성화됩니다. 아직 유효성 검사를 수행하지 않기 때문에 지금은 버튼이 항상 활성화됩니다. 기본적인 폼 유효성 검사는 폼 입력 유효성 검사하기 섹션에서 설명합니다.

  4. 컴포넌트 표시하기

    폼이 있는 ProfileEditor 컴포넌트를 표시하려면 컴포넌트 템플릿에 추가하세요.

    app.component.html (프로필 편집기)

    <app-profile-editor />

    ProfileEditor에서는 폼 그룹 인스턴스 안에서 firstName과 lastName 컨트롤의 폼 컨트롤 인스턴스를 관리할 수 있습니다.

폼 그룹 중첩하기

폼 그룹은 개별 폼 컨트롤 인스턴스뿐 아니라 다른 폼 그룹 인스턴스도 자식으로 가질 수 있습니다. 따라서 복잡한 폼 모델도 논리적으로 묶어서 구성하고 유지보수하기 쉽습니다.

복잡한 폼을 구성할 때는 정보를 작은 영역으로 나누면 관리하기 쉽습니다. 폼 그룹 인스턴스를 중첩하면 큰 폼 그룹을 관리하기 쉬운 작은 그룹으로 나눌 수 있습니다.

더 복잡한 폼을 구성하려면 다음 단계를 진행하세요.

  1. 중첩 그룹을 생성합니다.
  2. 템플릿에서 중첩 폼을 그룹으로 묶습니다.

일부 정보는 자연스럽게 같은 그룹으로 묶입니다. 이름과 주소는 중첩 그룹으로 묶기 좋은 대표적인 예이며, 다음 예제에서도 사용합니다.

  1. 중첩 그룹 생성하기

    profileForm에 중첩 그룹을 생성하려면 폼 그룹 인스턴스에 address 엘리먼트를 중첩해서 추가하세요.

    profile-editor.component.ts (중첩 폼 그룹)

    import {Component} from '@angular/core';
    import {FormGroup, FormControl, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'app-profile-editor',
      templateUrl: './profile-editor.component.html',
      styleUrls: ['./profile-editor.component.css'],
      imports: [ReactiveFormsModule],
    })
    export class ProfileEditorComponent {
      profileForm = new FormGroup({
        firstName: new FormControl(''),
        lastName: new FormControl(''),
        address: new FormGroup({
          street: new FormControl(''),
          city: new FormControl(''),
          state: new FormControl(''),
          zip: new FormControl(''),
        }),
      });
    }

    이 예제에서는 address group을 추가해서 기존 firstName, lastName 컨트롤과 새로운 street, city, state, zip 컨트롤을 함께 구성합니다. 폼 그룹의 address 엘리먼트는 전체 profileForm 엘리먼트의 자식이지만, 값과 상태가 바뀔 때 적용되는 규칙은 같습니다. 중첩 폼 그룹에서 상태와 값이 바뀌면 부모 폼 그룹으로 전파되기 때문에 전체 모델의 일관성이 유지됩니다.

  2. 템플릿에서 중첩 폼을 그룹으로 묶기

    컴포넌트 클래스의 모델을 갱신했으면 폼 그룹 인스턴스와 입력 엘리먼트가 연결되도록 템플릿을 수정하세요. ProfileEditor 템플릿에 street, city, state, zip 필드가 있는 address 폼 그룹을 추가하세요.

    profile-editor.component.html (템플릿 중첩 폼 그룹)

    <div formGroupName="address">
      <h2>Address</h2>
    
      <label for="street">Street: </label>
      <input id="street" type="text" formControlName="street" />
    
      <label for="city">City: </label>
      <input id="city" type="text" formControlName="city" />
    
      <label for="state">State: </label>
      <input id="state" type="text" formControlName="state" />
    
      <label for="zip">Zip Code: </label>
      <input id="zip" type="text" formControlName="zip" />
    </div>

    ProfileEditor 폼은 하나의 그룹으로 표시되지만, 모델은 논리적으로 묶인 영역을 표현하도록 더 작게 나뉩니다.

    컴포넌트 템플릿에서 value 프로퍼티와 JsonPipe로 폼 그룹 인스턴스의 값을 표시하세요.

데이터 모델 일부 갱신하기

컨트롤이 여러 개 있는 폼 그룹 인스턴스의 값을 갱신할 때는 모델의 일부만 갱신하고 싶을 수 있습니다. 이 섹션에서는 폼 컨트롤 데이터 모델의 특정 부분만 갱신하는 방법을 알아봅니다.

모델 값을 갱신하는 방법은 두 가지입니다.

메서드 설명
setValue() 개별 컨트롤에 새 값을 지정합니다. setValue() 메서드는 폼 그룹의 구조를 엄격하게 따르며 컨트롤의 값 전체를 교체합니다.
patchValue() 전달한 객체에 정의된 프로퍼티에 해당하는 폼 모델 값을 교체합니다.

setValue() 메서드는 엄격하게 검사하기 때문에 복잡한 폼의 중첩 구조에서 발생한 오류를 발견하는 데 도움이 됩니다. 반면 patchValue()는 이런 오류가 발생해도 알리지 않고 실패합니다.

ProfileEditorComponent에서 다음 예제처럼 updateProfile 메서드를 사용하면 사용자의 이름과 도로명 주소를 갱신할 수 있습니다.

profile-editor.component.ts (값 일부 갱신)

updateProfile() {
  this.profileForm.patchValue({
    firstName: 'Nancy',
    address: {
      street: '123 Drew Street',
    },
  });
}

원할 때 사용자 프로필을 갱신해 볼 수 있도록 템플릿에 버튼을 추가하세요.

profile-editor.component.html (값 갱신)

<button type="button" (click)="updateProfile()">Update Profile</button>

사용자가 버튼을 클릭하면 profileForm 모델의 firstName과 street가 새 값으로 갱신됩니다. street는 address 프로퍼티 안의 객체에 지정한다는 점에 주의하세요. patchValue() 메서드는 모델 구조에 맞춰 변경사항을 적용하기 때문에 이 구조를 따라야 합니다. patchValue()는 폼 모델에 정의된 프로퍼티만 갱신합니다.

FormBuilder 서비스로 컨트롤 생성하기

여러 폼을 다루다 보면 폼 컨트롤 인스턴스를 직접 생성하는 작업이 반복될 수 있습니다. FormBuilder 서비스는 컨트롤을 간편하게 생성하는 메서드를 제공합니다.

다음 단계를 따라 이 서비스를 사용해 보세요.

  1. FormBuilder 클래스를 불러옵니다.
  2. FormBuilder 서비스를 의존성으로 주입합니다.
  3. 폼 내용을 생성합니다.

다음 예제에서는 폼 빌더 서비스로 폼 컨트롤과 폼 그룹 인스턴스를 생성하도록 ProfileEditor 컴포넌트를 리팩토링합니다.

  1. FormBuilder 클래스 불러오기

    @angular/forms 패키지에서 FormBuilder 클래스를 불러오세요.

    profile-editor.component.ts (불러오기)

    import {FormBuilder, ReactiveFormsModule} from '@angular/forms';
  2. FormBuilder 서비스 주입하기

    FormBuilder 서비스는 반응형 폼 모듈에서 제공하는 주입 가능한 프로바이더입니다. inject() 함수로 이 의존성을 컴포넌트에 주입하세요.

    profile-editor.component.ts (프로퍼티 초기화)

    private formBuilder = inject(FormBuilder);
  3. 폼 컨트롤 생성하기

    FormBuilder 서비스에는 control(), group(), record(), array()라는 네 가지 메서드가 있습니다. 이 메서드는 컴포넌트 클래스에서 폼 컨트롤, 폼 그룹, 폼 레코드, 폼 배열 인스턴스를 생성하는 팩토리 메서드입니다. group 메서드로 profileForm의 컨트롤을 생성하세요.

    profile-editor.component.ts (폼 빌더)

    profileForm = this.formBuilder.group({
      firstName: [''],
      lastName: [''],
      address: this.formBuilder.group({
        street: [''],
        city: [''],
        state: [''],
        zip: [''],
      }),
    });

    위 예제에서는 같은 객체를 group() 메서드에 전달해서 모델의 프로퍼티를 정의합니다. 각 컨트롤 이름에 대응하는 값은 배열이며, 배열의 첫 번째 항목이 초기값입니다.

    팁: 초기값만으로 컨트롤을 정의할 수도 있습니다. 하지만 컨트롤에 동기 또는 비동기 유효성 검사가 필요하다면 배열의 두 번째와 세 번째 항목에 각각 동기 유효성 검사기와 비동기 유효성 검사기를 추가하세요. 폼 빌더를 사용하는 방식과 인스턴스를 직접 생성하는 방식을 비교해 보세요.

폼 입력 유효성 검사하기

폼 유효성 검사는 사용자 입력이 빠짐없이 올바르게 작성되었는지 확인할 때 사용합니다. 이 섹션에서는 폼 컨트롤에 유효성 검사기 하나를 추가하고 전체 폼 상태를 표시하는 방법을 알아봅니다. 폼 유효성 검사에 관한 더 자세한 내용은 폼 유효성 검사 문서를 참고하세요.

폼 유효성 검사를 추가하려면 다음 단계를 진행하세요.

  1. 폼 컴포넌트에 유효성 검사 함수를 불러옵니다.
  2. 폼의 필드에 유효성 검사기를 추가합니다.
  3. 유효성 검사 상태를 처리하는 로직을 추가합니다.

가장 흔한 유효성 검사는 필드를 필수로 지정하는 것입니다. 다음 예제에서는 firstName 컨트롤에 필수 입력 검사를 추가하고 유효성 검사 결과를 표시합니다.

  1. 유효성 검사 함수 불러오기

    반응형 폼에는 자주 사용하는 유효성 검사 함수가 포함되어 있습니다. 이 함수는 검사할 컨트롤을 인자로 받아 유효성 검사 결과에 따라 오류 객체나 null 값을 반환합니다.

    @angular/forms 패키지에서 Validators 클래스를 불러오세요.

    profile-editor.component.ts (불러오기)

    import {Validators} from '@angular/forms';
  2. 필수 필드 지정하기

    ProfileEditor 컴포넌트에서 firstName 컨트롤 배열의 두 번째 항목에 Validators.required 정적 메서드를 추가하세요.

    profile-editor.component.ts (필수 입력 검사기)

    private formBuilder = inject(FormBuilder);
    
    profileForm = this.formBuilder.group({
      firstName: ['', Validators.required],
      lastName: [''],
      address: this.formBuilder.group({
        street: [''],
        city: [''],
        state: [''],
        zip: [''],
      }),
    });
  3. 폼 상태 표시하기

    폼 컨트롤에 필수 필드를 추가하면 초기 상태는 유효하지 않은 상태입니다. 이 상태는 부모 폼 그룹 엘리먼트로 전파되기 때문에 부모도 유효하지 않은 상태가 됩니다. 폼 그룹 인스턴스의 현재 상태는 status 프로퍼티로 확인할 수 있습니다.

    문자열 바인딩으로 profileForm의 현재 상태를 표시하세요.

    profile-editor.component.html (상태 표시)

    <p>Form Status: {{ profileForm.status }}</p>

    필수 폼 컨트롤인 firstName 때문에 profileForm이 유효하지 않으므로 Submit 버튼은 비활성화됩니다. firstName 입력란을 채우면 폼이 유효한 상태가 되고 Submit 버튼이 활성화됩니다.

    폼 유효성 검사가 더 궁금하면 폼 유효성 검사 문서를 참고하세요.

동적 폼 만들기

FormArray는 이름이 없는 컨트롤을 개수 제한 없이 관리할 때 FormGroup 대신 사용할 수 있습니다. 폼 그룹 인스턴스와 마찬가지로 폼 배열 인스턴스에도 컨트롤을 동적으로 추가하거나 제거할 수 있습니다. 폼 배열 인스턴스의 값과 유효성 검사 상태는 자식 컨트롤을 기준으로 계산됩니다. 하지만 각 컨트롤에 이름으로 된 키를 정의하지 않아도 되기 때문에 자식 값의 개수를 미리 알 수 없을 때 유용합니다.

동적 폼을 정의하려면 다음 단계를 진행하세요.

  1. FormArray 클래스를 불러옵니다.
  2. FormArray 컨트롤을 정의합니다.
  3. getter 메서드로 FormArray 컨트롤에 접근합니다.
  4. 템플릿에 폼 배열을 표시합니다.

다음 예제는 ProfileEditor에서 별칭(aliases) 배열을 관리하는 방법입니다.

  1. FormArray 클래스 불러오기

    타입 정보로 사용할 FormArray 클래스를 @angular/forms에서 불러오세요. FormBuilder 서비스는 FormArray 인스턴스를 생성할 준비가 되어 있습니다.

    profile-editor.component.ts (불러오기)

    import {FormArray} from '@angular/forms';
  2. FormArray 컨트롤 정의하기

    배열에 컨트롤을 정의하면 컨트롤이 없는 폼 배열부터 여러 컨트롤이 있는 폼 배열까지 원하는 개수로 초기화할 수 있습니다. profileForm의 폼 그룹 인스턴스에 aliases 프로퍼티를 추가해서 폼 배열을 정의하세요.

    FormBuilder.array() 메서드로 배열을 정의하고, FormBuilder.control() 메서드로 배열에 초기 컨트롤을 추가하세요.

    profile-editor.component.ts (별칭 폼 배열)

    private formBuilder = inject(FormBuilder);
    
    profileForm = this.formBuilder.group({
      firstName: ['', Validators.required],
      lastName: [''],
      address: this.formBuilder.group({
        street: [''],
        city: [''],
        state: [''],
        zip: [''],
      }),
      aliases: this.formBuilder.array([this.formBuilder.control('')]),
    });

    이제 폼 그룹 인스턴스의 aliases 컨트롤에는 컨트롤이 하나 들어 있습니다. 이후에 컨트롤을 동적으로 더 추가할 수 있습니다.

  3. FormArray 컨트롤에 접근하기

    각 인스턴스를 가져올 때마다 profileForm.get() 메서드를 호출하는 대신 getter로 폼 배열 인스턴스의 별칭에 접근할 수 있습니다. 폼 배열 인스턴스는 개수가 정해지지 않은 컨트롤을 배열로 표현합니다. getter를 사용하면 컨트롤에 편리하게 접근할 수 있고, 다른 컨트롤에도 같은 방식을 간단하게 적용할 수 있습니다.

    getter 문법으로 aliases 클래스 프로퍼티를 생성해서 부모 폼 그룹에서 별칭의 폼 배열 컨트롤을 가져오세요.

    profile-editor.component.ts (별칭 getter)

    get aliases() {
      return this.profileForm.get('aliases') as FormArray;
    }

    반환되는 컨트롤의 타입은 AbstractControl이기 때문에 폼 배열 인스턴스의 메서드를 사용하려면 타입을 명시해야 합니다. 별칭의 폼 배열에 별칭 컨트롤을 동적으로 추가하는 메서드를 정의하세요. FormArray.push() 메서드는 컨트롤을 배열의 새 항목으로 추가합니다. FormArray.push()에 컨트롤 배열을 전달하면 여러 컨트롤을 한 번에 등록할 수도 있습니다.

    profile-editor.component.ts (별칭 추가)

    addAlias() {
      this.aliases.push(this.formBuilder.control(''));
    }

    템플릿에서는 각 컨트롤이 개별 입력 필드로 표시됩니다.

  4. 템플릿에 폼 배열 표시하기

    폼 모델의 별칭을 연결하려면 템플릿에 추가해야 합니다. FormGroupName이 제공하는 formGroupName 입력 프로퍼티와 비슷하게, formArrayName은 FormArrayName을 통해 폼 배열 인스턴스와 템플릿을 연결합니다.

    formGroupName 엘리먼트를 닫는 <div> 뒤에 다음 템플릿 HTML을 추가하세요.

    profile-editor.component.html (별칭 폼 배열 템플릿)

    <div formArrayName="aliases">
      <h2>Aliases</h2>
      <button type="button" (click)="addAlias()">+ Add another alias</button>
    
      @for (alias of aliases.controls; track $index; let i = $index) {
        <div>
          <!-- The repeated alias template -->
          <label for="alias-{{ i }}">Alias:</label>
          <input id="alias-{{ i }}" type="text" [formControlName]="i" />
        </div>
      }
    </div>

    @for 블록은 aliases 폼 배열 인스턴스에 있는 폼 컨트롤 인스턴스를 하나씩 순회합니다. 폼 배열의 항목에는 이름이 없기 때문에 인덱스를 i 변수에 할당한 다음 각 컨트롤에 전달해서 formControlName 입력 프로퍼티에 바인딩합니다.

    새 별칭 인스턴스가 추가될 때마다 새 폼 배열 인스턴스에는 인덱스를 기준으로 컨트롤이 연결됩니다. 따라서 최상위 컨트롤의 상태와 값을 계산할 때 각 컨트롤을 추적할 수 있습니다.

    참고: 존리스 애플리케이션에서 반응형 폼 모델을 변경해도(예: FormArray.push() 호출) 컴포넌트 변경 감지가 자동으로 예약되지는 않습니다. 템플릿이 aliases.controls처럼 모델 구조의 변화에 의존한다면, 컴포넌트가 Angular에 변경 감지 실행을 알리도록 해야 합니다. 예를 들어 폼 옵저버블을 ChangeDetectorRef.markForCheck()와 연결할 수 있습니다.

    import {ChangeDetectorRef, Component, inject} from '@angular/core';
    import {takeUntilDestroyed} from '@angular/core/rxjs-interop';
    
    @Component({/* ... */})
    export class ProfileEditor {
      private readonly cdr = inject(ChangeDetectorRef);
    
      constructor() {
        this.profileForm.valueChanges
          .pipe(takeUntilDestroyed())
          .subscribe(() => this.cdr.markForCheck());
      }
    }
  5. 최상위 폼 배열에 FormArrayDirective 사용하기

    FormArrayDirective를 사용하면 FormArray를 <form> 엘리먼트에 직접 바인딩할 수 있습니다.
    최상위 FormGroup 없이 배열 자체가 전체 폼 모델을 나타내는 경우에 유용합니다.

    import {Component} from '@angular/core';
    import {FormArray, FormControl, ReactiveFormsModule} from '@angular/forms';
    
    @Component({
      selector: 'form-array-example',
      imports: [ReactiveFormsModule],
      template: `
        <form [formArray]="form">
          @for (control of form.controls; track $index) {
            <input [formControlName]="$index" />
          }
        </form>
      `,
    })
    export class FormArrayExampleComponent {
      controls = [new FormControl('fish'), new FormControl('cat'), new FormControl('dog')];
    
      form = new FormArray(this.controls);
    }

  6. 별칭 추가하기

    처음에는 폼에 Alias 필드가 하나 있습니다. 필드를 추가하려면 Add Alias 버튼을 클릭하세요. 템플릿 아래쪽의 Form Value에 표시된 폼 모델을 통해 별칭 배열을 확인할 수도 있습니다. 별칭마다 폼 컨트롤 인스턴스를 사용하는 대신, 추가 필드가 있는 다른 폼 그룹 인스턴스를 구성할 수도 있습니다. 각 항목의 컨트롤을 정의하는 과정은 같습니다.

  7. 통합 컨트롤 상태 변경 이벤트

    모든 폼 컨트롤은 AbstractControl(FormControl, FormGroup, FormArray, FormRecord)의 events 옵저버블을 통해 컨트롤 상태 변경 이벤트를 하나의 통합 스트림으로 제공합니다. 이 통합 스트림을 사용하면 값, 상태, pristine, touched, 초기화 상태의 변화뿐 아니라 제출 같은 폼 수준의 동작에도 대응할 수 있습니다. 여러 옵저버블을 연결하는 대신 한 번의 구독으로 모든 갱신을 처리할 수 있습니다.

    이벤트 타입

    events가 방출하는 각 항목은 특정 이벤트 클래스의 인스턴스입니다.

    모든 이벤트 클래스는 ControlEvent를 상속하며, 변화가 시작된 AbstractControl을 가리키는 source 참조를 포함합니다. 이 참조는 큰 폼을 다룰 때 유용합니다.

    import {Component} from '@angular/core';
    import {
      FormControl,
      ValueChangeEvent,
      StatusChangeEvent,
      PristineChangeEvent,
      TouchedChangeEvent,
      FormResetEvent,
      FormSubmittedEvent,
      ReactiveFormsModule,
      FormGroup,
    } from '@angular/forms';
    
    @Component(/* ... */)
    export class UnifiedEventsBasicComponent {
      form = new FormGroup({
        username: new FormControl(''),
      });
    
      constructor() {
        this.form.events.subscribe((e) => {
          if (e instanceof ValueChangeEvent) {
            console.log('Value changed to: ', e.value);
          }
    
          if (e instanceof StatusChangeEvent) {
            console.log('Status changed to: ', e.status);
          }
    
          if (e instanceof PristineChangeEvent) {
            console.log('Pristine status changed to: ', e.pristine);
          }
    
          if (e instanceof TouchedChangeEvent) {
            console.log('Touched status changed to: ', e.touched);
          }
    
          if (e instanceof FormResetEvent) {
            console.log('Form was reset');
          }
    
          if (e instanceof FormSubmittedEvent) {
            console.log('Form was submitted');
          }
        });
      }
    }

    특정 이벤트 필터링하기

    일부 이벤트 타입만 필요하다면 RxJS 연산자를 사용하는 것이 좋습니다.

    import {filter} from 'rxjs/operators';
    import {StatusChangeEvent} from '@angular/forms';
    
    control.events
      .pipe(filter((e) => e instanceof StatusChangeEvent))
      .subscribe((e) => console.log('Status:', e.status));

    여러 구독을 하나로 통합하기

    변경 전

    import {combineLatest} from 'rxjs';
    
    combineLatest([control.valueChanges, control.statusChanges]).subscribe(([value, status]) => {
      /* ... */
    });

    변경 후

    control.events.subscribe((e) => {
      // ValueChangeEvent, StatusChangeEvent 등의 이벤트를 처리합니다.
    });

    참고: 값 변경 이벤트는 해당 컨트롤의 값이 갱신된 직후에 방출됩니다. 부모 컨트롤의 값(예: 이 FormControl이 FormGroup에 속한 경우)은 나중에 갱신됩니다. 따라서 이 이벤트의 콜백에서 부모 컨트롤의 value 프로퍼티에 접근하면 아직 갱신되지 않은 값을 가져올 수 있습니다. 이런 경우에는 부모 컨트롤의 events를 구독하세요.

    폼 컨트롤 상태 관리하기

    반응형 폼은 touched/untouched와 pristine/dirty로 컨트롤 상태를 추적합니다. Angular는 DOM 상호작용이 발생하면 이 상태를 자동으로 갱신하지만, 코드로 직접 관리할 수도 있습니다.

    markAsTouched — 값을 바꾸지 않는 focus와 blur 이벤트로 상호작용한 상태를 나타내도록 컨트롤이나 폼을 touched로 표시합니다. 기본적으로 부모 컨트롤로 전파됩니다.

    // 사용자가 필드에서 벗어나면 유효성 검사 에러를 표시합니다.
    onEmailBlur() {
      const email = this.form.get('email');
      email.markAsTouched();
    }

    markAsUntouched — 컨트롤이나 폼을 untouched로 표시합니다. 모든 자식 컨트롤에 적용되고, 모든 부모 컨트롤의 touched 상태를 다시 계산합니다.

    // 제출에 성공하면 폼 상태를 초기화합니다.
    onSubmitSuccess() {
      this.form.markAsUntouched();
      this.form.markAsPristine();
    }

    markAsDirty — 값이 변경되었다는 의미로 컨트롤이나 폼을 dirty로 표시합니다. 기본적으로 부모 컨트롤로 전파됩니다.

    // 코드로 바꾼 값을 변경된 상태로 표시합니다.
    autofillAddress() {
      const previousAddress = getAddress();
      this.form.patchValue(previousAddress, { emitEvent: false });
      this.form.markAsDirty();
    }

    markAsPristine — 컨트롤이나 폼을 pristine으로 표시합니다. 모든 자식 컨트롤을 pristine으로 표시하고, 모든 부모 컨트롤의 pristine 상태를 다시 계산합니다.

    // 저장 후 pristine 상태를 초기화해서 새로운 변경사항을 추적합니다.
    saveForm() {
      this.api.save(this.form.value).subscribe(() => {
        this.form.markAsPristine();
      });
    }

    markAllAsDirty — 컨트롤이나 폼과 그 아래에 있는 모든 하위 컨트롤을 dirty로 표시합니다.

    // 가져온 데이터를 dirty 상태로 표시합니다.
    loadData(data: FormData) {
      this.form.patchValue(data);
      this.form.markAllAsDirty();
    }

    markAllAsTouched — 컨트롤이나 폼과 그 아래에 있는 모든 하위 컨트롤을 touched로 표시합니다. 전체 폼의 유효성 검사 오류를 표시할 때 유용합니다.

    // 제출하기 전에 모든 유효성 검사 에러를 표시합니다.
    onSubmit() {
      if (this.form.invalid) {
        this.form.markAllAsTouched();
        return;
      }
      this.saveForm();
    }

    이벤트 방출과 전파 제어하기

    폼 컨트롤을 코드로 갱신할 때는 변화가 폼 계층에 전파되는 방식과 이벤트 방출 여부를 세밀하게 제어할 수 있습니다.

    이벤트 방출 이해하기

    기본값은 emitEvent: true이므로 컨트롤이 바뀔 때마다 valueChanges와 statusChanges 옵저버블에서 이벤트가 방출됩니다. emitEvent: false로 설정하면 이 이벤트가 방출되지 않습니다. 자동 저장 같은 반응형 동작을 실행하지 않고 코드로 값을 설정하거나, 컨트롤 사이의 순환 갱신을 방지하거나, 일괄 갱신이 끝날 때 이벤트를 한 번만 방출하려는 경우에 유용합니다.

    @Component({/* ... */})
    export class BlogPostEditor {
      postForm = new FormGroup({
        title: new FormControl(''),
        content: new FormControl(''),
      });
    
      constructor() {
        // 사용자가 입력할 때마다 초안을 자동으로 저장합니다.
        this.postForm.valueChanges.subscribe((formValue) => {
          this.autosaveDraft(formValue);
        });
      }
    
      loadExistingDraft(savedDraft: {title: string; content: string}) {
        // 자동 저장을 실행하지 않고 초안을 복원합니다.
        this.postForm.setValue(savedDraft, {emitEvent: false});
      }
    }

    전파 제어 이해하기

    기본값은 onlySelf: false이므로 갱신이 부모 컨트롤로 전파되어 부모의 값과 유효성 검사 상태도 다시 계산됩니다. onlySelf: true로 설정하면 현재 컨트롤만 갱신하고 부모에게 알리지 않습니다. 일괄 작업이 끝난 뒤 부모의 갱신을 직접 한 번만 실행하고 싶을 때 유용합니다.

    updatePostalCodeValidator(country: string) {
      const postal = this.addressForm.get('postalCode');
    
      const validators = country === 'US'
        ? [Validators.maxLength(5)]
        : [Validators.maxLength(7)];
    
      postal.setValidators(validators);
      postal.updateValueAndValidity({ onlySelf: true, emitEvent: false });
    }

    참고: 실행 중에 유효성 검사기를 동적으로 관리하는 방법은 폼 유효성 검사 문서의 반응형 폼에서 유효성 검사기 동적으로 관리하기 섹션을 참고하세요.

    폼 컨트롤 타입을 좁히는 유틸리티 함수

    Angular는 AbstractControl의 구체적인 타입을 확인하는 유틸리티 함수 네 개를 제공합니다. 이 함수는 타입 가드로 동작하며, true를 반환하면 컨트롤의 타입이 좁혀집니다. 따라서 같은 블록 안에서 하위 타입에만 있는 프로퍼티에도 안전하게 접근할 수 있습니다.

    유틸리티 함수 설명
    isFormControl 컨트롤이 FormControl이면 true를 반환합니다.
    isFormGroup 컨트롤이 FormGroup이면 true를 반환합니다.
    isFormRecord 컨트롤이 FormRecord이면 true를 반환합니다.
    isFormArray 컨트롤이 FormArray이면 true를 반환합니다.

    이 함수는 커스텀 유효성 검사기에서 특히 유용합니다. 함수 시그니처는 AbstractControl을 받지만, 실제 로직은 특정 종류의 컨트롤을 처리하도록 작성하는 경우가 있기 때문입니다.

    import {AbstractControl, isFormArray} from '@angular/forms';
    
    export function positiveValues(control: AbstractControl) {
      if (!isFormArray(control)) {
        return null; // FormArray가 아니므로 이 유효성 검사기를 적용하지 않습니다.
      }
    
      // 타입을 좁힌 뒤에는 FormArray 전용 API에 안전하게 접근할 수 있습니다.
      const hasNegative = control.controls.some((c) => c.value < 0);
      return hasNegative ? {positiveValues: true} : null;
    }

    반응형 폼 API 요약

    다음 표는 반응형 폼 컨트롤을 생성하고 관리하는 데 사용하는 기반 클래스와 서비스를 정리한 것입니다. 자세한 문법은 Forms 패키지의 API 레퍼런스를 참고하세요.

    클래스

    클래스 설명
    AbstractControl 구체적인 폼 컨트롤 클래스인 FormControl, FormGroup, FormArray의 추상 기반 클래스입니다. 공통 동작과 프로퍼티를 제공합니다.
    FormControl 개별 폼 컨트롤의 값과 유효성 상태를 관리합니다. <input>, <select> 같은 HTML 폼 컨트롤에 대응합니다.
    FormGroup AbstractControl 인스턴스 그룹의 값과 유효성 상태를 관리합니다. 그룹의 프로퍼티에는 자식 컨트롤이 포함됩니다. 컴포넌트의 최상위 폼은 FormGroup입니다.
    FormArray 숫자 인덱스로 접근하는 AbstractControl 인스턴스 배열의 값과 유효성 상태를 관리합니다.
    FormBuilder 컨트롤 인스턴스를 생성하는 팩토리 메서드를 제공하는 주입 가능한 서비스입니다.
    FormRecord 값의 타입이 같은 FormControl 인스턴스 모음의 값과 유효성 상태를 추적합니다.

    디렉티브

    디렉티브 설명
    FormControlDirective 독립적인 FormControl 인스턴스와 폼 컨트롤 엘리먼트를 동기화합니다.
    FormControlName 기존 FormGroup 인스턴스에 있는 FormControl을 이름으로 찾아 폼 컨트롤 엘리먼트와 동기화합니다.
    FormGroupDirective 기존 FormGroup 인스턴스와 DOM 엘리먼트를 동기화합니다.
    FormGroupName 중첩된 FormGroup 인스턴스와 DOM 엘리먼트를 동기화합니다.
    FormArrayName 중첩된 FormArray 인스턴스와 DOM 엘리먼트를 동기화합니다.
    FormArrayDirective 독립적인 FormArray 인스턴스와 DOM 엘리먼트를 동기화합니다.