Angular FormArray: 폼 구조 동적으로 변경하기

Adam Kim·2026년 2월 3일

angular

목록 보기
98/102

회원가입 폼을 개발하다 보면 사용자의 선택에 따라 입력해야 할 항목이 완전히 달라지는 경우를 마주하게 됩니다. 대표적인 예가 '내국인''외국인'을 구분하여 가입을 받는 경우입니다.

내국인은 주민등록번호를 입력해야 하지만, 외국인은 여권 번호와 국적을 입력해야 합니다. 만약 단순히 *ngIf로 화면만 숨기고 폼 컨트롤을 그대로 두면, 숨겨진 필드의 Validator 때문에 폼 제출(Submit)이 불가능해지는 문제가 발생합니다.

이번 글에서는 FormArray와 최신 Angular 문법을 활용하여, 라디오 버튼 선택에 따라 폼의 구조(Schema) 자체를 안전하게 교체하는 방법을 공유합니다.

문제 상황 (The Problem)

우리가 구현해야 할 요구사항은 다음과 같습니다.

  1. 내국인 / 외국인을 선택하는 라디오 버튼이 있다.
  2. 내국인 선택 시: FormArray에 '주민등록번호' 입력 필드 하나만 생성되어야 한다.
  3. 외국인 선택 시: FormArray가 초기화되고, '여권 번호'와 '국적' 두 개의 입력 필드가 생성되어야 한다.
  4. 타입을 전환할 때 이전 데이터와 유효성 검사 상태는 즉시 초기화되어야 한다.
  5. Submit 시 선택된 타입에 맞는 데이터만 전송되어야 한다.

해결 방법: FormArray 재구성을 통한 동적 관리

단순히 UI를 가리는 것이 아니라, 라디오 버튼의 값이 변할 때마다 FormArray 내부를 clear() 하고 새로운 FormGrouppush() 하는 방식을 사용합니다.

Step 1: 컴포넌트 로직 (TS)

valueChanges를 구독하여 사용자의 선택을 감지하고, 그에 맞춰 FormArray의 구성을 변경해주는 메소드를 호출합니다.

// signup-form.component.ts
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { FormArray, FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';

@Component({
  selector: 'app-signup-form',
  standalone: true,
  imports: [ReactiveFormsModule, JsonPipe],
  templateUrl: './signup-form.component.html',
  styleUrl: './signup-form.component.scss'
})
export class SignupFormComponent implements OnInit, OnDestroy {
  private fb = inject(FormBuilder);
  private destroy$ = new Subject<void>();

  // 1. 기본 폼 구조 정의
  form = this.fb.group({
    name: ['', Validators.required],
    userType: ['local', Validators.required], // 기본값: 내국인
    // 동적 필드를 담을 컨테이너로 FormArray 사용
    additionalInfo: this.fb.array([])
  });

  get additionalInfo(): FormArray {
    return this.form.controls.additionalInfo as FormArray;
  }

  ngOnInit() {
    // 초기값에 맞춰 폼 구성
    this.updateFormSchema('local');

    // 2. 라디오 버튼 변경 감지
    this.form.controls.userType.valueChanges
      .pipe(takeUntil(this.destroy$))
      .subscribe((type) => {
        if (type) {
          this.updateFormSchema(type);
        }
      });
  }

  // 3. 타입에 따른 스키마 교체 로직
  private updateFormSchema(type: string) {
    this.additionalInfo.clear(); // 기존 필드 초기화

    if (type === 'local') {
      // 내국인용 Form Group 생성
      const localGroup = this.fb.group({
        residentId: ['', [Validators.required, Validators.pattern(/^\d{6}-\d{7}$/)]]
      });
      this.additionalInfo.push(localGroup);

    } else if (type === 'foreigner') {
      // 외국인용 Form Group 생성
      const foreignerGroup = this.fb.group({
        passportNum: ['', Validators.required],
        nationality: ['', Validators.required]
      });
      this.additionalInfo.push(foreignerGroup);
    }
  }

  onSubmit() {
    if (this.form.invalid) {
      this.form.markAllAsTouched(); // 에러 메시지 표시 트리거
      return;
    }
    // 현재 활성화된 폼 데이터만 포함됨
    console.log('Submission:', this.form.getRawValue());
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Step 2: Control Flow를 적용한 템플릿 (HTML)

Angular의 Control Flow(@if, @for)를 사용하여 현재 상태에 맞는 UI를 렌더링합니다. FormArray 내부는 항상 현재 선택된 타입에 맞는 컨트롤만 존재하므로 안전합니다.

<form [formGroup]="form" (ngSubmit)="onSubmit()">
  
  <div class="section">
    <label>이름</label>
    <input formControlName="name" placeholder="홍길동">
  </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="additionalInfo">
    @for (group of additionalInfo.controls; track group; let i = $index) {
      <div [formGroupName]="i" class="dynamic-panel">
        
        @if (form.value.userType === 'local') {
          <h4>내국인 필수 정보</h4>
          <div class="field">
            <label>주민등록번호</label>
            <input formControlName="residentId" placeholder="000000-0000000">
            @if (group.get('residentId')?.invalid && group.get('residentId')?.touched) {
              <small class="error">올바른 주민등록번호를 입력해주세요.</small>
            }
          </div>

        } @else {
          <h4>외국인 필수 정보</h4>
          <div class="field">
            <label>여권 번호</label>
            <input formControlName="passportNum" placeholder="M12345678">
            @if (group.get('passportNum')?.invalid && group.get('passportNum')?.touched) {
              <small class="error">여권 번호는 필수입니다.</small>
            }
          </div>
          <div class="field">
            <label>국적</label>
            <input formControlName="nationality" placeholder="예: USA">
          </div>
        }

      </div>
    }
  </div>

  <button type="submit" [disabled]="form.invalid">가입하기</button>
</form>

<pre>{{ form.getRawValue() | json }}</pre>

Step 3: 이 방식이 안전한 이유

단순히 CSS로 숨기거나 HTML에서 조건부 렌더링만 할 경우, 폼 객체(FormGroup) 내부에는 여전히 '여권 번호' 필드의 Required Validator가 살아있을 수 있습니다. 내국인이 주민번호를 다 입력해도 여권 번호가 비어있어 form.invalidtrue가 되는 상황이 발생합니다.
FormArray.clear()를 사용하여 물리적으로 컨트롤을 제거하고 다시 생성함으로써, 현재 화면에 보이는 필드와 실제 폼 모델의 상태를 100% 일치시킬 수 있습니다.

Conclusion

FormArray는 단순히 동일한 항목을 나열하는 리스트뿐만 아니라, 비즈니스 로직에 따라 폼의 섹션을 통째로 교체해야 하는 상황에서도 강력한 컨테이너 역할을 합니다.

이러한 패턴을 익혀두면 복잡한 조건부 폼 유효성 검사를 아주 깔끔하게 해결할 수 있습니다.

profile
Angular2+ Developer

0개의 댓글