個人開発で運営している壁紙アプリのカタログは、気づけば2万件を超えていました。起動直後に取得するカタログ JSON は素の状態で約 7MB。gzip がかかっても 1MB 近くあり、回線の細い環境では初回表示の遅れが体感できるほどになっていました。
最初に手を付けたのはキー名の短縮です。imageUrl を u に、fileSize を f に。ファイルは 19% 小さくなり、これで一歩前進だと思っておりました。ところが配信されるサイズ、つまり gzip 後のサイズを測ると、削減はわずか 3.2%。コードの可読性を差し出した見返りとしては、あまりに小さい数字です。
どこを削れば配信が本当に軽くなるのか。思い込みを一つずつ実測で潰していった記録です。
計測の前提 — 2万件の合成カタログ
実測は Node v22.22.3 と標準モジュールの zlib だけで行いました。実データをそのまま載せるわけにはいかないため、実際のカタログと同じフィールド構成を持つ合成データを、シード固定の乱数で生成しています。シードを固定しているので、手元で走らせれば同じバイト数が再現できます。
1件のレコードは、壁紙アプリのカタログとして典型的な12フィールドです。ID・タイトル・カテゴリ・タグ配列・解像度・ファイルサイズ・画像 URL 2本・プレミアムフラグ・作成日時・ソート用スコア。これを 20,000 件並べます。
// gen.js — Node v22.22.3 / 依存なし(zlib は標準モジュール)
const zlib = require('zlib');
const fs = require('fs');
// シード固定の乱数(mulberry32)。実行のたびに同じカタログが再現される
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6D2B79F5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
const rand = mulberry32(20260805);
const cats = ['nature','city','space','abstract','animal','flower','sea','sky','night','minimal'];
const words = ['sakura','yozora','umi','yama','hikari','kaze','ame','yuki','hoshi','tsuki','asa','yugure','mori','kawa','sora','kumo'];
const pick = (a) => a[Math.floor(rand() * a.length)];
const N = 20000;
const items = [];
for (let i = 0; i < N; i++) {
const id = i + 1;
const cat = pick(cats);
items.push({
id,
title: `${pick(words)}-${pick(words)}-${String(id).padStart(5, '0')}`,
category: cat,
tags: [pick(words), pick(words), pick(words)],
width: [1080, 1170, 1290][Math.floor(rand() * 3)],
height: [1920, 2532, 2796][Math.floor(rand() * 3)],
fileSize: Math.floor(rand() * 4000000) + 200000,
imageUrl: `https://cdn.example.com/wallpapers/${cat}/${id}/original.jpg`,
thumbUrl: `https://cdn.example.com/wallpapers/${cat}/${id}/thumb.jpg`,
premium: rand() < 0.2,
createdAt: new Date(1600000000000 + Math.floor(rand() * 150000000000)).toISOString(),
sortScore: Math.round(rand() * 100000) / 100,
});
}
このベースラインに対して、次の4つの変換を用意しました。
// B: キー名を1文字に短縮(imageUrl → u など)
const KM = { id:'i', title:'t', category:'c', tags:'g', width:'w', height:'h',
fileSize:'f', imageUrl:'u', thumbUrl:'v', premium:'p', createdAt:'d', sortScore:'s' };
const B = items.map((o) => { const r = {}; for (const k in o) r[KM[k]] = o[k]; return r; });
// C: URL 2本を落とす(category と id から端末側で組み立て直せるため)
const C = items.map(({ imageUrl, thumbUrl, ...rest }) => rest);
// D: 列指向 — オブジェクトの配列を「フィールドごとの配列」に転置する
const keys = Object.keys(items[0]);
const D = {};
for (const k of keys) D[k] = items.map((o) => o[k]);
// E: 列指向 + URL 除去の合わせ技
const E = {};
for (const k of keys.filter((k) => k !== 'imageUrl' && k !== 'thumbUrl')) E[k] = items.map((o) => o[k]);
// それぞれ raw / gzip(level 6, 9) / Brotli(quality 11) のバイト数を測る
for (const [name, v] of Object.entries({ A: items, B, C, D, E })) {
const s = JSON.stringify(v);
console.log(name, {
raw: Buffer.byteLength(s),
gz6: zlib.gzipSync(s, { level: 6 }).length,
gz9: zlib.gzipSync(s, { level: 9 }).length,
br11: zlib.brotliCompressSync(s, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 11 } }).length,
});
}
gzip の level 6 は CDN やリバースプロキシの既定値に相当します。Brotli は quality 11、つまり事前圧縮で使う最高設定です。
5つの形式と実測結果
結果を先に並べます。削減率はすべてベースライン A の同じ列に対する値です。
| 形式 | raw | gzip(6) | gzip(9) | Brotli(11) | gzip(6) 削減率 |
| A ベースライン(オブジェクト配列) | 7,035,702 | 943,796 | 891,579 | 691,526 | — |
| B キー名短縮 | 5,695,702 | 913,189 | 883,867 | 664,434 | −3.2% |
| C URL 除去 | 4,185,850 | 745,072 | 704,625 | 559,871 | −21.1% |
| D 列指向 | 4,695,841 | 691,065 | 648,090 | 556,131 | −26.8% |
| E 列指向 + URL 除去 | 2,285,963 | 534,016 | 523,630 | 451,554 | −43.4% |
raw の削減率と gzip 後の削減率が、まるで対応していません。B は raw で −19.0% なのに gzip 後は −3.2%。C は raw −40.5% に対して gzip 後 −21.1%。逆に D は raw −33.3% で、gzip 後は −26.8% と比較的よく残ります。
「ファイルを小さくすればその分だけ配信も軽くなる」という直感は、圧縮のかかった配信経路では成り立たないということです。
キー短縮が効かない理由
gzip(DEFLATE)は、直前 32KB の窓の中に同じバイト列があれば、それを「距離と長さ」の参照に置き換えます。オブジェクト配列の JSON では "imageUrl":"https://cdn... のようなキー部分が20,000回繰り返されますが、この繰り返しはまさに gzip が最も得意とするパターンで、2回目以降はほぼ数ビットの参照に畳み込まれています。
つまり、キー名の長さはすでにほぼタダになっている。そこを短縮しても、もともと数ビットだったものが数ビットのままで、配信サイズはほとんど変わりません。B 案が raw で 1.34MB も削れているのに配信では 30KB しか変わらないのは、これが理由です。
私はこの検証をするまで、キー短縮を「地味だが確実に効く手」だと思っておりました。実際には、コードの可読性とデバッグのしやすさを差し出して 3% を買う取引です。少なくとも私のカタログでは、割に合いません。
列指向への配置換え — gzip のまま Brotli 相当に届いた
D 案は、オブジェクトの配列を「フィールドごとの配列」に転置しただけです。データは1バイトも減らしていません。それでも gzip 後は −26.8% と、今回試した単独の手の中で最も効きました。
理由は gzip の窓の使い方にあります。行指向では、似た値(同じカテゴリ名、近い数値、同じ形式の日時文字列)が他のフィールドを挟んで離れた位置に散らばります。列指向に転置すると、似た値どうしが隣接し、後方参照が短い距離で当たり続ける。圧縮アルゴリズムに歩み寄る配置です。
面白かったのは絶対値です。D 案の gzip(6) は 691,065 バイト。ベースラインを Brotli quality 11 で圧縮した 691,526 バイトと、ほぼ同じところに着地しました。配置換えだけで、gzip のまま Brotli 相当まで届いたことになります。Brotli が使えない配信経路でも、ペイロード側の工夫で同じ水準に持っていける余地があるということです。
パース時間も測っておきました。JSON.parse の11回中央値で、A が 37.2ms、D が 19.8ms。D は端末側でオブジェクト配列に戻す復元処理が要りますが、その復元も 12.7ms で、合計 32.5ms。行指向をそのままパースするより、転置してパースして戻すほうが速いという、これも事前の予想と逆の結果でした。パース時間は概ね raw サイズに比例するため、raw が小さい列指向はパースでも得をします。
URL はテンプレートで外に出す
C 案の URL 除去は gzip 後 −21.1%。B 案よりずっと効きますが、raw の −40.5% からは大きく目減りしています。URL は https://cdn.example.com/wallpapers/ のような共通接頭辞を持つ、圧縮が非常によく効くデータだからです。
それでも、URL がパスの規約({category}/{id}/original.jpg)から機械的に組み立てられるなら、外に出す価値はあります。E 案(列指向 + URL 除去)は gzip 後 534,016 バイトで −43.4%。gzip の解凍時間も A の 12.9ms から 4.8ms に縮み、JSON.parse は 11.9ms でした。
注意点として、URL をテンプレート化すると、配信経路の規約がクライアントコードに焼き付きます。CDN のパス構成を変えたくなったときに、古いバージョンのアプリが古い規約で URL を組み立て続ける。この足かせを受け入れられるかどうかが、C・E 案を採るかどうかの分かれ目です。私はスキーマにバージョン番号を持たせ、規約変更時はカタログ側で旧バージョン向けのフル URL 配信に戻せるようにしています。
ページングのサイズ税は 2.9%
「分割すると圧縮の効率が落ちるのでは」という点も測りました。20,000件を500件ずつ40ページに分け、各ページを個別に gzip(6) して合計すると 970,897 バイト。一括の 943,796 バイトに対して +2.9% です。
窓が小さくなる分の損は確かにありますが、税率はこの程度。初回表示に必要な1ページだけ先に取り、残りをバックグラウンドで取る設計は、サイズの面ではほとんど損をしません。一覧の取得設計そのものはカーソルページネーションと再取得の状態設計で別途扱っているため、ここではサイズの実測だけ記しておきます。
クライアント側の実装 — 復元とスキーマガード
Rork で生成した Expo(React Native)アプリ側の受け口です。列指向カタログの取得・検証・復元・URL 組み立てまでを1つの関数にまとめています。スキーマの v を最初に確認し、想定外の形が来たら例外にして呼び出し側でキャッシュ済みカタログへフォールバックさせます。
const CDN = 'https://cdn.example.com';
const CATALOG_SCHEMA_VERSION = 2;
type WallpaperItem = {
id: number; title: string; category: string; tags: string[];
width: number; height: number; fileSize: number;
premium: boolean; createdAt: string; sortScore: number;
imageUrl: string; thumbUrl: string;
};
export async function fetchCatalog(url: string, timeoutMs = 10000): Promise<WallpaperItem[]> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
// fetch は gzip / Brotli を透過的に解凍する。Accept-Encoding の手動指定は不要
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) throw new Error(`catalog fetch failed: ${res.status}`);
const cols = await res.json();
// スキーマガード。列指向は「形が崩れても配列としては読めてしまう」ため、
// バージョンと列の存在・長さ一致を先に検証しておく
if (cols?.v !== CATALOG_SCHEMA_VERSION || !Array.isArray(cols.id)) {
throw new Error('unexpected catalog schema');
}
const n = cols.id.length;
for (const k of ['title','category','tags','width','height','fileSize','premium','createdAt','sortScore']) {
if (!Array.isArray(cols[k]) || cols[k].length !== n) {
throw new Error(`catalog column broken: ${k}`);
}
}
// 復元 + URL 組み立て(20,000件で実測 12.7ms)
const items: WallpaperItem[] = new Array(n);
for (let i = 0; i < n; i++) {
const id = cols.id[i];
const category = cols.category[i];
items[i] = {
id, category,
title: cols.title[i], tags: cols.tags[i],
width: cols.width[i], height: cols.height[i],
fileSize: cols.fileSize[i], premium: cols.premium[i],
createdAt: cols.createdAt[i], sortScore: cols.sortScore[i],
imageUrl: `${CDN}/wallpapers/${category}/${id}/original.jpg`,
thumbUrl: `${CDN}/wallpapers/${category}/${id}/thumb.jpg`,
};
}
return items;
} finally {
clearTimeout(timer);
}
}
配信側でひとつ運用知見を。Brotli quality 11 の圧縮は 7MB の入力に 14.4 秒かかりました(列指向でも 9.5 秒)。リクエストのたびに圧縮する設定にはせず、カタログ生成時に .json.br と .json.gz を事前に作って静的配信するのが現実的です。逆に解凍側は gzip で 12.9ms と軽く、端末側の負担にはなりません。
どの順で手を付けるか
今回の実測から、私は次の順番で判断するようにしています。
- まず配信経路の圧縮を確認する: gzip なしの 7MB と gzip ありの 944KB の差(−86.6%)は、この記事のどの工夫よりも大きい。CDN の圧縮設定と、レスポンスの content-encoding の確認が先です
- 列指向への転置: データを削らずに −26.8%。パースも速くなる。復元コードとスキーマガードを書く手間だけ払う
- 派生可能なフィールドの除去: URL のように規約から組み立て直せるものを外す。規約変更に備えてスキーマバージョンを必ず持たせる
- ページング: サイズの税は +2.9% しかない。初回表示に要る分だけ先に取る
- キー名短縮はやらない: gzip 後 −3.2% のために可読性を差し出す取引は、少なくともカタログ配信では成立しない
なお、カタログを軽くしても、その先の画像取得が重ければ体感は変わりません。画像側の話は壁紙アプリの画像キャッシュの計測と上限設計に運用メモをまとめています。
まずはご自身のカタログで、A・D・E の3形式だけでも測ってみてください。数字は環境ごとに違っても、「raw の削減率と配信の削減率は別物」という構図はおそらく同じはずです。同じように配信サイズと向き合っている方の、比較の手間を少しでも省ければ幸いです。