심화 가이드
폼

동적 폼 만들기

설문지처럼 형식과 목적이 서로 비슷한 폼이 많습니다. 이런 폼의 여러 버전을 더 빠르고 쉽게 만들려면 비즈니스 객체 모델을 설명하는 메타데이터를 바탕으로 동적 폼 템플릿 을 만들면 됩니다. 그런 다음 데이터 모델이 변경될 때마다 이 템플릿으로 새 폼을 자동으로 생성할 수 있습니다.

이 방법은 빠르게 바뀌는 업무 요구사항이나 규제에 맞춰 폼 내용을 자주 변경해야 하는 경우에 특히 유용합니다. 대표적인 사례는 설문지입니다. 상황에 따라 사용자에게 서로 다른 내용을 입력받아야 할 수 있습니다. 이때 사용자에게 보이는 폼의 형식과 스타일은 일정하게 유지하면서, 실제 질문은 상황에 맞춰 바꿀 수 있어야 합니다.

이 튜토리얼에서는 간단한 설문지를 표시하는 동적 폼을 만들어 봅니다. 일자리를 찾는 히어로를 위한 온라인 지원서를 만드는 예제입니다. 채용 기관에서는 지원 절차를 계속 수정하지만, 동적 폼을 사용하면 애플리케이션 코드를 변경하지 않고도 새 폼을 바로 만들 수 있습니다.

다음 순서로 진행합니다:

  1. 프로젝트에서 반응형 폼을 사용할 수 있도록 설정합니다.
  2. 폼 컨트롤을 표현하는 데이터 모델을 정의합니다.
  3. 모델에 예제 데이터를 채웁니다.
  4. 폼 컨트롤을 동적으로 생성하는 컴포넌트를 만듭니다.

만들 폼에는 사용자 경험을 개선하기 위해 입력 유효성 검사와 스타일을 적용합니다. 모든 입력 값이 유효할 때만 저장 버튼이 활성화되며, 유효하지 않은 입력은 색상과 오류 메시지로 표시합니다.

이 기본 구현을 확장하면 더 다양한 질문을 지원하고, 화면 표시를 다듬으며, 사용자 경험을 개선할 수 있습니다.

프로젝트에서 반응형 폼 사용하기

동적 폼은 반응형 폼을 기반으로 합니다.

애플리케이션에서 반응형 폼 디렉티브를 사용하려면 필요한 컴포넌트에서 @angular/forms 패키지의 ReactiveFormsModule을 로드하면 됩니다.

폼 객체 모델 만들기

동적 폼에는 폼 기능에 필요한 모든 상황을 표현할 수 있는 객체 모델이 필요합니다. 히어로 지원서 예제는 질문의 모음입니다. 즉, 폼의 각 컨트롤은 질문을 하나 표시하고 답변을 받아야 합니다.

이런 폼의 데이터 모델은 질문을 표현할 수 있어야 합니다. 이 예제에는 질문을 모델의 기본 객체로 사용하는 DynamicFormQuestionComponent가 있습니다.

다음 QuestionBase는 폼에서 질문과 답변을 표현하는 컨트롤의 기본 클래스입니다.

question-base.ts

export class QuestionBase<T> {
  value: T | undefined;
  key: string;
  label: string;
  required: boolean;
  order: number;
  controlType: string;
  type: string;
  options: {key: string; value: string}[];

  constructor(
    options: {
      value?: T;
      key?: string;
      label?: string;
      required?: boolean;
      order?: number;
      controlType?: string;
      type?: string;
      options?: {key: string; value: string}[];
    } = {},
  ) {
    this.value = options.value;
    this.key = options.key || '';
    this.label = options.label || '';
    this.required = !!options.required;
    this.order = options.order === undefined ? 1 : options.order;
    this.controlType = options.controlType || '';
    this.type = options.type || '';
    this.options = options.options || [];
  }
}

컨트롤 클래스 정의하기

이 예제에서는 기본 클래스를 상속해서 서로 다른 컨트롤 타입을 표현하는 TextboxQuestion과 DropdownQuestion 클래스를 만듭니다. 다음 단계에서 폼 템플릿을 만들 때 각 질문 타입의 인스턴스를 생성해서 알맞은 컨트롤을 동적으로 렌더링합니다.

TextboxQuestion 컨트롤은 폼 템플릿에서 <input> 엘리먼트로 표현합니다. 질문을 표시하고 사용자가 값을 입력할 수 있도록 합니다. 엘리먼트의 type 어트리뷰트는 options 인자에 지정한 type 필드에 따라 정해집니다. 예를 들어 text, email, url을 지정할 수 있습니다.

question-textbox.ts

import {QuestionBase} from './question-base';

export class TextboxQuestion extends QuestionBase<string> {
  override controlType = 'textbox';
}

DropdownQuestion 컨트롤은 선택 상자에 선택지 목록을 표시합니다.

question-dropdown.ts

import {QuestionBase} from './question-base';

export class DropdownQuestion extends QuestionBase<string> {
  override controlType = 'dropdown';
}

폼 그룹 구성하기

동적 폼은 서비스를 사용해서 폼 모델에 따라 입력 컨트롤을 그룹으로 묶어 생성합니다. 다음 QuestionControlService는 질문 모델의 메타데이터를 바탕으로 FormControl 인스턴스들로 구성된 FormGroup을 생성합니다. 기본값과 유효성 검사 규칙도 지정할 수 있습니다.

question-control.service.ts

import {Injectable} from '@angular/core';
import {FormControl, FormGroup, Validators} from '@angular/forms';

import {QuestionBase} from './question-base';

@Injectable()
export class QuestionControlService {
  toFormGroup(questions: QuestionBase<string>[]) {
    const group: any = {};

    questions.forEach((question) => {
      group[question.key] = question.required
        ? new FormControl(question.value || '', Validators.required)
        : new FormControl(question.value || '');
    });
    return new FormGroup(group);
  }
}

동적 폼 내용 구성하기

동적 폼 자체는 이후 단계에서 추가할 컨테이너 컴포넌트로 표현합니다. 폼 컴포넌트의 템플릿에서 각 질문은 <app-question> 태그로 표현하며, 이 태그는 DynamicFormQuestionComponent 인스턴스에 대응합니다.

DynamicFormQuestionComponent는 데이터 바인딩으로 전달된 질문 객체의 값에 따라 개별 질문의 세부 내용을 렌더링합니다. 폼은 [formGroup] 디렉티브를 사용해서 템플릿 HTML과 실제 컨트롤 객체를 연결합니다. DynamicFormQuestionComponent는 QuestionControlService가 생성한 폼 그룹을 전달받아 해당 질문의 컨트롤을 렌더링하고, 서비스에서 설정한 규칙에 따라 유효성 검사 상태를 표시합니다.

DynamicFormQuestionComponent의 목적은 모델에 정의된 질문 타입을 화면에 표시하는 것입니다. 지금은 질문 타입이 두 가지뿐이지만, 더 다양한 타입을 추가할 수도 있습니다. 템플릿의 @switch 블록은 어떤 질문 타입을 표시할지 결정합니다. 이 블록에서는 formControlName과 formGroup 셀렉터를 사용하는 디렉티브를 활용합니다. 두 디렉티브는 모두 ReactiveFormsModule에 정의되어 있습니다.

데이터 제공하기

각 폼을 구성할 구체적인 질문 목록을 제공하는 서비스도 필요합니다. 이 예제에서는 코드에 직접 작성한 예제 데이터로 질문 배열을 제공하는 QuestionService를 만듭니다. 실제 애플리케이션에서는 이 서비스가 백엔드 시스템에서 데이터를 받아올 수도 있습니다. 중요한 점은 히어로 지원서의 질문 전체를 QuestionService가 반환하는 객체로 제어한다는 것입니다. 요구사항이 변경되어 설문지를 수정하려면 questions 배열의 객체를 추가하거나 수정하거나 제거하기만 하면 됩니다.

QuestionService는 input()으로 선언한 questions에 바인딩할 질문 배열을 제공합니다.

question.service.ts

import {Injectable} from '@angular/core';

import {DropdownQuestion} from './question-dropdown';
import {QuestionBase} from './question-base';
import {TextboxQuestion} from './question-textbox';
import {of} from 'rxjs';

@Injectable()
export class QuestionService {
  // TODO: get from a remote source of question metadata
  getQuestions() {
    const questions: QuestionBase<string>[] = [
      new DropdownQuestion({
        key: 'favoriteAnimal',
        label: 'Favorite Animal',
        options: [
          {key: 'cat', value: 'Cat'},
          {key: 'dog', value: 'Dog'},
          {key: 'horse', value: 'Horse'},
          {key: 'capybara', value: 'Capybara'},
        ],
        order: 3,
      }),

      new TextboxQuestion({
        key: 'firstName',
        label: 'First name',
        value: 'Alex',
        required: true,
        order: 1,
      }),

      new TextboxQuestion({
        key: 'emailAddress',
        label: 'Email',
        type: 'email',
        order: 2,
      }),
    ];

    return of(questions.sort((a, b) => a.order - b.order));
  }
}

동적 폼 템플릿 만들기

DynamicFormComponent는 폼의 진입점이자 최상위 컨테이너이며, 템플릿에서는 <app-dynamic-form>으로 표현합니다.

DynamicFormComponent는 각 질문을 DynamicFormQuestionComponent에 대응하는 <app-question> 엘리먼트에 바인딩해서 질문 목록을 표시합니다.

폼 표시하기

동적 폼 인스턴스를 화면에 표시하기 위해 AppComponent의 최상위 템플릿에서 QuestionService가 반환한 questions 배열을 폼 컨테이너 컴포넌트인 <app-dynamic-form>에 전달합니다.

app.component.ts

import {Component, inject} from '@angular/core';
import {AsyncPipe} from '@angular/common';

import {DynamicFormComponent} from './dynamic-form.component';

import {QuestionService} from './question.service';
import {QuestionBase} from './question-base';
import {Observable} from 'rxjs';

@Component({
  selector: 'app-root',
  template: `
    <div>
      <h2>Job Application for Heroes</h2>
      <app-dynamic-form [questions]="questions$ | async" />
    </div>
  `,
  providers: [QuestionService],
  imports: [AsyncPipe, DynamicFormComponent],
})
export class AppComponent {
  questions$: Observable<QuestionBase<string>[]> = inject(QuestionService).getQuestions();
}

이렇게 모델과 데이터를 분리하면 질문 객체 모델과 호환되는 모든 종류의 설문에 컴포넌트를 재사용할 수 있습니다.

데이터 유효성 보장하기

폼 템플릿은 메타데이터를 동적으로 바인딩해서 폼을 렌더링합니다. 따라서 특정 질문에 대한 가정을 코드에 고정해 둘 필요가 없습니다. 컨트롤 메타데이터와 유효성 검사 기준은 모두 동적으로 추가합니다.

유효한 값만 입력받기 위해 폼이 유효한 상태가 될 때까지 Save 버튼을 비활성화합니다. 폼이 유효한 상태에서 Save 버튼을 클릭하면 애플리케이션이 현재 폼 값을 JSON 형식으로 표시합니다.

완성된 폼은 다음과 같습니다.

동적 폼

다음 단계