안드로이드 스레드 예제. 스레드로 고민해보기. (Android Thread UseCase)

2019. 2. 1. 19:57


1. 스레드(Thread)로 고민해보기.

지난 글 [안드로이드 스레드(Android Thread)]에서, 스레드(Thread)의 개념에 대해 설명하고, 안드로이드에서 스레드를 만들고 실행하는 방법에 대해 살펴보았습니다. 그리고 프로세스의 시작과 함께 반드시 실행되는 메인 스레드(Main Thread)에 대해 언급하고, 안드로이드 메인 스레드의 중요한 역할(루프 실행과 화면 그리기)에 대해 설명하였습니다.


아마도 [안드로이드 스레드(Android Thread)]를 미리 살펴봤다면, 스레드에 대한 기본 개념 정도는 이해하게 되었으리라 생각되는데요. 이제 간단한 앱 개발을 통해 스레드를 사용하는 이유와 방법에 대해 알아보도록 하겠습니다.


참고로, 예제에서 보여주는 앱의 구현 과정은 오직 스레드 사용법에 대한 설명을 위해 임의로 채택한 방법입니다. 그러므로 예제와 유사한 기능을 구현하기 위한 목적으로 본문의 예제 소스를 사용하는 것은 추천하지 않습니다.


자, 그럼 시작해 볼까요?

2. 앱 만들어보기.

2.1 디지털 시계 앱 구상.

예제에서 만들고자 하는 앱은 "디지털 시계"입니다. 먼저, 아래와 같이, 앱을 구상하는 상황을 가정해보겠습니다. 가볍게 읽어주세요.


[단계-1] 앱 아이디어.
어느 날 갑자기, 멋진(?) 앱에 대한 아이디어가 떠올랐습니다. 바로 "디지털 시계" 인데요. 
보통 스마트폰을 구매하면 기본적으로 설치된 앱 중에 하나이지만, 
마음에 드는 디자인이 없는 관계로 직접 만들기로 결정합니다. 
이름은... 디지털(Digital)과 시계(Watch)라는 단어를 적절히 조합하여 디와치(DiWatch)로 정했네요.

2.2 요구사항 정의

어떤 앱을 만들지 정했으니, 이제 간단하게나마 디와치(DiWatch) 앱에 대한 요구사항을 정리해 보겠습니다.


[단계-2] 요구사항 정의.
R1. 현재 시각을 숫자 형식으로 화면에 표시한다.
R2. 표시 형식은 "시:분:초"로 표시한다.
R3. 시간은 24시간 기준(0~23), 분은 60분(0~59), 초는 60초(0~59) 범위로 표시한다.
R4. 화면 갱신은 1초 단위로 수행한다. (즉, 1초마다 한번씩 화면을 다시 그린다.)
R5. 현재 시각 정보는 화면의 중앙에 표시한다.
R6. 시각 표시는 앱 시작과 동시에 수행한다.

2.3 앱 화면 구성 설계.

앱 실행 화면은 요구사항에 따라 아래 그림처럼 간단하게 구성합니다. (참고로, 여기서는 스토리보드를 따로 작성하지 않았습니다. 예제가 너무 간단하여 스토리보드에 넣을만한 내용이 없네요.)


[단계-3] 앱 화면 설계.


그리고 이제 앱을 어떻게 만들지 SW구조를 설계해야 하는데요. 이 과정을 먼저, 스레드 없이 접근해보도록 하겠습니다.

3. 간단한 접근. 스레드 없이 무작정 만들어보기.

3.1 앱 구조 설계

앞서 정리한 요구사항에 따라 아래와 같이 SW구조를 설계해봤는데요. 정해진 형식없이 간단하게 작성한 내용이니, 처리 흐름을 이해하는 정도로만 참고하시면 될 것 같습니다.


[단계-4] 앱 구조 설계. (스레드 없이 무작정 만들어보기)

앱 구조 설계(스레드 없이 무작정 만들어보기)


3.2 구현. (스레드 없이 무작정 만들어보기.)

자, 이제 설계에 따라 앱을 구현해보도록 하겠습니다. [2.3 앱 화면 설계] 단계에서 설계한 화면대로, 메인액티비티의 레이아웃은 아래와 같이 작성합니다.


[단계-5.1] 메인액티비티 레이아웃 작성.
<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context=".MainActivity"
    tools:showIn="@layout/activity_main">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="48sp"
        android:text="00:00:00"
        android:id="@+id/clock"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</android.support.constraint.ConstraintLayout>

이제 1초마다 현재 시각을 얻어와서 화면에 표시하는 자바 코드를 작성하겠습니다. 앱이 시작됨과 동시에 시각을 표시하는 작업이 수행되어야 하므로, 앞서 설계한 내용대로, 액티비티의 초기화가 수행되는 onCreate() 메서드에 구현하면 될 것 같습니다. while 루프를 돌며 1초 마다 한번씩 현재 시각을 구해온 다음, 텍스트뷰에 표시하도록 만들겠습니다.


[단계-5.2] onCreate()에서 1초 마다 시각을 갱신하는 코드 작성.
public class MainActivity extends AppCompatActivity {

    TextView clockTextView ;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...

        clockTextView = findViewById(R.id.clock) ;
        
        Calendar cal = Calendar.getInstance() ;
        SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss") ;
        while (true) {
            String strTime = sdf.format(cal.getTime());
            clockTextView.setText(strTime) ;

            try {
                Thread.sleep(1000) ;
            } catch (Exception e) {
                e.printStackTrace() ;
            }
        }
    }
}
class MainActivity : AppCompatActivity() {

    var clockTextView: TextView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        clockTextView = findViewById(R.id.clock)

        val cal = Calendar.getInstance()
        val sdf = SimpleDateFormat("HH:mm:ss")

        while (true) {
            val strTime = sdf.format(cal.getTime())
            clockTextView?.setText(strTime)

            Thread.sleep(1000)
        }
    }

3.3 실행 결과. (스레드 없이 무작정 만들어보기.)

앱을 빌드하고 실행해보겠습니다. 결과가 어떻게 나올까요? 디지털 시계가 1초마다 한번씩 표시되나요?

스레드 없이 무작정 만들어보기 예제 실행 화면


어라, 화면에 디지털 시계는 고사하고, 메인 액티비티 레이아웃에 배치했던 텍스트뷰(00:00:00)조차 표시되지 않습니다. 아예 화면은 갱신조차 되지 않고, 앱 실행은 멈춰버린 것 같습니다. 왜 그럴까요?


원인을 찾기 위해, 한 가지 질문을 던져보겠습니다.


일반적으로, 개발자가 안드로이드 앱의 초기화 코드를 작성하는 메서드인 onCreate() 메서드는
어디서 호출, 실행되는 것일까요?


onCreate() 메서드가, 안드로이드 스튜디오에서 액티비티를 추가할 때 자동으로 추가되고, 앱이 실행된 다음 액티비티가 만들어지는 시점에 무조건 호출되는 메서드라는 것은 막연히 알고 있지만, 프레임워크 내부에서 onCreate() 메서드가 어떤 과정을 거쳐 호출되는지에 대해서는 큰 관심을 가지지 않았을 것입니다.


하지만 [안드로이드 스레드]를 미리 살펴보고 내용을 이해했다면, onCreate()가 어떠한 이벤트 메시지가 수신되었을 때 실행되는 메서드이고, 안드로이드 앱의 메인 스레드에서 실행된다는 것을 어렴풋이 눈치챌 수 있을 것입니다.


그런데 위에서 작성한 코드를 보면, onCreate() 메서드에서 무한 루프(while(true))를 실행해 버렸습니다. 메인 스레드 입장에서는 onCreate() 메서드에서 초기화 작업을 마치고나서, 다시 메시지 큐를 확인하여 다른 이벤트(화면 그리기, 터치 입력 처리 등)들을 처리해야 하는데, onCreate() 메서드가 끝나지 않게 된 것이죠.


이런 이유 때문에, 안드로이드 앱의 메인 스레드에서는 무한 루프나 실행 시간이 긴 작업, 또는 Thread.sleep()을 통한 과도한 대기 등의 코드 작성을 피해야 합니다.


자, 어쨌든 문제가 무엇인지 파악했으니, 이제 코드를 수정해야겠군요. 스레드를 사용하겠습니다.

4. 스레드 사용. 일단 스레드로 만들어보기.

앞서 작성한 코드의 문제는, 지속적으로 실행되어야 하는 작업을 메인 스레드(onCreate())에서 실행했기 때문에, 메인 스레드의 다른 코드가 더 이상 실행되지 않는 것이었습니다. 사용자 입력도 처리하지 못하고, 화면 갱신도 수행하지 못하게 되어버렸죠.


그럼 이제 메인 스레드와 분리되어 동시적으로(Concurrently) 실행되어야 하는 작업(1초 마다 현재 시각 표시)을 별도의 스레드로 작성해보겠습니다.


4.1 스레드를 적용한 앱 구조 설계

이제 스레드를 적용한 처리 흐름을 작성해보겠습니다. 이 또한, 처리 흐름을 이해하는 정도로만 참고하시면 될 것 같습니다.


[단계-4] 앱 구조 설계. (스레드 적용)

앱 구조 설계(스레드 적용)


4.2 구현. (스레드 적용)

메인 액티비티의 레이아웃은 앞서 [3.2 구현]에서 작성한 XML 코드를 그대로 사용하고, 자바 코드만 새로 작성하겠습니다.


[단계-5.2] 스레드에서 1초 마다 현재 시각을 갱신하는 코드 작성.
public class MainActivity extends AppCompatActivity {

    TextView clockTextView ;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...

        clockTextView = findViewById(R.id.clock) ;

        class NewRunnable implements Runnable {
            Calendar cal = Calendar.getInstance() ;
            SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss") ;

            @Override
            public void run() {
                while (true) {
                    String strTime = sdf.format(cal.getTime());
                    clockTextView.setText(strTime) ;

                    try {
                        Thread.sleep(1000) ;
                    } catch (Exception e) {
                        e.printStackTrace() ;
                    }
                }
            }
        }

        NewRunnable nr = new NewRunnable() ;
        Thread t = new Thread(nr) ;
        t.start() ;
    }
}
class MainActivity : AppCompatActivity() {

    var clockTextView: TextView? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        clockTextView = findViewById(R.id.clock)

        thread(start = true) {
            var cal = Calendar.getInstance()
            var sdf = SimpleDateFormat("HH:mm:ss")

            while (true) {
                val strTime = sdf.format(cal.time)
                clockTextView?.setText(strTime)

                Thread.sleep(1000)
            }
        }
    }

[안드로이드 스레드(Android Thread) - 2.2 Runnable 인터페이스 구현]에서 스레드를 만드는 방법에 대해 설명했듯이, 새로운 스레드를 만들기 위해 Runnable인터페이스를 구현(implements)한 클래스를 생성하였습니다. 그리고 run() 메서드를 추가한 다음, 1초마다 현재 시각을 가져와서 화면에 표시하도록 만들었습니다.


코드 자체는 onCreate() 메서드 내부에 작성되어 있지만, 메인 스레드와 분리된, 새로 생성한 스레드에서 실행됩니다. 그래서 while(1) 루프로 인해 메인 스레드의 루프가 실행되지 않는 문제가 해결되었죠. 이제 메인 스레드가 정상적으로 다른 이벤트(화면 그리기, 터치 이벤트 등)를 처리할 수 있게 되었습니다.

4.3 실행 결과. (스레드 적용)

자, 이제 스레드를 사용하여 1초 단위로 시각을 표시하도록 만들었으니, 앱을 실행하여 결과를 확인하겠습니다.

스레드 적용 예제 실행 화면


이런, 현재 시각이 한번 제대로 표시되는 것 같더니 앱이 종료되어 버렸습니다. 작성한 코드에 문제는 없는 것 같은데, 앱이 왜 종료된 것일까요? 무엇이 원인일까요? 문제에 대한 원인과 해답을 찾기 위해, 앱이 중지될 때 출력된 로그 메시지를 확인해 보겠습니다.

Only the original thread that created a view hierarchy can touch its views


음, 에러 메시지 중에 눈에 띄는 메시지가 있네요.


"Only the original thread that created a view hierarchy can touch its views."

대충 번역해보면, "뷰 계층을 생성한 원래 스레드만이 해당 뷰를 건드릴 수 있습니다."라는 의미인데요. 어떤 의미인지 한번에 이해가 되나요? 이해하기 쉽게 문장을 살짝 바꿔볼까요? "뷰 계층을 생성하지 않은 스레드는 해당 뷰를 건드릴 수 없습니다.". 새로 만든 스레드와 뷰의 문제인 것은 확실하네요.


소스에서, 화면의 텍스트뷰(clockTextView)에 현재 시각을 표시하기 위해, 새로 만든 스레드 내에서 텍스트뷰의 setText() 메서드를 호출하였습니다. 하지만 이는 잘못된 구현입니다.


TextViewsetText() 메서드는 텍스트뷰에 표시될 문자열을 변경합니다. 즉, UI 그리기 기능이 실행되는 것인데요, 메인 스레드가 아닌 다른 스레드에서 화면을 그리는 기능이 실행되었기 때문에 에러가 발생한 것입니다. [안드로이드 스레드(Android Thread) - 3.3 안드로이드 메인 UI스레드의 중요한 역할:화면 그리기]에서 설명했던 내용을 다시 상기시켜 볼까요?


"그리기 기능"은 반드시 메인 UI 스레드에서 실행되어야 합니다.

또 다시, 문제의 원인을 찾아내었습니다. 수정해야겠군요.

5. 스레드 적용. 스레드 간 통신 적용하기.

앞서 작성한 코드를 통해, 안드로이드 메인 스레드가 아닌 스레드에서 뷰(View)에 대한 직접적인 접근의 문제에 대해 살펴보았습니다. 다행히도, 그리고 친절하게도, 안드로이드 프레임워크에서 해당 문제에 대한 에러 메시지를 띄워준 덕분에 문제점을 쉽게 찾을 수 있었죠.


문제를 해결하기 위한 방법은 여러가지가 있지만, 여기서는 가장 기본적인 접근 방법, "스레드 간 통신"을 적용하겠습니다.


"스레드 간 통신"의 핵심은 간단합니다. 하나의 스레드에서 다른 스레드로 메시지를 보내는 것입니다. 예제에서는, 새로 만든 스레드(1초 마다 메시지 전달)에서 메인 스레드(현재 시각 화면에 표시)로 메시지를 보내면 됩니다.


5.1 스레드 간 통신을 적용한 앱 구조 설계

수정해야 할 내용은 명확한데요, 새로 생성한 스레드에서 뷰를 직접 다루지 않고, 메인 스레드에서 접근하도록 만들면 됩니다. 그리고 이를 위해 새로운 스레드에서 메인 스레드로 메시지를 보내는 것이죠. 아래 그림과 같은 구조로 처리될 수 있습니다.


[단계-4] 스레드 간 통신을 적용한 앱 구조 설계.

앱 구조 설계(스레드 간 통신을 적용)


5.2 구현. (스레드 간 통신 적용하기.)

일단, 자세한 설명은 뒤로 하고, 아래 코드처럼 구현할 수 있습니다.


[단계-5.2] 스레드 간 통신으로 메인 스레드에서 현재 시각을 갱신하는 코드 작성.
public class MainActivity extends AppCompatActivity {

    TextView clockTextView ;
    private static Handler mHandler ;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        // ...

        mHandler = new Handler() {
            @Override
            public void handleMessage(Message msg) {
                Calendar cal = Calendar.getInstance() ;

                SimpleDateFormat sdf = new SimpleDateFormat("HH:mm:ss");
                String strTime = sdf.format(cal.getTime());

                clockTextView = findViewById(R.id.clock) ;
                clockTextView.setText(strTime) ;
            }
        } ;

        class NewRunnable implements Runnable {
            @Override
            public void run() {
                while (true) {
                    
                    try {
                        Thread.sleep(1000);
                    } catch (Exception e) {
                        e.printStackTrace() ;
                    }

                    mHandler.sendEmptyMessage(0) ;
                }
            }
        }

        NewRunnable nr = new NewRunnable() ;
        Thread t = new Thread(nr) ;
        t.start() ;
    }
}
class MainActivity : AppCompatActivity() {

    var clockTextView: TextView? = null
    private var mHandler: Handler? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        // ...

        @SuppressLint("HandlerLeak")
        mHandler = object : Handler() {
            override fun handleMessage(msg: Message) {
                val cal = Calendar.getInstance()

                val sdf = SimpleDateFormat("HH:mm:ss")
                val strTime = sdf.format(cal.time)

                clockTextView = findViewById(R.id.clock)
                clockTextView?.setText(strTime)
            }
        }

        thread(start = true) {
            while (true) {
                Thread.sleep(1000)
                mHandler?.sendEmptyMessage(0)
            }
        }
    }

여기서 눈여겨 볼 부분은 핸들러를 만들고(new Handler) 수신 메시지를 처리하는 코드(handleMessage), 그리고 메인 스레드의 핸들러를 통해 메시지를 전달(sendEmptyMessage)하는 코드인데요. 지금 당장 상세한 설명을 덧붙이지 않아도, 코드의 흐름이 어떻게 흘러가는지 어렴풋이 이해될 거라 생각합니다.


그럼, 실행 결과를 확인해보죠.

5.3 실행 결과. (스레드 간 통신 적용하기.)

스레드 간 통신 적용 예제 실행 화면


자, 이제 드디어 원하는 기능이 동작되도록 만들었습니다.

6. 정리

[안드로이드 스레드(Android Thread)]의 스레드에 대한 기본적인 설명에 이어, 이 글에서는 다소 억지스런(?) 예제를 통해 스레드를 사용하는 이유와 사용법, 그리고 몇 가지 주의 사항에 대해 살펴보았습니다.


간단히 요약하자면,

  • 메인 스레드와 병행적으로(Concurrently) 실행되어야 할 작업은 스레드로 작성한다.
  • 메인 스레드에서는 실행 시간 또는 대기 시간이 긴 작업의 실행을 피해야 한다.
  • UI를 변경하는 작업은 반드시 메인 스레드에서 실행되어야 한다.
  • 핸들러(Handler)를 사용하여 메인 스레드로 메시지를 보낼 수 있다.

정도로 정리될 수 있겠네요.


본문의 내용을 꼼꼼하게 살펴보고 예제 코드를 직접 작성해 본다면, 아마도 스레드의 기본 개념과 사용법에 대해 조금 더 깊이 이해할 수 있지 않을까 생각합니다.


7. 참고.

.END.


ANDROID 프로그래밍/THREAD