원문 캡처 · blog.kulman.sk
원문 캡처 · blog.kulman.sk

Swift가 async/await를 발표했을 때 정말 안도했다. 완료 핸들러가 피라미드처럼 쌓이고 델리게이트 체인이 이어지는 코드, 작성한 순서와 전혀 다른 순서로 실행되는 코드를 읽을 때의 인지 부담에서 벗어날 방법이 생길 것 같았다.

몇 년 전 C#의 async/await를 사용한 경험이 있어 내 머릿속에는 그때의 작동 방식이 자리 잡고 있었다. UI 앱에 SynchronizationContext가 있으면 Task를 await할 때 그 컨텍스트가 저장되고, 이어서 실행할 코드는 다시 그곳에 전달된다. 실제로는 UI 계층에서 무언가를 await한 뒤 UI 스레드에서 작업을 이어갈 수 있다는 뜻이었다. 보통 라이브러리 코드에서처럼 그 동작을 원하지 않을 때는 .ConfigureAwait(false)를 사용해 명시적으로 빠져나왔다.

이것이 .NET 비동기 프로그래밍의 전부는 아니다. 콘솔 앱과 ASP.NET Core, 사용자 지정 스케줄러에는 각자 다른 세부 사항이 있다. 하지만 내가 작성하던 UI 코드에서는 기본 동작이 적절했다. 달리 지정하지 않는 한 시작한 곳에 머무르는 방식이었다.

그 경험이 Swift의 async/await에 대한 기대를 만들었다. 더 편리하게 기다리는 방법을 기대했지만, 실제로 얻은 것은 훨씬 큰 것이었다.

Swift가 실제로 제공한 것

Swift의 동시성은 async/await만을 뜻하지 않는다. 액터(actor), 격리 영역(isolation domain), Sendable, 그리고 어떤 컨텍스트의 코드가 어떤 데이터에 접근할 수 있는지를 정하는 컴파일러 규칙을 아우르는 완전한 동시성 모델이다.

목표는 데이터 경쟁을 막는 것이다. Swift 6에서는 Swift 6 언어 모드에서 잠재적인 데이터 경쟁을 컴파일러 오류로 처리하면서 이 목표가 훨씬 두드러졌다. 완전한 엄격 동시성 검사를 켰을 때 이전에는 경고와 함께 컴파일되던 코드도 이제는 문제를 해결해야 한다.

오류 중 일부는 실제 버그를 가리킨다. Sendable이 아닌 상태를 동시 실행 컨텍스트 사이에서 전달하는 일은 실제로 문제가 될 수 있다. 액터 격리 상태에 잘못된 위치에서 접근하는 일도 마찬가지다. 컴파일러가 이런 사례를 잡아내는 것은 가치가 있다.

하지만 많은 iOS 코드는 서버나 동시성이 높은 시스템처럼 작성되지 않는다. 대부분 UI를 중심으로 돌아간다. 코드에서 명시하지 않더라도 앱 상태의 상당수는 메인 액터에 있다. 백그라운드 작업도 있지만 대개 범위가 좁고 잘 분리돼 있다. 데이터를 가져오고, 디코딩하고, 디스크에 기록한 다음 UI로 돌아오는 식이다.

그런 앱에서 내가 겪던 문제는 공유 상태를 동시에 변경하는 일이 아니었다. 읽기 어려운 제어 흐름이었다.

Swift는 시스템 프로그래밍 언어다운 철저함으로 데이터 경쟁 문제를 해결했고, 그 과정에서 모든 앱 개발자가 개념적 비용을 치르게 했다.

기본값의 중요성

그래서 C#과의 차이가 여전히 중요하게 느껴진다. 내가 작성하던 UI 코드에서는 안전하고 명확한 동작이 기본값이었다. 무언가를 await한 뒤 UI 컨텍스트에서 계속 실행했다. 그 컨텍스트를 저장하지 않으려면 .ConfigureAwait(false)를 명시해야 했다.

Swift는 다른 지점에서 출발했다. SE-0338에서 의미가 명확해진 규칙에 따르면, 액터에 격리되지 않은 비동기 함수는 형식상 일반 실행기(generic executor)에서 실행된다. 메인 액터의 코드처럼 액터 격리 코드에서 이런 함수를 호출하면 함수가 해당 액터를 벗어날 수 있다. 호출이 끝나면 액터 격리 상태인 호출자는 자신의 액터에서 다시 실행된다.

Swift 모델을 이해하고 나면 일관된 규칙이다. 하지만 내가 원한 것이 async/await뿐이었다면 신경 쓰지 않아도 될 줄 알았던 바로 그런 종류의 규칙이기도 하다.

Swift 6.2에서는 이 동작이 개선됐다. NonisolatedNonsendingByDefault라는 향후 기능으로 제공된 SE-0461은 액터에 격리되지 않은 비동기 함수가 기본적으로 호출자의 격리 상태에서 실행되도록 바꾼다. Swift 6.2에는 비동기 함수가 호출자의 액터를 벗어나 동시 실행되도록 명시하는 방법으로 @concurrent도 도입됐다.

이는 Swift를 처음 기대했던 모델, 즉 떠나라고 요청하지 않는 한 현재 위치에 머무는 방식에 더 가깝게 만든다. 반가운 변화지만, 애초에 이런 변경이 필요했다는 점은 원래 기본값에 대해 생각할 거리를 남긴다.

Swift 6.2에는 모듈이 기본적으로 @MainActor 격리를 추론하도록 하는 또 다른 기능인 SE-0466도 있다. UI 앱과 스크립트에 유용한 기능이다. 다만 “Approachable Concurrency”와는 별도의 설정이다. 이제 같은 코드도 컴파일러 설정, 언어 모드, 향후 기능 플래그, 대상 유형, 모듈 경계에 따라 다르게 동작할 수 있다는 점이 중요하다.

비동기 함수가 어디서 실행되는지 이해하는 데 알아야 할 것이 너무 많다.

복잡성은 곳곳으로 번진다

어려운 점은 특정 키워드 하나가 아니다. @MainActor, nonisolated, Sendable, @unchecked Sendable, nonisolated(unsafe), nonisolated(nonsending), @concurrent는 배울 수 있다. 문제는 이 개념들이 서로 영향을 준다는 데 있다.

한 타입에 Sendable을 추가하면 모델 계층의 나머지 부분에도 연쇄적으로 요구 사항이 생기는 경우가 많다. 뷰 모델을 @MainActor로 만들면 프로토콜 준수 방식에 영향을 줄 수 있다. 겉보기에는 단순한 프로토콜도 격리 개념이 들어오면 준수하기 어려워질 수 있다. 한 모듈에서는 문제가 없는 타입이 동시성 주석이 다른 모듈에서 가져오면 다르게 동작할 수도 있다.

이 점이 내가 원했던 async/await와 가장 거리가 멀게 느껴진다. 제어 흐름은 더 읽기 좋아졌지만, 타입 시스템에는 경계마다 두 번째 의미 계층이 생겼다. 이 값을 여기로 넘겨도 되는가? 이 함수는 격리돼 있는가? 이 준수 관계에도 격리가 적용되는가? 이 클로저에 @Sendable이 필요한가? 이 경고는 실제 버그인가, 모델링 문제인가, 아니면 컴파일러가 보수적으로 판단한 결과인가?

답은 있다. 문제는 애초에 이런 질문을 해야 한다는 점이다.

밥 나이스트롬(Bob Nystrom)은 “What Color is Your Function?”이라는 글에서 비동기의 기본 문제를 설명했다. 함수가 비동기가 되면 그 특성이 호출 스택을 따라 위로 번지는 경향이 있다는 것이다.

Swift에는 그 문제에 더해 색이 더 늘어난다. 함수는 동기 또는 비동기일 뿐 아니라 액터 격리 또는 비격리 상태일 수도 있다. Sendable 값을 요구하거나 거부할 수 있다. 주석과 빌드 설정에 따라 메인 액터, 다른 액터, 호출자의 액터 또는 일반 실행기에서 실행될 수 있다.

동시성이 높은 시스템이라면 적절한 절충일 수 있다. 하지만 대부분의 iOS 앱에서는 사실상 단일 스레드로 동작하던 코드 주변에 추가적인 개념 부담을 만든다.

절충의 대가

Swift의 동시성 모델이 나쁘다고 생각하지는 않는다. 컴파일 시점에 데이터 경쟁을 막는 것은 중요한 성과다. 실제 격리 버그를 컴파일러가 잡아내는 편이 프로덕션에서 드물게 발생하는 충돌을 발견하는 것보다 낫다.

다만 비용은 고르게 분배되지 않는다. 복잡한 동시성 시스템을 만드는 개발자는 가장 큰 이점을 얻는다. 반면 단순한 UI 앱을 만드는 개발자도 마이그레이션 비용과 지속적인 인지 부담을 치러야 한다. 그 대가로 얻는 보장이 애초에 필요하다고 생각하지 않았던 경우도 많다.

Swift 6.2의 “Approachable Concurrency” 작업은 올바른 방향으로 나아가는 조치다. 간단한 앱은 간단한 상태로 두고, 필요할 때만 완전한 모델을 도입하자는 생각은 옳다.

하지만 복잡성이 사라지는 것은 아니다. 결국 그 모든 것을 배워야 한다. 6.2는 그 시점을 조금 미뤄줄 뿐이다.

콜백 형태의 코드를 그만 쓰고 싶어서 async/await를 원했다. 그 바람은 이뤘지만, 액터와 격리, 전송 가능성, 컴파일러 모드를 계속 능숙하게 다뤄야 하는 타입 시스템 기반 동시성 모델도 함께 얻었다.

Swift 전체를 놓고 보면 정당한 절충일 수도 있다. 하지만 내가 주로 만드는 종류의 iOS 앱에서는 해결하려던 문제보다 훨씬 큰 변화처럼 느껴진다.

관련 글: