안드로이드를 개발해본 사람들이라면 다들 알다시피, 안드로이드의 UI 처리는 싱글 쓰레드 모델로 동작한다. 즉, 메인 쓰레드가 아닌 다른 쓰레드에서 UI 를 업데이트하는 등의 행위를 하면 안된다. 따라서 메인 쓰레드를 UI 쓰레드라고 부르기도 한다.
이유는 간단하면서도 당연하다. 멀티 쓰레드 환경이라고 가정했을 때, 여러 쓰레드에서 TextView
의 텍스트를 변경하는 상황이 발생하면 어떤 결과가 나타날 지 미지수이기 때문이다. 따라서 동작의 무결성을 보장하기 위해 타 쓰레드에서는 UI 를 건드릴 수 없고, 오로지 메인 쓰레드에서만 UI 관련 동작을 할 수 있게끔 하는 것이다.
1. 메인 쓰레드 (UI 스레드) 를 블로킹해서는 안 됨
→ 메인 쓰레드를 블로킹한다는 뜻은, 사용자에게 보여지는 UI 동작을 멈춘다는 뜻이다. 메인 쓰레드가 블로킹되어 UI 동작이 멈추게 되면, 이는 표면적으로 앱 퍼포먼스 저하를 유발하게 되며 사용자 경험상 악영향을 끼친다. 따라서, 시간이 오래 걸리는 동작을 수행하는 등 메인 쓰레드를 블로킹해선 안 된다.
2. UI 관련 동작은 오로지 메인 쓰레드에서만 접근해야 함
→ 이유는 위에서 설명했다. UI 동작의 무결성을 보장하기 위함이다.
즉, 무거운 동작들은 메인 쓰레드가 아닌 다른 쓰레드를 생성하여 수행해야 한다. 그런데, 어차피 쓰레드를 별도로 생성하여 시간이 오래 걸리는 동작들을 한다고 해도, 그 동작의 '결과' 는 보통 UI 를 업데이트하는 데에 사용된다.
예를 들어 쓰레드를 새로 생성하여, 고양이 사진을 제공해주는 서버의 API 를 호출하여 고양이 사진을 받은 뒤
ImageView
에 보여주는 동작을 한다고 하자. 그런데 아까 별도의 쓰레드에선 UI 관련 동작을 해서는 안 된다고 했다. 그럼 결과로 받은 고양이 사진을 어떻게 사용자에게 보여줄 수 있을까?
가장 먼저 떠오르는 방법은, 다른 쓰레드에서 메인 쓰레드로 결과를 전송하는 방식이다. 즉, 쓰레드간의 통신을 구현하는 것이다.
안드로이드에선 쓰레드간의 통신을 위해, Looper
와 Handler
라는 장치를 제공해준다. 이 녀석들을 활용하여 효율적으로 멀티 쓰레딩 환경을 구축할 수 있다. 이번 포스팅에선 이 녀석들을 알아보고자 한다.
하나의 쓰레드에는 오직 하나의 Looper
를 가지며, Looper
는 오직 하나의 쓰레드를 담당한다. 안드로이드에선 기본적으로 MainActivity
가 실행됨과 동시에 자동으로 메인 쓰레드의 Looper
가 돌기 시작한다.
각 쓰레드의 Looper
내부에는 MessageQueue 라는 것이 존재하는데, 여기에는 해당 쓰레드가 처리해야 할 동작들이 '메세지' 라는 형태로 하나씩 쌓이게 된다. (큐 라는 이름에서 알 수 있듯 당연히 FIFO 방식이다)
Looper
는 궁극적으로, MessageQueue 에 들어오는 메세지들을 하나씩 꺼내어 이를 적절한 Handler
로 전달하는 역할을 한다.
기본적으로
Looper
는 자신이 어떤Handler
에 메세지를 전달해야 하는지에 대한 참조를 갖고 있다. (기본값 : 메인 쓰레드의Handler
)
물론 MessageQueue 가 비어있을 땐 아무 동작을 수행하지 않는다. 무한 루프를 돌면서 큐에 쌓여있는 메세지를 Handler
에 전달해주는 동작 특성 상 Looper 라는 이름이 제격이다.
특히, 메인 쓰레드의 Looper
는 보통 UI 작업을 위한 메세지를 처리하게 된다.
메세지는 또 뭔데 아 ㅋㅋ
Message
는 '하나의 작은 작업 단위' 라고 생각하면 편하다. MessageQueue 에는 이러한 작은 작업 단위를 하나씩 적재해두고,Looper
가 이를 차례대로 처리하는 것이다.
Message
객체는 내용물이 두 가지 종류로 이루어진다.Runnable
객체로 이루어져있을 수도 있고, 일반적인 경우Message
객체로 이루어져있을 수도 있다.Looper
객체가 메세지 큐에서 메세지를 하나 딱 까봤을 때,Runnable
객체가 담겨져있으면 Handler 에 메세지를 전달하지 않고run()
을 수행하여 해당Runnble
작업을 바로 시작하고,Runnable
객체가 없을 경우Message
객체 내부에 명시돼있는Handler
의handleMessage()
를 수행하여 처리한다.Handler
개념을 살펴보며 더 이해해보자.
Handler
는 명칭에서 알 수 있듯 뭔가를 다루는 녀석인데, 특정 메세지를 Looper
의 MessageQueue 에 넣거나, Looper
가 MessageQueue 에서 특정 메세지를 꺼내어 전달하면 이를 처리하는 기능을 수행한다. 중간 다리 역할을 수행한다.
Looper
로 메세지를 전달하는 경우Message
객체를 생성하여 이를 전달하는 방식으로 구현한다.
sendMessage()
메소드를 통해 메세지 큐에 Message
객체를 적재할 수 있다.post
로 시작하는 메소드들을 통해 Runnable
객체를 직접 적재할 수 있다.Looper
로부터 메세지를 전달받는 경우Looper
가 메세지 큐에서 메세지 하나를 딱 꺼냈을 때,
Runnable
객체가 담겨있다면
→ 해당 Runnable
의 run()
메소드를 호출하여 작업을 실행할 수 있다.
Message
객체가 담겨있다면
→ 해당 메세지 내부의 Handler
가 갖고 있는 handleMessage()
메소드를 호출함으로써 해당 Handler
가 메세지를 전달받을 수 있다.
다른 쓰레드에서 특정 쓰레드 Handler
의 sendMessage()
를 활용하여 메인 쓰레드 Looper
의 MessageQueue 에 메세지를 전달함
해당 쓰레드의 Looper
는 MessageQueue 에서 loop()
를 통해, 메세지를 하나씩 Handler
에 전달함
Handler
에서 handleMessage()
를 통해 메세지 처리함
Handler
는 MessageQueue 와, MessageQueue 안의 메세지들을 자신에게 전달해주는Looper
에 의존적인 녀석임을 알 수 있다.Looper
가 없다면 아무것도 못하는 녀석이다.
위와 같은 동작 흐름을 통해 Handler
와 Looper
를 통해 쓰레드간 통신이 가능하다는 사실을 알게 되었다. 이 개념을 그대로 '메인 쓰레드 ↔ 다른 쓰레드' 상황으로 옮겨보자.
당연하게도, 다른 쓰레드에서 메인 쓰레드에 종속되는 Handler
를 활용하게 되면 다른 쓰레드의 특정 결과를 메인 쓰레드에서 받아볼 수 있게 되는 것이다.
즉, 메인 쓰레드가 갖고있는
Looper
의 MessageQueue 에 다른 쓰레드의 결과를 활용한 작업들을 담아두면, 메인 쓰레드의Handler
가 이를 차례대로 수행하게 되는 것이다.
자, 그럼 Handler
를 생성해보자. Handler
는 Looper
와 MessageQueue 가 있어야 하는 상당히 의존적인 녀석이기 때문에, 무조건 Looper
가 필요하다.
예제 코드들은 Kotlin 을 기준으로 합니다!
Handler
와 Looper
생성은 다음과 같이 구현하게 된다.
var handler: Handler? = null
val thread = Thread { // Runnable 익명 객체 구현
Looper.prepare()
handler = Handler()
Looper.loop()
}
thread.start()
Looper.prepare()
를 통해 해당 쓰레드에 종속되는 Looper
와 MessageQueue 를 준비해주고, Handler
를 생성해준다. 이 순간 Handler
와 Looper
가 연결된다. 그리고 해당 쓰레드가 익명으로 구현한 Runnable
객체의 run()
메소드 마지막에서 Looper.loop()
를 호출해줌으로써 Message
전달을 기다리는 동작을 시작한다.
하지만, 이렇게
Looper
를 생성하면Handler
가 암시적으로Looper
를 선택하게 되는데, 이 과정에서 특정 작업이 손실되거나 충돌하는 등의 버그가 발생할 수 있어 해당 방식은deprecated
되었다.
안드로이드는 연결할 Looper
를 명시하여 Handler
를 생성하는 방법을 권장한다. 보통 메인 쓰레드와의 통신을 필요로 하기 때문에, 이번 포스팅에선 메인 쓰레드와의 통신을 다뤄보겠다.
안드로이드에선 별도 쓰레드의 결과를 보통 메인 쓰레드에서 처리하기 때문에, 아래와 같이 메인 쓰레드가 갖고 있는 Looper
를 명시하여 Handler
를 생성하면 된다.
이렇게 구현하면 메인 쓰레드의 Message Queue 에 메세지가 쌓이게 되고, 이를 메인 쓰레드의 Looper
가 하나씩 꺼내보게 된다. 또한 해당 Handler
는 메인 쓰레드의 Looper
를 명시하여 생성되었기 때문에, UI 관련 작업이 가능하다.
var handler: Handler? = null
val thread = Thread { // Runnable 익명 객체 구현
handler = Handler(Looper.getMainLooper())
}
thread.start()
해당 포스팅에선 Handler
와 Looper
의 개념에 대해서만 간략히 다뤄보았다. 안드로이드에서 멀티 쓰레딩 환경을 완벽히 구현하기 위해서는, Handler
와 Looper
에 대해서 빠삭하게 알고 있어야 한다.
이러한 기본 개념을 바탕으로, 다음 포스팅에선 이들의 활용 방법에 대해 예제 코드를 기반으로 설명해보려 한다.
완전 도움이 되었습니다...! 루퍼와 핸들러에 대해 제대로 알게 된 것 같습니다.
질문하고 싶은 것이 있는데, 제가 이해한 것이 맞다면, 쓰레드를 생성하여 핸들러를 만들어주고 Looper.getMainLooper()로 연결해주면, 이전 코드인 Looper.loop()를 해주지 않아도 Message를 받으면 처리해주는 것이죠?
그리고, 만약 두 개 이상의 쓰레드를 생성하여 핸들러를 만들어서 각각 Looper.getMainLooper()로 메인스레드와 연결해준다면, 여러 쓰레드의 Message들을 순서대로 받아서 동시에 처리하는 효과를 받을 수 있는 거겠죠?
이상 궁금한 점이고 대답해주시면 감사하겠습니다. 좋은 글 감사합니다!
글 너무 재밌게 잘 보고 있습니다!
한 가지 질문이 있는데 메시지 큐에서 runnable객체 일 경우 루퍼를 통해 핸들러에게 runnable객체를 전달하지 않고 바로 run()하는게 맞나요? 다른 곳에서는 둘 다(message,runnable) 핸들러에게 전달한다고 되어있어서 혼란스럽습니다ㅜㅜ