Angular forms는 표준 HTML과 완벽하게 작동합니다. input, textarea, select 모두 별도의 노력 없이 Reactive Forms에 바로 통합됩니다. 프레임워크는 이들의 이벤트, 값, 그리고 상태를 이해합니다.
하지만 현대적인 애플리케이션이 표준 요소만으로 운영되는 경우는 드뭅니다. 별점 위젯, 복합 날짜 선택기, 또는 커스텀 컬러 피커가 필요할 수 있습니다. 이러한 요소 중 하나를 form group에 넣으면, Angular는 이를 단순한 HTML로 취급합니다. patchValue는 아무런 동작도 하지 않고, Validators는 이를 무시합니다. 폼은 사용자가 컨트롤과 상호작용하는 시점을 알 수 없으며, form.disable()을 호출해도 커스텀 위젯은 여전히 상호작용이 가능한 상태로 남습니다.
이것이 바로 ControlValueAccessor가 존재하는 이유입니다.
ControlValueAccessor가 실제로 하는 일
ControlValueAccessor는 커스텀 컴포넌트를 일급 폼 시민(first-class form citizen)으로 만들어주는 계약입니다. 이는 Angular Forms API와 여러분의 UI 사이에서 번역가 역할을 합니다. 이를 올바르게 구현하면, 폼의 관점에서 여러분의 컴포넌트는 네이티브 input과 구별할 수 없을 정도가 됩니다. 내장 요소와 마찬가지로 값을 수신하고, 변경 사항을 방출하며, 터치 상태를 보고하고, 비활성화 상태를 준수할 수 있습니다.
이 인터페이스는 네 가지 특정 메서드를 요구합니다. 각 메서드는 서로 다른 방향의 통신을 담당합니다.
writeValue: Form에서 Component로
writeValue(obj)는 인바운드(inbound) 경로입니다. 폼 모델이 업데이트되어 UI에 새로운 값을 전달해야 할 때마다 Angular는 이 메서드를 호출합니다. 만약 form group에서 patchValue({ rating: 4 })를 호출하면, 4라는 값이 writeValue를 통해 컴포넌트 내부로 전달됩니다. 폼을 리셋하면 writeValue는 새로운 초기값이나 null을 받습니다. 이 메서드 내에서의 역할은 들어온 데이터를 컴포넌트의 내부 상태에 매핑하는 것입니다. 컬러 피커를 만든다면, writeValue는 #ff4400과 같은 16진수 문자열을 받게 되며, 여러분은 해당 색상이 선택된 것으로 보이도록 뷰를 업데이트해야 합니다.
여기서 실무적인 주의사항이 하나 있습니다. 특히 동적으로 렌더링되는 컴포넌트, 다이얼로그, 또는 탭 인터페이스 내부에서는 뷰가 완전히 초기화되기 전에 Angular가 writeValue를 호출할 수 있습니다. 컴포넌트가 너무 일찍 DOM이나 자식 컴포넌트에 접근하려고 하면 런타임 에러가 발생할 수 있습니다. 안정적인 패턴은 값을 로컬 프로퍼티에 저장해 두었다가 뷰가 초기화된 후에 적용하거나, 정의되지 않은(undefined) 자식 참조에 대해 방어 코드를 작성하는 것입니다. writeValue가 템플릿이 안정된 상태에서만 호출될 것이라고 가정해서는 안 됩니다.
registerOnChange: Component에서 Form으로
registerOnChange(fn)은 아웃바운드(outbound) 경로를 설정합니다. Angular는 여러분에게 콜백 함수를 전달하며, 여러분은 이 함수의 참조를 유지해야 합니다. 사용자가 컴포넌트 내부의 값을 변경할 때마다, 해당 함수를 새로운 값과 함께 호출해야 합니다. 별점 컴포넌트에서 사용자가 세 번째 별을 클릭하면, 저장된 콜백을 3과 함께 호출합니다. 이 호출은 다시 FormControl로 흘러 들어가 모델을 업데이트하고, 모든 valueChanges 구독을 트리거하며, Validators를 다시 실행합니다.
이 단계를 건너뛰는 것은 폼을 소리 없이 망가뜨리는 가장 흔한 방법입니다. 위젯은 정상적으로 작동하는 것처럼 보일 수 있습니다. 사용자는 별이 켜지거나, 색상이 변하거나, 날짜가 입력되는 것을 봅니다. 하지만 폼 모델은 전혀 업데이트되지 않습니다. Validators는 계속해서 오래된 데이터를 평가하고, 제출 핸들러는 이전 값을 전송합니다. 컴포넌트는 작동하는 것처럼 보이지만, 폼은 사실상 아무것도 인지하지 못하는 상태가 됩니다. 커스텀 컨트롤이 사용자 입력을 받음에도 불구하고 주변 폼이 이를 전혀 알아차리지 못한다면, 거의 항상 이 단계가 원인입니다.
registerOnTouched: 상호작용 보고
폼은 값만 추적하는 것이 아닙니다. 사용자가 필드와 상호작용했는지 여부도 추적합니다. Angular는 validation 에러를 표시하기에 적절한 시점을 결정하기 위해 touched 상태를 사용합니다. 필수 입력 텍스트 필드가 페이지가 로드되자마자 빨간색으로 깜빡거려서는 안 됩니다. 사용자가 탭 키를 눌러 다른 곳으로 이동하거나 다른 곳을 클릭할 때까지 기다려야 합니다.
네이티브 input은 blur 이벤트를 통해 이를 자동으로 처리합니다. 하지만 커스텀 컴포넌트는 그렇지 않습니다. 여러분은 registerOnTouched(fn)을 사용하여 이러한 상호작용을 직접 보고해야 합니다. Angular는 또 다른 콜백을 제공하며, 사용자가 컨트롤과 의미 있는 상호작용을 했다고 판단되는 시점에 이 콜백을 호출하면 됩니다.
정확한 타이밍은 컴포넌트에 따라 다릅니다. 텍스트 형태의 커스텀 input이라면 blur 시점에 호출할 수 있습니다. 별점의 경우 첫 번째 클릭이 적절한 시점일 것입니다. 팝오버가 열리는 컬러 피커라면 팔레트가 닫힐 때까지 기다릴 수도 있습니다. 핵심은 일관성입니다. touched 콜백을 한 번도 호출하지 않으면, Angular는 해당 컨트롤을 계속 pristine 상태로 유지합니다. 사용자가 편집을 명확히 마쳤음에도 validation 에러가 계속 숨겨져 있게 됩니다. 이는 혼란을 야기하고 사용자 경험을 저해합니다.
setDisabledState: 폼 명령 준수하기
동적 폼은 비즈니스 로직에 따라 필드를 지속적으로 활성화하거나 비활성화합니다. FormControl에서 .disable()을 호출하면, Angular는 커스텀 컴포넌트가 이에 반응하기를 기대합니다. setDisabledState(isDisabled)는 불리언(boolean) 값을 받습니다. 이 값이 true이면 UI를 잠가야 합니다.
이는 단순히 클릭을 무시하는 것 이상의 의미를 갖습니다. 내부 버튼을 비활성화하고, 포커스 가능한 상태를 제거하며, 불투명도를 낮추거나 pointer-events: none과 같은 시각적 처리를 적용해야 합니다. 만약 이 메서드를 무시한다면, 폼 모델은 비활성화 상태라고 명시하고 있음에도 컴포넌트는 여전히 완전히 상호작용 가능한 상태로 남게 됩니다. 이는 추적하기 어려운 버그를 만들어냅니다. 사용자가 폼에서 거부할 것으로 예상되는 값을 수정할 수 있게 되며, 저장 버튼이 유효하지 않은 상태에서도 활성화될 수 있습니다. 결국 폼 그룹과 UI가 서로 어긋나게 됩니다.
잘 설계된 커스텀 컨트롤은 setDisabledState를 나중에 생각할 문제가 아닌, 최우선 요구 사항으로 취급합니다.
디버깅 시간을 낭비하게 만드는 실수들
이 인터페이스를 처음 접하는 개발자들이 자주 저지르는 몇 가지 실수가 있습니다.
변경 콜백(change callback) 호출을 잊는 경우. 컴포넌트는 내부 상태를 업데이트하지만, 폼은 그 사실을 전혀 알지 못합니다. 이로 인해 유효성 검사(Validator)가 제대로 작동하지 않고, 부모 폼은 오래된(stale) 데이터를 제출하게 됩니다. 사용자가 새로운 값을 확정하는 즉시 저장된 onChange 함수를 반드시 호출해야 합니다.
touched 콜백을 건너뛰는 경우. 이 콜백이 없으면 Angular는 컨트롤을 touched 상태로 표시하지 않습니다. touched 또는 dirty 상태와 연결된 에러 메시지가 표시되지 않습니다. 사용자는 폼이 정상적으로 보임에도 불구하고 제출되지 않는 상황을 마주하게 되며, 무엇이 잘못되었는지 알 수 있는 시각적 표시도 없게 됩니다.
비활성화 상태를 소홀히 하는 경우. 시각적으로는 활성화되어 보이지만 폼은 비활성화 상태라고 판단하는 컨트롤은 신뢰 경계를 무너뜨립니다. 사용자는 계속 타이핑하거나 클릭할 수 있지만 모델은 이를 무시합니다. 더 심각한 경우, 동기화 주기 중에 모델이 사용자의 입력을 간헐적으로 덮어씌울 수도 있습니다.
NG_VALUE_ACCESSOR 프로바이더를 누락하는 경우. 이는 '조용한 살인자'와 같습니다. 네 가지 메서드를 모두 구현했더라도 컴포넌트의 providers 배열에 NG_VALUE_ACCESSOR를 추가하는 것을 잊으면, Angular는 해당 컴포넌트를 value accessor로 등록하지 않습니다. 코드는 컴파일되고 뷰도 렌더링되지만, 아무런 바인딩도 일어나지 않습니다. 에러 메시지조차 뜨지 않은 채, 컴포넌트가 폼과 완전히 따로 노는 상황이 발생합니다. 항상 데코레이터 메타데이터에 이를 포함해야 합니다.
Signals, Validators, 그리고 현대적인 Angular
ControlValueAccessor는 구식(legacy) API가 아닙니다. 현대적인 Angular 개발 환경에도 완벽하게 부합합니다. 내부 상태를 Signals, 일반 프로퍼티, 또는 RxJS subject로 관리하든 관계없이, 이 네 가지 메서드는 폼 모듈과의 공용 계약(public contract)으로 유지됩니다. writeValue에서 값을 받아들이고, Signals나 상태를 변경한 뒤, Angular가 제공하는 콜백을 통해 값을 내보내면 됩니다.
표준 유효성 검사기(Validators)는 수정 없이 그대로 작동합니다. Validators.required, Validators.min, Validators.pattern 및 커스텀 교차 필드(cross-field) 유효성 검사기는 모두 CVA 기반 컴포넌트를 네이티브 input과 동일하게 평가합니다. 폼 컨트롤은 값과 상태만을 확인합니다. 그 값이 텍스트 박스에서 왔는지, 아니면 직접 구현한 월 피커(month-picker)에서 왔는지는 중요하지 않습니다.
이러한 이식성 덕분에 디자인 시스템과 공유 UI 라이브러리에서 CVA가 중요하게 다뤄집니다. 한 팀이 견고한 전화번호 입력창이나 파일 업로드 위젯을 개발하면, 인터페이스를 단 한 번만 구현하면 됩니다. 그러면 조직 내 다른 모든 팀은 추가적인 연결 작업 없이도 이를 자신의 Reactive Forms에 바로 적용할 수 있습니다. 컴포넌트는 예측 가능한 방식으로 동작하며, 일관된 유효성 검사를 수행하고, 모든 기능 모듈에서 일관되게 비활성화됩니다.
핵심 요약
ControlValueAccessor는 단순히 면접 질문을 위해 암기해야 할 인터페이스가 아닙니다. 이는 커스텀 컴포넌트가 네이티브 HTML 요소와 대등하게 Angular 폼 생태계에 참여할 수 있도록 해주는 가교입니다. 이를 마스터한다는 것은 위젯과 폼 사이의 전체적인 상호작용을 이해한다는 것을 의미합니다. 즉, 값을 수신하고, 변경 사항을 보고하며, 터치(touch) 상태를 알리고, 비활성화 상태를 준수하는 것입니다. 이 네 가지 요소를 제대로 구현한다면, 사용하는 개발자가 의식하지 못할 정도로 자연스럽고 복잡하며 재사용 가능한 폼 컨트롤을 구축할 수 있습니다. 이것이 바로 전문적인 Angular 컴포넌트의 기준입니다.
