MDN의 프런트엔드를 새롭게 재구축하며 선택한 기술, 아키텍처 변화, 그리고 그 이유를 자세히 살펴봅니다.
지난해에 우리는 MDN을 위한 새로운 프런트엔드를 출시했습니다. 가장 눈에 띄는 변화는 스타일 조정이었습니다. 모든 페이지 전반에서 MDN 디자인을 단순화하고 일관되게 통일했습니다. 하지만 사실 가장 큰 변화는 독자 눈에 보이는 부분이 아니라, 프런트엔드를 구동하는 개편된 코드였습니다. 이 글에서는 우리가 무엇을 했는지, 어떤 기술을 선택했는지, 그리고 애초에 왜 그렇게 했는지를 설명합니다.
MDN 프런트엔드에 우리가 가한 변화를 제대로 이해하려면, 여러분이 잘 알고 애용하는 웹사이트로 MDN 콘텐츠가 어떻게 조립되는지에 대한 배경을 먼저 설명해야 합니다. MDN의 아키텍처만으로도 아마 별도의 블로그 글 하나가 나올 만하지만, 이 글에서는 단순화를 위해 페이지가 다음과 같은 주요 단계를 거쳐 사이트에 게시된다고 설명하겠습니다.
프런트엔드를 재구축해야 한다는 필요는 오래전부터 있었습니다. MDN의 UI 작업이 너무 까다로웠기 때문입니다. 이전 프런트엔드(yari라고 불렸습니다)는 React 앱이었는데, 안타깝게도 상당한 기술 부채가 누적되어 있었습니다. 유지 관리가 완전히 불가능한 것은 아니었지만, 확실히 고통스러운 일이었습니다. 이슈를 수정하거나 새로운 사이트 기능을 추가할 때마다, 우리는 필연적으로 더 많은 기술 부채를 쌓게 되었습니다. 그런데 어떻게 이런 상황에 이르렀을까요?
이 React 앱은 처음에 “Create React App”으로 시작했지만, 기본 제공되는 설정 여러 가지가 우리에게는 맞지 않았습니다. 당연히 여러 우회 방법이 생겨났고, 결국 설정을 "eject"해야 했습니다. 그 결과, 극도로 복잡한 Webpack 설정과 상당히 임시방편적인 빌드 스크립트들이 남게 되었습니다.
CSS 측면에서도 상황은 점점 통제 불능에 가까워졌습니다. 우리는 Sass를 광범위하게 사용했고, 이후 CSS 변수 같은 현대적인 CSS 기능을 추가하면서 파일 전반에 두 가지 스타일이 뒤섞인 기묘한 상태가 되었습니다.
CSS는 스코프 관리가 부실하거나 거의 존재하지 않아, 서로 매우 복잡하게 얽혀 있기도 했습니다. 하나의 UI 컴포넌트에서 변경을 가하면 다른 컴포넌트에서도 의도하지 않은 변화가 자주 나타났습니다. 이런 문제들과 CSS를 분리해 주는 빌드 도구의 부재 때문에, 사용자에게는 렌더링을 막는 거대한 CSS 덩어리를 전송해야 했고, 그 안에는 사용자가 평생 불러오지 않을 컴포넌트의 스타일까지 포함되어 있었습니다.
하지만 가장 큰 문제는, 우리 React 앱이 정적 콘텐츠를 감싸는 래퍼에 불과했다는 점입니다. 빌드 도구가 생성한 HTML 콘텐츠를 React 앱이 인식하게 만들려면 HTML을 비용 많이 들여 다시 파싱해야 했고, 사용자 쪽 클라이언트 JavaScript에 실어 보내야 하는 엄청난 양의 로직이 필요했을 것입니다. 우리는 그렇게 하고 싶지 않았기 때문에, React 앱의 경계는 사실상 문서가 시작되는 지점에서 끝났습니다. 콘텐츠를 삽입할 때는 React의 dangerouslySetInnerHTML를 사용했습니다.
우리 콘텐츠는 대부분 정적입니다. 산문과 코드 예제가 여기에 해당합니다. 하지만 이 정적 콘텐츠 안에도 상호작용을 추가해야 하는 지점들이 있었습니다. 예를 들면 코드 블록의 “Copy” 버튼 같은 것입니다. 이런 상호작용 부분은 결국 일반적인 DOM API를 사용해 구현했는데, 사이트의 나머지가 React로 작성되어 있다는 점을 생각하면 우아한 방식은 아니었습니다. JSX (React의 HTML과 유사한 문법)를 사용할 수 없었기 때문에, 더 복잡한 상호작용 조각들의 유지 보수성이 제한되었습니다. 때때로는 최악의 경우도 발생했는데, React 버전 하나와 DOM API 버전 하나, 이렇게 중복 구현을 유지해야 했습니다.
이 문제를 해결할 수 있는 방법으로, 2024년에 우리는 Lit과 web components를 실험하기 시작했습니다. 이런 종류의 콘텐츠 내부 상호작용을 다룰 때 개발자 경험을 개선할 수 있는지 보기 위해서였습니다. 첫 번째 제대로 된 프로토타입이자 최종적으로 운영 환경에 들어간 구현은, Scrimba와 협력하며 진행한 MDN Curriculum 작업에서 나왔습니다.
Scrimba에는 “Scrims”라는 기능이 있습니다. 이것은 우리가 MDN에서 <iframe>을 통해 임베드하는 상호작용 학습 환경입니다. Scrims는 학습자가 짧은 코딩 튜토리얼을 보고, 이어서 같은 화면 안에서 직접 코드를 수정할 수 있게 해 줍니다. 상호작용 가능한 스크린캐스트라고 생각하면 됩니다.
우리 페이지에서는 사용자가 Scrimba 콘텐츠와 상호작용하기로 선택하기 전까지는 어떤 사용자 데이터도 Scrimba로 보내고 싶지 않았습니다. 그래서 사용자가 클릭해서 열기 전까지는 <iframe>을 불러오지 않았습니다. 또한 사용자가 MDN을 떠나지 않고도 Scrim을 전체 화면으로 확장할 수 있기를 원했기 때문에, <dialog> 요소를 사용했습니다.
우리는 web component를 만들면 커스텀 요소를 사용해 이 Scrims를 콘텐츠에 직접 삽입할 수 있고, 그 결과 여러 렌더링 단계를 건너뛰며 유지 관리가 까다로운 DOM API 구현도 피할 수 있을 것이라고 판단했습니다.
우리 컴포넌트는 LitElement를 확장하는 것으로 시작합니다.
그 안에서 몇 가지 상태를 정의해야 합니다. Lit에서는 정적 properties 속성을 통해 이를 할 수 있습니다.
그리고 클래스 생성자에서 기본값을 설정합니다.
커스텀 요소의 속성으로 전달된 URL을 조작하고 싶었습니다. Lit은 이를 위해 생명주기 메서드를 제공합니다. 우리는 컴포넌트의 업데이트가 렌더링될 것이라는 사실을 알게 된 시점에 값을 계산하고 싶었습니다.
그다음 이 상태를 사용해 컴포넌트를 렌더링할 수 있습니다.
LIt의 html 템플릿 리터럴은 JavaScript 안에서 HTML 비슷한 문법을 쓸 수 있게 해 준다는 점에서 JSX만큼 편리합니다. JSX에 비해 큰 장점은, 사용을 위해 어떤 컴파일도 필요 없다는 것입니다. 이것은 네이티브 JavaScript입니다.
제가 “HTML 비슷한”이라고 말한 이유는, 위 템플릿의 특정 속성 앞에 몇 가지 표기법이 보이기 때문입니다. 바로 @close와 @click입니다. 이것은 Lit 문법으로, 요소에 이벤트 리스너를 바인딩할 수 있게 합니다. <dialog>의 close 이벤트와 몇 개 버튼의 click 이벤트가 여기에 해당합니다. 이것들도 클래스 안에서 정의합니다.
사용자가 클릭해서 Scrim을 열면 #open 메서드가 실행되고, _scrimLoaded와 _fullscreen 값이 갱신됩니다. Lit은 우리가 static properties에서 이 속성들을 정의해 두었기 때문에, 해당 속성의 변경을 감지하고 컴포넌트를 자동으로 다시 렌더링하여 <iframe>과 그 안의 Scrim을 불러옵니다.
간결함을 위해 이 컴포넌트를 약간 단순화해 설명했습니다. 전체 코드는 GitHub의 MDNScrimInline 소스에서 볼 수 있습니다. 여기에는 텔레메트리, 그리고 동적으로 렌더링되는 썸네일 같은 몇 가지 추가 요소가 있습니다. 썸네일은 많은 이미지를 미리 렌더링하는 것보다 바이트 수가 적고 구현도 더 단순했습니다. 짐작할 수 있듯이, 이것은 Lit이 제공하는 편의 기능 덕분에 개발하기가 매우 쉬웠습니다. 전통적인 DOM API만으로 직접 구현했다면 엄청난 골칫거리였을 것입니다.
여러 면에서 저는 Lit으로 구현한 방식이 React보다 더 단순하다고 느꼈습니다. 다루는 상태가 그리 복잡하지 않고, 이를 표현하기 위해 복잡한 컴포넌트 아키텍처가 필요하지 않다는 점이 눈에 띌 것입니다. 하지만 더 중요한 점은, 이것이 우리에게 커스텀 요소를 제공한다는 것입니다. 그래서 Curriculum 콘텐츠 어디에서든 Scrim을 넣어야 하는 곳에 바로 삽입할 수 있습니다.
Scrimba 구현은 팀이 작은 컴포넌트를 작성해 보는 좋은 입문 사례였습니다. 하지만 더 복잡한 것은 어떨까요? 인터랙티브 예제는 많은 CSS, JavaScript, HTML 페이지 상단의 “Try it” 섹션 아래에 나타나는 컴포넌트입니다.
이 인프라를 개선하는 일은 한동안 엔지니어링 팀의 백로그에 있었습니다. 기술 작가와 커뮤니티가 유지 보수하고 작성하기 어려웠기 때문입니다. 기존 구현은 네 개의 git 리포지터리에 걸쳐 있었고, 예제를 작성하거나 디버깅하려면 그 모든 저장소에 걸친 변경 사항을 동기화해야 할 때도 있었습니다. 더 나쁜 점은, 예제가 포함될 콘텐츠와 분리된 상태로 작성되어야 했다는 것입니다. 그래서 예제 변경과, 해당 예제가 포함될 MDN 페이지 콘텐츠 변경을 함께 보여 주는 라이브 미리보기를 만드는 것이 불가능했습니다.
이 복잡성은 나름의 이유가 있었습니다. 이런 인터랙티브 예제는 DOM API만으로 직접 쉽게 설계하고 유지 보수하기에는 너무 복잡했습니다. 그래서 별도의 빌드 시스템과 예제 리포지터리를 두고, 이 예제들을 별도 HTML 페이지로 렌더링한 뒤 <iframe>에서 직접 불러오는 방식을 사용했습니다.
우리는 이 아키텍처를 단순화해 작성자들이 인터랙티브 예제를 더 쉽게 만들 수 있기를 원했습니다. 그래서 다시 한번 Lit을 선택해 콘텐츠에 직접 포함할 수 있는 web component를 만들었습니다. 이것은 Scrims보다 훨씬 더 기술적으로 복잡한 구현이었습니다. 먼저, 인터랙티브 예제가 표시되는 여러 방식에 맞는 여러 템플릿이 필요했습니다.
background-clip 속성을 보세요).둘째로, 예제와 사용자가 한 편집 내용을 렌더링할 방법이 필요했습니다. 우리는 이미 상호작용형 Playground를 만들기 위해 그 로직을 작성해 두었지만, 그것은 React로 되어 있었습니다. 따라서 그것도 포팅해야 했습니다.
그래서 우리는 그 모든 작업을 진행했습니다. 일을 훨씬 단순하게 만들어 준 것은, Lit의 React 통합 덕분에 기존 React 앱 안에서 이 web components를 렌더링할 수 있었다는 점입니다. 그래서 Playground 전체를 한 번에 포팅하지 않고도, 필요한 부분을 하나씩 web component로 옮길 수 있었습니다. 또한 이중 구현을 유지할 필요도 없었습니다.
큰 틀에서 보면, 우리는 단일하고 복잡하게 얽혀 있던 Playground React 컴포넌트를 일련의 커스텀 요소로 분리했습니다.
<play-editor>: CodeMirror 기반 편집기.<play-console>: 콘솔 메시지를 포맷하고 렌더링하는 요소.<play-runner>: 각 편집기의 현재 상태를 렌더링하는 역할의 요소.<play-controller>: 위 요소들 사이에서 이벤트와 상태를 전달하는 역할의 요소.이 덕분에 Playground의 로직은 더 단순해지고 결합도가 낮아졌으며, 우리가 만든 <interactive-example> 요소에서 이 요소들을 재사용할 수 있게 되었습니다. 여기에는 인터랙티브 예제 컴포넌트가 읽어들여야 하는 <code> 요소를 페이지에서 찾아내고, 그 내용을 <play-controller>로 전달하며, 위 템플릿 중 어떤 것을 렌더링해야 하는지 판단하는 로직이 포함되었습니다. 이를 위해 다양한 <play-*> 요소 조합을 사용했습니다.
이제 작성자는 콘텐츠에 매크로를 추가하고, 이 매크로는 내부적으로 <interactive-example> 커스텀 요소를 렌더링한 뒤, 예제가 사용할 코드 블록을 이어서 배치할 수 있게 되었습니다.
이 예제의 전체 소스는 GitHub의 CSS background-repeat 페이지에서 볼 수 있습니다. Curriculum이 아닌 Markdown 콘텐츠에 커스텀 요소를 직접 넣는 것에 대해서는 아직 입장이 완전히 정리되지는 않았습니다. 그렇게 되면 우리의 아키텍처는 더 단순해질 수도 있습니다. 하지만 그 논의는 다음 기회로 미루겠습니다.
여기까지는 모두 좋아 보입니다. web components는 꽤 훌륭해 보이고, 정적 콘텐츠 안에 상호작용을 넣는 문제 일부를 해결해 줍니다. 하지만 이 글은 원래 프런트엔드 스택 전체를 어떻게 다시 썼는지에 대한 이야기 아니었나요? 그건 어떻게 된 걸까요? 그 질문에 답하려면, 이전 프런트엔드가 가진 또 다른 문제로 들어가 보아야 합니다.
앞서 React 앱이 “래퍼”에 불과했고, 콘텐츠와 상호작용할 수 없었다는 문제를 언급했습니다. 근본적인 문제는, 적어도 고전적인 의미에서 React 앱은 단일 페이지 애플리케이션(SPA)이라는 점입니다. 즉, 서버에서 어떻게 렌더링할지 고민하고, 그다음 사용자에게 엄청나게 거대한 JavaScript 번들을 보내지 않기 위해 또 고민해야 합니다.
마지막 부분은 필수적입니다. SPA에서 렌더링하는 모든 것은, 설령 그것이 서버에서 정적으로 렌더링되거나 컴파일 단계에서 생성될 수 있는 것이라 하더라도, 클라이언트 측 JavaScript 번들에 포함되어 사용자에게 전달되어야 하고, 클라이언트에서 다시 렌더링되어야 합니다. 아무것도 바뀌지 않았음을 확인하기 위해서 말입니다. React 문서 자체가 이것을 제가 설명하는 것보다 더 잘 요약합니다.
이 패턴은 사용자가 추가로 75K(gzip 압축 기준)의 라이브러리를 다운로드하고 파싱해야 하며, 페이지가 로드된 뒤 데이터를 가져오기 위한 두 번째 요청까지 기다려야 한다는 뜻입니다. 이 모든 것은 페이지 수명 동안 바뀌지 않을 정적 콘텐츠를 렌더링하기 위해서입니다.
react.dev의 Server Components
이것은 React Server Components(RSC) 문서에서 가져온 인용입니다. 즉, 이 프로젝트도 이것이 문제라는 점을 인식하고 있으며, 이를 해결하기 위해 많은 노력을 기울이고 있습니다. 하지만 안타깝게도 RSC를 효과적으로 사용하려면 우리가 현재 사용하고 있지 않은 프레임워크를 써야 합니다. 그쪽으로 마이그레이션하려면 어차피 프런트엔드 상당 부분을 다시 써야 했습니다.
따라서 이 근본적인 문제를 해결하려면 큰 규모의 재작성이 필요했기 때문에, 동시에 MDN이 어떤 종류의 사이트인지, 그리고 얼마나 복잡할 필요가 있는지도 다시 평가할 수 있었습니다. 사실 MDN은 “상호작용이 필요한 것들”의 관점에서 보면 그렇게 복잡한 사이트가 아닙니다. MDN 문서 페이지 콘텐츠의 대다수는 HTML과 CSS입니다. 사이트 대부분을 복잡한 앱이 구동할 필요가 없습니다. 본질적으로 우리는 상호작용의 섬들을 갖고 있을 뿐이며, 이것들은 모두 web components로 쉽게 구현할 수 있습니다.
그리고 모든 기능을 고립된 web components로 구현한다면, 그것들이 어떻게 조립되는지는 사실 크게 중요하지 않습니다. 우리는 그저 HTML 템플릿을 조합하면 되고, 그것은 전체 빌드 시스템의 여러 곳에서 여러 번 일어날 수 있습니다. 페이지 전체 상태를 이해해야 하는 상위 수준의 “앱”이 존재하지 않기 때문에, “래퍼” 문제는 애초에 발생할 수 없습니다. 우리의 markdown-to-HTML 빌드 도구는 프런트엔드에서 수행하는 어떤 템플릿 작업과도 동등하게 일급 시민이 됩니다.
이 접근 방식은 세 가지 문제를 한 번에 해결했습니다. 정적 콘텐츠를 다시 렌더링하기 위한 불필요한 JavaScript를 보내는 SPA가 없고, 문서 HTML 내부로 들어갈 수 없는 “래퍼”도 없으며, 각 상호작용 조각은 필요할 때만 로드되는 독립적인 web component가 됩니다. 남은 일은 나머지를 조립하는 정적 템플릿 작업을 어떻게 수행할지 결정하는 것이었습니다.
우리는 프런트엔드 템플릿 작업을 위해 EJS 같은 전용 템플릿 언어를 사용하는 방안도 고려했지만, 컴포넌트 기반 아키텍처에는 많은 이점이 있다는 사실을 깨달았습니다. 서버에서 정적 HTML 템플릿을 처리하면 클라이언트 측 JavaScript 번들에 로직을 실어 보낼 필요는 없어지지만, 이 HTML에는 여전히 스타일이 필요합니다. 그리고 기억하시겠지만, 이전 프런트엔드의 CSS는 엉망이었고, 현재 페이지를 렌더링하는 데 필요하지 않다면 불필요한 CSS를 보내고 싶지 않았습니다.
우리는 먼저 Lit의 HTML 템플릿 리터럴을 사용해 자체적인 Server Components 개념을 만들었습니다. 이미 익숙한 방식이었기 때문입니다. 다음은 상단 탐색 바 컴포넌트의 예입니다.
보시다시피 로직은 render 메서드에서 처리되며, 이는 Lit 컴포넌트와 매우 유사합니다. 이것은 한 번만 실행되기 때문에 어떤 생명주기 메서드도 필요하지 않습니다. 이 컴포넌트는 Logo, Menu 같은 다른 server component를 렌더링할 수 있으며, <mdn-search-button>, <mdn-search-modal> 같은 web component도 렌더링할 수 있습니다.
우리는 Lit이 제공하는 편리한 함수를 사용해 NodeJS에서 이것을 HTML로 렌더링합니다. 이 과정에서 해당 Lit web components는 Declarative Shadow DOM으로도 렌더링되므로, 호환되는 브라우저에서는 JavaScript가 로드되기 전에 커스텀 요소의 Shadow DOM과 CSS가 먼저 렌더링됩니다.
앞서 언급했듯이, SPA 방식으로 웹사이트를 만들 때의 큰 문제 중 하나는 페이지 렌더링에 필요한 모든 것이 클라이언트 측 JavaScript 번들에 포함되어야 한다는 점입니다. 이와 비슷한 또 다른 문제는, 현재 페이지 렌더링에는 필요 없고 다른 페이지 렌더링에만 필요한 코드까지 하나의 거대한 클라이언트 번들에 실어 보내기가 매우 쉽고, 그래서 실제로 매우 흔하다는 점입니다. 우리의 이전 프런트엔드는 JavaScript와 CSS 모두에서 이 함정에 빠졌습니다.
시간이 지나면서 특정 라우트를 별도 청크로 분리하기도 했지만, 그것은 JavaScript에서만 가능했습니다. CSS는 너무 복잡하게 얽혀 있었고, 빌드 도구도 이를 지원하도록 설정되어 있지 않았습니다.
그리고 이것은 라우트 단위 에서만 가능했을 뿐, 같은 페이지 내의 컴포넌트 단위로는 불가능했습니다. 어떤 라우트에서 특정 컴포넌트를 불러올 가능성이 있다면, 실제로 불러오지 않더라도 서버 사이드 렌더링을 위해 그 컴포넌트는 번들에 포함되어야 했고, 그 JavaScript는 클라이언트 측에서 전혀 실행되지 않을 수도 있었습니다.
우리는 새로운 프런트엔드에서 이 모든 것을 피하고 싶었습니다. 즉, 페이지를 렌더링하고 상호작용 가능하게 만드는 데 필요한 최소한의 CSS와 JavaScript 번들만 로드하고 싶었습니다. 그리고 저는 이것을 아키텍처 수준에서 달성해, 그렇게 하지 않기가 거의 불가능한 구조로 만들고 싶었습니다. 우리는 여러 방식으로 이를 달성했는데, 그 모든 것을 가능하게 한 핵심은 이름 기반의 평평한 컴포넌트 구조였습니다.
모든 컴포넌트는 ./components/ 디렉터리 아래 평평한 계층 구조로 존재하며, 특정 역할의 파일 이름은 다음과 같이 예약되어 있습니다.
components/example-component
├── element.css
├── element.js
├── global.css
├── server.css
└── server.js
element.js - MDNExampleComponent 클래스를 내보내고 <mdn-example-component> 요소를 정의하는 web component.server.js - components/server/index.js의 ServerComponent를 확장하는 server component.server.css - 서버에서 이 컴포넌트용으로 자동 로드되는 server component용 CSS.global.css - 어디서나 항상 로드되는 컴포넌트용 CSS.우리는 이 구조의 일부를 린팅으로 강제하고, 일부는 이름 규칙을 따르지 않으면 오류를 던지는 방식으로 강제합니다.
각 web component가 이름을 기준으로 어디에 있는지 알고 있기 때문에, 몇 가지 영리한 일을 할 수 있습니다. 페이지가 로드되면 클라이언트 측에서 다음과 같은 로직을 실행합니다.
그 결과, 로드 시점에 DOM 안에 존재하는 모든 커스텀 요소를 완전히 비동기적이고 병렬로 지연 로딩하게 됩니다. 여기에는 많은 장점이 있습니다.
우리는 또한 SSR 번들에서 모든 web component를 자동으로 로드합니다. 이 과정에서 Lit은 그것들을 Declarative Shadow DOM으로 렌더링합니다(서버 렌더링이 적절하지 않은 경우 컴포넌트가 이를 선택적으로 비활성화하지 않는 한). 이것은 JavaScript가 로드될 때 레이아웃 이동이 발생하지 않도록 도와줍니다. 그 결과, 초기 렌더링 뒤에 JavaScript를 로드하면서 생기는 약간의 상호작용 지연은 거의 느껴지지 않습니다. 컴포넌트는 이미 페이지에 존재하고, 단지 아직 상호작용만 가능하지 않은 상태이기 때문입니다.
적어도 완전히 상호작용이 없는 것은 아닙니다. 이 아키텍처는 특정 컴포넌트에서는 매우 영리한 동작을 구현할 수 있을 만큼 유연합니다. 그중 하나가 <mdn-dropdown>입니다.
이 컴포넌트는 이름에서 짐작할 수 있듯이 드롭다운을 구현합니다. render 메서드는 꽤 단순합니다.
우리는 두 개의 슬롯을 렌더링하며, 다음과 같이 사용할 수 있습니다.
여기서 슬롯을 사용하기 때문에, <mdn-dropdown>의 shadow DOM은 거의 중요하지 않습니다. 그 자식 요소들은 완전히 일반적인 방식으로 스타일링할 수 있고, 이 요소는 상호작용만 추가합니다. 스타일이 아예 없는 것은 아니지만, 그것들도 스타일링을 위한 것이 아니라 상호작용을 위한 것입니다. 실제로 우리는 생명주기 메서드도 하나 가지고 있습니다.
그리고 loaded 속성은 다음과 같이 정의합니다.
이 로직을 따라가 보면, 기본적으로 dropdown 슬롯은 숨겨져 있지 않아서 서버에서 DSD로 렌더링될 때는 보이게 됩니다. 그리고 컴포넌트가 loaded=true가 되면, 그 속성을 DOM에 반영하여 다음과 같은 형태가 됩니다.
우리가 이렇게 하려는 이유는, 요소에 다음과 같은 CSS도 붙여 두었기 때문입니다.
여기 로직은 읽기가 조금 어렵습니다. 이 코드를 쓴 사람인 저조차 그렇게 느낍니다. 하지만 사실상 다음과 같은 뜻입니다.
mdn-dropdown용 JavaScript가 로드되었다면, 스타일을 적용하지 않습니다.mdn-dropdown용 JavaScript가 아직 로드되지 않았다면, 다음을 수행합니다.
이것은 상단 탐색 메뉴에서 매우 중요합니다. 거기서는 대부분의 링크가 드롭다운 뒤에 있기 때문입니다. 이 링크들은 페이지에 렌더링되자마자 완전히 사용할 수 있습니다. 예를 들어 테마 전환기 같은 다른 컴포넌트에서는, JavaScript가 로드되기 전까지는 테마 선택이 불가능하더라도, 드롭다운 자체가 이미 상호작용 가능하므로 사용자가 그것이 필요한 요소를 클릭하기 전까지 JavaScript가 로드될 시간을 몇 초 더 벌 수 있습니다.
현재 Declarative Shadow DOM은 아직 널리 보급되어 있지 않기 때문에, 조금 오래된 브라우저에서도 잘 동작하도록 해야 합니다. 이때 global.css 파일이 등장합니다. 이런 파일들에 작성된 모든 CSS는 모든 페이지에, 항상 포함됩니다.
물론 이것은 전역 CSS 변수, 전역 리셋 스타일 등을 설정하는 데 필요합니다. 하지만 컴포넌트 관점에서 보면, DSD를 사용할 수 없는 경우 JavaScript가 로드되기 전의 컴포넌트는 스타일이 없는 빈 인라인 요소처럼 브라우저에 보이게 됩니다. 이것은 항상 최적의 상태는 아니며, JavaScript가 로드될 때 레이아웃 이동을 일으킬 수 있습니다. 그래서 우리는 특정 요소에 대해 전역 스타일을 설정합니다. 예를 들어 버튼 컴포넌트는 다음과 같습니다.
이것은 버튼이 로드되기 전에 레이아웃을 흔들지 않도록 보장할 만큼만 최소한으로 작성한 것입니다. mdn-button이 페이지에 있을 때만 이 스타일을 로드하고, 모든 페이지에서 항상 로드하지 않는 약간의 최적화는 가능합니다. 하지만 그 이득은 아주 작기 때문에, 아마도 추가 복잡성을 감수할 가치는 없을 것입니다. 이것은 앞서 언급한 dropdown 컴포넌트에도 중요합니다. DSD가 로드되지 않았더라도 이 컴포넌트가 상호작용 가능하기를 원하므로, 비슷한 스타일을 global.css 파일에도 포함합니다.
server component의 경우에는 고려 사항이 조금 다릅니다. JavaScript 자체 는 우리의 HTML을 SSR하는 데만 사용되므로, 지연 로딩하거나 줄여야 할 필요는 없습니다. 하지만 신중하게 로드해야 하는 것은 각 server component에서 사용하는 CSS입니다. 우리는 해당 컴포넌트가 페이지에 렌더링될 때만 이것을 로드하고 싶습니다. 그런데 그 사실을 어떻게 알 수 있을까요?
우리의 server component는 ServerComponent 클래스를 확장하므로, 각 server component를 인스턴스화하기 전과 후에 실행되는 그 정적 render 메서드 안에 추적 로직을 넣었습니다.
이렇게 하면 실제로 무언가를 렌더링한 컴포넌트만 담고 있는 Set, componentsUsed를 얻게 됩니다. 그런 다음 이것을 OuterLayout 컴포넌트에서 사용합니다.
여기서 compilationStats 객체는 빌드 도구인 Rspack에서 옵니다(이에 대해서는 뒤에서 더 설명하겠습니다). 이 코드 블록은 현재 페이지를 렌더링하는 데 필요한 CSS만 포함하는 <link> 태그 목록을 <head> 안에 넣을 수 있게 해 줍니다. 앞서 언급한 모든 global.css 파일을 하나로 묶은 CSS 파일도 함께 로드합니다.
다시 말하지만, 이것은 단순화한 버전입니다. 관심이 있다면 저장소에서 ServerComponent와 OuterLayout 클래스 전체를 볼 수 있습니다.
여기서 제가 설명한 방식은 페이지에서 꽤 작은 CSS와 JavaScript 파일 여러 개를 로드하게 된다는 점을 눈치채셨을 것입니다. 이는 브라우저가 필요한 모든 자산을 불러오기 위해 왕복 횟수를 줄이도록 여러 자산을 하나로 묶는 것이 최적의 번들 크기라는 고전적인 통념에 반하는 것처럼 보입니다.
저는 성능 전문가라고 주장할 수는 없지만, HTTP/2와 HTTP/3는 그 통념을 많이 바꾸었습니다. 이제 자산을 병렬로 다운로드하고 연결을 재사용할 수 있으므로, 여러 개의 작은 자산은 예전만큼의 오버헤드를 갖지 않으며, 특히 web component를 로드하는 우리 방식에서는 오히려 이점이 있을 수 있습니다.
앞서 설명했듯이, 우리는 페이지 렌더링 후 web component를 비동기적이고 독립적으로 로드합니다. 따라서 모든 코드를 한 덩어리로 내려보내 브라우저가 여러 컴포넌트 코드를 파싱한 뒤 아마도 그중 하나에만 상호작용을 추가하는 것보다, 컴포넌트별로 각각 코드를 전송하는 편이 더 빠릅니다. 그렇게 하면 브라우저는 코드가 로드되자마자 상호작용 추가 작업을 수행할 수 있습니다.
여기에 캐싱까지 더하면 훨씬 더 빨라지고, 이것은 CSS에도 그대로 적용됩니다. 하나의 컴포넌트 업데이트는 많은 경우 다른 컴포넌트의 번들 코드를 건드리지 않습니다. 따라서 MDN을 다시 방문하는 사용자는 캐시된 컴포넌트의 상호작용 기능을 거의 즉시 사용할 수 있고, 변경된 컴포넌트나 변경된 server component CSS에 대해서만 해당 부분을 다시 다운로드하는 시간만 기다리면 됩니다.
이런 것들은 언제나 벤치마킹이 필요하며, 더 많은 실험을 할 수도 있습니다. 하지만 지금까지 해 본 결과로는, 콜드 캐시 상황에서는 여러 요소를 묶는 방식이 같거나 더 느렸습니다. 또한 향후 벤치마킹 결과 더 나은 선택으로 드러난다면, 더 작은 컴포넌트들을 쉽게 함께 번들링할 수 있도록 빌드 설정에 몇 가지 조절 장치도 마련해 두었습니다.
우리는 여기서 꽤 많은 현대적인 웹 기술을 사용하고 있으며, 어떤 것을 써도 되는지, 그리고 polyfill이나 점진적 향상 없이도 괜찮은지를 쉽게 판단할 방법이 필요했습니다. 다행히 지난 몇 년 동안 우리는 WebDX 그룹의 여러 벤더 파트너와 함께 Baseline 프로젝트를 진행해 왔습니다.
이것은 어떤 API를 사용할지 판단하는 데 매우 쉬운 기준을 제공해 주었습니다. 제가 다른 엔지니어들에게 준 조언은 이랬습니다. “Baseline Widely Available”이면 그냥 사용하세요. “Baseline Newly Available”이면 먼저 저와 이야기합시다. 그러면 polyfill이 필요한지, 아니면 점진적 향상으로 사용할 수 있는지 함께 판단하겠습니다. 그리고 “Baseline Limited Availability”이거나 아직 API조차 없는 일을 해야 한다면, 정말 그것이 필요한지 한 번 더 생각해 보고 그다음 저와 이야기하세요.
결국 우리는 이런 여러 상태에 걸쳐 다양한 기술을 사용하게 되었습니다.
Custom Elements나 Shadow DOM 같은 것들은 요즘 기준으로 놀랄 만큼 오랫동안 브라우저 전반에서 지원되어 왔고, Baseline Widely Available에 확실히 해당합니다.
앞서 언급한 Declarative Shadow DOM은 브라우저 전반에서 지원되지만, 웹 플랫폼에 들어온 지 아직 충분히 오래되지 않아 Widely Available은 아닙니다. 그래서 우리는 이를 점진적 향상으로 사용하고, 아직 지원하지 않는 오래된 브라우저를 위해 대체 수단도 마련해 두었습니다. 예를 들어 global.css 스타일시트가 그렇습니다. 또한 우리는 최전선의 기능도 몇 가지 원했습니다. 그중 하나는 light-dark를 이미지까지 확장하는 것이었는데, 이를 위해 PostCSS로 사용자 정의 mixin을 정의하여 다음과 같은 문법을 사용할 수 있게 했습니다.
Baseline을 사용하면 자신 있게 구축할 수 있습니다. 기능이 Widely Available에 도달하면 대다수 사용자가 그 기능을 사용할 수 있다는 점을 알 수 있기 때문입니다. 또한 기능이 Newly Available에서 Widely Available으로 이동할 때 polyfill을 자동으로 제거할 수 있어, polyfill 집합과 그 오버헤드를 작게 유지할 수 있습니다.
제가 생각하기에 가장 큰 개선은 마지막에 남겨 두었습니다. 다만 MDN에서 일하는 엔지니어로서 약간 편향되어 있을 수는 있습니다. 예전 프런트엔드 개발 환경은 제가 사용할 때마다 괴로웠습니다.
가장 큰 문제는 다음과 같았습니다.
package.json에 엄청난 수의 명령이 있었고, 그중 일부는 빌드의 일부 요소를 생략해 더 빨리 개발 환경을 띄워 주었지만, 그런 복잡한 명령이 정확히 무엇을 하는지에 대한 정교한 이해가 있어야 그것이 필요한지 아닌지를 알 수 있었습니다.우리는 이것을 개선하고 싶다는 의지가 매우 강했습니다. 당연히 우리 자신을 위해서이기도 했지만, 기여 과정을 더 쉽게 만들기 위해서이기도 했습니다. 그리고 실제로 그렇게 했습니다. 새로운 프런트엔드는 시작하는 데 2_초_가 걸리고, 정말로 필요한 명령은 하나뿐입니다.
이 속도의 상당 부분은 빌드 도구로 Rspack을 사용한 덕분입니다. 예전 프런트엔드가 사용하던 것은 Webpack이었고, 공정하게 말하자면 매우 뛰어난 설정 가능성을 제공했습니다. 우리의 아키텍처 접근 방식에는 그런 유연성이 필요했습니다. Rspack은 webpack 호환 API를 제공하지만 Rust로 작성되었고, 매우 빠릅니다.
현재 650 LOC 정도 되는 Rspack 설정이 꼭 단순 하다고 말할 수는 없지만, 저는 그것이 직관적 이라고는 말할 수 있습니다. 빌드 도구 안에서 마술처럼 숨어 돌아가는 로직은 거의 없습니다. 이 설정은 우리가 앞서 설명한 번들링, polyfill, mixin, 최적화 등을 모두 수행합니다.
우리 아키텍처는 거의 모든 것을 처리하는 단일 명령에 의존할 수 있을 만큼 단순합니다. 예전과 달리 독립적으로 따로 빌드해야 하는 별개의 요소들이 사실상 없습니다. SSR 없이 렌더링할 수 있는 SPA도 없습니다. server component는 우리 아키텍처의 핵심이기 때문입니다. 웹사이트가 조립되는 방식이 하나뿐이므로 여러 명령이 필요하지 않습니다.
이 덕분에 개발 환경은 예전보다 훨씬 더 프로덕션 환경과 유사해졌습니다. 주요 차이는 변경이 있을 때마다 페이지를 다시 로드하는지, 그리고 각 요청마다 server component를 다시 로드하고 다시 렌더링하는지 정도입니다. 즉, Rspack 설정 자체를 변경하거나 각종 프로덕션 수준 최적화를 적용하는 경우가 아니라면, 개발 환경을 재시작할 일이 거의 없습니다. 우리는 그런 최적화와 동적 로딩 비활성화가 적용된 프로덕션 빌드를 만드는 또 다른 명령도 가지고 있으며, 필요할 때 쉽게 실행할 수 있습니다.
이 새로운 환경에서 개발하는 일은 저에게 정말 큰 즐거움이었고, 이런 개선을 이뤄 낸 것이 너무 기쁩니다.
이 블로그 글이 이미 충분히 길다는 데에는 여러분도 동의하시리라 생각합니다. 콘텐츠 팀이 이제 그만 마무리하라고 보내는 Slack 알림 소리도 들리는 것 같습니다. 하지만 그래도 새로운 프런트엔드 아키텍처 이야기의 절반도 다 하지 못한 기분입니다.
더 알고 싶거나, 우리가 한 일에 대해 궁금한 점이 있다면 Discord의 #platform 채널에서 편하게 이야기해 주세요.
문제를 발견했다면 fred GitHub 리포지터리에 제기해 주세요. 그리고 그곳에 여러분이 고쳐 보고 싶은 것이 있다면, 직접 시도해 보고 PR을 제출해 주세요.
이 새로운 프런트엔드를 만드는 일은 정말 큰 즐거움이었습니다. 이를 문서화하는 그 웹사이트를 만들기 위해 새로운 웹 기술을 사용할 수 있었다는 것은 특권이었습니다.