Background Thread — 사용자 인터페이스와 관련이 없는 실행 스레드로, 장시간 작업(네트워크 요청, 파일 작업, JSON 파싱, 이미지 압축, 암호화, 데이터베이스 쿼리)을 위해 설계되었습니다. iOS에서는 백그라운드 스레드가 GCD(DispatchQueue.global)와 OperationQueue를 통해 관리됩니다. Android에서는 Executors, WorkManager 및 Kotlin Coroutines(Dispatchers.IO, Dispatchers.Default)를 통해 관리됩니다. Apple DispatchQueue 문서에 따르면, 백그라운드 작업이 완료된 후 인터페이스를 업데이트하려면 결과를 Main Thread로 반환해야 합니다.
핵심 요점
Background Thread — 애플리케이션에서 Main Thread가 아니고 UI에 접근할 수 없는 모든 스레드입니다. 그 임무는 메인 스레드를 무거운 작업에서 해방시켜 인터페이스의 응답성을 유지하는 것입니다. 운영 체제는 백그라운드 스레드를 CPU 코어에 분산하여 여러 작업을 병렬로 실행할 수 있게 합니다. iOS는 GCD를 통해, Android는 Java Executors 풀을 통해 스레드 풀을 자동으로 관리합니다.
이벤트를 순차적으로(하나씩) 처리하는 Main Thread와 달리, 백그라운드 스레드는 CPU 코어 수에 의해서만 제한되어 병렬로 실행될 수 있습니다. 예를 들어, 8코어 장치에서는 최대 8개의 병렬 백그라운드 작업을 심각한 지연 없이 실행할 수 있습니다. 그러나 과도한 수의 스레드(수백 개)는 thread starvation — 코어 경쟁 및 컨텍스트 스위치 오버헤드 증가를 초래합니다.
Quality of Service(QoS) — iOS 메커니즘으로, 백그라운드 작업의 우선순위를 지정할 수 있습니다. 값: .userInteractive(가장 높음, 거의 Main Thread), .userInitiated(사용자가 결과를 기다림), .default(표준), .utility(사용자가 직접 기다리지 않음), .background(가장 낮음, 동기화 및 인덱싱용). Android에서는 Thread.setPriority()(1~10)가 이에 해당하지만, Android는 cgroups를 사용한 스레드 우선순위 그룹 관리도 사용합니다.
DispatchQueue.global(qos:) — iOS에서 백그라운드 큐를 얻는 주요 방법입니다. GCD(Grand Central Dispatch)는 자동으로 스레드 풀을 생성하고 코어에 작업을 분배합니다. DispatchQueue.global(qos: .background).async {} 호출은 가장 낮은 우선순위로 백그라운드 큐에 블록을 보냅니다. 결과가 즉시 필요한 작업에는 .userInitiated 또는 .utility를 사용하세요.
OperationQueue — GCD 위의 고수준 추상화로, 작업 간 의존성, 최대 동시 작업 수(maxConcurrentOperationCount) 및 우선순위를 설정할 수 있습니다. OperationQueue는 복잡한 멀티태스킹 체인(파일 다운로드→압축 풀기→캐시에 저장)에 유용합니다. 기본적으로 OperationQueue는 달리 지정되지 않는 한 백그라운드 스레드를 사용합니다.
import UIKit
class ImageDownloader {
func downloadImagesSequentially() {
let urls = ["https://example.com/1.png", "https://example.com/2.png"]
// maxConcurrentOperationCount = 2인 OperationQueue
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 2
queue.qualityOfService = .utility
for urlString in urls {
queue.addOperation {
guard let url = URL(string: urlString),
let data = try? Data(contentsOf: url)
else { return }
DispatchQueue.main.async {
print("로드됨: \(url.lastPathComponent)")
}
}
}
}
// GCD: 다양한 QoS를 가진 글로벌 백그라운드 큐
func backgroundTaskWithQoS() {
DispatchQueue.global(qos: .userInitiated).async {
// 높은 우선순위 — 사용자가 결과를 기다리고 있음
let result = self.heavyComputation()
DispatchQueue.main.async {
self.showResult(result)
}
}
}
private func heavyComputation() -> String {
Thread.sleep(forTimeInterval: 2) // 작업 시뮬레이션
return "계산 결과"
}
private func showResult(_ result: String) {
print("Result on Main: \(result)")
}
}
예제에서 OperationQueue는 qualityOfService = .utility를 통한 백그라운드 QoS로 두 이미지를 병렬 로드합니다(maxConcurrentOperationCount = 2). GCD 메서드 backgroundTaskWithQoS는 사용자가 결과를 기다리는 작업에 .userInitiated로 글로벌 큐를 사용합니다. 두 접근 방식 모두 UI 업데이트를 위해 DispatchQueue.main으로 돌아와 종료됩니다 — 이는 iOS의 필수 요구사항입니다.
GCD는 직렬(serial) 큐와 동시(concurrent) 큐의 두 가지 유형을 지원합니다. 직렬 큐는 작업을 하나씩 순차적으로 실행합니다 — 이는 잠금 없이 공유 리소스(파일, DB)에 접근하는 데 편리합니다. 동시 큐는 작업을 병렬로 실행하여 사용 가능한 코어에 분배합니다. DispatchQueue.global은 항상 동시 큐입니다. 직렬 큐를 만들려면 DispatchQueue(label: "com.app.queue")를 사용하세요.
Android는 백그라운드 스레드를 위해 여러 추상화 계층을 제공합니다. 클래식한 접근 방식은 java.util.concurrent.Executors.newFixedThreadPool(n) 또는 Executors.newCachedThreadPool()입니다. 현대적인 접근 방식은 Dispatchers.IO(I/O: 네트워크, 파일, DB)와 Dispatchers.Default(CPU 집약적 작업: 정렬, 이미지 처리)를 갖춘 Kotlin Coroutines입니다. WorkManager는 지연 및 보장된 백그라운드 작업용입니다.
HandlerThread — 자체 Looper(메시지 큐)를 가진 백그라운드 스레드를 생성하기 위한 Android 특화 클래스입니다. Executors와 달리 HandlerThread는 Handler를 통해 메시지와 Runnable을 보낼 수 있습니다. 큐잉이 필요한 작업(예: 순차적 DB 쓰기)에 사용됩니다. 사용 후에는 quit() 또는 quitSafely()를 호출하여 리소스를 해제해야 합니다.
// Android: Executors 및 Coroutines Dispatchers
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.concurrent.Executors
class DataRepository {
private val ioExecutor = Executors.newFixedThreadPool(4)
// Executors를 통한 클래식 접근 방식
fun loadDataLegacy(callback: (String) -> Unit) {
ioExecutor.execute {
val result = readFromFile()
val handler = android.os.Handler(android.os.Looper.getMainLooper())
handler.post { callback(result) }
}
}
// Coroutines를 통한 현대적 접근 방식
suspend fun loadDataCoroutines(): String {
return withContext(Dispatchers.IO) {
// 파일 작업 — 백그라운드 풀에서 실행 중
readFromFile()
}
// 결과가 자동으로 Dispatchers.Main으로 반환됨
}
// Dispatchers.Default에서 CPU 집약적 작업
suspend fun processImage(pixels: IntArray): IntArray {
return withContext(Dispatchers.Default) {
// 정렬, 필터링 — Default 풀에서 실행 중
pixels.sortedArray()
}
}
private fun readFromFile(): String {
Thread.sleep(1000) // 파일 읽기 시뮬레이션
return "file_content"
}
fun cleanup() {
ioExecutor.shutdown()
}
}
DataRepository 예제는 Android에서 백그라운드 스레드의 진화를 보여줍니다. 레거시 메서드 loadDataLegacy는 Main Thread로 돌아가기 위해 Handler와 함께 Executors.newFixedThreadPool(4)를 사용합니다. 현대적인 loadDataCoroutines는 withContext(Dispatchers.IO)를 사용합니다 — coroutine은 스레드를 차단하지 않고 실행 중에 일시 중단되며 자동으로 Main Thread에서 재개됩니다. Dispatchers.Default는 CPU 바운드 작업(정렬, 필터링, 데이터 변환)에 권장됩니다.
Kotlin Coroutines — 단순한 스레드 작업 방식이 아니라 근본적으로 다른 모델입니다. 비동기 작업은 특정 스레드에 바인딩되지 않으며 차단 없이 일시 중단될 수 있습니다. 즉, 백그라운드에 있는 동안 coroutine은 스레드를 점유하지 않고 다른 작업을 위해 해제합니다. 일시 중단 메커니즘을 통해 4~8개 스레드 풀에서 thread starvation 없이 수십만 개의 동시 작업을 실행할 수 있습니다.
세 가지 주요 디스패처: Dispatchers.Main(UI, 1개 스레드), Dispatchers.IO(기본적으로 64개 스레드, 차단 작업용: 네트워크, 파일, DB), Dispatchers.Default(CPU 코어 수와 동일, 집약적 계산용). withContext를 통해 이를 결합하여 개발자는 콜백을 만들지 않고 스레드 간 전환을 수행합니다. withContext는 작업이 완료될 때까지 제어를 반환하지 않는 suspend 함수입니다.
// Coroutines: 백그라운드 작업 구성
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
suspend fun loadUserProfile(userId: String): UserProfile =
coroutineScope {
// 여러 소스에서 병렬 데이터 로드
val user = async(Dispatchers.IO) { fetchUser(userId) }
val posts = async(Dispatchers.IO) { fetchPosts(userId) }
val avatar = async(Dispatchers.Default) {
processAvatar(fetchAvatar(userId))
}
// await() — 모든 작업이 완료될 때까지 일시 중단
UserProfile(
user = user.await(),
posts = posts.await(),
avatar = avatar.await()
)
}
data class UserProfile(
val user: String,
val posts: List<String>,
val avatar: ByteArray
)
suspend fun fetchUser(id: String): String { delay(300); return "User:$id" }
suspend fun fetchPosts(id: String): List<String> { delay(500); return listOf("Post1") }
suspend fun fetchAvatar(id: String): ByteArray { delay(200); return ByteArray(1024) }
suspend fun processAvatar(data: ByteArray): ByteArray { delay(100); return data }
loadUserProfile 함수는 async를 통해 세 개의 병렬 백그라운드 작업을 시작합니다. fetchUser와 fetchPosts는 IO 바운드(네트워크)로 Dispatchers.IO에서 실행됩니다. processAvatar는 CPU 바운드(이미지 처리)로 Dispatchers.Default에서 실행됩니다. await()는 모든 작업이 완료될 때까지 coroutine을 일시 중단합니다. 총 실행 시간은 세 작업의 최대 시간(fetchPosts의 경우 500ms)과 같으며 합계가 아닙니다. 이것이 순차 실행에 비해 coroutines의 핵심 이점입니다.
Structured concurrency — 각 coroutine이 부모 범위를 가지며, 부모 취소 시 자식 coroutine이 자동으로 취소되는 원칙입니다. Android에서 lifecycleScope는 Activity가 소멸될 때 모든 coroutine을 취소합니다. viewModelScope는 ViewModel이 정리될 때 취소합니다. 이는 백그라운드 작업 누수를 방지합니다. 사용자가 화면을 닫으면 백그라운드에 있는 coroutine이 더 이상 필요하지 않은 데이터 로드를 계속하지 않습니다.
WorkManager — 기기 재시작 또는 앱 종료 후에도 완료되어야 하는 백그라운드 작업을 실행하기 위한 Android Jetpack 라이브러리입니다. 앱 프로세스 내에서 작동하는 Executors 및 coroutine과 달리, WorkManager는 작업을 시스템 디스패처에 전달하여 적절한 조건(네트워크 가용성, 배터리 충전, 여유 공간)에서 실행을 보장합니다. WorkManager는 데이터 동기화, 로그 업로드 및 백업에 적합합니다.
WorkManager의 작업은 Worker(또는 coroutine용 CoroutineWorker)를 확장하는 클래스입니다. Worker.doWork()는 WorkManager가 제공하는 백그라운드 스레드에서 실행됩니다. 결과는 Result.success(), Result.retry() 또는 Result.failure()를 통해 반환됩니다. 작업은 체인화될 수 있습니다: oneTimeWorkRequest.andThen(nextRequest).enqueue(). WorkManager는 제약 조건(Constraints)을 고려하여 최적의 실행 시간을 선택합니다.
// Coroutines와 함께 WorkManager
import android.content.Context
import androidx.work.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class SyncWorker(
appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result {
// Dispatchers.Default에서 실행 중(기본값)
return withContext(Dispatchers.IO) {
try {
syncDataToServer()
Result.success()
} catch (e: Exception) {
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
}
private suspend fun syncDataToServer() {
// 동기화 시뮬레이션
delay(1000)
}
}
// 제약 조건으로 WorkManager 작업 시작
fun scheduleSync(context: Context) {
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresBatteryNotLow(true)
.build()
val syncWork = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 10, java.util.concurrent.TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context).enqueue(syncWork)
}
SyncWorker는 CoroutineWorker — coroutine을 지원하는 Worker 버전을 확장합니다. doWork()는 Dispatchers.Default에서 실행되며, withContext를 통해 네트워크 작업을 위해 IO로 전환됩니다. Constraints는 네트워크를 사용할 수 있고 배터리 충전이 낮은 수준 이하가 아닐 때만 동기화가 시작되도록 보장합니다. EXPONENTIAL을 사용한 BackoffCriteria는 재시도 간격을 증가시킵니다: 10, 20, 40초.
정기적 작업(15분마다 동기화, 매시간 분석 전송)을 위해 WorkManager는 PeriodicWorkRequestBuilder를 제공합니다. 최소 간격은 15분입니다. OneTimeWorkRequest와 달리 PeriodicWorkRequest는 정확한 간격 준수를 보장하지 않습니다 — 시스템이 배터리 절약을 위해 여러 정기 작업을 배치 처리할 수 있습니다. 정확한 간격이 필요한 경우 AlarmManager를 사용하되, Android 12+의 정확한 알람 제한을 고려하세요.
첫 번째 실수 — 각 작업에 대해 새 Thread를 생성하는 것입니다. new Thread().start()는 스택에 약 1MB를 할당하는 네이티브 스레드를 생성합니다. 100개의 병렬 작업의 경우 스택만 100MB이며, 컨텍스트 스위치 오버헤드가 추가됩니다. 스레드 풀(Android에서는 Executors.newFixedThreadPool(n), iOS에서는 DispatchQueue.global())을 사용하세요. 스레드를 재사용하여 오버헤드를 획기적으로 줄입니다.
두 번째 실수 — 동기화 없이 여러 백그라운드 스레드에서 변경 가능한 상태에 접근하는 것입니다. 두 개의 백그라운드 스레드가 동시에 동일한 ArrayList 또는 HashMap에 쓰면 경합 상태(race condition)가 발생합니다: Android에서는 ConcurrentModificationException, iOS에서는 데이터 손상. 해결책: 스레드 안전 컬렉션(ConcurrentHashMap, CopyOnWriteArrayList)을 사용하거나 단일 큐(DispatchQueue serial)를 통해 접근을 직렬화하세요.
세 번째 실수 — 수명 주기 관리 없이 백그라운드 작업을 실행하는 것입니다. Activity 또는 ViewModel의 수명 주기에 바인딩하지 않고 전역 범위에서 coroutine을 시작하면 누수가 발생합니다: 화면이 소멸된 후에도 작업이 계속 실행됩니다. Android에서는 lifecycleScope(Activity/Fragment) 또는 viewModelScope(ViewModel)를 사용하세요. iOS에서는 클로저에서 weak self를 사용하고 deinit 시 작업을 취소하세요.
자주 묻는 질문
Background Thread — UI와 관련 없는 작업(네트워크 요청, 파일 읽기/쓰기, JSON 파싱, 계산)이 실행되는 스레드입니다. Main Thread를 무거운 작업에서 해방시켜 인터페이스의 응답성을 유지합니다. iOS에서는 GCD(DispatchQueue.global)를 통해, Android에서는 Executors 또는 Kotlin Coroutines(Dispatchers.IO, Dispatchers.Default)를 통해 백그라운드 스레드가 관리됩니다.
Dispatchers.IO는 차단 I/O 작업(파일 읽기, 네트워크 요청, DB 작업)용으로 설계되었습니다. 64개 스레드 풀을 가집니다. Dispatchers.Default는 CPU 집약적 작업(정렬, 필터링, 이미지 처리)용입니다. 풀은 CPU 코어 수와 같습니다. I/O 작업에 Dispatchers.Default를 사용하면 모든 코어가 차단될 수 있고, CPU 작업에 Dispatchers.IO를 사용하면 과도한 스레드가 생성될 수 있습니다.
DispatchQueue.global(qos: .background).async { }는 글로벌 백그라운드 큐에 블록을 보냅니다. 백그라운드 작업 완료 후 UI 업데이트를 위해 DispatchQueue.main.async { }를 통해 메인 스레드로 돌아와야 합니다. 순차적 백그라운드 작업의 경우 maxConcurrentOperationCount = 1인 OperationQueue 또는 DispatchQueue(label: "serial")를 사용하세요.
권장 백그라운드 스레드 수는 CPU 코어 수에 IO 바운드 작업용 1을 더한 값입니다. 최신 8코어 기기에서는 9개 스레드입니다. 수백 개의 스레드를 생성하면 thread starvation이 발생합니다: OS가 작업 실행보다 컨텍스트 스위칭에 더 많은 시간을 소비합니다. iOS의 GCD와 Android의 Executors는 현재 기기에 맞게 스레드 풀을 자동 최적화합니다.
Kotlin Coroutines에서는 coroutine이 Main 범위(lifecycleScope.launch, viewModelScope.launch)에서 시작된 경우 Main Thread로의 복귀가 자동으로 이루어집니다. withContext(Dispatchers.IO) 함수는 IO 스레드에서 coroutine을 일시 중단하고, 완료 후 시작된 디스패처(보통 Main)에서 자동으로 재개합니다. DispatchQueue.main.async의 명시적 호출은 필요하지 않습니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.