티스토리 뷰

 

 

라이브러리를 배포하기 위해 기존 앱 프로젝트의 코드를 Swift Package로 옮긴 뒤 빌드했을 때 컴파일 에러가 발생하였다.

 

 

컴파일러가 두 가지 제안을 줬는데 MainActor를 호출하던가 nonisolated 키워드를 사용하라고 제시했다. 하지만 AVFoundation과 같이 카메라나 미디어 처리는 주로 백그라운드에서 작업되는 것이라고 알고 있는데 MainActor를 호출하는 게 맞는 건가? 싶다가 nonisolated를 사용해보고자 하였다. 

 

따라서 오늘은 swift Concurrency의 일부인 nonisolated 키워드에 대해서 알아보겠다!

nonisolated와 nonisolated(unsafe)는 어떤 점이 다른지, 어떤 원리로 실행되는지 궁금해서 공부해보고자 한다.

(제가 공부하기 위해 작성한 것이므로 여러 개념들이 나올 수 있으며 트러블 슈팅에 관한 이야기가 포함되어 있습니다.)

 


⚔️ 문제의 원인을 찾아서..

 

앱 타깃에서는 빌드도 잘되고 시뮬레이터로 실행도 잘되던 코드를 Swift Package로 그대로 옮겼는데 컴파일 에러가 발생한 이유에 대해서 본질적인 문제의 원인이 궁금해졌다.

 

 

코드 내부를 살펴보면 UIKit의 AppDelegate와 SwiftUI의 App 프로토콜안에서 @MainActor가 이미 내부적으로 호출되고 있다.

그래서 암묵적으로 @MainActor나 동시성 로직을 따로 관리해주지 않아도 허용되는 범위가 넓지만, 패키지로 분리하는 순간 어떤 스레드에서 호출될지 모르기 때문에 호출 스레드를 통제할 수 없어 동시성 규칙이 더 엄격하게 적용되고 오류가 난 것이다.

 

제일 처음 스크린샷을 보면 알겠지만 컴파일러 딴에서 오류가 난 이유는 data race에 대한 가능성 때문이였다.

 

 

✏️ Data Race

여러 쓰레드가 동일한 메모리 영역에 접근하면서 읽기/쓰기 순서가 보장되지 않아 데이터의 값이 변하는 상황

 

동기화에서는 여러 쓰레드나 프로세스가 공유자원에 동시에 접근하려고 할 때 Date Race가 발생하게 된다.

(학부생 때 컴퓨터 구조를 공부하던 때가 떠오른다 😓)

 

Swift Concurrency 관점에서 Data Race를 보면 서로 다른 task나 actor가 동일한 변경가능한 상태(mutable state)에 접근할 때 그 접근 순서를 컴파일러가 증명할 수 없다면 컴파일 에러가 나게 된다.

var result = 0

func A() {
    result += 1
}

Task {
    A()
}

Task {
    A()
}

 

위코드를 보면 result는 전역변수로 선언되어 있기 때문에 언제든지 변경 가능한 mutable 상태를 지닌다. 각각 독립적인 Task내부에서 A함수를 호출하고 있는데 접근 순서가 보장되지 않기 때문에 해당 코드는 swift 딴에서 해당 코드의 안전함을 증명할 수 없어 컴파일 에러가 발생한다.

 

 

이 문제를 해결하기 위해 swift에서 도입한 개념이 Actor이다.

 

 

✏️ Actor 

자신이 소유한 값이 바뀔 수 있는 mutable state에 대해 동시에 접근하지 못하도록 보장하는 것

 

공식문서에 따르면 actor는 기본적으로 serial executor를 사용하여 작업을 순차적으로 처리하는데 필요시 특정 serial executor를 사용하도록 설정할 수도 있고 task executor를 사용하여 기본 task와 actor의 스케줄링에도 영향을 줄 수 있다고 한다.

 

이 말은 Actor는 serial executor로 인해 순차 실행되면서 data race가 발생하지 않도록 접근 순서를 보장한다는 것이다. 

 

앞서 설명했던 코드를 다시 가져와 actor를 적용해 보면 아래와 같다.

 

actor ResultActor {
    private var result = 0
    
    func A() {
        result += 1
    }
}

let resultActor = ResultActor()

Task { 
    await resultActor.A()
}

Task {
    await resultActor.A()
}

 

actor안에 result 변수와 함수 A가 포함되어 있는데 actor-isolated 때문에 actor 밖에서는 result와 함수 A를 직접 호출할 수가 없게 된다. actor 외부에서 result나 A함수를 호출하기 위해서는 await 키워드를 사용해서 이 작업은 ResultActor 큐에 맡길 테니 기다렸다가 실행해 줘~라고 표시함으로써 data race가 발생하지 않고 result의 값이 안전하게 수정될 수 있는 것이다.

 

📍actor 격리 규칙(actor-isolated)이란?
:  actor가 가진 데이터를 동시에 여러 곳에서 수정하지 못하게 항상 actor 안에서 한 번에 하나씩만 접근하도록 하는 규칙이다.

 

실행 흐름 관점에서 보면 actor의 executor는 선입선출(FIFO)을 보장하고 한 시점의 하나의 actor-isolated 함수만 실행하는 게 원칙이기 때문에 첫 번째 Task에서 A를 호출하면 해당 결과를 반환하고, 두 번째 Task를 실행하여 해당 결과를 반환되어 순서가 보장되니 값이 변경될 일도 극히 드물다!

(Task는 동시에 시작되지만 actor 내부에서 실행될 때는 순차적으로 실행된다.)

 

 

그럼 nonisolated는 언제 쓰이는 것일까? Actor 인스턴스는 기본적으로 isolated self를 가지는데, nonisolated가 이걸 끄는 것이다.

 

 

✏️ nonisolated 

actor의 격리에 의존하지 않음을 컴파일러에 명시하는 키워드

 

Actor 격리 규칙 덕분에 Actor 내부에서 값이 바뀔 수 있는 부분에는 별도의 lock 없이도 안전하지만 모든 멤버가 항상 Actor의 상태를 읽거나 수정하는 것은 아니다. 예를 들어 Actor 내부에 존재하는 계산 로직이 포함된 함수나 immutable을 반환하는 함수는 굳이 순차적으로 실행될 필요가 없다.

 

 

이때 nonisolated는 Actor 내부에서 await 없이도 호출 가능하다는 점이다. nonisolated로 선언된 메서드나 프로퍼티는 actor에서 보장하는 변경가능한 상태에 접근할 수 없기 때문에 data race의 가능성 자체가 없어지게 되어 await없이도 안전하게 호출할 수 있는 것이다.

 

 

한마디로 정리하자면 해당 키워드를 사용함으로써 actor의 보호를 받지 않는 대신 mutable state에 접근을 금지함으로써 어디서 호출되든 안전하다는걸 컴파일러가 증명할 수 있게 하는 것이다.

 

아래 코드를 통해 이해해 보자.

actor A {
    nonisolated func B() -> Int {
        ...
    }
}


이런 코드가 존재한다고 가정했을 때 nonisolated키워드가 명시된 B함수는 actor 타입에 속해있지만, actor가 관리하는 상태와 실행 순서를 보호하기 위한 격리 규칙의 대상에서 제외되어 다른 곳에서도 await 키워드 없이 B함수를 호출할 수 있다.

 

 

📍nonisolated(unsafe)는 무엇일까?
nonisolated(unsafe)는 현재 격리 주체인 actor이 존재하지만 actor의 격리 범위를 벗어나고 싶을 때 사용하는 키워드다. 

예를 들어 전역 변수나 정적 변수는 어디서든, 어떤 스레드/actor/task든 동시에 접근가능하기 때문에 Read, Write 순서가 보장되지 않아 값이 마음대로 바뀔 수 있다. 하지만 전역변수나 정적 변수이여도 실제로 개발자가 수동으로 NSLock을 잘해놓은 코드일수도 있고 순차 큐등 동기화를 잘 해놓은 코드일 수도 있다. swift가 그 내부까지는 확인할 수 없기 때문에 "컴파일러에게 내가 책임질 테니까 막지 마!"라고 얘기하는 것이라고 이해하면 쉬울 거 같다! 

즉 nonisolated(unsafe)는 swift가 안전하다고 인정한 것이 아니라 모른 척 배제해 달라고 요청하는 것이다.

 

 

두 개념의 차이를 정리하자면 nonisolated 키워드는 안전하다고 컴파일러에서 보장해 주지만, nonsiolated(unsafe) 개발자가 안전하다고 컴파일러에게 주장하는 것이다!

 

 

이렇게 nonisolated 키워드를 메서드에 호출시키고 변수에 선언해 줬더니 더 이상 컴파일 에러가 발생하지 않았다.

 

하지만 항상 nonisolated를 사용하는 게 정답은 아니다. 특히 nonisolated(unsafe)는 컴파일러가 안전을 보장해 주는 장치가 아니기 때문에 언제든 런타임 크래시가 발생할 수 있고, 동시성을 고려하지 않은 로직일수록 사용을 지양하는 것이 좋다.

 

 

✏️  Strict concurrency와 Swift 6

swift 6에서는 xcode에서 build setting에 strict concurrency checking을 활성화하면 동시성 환경에서 date race가 발생할 가능성이 있는 곳에서 에러나 경고를 표시한다. (거의 기본으로 세팅이 되어 있는 것으로 알고 있다.)

swift 6 이전까지는 strict concurrency가 적용되지 않아 컴파일은 되지만 시뮬레이터로 돌렸을 때 갑자기 앱이 강제 종료되는 등 런타임 크래시가 발생한 적이 꽤 많았지만, 이제는 컴파일러가 Actor 격리 규칙을 100% 컴파일 타임에 강제하기 때문에 런타임 크래시까지 갈 것도 없이 컴파일 딴에서 컴파일 에러가 발생하도록 보안을 강화했다고 한다.

(이유도 모르게 앱이 터지기 전에 미리 막아주는 건 꽤 좋은 거 같다.)

 

마무리

이번 블로그를 통해 nonisolated에 대해서 살펴보고 actor에 대한 개념도 살펴보았다. 최근 공부했던 것 중에서 가장 어렵게 느껴졌다.

다른 블로그에서 정리된 내용이 많긴 하지만 여러 사전 지식을 알지 못하면 이해가 가지 않은 블로그들도 많았고, 하나부터 다 공부하자 하니 너무 막막했다!!! 그래서 stackoverflow랑 여러 공식문서를 참고하면서 이해했는데 concurrency는 언제나 어려운 거 같다.

이번 블로그는 나처럼 여러 개념을 몰라도 한 번에 이해하기 쉽도록 작성하는 것이 목표였는데 달성한 지는 모르겠다 ㅎ ㅎ.!


참고

https://www.avanderlee.com/swift/thread-sanitizer-data-races/

https://www.avanderlee.com/swift/actors/

https://www.avanderlee.com/swift/nonisolated-isolated/

https://github.com/swiftlang/swift-evolution/blob/main/proposals/0313-actor-isolation-control.md

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
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
글 보관함