엔터프라이즈 애플리케이션을 개발하다 보면 사용자 입력에 따라 폼의 구조가 완전히 바뀌어야 하는 경우가 있습니다. '내국인(주민번호)'과 '외국인(여권번호)'을 구분하여 가입을 받는 상황이 대표적입니다.
이때 흔히 저지르는 실수는 부모 컴포넌트 하나에서 모든 타입의 폼 로직을 관리하려고 하는 것입니다. 이렇게 되면 부모 컴포넌트는 모든 하위 타입의 유효성 검사 로직을 알아야 하므로 코드가 비대해지고 유지보수가 어려워집니다.
이번 글에서는 FormArray를 리스트가 아닌 '동적 슬롯(Slot)'으로 활용하여, 부모는 컨테이너 역할만 하고 자식 컴포넌트가 독립적으로 폼 로직을 관리하는 아키텍처를 소개합니다.
부모가 모든 FormGroup을 생성해서 자식에게 Input으로 내려주는 방식은 강한 결합(Tight Coupling)을 초래합니다.
이 문제를 해결하기 위해 통합 폼 인터페이스 패턴을 사용합니다.
FormGroup을 스스로 생성하고, 준비가 되면 @Output으로 부모에게 전달합니다.FormArray 슬롯을 비우고(clear), 전달받은 폼을 연결(push)합니다.form.valid 상태는 자동으로 자식 폼과 동기화됩니다.자식 컴포넌트는 UI와 Validation을 스스로 책임집니다. Input을 받지 않고 Output으로 폼을 내보내는 것이 핵심입니다.
// local-form.component.ts
import { Component, inject, Output, EventEmitter, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
@Component({
selector: 'app-local-form',
standalone: true,
imports: [ReactiveFormsModule],
template: `
<div [formGroup]="form" class="panel">
<h4>🇰🇷 내국인 필수 정보</h4>
<div class="field">
<label>주민등록번호</label>
<input formControlName="residentId" placeholder="000000-0000000" />
@if (form.get('residentId')?.invalid && form.get('residentId')?.touched) {
<small class="error">올바른 형식이 아닙니다.</small>
}
</div>
</div>
`
})
export class LocalFormComponent implements OnInit {
private fb = inject(FormBuilder);
// 1. 내부 폼 그룹 독립적 관리
form: FormGroup = this.fb.group({
residentId: ['', [Validators.required, Validators.pattern(/^\d{6}-\d{7}$/)]]
});
// 2. 부모에게 폼 인스턴스 전달
@Output() formReady = new EventEmitter<FormGroup>();
ngOnInit() {
// 초기화 즉시 부모에게 폼 전달
this.formReady.emit(this.form);
}
}
부모는 dynamicSection이라는 FormArray를 슬롯처럼 사용합니다. 어떤 자식 컴포넌트가 들어오든 상관없이, 전달받은 FormGroup을 슬롯에 끼워 넣기만 하면 됩니다.
// registration.component.ts
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators, FormArray, FormGroup } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { LocalFormComponent } from './local-form.component';
import { ForeignerFormComponent } from './foreigner-form.component';
@Component({
selector: 'app-registration',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe, LocalFormComponent, ForeignerFormComponent],
templateUrl: './registration.component.html'
})
export class RegistrationComponent {
private fb = inject(FormBuilder);
form = this.fb.group({
name: ['', Validators.required],
userType: ['local', Validators.required], // 선택 스위치
dynamicSection: this.fb.array([]) // 동적 슬롯 (Slot)
});
get dynamicSection(): FormArray {
return this.form.controls.dynamicSection;
}
// 3. 자식 컴포넌트로부터 폼을 받아 슬롯에 장착
onChildFormReady(childForm: FormGroup) {
this.dynamicSection.clear(); // 기존 슬롯 비우기
this.dynamicSection.push(childForm); // 새 폼 연결
// 이제 this.form.valid는 자식 폼의 유효성 검사 결과를 포함합니다.
}
onSubmit() {
if (this.form.invalid) {
this.form.markAllAsTouched();
return;
}
// 데이터 페이로드의 'dynamicSection' 안에 자식 폼 데이터가 포함됨
console.log('Submission:', this.form.getRawValue());
}
}
Angular의 @switch 문법을 사용하여 실제로 렌더링할 컴포넌트를 결정합니다. 컴포넌트가 생성(Init)될 때 formReady 이벤트가 발생하여 부모와 연결됩니다.
<form [formGroup]="form" (ngSubmit)="onSubmit()">
<div class="section">
<label>이름</label>
<input formControlName="name" />
</div>
<div class="section">
<label>가입 유형</label>
<div class="radio-group">
<label><input type="radio" formControlName="userType" value="local"> 내국인</label>
<label><input type="radio" formControlName="userType" value="foreigner"> 외국인</label>
</div>
</div>
<hr />
<div formArrayName="dynamicSection">
@switch (form.value.userType) {
@case ('local') {
<app-local-form (formReady)="onChildFormReady($event)"></app-local-form>
}
@case ('foreigner') {
<app-foreigner-form (formReady)="onChildFormReady($event)"></app-foreigner-form>
}
}
</div>
<button type="submit" [disabled]="form.invalid">가입 완료</button>
</form>
<pre>{{ form.getRawValue() | json }}</pre>
FormArray를 데이터 리스트가 아닌 '컴포넌트 슬롯'으로 바라보고, 자식 컴포넌트에게 폼 관리의 책임을 위임함으로써 진정한 의미의 컴포넌트 독립성을 달성했습니다.
@case만 추가하면 됩니다.LocalFormComponent 파일 하나에만 존재합니다.