본문 바로가기

Android

[Android] UI는 왜 Main Thread에서만 그려져야 할까?

평소에 안드로이드 개발을 하면서 자연스럽게 오래 걸리는 작업은 백그라운드에서 실행하고 UI는 메인 스레드에서 그려야지 ! 라고 생각했는데 문득 왜...? 라는 생각이 들면서 한번에 대답이 나오질 않았다 그래서 이 참에 한번 정리를 해보려고 한다

 

안드로이드에는 Main ThreadWorker Thread가 존재합니다

 

Main Thread는 어플리케이션을 실행 시 자동으로 생성되는 스레드이고,

Worker Thread는 Main Thread와 별도로 동작하는 백그라운드 스레드입니다. 주로 작업을 병렬로 처리하거나 백그라운드 작업(ex. 네트워크 요청, 이미지 처리 등)을 실행하기 위해 사용합니다

 

우선 Main Thread에 대해 먼저 알아보도록 하겠습니다

 

 

 

✏️ Main Thread

일반적으로 메인 스레드는 앱이 시작되면서 자동으로 생성됩니다

이때, 이 스레드는 main() 함수에서 시작되게 됩니다

 

즉, 프로세스가 시작되고 프로세스의 시작점인 main() 함수에서 메인 스레드가 실행되는 것입니다

 

 

 

그렇다면 main() 함수는 어디에서 확인할 수 있을까요..?


 

 

위 코드에서 앱의 AndroidManifest.xml 안에 intent-filter 태그를 통해 SplashActivity를 앱의 시작점으로 설정해둔 것을 볼 수 있는데요, 그럼 main() 함수는 어디에 있을까요?

 

 

기본적으로 안드로이드 앱은 안드로이드 프레임워크 위에서 동작합니다

 

즉, main() 함수는 안드로이드 프레임워크에 위치합니다. 정확히는 android.app.ActivityThread 클래스에 위치한 main() 함수가 실행되며 앱 프로세스가 시작되는 것입니다. 이때 함수 안에서 메인 스레드가 준비되고 실행됩니다.

 

그리고 메인스레드가 실행되면 위에서 런처로 지정한 액티비티를 실행하게 되는 것입니다

 

 

 

드디어 등장한 Main Thread는 컴포넌트 생명주기 호출 및 관리, UI 업데이트 처리 등을 해줍니다

 

 

 

그럼 이때!

왜 Main Thread에서 UI를 그려야 할까요?


 

쉽게 생각해보면

  1. Main Thread는 단일 스레드이기 때문에 주어지는 작업들이 순차적으로 진행됩니다
  2. 그리고 UI는 호출한 순서대로 그려져야 합니다

-> 그렇기 때문에 UI는 작업들이 순차적으로 진행되는 단일 스레드에서 진행이 되어야 하고, 단일 스레드로 보장이 되는 스레드는 Main Thread이기 때문에 UI는 Main Thread에서만 그려져야 합니다!

 

 

단순하고 쉽게 생각하면 이렇게 되지만, 조금만 더 구체적으로 살펴보겠습니다

 

안드로이드 UI는 기본적으로 단일 스레드 모델로 이루어집니다

단일 스레드 모델이란 안드로이드 화면을 구성하는 뷰나 뷰 그룹을 하나의 스레드에서만 담당하는 원칙을 말합니다. 이러한 단일 스레드 모델은 자원 접근에 대한 동기화를 신경쓰지 않아도 되고, 작업 전환(context switching) 비용을 요구하지 않으므로, *경합 상태와 *교착 상태를 방지할 수 있습니다

 

안드로이드 공식문서에서는 단일 스레드에 대해 다음과 같이 말하고 있습니다

1. UI 스레드를 차단하지 말 것
2. UI 스레드 외부에서 Android UI 도구 키트에 액세스 하지 말 것

 

*경합 상태(Race Condition) : 여러 스레드가 공유 자원에 접근할 때 발생하는 문제로, 스레드들이 자원에 동시에 접근하거나 자원을 수정하는 순서가 예기치 않게 엉켜서 결과가 불안정해지는 현상

ex) 은행 계좌에서 여러 사용자가 동시에 돈을 출금하려고 할 때, 계좌 잔액이 올바르게 업데이트 되지 않는 경우가 발생

 

*교착 상태(Deadlock) : 두 개 이상의 스레드나 프로세스가 서로 다른 자원을 요구하고, 그 자원을 서로 점유한 채 기다리고 있는 상황

즉, 서로가 자원을 기다리고 있기 때문에 어떤 스레드도 더 이상 실행될 수 없게 되는 상태

ex) 스레드 A는 리소스 1을 점유하고, 리소스 2를 요청

스레드 B는 리소스 2를 점유하고, 리소스 1을 요청

-> 두 스레드는 서로 다른 자원을 기다리며 멈추게 됨

 

만약 여러 작업이 동시에 실행되는 멀티 스레드를 사용하게 된다면 화면에 UI가 그려지는 순서를 보장할 수 없게 됩니다

그렇기 때문에 UI는 단일 스레드 모델로 이루어져야 하며, UI를 메인 스레드에서만 그려야 하는 것입니다

 

이때 시간이 오래 걸리는 작업은 다른 스레드(worker thread)로 분리하여 작업하고, 그 작업 내용들을 다시 Main Thread로 전달해주기 위해 Looper와 Handler 등을 사용할 수 있습니다

 

 

 

📁 Worker Thread

Worker Thread는 Main Thread와 별개로 백그라운드에서 비동기 작업을 처리하는 스레드입니다

Main Thread에서는 UI 갱신과 같은 중요한 작업을 처리하므로, 시간이 오래 걸리는 작업은 Worker Thread에서 처리하여 UI Thread가 차단되지 않도록 해야 합니다

 

Main Thread는 자동으로 생성이 되었는데 Worker Thread는 어떻게 생성이 될까요?

 

 

Worker Thread의 종류


 

1. Thread 클래스 사용하기

Thread {
    // 긴 작업을 처리
    val result = performLongTask()

    // 메인 스레드에서 UI 업데이트
    Handler(Looper.getMainLooper()).post {
        updateUI(result)
    }
}.start()

 

가장 기본적인 방법은 `Thread` 클래스를 이용하는 것입니다

Thread를 생성하고, `start()` 함수를 호출하여 백그라운드에서 실행할 작업을 시작합니다. 이렇게 생성한 스레드는 별도의 메인 스레드와 독립적으로 실행됩니다

 

그러나 이 방식은 직접 스레드를 관리하는 방식으로 복잡한 동시성 문제나 스레드 관리가 필요할 경우 적합하지 않을 수 있습니다

 

 

2. ExecutorService 사용하기

val executor = Executors.newSingleThreadExecutor()

executor.execute {
    // 긴 작업을 처리
    val result = performLongTask()

    // 메인 스레드에서 UI 업데이트
    Handler(Looper.getMainLooper()).post {
        updateUI(result)
    }
}

 

`ExecutorService` 는 여러 스레드를 관리하면서 동시에 여러 작업을 효율적으로 처리할 수 있는 방법입니다. 스레드 풀을 관리하고, 작업을 분배하여 효율적으로 리소스를 사용할 수 있습니다

 

반면 복잡한 작업 흐름에 대한 관리가 필요하고, 스레드를 직접 제어해야 하므로 코드가 길어질 수 있습니다

 

코루틴과 비교했을 때 ExecutorService는 직접 스레드를 생성하여 작업을 실행하기 때문에 복잡한 작업을 다루기엔 적합하지만, 스레드 관리와 작업 흐름 제어가 복잡할 수 있다고 합니다. 멀티 스레드 환경에서 동시성 문제가 발생할 수 있기 때문에 스레드 안전을 보장하기 위해 추가적인 관리가 필요할 수 있습니다

 

 

3. Coroutine 사용하기

GlobalScope.launch(Dispatchers.IO) {
    // 긴 작업을 처리
    val result = performLongTask()

    // 메인 스레드에서 UI 업데이트
    withContext(Dispatchers.Main) {
        updateUI(result)
    }
}

 

현재 안드로이드에서 가장 많이 사용하는 비동기 처리방식이라고 할 수 있는 코루틴입니다.

코틀린의 코루틴(Coroutine)은 비동기 작업을 처리하는 강력한 방법으로, 스레드를 관리할 필요 없이 간결하게 비동기 코드를 작성할 수 있게 해줍니다. 코루틴은 UI 스레드를 차단하지 않고, 백그라운드에서 긴 작업을 처리할 수 있습니다.

 

 

 

Worker Thread는 어떤 일을 할까?


그럼 이렇게 생성된 Worker Thread에서는 어떤 일을 할까요?

 

  • 메인 스레드와 별도의 스레드로, 사용자 인터페이스(UI)와 관련 없는 작업을 수행합니다
  • 비동기 작업을 처리하는 데 사용되며, 예를 들어 네트워크 요청, 파일 입출력, 데이터베이스 작업 등이 이에 포함됩니다
  • 작업이 Worker Thread에서 완료되면, 그 결과를 메인 스레드로 전달하여 UI를 갱신합니다
    • 이를 위해 Handler, Looper 등을 사용할 수 있습니다

 

예를 들어 사용자가 화면의 버튼을 클릭하면 Worker Thread에서 네트워크 요청을 수행하고 다시 그 결과를 Main Thread로 전달하여 UI를 안전하게 업데이트 할 수 있습니다

 

 

 

📎 Looper와 Handler

Main Thread의 마지막에서 잠깐 이야기했듯 백그라운드에서 긴 작업을 수행한 결과를 Main Thread에 전달해 UI를 업데이트 하기 위해서는 Looper와 Handler가 필요합니다

 

Looper와 Handler를 알아보기 전에 Message, Message Queue에 대해 먼저 알아보자면,

Message란 스레드 간 통신할 내용을 담는 객체로 Handler를 통해 전달할 수 있습니다 (이때 안드로이드 시스템이 만들어 둔 Message Pool 객체를 사용합니다)

Message Queue란 스레드가 다른 스레드나 자기 자신으로부터 전달받은 Message를 FIFO(First In First Out) 형식으로 보관하는 Queue입니다

 

 

 

Looper

 

Looper는 이름 그대로 무한루프를 돌게 합니다

특정 스레드(Worker Thread)에서 무한 루프를 실행하며 메시지(queue)를 지속적으로 처리할 수 있게 해줍니다

 

  1. Looper는 스레드에 1개의 메시지 큐를 생성합니다
  2. 메시지가 들어오면 이를 큐에 넣고, 반복(looping)하면서 큐에서 메시지를 하나씩 꺼내서 처리하도록 합니다
  3. 처리할 메시지를 적절한 Handler에게 전달합니다

 

Main Thread는 기본적으로 Looper가 활성화된 상태에서 실행되지만, Worker Thread는 기본적으로 Looper가 생성되지 않습니다

그렇기 때문에 Looper를 생성하지 않는다면 메시지를 메시지 큐에 넣지도 못하고, 작업을 꺼내 Handler에게 넘겨줄 수도 없습니다

 

즉, Looper를 생성하지 않은 Thread는 다른 Thread에게 메시지를 전달만 할 수 있고, 다른 Thread로부터 메시지를 받을수는 없습니다

따라서 다른 Thread로부터 메시지를 전달받을 필요 없이 Worker Thread를 통한 백그라운드 작업만 수행하고 값을 전달할 용도라면 Looper를 생성할 필요는 없습니다

 

 

  • 메시지를 "보내는 것"다른 Thread의 Looper와 메시지 큐를 사용하는 행위입니다
  • 메시지를 "받는 것"자신의 Thread에 Looper와 메시지 큐가 있어야 가능합니다

 

 

 

 

Handler

 

Handler는 Looper로부터 전달받은 메시지나 작업을 실제로 처리하는 역할을 합니다

 

  1. Handler는 특정 Looper와 연결되어 있습니다
  2. 사용자는 Handler를 통해 메시지를 생성하고 전송하며, 전달받은 메시지를 처리하는 `handleMessage()` 메서드를 오버라이드하여 작업을 정의합니다

 

Thread의 Handler는 받아온 작업을 수행할 수 있게 해주며, 메시지를 보내고자 하는 다른 Thread의 Handler를 통해 메시지를 보낼 수 있게 해줍니다

 

이때 Handler와 연결되는 Thread는 생성한 위치에 따라 결정됩니다. 또한, Handler를 생성할 때 Looper가 필수적으로 필요하기 때문에, Looper가 없는 Thread에서는 Handler를 생성할 수 없습니다

Main Thread에서 생성한 Handler는 Main Thread의 Handler가 됩니다

 

 

 

위 이미지에서 Looper와 Handler의 흐름을 따라가보면

  1. Thread가 시작되고, `Looper.prepare()` 로 Looper와 Message Queue가 생성됩니다
  2. Handler가 생성되며, 이를 통해 다른 스레드에서 메시지나 Runnable을 Message Queue에 추가합니다
  3. `Looper.loop()` 는 무한 루프를 돌며, Message Queue에 있는 메시지를 꺼냅니다
  4. Handler는 메시지를 받아 `handleMessage()` 를 호출하고, 작업을 수행합니다
  5. 작업이 끝나면 다음 메시지를 기다리며 반복됩니다

 

그런데 안드로이드에서 자주 사용하는 코루틴은 Looper와 Handler를 직접적으로 사용할 일이 없습니다. 코루틴은 스레드를 직접 생성하고 관리하는 대신 스레드 풀이나 이미 존재하는 스레드를 효율적으로 사용하기 때문입니다. 또한 Dispatcher가 내부적으로 Looper와 Handler의 역할을 대신하기 때문에 별도의 Looper와 Handler를 사용할 필요가 없습니다.

 

 

 

🍀 마무리

처음에 UI를 왜 Main Thread에서만 그려야하지? 라는 물음표를 던졌을 땐 딱 답이 나오지 않았는데 막상 정리를 하다보니 당연한(?) 개념이였던 것 같습니다 ㅎㅎ 그런데 한번에 대답이 나오지가 않는다는 점이 .. 😂

 

그래도 물음표를 던진 덕에 Main Thread와 Worker Thread를 다시 한번 정리하면서 몰랐던 개념도 몇 개 배워가네요

워낙 비동기 처리는 코루틴이 유명하다보니 당연하게 코루틴만 많이 사용해왔는데 다른 비동기 처리에 대해서도 한번 돌아볼 수 있게 된 시간이였습니다 ㅎㅎ

역시 스스로 물음표를 많이 던질수록 많은 것들을 배워갈 기회가 생기는 것 같네요..!

 

Main Thread와 Worker Thread 알아갑니다 ~