본문 바로가기
Language/Java

[Java 8] CompletableFuture - 2 (비블록 코드)

by ocwokocw 2021. 2. 11.

- 출처: 자바 8 in action

- 비블록 코드 만들기

CompletableFuture 기본에서는 비행기 티켓의 출발지와 목적지에 따른 가격을 반환하였다. 이제 여러 개의 티켓의 가격을 검색해보자. 우선 아래와 같이 출발지 목적지 정보를 가지고 있는 Ticket 클래스를 생성한다.

 

public class Ticket {

	private String from;
	private String to;
	
	public Ticket(String from, String to) {
		super();
		this.from = from;
		this.to = to;
	}
	
	public String getFrom() {
		return from;
	}
	public void setFrom(String from) {
		this.from = from;
	}
	public String getTo() {
		return to;
	}
	public void setTo(String to) {
		this.to = to;
	}

	@Override
	public String toString() {
		return "Ticket [from=" + from + ", to=" + to + "]";
	}
}

 

앞에서 만든 API중에서 Future가 아닌 동기 방식으로 생성한 API를 사용해보자.

 

List<Ticket> tickets = Arrays.asList(
			new Ticket("KOR", "JPN"),
			new Ticket("KOR", "CHN"),
			new Ticket("KOR", "USA"),
			new Ticket("KOR", "KAZ"),
			new Ticket("KOR", "CHE"));
	
Airline airline = new Airline();

long startTime = System.currentTimeMillis();
	
List<String> ticketAndPrice = tickets.stream()
	.map(ticket -> {
		
		double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
		return ticket.toString() + ", price: " + price;
	})
	.collect(Collectors.toList());
	
long endTime = System.currentTimeMillis();
System.out.println("duration: " + (endTime - startTime) / 1000.0);
	
ticketAndPrice.stream()
	.forEach(System.out::println);

 

- 실행결과

 

duration: 5.056
Ticket [from=KOR, to=JPN], price: 0.16879170758291162
Ticket [from=KOR, to=CHN], price: 0.8368543707151848
Ticket [from=KOR, to=USA], price: 0.004179518687652317
Ticket [from=KOR, to=KAZ], price: 0.43799343943281366
Ticket [from=KOR, to=CHE], price: 0.044105878435208856

 

API안에서 1초의 sleep을 주었기 때문에 실행결과와 같이 5초이상의 시간이 걸릴것이다.


- 병렬 Stream 사용

자바 8 이전이라면 병렬 처리시 꽤나 어려움이 있었지만 자바8에서는 stream 대신 parallelStream 만 사용해주면 된다. 물론 문법적으로는 간단하겠지만 병렬 처리시에는 고려할 요소가 꽤나 많다.

 

List<String> ticketAndPrice = tickets.parallelStream()
		.map(ticket -> {
			
			double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
			return ticket.toString() + ", price: " + price;
		})
		.collect(Collectors.toList());

 

위와 같이 parallelStream() 으로 변경해준 후 실행을 하면 성능이 훨씬 좋아진 결과를 얻을 수 있다.

 

duration: 1.079
Ticket [from=KOR, to=JPN], price: 0.29382721518168553
Ticket [from=KOR, to=CHN], price: 0.6857913459035405
Ticket [from=KOR, to=USA], price: 0.5641860119874191
Ticket [from=KOR, to=KAZ], price: 0.5349808117214014
Ticket [from=KOR, to=CHE], price: 0.846714310346217

 

훨씬 성능이 개선되었다. 위의 코드는 API는 동기 방식인데 API 호출을 병렬 스레드에서 수행한것이다. 병렬인지 눈으로 확인하고 싶다면 map 메소드에 아래와 같이 현재 Thread 정보를 출력해보면 확인이 용이할것이다.

 

.map(ticket -> {
			System.out.println("cur thread: " + Thread.currentThread());
			
			double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
			return ticket.toString() + ", price: " + price;
		})
cur thread: Thread[ForkJoinPool.commonPool-worker-4,5,main]
cur thread: Thread[ForkJoinPool.commonPool-worker-1,5,main]
cur thread: Thread[ForkJoinPool.commonPool-worker-3,5,main]
cur thread: Thread[ForkJoinPool.commonPool-worker-2,5,main]
cur thread: Thread[main,5,main]

- 비동기 호출

이전글에서 supplyAsync로 CompletableFuture 를 만들어 보았다. 한번 사용해보자.

 

List<CompletableFuture<String>> ticketAndPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> {
			double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
			return ticket.toString() + ", price: " + price;
		})
	)
	.collect(Collectors.toList());
	
List<String> ticketAndPriceInfos = ticketAndPriceFutures.stream()
		.map(ticketAndPriceFuture -> ticketAndPriceFuture.join())
		.collect(Collectors.toList());

 

처음 List는 CompletableFuture 목록을 가지고 있다. 리스트의 CompletableFuture는 각각 계산결과가 끝난 결과를 가지고 있다. 하지만 우리는 결과 문자열의 목록을 얻어야 하므로 CompletableFuture 클래스의 join 을 호출해서 모든 동작이 끝나기를 기다려야 한다. CompletableFuture의 join 메소드는 Future 의 get 메소드와 같은 의미이다. join은 아무 예외도 발생시키지 않는다는점이 다르다.

 

위의 코드를 보다보면 한 가지 의문점이 생길것이다. 1번째 List의 map에서 CompletableFuture을 반환하였고, 2번째 List의 map은 이를 다시 String으로 변환하였는데 2개의 List로 나누었다. 곧바로 map을 2번 호출했으면 됐을텐데 왜 그랬을까? 하나로 합쳐서 코드를 작성해보자.

 

List<String> ticketAndPriceFutures = tickets.stream()
		.map(ticket -> CompletableFuture.supplyAsync(() -> {
			
				System.out.println("called supplyAsync");
				
				double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
				return ticket.toString() + ", price: " + price;
			})
		)
		.map(CompletableFuture::join)
		.collect(Collectors.toList());

 

당연히 동작은 잘 된다. 실행결과를 살펴보자.

 

duration: 5.065
Ticket [from=KOR, to=JPN], price: 0.8081074883363153
Ticket [from=KOR, to=CHN], price: 0.054872717186825404
Ticket [from=KOR, to=USA], price: 0.6704233964178821
Ticket [from=KOR, to=KAZ], price: 0.7092406051535204
Ticket [from=KOR, to=CHE], price: 0.7248313604920008

 

5초가 넘어버렸다. 이는 CompletableFuture의 동작방식라기보다는 stream의 lazy 연산과 관련된 특성때문이다. 아래 코드를 살펴보자. 만약 stream의 lazy 특성을 이미 알고있다면 이 설명은 스킵하여도 좋다.

 

Optional<String> alphaB = Arrays.asList("a", "b", "c", "d", "e")
			.stream()
			.filter(character -> {
				System.out.println("filter method is executed, character : " + character);
				return "c".equals(character);
			})
			.map(character -> {
				System.out.println("map method is executed, character : " + character);
				return character.toUpperCase();
			})
			.findAny();
	
	System.out.println("Did you find alphaB? " + alphaB);

 

위와 같은 코드를 실행했을때 자바는 어떤 순서로 filter, map 메소드를 호출할까? 알파벳 a,b,c,d,e에 대해서 filter 메소드를 모두 1번씩 수행한 후 map 메소드를 모두 1번씩 수행하고 그 중 하나를 반환할까? print된 실행결과를 살펴보자.

 

filter method is executed, character : a
filter method is executed, character : b
filter method is executed, character : c
map method is executed, character : c
Did you find alphaB? Optional[C]

 

filter를 만족하면 그 다음 연산을 수행 한다. findAny는 stream의 아무 원소나 반환하고, 아무 원소나 반환하라고 했으니 d,e를 굳이 검사하면서 수행할 필요가 없게되어 수행하지 않는다. 우리가 작성한 CompletableFuture 코드에서 map을 2개로 나누지 않고 이어서쓰면 동기적으로 연산을 한것과 같은 결과가 된다.


- 커스텀 Executor

기존의 가장 원시적인 코드에서 성능 향상을 위해 parallelStream 과 CompletableFuture 를 사용한 2 가지 버전을 살펴보았다. 둘다 성능향상이 있었지만 만약 내가 실전에서 원시적이었던 코드의 성능 향상을 시키라는 요구사항을 받는다면 주저없이 parallelStream을 선택하겠다. parallelStream은 stream만 변경시키면 되는데, CompletableFuture는 온갖 고생을 하면서 변경해도 결과가 비슷하기 때문이다. 정말 단순히 parallelStream으로 변경하는게 최선일까? CompletableFuture의 장점은 없는건가?

 

private static final Executor executor = Executors.newFixedThreadPool(100, new ThreadFactory() {
	
	@Override
	public Thread newThread(Runnable r) {
		
		Thread t = new Thread(r);
		t.setDaemon(true);
		
		return t;
	}
});

 

위의 코드는 커스텀 Executor를 선언한 예제이다. 최대 개수를 100개로 설정하였고, 데몬 스레드를 포함하였다. 데몬 스레드는 자바 프로그램이 종료될 때 강제로 실행이 종료될 수 있다. supplyAsync 메소드 코드를 작성할 때 2번째 인자가 있는 메소드를 보았을것이다. 이 2번째 인자가 Executor 이다. executor를 사용한것과 안한것의 차이가 정말 있을까? 코드를 조금 수정해보자.

 

Airline airline = new Airline();
long startTime = System.currentTimeMillis();
	
List<String> threadList = new ArrayList<>(100);
	
List<CompletableFuture<String>> ticketAndPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> {
			
			String threadInfo = "current thread: " + Thread.currentThread();
			threadList.add(threadInfo);
			
			double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
			return ticket.toString() + ", price: " + price;
		})
	)
	.collect(Collectors.toList());

List<String> ticketAndPriceInfos = ticketAndPriceFutures.stream()
	.map(CompletableFuture::join)
	.collect(Collectors.toList());

long endTime = System.currentTimeMillis();

threadList.stream()
	.distinct()
	.forEach(System.out::println);

System.out.println("duration: " + (endTime - startTime) / 1000.0);

ticketAndPriceInfos.stream()
	.forEach(System.out::println);

 

Ticket을 50개 기준으로 Test 하였다. threadList 하나를 선언하고, supplyAsync 에서 현재 스레드 정보를 List에 하나씩 추가하자. 수행이 끝난 후 distinct() 하여 출력해보면 결과는 아래와 같다.

 

current thread: Thread[ForkJoinPool.commonPool-worker-1,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-2,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-3,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-4,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-5,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-6,5,main]
current thread: Thread[ForkJoinPool.commonPool-worker-7,5,main]
duration: 8.057
Ticket [from=KOR, to=JPN], price: 0.5311400380291785
Ticket [from=KOR, to=JPN], price: 0.22015302166147466
Ticket [from=KOR, to=JPN], price: 0.24216201979633545
.....

 

executor를 2번째 인자로 넘기면 어떻게 될까? 아래와 같이 executor 를 supplyAsync의 2번째 인자로 추가한 후 다시 실행해보자.

 

List<CompletableFuture<String>> ticketAndPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> {
				
			String threadInfo = "current thread: " + Thread.currentThread();
			threadList.add(threadInfo);
			
			double price = airline.getTicketPrice(ticket.getFrom(), ticket.getTo());
			return ticket.toString() + ", price: " + price;
		}, executor)
	)
	.collect(Collectors.toList());
current thread: Thread[Thread-0,5,main]
current thread: Thread[Thread-1,5,main]
current thread: Thread[Thread-2,5,main]
current thread: Thread[Thread-3,5,main]
current thread: Thread[Thread-4,5,main]
.......
current thread: Thread[Thread-47,5,main]
current thread: Thread[Thread-48,5,main]
current thread: Thread[Thread-49,5,main]
duration: 1.058
Ticket [from=KOR, to=JPN], price: 0.44931024204413406
Ticket [from=KOR, to=JPN], price: 0.8726346864670308
Ticket [from=KOR, to=JPN], price: 0.599592758981073
Ticket [from=KOR, to=JPN], price: 0.26408083474772126

 

사용한 스레드 그리고 속도가 8초에서 1초로 줄어들었다. 당연한 소리겠지만 스레드의 수를 무조건 늘리면 되는것은 아니다. 적당한 스레드 풀 크기를 산출하는 공식이 있긴 하다. 아래 공식은 자바 병렬 프로그래밍 - 브라이언 게츠에 나오는 공식이다.

 

Runtime.getRuntime().availableProcessors() 반환 수 * CPU 활용 비율(0~1사이) * W/C(대기시간과 계산시간의 비율)

위의 공식은 단순히 이론으로 참조할 뿐 가능하면 실제 서버에서 테스트를 해보는것이 중요하다.


- parallelStream vs CompletableFuture

parallelStream: I/O가 포함되지 않은 계산 중심의 동작, 모든 스레드가 계산 작업을 할 시 프로세스 코어 이상의 스레드 수는 의미 없다.

 

CompletableFuture: I/O를 기다리는 작업수행

댓글