AngularDart에서 어노테이션은 한 번만 — 상속되는 메타데이터
AngularDart에서 어노테이션은 한 번만 — 상속되는 메타데이터
AngularDart로 컴포넌트를 만들다 보면, 비슷한 기능을 가진 컴포넌트나 디렉티브마다 같은 메타데이터를 반복해서 다시 선언해야 할 때가 있어요. 이 글에서는 AngularDart 4.0부터 상위 타입의 메타데이터를 상속받을 수 있게 되어 이런 중복을 없앨 수 있다는 것을 코드 예시와 함께 설명해요.
본문
AngularDart는 개발자가 클래스 정의에 메타데이터를 어노테이션으로 붙여 컴포넌트와 디렉티브를 만들 수 있게 해 줘요. 이 메타데이터는 의존성 주입(dependency injection), 템플릿 구성, 데이터 바인딩 등에 필요한 코드를 컴파일러가 생성하도록 지시해요.
import 'package:angular/angular.dart';
@Component(
selector: 'greet',
template: '<p>Hello !</p>',
)
class GreetComponent {
String name;
}
얼마 전까지만 해도 AngularDart 컴파일러는 상위 타입(supertype)의 메타데이터를 처리하지 않았어요. 그 말은, 상속을 통해 여러 컴포넌트와 디렉티브 사이에서 공통 기능을 공유하려면 각 클래스에 모든 메타데이터를 다시 선언해야 한다는 뜻이었어요.
그런 패턴이 실제로 어떤 모습이었는지 볼게요.
이 글 전체에서 아래 예시를 다시 꺼내서 메타데이터 상속이 주는 이점을 보여줄 거예요. 이 예시는 Google에서 사용하는 공식 Material Design components for AngularDart에서 가져온 단순화된 버전이에요.
import 'dart:async';
import 'dart:html';
import 'package:angular/angular.dart';
@Directive(selector: '[my-button]')
class MyButtonDirective {
final StreamController<UIEvent> _trigger = new StreamController<UIEvent>();
@Input()
bool disabled = false;
@Output()
Stream<UIEvent> get trigger => _trigger.stream;
@HostListener('click', const [r'$event'])
void onClick(MouseEvent event) {
if (disabled) return;
_trigger.add(event);
}
@HostListener('keypress', const [r'$event'])
void onKeyPress(KeyboardEvent event) {
if (disabled) return;
if (event.keyCode == KeyCode.ENTER) {
_trigger.add(event);
}
}
}
여기 호스트 요소에 버튼 기능을 더해 주는 간단한 디렉티브가 있어요. 호스트 요소를 클릭하거나, 포커스된 상태에서 엔터 키를 누르면 trigger 출력(output)을 통해 이벤트를 내보내요. 이벤트를 내보내지 않도록 disabled로 만들 수도 있어요. 상속을 이용하면 이 로직을 재사용해서 버튼 컴포넌트를 만들 수 있어요.
import 'package:angular/angular.dart';
import 'my_button_directive.dart';
@Component(
selector: 'my-button',
template: '<ng-content></ng-content>',
inputs: const ['disabled'],
outputs: const ['trigger'],
host: const {
'(click)': r'onClick($event)',
'(keypress)': r'onKeyPress($event)',
},
)
class MyButtonComponent extends MyButtonDirective {}
이제 디렉티브에서 버튼 동작을 상속받는 컴포넌트가 생겼어요. 그런데 파생된 컴포넌트 어노테이션에서 input, output, host 리스너를 다시 선언해야 한다는 걸 눈치채셨나요?
버전 4.0부터는 파생 타입에 메타데이터를 다시 선언할 필요가 없어요!
import 'package:angular/angular.dart';
import 'my_button_directive.dart';
@Component(
selector: 'my-button',
template: '<ng-content></ng-content>',
)
class MyButtonComponent extends MyButtonDirective {}
이제 MyButtonComponent는 MyButtonDirective와 같은 input, output, host 리스너를 암시적으로 갖게 돼요. 클래스가 보통 속성과 메서드를 상속받는 것과 비슷한 방식이에요. 간단하고 — 바라건대 설득력 있는 — 예시를 봤으니, 이제 정확히 무엇이 상속되는지, 어떻게 동작하는지 살펴볼게요.
무엇이 상속되나요?
이제 다음 어노테이션들은 슈퍼클래스, 인터페이스, 믹스인을 포함한 모든 상위 타입에서 상속돼요.
@ContentChild/@ContentChildren@HostBinding@HostListener@Input@Output@ViewChild/@ViewChildren
즉, 파생 클래스에서 위 어노테이션 중 어떤 것도 다시 선언할 필요가 없다는 뜻이에요.
무엇은 상속되지 않나요?
템플릿, 스타일, 그리고 그 밖의 @Directive 매개변수는 상속되지 않아요. 속성이나 메서드에 바인딩된 메타데이터만 상속돼요. 이렇게 정한 이유는, 파생 타입은 접근자와 메서드를 오버라이드하면서 super를 통해 원래 구현을 참조할 수 있는 반면, 다른 메타데이터에 바인딩된 값에는 그런 메커니즘이 없기 때문이에요.
상속된 메타데이터는 불변(immutable)
파생 타입에서 어노테이션을 다시 선언하는 것은 여전히 유효해요. 다만 상속받은 메타데이터를 수정하거나 다른 속성에 바인딩하는 것은 허용되지 않아요.
import 'package:angular/angular.dart';
import 'my_button_directive.dart';
@Directive(selector: '[illegal-button]')
class IllegalButtonDirective extends MyButtonDirective {
@Input('notEnabled') // Changing the binding name causes a compile error.
set disabled(bool value) {
super.disabled = value;
}
}
이 제한은 나중에 풀릴 수도 있지만, 지금으로서는 도구를 단순하게 유지하고 컴포넌트와 디렉티브를 쉽게 대체할 수 있게 하기 위해 필요한 선택이에요.
메타데이터 베이스 타입
메타데이터 상속 덕분에 생기는 흥미로운 결과가 하나 있어요. 디렉티브나 컴포넌트가 아닌 클래스도 이제 어노테이션을 붙여 다른 클래스의 베이스 타입이나 믹스인 역할을 하게 할 수 있어요. 우리 예시를 조금 더 발전시켜서 살펴볼게요.
현재 버튼 컴포넌트 구현에는 문제가 하나 있어요. 버튼이 포커스 가능(focusable)하지 않다는 거예요. 그 말은 접근성이 떨어지고, 키 누름 리스너로도 동작시킬 수 없다는 뜻이에요. 이 문제는 버튼 디렉티브의 호스트 요소가 포커스 가능하지 않을 때도 동일하게 나타나요. 버튼 디렉티브가 호스트 요소에 tabindex 속성을 설정하도록 하면 이 문제를 해결할 수 있어요.
MyButtonDirective가 직접 이걸 구현할 수도 있지만, 포커스 가능 동작이 필요한 다른 컴포넌트들도 있을 거라고 상상해 볼게요. 그래서 우리는 디렉티브나 컴포넌트가 extends하거나 믹스인할 수 있는 추상 클래스를 만들 거예요.
import 'package:angular/angular.dart';
abstract class HasTabIndex {
bool get disabled;
@HostBinding('tabindex')
String get tabIndex => disabled ? null : '0';
}
이제 HasTabIndex를 간단히 extends하거나 믹스인하면 버튼 디렉티브 — 그리고 그에 따라 파생 컴포넌트 — 를 포커스 가능하게 만들 수 있어요.
@Directive(selector: '[my-button]')
class MyButtonDirective extends HasTabIndex {
...
}
마무리
상속 가능한 메타데이터가 공통 기능을 가진 컴포넌트와 디렉티브 사이에서 코드 중복을 줄여 주는 걸 살펴봤어요. 게다가 메타데이터를 관련 필드나 메서드 옆에 둘 수 있게 되어 가독성도 좋아져요. 여러분이 직접 프로젝트를 만들고 유지보수할 때 이 기능이 도움이 되길 바래요.