Async/Await (12)
시나리오: 간단한 숫자 증가 앱
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Counter {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
struct ContentView: View {
var body: some View {
Button {
let counter = Counter()
print(counter.increment())
print(counter.increment())
} label: {
Text("Increment")
}
}
}
위와 같이 버튼을 누르면 숫자가 증가하는 앱이 있다.
버튼을 누르면 다음과 같이 동작한다.
1
2
1
2
그렇다면 Concurrent Queue처럼 동시에 실행하면 어떻게 될까?
과연 우리가 원하는대로 작동할까
1
2
3
DispatchQueue.concurrentPerform(iterations: 10) { _ in
print(counter.increment())
}
이렇게 하면 우리가 정한 횟수만큼 동시에 작동하게 된다.
결과는?
1
2
3
4
5
6
7
8
9
10
4
2
9
1
8
6
3
5
10
7
우리가 생각한대로 1부터 10까지 순서대로 나오는 숫자가 아니다.
이걸 Race Condition
이라고 한다.
여러 쓰레드 또는 작업이 동시에 동일한 자원(데이터 또는 상태)에 접근하고, 그 순서가 실행마다 달라져 예측할 수 없는 결과를 초래하는 상황을 말한다. 즉, 동시에 실행되는 작업들이 공유 자원에 접근하여 결과가 실행 순서에 따라 달라지는 상황.
여기선 동시에 실행되는 작업은 increment이고 공유 자원이란 value를 말한다.
Race Condition이 발생하는 이유는 값(value)을 읽는 작업과 쓰는 작업이 동시에 실행되며, 각 작업이 서로의 상태를 고려하지 못하기 때문이다. 예를 들어, 쓰레드 A가 값을 읽고 증가시키기 전에 쓰레드 B가 값을 수정하면, 쓰레드 A는 이전 상태를 기준으로 덮어쓰기 때문에 결과가 엉키게 된다.
그렇다면 class를 struct로 바꾼다면?
1
2
3
4
5
6
7
8
struct Counter {
var value = 0
mutating func increment() -> Int {
value += 1
return value
}
}
이때 struct의 값은 바뀌지 않기에 값이 변하는 함수를 구현할때 반드시 mutating
을 작성해줘야한다.
1
2
3
4
5
6
7
8
9
10
3
1
4
6
7
8
9
10
2
5
하지만 결과는 같다.
내부에 instance를 해주면?
1
2
3
4
5
let counter = Counter()
DispatchQueue.concurrentPerform(iterations: 10) { _ in
var counter = counter
print(counter.increment())
}
1
2
3
4
5
6
7
8
9
10
1
1
1
1
1
1
1
1
1
1
이제는 결과가 1만 나온다.
이건 counter 인스턴스가 계속 새롭게 생성되기에 값이 0부터 1씩 더해지는 작업이 반복되는것
Actor
이를 해결하기 위한 방법이 Actor의 사용이다.
1
2
3
4
5
6
7
8
actor Counter {
var value = 0
func increment() -> Int {
value += 1
return value
}
}
내부는 우리가 class나 struct를 사용하는것과 같은 형태이다.
특이점이라면
하나의 Thread만 호출되고 또한 내용을 업데이트하게 된다는 점이다.
그리고 또 하나 특이점이 생기는데 increment 함수가 비동기 함수로 바뀐다는것.
즉 해당 함수를 호출하기 위해선 Task, await가 추가로 필요해진다.
1
2
3
4
5
DispatchQueue.concurrentPerform(iterations: 10) { _ in
Task {
print(await counter.increment())
}
}
1
2
3
4
5
6
7
8
9
10
1
2
3
4
5
6
7
8
9
10
이젠 순서대로 출력이 된다.
하지만 다시 누르게 되면 제대로 될떄도 있고 안될때가 있는데
이걸 제대로 원하는대로 하려면
1
2
3
4
5
func increment() -> Int {
value += 1
print(value)
return value
}
출력을 여기서 하면 된다.
print 자체가 비동기적으로 이루어지다 보니 출력이 꼬일수도 있는것.
시나리오: Actor를 사용한 간단한 은행 인출 앱
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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class BankAccountViewModel: ObservableObject {
private var bankAccount: BankAccount
@Published var currentBalance: Double?
@Published var transactions: [String] = []
init(balance: Double) {
bankAccount = BankAccount(balance: balance)
}
func withdraw(_ amount: Double) {
bankAccount.withdraw(amount)
DispatchQueue.main.async {
self.currentBalance = self.bankAccount.getBalance()
self.transactions = self.bankAccount.transactions
}
}
}
class BankAccount {
private(set) var balance: Double
private(set) var transactions: [String] = []
init(balance: Double) {
self.balance = balance
}
func getBalance() -> Double {
return balance
}
func withdraw(_ amount: Double) {
if balance >= amount {
let processingTime = UInt32.random(in: 0...3)
print("[Withdraw] Processing for \(amount) \(processingTime) seconds")
transactions.append("[Withdraw] Processing for \(amount) \(processingTime) seconds")
sleep(processingTime)
print("Withdrawing \(amount) from account")
transactions.append("Withdrawing \(amount) from account")
self.balance -= amount
print("Balance is \(balance)")
transactions.append("Balance is \(balance)")
}
}
}
struct ContentView: View {
@StateObject private var bankAccountVM = BankAccountViewModel(balance: 500)
let queue = DispatchQueue(label: "ConcurrentQueue", attributes: .concurrent)
var body: some View {
VStack {
Button("Withdraw") {
queue.async {
bankAccountVM.withdraw(200)
}
queue.async {
bankAccountVM.withdraw(500)
}
}
Text("\(bankAccountVM.currentBalance ?? 0.0)")
List(bankAccountVM.transactions, id: \.self) { transaction in
Text(transaction)
}
}
}
}
지금은 이런식으로 되어있다.
실행해서 버튼을 누르면 이전의 playground처럼 나온다.
Actor를 사용하여 해결하기
ViewModeld을 class에서 actor로 바꾸자.
그러면 발생하는 에러
Actor-isolated 메서드인 withdraw를 동기적(synchronous) 이고 Actor와 분리된(non-isolated) 컨텍스트에서 호출하려고 시도할 때 발생한다. Swift의 Actor는 내부 상태를 보호하기 위해 비동기적(async) 접근만 허용한다.
즉, Actor 외부 컨텍스트에서 Actor의 메서드나 속성을 호출하려 할 때 해당 에러가 발생한다. 이 에러는 Swift의 동시성 모델에서 Race Condition 방지와 상태 안정성을 보장하기 위함이다.
1
2
3
4
5
6
func withdraw(_ amount: Double) async {
await bankAccount.withdraw(amount)
self.currentBalance = await self.bankAccount.getBalance()
self.transactions = await self.bankAccount.transactions
}
그리고 버튼에서 작동하던 부분도
1
2
3
4
5
6
7
Task.detached {
await bankAccountVM.withdraw(200)
}
Task.detached {
await bankAccountVM.withdraw(500)
}
Task.detached
를 사용하면서 2개의 다른 액션을 동시에 하기 위함이다.
이렇게 하나만 작동하는걸 확인할 수 있다.
이렇게 실행에 따라 결과가 다르게 나올수 있다. 왜냐 작업을 동시에 하니까.
하지만 콘솔을 보면
1
Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
다음과 같은 에러가 발생한다.
예전에 UIKit을 할때 UI변경에 대한내용이 Main Thread에서 이루어지지 않았다는 것과 같은 맥락으로 보면 된다.
해당부분을 해결하기위해
class에 @MainActor
를 추가해주자.
이젠 해당 에러가 뜨지 않는다.
시나리오: Actor를 사용한 간단한 은행 송금 앱
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
32
33
34
35
36
37
38
39
40
41
42
43
44
class BankAccount {
let accountNumber: Int
var balance: Double
init(accountNumber: Int, balance: Double) {
self.accountNumber = accountNumber
self.balance = balance
}
func deposit(_ amount: Double) {
balance += amount
}
func transfer(amount: Double, to other: BankAccount) async throws {
if amount > balance {
throw BankError.insufficientFunds(amount)
}
balance -= amount
other.balance += amount
print("Current Account: \(balance), Other Account: \(other.balance)")
}
}
struct ContentView: View {
var body: some View {
Button {
let bankAccount = BankAccount(accountNumber: 123, balance: 500)
let otherAccount = BankAccount(accountNumber: 456, balance: 100)
DispatchQueue.concurrentPerform(iterations: 100) { _ in
try? bankAccount.transfer(amount: 300, to: otherAccount)
}
} label: {
Text("Transfer")
}
}
}
여러번 실행하다보면
1
2
3
4
5
6
Current Account: 200.0, Other Account: 400.0
Current Account: 200.0, Other Account: 400.0
Current Account: 200.0, Other Account: 400.0
Current Account: 200.0, Other Account: 400.0
Current Account: -100.0, Other Account: 700.0
Current Account: -100.0, Other Account: 700.0
이런 문제가 발생한다.
actor 문제 해결하기
이번에도 역시 Actor를 사용하여 해결해보자.
역시나 발생하는 에러
하지만 아까와는 결이 다르다.
아까는 actor로 바꾼 BanckAccount에 대해 호출을 할때 생긴 에러였다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// before
func withdraw(_ amount: Double) {
bankAccount.withdraw(amount)
DispatchQueue.main.async {
self.currentBalance = self.bankAccount.getBalance()
self.transactions = self.bankAccount.transactions
}
}
// after
func withdraw(_ amount: Double) async {
await bankAccount.withdraw(amount)
self.currentBalance = await self.bankAccount.getBalance()
self.transactions = await self.bankAccount.transactions
}
이건 위에서도 언급 했지만 Actor 외부 컨텍스트에서 Actor의 메서드나 속성을 호출할때 발생해서, 호출한 부분에 대해 비동기적으로 작업하는 async/await
를 사용해주었다.
이것도 위에서 언급했찌만 Actor는 내부 상태를 보호하기 위해 비동기적(async) 접근만 허용하기 떄문.
다시 돌아와서 지금 문제는 actor 내부 상태(예: balance)를 actor 외부 컨텍스트에서 직접 수정하려고 시도할 때 발생한다.
왜 외부에서 수정하냐라고 생각한다면
1
2
3
4
func transfer(amount: Double, to other: BankAccount) throws {
let bankAccount = BankAccount(accountNumber: 123, balance: 500)
let otherAccount = BankAccount(accountNumber: 456, balance: 100)
bankAccount와 otherAccount가 같은 BankAccount지만 실제로는 두개는 서로 다른 녀석들이고 그게
1
2
3
4
5
6
7
8
9
10
11
12
func transfer(amount: Double, to other: BankAccount) throws {
if amount > balance {
throw BankError.insufficientFunds(amount)
}
balance -= amount
other.balance += amount
}
DispatchQueue.concurrentPerform(iterations: 100) { _ in
try? bankAccount.transfer(amount: 300, to: otherAccount)
}
여기서 서로 다른녀석들을 바꾸려고 하기 때문이다.
상황을 간단하게 말하면
자기 자신에 대한 값을 변경하는건 괜찮으나, 여기서 문제가 되는 포인트는 other.balance
이고 A라는 BankAccount 객체가 B라는 BankAccount 객체의 값을 바꾸기 때문이다.
외부에서 내부를 수정한다.
Swift는 actor 내부 상태를 보호하기 위해 동시 접근을 제한하므로, actor-isolated 속성에 대한 외부 수정은 허용되지 않는다.
해당 부분을 해결하기 위해
값을 수정하는 부분을 외부에서 수정하는게 아닌, 자기자신이 직접 수정하게 하는 걸로 바꾸어야 한다.
즉 새롭게 함수를 하나 더 만들어 줘야 한다는것을 의미
1
2
3
func deposit(_ amount: Double) {
balance += amount
}
그리고 자기자신이 직접 바꾸게 이렇게 적어준다.
1
2
3
4
// before
other.balance += amount
// after
other.deposit(amount)
차이점이라면 아까는 외부의 값이 다이렉트로 변경 되었다면,
이번엔 외부의 객체가 자기자신의 balance 값을 변경하는것.
같은 other를 쓰고 balance의 값을 바꾸지만,
외부에서 들어온 other(B)의 balance가 A에서 바뀌느냐,
외부에서 들어온 other(B)가 B스스로 deposit 함수를통해 직접 바꾸느냐의 차이이다.
즉 능동/수동의 차이이다.
역시나 발생하는에러 이건 조금전에 했떤 내용과 같다. actor에서 호출하는건 내부를 보호하기위해 비동기적 접근만 허용하기 때문.
1
2
3
4
5
6
7
8
func transfer(amount: Double, to other: BankAccount) async throws {
if amount > balance {
throw BankError.insufficientFunds(amount)
}
balance -= amount
await other.deposit(amount)
}
아이러니한건 other.accountNumber에서는 에러가 뜨지않고 other.balance만 뜬다.
accountNumber는 let인 상수이기에 값이 변하지 않기에 Race Condition, Concurrency 같은 어떠한 에러도 발생하지 않는다. 하지만 balance는 var로 값이 변하는 변수이기에 실제 오브잭트나, 클래스 밖에서도 값이 변한다.
그래서 await
를 사용하면 오직 1개의 Thread가 balance에 접근하게 된다.
이건 DispatchQueue 내부에 Task를 추가 그리고 transfer가 actor 내부에 있으므로 await를 추가로 적어주면 해결
1
2
3
4
5
6
7
8
9
10
11
// before
DispatchQueue.concurrentPerform(iterations: 100) { _ in
try? bankAccount.transfer(amount: 300, to: otherAccount)
}
// after
DispatchQueue.concurrentPerform(iterations: 100) { _ in
Task {
try? await bankAccount.transfer(amount: 300, to: otherAccount)
}
}
실행하면
다음과 같은 결과가 나온다.
1
2
3
4
456
Current Account: 200.0, Other Account: 400.0
456
Current Account: 200.0, Other Account: 400.0
이젠 여러번 눌러도 값이 -로 떨어지는 그런 문제는 발생하지 않는다.
nonisolated
그렇다면 이렇게 생각도 해볼 수 있다.
아무 데이터도 접근하지 않고, 그냥 순수하게 값만 리턴하는 함수는 어떻게 될까?
BankAccount 내부에 다음과 같이 함수를 만들어주고
1
2
3
func getCurrentAPR() -> Double {
return 0.2
}
이렇게 사용해보려고하니 해당 함수는 이미 aysnc가 있는 비동기 함수로 바뀌게 되었다.
하지만 해당기능은 작업하는 Thread가 서로 달라도 크게 의미가 없는 함수인데도 비동기 처리를 해야할까?
이런 생각이 들수 있다.
이때 사용하는게 nonisolated
이다.
1
2
3
nonisolated func getCurrentAPR() -> Double {
return 0.2
}
비동기 함수가 없어진걸 알수있다.
그렇다면 nonisolated 로 선언된 함수에 값을 변경하게 한다면?
바로 에러가 발생.
isolated vs non-isolated
구분 | isolated | nonisolated |
---|---|---|
정의 | Actor 내부의 상태에 접근하거나 수정하는 메서드. | Actor 내부 상태에 의존하지 않는 메서드. |
특징 | - Actor의 상태 보호를 위해 반드시 await 사용.- 다른 작업과 직렬화된 동작 보장. | - Actor 외부에서 동기적으로 호출 가능. - 내부 상태에 접근하지 않으므로 동시성 문제가 없음. |
비동기 여부 | 기본적으로 async 메서드로 동작. | 동기적(synchronous)으로 동작. |
상태 접근 | Actor 내부의 가변 상태(var )를 안전하게 접근 및 수정 가능. | Actor 내부 상태에 접근하거나 수정할 수 없음. |
사용 예시 | - func withdraw(amount: Double) async { ... } - func deposit(amount: Double) async { ... } | - nonisolated func getCurrentAPR() -> Double { return 0.2 } |
주요 목적 | - Race Condition 방지. - Actor 내부 상태를 안전하게 보호. | - 단순 값 리턴 등 상태에 의존하지 않는 작업 수행. |
장점 | - 동시성 모델을 준수하여 안전한 상태 관리 가능. | - 상태 보호가 필요 없는 작업에서 불필요한 비동기 작업을 줄여 성능 최적화. |
단점 | - await 가 필요하므로 호출 측에서 비동기 문맥을 강제. | - Actor 내부 상태를 수정할 수 없으므로 적합하지 않은 작업에 사용할 경우 오류 가능. |
주요 사용 사례 | - 잔액 인출 및 입금과 같이 Actor 내부 상태를 수정하는 작업. | - 단순 계산이나 상수 반환처럼 Actor 내부 상태와 무관한 작업. |