Swift - 비동기와 동시성, 그리고 async/await

이재원·2025년 1월 19일
1

Swift

목록 보기
17/17
post-thumbnail

비동기(Asynchronous)의 개념

비동기 프로그래밍은 작업이 완료될 때까지 기다리지 않고 다음 작업을 계속할 수 있도록 설계된 방식입니다. 이는 네트워크 요청, 파일 읽기/쓰기처럼 오래 걸리는 작업에서도 프로그램이 멈추지 않고 다른 작업을 수행할 수 있도록 돕습니다. 예를 들어, 사용자가 웹 브라우저를 사용하는 동안 새로고침 버튼을 눌렀을 때 화면이 멈추지 않고 다른 버튼도 정상적으로 작동한다면, 이는 비동기 작업이 존재하기 때문입니다.

func fetchData(completion: @escaping (String) -> Void) {
    DispatchQueue.global().async {
        let data = "Fetched Data"
        completion(data)
    }
}

fetchData { result in
    print(result) // 출력: "Fetched Data"
}

위 코드는 completion 클로저를 통해 결과를 나중에 전달하고, 데이터를 가져오는 동안 다른 작업을 계속 진행할 수 있도록 되어 있습니다.

이처럼 비동기는 “작업이 완료될 때까지 기다리지 않고 다른 작업을 진행하는 방식”이지만, 작업의 처리 자체는 결국 순차적으로 진행됩니다. 이유는 다음과 같습니다.

  1. CPU는 하나의 작업을 순서대로 처리한다.
    → 하나의 코어는 한 번에 하나의 작업만 수행합니다. 따라서 작업 자체는 순차적으로 진행됩니다.
  2. 작업 대기열(Queue)의 순서
    → 대부분의 비동기 작업은 작업 대기열에 추가되어, 대기열에 있는 작업 순서대로 실행됩니다.
  3. 작업의 독립성 보장
    → 작업 간의 의존성이나 순서를 명시하지 않으면 비동기 작업도 결국 정의된 순서대로 처리됩니다.

예제 코드를 살펴보면서 더 자세히 알아보도록 하겠습니다.

func task1(completion: @escaping () -> Void) {
    DispatchQueue.global().async {
        print("Task 1 started")
        sleep(2) // 2초 대기
        print("Task 1 finished")
        completion()
    }
}

func task2(completion: @escaping () -> Void) {
    DispatchQueue.global().async {
        print("Task 2 started")
        sleep(1) // 1초 대기
        print("Task 2 finished")
        completion()
    }
}

task1 {
    task2 {
        print("All tasks finished")
    }
}

위 코드에서 task1이 완료되기 전에 task2는 실행되지 않습니다. 비동기 작업이긴 하지만 처리 순서는 여전히 코드에 의해 결정됩니다.

이처럼 비동기는 현재 실행 중인 코드가 완료되지 않아도 다음 코드를 실행할 수 있도록 만들지만, 작업 자체는 여전히 순차적으로 처리됩니다. 즉, 하나의 비동기 작업이 끝나기 전에 다른 작업이 대기 상태로 스케쥴링 될 수 있지만, 결국 CPU는 이를 하나씩 처리하기 때문입니다.

콜백(callback)

다음은 비동기 프로그래밍을 실현시키기 위한 방법인 콜백에 대해 알아보도록 하겠습니다. 콜백은 어떤 작업이나 함수가 완료된 후 실행되도록 나중에 호출되는 함수로, 작업이 완료된 후 결과를 처리하거나 추가 작업을 수행하기 위해 사용됩니다. 즉, 다른 함수에 매개변수로 전달되어 특정 이벤트(작업 완료, 데이터 처리, 에러 발생 등)가 발생했을 때 호출됩니다.

func processAsynchronously(value: Int, completion: @escaping (Int) -> Void) {
    DispatchQueue.global().asyncAfter(deadline: .now() + 3) {
        let result = value * 2
        completion(result) // 작업 완료 후 콜백 호출
    }
}

print("Start")
processAsynchronously(value: 10) { result in
    print("Result: \(result)") // 출력: Result: 20
}
print("End")

// 출력 결과
// Start
// End
// Result: 20

콜백의 작동원리는 다음과 같습니다.

  1. 어떤 작업을 요청하고, 해당 작업이 끝나면 그 결과를 처리하는 함수를 전달
  2. 요청된 작업이 비동기로 수행되는 동안, 프로그램은 다른 작업을 계속 진행
  3. 작업이 완료되면 콜백 함수가 호출되어 결과를 처리

위 코드에서 End가 먼저 출력되고 Result: 20이 나중에 출력되는 이유는, 비동기 작업이 3초 동안 실행을 기다리는 동안 메인 스레드가 다른 작업을 계속하기 때문입니다.

콜백의 한계와 콜백 지옥

비동기 작업의 결과를 처리하기 위해 가장 많이 사용하는 방식 중 하나가 콜백(callback)입니다. 콜백은 특정 작업이 완료된 후 실행되는 함수로, 비동기 작업에서 필수적인 도구입니다. 하지만 콜백은 작업이 중첩될수록, 코드의 가독성과 유지보수성이 떨어집니다. 이를 흔히 콜백 지옥(callback hell)이라고 부릅니다.

fetchData { result in
    switch result {
    case .success(let data):
        processData(data) { result in
            switch result {
            case .success(let processedData):
                saveData(processedData) { result in
                    switch result {
                    case .success:
                        print("Data saved successfully!")
                    case .failure(let error):
                        print("Error saving data: \(error)")
                    }
                }
            case .failure(let error):
                print("Error processing data: \(error)")
            }
        }
    case .failure(let error):
        print("Error fetching data: \(error)")
    }
}

콜백을 통한 비동기 처리 방식에는 각 단계마다 에러를 명시적으로 처리해야 했습니다. 이로 인해 코드가 장황해지고, 에러 핸들링 로직이 비효율적으로 작성되는 경우가 많았습니다.

또한 콜백 기반 코드는 작성자 입장에서는 동작을 이해하기 쉽지만, 코드를 읽는 사람에게는 흐름을 파악하기 어렵게 만듭니다. 이는 비즈니스 로직이 복잡해질수록 심화됩니다.

async/await의 등장과 장점

Swift는 콜백 지옥의 문제를 해결하기 위해 async/await 구문을 도입했습니다. async/await는 비동기 작업을 동기 코드처럼 순차적으로 작성할 수 있도록 설계하여 가독성과 유지보수성이 크게 향상시킬 수 있습니다.

async 함수는 작업이 비동기로 처리됨을 나타내며, 호출할 때 await를 사용해 결과를 기다립니다. 콜백 지옥에서 다룬 예제를 async/await로 변환하면 다음과 같습니다.

do {
    let data = try await fetchData()
    let processedData = try await processData(data)
    try await saveData(processedData)
    print("Data saved successfully!")
} catch {
    print("Error: \(error)")
}

이렇게 async/await를 사용함으로써, 코드가 순차적으로 읽히기 때문에 가독성이 크게 향상됩니다. 이를 통해 동기 코드처럼 작성하지만, 내부적으로는 비동기로 처리할 수 있습니다.

동시성(Concurrency)

비동기가 “언제 처리될지”에 대한 문제를 다룬다면, 동시성은 “어떻게 여러 작업을 동시에 실행할지”를 다룹니다. 동시성은 작업을 병렬로 실행하거나 번갈아가며 실행하는 방식으로, CPU와 메모리 같은 시스템 자원을 최대한 활용합니다. 예를 들어, 동시에 여러 네트워크 요청을 처리하거나 백그라운드 데이터를 처리하면서도 UI가 부드럽게 작동할 수 있는 이유는 동시성이 제공하는 효율성 덕분입니다.

운영체제는 동시성을 실현하는데 핵심적인 역할을 합니다. 현대 운영체제는 멀티스레드와 스케쥴링 알고리즘을 통해 작업이 독립적으로 실행되도록 보장합니다. 멀티스레드는 하나의 프로그램이 여러 작업을 동시에 처리할 수 있도록 스레드를 생성하며, 멀티코어 CPU 환경에서는 각 스레드가 서로 다른 코어에서 병렬로 실행됩니다. 이 과정에서 운영체제는 작업의 우선순위를 판단하고, 적절한 시점에 작업이 실행되도록 CPU 자원을 분배합니다.

Swift는 이러한 운영체제의 기능을 활용해 Grand Central Disapatch(GCD)Operation Queue 같은 동시성 모델을 제공합니다. 이를 통해 개발자는 직접 스레드를 관리하지 않고도 간단하게 동시성을 구현할 수 있습니다.

동시성의 두 가지 접근 방식: 순차 처리와 병렬 처리

동시성의 효과를 이해하기 위해 순차 처리와 병렬 처리의 차이를 살펴보겠습니다.

순차적 처리

순차적 처리는 작업을 하나씩 순서대로 실행한다. 아래 코드는 두 개의 데이터를 순차적으로 가져오는 비동기 작업을 보여줍니다.

func fetchData1() async -> String {
    await Task.sleep(2 * 1_000_000_000) // 2초 대기
    return "Data 1"
}

func fetchData2() async -> String {
    await Task.sleep(1 * 1_000_000_000) // 1초 대기
    return "Data 2"
}

func performSequentialTasks() async {
    let result1 = await fetchData1()
    let result2 = await fetchData2()
    print("Results: \(result1), \(result2)")
}

Task {
    await performSequentialTasks()
}

위 코드는 각 작업이 완료될 때까지 기다리므로, 총 3초가 걸립니다.

병렬 처리

병렬 처리는 여러 작업을 동시에 실행하며, 가장 오래 걸리는 작업의 시간만큼 대기합니다.

func performConcurrentTasks() async {
		// async let을 통해 동시성 실현
    async let result1 = fetchData1()
    async let result2 = fetchData2()

		// 두 작업의 결과를 동시에 기다림
    let results = await [result1, result2]
    print("Results: \(results)")
}

// 병렬 처리 실행
Task {
    await performConcurrentTasks()
}

// 출력 결과
// Results: ["Data 1", "Data 2"]

이 코드는 두 작업을 동시에 실행하므로 총 실행 시간은 2초로 줄어듭니다. 병렬 처리를 통해 시스템 자원을 더 효율적으로 사용할 수 있음을 보여줍니다.

마무리

비동기와 동시성을 음식에 비유하자면, 비동기는 “음식점에서 주문을 하고 음식이 나오기를 기다리지 않고 다른 일을 하는 것”과 같습니다. 동시성은 “음식점에서 여러 손님이 각자 다른 요리를 주문했을 때, 주방에서 여러 요리를 병렬로 준비하는 것”으로 비유할 수 있습니다. 이렇게 보면 두 개념이 서로 다른 역할을 한다는 점이 분명히 드러납니다.

하지만 비동기와 동시성, 그리고 async/await는 처음 접할 때는 직관적으로 이해하기 어려운 개념입니다. 특히, “나중에 처리되지만 동시에 처리되는 것처럼 보인다”는 점은 처음에는 서로 모순처럼 느껴질 수 있습니다. 더불어, async/await는 높은 추상화를 제공하기 때문에 비동기와 동시성의 기초를 제대로 이해하지 못한 상태에서 사용하려고 하면 동기처럼 보이는 코드가 왜 비동기로 동작하는지 혼란스러울 수 있습니다.

결국, 비동기와 동시성은 단지 시간과 자원을 효율적으로 사용하기 위한 프로그래밍 방식일 뿐입니다. 이 두 개념은 각각의 역할도 중요하지만, 결합 되었을 때 더욱 강력한 시너지를 발휘합니다. 이번에 이 글을 작성하면서 저도 개념을 다시 한번 확실히 이해하게 되었습니다. 앞으로는 더 유연한 사고를 바탕으로 비동기와 동시성을 활용하여 효율적인 코드를 작성할 수 있을 것 같습니다🙂

참고 자료
https://bbiguduk.gitbook.io/swift/language-guide-1/concurrency
https://green1229.tistory.com/336
https://codingmon.tistory.com/34
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/
https://developer.apple.com/videos/play/wwdc2021/10134/
https://developer.apple.com/videos/play/wwdc2021/10132/

profile
20학번 새내기^^(였음..)

0개의 댓글

관련 채용 정보