[Android] Thread간 통신 (using Handler & Looper)

부나·2023년 10월 15일
9

안드로이드

목록 보기
5/12
post-thumbnail

Thread란?

Thread 는 한 Process 내에서 실행되는 작업의 흐름이다.
Thread는 각각의 Stack 영역이 존재하며, 그 외의 메모리를 공유할 수 있다.

그러나! 이렇게 설명하면 당연히 이해가 어려울 것이다.
아래 상황으로 함께 이해해보자.

만약 10초가 걸리는 복잡한 작업을 해야 한다고 가정해보자.

연산이 모두 완료되기 전까지 사용자가 다른 작업을 하지 못하고 기다려야 한다면?

분명 사용자는 불편함을 느낄 것이다.
이를 동기적 방식이라고 하며, 그림 (a) 처럼 뒤에 있는 누군가는 시계를 보며 초조하게 기다려야만 할 것이다.
이것이 Thread가 존재해야 하는 이유이다.

그렇기 때문에, 하나의 Process에는 여러개의 Thread가 존재 할 수 있고, CPU는 Context Switching 을 통해 일정 시간마다 Thread를 번갈아가며 실행시킨다. (Time Sharing)

10초 연산 완전히 이루어질 때까지 기다릴 필요가 없다면, 그동안 다른 작업을 번갈아가며 해도 문제가 되지 않는다.
ex) 클라이언트가 서버와 통신하는 사이에 다른 Thread는 화면을 그린다.

정리하자면, 동기적인 작업을 비동기적으로 수행하기 위한 용도이다.

  • 동기 : 작업1이 완료된 후 작업2가 실행되는 식으로 순차적으로 작업을 수행하는 방식
  • 비동기 : 작업1이 완료되지 않았더라도 작업2가 실행될 수 있는 동시성을 가지는 방식

Android의 Thread

안드로이드에는 크게 2가지 종류의 Thread가 존재한다.
1. Main Thread(= Ui Thread)
2. Worker Thread(= Background Thread)

Main Thread

안드로이드에는 특별하게 Main Thread(= Ui Thread) 라는 개념이 있다.
Main Thread는 UI 관련 동작 을 다룬다.

가령, 아래와 같은 작업이 UI와 관련된 작업이다.

  • TextView 의 문자를 변경한다.
  • ImageView 에 새로운 이미지를 보여준다.
  • Button 의 색상을 변경한다.

만약 Main Thread가 아닌 다른 Thread에서 UI 로직을 수행하려고 하면 예외가 발생한다.
그 이유는 UI의 무결성 을 유지하기 위해서이다.

아래와 같은 상황을 살펴보자.

뒤에서 다룰 Worker Thread와 Main Thread 모두 UI 관련 작업이 가능했을 때를 가정한 사진이다.
Main Thread 는 텍스트를 "main"으로 변경하고 있다.
반면, Worker Thread 는 텍스트를 "worker"로 변경하고 있다.

결과적으로 textView는 화면에 어떤 텍스트를 띄울까?

고개를 갸우뚱거렸다면 이미 정답을 알고 있는 것과 같다.
CPU 스케쥴링 순서에 따라 둘 중 어떤 것이 화면에 보일지 개발자는 결과를 예측할 수 없다.

그렇기 때문에 UI 관련 작업을 하나의 (Main) Thread에서만 가능하도록 하여 순서와 결과를 보장하는 것이다.

Main Thread 주의사항!

Main Thread는 화면에 보여주는 작업을 처리해야 하기 때문에, 긴 작업을 처리할 수 없다.
시간이 오래 걸리는 작업을 Main Thread에서 처리하려고 하면 사용자는 심한 버벅거림을 느끼게 된다.
다시 말해, 사용자 입출력에 대한 반응이 느려진다.

아래 예제 코드를 통해 이해해보자.

 override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_main2)

    button.setOnClickListener {
	    Log.d("buna", "Clicked!")
        Thread.sleep(6000)
        Log.d("buna", "${Thread.currentThread().name} : this will be printed.")
    }
}

별도의 Thread를 생성하지 않는다면 기본적으로 Main Thread에서 실행된다.
button을 두 번 누르면 Main Thread는 6초간 Block 되어 화면이 멈추고 사용자는 어떠한 작업도 할 수 없게 된다.
대략 5초가 지나면 아래와 같은 크래시가 발생한다.

이러한 크래시를 ANR(Application Not Responding) 라고 한다.

한 번만 누르면 크래시가 발생하지 않는다.

그 이유는 사용자의 첫 번째 터치 또는 키 이벤트를 정상적으로 전달은 하였기 때문이다.
하지만 다시 버튼을 클릭한다면 사용자의 두 번째 터치 이벤트는 지연되기 때문에 ANR이 발생하게 된다.
위 예제 코드에서 Clicked! 라는 Log가 한 번만 출력되는 것도 터치 이벤트가 지연되었기 때문이다.

ANR 발생 기준

  • Activity : 5초
  • Service, BroadcastReceiver : 10초

이 글을 읽었다면, 이제부터는 Network, Database 접근과 같이 긴 작업은 Main Thread에서 수행하지 않을 것이다.

Worker Thread

Worker Thread 는 Background Thread 라고도 불린다.

Main Thread가 아닌 Thread를 Worker Thread 라고 부르며,
시간이 오래 걸리는 복잡한 연산이나 네트워크 통신, 데이터베이스 접근 등의 작업은 Worker Thread에서 수행해야 한다.

UI 작업은 Main Thread에서만 가능한데?

그렇다면 복잡한 작업을 마친 후에, 그 결과를 어떻게 UI에 나타낼 수 있을까?

워커 스레드에서 메인 스레드에 접근하는 방법

Worker Thread에서 시간이 오래 걸리는 작업을 처리한 후, 그 결과를 화면에 보여주기 위해서는 결국 Main Thread가 필요하다.
정확히는 이후에 다룰 Main Thread의 LooperMessageRunnable 을 전달해주어야 한다.

Android에서는 이런 작업을 편리하게 사용할 수 있도록 아래 3가지 메서드를 제공한다.

Activity.runOnUiThread { // UI 처리 }
View.post { // UI 처리 }
View.postDelayed( { // UI 처리 }, delayMillis)

runOnUiThread

thread(name = "WorkerThread") {
    Thread.sleep(10000L) // 10초가 소요되는 작업을 Worker Thread에서 수행
    val result = 10

    runOnUiThread { // Ui Thread 에서 작업
    	textView.text = result.toString()
	}
}

10초가 소요되는 작업 을 Worker Thread에서 수행한 뒤, 그 결과를 TextView에 나타내는 작업이다.
runOnUiThread의 내부 코드를 살펴보면 다음과 같다.

// Activity.java

public final void runOnUiThread(Runnable action) {
    if (Thread.currentThread() != mUiThread) {
        mHandler.post(action);
    } else {
        action.run();
    }
}

runOnUiThread() 는 Runnable로 수행할 작업을 인자로 전달받는다.
만약 Thread가 UiThread 라면 바로 Runnable.run() 하고,
Worker Thread 라면 Activity의 Handler에 작업을 전달한다. (자세한 원리는 Looper와 Handler 섹션에 설명합니다.)

runOnUiThread()는 Activity의 메서드이기 때문에 Activity 인스턴스를 참조할 수 있는 경우에만 사용 가능하다.

View.post

만약 View만 접근이 가능한 상황이라면 View.post() 메서드가 제격이다.
다음 코드를 살펴보자.

thread {
	Thread.sleep(10000L) // 10초가 소요되는 작업
	val result = 10

	textView.post {
		textView.text = result.toString()
	}
}

이전 코드와 동일한 기능을 수행하지만, View만으로도 Ui Thread로 작업을 전달할 수 있다.
View.post() 의 내부 코드는 다음과 같다.

// View.java

public boolean post(Runnable action) {
	final AttachInfo attachInfo = mAttachInfo;
	if (attachInfo != null) {
		return attachInfo.mHandler.post(action);
	}

	// Postpone the runnable until we know on which thread it needs to run.
	// Assume that the runnable will be successfully placed after attach.
	getRunQueue().post(action);
	return true;
}

runOnUiThread() 처럼 Runnable 객체를 전달받고, 다시 handler에게 작업을 전달한다.
여기서 AttachInfo는 View가 화면에 Attach될 때의 정보를 가지고 있다.

AttachInfo는 뷰가 부모 Window에 Attach될 때 뷰에 제공되는 정보 집합입니다.

View.postDelayed

만약 Ui 작업을 특정 시간 이후에 처리하고 싶다면 View.postDelayed() 를 사용할 수 있다.

thread {
	Thread.sleep(10000L) // 10초가 소요되는 작업
	val result = 10

	textView.postDelayed({
		textView.text = result.toString()
	}, 1000L)
}

UI 작업을 1초 뒤에 처리한다는 것 외에는 이전 코드와 동일하다.

Looper와 Handler

지금까지 Worker Thread에서 Main Thread로 작업을 넘기는 3가지 방법을 알아보았다.
이제는 이러한 과정이 어떻게 이루지는지 알 필요가 있다.

Thread에는 중요한 2가지 클래스가 있다.

  • Looper
  • Handler

Looper

Looper는 이름 그대로 Loop(반복)를 수행하는 담당하는 클래스이다.
Thread는 반드시 하나의 Looper를 가지고 있어야 한다.

Looper는 무한 반복을 하면서 MessageQueue에 대기 중인 어떠한 작업(이하 Runnable) 이나 메시지(이하 Message) 를 Handler에게 전달하는 역할을 한다.

Message 객체는 Handler.handleMessage(Message) 가 호출되어 작업을 처리한다.
반면, Runnable 객체는 단순히 run() 메서드를 호출하기만 한다.

// Looper.java

private Looper(boolean quitAllowed) {
    mQueue = new MessageQueue(quitAllowed);
    mThread = Thread.currentThread();
}

Looper는 자체적으로 MessageQueue를 생성하고 관리한다.
Queue 자료구조를 사용하는만큼 전달되는 작업들을 순차적으로 처리한다.

Handler

Handler 는 Looper의 MessageQueue에 Message를 전달받거나, 전달한다.

Message를 전달받는 경우

이전에 설명한 바와 같이, Looper의 MessageQueue에 대기 중인 Message를 Handler에 전달한다.
Handler 객체를 생성할 때 handleMessage(Message) 메서드를 가지는 Callback을 정의할 수 있다.
Looper는 Handler의 handleMessage()를 통해 Message를 전달할 수 있다.

Message를 전달하는 경우

반대로, Handler가 Looper의 MessageQueue에 Message나 Runnable을 전달할 수도 있다.
Handler의 sendMessage(Message), post(Runnable) 를 사용하면 된다.

Handler는 특정 작업 을 MainThread의 Looper가 가진 MessageQueue에 추가 한다.
그러면 MessageQueue에서 대기하고 있다가 자신의 차례가 되면, Looper는 다시 Handler에게 작업을 전달한다.

즉, Handler와 Looper가 Message를 서로 주고 받는 구조이다.

Handler를 통해 Thread 간의 통신

왜 메시지를 주고 받아야 할까?

이제 슬슬 위에서 살펴보았던 runOnUiThread()나 View.post() 메서드를 다시 살펴볼 시간이다.

// Activity.java

public final void runOnUiThread(Runnable action) {
    if (Thread.currentThread() != mUiThread) {
        mHandler.post(action);
    } else {
        action.run();
    }
}

runOnUiThread() 메서드는 Worker Thread에서 어떠한 작업을 실행하다가 Ui 관련 작업을 전달해주기 위해 사용하였다.
내부적으로 mHandler.post() 를 호출하고 있다는 사실을 기억하자.

mHandler는 Main Thread에서 동작하고, 작업을 Main Thread의 Looper로 전달 하여 줄세우는 일을 수행한다.
그리고 Looper는 Message를 처리해야 할 순서가 되면 다시 Handler에게 작업을 전달하여 처리 한다.

이러한 원리로 다른 Thread에서도 Handler를 통해 Main Thread Looper의 MessageQueue에 작업을 전달 하는 것이 가능하다.

이전에 언급한 3가지 메서드를 사용하지 않고, 직접 Handler를 정의하여 다룰 수도 있다.

class MainActivity : AppCompatActivity() {
    private val textView by lazy { findViewById<TextView>(R.id.textView) }
    private val mHandler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main2)

        thread(name = "WorkerThread") {
            Thread.sleep(3000)

            mHandler.post {
                textView.text = "Hello World!"
            }
        }
    }
}

우선, Handler에 명시적으로 MainLooper를 전달해준다.
이렇게 하면 Handler의 post(), sendMessage() 를 호출했을 때, MainLooper의 MessageQueue에 작업이 전달된다.

class MainActivity : AppCompatActivity() {
    private val textView by lazy { findViewById<TextView>(R.id.textView) }

    private val mHandler = Handler(Looper.getMainLooper()) { msg ->
        textView.text = msg.obj.toString()
        true
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main2)

        thread(name = "WorkerThread") {
            Thread.sleep(3000)

            val msg = mHandler.obtainMessage().apply {
                obj = "Hello"
            }
            mHandler.sendMessage(msg)
        }
    }
}

당연히 Message 또한 전달이 가능하다.
Handler에게 sendMessage(Message) 하고, 자신의 차례가 되었을 때 Message를 전달받아 텍스트뷰를 갱신하고 있다.


글을 마무리하며

Thread와 Handler, 그리고 Looper 등 다양한 개념들을 살펴보았다.
쉬운 개념은 아니라고 생각하기 때문에, 위 개념들을 처음 듣거나 아직 생소하게 느껴진다면 글이 조금은 어려울 것이다.
만약 바로 이해가 되지 않는다면 댓글을 달거나, 여러 번 글을 정독해 보아야 할 것이다.

비동기 처리, Thread는 Android 개발을 하면서 중요한 개념이고, Coroutine 또한 결국엔 Thread에 대한 지식이 있어야 하기 때문에 위 개념들은 매우 중요하다고 생각한다.

profile
망각을 두려워하는 안드로이드 개발자입니다 🧤

2개의 댓글

comment-user-thumbnail
2023년 10월 15일

Handler랑 Looper 개념을 잘 풀어주셨네요!
글 잘 읽고 갑니다~

답글 달기
comment-user-thumbnail
2023년 10월 15일

그림이랑 설명이랑 같이 읽으니까 잘 이해가 되는 것 같아요~

답글 달기