ashrimp blog

subfont + tailwindcss 같이 쓰기

Written in 2022/05/15 05:11:55 UTC, categoried as web

결론부터 말하자면

둘을 같이 쓰는 순간 문제가 발생한다. 다행히 나는 해결한 상태다. 무슨 문제가 발생했고 어떻게 해결했는지 이 글에서 자세히 설명한다. 그러니 이 글을 읽는 여러분들은 걱정할 필요 없다.

멸망의 전조

블로그를 개발할 당시 고정폭 서체에 꽂혀 어떻게든 써보려고 발악을 했다. 선뜻 채택하지 못한 주된 이유는 폰트 크기이다. 난 한국인이므로 당연히 한글로 포스팅을 할 것이고, 따라서 폰트가 반드시 cjk를 지원해야 한다. 문제는 이러면 크기가 너무 커진다는 것. 짧은 포스트 하나 읽자고 수십 MiB씩 트래픽이 오가는건 심히 좋지 않다.

핵폭탄 1번 재료: subfont

그러다 subfont라는 훌륭한 도구를 발견했다. 이 녀석은 똑똑하게도 웹 페이지(온라인/오프라인 둘 다 가능)를 분석해서 폰트를 찾는다. 그리고 원본 폰트 파일을 수정해서 실제 사용되는 글리프만 들어있는 서브셋으로 썰어버린다. 도입 결과 200MiB가 넘는 폰트가 수십 KiB 수준으로 줄어들었다...

dist/about/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (15 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (39 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/fallback/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (19 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (38 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (14 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (95 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/blog-renewal/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (45 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (437 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/implement-ssr-on-svelte/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (37 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (328 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/k8s-cicd/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (37 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (277 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/macos-big-sur-beta-10-mds-stores-huge-memory-usage/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (29 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (138 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/meteorknight-engine-devlog-1/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (20 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (149 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/personal-svelte-pros-and-cons/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (67 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (273 on this page), 11.4 MB (woff2) => 80 kB (woff2)
dist/posts/piped-devlog-display-errmsg-gracefully/index.html: 1 font (2 variants) in use, 23.5 MB total. Created subsets: 109 kB total
  NotoSansMonoCJK:
    700 : 128/44810 codepoints used (32 on this page), 12.1 MB (woff2) => 29.4 kB (woff2)
    400 : 584/44810 codepoints used (152 on this page), 11.4 MB (woff2) => 80 kB (woff2)
HTML/SVG/JS/CSS size increase: 48 kB
Total savings: 234 MB

핵폭탄 2번 재료: tailwindcss

처음 tailwindcss를 발견하게 된 것은 CSS에 대한 끝없는 증오가 나를 잠식하고 있을 때였다(지금도 그렇다). 이 놀라운 프레임워크는 CSS를 단 한 줄도! 작성하지 않고도 자유자재로 DOM을 스타일링할 수 있게 해준다. 심지어 당연하다는 듯이 미디어 쿼리를 활용한 responsive design도 지원한다. 이제 CSS 지옥에서 벗어날 수 있어...!

첫번째 폭발

두 도구를 스까서 개발하던 도중, 나는 갑작스레 블로그 빌드가 진행되지 않는 문제에 직면하고 만다. 그러나 불행히도 2021년 인류의 놀라운 기술력 뽕에 취해버린 나는 그만 이 문제를 과소평가해 버리고 말았다.

나: 에이. 내가 cli 옵션을 잘못 줬겠지?

하지만 슬프게도, 이것은 그리 단순한 문제가 아니었다. 나는 문제 해결을 위해 수일을 쏟아부었고, 이내 원인을 찾아냈다. 여러분들도 짐작했듯이, 원인은 subfonttailwindcss의 조합이었다.

첫번째 폭발의 자세한 경위

  1. tailwindcssCSS 프레임워크로써, 거의 모든 가능한 CSS 속성들을 개별 class로 쪼개놓은 거대한 CSS 덩어리(와 약간의 유틸리티)다. 개발 당시에는 이 거대 CSS를 그대로 사용하고, 프로덕션 빌드시에 이 중 실제로 사용되는 class만 남겨 크기를 줄인다.
  2. tailwindcss의 프로덕션 빌드 여부는 환경 변수로 지정하는데, 내 빌드 환경 특성상 이것이 제대로 전달되지 않아 크기가 제대로 줄어들지 않았다.
  3. tailwindcss는 특문을 활용한 class 이름이 많다. 예를 들면 :-translate-y-2\.5와 같은 것이 있다.
  4. subfont가 모종의 이유로 이 특문에 대한 escape 처리를 제대로 하지 못한다. 특문을 만나게 되면 DOMException이 발생하고, 이 예외를 처리하느라 온갖 시간을 다 쓴다.
  5. tailwindcsspurge되지 않으면 거대하다. 따라서 subfont는 수많은 특문 class를 처리하려 시도하고, 예외가 발생하면서 시간을 죄다 잡아먹는다.
  6. 사용하는 사람인 내 입장에서는 빌드가 영원히 끝나지 않는 것처럼 보인다...

다행스럽게도, 이 문제는 tailwindcss의 설정을 수정해 purge를 항상 하도록 하면 된다.

// tailwind.config.js
module.exports = {
  purge: {
    enabled: true,
    content: [
      /* ... */
    ],
  },
};

잠시 시간을 내서 나의 환상의 똥꼬쇼를 감상해주기 바란다. 아래 링크에서 감상할 수 있다.

아크릴새우의 똥꼬쇼 감상하기

두번째 폭발

완성된 블로그를 잠시 방치하다가, 최근에 다시 운영하려고 유지보수 작업을 실시했다. 패키지도 업데이트하고, html minification도 적용하고... 그러다 두 번째 문제를 만났다. subfontOOM을 뱉고 증발한다. 심지어 40GiB나 줘도, 다 먹고도 부족한지 더 달라고 징징대다가 증발한다.

이번에도 어김없이 나는 디버깅을 시작했고, 이번엔 하루만에 원인을 찾아냈다. font-tracer라는, 폰트를 추적하는 라이브러리 코드의 문제였다. 이 코드에는 expandPermutations라는 함수가 있다.

// Expand an object with array values into all possible permutations of the properties

// expandPermutations({a: [1, 2], b: [3, 4]}) =>
// [
//   { b: 3, a: 1 },
//   { b: 4, a: 1 },
//   { b: 3, a: 2 },
//   { b: 4, a: 2 }
// ]

function* expandPermutations(obj, propertyNames) {
  propertyNames = propertyNames || Object.keys(obj);
  if (propertyNames.length === 0) {
    return [];
  }
  const firstPropertyName = propertyNames[0];
  const firstPropertyValues = obj[propertyNames[0]];

  for (let i = 0; i < Math.max(1, firstPropertyValues.length); i += 1) {
    if (propertyNames.length > 1) {
      for (const permutation of expandPermutations(
        obj,
        propertyNames.slice(1),
      )) {
        permutation[firstPropertyName] = firstPropertyValues[i];
        yield permutation;
      }
    } else {
      const permutation = {};
      permutation[firstPropertyName] = firstPropertyValues[i];
      yield permutation;
    }
  }
}

보시다시피 가능한 모든 조합을 찾는 함수다. 그런데 입력으로 오는 객체의 키를 조사해봤더니 64개였다. 벌써부터 느낌이 좋지 않다. 아래 스크립트로 총 가능한 순열의 개수를 구해봤다.

Object.keys(obj)
  .map((key) => obj[key].length)
  .reduce((acc, cur) => acc * cur, 1);

35184372088832

다시 말해서, 입력으로 들어오는 순열의 총 개수는 대략 35조개가 넘는다. 이걸 배열에다 넣으려고 시도하는 것이다! 그런데 github를 뒤적여도 최근에는 해당 코드가 수정된 흔적이 없었다. 원래 잘 되던 코드란 말인데. 더 자세한 분석을 위해 입력 객체의 덤프를 떴다.

Object dump

보이는가? 무수한 --tw-의 행렬이! tailwindcss가 자체적으로 사용하는 엄청난 양의 CSS 속성이 이 순열 생성에 참여하고 있는 것이다.

두번째 폭발의 자세한 경위

  1. tailwindcss는 별다른 유틸리티 없이도 정상 동작을 보장하기 위해서 자신만이 사용하는 CSS 속성을 대량으로 생성한다.
  2. subfont는 사용중인 폰트를 분석하기 위해 CSS 속성의 모든 순열을 시도한다. 앞서 tailwindcss가 폰트 계산에는 영향을 주지 않는 CSS 속성을 대거 추가했다. 이는 감당할 수 없는 숫자가 된다.
  3. subfont가 35조개 정도 되는 순열을 죄다 배열에 넣는다.
  4. OOM이 발생하고, 커널이 subfont가 점유했던 모든 메모리를 강제로 환수한다.
  5. 사용하는 사람인 내 입장에서는 subfont가 무한루프를 돌며 메모리 누수를 발생시키는 것으로 보인다...

해결 방법은 의외로 간단한데, subfont가 의존하는 라이브러리인 font-trace의 소스코드를 수정해 --tw-로 시작하는 모든 CSS 속성을 날려버리는 것이다.

// node_modules/font-tracer/lib/fontTracer.js L1014

// Purge the custom css properties from the tailwindcss
for (const styledText of styledTexts) {
  styledText.props = Object.keys(styledText.props)
    .filter((key) => !key.startsWith('--tw-'))
    .reduce((acc, key) => {
      acc[key] = styledText.props[key];
      return acc;
    }, {});
}

이후 이 변경사항을 프로젝트에 영구적으로 반영할 수 있도록 patch-package를 사용해 고정한다.

npx patch-package font-tracer

그러면 프로젝트 내에 아래와 같은 patch가 생긴다. 잘 커밋해둔다.

diff --git a/node_modules/font-tracer/lib/fontTracer.js b/node_modules/font-tracer/lib/fontTracer.js
index 6dff2e2..cbcc744 100644
--- a/node_modules/font-tracer/lib/fontTracer.js
+++ b/node_modules/font-tracer/lib/fontTracer.js
@@ -1011,6 +1011,16 @@ function fontTracer(
     }
   }

+  // Purge the custom css properties from the tailwindcss
+  for (const styledText of styledTexts) {
+    styledText.props =
+      Object.keys(styledText.props)
+        .filter(key => !key.startsWith("--tw-")).reduce((acc, key) => {
+          acc[key] = styledText.props[key];
+          return acc;
+        }, {});
+  }
+
   const seenPermutationKeys = new Set();
   const multipliedStyledTexts = _.flatMap(styledTexts, ({ props, ...rest }) =>
     [...expandPermutations(props)]

잠시 시간을 내서 나의 환상의 현재진행형 똥꼬쇼를 감상해주기 바란다. 아래 링크에서 감상할 수 있다.

아크릴새우의 현재진행형 똥꼬쇼 감상하기

결과

두 번의 큰 어려움이 있었음에도 불구하고, 나는 이 두 도구(subfont + tailwindcss)를 유지하기로 결정했다. 들이는 노력에 비해 이득이 너무 달콤하다고나 할까... 폭발 사고와 후처리가 걱정되면서도 원자력 발전소를 포기하지 않는 이유와 같다. subfonttailwindcss는 나를 무적으로 만들어 준다. 폰트를 고를 때 오직 디자인만 신경 쓸 수 있다. CSS는 한 줄도 작성할 필요가 없다. 심지어 모바일 지원이 필요할 때 조차!

이 글을 읽고 있는 당신도 사용해보는 것은 어떨까?