작년에 우리는 MDN의 새로운 프론트엔드를 공개했다. 가장 눈에 띄는 변화는 스타일 조정이었다. 페이지 전반의 디자인을 단순화하고 통일했다. 하지만 실제로 더 큰 변화는 독자 눈에 보이지 않는 곳, 즉 프론트엔드를 움직이는 코드 전반의 개편이었다. 이 글은 우리가 무엇을 바꿨고, 어떤 기술을 선택했으며, 왜 이런 선택을 했는지를 설명한다.
MDN 프론트엔드가 왜 크게 바뀌었는지를 이해하려면, 먼저 MDN 콘텐츠가 어떤 과정을 거쳐 지금의 웹사이트가 되는지 봐야 한다.
간단히 요약하면 다음과 같다.
- 문서는 Markdown으로 작성되고, 여러 git 저장소에서 기술 문서 작성자, 파트너, 외부 전문가, 그리고 거대한 커뮤니티 기여자 및 번역자들이 함께 관리한다.
- 빌드 도구가 이 Markdown 파일들을 읽어 HTML로 변환하고, 각 페이지에 대한 보조 메타데이터와 함께 JSON 파일 집합으로 저장한다.
- 프론트엔드는 이 JSON들을 순회하며 브라우저 호환성 표, 다국어 지원, 내비게이션 메뉴 등을 포함한 완전한 페이지를 조합한다. 우리는 이 단계를 SSR이라고 불러왔다.
- 최종적으로 생성된 HTML, CSS, JavaScript 파일은 클라우드 버킷에 업로드되고 전 세계 사용자에게 전달된다.
프론트엔드를 다시 짜야겠다는 생각은 오래전부터 있었다. 기존 프론트엔드인 yari는 React 앱이었지만, 시간이 지나며 기술 부채가 많이 쌓였다. 유지보수가 완전히 불가능한 수준은 아니었지만, 무척 고통스러웠다. 버그를 고치거나 기능을 추가할수록 오히려 기술 부채가 더 늘어나는 구조였다.
문제의 출발점은 Create React App이었다. 초기에는 충분했지만, 기본 설정이 점점 우리의 요구를 충족하지 못했다. 결국 여러 우회책이 생겼고, 나중에는 eject까지 하면서 매우 복잡한 Webpack 설정과 해키한 빌드 스크립트가 남았다.
CSS도 상황이 좋지 않았다. Sass를 광범위하게 쓰다가 CSS 변수 같은 현대 CSS 기능이 추가되면서, 서로 다른 스타일링 방식이 뒤섞였다. 스코프는 약하거나 아예 없었고, 한 컴포넌트의 스타일을 고치면 전혀 다른 컴포넌트가 같이 깨지는 일이 잦았다. CSS 분할도 잘 되지 않아, 사용자가 실제로 보지 않을 컴포넌트의 스타일까지 포함된 거대한 렌더 차단 CSS를 내려보내고 있었다.
가장 큰 문제는 React 앱이 정적 콘텐츠를 감싸는 “래퍼”에 불과했다는 점이다. 빌드 도구가 만든 HTML을 React 앱이 이해하게 만들려면, HTML을 비싼 비용으로 다시 파싱하고, 그 로직 상당 부분을 클라이언트 JavaScript로 보내야 했다. 우리는 그 길을 원하지 않았고, 결국 React의 경계는 문서 본문이 시작되는 지점에서 끝나버렸다. 실제로 콘텐츠는 dangerouslySetInnerHTML로 삽입됐다.
문서의 대부분은 정적이지만, 곳곳에는 코드 블록의 “복사” 버튼 같은 상호작용이 필요했다. 하지만 React 경계 밖의 정적 HTML 안에서 이를 구현해야 했기 때문에, React가 아니라 DOM API를 직접 쓰는 방식이 늘어났다. JSX를 사용할 수 없으니 유지보수성이 떨어졌고, 어떤 경우에는 React 버전과 DOM API 버전 구현을 둘 다 유지해야 하는 최악의 상황도 생겼다.
이 문제를 풀기 위한 해법으로 2024년부터 Lit과 웹 컴포넌트를 실험하기 시작했다. 목표는 정적 콘텐츠 안에 들어가는 인터랙션을 더 좋은 개발 경험으로 만들 수 있는지 확인하는 것이었다. 첫 실전 사례는 Scrimba와 협력한 MDN Curriculum이었다.
Scrimba의 “Scrim”은 iframe으로 삽입되는 인터랙티브 학습 환경이다. 사용자가 실제로 열기 전까지는 Scrimba로 데이터를 보내고 싶지 않았기 때문에 iframe은 클릭 이후에만 로드되게 했다. 또한 사용자가 MDN을 떠나지 않고도 이를 전체 화면처럼 쓸 수 있게 dialog 요소를 사용했다.
우리는 이를 웹 컴포넌트로 만들면, 커스텀 엘리먼트를 콘텐츠 안에 직접 넣어 렌더링 단계를 줄이고 DOM API 기반 구현의 복잡함도 피할 수 있다고 판단했다.
구현은 LitElement를 상속하는 클래스에서 시작했다.
export class MDNScrimInline extends LitElement {}
그 안에서 상태를 정의했다.
static properties = {
url: { type: String },
_fullscreen: { state: true },
_scrimLoaded: { state: true },
};
그리고 생성자에서 기본값을 설정했다.
constructor() {
super();
this.url = undefined;
this._fullscreen = false;
this._scrimLoaded = false;
}
Lit의 라이프사이클 메서드를 이용해 URL을 가공하고:
willUpdate(changedProperties) {
if (changedProperties.has("url")) {
if (this.url) {
const url = new URL(this.url);
url.searchParams.set("via", "mdn");
url.searchParams.set("embed", "");
this._fullUrl = url.toString();
} else {
this._fullUrl = undefined;
}
}
}
렌더링에서는 다음처럼 dialog, 버튼, iframe을 선언적으로 조합했다.
return html`
<dialog @close=${this.#dialogClosed}>
<div class="inner">
<div class="header">
<span>Clicking will load content from scrimba.com</span>
<button tabindex="0" @click=${this.#toggle} class="toggle">
Toggle fullscreen
</button>
<a href=${this._fullUrl}>Open on Scrimba</a>
</div>
<div class="body">
${this._scrimLoaded
? html`<iframe src=${this._fullUrl}></iframe>`
: html`<button @click=${this.#open}>Load scrim and open dialog.</button>`}
</div>
</div>
</dialog>
`;
@close, @click 같은 문법은 Lit의 이벤트 바인딩 문법이다. 관련 메서드는 다음처럼 단순하다.
#toggle() {
if (this._fullscreen) this.#close();
else this.#open();
}
#open() {
const dialog = this.renderRoot.querySelector("dialog");
if (dialog) {
dialog.showModal();
this._scrimLoaded = true;
this._fullscreen = true;
}
}
#close() {
const dialog = this.renderRoot.querySelector("dialog");
dialog?.close();
}
#dialogClosed() {
this._fullscreen = false;
}
사용자가 Scrim을 열면 Lit이 상태 변화를 감지해 자동으로 다시 렌더링하고 iframe을 붙인다. 전통적인 DOM API로 직접 짰다면 훨씬 더 번거로웠을 구현이었다. 상태도 비교적 단순해서 React보다 Lit 쪽이 오히려 더 자연스럽게 느껴졌다. 그리고 가장 중요한 점은, 이제 이런 인터랙션을 콘텐츠 안에 커스텀 엘리먼트로 직접 넣을 수 있게 됐다는 것이다.
<scrim-inline
url="https://v2.scrimba.com/the-frontend-developer-career-path-c0j/~0lr"
scrimtitle="The Request-Response Cycle"></scrim-inline>
다음 과제는 “Try it” 인터랙티브 예제였다. CSS, JavaScript, HTML 문서 상단에 나오는 이 예제들은 저자와 커뮤니티가 작성하고 디버깅하기가 매우 어려웠다. 구현이 네 개의 저장소에 분산돼 있었고, 예제 하나를 고치려면 여러 곳에 동기화된 변경이 필요했다. 게다가 예제는 문서 본문과 분리된 상태에서 작성됐기 때문에, 실제 문서 안에서 예제가 어떻게 보일지를 한 번에 확인하기 어려웠다.
우리는 여기에도 Lit을 적용했다. 목표는 저자가 문서 안에서 직접 인터랙티브 예제를 기술하게 만드는 것이었다. 이 작업은 Scrim보다 훨씬 복잡했다. JavaScript 예제를 위한 코드 에디터 + 콘솔 템플릿, HTML 예제를 위한 탭형 에디터 + 결과 영역, CSS 예제를 위한 선택형 코드 편집 테이블 등 여러 템플릿이 필요했다. 여기에 기존 Playground 렌더링 로직까지 React에서 웹 컴포넌트 기반으로 옮겨야 했다.
다행히 Lit은 React 통합도 제공했다. 그래서 기존 React 앱 안에서 웹 컴포넌트를 단계적으로 렌더링하면서 점진적으로 포팅할 수 있었다. 우리는 엉켜 있던 Playground 컴포넌트를 아래와 같은 작은 커스텀 엘리먼트들로 쪼갰다.
- CodeMirror 기반 에디터
- 콘솔 메시지 렌더러
- 현재 에디터 상태를 렌더링하는 엘리먼트
- 위 엘리먼트들 사이의 이벤트와 상태를 중계하는 컨트롤러
이렇게 나누자 로직이 훨씬 단순해졌고, reusable한 형태가 됐다. 그 위에 interactive-example 엘리먼트를 얹어서, 페이지의 code 블록을 찾아 읽고 어떤 템플릿을 그려야 하는지 결정하게 만들 수 있었다.
이제 작성자는 문서에 매크로와 코드 블록을 그대로 배치하면 된다.
{{InteractiveExample("CSS Demo: background-repeat")}}
```css interactive-example-choice
background-repeat: space;
background-repeat: repeat;
<section id="default-example">
<div id="example-element"></div>
</section>
#example-element {
background-color: #cccccc;
}
이 구조는 저자 경험을 훨씬 좋게 만들었다.
## 서버 컴포넌트와 웹 컴포넌트
여기까지는 정적 콘텐츠 안에 인터랙션을 넣는 문제를 풀었다. 그런데 우리는 프론트엔드 전체를 재작성하려 했고, 여전히 더 큰 문제가 남아 있었다. 기존 React 앱은 결국 SPA였다. 서버에서 렌더링을 해도, 클라이언트에는 다시 거대한 JavaScript 번들을 내려보내고 재렌더링을 시켜야 했다. React 문서가 말하듯, 변하지 않을 정적 콘텐츠를 보여주기 위해 추가 라이브러리를 다운로드하고 파싱하고 재요청까지 해야 하는 구조였다.
React Server Components가 이 문제를 풀려는 시도라는 점은 인정한다. 하지만 이를 제대로 활용하려면 우리가 쓰지 않는 다른 프레임워크로 옮겨가야 했고, 어차피 대규모 재작성은 피할 수 없었다.
그래서 우리는 질문을 바꿨다. “MDN은 정말 복잡한 앱인가?” 사실 대부분의 MDN 페이지는 HTML과 CSS 중심의 정적 콘텐츠다. 진짜 인터랙션이 필요한 부분은 군데군데 떨어진 섬(islands)에 가깝다. 그렇다면 각 인터랙션을 독립적인 웹 컴포넌트로 만들고, 나머지는 서버에서 HTML을 조립하면 된다. 이 방식이라면 더 이상 “래퍼 문제”가 없다. Markdown → HTML 빌드 파이프라인도 정당한 1급 시민이 되고, 상위 앱이 전체 페이지 상태를 알아야 할 이유도 사라진다.
이 접근은 세 가지 문제를 한 번에 해결했다.
- 정적 콘텐츠를 다시 그리기 위한 불필요한 SPA JavaScript를 없앴다.
- 콘텐츠 HTML을 건드릴 수 없는 래퍼 구조를 제거했다.
- 필요한 인터랙션만 독립된 웹 컴포넌트로 로드할 수 있게 했다.
## 우리의 서버 컴포넌트
서버 템플릿 언어로 EJS 같은 것을 쓰는 선택지도 있었지만, 컴포넌트 기반 구조의 이점을 포기하고 싶지 않았다. 그래서 Lit의 html 템플릿 리터럴을 활용해 자체적인 서버 컴포넌트 개념을 만들었다.
예를 들어 상단 내비게이션은 이런 식이다.
```js
export class Navigation extends ServerComponent {
render(context) {
return html`
<nav class="navigation" data-open="false">
<div class="navigation__logo">${Logo.render(context)}</div>
<button class="navigation__button" type="button"></button>
<div class="navigation__popup" id="navigation__popup">
<div class="navigation__menu">${Menu.render(context)}</div>
<div class="navigation__search" data-view="desktop">
<mdn-search-button></mdn-search-button>
</div>
</div>
</nav>
<mdn-search-modal id="search"></mdn-search-modal>
`;
}
}
이 코드는 Node.js에서 HTML로 렌더링되며, Lit이 제공하는 기능을 이용해 웹 컴포넌트는 Declarative Shadow DOM 형태로도 내려보낸다. 브라우저가 지원한다면 JavaScript가 로드되기 전부터 Shadow DOM과 CSS가 함께 렌더링된다.
새 프론트엔드의 목표는 명확했다. 현재 페이지를 렌더링하고 인터랙티브하게 만드는 데 필요한 최소한의 CSS와 JavaScript만 보낸다.
핵심은 컴포넌트 디렉터리 구조를 이름 기반의 평평한 구조로 정리한 것이다.
components/example-component
├── element.css
├── element.js
├── global.css
├── server.css
└── server.js
- element.js: 웹 컴포넌트
- server.js: 서버 컴포넌트
- server.css: 서버 컴포넌트 전용 CSS
- global.css: 어디서나 항상 로드되는 글로벌 CSS
이 규칙은 린트와 런타임 검증으로 강제한다.
커스텀 엘리먼트 이름만 보면 어떤 디렉터리에서 로드해야 하는지 알 수 있기 때문에, 페이지 로드 시 DOM을 순회하며 mdn- 접두사가 붙은 엘리먼트를 자동으로 찾아 비동기 import할 수 있다.
for (const element of document.querySelectorAll("*")) {
const tag = element.tagName.toLowerCase();
if (tag.startsWith("mdn-")) {
const component = tag.replace("mdn-", "");
import(`../components/${component}/element.js`);
}
}
이 방식의 장점은 분명하다.
- 엔지니어가 서버 컴포넌트 안에서 웹 컴포넌트를 따로 import할 필요가 없다.
- Markdown이나 매크로 안에 커스텀 엘리먼트를 넣기만 하면 된다.
- 현재 페이지에 실제로 존재하는 웹 컴포넌트의 JavaScript만 로드된다.
- 한 컴포넌트 수정이 전체 번들에 미치는 영향이 작아 캐시 효율이 높다.
서버 렌더링 시에도 Lit은 웹 컴포넌트를 DSD로 렌더링한다. 덕분에 JavaScript가 늦게 붙더라도 레이아웃 시프트가 적다.
특히 흥미로운 예가 mdn-dropdown이다. 이 컴포넌트는 shadow DOM 안에 slot을 두 개 렌더링한다.
render() {
return html`
<slot name="button" @click=${this._toggleDropDown}></slot>
<slot name="dropdown" ?hidden=${!this.open && this.loaded}></slot>
`;
}
자식은 이렇게 쓸 수 있다.
<mdn-dropdown>
<button slot="button">Click me</button>
<div slot="dropdown">Hello world!</div>
</mdn-dropdown>
JS가 로드되기 전에는 CSS만으로 dropdown을 동작시키고, 로드된 뒤에는 richer한 JS 인터랙션으로 자연스럽게 전환한다. 상단 내비게이션처럼 접근성이 중요한 영역에서 특히 유용했다.
Declarative Shadow DOM은 아직 모든 브라우저에 널리 보급된 것은 아니다. 그래서 global.css가 필요하다. 예를 들어 버튼 컴포넌트는 로드 전에도 레이아웃 시프트가 크지 않도록 최소한의 전역 스타일을 준다.
mdn-button {
display: inline-flex;
vertical-align: middle;
}
서버 컴포넌트는 JavaScript를 줄일 필요는 없지만, CSS는 현재 페이지에서 렌더링된 컴포넌트에 대해서만 실어야 한다. 이를 위해 ServerComponent의 static render 메서드에서 어떤 컴포넌트가 실제로 렌더링됐는지를 추적한다.
export class ServerComponent {
static render(...args) {
const { componentsUsed } = asyncLocalStorage.getStore();
const componentUsedBefore = componentsUsed.has(this.name);
componentsUsed.add(this.name);
const renderResult = new this().render(...args);
if ((!renderResult || renderResult === nothing) && !componentUsedBefore) {
componentsUsed.delete(this.name);
return nothing;
}
return renderResult;
}
}
그리고 최상위 레이아웃에서 compilationStats와 componentsUsed를 합쳐, 필요한 CSS 링크만 head에 삽입한다.
const styles = componentsUsed
.flatMap((component) =>
compilationStats.assets.filter(
(name) => name === `${component.toLowerCase()}.css`,
),
)
.map((path) => html`<link rel="stylesheet" href=${path} />`);
이 구조는 작은 CSS/JS 파일이 많이 생기는 구조다. 과거의 “적당히 큰 하나의 번들”이라는 통념과 다를 수 있다. 하지만 HTTP/2와 HTTP/3에서는 병렬 다운로드와 연결 재사용이 가능하기 때문에, 여러 작은 파일이 꼭 불리하지만은 않다. 오히려 개별 웹 컴포넌트가 독립적으로 빨리 인터랙티브해질 수 있고, 캐시도 더 잘 작동한다. 실제 벤치마크 결과, 합치는 쪽이 같거나 더 느린 경우도 있었다.
MDN은 최신 웹 기술을 많이 쓰기 때문에, “이 API를 지금 써도 되는가?”를 빠르게 판단할 기준이 필요했다. 여기서 Baseline 프로젝트가 큰 도움이 됐다.
팀 내부 가이드는 간단했다.
- Baseline Widely Available이면 바로 쓴다.
- Newly Available이면 먼저 상의하고, 폴리필이나 점진적 향상을 고민한다.
- Limited Availability이면 정말 필요한지 다시 생각한다.
Custom Elements와 Shadow DOM은 이미 Widely Available 수준이었다. Declarative Shadow DOM은 아직 충분히 오래되진 않아 progressive enhancement로 사용했다. 가장 최신인 기능도 일부는 PostCSS mixin 같은 방식으로 감싸 활용했다.
개발 환경 개선은 이번 개편에서 체감상 가장 큰 성과였다. 예전 환경은 느리고, 복잡하고, 자주 재시작이 필요했으며, 기본적으로는 SSR 없는 SPA만 띄워서 디버깅도 어려웠다.
새 프론트엔드에서는 시작 시간이 약 2초다. 실질적으로 필요한 명령은 하나뿐이다.
이 속도 향상의 큰 부분은 Rspack 덕분이다. 기존 Webpack은 유연했지만 느렸다. Rspack은 Webpack 호환 API를 유지하면서 Rust로 구현돼 매우 빠르다. 설정 파일이 짧지는 않지만, 무슨 일이 일어나는지 감춰진 마법이 적고 구조는 오히려 더 직관적이다.
무엇보다 새 아키텍처에서는 “SSR 없는 개발 모드” 같은 별도 세계가 필요 없다. 서버 컴포넌트가 구조의 핵심이기 때문에, 개발 환경이 곧 실제 서비스 구조와 매우 비슷하다. 페이지 변경 시 새로고침과 서버 컴포넌트 재렌더링만 반복하면 대부분의 작업이 된다. 이제는 Rspack 설정 자체를 만지는 경우가 아니라면 개발 서버를 거의 재시작하지 않는다.
이번 글에서 우리는 새 MDN 프론트엔드의 절반도 다 설명하지 못했다. 그래도 핵심은 분명하다. MDN은 거대한 SPA가 아니라, 정적 콘텐츠 중심의 사이트 위에 적절한 인터랙션 섬을 얹는 구조로 재설계됐다. React 래퍼 문제를 버리고, 웹 컴포넌트와 서버 컴포넌트를 중심으로 아키텍처를 새로 잡았으며, 필요한 CSS와 JavaScript만 싣는 방식으로 성능과 유지보수성을 함께 얻었다.
이 새 프론트엔드를 만드는 일은 아주 즐거운 경험이었다. 새 웹 기술을 문서화하는 사이트를, 바로 그 새 웹 기술로 다시 만드는 일은 분명 특권 같은 작업이었다.