深夜、手元の 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(); と書くだけです。三層があることも、クランプが走っていることも意識しません。複雑さを一箇所に閉じ込め、外へは型のついた確かな値だけを出す。レイヤー設計の狙いはここにあります。
リモート値がまだ届いていない数秒を、どう埋めるか
三層のマージが正しく動いても、初回起動には穴が残ります。リモート設定はネットワーク越しに届くため、アプリが最初の画面を描く瞬間には、まだ何も手元にありません。
素直に実装すると、この数秒はすべて既定値で走ります。多くの場面ではそれで構いません。ただ、広告のクールダウンのように運営が意図して緩めている値を使っている最中だと、起動直後だけ既定の厳しい値に戻り、しばらくして緩む、という妙な揺れが起きます。私の壁紙アプリでは、この揺れがレビュー訴求のタイミングに出て、一部のユーザーに起動直後だけ評価ダイアログが早く出る状態になっていました。
対処として入れたのは、最後に受理できたリモートの生値を端末に残しておく仕組みです。
// config/remoteCache.ts
import AsyncStorage from "@react-native-async-storage/async-storage" ;
const KEY = "config.remote.cache.v1" ;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000 ; // 7日を超えた値は使わない
type Cached = { at : number ; values : Record < string , unknown > };
export async function saveRemoteCache ( values : Record < string , unknown >) {
const payload : Cached = { at: Date. now (), values };
await AsyncStorage. setItem ( KEY , JSON . stringify (payload));
}
export async function loadRemoteCache () : Promise < Record < string , unknown >> {
const raw = await AsyncStorage. getItem ( KEY );
if ( ! raw) return {};
try {
const c : Cached = JSON . parse (raw);
if (Date. now () - c.at > MAX_AGE_MS ) return {}; // 古すぎる値は黙って捨てる
return c.values;
} catch {
return {}; // 壊れていたら既定へ落とす
}
}
保存しているのが resolveConfig を通した後の完成値ではなく、通す前の生値である点に意味があります。解決済みの値をそのまま保存してしまうと、あとからポリシーの min/max を厳しくしたとき、キャッシュ側だけ古い境界で固まった値が残り続けます。生のまま持てば、次の起動時に必ず最新のポリシーで解決し直されます。
有効期限を7日にしたのは、それより古い配信内容を信じても判断材料にならないと考えたためです。1週間あれば運用の方針は動いています。期限を過ぎたキャッシュは静かに捨て、既定値へ戻します。
起動側では、フェッチ自体にもタイムアウトを置いています。
// config/remoteConfig.ts(抜粋)
const FETCH_TIMEOUT_MS = 2500 ;
const withTimeout = < T ,>( p : Promise < T >, ms : number ) : Promise < T | null > =>
Promise . race ([p, new Promise < null >(( r ) => setTimeout (() => r ( null ), ms))]);
export async function bootstrapRemote () : Promise < Record < string , unknown >> {
const fresh = await withTimeout ( fetchRemoteValues (), FETCH_TIMEOUT_MS );
if (fresh) {
await saveRemoteCache (fresh); // 次回起動のために生値を残す
return fresh;
}
return await loadRemoteCache (); // 届かなければ前回の値、それも無ければ空
}
届けば新しい値、間に合わなければ前回の値、それも無ければ既定値。三段構えにしておくと、通信状況にかかわらず必ず何かが返り、画面側は待たされません。空のオブジェクトが返っても resolveConfig は全キーを既定で埋めるので、呼び出し側の分岐は一切増えません。
なお、この仕組みは値を安全に届けるための土台であって、機能そのものの出し入れとは役割が違います。機能単位でのキルスイッチや段階的な有効化を組みたい場合は、Feature Flags を運用に耐えさせる — Rork Max のキルスイッチと段階的ロールアウト設計 で扱った設計と併用するのが現実的でした。値の境界は設定層で、機能の可否はフラグ層で守る、という住み分けです。
どの層から来た値かを、端末で見えるようにする
運用を始めて最初につまずいたのは、値そのものを見ても経路が分からないことでした。手元の端末に90秒が入っているとして、それが既定の90秒なのか、リモートが90秒を送ってきたのか、それとも極端な値が60秒にクランプされた結果なのか。数字だけでは区別できません。
そこで解決処理に、キーごとの出所を一緒に返させるようにしました。
// config/resolver.ts(追加分)
export type Origin = "default" | "user" | "remote" | "clamped" | "rejected" ;
export function resolveConfigWithOrigin ( sources : Sources ) : {
config : AppConfig ;
origin : Record < keyof AppConfig , Origin >;
rejected : string [];
} {
const out : AppConfig = { ... DEFAULTS };
const origin = {} as Record < keyof AppConfig , Origin >;
const rejected : string [] = [];
for ( const key of Object. keys ( DEFAULTS ) as ( keyof AppConfig )[]) {
const policy = POLICIES [key];
origin[key] = "default" ;
if (policy.kind === "locked" ) continue ;
if (policy.kind === "user" ) {
const val = sources.user[key];
if (val !== undefined ) {
(out[key] as unknown ) = val;
origin[key] = "user" ;
}
continue ;
}
const raw = sources.remote[key];
if (raw === undefined ) continue ;
const parsed = ConfigSchema.shape[key]. safeParse (raw);
if ( ! parsed.success) {
rejected. push ( `${ key }: ${ parsed . error . issues [ 0 ]?. message ?? "invalid"}` );
origin[key] = "rejected" ; // 既定のまま。ただし「届いたが弾いた」と記録
continue ;
}
const value = parsed.data;
if ( typeof value === "number" ) {
const clamped = clamp (value, policy.min, policy.max);
(out[key] as unknown ) = clamped;
origin[key] = clamped === value ? "remote" : "clamped" ;
} else {
(out[key] as unknown ) = value;
origin[key] = "remote" ;
}
}
return { config: out, origin, rejected };
}
肝は rejected と default を別の出所として扱っている点です。どちらも最終的な値は既定ですが、意味はまったく違います。前者は「届いたが信用できなかった」、後者は「そもそも届いていない」。この二つを同じ扱いにしていた頃は、配信ミスとネットワーク不通の切り分けがつかず、調べる順番を毎回間違えていました。
clamped を独立させたのも同じ理由です。境界に貼りついた値が続くようなら、それは運営側が設定したい水準とポリシーの上限がずれている合図です。ログを見返して境界そのものを見直す判断材料になります。
デバッグ画面は、開発ビルドでのみ出る一覧にしています。
表示する出所 画面上の意味 次に見る場所
default リモートに該当キーが無い、または未取得 配信側のキー名と、フェッチが成功しているか
user ユーザーが端末で選んだ値 設定画面の保存処理
remote 届いた値がそのまま通った 問題なし。想定どおり
clamped 境界に丸められた ポリシーの min/max と、配信した値の乖離
rejected 型が合わず弾いた 配信側の値の型。文字列と数値の取り違えが大半
これを入れてから、設定まわりの調査にかかる時間が体感で半分以下になりました。以前は配信側のコンソールと端末のログを往復していたのが、いまは実機を1台開けば結論が出ます。
リリースビルドではこの画面を含めていません。出所の情報そのものは無害ですが、境界値やキー名の一覧を配布物に残しておく必要はないと考えたためです。__DEV__ での分岐に加えて、画面のインポート自体を開発ビルドに閉じています。
数値をどの水準に置くべきかという判断は、この記事の範囲を超えます。広告頻度そのものの決め方についてはRork で作ったアプリを AdMob で月10万円にするまで — 個人開発者の収益化実践戦略 で実際の数字を追っていますので、境界を決める際の下敷きにしていただけるかもしれません。設定層が守るのは安全であって、最適な値を見つけるのは別の作業です。
直感に反したこと — 「リモートを万能にする」が事故を生んだ
正直に書きます。事故の前、私はリモート設定をできるだけ強くしておくのが良い設計だと思っていました。運営側から何でも即座に変えられるほど、運用は楽になるはずだと。実際は逆でした。何でも変えられるということは、何でも壊せるということでした。
もう一つ意外だったのは、rejected のログが宝の山だったことです。当初は「弾いた値を記録する」のは念のための保険くらいの位置づけでした。ところが運用してみると、配信の設定ミス・型のうっかり・古いキー名の残存が、このログに先に現れます。私の記録では、リモート配信の約3%で何らかの型不一致や範囲外の値が検出されていました。ユーザーが不具合として気づく前に、こちら側が気づける。境界で弾いた事実こそが、最良の監視だったのです。
ひとつ運用で困ったのは、スキーマを変えた直後に古いキー名が rejected へ大量に出る現象でした。設定ミスと見分けがつきません。私は、廃止するキーをすぐ消さず、1リリースのあいだスキーマに残して既定値へ静かに落とす期間を設けています。この猶予を挟むだけで、rejected のログは「本当に直すべき設定ミス」だけを映すようになり、週次の確認が数分で終わるようになりました。境界で弾く仕組みは、弾いた理由をきれいに保ってこそ監視として機能します。
公式ドキュメントは「リモート設定の入れ方」は丁寧に教えてくれますが、「届いた値をどこまで信じるか」はアプリ側の責任として残されます。私が6本のアプリで決めた運用上の判断を、いくつか共有します。
判断 採用した方針 理由
境界の決め方 過去に安全だった実測レンジの少し内側に min/max を置く 攻めた値を試したいときほど、事故時の振れ幅を先に狭めておく
弾いた値の扱い 捨てずに必ずログへ。週次で件数を確認する 設定ミスの最速の検知経路になるため
locked の使いどころ 体験の骨格に関わるフラグだけに限定する 安全弁を増やしすぎると、今度は素早く直せなくなる
スキーマとポリシーの分離 受理範囲は広め、運用範囲は狭めの二段構え 本番の許容と、そもそもの妥当性を別々に調整したいため
境界の内側で、実際にどの値がいちばん効くのかを比べたくなる段階も来ます。そこまで進むと解決層のクランプだけでは足りず、配信を分けて結果を測る仕組みが必要になります。その組み方はRork の A/B テスト基盤を GrowthBook と PostHog でゼロから組む に整理しました。ここで作った三層の解決は、その手前で「どんな値が来ても壊れない」を保証する土台にあたります。
もし今から一つだけ入れるとしたら、私は remote ポリシーのクランプから始めることをお勧めします。全キーをポリシー化しなくても、外部から届く数値にひとつ min/max を挟むだけで、あの夜のような連鎖はほぼ止まります。locked や user への分類は、運用しながら少しずつ足していけば十分です。
設定は地味な層です。けれど、壊れたときにユーザーの手元でいちばん目立つ層でもあります。値を受け取る側で守る、という一点だけでも持ち帰っていただければ、この記事を書いた甲斐があります。お読みいただきありがとうございました。