Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

AIを活用した大規模iOSアプリのSwift Concurrency移行戦略

はじめに

こんにちは!技術本部 Sansan Engineering Unit Mobile Application Groupに所属するiOSエンジニアの劉 志輝です。

今回は、ビジネスデータベース「Sansan」のiOSアプリで進めている、Swift6時代に向けたSwift Concurrencyへの移行戦略についてお話しします。

このアプリは10年以上にわたって継続開発されており、UIKit + VIPERアーキテクチャで構成されています。 非同期処理にはRxSwift(Single、Observable、BehaviorRelay)とGCD(DispatchSemaphore、DispatchQueue)が広範に使われており、複数のモジュールにまたがる大規模なコードベースです。

そのため、単純な書き換えではなく、既存設計との整合性を保ちながら段階的に移行する必要があります。この移行は現在も進行中で、全5フェーズのうちPhase4(Presenter層)まで進んでおり、Strict Concurrency Checking(SCC)の全面有効化に向けた対応は約60%が完了した段階です。

本記事では、この大規模アプリで「壊さずに」移行を進めるための5フェーズ段階的移行戦略と、各層での具体的な移行パターンを紹介します。

なぜ今、移行するのか

Swift6ではStrict Concurrency Checking(SCC)がデフォルトで有効になり、データ競合をコンパイル時に検出できるようになります。10年超にわたり継続開発してきた大規模アプリにとって、この静的な安全性保証は非常に価値があります。私たちはSCCの全面有効化を目標に掲げ、Swift6時代に適合した並行処理基盤の構築を進めています。

しかし、この目標に向けて現状のコードベースを見ると、大きく2つの課題がありました。

1つ目は、RxSwiftがSCCと根本的に相性が悪いことです。

RxSwiftの主要な型(AnyObserverObservableなど)はSendableに準拠していません。そのため、SCCを有効にすると @Sendable クロージャ内でのキャプチャ警告が大量に発生します。

.uploadProgress { progress in
    // ⚠️ Capture of 'observer' with non-sendable type
    //    'AnyObserver<UploadState>' in a `@Sendable` closure
    observer.onNext(.uploading(progress))
}

@preconcurrency import RxSwift で警告を抑制できますが、これはConcurrencyの安全性チェックそのものを無効化する応急措置です。RxSwiftに依存したコードが残る限り、Swift6が提供するデータ競合の静的検出の恩恵を受けることができません。つまり、RxSwiftへの依存を解消しない限り、SCCの全面有効化が実質的にできない状態です。

2つ目は、RxSwiftとSwift Concurrencyの混在による保守性の低下です。

プロジェクト全体の約20%のファイルがRxSwiftを使用しています。Swift Concurrencyの導入が進むにつれて、両者が混在するコードも増えてきました。

たとえば、UseCase層がasync/awaitで実装済みなのに、Interactor層がRxSwiftのインターフェースを維持しているために、以下のような冗長なラップが生まれます。

// RxSwift版: async処理をSingleでラップ(11行)
func deletePost(conversationID: String, postID: String) -> Single<Void> {
    return .create { observer in
        let task = Task { [weak self] in
            guard let self else { return }
            let result = await self.conversationUseCase.deletePost(
                conversationID: conversationID,
                postID: postID
            )
            switch result {
            case .success:
                observer(.success(()))
            case let .failure(error):
                observer(.failure(error))
            }
        }
        return Disposables.create { task.cancel() }
    }
}

// async版(1行)
func deletePostAsync(conversationID: String, postID: String) async throws {
    try await conversationUseCase.deletePost(conversationID: conversationID, postID: postID).get()
}

内部ではasync/awaitを使いながら Single でラップする——まさに本末転倒なパターンです。また、近年はSwiftUI・async/awaitが標準的になりつつあり、RxSwift固有の概念(Hot/Cold Observable等)を理解する機会が減少しています。こうした状況が続くと、RxSwiftに不慣れな開発者による誤用のリスクが高まり、保守性がさらに低下していきます。

5フェーズ移行戦略の全体像

10年超の大規模コードベースの移行には、機能開発を止めることなく安全に進められる仕組みが不可欠です。そこで、VIPERアーキテクチャの依存方向を生かした5フェーズの段階的移行戦略を策定しました。

Phase1: API層         ✅ 完了
    ↓
Phase2: UseCase層     ✅ 完了
    ↓
Phase3: Interactor層  ✅ 完了
    ↓
Phase4: Presenter層   🔄 進行中
    ↓
Phase5: クリーンアップ  📅 将来

なぜこの順番なのか。 VIPERアーキテクチャの依存関係は Presenter → Interactor → UseCase → API の方向に流れます。下位層(API)から上位層(Presenter)へ順番に移行することで、移行済みのasync版を上位層から安全に呼び出せるようになります。

この戦略を支えているのが、安全に移行するための3つの仕組みと、スピードを上げるためのAI活用です。

安全に移行するための仕組み:

  1. 並行実装: 既存のRxSwift版を維持したまま、async版を追加する
  2. Deprecation First: 旧版に @available(*, deprecated) を付けて移行漏れや新規コードでの誤用を防ぐ
  3. Feature Flag: Presenter層でリモートフラグにより段階的にロールアウトする

移行を加速するためのAI活用:

  1. AIエージェント: 移行ガイドに基づき、一貫したパターンで大量のコード変換を自動化する

まずは「安全に移行するための仕組み」がどのように各フェーズで機能するか、具体的な移行パターンを見ていきましょう。AI活用については後半で紹介します。

Phase1: API層の移行

最初に手をつけたのはAPI層です。ここでは説明用に、AsyncHTTPClient という新しいHTTPクライアントを導入した例を示します。

protocol Endpoint: Sendable {
    associatedtype Response: Decodable & Sendable
    var urlRequest: URLRequest { get }
}

final class AsyncHTTPClient: Sendable {
    static let shared = AsyncHTTPClient()
    private let session: URLSession = .shared

    func send<T: Endpoint>(_ endpoint: T) async throws -> T.Response {
        let (data, _) = try await session.data(for: endpoint.urlRequest)
        return try JSONDecoder().decode(T.Response.self, from: data)
    }
}

ポイントは Sendable に準拠していることです。Swift Concurrencyの世界で安全に共有するために、API層のプロトコルにもすべて Sendable を付与しています。

APIプロトコルでは、async版と旧版を並行して定義します。

public struct Page<Element: Sendable, Parameter: Sendable>: Sendable {
    public let items: Element
    public let next: Parameter?
}

public struct ItemSearchParameter: Sendable { /* ... */ }
public struct ItemSearchResult: Sendable { /* ... */ }

public protocol ItemSearchAPIInterface: Sendable {
    // Swift Concurrency版
    func searchAsync(parameter: ItemSearchParameter) async throws -> Page<[ItemSearchResult], ItemSearchParameter>

    // Deprecated版
    @available(*, deprecated, message: "Use searchAsync(parameter:) instead")
    func search(parameter: ItemSearchParameter) -> Single<Page<[ItemSearchResult], ItemSearchParameter>>
}

この「並行実装 + deprecation」のパターンが、全層を通じた移行の基本形です。

Phase2: UseCase層の移行

UseCase層では、Single<T>async throws への変換が中心です。ここでのコード削減効果はとくに大きく、DispatchSemaphore を使っていた同期呼び出しパターンが完全に不要になります。

たとえば、UseCase内でRxSwiftの結果を同期的に返すために DispatchSemaphore で待機していた処理は、以下のように書いていました(例: SettingsUseCase.getSettings() を簡略化)。

Before(RxSwift + DispatchSemaphore版):

func getSettings() throws -> AppSettings {
    let disposeBag = DisposeBag()
    let semaphore = DispatchSemaphore(value: 0)
    var result: Result<AppSettings, Error>?
    settingsAPI.fetchSettings()
        .subscribe(onSuccess: { setting in
            result = .success(setting)
            semaphore.signal()
        }, onFailure: { error in
            result = .failure(error)
            semaphore.signal()
        })
        .disposed(by: disposeBag)
    semaphore.wait()
    return try result!.get()
}

これがasync/awaitでは以下のように書けます。

After(async/await版):

func getSettingsAsync() async throws -> AppSettings {
    try await settingsAPI.fetchSettingsAsync()
}

十数行が3行に。セマフォもコールバックも DisposeBag も不要です。

Phase3: Interactor層の移行

Interactor層はPresenterとUseCase層の間に位置し、複数の下位層を束ねる調整役です。この層の移行では、RxSwiftのオペレータチェーンをSwift Concurrencyの構造化された並行性(Structured Concurrency)に置き換えることが中心になります。

Structured Concurrencyとは、非同期タスクの親子関係をコンパイラが管理する仕組みです。async let で子タスクを生成すると、親タスクのスコープを抜ける際に自動でキャンセル・待機されるため、タスクのライフサイクル管理を手動で行う必要がありません。RxSwiftでは DisposeBagCompositeDisposable でサブスクリプションを管理していましたが、Structured Concurrencyではスコープベースの自動管理に置き換わります。

Interactor層で頻出する「複数のAPIを束ねる」パターンとの相性が良く、RxSwiftのオペレータチェーンに比べて処理の流れが格段に読みやすくなります。ここでは、Interactor層でよく見られる2つのパターンを紹介します。

並行取得: combineLatestasync let

まずは、複数のAPIを同時に呼び出して結果を束ねるパターンです。

// Before: Observable.combineLatest
func fetchDraftAndCategoryGroups(draftID: String) -> Single<(Draft, [CategoryGroup])> {
    Observable.combineLatest(
        getDraftObservable(draftID: draftID),
        getCategoryGroupsObservable()
    ).asSingle()
}

// After: async let
func fetchDraftAndCategoryGroupsAsync(draftID: String) async throws -> (Draft, [CategoryGroup]) {
    async let draft = draftsAPI.fetchDraftAsync(draftID: draftID)
    async let groups = categoriesAPI.fetchCategoryGroupsAsync()
    return try await (draft, groups)
}

async let を使えば、2つのAPIを並行実行して結果をタプルで受け取れます。Observable.combineLatest と同じ動作を、より直感的に表現できます。どちらかが失敗すれば、もう一方は自動的にキャンセルされます。

逐次依存: flatMap チェーン → sequential await

次に、1つ目のAPI結果を使って2つ目を呼ぶ逐次依存パターンです。

// Before: flatMap チェーン
func fetchUserAndPosts(userID: String) -> Single<(User, [Post])> {
    userAPI.fetchUser(userID: userID)
        .flatMap { [weak self] user in
            guard let self else { return .error(SomeError.deallocated) }
            return self.postAPI.fetchPosts(userID: user.id)
                .map { posts in (user, posts) }
        }
}

// After: sequential await
func fetchUserAndPostsAsync(userID: String) async throws -> (User, [Post]) {
    let user = try await userAPI.fetchUserAsync(userID: userID)
    let posts = try await postAPI.fetchPostsAsync(userID: user.id)
    return (user, posts)
}

flatMap の入れ子が素直な順次処理になります。[weak self] やガード処理も不要になり、処理の依存関係が一目でわかります。

Phase4: Presenter層の移行

Phase1〜3では、既存のRxSwift版を維持したまま、async版のインターフェースを並行して追加しました。旧版がそのまま動き続けるため、この段階ではアプリの動作は一切変わりません。

Phase4のPresenter層で初めて、呼び出し先をRxSwift版もしくはGCD版からasync版に切り替えます。つまり、実際に非同期処理の振る舞いが変わるのはこのフェーズだけです。そのため、いきなり全面切り替えはせず、Feature Flagによる段階的ロールアウトを採用しました。問題が発覚した場合、フラグをオフにするだけで即座にロールバックできます。

@MainActor
final class SubscriptionListPresenter {
    private let featureFlagProvider: FeatureFlagProvider

    private var isAsyncMigrationEnabled: Bool {
        featureFlagProvider.isEnabled(FeatureFlags.asyncPresenterMigration)
    }

    // Feature Flag分岐
    func subscribe(userID: String) {
        if isAsyncMigrationEnabled {
            subscribeAsync(userID: userID)
        } else {
            subscribeRx(userID: userID)
        }
    }
}

Feature Flagによる分岐の仕組みは上記の通りシンプルです。次に、async版の実装で重要になるTaskのライフサイクル管理を見ていきます。

private var tasksByUserID = [String: Task<Void, Never>]()

deinit {
    tasksByUserID.values.forEach { $0.cancel() }
}

func subscribeAsync(userID: String) {
    tasksByUserID[userID] = Task { [weak self] in
        defer {
            self?.tasksByUserID.removeValue(forKey: userID)
        }

        do {
            try await self?.interactor.subscribeAsync(userIDs: [userID])
            guard !Task.isCancelled else { return }

            Haptics.shared.impact(.light)
            self?.view?.setSubscribed(true, forUserID: userID)
        } catch {
            guard !Task.isCancelled else { return }
            self?.view?.showErrorAlert(message: "Failed to update subscription.")
        }
    }
}

RxSwift版では DisposeBag がサブスクリプションのライフサイクルを管理していましたが、async版では Task を辞書で管理し、deinit で明示的にキャンセルします。

また、Presenter層には @MainActor を付与してスレッドの安全性を保証します。既存のPresenter用プロトコルに直接 @MainActor を付けると影響範囲が大きくなりがちなため、@MainActor 前提の専用プロトコルを別途用意するのが安全です。

@MainActor
public protocol MainActorViewLifecycle: AnyObject {
    func viewDidLoad()
    func viewWillAppear(_ animated: Bool)
    func viewDidAppear(_ animated: Bool)
    func viewWillDisappear(_ animated: Bool)
    func viewDidDisappear(_ animated: Bool)
}

Phase5: クリーンアップ(将来)

Phase4までの移行が完了し、すべてのPresenterでasync版が安定稼働していることを確認した後、最終フェーズとして以下のクリーンアップを予定しています。

  1. 旧実装の削除: @available(*, deprecated) を付けたRxSwift版・GCD版メソッドをすべて削除し、RxSwiftへの依存を完全に除去する
  2. Feature Flagの削除: Presenter層に埋め込んだasync移行用のフラグ分岐コードを削除し、async版のみのシンプルな実装にする
  3. コードレビュー・リファクタリング: 移行過程で生じたAsyncサフィックスの命名見直しや、不要になったDisposeBag・ブリッジコードの整理など、コードベース全体の品質を改善する

このフェーズが完了すれば、SCCの全面有効化が実現し、Swift6時代に適合した並行処理基盤が整います。

AIエージェントを活用した移行の加速

ここからは、戦略のもう1つの柱である「移行を加速するためのAI活用」について紹介します。数百のモジュールに対して同じパターンの変換を繰り返す作業は、まさにAIエージェントが得意とする領域です。私たちはClaude Codeを活用し、移行のスピードを大幅に引き上げています。

移行ガイドの整備 — AIへの「指示書」

AIに一貫したパターンで移行を実行させるために、まず各層の移行ガイドを整備しました。

.claude/
├── commands/
│   ├── api-migration.md                           # Phase1: API層移行コマンド
│   ├── usecase-migration.md                       # Phase2: UseCase層移行コマンド
│   ├── interactor-migration.md                    # Phase3: Interactor層移行コマンド
│   └── usecase-ut-creation.md                     # UT作成コマンド
├── rules/concurrency/
│   ├── API_Concurrency_Migration_Guide.md         # Phase1: API層(1,300行超)
│   ├── UseCase_Concurrency_Migration_Guide.md     # Phase2: UseCase層
│   └── Interactor_Concurrency_Migration_Guide.md  # Phase3: Interactor層
│   ├── Presenter_Concurrency_Migration_Guide.md   # Phase4: Presenter層
│   └── SCC_Best_Practices.md                      # SCC対応パターン集
└── skills/
    ├── presenter-migration/SKILL.md               # Presenter移行スキル
    └── swift-concurrency-migration/SKILL.md       # 移行全体ガイド

ガイドには、移行パターンのBefore/After、命名規則、deprecationメッセージの書き方、Task管理の判断基準など、本記事で紹介したパターンがすべて網羅されています。これが「人間が書いたコードとAIが書いたコードに差がない」状態を実現するための鍵です。

スキルによるワークフローの自動化

移行の自動化には、当初Claude Codeの「カスタムスラッシュコマンド」(.claude/commands/)を利用していました。これはPhaseごとの移行手順をMarkdownで定義し、/api-migration/usecase-migration のようなコマンドで実行できる仕組みです。

その後、2026年1月にClaude Codeに「スキル」機能(.claude/skills/)がリリースされ、現在はこちらに移行しています。スキルではサブエージェントでの実行(context: fork)、動的コンテキスト注入、ツールの制限など、より高度なワークフロー制御が可能になりました。

たとえばPresenter層の移行では、以下のコマンド一つで移行が開始されます。

/migrate-presenter <PresenterName> --flag <FlagKey>

このスキルが実行するワークフローは以下の通りです。

  1. Git Worktreeで作業環境を準備 — ベースブランチから作業ブランチを切る
  2. 移行ガイドを読み込む — Presenter移行ガイドとSCCベストプラクティスを理解
  3. 移行を実行 — Feature Flag設計、async版実装、Task管理、SCC対応を一括で実施
  4. ビルドチェック → エラー修正を繰り返す — SCCの警告/エラーがなくなるまでループ
  5. コミット — 変更をコミットしてレビューに回す

特にステップ4の「ビルドチェック → 修正ループ」が強力です。SCCの対応では、@MainActor の追加が別のファイルに連鎖的にエラーを生むことがあります。AIがビルドログを解析して対象の警告/エラーを抽出し、移行ガイドに沿って修正を繰り返すことで、人間が手作業で行うと見落としがちな連鎖対応も漏れなく処理できます。

人間が「何を、どのパターンで移行するか」を決め、AIが「そのパターンを正確に大量に適用する」。この分担により、一貫性を保ちながら移行のスピードを上げることができています。

ハマったポイントと対策

【戦略】膨大な移行量とモジュール未分割によるチケット分割の難しさ

移行対象を洗い出した結果、RxSwiftを使用しているファイルはプロジェクト全体の約20%、数百ファイルに及びました。さらに、SansanのiOSアプリは歴史的経緯からモジュール分割が十分に進んでおらず、Sansanターゲットモジュール内に多くの機能が密結合した状態でした。モジュールが適切に分割されていれば、そのモジュール単位で移行作業を進められますが、Sansanターゲットは数百ファイルを含む巨大なモジュールであり、一気に移行することはできません。

そこで課題になったのが、どのような作業単位でチケットを切るかです。機能ごとの依存関係「このPresenterはどのInteractorを使い、どのUseCaseを経由し、どのAPIを呼んでいるか」を正確に把握し、影響範囲を特定する必要がありました。Sansanターゲットが巨大なため、この依存関係の分析はAIエージェントに任せ、機能グループの分割案を自動生成してもらいました。最終的には、Phase1〜3(API・UseCase・Interactor層)はasync版インターフェースの並行追加でアプリの振る舞いが変わらないため比較的シンプルに分割し、Phase4(Presenter層)は機能グループ単位(例: メッセージ関連、名刺関連、設定関連など)で分割してグループごとにFeature Flagで制御してリリースする方針としました。

【移行実装】1. weak self vs guard let selfの罠

async/awaitへの移行で最も注意が必要だったのが、Task内での self の扱いです。

// NG: awaitの前にguard let selfを使うとawaitの間もselfを強参照
tasksByID[id] = Task { [weak self] in
    guard let self else { return }
    let data = try await interactor.fetchDataAsync()  // awaitの間もselfを保持!
    view?.displayData(data)
}

guard let selfawait の前で使うと、非同期処理が完了するまで self が強参照されます。画面を閉じても deinit が呼ばれず、キャンセル処理が実行されません。

正しくは、[weak self] + self?. パターンを使います。

// OK: [weak self] + self?.パターン
tasksByID[id] = Task { [weak self] in
    let data = try await self?.interactor.fetchDataAsync()
    self?.view?.displayData(data)  // selfがnilなら何もしない
}

なお、すべての await が完了した後であれば guard let self を使っても問題ありません。

【移行実装】2. SCCエラーの連鎖対応

Presenterに @MainActor を付けると、そのPresenterのメソッドを呼び出す側にも対応が波及します。

たとえば、Routerの init() でPresenterを生成している場合、init() にも @MainActor が必要になります。さらに、そのRouterを呼び出しているクロージャにも @MainActor が必要に...と連鎖します。

// Routerに@MainActorが必要 → 呼び出し元のクロージャも対応が必要
modalController.dismiss(animated: true, completion: { @MainActor [weak self] in
    self?.navigateToDetail()  // @MainActorメソッド
})

また、@MainActor なViewControllerが @MainActor でないプロトコルに準拠するとSCCエラーになります。これにはMainActor 版のプロトコルを別途用意して対応しました。

// 元のプロトコル(変更しない)
public protocol ErrorPresentable { /* ... */ }

// MainActor版を追加(元のプロトコルを継承しない)
@MainActor
public protocol MainActorErrorPresentable: AnyObject { /* 同じメソッドを再定義 */ }

元のプロトコルを変更しないことで、移行済みでないクラスへの影響を避けています。

移行を通じて学んだこと

技術的な学び。 下位層から順に移行することで、各フェーズの影響範囲を明確に限定できました。API層を移行した時点でUseCase層以上には一切影響がなく、安心して進められます。

チーム運営の学び。 Feature Flagにより「いつでもロールバックできる」という安心感が、移行のスピードを上げてくれました。完璧を求めて慎重になるよりも、フラグで制御しながら少しずつリリースするほうが、チーム全体の心理的負荷が小さくなります。

まとめ

本記事では、10年超の大規模iOSアプリにおけるRxSwiftからSwift Concurrencyへの5フェーズ段階的移行戦略を紹介しました。

  • Phase1-2(API・UseCase層): DispatchSemaphore の排除と劇的なコード削減
  • Phase3(Interactor層): .do()defercombineLatestasync let への変換
  • Phase4(Presenter層): Feature Flagによる安全なロールアウト、@MainActor + Task管理

並行実装アプローチにより既存機能を壊すことなく、段階的に移行を進められています。

また、AIエージェントの活用により、巨大なモジュールの依存関係分析や機能グループの分割、移行ガイドに基づく一貫したパターンでの大量のコード変換を自動化し、移行のスピードと品質の両立を実現しています。人間が「何を、どのパターンで移行するか」を決め、AIが「そのパターンを正確に大量に適用する」という分担が、大規模移行プロジェクトにおいて効果的でした。

現在はPhase4のPresenter層のリリースを進めており、その先にはPhase5(deprecated版の削除、業務ロジック層以下のRxSwift完全除去)が控えています。Swift6時代に向けた基盤づくりは、着実に前進しています。

移行が完了した際には、最終的な成果や得られた知見を改めて記事にまとめる予定です。

Sansan技術本部ではカジュアル面談を実施しています

Sansan技術本部では中途の方向けにカジュアル面談を実施しています。Sansan技術本部での働き方、仕事の魅力について、現役エンジニアの視点からお話しします。「実際に働く人の話を直接聞きたい」「どんな人が働いているのかを事前に知っておきたい」とお考えの方は、ぜひエントリーをご検討ください。

参考リンク

© Sansan, Inc.