다크 모드
Chapter 01. 돌아가기만 하는 사내 레거시: jQuery 게시판의 종말
"수년간 사내 인트라넷을 지탱해 온 저 jQuery 파일은 왜 지금 우리 팀의 가장 큰 기술 부채가 되었는가? 그리고 우리는 이 하나의 코드를 어디서부터, 어떻게 손으로 직접 작성하며 현대적 아키텍처로 진화시킬 수 있는가?"
1.1 사내 인트라넷에 살아 숨 쉬는 jQuery 게시판
우리 회사 인트라넷에는 사내 Q&A와 공지사항을 위해 구축된 전형적인 **jQuery 기반 게시판(legacy-board.html)**이 지금도 돌아가고 있습니다.
기능 자체는 겉보기에 동작합니다:
- 브라우저가 열리면
$.ajax로 백엔드 Spring Boot(/api/posts)에서 목록을 가져옵니다. $('#board-tbody').empty()를 한 뒤 문자열<tr><td>...</td></tr>을 루프로 돌려.append()합니다.- [등록] 버튼을 누르면 인풋 필드의
$('#input-title').val()을 읽어와$.post를 날립니다. - 각 행의 [삭제] 버튼을 누르면 동적 요소 이벤트 위임(
$(document).on('click', '.btn-delete'))으로 행을 지웁니다.
🖥️ [핸즈온 01] 레거시 jQuery 게시판 직접 실행해보기
백엔드 서버를 먼저 띄워 jQuery 게시판이 어떻게 동작하는지 눈으로 확인해 봅시다.
- 터미널을 열고 백엔드(Spring Boot) 서버를 실행합니다:
bash
cd backend
./mvnw spring-boot:run
# (Windows PowerShell / CMD 환경의 경우):
# .\mvnw.cmd spring-boot:run백엔드가 정상 기동되면 콘솔에
Started BoardApplication in ... seconds (process running for ...)로그와 함께 8080 포트에서 대기합니다.
- 웹 브라우저를 열고 레거시 화면에 접속합니다:
- 접속 주소:
http://localhost:8080/legacy-board.html - 초기 데이터로 5개의 사내 기술 게시글이 테이블에 노출됩니다.
- 제목과 작성자를 넣고 [게시글 등록] 버튼을 눌러보고, 행 우측의 [삭제] 버튼도 눌러보십시오. 일단 돌아는 갑니다!
- 접속 주소:
1.2 [실습] 브라우저에서 직접 터뜨려보는 jQuery 3대 버그
http://localhost:8080/legacy-board.html 화면 상단에 있는 안내 박스를 보며, 아래 3가지 액션을 직접 브라우저에서 수행해 보십시오.
[실험 1] 사내 세션 탈취 XSS 취약점 터뜨리기
- 행동: 게시글 제목 입력창에 아래 악의적인 자바스크립트 태그를 복사해서 붙여넣고 [게시글 등록] 버튼을 누릅니다:
html
<img src="invalid-image" onerror="alert('🚨 [보안 경고] 사내 세션 토큰이 탈취되었습니다!');">- 결과: 등록되자마자 브라우저에
alert경고창이 뜹니다! - 원인: jQuery 코드가
post.title을 따옴표 문자열 결합('<td>' + p.title + '</td>')으로 HTML에 그대로 때려 박았기 때문에, 브라우저가 이미지 로드 실패 시 인라인 자바스크립트를 즉시 실행해 버린 것입니다.
[실험 2] 상태와 뷰의 이원화 버그 (동기화 실패)
- 행동: 등록된 게시글들을 [삭제] 버튼을 눌러 하나씩 모두 지워봅니다.
- 결과: 마지막 글을 지웠을 때 화면에 "게시글이 없습니다"라는 안내 문구가 DOM과 어긋나거나, 새로고침하지 않으면 정상적인 빈 상태 UI가 표현되지 않는 미묘한 화면 버그가 발생합니다.
- 원인: 데이터(메모리)와 화면(DOM)이 따로 놀기 때문에, 개발자가
$targetRow.remove()와$('#board-tbody').html(...)을 일일이 수동으로 계산해서 맞춰줘야 하는 구조적 한계 때문입니다.
[실험 3] HTML 구조 변경 시 셀렉터 먹통 현상
- 원인: jQuery 코드의
$(this).closest('tr')나$('#board-tbody')는 HTML 마크업 구조에 100% 종속적입니다. 디자이너가table태그를div기반의 반응형 카드 뷰로 리뉴얼하는 순간, 자바스크립트는 아무런 콘솔 에러도 없이 침묵 속에 삭제 기능이 멈춥니다.
1.3 [실습 환경 준비] Vue 3 + TypeScript 프론트엔드 프로젝트 구조 이해하기
이제 이 불안정한 jQuery 코드를 모던 프론트엔드의 사실상 표준인 Vue 3 + TypeScript로 전환하겠습니다.
우선 프론트엔드 실습 환경이 어떻게 구성되어 있는지 살펴봅시다.
1) 실무에서 Vue 3 프로젝트를 새로 구성하는 방식
만약 사내에서 신규 프로젝트를 처음부터 구축한다면 공식 스캐폴딩 도구를 사용합니다:
bash
npm create vue@latest- 대화형 프롬프트에서 TypeScript: Yes, Vue Router: Yes, Pinia: Yes, Vitest: Yes를 선택하면 표준화된 엔터프라이즈 프로젝트가 단 10초 만에 생성됩니다.
2) 본 교재 실습 환경 (frontend/ 폴더)
본 도서에는 학습자가 복잡한 초기 번들러 설정을 할 필요 없이 핵심 아키텍처에만 집중할 수 있도록, 프로젝트 루트의 frontend/ 디렉터리에 완벽한 실습 환경이 준비되어 있습니다.
vue3-board-architecture/
├── backend/ # Spring Boot 3 백엔드 (REST API: 8080)
└── frontend/ # Vue 3 + TypeScript 프론트엔드 (3000)
├── package.json # Vue 3.5, TypeScript, Axios, Pinia 의존성
├── vite.config.ts # Vite 번들러 설정 (8080 포트 프록시 내장)
├── index.html # SPA 단일 HTML 엔트리포인트
└── src/
├── main.ts # Vue 앱 인스턴스 부트스트랩
├── App.vue # 최상위 레이아웃 컴포넌트
└── BoardApp.vue # 👈 우리가 직접 한 줄씩 코딩할 실습 파일!NOTE
CORS 걱정 없는 Vite 프록시 설정:frontend/vite.config.ts에는 /api로 시작하는 모든 HTTP 비동기 요청을 Spring Boot 백엔드(http://localhost:8080)로 자동 전달해주는 프록시(Proxy)가 내장되어 있어 별도의 CORS 설정 없이 로컬에서 원활하게 개발할 수 있습니다.
3) 프론트엔드 개발 서버 기동하기
새로운 터미널 창을 하나 더 열고(백엔드 터미널은 켜둔 상태 유지), 프론트엔드 개발 서버를 실행합니다:
bash
cd frontend
npm install
npm run dev터미널에 다음과 같은 녹색 메시지가 출력되면 성공입니다:
VITE v5.4.8 ready in 280 ms
➜ Local: http://localhost:3000/
➜ Network: use --host to expose이제 웹 브라우저를 열고 http://localhost:3000에 접속해 두십시오.frontend/src/App.vue가 우리가 작성할 frontend/src/BoardApp.vue 컴포넌트를 마운트하여 화면에 표시해 줄 준비를 마쳤습니다.
1.4 [따라하기 실습] BoardApp.vue 4단계 점진적 코딩
이제 본격적으로 코드를 작성해 봅시다.
한 번에 완성된 코드를 붙여넣지 마십시오! **조회(Read) → 등록(Create) → 삭제(Delete)**의 순서로 한 단계씩 코딩하며 화면의 변화를 직접 눈으로 확인해야 선언적 반응형 프로그래밍의 멘탈 모델이 뇌리에 각인됩니다.
[Step 1] 파일 준비 및 기본 골격 작성
frontend/src/BoardApp.vue 파일을 에디터(VS Code 등)로 열고, 내용을 모두 지운 뒤 아래의 기본 골격부터 작성해 봅시다:
html
<!-- frontend/src/BoardApp.vue -->
<template>
<div class="board-card">
<div class="card-header">
<h2>✅ [Vue 3 마이그레이션] 사내 기술 Q&A 게시판</h2>
<span class="badge">Vue 3.5 + TypeScript</span>
</div>
<p style="color: #64748b; padding: 20px 0;">게시판 컴포넌트가 성공적으로 마운트되었습니다.</p>
</div>
</template>
<script setup lang="ts">
// 여기에 상태(State)와 비즈니스 로직을 하나씩 채워 넣을 것입니다.
</script>
<style scoped>
.board-card {
background: #ffffff;
border-radius: 12px;
box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1);
padding: 24px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #e2e8f0;
padding-bottom: 16px;
margin-bottom: 20px;
}
.badge {
background: #ecfdf5;
color: #10b981;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
}
</style>💡 [확인하기]:
파일을 저장하고 브라우저http://localhost:3000을 새로고침(또는 Vite HMR 자동 반영)해 보십시오. 흰색 카드 상단에 제목과 뱃지가 깔끔하게 나타납니다.
[Step 2] 데이터 모델 정의 및 목록 선언적 조회 (Read)
이제 백엔드 Spring Boot의 /api/posts API를 호출하여 게시글 목록을 테이블에 뿌려보겠습니다.
<script setup>에 TypeScript 인터페이스와 상태를 선언합니다:
typescript
import { ref, onMounted } from 'vue';
import axios from 'axios';
// 1. 게시글 데이터 타입 정의 (백엔드 PostResponse DTO와 계약)
interface Post {
id: number;
title: string;
author: string;
content: string;
createdAt: string;
}
// 2. 상태(State) 선언: posts 배열이 바뀌면 Vue가 알아서 화면을 갱신한다.
const posts = ref<Post[]>([]);
const isLoading = ref<boolean>(false);
// 3. 서버 API 호출 함수
async function loadPosts() {
isLoading.value = true;
try {
const res = await axios.get<{ items: Post[] }>('/api/posts?size=20');
posts.value = res.data.items || [];
} catch (err) {
alert('게시글 로딩에 실패했습니다.');
} finally {
isLoading.value = false;
}
}
// 4. 컴포넌트가 마운트될 때 1회 즉시 실행
onMounted(() => {
loadPosts();
});<template>에 목록 테이블과v-for반복문을 작성합니다:<p>게시판 컴포넌트가...</p>문장을 지우고 그 자리에 아래 테이블을 작성합니다:
html
<!-- 로딩 인디케이터 선언적 렌더링 -->
<div v-if="isLoading" class="loading-state">
데이터를 불러오는 중입니다...
</div>
<!-- 게시글 목록 테이블: jQuery의 for문 문자열 결합 완전 제거 -->
<table v-else class="data-table">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th>제목</th>
<th style="width: 100px;">작성자</th>
<th style="width: 120px;">작성일</th>
<th style="width: 70px;">동작</th>
</tr>
</thead>
<tbody>
<!-- v-for: 상태 배열의 개수만큼 자동으로 <tr>을 렌더링 -->
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td>
<!-- 머스태시 {{ }}는 HTML을 자동 이스케이프하므로 XSS가 원천 차단된다! -->
<span class="post-title">{{ post.title }}</span>
</td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
<td>
<button class="btn-delete">삭제</button>
</td>
</tr>
<!-- 빈 상태 UI: 상태 배열 길이가 0이면 자동 노출 -->
<tr v-if="posts.length === 0">
<td colspan="5" class="empty-state">등록된 게시글이 없습니다.</td>
</tr>
</tbody>
</table>- 테이블 스타일을
<style scoped>하단에 추가합니다:
css
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid #e2e8f0; padding: 12px; text-align: left; }
.data-table th { background: #f8fafc; color: #475569; font-weight: 600; }
.post-title { font-weight: 600; color: #0f172a; }
.loading-state, .empty-state { text-align: center; padding: 40px; color: #94a3b8; }
.btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
.btn-delete:hover { background: #fecaca; }💡 [확인하기]:
파일을 저장하고 브라우저http://localhost:3000을 확인하십시오.
백엔드 Spring Boot H2 데이터베이스에 들어있던 5개의 게시글 목록이 테이블에 정갈하게 출력됩니다!
[Step 3] 양방향 바인딩(v-model)으로 등록 폼 구축 (Create)
이제 게시글을 등록하는 폼을 붙여봅시다.
과거 jQuery에서는 $('#input-title').val()로 일일이 돔 값을 읽어야 했지만, Vue에서는 상태 객체와 인풋을 v-model로 동기화합니다.
<script setup>에form상태와handleSubmit함수를 추가합니다:
typescript
import { ref, reactive, onMounted } from 'vue'; // reactive 임포트 추가
// 등록 폼 상태 선언
const isSubmitting = ref<boolean>(false);
const form = reactive({
title: '',
author: '',
content: ''
});
// 등록 요청 함수
async function handleSubmit() {
if (!form.title.trim() || !form.author.trim()) {
alert('제목과 작성자를 모두 입력하세요.');
return;
}
isSubmitting.value = true;
try {
const payload = {
...form,
category: 'DEV'
};
const res = await axios.post<Post>('/api/posts', payload);
// 💡 마법의 핵심: 메모리의 posts 배열 맨 앞에 새 글을 끼워 넣기만 한다!
posts.value.unshift(res.data);
// 폼 입력창 초기화
form.title = '';
form.author = '';
form.content = '';
} catch (err) {
alert('게시글 등록에 실패했습니다.');
} finally {
isSubmitting.value = false;
}
}<template>의 테이블 위쪽에 등록 폼 UI를 추가합니다:
html
<!-- 1. 양방향 데이터 바인딩 폼 (v-model) -->
<form class="form-container" @submit.prevent="handleSubmit">
<div class="input-row">
<input
v-model.trim="form.title"
type="text"
placeholder="제목 (XSS 공격 스크립트를 입력해 보세요)"
required
/>
<input
v-model.trim="form.author"
type="text"
placeholder="작성자"
class="author-input"
required
/>
<button type="submit" :disabled="isSubmitting" class="btn-submit">
{{ isSubmitting ? '등록 중...' : '게시글 등록' }}
</button>
</div>
<textarea
v-model="form.content"
placeholder="내용을 입력하세요"
rows="3"
></textarea>
</form>- 폼 스타일을
<style scoped>에 추가합니다:
css
.form-container { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 24px; }
.input-row { display: flex; gap: 10px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.input-row .author-input { flex: 0 0 140px; }
.btn-submit { background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; }
.btn-submit:disabled { background: #94a3b8; cursor: not-allowed; }
textarea { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; resize: vertical; }💡 [확인하기 - XSS 공격 방어 검증!]:
- 브라우저
http://localhost:3000에서 제목에 아까 jQuery에서 성공했던 악의적인 스크립트를 그대로 입력해 보십시오:html<img src="x" onerror="alert('해킹');">- 작성자를 넣고 [게시글 등록] 버튼을 누릅니다.
- 결과:
alert경고창이 전혀 뜨지 않고, 문자열 그대로 테이블 최상단에 안전하게 등록됩니다!
Vue의 템플릿 컴파일러가 머스태시{{ post.title }}을 텍스트 노드로 자동 이스케이프하기 때문입니다.
[Step 4] 단 한 줄로 끝내는 삭제 기능 연결 (Delete)
과거 jQuery에서는 행을 지우기 위해 $(this).closest('tr').remove()로 DOM 요소를 수동 추적해서 뜯어내야 했습니다.
Vue에서는 어떻게 할까요?
<script setup>에handleDelete함수를 작성합니다:
typescript
async function handleDelete(id: number) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
await axios.delete(`/api/posts/${id}`);
// 💡 충격적인 단순함: 메모리 배열에서 해당 ID만 제외하면 끝!
posts.value = posts.value.filter(p => p.id !== id);
} catch (err) {
alert('게시글 삭제에 실패했습니다.');
}
}<template>테이블의 [삭제] 버튼에 클릭 이벤트를 연결합니다:
html
<!-- 기존: <button class="btn-delete">삭제</button> -->
<button class="btn-delete" @click="handleDelete(post.id)">삭제</button>💡 [확인하기 - 선언적 렌더링 체감]:
브라우저에서 방금 등록한 글의 [삭제] 버튼을 클릭해 보십시오.posts.value = posts.value.filter(...)한 줄로 배열 참조가 바뀌자마자, Vue의 반응형 엔진이 해당<tr>을 스스로 감지하여 돔 트리에서 제거합니다!
[Step 5] 1차 완성본 전체 소스 코드 확인
우리가 단계별로 작성한 frontend/src/BoardApp.vue의 완성 코드는 총 150줄 남짓입니다. 전체 코드가 올바르게 작성되었는지 대조해 보십시오:
html
<template>
<div class="board-card">
<div class="card-header">
<h2>✅ [Vue 3 마이그레이션] 사내 기술 Q&A 게시판</h2>
<span class="badge">Vue 3.5 + TypeScript</span>
</div>
<!-- 1. 등록 폼 -->
<form class="form-container" @submit.prevent="handleSubmit">
<div class="input-row">
<input v-model.trim="form.title" type="text" placeholder="제목" required />
<input v-model.trim="form.author" type="text" placeholder="작성자" class="author-input" required />
<button type="submit" :disabled="isSubmitting" class="btn-submit">
{{ isSubmitting ? '등록 중...' : '게시글 등록' }}
</button>
</div>
<textarea v-model="form.content" placeholder="내용을 입력하세요" rows="3"></textarea>
</form>
<!-- 2. 로딩 상태 분기 -->
<div v-if="isLoading" class="loading-state">데이터를 불러오는 중입니다...</div>
<!-- 3. 목록 테이블 -->
<table v-else class="data-table">
<thead>
<tr>
<th style="width: 60px;">ID</th>
<th>제목</th>
<th style="width: 100px;">작성자</th>
<th style="width: 120px;">작성일</th>
<th style="width: 70px;">동작</th>
</tr>
</thead>
<tbody>
<tr v-for="post in posts" :key="post.id">
<td>{{ post.id }}</td>
<td><span class="post-title">{{ post.title }}</span></td>
<td>{{ post.author }}</td>
<td>{{ post.createdAt }}</td>
<td><button class="btn-delete" @click="handleDelete(post.id)">삭제</button></td>
</tr>
<tr v-if="posts.length === 0">
<td colspan="5" class="empty-state">등록된 게시글이 없습니다.</td>
</tr>
</tbody>
</table>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import axios from 'axios';
interface Post {
id: number;
title: string;
author: string;
content: string;
createdAt: string;
}
const posts = ref<Post[]>([]);
const isLoading = ref<boolean>(false);
const isSubmitting = ref<boolean>(false);
const form = reactive({
title: '',
author: '',
content: ''
});
async function loadPosts() {
isLoading.value = true;
try {
const res = await axios.get<{ items: Post[] }>('/api/posts?size=20');
posts.value = res.data.items || [];
} catch (err) {
alert('게시글 로딩 실패');
} finally {
isLoading.value = false;
}
}
async function handleDelete(id: number) {
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
await axios.delete(`/api/posts/${id}`);
posts.value = posts.value.filter(p => p.id !== id);
} catch (err) {
alert('게시글 삭제 실패');
}
}
async function handleSubmit() {
if (!form.title.trim() || !form.author.trim()) {
alert('제목과 작성자를 모두 입력하세요.');
return;
}
isSubmitting.value = true;
try {
const payload = { ...form, category: 'DEV' };
const res = await axios.post<Post>('/api/posts', payload);
posts.value.unshift(res.data);
form.title = '';
form.author = '';
form.content = '';
} catch (err) {
alert('게시글 등록 실패');
} finally {
isSubmitting.value = false;
}
}
onMounted(() => {
loadPosts();
});
</script>
<style scoped>
.board-card { background: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #e2e8f0; padding-bottom: 16px; margin-bottom: 20px; }
.badge { background: #ecfdf5; color: #10b981; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.form-container { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 24px; }
.input-row { display: flex; gap: 10px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.input-row .author-input { flex: 0 0 140px; }
.btn-submit { background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; }
.btn-submit:disabled { background: #94a3b8; cursor: not-allowed; }
textarea { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; resize: vertical; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid #e2e8f0; padding: 12px; text-align: left; }
.data-table th { background: #f8fafc; color: #475569; font-weight: 600; }
.post-title { font-weight: 600; color: #0f172a; }
.loading-state, .empty-state { text-align: center; padding: 40px; color: #94a3b8; }
.btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
.btn-delete:hover { background: #fecaca; }
</style>1.5 jQuery vs Vue 3 패러다임 비교 요약
| 비교 항목 | jQuery 레거시 (legacy-board.js) | Vue 3 선언적 컴포넌트 (BoardApp.vue) |
|---|---|---|
| 개발 방식 | 명령형(Imperative): "어디를 찾아서 어떻게 바꿔라" | 선언형(Declarative): "이 상태일 때 화면은 이 모양이어야 한다" |
| 화면 갱신 | 수동 조작: $('tbody').append(...), $tr.remove() | 자동 반응: posts.value.unshift(...), posts.value.filter(...) |
| 보안(XSS) | 취약: 따옴표 문자열 결합 시 XSS 즉시 감염 | 완벽 방어: 템플릿 컴파일러가 텍스트 자동 이스케이프 |
| DOM 결합도 | 100% 종속: 마크업이 바뀌면 JS 셀렉터가 깨짐 | 0% 분리: 컴포넌트는 오직 메모리의 상태(State)만 바라봄 |
1.6 다음 챕터로 이어지는 질문
우리는 방금 단 150줄의 코드로 레거시 jQuery를 몰아내고 최초의 Vue 3 컴포넌트 BoardApp.vue를 직접 손으로 완성했습니다.
하지만 여기서 개발자라면 가슴속에 깊은 의문이 생겨야 정상입니다:
*"우리는 분명
posts.value = ...라며 단순한 자바스크립트 변수의 메모리 주소(참조)를 바꿨을 뿐이다.
브라우저 자바스크립트 엔진(V8)에는 '변수가 바뀌면 테이블<tr>을 다시 그려라'는 기능 따위는 없다.
도대체 Vue 3는 어떤 원리로 변수의 수정을 가로채서 화면을 자동으로 다시 그린 것일까?"
이어지는 Chapter 02에서는 BoardApp.vue의 심장부인 Vue 3의 ES6 Proxy 기반 반응형 엔진의 내부 메커니즘을 파헤치고, 50줄의 순수 TypeScript 코드로 미니 반응형 엔진(mini-reactivity.ts)을 직접 작성하고 터미널에서 실행해 검증해 보겠습니다.