
안드로이드 앱을 개발하다 보면 UI를 블로킹하지 않으면서도 백그라운드 작업을 처리해야 하는 상황을 자주 마주친다. 네트워크 요청을 하거나 무거운 계산을 수행한 후 그 결과를 UI에 반영해야 할 때, 어떻게 하면 안전하고 효율적으로 스레드 간 통신을 할 수 있을까?
안드로이드는 이런 문제를 해결하기 위해 Looper, Handler, HandlerThread라는 메시지 기반 스레딩 시스템을 제공한다. 이 글에서는 각 컴포넌트의 역할과 상호관계, 그리고 실무에서 어떻게 활용할 수 있는지 살펴보겠다.
Looper는 스레드에서 메시지 큐를 관리하고 이를 순차적으로 처리하는 무한 루프를 담당한다. 안드로이드에서 스레드 간 통신의 기반이 되는 핵심 요소로, 각 스레드는 최대 하나의 Looper만 가질 수 있다.
메인 스레드의 경우 시스템이 자동으로 Looper를 생성하고 시작하기 때문에 별도의 준비 과정이 필요 없다. 하지만 새로 생성한 백그라운드 스레드에서 Handler를 사용하려면 반드시 Looper.prepare()를 호출해야 한다.
Looper는 내부적으로 MessageQueue를 가지고 있으며, 이 큐에서 메시지나 Runnable을 하나씩 꺼내어 처리한다. prepare() 메서드로 현재 스레드에 Looper를 준비하고, loop() 메서드로 무한 루프를 시작한다. 이 루프는 quit()이나 quitSafely()가 호출되기 전까지 계속 실행되며, loop()는 blocking 메서드로 quit()이 호출되기 전까지 현재 스레드를 점유한다.
// 백그라운드 스레드에서 Looper 생성
class CustomThread : Thread() {
override fun run() {
Looper.prepare()
val handler = Handler(Looper.myLooper()!!) { message ->
// 메시지 처리 로직
true
}
Looper.loop() // 무한 루프 시작
}
}
주로 백그라운드 스레드에서 순차적인 작업 처리가 필요할 때 사용한다. 예를 들어 데이터베이스 작업이나 파일 I/O 같은 작업들을 순서대로 처리해야 할 때 Looper를 활용한다.
Handler는 Looper의 MessageQueue에 메시지나 Runnable을 삽입하고, 이를 처리하는 역할을 담당한다. 스레드 간 통신의 실질적인 인터페이스 역할을 하며, 메시지 전송과 처리를 모두 담당한다.
Handler는 생성 시점에 특정 Looper와 연결된다. 이후 post(), postDelayed(), sendMessage() 등의 메서드를 통해 연결된 Looper의 MessageQueue에 작업을 추가한다. 메시지가 처리될 때는 handleMessage() 메서드나 생성 시 전달한 콜백이 호출된다.
Handler를 통한 스레드 간 통신에서 데이터는 Message 객체나 Runnable을 통해 전달된다. Message 객체는 what, arg1, arg2, obj 필드를 통해 다양한 형태의 데이터를 전달할 수 있고, Runnable은 클로저를 활용해 외부 변수를 캡처할 수 있다.
class DataProcessor {
private val mainHandler = Handler(Looper.getMainLooper())
fun processInBackground(data: String) {
Thread {
val result = heavyProcessing(data)
// UI 스레드에서 결과 처리
mainHandler.post {
updateUI(result)
}
}.start()
}
// 지연된 작업 실행
fun scheduleDelayedTask() {
mainHandler.postDelayed({
performDelayedAction()
}, 3000)
}
}
가장 일반적인 사용 사례는 백그라운드 스레드에서 작업한 결과를 UI 스레드로 전달하는 것이다. 또한 주기적인 작업 실행, 지연된 작업 실행, 스레드 간 데이터 전달 등 다양한 상황에서 활용된다.
HandlerThread는 Looper가 준비된 백그라운드 스레드를 쉽게 생성할 수 있게 해주는 Thread의 서브클래스다. 복잡한 Looper 준비 과정을 내부적으로 처리해주어 개발자가 간편하게 메시지 기반 백그라운드 스레드를 사용할 수 있게 한다.
수동으로 Looper가 있는 백그라운드 스레드를 만들려면 여러 단계의 작업이 필요하다. Looper.prepare()를 호출하고, Handler 생성을 위한 동기화를 처리하고, Looper.loop()를 시작하고, 적절한 시점에 정리 작업을 해야 한다. HandlerThread는 이 모든 과정을 내부적으로 처리해준다.
class ImageProcessor {
private val processingThread = HandlerThread("ImageProcessor")
private lateinit var processingHandler: Handler
private val mainHandler = Handler(Looper.getMainLooper())
fun initialize() {
processingThread.start()
processingHandler = Handler(processingThread.looper)
}
fun processImage(bitmap: Bitmap, callback: (Bitmap) -> Unit) {
processingHandler.post {
val processedBitmap = applyFilters(bitmap)
mainHandler.post {
callback(processedBitmap)
}
}
}
fun cleanup() {
processingThread.quitSafely()
}
}
HandlerThread의 가장 큰 장점은 단순함이다. 개발자는 복잡한 Looper 설정 과정을 신경 쓸 필요 없이 백그라운드에서 순차적으로 작업을 처리할 수 있는 스레드를 얻을 수 있다. 파일 다운로드, 이미지 처리, 데이터베이스 작업 등 시간이 오래 걸리는 작업을 백그라운드에서 순차적으로 처리할 때 매우 유용하다.
이 세 컴포넌트는 명확한 계층 구조를 가진다. 가장 하위에는 Looper가 있어 메시지 큐와 처리 루프를 제공한다. 그 위에 Handler가 있어 Looper에 메시지를 전달하고 처리하는 인터페이스 역할을 한다. 가장 상위에는 HandlerThread가 있어 Looper가 준비된 스레드를 쉽게 생성할 수 있게 해준다.
Handler는 반드시 Looper에 의존해야 하며, Looper 없이는 동작할 수 없다. HandlerThread는 Thread와 Looper를 결합한 편의 클래스로, 복잡한 설정 과정을 숨겨준다.
각 컴포넌트는 명확히 구분된 책임을 가진다. Looper는 메시지 큐 관리와 순차 처리를 담당하고, Handler는 메시지 전송과 처리 로직을 담당하며, HandlerThread는 백그라운드 스레드에서의 Looper 생명주기 관리를 담당한다.
Handler와 Looper는 Producer-Consumer 패턴으로 동작한다. Handler는 메시지를 생산하는 Producer 역할을, Looper는 이를 소비하는 Consumer 역할을 담당한다. 이 과정에서 MessageQueue가 중간 버퍼 역할을 한다.
Handler가 메시지를 전송하면, 이 메시지는 Handler와 연결된 Looper의 MessageQueue에 삽입된다. Looper는 무한 루프를 돌면서 이 큐에서 메시지를 하나씩 꺼내어 해당 Handler의 처리 메서드로 전달한다.
Handler의 가장 기본적인 사용사례는 백그라운드 작업의 결과를 UI에 반영하는 것이다. 안드로이드에서는 메인 스레드가 아닌 곳에서 UI를 직접 수정할 수 없기 때문에 Handler를 통해 메인 스레드로 전환해야 한다.
주기적인 작업 실행도 Handler의 중요한 활용 분야다. postDelayed()를 활용하여 주기적인 작업을 구현할 수 있으며, 이는 Timer보다 안드로이드 환경에 더 적합하고 생명주기 관리가 용이하다.
사용자 입력 디바운싱도 실무에서 자주 사용되는 패턴이다. 사용자가 빠르게 연속으로 입력할 때 마지막 입력만 처리하도록 하는 디바운싱 패턴에 Handler를 활용할 수 있다.
여러 단계의 백그라운드 작업을 순차적으로 처리해야 할 때는 HandlerThread를 활용한 파이프라인을 구성할 수 있다. 예를 들어 이미지 다운로드 → 처리 → 캐시 저장 → UI 업데이트와 같은 복잡한 워크플로우를 각각의 HandlerThread로 분리하여 관리할 수 있다.
가장 중요한 것은 적절한 정리 작업이다. Handler는 강한 참조를 유지할 수 있어 메모리 누수의 원인이 될 수 있으므로, WeakReference를 사용하거나 생명주기에 맞춰 removeCallbacks()를 호출해야 한다. HandlerThread는 반드시 quit()을 호출하여 정리해야 한다.
최근 안드로이드 개발에서는 코루틴이 널리 사용되면서 Handler/Looper의 활용도가 많이 줄어들었다. 코루틴은 대부분의 비동기 작업과 스레드 간 통신을 더 간단하고 안전하게 처리할 수 있게 해준다.
특히 UI 업데이트, 네트워크 요청, 데이터베이스 작업 등 일반적인 앱 개발 시나리오에서는 Handler/Looper보다 코루틴이 훨씬 효율적이다. 코드도 더 직관적이고 테스트하기도 쉽다.
하지만 Handler/Looper가 완전히 불필요해진 것은 아니다. 레거시 라이브러리와의 연동, 매우 정밀한 타이밍 제어, 시스템 레벨 통신, 네이티브 코드와의 상호작용 등에서는 여전히 필요할 수 있다.
실제 프로젝트에서는 코루틴을 주요 비동기 처리 수단으로 사용하되, 특수한 경우에 Handler를 보조적으로 활용하는 것이 현재의 모범 사례라고 할 수 있다.
Looper, Handler, HandlerThread는 안드로이드의 메시지 기반 스레딩 시스템의 핵심이다. 각각의 역할을 명확히 이해하고 적절히 활용하면 효율적이고 안전한 비동기 프로그래밍을 할 수 있다.
코루틴이 널리 사용되는 현재에도 이 개념들을 이해하는 것은 중요하다. 안드로이드 시스템의 동작 원리를 깊이 이해할 수 있고, 레거시 코드를 유지보수하거나 특수한 상황에서 적절한 도구를 선택할 수 있기 때문이다.
핵심은 각 도구의 장단점을 이해하고 상황에 맞는 최적의 선택을 하는 것이다. UI 스레드를 블로킹하지 않으면서도 사용자에게 부드러운 경험을 제공하는 것이 우리의 궁극적인 목표라는 점을 잊지 말자.