domwire로 서버 렌더링 페이지에 컴포넌트 연결
출처: Dev.to
domwire 은 순수 JavaScript와 TypeScript 애플리케이션을 위한 DOM 기반, 온디맨드 컴포넌트 로더입니다. 마크업에 있는 data-component 속성으로 ES6 클래스를 초기화하고, 라이프사이클을 관리하며, 해당 요소가 실제 페이지에 존재할 때만 코드를 지연 로드합니다. 런타임 의존성이 전혀 없으며, 압축 시 약 2 KB 정도의 용량을 가집니다.
이 튜토리얼에서는 라이브러리가 존재하는 이유, 해결하고자 하는 문제, 접근 방식이 어떻게 동작하는지, 그리고 API의 모든 부분을 어떻게 사용하는지를 다룹니다.
1. 문제
대부분의 웹 애플리케이션은 싱글 페이지 앱이 아닙니다. 서버‑렌더링된 사이트—Rails, Laravel, Django, WordPress, 정적 사이트—도 여전히 JavaScript 동작이 필요합니다. 여기서는 날짜 선택기, 저기서는 캐러셀, 50개의 페이지 중 하나의 폼에 자동완성 기능 등 말이죠. 이러한 코드베이스가 반드시 답해야 하는 질문은 “현재 페이지가 해당 JavaScript를 필요로 하는지 어떻게 알 수 있나요?” 입니다.
대부분의 코드베이스, 특히 경험 많은 개발자가 작성한 코드에서도 흔히 볼 수 있는 답은 다음과 같습니다:
// app.js — 모든 페이지에서 실행
import UserCard from "./components/UserCard.js";
import Carousel from "./components/Carousel.js";
import DatePicker from "./components/DatePicker.js";
import Autocomplete from "./components/Autocomplete.js";
// ...30개 더 import
document.addEventListener("DOMContentLoaded", () => {
const userCard = document.querySelector(".user-card");
if (userCard) new UserCard(userCard);
const carousel = document.querySelector(".carousel");
if (carousel) new Carousel(carousel);
document.querySelectorAll(".date-picker").forEach((el) => {
new DatePicker(el);
});
const search = document.querySelector("#search");
if (search) new Autocomplete(search, { minChars: 3 });
// ...30개 더
});
Enter fullscreen mode
Exit fullscreen mode
때때로 { selector, klass } 형태의 배열로 묶어 루프를 돌리기도 하지만, 본질은 동일합니다. 애플리케이션 전체의 모든 컴포넌트가 “내가 여기서 필요한가?” 라는 질문을 모든 페이지에 던지는 것이죠.
왜 이것이 근본적으로 어색한가
질문의 방향이 뒤바뀌었습니다. 서버는 이미 페이지에 어떤 컴포넌트가 필요한지 정확히 알고 있습니다—마크업을 렌더링했으니까요. 그러나 그 지식은 버려지고, 클라이언트는 현재 문서에 대해 정의된 모든 셀렉터를 일일이 검사하면서 다시 추론합니다. 페이지는 필요한 것을 선언 해야 하는데, 대신 모든 스크립트가 묻고 있습니다.
모든 페이지가 모든 컴포넌트 비용을 지불합니다. 위의 34개 import는 현재 페이지가 하나도 사용하지 않든 전부 사용하든 번들에 포함됩니다. 블로그 인덱스 페이지가 체크아웃 폼의 검증 로직까지 다운로드하고 파싱·실행하게 됩니다. 코드 스플리팅이 기술적으로 가능하더라도, 수동 패턴은 자연스러운 분할 지점을 제공하지 못합니다.
와이어링 파일이 병목이 됩니다. 새 컴포넌트를 추가할 때마다 같은 중앙 파일을 수정해야 합니다: import를 추가하고, 셀렉터 검사를 추가합니다. 파일은 단조롭게 커지고, 머지 충돌이 그곳에 집중되며, 누가 어떤 페이지에서 어떤 셀렉터를 아직 쓰는지 확신할 수 없기 때문에 절대 삭제되지 않습니다.
구성이 비공식적인 채널을 통해 은밀히 전달됩니다. 한 컴포넌트는 data-min-chars를 읽고, 다른 컴포넌트는 전역 변수를, 또 다른 컴포넌트는 carousel--speed-3 같은 클래스명을 파싱합니다. 패턴 자체가 관례를 제공하지 않기 때문에 일관된 규칙이 없습니다.
동적으로 삽입된 마크업은 아무 일도 하지 않습니다. 셀렉터 검사는 DOMContentLoaded 시점에 한 번만 실행됩니다. 나중에 들어오는 콘텐츠—htmx 스와핑, fetch된 파셜, 무한 스크롤 페이지—는 올바른 마크업을 가지고 있어도 이미 검사가 끝났기 때문에 무시됩니다. 전체 초기화 함수를 다시 실행하거나 프레임워크 전용 재초기화 이벤트를 사용하는 일반적인 해결책은 깨지기 쉽고, 이미 살아있는 요소들을 다시 초기화합니다.
소멸 단계가 없습니다. 컴포넌트를 보유한 노드가 제거되면, window·document에 달린 이벤트 리스너, 타이머, 옵저버 등이 계속 실행됩니다. 수동 패턴에는 파괴가 일어날 수 있는 위치가 전혀 없습니다.
이러한 문제들은 특수한 엣지 케이스가 아니라, 패턴의 기본적인 실패 모드이며, 의미 있는 규모의 비프레임워크 코드베이스에서 거의 모두 나타납니다.
2. domwire 뒤에 숨은 아이디어
domwire는 질문의 방향을 뒤집습니다. 모든 컴포넌트가 “페이지야, 나를 필요로 해?” 라고 묻는 대신, 페이지가 필요한 것을 선언합니다:
Enter fullscreen mode
Exit fullscreen mode
그리고 단일 매니저가 DOM을 한 번만 순회하면서, 선언된 이름을 레지스트리에서 찾아 해당 클래스를 로드하고, 해당 요소에 인스턴스를 생성합니다:
import { ComponentManager } from "domwire";
const manager = new ComponentManager({
registry: {
UserCard: () => import("./components/UserCard.js"),
Carousel: () => import("./components/Carousel.js"),
DatePicker: () => import("./components/DatePicker.js"),
},
});
await manager.boot();
Enter fullscreen mode
Exit fullscreen mode
이것이 전체 애플리케이션의 전부 와이어링입니다. 구조적으로 바뀐 점은 세 가지입니다:
마크업이 매니페스트가 됩니다. 요소를 렌더링하는 서버‑사이드 템플릿이 동일한 위치에서 동작을 선언합니다. 서버가 가지고 있던 지식이 이제 버려지지 않습니다.
레지스트리는 클래스를 보관하지 않고 import 함수를 보관합니다. () => import(...) 은 아직 실행되지 않은 함수입니다. user-card 요소가 전혀 없으면 UserCard.js 를 다운로드하지 않습니다. 번들러는 동적 import를 감지해 각 컴포넌트를 자동으로 별도 청크로 분리합니다.
쿼리는 정확히 하나만 수행됩니다. N개의 컴포넌트마다 N개의 셀렉터 검사를 하는 대신, 부팅 시 [data-component] 를 한 번만 querySelectorAll 합니다.
3. 시작하기
설치
npm install domwire
Enter fullscreen mode
Exit fullscreen mode
컴포넌트 작성
컴포넌트는 AbstractComponent 를 상속하는 클래스입니다. 선언된 요소와 파싱된 옵션을 받습니다:
// components/UserCard.js
import { AbstractComponent } from "domwire";
export default class UserCard extends AbstractComponent {
mounted() {
this.el.textContent = `User ${this.options.id}`;
}
destroy() {
// 리스너 제거, 타이머 취소 등
}
}
Enter fullscreen mode
Exit fullscreen mode
this.el 은 data-component 속성을 가진 HTMLElement이며, this.options 는 data-options 로부터 파싱된 객체입니다. 컴포넌트는 반드시 모듈의 default export 여야 합니다—로드러가 바로 이 값을 꺼내 사용합니다.
마크업에 선언하기
Enter fullscreen mode
Exit fullscreen mode
속성값은 케밥 케이스(kebab-case)이며, 레지스트리 키는 파스칼 케이스(PascalCase) 형태(user-card → UserCard)입니다. 이렇게 하면 HTML은 관용적인 형태(HTML은 대소문