SPA 프레임워크 설계와 구현
📖 SPA 프레임워크 설계와 구현: dom-render를 통한 여정
서문: 왜 우리는 또 다른 프레임워크를 만드는가?
제1장: 모든 것의 시작 - 반응성이란 무엇인가?
1.1. 현대 프레임워크의 반응성 모델 비교
1.2. JavaScript
Proxy심층 탐구1.3. 의존성 추적(Dependency Tracking)의 개념
제2장: 가장 단순한 템플릿 엔진 만들기
2.1. 템플릿 파싱의 첫걸음
2.2.
RawSet의 개념 설계2.3. 1회성 렌더링 함수 구현
제3장: 반응성 주입 - DomRenderProxy의 탄생
3.1. 1장과 2장의 결합
3.2. 재귀적 Proxy 적용
3.3.
DomRender.run의 완성
제4장: 제어 흐름 구현 - 지시어(Directive) 시스템
4.1. Operator(연산자) 패턴 설계
4.2.
dr-if구현하기4.3.
dr-for-of구현하기
제5장: 생명과 영혼을 불어넣다 - 컴포넌트 시스템
5.1.
DomRender.createComponent설계5.2. 생명주기(Lifecycle) 훅 구현
5.3. 스코프 격리와 데이터 흐름
5.4. 격리된 스타일(Scoped Styles) 구현
제6장: 프레임워크 완성하기
6.1. 라우터(
PathRouter,HashRouter) 설계6.2. 메신저(
Messenger) 시스템6.3. 최적화와 예외 처리
6.4. 빌드 및 배포
부록: 더 나아가기
A.
dom-render아키텍처의 장단점B. 성능 개선을 위한 아이디어
C. 프레임워크 개발자로서의 성장 로드맵
서문: 왜 우리는 또 다른 프레임워크를 만드는가?
수많은 자바스크립트 프레임워크가 존재하는 세상에서, "왜 또 다른 프레임워크를 만드는가?"라는 질문은 당연합니다. 이 책은 새로운 프레임워크를 세상에 내놓기 위함이 아닙니다. 오히려 그 반대입니다. 우리는 프레임워크의 내부를 해부하고, 직접 만들어보는 과정을 통해 소프트웨어 아키텍처의 정수를 배우고자 합니다.
dom-render의 핵심 철학
dom-render는 다음과 같은 단순하지만 강력한 철학 위에 세워졌습니다.
HTML 중심 (HTML-centric): 로직이 템플릿을 지배하는 것이 아니라, 템플릿(HTML)이 필요한 로직을 선언적으로 요청합니다. 개발자는 HTML 구조 안에서 모든 것을 파악할 수 있어야 합니다.
순수한 반응성 (Pure Reactivity): 가상돔(Virtual DOM)이나 복잡한 컴파일 과정 없이, 순수 JavaScript
Proxy객체를 통해 상태(State)의 변경이 DOM에 직접적이고 효율적으로 반영됩니다. "마법"처럼 보이는 코드를 최소화하고, JavaScript의 기본 원리에 충실합니다.
이 책의 목표
이 책은 dom-render의 사용법을 알려주는 설명서가 아닙니다. 여러분을 프레임워크 제작의 여정으로 안내하는 가이드입니다. 우리는 텅 빈 파일에서 시작해, 다음과 같은 질문에 스스로 답하며 코드를 채워나갈 것입니다.
반응성이란 정확히 무엇이며,
Proxy로 어떻게 구현하는가?템플릿의 동적인 부분만 효율적으로 찾아 업데이트하려면 어떻게 해야 하는가?
재사용 가능한 코드 묶음, 즉 '컴포넌트'는 어떤 구조를 가져야 하는가?
페이지 전환(Routing)이나 컴포넌트 간 통신(Messaging) 같은 고급 기능은 어떻게 설계하는가?
이 과정을 통해 독자 여러분은 단순히 하나의 프레임워크를 배우는 것을 넘어, 소프트웨어 아키텍처를 설계하고, 문제를 정의하며, 해결책을 코드로 구현하는 능력을 기르게 될 것입니다.
최종 결과물 미리보기
이 책의 마지막 장에 도달했을 때, 우리는 Proxy 기반의 반응성, 지시어(Directive)를 통한 제어 흐름, 컴포넌트 시스템, 라우팅, 그리고 격리된 스타일(Scoped Style)까지 갖춘 작지만 강력한 프레임워크, dom-render의 핵심을 완성하게 될 것입니다. 이제, 프레임워크 제작의 즐거운 여정을 시작하겠습니다.
제1장: 모든 것의 시작 - 반응성이란 무엇인가?
프레임워크의 심장은 '반응성(Reactivity)'입니다. 반응성이란 데이터(상태, State)가 변경되었을 때, 이 데이터를 사용하는 사용자 인터페이스(UI)가 자동으로 업데이트되는 것을 의미합니다. 이 장에서는 현대 프레임워크들이 반응성을 어떻게 구현하는지 비교해보고, dom-render의 기반이 되는 JavaScript Proxy에 대해 깊이 알아봅니다.
1.1. 현대 프레임워크의 반응성 모델 비교
Virtual DOM (React): 데이터가 변경되면, 메모리상에 가상의 DOM 트리를 새로 만듭니다. 그리고 이전의 가상 DOM 트리와 비교하여 변경된 부분(diffing)을 찾아내 실제 DOM에 한 번에 적용합니다. 이 방식은 DOM 조작을 최소화하지만, 메모리 사용량과 비교 연산 비용이 발생합니다.
컴파일러 기반 (Svelte): 빌드 시점에 코드를 분석하여, 어떤 데이터가 변경될 때 DOM의 어느 부분을 직접 수정해야 하는지에 대한 고도로 최적화된 코드를 생성합니다. 런타임에 가상 DOM이나 복잡한 비교 과정이 없어 매우 빠르지만, 프레임워크가 "마법"처럼 보일 수 있습니다.
Proxy 기반 (Vue 3,
dom-render):dom-render가 채택한 방식입니다. 데이터 객체를Proxy로 감싸서, 속성에 접근(get)하거나 값을 변경(set)하는 동작을 가로챕니다. 이를 통해 어떤 데이터가 어디서 사용되는지(의존성)를 정확히 추적하고, 변경 시 필요한 부분만 정밀하게 업데이트할 수 있습니다. 가상 DOM의 메모리 오버헤드가 없고, Svelte처럼 빌드 과정에 전적으로 의존하지도 않는 균형 잡힌 접근법입니다.
1.2. JavaScript Proxy 심층 탐구
Proxy는 객체에 대한 기본적인 동작(속성 접근, 할당, 순회 등)을 가로채고 재정의할 수 있는 특별한 객체입니다. 우리는 주로 get과 set이라는 두 가지 '트랩(trap)'을 사용합니다.
// 순수한 데이터 객체
const user = {
name: 'Alice',
age: 30
};
// Proxy 핸들러: 트랩들을 정의하는 객체
const handler = {
get(target, property) {
console.log(`'${property}' 속성에 접근했습니다.`);
return target[property];
},
set(target, property, value) {
console.log(`'${property}' 속성을 '${value}'(으)로 변경합니다.`);
target[property] = value;
return true; // set 트랩은 성공 시 true를 반환해야 합니다.
}
};
// Proxy 객체 생성
const reactiveUser = new Proxy(user, handler);
// 사용 예시
console.log(reactiveUser.name); // "'name' 속성에 접근했습니다." 출력 후 "Alice" 출력
reactiveUser.age = 31; // "'age' 속성을 '31'(으)로 변경합니다." 출력
이 간단한 예제가 dom-render 반응성의 핵심입니다. get을 할 때 "누가 이 데이터를 사용하는지" 기록하고, set을 할 때 "이 데이터를 사용하던 모두에게 변경 사실을 알리는" 로직을 추가하면 됩니다.
1.3. 의존성 추적(Dependency Tracking)의 개념
의존성 추적은 다음 두 단계로 이루어집니다.
의존성 수집 (Dependency Collection): 코드가 실행되는 동안(
get트랩이 호출될 때), 현재 실행 중인 작업(예: 특정 DOM 요소를 업데이트하는 함수)을 특정 데이터 속성과 연결합니다. "user.name이 변경되면, 이 DOM 요소를 업데이트해야 해"라고 기록해두는 것과 같습니다.변경 알림 (Change Notification): 데이터가 변경되면(
set트랩이 호출될 때), 해당 속성에 연결된 모든 작업(DOM 업데이트 함수들)을 다시 실행합니다.
이 개념을 코드로 표현하면 다음과 같습니다.
// 의존성을 저장할 맵: { target -> { property -> [effect1, effect2, ...] } }
const dependencyMap = new WeakMap();
// 현재 실행 중인 effect를 저장할 변수
let activeEffect = null;
// 의존성을 추적하는 함수
function track(target, property) {
if (activeEffect) {
let depsMap = dependencyMap.get(target);
if (!depsMap) {
dependencyMap.set(target, (depsMap = new Map()));
}
let deps = depsMap.get(property);
if (!deps) {
depsMap.set(property, (deps = new Set()));
}
deps.add(activeEffect);
}
}
// 변경 알림을 보내는 함수
function trigger(target, property) {
const depsMap = dependencyMap.get(target);
if (!depsMap) return;
const deps = depsMap.get(property);
if (deps) {
deps.forEach(effect => effect());
}
}
// Proxy 핸들러에 적용
const handler = {
get(target, property) {
track(target, property);
return target[property];
},
set(target, property, value) {
target[property] = value;
trigger(target, property);
return true;
}
};
// effect 함수: UI 업데이트 등 실제 작업을 수행하는 함수
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}
// --- 사용 예시 ---
const user = new Proxy({ name: 'Bob' }, handler);
effect(() => {
// 이 함수는 user.name에 의존합니다.
console.log('User name is:', user.name);
});
// user.name을 변경하면, 위 effect 함수가 자동으로 다시 실행됩니다.
user.name = 'Charlie'; // "User name is: Charlie"가 다시 출력됨
이제 우리는 프레임워크의 가장 근본적인 부분인 반응성 시스템의 원리를 이해했습니다. 다음 장에서는 이 반응성 시스템을 실제 DOM과 연결하는 템플릿 엔진을 만들어 보겠습니다.
제2장: 가장 단순한 템플릿 엔진 만들기
반응성 시스템이 데이터를 감지하는 '뇌'라면, 템플릿 엔진은 변경 사항을 화면에 그려내는 '손'입니다. 이 장에서는 HTML 템플릿을 분석하여 동적인 부분과 정적인 부분을 분리하고, 이를 데이터와 결합하여 실제 DOM으로 변환하는 과정을 단계별로 구현합니다.
2.1. 템플릿 파싱의 첫걸음
우리의 템플릿 엔진은 특별한 문법을 가집니다. 예를 들어, ${this.name}$은 텍스트를, #{this.htmlContent}#는 HTML을 삽입합니다. 가장 먼저 할 일은 주어진 HTML 문자열에서 이런 표현식들을 찾아내는 것입니다.
정규표현식(Regular Expression)은 이 작업을 위한 강력한 도구입니다. dom-render에서는 RawSet.expressionGroups 메소드 내부에서 다음과 유사한 정규표현식을 사용합니다.
// `${...}$` 또는 `#{...}#` 형태의 표현식을 찾는 정규표현식
const expressionRegex = /[$#]\{([\s\S.]*?)\}[$#]/g;
const template = '<div><p>Hello, ${this.user.name}$</p> <div class="content">#{this.post.content}#</div> </div>';
// String.prototype.matchAll() 또는 커스텀 유틸리티를 사용해 모든 일치 항목을 찾습니다.
const matches = Array.from(template.matchAll(expressionRegex));
// 결과:
// matches[0] -> ["${this.user.name}$", "this.user.name"]
// matches[1] -> ["#{this.post.content}#", "this.post.content"]
이 과정을 통해 우리는 템플릿의 어느 부분이 동적으로 변경되어야 하는지, 그리고 어떤 데이터에 의존하는지를 알 수 있습니다.
2.2. RawSet의 개념 설계
템플릿 전체를 하나의 큰 덩어리로 관리하는 것은 비효율적입니다. this.user.name이 바뀔 때마다 전체 템플릿을 다시 파싱하고 렌더링하는 것은 낭비입니다. 우리는 변경이 필요한 부분만 정확히 교체하고 싶습니다.
여기서 RawSet 이라는 핵심 개념이 등장합니다. RawSet은 하나의 동적 표현식을 관리하는 최소 단위입니다. 각 RawSet은 DOM 내에서 자신의 위치를 기억해야 합니다. dom-render는 이를 위해 주석 노드(Comment Node)나 meta 태그를 사용합니다.
<!-- 원본 템플릿 -->
<p>Hello, ${this.user.name}$</p>
<!-- 파싱 후 DOM 구조 (개념적) -->
<p>
Hello,
<!-- RawSet-UUID-12345-start -->
<!-- 이 사이에 this.user.name의 값이 들어감 -->
<!-- RawSet-UUID-12345-end -->
</p>
이렇게 하면, this.user.name이 변경되었을 때, 우리는 DOM 전체를 검색할 필요 없이 RawSet-UUID-12345에 해당하는 시작과 끝 지점 사이의 내용만 교체하면 됩니다.
RawSet 클래스는 다음과 같은 정보를 가집니다.
uuid: 각RawSet을 식별하는 고유 ID.type: 이것이 텍스트 노드인지(TEXT), 속성인지(TARGET_ATTR), 아니면 커스텀 엘리먼트인지(TARGET_ELEMENT) 구분.point: DOM 내의 시작(start)과 끝(end) 노드에 대한 참조.dataSet: 렌더링에 필요한 원본 템플릿 조각(fragment), 설정(config) 등의 정보.
RawSet.checkPointCreates 메소드는 주어진 DOM 노드를 순회하며 동적인 부분을 찾아내고, 위와 같은 구조로 RawSet 객체들을 생성하여 반환하는 역할을 합니다.
2.3. 1회성 렌더링 함수 구현
이제 반응성을 잠시 잊고, 주어진 데이터로 템플릿을 한 번만 렌더링하는 함수를 만들어 봅시다. 이 과정은 RawSet.render 메소드의 핵심 로직과 유사합니다.
checkPointCreates호출: 템플릿 문자열을 DOM 노드로 변환한 후,RawSet.checkPointCreates를 호출하여RawSet배열을 얻습니다.RawSet순회: 각RawSet을 순회하며 다음을 수행합니다. a.RawSet이 가진 템플릿 조각(예:${this.user.name}$)에서 표현식(예:this.user.name)을 추출합니다. b.ScriptUtils.evalReturn같은 유틸리티를 사용해, 주어진 데이터 객체(obj)와 표현식을 기반으로 실제 값을 계산합니다. (단순히eval을 사용하는 것은 보안상 위험하므로, 실제로는new Function()등을 사용해 스코프를 제한하는 것이 좋습니다.) c. 계산된 값으로 새로운 텍스트 노드나 DOM 요소를 생성합니다. d.RawSet의point.start와point.end사이의 기존 내용을 모두 지우고, 새로 생성된 노드를 삽입합니다.
이 과정을 모든 RawSet에 대해 반복하면, 템플릿은 완전히 렌더링된 DOM으로 변환됩니다.
// 개념적인 1회성 렌더링 함수
function initialRender(templateString, data) {
const targetElement = document.createElement('div');
targetElement.innerHTML = templateString;
const rawSets = RawSet.checkPointCreates(targetElement, data, config);
for (const rawSet of rawSets) {
// rawSet.render는 내부적으로 표현식을 평가하고 DOM을 교체합니다.
rawSet.render(data, config);
}
return targetElement;
}
지금까지 우리는 템플릿을 분석하고, 동적 단위를 RawSet으로 정의했으며, 이를 기반으로 DOM을 생성하는 방법을 구현했습니다. 이제 다음 장에서는 1장에서 만든 반응성 시스템과 2장에서 만든 템플릿 엔진을 결합하여, 데이터 변경에 따라 DOM이 자동으로 업데이트되는 진정한 반응형 프레임워크의 심장을 만들어 보겠습니다.
제3장: 반응성 주입 - DomRenderProxy의 탄생
지금까지 우리는 두 개의 독립적인 조각, 즉 데이터 변경을 감지하는 '반응성 시스템'과 데이터를 화면에 그리는 '템플릿 엔진'을 만들었습니다. 이 장에서는 이 둘을 하나로 엮어 dom-render의 핵심인 DomRenderProxy를 완성합니다. 이 과정을 통해 데이터의 변경이 어떻게 자동으로 DOM 업데이트로 이어지는지 그 비밀을 파헤칩니다.
3.1. 1장과 2장의 결합
핵심 아이디어는 간단합니다. "데이터에 접근(get)할 때, 어떤 RawSet이 그 데이터에 의존하는지 기록하고, 데이터가 변경(set)될 때, 기록된 모든 RawSet을 다시 렌더링한다."
이를 위해 1장에서 만든 Proxy 핸들러와 activeEffect 개념을 RawSet 렌더링 과정에 통합합니다.
RawSet.render메소드 수정:RawSet을 렌더링하기 직전에, 해당RawSet자신을 전역 변수(예:activeRawSet)에 할당합니다.Proxy의get트랩 수정:get트랩은 이제activeEffect대신activeRawSet을 확인합니다. 만약activeRawSet이 존재한다면, 현재 접근 중인 데이터 속성(예:user.name)과activeRawSet을 의존성 맵에 연결합니다.Proxy의set트랩 수정:set트랩은 변경된 속성(예:user.name)에 연결된 모든RawSet을 의존성 맵에서 찾아, 각각의render메소드를 다시 호출합니다.
// DomRenderProxy.ts의 개념적 흐름
let activeRawSet = null; // 현재 렌더링 중인 RawSet
class DomRenderProxy {
// ... Proxy 핸들러 ...
get(target, property) {
// 2. get 트랩: activeRawSet이 있다면 의존성 기록
if (activeRawSet) {
track(target, property, activeRawSet);
}
return target[property];
}
set(target, property, value) {
target[property] = value;
// 3. set 트랩: 의존하는 모든 RawSet을 찾아 재렌더링
const dependentRawSets = findDependentRawSets(target, property);
dependentRawSets.forEach(rawSet => rawSet.render());
return true;
}
}
class RawSet {
render(obj, config) {
// 1. 렌더링 직전, 자신을 activeRawSet으로 설정
activeRawSet = this;
// ... 템플릿 표현식 평가 및 DOM 생성 로직 ...
// 이 과정에서 obj의 속성에 접근하면 Proxy의 get 트랩이 호출됨
const value = ScriptUtils.evalReturn(this.expression, obj);
// ...
// 렌더링 완료 후, activeRawSet을 초기화
activeRawSet = null;
}
}
3.2. 재귀적 Proxy 적용
user.profile.name과 같이 중첩된 객체의 변경도 감지해야 합니다. 이는 Proxy를 재귀적으로 적용하여 해결합니다.
DomRenderProxy의 set 트랩이나 객체 초기화 시점에, 새로 할당되는 값이 객체이고 아직 Proxy가 아니라면, 그 객체에 대해서도 새로운 DomRenderProxy를 생성하여 감싸줍니다.
이때 중요한 것은 부모-자식 관계를 의존성 맵에 기록하는 것입니다. dom-render에서는 이를 _domRender_ref 속성에 저장합니다. 예를 들어, user 객체의 profile 속성에 새로운 객체가 할당되면, profile 객체의 Proxy는 user 객체를 자신의 부모로 참조합니다.
이렇게 하면, user.profile.name이 변경되었을 때, profile 객체의 set 트랩이 먼저 호출되고, 이 변경은 부모인 user 객체에게도 전파될 수 있습니다. DomRenderProxy의 root 메소드는 이러한 참조 관계를 역으로 거슬러 올라가, 최종적으로 어떤 RawSet이 재렌더링되어야 하는지를 찾는 복잡한 로직을 수행합니다.
3.3. DomRender.run의 완성
이제 모든 조각을 맞춰 최종적인 진입점(Entry Point)인 DomRender.run 클래스 메소드를 완성할 수 있습니다.
DomRender.run(rootObject, targetElement, config)가 호출되면 일어나는 일은 다음과 같습니다.
DomRenderProxy생성: 사용자가 전달한 순수 데이터 객체(rootObject)를DomRenderProxy로 감싸서 반응형 객체로 만듭니다.초기 렌더링: a.
targetElement의innerHTML을 파싱하여 초기RawSet들을 생성합니다 (RawSet.checkPointCreates). b. 생성된 모든RawSet의render메소드를 처음으로 호출합니다.의존성 수집: 초기 렌더링 과정에서 각
RawSet이 템플릿 표현식을 평가하기 위해 반응형 객체의 속성에 접근(get)할 때마다, 의존성 맵이 자동으로 채워집니다.반환:
Proxy로 감싸진 반응형 객체를 사용자에게 반환합니다.
이제 사용자가 반환된 객체의 속성을 변경하면, set 트랩이 자동으로 호출되고, 의존성 맵에 따라 필요한 RawSet들이 정밀하게 재렌더링됩니다. 이로써 데이터와 DOM이 완벽하게 동기화되는 반응형 시스템이 완성되었습니다.
다음 장에서는 dr-if, dr-for와 같은 지시어(Directive)를 구현하여, 단순한 값 치환을 넘어 프로그램적인 제어 흐름을 템플릿에 도입하는 방법을 알아보겠습니다.
제4장: 제어 흐름 구현 - 지시어(Directive) 시스템
지금까지 만든 템플릿 엔진은 데이터를 DOM에 표시할 수는 있지만, 조건에 따라 요소를 보여주거나 숨기고, 배열 데이터를 목록으로 표시하는 등의 동적인 구조 변경은 불가능합니다. 이 장에서는 dr-if, dr-for-of와 같은 특별한 HTML 속성, 즉 지시어(Directive)를 구현하여 템플릿에 프로그래밍적인 제어 흐름을 부여합니다.
4.1. Operator(연산자) 패턴 설계
dr-if, dr-for-of 등 각 지시어는 고유한 로직을 가집니다. 이를 체계적으로 관리하기 위해 dom-render는 Operator(연산자) 패턴을 사용합니다.
각 지시어(
dr-if,dr-for-of등)는 자신만의 로직을 가진Operator클래스(예:DrIf,DrForOf)와 일대일로 대응됩니다.RawSet이 렌더링될 때, 자신의 엘리먼트에 어떤dr-*속성이 있는지 확인하고, 해당하는Operator클래스를 인스턴스화하여 실행을 위임합니다.이 패턴은 각 지시어의 로직을 독립적으로 분리하여 코드의 유지보수성과 확장성을 높입니다. 새로운 지시어를 추가하고 싶다면, 새로운
Operator클래스를 만들기만 하면 됩니다.
OperatorExecuter는 모든 Operator 클래스가 상속받는 추상 클래스로, execute라는 공통 메소드를 정의하여 렌더링 로직 실행을 표준화합니다.
4.2. dr-if 구현하기
dr-if는 가장 기본적인 제어 흐름 지시어입니다. DrIf Operator의 execute 메소드는 다음과 같은 로직을 수행합니다.
표현식 평가:
dr-if속성의 값(예:"this.user.isLoggedIn")을 JavaScript 표현식으로 평가하여true또는false값을 얻습니다.DOM 조작:
결과가
true일 때: 템플릿 엘리먼트의 복사본(cloneNode)을 만들고, 이 복사본을RawSet의 위치(시작과 끝 주석 사이)에 삽입합니다. 그리고 이 새로운 DOM 조각에 대해RawSet.checkPointCreates를 재귀적으로 호출하여 내부의 다른 동적 요소들도 처리하도록 합니다.결과가
false일 때:RawSet의 위치에 있는 모든 DOM 노드를 제거합니다.
상태 저장:
DrIfOperator는 이전에 평가된 값을 기억하고 있어야 합니다. 만약 이전 값과 현재 값이 같다면, DOM을 조작할 필요가 없므로 렌더링을 건너뛰어 성능을 최적화합니다.
// DrIf.ts의 개념적 로직
class DrIf extends OperatorExecuterAttrRequire<string> {
async executeAttrRequire(attr: string): Promise<ExecuteState> {
// 1. 표현식 평가
const condition = ScriptUtils.evalReturn(attr, this.source.obj);
// 3. 이전 상태와 비교하여 변경이 없으면 중단
if (this.rawSet.data === condition) {
return ExecuteState.STOP;
}
this.rawSet.data = condition; // 현재 상태 저장
// 2. DOM 조작
if (condition) {
// 엘리먼트 복사 및 삽입
const newElement = this.elementSource.element.cloneNode(true);
// ... newElement에서 dr-if 속성 제거 ...
this.returnContainer.fag.append(newElement);
} else {
// 자식 노드 없음 (결과적으로 아무것도 렌더링하지 않음)
}
// ... 부모 노드에 최종 DocumentFragment를 연결하고, 재귀적 RawSet 생성 ...
return ExecuteState.EXECUTE;
}
}
4.3. dr-for-of 와 dr-appender 구현하기
dr-for-of는 배열 데이터를 다루기 때문에 dr-if보다 복잡합니다. DrForOf Operator는 다음과 같이 동작합니다.
배열 데이터 평가:
dr-for-of속성의 값(예:"this.users")을 평가하여 순회할 배열을 얻습니다.루프 실행: 배열의 각 아이템에 대해 다음을 반복합니다. a. 원본 엘리먼트를
cloneNode(true)로 복제합니다. b. 스코프 생성: 복제된 엘리먼트 내부에서#it#이나#nearForOfIndex#같은 특수 변수를 사용할 수 있도록 처리합니다.dom-render는 템플릿을 파싱할 때 이런 변수들을 임시 고유 문자열로 바꾸고, 렌더링 시점에 실제 값(배열의 현재 아이템, 인덱스 등)으로 다시 치환하는 방식을 사용합니다. c. 처리된 엘리먼트를 최종 결과물인DocumentFragment에 추가합니다.DOM 교체: 반복이 끝나면, 생성된
DocumentFragment로RawSet의 위치에 있는 기존 DOM을 통째로 교체합니다.
// DrForOf.ts의 개념적 로직
class DrForOf extends OperatorExecuterAttrRequire<string> {
async executeAttrRequire(attr: string): Promise<ExecuteState> {
// 1. 배열 데이터 평가
const collection = ScriptUtils.evalReturn(attr, this.source.obj);
if (collection) {
let index = -1;
for (const item of collection) {
index++;
// 2a. 엘리먼트 복제
const newElement = this.elementSource.element.cloneNode(true);
// 2b. 스코프 변수 치환
// 'destIt'은 '#it#'에 해당하는 실제 데이터 경로 (예: 'this.users[0]')
const destIt = `${attr}[${index}]`;
newElement.innerHTML = newElement.innerHTML.replace(/#it#/g, destIt).replace(/#nearForOfIndex#/g, index);
// ... 다른 속성들도 치환 ...
// 2c. 최종 결과물에 추가
this.returnContainer.fag.append(newElement);
}
}
// ... DOM 교체 및 재귀적 RawSet 생성 ...
return ExecuteState.EXECUTE;
}
}
dr-appender: 추가(Append)에 최적화된 리스트 렌더링
dr-for-of는 배열이 변경될 때마다 전체 목록을 다시 그리는 단순한 접근 방식을 사용합니다. 이는 데이터가 적을 때는 문제가 없지만, 아이템이 많은 리스트에 새로운 항목 하나를 추가하는 경우 매우 비효율적일 수 있습니다.
dr-appender는 이러한 문제를 해결하기 위해 고안된, "추가" 작업에 특화된 지시어입니다. DrAppender Operator는 dr-for-of를 영리하게 활용하여 전체를 다시 그리지 않고 새 항목만 추가합니다.
동적
dr-for-of생성:DrAppender는 템플릿을 직접 반복 실행하지 않습니다. 대신, 런타임에dr-for-of속성을 가진 새로운 엘리먼트를 생성합니다.마지막 항목만 타겟팅: 생성된
dr-for-of는 전체 배열이 아닌, 배열의 마지막 항목(appender[appender.length - 1])만을 가리키도록 설정됩니다.다음 항목 예약: 동시에
dr-option-next라는 내부용 속성을 추가하여, 다음 렌더링 사이클에서 처리해야 할 다음 항목(appender,appender.length)을 예약합니다.
이 방식을 통해, Appender 객체에 push가 발생했을 때, 프레임워크는 전체 리스트를 다시 그리는 대신 마지막에 추가된 항목에 대해서만 렌더링 로직을 수행하게 됩니다. 이는 Keyed-Diffing의 완전한 구현은 아니지만, 리스트의 끝에 데이터를 추가하는 흔한 시나리오에서 매우 효과적인 최적화 기법입니다.
고급 주제: Keyed-Diffing 실제 고성능 프레임워크에서는
dr-for-of를 구현할 때, 배열의 아이템이 추가/삭제/순서 변경될 때마다 모든 DOM을 지우고 새로 그리지 않습니다. 각 아이템에 고유한key를 부여하고, 이전 렌더링 결과와 비교하여 최소한의 DOM 조작(이동, 추가, 삭제)만 수행하는 Keyed-Diffing 알고리즘을 사용합니다.dom-render는 현재 이 방식을 완전히 구현하지는 않았지만, 이는 프레임워크 성능 최적화의 핵심적인 다음 단계가 될 수 있습니다.
이제 우리의 프레임워크는 단순한 데이터 표시를 넘어, 조건과 반복을 통해 동적인 UI 구조를 만들어낼 수 있게 되었습니다. 다음 장에서는 이 모든 것을 재사용 가능한 캡슐화된 단위, 즉 '컴포넌트'로 만드는 방법을 탐구하여 프레임워크에 생명과 영혼을 불어넣겠습니다.
제5장: 생명과 영혼을 불어넣다 - 컴포넌트 시스템
애플리케이션이 복잡해지면 코드의 재사용성과 관리가 중요해집니다. 컴포넌트(Component)는 UI를 독립적이고 재사용 가능한 조각으로 나눈 단위로, 현대 프레임워크의 가장 중요한 개념 중 하나입니다. 이 장에서는 로직(JavaScript 클래스)과 뷰(HTML 템플릿)를 캡슐화하여 dom-render의 컴포넌트 시스템을 구축하는 방법을 알아봅니다.
5.1. DomRender.createComponent 설계
컴포넌트는 본질적으로 "어떤 태그 이름이 사용되었을 때, 어떤 클래스를 인스턴스화하고 어떤 템플릿으로 렌더링할 것인가"에 대한 규칙입니다. DomRender.createComponent는 이 규칙을 정의하는 팩토리 함수입니다.
여기서 주목할 점은 dom-render가 브라우저의 내장 Web Component 표준(Custom Elements, Shadow DOM 등)을 직접 사용하지 않고, 자체적인 컴포넌트 메커니즘을 구현한다는 것입니다. 이는 프레임워크가 렌더링 과정과 스코프 관리에 대한 완전한 제어권을 가지며, 특정 브라우저 환경에 덜 의존적인 일관된 동작을 보장하기 위함입니다.
// DomRender.ts
public static createComponent(param: CreateComponentParam) {
// CreateComponentParam: { type: 클래스, tagName: 'custom-tag', template: '...', styles: '...' }
const component = RawSet.createComponentTargetElement({
name: param.tagName, // 예: 'profile-card'
objFactory: (element, obj, rawSet, constructorParam) => {
// 이 함수는 <profile-card> 태그를 만났을 때 호출됩니다.
// param.type에 해당하는 클래스를 인스턴스화하여 반환합니다.
return new param.type(...constructorParam);
},
template: param.template, // 이 컴포넌트의 내부 HTML 템플릿
styles: param.styles, // 이 컴포넌트에만 적용될 스타일
noStrip: param.noStrip
});
return component;
}
이렇게 생성된 "규칙" 객체는 DomRender.run의 config.targetElements 배열에 추가됩니다. 렌더링 과정에서 DrTargetElement Operator는 HTML 태그 이름이 config.targetElements에 등록된 이름과 일치하는지 확인하고, 일치한다면 해당 컴포넌트의 callBack (내부적으로 objFactory 호출)을 실행하여 컴포넌트를 인스턴스화하고 렌더링합니다.
5.2. 생명주기(Lifecycle) 훅 구현
컴포넌트는 생성되고, DOM에 추가되고, 데이터가 변경되며, 결국 DOM에서 제거되는 생명주기를 가집니다. 프레임워크는 이 생명주기의 특정 시점에 사용자가 정의한 코드를 실행할 수 있도록 생명주기 훅(Lifecycle Hook)을 제공해야 합니다.
dom-render는 인터페이스(Interface)를 통해 이를 구현합니다.
OnCreateRender: 컴포넌트 인스턴스가 생성되고, 초기 데이터가 설정된 직후에 호출됩니다. DOM에 실제로 삽입되기 전이므로, 초기화 로직에 적합합니다.OnInitRender: 컴포넌트와 그 모든 자식 요소의 렌더링이 완료된 후 호출됩니다. DOM 요소에 직접 접근해야 하는 로직(예:canvas컨텍스트 가져오기)을 여기에 배치합니다.OnDestroyRender: 컴포넌트가 DOM에서 제거될 때 호출됩니다. 이벤트 리스너를 해제하거나 타이머를 정리하는 등의 클린업(clean-up) 로직을 수행합니다.
구현은 간단합니다. DrTargetElement Operator가 컴포넌트를 처리하는 각 단계에서, 해당 컴포넌트 인스턴스가 특정 생명주기 인터페이스의 메소드(예: onCreateRender)를 가지고 있는지 확인하고, 있다면 호출해주는 방식입니다.
// DrTargetElement.ts의 개념적 로직
// ... 컴포넌트 인스턴스 생성 후 ...
const instance = objFactory(...);
// OnCreateRender 훅 호출
if (isOnCreateRender(instance)) {
instance.onCreateRender(...);
}
// ... 렌더링 완료 후 ...
// OnInitRender 훅 호출
if (isOnInitRender(instance)) {
instance.onInitRender(...);
}
5.3. 스코프 격리와 데이터 흐름
컴포넌트는 독립적인 스코프(Scope)를 가져야 합니다. 컴포넌트 내부의 this는 외부가 아닌 컴포넌트 인스턴스 자신을 가리켜야 합니다. dr-this 지시어는 이 스코프 변경을 담당합니다.
DrThis Operator는 dr-this 속성에 지정된 객체(예: this.__domrender_components.some_uuid)를 새로운 렌더링 컨텍스트로 설정하고, 그 자식 노드들을 해당 컨텍스트에서 렌더링합니다.
데이터 흐름(Props)은 어떻게 구현할까요? dom-render는 dr-on-create:callback이나 dr-detect 같은 속성을 통해 이를 해결합니다. 부모 스코프에서 자식 컴포넌트의 속성을 설정하는 스크립트를 실행하는 방식입니다.
<!-- 부모 템플릿 -->
<profile-card dr-detect="$component.age = @this@.age"></profile-card>
위 코드에서 $component는 profile-card 컴포넌트의 인스턴스를, @this@는 부모의 컨텍스트를 가리킵니다. DrTargetElement Operator는 컴포넌트를 렌더링할 때 이 dr-detect 스크립트를 평가하여 부모의 age를 자식의 age에 할당합니다. 부모의 age가 반응형 데이터라면, 이 변경은 자동으로 자식에게 전파됩니다.
5.4. 격리된 스타일(Scoped Styles) 구현
컴포넌트의 스타일이 다른 컴포넌트에 영향을 주지 않도록 하려면 스타일을 해당 컴포넌트 내부에만 적용해야 합니다. 이를 스코프 스타일(Scoped Styles)이라고 합니다.
dom-render는 컴포넌트 렌더링 시 고유한 UUID를 생성하고, 이를 이용해 스타일을 격리합니다.
CSS 파싱:
DomRender.createComponent에 전달된 CSS 문자열을css-parse라이브러리를 사용해 AST(추상 구문 트리)로 변환합니다.셀렉터 변환: CSS AST를 순회하며 모든 셀렉터(예:
.title,p)를 변환합니다. 예를 들어,.title은#component-uuid-start ~ .title:not(#component-uuid-start ~ #component-uuid-end ~ *)와 같은 복잡한 형태로 변경됩니다. 이 셀렉터는 "#component-uuid-start요소 뒤에 있으면서, 그 컴포넌트의 끝을 나타내는#component-uuid-end요소의 자손은 아닌.title요소"를 의미합니다.CSS 재생성 및 삽입: 변환된 AST를
css-stringify를 사용해 다시 CSS 문자열로 만들고, 이 내용을 담은<style>태그를 컴포넌트의 템플릿 최상단에 삽입합니다.
이 과정을 통해 각 컴포넌트는 자신만의 고유한 스타일 스코프를 갖게 되어 스타일 충돌 문제를 원천적으로 방지합니다.
이제 우리의 프레임워크는 재사용 가능하고, 캡슐화되었으며, 자체적인 생명주기와 스타일을 갖는 컴포넌트를 만들어낼 수 있게 되었습니다. 마지막 장에서는 라우팅, 상태 관리 등 프레임워크를 완성하는 고급 기능들을 구현하고 전체 프로젝트를 빌드하는 방법을 다루겠습니다.
제6장: 프레임워크 완성하기
지금까지 우리는 반응성 시스템, 템플릿 엔진, 컴포넌트 모델이라는 프레임워크의 3대 핵심 요소를 모두 구축했습니다. 이 마지막 장에서는 애플리케이션을 구조화하는 데 필수적인 라우팅과 컴포넌트 간 통신 기능을 추가하고, 최적화 및 빌드 과정을 통해 우리의 프레임워크를 실제 사용 가능한 형태로 완성합니다.
6.1. 라우터(PathRouter, HashRouter) 설계
SPA(Single Page Application)는 여러 페이지를 가진 것처럼 보이지만 실제로는 하나의 HTML 파일 위에서 동작합니다. 라우터는 URL의 변경을 감지하고, 그에 맞는 컴포넌트를 화면에 렌더링하는 역할을 합니다.
dom-render의 라우터는 다음과 같이 설계됩니다.
URL 변경 감지: 브라우저의
popstate이벤트를 리스닝합니다. 이 이벤트는 사용자가 브라우저의 '뒤로 가기'/'앞으로 가기' 버튼을 누르거나,history.pushState()가 호출될 때 발생합니다.추상 클래스와 구현체:
Router라는 추상 클래스를 만들어go,test,getRouteData등 공통 인터페이스를 정의합니다. 그리고 URL 형식을 다루는 방식에 따라 두 개의 구현체를 만듭니다.HashRouter:example.com/#/path와 같이 해시(#)를 이용한 라우팅.window.location.hash를 읽어 경로를 파악합니다.PathRouter:example.com/path와 같이 표준 URL 경로를 이용한 라우팅.window.location.pathname을 읽어 경로를 파악하며, 서버 설정이 필요합니다.
렌더링과의 연동: 라우터는
dr-if지시어와 함께 사용될 때 가장 강력합니다.$router라는 특수 변수를 템플릿에 노출시켜, 다음과 같이 선언적인 라우팅을 구현합니다.<main> <!-- 현재 URL이 '/'일 때만 main-page 컴포넌트를 렌더링 --> <main-page dr-if="$router.test('/')"></main-page> <!-- URL이 '/users/{id}' 패턴과 일치할 때 user-detail 컴포넌트를 렌더링 --> <user-detail dr-if="$router.test('/users/{id}')"></user-detail> </main>$router.test()메소드는 내부적으로 현재 URL과 주어진 URL 표현식을 비교하여true또는false를 반환합니다.popstate이벤트가 발생하면, 라우터는 반응성 객체의 특정 속성(예:_router_update_trigger)을 변경하여$router변수에 의존하는 모든dr-if지시어가 다시 평가되도록 유도합니다. 이를 통해 URL 변경에 따라 적절한 컴포넌트가 자동으로 렌더링됩니다.
6.2. 메신저(Messenger) 시스템
부모-자식 관계가 아닌, 멀리 떨어진 컴포넌트 간에 데이터를 주고받아야 할 때가 있습니다. 이를 위해 dom-render는 발행/구독(Publish/Subscribe) 패턴을 사용하는 Messenger 시스템을 제공합니다.
Messenger: 모든 통신을 중재하는 중앙 허브입니다.Channel: 특정 주제(주로 컴포넌트 클래스나 고유 키)에 대한 통신 채널입니다. 컴포넌트는 자신만의 채널을 생성할 수 있습니다.publish: 특정 채널에 데이터를 발행(전송)합니다.messenger.publish(TargetComponent, { message: 'Hello' })와 같이 사용합니다.subscribe: 특정 채널을 구독하여 데이터가 발행될 때마다 콜백 함수를 실행합니다.messenger.createChannel(this).subscribe(data => ...)와 같이 사용합니다.
이 시스템은 컴포넌트 간의 직접적인 결합(coupling)을 없애주어, 애플리케이션 구조를 더 유연하고 확장 가능하게 만듭니다.
6.3. 최적화와 예외 처리
프레임워크는 안정적이어야 합니다. dom-render는 몇 가지 장치를 통해 성능을 최적화하고 예외적인 상황에 대처합니다.
DomRenderFinalProxy,Shield: 모든 객체를Proxy로 감싸는 것은 때로 불필요하거나 성능 저하를 유발할 수 있습니다. (예: 외부 라이브러리 객체, 거대한 데이터 구조).DomRenderProxy.final()이나new Shield()로 객체를 감싸면, 해당 객체는 더 이상Proxy로 변환되지 않아 반응성 추적에서 제외됩니다. 이는 "탈출구"를 제공하여 개발자가 성능을 미세 조정할 수 있게 합니다.proxyExcludeTyps:config객체에 특정 클래스(예:Window,Map,Set)를 등록하면, 해당 클래스의 인스턴스는Proxy변환에서 자동으로 제외됩니다. 이는Maximum call stack size exceeded같은 무한 재귀 오류를 방지하는 데 중요합니다.
6.4. 빌드 및 배포
마지막으로, 우리가 작성한 TypeScript 소스 코드를 브라우저가 이해할 수 있는 단일 JavaScript 파일로 변환해야 합니다. 이 과정을 번들링(Bundling)이라고 하며, webpack 같은 도구를 사용합니다.
tsconfig.json: TypeScript 컴파일러의 설정을 정의합니다.target: "ESNext"는 최신 JavaScript 문법을 사용하도록 하고,module: "esnext"는 모듈 시스템을 정의합니다.experimentalDecorators와emitDecoratorMetadata는 데코레이터 문법을 사용하기 위해 필요합니다.webpack.config.js: Webpack의 동작을 설정합니다.entry: 번들링을 시작할 진입점 파일(예:index.ts)을 지정합니다.output: 결과물 파일의 이름과 저장될 경로를 지정합니다.resolve:import시.ts나.js같은 확장자를 생략할 수 있도록 설정합니다.module.rules:.ts파일은ts-loader를 통해 TypeScript에서 JavaScript로 변환하고,.css나.html파일은 텍스트로 불러올 수 있도록 설정합니다.
npm run build 같은 명령어를 실행하면, Webpack은 entry 파일부터 시작해 모든 import된 파일들을 따라가며 하나의 파일로 합치고 변환하여 최종 결과물을 만들어냅니다.
축하합니다! 이 장을 끝으로 우리는 반응성 코어부터 컴포넌트 시스템, 라우팅, 빌드 과정에 이르기까지 현대적인 프론트엔드 프레임워크의 모든 핵심 요소를 직접 설계하고 그 원리를 탐구했습니다. 이 여정을 통해 얻은 지식은 여러분이 어떤 프레임워크를 사용하든 그 내부를 더 깊이 이해하고, 더 나은 소프트웨어 아키텍처를 설계하는 데 훌륭한 밑거름이 될 것입니다.
부록: 더 나아가기
이 책을 통해 우리는 dom-render라는 프레임워크의 핵심을 처음부터 끝까지 만들어보는 여정을 함께했습니다. 이 부록에서는 dom-render의 아키텍처를 객관적으로 평가하고, 앞으로 프레임워크를 어떻게 더 발전시킬 수 있을지, 그리고 프레임워크 개발자로서 어떻게 성장할 수 있을지에 대한 아이디어를 공유합니다.
A. dom-render 아키텍처의 장단점
모든 기술적 선택에는 장점과 단점이 따릅니다. 우리가 만든 프레임워크도 예외는 아닙니다.
장점
학습 곡선:
Proxy와 순수 JavaScript 객체를 기반으로 하므로, 특별한 API나 개념(예:useState,useEffect)을 많이 배울 필요 없이 직관적으로 사용할 수 있습니다.성능: 가상 DOM이 없어 메모리 사용량이 적고, 정밀한 의존성 추적 덕분에 불필요한 렌더링이 발생하지 않습니다. 간단한 애플리케이션에서는 매우 뛰어난 성능을 보입니다.
유연성: 핵심 로직이 특정 뷰 라이브러리에 종속되지 않습니다.
Operator패턴과config객체를 통해 프레임워크의 동작을 쉽게 확장하거나 수정할 수 있습니다.HTML 친화적: 모든 로직이 HTML 템플릿 안에 선언적으로 작성되므로, 디자이너나 마크업 개발자와의 협업이 용이합니다.
단점
디버깅의 어려움:
Proxy는 객체의 동작을 투명하게 가로채기 때문에, 데이터의 변경이 어디서 시작되었는지 추적하기가 어려울 수 있습니다. 브라우저 개발자 도구에서Proxy객체를 검사하는 것도 일반 객체보다 복잡합니다.정적 분석의 한계: 템플릿 내의 표현식이 문자열로 처리되므로, 컴파일 타임에 오류를 잡거나 타입을 체크하기가 어렵습니다. (TypeScript의 이점을 완전히 살리지 못할 수 있습니다.)
최적화의 부재: 현재
dr-for-of구현은 배열이 변경될 때마다 전체 목록을 다시 렌더링합니다. 수백, 수천 개의 아이템을 다룰 경우 성능 문제가 발생할 수 있습니다.생태계: 성숙한 프레임워크들이 제공하는 방대한 라이브러리, 개발자 도구, 커뮤니티 지원이 부족합니다.
B. 성능 개선을 위한 아이디어
dom-render를 한 단계 더 발전시키기 위한 몇 가지 아이디어입니다.
DOM 업데이트 배치(Batching): 짧은 시간 안에 여러 데이터가 연속으로 변경될 때(예: 루프 안에서 속성 값 변경), DOM 업데이트를 한 번에 모아서 실행하는 스케줄러를 도입할 수 있습니다.
requestAnimationFrame을 사용하면 브라우저의 다음 페인트 시점에 맞춰 렌더링을 동기화하여 성능을 향상시킬 수 있습니다.Keyed-Diffing 알고리즘 구현:
dr-for-of지시어에key속성을 도입하여, 배열의 각 아이템에 고유한 식별자를 부여합니다. 배열이 변경되면, 이전 DOM 노드와 새로운 데이터 배열을key를 기준으로 비교하여, 이동, 추가, 삭제가 필요한 노드를 최소한으로 계산하여 DOM을 조작합니다. 이는 리스트 렌더링 성능을 극적으로 향상시키는 가장 중요한 최적화입니다.컴파일러 도입: 빌드 시점에 템플릿을 정적으로 분석하는 간단한 컴파일러를 도입할 수 있습니다. 예를 들어,
${this.name}$같은 표현식을_renderTextNode(this.name)같은 함수 호출로 변환하면, 런타임에 정규표현식으로 파싱하는 비용을 줄일 수 있고, 더 많은 최적화 기회를 얻을 수 있습니다.
C. 프레임워크 개발자로서의 성장 로드맵
프레임워크를 직접 만들어 본 경험은 여러분을 더 나은 개발자로 만들어 줄 것입니다. 여기서 멈추지 말고 다음 단계로 나아가 보세요.
다른 프레임워크 소스 코드 읽기: 이제 여러분은 React, Vue, Svelte의 소스 코드를 읽을 수 있는 기초 체력을 갖췄습니다. 그들이 같은 문제를 어떻게 다른 방식으로 해결했는지 비교 분석해보세요. 특히 렌더링 스케줄러, 동시성(Concurrency) 처리, 상태 관리 라이브러리(Redux, Pinia)의 구현은 많은 영감을 줄 것입니다.
브라우저 렌더링 과정 깊이 이해하기: 브라우저가 HTML을 파싱하고, CSS를 계산하며, 픽셀을 화면에 그리기까지의 과정(Critical Rendering Path)을 학습하세요. 이는 프레임워크 성능 최적화의 근본적인 배경지식이 됩니다.
자신만의 작은 라이브러리 만들기: 꼭 프레임워크가 아니어도 좋습니다. 상태 관리, 라우팅, 국제화(i18n) 등 특정 문제를 해결하는 작은 라이브러리를 만들어보세요. 이는 소프트웨어 설계 능력을 연마하는 좋은 훈련이 됩니다.
프레임워크 개발은 단순히 코드를 작성하는 행위를 넘어, 문제 해결에 대한 철학과 아이디어를 아키텍처로 표현하는 예술과 같습니다. 이 책이 여러분의 즐거운 여정에 작은 등대가 되었기를 바랍니다.
소스 및 구현부는 dom-render github 에서 확인가능합니다