개발? 새발!
개발? 새발!

Swift 6 Sendable 오류, 경계부터 고치는 단계별 전환법

2026.09.09

Swift 6 전환에서 Sendable 오류를 줄이는 가장 안전한 순서는 ‘경고를 한꺼번에 없애기’가 아니라 값이 넘어가는 동시성 경계를 먼저 찾고, 불변 값은 Sendable로 만들고, UI는 @MainActor에 두고, 공유 가변 상태는 actor로 보호하는 것입니다. 외부 라이브러리에는 @preconcurrency를 임시로 적용할 수 있지만, @unchecked Sendable을 붙여 컴파일만 통과시키는 방식은 마지막 예외로 남겨야 해요.

네이비 배경과 민트 연결선으로 구성한 코드 노트형 도해. Swift 6 동시성 경계 진단에서 시작해 불변 전달 값은 Sendable, UI 전용 상태는 MainActor, 공유 가변 상태는 actor로 분류하는 흐름을 보여준다.

전환 범위를 작은 타깃으로 자르기

2026년 9월 기준 공식 마이그레이션 문서에서 Swift 6 언어 모드는 타깃별로 선택할 수 있습니다. 컴파일러 버전과 언어 모드도 별개예요. 따라서 앱과 모든 패키지를 동시에 Swift 6 모드로 바꾸기보다 의존성이 적은 내부 모듈 하나부터 검사하는 편이 수정 원인을 추적하기 쉽습니다.

먼저 기존 Swift 언어 모드에서 엄격한 동시성 검사를 Complete로 켜 경고 목록을 확보하세요. Xcode에서는 타깃의 Strict Concurrency Checking을 Complete로 설정하고, Swift Package라면 지원하는 도구 버전에 맞춰 StrictConcurrency 예정 기능을 활성화할 수 있습니다. 경고를 정리한 뒤 해당 타깃만 Swift 6 언어 모드로 전환해 오류가 다시 나타나는지 확인합니다.

목록은 메시지 문구보다 경계별로 묶는 것이 유용해요. Task나 @Sendable 클로저의 캡처, actor 또는 @MainActor 호출, 전역 가변 상태, 외부 모듈 타입 순으로 분류하면 같은 원인을 여러 번 고치는 일을 줄일 수 있습니다.

오류가 가리키는 경계를 먼저 읽기

Sendable은 ‘이 값을 복사하면 안전하다’는 뜻만은 아닙니다. 서로 동시에 실행될 수 있는 격리 영역 사이로 값을 전달해도 데이터 경쟁을 만들지 않는다는 계약이에요. Swift 6은 task, actor, 전역 actor 같은 경계를 넘는 지점에서 그 계약을 확인합니다.

예를 들어 sending 'draft' risks causing data races라는 진단이 나오면 draft 타입부터 무조건 고치지 마세요. 누가 draft를 보낸 뒤에도 사용하고 있는지, 내부에 클래스나 가변 참조가 들어 있는지, 받는 함수가 @MainActor 또는 다른 actor에 격리됐는지를 함께 봐야 합니다. Swift 6의 영역 기반 격리는 이후 접근이 없음을 증명할 수 있는 일부 비-Sendable 값의 이동도 허용하지만, 별칭이 남아 있으면 여전히 막습니다.

여기서 결정 질문은 세 가지면 충분해요. 값 자체가 불변인가, 변경은 한 실행 영역에서만 일어나야 하는가, 여러 작업이 공유해야 하는가? 답이 각각 Sendable 값 타입, @MainActor 같은 격리, actor 또는 명시적 동기화로 이어집니다.

네이비 배경과 민트 연결선으로 구성한 코드 노트형 도해. Swift 6 동시성 경계 진단에서 시작해 불변 전달 값은 Sendable, UI 전용 상태는 MainActor, 공유 가변 상태는 actor로 분류하는 흐름을 보여준다.

불변 데이터는 Sendable 값으로 만들기

화면이나 작업 사이에 전달하는 데이터가 값 타입이고 저장 프로퍼티도 모두 Sendable이라면 명시적으로 계약을 선언할 수 있습니다.

struct Draft: Sendable {
let id: UUID
let text: String
}

공개 구조체는 라이브러리의 향후 저장 프로퍼티까지 포함하는 API 계약이 되므로 명시적 채택이 특히 중요합니다. 제네릭 컨테이너라면 struct Box<Value: Sendable>: Sendable처럼 내부 값에도 제약을 전달해야 해요.

클로저 오류도 같은 원리입니다. @Sendable 클로저가 변경 가능한 var나 비-Sendable 클래스를 캡처했다면 실행 시점까지 그 값이 달라질 수 있습니다. 필요한 데이터만 let snapshot = model.title처럼 값으로 복사해 캡처하거나, 원래 객체의 사용을 적절한 actor 메서드 안으로 옮기세요. 캡처 목록을 추가했다는 사실만으로 참조 타입이 안전해지는 것은 아닙니다.

클래스의 검사 가능한 Sendable 채택은 범위가 좁습니다. 공식 정의상 final이고, 저장 상태가 불변이며 Sendable이고, 허용된 상속 조건을 충족해야 합니다. 상태를 계속 바꿔야 한다면 억지로 클래스에 Sendable을 붙이기보다 다음 단계의 격리가 더 잘 맞습니다.

UI와 공유 상태를 서로 다른 격리에 두기

UI 상태는 @MainActor로 의도를 드러내는 편이 자연스럽습니다.

@MainActor
final class EditorViewModel {
private(set) var title = ""
func apply(_ draft: Draft) { title = draft.text }
}

다른 격리 영역의 async 코드에서 apply를 부르면 await가 필요합니다. 기존 코드가 메인 큐에서 실행된다는 주석이나 DispatchQueue.main 호출만 갖고 있다면, 컴파일러는 타입 수준의 격리 계약을 충분히 알지 못할 수 있어요.

여러 백그라운드 작업이 함께 갱신하는 저장소는 actor가 후보입니다.

actor DraftStore {
private var drafts: [UUID: Draft] = [:]
func save(_ draft: Draft) { drafts[draft.id] = draft }
func load(_ id: UUID) -> Draft? { drafts[id] }
}

actor는 자신의 가변 상태 접근을 직렬화하며 암시적으로 Sendable입니다. 불러온 Draft는 Sendable 값이므로 store 경계에서 UI의 MainActor 경계로 전달하기도 명확해집니다. 반대로 모든 서비스를 @MainActor로 덮으면 오류는 줄어 보여도 불필요한 메인 actor 작업과 결합이 생길 수 있으니, 실제 소유 상태를 기준으로 격리를 정하세요.

외부 모듈과 예외는 유효기간을 붙이기

수정할 수 없는 라이브러리 타입 때문에 막히면 @preconcurrency import LegacyKit으로 관련 진단을 완화할 수 있습니다. 공식 가이드에 따르면 Swift 6 언어 모드에서는 해당 오류가 경고로 내려갈 수 있지만, 타입이 실제로 Sendable이 되는 것은 아닙니다. 의존성의 동시성 지원 버전과 변경 기록을 확인하고, 제거할 조건을 이슈에 남기는 임시 호환 장치로 써야 해요.

값을 받은 쪽으로 완전히 넘기고 원래 쪽에서 다시 사용하지 않는 API라면 sending 매개변수도 검토할 수 있습니다. 예를 들어 func submit(_ draft: sending LegacyDraft) async처럼 계약하면 컴파일러가 전달 이후 접근을 제한해 안전한 이동을 검사합니다. 단순 공유가 아니라 소유 영역을 옮기는 API에 어울립니다.

@unchecked Sendable은 내부 잠금이나 다른 동기화 규칙으로 안전성을 직접 보증할 수 있을 때만 선택합니다. 모든 가변 프로퍼티가 같은 잠금으로 보호되는지, 콜백 중 재진입이 가능한지, 참조가 외부로 새는지까지 감사해야 합니다. 근거를 설명할 수 없다면 actor로 감싸거나 Sendable 스냅숏을 만들어 전달하는 쪽이 낫습니다.

마지막 검증은 오류 개수보다 경로로 보기

수정 후에는 Complete 검사와 Swift 6 모드 빌드를 모두 다시 실행하고, 각 타깃의 경고가 새로 생기지 않았는지 확인하세요. 이어서 동시 저장, 취소 직후 UI 갱신, 빠른 화면 진입과 이탈처럼 실행 순서가 달라지는 테스트를 반복합니다. 컴파일 검사는 데이터 경쟁 가능성을 줄여 주지만 업무 로직의 순서 오류나 취소 처리까지 자동으로 보장하지는 않습니다.

리뷰 체크리스트도 짧게 고정할 수 있어요. Sendable 선언의 모든 저장 값이 안전한가, @MainActor가 정말 UI 소유권을 나타내는가, actor 밖으로 가변 참조가 유출되지 않는가, @preconcurrency와 @unchecked에 제거 조건 또는 안전성 설명이 있는가를 확인합니다. 마지막으로 한 타깃을 Swift 6 모드에서 깨끗하게 만든 뒤 의존 방향을 따라 다음 타깃으로 넓히면 됩니다.

첫 작업으로 경고 하나를 골라 ‘보내는 쪽 → 넘어가는 값 → 받는 격리 → 전달 이후 사용’ 네 지점을 적어 보세요. 그 한 경로가 불변 데이터인지, UI 전용 상태인지, 공유 가변 상태인지 분류하면 필요한 수정은 대개 Sendable, @MainActor, actor 중 하나로 좁혀집니다. 임시 완화 표시는 별도 목록으로 관리하고 다음 타깃 전환 전에 다시 감사하는 것이 좋습니다.

이 블로그의 다음 이야기도 받아보세요

새 글 구독 (RSS)