본문 바로가기
Language/Java

[Java 8] CompletableFuture - 3 (비동기 파이프라인)

by ocwokocw 2021. 2. 11.

- 출처: 자바 8 in action

- 할인 계산 서비스 추가

CompletableFuture-2 코드를 이용하여 할인을 적용한 가격을 구하는 서비스를 작성해보자. 앞에서 getPrice는 가격 하나만을 반환했는데 from::to::price 형태의 :: 구분자 반환한다고 가정하자. 아래는 반환부가 새로 정의된 Airline class이다.

 

public class Airline {

	public Future<String> getTicketPriceAsyncViaSupplyAsync(String from, String to){
		return CompletableFuture.supplyAsync(() -> {
			
			validateTicket(from, to);
			
			return getTicketPrice(from, to);
		});
	}
	
	public Future<String> getTicketPriceAsync(String from, String to){
		
		CompletableFuture<String> futurePrices = new CompletableFuture<>();
		
		new Thread(() ->  {
			
			try {
				
				validateTicket(from, to);
				
				String priceInfo = from + "::" + to + "::" + getTicketPrice(from, to);
				futurePrices.complete(priceInfo);
			} catch (Exception e) {
				futurePrices.completeExceptionally(e);
			}
		}).start();
		
		return futurePrices;
	}
	
	private void validateTicket(String from, String to) {
		
		if(from == null || from.isEmpty()) {
			throw new RuntimeException("from place is empty");
		}
		else if(to == null || to.isEmpty()) {
			throw new RuntimeException("to place is empty");
		}
		else if(from.equals(to)) {
			throw new RuntimeException("from-to place must be not same.");
		}
	}
	
	public String getTicketPrice(String from, String to) {

		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		Random random = new Random();
		
		int fromToLength = 0;
		if(from.isEmpty() || to.isEmpty() || from.length() - to.length() == 0) {
			fromToLength = 1;
		}
		else {
			fromToLength = Math.abs(from.length() - to.length());
		}
		
		return from + "::" + to + "::" + random.nextDouble() * fromToLength;
	}
}

 

아래는 ::구분자 형태로 반환된 문자열을 Discount 서비스 요청을 위한 형태로 변환해주는 DiscountForm 이다.

 

public class DiscountForm {

	private Ticket ticket;
	private double price;

	public DiscountForm(String priceInfo) {
		
		String[] parsedPriceInfo = priceInfo.split("::");
		
		this.ticket = new Ticket(parsedPriceInfo[0], parsedPriceInfo[1]);
		this.price = Double.parseDouble(parsedPriceInfo[2]);
	}

	public Ticket getTciket() {
		return ticket;
	}

	public double getPrice() {
		return price;
	}
}

 

또 아래는 이런 DiscountForm을 이용하여 실제 할인된 가격을 구하고, 이를 from, to 와 조합하여 최종적으로 , 구분자로 반환하는 DiscountService이다. 원격 서비스임을 표시하기 위해 1초의 sleep을 추가하였다.

 

public class DiscountService {

	public static String getPriceViaDiscountForm(DiscountForm form) {

		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		
		double price = form.getPrice(); 
		Ticket ticket = form.getTciket();
		String fromToInfo = ticket.getFrom() + "," + ticket.getTo() + ",";  
		
		if(price < 0.5) {
			return fromToInfo + price;
		}
	
		if("JPN".equals(ticket.getTo())) {
			return fromToInfo + (price * 0.95);
		}
		else if("USA".equals(ticket.getTo())) {
			return fromToInfo + (price * 0.8);
		}
		
		return fromToInfo + price;
	}
}

 

정리하면 Airline에서 :: 구분자로 된 가격정보 문자열을 얻은 후, 이를 DiscountForm 형태로 변환하고 이를 이용해 할인된 가격을 구하는 과정이다. 이를 구하는 과정을 단순하게 코드로 작성하면 아래와 같다.

 

public static void main(String[] args) {
	
	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> discountedPrices = tickets.stream()
		.map(ticket -> airline.getTicketPrice(ticket.getFrom(), ticket.getTo()))
		.map(DiscountForm::new)
		.map(DiscountService::getPriceViaDiscountForm)
		.collect(Collectors.toList());
	
	
	long endTime = System.currentTimeMillis();	
		
	System.out.println("Done : " + (endTime - startTime) / 1000.0);
	
	discountedPrices.stream()
		.forEach(System.out::println);
		
}

 

- 실행결과

 

Done : 10.077
KOR,JPN,0.34776316523571704
KOR,CHN,0.7689604780255387
KOR,USA,0.48352139720246873
KOR,KAZ,0.8882699553358919
KOR,CHE,0.2638290444239364

 

1번째와 3번째 map에 해당하는 메소드는 가격을 구하는 원격서비스 이므로 5개의 출발-목적지 티켓에 대해 2번씩 적용되어 대략 10초라는 시간이 걸렸다.


- 동기작업과 비동기 작업 조합

위의 과정을 비동기로 구현해보자. 아래코드는 변환한 완성본이다. 코드가 이해되지 않아도 괜찮다. 천천히 읽어만 보도록하자.

 

long startTime = System.currentTimeMillis();
	
List<CompletableFuture<String>> discountedPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> 
		airline.getTicketPrice(ticket.getFrom(), ticket.getTo()), executor))
	.map(future -> future.thenApply(DiscountForm::new))
	.map(future -> future.thenCompose(discountForm -> 
		CompletableFuture.supplyAsync(
			() -> DiscountService.getPriceViaDiscountForm(discountForm), executor)
	)) 
	.collect(Collectors.toList());

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

long endTime = System.currentTimeMillis();

 

1번째 map은 이전글에서 사용한 CompletableFuture의 supplyAsync 메소드를 이용하여 할인전 가격을 비동기로 얻었다. 그리고 2번째 map은 1번째 map에서 반환한 문자열을 DiscountForm으로 변환하였다. 3번째 map 에서는 할인 적용하는 과정을 비동기로 얻는다.

 

사실 이렇게 설명한다고 해도 못알아 들을 수도 있다. 이렇게 설명을 한다고 블로그 글을 쓰는사람은 1번보고 다 알고있는듯이 말하지만 전혀 그렇지 않다. 다시 한번 3단계 과정(1. 가격정보 얻기, 2. DiscountForm 변환, 3. 할인적용)을 천천히 살펴보자.


- 1. 가격정보 얻기

팩토리 메소드 supplyAsync를 사용하여 비동기적으로 가격정보를 조회하였다. 이 단계의 map을 수행하면 Stream<CompletableFuture<String>>이 된다. 그리고 앞의 글에서 만들었떤 Executor를 2번째 인자로 넘겼다.


- 2. DiscountForm 변환

첫번째 결과 String을 생성자의 인자로 받아 3단계 할인 서비스에 적용하기 위한 인자인 DiscountForm 으로 만들었다. 이 과정은 네트워크나 I/O작업이 전혀 없다고 가정하여 sleep을 넣지 않은 코드이다. 이 단계의 map 에서 thenApply 메소드를 적용하였다.

 

thenApply 메소드는 map을 사용하듯이 순수한 값을 반환하는 Function<T, R> 형의 함수형 인터페이스를 인자로 받는다. DiscountForm의 원격작업이나 I/O작업이 없으므로 바로 수행될 수 있어서 String을 인자로 받아 DiscountForm으로 변환하는 생성자 레퍼런스를 인자로 넘겼다.

 

이 과정은 이 글의 소제목에서 동기작업과 조합을 하였다고 할 수 있다.


- 3. 할인된 가격 계산

원격 서비스를 이용한다고 가정하였으므로(sleep으로 1의 시간을 주어 흉내내었다.) 비동기작업으로 수행하였다. 

 

thenCompose 메소드는 어떤 객체형의 인자를 받아 CompletionStage 를 상속받은 형을 리턴하는 함수를 인자로 받는데, CompletableFuture가(CompletableFuture.supplyAsync의 반환형) CompletionStage를 구현하고 있다.

 

Function<? super T, ? extends CompletionStage<U>> fn

....

public class CompletableFuture<T> implements Future<T>, CompletionStage<T> {....

 

사실 처음보면 thenApply와 thenCompose를 구분하기가 헷갈릴 수 있다. 개념적으로 thenApply는 동기작업을 함수형인자를 받아 수행하는것이고, thenCompose는 비동기작업을하는 함수형인자를 받아 수행하는것이다.

 

개념이 헷갈려서 보면 검색을 하다가 map과 flatMap과의 관계로 비유한 글을 몇몇 볼 수 있는데, thenApply로 사용하면 중첩된 CompletableFuture 로 리턴하기 때문에 flatMap을 사용하여 중첩구조를 해결하는 방식이 비슷해서 이다.

 

List<CompletableFuture<String>> discountedPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> 
		airline.getTicketPrice(ticket.getFrom(), ticket.getTo()), executor))
	.map(future -> future.thenApply(DiscountForm::new))
	.map(future -> future.thenCompose(discountForm -> 
		CompletableFuture.supplyAsync(
				() -> DiscountService.getPriceViaDiscountForm(discountForm), executor)
	)) 
	.collect(Collectors.toList());

.............................................
	
List<CompletableFuture<CompletableFuture<String>>> discountedPriceFutures_2 = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> 
		airline.getTicketPrice(ticket.getFrom(), ticket.getTo()), executor))
	.map(future -> future.thenApply(DiscountForm::new))
	.map(future -> future.thenApply(discountForm -> 
		CompletableFuture.supplyAsync(
			() -> DiscountService.getPriceViaDiscountForm(discountForm), executor)
	)) 
	.collect(Collectors.toList());

 

결국 논리적으로 3단계 이긴 했지만 어떤 비동기간의 작업을 조합한것이냐를 놓고 보면 할인전 가격을 얻은 다음 DiscountForm을 변환한 단계까지를 1개의 비동기작업으로 볼 수 있고, DiscountForm을 이용하여 할인된 가격을 구한 비동기작업 이렇게 2개의 비동기작업으로 볼 수 있다.

 

정말 성능이 향상되었는지 코드를 수행해보자.

 

Done : 2.07
KOR,JPN,0.2066593562070339
KOR,CHN,0.2859532749331577
KOR,USA,0.10412780032575353
KOR,KAZ,0.38128187385253975
KOR,CHE,0.3522371563233051

 

10초가 걸리던게 2초가 되었다. 이는 5개의 Ticket으로 DiscountForm을 얻는 과정을 비동기적으로 수행하여 1초 정도의 시간이 걸렸고, 거기에 더해 DiscountForm에서 할인 가격을 얻는 과정을 비동기적으로 수행한 1초 정도의 시간이 합쳐진 결과라고 볼 수 있다.


- 독립 CompletableFuture와 비독립 CompletableFuture 조합

어떤 때에는 첫번째 CompletableFuture의 동작 완료와 관계없이 두 번째 CompletableFuture를 실행할 수 있어야 한다. 그리고 이런 결과를 병합하는 과정이 있을 수 있다. 이렇게 인과관계가 없는 두 개의 CompletableFuture를 결합하는 상황에서는 thenCombine 메소드를 사용한다.

 

public <U,V> CompletableFuture<V> thenCombine(
    CompletionStage<? extends U> other,
    BiFunction<? super T,? super U,? extends V> fn) {
    return biApplyStage(null, other, fn);
}

 

위의 코드는 thenCombine 정의이다. 1번째 인자로는 다른 CompletableFuture 를 인자로 받고, 2번째는 2개의 인자를 받아서 1개로 리턴하는 BiFunction 함수형 인터페이스를 받는데, 여기서는 2개의 CompletableFuture의 결과를 어떻게 결합하는지에 대한 동작을 정의한다.

 

만약 공항에서 해당 유저가 보안검사 대상인지 체크하는 정보도 추가한다고 가정해보자. 보안검사 대상인지는 해당 유저가 누구인지에 따라 좌우되며 앞의 할인된 가격을 구하는 동작이 선행되어야 수행가능한 동작은 아니다.

 

public static String isTargetSecureCheck(String userId) {
		
	try {
		Thread.sleep(1000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	
	if("superuser".equals(userId)) {
		return "NO";
	}
		
	return "YES";
}

 

위의 isTargetSecureCheck 메소드는 DiscountService에 정의된 함수이며 superuser는 검색을 하지 않는다는 의미로 "NO"를 반환한다. 

 

final String loginUserId = "superuser";

List<CompletableFuture<String>> discountedPriceFutures = tickets.stream()
	.map(ticket -> CompletableFuture.supplyAsync(() -> 
		airline.getTicketPrice(ticket.getFrom(), ticket.getTo()), executor))
	.map(future -> future.thenApply(DiscountForm::new))
	.map(future -> future.thenCompose(discountForm -> 
		CompletableFuture.supplyAsync(() -> 
				DiscountService.getPriceViaDiscountForm(discountForm))
			.thenCombine(CompletableFuture.supplyAsync(() -> 
				DiscountService.isTargetSecureCheck(loginUserId))
				,(price, seatGrade) -> price + "," + seatGrade) 
	))
	.collect(Collectors.toList());

 

1,2번째는 이전과 독같다. 3번째 map을 주의깊게 살펴보자.

 

할인된 가격을 얻기 위해 supplyAsync 메소드로 수행하였었다. 근데 여기서 할인된 가격을 얻는 동작과는 상관없이 user의 id로부터 보안검사 대상인지를 반환하는 isTargetSecureCheck를 thenCombine 메소드로 수행하였다. 그렇다면 정말 두 개의 동작은 다른 스레드에서 수행될까?

 

아래와 같이 threadTackList 의 ArrayList를 선언하고 아래 현재 Thread를 add 해주는 코드를 각 DiscountService 해주는 곳 마다 추가 하자.

 

List<String> threadTrackList = new ArrayList<>();

threadTrackList.add("step price: " + Thread.currentThread());

 

그리고 join 동작 후에 print를 해보면 실행결과는 아래와 같다.

 

step price: Thread[ForkJoinPool.commonPool-worker-3,5,main]
step price: Thread[ForkJoinPool.commonPool-worker-1,5,main]
step price: Thread[ForkJoinPool.commonPool-worker-2,5,main]
step secure: Thread[ForkJoinPool.commonPool-worker-5,5,main]
step price: Thread[ForkJoinPool.commonPool-worker-4,5,main]
step price: Thread[ForkJoinPool.commonPool-worker-7,5,main]
step secure: Thread[ForkJoinPool.commonPool-worker-6,5,main]
step secure: Thread[ForkJoinPool.commonPool-worker-5,5,main]
step secure: Thread[ForkJoinPool.commonPool-worker-6,5,main]
step secure: Thread[ForkJoinPool.commonPool-worker-7,5,main]
step merge: Thread[ForkJoinPool.commonPool-worker-3,5,main]
step merge: Thread[ForkJoinPool.commonPool-worker-2,5,main]
step merge: Thread[ForkJoinPool.commonPool-worker-5,5,main]
step merge: Thread[ForkJoinPool.commonPool-worker-7,5,main]
step merge: Thread[ForkJoinPool.commonPool-worker-6,5,main]
Done : 3.057
KOR,JPN,0.5180297621611876,NO
KOR,CHN,0.709744320510899,NO
KOR,USA,0.49184488057214076,NO
KOR,KAZ,0.1470556235729299,NO
KOR,CHE,0.9892683107660869,NO

 

위를 보면 step price는 할인 가격을 구하는 getPriceViaDiscountForm에, step secure는 isTargetSecureCheck에 그리고 merge는 가격 뒤에 검사대상 여부를 추가하는 곳에 스레드 수행 정보를 add한것이다.

 

5개의 ticket으로 수행한것인데 price 가 5개 나온뒤에야 secure가 수행된게 아니라 price, secure가 뒤섞여 수행되다가 merge가 나중에 수행됨을 알 수 있다.

댓글