리액트 바닥부터 만들어보기 (1) - 전체적인 구조와 가상DOM
1. 계기
이번에 리액트의 핵심 기능들을 직접 구현해보며 그 내부 동작을 깊이 이해하고자 프로젝트를 진행했습니다. 먼저 이 글에서는 JSX와 가상 DOM에 대해 구현 과정을 공유하려고 합니다.
최근 한국과 해외 모두에서 React를 베이스로 하는 프로젝트가 많습니다. Next.js도 React 프레임워크로, 널리 사용되고 있죠. 이전 회사와 여러 부트캠프들에서도 당연하게 React를 기술 스택으로 선정했습니다.
퇴사 후 기본기를 다지며 “과연 내가 React를 제대로 이해하고 있을까?” 하는 의문이 들었습니다. 특히 React의 핵심 컨셉인 가상DOM이 어떻게 동작하는지 useState를 활용하는 상태 관리는 어떻게 작동할 지 더 자세히 알고 싶었습니다. 그래서 React를 바닥부터 만들어보며 핵심 개념을 익히고자 이 프로젝트를 시작하게 되었습니다. 부가적으로 최근 보완한 자바스크립트 기본기를 활용해 볼 좋은 기회라고 생각했습니다.
2. JSX
JSX란?
React를 처음 배울 때, JSX 문법을 제일 처음 접하게 될 겁니다. JSX는 JavaScript XML의 약자로, React에서 UI를 표현하기 위해 사용하는 문법입니다. JavaScript 파일 안에서 HTML과 유사한 마크업을 작성할 수 있게 해주어 별다르게 문법을 배우지 않아도 쉽게 작성할 수 있다는 장점이 있죠.
그러면 JSX 문법을 꼭 사용해야 할까요? 그렇진 않습니다. 아래와 같이 작성할 수도 있어요. (React v18에서는 createElement
가 레거시가 되었네요)
이는 곧 아래 JSX 코드와 같습니다.
확실히 JSX를 사용한 아래 코드가 더 직관적이고 읽기 쉽습니다. 복잡한 마크업의 경우 createElement
를 사용하면 코드가 매우 복잡해질 수 있습니다.
JSX 변환
리액트 만들기 프로젝트에서의 첫 번째 목표는 JSX를 JavaScript로 변환하는 과정이었습니다. 처음에는 JSX 문법을 변환하는 파서를 직접 만들어 더 깊이 들어갈까 고민했습니다. 하지만 이게 내 주 목적에 맞나? 했을 때, 아니라는 결론이 나왔고 도구의 도움을 받기로 했습니다.
저는 프로젝트 세팅을 할 때, vite를 주로 사용합니다. vite는 번들러, 트랜스파일러, 개발 서버 등을 포함한 종합적인 프론트엔드 개발 환경 구축 도구입니다. (vite 공식문서 참고.) webpack, babel로 구축해본 적도 있는데 (Vanilla JS 개발환경 세팅하는 법 참고) 이것저것 해주어야 할 것들이 많은데 vite는 명령어 한 줄이면 정말 간단하게 구축해주기 때문에 애용합니다.
💡 용어 설명
- 번들러: 여러 개의 파일을 하나의 파일로 묶어주는 도구입니다. 보통 자바스크립트 코드에서 의존성을 관리하고 최적화하여 배포용 파일을 생성하는 데 사용됩니다. 대표적인 번들러로는 Webpack, Rollup, Parcel 등이 있습니다.
- 트랜스파일러: 한 프로그래밍 언어로 작성된 코드를 다른 프로그래밍 언어로 변환해주는 도구입니다. 주로 최신 자바스크립트 코드를 구버전의 자바스크립트로 변환하거나, 타입스크립트 코드를 자바스크립트로 변환하는데 사용됩니다. 대표적인 트랜스파일러로는 Babel, TypeScript Compiler가 있습니다.
vite를 활용해 다음 단계를 진행해 기본 틀을 만들어줍니다.
- 프로젝트 생성
yarn create vite . --template vanilla
vite.config.js
파일 생성
vite.config에서 esbuild 옵션을 지정해줄 수 있습니다.
이 설정을 추가해주면 JSX 문법이 createElement 함수 호출로 변환되도록 설정해주는 것입니다.
위의 설정으로 JSX 코드는 다음과 같이 변환됩니다.
createElement
함수로 변환만 해줬고 실제 구현된 createElement
를 import 해주어야 합니다. 근데 매번.. 컴포넌트를 작성할 때마다 import할 생각을 하니 힘들더라고요. esbuild 옵션을 뒤져보니 jsxInject
라는 옵션이 있었습니다. jsx 파일 상단에 설정한 구문을 주입해줍니다.
다음과 같이 설정하면 됩니다.
이 설정으로 인해 JSX 코드는 다음과 같이 변환된다.
3. 가상 DOM
JSX가 JavaScript 코드로 변환되면, createElement가 호출되도록 만들었습니다. 이 함수의 역할은 무엇일까요? 바로 가상 DOM을 생성하는 것입니다. 실제로는 JavaScript “객체”를 만드는 과정입니다.
가상DOM이란?
일단 가상DOM이 무엇인지 짚고 넘어갈까요. 가상DOM은 실제DOM의 가벼운 복사본입니다. React는 이를 사용해 실제 DOM 조작을 최소화하고 성능을 개선합니다.
가상DOM이란?
일단 가상DOM이 무엇인지 짚고 넘어갈까요. 가상DOM은 실제 DOM의 가벼운 복사본입니다. React는 이 가상DOM을 사용해 변경 사항을 추적하고, 실제 DOM을 직접 조작하는 대신 변경이 필요한 부분만 효율적으로 업데이트합니다. 이를 통해 실제 DOM 조작을 최소화하고 성능을 개선할 수 있습니다.
React는 UI의 상태가 변경될 때마다 가상DOM을 먼저 업데이트하고, 이를 실제 DOM과 비교한 후 차이점만을 반영하는 방식으로 업데이트합니다. 이 과정은 'reconciliation'이라고 불리며, 이를 통해 불필요한 렌더링을 방지하고 빠른 사용자 경험을 제공합니다.
3-1. 가상DOM으로 변환하기
가상DOM 객체를 뜯어보면 아래와 같이 생겼습니다. 엘리먼트 type, props, children 배열 입니다. 앞의 예제를 가져왔는데요
이게 아래와 같은 형태의 객체로 변환되도록 해야합니다. (실제 리액트에서는 props 내부에 children이 존재하는 형태입니다. 저는 코드를 조금 더 간결하게 하고 싶어 따로 분리했어요)
그러면 createElement
는 이런 형태가 되어야겠죠.
여기서 끝나면 안되고 한 가지 더 생각해볼 게 있습니다. 이 상태에서는 children: [’Hello World’, {type: ‘div’, props: {}, children: []]
이런 식으로 children 배열의 형태가 일치하지 않습니다. 객체와 문자열이 같이 존재할 수 있으니까요.
여기서 고민을 했는데, 이 상태 그대로 두고 이 가상DOM 객체를 실제DOM으로 변환하는 과정에서 타입이 string이면 분기로 처리해주는 방식을 고안했고 그렇게 하면 if (typeof node === ‘string’)
구문이 들어가게 됩니다. 이렇게 구현했을 때, 구현상의 큰 어려움은 없었지만 저 분기문을 딱 봤을 때 이게 뭘 분기치는거였더라? 이런 생각이 들더라고요. 일주일만 지나도 이해못할 코드일 것 같았습니다.
어떻게하면 이 문제를 해결할 수 있을까? 아이디어가 떠오르지 않아 다른 사람들은 어떻게 했을까 눈물의 서치를 한 결과 이 글을 발견했습니다. https://pomb.us/build-your-own-react/ (이 분은 fiber tree, working queue 개념도 모두 도입하여 리액트 만들기를 했으니 읽어보면 매우 도움이 될 겁니다.)
이 글 초반부에 문자열을 객체로 만들어 규격을 통일시키는 부분을 보고 이거다! 하고 가져왔습니다. ‘Hello Wolrd’ 문자열을 아래와 같이 변환해 객체로 만들어주는 거죠.
이렇게 바꿔도 어쨌든 실제DOM으로 변환할 때는 분기문을 쳐야합니다. document.createElement랑 document.createTextNode를 분리해야하거든요. 하지만 if (typeof node === ‘string’)
보다 if (node.type === ‘TEXT_NODE’)
가 더 읽기 쉽고 직관적인 것 같네요.
그러면 createElement
에서 문자열을 텍스트 노드로 바꿔주는 로직이 필요합니다.
문자열을 객체로 바꾸는 함수 createTextElement
를 만들어주고,
createElement
에서 타입이 객체가 아니면 텍스트 노드로 변환하는 함수를 호출해 변경해줍니다.
아 그래서 객체만 계속 만들고 이거 화면에 그릴 수 있는거야??? 이제 해봅시다.
3-2. 실제로 그려보자
그럼 이제 react.js 코드를 만들어봅시다.
이번 단계에서는 다음 2가지를 완성해볼게요.
- 루트 컴포넌트와 이것을 넣을 HTML element를 설정해주는 부분
- 반환된 가상DOM을 실제DOM으로 변환해 실제 브라우저에 그리는 부분
이렇게 틀을 짜보았습니다. React 프로젝트의 main.jsx에서 루트 컴포넌트와 이것을 넣을 DOM element를 querySelector로 설정해주는 부분이 있습니다. 이걸 저는 render
함수로 해주도록 하겠습니다. 추가로 _render
함수를 만들었는데요 지금 단계에서는 굳이 필요 없지만 useState를 나중에 추가할 때 사용할 것이라 분리해두겠습니다. _render
함수에서는 가상DOM을 실제DOM으로 변환하고 브라우저에 그리는 로직을 추가할 겁니다.
render
함수에서는 vnode, container를 매개변수로 받고 (각 각, 루트 컴포넌트가 가상DOM으로 변환된 값, element) React 내부 변수에 할당해주고 _render
를 호출해주도록 했습니다. _container, _vnode 변수를 함수들에서 사용할 수 있도록 해두었어요.
_render
함수에서는 createDOM으로 실제DOM을 만든 후, 컨테이너에 appendChild로 붙여주면 끝입니다. 간단하죠
이제 createDOM을 만들어야 하네요. 얘도 간단합니다 type에 맞는 DOM element를 만들고 props를 attribute에 추가해주고 자식들을 여기에 붙여주면 끝이겠네요! 이 부분은 사실 크게 어려운 부분은 없었고 가상 DOM이 브라우저에 그려지는 과정을 이전보다 구체적으로 알 수 있게 되었습니다.
createDOM
부분만 이제 추가하겠습니다.
(1) type이 TEXT_ELEMENT
이면 텍스트 노드를 만들어주고 아니면 type에 맞는 엘리먼트를 만들어줍니다.
(2) props를 attribute에 추가해줍니다. 지금은 아주 간단히 추가했는데 더 예외 처리 해줄 것들이 있어요 나중에 해줄게요
(3) 이제 자식들을 순회하면서 재귀적으로 createDOM
을 호출해준 뒤, 루트 엘리먼트에 추가해주면 됩니다.
여기까지 코드입니다.
react.js
이제 실제 브라우저에 그려지는지 테스트 해볼게요!
index.html
main.js
App.jsx
여기까지 코드 작성하고 yarn dev 해주면 짜잔~~ Hello World 출력 완료.
4. 다음단계
근데 이건 가상DOM의 효과를 제대로 보지 못하고 있죠 useState도 없고요! 지금까지는 빌드업 단계입니다. 게임하다보면 완성 아이템 바로 만드는게 아니라 재료 아이템 만들고 합쳐가면서 맞추잖아요? 다음 글에서 채워보겠습니다.