⎮ 프로세스와 스레드
Swift의 GCD와 Concurrency에 대해 이야기 하기 전에,
먼저 프로세스와 스레드를 짚고 넘어가야 합니다
프로세스란, 실행 중인 프로그램을 말합니다
iOS에서는 그냥 '실행 중인 앱'이 프로세스라고 생각하면 됩니다
각 프로세스는 독립된 메모리 공간(코드, 데이터, 스택, 힙)을 가집니다
한 프로세스가 다른 프로세스의 메모리 영역에 마음대로 침범하지 못하도록 OS는 이를 보호합니다
스레드란, 프로세스 내부에서 실제 일을 하는 '실행 단위'를 말합니다
따라서 한 프로세스 안에 스레드가 여러 개 있을 수 있습니다
iOS 기준으로는 main thread와 background threads로 구분됩니다
그렇다면 왜 스레드는 구분되어 있는거고, 여러개의 스레드가 필요한 걸까요?
⎮ 스레드는 왜 여러개일까?
만약 메인 스레드 하나만 있다면 어떻게 될까요?
3초 뒤 응답이 오는 네트워크 작업이 있다고 해보겠습니다
유저가 버튼을 누르면, 3초동안 UI가 멈춰버립니다
스레드는 일을 하는 작업자와 같아서 응답이 오기 전까지 기다려버립니다
그래서 우리가 네트워킹을 할 때,
무거운 작업은 백그라운드 스레드에서
UI 업데이트는 메인 스레드에서 하는 것이죠
여기까지가 OS 레벨에서의 동시성 개념입니다
사실 예전에는 개발자가 스레드를 직접 만들고 관리(pthread)했습니다
이러한 방식은 이후 NSThread로 발전해왔습니다
다만 Objective-C 환경에서 사용하던 NSThread도
개발자가 직접 스레드 수를 신경 써야하고, 생명주기를 직접 관리해야하고, 동기화도 직접 해야했습니다
이후 Queue로 관리하던 NSOperationQueue 개념을 지나 GCD로 발전하게 된 것입니다
⎮ GCD(Grand Central Dispatch)는 왜 나왔을까?
예전의 PC는 CPU 코어가 1개였습니다
코어가 1개라는건, 작업자가 하나임을 의미합니다
다만 그때도 프로그램은 동시에 돌아가는 것처럼 보였는데,
CPU가 아주 빠른 속도로 작업을 번갈아 가면서 처리(작업 A 조금 -> 작업 B 조금 -> 작업 C ...)했기 때문입니다
따라서 싱글코어 시절에도 '스레드'라는 건 있었고, 동시성이라는 개념도 있었습니다
(다만 진짜 병렬 구조는 아니고, 빠르게 스위칭하는 형태로 작동)
GCD가 등장하는 시기(2009년)에는 이미 멀티코어가 일상이었습니다
따라서 이 멀티코어 환경에 맞춰서 OS도 효율적으로 사용하고자 한 것입니다
만약 코어가 4개면, 이론상 서로 다른 4개의 스레드를 동시에 실행할 수 있습니다
이 시점에서 개발자가 스레드를 생성하고, 코어 활용을 어떻게 할지 다 사용하는 것은 너무 복잡한 일입니다
따라서 다음과 같은 패러다임이 등장한 것입니다
"할 일은 작업장(큐)에 던져. 나머지는 OS + 런타임이 알아서 최적화할게"
이게 바로 GCD입니다
⎮ GCD의 핵심 개념
GCD를 이해하려면 DispatchQueue와 sync / async 를 알아야합니다
DispatchQueue는 할 일을 쌓아 두는 곳입니다
이 Queue에 작업을 넣어주면 GCD가 알아서 적당한 스레드에서 꺼내서 해당 작업을 실행합니다
Queue는 '어디서' 일을 할지와 관련이 있습니다
- Main Queue: 메인 스레드에서 실행되는 serial Queue(UI 업데이트 전용)
- Global Queue: 백그라운드에서 돌리는 concurrent Queue
- Custom Queue: 직접 만드는 serial / concurrent Queue
// main
DispatchQueue.main.async {
}
// global(background)
DispatchQueue.global().async {
}
sync와 async는 '언제' 일을 시작하고 끝낼지에 대한 것으로,
async는 비동기로, 작업을 큐에 맡기고 기다리지 않습니다(보통 네트워크 작업, 디스크 IO 등 비동기 작업에 주로 사용)
sync는 동기로, 작업이 끝날 때까지 현재 스레드를 블록합니다(주로 짧은 보호 구간이나 동기적인 계산에 쓰임)
⎮ GCD의 이점과 한계
GCD 환경으로 넘어오면서 개발자가 스레드를 직접 만들 필요가 거의 없어졌습니다
또 이제는 큐 단위로 생각하면 되니까, 코드가 비교적 구조화되었습니다
DispatchQueue.global().async {
let data = heavyWork()
DispatchQueue.main.async {
self.updateUI(data)
}
}
시간이 오래 걸리는 무거운 작업은 background에서,
UI 업데이트는 main으로 구분할 수 있는 큰 단위로 생각이 가능해졌습니다
다만 구조상 생기는 문제점도 있었습니다
비동기 작업 중간에 비동기 작업을 또 하게 되면, 콜백 지옥이 생깁니다(completion 클로저 중첩으로 인한 가독성 하락)
Result Type이나 error를 클로저 기반으로 계속 전달하다보니, 에러처리가 꼬이는 현상이 생깁니다
또한 진행 중이던 작업이 필요 없어졌을 때, 작업의 cancel 관리가 어려워집니다
같은 값을 여러 큐에서 건드리면 race condition이 발생하기 때문에
개발자가 직접 NSLock이나 DispatchSemaphore(Int 기반 작업 관리)를 써서 관리해야 합니다
또한 스레드 관점에서의 단점도 있었는데,
GCD는 내부에 있는 스레드 풀에서 큐에 들어온 작업을 OS 스레드 위에서 실행합니다
A 스레드를 제외한 모든 스레드가 일하고 있는 상황이라고 가정해보겠습니다
A 스레드에서 네트워크를 동기적으로 호출합니다
A 스레드는 네트워크의 응답이 올때까지 기다립니다
이때 추가적으로 해야하는 작업이 생겼습니다
GCD 입장에서는 A 스레드가 대기 중이기 때문에, 더이상 작업을 지시할 스레드가 없습니다
추가 작업을 위해 스레드를 하나 더 생성합니다
그러면 이때 스레드 스택 메모리가 추가로 들어가고 컨텍스트 스위칭 비용도 들어갑니다
이러한 단점들을 보완하기 위해서 나온게
Swift Concurrency + async / await입니다
⎮ Swift Concurrency
Swift Concurrency는 다음과 같은 목표로 설계되었습니다
"동시성 + 비동기 코드를 더 읽기 쉽게, 더 안전하게 만들자"
Swift Concurrency에서 중요한 개념은 async/await, Task + Structured Concurrency, Actor / Sendable입니다
이해를 돕기 위해 GCD 기반의 네트워크 처리부터 Swift Concurrency를 적용하면 어떻게 달라지는지 알아보겠습니다
// 모델 정의
struct UserResponse: Decodable {
let data: User
}
struct User: Decodable {
let id: Int
let email: String
let first_name: String
let last_name: String
let avatar: String
}
// 네트워크 에러 정의
enum NetworkError: Error {
case invalidURL
case invalidStatusCode(Int)
case noData
}
final class UserAPIService {
func fetchUser(completion: @escaping (Result<User, Error>) -> Void) {
guard let url = URL(string: "https://reqres.in/api/users/0") else {
completion(.failure(NetworkError.invalidURL))
return
}
let task = URLSession.shared.dataTask(with: url) { data, response, error in
if let error {
DispatchQueue.main.async {
completion(.failure(error))
}
return
}
guard let httpResponse = response as? HTTPURLResponse else {
DispatchQueue.main.async {
completion(.failure(NetworkError.noData))
}
return
}
guard (200..<300).contains(httpResponse.statusCode) else {
DispatchQueue.main.async {
completion(.failure(NetworkError.invalidStatusCode(httpResponse.statusCode)))
}
return
}
guard let data else {
DispatchQueue.main.async {
completion(.failure(NetworkError.noData))
}
return
}
do {
let decoded = try JSONDecoder().decode(UserResponse.self, from: data)
DispatchQueue.main.async {
completion(.success(decoded.data))
}
} catch {
DispatchQueue.main.async {
completion(.failure(error))
}
}
}
task.resume()
}
}
let service = UserAPIService()
service.fetchUser { result in
switch result {
case .success(let user):
print("유저 이름:", user.first_name, user.last_name)
case .failure(let error):
print("에러", error)
}
}
이를 Swift Concurrency로 변경하면 아래의 코드처럼 됩니다
final class UserAPIService {
func fetchUser() async throws -> User {
guard let url = URL(string: "https://reqres.in/api/users/0") else {
throw NetworkError.invalidURL
}
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200..<300).contains(httpResponse.statusCode) else {
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
throw NetworkError.invalidStatusCode(statusCode)
}
let decoded = try JSONDecoder().decode(UserResponse.self, from: data)
return decoded.data
}
}
let service = UserAPIService()
Task {
do {
let user = try await service.fetchUser()
print("유저 이메일:", user.email)
} catch {
print("에러:", error)
}
}
먼저, 코드의 가독성이 개선됩니다
completion handler를 사용한 코드는 다음과 같습니다
service.fetchUser { result in
switch result {
case .success(let user):
print(user.email)
case .failure(let error):
print(error)
}
}
이 코드를 읽었을 때 fetchUser가 동기 코드인지, 비동기 코드인지 직관적으로 확인되지 않습니다
Swift Concurrency의 async / await는 비동기인지, 에러를 전달하는지 등을 직관적으로 알 수 있게 해줍니다
Task {
do {
let user = try await service.fetchUser()
print(user.email)
} catch {
print(error)
}
}
또 에러 핸들링 방식에도 차이가 있습니다
completion Handler와 Result를 사용한 구문은 에러가 Result에 wrapping되어 내려옵니다
따라서 호출하는 쪽에서는 항상 switch문으로 풀어줘야 합니다
fetchUser { result in
switch result {
case .success(let user): ...
case .failure(let error): ...
}
}
만약 이 에러를 또 다른 함수로 넘기고 싶으면,
다시 Result로 감싸거나, if case .failure(error) 같은 패턴을 사용해야해서 코드가 길어집니다
async / await + throws를 사용하면 에러가 발생할 수 있음을 throws로 표현하기 때문에 직관적입니다
func fetchUser() async throws -> User {
}
그리고 호출하는 쪽에서도 동기 코드처럼 작성할 수 있습니다
do {
let user = try await service.fetchUser()
} catch {
print(error)
}
단일 네트워크 호출일 때는 이 장점이 보이지 않지만,
여러 네트워크 호출을 이어 붙일 때 에러 전파가 자연스럽습니다
do {
let user = try await fetchUser()
let posts = try await fetchPosts(for: user)
let comments = try await fetchComments(for: posts)
} catch {
print(error)
}
스레드 / Dispatch 관점에서도 차이가 발생합니다
completion 버전은 메인 스레드 보장을 하지 않기 때문에, UI 업데이트가 필요한 작업을 할 때는
항상 main thread로 되돌려줘야 합니다
let task = URLSession.shared.dataTask(with: url) { data, response, error in
// 여기 클로저는 백그라운드 스레드에서 돌아올 수도 있음
DispatchQueue.main.async { // UI 업데이트를 할 때는 항상 main으로 돌려줘야 함
completion(.success(decoded.data))
}
}
async / await를 사용하면
Swift Concurrency가 actor / 메인 actor / 또는 executor로 어디서 돌아가야 하는지 관리를 해줍니다
let (data, response) = try await URLSession.shared.data(from: url)
만약 이 함수를 @MainActor에서 호출하면, 중간에 await로 잠깐 양보했다가
다시 돌아올 때 자동으로 메인 컨텍스트로 복귀하게 됩니다
⎮ 정리
GCD가 "어느 큐/스레드에서 이걸 실행할지?"에 초점을 맞춘다면,
Swift Concurrency는 "어떤 작업을 어떤 관계(부모 - 자식 Task)로 묶을까?"에 초점을 맞추고 있습니다
즉 GCD는 스레드 / 큐 레벨에서 동시성을 다루고,
Swift Concurrency는 Task와 구조화된 동시성 레벨에서 동시성을 다룹니다
GCD와 Swift Concurrency는 양자택일의 개념은 아닙니다(추상화 레벨이 다른 도구)
GCD는 큐와 스레드를 직접 다루는 OS 차원의 Low level Framework이고,
시리얼큐를 사용하거나 Qos를 디테일하게 제어하고 싶을 때, 레거시 코드나 라이브러리를 사용할 때는 여전히 필요합니다
Swift Concurrency는 동시성을 설계하는 언어 레벨의 추상화 도구이기 때문에,
최신 iOS 버전을 사용하거나 신입/주니어가 많은 팀에서 도입할 때 강점을 발휘할 것으로 보입니다
'Swift > TOPIC' 카테고리의 다른 글
| Swift | 메모리 누수(2) (Memory Graph) (0) | 2025.09.01 |
|---|---|
| Swift | 메모리 누수(1) (feat. Instruments Leaks) (0) | 2025.08.30 |
| Swift | translatesAutoresizingMaskIntoConstraints는 왜 끄는걸까? (2) | 2025.07.20 |
| Swift | 초기화에는 시간이 얼마나 걸릴까? (0) | 2025.07.13 |
| Swift | UserDefaults는 어디에 저장될까? (+ PropertyWrapper) (1) | 2025.07.09 |