많은 분들은 리액트(React)의 '가상돔(Virtual DOM)' 이야기를 들어보셨을 수도 있겠습니다. 리액트는 상태 변경을 요청할 때 가상돔을 재생성하여 필요한 곳을 업데이트 합니다. 저는 최근에 앵귤러를 다루면서 성능적인 이슈를 대응할 필요가 있었고 이를 위해서 렌더링 방식에 대해서 알아야 했습니다.
앵귤러에는 가상돔과 같은 개념은 존재하지 않습니다.
앵귤러는 대체 어떻게 작동하고 있는 걸까요? 🤔
✨ 1단계: 포착 (Zone)
웹 사이트에는 눈에 보이지 않는 수많은 일들이 계속 일어납니다.
- 사용자가 마우스를 클릭하거나 키보드를 누르는 이벤트
- "3초 뒤에 이 함수 실행해줘!" 같은 setTimeout
- 서버에서 데이터를 가져오는 HTTP 요청
앵귤러는 이런 일들이 언제 시작되고 언제 끝나는지 알아야 합니다. "데이터 로딩이 끝났으니 이제 화면을 바꿔야겠군!" 하고 눈치챌 수 있어야 하니까요.
이를 해결하는 것이 Zone.js 입니다. Zone.js는 위에 나열된 브라우저의 비동기 동작들을 몽키패칭하여 작업을 추적할 수 있습니다. 비동기 작업이 완료되면 Zone.js가 이를 감지하고 Angular에게 알려줍니다. 아래 코드를 보면 이해하기 쉽습니다.
function addEventListener(eventName, callback) {
// call the real addEventListener
callRealAddEventListener(eventName, function() {
// first call the original callback
callback(...);
// and then run Angular-specific functionality
var changed = angular.runChangeDetection();
if (changed) {
angular.reRenderUIPart();
}
});
}
✨ 2단계: 변경 감지 (runChangeDetection)
Zone.js가 "작업 완료!" 신호를 보내면, Angular는 이제 "무엇이 변했는지" 확인할 차례입니다. 1단계의 코드 예시에서 angular.runChangeDetection()
부분이 바로 이 역할을 개념적으로 나타냅니다. 이것이 바로 변경 감지(Change Detection) 과정입니다.
React의 가상돔(Virtual DOM)과 달리, Angular는 컴포넌트의 상태(데이터)가 이전과 비교해서 달라졌는지 직접 확인합니다. 어떻게 할까요?
- 데이터 바인딩 확인: Angular는 템플릿에 있는 모든 데이터 바인딩(예:
{{ title }}
,[value]="count"
)을 기억하고 있습니다. 변경 감지가 시작되면, Angular는 각 바인딩에 연결된 컴포넌트의 현재 값(예:this.title
,this.count
)을 확인합니다. - 이전 값과 비교: Angular는 이전에 기억해둔 값과 현재 값을 비교합니다. 만약
this.title
의 값이 이전과 다르다면, "변경되었군!"이라고 표시합니다. - 변경 상태 전파: 이 과정은 애플리케이션의 모든 컴포넌트를 대상으로 (기본 전략상) 위에서 아래로 진행됩니다. 부모 컴포넌트부터 자식 컴포넌트까지 차례대로 확인하며 변경된 부분을 찾아냅니다.
runChangeDetection()
은 바로 이 꼼꼼한 '비교 및 확인 작업' 전체를 의미합니다. 이 과정의 결과로 "어떤 부분이 변경되었는지"에 대한 정보가 모입니다. 코드 예시의 var changed
변수는 이 비교 과정에서 단 하나라도 변경된 사항이 있었는지를 나타내는 셈이죠.
✨ 3단계: 화면 업데이트 (reRenderUIPart)
변경 감지(2단계)를 통해 "어떤 데이터가 바뀌었는지" 확인했다면, 이제 그 결과를 실제 사용자 화면에 반영할 시간입니다. 코드 예시의 if (changed) { angular.reRenderUIPart(); }
부분이 이 마지막 단계를 나타냅니다.
여기서 중요한 점은 Angular가 변경된 부분만 정확히 찾아 실제 DOM을 업데이트한다는 것입니다. 이에 필요한 정보들은 앵귤러가 앱을 컴파일하면서 기억해 둡니다.
- 가상돔 없음: React처럼 가상돔 전체를 새로 만들고 비교하는 과정이 없습니다.
- 직접 DOM 조작: Angular는 2단계에서 변경되었다고 확인된 데이터 바인딩과 연결된 실제 DOM 요소를 직접 찾아갑니다.
- 예를 들어, 컴포넌트의
title
속성이 'Hello'에서 'World'로 바뀌었고, 이것이 템플릿의<h1>{{ title }}</h1>
부분과 연결되어 있다면, Angular는 정확히 해당<h1>
태그를 찾아 그 안의 텍스트 내용만 'World'로 업데이트합니다. - 만약
[disabled]="isButtonDisabled"
바인딩 값이false
에서true
로 바뀌었다면, 해당 버튼 요소의disabled
속성을 직접 추가합니다.
- 예를 들어, 컴포넌트의
- 최소한의 업데이트: 전체 HTML 구조를 새로 그리는 것이 아니라, 마치 외과 의사가 수술하듯 필요한 부분만 정밀하게 수정합니다. 이 방식 덕분에 효율적인 화면 업데이트가 가능합니다.
따라서 reRenderUIPart()
는 비록 개념적인 이름이지만, 실제로는 Angular가 변경된 데이터를 바탕으로 실제 DOM의 특정 부분(텍스트 노드, 속성 등)을 직접적이고 효율적으로 수정하는 과정을 의미합니다.
✨ 보너스: 성능을 높히는 OnPush 전략
지금까지 설명한 Angular의 변경 감지 과정(Zone.js 감지 -> 변경 확인 -> DOM 업데이트)은 기본(Default) 전략입니다. 이 전략은 매우 편리하지만, 애플리케이션이 복잡해지면 때로는 불필요한 확인 작업이 성능에 부담을 줄 수도 있습니다. Zone.js가 아주 사소한 비동기 작업 완료 신호만 보내도, Angular는 잠재적으로 모든 컴포넌트의 변경 여부를 확인하기 때문이죠.
이럴 때 사용할 수 있는 강력한 최적화 기법이 바로 ChangeDetectionStrategy.OnPush
전략입니다.
OnPush 전략이란?
컴포넌트 설정에 changeDetection: ChangeDetectionStrategy.OnPush
를 추가하면, 해당 컴포넌트는 더 이상 모든 Zone.js의 신호에 반응하여 무조건 변경 감지를 수행하지 않습니다. 대신, 다음과 같은 특정 조건 중 하나가 만족될 때만 변경 감지를 진행합니다.
- 새로운 참조(Reference)의
@Input()
변경: 컴포넌트의@Input()
프로퍼티로 새로운 객체나 배열 참조가 전달될 때. (주의: 객체 내부의 속성만 바뀌거나 배열 요소만 추가/삭제되는 등 참조 자체가 변경되지 않으면 감지하지 못합니다.) - 컴포넌트 또는 자식 요소의 이벤트 발생: 해당 컴포넌트의 템플릿 내부나 그 자식 컴포넌트에서 이벤트(예: 클릭, 입력 등)가 발생하여 이벤트 핸들러가 실행될 때.
async
파이프 사용: 템플릿에서async
파이프가 새로운 값을 방출(emit)할 때. (async
파이프는 내부적으로 변경 감지를 요청합니다.)- 명시적 요청: 개발자가 컴포넌트의
ChangeDetectorRef
를 주입받아markForCheck()
메소드를 직접 호출하여 변경 감지가 필요함을 명시적으로 알릴 때.
왜 성능이 향상될까요?
OnPush
전략을 사용하면, 해당 컴포넌트는 오직 '자신과 직접적으로 관련된 변화'가 발생했을 가능성이 높을 때만 변경 감지를 수행합니다. 애플리케이션의 다른 부분에서 발생한 비동기 작업 완료에는 영향을 받지 않고 '독립적'으로 동작하는 것이죠.
이는 마치 "내게 꼭 필요한 정보가 업데이트되거나, 내 구역에서 직접적인 요청이 있을 때만 확인하겠다"고 선언하는 것과 같습니다. 불필요한 확인 작업을 대폭 줄여주므로, 특히 컴포넌트 트리가 깊고 복잡한 대규모 애플리케이션에서 성능 향상 효과를 크게 볼 수 있습니다.
주의할 점:
OnPush
를 사용하면 @Input
으로 받은 객체의 내부 속성만 변경하는 방식(mutable update)으로는 변경 감지가 자동으로 일어나지 않습니다. 따라서 OnPush
컴포넌트에 데이터를 전달할 때는 항상 새로운 객체나 배열 참조를 생성하여 전달하는 방식(immutable update)을 사용하는 것이 중요합니다. 또는 필요시 markForCheck()
를 사용하여 수동으로 변경 감지를 예약해야 합니다.
OnPush
전략은 Angular의 변경 감지 시스템을 더 깊이 이해하고 애플리케이션 성능을 한 단계 끌어올리고 싶을 때 고려해볼 만한 강력한 도구입니다.
마무리
자, 이제 앵귤러가 화면을 그리는 과정이 이해가 되셨나요?
- 미리 컴파일된 명령어들이 대기하고 있다가,
- Zone.js가 앱의 변화 낌새를 알려주고,
- 변경 감지 프로세스가 시작되면 이전 값과 현재 값을 비교해서,
- 변경된 부분의 실제 DOM만 콕 집어 직접 업데이트한다!
이것이 바로 앵귤러 렌더링의 핵심 원리입니다. 덕분에 우리는 복잡한 내부 동작을 몰라도 선언적으로 템플릿을 작성하고, 앵귤러가 알아서 효율적으로 화면을 그려주는 편리함을 누릴 수 있는 것이죠. 😊
참고 자료
번외
뭔가 스벨트(Svelte)랑 비슷...한가?
여기까지 생각하다 보니, 문득 다른 친구가 떠올랐어요. 바로 스벨트인데요. 스벨트도 컴파일 방식을 적용한 프레임워크 잖아요. 가상 DOM 안 쓰고, 컴파일할 때 최적의 자바스크립트 코드를 만들어낸다는 점에서 어쩐지 앵귤러랑 좀 흡사해 보이더라고요.
결정적인 차이는?
비슷해 보이지만 결정적인 차이가 있었는데, 바로 '런타임(Runtime)' 번들의 사이즈입니다.
- 스벨트: 이 친구는 컴파일하고 나면 거의 자기 흔적(런타임 코드)을 남기지 않아요. 그냥 순수한 자바스크립트 코드만 남아서 브라우저에서 직접 DOM을 막 조작하죠. 스벨트의 핵심 철학인 '프레임워크가 사라지는' 느낌에 더욱 가까워요.
- 앵귤러: 컴파일을 열심히 하긴 하지만, 여전히 브라우저에는 꽤 덩치 있는 '앵귤러 엔진(런타임)'이 남아서 전체를 관리해요. 런타임에 변경 감지 시스템을 돌리고, 의존성 주입이다 뭐다 이것저것 여전히 챙겨야 할 것이 많기 때문인 것 같아요.
Ghost