이 글은 당신은 프론트엔드 개발자가 맞나요?에서 이어져 특정 프레임워크에 종속되지 말자는 생각으로 작성하게 되었습니다. 🧐
많은 프레임워크 중 아직 능숙하게 다룰 수 있는 React와 Vue정도만 다뤄봤습니다..🥲
웹 애플리케이션은 시간이 지남에 따라 더욱 복잡해지고, 정적인 웹 페이지에서 새로고침 없이 변화하는 동적 인터랙션이 주목받으면서, 상태 관리
메커니즘은 사용자 경험을 향상하는 중요한 요소로 자리 잡고 있다. 효율적인 상태 관리 메커니즘을 구현하면 애플리케이션의 성능과 유지보수성이 향상되는 큰 장점을 지닐 수 있다.
사용자 기기의 성능이 비약적으로 상승하면서, 기존의 서버 사이드에서 사용자 기기에 렌더링 책임을 부여하는 클라이언트 사이드 렌더링(CSR)
방식이 유행하기 시작했다.
흔히 사용하는 CSR 기반의 JavaScript 프레임워크(라이브러리)들은 모두 바닐라 JavaScript로 구현되어 있지만, 프레임워크마다 구현 방식에 차이가 있다. 대표적으로 사용되는 React
와 Vue
의 상태 관리 방식에 대해 자세히 알아보자. (React에 대한 reference는 많아. Vue를 좀 더 다뤄봤다.)
React에서 자주 사용되는 상태관리 훅인 useState는 클로저
기반으로 이루어져 있다.
클로저
는 렉시컬 스코프(lexical scope)
의 원리를 이용하여, 함수가 생성될 당시의 외부 변수에 접근할 수 있는 함수를 의미한다. 클로저는 함수가 선언된 시점의 렉시컬 환경을 기억하여, 외부 함수가 종료된 후에도 그 환경에 접근할 수 있게 해준다.
const closure = () => {
let count = 0
const countUp = () => {
count++
console.log(count)
}
return countUp
}
const counter = closure()
counter() // 1
counter() // 2
counter() // 3
이렇게 되면 정보를 은닉할 수 있으며, 현재 스코프의 상태를 보존할 수 있다.
function reactHook() {
// 상태를 저장하고 상태 인덱스를 관리하는 객체
const stateContext = {
currentIndex: 0, // 현재 상태의 인덱스
states: [] // 상태를 저장하는 배열
};
/**
* useState 훅의 구현
* @param {Function|any} initialState - 상태의 초기값 또는 초기화 함수
* @returns {[any, Function]} - 현재 상태와 상태를 업데이트하는 함수
*/
const useState = (initialState) => {
// 게으른 초기화: initialState가 함수인 경우 실행하여 상태를 결정
const currentState = typeof initialState === "function" ? initialState() : initialState;
// 현재 상태 인덱스
const currentIndex = stateContext.currentIndex;
// 상태 배열에서 현재 인덱스의 상태를 가져오거나 초기 상태를 저장
stateContext.states[currentIndex] = stateContext.states[currentIndex] || currentState;
/**
* 상태를 업데이트하는 함수
* @param {any} newState - 새로운 상태 값
*/
const setState = (newState) => {
if (!Object.is(stateContext.states[currentIndex], newState)) {
// 상태가 변경된 경우 새로운 상태로 업데이트
stateContext.states[currentIndex] = newState;
// 리렌더링 로직: 상태가 변경되었을 때 호출
// 이 부분에 리렌더링 로직을 추가해야 함
}
};
// 상태 인덱스 증가
stateContext.currentIndex++;
// 현재 상태와 상태 업데이트 함수를 반환
return [stateContext.states[currentIndex], setState];
};
// useState 훅을 외부에 노출
return { useState };
}
클로져 기반으로 간단히 useState를 구현했다. 코드를 하나씩 순차적으로 살펴보자.
const stateContext = {
currentIndex: 0, // 현재 상태의 인덱스
states: [] // 상태를 저장하는 배열
};
stateContext는 현재 상태의 인덱스와 상태를 전역으로 관리한다고 볼 수 있다. react는 순차적으로 실행되기 때문에 useState를 선언한 순서대로 index를 부여하여 고유 state를 구분할 수 있게 한다.
const useState = (initialState) => {
const currentState = typeof initialState === "function" ? initialState() : initialState;
const currentIndex = stateContext.currentIndex;
stateContext.states[currentIndex] = stateContext.states[currentIndex] || currentState;
currentState에서 initialState의 타입에 따른 분기처리를 해준다. (리액트의 useState와 lazy initialization)
그리고 현재 인덱스를 내부에 정의하고, global상태인 stateContext.states
배열에 설정한다.
const setState = (newState) => {
if (!Object.is(stateContext.states[currentIndex], newState)) {
stateContext.states[currentIndex] = newState;
// 리렌더링 로직: 상태가 변경되었을 때 호출
// 이 부분에 리렌더링 로직을 추가해야 함
}
};
상태를 변경해 주는 setState
다. 현재의 상태와 새로운 상태를 비교하여 값이 다르다면 새로운 값으로 갱신해주며, 리렌더링을 해준다. 여기서 값을 비교 할때는 얕은 비교
를 통해 수행한다.
stateContext.currentIndex++;
return [stateContext.states[currentIndex], setState];
마지막으로 index를 하나 증가 시켜 다음 상태관리에 대한 스코프 관리를 하고, return해줍니다. [state, setState] = useState() 형태
이와 달리 Vue는 클로저가 아닌 좀 더 직관적인 느낌의 상태 관리 메커니즘을 채택했다. javascript 객체 정적 메서드인 Object.defineProperty
를 기반으로 반응형 데이터(vue에서는 상태관리보다 반응형이라는 용어를 사용하는 것 같다, Vue에서는 상태라는 용어 대신 반응형으로 대체하겠다!)를 추적하고 이를 템플릿에 반영한다.
Object.defineProperty는 객체 속성의 descriptor를 정의 한다.(Object.defineProperty에 대해서)
즉, Vue에서는 Object.defineProperty의 getter와 setter를 사용하여 반응형 데이터를 관리한다.
모든 컴포넌트 인스턴스에는 반응형 데이터를 감시하는 watcher가 있고, 반응형 데이터에 따라 watcher는 변화를 감지하여 리렌더링이 트리거 된다.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reactive Data Example</title>
</head>
<body>
<div>
<label for="nameInput">Name:</label>
<input type="text" id="nameInput" placeholder="Enter your name" />
</div>
<div>
<p class="output">Hello, <span id="nameDisplay"></span></p>
</div>
<script>
// 반응형 객체를 생성하는 함수
const reactive = (obj) => {
Object.keys(obj).forEach((key) => {
let internalValue = obj[key];
Object.defineProperty(obj, key, {
get() {
return internalValue;
},
set(newValue) {
internalValue = newValue;
// DOM 업데이트
if (key === "name") {
document.getElementById("nameDisplay").textContent = newValue;
}
},
});
});
return obj;
};
// 반응형 데이터 객체 생성(like vue3)
const data = reactive({
name: "",
});
// 입력 필드와 데이터 바인딩
const nameInput = document.getElementById("nameInput");
nameInput.addEventListener("input", (event) => {
data.name = event.target.value; // 데이터 변경 시 DOM 자동 업데이트
});
</script>
</body>
</html>
다음과 같이 reactive라는 반응형 데이터를 생성해 주는 함수를 만들었다. reactive의 특정 속성을 설정할 때(set) 의존성을 갖는 템플릿이 리렌더링 되어 반영되는 것이다.
여기서는 name이라는 속성이 변경된다면 관련 템플릿이 변경된다. (여기서는 nameDisplay라는 id를 가진 span태그)
바닐라 Js로 임의로 구현했지만, 실제로 vue에서는 {{}}형태의 mustache
와 v-bind
문법을 이용해 반응형 데이터에 의존성을 갖는 템플릿을 인식시켜준다.
그런데 vue2(Option API)에서 vue3(Composition API)로 업데이트가 되면서 반응형 데이터를 업데이트하는 방식이 변경되었다.
javascript es6에 Proxy
객체가 추가 되었는데, 기존의 객체의 getter/setter처럼 속성 변경 감지를 가로채서 동작할 수 있게 되었다. 말그대로 우리가 흔히 아는 그 proxy의 의미이다.
const target = {
message1 : "hello",
message2 : "everyone"
}
const handler = {
get(target, prop, receiver) {
return "world"
}
}
const proxy = new Proxy(target, handler)
proxy.message1 // "world"
proxy.message2 // "world"
그렇다면 vue3에서는 왜 Object.defineProperty
대신 Proxy
를 기반으로 반응형 데이터를 구현했을까?
// Object.defineProperty를 이용한 방식
let data = { count: 0, name : "" };
Object.defineProperty(data, 'count', {
get() {
console.log('count가 읽혔습니다.');
return this._count;
},
set(newValue) {
console.log('count가 변경되었습니다.');
this._count = newValue;
}
});
Object.defineProperty(data, 'name', {
get() {
console.log('name이 읽혔습니다.');
return this._name;
},
set(newValue) {
console.log('count가 변경되었습니다.');
this._name = newValue;
}
});
기존의 방식에서는 정의한 객체의 속성마다 getter와 setter를 설정해 주어야 한다. 물론 반복문이나 Object.defineProperties
로 모든 속성을 설정해 줄 수 있기는 하지만 번거롭다. 또한 속성마다 설정해준다면 메모리 사용 측면에서도 효율적이지 못한 것 같다.
// Proxy를 사용한 방식
let data = { count: 0, name : "choi" };
let proxyData = new Proxy(data, {
get(target, key) {
console.log(`${key}가 읽혔습니다.`);
return target[key];
},
set(target, key, value) {
console.log(`${key}가 변경되었습니다.`);
target[key] = value;
return true;
}
});
proxyData.count // count가 읽혔습니다. 0
proxyData.count = 100 // count가 변경되었습니다. 100
proxyData.name // name가 읽혔습니다. 'choi'
proxyData.name = "mincho" // name가 변경되었습니다. mincho
반면 Proxy
를 사용한 방법은 전체 객체를 래핑하여 정의하는 방식이라 더 효율적이다.(좀 더 직관적인 느낌이 더 크다.)
// Object.defineProperty를 이용한 방식
let data = { existingProp: 'I exist' };
Object.defineProperty(data, 'existingProp', {
get() {
console.log('getter!');
return this._existingProp;
},
set(newValue) {
console.log('setter!');
this._existingProp = newValue;
}
});
// 새 속성 추가 - 반응형으로 동작하지 않음
data.newProp = 'I am new';
Object.defineProperty
는 기존에 정의된 속성을 기반으로 동작하기 때문에 새로운 속성이 추가된다면 다시 getter/setter를 정의해 주지 않는다면 원하는 대로 동작이 되지 않는다.
// Proxy를 사용한 방식
let data = { existingProp: 'I exist' };
let proxyData = new Proxy(data, {
get(target, key) {
console.log(`${key}가 읽혔습니다.`);
return target[key];
},
set(target, key, value) {
console.log(`${key}가 설정되었습니다.`);
target[key] = value;
return true;
}
});
// 새 속성 추가 - 반응형으로 동작함
proxyData.newProp = 'I am new'; // "newProp가 설정되었습니다."
Proxy
는 새로운 속성을 추가한다면 proxy객체로 정의한대로 getter/setter를 다시 정의하지 않아도 된다. 즉 동적으로 속성을 추가해도 반응형 값으로 유지할 수 있다.
그렇다면 위에서 Object.defineProperty
로 구현했던 것을 Proxy
를 이용해 재구성해보자.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Reactive Data Example</title>
</head>
<body>
<div>
<label for="nameInput">Name:</label>
<input type="text" id="nameInput" placeholder="Enter your name" />
<input
type="text"
id="descriptionInput"
placeholder="Enter your description"
/>
</div>
<div>
<p class="output">Hello, <span id="nameDisplay"></span></p>
<p>
description :
<span id="descriptionDisplay"></span>
</p>
</div>
<script>
// Proxy 기반의 반응형 객체 생성 함수
const reactive = (obj) => {
return new Proxy(obj, {
set(target, key, value) {
target[key] = value;
// DOM 업데이트
if (key === "name") {
document.getElementById("nameDisplay").textContent = value;
return;
}
if (key === "description") {
document.getElementById("descriptionDisplay").textContent = value;
return;
}
},
});
};
const data = reactive({
name: "",
description: "",
});
// 입력 필드와 데이터 바인딩
const nameInput = document.getElementById("nameInput");
nameInput.addEventListener("input", (event) => {
data.name = event.target.value;
});
const descriptionInput = document.getElementById("descriptionInput");
descriptionInput.addEventListener("input", (event) => {
data.description = event.target.value;
});
</script>
</body>
</html>
객체를 Proxy로 래핑하여 reactive반응형 객체를 생성했다. 기존 예제에서 description속성을 추가하였다. 객체의 속성마다 getter/setter를 생성해주지 않아 좀 더 직관적인 코드가 되었다.
지금까지 React와 Vue의 간단한 상태관리 메커니즘에 대해 알아봤다. 그렇다면 예제 코드와 함께 상태 혹은 반응형 데이터에 따라 어떻게 렌더링되는지 알아보자.
단순히 CounterButton
을 클릭하면 count
를 하나씩 늘려나가는 구조이다.
그렇다면 한번 실행을 시켜보자.
누구나 간단하게 예상가능하듯 버튼을 클릭하여 setCount
가 트리거 되며 App컴포넌트가 다시 호출되며 재평가 되는 것을 알 수 있다.
Vue에서도 다음과 같이 React와 같은 기능을 구현했다.그렇다면 Vue에서는 어떻게 동작할까??
콘솔에는 예상과는 다르게 초기 렌더링으로 인한 Current Count : 0
만 표시되고 상태를 변경한다 한들 컴포넌트가 재평가가 되지 않는 것처럼 보인다.
React와 Vue는 모두 가상Dom(사실을 객체)
을 이용해, Dom변경을 효율적으로 처리한다. 흔히 Diffing알고리즘을 통해, 기존의 가상 Dom과 새롭게 들어온 가상 Dom을 비교해 변경된 부분만 업데이트를 하여 렌더링한다.
그런데 위의 테스트 결과에서는 왜 이런 차이를 보이는 것일까??
어떻게 보면 간단한 문제이다. 두 기술스택의 철학의 차이(?)라고 볼 수 있다.
React는 16.8버전부터 훅이 도입되면서 함수형 컴포넌트가 떠오르기 시작했다.(사실 React초창기에도 함수형 컴포넌트를 사용할수는 있었다고 한다.) 함수는 말그대로 순수해야하며 부수효과가 발생해서는 안된다. 이 논리를 컴포넌트에도 적용한 것인데, state와 props
에만 의존하고 다른 외부의 것들에는 변함이 없어야 하는 것이다.
물론 함수형 컴포넌트에서
useEffect
와 같이 부수효과를 발생시키는 경우도 있지만, 기본 전제가 순수해야한다는 철학이다.
그렇기 때문에 state나 props
의 변경이 일어난다면 컴포넌트는 재실행된다. 결국 리렌더링된 컴포넌트를 바탕으로 가상 Dom이 생성되며 기존 Dom이랑 비교하게 된다.
여기서 오해하면 안되는 것은 렌더링
이라는 말이다. React에서는 렌더링의 과정을 렌더
와 커밋
단계로 설명하고 있다.
렌더 단계 : 컴포넌트가 어떻게 보여야 하는지를 결정하는 순수한 계산 과정. 실제 Dom에는 반영이 되지 않는다.
커밋 단계 : 렌더링된 결과를 실제로 DOM에 반영하는 단계. Dom을 업데이트한 이후에 사이드 이펙트를 수행한다.👉 즉 렌더단계에서 기존의 가상 Dom과 변경된 가상 Dom을 비교하고 커밋단계에서 반영한다.
import { useState } from "react";
import CounterButton from "./component/CounterButton";
import CounterDisplay from "./component/CounterDisplay";
function App() {
// 렌더 단계
const [count, setCount] = useState(0);
const handleIncrement = () => {
setCount((prevCount) => prevCount + 1);
};
console.log(
`%cCurrent Count : ${count}`,
"background-color: yellow; color: black"
);
return (
<div className="app">
<h1>Counter</h1>
<CounterDisplay count={count} />
<CounterButton onIncrement={handleIncrement} />
</div>
);
//~~여기 까지 (단 jsx는 가상 Dom으로 비교하여 실제 변경된 부분만 업데이트 한다.)
}
export default App;
실제 dom에 반영이 되지 않아도 state와 props의 변화
에 따라 반복적으로 렌더
단계에 진입하여 실행될 수 밖에 없고 console.log도 반복해서 보여지는 이유다.
규모가 큰 애플리케이션에서는 불필요한 렌더링은 큰 비용이 든다. 그래서 React에서는 이를 인지하고 메모이제이션을 위해 useMemo, useCallback, React.memo
를 제공한다. 리렌더링이 되더라도 캐싱을 통해 같은 주소를 참조하게끔 하여 최적화를 진행하는 것이다.
컴포넌트를 함수처럼 순수하게 동작하는 건 좋은데, 굳이 불필요한 렌더단계의 반복적인 연산이 꼭 필요할까?? 사실 실제 변경되는 반응형 데이터만 추적하여 비교하면 되지 않을까??
실제로 리액트처럼 리렌더링시 발생하는 불필요한 연산을 모두 메모이제이션하는 과정은 개발자에게 꽤나 번거로운 일이다.
실제 Vue에서는 반응형 데이터를 기반으로 가상 Dom비교를 진행한다. 앞에서 살펴봤듯이 Vue의 반응형 시스템은 데이터가 사용되는 컴포넌트나 템플릿 부분을 추적하, 실제로 업데이트 된 곳만 반영한다.
Vue는 데이터를 사용하는 템플릿 부분을 기억하기 때문에, 불필요한 컴포넌트 전체의 재실행을 방지하고 최소한의 부분으로 업데이트 한다. 즉, 컴포넌트 전체를 업데이트 하는 것이 아니라, 반응형 데이터와 직접적으로 연관된 부분만 업데이트
가 되는 것이다.
Vue에서는 어떠한 원리로 처리하는 것인지, Vue공식문서에서 렌더링 메커니즘과 관련된 내용을 좀 더 살펴보자.
<div>
<div>foo</div> <!-- 호이스트(hoist) 됨 -->
<div>bar</div> <!-- 호이스트 됨 -->
<div>{{ dynamic }}</div>
</div>
Vue는 컴파일러를 통해 기존 정적 노드를 호이스팅하여 최적화할 수 있다. 반응형 데이터를 사용하지 않은 foo
와 bar
의 경우 호이스팅되어 렌더링 되어도 같은 값을 바라보며 비교할 필요도 없게 된다.
위의 템플릿을 템플릿 분석기를 통해 한번 살펴보자. Vue 템플릿 분석기
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_cache[0] || (_cache[0] = _createElementVNode("div", null, "foo", -1 /* HOISTED */)),
_cache[1] || (_cache[1] = _createElementVNode("div", null, "bar", -1 /* HOISTED */)),
_createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
]))
}
// Check the console for the AST
foo
와 bar
모두 Vue context의 cache에 쌓이는 것을 확인할 수 있다. 이 cache의 내용은 정적으로 재사용된다.
반면 {{ dynamic }}
의 내용은 _toDisplayString(_ctx.dynamic)
으로 변환된 것을 알 수 있는데, 즉 동적인 값이므로 렌더링 되었을 때 추적해야하는 값으로 인식된다고 오해할 수 있는데, 반은 맞고 반은 틀리다.
사실 우리가 주목해야하는 건 _toDisplayString(_ctx.dynamic)
뒤에 오는 숫자 1
이다.
Vue.js 깃헙 레포지토리에서 패치플래그 부분를 보면 다음과 같은 부분을 확인할 수 있다.
/**
* dev only flag -> name mapping
*/
export const PatchFlagNames: Record<PatchFlags, string> = {
[PatchFlags.TEXT]: `TEXT`,
[PatchFlags.CLASS]: `CLASS`,
[PatchFlags.STYLE]: `STYLE`,
[PatchFlags.PROPS]: `PROPS`,
[PatchFlags.FULL_PROPS]: `FULL_PROPS`,
[PatchFlags.NEED_HYDRATION]: `NEED_HYDRATION`,
[PatchFlags.STABLE_FRAGMENT]: `STABLE_FRAGMENT`,
[PatchFlags.KEYED_FRAGMENT]: `KEYED_FRAGMENT`,
[PatchFlags.UNKEYED_FRAGMENT]: `UNKEYED_FRAGMENT`,
[PatchFlags.NEED_PATCH]: `NEED_PATCH`,
[PatchFlags.DYNAMIC_SLOTS]: `DYNAMIC_SLOTS`,
[PatchFlags.DEV_ROOT_FRAGMENT]: `DEV_ROOT_FRAGMENT`,
[PatchFlags.CACHED]: `HOISTED`,
[PatchFlags.BAIL]: `BAIL`,
}
PatchFlagNames
를 정의해주고 이에 따라 어떤 타입으로 매핑되는지 확인 할 수 있는데, 아래와 같이 enum타입으로 정의된PatchFlags
와 매핑 관계가 있다.
export enum PatchFlags {
CACHED = -1, // HOISTED
TEXT = 1, // TEXT
STYLE = 1 << 2, // STYLED
PROPS = 1 << 3, // PROPS
NEED_HYDRATION = 1 << 5, // NEED_HYDRATION
DYNAMIC_SLOTS = 1 << 10, // DYNAMIC_SLOTS
//...등등
}
그래서 패치 플래그를 통해 각 Node의 타입 및 유형을 미리 지정해주고, Vue는 컴파일 시 많은 정보를 추론할 수 있게 된다.
또한 정적 노드가 연속적으로 들어온다면, 아예 템플릿으로 캐싱해 버린다.
<div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div class="foo">foo</div>
<div>{{ dynamic }}</div>
</div>
import { createElementVNode as _createElementVNode, toDisplayString as _toDisplayString, createStaticVNode as _createStaticVNode, openBlock as _openBlock, createElementBlock as _createElementBlock } from "vue"
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(), _createElementBlock("div", null, [
_cache[0] || (_cache[0] = _createStaticVNode("<div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div><div class=\"foo\">foo</div>", 5)),
_createElementVNode("div", null, _toDisplayString(_ctx.dynamic), 1 /* TEXT */)
]))
}
// Check the console for the AST
이 모든 정밀한 제어는 Vue가 다른 Js 기술 스택과는 달리
컴파일러
를 제어하여 가능한 것이다.
React에서도 시범적으로 Compiler를 신경쓰고 있는 느낌이다. (리액트 컴파일러) React 컴파일러도 자동으로 코드를 메모이제이션하여, 보다 개발자의 수고를 덜어주는 방향을 잡아가려는 것 처럼 보였다.
컴파일러의 도입은 우리가 기존에 알고 있던 리액트의 리렌더링 조건이 달라질지도 모르지만 리액트 생태계가 크게 개선될 것으로 기대된다.😄
예제 레포 링크는 여기서 확인가능합니다.
Vue 공식문서
반응형에 대해 깊이 알아보기
참조 동일성을 위한 메모이제이션
Vanilla Javascript로 상태관리 시스템 만들기