Function is the New Black in React and Vue.js: 리액트와 뷰의 함수에 주목해야 하는 이유

Function is the New Black in React and Vue.js : 리액트와 뷰의 함수에 주목해야 하는 이유

웹 프론트엔드 기술은 빠르게 발전하는 것만큼이나 유행에 민감합니다. jQuery로 대표되는 초기 프론트엔드 라이브러리를 시작으로 다수의 프레임워크(또는 라이브러리, 이후 프레임워크로 통칭)들이 나타나고 사라지기를 반복해 왔습니다. 때로 이들은 개발자들에게 새로운 패러다임, 디자인 방식, 개념 등을 제시하기도 했습니다. 그러나 개발자 입장에서는 얼마나 도움이 될지도 모르는 신기술을 익히기 위해 무작정 시간과 노력을 들이는 것은 부담이 될 수밖에 없었습니다.

그런데 만약 복잡하고 어려운 기술을 익숙하고 쉬운 것으로 대체할 수 있다면 어떨까요? 최근 웹 프론트엔드 분야에서 가장 주목 받고 있는 기술인 React와 Vue.js에 이와 같은 일이 일어나고 있어서 본 아티클을 통해 소개하고자 합니다.

React, Vue.js의 기술 대체
프레임워크 기존 기술 새로운 기술
React 클래스를 이용한 컴포넌트 정의 함수 컴포넌트와 Hooks API
Higher-order 컴포넌트
render prop
Mixin
Vue.js Higher-order 컴포넌트 함수 기반 API를 이용한 options 객체 확장
Renderless 컴포넌트
Mixin
표1 - 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]의 좌측은 클래스 컴포넌트의 한 예입니다. 버튼 클릭횟수와 마우스 위치정보를 컴포넌트 인스턴스의 상태정보로 가지고 있습니다. 마우스 위치정보를 얻어오는 핸들러를 두 개의 라이프사이클 메소드에서 등록하고 해지합니다.

그림1 - 마우스 버튼 클릭 횟수와 위치정보 컴포넌트. 클래스 컴포넌트(좌), Hooks API를 이용한 함수 컴포넌트(우) : mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,);mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,); 그림1 - 마우스 버튼 클릭 횟수와 위치정보 컴포넌트. 클래스 컴포넌트(좌), Hooks API를 이용한 함수 컴포넌트(우)

일반 함수로 컴포넌트를 정의할 수도 있지만 이러한 함수 컴포넌트는 (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 - [그림 1]의 컴포넌트에 마우스 추적 기능을 재사용 가능한 형태로 분리 및 적용할 때 HOC를 이용한 경우(좌)와 Hooks를 이용한 경우(우) :mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,);mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,); 그림2 - [그림 1]의 컴포넌트에 마우스 추적 기능을 재사용 가능한 형태로 분리 및 적용할 때 HOC를 이용한 경우(좌)와 Hooks를 이용한 경우(우)

[그림 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 컴포넌트 사용(좌), 함수 기반 API 사용(우) : mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,);mkdir puppeteer-project && cd puppeteer-project;yarn add puppeteer; use strict; const puppeteer = require(puppeteer);(async() = > {; const brower= await puppeteer.launch(); const page = await browser.newPage();await page.goto(http://google.com,); 그림3 - 버튼 클릭횟수를 세는 컴포넌트. Renderless 컴포넌트 사용(좌), 함수 기반 API 사용(우)

[그림 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:
[1] https://www.infoq.com/news/2019/07/vue3-function-based-api-rfc

에스코어 - 에스코어는 경영 컨설팅 전문성과 소프트웨어 기술력을 바탕으로 성공적인 디지털 트랜스포메이션을 위한 IT 전략 수립, 신기술 소프트웨어 개발 및 기술 서비스를 One-Stop으로 제공합니다. -----> 본 아티클은 ㅡ에스코어 홈페이지에서 PDF 파일로 다운로드 받을 수 있습니다. - PDF 다운로드


▶   해당 콘텐츠는 저작권법에 의하여 보호받는 저작물로 기고자에 저작권이 있습니다.
▶   해당 콘텐츠는 사전 동의없이 2차 가공 및 영리적인 이용을 금하고 있습니다.



공유하기
김세원 프로
김세원 프로

Principal Professional at Development Platform Group, S-Core
에스코어㈜ 소프트웨어사업부 소프트웨어플랫폼팀

개발플랫폼그룹에서 JavaScript 코드 품질 분석 솔루션(DeepScan)의 엔진 개발을 담당하고 있습니다.