actor는 컴파일러 수준에서 data race를 막아주지만, await 지점에서는 예상과 다른 실행 흐름이 생길 수 있습니다. Reentrancy라고 부르는 이 동작은 actor가 의도적으로 허용하는 설계이면서도, 인식하지 못하면 미묘한 버그로 이어질 수 있습니다.
이 글에서는 토큰 재갱신을 위한 TokenRefreshService를 예시로 들어 actor가 data race를 막는 원리부터 reentrancy 문제의 발생 조건, 그리고 해결 패턴까지 정리합니다.
actor는 내부 상태에 대한 모든 접근을 Serial Executor를 통해 직렬화합니다. 한 번에 하나의 Task만 actor 내부에서 실행될 수 있고, 외부에서 격리된 프로퍼티나 메서드에 접근하려면 반드시 await가 필요합니다. 이 규칙을 컴파일러가 강제하기 때문에, 규칙을 어기면 빌드 자체가 실패합니다.
actor Counter {
var count = 0
func increment() { count += 1 }
}
let counter = Counter()
// 컴파일 에러: actor-isolated property 'count' can not be mutated from a non-isolated context
counter.count += 1
// 올바른 접근: await를 통해 actor 컨텍스트에서 실행
await counter.increment()
GCD를 사용할 때는 올바른 큐를 개발자가 직접 지정해야 하고, 실수하면 런타임 크래시가 발생합니다. actor는 이 책임을 컴파일러에게 위임합니다.
Serial Executor가 동시 진입을 막아준다면 data race는 완전히 해결된 것처럼 보입니다. 그런데 actor에는 중요한 점이 있습니다. actor는 await 지점에서 재진입(reentrancy)을 허용합니다.
SE-0306 Actors proposal은 이유를 명시합니다. await에서 actor 자신을 완전히 잠궈버리면 교착 상태(deadlock) 위험이 생기고, 다른 Task들이 불필요하게 블로킹됩니다. 그래서 actor는 await에서 suspend 상태가 되는 순간, 대기 중인 다른 Task의 진입을 허용합니다.
actor TokenRefreshService {
private var token: String?
func getValidToken() async throws -> String {
if let token { return token }
// ⚠️ 여기서 suspend — 다른 Task가 actor에 진입할 수 있음
let newToken = try await fetchNewToken()
token = newToken // 여러 Task가 각각 이 줄에 도달해 중복 갱신 발생
return newToken
}
}
여러 Task가 동시에 getValidToken()을 호출하면 다음 순서로 실행됩니다.
token == nil 확인 후 fetchNewToken()에서 suspendtoken == nil 확인 (Task A가 아직 token을 업데이트하지 않았기 때문)fetchNewToken() 호출 → 중복 네트워크 요청 발생await 전후로 actor의 상태 가정이 달라질 수 있다는 것이 reentrancy의 본질입니다. WWDC21 "Protect mutable state with Swift actors" 세션은 이 원칙을 다음과 같이 표현합니다.
In implementing your actors, and in any asynchronous code, always design for reentrancy; an await in your code means the world can move on and invalidate your assumptions.
— WWDC21 Session 10133
핵심 원칙은 하나입니다. await에 도달하기 전에, 다른 Task가 중복 작업을 시작하지 않도록 상태를 먼저 확정짓는다.
TokenRefreshService에서는 진행 중인 Task를 activeTask 프로퍼티에 저장하는 방식으로 해결했습니다.
actor TokenRefreshService {
private var token: String?
private var activeTask: Task<String, Error>?
func getValidToken() async throws -> String {
if let token { return token }
// 갱신이 이미 진행 중이면 동일한 Task의 결과를 공유
if let task = activeTask { return try await task.value }
// await 이전에 Task를 저장 — 이후 진입하는 모든 호출은 위 분기에서 처리됨
let task = Task { try await fetchNewToken() }
activeTask = task
defer { activeTask = nil }
let newToken = try await task.value
token = newToken
return newToken
}
}
activeTask = task 할당이 핵심입니다. 이 라인은 await 전에 동기적으로 실행됩니다. Task A가 suspend되는 순간 Task B가 진입해도, 이미 activeTask가 설정되어 있어 두 번째 if let 분기에서 동일한 Task의 결과를 대기합니다. 네트워크 요청은 단 한 번만 발생하고, 모든 호출자가 같은 결과를 받습니다.
같은 문제를 다루는 다른 패턴도 있습니다.
상태를 enum으로 명시적으로 표현합니다. activeTask와 동일한 원칙이지만, 상태 전이가 코드에서 더 명확하게 드러납니다.
enum RefreshState {
case inProgress(Task<String, Error>)
case loaded(String)
}
actor TokenRefreshService {
private var state: RefreshState?
func getValidToken() async throws -> String {
switch state {
case .loaded(let token):
return token
case .inProgress(let task):
return try await task.value
case nil:
let task = Task { try await fetchNewToken() }
state = .inProgress(task) // await 전에 상태 확정
let newToken = try await task.value
state = .loaded(newToken)
return newToken
}
}
}
만료 토큰 재갱신이나 오류 후 재시도 같은 케이스를 핸들링하려면 enum 케이스를 확장하면 되어 확장성이 좋습니다. 다만 상태 케이스가 늘어날수록 switch 구문 관리가 복잡해집니다.
swift-async-algorithms 패키지의 AsyncChannel을 이용하면 대기 중인 호출을 큐로 관리하고, 갱신 완료 시 일괄 해소하는 방식으로 구현할 수 있습니다. 호출 순서 보장이 필요한 경우에 적합하지만, 외부 패키지 의존성이 생기고 구현 복잡도가 높아집니다.
| 접근법 | 핵심 전략 | 장점 | 단점 |
|---|---|---|---|
| activeTask 패턴 | 진행 중인 Task를 저장, await 전 상태 확정 | 간결, 추가 의존성 없음 | 재시도 로직 추가 시 관리 복잡 |
| LoadingTask enum | 상태를 enum 케이스로 명시 | 상태 전이가 코드에서 명확함 | 케이스 증가 시 switch 복잡 |
| AsyncChannel | 대기자를 큐로 관리 | 호출 순서 보장 가능 | 외부 패키지 의존성 |
actor reentrancy를 다룰 때 적용할 수 있는 원칙을 하나로 정리하면, "await 앞에서 세운 상태 가정은 await 뒤에서 유효하지 않을 수 있다. 중복 작업을 막으려면 await 이전에 상태를 확정지어라" 입니다.
글에 대한 피드백이나 더 좋은 방법이 있다면 댓글로 공유해주세요.