環境の変化に伴い、過去に下した合理的な判断がもはや有効でなくなることはよくあります。この記事では、LINEアプリの成長に伴い、動的設定配信システムである「サービス設定」のiOSクライアントがどのような課題に直面し、それをどのように乗り越えてきたかをご紹介します。
サービス設定とは?
LINEアプリは2週間ごとに新しいバージョンがリリースされます。LINEアプリ内には、複数のチームが開発する数多くのサービスが共存しているため、個々のサービスの要件に合わせてリリーススケジュールを変更することは困難です。この制約があるため、「サービス設定」が必要となります。
例えば、あるサービスが特定の国の連休に合わせて新機能をリリースしたいとしましょう。このような場合、サービスはその機能を動的にオン/オフできる状態でアプリを配信し、デフォルトではオフにしておきます。その後、任意のタイミングでその機能をオンにすれば、アプリをアップデートすることなく新機能をリリースできます。
このような動的なサービス配信を可能にするシステムが、「サービス設定」システムです。サービス運営 者が管理画面で設定値を変更すると、サービス設定サーバーはLINEアプリに通知を送信します。LINEアプリはサーバーに新しい設定値をリクエストし、サーバーはユーザーの地域、デバイス、OSのバージョンなどに基づいて適用する値を決定してそれを返します。
サービス設定は、機能のトグル以外にも、ロールバック計画、A/Bテスト、エラー収集のサンプリング率の決定、特定のUIの動作ポリシーなど、さまざまな用途で活用されています。このように複数の設定が同時に運用されるため、個々のサービスでは、動的な制御が必要な機能ごとに文字列の「キー」を定義し、キーごとに「値」を設定します。サービス設定サーバーは、それらの値を文字列から文字列へとマッピングする辞書形式で提供します。
{
"function.media.image_medium": "1280,70",
"function.media.image_high": "2048,80",
"function.media.message.flow.v2.image": "Y",
...
}
現在、LINEのiOSアプリでは、約700個の設定キーが60ほどのモジュールで使用されています。
規模の拡大がもたらした問題
従来のシステムはモノリシック(monolithic)で開発されました。モノリシック構造という制約のため、1つのファイルにすべてのキーを宣言しなければならず、キーを列挙したファイルは7,000行にも達していました。また、ほとんどのキーが特定のモジュールでしか使用されないにもかかわらず、常に全体公開として宣言されなければなりませんでした。
プロジェクトの初期段階では、1つのファイルですべてのキーを管理することが、シンプルかつ合理的なアプローチだ ったはずです。しかし、プロジェクトやチームの規模が大きくなるにつれて、この構造に起因する問題が次第に深刻化していました。
循環依存のジレンマ
モノリシック構造の最も根本的な問題は、モジュール間の依存関係の方向性のため、設定値を意味のある型として扱えないことでした。
例を挙げてみましょう。LINEアプリでは、トークルームに送信する写真の画質を標準画質と高画質から選択できます。このとき、各オプションが実際にどのような解像度やJPEG圧縮率に対応するかは、サービス設定によって決定されます。例えば、"1280,70"という値は、画像を「最大1280×1280ピクセル、圧縮率0.7」で送信することを意味します。このような値は、文字列のまま扱うよりも、ImageTransferQualityのような専用の型として扱うことが望ましいです。
ここで問題は、「この専用の型をどこに宣言するのか」です。関心の分離の原則に従えば、写真関連のモジュールで宣言するのが適切ですが、そうするとサービス設定システムから専用の型を直接返すことができません。写真モジュールはすでに設定モジュールに依存しているため、逆方向の依存関係が生じてしまうからです。その結果、以下のように設定モジュール側ではパース前の生の文字列を公開し、写真モジュール側でその都度パース処理を行っていました。
// LineConfigurationSubsystemモジュール — 文字列のみを返却可能
public final class LineConfigurationManager {
public var transferQualityImageMedium: String? { /* ... */ }
}
// LinePhotosモジュール — 呼び出し元で直接パース
let string = configurationManager.shared.transferQualityImageMedium
let mediumQuality = ImageTransferQuality.parse(string)
型を設定モジュールで宣言すれば、パース処理を省略できますが、写真の画質とは無関係な他のモジュールもこの型を認識することになります。実際、このような型には慣例的にLineConfiguration-の接頭辞を付けて、名前空間の汚染を防ぐ必要がありました。どちらを選んでもデメリットが伴う、構造的なジレンマでした。
不完全な抽象化
従来の実装のもう一つの特徴は、使っていくうちに実装の詳細をいちいち把握しなければならなかった点でした。
まず、サーバーから受け取った値をデコードするロジックを自分で記述する必要がありました。従来の実装では、一緒に使用されるキーをプロパティグループという単位でまとめて構造体として宣言し、一度にデコードしていました。以下は、AlbumというプロパティグループでalbumLikeEnabledというブール値の設定をデコードする実装です。
extension LineConfigurationManagerProperties.Album: Decodable {
init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
albumLikeEnabled = try container
.decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false
}
}
Swiftを長く使ってきた方ならお気づきかもしれませんが、デコードに使用したdecodeBoolIfPresent(forKey:)は、標準ライブラリで定義されているメソッドではありません。なぜこのメソッドが必要なのかを理解するには、サービス設定サーバーとの通信プロトコルを確認する必要があります。
サービス設定サーバーは、ブール値を"Y"/"N"の文字列として返し ます。iOSクライアントはこれをプロパティリストとしてエンコードし、上記のinit(from:)イニシャライザを呼び出します。標準のdecodeIfPresent(_:forKey:)メソッドの立場からすると、"Y"や"N"のような文字列をブール値と見なす理由がないため、型の不一致と判断してエラーをスローし、この呼び出しは失敗してしまいます。
decodeBoolIfPresent(forKey:)メソッドは、まさにこうした問題を回避するために作られたものですが、標準メソッドと名前が似ているため、混同されやすかったのです。さらに、誤って標準メソッドが使用されて初期化に失敗しても、サイレントにデフォルト値が使用されていたため、原因の特定も困難でした。そのため、標準メソッドを使用した誤った実装がコードレビューをすり抜け、後になって発見されたこともありました。
もう一つの問題は、用途が不明確なデフォルト値を、以下のように3回も記述しなければならなかった点です。
// 1つ目:PropertyGroupのstatic default(他のキーのデコードに失敗した場合のフォールバック)
extension LineConfigurationManagerProperties {
struct Album: PropertyGroup {
let albumLikeEnabled: Bool
static var `default`: Album {
Album(albumLikeEnabled: false) // ← 1つ目のデフォルト値
// 2つ目:init(from:)のフォールバック(キーの欠落または値が不正な場合)
extension LineConfigurationManagerProperties.Album: Decodable {
init(from decoder: any Decoder) throws {
albumLikeEnabled = try container
.decodeBoolIfPresent(forKey: .albumLikeEnabled) ?? false // ← 2つ目のデフォルト値
// 3つ目:defaultConfiguration辞書(サーバーから値が届いていない場合)
extension LineConfigurationManager {
static var defaultConfiguration: [String: Any] {
["function.album.like.enabled": false] // ← 3つ目のデフォルト値
これら3つのデフォルト値の用途は微妙に異なりますが、実際に区別する必要があるケースはありませんでした。特に1つ目のデフォルト値は、同じプロパティグループにまとめられた他の設定キーのデコードに失敗した際に使用されますが、あるキーのデコード失敗が他のキーの値に影響を与える理由はないため、これは設計上の欠陥に起因する不必要な要件でした。
こうした設計上の欠陥は、すでにサーバーとの通信プロトコルや実装の詳細を把握している開発者にとっては当たり前のことかもしれませんが、チームに新しく加わった開発者にとっては暗記すべき恣意的なルールとなり、オンボーディングで教えるべき知識を無駄に増やす結果となりました。
スレッド安全性の欠如
従来の実装は、並行性を全く考慮せずに実装されていました。しかし、並行性の問題が発生する構造的な要因は十分に存在していました。プロパティグループごとのデコードが遅延評価され、サーバーから新しい値を受け取るたびに、以前にデコードされたインスタンスが解放されるなど変更が頻繁に発生した上、複数のサービスが異なるスレッドから設定値を読み取る状況だったためです。
この問題は指標にも表れていました。解放済みメモリ使用(use-after-free)により、毎日数百件のクラッシュが発生しており、これによるバグチケットが継続的に作成され、ホットフィックスのリリースにつながりました。未定義の動作という特性上、実際にはさらに多くのデバイスにさまざまな形で影響を及ぼしていたと考えられます。
このような問題も、プロジェクトの規模が小さく、Swift Concurrencyを本格的に導入する前のコードベースでは、極めて稀に発生するものであり、根本的に修正するにはコストに見合わないバグだったでしょう。しかし、サービスの数が増え、同時に実行されるタスクも増えるにつれて、ますます頻繁に発生する、解決すべき課題として浮上してきました。
デバッグ用オーバーライドの欠如
QAの過程では、特定の設定値を一時的に変更してテストしたいケースが頻繁に発生します。しかし、サービス設定システム自体にはオーバーライド機能がありませんでした。そのため、QA担当者に設定値をオーバーライドする機能を提供するには、別途永続ストレージを確保し、デバッグメニューのUIを宣言したうえで、現在の値が何であるかを示すメッセージを作成する必要がありました。毎回、異なるモジュールの6~7つのファイルで、少ない場合は3~4行、多い場合は20行程度を修正しなければなりませんでした。
テストダブルの乱立
設定値を提供するLineConfigurationManagerがシングルトンだったため、各モジュールは自身が使用する属性のみを抽出したプロトコルを定義して、実装がシングルトンに直接依存するのではなくプロトコルに依存するようにし、テストダブルを自前で作成することでテスタビリティを確保していました。
このアプローチは、個々のサービス実装者にとっては合理的な妥協策だったかもしれませんが、プロジェクト全体から見ると大きな無駄でした。プロジェクト全体に数十のプロトコルとテストダブルが存在し、これらの実装は設定キーのライフサイクルに合わせて継続的に修正されなければなりませんでした 。テストダブルはそれぞれ別のコンパイルターゲットであるため、注意しないと、プルリクエストを送信した後のCIビルドで初めて更新を見逃していたことに気づくこともありました。また、手作業で作成されたテストダブルは品質にもばらつきがありました。新しい設定値があることを通知するタイミングが実際の実装と異なるため、本番環境にはバグがあるのにテストでは検出されなかったり、その逆の状況が発生したりする恐れもありました。
個々のチームの立場からすれば些細な問題かもしれないこうした不便さが、じわじわとプロジェクト全体の生産性を蝕んでいました。
解決の糸口:実証済みの設計から学ぶ
私たちは課題を整理した後、類似の課題をすでに解決している設計があるかどうかを探しました。解決すべき課題の本質は、以下のとおりでした。
- 大量のキー・値のペアに、型安全にアクセスできること。
- 各モジュールは、独自にキーを定義できること。
- 並行処理環境において安全に動作すること。
これらの条件を満たす先行設計として、FoundationのAttributedStringを見つけました。AttributedStringは、領域ごとに属性を付与できるテキストを表す型です。数多くのテキスト属性(フォント、色、リンクなど)を型安全に管理しつつ、UIKit、AppKit、SwiftUIといった複数のフレームワークが独立して属性を定義できるように設計されています。AttributedStringの設計は、Swift Foundationプロジェクトでオープンソースとして公開されているため、内部の実装まで確認できました。
型構造
今回のプロジェクトでは AttributedString APIの4つの型に注目しました。
AttributedStringKey
1つの属性を定義するプロトコルです。キーの名前と値の型を宣言します。
public protocol AttributedStringKey {
associatedtype Value: Hashable
static var name: String { get }
}
AttributeScope
関連するキーをグループ化するプロトコルです。UIKitの属性、SwiftUIの属性など、フレームワークごとにスコープを定義します。
extension AttributeScopes {
struct UIKitAttributes: AttributeScope {
let font: UIKitAttributes.FontAttribute
let foregroundColor: UIKitAttributes.ForegroundColorAttribute
}
}
AttributeDynamicLookup
列挙メンバーを持たない(caseがない) enumであるため、インスタンス化できません。ダイナミックメンバールックアップ(dynamic member lookup)によるキーパス(key path)ルーティングの対象としてのみ機能します。
@dynamicMemberLookup
public enum AttributeDynamicLookup {
public subscript<T: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeScopes, T>
) -> T {
fatalError("Unreachable")
}
}
fatalErrorを持つsubscriptが「実行時には絶対に呼び出されない」という点が、この設計の肝です。コンパイラが、キーパスが指すT型の情報を抽出するだけでAttributeDynamicLookupの役割は完了します。これは、実行時のオーバーヘッドなしに型レベルのルーティングを実現する手法です。このルーティングについては、次のセクションでさらに詳しく見ていきます。
AttributeContainer
実際の値を格納する値型です。前述した3つの型が提供する型情報を用いて、安全な読み取り/書き込みを実現します。
@dynamicMemberLookup
public struct AttributeContainer {
public subscript<K: AttributedStringKey>(
dynamicMember keyPath: KeyPath<AttributeDynamicLookup, K>
) -> K.Value? { get set }
}
型の解釈フロー
これら4つの型がどのように連携するかを理解するために、以下のコードのうちcontainer.fontという1行のコードが解釈されるプロセスを追ってみましょう。
var container = AttributeContainer()
container.font = .systemFont(ofSize: 14)
// ^^^^
// このプロパティへのアクセスは、実際にはどのように解釈されるのか?
AttributeContainerにはfontというプロパティがありません。その代わりに@dynamicMemberLookupが適用されているため、コンパイラはsubscript(dynamicMember:)を探します。このsubscriptはKeyPath<AttributeDynamicLookup, K>を引数として受け取ります。
したがって、コンパイラは AttributeDynamicLookup内からfontを探します。AttributeDynamicLookupにもfontプロパティはなく、同様に@dynamicMemberLookupが適用された型なので、再度そのsubscriptを確認します。UIKitはAttributeDynamicLookupにKeyPath<AttributeScopes.UIKitAttributes, K>を受け取る拡張サブスクリプトを宣言しているため、もう一度UIKitAttributes内でfontを探索します。UIKitのスコープにはfontプロパティが存在するため、ここで解釈は完了します。
その結果、コンパイラはKがUIKitAttributes.FontAttributeであると推論します。AttributeContainerのサブスクリプトは、この型から必要な情報を取得し、その値を適切なキー名で格納します。この一連のプロセスはコンパイル時に行われ、実行時には単純な辞書の読み取り/書き込みのみが行われます。
container.font
│
▼ @dynamicMemberLookup
AttributeContainer.subscript(dynamicMember: KeyPath<AttributeDynamicLookup, K>)
│
▼ @dynamicMemberLookup
AttributeDynamicLookup.subscript(dynamicMember: KeyPath<AttributeScopes.UIKitAttributes, K>)
│
▼ プロパティ探索
AttributeScopes.UIKitAttributes.font → UIKitAttributes.FontAttribute
│
▼ 型推論完了
K = UIKitAttributes.FontAttribute, K.Value = UIFont
新しい属性を追加する際はAttributedStringKeyを実装し、スコープにプロパティを追加するだけで、このチェーンに自然に組み込むことができます。既存のコードを修正する必要がなく、拡張可能な構造となっています。
再適用と変形
調査の結果、AttributedString APIの設計には、新しいサービス設定にそのまま適用できる優れた点が数多くありました。ただし、私たちの要件に合わない部分もあったため、型構造はほぼそのまま採用しつつ、それ以外の合わない部分については必要に応じて変更を加えました。
まず、caseがないenumでKeyPathルーティングを実装するための主要な手法や、スコープを用いたキーのグループ化、@dynamicMemberLookupを用いたプロパティアクセス構文までは、同じ構造を採用しました。
| AttributedString | Configuration |
|---|---|
AttributedStringKey | ConfigurationKey |
AttributeScope | ConfigurationScope |
AttributeScopes | ConfigurationScopes |
AttributeDynamicLookup | ConfigurationDynamicLookup |
AttributeContainer | ConfigurationValues |
ScopedAttributeContainer | ScopedConfigurationValues |
ここからは、ドメインの違いによって変更を加えた部分について取り上げます。それぞれの変更が、前述の問題をどのように解決するのかを見ていきましょう。
循環依存の解決:モジュールごとの宣言をサポートする
キーを個別のモジュールに定義できれば、専用の値型も同じモジュールに配置できます。サービス設定システムにはキープロトコルのみが存在し、個別のキーは宣言されないため、逆方向の依存関係は発生しません。
// 1. スコープにキーを定義 — 写真モジュール内で宣言
extension ConfigurationScopes {
struct LinePhotos: ConfigurationScope {
let transferQualityImageMedium: TransferQualityImageMediumKey
enum TransferQualityImageMediumKey: ConfigurationKey {
static let name = "function.media.image_medium"
static let defaultValue: ImageTransferQuality = .standard
}
}
}
// 2. DynamicLookupにsubscriptを追加(スコープ単位で1回のみ)
extension ConfigurationDynamicLookup {
subscript<K: ConfigurationKey>(
dynamicMember keyPath: KeyPath<ConfigurationScopes.LinePhotos, K>
) -> K {
self[K.self]
}
}
service.transferQualityImageMediumにアクセスすると、AttributedStringで分析したものと同様の型解析チェーンが動作します。
service.transferQualityImageMedium
│
▼ ConfigurationService.subscript(dynamicMember: KeyPath<ConfigurationDynamicLookup, K>)
▼ ConfigurationDynamicLookup.subscript(dynamicMember: KeyPath<ConfigurationScopes.LinePhotos, K>)
▼ LinePhotos.transferQualityImageMedium → TransferQualityImageMediumKey
│
▼ K = TransferQualityImageMediumKey, K.Value = ImageTransferQuality
"function.media.image_medium" の値を取得→ decode → ImageTransferQuality
もはや設定モジュールにImageTransferQualityのような値型を宣言する必要はありません。写真モジュールのスコープ内に、キーと値型を一緒に定義すれば良いのです。これで循環依存のジレンマが解決されました。
抽象化問題の解決:キーにデコードをカプセル化する
AttributedStringKeyにはdefaultValue、decode、encodeがありません。AttributeContainerは[ObjectIdentifier: Any]型として値を格納するため、別途デコードすることなくValue型へダウンキャストすればよく、属性がない場合はnilを返せばよいからです。
しかし、サービス設定システムは異なります。サーバーから値が文字列として送信され、キーごとにシリアライズ方式が異なり、サーバーの値がない場合でもnilよりも、デフォルト値を返すほうが便利です。そのため、以下 のようにdecode(from:)とencode(_:)をキープロトコルに含めることで、従来プロパティグループのイニシャライザに記述していたシリアライズの実装を「個別のキー単位」に分離しました。defaultValueは、サーバーの値がない場合のフォールバック(fallback)として機能し、従来の3重のデフォルト値を1つに統合します。
public protocol ConfigurationKey: SendableMetatype {
associatedtype Value: Sendable
static var name: String { get }
static var defaultValue: Value { get }
static func decode(from string: String) throws -> Value
static func encode(_ value: Value) throws -> String?
}
同じデコードロジックを重複して実装しなければならなかった問題は、decode(from:)とencode(_:)メソッドのデフォルト実装を提供することで解決しました。私たちは、デフォルト実装を提供するために、値型を次のように2つのプロトコルに分類しました。
// "Y"/"N"のブール値や数値など、サービス設定独自のエンコーディング規則が適用される値
public protocol ConfigurationValueStringRepresentable {
init?(configurationValueString string: String)
var configurationValueString: String { get }
}
// JSONとしてシリアライズされる複合値
public protocol ConfigurationValueJSONRepresentable {
init(fromConfigurationValueJSON decoder: any Decoder) throws
func encode(toConfigurationValueJSON encoder: any Encoder) throws
}
Boolは ConfigurationValueStringRepresentableに準拠しているため、関連型Valueが Boolであるキーには、"Y"/"N"を正しくtrue、falseにデコードする実装が 自動的に提供されます。そのおかげで、自分でデコードロジックを記述する必要もなく、decodeBoolIfPresent(forKey:)と標準メソッドのどちらを使うか迷う必要もなく、以下のようにキーを宣言するだけで、正しいデコードロジックが使用されます。
enum AlbumLikeEnabledKey: ConfigurationKey {
static let name = "function.album.like.enabled"
static let defaultValue = false
}
スレッド安全性の確保:コンパイラに任せる
AttributeContainerは値型(value type)です。一方、サービス設定システムでは、サーバーとの同期時に値が更新され、多くのユーザーは常に最新の値を読み取りたいと考えます。そのため、設定サービスは参照型(reference type)であるConfigurationServiceプロトコルを中心に設計しました。この点が、AttributeContainerと最も根本的に異なる構造上の違いです。
参照型としてモデリングしたため、従来と同様に並行性の問題が発生しうる状況でしたが、この問題はSwift 6の強力なSendableチェックによって静的に解決しました。具体的には、すべての可変状態をOSAllocatedUnfairLockの内部に隠蔽し、ロック(lock)のwithLock APIを介してのみ状態にアクセスするようにして、可変状態へのアクセスがすべて同期されていることをコンパイラがチェックできるようにしました。その結果、@unchecked属性なしでConfigurationServiceクラスをSendableに準拠させ、利用側でもコンパイラの警告やエラーが発生することなく、複数の並行処理領域から設定値を自由に読み込めるように実装しています。
ロック を使用する中で、「デコードをクリティカルセクション(critical section)内で行うか」という点も興味深いテーマでした。ユーザー定義のdecode(from:)実装が別のロックにアクセスしてデッドロックが発生したり、別の設定値を読み取ろうとして再帰的にロックにアクセスしてクラッシュが発生したりする恐れがあったためです。これを防ぐため、ロック内では文字列の値を取り出すだけに留め、デコードはロックの外で行うように実装しました。
QA問題の解決:オーバーライドをサービス設定システムに組み込む
AttributeContainerは、値があるかないかという2値モデルですが、サービス設定システムでは3つの値が設定可能です。デバッグビルドにおいて、開発者が手動でオーバーライドした値の優先度が最も高く、次にサーバーの値、最後にデフォルト値となります。オーバーライド値はサーバーの値とは別に保存されるため、サーバーが同期されてもオーバーライドは維持されます。
enum ValueSource: CaseIterable {
#if DEBUG_MENU_ENABLED
case overridden // デバッグメニューで手動設定した値
#endif
case server // サーバーから取得した値
case `default` // キーに定義されたデフォルト値
}
ConfigurationKeyプロトコルを介して、デバッグUIを構成するためのメタデータも定義できるように実装しました。
protocol ConfigurationKey {
#if DEBUG_MENU_ENABLED
static var debugTitle: String { get }
static var debugControlType: ConfigurationDebugControlType<Value> { get }
#endif
}
これらの属性には、デフォルト実装が用意されています。設定キーを人 間が読みやすい表示名にするdebugTitleはキーのnameからヒューリスティックに生成され、設定値を変更する際に使用する入力コントロールを決定するdebugControlTypeはBool値に対するトグルなど、関連型Valueに応じて決定されるよう実装しました。
従来は複数のファイルを行き来しながら実装していたデバッグオーバーライド機能が、キーのenumという一箇所に集約され、十分に実用的なデフォルト実装が提供されることで、デバッグメニューの実装に頭を悩ませる必要すらなくなりました。
テスト問題の解決:高品質なテストダブルを提供する
従来は、各モジュールがシングルトンを注入するためのプロトコルやテストダブルを各自で記述していました。新しいシステムでは、ConfigurationServiceプロトコルとともにMockConfigurationServiceが提供されます。
@Test
func `check calculation`() {
let service = MockConfigurationService()
let logic = MyBusinessLogic(configurationService: service)
// writable subscriptで値を注入
service.myValue = 7
#expect(logic.calculate() == 42)
}
テストダブルはプロトコルにはないセッター(setter)を提供するため、テスト内で設定値を自由に設定できます。また、実際の実装と同じ値の解釈ロジック(ValueSourceの優先順位、 decode/encode)を使用します。これにより、プロトコルを直接定義する必要もなく、テストダブルの動作が本番環境と乖離する心配もありません。
おわりに
単にFoundationのアプローチを借用するだけでは、他のドメインの要件を完全に満たすことはできませんでした。最新の値を読み取れるように参照型を使用する必要があったため、並行性について改めて検討する必要がありました。さらに、シリアライズやデバッグメニューなど、ドメイン固有の要件をプロトコルに追加する変更も必要でした。
新しいサービス設定システムは、複数のバージョンにわたり段階的に展開されています。現時点では数十のキー程度ですが、従来のシステムに取って代わって現状の課題を解決し、より大規模な、あるいは全く異なる種類のチャレンジに直面して次の仕組みに移行されるまで、有効に活用されることを願っています。同様の課題を抱えている他のチームにとって、この記事で紹介したアプローチが参考になれば幸いです。


