가장 쉬운 코드 예시
struct User: Sendable {
let name: String
}
let user = User(name: "Hankyu")
Task {
print(user.name)
}`User`는 값 타입이고 내부도 `let`이라 다른 task로 넘겨도 비교적 안전하다. 그래서 이런 형태는 자연스럽다.
`Sendable`은 어떤 값을 동시성 경계를 넘어 전달해도 안전한지 표현하는 규칙이고, `@Sendable`은 클로저가 그런 값만 캡처하도록 요구하는 제약이다. 핵심은 data race를 만들 수 있는 mutable shared state를 경계에서 걸러내는 것이다.
2026-04-09 2026-04-14
`Sendable`은 “이 값을 다른 작업으로 보내도 사고 안 나는가?”를 확인하는 규칙이다. 쉽게 말하면 여러 사람이 동시에 만져도 괜찮은 값만 다른 task로 넘기게 하려는 장치다.
종이 복사본 하나를 다른 사람에게 주는 것은 안전하다. 각자 자기 종이를 보면 된다. 하지만 하나뿐인 리모컨을 여러 사람이 동시에 막 누르면 충돌이 난다. Swift는 이런 충돌 가능성이 있는 값을 task 사이에 함부로 넘기지 못하게 막는다.
struct User: Sendable {
let name: String
}
let user = User(name: "Hankyu")
Task {
print(user.name)
}`User`는 값 타입이고 내부도 `let`이라 다른 task로 넘겨도 비교적 안전하다. 그래서 이런 형태는 자연스럽다.
final class Counter {
var value = 0
}
let counter = Counter()
Task {
counter.value += 1
}`Counter`는 참조 타입이고 내부 `var`가 있다. 다른 task가 같은 객체를 같이 만질 수 있으므로 Swift는 이런 값을 경계 밖으로 보내는 것을 경계한다.
actor ImageStore {
func save() { }
}
let imageStore = ImageStore()
Task {
await withTaskGroup(of: Void.self) { group in
group.addTask { [imageStore] in
await imageStore.save()
}
}
}`addTask` 안 클로저는 `@Sendable` 제약을 받는다. 그래서 바깥 값을 아무거나 막 캡처하지 않고, 필요한 값만 명시적으로 가져오는 방식이 중요하다.
Swift Concurrency를 공부하다 보면 `Capture of 'self' with non-sendable type...`, `TaskGroup.addTask` 에러, `@Sendable` 클로저 제약을 자주 본다. 그런데 많은 경우 `Sendable = 스레드 안전` 정도로만 이해해서 왜 막히는지 놓친다.
흔한 오해는 `Sendable이면 무조건 thread-safe`, `값 타입이면 자동으로 다 Sendable`, `@Sendable은 그냥 클로저에 붙는 키워드` 정도로 생각하는 것이다.
더 정확한 이해는 이렇다. `Sendable`은 “이 값을 다른 동시성 문맥으로 보내도 괜찮다”는 약속이고, `@Sendable`은 “이 클로저가 그런 안전한 값만 캡처해야 한다”는 컴파일러 제약이다.
`Sendable`은 “이 타입이 멋져 보인다”가 아니라, “이 값을 다른 동시성 문맥으로 보내도 shared mutable state 문제가 생기지 않는다”는 약속이다.
참조 타입은 여러 동시성 문맥이 같은 인스턴스를 동시에 볼 수 있기 때문에, 기본적으로는 `shared mutable state` 문제가 생기기 쉽다. 그래서 `class`를 `Sendable`로 보려면 "이 인스턴스는 어디로 보내도 레이스가 나지 않는다"는 근거가 필요하다.
final class UserBox: Sendable {
let id: Int
let name: String
init(id: Int, name: String) {
self.id = id
self.name = name
}
}위처럼 `final class`이고 저장 프로퍼티가 사실상 불변이면 비교적 설명이 쉽다. 반대로 상속이 열려 있으면 subclass가 mutable state를 추가해 약속을 깨뜨릴 수 있으므로, Swift는 non-final class의 `Sendable` 준수를 엄격하게 제한한다.
부모 클래스가 지금은 `let`만 들고 있어도, 상속이 가능하면 자식 클래스가 `var` 프로퍼티를 추가하거나 스레드 안전하지 않은 상태를 넣을 수 있다. 그러면 부모를 `Sendable`이라고 믿고 다른 task로 보냈는데, 실제 인스턴스는 안전하지 않은 subtype일 수 있다.
함수 타입은 `struct User`, `final class Box`처럼 이름 있는 nominal type이 아니다. 예를 들어 `() -> Void`, `(Int) -> String` 같은 형태는 "타입 그 자체"이긴 하지만 여기에 프로토콜 채택 문법을 붙일 수 있는 종류가 아니다.
let work: @Sendable () -> Void = {
print("run")
}그래서 함수 타입의 동시성 안전성은 `Sendable` 채택으로 표현하지 않고, 함수 타입 전용 속성인 `@Sendable`로 표현한다. 즉 `@Sendable`은 "이 클로저가 다른 동시성 경계로 넘어갈 수 있도록 캡처를 검사하라"는 뜻이다.
"Function type은 프로토콜을 준수할 수 없는 참조 타입"이라는 말은, 클로저도 참조처럼 동작하지만 일반 타입처럼 `: Sendable`을 붙여 채택 선언을 할 수는 없다는 뜻이다. 그래서 Swift는 함수 타입에 대해 별도 표기인 `@Sendable`을 둔다.
왜 `final class + let 프로퍼티`가 자주 기준처럼 나오나
참조 타입의 동시성 안전성을 가장 보수적으로 설명하기 쉬운 형태이기 때문이다. 상속이 막혀 있고, 내부 상태가 불변이면 shared mutable state 위험이 크게 줄어든다. 즉 이것은 "쉽게 설명 가능한 Sendable class의 최소 형태"에 가깝다.
actor 내부에 class 프로퍼티가 있으면 actor도 non-Sendable인가
자동으로 그렇게 되지는 않는다. actor는 상태 접근을 직렬화하는 경계를 제공하므로, 내부에 class 참조가 있어도 actor가 그 상태를 캡슐화해서만 다루면 된다. 문제는 그 참조를 actor 밖으로 꺼내 shared mutable state로 쓰는 경우다.
`@Sendable` 클로저가 참조 타입을 캡처하면 무조건 안 되나
무조건은 아니다. 그 참조 타입이 Sendable 규칙을 만족하면 가능하다. 다만 일반 mutable class는 보통 그 조건을 만족하지 못하므로, 실무에서는 자주 막히는 것이다.
`weak self`를 쓰면 `@Sendable` 문제도 같이 해결되나
아니다. `weak self`는 retain cycle을 끊는 메모리 관리 전략이고, `@Sendable`은 동시성 경계를 넘는 캡처 안전성 검사다. 둘은 자주 같이 보이지만 해결하는 문제가 다르다.
함수 타입이 nominal type이 아니라는 말이 왜 중요하나
nominal type이 아니면 일반적인 프로토콜 채택 문법의 대상이 아니다. 그래서 함수 타입은 `: Sendable`로 적는 모델이 아니라, 타입 속성인 `@Sendable`로 제약을 표현하는 별도 문법 체계를 가진다.
`Sendable`은 값을 다른 동시성 문맥으로 넘길 때 안전해야 한다는 개념이다. 여기서 동시성 문맥은 다른 task, 다른 actor, 다른 실행 문맥을 포함한다.
중요한 것은 “이 값이 건너간 뒤에도 원래 문맥과 새 문맥이 동시에 같은 mutable state를 위험하게 만지지 않느냐”다. 그래서 immutable value type은 유리하고, mutable shared class는 불리하다.
struct User: Sendable {
let id: Int
let name: String
}위 예시는 내부 상태가 불변이고 멤버도 안전하므로 `Sendable`로 이해하기 쉽다.
final class Counter {
var value: Int = 0
}이런 class 인스턴스는 참조를 복사해 여러 문맥이 같은 객체를 보게 된다. 그리고 `value`를 동시에 수정할 수 있으면 data race 위험이 생긴다.
그래서 일반 mutable class는 그냥 Sendable하다고 보기 어렵다. 참조 타입은 “복사해도 같은 실체를 본다”는 점 때문에 더 조심해야 한다.
`@Sendable`은 함수 타입, 특히 클로저에 붙는다. 의미는 “이 클로저가 동시성 경계를 넘어 실행될 수 있으므로, 캡처하는 값들도 안전해야 한다”는 것이다.
taskGroup.addTask { [imageStore] in
let image = try await Self.downloadImage(from: url)
await imageStore.save(image, at: index)
}`TaskGroup.addTask`의 클로저는 보통 `@Sendable` 규칙을 따른다. 그래서 여기서 `self` 전체를 암묵적으로 끌고 들어오면 `UIViewController` 같은 non-Sendable 타입 캡처 문제를 만날 수 있다.
`[imageStore]`는 배열이 아니라 capture list다. 이 문법은 “이 클로저는 외부 값 중 imageStore만 명시적으로 캡처한다”는 뜻이다.
taskGroup.addTask {
await imageStore.save(image, at: index) // self 전체 암묵 캡처 위험
}위처럼 쓰면 상황에 따라 `self`가 암묵적으로 끌려와 non-Sendable 캡처 문제를 만들 수 있다. 반면 `[imageStore]`를 쓰면 필요한 값만 명시적으로 가져와 의도를 더 안전하게 표현할 수 있다.
둘은 해결하는 문제가 다르다. `weak self`를 썼다고 자동으로 Sendable 문제가 해결되는 것도 아니고, 반대로 Sendable해도 retain cycle은 별도 문제다.