Worker Thread 패턴

안드로이드나 자바는 개발자가 처음부터 쓰레드를 작성해야 하는 개발 환경입니다. 그럼 처음부터 쓰레드를 제작하지 않는 환경도 있다는 것인가요?

물론입니다.

이벤트모델이나 엑티비티 모델 등은 OS가 쓰레드를 은닉하고 개발자에게는 반복적으로 호출될 메서드 이름만 콜백 형태로 노출시킵니다.
이러한 환경에서는 쓰레드 자체를 신경 쓰지 않고 개발할 수 있습니다. 뿐만 아니라 쓰레드를 감추고 보다 친절한 인터페이스를 제공하는 타이머, 핸들러, asynkTask의 경우도 직접 쓰레드를 제작하지 않습니다.

하지만 직접 구현해야 하는 경우도 많이 있는데, 실무적으로 봤을 때 가장 큰 이유는 성능보다 블록킹을 막기 위해서 입니다.

동기화된 로직은 하나의 루틴이 완료될 때 까지 프로세스를 완전히 먹통으로 만들기 때문에 응답성을 확보하려면 반드시 쓰레드로 보내야 합니다.

안드로이드의 응답성 제약과 동시성 문제

안드로이드는 OS수준에서 5초 이상의 블록킹을 감지하면 app자체를 정지시키는 강력한 응답성 정책을 쓰고 있습니다.

이는 주요 브라우저가 자바스크립트에 대해 15초 정도의 블록킹 타임을 허용하는 것에 비해 훨씬 강력한 정책입니다.

게다가 모바일 환경은 통신도 불안정하고 머신 파워도 매우 약한데 5초 내에 반드시 응답성을 회복하라는 것은 다시 말하면 쓰레드를 남발하라 라고 말하는 것입니다.

쓰레드를 남발하는 것은 큰 문제가 있습니다.

  1. 일단 동시성 문제로 인해 프로그래밍의 난이도를 급격하게 올립니다.
    간단한 함수와 변수가 순식간에 동기화 이슈에 휘말리면서 걷잡을 수 없이 복잡해집니다.
  2. 또한 디버깅도 까다롭게 변합니다.
    멀티 쓰레드 환경 내에서 디버깅하는 것은 사실 불가능하다고 봐도 무방합니다.
    모든 에러가 런타임에서 발생하기 때문에 애당초 추적하기 매우 힘들고, 그 런타임 에러가 상호 관계하는 복수의 쓰레드 관계 내에서 발생하기 때문에 최종 브레이크 포인트에서의 결과물이 어떻게 만들어진 것인지 추적하는 것은 거의 인간 한계의 도전입니다.
  3. 사람만 힘들게 하는 것으로 끝나지 않습니다.
    제한된 자원을 갖고 있는 모바일 머신에서 쓰레드를 남발하는 것은 일단 불가능합니다.
    다수의 쓰레드를 제어하기 위한 비용도 막대하게 발생해 폰의 경우 배터리가 급속히 소진되고 발열이 심해집니다.

따라서 응답성을 확보하되 적당한 수의 쓰레드만 운영하는 형태가 가장 이상적입니다. 정해진 갯수의 쓰레드만 사용하는 포괄적인 형태를 쓰레드 풀이라 부르는 반면 역할을 나눠 쓰레드 생성을 억제하고 작업을 할당하여 재활용하는 병행알고리즘 패턴의 이름이 Worker Thread입니다.

Worker Thread 패턴

이 패턴에는 총 4가지의 역할이 등장합니다.

  1. 채널이라 불리는 쓰레드풀 관리자가 있고
  2. 여기에 등록되어 실제 작업을 수행하는 워커가 등장합니다.
  3. 실제 워커가 처리할 작업을 요청이라 하는데 보통 Runnable의 구상체입니다.
  4. 마지막으로 요청을 생성하고 채널에게 등록할 클라이언트가 있습니다.

자바 동시성 패키지는 각 역할별 클래스 중 채널 클래스를 지원함 워커를 자바 내장 Thread로 사용할 수 있게 해줍니다.

사실 채널 클래스가 이 패턴에서 가장 구현하기 어렵기 때문에 개발자가 직접 작성하기 보다 자바 내장 클래스를 사용하는 편이 훨씬 안전합니다.

따라서 실무적으로 개발자가 구현할 역할은 요청과 클라이언트입니다.

간단한 예를 구현해보면서 실체를 바라보기 위해 다음과 같은 요구사항을 생각해보죠.

  • 웹 서버로부터 데이터를 다운로드 받아 저장한다.
  • 다운로드 인해 응답성에 영향을 주지 않도록 한다.
  • 여러 개를 다운로드 할 수 있고 동시에 다운로드 할 최대 수량을 정할 수 있다.
  • 다운로드를 거는 시점은 정해져 있지 않고 실행 중 아무 때나 일어날 수 있다.

위와 같은 요구사항은 매우 현실적이며 실무적입니다. 무엇보다 이 패턴을 적용하기에 매우 적합합니다.

어찌 보면 실무 상 작성해야 하는 대부분의 코드는 응답성 문제를 해결하기 위해 거의 이 패턴에 의존해야 할지 모릅니다.

클라이언트

우선 호스트 코드에서 가장 중심이 되는 클라이언트입니다.

클라이언트는 다양한 형태가 있지만 결국 그 실체는 간단합니다.

  1. 요청을 생성하여
  2. 채널에 넘겨주는 것이죠.

클라이언트는 저 역할만 수행하기 때문에 실제 처리할 일은 전부 요청에 기술해야 합니다.

static ExecutorService _channel = Executors.newFixedThreadPool( 3 );

void request( String $url ){
    _channel.execute( new Request( $url ) );
}

위 코드에 등장한 ExcutorService가 채널 역할을 수행하고 execute하는 순간 내부적으로 워커를 생성하기 때문에 저 짧은 코드로 순식간에 채널, 워커, 클라이언트를 해결했습니다 ^^;

채널 생성 시 넘겨준 3이라는 인자는 최대 3개의 쓰레드만 사용하고 그 이상의 요청이 오는 경우 대기하여 완료된 쓰레드가 생기면 처리하는 식으로 지연처리 하겠다는 의미입니다.

결국 클라이언트는 요청을 만들어 채널에게 넘겨줄 뿐입니다.

요청

ExecutorService를 쓰는 이상 요청은 반드시 Runnable의 구상체여야 합니다. 또한 실질적으로 쓰레드에서 실행하게 되는 것도 요청입니다. 이 패턴 전체에서 주인공은 요청인거죠.

그럼 최초 요구사항에서 제시한 웹서버에 자원을 다운로드하여 저장하는 모든 임무는 죄다 요청이 처리해야 합니다.

class Request implements Runnable{

	private String _url;

	Request( String $url ){
		_url = $url;
	}

	@Override
	public void run(){
		sdWrite( new URL( _url ).openConnection().connect() );
	}
}

sdWrite 함수는 이 글의 주제와 다소 무관하니 생략합니다 ^^;

요청 클래스는 결국 해당 url의 자원을 받아와 기록하기 되는데 이 과정이 원래는 동기화이므로 블록킹을 일으키게 됩니다. 하지만 워커가 별도의 쓰레드에서 부르기 때문에 블록킹이 되지 않고 메인 쓰레드는 풀려나게 됩니다.

이렇게 요청의 실행이 완료되면 채널은 해당 워커를 회수하여 재활용합니다.

결론

Worker Thread 패턴은 제한된 쓰레드로 다수의 요청을 처리하는 정형화된 방법론 입니다.

자바에서는 이를 위한 내장라이브러리를 제공하고 있으므로 개발자는 세부사항을 몰라도 뛰어난 성능의 쓰레드 풀을 구현할 수 있습니다.

이제 동기화 되어있던 많은 작업을 비동기화 하면서 동시에 제한된 쓰레드로 통제하여 응답성과 성능을 동시에 높여보도록 합시다.

%d 블로거가 이것을 좋아합니다: