안녕하세요, 루피입니다.
이번 포스팅은 Swift Concurrency 2번째 시간입니다. 오늘은 Task의 지연 실행에 대해 깊이 있게 다뤄보겠습니다.
바로 시작합니다.
왜 Task 지연이 필요할까?
다음과 같은 상황에서 지연 실행이 필요합니다.
- 검색 디바운싱: 사용자가 타이핑을 멈춘 후 0.3초 뒤 검색 실행
- 로딩 스피너 지연 표시: 빠른 작업 시 UI 깜빡임 방지
- 툴팁 표시 지연: 사용자가 버튼 위에 1초간 머물 때만 표시
- 네트워크 재시도: 실패 후 일정 시간 대기 후 재시도
이런 패턴들은 사용자 경험을 크게 개선하며, 팀 협업 환경에서 일관된 UX를 제공하는데 중요한 역할을 합니다.
Task 지연 시키기
대부분의 경우, 우리는 다양한 비동기 작업들이 생성된 후 가능한 빨리 시작되기를 원하지만, 때로는 실행에 약간의 지연을 추가하고 싶을 수 있습니다. 다른 작업이 먼저 완료될 시간을 주거나, 어떤 형태의 "디바운싱" 동작을 추가하기 위해서 말입니다.
Swift Task를 특정 지연 시간과 함께 실행하는 직접적이고 내장된 방법은 없지만, 실제로 작업을 수행하기 전에 작업이 주어진 나노초 동안 sleep하도록 지시함으로써 그러한 동작을 달성할 수 있습니다.
// 이런 기능은 Swift에 없음
Task.delayed(seconds: 2) {
print("2초 후 실행")
}
// 실제 구현 방법
Task {
try await Task.sleep(nanoseconds: 2_000_000_000) // 2초 대기
print("2초 후 실행") // 실제 작업
}
시간 단위 변환 참고표
// 자주 사용하는 시간 단위 변환
let oneSecond = 1_000_000_000 // 1초
let halfSecond = 500_000_000 // 0.5초
let oneMillisecond = 1_000_000 // 1밀리초
let oneHundredMs = 100_000_000 // 0.1초
Task.sleep vs 기존 방법들 비교
1. blocking vs non-blocking의 차이
// ❌ 잘못된 방법 - 스레드 차단
sleep(1) // 스레드를 완전히 차단합니다.
// ✅ 올바른 방법 - Non-blocking
Task {
try await Task.sleep(nanoseconds: 1_000_000_000)
print("1초 후 실행")
}
Task.sleep을 호출하는 것은 sleep 시스템 함수와 같은 것들을 사용하는 것과 매우 다릅니다. Task 버전은 다른 코드와 관련하여 완전히 non-blocking입니다.
2. 방법별 비교표
| 방법 | 장점 | 단점 | 사용 권장 |
|---|---|---|---|
Task.sleep() |
Non-blocking, 취소 가능, Swift Concurrency 통합 | 나노초 단위 불편 | ✅ 권장 |
DispatchQueue.asyncAfter |
간단한 사용법 | 콜백 지옥, 취소 어려움 | ⚠️ 제한적 |
Timer |
반복 작업 가능 | 복잡한 설정, 메모리 관리 | ⚠️ 특수 목적 |
sleep() |
직관적 | Blocking, 비동기 부적합 | ❌ 비권장 |
Task.sleep의 특징
위의 Task.sleep 호출이 try 키워드로 표시된 이유는 작업이 sleep 시간 동안 취소된 경우 해당 호출이 오류를 던지기 때문입니다.
// 취소 가능한 지연 작업
Task {
do {
try await Task.sleep(nanoseconds: 2_000_000_000)
print("2초 후 실행")
} catch is CancellationError {
print("작업이 취소되었습니다")
} catch {
print("예상치 못한 에러: \(error)")
}
}
실제 활용 예시
예를 들어, 비동기 작업이 완료되는 데 150밀리초 이상 걸리는 경우에만 뷰 컨트롤러가 로딩 스피너를 표시하도록 하고 싶다면, 다음과 같이 구현할 수 있습니다.
class VideoViewController: UIViewController {
private var loadingSpinnerTask: Task<Void, Never>?
private var videoTask: Task<Void, Never>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 0.15초 후 로딩 스피너 표시
loadingSpinnerTask = Task {
try await Task.sleep(nanoseconds: 150_000_000)
showLoadingSpinner()
}
// 비디오 준비 작업
videoTask = Task {
await prepareVideo()
loadingSpinnerTask?.cancel() // 빠르게 완료되면 스피너 취소
hideLoadingSpinner()
}
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
loadingSpinnerTask?.cancel()
videoTask?.cancel()
}
}
SwiftUI에서의 디바운싱 예시
struct SearchView: View {
@State private var searchText = ""
@State private var searchTask: Task<Void, Never>?
@State private var searchResults: [String] = []
var body: some View {
VStack {
TextField("검색", text: $searchText)
.onChange(of: searchText) { newValue in
// 기존 검색 취소
searchTask?.cancel()
// 0.3초 지연 후 검색
searchTask = Task {
try await Task.sleep(nanoseconds: 300_000_000)
await performSearch(query: newValue)
}
}
List(searchResults, id: \.self) { result in
Text(result)
}
}
}
private func performSearch(query: String) async {
// 실제 검색 로직
searchResults = await searchAPI(query: query)
}
}
위의 예시는 Task를 사용하여 뷰 컨트롤러의 콘텐츠를 로드하는 방법에 대한 완전한 예시는 아닙니다. 예를 들어, 새로운 작업을 시작하기 전에 기존 로딩 작업이 이미 진행 중인지 확인하고 싶을 것입니다.
Task 확장을 통한 편의성 개선
주어진 코드베이스 내에서 많은 지연된 작업을 사용할 예정이라면, 그러한 지연된 작업을 더 쉽게 생성할 수 있게 해주는 간단한 추상화를 정의하는 것이 가치가 있을 수 있습니다. 예를 들어, 나노초를 사용해야 하는 대신 더 표준적인 TimeInterval 값을 사용하여 초 기반 지연을 정의할 수 있게 해주는 것입니다.
extension Task where Failure == Error {
static func delayed(
byTimeInterval delayInterval: TimeInterval,
priority: TaskPriority? = nil,
operation: @escaping @Sendable () async throws -> Success
) -> Task {
Task(priority: priority) {
let delay = UInt64(delayInterval * 1_000_000_000) // 1초 = 10억 나노초
try await Task<Never, Never>.sleep(nanoseconds: delay)
return try await operation()
}
}
}
1. Task<Never, Never> 명시가 필요한 이유
우리가 sleep 작업을 명시적으로 Task<Never, Never>로 표시해야 하는 이유는 해당 메서드가 정확히 그 Task 특수화에서만 사용 가능하고, 우리 확장의 범위 내에서 Task 심볼은 우리 확장이 사용되는 현재 특수화를 참조하기 때문입니다.
2. 확장 사용 예시
위의 확장이 있으면, 지연된 작업을 생성하고 싶을 때마다 간단히 Task.delayed를 호출할 수 있습니다:
class VideoViewController: UIViewController {
private var loadingSpinnerTask: Task<Void, Error>?
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
// 개선된 방식 - 편리함
loadingSpinnerTask = Task.delayed(byTimeInterval: 0.15) {
self.showLoadingSpinner()
}
Task {
await prepareVideo()
loadingSpinnerTask?.cancel()
hideLoadingSpinner()
}
}
}
3. 사용법 비교
// 기존 방식 - 불편함
Task {
try await Task.sleep(nanoseconds: 150_000_000) // 0.15초
showLoadingSpinner()
}
// 개선된 방식 - 편리함
Task.delayed(byTimeInterval: 0.15) {
showLoadingSpinner()
}
이 접근 방식의 유일한 단점은 이제 해당 작업 클로저 내에서 self를 수동으로 캡처해야 한다는 것입니다.
Task 확장 활용 패턴
1. 네트워크 재시도 패턴
extension Task where Failure == Error {
static func retryWithDelay<T>(
maxAttempts: Int = 3,
delay: TimeInterval = 1.0,
operation: @escaping () async throws -> T
) -> Task<T, Error> {
Task {
for attempt in 1...maxAttempts {
do {
return try await operation()
} catch {
if attempt == maxAttempts {
throw error
}
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
}
fatalError("Unreachable")
}
}
}
// 사용 예시
let dataTask = Task.retryWithDelay(maxAttempts: 3, delay: 2.0) {
try await networkService.fetchData()
}
2. 메모리 관리 패턴
class ViewController: UIViewController {
private var delayedTasks: [Task<Void, Never>] = []
func addDelayedTask(_ task: Task<Void, Never>) {
delayedTasks.append(task)
}
deinit {
// 뷰 컨트롤러 해제 시 모든 지연 작업 취소
delayedTasks.forEach { $0.cancel() }
}
}
3. 디바운싱 헬퍼 클래스
class Debouncer {
private var task: Task<Void, Never>?
private let delay: TimeInterval
init(delay: TimeInterval) {
self.delay = delay
}
func debounce(action: @escaping () async -> Void) {
task?.cancel()
task = Task {
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
await action()
}
}
func cancel() {
task?.cancel()
task = nil
}
}
// 사용 예시
class SearchViewController: UIViewController {
private let searchDebouncer = Debouncer(delay: 0.3)
@IBAction func searchTextChanged(_ sender: UITextField) {
searchDebouncer.debounce {
await self.performSearch(query: sender.text ?? "")
}
}
}
암시적 Self 캡처
그 작은 문제를 해결하는 한 가지 방법이 있습니다. 바로 @_implicitSelfCapture 속성을 사용하는 것입니다. 이는 Swift 표준 라이브러리가 모든 내장 Task 클로저가 자동으로 self 참조를 캡처하도록 하는 데 사용하는 것입니다:
extension Task where Failure == Error {
static func delayed(
byTimeInterval delayInterval: TimeInterval,
priority: TaskPriority? = nil,
@_implicitSelfCapture operation: @escaping @Sendable () async throws -> Success
) -> Task {
Task(priority: priority) {
let delay = UInt64(delayInterval * 1_000_000_000)
try await Task<Never, Never>.sleep(nanoseconds: delay)
return try await operation()
}
}
}
주의사항
하지만 위의 속성은 아직 Swift의 공개 API의 공식적인 부분이 아니므로 해당 속성을 사용하는 모든 코드가 언제든지 깨질 수 있다는 위험을 받아들일 의향이 있지 않다면 프로덕션 코드에서 사용하는 것을 실제로 권장하지 않습니다. 팀 협업 환경에서는 코드의 안정성과 유지보수성이 중요하므로, 명시적 self 캡처를 사용하는 것이 더 안전한 접근법입니다.
https://www.swiftbysundell.com/articles/delaying-an-async-swift-task/
Delaying an asynchronous Swift Task | Swift by Sundell
How we can use the built-in Task type to delay certain operations when using Swift’s new concurrency system.
www.swiftbysundell.com
오늘도 화이팅입니다!
'iOS > Swift' 카테고리의 다른 글
| [Swift] Concurrency - Collections & Parallelization (1) (1) | 2025.06.09 |
|---|---|
| [Swift] Concurrency - Task(3) (3) | 2025.06.08 |
| [Swift] Concurrency - Task(1) (2) | 2025.06.07 |
| [Swift] Main Thread (0) | 2025.02.26 |
| [Swift] 동시성 vs 병렬성 (0) | 2025.02.26 |