웹 프론트엔드 기술은 빠르게 발전하고 있으며 유행에 민감하다. jQuery로 대표되는 초기 프론트엔드 라이브러리를 시작으로 다수의 프레임워크(또는 라이브러리, 이후 프레임워크로 통칭)들이 나타나고 사라지기를 반복해왔다. 때로 이들은 개발자들에게 새로운 패러다임, 디자인 방식, 개념 등을 제시하기도 하였다. 그러나 개발자 입장에서는 얼마나 도움이 될지도 모르는 신기술을 익히기 위해 무작정 시간과 노력을 들이는 것은 부담이 될 수 밖에 없었다.
그런데 만약 복잡하고 어려운 기술을 익숙하고 쉬운 것으로 대체할 수 있다면 어떨까? 최근 웹 프론트엔드 분야에서 가장 주목 받고 있는 기술인 React와 Vue.js에 이와 같은 일이 일어나고 있어 본 아티클을 통해 소개하고자 한다.
대체 기술의 중심에는 프로그래머라면 누구나 익숙한 ‘함수’가 있다. 컴포넌트, hook, options 객체 같은 낯선 단어들은 잠시 무시해도 좋다. 대체 기술은 단순히 “프레임워크가 제공하는 몇몇 API 함수와 그것을 이용하는 함수”가 전부다. 자바스크립트의 함수는 다른 언어와 다를 바 없는 평범한 ‘함수’이고 자바스크립트에 항상 포함되어 있던 가장 기본적인 기능 추상화 단위이다. 그리고 어떤 프레임워크가 API 함수 집합으로 기능을 제공하는 것이나 그것을 이용하는 사용자 함수를 작성하는 것은 매우 흔하다. 그런데 여기서 소개하는 방식은 약간 다른 점이 있다.
– API 함수들은 프레임워크에 의해, 프레임워크가 정의한 문맥에서 수행된다.
– API 함수들이 필요로 하는 문맥을 프레임워크가 암묵적으로 제공한다.
대략 다음과 같이 암묵적으로 제공한다.
(1) API 함수 정의 바깥 scope에 문맥 정보를 담는 변수가 있다.
(2) 프레임워크는 상황에 따라 해당 변수의 문맥 정보를 적절히 바꾼다.
(3) API 함수는 해당 변수를 참조하여 동작한다.
이렇게 하면 동일한 API 함수가 문맥 정보에 따라 다양한 일을 할 수 있다. 그런데 바깥 scope 변수가 바뀌고 그것을 참조하면 함수가 순수하지 않고 의미를 선언적으로 이해하기 어려워지는 것 아닌가?
함수의 순수성 문제는 걱정하지 않아도 된다. 변경 내용이 특정 문맥 정보로 제한되어 있을 뿐 아니라 다음과 같은 특징이 있기 때문이다.
– API 함수의 문맥 정보는 프레임워크 내부 정보이기 때문에 사용자가 직접 접근하는 것은 적절치 않다.
– 필요한 문맥 정보는 프레임워크가 정의한 수행 환경과 타이밍에 따라 결정되므로 프레임워크가 가장 적절한 정보를 제공할 수 있다.
– API 함수에 문맥 정보를 인자로 나열하지 않아도 되므로 작성한 코드를 다른 문맥에서도 재사용할 수 있다.
즉, 프레임워크 추상화를 돕고, 일일이 API 함수 인자를 나열하지 않아도 될 뿐 아니라 작성한 코드를 여러 군데에서 재사용 할 수 있게 된다. 그리고 API 함수들은 정도의 차이는 있겠지만 문맥 정보를 암묵적으로 받는다고 간주할 수 있는 경우가 많다. 조금 극단적인 예지만 브라우저의 alert() 함수를 생각해보자. 제대로 수행하려면 브라우저의 어느 탭에서 실행되고 있는지는 알아야 할 것이다. 만약 이런 문맥 정보를 사용자가 직접 alert() 함수에 알려줘야 한다면 어떻게 될까? 코드 작성도 번거롭고 탭이 없는 브라우저에서는 그 코드가 동작하지 않을 수도 있을 것이다. 앞으로 나올 내용의 문맥 정보와는 다르지만 기본적인 아이디어는 유사하다.
원래 이슈로 돌아가서 이전에 개발자들의 머리를 아프게 했던 어려운 기술들이 어떻게 평범한 함수로 대체될 수 있는지 예제를 통해 살펴보도록 하자.
React에서 클래스 기반 컴포넌트의 대체
React에서는 클래스 문법으로 컴포넌트를 정의하는 것이 기본이다. 이런 클래스 컴포넌트의 장점은 명확하다. 클래스 인스턴스에 컴포넌트 인스턴스(브라우저 DOM에 대응하여 React가 가지고 있는 트리 구조에서 트리 노드)의 상태 정보를 저장할 수 있고, 클래스 내 메소드로 각 라이프사이클에서 수행할 기능을 정의할 수 있다. 그리고 최종적으로 하나의 클래스로 캡슐화 할 수 있다.
[그림 1]의 좌측은 클래스 컴포넌트의 한 예이다. 버튼 클릭 횟수와 마우스 위치 정보를 컴포넌트 인스턴스의 상태 정보로 가지고 있다. 마우스 위치 정보를 얻어오는 핸들러를 두 개의 라이프사이클 메소드에서 등록하고 해지한다.
일반 함수로 컴포넌트를 정의할 수도 있지만 이러한 함수 컴포넌트는 (Hooks API 이전에는) 인스턴스 상태를 저장할 수 없었고, 함수 컴포넌트 자체가 render 라이프사이클이어서 다른 라이프사이클에 수행될 기능을 정의하는 것도 불가능했다. 하지만 Hooks API가 도입되면서 대등한 기능을 할 수 있게 되었다.
[그림 1]의 우측은 Hooks API를 이용해 좌측과 동등한 함수 컴포넌트를 정의한 것이다. 요컨대 프레임워크가 함수 컴포넌트의 인스턴스에 대응하는 상태를 만들고 Hooks API로 접근하여 사용하는 방식이다. 맨 윗줄에서 임포트(import)한 useState, useEffect는 Hooks API 함수들이다. useState는 인자값을 초기값으로 하는 공간 하나를 상태에 할당하고 그 공간에 있는 최신값과 그 값을 바꿀 수 있는 세터(setter) 함수를 반환해준다. 이를 통해 저장된 값을 읽고 쓸 수 있다. useEffect의 콜백(callback) 함수는 인자로 받은 콜백을 바로 수행하는 것이 아니라 프레임워크가 관리하는 큐(queue)에 저장해둔다. 그리고 함수 컴포넌트 자체가 수행된 이후, 마운트(mount), 업데이트(update), 언마운트(unmount) 등의 라이프사이클 시점에 프레임워크가 수행해준다. 프레임워크는 re-render 등의 이슈로 함수 컴포넌트를 다시 실행할 때, 이전에 컴포넌트 인스턴스를 위해 만들었던 상태를 다시 사용하도록 상태 문맥 정보를 때마다 바꿔준다. 이런 방식으로 함수 컴포넌트가 클래스 인스턴스처럼 상태 저장 공간과 라이프사이클 메소드를 가질 수 있다.
React에서 고차 컴포넌트(Higher-order component) 대체
Hooks API의 장점이 좀 더 명확히 나타나는 예제를 살펴보자. 앞의 예에서 마우스 위치 추적 기능을 여러 컴포넌트에서 사용한다고 가정했을 때, 클래스 컴포넌트에서 해당 기능만 분리해 타 컴포넌트에 재사용하는 것은 간단치 않다. 많이 쓰이는 방법 중 하나가 고차 컴포넌트(Higher-order component, HOC)를 정의하는 것이다. HOC는 컴포넌트를 인자로 받아서 새로운 컴포넌트를 만들어 준다. 대개는 어떤 기능을 추가한다. [그림 1]의 두 가지 방식에서 마우스 추적 기능을 재사용 가능하게 떼어내어 다시 적용하면 다음과 같다.
[그림 2]의 좌측에서는 withMouseTracker라는 HOC를 정의했다. Comp라는 컴포넌트를 받아서 클래스 컴포넌트를 반환한다. 이 컴포넌트는 마우스 위치 정보를 저장하고 이벤트 핸들러를 등록·제거하며 인자인 Comp에 마우스 위치 정보를 prop으로 전달한다. Higher-order component라는 명칭이 멋있어 보일 수도 있지만 입출력이 컴포넌트라는 사실에 잠시 머리가 멍해질 수도 있다. 조금 더 복잡하게 인자를 받아서 HOC를 만들어내는 함수도 제법 있다. 자바스크립트가 오캐믈(OCaml), 하스켈(Haskell) 같은 전통적인 함수형 언어가 아니다 보니 HOC 함수는 다소 이질감이 느껴진다.
[그림 2]의 우측은 앞의 함수 컴포넌트 예제에서 마우스 추적 기능을 useMouse()라는 Custom Hook 함수로 분리하였다. 마우스 추적 기능이 필요한 컴포넌트는 useMouse()를 이용하여 마우스 위치 정보를 얻어올 수 있다. 좌측과 비교해서 장점은 다음과 같다.
– 좌측 mouseX, mouseY prop의 출처가 withMouseTracker()라는 사실보다 우측 mouseX, mouseY 변수값의 출처가 useMouse()라는 사실이 더 명확하다.
– Comp 컴포넌트가 기존에 mouseX, mouseY prop을 가지고 있었다면 HOC 적용 시 이름 충돌이 발생했을 것이다. 반면 우측에서는 useMouse() 호출 좌변의 mouseX, mouseY 변수 이름을 자유롭게 바꿀 수 있다.
– HOC처럼 컴포넌트가 다른 컴포넌트를 감싸고 있는 형태가 아니다. 대신 필요한 기능을 현재 컴포넌트에 융합한다. 이로 인한 약간의 성능 이득이 있을 수 있다.
이와 같이 Hooks를 이용하여 HOC보다 좀 더 직관적인 형태로 기능을 분리하여 정의해두고 보다 편리하게 조합할 수 있다.
Vue.js의 Renderless 컴포넌트, Slot, Scoped-slot
Vue.js에서 곧 도입될 것으로 보이는 함수 기반 API의 경우도 살펴보자. 예를 들어 특정 컴포넌트를 화면 구성과 논리 기능에 대한 것으로 각기 나누고 싶을 수 있다. 왜냐하면 화면 표시를 고정해두면 해당 컴포넌트의 논리적인 기능은 원하지만 화면 표시 방법이 마음에 들지 않아 컴포넌트를 재사용 하지 못하는 경우가 생길 수 있기 때문이다. 이럴 때 Vue.js는 Render가 빠져있는 Renderless 컴포넌트를 정의하여 사용할 수 있다.
버튼 클릭 횟수를 세는 간단한 컴포넌트를 생각해보자.
[그림 3]의 좌측이 Renderless 컴포넌트를 사용한 경우이다. Count.vue에 Renderless 컴포넌트인 Count가 정의되어 있다. Script 부분을 보면 이 컴포넌트는 내부 상태로 초기값이 0인 Count가 있고 이를 증가시키는 Increment 메소드를 가지고 있다. template을 보면 renderless라고 했지만 template이 완전히 비어있지는 않고 slot 태그가 있다.
(1) slot은 컴포넌트가 전달 받은 template 조각으로 채워지는 빈칸이다. 사실상 비워져 있어 renderless라고 부른다. template 조각은 함수처럼 인자를 받을 수 있는데 여기서는 App 컴포넌트의 count와 increment가 인자로 사용되었다.
(2) App 컴포넌트에서 Count 태그 안의 내용이 Count 컴포넌트에 전달되는 template 조각이다. 이 template 조각은 slot-scope 속성으로 count와 increment 정보를 받는 것을 표시하고 있다.
template 조각을 함수처럼 정의하고 태그 사이에 둠으로써 콜백 전달처럼 Count에 template 조각을 전달하고, 콜백 호출처럼 제공 받은 인자로 template의 빈 부분을 채워 사용한다. template 조각의 정의, 전달, 호출이 모두 template의 고유한 문법으로 이뤄지고 있다.
[그림 3] 우측의 코드는 함수 기반 API를 이용한 경우이다. useCount() 함수에 count의 논리적인 기능을 담았다. 맨 윗줄에서 import한 value가 함수 기반 API이다. 이는 setup() 메소드 수행 중에만 사용되어야 하며, 암묵적 문맥 정보는 컴포넌트 인스턴스 자체다. value()는 인자를 초기값으로 하는 값 저장 공간을 만드는데, 이 값 저장 공간 count와 increment 함수를 객체에 묶어서 useCount()가 반환하고 있다. 최종적으로 그 값들이 setup() 함수의 반환 객체에 포함되는데 Vue.js는 setup()이 반환한 객체의 속성을 현재 Vue 인스턴스에 추가한다.
React Hooks와 유사하지만 차이점도 있는데,
– setup() 함수는 컴포넌트 인스턴스가 만들어질 때 한 번만 수행된다. 덕분에 동일한 컴포넌트 인스턴스에 대해 setup()이 다시 수행되는 상황은 고려할 필요가 없다.
– API로 만들어진 값 저장 공간을 프레임워크가 따로 관리하지 않고 컴포넌트 인스턴스 속성으로 처리한다.
알아챘을 수도 있겠지만 사실 value()는 문맥 정보가 없어도 수행할 수 있다. value() 자체는 컴포넌트 인스턴스와 상관없이 저장 공간 하나를 할당하기만 하기 때문이다. setup()의 반환 객체에 그 값이 포함되어 컴포넌트 인스턴스의 속성으로 추가될 때 비로소 컴포넌트 인스턴스와 연결된다. 반면 함수 기반 API의 onMounted()와 같은 라이프사이클 hook 추가 API는 컴포넌트 인스턴스 문맥이 필요하다. 이런 함수들은 인자 함수를 부수 효과로 컴포넌트 인스턴스의 라이프사이클에 등록하기 때문이다. 개념적으로는 모든 함수 기반 API가 컴포넌트 인스턴스를 문맥 정보로 사용하는 것으로 봐도 무방하다.
나머지 경우?
[표 1]에 언급한 기술 중 본문에서 예를 들어 설명하지 않은 것들은 생략하겠다. React의 render prop은 Vue.js의 renderless 컴포넌트와 유사하고, Vue.js의 Higher-order 컴포넌트도 React의 Higher-order 컴포넌트와 유사하다. 그리고 mixin은 양쪽 모두 문제 발생 소지가 많아 사용하지 않는 것을 권장하고 있어 굳이 예를 들지 않았다. 하지만 만약 mixin을 사용한 코드가 있다면 다른 것보다 먼저 대체를 고려해야 할 것이다.
맺는 말
React와 Vue.js에서 이전에 어려운 개념을 적용해 해결했던 문제들을 Hooks API와 함수 기반 API로 더 쉽게 해결할 수 있음을 간단하게나마 살펴보았다. API는 새롭지만 그 사용 방식이 단순하고 기존의 어려운 것들을 대체할 수 있어 앞으로 널리 사용될 것으로 보인다.
기능 추상화 단위가 평범한 함수라는 사실도 장점이다. API 함수를 사용하여 정의한 함수를 묶어서 다시 새로운 라이브러리 API 함수로 제공할 수 있다. 기능을 추가하거나 여러 기능을 조합하기 편리하다. 덕분에 에코 시스템이 더 풍성해질 가능성이 높다.
약간의 단점도 보인다. 기본적으로 문맥 정보에 부수 효과를 일으키다 보니 이전에는 컴포넌트의 자료나 함수가 통합된 선언(declarative)으로 기술되었는데, 새로운 방식에서는 자료 공간을 할당하고 함수를 등록하는 등 여러 개의 명령(imperative) 코드로 읽힌다는 점이다. 이는 코드를 구조화하는 방법을 마련하면 어느 정도 완화할 수 있을 것으로 보인다.
혹시 이 아티클을 통해 React의 Hook와 Vue.js의 함수 기반 API에 관심이 생겼다면 공식 문서나 튜토리얼 등으로 공부할 것으로 권한다. 당신의 개발 공구함에 들어있던 무거운 전용 공구들이 가볍고 다재다능한 스위스 군용 칼로 바뀌면서 개발 업무가 한층 경쾌해질 것이다.
# References
- https://www.infoq.com/news/2019/07/vue3-function-based-api-rfc/
김세원 프로
에스코어㈜ 소프트웨어사업부 개발플랫폼그룹
JavaScript 코드 품질 분석 솔루션(DeepScan)의 엔진 개발을 담당하고 있습니다.
Register for Download Contents
- 이메일 주소를 제출해 주시면 콘텐츠를 다운로드 받을 수 있으며, 자동으로 뉴스레터 신청 서비스에 가입됩니다.
- 뉴스레터 서비스 가입 거부 시 콘텐츠 다운로드 서비스가 제한될 수 있습니다.
- 파일 다운로드가 되지 않을 경우 s-core_mktg@samsung.com으로 문의해주십시오.