안녕하세요! 지난 글에서는 동시성의 근본 원리를 알아보기 위해 하드웨어까지 파고 들어가 봤습니다. 동시성과 병렬성이 어떻게 다른지, 비동기가 왜 사용자 경험(UX)에 대해 다루어 봤습니다.
오늘은 기존에 있던 GCD와 Swift Concurrency가 뭐가 다른건지 알아보겠습니다.
결론부터 말하면, Swift Concurrency는 단순히 GCD를 좀 더 예쁘게 쓰는 문법 설탕(Syntactic Sugar)이 아닙니다. 코드를 더 읽기 쉽게, 더 안전하게, 그리고 더 효율적으로 만들기 위한 패러다임의 전환에 가깝습니다. 오늘은 그 이유를 개발자들이 가장 크게 체감하는 4가지 변화를 통해 알아보겠습니다.
GCD를 사용하며 가장 고통받았던 순간을 꼽으라면 단연 콜백 지옥(Callback Hell)일 겁니다. 비동기 작업이 여러 개 중첩될 때 코드가 끝없이 오른쪽으로 파고드는 그 모습입니다?
// MARK: - GCD 방식의 콜백 지옥 예시
func fetchUserData() {
fetchUserProfile("user_id") { result in
switch result {
case .success(let profile):
fetchUserPosts(profile.id) { result in
switch result {
case .success(let posts):
// 또 다른 콜백... 🤯
print("\(profile.name)의 게시물을 찾았습니다.")
case .failure(let error):
handleError(error)
}
}
case .failure(let error):
handleError(error)
}
}
}
이런 코드는 읽기도 어렵고, 나중에 유지보수하기는 더 힘듭니다.
하지만 Swift Concurrency의 async/await를 사용하면 마법처럼 코드가 깔끔해집니다.
// MARK: - async/await 방식의 깔끔한 코드
func fetchUserData() async {
do {
let profile = try await fetchUserProfile("user_id")
let posts = try await fetchUserPosts(profile.id)
print("\(profile.name)의 게시물을 찾았습니다.")
} catch {
handleError(error)
}
}
어떤가요? 비동기 코드인데도 마치 위에서 아래로 흐르는 동기 코드처럼 읽히지 않나요? 이것만으로도 Swift Concurrency를 써야 할 이유는 충분합니다.
콜백 지옥의 또 다른 골칫거리는 바로 에러 처리였습니다. 각 콜백마다 에러를 따로따로 처리해줘야 했고, 도대체 어디서 에러가 터진 건지 추적하기도 까다로웠습니다.
Swift Concurrency는 이걸 Swift의 기본 에러 처리 방식인 try-catch 구문으로 깔끔하게 통합했습니다. 여러 await 호출 중 어디서든 에러가 발생하면, 즉시 실행이 중단되고 catch 블록으로 제어가 넘어갑니다. 덕분에 에러 처리 로직을 한 곳에서 관리할 수 있게 되어 코드가 훨씬 간결하고 안정적으로 변했죠.
"이미 시작된 비동기 작업을 중간에 취소해야 한다면?"
GCD에서는 DispatchWorkItem을 사용해 취소 기능을 구현할 수 있었지만(사용해본 적은 없습니다.. 그렇다고 합니다.), cancel()을 호출해도 이미 실행 중인 코드 블록이 즉시 멈추지 않았고, 작업 중간중간 isCancelled 속성을 직접 확인해서 수동으로 중단 로직을 넣어줘야 했죠(했다고 합니다).
// MARK: - GCD의 작업 취소 (수동 체크 필요)
let workItem = DispatchWorkItem {
// ... 작업 시작 ...
if isCancelled {
print("작업이 취소되었습니다.")
return
}
// ... 다음 작업 ...
}
workItem.perform()
workItem.cancel() // 취소 플래그만 설정할 뿐, 실행을 즉시 막진 못함
반면 Task는 취소 기능이 훨씬 직관적입니다. Task.cancel()을 호출하면 해당 작업과 그에 속한 자식 작업들까지 취소 상태가 전파됩니다. 개발자는 Task.isCancelled 속성을 확인하거나, try Task.checkCancellation()을 호출해 작업이 취소되었다면 즉시 에러를 던지도록 할 수 있습니다.
// MARK: - Task의 작업 취소 (자동 전파 및 확인 용이)
let task = Task {
// ... 작업 시작 ...
try Task.checkCancellation() // 취소되었다면 여기서 에러를 던지고 중단됨
// ... 다음 작업 ...
}
task.cancel() // 이 Task와 하위 Task들에 취소 상태를 전파
동시성 프로그래밍의 가장 큰 숙제는 데이터 경쟁입니다. 여러 스레드가 동시에 하나의 데이터에 접근해 값을 수정하려고 할 때 발생하는 문제죠. 이로 인해 데이터가 깨지거나 앱이 크래시 나는 등 예측 불가능한 오류가 발생합니다.
지금까지는 이런 문제를 해결하기 위해 개발자가 직접 락(NSLock)을 걸거나, 직렬 큐(Serial Queue)를 만들어 접근을 통제해야 했습니다. 하지만 락을 제때 풀어주지 않으면 데드락(Deadlock)에 빠지는 등 또 다른 위험이 도사리고 있었죠.
Swift Concurrency는 이 문제를 언어 차원에서 해결하기 위해 actor 라는 새로운 타입을 도입했습니다. actor는 자신의 내부 데이터(상태)를 외부로부터 안전하게 보호하는 역할을 합니다. actor 내의 데이터에 접근하려면 반드시 await를 사용해야 하고, actor는 한 번에 하나의 작업만 내부 데이터에 접근하도록 보장해 데이터 경쟁을 원천적으로 방지합니다.
💡
actor에 대한 더 자세한 이야기는 다음 심화 편에서 다른 주제들과 함께 깊이 있게 다뤄보겠습니다!actor가 어떻게 우리의 코드를 더 안전하게 만들어주는지, 그 내부 동작 원리까지 함께 살펴보시죠.
오늘은 Swift Concurrency가 기존의 GCD에 비해 어떤 점들을 개선했는지, 우리 개발자들이 직접 체감할 수 있는 부분들을 중심으로 살펴보았습니다.
async/await으로 콜백 지옥을 해결하고,try-catch와 actor로 에러 처리와 데이터 경쟁 문제를 해결했으며,Task로 작업 취소를 간편하게 만들었습니다.하지만 이게 끝이 아닙니다. Swift Concurrency의 진정한 힘은 눈에 보이는 문법 개선 너머에 있습니다. 다음 심화 편에서는 Swift Concurrency의 핵심 철학을 파헤쳐 보겠습니다.
이 주제들을 통해 Swift Concurrency가 왜 단순한 문법 개선이 아닌, 동시성 프로그래밍의 패러다임 전환인지 함께 알아보겠습니다.