-
[SE-0538] DisconnectedSwift 2026. 8. 22. 08:38
안녕하세요. 그린입니다 🍏
이번 포스팅에서는 SE-0538 — Disconnected에 대해 정리해보겠습니다 🙋🏻
Intro
Proposal: SE-0538
Author: Franz Busch
Review Manager: Holly Borla
Status: Active review (July 17 - July 31, 2026)
Implementation: swiftlang/swift#89597
SE-0414는 control flow 기반 진단을 활용해서 non-Sendable 값을 isolation 경계 너머로 안전하게 보낼 수 있는지 판단하는 region-based isolation을 도입했어요.
SE-0430은 함수 경계에서 disconnected region에 있어야 하는 값을 명시적으로 표시하는
sending파라미터/리턴 어노테이션을 도입했고요.이 제안은 값의 disconnected 속성을 자료구조나 actor 같은 저장소를 거쳐서도 유지해주는
Disconnected타입을 도입해요.제네릭 타입이
sending이펙트를 신경 쓰지 않고도 non-Sendable 값을 isolation region 너머로 안전하게 옮길 수 있게 해줍니다 🙌Motivation
region-based isolation 덕분에 값이 disconnected region에 있으면 isolation 경계를 넘어 non-Sendable 값을 전달할 수 있어요.
SE-0430의
sending파라미터/리턴 어노테이션은 함수 경계에서 disconnected 값을 명시적으로 요구할 수 있게 해주지만, 저장 프로퍼티나 컬렉션 타입, 제네릭 컨테이너를 거치면sending은 유지되지 않아요.isolation 경계 너머로 처리할 원소를 저장하는 큐 구현을 생각해볼게요.
가상의 UniqueDeque를 예로 들어봅니다.
struct UniqueDeque: ~Copyable { func append(_ element: consuming Element) { ... } func popFirst() -> Element? { ... } }이
UniqueDeque에 non-Sendable한 disconnected 값을 append하고, 나중에 pop할 때 다른 isolation region으로 보내고 싶은 경우가 있을 수 있어요 😁var deque = UniqueDeque() deque.append(NonSendable()) guard let element = deque.popFirst() else { return } Task { print(element) // Error: Element is assumed to be in the same isolation region as uniqueDeque }이걸 되게 하려면
append에서 원소를 consume하고,popFirst에서sending으로 반환해야 해요.하지만 그러면 non-Sendable이지만 disconnected는 아닌 원소를 저장하고 싶은 다른 중요한 용례에서 이 타입이 크게 제약을 받게 됩니다.
근본적인 한계는
sending이 함수 경계의 속성이지 타입의 속성이 아니라는 점이에요.UniqueDeque같은 제네릭 타입은 저장된 원소가 disconnected 상태를 유지해야 하는지를 조건부로 제어할 수 없어요.append와popFirst를sending으로 요구하면 결국 disconnected 값만UniqueDeque에 담을 수 있게 되는 셈입니다.Proposed Solution
disconnected 값을 모델링할 수 있는 새로운 Disconnected 타입을 도입합니다.
var deque = UniqueDeque<Disconnected>() deque.append(Disconnected(NonSendable())) guard var disconnected = deque.popFirst() else { return } let element = disconnected.take() Task { print(element) }Disconnected타입은 값을 wrap해서 disconnected region에 머물도록 보장해줘요.take()메서드는Disconnectedwrapper를 consume하고 값을sending으로 반환해서 isolation 경계를 넘을 수 있게 해줍니다.Detailed Design
Disconnected 타입은 타입 시스템을 통해 region isolation을 강제하는 wrapper예요.
Disconnected 타입 정의
@frozen public struct Disconnected: ~Copyable, Sendable { public init(_ value: consuming sending Value) public consuming func take() -> sending Value @discardableResult public mutating func swap( newValue: consuming sending Value ) -> sending Value public mutating func withValue( body: (inout sending Value) throws(Failure) -> Return ) throws(Failure) -> Return }disconnected region이란?
region-based isolation은 프로그램 실행 중 어느 시점에 존재하는 값들을, 어떤 참조가 어떤 저장소에 도달하는지에 따라 isolation region으로 나눕니다.
어떤 값의 저장소에 다른 region으로부터/다른 region으로의 참조가 전혀 도달하지 않으면, 그 값은 disconnected region에 있는 거예요.
이런 값은 actor, Task, 다른 동시성 컨텍스트 등 다른 isolation region으로 옮겨도 안전한데, 옮기는 행위 자체가 데이터 레이스를 만들 수 없기 때문이에요.
실제로 disconnected region은 보통 다음 중 하나에서 생겨요.
- 초기화 인자 자체가 disconnected였던, 새로 생성된 값
- 다른 disconnected 컨테이너에서 막 꺼낸 값
- 함수 경계의
sending파라미터 (callee가 disconnected region으로 전달받음) - 함수의
sending리턴 값 (caller가 disconnected region으로 전달받음)
disconnected 속성은 보통
sending경계에서만 추적돼요.Disconnected<Value>는 그렇지 않았다면sending경계를 벗어나는 순간 region 정보를 잃어버렸을 저장 경계(제네릭 컨테이너, 저장 프로퍼티, 큐)를 거치면서도 이 속성을 유지할 수 있게 해줘요.연산들
값은
init(_:)을 통해 wrapper 안으로 들어오는데, 이때sending인자가 필요해요.값은
take()나swap(newValue:)를 통해 나가는데, 둘 다sending Value를 반환합니다.withValue(body:)는 wrap된 값을inout sending Value로 받는 클로저에 그 자리에서 빌려줘요.모든 연산은 wrapper를 consume하거나,
sending경계를 통해 wrap된 값을 교체하기 때문에, wrapper의 저장소로 향하는 alias가 transfer보다 오래 살아남을 수 없어요.이 특성 덕분에
Value자체가Sendable인지와 무관하게Disconnected가Sendable을 채택할 수 있는 겁니다.isolation 경계를 넘을 수 있는 값 만들기
take()로 wrap된 값을 꺼낼 수 있어요.이 호출은 wrapper를 consume해서, 그 이후로는 wrapper에 어떤 연산도 할 수 없습니다.
반환된 값은 disconnected region에 있어서, 같은 표현식 안에서 바로 isolation 경계를 넘어 전달하거나 저장해뒀다가 나중에 전달할 수 있어요.
final class Resource: ~Sendable {} // wrapper는 Disconnected 값을 담고 있던 큐 등에서 꺼낸 것이라, // 그 안의 resource는 이미 주변 컨텍스트와 disconnected된 것으로 알려져 있어요. func process(wrapper: consuming Disconnected) async { let resource = wrapper.take() await Task.detached { use(resource) // OK: 'resource'는 disconnected region에 있음 }.value }결과에 disconnected 보장이 없었다면, 캡처된
resource는 caller의 region에 속한 것으로 간주되어 detached task에서의 캡처가 허용되지 않았을 거예요.wrap된 값을 제자리에서 교체하기
swap(newValue:)로 한 번에 값을 새 값으로 교체할 수 있어요.newValue인자는 disconnected region에 있어야 하고,swap은 이전에 저장돼 있던 값을 반환하는데 이 값도 disconnected region에 있습니다.final class Resource: ~Sendable {} func swapResources(in wrapper: inout Disconnected) async { let old = wrapper.swap(newValue: Resource()) await Task.detached { dispose(old) // OK: 'old'는 disconnected region에 있음 }.value }swap의 양방향 모두 disconnected-region 경계를 넘나들어요.
새 값은 들어갈 때 disconnected여야 하고, 예전 값은 나올 때 disconnected로 알려집니다.
꺼내지 않고 wrap된 값 mutate하기
값을 꺼내지 않고 임시로 mutable 접근이 필요하면
withValue(body:)를 쓰면 돼요.클로저는 값을
inout sending Value로 전달받고,withValue는body가 반환한 값을 그대로 반환합니다.var wrapper = Disconnected([Int]()) wrapper.withValue { array in array.append(42) }inout sending파라미터 형태는 보통의inout보다 더 많은 것을 의미해요.클로저 안에서는 값을 다른 isolation region으로 옮길 수 있는데, 단 클로저가 반환될 때 wrapper에는 disconnected 값이 남아 있어야 합니다.
보통은 클로저가 제자리 mutation을 수행하지만, 이 더 관대한 형태 덕분에
withValue는 값을 다른 isolation region으로 보내고 싶어하는 코드와도 조합될 수 있어요.init(_:)
주어진 값을 감싸는 disconnected wrapper를 만들어요.
이 인자는 호출 시점에 disconnected region에 있어야 하는데, alias가 없는 새로 생성된 값이 이 요구사항을 바로 만족시킵니다.
final class Resource: ~Sendable {} let wrapper = Disconnected(Resource())Sendable 채택과 exclusivity
Disconnected타입은 wrap된 값이 disconnected region에 있음을 보장하기 때문에Sendable을 채택해요.disconnected region은 isolation 경계를 안전하게 넘을 수 있기 때문에,
T가Sendable인지와 무관하게Disconnected<T>를 공유하는 게 안전합니다.더불어
Disconnected의 모든 메서드는consuming이거나mutating이라서, 컴파일러가 정적/동적 exclusivity 검사를 강제해서 겹치거나 동시적인 접근을 금지해요.이 API는 의도적으로
sending경계에서의 atomic transfer로만 제한돼요.init은sending값을 consume하고,take는 wrapper를 consume해서sending값을 반환하고,swap은 wrap된 값을 다른sending값과 교환하고,withValue는 클로저가 끝날 때 disconnected 값을 남겨야 하는 조건으로 wrap된 값을inout sending으로 빌려줘요.wrap된 값을 consume하거나 교체하지 않고 노출하는 accessor는 없습니다.
왜 borrow accessor가 무조건적인
Sendable채택과 함께라면 안전하지 않은지는 Alternatives Considered 섹션에서 다룹니다.Disconnected는Mutex,Atomic등 표준 라이브러리가 제공하는 isolation 경계를 넘기 위한 다른 primitive들과 함께Synchronization모듈에 자리합니다.Conclusion
Disconnected는sending이 함수 경계에만 머물던 정보를 저장소를 통과해서도 유지할 수 있게 해줘요.non-Sendable 값을 담는 제네릭 컨테이너나 큐를 만들 때, 그 원소가 disconnected인지 아닌지를 타입 하나로 명확히 구분할 수 있게 된 셈이죠.
init/take/swap/withValue모두sending경계에서의 atomic transfer로만 제한된 설계 덕분에, 무조건적인Sendable채택에도 안전성이 깨지지 않는다는 점이 인상적입니다 🙌References
swift-evolution/proposals/0538-disconnected.md at main · swiftlang/swift-evolution
This maintains proposals for changes and user-visible enhancements to the Swift Programming Language. - swiftlang/swift-evolution
github.com
'Swift' 카테고리의 다른 글
[SE-0537] Section Placement Control for Functions (2) 2026.08.15 [SE-0535] Add CLI for editing global mirrors configuration (0) 2026.08.08 [SE-0527] RigidArray & UniqueArray (1) 2026.08.02 [SE-0525] Safe loading API for RawSpan (1) 2026.07.26 [SE-0532] Optional noncopyable improvements and generalizations (1) 2026.07.18