Thread Pool — 미리 생성된 스레드 풀을 작업 실행에 재사용하여 스레드 생성 및 소멸 오버헤드를 방지하는 스레드 관리 메커니즘입니다. 모바일 개발에서 스레드 풀은 백그라운드 작업(네트워크 요청, 이미지 처리, 데이터베이스 작업)에 사용됩니다. Google Android 문서(2025)에 따르면 ExecutorService는 Android에서 백그라운드 스레드를 관리하는 권장 방법입니다. iOS에서는 OperationQueue와 전역 concurrent 큐를 가진 GCD DispatchQueue가 유사한 역할을 수행합니다.
핵심 포인트
Thread Pool(스레드 풀) — 일정 수의 스레드를 미리 생성하고 여러 작업 실행에 재사용하는 아키텍처 패턴입니다. 각 작업마다 새 스레드를 생성하는 대신(JVM에서 스레드당 약 1MB 스택 필요), 작업은 큐에 배치되고 풀의 사용 가능한 스레드에 의해 실행됩니다. 모바일 개발에서 스레드 풀은 성능에 중요합니다 — Android와 iOS는 애플리케이션당 스레드 수를 제한합니다.
스레드 생성은 비용이 많이 드는 작업입니다: 스택 할당, 시스템 등록, 컨텍스트 스위칭. 리소스가 제한된 모바일 기기에서 통제되지 않은 스레드 생성은 Android에서 OOM(OutOfMemoryError), iOS에서 스로틀링을 유발합니다. Thread Pool은 두 문제를 모두 해결합니다: 동시 실행 스레드의 최대 수를 제한하고 이미 생성된 스레드를 재사용합니다. Google은 raw Thread() 대신 ExecutorService를, Apple은 Thread 대신 OperationQueue를 권장합니다.
| 매개변수 | 풀 없음(raw Thread) | Thread Pool 있음 |
|---|---|---|
| 스레드 생성 | 작업마다 | 풀 생성 시 한 번 |
| 최대 스레드 | 무제한(OOM 위험) | core/max pool size로 제한 |
| 활용도 | 낮음(작업 후 스레드 소멸) | 높음(스레드 재사용) |
| 관리 | 수동(join, interrupt) | 자동(ExecutorService) |
| 메모리 소비 | 작업마다 증가 | 고정 |
스레드 풀은 Producer-Consumer 원칙으로 작동합니다: 작업(Runnable/Callable)은 블로킹 큐(BlockingQueue)에 배치됩니다. 풀의 스레드는 큐에서 작업을 기다렸다가 실행을 위해 가져갑니다. 알고리즘: 사용 가능한 스레드 수가 corePoolSize보다 적으면 새 스레드가 생성됩니다. corePoolSize에 도달하면 작업이 큐에 배치됩니다. 큐가 가득 차고 스레드 수가 maximumPoolSize보다 적으면 추가 스레드가 생성됩니다. maximumPoolSize를 초과하면 RejectedExecutionHandler를 통해 작업이 거부됩니다.
Core pool size — 유휴 상태에서도 풀에 유지되는 스레드 수. Maximum pool size — 큐 오버플로 시 생성할 수 있는 최대 스레드 수. 이 차이는 일시적으로 생성되어 유휴 타임아웃 후 종료되는 추가(overflow) 스레드입니다. 모바일 기기에서는 스레드 생성으로 인한 피크 부하를 피하기 위해 corePoolSize를 maximumPoolSize와 동일하게 설정하는 것이 좋습니다.
BlockingQueue는 실행을 기다리는 작업을 저장합니다. 가장 일반적인 구현: LinkedBlockingQueue(무제한), ArrayBlockingQueue(제한), SynchronousQueue(저장 없음 — 작업이 스레드에 직접 전달). 큐와 풀이 가득 차면 RejectedExecutionHandler가 트리거됩니다. 표준 정책: AbortPolicy(RejectedExecutionException 발생), CallerRunsPolicy(호출자 스레드에서 실행), DiscardPolicy, DiscardOldestPolicy.
// Android에서 Thread Pool 생성
val threadPool = ThreadPoolExecutor(
corePoolSize = 2, // 최소 2개 스레드
maximumPoolSize = 4, // 최대 4개 스레드
keepAliveTime = 30L, // 오버플로 스레드 수명
unit = TimeUnit.SECONDS,
workQueue = LinkedBlockingQueue<Runnable>(16),
threadFactory = Executors.defaultThreadFactory(),
handler = ThreadPoolExecutor.CallerRunsPolicy()
)
// 작업 제출
threadPool.execute {
val result = api.fetchData()
runOnUiThread { showData(result) }
}
// 풀 종료
threadPool.shutdown()
// 모든 작업 완료 대기
threadPool.awaitTermination(10, TimeUnit.SECONDS)
Android는 java.util.concurrent를 통해 여러 스레드 풀 구현을 제공합니다. Executors — 미리 만들어진 구성을 가진 팩토리: newFixedThreadPool(n)(고정 풀), newCachedThreadPool()(무제한, 필요 시 스레드 생성), newSingleThreadExecutor()(단일 스레드 — 순차 실행). 모바일 프로젝트의 경우 캐시 풀이 너무 많은 스레드를 생성할 수 있으므로 적절한 제한(2-4 스레드)이 있는 newFixedThreadPool을 권장합니다.
ThreadPoolExecutor(TPE) — 구성 가능한 매개변수를 가진 ExecutorService의 완전한 구현. Android에서 TPE는 AsyncTask, IntentService, JobIntentService 내부에서 사용됩니다. corePoolSize, maximumPoolSize, keepAliveTime, BlockingQueue, RejectedExecutionHandler 매개변수를 통해 풀 동작을 미세 조정할 수 있습니다. Android 권장 사항: corePoolSize = CPU 코어 수 - 1(IO 바운드 작업의 경우) 또는 코어 수(CPU 바운드 작업의 경우). 일반 애플리케이션의 경우: 2-4 스레드.
// Executors 미리 구성된 설정
// 1. 3개 스레드 고정 풀
val fixedPool = Executors.newFixedThreadPool(3)
// 2. 캐시 풀(모바일 비권장)
val cachedPool = Executors.newCachedThreadPool()
// 3. 단일 스레드(직렬화)
val singlePool = Executors.newSingleThreadExecutor()
// 4. 스케줄러(주기적 작업)
val scheduler = Executors.newScheduledThreadPool(2)
// Callable 및 Future 사용
val future: Future<String> = fixedPool.submit(Callable {
"Result: ${api.call()}"
})
// 결과 가져오기(스레드 차단)
val result = future.get(5, TimeUnit.SECONDS)
// 풀 종료
fixedPool.shutdownNow()
Kotlin 코루틴은 CoroutineDispatcher를 제공합니다 — 스레드 풀과 유사한 추상화입니다. Dispatchers.IO는 64개 스레드(제한)의 풀을 사용합니다. Dispatchers.Default — CPU 코어 수와 동일한 풀. CoroutineDispatcher는 수동 종료가 필요 없으며 자동으로 관리됩니다. 미세 조정을 위해 Executors.newFixedThreadPool(2).asCoroutineDispatcher()를 통해 사용자 정의 ExecutorCoroutineDispatcher를 생성하세요. 코루틴은 스레드 풀을 대체하지 않고 래핑합니다.
iOS는 스레드 풀 관리를 위한 두 가지 주요 메커니즘을 제공합니다: OperationQueue(GCD 기반 고수준 API)와 GCD DispatchQueue(저수준 C-API). OperationQueue는 maxConcurrentOperationCount 속성을 통해 스레드 풀을 캡슐화합니다. 기본적으로 OperationQueue는 시스템 정의 최대값(시스템 부하에 따라 다름)을 사용합니다. DispatchQueue.global()은 시스템 스레드 풀이 있는 concurrent 큐를 제공합니다.
OperationQueue는 maxConcurrentOperationCount를 통해 스레드 풀을 관리합니다. 값 1은 직렬 큐(단일 스레드 풀과 유사)를 생성합니다. 1보다 큰 값은 지정된 제한이 있는 concurrent 풀을 생성합니다. 기본적으로 maxConcurrentOperationCount = NSOperationQueueDefaultMaxConcurrentOperationCount(시스템 최적값, 일반적으로 4-8 스레드). Operation은 종속성, 우선순위 및 취소를 지원합니다. 각 작업은 시스템 풀의 사용 가능한 스레드에서 실행됩니다.
// 3개 스레드 풀의 OperationQueue
let queue = OperationQueue()
queue.maxConcurrentOperationCount = 3
queue.qualityOfService = .utility
// 작업 생성
let operation1 = BlockOperation {
let data = fetchData(from: url1)
DispatchQueue.main.async { updateUI(data) }
}
let operation2 = BlockOperation {
let data = fetchData(from: url2)
DispatchQueue.main.async { updateUI(data) }
}
// 종속성: operation2가 operation1을 기다림
operation2.addDependency(operation1)
// 큐에 추가
queue.addOperations([operation1, operation2], waitUntilFinished: false)
// 모든 작업 취소
queue.cancelAllOperations()
DispatchQueue — Apple의 스레드 풀입니다. concurrent 큐(qos: .utility)는 현재 기기 부하에 최적화된 시스템 스레드 풀을 사용합니다. 서로 다른 QoS 레벨(userInteractive, userInitiated, utility, background)은 서로 다른 우선순위를 가진 서로 다른 풀에 매핑됩니다. DispatchGroup은 여러 작업을 동기화할 수 있습니다. DispatchWorkItem은 취소와 qualityOfService를 지원합니다. 세밀한 제어를 위해 DispatchQueue(label: qos: attributes: .concurrent)를 통해 사용자 정의 concurrent 큐를 생성하세요.
// 스레드 풀로서의 GCD DispatchQueue
let customQueue = DispatchQueue(
label: "com.app.background",
qos: .utility,
attributes: .concurrent,
autoreleaseFrequency: .workItem
)
// 풀에 작업 제출
customQueue.async { self.processFile(file1) }
customQueue.async { self.processFile(file2) }
// 동기화를 위한 DispatchGroup
let group = DispatchGroup()
let pool = DispatchQueue.global(qos: .utility)
pool.async(group: group) { fetchData() }
pool.async(group: group) { processImage() }
group.notify(queue: .main) {
self.showResult() // 두 작업 모두 완료
}
// 세마포어를 통한 동시성 제한
let semaphore = DispatchSemaphore(value: 3)
for url in urls {
pool.async {
semaphore.wait()
download(url)
semaphore.signal()
}
}
스레드 풀 구성은 애플리케이션 성능에 직접적인 영향을 미칩니다. 잘못된 매개변수는 CPU 과소 활용(스레드太少) 또는 시스템 과부하(스레드太多)를 초래합니다. 모바일 애플리케이션의 경우 제한된 리소스와 전력 소비로 인해 최적값이 서버 측과 다릅니다. 주요 매개변수: corePoolSize, maxPoolSize, 큐 용량, keepAliveTime.
IO 바운드 작업 공식: corePoolSize = CPU 코어 수 × 2(스레드가 I/O 대기). CPU 바운드 작업의 경우: corePoolSize = CPU 코어 수(스레드가 계산으로 계속 busy). 최신 모바일 기기(6-8코어)에서는 CPU 바운드에 6-8 스레드, IO 바운드에 12-16 스레드입니다. 실제 테스트에 따르면 일반 모바일 애플리케이션에는 3-4 스레드가 최적입니다 — 더 많은 스레드는 성능 향상 없이 전력 소비만 증가시킵니다.
워크 큐 크기는 실행을 기다릴 수 있는 작업 수를 결정합니다. 무제한 큐(제한 없는 LinkedBlockingQueue)는 빠른 작업 도착 시 OOM을 유발할 수 있습니다. 제한 큐(고정 크기 ArrayBlockingQueue)는 가득 차면 작업을 거부합니다. 모바일 애플리케이션에는 16-32 작업 용량의 ArrayBlockingQueue를 권장합니다. CallerRunsPolicy는 모바일에 가장 적합한 RejectedExecutionHandler입니다: 작업을 잃는 대신 호출자를 느리게 합니다(백프레셔).
| 매개변수 | 모바일 권장값 | 이유 |
|---|---|---|
| corePoolSize | 2-4 | 모바일 기기의 제한된 리소스 |
| maxPoolSize | corePoolSize(또는 +1-2) | 스레드 생성으로 인한 피크 부하 방지 |
| keepAliveTime | 15-30초 | 빈번한 생성 없이 빠른 메모리 해제 |
| 큐 용량 | 16-32 | 버퍼링과 OOM 위험 사이의 균형 |
| 핸들러 | CallerRunsPolicy | 작업 손실 없는 백프레셔 |
모바일 애플리케이션 개발자는 스레드 풀 사용 시 크래시, 메모리 누수, 불안정한 작동의 원인이 되는 실수를 자주 범합니다. 가장 일반적인 것: ExecutorService에서 shutdown() 미호출, 작업마다 새 풀 생성, 너무 큰 풀, 작업 간 데드락, Android에서 CachedThreadPool 사용.
데드락은 풀의 한 작업이 같은 풀의 다른 작업 결과를 기다리지만 모든 스레드가 대기 중일 때 발생합니다. 예: 작업 A가 같은 풀에 작업 B를 보내고 future.get()을 호출 — 풀이 소진되면 작업 A는 작업 B를 기다리고, 작업 B는 사용 가능한 스레드가 없어 실행할 수 없습니다. 해결책: 다른 수준의 작업에 별도의 풀을 사용하거나 blocking .get() 대신 비동기 콜백을 사용하세요.
// Thread Pool의 데드락
val pool = Executors.newFixedThreadPool(1)
// 작업 A가 작업 B를 기다림 — 데드락!
val futureA = pool.submit {
// 이 작업은 절대 실행되지 않음
val futureB = pool.submit { 42 }
futureB.get() // 영원히 차단
}
// 해결책: 별도 풀
val workerPool = Executors.newFixedThreadPool(2)
val callbackPool = Executors.newSingleThreadExecutor()
workerPool.submit {
callbackPool.submit {
// 별도 풀에서 실행 — 데드락 불가능
}
}
// 또는 CompletableFuture 사용
workerPool.submit {
CompletableFuture
.supplyAsync { 42 }
.thenAccept { result ->
println(result)
}
}
Activity에서 생성된 ExecutorService는 onDestroy()에서 종료되어야 합니다. 그렇지 않으면 Activity가 소멸된 후에도 스레드가 메모리에 남아 있습니다. 해결책: Activity 대신 Application 범위나 ViewModel에 풀을 유지하세요. 코루틴의 경우 viewModelScope나 lifecycleScope를 사용하세요. Activity 내에서 풀이 생성된 경우 onDestroy()에서 pool.shutdown()을 반드시 호출하세요. 테스트의 경우 즉시 중지를 위해 es.shutdownNow()를 사용하세요.
자주 묻는 질문
Thread Pool은 이미 생성된 스레드를 재사용하여 여러 작업을 실행합니다. 일반 스레드(raw Thread)는 생성되어 하나의 작업을 실행하고 소멸됩니다. 스레드 생성에는 약 1MB 메모리와 ~1ms 시간이 필요합니다. Thread Pool은 오버헤드를 줄이고 최대 스레드 수를 제한하며 관리 API(shutdown, awaitTermination)를 제공합니다.
일반 모바일 애플리케이션에는 2-4 스레드가 최적입니다. CPU 바운드 작업의 경우 CPU 코어 수, IO 바운드 작업의 경우 코어 수 × 2. 더 많은 스레드는 성능 향상 없이 전력 소비와 컨텍스트 스위칭만 증가시킵니다. Android에서는 Process.availableProcessors(), iOS에서는 ProcessInfo.processInfo.processorCount를 사용하여 코어 수를 확인하세요.
CachedThreadPool은 필요에 따라 스레드를 생성하고 기존 스레드를 재사용합니다. 문제: 최대 스레드 수를 제한하지 않습니다. 100개의 작업이 동시에 도착하면 100개의 스레드가 생성됩니다. 이는 Android에서 OOM을 유발합니다(각 스레드 약 1MB). 명시적 제한이 있는 newFixedThreadPool(n)을 사용하세요. CachedThreadPool은 소량이 보장된 단기 버스트 작업에만 허용됩니다.
예, 풀이 관리 컨테이너(코루틴 등)에 속하지 않은 경우. shutdown()은 새 작업 수락을 중단하고 현재 작업 완료 후 스레드를 종료합니다. shutdown()이 없으면 스레드가 메모리에 남아 애플리케이션이 종료되지 않습니다. Activity의 경우 onDestroy()에서 호출하세요. ViewModel의 경우 coroutineScope를 사용하세요. 풀 종료는 Cursor나 InputStream 닫기와 마찬가지로 리소스 관리의 필수 부분입니다.
예, OperationQueue와 DispatchQueue는 iOS가 제공하는 스레드 풀입니다. OperationQueue는 maxConcurrentOperationCount로 동시성을 제한합니다. DispatchQueue.global()은 직접 제어 없이 시스템 스레드 풀을 사용합니다. Java의 ThreadPoolExecutor와 달리 corePoolSize나 큐 용량을 관리하지 않습니다 — 시스템이 현재 부하와 기기 전력 소비에 따라 풀을 자동 최적화합니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.