Swift actor의 await 뒤에서 상태가 바뀌는 이유와 캐시 수정 예제
Swift actor 안에서도 await로 실행이 중단되면 다른 호출이 같은 actor의 상태를 바꿀 수 있어요. 이를 재진입(reentrancy)이라고 해요. actor는 격리된 상태의 동시 접근을 막지만, 비동기 메서드 전체를 하나의 작업처럼 보장하지는 않아요. 캐시라면 await 뒤에 현재 값을 다시 확인하고, 확인부터 저장까지를 중단 없는 동기 구간으로 묶는 것이 기본 수정 방법이에요. Swift actor 설계 문서

재진입 재현과 수정: ActorCacheDemo.swift
// 외부 패키지와 네트워크가 필요 없는 예제입니다.
// ManualDownload는 요청 하나를 제어하는 테스트 전용 장치입니다.
actor ManualDownload {
private var response: CheckedContinuation<String, Never>?
private var started: CheckedContinuation<Void, Never>?
func fetch() async -> String {
await withCheckedContinuation { continuation in
response = continuation
started?.resume()
started = nil
}
}
func waitUntilStarted() async {
if response != nil { return }
await withCheckedContinuation { continuation in
started = continuation
}
}
func finish(with value: String) {
guard let continuation = response else {
preconditionFailure("다운로드 시작 후 완료해야 합니다")
}
response = nil
continuation.resume(returning: value)
}
}
actor TextCache {
private var values: [String: String] = [:]
private let recheck: Bool
init(recheck: Bool) {
self.recheck = recheck
}
func store(_ value: String, for key: String) {
values[key] = value
}
func cached(_ key: String) -> String? {
values[key]
}
func value(for key: String, from download: ManualDownload) async -> String {
if let existing = values[key] { return existing }
let downloaded = await download.fetch()
// 수정: 재개 시점에 캐시가 채워졌다면 그 값을 유지합니다.
// recheck가 false이면 문제를 재현합니다.
if recheck, let existing = values[key] {
return existing
}
// 재확인부터 저장까지 await가 없습니다.
values[key] = downloaded
return downloaded
}
}
@main
struct Demo {
static func main() async {
for fixed in [false, true] {
let cache = TextCache(recheck: fixed)
let download = ManualDownload()
let request = Task {
await cache.value(for: "profile", from: download)
}
await download.waitUntilStarted()
await cache.store("new", for: "profile")
await download.finish(with: "old")
let returned = await request.value
let stored = await cache.cached("profile")
let expected = fixed ? "new" : "old"
precondition(returned == expected)
precondition(stored == expected)
print("\(fixed ? "수정 후" : "수정 전"): 반환=\(returned), 캐시=\(stored ?? "없음")")
}
}
}
// 예상 출력:
// 수정 전: 반환=old, 캐시=old
// 수정 후: 반환=new, 캐시=new
컴파일과 실행
swiftc -swift-version 6 -parse-as-library ActorCacheDemo.swift -o ActorCacheDemo
./ActorCacheDemo

캐시가 비어 있었다는 사실에는 유효기간이 있어요
캐시 조회에서 값이 없었다면 다운로드를 시작하겠죠. 하지만 다운로드를 기다리는 동안 다른 호출이 값을 저장할 수 있어요. 처음 호출이 돌아와 다운로드 결과를 무조건 저장하면, 그사이 등록된 값을 덮어써요. 메모리에 동시에 쓰지 않았어도 앱이 기대한 순서는 깨지는 거예요. Apple의 actor 설명도 이 캐시 문제와 재개 후 재확인을 다뤄요. Apple의 캐시 예제
await는 반드시 중단된다는 뜻이 아니라 중단될 수 있다는 표시예요. 따라서 몇 번 실행해서 문제가 없었다고 해도 안전하다는 증거는 아니에요. 검토할 대상은 await 전에 읽은 값으로 await 뒤의 결정을 내리는 부분이에요. Swift의 await 표현식
기다리는 시간을 조절하지 말고 실행 순서를 고정해요
‘재진입 재현과 수정: ActorCacheDemo.swift’는 다운로드 대신 테스트용 응답 대기 장치를 사용해요. 다운로드 요청이 도착했다는 신호를 받은 뒤 캐시를 갱신하고, 마지막으로 이전 응답을 돌려줘요. 네트워크 속도나 임의의 sleep에 기대지 않고 문제를 일으키는 순서를 만들기 위한 예제예요.
Swift 6 컴파일러가 설치된 macOS나 Linux에서 파일을 저장한 뒤, ‘컴파일과 실행’ 명령을 사용하세요. 코드에 적은 예상 출력과 precondition을 확인하면 돼요. 실행 결과를 측정한 기록이 아니라, 직접 확인할 수 있도록 작성한 예제예요. @main 진입점이 있는 파일을 위한 -parse-as-library 옵션은 Swift 드라이버에 정의되어 있어요. Swift 컴파일러 옵션
수정의 핵심은 재확인 조건과 저장 사이에 await가 없다는 점이에요. 재확인한 뒤 다른 비동기 검사를 넣으면 또다시 상태가 달라질 기회가 생겨요. 함께 성립해야 하는 상태 변경은 동기 메서드로 묶으면 검토하기 쉬워요. Apple의 재진입 설계 지침
재확인만으로 해결되는 범위를 정해요
이 예제의 정책은 ‘응답이 돌아왔을 때 캐시에 값이 있으면 그 값을 유지한다’예요. 문자열의 old와 new는 상황을 설명하는 이름일 뿐, 코드가 서버 데이터의 최신 버전을 판별하는 것은 아니에요. 실제로 최신 버전을 선택해야 한다면 응답의 버전 정보와 비교하는 별도 정책이 필요해요.
캐시 삭제에는 다른 문제가 있어요. 로그아웃으로 캐시를 비웠는데 이전 요청이 돌아오면, 재확인 시에도 빈 캐시여서 과거 데이터를 다시 저장할 수 있죠. 이런 요구사항에는 세대 번호를 두는 방식을 고려하세요. 요청 전에 번호를 보관하고, 무효화할 때 번호를 바꾼 뒤, 응답 처리 시 번호가 달라졌으면 저장과 반환을 거부하도록 설계할 수 있어요.
같은 키의 중복 다운로드를 줄이는 것도 별도 과제예요. 이 수정은 이미 시작된 요청을 합치지 않아요. 중복 요청까지 막아야 한다면 키별 진행 중 Task를 보관해 공유하는 설계로 확장하고, 실패한 Task를 언제 제거할지와 취소 정책을 함께 정하세요. Apple도 재확인과 중복 다운로드 방지를 구분해 설명해요. Apple의 actor 캐시 설명
실제 코드에 적용할 때는 먼저 캐시 삭제나 갱신이 다운로드 도중 발생하는 경우를 테스트에 추가해 보세요. 그때 어떤 값을 반환해야 하는지 정하면, 단순 재확인으로 충분한지 버전 검사까지 필요한지가 분명해져요.