⚠️ 이번 글은 실무에서 겪었던 일을 실수하지 않기 위해 남겨둡니다 ⚠️
콜백 기반 SDK 를 async/await 코드로 감싸면서 응답 타임아웃이 필요했습니다.
구현 아이디어는 단순했습니다.
- 첫 번째 작업에서 SDK 응답을 기다립니다.
- 두 번째 작업은 일정 시간 동안 기다린 후 타임아웃 값을 반환합니다.
- 먼저 끝난 작업의 결과를 사용합니다.
- 나머지 작업은 취소합니다.
func requestValue() async -> String? {
await withTaskGroup(of: String?.self) { group in
group.addTask {
await withCheckedContinuation { continuation in
LegacySDK.request { value in
continuation.resume(returning: value)
}
}
}
group.addTask {
try? await Task.sleep(for: .seconds(2))
return nil
}
let firstResult = await group.next() ?? nil
group.cancelAll()
return firstResult
}
}
SDK 가 2초 안에 응답하면 특별한 문제가 없어보이는데요.
문제는 SDK 가 어떠한 이유로 콜백을 호출하지 않을 때 발생하게 됩니다.
타임아웃 작업은 정상적으로 끝났지만 requestValue() 함수는 반환되지 않았습니다.
처음에는 group.next() 또는 group.cancelAll() 이 제대로 동작하지 않는다고 생각했지만, 실제 원인은
TaskGroup 과 continuation 의 종료 방식에 있었습니다.
group.next() 가 끝나도 TaskGroup 은 끝난 것이 아닙니다.
다음 코드는 가장 먼저 완료된 자식 작업의 결과를 가져오게 됩니다.
let firstResult = await group.next()
타임아웃 작업이 먼저 끝났다면 firstResult 에는 정상적으로 nil 이 들어오게 됩니다.
그 뒤엔 남은 작업을 취소하게 되는데요.
group.cancelAll()
여기까지만 보면 withTaskGroup 이 바로 종료될 것 처럼 보입니다.
하지만 cancelAll() 은 자식 작업에 취소를 요청할 뿐, 작업을 강제로 종료하지 않습니다.
withTaskGroup 은 스코프를 완전히 빠져나가기 전에 그룹에 추가된 모든 자식 작업이 종료되기를 기다립니다.
위 코드의 상황을 단순화해서 보면
TaskGroup
├── SDK 응답 작업: continuation 대기 중
└── 타임아웃 작업: 완료
타임아웃 결과를 가져온 후 SDK 응답을 취소하더라도, continuation 은 여전히 SDK 의 콜백을 기다리고 있게 됩니다.
Task 취소는 continuation 을 자동으로 resume 하지 않습니다.
continuation 을 사용한 다음 코드는 콜백이 호출되어야만 완료됩니다.
await withCheckedContinuation { continuation in
LegacySDK.request { value in
continuation.resume(returning: value)
}
}
여기서 중요한 점은 바깥쪽 Task 를 취소해도 continuation 이 자동으로 resume 되지 않는다는 것입니다.
SDK 가 콜백을 호출하지 않으면 다음 코드도 실행되지 않습니다.
continuation.resume(returning: value)
따라서 해당 자식 작업은 계속 대기하게 됩니다.
group.cancelAll() 을 호출했더라도 자식 작업이 취소 요청에 반응하여 실제로 종료된 것은 아닙니다.
결국 withTaskGroup 은 종료되지 않은 자식 작업을 계속 기다렸고, 함수 전체가 멈춘 것 처럼 보였습니다.
타임아웃도 같은 continuation을 종료하도록 변경하기
위의 문제를 해결하기 위해 TaskGroup 을 두는 방식이 아닌 동일한 continuation 을 종료하도록 구성하는 형태로 변경을
진행했습니다.
func requestValue() async -> String? {
await withCheckedContinuation { continuation in
let resumeOnce = ResumeOnce()
LegacySDK.request { value in
resumeOnce.perform {
continuation.resume(returning: value)
}
}
Task {
try? await Task.sleep(for: .seconds(2))
resumeOnce.perform {
continuation.resume(returning: nil)
}
}
}
}
SDK 응답이 먼저 들어오면 해당 값을 반환합니다.
continuation.resume(returning: value)
반대로 2초 동안 응답이 없으면 타임아웃 작업이 nil 을 반환하게 됩니다.
continuation.resume(returning: nil)
이제 SDK 가 콜백을 호출하지 않더라도 continuation 은 타임아웃 경로를 통해 반드시 종료될 수 있게 됩니다.
중복 resume 방지하기
SDK 응답과 타임아웃은 거의 동시에 실행될 수 있습니다.
예를들어 타임아웃이 continuation 을 먼저 종료한 직후 SDK 콜백이 도착할 수 있습니다.
이때 양쪽에서 모두 resume 을 호출하면 continuation 을 두 번 종료하게 될 수 있습니다.
Checked continuation 을 두 번 resume 하면 런타임 오류가 발생할 수 있으므로
두 경로 중 하나만 성공하도록 보호해야 합니다. 단순화한 ResumeOnce 구현은 다음과 같습니다.
final class ResumeOnce: @unchecked Sendable {
private let lock = NSLock()
private var hasResumed = false
func perform(_ action: () -> Void) {
lock.lock()
guard !hasResumed else {
lock.unlock()
return
}
hasResumed = true
lock.unlock()
action()
}
}
SDK 콜백과 타임아웃이 서로 다른 스레드에서 거의 동시에 실행될 수 있으므로 단순한 Bool 확인만으로는 충분하지
않을 수 있습니다.
if !hasResumed {
hasResumed = true
continuation.resume(returning: value)
}
이 코드는 확인과 변경 사이에 다른 실행 흐름이 끼어들 수 있습니다. 따라서 lock 이나 actor 등으로 상태 변경을 동기화해야 합니다.
UI 객체를 다룬다면 MainActor 도 고려해야 합니다.
@MainActor
func requestBanner() async -> BannerView? {
await withCheckedContinuation { continuation in
let resumeOnce = ResumeOnce()
let bannerView = BannerView()
bannerView.onComplete = { isAvailable in
resumeOnce.perform {
continuation.resume(
returning: isAvailable ? bannerView : nil
)
}
}
bannerView.load()
Task {
try? await Task.sleep(for: .seconds(2))
resumeOnce.perform {
continuation.resume(returning: nil)
}
}
}
}
UIKit은 기본적으로 스레드 안전하게 설계된 프레임워크가 아닙니다.
따라서 UIView를 생성하거나 뷰의 속성을 변경하고, 화면 계층에 추가하는 것과 같은 UI 작업은 메인 스레드에서 수행해야 합니다.
위 코드에서는 BannerView를 생성하고, 콜백을 설정하고, 광고 로드를 시작하는 과정이 모두 메인 액터에서 실행됩니다.
다만, continuation 의 resume 자체가 반드시 메인 액터에서 실행되어야 하는 것은 아닙니다.
resume 은 중단된 async 작업을 다시 실행 가능한 상태로 만드는 동작입니다.
메인 액터가 필요한 이유는 continuation 때문이 아니라, 그 주변에서 UIKit 객체를 생성하거나 변경하기 때문입니다.
또한 SDK 콜백이 항상 메인 스레드에서 전달된다고 가정해서는 안됩니다. 콜백 안에서 UI를 직접 변경해야 한다면 명시적으로 메인 액터로 이동해야합니다.
이번 문제에서 배운 점
TaskGroup 으로 여러 작업을 경쟁시킨 뒤 group.next() 로 첫 번째 결과를 가져왔다고 해서 그룹이 곧바로 종료되는 것은 아닙니다.
또한 다음 코드도 남은 작업을 강제로 종료하지 않습니다.
group.cancelAll()
취소는 협력적으로 동작합니다. 자식 작업이 취소 상태를 확인하거나, 취소되었을 때 대기를 끝낼 수 있도록 구현되어 있어햐 합니다.
특히, continuation 은 task 취소만으로 자동 종료되지 않습니다. 성공, 실패, 타임아웃, 취소 등 모든 실행 경로 중 하나에서
반드시 정확히 한번만 resume 되어야 합니다.
⭐ 덧붙여서 콜백 API 를 async/await 로 변환할 때는 정상적인 응답 뿐 아니라 다음 상황도 반드시 고려해야 합니다.
- SDK 가 콜백을 호출하지 않는 경우
- 타임아웃이 먼저 발생하는 경우
- 상위 Task 가 취소되는 경우
- 타임아웃 직후 콜백이 도착하는 경우
- 콜백이 실수로 여러 번 호출되는 경우
⭐ Continuation 에서 가장 중요한 규칙은 모든 경로에서 한번만 resume 해야한다는 것 입니다.
'Concurrency' 카테고리의 다른 글
| [Concurrency] async let 과 TaskGroup (0) | 2026.05.09 |
|---|---|
| [Concurrency] for await 알아보기 - AsyncSequence와 비동기 반복문 (0) | 2026.05.04 |
| [Concurrency] 연속 탭 버그, Task cancel 하나로 해결하기 (0) | 2026.04.19 |
