Explore structured concurrency in Swift

피터·2025년 9월 8일

Concurrency

목록 보기
6/10

오늘은 Swift의 구조적 동시성(Structured Concurrency)에 대해 알아보겠습니다.

Swift 5.5에서 도입된 구조적 동시성을 공부하다가 문득 궁금해졌습니다. "왜 굳이 '구조적'이라는 말을 붙인 걸까요?"

초창기 프로그래밍의 goto 문에서 시작해 Swift의 최신 동시성 기능인 async/await, TaskGroup에 이르기까지, 구조적 프로그래밍이라는 하나의 맥락으로 동시성을 이해해보겠습니다.

1. 구조적 프로그래밍의 등장 배경

1960년대까지만 해도 프로그래밍에서는 goto 문이 당연하게 사용되었습니다. goto는 코드의 실행 흐름을 원하는 곳 어디로든 이동시킬 수 있는 강력한 명령어였습니다. 1부터 10까지 더하는 간단한 프로그램도 goto로 작성하면 다음과 같았습니다:

start:
  //... 초기화 ...
loop_start:
  //... 덧셈 처리 ...
  //... 카운터 증가 ...
  if (counter <= 10) goto loop_start // 다시 루프 시작으로 점프
  else goto end // 끝으로 점프
//... 다른 작업 ...
end:
  //... 결과 출력 ...

하지만 goto를 남발한 코드는 흐름을 예측하기가 매우 어려웠습니다. 코드가 어디서 와서 어디로 갈지 모르는 상황이 마치 얽히고설킨 스파게티 면발 같다고 해서 스파게티 코드(Spaghetti Code)라는 별명이 붙었습니다.

1968년 에드거 다익스트라(Edsger Dijkstra)가 "Go To Statement Considered Harmful"이라는 편지를 통해 goto의 문제점을 지적했습니다. 그가 제시한 구조적 프로그래밍의 핵심은 의외로 단순했습니다. goto 대신 세 가지 제어 구조만 사용하자는 것이었습니다:

  • 순차(Sequence): 코드는 위에서 아래로 순서대로 실행
  • 선택(Selection): if-then-else로 조건에 따른 분기
  • 반복(Repetition): for, while로 특정 조건 동안 반복

놀랍게도 이 세 가지만으로도 어떤 복잡한 로직이든 표현할 수 있었습니다. 무엇보다 코드의 흐름을 논리적으로 예측할 수 있게 되었습니다.

2. 비동기 코드와 콜백 지옥

시간이 흐르면서 웹과 모바일 앱이 발달하게 되면서 비동기 프로그래밍이 필수가 되었습니다. 여러 썸네일 이미지를 비동기적으로 가져오는 전통적인 방식의 코드를 살펴보겠습니다:

func fetchThumbnails(
    for ids: [String],
    completion handler: @escaping ([String: UIImage]?, Error?) -> Void
) {
    guard let id = ids.first else { return handler([:], nil) }
    let request = thumbnailURLRequest(for: id)
    let dataTask = URLSession.shared.dataTask(with: request) { data, response, error in
        // ... 데이터 처리 ...
        UIImage(data: data)?.prepareThumbnail(of: thumbSize) { image in
            // ... 썸네일 처리 ...
            fetchThumbnails(for: Array(ids.dropFirst())) { thumbnails, error in
                // ... 결과 조합 ...
            }
        }
    }
    dataTask.resume()
}

이 코드에서 이상한 점을 발견할 수 있습니다. 더 이상 위에서 아래로 순차적으로 읽히지 않습니다. dataTask에 작업을 맡기고 나면 언젠가 모르는 시점에 completion handler가 호출되면서 코드 실행 흐름이 클로저 안으로 점프해버립니다.

이렇게 중첩된 콜백들로 인한 콜백 지옥(Callback Hell)을 보면, 예전에 봤던 스파게티 코드와 똑같은 문제임을 알 수 있습니다. goto가 만들었던 문제와 본질적으로 동일합니다. 오류 처리는 복잡해지고, 코드의 논리적 흐름도 파악하기 어려워집니다.

3. async/await - 구조의 회복

그래서 Swift 5.5에서 async/await가 등장했습니다. 이는 비동기 코드를 구조적 프로그래밍의 원칙을 지키면서도 마치 동기 코드처럼 작성할 수 있게 해줍니다.

위에서 봤던 콜백 지옥을 async/await로 다시 작성하면 다음과 같습니다:

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        let request = thumbnailURLRequest(for: id)
        let (data, response) = try await URLSession.shared.data(for: request)
        try validateResponse(response)
        guard let image = await UIImage(data: data)?.byPreparingThumbnail(ofSize: thumbSize) else {
            throw ThumbnailFailedError()
        }
        thumbnails[id] = image
    }
    return thumbnails
}

try await 키워드가 비동기 작업이 끝날 때까지 기다렸다가, 완료되면 자연스럽게 다음 줄로 넘어갑니다. for 루프 안에서도 비동기 코드가 순차적으로 실행되는 구조가 회복되었습니다.

하지만 여기서 한 가지 문제가 있습니다. 만약 100개의 이미지를 처리해야 하고 각각 1초씩 걸린다면, 이 코드는 총 100초가 걸립니다. 동시성의 핵심인 '동시 실행'의 장점을 전혀 활용하지 못하고 있습니다.

4. async let과 TaskGroup

동시 실행의 시작: async let

이를 해결하기 위해 등장한 것이 async let입니다. 여러 비동기 작업을 동시에 시작하고 싶을 때 사용하며, async let은 작업을 바로 자식 태스크(Child Task)로 만들어서 실행을 시작하고, await는 그 결과가 필요할 때까지 기다리는 역할을 합니다.

let (일반 바인딩)async let (동시성 바인딩)
작업 시작 시점등호(=) 오른쪽 표현식 평가가 시작되는 즉시async let 키워드를 만나는 즉시
실행 흐름초기화가 완료될 때까지 다음 줄로 넘어가지 않음 (순차적)자식 태스크를 시작하고, 부모는 즉시 다음 줄로 넘어감 (동시적)
결과 사용변수 선언 즉시 사용 가능await 키워드를 사용해야 실제 값을 받을 수 있음

간단한 예제로 그 차이를 확인해보겠습니다.

func makeString(for seconds: Int) async throws -> String {
    try await Task.sleep(for: .seconds(seconds))
    return "\(seconds)초 뒤에 나오는 문구"
}

위 예제 코드는 파라미터로 시간을 받고 그 시간 후에 텍스트를 반환하는 함수입니다.

// 1. 순차적 await
let start = Date()
print(try await makeString(for: 1)) // 1초 대기
print(try await makeString(for: 2)) // 2초 대기
print("총 걸린 시간: \(Date().timeIntervalSince(start))초")

이 결과는 어떻게 될까요?

결과: 총 걸린 시간: 3.xxxx초

// 2. 동시적 async let
async let firstString = makeString(for: 1)  // 1초짜리 작업 시작!
async let secondString = makeString(for: 2) // 2초짜리 작업 시작!

let start2 = Date()
print(try await firstString)  // 1초짜리 결과 기다리기
print(try await secondString) // 2초짜리 결과 기다리기
print("총 걸린 시간: \(Date().timeIntervalSince(start2))초")

이번에는 어떨까요?

결과: 총 걸린 시간: 2.xxxx초

async let을 사용하니 두 작업이 동시에 시작되어 가장 오래 걸리는 작업 시간(2초)만큼만 소요되었습니다.

정말 async let이 생기자마자 자식 태스크가 생성되어 바로 작업을 시작하는지 확인해보겠습니다.

async let firstString = makeString(for: 1)
async let secondString = makeString(for: 2)

try await Task.sleep(for: .seconds(2))

let start2 = Date()
print(try await firstString)
print(try await secondString)
print("총 걸린 시간: \(Date().timeIntervalSince(start2))초")

결과는 다음과 같습니다:

총 걸린 시간: 8.404254913330078e-05초

8초가 아닌 10⁻⁵초(거의 즉시)로 완료되었습니다. 이는 async let으로 생성된 자식 태스크들이 정말로 선언과 동시에 실행을 시작한다는 것을 증명합니다.

이제 실제 이미지를 받는 부분에 async let을 활용해보겠습니다.

func fetchOneThumbnail(withID id: String) async throws -> UIImage {
    let imageReq = imageRequest(for: id), metadataReq = metadataRequest(for: id)
    async let (data, _) = URLSession.shared.data(for: imageReq)
    async let (metadata, _) = URLSession.shared.data(for: metadataReq)
    guard let size = parseSize(from: try await metadata),
          let image = try await UIImage(data: data)?.byPreparingThumbnail(ofSize: size)
    else {
        throw ThumbnailFailedError()
    }
    return image
}

async let을 사용하면 부모 태스크인 fetchOneThumbnail의 자식 태스크로 metadata와 image를 불러오는 태스크가 생성되어 priority, task local value, actor 등을 상속받게 됩니다.
만약 metadata를 받는 과정에서 오류가 발생하면 어떻게 될까요? 부모 태스크가 취소되고, 이 취소 신호가 모든 자식 태스크에게 전파됩니다. 하지만 취소 신호를 받았다고 해서 작업이 즉시 멈추는 것은 아닙니다.
취소는 단지 해당 작업의 결과가 더 이상 필요하지 않다는 의미입니다. 여기서 중요한 것은 취소 신호가 계층적으로 전파된다는 점입니다.

이를 Task Tree 구조라고 하며, 구조적 동시성의 핵심 이점 중 하나입니다. 이를 통해 메모리 관리와 리소스 해제가 체계적으로 이루어집니다.

그렇다면 실제로 언제 작업이 취소될까요?
이는 개발자가 명시적으로 확인해야 하는 부분입니다.
항상 취소 가능성을 염두에 두고 이에 대응하는 코드를 작성해야 합니다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        try Task.checkCancellation()
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

위 코드처럼 다운로드 작업에서는 태스크의 취소 상태를 확인하고 더 이상 불필요한 작업을 수행하지 않도록 해야 합니다.
Task.checkCancellation()은 태스크가 취소된 경우 CancellationError를 throw하며,

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    for id in ids {
        if Task.isCancelled { break }
        thumbnails[id] = try await fetchOneThumbnail(withID: id)
    }
    return thumbnails
}

Task.isCancelled는 취소 여부를 불리언 값으로 반환합니다.
하지만 위 코드는 취소되기 전까지 완료된 일부 썸네일 이미지만 반환합니다.

이것이 의도된 동작일까요?

만약 모든 이미지를 완전히 받거나 아예 받지 않는 "전부 또는 전무(All or Nothing)" 방식을 원한다면 어떻게 처리해야 할까요?

이때 필요한 개념이 바로 TaskGroup입니다.

동적인 작업들을 위한 TaskGroup

async let은 동시에 실행할 작업의 개수가 정해져 있을 때 유용합니다. 하지만 배열의 요소만큼 동적으로 작업을 생성해야 한다면 TaskGroup을 사용해야 합니다.
TaskGroup 하위에 fetchOneThumbnail 태스크들이 추가됩니다. 그리고 각 fetchOneThumbnail 태스크에는 async let으로 생성된 두 개의 자식 태스크가 존재하는 트리 구조를 형성합니다.

그런데 여러 작업이 하나의 자원을 동시에 수정하려고 할 때 데이터 경쟁(Data Race)이라는 위험한 문제가 발생할 수 있습니다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: Void.self) { group in
        for id in ids {
            group.addTask {
                // 💥 에러: 여러 태스크가 'thumbnails' 딕셔너리를 동시에 수정하려 함!
                thumbnails[id] = try await fetchOneThumbnail(withID: id)
            }
        }
    }
    return thumbnails
}

위 코드에서는 여러 자식 태스크가 thumbnails라는 공유 자원에 동시에 쓰기 작업을 시도하면 데이터가 손상되거나 앱이 크래시될 수 있습니다.

이를 해결하기 위해서는 @Sendable 클로저나 불변 값, 또는 actor를 활용할 수 있습니다.

안전한 TaskGroup 패턴은 각 자식 태스크가 독립적으로 작업을 수행하고 결과만 반환하도록 하는 것입니다. 그리고 부모는 for try await 루프를 통해 완료된 작업들의 결과를 순차적으로 받아 처리합니다.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        // 1. 동적으로 자식 태스크들을 생성하고 그룹에 추가
        for id in ids {
            group.addTask {
                // 각 태스크는 (ID, 이미지) 튜플을 반환
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        
        // 2. 완료되는 태스크의 결과를 순차적으로 받아서 처리
        // 이 루프는 한 번에 하나의 결과만 처리하므로 데이터 경쟁이 없음
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

이 패턴을 통해 우리는 수많은 작업을 동시에 실행하면서도 데이터 정합성을 안전하게 지킬 수 있습니다.

5. 구조를 넘어서: 비구조적 태스크

지금까지 살펴본 구조적 동시성은 태스크의 수명이 함수나 코드 블록의 범위를 벗어나지 않는다는 특징이 있습니다. 하지만 때로는 태스크가 특정 스코프를 넘어 더 오래 살아남아야 할 때가 있습니다.

예를 들어, UICollectionView의 셀이 화면에 보일 때(willDisplay) 이미지 다운로드를 시작하고, 셀이 화면에서 사라질 때(didEndDisplaying) 다운로드를 취소하고 싶다고 가정해봅시다. willDisplay 함수가 끝났다고 해서 다운로드 태스크가 취소되면 안 됩니다.

이럴 때 사용하는 것이 바로 비구조적 태스크(Unstructured Task)입니다. 태스크를 생성하여 변수에 저장하고, 필요할 때 직접 제어하는 방식입니다.

@MainActor
class MyDelegate: UICollectionViewDelegate {
    var thumbnailTasks: [IndexPath: Task<Void, Never>] = [:]
    
    func collectionView(_ view: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        // 비구조적 태스크를 생성하고 딕셔너리에 저장
        thumbnailTasks[item] = Task {
            defer { thumbnailTasks[item] = nil }
            let thumbnails = await fetchThumbnails(for: ids)
            display(thumbnails, in: cell)
        }
    }
    
    func collectionView(_ view: UICollectionView, didEndDisplay cell: UICollectionViewCell, forItemAt item: IndexPath) {
        // 셀이 사라질 때 저장된 태스크를 찾아 취소
        thumbnailTasks[item]?.cancel()
    }
}

이처럼 태스크의 수명을 함수의 스코프가 아닌, 객체의 수명 주기에 맞출 수 있습니다.

만약 이미지 캐싱처럼 UI와 완전히 무관하고, 부모 태스크의 실행 컨텍스트(예: MainActor)를 상속받지 않아야 하는 독립적인 작업이 필요하다면 Task.detached를 사용하여 분리된 태스크(Detached Task)를 생성할 수도 있습니다.

결론: 코드를 다시, 우리 생각의 흐름대로

Swift의 현대적 동시성은 단순히 비동기 코드를 쉽게 작성하게 해주는 것을 넘어, goto와 콜백으로 인해 헝클어졌던 코드의 '구조'를 되찾아주었습니다.

  • async/await로 비동기 코드를 우리 생각의 흐름대로, 위에서 아래로 작성하고
  • async letTaskGroup으로 복잡한 동시 작업을 안전하고 명확하게 구성하며
  • 필요할 때는 비구조적 태스크로 유연성까지 확보할 수 있습니다

이제 더 이상 코드의 흐름을 쫓아 이리저리 점프하지 않아도 됩니다. 구조적으로, 논리적으로, 그리고 안전하게 동시성 코드를 작성하며 더 나은 앱을 만드는 데 집중할 수 있게 되었습니다.

profile
iOS 개발자입니다.

0개의 댓글