🔥 동시성 프로그래밍

969자
11분

Swift 는 동시성 프로그래밍을 위한 다양한 기능을 제공하고 있어요. 이번 글에서는 async, await, async let, Task, task group, actor 등 Swift 의 동시성 프로그래밍 기능에 대해 차근차근 살펴보도록 하겠습니다.

async 로 비동기 함수 만들기

async 키워드를 함수 앞에 붙이면 해당 함수가 비동기로 실행된다는 것을 나타낼 수 있어요. 다음은 async 로 비동기 함수를 만드는 예시 코드입니다.

func fetchUserID(from server: String) async -> Int {
    // 서버가 "primary" 인 경우 97 을 반환
    if server == "primary" {
        return 97
    }
    // 그 외의 경우 501 을 반환
    return 501
}
swift

이 코드는 fetchUserID 라는 비동기 함수를 정의하고 있어요. 이 함수는 server 라는 문자열 매개변수를 받아서 서버에 따라 다른 사용자 ID 를 반환해요. 만약 server 가 "primary" 라면 97 을 반환하고, 그 외의 경우에는 501 을 반환하도록 되어 있어요. 함수 선언부에 async 키워드가 붙어 있는 것을 볼 수 있는데, 이는 이 함수가 비동기적으로 실행된다는 것을 나타내요.

await 로 비동기 함수 호출하기

비동기 함수를 호출할 때는 await 키워드를 함수 호출 앞에 붙여주어야 해요. await 를 붙이면 해당 비동기 함수가 완료될 때까지 대기한 후 결과를 받아올 수 있습니다. 아래는 await 를 사용하여 비동기 함수를 호출하는 예시 코드예요.

func fetchUsername(from server: String) async -> String {
    // fetchUserID 비동기 함수를 호출하고 결과를 기다림
    let userID = await fetchUserID(from: server)
 
    // 받아온 userID 가 501 이면 "John Appleseed" 를 반환
    if userID == 501 {
        return "John Appleseed"
    }
    // 그 외의 경우 "Guest" 를 반환
    return "Guest"
}
swift

fetchUsername 이라는 비동기 함수가 정의되어 있어요. 이 함수는 server 매개변수를 받아서 해당 서버의 사용자 이름을 반환하는데요, 먼저 await 키워드를 사용해 fetchUserID 비동기 함수를 호출하고 그 결과를 userID 상수에 저장해요. 그리고 userID 값에 따라 사용자 이름을 반환하는데, 만약 userID 가 501 이면 "John Appleseed" 를, 그 외의 경우에는 "Guest" 를 반환하도록 되어 있어요.

async let 으로 비동기 함수 병렬 실행하기

async let 을 사용하면 여러 개의 비동기 함수를 병렬로 실행할 수 있어요. async let 으로 선언한 상수는 해당 비동기 함수의 결과값을 담게 되는데, 이 값을 사용할 때는 await 키워드를 붙여주어야 합니다. 다음은 async let 을 활용한 예시 코드입니다.

func connectUser(to server: String) async {
    // fetchUserID 와 fetchUsername 을 동시에 실행
    async let userID = fetchUserID(from: server)
    async let username = fetchUsername(from: server)
 
    // 위 두 함수의 결과를 기다렸다가 인사말을 만듦
    let greeting = await "Hello \(username), user ID \(userID)"
 
    // 인사말을 출력
    print(greeting)
}
swift

connectUser 라는 비동기 함수 내에서 async let 을 사용해 fetchUserIDfetchUsername 두 비동기 함수를 동시에 실행하고 있어요. async let 으로 선언된 userIDusername 은 각각의 비동기 함수 결과값을 담게 되는데요, 이후 await 키워드를 사용해 두 결과값을 모두 받아온 뒤 인사말을 만들어 출력하고 있습니다. 이렇게 async let 을 사용하면 서로 의존성이 없는 비동기 작업들을 동시에 실행시켜 효율성을 높일 수 있어요.

Task 로 비동기 함수를 동기 코드에서 실행하기

Task 를 사용하면 동기 코드 내에서 비동기 함수를 기다리지 않고 실행할 수 있어요. 아래는 Task 를 활용하는 코드 예시입니다.

Task {
    // connectUser 비동기 함수를 실행하고 끝나길 기다리지 않음
    await connectUser(to: "primary")
}
// "Hello Guest, user ID 97" 출력
swift

위 코드는 Task 를 사용해 비동기 함수를 실행하는 방법을 보여주고 있어요. Task 블록 내에서는 await 키워드를 사용해 connectUser 비동기 함수를 호출하고 있는데요, Task 블록 밖의 코드는 이 비동기 함수가 완료되길 기다리지 않고 바로 다음 코드를 실행해요. 따라서 connectUser 함수의 실행 결과는 나중에 출력될 거예요.

Task Group 으로 동시성 코드 구조화하기

Task group 을 사용하면 관련있는 비동기 작업들을 구조화할 수 있어요. 다음은 withTaskGroup 을 활용하는 예시 코드입니다.

let userIDs = await withTaskGroup(of: Int.self) { group in
    // 여러 서버에 대해 반복하며
    for server in ["primary", "secondary", "development"] {
        // 각 서버에 대해 fetchUserID 태스크를 추가
        group.addTask {
            return await fetchUserID(from: server)
        }
    }
 
    // 결과를 담을 배열 생성
    var results: [Int] = []
 
    // 태스크 결과를 반복하며 결과 배열에 추가
    for await result in group {
        results.append(result)
    }
 
    // 결과 배열 반환
    return results
}
 
swift

withTaskGroup 을 사용해 여러 비동기 작업을 구조화하는 방법을 보여주고 있어요. withTaskGroup 블록 내에서는 group 에 대해 addTask 메서드를 호출하며 각 서버에 대한 fetchUserID 태스크를 추가하고 있어요. 그리고 group 의 결과를 for await 루프를 사용해 하나씩 받아오며 results 배열에 추가한 후 최종 결과 배열을 반환하고 있습니다.

Actor 로 안전한 동시성 보장하기

Actor 는 클래스와 유사하지만, 동일한 actor 의 인스턴스에 대해 여러 비동기 함수가 동시에 안전하게 접근할 수 있도록 보장해줍니다. 아래는 actor 를 선언하는 예시 코드예요.

actor ServerConnection {
    // 기본 서버를 "primary" 로 설정
    var server: String = "primary"
 
    // 활성 사용자 배열 (외부에서 직접 접근 불가)
    private var activeUsers: [Int] = []
 
    // 비동기 메서드 connect() 선언
    func connect() async -> Int {
        // 서버로부터 userID 를 가져옴
        let userID = await fetchUserID(from: server)
 
        // ... 서버와 통신하는 코드 ...
 
        // 활성 사용자 배열에 userID 추가
        activeUsers.append(userID)
 
        // userID 반환
        return userID
    }
}
swift

이 코드는 actor 를 사용해 동시성을 안전하게 관리하는 방법을 보여주고 있어요. ServerConnection 이라는 actor 를 정의하고 있는데요, 내부에는 기본 서버를 나타내는 server 속성과 활성 사용자 ID 를 담는 activeUsers 배열, 그리고 connect() 비동기 메서드가 선언되어 있어요.

connect() 메서드는 server 에서 fetchUserID 함수를 호출해 사용자 ID 를 가져오고, 가져온 ID 를 activeUsers 배열에 추가한 후 반환하도록 되어 있는데요, 여기서 주목할 점은 activeUsers 배열이 actor 외부에서는 접근할 수 없도록 private 으로 선언되어 있다는 것입니다. 이렇게 함으로써 여러 비동기 태스크에서 동시에 activeUsers 배열에 접근하는 일이 생겨도 데이터의 안전성을 보장할 수 있게 됩니다.

Actor 의 메서드나 속성에 접근할 때는 해당 코드 앞에 await 를 붙여 이미 실행 중인 다른 코드가 끝날 때까지 기다려야 할 수 있음을 나타냅니다. 다음은 ServerConnection actor 를 사용하는 예시입니다.

// ServerConnection 의 인스턴스 생성
let server = ServerConnection()
 
// connect() 메서드 호출하고 결과 기다림
let userID = await server.connect()
swift

이상으로 Swift 의 동시성 프로그래밍 기능에 대해 살펴보았습니다. asyncawait 를 사용해 비동기 코드를 깔끔하게 작성하고, task groupactor 를 활용해 안전하고 구조화된 동시성 코드를 만들 수 있습니다. Swift 의 동시성 프로그래밍 기능은 정말 강력하기 때문에 잘 활용할 수 있는 능력을 꼭 갖춰야합니다.