Skip to content

Instantly share code, notes, and snippets.

@leedc0101
Created April 12, 2026 14:53
Show Gist options
  • Select an option

  • Save leedc0101/0d224ce1bbe609011de618439b5af572 to your computer and use it in GitHub Desktop.

Select an option

Save leedc0101/0d224ce1bbe609011de618439b5af572 to your computer and use it in GitHub Desktop.
frontend-briefing-2026-04-12-KST

frontend-briefing-2026-04-12-KST

프론트엔드 데일리 브리핑 한국어 번역 모음입니다.

수록 파일:

  1. Moving Railway's Frontend Off Next.js
  2. Under the hood of MDN's new frontend
  3. Alternatives to the !important Keyword
  4. Full Text Search with IndexedDB
  5. You can't cancel a JavaScript promise (except sometimes you can)
  6. Hugo’s new CSS powers

기준일: 2026-04-12 KST

원문 정보

Next.js는 우리에게 큰 도움이 됐다. 그러다 더는 그렇지 않게 됐다

이제 Railway의 프로덕션 프론트엔드 전체는 Next.js 위에서 돌아가지 않는다. 대시보드, 캔버스, railway.com 전체가 이제 Vite + TanStack Router 위에서 동작하며, 우리는 이 마이그레이션을 다운타임 없이 단 두 개의 PR로 배포했다.

Next.js는 railway.com을 0에서 시작해 월 수백만 사용자를 처리하는 프로덕션 앱으로 키우는 데 큰 역할을 했다. 훌륭한 프레임워크였지만, 우리 제품에 가장 잘 맞는 선택지는 더 이상 아니었다.

프론트엔드 빌드 시간은 어느새 10분을 넘어갔다. 그중 6분은 Next.js 자체에 들었고, 절반가량은 “finalizing page optimization” 단계에서 소비됐다. 하루에도 여러 번 배포하는 팀에게 이런 빌드 시간은 사소한 불편이 아니다. 매 이터레이션마다 내야 하는 비싼 세금에 가깝다.

Railway의 앱은 압도적으로 클라이언트 사이드 중심이다. 대시보드는 풍부하고 상태가 많은 인터페이스이고, 캔버스는 실시간이며, 웹소켓이 곳곳에 있다. Next.js의 서버 우선(server-first) 프리미티브는 우리가 실제로 많이 쓰지 않는 영역이었다. 오히려 Pages Router 위에서 레이아웃과 라우팅 문제를 해결하려고 자체 추상화를 계속 얹어야 했다.

우리는 여전히 Pages Router를 사용하고 있었고, 그 때문에 공용 레이아웃은 어색하고 해키하게 구성될 수밖에 없었다. 모든 레이아웃 패턴이 프레임워크의 일급 기능이 아니라 임시방편의 우회로처럼 붙어 있었다. App Router는 이 문제의 일부를 해결해줄 수 있었겠지만, 그 철학은 강하게 서버 우선에 기울어 있다. 우리의 제품은 의도적으로 클라이언트 주도형이기 때문에, 이를 채택하는 일은 필요하지 않은 패러다임에 맞춰 제품을 다시 짜는 일과 비슷했다.

왜 TanStack Start + Vite였나

우리가 원한 것은 우리가 실제로 프론트엔드를 만드는 방식과 맞는 스택이었다. 더 명시적이고, 클라이언트 우선이며, 빠르게 반복할 수 있는 스택 말이다. 여기에 더해, 우리는 이 조합으로 작업하는 경험 자체를 진심으로 즐겼다.

프로덕트 팀 입장에서도 프론트엔드를 어떻게 구현해야 할지 덜 고민하게 해주는 몇 가지 장점이 있었다.

  • 기본 제공되는 타입 안전 라우팅. route params와 search params가 추론되고, 전체 라우트 트리 전반에 자동완성이 동작하며, 라우트 자체도 파일 시스템으로부터 생성된다.
  • 일급 레이아웃. pathless layout routes가 기존의 레이아웃 해킹을 조합 가능하고 예측 가능한 구조로 대체했다.
  • 신경 쓸 필요조차 없을 정도로 빠른 개발 루프. 즉시 HMR, 거의 0에 가까운 시작 시간 덕분에 코드를 바꾸고 결과를 보는 사이클이 사실상 사라진다.
  • 필요한 곳에서만 쓰는 SSR. 마케팅 페이지, 체인지로그, 채용 페이지에는 SSR을 쓰고, 그 외 대부분은 순수 클라이언트 사이드로 유지한다. 이점이 없는 화면에 서버 렌더링을 억지로 밀어 넣지는 않겠다는 뜻이다.
  • 더 명시적인 모델. TanStack는 프레임워크 마법을 덜 쓰고, 내부 동작을 더 많이 제어할 수 있게 해준다고 느꼈다.

연휴 동안 팀원 몇 명이 TanStack Start를 시험해봤는데 반응은 만장일치였다. 이걸로 만드는 과정이 즐거웠고, Railway 대시보드 같은 제품에서는 그 점이 어떤 벤치마크 못지않게 중요했다.

두 개의 PR, 다운타임 0

선택을 마친 뒤 나는 바로 작업에 들어갔다. 머지 전 스쿼시를 기준으로 하면 수백 번의 커밋을 만들었을 것이다.

수백만 사용자를 처리하고 200개가 넘는 라우트를 가진 프로덕션 프론트엔드를 옮기는 작업은 보통 수개월에 걸친 병행 운영과 점진적 전환이 필요하다. 하지만 우리는 데드라인이 있었고, 이를 두 개의 PR로 끝냈다.

PR 1은 Next.js 전용 의존성을 전부 제거하는 작업이었다. next/image, next/head, next/router를 각각 네이티브 브라우저 API나 프레임워크 비종속 대안으로 바꿨다. 이 PR은 프레임워크 자체는 건드리지 않았다. 단지 Next.js에 대한 의존을 없애서, PR 2에서 프레임워크를 깔끔하게 교체할 수 있게 만들었다.

PR 2는 프레임워크 교체 자체였다. 200개 이상의 라우트를 옮겼다. 먼저 페이지 파일에서 라우팅과 무관한 부분을 개별 React 컴포넌트로 체계적으로 분리한 뒤, 기존 페이지 트리로부터 전체 라우트를 생성했다.

이후 서버 레이어로 Nitro를 추가하고, next.config.js를 Nitro 설정으로 대체했다. 그 과정에서 500개가 넘는 리다이렉트, 보안 헤더, 캐시 규칙을 한 곳으로 통합했다. 또한 Next.js가 폴리필해주던 Node.js API(Buffer, url.parse 등)도 브라우저 네이티브 대안으로 바꿨는데, 그 부수 효과로 코드가 더 깔끔해졌다.

배포는 일요일 이른 아침에 머지됐다. 팀은 곧바로 dogfood를 시작했고, Discord 워룸에서 실시간으로 점검하며 같은 날 여러 수정이 연달아 반영됐다. 다운타임은 없었다.

무엇을 포기했나

물론 더 빠르고 더 명시적인 스택을 얻었지만, 트레이드오프가 없었던 것은 아니다.

  • 내장 이미지 최적화. next/image는 일반 이미지 태그와 Fastly의 엣지 이미지 최적화로 대체했다.
  • 생태계 일부. next-seo, next-sitemap 같은 도구는 작고 단순한 사내 구현으로 바꿨다. 직접 만들기 어렵지 않았고, 추가 의존성도 줄였다.
  • 성숙도. TanStack Start는 아직 새롭고, 거친 부분이 있을 수 있다. 그래도 방향성이 맞고 유지보수자들의 응답이 빠르며, 우리는 Vite와 TanStack을 후원할 만큼 이 생태계가 가는 방향을 믿고 있다.

Railway의 프론트엔드는 Railway 위에서 돈다

우리는 사용자들이 Railway 위에서 서비스를 운영하는 방식 그대로, 우리 프로덕션 프론트엔드도 운영한다. PR마다 프리뷰 배포를 만들고, 헬스 체크를 돌리며, 다운타임 없는 롤아웃을 수행한다. 전체 빌드 시스템과 프레임워크를 바꿀 때도 인프라는 손대지 않았다. 코드를 바꾸고 푸시했을 뿐이고, 나머지는 Railway가 처리했다.

이제 대부분의 트래픽은 Fastly가 엣지에서 직접 서빙한다. 마케팅 페이지는 캐시되고, 동적 페이지는 필요한 곳에 ISR을 사용하며, 그 결과 프론트엔드 서버는 대부분 한가하다. Vite의 애셋 모델은 이런 구조와 특히 잘 맞는다. 각 모듈은 자체 content-hashed chunk를 가지기 때문에, 예를 들어 결제 관련 코드 하나를 바꿔도 그 청크만 무효화된다. 재방문 사용자는 메가바이트가 아니라 킬로바이트만 다시 받으면 된다.

우리는 프론트엔드 배포가 이렇게 되어야 한다고 생각한다. 빌드는 빨라야 하고, 애셋은 불변이며 캐시 친화적이어야 하고, 그 아래 인프라는 롤아웃과 프리뷰, 라우팅을 개발자가 의식하지 않아도 알아서 처리해야 한다. 프론트엔드 프레임워크는 반복 속도에 최적화되어야 하고, 인프라는 그 반복을 보이지 않게 만들어야 한다. 우리는 바로 그런 경험을 우리 자신과 Railway 사용자 모두를 위해 만들고 있다.

왜 지금이었나

프론트엔드에서 반복 속도는 지금 그 어느 때보다 중요하다.

10분 넘게 걸리던 빌드는 이제 2분 이내로 끝난다. 개발 서버는 즉시 뜬다. 라우트 변경은 경계에서 타입 체크된다. 레이아웃은 더 이상 우회책 없이 자연스럽게 조합된다.

결국 병목은 “코드를 쓰는 순간”과 “그 코드가 사용자 앞에 도달하는 순간” 사이의 간격이다. 우리가 한 모든 일, 프레임워크 교체, 엣지 캐싱, 애셋 모델 정리는 그 간격을 줄이기 위한 작업이다. Vite + TanStack는 프론트엔드 변경을 거의 즉시 배포할 수 있는 세계를 가능하게 해주고, 우리는 바로 그 세계를 향해 가고 있다.

원문 정보

작년에 우리는 MDN의 새로운 프론트엔드를 공개했다. 가장 눈에 띄는 변화는 스타일 조정이었다. 페이지 전반의 디자인을 단순화하고 통일했다. 하지만 실제로 더 큰 변화는 독자 눈에 보이지 않는 곳, 즉 프론트엔드를 움직이는 코드 전반의 개편이었다. 이 글은 우리가 무엇을 바꿨고, 어떤 기술을 선택했으며, 왜 이런 선택을 했는지를 설명한다.

MDN의 아키텍처

MDN 프론트엔드가 왜 크게 바뀌었는지를 이해하려면, 먼저 MDN 콘텐츠가 어떤 과정을 거쳐 지금의 웹사이트가 되는지 봐야 한다.

간단히 요약하면 다음과 같다.

  • 문서는 Markdown으로 작성되고, 여러 git 저장소에서 기술 문서 작성자, 파트너, 외부 전문가, 그리고 거대한 커뮤니티 기여자 및 번역자들이 함께 관리한다.
  • 빌드 도구가 이 Markdown 파일들을 읽어 HTML로 변환하고, 각 페이지에 대한 보조 메타데이터와 함께 JSON 파일 집합으로 저장한다.
  • 프론트엔드는 이 JSON들을 순회하며 브라우저 호환성 표, 다국어 지원, 내비게이션 메뉴 등을 포함한 완전한 페이지를 조합한다. 우리는 이 단계를 SSR이라고 불러왔다.
  • 최종적으로 생성된 HTML, CSS, JavaScript 파일은 클라우드 버킷에 업로드되고 전 세계 사용자에게 전달된다.

왜 MDN 프론트엔드를 다시 만들었나

프론트엔드를 다시 짜야겠다는 생각은 오래전부터 있었다. 기존 프론트엔드인 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이었다.

Scrim을 이용한 개념 검증

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의 점진적 향상

특히 흥미로운 예가 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 인터랙션으로 자연스럽게 전환한다. 상단 내비게이션처럼 접근성이 중요한 영역에서 특히 유용했다.

DSD가 없는 브라우저 지원

Declarative Shadow DOM은 아직 모든 브라우저에 널리 보급된 것은 아니다. 그래서 global.css가 필요하다. 예를 들어 버튼 컴포넌트는 로드 전에도 레이아웃 시프트가 크지 않도록 최소한의 전역 스타일을 준다.

mdn-button {
  display: inline-flex;
  vertical-align: middle;
}

서버 컴포넌트 CSS 추적

서버 컴포넌트는 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에서는 병렬 다운로드와 연결 재사용이 가능하기 때문에, 여러 작은 파일이 꼭 불리하지만은 않다. 오히려 개별 웹 컴포넌트가 독립적으로 빨리 인터랙티브해질 수 있고, 캐시도 더 잘 작동한다. 실제 벤치마크 결과, 합치는 쪽이 같거나 더 느린 경우도 있었다.

Baseline 활용

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초다. 실질적으로 필요한 명령은 하나뿐이다.

npm run start

이 속도 향상의 큰 부분은 Rspack 덕분이다. 기존 Webpack은 유연했지만 느렸다. Rspack은 Webpack 호환 API를 유지하면서 Rust로 구현돼 매우 빠르다. 설정 파일이 짧지는 않지만, 무슨 일이 일어나는지 감춰진 마법이 적고 구조는 오히려 더 직관적이다.

무엇보다 새 아키텍처에서는 “SSR 없는 개발 모드” 같은 별도 세계가 필요 없다. 서버 컴포넌트가 구조의 핵심이기 때문에, 개발 환경이 곧 실제 서비스 구조와 매우 비슷하다. 페이지 변경 시 새로고침과 서버 컴포넌트 재렌더링만 반복하면 대부분의 작업이 된다. 이제는 Rspack 설정 자체를 만지는 경우가 아니라면 개발 서버를 거의 재시작하지 않는다.

마무리

이번 글에서 우리는 새 MDN 프론트엔드의 절반도 다 설명하지 못했다. 그래도 핵심은 분명하다. MDN은 거대한 SPA가 아니라, 정적 콘텐츠 중심의 사이트 위에 적절한 인터랙션 섬을 얹는 구조로 재설계됐다. React 래퍼 문제를 버리고, 웹 컴포넌트와 서버 컴포넌트를 중심으로 아키텍처를 새로 잡았으며, 필요한 CSS와 JavaScript만 싣는 방식으로 성능과 유지보수성을 함께 얻었다.

이 새 프론트엔드를 만드는 일은 아주 즐거운 경험이었다. 새 웹 기술을 문서화하는 사이트를, 바로 그 새 웹 기술로 다시 만드는 일은 분명 특권 같은 작업이었다.

원문 정보

가끔 예전 프로젝트, 혹은 더 끔찍하게는 남이 만든 오래된 프로젝트를 다시 보면 CSS가 시간이 지나며 얼마나 혼란스러워질 수 있는지 새삼 실감하게 된다. 그리고 그런 프로젝트에는 높은 확률로 !important가 끼어 있다. 왜 개발자들이 여기에 의존하는지도 이해하기 어렵지 않다. 당장 눈앞의 문제를 빠르게 해결해주고, 해당 규칙의 우선순위를 강제로 끌어올려 주기 때문이다.

그렇다고 !important가 절대 쓰면 안 되는 것은 아니다. 문제는 이것을 쓰기 시작하는 순간, 더 이상 캐스케이드를 활용하는 것이 아니라 우회하기 시작한다는 점이다. 여러 사람이 함께 작업하는 큰 프로젝트에서는 새 override가 생길수록 다음 override는 더 어려워진다.

캐스케이드 레이어, specificity 조절, 더 똑똑한 선언 순서, 약간의 선택자 트릭만으로도 !important 없이 더 깔끔하고 예측 가능하며, 미래의 나에게 덜 부끄러운 CSS를 만들 수 있다.

이제 그런 대안들을 살펴보자.

Specificity와 !important

선택자 specificity는 깊게 들어가면 끝이 없는 주제지만, !important를 이해하려면 CSS가 원래 어떤 규칙으로 충돌을 해결하는지는 알아야 한다.

기본적으로 CSS는 각 선택자에 일종의 “가중치”를 부여한다. 두 규칙이 같은 요소를 겨냥하면 specificity가 더 높은 규칙이 이긴다. specificity가 같다면, 스타일시트에서 더 나중에 선언된 규칙이 우선한다.

  • 인라인 스타일(style="...")은 가장 무겁다.
  • ID 선택자(#header)는 클래스나 태그 선택자보다 강하다.
  • 클래스, 속성, 의사 클래스(.btn, [type="text"], :hover)는 중간 정도 가중치를 가진다.
  • 태그 선택자와 의사 요소(div, p, ::before)는 더 낮다. 참고로 * 선택자는 이것보다도 낮아 specificity가 0-0-0이다.
/* 낮은 specificity (0,0,1) */
p {
  color: gray;
}

/* 중간 specificity (0,1,0) */
.button {
  color: blue;
}

/* 높은 specificity (1,1,0) */
#header .button {
  color: red;
}
<!-- 인라인 스타일 -->
<p style="color: green;">Hello</p>

인라인 스타일이 “가장 무겁다”는 사실이, 그것이 왜 깔끔하지 않다고 여겨지는지를 설명해주기도 한다. 보통 우리가 유지하려는 CSS 구조를 대부분 무시해버리기 때문이다.

그런데 !important는 이 규칙을 바꿔버린다.

p {
  color: red !important;
}

#main p {
  color: blue;
}

여기서는 #main p가 더 구체적이어도, 문단은 빨간색이 된다. !important가 specificity와 source order를 뛰어넘어 그 선언을 위로 끌어올리기 때문이다.

왜 !important가 문제가 될 수 있나

여러 명이 참여하는 프로젝트에서 !important는 흔히 이런 생애주기를 따른다.

“왜 이게 안 먹지? !important 붙여. 됐다.”

그 다음 다른 개발자가 같은 컴포넌트를 수정하려고 한다. 그런데 자기 규칙이 적용되지 않는다. 조사해보니 예전 코드에 !important가 있다. 이제 선택지는 둘이다.

  • 그걸 지우고 다른 데가 깨질 위험을 감수하거나
  • 또 다른 !important를 얹어 덮어버리거나

대개는 원래 왜 붙었는지 정확히 모르기 때문에, 더 안전해 보이는 두 번째 선택을 하게 된다. 그렇게 프로젝트는 빠르게 엉킨다.

기술적으로도 핵심 문제는 같다. !important는 CSS 캐스케이드가 의도한 충돌 해결 순서를 깨뜨린다. CSS는 원래 specificity와 선언 순서를 통해 예측 가능하게 동작하도록 설계됐다. 나중 규칙이 앞 규칙을 덮고, 더 구체적인 선택자가 덜 구체적인 선택자를 덮는 것이다.

이 문제가 가장 명확하게 드러나는 곳 중 하나는 테마 전환이다.

.button {
  color: red !important;
}

.dark .button {
  color: white;
}

다크 테마 안에서도 버튼은 빨간색이다. 즉, 스타일시트 전체를 이해하기가 더 어려워지고, 디버깅 난이도도 올라간다.

물론 이것이 !important를 절대 쓰면 안 된다는 뜻은 아니다. 유틸리티 클래스, 접근성 override, 사용자 스타일시트처럼 정당한 경우가 있다. 다만 선택자 충돌을 해결하는 기본 수단으로 습관처럼 쓰고 있다면, 그건 대개 캐스케이드 구조에 손을 봐야 한다는 신호다.

대안 1: 캐스케이드 레이어

캐스케이드 레이어는 CSS에서 우선순위 그룹을 명시적으로 정의하게 해준다. 선택자 specificity에만 의존하지 않고, 스타일의 범주별 우선순위를 미리 정할 수 있다.

@layer reset, defaults, components, utilities;

이 선언은 낮은 우선순위에서 높은 우선순위 순으로 레이어를 정한다. 그리고 각 레이어에 스타일을 넣는다.

@layer defaults {
  a:any-link {
    color: maroon;
  }
}

@layer utilities {
  [data-color='brand'] {
    color: green;
  }
}

여기서 [data-color='brand']는 specificity가 더 낮아도 utilities 레이어가 나중에 정의됐기 때문에 우선한다.

레이어 안에서는 여전히 specificity 규칙이 작동하지만, 레이어 간에는 레이어 순서가 더 중요하다. 즉, 개별 규칙마다 !important로 전쟁을 벌이는 대신, 아예 “이 범주의 스타일이 더 강하다”는 구조를 설계할 수 있다.

서드파티 CSS 통합에도 유용하다.

@layer framework, components;

@import url('framework.css') layer(framework);

@layer components {
  .card {
    padding: 2rem;
  }
}

이렇게 하면 프레임워크의 selector가 더 구체적이어도, framework 레이어에 속한 이상 components 레이어에 있는 내 스타일이 우선한다. 단, 프레임워크가 !important를 쓰지 않았다는 전제에서 말이다.

흥미로운 점은, 레이어와 !important를 함께 쓰면 직관이 깨진다는 것이다. !important는 레이어 순서를 뒤집어버린다. 예를 들어:

  • utilities (가장 강함)
  • components
  • defaults (가장 약함)

이 구조에서 !important가 붙으면 실제 우선순위는 이렇게 된다.

  • !important defaults (가장 강함)
  • !important components
  • !important utilities
  • 일반 utilities
  • 일반 components
  • 일반 defaults (가장 약함)

즉, !important는 단순한 “맨 위로 점프”가 아니라, 캐스케이드 레이어 구조를 통째로 비틀어버릴 수 있다.

대안 2: :is() 의사 클래스

:is()는 전달된 인자 중 가장 강한 specificity를 가져온다. 이 점을 이용하면 실제 매칭 대상은 유지하면서 specificity만 끌어올릴 수 있다.

#sidebar a {
  color: gray;
}

.nav-link {
  color: blue;
}

이때 !important 대신 다음처럼 쓸 수 있다.

:is(#some_id, .nav-link) {
  color: blue;
}

이 선택자는 ID 수준 specificity를 가지면서도 .nav-link를 매칭하게 만들 수 있다. 여기서 #some_id는 실제 DOM에 없어도 specificity만 끌어올리는 용도로 쓴다. 다만 정말 존재하는 ID라면 그 요소도 매칭되므로, 충돌 없는 값을 써야 한다.

반대로 :where()는 항상 specificity를 0,0,0으로 만든다. 그래서 reset이나 base 스타일처럼 나중 규칙이 쉽게 덮어쓰길 원하는 경우에 좋다.

대안 3: 선택자 반복

가장 직관적인 specificity 증가 방법 중 하나는 선택자를 반복하는 것이다.

.button {
  color: blue;
}

.button.button {
  color: red;
}

이렇게 하면 뒤 규칙의 specificity가 더 커진다. 다만 남용하면 가독성이 급격히 나빠지므로 신중해야 한다.

대안 4: 선언 순서 재정렬

specificity가 같다면, CSS는 뒤에 나오는 선언을 우선한다. 큰 스타일시트에서는 이 점을 놓치기 쉽다. 더 일반적인 규칙이 더 구체적인 규칙을 계속 이긴다면, 선택자 복잡도를 올리기 전에 먼저 파일 로드 순서와 선언 순서를 확인해보는 것이 좋다.

처음부터 스타일시트 구조를 “reset → base → layout → components → utilities”처럼 일반적인 것에서 구체적인 것으로 정리해 두면 이런 충돌을 많이 줄일 수 있다.

그래도 !important가 맞는 경우

여기까지 읽고 나면 !important는 나쁜 것처럼 느껴질 수 있지만, 그렇지는 않다. 중요한 건 의도다. CSS 캐스케이드를 이해한 상태에서 “이 규칙은 어디서든 반드시 적용돼야 한다”고 판단해 사용하는 것과, 지금 안 되니까 일단 덕지덕지 붙이는 것은 완전히 다르다.

대표적인 정당한 사례는 유틸리티 클래스다. 예를 들어 .visually-hidden은 어디서든 같은 역할을 해야 한다.

.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  overflow: hidden !important;
  clip-path: inset(50%) !important;
}

서드파티 스타일이나 JavaScript가 주입한 인라인 스타일을 덮어야 하는 상황도 흔하다. 접근성 관점에서는 사용자 스타일시트가 대표적이다. 모든 웹페이지에서 확실하게 우선하려면 사실상 !important가 유일한 수단이 되기도 한다.

사용자 브라우저 설정을 존중하는 경우도 마찬가지다. 예를 들어 prefers-reduced-motion처럼 애니메이션을 강하게 줄여야 하는 경우다.

@media screen and (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

마무리

좋은 !important와 나쁜 !important의 차이는 결국 의도에 있다. CSS 캐스케이드를 이해한 뒤, 특정 선언이 항상 이겨야 한다고 판단해 쓰는 것은 합리적일 수 있다. 하지만 selector 충돌이 생길 때마다 반창고처럼 붙이는 습관은 시간이 지나며 반드시 문제를 만든다.

먼저 캐스케이드 레이어, specificity 조정, 선언 순서 재구성 같은 구조적 해법을 살펴보자. 그러고도 정말 필요한 곳에서만 !important를 쓰는 편이, 미래의 팀원과 미래의 나에게 훨씬 친절하다.

원문 정보

Borogove를 만들면서, 로컬에 저장된 채팅 메시지 히스토리에 대한 전문 검색(full-text search)이 필요해졌다. 웹 앱에서 로컬 저장소로 흔히 쓰는 것은 IndexedDB인데, 이건 꽤 저수준 시스템이라 전문 검색 같은 기능을 쉽게 제공하지는 않는다. 그렇다면 IndexedDB에서 전문 검색을 가장 단순하면서도 성능 좋게 구현하는 방법은 무엇일까?

이 글에서 말하는 전문 검색은 “사용자가 입력한 모든 단어를, 순서는 상관없이 모두 포함하는 메시지를 찾는 검색”이다.

먼저는 테이블 전체 스캔

최종 해법은 아니지만, 출발점으로는 거의 항상 맞는 접근이다. 데이터가 작다면, 예를 들어 문서가 1만 건 이하라면 전체 스캔은 꽤 빠르고 구현도 가장 단순하다.

먼저 IndexedDB 요청을 Promise로 감싸는 헬퍼가 필요하다.

function promisifyRequest(request) {
  return new Promise((resolve, reject) => {
    request.oncomplete = request.onsuccess = () => resolve(request.result);
    request.onabort = request.onerror = () => reject(request.error);
  });
}

그 다음 검색어와 문서를 같은 방식으로 토큰화해야 한다.

const stopwords = ["and", "if", "but"];
function tokenize(s) {
  return s
    .toLowerCase()
    .split(/\s*\b/)
    .filter((w) => w.length > 1 && w.match(/\w/) && !stopwords.includes(w));
}

function stemmer(s) {
  // Optional, https://www.npmjs.com/package/porter2 or similar
}

그리고 검색 함수는 이렇게 시작할 수 있다.

async function search(q) {
  const qTerms = new Set(tokenize(q).map(stemmer));
  const tx = db.transaction(["messages"], "readonly");
  const store = tx.objectStore("messages");
  const cursor = store.openCursor();

  const result = [];
  while (true) {
    const cresult = await promisifyRequest(cursor);
    if (!cresult?.value) break;

    if (new Set(tokenize(cresult.value.text).map(stemmer)).isSupersetOf(qTerms)) {
      result.push(cresult.value);
    }
    cresult.continue();
  }

  return result;
}

여기에는 중요한 구성 요소가 몇 가지 있다.

첫째, 쿼리를 토큰화한다. 여기서 “단어”는 꼭 사전적인 단어일 필요는 없다. 문서와 쿼리 모두에 대해 같은 방식으로 일관되게 쪼개기만 하면 된다. 예시에서는 \b 단어 경계 정규식을 신뢰하고, 공백을 정리하고, 길이가 너무 짧거나 비단어 문자만으로 이뤄진 항목을 버리며, stopword도 제거한다.

둘째, 토큰에 stemming을 적용할 수 있다. 예를 들어 “flying”을 검색했을 때 “fly”가 포함된 문서도 찾고 싶다면 stemming이 도움이 된다. 다만 검색 요구사항에 따라 선택 사항이다.

셋째, 모든 메시지를 순회한다. 특정 정렬이 필요하면 object store 대신 인덱스를 순회할 수도 있다. 각 문서의 텍스트를 토큰화하고 stem 처리한 뒤, 쿼리의 모든 term을 포함하는지 검사해 결과에 넣는다.

작은 데이터셋이라면 이 방식만으로도 충분히 실용적이다.

인덱스를 써서 속도 올리기

하지만 메시지가 아주 많다면, 예를 들어 백만 건 이상이라면 전체 스캔은 느려질 수 있다. IndexedDB가 제공하는 인덱스는 정렬된 인덱스 하나뿐이며, 보통 B-Tree 기반이다. 이것이 전문 검색 전용 인덱스는 아니지만, 비교적 적은 복잡성으로도 큰 성능 향상을 얻을 수 있다.

먼저 onupgradeneeded 단계에서 인덱스를 만든다.

tx.objectStore("messages").createIndex("terms", "terms", { multiEntry: true });

multiEntry 인덱스는 배열 전체를 하나로 색인하는 대신, 배열 각 항목마다 인덱스 엔트리를 만든다. 따라서 메시지를 저장할 때도 토큰 배열을 함께 저장해야 한다.

tx.objectStore("messages").put({
  text,
  terms: [...new Set(tokenize(text).map(stemmer))],
});

문제는 이 인덱스로는 “전체 쿼리”를 바로 검색할 수 없고, 한 단어(term) 단위로만 찾을 수 있다는 점이다. 하지만 여기서 중요한 아이디어가 나온다. 쿼리의 모든 term을 동시에 찾지 못해도, 적어도 하나의 term은 반드시 포함하는 문서들만 먼저 좁혀볼 수 있다. 그리고 그중에서도 결과 수가 가장 적은 term을 기준으로 스캔하면 훨씬 효율적이다.

async function search(q) {
  const qTerms = new Set(tokenize(q).map(stemmer));
  const tx = db.transaction(["messages"], "readonly");
  const store = tx.objectStore("messages");
  const index = store.index("terms");

  let probeTerm = null;
  let probeScore = null;
  for (const term of qTerms) {
    const score = await promisifyRequest(index.count(IDBKeyRange.only(term)));
    if (!probeTerm || score < probeScore) {
      probeTerm = term;
      probeScore = score;
    }
  }

  const result = [];
  const cursor = index.openCursor(IDBKeyRange.only(probeTerm));
  while (true) {
    const cresult = await promisifyRequest(cursor);
    if (!cresult?.value) break;
    if (new Set(cresult.value.terms).isSupersetOf(qTerms)) {
      result.push(cresult.value);
    }
    cresult.continue();
  }

  return result.sort((a, b) =>
    a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0,
  );
}

이 접근의 흐름은 이렇다.

  1. 쿼리를 term 집합으로 만든다.
  2. 각 term에 대해 index.count를 사용해 매칭되는 문서 수를 센다.
  3. 결과가 가장 적은 term을 probeTerm으로 고른다.
  4. 그 term을 포함하는 문서만 인덱스 커서로 순회한다.
  5. 각 문서가 쿼리의 모든 term을 포함하는지 최종 확인한다.
  6. 필요하면 원하는 정렬 순서로 정렬한다.

term별 count를 매번 구하는 비용이 부담된다면, 문서 삽입 시 term별 개수를 별도 키에 저장해 캐시할 수도 있다. 다만 예시 수준에서는 count() 자체가 꽤 빠르다.

왜 이게 잘 통하나

핵심은 전체 스캔을 없애는 것이 아니라, 매우 작은 부분집합만 스캔하게 만드는 데 있다. 일반적인 자연어 데이터에서는 쿼리의 여러 term 중 하나는 비교적 희귀한 경우가 많다. 그 term을 기준으로 후보군을 좁히면, 백만 건 전체를 보지 않아도 된다.

글의 테스트에서는 이 단순한 인덱스 전략만으로도 체감 성능이 “쓸 수 없을 정도로 느림”에서 “거의 즉시 응답” 수준으로 바뀌었다. 가장 작은 term에 매칭되는 문서 수가 대체로 1만 건 이하로 유지되었기 때문이다.

정리

이 글의 요점은 전문 검색을 위해 곧바로 거대한 검색 엔진으로 가지 않아도 된다는 것이다.

  • 데이터가 작다면, 전체 스캔이 가장 단순하고 충분히 빠르다.
  • 데이터가 커지면 multiEntry 인덱스 하나만 추가해도 엄청난 개선을 얻을 수 있다.
  • 쿼리 전체를 직접 인덱싱하지 않더라도, 가장 희귀한 term을 probe로 잡아 후보군을 줄이는 전략이 매우 효과적이다.
  • 토큰화, stopword 제거, stemming 같은 기본 텍스트 처리 품질이 전체 검색 경험을 크게 좌우한다.

즉, IndexedDB는 전문 검색용 데이터베이스는 아니지만, 약간의 설계만 더해주면 로컬 웹 앱 수준에서는 놀랄 만큼 실용적인 검색 엔진이 될 수 있다.

원문 정보

JavaScript의 Promise는 취소할 수 없다. .cancel()도 없고, AbortController와 자동으로 연결되지도 않으며, 언어 차원에서 “됐어, 중단해”라고 말할 수 있는 내장 수단도 없다. TC39는 2016년에 취소 가능한 Promise를 논의했지만, 결국 제안은 철회됐다. 임의의 코드를 실행 중간에 강제로 멈추면 열린 핸들, 반쯤 기록된 데이터 같은 더러운 상태를 남길 수 있기 때문이다. 진짜 취소를 하려면 결국 협력적 정리가 필요하고, 그러면 사람들이 기대하는 단순한 .cancel()의 의미는 무너진다.

그런데 이상한 방식으로는 “멈추게” 만들 수 있다. 절대 resolve되지 않는 Promise를 반환하고, 그걸 await하게 만든 다음, 나머지는 가비지 컬렉터가 정리하게 두는 것이다. 예외도, try/catch도, 특수한 반환값도 없다. 함수는 그냥 더 진행되지 않는다.

이 기법은 Inngest TypeScript SDK가 비동기 워크플로 함수를 중단하는 데 실제로 쓰는 방식이다. 하지만 그 자체로도 JavaScript 의미론을 이해하는 흥미로운 사례다.

왜 함수 중단이 필요한가

때로는 다른 사람이 작성한 async 함수를, 그 코드가 특별한 협조를 하지 않아도 정확한 지점에서 멈춰야 할 때가 있다. 함수 작성자는 평범한 async/await 코드를 쓴다. 대신 런타임이 언제 어디서 멈출지를 결정한다.

Inngest의 실제 사례는 서버리스 환경에서 돌아가는 워크플로였다. 워크플로 전체는 몇 시간 걸릴 수 있지만, 개별 invocation은 몇 초 또는 몇 분만 허용된다. SDK는 중간에 함수를 끊고, 진행 상태를 저장한 뒤, 나중에 다시 실행해 이어서 처리해야 한다. 그것도 사용자 코드가 이런 중단을 전혀 의식하지 못하는 형태로 말이다.

즉, 예외를 던지지 않고 await를 중단할 수 있어야 했다.

예외로 중단하기의 문제

가장 먼저 떠오르는 방법은 특수한 예외를 던지는 것이다.

class InterruptError extends Error {}

async function run(callback) {
  const result = await callback();
  throw new InterruptError();
}

async function myWorkflow() {
  const data = await run(() => fetchData());
  await run(() => processData(data));
}

문제는 사용자가 평범하게 try/catch를 쓰는 순간 드러난다.

async function myWorkflow() {
  let data;
  try {
    data = await run(() => fetchData());
  } catch {
    console.log("Failed to fetch data, using default");
    data = defaultData;
  }

  await run(() => processData(data));
}

개발자는 단지 fetchData()가 실패했을 때의 fallback을 작성한 것이지만, run()이 interruption을 위해 던진 예외까지 같이 삼켜버린다. 결국 원래는 함수가 멈춰야 했는데, defaultData를 사용해 다음 단계로 계속 진행된다. 사용자 코드 속 모든 try/catch가 런타임 제어 흐름을 깨뜨릴 잠재적 함정이 된다.

제너레이터는 잘 맞지만 불편하다

제너레이터는 원래 중단과 재개를 위해 설계됐다.

function* myWorkflow() {
  let data;
  try {
    data = yield run(async () => fetchData());
  } catch {
    console.log("Failed to fetch data, using default");
    data = defaultData;
  }

  yield run(async () => processData(data));
}

호출자는 .next()를 호출해 실행을 진행한다. 중단하고 싶으면 그냥 다음 .next()를 호출하지 않으면 된다. 예외도 없고, catch에 삼켜질 위험도 없다.

문제는 사용성이 떨어진다는 점이다. 사용자는 async function 대신 function*를, await 대신 yield를 배워야 한다. 게다가 병렬 처리도 어색하다. async/await에서는 Promise.all()로 자연스럽게 병렬 처리하지만, yield는 본질적으로 순차적이기 때문이다.

그래서 질문이 생긴다. “사용자에게 평범한 async/await를 유지하게 하면서, 제너레이터처럼 중단할 수는 없을까?”

핵심 트릭: 절대 resolve되지 않는 Promise

답은, 예외 대신 영원히 resolve되지 않는 Promise를 반환하는 것이다.

const start = Date.now();
process.on("exit", () => {
  const elapsed = Math.round((Date.now() - start) / 1000);
  console.log(`Exited after ${elapsed}s`);
});

async function interrupt() {
  return new Promise(() => {});
}

async function main() {
  console.log("Before interrupt");
  await interrupt();
  console.log("After interrupt");
}

main();

출력은 대략 이렇게 된다.

Before interrupt
Exited after 0s

After interrupt는 출력되지 않는다. 흥미로운 점은, 이 코드가 에러 없이 바로 종료된다는 것이다. 많은 사람은 “Promise가 안 끝났으니 프로세스가 영원히 살아 있겠지”라고 생각하지만, Node.js 이벤트 루프는 타이머, 소켓, I/O watcher 같은 활성 핸들이 있어야 유지된다. resolve되지 않은 Promise는 그냥 메모리 속 객체일 뿐이다. 기다릴 다른 핸들이 없으면 프로세스는 종료한다.

타이머를 추가하면 진짜로 멈춰 있다는 사실을 확인할 수 있다.

async function main() {
  setTimeout(() => {}, 2000);

  console.log("Before interrupt");
  await interrupt();
  console.log("After interrupt");
}

이 경우 프로세스는 2초 뒤 종료되지만, 여전히 After interrupt는 출력되지 않는다.

이걸 워크플로 재개에 어떻게 쓰나

재미있는 데모에서 끝나면 의미가 없다. 실제로 필요한 건 함수를 여러 번 호출하면서, 매번 한 단계만 실행하고 끊은 뒤 다음 호출에서 이어가는 것이다. 그러려면 이미 실행한 step의 결과를 저장해두고, 다음 호출에서는 그 결과를 재사용해야 한다.

사용자 입장에서는 이런 코드를 쓰게 된다.

async function myWorkflow(step) {
  console.log(" Workflow: top");

  const data = await step.run("fetch", () => {
    console.log(" Step: fetch");
    return [1, 2, 3];
  });

  const processed = await step.run("process", () => {
    console.log(" Step: process");
    return data.map((n) => n * 2);
  });

  console.log(" Workflow: complete", processed);
}

런타임은 이 함수를 반복 호출하면서 매번 새로운 step 하나만 실행해야 한다.

async function main() {
  const stepState = new Map();

  let done = false;
  let i = 0;
  while (!done) {
    console.log(`Run ${i}:`);
    done = await execute(myWorkflow, stepState);
    console.log("--------------------------------");
    i++;
  }
}

기대하는 출력은 이렇다.

Run 0:
 Workflow: top
 Step: fetch
--------------------------------
Run 1:
 Workflow: top
 Step: process
--------------------------------
Run 2:
 Workflow: top
 Workflow: complete [ 2, 4, 6 ]
--------------------------------

즉, 함수는 매번 처음부터 다시 실행되지만, 이미 끝난 step은 메모이즈된 결과를 반환하고, 새 step을 만나면 거기서 멈춘다.

이를 위한 execute()는 다음과 같이 구현할 수 있다.

async function execute(fn, stepState) {
  let newStep = null;

  fn({
    run: async (id, callback) => {
      if (stepState.has(id)) {
        return stepState.get(id);
      }

      newStep = { id, callback };
      return new Promise(() => {});
    },
  });

  await new Promise((r) => setTimeout(r, 0));

  if (newStep) {
    const result = await newStep.callback();
    stepState.set(newStep.id, result);
    return false;
  }

  return true;
}

동작 원리는 이렇다.

  • 이미 끝난 step이면 즉시 메모이즈된 결과를 반환한다.
  • 새 step이면 newStep에 기록하고, 영원히 끝나지 않는 Promise를 반환해 워크플로를 거기서 멈춘다.
  • setTimeout(..., 0)으로 매크로태스크 하나를 예약해, 그 전에 해결될 수 있는 모든 microtask가 다 비워지게 한다.
  • 그 사이 워크플로 함수는 메모이즈된 step들을 쭉 통과해 다음 새 step까지 진행한다.
  • 새 step이 발견되면 callback을 실제로 실행하고 결과를 저장한 뒤, 다음 invocation으로 넘긴다.
  • 더 이상 새 step이 없으면 함수가 끝난 것이므로 true를 반환한다.

setTimeout(0)이 필요한가

메모이즈된 step은 이미 값이 있으므로 await가 microtask로 바로 이어진다. 따라서 함수는 여러 메모이즈된 step을 아주 빠르게 통과할 수 있다. 우리가 너무 일찍 상태를 확인하면 아직 다음 새 step에 도달하지 못했을 수 있다. setTimeout(0)으로 매크로태스크를 하나 미뤄두면, 그 전에 모든 microtask가 먼저 실행되므로 함수가 충분히 앞으로 진행할 시간을 벌 수 있다.

실제 Inngest SDK는 더 똑똑한 접근을 쓰지만, 개념 설명에는 이 방법이 가장 이해하기 쉽다.

메모리 누수는 없을까

영원히 resolve되지 않는 Promise를 계속 만들면 메모리 누수 아닌가 싶을 수 있다. 하지만 중요한 건 Promise가 끝났는지가 아니라, 더 이상 참조되고 있는지 여부다. JavaScript 가비지 컬렉터는 settle 여부가 아니라 reachability를 본다.

만약 함수가 그 Promise를 await한 상태로 멈춰 있고, 그 함수와 Promise를 가리키는 참조가 완전히 끊기면, 가비지 컬렉터는 그 Promise와 일시 정지된 함수 상태 전체를 회수할 수 있다.

글에서는 FinalizationRegistry를 사용해 이 점을 실험적으로 보여준다.

const registry = new FinalizationRegistry((value) => {
  console.log(" GC", value);
});

그리고 각 step의 Promise를 등록한 뒤 강제 GC를 실행해 보면, 메모이즈된 step의 Promise도, 영원히 매달려 있던 Promise도 결국 회수되는 것을 볼 수 있다.

단, 함정도 있다. 참조 체인이 어딘가 남아 있으면 가비지 컬렉터는 손을 대지 못한다. 즉, 이 패턴은 “정말로 참조를 끊는다”는 전제가 있을 때만 안전하다.

마무리

영원히 끝나지 않는 Promise를 의도적으로 만드는 일은 언뜻 이단처럼 보인다. 하지만 제어 흐름 도구로서는 꽤 합법적이고 강력하다. 제너레이터는 깔끔하게 중단할 수 있지만 문법을 바꾸게 만들고, 예외 기반 중단은 try/catch에 깨진다. 반면 hanging Promise는 사용자가 평범한 async/await를 그대로 쓰게 하면서도, 런타임이 원하는 지점에서 함수를 안정적으로 멈출 수 있게 해준다.

가끔은 함수를 멈추는 가장 좋은 방법이, 기다릴 것을 아예 주지 않는 것일 수 있다.

원문 정보

이전 글에서도 언급했듯, 나는 Hugo v0.158.0에 css.Build 함수가 추가됐다는 소식에 흥미를 느꼈다. 이 기능은 Hugo 기반 사이트의 스타일링 구조를 생각할 때 꽤 중요한 변화를 가져온다. 물론 장점만 있는 것은 아니고, 몇 가지 제한도 함께 고려해야 한다.

Hugo 사이트의 CSS 구조를 설계할 때는 선택지가 많다. CSS 자체가 많이 발전했고, 브라우저도 이를 처리할 수 있게 됐다.

예를 들어 예전에는 이런 중첩 CSS를:

.my-div {
  background-color: #ffffaa;

  h1 {
    font-size: 2rem;
    color: #005500;
  }

  p {
    font-size: 0.75rem;
  }
}

그대로 프로덕션에 쓰려면 Sass 같은 전처리기나 PostCSS, Lightning CSS 같은 후처리기가 필요했다. 하지만 이제는 Baseline 2023을 지원하는 브라우저라면 이런 현대 CSS도 거의 그대로 이해한다. 다만 사이트 대상 사용자가 항상 최신 브라우저를 쓴다는 보장은 없기 때문에, 현실에서는 여전히 호환성 전략이 필요하다. 여기서 css.Build가 특히 빛난다. 완전하지는 않지만, 현대 CSS를 비교적 손쉽게 구형 브라우저 친화적인 결과물로 바꾸는 데 도움을 준다.

css.Build가 도와주는 것들

스타일이 단순하지 않다면 CSS를 여러 파일로 나눠 관리하고 싶어진다. 이런 경우 프로덕션에서 어떤 방식으로 전달할지도 결정해야 한다. HTML에서 스타일시트를 여러 개 링크할 수도 있지만, 특히 critical CSS를 포함하는 경우 여러 파일을 하나의 번들로 합치는 편이 더 나을 때가 많다.

예전에는 이런 번들링도 외부 패키지가 필요했지만, css.Build는 CSS 번들링도 지원한다.

또한 대부분의 사이트는 프로덕션에서 CSS를 minify하고 싶어 한다. Hugo는 예전부터 CSS minify를 할 수 있었지만, 이제 css.Build는 CSS 전용 파이프라인 안에서 이를 좀 더 자연스럽게 처리해준다.

즉, css.Build는 다음 같은 역할을 해낼 수 있다.

  • 현대 CSS의 일부를 트랜스파일해 구형 브라우저 호환성을 높이기
  • 여러 CSS 파일을 하나로 번들링하기
  • 프로덕션용으로 minify하기
  • Hugo 파이프라인 안에서 빠르게 처리하기

하지만 한계도 있다

문제는 css.Build만으로 모든 CSS 요구를 해결할 수 있는지는 사이트 성격에 따라 달라진다는 점이다. 이 기능은 내부적으로 esbuild 위에서 동작한다. 따라서 어떤 CSS 기능을 변환하거나 vendor prefix를 붙일 수 있는지는 결국 esbuild의 CSS 지원 범위에 의존한다.

즉, css.Build를 사용할 때는 “내 사이트가 쓰는 최신 CSS 기능이 esbuild에서 원하는 방식으로 처리되는가?”를 직접 확인해야 한다. 만약 원하는 기능을 충분히 변환하지 못한다면 선택지는 둘 중 하나다.

  1. PostCSS나 Lightning CSS 같은 추가 후처리기를 붙여 부족한 부분을 메운다.
  2. 아예 최신 브라우저만 지원 대상으로 삼는다.

이 판단을 할 때 Browserslist playground나 Baseline의 지원 브라우저 목록 같은 도구가 유용하다.

저자는 자신의 비교적 가벼운 개인 사이트에서는 Baseline 2024 지원 정도면 충분하다고 판단했지만, 방문자가 많거나 상업적 사이트라면 더 오래된 브라우저를 고려해야 할 수 있으므로 추가 후처리가 필요할 수도 있다고 말한다.

그럼 대안들과 비교하면 어떤가

글은 css.Build를 Sass, PostCSS, Lightning CSS와 비교한다.

1) Sass 전처리

Sass는 .scss.sass 파일을 작성하게 하며, Dart Sass 바이너리를 사용한다. Hugo Pipes와 잘 맞고 매우 빠르다. 하지만 Sass는 전처리기이기 때문에 브라우저 prefixing을 제공하지 않는다. 결국 오래된 브라우저까지 커버하려면 Sass 뒤에 또 다른 후처리기를 붙여야 하는 경우가 많다.

장점은 다음과 같다.

  • 익숙한 Sass 문법
  • @use를 통한 번들링
  • compressed 출력 옵션을 통한 minification
  • 수학 함수, 논리 함수, mixin 같은 강력한 추상화

하지만 이 추상화들 중 일부는 이미 순수 CSS로 흡수되고 있거나, 앞으로 흡수될 가능성도 있다. 즉 장기적으로는 “반드시 Sass여야만 하는 이유”가 줄어들 수 있다.

2) PostCSS 후처리

PostCSS는 Hugo Pipes와 잘 통합되지만, JavaScript 기반이라 다른 선택지보다 느리다. 대신 플러그인 생태계가 강력하다.

  • 트랜스파일
  • polyfill
  • vendor prefixing
  • @import 기반 번들링
  • minification

이런 작업을 매우 유연하게 처리할 수 있다. 호환성 요구가 빡빡하다면 여전히 강력한 옵션이다. 다만 개발 중 속도 저하는 감수해야 한다.

3) Lightning CSS 후처리

Lightning CSS는 Rust 기반이라 매우 빠르며, 트랜스파일, polyfill, prefixing, 번들링, minification을 잘 처리한다. 다만 Hugo 안에 자연스럽게 들어가지는 않고, 저자가 말하듯 약간 “억지로 끼워 넣는(shoehorn)” 작업이 필요하다. 게다가 개발 중 파일 감시 기능이 부족해 워크플로를 별도로 구성해야 한다.

즉, 성능은 좋지만 운영 편의성은 떨어진다.

css.Build의 실전 포지션

결국 css.Build는 “간단하고 빠른 기본값”으로서 매우 매력적이다.

  • 번들링과 minify는 별다른 추가 도구 없이 된다.
  • 개발 속도가 빠르다.
  • 사이트 규모가 클수록, 그리고 CSS가 많을수록 이 속도 차이가 더 체감된다.
  • 현대 CSS를 더 많이 그대로 활용하는 방향과 잘 맞는다.

반면 아래와 같은 경우에는 추가 도구가 필요할 수 있다.

  • 오래된 브라우저 지원이 중요한 경우
  • 최신 CSS 기능을 보다 강하게 트랜스파일해야 하는 경우
  • 세밀한 prefixing/polyfill 전략이 필요한 경우

즉, css.Build는 모든 문제를 해결하는 만능 도구라기보다는, 많은 Hugo 사이트에서 기본 CSS 파이프라인을 훨씬 단순하게 만들어주는 실용적인 업그레이드다.

핵심 결론

이 글의 메시지는 분명하다. 예전에는 CSS 번들링, minify, 최신 문법 처리만 하더라도 Sass, PostCSS, Lightning CSS 같은 여러 외부 도구를 조합해야 했다. 이제 Hugo의 css.Build 덕분에 적어도 상당수 사이트는 그 복잡성을 크게 줄일 수 있다.

물론 브라우저 지원 범위와 사용하는 CSS 기능 수준에 따라 후처리기가 여전히 필요할 수 있다. 하지만 개발 속도, 단순성, Hugo와의 자연스러운 통합이라는 면에서는 css.Build가 매우 강력한 기본 선택지가 되었다.

특히 실무 관점에서 중요한 포인트는 이거다. “최신 CSS를 어디까지 순정 상태로 쓰고, 어디서부터 호환성 비용을 추가할지”를 다시 판단해야 하는 시점이 왔다는 것. Hugo 사용자는 이제 그 판단을 더 가볍고 빠른 도구 위에서 할 수 있게 됐다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment