[android] 루퍼와 핸들러의 이해 #1

개요top

자바에서 쓰레드에 대한 복잡한 처리를 감추는 전략은 각 활용될 컨텍스트에 맞게 적절한 추상API를 제공하는 경우가 대부분입니다. 대표적으로 퓨처나 프라미스를 들 수 있습니다. 안드로이드상에서도 비슷하게 병렬처리에 대한 특수성을 감추고 추상API를 제공하는데 대표적인 것이 바로 생산자-소비자 모델에 근거한 메세지큐 시스템입니다. 하지만 워낙 복붙으로 많이 쓰는 기능이기도 하고 AsyncTask를 통해 패턴처럼 쓰게 되니 거의 원리에 대해 생각해볼 필요가 없어 개략적인 형태만 인지하고 있었습니다. 이를 보다 구체적으로 살펴보고 세세한 응용을 해봅니다.

생산자 소비자 패턴top

Producer-Consumer패턴은 멀티쓰레드패턴 중 워커쓰레드패턴만큼이나 굉장히 유명한 녀석으로 워낙 흔하게 쓰다보니 거의 언어레벨에서 이 패턴을 제공하는 경우가 많습니다. 이 패턴이 유용한 이유는 생산자쓰레드와 소비자 쓰레드가 분리되어 생산하는 타이밍이나 속도를 소비하는 측과 동기화하지 않아도 된다는 점입니다. 특히 이를 확장하여 생각해보면 다수의 생산자쓰레드와 다수의 소비자쓰레드가 참여할 수도 있어 굉장히 유연합니다. 당연히 이러한 마법은 그냥 일어나지 않고 중간에 양측을 조율한 중개 역할이 존재하는 것으로 성립됩니다.
보통 생산자와 소비자가 모두 단일한 쓰레드인 경우는 파이프패턴이라고 부르고 자바에서는 PipedReader와 PipedWriter를 아예 제공하여 이 패턴의 구현을 돕습니다.
만약 개발자가 직접 중개 역할을 구현하는 경우 대부분 큐나 스택구조를 사용하게 됩니다. 하지만 무한 큐나 스택을 쓰면 메모리의 총량을 가늠할 수 없으므로 제약을 두게 되는데 제약보다 많은 생산이 일어나거나, 비었을때 소비자가 가져가려는 경우 대기시킬 필요가 생깁니다. 보통 이때는 가드서스펜션(guard suspension) 패턴을 써서 wait()를 걸고 가능해질 때 notifyAll()을 때리는 식으로 구현하게 되죠. 이상의 내용을 바탕으로 간단한 String배열로 중개 역할을 하는 클래스를 구현해보죠. 큐로 구현하려면 복잡한 서클큐 로직이 관여하므로 이해하기 편한 스택으로 가볍게 구현하죠(절대 귀찮아서가 아닙니..^^)

class Mediator{

  private String[] stack; //사용할 스택
  private int curr = 0; //현재 크기

  Table(int size){
    stack = new String[size];
  }

  //생산자가 삽입하기
  synchronized void push(String v){
    try{

      //다차면 대기(가드서스펜션)
      while(curr == stack.length) wait();
    
      stack[curr++] = v; //스택에 넣고
      notifyAll(); //대기 중인 생산자를 깨운다.

    }catch(Exception e){
    }
  }

  //소비자가 가져가기
  synchronized String pop(){
    try{

      //가져갈게 없으면 대기시킴
      while(curr == 0) wait();

      String v = stack[curr--]; //스택에서 빼고
      notifyAll(); //다 깨움
      return v;
    }catch(Exception e){
    }
  }
}

결국 핵심이 되는 stack만 싱크로나이즈로 지키면 되는 심플한 구조임에도 생산자와 소비자가 모두 이 중개구현체를 통해서만 통신하므로 동기화문제가 일어나지 않는 것이죠. 더불어 가드서스펜션이 포함되어 자동대기처리를 포함하여 구현하는 것이 일반적입니다. 자바는 고수준의 블로킹계열의 동시성 자료구조를 제공합니다. java.util.concurrent.BlockingQueue로 제공되며 더 나아가 LinkedBlockingQueue도 제공합니다.
따라서 위의 스택모델을 큐기반으로 다시 작성한다면 아래와 같이 현격하게 줄일 수 있습니다.

class Mediator{
  
  private BlockingQueue<String> que;

  Mediator(int size){
    que = new BlockingQueue<String>(size);
  }
  void put(String v){
    try{
      que.put(v);
    }catch(Exception e){}
  }
  String take(){
    try{
      return que.take();
    }catch(Exception e){}
  }
}

핸들러, 루퍼, 메세지큐의 관계top

위의 생산자-소비자 패턴에 비추어 생각해보면 생산자는 메세지큐에 계속 생산물을 넣어서 소비자는 그 메세지큐를 비워가며 소비하는 구조임을 쉽게 이해할 수 있습니다.
대충 메세지큐가 어떻게 생겼을지도 예상이 되실 겁니다. 구글은 여기서 다소 특이한 추상화를 했는데 핸들러라는 다중역할 클래스를 도입했습니다. 핸들러는

  1. 생산자가 생산물(메세지)을 메세지큐에 넣기 위한 중계자이면서 동시에
  2. 소비자가 메세지를 수신하는 수신함으로도 사용되는 객체입니다.

이렇게 설계된 이유는 소비자가 쓰레드가 아니더라도 작동하도록 단순화하기 위해서입니다.

  1. 즉 기존의 생산자-소비자모델에서는 소비자도 쓰레드이기 때문에 주기적으로 파이프를 감시하여 메세지를 가져가고 가드서스펜션에 의해 알아서 대기탔다가 풀려나는 식으로 작동했습니다.
  2. 하지만 안드로이드는 개발의 편의성을 위해 소비자가 쓰레드가 아닙니다. 쓰레드가 아닌 평범한 인터페이스 구현체가 생산자-소비자 모델에 참여하려면 이를 대리할 쓰레드가 필요합니다.

생산자의 경우 실제 쓰레드이거나 혹은 터치이벤트 등의 이벤트기반의 리스너가 될 수 있습니다. 따라서 평범한 핸들러를 노출함으로서 특정 메세지큐와 연결되어 메세지를 받아줄 수 있는 것입니다.

  1. 하지만 소비자가 쓰레드가 아니므로 풀(pull)방식으로 메세지를 가져오는 것이 불가능합니다.
  2. 이를 해소하기 위해 푸쉬(push)방식으로 메세지큐에서 메세지를 퍼날라 직접 소비자를 호출하여 넣어주는 방식을 취합니다.
  3. 이러한 푸쉬행위를 하기 위해 지속적으로 메세지큐를 감시하면서 소비자에게 새 메세지를 가져다주는 녀석이 필요한데 이게 바로 루퍼입니다.

즉 루퍼가 생겨난 이유는 풀방식의 생산자-소비자패턴을 푸쉬방식의 생산자-소비자패턴으로 바꾸기 위한 것입니다. 이게 과연 오리지널보다 구현이 편한거냐고 의문이 드실텐데 당연히 편합니다.
쓰레드루프에서 무언가 감시하는 코드보다는 이벤트를 수신하는 리스너 코드가 양도 적고 생각할 것도 훨씬 적습니다. 구체적으로는 핸들러의 handleMessage() 메소드를 구상하는 것만으로 쓰레드작성없이 소비자를 작성할 수 있습니다.

  1. 사정이 이렇다보니 메세지큐를 감시하는 루퍼 자신이 메세지큐를 소유하는 편이 자연스럽고
  2. 당연하게도 메세지큐에 메세지를 삽입할 수 있는 인터페이스인 핸들러도 루퍼로부터 만들어지는게 자연스러운 것입니다.

메인UI쓰레드와 기본루퍼top

하지만 안드로이드의 구현에서 루퍼는 쓰레드여야 작동하는게 당연한데도 자신은 쓰레드가 아닙니다.

  1. 반드시 별도의 쓰레드를 만들어서
  2. 쓰레드가 활성화된 후
  3. 그 쓰레드를 물고 루퍼를 만들어내야하는 구조를 갖고 있습니다.

왜 이렇게 불편하게 만드는가 생각해보면

  1. 안드로이드OS가 최초 액티비티를 활성화시켰을때 자동으로 메인쓰레드를 만들어내는데
  2. 만들어진 메인쓰레드를 물고 있는 루퍼를 만들어내기 때문에 루퍼 자신은 쓰레드가 아닌 형태로 설계되었다고 생각합니다.

만약 루퍼 자체가 쓰레드였다면 안드로이드OS는 앱구동시 메인UI쓰레드를 만드는게 아니라 메인루퍼를 만들어내야할 상황일 것입니다.
OS는 이 귀찮은 과정을 자동으로 처리하여 액티비티가 구동되자마자

  1. 메인UI쓰레드를 만들어 활성화시키고
  2. 이 쓰레드를 물고 있는 UI용 루퍼를 생성합니다.

해서 언제나 앱이 실행되면 무조건 다음과 같이 메인루퍼를 얻어낼 수 있습니다.

Looper.getMainLooper();

즉 안드로이드 구동 중에 루퍼가 하나도 없는 경우는 없다는 것입니다. 최소 하나의 루퍼와 하나의 메세지큐가 대기 중인거죠. 하지만 여태까지 떠든 것을 바탕으로 직접 만든 쓰레드를 기반으로 하는 루퍼를 생성해보죠.
구현 계획을 보자면

  1. 우선 핸들러가 메세지를 수신후 외부의 리스너에게 토스할 수 있도록 Message를 받는 인터페이스 구상체를 생성자에서 받습니다.
  2. 쓰레드가 시작하면 쓰레드를 루퍼로 바인딩하고,
  3. 루퍼를 바인딩한 핸들러를 생성하고 외부에서 큐에 메세지를 넣을 수 있게 외부에 노출해 줍니다.
  4. 마지막으로 루퍼를 루프를 호출하여 쓰레드를 홀딩합니다.

우선 간단하게 Message를 받아들이는 인터페이스를 정의하죠.

interface Messanger{
  void receive(Message m);
}

별거 없습니다. 걍 메세지를 받는 단순한 인터페이스입니다. 이제 루퍼로 사용할 쓰레드를 정의해야죠. 위의 1~4번까지의 계획을 코드에 반영합니다.

public class LooperThread extends Thread{

    public Handler handler; //밖에 노출할 핸들러
    private Messanger listener;//메세지 수신자

    public LooperThread(Messanger m){ //1번상황
      listener = m;
    }

    public void run(){

      //2번상황 - 해당 쓰레드를 루퍼로 바인딩 
      Looper.prepare(); 

      //3번상황 - 기본생성자로 현재 루퍼 바인딩
      handler = new Handler(){ 
        public void handleMessage(Message m){
          listener(m); //리스너에게 전달함
        }
      };

      //4번상황 - 루퍼루프 홀딩
      Looper.loop();
    }
}

머 귀찮다면 귀찮고 별거 아니라면 별거 아닙니다만, static기반의 인터페이스라 문서 좀 한참봐야하고 쓰레드와의 관계를 명시적으로 나타내지 않고 Thread.currentThread()를 내부에서 사용하는 형태로 은닉되어있어 처음 보는 사람을 당황시킵니다. 이제 액티비티에서 써보죠.

public class Act extends AppCompatActivity{

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

        LooperThread looper = new LooperThread(new Messanger(){public receive(Message m){/*메세지처리*/}});
        looper.start(); //쓰레드시작

        //메세지생성 및 전달
        looper.handler.sendMessage(looper.handler.obtainMessage(0));
    }
}

이제 기본 루퍼외에도 마음대로 루퍼를 만들고 메세지큐를 처리할 수 있게 되었습니다.

결론top

1회차에서는 가볍게 생산자-소비자패턴을 설명하고 안드로이드에서 이를 어떻게 추상화했는지를 설명했습니다. 다음 포스팅에서는 보다 구체적이고 상세한 메세지와 핸들러, UI의 처리를 알아봅니다.