모던 자바 인 액션 #1 자바8과 Predicate
자바는 1996년 자바 개발 키트(JDK 1.0)가 발표된 이후로 많은 사람들이 적극적으로 활용했다. 그래서 java7(2011)까지 많은 변화가 생겼고 가장 큰 변혁이 있었다는 java8(2014)이 탄생하였다. java9 에서도 중요한 변화가 있었지만 java8 만큼 획기적이거나 생산성이 바뀌는 것은 아니었다. java10 에서는 형 추론과 관련해 약간의 변화만 일어났다. 이런 크고 작은 변화 덕분에 프로그램을 더 쉽게 구현할 수 있게 되었다.
예를들어 다음은 사과 목록을 무게순으로 정렬하는 고전적 코드다.
Collections.sort(inventory, new Comparator<Apple>() {
public int compare(Apple a1, Apple a2) {
return a1.getWeight().compareTo(a2.getWeight());
}
});
java8을 이용하면 자연어에 더 가깝게 간단한 방식으로 코드를 구현할 수 있다.
inventory.sort(comparing(Apple::getWeight));
멀티코어 CPU 대중화와 같은 하드웨어적인 변화도 자바8에 영향을 미쳤다. 우리들의 컴퓨터에는 쿼드코어 이상을 지원할 것이다. 하지만 지금까지 대부분의 자바 프로그램은 코어 중 하나만을 사용했다. (즉 나머지는 유휴 상태로 두거나, 운영체제나 바이러스 검사 프로그램과 프로세스 파워를 나눠서 사용했다.)
자바8 등장하기전에는 나머지 코어를 활용하려면 스레드를 사용하는 것이 좋다고 누군가 조언했을 것이다. 하지만 스레드를 사용하면 관리가 어렵고 많은 문제가 발생할 수 있다는 단점이 있다. 그래서 자바는 이러한 병렬 실행 환경을 쉽게 관리하고 에러가 덜 발생하는 방향으로 진화하려 노력했다. 자바8에서는 병렬 실행을 새롭고 단순한 방식으로 접근할 수 있는 방법을 제공한다.
자바8에서 제공하는 새로운 기술
- 스트림 API
- 메서드에 코드를 전달하는 기법
- 인터페이스의 디폴트 메서드
자바8은 간결한 코드, 멀티코어 프로세서의 쉬운 활용이라는 두 가지 요구사항을 기반으로 한다. 우선 데이터베이스 질의 언어에서 표현식을 처리하는 것처럼 병렬 연산을 지원하는 스트림이라는 API다. 데이터베이스 질의 언어에서 고수준 언어로 원하는 동작을 표현하면, 구현(스트림 라이브러리가 이 역할)에서 최적의 저수준 실행방법을 선택하는 방식으로 동작한다.
즉, 스트림을 이용하면 멀티코어 CPU를 이용하는 것보다 비용이 훨씬 비싸고, 에러를 자주 일으키는 synchronized를 사용하지 않아도 된다. 조금 다른 관점에서 보면 결국 자바8에 추가된 스트림 덕분에 다른 두 가지 기능, 즉 메서드에 코드를 전달하는 간결 기법과 인터페이스의 디폴트 메서드가 존재할 수 있음을 알 수 있다. 하지만 스트림 때문에 메서드에 코드를 전달하는 기법이 생겼다고 추리하는 것은 메서드에 코드를 전달하는 기법의 활용성을 제한할 수 있는 위험한 생각이다. 메서드에 코드를 전달하는 기법을 이용하면 새롭고 간결한 방법으로 동작 파라미터화를 구현할 수 있다.
예를 들어 약간만 다른 두 메서드가 있다고 가정하자. 이때 두 메서드를 그대로 유지 하는 것 보다는 인수를 이용해서 다른 동작을 하도록 하나의 메서드로 합치는 것이 바람직할 수 있다. 메서드에 코드를 전달하는 자바8 기법은 함수형 프로그래밍에서 위력을 발휘한다.
1. Apple 클래스와 getColor 메서드가 있고, Apples 리스트를 포함하는 변수 inventory가 있다고 가정하자. 이때 모든 녹색 사과를 선택해서 리스트를 반환하는 프로그램을 구현하려 한다. 자바8 이전의 코드는 다음과 같다.
public static List<Apple> filterGreenApples(List<Apple> inventory){
List<Apple> result = new ArrayList<>();
for(Apple apple : inventory){
if(apple.getColor().equals("GREEN"))
result.add(apple);
}
return result;
}
하지만 누군가는 사과를 무게(150g이상)로 필터링하고 싶을 수 있다. 그러면 if의 조건이 apple.getWeight() > 150으로 바꿔야 할 것인데 위의 코드를 복붙해서 filterHeavyApple라는 이름으로 새롭게 메서드를 만들 것이다. 이러한 방식으로 복붙의 메서드를 주르르륵 만들었는데 이 복붙한 메서드의 문제가 있다면 모든 메서드을 일일히 찾아가며 소스를 고쳐야한다는 번거로움이 발생하고, 혹시라도 빼먹을 경우 오류가 발생할 것이다.
그렇지만 자바8에서는 코드를 인수로 넘겨줄 수 있기 때문에 filter 메서드를 중복으로 구현할 필요가 없다. 앞의 코드를 자바8에 맞게 새롭게 구현해 보겠다.
public static boolean isGreenApple(Apple apple){
return apple.getColor().equals("GREEN");
}
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;
}
<호출 하는법>
filterApples(inventory, Apple::isGreenApple);
filterApples(inventory, Apple::isHeavyApple);
메서드 전달에서 람다로
메서드 값으로 전달하는 것은 분명 유용한 기능이다. 하지만 isGreenApple이나 isHeavyApple처럼 한두 번만 사용할 메서드를 매번 정의하는 것은 귀찮은 일이다. 자바8은 이 문제도 간단히 해결할 수 있다. 람다를 이용하는 것이다.
filterApples(inventory, (Apple apple) -> apple.getColor().equals("GRREN"));
filterApples(inventory, (Apple apple) -> apple.getWeight() > 150 );
filterApples(inventory, (Apple apple) -> apple.getColor().equals("RED") || apple.getWeight() < 70);
즉, 한 번만 사용할 메서드는 따로 정의를 구현할 필요가 없다. 위 코드는 우리가 넘겨주려는 코드를 애써 찾을 필요가 없을 정도로 더 짧고 간결하다.
하지만 람다가 몇 줄 이상으로 길어진다면(조금 복잡한 동작을 수행) 익명 람다 보다는 코드가 수행하는 일을 잘 설명하는 이름을 가진 메서드로 정의하고 메서드를 참조 활용하는 것이 더 바람직하다.
멀티코어 CPU가 아니었다면 원래 자바8 설계자들은 여기까지만 계획 했을 것이다. 지금까지 등장한 함수형 프로그래밍은 함수형 프로그래밍이 얼마나 강력한지 증명해 왔다. 아마 자바는 filter 그리고 다음과 같은 몇몇 일반적인 라이브러리 메서드를 추가하는 방향으로 발전했을 수도 있었다.
static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);
filter(inventory, (Apple apple) -> apple.getWeight() > 150);
하지만 병렬성이라는 중요성 때문에 설계자들은 이와같은 설계를 포기했다. 대신 자바8에서 filter와 비슷한 동작을 수행하는 연산집합을 포함하는 새로운 스트림 API를 제공한다. 다음 포스팅에서 스트림에 대해 자세히 알아보자.
스트림
거의 모든 자바 애플리케이션은 컬렉션을 만들고 활용한다. 하지만 컬렉션으로 모든 문제가 해결되는 것은 아니다. 예를 들어 리스트에서 고가의 트랜잭션(거래)만 필터링한 다음에 통화로 결과를 그룹화해야 한다고 가정하자. 다음 코드처럼 많은 기본 코드를 구현해야한다.
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를 이용하면 간단하게 해결할 수 있다.
Map<Currency, List<Transaction>> transactionsByCurrencies =
transactions.stream()
.filter((Transaction t) -> t.getPrice() > 1000)
.collection(groupingBy(Transaction::getCurrency));