メルペイのスケーラビリティを支えるマルチモジュール開発

こんにちは。メルペイiOSチームのyusuke_hです。

この記事は、Merpay Tech Openness Month 2021 の6日目の記事です。

「メルペイのスケーラビリティを支えるマルチモジュール開発」というテーマで、メルペイiOSチームで現在採用している開発スタイル及びその経緯、現状について紹介していきます。

背景

モジュールの肥大化

メルペイではリリース以来、様々な機能を開発してきました。
機能が追加されていく度に、モジュールは段々と大きくなっていき、様々な問題を生むようになりました。まず初めにビルド時間の増加です。そしてモジュール内にどのような機能があるかが分かりづらくなるという不透明性の強まりがありました。更に、複数の人が同じモジュールに対して変更を加える機会が多くなり、コンフリクトが発生しやすくなりました。

ビルド時間の増加

今までは開発用のアプリは全てのモジュールを統合した1つのアプリを使って開発を行っていました。機能が多くなるにつれて、ソースコードの量もどんどん増えていき、ビルド時間はそれに伴い長くなっていきました。メルペイでは特にフリマアプリ「メルカリ」のメルペイタブのトップ画面である、ダッシュボード周りのモジュールのソースコードが多くなってしまっていて、純粋に1モジュール単位でアプリを作成するだけではビルド時間の改善は見込めませんでした。

また、このビルド時間の問題を抱えている時期にXcode Previewsの導入を行いました。Xcode Previewsではコードによるビューの実装によって、そのビューのプレビューを見ることが出来るようになっています。これはコードで実装したビューをプレビューによって即座に見れるようになるため、細かいビューの修正などをプレビューを見るだけで修正できるようになり、開発効率の上昇に大きく貢献した機能です。しかしこのXcode Previewsでも、1つのモジュールが大きいとプレビューを見るためのビルドをするのに時間がかかってしまうという同様の問題を抱えていました。

これらの問題により、大きくなってしまったモジュールの細分化、そして新規機能を新モジュールとして定義するための仕組み作りによって、1モジュール当たりのビルド時間短縮による開発効率の改善の需要が高まっていきました。

機能の不透明性

ビルド時間に加えて、1つのモジュールに様々な機能が入っていたことにより、開発者がそのモジュール内にどういった機能があるのかを管理しきれないという問題も発生してきました。以下では実際にメルペイのモジュールとして存在していた、ダッシュボードのモジュールを例に取って説明します。

ダッシュボードのモジュールの機能

ダッシュボードには様々な機能があります。NFCはiD決済を行うために必要な機能のまとまりです。ここにはiD決済をするための機能はもちろん、iD決済をするにあたっての設定画面やチュートリアルのページなども含まれます。QRはQR決済を行うために必要な機能のまとまりです。NFCと同様に決済だけではなく、その周りの機能も含まれます。更にダッシュボードにはCouponの機能もあります。これはメルペイの画面でクーポンを表示したり、その表示するためのクーポンを取得する機能などがまとまっています。

ダッシュボードの機能はこれだけに留まらず、他にも履歴やパスコードなどの機能もあります。これらの機能間にはそこまで依存関係がないのにもかかわらず、1つのモジュールに入ってしまっていて、かつ「ダッシュボード」というモジュール名でまとまっているので開発者が機能を探したりそれを利用したりする時に不便が生じやすいです。

こうした観点からも機能を意味ごとに分けて、機能単位でモジュールを作成するという開発スタイルが現状のメルペイには合っていると考えられてきました。

コンフリクトの発生

メルペイの仕事は基本的にプロジェクト単位で行われます。各チームのエンジニア達はアサインされたプロジェクト内で仕事を行います。この体制では他のプロジェクトにいる自チームのエンジニアがどんなことをやっているか把握しにくいという問題があります。

これによってメルペイiOSチームでは、複数プロジェクト間で同一のモジュールを変更することによる、コンフリクトの発生が多々起こっていました。各プロジェクト毎に基本的にはfeatureブランチを切っているため、他のプロジェクトの人たちがmasterブランチに変更をマージした際に、とても大きなコンフリクトが発生することがしばしばありました。

メルペイの開発体制

このコンフリクト問題を解決するには、できるだけ複数プロジェクトで同一モジュールを変更するのを避けるべきであり、そこでまず思いつくのがプロジェクト毎にモジュールを作成するというものです。この提案を採用するには、やはり機能単位でモジュールを分割するのが理想的であると結論づけられました。

 マルチモジュール開発の採用

 既存モジュールの分割

マルチモジュール開発を採用するにあたって、まず初めに既存モジュールを機能毎に分割しました。上記で説明したダッシュボードモジュールを例にとって解説します。

前述の通り、ダッシュボードモジュールにはNFCやQRなどの様々な機能を持ち合わせていました。マルチモジュール開発では、これらの機能1つあたりにつき1つのモジュールを作成することを目指すので、ダッシュボードモジュールを機能毎にモジュール分割しました。これによりダッシュボードモジュールに加えて、新たにNFCモジュールやQRモジュールといったモジュールができました。この分割によって、メルペイのモジュール構成は機能単位になり、プロジェクト単位でモジュールを作成・管理するという体制を整えることができました。

ダッシュボードモジュールの分割

モジュール間は比較的疎結合でありましたが、完全に互いに独立しているわけではありません。その最たる例として、モジュール間の画面遷移が挙げられます。例えばiD決済(NFCモジュール)の際に、クーポン(Couponモジュール)を使用して決済することを考えると、この2つのモジュール間で画面遷移が発生することが考えられます。

このモジュール間連携に関しても、マルチモジュール開発に沿ったものになっていくべきだとチームで議論になりました。次節では実際にモジュール間連携をどのように行うようにしているのかを解説していきたいと思います。

モジュール間連携

マルチモジュール開発を採用したことにより、以前と比べてモジュール数は増加しました。そして、新機能を開発するごとにモジュールは新しく作られます。機能をもったモジュールのことをFeatureモジュールと呼んでいます。これらのFeatureモジュール間で直接的に参照をしてしまうと、依存関係が複雑になり管理しきれなくなってしまいます。例えばFeatureモジュール数が10個存在した場合、Featureモジュール間の直接参照の可能性として考えられる組み合わせは45個となります。これではどんどん新しいモジュールを作成していく開発スタイルでは、すぐに管理の限界が来てしまいます。また、複数のFeatureモジュールで使用したいコンポーネントなどは、各Featureモジュールごとに定義しているとコード量が多くなってしまいます。また、あるモジュールでそのコンポーネントを変更したいときに、他のモジュールのそのコンポーネントも修正しないといけなく、メンテナンスがとても大変になってしまいます。

Featureモジュール間の参照

そこでFeatureモジュールとは別に、Featureモジュール間で共有して使うことができるCoreモジュール(Sharedモジュールのうちの1つ)を作成しました。Coreモジュールには複数のモジュールで使用したい機能やコンポーネントなどを定義しています。そしてDependency Injectionを取り入れ、その中に画面遷移の機能を入れることによってFeatureモジュール間で画面遷移ができる仕組みを実現しています。Featureモジュール内でDependencyを使うことによって、他のモジュールの画面などを取得することが可能になっています。

モジュール間連携

Coreモジュールによって複数のモジュールで使用する機能などは1つ定義するだけで良くなりました。そしてDependency Injectionにより、Featureモジュール間の連携の管理がしやすくなりました。次節では、実際にFeatureモジュール間の画面遷移をCoreモジュールを介してどのように実装しているのかを解説していきます。

モジュール間の画面遷移

マルチモジュール開発をするにあたって、課題として挙げられたのが、Featureモジュール間の画面遷移でした。メルペイではメルカリでも採用されている、ViewControllerをリクエストする方式を取りました。実際にどのようなコードを書いているのか紹介していきたいと思います。

メルペイではViewControllerリクエストをするために、Coreモジュールの中にMerpaySceneというものを定義しました。定義の内容は以下のようになります。

public enum MerpayScene {
    case nfcKit(scene: NFCKitScene)
    case qrKit(scene: QRKitScene)
    case couponKit(scene: CouponKitScene)
    ...
}

extension MerpayScene {
    public enum NFCKitScene {
        case nfcScene1(argument: NFCKitScene1Argument)
        case nfcScene2(argument: NFCKitScene2Argument)
        ...
    }
}

extension MerpayScene {
    public enum QRKitScene {
        case qrScene1(argument: QRKitScene1Argument)
        case qrScene2(argument: QRKitScene2Argument)
        ...
    }
}

...

モジュール間で共有する画面は「Scene」として定義します。Sceneはモジュールごとにネストしたenumで定義することにより実装漏れなどを防ぐことができ、修正や追加などに強いコードになっています。また引数を定義することによって、ViewControllerリクエストに引数がある場合でも呼び出すことができるようになっています。引数のクラスは別ファイルで定義しています。以下例になります。これも同様にCoreモジュールに定義します。

extension MerpayScene {
    public struct NFCKitScene1Argument {
        public let foo: String
        public let bar: Int64

        public init(foo: String, bar: Int64) {
            self.foo = foo
            self.bar = bar
        }
    }
}

次にこのMerpaySceneを実際に使用して、ViewControllerをリクエストする関数を実装します。ViewControllerを呼び出すための関数であるviewControllerを持つMerpaySceneFactoryTypeというプロトコルを定義します。そして、そのプロトコルを実装したMerpaySceneFactoryというクラスを作成します。このクラスはクラスプロパティとしてfactoriesを持っていて、register関数によって各モジュールで実装したSceneFactoryをここに登録します。実際にViewControllerを呼び出すときは、登録したfactoryをひとつずつ見ていき、最初にマッチしたリクエストのViewControllerを返します。このMerpaySceneFactoryのインスタンスはDependencyTypeの中のsceneFactoryプロパティに格納されます。

public protocol MerpaySceneFactoryType {
    func viewController(scene: MerpayScene, dependency: Dependency) -> UIViewController?
}

public class MerpaySceneFactory: MerpaySceneFactoryType {
    private var factories: [MerpaySceneFactoryType] = []

    public func register(factory: MerpaySceneFactoryType) {
        factories.append(facotry)
    }

    public func viewController(for scene: MerpayScene, dependency: Dependency) -> UIViewController? {
        for factory in factories {
            if let viewController = factory.viewController(for: scene, dependency: dependency) {
                return viewController
            }
        }
        return nil
    }
}
public protocol DependencyType {
    var sceneFactory: MerpaySceneFactoryType { get }
}

ここまではCoreモジュール内に必要な実装について話しました。次に、各モジュールでのSceneFactoryの定義について解説します。NFCモジュールの中のNFCKitSceneFactoryを例にとってみます。ここでは実際に他のモジュールからNFCモジュールの画面を呼び出すための関数を実装します。下で示しているように、Coreモジュール内で定義したMerpaySceneFactoryTypeを実装します。このプロトコルにはviewController関数があり、これがViewControllerリクエストの時に実際に呼ばれる関数です。ケースごとにあらかじめ定義した引数などを使用してViewControllerを作成して、それを返します。ここで注意するのは、viewController関数の引数として使用しているsceneMerpayScene全体を含んでいるので、NFCモジュール内でNFCモジュール以外のリクエストは棄却するようにしなければいけません。それが関数内の1行目のguard文にあたります。

import Core
final class NFCKitSceneFactory: MerpaySceneFactoryType {
    func viewController(for scene: MerpayScene, dependency: Dependency) -> UIViewController? {
        guard case .nfcKit(let scene) = scene else { return nil }
        switch scene {
        case .scene1(let argument):
            return NFCKitScene1ViewController(
                argument: .init(foo: argument.foo, bar: argument.bar),
                dependency: dependency
            )
        case .scene2(let argument):
            return NFCKitScene2ViewController(
                argument: .init(foo: argument.foo, bar: argument.bar),
                dependency: dependency
            )
        ...
        }
    }
}

各モジュールで作成したSceneFactoryは、先ほど説明したCoreモジュールのMerpaySceneFactoryに登録し、それをDependencyに渡す必要があります。以下にその実装を載せます。application関数にMerpaySceneFactoryのインスタンスを作って、そこに各モジュールのSceneFactoryを登録していきます。そしてDependencyのインスタンスに、このMerpaySceneFactoryのインスタンスを引数として渡し、そのDependencyRootViewControllerに渡します。このDependencyをViewControllerの遷移の度に渡していくことによって、モジュール間の画面遷移を実現させることができます。

import Core
import NFCKit
import QRKit
import CouponKit

func application(xxx, didFinishLaunchingWithOptions ...) -> Bool {
    ...
    let sceneFactory = MerpaySceneFactory()
    sceneFactory.register(NFCKitSceneFactory())
    sceneFactory.register(QRKitSceneFactory())
    sceneFactory.register(CouponKitSceneFactory())
    let dependency = Dependency(
        ...,
        sceneFactory: sceneFactory
    )
    let rootVC = RootViewController(dependency: dependency)
    window?.rootViewController = rootVC
    ...
}

最後に実際に他のモジュールからNFCモジュールの画面を呼び出すコードを紹介します。以下がその実装になります。先ほど説明したDependencyをViewControllerは必ず持っているはずなので、そこから呼び出したい画面である.nfcScene1を呼び出します。この時にCoreモジュールで定義した引数も渡します。基本的には期待しているViewControllerが返ってきますが、引数の値などによってうまくViewControllerが作成できないパターンもあり、viewController関数はOptionalで返ってくるため、guard文でリクエストを記述しています。そしてViewControllerを取得したら、pushやpresentによって画面遷移をさせます。このViewControllerリクエストの良いところは、呼び出し側で遷移の方法を定義できるところにもあります。ここまでがモジュール間の画面遷移を実現させるのに必要な実装でした。

import Core

final class SceneViewController: UIViewController, Instantiatable {

    init(argument: Argument, dependency: Dependency) {
        self.dependency = dependency
        ...
    }

    private let dependency: DependencyType

    func showNFC() {
        guard let vc = dependency.sceneFactory.viewController(
            for: .nfcKit(scene: .nfcScene1(argument: .init(foo: foo, bar: bar)))
        ) else { return }

        navigationController?.pushViewController(vc, animated: true)
    }
}

このMerpaySceneによって、モジュール間で直接参照をすることなくモジュール間の画面遷移を実現させることができました。これによってモジュール内にあるViewControllerは全てinternalなものにすることができました。この画面遷移のためのviewController関数が返すのはUIViewControllerであって抽象化されたクラスであるので、あるモジュールから他のモジュールの画面のクラスを取得するなどといったことは、この機能ではできないことに注意してください。

 マルチモジュール開発導入のまとめ

背景に示したような問題点から、機能ごとにモジュールが分かれているべきだという結論になり、既存のモジュールを機能ごとに分解しました。そしてその際に、モジュール間の連携という課題が発生しました。この課題はSharedモジュールとなるCoreモジュールの導入やDependency Injectionによって解決しました。これによってモジュール同士に依存関係が発生することなく、スッキリとしたストラクチャを実現させることができました。以下にメルペイ全体の構成図を載せておきます。

メルペイ構成図

今回紹介したCoreモジュール以外にも、Sharedモジュールの中にはAPIを呼ぶためのAPIモジュールなどもあります。また、メルペイはメルカリにSDKとして機能を提供しており、メルカリとメルペイ両方で使用しているモジュールも存在します。代表的な例として、メルカリとメルペイのデザインを統一するために定義されているUIコンポーネントの集まりであるDesign Systemというモジュールがあります。そしてDependencyはメルカリとメルペイ合わせたものがメルペイ内のDependencyとして使用されています。

各Featureモジュールはスタティックリンクをしているので、Featureモジュールが増えたとしてもアプリの起動時間に影響を及ぼすことがありません。これによってスケールに強い構造となっています。また、機能ごとにモジュールを分けたことによって、独立して開発がしやすくなったため、コンフリクトが発生しにくくなりました。そして、メルペイのコード全てをビルドする必要なく、各Featureモジュールごとにサンプルアプリを作成することによって、開発効率を上げることが可能になりました。これについては後ほど解説します。

このマルチモジュール開発では新機能が出るごとに、新モジュールを作成します。これによって開発に携わるエンジニアはそのモジュールに対してオーナーシップを持つことができ、モジュールが育っていく姿を見ることができるため、エンジニアのモチベーション向上にも繋がることが期待されます。

マルチモジュール開発導入のその後

新機能開発プロセス

メルペイではまだまだ新機能を開発している途中です。新しい機能を追加する機会も少なくありません。そこで新機能のための新モジュールを作るための機構も準備してあります。あらかじめ新規モジュールに最低限必要な機能をまとめた、テンプレートモジュールを作っています。そのテンプレートモジュールを使用して、新規モジュールを作成するためのシェルスクリプト(init_framework.sh)も用意しています。以下そのシェルスクリプトを利用した新機能開発プロセスになります。

  1. 新規モジュール作成 ./bin/init_framework.sh MerpayNewFeatureKit
  2. Featureブランチ作成 git checkout -b feature/merpay-new-feature
  3. 毎日mainブランチをマージしながらFeatureブランチ以下で開発
  4. FeatureブランチでメルカリをビルドしてQA

このような手順で開発しています。最近ではFeature Flagを使用して、できるだけFeatureブランチを使わない開発スタイルにも取り組んでいます。これについては次節で解説します。

 トランクベース開発

mainブランチからFeatureブランチを切って新機能を開発するという開発スタイルには問題点があります。それは新機能が完全に出来上がるまでmainブランチにマージされないので、とても大きな変更を持ったプルリクエストができてしまうことです。これによってマージした際に、他のブランチに大きく影響が出る可能性があり、コンフリクトが大きく発生した場合、開発効率が下がってしまいます。またFeatureブランチ自体に対してコンフリクトが発生する可能性も、Featureブランチが大きくなるにつれて高まっていきます。これによって開発が完全に終わるまでの管理コストが膨大になり、同じく開発効率を下げてしまいます。

ここで考えられたのが、トランクベース開発の採用です。トランクベース開発では新機能もmainブランチへとどんどんマージしていきます。これによって先ほど挙げた管理コストやマージの際のコンフリクトの危険性も低くなります。

この開発スタイルを採用するために、新機能はリリースまで、メルカリにリンクしないという手法が生み出されました。新モジュールがmainブランチに入っていてもメルカリにリンクされていなければ問題ないという観点から、この手法が採用されました。よって新規モジュール開発は、メルカリにリンクをしないでmasterブランチにマージしていくトランクベース開発を採用しています。

また既存のモジュールで新機能を開発する時にはFeature Flagを使って、新機能のリリースの制御をしています。しかしこれはアプリのバイナリには、その機能が入ってしまうという問題があるため、トランクベース開発は取り入れずにFeature Branchを使ってリリースの制御をしています。メルペイでは現在、このトランクベース開発とFeature Branchを使用した開発の両方を採用したハイブリッドな開発スタイルで開発しています。以下にFeature Flagの使用例を載せておきます。Feature Flagが有効かどうかによって処理を変えることができます。下の例ではFeature Flagが有効かどうかによって遷移する画面を切り替えています。

func showNFC() {
    let scene: MerpayScene
    dependency.merpayFeatureFlag.result(from: .showNewNFCScene).isVariant {
        scene = .nfcKit(scene: .nfcScene1(argument: .init(foo: foo, bar: bar)))
    } else {
        scene = .nfcKit(scene: .nfcScene2(argument: .init(foo: foo, bar: bar)))
    }

    guard let vc = dependency.sceneFactory.viewController(for: scene) else { return }
    navigationController?.pushViewController(vc, animated: true)
}

サンプルアプリ

背景でもお話した通り、今までは開発用のアプリは1つに全ての機能をまとめたものを使っていました。しかし、機能が増えるにつれてビルドにかかる時間が増えていきました。これを解消するために、モジュール単位でサンプルアプリを作成することを試みました。モジュール単位のサンプルアプリであれば、他のFeatureモジュールを含めないため、大幅にビルド時間を短縮することができます。実際に全ての機能を含んだアプリと、Featureモジュールのサンプルアプリのビルド時間を計測してみました。以下が結果になります。

統合アプリ ダッシュボードアプリ NFCアプリ
166.26(s) 81.63(s) 66.10(s)

コード量は統合アプリ > ダッシュボードアプリ > NFCアプリになります。ビルド時間もコード量の関係と同様になりました。UnitTestやPreviewも各Featureサンプルごとに実行できるようになっているため、統合アプリを使用する場合に比べて、大幅に開発効率を上げられていると言えます。これによってよりスケールに強い開発基盤を整えることができました。

マルチモジュール開発による課題

ここまでマルチモジュール開発によって改善してきたことを説明してきましたが、同時にマルチモジュール開発による課題も見受けられるようになりました。1つ目に機能ごとに独立しているが故に、他のモジュールについてあまり知ることがなく、ドメイン知識が個々人で偏ってしまうという問題があります。この問題は、チームミーティングの際にナレッジシェアを行うことによって補っています。現在メルペイiOSチームでは、週替わりでメンバーが発表を行なっています。2つ目にSharedモジュールへの機能移行です。複数モジュールで使用される機能は基本的にSharedモジュールに移すべきですが、あまりにも移しすぎてしまうとSharedモジュールが肥大化してしまい、マルチモジュール開発の利点が薄れてしまいます。これに関しては、ベストプラクティスが見つかっていないのでこれからより良い方法を探していこうと思っています。

これら以外にも先ほど話したトランクベース開発への完全移行の検討など、様々な課題があります。これからもスケールに強く、開発のしやすい環境を整えるためにチームで議論して改善していきたいと考えています。

まとめ

マルチモジュール開発の導入によって、現在のメルペイにあった開発スタイルを実現させることができました。この開発スタイルはスケールにとても強いものになっていて、これから新機能をどんどん開発するための準備を整えることができました。また、マルチモジュール開発を生かしたブランチマネジメントを採用することによって、より良い開発環境を構築できています。これからもメルペイそしてメルカリの新機能にご期待ください。最後まで読んでいただきありがとうございました。

謝辞

今回紹介した仕組みは、チームメンバーである@masamichiによって提案されました。またMerpaySceneを最初に提案してくださった同じくiOSチームメンバーの@kuにも大きく助けられました。最後に同iOSチームメンバー@kenmazにはマルチモジュール開発の導入の実装を手伝って頂きました。皆さんありがとうございました!!

関連資料

iOS Tech Talk 〜 Multi module 戦略座談会 〜
https://www.youtube.com/watch?v=5p6h5yiQ2PQ

iOS Tech Talk 〜 Multi module 戦略座談会 vol.2 〜
https://www.youtube.com/watch?v=glpfnnDDaz8