컴포넌트 독립성을 보장하는 다형성 폼(Polymorphic Form) 설계

Adam Kim·2026년 2월 6일

angular

목록 보기
99/102

엔터프라이즈 애플리케이션을 개발하다 보면 사용자 입력에 따라 폼의 구조가 완전히 바뀌어야 하는 경우가 있습니다. '내국인(주민번호)''외국인(여권번호)'을 구분하여 가입을 받는 상황이 대표적입니다.

이때 흔히 저지르는 실수는 부모 컴포넌트 하나에서 모든 타입의 폼 로직을 관리하려고 하는 것입니다. 이렇게 되면 부모 컴포넌트는 모든 하위 타입의 유효성 검사 로직을 알아야 하므로 코드가 비대해지고 유지보수가 어려워집니다.

이번 글에서는 FormArray를 리스트가 아닌 '동적 슬롯(Slot)'으로 활용하여, 부모는 컨테이너 역할만 하고 자식 컴포넌트가 독립적으로 폼 로직을 관리하는 아키텍처를 소개합니다.

문제 상황: 비대해진 부모 컴포넌트 (The God Component)

부모가 모든 FormGroup을 생성해서 자식에게 Input으로 내려주는 방식은 강한 결합(Tight Coupling)을 초래합니다.

  1. 관심사 분리 위배: 부모 컴포넌트가 '내국인'의 정규식과 '외국인'의 여권 번호 규칙을 모두 Import 해야 합니다.
  2. 확장성 부족: '법인 회원' 타입이 추가되면 부모 컴포넌트의 로직, 템플릿, 폼 정의를 모두 수정해야 합니다.

해결 방법: FormArray를 활용한 슬롯 패턴

이 문제를 해결하기 위해 통합 폼 인터페이스 패턴을 사용합니다.

  1. 자식 컴포넌트: 자신의 FormGroup을 스스로 생성하고, 준비가 되면 @Output으로 부모에게 전달합니다.
  2. 부모 컴포넌트: 이벤트를 감지하면 자신의 FormArray 슬롯을 비우고(clear), 전달받은 폼을 연결(push)합니다.
  3. 결과: 부모는 자식 폼의 내부 구조를 몰라도 되며, form.valid 상태는 자동으로 자식 폼과 동기화됩니다.

Step 1: 독립적인 자식 컴포넌트 (Child)

자식 컴포넌트는 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);
  }
}

Step 2: 슬롯 관리자로서의 부모 (Parent)

부모는 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());
  }
}

Step 3: Control Flow 템플릿 구현

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>

Conclusion

FormArray를 데이터 리스트가 아닌 '컴포넌트 슬롯'으로 바라보고, 자식 컴포넌트에게 폼 관리의 책임을 위임함으로써 진정한 의미의 컴포넌트 독립성을 달성했습니다.

  • 확장성: 새로운 가입 유형이 생겨도 부모 로직을 건드릴 필요 없이 새 컴포넌트를 만들고 @case만 추가하면 됩니다.
  • 유지보수성: 내국인 관련 로직은 LocalFormComponent 파일 하나에만 존재합니다.
  • 데이터 무결성: 부모 폼은 항상 현재 활성화된 자식 폼의 상태를 실시간으로 반영합니다.
profile
Angular2+ Developer

0개의 댓글