티스토리 뷰

5장에서는 서버와 클라이언트 모델로 네트워킹을 통해 서버에서 정보를 받아와서 클라이언트에서 보여주는 내용에 대해 다룬다. 이번 포스팅에서는 이런 네트워킹을 알아보기 전에 반드시 알아야 하는 스레드와 핸들러라는 개념을 먼저 정리해 보려고 한다.

 

[스레드와 핸들러]

스레드 사용하기

어떤 프로그램을 만들 때 작업 동시 수행은 굉장히 중요한 요소이다. 작업이 동시에 수행되지 않는다면 처리 시간이 긴 작업을 끝낼 때 까지 그 다음 작업들이 계속해서 미뤄지면서 전체적인 처리 속도가 느려질 것이다. 작업을 동시에 수행하는 것을 멀티 스레드(Multi Thread) 방식이라고 하고, 동시 수행이 가능한 작업 단위를 스레드(Thread)라고 한다.

 

멀티 스레드 방식은 같은 프로세스 안에 들어있어 메모리 리소스를 공유하기 때문에 효율적인 처리가 가능하지만 여러 스레드가 동시에 리소스에 접근할 때 데드락(Deadlock) 또는 Race Condition이 발생하여 시스템이 비정상적으로 동작할 수도 있다. 데드락이란 동시에 두 곳 이상에서 요청이 생겼을 때 어떤 것을 먼저 처리할지 판단할 수 없어 발생하는 시스템 상의 문제로, 이런 경우는 런타임 시의 예외 상황이기 때문에 디버깅하기 쉽지 않은 경우가 많다. 이렇게 스레드는 동시에 작업하는 것을 가능하게 해 주지만 그렇기 때문에 주의할 점이 많다. 이번 장에서는 이런 스레드를 통한 멀티 스레드 방식을 구현하는 방법을 알아볼 것이다.

 

우리가 새로운 프로젝트를 만들고 앱을 처음 실행하면 자동으로 Main Thread가 만들어지고 이 Main Thread가 공통 메모리 소스에 접근하면서 작업을 수행한다. 여기서 우리가 스레드를 더 추가해서 만들면 추가한 스레드도 공통 메모리 소스에 접근하면서 작업을 수행하는 것이다. 일반적으로 안드로이드 앱은 UI Thread와 Main Thread가 동일하기 때문에 안드로이드에서 UI 처리할 때 기본 스레드를 Main Thread라고 부른다. (하지만 특수한 상황에서는 앱의 기본 스레드가 UI Thread가 아닐 수도 있다.) 메인 스레드에서 이미 UI에 접근하고 있기 때문에 새로 생성한 다른 스레드에서는 화면의 UI를 직접 접근할 수 없다. 그 대신 핸들러(Handler) 객체를 사용해서 메시지를 전달함으로써 메인 스레드에서 처리하도록 만들어야 한다. 만약 다른 스레드에서도 화면의 UI에 접근할 수 있다면 메인 스레드와 다른 스레드에서 동시에 같은 화면 UI에 접근하면 위에서 언급한 데드락이 발생할 수 있기 때문에 핸들러를 통해 이런 상황을 막는 것이다. 

 

안드로이드에서 스레드는 표준 자바의 스레드를 그대로 사용할 수 있다. Thread 클래스에 정의된 생성자는 파라미터가 없는 경우와 Runnable 객체를 파라미터로 갖는 경우가 있는데, 아래의 코드는 파라미터가 없는 생성자를 통해 스레드를 사용한 예이다.

Thread thread = new Thread();
thread.start();

하지만 이 경우에는 아무런 동작을 하지 않는 스레드가 만들어지기 때문에 스레드를 만든 의미가 없다. 그렇기 때문에 파라미터가 없는 생성자는 Thread를 상속한 새로운 클래스를 만들어서 사용한다. 해당 스레드 객체를 start()를 하면 내부의 run() 함수가 호출되어 동작한다.

class MyThread extends Thread {
	@Override
    public void run() {
    	// 스레드 내부 동작 정의
    }
}
MyThread thread = new MyThread();
thread.start();

 

새로운 스레드 클래스를 만드는 것이 번거롭다면 아래와 같이 Runnable 객체를 파라미터로 갖는 스레드를 만들어서 실행시킨다.

Thread thread2 = new Thread(new Runnable() {
    @Override
    public void run() {
        // 스레드 내부 동작 정의
    }
});
thread2.start();

 

핸들러로 메시지 전송하기

새로 만드는 스레드에서는 데드락 문제가 발생할 수 있어 직접 UI를 변경할 수 없기 때문에 핸들러 객체를 사용해서 메시지를 전달함으로써 메인 스레드에서 처리하도록 만들어야 한다고 했다. 메인 스레드에서는 각각의 스레드에서 요청한 메시지를 처리해야 하는데 이 메시지를 관리하는 객체가 메시지 큐(Message Queue)이다. 메시지 큐는 최상위에서 관리되는 앱 구성 요소(액티비티, 서비스, 브로드캐스트 수신자, 내용 제공자)와 새로 만들어지는 윈도우를 관리한다.

메인 스레드에서 처리할 메시지를 메시지 큐로 전달하는 역할을 핸들러(Handler) 클래스가 담당한다. 결국, 실행하려는 특정 기능이 있을 때 핸들러가 포함되어 있는 스레드에서 순차적으로 실행시키고자 할 때 핸들러를 사용하게 된다. 또한 핸들러는 특정 메시지가 미래의 어느 시점에 실행되도록 스케줄링 할 수도 있다.

아래의 그림은 핸들러의 메시지 처리 방법을 그림으로 표현한 것이다. 

핸들러를 사용할 때 필요한 세 가지 단계

 

새로 만든 스레드가 수행하려는 정보를 메인 스레드로 전달하기 위해서는 먼저 obtainMessage() 메서드로 핸들러가 관리하는 메시지 큐에서 처리할 수 있는 메시지 객체 하나를 참조한다. 그 후에 참조한 메시지 객체에 필요한 정보를 넣은 후 sendMessage() 메서드로 메시지를 메시지 큐에 넣는다. 메시지 큐에 들어간 메시지는 핸들러가 순서대로 처리하게 되며 이때 handleMessage()에 들어온 메시지를 처리하는 동작을 정의하면 메인 스레드에서 정의된 기능이 수행된다.

public class MainActivity extends AppCompatActivity {
    TextView textView;
    MyHandler handler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        
        textView = findViewById(R.id.textView);
        
        // 스레드 생성
        MyThread thread = new MyThread();
        thread.start();
        
        // 핸들러 생성
        handler = new MyHandler();
    }

    class MyThread extends Thread {
        @Override
    	public void run() {
            Message message = handler.obtainMessage(); // 메시지 객체 참조
            Bundle bundle = new Bundle();
            bundle.putInt("value", 1);
            message.putData(bundle);		// 메시지 객체에 Bundle 객체로 필요한 정보 넣기
            
            handler.sendMessage(message);	// 메시지큐에 메시지 넣기
    	}
    }

    class MyHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
        
            Bundle bundle = msg.getData();	// 메시지 객체로 넘어온 Bundle 객체 얻어오기
            int value = bundle.getInt("value");
            
            textView.setText("value: " + value);
        }
    }
}

여기서 메시지 객체에 필요한 정보를 Bundle 객체로 넣는 메서드가 putData(Bundle bundle) 메서드이고, handleMessage()에서 Bundle 객체의 메시지를 꺼내는 메서드가 getData()이다. 

 

정리하자면, 스레드 작업 중 화면 UI 접근이 필요한 경우에는 핸들러 객체의 obtainMessage() 메서드를 이용해 메시지 객체를 참조하고, 그 메시지에 putData()로 Bundle 객체를 담아 sendMessage()로 메시지를 메시지 큐에 넣은 뒤, handleMessage()에서 getData()로 메시지 안에 들어온 Bundle 객체를 얻어와 메시지를 처리하는 동작을 정의한다. 그러면 핸들러가 순차적으로 handleMessage()에 정의된 코드를 실행한다. 핸들러가 정의된 동작을 수행하기 위해서는 handleMessage()가 호출되어야 하는데 그 역할을 하는 것이 Looper이다. Looper는 메인 스레드에 기본적으로 생성되어 있고, 메시지 큐를 돌면서 메시지를 순차적으로 꺼내 핸들러에게 전달하면 handleMessage()가 실행된다. 그렇기 때문에 결국 메인 스레드에서 이 핸들러의 동작이 수행되는 것이다. Looper에 대해서는 뒤에서 자세히 다룰 것이다.

 

Runnable 객체 실행하기

여기서 위의 방법처럼 스레드의 작업마다 핸들러를 정의해 줘야 한다면 실제 앱에서는 핸들러를 작성하는 코드가 굉장히 많아져 복잡해 질 것이다. 이러한 이유로 한 번 생성되어 하나의 작업을 하기 위해 만드는 스레드와 핸들러는 다음과 같이 익명 클래스와 handler의 post() 메서드를 사용한다.

Handler handler = new Handler();	// 핸들러 클래스를 새로 만들지 않고 기존 Handler 사용

new Thread(new Runnable() {		// Runnable 객체로 스레드 생성
    @Override
    public void run() {
        // 스레드 작업 정의

        handler.post(new Runnable() {
            @Override
            public void run() {
                // 핸들러 작업 정의
            }
        });
    }).start();

메시지 큐에는 위에서 본 것 처럼 Message 객체가 들어갈 수 있지만, 위의 코드처럼 Runnable 객체가 들어갈 수도 있다. post() 메서드는 메시지 큐에 핸들러 작업을 정의한 Runnable 객체를 전달한다. 이 메서드를 사용하면 스레드에서 핸들러로 메시지를 전달할 때 Message 객체를 사용하지 않아도 된다는 점이 장점이다. Message 객체를 사용하지 않으면 핸들러를 만들고 받은 메시지를 처리하는 메서드를 따로 정의할 필요가 없기 때문에 코드가 단순해진다. 여기서 알아야 하는 점은 post로 전달한 Runnable 객체에 정의된 run() 메서드는 메인 스레드에서 실행된다는 것이다. 이렇게 하면 Message를 전달했을 때와 동일한 결과를 볼 수 있기 때문에 실제 앱을 만들 때는 post() 메서드를 더 많이 사용한다.

 

 

[일정 시간 후에 실행하기]

핸들러에는 직접 Message를 생성하지 않고도 간단히 처리해주는 편리한 메서드가 많이 포함되어 있다. 이 메서드를 통해 딜레이를 이용한 타이머나 스케줄링 역할을 할 수 있다. 그 중 대표적으로 아래 4개의 메서드가 있다.

  1. handler.post(Runnable r); – 큐의 마지막에 태스크를 삽입한다. (메시지를 따로 작성하지 않아도 알아서 내부에서 처리해준다.)

  2. handler.postAtFrontOfQueue(Runnable r); – 1과 동일하지만 큐의 젤 앞에 삽입한다.

  3. handler.postAtTime(Runnable r, long uptimeMillis); – 지정한 시간으로 큐에 삽입한다.

  4. handler.postDelayed(Runnable r, long delayMillis); – 지정한 시간만큼 딜레이된 시간으로 지정한뒤 큐에 삽입한다.

메시지 큐에 메시지를 삽입하는건 순서가 중요한 것이 아니라 메시지에 설정된 시간이 중요하다. 삽입될 때마다 시간순으로 메시지를 정렬하고 Looper는 설정된 시간에 맞춰 메시지를 꺼내가기 때문이다.

 

 

스레드에서 전달 받은 메시지 처리하기

앞서 설명한 핸들러 기능은 새로 만든 스레드에서 메인 스레드로 메시지를 전달하는 것이다. 그런데 이와 반대로 메인 스레드에서 별도의 스레드로 메시지를 전달하는 방법이 필요할 때가 있는데 이 때도 핸들러를 사용한다. 별도의 스레드가 관리하는 동일한 객체를 여러 스레드에서 접근할 때에도 별도의 스레드 안에 들어있는 메시지 큐를 이용해 순서대로 접근하도록 만들어야 한다. 이와 같이 핸들러는 Looper로부터 받은 Message를 실행, 처리하거나 다른 스레드로부터 메시지를 받아서 메시지 큐에 넣는 역할을 하는 스레드 간의 통신 장치이다.

 

메인 스레드는 내부적으로 Looper 객체를 가지며 그 안에는 메시지 큐가 포함된다. 여기서 Looper는 무한히 루프를 돌며 자신이 속한 스레드의 메시지 큐에서 Message나 Runnable 객체를 차례로 꺼내 핸들러가 처리하도록 전달한다. 

루퍼를 이용한 메시지 처리

 

이제 여기서 새로 생성한 스레드에서 메시지 큐를 이용하려고 한다. 새로 만든 스레드는 기본적으로 Looper를 가지고 있지 않고, 단지 run() 메서드만 실행한 후 종료하기 때문에 다른 스레드로부터 메시지를 받을 수 없다. 따라서 새로 만든 스레드에서 메시지를 전달받으려면 prepare() 메서드를 통해 Looper를 생성하고, loop() 메서드를 통해 Looper가 무한히 루프를 돌며 메시지 큐에 쌓인 Message나 Runnable 객체를 꺼내 핸들러에 전달하도록 해야한다. 이렇게 활성화된 Looper는 quit()이나 quitSafely() 메서드로 중단할 수 있다. quit() 메서드가 호출되면 Looper는 즉시 종료되고, quitSafely() 메서드가 호출되면 현재 메시지 큐에 쌓인 메시지들을 처리한 후 종료된다.

(참고) https://academy.realm.io/kr/posts/android-thread-looper-handler/

 

예시로 메인 스레드에서 새로 만든 스레드로 메시지를 보내고 스레드에서 메시지를 받아서 처리하는 예제 코드를 살펴보자.

public class MainActivity extends AppCompatActivity {
    TextView textView;

    Handler handler = new Handler();
    ProcessThread thread = new ProcessThread();

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        textView = findViewById(R.id.textView);
        
        Message message = Message.obtain();	// 메시지 객체 하나 얻어오기
        message.obj = "input";
        
        // 새로 만든 스레드의 핸들러를 이용해 메시지를 새로 만든 스레드의 메시지 큐에 넣기
        thread.processHandler.sendMessage(message);
    }
    
    class ProcessThread extends Thread {
        ProcessHandler processHandler = new ProcessHandler();

        public void run() {
            Looper.prepare();
            Looper.loop();
        }

        class ProcessHandler extends Handler {
            String output;
            
            public void handleMessage(Message msg) {
                // 새로 만든 스레드 안에서 전달받은 메시지 처리
                output = msg.obj + " from thread.";
                Log.d("Log", output);

                handler.post(new Runnable() {
                    @Override
                    public void run() {
                        // 메인 스레드 핸들러 작업 정의 (UI 변경을 위해)
                        textView.setText(output);
                    }
                });
            }
        }
    }    
}

새로 만든 스레드에서 메인 스레드에게 처리할 작업을 전달해 주기 위해 핸들러의 3가지 메서드(obtainMessage(), sendMessage(), handleMessage())를 사용했었다. 위의 코드를 보면 메인 스레드에서 새로 만든 스레드에게 작업을 전달해 주기 위해 동일한 작업을 했다는 것을 알 수 있다. Message.obtain()으로 메시지 객체를 얻어오고, 새로 만든 스레드의 핸들러를 이용해 sendMessage()로 메시지 큐에 메시지를 전달한 뒤, 핸들러에 handleMessage()를 정의해서 메시지 큐에서 꺼낸 메시지를 스레드에서 처리하는 동작을 정의했다. 그리고 한 가지 추가된 것이 스레드 안에 Looper를 만들어서 동작하게 하는 것이다.

handleMessage() 안에 post() 메서드는 메인 스레드에서 전달한 메시지를 스레드에서 받았음을 화면에 표시하기 위함이다. 화면에 표시하고 싶지 않다면 이 코드는 생략해도 된다.

 

 

[AsyncTask]

위의 코드를 보면 하나의 스레드에서 어떤 작업을 하고 화면 UI를 변경하는 일련의 작업이 핸들러와 스레드와 메인 스레드가 얽혀 있어서 순서 없이 이곳 저곳에서 실행된다고 느껴질 수 있다. 이를 위해 일련의 작업을 하나의 클래스에서 쉽게 작성하고 쉽게 알아볼 수 있도록 지원해주는 클래스가 AsyncTask이다. AsyncTask는 스레드와 핸들러를 내부 동작으로 감춰놓고, Background 작업과 UI 변경 작업을 위한 메서드만 구현해 주면 안에서 알아서 동작하는 구조로, 스레드로 처리해야 하는 코드를 하나의 AsyncTask 클래스로 정의할 수 있다는 장점 때문에 작성하기도 쉽고 알아보기도 쉬워진다. 

 

AsyncTask를 사용하기 위해서는 AsyncTask를 상속 받는 클래스의 객체를 만들고 execute() 메서드를 실행하면 이 객체는 정의된 백그라운드 작업을 수행하고 필요한 경우 그 결과를 메인 스레드에서 실행하여 UI를 변경할 수 있다. 아래의 표는 AsyncTask 클래스에 정의된 주요 메서드에 대한 설명이다.

메서드 명 설명
doInBackground 신규 스레드에서 백그라운드 작업을 수행한다. execute() 메서드를 호출할 때 사용된 파라미터를 배열로 전달 받는다.
onPreExecute 백그라운드 작업을 수행하기 전에 호출된다. 메인 스레드에서 실행되며 초기화 작업에 사용된다.
onProgressUpdate 백그라운드 작업 중에 진행 상태를 표시하기 위해 호출된다. 작업 수행 중간에 UI 객체에 접근하는 경우에 사용된다.
백그라운드 작업 중간에 publishProgress() 메서드를 호출하면 이 메서드가 호출된다.
onPostExecute 백그라운드 작업이 끝난 후에 호출된다. 메인 스레드에서 실행되며 메모리 리소스를 해제하는 등의 작업에 사용된다. 백그라운드 작업의 결과는 Result 타입의 파라미터로 전달된다.

 

즉, AsyncTask의 작업 수행 방식은 아래 그림과 같다.

 

아래 그림은 메인 스레드에서 실행되는 메서드 및 호출 시점과 백그라운드에서 실행되는 메서드 사이의 관계를 나타낸 것이다.

AsyncTask에서 메소드 실행 순서 예시

 

AsyncTask는 추상 클래스로 이 클래스를 상속 받은 클래스는 반드시 AsyncTask의 doInBackground()메서드를 재정의 해야 한다. 그런데 직접 AsyncTask를 상속해서 클래스를 만들려고 하면 AsyncTask에 제네릭 타입으로 3개의 자료형을 넣어주어야 한다는 것을 알게 될 것이다. 이는 순서대로 doInBackground()의 파라미터, onProgressUpdate()의 파라미터, onPostExecute()의 파라미터를 결정한다. 예를 들어 아래 코드와 같이 AsyncTask<String, Integer, Bitmap>라고 하면, doInBackground()에서 파라미터로 String 객체가 들어오고 Bitmap 객체를 반환한다. 그리고 반환된 Bitmap객체는 onPostExecute()의 파라미터로 들어가고, onProgressUpdate()의 파라미터는 Integer가 되는 것이다.

public class MyAsyncTask extends AsyncTask<String, Integer, Bitmap> {
    @Override
    protected Bitmap doInBackground(String... strings) {
        return null;
    }

    @Override
    protected void onPreExecute() {
        super.onPreExecute();
    }

    @Override
    protected void onPostExecute(Bitmap bitmap) {
        super.onPostExecute(bitmap);
    }

    @Override
    protected void onProgressUpdate(Integer... values) {
        super.onProgressUpdate(values);
    }
}

 

이렇게 이번 포스팅에서는 앞으로 네트워킹 프로젝트를 진행할 때 필요한 베이스가 되는 스레드와 핸들러에 대해 알아보았다. 다음 포스팅에서는 스레드와 핸들러를 이용해 실제로 서버에 데이터를 요청하고 응답 받는 방법에 대해 알아볼 것이다.

 

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함