반응형

1. 서론

이번 포스팅에서는 Chapter17의 리액티브 프로그래밍에 대해 진행하도록 하겠습니다.

 

2. 리액티브 매니패스토

리액티브 매니패스토란 리액티브 어플리케이션과 시스템 개발의 핵심 원칙을 공식적으로 정의한 내용입니다.

 

  1. 반응성 : 리액티브 시스템은 빠를 뿐 아니라 더 중요한 특징으로 일정하고 예상할 수 있는 반응 시간을 제공합니다.
  2. 회복성 : 장애가 발생해도 시스템은 반응해야 합니다.
  3. 탄력성 : 어플리케이션의 생명주기 동안 다양한 작업 부하를 받게 되는데 이 다양한 작업 부하로 어플리케이션의 반응성이 위협받을 수 있습니다. 이를 대비해, 자동으로 할당 된 자원 수를 늘립니다.
  4. 메시지 주도 : 회복성과 탄력성을 위해 메시지 기반의 통신으로 이루어 지도록 합니다.

아래는 위 4가지가 어떤 관계로 얽혀있는지 보여줍니다.

 

 

3. 리액티브 스트림과 플로 API

리액티브 프로그래밍은 리액티브 스트림을 사용하는 프로그래밍입니다.

 

리액티브 스트림은 무한의 비동기 데이터를 순서대로 블록하지 않은 역압력을 전제하여 처리하는 기술입니다.

스트림의 비동기가 기반이기 때문에 역압력 기술은 필수입니다.

 

역압력이란, 데이터 소모하는 쪽에서 데이터 발행자에게 요청한 경우에만 처리하도록 하는 기법입니다.

 

 

1) Flow 클래스 소개

 

자바 9 에서는 java.util.concurrent.Flow를 추가했습니다.

 

Flow 클래스는 내부적으로 4개의 인터페이스를 가지고 있습니다.

 

  • Publisher
  • Subscriber
  • Subscription
  • Processor

아래는 각 4개의 인터페이스 정의입니다.

 

@FunctionalInterface
public static interface Publisher<T> {
    public void subscribe(Subscriber<? super T> subscriber);
}

public static interface Subscriber<T> {
    public void onSubscribe(Subscription subscription);
    public void onNext(T item);
    public void onError(Throwable throwable);
    public void onComplete();
}

public static interface Subscription {
    public void request(long n);
    public void cancel();
}

public static interface Processor<T,R> extends Subscriber<T>, Publisher<R> {

}

 

여기서 Processor는 리액티브 스트림에서 처리하는 이벤트의 변환을 담당합니다.

 

 

아래는 이 4개의 인터페이스의 동작 원칙입니다.

 

  • Publisher는 반드시 Subscription의 request 메서드에 정의된 개수 이하의 요소만 Subscriber에 전달해야 합니다.
  • Subscriber 는 요소를 받아 처리할 수 있음을 Publisher에게 알려야 합니다.
  • Publisher 와 Subscriber 는 Subscription을 공유해야 합니다.

 

 

1) 첫번째 리액티브 어플리케이션 만들기

 

아래는 매초 온도를 보고하는 예제입니다.

 

public class TempInfo {

    public static final Random random = new Random();

    private final String town;
    private final int temp;

    public TempInfo(String town, int temp) {
        this.town = town;
        this.temp = temp;
    }
    
    public static TempInfo fetch(String town) {
        if(random.nextInt(10) == 0) {
            throw new RuntimeException("ERROR!!");
        }
        return new TempInfo(town, random.nextInt(100));
    }
}

 

public class TempSubscriber implements Flow.Subscriber<TempInfo> {

    private Flow.Subscription subscription;

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        this.subscription = subscription;
        subscription.request(1);
    }

    @Override
    public void onNext(TempInfo item) {
        System.out.println(item);
        subscription.request(1);
    }

    @Override
    public void onError(Throwable throwable) {
        System.err.println(throwable.getMessage());
    }

    @Override
    public void onComplete() {
        System.out.println("Done!");
    }
}

 

public class TempSubscription implements Flow.Subscription {

    private final Flow.Subscriber<? super TempInfo> subscriber;
    private final String town;

    public TempSubscription(Flow.Subscriber<? super TempInfo> subscriber, String town) {
        this.subscriber = subscriber;
        this.town = town;
    }

    @Override
    public void request(long n) {
        for(long i = 0L; i < n; i++) {
            try {
                subscriber.onNext(TempInfo.fetch(town));
            } catch (Exception e) {
                subscriber.onError(e);
                break;
            }
        }
    }

    @Override
    public void cancel() {
        subscriber.onComplete();
    }
}

 

public class Main {

    public static void main(String[] args) {
        getTemperatures("New York").subscribe(new TempSubscriber());
    }

    private static Flow.Publisher<TempInfo> getTemperatures(String town) {
        return subscriber -> subscriber.onSubscribe(
                new TempSubscription(subscriber, town)
        );
    }
}

 

 

위 예제에서는 Subscription의 request를 호출하면, Subscriber가 Subscription을 또 호출하는 재귀 문제가 있습니다.

이를 해결하기 위해서는 별도 Executor를 사용할 수 있습니다.

 

Executor 소스를 추가한 코드는 아래와 같습니다.

 

public class TempSubscription implements Flow.Subscription {
    
    private static final ExecutorService executor = Executors.newSingleThreadExecutor(); 
    private final Flow.Subscriber<? super TempInfo> subscriber;
    private final String town;

    public TempSubscription(Flow.Subscriber<? super TempInfo> subscriber, String town) {
        this.subscriber = subscriber;
        this.town = town;
    }

    @Override
    public void request(long n) {
        executor.submit(() -> {
            for(long i = 0L; i < n; i++) {
                try {
                    subscriber.onNext(TempInfo.fetch(town));
                } catch (Exception e) {
                    subscriber.onError(e);
                    break;
                }
            }    
        });
    }

    @Override
    public void cancel() {
        subscriber.onComplete();
    }
}

 

 

 

2) Processor로 데이터 변환하기

 

Processor의 목적은 Publisher를 구독한 다음 수신한 데이터를 가공해 다시 제공하는 것입니다.

 

아래는 위 예제에 Processor를 적용한 코드입니다.

 

public class TempSubscription implements Flow.Subscription {

    private static final ExecutorService executor = Executors.newSingleThreadExecutor();
    private final Flow.Processor<? super TempInfo, ? super TempInfo> processor;
    private final String town;

    public TempSubscription(Flow.Processor<? super TempInfo, ? super TempInfo> processor, String town) {
        this.processor = processor;
        this.town = town;
    }

    @Override
    public void request(long n) {
        executor.submit(() -> {
            for(long i = 0L; i < n; i++) {
                try {
                    processor.onNext(TempInfo.fetch(town));
                } catch (Exception e) {
                    processor.onError(e);
                    break;
                }
            }
        });
    }

    @Override
    public void cancel() {
        processor.onComplete();
    }
}

 

public class TempProcessor implements Flow.Processor<TempInfo, TempInfo> {

    private Flow.Subscriber<? super TempInfo> subscriber;

    @Override
    public void subscribe(Flow.Subscriber<? super TempInfo> subscriber) {
        this.subscriber = subscriber;
    }

    @Override
    public void onSubscribe(Flow.Subscription subscription) {
        subscriber.onSubscribe(subscription);
    }

    @Override
    public void onNext(TempInfo item) {
        subscriber.onNext(
                new TempInfo(
                        item.getTown(),
                        (item.getTemp() -32) * 5 /9
                )
        );

    }

    @Override
    public void onError(Throwable throwable) {
        subscriber.onError(throwable);
    }

    @Override
    public void onComplete() {
        subscriber.onComplete();
    }
}

 

public class Main {

    public static void main(String[] args) {
        getTemperatures("New York").subscribe(new TempSubscriber());
    }

    private static Flow.Publisher<TempInfo> getTemperatures(String town) {
        return subscriber -> {
            TempProcessor processor = new TempProcessor();
            processor.subscribe(subscriber);
            processor.onSubscribe(new TempSubscription(processor, town));
        };
    }
}

 

Processor를 이용해 onNext 메서드에서 화씨 온도를 섭씨 온도로 변경하여 Subscriber 에게 전달하는 것을 볼 수 있습니다.

 

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

 

4. 리액티브 라이브러리 RxJava

RxJava는 자바로 리액티브 어플리케이션을 구현하는데 사용하는 라이브러리입니다.

compile group: 'io.reactivex.rxjava2', name: 'rxjava', version: '2.2.19'

 

RxJava는 io.reactivex 패키지하위의 Observable, Observer 를 사용할 수 있습니다.

 

간단히 자바 플로에서 Publisher 가 RxJava에서는 Observable, Subscriber가 Observer라고 보시면 됩니다.

 

 

1) Observable 만들고 사용하기

 

아래는 간단한 Observable 을 만드는 예제 코드입니다.

 

Observable<String> strings = Observable.just("fisrt", "second");

 

just 팩토리 메서드는 한개 이상의 요소를 이용해 방출하는 Observable 을 만듭니다.

 

Observable 구독자는 onNext("first"), onNext("second"), onComplete() 순으로 메시지를 받게 됩니다.

 

특정 시간단위로 상호작용할때는 아래와 같이 interval 팩토리 메서드를 사용할 수 있습니다.

 

Observable<Long> onePerSec = Observable.interval(1, TimeUnit.SECONDS);

 

추가로 RxJava는 역압력을 지원하지 않아, Observable에는 Subscription의 request 메서드 같은 기능이 없습니다.

 

Observer 인터페이스는 위의 Subscriber와 정의도 비슷합니다.

 

아래는 Observer 인터페이스 정의입니다.

 

public interface Observer<T> {
    void onSubscriber(Disposable d);
    void onNext(T t);
    void onCompleted();
    void onError(Throwable e);
}

 

RxJava는 위의 메서드를 모두 구현할 필요가 없고, onNext 만 구현해도 괜찮습니다.

 

예제는 아래와 같습니다.

 

onePerSec.subscribe(i -> System.out.println(TempInfo.fetch("New York")));

 

 

이 예제는 초마다 뉴욕의 온도를 출력하는 예제입니다.

 

하지만 실제로 동작시키면 아무런 출력이 되지 않습니다.

이유는, Observable이 RxJava의 연산 쓰레드 풀 즉 데몬 쓰레드에서 실행되기 때문입니다.

 

RxJava는 호출 쓰레드에서 값을 받는 메서드도 제공합니다.

 

예제는 아래와 같습니다.

 

onePerSec.blockingSubscribe(i -> System.out.println(TempInfo.fetch("New York")));

 

이제 위 예제에 RxJava를 적용해보겠습니다.

 

private static Observable<TempInfo> getTemperatures(String town) {
    return Observable.create(emitter -> {
        Observable.interval(1, TimeUnit.SECONDS)
                .subscribe(i -> {
                    if (!emitter.isDisposed()) {
                        if (i >= 5) {
                            emitter.onComplete();
                        } else {
                            try {
                                emitter.onNext(TempInfo.fetch(town));
                            } catch (Exception e) {
                                emitter.onError(e);
                            }

                        }
                    }
                });
    });
}

 

public class TempObserver implements Observer<TempInfo> {
    
    @Override
    public void onCompleted() {
        System.out.println("Done!");    
    }

    @Override
    public void onError(Throwable e) {
        System.out.println("Got problem : " + e.getMessage());

    }

    @Override
    public void onNext(TempInfo tempInfo) {
        System.out.println(tempInfo);
    }
}

 

public static void main(String[] args) {
    Observable<TempInfo> observable = getTemperatures("New York");
    observable.blockingSubscribe(new TempObserver());
}

 

 

2) Observable 변환하고 합치기

 

Observable에는 스트림과 비슷한 메서드들을 제공합니다.

 

  • map
  • filter
  • merge

 

map

 

map은 스트림의 map 과 같이 요소를 변환하는 메서드입니다.

자바 플로의 Processor라고 보시면 됩니다.

 

아래는 섭씨로 변환하는 작업을 Observable의 map을 사용하여 만드는 예제입니다.

 

private static Observable<TempInfo> getCelsiusTemperature(String town) {
    return getTemperatures(town).map(item -> new TempInfo(
            item.getTown(),
            (item.getTemp() -32) * 5 /9
    ));
}

 

filter

 

filter도 스트림과 동일하게 특정 조건의 데이터만을 추출하는 용도의 메서드입니다.

 

아래는 filter 예제 코드입니다.

 

private static Observable<TempInfo> getNegativeTemperature(String town) {
    return getCelsiusTemperature(town).filter(temp -> temp.getTemp() < 0);
}

 

merge

 

merge는 여러 Observable를 하나의 Observable로 만드는 메서드입니다.

 

아래는 merge 예제 코드입니다.

 

private static Observable<TempInfo> getCelsiusTemperatures(String... towns) {
    return Observable.merge(
            Arrays.asList(towns).stream()
            .map(Main::getCelsiusTemperature)
            .collect(Collectors.toList())
    );
}

 

public static void main(String[] args) {
    Observable<TempInfo> observable = getCelsiusTemperatures("New York", "Chicago", "San Francisco");
    observable.blockingSubscribe(new TempObserver());
}

 

merge 메서드는 Observable의 Iterator을 인수로 받아 한개의 Observable처럼 동작합니다.

 

5. 마무리

이번 포스팅에서는 Chapter17 리액티브 프로그래밍 대해 진행하였습니다.

이렇게, 모던 자바 인 액션에 대한 포스팅은 완료했습니다.

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter16의 CompletableFuture : 안정적 비동기 프로그래밍에 대해 진행하도록 하겠습니다.

 

2. Future의 단순 활용

Future 인터페이스는 비동기 계산을 모델링 하는데 쓰이며, 계산이 끝났을 때 결과에 접근할 수 있는 참조를 제공합니다.

 

Future 이용시에는 시간이 오래걸리는 작업을 Callable 객체 내부로 감싼 다음 ExecutorService에 제출하면 됩니다.

 

아래는 예제 코드입니다.

 

ExecutorService executorService = Executors.newCachedThreadPool();
Future<Double> future = executorService.submit(new Callable<Double>() {
    Override
    public Double call() throws Exception {
        return doSomeThingLongComputation();
    }
});        
doSomeThingElse();        
try {
    double result = future.get(1, TimeUnit.SECONDS);
} catch (InterruptedException e) {
    // 현재 스레드에서 대기 중 인터럽트 발생
} catch (ExecutionException e) {
    // 계산 중 예외
} catch (TimeoutException e) {
    // Future가 완료되기 전에 타임아웃 발생 
}

 

위 예제에서 doSomeThingLongComputation 연산은 별도 쓰레드에서 수행하게 됩니다.

수행 후, 값을 얻기 위해 get 메서드를 호출해야합니다.

연산이 끝난 경우에는 바로 반환되지만 연산이 진행중이라면 get 메서드에서 블로킹 됩니다.

 

1) Future의 제한

 

Future는 일련의 동시 실행 코드를 구현하기에 충분하지 않습니다.

 

예를들어, '오래 걸리는 A라는 계산이 끝나면 그 결과를 다른 오래 걸리는 계산 B로 전달하시오 그리고 B의 결과가 나오면 다른 질의의 결과와 B의 결과를 조합하시오' 와 같은 요구사항을 쉽게 구현하기가 어렵다는 점입니다. 

 

이러한 제한적인 부분을 자바 8에서 제공하는 CompletableFuture로 해결할 수 있습니다.

 

3. 비동기 API 구현

CompletableFuture를 이용하여 최저가격 검색 어플리케이션 예제를 진행하겠습니다.

 

public class Shop {
    
    private static final Random random = new Random();
    
    private String name;

    public Shop(String name) {
        this.name = name;
    }
    
    public double getPrice(String product) {
        return calculatePrice(product);
    }
    
    private double calculatePrice(String product) {
        delay();
        return  random.nextDouble() * product.charAt(0) + product.charAt(1);
    }
    
    private static void delay() {
        try {
            Thread.sleep(1000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

 

위 예제에서는 상품명을 받아 1초의 작업 후 연산 결과를 반환하는 getPrice 메서드가 있습니다.

 

 

1) 동기 메서드를 비동기 메서드로 변환

 

위 예제의 경우 getPrice를 호출한 쓰레드는 1초 간 블로킹 되어집니다.

 

이 부분을 해소하기 위해 CompletableFuture를 이용한 getPriceAsync 메서드를 추가하겠습니다.

 

public Future<Double> getPriceAsync(String product) {
    CompletableFuture<Double> futurePrice = new CompletableFuture<>();
    new Thread(() -> {
       double price = calculatePrice(product);
       futurePrice.complete(price);
    }).start();
    
    return futurePrice;
}
Shop shop = new Shop("BestShop");
long start = System.nanoTime();
Future<Double> futurePrice = shop.getPriceAsync("my favorite product");
long invocationTime = ((System.nanoTime() - start) / 1000000);
System.out.println("Invocation returned after " + invocationTime + "msecs");

doSomeThingElse(); // 가격 정보를 가져오는 동안 다른 일 수행
        
try {
    double price = futurePrice.get();
    System.out.printf("Price is %.2f%n", price);
} catch (Exception e) {
    throw new RuntimeException(e);
}
        
long retrievalTime = ((System.nanoTime() - start) / 1000000);
System.out.println("Price returned after " + retrievalTime + " msecs");

 

위 코드의 getPriceAsync는 가격 계산이 끝나기 전에 return을 받습니다.

단, doSomeThingElse 의 작업이 먼저 끝난다면 get 메서드시 블로킹 되는 문제는 여전히 가지고 있습니다.

 

이 문제는, CompletableFuture의 기능을 활용하면 해결 가능하며 아래에서 더 살펴보겠습니다.

 

 

2) 에러 처리 방법

 

비동기로 처리시에는 별도의 쓰레드에서 동작하기에 에러 처리가 매우 까다롭습니다.

 

하지만, CompletableFuture 의 completeExceptionally 메서드를 사용하면 매우 간단해집니다.

 

아래는 예제 코드입니다.

 

public Future<Double> getPriceAsync(String product) {
    CompletableFuture<Double> futurePrice = new CompletableFuture<>();
    new Thread(() -> {
        try {
            double price = calculatePrice(product);
            futurePrice.complete(price);     
        } catch (Exception e) {
            futurePrice.completeExceptionally(e);
        }
    }).start();

    return futurePrice;
}

 

위와 같이 코드 추가 후 예외가 실제로 발생되면, 클라이언트는 ExecutionException을 받게 됩니다.

 

 

3) 팩토리 메서드 supplyAsync로 CompletableFuture 만들기

 

위 CompletableFuture를 만드는 코드가 매우 긴게 가독성이 좋지 않습니다.

 

이를 위해, CompletableFuture는 팩터리 메서드로 supplyAsync를 제공합니다.

 

위 getPriceAsync 메서드를 팩터리 메서드 supplyAsync를 적용한 코드는 아래와 같습니다.

 

public Future<Double> getPriceAsync(String product) {
   return CompletableFuture.supplyAsync(() -> calculatePrice(product));
}

 

이 방법은 위 에러처리 방법에서 본 completeExceptionally 까지 포함한 메서드입니다.

 

 

 

반응형

 

 

4. 비블록 코드 만들기

이제 위 예제를 좀 더 확장하여 여러 가게에서 가격정보를 가져와 노출하는 경우를 추가하겠습니다.

 

코드는 아래와 같습니다.

 

public class ShopTest {

    private static List<Shop> shopList = Arrays.asList(
            new Shop("BestPrice"),
            new Shop("LetsSaveBig"),
            new Shop("MyFavoriteShp["),
            new Shop("BuyItAll")
    );

    public static void main(String[] args) {
        long start = System.nanoTime();
        System.out.println(findPrices("myPhone27S"));
        long duration = (System.nanoTime() - start) / 1000000;
        System.out.println("Done in " + duration + "msecs");
    }

    public static List<String> findPrices(String product) {
        return shopList.stream()
                .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
                .collect(Collectors.toList());
    }
}

 

위 예제는 각 shop 마다 delay가 있어 최소 4초 이상의 시간이 걸립니다.

 

 

1) 병렬 스트림으로 요청 병렬화 하기

 

 

위 예제를 앞장에서 배웠던 병렬 스트림을 통해서 성능을 개선시킬 수 있습니다.

 

코드는 아래와 같습니다.

 

public static List<String> findPrices(String product) {
    return shopList.parallelStream()
        .map(shop -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product)))
        .collect(Collectors.toList());
}

 

위와 같이 병렬로 변경 시, 작업은 대략 1/4로 줄어듭니다.

 

2) CompletableFuture로 비동기 호출 구현하기

 

위 병렬 스트림으로 성능이 개선되었지만 비동기를 입혀 블로킹까지 없애 성능을 더욱 올리는게 바람직합니다.

병렬스트림으로 처리하더라도, 위와 같은 경우 한 쓰레드가 shop을 2개 이상 가지게 된다면 블로킹은 여전히 존재합니다.

 

코드는 아래와 같습니다.

 

public static List<String> findPrices(String product) {
    List<CompletableFuture<String>> priceFutures = shopList.stream()
            .map(shop -> CompletableFuture.supplyAsync(
                    () -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product))
            ))
            .collect(Collectors.toList());
    return priceFutures.stream().map(CompletableFuture::join)
            .collect(Collectors.toList());
}

 

 

3) 커스텀 Executor 사용하기

 

위 병렬스트림과 CompletableFuture 를 적용한 성능은 내부적으로 Runtime.getRuntime().availableProcessors() 가 반환하는 스레드 수로 동작합니다.

 

따라서, 쓰레드 풀을 직접 생성하여 동작시킨다면 CompletableFuture를 이용한 비동기 프로그래밍은 더욱 유연해지고 성능 향상이 일어나게 됩니다.

 

아래는 예제입니다.

 

public static List<String> findPrices(String product) {
    List<CompletableFuture<String>> priceFutures = shopList.stream()
            .map(shop -> CompletableFuture.supplyAsync(
                    () -> String.format("%s price is %.2f", shop.getName(), shop.getPrice(product))
			, executor
            ))
            .collect(Collectors.toList());
    return priceFutures.stream().map(CompletableFuture::join)
            .collect(Collectors.toList());
}

 

간단히 비동기 처리에 쓸 Executor를 두번째 인자로 추가만 해주면 됩니다.

 

5. 비동기 작업 파이프라인 만들기

위 예제에서 할인율을 더해 출력하는 요구사항을 추가해보도록 하겠습니다.

 

아래는 getPrice 메서드를 shop 이름, 가격, DisCount 정보를 가진 문자열로 반환하도록 변경한 예제입니다.

 

public class Discount {
    
    public enum Code {
        NONE(0),
        SILVER(5),
        GOLD(10),
        PLATINUM(15),
        DIAMOND(20)
        ;
        
        private final int percentage;

        Code(int percentage) {
            this.percentage = percentage;
        }
    }
}

 

public String getPrice(String product) {
    double price = calculatePrice(product);
    Discount.Code code = Discount.Code.values()[random.nextInt(Discount.Code.values().length)];
    return String.format("%s:%.2f:%s", name, price, code);
}

 

1) 할인 서비스 구현

 

위 getPrice의 문자열을 파싱하여 사용하는 클래스는 아래 Quote로 정의하였습니다.

추가로, 이제 Quote 객체를 Discount에 넘겨 할인이 적용된 값을 반환하는 메서드를 추가했습니다.

 

public class Quote {

    private final String shopName;
    private final double price;
    private final Discount.Code code;

    public Quote(String shopName, double price, Discount.Code code) {
        this.shopName = shopName;
        this.price = price;
        this.code = code;
    }

    public static Quote parse(String s) {
        String[] split = s.split(":");
        
        String shopName = split[0];
        double price = Double.parseDouble(split[1]);
        Discount.Code code = Discount.Code.valueOf(split[2]);
        return new Quote(shopName, price, code);
    }

    public String getShopName() {
        return shopName;
    }

    public double getPrice() {
        return price;
    }

    public Discount.Code getCode() {
        return code;
    }
}

 

public class Discount {

    public enum Code {
        NONE(0),
        SILVER(5),
        GOLD(10),
        PLATINUM(15),
        DIAMOND(20)
        ;

        private final int percentage;

        Code(int percentage) {
            this.percentage = percentage;
        }
    }
    
    public static String applyDiscount(Quote quote) {
        return quote.getShopName() + "price is " + 
                Discount.apply(quote.getPrice(), quote.getCode());
    }
    
    private static double apply(double price, Code code) {
        delay();
        return price * (100 - code.percentage) / 100;
    } 
}

 

2) 할인 서비스 사용

 

이제 위 추가된 내용을 사용하기에 가장 쉬운 방법은 아래와 같이 stream으로 처리하는 것입니다.

 

public static List<String> findPrices(String product) {
    return shopList.stream()
        .map(shop -> shop.getPrice(product))
        .map(Quote::parse)
        .map(Discount::applyDiscount)
        .collect(Collectors.toList());
}

 

위 코드는 처리만 할 뿐 성능 최적화와는 거리가 멀게 됩니다.

 

 

2) 동기 작업과 비동기 작업 조합하기

 

위 코드를 비동기를 이용한 코드로 바꿔보겠습니다.

 

예제는 아래와 같습니다.

public static List<String> findPrices(String product) {
    List<CompletableFuture<String>> priceFutures = shopList.stream()
            .map(shop -> CompletableFuture.supplyAsync(
                    () -> shop.getPrice(product), executor)
            )
            .map(future -> future.thenApply(Quote::parse))
            .map(future -> future.thenCompose(
                    quote -> CompletableFuture.supplyAsync(
                            () -> Discount.applyDiscount(quote), executor
                    )
                )
            )
            .collect(Collectors.toList());
    
    return priceFutures.stream().map(CompletableFuture::join)
            .collect(Collectors.toList());
}

 

위 코드에서 thenApply 는 CompletableFuture가 끝날때까지 블록하지 않습니다.

 

따라서 블로킹 없이 CompletableFuture<String> 에서 CompletableFuture<Quote>로 변환됩니다.

 

추가로, thenCompose 메서드는 첫번째 연산의 결과를 두번째 연산으로 전달하는 메서드입니다.

위에서는 quote의 결과를 받으면 Discount.applyDiscount 메서드를 한번 더 비동기로 수행시키는 코드입니다.

 

 

3) 독립 CompletableFuture 와 비독립 CompletableFuture 합치기

 

개발을 하다보면 두개의 독립적인 비동기 작업을 합쳐야 하는 경우가 있습니다.

이런 경우 thenCombine 메서드를 활용하시면 됩니다.

 

예제는 아래와 같습니다.

 

Future<Double> futurePriceInUSD = 
        CompletableFuture.supplyAsync(() -> shop.getPrice(prduct))
        .thenCombine(CompletableFuture.supplyAsync(() -> exchangeService.getRate(Money.EUR, Money.USD)), (price, rate) -> price * rate);

 

아래는 두 Future를 합치는 작업을 도식화 한것입니다.

 

 

출처 : 모던 자바 인 액션

 

 

4) 타임아웃 효과적으로 사용하기

 

Future의 단점으로는 계산이 길어지는 경우 무한정 대기할 수도 있다는 부분입니다.

 

CompletableFuture에서는 이를 위해 orTimeout 메서드를 제공합니다.

 

아래는 예제입니다.

 

Future<Double> futurePriceInUSD = 
        CompletableFuture.supplyAsync(() -> shop.getPrice(prduct))
        .thenCombine(CompletableFuture.supplyAsync(() -> exchangeService.getRate(Money.EUR, Money.USD)), (price, rate) -> price * rate)
        .orTimeout(3, TimeUnit.SECONDS);

 

 

6. CompletableFuture의 종료에 대응하는 방법

CompletableFuture는 thenAccept라는 메서드를 제공합니다.

 

이 메서드는 연산 결과를 소비하는 Consumer를 인수로 받아 사용합니다.

 

아래는 예제입니다.

 

CompletableFuture[] futures = shopList.stream()
    .map(shop -> CompletableFuture.supplyAsync(
            () -> shop.getPrice(product), executor)
    )
    .map(future -> future.thenApply(Quote::parse))
    .map(future -> future.thenCompose(
            quote -> CompletableFuture.supplyAsync(
                    () -> Discount.applyDiscount(quote), executor
            )
        )
    )
    .map(f -> f.thenAccept(System.out::println))
    .toArray(size -> new CompletableFuture[size]);

 

7. 마무리

이번 포스팅에서는 Chapter16  CompletableFuture : 안정적 비동기 프로그래밍 대해 진행하였습니다.

다음에는 Chapter17 리액티브 프로그래밍에 대해 포스팅하겠습니다.

반응형
반응형

1. 서론

 

이번 포스팅에서는 Chapter15의 CompletableFuture와 리액티브 프로그래밍의 컨셉의 기초에 대해 진행하도록 하겠습니다.

 

2. 동시성을 구현하는 자바 지원의 진화

초기 자바는 Runnable과 Thread를 동기화된 클래스와 메서드를 이용해 잠갔습니다.

 

그 후, 자바 5에서는 ExecutorService 인터페이스를 제공하며 스레드 실행과 태스크를 분리하였고,

값을 반환하는 Callable도 ExecutorService로 사용할 수 있게 되었습니다.

ExecutorService는 Runnable, Callable 둘 다 인자로 받을 수 있습니다.

 

자바 8에서는 Future의 진화 단계인 CompletableFuture를 제공, 자바 9에서는 발행-구독 메커니즘을 위한 Flow를 제공하게 되었습니다.

 

CompletableFuture, Flow 의 제공 목표는 가능한한 동시에 블록킹 되지 않게 실행할 수 있도록 제공하기 위함입니다.

 

 

1) Executor와 쓰레드 풀

 

쓰레드의 문제

 

쓰레드는 자바 뿐만이 아닌 하드웨어, 운영체제에서도 최대 사용가능한 갯수가 있습니다.

 

만약 자바 어플리케이션의 쓰레드가 운영체제가 지원하는 쓰레드 수를 초과하면 에러가 발생 할 수 있습니다.

 

쓰레드 풀 그리고 쓰레드 풀이 더 좋은 이유

 

쓰레드 풀은 쓰레드를 계속 생성하는 것이 아니라 사용이 끝나면 반환하게 하여 재사용을 할 수 있도록 하는 방법입니다.

 

장점은 위의 쓰레드 문제같이 에러 나는 경우를 예방할 수 있으며, 쓰레드를 재사용할 수 있다는 점입니다.

 

쓰레드 풀 그리고 쓰레드 풀이 나쁜 이유

 

쓰레드 풀이 장점만 있는 것은 아닙니다.

 

쓰레드 풀을 사용 시 유휴상태의 쓰레드가 풀에 없다면 태스크는 블록킹되기 때문입니다.

 

3. 동기 API와 비동기 API

동기 API는 쓰레드가 CPU 자원을 점유한 상태로 아무런 일을 하지 않는 상황을 만듭니다.

 

이러한 동기 API의 문제점을 아래 예제 코드를 통해 보겠습니다.

 

public class ThreadExample {

    public static void main(String[] args) throws InterruptedException {
        
        int x = 1337;

        Result result = new Result();
        
        Thread t1 = new Thread(() -> {result.left = f(x);});
        Thread t2 = new Thread(() -> {result.right = g(x);});
        
        t1.start();
        t2.start();
        
        t1.join();
        t2.join();
        System.out.println(result.left + result.right);
    }
    
    private static class Result {
        private int left;
        private int right;
    }
}

 

위 예제는 f, g 라는 연산을 수행 후 두 결과를 합쳐 출력하는 예제입니다.

 

쓰레드를 2개 만들어 사용하지만 join 메서드로 인해 이들은 동시성이라고 할 수 없습니다.

 

 

1) Future 형식 API

 

위 예제를 Future 를 사용한다면 조금 개선이 될 수 있습니다.

 

Future는 태스크의 작업을 비동기로 쓰레드에서 수행하도록 합니다.

 

아래는 Future를 적용한 예제입니다.

 

int x = 1337;
ExecutorService executorService = Executors.newFixedThreadPool(2);
Future<Integer> y = executorService.submit(() -> f(x));
Future<Integer> z = executorService.submit(() -> g(x));

System.out.println(y.get()  + z.get());

 

2) 리액티브 형식 API

 

위 문제에서 Future 를 적용하였더라도 get() 메서드는 블로킹 되기 때문에 완벽한 해결책은 아닙니다.

 

리액티브 형식으로는 처리가 완료 되었을때의 콜백함수를 인자로 넣어 사용하는 것입니다.

 

아래는 예제입니다.

 

int x = 1337;

Result result = new Result();

f(x, (int y) -> {
    result.left = y;
    System.out.println((result.left + result.right));
});

g(x, (int z) -> {
    result.right = z;
    System.out.println((result.left + result.right));
});

 

 

위 같이 수행한다면 블록킹 되는 부분은 없을 겁니다.

하지만, f 와 g의 합계가 정확하게 출력되지 않으며 2번이나 출력이 될 것입니다.

 

이런 경우, 일반적으로 if-then-else 를 이용하여 해결 할 수 있습니다.

 

 

3) 잠자기(그리고 기타 블로캉 동작)는 해로운 것으로 간주

 

블로킹 코드가 있다면 쓰레드는 자원을 점유한 채 아무일도 하지 않을 것입니다.

 

이러한, 작업들이 조금씩 쌓이게되면 프로그램 전체에 영향이 가게 됩니다.

 

그러므로, 블로킹 동작은 최대한 배제해야하며 해로운 것으로 간주해야 합니다.

 

 

4) 비동기 API에서 예외는 어떻게 처리하는가?

 

비동기 API에서 호출된 메서드는 별도의 쓰레드에서 수행되기 때문에 호출자에서 예외를 핸들링 할 수 없습니다.

 

때문에, CompletableFuture에서는 런타임 get() 메서드에 예외를 처리할 수 있는 기능을 제공하였고,

exceptionally() 로 예외에서 회복할 수 있는 메서드도 제공하였습니다.

 

리액티브 형식에서는 값을 콜백형식으로 처리하기 때문에 예외 또한 콜백으로 처리하게됩니다.

 

플로를 예로 든다면 아래와 같이 예외에 대한 콜백 함수가 있습니다.

 

void onError(Throwable throwable)

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

 

4. CompletableFuture와 콤비네이터를 이용한 동시성

CompletableFuture는 complete 메서드를 통해 나중에 어떤 값을 이용해 다른 쓰레드가 이를 완료할 수 있게 허용합니다.

 

아래는 위 예제를  CompletableFuture를 이용한 예제로 바꾼 코드입니다.

 

ExecutorService executorService = Executors.newFixedThreadPool(2);
int x = 1337;

CompletableFuture<Integer> a = new CompletableFuture<>();
executorService.submit(() -> a.complete(f(x)));
int b = g(x);
System.out.println(a.get() + b);

 

위 예제의 경우 CompletableFuture를 사용하기만 했지 Future로 했을때처럼 블로킹은 존재합니다.

 

CompletableFuture에서는 이러한 문제를 해결하기 위해 thenCombine 메서드를 제공합니다.

 

아래는 예제입니다.

 

ExecutorService executorService = Executors.newFixedThreadPool(10);
int x = 1337;

CompletableFuture<Integer> a = new CompletableFuture<>();
CompletableFuture<Integer> b = new CompletableFuture<>();
CompletableFuture<Integer> c = a.thenCombine(b, (y, z) -> y + z);
executorService.submit(() -> a.complete(f(x)));
executorService.submit(() -> b.complete(g(x)));

System.out.println(c.get());

 

c의 경우 a와 b의 결과가 반환되기 전까지 쓰레드로 수행되지 않습니다.

 

때문에, 이제 블로킹이 없는 코드가 완성되었습니다.

 

5. 발행-구독 그리고 리액티브 프로그래밍

Future는 한번만 실행하여 결과를 제공합니다.

반면, 리액티브 프로그래밍은 시간이 흐르면서 여러 Future 같은 객체를 통해 여러 결과를 제공합니다.

 

자바 9에서는 java.util.concurrent.Flow 인터페이스에 발행-구독 모델을 적용하여 리액티브 프로그래밍을 제공합니다.

 

플로 API는 아래와 같이 3가지로 정리할 수 있습니다.

 

  1. 구독자가 구독할 수 있는 발행자
  2. 이 연결을 구독이라 합니다.
  3. 이 연결을 이용해 메시지 또는 이벤트를 전송합니다.

 

1) 발행 구독 예제

 

아래는 발행-구독의 간단한 예제 입니다.

 

public class SimpleCell implements Publisher<Integer>, Subscriber<Integer> {
    
    private int value = 0;
    private String name;
    private List<Subscriber> subscriberList = new ArrayList<>();

    public SimpleCell(String name) {
        this.name = name;
    }

    @Override
    public void subscriber(Subscriber<? super Integer> subscriber) {
        subscriberList.add(subscriber);
    }
    
    private void notifyAllSubscribers() {
        subscriberList.forEach(subscriber -> subscriber.onNext(this.value));
    }

    @Override
    public void onNext(Integer newValue) {
        this.value = newValue;
        System.out.println(this.name + ":" + this.value);
        notifyAllSubscribers();
    }
}

 

public class FlowTest {

    public static void main(String[] args) {
        SimpleCell c3 = new SimpleCell("C3");
        SimpleCell c2 = new SimpleCell("C2");
        SimpleCell c1 = new SimpleCell("C1");

        c1.subscriber(c3);

        c1.onNext(10);
        c1.onNext(20);
    }
}

 

위 예제의 결과는 아래와 같이 출력됩니다.

 

C1 : 10
C3 : 10
C2 : 20

 

2) 역압력

 

발행-구독 모델에서는 구독자가 처리할 수 있는 양에 비해 발행자가 무수히 많은 데이터를 전달할 수 있으며, 이러한 상황을 압력이라고 합니다.

 

압력 현상이 발생 시 구독자는 처리할 양만 늘어나며 나중에는 많은 작업으로 어떤 문제가 발생할 수 있을지 모릅니다.

때문에, 구독자가 처리할 수 있을때만 발행자에게 데이터를 전달받도록 조절하는 것이 필요하며, 이를 역압력이라고 합니다.

 

플로 API에서는 Subscription을 통해 이 역압력을 지원합니다.

 

아래는 Subscription 입니다.

 

public static interface Subscription {
    public void request(long n);
    public void cancel();
}

 

Subscription 은 발행자와 구독자의 중간에서 소통을 해주는 역할로 이해하시면 됩니다.

 

아래는 Publisher, Subscriber, Subscription에 대해 그림으로 나타낸 것입니다.

 

 

6. 마무리

 

이번 포스팅에서는 Chapter15 CompletableFuture와 리액티브 프로그래밍의 컨셉의 기초에 대해 진행하였습니다.

다음에는 Chapter16 CompletableFuture : 안정적 비동기 프로그래밍에  대해 포스팅하겠습니다.

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter14의 자바 모듈 시스템에 대해 진행하도록 하겠습니다.

 

2. 압력 : 소프트웨어 유추

일반적으로 자바 언어는 유지보수가 쉬운 언어라고 합니다.

 

그 이유는 관심사 분리, 정보 은닉의 특징을 제공하기 때문입니다.

 

1) 관심사 분리

 

관심사 분리란 프로그램을 고유의 기능으로 나누는 동작을 권장하는 원칙입니다.

 

관심사 분리의 장점은 아래와 같습니다.

 

  1. 개별 기능을 따로 작업할 수 있으므로 팀이 쉽게 협업할 수 있습니다.
  2. 개별 부분을 재사용하기 쉽습니다.
  3. 전체 시스템을 쉽게 유지보수할 수 있습니다.

이러한 관심사 분리를 도울도록 자바 8에서는 모듈 시스템을 제공하였습니다.

 

2) 정보 은닉

 

정보 은닉은 어떤 부분을 변경하였을 때, 다른 부분까지 영향을 미칠 가능성을 줄일 수 있게 합니다.

 

3. 자바 모듈 시스템을 설계한 이유

 

1) 모듈화의 한계

 

자바 9 이전에는 모듈화된 소프트웨어 프로젝트를 만드는데 한계가 있었습니다.

 

자바는 클래스, 패키지, jar 세 가지 수준의 코드 그룹화를 제공합니다.

하지만 패키지와 jar 수준에서 캡슐화를 거의 지원하지 못했습니다.

 

제한된 가시성 제어

 

자바는 클래스 단위에서는 private, protected, public 으로 가시성을 제공합니다.

하지만 패키지 단위에서는 제공하고 있지 않는것이 문제가 되었습니다.

클래스는 다르고, 같은 패키지에서만 공개하여 사용하고 싶은 경우
다른 패키지에서도 사용이 가능한 public으로 해야하기 때문입니다.

 

클래스 경로

 

자바에는 태생적으로 클래스 경로와 JAR 조합에 약점을 가지고 있습니다.

 

  1. 클래스 경로에는 같은 클래스를 구분하는 버전 개념이 없습니다.
  2. 클래스 경로는 명시적인 의존성을 지원하지 않습니다.
    1. 한 JAR가 다른 JAR에 포함된 클래스 집합을 사용하라는 명시적인 의존성을 지원하지 않는다는 의미입니다.

 

 

반응형

 

 

 

4. 자바 모듈 큰 그림

자바 8에서는 모듈이라는 새로운 구조 단위를 제공합니다.

 

모듈 디스크립터는 module-info.java 라는 특별한 파일에 저장되고, 보통 패키지와 같은 폴더에 위치합니다.

 

아래는 자바 모듈 디스크립터의 구조를 도식화 한것입니다.

 

 

5. 여러 모듈 활용하기

 

1) exports 구문

 

exports는 패키지 단위로 다른 모듈에서도 사용할 수 있도록 공개하는 키워드입니다.

 

아래는 한 모듈의 exports 예제입니다.

 

module expenses.readers {
    exports com.example.expenses.readers;
    exports com.example.expenses.readers.file;
    exports com.example.expenses.readers.http;
}

 

2) requires 구문

 

requires는 의존하고 있는 모듈을 지정하는 키워드입니다.

 

기본적으로 모든 모듈은 java.base 라는 모듈을 의존하고 있습니다.

때문에, 이 java.base는 생략가능하며, java.base 가 아닌 모듈을 의존하는 경우에 requires를 사용하면 됩니다.

 

3) 이름 정하기

 

모듈명의 경우 자바 8에서 새롭게 나온 개념으로 이름을 정하는 것에 대한 정확한 규칙이 없습니다.

다만, 오라클에서는 패키지명처럼 인터넷 도메인명을 역순으로 모듈의 이름을 정하도록 추천하고 있습니다.

 

6. 모듈 정의와 구문들

모듈 정의는 module-info.java 파일에 해야하며, 이 파일은 src/main/java 디렉터리에 있어야 합니다.

 

정의할때 사용하는 구문들은 위에서 살펴본 requires, exports 말고도,  requires-transitive, exports-to, open, opens, uses, provides 구문들이 있습니다.

 

1)  requires-transitive

 

이 구문은 다른 모듈이 제공하는 공개 형식을 한 모듈에서 사용할 수 있다고 지정할 수 있습니다.

 

아래는 예제입니다.

 

module com.iteratrlearning.ui {
    requires transitive com.iteratrlearning.core;

    exports com.iteratrlearning.ui.panels;
    exports com.iteratrlearning.ui.widgets;
}

module com.iteratrlearning.application {
    requires com.iteratrlearning.ui
}

 

결과적으로 application 모듈은 core에서 제공하는 공개 형식에 접근할 수 있습니다.

 

 

2) exports to

 

exports to 구문은 사용자에게 공개할 기능을 제한함으로 가시성을 좀 더 정교하게 제어할 수 있습니다.

 

아래는 예제 입니다.

 

module com.iteratrlearning.ui {
    requires com.iteratrlearning.core;

    exports com.iteratrlearning.ui.panels;
    exports com.iteratrlearning.ui.widgets to
            com.iteratrlearning.ui.widgetuser;
}

 

위 예제는 com.iteratrlearning.ui.widgets 에 접근 권한을 가진 사용지의 권한을 com.iteratrlearning.ui.widgetuser로 제한합니다.

 

 

3) open 과 opens

 

모듈 선언에 open 키워드를 사용하면 모든 패키지를 다른 모듈에 반사적으로 접근을 허용할 수 있습니다.

 

전체 모듈을 개발하지 않고도 opens 구문을 모듈 선언에 이용해 필요한 개별 패키지만 개방할 수 있습니다.

 

7. 마무리

 

이번 포스팅에서는 Chapter14 자바 모듈 시스템에 대해 진행하였습니다.

다음에는 Chapter15 CompletableFuture와 리액티브 프로그래밍의 컨셉의 기초에  대해 포스팅하겠습니다.

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter13의 디폴트 메서드에 대해 진행하도록 하겠습니다.

 

2. 변화하는 API

인터페이스에 새로운 메서드를 추가하는 등 인터페이스를 바꾸고 싶을때는 문제가 발생합니다. 

 

변경 인터페이스를 상속하고 있는 클래스들에 모두 영향이 가기 때문이죠.

 

이러한 문제점을 java에서는 디폴트 메서드라는 개념을 도입하여  해결하였습니다.

 

디폴트 메서드가 왜 필요한지 예제를 통해 알아보겠습니다.

 

처음 아래처럼 Resizable 인터페이스를 만들어 제공하였습니다.

 

public interface Resizable {
    int getWidth();
    int getHeight();
    void setWidth();
    void setHeight();
    void setAbsoluteSize(int width, int height);
}

public class Ellipse implements Resizable {
	...
}

 

시간이 지나 요구사항을 처리하다보니 Resizable에 setRelativeSize(int wFactor, int hFactor) 가 필요함을 깨달았습니다.

 

이때 바로 문제가 생깁니다.

 

Resizable를 구현한 클래스들은 모두 setRelativeSize 메서드를 구현해야 하기 때문입니다.

 

3. 디폴트 메서드란 무엇인가?

디폴트 메서드는 인터페이스에 구현 메서드를 놓을 수 있는 새로운 시그니쳐입니다.

 

이를 통해, 위와같은 문제점을 해결할 수 있습니다.

 

사용법으로는 default 라는 키워드로 메서드를 정의하면 됩니다.

 

아래는 디폴트 메서드를 사용한 예제입니다.

 

public interface Sized {
    int size();
    default boolean isEmpty() {
        return size() == 0;
    }
}

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

 

4. 디폴트 메서드 활용 패턴

디폴트 메서드를 활용하는 방식으로는 두가지가 있습니다.

 

  • 선택형 메서드
  • 동작 다중 상속

 

1) 선택형 메서드

 

인터페이스에는 간혹 구현 클래스에서 크게 중요하지 않은 메서드를 정의할때가 있습니다.

 

그로인해, 구현 클래스들은 메서드 오버라이드만 할 뿐 실제 바디에는 내용이 없게 하는 코드들이 생기게 됩니다.

 

하지만 디폴트 메서드를 사용하면 기본 메서드 구현을 인터페이스에 상주할 수 있어 이러한 문제를 해결하게 됩니다.

 

2) 동작 다중 상속

 

디폴트 메서드를 통해 여러 인터페이스를 상속하고 있는 클래스들은 사용할 수 있는 동작이 풍부해집니다.

 

아래는 코드로 해당 이점을 보여주는 예제입니다.

 

public interface Rotatable {
    void setRotationAngle(int angleInDegrees);
    int getRotationAngle();
    default void rotateBy(int angleInDegrees) {
        setRotationAngle(getRotationAngle() + angleInDegrees);
    }
}

public interface Resizable {
    int getWidth();
    int getHeight();
    void setWidth();
    void setHeight();
    void setAbsoluteSize(int width, int height);
    default void setRelativeSize(int wFactor, int hFactor) {
        setAbsoluteSize(getWidth() / wFactor, getHeight() / hFactor);
    }
}

public interface Moveable {
    int getX();
    int getY();
    void setX(int x);
    void setY(int y);
    default void moveHorizontally(int distance) {
        setX(getX() + distance);
    }

    default void moveVertically(int distance) {
        setY(getY() + distance);
    }
}

public class Moster implements Rotatable, Moveable, Resizable {

}

 

5. 해석 규칙

디폴트 메서드로 인해 개발자에게 장점만 제공된것은 아닙니다.

 

단점으로는 같은 시그니처를 갖는 디폴트 메서드를 상속 받는 상황이 생길 수 있다는 것입니다.

 

자바에서는 이러한 경우를 대비해 아래와 같은 3가지 규칙을 세워 메서드의 상속 우선순위를 가지도록 했습니다.

 

  1. 클래스가 항상 이긴다. 클래스나 슈퍼클래스에서 정의한 메서드가 디폴트 메서드보다 우선권을 갖습니다.
  2. 1번 다음으로는 서브 인터페이스가 이깁니다.
  3. 1번과 2번과 같은 상황이 없고, 디폴트 메서드에 대해 순위가 정해지지 않은 경우에는 상속 클래스에서 명시적으로 디폴트 메서드를 오버라이드하여 호출해야 합니다.

 

6. 마무리

이번 포스팅에서는 Chapter13 디폴트 메서드 대해 진행하였습니다.

다음에는 Chapter14 자바 모듈 시스템에 대해 포스팅하겠습니다.

반응형
반응형

1. 서론

 

이번 포스팅에서는 Chapter12의 새로운 날짜와 시간 API에 대해 진행하도록 하겠습니다.

 

2. LocalDate, LocalTime, Instant, Duration, Period 클래스

java.time 패키지에는 LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period 등의 새로운 클래스를 제공합니다.

 

1) LocalDate 와 LocalTime 사용

 

LocalDate 인스턴스는 시간을 제외한 날짜를 표현하는 불변 객체입니다.

 

LocalDate는 팩토리 메서드 of를 통해 생성이 가능합니다.

 

아래는 2017년 9월 21일의 LocalDate를 만들어 사용하는 예제입니다.

 

LocalDate date = LocalDate.of(2017, 9, 21);
int year = date.getYear(); // 2017
Month month = date.getMonth(); // 9월
int day = date.getDayOfMonth(); // 21
DayOfWeek dow = date.getDayOfWeek(); // 목요일 
int len = date.lengthOfMonth(); // 31 (월의 일 수)
boolean deal = date.isLeapYear(); // false (윤년 유무)

 

현재 날짜를 구할때는 팩토리 메서드 now를 사용하면 됩니다.

아래는 예제입니다.

 

LocalDate today = LocalDate.now();

 

아래는 LocalDate 객체에서 값을 얻는 또 다른 방법입니다.

 

int year = date.get(ChronoField.YEAR);
int month = date.get(ChronoField.MONTH_OF_YEAR);
int day = date.get(ChronoField.DAY_OF_MONTH);

 

 

LocalTime은 날짜가 아닌 시간을 제공하는 클래스입니다.

 

LocalTime 역시 of 메서드를 통해 생성이 가능하며, (시, 분), (시, 분, 초) 각각 인수로 받아 생성 가능하도록 오버로드 되어 있습니다.

 

아래는 13시 24분 50초 를 만드는 예제 입니다.

 

LocalTime time = LocalTime.of(13, 45, 20);
int hour = time.getHour();
int minute = time.getMinute();
int second = time.getSecond();

 

날짜 혹은 시간의 정보를 담고있는 문자열에서 LocalDate, LocalTime으로 만들고 싶은 경우가 있습니다.

이를 위해, LocalDate, LocalTime 은 parse 메서드를 제공합니다.

 

아래는 예제 입니다.

 

LocalDate date = LocalDate.parse("2017-09-21");
LocalTime time = LocalTime.parse("13:45:20");

 

2) 날짜와 시간 조합

 

LocalDateTime은 날짜와 시간 정보를 모두 가지고 있는 클래스입니다.

 

아래는 LocalDateTime 을 만드는 예제입니다.

 

LocalDateTime dt1 = LocalDateTime.of(2017, Month.SEPTEMBER, 21, 13, 45, 20);
LocalDateTime dt2 = LocalDateTime.of(date, time);
LocalDateTime dt3 = date.atTime(13 ,45 ,20);
LocalDateTime dt4 = date.atTime(time);
LocalDateTime dt5 = time.atDate(date);

 

반대로 LocalDateTime 에서 LocalDate, LocalTime 만을 빼내고 싶은 경우에는 아래와 같이 toLocalDate, toLocalTime 메서드를 사용하면 됩니다.

 

LocalDate date1 = dt1.toLocalDate();
LocalTime time1 = dt1.toLocalTime();

 

 

3) Instant 클래스 : 기계의 날짜와 시간

 

Instant 클래스는 유닉스 에포크 시간을 기준으로 특정 지점까지의 시간을 초로 표현합니다.

또한, 이 클래스는 나노초의 정밀도를 제공합니다.

 

아래는 Instant 인스턴스를 만드는 예제입니다.

 

Instant.ofEpochSecond(3);
Instant.ofEpochSecond(3, 0);
Instant.ofEpochSecond(2, 1000000000);
Instant.ofEpochSecond(4, -1000000000);

 

Instant 클래스 역시 정적 메서드로 now를 제공합니다.

 

Instant는 초, 나노초 정보를 가지고 있기 때문에 LocalDate, LocalTime와 같이 사람이 읽을 수 있는 정보를 제공하지 않습니다.

 

대신 Period와 Duration와는 함께 활용할 수 있습니다.

 

4) Duration 과 Period 정의

 

Duration은 두 시간 차의 정보를 가지고 있는 클래스입니다.

 

아래는 Duration을 생성하는 예제입니다. 생성시에는 LocalTime, LocalDateTime, Instant를 활용할 수 있습니다.

 

Duration d1 = Duration.between(time1, time2);
Duration d2 = Duration.between(dateTime1, dateTime2);
Duration d3 = Duration.between(instant1, instant2);

 

Period는 시간이 아닌 두 날짜 차의 정보를 가지고 있는 클래스입니다.

 

아래는 Period를 생성하는 예제입니다.

 

Period tenDays = Period.between(LocalDate.of(2017, 9, 11), LocalDate.of(2017, 9, 21));

 

Duration과 Period는 자체적으로도 생성이 가능하도록 팩토리 메서드들을 제공합니다.

 

아래는 예제입니다.

 

Period tenDays = Period.ofDays(10);
Period threeWeeks = Period.ofWeeks(3);
Period twoYearsSixMonthsOneDay = Period.of(2, 6, 1);

 

지금까지 알아본 클래스들은 모두 불변 객체로 생성하여 제공합니다.

 

 

 

반응형

 

 

 

3. 날짜 조정, 파싱, 포매팅

기존 날짜, 시간 데이터를 절대적으로 변경하고 싶은 경우가 있습니다.

 

이런 경우 with 관련 메서드를 통해 가능합니다.

with 메서드를 사용한다고 기존 객체의 값이 변하는것이 아니며, 새로운 객체에 값만을 바꿔 제공합니다.
이는 불변 객체라는 특징을 제공하기 위해서 입니다.

 

아래는 예제입니다.

 

LocalDate date1 = LocalDate.of(2017, 9 , 21);
LocalDate date2 = date1.withYear(2011); // 2011-09-21
LocalDate date3 = date2.withDayOfMonth(25); // 2011-09-25
LocalDate date4 = date3.with(ChronoField.MONTH_OF_YEAR, 2); // 2011-02-25

 

 

상대적인 방법으로도 변경도 가능합니다.

 

아래는 예제입니다.

 

LocalDate date1 = LocalDate.of(2017, 9, 21);
LocalDate date2 = date1.plusWeeks(1);
LocalDate date3 = date2.minusYears(6);
LocalDate date4 = date3.plus(6, ChronoUnit.MONTHS);

 

1) TemporalAdjusters 사용하기

 

복잡한 날짜 조정기능이 필요한 경우 with 메서드에 TemporalAdjusters 를 사용하여 해결할 수 있습니다.

 

아래는 TemporalAdjusters를 사용하는 한 예제입니다.

 

LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18
LocalDate date2 = date1.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23
LocalDate date3 = date2.with(TemporalAdjusters.lastDayOfMonth()); // 2014-03-31

 

TemporalAdjusters 에서 제공하는 메서드가 없는 경우에는 TemporalAdjuster 함수형 인터페이스를 구현하여 사용하면 됩니다.

 

아래는 TemporalAdjuster 입니다.

 

@FunctionalInterface
public interface TemporalAdjuster {
    Temporal adjustInto(Temporal temporal);
}

 

 

2) 날짜와 시간 객체 출력과 파싱

 

날짜와 시간 관련에서는 포매팅과 파싱은 서로 떨어질 수 없는 관계입니다.

 

때문에, java에서는 DateTimeFormatter를 제공하여 손쉽게 날짜나 시간을 특정 형식의 문자열로 만들어 줍니다.

 

아래는 예제입니다.

 

LocalDate date = LocalDate.of(2014, 3, 18);
String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE);
String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE);

 

반대로 문자열에서 날짜나 시간으로도 변환시 사용할 수도 있습니다.

 

아래는 예제입니다.

 

LocalDate date1 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE);
LocalDate date2 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE);

 

DateTimeFormatter 은 기존의 java.util.DateFormat과 달리 쓰레드에 안전합니다.

 

추가로 DateTimeFormatter 은 특정 패턴으로도 포매팅을 제공합니다.

 

DateTimeFormatter formatter = DateTimeFormatter.ofPattern("dd/MM/yyyy");
LocalDate date1 = LocalDate.of(2014, 3, 18);
String formattedDate = date1.format(formatter);
LocalDate date2 = LocalDate.parse(formattedDate, formatter);

 

좀 더 복합적인 포매팅을 원할시에는 DateTimeFormatterBuilder 를 사용하면 됩니다.

 

4. 마무리

이번 포스팅에서는 Chapter12 새로운 날짜와 시간 API에 대해 진행하였습니다.

다음에는 Chapter13 디폴트 메서드에 대해 포스팅하겠습니다.

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter11의 null 대신 Optional 클래스에 대해 진행하도록 하겠습니다.

 

2. 값이 없는 상황을 어떻게 처리할까?

책에서는 null 관련하여 아래 예제를 말합니다.

 

public class Person {
    private Car car;

    public Car getCar() {
        return car;
    }
}
    
public class Car {
    private Insurance insurance;

    public Insurance getInsurance() {
        return insurance;
    }
}
    
public class Insurance {
    private String name;
    
    public String getName() {
        return name;
    }
}

public String getCarInsuranceName(Person person) {
    return person.getCar().getInsurance().getName();
}

 

위의 코드는 Person이 차를 가지고 있지 않는 경우에는 NullPointerException 이 발생하게 됩니다.

 

1) 보수적인 자세로 NullPointerException 줄이기

 

위 예제에서 null를 예방하기 위해 if-else 를 사용하게 된다면 아래와 같은 코드가 될 것입니다.

 

public String getCarInsuranceName(Person person) {
    if (person != null) {
        Car car = person.getCar();
        if (car != null) {
            Insurance insurance = car.getInsurance();
            if (insurance != null) {
                return insurance.getName();
            }
        }
    }
    return "Unknown";
}

 

한눈에 봐도 코드가 난잡하며, 객체의 연관도가 깊을수록 if의 깊이는 증가됩니다.

 

가끔 깊이가 너무 깊어져 이를 예방하기 위해 아래와 같은 코드가 나올수도 있습니다.

 

public String getCarInsuranceName(Person person) {
    if (person == null) {
        return "Unknown";
    }
    Car car = person.getCar();
    if (car == null) {
        return "Unknown";
    }
    Insurance insurance = car.getInsurance();
    if (insurance == null) {
        return "Unknown"; 
    }
    
    return insurance.getName();
}

 

 

이 코드의 경우에는 깊이는 깊어지지 않지만 너무 많은 return 출구가 있어 유지보수가 어렵다는 단점을 가지게 됩니다.

 

 

 

2) null 때문에 발생하는 문제

 

위와 같이 null로 인해서 발생하는 문제로는 아래와 같습니다.

 

  1. 에러의 근원 : NullPointerException 은 자바에서 가장 흔히 발생하는 에러입니다.
  2. 코드를 어지럽힘 : null 체크를 통해 코드가 난잡하고 어지럽게 됩니다.
  3. 아무 의미가 없음 : null은 아무 의미도 표현하지 않으며, 이는 값이 없음을 표현하기에 부적합 합니다.
  4. 자바 철학에 위배된다 : 자바의 경우, 개발자에게 모든 포인터를 숨겼지만 null은 유일하게 포인터를 숨길 수 없었습니다.
  5. 형식 시스템에 구멍을 만든다 : null은 무형식이며 정보를 포함하고 있지 않으므로 모든 참조 형식에 null을 할당 할 수 있고, 이는 결국 위험한 코드를 만들게 됩니다.

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

 

3. Optional 클래스 소개

 

자바 8 에서는 null 문제를 해결하기 위해 Optional<T> 라는 클래스를 제공합니다.

 

이는 사실상 단순히 null를 위한 Wrapper 클래스입니다.

 

아래는 Optional 를 간단히 보여주는 그림입니다.

 

 

 

모든 null 참조를 Optional로 대치하는것은 바람직하지 않습니다.

Optional은 객체에 대해서 null 체크, null 인 경우 대처를 어떻게 할지를 도와주는 역할입니다.

 

때문에, Optional를 사용한다고 NullPointerException이 나지 않는것은 아닙니다.

 

4. Optional 적용 패턴

 

1) Optional 객체 만들기

 

Optional 도 클래스이기 때문에 객체를 생성해서 사용해야 합니다.

 

1. 빈 Optional

 

아래와 같이 빈 Optional 객체를 생성할 수 있습니다.

 

Optional<Car> optCar = Optional.empty();

 

2. null이 아닌 값으로 Optional 만들기

 

아래와 같은 기존에 있는 객체로 Optional 객체를 만들 수도 있습니다.

 

Optional<Car> optCar = Optional.of(car);

 

단, 이 경우 인자인 car가 null인 경우 NullPointerException이 발생합니다.

 

 

3. null 값으로 Optional 만들기

 

아래와 같은 방법으로도 Optional 객체를 만들 수 있습니다.

 

Optional<Car> optCar = Optional.ofNullable(car);

 

2번의 of 메서드와의 차이점으로는 car가 null인 경우 NullPointerException 가 아닌 빈 Optional을 반환한다는 점입니다.

 

 

2) 맵으로 Optional의 값을 추출하고 변환하기

 

Optional 클래스는 스트림 메서드와 비슷한 map 메서드를 지원합니다.

 

아래와 같이 map 을 사용할 수 있습니다.

 

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
Optional<String> name = optInsurance.map(Insurance::getName);

 

만약, Optional이 비어있으면 아무일도 일어나지 않습니다.

 

 

3) flatMap으로 Optional 객체 연결

 

스트림 메서드의 flatMap 비슷한 기능의 메서드도 제공하고 있습니다.

 

Optional의 flatMap도 스트림 메서드와 동일하게 Optional<Optional<T>> 와 같은 depth 가 생기는 것을 Optional<T> 로 평준화 해주는 메서드입니다.

 

 

4) Optional로 자동차의 보험회사 이름 찾기

 

그럼 이제 배운 Optional를 사용하여 예제를 해결하게 되면 아래와 같은 코드가 나오게 됩니다.

 

public class Person {
    private Car car;
    public Optional<Car> getCarAsOptional() {
        return Optional.ofNullable(car);
    }
}

public class Car {
    private Insurance insurance;
    public Optional<Insurance> getInsuranceAsOptional() {
        return Optional.ofNullable(insurance);
    }
}

public class Insurance {
    private String name;
    public String getName() {
        return name;
    }
}

public String getCarInsuranceName(Person person) {
    return Optional.of(person)
            .flatMap(Person::getCarAsOptional)
            .flatMap(Car::getInsuranceAsOptional)
            .map(Insurance::getName)
            .orElse("other value");
}

 

Optional 클래스는 필드 형식으로 사용할 것을 가정하지 않아, Serializable 인터페이스를 구현하지 않습니다.
때문에, 위와 같이 get 메서드에만 Optional 를 사용하는것을 권장합니다.

 

5) Optional 스트림 조작

 

자바 9 에서는 Optional 의 스트림 처리를 제공하기 위해 Optional에 stream() 메서드를 제공합니다.

 

아래는 Optional의 stream 함수를 사용한 예제입니다.

 

public Set<String> getCarInsuranceNames(List<Person> persons) {
    return persons.stream()
            .map(Person::getCarAsOptional)
            .map(optCar -> optCar.flatMap(Car::getInsuranceAsOptional))
            .map(optIns -> optIns.map(Insurance::getName))
            .flatMap(Optional::stream)
            .collect(Collectors.toSet());
}

 

stream 메서드를 지원함으로 위와 같이 더욱 간단하게 null 처리를 할 수 있게 되었습니다.

 

하지만, 마지막 결과를 얻기 위해서는 빈 Optional 은 제거하고 있는것은 언랩해야 하는 문제가 있습니다.

 

이는 위 예제에서 Optional::stream 메서드로 해결할 수 있습니다.

stream 메서드는 값이 있는 것 만을 Stream에 담아서 전달하기 때문입니다.

 

 

6) 디폴트 액션과 Optional 언랩

 

아래는 Optional 클래스가 가지고 있는 디폴트 액션입니다.

 

디폴트 액션 설명
get Optional 의 값을 가져오는 메서드입니다.
만약, 값이 없다면 NoSuchElementException이 발생하기 때문에 위험한 메서드입니다.
orElse(T other) orElse는 값이 없는경우 인자인 other를 반환합니다.
orElseGet(Supplier<? extends T> other) orElseGet는 orElse의 게으른 버전입니다.
값이 없는경우에서야 Supplier 를 수행하여 값을 반환하기 때문입니다.
orElseThrow(Supplier<? extends T> exceptionSupplier) orElseThrow는 값이 없는 경우 예외를 발생합니다.
ifPresend(Consumer<? super T> consumer) ifPresend는 값이 존재할때만, 인자의 Consumer를 수행합니다.
ifPresendOrElse(Consumer<? super T> action, Runnable emptyAction) ifPresendOrElse는 자바 9에서 추가된 메서드로,
위의 ifPresent와의 차이점으로는 값이 비어있는 경우 Runnable 인자를 실행한다는 점입니다.

 

7) 필터로 특정값 거르기

 

Optional은 filter 메서드를 지원하고 있습니다.

 

이 메서드는 프레디케이트를 인자로 받으며, Optional 객체가 값을 가지고 있는 경우 프레디 케이트를 적용하고, 값이 없는경우에는 빈 Optional를 반환합니다.

프레디 케이트 적용 결과가 false의 경우에도 빈 Optional 을 반환합니다.

 

아래는 filter 사용 예제 입니다.

 

Optional<Insurance> optInsurance = Optional.ofNullable(insurance);
optInsurance.filter(insurance -> "CabridgeInsurance".equals(insurance.getName()))
            .ifPresent(x -> System.out.println("ok"));

 

6. 마무리

 

이번 포스팅에서는 Chapter11 null 대신 Optional 클래스에 대해 진행하였습니다.

다음에는 Chapter12 새로운 날짜와 시간 API에 대해 포스팅하겠습니다.

 

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter10의 람다를 이용한 도메인 전용 언어에 대해 진행하도록 하겠습니다.

 

2. 도메인 전용 언어

도메인 전용 언어 ( = Domain Specific Languages )는 특정 비즈니스 도메인의 문제를 해결하기 위해 만든 언어입니다.

간단히, 특정 비즈니스 도메인을 인터페이스로 만든 API라고 말할 수 있습니다.

 

아래 2가지를 생각하면서 DSL을 개발해야 합니다.

 

  • 코드의 의도가 개발자가 아니더라도 이해할 수 있도록 해야합니다.
  • 가독성을 높여 유지보수를 쉽게 할 수 있도록 해야합니다.

 

1) DSL의 장점과 단점

 

DSL은 비즈니스 의도와 가독성 측면에서는 좋지만, 해당 코드가 올바른지 검증과 유지보수를 해야하는 책임이 잇따르게 됩니다.

 

때문에, 장점과 단점이 있습니다.

 

아래는 DSL의 장점입니다.

 

  • 간결함 : 비즈니스 로직을 캡슐화하여 반복을 피하고 간결해집니다.
  • 가독성 : 도메인 영역의 용어를 사용하므로 비 도메인 전문가도 코드를 이해 할 수 있습니다.
  • 유지보수 : 간결함과 가독성으로 인해 어플리케이션의 유지보수가 좋습니다.
  • 높은 수준의 추상화 : 도메인과 같은 추상화 수준에서 동작하므로 도메인의 문제와 직접적으로 관련되지 않은 세부사항을 숨깁니다.
  • 집중 : 비즈니스 도메인을 표현하기 위한 언어이므로 특정 코드에 집중할 수 있습니다.
  • 관심사 분리 : 인프라구조 관련된 문제와 독립적으로 비즈니스 관련된 코드가 분리되어 집니다.

 

아래는 DSL의 단점입니다.

 

  • DSL 설계의 어려움 : 제한적인 언어에 도메인 지식을 담는것이 쉬운 작업은 아닙니다.
  • 개발 비용 : DSL을 프로젝트에 추가하는 것은 많은 비용과 시간이 소모됩니다.
  • 추가 우회 계층 : DSL 은 새로운 계층으로 기존 조메인 모델을 감싸는 형태가 되며, 계층을 최대한 작게 만들어 성능문제가 발생할 수 있습니다.
  • 새로 배워야 하는 언어 : DSL도 결국에는 언어이기 때문에, 해당 언어를 배워야합니다.
  • 호스팅 언어 한계 : 자바와 같이 엄격한 문법을 가진 언어는 사용자 친화적 DSL을 만드는데 한계가 있습니다.

 

2) JVM에서 이용할 수 있는 다른 DSL 해결책

 

DSL은 내부 DSL외부 DSL 이라는 카테고리로 나눌 수 있습니다.

 

  • 내부 DSL : 순수 자바코드 같은 기존 호스팅 언어를 기반으로 구현한 언어
  • 외부 DSL : 호스팅 언어와는 독립적으로 자체의 문법을 가지는 언어 (ex : SQL)

추가로 최근 자바는 아니지만 스칼라, 코틀린과 같이 JVM에서 실행되는 언어들이 나옴으로 인해 다중 DSL이라는 카테고리가 추가 되었습니다.

 

 

1.  내부 DSL

 

엄격한 문법을 가진 자바의 경우 DSL을 구현하는데 한계가 있었습니다.

 

하지만, 자바 8에서 나온 람다, 메서드 참조 등을 이용해 한계는 어느정도 해결할 수 있게 되었습니다.

 

자바를 통해 DSL을 구현함으로 얻는 장점은 아래와 같습니다.

 

  1. 새로운 언어와 기술을 배울 노력이 줄어듭니다.
  2. 다른 언어가 아닌 자바로 DSL을 구현하게 되면, 나머지 코드와 같이 컴파일이 가능해집니다.
  3. 자바를 사용하는 IDE의 혜택을 누릴수 있습니다.
  4. 자바는 유지보수에는 좋기 때문에 향후 도메인 변경 이슈에 대해 용이합니다.

 

2. 다중 DSL

 

JVM에서 동작하는 다양한 언어가 있어, 자바 어플리케이션이지만 DSL은 스칼라같은 언어로 대체가 되어 유연해질 수 있습니다.

 

스칼라가 자바에 비해 함수형 언어에 가까워 DSL을 구현하기에는 적합합니다.
이유는 함수형 언어는 객체지향보다 좀 더 코드가 직관적이기 때문입니다.

 

하지만 아래와 같은 단점도 존재합니다.

 

  • 새로운 프로그래밍을 배우거나 팀원 중 해당 언어에 대해 숙련자가 있어야 합니다.
  • 두개 이상의 언어가 혼재하므로 컴파일 빌드 시 과정을 개선해야 합니다.
  • 같은 JVM에서 동작은 하지만 사실상 100% 자바와 호환이 되는것은 아닙니다.

 

3. 외부 DSL

 

외부 DSL을 구현하기 위해서는 자신만의 문법과 구문으로 새 언어를 설계해야합니다.

 

또한, 새 언어를 파싱하고 파서의 결과를 분석하여 외부 DSL을 실행할 코드도 만들어야 합니다.

 

이러한 작업들은 매우 어려우며 잘못하면 성능이슈와 더불어 프로그램이 안전하지 않을 수 있습니다.

 

하지만 자신만의 언어를 설계함으로 인해 무한한 유연성을 취할 수 있다는 장점은 존재합니다.

또한, 자바코드와 외부 DSL코드의 명확한 분리가 된다는 장점도 존재합니다.

 

3. 최신 자바 API의 작은 DSL

자바 8에서 등장한 람다, 디폴트 메서드로 인해 작은 DSL을 제공하고 있다고 볼 수 있습니다.

 

아래는 자바 컬렉션에서 제공하는 작은 정렬 DSL입니다.

 

Collections.sort(persons, comparing(Person::getAge).reverse());

 

위 comparing, reverse와 같은 것들이 최신 자바에서 제공하는 DSL로 볼 수 있습니다.

 

자바 개발자가 아니더라도, comparing을 통해 비교를 하고, reverse를 보고 반대로 정렬하도록 되어있다는 것을 볼 수 있기 때문입니다.
또한, 내부적으로 어떠한 일을 하는지 숨겨놨기 때문에 자바로 만든 DSL로 볼 수 있습니다.

 

 

1) 스트림 API는 컬렉션을 조작하는 DSL

 

Stream 인터페이스는 네이티브 자바 API에 작은 내부 DSL을 적용한 좋은 예입니다.

 

List<String> errors = Files.lines(Paths.g(fileName))
        .filter(line -> line.startsWith("ERROR"))
        .limit(40)
        .collect(Collectors.toList());

 

위의 filter, limit, collect 모두 작은 DSL로 볼 수 있습니다.

 

 

2) 데이터를 수집하는 DSL인 Collectors

 

위에서 본 filter와 limit의 경우에는 Stream 인터페이스에서 제공하는 데이터 조작 DSL이라고 볼 수 있습니다.

 

하지만 마지막 collect의 경우에는 데이터를 수집하는 DSL로 간주 할 수 있습니다.

 

Collectors 클래스 또한 디폴트 메서드를 통해 쉽세 DSL을 사용할 수 있습니다.

 

다만, 가독성 측면에서 Collectors는 좋지 않을때가 있는데 아래는 그 예로 Comparator와 비교한 코드입니다.

 

// Collectors
Map<String, Map<Color, List<Car>>> carsByBrandAndColor =
                cars.stream().collect(Collectors.groupingBy(Car::getBrand), Collectors.groupingBy(Car::getColor));

// Comparator
Comparator comparator = Comparator.comparing(Person::getAge).thenComparing(Person::getName);

 

위는 Collectors의 중첩함수와 Comparator의 비교 함수입니다.

 

Collectors의 중첩의 경우 메서드 안에 메서드가 있는 형태입니다.

이는 Comparator의 체이닝메서드 형태보다 가독성 측면에서 떨어지게 됩니다.

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

4. 자바로 DSL을 만드는 패턴과 기법

 

위 3번의 Collectors 와 같이 제공하는 DSL이 모두 좋지 않을 수 있습니다.

때문에 이번에는 직접 DSL을 만드는 패턴과 기법에 대해 소개하겠습니다.

 

먼저 들어가기에 앞서 DSL은 특정 도메인 모델을 위한 언어이므로 아래와 같은 도메인을 설정하고 시작하겠습니다.

 

@Getter
@Setter
public class Stock {
    private String symbol;
    private String merket;
}


@Getter
@Setter
public class Trade {
    
    public enum Type {BUY, SELL};
    private Type type;
    private Stock stock;
    private int quantity;
    private double price;
    
    public double getValue() {
        return quantity * price;
    }
}



public class Order {

    @Getter
    private String customer;
    private List<Trade> trades = new ArrayList<>();
    
    public void addTrade(Trade trade) {
        trades.add(trade);
    }
    
    public double getValue() {
        return trades.stream().mapToDouble(Trade::getValue).sum();
    }
    
}

 

아래는 위 도메인 모델을 이용하여 BigBank라는 고객이 요청한 두 거래를 포함하는 주문을 만들은 예제입니다.

 

Order order = new Order();
order.setCustomer("BigBank");

Trade trade1 = new Trade();
trade1.setType(Trade.Type.BUY);

Stock stock1 = new Stock();
stock1.setSymbol("IBM");
stock1.setMerket("NYSE");

trade1.setStock(stock1);
trade1.setPrice(125.00);
trade1.setQuantity(80);
order.addTrade(trade1);

Trade trade2 = new Trade();
trade2.setType(Trade.Type.SELL);

Stock stock2 = new Stock();
stock2.setSymbol("GOOGLE");
stock2.setMerket("NASDAQ");

trade2.setStock(stock2);
trade2.setPrice(375.00);
trade2.setQuantity(50);
order.addTrade(trade2);

 

위 코드는 한눈에 봐도 장황하며 비개발자가 이해하기는 더욱 불가능한 코드입니다.

 

직관적으로 도메인 모델을 반영할 수 있는 DSL이 필요한 시점입니다.

 

1) 메서드 체인

 

DSL에서 가장 흔한 방식 중 하나가 메서드 체인 방식입니다.

 

메서드 체인을 사용하면 위에서 도메인 객체를 만들기 위한 장황한 코드는 아래와 같이 바뀔수 있습니다.

 

Order order = forCustomer("BigBank")
        .buy(80)
        .stock("IBM")
        .on("NYSE")
        .at(125.00)
        .buy(50)
        .stock("GOOGLE")
        .on("NASDAQ")
        .at(375.00)
        .end();

 

위와 같은 DSL을 제공하기 위해서는 몇개의 빌더 클래스를 만들어야 합니다.

 

public class MethodChainingOrderBuilder {
    
    public final Order order = new Order();
    
    private MethodChainingOrderBuilder(String customer) {
        order.setCustomer(customer);
    }
    
    public static MethodChainingOrderBuilder forCustomer(String customer) {
        return new MethodChainingOrderBuilder(customer);
    }
    
    public TradeBuilder buy(int quantity) {
        return new TradeBuilder(this, Trade.Type.BUY, quantity);
    }
    
    public TradeBuilder sell(int quantity) {
        return new TradeBuilder(this, Trade.Type.SELL, quantity);
    }
    
    public MethodChainingOrderBuilder addTrade(Trade trade) {
        order.addTrade(trade);
        return this;
    }
    
    public Order end() {
        return order;
    }
}

 

public class TradeBuilder {
    private final MethodChainingOrderBuilder builder;
    public final Trade trade = new Trade();

    public TradeBuilder(MethodChainingOrderBuilder builder, Trade.Type type, int quantity) {
        this.builder = builder;
        trade.setType(type);
        trade.setQuantity(quantity);
    }

    public StockBuilder stock(String symbol) {
        return new StockBuilder(builder, trade, symbol);
    }
}

 

public class StockBuilder {
    private final MethodChainingOrderBuilder builder;
    private final Trade trade;
    private final Stock stock = new Stock();

    public StockBuilder(MethodChainingOrderBuilder builder, Trade trade, String symbol) {
        this.builder = builder;
        this.trade = trade;
        stock.setSymbol(symbol);
    }

    public TradeBuilderWithStock on(String market) {
        stock.setMerket(market);
        trade.setStock(stock);
        return new TradeBuilderWithStock(builder, trade);

    }
}

 

@AllArgsConstructor
public class TradeBuilderWithStock {
    private final MethodChainingOrderBuilder builder;
    private final Trade trade;

    public MethodChainingOrderBuilder at(double price) {
        trade.setPrice(price);
        return builder.addTrade(trade);
    }
}

 

이러한 빌드 클래스를 이용하여 좀 더 직관적인 도메인 객체를 생성하는 DSL을 만들 수 있게 되었습니다.

 

하지만, 복잡한 빌드 클래스를 만들어야 한다는 단점이 있습니다.

 

 

2) 중첩된 함수 이용

 

중첩된 함수 DSL 패턴은 다른 함수안에 함수를 이용해 도메인 모델을 만듭니다.

 

아래는 중첩 함수 DSL를 사용하여 도메인 객체를 만드는 예제 입니다.

 

Order order = order("BigBank",
        buy(80,
                stock("IBM", on("NYSE")), at(125.00)),
        sell(50,
                stock("GOOGLE", on("NASDAQ")), at(375.00))
);



public class NestedFunctionOrderBuilder {
    
    public static Order order(String customer, Trade... trades) {
        Order order = new Order();
        order.setCustomer(customer);
        Stream.of(trades).forEach(order::addTrade);
        return order;
    }
    
    public static Trade buy(int quantity, Stock stock, double price) {
        return buildTrade(quantity, stock, price, Trade.Type.BUY);
    }

    public static Trade sell(int quantity, Stock stock, double price) {
        return buildTrade(quantity, stock, price, Trade.Type.SELL);
    }
    
    private static Trade buildTrade(int quantity, Stock stock, double price, Trade.Type buy) {
        Trade trade = new Trade();
        trade.setQuantity(quantity);
        trade.setType(buy);
        trade.setStock(stock);
        trade.setPrice(price);
        return trade;
    }
    
    public static double at(double price) {
        return price;
    }
    
    public static Stock stock(String symbol, String market) {
        Stock stock = new Stock();
        stock.setSymbol(symbol);
        stock.setMerket(market);
        return stock;
    }
    
    public static String on(String market) {
        return market;
    }
}

 

 

메서드 체인에 비해 함수의 중첩 방식이 도메인 객체 계층 구조에 그대로 반영된다는 것이 장점입니다.

 

하지만 이러한 DSL은 사용시 괄호가 많아진다는 단점이 있습니다.

 

 

3) 람다 표현식을 이용한 함수 시퀀싱

 

위 중첩함수 DSL을 이번에는 람다를 사용하여 더욱 깔끔하게 만들 수 있습니다.

 

아래는 예제입니다.

 

Order order = order( o-> {
    o.forCustomer("BigBank");
    o.buy( t -> {
        t.quantity(80);
        t.price(125.00);
        t.stock( s -> {
            s.symbol("IBM");
            s.market("NYSE");
        });
    });
    o.sell(t -> {
        t.quantity(50);
        t.price(375.00);
        t.stock(s -> {
            s.symbol("GOOGLE");
            s.market("NASDAQ");
        });
    });
});

 

public class LambdaOrderBuilder {
    private Order order = new Order();
    
    public static Order order(Consumer<LambdaOrderBuilder> consumer) {
        LambdaOrderBuilder builder = new LambdaOrderBuilder();
        consumer.accept(builder);
        return builder.order;
    }
    
    public void forCustomer(String customer) {
        order.setCustomer(customer);
    }
    
    public void buy(Consumer<LambdaTradeBuilder> consumer) {
        trade(consumer, Trade.Type.BUY);
    }

    public void sell(Consumer<LambdaTradeBuilder> consumer) {
        trade(consumer, Trade.Type.SELL);
    }
    
    private void trade(Consumer<LambdaTradeBuilder> consumer, Trade.Type type) {
        LambdaTradeBuilder builder = new LambdaTradeBuilder();
        builder.trade.setType(type);
        consumer.accept(builder);
        order.addTrade(builder.trade);
    }
}
public class LambdaTradeBuilder {
    public Trade trade = new Trade();

    public void quantity(int quantity) {
        trade.setQuantity(quantity);
    }

    public void price(double price) {
        trade.setPrice(price);
    }

    public void stock(Consumer<LambdaStockBuilder> consumer) {
        LambdaStockBuilder builder = new LambdaStockBuilder();
        consumer.accept(builder);
        trade.setStock(builder.stock);
    }
}
public class LambdaStockBuilder {
    public Stock stock = new Stock();

    public void symbol(String symbol) {
        stock.setSymbol(symbol);
    }

    public void market(String market) {
        stock.setMerket(market);
    }
}

 

위 방법은 앞서 봤던 2가지 방법의 장점을 모두 가지고 있습니다.


위 3가지 방법은 모두 장단점을 가지고 있으며 선호에 따라 3가지의 방법을 조합하여 사용해도 됩니다.

 

4) DSL에 메서드 참조 사용하기

 

DSL을 만들때 메서드 참조를 사용하여 더욱 직관적으로 만들 수 있습니다.

 

아래는 위 주식 거래 도메인에서 계산을 하는 DSL을 만드는 예제입니다.

 

double value = new TaxCalculator()
    .withTaxRegional()
    .withTaxSurcharge()
    .calculate(order);

 

public class TaxCalculator {
    private boolean useRegional;
    private boolean useGeneral;
    private boolean useSurcharge;

    public TaxCalculator withTaxRegional() {
        useRegional = true;
        return this;
    }

    public TaxCalculator withTaxGeneral() {
        useGeneral = true;
        return this;
    }

    public TaxCalculator withTaxSurcharge() {
        useSurcharge = true;
        return this;
    }

    public double calculate(Order order) {
        return calculate(order, useRegional, useGeneral, useSurcharge);
    }

    public static double calculate(Order order, boolean useRegional, boolean useGeneral,
                                   boolean useSurcharge) {
        double value = order.getValue();
        if(useRegional) value = Tax.regional(value);
        if(useGeneral) value = Tax.general(value);
        if(useSurcharge) value = Tax.surcharge(value);
        return value;
    }
}

 

위의 TaxCalculator 클래스는 각 boolean 값을 사용하여 확장성 부분에서 미약합니다.

하지만, 메서드 참조를 통해 리팩터링이 가능합니다.

 

아래는 메서드 참조를 통해 리팩터링한 코드입니다.

 

double value = new TaxCalculator()
    .with(Tax::regional)
    .with(Tax::surcharge)
    .calculate(order);

 

public class TaxCalculator {
    public DoubleUnaryOperator taxFunction = d -> d;

    public TaxCalculator with(DoubleUnaryOperator f) {
        taxFunction = taxFunction.andThen(f);
        return this;
    }

    public double calculate(Order order) {
        return taxFunction.applyAsDouble(order.getValue());
    }
}

 

5. 마무리

이번 포스팅에서는 Chapter10 람다를 이용한 도메인 전용 언어에 대해 진행하였습니다.

다음에는 Chapter11 null 대신 Optional 클래스에 대해 포스팅하겠습니다.

반응형
반응형

1. 서론

이번 포스팅에서는 Chapter9의 리팩터링, 테스팅, 디버깅 에 대해 진행하도록 하겠습니다.

 

2. 가독성과 유연성을 개선하는 리팩터링

 

람다를 사용한 코드는 동작 파라미터화를 통해 다양한 요구사항에 대응할 수 있습니다.

 

1) 코드 가독성 개선

 

코드 가독성이란 "어떤 코드를 다른 사람도 쉽게 이해할 수 있음" 을 의미합니다.

 

람다를 사용하게되면 이 코드 가독성을 높일 수 있습니다.

 

2) 익명 클래스를 람다 표현식으로 리팩터링하기

 

아래는 익명클래스를 람도로 리팩터링한 예제입니다.

 

Runnable r1 = new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello");
        
    }
};

Runnable r2 = () -> System.out.println("Hello");

 

한눈에 봐도 코드가 간결해지면서 가독성이 올라간것을 볼 수 있습니다.

 

하지만 모든 익명 클래스를 람다로 바꿀수 있는것은 아닙니다.

 

1. 익명 클래스에서 사용한 this, super는 람다와 다릅니다.

 

익명클래스의 this는 익명클래스 자신을 가리키는 반면,

람다는 this의 경우 람다를 감사는 클래스를 가리킵니다.

 

2. 익명 클래스는 감싸고 있는 클래스의 변수를 가릴수 있지만 람다는 가릴수 없습니다.

 

아래는 예제입니다.

 

int a = 10;

Runnable r1 = new Runnable() {
    @Override
    public void run() {
        int a =2; // 정상 동작
        System.out.println(a)
    }
};

Runnable r2 = () -> {
    int a= 2; // 컴파일 에러
    System.out.println(a);
};

 

3. 익명클래스는 람다 표현식으로 바꿀시 컨텍스트 오버로딩에 따른 모호함이 발생합니다.

 

이런 경우, 명시적 형변환을 통해 모호함을 제거할 수 있습니다.

 

아래는 예제입니다.

 

interface Task {
    void execute();
}

public static void doSomething(Task a) {a.execute();};
public static void doSomething(Runnable r) {r.run();};

public static void main(String[] args) {
    doSomething((Task)() -> System.out.println("Danger Danger!!"));
}

 

3) 람다 표현식을 메서드 참조로 리팩터링하기

 

람다에서 메서드 참조를 통해 더욱 가독성을 높일 수 있습니다.

 

아래는 예제입니다.

 

enum CaloricLevel {
    DIET, NORMAL, FAT
}

@Getter
@RequiredArgsConstructor
private static class Dish {
    private final String name;
    private final boolean vegetarian;
    private final int calories;
    private final Type type;
    enum Type {
        MEAT, FISH, OTHER
    }
    
    public CaloricLevel getCaloricLevel() {
        if (this.getCalories() <= 400) return CaloricLevel.DIET;
        else if (this.getCalories() <= 700) return CaloricLevel.NORMAL;
        else return CaloricLevel.FAT;
    }
}

public static void main(String[] args) {
    List<Dish> menu = Arrays.asList(
            new Dish("pork", false, 800, Dish.Type.MEAT),
            new Dish("beef", false, 700, Dish.Type.MEAT),
            new Dish("chicken", false, 400, Dish.Type.MEAT),
            new Dish("french fries", true, 530, Dish.Type.OTHER),
            new Dish("rice", true, 350, Dish.Type.OTHER),
            new Dish("season fruit", true, 120, Dish.Type.OTHER),
            new Dish("pizza", true, 550, Dish.Type.OTHER),
            new Dish("prawns", false, 300, Dish.Type.FISH),
            new Dish("salmon", false, 450, Dish.Type.FISH)
            ;
    Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = 
                    menu.stream().collect(groupingBy(Dish::getCaloricLevel));
}

 

4) 명령형 데이터 처리를 스트림으로 리팩터링하기

 

반복자를 통한 명령형 처리를 스트림으로 리팩터링할 시 더욱 가독성이 올라갑니다.

 

아래는 예제입니다.

 

List<String> dishNames = new ArrayList<>();
for (Dish dish : menu) {
    if(dish.getCalories() > 300) {
        dishNames.add(dish.getName());
    }
}

menu.parallelStream()
        .filter(d -> d.getCalories() > 300)
        .map(Dish::getName)
        .collect(Collectors.toList());

 

 

5) 코드 유연성 개선

 

람다 표현식으로 동작 파라미터화를 쉽게 가능한것을 앞절에서 살펴봤습니다.

 

이것은 결국 코드가 유연하게 돌아간다는 것을 의미하게 됩니다.

 

대표적으로 앞에서 살펴본 실행 어라운드가 있습니다.

 

 

 

 

 

 

 

반응형

 

 

 

 

 

 

 

3. 람다로 객체지향 디자인 패턴 리팩터링하기

기존 자바의 유지보수와 더욱 깔끔한 코드를 생성하기 위해 디자인 패턴을 사용했었습니다.

 

람다를 이용하면 이 디자인 패턴으로 해결하던 문제를 더욱 쉽고 간단하게 해결할 수 있습니다.

 

1) 전략

 

전략(strategy) 패턴은 런타임에 적절한 알고리즘을 선택하는 기법입니다.

 

아래는 기존 디자인 패턴으로 전략패턴을 구현한 예제입니다.

 

public interface ValidationStrategy {
    boolean execute(String s);
}

public class IsAllLowerCase implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("[a-z]+");
    }
}

public class IsNumeric implements ValidationStrategy {
    @Override
    public boolean execute(String s) {
        return s.matches("\\d+");
    }
}

public class Validator {
    private final ValidationStrategy validationStrategy;
    public Validator(ValidationStrategy v) {
        this.validationStrategy = v;
    }
    public boolean validate(String s) {
        return validationStrategy.execute(s);
    }
}

public static void main(String[] args) {
    Validator numericValidator = new Validator(new IsNumeric());
    boolean b1 = numericValidator.validate("aaaa");
    Validator lowerValidator = new Validator(new IsAllLowerCase());
    boolean b2 = lowerValidator.validate("bbbb");
}

 

위에는 구현체를 모두 정의하여 사용해야합니다.

 

하지만 람다를 사용하게 되면 아래와 같이 리팩터링이 가능합니다.

 

public interface ValidationStrategy {
    boolean execute(String s);
}

public class Validator {
    private final ValidationStrategy validationStrategy;
    public Validator(ValidationStrategy v) {
        this.validationStrategy = v;
    }

    public boolean validate(String s) {
        return validationStrategy.execute(s);
    }
}

public static void main(String[] args) {
    Validator numericValidator = new Validator( s -> s.matches("[a-z]+"));
    boolean b1 = numericValidator.validate("aaaa");

    Validator lowerValidator = new Validator(s -> s.matches("\\d+"));
    boolean b2 = lowerValidator.validate("bbbb");
}

 

2) 템플릿 메서드

 

템플릿 메서드 패턴은 알고리즘의 일부만을 고쳐야하는 상황에 필요합니다.

 

아래는 디자인 패턴으로 템플릿 메서드 패턴을 구현한 예제입니다.

 

abstract class OnlineBanking {
    public void processCustomer(int id) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy(c);
    }
    
    abstract void makeCustomerHappy(Customer c);
}

 

위 같은 경우에는 OnlineBanking의 추상클래스를 상속받은 하위 클래스가 필요합니다.

 

하지만 람다를 사용한다면 굳이 추상, 하위 클래스 구조를 만들지 않아도 됩니다.

 

예제는 아래와 같습니다.

 

class OnlineBanking {
    public void processCustomer(int id, Consumer<Customer> makeCustomerHappy) {
        Customer c = Database.getCustomerWithId(id);
        makeCustomerHappy.accept(c);
    }
}

 

3) 의무 체인

 

작업 처리 객체의 체인을 만들 때는 의무 체인 패턴을 사용합니다.

 

아래는 디자인 패턴을 통해 의무체인 패턴을 구현한 코드입니다.

 

@Setter
public abstract class ProcessingObject<T> {
    protected ProcessingObject<T> successor;

    public T handle(T input) {
        T r = handleWork(input);
        if (successor != null) {
            return successor.handle(input);
        }
        return r;
    }
    abstract protected T handleWork(T input);
}
    
public class HeaderTextProcessing extends ProcessingObject<String> {

    @Override
    protected String handleWork(String input) {
        return "From Raoul, Mario and Alan: " + input;
    }
}

public class SpellCheckProcessing extends ProcessingObject<String> {

    @Override
    protected String handleWork(String input) {
        return input.replaceAll("labda", "lambda");
    }
}

public static void main(String[] args) {
    ProcessingObject<String> p1 = new HeaderTextProcessing();
    ProcessingObject<String> p2 = new SpellCheckProcessing();
    
    p1.setSuccessor(p2);
    
    String result = p1.handle("Aren't labdas really sexy?!!");
    System.out.println(result);
}

 

위와 같은 작업 처리는 Function의 andThen 메서드를 통해 간편하게 구현이 가능해졌습니다.

 

코드는 아래와 같습니다.

 

UnaryOperator<String> headerTextProcessing = s -> "From Raoul, Mario and Alan: " + s;
UnaryOperator<String> spellCheckProcessing = s -> s.replaceAll("labda", "lambda")

Function<String, String> pipeline = headerTextProcessing.andThen(spellCheckProcessing);
String result = pipeline.apply("Aren't labdas really sexy?!!");

 

 

4. 람다 테스팅

이번엔 람다를 테스팅하는 방법에 대해 살펴보겠습니다.

 

1) 보이는 람다 표현식의 동작 테스팅

 

람다는 결국 익명함수로 테스트 코드 작성 시 호출 할 수 없습니다.

 

이런 경우, 람다를 필드에 저장하여 재사용할 수 있도록 하여 람다의 로직 테스트를 수행할 수 있습니다.

 

2) 람다를 사용하는 메서드의 동작에 집중하라

 

람다의 목표는 정해진 동작을 다른 메서드에서 사용할 수 있도록 캡슐화하는 것입니다.

 

그렇기 때문에, 테스트 시 람다의 세부구현이 아닌 인풋과 람다를 통한 아웃풋으로 테스트를 진행하여 검증하면 됩니다.

 

3) 복잡한 람다를 개별 메서드로 분할하기

 

만약 람다 표현식이 복잡하여 테스트하기가 힘든경우에는,

위에서 살펴본곳처럼 메서드 참조로 수정하여 일반 메서드를 테스트하듯이 람다표현식을 테스트할 수 있습니다.

 

5. 디버깅

디버깅 시 개발자는 아래 두가지를 먼저 확인해야 합니다.

 

  • 스택 트레이스
  • 로깅

하지만, 람다 표현식와 스트림은 디버깅하기가 좀 까다롭습니다.

 

1) 스택 트레이스 확인

 

프로그램이 메서드를 호출할 때마다 호출위치, 호출할 때의 인수값, 호출된메서드의 지역변수 등 정보들이 생성되며 스택프레임에 저장됩니다.

 

스택트레이스는 이 스택프레임에서 정보를 가져와 보여주게됩니다.

 

하지만 람다의 경우 스택 트레이스가 기존과는 다르게 나오게 되며, 메서드 이름이 나오지 않습니다.

그 이유는, 람다 표현식은 이름이 없어 컴파일러가 이름을 자동을 생성하게 되면서 생겨나게 되기 때문입니다.

 

하지만 클래스와 같은 곳에 선언되어 있는 메서드 참조시에는 메서드 이름이 스택트레이스에 포함하게 됩니다.

 

안타깝게도 이것은 아직 fix되지 않은 점으로, 개발 시 스택트레이스의 확인에 불편함이 있을 수 있습니다.

 

2) 정보 로깅

 

스트림에서는 마지막 연산이 호출되는 순간 전체 스트림이 소비되어 로깅하기에 매우 까다롭습니다.

 

하지만, 이를 위해 peek 메서드를 제공하고 있습니다.

 

peek 메서드의 경우 실제로 스트림 요소를 소비하지 않고, 자신이 확인한 요소를 파이프라인의 다음 연산으로 그대로 전달하게 됩니다.

 

아래는 예제입니다.

 

List<Integer> numbers = List.of(1,2,3,4,5);
numbers.stream()
        .peek(System.out::println)
        .map(x -> x + 17)
        .collect(Collectors.toList());

 

6. 마무리

 

이번 포스팅에서는 Chapter9 리팩터링, 테스팅, 디버깅에 대해 진행하였습니다.

다음에는 Chapter10 람다를 이용한 도메인 전용 언어에 대해 포스팅하겠습니다.

반응형
반응형

1. 서론

 

이번 포스팅에서는 Chapter8의 컬렉션 API 개선 에 대해 진행하도록 하겠습니다.

 

 

2. 컬렉션 팩토리

자바 9에서는 작은 컬렉션 객체를 쉽게 만들 수 있는 팩토리 메서드를 제공하고 있습니다.

 

기존 작은 List 객체를 간단히 생성할 때는 Arrays.asList() 를 통해 만들었습니다.

Arrays 를 통해 만든 리스트에는 새 요소를 추가하거나 삭제는 불가능하지만 갱신이 가능했습니다.

 

자바 9에서는 컬렉션에 Immutable 한 객체를 생성할 수 있도록 팩토리 메서드를 제공하고 있습니다.

 

1) 리스트 팩토리

 

List.of 팩토리 메서드를 통해 간단하게 리스트를 만들 수 있습니다.

 

아래는 예제입니다.

 

List<String> friends = List.of("Raphael", "Olivia", "Thibaut");
System.out.println(friends);

 

List.of 로 만든 리스트의 경우에는 추가, 삭제, 변경 모두 되지 않는 리스트입니다.

또한, null은 허용하지 않아 의도치 않은 버그를 방지할 수 있습니다.

 

of 메서드의 경우 가비지 컬렉션 비용으로 인해 10개까지는 고정인자로 받을 수 있도록 오버로딩이 되어 있습니다.
10개 이상으로는 ... 문법을 통해 가변인자로 받고 있습니다.
이것은 Set, Map에서도 동일하게 적용되어 있습니다.

 

2) 집합 팩토리

 

List.of와 비슷하게 Set.of 를 통해 간단히 Set을 만들 수 있습니다.

 

 

3) 맵 팩토리

 

Map의 경우 자바 9에서 만들 수 있는 방법을 2가지 제공하고 있습니다.

 

1. Map.of

 

첫째로 Map.of 를 통해 만들 수 있습니다.

 

아래는 예제입니다

 

Map<String, Integer> ageOfFriends
	= Map.of("Raphael", 30, "Olivial", 25, "Thibaut", 26);
System.out.println(ageOfFriends);

 

위 방법은 10개 이하의 키와 값을 통해 만들 때 유용합니다.

 

10개가 넘어가는 Map을 만들때는 Map.ofEntries 를 사용해야합니다.

 

Map<String, Integer> ageOfFriends = Map.ofEntries(
        Map.entry("Raphael", 30),
        Map.entry("Olivial", 25),
        Map.entry("Thibaut", 26)
);

 

3. 리스트와 집합 처리

자바8 에서는 List, Set 인터페이스에 아래와 같은 메서드가 추가되었습니다.

 

  • removeIf: 프레디케이트를 만족하는 요소를 제거
  • replaceAll : 리스트에서 이용할 수 있는 기능으로 UnaryOperator 함수를 이용해 요소를 바꿈
  • sort : List 인터페이스에서 제공하는 기능으로 리스트를 정렬한다.

위 메서드는 새로운 컬렉션을 반환하는것이 아니라 메서드를 호출한 컬렉션 자체를 바꿉니다.

 

컬렉션을 바꾸는 동작은 에러를 유발하는 위험한 코드입니다.

이를 위해 위와 같은 메서드가 만들어지게 되었습니다.

 

1) removeIf

 

아래는 리스트에서 숫자로 시작되는 값을 삭제하는 코드입니다.

 

for(Transaction transaction : transactions) {
    if(Character.isDigit(transaction.getReferenceCode().charAt(0))) {
        transactions.remove(transaction);
    }
}

 

아쉽게도 위 코드는 ConcurrentModificationException을 발생시킵니다.

 

내부적으로 for-each는 Iterator 객체를 사용하게 되는데 위와같이 remove를 하게 되면,

Iterator와 컬렉션의 상태가 동기화가 되지 않기 때문입니다.

 

하지만 자바8에서 제공하는 removeIf를 통해 이 문제를 해결할 수 있습니다. 또한, 더욱 readable해진 코드가 탄생합니다.

 

아래는 removeIf를 적용한 예제 코드입니다.

 

transactions.removeIf(transaction -> Character.isDigit(transaction.getReferenceCode().charAt(0)));

 

2) replaceAll 메서드

 

자바8에서는 변경관련하여 replcateAll이라는 메서드를 제공하고 있습니다.

 

아래와 같이 stream API를 통해서도 가능합니다.

List<String> referenceCodes = List.of("a12", "C14", "b13");
referenceCodes
        .stream()
        .map(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1))
        .collect(Collectors.toList());

 

하지만 위의 코드의 경우에는 새로운 컬렉션이 생성될 뿐더러 코드가 replcaceAll보다는 길게 만들어집니다.

 

아래는 replcaceAll를 적용한 코드입니다.

 

referenceCodes.replaceAll(code -> Character.toUpperCase(code.charAt(0)) + code.substring(1));

 

 

 

반응형

 

 

 

4. 맵 처리

자바8에서는 Map 인터페이스에도 몇가지 디폴트 메서드가 추가되었습니다.

 

1) forEach 메서드

 

아래는 기존 Map을 순회하는 코드입니다.

 

for(Map.Entry<String, Integer> entry : ageOfFriends.entrySet()) {
    String friend = entry.getKey();
    Integer age = entry.getValue();
    System.out.println(friend + " is " + age + " years old");
}

 

자바 8에서는 forEach 메서드를 제공하여 아래와 같이 간편하게 코드를 사용할 수 있도록 되었습니다.

 

ageOfFriends.forEach((friend, age) -> System.out.println(friend + " is " + age + " years old"));

 

2) 정렬 메서드

 

정렬 관련해서드 아래와 같이 2개 메서드를 제공하고 있습니다

 

  • Entry.comparingByValue
  • Entry.comparingByKey

아래는 Entry.comparingByKey의 예제입니다.

 

Map<String, String> favoriteMovies = Map.ofEntries(
        Map.entry("Raphael", "Star Wars"),
        Map.entry("Cristina", "Matrix"),
        Map.entry("Olivia", "James Bond")
);

favoriteMovies
        .entrySet()
        .stream()
        .sorted(Map.Entry.comparingByKey())
        .forEachOrdered(System.out::println);

 

3) getOrDefault 메서드

 

Map에 키가 없는경우, null이 아닌 지정한 디폴트 값을 가져오도록 getOrDefault 메서드를 제공하고 있습니다.

 

이는, NullPointerException을 방지하기 위한 null 체크로직이 없어져 개발자에게 큰 이득을 주게되었습니다.

 

아래는 예제입니다.

 

Map<String, String> favoriteMovies = Map.ofEntries(
        Map.entry("Raphael", "Star Wars"),
        Map.entry("Cristina", "Matrix"),
        Map.entry("Olivia", "James Bond")
);

System.out.println(favoriteMovies.getOrDefault("Olivia", "Matrix"));
System.out.println(favoriteMovies.getOrDefault("Thibaut", "Matrix"));

 

getOrDefault 도 키가 존재하지만 값이 null인 경우에는 null을 반환하는 점을 유의해야 합니다.

 

4) 계산 패턴

 

맵에 키 존재여부에 따라 동작을 수행 후 결과를 저장해야 하는 경우가 있습니다.

 

이를 위해 아래 3개 메서드를 제공하고 있습니다.

 

  • computeIfAbsent : 제공된 키에 해당하는 값이 없으면, 키를 이용해 새 값을 계산하고 맵에 추가
  • computeIfPresent : 제공된 키가 존재하면 새 값을 계산하고 맵에 추가
  • compute : 제공된 키로 새 값을 계산하여 맵에 저장

아래는 computeIfAbsent 예제입니다.

 

Map<String, List<String>> favoriteMovies = new HashMap<>();
favoriteMovies.computeIfAbsent("Raphael", name -> new ArrayList()).add("Star Wars");

 

위와 같이 Map<String, List<String>> 같은 자료구조를 사용할때 유용하게 사용할 수 있는것을 볼 수 있습니다.

 

computeIfPresent는 computeIfAbsent와 반대라고 생각하시면 됩니다.
단, 한가지 특이점은 computeIfPresent의 경우 반환값이 null인 경우 매핑을 해제하게 됩니다.

 

5) 삭제 패턴

 

자바8 에서는 기존에 있는 remove메서드를 오버로드하여, 키가 특정한 값과 연관되었을때만 지우도록 제공하고 있습니다.

 

아래는 예제입니다.

 

favoriteMovies.remove(key, value);

 

6) 교체 패턴

 

맵의 항목을 바꾸는데 사용할 수 있는 메서드도 제공하고 있습니다.

 

  • replaceAll : BiFunction을 적용한 결과로 각 항복의 값을 교체
  • Replace : 키가 존재하면 맵의 값을 바꿈

아래는 replaceAll의 예제 코드입니다.

 

Map<String, String> favoriteMovies = new HashMap<>();
favoriteMovies.put("Raphael", "Star Wars");
favoriteMovies.put("Olivia", "James Bond");
favoriteMovies.replaceAll((friend, movie) -> movie.toUpperCase());

 

7) 합침

 

두개의 Map을 합치는 경우에는 putAll 메서드를 제공하고 있습니다. 

하지만, 이 메서드의 경우에는 두 Map에 중복된 키가 없는 경우에만 가능합니다.

 

이를 위해 자바 8에서는 merge 메서드를 제공하고 있습니다.

 

아래는 merge 메서드를 사용한 예제입니다.

 

Map<String, String> family = Map.ofEntries(
        Map.entry("Teo", "Star Wars"),
        Map.entry("Cristina", "James Bond")
);

Map<String, String> friends = Map.ofEntries(
        Map.entry("Raphael", "Star Wars"),
        Map.entry("Cristina", "Matrix")
);

Map<String, String> everyOne = new HashMap<>(family);
friends.forEach(
        (k, v) -> everyOne.merge(k, v, (movie1, movie2) -> movie1 + " & " + movie2));

 

3번째 인자로 중복키가 있는 경우 어떻게 처리할건지에 대해 받고 있는것을 볼 수 있습니다.

 

5. 개선된 ConcurrentHashMap

ConcurrentHashMap은 내부 자료구조의 특정 부분만 잠궈 동시추가, 갱신 작업을 허용하는 동시성에 친화적인 Map입니다.

 

1) 리듀스와 검색

 

ConcurrentHashMap에서는 스트림과 비슷하게 아래 3개의 연산을 지원하고 있습니다.

 

  • forEach : 각 (키, 값) 쌍에 주어진 액션 실행
  • reduce : 모든 (키, 값) 쌍을 제공된 리듀스 함수를 이용해 결과로 합침
  • search : 널이 아닌 값을 반환할 때까지 각 (키, 값) 쌍에 함수를 적용

아래는 위 연산을 수행할 수 있도록 제공하는 메서드입니다.

 

  • 키 값으로 연산 -> forEach, reduce, search
  • 키로 연산 -> forEachKey, reduceKeys, searchKeys
  • 값으로 연산 -> forEachValue, reduceValues, searchValues
  • Map.Entry 객체로 연산 -> forEachEntry, reduceEntries, searchEntries

위 연산들은 모두 ConcurrentHashMap의 상태를 잠그지 않고 연산을 수행하게 됩니다.

따라서, 연산이 수행된는 동안 변경에 대해 의존이 있으면 안됩니다.

 

2) 계수

 

기존 Map의 사이즈는 size 메서드를 사용했습니다.

하지만 size는 int형으로서 int 범위를 넘어서는 상황을 대처하기 위해 mappingCount 함수를 제공하고 있습니다.

 

mappingCount는 long형 입니다.

 

6. 마무리

 

이번 포스팅에서는 Chapter8 컬렉션 API 개선에 대해 진행하였습니다.

다음에는 Chapter9 리팩터링, 테스팅, 디버깅에 대해 포스팅하겠습니다.

반응형

+ Recent posts