Swift 6 언어 모드가 Sendable 검사를 경고에서 에러로 승격시키면서, 실제로 걸려 넘어지는 지점은 새로 짠 코드가 아니라 오래전부터 있던 Foundation 의존 코드다. actor·Task·async/await 자체의 기초 문법은 Swift Concurrency 로드맵과 Swift Concurrency 인터뷰 대비 강의가 이미 다루므로 여기서는 되풀이하지 않는다. 이 챕터는 정확히 한 가지에 집중한다 — Foundation의 참조 타입 클러스터와 strict concurrency가 실제로 충돌하는 지점이다. 어떤 Foundation 타입이 진짜 Sendable인지는 추측 대신 컴파일러에게 직접 물어서 확인하고, 레거시 코드를 옮길 때 실제로 통하는 순서를 짚는다.
Q1. 값 타입은 Sendable이 되기 쉽고 참조 타입은 어려운 이유를 NSMutableArray로 설명하라
값 타입은 대입될 때마다 필드가 통째로 복사되므로, 저장 프로퍼티가 전부 Sendable이면 컴파일러가 Sendable 합성을 자동으로 붙여준다 — 각 격리 도메인이 자기만의 독립된 사본을 갖기 때문에 애초에 "공유"라는 개념이 없다. 참조 타입은 대입해도 힙 인스턴스를 가리키는 포인터만 복사되므로, 그 인스턴스가 가변이면 두 도메인이 같은 메모리를 동시에 두드리는 진짜 데이터 레이스가 가능하다. NSMutableArray가 정확히 그 경우이고, Swift 6 모드에서 실제로 컴파일해보면 conformance of 'NSArray' to 'Sendable' is unavailable 에러가 난다.
원리
Swift의 SE-0302가 정의한 Sendable 합성 규칙은 값 타입과 참조 타입을 근본적으로 다르게 취급한다. struct/enum은 저장 프로퍼티가 전부 Sendable이면 아무 어노테이션 없이 자동으로 Sendable이 된다. class는 그 반대로 절대 자동 합성되지 않는다 — final이고 저장 프로퍼티가 전부 let이며 그 타입들이 각각 Sendable이어도, : Sendable을 직접 써주지 않으면 합성되지 않는다. 명시적으로 선언해도 final이 아닌 클래스는 거부된다.
내부 동작
이 비대칭은 직접 컴파일해보면 정확히 확인된다. 클래스는 조건을 다 채워도 저절로 합성되지 않는다.
final class ImmutablePoint {
let x: Int
let y: Int
init(x: Int, y: Int) { self.x = x; self.y = y }
}
func needsSendable<T: Sendable>(_ v: T) {}
needsSendable(ImmutablePoint(x: 1, y: 2))
// error: type 'ImmutablePoint' does not conform to the 'Sendable' protocol: Sendable을 직접 붙이면 컴파일러가 final 여부와 저장 프로퍼티를 검사해 통과시킨다. 대신 non-final 클래스에 똑같이 붙이면 정확히 이런 에러가 난다.
class OpenPoint: Sendable {
let x: Int = 0
}
// error: non-final class 'OpenPoint' cannot conform to 'Sendable';
// use '@unchecked Sendable'이 마지막 줄이 실무에서 벌어지는 일의 근원이다. 컴파일러가 non-final 클래스에게 제안하는 유일한 출구가 @unchecked Sendable이라, 상속 가능한 클래스를 Sendable로 만들고 싶으면 사실상 검사를 끄라는 요구를 받는 셈이다(자세한 위험은 Q2). NSMutableArray는 애초에 그 경계 밖에 있다 — Foundation의 Swift 오버레이가 NSArray의 Sendable 준수 자체를 명시적으로 막아뒀다.
let arr = NSMutableArray()
needsSendable(arr)
// error: conformance of 'NSArray' to 'Sendable' is unavailable
// note: conformance of 'NSArray' to 'Sendable' has been explicitly
// marked unavailable here
// 1 | @_nonSendable(_assumed)주목할 점은 에러가 NSMutableArray가 아니라 NSArray를 가리킨다는 것이다. "불변인 NSArray는 그럼 Sendable이겠지"라고 짐작하기 쉽지만 실제로는 아니다 — Swift는 정적 타입만 보고, 어떤 NSArray 변수에 실제로는 NSMutableArray 인스턴스가 들어 있을 수 있다는 걸 구분할 방법이 없다. 그래서 클래스 클러스터 전체를 한꺼번에 비Sendable로 막아둔 것이다. Apple은 프레임워크 전체를 일괄 처리하지 않고 타입 하나하나에 이 마킹을 직접 붙인다 — 겉보기엔 불변처럼 보이는 클래스도 내부에 지연 계산 캐시 같은 숨은 가변 상태를 가질 수 있어서, "이름이 immutable스러우니 안전할 것"이라는 추측 자체가 근거가 없기 때문이다.
실험 · 도구
이 챕터의 모든 Sendable 판정은 짐작이 아니라 아래 패턴으로 직접 확인한 것이다. 제네릭 함수 하나로 원하는 타입을 Sendable 경계에 통과시켜보면 된다.
func check<T: Sendable>(_ x: T) {}
func testAll() {
check(Date()) // 통과
check(NSMutableArray()) // 에러
}swiftc -swift-version 6 -typecheck 파일.swift로 실행하면 실제 진단 메시지를 그대로 볼 수 있다. Xcode에서는 프로젝트의 Swift Language Mode를 6으로 올린 뒤 어떤 줄에서 빨간 줄이 뜨는지 확인하는 것과 동일하다.
프로젝트 적용
① 격리 경계를 넘겨야 하는 데이터는 애초에 값 타입으로 모델링한다. NSMutableArray/NSMutableDictionary를 프로퍼티로 감싼 타입은 그 프로퍼티 하나 때문에 전체가 Sendable이 될 수 없다 — struct의 Sendable 합성은 all-or-nothing이다.
// ❌ NSMutableArray 프로퍼티가 있으면 struct 전체가 Sendable 합성에서 빠진다
struct BadConfig {
var tags: NSMutableArray
}
// ✅ [String]은 Sendable 원소를 담은 Sendable 값 타입 — 자동 합성된다
struct GoodConfig: Sendable {
var tags: [String]
}② 이미 NSCache처럼 참조 타입을 써야 하는 레거시 API를 감싸고 있다면, 경계를 넘기기 직전에 Sendable한 값으로 스냅샷을 떠서 넘긴다. 참조 자체를 넘기지 않는 것이 핵심이다.
func snapshot(of array: NSMutableArray) -> [String] {
array.compactMap { $0 as? String }
}
func upload(_ array: NSMutableArray) {
let tags = snapshot(of: array) // 여기서부터는 순수 Swift Array, Sendable
Task {
await send(tags) // 참조가 아니라 사본이 경계를 넘는다
}
}"NSArray는 불변이니까 당연히 Sendable일 것"은 틀렸다 — 실측 결과도 그렇고, 그 이유도 정확하다. Swift 타입 체커는 NSArray 변수 뒤에 실제로 NSMutableArray 인스턴스가 숨어 있을 가능성을 배제할 수 없어서, 클래스 이름의 "불변스러움"과 무관하게 클러스터 전체를 비Sendable로 막는다. 반대로 "클래스는 절대 Sendable이 될 수 없다"도 틀렸다 — final이고 저장 프로퍼티가 전부 불변 Sendable이면 명시적으로 Sendable을 채택할 수 있다(Q3에서 실제로 그런 클래스들을 확인한다).
값 타입은 사진을 인화해서 나눠 갖는 것과 같다 — 각자 자기 사진을 들고 있으니 누가 낙서해도 남의 사진은 멀쩡하다. 참조 타입은 화이트보드 하나를 여러 팀이 같이 쓰는 것과 같다 — 동시에 낙서하면 서로의 글씨를 지워버릴 수 있다. 비유가 깨지는 곳: 화이트보드가 아무리 "유리판이라 안 지워진다"고 적혀 있어도(NSArray라는 이름), Swift는 그 유리판 뒤에 몰래 지우개가 달려 있을 가능성(실제로는 NSMutableArray)을 사람이 아니라 타입만으로는 확인할 수 없어서, 이름을 믿지 않고 아예 전체를 조심하는 쪽을 택한다.
꼬리 질문
final class에 저장 프로퍼티가 전부 let이고 Sendable 타입이면 자동으로 Sendable이 되는가?
: Sendable을 명시하지 않는 한 조건을 다 채워도 합성되지 않는다 — struct/enum과 달리 class는 자동 합성 대상이 아니다. 직접 final class Foo: Sendable이라고 써야 컴파일러가 그때 조건(final, 저장 프로퍼티가 전부 let이고 Sendable)을 검사해 통과시킨다.: Sendable)를 내야 심사해주는 창구다.구조체 안에 NSMutableArray 프로퍼티가 하나라도 있으면 그 구조체는 Sendable이 될 수 있는가?
[String] 등)으로 바꾸거나, 구조체 밖에서 스냅샷을 떠서 넘겨야 한다.NSArray는 Sendable이 아닌데 순수 Swift [String]은 왜 Sendable인가?
[String]은 Swift 네이티브 값 타입이라 대입 시 실제로(혹은 CoW로 지연되어) 독립된 사본이 만들어지고 원소인 String도 Sendable이라 자동 합성된다. NSArray는 ObjC 클래스이고 대입해도 포인터만 복사되는 참조 타입이라 애초에 카테고리가 다르다. 둘 사이를 브리징으로 오갈 때는 그 변환 지점에서 실제 복사가 일어나야 Sendable의 이점을 그대로 가져올 수 있다.Q2. `@unchecked Sendable`을 상속 계층에 붙이면 왜 위험한가?
@unchecked Sendable은 "이 타입은 안전하다는 걸 컴파일러 대신 내가 확인했다"는 사람의 약속이다. 문제는 이 약속이 non-final 클래스에 붙으면 아직 등장하지 않은 모든 서브클래스에도 그대로 상속된다는 점이다. 서브클래스가 가변 저장 프로퍼티를 추가해도 Swift는 그 안전성을 재검증하지 않는다 — 컴파일러가 주는 건 "conformance를 다시 써라"는 형식적 경고뿐이고, 그걸 그대로 다시 써넣어도 실제 검사는 여전히 일어나지 않는다. final 클래스 + 내부 락은 사람이 감사할 수 있는 범위가 그 클래스 하나로 닫혀 있어 방어 가능하지만, 상속 가능한(open) 클래스에 붙이는 @unchecked Sendable은 함정이다.
원리
Sendable이라는 프로토콜 자체가 요구하는 건 "이 타입의 인스턴스를 여러 격리 도메인이 동시에 들고 있어도 데이터 레이스가 나지 않는다"는 컴파일 타임 계약이다. 컴파일러는 보통 이 계약을 저장 프로퍼티를 재귀적으로 훑어 스스로 증명한다. @unchecked는 그 증명 과정을 통째로 건너뛰고 "증명은 내가 코드 밖에서 이미 했다"고 사람이 서명하는 것이다. 서명한 사람이 실제로 확인한 범위는 그 순간 그 타입의 코드뿐인데, 상속이 가능한 타입이면 그 서명이 미래의, 아직 쓰이지도 않은 서브클래스에까지 자동으로 적용된다는 게 구조적 문제다.
내부 동작
직접 컴파일해서 확인하면 이 구멍이 정확히 어디서 열리는지 보인다.
class BaseSettings: @unchecked Sendable {
let immutableFlag: Bool = true // 베이스는 진짜 불변이라 안전해 "보인다"
}
final class MutableSettings: BaseSettings {
var cachedValue: Int = 0 // 가변 저장 프로퍼티 추가
}
// warning: class 'MutableSettings' must restate inherited
// '@unchecked Sendable' conformance이 경고 문구를 그대로 믿으면 "다시 쓰라니까 다시 쓰면 안전해지는구나" 착각하기 쉽다. 하지만 실제로 요구되는 건 final class MutableSettings: BaseSettings, @unchecked Sendable처럼 같은 문구를 한 번 더 타이핑하는 것뿐이다. 그 순간 컴파일러가 cachedValue가 정말 스레드 세이프하게 접근되는지 다시 검사해주는 게 아니다 — 경고를 지우는 행위와 실제로 안전한 코드를 만드는 행위가 완전히 분리돼 있다. 게다가 이 취약점은 정적 타입 뒤에 숨는다.
func crossIsolation(_ s: BaseSettings) {
Task { print(s) } // 컴파일 통과 — 인자로 MutableSettings가 와도 경고 없음
}
crossIsolation(MutableSettings()) // cachedValue를 가진 인스턴스가 그대로 넘어간다실험 · 도구
위 스니펫을 그대로 파일로 저장해 swiftc -swift-version 6 -typecheck로 돌려보면 경고가 정확히 어느 줄에서 나는지 확인할 수 있다. 그다음 MutableSettings 선언에 , @unchecked Sendable을 그대로 추가해 다시 컴파일해보면, 경고는 사라지지만 cachedValue에 동기화 코드를 단 줄도 추가하지 않았다는 걸 직접 눈으로 확인할 수 있다 — "경고가 없다"와 "안전하다"가 다른 말이라는 걸 체감하는 가장 빠른 방법이다.
프로젝트 적용
① 방어 가능한 패턴은 final 클래스로 상속 가능성 자체를 없애고, 모든 가변 상태 접근을 락 하나로 직렬화하는 것이다. 감사해야 할 코드가 그 클래스 하나로 닫혀 있어서 사람이 실제로 검토할 수 있다.
final class SafeCounter: @unchecked Sendable {
private let lock = NSLock()
private var _value = 0
var value: Int {
lock.lock(); defer { lock.unlock() }
return _value
}
func increment() {
lock.lock(); defer { lock.unlock() }
_value += 1
}
}② "경고를 지우려고 붙였다"는 게 실제 목적이라면 애초에 @unchecked를 쓰지 말고 actor로 격리 자체를 위임한다. actor는 컴파일러가 접근을 강제로 직렬화해주므로 사람이 락을 빠뜨릴 여지가 없다.
// ❌ 나쁜 예 — 경고를 지우려고만 붙였다, 동기화 코드가 없다
final class ImageLoader: @unchecked Sendable {
var cache: [String: Data] = [:] // 그대로 두면 진짜 레이스
}
// ✅ 좋은 예 — actor가 접근을 강제로 직렬화한다
actor ImageLoader {
var cache: [String: Data] = [:]
}"@unchecked Sendable을 붙이면 그 타입은 안전해진다"는 틀렸다 — 컴파일러의 자동 검증을 끄는 스위치일 뿐, 실제 스레드 안전성을 만들어주는 코드는 한 줄도 추가되지 않는다. 반대로 "@unchecked Sendable은 항상 위험한 안티패턴"이라는 것도 과도한 일반화다. final 클래스 안에서 모든 가변 상태가 락으로 보호되는 패턴은 표준 라이브러리와 Foundation 내부에도 흔히 쓰이는 정당한 기법이다. 진짜 위험 요소는 @unchecked 자체가 아니라 상속 가능한(non-final) 클래스에 그것을 붙이는 조합이다.
"이 방은 안전합니다"라고 붙인 표지판을 생각하면 된다. 원래 방 주인(베이스 클래스)이 정말 아무것도 안 바뀌는 텅 빈 방이라 표지판을 붙였는데, 나중에 누가 그 방을 물려받아(서브클래스) 몰래 가구를 들여놔도 표지판은 그대로 붙어 있다. 비유가 깨지는 곳: 현실에서는 가구를 들여놓으면 안전 점검을 다시 받아야 한다는 규칙이 있지만, Swift 컴파일러는 "표지판 문구를 다시 써 붙여라"라는 형식적 요구만 하지 방이 진짜 안전한지는 다시 확인하지 않는다 — 그 판단은 여전히 사람 몫으로 남는다.
꼬리 질문
final class + NSLock 조합은 왜 @unchecked Sendable을 붙여도 방어 가능하다고 보는가?
actor 대신 굳이 @unchecked Sendable + 락을 쓰는 이유가 있는가?
restate 경고에 맞춰 서브클래스에도 그냥 @unchecked Sendable을 다시 써넣으면 그걸로 충분한가?
Q3. Foundation 타입 중 무엇이 Sendable로 표시됐고 무엇이 아닌가?
이 목록은 기억해서 답할 게 아니라 그때그때 컴파일러에게 물어야 하는 것이다 — SDK가 바뀔 때마다 갱신될 수 있기 때문이다. Swift 6.2.1 / macOS 26 SDK에서 실제로 컴파일해 확인한 결과, Date/Data/URL/UUID/Calendar/Locale처럼 swift-foundation에서 값 타입으로 재작성된 타입들과, 놀랍게도 DateFormatter/NotificationCenter/Bundle 같은 일부 클래스까지 Sendable이다. 반면 NSMutableArray/NSMutableDictionary/NSCache/UserDefaults/FileManager/NSAttributedString은 여전히 아니다.
원리
패턴은 대체로 14장에서 다룬 swift-foundation 재작성 축과 겹친다. 재작성으로 순수 값 타입이 된 것들(Date/Data/URL/UUID/Calendar/Locale, 그리고 Swift 네이티브로 새로 생긴 AttributedString)은 Q1의 원리 그대로 자동 또는 검증된 Sendable이다. 반면 NSCache/UserDefaults/FileManager/NSAttributedString 같은 오래된 참조 타입 클러스터는 여전히 클래스이고 Q1에서 본 @_nonSendable(_assumed) 마킹이 그대로 남아 있다. 다만 이 원리만으로는 설명 안 되는 예외가 있다 — DateFormatter, NotificationCenter, Bundle은 명백히 참조 타입(클래스)인데도 Sendable로 표시돼 있다.
내부 동작
직접 컴파일해서 16개 타입을 한 번에 확인한 결과는 아래 표와 같다. 제네릭 함수 하나로 각 타입을 Sendable 경계에 밀어 넣고 어떤 줄에서 에러가 나는지 본 것이다.
| Foundation 타입 | Sendable 여부 | 근거 |
|---|---|---|
Date | Sendable | swift-foundation 값 타입 |
Data | Sendable | 값 타입, CoW 버퍼 |
URL | Sendable | 값 타입 |
UUID | Sendable | 값 타입 |
Calendar | Sendable | 값 타입으로 재작성(내부 참조는 CoW로 은닉) |
Locale | Sendable | 값 타입 |
NSMutableArray | 비Sendable | conformance of 'NSArray' to 'Sendable' is unavailable |
NSMutableDictionary | 비Sendable | conformance of 'NSDictionary' to 'Sendable' is unavailable |
DateFormatter | Sendable | 클래스지만 명시적으로 마킹됨(아래 참고) |
NSCache | 비Sendable | conformance ... is unavailable in macOS |
UserDefaults | 비Sendable | conformance of 'UserDefaults' to 'Sendable' is unavailable |
NotificationCenter | Sendable | 클래스지만 명시적으로 마킹됨 |
FileManager | 비Sendable | conformance of 'FileManager' to 'Sendable' is unavailable |
Bundle | Sendable | 클래스지만 명시적으로 마킹됨 |
NSAttributedString | 비Sendable | conformance ... is unavailable in macOS |
AttributedString | Sendable | Swift 네이티브 값 타입 |
Swift 6.2.1 / macOS 26 SDK 기준 실측이며, Foundation 어노테이션은 SDK 릴리스마다 바뀔 수 있다. 이 표를 암기하지 말고 아래 실험 코드로 그때그때 다시 확인하는 습관이 이 문항의 진짜 요점이다. DateFormatter/NotificationCenter/Bundle이 클래스인데도 Sendable인 이유의 정확한 내부 근거는 공개돼 있지 않지만, 공통점은 있다 — 셋 다 오래전부터 "설정을 끝낸 뒤에는 여러 스레드에서 동시에 읽어도 안전하다"고 애플이 문서로 보장해온 API라는 점이다. 다만 이 Sendable 마킹이 동시 mutate까지 안전하다는 뜻은 아니다 — DateFormatter의 dateFormat을 어떤 스레드가 포맷팅 중인 인스턴스에서 바꾸는 건 여전히 경합이다. Sendable은 "경계를 넘겨 들고 있어도 되는가"를 보장할 뿐, "동시에 고쳐도 되는가"는 별개 질문이다.
실험 · 도구
16개 타입을 각각 따로 컴파일하지 말고, 한 파일에 전부 몰아넣고 한 번에 컴파일하면 어떤 줄이 에러인지 목록으로 바로 얻을 수 있다.
import Foundation
func check<T: Sendable>(_ x: T) {}
func testAll() {
check(Date())
check(Data())
check(URL(string: "https://a.com")!)
check(UUID())
check(Calendar.current)
check(Locale.current)
check(NSMutableArray())
check(NSMutableDictionary())
check(DateFormatter())
check(NSCache<NSString, NSObject>())
check(UserDefaults.standard)
check(NotificationCenter.default)
check(FileManager.default)
check(Bundle.main)
check(NSAttributedString(string: "x"))
check(AttributedString("x"))
}swiftc -swift-version 6 -typecheck 파일.swift 2>&1 | grep error로 돌리면 위 표와 같은 6개 에러 줄만 걸러진다. SDK를 올릴 때마다 이 파일을 다시 돌려보는 것 자체가 회귀 테스트가 된다.
프로젝트 적용
① 격리 경계를 넘겨야 하는 데이터를 고를 때 이 표의 왼쪽(Sendable) 타입으로 옮겨 담고, 오른쪽(비Sendable) 타입은 actor나 @MainActor 경계 안쪽에 가둬둔다.
actor ImageDiskCache {
private let cache = NSCache<NSString, NSData>()
func store(_ data: Data, for key: String) {
cache.setObject(data as NSData, forKey: key as NSString)
}
// 밖으로 나가는 건 Sendable한 Data뿐, NSCache 참조 자체는 절대 밖으로 새지 않는다
func load(for key: String) -> Data? {
cache.object(forKey: key as NSString) as Data?
}
}② 이 표를 문서에 박제해두지 말고, 위 testAll() 스니펫을 프로젝트 안에 작은 진단용 파일로 남겨서 SDK가 바뀔 때마다 실제로 다시 컴파일해본다. 표는 "2026년 7월 Swift 6.2.1 기준"이라는 유효기간이 있는 관측 결과일 뿐이다.
"Foundation이 Swift로 재작성됐으니 이제 거의 다 Sendable이다"는 틀렸다 — NSCache/UserDefaults/FileManager처럼 재작성 이후에도 여전히 참조 타입인 클러스터는 그대로 비Sendable이다. "클래스는 무조건 비Sendable이다"도 틀렸다 — DateFormatter/NotificationCenter/Bundle이 반례다. 이 두 오해가 겹치면 "표시된 대로 다 믿어도 된다"는 세 번째 오해로 이어지기 쉬운데, Sendable 마킹은 "경계를 넘겨도 되는가"만 보장하지 "동시에 고쳐도 되는가"는 보장하지 않는다는 것도 함께 기억해야 한다.
택배 상자에 붙은 "이 방향으로만 놓으세요" 스티커를 생각하면 된다. 스티커가 있으면 어느 트럭에 실어도 되지만(경계를 넘겨도 됨), 스티커가 "내용물을 아무렇게나 흔들어도 된다"는 뜻은 아니다(동시에 마구 고쳐도 된다는 뜻은 아님). 비유가 깨지는 곳: 진짜 택배 스티커는 한 번 붙으면 안 바뀌지만, 이 스티커는 애플이 새 SDK를 낼 때마다 다시 붙이거나 뗄 수 있어서, 작년에 확인한 표를 올해도 그대로 믿으면 안 된다.
꼬리 질문
이 표는 다음 Xcode/SDK 버전에서도 그대로 유지된다고 보장할 수 있는가?
Sendable로 표시된 DateFormatter를 여러 스레드에서 동시에 mutate해도 되는가?
dateFormat)를 동시에 바꿔도 안전하다는 뜻이 아니다. Sendable은 전달의 안전을, 그 이후 동시 mutate의 안전은 별개의 동기화 문제다.AttributedString은 Sendable인데 NSAttributedString은 아니다 — 실무에서 이게 뭘 뜻하는가?
AttributedString 쪽으로 옮기는 편이 구조적으로 더 단순해진다는 신호다. NSAttributedString에 남아 있으면 경계를 넘길 때마다 값으로 복사하거나 락으로 보호하는 코드를 직접 짜야 하지만, AttributedString은 그 비용을 타입 자체가 흡수한다.Q4. Foundation 기반 레거시 코드를 strict concurrency로 옮길 때 무엇부터 손대야 하는가?
프로젝트 전체에 Swift 6 언어 모드를 한 번에 강제하지 않는다. -strict-concurrency 검사 수준을 minimal → targeted → complete 순으로 모듈 단위로 점진적으로 올리고, @MainActor는 뿌리는 게 아니라 UI 경계에만 두고, 공유 가변 싱글턴은 actor나 @MainActor 타입으로 옮기고, DispatchQueue 직렬화는 정말 이득이 있을 때만 actor로 바꾸고, 아직 못 옮긴 의존성은 @preconcurrency import로 명시적인 부채로 남겨 추적한다.
원리
SE-0337이 이 점진적 전환의 근거다 — Swift 6 언어 모드로의 전환과 strict concurrency 검사 수준은 원래 하나의 스위치가 아니라, 모듈(타깃) 단위로 독립적으로 조절 가능한 다이얼로 설계됐다. 그래서 "전체 앱을 한 번에 Swift 6로 올린다"는 접근은 애초에 이 기능이 상정한 사용법이 아니다. 오래된 대형 프로젝트일수록 리프(leaf) 모듈부터 순서대로 올리는 편이 실패 지점을 국소화하기 쉽다.
내부 동작
실제로 통하는 순서는 대략 다섯 단계다.
① 검사 수준을 모듈 단위로 점진 적용한다. 빌드 설정(또는 컴파일러 플래그 -strict-concurrency=minimal|targeted|complete)을 타깃마다 따로 올릴 수 있다. 의존성 그래프의 리프(다른 걸 참조하지 않는 모듈)부터 complete로 올리고, 그 모듈이 깨끗해지면 그걸 참조하는 다음 모듈로 넘어간다. 앱 타깃 하나를 통째로 올리면 에러가 수백 개 쏟아져 원인을 특정하기 어렵다.
② @MainActor를 뿌리지 않는다. 컴파일 에러를 지우려고 아무 타입에나 @MainActor를 붙이면 당장은 통과하지만, 그 타입을 쓰는 모든 코드가 메인 스레드에 묶여버려 원래 백그라운드에서 돌아야 했던 작업까지 메인 액터로 끌려온다. @MainActor는 실제로 UI를 그리거나 UIKit/AppKit API를 건드리는 경계에만 최소한으로 남긴다.
③ 공유 가변 싱글턴을 actor나 @MainActor 타입으로 옮긴다. static let shared로 앱 전역에서 참조하는 가변 클래스가 strict concurrency에서 가장 먼저 에러를 뿜는 지점이다. UI와 무관한 상태(캐시, 세션 매니저 등)는 actor로, UI 상태를 들고 있는 싱글턴은 @MainActor로 옮긴다.
④ DispatchQueue 직렬화를 actor로 바꾸는 건 실익이 있을 때만 한다. "동시성 코드는 다 actor로"라는 기계적 치환은 재진입성 차이(actor는 await 지점마다 다른 태스크가 끼어들 수 있다) 때문에 기존 큐 기반 코드의 암묵적 원자성 가정을 깨뜨릴 수 있다. 큐 하나로 상태를 보호하던 코드가 잘 동작하고 있었다면, 굳이 급하게 바꾸지 않는다.
⑤ @preconcurrency import는 추적하는 부채로 남긴다. 아직 Sendable 감사를 안 마친 서드파티/사내 모듈은 @preconcurrency import ModuleName으로 그 모듈에서 오는 타입의 경고를 잠시 낮춰서 마이그레이션을 막지 않게 하되, 그 import 목록 자체를 이슈 트래커에 남겨 "언젠가 지워야 할 항목"으로 취급한다. 영구적인 회피 수단으로 남겨두면 안 된다.
정말로 불변인 전역 변수(설정값, 상수 룩업 테이블 등)에는 SE-0412가 도입한 nonisolated(unsafe)가 실용적인 정답일 때가 있다. actor로 감싸기엔 과하고 실제로 런타임에 값이 바뀌지 않는다는 걸 사람이 확신할 수 있는 전역에 한해서다.
// 앱 시작 시 한 번만 설정되고 이후 절대 바뀌지 않는다는 게 보장될 때
nonisolated(unsafe) let sharedFeatureFlags: [String: Bool] = loadFeatureFlags()
// @preconcurrency로 아직 감사 못 한 의존성의 경고를 명시적으로 낮춘다
@preconcurrency import LegacyAnalyticsSDK실험 · 도구
Xcode에서는 타깃의 Build Settings에서 Swift Language Mode를 6으로, 또는 SWIFT_STRICT_CONCURRENCY를 targeted로 먼저 올려보고 에러 개수를 세어본다. 한 타깃을 complete까지 올려 에러가 0이 되면 그 타깃을 참조하는 다음 타깃으로 넘어가는 식으로, 의존성 그래프를 따라 리프에서 루트 방향으로 진행 상황을 체크리스트로 관리하면 진행률이 눈에 보인다.
프로젝트 적용
① 마이그레이션 순서를 실제 의존성 그래프에 매핑해 어떤 모듈부터 올릴지 정한다. Tuist/SPM으로 모듈화된 iOS 프로젝트라면 Core류의 리프 모듈이 먼저다.
// CoreNetworking (리프 모듈) — 다른 사내 모듈을 참조하지 않음
// 가장 먼저 complete로 올린다
// FeatureProfile (CoreNetworking에 의존)
// CoreNetworking이 깨끗해진 뒤에 targeted로 시작② 가변 싱글턴을 actor로 옮길 때, 기존 동기 API를 호출하던 곳까지 한꺼번에 async로 바꾸지 말고 우선 컴파일이 통과하는 최소 변경으로 옮긴 뒤, 실제 호출부는 팀 일정에 맞춰 순차적으로 정리한다.
// ❌ 이전: 가변 상태를 가진 전역 싱글턴, strict concurrency에서 즉시 에러
final class SessionStore {
static let shared = SessionStore()
var token: String?
}
// ✅ 이후: actor로 옮기면 컴파일러가 접근을 직렬화해준다
actor SessionStore {
static let shared = SessionStore()
var token: String?
}
// 호출부는 await session.token 처럼 하나씩 고쳐나가면 된다"Swift 6 마이그레이션은 프로젝트 설정에서 언어 모드 하나만 6으로 바꾸면 끝"은 틀렸다 — 언어 모드와 strict concurrency 검사 수준은 모듈 단위로 별도로 조절되고, 실제 작업 대부분은 설정 변경이 아니라 위 다섯 단계처럼 코드 구조를 바꾸는 일이다. "일단 다 @unchecked Sendable이나 nonisolated(unsafe)로 덮으면 빠르게 끝난다"도 틀렸다 — Q2에서 본 것처럼 그건 경고를 지우는 것이지 실제 안전을 만드는 게 아니고, 나중에 진짜 버그를 추적할 실마리마저 지워버린다.
낡은 집 전체를 하루 만에 리모델링하려 하지 않고, 방 하나씩 순서대로 고치는 것과 같다. 배선(모듈 의존성)이 방마다 연결돼 있어서, 안쪽 방(리프 모듈)부터 손봐야 바깥 방을 고칠 때 이미 고친 배선을 다시 뜯을 일이 없다. 비유가 깨지는 곳: 집 리모델링은 방을 안 쓰는 동안만 하면 되지만, 이 마이그레이션은 앱을 계속 배포하면서 진행해야 해서 "공사 중인 방"과 "이미 완성된 방"이 한 앱 안에 계속 같이 존재한다.
꼬리 질문
@preconcurrency import를 붙이면 그 모듈에서 오는 타입들이 진짜로 안전해지는가?
@preconcurrency import는 그 모듈이 아직 Swift 6 검사를 통과하지 않았다는 걸 인정하고, 그로부터 오는 타입에 대한 경고를 임시로 낮춰 마이그레이션이 막히지 않게 해주는 장치일 뿐이다. 실제 안전성은 그 모듈 자체가 나중에 Sendable 감사를 받아야 확보된다 — 그전까지는 명시적으로 표시된 부채로 남아 있는 것이다.DispatchQueue로 잘 동작하던 코드를 actor로 무조건 바꾸면 왜 문제가 될 수 있는가?
sync 블록이나 직렬 큐는 한 작업이 끝날 때까지 다른 작업이 절대 끼어들지 않는 원자성을 암묵적으로 보장했지만, actor의 메서드는 내부에 await가 있으면 그 지점에서 다른 태스크가 끼어들 수 있는 재진입(reentrancy) 지점이 생긴다. 큐 기반 코드가 "이 블록 안에서는 상태가 절대 안 바뀐다"는 가정에 기대고 있었다면, 기계적으로 actor로 바꾸는 순간 그 가정이 깨지는 새 버그가 생길 수 있다.nonisolated(unsafe)와 @unchecked Sendable은 같은 위험을 갖는가?
@unchecked Sendable은 타입 전체의 Sendable 준수를 통째로 우회하는 것이고, nonisolated(unsafe)는 전역/정적 변수 하나의 격리를 개별적으로 우회하는 더 좁은 도구다. 좁을수록 사람이 감사해야 할 범위도 좁아지지만, 그 변수가 정말 불변이라는 전제가 깨지면 여전히 같은 종류의 데이터 레이스가 난다는 근본 위험은 동일하다.