본문 바로가기

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

[자바] Future 패턴

future라는 것은 "미래", "(경제 용어로는)선물"이라는 의미입니다. 실행 결과를 얻기까지 시간이 걸리는 메소드가 있다고 합시다. 실행 결과를 얻기까지 기다리는 대신 "교환권"을 받게 됩니다. 교환권을 받는 것에는 시간이 걸리지 않습니다. 이 때의 "교환권"을 Future라고 부릅니다.


Future라고 하는 교환권을 받은 쓰레드는 나중에 Future를 사용해서 실행 결과를 받으러 갑니다. 그것은 교환권을 가지고 케이크를 받으러 가는 것과 비슷합니다. 만일 실행 결과가 나와 있으면 바로 그것을 받습니다. 준비가 되지 않으면 준비가 될 때까지 기다리게 됩니다. 


Future는 케이크의 교환권, 예매권, 정리권, "미래"에 현금으로 바꿀수 있는 약속 어음이라고 생각하면 됩니다.





Thread-Per-Message 패턴에서 리퀘스트를 낼때마다 쓰레드를 만드는 에제 프로그램이 있었습니다. 거기에서는

host.request(10, 'A');

와 같이 리퀘스트를 낼 뿐 되돌아오는 값을 얻는 것은 아니었습니다. Future 패턴에서는 리퀘스트를 내면 바로 되돌아오는 값을 받아들입니다.

Data data = host.request(10, 'A');

와 같이 되돌아오는 값(data)이 있는 것입니다. 그러나 이 되돌아오는 data는 리퀘스트를 실행한 결과 그 자체는 아닙니다. 실행 결과를 얻기 위한 처리는 바로 지금 별도의 쓰레드에서 시작되었기 대문입니다. 이 되돌아오는 값은 케이크 그 자체가 아니라 케이크의 교환권(Future)인 것입니다. 조금 후에 쓰레드는 

data.getContent()

와 같이 data의 getContent 메소드를 사용해서 실행 결과를 가지러 갑니다. 이것은 교환권을 사용해서 주문한 케이크를 받는 것에 해당합니다. 만일 다른 쓰레드에 의한 처리가 완료되어 있다면 getContent를 호출한 쓰레드는 곧 바로 이 쓰레드에서 되돌아옵니다. 아직 완료되어 있지 않으면 될 때까지 기다립니다.


여기까지가 Future 패턴의 대략적인 줄거리입니다.


 이름 

 해설

 Main

 Host에 리퀘스트를 내서 데이터를 얻는 클래스

 Host

 리퀘스트에 대해서 FutureData의 인스턴스를 돌려주는 클래스

 Data

 데이터를 액세스하는 방법을 표현한 인터페이스, FutureDatadhk RealData를 구현합니다.

 FutureData

 RealData의 교환권이 되는 클래스. 다른 쓰레드로 RealData의 인스턴스를 만듭니다

 RealData

 실제의 데이터를 표현한 클래스. 생성자 처리에 시간이 걸립니다.





Main 클래스


Main 클래스는 3개 request 메소드를 호출합니다. 그리고 세 개의 Data를 되돌아오는 값으로서 받아들입니다(data1, data2, data3). 이 되돌아오는 값은 실제로는 FutureData의 인스턴스이고 얻는 데에 시간은 걸리지 않습니다. 이것은 케이크의 교환권 같은 것입니다. 교환권을 세 장이나 받은 것입니다.


그 후 약 2초 sleep하고 있습니다. 이것은 무엇인가 다른 일을 하고 있다는 것을 표현하기 위한 처리입니다.


그 후 방금 받아들인 값 data1, data2, data3의 getContent 메소드를 사용해서 정말로 갖고 싶었던 것(request 메소드의 처리 결과)을 얻습니다. 교환권을 사용해서 케이크를 얻고있는 것입니다.

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

		System.out.println("main otherJob BEGIN");
		try {
			Thread.sleep(2000);
		} catch (InterruptedException e) {
		}
		System.out.println("main otherJob END");

		System.out.println("data1 = " + data1.getContent());
		System.out.println("data2 = " + data2.getContent());
		System.out.println("data3 = " + data3.getContent());
		System.out.println("main END");
	}
}



Host 클래스


Host 클래스는 우선 최초로 FutureData의 인스턴스(future)를 만듭니다. 이 것에는 시간이 걸리지 않습니다. 이 인스턴스가 되돌아오는 값이 됩니다.


다음으로 새로운 쓰레드를 기동해서 그 중에서 RealData의 인스턴스(realdata)를 만듭니다. RealData의 인스턴스를 만드는 처리에는 시간이 걸리지만 이 처리는 새로운 쓰레드가 합니다. 여기서 말하는 "새로운 쓰레드"는 케이크를 만드는 케이크 가게에 해당합니다.


새로운 쓰레드는 RealData의 인스턴스(realdata)를 열심히 만듭니다. realdata가 만들어지면 setRealData 메소드를 불러내어 future 필드에 set 합니다. realdata를 만드는 데에는 시간이 걸리므로 이 set이 행해지는 것은 조금 나중(미래)의 일입니다. future에 realdata를 set하는 것은 교환권을 가진 손님이 오는 경우를 위해 케이크를 준비해두는 것과 같습니다.


그럼 request 메소드를 호출한 쓰레드(메인 쓰레드)는 새로운 쓰레드를 기동하면 future를 되돌아오는 값으로 해서 바로 돌아옵니다. 이것은 교환권을 받은 손님이 케이크가 다 만들어 질 때까지 기다리지 않고 돌아가는 것과 같은 것입니다.


결국 request를 실행하는 쓰레드(결국 케이크를 사러 오는 손님)은 아래의 세 개의 일을 하는 것이 됩니다.


1. FutureData의 인스턴스를 만든다.

2. RealData의 인스턴스를 만들기 위한 새로운 쓰레드를 기동한다.

3. FutureData의 인스턴스를 돌아오는 값으로 한다.


이 세 가지 일은 어느 것이나(RealData의 인스턴스 작성에 비교하면) 시간이 걸리지 않는 처리입니다. 그러므로 request를 호출한 쓰레드는 곧바로 이 메소드에서 돌아오게 됩니다.

public class Host {
	public Data request(final int count, final char c) {
		System.out.println(" request(" + count + ", " + c + ") BEGIN");

		// 1. FutureData의 인스턴스를 만든다.
		final FutureData future = new FutureData();

		// 2. RealData의 인스턴스를 만들기 위한 새로운 쓰레드를 기동한다.
		new Thread() {
			public void run() {
				RealData realdata = new RealData(count, c);
				future.setRealData(realdata);
			}
		}.start();

		System.out.println(" request(" + count + ", " + c + ") END");

		// 3. FutureData의 인스턴스를 돌아오는 값으로 한다.
		return future;
	}
}


인수와 지역 변수는 쓰레드에 고유

request 메소드는 synchronized가 되어 있지 않지만 복수의 쓰레드에서 호출되어도 안전합니다. request에서 사용되고 있는 인수(count, c)나 지역 변수(future)는 이 request 메소드를 호출한 쓰레드 각각에 고유하며 복수의 쓰레드에 공유되어 있지는 않기 때문입니다.


final과 익명 내부 클래스

request 메소드의 인수(count, c)나 지역 변수(future)가 final이 되어 있는 것은 익명 내부 클래스에서 이용하고 있기 때문입니다.




Data 인터페이스


Data는 데이터로 액세스하는 방법(getContent 메소드)을 표현한 인터페이스 입니다. 이 인터페이스를 FutureData 클래스와 RealData 클래스로 구현합니다.

public interface Data {
	public abstract String getContent();
}



FutureData 클래스


FutureData 클래스는 "교환권"이 되는 클래스입니다.


realdata 필드는 앞으로 만들어진 RealData의 인스턴스를 보유하는 필드입니다. 이 필드는 setRealData 메소드에 의해 대입됩니다.


ready 필드는 realdata에 값이 대입되는지를 나타내는 필드입니다. true면 realdata에 값이 대입된 것(케이크가 다 만들어졌다)을 나타냅니다.


setRealData의 메소드는 RealData의 인스턴스를 realdata에 대입하는 메소드입니다. RealData의 인스턴스가 만들어지게 되어서 ready 필드를 true로 하고 getContent 메소드에 기다리고 있는 쓰레드가 있으면 깨우기 위해서 notifyAll 합니다.


setRealData 메소드는 Host 클래스의 request 메소드 중에서 새롭게 만들어진 쓰레드에서 호출됩니다. Balking 패턴을 사용해서 두 개 이상 setRealData가 불려지지 않도록 방지하고 있습니다(이 처리가 반드시 필요한 것은 아닙니다)


getContent 메소드는 실제의 데이터를 얻기 위한 메소드입니다. setRealData에 의해서 realdata가 set되는 것을 기다리기 위해 ready를 가드 조건으로 하고 Guarded Suspension 패턴을 사용하고 있습니다. 그리고 그 후에는

return realdata.getContent();

이라는 문으로 실제의 데이터를 얻고 있습니다. FutureData 클래스의 getContent 메소드가 RealData 클래스의 getContent 메소드에게 일을 위임하고 있는 것입니다.


만일 RealData의 인스턴스가 set되어 있다면 getContent에서 바로 되돌아옵니다. 그러나 아직 완성되지 않았다면 완성될 때까지 wait에서 기다립니다. wait에서 기다리고 있는 쓰레드는 setRealData 중에서 호출되어 있는 notifyAll로 깨워집니다.


케이크의 교환권을 받은 후에 바로 케이크를 받으러가도 아직 다 만들어져 있지 않을지도 모릅니다. 그 때 손님은 케이크 가게에서 기다리게 됩니다. "오래 기다리셨습니다. 케이크가 다 만들어졌어요"라는 주인의 소리는 기다리고 있는 손님에게는 notifyAll인 것입니다.

public class FutureData implements Data {
	private RealData realdata = null;
	prviate boolean ready = false;

	public synchronized void setRealData(RealData realdata) {
		if (ready) {
			return; // balk
		}
		this.realdata = realdata;
		this.ready = true;
		notifyAll();
	}
	public synchronized String getContent() {
		while(!ready) {
			try {
				wait();
			} catch(InterruptedException e) {
			}
		}
		return realdata.getContent();
	}
}


RealData 클래스


RealData 클래스는 인스턴스를 만드는데 시간이 걸리는 클래스입니다. count개의 문자 c에서 표현되는 String의 인스턴스를 만드는 것이 생성자의 일입니다. 시간이 걸리는 것을 표현하기 위해서 sleep을 사용하고 있습니다. 일부러 지연시켜 만든 결과는 contnet 필드에 String 인스턴스로 저장됩니다.


getContent 메소드는 content 필드의 내용을 되돌릴 뿐입니다.


이 클래스에는 synchronized가 전혀 등장하지 않는 것에 주의하세요. 이를테면 이 클래스에는 "인스턴스가 생길 때까지 기다린다"라는 쓰레드의 제어는 포함되어 있지 않습니다. RealData는 멀티 쓰레드에 대해서는 아무것도 생각하고 있지 않아도 되는 것입니다.


Future 패턴은 이러한 "시간이 걸리는 처리를 포함하고 있는 보통의 클래스"에 대해서 "교환권"을 만들어 주고 멀티 쓰레드화해서 수행 능력을 높이는 패턴 입니다.

public class RealData implements Data {
	private final String content;
	public RealData(int count, char c) {
		System.out.println("making RealData(" + count + ", " + c + ") BEGIN");
		char[] buffer = new char[count];
		for (int i = 0; i < count; i++) {
			buffer[i] = c;
			try {
				Thread.sleep(100);
			} catch (InterruptedException e) {
			}
		}
		System.out.println("making RealData(" + count + ", " + c + ") END");
		this.content = new String(buffer);
	}
	public String getContent() {
		return content;
	}
}


세 개 있는 request 메소드의 호출이 바로 종료되는 것은 Thread-Per-Message 패턴의 예제 프로그램과 같습니다.


더욱이 이 예제 프로그램에서는 RealData의 인스턴스가 새로운 쓰레드로 만들어진 모습도 알 수 있습니다. 메인 쓰레드가 다른 처리를 하고 있는 사이에도 RealData의 인스턴스는 차례차례 만들어지고 있습니다.


그리고 메인 쓰레드가 getContent 메소드를 부르고 있습니다. RealData가 작성되고 있으면 내용을 바로 표시합니다. 작성되지 않으면 작성될 때까지 기다리고 나서 내용을 표시합니다.



Future 패턴의 구성요소



Client(의뢰자)

Client는 Host에 대해서 리퀘스트(request)를 냅니다. 그 리퀘스트의 결과(되돌아오는 값)로서 Client는 바로 VirtualData를 받아들입니다.


다만 여기에서 받아들인 VirtualData는 실제로는 Future입니다. 말하자면 Future가 VirtualData의 가면을 쓰고 있는 것입니다. Client는 되돌아오는 값이 RealData인지, Future인지 알 필요는 업습니다. 나중에 Client는 그 VirtualData를 통해서 조작을 합니다.



Host

Host는 새로운 쓰레드를 만들어 RealData를 만들기 시작합니다. 한편 Client에게는 Future를 (VirtualData로서) 보냅니다. 예제 프로그램에서는 Host 클래스가 이 역할을 합니다.


새로운 쓰레드는 RealData를 만들면 Future에 RealData를 set합니다.



VirtualData(가상 데이터)

VirtualData는 Future과 RealData를 동일시시킵니다. 예제 프로그램에서는 Data 인터페이스가 이 역할을 합니다.



RealData(실제 데이터)

RealData는 실제의 데이터를 나타냅니다. 이 객체를 만드는 데에는 시간이 걸립니다. 예제 프로그램에서는 RealData가 이 역할을 합니다.



Future

Future는 RealData의 "교환권"으로 Host에서 Client에 건네집니다.


Future는 Client에 대해서는 VirtualData로서 됩니다. 실제로 Clinet에서 조작되는 경우 RealData가 완성되기까지 쓰레드는 wait에서 기다립니다. 그러나 RealData가 만들어지면 기다리지 않습니다. Future는 Client에서의 조작을 RealData에 위임합니다.





스루풋(Throughtput)은 향상하는 것일까?


Thread-Per-Message 패턴에서 처리 결과는 얻어지지 않았습니다. 그러나 Future 패턴에서는 Thread-Per-Message 패턴의 좋은 응답성을 가지고 있으면서 처리의 결과를 얻을 수도 있습니다.


그러나 이러한 의문이 생길지도 모릅니다. "응답성이 좋아지는 것은 납득할 수 있다. 그러나 스루풋이 변하지 않을 것 같은데, 모든 겨로가를 얻은 마지막까지 고려하고 있다면 싱글 쓰레드에서 실행해도, 멀티 쓰레드로 실행해도 모든 결과를 얻기까지 걸리는 시간은 같지 않을까. 왜냐하면 멀티 쓰레드로 했다고 해서 처리에 걸리는 시간의 총계가 짧아지는 것은 아니니까..."


이 의문은 반은 맞고 반은 틀렸습니다.


확실히 멀티 쓰레드로 했다고 해서 처리에 걸리는 시간의 총계가 짧아지는 것은 아닙니다. 문제는 처리에 걸리는 시간을 부담하고 있는 것은 어떤 쓰레드인가 입니다.


단일 CPU로 동작하고 있는 자바 실행 처리계에서는 단순한 계산을 멀티 쓰레드로 해도 Throughput은 향상되지 않겠지요. 복수의 쓰레드에 계산을 분담시켰다고 해도 결국 계산을 하는 것은 하나의 CPU이니까요.


쓰레드의 수에 따라서 OS가 자바 실행 처리계 전체에 할당하는 시간이 변화할지도 모르므로 throughput이 어떻게 될지를 정확히 이야하기 하는 것은 실제로 어렵습니다.


그러나 입출력(I/O) 처리가 관련되면 이야기는 달라집니다. 예를 들어 하드디스크에 읽고 쓰기를 할 때 모든 일은 CPU가 하는 것은 아닙니다. 하드웨어가 데이터의 읽고 쓰기 작업을 하고 있을 때 CPU는 그 작업의 완료를 기다리고 있을 뿐입니다. 그 때는 CPU에는 "비는 시간"이 있습니다. 그 빈시간을 다른 쓰레드에게 할당해서 처리를 먼저 하도록 할 수 있게 되면 스루풋이 향상됩니다.



비동기적인 메소드 호출의 "반환값"


자바 메소드 호출은 모두 동기적(synchronous)입니다. 다시 말해 일단 메소드를 호출하면 그 메소드의 실행이 끝나서 되돌아오기까지는 먼저 진행되지 않습니다.


Thread-Per-Message 패턴은 메소드 중에서 쓰레드를 새롭게 기동해서 비동기적(asynchronous)인 호출을 예외적으로 실현하고 있습니다.


물론 아무리 Thread-Per-Message 패턴을 사용해도 자바 메소드의 호출 자체는 동기적입니다. 그러나 기ㅏ대한 처리가 완료하지 않아도 호출하는 쪽의 처리를 먼저할 수 있는것입니다. 비동기적인 호출을 예외적으로 실현하고 있다는 것은 그런 의미입니다.


Thread-Per-Message 패턴을 사용하는 것만으로는 처리의 결과를 얻을 수 없습니다. Future 패턴을 사용해서 "처리의 결과를 나중에 set한다"는 것에 의해 비동기적인 메소드 호출의 "반환값"이 얻어지는 것입니다.



"반환값의 준비"와 "반환값의 이용"을 분리


Worker Thread 패턴에서 "invocation과 execution의 분리"라는 이야기를 했습니다. 거기에서는 메소드를 기동하는(invoke) 것과 메소드를 실행하는(execute) 것을 분리하여 다른 쓰레드로 처리하는 것을 배웠습니다.


Future 패턴에 의해서 "메소드의 반환값을 준비해 둘 것"과 "메소드의 반환값을 이용할 것"도 분리할 수 있는 것을 알았습니다. 예제 프로그램으로 이야기하자면 RealData의 인스턴스를 만드는 것이 "메소드의 반환값을 준비하는 것"에 해당하고 getContent 메소드를 호출하는 것이 "메소드의 반환값을 이용하는 것"에 해당합니다. 그리고 이 "반환값의 준비"와 "반환값의 이용"도 다른 쓰레드로 처리할 수 있는 것입니다.


메소드의 호출에 따른 일련의 처리를 마치 슬로우 모션으로 재생하듯이 따로따로 분해해봅시다. 그리고 분해한 각각의 처리(기동, 실행, 반환값의 준비, 반환값의 이용)를 쓰레드에 분배해 봅시다. 우리들은 멀티 쓰레드라는 도구를 사용해서 그러한 일을 하고 있는 것입니다.


메소드에 관한 처리는 한가지더 존재합니다. "예외가 던져져서 반환값이 얻어지지 않는다"라는 케이스입니다.



변형 - 기다리게 하지 않는 Future


예제 프로그램에서는 FutureData의 getContent 메소드를 부를 때에 RealData의 인스턴스가 아직 만들어 있지 않으므로 Guarded Suspension 패턴을 사용해서 "생길 때까지 기다리고" 있었습니다. 생길 때까지 기다리는 것이므로 FutureData의 getContnet 메소드에서 돌아올때에는 필요한 정보를 가지고 있습니다.


그러나 getContent 메소드를 비동기적으로 구현하는 것도 가능합니다. 그렇다고 해도 getContet 안에서 새로운 쓰레드를 만든다는 것이 복잡한 이야기는 하닙니다.  Balking 패턴을 사용해서 "다 만들어져 있지 않으면 일단 돌아간다"처럼 하는 것입니다. RealData의 인스턴스가 만들어있지 않으면 일단 돌아가서 자기의 일을 조금하고 또 다음에 getContent를 부르도록 하는 것입니다.


이것은 Future 패턴의 변형 중 하나입니다.



변형 - 변화하는 Future


Future에 "반환값"이 set되는 것은 통상 한 번뿐입니다. 결국 Future는 "딱 한번만 상태가 변하는 변수" 같은 것입니다. 그런 Future에 대해서 반복해서 "반환값"을 set하고 싶어질 때도 있습니다. 말하자면 Future에는 "현시점에서의 환산값"이 항상 set되어 있다는 것입니다.


예를 들어 화상 데이터를 네트워크를 경유하여 얻었다고 합시다. 처음에 화상의 가로 세로 길이를 알고 다음으로 거친 화상 데이터를 얻은 후 마지막에 선명한 화상데이터를 얻게 되는 때가 있습니다. 그런 경우에 Future를 이용할 수 있을지도 모르겠습니다.



멀티 쓰레드를 의식하고 있는 것은 누구인가(재사용성)


예제 프로그램의 RealData 클래스에 주목해보겠습니다. RealData는 멀티 쓰레들 고려하고 있지 않는, 말하자면 보통 클래스입니다. RealData는 중요한 처리를 합니다. 만일 RealData를 Host 클래스에서 직접 사용하면 응답성이 낮은 프로그램이 되어 버립니다.


Thread-Per-Message 패턴과 Future 패턴을 사용해서 응답성을 높일 수 있게 됩니다. 그런데 RealData를 둘러싸고 있는 클래스들(Data, Host, FutureData)의 어디에 "멀티 쓰레드에 관한 처리"가 들어있는지 주목 합니다.


Data 인터페이스는 멀티 쓰레드를 의식하고 있지 않습니다.


Host 클래스는 Thread-Per-Message 패턴을 사용해서 새로운 쓰레드를 기동하고 있으므로 멀티 쓰레드를 의식하고 있습니다.


FutureData는 물론 멀티 쓰레드를 의식하고 있습니다. setRealData 메소드와 getContent 메소드에 Guarded Suspension 패턴이 쓰이고 있습니다.


그러면 RealData 클래스를 다시 한번 보면 이 클래스는 멀티 쓰레드를 의식하고 있지 않습니다. 멀리 쓰레드에 관한 부분은 Host 클래스와 FutureData 클래스 안에 정리되어 있어서 RealData 클래스에는 영향이 미치지 않습니다. 이 점은 기존의 클래스에 Future 패턴을 적용할 때에 중요한 것입니다.



* Open Call 패턴

"Host 클래스는 멀티 쓰레드를 의식하고 있다"는 부분에서 조금 해설이 필요합니다. Host 클래스의 request 메소드는 synchronized 가 되어 있지 않지만 안전하게 복수 쓸에드에 호출할 수 있습니다. Host 클래스는 필드를 가지고 있지 않기(결국 상태를 가지고 있지 않기) 때문에 복수 쓰레드에서 액세스되어도 안정성이 무너지는 일이 없기 때문입니다. Immutable 패턴의 특수한 형태라고도 할 수 있겠지요. 또한 Open Call 패턴의 특수한 형태이기도 합니다. Open Call 패턴은 Host가 상태를 가지고 있을 때 상태 갱신의 부분만을 가드하는 패턴입니다.



콜백 Future 패턴


처리의 완료를 기다려셔 환산값을 얻고 싶을 때 콜백이라는 방법도 생각해 볼 수 있습니다. 콜백이라는 것은 처리를 완료했을 때 Host가 기동한 쓰레드가 Client의 메소드를 호출하는 방법입니다. 단지 이경우에는 Client라도 멀티 쓰레드를 의식해야 합니다. 구체적인 것은 환산값을 안전하게 주고받기 위한 코드가 Client에 만들어져 있어야할 필요가 있다는 것입니다.




Thread-Oer-Message 패턴을 사용해서 시간이 걸리는 처리르 별도의 쓰레드에 밭기도 응답성을 높이고 있습니다. 그러나 다른 쓰레드가 처리한 결과도 또한 필요합니다. 시간이 걸리는 처리를 동기적으로 처리하면 응답성이 나빠지게 됩니다. 그러나 비동기적으로 처리를 개시해도 그 시점에서는 결과를 알 수 없습니다.


그런때에 Future 패턴을 사용합니다. 그리고 처리의 개시 시점에는 Future를 인터페이스(API)로 갖는 Future를 만듭니다. 그리고 처리의 개시 시점에서는 Future를 환산값으로 합니다. 다른 쓰레드의 처리가 완료되면 그 결과를 Future에 set합니다. Client는 Future를 사용해서 처리의 결과를 얻을 수 있습니다.


이 패턴을 사용하면 응답성을 떨어뜨리는 일 없이 처리의 결과를 얻을 수 있게 됩니다.