Search
Duplicate

6. 람다와 스트림

목차

42. 익명 클래스보다는 람다를 사용하라.

요약

자바 8에 나온 람다(Lambda)는 익명 클래스에 비교해 간결하고 Method Reference까지 사용한다면 극도로 압축된다.
람다는 타입 컴파일러에서 문맥을 살펴 타입추론을 해주는데, 타입 추론에 필요한 대부분의 정보는 제네릭에서 얻을 수 있다. 그래서 제네릭이 없거나 하면 일일히 타입을 명시해줘야 한다.
타입을 명시해야만 할 때를 제외하고는 람다의 모든 매개변수 타입은 생략하도록 하자.
람다는 이름이 없고 문서화도 못한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드의 줄이 길어진다면 람다를 쓰지 말아야 한다. (== 3줄이 넘는다면 변경해라)
추상 메서드가 여러개인 인터페이스의 인스턴스를 만들때는 익명 클래스를 써야 한다.
람다에서의 this 키워드는 바깥 인스턴스를 가리키기에 함수 객체 자신을 참조해야 한다면 익명클래스를 써야 한다.

사용해보기

익명 클래스

public static void main(String[] args) { List<String> strings = Arrays.asList("book", "apple", "fineapple", "grape", "car", "bobo", "bbibbi", "crong"); Collections.sort(strings, new Comparator<String>() { @Override public int compare(String o1, String o2) { return Integer.compare(o1.length(), o2.length()); } }); }
Groovy
복사
익명 클래스를 사용해 문자열 정렬을 구현했다. 예전에는 이런 방식을 사용해 정렬을 구현했는데, Comparator처럼 추상 메서드가 하나뿐인 인터페이스는 FunctionalInterface라 하며 람다식을 사용할 수 있도록 해준다.

람다식사용

Collections.sort(strings, (s1, s2)-> Integer.compare(s1.length(), s2.length()));
Groovy
복사
s1, s2가 무슨 타입인지 따로 명시해주지 않았다.
하지만, 컴파일러는 문맥을 살펴 타입추론을 해준다. 물론 모든 상황에서 추론을 해주는 것은 아니기에 직접 명시해야 하는 경우도 있지만 대부분의 경우 매개변수 타입을 생략해줘도 타입추론은 잘 동작한다.
위에서 정렬에 사용한 strings는 ArrayList로 타입에 제네릭으로 List<String> strings = ... 으로 작성해줬기 때문에 컴파일러에서는 타입 추론이 가능하며 만약 로타입(raw type)으로 작성했다면 컴파일 오류가 났을 것이다.
이게 끝이 아니다 method reference를 사용한다면 여기서 한 번 더 축약할 수 있다.

람다식 + Method Reference 사용

import static java.util.Comparator.comparing; Collections.sort(strings, comparing(String::length));
Groovy
복사

List의 sort 메서드 이용

import static java.util.Comparator.comparingInt; strings.sort(comparingInt(String::length));
Groovy
복사
이처럼 람다식이 작성될 수 있는 인수위치에 전달될 파라미터의 타입과 갯수가 명확하고 Method Reference대상이 되는 메서드의 매개변수의 타입과 갯수가 명확하고 일치하면 이처럼 ::키워드를 이용해 축약할 수 있다.

람다식의 단점

코드가 간결해진다는 점에서 메리트가 있는 이 람다식은 마냥 권장되지만은 않는다.
상황에따라 익명 클래스를 사용해야하는 경우도 있다. 일단 람다는 이름이 없고 문서화도 할 수 없기 때문에 코드 자체로 동작이 명확하지 않고 모호하다면 람다를 써서는 안된다. 다시 말하자면 람다식은 간결함을 유지하지 못하면 코드를 작성한 개발자가 아닌 다른 사람이 볼 때 수숙께기의 코드가 되기 쉽다. (개발자 본인도 시간이 지나면 잊어먹기 쉽상이다.) 그렇기 때문에 람다식을 사용할 때 해당 코드가 간결함을 잃거나 명확성을 잃을 것 같다면 리팩토링을 하거나 그게 아니라면 람다식을 사용하지 말아야 한다.
또한,애초에 람다식으로 대체할 수 없는 영역도 존재하는데, 위에서 말했다시피 추상 메서드가 하나인 인터페이스, 즉 FunctionalInterface에서 람다식을 사용할 수 있다는 말은 추상 메서드가 두 개 이상인 인터페이스에선 람다식을 쓸 수 없다는 의미이다. 그렇기 때문에 이런 경우 익명 클래스를 사용하거나 구현클래스를 만들어야 한다.
이뿐만이 아니다. 람다는 this 키워드로 자기 자신을 가르키는게 아니라 바깥 인스턴스를 가리킨다.
그렇기 때문에 만약 this로 함수 객체 자기 자신을 가르켜야 하는상황이라면 람다식이 아닌 익명 클래스를 사용해야 한다. (익명 클래스에서의 this는 인스턴스 자신을 가리킨다.)

주의점

람다도 익명 클래스처럼 직렬화 형태가 구현별로(ex: VM별로) 다를 수 있기에 람다를 직렬화 하는 일은 없어야 하며 만약 직렬화해야하는 함수 객체가 있다면 private 정적 중첩 클래스의 인스턴스를 사용하자.

정리

단점과 주의점만 봐선 람다식을 쓰기 애매할 수 있다.
하지만, 단점에서 언급한 몇몇 경우를 제외하고, 코드를 작은 단위로 명확하게 사용한다면 재사용성도 높아지고 코드의 간결함도 올라간다. 그리고 코드가 명확하고 간결하다는 것은 유지 보수및 확장에 용이하다는 의미이기도 한다. 그렇기에 우리는 주의점을 잘 인지하며 람다식을 사용함으로써 코드의 가독성을 높게 유지하도록 하자.

43. 람다보다는 메서드 참조를 사용하라

메서드 참조(Method Reference)는 함수 객체를 람다보다도 간결하게 만드는 방법이다.
바로 예제를 통해 람다를 사용하지 않는 코드부터 람다에 메서드 참조까지 사용하면서 코드가 어디까지 간결해지는지 살펴보자.

요구사항

사용자에게 전달받은 내용중 FoodSaveForm 리스트를 Food 도메인 객체로 변환한 뒤 이를 데이터베이스에 저장하라.

기본적인 코드 작성

public void saveFoodAll(List<FoodSaveForm> forms) { for (FoodSaveForm form : forms) { Food food = form.toFood(); foodRepository.save(food); } }
Java
복사

람다식을 적용한 코드 작성

public void saveFoodAll(List<FoodSaveForm> forms) { forms.stream() .map(form-> form.toFood()) .forEach(food -> formRepository.save(food)); }
Java
복사

람다식에 메서드 참조를 사용한 코드 작성

forms.stream() .map(FoodSaveForm::toFood) .forEach(FoodRepository::save);
Java
복사
대충 봐도 코드가 많이 축약되었다. 하나의 매개변수를 받는 예를 추가로 들어보자.
자바 8에서 추가된 Map의 메서드인 merge 메서드는 키, 값, 함수를 인수로 받아 주어진 키가 맵 안에 아직 없다면 주어진 키/값을 그대로 저장하고, 이미 있다면 세 번째 인자로 받은 함수에 기존 값과 새로운 값을 전달해 나온 결과로 덮어씌운다.
default V merge(K key, V value, BiFunction<? super V, ? super V, ? extends V> remappingFunction) { Objects.requireNonNull(remappingFunction); Objects.requireNonNull(value); V oldValue = get(key); V newValue = (oldValue == null) ? value : remappingFunction.apply(oldValue, value); if (newValue == null) { remove(key); } else { put(key, newValue); } return newValue; }
Java
복사
Map 콜렉션의 merge 메소드
이를 간단하게 람다식을 이용해 구현하면 예를 들어 다음과 같이 사용할 수 있다
map.merge(key, 1, (count, incr) -> count + incr);
Java
복사
한 줄에 모두 표현되고 간결하다. 하지만, 이마저도 메서드 참조를 사용하면 더 의미있고 간결하게 할 수 있다.
map.merge(key, 1, Integer::sum);
Java
복사
작성된 메서드 참조를 보면 두 매개변수를 받아서 더한 값을 반환하겠다고 이해하기도 더 쉽다.
또한, 전달해야 할 인수가 많아질수록 메서드 참조로 줄일 수 있는 코드의 양도 많아진다.
(근데, 애초에 전달해야 할 인수 갯수가 많아지는걸 지양하자. 클린코드에 어긋난다.)
즉, 개발자에게 코드의 간결함을 제공하면서도 가독성을 최대한 높히고자 한다는 취지를 느낄 수 있다.
이를 연결지어 생각하면 람다를 사용하여 함수를 구현했을 때도 너무 길거나 복잡하면 메서드를 분리해 만들어 준 뒤 메서드 참조를 통해 의미를 전달하는게 좋은 방법이 될 수 있다.
그럼 항상 메서드 참조는 정답일까? 그렇지는 않다.
그렇다고 항상 메서드 참조를 사용하는게 옳지는 않다. 어떤 상황에서는 람다가 메서드 참조보다 간결한 경우도 있다. 대표적으로 메서드와 람다가 같은 클래스에 있을 때 그렇다.
다음 코드를 보자.
public class GoshThisClassNameIsHumongous { public void logic(){ service.execute(GoshThisClassNameIsHumongous::action); } public void action(){...} }
Java
복사
service.execute(GoshThisClassNameIsHumongous::action); 이 코드는 메서드 참조를 사용했지만 결코 짧지 않다. 이를 그냥 람다로 대체하면 다음과 같이 축약된다.
service.execute(()->action());
Java
복사
메서드 참조는 더 짧지도 않고 더 명확하지도 않은 반면, 람다식이 더 짧고 명확하다.

메서드 참조 유형

메서드 참조의 유형은 위에서 이미 봤 던 것처럼(ex: Integer::sum) 정적 메서드를 가리키는 메서드 참조를 더해 5가지가 있다. 위에서 소개하지 않은 나머지 4가지 방법도 알아보자.
인스턴스 메서드를 참조하는 유형
수신 객체(receiving object: 참조 대상 인스턴스)를 특정하는 한정적(bound) 인스턴스 메서드 참조
: 정적 참조와 비슷하다. 즉, 함수 객체가 받는 인수와 참조되는 메서드가 받는 인수가 똑같다.
수신 객체를 특정하지 않는 비한정적(unbound) 인스턴스 메서드 참조.
: 함수 객체를 적용하는 시점에 수신 객체를 알려준다. 이를 위해 수신 객체 전달용 매개변수가 매개변수 목록의 첫 번째로 추가되며, 그 뒤로 참조되는 메서드 선언에 정의된 매개변수들이 뒤따른다.
주된 사용처는 스트림 파이프라인매핑필터 함수이다.
클래스 생성자를 가리키는 메서드 참조
: 보통 생성자 참조는 팩터리 객체로 사용된다.
배열 생성자를 가리키는 메서드 참조
5가지 메서드 참조

44. 표준 함수형 인터페이스를 사용하라.

요약

자바 8 이후 람다가 등장하며 개발에 람다를 고려 해야한다.
대부분의 경우 함수형 인터페이스를 구현하기보다 표준 함수형 인터페이스를 사용하는게 좋다.
java.util.function 패키지에서 제공하는 인터페이스
표준 함수형 인터페이스 대부분은 기본 타입만 지원한다.
기본 함수형 인터페이스에 박싱된 기본 타입(래퍼 클래스)를 사용하지 말자.
다음과 같은 경우에만 함수형 인터페이스를 고려하라.
자주 사용되며, 이름 자체가 용도를 명확하게 설명하는 경우
반드시 따라야 하는 규약이 있는 경우
유용한 디폴트 메서드를 제공할 수 있는 경우
직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 애너테이션을 사용하라.
서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의하지 말아라.

람다의 등장으로 바뀐 개발 방향

자바 8에 람다가 등장하며 상위 클래스의 기본 메서드를 재정의하는 템플릿 메서드 패턴의 가치가 많이 떨어졌다. 인수로 함수 객체를 전달하면 되기 때문이다. 즉, 함수 객체를 받는 정적 팩토리나 생성자를 사용하면 되기 때문에 템플릿 메서드 패턴의 효용이 떨어졌다.
이 때, 함수 객체를 건내주기 위해서는 함수형 인터페이스를 만들어야하는데 이는 다음과 같다.
@FunctionalInterface interface TestFunction<K, V> { V excute(K k, V v); }
Java
복사
불필요한 함수형 인터페이스
이처럼 함수형 인터페이스를 선언하면 이를 하나의 인수로 취급해 함수 객체로 전달할 수 있다. 하지만, 굳이 이런 방식을 사용할 이유는 없다. 대부분의 경우 자바 표준 라이브러리에서 함수형 인터페이스를 제공하기 때문이다.

표준 함수형 인터페이스

java.util.function 패키지 내부에는 다양한 용도의 표준 함수형 인터페이스가 담겨있다.
그래서 굳이 직접 함수형 인터페이스를 구현하기보단 제공되는 표준 함수형 인터페이스를 활용하는게 좋다.
표준이기에 다른 개발자와의 소통도 쉽고, API를 다루는 개념의 숫자도 줄어들어서 익히기도 쉽다.
(같은 용도의 함수형 인터페이스를 개발자가 필요할 때마다 매번 새로 만든다면 알아야 할 함수형 인터페이스만 늘어나며 혼란이 커진다.)
또한 이러한 표준 함수형 인터페이스는 유용한 디폴트 메서드들도 많이 제공하기에 사용하기에 편하다.
(ex: Predicate 인터페이스는 predicate들을 조합하는 메서드를 제공한다. )
java.util.function 패키지에는 총 43개의 인터페이스가 담겨져있는데, 전부 외울 필요도 없고 기본 인터페이스 6개만 기억한다면, 나머지 인터페이스는 대부분 이름의 유사성이나 기본 인터페이스명과 비슷하게 특징을 넣은 이름이기에 사용하기 어렵지 않다.

기본 함수형 인터페이스

인터페이스함수 시그니쳐UnaryOperator<T>T apply(T t)String::toLowerCaseBinaryOperator<T>T apply(T t1, T t2)BigInteger::addPredicate<T>boolean test(T t)Collection::isEmptyFunction<T, R>R apply(T t)Arrays::asListSupplier<T>T get()Instant::nowConsumer<T>void accept(T t)System::out::println\begin{array}{|c|c|c|}\hline \textbf{인터페이스}&\textbf{함수 시그니쳐}&\textbf{예}\\\hline \text{UnaryOperator<T>}&\text{T apply(T t)}&\text{String::toLowerCase}\\\hline \text{BinaryOperator<T>}&\text{T apply(T t1, T t2)}&\text{BigInteger::add}\\\hline \text{Predicate<T>}&\text{boolean test(T t)}&\text{Collection::isEmpty}\\\hline \text{Function<T, R>}&\text{R apply(T t)}&\text{Arrays::asList}\\\hline \text{Supplier<T>}&\text{T get()}&\text{Instant::now}\\\hline \text{Consumer<T>}&\text{void accept(T t)}&\text{System::out::println}\\\hline \end{array}
Operator: 인수 타입과 반환 타입이 동일한 인터페이스
UnaryOperator : 인수가 1개인 인터페이스
BinaryOperator: 인수가 2개인 인터페이스
Predicate: 인수 하나를 받아 boolean 을 반환하는 인터페이스
Function: 인수와 반환 타입이 다른 인터페이스
Supplier: 인수를 받지 않고 값을 반환(혹은 제공)하는 인터페이스
Consumer: 인수를 하나 받고 반환값은 없는(특히 인수를 소비하는) 인터페이스
이런 기본 인터페이스에서 int, long, double용으로 각 3개씩 변형이 생기는데 접두사로 해당 타입이 붙는다.
Predicate IntPredicate, LongPredicate , DoublePredicate
BinaryOperator IntBinaryOperator, LongBinaryOperator, DoubleBinaryOperator
이러한 변형된 함수형 인터페이스는 제공할 인수 타입이 기본 타입(int, long, double)일 때 좀 더 편하게 사용하도록 명시된 인터페이스로 기본 인터페이스와는 다음과 같이 다르며 실제 타입 매개변수를 작성해야 하는 수고를 덜어준다.
//before Predicate<Integer> isEvenV1 = value -> value % 2 == 0; //after IntPredicate isEvenV2 = value -> value % 2 == 0;
Java
복사
숫자가 짝수인지 검증하는 함수형 인터페이스
그리고 그 밖에 다음과 같은 변형 인터페이스가 있다.

인수 타입을 두 개(혹은 세 개)씩 받도록 변형된 함수형 인터페이스

인수 갯수가 하나 혹은 두개로 부족한 경우를 대비하기위해 Bi라는 접두사가 붙은 인수 갯수를 늘린 세 가지 인터페이스를 제공한다.
Predicate<T> → BiPredicate<T, U>
Function<T, R> → BiFunction<T, U, R>
Consumer<T> → BiConsumer<T, U>

기본 타입을 반환하는 BiFunction 변형 모델

BiFunction 인터페이스가 만약 기본 타입(int, long, double)을 반환한다면 이를 지원하는 변형 인터페이스가 또 있다.
Integer 타입을 반환하는 경우
⇒ ToIntBiFunction<T, U>
Long 타입을 반환하는 경우
⇒ ToLongBiFunction<T, U>
Double 타입을 반환하는 경우
⇒ ToDoubleBiFunction<T, U>

기본 타입을 받는 BiConsumer 변형 모델

인수 갯수를 하나가 아닌 두 개를 받도록 한 BiConsumer 인터페이스에서 하나의 인수 타입이 기본 타입인 경우 이를 지원하는 변형 모델도 있다.
Integer 타입을 받는 경우
⇒ ObjIntConsumer<T>
Long 타입을 받는 경우
⇒ ObjLongConsumer<T>
Double 타입을 받는 경우
⇒ ObjDoubleConsumer<T>

Boolean 타입을 반환하는 Supplier

인수를 받지 않고 값을 반환 혹은 제공하는 Supplier에서 만약 반환 타입이 Boolean 일 경우 이를 따로 실제 타입 매개변수를 작성하지 않아도 되도록 BooleanSupplier 라는 인터페이스를 제공한다.
Supplier<Boolean> → BooleanSupplier

모두 외울 필요는 없다.

java.util.function 기본적인 6가지 표준 함수형 인터페이스와 여기서 편의를 위해 변형할 수 있는 모델에 대해서는 변형된 인터페이스를 만들어 제공해주고 있다. 그렇게 만들어진 함수형 인터페이스 갯수가 43개인데, 모두 외울 필요도 없고 이름이 명시적이기에 필요할때마다 찾아서 써도 어렵지 않다.
다만, 이 점은 기억해야하는데 표준 함수형 인터페이스는 대부분 기본 타입만 지원한다. 물론 래퍼 클래스(Wrapper Class)로 사용을 해도 동작은 하지만 계산량이 많아질수록 처참하게 떨어지는 성능만이 기다리고 있을 것이다.

직접 함수형 인터페이스를 작성해야 하는 경우

그렇다고 항상 기본으로 제공해주는 함수형 인터페이스로 모든 상황을 헤쳐나갈 수 있는 것은 아니다. 예를 들어 매개변수의 갯수가 3개를 넘는 Predicatejava.util.function에서 제공하지 않는다. 그리고 구조적으로 똑같은 표준 함수형 인터페이스가 이미 제공되고 있더라도 직접 작성해야 하는경우도 있다.

Comparator<T>

비교를 하기위해 제공되는 이 인터페이스는 로직만 파악하자면 ToIntBiFunction<T, U>와 같다.
그럼에도 불구하고 Comparator<T>를 사용해야만 하는 이유가 있는데 그 이유는 다음과 같다.
사용 빈도가 높으며 이름 자체로 용도를 명확히 설명해준다.
구현하는 쪽에서 반드시 지켜야 할 규약을 담고 있다.
비교자들을 변환하고 조합해주는 유용한 디폴트 메세지를 가지고 있다.

@FunctionalInterface

이 애노테이션은 인터페이스가 함수형 인터페이스로 사용됨을 알려주는 애노테이션이다.
자체적으로 어떤 특별한 동작을 수행하는 것은 아니다. 그렇기에 해당 애노테이션이 없어도 인터페이스가 함수형으로 하나의 추상 메서드만을 가진다면 정상적으로 동작을 한다. 하지만 이 애노테이션을 사용하는 이유는 @Override 를 사용하는 것과 같다. 프로그래머의 의도를 명시하는 것으로 다음과 같은 목적이 있다.
1.
사용자에게 이 인터페이스가 람다용으로 설계된 것임을 알려준다.
2.
추상 메서드가 하나만 있어야 함을 컴파일러에게 알려줘 추상 메서드가 하나일 때만 컴파일이 된다.
3.
차후 유지보수 과정에서 다른 개발자가 실수로라도 메서드 추가를 하지 못하도록 막아준다.
이런 이유로 함수형 인터페이스를 직접 만들 때는 항상 @FunctionalInterface 애노테이션을 사용하자.

주의사항

서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안된다.
클라이언트에게 불필요한 모호함을 안겨주고 이런 모호함으로 문제가 발생하기도 쉽다.

45. 스트림은 주의해서 사용하라

스트림 API는

자바 8에서 람다와 같이 나온 API로 다량의 데이터 처리 작업을 도와주며 다음과같은 추상 개념을 가진다.
스트림(stream)은 데이터 원소의 유한(혹은 무한) 시퀀스(sequence)를 의미한다.
스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
스트림 자체를 잘 이해하기 힘들다면 다음 링크를 통해 스트림을 학습하고 오자.

특징

스트림 파이프라인은 지연 평가(lazy evaluation)된다. 평가는 종단 연산(종료 오퍼레이터)이 호출될 때 이뤄지며 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이 개념 덕분에 무한 스트림을 다룰 수 있다.
스트림 API는 메서드 연쇄를 지원하는 플루언트 API(fluent API)다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결해 단 하나의 표현식으로 완성할 수 있다. 즉 파이프라인 여러 개를 연결해 하나의 표현식으로 만들 수 있다.
스트림 파이프라인은 값을 다른 값에 매핑(사용)하고나면 원래의 값을 잃는다.

과도한 스트림은 안하느니만 못하다.

새로나온 API라고(사실 자바8이면 신규라고 하기도 그렇다.) 억지로 여기저기 사용하는 과도한 스트림 사용은 오히려 코드의 유지보수를 어렵게 만든다. 다음 요구사항을 구현하는 로직을 스트림 없이 구현해본 뒤 스트림을 최대한 사용해보며 과연 코드가 간결해지는지 살펴보자.

요구사항

사전 파일에서 단어를 읽어온 다음 사용자가 지정한 문자의 길이보다 큰 원소 수를 가진 아나그램(anagram) 그룹을 출력한다. (anagram: 철자를 구성하는 알파벳이 같고 순서만 다른 단어)
맵의 키는 각 단어를 구성하는 철자들을 알파벳순으로 정렬한 값이다.
(ex: abc, bac, bcd, cab 모두 동일한 키(abc)를 가진다. )
스트림 없이 구현하는 Anagram
public class Anagrams { public static void main(String[] args) throws IOException { int minGroupSize = 4; final Map<String, Set<String>> groups = Anagrams.getGroups("src/main/java/me/catsbi/effectivejavastudy/chapter6/item45/dictionary.txt"); for (Set<String> value : groups.values()) { if (value.size() >= minGroupSize) { System.out.println(value.size() + ": "+ value); } } } public static Map<String, Set<String>> getGroups(String filePath) throws IOException { File dictionay = new File(filePath); Map<String, Set<String>> groups = new HashMap<>(); try(Scanner sc = new Scanner(dictionay)){ while (sc.hasNext()) { String word = sc.next(); groups.computeIfAbsent(alphabetize(word), (unsued) -> new TreeSet<>()).add(word); } } return groups; } private static String alphabetize(String word) { final char[] chars = word.toCharArray(); Arrays.sort(chars); return new String(chars); } }
Java
복사
스트림을 최대한 사용하여 파이프를 연결시킨 Anagram
public static void getGroupsOnlyStream(String filePath, int minGroupSize) throws IOException { Path dictionary = Paths.get(filePath); try (Stream<String> words = Files.lines(dictionary)) { words.collect(groupingBy(word -> word.chars().sorted() .collect(StringBuilder::new, (sb, c) -> sb.append((char) c), StringBuilder::append).toString())) .values().stream() .filter(group -> group.size() >= minGroupSize) .map(group -> group.size() + ": " + group) .forEach(System.out::println); } }
Java
복사
아무리봐도 기존 코드에 비교해서 스트림을 사용한 위 코드가 더 나은 것 같지 않다. 오히려 가독성이 떨어지는 것 같다. 이는 스트림을 너무 과하게 사용했기 때문인데 적절히 절충할 필요가 있다.
이전에 만들었던 alphabetize 메서드를 활용하는 방식으로 다시 구현해보면 다음과 같다.
public static void getGroupsMix(String filePath, int minGroupSize) throws IOException { Path dictionary = Paths.get(filePath); try (Stream<String> words = Files.lines(dictionary)) { words.collect(groupingBy(Anagrams::alphabetize)) .values().stream() .filter(group -> group.size() >= minGroupSize) .forEach(group-> System.out.println(group.size() + ": " + group)); } } private static String alphabetize(String word) { final char[] chars = word.toCharArray(); Arrays.sort(chars); return new String(chars); }
Java
복사

그럼 우리는 언제 어떻게 스트림을 사용해야 할까?

기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.
반복 코드에서는 코드 블록을 이용하고 스트림 파이프라인에서는 되풀이되는 계산을 함수 객체로 표현을 하는데, 두 방법이 가진 차이점을 고려해 적절히 선택하도록 하자. 다음은 코드 블록으로만 할 수 있는 일들이며 이런 일이 필요할 경우에는 람다나 스트림을 써서는 안된다.
코드 블록에서는 범위 안의 지역 변수를 읽고 수정할 수 있지만 람다는 final( or effective final)만 읽을 수 있고 지역변수를 수정하지 못한다.
코드블럭은 return, break, continue문을 이용해 코드 반복을 제어(종료, 건너뛰기,..)할 수 있다. 하지만, 람다로는 불가능하다.
계속 쓰면 안되는 경우에 대해서만 얘기했는데, 다음과 같은 경우에는 스트림을 사용하기 좋다.
원소들의 시퀀스를 일관되게 변환한다.
원소들의 시퀀스를 필터링한다.
원소들의 시퀀스를 하나의 연산을 사용해 결합한다.(ex: 덧셈, 연결, 최솟값 구하기, ...)
원소들의 시퀀스를 컬렉션에 모은다
원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

정리

반복 코드와 스트림 둘 다 과도하게 하나의 방식만 고집할게 아닌 상황에 맞게 사용하면 된다.
스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은쪽을 택하면 된다.

46. 스트림에서는 부작용 없는 함수를 사용하라

개요

Java 8 에서 등장한 개념인 Stream API는 얼핏 보면 그냥 조금 편리한 유틸리티로 보일 수 있다.
하지만, 이해하기는 힘들고, 원하는 작업을 수행하기 위해 학습해야하는 양도 꽤 되기에 사용을 꺼릴 수 있다. 이는 스트림이 하나의 API라기보단 함수형 프로그래밍에 기초한 패러다임이기 때문이다.
그렇기 때문에, 이러한 Stream이 제공하는 표현력, 속도, (Optional) 병렬성을 체감하기 위해서는 API의 사용법 뿐 아니라 이 패러다임도 이해해야 하나다.
스트림 패러다임은 결국 계산을 일련의 변환으로 재구성하는 부분에 있다.
그리고 이 때, 각 변환 단계는 이전 단계의 결과를 받아 처리하는 순수함수여야 한다.
순수 함수란? 오직 입력 값만이 결과에 영향을 주는 함수를 말한다. 다른 가변 상태를 참조하거나 외부 상태에 영향을 주지 않는다. 즉, 사이드 이펙트(side effect)가 없는 함수를 순수함수라 한다.
아직 이해가 잘 안된다면 좀 더 알기 쉽게 예제를 통해 알아보자.
우선 다음 로직은 스트림의 패러다임을 이해하지 못하고 API만 사용하는 코드이다.
final HashMap<String, Long> map = new HashMap<>(); LongStream.rangeClosed(0L, 100L) .forEach(index -> map.put("value" + index, index));
Java
복사
잘못 된 Stream API 사용법
위 코드는 0부터 100까지 순회하며 map이라는 외부 가변 상태 콜렉션에 값을 넣는 Stream API이다. 바로 위에서 말한 다른 순수함수의 의미인 외부 상태에 영향을 주지 않아야 한다는 말을 위반한다.
결과가 의도한대로 나온다고, 결과에 치중한 이런 사용법은 같은 기능의 반복적 코드보다 복잡하고 가독성도 낮을 뿐 아니라 유지보수도 좋지 않다. 그럼 이를 순수함수로 표현하여 구현하면 어떻게 될까?
Map<String, Long> map = LongStream.rangeClosed(0L, 100L) .boxed() .collect(toMap(index -> "value" + index, index -> index));
Java
복사
결과는 위에서 작성한 코드와 동일하다. 하지만, forEach를 사용하지 않았고 외부 상태를 참조하거나 변경하지 않는다. 즉, 순수성을 지킨다.
여기서 forEach는 기존의 for-each 반복문과 비슷하다.
그렇기 때문에 많은 자바 개발자들이 익숙하다는 이유로 forEach를 자주 사용하는데 이는 기능도 적을 뿐더러 가장 '덜' 스트림답다. 그렇기에 forEach 연산은 스트림 계산 결과를 보고할 경우에만 사용하며 계산용도로는 쓰지 말도록 하자.

참고: 스트림을 쓰기 위해 필요한 사전 지식

1. 수집기(collector)

java.util.stream.Collectors 클래스는 다양한 메서드(대략 39개)를 제공하는데 이를 통해 스트림의 연산을 수행하고 결과를 수집하여 우리 원하는 형태로 반환받을 수 있다.
즉, 축소(reduction)전략을 캡슐화한 블랙박스 객체라고 할 수 있는데, 축소라는 단어가 위에서 말한 스트림의 원소들을 하나의 객체로 취합한다는 의미이다.
수집기는 대표적으로 다음과 같다.
toList() : 스트림의 원소들을 하나의 List 객체에 담아 반환한다.
toSet() : 스트림의 원소들을 하나의 Set 객체에 담아 반환한다.
toCollection(collectionFactory)
: 스트림의 원소들을 원하는 컬렉션을 생성하여 담아 반환한다.
toMap()
: 스트림의 원소들을 keyMapper, ValueMapper를 이용해 축소해 반환한다.
(java 10에서 등장) toUnmodifiableMap()
(java 10에서 등장) toUnmodifiableList()
(java 10에서 등장) toUnmodifiableSet()
수집기 사용 예
public static void main(String[] args) throws IOException, FileNotFoundException { final Map<String, Long> freq; try(final Stream<String> words = new Scanner(new FileInputStream(PATH)).tokens();) { freq = words.collect(groupingBy(String::toLowerCase, counting())); List<String> topTen = freq.keySet().stream() .sorted(comparing(freq::get).reversed()) .limit(10) .collect(Collectors.toList()); } }
Java
복사

다양한 toMap 수집기

toMap(keyMapper, valueMapper) : 가장 간단한 맵 수집기로 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다. 다음은 열거 타입 상수의 문자열 표현을 열거 타입 자체에 매핑하는 코드이다.
private static final Map<String, Operation> stringToEnum = Stream.of(values()).collect( toMap(Object::toString, e-> e));
Java
복사
toMap(keyMapper, valueMapper, mergeFunction)
: 값의 병합(merge) 역할을 할 mergeFunction이라는 인자가 추가되었는데 이는 BinaryOperator<U> 타입이며 U는 해당 맵의 값 타입을 뜻한다.
다음은 음악가와 그음악가의 베스트 앨범을 연관짓는 코드이다.
Map<Artist,Album> topHits = albums.collect( toMap(Album::artist, a->a, maxBy(comparing(Album::sales)));
Java
복사
또한 인자가 3개인 해당 toMap() 수집기는 값 충돌 시 마지막 값을 취하는 (last-write-wins) 수집기를 만들 때도 유용하다.
toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)
Java
복사
toMap(keyMapper, valueMapper, mergeFunction, mapFactory) : 마지막 인수로 mapFactory가 추가되었는데 이 인수로는 EnumMap이나 TreeMap처럼 원하는 특정 맵 구현체를 직접 지정할 수 있다.
Map<String, String> map = words.collect(toMap(String::toLowerCase, word-> word, (oldVal, newVal)-> newVal, HashMap::new));
Java
복사

groupingBy

Collectors가 제공하는 메서드 중 groupingBy라는 메서드가 있는데 이는 입력으로 분류 함수(classifier)를 받고 출력으로 원소들을 카테고리별로 모아놓은 맵을 담은 수집기를 반환한다.
groupingBy(Function<? super T, ? extends K> classifier) : 가장 단순하게 분류 함수 하나를 인수로 받아 맵을 반환한다. 이 때 반환된 맵에 담긴 각각의 값 은 해당 카테고리에 속하는 원소들을 모두 담은 리스트다.
reportCards.collect(groupingBy(card-> getGrade(reportCard::math)));
Java
복사
성적표에서 수학점수를 성적별로 분류하는 스트림 API
groupingBy(Function<? super T, ? extends K> classifier, Collector<? super T, A, D> downstream) : 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하기 위해선 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다. 해당 수집기는 해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성하는 역할을 가진다.
reportCards.collect(groupingBy(card-> getGrade(reportCard::math), counting()));
Java
복사
다운스트림으로 counting() 수집기를 건네 해당 카테고리의 원소의 개수를 얻을 수 있다.
groupingBy(Function<? super T, ? extends K> classifier, Supplier<M> mapFactory, Collector<? super T, A, D> downstream)
: 다운스트림 수집기에 더해 맵 팩토리도 지정할 수 있는 groupingBy 수집기이다.
여기서 주의할점은 해당 메서드는 점층적 인수 목록 패턴이 아니다. 즉, 인수를 2개갖는 groupingBy에서 마지막에 세 번째 인자가 더해지는게 아니라 2번 째 인자로 mapFactory가 들어오기에 이를 주의해야 한다. 해당 수집기에서는 mapFactory를 통해 맵과 그 안에 담긴 컬렉션의 타입을 모두 지정할 수 있다.

정리

스트림 파이프라인 프로그래밍의 핵심은 부작용이 없는 함수 객체에 있다.
종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용하자.
가장 중요한 수집기 팩토린 toList, toSet, toMap, groupingBy, joining이 있다.

47. 반환 타입으로는 스트림보다 컬렉션이 낫다.

요약

Stream API는 Iterable 인터페이스가 정의한 추상 메서드를 모두 포함하고 해당 인터페이스가 정의한 방식대로 동작하지만, Iterable을 확장(extends)하지는 않았다.
원소 시퀀스를 반환하는 API를 정의할 때 스트림 파이프라인과 반복문에서 사용할 사람들 모두를 위해서 한 쪽에 편향되지 않는 타입을 반환해야 한다.
Collection 인터페이스는 Iterable의 하위타입이면서 stream 메서드도 제공하기에 반복과 스트림을 동시 지원한다.
원소 시퀀스를 반환하는 공개 API는 반복문과 Stream을 모두 지원하는 Collection(혹은 그 하위 객체)를 사용하는게 좋다.
반환타입을 다음과 같이 고민해보자.
처리 방식을 스트림으로 하길 바라는사람과 반복으로 하길 바라는사람 모두를 만족할 수 있는 방법을 고민하자.
컬렉션을 반환할 수 있다면 그렇게 하자.
이미 컬렉션으로 관리하거나 원소의 수가 적다면, 표준 컬렉션(ex: ArrayList)에 담아 반환하자.
전용 컬렉션을 구현할지 고민하자.
컬렉션을 반환할 수 없다면, Strem, Iterable중 자연스러운 것을 반환하자.

Java 8 이전

Java 8 이전까지는 Stream이 없었기 때문에 원소 시퀀스를 반환하는 메서드는 보통 Collection 인터페이스나 Iterable, 배열을 사용하곤 했는데, Java 8 이후 Stream이 등장하며 다시 고려해야 할 부분들이 생겨났다.

반복을 지원하지 않는 Stream API

public interface BaseStream<T, S extends BaseStream<T, S>> extends AutoCloseable {...} public interface Stream<T> extends BaseStream<T, Stream<T>> { ... }
Java
복사
Stream과 Stream의 상위 객체인 BaseStream까지 모두 Iterable 인터페이스를 구현하거나 상속하지 않는다. 하지만, 그럼에도 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함하고 Iterable 인터페이스가 의도하는(정의하는) 방식으로 동작한다.
그래서 Iterable의 기능을 사용할 수 있지만 forEach를 사용할 수는 없다.(향상된 for문)

Stream과 Iterable간의 호환성을 고려하자.

Stream을 반복하기 위해서 제공되는 우회로는 적절한것이 없다. 어댑터를 사용해서 반복할 수 있지만, 이왕이면 이런 어댑터를 구현하지 않는것이 비용적으로 더 절약될 것이다.
void iterateStream(){ for(ProcessHandle p : iterableOf(ProcessHandle.allProcesses())) { //logic ... } } public static <E> Iterable<E> iterableOf(Stream<E> stream){ return stream::iterator; } public static <E> Stream<E> streamOf(Iterable<E> iterable){ return StreamSupport.stream(iterable.spliterator(), false); }
Java
복사
어댑터(iterableOf)를 이용한 Stream 반복하기와 Stream 중개 어댑터(streamOf)

Collection으로 반환

public interface Collection<E> extends Iterable<E> { ... default Stream<E> stream() { return StreamSupport.stream(spliterator(), false); } }
Java
복사
Collection Interface는 Iterable을 상속한다. 그리고 stream() 메서드를 통해 Stream 기능도 제공한다.
어댑터 메서드를 작성해서 Stream과 Iterable을 중개해줄수는 있지만, 이보다는 애초에 반복과 스트림 파이프라인 처리를 모두 지원하는 Collection 인터페이스를 사용하는것을 권장한다.

전용 컬렉션 구현을 고려하라

데이터가 충분히 적다면 표준 컬렉션 구현체(ex: ArrayList, HashSet)을 사용해도 되지만, 그게 아니라면 전용 컬렉션을 구현하는것을 고려할 수 있다. 책에서는 멱집합을 반환하는 상황을 얘기하는데, 멱집합은 원소의 개수가 n개일 때 2n2^n개가 된다는 점을 알고 있기에 AbstractList를 이용해 효율성을 높힌 전용 컬렉션을 반환할 수 있다.
public class PowerSet { public static final <E>Collection<Set<E>> of(Set<E> s){ List<E> src = new ArrayList<>(); if (src.size() > 30) { throw new IllegalArgumentException(); } return new AbstractList<>() { @Override public boolean contains(Object o) { return o instanceof Set && src.containsAll((Set)o); } @Override public Set<E> get(int index) { Set<E> result = new HashSet<>(); for (int i = 0; index != 0; i++, index >>= 1) { if ((index & 1) == 1) { result.add(src.get(i)); } } return result; } @Override public int size() { return 1 << src.size(); } }; } }
Java
복사
AbstractCollection을 활용할 때는 Iterable용 메서드외 contains, size 메서드만 더 구현하면 된다.
contains, size를 구현할 수 없을 땐 Stream, Iterable을 반환하는게 낫다.

48. 스트림 병렬화는 주의해서 적용하라

요약

자바는 동시성 프로그래밍의 선두주자로 시간이 지날수록 동시성 프로그램을 작성하기 쉬워졌다.
자바의 첫 등장 당시: 스레드, 동기화, wait/notify 지원
Java 5 : 동시성 컬렉션(java.util.concurrent)라이브러리, 실행자(Executor) 프레임워크
Java 7: 포크-조인 패키지(고성능 병렬 분해 프레임워크(parallel decom-position)) 등장
Java 8: parallel() 메서드로 파이프라인을 병렬 실행 가능한 Stream API 등장
동시성 프로그래밍은 안전성(safety)응답 가능(liveness) 상태를 유지해야 한다.
Stream API의 병렬 기능을 제공하는 parallel() 메서드는 ArrayList, HashMap, HashSet, ConcurrentHashMap이거나 배열로 범위가 int이거나 long일 때 병렬화의 효과가 크다.
병렬화의 이점을 제대로 활용하는 구조를 만들고 싶다면 spliterator 메서드를 재정의하고 Stream 의 병렬화 기능을 최대한 테스트해봐야 한다.
스트림 병렬화의 도입시점은 병렬화를 한 이후의 코드가 정확한 계산 결과와 높은 성능지표를 달성하는게 확실할 경우에만 해당된다.

Stream API의 병렬화가 문제가 되는 경우

책에서는 메르센 소수를 생성하는 코드를 예시로 병렬화의 문제점을 언급한다.
void mersenne() { primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) .filter(mersenne -> mersenne.isProbablePrime(50)) .limit(20) .forEach(System.out::println); } static Stream<BigInteger> primes() { return Stream.iterate(TWO, BigInteger::nextProbablePrime); }
Java
복사
20개의 메르센 소수를 생성하는프로그램
위 코드는 메르센 소수를 20개 생성하는 프로그램인데, 여기서 parallel() 메서드를 호출해서 여유 스레드를 활용한 동시성 프로그래밍으로 퍼포먼스를 높힐 수 있겠다고 추측할 수 있지만, 실제로는 1시간이 넘어도 결과를 출력하지 못하는 응답 불가(liveness) 상태에 빠지게 된다.
그 이유는, 데이터 소스가 Stream.iterate이거나 중개 오퍼레이터에 limit이 있는 경우 파이프라인 병렬화로 성능 향상을 시킬 수 없다. 그 이유는 스트림 라이브러리에서 병렬화 하는 방법을 찾지 못하기 때문인데, primes() 메서드에서 반환하는 스트림 파이프라인은 무한 스트림이기고, limit으로 제한까지 두니 적절하게 값을 분할 할 수도 없고, 지연 평가가 되기 때문에 각각의 값들의 참조 지역성도 떨어지게 된다.

병렬화가 가능하도록 리팩토링

그럼 이 문제점을 알았다면 해결을 할 수도 있을 것이다.
우선 무한 스트림이아닌 참조 지역성이 뛰어나고, 데이터의 범위가 정확하여 분할 및 스레드에 분배가 쉬운 자료구조를 선택하고, limit() 중개 오퍼레이션을 없애면 된다.
static void mersenne(long n) { LongStream.rangeClosed(2, n) .parallel() .mapToObj(BigInteger::valueOf) .filter(i -> i.isProbablePrime(50)) .forEach(System.out::println); }
Java
복사
무한 스트림도 없고 인자로 받는 인수가 limit의 역할도 할 수 있게 되었다. 이제 여기서 parallel() 메서드를 사용하면 병렬화가 되며 훨씬 빠른 속도로 결과를 확인할 수 있다.

Spliterator

병렬화의 효과를 잘 누릴 수 있는 자료구조는 다음과 같다.
ArrayList
HashMap
int, long 범위의 배열
HashSet
ConcurrentHashMap
이러한 자료구조의 특징은 다음과 같다.
데이터를 원하는 크기로 정확하고 쉽게 나눌 수 있다.
스레드에 분배하기가 좋다.
참조 지역성(locality of reference)이 뛰어나다.
나누는 작업을 Spliterator가 담당한다.
여기서 말하는 Spliterator는 다수에 스레드에게 작업양을 나눠서 할당해주는 역할을 맡는데, 이는 Stream, Iterable에서 spliterator() 메서드를 통해 지원한다.
그리고 퍼포먼스를 위해 직접 구현한 전용 컬렉션이나 Stream, Iterable에서 병렬화의 이점을 더 누리고 싶은 경우 spliterator 메서드를 재정의한 뒤 테스트를 통해 성능 지표를 확인하자.
(해당 책에서는 spliterator 튜닝에 대해서는 언급하지 않는다.)

병렬화에 적합한 Stream API

스트림 파이프라인에서 제공하는 기능은 상당히 많은데 이 중 병렬화의 효과를 제대로 볼 수 있는 작업은 축소(reduction) 작업이다. 이는 파이프라인에서 만들어지는 원소들을 하나로 합치는 작업으로 reduce, max, min, count, sum같은 취합 메서드나 anyMatch, allMatch, noneMatch 처럼 조건에 맞을 경우 바로 반환되는 메서드를 의미한다. 이런 축소는 각각의 그룹(스레드 작업 영역)이 순서가 없이 완료가 되더라도 병합(merge)하는데 사이드 이펙트가 없기 때문에 병렬화의 효과를 제대로 누릴 수 있다. 다만, 가변 축소(mutable reduction)를 수행하는 collect 메서드는 컬렉션들을 합치는 부담이 크기 때문에 병렬화에 적합하지 않다.
Stream API로 병렬화가 진행될 때 collect로 값을 취합하는 과정

Stream API 규약

스트림을 이용한 병렬화를 잘 못 사용하면 응답 불가 뿐 아니라 성능 저하, 그리고 결과가 잘못 도출되거나 기타 문제가 발생할 수 있는데, 이런 문제를 안전 실패(safety failure)라 한다.
즉, 동시성 프로그래밍에 중요한 안전성과 응답가능을 모두 지키지 못하게 된다.
Stream 명세에서는 이를 위해 규약들을 정의해놨다.
Stream의 reduce 연산에 전달되는 accumulator, combiner함수는 다음 조건을 만족해야 한다.
결합 법칙(associative)를 만족해야 한다.
간섭받지 않아야 한다.(non-interfering)
상태를 가지지 말아야 한다.(stateless)

참고: ThreadLocalRandom 보단 SplittableRandom

무작위 수를 스트림으로 병렬화 하고싶다면, TreadLocalRandom보다는 SplittableRandom을 사용하자. SplittableRandom는 애초에 병렬화를 고려하여 설계되었기에 병렬화 시 성능이 선형으로 증가한다.ThreadLocalRandom은 단일 스레드를 고려하고 만들었기에 병렬 스트림용 데이터 소스로는 최적의 성능을 낼 수 없다.
Random은 모든 연산을 동기화하기에 병렬 처리를 하면 안된다.

이전 챕터로

다음 챕터로