다크 모드
Chapter 02. Vue 3 반응형 엔진의 밑바닥 까보기 (Reactivity Deep Dive)
"1장에서 완성한
BoardApp.vue는 어떻게 변수 대입만으로 화면을 스스로 다시 그렸을까? 프레임워크의 마법을 걷어내면 그 자리에는 순수한 ES6 Proxy 아키텍처가 남는다."
2.1 자바스크립트는 원래 반응형(Reactive)이 아니다
1장에서 직접 코딩한 BoardApp.vue의 삭제 로직을 다시 살펴봅시다:
typescript
// BoardApp.vue의 삭제 로직
posts.value = posts.value.filter(p => p.id !== id);우리는 분명 단순한 자바스크립트 배열 변수의 참조를 바꿨을 뿐입니다.
그런데 웹 브라우저는 어떻게 해당 <tr> 행이 화면에서 사라져야 한다는 것을 알고 스스로 다시 그렸을까요?
자바스크립트 언어 자체는 본래 반응형 언어가 아닙니다:
typescript
let price = 1000;
let quantity = 2;
let total = price * quantity; // 2000
price = 2000;
console.log(total); // 🚨 여전히 2000! (자동으로 4000이 되지 않는다)자바스크립트는 명령형(Imperative) 언어입니다. price = 2000으로 바꿨다고 해서 연관된 계산이나 화면 갱신이 자동으로 일어나는 마법은 브라우저 자바스크립트 엔진(V8)에 존재하지 않습니다.
2.2 Vue 3 반응형의 핵심: ES6 Proxy
Vue 3는 이 문제를 해결하기 위해 모던 자바스크립트 표준 사양인 Proxy 객체를 전면 도입했습니다.
Proxy는 "원본 객체를 감싸는 투명한 대리인(Wrapper)" 역할을 수행합니다.
객체의 속성을 읽거나(get), 변경할 때(set) 그 동작을 가로채서(Interception) 추가 작업을 수행할 수 있습니다.
2.3 [핸즈온 실습] 50줄로 만드는 초경량 Vue 3 반응형 엔진
이 메커니즘을 온전히 체화하는 가장 좋은 방법은 Vue 라이브러리 없이 순수 TypeScript로 반응형 엔진을 직접 밑바닥부터 만들어 보는 것입니다.
📁 실습 준비: 파일 생성 위치 및 실행 방법
- 작성할 파일 경로:
frontend/src/practice/mini-reactivity.ts - 실행 명령어: 터미널을 열고
frontend/디렉터리에서 아래 명령어를 실행하면 브라우저 없이 Node.js 콘솔에서 TypeScript 코드가 즉시 실행됩니다:
bash
cd frontend
npx tsx src/practice/mini-reactivity.ts
# (또는 package.json에 등록된 단축 명령어):
npm run practice:reactivity[Step 1] 의존성 저장소(targetMap)와 포인터(activeEffect) 선언
에디터로 frontend/src/practice/mini-reactivity.ts를 열고, 기존 내용을 비운 뒤 1단계부터 타이핑해 봅시다:
typescript
// frontend/src/practice/mini-reactivity.ts
// 1. 현재 실행 중인 부수 효과(렌더링 함수 등)를 기억할 전역 포인터
let activeEffect: (() => void) | null = null;
// 2. 의존성 저장소:
// Map 구조: Target(객체) -> Key(속성명) -> Set(이 속성을 바라보는 Effect 함수들)
const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>();왜 WeakMap을 쓸까요?
대상 객체(target)가 메모리에서 해제(Garbage Collection)될 때, 해당 객체와 관련된 반응형 의존성 정보도 자동으로 함께 메모리에서 증발하도록 만들기 위해 키를 약하게 참조하는WeakMap을 사용합니다. 메모리 누수를 원천 방지하는 훌륭한 설계입니다.
[Step 2] 의존성 수집(track) 함수 구현
객체의 프로퍼티를 읽는 순간(Getter), 현재 실행 중인 함수(activeEffect)를 해당 프로퍼티의 구독자 목록(Set)에 등록합니다:
typescript
// 3. 의존성 수집: "누가 날 읽었지? 내 구독자 명단에 적어두자!"
function track(target: object, key: string | symbol) {
if (!activeEffect) return; // 읽고 있는 함수가 없으면 무시
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let dep = depsMap.get(key);
if (!dep) {
dep = new Set();
depsMap.set(key, dep);
}
// 현재 실행 중인 함수를 구독자로 추가!
dep.add(activeEffect);
}[Step 3] 의존성 통보(trigger) 함수 구현
객체의 프로퍼티가 수정되는 순간(Setter), 아까 수집해 둔 구독자 목록(dep)을 꺼내어 모든 함수를 일괄 재실행합니다:
typescript
// 4. 의존성 통보: "내 값이 바뀌었으니 날 구독하던 함수들 모두 일어나!"
function trigger(target: object, key: string | symbol) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
// 등록되어 있던 모든 Effect(렌더링 함수 등)를 다시 호출!
dep.forEach(effect => effect());
}
}[Step 4] reactive() 함수로 Proxy 트랩 씌우기
이제 자바스크립트 객체를 Proxy로 감싸, get 시점에 track()을, set 시점에 trigger()를 호출하도록 묶어줍니다:
typescript
// 5. reactive: Proxy로 객체를 감싸 get/set 트랩을 건다
export function reactive<T extends object>(target: T): T {
return new Proxy(target, {
get(target, key, receiver) {
track(target, key); // 👈 1. 의존성 수집
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const oldValue = (target as any)[key];
const result = Reflect.set(target, key, value, receiver);
// 값이 실제로 바뀌었을 때만 화면 갱신 트리거!
if (oldValue !== value) {
trigger(target, key); // 👈 2. 변경 통보
}
return result;
}
});
}[Step 5] effect() 함수로 자동 감시자 연결하기
실행할 함수(컴포넌트의 렌더링 함수 등)를 activeEffect에 임시로 올려두고 즉시 실행시키는 러너 함수를 만듭니다:
typescript
// 6. effect: 콜백을 실행하고 반응형 의존성을 자동으로 엮어줌
export function effect(fn: () => void) {
const effectRunner = () => {
activeEffect = effectRunner;
try {
fn();
} finally {
activeEffect = null; // 실행 후 전역 포인터 초기화
}
};
effectRunner(); // 최초 1회 즉시 실행 (의존성 수집 트리거)
}[Step 6] 게시판 실시간 렌더링 시뮬레이션 및 터미널 검증
이제 파일 최하단에 테스트 실행 코드를 덧붙여 봅시다:
typescript
// ==========================================
// 🧪 [테스트 실행 구역]
// ==========================================
console.log('🚀 [Chapter 02] 미니 반응형 엔진 테스트 시작!\n');
// 1장의 게시글 하나를 모방한 상태 객체
const post = reactive({
title: 'jQuery 레거시의 종말',
viewCount: 0
});
// Vue 컴포넌트의 가상 렌더링 함수 시뮬레이션
effect(() => {
console.log(`[DOM 렌더링] 제목: "${post.title}" | 조회수: ${post.viewCount}`);
});
// 💡 최초 실행 시: effect 내부에서 post.title과 post.viewCount를 '읽었으므로',
// 두 속성의 targetMap 구독자 명단에 이 console.log 함수가 자동으로 등록됩니다!
console.log('\n--- 1. 조회수 증가 (post.viewCount++) ---');
post.viewCount++;
console.log('\n--- 2. 제목 변경 (post.title = ...) ---');
post.title = 'Vue 3 아키텍처 완성';
console.log('\n🎉 모든 반응형 트리거가 정상 작동했습니다!');💻 터미널에서 실행하고 결과 확인하기
파일을 저장하고, 터미널에서 아래 명령어를 실행해 보십시오:
bash
cd frontend
npx tsx src/practice/mini-reactivity.ts[실제 콘솔 출력 결과]:
text
🚀 [Chapter 02] 미니 반응형 엔진 테스트 시작!
[DOM 렌더링] 제목: "jQuery 레거시의 종말" | 조회수: 0
--- 1. 조회수 증가 (post.viewCount++) ---
[DOM 렌더링] 제목: "jQuery 레거시의 종말" | 조회수: 1
--- 2. 제목 변경 (post.title = ...) ---
[DOM 렌더링] 제목: "Vue 3 아키텍처 완성" | 조회수: 1
🎉 모든 반응형 트리거가 정상 작동했습니다!놀랍지 않습니까?
우리는post.viewCount++와post.title = ...라는 단순한 변수 수정을 했을 뿐인데, 아무도 부르지 않은console.log렌더링 함수가 마법처럼 2번이나 알아서 재실행되었습니다.
이것이 바로 Vue 3가 1장의BoardApp.vue에서 테이블을 자동으로 다시 그린 핵심 엔진의 정체입니다!
2.4 그렇다면 ref는 왜 필요하고 어떻게 동작하는가?
1장의 BoardApp.vue를 보면 게시글 목록은 ref<Post[]>([])로 선언했고, 폼 입력값은 reactive({ ... })로 선언했습니다.
"Proxy로 다 해결된다면, 왜
ref라는 걸 또 만들어서 귀찮게.value를 붙이게 만들었을까요?"
자바스크립트 원시값(Primitive)의 한계
Proxy는 오직 **Object (객체, 배열 등)**에만 씌울 수 있습니다.
자바스크립트의 기본 원시값인 number, string, boolean에는 Proxy를 씌울 수가 없습니다:
typescript
// ❌ 자바스크립트 엔진 문법 에러!
new Proxy(0, { ... }); // TypeError: Cannot create proxy with a non-object따라서 원시값을 반응형으로 다루려면 **"원시값을 .value라는 속성으로 감싸는 객체 래퍼(Wrapper)"**를 만들어야 합니다. 이것이 바로 ref의 정체입니다:
typescript
// ref의 내부 구현 메커니즘
class RefImpl<T> {
private _value: T;
constructor(value: T) {
this._value = value;
}
get value() {
track(this, 'value'); // .value를 읽을 때 의존성 수집!
return this._value;
}
set value(newVal) {
if (newVal !== this._value) {
this._value = newVal;
trigger(this, 'value'); // .value가 바뀔 때 화면 갱신 통보!
}
}
}우리가 <script>에서 posts.value = ... 처럼 .value를 반드시 붙여야 하는 이유는, .value의 getter/setter 트랩을 건드려야만 Vue 엔진이 화면을 다시 그릴 수 있기 때문입니다.
2.5 BoardApp.vue의 상태 설계 기준
실무에서 어떤 상태를 ref로 만들고 어떤 상태를 reactive로 만들어야 할지 헷갈린다면 아래 원칙을 적용하십시오:
| 구분 | 적용 대상 | BoardApp.vue 적용 예시 |
|---|---|---|
ref | 모든 원시값(string, number, boolean) 및 재할당이 필요한 배열/객체 | const posts = ref<Post[]>([])const isLoading = ref<boolean>(false) |
reactive | 한 덩어리로 묶여 다니며 절대 통째로 재할당되지 않는 폼 객체 | const form = reactive({ title: '', author: '', content: '' }) |
2.6 다음 챕터로 이어지는 질문
이제 우리는 BoardApp.vue의 반응형 엔진이 내부에서 어떻게 돌아가는지 완벽히 이해했습니다.
다음 단계로, 게시글 상세 보기 화면과 수정 폼을 BoardApp.vue에 추가해 볼 차례입니다.
그런데 여기서 실무 개발자들이 가장 많이 겪는 치명적인 실무 버그 1위가 터집니다:
"코드를 깔끔하게 쓰려고
const { title, content } = form처럼 ES6 구조 분해 할당을 했더니, 반응성의 파이프가 끊어져 브라우저 화면이 요지부동 멈춰버렸습니다!"
이어지는 Chapter 03에서는 이 반응형 유실(Reactivity Loss) 현상을 브라우저에서 직접 터뜨려보고, toRefs 및 Vue 3.5 신규 표준으로 해결하는 실습을 진행하겠습니다.