개요
안녕하세요. 본 글에서는 가장 먼저 JavaScript와 JavaScript 기반 라이브러리 및 프레임워크의 등장 배경에 대해 소개하고
JavaScript 기반 라이브러리인 React의 핵심 기능을 Vanilla JS로 구현하며, 웹 프론트엔드 학습을 시작하는 분들이 앞으로 사용할 라이브러리 및 프레임워크를 더 잘 이해하고 사용할 수 있게 되는 것을 목적으로 합니다.
또한 본 글은 2022.09.27에 고려대 기반 블록체인 학회 '블록체인 밸리'의 프론트엔드 세션 강의자료로 활용되었습니다.
웹 프론트엔드 영역에서 JavaScript가 해결하는 문제
JavaScript가 개발되기 전, 초기 웹 브라우저에서는 오직 정적인 웹페이지만을 서비스할 수 있었습니다. 유저가 할 수 있는 일은 정적인 웹 페이지에 서비스된 정보를 소비하고, 내, 외부 웹 페이지와 연결된 링크를 통해 새로운 페이지로 이동하는 것뿐이었죠.
그리고 90년대 웹 브라우저 시장을 장악하고 있던 Netscape(a16z의 마크 안데르센이 공동창업자) 팀은 정적이던 웹 사이트를 동적으로 발전시켜 웹 생태계에서 유저가 웹 페이지와 더 활발하게 상호작용 할 수 있도록 스크립팅 언어를 개발하고자 했고, 썬마이크로시스템즈와의 파트너십과 엔지니어 Brendan Eich를 고용이라는 두 가지 선택지 중 후자를 선택하여 1995년 12월 4일, 당사 웹 브라우저인 Netscape Navigator에서 동작하는 스크립팅 언어를 개발했습니다.
그 언어의 첫 이름은 Mocha 였지만 이후 LiveScript로, 그리고 최종적으로는 그 당시 가장 핫한 프로그래밍 언어인 Java의 인기에 편승하기 위해 JavaScript라는 이름으로 출시되었습니다.
JavaScript를 통해 개발된 동적 웹 페이지를 서비스할 수 있게 된 것은 어떤 의미가 있었을까요? 저는 웹이라는 플랫폼에서 개발자가 제공하는 제품, 서비스를 통해 사용자가 해결할 수 있는 문제가 더욱 다양해지고 많아졌다는 것을 의미한다고 생각합니다. 정보 전달의 수단을 넘어, 현실의 문제를 해결할 수 있는 플랫폼으로서 웹 기술이 사용될 기반이 만들어졌기 때문이죠.
그럼 JavaScript를 활용해 어떻게 기존의 정적 웹 페이지를 동적 웹 페이지로 개발할 수 있을까요?
바로 DOM API를 활용하는 것입니다. DOM의 정확한 이해를 돕기 위해 MDN에서 그 정의를 일부 발췌했습니다. 빨간색으로 강조된 글씨를 주의 깊게 살펴봐주세요.
문서 객체 모델(The Document Object Model, 이하 DOM) 은 HTML, XML 문서의 프로그래밍 interface이다. DOM은 문서의 구조화된 표현(structured representation)을 제공하며 프로그래밍 언어가 DOM 구조에 접근할 수 있는 방법을 제공하여 그들이 문서 구조, 스타일, 내용 등을 변경할 수 있게 돕는다. DOM 은 nodes와 objects로 문서를 표현한다. 이들은 웹 페이지를 스크립트 또는 프로그래밍 언어들에서 사용될 수 있게 연결시켜주는 역할을 담당한다.
웹 페이지는 일종의 문서(document)다. 이 문서는 웹 브라우저를 통해 그 내용이 해석되어 웹 브라우저 화면에 나타나거나 HTML 소스 자체로 나타나기도 한다.
… 중략
DOM 은 웹 페이지의 객체 지향 표현이며, JavaScript와 같은 스크립팅 언어를 이용해 DOM을 수정할 수 있다.
출처: MDN
요약하자면 JavaScript 코드로 브라우저의 DOM API를 활용해 문서를 조작할 수 있다는 것인데, 이는 곧 서버에서 이미 제공된 정적 웹 페이지 위에서 사용자와의 상호작용에 따라 새로운 정보와 스타일, 동작 등을 추가적으로 표현할 수 있다는 것을 의미합니다.
하지만 JavaScript로 동작하는 초기 동적 웹사이트에도 단점이 존재했는데요, 바로 화면에 표시된 데이터가 변화할 때마다 새로운 문서를 서버로부터 불러와 렌더링 해야 된다는 것입니다. 이는 데이터가 변화하는 순간마다 페이지를 새로고침 해야 하고, 매 트랜잭션마다 HTML 문서 등 변화하지 않은 정적 데이터도 교환해야 했기 때문에 대역폭이 낭비되어 결과적으로는 웹 어플리케이션의 사용자 경험을 해칠 수 있었습니다.
그리고 1999년 3월, AJAX의 등장으로 새로고침 없이 HTTP 요청과 응답을 비동기적으로 처리할 수 있게 되었으며, 이는 웹 어플리케이션이 마치 모바일 앱처럼 하나의 페이지에서 매끄럽게 동작하는 SPA(Single Page Application)을 개발할 수 있는 기반 기술이 되었습니다
프레임워크, 라이브러리란?
어느 정도 규모가 있는 소프트웨어를 개발할 때 우리는 라이브러리와 프레임워크를 자주 활용할 것입니다. 그럼 간단하게 라이브러리와 프레임워크의 정의에 대해 살펴봅시다.
라이브러리와 프레임워크는 특정 소프트웨어를 개발하면서 높은 빈도로 발생하는 문제를 해결하기 위해 작성된 재사용 가능한 코드의 모음이고, 마치 도서관에서 책을 고르듯 다른 개발자가 필요에 따라 본인의 문제를 해결하는 데 코드를 사용할 수 있도록 한 것입니다. 해당 코드를 사용할 때 개발자에게 주어지는 자유도에 따라 라이브러리와 프레임워크를 구분하기도 합니다. 라이브러리가 프레임워크에 비해 상대적으로 자유도가 높습니다.
그렇다면 프론트엔드 진영에서는 왜 라이브러리와 프레임워크가 개발되었을까요?
앞서 동적 웹페이지를 서비스하기 위해서는 브라우저의 DOM API를 활용해 정적인 웹페이지가 동적으로 동작하도록 개발해야 한다고 했습니다. 하지만 데이터가 많아질수록 많은 DOM을 다뤄야 하고, 이는 기하급수적으로 코드의 양을 늘려 코드 관리가 힘들어지게 합니다. 코드 관리가 어려워지면 디버깅, 에러 핸들링도 점차 어려워지고 유지 보수도 어려워져 개발 생산성에도 큰 영향을 줍니다. 또 하나의 문제는 브라우저별로 다른 DOM API와 스펙이었습니다. 각기 다른 웹 브라우저에 구현된 스펙을 일일이 고려하여 웹 개발을 하면 그 복잡성으로 인해 개발자가 신경 써야 할 것들이 많았습니다.
즉, 프론트엔드 진영에서의 라이브러리 및 프레임워크는 DOM API를 쉽게 다룰 수 있도록 만들어졌고, DOM API를 어떻게 성공적으로 다룰 수 있는지가 핵심이었습니다.
하지만 라이브러리와 프레임워크는 사용자 경험보다 개발자 경험과 개발 생산성 향상을 위해 존재한다는 것을 인지해야 합니다. 극단적으로 말하면 사용자는 우리가 어떤 라이브러리를 사용하는지 관심이 없습니다. 본인이 원하는 대로 동작한다면요. 그리고 그러한 사용자 경험을 구현하는 데 라이브러리와 프레임워크가 필수적인 것도 아닙니다. 오히려 라이브러리를 사용하면 사용자에게 전달되는 JavaScript의 bundle size를 증가시키고, 라이브러리 자체를 학습하는 데에 시간을 들여야 한다는 단점이 존재합니다.
그럼에도 불구하고 더 나아진 개발자 경험과 개발 생산성은 곧 제품의 퀄리티(사용자 경험)와 직접적으로 연관되기 때문에 라이브러리 사용으로 인해 지출되는 비용과 기대 효과를 측정한 뒤 라이브러리 및 프레임워크를 사용하는 것이 합리적일 것입니다.
다음으로는 프론트엔드 진영에서 어떤 라이브러리와 프레임워크가 주로 사용되었는지 살펴봅시다.
프론트엔드 프레임워크
현재 프론트엔드 진영에서 가장 많이 사용되고 있는 UI 라이브러리 및 프레임워크는 React, Angular, Vue 크게 3가지 입니다. 앞으로는 편의를 위해 라이브러리와 프레임워크를 프레임워크로 통일시켜 부르겠습니다.
프론트엔드 프레임워크를 결정하는 데 정답은 없습니다. 결국 같은 문제를 해결하는 서로 다른 방식이며, 각 프레임워크의 철학, 러닝 커브, 사용처 등을 고려해 본인이 가장 마음에 드는 프레임워크를 선택하면 됩니다.
저는 아직 React만을 사용해봤고, 직접적인 경험에 의거해 프레임워크를 비교할 수는 없겠지만, 프레임워크 별 간단한 특징과, 제가 참고한 자료를 첨부하도록 하겠습니다.
React
A JavaScript library for building user interfaces
- Meta(Facebook)의 공식 지원 프로젝트
- 세 프레임워크 중 가장 커뮤니티가 큼
- 스스로를 프레임워크가 아닌 라이브러리로 정의(상대적으로 높은 자유도)
Vue
The progressive javascript framework
- 커뮤니티 주도 프로젝트
- 세 프레임워크 중 가장 늦게 출시되어 각각의 장점을 흡수할 수 있었음
- Angular를 개발하던 개발자가 개발
- HTML 기반의 템플릿을 활용하고, 초심자가 배우기 쉬움
Angular
The modern web developer's platform
- 구글의 공식 지원 프로젝트
- 세 프레임워크 중 구성에 대한 강제가 비교적 엄격함. 즉 따라야 하는 규칙이 많음
- 웹 프론트엔드 개발 시 필요한 대부분의 도구가 내장되어 있는 프레임워크
- Enterprise level에서 주로 사용되었음
저는 처음으로 학습할 프레임워크로 React를 선택했고, 다음과 같은 주관적인 이유 때문입니다.
- 방대한 커뮤니티
프론트엔드 프레임워크 중 가장 많은 사용처와 큰 커뮤니티를 보유하고 있습니다. 커뮤니티가 크기 때문에
- 학습자료가 가장 많고, 개발하다가 문제에 봉착했을 때 해당 문제에 대한 해결책을 빠르게 찾을 수 있어, 처음으로 프론트엔드 프레임워크를 배우는 제가 학습하는 데 큰 도움이 됩니다.
- 인재 채용과 프레임워크의 안정성 때문에 기업에서도 프론트엔드 프레임워크로 React를 더 선호하고, 결과적으로 React를 배웠을 때 얻을 수 있는 채용 기회가 더 많습니다.
- Learn Once, Write Everywhere
- 플랫폼에 구애받지 않고 어느 아이디어든 빠르게 제품으로 만들어 실험하고 싶은 제게 React 문법으로 모바일 앱을 만들 수 있는 React Native는 매력적입니다. 그리고 이와 같은 제품 개발 철학을 갖고 있는 많은 초기 스타트업이 모바일 앱 개발에 RN을 사용하고 있습니다.
- 다만 Vue Native라는 대안이 있고, React Native는 릴리즈 된 지 몇 년이 지났는데도 아직 1.0 버전이 릴리즈 되지 않았다는 점은 고려해야 합니다.
- 플랫폼에 구애받지 않고 어느 아이디어든 빠르게 제품으로 만들어 실험하고 싶은 제게 React 문법으로 모바일 앱을 만들 수 있는 React Native는 매력적입니다. 그리고 이와 같은 제품 개발 철학을 갖고 있는 많은 초기 스타트업이 모바일 앱 개발에 RN을 사용하고 있습니다.
한 프레임워크를 선택한다 해도 평생 그 프레임워크를 고집할 필요가 없습니다. 기술은 빠르게 발전할 것이고, 마치 jQuery처럼 React가 불과 5년 뒤에는 레거시가 될 수도 있습니다. 어쩌면 5년도 안 걸릴 수 있고요. 그렇기 때문에 특정 기술에 매몰되지 않고, 문제를 해결하는 도구로 프레임워크를 바라볼 필요가 있습니다.
저 또한 앞서 설명한 이유로 가장 먼저 React를 배워보기로 했지만, 기술의 발전, 저의 니즈를 충족시키는 새로운 프레임워크의 등장과 같은 사건이 발생한다면 언제든 새로운 프레임워크를 학습할 의사가 있습니다.
하지만 그전에 한 프레임워크의 동작 원리를 이해하고 깊이 있게 활용해 봄으로써 웹 개발 과정에서 맞닥뜨리는 문제를 직접 해결해보는 과정, 그리고 그 과정에서 내가 사용하는 도구의 장, 단점을 인지하는 과정이 필요합니다. 새로운 도구가 등장했을 때 이 도구의 어떤 점이 기존보다 개선된 것이고, 어떤 점은 기존과 별반 다르지 않은지 파악할 수 있다면, 수많은 선택지 중에서 “새로운 기술이라서”, “동료 개발자가 좋다고 해서” 등의 이유보다 더 합리적인 이유를 토대로 의사결정할 수 있기 때문이죠.
그리고 한 프로그래밍 언어를 깊이 있게 학습한 뒤 다른 언어를 학습할 때 두 배의 노력이 들지 않는 것처럼, 같은 문제를 해결하는 더 좋은 프레임워크를 도입하는 데에도 두 배의 노력이 들지 않을 것이기 때문입니다.
React
React는 무엇이고, 어떤 특징이 있을까요? 아래에 React 공식 문서에서 발췌한 글을 기본으로, 제가 해석한 내용을 추가했습니다.
React는 사용자 인터페이스를 만들기 위한 JavaScript 라이브러리입니다.
특징
- Declarative
- UI를 선언형 컴포넌트로 작성하고, 상태 변화에 따른 배후의 DOM 업데이트는 React가 알아서 합니다. 기존 JavaScript에서는 상태 변화에 따른 DOM 업데이트를 일일이(명령형으로) 관리해야 했습니다. 하지만 React는 Virtual DOM을 활용하여 원래 DOM과 비교해 변경된 부분만 알아서 갈아 끼웁니다. 개발자는 데이터 바인딩을 명시적으로 지정할 필요가 없습니다. 이후 실습을 통해 그 차이를 더욱 자세히 알아봅니다.
상태란? 변하는 데이터 - 그리고 이러한 특징은 사용자와 상호작용하고, 지속적으로 데이터가 변화하는 어플리케이션의 개발을 더욱 쉽게 합니다.
- UI를 선언형 컴포넌트로 작성하고, 상태 변화에 따른 배후의 DOM 업데이트는 React가 알아서 합니다. 기존 JavaScript에서는 상태 변화에 따른 DOM 업데이트를 일일이(명령형으로) 관리해야 했습니다. 하지만 React는 Virtual DOM을 활용하여 원래 DOM과 비교해 변경된 부분만 알아서 갈아 끼웁니다. 개발자는 데이터 바인딩을 명시적으로 지정할 필요가 없습니다. 이후 실습을 통해 그 차이를 더욱 자세히 알아봅니다.
- Component-based
- React의 멘탈 모델은 모든 UI 요소를 하나의 재사용 가능한 컴포넌트로 바라보는 데서 출발합니다. UI를 쪼개서 보면 결국 컴포넌트의 집합인데, React는 컴포넌트를 마치 레고 조립하는 것과 같이 조립해 UI를 설계합니다. 그리고 각 컴포넌트는 독립된 상태를 가질 수 있습니다.
- Learn Once, Write Everywhere
- React는 Node 서버에서 렌더링을 할 수도 있고, React Native를 이용하면 모바일 앱도 만들 수 있습니다.
앞서 프론트엔드 진영에서의 라이브러리 및 프레임워크는 DOM API를 쉽게 다룰 수 있도록 만들어졌고, DOM API를 어떻게 성공적으로 다룰 수 있는지가 핵심이라고 한 것 기억하시나요? React는 선언형으로 컴포넌트를 작성하고 상태만 관리하면, Virtual DOM을 활용해 변경된 부분만 리액트가 실제 DOM에 렌더링함으로써, 개발자가 DOM API를 다뤄야 하는 수고를 거의 없앴습니다.
Virtual DOM이란?
Virtual DOM, 즉 가상돔은 UI로 표현될 객체를 가상 메모리에 저장하고, ReactDOM과 같은 라이브러리에 의해 실제 DOM과 동기화되는 개념입니다.
쉽게 설명하면 DOM의 형태를 본떠 만든 객체입니다.
React에서 state나 props가 갱신되면 render() 함수가 호출되어 새로운 엘리먼트(VDOM) 트리를 반환합니다.
이때 효과적으로 UI를 갱신하기 위해서 기존의 VDOM(Old virtual DOM)과 새로운 VDOM(Old virtual DOM)의 차이점을 찾아내어 변경된 부분만 새롭게 랜더링 합니다.
즉, Virtual DOM은 DOM이 변경될 때마다 전체 DOM을 Reflow 하는 것이 아니라, 가상의 DOM을 이용하여 한번만 Reflow를 수행함으로 부하를 줄여 빠르게 그릴 수 있습니다.
사진 및 인용글 출처: https://minemanemo.tistory.com/120
(실습) Vanilla JS와 React 비교하기
실습의 최종 결과물은 다음과 같습니다. ‘아메리카노 추가’ 버튼을 클릭하면 2,000원으로 초기화되어 있는 총금액이 2,000원씩 증가하는 간단한 예제입니다.
이를 VanillaJS와 React로 각각 구현해보겠습니다.
우선 실습을 위해 아래 명령어를 터미널에 입력해 Git repository를 clone 합니다.
git clone https://github.com/Block-Chain-Valley/front-end-session.git
clone이 완료되었다면 Vscode 등 코드 에디터에서 react_basic 디렉토리의 practice.html을 열어줍니다. 코드는 아래와 같을 것입니다.
body > script > /* Vanilla JS */ 아래에 버튼과 총금액을 표시하는 element를 생성하고, 이벤트 핸들러를 등록해 버튼이 클릭될 때마다 금액이 추가되도록 구현합니다. 그리고 이를 id가 “root”인 div element의 자식으로 추가합니다.
<style> 태그 내 미리 정의한 id 값을 참조해 구현하면 따로 스타일링을 하지 않아도 됩니다.
index.html 파일에 코드가 있지만 가급적 스스로 구현하는 것을 목표로 합니다.
Vscode를 사용하시는 분은 Liveserver extension을 이용해 실습 도중 결과물을 주기적으로 확인해주세요.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vanilla JS & React</title>
<style>
#root {
margin: 50px auto;
width: 500px;
}
#btnAdd {
width: 150px;
height: 40px;
border-radius: 15px;
padding: 5px;
border: none;
font-weight: 600;
background-color: rgba(0, 0, 0, 0.1);
cursor: pointer;
}
#btnAdd:hover {
background-color: rgba(0, 0, 0, 0.2);
}
#price {
margin-left: 10px;
font-size: 20px;
}
</style>
</head>
<body>
<div id="root"></div>
<script
crossorigin
src="https://unpkg.com/react@18/umd/react.development.js"
></script>
<script
crossorigin
src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"
></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
/* Vanilla JS */
/* React */
</script>
</body>
</html>
const unitPrice = 2000;
let totalPrice = unitPrice;
const $root = document.querySelector('#root');
const $btnAdd = document.createElement('button');
const $price = document.createElement('strong');
$btnAdd.innerText = '아메리카노 추가';
$btnAdd.id = 'btnAdd';
$price.innerText = `금액: ${unitPrice}원`;
$price.id = 'price';
const btnClickHandler = event => {
totalPrice += unitPrice;
$price.innerText = `금액: ${totolPrice}원`;
};
$btnAdd.addEventListener('click', btnClickHandler);
$root.appendChild($btnAdd);
$root.appendChild($price);
이번엔 같은 결과물을 React로 구현해보겠습니다. Vanilla JS로 작성한 내용을 주석처리하고, React 주석 하단에 작성하겠습니다.
<script type="text/babel">
/* React */
</script>
가장 먼저 React component를 만들겠습니다.
const App = () => {};
JavaScript 함수와 같다는 것을 눈치채셨나요?
맞습니다. React는 JavaScript class 기반 component와 function 기반 component를 사용할 수 있으며, 실습에서는 functional component를 사용합니다.
functional component의 이름은 대문자로 시작해야 하고, React가 UI에 렌더링 할 element를 반환해야 합니다. 그리고 그 element는 마치 HTML처럼 보이는 JSX 문법으로 작성된 element가 될 수 있습니다.
예제의 경우 button과 strong 태그를 추가해 UI 요소를 구성하겠습니다. 지금은 이해가 잘 가지 않아도 넘어가 주세요.
const App = () => {
return (
<>
<button id='btnAdd'>아메리카노 추가</button>
<strong id='price'>금액: 2000원</strong>
</>
);
};
축하합니다! 우리의 첫 컴포넌트를 만들었습니다 🎉
이제 이 컴포넌트를 렌더링 해볼까요? 아래 코드를 App 컴포넌트 아래에 추가해주세요.
const root = ReactDOM.createRoot(document.querySelector('#root'));
root.render(<App />);
Liveserver를 확인해보면 최종 결과물과 정확히 같은 UI의 결과물이 렌더링 되었을 것입니다.
하지만 이벤트 핸들러를 등록하지 않았기 때문에 버튼을 클릭해도 아무런 동작을 하지 않습니다. 그리고 현재의 총금액은 2,000원으로 하드코딩되어 있습니다.
그럼 어떻게 하면 버튼을 클릭했을 때 총금액을 업데이트할 수 있을까요?
바로 React의 useState Hook을 사용하고, 이벤트 핸들러를 버튼 컴포넌트에 등록하면 됩니다.
앞서 개발자가 상태만 관리하면 React가 알아서 DOM을 렌더링 한다는 것 기억하시나요? useState Hook은 함수이고, 이 함수는 함수의 초기값과 상태 변환 함수를 리턴합니다. 아래 예제를 함께 보시죠.
const [totalPrice, setTotalPrice] = React.useState(2000);
useState의 인자로는 초기값을 건네주고, JavaScript Array destructuring 문법을 이용해 상태와 상태 변환 함수를 저장하는 코드입니다.
이를 저희 예제에 추가하면, 아래와 같습니다.
const App = () => {
/* useState 함수 */
const [totalPrice, setTotalPrice] = React.useState(2000);
return (
<>
<button id='btnAdd'>아메리카노 추가</button>
<strong id='price'>금액: 2000원</strong>
</>
);
};
const root = ReactDOM.createRoot(document.querySelector('#root'));
root.render(<App />);
이제 상태는 관리할 수 있게 되었으니 마지막으로 이벤트 핸들러 함수를 작성해볼까요? 이벤트 핸들러 함수는 Vanilla JS에서와 크게 다르지 않습니다. 하지만 버튼을 클릭했을 때 상태를 업데이트해야 되니 useState를 통해 받아온 상태 변환 함수를 실행하도록 하죠.
상태 변환 함수는 컴포넌트의 리렌더링을 유발합니다.
const btnClickHandler = event => {
setTotalPrice(prev => prev + unitPrice);
};
코드를 해석해볼까요? 상태 변환 함수는 callback을 인자로 받는데, ‘prev’는 이전 상태이고, return 값은 이전 상태에 단위 값만큼 추가한 값이 되겠네요.
이를 저희 예제에 추가하면 아래와 같습니다.
button 태그의 onClick과 strong 태그에 totalPrice를 주의 깊게 봐주세요.
onClick에는 click event가 발생했을 때 실행할 함수를 넘겨주는 것이고, totalPrice는 상태 값입니다.
const App = () => {
/* useState 함수 */
const [totalPrice, setTotalPrice] = React.useState(2000);
const unitPrice = 2000;
const btnClickHandler = event => {
setTotalPrice(prev => prev + unitPrice);
};
return (
<>
<button id='btnAdd' onClick={btnClickHandler}>아메리카노 추가</button>
<strong id='price'>금액: {totalPrice}원</strong>
</>
);
};
const root = ReactDOM.createRoot(document.querySelector('#root'));
root.render(<App />);
이제 우리는 useState를 통해 초기화한 상태 값을 버튼이 클릭될 때마다 업데이트해 UI에 반영할 수 있게 되었습니다 😊
다시 한번 코드를 실행해볼까요?
잘 동작 하나요? 잘 동작하지 않는다면 차분히 디버깅을 해보세요 :)
이제 Vanilla JS와 React 코드를 비교해보겠습니다.
엘리먼트를 생성하고, id를 설정하고, 이벤트 핸들러를 등록하는 일. Vanilla JS에서는 명령하듯 하나씩 추가해나갔습니다. 반면 React에서는 렌더링 하고 싶은 element를 HTML처럼 선언하고(JSX 문법) 그 속성으로 id, event를 건네주면, 우리가 해야 했던 바인딩을 React가 배후에서 모두 해줍니다.
앞서 React를 사용하면 데이터 바인딩을 명시적으로 지정할 필요가 없다고 설명드렸던 부분이 어느 정도 이해되시나요?
const unitPrice = 2000;
let totalPrice = unitPrice;
const $root = document.querySelector('#root');
const $btnAdd = document.createElement('button');
const $price = document.createElement('strong');
$btnAdd.innerText = '아메리카노 추가';
$btnAdd.id = 'btnAdd';
$price.innerText = `금액: ${unitPrice}원`;
$price.id = 'price';
const btnClickHandler = event => {
totalPrice += unitPrice;
$price.innerText = `금액: ${totolPrice}원`;
};
$btnAdd.addEventListener('click', btnClickHandler);
$root.appendChild($btnAdd);
$root.appendChild($price);
const App = () => {
/* useState 함수 */
const [totalPrice, setTotalPrice] = React.useState(2000);
const unitPrice = 2000;
const btnClickHandler = event => {
setTotalPrice(prev => prev + unitPrice);
};
return (
<>
<button id='btnAdd' onClick={btnClickHandler}>아메리카노 추가</button>
<strong id='price'>금액: {totalPrice}원</strong>
</>
);
};
const root = ReactDOM.createRoot(document.querySelector('#root'));
root.render(<App />);
버튼과 텍스트를 추가하는 단순한 예제기 때문에 그 차이가 극명하게 나타나지 않을 수 있습니다.
하지만 프로그램의 규모가 커지면 어떤 차이가 있을지 상상해보면 어떨까요?
만약 우리가 작성한 코드 중 일부가 특정 브라우저에서 사용할 수 없는 DOM API를 활용했다면요?
그래도 아직 차이를 모르겠고, 기존 Vanilla JS에서 불편함을 느끼지 못한다면 그 불편함이 무르익을 때까지 프레임워크를 사용하지 않는 것도 좋은 방법입니다.
저는 개인적으로 진행하던 VanillaJS 프로젝트에서 전역으로 관리하는 상태가 업데이트될 때마다 일일이 UI를 rendering 해본 경험을 통해 React의 선언형 프로그래밍 방식의 편리함을 알게 되었습니다.
(실습) Vanilla JS로 React 핵심 기능 구현하기
본 실습은 (주) 우아한 형제들 김의중 님의 ‘만들어 가며 알아보는 React: React는 왜 성공했나’를 기반으로 제작되었습니다. 원문에서 더 잘 설명되어 있는 글은 수정하지 않고 기재했고, 실습 상 보완이 필요한 부분만 보완하였습니다. 문제가 된다면 곧바로 삭제하겠습니다.
이번 실습에서는 React의 핵심 기능 5가지를 직접 Vanilla JS로 구현하며 그 동작 원리를 더욱 깊이 파헤쳐 볼 겁니다.
다만, 아래의 코드들은 실제 React 개발 코드와는 차이가 있습니다. 이해를 돕기 위해 많은 부분이 생략되었고, 구현 방식이 조금 다를 수 있습니다. JavaScript 레벨에서 어떤 방식으로 React 기능들을 구현할 수 있는지에 초점을 두고 봐 주세요.
리액트 핵심 기능 5가지
- 가상돔 (Virtual DOM)
- JSX(Javascript And XML)
- VDOM to RealDOM
- Diffing Update
- Hooks
먼저 위 실습에서 clone 한 repository의 react_deepdive 디렉토리를 열어줍니다.
터미널에 npm install을 입력하고, dependency를 모두 설치합니다.
npm install
public/index.html을 열고, Liveserver 실행해 결과물을 확인합니다.
이번에도 마찬가지로 아메리카노를 추가해보도록 하겠습니다 ☕️
Liveserver로 결과물이 잘 실행되는지 확인한 후에는 root 디렉토리의 webpack.config.json을 다음과 같이 변경합니다.
const path = require('path');
module.exports = {
// enntry file
entry: './src/js/app.js',
// 컴파일 + 번들링된 js 파일이 저장될 경로와 이름 지정
output: {
path: path.resolve(__dirname, 'dist/js'),
filename: 'bundle.js',
},
module: {
rules: [
{
test: /\\.js$/,
include: [path.resolve(__dirname, 'src/js')],
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
},
},
},
],
},
devtool: 'source-map',
// <https://webpack.js.org/concepts/mode/#mode-development>
mode: 'development',
};
이제 코드를 작성해보겠습니다! src/js/react.js 파일을 열어주세요.
1. Virtual DOM
Virtual DOM에 대해서는 앞서 DOM의 형태를 본떠 만든 객체라고 설명했습니다. 코드를 보면서 이를 더 구체적으로 이해해보겠습니다.
아래와 같은 태그 정보는
<div id="container">
<p>VirtualDOM</p>
</div>
이와 같이 객체로 추상화할 수 있습니다. 객체에는 tag, props, children 세 가지 속성이 있습니다.
const VirtualDOM = {
tag: 'div',
props: {
id: 'container'
},
children: [
{
tag: 'p',
props: {},
children: ["VirtualDOM"],
},
],
};
그리고 위 객체를 생성하기 위해 js/react.js 파일에 createElement 함수를 작성합니다.
function createElement(tagName, props, ...children) {
return { tagName, props, children: children.flat() }
}
그렇게 하면 아래와 같이 객체를 생성할 수 있습니다.
const VirtualDOM2 = (
createElement('div', { id: 'container' },
createElement('p', { style: 'color: red' }, '제목 입니다'),
)
)
하지만 복잡한 UI를 위한 객체를 매번 개발자가 위와 같이 생성하기엔 너무 불편합니다. 그리고 이 createElement를 쉽게 사용할 수 있게 해주는 것이 JSX입니다.
2. JSX (JavaScript And Xml)
JSX는 개발자는 마크업 문법으로 개발하고, JavaScript 컴파일러인 Babel을 통해 createElement 함수로 변환(transpile) 해주는 역할을 합니다.
JSX는 @babel/preset-react 플러그인에 의해 JSX를 위의 createElement 함수로 변환합니다.
하지만 우리는 React 없이 JSX를 사용할 것이기 때문에
소스코드 주석에 @jsx ‘함수명'을 기입해야 하고, 제가 js/app.js에 미리 추가해두었습니다.
3. renderRealDOM (가상돔을 실제 돔으로 렌더링 하기)
가상돔을 실제 돔으로 렌더링 하기 위해 새로운 함수를 작성해보겠습니다. 아래 함수를 js/react.js 1번 실습 하단에 추가해주세요.
export function renderRealDOM(VirtualDOM) {
// 가장 끝 하위요소 예외처리
if (typeof VirtualDOM === 'string') {
return document.createTextNode(VirtualDOM);
}
//tag 생성
const $Element = document.createElement(VirtualDOM.tagName);
// click eventListener 등록
if (VirtualDOM.props !== null) {
if (VirtualDOM.props.hasOwnProperty('id')) {
$Element.id = VirtualDOM.props.id;
}
if (VirtualDOM.props.hasOwnProperty('onClick')) {
$Element.addEventListener('click', VirtualDOM.props.onClick);
}
}
// 재귀 호출
VirtualDOM.children
.map(renderRealDOM)
.forEach(node => $Element.appendChild(node));
return $Element;
}
이 renderRealDOM 함수가 하는 일은 아래와 같습니다.
- VirtualDOM의 tagName을 바탕으로 document.createElement API를 이용하여 태그를 생성해 줍니다.
- VirtualDOM의 자식(children) 구조가 동일하므로 재귀 호출로 renderRealDOM을 호출해 줍니다.
- 각각의 Children Node 데이터를 appendChild API로 Element를 추가해 줍니다.
- 가장 끝 하위 요소 Children은 String이기 때문에, 예외 처리를 해 주고, createTextNode로 TextNode를 생성해 줍니다.
요약하면, 재귀 호출로 DOM API를 이용해 태그를 생성해 줍니다.
+ 김의중 님 예제에 더해 props의 값을 추가하는 코드를 덧붙였습니다.
4. diffingUpdate
VirtualDOM은 객체이기 때문에, 이전에 적용된 Old VirtualDOM과 New VirtualDOM을 비교해서 변경된 부분만을 손쉽게 업데이트할 수 있습니다. 쉬운 예시를 위해, 텍스트가 변경된 경우에 한해서만, Diffing Update를 구현합니다.
아래 함수도 js/react.js 2번 실습 하단에 추가해주세요.
export function diffingUpdate(parent, nextNode, previousNode, parentIndex = 0) {
// Node가 string일 때만 변경
if (typeof nextNode === 'string' && typeof previousNode === 'string') {
// 바꿀 필요 없다면 return
if (nextNode === previousNode) return;
return parent.replaceChild(
renderRealDOM(nextNode),
parent.childNodes[parentIndex]
);
}
//Array.prototype.entries() => [index, element]
for (const [index] of nextNode.children.entries()) {
diffingUpdate(
parent.childNodes[parentIndex],
nextNode.children[index],
previousNode.children[index],
index
);
}
}
이 diffingUpdate 함수가 하는 일은 다음과 같습니다.
- VirtualDOM 객체의 구조(tag, props, children)는 동일하기 때문에 diffingUpdate 함수를 재귀 호출함으로써, 모든 자식 태그를 순회합니다.
- 함수의 인자로 부모 노드, 변경할 노드, 이전 노드, parentIndex를 받아서, replaceChild DOM API로 변경된 부분만을 업데이트합니다.
5. Hooks
이번에는 이전 실습에서 활용했던 react의 useState Hook을 구현해보겠습니다.
우선 js/app.js를 열고, js/react.js에서 작성한 함수들을 import 해줍니다.
import { createElement, renderRealDOM, diffingUpdate } from './react';
다음은 전역으로 관리할 상태를 저장하는 배열과 여러 개의 상태에 접근할 때 사용할 인덱스를 초기화하고,
useState Hook을 작성해봅시다. 아래 코드도 js/app.js에 추가합니다.
let currentIndex = 0;
const hookState = [];
function useState(initialState) {
const index = currentIndex;
if (hookState.length === index) {
hookState.push(initialState);
}
const setState = callback => {
hookState[index] = callback(hookState[index]);
render();
};
currentIndex++;
return [hookState[index], setState];
}
이 useState 함수가 하는 일은 다음과 같습니다.
- 초기값이 설정되어 있지 않을 시 초기값을 설정합니다.
- 각각의 함수 컴포넌트들이 useState를 호출할 때마다(리렌더링) currentIndex로 해당 컴포넌트의 배열 위치 값을 관리합니다. useState를 호출하는 각각의 컴포넌트를 순서대로 currentIndex 즉, 일종의 ‘Key’로 구분해 줍니다.
- hookState를 useState 함수 외부에 두어, 클로저로 데이터를 유지시켜, 함수가 다시 호출되더라도 이전 상태를 기억할 수 있습니다.
- setState로 데이터를 수정 시, 해당 배열 내부의 값을 변경해 줍니다.
- useState 함수가 종료되기 전 currentIndex 값을 증가시켜 다음 hookStates 배열의 Index 값을 업데이트해 줍니다.
useState를 사용하는 컴포넌트들의 상태는 hookState 배열에 ‘순서대로’ 저장됩니다. Hook이 사용 규칙이 있는 이유입니다.
Hook의 두 가지 사용 규칙
최상위(at the Top Level)에서만 Hook을 호출해야 합니다.
Hook은 순서대로 배열에 저장됩니다. 만약 최상위 레벨이 아닌 조건문이나, 반복문, 중첩 함수에서 Hook을 사용한다면 맨 처음 함수가 실행될 때 저장되었던 순서와 맞지 않게 됩니다. 따라서 최초에 저장되었던 Hook의 상태 테이블에서 다른 상태 값을 참조하게 되는 버그를 유발할 수 있습니다. Hook의 상태 테이블은 useState 내부가 아닌 외부 상태를 참조하고 있기 때문입니다.
오직 React 함수 내에서 Hook을 호출해야 합니다.
Hook은 React 함수 컴포넌트가 상태를 가질 수 있게 제공하는 기능입니다. 따라서 React 함수가 아닌, 일반 함수는 Hook을 저장할 수도, 위치 값을 알 수도 없습니다. 클래스 컴포넌트는 상태가 변경될 때 인스턴스를 새롭게 만들지 않고, render 메서드를 통해 상태가 업데이트됩니다. 따라서 Hook의 호출 시점을 만들 수 없으므로 Hook을 사용할 수 없습니다.
6. 최종 결과물 작성
다음으로 App 컴포넌트를 작성합니다. 이는 useState 함수 하단에 추가합니다.
function App() {
const [totalPrice, setTotalPrice] = useState(2000);
return (
<div>
<button id='btnAdd' onClick={() => setTotalPrice(prev => prev + 2000)}>
아메리카노 추가
</button>
<strong id='price'>{`금액: ${totalPrice}`}</strong>
</div>
);
}
코드 최상단 currentIndex 상단에 previousNode를 선언합니다.
let previousNode;
let currentIndex = 0;
const hookState = [];
마지막으로 첫 렌더링과 setState 함수가 호출될 시 실행될 render 함수를 작성하고 실행합니다.
이 코드도 js/app.js 최하단에 추가합니다.
const render = () => {
const $root = document.querySelector('#root');
if ($root.firstChild) {
const nextNode = App();
diffingUpdate($root, nextNode, previousNode);
previousNode = nextNode;
} else {
previousNode = App();
$root.appendChild(renderRealDOM(previousNode));
}
currentIndex = 0;
};
render();
이 render 함수가 하는 일은 다음과 같습니다.
- 첫 렌더링이 아니라면 기존 렌더링 된 컴포넌트와 새롭게 렌더링 할 컴포넌트에 diffingUpdate를 적용한다.
- 기존 컴포넌트 변수에 새 컴포넌트를 할당한다.
- 첫 렌더링이라면 App 컴포넌트를 root의 자식 노드로 추가한다.
- hookState 배열을 조회할 때 사용될 인덱스를 0으로 초기화한다.
다시 Liveserver를 켜서 실행해봅시다!
잘 실행되시나요?
막힌다면.. 또 디버깅을 열심히 해봅시다 😅
긴 글과 실습을 따라와 주셔서 감사드립니다. 😊
참고자료
JavaScript
The Weird History of JavaScript
React
Web Frontend | 우아한형제들 기술블로그 만들어 가며 알아보는 React: React는 왜 성공했나
React의 Virtual DOM(VDOM)과 Diffing 알고리즘
[JavaScript] Vanilla JS로 React 만들기
React의 Virtual DOM(VDOM)과 Diffing 알고리즘
Do We Really Need a Front-end Framework?
How React works under the hood
개발환경
'SW > Javascript' 카테고리의 다른 글
Javascript 자료형 (원시 값) (0) | 2022.07.16 |
---|
댓글