본문 바로가기

프로그래밍(TA, AA)/JVM 언어

[자바] Thread-Per-Message 패턴

thread per message를 직역하면 "메세지마다의 쓰레드"라는 의미가 됩니다. message라는 것은 여기서는 "명령"이라고 생각해도 좋고 "요구"라고 생각해도 좋습니다. 어떤 명령이나 요구마다 새롭게 하나의 쓰레드가 할당되어 그 쓰레드가 처리를 행합니다. 이것이 바로 Thread-Per-Message 패턴입니다.


Thread-Per-Message 패턴을 사용하면 메시지는 "의뢰하는 측"과 메시지를 "처리하는 축"이 다른 쓰레드로 되어 있습니다. 메시지를 의뢰하는 측의 쓰레드가 처리하는 측의 쓰레드에 "이 일 좀 해주세요"라고 맡기는 것입니다.


Thread-Per-Message 패턴을 사용한 예제 프로그램을 읽어 보겠습니다. 예제 프로그램에서는 Main 클래스가 Host 클래스로 문자의 표시를 의뢰합니다. Host 클래스는 그 의뢰를 처리하는 쓰레드를 생성해서 기동합니다. 기동시킨 쓰레드는 Helper 클래스를 사용해서 실제의 표현을 행합니다.


 이름

 해설

 Main

 Host에 문자 표현 요구를 하는 클래스

 Host

 요구에 대해서 쓰레드를 생성하는 클래스

 Helper

 문자 표시라는 기능을 제공하는 수동적인 클래스



Main 클래스


Main 클래스는 우선 Host 클래스의 인스턴스를 작성합니다. 그리고 Host의 request 메소드를 호출하고 있습니다. 다음 호출은 A라는 문자를 10번 표시한다는 의미입니다.

host.request(10, 'A');


쓰레드 실행의 모습을 알 수 있도록 main 메소드의 처음과 끝에 디버그 프린트를 넣었습니다.

public class Main {
	public static void main(String[] args) {
		System.out.println("main BEGIN");
		Host host = new Host();
		host.request(10, 'A');
		host.request(20, 'B');
		host.request(30, 'C');
		System.out.println("main END");
	}
}



Host 클래스


Host 클래스에서는 request 메소드 중에서 새롭게 쓰레드를 기동합니다. 실제의 처리는 그 쓰레드가 하는 것입니다. 자바의 익명 내부 클래스(anonymouse inner class)를 사용해서 Thread의 서브 클래스 인스턴스를 생성하고 그것을 사용해서 쓰레드를 기동하고 있습니다.


익명 내부 클래스의 구문은 익숙해지지 안으면 읽기 어렵지만 익숙해지면 그렇게 어렵지 않습니다. 아래의 문장을 꼼꼼히 관찰해 봅시다.

new Thread() {
	public void run() {
		helper.handle(count, c);
	}
}.start();


이 문장은 클래스의 선언, 인스턴스의 생성, 게다가 쓰레드의 기동을 정리해서 기술하고 있다고 생각하면 알기 쉬울 것입니다. 익명 내부 클래스는 클래스의 선언과 인스턴스의 생성을 정리해서 기술하고 있습니다. 그러나 실행시에 클래스 파일을 만들고 있는 것은 아닙니다. 익명 내부 클래스도 통상의 클래스와 마찬가지이고 컴파일 할 때 클래스 파일이 만들어집니다.


위의 문장을 생략해서 보면 확실히 클래스 선언의 일부(run이라는 메소드의 선언)처럼 보입니다. 이번에는 위에서 생략한 부분만을 보면 Thread의 인스턴스 생성과 쓰레드 기동처럼 보입니다. 익명 내부 클래스를 사용해서 다음과 같은 일을 합니다.


 - run 메소드를 오버라이드한 Thread의 서브 클래스를 선언

 - 그 클래스의 인스턴스를 생성

 - 그 인스턴스의 start 메소드를 불러 쓰레드를 기동


익명 내부 클래스의 run 메소드에서 request 메소드에 건내어져 있는 인수 count와 c를 사용하고 있는 것에 유의해서 살펴봅니다. 이와 같이 익명 내부 클래스 중에서 메소드의 인수나 지역 변수를 이용하는 경우에 변수는 final로 선언해둬야 합니다. count와 c를 final로 선언하지 않으면 컴파일 에러가 발생합니다.

public class Host {
	private final Helper helper = new Helper();
	public void request(final int count, final char c) {
		System.out.println(" request(" + count + ", " + c + ") BEGIN");
		new Thread() {
			public void run() {
				helper.handle(count, c);
			}
		}.start();
		System.out.println(" request(" + count + ", " + c + ") END");
	}
}



Helper 클래스


Helper 클래스는 지정한 횟수만큼 실제로 문자로 표시하는 handle 메소드를 제공합니다. 또 천천히 표시하도록 하기 위해(handle의 처리는 시간이 걸린다는 것을 표현하기 위해) slowly 메소드 중에서 Thread.sleep을 사용하고 있습니다.

public class Helper {
	public void handle(int count, char c) {
		Sustem.out.println(" handle(" + count + ", " + c + ") BEGIN");
		for (int i = 0; i < count; i++) {
			slowly();
			System.out.print(c);
		}
		System.out.println("");
		System.out.println(" handle(" + count + ", " + c + ") END");
	}
	private void slowly() {
		try {
			Thread.sleep(100);
		} catch (InterrptedException e) {
		}
	}
}

Host 클래스 소스 내용을 살펴보면 handle 메소드가 종료하기도 전에 request 메소드가 종료하고 있는 것을 알 수 있습니다.


main 메소드를 실행하고 있는 메인 쓰레드는 host의 request 메소드를 불러서 바로 종료 합니다. "A 표시 부탁해!" "B 표시 부탁해!" "C 표시 부탁해!"라는 지시만을 내리고 자신은 끝나 버리는 것입니다. handle 메소드의 처리에 아무리 시간이 걸린다 해도 request 메소드의 응답성에는 영향이 없습니다. request 메소드는 handle 메소드의 실행 완료를 기다리지 않고 바로 돌아오기 때문입니다.


예제 프로그램의 시퀀스 다이어그램을 표시하면 아래와 같습니다. request 메소드를 호출한 쓰레드는 바로 되돌아가고 handle 메소드는 다른 쓰레드가 처리하고 있는 것을 알 수 있을 것입니다.


main BEGIN

request(10, A)  BEGIN

request(10, A)  END                // handle(10, A) BEGIN보다도 먼저 request(10, A)는 종료

request(20, B) BEGIN

request(20, B) END                // handle(20, B) BEGIN보다도 먼저 request(20, B)는 종료

request(30, C) BEGIN

request(30, C) END                // handle(30, C) BEGIN보다도 먼저 request(30, C)는 종료

MAIN END                                         // 메인 쓰레드는 여기서 종료된다.

handle(10, A) BEGIN          // 다른 쓰레드로 handle(10, A)이 처리되기 시작한다

handle(20, B) BEGIN         // 다른 쓰레드로 handle(20, B)이 처리되기 시작한다

handle(30, C) BEGIN         // 다른 쓰레드로 handle(30, C)이 처리되기 시작한다

handle(10, A) END

handle(20, B) END

handle(30, C) END




Host 클래스 안에서 새로운 쓰레드가 생성되고 있는 것을 알 수 있습니다. 덧붙여 말하면 만일 Host 클래스 안에서 새로운 쓰레드를 생성하지 않고 같은 쓰레드가 Helper 클래스를 호출하는 경우가 있을 수도 있습니다.




Thread-Per-Message 패턴 구성요소



Client(의뢰자)

Client는 Host에 대해서 요구(request)를 합니다. Host가 어떻게 해서 그 요구를 실현하고 있는지 Client는 알지 못합니다. 예제 프로그램에서는 Main 클래스가 이 역할을 합니다.


Host

Host는 Client에서 요구(request)를 받으면 쓰레드를 새롭게 만들어 기동합니다. 새롭게 작성된 쓰레드는 Helper를 사용해서 요구를 "처리(handle)"합니다. 예제 프로그램에서는 Host 클래스가 이 역할을 합니다.


Helper(원조자)

Helper는 요구를 처리(handle)하는 기능을 Host에 제공합니다. Host에 의해서 만들어진 새로운 쓰레드가 Helper를 이용합니다. 예제 프로그램에서는 Helper 클래스가 이 역할을 합니다.






응답성을 높이고 지연 시간을 줄인다


Thread-Per-Message 패턴을 사용하면 Client에 대한 Host의 응답성이 좋아지고 지연 시간이 줄어듭니다. 특히 handle의 처리에 시간이 걸리는 경우나 handle의 처리 중에 입출력을 기다리는 등의 상황이 발생하는 경우에 잘 알 수 있습니다. 응답성을 체감하기 위해서 GUI 응용프로그램의 예를 참고하면 좋습니다.


Thread-Per-Message 패턴을 사용하면 Host 내에서 새로운 쓰레드를 기동합니다. 쓰레드의 기동에는 시간이 걸리므로 응답성을 높일 목적으로 Thread-Per-Message 패턴을 사용할지 말지는 "handle의 처리에 걸리는 시간"과 "쓰레드의 기동에 걸리는 시간"과의 트레이드 오프가 됩니다.


쓰레드 기동에 걸리는 시간을 절약하기 위해서 Worker Thread 패턴이 사용되는 경우가 있습니다.




처리의 순서를 신경 쓰지 않아도 될 때에 사용한다


Thread-Per-Message 패턴에서 handle 메소드의 처리가 행해지는 순서는 request 메소드가 호출된 순서라고는 단정 지을 수 없습니다. 그러므로 처리의 순서가 의미를 가지는 경우에는 Thread-Per-Message 패턴을 사용하는 것은 부적절합니다.




되돌아오는 값이 불필요한 경우에 사용한다


Thread-Per-Message 패턴에서 request 메소드 측은 handle 메소드의 완료를 기다리지 않습니다. 그러므로 handle의 실행 결과를 request측에서 얻을 수는 없습니다. 따라서 Thread-Per-Message 패턴은 처리의 결과가 필요없는 경우에 사용하게 됩니다. 예를 들면 어떤 이벤트를 통지하는 경우 등을 생각해 볼 수 가 있습니다.


처리의 결과가 필요한 경우에는 Future 패턴을 사용합니다.




서버에 응용하기


복수의 요구를 처리하는 서버를 실현하기 위해서 Thread-Per-Message 패턴이 사용되는 일이 있습니다. 클라이언트에서 오는 요구는 일단 서버 본체의 쓰레드가 받아들입니다. 그리고 그 요구를 실제로 처리하는 것은 다른 쓰레드에 맡기고 서버 본체의 쓰레드는 다시 다른 클라이언트의 요구를 기다리는 상태로 돌아가는 것입니다. 작은 웹서버를 Thread-Per-Message 패턴을 사용해서 구현하는 예를 생각해 보면 좋습니다.




메소드 호출 + 쓰레드 기동 => 메시지 송신


보통은 메소드를 호출하면 거기에 쓰여 있는 처리가 모두 실행되고 나서 제어가 도아옵니다. Thread-Per-Message 패턴에 등장한 request 메소드도 보통의 메소드이므로 거기에 쓰여 있는 처리를 실행한 후에 돌아옵니다. 그러나 "request 메소드로 정말 하고 싶었던 것은 실행 되었을까" 한번 생각해 봅시다. request에서 돌아왔다고 해서 문자열의 표시가 완료했다는 것은 아닙니다. request는 기대한 처리를 개시하는 계기(트리거)가 되어 있지만 처리의 완료를 기다리는 것은 아닙니다.


이 사실은 어떻게 받아들이면 좋을까요. "request 메소드에서 쓰레드를 기동하고 있네"는 맞는 말이지만 그것은 하나의 받아들이는 방법에 지나지 않습니다. 메소드 호출과 쓰레드 기동을 이용해서 "비동기 메시지 송신"을 실현하고 있다는 것에 주목합니다. 이것은 조금 넓은 관점에서 프로그램을 보고 있는 것이 됩니다.


다르게 말하면 멀티 쓰레드판 Proxy 패턴 혹은 멀티쓰레드 Adapter 패턴이라고 말할 수 도 있을지 모릅니다.


예를 들면 Guarded Suspension 패턴을 생각해 보면 그 패턴은 "멀티 쓰레드판 if"라고 생각했었지요. while과 wait, notifyAll을 이용해서 "멀티 쓰레드판 if"를 실현한 것입니다.


그리고 Read-Write Lock 패턴에서도 비슷한 것이 있습니다. synchronized에 의한 "물리적"인 락으로는 유연성이 결여되기 때문에 synchronized를 사용해서 "읽기 위한 락과 쓰기 위한 락"을 실현한 형태입니다.





프로세스와 쓰레드


"자바 언어의 쓰레드"에서 쓰레드는 "프로그램을 실행하는 주체"라는 표현했습니다. 그러나 운영체제의 프로세스(process)도 프로그램을 실행하는 주체라고 말할 수 있습니다.


프로세스와 쓰레드의 관계는 플랫폼의 차이점(OS나 하드웨어의 차이)에 의해서 크게 달라집니다. 플랫폼이 같아도 자바 가상 머신(Java Virtual Machine, JVM)의 구현 방법에 따라 프로세스와 쓰레드의 관계는 달라집니다. 그러나 일반적으로 하나의 프로세스 안에서 복수의 쓰레드가 구축된다고 해도 맞는 말이 됩니다.



쓰레드는 메모리를 공유한다


프로세스와 쓰레드의 가장 큰 차이점은 메모리 공유의 유무입니다.


프로세스는 각각 독립된 메모리 공간을 가지고 있는 것이 보통입니다. 어떤 프로세스가 다른 프로세스의 메모리를 마음대로 읽는다든지 메로리에 쓴다든지 하는 것은 아닙니다. 프로세스의 메모리 공간은 각각 도립되어 있기 때문에 어떤 프로세스가 다른 프로세스 때문에 망가질 위험이 없는 것입니다.


쓰레드는 메모리를 공유합니다. 자바의 메모리 모델에서는 메인 메모리와 워킹 메모리라는 두 종류의 메모리가 등장합니다. 이 때에 쓰레드가 공유하는 것은 메인 메모리 쪽입니다. 하나의 쓰레드가 메모리 상에 정보를 쓰고 그것을 다른 쓰레드가 읽는 것은 자주 일어나는 일입니다. "메모리를 공유하고 있다"라는 것은 자바에서 말하자면 "인스턴스를 공유하고 있다"라는 의미입니다. 자바의 인스턴스는 메모리 상에 있고 복수의 쓰레드가 그 인스턴스를 읽고 쓸 수 있도록 되어 있습니다.


쓰레드가 메모리를 공유하고 있기 때문에 쓰레드 간의 통신은 매우 자연스럽고 간단히 실현될 수 있습니다. 어떤 쓰레드가 인스턴스에 정보를 써서 다른 쓰레드가 그 인스턴스에서 정보를 읽으면 되는 것입니다. 한편 같은 인스턴스에 복수의 쓰레드에서 접근하기 때문에 배타 제어를 바르게 해야합니다.



쓰레드는 context-switch가 가볍다


프로세스와 쓰레드의 또 하나의 차이점은 콘텍스트 스위치의 비용입니다.


동작중인 프로세스가 바뀔 때 프로세스는 현재 자신의 상태(context 정보)를 일단 보존합니다. 그리고 새롭게 동작 개시하는 프로세스는 이전에 보존해 두었던 자신의 컨텍스트 정보를 다시 복구합니다. 이와 같은 context-switch에는 비용이 듭니다.


동작 중의 쓰레드가 바뀔 때 쓰레드는 프로세스와 같이 문맥 교환을 행합니다. 그러나 쓰레드가 관리하고 있는 문맥 정보는 프로세스보다도 적기 때문에 쓰레드의 문맥 교환은 프로그램의 문맥 교환보다도 가볍게 행해지는 것이 보통힙니다.


하지만, 실제로 쓰레드와 프로세스의 실제의 관계는 자바 실행 처리계의 구현에 크게 의존합니다.


그러므로 빽빽히 연달아 쉬고 있는 복수의 작업을 행하는 경우에는 프로세스보다도 쓰레드 쪽이 좋은 경우가 많이 있습니다.




Client는 Host의 request 메소드를 호출하고 요구를 냅니다. 그 요구를 실제로 처리하기 위해서 Helper의 handle 메소드가 있습니다. 그러나 Client의 쓰레드를 사용해서 request 속에서 handle를 호출해 버리면 실제 처리가 완료하기까지 handle에서 돌아오지 않고 결과적으로 request에서 돌아오지 않게 됩니다. 이 때문에 request의 응답성이 떨어지게 됩니다. 거기에 요구를 처리하기 위한 새로운 쓰레드를 Host에서 기동합니다. 그리고 이 새로운 쓰레드가 handle의 호출을 합니다. 그렇게 되면 요구를 낸 쓰레드는 request에서 바로 되돌아 올 수 있습니다.


이러한 방법으로 비동기 메시지 송신을 실현하고 있는 것입니다.


'프로그래밍(TA, AA) > JVM 언어' 카테고리의 다른 글

[JVM Internal] JVM 메모리 구조  (1) 2018.05.21
[자바] Future 패턴  (0) 2018.05.13
[자바] Event-Dispatching Thread  (0) 2018.05.09
[자바] Worker Thread 패턴  (0) 2018.05.09
[자바] Guarded-Suspension 패턴  (1) 2018.05.03