[Swift Concurrency] 동시성 환경에서 데이터 경합을 방지하기

이정훈·2025년 9월 2일
0

Swift Concurrency

목록 보기
5/5
post-thumbnail

Task에서 데이터 전달

Swift Concurrency에서 Task는 비동기 코드를 실행하기 위한 기본 실행 단위이며, Task.detached(priority:operation:)를 사용하면 부모 Task와 분리된 독립적인 Task를 생성한다.

또한 Structured Concurrency를 기반으로 하나의 Task에서 여러 개의 비동기 작업을 실행 하는 경우, child task를 생성하여 해당 child task들의 생명 주기를 관리하고 결과를 수집한다.

struct Task<Success, Failure> where Success : Sendable, Failure : Error

Task 정의에서도 알 수 있듯이, Task는 실행이 완료되면, 결과 값을 반환 하는데, 이 결과 값의 타입은 Sendable 프로토콜을 따라야 하며, 이것은 여러 Task 사이에서 데이터를 안전하게 전달하기 위함이다.

비동기 작업은 일반적으로 동시에 실행되는 detached task나 child task 간의 데이터 공유가 발생할 수 있다. 만약 공유되는 값이 스레드 안전성을 보장하지 않는다면, data race, 메모리 손상, 예측 불가능한 동작이 발생할 수 있다. 따라서 Sendable 제약을 통해 Swift 컴파일러가 데이터의 안전한 전달 가능성을 검증하며, 이를 통해 동시성 환경에서도 안정성과 일관성을 유지할 수 있다.

참고로 Task에서 아무 값도 반환 하지 않을 경우 Success 제네릭 타입은 Void가 된다.

Sendable 타입이란?

그렇다면 여기서 말하는 Sendable 프로토콜이란 무엇일까?

💡 Sendable 프로토콜은 동시성 컨텍스트 사이에서 데이터 경합 없이 안전하게 데이터를 공유할 수 있는 thread-safe 타입을 말한다.

Swift에서 Sendable 프로토콜을 채택할 수 있는 타입은 아래와 같다.

  • Value types
  • Reference types with no mutable storage
  • Reference types that internally manage access to their state
  • Functions and closures (by marking them with @Sendable)

Sendable 프로토콜은 다른 프로토콜과 달리 명시적인 프로퍼티 요구나 메서드 요구 사항은 없지만, Sendable 프로토콜을 채택하기 위한 조건이 있는데, 각 타입별 조건은 아래와 같다.

Sendable Structures and Enumerations

구조체열거형에서 Sendable 프로토콜을 채택하기 위해서는 구조체열거형이 가지는 멤버와 연관값이 모두 Sendable을 만족하면 된다.

또한 아래의 조건을 만족하는 경우 암시적으로 Sendable 프로토콜을 채택한다.

  • Frozen 형태의 구조체와 열거형
  • public으로 선언 되지 않거나 @usableFromInline로 표시되지 않은 경우
  • Sendable 프로토콜을 따르는 프로퍼티만 가지는 구조체, Sendable 프로토콜을 따르는 연관값 만을 가지는 열거형

Sendable Actors

Swiftactor 타입은 암시적으로 Sendable 프로토콜을 준수한다.

Sendable Classes

Swift class 타입은 Sendable 프로토콜을 채택하기 위해서는 아래와 같은 조건을 만족해야 한다.

  • final 클래스
  • 저장 프로퍼티가 immutable 하고, Sendable protocol을 채택한 경우
  • 부모 클래스가 존재하지 않거나 NSObject를 부모 클래스로 가지는 경우

또한 클래스가 @MainActor로 선언되어 있는 경우, 암시적으로 Sendable protocol을 따른다.

Sendable Functions and Closures

클로저는 파라미터 정의 앞에 @Sendable 속성을 명시하여 해당 클로저가 안전하게 동시성 환경에서 사용될 수 있음을 보장할 수 있다. @Sendable이 적용된 함수와 클로저는 캡처 시 값 복사를 통해만 동작하며, 캡처된 모든 값은 반드시 Sendable 프로토콜을 준수해야 한다.

let sendableClosure = { @Sendable (number: Int) -> String in
    if number > 12 {
        return "More than a dozen."
    } else {
        return "Less than a dozen"
    }
}

Sendable Tuples

튜플 내부 요소가 모두 Sendable protocol을 채택한다면, 암묵적으로 해당 튜플은 Sendable protocol을 채택한다.

Actor 타입

이처럼, Sendable 프로토콜은 독립적인 Task 간에 값이 전달될 때 발생할 수 있는 데이터 경합을 사전에 차단하여, 안전하게 데이터를 공유할 수 있도록 보장한다.

하지만 Sendable 타입은 데이터를 안전하게 전달할 수 있도록 가이드만 제공할 뿐, 여러 Task에서 동시에 동일한 인스턴스에 접근하는 경우, 직접적으로 데이터를 동기화할 수는 없다.

actor 타입은 Swift Concurrency에서 동시 접근 제어를 보장하는 Swift의 새로운 타입이다.

actor 타입은 다음과 같은 특징을 가진다.

  • actor도 일종의 타입이다.
  • 따라서 다른 타입에서 사용하는 프로퍼티메서드이니셜라이저서브스크립트 등을 모두 사용 가능하다.
  • 다른 타입과 마찬가지로 protocol 준수, extension도 모두 사용 가능하다.
  • class와 동일하게 참조 타입이다.
  • 하지만 class와 달리 상속이 불가능하다.

actor isolation

actor 타입과 다른 타입들의 가장 큰 차이점은 데이터 경합으로부터 actor의 상태를 보호한다는 것이다. 그리고 actor의 가변 상태 데이터를 보호하기 위해 actor와 그 인스턴스 멤버에 접근 방식에 제한을 주게 되는데 이것을 actor isolation이라고 한다.

actor isolation의 첫 번째 규칙은 다음과 같다.

actor의 저장 인스터스 프로퍼티를 self를 통해서만 접근이 가능하도록 제한한다.

다음은 BankAccount라는 actor 타입을 선언하고 transfer(amount:to:) 메서드를 통해 계좌이체를 구현한 예시이다.

actor BankAccount {
	let accountNumber: Int
	var balance: Double
	
	init(accountNumber: Int, initialDeposit: Double) {
	self.accountNumber = accountNumber
	self.balance = initialDeposit
	}
}

extension BankAccount {
	enum BankError: Error {
	case insufficientFunds
	}
	
	func transfer(amount: Double, to other: BankAccount) throws {
		if amount > balance {
		  throw BankError.insufficientFunds
		}
		
		print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
		
		balance = balance - amount
		other.balance = other.balance + amount  // error: actor-isolated property 'balance' can only be referenced on 'self'
	}
}

이때 transfer(amount:to:) 메서드와 같이 other.balance로 actor의 프로퍼티에 직접적으로 접근하게 되면 에러가 발생하게 되는데 에러 메세지 중 actor-isolated라는 의미는 특정 actor 내부에서만 접근이 가능함을 의미한다. 즉, self 키워드를 통해서만 접근이 가능하지만 self로 접근하지 않았기 때문에 에러가 발생한 것이다. 

actor 타입에서 저장, 연산 프로퍼티인스턴스 메서드인스턴스 서브스크립트 등은 기본적으로 actor-isolated 상태이다. 동일한 actor 인스턴스 내부에서 선언되어 있는 서로 다른 actor-isolated의 접근은 자유롭게 참조 가능하다.

cross-actor reference

actor 외부에서 actor-isolated 상태의 선언을 참조하는 것을 cross-actor reference라고 한다. cross-actor reference는 두 가지 방법으로만 접근할 수 있도록 허용하는데 그 방법은 아래와 같다.

  • 불변 상태(상수)에 대한 cross-actor reference는 actor가 선언 되어 있는 모듈 어디서나 접근이 가능하다. (한번 초기화되면 수정할 수 없기 때문에) 따라서 위의 코드에서 상수로 선언 되어 있는 other.accountNumber 이런 식의 접근은 가능하다는 것이다.
  • 비동기 함수 내에서의 cross-actor reference는 허용된다. 비동기 함수 호출은 actor가 비동기 함수를 안전하게 수행할 수 있도록 messages 형태로 변환된다. 이 messages는 actor의 mailbox라는 곳에 저장 되고 비동기 함수의 호출자는 mailbox에 저장된 messages가 실행될 때까지 대기할 수 있다. 또한 actor의 mailbox는 한번에 하나의 messages가 수행됨을 보장하므로 가변 상태의 데이터 경합은 발생하지 않는 것을 보장한다.

cross-actor reference 조건을 바탕으로 위의 코드를 아래와 같이 리팩토링하여 에러를 제거할 수 있다.

extension BankAccount {
    ...
    
    func transfer(amount: Double, to other: BankAccount) async throws {
        if amount > balance {
            throw BankError.insufficientFunds
        }
        
        print("Transferring \(amount) from \(accountNumber) to \(other.accountNumber)")
        
        balance = balance - amount
        await other.deposit(amount: amount)
    }
    
    private func deposit(amount: Double) async {
        assert(amount >= 0)
        balance = balance + amount
    }
}

Reference

https://developer.apple.com/videos/play/wwdc2022/110351/

profile
새롭게 알게된 것을 기록하는 공간

0개의 댓글