iOS/Swift

[Swift] RunLoop 와 GCD

kimsangjunzzang 2025. 7. 28. 02:31

안녕하세요, iOS 개발하는 루피입니다.

 

개발을 하시면서, DispatchQueue.global().async라는 코드를 한 번쯤은 사용해 보셨을 겁니다. 우리는 이 코드를 통해 무거운 작업을 백그라운드 스레드로 보내 UI 프리징을 막곤 하죠. 하지만 혹시 이런 질문을 던져본 적 있으신가요?

  • DispatchQueue/global() 은 정확히 무엇일까?
  • 왜 우리는 Thread.start()처럼 스레드를 직접 만들지 않을까?
  • 시스템은 우리가 던져준 작업을 어떻게 처리하고 있는 걸까?

오늘은 이러한 숨겨진 내부 동작을 공부해 보면서, iOS 동시성의 가장 낮은 곳에 있는 RunLoopThread Pool의 개념을 이해하고, 이를 통해 우리가 자주 사용하는 GCDOperationQueue가 왜 효율적인 방법인지에 대해 한번 정리해 보겠습니다. 바로 시작합니다.


Event Loop와 RunLoop

Event Loop는 특정 스레드가 할 일이 생길 때까지 종료되지 않고 계속 대기하며, 이벤트가 발생하면 즉시 처리하도록 만드는 프로그래밍 모델입니다. 그리고 이를 Apple에서 객체로 구현한 것이 바로 RunLoop입니다.

 

RunLoop를 24시간 대기하는 담당 직원에 비유할 수 있습니다.

  1. 이벤트 발생: 사용자의 터치, 타이머 신호 등 업무 요청이 들어옵니다.
  2. 큐에 저장: 이 요청들은 Event Queue에 순서대로 쌓입니다.
  3. 루프 실행: RunLoop가 대기열을 계속 확인하며, 요청이 있으면 하나씩 꺼내 처리합니다.
  4. 대기: 처리할 요청이 없으면, 직원은 전력을 아끼며 잠시 sleep에 들어갔다가 다음 요청이 오면 즉시 깨어납니다.

메인 스레드의 RunLoop

앱이 실행될 때, 시스템은 메인 스레드를 생성하고 자동으로 이 스레드의 RunLoop를 실행시킵니다. 이 RunLoop는 앱의 생명줄과도 같습니다. 메인 RunLoop가 처리하는 주요 이벤트는 다음과 같습니다.

  • UI 이벤트: 사용자의 터치, 스크롤, 버튼 클릭 등
  • 화면 갱신: Core Animation을 통한 매 프레임 UI 렌더링
  • Timer: 설정된 시간에 맞춰 코드 실행
  • 네트워크 응답: URLSession 등에서 오는 데이터 수신 처리

만약 개발자가 메인 스레드에서 무겁고 오래 걸리는 작업을 시키면, RunLoop는 그 작업에 발목이 잡힙니다. 그 결과, RunLoop는 대기열에 쌓인 다른 UI 이벤트를 처리할 수 없게 되어 앱이 멈춘 것처럼 보이는 UI 프리징 현상이 발생하게 되는 거죠.

// ❌ 나쁜 예시: 메인 스레드의 RunLoop를 막는 코드
@IBAction func myButtonTapped(_ sender: Any) {
    // 이 루프가 도는 5초 동안 RunLoop는 다른 일을 못하고 여기에 묶여 있습니다.
    // 그 결과, 이 작업이 끝날 때까지 앱은 아무런 터치에도 반응하지 않습니다!
    for _ in 0..<5_000_000_000 {
        // ... 아주 복잡한 동기(sync) 계산 ...
    }
}

백그라운드 스레드의 RunLoop

DispatchQueue.global() 등으로 생성된 백그라운드 스레드는 기본적으로 RunLoop를 가지고 있지 않습니다. 그냥 주어진 작업을 한 번 실행하고 소멸하는 것이 일반적입니다.

 

하지만 특정 백그라운드 스레드를 계속 살아있게 만들어서 Timer나 네트워크 포트 감시 같은 지속적인 대기 작업을 시키고 싶을 때, 우리는 수동으로 RunLoop를 실행할 수 있습니다. 

class MyCustomThread: Thread {
    override func main() {
        let runLoop = RunLoop.current

        // 이 스레드에서 타이머가 동작하도록 RunLoop에 추가
        Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
            print("백그라운드 스레드 타이머 동작 중...")
        }

        // RunLoop를 실행하여 스레드가 종료되지 않고 이벤트(타이머)를 기다리게 함
        runLoop.run()

        print("이 코드는 runLoop가 멈추기 전까지 실행되지 않습니다.")
    }
}

// 사용법
let myThread = MyCustomThread()
myThread.start()

스레드 풀 (Thread Pool)

미리 고용해 둔 아르바이트생들

매번 작업이 필요할 때마다 스레드를 생성하고 파괴하는 것은 컴퓨터에게 시간과 메모리 측면에서 큰 부담입니다.

 

스레드 풀은 이런 비효율을 막기 위해, 미리 여러 개의 스레드를 만들어 두고 필요할 때마다 재사용하는 기술입니다.

왜 개발자는 직접 스레드 풀을 다루지 않을까?

Apple은 개발자가 스레드 풀을 직접 제어하는 것을 의도적으로 막고 있습니다. 이유는 명확합니다.

  1. 시스템 최적화의 한계: iOS는 CPU 코어 개수, 현재 부하, 메모리 상태, 배터리, 발열 등 시스템 전체 상황을 알고 있습니다. 이 정보를 바탕으로 OS는 지금 몇 개의 스레드를 돌리는 것이 최적인지 가장 잘 판단할 수 있습니다. 개별 앱 수준에서는 불가능한 일입니다.
  2. 과도한 복잡성과 위험: 스레드 풀을 직접 관리하려면 스레드의 생명 주기, 최적의 개수, 작업 큐, 동기화 문제 등 매우 복잡하고 오류가 발생하기 쉬운 문제들을 개발자가 모두 책임져야 합니다.
  3. Apple의 설계 철학: "무엇을 할지"에만 집중하라!
    Apple은 GCD를 통해 개발자의 패러다임을 바꿨습니다. "이 작업을 어떤 스레드에서 실행하지?(How)"가 아닌, "이 작업어떤 중요도로 처리할까?(What & Why)"에만 집중하도록 유도합니다.

Apple의 해답: 추상화를 통한 위임

개발자는 스레드 풀을 직접 만드는 대신, 시스템에 작업을 위임합니다. 그러면 시스템이 알아서 최적으로 처리해주게 되는데요 이 위임을 위한 최고의 도구가 바로 GCDOperationQueue입니다.

1. GCD

DispatchQueue.global()를 호출하는 것은 "시스템아, 네가 관리하는 전역 스레드 풀에서 스레드 하나를 빌려줘. 이 작업을 시킬게"라고 말하는 것과 같습니다.

 

GCD는 시스템 상황에 따라 스레드를 재사용하거나, 새로 만들거나, 기다리게 하는 등 동적으로 스레드 풀을 최적 운영합니다. 개발자는 스레드를 고르는 대신, 작업의 중요도(QoS)를 지정하여 시스템에게 힌트를 줍니다.

  • userInteractive: UI와 직접 관련된 즉각적인 작업
  • userInitiated: 사용자가 시작하여 즉각적인 피드백이 필요한 작업
  • utility: 시간이 좀 걸려도 되는 작업 (네트워크 통신 등)
  • background: 사용자가 인지하지 못하는 백그라운드 작업

2. OperationQueue

OperationQueue는 GCD 위에서 동작하는 고수준 API로, GCD의 스레드 풀을 활용하면서 훨씬 더 정교한 제어 기능을 제공합니다. OperationQueue"스레드 풀을 직접 관리하는 듯한 경험"을 안전하게 제공하는 최고의 도구입니다.

 

핵심 기능은 maxConcurrentOperationCount입니다. 동시에 실행할 최대 작업의 개수를 제한하여, 개발자가 원했던 흐름 제어(Flow Control)를 손쉽게 구현할 수 있습니다.

let downloadQueue = OperationQueue()

// 이 큐에서는 동시에 최대 3개의 다운로드 작업만 병렬로 실행된다.
// 4번째 작업은 앞선 작업 중 하나가 끝나야 시작된다.
// ※ 주의: 3개의 '스레드'가 아니라 3개의 '작업'을 의미. 스레드 할당은 여전히 GCD가 알아서 한다.
downloadQueue.maxConcurrentOperationCount = 3

for i in 1...10 {
    downloadQueue.addOperation {
        print("[\(i)번] 다운로드 시작 - 스레드: \(Thread.current)")
        sleep(2) // 다운로드를 흉내 내기 위한 2초 대기
        print("[\(i)번] 다운로드 완료")
    }
}

이 외에도 작업 간의 의존성 설정, 작업 취소 등 강력한 기능들을 제공합니다.


정리

이 글을 통해 우리는 iOS 동시성 API의 표면 아래를 들여다보았습니다.

  • RunLoop는 스레드의 심장 박동처럼 이벤트를 처리하며 스레드를 살아있게 만듭니다.
  • 스레드 풀은 시스템이 작업을 효율적으로 처리하기 위해 관리하는 일꾼들의 집합입니다.
  • GCDOperationQueue는 이 복잡한 저수준 메커니즘을 아름답게 추상화하여, 우리가 무엇을 할지에만 집중하도록 돕는 강력한 도구입니다.
구분 RunLoop GCD (DispatchQueue.global) OperationQueue
목적 스레드를 종료시키지 않고 이벤트 처리를 위해 대기 작업을 시스템의 전역 스레드 풀에 위임 GCD 기반의 고수준 작업 관리 및 흐름 제어
제어 수준 저수준(Low-level) 중수준(Mid-level) 고수준(High-level)
주요 사용처 [시스템] 메인 스레드 생명 유지
[개발자] 특정 Background 스레드에서 타이머 등 필요시
간단한 비동기 작업 처리 복잡한 작업 흐름 제어 (동시 실행 개수 제한, 작업 의존성)

 


REF

1. Apple 공식 문서

2. Apple WWDC 세션 영상

3. 참고 사이트


오늘도 화이팅입니다.