티스토리 뷰

iOS/Swift

[swift] Swift Concurrency - Task (1)

yanni13 2025. 6. 18. 20:58

 

 

Swift Concurrency는 Swift 자체에서 비동기와 동기 처리를 더 안전하게 하고 간결하게 만들기 위한 기능이며 swift 5.5부터 도입되었다고 한다.

 

async/await, Actor, @sendable등 여러 가지가 있지만 그중에서 오늘 공부해 볼 주제는 바로 Task이다!

 

 

Task

 

Task는 Concurrency에서 비동기 작업의 최소 단위라고 한다.

 

모든 비동기 코드는 어떤 작업의 일부로 실행되곤 하는데, Task는 한 번에 하나의 작업만 수행하지만, 여러 작업을 실행할 때 동시에 수행하기 위해 Task를 스케줄링할 수 있다고 한다.

 

하나하나 뜯어보자.

 

 

1. Task는 한 번에 하나의 작업만 수행한다. [순차] 

보통 비동기는 한번에 하나의 작업만 수행하곤 한다. 그래서 하나의 Task 내부에서 여러 개의 await 코드가 존재해도 한 시점에 하나의 코드만 실행되어 내부적으로는 병렬이 아닌 직렬처럼 동작하게 된다.

 

아래 코드를 통해서 확인해보자!

Task { 
    await loadUser()
    await loadImage() 
}

 

이런 식으로 하나의 Task 블록 안에 여러 개의 await을 작성하게 된다면 loadUser 작업이 끝나고, loadImage 작업을 수행하게 되는 동기 형태가 이루어지게 된다.

 

하나의 Task 블록 안에 순차적으로 await을 나열하는 경우는 A 작업이 끝난 후 바로 B 작업이 실행되게 끔 이어져야 할 때 (= 순서를 보장해야 할 때) 동기적으로 실행한다.

 

 

2. 여러 작업을 동시에 실행하기 위해 Task를 스케줄링할 수 있다. [병렬]

여러 개의 task를 만들어서 각 task가 병렬로 실행될 수 있게 한다.

Task {
    await loadUser()
}

Task {
    await loadImage()
} 

Task {
    await loadProfile()
}

 

 Swift는 여러개의 task들을 적절한 스케줄링 해서 실행한다. 위의 코드에서 3가지의 Task 블록을 사용하는데 각 작업이 동시에 실행된다는 점이다.

 

Task사람이라고 생각하고 await에 감싸진 함수가 하나의 작업이라고 생각해 보자!

 

순차적인 방법(1번)의 경우에는 한 사람이 여러 일을 하고 있다 보니 하나의 작업이 끝나야 두 번째 작업을 시작할 수 있게 된다.

 

반면 병렬적인 방법(2)은 3명의 사람이 각 작업을 하나씩 맡아서 하고 있다고 생각하면 된다

그럼 Task1, Task2, Task3에 해당하는 각각의 함수가 동시에 실행되고 있는 모습이다

 

📍 스케줄링은 어떤 기준으로 동시에 실행된다는 걸까?

 

Swift 런타임이 loadUser용 Task와 loadImage용 Task를 각각의 큐에 올려서 백그라운드에서 병렬로 실행할 수 있게 스케줄링해준다는 의미이다. 

 

정리하자면, Task를 생성하면 자동으로 큐에 올려서 백그라운드에서 실행되게 하며, await를 사용해서 비동기 작업을 수행할 수 있다. 

 

 

Task 취소

Task는 메모리가 부족해지면 실제 작업이 취소되도록 cancel을 통해서 호출할 수 있다.

Task.cancel()

 

Task.cancel()을 호출하면 취소 요청이 들어간 상태이며 해당 task의 모든 자식 task와 group task까지 취소 요청이 들어간다.

 

Task.cancel()을 실행했다고 무조건 작업이 취소되는 건 아니다.

 

Swift concurrency는 cooperative cancellation을 채택하고 있어서 Task에 취소 상태를 알려준 것일 뿐 즉시 종료되도록 강제하지는 않기 때문에, 내부 로직에서 명시적으로 취소 상태를 감지해야 중단되는 구조이다.

 

즉, Task의 취소 상태가 true라고 해서 즉시 강제로 작업이 중단되지 않으며 task.cancel()를 호출하면 내부적으로 해당 Task에 대하 isCancelled = true로 표시를 한다는 것!

 

그래서 진짜 작업을 중단하기 위해서는 취소 상태를 확인하거나, 처리하는 코드를 개발자가 구현해야 한다.

 

취소를 감지하는 방법은 3가지 방법이 있다.

  1. Task.checkCancellation() - 에러를 반환하고 이후 작업은 종료됨
  2. Task.isCancelled - bool 값으로 컨트롤 가능하다.
  3. withTaskCancellationHandler(operation: , onCancel:) - Task 취소를 감지했을 때 추가적인 복원 작업이나, 마무리 작업이 가능하다.

 

 

💡 Task와 일반 비동기 처리방식의 차이점

 

일반적으로 비동기 처리를 할 때 Completion Handler를 제일 많이 쓰곤 하는데, Task 와의 차이점이 어느 부분에서 다른지 한번 확인해 보자!

 

 

Completion Handler

클로저 기반으로 구성되어 있는 비동기 작업을 처리하는 방법이다.

 

func deleteChatRoom(chatRoomId: Int64, completion: @escaping (Result<Void, DeleteChatRoomError>) -> Void) {
        ChatRoomAlamofire.shared.deleteChatRoom(chatRoomId) { result in
            switch result {
            case .success:
                Log.debug("[DefaultChatRoomRepository]: 채팅방 나가기 성공")
                completion(.success(()))

            case let .failure(error):
                if let statusSpecificError = error as? StatusSpecificError,
                   statusSpecificError.domainError == .conflict,
                   statusSpecificError.code == ConflictErrorCode.requestConflictWithResourceState.rawValue
                {
                    completion(.failure(DeleteChatRoomError.mismatchConflict))
                } else {
                    completion(.failure(DeleteChatRoomError.other(error)))
                }
            }
        }
    }

 

 

예를 들면 위의 채팅방 삭제 함수처럼 클로저를 통해 작업 완료 시점을 completion으로 알려준다.

함수에 callback을 전달하고, 작업이 끝나면 이 callback을 미리 정의된 클로저를 호출해서 결과를 전달하도록 하는 흐름이다. 

 

위에 코드에서 알 수 있듯이, api 호출에 성공했을 때, 실패했을 때에 대한 각각의 예외처리를 해주고 있는데 이 예외처리 과정이 복잡하거나 더 많은 가지의 switch문을 처리해야 한다면 클로저 중첩이 많아져서 결국 콜백 지옥을 유발할 수 있다.

 

뿐만 아니라, 거의 대부분 예외처리 기능은 별도로 구현되어야 하기 때문에 코드의 가독성 측면에서 점점 길어지게 된다!

 

 

이외에도 Completion Handler는 거의 모든 상황에서 retain cycle의 위험이 존재한다.

 

📍 Retain Cycle이란?
두 객체가 서로를 강하게 참조하여, 메모리에서 해제되지 못하는 상황을 말한다.

 

 

Task

Task에서도 Retain Cycle이 발생될 수 있다. Task가 self를 강하게 참조하고, self가 Task를 강하게 참조하면 Retain Cycle이 발생할 수 있다고 한다.

 

class DataManager {
    var currentTask: Task<Void, Never>?  // DataManager가 Task를 소유
    
    func start() {
        currentTask = Task {
            await self.doWork()  // Task가 self를 사용
        }
    }
}

 

task 변수에 Task를 할당하면서 self가 Task를 강하게 참조하는 방식이다.

 

여기서 강하게 참조한다는 건 기본적으로 변수를 선언할 때 '강하게' 참조한다고 의미하고, weak 키워드를 사용하면 '약하게' 참조한다는 의미이다

 

  • var currentTask: Task<Void, Never>?
    • DateManager가 currentTask라는 변수를 생성하여 Task를 강하게 참조하고 있다.
  • await self.doWork
    • Task 클로저 안에서 self를 사용함으로써 Task가 self를 강하게 참조하고 있다.

 

서로가 서로를 끌어안고 있어서 놔주지 않는다..라고 해석하면 조금 더 쉽게 이해가 갈 것이다!

 

따라서 이렇게 강한 참조를 하게 되면 참조 카운트가 0이 되지 않아 둘 다 메모리에서 해제되지 않고 남아있게 되는 문제가 발생하는 것이다.

 

 

이때 Task에서는 3가지의 방법으로 Retain Cycle을 해결할 수 있다.

1️⃣ weak self

weak self는 메모리 누수를 방지하기 위해 약한 참조를 실행하겠다~라고 하는 것이므로 순환 참조 문제를 방지할 수 있다.

 

class DataManager {
    var currentTask: Task<Void, Never>?  // DataManager가 Task를 소유
    
    func start() {
        currentTask = Task { [weak self] in 
            await self.doWork()  // Task가 self를 사용
        }
    }
}

 

이 방법은 completion handler에서도 적용 가능하며, Task에서도 실행 가능하다.

 

2️⃣ Task를 프로퍼티로 저장하지 않고 지역적으로 사용

Task를 변수에 저장하지 않고 내부에서 바로 생성해서 사용하는 경우 Task는 함수가 실행되자마자 같이 시작되기 때문에 별도의 참조 없이 작업이 끝나면 바로 자동으로 해제된다.

 

func runMultipleTasks() {
	log("🔵 여러 Task 시작")
	let startTime = Date()
        
	// Task A
	Task {
		log("  📝 Task A 시작")
		try? await Task.sleep(nanoseconds: 2 * 1_000_000_000)
		log("  ✅ Task A 완료")
	}
    ...

 

위와 같이 Task가 함수 내부에서 일시적으로 생성되고 있으며 아무런 저장이 되지 않고 있기 때문에 참조가 형성되지 않는 것을 확인할 수 있다

 

따라서 중복으로 Retain Cycle이 발생할 일도 없으며 약한 참조를 해야 할 필요조차 없게 되어버리기 때문에 아주 간단하게 비동기 작업을 처리할 수 있게 되는 것이다.

 

3️⃣ Task 취소

위에 설명을 읽고 나면 어느 정도 Task 취소에 대한 이해가 갈 것이다

func runMultipleTasks() {
    let startTime = Date()
    
    let taskA = Task {
        log("Task A 시작")
        
        do {
            for i in 1...4 {
                try Task.checkCancellation() // 취소 감지 지점
                log("Task A 작업 \(i) 진행 중...")
                try await Task.sleep(nanoseconds: 500_000_000) // 2초
            }
            log("Task A 완료")
        } catch {
            log("Task A 취소됨 (에러: \(error.localizedDescription))")
        }
    }
    
    Task {
        try? await Task.sleep(nanoseconds: 1 * 1_000_000_000)
        log("Task A 취소 시도")
        taskA.cancel()
    }
}

 

여기서 한 가지 알아야 할 사항은 Task cancel 작업을 하기 위해선 Task에 접근을 해야 되기 때문에 변수에 저장해야 한다. 따라서 변수에 Task를 넣어둬야 함!!!

 

그래야 task.cancel() 이렇게 각 Task에 대한 취소가 가능하기 때문이다.

 

 

 

참고


https://developer.apple.com/documentation/swift/task

https://bbiguduk.gitbook.io/swift/language-guide-1/concurrency

https://developer.apple.com/videos/play/wwdc2021/10134/

https://medium.com/@shobhakartiwari/completion-handlers-vs-tasks-in-swift-471645f8234d

https://developer.apple.com/documentation/swift/task/cancel()

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/03   »
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
글 보관함