Angular로 대규모의 복잡한 애플리케이션을 개발하다 보면, 일관된 디자인 표준을 유지하기 위해 공통으로 사용할 수 있는 UI 컴포넌트 라이브러리를 구축하는 경우가 많습니다. 특히 input, select와 같은 폼 컨트롤(Form Control)은 애플리케이션 전반에 걸쳐 재사용되는 핵심 요소입니다.
하지만 커스텀 폼 컨트롤을 만들 때 단순히 @Input과 @Output 데코레이터를 사용하는 전통적인 방식은 여러 가지 문제를 야기합니다. 예를 들어, 부모 컴포넌트에서 컨트롤의 상태(touched, dirty, valid 등)를 추적하거나 Angular의 폼 유효성 검사 기능을 매끄럽게 연동하기가 매우 번거롭습니다.
이러한 문제를 해결하기 위해 Angular는 ControlValueAccessor라는 강력한 인터페이스를 제공합니다. ControlValueAccessor는 우리가 만든 커스텀 폼 컨트롤이 Angular의 FormControl과 자연스럽게 상호작용할 수 있도록 연결해주는 다리 역할을 합니다.
ControlValueAccessor는 커스텀 컴포넌트를 네이티브 DOM 요소처럼 Angular 폼 시스템에 통합시켜주는 인터페이스입니다. 이 인터페이스를 구현하면, 우리가 만든 컴포넌트에서도 formControlName, [(ngModel)]과 같은 디렉티브를 손쉽게 사용할 수 있습니다.
이 인터페이스는 다음 네 가지 핵심 메서드를 정의합니다.
ControlValueAccessor를 사용하기 전에, 일반적인 @Input/@Output 방식의 문제점을 짚어보겠습니다.
// 부모 컴포넌트 TS (standalone)
import { Component } from '@angular/core';
import { ColorPickerTraditionalComponent } from './color-picker-traditional.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ColorPickerTraditionalComponent],
template: `
<app-color-picker-traditional
[initialColor]="selectedColor"
(colorChange)="onColorChange($event)">
</app-color-picker-traditional>
<!-- touched나 dirty 상태를 확인하려면 추가적인 로직이 필요 -->
@if (isTouched) {
<p>컨트롤을 사용했습니다.</p>
}
`,
})
export class AppComponent {
selectedColor = '#FFFFFF';
isTouched = false;
onColorChange(color: string) {
this.selectedColor = color;
this.isTouched = true; // 상태를 수동으로 관리해야 함
}
}
이 방식은 간단한 값 전달에는 문제가 없지만, 부모 컴포넌트에서 colorChange 이벤트를 항상 수신해야 하고, touched, valid 같은 폼 상태를 추적하기 위해 별도의 코드를 작성해야 하는 번거로움이 있습니다.
이제 ControlValueAccessor를 사용하여 ColorPicker 컴포넌트를 만들어 보겠습니다. 목표는 부모 컴포넌트에서 일반 input 요소처럼 formControl을 사용하여 상태와 값을 관리하는 것입니다.
먼저, 컴포넌트가 ControlValueAccessor로 동작할 수 있도록 providers에 등록해야 합니다. 이때 NG_VALUE_ACCESSOR 토큰과 forwardRef를 사용합니다.
// color-picker.component.ts
import { Component, forwardRef, Input, signal } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
@Component({
selector: 'app-color-picker',
standalone: true,
imports: [], // @for는 내장 기능이므로 별도 import 불필요
templateUrl: './color-picker.component.html',
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => ColorPickerComponent),
multi: true,
},
],
})
export class ColorPickerComponent implements ControlValueAccessor {
@Input() colors: string[] = [];
// 내부 상태를 Signal로 관리
value = signal('');
disabled = signal(false);
// CVA 콜백 함수
onChange: (value: string) => void = () => {};
onTouched: () => void = () => {};
// 1. FormControl -> View
writeValue(value: string): void {
this.value.set(value);
}
// 2. View -> FormControl (변경 콜백 등록)
registerOnChange(fn: any): void {
this.onChange = fn;
}
// 3. View -> FormControl (터치 콜백 등록)
registerOnTouched(fn: any): void {
this.onTouched = fn;
}
// 4. FormControl 비활성화 상태 변경 시
setDisabledState(isDisabled: boolean): void {
this.disabled.set(isDisabled);
}
// 사용자가 색상 선택 시 호출
selectColor(color: string) {
if (!this.disabled()) {
this.value.set(color);
this.onChange(this.value()); // 변경 사항 알림
this.onTouched(); // 터치 상태 알림
}
}
}
<!-- color-picker.component.html -->
<div class="color-palette">
@for (color of colors; track color) {
<span
class="color-box"
[class.selected]="color === value()"
[class.disabled]="disabled()"
[style.background-color]="color"
(click)="selectColor(color)">
</span>
}
</div>
ReactiveFormsModule과 ColorPickerComponent를 imports 배열에 추가합니다.
// app.component.ts
import { Component } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { ColorPickerComponent } from './color-picker/color-picker.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe, ColorPickerComponent],
templateUrl: './app.component.html',
})
export class AppComponent {
availableColors = ['#e63946', '#f1faee', '#a8dadc', '#457b9d', '#1d3557'];
colorForm = new FormGroup({
favoriteColor: new FormControl('#a8dadc'), // 초기값 설정
});
}
이제 부모 컴포넌트에서는 @Input이나 @Output 없이, 일반적인 FormControl을 사용하는 것처럼 ColorPicker 컴포넌트를 사용할 수 있습니다.
// app.component.ts
import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup } from '@angular/forms';
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent implements OnInit {
colorForm: FormGroup;
availableColors = ['#e63946', '#f1faee', '#a8dadc', '#457b9d', '#1d3557'];
ngOnInit() {
this.colorForm = new FormGroup({
favoriteColor: new FormControl('#a8dadc') // 초기값 설정
});
}
}
<!-- app.component.html -->
<form [formGroup]="colorForm">
<h3>Choose your favorite color:</h3>
<app-color-picker
formControlName="favoriteColor"
[colors]="availableColors">
</app-color-picker>
<hr>
<h4>Form Control State:</h4>
<pre>Value: {{ colorForm.get('favoriteColor')?.value | json }}</pre>
<pre>Touched: {{ colorForm.get('favoriteColor')?.touched | json }}</pre>
<pre>Dirty: {{ colorForm.get('favoriteColor')?.dirty | json }}</pre>
</form>
결과적으로, 부모 컴포넌트의 코드가 매우 간결해졌습니다. ControlValueAccessor 덕분에 app-color-picker는 Angular 폼 시스템의 모든 기능을 완벽하게 지원하는 재사용 가능한 컴포넌트가 되었습니다. 값의 변경, 상태(touched, dirty, valid 등) 추적, 유효성 검사 등을 별도의 이벤트 처리 없이 FormControl을 통해 손쉽게 관리할 수 있습니다.
ControlValueAccessor는 Angular에서 재사용 가능한 커스텀 폼 컨트롤을 만드는 표준적이고 강력한 방법입니다. 초기 설정이 다소 복잡해 보일 수 있지만, 한번 구조를 이해하고 나면 Angular 폼 시스템과의 완벽한 통합, 코드의 간결성, 유지보수 용이성 등 많은 이점을 얻을 수 있습니다.
복잡한 폼 컨트롤을 만들어야 한다면, @Input/@Output 조합 대신 ControlValueAccessor를 적극적으로 활용해 보시길 권장합니다.