1. Content Projection
Content Projection이란 부모 컴포넌트 셀렉터 태그 내부에 자식 컴포넌트 혹은 HTML 엘러먼트를 작성하여 특정 템플릿을 전달할 수 있는 기능입니다. 마치 리액트에서 props.children으로 내부에 작성된 엘리먼트를 props로 전달하는 것과 비슷합니다.
그렇다면 Content Projection은 왜 필요할까요? 그 이유를 여기에서 확인할수 있었는데 글에서 예제로 좌측에 아이콘이 달린 input 태그를 나타내는 fa-input 컴포넌트를 작성한다고 가정합니다.
글에서 제시한 Content Projection없이 작성한 fa-input 컴포넌트는 다음의 문제가 있습니다.
- input 태그에서 이용할 수 있는 31개나 되는 attribute를 일일히 전달해야 하는 문제
- (6장에서 소개할) Angular Form과 함께 사용할 때 formControlName과 같은 디렉티브또한 전달해야 하는 문제
- 브라우저 이벤트(keydown 등) 감지를 위해 또다른 코드를 작성해야 하는 번거로운 문제
- dataset과 같이 커스텀 써드파티 속성을 컴포넌트 내부에 또다시 설정해야 하는 문제
위와 같은 문제로 인해 위에서 작성한 fa-input 컴포넌트를 한계점을 지니게 되는 것입니다.
Content Projection을 이용하면 input태그에서 사용가능한 어떤 attribute를 사용하든간에 개발자가 필요로 하는 로직만을 작성할 수 있게 됩니다. 즉, 재사용 가능한 코드를 작성할 수 있게 합니다.
이렇게 fa-input 내부에 input 엘리먼트를 작성하고 fa-input에서 <ng-content>라는 앵귤러 코어 디렉티브가 내부 템플릿을 반영할 수 있게 합니다.
2. Change Detection과 zone.js
변화감지를 공부하기 위해 그전에 앵귤러는 어떻게 DOM을 렌더링하는지를 공부하였습니다.
앵귤러는 내부적으로 Zone.js라는 라이브러리를 사용하고 있는데 README에서 zone.js를 다음처럼 설명합니다.
A Zone is an execution context that persists across async tasks.
자바스크립트의 VM execution turns와 같은 맥락으로 동작한다는데 아직 VM에 대한 이해가 부족하여 원리를 CS단에서 완벽히 이해하진 못했지만 어떻게 동작하는지를 이해한 바대로 쓰면 다음과 같습니다.
- 순수 자바스크립트에서 DOM을 렌더링하기 위해 리플로우/리페인트가 발생할 수 있도록 DOM 요소의 값을 변경해야 함
- Zone.js는 비동기 작업에 특히 초점을 맞춰 DOM이 업데이트되는 작업을 다음으로 가정함
- 모든 브라우저 이벤트
- Ajax와 같은 XHR
- setTimeout() setInterval()
- Zone.js는 이러한 비동기 함수가 실행되면 이 함수를 몽키패치하여 비동기 함수 전/후에 별도의 작업을 수행 후 원래의 비동기 함수를 실행함
Zone은 어떤 함수가 실행되느냐에 따라 함수를 wrapping하거나 microTask하는 등 원래 함수 중간에 끼어들어 렌더링을 유발한 다음에 원래 함수를 실행합니다.
아래는 setTimeout을 어떻게 microTasking하는지 나타내는 코드입니다.
앵귤러에서 zone.js는 비동기 함수가 실행되면 중간에 DOM을 렌더링하고 나서 원래의 함수를 실행합니다. 즉, 앵귤러는 DOM을 랜더링할 필요가 없는 이벤트라도 Zone.js에 의해 반드시 DOM을 렌더링하게 됩니다.
변화감지는 바로 앵귤러가 DOM을 업데이트할 것이지 확인하는 작업을 말하며 이는 위의 사용자 이벤트와 비동기 함수가 실행될 때마다 빈번하게 발생합니다.
변화감지는 크고 복잡한 어플리케이션일수록 불필요하게 자주 발생하여 매우 낭비적이기 때문에 앵귤러는 개발자가 직접 DOM을 렌더링할 것이지를 결정할 수 있도록 변화 감지(Change Detection) 인터페이스를 제공합니다.
바로 Change Detection Strategy를 @Component 데코레이터의 changeDetection key로 설정해주기만 하면 됩니다.
@Component({
selector: 'app-root',
templateUrl: './app.component.ts',
changeDetection: ChangeDetectionStrategy.OnPush
})
ChangeDetectionStrategy에서 설정가능한 값으로 OnPush와 Default가 있는데, Default는 말그대로 무조건 변화 감지를 실행하는 전략이고 OnPush는 실제 레퍼런스가 변경되었는지를 확인하여 레퍼런스가 변경되었을 때만 하위 컴포넌트에게 change detection을 수행합니다. 아래의 상황에서 변화 감지를 수행하게 됩니다.
- @Input() 데이터가 변경
- DOM 엘리먼트가 변경
- changeDetectorRef 수행
규모가 큰 앱일수록 컴포넌트에 OnPush 변화감지 전략을 설정할 것을 권장하지만 OnPush가 언제 DOM을 업데이트하는지를 이해하지 않고 사용한다면 예상했던 대로 동작하지 않을 수 있습니다. 여기에서 설명하는 아래의 예제가 예상한대로 동작할지 봅시다.
toggleFirst()가 실행되면 this.todos[0]의 complete가 변하고 DOM에 반영이 될까요?
정답은 OnPush가 이를 감지하지 못해 DOM이 업데이트되지 않습니다. @Input() 에 설정한 todo가 변하는 것처럼 보이지만 결과적으로는 참조값이 변하지 않기 때문에 DOM이 업데이트되지 않습니다.
반면, addTodo()를 통해 새로운 todo 객체를 추가하고 이를 this.todos로 교체하기 때문에 이는 레퍼런스가 변한것으로 간주되어 OnPush에서 변화가 감지되어 DOM을 업데이트합니다.
위에서 complete 속성에 따라 DOM을 임의로 업데이트하고 싶다면 changeDetectorRef를 수행하면 됩니다.
changeDetectorRef는 디렉티브에서 직접 변화감지를 수행하도록 작성할 수 있는 클래스입니다.
아까 변화 감지에 대해 한가지 설명을 빠뜨린 부분이 있는데 앵귤러의 각 컴포넌트는 내부에 change detector를 가지고 있고, 이들은 트리 형태로 되어 있습니다. 이 글에서 설명하는 것처럼 루트 컴포넌트에서 발생한 변화 감지는 그대로 모든 컴포넌트들에게도 전파되어 변화감지가 발생하는데 OnPush 정책을 통해 위의 세가지 변화 조건 외의 모든 이벤트는 하위 컴포넌트에 변화감지를 수행하지 않도록 할 수 있습니다.
앵귤러 첫걸음(조우진, 2017)이라는 책에서 설명이 잘 되어 있는 부분을 캡쳐하였습니다.
changeDetectorRef는 변화 감지 트리에서 컴포넌트를 제거하거나 추가하는 등의 작업을 수행하는 것 뿐만 아니라 OnPush에 의해 변화감지가 수행되지 않는 컴포넌트를 강제로 변화감지를 수행하도록 할 수도 있습니다.
위의 toggleFirst()가 실행되었을 때 무조건 변화감지를 수행하고 싶다면 changeDetectorRef의 markForCheck()를 호출합니다.
이 글에서는 변화 감지를 이용한 앵귤러 성능 최적화에 대해 다루고 있는데 아직 앵귤러 초보인 저에게는 어려운 부분이 있어 다른 글로 한 번 정리할 예정입니다.
3. DOM 요소에 직접 접근하기
리액트에서 useRef()나 React.createRef()로 DOM 노드 혹은 컴포넌트에 직접적으로 접근하여 조작할 수 있던 것처럼 앵귤러에서도 요소에 직접적으로 접근할 수 있는 데코레이터, @ViewChild()와 @ViewChildren()를 제공합니다.
아래는 이 글에서 소개하는 @ViewChild()를 사용하여 컴포넌트에 접근하는 예제입니다.
@ViewChild의 첫번째 인자인 selector로 사용할 수 있는 값은 아래와 같습니다. 아직 블로그에서 소개하지 않은 프로바이더 또한 @ViewChild로 접근할 수 있습니다.
- @Component(), @Directive() 데코레이터 하단의 클래스
- 템플릿 변수 문자열 (예: <app-view #myTemplate></app-view>라면 @ViewChild('myTemplate')로 사용 가능)
- 자식 컴포넌트에서 정의된 프로바이더 (예: @ViewChild(myService))
- 문자열 토큰으로 정의된 프로바이더 (예: @ViewChild('token'))
- TemplateRef
@ViewChild로는 change detector가 셀렉터와 매치되는 가장 첫번째 엘리먼트만을 찾지만, 셀렉터와 매치되는 모든 엘리먼트들의 queryList를 받고 싶을 땐 @ViewChildren을 사용할 수 있습니다. 특히 queryList의 메소드 중 changes()는 값의 변화를 Observable로 구독하여 인지할 수 있어 쿼리 리스트가 변경될 때마다 추가적인 로직을 구현할 수 있습니다.
3.1 Content Projection된 템플릿에 접근하기
컴포넌트 내부의 요소들은 @ViewChild로 접근할 수 있었지만 @ViewChild는 <ng-content> 내에 존재하는 요소에는 접근할 수 없습니다. <ng-content> 안의 요소에 접근하기 위해서는 @ContentChild, @ContentChildren 데코레이터를 사용합니다.
위의 코드처럼 app-child를 정의하고, app-parent에서 <ng-content>에 속하는 ChildComponent QueryList를 @ContentChild로 접근합니다.
아래는 app.component.ts에서 app-parent 내부에 app-child를 반복적으로 렌더링하였습니다.
예상대로 ParentComponent의 this.ids에는 ['name', 'birth', 'hp']가 존재할 것입니다.
4. 실습
(1)에서 만든 컴포넌트는 아직 아무런 인터렉션이 없는 상태라 이번엔 사용자 이벤트를 받는 기능을 추가하고 OnPush정책으로 변화 감지 횟수를 최소화하는 최적화를 적용해보겠습니다.
이제 메뉴에서 수량을 조절해 주문이 가능하도록 구현해보겠습니다. 메뉴를 클릭하면 아래에 카운터가 뜨고 수량을 조절할 수 있습니다.
아직 Observable을 들어가지 않아 억지지만 setTimeout()에서 count를 조절해보겠습니다. 그리고 changeDetection을 OnPush로 설정합니다.
이렇게 만든 카운터는 우리가 원하는대로 동작하지 않을 것입니다.
맨 처음 setTimeout은 렌더링을 발생시키지 않지만 그 이후의 DOM 이벤트가 렌더링을 발생시켜 수량이 한차례 늦게 렌더링됩니다. 이것을 정상적으로 동작시키기 위해서는 changeDetectorRef를 사용하여 임의로 변화 감지를 수행토록 합니다.
이제 헤더 바로 하단에 우리가 1잔 이상 주문하려는 메뉴를 간략하게 띄워보겠습니다.
위의 그림처럼 각 메뉴의 가격과 잔 수가 주문 리스트에 표현되고, 총 금액을 계산해야 합니다.
ct-order-list 컴포넌트에 view-order-item이 반복적으로 들어갈 것입니다.
이 때 view-order-item의 changeDetectionStretegy를 OnPush로 설정하면, @Input으로 전달하는 데이터는 오브젝트 객체이므로 내부의 값을 아무리 바꿔도 결국 레퍼런스가 변하지 않아 변화 감지가 수행되지 않습니다.
사실 컴포넌트만으로만 이를 구현하면 이렇게 OnPush를 적용하면 원하는대로 동작하지 않는 문제가 있어 데이터를 리듀서에서 관리하게 됩니다.
또한, view-item에서 컴포넌트간 데이터 공유를 위해 @Output으로 EventEmitter를 등록하였습니다.
이렇게 컴포넌트가 많아져서 @Input, @Output으로 계속 데이터를 주고받는 과정이 복잡해지게 되면 해당 데이터 및 데이터 관리 로직을 서비스와 리듀서로 위임하면 훨씬 간결한 코드를 작성할 수 있게되는데 이것은 추후에 리팩토링해보겠습니다.
이렇게 컴포넌트와 디렉티브에 대한 기본 공부가 끝났습니다.
최대한 1장 주제에 맞는 예제를 구현하려고 서비스라던가 모듈이라던가 전혀 사용하지 않고 구현했더니 컴포넌트가 서너개 생기고 컴포넌트들 간 데이터를 공유하려고 하다보니 데이터 흐름도 햇갈리고 깔끔하지 않은 구조로 구현되었습니다.
2, 3장의 주제인 모듈과 서비스에서 이를 리팩토링해서 데이터를 훨씬 간편하게 관리할 수 있도록 바꾸겠습니다:)
'Today I Learn > Angular 기초' 카테고리의 다른 글
5. 서비스와 의존성 주입 (2) (0) | 2020.07.04 |
---|---|
4. 서비스와 의존성 주입 (1) (0) | 2020.06.30 |
3. Modules (0) | 2020.06.26 |
1. Component와 Directive (1) (2) | 2020.06.19 |
0. Angular 공부 목록 (2) | 2020.06.15 |