일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- actor
- 작업 취소
- StateObject
- MainActor
- ObservedObject
- 앱실행
- assosiated type
- SwiftUI
- weak
- Swift
- environment value
- REDRAW
- navigationview
- Access Control
- async/await
- unowned
- 격리 시스템
- Swift Concurrency
- 동시성 프로그래밍
- NavigationLink
- 순환참조
- rest api
- 스레드 점유권
- MVVM
- Git
- git 명령어
- environment object
- restful api
- task 취소
- swfitui
- Today
- Total
Develup
[Swift] Sendable 프로토콜: 안전한 동시성을 위한 완벽 가이드 본문
Swift의 동시성 모델에서 가장 중요한 구성 요소 중 하나는 바로 Sendable 프로토콜입니다. 이 프로토콜은 Swift 5.5에서 도입되었으며, 동시성 코드에서 데이터를 안전하게 공유하기 위한 핵심 메커니즘을 제공합니다. 하지만 많은 개발자들이 Sendable의 중요성과 적절한 사용법을 완전히 이해하지 못하고 있습니다. 이 글에서는 Sendable 프로토콜의 목적, 작동 방식, 그리고 실제 코드에서 어떻게 활용할 수 있는지 자세히 알아보겠습니다.
Sendable 프로토콜이란?
Sendable 프로토콜은 값이 동시성 도메인 간에 안전하게 전달될 수 있음을 나타내는 마커 프로토콜입니다. 여기서 동시성 도메인이란 태스크나 액터와 같은 동시성 컨텍스트를 의미합니다.
public protocol Sendable {
// 구현해야 할 요구사항이 없는 마커 프로토콜
}
Sendable 프로토콜은 실제로 구현해야 할 메서드나 프로퍼티가 없습니다. 대신, 컴파일러에게 "이 타입은 동시성 경계를 안전하게 넘을 수 있다"라고 알려주는 역할을 합니다. 이를 통해 컴파일러는 동시성 코드에서 발생할 수 있는 데이터 레이스와 같은 문제를 컴파일 타임에 감지할 수 있습니다.
Sendable이 필요한 이유
동시성 프로그래밍의 가장 큰 문제 중 하나는 여러 스레드에서 동시에 같은 데이터에 접근할 때 발생하는 데이터 레이스입니다. 이러한 문제는 디버깅이 매우 어렵고, 간헐적으로 발생하기 때문에 찾아내기도 쉽지 않습니다.
Swift의 Sendable 프로토콜은 이러한 문제를 미연에 방지하기 위해 설계되었습니다. 타입이 Sendable을 준수한다는 것은 해당 타입의 값이 다른 태스크나 액터로 안전하게 전달될 수 있음을 보장합니다. 이는 다음 중 하나의 조건을 충족함으로써 가능합니다:
- 값 타입(구조체, 열거형)으로, 내부의 모든 저장 프로퍼티도 Sendable을 준수
- 불변(immutable) 클래스로, 모든 저장 프로퍼티가 불변이며 Sendable 준수
- @MainActor로 마크된 클래스나 다른 동기화 메커니즘을 사용하는 클래스
- 내부적으로 동시성 안전성을 보장하는 표준 라이브러리 타입(예: Array, Dictionary 등)
언제 Sendable을 사용해야 하는가?
Sendable은 주로 다음과 같은 상황에서 사용됩니다:
- 액터 간에 데이터를 전달할 때
- 비동기 함수에 데이터를 전달할 때
- 태스크 간에 데이터를 공유할 때
- @MainActor로 마크된 코드와 일반 코드 사이에 데이터를 주고받을 때
실제로 Swift의 동시성 API들은 Sendable 타입을 매개변수로 요구하는 경우가 많습니다. 예를 들어, Task 이니셜라이저는 클로저 내에서 캡처되는 모든 값이 Sendable을 준수해야 합니다.
기본 타입과 Sendable
Swift의 많은 기본 타입들은 이미 Sendable을 암시적으로 준수합니다:
- 기본 값 타입: Int, Double, String, Bool 등
- 컬렉션 타입: Array, Dictionary, Set 등 (요소가 Sendable인 경우)
- 옵셔널: 래핑된 타입이 Sendable인 경우
- 튜플: 모든 요소가 Sendable인 경우
다음은 이러한 기본 타입들이 어떻게 Sendable로 동작하는지 보여주는 예시입니다:
func processConcurrently(data: [String]) async {
// String은 Sendable이므로 비동기 태스크에 안전하게 전달됨
await withTaskGroup(of: Void.self) { group in
for item in data {
group.addTask {
await processItem(item)
}
}
}
}
func processItem(_ item: String) async {
// 처리 로직
}
위 코드에서 String 타입은 Sendable을 준수하므로, 각 태스크에 안전하게 전달될 수 있습니다.
사용자 정의 타입에 Sendable 적용하기
구조체와 열거형
구조체와 열거형은 모든 저장 프로퍼티나 연관 값이 Sendable을 준수한다면 쉽게 Sendable을 채택할 수 있습니다:
struct UserProfile: Sendable {
let id: UUID
let name: String
let age: Int
let preferences: [String: Bool]
// 모든 프로퍼티가 Sendable이므로 이 구조체도 Sendable
}
enum Result<Success: Sendable, Failure: Error & Sendable>: Sendable {
case success(Success)
case failure(Failure)
// 연관 값이 모두 Sendable이므로 이 열거형도 Sendable
}
위 코드에서 UserProfile 구조체의 모든 프로퍼티(UUID, String, Int, [String: Bool])는 Sendable을 준수하므로, 이 구조체 자체도 Sendable이 될 수 있습니다.
클래스와 Sendable
클래스는 구조체나 열거형보다 Sendable을 적용하기가 더 까다롭습니다. 클래스는 참조 타입이기 때문에, 여러 태스크나 액터가 동시에 같은 인스턴스를 수정할 수 있어 데이터 레이스의 위험이 있습니다.
클래스를 Sendable로 만들기 위한 주요 방법들은 다음과 같습니다:
1. 불변(final) 클래스 사용
가장 간단한 방법은 클래스를 final로 선언하고 모든 프로퍼티를 불변(let)으로 만드는 것입니다:
final class ImmutableConfig: Sendable {
let serverURL: URL
let apiKey: String
let maxRetries: Int
init(serverURL: URL, apiKey: String, maxRetries: Int) {
self.serverURL = serverURL
self.apiKey = apiKey
self.maxRetries = maxRetries
}
}
이 클래스는 모든 프로퍼티가 불변이고, 클래스 자체가 상속될 수 없으므로 Sendable을 안전하게 준수할 수 있습니다.
2. @unchecked Sendable 사용
때로는 내부적으로 동기화 메커니즘을 사용하여 스레드 안전성을 보장하는 클래스가 있습니다. 이런 경우 @unchecked Sendable을 사용할 수 있습니다:
final class ThreadSafeCache<Key: Hashable & Sendable, Value: Sendable>: @unchecked Sendable {
private var storage: [Key: Value] = [:]
private let lock = NSLock()
func setValue(_ value: Value, forKey key: Key) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
func getValue(forKey key: Key) -> Value? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
}
@unchecked Sendable는 컴파일러에게 "이 타입은 내가 직접 스레드 안전성을 보장할 테니 신뢰해달라"고 말하는 것과 같습니다. 그러나 이는 개발자의 책임이 큰 접근 방식이므로, 정말로 필요한 경우에만 사용해야 합니다.
3. @MainActor 활용
@MainActor로 마크된 클래스는 항상 메인 스레드에서만 접근되므로, 동시성 문제를 피할 수 있습니다:
@MainActor
class ViewModel: Sendable {
private var data: [String] = []
func addData(_ newData: String) {
data.append(newData)
}
func getData() -> [String] {
return data
}
}
이 클래스의 모든 메서드는 메인 스레드에서만 실행되므로, 데이터 레이스 위험이 없습니다.
클로저와 Sendable
클로저가 Sendable이 되기 위해서는 캡처하는 모든 값이 Sendable이어야 합니다. Swift 5.7부터는 @Sendable 속성을 사용하여 클로저가 Sendable 요구사항을 준수해야 함을 나타낼 수 있습니다:
func performAsyncOperation(
with data: String,
completion: @Sendable @escaping (Result<String, Error>) -> Void
) {
// 비동기 작업 수행
Task {
do {
let result = try await processData(data)
completion(.success(result))
} catch {
completion(.failure(error))
}
}
}
이 예제에서 completion 클로저는 @Sendable로 마크되었습니다. 이는 클로저가 다른 동시성 컨텍스트에서 호출될 수 있음을 의미하며, 클로저 내에서 캡처된 모든 값이 Sendable을 준수해야 합니다.
Task와 같은 API를 사용할 때는 클로저가 암시적으로 @Sendable이 됩니다:
func processData(items: [String]) {
for item in items {
// 이 클로저는 암시적으로 @Sendable
Task {
await processItem(item)
// 에러: nonSendable은 Sendable이 아니므로 Task 내에서 캡처할 수 없음
// let captured = nonSendable
}
}
}
실전 적용 패턴
1. 데이터 전송 모델로 Sendable 사용
API 요청이나 응답 모델과 같은 데이터 전송 객체는 Sendable을 준수하도록 만드는 것이 좋습니다:
struct APIRequest: Sendable {
let endpoint: String
let method: HTTPMethod
let headers: [String: String]
let body: Data?
}
struct APIResponse<T: Decodable & Sendable>: Sendable {
let statusCode: Int
let data: T
let headers: [String: String]
}
actor NetworkManager {
func send<T: Decodable & Sendable>(_ request: APIRequest) async throws -> APIResponse<T> {
// 네트워크 요청 처리
// ...
}
}
이렇게 하면 네트워크 요청과 응답이 액터나 태스크 사이에서 안전하게 전달될 수 있습니다.
2. Sendable 적용이 어려운 레거시 코드 처리
기존의 레거시 코드, 특히 가변 클래스가 많은 경우에는 Sendable을 적용하기 어려울 수 있습니다. 이런 경우 다음과 같은 방법을 사용할 수 있습니다:
// 레거시 비-Sendable 클래스
class LegacyUser {
var id: Int
var name: String
var status: UserStatus
init(id: Int, name: String, status: UserStatus) {
self.id = id
self.name = name
self.status = status
}
}
// Sendable 래퍼 구조체
struct UserDTO: Sendable {
let id: Int
let name: String
let status: UserStatus
init(from legacyUser: LegacyUser) {
self.id = legacyUser.id
self.name = legacyUser.name
self.status = legacyUser.status
}
func toLegacyUser() -> LegacyUser {
return LegacyUser(id: id, name: name, status: status)
}
}
이 패턴을 사용하면 레거시 모델을 직접 수정하지 않고도 동시성 컨텍스트 간에 데이터를 안전하게 전달할 수 있습니다.
3. 상속과 Sendable
클래스 상속 계층에서 Sendable을 적용할 때는 추가적인 고려사항이 있습니다:
open class BaseViewModel: Sendable {
let id: UUID
init(id: UUID) {
self.id = id
}
// Sendable 프로토콜을 준수하는 서브클래스만 허용하는 제약 조건
// 서브클래스에서 구현해야 하는 메서드
func getData() async -> [String] {
fatalError("Subclasses must implement this method")
}
}
final class ProductViewModel: BaseViewModel {
private let productId: String
init(id: UUID, productId: String) {
self.productId = productId
super.init(id: id)
}
override func getData() async -> [String] {
// 구현
return []
}
}
베이스 클래스가 Sendable을 준수하면, 모든 서브클래스도 Sendable 요구사항을 충족해야 합니다. 이를 강제하기 위해 final 키워드를 사용하거나, 서브클래스에 추가적인 제약 조건을 적용할 수 있습니다.
Sendable 채택 시 주의사항
1. 가변성과 동기화
Sendable 클래스가 가변 상태를 가질 경우, 그 상태에 대한 접근은 반드시 동기화되어야 합니다:
final class SafeCounter: @unchecked Sendable {
private var count = 0
private let queue = DispatchQueue(label: "counter.queue", attributes: .concurrent)
var value: Int {
queue.sync { count }
}
func increment() {
queue.async(flags: .barrier) {
self.count += 1
}
}
}
여기서 @unchecked Sendable은 개발자가 직접 동기화를 처리한다는 것을 나타냅니다. DispatchQueue를 사용하여 읽기와 쓰기 작업을 동기화하고 있습니다.
2. 제3자 라이브러리와 Sendable
서드파티 라이브러리의 타입이 Sendable을 준수하지 않는 경우에는 래퍼를 만들거나, 액터 내부에 캡슐화하여 사용할 수 있습니다:
// 서드파티 라이브러리의 non-Sendable 타입
class ThirdPartyAnalytics {
func trackEvent(_ event: String) {
// 이벤트 추적
}
}
// 액터로 캡슐화하여 안전하게 사용
actor AnalyticsManager {
private let analytics = ThirdPartyAnalytics()
func trackEvent(_ event: String) {
analytics.trackEvent(event)
}
}
이렇게 하면 ThirdPartyAnalytics 인스턴스는 액터 내부에 격리되어, 동시성 문제 없이 안전하게 사용할 수 있습니다.
3. 컴파일 시간 vs 런타임 검사
대부분의 Sendable 확인은 컴파일 시간에 이루어지지만, 일부는 런타임에 확인됩니다. 특히 제네릭 타입과 관련된 검사는 런타임에 발생할 수 있습니다:
struct GenericWrapper<T>: Sendable {
let value: T
// 컴파일러 경고: T가 Sendable을 준수한다는 보장이 없음
}
// 수정된 버전
struct GenericWrapper<T: Sendable>: Sendable {
let value: T
// 이제 T는 항상 Sendable을 준수함이 보장됨
}
제네릭 타입을 Sendable로 만들 때는 타입 매개변수에 대한 Sendable 제약 조건을 추가하는 것이 중요합니다.
Sendable 디버깅 및 문제 해결
Sendable 관련 문제를 디버깅할 때 유용한 팁들입니다:
1. 컴파일러 경고와 오류 이해하기
Swift 컴파일러는 Sendable 준수와 관련된 여러 경고와 오류를 제공합니다:
struct User {
var name: String
var mutableArray: [String] // 가변 프로퍼티
}
// 에러 메시지:
// Non-sendable type 'User' in conformance to sendable protocol 'Sendable'
extension User: Sendable {}
이 오류는 User 구조체가 가변 프로퍼티를 가지고 있어 Sendable의 요구사항을 충족하지 못한다는 것을 알려줍니다. 이런 경우, 프로퍼티를 불변(let)으로 변경하거나, 타입 자체를 수정해야 합니다.
2. @unchecked Sendable 사용 시 주의사항
@unchecked Sendable을 사용할 때는 다음 사항에 주의해야 합니다:
final class DatabaseConnection: @unchecked Sendable {
private var connection: Connection
private let lockQueue = DispatchQueue(label: "connection.lock")
// 반드시 모든 상태 접근을 동기화해야 함
func execute(_ query: String) -> [Record] {
lockQueue.sync {
return connection.execute(query)
}
}
// 동기화되지 않은 접근은 데이터 레이스를 일으킬 수 있음!
func dangerousMethod() {
// 이렇게 하면 안 됨: connection에 직접 접근
connection.someMethod()
}
}
@unchecked Sendable을 사용할 때는 모든 가변 상태에 대한 접근이 적절히 동기화되는지 확인해야 합니다. 그렇지 않으면 런타임에 데이터 레이스가 발생할 수 있습니다.
3. 제네릭 및 associated type 문제
제네릭 타입과 연관 타입이 관련된 경우, Sendable 준수 여부를 확인하는 것이 복잡해질 수 있습니다:
protocol DataProvider {
associatedtype Data
func getData() -> Data
}
// Sendable 제약 조건 추가
protocol SendableDataProvider: DataProvider, Sendable {
associatedtype Data: Sendable
}
struct UserDataProvider: SendableDataProvider {
func getData() -> [User] {
// 구현
return []
}
}
연관 타입이 Sendable을 준수해야 하는 경우, 프로토콜 정의에 명시적인 제약 조건을 추가해야 합니다.
실용적인 예제: Sendable을 활용한 비동기 캐시
다음은 Sendable을 활용한 비동기 캐시 구현의 실제 예시입니다:
actor AsyncCache<Key: Hashable & Sendable, Value: Sendable> {
private var storage: [Key: Value] = [:]
private var computations: [Key: Task<Value, Error>] = [:]
func value(for key: Key, computation: @Sendable @escaping () async throws -> Value) async throws -> Value {
// 이미 캐시된 값이 있으면 반환
if let cachedValue = storage[key] {
return cachedValue
}
// 이미 계산 중인 태스크가 있으면 그 결과를 기다림
if let existingTask = computations[key] {
return try await existingTask.value
}
// 새로운 계산 태스크 시작
let task = Task {
do {
let value = try await computation()
storage[key] = value
return value
} catch {
throw error
} finally {
computations[key] = nil
}
}
computations[key] = task
return try await task.value
}
func removeValue(for key: Key) {
storage[key] = nil
computations[key]?.cancel()
computations[key] = nil
}
func clear() {
storage.removeAll()
for task in computations.values {
task.cancel()
}
computations.removeAll()
}
}
이 캐시 구현은 다음과 같은 특징을 가집니다:
- 액터를 사용하여 내부 상태에 대한 동시 접근을 동기화
- 제네릭 타입 매개변수에 Sendable 제약 조건 적용
- @Sendable 클로저를 사용하여 계산 로직의 안전한 실행 보장
- 중복 계산 방지를 위한 진행 중인 태스크 추적
다음과 같이 사용할 수 있습니다:
let cache = AsyncCache<URL, Data>()
func fetchData(from url: URL) async throws -> Data {
return try await cache.value(for: url) {
// 이 클로저는 @Sendable이므로 캡처하는 모든 값이 Sendable이어야 함
try await URLSession.shared.data(from: url).0
}
}
이 구현은 네트워크 요청과 같은 비용이 많이 드는 작업의 결과를 효율적으로 캐싱하면서도, Sendable을 통해 동시성 안전성을 보장합니다.
결론
Swift의 Sendable 프로토콜은 동시성 코드에서 데이터를 안전하게 공유하기 위한 강력한 도구입니다. 값 타입, 불변 참조 타입, 액터, 또는 내부적인 동기화 메커니즘을 통해 Sendable을 준수할 수 있으며, 이를 통해 컴파일 시간에 많은 동시성 문제를 발견하고 방지할 수 있습니다.
Sendable을 효과적으로 활용하기 위한 핵심 포인트는 다음과 같습니다:
- 값 타입(구조체, 열거형)을 우선적으로 사용하세요.
- 클래스를 사용해야 한다면 불변성을 유지하거나 적절한 동기화 메커니즘을 구현하세요.
- 타입 간 경계를 명확히 하고, 동시성 도메인 간에 전달되는 데이터는 Sendable을 준수하게 만드세요.
- 레거시 코드를 다룰 때는 Sendable 래퍼나 액터 캡슐화를 고려하세요.
- 제네릭 타입을 사용할 때는 타입 매개변수에 Sendable 제약 조건을 추가하세요.
Sendable을 통한 컴파일 시간 검사는 동시성 코드의 신뢰성을 크게 향상시키며, Swift의 동시성 모델을 안전하게 활용할 수 있도록 도와줍니다. 이는 단순히 버그를 잡는 것을 넘어서, 더 견고하고 유지보수하기 쉬운 코드를 작성하는 데 기여합니다.
'Swift > ETC' 카테고리의 다른 글
[Swift] Access Control: 깊이 있는 이해와 실전 활용법 (0) | 2025.03.19 |
---|---|
[Swift] Actor 격리 시스템: 동시성 환경에서 안전한 데이터 접근을 위한 핵심 메커니즘 (0) | 2025.03.11 |
[Swift] @MainActor: 동시성 환경에서 UI 업데이트를 안전하게 처리하는 방법 (0) | 2025.03.10 |
[Swift] 제네릭과 어소시에이티드 타입: 차이점과 활용 가이드 (0) | 2025.03.06 |
[Swift] 메모리를 참조하는 방법(strong, weak, unowned) (0) | 2021.03.10 |