Skip to content

4. 동적 폼 구성하기


설문지를 폼으로 구현한다면 설문지마다 구성 형식과 의도가 비슷한 경우가 대부분일 것이다. 이런 경우라면 업무 효율성을 위해 비즈니스 객체 모델을 메타데이터로 정의하고 동적 폼 템플릿(dynmaic form template)을 구성해둔 뒤에 개별 폼을 빠르게 자동으로 생성하는 것이 더 좋다.


내용이 계속 바뀌고 이 변화에 빠르게 대응해야 하는 업무일수록 이 방식이 특히 효율적이다. 개별 질문마다 사용자에게 입력을 받는 설문지가 대표적인 경우이다. 설문지는 질문이 달라지더라도 사용자가 입력하는 형식이나 스타일은 거의 비슷하다.


이 문서에서는 동적 폼을 구성하면서 설문지 기본 양식을 만들어 본다. 구체적으로 설명하면, 히어로가 히어로 관리 회사에 지원할 수 있는 온라인 애플리케이션을 만들어 본다. 히어로 관리 회사는 신청 프로세스를 지속적으로 개선하고 있지만 동적 폼을 사용하면 애플리케이션 코드를 변경하지 않아도 새로운 양식을 만들 수 있다.


이 문서는 이런 순서로 진행한다.


1] 프로젝트에 반응형 폼을 로드한다.

2] 폼 컨트롤을 표현하는 데이터 모델을 정의한다.

3] 데이터 모델로 샘플 데이터를 만들어 본다.

4] 폼 컨트롤을 동적으로 구성하는 컴포넌트를 구현한다.


이 문서에서 폼에 유효성 검사를 추가하고 스타일을 적용하면 사용자 UX도 좋아질 수 있다. 폼에 있는 제출 버튼은 사용자가 모든 필드를 입력했을 때만 활성화되며, 입력값이 유효하지 않은 필드는 다른 색으로 표시하면서 에러 메시지도 함께 표시하는 식이다.


동적 폼은 간단하게 구성할 수 있지만 질문의 개수, 종류를 더 늘리거나 더 나은 스타일로 꾸미기도 쉽다.


Note


1. 사전지식

이 문서를 보기 전에 이런 내용을 미리 이해하고 있는 것이 좋다.



2. 프로젝트에 반응형 폼 로드하기

동적 폼은 반응형 폼을 기반으로 구성한다. 그래서 애플리케이션에 반응형 폼을 구성하려면 @angular/forms 패키지로 제공하는 ReactiveFormsModule을 앱 최상위 모듈에 로드해야 한다.


최상위 모듈은 이렇게 설정하면 된다.


import { BrowserModule } from "@angular/platform-browser";
import { ReactiveFormsModule } from "@angular/forms";
import { NgModule } from "@angular/core";

import { AppComponent } from "./app.component";
import { DynamicFormComponent } from "./dynamic-form.component";
import { DynamicFormQuestionComponent } from "./dynamic-form-question.component";

@NgModule({
  imports: [BrowserModule, ReactiveFormsModule],
  declarations: [
    AppComponent,
    DynamicFormComponent,
    DynamicFormQuestionComponent,
  ],
  bootstrap: [AppComponent],
})
export class AppModule {
  constructor() {}
}
import { enableProdMode } from "@angular/core";
import { platformBrowserDynamic } from "@angular/platform-browser-dynamic";

import { AppModule } from "./app/app.module";
import { environment } from "./environments/environment";

if (environment.production) {
  enableProdMode();
}

platformBrowserDynamic().bootstrapModule(AppModule);


3. 폼 객체 모델 정의하기

동적 폼을 구성하려면 폼에 필요한 시나리오를 표현하는 객체 모델을 먼저 정의해야 한다. 예제 애플리케이션은 질문들로 구성되며, 사용자가 개별 폼 컨트롤을 모두 입력해야 한다.


개별 질문은 데이터 모델로 표현한 후에 DynamicFormQuestionComponent로 처리해 보자. 아래 예제 코드는 폼에 있는 질문과 답 한 쌍을 표현하는 QuestionBase 코드이다.


src/app/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 || [];
  }
}


1) 폼 컨트롤 클래스 정의하기

이 클래스는 TextboxQuestionDropdownQuestion으로 확장되어 새로운 컨트롤 타입을 구성할 때 사용된다. 그리고 이후에 폼 템플릿을 동적으로 구성할 때도 질문의 종류에 따라 컴포넌트를 다양하게 사용할 것이다.


  • TextboxQuestion 컨트롤 타입은 사용자가 질문에 직접 답을 입력할 때 사용한다.


src/app/question-textbox.ts
import { QuestionBase } from "./question-base";

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


TextboxQuestion 타입은 폼 템플릿을 구성할 때 <input> 엘리먼트를 사용한다. 이 때 엘리먼트의 type 어트리뷰트는 option으로 받으며 text, email, url과 같은 형식을 사용할 것이다.


  • DorpdownQuestion 타입은 셀렉트 박스에서 항목 하나를 고를 때 사용한다.


src/app/question-dropdown.ts
import { QuestionBase } from "./question-base";

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


2) 폼 그룹 구성하기

폼 모델을 폼 컨트롤로 변환하는 서비스를 만들어 보자. 아래 코드는 데이터 모델을 모아서 FormGroup 인스턴스로 변환하는 QuestionControlService 클래스 코드이다. 폼 컨트롤의 기본값이나 유효성 검사 함수는 이 서비스에서 지정하면 된다.


src/app/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 {
  constructor() {}

  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);
  }
}


4. 동적 폼 구성하기

이번 예제에서 동적 폼은 그 자체로 컨테이너 컴포넌트의 역할도 한다. 이 내용은 다음 단계에서 추가해 보자. 개별 질문은 폼 컴포넌트 템플릿에 <app-question> 태그를 사용하는데, 이 태그는 DynamicFormQuestionComponent를 의미한다.


DynamicFormQuestionComponent는 바인딩된 질문 객체에 따라 개별 질문과 대답을 렌더링하는 역할을 한다. 그리고 템플릿 HTML과 폼 컨트롤 객체를 연결할 때는 [formGroup] 디렉티브를 활용한다. 결국 DynamicFormQuestionComponent는 사전에 정의된 질문 모델에 따라 전체 폼 그룹을 구성하며 화면에 표시하고 유효성을 검사한다.


<div [formGroup]="form">
  <label [attr.for]="question.key">{{question.label}}</label>

  <div [ngSwitch]="question.controlType">
    <input
      *ngSwitchCase="'textbox'"
      [formControlName]="question.key"
      [id]="question.key"
      [type]="question.type"
    />

    <select
      [id]="question.key"
      *ngSwitchCase="'dropdown'"
      [formControlName]="question.key"
    >
      <option *ngFor="let opt of question.options" [value]="opt.key">
        {{opt.value}}
      </option>
    </select>
  </div>

  <div class="errorMessage" *ngIf="!isValid">
    {{question.label}} is required
  </div>
</div>
import { Component, Input } from "@angular/core";
import { FormGroup } from "@angular/forms";

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

@Component({
  selector: "app-question",
  templateUrl: "./dynamic-form-question.component.html",
})
export class DynamicFormQuestionComponent {
  @Input() question!: QuestionBase<string>;
  @Input() form!: FormGroup;
  get isValid() {
    return this.form.controls[this.question.key].valid;
  }
}


DynamicFormQuestionComponent는 데이터 모델로 정의한 질문들을 표시하는 역할을 한다. 지금 단계에서는 질무느이 종류가 두 가지밖에 없지만, 이후에 이 종류는 얼마든지 확장할 수 있다. 이 때 질문 종류에 맞는 컴포넌트를 사용하려면 템플릿에 ngSwitch를 사용하면 된다. 그리고 개별 스위치는 formControlName 디렉티브와 formGroup 디렉티브를 함께 사용한다. 두 디렉티브 모두 ReactiveFormsModule이 제공하는 디렉티브이다.


1) 데이터 제공하기

개별 폼을 구성하려면 각 질문 데이터를 제공하는 서비스가 하나 더 필요하다. 그래서 이번에는 QuestionService를 만들어서 질문을 QuestionBase 배열 타입으로 반환하는 코드를 구현해 보자. 실제로 운영하는 애플리케이션이라면 이 데이터는 백엔드 서버에서 받아올 것이다. 하지만 이 문서는 QuestionService를 활용하는 측면을 다루는 것이 주요 내용이기 때문에, 예제에서는 하드코딩된 샘플 데이터를 사용한다. 설문지 항목이 계속 변경된다면 questions 배열을 수정하면 된다.


QuestionService는 질문 데이터를 배열 형태로 반환하며, 이 데이터는 @Input() 프로퍼티에 바인딩하는 방식으로 사용할 것이다.


src/app/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: 질문을 정의하는 메타데이터는 리모트 서버에서 받아오기
  getQuestions() {
    const questions: QuestionBase<string>[] = [
      new DropdownQuestion({
        key: "brave",
        label: "Bravery Rating",
        options: [
          { key: "solid", value: "Solid" },
          { key: "great", value: "Great" },
          { key: "good", value: "Good" },
          { key: "unproven", value: "Unproven" },
        ],
        order: 3,
      }),

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

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

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


5. 템플릿 구성하기

DynamicFormComponent 컴포넌트는 폼 전체를 구성하는 컴포넌트이며 템플릿에는 <app-dynamic-form>으로 사용된다.


그리고 이 컴포넌트는 질문 목록을 <app-question> 엘리먼트로 구성한다. 개별 <app-question> 엘리먼트는 DynamicFormQuestionComponent를 의미한다.


<div>
  <form (ngSubmit)="onSubmit()" [formGroup]="form">
    <div *ngFor="let question of questions" class="form-row">
      <app-question [question]="question" [form]="form"></app-question>
    </div>

    <div class="form-row">
      <button type="submit" [disabled]="!form.valid">Save</button>
    </div>
  </form>

  <div *ngIf="payLoad" class="form-row">
    <strong>Saved the following values</strong><br />{{payLoad}}
  </div>
</div>
import { Component, Input, OnInit } from "@angular/core";
import { FormGroup } from "@angular/forms";

import { QuestionBase } from "./question-base";
import { QuestionControlService } from "./question-control.service";

@Component({
  selector: "app-dynamic-form",
  templateUrl: "./dynamic-form.component.html",
  providers: [QuestionControlService],
})
export class DynamicFormComponent implements OnInit {
  @Input() questions: QuestionBase<string>[] | null = [];
  form!: FormGroup;
  payLoad = "";

  constructor(private qcs: QuestionControlService) {}

  ngOnInit() {
    this.form = this.qcs.toFormGroup(this.questions as QuestionBase<string>[]);
  }

  onSubmit() {
    this.payLoad = JSON.stringify(this.form.getRawValue());
  }
}


1) 폼 표시하기

동적 폼 인스턴스를 화면에 표시하려면 QuestionService가 반환하는 questions 배열을 AppComponent에 있는 <app-dynamic-form> 엘리먼트에 바인딩하면 된다.


app.component.ts
import { Component } from "@angular/core";

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"></app-dynamic-form>
    </div>
  `,
  providers: [QuestionService],
})
export class AppComponent {
  questions$: Observable<QuestionBase<any>[]>;

  constructor(service: QuestionService) {
    this.questions$ = service.getQuestions();
  }
}


이 문서에서 다루는 예제는 히어로가 입력하는 지원서를 구현한 것이며, 지원서에 입력하는 질문은 모두 QuestionService에 정의되어 있다. 폼 모델과 데이터를 분리했기 때문에 이 컴포넌트는 QuestionBase 객체 타입에 맞기만 하면 질문이 변경되더라도 문제없이 동작한다.


2) 데이터 유효성 검사하기

지금까지 구현한 폼 템플릿은 컴포넌트에 바인딩된 동적 데이터를 기반으로 폼을 구성하며, 개별 질문에 대해 하드코딩된 내용은 아무것도 없다. 그래서 폼 컨트롤의 메타데이터나 유효성 검사 규칙도 동적으로 구성할 수 있다.


이 상황에서 폼에 입력된 값이 유효하다는 것을 보장하기 위해 Save 버튼은 폼 전체가 유효한 상태가 되어야만 활성화해야 한다. 폼이 유효한 상태가 된 이후에 Save 버튼을 클릭하면 폼에 입력된 데이터를 JSON 형태로 표시할 것이다.


폼이 모두 구성되면 이런 모습이 된다.


001


References