Skip to content

1. 개요


사용자가 입력한 내용을 폼으로 받아 처리하는 동작은 대부분 앱에서 아주 중요한 기능이다. 사용자가 로그인하거나 프로필을 수정하는 작업, 개인정보를 입력하거나, 데이터를 다루는 작업 등 입력을 처리할 때 폼을 활용할 수 있다.


Angular는 반응형(reactive)과 템플릿 기반(template-driven), 두 가지 방식으로 폼을 제공한다. 두 방식 모두 사용자가 화면에서 입력한 이벤트를 감지하다가 입력한 내용의 유효성을 검사하며, 폼 모델을 생성하고 데이터 모델에 반영하는 동작을 반복한다.


이 문서를 보고 나면 어떤 방식으로 폼을 사용할지 결정하는 데에 도움이 될 것이다. 그리고 두 방식에 모두 사용되는 기본 구성요소에 대해서도 설명한다. 마지막에는 두 방식의 차이점에 대해 정리해보고, 각 방식으로 구성하는 방법, 데이터 흐름, 테스트 방법에 대해서도 안내한다.


1. 사전지식

이 가이드 문서를 제대로 이해하려면 이런 내용에 대해 먼저 이해하고 있는 것이 좋다.



2. 폼 동작 방식 선택하기

반응형 폼(Reactive form)과 템플릿 기반 폼(template-drive form)은 폼을 처리하는 방법과 데이터를 관리하는 방법이 다르다.


  • 반응형 폼은 폼 객체 모델에 직접 명시적으로 접근한다. 템플릿 기반 폼과 비교해보면 이 방식이 확실히 사용하기 편하다. 반응형 폼 방식은 확장하기 편하고, 재사용하기 쉬우며, 테스트하기 쉽다. 애플리케이션에서 폼이 중요한 역할을 하거나 애플리케이션을 반응형 패턴으로 구성했다면 반응형 폼을 사용하는 것이 좋다.
  • 템플릿 기반 폼은 템플릿에 디렉티브를 활용하는 방식이며 객체 모델은 디렉티브가 직접 관리한다. 이메일을 입력받는 정도로 폼 구성이 간단하다면 템플릿 기반으로도 충분하다. 하지만 폼 구성이 복잡해지면 반응형 폼처럼 확장하기는 어렵다. 구성이 간단한 폼을 템플릿 안에서만 동작하도록 구현하려면 템플릿 기반 폼을 사용하는 것이 좋다.


1) 차이점

아래 표를 보면서 반응형 폼과 템플릿 기반 폼의 차이점에 대해 확인해 보자.


반응형 폼 템플릿 기반 폼
폼 모델 구성방식 명시적으로 컴포넌트 클래스 안에서 생성 디렉티브 내부 로직이 생성
데이터 모델 구조가 명확하며 이뮤터블(immutable) 구조를 파악하기 어려우며 뮤터블(mutable)
데이터 흐름 동기 비동기
폼 유효성 검사 함수 디렉티브


2) 확장성

폼이 애플리케이션에서 중요한 역할을 한다면 폼을 확장할 수 있는지 여부가 아주 중요하다. 폼 모델을 재사용할 수 있느냐가 최우선 조건일 수도 있다.


반응형 폼은 템플릿 기반 폼과 비교했을 때 확장성이 좋다. 반응형 폼 방식을 사용하면 폼 API를 사용해서 폼에 직접 접근할 수 있으며, 폼 데이터 모델이 동기 방식으로 전달되기 때문에 구성이 복잡한 폼을 만들 때 유리하다. 그리고 반응형 폼은 유닛 테스트를 적용할 때 구성해야 할 것도 적고, 변화 감지 동작에 의해 폼이 갱신되는 방식이나 유효성 검사에 대해 간단하게만 알아도 쉽게 테스트할 수 있다.


반면에 템플릿 기반 폼은 간단한 시나리오에 사용하기에는 적합하지만 재사용하기 어렵다. 템플릿 기반 폼을 사용하면 폼 API를 활용할 수 없으며 폼 데이터 모델도 비동기로만 참조할 수 있다. 템플릿 기반 폼으로 구성한 컴포넌트를 테스트할 때는 원하는 대로 동작하는 것을 보장하기 위해 변화 감지 로직을 수동으로 실행해야 하며, 테스트 환경을 구성하는 것도 복잡하다.


3. 폼 모델 구성하기

반응형 폼과 템플릿 기반 폼 모두 사용자가 입력 엘리먼트에 입력한 내용을 추적하고 컴포넌트 모델에 있는 폼 데이터를 갱신한다. 이 때 두 방식에서 기본 구성 요소를 활용하더라도 사용하는 방식이 조금 다르다.


1) 공통으로 사용하는 클래스

반응형 폼과 템플릿 기반 폼에서 공통으로 활용하는 클래스들은 이런 것들이 있다.


  • FormControl: 개별 폼 컨트롤 값이 변경되는 것을 감지하며 유효성 검사 결과를 관리한다.
  • FormGroup: 폼 컨트롤 그룹의 값이 변경되는 것을 감지하며 유효성 검사 결과를 관리한다.
  • FormArray: 폼 컨트롤 배열의 값이 변경되는 것을 감지하며 유효성 검사 결과를 관리한다.
  • ControlValueAccessor: Angular FormControl과 DOM 엘리먼트를 연결하는 역할을 한다.


2) 반응형 폼 구성하기

반응형 폼을 구성할 때는 컴포넌트 클래스에 폼 모델을 직접 정의한다. [formControl] 디렉티브는 화면에 있는 폼 엘리먼트와 FormControl 인스턴스를 명시적으로 연결하며, 이 때 내부적으로 값 접근자(value accessor)를 활용한다.


아래 코드는 반응형 폼을 구성하는 방식으로 입력 필드 하나를 정의한 예제 코드이다. 폼 모델은 FormControl 인스턴스이다.


import { Component } from "@angular/core";
import { FormControl } from "@angular/forms";

@Component({
  selector: "app-reactive-favorite-color",
  template: `
    Favorite Color: <input type="text" [formControl]="favoriteColorControl" />
  `,
})
export class FavoriteColorComponent {
  favoriteColorControl = new FormControl("");
}


그림1에서 보듯이 반응형 폼은 폼 모델을 데이터 원천 소스로 활용한다. 그리고 입력 엘리먼트에 [formControl] 디렉티브를 적용하면 아무때나 폼 엘리먼트의 값과 유효성 검사 결과를 참조할 수 있다.


그림1. 반응형 폼은 폼 모델에 직접 접근한다.

001


3) 템플릿 기반 폼 구성하기

템플릿 기반 폼을 구성할 때는 폼 모델을 명시적으로 구성하지 않는다. 대신, 엘리먼트에 적용된 NgModel 디렉티브가 FormControl을 내부적으로 생성해서 관리한다.


아래 코드는 위 예제 코드와 비슷하게 동작하는 폼을 템플릿 기반 폼 방식으로 구성한 예제 코드이다.


import { Component } from "@angular/core";

@Component({
  selector: "app-template-favorite-color",
  template: `
    Favorite Color: <input type="text" [(ngModel)]="favoriteColor" />
  `,
})
export class FavoriteColorComponent {
  favoriteColor = "";
}


템플릿 기반 폼에서 데이터 원천 소스는 템플릿이다. 그리고 그림2에서 볼 수 있듯이 템플릿 기반 폼에서 만든 FormControl 인스턴스에는 직접 접근할 수 없다.


그림2. 템플릿 기반 폼은 폼 모델에 직접 접근하지 않는다.

002


4. 폼 안에서 이동하는 데이터의 흐름

애플리케이션에 폼이 있으면 Angular는 화면에 있는 값과 컴포넌트 모델에 있는 값을 정확하게 동기화해야 한다. 사용자가 화면에서 값을 변경하면 새로운 값으로 데이터 모델을 갱신해야 하며, 프로그램 로직이 데이터 모델의 값을 변경하면 변경된 값도 화면에 반영되어야 한다.


반응형 폼과 템플릿 기반 폼은 이런 경우에 데이터를 처리하는 방식이 다르다. 구성 방식에 따라 데이터가 어떻게 이동하는지 글미을 보면서 확인해 보자. 예제는 이전 섹션에서 살펴봤던 예제와 비슷하다.


1) 반응형 폼에서 데이터의 흐름

반응형 폼에서는 화면에 있는 개별 폼 엘리먼트가 폼 모델(ex. FormControl 인스턴스)과 하나씩 직접 연결된다. 그래서 화면에서 변경한 값이 모델에 반영되는 것과 모델에서 변경한 값이 화면에 반영되는 것은 화면이 어떻게 렌더링되는지와는 관계가 없다.


화면에 있는 입력 필드 값이 변경되면 이렇게 처리된다.


1] 사용자가 입력 엘리먼트에 값을 입력한다. 이 경우에는 Blue를 입력했다.

2] 폼 입력 엘리먼트가 변경된 값으로 "input" 이벤트를 발생시킨다.

3] 폼 입력 엘리먼트에 있는 값 접근자가 이 이벤트를 감지하면 FormControl 인스턴스에 새로운 값을 바로 반영한다.

4] FormControl 인스턴스가 valueChanges 옵저버블로 새로운 값을 보낸다.

5] valueChanges 옵저버블을 구독하는 구독자가 새 값을 받는다.


003


그리고 프로그램 로직으로 모델 값이 변경되면 이렇게 처리된다.


1] 사용자가 favoriteColorControl.setValue() 메서드를 실행하면 FormControl 인스턴스의 값을 변경한다.

2] FormControl 인스턴스가 valueChanges 옵저버블로 새로운 값을 보낸다.

3] valueChanges 옵저버블을 구독하는 구독자가 새 값을 받는다.

4] 폼 입력 엘리먼트에 있는 값 접근자가 새로운 값으로 엘리먼트를 갱신한다.


004


2) 템플릿 기반 폼에서 데이터의 흐름

템플릿 폼에서는 개별 폼 엘리먼트가 디렉티브와 연결되며 폼 모델은 디렉티브가 내부적으로 관리한다.


화면에 있는 입력 필드 값이 변경되면 이렇게 처리된다.


1] 사용자가 입력 엘리먼트에 Blue를 입력한다.

2] 입력 엘리먼트가 Blue 값으로 "input" 이벤트를 발생시킨다.

3] 입력 엘리먼트에 적용된 값 접근자가 FormControl 인스턴스의 setValue() 메서드를 실행한다.

4] FormControl 인스턴스가 valueChanges 옵저버블로 새로운 값을 보낸다.

5] valueChanges 옵저버블을 구독하는 구독자가 새 값을 받는다.

6] 값 접근자가 NgModel.viewToModelUpdate() 메서드를 실행하면 ngModelChange 이벤트가 발생한다.

7] 컴포넌트 템플릿은 컴포넌트 클래스에 있는 favoriteColor 프로퍼티와 양방향으로 바인딩되어 있기 때문에 컴포넌트 favoriteColor 프로퍼티가 ngModelChange 이벤트로 전달되는 Blue 값으로 갱신된다.


005


컴포넌트 프로퍼티 favoriteColor 값이 Blue에서 Red로 변경되면 이렇게 처리된다.


1] 컴포넌트에 있는 favoriteColor 값이 갱신되었다.

2] 변화 감지 로직이 실행된다.

3] 변화 감지 로직이 실행되는 동안 NgModel 디렉티브 인스턴스에 있는 ngOnChanges 라이프싸이클 후킹 함수가 실행된다. 이 디렉티브에 바인딩되는 입력값이 변경되었기 때문이다.

4] ngOnChanges() 메서드가 디렉티브 내부에 있는 FormControl 인스턴스 값을 변경하는 비동기 태스크를 큐에 넣는다.

5] 변화 감지 로직이 끝난다.

6] 다음 실행 싸이클에 FormControl 인스턴스 값을 변경하는 태스크가 실행된다.

7] FormControl 인스턴스가 valueChanges 옵저버블로 새로운 값을 보낸다.

8] valueChanges 옵저버블을 구독하는 구독자가 새로운 값을 받는다.

9] 화면에서 폼 입력 엘리먼트에 있는 값 접근자가 새로운 값으로 favoriteColor 값을 갱신한다.


006


3) 데이터 모델의 불변성

변화를 추적하는 메서드는 이런 역할을 한다.


  • 반응형 폼은 이뮤터블 데이터 구조를 활용해서 데이터 모델을 변하지 않도록 유지한다. 그래서 데이터 모델에서 값이 변경된 이벤트가 발생할 때마다 FormControl 인스턴스는 기존 데이터 모델을 변경하지 않고 새로운 데이터 모델을 반환한다. 이 데이터 모델은 옵저버블로 전달되기 때문에 실제로 변경된 것만 골라서 추적할 수 있다. 그리고 전체 필드 중에서 특정 필드가 변경된 것을 감지하는 방식으로도 활용할 수 있다. 데이터가 반응형 패턴으로 전달되기 때문에 옵저버블 연산자를 활용해서 자유롭게 조작할 수도 있다.
  • 템플릿 기반 폼은 데이터 모델과 컴포넌트가 양방향으로 데이터 바인딩되기 때문에 템플릿에서 발생하는 변경사항은 모두 뮤터블로 처리된다. 그래서 실제로 데이터 모델이 변경되지 않아도 바인딩된 데이터가 전달되며, 변화 감지 로직만으로는 실제로 값이 변경되었는지 판단하기 어렵다.


이런 차이점은 이전 섹션에서 살펴봤던 예제로도 확인할 수 있다.


  • 반응형 폼에서는 폼 컨트롤의 값이 변경되었을 때만 FormControl 인스턴스가 새로운 값을 반환한다.
  • 템플릿 기반 폼에서는 favorite-color 프로퍼티 값이 항상 새로운 값으로 사용된다.


5. 폼 유효성 검사

폼 유효성 검사는 폼 전체를 종합해서 관리하는 단계이다. 필수 입력 필드에 값이 입력되었는지, 백엔드 API를 활용해서 사용자 이름이 서버에 존재하는지 등과 같은 검사 로직은 Angular가 제공하는 기본 유효성 검사기나 커스텀 유효성 검사기를 정의해서 적용할 수 있다.


  • 반응형 폼에 적용할 커스텀 유효성 검사기를 구현하려면 함수를 사용한다.
  • 템플릿 기반 폼은 템플릿 디렉티브와 직접 연결되어 있기 때문에, 커스텀 유효성 검사기를 구현하려면 유효성 검사 함수를 디렉티브로 랩핑해야 한다.


6. 테스트

애플리케이션이 복잡할수록 테스트의 중요성이 커진다. 그리고 이런 경우에도 폼 기능이 제대로 동작하는지만 간단하게 검사하는 방식을 활용하는 것이 좋다. 반응형 폼과 템플릿 기반 폼은 폼 컨트롤 상태나 폼 필드 값을 검증하는 방식이 다르다. 좀 더 정확하게 이야기하면, 화면 렌더링과 엮여있는 정도가 다르기 때문에 테스트하는 방식도 다르다.


1) 반응형 폼 테스트하기

반응형 폼은 폼과 데이터 모델에 직접 접근할 수 있기 때문에 테스트를 적용하기도 쉽고 화면 렌더링을 신경쓰지 않아도 된다. 그리고 테스트 스펙 안에서는 변화 감지 로직이 따로 실행되지 않아도 폼 컨트롤이 저장하고 있는 데이터를 직접 참조하고 조작할 수 있다.


아래 코드는 이전 반응형 폼 섹션에서 살펴봤던 favorite-color 컴포넌트가 화면에서 모델 방향으로, 모델에서 화면 방향으로 잘 연결되었는지 검사하는 테스트 코드이다.


화면에서 모델로 데이터가 반영되는 것을 검사하는 과정은 이렇다.


1] 화면에서 폼 입력 엘리먼트를 참조하고 "input" 이벤트를 임의로 발생시킨다.

2] 입력 엘리먼트의 값을 Red로 변경하고 이 값으로 "input" 이벤트를 발생시킨다.

3] 컴포넌트의 favoriteColorControl 값이 갱신되었는지 확인한다.


테스트 - 화면에서 모델로 반영
it("should update the value of the input field", () => {
  const input = fixture.nativeElement.querySelector("input");
  const event = createNewEvent("input");

  input.value = "Red";
  input.dispatchEvent(event);

  expect(fixture.componentInstance.favoriteColorControl.value).toEqual("Red");
});


그리고 모델에서 화면으로 데이터가 반영되는 것을 검사하는 과정은 이렇다.


1] FormControl 인스턴스 favoriteColorControl에 새 값을 지정한다.

2] 화면에서 폼 입력 엘리먼트를 참조한다.

3] 입력 엘리먼트의 값이 갱신되었는지 확인한다.


테스트 - 모델에서 화면으로 반영
it("should update the value in the control", () => {
  component.favoriteColorControl.setValue("Blue");

  const input = fixture.nativeElement.querySelector("input");

  expect(input.value).toBe("Blue");
});


2) 템플릿 기반 폼 테스트하기

템플릿 기반 폼에 테스트를 적용하려면 변화 감지 로직과 디렉티브가 동작하는 과정을 확실하게 알아야 정확한 시점에 엘리먼트를 참조하고, 테스트하고, 변경할 수 있다.


이번 섹션에서는 이전 섹션에서 살펴봤던 템플릿 폼을 대상으로 화면에서 모델로 데이터가 반영되고, 모델에서 화면으로 데이터가 변경되는 것을 어떻게 테스트할 수 있는지 알아보자.


화면에서 모델로 데이터가 반영되는 것을 검사하는 코드는 이렇다.


테스트 - 화면에서 모델로
it("should update the favorite color in the component", fakeAsync(() => {
  const input = fixture.nativeElement.querySelector("input");
  const event = createNewEvent("input");

  input.value = "Red";
  input.dispatchEvent(event);

  fixture.detectChanges();

  expect(component.favoriteColor).toEqual("Red");
}));


이 코드는 이런 순서로 실행된다.


1] 화면에서 폼 입력 엘리먼트를 참조하고 커스텀 "input" 이벤트를 발생시킨다.

2] 입력 엘리먼트의 값을 Red로 변경하고 폼 입력 엘리먼트에 "input" 이벤트를 발생시킨다.

3] 테스트 픽스쳐에서 변화 감지 로직을 실행한다.

4] 컴포넌트 프로퍼티 favoriteColor가 갱신되었는지 확인한다.


그리고 모델에서 화면으로 데이터가 반영되는 것을 검사하는 코드는 이렇다.


테스트 - 모델에서 화면으로
it("should update the favorite color on the input field", fakeAsync(() => {
  component.favoriteColor = "Blue";

  fixture.detectChanges();

  tick();

  const input = fixture.nativeElement.querySelector("input");

  expect(input.value).toBe("Blue");
}));


그리고 모델에서 화면으로 데이터가 반영되는 것을 검사하는 과정은 이렇게 실행된다.


1] 컴포넌트 프로퍼티 favoriteColor 값을 변경한다.

2] 테스트 픽스쳐에서 변화 감지 로직을 실행한다.

3] 시간이 지난 것을 시뮬레이션하기 위해 fakeAsync() 안에서 tick()를 실행한다.

4] 화면에서 폼 입력 엘리먼트를 참조한다.

5] 입력 엘리먼트의 값이 컴포넌트 프로퍼티 favoriteColor 값은 같은지 확인한다.


References