폼 모델은 시그널 폼의 기본 요소이며, 폼의 원본 데이터를 한 곳에서 관리합니다. 이 문서는 폼 모델을 어떻게 만드는지, 값을 어떻게 변경하는지, 유지보수를 편하게 하려면 어떻게 설계해야 하는지 안내합니다.
참고: 폼 모델은 컴포넌트 양방향 바인딩용으로 제공되는 model() 시그널과는 다릅니다.
model()은 부모/자식 컴포넌트 통신에 필요한 입출력 시그널을 정의하지만, 폼 모델은 폼 데이터를 저장하고 있다가 값도 변경할 수 있는 시그널입니다.
폼 모델의 역할
폼 데이터는 계속해서 변경됩니다. 그래서 구조를 제대로 잡지 않으면 데이터가 컴포넌트 프로퍼티로 흩어지면서 추적하기 어렵고, 유효성 검사를 하기도 어려우며, 서버로 보낼 데이터를 모으기도 힘들어집니다.
폼 모델은 폼 데이터를 하나의 시그널로 집중하는 개념입니다. 그리고 폼 모델의 값이 변경되면 자동으로 변경사항을 전파합니다. 사용자가 폼에 데이터를 입력하면, 폼 모델도 자동으로 갱신됩니다.
모델 정의하기
폼 모델은 Angular가 제공하는 signal() 함수로 생성하는 시그널이며, 이 시그널에는 값을 쓸 수 있습니다.
이 시그널은 폼의 데이터 구조를 그대로 저장합니다.
import {Component, signal} from '@angular/core';
import {form, FormField} from '@angular/forms/signals';
@Component({
selector: 'app-login',
imports: [FormField],
template: `
<input type="email" [formField]="loginForm.email" />
<input type="password" [formField]="loginForm.password" />
`,
})
export class LoginComponent {
loginModel = signal({
email: '',
password: '',
});
loginForm = form(this.loginModel);
}
form() 함수는 모델 시그널을 받아서 필드 트리(field tree) 를 생성합니다.
필드 트리는 폼 모델을 반영하는 객체입니다.
필드 트리는 loginForm.email과 같이 객체 참조 방식으로 직접 접근할 수 있으며, 함수처럼 실행하면 필드의 상태에 접근할 수 있습니다.
[formField] 디렉티브는 입력 필드 엘리먼트와 필드 트리의 필드, 즉 UI와 폼 모델을 동기 방식으로 양방향 연결합니다.
지원 타입
시그널 폼은 모델을 순회해서 필드 트리를 구성합니다. 이때 순회하는 객체와 배열, 즉 구조 계층은 일반 JavaScript 객체와 배열이어야 합니다. 중첩 필드가 없는 말단 값은 보통 기본 자료형이나 null입니다. 기본 date, month, time, week 입력은 Date도 지원하며, 커스텀 컨트롤의 말단 값은 해당 컨트롤이 처리할 수 있는 타입이면 됩니다.
기본 구조
interface UserFormModel {
name: string;
birthday: Date | null;
preferences: {
theme: string;
notifications: boolean;
};
tags: string[];
}
const userModel = signal<UserFormModel>({
name: '',
birthday: null,
preferences: {
theme: 'dark',
notifications: true,
},
tags: [],
});
중요: TypeScript에서 허용하더라도 클래스 인스턴스, Map, Set은 구조 계층에서 지원하지 않습니다. 시그널 폼은 실행 시점에 모델 구조를 검증하지 않으므로 이런 값을 예외 없이 받아들인 뒤, 구조에 따라 잘못 동작할 수 있습니다:
- 클래스 인스턴스는 첫 쓰기 시 부모 객체를 얕게 복사하면서 프로토타입을 잃습니다. 이후 메서드, 게터,
instanceof검사가 유지되지 않습니다. - 배열 안의 확장 불가능하거나 동결된 객체에는 재정렬 시 항목 식별을 유지하기 위한 추적 심볼을 할당할 수 없어 예외가 발생합니다.
- **
Map,Set**은 자식을Object.keys로 순회하므로 빈 필드 트리를 만듭니다.
애플리케이션에서 특정 도메인 클래스를 사용하는 경우라면, 폼으로 전달하기 전에 일반 객체로 변환하세요. 폼 모델과 도메인 모델 변환 문서를 참고하세요.
TypeScript 타입 활용하기
TypeScript는 객체 타입을 추론하기 때문에, IDE 지원과 코드 품질 향상을 위해 타입을 명확하게 정의해야 합니다.
interface LoginData {
email: string;
password: string;
}
export class LoginComponent {
loginModel = signal<LoginData>({
email: '',
password: '',
});
loginForm = form(this.loginModel);
}
객체 타입을 명확하게 지정하면 필드 트리도 타입이 정확하게 지정됩니다.
loginForm.email은 FieldTree<string> 타입이 되며, 존재하지 않는 프로퍼티에 접근하면 컴파일 오류가 발생합니다.
// TypeScript는 FieldTree<string> 타입이라는 것을 알고 있습니다.
const emailField = loginForm.email;
// TypeScript 에러: 'username' 프로퍼티는 존재하지 않습니다.
const usernameField = loginForm.username;
필드 초기화
폼 모델을 초기화할 때는 모든 필드 트리에 기본값을 지정해야 합니다.
// 모범사례: 모든 필드를 초기화합니다.
const userModel = signal({
name: '',
email: '',
age: 0,
});
// 에러: 기본값이 빠졌습니다.
const userModel = signal({
name: '',
email: '',
// age 필드의 초기값이 지정되지 않았습니다.
});
옵션 필드라면 빈값이나 null 값을 지정하면 됩니다:
interface UserData {
name: string;
email: string;
phoneNumber: string | null;
}
const userModel = signal<UserData>({
name: '',
email: '',
phoneNumber: null,
});
참고: <input type=text>나 <textarea>는 null 값을 지원하지 않습니다. 빈 값으로 ''을 사용하세요.
필드 기본값으로 undefined를 지정하면 해당 필드는 필드 트리에서 제외됩니다.
그래서 {value: undefined}라는 모델을 사용하면 {}와 동일한 필드 트리가 구성되기 때문에, 의도한 프로퍼티에 접근해도 FieldTree 대신 undefined를 반환합니다.
폼 모델 값 읽기
폼 값은 두 가지 방법으로 접근할 수 있습니다. 모델 시그널로 접근하거나, 개별 필드를 통해 접근하면 됩니다. 두 방식의 목적은 다릅니다.
폼 모델로 읽기
폼 데이터 전체를 확인하려면 모델 시그널에 접근하면 됩니다:
async onSubmit() {
const formData = this.loginModel();
console.log(formData.email, formData.password);
// 서버로 보냅니다.
await this.authService.login(formData);
}
모델 시그널은 데이터 객체 전체를 반환하기 때문에, 폼 데이터 전체를 다룰 때 유용합니다.
폼 필드로 읽기
필드 트리의 개별 필드는 함수입니다.
그래서 실행하면 FieldState 객체를 반환하는데, 이 필드에는 필드 값, 유효성 상태, 사용자의 동작 상태가 시그널로 전달됩니다.
템플릿에서 개별 필드에 접근하거나, 필드값 변화에 반응형으로 동작할 때는 개별 필드를 활용하는 것이 좋습니다:
@Component({
template: `
<p>Current email: {{ loginForm.email().value() }}</p>
<p>Password length: {{ passwordLength() }}</p>
`,
})
export class LoginComponent {
loginModel = signal({email: '', password: ''});
loginForm = form(this.loginModel);
passwordLength = computed(() => {
return this.loginForm.password().value().length;
});
}
필드 상태 객체는 개별 필드의 값을 시그널로 전달하기 때문에, 필드별 정보를 표시하거나 다른 시그널로 연동할 때 활용하면 좋습니다.
참고: 필드 상태 객체는 value() 외에도 valid, invalid, errors, touched, dirty, hidden, disabled 등 다양한 상태를 담고 있습니다.
자세한 내용은 필드 상태 관리 가이드를 참고하세요.
로직으로 폼 모델 값 변경하기
전체 변경하기: set()
set()을 사용하면 폼 모델 전체 값을 한 번에 변경할 수 있습니다:
loadUserData() {
this.userModel.set({
name: 'Alice',
email: 'alice@example.com',
age: 30,
});
}
resetForm() {
this.userModel.set({
name: '',
email: '',
age: 0,
});
}
이 방식은 API로 초기 데이터를 불러 오거나 전체 폼을 재설정할 때 유용합니다.
개별 필드 값 변경하기: set(), update()
개별 필드에 set()을 사용하면 해당 필드 값을 변경할 수 있습니다:
clearEmail() {
this.userForm.email().value.set('');
}
incrementAge() {
this.userForm.age().value.update(currentAge => currentAge + 1);
}
이 방식을 "필드 계층의 값 변경" 이라고 합니다. 이렇게 값을 변경하면 폼 모델에도 변경된 값이 반영됩니다.
예제: API로 데이터 불러오기
데이터를 불러와서 폼 모델에 반영하는 일반적인 로직은 이렇습니다:
export class UserProfileComponent {
userModel = signal({
name: '',
email: '',
bio: '',
});
userForm = form(this.userModel);
private userService = inject(UserService);
ngOnInit() {
this.loadUserProfile();
}
async loadUserProfile() {
const userData = await this.userService.getUserProfile();
this.userModel.set(userData);
}
}
폼 모델 값이 변경되면 별도 로직 없어도 폼 필드 값이 자동으로 갱신됩니다.
예제: 서비스와 동기화하기
상황에 따라 서비스에서 폼 모델을 저장할 수도 있습니다:
@Service()
export class FormService {
private readonly formModel = signal<UserFormModel>({ name: '', birthday: null });
public readonly model = this.formModel.asReadonly();
public syncFormModel(value: UserFormModel) {
this.formModel.set(value);
}
// 서비스의 다른 곳에서 동기 또는 비동기 방식으로 모델을 설정합니다.
}
@Component(...)
export class UserForm {
private readonly formService = inject(FormService);
private readonly formModel = linkedSignal(() => this.formService.model(), {
set: (value) => this.formService.syncFormModel(value),
});
protected readonly form = form(this.formModel);
}
linkedSignal의 계산 함수는 서비스의 폼 모델을 읽고, 옵션에 지정한 set 함수는 컴포넌트의 폼 모델을 서비스의 폼 모델에 동기화합니다.
서비스의 모델이 변경되면 폼 필드가 자동으로 갱신됩니다.
또한 폼 필드가 컴포넌트의 폼 모델을 변경하면 서비스의 폼 모델도 자동으로 갱신됩니다.
양방향 바인딩
[formField] 디렉티브를 사용하면 폼 모델과 폼 상태, 화면이 모두 양방향으로 연결됩니다.
데이터 흐름
변경사항은 양방향으로 전파됩니다:
사용자 입력 → 폼 모델:
- 사용자가 입력 엘리먼트에 글자를 입력합니다.
[formField]디렉티브가 변경사항을 감지합니다.- 필드 상태가 갱신됩니다.
- 모델 시그널이 갱신됩니다.
로직으로 폼 모델 변경 → 화면:
set(),update()와 같은 코드로 폼 모델 값을 변경합니다.- 모델 시그널이 구독자들에게 값을 전파합니다.
- 폼 필드 상태가 갱신됩니다.
[formField]디렉티브가 입력 엘리먼트의 값을 변경합니다.
이 동기화 동작은 자동으로 이루어집니다. 폼 모델과 화면을 같은 상태로 유지하기 위해 별도 구독 코드나 이벤트 핸들러를 구현할 필요는 없습니다.
예제: 양방향 연결
@Component({
template: `
<input type="text" [formField]="userForm.name" />
<button (click)="setName('Bob')">Set Name to Bob</button>
<p>Current name: {{ userModel().name }}</p>
`,
})
export class UserComponent {
userModel = signal({name: ''});
userForm = form(this.userModel);
setName(name: string) {
this.userForm.name().value.set(name);
// 입력 엘리먼트 값은 `Bob` 이 됩니다.
}
}
사용자가 입력 필드에 무언가 입력하면 userModel().name이 갱신됩니다.
그리고 버튼을 클릭하면 setName() 메서드가 실행되면서 입력 필드의 값이 "Bob"으로 변경됩니다.
수동으로 뭔가 해야 하는 것은 없습니다.
모델 패턴
폼 모델 구조는 일반적인 객체나 배열이 모두 가능합니다. 필드에 어떻게 접근할지, 유효성 검사를 어떻게 구조화할지에 따라 폼 모델 구조를 정하면 됩니다.
평평한 모델 vs. 중첩된 모델
평평한 폼 모델은 모든 필드를 한 계층으로 구성하는 것을 의미합니다:
// 평평한 구조
const userModel = signal({
name: '',
email: '',
street: '',
city: '',
state: '',
zip: '',
});
중첩된 폼 모델은 연관된 필드를 그룹으로 묶어서 구성하는 것을 의미합니다:
// 중첩된 구조
const userModel = signal({
name: '',
email: '',
address: {
street: '',
city: '',
state: '',
zip: '',
},
});
평평한 구조를 사용하는 경우:
- 필드 간 연관이 없을 때
- 필드 접근을 단순하게 할 때 (
userForm.cityvsuserForm.address.city) - 유효성 검사가 여러 그룹으로 묶일 때
중첩된 구조를 사용하는 경우:
- 주소와 같이 연관된 필드가 있을 때
- API 구조와 폼 모델을 일치시킬 때
- 유효성 검사하는 그룹이 정해져있는 경우
중첩된 객체로 작업하기
중첩된 폼 모델은 객체 참조 방식으로 접근할 수 있습니다:
const userModel = signal({
profile: {
firstName: '',
lastName: '',
},
settings: {
theme: 'light',
notifications: true,
},
});
const userForm = form(userModel);
// 중첩된 폼 모델에 접근하기
userForm.profile.firstName; // FieldTree<string>
userForm.settings.theme; // FieldTree<string>
템플릿에서도 객체를 바인딩하듯이 사용하면 됩니다:
@Component({
template: `
<input [formField]="userForm.profile.firstName" />
<input [formField]="userForm.profile.lastName" />
<select [formField]="userForm.settings.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
`,
})
배열로 작업하기
폼 모델은 배열로 구성될 수도 있습니다:
const orderModel = signal({
customerName: '',
items: [{product: '', quantity: 0, price: 0}],
});
const orderForm = form(orderModel);
// 인덱스로 배열에 접근하기
orderForm.items[0].product; // FieldTree<string>
orderForm.items[0].quantity; // FieldTree<number>
객체의 각 항목은 자동으로 고유값을 갖고 추적하기 때문에, 배열 안에서 위치가 변경되는 것도 감지할 수 있습니다. 그래서 배열의 순서가 변경되더라도 유효성 검사 상태와 사용자의 상호작용은 문제없이 연결됩니다.
다음 단계
이 문서는 폼 모델을 정의하고 값을 변경하는 방법을 다뤘습니다. 시그널 폼을 더 알아보세요: