728x90
반응형


본 글은 모던자바인액션 책을 참고하여 작성한 글입니다.

Java 8 등장 배경

멀티코어 CPU의 대중화

Java 8이 등장하기 이전의 Java프로그램은 대부분 코어 중 하나만을 사용하였고, 나머지 코어는 유휴 idle 상태로 두었습니다.
유휴 idle 상태인 나머지 코어를 활용하기 위해서는 멀티 스레드 기법을 활용하면 되었지만, 관리하기 어렵고 많은 문제가 발생할 우려가 크다는 단점이 있었습니다.

이를 보완하기 위해 Java 8에서는 아래와 같이 새로운 기능이 추가되었습니다.

  1. 동작 파라미터화
  2. 메서드 참조
  3. 람다
  4. 스트림 API
  5. 기타 기능들 (디폴트 메서드, Optional 등)

동작 파라미터화기능을 추가하여 특정 메서드의 파라미터로 메서드도 입력할 수 있도록 하였습니다. 간단한 메서드의 경우에는 별도의 메서드 구현 없이도 입력 값에 바로 메서드 코드를 입력할 수 있게끔 람다 기능도 추가되었습니다.
그리고 이러한 동작 파라미터화 기능이 Java 8의 핵심 기능인 스트림 API의 토대가 되었습니다.

결론적으로 Java 8에서 위 기능들이 추가되어 보다 안정적인 멀티코어 기반의 Java 프로그램을 구현하기 위한 환경이 조성되었다고 볼 수 있습니다.
이제 아래 메뉴에서 새롭게 추가된 기능으로 어떤 것들이 있는지? 왜 해당 기능들을 추가하면 보다 안정적인 프로그램 구현이 가능한지? 알아보겠습니다.

새로운 기능

동작 파라미터화

동작 파라미터화는 말그대로 특정 동작을 파라미터로 입력할 수 있는 기능을 지칭합니다. 즉, 메서드(코드)를 다른 메서드의 인수로 넘겨줄 수 있는 기능이라고 보면 됩니다.

  • 예시
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
    public boolean accept(File file) {
        return file.isHidden();
    }
});



메서드 참조

메서드 참조는 특정 메소드의 파라미터로 입력될 메소드를 지칭하기 위한 기능입니다.

위에서 언급한 동작 파라미터화 기능의 예시 코드를 보면, 가독성 측면에서 꽤나 복잡하다고 느껴집니다.
단번에 해석하기도 쉽지 않고 isHidden이라는 메서드를 활용하기 위해서 굳이 별도 메서드를 추가적으로 만들어서 파라미터로 입력하고 있습니다.

이러한 비효율을 없애기 위해 Java 8에서는 메서드 참조기능을 제공하고 있습니다.

  • As-Is
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
    public boolean accept(File file) {
        return file.isHidden();
    }
});



  • To-Be
File[] hiddenFiles = new File(".").listFiles(File::isHidden);




위의 To-Be 코드처럼 별도 메소드 구현 없이 필요한 메서드를 참조하여 파라미터로 입력할 수 있습니다.
클래스명에 ::기호를 붙이고, 그 뒤에 참조할 메서드명을 붙여서 메서드 참조를 활용할 수 있습니다.

람다

람다기능은 메서드를 다른 메서드의 파라미터로 입력할 때, 메서드를 별도로 구현하지 않고 바로 코드를 파라미터로 입력하도록 할 수 있는 기능입니다.
먼저 프레디케이트에 대해서 언급하고 넘어가겠습니다.

프레디케이트

프레디케이트는 인수로 특정 값을 받아서 true/false로 반환하는 함수를 가리키는 말입니다.

Function<Apple, Boolean> == Predicate<Apple>형태로 볼 수 있고,
입력할 Object type을 제네릭 형태로 입력하게 됩니다.

Predicate를 활용하는 방식이 좀더 표준적인 방식이고, 구현은 아래 코드와 같이 진행할 수 있습니다.

public interface Predicate<T> {
    boolean test(T t);
}

static List<Apple> filterApples(Predicate<Apple> p) {
    List<Apple> result = new ArrayList<>();
    Apple input = new Apple();
    if (p.test(input)) {
        result.add(apple);
    }
    return result;
}

위와 같이 특정 Object를 입력으로 받아 특정 기준에 부합하는지에 대한 판단을 Predicate활용을 통해 진행할 수 있습니다.
그리고 아래와 같이 Predicate 파라미터의 메서드를 호출할 수 있습니다.

public static boolean isHeavyApple(Apple apple) {
    return apple.getWeight() > 150;
}

// Predicate 파라미터 메서드
filterApples(Apple::isHeavyApple);



람다의 활용

람다기능은 앞서 언급한 것처럼 메서드를 다른 메서드의 파라미터로 입력할 때, 메서드를 별도로 구현하지 않고 바로 코드를 파라미터로 입력하도록 할 수 있는 기능입니다.

위 프레디케이트 메서드 호출을 위해 사용되는 isHeavyApple메서드는 매우 간단한 코드입니다.
람다 기능을 활용한다면, 위 프레디케이트 메서드 호출 코드를 아래 코드와 같이 별도 메서드 구현없이 간단하게 표현할 수 있습니다.

filterApples( (Apple a) -> a.getWeight() > 150 );




()안에 람다 함수의 인수로 입력할 변수를 선언해주고, ->로 연결된 선언부에서 구현할 코드를 작성해주면 됩니다.

Stream API

Stream API는 Java 8이 등장하면서 동작 파라미터화기능을 토대로 출시된 API입니다.
Stream API를 활용한다면, Java 8이전에 길고 복잡한 코드로 처리했던 로직을 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 간결하게 코드를 작성하여 처리할 수 있습니다.

  • Java 8 이전 예시코드
Map<Currency, List<Transaction>> transactionByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
    if (transaction.getPrice() > 1000) {
        Currency currency = transaction.getCurrency();
        List<Transaction> transactionsForCurrency = transactionByCurrencies.get(currency);
        if (transactionsForCurrency == null) {
            transactionsForCurrency = new ArrayList<>();
            transactionByCurrencies.put(currency, transactionsForCurrency);
        }
        transactionsForCurrency.add(transaction);
    }
}



  • Stream API 적용 에시코드
import static java.util.stream.Collectors.groupingBy;
Map<Currency, List<Transaction>> transactionByCurrencies = transactions.stream()
                                                                    .filter((Transaction t) -> t.getPrice() > 1000)
                                                                    .collect(groupingBy(Transaction::getCurrency));




위 코드에서 볼 수 있는 것처럼 Stream API를 활용하면 코드의 가독성 측면에서 이점이 있습니다.
뿐만 아니라 Stream API는 아래 문제 해결에 주안점을 두었습니다.

  1. 모호함과 반복적인 코드 문제
  2. 멀티코어 활용 어려움

이제 두 가지 문제의 관점에서 Stream API를 알아보겠습니다.

모호함과 반복적인 코드 문제 해결

기존의 컬렉션에서는 데이터를 처리할 때 아래와 같이 반복되는 패턴이 많았습니다.

  1. 주어진 조건으로 데이터를 필터링 (ex. 특정 무게를 기준으로 사과 선별)
  2. 데이터 추출 (ex. 특정 사과의 무게필드 추출)
  3. 데이터 그룹화 (ex. 숫자 리스트를 홀수와 짝수로 그룹화)
  4. 등등...

Stream API를 활용한다면, 위 반복되는 패턴을 제공되는 Stream 라이브러리를 활용하여 구현할 수 있습니다.
가령 데이터 필터링 패턴은 Stream의 Filter 라이브러리를 활용하여 구현할 수 있죠.





위처럼 패턴을 라이브러리화 시킬 수 있었던 배경은 Stream API에서는 내부반복을 활용하는 것이었습니다.
외부반복은 기존에 사용하던 방식처럼 For-loop를 활용하는 방식이고,
내부반복은 Stream 라이브러리 자체에서 진행되는 반복을 의미한다고 볼 수 있습니다.
그렇기 때문에 내부반복이 진행될 때에는 어떻게 돌아가는지 개발자가 직접 확인할 수 없죠.

멀티코어 활용 어려움 해결

Java 8 등장 이전에는 멀티스레딩 구현이 쉽지 않았습니다.
각각의 스레드에서 공유된 데이터에 접근하게 되면 동시성 문제가 발생할 수도 있는데, 각 스레드들을 잘 제어하지 못하면 데이터가 원치않은 방식으로 바뀔 수도 있습니다.

Stream API의 핵심적인 목표는 이러한 멀티스레딩 기능을 쉽게 구현하는 것입니다.
아래 Parallel 코드를 활용하면 쉽게 병렬처리를 구현할 수 있습니다.

  • Sequential
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples = inventory.stream().filter((Apple a) -> a.getWeight() > 150).collect(toList());



  • Parallel
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples = inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150).collect(toList());




Java 8 이전에는 멀티스레딩 기능을 활용하기 위한 데이터를 나누는 등의 작업을 모두 개발자가 진행해줘야 했지만,
Stream API의 parallelStream을 활용하면 Stream API 내부적으로 Java 7에서 추가된 포크-조인 프레임워크를 활용하여 멀티스레딩을 위한 작업이 진행됩니다. 아래 사진은 포크-조인 프레임워크에서 멀티스레딩 작업이 진행되는 과정을 도식화한 그림입니다.





그래서 Java 8에서는 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼( 고수준 언어로 원하는 동작을 구현했을 때, 최적의 저수준 실행방법을 선택하는 방식 ) 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공합니다. 이러한 스트림을 이용하면 에러를 자주 일으키고 이용 비용이 비싼 Synchronized를 사용하지 않아도 됩니다.

동시성 문제

멀티스레딩 기능을 활용한다면, 당연히 동시성 문제도 피해갈 수 없습니다.
이를 해결하기 위한 기존에 활용하던 첫번째 방법은 Synchronized를 활용하는 것입니다.

아래 코드와 같이 Synchronized Collection을 활용한다면,
하나의 스레드가 해당 Collection을 점유하는 시점에 Lock을 걸어 Thread Safe하게 작동될 수 있도록 합니다.

List<Interger> evenNumbers = Collections.synchronizedList(new ArrayList());




하지만, 스레드가 점유를 풀 때 Lock-Unlock 작업이 반복되기 때문에 성능이 떨어집니다.
(멀티코어 CPU의 각 코어는 별도의 캐시를 포함하고 있는데, 락을 사용하면 캐시의 동기화를 위해 속도가 느린 캐시 일관성 프로토콜 인터코어 통신이 이루어집니다.)
그래서 이를 보완하기 위한 방법으로 Stream 라이브러리의 Collect 메서드를 호출하는 것입니다.

List<Integer> evenNumbers = numbers.parallelStream()
                                .filter(number -> number % 2 == 0)
                                .collect(Collectors.toList());




Collect 메서드는 병렬처리와 동시성 여부를 확인하는 로직이 포함되어 있기 때문에,
이를 활용한다면 parallelStream이 종료되는 시점에 각 스레드에서 return되는 값들을 List로 수집하여 동시성 문제로부터 안전하게 값을 받아볼 수 있습니다.

이어서

지금까지 Java 8의 개요와 새로 추가된 기능들에 대해 간략히 알아보았습니다.
이어서 각 기능별로 어떻게 동작하는지나 활용해야 하는지 등에 대해서 자세히 알아보겠습니다.

Reference

반응형

+ Recent posts