요약
- AppDelegate, Scene, 딥링크가 같은 초기화를 동시에 부르면 부작용이 두 번 일어나거나 반쯤 준비된 상태가 노출된다.
- actor 안에 실행 중인 Task를 상태로 저장해, 모든 호출자가 같은 작업과 같은 결과를 기다리게 한다.
- 실패 뒤 재시도, 영구 실패, 호출자 취소를 어떻게 처리할지 정하고 테스트로 고정한다.
앱 시작 코드는 흔히 didFinishLaunching 안의 순서도처럼 보인다. 설정을 읽고, 저장소를 열고, 세션을 복원한 다음 첫 화면을 만든다. 이 설명은 시작이 한 번만 호출되고 끝까지 순서대로 실행된다고 전제한다.
실제 앱에는 진입점이 여럿이다. AppDelegate가 기본 초기화를 하는 동안 Scene이 연결될 수 있다. 푸시 알림이나 딥링크가 먼저 와서 인증 상태를 확인하려 할 수도 있다. 백그라운드 작업이나 복구 코드가 같은 의존성을 요청할 수도 있다. 각각이 initialize()를 호출하면 작업이 중복되고, 호출자마다 서로 다른 부분 상태를 보게 된다.
이 글의 코드는 초기화 중복 호출을 설명하려고 공개용으로 다시 만든 예제다. 실서비스 코드나 성능 측정 결과가 아니다. 실행 중인 Task와 완료 결과를 여러 진입점에서 공유하는 방법을 다룬다.
초기화가 겹칠 때 생길 수 있는 문제
가장 알아보기 쉬운 형태는 확인 후 실행하는 코드다.
final class AppBootstrapper {
private var isReady = false
func start() async throws {
guard !isReady else { return }
let configuration = try await loadConfiguration()
try await openDatabase(configuration)
try await restoreSession()
isReady = true
}
}
guard는 동시 호출을 막지 못한다. 두 Task가 모두 isReady == false를 읽고 다음 줄로 진행할 수 있다. 여기서 나타나는 실패는 대체로 세 종류다.
- 중복 부작용: 데이터베이스 마이그레이션, 이벤트 구독, 토큰 갱신이 두 번 수행된다.
- 부분 상태 노출: 설정은 준비됐지만 세션은 아직인 순간을 다른 진입점이 관찰한다.
- 실패 정책 충돌: 한 호출은 성공하고 다른 호출은 실패해, 호출자마다 앱 준비 상태를 다르게 판단한다.
직렬 큐나 actor로 함수를 감싸도 충분하지 않을 수 있다. actor는 저장 프로퍼티 접근을 직렬화하지만, actor 메서드는 await에서 재진입될 수 있다. 첫 호출이 네트워크 응답을 기다리는 동안 두 번째 호출이 actor 안으로 들어와 다시 작업을 시작할 수 있다.
이 예제에서는 이미 시작된 작업을 상태에 저장해, 뒤에 들어온 호출도 같은 작업을 기다리게 한다.
상태를 idle, running, ready로 나누기
초기화 상태는 Boolean 하나로는 부족하다. 세 상태로 나눈다.
idle: 아직 시작하지 않았거나, 재시도 가능한 실패 뒤에 돌아온 상태running(Task): 누군가 시작했고 다른 호출자가 기다려야 하는 상태ready(Services): 완성된 결과를 즉시 재사용할 수 있는 상태
실행 중인 Task를 저장하면 두 번째 호출자는 새 작업을 만들지 않고 같은 결과를 기다릴 수 있다.
import Foundation
struct AppServices: Sendable, Equatable {
let sessionID: String?
}
actor StartupCoordinator {
typealias Builder = @Sendable () async throws -> AppServices
private enum State {
case idle
case running(Task<AppServices, Error>)
case ready(AppServices)
}
private var state: State = .idle
private let build: Builder
init(build: @escaping Builder) {
self.build = build
}
func start() async throws -> AppServices {
switch state {
case .ready(let services):
return services
case .running(let task):
return try await task.value
case .idle:
let task = Task { try await build() }
state = .running(task)
do {
let services = try await task.value
state = .ready(services)
return services
} catch {
state = .idle
throw error
}
}
}
}
Task를 만들면 첫 await보다 먼저 .running(task)를 기록해야 한다. actor는 첫 await까지 다른 호출을 끼우지 않고 이어서 실행한다. 이후 재진입한 호출은 .running을 보고 같은 Task의 값을 기다린다.
성공한 결과도 상태에 저장한다. 준비가 끝난 뒤 들어온 딥링크는 초기화를 다시 실행하지 않고 같은 AppServices를 받는다.
실패 뒤에 어떤 상태로 돌아갈 것인가
예제는 모든 오류에서 .idle로 돌아간다. 그래서 다음 호출이 재시도할 수 있다. 하지만 설정 파일 손상처럼 재시도로 해결되지 않는 오류에는 별도의 종료 상태가 필요하다. 예제 저장소의 RecoverableStartupCoordinator는 영구 실패를 판정하는 분류기와 failed 상태를 더한다.
/// Decides whether a build error is worth retrying on the next call.
public typealias FailureClassifier = @Sendable (any Error) -> Bool
private enum State {
case idle
case running(Task<Value, any Error>)
case ready(Value)
case failed(any Error)
}
오류를 구분하지 않으면 복구할 수 없는 작업을 계속 재시도하거나, 재시도하면 될 오류를 영구적으로 저장할 수 있다. 앱에 적용할 때는 다음 항목도 정해야 한다.
| 질문 | 가능한 정책 |
|---|---|
| 네트워크 실패 뒤 재시도할까? | .idle로 복귀하고 사용자 동작이나 백오프로 재시도 |
| 로컬 데이터 형식이 지원되지 않으면? | .failed로 고정하고 복구 UI 제공 |
| 한 호출자가 취소되면 공유 작업도 취소할까? | 보통은 공유 작업을 유지하고 해당 대기만 종료 |
| 로그아웃 뒤 다시 만들 수 있어야 할까? | 명시적 reset()과 세대(generation) 구분 도입. 세대는 초기화를 새로 시작할 때마다 바뀌는 식별자다 |
위 코드는 호출자 취소를 처리하지 않는다. 공유 작업을 같이 취소하지도, 그 호출자의 대기만 끝내지도 않는다. 앱 전체에 필요한 초기화를 화면 전환과 함께 취소할지, 화면이 사라져도 유지할지는 별도로 정해야 한다. 작업이 화면, Scene, 앱 프로세스 중 어디까지 필요한지에 따라 선택이 달라진다.
필요한 값을 모두 만든 뒤 반환하기
StartupCoordinator가 안전해도 build()가 전역 싱글턴을 단계별로 바꾸면 부분 상태가 다시 노출된다.
// 피하고 싶은 형태
GlobalContainer.shared.configuration = configuration
GlobalContainer.shared.database = database
GlobalContainer.shared.session = session
대신 지역 변수에서 모든 의존성을 만든 뒤 마지막에 하나의 값으로 공개한다.
func buildServices() async throws -> AppServices {
let configuration = try await loadConfiguration()
let database = try await openDatabase(using: configuration)
let session = try await restoreSession(from: database)
// 필요한 값이 모두 준비된 뒤 한 번에 반환한다.
return AppServices(sessionID: session?.id)
}
이 구조에서는 필요한 작업이 모두 끝난 뒤에만 AppServices를 반환한다. 다만 데이터베이스 변경이나 이벤트 구독처럼 이미 발생한 부작용을 되돌리지는 않는다. 실패 후 재시도를 허용하려면 각 작업의 중복 실행과 정리 방법도 확인해야 한다.
진입점은 조정기만 호출한다
AppDelegate, SceneDelegate, 딥링크 처리기가 각각 초기화 상세를 알면 시간이 지나며 순서가 다시 갈라진다. 진입점은 같은 조정기를 호출하고, 결과를 받은 뒤의 화면 전환만 맡는다.
@MainActor
final class AppRouter {
private let startup: StartupCoordinator
init(startup: StartupCoordinator) {
self.startup = startup
}
func handle(_ route: AppRoute) async {
do {
let services = try await startup.start()
present(route, using: services)
} catch {
presentRecovery(for: error)
}
}
}
각 진입점이 같은 StartupCoordinator 인스턴스를 사용하면 실행 중인 초기화와 성공 결과를 공유할 수 있다.
테스트: 중복 실행과 재시도 확인하기
동시성 테스트에서 특정 실행 순서를 가정하면 결과가 불안정해질 수 있다. 여기서는 호출 횟수와 반환값을 기준으로 다음 동작을 확인한다.
- 동시에 여러 번 호출해도 builder는 한 번만 실행된다.
- 모든 호출자는 같은 결과를 받는다.
- 성공 뒤 호출은 builder를 다시 실행하지 않는다.
- 일시적 실패 뒤 다음 호출은 재시도한다.
- 영구 실패 정책이 있다면 같은 오류 상태를 유지한다.
Swift Testing으로 여러 호출의 반환값과 builder 실행 횟수를 확인하는 기본 예시는 다음과 같다.
import Foundation
import Testing
actor CallCounter {
private(set) var value = 0
func increment() {
value += 1
}
}
@Test
func concurrentStartsShareOneBuild() async throws {
let counter = CallCounter()
let expected = AppServices(sessionID: "fixture-session")
let coordinator = StartupCoordinator {
await counter.increment()
try await Task.sleep(for: .milliseconds(20))
return expected
}
async let first = coordinator.start()
async let second = coordinator.start()
async let third = coordinator.start()
let results = try await [first, second, third]
#expect(results == [expected, expected, expected])
#expect(await counter.value == 1)
}
20ms를 기다린다고 모든 호출이 .running 상태에서 겹친다는 보장은 없다. 이 분기를 확실히 검사하려면 테스트용 신호로 builder 완료를 늦추고, 다른 호출자가 실행 중인 Task를 기다리는 것을 확인한 뒤 신호를 해제해야 한다.
재시도는 첫 실행만 실패하는 builder로 확인할 수 있다.
actor AttemptPlan {
private var attempt = 0
func next() throws -> AppServices {
attempt += 1
if attempt == 1 {
throw URLError(.timedOut)
}
return AppServices(sessionID: nil)
}
}
@Test
func transientFailureReturnsToIdle() async throws {
let plan = AttemptPlan()
let coordinator = StartupCoordinator {
try await plan.next()
}
await #expect(throws: URLError.self) {
try await coordinator.start()
}
let services = try await coordinator.start()
#expect(services == AppServices(sessionID: nil))
}
단위 테스트만으로 실제 생명주기 결합을 모두 확인할 수는 없다. 통합 테스트에서는 다음 시나리오를 별도로 만든다.
- 콜드 스타트 직후 딥링크 전달
- Scene 재연결과 세션 복원 동시 발생
- 초기화 중 백그라운드로 전환한 뒤 포그라운드로 복귀
- 일시적 네트워크 실패 뒤 사용자 재시도
테스트 로그에 호출 횟수와 상태 전이를 남기면 문제를 추적하기 쉽다. reset()과 세대 구분을 추가했다면 generation 식별자도 기록한다. 민감한 세션 값이나 내부 URL은 기록하지 않는다.
정리
- AppDelegate, Scene, 딥링크 같은 진입점이 모두 같은 조정기를 호출하게 한다.
running(Task)를 상태로 저장해 중복 작업을 합친다.- 모든 의존성을 지역에서 완성한 뒤 하나의 값으로 공개한다.
- 실패 후 재시도와 호출자 취소를 어떻게 처리할지 정한다.
공유 범위는 조정기 인스턴스 하나다. 로그아웃이나 계정 변경으로 초기화를 다시 해야 한다면 결과를 언제 폐기할지도 함께 정해야 한다.
이 글의 코드와 테스트는 examples/ios-startup-idempotent-initialization에 있고, CI가 실행한다. 같은 폴더의 RecoverableStartupCoordinator는 이 조정기에 일시적 실패와 영구 실패를 구분하는 분류기와 로그아웃 뒤 결과를 폐기하는 reset()을 더했다. 동시 호출·재시도·영구 실패·reset() 중 취소를 확인하는 테스트도 함께 있다.