리엑트를 사용하기만 했지...
프로젝트를 진행하면서 가장 많이 사용한 라이브러리(요새는 거진 프레임워크라 해도 될..)는 리엑트이다. 많이 사용하는 만큼 항상 사용하는 hook 과 패턴에 의존하는 편이 있어서 좀 더 리엑트에 대해 공부를 하고자, 다시 공식문서를 참고하며 여럿 유명하신 개발자분들의 의견들을 참고하고 있다. (감사합니다!)
그저 당연하게 사용해왔던 라이브러리의 동작 원리에 대해서 할 수 있는만큼 알아보는것이 좋겠다 생각이 들어서, 검색과 강의를 참조하여 아주 간략하게 리엑트를 구현해보았다. 구현하는 과정과 이를 통해 리엑트에서 제안하는 규칙에 대해서 다뤄보도록 하자.
이 포스팅은 김민태: React로 구현하는 아키텍쳐와 리스크 관리법을 참고하였습니다.
간략하게 기본 셋팅
자바스크립트로 구현을 해볼 것이지만, jsx는 사용할 생각이 있으니(불편하니깐) babel 을 npm 에서 설치해주도록 하겠다.
npm i -D @babel/cli @babel/core @babel/preset-react
이후 babel.config.json 에서 간단하게 preset 설정을 해주자.
{
"presets": ["@babel/preset-react"]
}
설정이 끝났다면 (pakage.json 에서 build 명령어 설정후) npm run build 를 진행하여 트랜스파일링을 진행을 할 준비를 끝내자.
CreateElement 과 Virtual DOM
바벨을 통해서 트랜스파일링을 진행하게 되면 우리가 jsx 문법으로 작성한 html 의 태그값이, CreateElement 메서드를 통해 구현되고 있음을 확인할 수 있다. 기본적으로 React 의 경우 jsx 를 기본메서드인 React.CreateElement 를 통해 트랜스파일링 하고 코드 위에 굳이 지시어를 설정해줄 필요가 없다.
다만 우리는 지금 직접 메서드까지 구현해보는것이니 맨 위 코드에 지시어를 작성해주자
/** @jsx createElement */
import { render, createElement } from "./react.js";
function Title() {
return (
<div>
<h2>정말 동작할까</h2>
<p>한번 확인해보자</p>
</div>
);
}
render(<Title />, document.querySelector("#root"));
위 코드가 트랜스파일링 되면
/** @jsx createElement */
import { render, createElement } from "./react.js";
function Title() {
return createElement("div", null, createElement("h2", null, "\uC815\uB9D0 \uB3D9\uC791\uD560\uAE4C"), createElement("p", null, "\uD55C\uBC88 \uD655\uC778\uD574\uBCF4\uC790"));
}
render(createElement(Title, null), document.querySelector("#root"));
이런식으로 변형이 이루어진다. 참고로 위 @jsx 는 지시어이며 뜻은 jsx 문법을 옆 createElement 를 통해서 트랜스파일링을 하겠다는 지시다. 이 메서드 이름은 내가 지어주기 나름이다.
그렇다면 createElement 를 통해 무엇을 하고 싶은 것일까? 앞서 jsx 를 통해 우리는 실제 화면에 렌더링하고 싶은 html 태그들을 작성하였다. 즉, 우리는 자바스크립트 내에서 화면에 렌더링 하고자 하는 요소들을 생성하려는 것이 목표였으며, 실제 리엑트를 사용하다 보면 너무나도 당연하게 작성했던 부분이다. 그리고 이걸 가능하게 해주었던 메서드가 createElement 와 render(밑에서 설명)라고 할 수 있겠다.
render 는 이해가 간다. 말 그래도 실제 DOM 에 요소를 append 하여 화면을 렌더링 하겠다는 의미일테고, 실제 DOM 의 root div 에 append 할 것이다. 그럼 createElement 의 역할은 무엇일까? 바로 Virtual DOM 을 생성하는 메서드라고 할 수 있다.
createElement 는 jsx 에 사용되는 태그 요소들을 통해 가상돔을 만들어 주는 역할을 하게 된다. 다만 이러한 가상돔은 자바스크립트가 해석하기 좋아야 하고 친숙한 형태여야 할 것이다. 예측하건데 아래와 같이 객체형태일 것이다.
{
tagName: 'div',
props: {
id: 'root',
className: 'container'
},
children: [
{
tagName: 'span',
props: {},
children: [
'hello'
]
}
]
}
예를 들어 태그 <div id='root'><span>hello</span></div> 를 살펴보면, 이 태그의 정보들을 객체의 프로퍼티로서 각각 나타내자면 태그 이름, 태그 속성, 자식 요소 로 나타낼 수 있을 것이다. 자식은 여러개와 여러 단계로 이루어질 테고, 자식 역시 태그니 역시 태그이름 등 부모와 같은 객체 형태로 표현이 될 것이다. 이를 바탕으로 createElement 를 생성해보자
react.js 라는 파일을 생성하고 여기에 가장 기본적으로 리엑트에서 사용하는 메서드들을 구현해보자.
// createElement 메서드 : 가상돔 생성
// 가상돔의 이전상태와 현재상태를 비교할 수 있을 것이다.
export function createElement(tagName, props, ...children){
if(typeof tagName === 'function'){
return tagName.apply(null, [props, ...children]);
}
return { tagName, props, children };
}
여기서 tagName 은 문자열일 수 있으며 동시에 함수일 수 도 있다. <Title /> 이라는 함수 컴포넌트를 트랜스파일링 한다 생각해보면 일반 태그들이랑 차이점을 보이는 함수컴포넌트이기 때문에 이 함수 컴포넌트를 실행하여, 그 return 값으로 다시 랜더링할 태그들을 가상돔화 시켜야 한다.
그렇기 때문에 tagName 을 함수일 경우 실행시키면서, 인자들을 전달한다. 가변 인자를 전달하는 것이니 apply 사용.
앞서서 Title 컴포넌트를 보면 <div> 태그안에 두가지 태그가 자식으로 있다. 이러한 구조를 가상돔으로 생성하면,
상위 div 로 부터 children 의 배열 속 2개의 태그가 들어가있다. 태그가 더 트리를 타고 이어져있다면 당연히 계속 타고 내려가는 형태로 객체가 형성됬을 것이다. 실제로 children 부분의 트랜스파일링은 재귀적으로 이루어지는데,
createElement("div", null, createElement("h2", null, "정말 동작할까"), createElement("p", null, "한번 확인해보자"));
이런식으로 타고타고 들어가면서 실행이 된다.
간략하지만 이런식으로 가상돔이 형성이 될 것이며, 이러한 가상돔의 이전 상태를 따로 저장하고 현재 상태와의 비교를 통해 변경사항이 있다면 실제 DOM 에 요소로서 전환될 것이다.
Render & RealDOM
가상돔을 생성했으니 이제 실제 돔에 요소를 append 해보자. render 함수를 생성해야 하는데, 구조는 가상돔의 tagName 들을 타고타고 내려가야 하고, 그 끝을 미리 파악하기 어려우니 재귀적으로 작성하도록 하자.
// 즉시실행함수로 이전 가상돔을 참조할 수 있게 설정
export const render = (function(){
let prevVDOM = null;
return function(nextVDOM, container){
if(prevVDOM === null){
prevVDOM = nextVDOM
}
// diff 알고리즘
return container.appendChild(renderRealDOM(nextVDOM));
};
})();
함수는 이전 상태를 가져올 수 없기에, 이를 가능하게 하기 위해 리엑트의 어떤 특정한 API 를 설계하는 것이 아니라, 자바스크립트의 특징은 클로져를 활용해서 이전 상태 변수에 접근할 수 있도록 하였다.
render 를 호출 시 즉시실행함수로서 함수값을 반환하게 되고, 이러한 함수는 상위 스코프 내 변수 prevVDOM 을 참조할 수 있게 된다. 왜냐하면 선언할 때 이미 스코프는 결정나기 때문이다. 따라서 이전 VDOM 과 현재 인자로 전달받은 nextVDOM 과의 비교 알고리즘을 실행할 수 있게 되었다. (이 비교 알고리즘은 구현하진 않겠다... 아니 못하겠다...)
이 render 의 최종 반환은 container 에 가상돔을 통해 만든 요소를 append 하는 것이고, 이제 가상돔을 실제 요소 구조로 변경시켜주어야 한다. 그 과정을 하는 함수를 renderRealDOM 이라고 하자. 이 함수는 children 을 타고 들어가면서 더이상 tagName 이 객체가 아닐때까지 들어가 문자열로 된 children 을 createTextNode 를 통해 요소를 만들고, 재귀적으로 상위 함수로 돌아와야 한다.
function renderRealDOM(vdom){
if(typeof vdom === string){
return document.createTextNode(vdom);
}
if(vdom === null) return;
const $el = document.createElement(vdom.tagName);
vdom.children.map(renderRealDOM).forEach(node => {
$el.appendChild(node);
};
return $el;
}
가상돔에 대해서 인자로 전달을 받으면 우선 부모인 tagName 에 대한 요소를 생성한 뒤, 자식 children 은 배열일 테고, 각 배열의 요소들에 대해 다시 renderRealDOM 함수를 재귀적으로 실행시킨다. 이 함수는 결국 요소를 반환하게 되는데, 타고 들어가다가 기저조건을 만나게 되어 다시 사위 반복문으로 돌아오게 된다. map 을 다 돌렸다면, 이제 $el 에 추가시켜주고, 최종적으로 최상위 $el 을 반환하게 된다.
이 반환값이 위에서 살펴본 render 함수로 전달되고, 그렇게 실제 DOM 에 적용이 되게 된다.
Class Component
지금까지 함수 컴포넌트를 간략하게 구현해보았다. 특별한 API 의 사용이 아닌 자바스크립트의 클로져를 활용하여 이전 가상DOM 과 현재 DOM 을 비교할 수 있음을 확인하였고, render 하는 과정에서 재귀함수를 통해 요소들을 생성하는 것을 알게 되었다. 이제 hook 이 등장하기 전까지 많이 사용되던 Class Component 역시 간략하게 구현을 해보자.
class YourTitle extends Component {
render() {
return <p>클래스의 타이틀</p>;
}
}
function Title() {
return (
<div>
<h2>정말 동작할까</h2>
<YourTitle />
<p>한번 확인해보자</p>
</div>
);
}
클래스 컴포넌트는 기존 함수 컴포넌트와는 달리 내부에 render 메서드가 존재하며, 인스턴스가 필요하다. 즉, new 를 통해서 인스턴스를 생성하고 이 인스턴스에 대해 render 메서드로 위에서 언급한 render 과정을 거쳐야 한다.
문제는 JSX 에서 예를 들어 <YourTitle /> 이라는 컴포넌트를 렌더하고 싶은데, 이 컴포넌트가 함수형인지 클래스형인지 알 수가 없다는 점이다. JSX 는 이를 구별하지 못한다. 자바스크립트에서 이 둘을 구별하기 위해서는 클래스의 상속 성질을 이용하는 방법이 있는데, 클래스 YourTile 보다 더 상위 클래스를 생성하고, 조건문에서 YourTitle 의 프로토타입이 상위 클래시인지 여부를 boolean 으로 판단하는 것으로 함수형 컴포넌트와 클래스형 컴포넌트를 구별할 수 있을것이다.
일단 구별할 수 있음을 파악했으니, 이제 이를 어디서 구별할것인지를 결정해보자. tagName 이 함수냐 클래스냐의 차이점이기 때문에, 우선 함수형이라면 그 안에서 조건식으로 함수형과 클래스형을 가르면 될 것같다. 이를 분류하던 메서드는 createElement 이다.
export function createElement(tagName, props, ...children) {
// virtual DOM 만들기
if (typeof tagName === "function") {
// 이 부분이 함수와 클래스 여부를 가르게 된다.
if (tagName.prototype instanceof Component) {
const instance = new tagName({ ...props, children });
return instance.render();
} else {
return tagName.apply(null, [props, ...chidren]);
}
}
return { tagName, props, chidren };
}
tagName 이 함수라면 프로토타입을 검사하여 만약 상위 클래스 Component 에 속한다면 클래스형이니, 인스턴스를 생성한다. 이후 내부 메서드 render() 을 실행하면 그 반환값으로 JSX 가 나오게 되고, 지시에 의해 createElement 를 통해 가상 DOM 을 형성할 것이다.
주의할 점은 클래스 내부 메서드 render() 는 실제 우리가 생성한 함수 render() 와는 다른것이다. 내부 메서드의 경우 반환값은 가상돔을 위한 JSX 이며, 함수 render() 는 실제로 가상돔을 실제돔에 적용시키는 함수다.
실제로 클래스 컴포넌트가 랜더링 되어 실제 DOM에 적용된 Element 는 다음과 같다.
클래스형식과 함수형식의 차이라면, 인스턴스를 생성한다는 점에 있다. 위 코드는 수도 코드정도의 간략한 코드이지만, 실제로는 인스턴스 생성 과정이 외부에서 진행된다고 한다. 이 말은 인스턴스가 외부에 저장되어있고, 이 인스턴스를 통해 클래스형 컴포넌트는 이전 상태값을 가지며, 이를 통해 컴포넌트의 생명 주기를 다룰 수 있게 된다고 한다. 이 부분이 기존 함수형 컴포넌트를 사용하지 않은 이유이기도 하다. 기존 함수형 컴포넌트는 함수이기 때문에 이전 상태값을 가져올 수 없었기 때문이다. 내부 변수는 함수 소멸과 동시에 사라지기 때문이다.
이전부터 리엑트는 클래스형 컴포넌트에서 느끼는 불편함에 대해서 해결책을 찾고 있었고, 실제 공식 홈페이지에는 이러한 불편했던 점에 대해서 상세하게 설명하고 있다.
https://ko.reactjs.org/docs/hooks-intro.html
Hook의 개요 – React
A JavaScript library for building user interfaces
ko.reactjs.org
간략하게 요약해보자면
- 컴포넌트 사이에서 상태 로직을 재사용하기 어렵다
- 복잡한 컴포넌트들은 이해하기 어렵다. 특히 생명주기 로직 설계시 분리가 어려움
- class 컴포넌트에서 사용되는 this 는 사람과 기계를 혼동시킨다.
이러한 불편함이 있었고, 이에 18년도에 함수형 컴포넌트에서도 이전 상태값을 컨트롤할 수 있도록 hook 을 발표하게 된다.
hooks
hooks 은 어떠한 개념으로 기존 함수형 컴포넌트에서 이전 상태를 가져올 수 있게 했을까. 어떠한 방식이 더 맞는 표현일거같다.
리엑트 개발팀은 실제 이에 대한 해결책으로, 함수 컴포넌트가 랜더링 될 때, (사용자가 직접 코드를 수정하지 않는한) 랜더링되는 함수 컴포넌트의 개수, 위치, 순서가 몇번 랜더링 되더라도 동일하다는 것을 이용하기로 했다. 이것이 동일하기 때문에, 어떤 외부에 저장소 배열에 각 함수 컴포넌트의 순서를 저장하는 것이다.
10번째로 랜더링 되는 함수 컴포넌트 내 상태값을 상태 저장소 배열에서 인덱스 10에 해당하는 상태를 가져오면서 이전상태를 가져오는 문제를 해결하고자 하였다.
이러한 아이디어에 기반하여 간단하게 hook 에 대해서 구현해보자
export function useState(initialValue) {
let value = initialValue;
return [
value,
(nextValue) => {
value = nextValue;
},
];
}
가장 범용성이 높은 useState 에 대해서 간략하게 구현해보면 위와 같다. 초기값이 존재하며 이후 반환값으로 상태값과, 상태값을 업데이트하는 함수를 배열로 반환한다. 물론 위 코드대로 구현하면 제대로 작동하지 않는다. 왜냐하면 매 호출마다 value 가 initialValue 로 설정이 될 것이니 말이다. 그리고 무엇보다, 상태값 value 를 함수 컴포넌트 외부에 저장해놓아야 한다.
참고로 useState 역시 클로져를 활용한 것이다. 상위 변수 value 를 스코프체인에 의해 반환값이 참조할 수 있다.
const hooks = [];
let currentComponent = -1;
// 생략
export function createElement(tagName, props, ...children) {
// virtual DOM 만들기
if (typeof tagName === "function") {
// 이 부분이 함수와 클래스 여부를 가르게 된다.
if (tagName.prototype instanceof Component) {
const instance = new tagName({ ...props, children });
return instance.render();
} else {
// 매 함수컴포넌트의 인덱스를 증가시킨다.
currentComponent++;
return tagName.apply(null, [props, ...chidren]);
}
}
return { tagName, props, chidren };
}
상태값을 저장하기 위한 hooks 배열이 외부에 존재한다. 그리고 currentComponent 라는 상수가 존재하는데, 이는 랜더링되는 함수 컴포넌트의 순서에 대한 인덱스 값이다. 매번 같은 순서대로 함수 컴포넌트는 랜더링 될 것이고, 그 함수들에 대한 인덱스를 순차적으로 증가시킨다고 생각하자. 그리고 함수를 실행시키면서 함수 내부의 hook으로 관리하는 state 값들을 hooks 배열에 hooks[currentComponent] 를 통해 넣어주도록 하자. hooks[10] 이라면 10번째로 랜더링 되는 함수 컴포넌트 내 상태값들이라고 생각하면 된다. (물론 함수 컴포넌트 내 상태값들이 여러개라면 배열이나 객체로서 상태값이 들어갈 것이다)
및 createElement 함수를 보게 되면 reuturn 부분이 함수를 실행시키는 것이고, 그러니 그 전에 인덱스 값이 상승되어야 한다. 그래서 초기 값을 -1로 한 것이다. 그래야 0 부터 시작하게 되니말이다.
이제 다시 useState 를 외부 hooks 에 상태값을 전달하도록 수정해보자.
export function useState(initialValue) {
const position = currentComponent;
hooks[position] = initialValue;
return [
hooks[position],
(nextValue) => {
hooks[position] = nextValue;
},
];
}
position 으로 캡쳐링 한 후, 상태값을 hooks 배열에 담는다. 물론 이 코드 역시 수도 코드로 받아들이자. (아마도 좀 더 엄밀하게 하자면 함수컴포넌트 내 상태값이 여러개일 경우 좀 더 다르게 저장해야할 것이다.)
중요한 점은 외부 배열에 상태값을 저장하고 필요할 때 가져온다는 개념이다. 그리고 그 저장 순서는 함수 컴포넌트의 랜더링 순서이다. 이러한 개념으로 이제 함수 컴포넌트도 상태값을 컨트롤할 수 있고, 이에 따라 현재 거진 함수형 컴포넌트 사용으로 방향이 틀어졌다.
간단하다 얘기했지만, 사실 구현을 하려다 보니 감이 잡히지 않아 많은 소스를 참고하게 되었다. 그럼에도 유의미하다 느꼈던 점은 실제 리엑트가 어떠한 방식으로 가상 DOM 을 생성하고, 가상 DOM 의 형태가 객체라는 사실(추정)과 이러한 가상 DOM 을 통해 render 과정을 거쳐서 실제 DOM 에 적용해보는 과정을 거치면서 조금 더 리엑트를 이해할 수 있었던 것 같다.
조금 더 길어질 것 같아 이번 포스팅에는 다루지 못했지만, 바로 다음에 다루고자 하는 내용은 hook의 규칙이다.
위에서 우리는 hook이 외부 상태 저장소를 이용한다는 것을 알게 되었고, 그렇기에 이를 기반으로 하는 hook의 규칙이 있다. 이러한 규칙과 더불어 규칙을 지키지 않았을 경우 어떠한 문제가 발생하는지에 대해서 다음에 다루어 보겠다.
'Programing > React' 카테고리의 다른 글
Redux를 간략하게 구현해보자 (0) | 2023.02.01 |
---|---|
Hook을 올바르게 사용하기 (0) | 2023.01.26 |
useCallback & useMemo (0) | 2022.12.01 |
React.memo (0) | 2022.11.10 |
2차 프로젝트 - 에어비엔비 Filter(3) (1) | 2022.10.08 |