はじめに
こんにちは!技術本部Sansan Engineering Unit Mobile Applicationグループに所属しているiOSエンジニアのヤズジュ夢佐です。
Sansan iOSアプリでは、5年以上前からマルチモジュール化の取り組みが始まっています。
しかし、まだ過半数のファイルが1つのSansan Main Targetに詰まっています。この巨大なSansan Main Targetのビルドは重たく、Full Buildには2分程度かかります。コンパイル時間の長さによって、開発体験は悪化していました。部分的に進んでいるマルチモジュール化も、責務が明確ではないモジュールが複数存在しており、どのコードがどのモジュールに配置すべきか分かりにくい状態でした。
本記事では、これらの課題に対して取り組んだ施策を紹介します。画面単位で独立したFeatureモジュールの導入、モジュール再設計によってFeatureモジュールの依存物を最適化し、Buildを高速化するところまで、どのようなアプローチを取ったか解説していきます。
前提: マルチモジュール化で得られる恩恵は何か
マルチモジュール化の話に入る前に、まずはマルチモジュール化に期待する恩恵を整理してみました。期待するものが明確でないと、モジュール構成をどのように組んでいくかの軸が決まらない上に、マルチモジュール化の取り組み自体の優先度が下がり、先延ばしされてしまいます。
1: 小さな範囲でBuildを素早く実行でき、開発生産性が向上する
開発中の機能に閉じた範囲でBuildを実行でき、アプリ全体をBuildするよりもはるかに短い時間でBuildを完了できるようになります。コンパイルを素早く終えられることで、コードの変更が有効であることを瞬時に確認できます。SwiftUI Previewも高速に動作させることが可能になり、UI開発効率も向上します。
2: Incremental Buildの高速化を期待できる
すべてのコードベースが1つのモジュールに詰まっている場合、一部を変更した後のBuildでプロジェクト全体が再コンパイルされてしまいます。しかし、マルチモジュール化が進んでいれば、変更のないモジュールについてはキャッシュを利用でき、Incremental Buildの再コンパイルの範囲を限定できます。
3: モジュール境界による責務の分離・設計の明確化
モジュール分割は物理的なバイナリ境界を設けるため、コードレベルの依存関係を制限できます。Swiftのアクセスレベルのデフォルトはinternalです。コードのアクセス可能な範囲は、明示的にopenやpublicに指定しない限り、モジュールに閉じた状態にできます。すべてのコードが1つのモジュールに詰まったモノリス構造では、デフォルトでコードのアクセス可能な範囲はプロジェクト全体になるため、意図せず誤った依存関係が生まれやすいです。
他にも、モジュール単位でテストを書けることによってテストパフォーマンスが向上したりするなど、マルチモジュール構成の恩恵はいくつもあります。これらの恩恵を踏まえた上で、Featureモジュール導入についての話に移っていきます。
Featureモジュールの導入
Sansanでは、KMPとFluxアーキテクチャを組み合わせた設計の導入がされています。
この新しい設計で作られた機能は、すべてFluxViewという1つのモジュールに詰め込まれた状態でした。このFluxViewモジュールには、十数個の画面の実装が存在していましたが、それぞれの画面の実装同士は依存関係がなく、同じモジュールに存在させる必要がありませんでした。

そのため、FluxViewモジュールに存在していた実装を、画面単位でFeatureモジュールとして切り分けることにしました。

これによって、次のような恩恵を得ることができます。
- 各FeatureモジュールにFeature独自の実装が閉じた状態になり、Feature間のCode Isolationが促進され、機能開発効率の向上や、意図しない副作用による不具合の発生率の低下などを期待できる
- 1つのモジュールあたりに含まれるコードや依存物が最小化され、より高いBuildパフォーマンスを得られる
このFeatureモジュール構成の導入によって、KMPとFluxアーキテクチャで実装された比較的新しい設計の機能は、すべてFeatureモジュールとして切り分けられた状態を実現できました。
課題: Featureモジュールのビルドが重たい
Featureモジュール導入直後の各FeatureモジュールのFull Buildは平均40.2秒でした。これはSansan Main Targetが2分程度かかるのに比較すると3倍近い速さです。コンパイルが素早く完了するため、コードの編集作業はより高速に進められるようになりました。そして何より、SwiftUI Previewの立ち上げのパフォーマンスが大幅に向上したことが、UI開発効率を大きく改善しました。
しかし、この時のFeatureモジュールには、本来不要な依存物が多く含まれており、無駄にBuildが遅延している状態でした。ここからは、FeatureモジュールのBuild高速化を実現するために行った取り組みについて話していきます。
Featureモジュールの高速化準備: 現状を分析する
Buildが遅い時は闇雲に対策を打つのではなく、まず何を理由としてどの程度Buildに時間がかかっているかを分析するのが効果的です。Xcode Build Timelineを使うことで、Build中にどのモジュール・ライブラリ・ファイルがどれだけの時間を消費しているかをタイムライン形式で可視化できます。
詳しい活用方法は、こちらのWWDCの動画が参考になります。
この機能によって、導入当時のFeatureモジュールのBuildを分析した結果の一例がこちらです。

左端が0秒地点、右端がBuild完了時の37秒あたりの地点となっており、左から右へ、Build中に何のコンパイルに対して時間を使っているのか可視化できます。
Realmによる遅延が大きい
この画像では、左の方の3秒地点から13秒地点あたりに広く水色のエリアが広がっています。その右隣の13秒地点から18秒地点まで広がっている黄色のエリアも大きいですね。
この水色のエリアと黄色のエリアは、すべてRealmのコンパイルです。つまり、Realmのためだけにこれだけのコンパイル時間がかかっている。逆に言えば、Realmを外すだけで、15秒近くのコンパイル時間を削減できるわけです。
UIKit実装による遅延が大きい
さらに右の方も見てみましょう。25秒地点から30秒地点まで、緑色のエリアが広がっています。さらに、その右隣の30秒地点から36秒地点まで、ピンクのエリアが広がっています。これらのほとんどはxibファイル、つまりUIKitの実装にかかっているコンパイル時間です。
Featureを超えて共有される汎用的なUIKitの実装が入ったモジュールが、この緑色とピンク色のエリアを埋めています。FeatureモジュールにはUIKitで実装されているものもSwiftUIで実装されているものもあります。SwiftUIで実装されているFeatureでは、このUIKitの汎用実装に依存する必要がありません。
まとめると、Featureモジュールは平均でおよそ40秒をBuildに費やしますが、そのうちの15秒程度は不要なRealmへの依存、10秒程度はUIKitへの依存によって生まれていることが分かりました。
このあとは、Realm、UIKit実装を依存物から外すためにやったことを話していきます。
Featureモジュール高速化施策1: Realmの依存を解消する
FeatureモジュールからRealmの依存を外すことは簡単ではありませんでした。理由は、RealmのObjectが、Realmとは関係ない処理でも乱用されていたためです。
課題: Realm ObjectがRealm以外の処理で乱用されている
Data LayerでのAPI decode処理や、Domain Layerでのデータの受け渡しなどで、Realm Objectがそのまま使われていました。そのため、Data LayerやDomain Layerが必然的にRealmに依存することになり、連鎖してFeatureモジュールもRealmの依存を持たざるを得ない状況でした。
実際のコードに近い、少し単純化したサンプルコードを用意しました。
public final class CompanyRealmObject: Object, Decodable, @unchecked Sendable { @objc public dynamic var id = "" @objc public dynamic var name: String? @objc public dynamic var country: String? override public class func primaryKey() -> String? { "id" } }
継承されているObjectは、RealmのObjectであることを示します。このRealmのObjectがDecodableに準拠し、API responseのdecodeにも乱用されていました。
対処1: RealmのObjectとRealm以外の処理向けの構造体を分ける
Realm処理専用のRealm Objectと、それ以外の処理に向けて作られた構造体を分けて用意します。既存のRealm Objectと全く同じproperty構成を持つ、immutableでsendableなstructを用意しました。Realmの処理に関係のない箇所で使われているRealm Objectを、このような単純なstructに置き換えます。
public struct Company: Decodable, Sendable, Equatable { public let id: String public let name: String? public let country: String? public init(id: String, name: String?, country: String?) { self.id = id self.name = name self.country = country } }
対処2: RealmのObjectはRealm専用のモジュールに閉じた状態にする
すべてのRealm Objectは、Realmに関連する処理を行うために用意されたRealmTargetというモジュールを用意し、そこに移しました。Realm Objectは、Realm DatabaseのCRUD処理のためだけに存在する形になります。
RealmTargetモジュールの外部からは、Realmの実態が隠れた形のRepository protocolを経由して、Realm処理を呼び出します。
// SansanCoreモジュール(RealmTargetに依存しない) public protocol UserRepository: Sendable { func fetchUser(id: String) async throws -> User func save(_ user: User) async throws } // RealmTargetモジュール(Realm Objectはここにしか存在しない) actor UserDataStore: UserRepository { func fetchUser(id: String) async throws -> User { let realm = try await Realm.open(configuration: configuration) guard let object = realm.object(ofType: UserRealmObject.self, forPrimaryKey: id) else { throw UserError.notFound } return object.toEntity() // Realm Object → structに変換してから返す } func save(_ user: User) async throws { let realm = try await Realm.open(configuration: configuration) try await realm.asyncWrite { realm.add(UserRealmObject(from: user), update: .modified) } } } // Featureモジュール(Realmをimportしない) final class UserInteractor { private let userRepository: UserRepository // Realm ObjectもRealmも知らない init(userRepository: UserRepository) { self.userRepository = userRepository } func fetchUser(id: String) async throws -> User { try await userRepository.fetchUser(id: id) } }
これによって、Realm ObjectはRealmTargetモジュール以外のどこからも使われない状態になりました。
Realm依存解消後の高速化効果計測
Realmに依存するのは、RealmTargetモジュールと、Sansan Main Targetのみになり、ほぼすべてのモジュールがRealmへの依存から解放されます。それによって、Featureモジュールやその他のモジュールのBuildが15秒前後高速化されました。
Build時間が24秒程度だった、単純な構造体だけを基本的に含むEntityというモジュールは、Realm依存解消後は7秒程度になりました。その高速化効果は約71%と、すさまじいインパクトです。
Featureモジュールの平均Build時間も、Realmに依存している状態では40秒程度だったのが、Realm依存解消後は24秒程度まで高速化されています。Xcode Build Timelineを見ても、Realmが占めていた部分がごっそりなくなっているのがわかります。

さて、残った大きなボトルネックはUIKitの実装です。
Featureモジュール高速化施策2: UIKit実装への依存を解消する
不要なUIKit実装の依存を剥がすには、モジュール構成の大きな改革が必要でした。改善前に抱えていた課題は大きく二点あります。
課題1: 雑多に責務が詰まったモジュールが存在する
SansanComponentsという名前の、複数の責務が雑多に詰まって肥大化したモジュールが存在していました。このモジュールに詰まっていた責務は主に次の通りです。
- VIPERアーキテクチャの基盤クラス
- 一部の機能のUseCase
- 一部のUIKitの汎用実装
- 一部のSwiftUIの汎用実装
- 一部の機能のPresentationModel (UIに表示する情報を構造化してまとめたもの)
- Extension(Color+Extension、DateFormatter+Extension、UINavigationController+Extension、UIStackView+Extension、UITextView+Extension、などなど)
- Utility系のクラス
- Tracker
- アプリのほぼすべての文字列リソース
- アプリの大部分の画像リソース
これ以外にも、細かいものをあげればたくさんあります。どんな機能を実装するとしてもSansanComponentsに依存せざるを得ない、そんな状況でした。そして、このSansanComponentsやその依存物に含まれる実装によって、Buildは当然遅延します。Featureモジュールが抱えていた、不要なUIKitの実装に伴うBuild遅延も、SansanComponentsとその依存物が引き起こしています。
SansanComponentsを責務に応じて適切に解体することが、Featureモジュールの依存物を最適化する上で避けられない課題でした。
課題2: 複数のモジュールに散らばった汎用的なUI実装
Sansanでは、汎用的なUIコンポーネントであるボタン・ラベル・テキストフィールド・ナビゲーションバーなどを定義するDesignKitがFigmaに用意されています。そのDesignKitに対応する実装や、Extension、Utility、基盤クラスなどを含めた汎用的なUI実装が入ったモジュールがあります。しかし、その汎用実装を扱うモジュールが複数存在している状態であり、それぞれのモジュールの責務境界も曖昧でした。存在していたUI関連のモジュールは次の通りです。
- SansanUICore: 色とフォント
- SansanSwiftUI: SwiftUIの汎用的な実装
- SansanUIComponent: UIKitの汎用的な実装
- SansanComponents: UIKit・SwiftUIの汎用的な実装、画像、文字列、その他
- ViewUtil: UIKitの汎用的な実装
依存グラフを最適化するには、UI関連のモジュール構成を再設計し、それぞれが明確な責務を持って存在する状態に作り変える必要がありました。
対処: UI関連のモジュール構成を再設計する
再設計後のUI関連のモジュール構成は次の通りです。
- SansanUICore: 色とフォント
- SwiftUIFoundation: SwiftUIの汎用的な実装
- UIKitFoundation: UIKitの汎用的な実装
- CommonImage: 複数の機能で共有して使う画像リソース
- SansanString: アプリ全体の文字列リソース
- 特定機能群向けのUIComponent(名刺UI、共有機能UI、画像切り取りUIなど)
まず、モジュールの命名は名前からモジュールの責務がある程度明らかになるように改善しました。そして、改善前はSansanComponentsに詰まっていた画像リソースや文字列リソースは、それぞれ専用のモジュールに切り出しました。DesignKitを含む汎用的な実装や、基盤クラス、Utility実装などは、すべてSwiftUIFoundationとUIKitFoundationにまとめています。
ただ、複数のFeatureで扱うUIの実装をすべてSwiftUIFoundationやUIKitFoundationにまとめていくと、これらが肥大化する恐れがあります。そのため、広く使われるDesignKitや基盤クラスとは別のレイヤーで、特定機能群に向けた実装は切り出しました。このような構成にすることによって、特定機能群に向けて作られた実装を不要とするFeatureが、依存物にそれらを含めずに済みます。
全体の依存関係は次のようになっています。

UIKit実装依存解消後の高速化効果計測
UI関連のモジュール構成を再設計した後の、Featureモジュールを含めた全体の構成はこのようになりました。

これによって、FeatureモジュールのFull Build平均時間は、Realm依存解消後の24.3秒から18.9秒へとさらに削減されました。Featureモジュール導入直後の40.2秒から比較すると、約53%の削減効果です。
SwiftUIで実装されており、UIKitの依存を必要としないFeatureモジュールについては、導入当初40秒程度のBuild時間だったところから14秒程度まで高速化されたものもありました。UIKitの不要な依存が丸ごと外れたことによって、コンパイル対象が大幅に削減されています。約65%の高速化効果です。大きいですね。実際にUIKit実装への依存が解消されたFeatureモジュールのXcode Build Timelineを見ると、UIKit関連のコンパイルに費やしていた時間がごっそり無くなっているのがわかります。

今後の展望
依存物は最適化され、コンパイルは大幅に高速化し、開発体験は大きく改善しました。しかし、まだまだ改善の余地は残されています。今後取り組んでいきたいことについて、紹介します。
1: Featureのアプリ化
現在はFeatureモジュールに閉じてBuildはできますが、Runはできません。Buildが小さな範囲で素早く回せることによって、コードの編集やその確認は素早くできるようになり、SwiftUI Previewの立ち上げも非常に速い状態を実現できました。しかし、実際にSimulatorや実機で動作確認するには、今でもSansan Main Targetとその依存物全体のコンパイルが必要になります。
Featureを単体でアプリとして立ち上げられる仕組みを構築すれば、素早く動作を確かめることができます。AI Agentを使いMobile MCPを活用して行う動作確認も、高速化を見込めます。
このFeature単体でアプリを立ち上げられるようにするアプローチは国内外の多くの企業で実践されています。
例えば、Airbnbでは「Dev App」と呼ばれる仕組みを整備しており、CLIコマンド1つで対象Featureモジュールとその依存物のみをホストするiOSアプリを生成できる仕組みが導入されています。
iOS engineers create Dev Apps using a robust and user-friendly command line interface. (...) The Dev App command line tool generates a container iOS application to host the feature and opens a generated Xcode workspace. (...) A Dev App allows a product developer to iterate on their feature's UI and much of its business logic while building a fraction of the overall Airbnb application. https://medium.com/airbnb-engineering/designing-for-productivity-in-a-large-scale-ios-application-9376a430a0bf
クックパッドでは、「Sandboxアプリ」としてFeature Moduleを単体のアプリとして動作させる仕組みを構築しています。
iOSアプリの開発途中で画面のレイアウトなど僅かな変更を確認したい場合、最も確実な方法はアプリをビルドして該当の画面まで手動で遷移して確認する方法です。この方法は特別なセットアップが必要なく単純明快な確認方法ですが、効率の面で問題があります。例えば一番の問題として挙げられるのがビルド時間の長さという問題です。(...)Feature Moduleを単体のアプリとして動作可能にしたのがSandboxアプリです。(...) 同じ少量の差分のビルドにかかる時間の計測結果を比較すると、本体アプリのビルドでは平均約20秒かかるのに対しSandboxアプリのビルドでは約5秒で済みます。(...)クックパッドのように機能の多いアプリでは、アプリトップから開発している画面にたどり着くまでがやや面倒な場合があります(...)そこでSandboxアプリを利用すると、確認したい画面を一番最初に、好みの条件で起動することが出来ます。 https://techlife.cookpad.com/entry/2020/08/05/090000
2: Sansan Main Targetの解体
マルチモジュール化が進んでいるものの、まだコードの多くはSansan Main Targetに残っています。file数単位で計測すると、過半数が残っている状態です。Sansan Main Targetを解体することで、Incremental Buildの効率化、Code Isolationの促進、Test Performanceの向上など、数々の恩恵を期待できます。
本記事で紹介したモジュール再設計の取り組みの前は、Sansan iOSのモジュール構成は責務境界が曖昧な部分がありました。しかし、本記事の取り組みによってモジュール間の責務境界も明確に整理された状態になってきており、Sansan Main Targetの解体も進めやすくなっています。適切に責務を切り分けつつ、Sansan Main Targetの実装もFeature単位でどんどん分割していきたいです。
まとめ
本記事では、Featureモジュールの導入や、モジュール構成の再設計による導入後のBuild高速化などの取り組みについて紹介しました。FeatureモジュールのFull Buildにかかる時間は約53%削減され、SwiftUI Previewの立ち上げも大幅に速くなり、開発体験は大きく向上しました。
マルチモジュール化や、モジュール構成の改善に取り組んでいる方の参考になれば幸いです。
Sansan技術本部ではカジュアル面談を実施しています
Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。