회원가입 폼을 개발하다 보면 사용자의 선택에 따라 입력해야 할 항목이 완전히 달라지는 경우를 마주하게 됩니다. 대표적인 예가 '내국인'과 '외국인'을 구분하여 가입을 받는 경우입니다.
내국인은 주민등록번호를 입력해야 하지만, 외국인은 여권 번호와 국적을 입력해야 합니다. 만약 단순히 *ngIf로 화면만 숨기고 폼 컨트롤을 그대로 두면, 숨겨진 필드의 Validator 때문에 폼 제출(Submit)이 불가능해지는 문제가 발생합니다.
이번 글에서는 FormArray와 최신 Angular 문법을 활용하여, 라디오 버튼 선택에 따라 폼의 구조(Schema) 자체를 안전하게 교체하는 방법을 공유합니다.
우리가 구현해야 할 요구사항은 다음과 같습니다.
FormArray에 '주민등록번호' 입력 필드 하나만 생성되어야 한다.FormArray가 초기화되고, '여권 번호'와 '국적' 두 개의 입력 필드가 생성되어야 한다.단순히 UI를 가리는 것이 아니라, 라디오 버튼의 값이 변할 때마다 FormArray 내부를 clear() 하고 새로운 FormGroup을 push() 하는 방식을 사용합니다.
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();
}
}
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>
단순히 CSS로 숨기거나 HTML에서 조건부 렌더링만 할 경우, 폼 객체(FormGroup) 내부에는 여전히 '여권 번호' 필드의 Required Validator가 살아있을 수 있습니다. 내국인이 주민번호를 다 입력해도 여권 번호가 비어있어 form.invalid가 true가 되는 상황이 발생합니다.
FormArray.clear()를 사용하여 물리적으로 컨트롤을 제거하고 다시 생성함으로써, 현재 화면에 보이는 필드와 실제 폼 모델의 상태를 100% 일치시킬 수 있습니다.
FormArray는 단순히 동일한 항목을 나열하는 리스트뿐만 아니라, 비즈니스 로직에 따라 폼의 섹션을 통째로 교체해야 하는 상황에서도 강력한 컨테이너 역할을 합니다.
이러한 패턴을 익혀두면 복잡한 조건부 폼 유효성 검사를 아주 깔끔하게 해결할 수 있습니다.