새벽의 기록

[iOS] Swift Concurrency 박살내기 #4 - Sendable과 nonisolated 본문

[iOS]

[iOS] Swift Concurrency 박살내기 #4 - Sendable과 nonisolated

OneTen 2025. 12. 6. 19:59

일전에 멀티프로그래밍 환경에서 가장 경계하고 무서워 하는 요소가 Data Race라고 한 적 있다.

Data Race는 간단히 말해 여러 작업이 동시에 실행되며 결과값을 예측할 수 없는 것을 의미하는 데, 이러한 위험을 막기 위해 Swift 6 이상부터는 Sendable 프로토콜을 강력하게 밀고 있다.

Swift 6에서는 Sendable 규칙 위반이 경고/에러로 잡히며, 동시성 버그를 컴파일 타임에 미리 터뜨려준다.

Sendable이란?

Sendable | Apple Developer Documentation

 

Sendable | Apple Developer Documentation

A thread-safe type whose values can be shared across arbitrary concurrent contexts without introducing a risk of data races.

developer.apple.com

데이터 경쟁의 위험을 초래하지 않고 임의의 동시 컨텍스트에서 값을 공유할 수 있는 스레드 안전 유형입니다.

 

Sendable여러 스레드·Task·actor 사이를 넘나들며 공유해도 데이터 레이스가 발생하지 않는 타입이다.

공식 정의 그대로, “임의의 동시 컨텍스트에서 안전하게 공유될 수 있는 thread-safe 타입”을 의미한다.

“이 타입은 다른 actor/Task로 ‘보내도(send)’ 안전합니다.” → 그래서 이름이 Sendable.

어떤 타입이 Sendable인가?

앞서 sendable은 스레드 간 공유가 되어도 안전한 타입을 의미한다고 했다.

즉, 기본적으로 객체들이 복사되어 사용되는 값 타입들은 sendable하다.

또한 하나의 스레드, 하나의 작업만 접근할 수 있게 강제하는 타입인 actor 역시 sendable하다.

그리고 직접 @Sendable 어트리뷰트 키워드를 붙임으로써 컴파일러에게 sendable한 요소라고 알려줄 수 있다.

커스텀 타입을 동시성-safe하게 만들고 싶으면 Sendable 채택을 활용할 수 있다.

struct UserInfo: Sendable {
    let id: Int
    let name: String
}

값 타입이면서, 내부 프로퍼티들이 모두 Sendable이면(let으로 선언했기에 불변) 자동으로 안전하다.

 

반면에 Class와 같은 참조타입들은 기본적으로 Sendable이 아니다. 이들은 값이 복사되어 독립적으로 사용되는 것이 아니라 여러 곳에서 접근할 수 있기 때문이다.

하지만 Sendable 충족이 아예 불가능 한 것은 아닌데, 내부 동기화 전략(불변, 락 등)을 스스로 보장해야 하며, 보통 @unchecked Sendable을 사용해 “이건 내가(개발자가) 책임질게”라고 선언한다.

 

class가 Sendable이 되려면:

  1. final 클래스여야 함 (상속 방지).
  2. 모든 속성이 불변(let)이고 Sendable이어야 함.
  3. 또는 내부적으로 락(Lock) 등을 이용해 동기화 처리가 되어 있어야 함
  4. 그 후 @unchecked Sendable을 채택
final class Logger: @unchecked Sendable {
    private let lock = NSLock()
    private var logs: [String] = []

    func log(_ message: String) {
        lock.lock()
        logs.append(message)
        lock.unlock()
    }
}

@unchecked Sendable은 컴파일러가 구조를 완전히 검증할 수 없을 때, 개발자가 직접 thread-safe를 보증하겠다는 의미다.

nonisolated?

이전 포스팅에서 Actor에 대해 다뤘다.

간단히 말하면, Actor는 Swift의 동시성(Concurrency) 모델에서 여러 작업(Task, 스레드)이 동시에 접근할 수 있는변경 가능한 데이터(shared mutable state)를 안전하게 보호하기 위해 등장한 새로운 참조 타입이다.

 

nonisolated는 이 Actor의 엄격한 격리(Isolation) 규칙을 벗어날 수 있는 방법으로, Actor 내부에 정의되어 있지만, Actor의 격리(Isolation) 규칙을 따르지 않겠다고 선언하는 키워드다.

nonisolated 키워드가 붙은 프로퍼티나 메서드는 동기(Sync)적으로 접근할 수 있다. (await 불필요)

→ 기존에는 Actor 내부 요소들에 접근하기 위해서는 await이 강제됐음

 

마치 일반 class나 struct의 메서드처럼 동작하며, 시스템의 스레드 풀(Thread Pool) 어디에서나 실행될 수 다.

nonisolated가 왜 필요한가?

Actor는 기본적으로 모든 프로퍼티와 메서드를 격리(Isolation)시킨다. 외부에서 접근하려면 반드시 비동기(await)로 접근해야 한다. Actor를 이용하면 Data Race를 비약적으로 방지할 수 있다.

 

그런데 대체 왜?? 기껏 Data Race를 방지하기 위해 Actor를 만들어 놓고 독립성을 거부하는 nonisolation 키워드를 사용하냐?

대부분의 이유가 그렇듯이 효율의 문제다.

  • 상황: "나는 그냥 변하지 않는 let 상수를 읽고 싶은데 매번 await를 써야 한다고?"
  • 상황: "CustomStringConvertible나 Hashable 같은 프로토콜을 채택했는데, 프로토콜의 메서드는 동기(Synchronous) 함수라서 Actor의 비동기 환경과 충돌이 난다면?"
  • 상황: “ViewModel은 UI와 관련된 로직을 처리하니까 @MainActor를 붙였는데 무거운 계산 로직들도 모두 메인 스레드에서 작동되고 있네?”

이럴 때 필요한 게 nonisolated다.

Case 1: 불변 데이터에 대한 접근

변경될 일이 없는 데이터는 굳이 await할 필요가 없다.

actor UserStore {
    let id: String
    var name: String // 가변 상태 (격리 필요)

    init(id: String, name: String) {
        self.id = id
        self.name = name
    }

    // ❌ 일반 접근: 외부에서 접근 시 await 필요 (비효율적일 수 있음)
    // func getID() -> String { return id }

    // ✅ nonisolated: 외부에서 await 없이 바로 접근 가능
    nonisolated var publicID: String {
        return id
    }
}

// 사용 시
let store = UserStore(id: "123", name: "Gemini")
print(store.publicID) // await 없이 즉시 출력 가능

Case 2: 프로토콜 준수

CustomStringConvertible의 description은 동기 프로퍼티여야 한다. 하지만 Actor 내부의 데이터에 접근하려면 비동기여야 하므로 충돌이 발생한다. 이때 nonisolated가 해결책이 된다.

actor BankAccount: CustomStringConvertible {
    let accountNumber: String
    var balance: Int

    // ❌ 컴파일 에러 발생!
    // Actor-isolated property 'description' cannot be used to satisfy
    // nonisolated protocol requirement.
    /*
    var description: String {
        return "Account: \\(accountNumber), Balance: \\(balance)"
    }
    */

    // ✅ 해결 방법: nonisolated 사용
    nonisolated var description: String {
        // 주의: balance는 가변(var)이므로 여기서 직접 접근 불가!
        // 불변(let)인 accountNumber만 접근 가능
        return "Account: \\(accountNumber)"
    }
}

Case 3: ViewModel에 붙인 MainActor

struct HomeStateModel {
    var username: String = ""
    var userUniversity: String = ""
    var userDepartment: String = ""
    var progressValue: Int = 0
    
    var recommendLicenses: [RecommendLicenseCardModel] = []
    var preLicenses: [PreLicenseCardModel] = []
    var favoriteLicenses: [FavoriteLicenseCardModel] = []
}

@MainActor
final class HomeViewModel: ObservableObject {

// ... 기존 프로퍼티 ...

// MARK: - Network

extension HomeViewModel {
    func getUserInfo() async {
        let result = await fetchUserInfoUseCase.execute()
        
        switch result {
        case .success(let response):
            logger.info("✅ 유저 정보 조회 성공")
            homeStateModel = response.toHomeStateModel()
            AuthManager.shared.nickname = response.name
            
        case .failure(let error):
            logger.error("❌ 탈퇴 실패: \\(error.localizedDescription)")
        }
    }   
}

위의 코드 예제처럼 @MainActor를 클래스 전체에 붙이면, 그 안의 모든 메서드와 프로퍼티 접근이 메인 스레드(UI 스레드)에서 실행된다.

물론 await가 있는 동안은 스레드 제어권을 양보(Suspend)하지만, 데이터를 받아온 직후(await 뒷줄)부터 실행되는 데이터 가공(매핑, 필터링, 정렬 등) 로직은 다시 메인 스레드로 돌아와서 실행된다.

만약 response로 받은 데이터가 수천 개라면? 리스트를 변환하는 동안 UI가 버벅거릴 것이다.

 

[현재 코드의 흐름]

  1. getUserInfo() 호출 (Main Thread)
  2. await fetchUserInfoUseCase.execute() (Suspend -> 백그라운드에서 네트워크 통신)
  3. 통신 완료 후 복귀 (Main Thread) 👈 여기가 문제!!!!!!
  4. response.toHomeStateModel() 실행 (Main Thread)
  5. homeStateModel 업데이트 (Main Thread)

여기서 4번 과정(데이터 매핑)이 복잡하거나 데이터 양이 많다면, 그 연산을 처리하느라 UI가 순간적으로 멈칫하게 될 것이다.

이럴 때 필요한 것이 nonisolated 다.

UI 업데이트와 관련 없는 순수 데이터 가공 로직은 굳이 메인 액터에 격리될 필요가 없다. 이 부분을 nonisolated로 선언하여 메인 스레드의 부담을 줄여줘야 한다.

@MainActor
final class HomeViewModel: ObservableObject {
    // ... 기존 프로퍼티 ...

    // MARK: - Network (Refactored)

    func getRecommendCertificationList() async {
        // 1. 네트워크 통신 (UseCase 내부 구현에 따라 백그라운드 실행)
        let result = await fetchRecommendUseCase.execute()
        
        switch result {
        case .success(let response):
            logger.info("✅ 추천 자격증 조회 성공")
            
            // 2. [Heavy Task] 데이터 가공을 nonisolated 환경으로 넘김
            // 이 작업은 메인 스레드가 아닌 스레드 풀에서 수행될 수 있음
            let list = await transformRecommendList(response: response)
            
            // 3. 가공된 가벼운 결과만 받아서 UI 업데이트 (Main Thread)
            self.homeStateModel.recommendLicenses = list
            
        case .failure(let error):
            logger.error("❌ 추천 자격증 조회 실패: \\(error.localizedDescription)")
        }
    }
    
    // ... 다른 메서드들 ...
}

// MARK: - Nonisolated Logic (Helper)

extension HomeViewModel {
    
    /// UI와 상관없는 데이터 가공 로직
    /// nonisolated로 선언하여 Actor(MainActor)의 격리를 벗어남
    nonisolated func transformRecommendList(response: RecommendResponseDTO) async -> [RecommendLicenseCardModel] {
        // 여기서 1,000개의 배열을 매핑하거나 필터링해도 메인 스레드에 영향 없음
        // 주의: 여기서는 self.homeStateModel 같은 뷰모델의 상태(var)에 접근 불가
        return response.toRecommendLicenseCardModelList()
    }
}

 

이를 통해,

  • transformRecommendList 함수는 nonisolated이므로 HomeViewModel이 MainActor임에도 불구하고, 메인 스레드 강제성이 사라진다. 따라서, 시스템이 판단하여 적절한 백그라운드 스레드에서 연산을 수행할 수 있게 된다.
  • nonisolated 메서드 안에서는 self.homeStateModel 같은 Actor 내부의 가변 상태(var)를 건드릴 수 없다. (컴파일 에러 발생) 따라서 입력값(Response)을 받아서 결과값(Model)을 리턴하는 깔끔한 구조가 강제된다. 이는 테스트하기에도 훨씬 좋다.
  • 메인 스레드는 오직 완성된 데이터를 UI에 반영(self.homeStateModel = ...)하는 아주 짧은 순간만 사용된다. 나머지 무거운 변환 작업은 백그라운드에서 처리되므로 스크롤 버벅임이 사라진다.

 

 

Comments