Angular Signal Forms에서 폼을 현대적인 방식으로 제출하기

발행: (2026년 1월 2일 오후 05:00 GMT+9)
18 min read
원문: Dev.to

Source: Dev.to

Angular Signal Forms 로 최신 방식으로 폼 제출하기

Angular 16부터 도입된 Signal 기반 폼은 기존 ReactiveForms보다 더 선언적이고 직관적인 API를 제공합니다. 이 글에서는 Signal Forms를 사용해 폼을 만들고, 제출 로직을 구현하는 과정을 단계별로 살펴보겠습니다.


1️⃣ Signal 기반 폼 만들기

import { Component, signal } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
  selector: 'app-contact-form',
  templateUrl: './contact-form.component.html',
})
export class ContactFormComponent {
  // 폼 컨트롤을 Signal 로 선언
  readonly contactForm = signal(
    this.fb.group({
      name: ['', Validators.required],
      email: ['', [Validators.required, Validators.email]],
      message: ['', Validators.required],
    })
  );

  constructor(private fb: FormBuilder) {}
}

핵심 포인트

  • signal() 안에 FormGroup을 넣어 Signal 형태의 폼을 생성합니다.
  • 기존 FormBuilder와 검증 로직은 그대로 사용할 수 있습니다.

2️⃣ 템플릿에서 Signal 폼 바인딩

<form [formGroup]="contactForm()" (ngSubmit)="onSubmit()">
  <label>
    이름
    <input formControlName="name" />
  </label>

  <label>
    이메일
    <input formControlName="email" />
  </label>

  <label>
    메시지
    <textarea formControlName="message"></textarea>
  </label>

  <button type="submit" [disabled]="contactForm.invalid">
    전송
  </button>
</form>
  • contactForm() 처럼 Signal을 호출해서 현재 FormGroup 인스턴스를 얻어야 합니다.
  • ngSubmit 이벤트는 기존과 동일하게 동작합니다.

3️⃣ 제출 로직 구현 (Signal + RxJS)

import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({ /* ... */ })
export class ContactFormComponent {
  // ... 위 코드와 동일 ...

  private http = inject(HttpClient);

  // HTTP 요청을 Signal 로 변환 (선택 사항)
  readonly submitResult = signal(null);

  onSubmit() {
    if (this.contactForm().invalid) return;

    const payload = this.contactForm().value;

    // 기존 Observable을 Signal 로 변환해 상태 관리가 쉬워짐
    toSignal(this.http.post('/api/contact', payload)).subscribe({
      next: (res) => this.submitResult.set(res),
      error: (err) => console.error('전송 실패', err),
    });
  }
}
  • toSignal()Observable을 Signal 로 감싸서, 반응형 상태와 결합할 수 있게 해줍니다.
  • submitResult Signal 은 전송 결과를 UI에 바로 반영하는 데 활용할 수 있습니다.

4️⃣ 결과 표시하기

<div *ngIf="submitResult() as result">
  <p>전송이 성공했습니다! 응답 ID: {{ result.id }}</p>
</div>

<div *ngIf="contactForm().invalid && contactForm().touched">
  <p class="error">필수 입력란을 모두 채워 주세요.</p>
</div>
  • submitResult() 로 현재 값을 읽어오고, *ngIf 로 조건부 렌더링을 수행합니다.
  • Signal 기반 폼은 자동으로 변경 감지를 트리거하므로 별도의 ChangeDetectorRef 호출이 필요 없습니다.

📌 마무리 정리

항목기존 ReactiveFormsSignal Forms
선언 방식new FormGroup()signal(this.fb.group())
값 읽기form.valueform().value
변경 감지수동 markAsDirty자동 (Signal 기반)
상태 관리BehaviorSubject 등 별도 도구 필요Signal 자체가 상태 저장소 역할

Signal Forms 를 도입하면 코드 가독성이 향상되고, 반응형 상태 관리가 자연스럽게 통합됩니다. 특히 Angular 17 이상에서 toSignal() 과 같은 RxJS‑Signal 연동 헬퍼가 제공되면서, 비동기 로직과 폼 상태를 하나의 일관된 흐름으로 다룰 수 있게 됩니다.


🎉 다음 단계

  1. 폼 레이아웃Angular Material 혹은 Tailwind CSS 와 결합해 UI를 개선합니다.
  2. 다중 단계 폼(Wizard) 구현 시 각 단계마다 별도 Signal Form 을 만들고, 최종 제출 시 하나의 payload 로 합칩니다.
  3. 테스트@angular/forms/testing 모듈을 사용해 Signal Form 의 유효성 검증과 제출 로직을 단위 테스트합니다.

Signal 기반 폼은 아직 비교적 새로운 기능이므로, 프로젝트에 도입하기 전에 Angular 공식 문서커뮤니티 가이드를 참고해 최신 베스트 프랙티스를 확인하는 것이 좋습니다.


참고: 이 글은 Angular 16+ 환경을 기준으로 작성되었습니다. Angular 17 이상에서는 toSignal 외에도 다양한 Signal‑RxJS 연동 API가 추가될 수 있습니다.

How Signal Forms Handle Client‑Side Validation

Simple signup form built entirely with the Signal Forms API

<!-- Username field -->
<div>
  Username
</div>

@if (form.username().touched() && form.username().invalid()) {
  <ul>
    @for (err of form.username().errors(); track $index) {
      <li>- {{ err.message }}</li>
    }
  </ul>
}

<!-- Email field -->
<div>
  Email
</div>

@if (form.email().touched() && form.email().invalid()) {
  <ul>
    @for (err of form.email().errors(); track $index) {
      <li>- {{ err.message }}</li>
    }
  </ul>
}

<!-- Submit button -->
<button type="submit" [disabled]="form.invalid()">Create account</button>

Initial state – “Create account” 버튼은 폼이 유효하지 않기 때문에(사용자명이나 이메일이 입력되지 않음) 비활성화됩니다.

Client‑side validation – 필드에 포커스를 주었다가 블러(포커스 해제)하면 즉시 검증 오류가 표시됩니다:

  • Username 필드: required → “Please enter a username”
  • Email 필드: required → “Please enter an email address”

유효한 값을 입력하면 오류가 사라지고 제출 버튼이 활성화됩니다. 현재까지는 클라이언트‑사이드 검증이 정상적으로 동작합니다.

The problem: no submission logic

Create account 버튼을 클릭하면 브라우저가 페이지를 새로 고칩니다. 비동기 처리 로직이 없고, 서버 검증 오류를 표시할 방법도 없으며, 로딩 상태도 없습니다.

Template snippet (no submit handler)

<form>

</form>

<form> 요소에 (ngSubmit)이나 submit() 핸들러가 연결되어 있지 않으므로 기본 브라우저 동작(페이지 리로드)이 발생합니다.

Field wiring

<input type="text" field="username" />
<input type="email" field="email" />

두 입력 모두 field 디렉티브를 사용해 Signal Form에 연결됩니다.

Conditional error rendering

@if (form.username().touched() && form.username().invalid()) {
  @for (err of form.username().errors(); track $index) {
    - {{ err.message }}
  }
}

이와 동일한 패턴이 이메일 필드에도 사용됩니다.

Disabled submit button

<button type="submit" [disabled]="form.invalid()">Create account</button>

위 모든 내용은 클라이언트‑사이드 검증에서는 완벽히 동작합니다; 이제 제출 로직을 추가하기만 하면 됩니다.

Source:

Component TypeScript

모델 시그널

protected readonly model = signal({
  username: '',
  email: '',
});

시그널 기반 폼 정의

protected readonly form = form(this.model, s => {
  required(s.username, { message: 'Please enter a username' });
  minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });

  required(s.email, { message: 'Please enter an email address' });
});

이 모든 것은 클라이언트‑사이드 검증입니다: 빠르고, 동기식이며, 제출 시도 이전에 실행됩니다.

모의 회원가입 서비스 (백엔드 호출을 시뮬레이션)

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

export interface SignupModel {
  username: string;
  email: string;
}

export type SignupResult =
  | { status: 'ok' }
  | {
      status: 'error';
      fieldErrors: Partial<SignupModel>;
    };

@Injectable({ providedIn: 'root' })
export class SignupService {
  async signup(value: SignupModel): Promise<SignupResult> {
    await new Promise(r => setTimeout(r, 700));

    const fieldErrors: Partial<SignupModel> = {};

    // Username rules
    if (value.username.trim().toLowerCase() === 'brian') {
      fieldErrors.username = 'That username is already taken.';
    }

    // Email rules
    if (value.email.trim().toLowerCase() === 'brian@test.com') {
      fieldErrors.email = 'That email is already taken.';
    }

    return Object.keys(fieldErrors).length
      ? { status: 'error', fieldErrors }
      : { status: 'ok' };
  }
}

이 서비스는 성공 결과 또는 필드별 서버 오류를 포함하는 객체 중 하나를 반환합니다.

  • 클라이언트 검증 – 형태/포맷 확인 (필수 입력, 이메일 형식, 최소 길이).
  • 서버 검증 – 비즈니스 규칙 적용 (예약된 사용자명, 차단된 도메인, 고유성).

올바른 폼 제출 구현

Signal Forms는 많은 복잡한 작업을 처리해 주는 새로운 submit() API를 제공합니다.

1. signup 서비스 주입

import { inject } from '@angular/core';
import { SignupService } from './signup.service';

export class SignupComponent {
  // …
  private readonly signupService = inject(SignupService);
}

2. onSubmit 메서드 추가

protected onSubmit(event: Event) {
  // Prevent the default browser form submission (page reload)
  event.preventDefault();

  // Use the new submit() API
  this.form.submit(async () => {
    // Mark all fields as touched so validation messages appear if needed
    this.form.markAllAsTouched();

    // If the form is still invalid after client‑side checks, abort
    if (this.form.invalid()) return;

    // Call the mock service
    const result = await this.signupService.signup(this.model());

    // Handle server‑side errors
    if (result.status === 'error') {
      // Map each field error onto the corresponding Signal Form field
      for (const [field, message] of Object.entries(result.fieldErrors)) {
        // `field` is a key of SignupModel (e.g., 'username' or 'email')
        // Cast to any to satisfy the index signature
        (this.form as any)[field].setServerError(message);
      }
      return;
    }

    // Success path – you could navigate, show a toast, etc.
    console.log('Signup successful!');
  });
}

3. 템플릿에 메서드 연결

<form (ngSubmit)="onSubmit($event)">

  <button type="submit" [disabled]="form.submitting()">
    {{ form.submitting() ? 'Creating…' : 'Create account' }}
  </button>
</form>
  • form.submitting()submit()이 제공하는 내장 로딩 상태 시그널입니다.
  • 비동기 작업이 실행되는 동안 버튼 텍스트가 자동으로 로딩 상태로 전환됩니다.

submit() API가 제공하는 것

기능작동 방식
비동기 처리비동기 콜백을 받아들이며, 프라미스가 해결될 때까지 폼이 “제출 중” 상태를 유지합니다.
로드 상태form.submitting()은 UI에 바인딩할 수 있는 신호이며(예: 버튼 비활성화 또는 스피너 표시).
터치된 필드 처리markAllAsTouched()는 사용자가 제출을 시도할 때 아직 터치되지 않은 필드에도 검증 메시지가 표시되도록 보장합니다.
서버 오류 매핑필드 신호에 setServerError(message)를 호출하면 기존 오류 UI와 통합되는 서버 측 오류가 추가됩니다.
기본 동작 방지(ngSubmit)을 사용할 때 API가 자동으로 event.preventDefault()를 호출합니다.

전체 컴포넌트 예제

import { Component, signal, inject } from '@angular/core';
import { form, required, minLength } from '@angular/forms';
import { SignupService, SignupModel } from './signup.service';

@Component({
  selector: 'app-signup',
  templateUrl: './signup.component.html',
  standalone: true,
})
export class SignupComponent {
  // Model signal
  protected readonly model = signal({ username: '', email: '' });

  // Signal‑based form
  protected readonly form = form(this.model, s => {
    required(s.username, { message: 'Please enter a username' });
    minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
    required(s.email, { message: 'Please enter an email address' });
  });

  // Service injection
  private readonly signupService = inject(SignupService);

  // Submit handler
  protected onSubmit(event: Event) {
    this.form.submit(async () => {
      this.form.markAllAsTouched();

      if (this.form.invalid()) return;

      const result = await this.signupService.signup(this.model());

      if (result.status === 'error') {
        for (const [field, message] of Object.entries(result.fieldErrors)) {
          (this.form as any)[field].setServerError(message);
        }
        return;
      }

      console.log('Signup successful!');
    });
  }
}

요약

  1. Client‑side validation은 Signal Forms와 함께 바로 사용할 수 있습니다.
  2. Submission은 페이지 새로고침을 방지하고 로딩 상태를 관리하며 서버 측 오류를 처리하기 위해 새로운 submit() API가 필요합니다.
  3. Implementation steps – 서비스를 주입하고, form.submit(...)를 호출하는 onSubmit 메서드를 추가하며, 필드를 touched 상태로 표시하고, 클라이언트 측 오류가 있으면 중단하고, 백엔드에 호출하고, 서버 오류를 매핑하며, 성공을 처리합니다.

이 요소들을 모두 갖추면 Angular Signal Forms가 완전히 반응형이며 사용자 친화적이고 실제 서버와의 상호작용에 준비됩니다. 즐거운 코딩 되세요!

브라우저가 전체 페이지 새로고침을 수행하지 않도록 방지

protected onSubmit(event: Event) {
  event.preventDefault();
}

새로운 submit() 함수 사용

import { ..., submit } from '@angular/forms/signals';

protected onSubmit(event: Event) {

  submit(this.form);
}

비동기 콜백 (두 번째 인자)

protected onSubmit(event: Event) {

  submit(this.form, async f => {
    // …
  });
}

Angular가 대신 해주는 일

  • 콜백을 폼이 유효한 경우에만 호출합니다.
  • 모든 필드를 touched 상태로 자동으로 표시합니다.
  • 제출 상태를 추적합니다.

콜백 내부에서는 f를 통해 폼의 필드 트리를 받을 수 있습니다.

현재 폼 값에 접근하기

submit(this.form, async f => {
  const value = f().value();   // validated client‑side value
});

회원가입 서비스 호출

submit(this.form, async f => {

  const result = await this.signupService.signup(value);
});

이는 백엔드 호출을 시뮬레이션합니다.

서버‑측 검증 오류 처리

서버가 제출을 거부하면 예외를 발생시키거나 상태를 수동으로 설정하는 대신 검증 오류를 반환합니다.

submit(this.form, async f => {

  if (result.status === 'error') {
    // …
  }
});

오류 배열 만들기

import { ..., ValidationError } from '@angular/forms/signals';

submit(this.form, async f => {

  if (result.status === 'error') {
    const errors: ValidationError.WithOptionalField[] = [];
  }
});

ValidationError.WithOptionalField는 특정 필드를 선택적으로 지정할 수 있는 검증 오류를 나타냅니다.

특정 필드에 대한 오류 추가

submit(this.form, async f => {

  if (result.status === 'error') {
    if (result.fieldErrors.username) {
      errors.push({
        field: f.username,
        kind: 'server',
        message: result.fieldErrors.username,
      });
    }
  }
});

이메일 오류 (동일 패턴)

submit(this.form, async f => {

  if (result.status === 'error') {
    if (result.fieldErrors.email) {
      errors.push({
        field: f.email,
        kind: 'server',
        message: result.fieldErrors.email,
      });
    }
  }
});

오류 반환 (또는 undefined)

submit(this.form, async f => {

  if (result.status === 'error') {

    return errors.length ? errors : undefined;
  }
});
  • errors 를 반환하면 Angular에 “폼을 제출하지 말고 이 오류들을 표시하라”는 의미가 됩니다.
  • undefined 를 반환하면 Angular에 “문제가 없으니 폼이 정상적으로 제출되었다”는 의미가 됩니다.

템플릿에서 onSubmit 메서드 연결

<form (ngSubmit)="onSubmit($event)">

</form>

제출 중에 제출 버튼 비활성화

<button type="submit" [disabled]="form.submitting()">
  {{ form.submitting() ? 'Creating…' : 'Create account' }}
</button>

전체 흐름 테스트

  1. 클라이언트 측 검증은 필드가 포커스를 잃을 때 작동합니다.
  2. 클라이언트 검증은 통과하지만 서버 검증에 실패하는 값을 입력합니다.
    • 클라이언트 오류가 사라집니다 (폼이 기술적으로 유효합니다).
    • 버튼이 활성화됩니다; 클릭합니다.
    • 모의 서버가 응답하는 동안 버튼이 비활성화되고 “Creating…”(생성 중…)이라는 문구가 표시됩니다.
    • 서버 검증 오류가 클라이언트 오류와 동일한 UI에 표시됩니다.
    • 오류를 반환했기 때문에 콘솔 로그가 나타나지 않으며, 따라서 폼이 제출되지 않았습니다.
  3. 사용자 이름과 이메일을 수정한 뒤 다시 제출합니다.
    • 이제 폼이 성공적으로 제출되고 데이터가 기록/처리됩니다.

전체 컴포넌트 코드

import { ChangeDetectionStrategy, Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
  Field,
  form,
  minLength,
  required,
  submit,
  ValidationError
} from '@angular/forms/signals';
import { SignupModel, SignupService } from './signup.service';

@Component({
  selector: 'app-form',
  imports: [CommonModule, Field],
  templateUrl: './form.component.html',
  styleUrl: './form.component.scss',
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormComponent {
  protected readonly model = signal({
    // … (model properties)
  });

  // … (rest of the component: form definition, onSubmit implementation, etc.)
}

위 스니펫은 전체 컴포넌트 구조를 보여줍니다. 누락된 부분(모델 필드, 폼 정의, onSubmit 로직)을 이전 섹션에 제시된 코드로 채워 넣으세요.

컴포넌트 (TypeScript)

username: '',
email: '',
});

protected readonly form = form(this.model, s => {
  required(s.username, { message: 'Please enter a username' });
  minLength(s.username, 3, { message: 'Your username must be at least 3 characters' });
  required(s.email, { message: 'Please enter an email address' });
});

private readonly signupService = inject(SignupService);

protected onSubmit(event: Event) {
  event.preventDefault();

  submit(this.form, async f => {
    const value = f().value();
    const result = await this.signupService.signup(value);

    if (result.status === 'error') {
      const errors: ValidationError.WithOptionalField[] = [];

      if (result.fieldErrors.username) {
        errors.push({
          field: f.username,
          kind: 'server',
          message: result.fieldErrors.username,
        });
      }

      if (result.fieldErrors.email) {
        errors.push({
          field: f.email,
          kind: 'server',
          message: result.fieldErrors.email,
        });
      }

      return errors.length ? errors : undefined;
    }

    console.log('Submitted:', value);
    return undefined;
  });
}

템플릿 (HTML)

Back to Blog

관련 글

더 보기 »

Angular 21 — 새로운 점, 변경된 점

Angular 21은 단순화, 성능, 그리고 현대적인 reactive patterns에 중점을 둡니다. 화려한 APIs를 추가하기보다는 Angular 개발자들이 이미 사용하고 있는 것을 강화합니다.