Chapter 1. Java 8, 9, 10, and 11: what's happening?

1.1 역사의 흐름은 무엇인가?

자바 역사를 통틀어 가장 큰 변화가 자바 8에서 일어났다. 예를 들어, 다음은 사과 목록을 무게수능로 정렬하는 고전적 코드이다.

Collections.sort(inventory, new Comparator<Apple>()) {
		public int compare(Apple a1, Apple a2) {
				return a1.getWeight().compareTo(a2.getWeight());
		}
});

자바 8을 이용한 코드

inventory.sort(comparing(Apple::getWeight));

멀티코어 CPU의 대중화에 따라 자바도 멀티스레딩을 관리하기 쉽고 에러가 덜 발생하는 방향으로 진화했다.

  • 자바 1.0: 스레드와 락, 메모리 모델까지 지원
  • 자바 5: 스레드 풀, Concurrent Collection 등
  • 자바 7: 병렬 실행에 도움을 줄 수 있는 포크/조인 프레임워크 제공
  • 자바 8: 병렬 실행을 새롭고 단순한 방식으로 접근할 수 있는 방법 제공
  • 자바 9: 리액티브 프로그래밍이라는 병렬 실행 기법 제공, RxJava

자바 8은 DB SQL에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 새로운 API를 제공한다. 데이터베이스 질의 언어에서 고수준 언어로 원하는 동작을 표현하면, 구현(자바에서는 스트림 라이브러리가 이 역할을 수행)에서 최적의 저수준 실행방법을 선택하는 방식으로 동작한다. 즉, 스트림을 이용하면 에러를 자주 일으키며 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싼 synchronized를 사용하지 않아도 된다.

결국 자바 8에 추가된 스트림 API 덕분에 다른 두 가지 기능, 즉 메서드에 코드를 전달하는 간결 기법(메서드 참조와 람다)과 인터페이스의 디폴트 메서드가 존재할 수 있음을 알 수 있다.

메서드에 코드를 전달(뿐만 아니라 결과를 반환하고 다른 자료구조로 전달할 수도 있음)하는 자바 8 기법은 함수형 프로그래밍에서 위력을 발휘한다.

1.2 왜 아직도 자바는 변화하는가?

1960년대에 사람들은 완벽한 프로그래밍 언어를 찾고자 노력했다. 이후로 수천 개의 언어가 쏟아져 나왔고 학계에서는 프로그래밍 언어가 마치 생태계와 닮았다고 결론을 내렸다. 즉, 새로운 언어가 등장하면서 진화하지 않은 기존 언어는 사장되었다. 현실적으로 완벽한 언어는 존재하지 않으며, 모든 언어가 장단점을 갖고 있다.

특정 분야에서 장점을 가진 언어는 다른 경쟁 언어를 도태시킨다.

1.2.1 프로그래밍 언어 생태계에서 자바의 위치

자바는 처음부터 많은 유용한 라이브러리를 포함하는 잘 설계된 객체지향언어로 시작했다. 자바는 처음부터 스레드와 락을 이용한 동시성도 지원했다. 코드를 JVM 바이트 코드로 컴파일하는 특징 때문에 자바는 인터넷 애플릿 프로그램의 주요 언어가 되었다. JVM 바이트 코드를 자바 언어보다 중요시하는 일부 애플리케이션에서는 JVM에서 실행되는 경쟁 언어인 스칼라, 그루비 등이 자바를 대체했다. 또한 자바는 다양한 임베디느 컴퓨팅 분야를 장학하고 있다.

하지만 프로그래머는 빅데이터라는 도전에 직면하면서 멀티코어 컴퓨터나 컴퓨팅 클러스터를 이용해서 빅데이터를 효과적으로 처리할 필요성이 커졌다. 즉, 병렬 프로세싱을 활용해야 하는데 지금까지의 자바로는 충분하지 않았다.

1.2.2. 스트림 처리

스트림이란 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 어떤 프로그램의 출력 스트림은 다른 프로그램의 입력 스트림이 될 수 있다.

유닉스나 리눅스의 많은 프로그램은 표준 입력에서 데이터를 읽은 다름에, 데이터를 처리하고, 결과를 표춘 출력으로 기록한다. 유닉스 명령행에서는 파이프를 이용해서 명령을 연결할 수 있다.

cat file1 file2 | tr "[A-Z]" "[a-z]" | sort | tail -3

sort는 여러 행의 스트림을 입력으로 받아 여러 행의 스트림을 출력으로 만들어낸다. 유닉스에서는 여러 명령을 병렬로 실행하므로 cat이나 tr이 완료되지 않은 시점에서 sort가 행을 처리하기 시작할 수 있다.

자바 8에서는 스트림 API를 이용하여 파이프라인을 만들 수 있다. 기존에는 한 번에 한 항목을 처리했지만 스트림 API를 이용하면 (데이터베이스 질의처럼) 고수준으로 추상화해서 일련의 스트림으로 만들어 처리할 수 있다. 또한 스트림 파이프라인을 이용해서 입력 부분을 여러 CPU 코어에 쉽게 할당할 수도 있다. 즉, 스레드라는 복잡한 작업을 사용하지 않으면서도 공짜로 병렬성을 얻을 수 있다.

1.2.3 동작 파라미터화로 메서드에 코드 전달하기

자바 8에서는 코드 일부를 API로 전달하는 것이 가능하다. sort 예제에서 송장ID가 있고 이를 고객ID 또는 국가 코드순으로 정렬해야한다고 가정하자. 그러면 sort 명령을 통해 고객ID나 국가 코드로 송장 ID를 정렬하도록 sort에 따로 코드를 제공해야 한다.

자바 8 이전에는 메서드를 다른 메서드로 전달할 방법이 없었다. Comparator 객체를 만들어서 sort에 넘겨주는 방법은 너무 복잡하다.자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공하는데, 이를 동적 파라미터화라고 부른다. 이처럼 스트림 API는 연산의 동작을 파라미터화할 수 있는 코드를 전달한다는 사상에 기초한다.

public int compareUsingCustomerId(String inv1, String inv2) {
...
}

1.2.4 병렬성과 공유 가변 데이터

세 번째는 ‘병렬성을 공짜로 얻을 수 있다'는 개념에 기초한 개념인데, 병렬성을 얻는 대신 스트림 메서드로 전달하는 코드의 동작 방식을 조금 바꿔야 한다. 스트림 메서드로 전달하는 코드는 다른 코드와 동시에 실행하더라도 안전하게 실행될 수 있어야 한다. 이를 위해 공유된 가변 데이터에 접근하지 않아야 한다. 이러한 함수를 순수 함수, 부작용 없는 함수, 상태 없는 함수라 부른다.

독립적으로 실행될 수 있는 다중 코드 사본과 관련된 병렬성과는 다르게, 공유된 변수나 객체가 있으면 병렬성에 문제가 발생한다. 기존처럼 synchronized를 이용해서 공유된 가변 데이터를 보호하는 규칙을 만들 수 있을 것이다. (일반적으로 synchronized는 시스템 성능에 악영향을 미친다) 하지만 자바 8 스트림에서 synchronized를 사용하면 기존의 자바 스레드 API보다 쉽게 병렬성을 활용할 수 있다.

공유되지 않은 가변 데이터, 메서드, 함수 코드를 다른 메서드로 전달하는 두 가지 기능은 함수형 프로그래밍 패러다임의 핵심이다. 반면 명령형 프로그래밍 패러다임에서는 일련의 가변 상태로 프로그램을 정의한다. 공유되지 않은 가변 데이터 요구사항이란 인수를 결과로 변환하는 기능과 관련된다. 즉, 이 요구사항은 수학적인 함수처럼 함수가 정해진 기능만 수행하며 다른 부작용은 일으키지 않음을 의미한다.

1.2.5 자바가 진화해야 하는 이유

  • 제네릭의 등장으로 컴파일 수준에서 에러 검출이 훨씬 용이해졌으며 가독성이 좋아졌다.
  • Iterator 대신 for-each 루프를 사용할 수 있게 되면서 함수형 프로그래밍으로 다가섰다.

이처럼 언어는 하드웨어나 프로그래머의 변화에 부응하는 방향으로 진화해야한다.

1.3 자바 함수

프로그래밍 언어에서 함수라는 용어는 메서드 중에서 특히 정적 메서드와같은 의미로 사용된다. 자바의 함수는 이에 더해 수학적인 함수처럼 사용되며 부작용을 일으키지 않는 함수를 의미한다.

자바 8에서는 함수(Function)를 새로운 값의 타입으로 추가했다. 멀티코어에서 병렬 프로그래밍을 활용할 수 있는 스트림과 연계될 수 있도록 함수를 만들었기 때문이다. 함수를 값처럼 취급하는 것에 대한 장점은 다음과 같다.

자바 프로그램에서 조작할 수 있는 값

  • int, double 등 기본값
  • 객체(정확히는 객체의 참조, 인스턴스). new 또는 팩토리 메서드 또는 라이브러리 함수를 이용해서 얻을 수 있다.

객체 참조는 인스턴스를 가리키며, 심지어 배열도 객체다. 그런데 왜 함수가 필요할까?

프로그래밍 언어의 핵심은 값을 바꾸는 것이다. 프로그래밍 언어에서 이 값을 일급값, 또는 일급 시민이라고 부른다. 자바 프로그래밍 언어의 다양한 구조체(메서드, 클래스 등)가 구조체를 자유롭게 전달할 수는 없다. 이렇게 전달할 수 없는 구조체는 이급 시민이다.

인스턴스화한 결과각 값으로 귀결되는 클래스를 정의할 때 메서드를 아주 유용하게 활용할 수 있지만 여전히 메서드와 클래스는 그 자체로 값이 될 수 없다. 이는 매우 중요한데, 예를 들어 런타임에 메서드를 전달할 수 있다면, 즉 메서드를 일급 시민으로 만들면 프로그래밍에 유용하게 활용할 수 있다. 따라서 자바 8 설계자들은 이급 시민을 일급 시민으로 바꿀 수 있는 기능을 추가했다.

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

스칼라와 그루비 같은 언어에서 메서드를 일급값으로 사용하면 프로그래머가 활용할 수 있는 도구가 다양해지면서 프로그래밍이 수월해진다는 사실을 이미 실험을 통해 확인했다. 그래서 자바 8의 설계자들은 메서드를 값으로 취급할 수 있게 설계했다. 더불어 자바 8에서 메서드를 값으로 취급할 수 있는 기능은 스트림 같은 다른 자바 8 기능의 토대를 제공했다.

첫 번째로 메서드 참조라는 새로운 자바 8의 기능을 소개한다. 디렉터리에서 모든 숨겨진 파일을 피터링한다고 가정하자. 우선 주어진 파일이 숨겨져 있는지 여부를 알려주는 메서드를 구현해야 한다. File 클래스는 이미 isHidden 메서드를제공한다. isHidden은 File 클래스를 인수로 받아 boolean을 반환하는 함수다. 다음 예제처럼 FileFilter 객체 내부에 위치한 isHidden의 결과를 File.listFiles 메서드로 전달하는 방법으로 숨겨진 파일을 필터링할 수 있다.

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

왜 굳이 FileFilter로 isHidden을 복잡하게 감싼 다은에 FileFilter를 인스턴스화해야 할까? 이미 isHidden이라는 함수는 준비되어 있으므로 자바 8의 메서드 참조를 이용해서 listFiles에 직접 전달할 수 있다.

File[] hiddenFiles = new File(".").listFiles(File::isHidden);
  • 익명 클래스로 구현된 코드에 대한 바이트 코드
   L0
    LINENUMBER 9 L0
    NEW java/io/File
    DUP
    LDC "."
    INVOKESPECIAL java/io/File.<init> (Ljava/lang/String;)V
    NEW functional/TestClass$1
    DUP
  • 메서드 참조로 구현된 코드에 대한 바이트 코드
   L0
    LINENUMBER 8 L0
    NEW java/io/File
    DUP
    LDC "."
    INVOKESPECIAL java/io/File.<init> (Ljava/lang/String;)V
    INVOKEDYNAMIC accept()Ljava/io/FileFilter; [
      // handle kind 0x6 : INVOKESTATIC
      java/lang/invoke/LambdaMetafactory.metafactory(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
      // arguments:
      (Ljava/io/File;)Z, 
      // handle kind 0x5 : INVOKEVIRTUAL
      java/io/File.isHidden()Z, 
      (Ljava/io/File;)Z
    ]

람다 : 익명 함수

자바 8에서는 메서드를 일급값으로 취급할 뿐 아니라 람다(도는 익명 함수)를 포함하여 함수도 값으로 취급할 수 있다. 예를 들어, (int x) -> x + 1, 즉, ‘x라는 인수로 호출하면 x+1을 반환'하는 동작을 수행하도록 코드를 구현할 수 있다.

1.3.2 코드 넘겨주기: 예제

Apple 클래스와 Apple의 리스트를 포함하는 변수 inventory가 있다. 이때 녹색 사과이면서 무게가 150보다 큰 사과만 필터링해서 반한화는 메서드를 구현하려고 한다.

public class Apple {
    private Color color;
    private int weight;

    public Color getColor() {
        return color;
    }

    public int getWeight() {
        return weight;
    }

    public static boolean isGreenApple(Apple apple) {
        return GREEN.equals(apple.getColor());
    }

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

기존의 방식으로 구현하려면 다음과 같이 조건에 따라 각각 메서드를 구현해야한다.

public static List<Apple> filterGreenApples(List<Apple> inventory) {
    List<Apple> result = new ArrayList<>();

    for (Apple apple : inventory) {
        if (GREEN.equals(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;
}

하지만 Predicate를 인자로 받는 filterApples라는 메서드를 구현해두면, boolean를 리턴하는 함수를 전달할 수 있다. 따라서 아래와 같이 기존의 Apple 클래스에 포함된 함수인 isGreenApple, isHeavyApple을 간략하게 사용 가능하다.

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

1.3.3 메서드 전달에서 람다로

메서드를 값으로 전달하는 것은 분명 유용한 기능이지만, isHeavyApple, isGreenApple 처럼 한 두번만 사용할 메서드를 매번 정의하는 것은 귀찮은 일이다. 자바 8에서는 이 문제를 해결하기 위해서 익명함수(람다)를 사용할 수 있다.

filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()));
filterApples(inventory, (Apple a) -> a.getWeight() > 150);
filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()) && a.getWeight() > 150);

1.4 스트림

거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 하지만 컬렉션으로 모든 문제가 해결되지 않는다. 예를 들어, 리스트에서 고가의 트랜잭션만 필터링한 다음에 통화로 결과를 그룹화해야 한다고 가정하자.

1.4.1 멀티스레딩은 어렵다

이전 자바 버전에서 제공하는 스레드 API로 멀티스레딩 코드를 구현해서 병렬성을 이용하는 것은 쉽지 않다. 멀티스레딩 환경에서 각각의 스레드는 동시에 공유된 데이터에 접근하고, 데이터를 갱신할 수 있다. 때문에 스레드를 잘 제어하지 못하면 원치 않는 방식으로 데이터가 바뀔 수 있다. 따라서 멀티스레딩 모델은 순차적인 모델보다 다루기가 어렵다.

자바 8은 스트림 API로 ‘컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제' 그리고 ‘멀티코어 활용의 어려움'이라는 두 가지 문제르 모두 해결했다. 기존의 컬렉션에서는 데이터를 처리할 때 반복되는 패턴이 너무 많았다. 따라서 라이브러리에서 이러한 반복되는 패턴을 직접 제공하게 되었다.

자주 반복되는 패턴이란 주어진 조건에 따라 데이터를 필터링, 추축, 그룹화 하는 등의 기능이다. 또한 이러한 동작들을 쉽게 병렬화할 수 있다는 점도 큰 장점이다. 두 개의 CPU를 가진 환경에서 리스트를 필터링할 때 한 CPU는 리스트의 앞 부분을 처리하고, 다른 CPU는 리스트의 뒷부분을 처리하도록 요청할 수 있다. 이 과정을 포킹단계라고 한다.

컬렉션은 어떻게 데이터를 저장하고 접근할지에 중점을 두는 반면, 스트림은 데이터에 어떤 계산을 할 것인지 행동을 묘사하는 것에 중점을 둔다. 컬랙션을 필터링할 수 있는 가장 빠른 방법은 컬렉션을 스트림으로 바꾸고, 병렬로 처리한 다음에, 리스트로 다시 복원하는 것이다.

1.5 디폴트 메서드와 자바 모듈

요즘은 외부에서 만들어진 컴포넌트를 이용해 시스템을 구축하는 경향이 있다. 지금까지 자바에서는 JAR파일을 제공하는 것이 전부였지만, 자바 9의 모듈 시스템은 모듈을 정의하는 문법을 제공하므로 이를 이용해 패키지 모음을 포함하는 모듈을 정의할 수 있다. 또한 자바 8에서는 인터페이스를 쉽게 바꿀 수 있도록 디폴트 메서드를 지원한다.

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

자바 8에서는 NullPointer 예외를 피할 수 있도록 도와주는 Optional<T>클래스를 제공한다. 이는 값을 갖거나 갖지 않을 수 있는 컨테이너 객체이며, 값이 없는 상황을 어떻게 처리할지 명시적으로 구현하는 메서드를 포함하고 있다. 따라서 Optional<T>를 사용하면 NullPointer 예외를 미연에 방지할 수 있다.

또한 구조적 패턴 매칭 기법도 있다. 패턴 매칭은 수학에서의 다음 예제와 같다.

f(0) = 1
f(n) = n * f(n - 1) 그렇지 않으면

자바에서는 if-then-else나 switch문을 이용했지만, 다른 언어에서 if-then-else보다 패턴 매칭으로 더 정확한 비교를 구현할 수 있다는 사실을 증명했다. (물론 자바에서도 다형성, 메서드 오버라이딩을 이용해서 if-then-else를 대신하는 비교문을 만들 수 있다.)

1.7 마치며

  • 언어 생태계의 모든 언어는 변화해서 살아남거나 그대로 머물다가 사라지게 된다.
  • 자바 8은 프로그램을 더 효과적이고 간결하게 구현할 수 있는 새로운 개념과 기능을 제공한다.
  • 기존의 자바 프로그래밍 기법으로는 멀티코어 프로세서를 온전히 활용하기 어렵다.
  • 함수는 일급값이다. 메서드를 어떻게 함수형값으로 넘겨주는지, 익명 함수(람다)를 어떻게 구현하는지 기억하자.
  • 자바 8의 스트림 개념 중 일부는 컬렉션에서 가져온 것이다. 스트림과 컬렉션을 적절하게 활용하면 스트림의 인수를 병렬로 처리할 수 있으며 더 가독성이 좋은 코드를 구현할 수 있다.