1장
1. 자바 역사의 흐름
사과 목록을 무게 순으로 정렬하기 위한 코드를 작성한다고 생각해보자. 자바 7까지는 아래와 같은 방식으로 작성해야 했다.
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));
자바 7까지 대부분의 자바 프로그램은 코어 중 하나를 사용했다. 나머지 코어를 활용하려면 스레드를 사용해야 했다. 그러나 스레드는 실행 환경 관리가 어렵고 에러가 많이 발생할 수 있다는 단점이 있었다. 자바 1.0의 스레드와 락, 자바 5의 스레드 풀과 병렬 실행 컬렉션, 자바 7의 포크/조인 프레임워크까지 지원했지만, 여전히 활용하기 어려웠다. 그리고 자바 8은 멀티코어 프로세서의 활용과 간결한 코드 작성을 지원하는 강력한 도구를 제공한다. 첫 번째는 스트림 API, 두 번째는 메서드에 코드를 전달하는 기법(메서드 참조와 람다), 세 번째는 인터페이스의 디폴트 메서드이다. 스트림 API는 병렬 연산을 지원한다. 메서드에 코드를 전달하는 기법은 함수형 프로그래밍에서 자주 쓰인다. 자바 9에서는 병렬 실행 기법으로 리액티브 프로그래밍을 지원한다. 자바 10에서는 형 추론과 관련해 약간의 변화가 일어났다.
2. 변화의 이유
왜 아직도 자바는 변화할까? 자연 생태계처럼 개발 환경에 맞춰 진화하는 언어가 살아남기 때문이다. 언어는 하드웨어나 프로그래머의 기대에 부응하는 방향으로 변화해야 한다. 객체지향 언어는 1990년대에 두 가지 이유로 각광받았다. 첫 번째는 (C와 비교했을 때) 캡슐화로 인한 엔지니어링적인 문제 감소, 두 번째는 객체지향의 개념과 이후 개발 환경의 일치였다. 그러나 환경이 바뀌었고 빅데이터가 등장했다. 빅데이터를 효과적으로 처리하기 위해서는 병렬 프로세싱이 필수적이었다. 변화하는 흐름에 대응하기 위해 자바 8에서 병렬 프로세싱을 지원하는 기능이 등장했다.
2.1. 스트림 처리
스트림 처리(stream processing)라는 개념을 알기 위해서는 스트림을 알아야 한다. 스트림(stream)은 한 번에 한 개씩 만들어지는 연속적인 데이터 항목들의 모임이다. 이론적으로 프로그램은 입력스트림에서 데이터를 한 개씩 읽고 출력 스트림으로 데이터를 한 개씩 기록한다. 어떤 프로그램의 출력 스트림이 다른 프로그램의 입력 스트림이 될 수 있다는 사실에 주목해야 한다. 자바 8부터 java.util.stream 패키지에 추가된 Stream<T>는 T 형식으로 구성된 일련의 항목을 의미한다. 스트림 API는 파이프라인을 만들기 위해 필요한 메서드들을 제공한다. 쉽게 이야기하면 어떤 항목을 연속으로 제공하는 기능이다. 입력 부분을 여러 CPU 코어에 할당 가능하므로 스레드 없이 공짜로 병렬 처리가 가능하다.
2.2. 동작 파라미터화
동작 파라미터화(behavior parameterization)는 코드 일부를 API로 전달하는 것이다. 자바 8에서는 메서드를 다른 메서드의 인수로 넘겨주는 기능을 제공한다. 동작 파라미터화는 스트림 API가 연산의 동작을 파라미터화할 수 있는 코드를 전달한다는 사상에 기초하기 때문에 중요하다.
2.3. 병렬성과 공유 가변 데이터
세상에 공짜는 없다. 그러나 자바 8부터는 스트림을 활용해서 '병렬성을 공짜로 얻을 수 있다'. 병렬성을 얻기 위해서는 스트림 메서드로 전달하는 코드가 다른 코드와 동시에 실행해도 안전하게 실행될 수 있도록 바꿔야 한다. 안전하게 실행하려면 공유된 가변 데이터(shared mutable data)에 접근하지 않아야 한다. 안전한 함수를 순수(pure) 함수, 부작용 없는(side effect free) 함수, 상태 없는(stateless) 함수라고 한다. 공유되지 않은 가변 데이터(no shared mutable data), 메서드, 함수 코드를 다른 메서드로 전달하는 기능은 함수형 프로그래밍 패러다임의 핵심이다. 명령형 프로그래밍(imperative programming)은 가변 상태로 프로그램을 정의한다. 공유되지 않은 가변 데이터 요구사항은 함수가 정해진 기능만 수행하며 다른 부작용을 일으키지 않음을 의미한다.
3. 자바 함수
프로그래밍 언어에서 함수(function)는 메서드(method) 특히 정적 메서드(static method)를 가리킨다. 자바에서 함수는 이에 더해 수학적인 함수, 부작용을 일으키지 않는 함수를 의미한다. 자바 8에서는 함수를 새로운 값의 형식으로 추가한다. 이는 멀티코어 환경에서 스트림과 연계될 수 있게 만든다. 프로그래밍 언어의 핵심은 값을 바꾸는 것이다. 전달할 수 있는 값을 일급 시민, 전달할 수 없는 값을 이급 시민이라고 한다면 메서드와 클래스 등은 이급 시민에 해당한다. 자바 8은 함수라는 이급 시민을 일급 시민으로 바꾸었다.
3.1. 메서드(람다) 참조
디렉터리에서 모든 숨김 파일을 필터링하는 코드를 작성한다고 생각해보자. 자바 7까지는 아래와 같은 방식으로 작성해야 했다.
File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
public boolean accept(File file) {
return file.isHidden();
}
});
File 클래스에는 isHidden이라는 메서드가 있지만, FileFilter로 isHidden을 감싼 다음에 FileFilter를 인스턴스화하고 있다. 자바 8에서는 위 코드를 한 줄로 구현할 수 있다.
File[] hiddenFiles = new File(".").listFiles(File::isHidden);
복잡한 과정을 생략하고 자바 8의 메서드 참조를 이용해서 listFiles에 isHidden 함수를 직접 전달하고 있다. 메서드 참조(method reference)는 메서드를 값으로 사용하는 것이다. 메서드 뿐만 아니라 람다와 함수(또는 익명 함수)도 값으로 사용할 수 있다. 함수형 프로그래밍의 핵심은 '함수를 값으로 넘겨주는 프로그램'이다.
3.2. 코드 넘겨주기
모든 녹색 사과를 선택해서 리스트로 리턴하는 프로그램을 작성한다고 생각해보자. 자바 8 이전에는 다음과 같이 구현해야 했다.
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;
}
특정 항목을 선택해서 반환하는 동작을 필터(filter)라고 한다. 사과 필터 프로그램은 색상을 기준으로 사과를 필터링한다. 만일 무게를 기준으로 필터링하고 싶으면 위 코드를 복사해서 아래와 같이 바꿔야 한다.
public static List<Apple> filterGreenApples(List<Apple> inventory) {
List<Apple> result = new ArrayList<>();
for (Apple apple: inventory) {
if (apple.getWeight() > 150) {
result.add(apple);
}
}
return result;
}
복사&붙여넣기의 단점은 명확하다. 복사 전 코드에 버그가 있다면 모든 코드를 고쳐야 한다. 무엇보다 코드가 중복된다. 자바 8부터는 코드를 중복으로 구현할 필요가 없다.
public static boolean isGreenApple(Apple apple) {
return GREEN.equals(apple.getColor());
}
public static boolean isHeavyApple(Apple apple) {
return apple.getWeight() > 150;
}
public interface Predicate<T> {
boolean test(T t);
}
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;
}
위 코드에서 메서드는 p라는 이름의 Predicate 파라미터로 전달된다. predicate란 인수로 값을 받아 true나 false로 반환하는 함수를 의미한다. Function<Apple, Boolean>과 같이 코드를 구현할 수는 있으나 Predicate을 사용하는 게 더 표준적인 방식이다. p.test(apple)은 사과가 p에서 제시하는 조건에 맞는지를 검사하고 필터링한다.
filterApples(inventory, Apple::isGreenApple);
filterApples(inventory, Apple::isHeavyApple);
이제 코드 중복 없이 두 가지 방법으로 사과를 필터링하는 프로그램을 구현했다.
3.3. 메서드 전달에서 람다로
메서드를 값으로 전달하는 건 유용하다. 그러나 한두 번만 사용할 메서드를 매번 정의하는 건 귀찮은 일이다. 익명 함수 또는 람다를 사용하면 더 간단하게 구현할 수 있다.
filterApples(inventory, (Apple a) -> GREEN.equals(a.getColor()));
filterApples(inventory, (Apple a) -> a.getWeight() > 150);
filterApples(inventory, (Apple a) -> a.getWeight() < 80 || RED.equals(a.getColor()));
분명 람다는 강력한 기능이지만, 람다가 몇 줄 이상 길어진다면 메서드 전달이 더 바람직하다. 어떤 상황에서도 코드의 명확성이 우선시되어야 한다는 걸 명심하자. 멀티코어 CPU가 없었다면 filter도 일반적인 라이브러리 메서드에 추가되었을지 모른다. 만일 filter가 라이브러리 메서드에 추가되었다면 아래와 같이 사용할 수 있었을 것이다.
filter(inventory, (Apple a) -> a.getWeight() > 150);
그러나 병렬성 때문에 자바 8에서는 filter와 비슷한 동작을 수행하는 연산집합을 포함해서 스트림 API를 제공하게끔 설계했다.
4. 스트림
거의 모든 자바 애플리케이션이 컬렉션을 만들고 활용한다. 리스트에서 고가의 거래(트랜잭션, transaction)만 필터링한 다음 통화로 결과를 그룹화하는 코드를 작성한다고 생각해보자. 자바 7까지는 아래와 같은 방식으로 작성해야 했다.
Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();
for (Transaction transaction : transactions) {
if (transaction.getPrice() > 1000) {
Currency currency = transaction.getCurrency();
List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);
if (transactionsForCurrency == null) {
transactionsForCurrency = new ArrayList<>();
transactionsByCurrencies.put(currency, transactionsForCurrency);
}
transactionsForCurrency.add(transaction);
}
}
이제 스트림 API를 사용해서 코드를 바꿔보자.
import static java.util.stream.Collectors.groupingBy;
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream()
.filter((Transaction t) -> t.getPrice() > 1000)
.collect(groupingBy(Transaction::getCurrency));
코드에서 알 수 있듯이 스트림 API는 컬렉션 API와 다른 방식으로 데이터를 처리한다. 컬렉션에서는 반복 과정을 직접 처리한다. for-each 루프를 이용해서 각 요소를 반복하는 걸 **외부 반복(external iteration)**이라고 한다. 반면에 스트림 API는 반복 과정을 API가 처리한다. 라이브러리 내부에서 모든 데이터를 처리하는 걸 **내부 반복(internal iteration)**이라고 한다.
4.1. 어려운 멀티스레딩
스레드 API로 멀티스레딩 코드를 구현하고 병렬 프로그래밍을 하는 건 어렵다. 자바 8은 스트림 API로 '컬렉션을 처리하면서 발생하는 모호함과 반복적인 코드 문제'와 '멀티 코어 활용의 어려움'이라는 두 가지 문제를 모두 해결했다. 스트림 API는 자주 반복되는 패턴으로 주어진 조건에 따라 데이터를 **필터링(filtering)**하고 **추출(extracting)**하고 **그룹화(grouping)**하는 등 유용한 기능을 제공한다. 이 동작들은 쉽게 병렬화할 수 있다. CPU가 두 개인 환경이 있다고 가정하자. 먼저 1번 CPU는 리스트를 반으로 가른 앞부분을 처리하고 2번 CPU는 리스트의 뒷부분을 처리한다. 각각의 CPU는 자신이 맡은 절반의 리스트를 처리하고 마지막으로 하나의 CPU과 두 결과를 합친다. 스트림은 이와 비슷한 방식으로 병렬 처리 환경을 제공한다. 스트림 라이브러리는 큰 스트림을 작은 스트림으로 분할하는 등의 분할을 처리한다. 또한 '프로그램이 실행되는 동안 컴포넌트 간에 상호 작용이 일어나지 않도록' 한다. 순차 또는 병렬 필터링은 아래 코드처럼 간단하게 구현할 수 있다.
import static java.util.stream.Collectors.toList;
// 순차 처리
List<Apple> heavyApples = inventory.stream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
// 병렬 처리
List<Apple> heavyApples = inventory.parallelStream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
5. 디폴트 메서드와 자바 모듈
자바 7까지는 JAR 파일에 정의된 패키지 인터페이스의 변경이 불가능에 가까웠다.
import static java.util.stream.Collectors.toList;
// 순차 처리
List<Apple> heavyApples = inventory.stream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
// 병렬 처리
List<Apple> heavyApples = inventory.parallelStream()
.filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
예를 들어 자바 7까지는 stream과 parallelStream을 지원하지 않는다. 위 코드를 컴파일하려면 Collection 인터페이스에 stream 메서드를 추가하고 ArrayList 클래스에서 메서드를 구현해야 한다. 그러나 이 방법은 매우 비효율적이다. 어떻게 기존 구현을 고치지 않고 공개된 인터페이스를 변경할 수 있을까? 자바 8은 구현 클래스에서 구현하지 않아도 되는 메서드를 인터페이스에서 추가할 수 있도록 새로운 기능을 제공한다. 디폴트 메서드(default method)는 default 제어자와 함께 정의되어 인터페이스의 일부로 포함되는 메서드를 의미한다. 예를 들어 자바 8에서는 List 인터페이스에 디폴트 메서드가 추가되었으므로 List에 직접 sort 메서드를 호출할 수 있다.
default void sort(Comparator<? super E> c) {
Collections.sort(this, c);
}
default sort 메서드는 List를 구현하는 모든 클래스가 구현하지 않아도 된다. 인터페이스는 다중 상속기 가능하므로 다중 디폴트 메서드는 다중 상속이 허용되는 걸까? 어느 정도는 '그렇다'. 다중 상속을 허용하는 C++에서 발생하는 **다이아몬드 상속 문제(diamond inheritance problems)**를 어떻게 피해야 할지 천천히 알아보자.
6. 유용한 아이디어
지금까지 살펴본 함수형 프로그래밍의 핵심 아이디어는 다음과 같다.
메서드와 람다를 값으로 사용하는 것
가변 공유 상태가 없는 병렬 실행을 이용한 함수와 메서드 호출
이외에도 다양한 아이디어가 존재한다. 먼저 서술형의 데이터 형식을 활용해 Null을 회피할 수 있다. 자바 8에서는 NullPointer 예외를 피할 수 있도록 Optional<T> 클래스를 제공한다. Optional<T>는 값을 갖거나 갖지 않을 수 있는 컨테이너 객체로 값이 없는 상황을 어떻게 처리할지 명시적으로 구현하는 메서드를 포함한다. 또한 (구조적 structural) 패턴 매칭 기법을 활용해서 보다 정확하게 비교할 수 있다. 자바 8은 패턴 매칭을 완벽하게 지원하지는 않는다. JVM을 사용하는 스칼라 프로그래밍 언어에서는 패턴 매칭을 사용한다.
참고 자료
모던 자바 인 액션 - 한빛미디어
Last updated