앵귤러 중급 강좌 #2 Template-driven Forms와 비교

8 분 소요

#1에서는 앵귤러의 폼 처리 방식 중 하나인 Reactive Forms를 다뤘습니다. FormGroupFormControl을 컴포넌트 클래스에서 직접 만들어 두고, 템플릿은 그 객체에 연결만 해주는 방식이었습니다. 그런데 앵귤러에는 이것 말고도 또 하나의 폼 방식이 있습니다. 바로 Template-driven Forms입니다.

이름에서 알 수 있듯이 이 방식은 폼의 모든 로직이 템플릿(HTML) 위에서 만들어집니다. 컴포넌트 클래스에는 폼 객체를 따로 선언할 필요가 없고, 그 대신 ngModel이라는 디렉티브가 거의 모든 일을 해줍니다. 이번 글에서는 Template-driven Forms의 사용법을 살펴보고, 마지막에 두 방식을 나란히 놓고 비교해보도록 하겠습니다.

Template-driven이란 #

Template-driven Forms는 말 그대로 템플릿이 폼을 주도(drive)하는 방식입니다. 폼의 구조도, 검증 규칙도, 상태 추적도 모두 HTML 안에 적힌 디렉티브로부터 앵귤러가 추론합니다.

핵심은 ngModel이라는 디렉티브입니다. 사실 기초 강좌 #3에서 양방향 바인딩을 다룰 때 이미 잠깐 만나본 친구입니다. 그때는 단순히 [(ngModel)]="name"처럼 변수와 입력란을 묶어주는 도구로 썼지만, 사실 ngModel은 그것보다 훨씬 많은 일을 하고 있었습니다. 폼 안에서 사용되면 자동으로 해당 입력란을 폼의 **컨트롤(control)**로 등록하고, 그 컨트롤의 값과 검증 상태(valid/invalid, touched/dirty 등)를 끊임없이 추적합니다.

FormsModule 셋업 #

Template-driven Forms를 쓰려면 FormsModule을 import해야 합니다. standalone 컴포넌트에서는 컴포넌트의 imports 배열에 직접 추가합니다.

src/app/signup-form.component.ts
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  // 이제 템플릿에서 ngModel, ngForm 같은 디렉티브를 쓸 수 있습니다
}

Reactive Forms에서는 ReactiveFormsModule을 import했었습니다. Template-driven에서는 그것 대신 FormsModule이 들어간다고 기억해두시면 됩니다. 두 모듈을 동시에 import해도 충돌은 없지만, 한 컴포넌트 안에서는 보통 한 가지 방식만 쓰는 것이 깔끔합니다.

[(ngModel)]로 양방향 바인딩 #

가장 단순한 예제부터 보겠습니다.

src/app/signup-form.component.html
<input
  type="text"
  name="username"
  [(ngModel)]="username"
  placeholder="아이디"
/>
<p>입력값: {{ username }}</p>
src/app/signup-form.component.ts
export class SignupFormComponent {
  username = '';
}

여기서 한 가지 중요한 규칙이 있습니다. ngModel을 폼 안에서 쓸 때는 반드시 name 속성을 붙여줘야 한다는 점입니다. 앵귤러는 이 name 값을 키로 삼아 폼 객체 안에 컨트롤을 등록합니다. name이 없으면 콘솔에 에러가 찍히면서 폼이 제대로 동작하지 않습니다.

노트
폼 바깥에서 단순히 입력값과 변수를 묶는 용도로만 [(ngModel)]을 쓸 때는 name 속성이 없어도 됩니다. 하지만 <form> 태그 안쪽에 들어가는 순간 name이 필수가 됩니다. “폼 안에서는 name도 한 세트"로 외워두시면 편합니다.

ngForm(ngSubmit) #

이제 폼 전체를 다뤄보겠습니다. <form> 태그를 쓰면 앵귤러가 자동으로 그 요소에 NgForm 인스턴스를 만들어 붙여줍니다. 이 객체에 접근하려면 템플릿 변수 문법(#)으로 참조를 잡으면 됩니다.

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <label>
    아이디
    <input type="text" name="username" [(ngModel)]="model.username" />
  </label>

  <label>
    이메일
    <input type="email" name="email" [(ngModel)]="model.email" />
  </label>

  <button type="submit">가입</button>
</form>

<p>폼이 valid한가? {{ userForm.valid }}</p>
src/app/signup-form.component.ts
import { Component } from '@angular/core';
import { FormsModule, NgForm } from '@angular/forms';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './signup-form.component.html',
})
export class SignupFormComponent {
  model = { username: '', email: '' };

  onSubmit(form: NgForm) {
    if (form.valid) {
      console.log('제출된 값:', form.value);
    }
  }
}

#userForm="ngForm"은 “이 폼에 붙어 있는 ngForm 디렉티브 인스턴스를 userForm이라는 이름으로 잡아라"는 뜻입니다. 이렇게 잡아두면 템플릿 안에서도, 그리고 (ngSubmit)을 통해 컴포넌트 클래스 안에서도 폼 전체 객체를 다룰 수 있습니다.

(submit)이 아니라 (ngSubmit)을 쓰는 이유는, (ngSubmit)이 브라우저의 기본 페이지 리로드 동작을 자동으로 막아주고 폼이 검증된 상태에서 제출 이벤트를 발행해주기 때문입니다. Template-driven 폼에서는 거의 항상 (ngSubmit)을 쓴다고 보시면 됩니다.

검증 — HTML5 속성으로 #

Template-driven Forms의 가장 큰 매력은 검증을 HTML5 속성으로 적는다는 점입니다. required, minlength, maxlength, pattern, email 같은 표준 속성을 그대로 적으면 앵귤러가 알아서 그것들을 검증 규칙으로 흡수합니다.

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <input
    type="text"
    name="username"
    [(ngModel)]="model.username"
    required
    minlength="3"
  />

  <input
    type="email"
    name="email"
    [(ngModel)]="model.email"
    required
    email
  />

  <input
    type="password"
    name="password"
    [(ngModel)]="model.password"
    required
    pattern="^(?=.*[A-Za-z])(?=.*\d).{8,}$"
  />

  <button type="submit" [disabled]="userForm.invalid">가입</button>
</form>

같은 검증을 Reactive Forms로 짠다면 Validators.required, Validators.minLength(3), Validators.email, Validators.pattern(...)을 컴포넌트 클래스에서 배열로 적었어야 합니다. Template-driven에서는 그 모든 게 HTML 한 줄에 들어가버립니다. 짧은 폼에서는 정말 편합니다.

[disabled]="userForm.invalid"도 주목할 만합니다. 폼 전체가 invalid한 동안에는 제출 버튼을 자동으로 비활성화하는 패턴입니다. 별도의 boolean 변수도, 메소드도 필요 없습니다.

폼 상태와 에러 표시 #

폼의 각 컨트롤은 다음과 같은 상태 플래그를 가집니다.

  • valid / invalid — 검증 규칙을 통과했는지
  • pristine / dirty — 사용자가 한 번이라도 값을 바꿨는지
  • untouched / touched — 사용자가 한 번이라도 포커스를 줬다가 떠났는지

이 플래그들을 활용해 “사용자가 입력란에서 떠난 뒤에야 에러를 보여주는” 자연스러운 UX를 만들 수 있습니다.

src/app/signup-form.component.html
<form #userForm="ngForm" (ngSubmit)="onSubmit(userForm)">
  <label>
    이메일
    <input
      type="email"
      name="email"
      [(ngModel)]="model.email"
      #emailCtrl="ngModel"
      required
      email
    />
  </label>

  @if (emailCtrl.invalid && emailCtrl.touched) {
    <p class="error">
      @if (emailCtrl.errors?.['required']) {
        이메일을 입력해주세요.
      }
      @if (emailCtrl.errors?.['email']) {
        올바른 이메일 형식이 아닙니다.
      }
    </p>
  }

  <button type="submit" [disabled]="userForm.invalid">가입</button>
</form>

#emailCtrl="ngModel"은 폼 전체가 아니라 개별 컨트롤 단위로 디렉티브 인스턴스를 잡아두는 패턴입니다. 이렇게 잡으면 그 컨트롤 하나의 valid, touched, errors 같은 정보에 직접 접근할 수 있습니다.

폼 전체 단위로 접근하고 싶다면 userForm.controls['email']?.errors처럼 들어갈 수도 있습니다. 컨트롤이 많아지면 후자 쪽이 깔끔해질 때도 있습니다.

Reactive vs Template-driven 비교 #

이제 두 방식을 나란히 놓고 비교해보겠습니다.

항목Template-drivenReactive
폼 정의 위치HTML 템플릿컴포넌트 클래스
핵심 도구ngModel, ngForm, HTML5 속성FormGroup, FormControl, Validators
import 모듈FormsModuleReactiveFormsModule
학습 난도낮음 — HTML 친화적중간 — 객체와 Observable 친화적
동적 폼 (필드 추가/삭제)불편함매우 자연스러움 (FormArray)
단위 테스트템플릿이 필요해서 무거움클래스만 테스트 가능, 가벼움
비동기 검증가능하지만 어색함자연스럽게 통합됨
값 변화 구독어색함valueChanges Observable로 깔끔

거칠게 정리하면 다음과 같습니다.

  • Template-driven: 폼이 단순하고, 검증도 HTML5 수준으로 충분하고, “폼 전체가 HTML 한 화면 안에 보이면 좋겠다"고 생각될 때.
  • Reactive: 폼이 복잡하거나, 입력에 따라 필드가 동적으로 늘었다 줄었다 하거나, 값의 변화에 따라 다른 영역이 반응해야 하거나, 단위 테스트가 중요할 때.

실무 권장 — 어느 쪽을 골라야 할까? #

새 프로젝트를 시작하는 분들께는 일반적으로 Reactive Forms를 기본으로 두시기를 권합니다. 처음에는 코드 양이 좀 더 많아 보이지만, 폼이 자라면 자랄수록 그 구조의 가치가 드러납니다. 검증 규칙이 클래스 안에 한곳에 모여 있어 추적하기 쉽고, 새 필드 하나를 추가할 때 몇 줄을 바꿔야 하는지 명확하며, 단위 테스트도 컴포넌트 클래스만으로 깔끔하게 짤 수 있습니다.

의사결정 기준 한 줄 요약: 폼이 자랄 가능성이 조금이라도 있다면 Reactive로 시작하세요. “이건 평생 입력란 두 개짜리야"라고 자신할 수 있는 폼만 Template-driven으로 가볍게 처리하세요. 중간에 두 방식을 섞어 쓰는 것은 가능하지만, 같은 폼 안에서 섞는 것은 피하시는 편이 좋습니다.

그렇다고 Template-driven이 구식이라거나 쓸모없다는 뜻은 절대 아닙니다. 헤더의 검색창, 뉴스레터 구독 폼, 댓글 입력처럼 입력란이 한두 개에 검증도 가벼운 폼이라면 Reactive Forms의 보일러플레이트가 오히려 과잉입니다. 그런 경우에는 Template-driven으로 HTML 몇 줄에 끝내는 것이 훨씬 자연스럽습니다.

또한 디자이너나 퍼블리셔와 협업하는 환경에서, 마크업 위주로 작업하시는 분들이 직접 검증 속성까지 손볼 수 있다는 것은 무시할 수 없는 장점입니다. 팀 구성과 프로젝트 성격에 따라 답이 달라질 수 있다는 점을 기억해두시면 좋습니다.

마무리 #

이번 글에서는 Template-driven Forms의 사용법과, Reactive Forms와의 비교를 살펴봤습니다. 정리하자면:

  • Template-driven은 템플릿 위주로 폼을 만듭니다. ngModel과 HTML5 속성이 핵심.
  • 폼 안의 ngModel에는 name 속성이 필수다.
  • #userForm="ngForm"으로 폼 전체를, #ctrl="ngModel"로 개별 컨트롤을 잡는다.
  • 단순,가벼운 폼은 Template-driven, 복잡,동적,테스트가 중요한 폼은 Reactive.
  • 새 프로젝트의 기본은 Reactive 우선, 단순 폼만 Template-driven으로.

다음 글인 “앵귤러 중급 강좌 #3 RxJS 기초"에서는 앵귤러의 또 다른 큰 축인 RxJS를 다뤄보겠습니다. Observable이 무엇이고, 왜 앵귤러는 HTTP 응답이나 라우터 파라미터 같은 비동기 흐름에 그것을 들고 왔는지, 그리고 가장 자주 쓰는 연산자 몇 가지를 손에 익혀보도록 하겠습니다.

X