[iOS] Swift Concurrency

Emily·2025년 5월 22일
1

Swift 5.5에서 도입된 Swift Concurrency는 비동기 프로그래밍을 더욱 쉽고 안전하게 만들어주는 시스템이다.

async / await

async 키워드는 비동기적으로 실행되는 함수에 붙이고, await 키워드는 비동기 함수의 결과를 기다릴 때 사용한다.

func fetchData(_ url: URL) async -> Data {
	let (data, _) = try await URLSession.shared.data(from: url)
    return data
}
  • 비동기 함수의 직렬 호출
let photo1 = await fetchImage(from: url1)
let photo2 = await fetchImage(from: url2)
let photo3 = await fetchImage(from: url3)
let photos = [photo1, photo2, photo3]

await를 통해 비동기 함수를 호출할 경우, 해당 코드가 완료될 때까지 기다린 뒤 다음 실행을 하기 때문에 위의 코드와 같은 상황이라면 3번을 기다리는 게 된다.

  • 비동기 함수의 병렬 호출
async let photo1 = fetchImage(from: url1)
async let photo2 = fetchImage(from: url2)
async let photo3 = fetchImage(from: url3)
let photos = await [photo1, photo2, photo3]

함수 이름 앞에 await를 붙이는 대신 결과를 할당하는 상수 앞에 async를 붙이고, 3개의 응답 결과 앞에 await를 붙이면 함수가 병렬로 실행된다.

throws / throw / try

throws는 오류를 발생하는 함수에 붙이고, throw는 오류를 발생시킬 때 사용한다.

func fetchData(_ urlString: String) async throws -> Data {
	guard let url = URL(string: urlString) else {
    	throw NetworkError.invalidURL	// NetworkError는 커스텀으로 정의한 에러 타입이다.
    }
    
    let (data, response) = try await URLSession.shared.data(from: url)
    
    guard let response = response as? HTTPURLResponse,
    	  (200..<300).contains(response.statusCode) else {
    	throw NetworkError.badResponse
    }
    
    guard !data.isEmpty else {
    	throw NetworkError.noData
    }
    
    return data
}

이 때 throws 함수의 결과를 받을 땐 try가 붙게 된다. async throws 함수의 경우에는 try await, throws 함수가 아니면 await만 붙여서 호출한다.

Task

Task는 비동기 작업의 기본 단위로, 작업을 수행할 클로저를 제공한다. async 함수를 비동기 코드가 아닌 일반 동기 코드에서 호출하려면 Task를 통해 실행해야 한다. 스코프 내에서 함수를 호출하고, await 키워드를 이용하여 비동기 작업이 완료될 때까지 대기한다.

Task {
	let data = try await fetchData("https://example.url")
	print(data)
}

위의 코드를 기존 DispatchQueue 코드와 비교해보면 아래와 같다.

// 백그라운드에서 데이터 호출
DispatchQueue.global().async {
	let data = fetchData()
    // 결과 처리는 메인 스레드
    DispatchQueue.main.async {
    	print(data)
    }
}

이와 달리 Task는 스레드 관리가 자동으로 처리되므로 메인 스레드와 백그라운드 스레드를 명시적으로 지정할 필요가 없다.

  • Task Priority : 작업 우선순위 관리
Task {
    let task1 = Task(priority: .high) {
        try await fetchData("https://example.url1")
    }
    
    let task2 = Task(priority: .medium) {
    	try await fetchData("https://example.url2")
    }
    
    let task3 = Task(priority: .low) {
    	try await fetchData("https://example.url3")
    }
    
    print(try await task1.value)
    print(try await task2.value)
    print(try await task3.value)
}
  • cancel() : 작업 취소 시 사용
let task = Task {
	try await fetchData("https://example.url")
}

task.cancel()
  • yield() : 작업의 중단 지점 명시
func fetchManyData() async {
	let datas = await fetchData()
    for data in datas {
    	print(data)
        await Task.yield()
    }
}

yield 호출 시 현재 Task의 실행이 중단되고 대기열의 끝으로 이동하며, 대기 중인 Task의 스케줄링이 진행된다. 과도한 사용은 성능 저하로 이어지므로 필요할 때만 사용하는 게 좋다고 한다.

  • sleep() : 동시성 동작을 확인하기 위해 주어진 시간만큼 작업을 중단할 때 사용
try await Task.sleep(for: .seconds(2))

Actor

동시성을 안전하게 관리하기 위해 설계된 참조 타입이다. 여러 스레드가 동일한 데이터에 접근할 때 발생할 수 있는 경합 문제를 방지한다. (작업을 순차적으로 처리하여 안전성을 보장)

actor 키워드를 사용하여 정의하며, Actor 내부의 데이터는 Actor 내부의 메소드를 통해서만 접근할 수 있다는(캡슐화) 특징이 있다. 내부 메소드는 기본적으로 비동기 메소드로 작성되고 await 키워드를 사용하여 호출한다.

여러 화면에서 동시에 수정 가능한 데이터를 다루는 객체를 액터로 관리하면 좋다. ex. 사용자 정보 관리, 파일 다운로드 매니저, 앱 로깅 시스템, WebSocket 연결 관리 등

actor UserManager {
	private var currentUser: User?
    
    func updateNickname(_ nickname: String) async {
    	currentUser?.nickname = nickname
        
        // 서버 업데이트
        try? await APIService.updateUserInfo(nickname: nickname)
    }
    
	func getCurrentUser() async -> User? {
    	return currentUser
    }
}

// 여러 view controller에서 동시에 접근해도 안전
class ProfileViewController: UIViewController {

	private let userManager: UserManager = .init()
    
	// ... //
    
    func changeNickname() {
    	Task {
        	await userManager.updateNickname(nicknameTextField.text ?? "")
        }
    }
}
profile
iOS Junior Developer

0개의 댓글