개발서적/모던 자바 인 액션

[1장] 자바 8, 9, 10, 11 : 무슨 일이 일어나고 있는가?

hanjongho 2021. 11. 30. 17:40

모던 자바 인 액션(라울-게이브리얼 우르마 외 2명 - 한빛미디어)를 읽고 정리한 내용입니다.

1. 자바의 멀티코어 병렬성 진화과정 

빅데이터에 직면하면서, 멀티코어 컴퓨터나 컴퓨팅 클러스터를 이용해서 빅데이터를 효과적으로 처리해야할 필요성이 커졌다. 

자바 8 이전의 대부분 프로그램은 멀티코어 환경에서도 하나의 코어만을 지원했다. 나머지 코어를 활용하려면 스레드를 활용하는 것이 일반적인 방법이었다. 하지만 스레드는 관리하기가 어렵고, 많은 문제가 발생할 수 있다는 단점이 있다. 이러한 병렬 실행 환경을 쉽게 관리하고 에러가 덜 발생하는 방향으로 발전하기 위해 자바 1.0 에서는 스레드, 락, 메모리 모델까지 지 자바 5에서는 스레드 풀, 병렬 실행 컬렉션을 도입, 자바 7에서는 병렬 실행에 도움을 주는 포크/조인 프레임워크를 제공했다. 자바 역사를 통틀어 가장 큰 변화가 나타난 자바 8에서는 세가지 개념을 사용해서 병렬 실행을 새롭고 단순한 방식으로 시장에서 요구하는 기능을 효과적으로 제공했다. 

  • 스트림 API
  • 메서드에 코드를 전달하는 기법
  • 인터페이스의 디폴트 메서드

자바 8은 DB 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API 를 제공한다. 스트림 라이브러리가 최적의 저수준 실행 방법을 선택하는 방식을 통해 비용이 비싼 synchronized를 사용하지 않아도 된다.

1.1. 스트림 API - 스트림 처리

첫번째 프로그래밍 개념은 스트림 처리다. 스트림이란 한번에 한개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 이론적으로, 프로그램은 입력 스트림에서 데이터를 하나씩 읽어 들이며 마찬가지로 출력 스트림으로 데이터를 한 개씩 기록한다.

스트림의 처리 방식은 자동차 생산라인에 비유할 수 있다. 각 작업장에서는 자동차를 받아서 작업한 뒤, 다음 작업장에서 처리할 수 있도록 넘겨준다. 컨베이어 벨트 자체는 자동차를 물리적인 순서로 한개씩 운반 하지만, 각각의 작업장에서는 동시에 작업을 처리한다.

 

스트림 API의 핵심은 기존에는 한번에 한 항목을 처리했지만 이제 자바 8에서는 우리가 하려는 작업을 고수준으로 추상화해서, 일련의 스트림으로 만들어 처리할 수 있다는 것이다. 또한 스트림 파이프라인을 사용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수 있다는 부가적인 이득도 얻을 수 있다. 스레드라는 복잡한 작업을 사용하지 않고도 공짜로 병렬성을 얻을 수 있다. 

 

스트림 메서드로 전달되는 코드는 다른 코드와 동시에 실행하더라도 안전하게 실행하기 위해 공유된 가변 데이터에 접근하지 않는 순수함수이여야 한다.   

1.2 메서드에 코드를 전달하는 기법 - 동작 파라미터화로 메서드에 코드 전달하기

두번째 개념은 코드 일부를 API로 전달하는 기능이다. 예를 들어 sort 메소드에서 우리가 지정하는 순서대로 정리하도록 명령을 내린다고 했을 때, 자바 8 이전에서는 메서드를 메서드에 전달할 방법이 없었다. 자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공한다. 이 기능을 이론적으로 동작 파라미터화라고 부른다.

1.3 인터페이스의 디폴트 메서드 - 디폴트 메시지와 자바 모듈

패키지의 인터페이스를 바꿔야 하는 상황에서는 인터페이스를 구현하는 모든 클래스의 구현을 바꿔야 했으므로 사실상 불가능 했다. 자바 8부터는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메소드를 지원한다. 예를 들어, List에 직접 sort 메서드를 호출할 수 있는 이유는 정적 메서드인 Collections.sort를 호출하기 때문이다.  

2. 자바 8에 추가된 새로운 개념

기존 값을 변화시키는데 집중했던 고전적인 객체지향 프로그래밍에서 벗어나 자바 8은 함수형 프로그래밍으로 다가섰다. 함수형 프로그래밍에서는 우리가 하려던 작업이 최우선시 되며 그 작업을 어떻게 수행하는지는 별개의 문제로 취급한다. 두 개념은 상극이 될 수도 있다. 함수형 프로그래밍의 도입으로 두 가지 패러다임의 장점을 모두 챙길 수 있게 되었다. 

자바 8 이전까지 자바의 일급시민은 객체였다. 프로그래밍 언어의 핵심은 값을 바꾸는 것이고, 이 바꿀수 있는 값은 일급시민, 전달하고 (메서드의 파라미터로서) 변경할 수 없는 값은 이급시민이다. 그동안 객체와 기본타입일급시민, 메서드클래스 등은 이급시민이었다. 자바 8 설계자들은 런타임에 메서드를 전달하여 (메서드의 일급 시민화) 사용한다면 유용하게 활용할 수 있어 기능을 추가했다.

2.1 메서드와 람다를 일급 시민으로

첫번째 기능은 메서드 참조 이다. 디렉터리에서 모든 숨겨진 파일을 필터링한다고 가정하자. File 클래스는 isHidden 메서드를 제공한다.

// 자바 8 이전
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
    public boolean accept(File file) {
	return file.isHidden();
    }
})

자바 8이전에는 이미 File에 isHidden 메서드가 있는데도 FileFilter를 사용해서 복잡하게 감싸서 사용해야했다.

// 자바 8 이후
File[] hiddenFiles = new File(".").listFiles(File::isHidden);

:: 라는 자바의 메서드 참조를 이용해서 준비된 함수를 전달했다. 

두번째 기능은 람다 : 익명함수 이다. 람다(또는 익명 함수)를 포함하여 함수도 값으로 취급할 수 있다. 메서드를 직접 정의할 수 도 있지만, 당장 쓸만한 클래스나 메서드가 없을 때 익명함수인 람다를 간단하게 구현할 수 있다. 람다는 다음과 같은 형식을 띈다.

(int x) -> x + 1

2.2 코드 넘겨주기의 예제

코드를 넘겨준다, 즉 메서드를 일급시민으로 취급하는 예제를 살펴보자.

사과 클래스가 있다. 그리고 두가지 일을 하고 싶다.

  • 초록 사과만 분류하고싶다.
  • 무게가 150g이 넘는 사과만 분류하고 싶다.

여기에서 초록사과, 무게가 150g이 넘는 사과는 조건에 해당한다. 분류하는 작업, 다시 말해 특정 항목을 선택해서 반환하는 동작은 필터(filter) 라고한다.

코드 넘겨주기 이전의 방법

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (GREEN.euqlas(apple.getColor())) {
            result.add(apple);
        }
    }
    return result;
}

public static List<Apple> filterHeavyApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        if (apple.getWeight() > 150) {
            result.add(apple);
        }
    }
    return result;
}
 
코드 넘겨주기의 예제
public static boolean isGreen(Apple apple) {
    return GREEN.equals(apple.getColor());
}

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

static List<Apple> filter(List<Apple> inventory, Predicate<Apple> p) {
    List<Apple> result = new ArrayList<>();
    for (Apple apple : inventory) {
        result.add(apple);
    }
    return result;
}

2.3 메서드 전달에서 람다로

위의 isHeavy나 isGreen은 한두번만 사용될 메소드이다. 이를 매번 정의하는 것은 귀찮은 일이다. 이를 람다로 치환하여 사용할 수 있다.

filter(inventory, (Apple a) -> GREEN.equals(a.getColor());
 
즉 한번만 사용할 메서드는 따로 정의를 구현할 필요가 없다. 다만, 람다가 몇 줄 이상으로 길어진다면 이름을 가진 메서드를 정의하고 메서드 참조를 활용하는 방향이 바람직하다. 

3. 스트림

위에서는 직접 정의한 filter 메서드를 사용했지만, 자바 8은 스트림 API 내부에 filter 와 비슷한 연산 집합을 포함하는 스트림 기능을 제공한다. 자바 어플리케이션은 컬렉션을 만들고 활용한다. 그런데 컬렉션을 자바 8 이전 버전으로 다루려면 조금 많은 코드가 필요하다. 예를 들어 리스트에서 고가의 트랜잭션만 필터링한 다음에 통화로 결과를 그룹화 해야한다고 하면 아래처럼 거대한 코드를 구현 해야한다.

for (Transaction transaction: list) {
    if (transaction : transactions) {
        Currency currency = tranaction.getCurrency();
        List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
        if (transactionsForCurrency == null) {
            transactionsForCurrency = new ArrayList<>();
            transactionsByCurrencies.put(currency, transactionsForCurrency);
        }
        transactionsForCurrency.add(transaction);
    }
}

스트림 API를 사용하면 간단하게 문제를 풀수 있다.

Map<Currency, List<Transaction>> result = transactions.stream()
    .filter(t -> t.getPrice() > 1000) // 필터링
    .collect(groupingBy(Transaction::getCurrency)) // 그룹화, map으로 만들기

위의 for-each 방식 반복을 외부반복이라고하고, 스트림 API 방식은 내부반복이라고 한다. 내부반복을 사용하면 라이브러리 내부에서 모든 데이터가 처리된다. 외부 반복처럼 처리하는 것은 단일 CPU, 단일 코어로 처리하는 일이다. 따라서 거대한 데이터가 들어왔을 때 감당하기 어려울 수 있다. 스트림 API를 사용하면 다른 코어에 일을 할 당할 수 있으므로 이론상 8개 코어 컴퓨터에서 병렬로 8배 빠르게 작업을 처리할 수 있다.

4. 멀티스레딩은 어렵다.

멀티스레딩은 어렵다. 이 환경에서 각 스레드는 공유 데이터에 동시에 접근하고 갱신할 수 있어서, 잘 제어하지 못하면 데이터가 엉망이 된다. 그래서 순차적인 모델보다 다루기가 훨씬 어려워진다.

자바8은 스트림 API를 통해 "컬렉션을 처리하면서 발생하는 모호함과 반복", "멀티코어 활용 어려움" 두가지 문제 모두 해결했다. 기존 컬렉션에서는 데이터 처리할 때 반복되는 패턴이 너무 많았다. 즉 대부분 데이터를 필터링, 추출, 그룹화하는 동작이다.

 

컬렉션은 어떻게 데이터를 저장하고 접근할 지에 중점을 두고, 스트림은 데이터에 어떤 계산을 할 것인지 묘사하는 것에 중점을 두고, 스트림 내 요소를 쉽게 병렬로 처리할 수 있는 환경을 제공하는 것이 핵심이다. 

 

컬렉션을 빠르게 필터링하는 방법스트림으로 바꾸고, 병렬로 처리한 후에 리스트로 다시 복원하는 것이다. 

5. 함수형 프로그래밍에서 가져온 다른 유용한 아이디어

  • Optional은 여태까지의 NPE를 효과적으로 풀어내줄 클래스이다. Optional<T>는 값을 갖거나 갖지 않을 수 있도록하는 컨테이너 클래스이다.
  • 구조적 패턴 매칭 기법