Vue의 장점 중 하나는 사용하기 쉽다는 것이다. 최근 Vue 2를 쓰다가 Vue 3를 이용해 개발을 하는데, Vue 2보다는 반응성 시스템에 대해 더 깊은 이해를 요구하지만 여전히 내부 동작 기작을 이해하지 않아도 적당히 넘어갈 수 있을만큼 진입장벽이 낮다고 느껴진다.
이런 사용성은 비숙련자도 빠른 개발을 할 수 있게 해주지만, Vue에서 제공하는 기능을 적극적으로 학습하지 않게 하는 요인이 되기도 하는 것 같다. 학습이 강요되지 않으니, 이미 익숙해진 기능에만 의존하는 경우도 생긴다.
업무 도중에 디렉티브를 사용하면 좋을 것 같은 기능을 만나, 디렉티브에 대해 조사를 하면서 조금은 더 Vue에 대해 이해하게 되어 그 내용을 남긴다.
Vue Directive의 어원
Chat GPT는 Vue의 디렉티브가 Angular의 디렉티브에 영감을 받았다고 한다. 처음엔 믿기 어려웠지만, v-if
/v-for
와 ng-if
/ng-repeat
의 대응 관계도 그렇고, 'Sturcutal Directive'라는 Angular의 용어도 Vue 코드베이스에서 그대로 사용하는 것을 보면 신빙성 있어 보인다.
개발 공부를 하다보면 '디렉티브(지시자)'라는 용어를 다양한 문맥에서 발견하게 되지만, 관심을 두고 조사하지 않았다면 여전히 생소할 것이다.[1]
어쩌면 우리는 이미 디렉티브와 친하다. 특히나 C언어로 프로그래밍 공부를 시작한 세대라면, 숱하게 사용했던 개념이다.
#include 'sdtio.h'
이 문장을 *.c
파일 위쪽에 넣어줘야지만 printf
를 사용해서 *
로 도형 그리기 연습을 할 수가 있었지 않은가?
이 문장이 바로 디렉티브다.[2] 이 디렉티브는 C언어 컴파일러 전처리기에 stdio.h
파일을 포함하도록 지시를 내리기 때문에 '전처리기 지시자'로 불린다.
흥미로운 것은 디렉티브는 언어 자체에 포함되지 않는다는 것이다. 개발자가 작성한 소스코드는 일련의 가공 파이프라인을 거치고 나서야 컴퓨터가 실행할 수 있는 형태가 되는데, 디렉티브는 이 파이프라인에서 일어나는 일에 영향을 주는 방법이다.
C언어에서 디렉티브가 *.c
파일을 실행가능한 형태로 바꾸는 전처리기/컴파일러에 지시를 내리기 위해 있는 것처럼, 현대의 '디렉티브'라는 이름을 가진 개념들 또한 해당 문맥의 파이프라인에 지시를 내리는 수단으로 이해될 수 있다.
Vue라는 문맥에서 디렉티브가 갖는 의미를 알려면, 컴포넌트 렌더링 과정을 이해해야 한다.
'에잉? 스크립트 언어인 js 라이브러리 얘기하다가, 갑자기 컴파일?'이라는 생각이 들 수 있다. 만약 그랬다면, 내부 기작을 모르고도 충분히 사용할 수 있는 Vue의 사용성에 감사하도록 하자.
render 함수만 가지고 앱을 개발하는게 아니라, Vue의 template을 사용하고 있다면 본인도 모르는 새에 적극적으로 컴파일러를 활용하고 있을 것이다. HTML을 닮은 Vue template이 빌드 후에 HTML 스니펫이 되리라 생각하기 쉽지만, 사실 VNode를 반환하는 render 함수들로 컴파일된다.
위 그림의 핵심은, .vue 파일을 작성해서 Vue template를 사용하는 것은 render 함수를 추상화해서 사용하는 것이고 컴파일이 필요하다는 것이다. 실제로 Vue 코드를 빌드하고 나온 .js
파일을 보면 render
함수들이 잔뜩 들어가 있는 것을 확인할 수 있다.
Vue에게 컴파일은 각별하다
Vue의 개발자들은 Virtual DOM을 사용하면서도 Vue의 성능이 좋길 바랐다. 널리 알려진 것처럼 Virtual DOM을 사용하면, 프로그래밍적으로 DOM을 관리하기 쉽지만 실제 DOM과 가상 DOM 사이를 싱크하는데 꽤 비용이 든다. 싱크 작업이 대체로 브루트 포스 방식으로 이루어질 수밖에 없어, 앱이 커지면서 사용자 단말기에 적지 않은 부담을 주기 때문이다.
Vue에서는 이를 타파하고자 컴파일 시에 유용한 정보를 얻어내어 런타임 코드에 등록하는 방법을 취했다. 덕분에 반응성이 필요없는 노드에 표시를 붙여 가상 노드와 HTML 노드 사이를 싱크할 때 해당 노드를 건너띈다던가 하는 최적화가 가능했고, 이런 하이브리드 접근을 'Compiler-Informed Virtual DOM'이라 부르며, 공식문서에서 어떤 최적화 기법들을 사용하는지 설명한다.
이상하지 않은가? *.vue
파일이 컴파일되면 나오는 것은 render 함수의 묶음인데, render 함수는 실행되고 나서 VNode를 반환한다. 그런데 우리가 개발을 하는 이유는 사용자에게 가상 Node가 아니라 진짜 Node를 전달하기 위해서가 아닌가.
어떻게 render
함수가 반환하는 가상 Node는 진짜 DOM Node가 되는 것일까? 그것이 바로 renderer의 역할이다. 더 정확히는 웹앱의 런타임에 올라온, 'runtime-dom'이 갖고 있는 renderer가 가상 Node를 진짜 Node로 만든다. [3]
정리하자면 다음 그림과 같이 된다.
*.vue
코드가 어떤 파이프라인을 타고 HTML이 되는지 간단히 살펴보았다. 파이프라인을 이해했으니, 디렉티브의 본 뜻을 살리며 Vue의 디렉티브를 이해할 수 있는 준비가 되었다.
디렉티브가 파이프라인에서 어떻게 소비되는지 알아보자. 개발자 입장에선 디렉티브를 사용하는 곳은 'template' 안이다. 다음 코드처럼 말이다:
<template>
<div v-if="isDeveloperJobStillSafe">아직 먹고 살 수 있다는 희망</div>
<div v-show="!isDeveloperJobStillSafe">직업을 잃을 수 있다는 두려움</div>
</template>
v-if
와 v-show
디렉티브를 사용한 예제이다. 만약 개발자라는 직업이 아직 안전하다면, '희망'과 '두려움' 모두 렌더되겠지만 '두려움'은 display: none
처리가 되어 눈에 보이진 않을 것이다. 하지만 '두려움'은 여전히 HTML 안에 존재할 것이다...
여하튼, 파이프라인 안에서 디렉티브가 해석되는 과정은:
C언어에서 #include stdio.h
를 하면 C 컴파일러가 전처리 시점에 stdio.h 파일의 내용을 파일 상단에 추가해주는 것처럼, Vue의 디렉티브는 renderer에게 VNode를 렌더할 때 개발자가 사용한 디렉티브를 활용하라고 지시하는 것이다.
Vue 디렉티브에는 크게 두 가지 종류가 있다:
v-if
, v-show
, v-for
등이 여기에 속한다.v-click-outside
가 있다.이 두 종류 사이의 가장 큰 차이점은 builtin 디렉티브는 Vue 라이브러리 코드와 함께 관리되며 컴파일러에서 고려될 수 있다는 것이다. 이를 잘 보여주는 예시가 바로 v-if
와 v-show
이다.
이 두 디렉티브는 모두 빌트인 디렉티브이고 역할도 비슷해보이지만, 서로 다르게 컴파일된다. v-show
디렉티브는 컴파일 시 _withDirectives
로 VNode 생성 함수를 감싸게 되지만, v-if
는 그렇지 않다. 그 이유는 v-if
디렉티브가 컴파일러에서 해석되고 런타임에서 디렉티브로서의 정체성을 잃기 때문이다.
이런 차이가 생긴 이유를 추측해보자면, 더 나은 퍼포먼스를 위해 컴파일 과정에서 최대한 런타임을 돕는다는 철학이 적용된 것일 수도 있고, Vue의 코어 기능과 DOM 특정 로직을 분리하기 위함일 수도 있을 것 같다. (v-show
가 HTML Element 스타일에 display: none
을 넣는 로직은 컴파일러 입장에서 알 필요가 없다.)
그래서 굳이 따지자면, v-if
는 컴파일러에게 렌더 함수를 만들 때 추가 사항을 지시하는 "컴파일러 지시자"이고, v-show
는 렌더러에게 추가 사항을 지시하는 "렌더러 지시자"이다.
이제 동료 개발자와 v-if와 v-show의 차이점에 대해 논할 때, 한 마디 더 할 수 있지 않겠는가?
Vue 공식 문서에 따르면, 디렉티브는 주로 저수준 DOM 조작이 필요할 때 사용된다. 한 번만 사용하는 특수한 로직이 아니라 서로 다른 컴포넌트/엘리먼트에 재사용될 수 있는 저수준 로직을 구성했다면, 디렉티브 작성을 고려해볼 수 있겠다.
아주 엄밀한 비유는 아니지만, 컴포넌트는 renderer라는 주방장에게 레시피를 건네는 것이고, 디렉티브는 개발자가 직접 교육한 조수를 주방장에게 붙여주는 것으로 생각할 수 있다. 주방장은 렌더링을 진행하며 적절한 때에 상황에 맞는 구호를 외치고, 조수는 각 구호에 맞춰 개발자가 입력한 지시사항을 실행할 것이다.
주방장은 항상 레피시에 적힌 일을 먼저 하고 (컴포넌트의 라이프사이클 훅을 먼저 실행), 조수에게 디렉티브 훅 이름을 외치는 것을 잊지 말자.
renderer가 엘리먼트를 마운트mount할 때:
- 주방장: "조수야 'created'!" (엘리먼트를 만들며)
- 주방장: "조수야 'beforeMount'!" (엘리먼트를 마운트 하기 전에)
- 주방장: "조수야 'mounted'!" (엘리먼트를 마운트하고 나서)
renderer가 엘리먼트를 업데이트patch할 때:
- 주방장: "조수야, 'beforeUpdate'!" (VNode에 변화가 생겨 처리하기 전에)
- 주방장: "조수야, 'updated'!" (변화를 모두 처리했고 렌더링까지 끝맞쳤을 때)
renderer가 엘리먼트를 언마운트unmount할 때:
- 주방장: "조수야, 'beforeUnmount'!" (엘리먼트를 언마운트 하기 전에)
- 주방장: "조수야, 'unmounted'!" (엘리먼트를 언마운트 하고 나서)
언제 조수가 필요할까? 구운 마늘을 좋아하는 사람은 두 가지 선택이 있다:
마늘에 대한 기호가 평생 바뀌지 않을 자신이 있다면, 두 선택 모두 괜찮다. 그런데, 어느 날 마늘이 질려 다른 재료를 넣길 바란다면 어떤 선택이 더 쉬운 변경을 가능하게 할까?
나는 나를 위해 주방 안에서 마늘을 불속에 넣어줄 조수가 있길 바란다. 내 취향은 요구사항 만큼이나 자주 바뀌기 때문이다.
이 이야기의 마늘이 무엇인지 정의하는 것은 팀마다의 숙제일 것이다. 그것은 각종 컴포넌트에서 비동기 동작을 기다리며 보여 줄 '로딩 레이어'일 수도 있고, 특정 엘리먼트에 대한 click 이벤트를 모니터링 시스템에 보내는 것일 수도 있다.
이제 디렉티브와 컴포넌트 사이에서 고민할 때 다음의 질문을 해보는 건 어떨까?
'나는 새로운 레시피가 필요한 것일까, 주방장의 조수가 필요한 것일까?'
디렉티브 사용 시 주의점
커스텀 디렉티브를 소개하는 공식 문서에서는 다음 주의점을 소개한다.
$attrs
를 이용해 속성을 강제 상속시키듯이, 디렉티브를 강제 상속 시킬 수는 없다. $attrs
사용 시 그저 다음 VNode에 context에 있던 값을 전달해주면 되는 반면에, 디렉티브 사용 시에는 VNode를 _withDirectives
함수로 감싸야 한다.공식 문서에서는 일반적으로 커스텀 디렉티브를 컴포넌트에 사용하는 것은 권장하지 않는다. 하지만 사랑받는 Vue 컴포넌트 라이브러리인 Vuetify에서도 여러 유용한 디렉티브를 제공하고 있고, 이 디렉티브들은 대체로 컴포넌트에 사용해도 문제가 없다. 주의점만 인지하고, 디렉티브 작성을 겁내지 말자.
타입스크립트 개발자라면 Triple-Slash Directvie가 가장 가까이에 있는 예시가 될 것 같다. 문서를 살펴보면 이 디렉티브는 TS로 작성하는 논리 자체에 영향을 주지 않고, TS 컴파일러에 지시를 내리기 위해 있는 수단인 것을 확인할 수 있다. ↩︎
Wiki: Directive. 디렉티브는 프래그마(pragma)라는 이름으로도 불린다. ↩︎
커스텀 renderer를 구현해서 Vue template을 이용해 PDF를 렌더하게 할 수도 있다: Writing to a PDF ↩︎