| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 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 |
- 자바
- 영화일기
- SOPT
- Flutter Toy Project
- MVVM-C
- 코딩공부
- swift concurrency
- IOS
- 리뷰
- 영화리뷰
- 토이프로젝트
- 일기
- 영화후기
- SWIFT
- 플러터
- 자바 스터디
- 오블완
- 영화
- sopt ios
- Flutter
- 자바공부
- 새벽녘 소소한 기록
- 영화기록
- 백준
- 키노
- sopt 35기
- 티스토리챌린지
- 독서일기
- java
- 프로그래머스
- Today
- Total
새벽의 기록
[iOS] Swift Concurrency 박살내기 #3 - Actor 본문
https://developer.apple.com/documentation/swift/actor
Actor | Apple Developer Documentation
Common protocol to which all actors conform.
developer.apple.com
Actor 기본 개념
Actor란 무엇인가?
Actor는 Swift의 동시성(Concurrency) 모델에서 여러 작업(Task, 스레드)이 동시에 접근할 수 있는 변경 가능한 데이터(shared mutable state)를 안전하게 보호하기 위해 등장한 새로운 참조 타입이다.
멀티 스레드 환경에서 가장 무서운 적은 Data Race와 같은 Race Condition이다.
Data Race는 2개 이상의 개별 쓰레드가 동시에 동일한 데이터에 접근하고, 이러한 접근 중 하나 이상이 write일 때 발생한다.
즉, 여러 스레드가 동시에 하나의 데이터에 접근해서 수정하려고 할 때 발생하는 동시 접근/수정으로 인한 문제를 의미한다.
기존에는 이를 막기 위해 개발자가 직접 Lock, Semaphore, DispatchQueue 등을 사용하여 줄 세우기를 해야 했다. 하지만 이는 어렵고 실수가 발생하기 쉽다.
Actor는 이 줄 세우기를 컴파일러 레벨에서 자동으로 해준다.
- 상태 격리 (Isolation): Actor 내부에 선언된 변수(State)는 외부에서 함부로 건드릴 수 없도록 격리된다.
- 직렬화된 접근 (Serialized Access): 외부에서 Actor의 데이터에 접근하려면 한 번에 하나만 들어올 수 있다. 만약 앞선 작업이 진행중이라면, 다음 작업은 기다려야(await) 한다. 즉, Actor 내부 상태는 한 번에 하나의 작업만 접근 및 변경할 수 있다.
- 외부(다른 스레드나 Task)에서 Actor의 상태를 바꾸려면 반드시 Actor가 제공하는 메서드를 통해 비동기적으로 접근해야 하며, Swift가 자동으로 직렬화(serialization)한다.
요약: Actor는 여러 스레드에서 동시에 접근해도 안전하게 동작하는 타입이며, 데이터 경합 없는 thread-safe 코드를 지원한다.
클래스와의 차이점
Actor는 클래스와 비슷한 참조 타입(Reference Type)이지만, 완전 다른 작동 방식을 준수하고 있기 때문에 헷갈리면 안된다.
| 클래스(Class) | Actor |
| 여러 작업이 동시에 내부 상태 접근/변경 가능 | 한 번에 하나의 작업만 상태 접근/변경 가능 (직렬화) |
| 동기화 직접 구현 필요 (lock, queue 등) | 동기화 자동 제공 (Actor 내부에서 자동) |
| 상속 가능 (subclassing) | 상속 불가능 (확장(extension), 프로토콜 채택만 가능) |
| 외부에서 프로퍼티 직접 접근 가능 | 외부에서는 메서드/프로퍼티에 async로만 접근 가능 |
Actor 선언 방법
선언 자체는 클래스랑 크게 다를 건 없다. 그냥 class 부분을 actor로 바꿔주는 정도?
actor Wallet {
// 1. 내부 상태 (외부에서 함부로 수정 불가)
var balance: Int = 1000
// 2. 불변 상태 (외부에서 언제든 읽기 가능)
let owner: String
init(owner: String) {
self.owner = owner
}
// 3. 메서드 (외부에서 호출 시 await 필요)
func deposit(amount: Int) {
self.balance += amount
print("입금 완료! 잔액: \\(balance)")
}
// 메서드 내에서는 자유롭게 자신의 상태 변경 가능
func withdraw(amount: Int) -> Int? {
guard balance >= amount else { return nil }
balance -= amount
return amount
}
}
하지만 외부에서 Actor 사용할 때는 class와는 많이 다르다 (중요!!)
let myWallet = Wallet(owner: "Sujin")
Task {
// ✅ let으로 선언된 상수는 변경되지 않으므로 await 없이 접근 가능
print("주인: \\(myWallet.owner)")
// ❌ actor의 변수는 그냥 접근 불가
// print(myWallet.balance)
// ✅ actor 외부에서 변수 접근시 await 필요
// (다른 스레드가 수정할 수도 있으므로 기다려야 함)
print("현재 잔액: \\(await myWallet.balance)")
// ✅ 메서드 호출도 await 필요
await myWallet.deposit(amount: 500)
// ❌ 컴파일 에러. actor 외부에서 actor 내부의 변수를 변경할 수 없음
// await myWallet.balance += 100
}
actor는 언제 접근이 허용되는지 확실하지 않기 때문에 actor의 변경 가능한 데이터에 대한 비동기 액세스를 생성한다.
이미 다른 Task들이 actor에 접근해서 코드를 실행하고 있으면 해당 actor에 대한 다른 코드는 실행되지 못하고 기다려야한다.
따라서 Actor를 사용하는 외부 코드(MainActor나 다른 Task)에서는 Actor의 변수나 메서드에 접근할 때 반드시 await를 붙여야 한다.
이 부분에서 나는 이해 부족에서 기반된 의문이 하나 생겼었다.
분명 actor는 애초에 하나의 스레드만 접근할 수 있는 참조 타입인데, 왜 다른 스레드가 수정할 수도 있으니 기다려야(await) 한다는 것인지??
한 번에 하나의 스레드만 접근하니까 다른 스레드의 영향을 받지 않아야 하는 것이 아닌지??
결론은, 내가 기다려야 한다는 내용을 잘못 이해하고 있었다.
나는 ‘어차피 actor를 쓸 때는 하나의 스레드만 접근이 가능한데, 왜 actor 내부 변수나 함수를 호출할 때 await 호출해야 하는지? 다른 스레드의 영향을 받을 일이 없는 것이 아닌가?’ 라고 생각을 해서 이런 의문이 들었었다.
그런데 코드를 보면 알 수 있듯이 await을 사용하는 부분은 myWallet.balance 처럼 actor.variable 을 외부에서 사용하고 있다.
즉, 외부에서 액터를 사용하려고 하고 있기 때문에, 액터 내부에 있는 변수에 접근하기 위해서 await을 붙여주는 것이 아니라!!
액터 자체에 접근을 하기 위해서 await을 붙여주는 것이다.
balance 라는 변수는 액터 내부에 있고, 외부에서 이 변수를 사용하려고 하면 당연히 액터에 접근을 해야 하고, 본인 작업의 스레드를 배정받을 때까지 기다려야 하므로 await을 붙여주는 것이다.
요약하자면, 나는 ‘액터 내부 변수에 접근할 때 왜 await을 붙이지? 어차피 본인 스레드만 있을텐데(전제가 이미 액터에 접근했다는 생각이었음)’ 라고 생각하고 있었고, 사실은 액터 내부 변수에 접근을 하려면 어찌됐든 액터에 접근을 해야하기 때문에 순서를 기다리기 위해 await을 붙여줘야 하는 것이다.(아직 액터에 접근하지 못 한 상태이니)
그렇다면 여기서 또 하나의 의문!!
이전은 외부에서 접근할 때의 경우에 대한 의문이었고, 그렇다면 내부에서 내부로 접근할 때에는 await이 필요할까?
actor Counter {
private var value = 0
func incrementAndPrint() {
value += 1 // actor 내부에서는 바로 접근 가능 (await 필요 없음)
print("Current value: \\(value)")
// 또 다른 내부 함수도 await 없이 동기 호출 가능
printValue()
}
func printValue() {
print("Value is \\(value)")
}
}
내부에서는 await이 필요하지 않았다. 액터 내부에서의 작업은 액터가 관리하는 단일 큐 내에서 외부 방해 없이 순차적으로 처리되거나 마무리 되기 때문에.
let counter = Counter()
Task {
await counter.incrementAndPrint() // 외부에서는 await 필요!
}
외부에서는 역시나 await 필요!
MainActor
@MainActor가 뭐지?
SwiftUI를 사용하며 MVVM 패턴을 채택해 본 사람이라면 누구나 한 번쯤 봤을 MainActor다.
iOS/macOS 앱에서 UI 작업은 반드시 메인 스레드에서 실행되어야 한다.
만약 백그라운드에서 UI를 업데이트하면 다음과 같은 문제가 발생한다
- UI "찢어짐", 오작동, 앱 크래시
- 사용자의 입력 리스폰스가 무너짐
- 디버깅이 매우 어려운 동시성 버그 발생
MainActor는 이런 위험을 원천적으로 막아주는 특별한 전역 액터(Global Actor)이다.
MainActor에서 실행되는 모든 코드는 반드시 메인 스레드에서 실행됨을 보장하며, 메인 스레드(Main Thread)와 1:1 매핑을 시켜준다.
MainActor 사용법
사용 방법은 @MainActor 어트리뷰트를 클래스, 메서드, 프로퍼티, 클로저에 붙여주기만 하면 된다.
사용 예시1: 클래스 전체 적용 (가장 흔한 패턴)
@MainActor
final class UserViewModel: ObservableObject {
@Published var username: String = ""
// 이 메서드는 자동으로 메인 스레드에서 실행됨
func updateUsername(_ name: String) {
self.username = name
}
// 네트워크 요청 등 시간이 오래 걸리는 작업은 격리 해제 가능
nonisolated func fetchUserData() async -> String {
// 백그라운드 스레드에서 실행될 수 있음
return "Server Data"
}
}
사용 예시2: 메서드/프로퍼티 단위 적용 - 특정 작업만 메인 스레드에서 실행해야 할 때 사용한다.
class ImageLoader {
func load() async {
let image = await downloadImage()
// 다운로드 후 UI 반영을 위해 MainActor 호출
await updateImageView(image)
}
@MainActor
func updateImageView(_ image: UIImage) {
imageView.image = image
}
}
사용 예시3: MainActor.run (블록 실행) - DispatchQueue.main.async와 유사하게, 즉시 메인 액터 문맥으로 전환하여 코드를 실행할 때 사용한다.
func distinctFunction() async {
// 백그라운드 작업 수행
let result = calculateHeavyTask()
// UI 업데이트를 위해 MainActor로 진입
await MainActor.run {
label.text = "\\(result)"
}
}
사용 예시4: Task 클로저에 직접 적용도 가능하다
Task { @MainActor in
self.title = "업데이트 완료"
}
MainActor 주의할 점
MainActor는 UI 작업을 안전하게 메인 스레드에서 실행하도록 보장해 주지만, 사용이 쉬운만큼 아무 곳에나 쓴다면 오히려 성능 저하와 새로운 버그를 불러올 수 있다.
1. 메인 스레드 차단 (UI Freezing)
@MainActor는 작업을 메인 스레드에서 실행하도록 강제한다.
즉, 무거운 작업(데이터 파싱, 이미지 필터링, 복잡한 연산)이 포함된 메서드가 @MainActor내부에 있다면, 그 작업이 끝날 때까지 화면이 멈춘다(Frame Drop).
→ 메인 스레드는 한 번에 하나의 작업만 처리할 수 있으므로, 작업이 많아질수록 UI가 느려지거나 멈추는 현상이 발생할 수 있다.
- 문제 상황: ViewModel 전체에 @MainActor를 선언하고, 그 안에서 CPU를 많이 쓰는 작업을 수행하는 경우.
- 현상: 스크롤이 버벅거리거나, 버튼을 눌러도 반응이 늦음.
@MainActor
class HeavyViewModel: ObservableObject {
@Published var data: [String] = []
// ❌ 위험: 이 함수는 메인 스레드에서 실행됨.
// 배열 정렬이나 무거운 연산이 길어지면 UI가 멈춤.
func processData() {
let sorted = (0..<100000).map { "\\($0)" }.sorted()
self.data = sorted
}
}
👉 해결책: UI와 관련 없는 로직은 nonisolated 키워드를 사용하거나, 별도의 Task.detached를 통해 백그라운드 스레드로 보내야 한다.
// ✅ 개선: 무거운 작업은 MainActor 격리에서 제외
nonisolated func processHeavyData() async -> [String] {
return (0..<100000).map { "\\($0)" }.sorted()
}
2. 동시성 상실 (Serial Bottleneck)
MainActor는 직렬(Serial) 실행을 보장한다. 이는 안전성을 주지만, 반대로 말하면 병렬(Parallel) 처리를 포기한다는 뜻이기도 하다.
- 문제 상황: 서로 의존성이 없는 여러 작업이 모두 @MainActor 안에 있거나, @MainActor 클래스 내부에서 실행될 경우, 실제로는 동시에 처리될 수 있는 일들도 한 줄로 서서(Serialization) 메인 스레드에서 하나씩 처리된다.
- 결과: 멀티 코어 성능을 활용하지 못하고 앱이 전반적으로 느려진다.
👉 해결책: 데이터 로딩, 네트워크 통신, 비즈니스 로직 등은 MainActor 밖(일반 Actor나 백그라운드)에서 처리하고, 결과를 UI에 반영하는 순간에만 MainActor를 타도록 범위를 좁혀야 한다.
그냥 UI 작업만 메인액터에서 처리한다고 생각하는 게 편하다. UI작업이 아니다? 메인 액터 쓰지마!
3. 재진입성(Reentrancy) 문제
(Reentrancy에 관해서는 이 뒤에서 더 자세하게 다룰 예정)
@MainActor 내부의 함수라도 await를 만나면 실행이 일시 중단(Suspend)된다.
이 중단된 시간 동안, 메인 스레드는 놀지 않고 다른 UI 이벤트(터치, 다른 버튼 클릭 등)를 처리하거나 MainActor 대기열에 있는 다른 작업을 실행한다.
- 문제: 함수가 await에서 깨어났을 때, 앱의 상태(State)가 await 전과 다를 수 있다.
- 시나리오:
- 사용자가 '새로고침' 버튼을 누름 (func refresh() 시작)
- await fetchData() (네트워크 요청 대기 → 실행 중단)
- 사용자가 기다리지 못하고 '삭제' 버튼을 누름 (데이터 소스가 비워짐)
- fetchData() 완료 후 복귀.
- 삭제된 데이터 소스에 새 데이터를 덮어쓰거나 접근하여 로직 충돌 발생.
@MainActor
func refresh() async {
let oldState = self.state // 1. 상태 저장
let newData = await fetchFromServer() // 2. 중단점 (여기서 다른 UI 이벤트 처리 가능)
// 3. 복귀 시점: self.state가 1번 시점과 같다고 보장할 수 없음!
// 그 사이 사용자가 로그아웃을 했을 수도 있음.
if self.state == oldState {
updateUI(newData)
}
}
👉 해결책: await 뒷부분의 코드를 작성할 때는 상태가 변했을 수 있다는 가정을 항상 해야한다. 필요한 경우 작업 취소(Task.checkCancellation)를 확인하거나 상태를 다시 검증해야 한다.
4. 테스트 환경에서의 어려움
- 비동기, await, MainActor 작업 분리 테스트가 복잡해질 수 있다.
- UI 테스트에서 백그라운드와 메인 스레드 분리가 명확하지 않으면 데이터 race 또는 테스트 실패로 이어질 가능성이 있다.
한 줄 요약: 내용이 너무 복잡하고 머리아프다면 그냥 MainActor는 UI와 직접 관련된 코드에만 제한적으로 적용하자!!!
GlobalActor
GlobalActor란?
GlobalActor는 이름 그대로 전역적으로 고유한(Singleton) 액터이다.
일반적인 actor는 인스턴스를 생성할 때마다 각자의 격리된 공간(Isolation Context)을 가진다.
반면, GlobalActor는 여러 타입이나 함수가 하나의 공통된 액터(실행 컨텍스트)를 공유하도록 만든다.
GlobalActor 사용법
@globalActor 속성을 붙이고, shared라는 이름의 정적(static) 액터 인스턴스를 포함하면 된다.
@globalActor
actor DatabaseActor {
static let shared = DatabaseActor()
}
왜 MainActor가 아닌 별도의 Global Actor가 필요할까?
UI와 무관하지만, 전역적으로 순차 처리가 필요한 작업 때문이다.
- 데이터베이스 접근 동기화: 앱 전역에서 발생하는 DB 읽기/쓰기 요청을 하나의 시리얼 큐(DatabaseActor)에서 처리하여 충돌 방지.
- 특수 하드웨어 제어: 블루투스나 특정 센서 등 동시에 하나의 접근만 허용해야 하는 리소스 관리.
- 전역 캐시(Cache) 시스템: 여러 스레드에서 동시에 접근하는 이미지 캐시 등을 안전하게 관리.
// 1. 커스텀 GlobalActor 정의
@globalActor
actor ImageCacheActor {
static let shared = ImageCacheActor()
}
// 2. 사용: 전역 캐시 매니저
// 이 클래스의 메서드들은 ImageCacheActor라는 단일 컨텍스트에서 실행됨을 보장받음
@ImageCacheActor
class ImageCacheManager {
private var cache: [String: UIImage] = [:]
func store(key: String, image: UIImage) {
cache[key] = image
}
func retrieve(key: String) -> UIImage? {
return cache[key]
}
}
// 3. 호출부
Task {
// 서로 다른 Task에서 호출하더라도, ImageCacheActor의 큐에 의해 순차적으로 실행됨
await ImageCacheManager().store(key: "profile", image: newImage)
}
한 마디로 말하명 Global Actor는 "이 코드들은 어디서 호출되든 하나의 전용 라인(Executor)을 타고 순서대로 실행해라."라는 의도로 사용 (특정 리소스의 전역적 동기화)
Actor의 한계 및 주의점
Deadlock (교착 상태)
Actor는 내부적으로 하나의 큐에 작업을 순서대로 처리한다.
동기/비동기 동작을 구분하고, 여러 작업이 기다릴 때 suspension(일시 중단)을 사용한다.
그런데 만약 여러 Actor 간에 서로 작업을 기다리고 있다면 교착상태(Deadlock)가 발생할 수 있다.
- Actor A: "B야, 작업 끝나면 나한테 알려줘." await B.work() -> A는 멈춤
- Actor B: "어? 그 작업하려면 A 너의 데이터가 필요한데?" await A.data -> B도 멈춤
- 결과: A는 B를 기다리고, B는 A를 기다림. 앱 멈춤.
두 개의 Actor가 서로를 참조하고 있을 때, 서로의 메서드를 호출하며 await를 걸면 영원히 서로를 기다리게 된다.
따라서 Swift의 Actor는 Reentrancy(재진입)를 통해 이런 교착상태를 최대한 피하려고 설계되어 있다.
await로 작업이 일시 중단(suspended)되는 동안, 다른 Task가 Actor의 작업을 차례대로 처리해 계속 진행되도록 한다.
하지만 그럼에도 actor 내에서 await가 걸리는 작업을 여러 번 반복하면, 예상치 못한 교착 상태가 발생할 수 있으므로, 작업 흐름을 단순하게 유지하는 것이 중요하다.
Reentrancy 재진입
Actor는 락(Lock)을 걸어주니까, 함수가 끝날 때까지 아무도 못 건드리겠지? 라고 생각할 수 있다.
하지만 위에서 설명한 DeadLock을 피하기 위한 수단인 Reentrancy로 인해 다른 스레드에게 예상치 못한 영향을 받을 수도 있다.
Actor는 한 번에 하나의 작업을 보장하지만, 함수 실행 도중 await를 만나면 실행을 멈추고(Suspend), 쥐고 있던 락을 놓는다. 이 틈을 타서 다른 작업이 Actor에 진입(Re-enter)하여 상태를 바꿔버릴 수 있다.
즉, Actor는 기본적으로 한 번에 하나의 작업만 처리하지만, await suspension 지점이 있다면 현재 작업이 재개되기 전에 새로운 작업이 실행될 수 있다.
예시 1
actor BankAccount {
var balance = 1000
func withdraw(_ amount: Int) async {
guard canWithdraw(amount) else { return }
guard await authorizeTransaction() else { return }
balance -= amount // 이때, balance가 다른 작업에 의해 변경됐을 수 있음!
}
}
await authorizeTransaction()에서 suspension이 발생하면, 그 사이에 다른 withdraw() 작업이 balance를 바꾸거나, 또 다른 작업이 실행될 수 있다.
예시 2
actor ImageDownloader {
var cache: [String: UIImage] = [:]
func image(from url: String) async -> UIImage {
// 1. 캐시 확인
if let cached = cache[url] { return cached }
// 2. 다운로드 (여기서 await!! -> 락 해제됨)
// 이 다운로드가 진행되는 동안, 다른 스레드가 또 image(from:)을 호출할 수 있음
let image = await download(from: url)
// 3. 캐시 저장
// 문제: await 동안 다른 작업이 이미 다운로드를 마치고 캐시에 저장했을 수도 있음.
// 결과적으로 똑같은 이미지를 두 번 다운로드하고, 덮어씌우는 낭비 발생.
cache[url] = image
return image
}
}
Actor의 상태 변화는 가급적 suspension 전, 즉 동기(synchronous) 코드에서 처리(vs. 비동기 처리)하며,
await 문장 전후로 상태(State)가 변했을 수 있다는 가정을 항상 해야 한다.
또한 await가 끝난 직후, 내가 원하던 상태인지 다시 확인하는 검증 로직을 추가하면 좋다.
성능상 한계
Actor의 가장 큰 장점인 직렬 구조(Serial Execution)는 성능 측면에서 가장 큰 단점이 되기도 한다.
Actor는 일종의 1차선 도로다. 아무리 빠른 스포츠카(CPU 코어)가 많아도, 1차선 도로에서는 앞차가 안 가면 뒤차도 못 간다.
따라서 만약 자주 접근하는 데이터를 하나의 Actor에 몰아넣으면, 수많은 작업이 그 Actor 앞에서 기다리게 된다.
멀티 코어를 활용해 동시에(Parallel) 처리할 수 있는 작업조차 Actor 안에 있으면 하나씩 처리되어 성능이 떨어진다.
해결책
- Actor 쪼개기: 거대한 하나의 Actor 대신, 역할별로 여러 Actor로 나눈다.
- nonisolated 활용: Actor 내부의 데이터(state)를 건드리지 않는 메서드는 nonisolated를 붙여서, 줄을 서지 않고 즉시 병렬로 실행되게 만든다.
actor StatisticManager {
var logs: [String] = []
func addLog(_ log: String) {
logs.append(log)
}
// 이 함수는 logs를 건드리지 않음 -> 굳이 줄 서서 기다릴 필요 없음
nonisolated func hash(data: String) -> String {
return data.hashValueString() // 병렬 실행 가능!
}
}
정리하자면, Actor를 사용할 때에는 다음과 같은 것들을 주의하며 사용해야한다.
- 이중 await과 상태 신뢰성: actor 함수 내 여러 await가 있을 때, 상태가 각 suspension 지점마다 예기치 않게 변경될 수 있음.
- actor끼리 서로 호출(chain call)하면 복잡한 suspension/재진입 문제 유발
- thread-safe와 reentrancy는 별개 개념: actor는 thread-safe를 보장하지만, suspension에 의한 reentrancy를 보장하진 않음.
- 직렬화된 실행의 오버헤드: 모든 작업이 순서대로 처리되므로, 실행 대기시간이 길어질 수 있음.
- 데이터 경합은 막지만, 논리적 충돌 가능성(비즈니스 로직/상태 관리)는 남음: 상태 변화가 suspension 지점에서 논리적으로 잘못될 수 있으니, 복잡한 로직은 suspension 전후 별도 체크 필수.
참고
https://sujinnaljin.medium.com/swift-actor-뿌시기-249aee2b732d
[Swift] Actor 뿌시기
근데 이제 async, Task 를 곁들인..
sujinnaljin.medium.com
Actor에 관한 설명은 이 아티클이 정말 바이블이라고 할 정도로 잘 설명되어 있다.
공부하면서 알게 되는 것이 조금씩 늘어날 때마다 다시 읽으면 매번 새롭게 이해되는 정말 대단한 글이라서 매우 추천!!
