Angular Signal Forms에서 폼을 현대적인 방식으로 제출하기
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 로 감싸서, 반응형 상태와 결합할 수 있게 해줍니다.submitResultSignal 은 전송 결과를 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호출이 필요 없습니다.
📌 마무리 정리
| 항목 | 기존 ReactiveForms | Signal Forms |
|---|---|---|
| 선언 방식 | new FormGroup() | signal(this.fb.group()) |
| 값 읽기 | form.value | form().value |
| 변경 감지 | 수동 markAsDirty 등 | 자동 (Signal 기반) |
| 상태 관리 | BehaviorSubject 등 별도 도구 필요 | Signal 자체가 상태 저장소 역할 |
Signal Forms 를 도입하면 코드 가독성이 향상되고, 반응형 상태 관리가 자연스럽게 통합됩니다. 특히 Angular 17 이상에서 toSignal() 과 같은 RxJS‑Signal 연동 헬퍼가 제공되면서, 비동기 로직과 폼 상태를 하나의 일관된 흐름으로 다룰 수 있게 됩니다.
🎉 다음 단계
- 폼 레이아웃을
Angular Material혹은Tailwind CSS와 결합해 UI를 개선합니다. - 다중 단계 폼(Wizard) 구현 시 각 단계마다 별도 Signal Form 을 만들고, 최종 제출 시 하나의 payload 로 합칩니다.
- 테스트 –
@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!');
});
}
}
요약
- Client‑side validation은 Signal Forms와 함께 바로 사용할 수 있습니다.
- Submission은 페이지 새로고침을 방지하고 로딩 상태를 관리하며 서버 측 오류를 처리하기 위해 새로운
submit()API가 필요합니다. - 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>
전체 흐름 테스트
- 클라이언트 측 검증은 필드가 포커스를 잃을 때 작동합니다.
- 클라이언트 검증은 통과하지만 서버 검증에 실패하는 값을 입력합니다.
- 클라이언트 오류가 사라집니다 (폼이 기술적으로 유효합니다).
- 버튼이 활성화됩니다; 클릭합니다.
- 모의 서버가 응답하는 동안 버튼이 비활성화되고 “Creating…”(생성 중…)이라는 문구가 표시됩니다.
- 서버 검증 오류가 클라이언트 오류와 동일한 UI에 표시됩니다.
- 오류를 반환했기 때문에 콘솔 로그가 나타나지 않으며, 따라서 폼이 제출되지 않았습니다.
- 사용자 이름과 이메일을 수정한 뒤 다시 제출합니다.
- 이제 폼이 성공적으로 제출되고 데이터가 기록/처리됩니다.
전체 컴포넌트 코드
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)