
はじめに
こんにちは、ZOZOTOWN開発1部iOSブロックの荻野です(@juginon)。
みなさんに日々使っていただいているZOZOTOWN iOSアプリのホーム画面ですが、実は2024年秋から2026年の年初まで約1年半、水面下でリアーキテクチャを行っていました。
リアーキテクチャに着手する前の当時の私はアーキテクチャ設計への理解がまだ浅く、「実際に手を動かしながら身につけたい」という動機でこのリアーキテクチャを主導しました。自分にとってはチャレンジングな取り組みで、アーキテクチャ設計やテスト設計への理解が実践を通して大きく深まったプロジェクトになりました。
本記事では、そのリアーキテクチャのすべての軌跡と、そこで得た学びをお伝えします。
なお、本記事で紹介するホーム画面リファクタリングは、iOSチーム全体で取り組んでいるアーキテクチャ刷新の具体的な事例の1つでもあります。チームとしての取り組みや知識共有の仕組みについてはZOZOTOWNのiOSアーキテクチャとチーム進化の軌跡にもまとめています。本記事と合わせて読むと、個々の取り組みとチーム全体の文脈をより立体的に理解できます。
目次
- はじめに
- 目次
- ホーム画面について
- ホーム画面が抱えていた課題
- リファクタリングの設計方針
- Step1: Objective-Cレガシー型への依存を剥がす
- Step2: 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する
- Step3: MallHomeViewController全体にViewModel/UseCaseを導入する
- Step3-ex: 命名整理とユニットテストの追加
- Step4: HomeViewControllerにViewModel/UseCaseを導入する
- 長期リファクタリングを進める上でのポイント
- おわりに
ホーム画面について
ZOZOTOWN iOSアプリのホーム画面は以下のように、主にタブとモジュールによって構成されています。

タブ
画面上部に表示されている「すべて」「コスメ」部分を指します。タブは切り替えが可能で、すべてタブではアパレル・シューズ・コスメ等すべての商品が、コスメタブではコスメ商品特化の画面表示になります。
実装上は以下の2種類のViewControllerで構成されています。
HomeViewController: ホームタブのルート画面となる画面全体を管理するViewController- ヘッダーや検索窓など、両方のタブで共通して表示する部分、ホーム画面全体の管理を担う
MallHomeViewController: すべてタブ/コスメタブのコンテンツを管理するViewController- それぞれのタブで表示が変わる部分の管理を担う
モジュール
各タブのコンテンツは、複数の「モジュール」と呼ばれるブロックが縦に並んだ構成です。モジュールとは、性別選択・バナー・チェックしたアイテムといった、個々のコンテンツ単位のことです。
ユーザーがホーム画面をスクロールすると、これらのモジュールが順番に表示されます。
ホーム画面が抱えていた課題
当初の設計と、その後の運用実態の乖離
ホーム画面の複雑さを理解するには、2021年のフルリニューアル時の背景を知る必要があります。
2021年3月のZOZOTOWNフルリニューアルで初めてタブ構成が導入されました。当時は3つのタブがあり、MallHomeViewControllerを基底クラスとした3つのサブクラスによる継承構成を採用しました。各タブで固有の処理が発生することを見越した設計です。

当時の取り組みについてはZOZOTOWNアプリ Home画面のリニューアルにおけるアーキテクチャ再設計でも詳しく紹介されています。
しかし、フルリニューアルから3年以上が経過し、運用を重ねる中で当初の設計前提が変わっていきました。
継承構造が不要になった
従来ではMallHomeViewControllerを継承する各タブのクラスを作成していましたが、各タブで固有の処理は実際にはほとんど発生しませんでした。
タブの種類を保持するだけで十分な状態で、各タブで専用のクラスを作成する構造はかえって全体像の把握を難しくしていました。
ログ管理の複雑化
リニューアル当初はGA(Google Analytics)のみだったログ送信を専用のLoggerクラスが管理していました。しかしその後、社内分析用ログなど複数種別のログが追加されていく中で、Logger自身が複雑な状態管理を担うようになっていきました。
複数のフラグがLoggerの内部に積み重なり、MallHomeViewControllerが持つ状態と常に同期させる必要が生じました。また、ログに関する責務分離が適切に行われていない部分もあり、こういった構造がコードを読む際のコストを高める要因の1つになっていきました。
MVCによるViewControllerへの責務集中
2021年当時はMVCアーキテクチャを採用していたため、API呼び出し・UI状態管理・ビジネスロジックの調整が MallHomeViewControllerに集中していました。前述のLoggerクラスとの状態同期もVCが直接担っており、改修を加えるたびにVC・Logger双方への影響を考慮しなければなりませんでした。こうした積み重ねで行数は再び1000行を超えるまでに膨らんでしまっていました。
特に問題だったのは、UICollectionViewへのデータ構築と商品押下時のログデータ作成が混在する500行弱の巨大なメソッドです。どこを触れば何が変わるのか把握するだけで大きなコストがかかる状態でした。
高い改修頻度
ZOZOTOWNのホーム画面は平均月1ペースで改修案件が入り、多い時期には3案件が同時並行で走ることもあります。
このリアーキテクチャが開始してから現在まででも、ホーム画面のモジュールを無限スクロールできる機能や、モジュール内のアイテムで動画を表示する機能など、規模の大きな案件がリリースされています。
影響範囲の把握が困難なFat ViewControllerは、改修のたびにリスクを伴い、チームの開発速度を下げる原因になっていました。
リファクタリングの設計方針
課題は明確でしたが、1000行超のVCを一気に書き換えるのはリスクが高すぎます。そこで以下の方針を立てました。
なお、このリファクタリングは通常の機能開発と並行して進めており、稼働の約2割をこの取り組みに充てながら進めていました。1年半という期間はそのためです。
方針1: 影響範囲を最小化しながら段階的に進める
各ステップの影響範囲を小さく保つことで、問題発生時の修正コストを抑えられ、PRの変更量も少なくなりレビューの負担を減らせます。また各ステップを独立してリリース可能な単位とすることで、他案件の進行をブロックしません。
以上のメリットを意識しながら以下のステップで進める計画を立てました(当初は4ステップ、結果として5ステップになりました)。
| ステップ | 内容 |
|---|---|
| Step1 | Objective-Cレガシー型への依存を剥がす |
| Step2 | 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する |
| Step3 | MallHomeViewController全体にViewModel/UseCaseを導入する |
| Step3-ex | Step3完了後にバグが発覚し、命名整理とユニットテストを追加 |
| Step4 | HomeViewControllerにViewModel/UseCaseを導入する |
ステップを設計する上でのポイントを3点紹介します。
Step1を最初に行った理由
MallHomeViewControllerにはObjective-Cのレガシーな型への依存がありました。MVVM化を先に進めると、ViewModel/UseCaseはObjCの型を扱う設計になります。その後ObjC依存を除去すると、ViewModel/UseCaseの設計変更も必要になり手戻りが発生します。そのため、MVVM化の前段階として依存の除去を最初のステップとしました。
MallHomeViewControllerから先に着手した理由
タブの中身を管理している MallHomeViewController は、着手開始から間もなく後続案件の改修が入る予定でした。そのため、それより前にMVVM化を完遂させることを優先しました。
Step2とStep3を分けた理由
ホーム画面では複数のAPIを呼び出しており、最初から全APIを対象とするとMVVM化の影響範囲が大きくなりすぎます。まず独立性の高い一部のAPIに絞ってViewModel/UseCaseを導入することで、アーキテクチャの全体像を小さな変更で確認でき、問題が発生した際の修正コストも抑えられます。
方針2: 段階的に責務を分離する
UseCase → ViewModel → ViewControllerの順で責務を分離していき、最終的に以下の構成を目指しました。当時のアーキテクチャガイドラインではUseCaseの採用が定められていました。またAPIリクエスト・ログ送信・ビジネスロジックが複合的に絡むホーム画面の規模感においても、ViewModelの肥大化を防ぐうえで適切な設計判断でした。

ここで紹介している大まかな全体方針は、以前チームメンバーのなんしーさんが行った商品詳細画面のリアーキテクチャにおける進め方を参考にしています。
Step1: Objective-Cレガシー型への依存を剥がす
MallHomeViewControllerでは、商品情報を表示する部分がObjective-Cで書かれたレガシーな型に依存しており、APIレスポンスからレガシーな型へ変換する不要な依存がありました。そのため、最初のステップはMVVM化でなく不要な依存の除去から始めました。
以下の3段階で依存を剥がしました。
- 商品の情報表示において必要な情報を持つUIModelを作成
- APIレスポンスをそのUIModelに変換するTranslatorを作成
- Translatorは外部APIのレスポンス型をUIModelの型に変換する責務を持つ
- 外部APIの型定義が変更されてもViewModelやVCへ直接影響しない構造になる
- レガシーな型を使わない新しいセルを実装し、移行
最終的にMallHomeViewControllerからObjective-Cレガシー型への依存を完全に除去しました。
Step2: 最も独立性の高いAPIを対象に、ViewModel/UseCaseを部分導入する
Step1でクリーンな基盤ができたため、いよいよMVVM化に着手します。設計計画で「最も独立性が高い」と判断した世代別ランキングモジュールから始めました。
世代別ランキングモジュールとは、ユーザーが世代(~10代、20代など)を選択すると、その世代の人気アイテムがランキング形式で表示されるモジュールです。
ヘッダーの世代選択ボタンをタップして切り替えると、対応するランキングが再取得・再表示されます。

以下の特徴があったため、ホーム画面のMVVM化における最初のステップとして工数がかからず、アーキテクチャの全体像を実装しながら理解できる最適な題材と判断し、着手しました。
- 世代別ランキング専用の独立したAPIを持つ
- ユーザーが世代を選択したときだけ更新される
- 他のモジュールの更新と独立して動作する
小さく始めることの重要性
Step2は全部で7つのPRを作成しました。UseCase作成→UIModel作成→ViewModel作成→ViewControllerからUseCase/ViewModelへ処理を移動する流れで修正を加えていきました。
巨大なViewControllerを一気に書き換えようとすると、変更が大きくなりすぎてレビューが困難になり、バグ混入リスクも高まります。Step2でOpenした7つのPRのほとんどが100行未満のコード追加に収まっており、レビューでの指摘もほとんどなくスムーズにマージできました。
また、Step2を通してPRの分割方法や変更を加えるレイヤーの順番が明確になり、次のステップであるモジュール更新全体のリアーキテクチャへの自信がつきました。大規模なリファクタリングに着手する際は、最も独立性の高い部分から始めることで、レビューでの問題検知やバグ混入の防止に直結します。最初の小さなステップを通じてPRの分割方法や変更を加えるレイヤーの順番を把握しておくと、後続の大きなステップをより自信を持って進められます。
Step3: MallHomeViewController全体にViewModel/UseCaseを導入する
ホーム画面では、世代別ランキングモジュールの取得API以外に合計4つのAPIを並行して呼び出しています。Step3ではそれらの主要APIを呼び出している部分すべてにViewModel/UseCaseを導入しました。Step3はStep2のようにスムーズには行かず、いくつかの問題に直面しました。代表的な問題を紹介します。
問題1. Swift Concurrencyへの移行
当時のMallHomeViewControllerでは、一部分のAPI呼び出しにBrightFuturesを使っていました。このライブラリは2022年にEOLとなっており、チーム内でも新規実装では非推奨としていたため、このタイミングでSwift Concurrencyへ移行しました。
Swift Concurrency対応に関してもこのときが初めての経験で、その中で色々と学びがありました。
並行処理によるビュー表示時の表示順担保
クロージャベースのコードでは、複数のモジュール取得APIを直列で呼び出しており、すべてのレスポンスが揃ってから一括で描画していました。Swift Concurrencyへ移行して並行呼び出しにしたことで、どのAPIレスポンスが先に返ってくるかが不定になります。レスポンスを受け取った順にUIModelを積んでいく実装のままでは表示順が変わってしまいますが、実装当初はこの問題に気づいていませんでした。
UIModelの配列に常に決まった順序で格納する実装に修正することで解決しました。すべてのAPIレスポンスが揃ってから正しい順序でまとめて描画するという基本的な流れは変わらず、並行取得による速度改善と表示順の保証を両立しています。
withCheckedThrowingContinuationにキャンセルが伝播しなかった
特定のAPI呼び出しにはタイムアウト処理が必要でした。withThrowingTaskGroupを使い、データ取得タスクと一定時間後にタイムアウトエラーを投げるタスクを並走させました。どちらかが完了したらgroup.cancelAll()でもう一方をキャンセルする実装を採用していました。
しかし実際にはキャンセルが正しく機能していませんでした。通信が切断された状態でリロードを繰り返すと、タイムアウトが発生してgroup.cancelAll()が呼び出されているにもかかわらず、ローディングが永遠に続く不具合が発生していました。
原因は、コールバック型のサードパーティSDKをwithCheckedThrowingContinuationでブリッジしていた部分にありました。このSDKは通信切断時にコールバックを呼び出さない場合があります。タスクグループのキャンセルはwithCheckedThrowingContinuation内には自動で伝播しません。コールバックが呼ばれない限り、continuationは解決されないままとなります。
// 修正前: キャンセルが continuation に伝播しない func fetchData() async throws -> Response { try await withCheckedThrowingContinuation { continuation in legacySDK.fetch { result in // 通信切断時はここが呼ばれない場合がある // group.cancelAll() されても continuation は resolve されないまま continuation.resume(with: result) } } } // 修正後: withTaskCancellationHandler を追加し、キャンセル時に continuation を resolve する func fetchData() async throws -> Response { let holder = ContinuationHolder() return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in holder.continuation = continuation legacySDK.fetch { result in holder.continuation?.resume(with: result) } } } onCancel: { // タスクがキャンセルされたとき、onCancel でエラーを投げて continuation を解決する holder.continuation?.resume(throwing: APIError.cancelled) } }
対応方法はwithTaskCancellationHandlerを追加することでした。タスクがキャンセルされるとonCancelクロージャが呼ばれ、そこでcontinuationにエラーを投げることで、コールバックが返ってこない状態でもタスクを終了できます。continuationへの参照をclassで保持しているのは、onCancelクロージャが別コンテキストで実行されるためです。varではSwift Concurrencyの警告が出ます。
withCheckedThrowingContinuationはコールバック型APIをasync/awaitに変換する手段として有効ですが、タスクキャンセルは自動では伝播しません。キャンセルに対応させるにはwithTaskCancellationHandlerと組み合わせて、onCancel時に明示的にcontinuationを解決する必要があります。
問題2. モジュール構築メソッドの整理
Step3の終盤では、ViewControllerに置かれていた500行弱の巨大なモジュール構築メソッドを整理しました。
このメソッドには2つの責務が混在していました。
- UICollectionViewに表示するデータソースの作成(VC側の責務)
- 商品押下時のログ送信に必要なモジュール内位置情報の計算(VM側の責務)
後者をViewModelへ移動し、各モジュールの同一性比較を可能な構造とすることで、位置情報を適切に取得できるようにしました。
やること自体は一文で書けるようにとてもシンプルなものです。実装当時の自分の認識も同様で、この整理に関してはスムーズに進み、そのままStep3をリリースしました。
しかし、ここで今回のリアーキテクチャにおける最大の壁にぶつかってしまいます。
Step3完了後にログに関するバグが発覚
Step3のリリース後、モジュールを管理しているチームから「カルーセルバナーのタップログで、バナーの位置が正常に送られていない」という問い合わせが届きました。
調査の結果、カルーセルバナーのタップ時のログに含まれる「バナーの位置」として、ホーム画面全体におけるセクションの表示位置を誤って送信していたことが判明しました。本来送るべき値はカルーセル内のバナーの位置(何枚目のバナーか)でした。
バグを引き起こした原因
モジュール/セクション/インデックスなどの位置に関する命名の曖昧さ
ホーム画面は複数のコンテンツを縦に並べた構成です。「画面上の表示順(セクション位置)」と「各コンテンツ内の位置(インデックス)」という2種類の"位置"が存在しますが、コード上でこれらを区別する命名が不明確でした。
APIから取得したレスポンス名/ログ送信用パラメーター名/内部で使用している変数名のそれぞれの使い分けが曖昧なまま実装を積み重ねており、コードを読む際に混同しやすい状態でした。
ログの値の正しさをテストで検証できていなかった
「ログが送信されること」は手動確認で検証していましたが、「送信されたログの値の正しさ」まで検証できていませんでした。
当時はユニットテストが整備されていなかったため、コードレビューだけでは防ぎきれませんでした。ユニットテストがあれば、このバグはリリース前に検知できたはずです。
これらを検知できなかった背景として、Swift Concurrency対応での想定外の工数による焦りと、ログの重要度を甘く見積もっていたことが挙げられます。
Step3の終盤のPRはStep2とは打って変わって500行を超える大きなPRになってしまい、レビュアにも大きな負担をかけてしまいました。「小さく分割して進める」という当初の方針を貫けなかった点も反省の1つです。
Step3-ex: 命名整理とユニットテストの追加
バグを迅速に修正した後、Step3の延長として命名の整理とユニットテストを追加しました。
命名の整理
Step3でのバグ原因の1つが「ログ送信コードの読みにくさ」にあったため、まず命名を整理してからテストを書くという順序を選びました。
- モジュール・セクション命名の統一
- UICollectionView上の概念の呼び方と変数の型を整理し、「モジュール」と「セクション」の使い分けルールを明確にしました。
- ログ送信の位置情報に関する命名統一
- セクションの表示位置とセクション内の商品位置を表す変数名を、それぞれ明確に区別できる名前に統一しました。
ユニットテストの追加
バグを引き起こしてしまったログ送信時のセクション位置に関するテストをはじめとして、モジュールの取得、性別変更、画面遷移、ライフサイクルイベントなど多数のシナリオをカバーしました。Step2, Step3でUseCaseをプロトコルでDIできる構造になっていたため、Mockを使ったテストが書けるようになっています。
ユニットテストを新規で書いていくのも初めての経験だったため、テストに関する知識が豊富なチームメンバーにモブレビューを行ってもらいました。
命名整理とテスト追加を終えた時点で、MallHomeViewModelのテストカバレッジは38%から99%に向上しました。
不確かさに気づいた時点でテストを書く
Step3では「アーキテクチャを整備してからテストを書けばいい」という考えからバグを引き起こしてしまい、その考えの危うさを実感しました。バグや不確かさに気づいたタイミングでテストを書くことで、結果的に次のステップを安心して進める力になります。
Step4: HomeViewControllerにViewModel/UseCaseを導入する
最終ステップのStep4ではホーム画面全体を管理しているHomeViewControllerのリアーキテクチャを行いました。このステップでは、Step3までの失敗と学びを活かしてTDD(テスト駆動開発)を採用しました。また、Step3でのPR分割の粒度ミスを踏まえ、レビューしやすい粒度でPRを作成しレビュアへの負担も考慮したPR戦略を取りました。
TDDによる設計の共有
Step4で特筆すべきは、UseCase/ViewModelのテストケースをProtocol/実装より先に作成したことです。UseCase/ViewModelのテスト雛形作成 → テストケースの作成 → UseCase/ViewModelとProtocolの作成 → 実装、という順番で進めました。
このTDDアプローチが特に威力を発揮したのが、ログ送信周りの仕様整理でした。
HomeViewControllerのログ送信ロジックは複雑で、起動経路(通常起動・プッシュ通知・Deeplink)やタブ切り替えに応じてどのログをどのタイミングで送るかが変わります。また、同じ画面遷移でも複数のライフサイクルイベントが連続して発火するため、ログの二重送信を防止する制御も必要です。このような仕様では実装者ごとに解釈が分かれやすく、Step3と同じ轍を踏む可能性もありました。
そこで実装に先立ち、起動経路ごとのログ送信フローをドキュメントとして整理し、チームで仕様を合意した上でテストケースを設計するというプロセスを踏みました。ドキュメントにはどの動線でどのログが何回送られるべきかを網羅的に記述し、それをそのままテストの仕様として共有しました。
テスト設計において重要な方針として、内部のフラグ状態ではなくユーザーの動線単位でテストを記述することを採用しました。例えば以下のようなシナリオをそのままテスト名として記述しています。
- 通常のアプリ起動でホーム画面を表示したとき、ログが1度だけ送信されること
- プッシュ通知でアプリを起動したとき、特定のログは送信しないこと
- Deeplinkでホーム画面に遷移したとき、viewWillAppearでのログ送信はスキップすること
「どの動線で何が起きるべきか」という形でテストを書くことで、テストが仕様書として機能するようになります。内部実装がリファクタリングで変わっても、動線ベースのテストはそのまま維持できるため、保守性も高まりました。
テストを先に書くことで、「このUseCaseは何をすべきか」をチームで議論しながら設計を進めることができました。Step3でロジックの漏れがバグにつながったという反省が、ここで活きています。
オンボーディング周りの状態管理
HomeViewControllerはオンボーディング(初回起動時の案内フロー)周りの状態管理も複雑です。
リファクタリング前の課題
初めてZOZOTOWNアプリをインストールしたユーザーは、ホーム画面が表示されるまでに複数の案内画面を経由します。
問題は、この一連のフローを管理するために5つ以上のBoolフラグが複数のファイルにまたがって散在していたことでした。例えば「ログイン画面の表示が完了したか」「プッシュ通知許諾を表示したか」「訴求バナーの表示が必要か」といったフラグが各所に分散していました。それらを組み合わせた条件分岐によって次の表示内容が決まる構造になっていました。これにより、「今どのフラグがどの状態のとき何が起きるのか」を把握するだけでもかなりのコストがかかっていました。
このような複雑さが原因の1つとなり、オンボーディングに関する不具合が発生したこともありました。
ステートマシンによる再設計
Step4ではこのオンボーディングフローをステートマシンとして再設計しました。
オンボーディングは以下の4つの状態(State)と、それぞれを遷移させるイベント(Event)によってモデル化されます。

ViewModelはこの状態を購読し、状態に応じてどの画面を表示するかを宣言的に記述します。
この設計により、「現在のフローのどこにいるか」が状態として一点に集約され、遷移のトリガーとなるイベントも明示的になりました。それまでのフラグの組み合わせによる暗黙的な状態管理から脱却し、コードを読むだけでオンボーディングフローの全体像が把握できるようになりました。
また、「どのイベントでどの状態に遷移するか」をテストで直接検証できるようになりました。将来的にオンボーディングのステップが追加・変更されても、状態遷移の定義を修正するだけで対応できます。
こうして、約1年5か月にわたるホーム画面リアーキテクチャが完了しました。Step4に関しては、ホーム画面に起因する障害や問い合わせは発生しませんでした。
Step3で体験したバグと、その後段階的に整備したテストが、実際の品質保証として機能している結果だと感じています。
ホーム画面リアーキテクチャ完了後、後続案件でホーム画面を触った他のメンバーから「実装が楽になった」というフィードバックをもらいました。これは、責務が適切に分割されたことで改修の影響範囲が把握しやすくなったことを示しています。
また、ログ周りの修正が入ったときも「テストで挙動が担保できるようになった」という声がありました。Step3で体験したバグに対して、Step3-ex以降で構築したテストが実際に機能している瞬間でした。

長期リファクタリングを進める上でのポイント
今回のリアーキテクチャを通しての学びやポイントは各ステップで紹介しましたが、全体を通じて特に重要だと感じた点として、設計ドキュメントの継続的な整備を挙げます。
設計計画(段階的なステップ計画、インタフェース設計)を文書化しておくことは、長期にわたるプロジェクトをチームで共有する土台になります。「なぜこの設計にしたか」が残っていることで、後続のステップでも一貫した判断ができます。また、AIを活用したコーディングが一般的になった現在では、設計方針が文書化されていることはより一層重要です。AIへの指示の精度が上がるだけでなく、生成されたコードがプロジェクトの設計意図と一致しているかの検証にも役立ちます。
おわりに
このリアーキテクチャを振り返ると、最初は「アーキテクチャについての理解を深めたい」という動機から始まりました。しかし実際には、「テストの重要性」「段階的な変更の価値」「失敗を次に活かすこと」という、より本質的なことを学んだプロジェクトになりました。
特に、Step3後のバグ発覚→Step3-exのテスト追加→Step4でのTDD採用でバグ0を達成できたことは、自分の成長を強く実感できたポイントでした。
ZOZOTOWN iOSアプリのリアーキテクチャはまだ続いています。このホーム画面での経験をチームの資産として積み上げながら、より良いアプリを作り続けていきたいと思います。
ZOZOでは、一緒にサービスを作り上げてくれる方を募集中です。ご興味のある方は、以下のリンクからぜひご応募ください。