React를 매일 쓰면서도 JSX가 어떻게 화면이 되는지, useState가 어떻게 값을 기억하는지는 정확히 모르고 있었습니다. 그래서 라이브러리 없이 빈 파일에서 출발해 React의 핵심 조각들을 하나씩 직접 만들어 봤습니다. 완성도보다는 "왜 이렇게 생겼는지"를 이해하는 것이 목적입니다.
2023년 8월 WING 세션에서 발표한 내용으로, 당시 velog에 올렸던 글을 다듬어 옮겼습니다.
프로젝트 준비
디렉토리를 만들고 TypeScript와 Parcel을 설치합니다.
mkdir deconstructing-react
npm install -d typescript
npm install parcel-bundler
yarn add typescript parcel -DTypeScript를 초기화합니다.
tsc --inittsconfig.json에서 두 가지를 바꿉니다. JSX를 React 방식으로 변환하도록 하고, 실험이 목적이니 엄격 모드는 끕니다.
"jsx": "react"
"strict": falseindex.html과 index.tsx를 만들고, HTML에서 스크립트를 불러옵니다.
<script src="index.tsx"></script>Parcel로 개발 서버를 띄웁니다.
parcel index.htmlJSX는 결국 함수 호출입니다
index.tsx에 JSX 한 줄을 넣어 봅니다.
const a = <div>hello</div>;
React가 없다는 오류가 납니다. JSX는 컴파일되면 React.createElement(...) 호출로 바뀌기 때문입니다. 그렇다면 빈 객체라도 React라는 이름으로 두면 어떻게 될까요.
const React = {};
const a = <div>hello</div>;
이번에는 createElement가 함수가 아니라고 합니다. 빈 함수를 하나 달아 줍니다.
let React = {
createElement: () => {},
};
const a = <div>hello</div>;
이것만으로 오류가 사라집니다. 이제 이 함수가 어떤 인자를 받는지 확인해 봅니다.
const React = {
createElement: (...args) => {
console.log(args);
},
};
const a = <div>hello</div>;
div는 태그 이름이고 hello는 요소 안의 내용입니다. 가운데의 null이 무엇인지 보려고 속성을 하나 붙여 봅니다.
const React = {
createElement: (...args) => {
console.log(args);
},
};
const a = <div className="good">hello</div>;
두 번째 인자는 속성(props)이었습니다. 즉 createElement는 태그 이름, 속성, 그리고 자식들을 차례로 받습니다.
엘리먼트 객체 만들기
이제 createElement가 실제로 무언가를 반환하게 합니다. React 엘리먼트는 결국 태그와 속성, 자식을 담은 평범한 객체입니다.
const React = {
createElement: (tag, props, ...children) => {
var element = { tag, props: { ...props, children } };
console.log(element);
return element;
},
};
const a = <div className="good">hello</div>;
중첩된 JSX도 넣어 봅니다.
const React = {
createElement: (tag, props, ...children) => {
var element = { tag, props: { ...props, children } };
console.log(element);
return element;
},
};
const a = (
<div className="good">
<h1>hello</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus
itaque similique, adipisci voluptatum atque ex deleniti harum quibusdam
inventore!
</p>
</div>
);
자식 요소까지 객체 트리로 잘 만들어집니다. 이것이 흔히 말하는 가상 DOM입니다.
다음은 이 변수 a를 함수 컴포넌트로 바꾸는 일입니다. createElement가 받은 tag가 함수라면 그 함수를 호출해 결과를 쓰면 됩니다. 변수 이름도 App으로 바꿉니다.
const React = {
createElement: (tag, props, ...children) => {
if (typeof tag === "function") {
return tag(props);
}
var element = { tag, props: { ...props, children } };
console.log(element);
return element;
},
};
const App = () => (
<div className="good">
<h1>hello</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus
itaque similique, adipisci voluptatum atque ex deleniti harum quibusdam
inventore!
</p>
</div>
);
<App />;여기까지가 아주 작은 React입니다. 다만 React 자체는 퍼즐의 한 조각일 뿐이고, 이 가상 DOM 트리를 실제 화면 어딘가에 놓아 주는 부분이 따로 필요합니다.
render: 객체를 실제 DOM으로
index.html에 app이라는 id를 가진 요소를 만듭니다.
<div id="app"></div>
<script src="index.tsx"></script>그리고 index.tsx에서 App을 그 안에 그리도록 render를 호출합니다.
render(<App />, document.getElementById("app"));첫 번째 시도입니다. 엘리먼트 객체를 받아 실제 DOM 요소를 만들고, 속성을 옮기고, 자식을 재귀적으로 그립니다.
const React = {
createElement: (tag, props, ...children) => {
if (typeof tag === "function") {
return tag(props);
}
var element = { tag, props: { ...props, children } };
console.log(element);
return element;
},
};
const App = () => (
<div className="good">
<h1>hello</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus
itaque similique, adipisci voluptatum atque ex deleniti harum quibusdam
inventore!
</p>
</div>
);
const render = (reactElement, container) => {
const actualDomElement = document.createElement(reactElement.tag);
if (reactElement.props) {
Object.keys(reactElement.props)
.filter((p) => p !== "children")
.forEach((p) => (actualDomElement[p] = reactElement[p]));
}
if (reactElement.props.children) {
reactElement.props.children.forEach((child) =>
render(child, actualDomElement),
);
}
container.appendChild(actualDomElement);
};
render(<App />, document.getElementById("app"));
오류가 납니다. <h1>hello</h1>의 hello처럼 자식 중에는 엘리먼트 객체가 아니라 그냥 문자열인 것이 있기 때문입니다. 숫자도 마찬가지입니다.
그래서 render가 받는 값이 엘리먼트일 수도, 문자열이나 숫자일 수도 있다고 보고 먼저 분기합니다. 이름도 그 사실이 드러나게 바꿨습니다. 이런 지점이 TypeScript가 있으면 미리 잡히는 종류의 실수입니다.
const React = {
createElement: (tag, props, ...children) => {
if (typeof tag === "function") {
return tag(props);
}
var element = { tag, props: { ...props, children } };
console.log(element);
return element;
},
};
const App = () => (
<div className="good">
<h1>hello</h1>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam natus
itaque similique, adipisci voluptatum atque ex deleniti harum quibusdam
inventore!
</p>
</div>
);
const render = (reactElementOrStringOrNumber, container) => {
if (["string", "number"].includes(typeof reactElementOrStringOrNumber)) {
container.appendChild(
document.createTextNode(String(reactElementOrStringOrNumber)),
);
return;
}
const actualDomElement = document.createElement(
reactElementOrStringOrNumber.tag,
);
if (reactElementOrStringOrNumber.props) {
Object.keys(reactElementOrStringOrNumber.props)
.filter((p) => p !== "children")
.forEach(
(p) => (actualDomElement[p] = reactElementOrStringOrNumber.props[p]),
);
}
if (reactElementOrStringOrNumber.props.children) {
reactElementOrStringOrNumber.props.children.forEach((child) =>
render(child, actualDomElement),
);
}
container.appendChild(actualDomElement);
};
render(<App />, document.getElementById("app"));
화면에 그려집니다. 하지만 이대로는 정적인 HTML과 다를 게 없습니다. 상태가 없기 때문입니다.
useState 첫 시도
먼저 App을 JSX를 바로 반환하는 형태에서 return 문을 쓰는 형태로 바꾸고, 입력창을 하나 둡니다.
const App = () => {
return (
<div className="good">
<h1>hello</h1>
<input type="text" placeholder="name" />
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};평소 React에서 쓰던 방식 그대로 useState를 써 봅니다. 한 가지 주의할 점은, React의 onChange는 React가 제공하는 합성 이벤트이고 실제 DOM 속성은 onchange라는 것입니다. 지금은 React가 없으니 DOM 속성을 그대로 씁니다.
const App = () => {
const [name, setName] = useState("ikjun");
return (
<div className="good">
<h1>Hello, {name}</h1>
<input
type="text"
placeholder="name"
value={name}
onchange={(e) => setName(e.target.value)}
/>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};
당연히 useState가 없다고 합니다. App 위에 가장 단순한 형태로 선언합니다. 초기값을 받아 두고, setState로 바꾸는 것이 전부입니다.
const useState = (initialState) => {
let state = initialState;
let setState = (newState) => (state = newState);
return [state, setState];
};
name을 쓸 수는 있게 됐지만, 입력창의 값을 바꿔도 화면은 그대로입니다. 값이 바뀌었으면 다시 그려야 하는데 그 부분이 없기 때문입니다.
render 아래에 rerender를 만들고, setState가 호출될 때마다 실행되게 합니다.
const rerender = () => {
render(<App />, document.getElementById("app"));
};const useState = (initialState) => {
let state = initialState;
let setState = (newState) => {
state = newState;
rerender();
};
return [state, setState];
};
다시 그리긴 하는데 이전 화면 위에 덧붙입니다. rerender에서 기존 요소를 먼저 지우도록 합니다.
const rerender = () => {
document.getElementById("app").firstChild.remove();
render(<App />, document.getElementById("app"));
};그래도 ikjun이라는 초기값은 바뀌지 않습니다. 무슨 일이 일어나는지 콘솔에 찍어 봅니다.
const useState = (initialState) => {
console.log("initial : ", initialState);
let state = initialState;
let setState = (newState) => {
console.log("setState : ", newState);
state = newState;
rerender();
};
return [state, setState];
};
setState는 잘 불리지만, 다시 그릴 때 useState가 다시 실행되면서 매번 초기값으로 돌아갑니다. 여기서 훅이 어떻게 값을 기억하는지 생각해 봐야 합니다.
상태는 컴포넌트 밖에 있어야 합니다
핵심은 클로저(closure)입니다. 함수가 실행될 때마다 지역 변수는 새로 만들어지므로, 함수 안에 둔 상태는 이전 값을 기억할 수 없습니다.

위 예제가 전형적인 경우입니다. increase가 호출될 때마다 counter가 0에서 시작하니 상태 변화를 만들 수 없습니다. 가장 먼저 떠오르는 해결책은 전역 변수지만, 어디서든 읽고 바꿀 수 있어 부작용의 원인이 되기 쉽습니다. 그래서 바깥 스코프에 저장소를 두되 접근은 함수를 통해서만 하는 방식, 즉 클로저로 상태를 관리합니다.
이름 하나만 바꾸는 것보다 숫자를 세는 쪽이 동작을 확인하기 쉬워서, 카운터와 증감 버튼을 컴포넌트에 추가했습니다.
const App = () => {
const [name, setName] = useState("ikjun");
const [count, setCount] = useState(0);
return (
<div className="good">
<h1>Hello, {name}</h1>
<input
type="text"
placeholder="name"
value={name}
onchange={(e) => setName(e.target.value)}
/>
<h2>The count is : {count}</h2>
<button onclick={() => setCount(count + 1)}>+</button>
<button onclick={() => setCount(count - 1)}>-</button>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};이 상태에서는 리렌더링은 되지만 값은 계속 초기화됩니다. 이제 상태를 컴포넌트 밖으로 꺼냅니다. 값을 보관할 states 배열과, 지금 몇 번째 상태를 다루는지 가리키는 stateCursor를 useState 위에 둡니다.
const states = [];
let stateCursor = 0;useState는 호출되는 순간의 커서를 FROZENCURSOR에 고정해 두고, 그 자리의 값을 읽고 씁니다. 그 자리에 아직 값이 없으면 초기값을 넣고, 끝나면 커서를 하나 올려 다음 useState 호출이 다음 칸을 쓰게 합니다.
const states = [];
let stateCursor = 0;
const useState = (initialState) => {
const FROZENCURSOR = stateCursor;
states[FROZENCURSOR] = states[FROZENCURSOR] || initialState;
const setState = (newState) => {
states[FROZENCURSOR] = newState;
rerender();
};
stateCursor++;
return [states[FROZENCURSOR], setState];
};다시 그릴 때는 커서를 0으로 되돌려, 같은 순서로 호출되는 useState들이 같은 칸을 다시 찾게 합니다.
const rerender = () => {
stateCursor = 0;
document.getElementById("app").firstChild.remove();
render(<App />, document.getElementById("app"));
};
드디어 상태가 유지됩니다. 훅을 조건문 안에서 부르면 안 되는 이유도 여기서 보입니다. 호출 순서가 곧 저장 위치이기 때문입니다.
데이터 불러오기와 Concurrent Mode 흉내
마지막으로 만들어 볼 것은 React가 밀고 있던 Concurrent Mode의 아주 작은 조각입니다. 거칠게 말하면 비동기 작업을 다루되, 그 우선순위를 React가 정해 DOM 트리의 일부만 먼저 그릴 수 있게 하는 것입니다. 여기서는 API로 사진 주소를 받아 와 화면에 띄우는 것까지만 해 봅니다.
App에서 강아지 사진 API를 호출해 주소를 받아 오고, 그 값을 화면에 넣어 봅니다.
const App = () => {
const [name, setName] = useState("ikjun");
const [count, setCount] = useState(0);
const dogPhotoUrl = fetch("https://dog.ceo/api/breeds/image/random")
.then((r) => r.json())
.then((playload) => playload.message);
return (
<div className="good">
<h1>Hello, {name}</h1>
<input
type="text"
placeholder="name"
value={name}
onchange={(e) => setName(e.target.value)}
/>
<h2>The count is : {count}</h2>
{dogPhotoUrl}
<button onclick={() => setCount(count + 1)}>+</button>
<button onclick={() => setCount(count - 1)}>-</button>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};
오류가 납니다. fetch의 결과는 Promise인데 그에 대한 처리가 전혀 없기 때문입니다. 이 Promise가 언제 끝나는지 알고 관리해 줄 무언가가 필요합니다. React에서는 이를 createResource라는 이름으로 다룹니다.
createResource의 동작을 정리하면 이렇습니다.
- 내부에서
wrapPromise를 호출해 Promise의 상태에 접근할 수 없던 문제를 해결합니다. - 클로저로 상태와 결과를 관리하고, 대기 중이면 Promise를
throw해서Suspense쪽으로 전달합니다. - pending 상태에서는 결과에 접근할 수 있도록
throw하고, error 상태에서도 처리를 위해throw합니다.
먼저 호출부를 이렇게 바꿉니다. 데이터를 불러오는 함수를 앞에, 고유한 키를 뒤에 넘깁니다.
const App = () => {
const [name, setName] = useState("ikjun");
const [count, setCount] = useState(0);
const dogPhotoUrl = createResource(() => {
(fetch("https://dog.ceo/api/breeds/image/random")
.then((r) => r.json())
.then((playload) => playload.message),
"dogPhoto");
});
return (
<div className="good">
<h1>Hello, {name}</h1>
<input
type="text"
placeholder="name"
value={name}
onchange={(e) => setName(e.target.value)}
/>
<h2>The count is : {count}</h2>
{dogPhotoUrl}
<button onclick={() => setCount(count + 1)}>+</button>
<button onclick={() => setCount(count - 1)}>-</button>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};이제 createResource를 App 바로 위에 만듭니다. 키로 캐시를 조회해서 값이 있으면 돌려주고, 없으면 Promise와 키를 함께 throw합니다.
const promiseCache = new Map();
const createResource = (asyncFunc, key) => {
if (promiseCache.has(key)) {
return promiseCache.get(key);
}
throw { promise: asyncFunc(), key };
};App 컴포넌트는 맨 위의 createElement가 함수를 호출해서 만들어집니다. 그 안에서 Promise가 throw될 수 있으니, 함수 컴포넌트 호출을 try/catch로 감쌉니다. 잡히면 Promise가 끝난 뒤 캐시에 넣고 다시 그리도록 예약하고, 당장은 "Loading..."을 반환합니다.
const React = {
createElement: (tag, props, ...children) => {
if (typeof tag === "function") {
try {
return tag(props);
} catch ({ promise, key }) {
promise.then((data) => {
promiseCache.set(key, data);
rerender();
});
return { tag: "h1", props: { children: ["Loading..."] } };
}
}
var element = { tag, props: { ...props, children } };
return element;
},
};대기 중이면 Loading 문구가 보이고, 데이터가 도착하면 정상적으로 그려집니다. 마지막으로 받아 온 주소로 사진을 표시하도록 컴포넌트를 정리합니다.
const App = () => {
const [name, setName] = useState("ikjun");
const [count, setCount] = useState(0);
const dogPhotoUrl = createResource(
() =>
fetch("https://dog.ceo/api/breeds/image/random").then((r) => r.json()),
"dog",
);
return (
<div className="good">
<h1>Hello, {name}</h1>
<input
type="text"
placeholder="name"
value={name}
onchange={(e) => setName(e.target.value)}
/>
<h2>The count is : {count}</h2>
<img src={dogPhotoUrl.message} />
<button onclick={() => setCount(count + 1)}>+</button>
<button onclick={() => setCount(count - 1)}>-</button>
<p>
Lorem ipsum, dolor sit amet consectetur adipisicing elit. Ipsa harum
perferendis veniam ut accusantium iusto deleniti nemo! Nisi quisquam
natus itaque similique, adipisci voluptatum atque ex deleniti harum
quibusdam inventore!
</p>
</div>
);
};
마치며
빈 파일에서 출발해 React의 네 조각을 만들었습니다.
createElement: JSX는 함수 호출이고, 그 결과는 태그·속성·자식을 담은 객체입니다.render: 그 객체 트리를 재귀적으로 실제 DOM으로 바꿉니다. 문자열 자식을 따로 처리해야 합니다.useState: 상태는 컴포넌트 밖의 배열에 살고, 호출 순서가 곧 저장 위치입니다. 훅의 규칙이 왜 있는지가 여기서 드러납니다.createResource: 아직 없는 데이터는 Promise를 던져서 알리고, 도착하면 다시 그립니다.
실제 React는 매번 전체를 지우고 다시 그리는 대신 차이만 반영하고, 상태도 컴포넌트마다 따로 관리합니다. 하지만 "왜 이런 모양인지"는 이 작은 버전만으로도 꽤 분명해졌습니다.
전체 코드는 no-ikjun/deconstructing-react 저장소에 있습니다.