좀 더 나은 번들 최적화를 위해

Shovel, Web

이전 글에서는 이 블로그 테마에 적용되는 노토 산스를 조금 더 예쁘게 보이게 하기 위해서 어떻게 해야하는 지를 실험해보았습니다.

이 글에서는 그 폰트를 어떻게 최적화해서 카에데에 실었는지, 또 그 외에 블로그를 조금 더 빠르게 로딩시키기 위해서 어떠한 일들을 했는지 정리를 해보려 합니다.

폰트 개수 줄이기

우선 카에데는 쓸데없이 폰트를 너무 많이 쓰는 경향이 있었습니다. 카에데 1.0에서 사용했던 폰트들을 나열해보면 다음과 같습니다.

Noto Sans KR, Noto Sans JP, Raleway, Nanum Square, Iosevka, RoboNoto

KoPubWorldDotum도 사용하였으나, 이는 따로 서빙은 하지 않고 로컬에서만 불러오게 하였기 때문에 예외입니다.

아무튼, 이렇게 폰트가 너무 많으면 통일감도 없어 보이고 사용자가 너무 많은 폰트를 다운받게 되는 문제도 있어서 과감히까지는 아니지만 몇 개를 쳐냈습니다.

그래서 카에데 1.1 버전부터는 다음과 같은 폰트만을 사용하게 됩니다.

코드 폰트: Iosevka, Noto Sans KR_JP (Fallback)
UI 폰트: Raleway
본문 및 제목폰트: Noto Sans KR_JP

우선 코드 폰트에서는 Fallback으로 사용되던 RoboNoto를 지웠는데, 이는 코드에서 한글은 거의 주석에만 사용되기 때문에 굳이 고정폭을 유지하는 것이 의미가 없어보였기 때문입니다. 또한, 애초에 Iosevka와 RoboNoto의 폭을 통일하지 않아서 원래부터 고정폭이 아니었다는 이유도 있습니다.

UI 폰트로는 이전에 제목 용도로 한정되어 사용하던 나눔스퀘어를 빼버렸습니다. 굳이 몇 자 나눔스퀘어로 적자고 모든 폰트를 받아오는 것이 굉장히 비 실용적이라고 생각했기 때문입니다.

그리고 Noto Sans KR과 JP를 합쳐 Noto Sans KR_JP 를 만들었는데, 이에 대해서는 이 다음 문단에서 설명하겠습니다.

웹폰트 파이프라인 개선

이전에 Noto Sans KR과 JP는 구글폰트로부터 서빙이 되고 있었습니다. 해외에서는 이전부터 구글폰트를 사용하지 말자는 글이 몇 개 있었는데, 특히 최근에 CDN 자원들이 더 이상 cross-site 간 공유되어 캐싱되지 않게 됨에 따라 더더욱 그런 글이 많아졌습니다.

저는 그러한 글들을 보면서 조금 부럽게도 느껴졌는데, 그 이유 중 하나는 한글은 워낙 Glyph 수가 많아서 구글폰트처럼 Subsetting 해서 서빙하지 않으면 너무 고용량이 되어버리기 때문입니다.

구글폰트의 서브셋팅 예시, 1 Noto Sans KR = 119 Chunks

그래서 지금까지 저는 따로 대안이 없다고 생각하고 구글폰트를 썼지만, 카에데를 업데이트 하면서 이전 게시글에서 설명했듯이 폰트의 렌더링을 조금 바꾸기 위해 직접 호스트하게 되었습니다. 그 과정에서 최적화를 위해 제가 직접 서브셋팅을 해보자고 다짐을 하였고, 제가 생각한 서브셋팅의 기본적인 방법은 다음과 같습니다.

  1. 기본적인 ASCII 영역은 하나의 chunk로 묶자
  2. 한글과 한자는 빈도순으로 정렬한 후에, 많이 쓰이는 글자는 많이 쓰이는 글자끼리, 적게 쓰이는 글자는 적게 쓰이는 글자끼리 묶어서 chunk를 만들자
  3. 너무 잘 안 쓰이는 영역은 다 하나로 묶어서 크게크게 chunking하자

3번과 같은 경우는 조금 의아해하실 수도 있는데, 이유는 간단합니다. 서로 멀리 떨어져있는 유니코드끼리 묶어서 Chunk를 하나 만들 때마다 스타일시트의 용량이 너무 많이 늘어납니다.

Noto Sans KR:wght@400의 gzip되지 않은 스타일시트가 92KiB 정도 나가는데, 사실 스타일시트의 용량이 너무 커져버리면 서브셋팅을 안할 때보다 더 다운로드를 많이 해버릴 수 있어서 최대한 스타일시트의 크기도 최적화하고 싶었습니다.

그래서 해당 규칙대로 폰트를 최적화해주는 프로그램을 하나 만들었습니다. 파이썬의 fontTools 라이브러리 기반이고, 직접 파이프라인을 명시하면 해당 파이프라인을 따라서 폰트를 이리저리 수정하는 형태입니다.

debug_pipeline: False
pipeline:
    -
        name: 'input'
        glob:
            - 'files/input/**/*.ttf'
            - 'files/input/**/*.otf'
            - 'files/input/**/*.woff'
            - 'files/input/**/*.woff2'

    -
        name: 'cff_to_glyf'

    -
        name: 'add_gasp'
        mode: 'replace'

    -
        name: 'parse_attribute'
        key: 'path'
        pattern: '^(?P<group>.*?)_.*$'

    -
        name: 'parse_attribute'
        key: 'group'
        pattern: '^.*(?P<fn1>\d)\d{2}$'

    -
        name: 'merge'
        output_template: '{fn1}.ttf'
        merge_by: 'group'
        merge_base:
            key: 'path'
            match: '^.*_kr.*$'

    -
        name: 'replace_attribute'
        keys:
            - 'family_name'
            - 'full_name'
            - 'postscript_name'
            - 'typographic_family_name'
        patterns:
            -
                find: '(?<!\w)[kK][rR](?!\w)'
                replace: 'KR_JP'

    -
        name: 'replace_attribute'
        keys:
            - 'family_name'
            - 'typographic_family_name'
        patterns:
            -
                find: 'KR_JP.*$'
                replace: 'KR_JP'

    -
        name: 'set_font_name'
        attributes:
            - 'family_name'

    -
        name: 'subset'
        group_by:
            # Basic Latin
            -
                name: 'unicode_blocks'
                include:
                    - 'Basic Latin'
                    - 'Latin-1 Supplement'

            # Basic Korean
            - 'hangul_2574'

            # Basic Japanese
            -
                name: 'unicode_blocks'
                merge_blocks: true
                max_chunk_size: 2048
                include:
                    - 'Hiragana'
                    - 'Katakana'

            # Ideographs
            - 'ideograph_jouyou'
            - 'ideograph_frequency'

            # Other CJK-Related
            -
                name: 'unicode_blocks'
                include:
                    - 'Hangul Jamo'
                    - 'Hangul Compatibility Jamo'
                    - 'CJK Symbols and Punctuation'
                    - 'CJK Strokes'
                    - 'CJK Compatibility'

            # Symbols
            -
                name: 'unicode_range'
                max_chunk_size: 2048
                range: 'U+2000-2BFF'

            # Emojis
            -
                name: 'unicode_range'
                max_chunk_size: 1024
                range: 'U+1F000-1FAFF'

            # Remaining Glyphs
            -
                name: 'all'
                max_chunk_size: 65536


        order_by: 'wikipedia_frequency'
        max_chunk_size: 384

    -
        name: 'add_program_info'

    -
        name: 'set_attribute'
        attributes:
            display: 'swap'

    -
        name: 'output'
        extensions:
            - 'woff'
            - 'woff2'

        output_fonts: 'files/output/noto/{root}.{ext}'
        output_css: 'files/output/noto/stylesheet.css'
        output_preview: 'files/output/preview.html'

현재 이 블로그에서 사용중인 노토 산스는 위와 같은 파이프라인을 따라 Noto Sans KR과 JP를 합친 후 서브셋팅을 해 제작이 되었고, HelloWorld017/nenwfont 에서 직접 받아서 실행해보실 수 있습니다.

nenwfont의 폰트 서브세팅 예시

결과 분석

구글폰트에 비해 얼마나 최적화가 잘 되었나 개인적으로 궁금하였고, 그래서 직접 비교분석을 해봤습니다.

구글폰트 nenwfont
폰트[1] 852KiB[2] 732KiB
스타일시트[3] 216KiB[4] 92KiB

  1. 제 블로그 첫 페이지를 끝까지 로딩했을 때 기준입니다. ↩︎

  2. Noto Sans JP 28KiB, Noto Sans KR 824KiB ↩︎

  3. gzip 압축된 기준입니다. ↩︎

  4. Noto Sans JP 122KiB, Noto Sans KR 94KiB ↩︎

물론, 제대로 테스트를 돌린 것도 아니고 그냥 제 블로그 메인 페이지 하나로만 테스트한 거라 신뢰성은 전혀 없습니다. 그리고 CFF를 glyf로 바꾸는 과정이나 기타 다른 과정에서 폰트 자체의 크기가 영향을 받았을 확률도 커서 이 데이터를 일반화하는 건 절대 불가능합니다.

하지만, 어느 정도 쓸만하게는 나온다는 사실을 알 수 있었고, 나중에 서브셋팅을 좀 더 개선해서 제대로 테스트해보고자 하는 마음이 들었습니다.

기타 번들 최적화

이 전부터 카에데는 웹팩의 트리셰이킹 등을 이용해 번들을 최적화해오고 있었습니다. 특히, 번들을 분석했을 때 Moment.js 의 그 악명높은 번들 크기를 한번 체험하고 나서 date-fns 로 전부 갈아엎는 등 최적화가 좀 진행되었습니다.

그럼에도 불구하고, 최근 번들 크기가 커져 확인해본 결과 date-fns/locales 를 import하면 쓸 locale만 import하더라도 전부 번들에 들어가는 현상을 발견하였습니다. 그래서 date-fns/esm/locales import로 변경하여 문제를 해결하였습니다.

date-fns 교체 이전
date-fns 교체 이후

또한, 자꾸만 CDN과 브라우저에 이전 버전이 캐싱되는 문제를 해결하고자 웹팩 설정을 조금 바꿔 뒤에 hash query를 달게 하였습니다.

그러자 문제가 일어나는 건 link[rel="preload"] 였는데, 이전에는 일일이 예상되는 파일들을 직접 html에 link[rel="preload"] 로 박아 넣고 있었습니다. 하지만 이제 빌드할 때마다 파일의 주소가 바뀌어서 그걸 직접 넣을 수 없게 되었습니다. 그래서 @vue/preload-webpack-plugin 를 통해 link[rel="preload"] 가 자동으로 생성되게 하였습니다.

결과는 다음과 같습니다.

Fast 3G에서의 다운로드 (preload 적용 전)
Fast 3G에서의 다운로드 (preload 적용 후)

Waterfall에서 보실 수 있듯이 적용 전에는 HTML을 가져오고, 메인 스크립트와 중요도가 낮은 것들을 먼저 로딩하고, 정작 중요한 스크립트와 스타일은 그 이후에 가져오고 있었습니다.

적용 후에는 HTML만 가져오고 중요한 파일들을 처음에 한꺼번에 로딩해 약 0.5초 정도 (Fast 3G 기준) 더 빨라진 것을 확인할 수 있었습니다.

결론

이 블로그에서 사용하는 카에데 테마는 1.1버전으로 업데이트 하면서 많은 최적화와 기능의 개선이 이루어졌습니다. 다시 한 번 최적화 작업은 굉장히 손이 많이 간다는 사실을 깨닫는 시간이었던 것 같습니다.