韓国語Webフォントのサブセットと next/font
今回の記事では、韓国語Webフォントを軽く届ける方法について話してみようと思う。
本文、見出し、コード、引用でフォントを使い分ける韓国語ページを運営するフロントエンド開発者に向けた記事だ。Webフォントを減らすさまざまな方法がそれぞれどれだけ効くのかを実際に測り、どの方法から手をつけるべきかを整理する。
韓国語フォントは文字数が多いので、ウェイトひとつで数百 kB から MB 単位になる。これを少なく受け取り、速く受け取り、受け取る間に画面が揺れないようにする方法はいくつもある。ファイル形式を woff2 に変える、サブセットに分ける、Google のサーバーから直接受け取る、next/font でフォントを事前に取得させ(preload)代替フォントを合わせる、font-display でフォントが遅れて届くときの画面を決める、といった方法だ。
では、これらの方法は実際にどれほど差が出るのだろうか。フォントを4つ使うページをひとつ作り、方法ごとに別々にデプロイして、フォントをどれだけ受け取るか、すべて受け取るまで何秒かかるか、画面がどれだけずれるかを測ってみよう。国内(韓国)のサービス5つがどの方法を使っているかもあわせて見ていく。デモと計測スクリプトは jiji-hoon96/blog-labs にあり、デプロイしたページは hooninedev-web-fonts.netlify.app で直接開いて見られる。
フォントを4つ使うページ
実験ページは、このブログの overlay-kit の記事の本文をコードブロックと引用までそのまま移したものだ。短いサンプルではなく実際の記事を1本選んだのは、韓国語フォントの転送量がページで使われた文字の種類に応じて動くからだ。フォントは技術ブログでよく見る構成どおり、4つを使い分ける。

本文は font-family: 'Inter', 'Noto Sans KR' だ。欧文と数字は Inter で、韓国語は Noto Sans KR で描き、ウェイトは 300、400、500、700 の4種類を使う。見出しは明朝系の Noto Serif KR 700 ひとつ、コードは等幅の JetBrains Mono 400 ひとつだ。引用は Inter のイタリックで描く。Noto Sans KR にはイタリックがないので、引用内の韓国語はブラウザが文字を傾けて合成する。
ファミリーとしては4つで、ウェイトとスタイルまで数えるとフォントは11種類になる。Inter が5つ(300、400、500、700、イタリック 400)、Noto Sans KR が4つ、残りの2つが1つずつだ。
ブラウザがフォントを取得するルール
Webフォントは CSS を読んだ瞬間にダウンロードされるわけではない。@font-face ルールはフォント名、ウェイト、ファイルの URL、そのファイルが含む文字の範囲(unicode-range)を宣言するだけで、ブラウザはテキストを描く途中でそのフォントが必要になった時点でファイルを要求する。CSS Fonts Level 4 仕様はこの遅延リクエストを設計上の意図として記している。同じ仕様の unicode-range の節は、範囲外の文字にはそのフォントをダウンロードも使用もしてはならない(must not)と定めている。
だから、ひとつのフォントを文字範囲ごとのファイルに分け、ファイルごとに unicode-range を書いておけば、ブラウザはページで使われた文字を含むファイルだけを取得する。このように一部の文字だけを切り出したファイルをサブセット(subset)と呼び、この記事ではそうして分けたファイル一つひとつをスライスと呼ぶ。以下はこの実験で使ったスライスひとつのルールだ。U+ac00(가)、U+c774(이)、U+d558(하)のようなよく使われるハングルが、スペースや欧文の一部と一緒に入っている。
@font-face {
font-family: 'Noto Sans KR';
font-style: normal;
font-weight: 300 700;
font-display: swap;
src: url(nskr-119.woff2) format('woff2');
unicode-range: U+20-22, U+27-2a, U+2c-38, U+3a-3b, U+3f, U+41-47, U+4a-4c, U+4f-5d, U+61-7b, U+7d, U+a1, U+ab, U+ae, U+b7, U+bb, U+bf, U+2013-2014, U+201c-201d, U+2122, U+ac00, U+ace0, U+ae30, U+b2e4, U+b85c, U+b9ac, U+c0ac, U+c2a4, U+c2dc, U+c774, U+c778, U+c9c0, U+d558;
}
韓国語フォントではこの差が大きくならざるを得ない。ハングルの音節だけで 11,172字あり、Noto Sans KR は漢字まで含めて 23,174字が入っている。サブセットがなければ、記事1本を描くためにこれをすべて受け取る。
比較した5つのページ
前に挙げた方法のうち、形式とサブセットから見ていく。同じページをフォントの配信方法だけ変えて5通り作り、Google がすでに分けてあるスライスをそのまま使う場合も入れた。本文と font-family の宣言は5通りすべて同じで、変わるのは fonts.css という1ファイルと、そのファイルが指すフォントファイルだけだ。
出発点は Google Fonts CSS API だ。Google Fonts のドキュメントは、この API がリクエストしたブラウザに合わせたスタイルシートを返すと記している。curl のようにブラウザと認識されないクライアントがリクエストするとウェイトごとに TTF の URL がひとつずつ返り、最新の Chrome がリクエストするとスライスごとに @font-face が付いた woff2 のレスポンスが返る。前者のレスポンスから TTF の URL だけを抜き出して CSS に固定しておくと、どのブラウザでアクセスしても TTF を受け取る。
- A. TTF: API が curl に返す TTF ファイルをそのままダウンロードしてホスティングした。TTF の URL を固定しておいた場合と同じ形だ。
- B. woff2: A の TTF を woff2 に変えただけだ。サブセットはない。
- C. 静的スライス: A の TTF を、Google がファミリーごとに使う文字範囲どおりに切って woff2 のスライスにした。ウェイトごとに別々に切る。
- D. Google のレスポンス: 同じ API が最新の Chrome に返す CSS とフォントファイルをそのままダウンロードしてホスティングした。スライスひとつが複数のウェイトを含む可変フォントだ。
- D-range: D と同じだが、API にウェイトを
wght@300;400;500;700のように列挙せず、wght@300..700のように範囲でリクエストした。
A、B、C は同じ TTF から出発する。A から B へは形式だけが、B から C へはサブセットだけが変わるので、それぞれがどれだけ減らしたかを分けて見られる。変換と切り出しには、フォントファイルを扱う Python ライブラリ fontTools を使った。ただし Google の範囲はフォントが含む文字をすべては覆わない。Noto Sans KR は 23,174字のうち 17,655字だけを覆い、漏れているのは大部分がまれにしか使わない漢字だ。そのため C は B より含む文字が少ないが、このページで使われた文字は4ファミリーとも範囲内にあるので、受け取る量には影響がない。
5通りは Netlify のサイトひとつに載せ、同じホストから HTTP/2 で受け取るようにした。D も Google のサーバーではなく同じサイトから受け取るので、取得元の違いは結果に混ざらない。Google のサーバーから直接受け取るときの差は後で別に測る。すべてのファイルに Cache-Control: public, max-age=31536000, immutable を付け、CDN(ユーザーの近くにファイルをコピーしておくキャッシュサーバー)がオリジンサーバーに問い合わせ直さずにファイルを返すようにした。
バリアント別のフォント転送量
まず5通りのフォント転送量を並べる。
| バリアント | フォントファイル | フォント転送量 | CSS 転送量 |
|---|---|---|---|
| A. TTF | 11 | 17,200 kB | 0.4 kB |
| B. woff2 | 11 | 12,045 kB | 0.4 kB |
| C. 静的スライス | 58 | 679 kB | 107 kB |
| D. Google のレスポンス | 28 | 579 kB | 107 kB |
| D-range | 28 | 579 kB | 43 kB |
A から C までで、フォント転送量は 96% 減った。これらの数字が DevTools のどこから出てくるのか、バリアントごとに Network 画面で確認していく。
TTF を固定した A

画面を読む前に設定を3つ合わせる。Font フィルター(1)をオンにするとフォントのリクエストだけが残り、Disable cache(2)をオンにすると再読み込みのたびに初めて訪れたユーザーのようにすべてを取得し直す。Content-Encoding 列はデフォルトでは表示されないので、列の見出しを右クリックして Response headers から Content-Encoding をオンにする。
名前(3)の拡張子は .ttf で、ウェイトとスタイルごとにファイルがひとつずつ、全部で11個だ。サイズ(4)を見ると、韓国語フォントのファイルはひとつで 2.8 MB から 2.9 MB ある。ページの文字数にかかわらず、このファイルには 23,174字がすべて入っているからだ。
最も大きいファイルは本文フォントではなく見出しフォントだ。Noto Serif KR 700 ひとつが 4,949 kB で、展開すると 14 MB になる。このページでこのフォントで描くハングルは、見出し9つ(h1 ひとつと h2 八つ)に含まれる異なる50字だけだ。
下部の行は数字を2つずつ示す。「17,206 kB / 17,243 kB transferred」(6)のうち、前の値はフィルターに一致したリクエストだけの合計で、後ろの値はページ全体だ。transferred はネットワークを通った量で、resources(7)は受け取った後に展開したサイズだ。両者が2倍以上違うのは、Content-Encoding(5)が Brotli(Google が作った圧縮方式)を表す br だからだ。TTF はファイル自体に圧縮がないので、Netlify が Brotli で圧縮して送った。ユーザーが待つのは transferred のほうなので、この記事の数値はすべて transferred 基準だ。
形式だけを変えた B
よく聞く助言どおり TTF を woff2 に変えると、どれだけ減るのだろうか。B がその場合だ。

17,200 kB が 12,045 kB になった。30% の減少だ。今回は Content-Encoding(3)が空で、transferred(4)と resources(5)がほぼ同じだ。woff2 はファイルの中ですでに圧縮された形式なので、Netlify は再圧縮しなかった。
この 30% は、圧縮していないファイルと比べた値ではない。A も Brotli で圧縮されて届いたので、残った差は圧縮方式と圧縮レベルから来る。W3C WOFF2 仕様によると、woff2 はグリフデータを圧縮しやすい形にまず変換してから Brotli で圧縮する。ところが Netlify が TTF にかけた Brotli は圧縮レベルが低かった。Brotli は圧縮レベルを 0 から 11 まで選べ、高いほどファイルは小さくなる代わりに圧縮に時間がかかる。同じ Noto Sans KR 400 の TTF を Netlify は 2,866 kB で送ったが、これはレベル 4 で圧縮したサイズとほぼ同じだ。最高レベルの 11 で事前に圧縮すると 2,475 kB で、woff2 は 2,053 kB だ。サーバーが TTF をどう圧縮するかによって、woff2 に変えて減る量も変わる。Google の gstatic は同じファイルを gzip で 3,023 kB で送る。
比較の基準を誤ると、この数字は水増しされる。A の resources 40,490 kB と B を比べると 70% 減ったように見えるが、ユーザーはそのサイズを受け取ったことがない。逆に自分のサーバーが TTF を圧縮なしで送っているなら、ユーザーは本当に 40 MB を受け取る。どちらなのかは Content-Encoding 列ですぐにわかる。
それでも B は記事1本を描くために 12 MB を受け取る。ファイルひとつに文字がすべて入っているので、形式を変えても受け取る文字数は変わらない。
サブセットに分けた C
C は同じ TTF を、Google が使う文字範囲どおりに切った。Noto Sans KR と Noto Serif KR は124個、Inter は7個、JetBrains Mono は6個の範囲だ。ハングルの範囲を分ける基準は、Google Fonts チームが2018年に日本語サポートを発表した記事であわせて明かしている。よく使う 2,000字を頻度順に20スライスに分け、残りは Unicode のコードポイント順に100スライスに分ける。Google の CSS にはハングルのスライスの番号がないので、筆者が CSS に出てくる順に0番から番号を振った。0番から99番がコードポイント順に分けたスライスで(CSS にはコードポイントの大きい文字のスライスから出てくる)、100番から119番が頻度順のスライスだ。最後にラテン、拡張ラテン、キリル、ベトナム語のスライス4つが付いて124個になる。C のファイル名もこの番号に従った。
C の CSS にはスライスごと、ウェイトごとにルールがひとつずつあり、全部で661個だ。Noto Sans KR が124範囲にウェイト4つで496個、Noto Serif KR が124個、Inter が7範囲に5種類で35個、JetBrains Mono が6個だ。

ルール661個のうちファイル58個だけを受け取り、合計(7)は 679 kB だ。A の 4% だ。ファイル名にウェイトが入っているので、名前順に並べるとウェイトごとに何スライス受け取ったかがすぐわかる。
本文のウェイトである 400 が19スライス(3)で最も多く、最初の段落の 300 が5スライス(2)、小見出しの 500 が9スライス(4)、太字の 700 が13スライス(5)だ。大部分はよく使う文字を含む100番台のスライスだ。400 の56番のように番号がかけ離れたスライスもある。このページで56番の範囲に入る文字は「나눴다」の「눴」1字だけで、その1字のためにスライスをひとつ受け取った。
A で 4,949 kB だった見出しフォントは6スライス(6)、113 kB になった。見出しの50字を含むスライスだけを受け取ったからだ。Inter はウェイトとスタイルごとにラテンのスライスをひとつずつ(1)、5つ受け取った。
Noto Sans KR のラテンのスライスはひとつも受け取らなかった。スペース、数字、英字をすべて、前に置いた Inter が描いたからだ。この計測の Chrome では、font-family の順序が受け取るファイルまで変えた。
woff2 変換とサブセットが減らした量を分けてみるとこうなる。
| 段階 | 転送量 | 減った量 |
|---|---|---|
| A. TTF (Brotli) | 17,200,139 B | 基準 |
| B. woff2、サブセットなし | 12,045,102 B | woff2 に変えて 5,155,037 B 減らした |
| C. woff2、サブセット | 678,925 B | サブセットに分けてさらに 11,366,177 B 減らした |
減った量の 69% はサブセットから来た。この比率は woff2 に先に変え、サブセットを後で分けた順序で数えたもので、woff2 変換に最も有利に数えた値だ。サブセットを先に分けると woff2 が圧縮する対象そのものが小さくなるので、サブセットが減らした量の比率はさらに大きくなる。
Google のレスポンスを使った D
D は Google が最新の Chrome に実際に返す成果物だ。範囲は C と同じだが、ファイル数は半分だ。

差は Noto Sans KR で生まれる。C はウェイトごとにスライスを別々に受け取って46個だったが、D は19個(2)だ。D のスライスが可変フォント(variable font)だからだ。可変フォントは、ウェイトを wght という軸の値として扱うフォントだ。

デプロイした nskr-119.woff2 をフォント検査ツールの Wakamai Fondue に入れると可変フォントと表示され(1)、wght 軸に 100 から 900 までが入っている(3)。300、400、500、700 はすべてその間の値なので、16 KB のこのファイルひとつ(2)が4つのウェイトをすべて描く。インストールせずに確かめるなら Google の CSS を開いてみればよい。ウェイトの違う @font-face 4つが同じファイルの URL を指していれば可変ファイルだ。D が受け取った19スライスは、C でウェイト 400 が受け取った19スライスと番号がすべて同じだ。別のウェイトで描いた文字が、すべて 400 で描いた文字と同じスライスの中にあったということだ。
その代わり、D のスライスひとつは C より大きい。同じ119番のスライスの転送量は C の 400 が 9.0 kB、D が 16.7 kB で約1.9倍だ。可変フォントはウェイト軸を描くためのデータを多く含み、筆者の fontTools と Google の制作過程も異なる。そのため Noto Sans KR だけを見ると、ファイル数は46個から19個に大きく減ったが、バイトは 441 kB から 370 kB へと 16% の減少にとどまった。スライスひとつが約1.9倍大きいので、スライスひとつを平均して2ウェイト近く共用しないと、可変スライスのほうが少なくならない。
Inter は5つが2つ(1)になった。通常スタイルの4ウェイトが可変ファイルひとつにまとまり、イタリックはもともと 400 ひとつなのでそのままひとつだ。このイタリックファイルは可変ではなく静的ファイルだ。Google はウェイトをひとつだけリクエストすると、そのウェイトだけを含む静的ファイルを返すからだ。
同じ理由で、見出しフォント(3)は C と D でほぼ同じだ。どちらも6スライス、113 kB だ。このページは Noto Serif KR を 700 ひとつだけリクエストした。この記事の4ファミリーのように可変版があるフォントは、2つ以上のウェイトをリクエストして初めて可変ファイルが返るが、軸はリクエストした範囲に切り詰められず、フォントが持つ全範囲(Noto Serif KR は 200 から 900)が返る。JetBrains Mono も 400 ひとつなので静的ファイルだ。
ウェイトを範囲でリクエストした D-range
D と D-range は同じフォントファイル28個を受け取る。受け取らないスライスまで含めて、2つの CSS が指すファイル268個を1つずつ比べてもバイトまで同じだった。違うのは CSS だけだ。

wght@300;400;500;700 のようにウェイトを列挙してリクエストすると、Google は同じファイルを指すルールをウェイトごとにひとつずつ書いてくれる。Noto Sans KR ひとつで124個かけるウェイト4つ、496個だ。wght@300..700 のように範囲でリクエストすると、先に見たコードのように font-weight: 300 700 と書いたルールがスライスごとにひとつだけ返る。そのため展開後の CSS は 429 kB から 173 kB に(2, 4)、転送量は 107 kB から 43 kB に(1, 3)減った。
ただし、この転送量は Netlify が圧縮して送った結果だ。Netlify は展開後のサイズの4分の1程度にしか縮めずに送ったが、同じ D の CSS を Brotli で事前に圧縮すると、最も低いレベルの 0 でも 41 kB だ。最も高いレベルの 11 では D が 14.9 kB、D-range が 14.3 kB だ。レベル 11 では差が 0.6 kB に縮まる。同じ文字列が繰り返されるルールはよく圧縮されるからだ。範囲リクエストが確実に減らすのは、転送量よりもブラウザが読むべき CSS の量だ。
Fast 4G でのフォント到着時刻
バイトがこれだけ違うと、時間はどれほど違うのだろうか。同じデプロイを DevTools の Fast 4G 条件で測り直した。DevTools のソースを見ると、この preset の基準値はレイテンシ 60 ms、9 Mbps で、実際には倍率をかけてレイテンシ 165 ms、ダウンロード 8.1 Mbps として適用する。計測にもこの適用値を使った。時間はドキュメントのリクエストから最後のフォントのレスポンスが終わるまでで、バリアントごとにキャッシュを無効にした新しいブラウザで9回開いて測った値の中央値を使う。この条件はリクエストごとのレイテンシと帯域幅だけを模したもので、実際のモバイル回線のパケットロスやスマートフォンの CPU は含まれていない。

A は 18.5秒、B は 13.2秒だった。この2つはバイトを帯域幅で割った値に近い。17.2 MB を 8.1 Mbps で受け取ると17秒、12.0 MB は12秒かかる。9回が1秒以内に収まり、同じ日に3回別々に測っても、2つのバリアントの中央値は0.3秒以内で一致した。
サブセットのある C、D、D-range は2秒前後だ。帯域幅がボトルネックの条件なので時間がバイトにそのまま従い、形式が減らした5秒よりもサブセットが減らした11秒のほうが大きい。
一方、C、D、D-range の間の順位はこの計測では決められなかった。同じバリアントの中でも回ごとに1.5秒から3.0秒まで揺れ、その幅がバリアント間の差より大きかった。揺れの大部分は Netlify の CDN から来ていた。
同じ D を10月27日に同じ条件で8回開き直し、回ごとに Network タブを撮った。今回はレスポンスヘッダー cache-status を列として表示した。そのうち2回分を並べるとこうなる。

上(1)はフォントがすべて CDN キャッシュから返った回なので hit と記録されている。下(4)は fwd=miss で、CDN にファイルがなくオリジンサーバーまで往復した回だ。この回は HTML と CSS もオリジンまで行った。Waterfall(2, 5)のバーで、緑はサーバーの最初の応答を待った時間、青は内容を受け取った時間だ。キャッシュから返った回は緑が短く、フォント28個が帯域幅を分け合うので青が長い。オリジンまで行った回はバーの大部分が緑だ。2つの Waterfall はそれぞれの全体時間に合わせて描かれているので、バーの長さを上下で比べることはできない。時間は下部の行(3, 6)で比べる。HTML がオリジンから遅れて届くので DOMContentLoaded が 373 ms から 889 ms に遅れ、フォントのリクエストもその分遅れて始まった。このページは最後のフォントが終わった時刻と Load が同じなので、その差が Load 1.42秒と2.21秒として残る。8回のうち6回はすべてキャッシュから返って1.39秒から1.44秒で終わり、オリジンまで行った2回は2.21秒と4.00秒だった。フォントをひとつも変えていないのに、キャッシュの状態だけでバリアント間の差より大きな差が出る。
10月26日に、CDN を事前に温めておく回数を6回に増やして測り直した。レスポンスヘッダーの cache-status で見ると、フォントが CDN キャッシュになくオリジンサーバーまで行った回は、最後のフォントの最初のバイトが届くまでたいてい 370 ms から 750 ms、一度は1.3秒かかった。キャッシュから返った回は中央値で約 210 ms だった。最も遅かった回(2.3秒から3.3秒)はすべて、フォントか HTML、CSS のいずれかがオリジンサーバーまで行った回だった。フォントがすべてキャッシュから返った回だけを残しても、バリアントごとに0.2秒から0.8秒の揺れが残り、C はそうした回が9回中3回しかなかった。そのため C と D の差は、時間ではなくバイトとファイル数でだけ語る。
最初の描画の時点(FCP)もこの計測では判定できなかった。中央値は A 1.0秒、B 1.0秒、C 1.4秒、D 1.1秒、D-range 1.3秒だったが、同じ日の別の計測では C が1.0秒で、CSS が最も小さい D-range が D より遅かった。バリアント間の差が回ごとの差より小さい。このページは font-display: swap を指定しているので、フォントが届く前に別のフォントで先に描く。だから A のユーザーも1秒で記事を読み始め、その後の17秒間、フォントが届くたびに文字がそのフォントに置き換わる。
サブセットが残すコスト
サブセットに分けるのが答えだからといって、コストがないわけではない。5つある。
第一に、CSS が大きくなる。A の fonts.css は転送量 0.4 kB だが、C と D は 107 kB だ。ルール661個が文字範囲をひとつずつ持っているからだ。展開すると C は 434 kB、D は 429 kB だ。<link> で読み込む CSS は、すべて受け取るまでブラウザが画面を描かないので、フォントファイルより先に受け取る必要がある。セルフホストするなら、この CSS を高いレベルで事前に圧縮しておくほうがよい。前に見たとおり、Netlify が送ったサイズと最高レベルで事前に圧縮したサイズは、この CSS で7倍違う。
第二に、リクエスト数が増える。B のファイル11個が C では58個になった。HTTP/2 以上では1本の接続の上で複数のリクエストを同時に送れるので、リクエストがひとつ増えるコストは小さいが、0 ではない。リクエスト数がいくつを超えるとそのコストがバイトの節約を上回るのかは、今回は測っていない。
第三に、スライスごとに重複するデータが生じる。スライスごとにフォントの共通データが繰り返し入るので、スライスを多く受け取るほどこの重複が積み重なる。Google の範囲が覆う Noto Sans KR の 17,655字を、ウェイト 400 のファイルひとつに切り出すと 1,302,420 B だが、同じ文字を124個に分けたスライスをすべて足すと 1,823,312 B で約 40% 大きい。ほぼすべてのスライスを受け取るページなら、ファイルひとつより多く受け取ることになる。このページはウェイトひとつにつき124スライス中多くても19スライスしか受け取らなかったので、その場合とはかけ離れている。
第四に、デプロイするファイルが多くなる。このデモの C フォルダーにはスライスが661個ある。ブラウザが受け取るのは58個だが、サーバーにはすべて載せる必要がある。ブラウザが受け取る量とサーバーに載せる量は別々に数えなければならない。
第五に、範囲外の文字は代替フォントで描かれる。Google の範囲は Noto Sans KR の 23,174字のうち 17,655字だけを覆うので、残りの 5,519字が出てくると Webフォントではないフォントで描く。大部分はまれにしか使わない漢字なのでこのページには影響がなかったが、人名や地名に漢字が混ざるサービスなら確認が必要だ。
そして入力欄やチャットのように文字が後から入ってくる画面では、新しい文字が出てきたとき、その文字のスライスをその時点で受け取る。記事ごとに使う文字が違うので、次の記事に移るときにも新しいスライスを受け取ることがある。最初の画面で測った転送量がすべてではない。2ページ目でどれだけ追加で受け取るかは、今回は測っていない。
サブセットを適用する道筋
Google Fonts を使うなら、CSS API をそのまま読み込めばよい。Next.js の next/font/google も、このレスポンスをビルド時に取得してそのままセルフホストする。この2つの道筋の差は後で別に測る。
セルフホストなら、Fontsource(オープンソースフォントを npm パッケージにまとめたプロジェクト)のようなパッケージがサブセットを一緒に提供する。2026-10-22 時点で最新の @fontsource/noto-sans-kr 5.3.1 の CSS は、Google と同じ124個の範囲に分かれた C の形で、この構造は 5.2.8 からだ。5.2.7 ではデフォルトの CSS にハングルのルールがなく、ハングルは範囲のないファイルひとつとして別にあった。可変スライスが必要なら、@fontsource-variable/noto-sans-kr が同じ範囲に font-weight: 100 900 ひとつで宣言された D-range の形だ。使うバージョンの CSS を開いて unicode-range を確認するのが安全だ。
Google にないフォントなら自分で切る。C がその場合だ。Google の範囲リストを取ってきて、fontTools の subset モジュール(pyftsubset と同じエンジン)で範囲ごとに切って woff2 で保存し、スライスごとに unicode-range を書いたルールを作る。デモリポジトリの scripts/ にあるビルドスクリプトがこの処理を行う。
自分のサービスで確認するときは、ファイル名に頼らない。この記事の図は読みやすいようにファイル名を変えてあるが、実際の Google のレスポンスや next/font の出力は名前がハッシュなので、ウェイトもスライス番号も見えない。代わりに Font フィルターをオンにして、ファイル数とサイズを見る。ウェイトごとに MB 単位のファイルがひとつずつあるならサブセットはない。スライスが数十個あってウェイト数に比例して増えるなら静的スライスで、CSS フィルターで fonts.css のレスポンスを開き、font-weight が 100 900 のような範囲で書かれていれば可変スライスだ。
Google のサーバーから受け取るコスト
受け取る量はサブセットで減った。残りの方法は、受け取る量をほとんど減らせない。その代わり、フォントをいつ受け取るかと、受け取る間に画面がどう見えるかを決める。その最初が、フォントをどこから受け取るかだ。
これまでの D は、Google のレスポンスをダウンロードして同じサイトから送った。実際のサービスでは、Google の CSS の URL をそのまま読み込むことが多い。するとブラウザは fonts.googleapis.com から CSS を、fonts.gstatic.com からフォントを受け取るので、接続先が2つ増える。このコストを見るために、D と同じリクエストを Google に直接送るページをさらに2つ作った。ひとつは CSS のリンクだけを置き、もうひとつは fonts.google.com が提供する埋め込みコードどおりに、接続を事前に開いておく preconnect を2か所に加えた。
10月26日に同じ Fast 4G 条件で測り、フォントがすべてキャッシュから返った回の中央値を使う。D は9回中7回、残りは9回すべてが該当した。前の図とは計測した日と事前に温める回数が違うので、D の値も違う(2.1秒と1.74秒)。この表の中だけで比較する。
キャッシュから返った回だけを選んだのは、同じサイトから受け取る側に有利な条件だ。トラフィックの少ないサイトは CDN キャッシュがよく空になるので、前に見た C のようにオリジンサーバーまで行く回が多い。そのコストはセルフホストが負う。
| ページ | フォント転送量 | 最後のフォントの到着 |
|---|---|---|
| D、同じサイトから受け取る | 579 kB | 1.74秒 |
| Google に直接接続 | 576 kB | 1.73秒 |
| Google に直接接続、preconnect 追加 | 576 kB | 1.76秒 |
3つの差は0.03秒以内だ。同じページを何度も開くときに生じる差(0.2秒から0.8秒)より小さい。接続のコストは確かにあった。fonts.googleapis.com への最初の接続に(暗号化接続を確立する TLS を含めて)回ごとに約 80 ms から 190 ms かかり、この時間は CSS を受け取る前に加わる。ところが fonts.gstatic.com の接続時間はフォントのリクエストで 0 と表示された。Google の CSS のレスポンスに Link: <https://fonts.gstatic.com>; rel=preconnect; crossorigin ヘッダーが入っていて、ブラウザが CSS を受け取る間にフォントサーバーへの接続を事前に開くからだ。HTML に入れた preconnect のうち gstatic 側の1行が差を生まなかったのも、同じ理由だと思われる。googleapis 側の1行も差がなかったが、その理由と、googleapis の接続時間が最後のフォントの到着時刻に現れなかった理由は切り分けていない。

ただし、この表は接続先が増えるコストを小さく見せる。DevTools の Fast 4G はリクエストごとにレイテンシを加える方式なので、接続を確立する過程(DNS、TCP、TLS)にはレイテンシがかからない。この計測でも、Fast 4G をオンにしたまま Netlify に接続するのにたいてい 147 ms 前後(そのうち TLS が 74 ms 前後)かかったが、これは筆者の回線(ソウル)そのままの値だ。Lighthouse の throttling ドキュメントも、リクエスト単位でかけるレイテンシはパケット単位より不正確だと記している。接続1回には往復が何度も含まれるので、往復時間が長い回線ほど接続先がひとつ増えるコストも大きくなる。その大きさは今回は測っていない。
かつては、Google に直接接続する側に利点がひとつあると考えられていた。多くのサイトが同じ Google フォントの URL を使うので、別のサイトですでに受け取ったフォントをブラウザキャッシュから再利用できるというものだ。Chrome は 86 からこの共有を止めた。Chrome のキャッシュ分割の案内によると、キャッシュキーにはリソースの URL とともにトップレベルのサイトとフレームのサイトが入る。サイトが違えば、同じ URL でもキャッシュを別々に使うということだ。
実際、同じ URL のフォントでもサイトが変わると最初から受け取り直した。Chrome のプロファイルひとつで、Google に直接接続するページをデプロイしたサイトで2回開き、続けて筆者のローカルサーバー(別のサイト)で2回開いた。同じサイトで開き直すとフォント28個がすべてキャッシュから返ったが、サイトを変えると同じ URL のフォント28個、576 kB を受け取り直した。同じ手順をキャッシュ分割を無効にした Chrome(--disable-features=SplitCacheByNetworkIsolationKey)で回すと、サイトを変えても28個すべてがキャッシュから返った。今の Chrome には、フォントをサイト間で共有するキャッシュはない。HTTP Archive の 2024 Web Almanac のフォントの章も、キャッシュ分割によって共有フォント CDN の利点が消えたと記し、フォントを自分のサイトからだけ送るサイトがデスクトップ基準で2022年の 22% から2024年の 28% に増えたと記している。
next/font の preload と代替フォント
Next.js の next/font は、この選択をビルド段階で代わりに行う。ビルド時に Google の CSS とフォントをダウンロードして自分のサイトから送り、指定したフォントを preload し、フォントが届く前に使う代替フォントのサイズを Webフォントに合わせておく。16.3.4 のローダーは Chrome 104 の User-Agent で CSS を取得し、その中のスライスファイルをすべてダウンロードして URL だけを書き換えるので、unicode-range はそのまま残る。2026-10-21 に同じ User-Agent でリクエストしてみると、D と同じルールと同じファイルが返ってきた。
同じ本文と同じ4ファミリーで Next.js 16.3.4 のページを作り、静的ファイルとして書き出して同じサイトに載せた。Noto Sans KR はウェイト4つを列挙し、4ファミリーとも subsets: ['latin'] だ。Noto Sans KR の subsets に指定できる値は cyrillic、latin、latin-ext、vietnamese だけで、ハングルを選ぶ方法がない。それでもハングルが抜け落ちるわけではない。subsets はどのスライスを preload するかだけを決め、ハングルのスライスの @font-face は CSS にすべて残る。出力にはフォントファイルが268個ある。
受け取る量は 631 kB で、D より 52 kB 多かった。このページで使われない Noto Sans KR の latin スライス(26 kB)を受け取り、Inter のイタリックが可変ファイルで届いて D の静的ファイルより 26 kB 大きかった。前の表と同じ条件で、最後のフォントは1.81秒に到着した(7回の中央値)。表の D(1.74秒)との差は0.07秒で、何度も開くときに生じる差より小さい。next/font がその上に加えるのは preload と代替フォントの補正だ。
preload はハングルのスライスには適用されない。HTML に入った preload は5つで、すべて latin のスライスだ。next/font の subsets ではハングルのスライスを選べないからだ。頻度順のスライス20個のように、どの記事でも受け取るスライスを HTML に直接 preload として書くことはできるが、今回は測っていない。しかもこの5つのうち Noto Sans KR の latin スライス(25,804 B)は、このページには不要なファイルだった。preload を無効にしてビルドすると、このファイルはリクエストすらされない。前に見たとおり、ラテン文字を Inter が先に描くからだ。preload を無効にしたビルドは最後のフォントが1.87秒で、D との差は0.13秒だった。
代替フォントの補正はラテン文字にしかかからない。next/font はフォントごとに Noto Sans KR Fallback のようなルールを作り、size-adjust と ascent-override で代替フォントのサイズを Webフォントに合わせる。ところがその土台が local("Arial") だ(Noto Serif KR は Times New Roman)。Arial にはハングルがないので、ハングルは補正のない次の代替フォントに回される。最初の描画の直後に本文の段落を実際に描いたフォントを見ると、ラテンはすでに Inter で、まだスライスが届いていないハングルは macOS のデフォルトの韓国語フォントである Apple SD Gothic Neo だった。この値は DevTools の Elements パネルの Computed タブの一番下にある Rendered Fonts でも見られ、計測には同じ情報を返す Chrome DevTools Protocol の CSS.getPlatformFontsForNode を使った。
next/font を選ぶ理由
ここまで見ると、next/font が韓国語ページを速くしたわけではなかった。サブセットは Google がすでにやってくれていて、preload と代替フォントの補正はハングルには適用されない。それでも広く使われるのには別の理由がある。
next/font は2022年10月の Next.js 13 で、@next/font という名前で初めて登場した。発表文はこのツールを、外部へのネットワークリクエストをなくしてプライバシーとパフォーマンスを確保し、ビルド時にフォントを取得してセルフホストし、size-adjust でレイアウトのずれをなくすものとして紹介している。発表文は、ブラウザが Google にリクエストをひとつも送らない点を別に強調している。
プライバシーの面では、同じ年の初めにドイツの判決があった。2022年1月20日のミュンヘン地方裁判所の判決(事件番号 3 O 17493/20)は、Web サイトが Google Fonts を読み込む際に訪問者の IP アドレスを同意なく Google に渡したことを違法とし、100ユーロの損害賠償を認めた。判決は、Google のサーバーに接続しなくても Google Fonts を使えることを根拠に挙げた。セルフホストすれば、この問題が生じる余地はない。
残りの理由も、前に見たことにつながる。Google に直接接続して得ていた共有キャッシュは、Chrome ではなくなった。接続先が増えるコストは今回の計測では小さく出たが、それは Fast 4G が接続時間にレイテンシをかけないからだ。そして、Google のレスポンスをダウンロードして URL を書き換え、preload を設定し、代替フォントを合わせる作業を手でやると、この記事のデモのようにスクリプトがいくつも必要になる。next/font はその作業を関数呼び出しひとつに減らす。
フォントが遅れて届くときの画面
font-display は、フォントが届く前に何を描くかを決める。指定しなければデフォルト値は auto で、web.dev のフォントの記事によると、Chromium 系と Firefox はこのときフォントを待って最大3秒間文字を描かない。Google の CSS も、URL に display=swap を付けないと font-display のないルールを返す。これまでのページはすべて swap を指定した。代替フォントで先に描き、フォントが届いたら置き換える。置き換えるときに文字幅や行の高さが違うと段落がずれるが、このずれを累積して測った値が CLS(Cumulative Layout Shift)だ。
swap の D 系ページの CLS は中央値 0.018 から 0.020 で、回ごとには 0.024 まで出た。web.dev の CLS の記事が「良好」の基準として定める 0.1 よりはるかに低い。ずれは一度ではなく、何度かに分かれて起きた。スライスごとに到着時刻が違うので、最初の描画の直後には、同じ段落の中で一部の文字だけが Noto Sans KR で、残りは Apple SD Gothic Neo という場合があった。
next/font の代替フォント補正を無効にしたビルドと有効にしたビルドも、CLS が 0.017 と 0.018 で同じだった。補正が適用されるラテン文字は、ずれとは関係がなかった。preload を無効にしたビルドだけが 0.009 で半分ほどだったが、大きなずれが2回から1回に減ったためと見られ、原因は確認していない。
D のファイルはそのままにして、font-display: optional にだけ変えたページも測った。CLS は9回すべて 0.0002 以下だった。その代わり、初回訪問ではフォントをすべてダウンロードしても最後まで代替フォントで描かれた。ハングルは Apple SD Gothic Neo、ラテンはシステムフォントで、初回訪問を別に3回開いてみても同じだった。CSS Fonts 4 仕様の font-display の節では、optional は最初のペイントに使えるほどすぐに準備できなかったフォントを、そのページでは使わない。今回の計測の Chrome はフォントを最後までダウンロードしたので、初めて来たユーザーはフォントを受け取るだけで目にすることはない。キャッシュに残ったフォントは次の訪問で使われうる。2024 Web Almanac のデスクトップ基準で、swap を使うサイトは約 44%、optional は 1% 未満だ。
この CLS は macOS の代替フォントで測った値だ。Windows では代替フォントが Malgun Gothic(맑은 고딕)なので、値が違う可能性がある。next/font が合わせられないハングルの代替フォントは、自分で合わせることもできる。local("Apple SD Gothic Neo") や local("Malgun Gothic") を土台にした @font-face に size-adjust と ascent-override を書いて、代替フォントとして使う方法だ。今回は測っていない。実際のユーザーのもとで起きるずれの大きさと、最も大きくずれた要素は、web-vitals ライブラリの attribution ビルドで集めるほうが、ラボでの計測より正確だ。
韓国のサービスのフォント
実際のサービスはどうしているのだろうか。2026年10月26日に、韓国のサービスいくつかのトップページを headless Chrome で初回訪問のように開き、ネットワーク制限なしでフォント転送量を測った。それぞれ2回ずつ測り、2回の差は数百バイト以内だった。
| サービス | フォント | サブセット | フォントファイル | フォント転送量 |
|---|---|---|---|---|
| MUSINSA | Pretendard、ウェイト4つ | なし | 4 | 3,279 kB |
| Toss | Toss Product Sans、ウェイト4つ | なし | 5 | 2,049 kB |
| カカオ会社紹介 | カカオの書体2種、ウェイト2つずつ | なし | 4 | 1,395 kB |
| Kurly | Pretendard 可変フォント | 92スライス | 19 | 501 kB |
| Karrot(당근) | Pretendard 可変フォント | 92スライス | 10 | 262 kB |

サブセットのない側は、この記事の B と同じ状態だ。woff2 だが、ファイルひとつがウェイトひとつの文字をすべて含む。MUSINSA の Pretendard はウェイトひとつで 800 kB を超える。Toss のファイル5つのうち4つは Toss Product Sans のウェイト別ファイルで、残りのひとつは 10 kB の別のフォントだ。反対に Kurly と Karrot は Pretendard 可変フォントを92個のスライスに分けた CSS を使い、ページで使われた文字を含むスライスだけを受け取る。Fast 4G の 8.1 Mbps で割ると 3,279 kB は約3.2秒、262 kB は約0.3秒だ。測った値ではなく計算値だが、この記事で測った Google 直接接続や preload の時間差より10倍以上大きい。
トップページ1ページを初回訪問だけで測った値なので、ページや時点によって変わる。ウェイトごとにファイルひとつの方式は、一度受け取れば次の訪問ではキャッシュから使うので、再訪問の多いサービスなら差は小さくなる。
表に入れなかったところもある。NAVER は本文をシステムフォントで描き、一部にだけ NanumSquare Neo のファイルひとつ(389 kB)を受け取った。配達の民族は Pretendard を宣言してあったがトップページでは受け取らず、独自書体のファイルひとつ(140 kB)だけを受け取った。
サブセットから見る順序
サブセットのないフォントは、ページが何文字使おうとファイルごとに文字をすべて受け取る。フォントを4つ使うこのページで、その量は TTF で 17.2 MB、woff2 で 12.0 MB で、Fast 4G で最後のフォントが届くまで18.5秒と13.2秒かかった。サブセットに分けると、受け取る量がページで使われた文字に従う。同じページが 0.6 MB、2秒前後になった。
韓国語ページでサブセットが特に重要な理由はファイルサイズだ。woff2 に変えると3分の1ほど減るが、それでもこのページの韓国語フォントはウェイトひとつで 2 MB から 3.2 MB が残った。見出しをいくつか描くために入れた明朝フォントひとつが、本文フォントより大きかった。
サブセットに分けた後の選択は、この計測では時間で優劣をつけられなかった。静的スライスと可変スライスは順位を決められず、Google に直接接続するか自分のサイトから送るか、そして preload、代替フォントの補正が生んだ差は、同じページを何度も開くときに生じる差より小さかった。ただし Fast 4G は接続を確立する時間にレイテンシをかけないので、接続先が増える Google 直接接続のコストは、この計測で小さく出た可能性がある。だからこれらの選択は別の基準で選ぶほうがよい。プライバシーとキャッシュ分割を考えるとセルフホストのほうがよく、その手間を減らしてくれるツールが next/font だ。font-display は、swap で小さなずれを受け入れるか、optional で初回訪問に代替フォントを見せるかを決めればよい。
今回比較しなかった方法もある。使うウェイトとファミリーの数を減らせば、サブセットがないときに受け取る量は減らしたファイル数の分だけ減る。このブログもウェイトは 400 と 700 の2つだけを使う。サブセットなしで可変フォントのファイルひとつで4ウェイトを送る方法もある。どちらも今回は測っていない。
確かめられなかったことも残った。往復時間の長い回線で接続先ひとつが加えるコスト、Windows の Malgun Gothic で描くときの CLS、preload を無効にすると CLS が半分になった原因、次の記事に移るときに新たに受け取るスライスの量、そしてキャッシュから返った回どうしでも残った揺れの原因だ。筆者は、どれもサブセットが生んだ差を覆すほどの大きさではないと見ている。ただし、測った値ではなく推論だ。
韓国の大手サービスにも、まだサブセットなしでウェイトごとに数百 kB のファイルを送っているところがある。この記事を読んだ方も、自分のサービスの Network 画面で Font フィルターをかけ、そうしたファイルがないか一度見てみることをおすすめする。あれば、next/font や preload より先にサブセットを見ればよい。