심화 가이드
폼

폼 모델

폼 모델은 시그널 폼의 기본 요소이며, 폼의 원본 데이터를 한 곳에서 관리합니다. 이 문서는 폼 모델을 어떻게 만드는지, 값을 어떻게 변경하는지, 유지보수를 편하게 하려면 어떻게 설계해야 하는지 안내합니다.

참고: 폼 모델은 컴포넌트 양방향 바인딩용으로 제공되는 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] 디렉티브를 사용하면 폼 모델과 폼 상태, 화면이 모두 양방향으로 연결됩니다.

데이터 흐름

변경사항은 양방향으로 전파됩니다:

사용자 입력 → 폼 모델:

  1. 사용자가 입력 엘리먼트에 글자를 입력합니다.
  2. [formField] 디렉티브가 변경사항을 감지합니다.
  3. 필드 상태가 갱신됩니다.
  4. 모델 시그널이 갱신됩니다.

로직으로 폼 모델 변경 → 화면:

  1. set(), update()와 같은 코드로 폼 모델 값을 변경합니다.
  2. 모델 시그널이 구독자들에게 값을 전파합니다.
  3. 폼 필드 상태가 갱신됩니다.
  4. [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.city vs userForm.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>

객체의 각 항목은 자동으로 고유값을 갖고 추적하기 때문에, 배열 안에서 위치가 변경되는 것도 감지할 수 있습니다. 그래서 배열의 순서가 변경되더라도 유효성 검사 상태와 사용자의 상호작용은 문제없이 연결됩니다.

다음 단계

이 문서는 폼 모델을 정의하고 값을 변경하는 방법을 다뤘습니다. 시그널 폼을 더 알아보세요: