深夜、手元の iPhone で自分の壁紙アプリを開いたら、画像を1枚めくるたびに全画面広告が挟まってきました。数十秒のあいだに二度、三度。胸の奥がひやりとしました。
原因はコードではありませんでした。リモート設定に置いていたインタースティシャル広告のクールダウン秒数が、何かの手違いで極端に小さい値になり、それがそのままアプリに配信されていたのです。しかも同じ設定を共有していた個人開発の壁紙アプリ6本すべてに、同時に。
このとき私自身が痛感したのは、「リモート設定はリモートで直せるから安全」という思い込みの危うさでした。リモートは確かに素早く直せます。けれど、壊れた値がユーザーの端末に届くまでの数十分、アプリは無防備なまま走り続けます。守るべき場所は配信元ではなく、値を受け取る側の端末でした。
取り上げたいのは、既定値・ユーザー設定・リモート設定という三つの源を、ひとつの型安全なレイヤーでマージし、壊れた値が入ってもアプリが崩れないようにする設計です。AdMob のインタースティシャル頻度をリモートで調整していた壁紙アプリ6本に、あの夜のあと実際に入れて運用しているコードをもとにしています。App Store 側の何を変えるでもなく、値を受け取る端末側だけで守れるのが、この設計の勘所でした。
設定の源は三つある、という前提から始める
多くのアプリで、設定値は無自覚に三層になっています。順に整理します。
一つ目はコードに埋め込んだ既定値です。ビルドに焼き込まれ、オフラインでも必ず存在します。型と初期値の source of truth はここに置きます。
二つ目はユーザー設定です。端末ローカル(AsyncStorage や MMKV)に保存され、ユーザーが明示的に選んだ好みを表します。プリフェッチ枚数やテーマなどが該当します。
三つ目はリモート設定です。Firebase Remote Config などから届く外部入力で、運営側が全ユーザーに素早く効かせたい値を入れます。広告頻度やレビュー訴求のタイミングが典型です。
問題は、この三層を「後から来たものが勝つ」という単純な上書きでマージしてしまうことです。あの夜の事故は、まさにリモートが何でも上書きできる設計だったから起きました。三層あること自体は避けられません。避けるべきは、層の強さを一律にすることです。
キーごとに「上書きの強さ」を変える
設計の核心は、値ごとに誰がどこまで上書きしてよいかを宣言することです。私は三種類のポリシーに落ち着きました。
locked — 誰も上書きできない安全弁
オンボーディングのバージョン切り替えのように、間違って動くと体験が壊れるフラグは locked にします。リモートもユーザーも触れず、常に既定値のまま。安全弁として、あえて動かせなくする判断です。
user — ユーザー設定だけが勝つ
プリフェッチ枚数のような純粋な好みは user ポリシーにします。リモートは介入せず、ユーザーが選んだ値だけが既定を上書きします。運営が勝手に変えてよい値ではないからです。
remote — 範囲を決めて、その中でだけ上書きを許す
広告クールダウンのように運営が調整したい値は remote にします。ただし丸ごと信じません。キーごとに min/max を定め、届いた値を必ずその範囲へクランプします。あの夜の3秒は、下限60秒に引き上げられて届くわけです。
マージ本体を型安全に書く
ここからが実装です。まず既定値をスキーマとして定義し、これを型と初期値の唯一の源にします。検証には zod を使います。
// config/resolver.ts
import { z } from "zod" ;
// 既定値のスキーマ。型と初期値の source of truth はここだけ
const ConfigSchema = z. object ({
interstitialCooldownSec: z. number (). int (). min ( 30 ). max ( 600 ),
reviewPromptAfterOpens: z. number (). int (). min ( 3 ). max ( 50 ),
imagePrefetchCount: z. number (). int (). min ( 0 ). max ( 40 ),
showOnboardingV2: z. boolean (),
});
export type AppConfig = z . infer < typeof ConfigSchema>;
const DEFAULTS : AppConfig = {
interstitialCooldownSec: 90 ,
reviewPromptAfterOpens: 8 ,
imagePrefetchCount: 12 ,
showOnboardingV2: false ,
};
// キーごとの上書きポリシー
// locked … リモートもユーザーも触れない
// user … ユーザー設定だけが既定を上書きできる
// remote … リモートが上書きできるが min/max に必ずクランプ
type Policy =
| { kind : "locked" }
| { kind : "user" }
| { kind : "remote" ; min : number ; max : number };
const POLICIES : Record < keyof AppConfig , Policy > = {
interstitialCooldownSec: { kind: "remote" , min: 60 , max: 300 },
reviewPromptAfterOpens: { kind: "remote" , min: 5 , max: 20 },
imagePrefetchCount: { kind: "user" },
showOnboardingV2: { kind: "locked" },
};
ポリシーの min/max とスキーマの min/max はわざと別にしています。スキーマは「そもそも受け付けてよい値の範囲」、ポリシーは「リモートに許す運用上の範囲」です。前者は広め、後者は狭め。この二段構えが後で効きます。
次に解決処理の本体です。届いたリモート値は信頼できない外部入力として扱い、キーを一つずつ検証・クランプしていきます。
type Sources = {
user : Partial < AppConfig >;
remote : Record < string , unknown >; // 信頼できない外部入力
};
const clamp = ( v : number , min : number , max : number ) =>
Math. min (max, Math. max (min, v));
export function resolveConfig ( sources : Sources ) : {
config : AppConfig ;
rejected : string [];
} {
const out : AppConfig = { ... DEFAULTS };
const rejected : string [] = [];
for ( const key of Object. keys ( DEFAULTS ) as ( keyof AppConfig )[]) {
const policy = POLICIES [key];
if (policy.kind === "locked" ) continue ; // 既定のまま動かさない
if (policy.kind === "user" ) {
const val = sources.user[key];
if (val !== undefined ) (out[key] as unknown ) = val;
continue ;
}
// policy.kind === "remote"
const raw = sources.remote[key];
if (raw === undefined ) continue ;
// 型がずれていたら、その1キーだけ既定に残す(全体は壊さない)
const field = ConfigSchema.shape[key];
const parsed = field. safeParse (raw);
if ( ! parsed.success) {
rejected. push ( `${ key }: ${ parsed . error . issues [ 0 ]?. message ?? "invalid"}` );
continue ;
}
// 数値はポリシーの境界に必ず収める
const value = parsed.data;
(out[key] as unknown ) =
typeof value === "number" ? clamp (value, policy.min, policy.max) : value;
}
return { config: out, rejected };
}
ここで最も大切なのは、検証に失敗したキーを continue で飛ばし、そのキーだけ既定値のまま残している点です。壊れた1キーのためにオブジェクト全体を捨てたりしません。一つの腐ったリンゴで箱ごと捨てない、という発想です。
リモート値をそのまま信じない、を動作で確かめる
言葉だけでは腑に落ちないので、壊れた値を流し込むテストを書きます。これがそのまま仕様書になります。
// resolver.test.ts
import { resolveConfig } from "./resolver" ;
test ( "壊れたリモート値でもアプリ既定に落ちる" , () => {
const { config , rejected } = resolveConfig ({
user: { imagePrefetchCount: 24 },
remote: {
interstitialCooldownSec: 3 , // 短すぎ → 下限60にクランプ
reviewPromptAfterOpens: "soon" , // 型違反 → 既定8に落とす
showOnboardingV2: true , // locked → 無視
},
});
expect (config.interstitialCooldownSec). toBe ( 60 ); // クランプ
expect (config.reviewPromptAfterOpens). toBe ( 8 ); // フォールバック
expect (config.imagePrefetchCount). toBe ( 24 ); // ユーザーが上書き
expect (config.showOnboardingV2). toBe ( false ); // locked のまま
expect (rejected. length ). toBe ( 1 );
});
あの夜の3秒は、このテストの一行目そのものです。今なら60秒にクランプされて届きます。文字列が紛れ込んだレビュー訴求も、既定の8回に静かに落ちます。locked のフラグは何が来ても動きません。事故の再現がそのまま回帰テストになっているのが、この設計の気持ちよさです。
React からは単一のフックに閉じる
呼び出し側にこの複雑さを漏らしたくありません。解決処理はひとつのフックに閉じ込め、画面側は完成した AppConfig だけを受け取ります。
// config/useConfig.ts
import { useMemo } from "react" ;
import { resolveConfig, type AppConfig } from "./resolver" ;
import { useUserPrefs } from "./userPrefs" ; // 端末ローカル
import { useRemoteConfig } from "./remoteConfig" ; // Firebase 等の生値
export function useConfig () : AppConfig {
const user = useUserPrefs ();
const remote = useRemoteConfig ();
return useMemo (() => {
const { config , rejected } = resolveConfig ({ user, remote });
if (rejected. length > 0 ) {
// 本番では捨てずに必ず記録する。ここが後で効く
console. warn ( "[config] rejected keys" , rejected);
}
return config;
}, [user, remote]);
}
画面側は const { interstitialCooldownSec } = useConfig(); と書くだけです。三層があることも、クランプが走っていることも意識しません。複雑さを一箇所に閉じ込め、外へは型のついた確かな値だけを出す。レイヤー設計の狙いはここにあります。
直感に反したこと — 「リモートを万能にする」が事故を生んだ
正直に書きます。事故の前、私はリモート設定をできるだけ強くしておくのが良い設計だと思っていました。運営側から何でも即座に変えられるほど、運用は楽になるはずだと。実際は逆でした。何でも変えられるということは、何でも壊せるということでした。
もう一つ意外だったのは、rejected のログが宝の山だったことです。当初は「弾いた値を記録する」のは念のための保険くらいの位置づけでした。ところが運用してみると、配信の設定ミス・型のうっかり・古いキー名の残存が、このログに先に現れます。私の記録では、リモート配信の約3%で何らかの型不一致や範囲外の値が検出されていました。ユーザーが不具合として気づく前に、こちら側が気づける。境界で弾いた事実こそが、最良の監視だったのです。
ひとつ運用で困ったのは、スキーマを変えた直後に古いキー名が rejected へ大量に出る現象でした。設定ミスと見分けがつきません。私は、廃止するキーをすぐ消さず、1リリースのあいだスキーマに残して既定値へ静かに落とす期間を設けています。この猶予を挟むだけで、rejected のログは「本当に直すべき設定ミス」だけを映すようになり、週次の確認が数分で終わるようになりました。境界で弾く仕組みは、弾いた理由をきれいに保ってこそ監視として機能します。
公式ドキュメントは「リモート設定の入れ方」は丁寧に教えてくれますが、「届いた値をどこまで信じるか」はアプリ側の責任として残されます。私が6本のアプリで決めた運用上の判断を、いくつか共有します。
判断 採用した方針 理由
境界の決め方 過去に安全だった実測レンジの少し内側に min/max を置く 攻めた値を試したいときほど、事故時の振れ幅を先に狭めておく
弾いた値の扱い 捨てずに必ずログへ。週次で件数を確認する 設定ミスの最速の検知経路になるため
locked の使いどころ 体験の骨格に関わるフラグだけに限定する 安全弁を増やしすぎると、今度は素早く直せなくなる
スキーマとポリシーの分離 受理範囲は広め、運用範囲は狭めの二段構え 本番の許容と、そもそもの妥当性を別々に調整したいため
もし今から一つだけ入れるとしたら、私は remote ポリシーのクランプから始めることをお勧めします。全キーをポリシー化しなくても、外部から届く数値にひとつ min/max を挟むだけで、あの夜のような連鎖はほぼ止まります。locked や user への分類は、運用しながら少しずつ足していけば十分です。
設定は地味な層です。けれど、壊れたときにユーザーの手元でいちばん目立つ層でもあります。値を受け取る側で守る、という一点だけでも持ち帰っていただければ、この記事を書いた甲斐があります。お読みいただきありがとうございました。