← 학습기록 (Java · 이펙티브 자바 3E)

7장 - 람다와 스트림

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

예전에는 자바에서 함수 타입을 표현할 때 추상 메서드를 하나만 담은 인터페이스(드물게는 추상 클래스)를 사용했다.

이런 인터페이스의 인스턴스를 함수 객체(function object)라고 하여, 특정 함수나 동작을 나타내는 데 썼다.

1997년 JDK 1.1이 등장하면서 함수 객체를 만드는 주요 수단은 익명 클래스가 되었다.

다음 코드를 예로 살펴보자. 문자열을 길이순으로 정렬하는데, 정렬을 위한 비교 함수로 익명 클래스를 사용한다.

Collections.sort(words, new Comparator<String>() {
	public int compare(String s1, String s2) {
		return Integer.compare(s1.length(), s2.length());
	}
});

이 코드에서 Comparator 인터페이스가 정렬을 담당하는 추상 전략을 뜻하며, 문자열을 정렬하는 구체적인 전략을 익명 클래스로 구현했다.

하지만 익명 클래스 방식은 코드가 너무 길기 때문에 자바는 함수형 프로그래밍에 적합하지 않았다.

자바 8에 와서 추상 메서드 하나짜리 인터페이스는 특별한 의미를 인정받아 특별한 대우를 받게 되엇다.

지금은 함수형 인터페이스라 부르는 이 인터페이스들의 인스턴스를 람다식을 사용해 만들 수 있게 된 것이다.

람다는 함수나 익명 클래스와 개념은 비슷하지만 코드는 훨씬 간결하다. 다음은 람다를 사용해 위 코드를 바꾼 모습이다

Collections.sort(words,
			(s1, s2) -> Integer.comapre(s1.length(), s2.length()));

여기서 람다, 매개변수(s1, s2), 반환값의 타입은 각각 (Comparator<String>), String, int 지만 코드에는 언급이 없다.

우리 대신 컴파일러가 문맥을 살펴 타입을 추론해준 것이다.

상황에 따라 컴파일러가 타입을 결정하지 못할 수도 있는데, 그럴 때는 프로그래머가 직접 명시해야 한다.

타입 추론 규칙은 자바 언어 명세의 Chaptor 하나를 통째로 차지할 만큼 복잡하다. 너무 복잡해서 이 규칙을 다 이해하는 프로그래머는 거의 없고, 잘 알지 못한다 해도 상관없다.

타입을 명시해야 코드가 더 명확할 때만 제외하고는, 람다의 모든 매개변수 타입은 생략하자.

람다 자리에 비교자 생성 메서드를 사용하면 이 코드를 더 간결하게 만들 수 있다.

Collections.sort(words, comparingInt(String::length));

더 나아가 자바 8때 List 인터페이스에 추가된 sort 메서드를 이용하면 더욱 짧아진다.

words.sort(comparingInt(String::length));

아이템 34에서는 상수별 클래스 몸체를 구현하는 방식보다는 열거 타입에 인스턴스 필드를 두는 편이 낫다고 했다

public enum Operation {
	PLUS("+") {
		return ...;
	}
}

람다를 이용하면 후자의 방식, 즉 열거 타입의 인스턴스 필드를 이용하는 방식으로 상수별로 다르게 동작하는 코드를 쉽게 구현할 수 있다.

단순히 각 열거 타입 상수의 동작을 람다로 구현해 생성자에 넘기고, 생성자는 이 람다를 인스턴스 필드로 저장해둔다.

그런 다음 apply 메서드에서 필드에 저장된 람다를 호출하기만 하면 된다.

이렇게 구현하면 원래 버전보다 간결하고 깔끔해진다

public enum Operation {
	PLUS ("+", (x, y) -> x + y)
	
	private final String symbol;
	private final DoubleBinaryOperator op;
}

람다 기반 Operation 열거 타입을 보면 상수별 클래스 몸체는 더 이상 사용할 이유가 없다고 느낄지 모르지만, 꼭 그렇지는 않다.

메서드나 클래스와 달리, 람다는 이름이 없고 문서화도 못 한다. 따라서 코드 자체로 동작이 명확히 설명되지 않거나 코드 줄 수가 많아지면 람다를 쓰지 말아야 한다.

람다는 한 줄일때가 가장 좋고 길어야 세 줄 안에 끝내는 게 좋다. 세 줄이 넘어가면 가독성이 심하게 안좋아 진다.

마지막으로, 람다는 자신을 참조할 수 없다. 람다에서의 this 키워드는 바깥 인스턴스를 가리킨다. 반면 익명 클래스에서의 this는 익명 클래스의 인스턴스 자신을 가리킨다.

그래서 함수 객체가 자신을 참조해야 한다면 반드시 익명 클래스로 써야 한다.

람다도 익명 클래스처럼 직렬화 형태가 구현별로 다를 수 있다. 따라서…

람다를 직렬화하는 일은 극히 삼가야 한다

직렬화해야만 하는 함수 객체가 있다면 private 정적 중첩 클래스의 인스턴스를 사용하자.

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

람다가 익명 클래스보다 나은 점 중에서 가장 큰 특징은 간결함이다.

그런데 자바에는 함수 객체를 심지어 람다보다도 더 간결하게 만드는 방법이 있으니, 바로 메서드 참조다.

다음 코드는 임의의 키와 Integer 값의 매핑을 관리하는 프로그램의 일부다.

이때 값이 키의 인스턴스 개수로 해석된다면, 이 프로그램은 멀티셋을 구현한 게 된다. 이 코드는 키가 맴안에 없다면 키와 숫자 1을 매핑하고, 이미 있다면 기존 매핑 값을 증가시킨다.

map.merge(key, 1, (count, incr) -> count + incr);

이 코드는 자바 8 때 Map에 추가된 merge 메서드를 사용했다.

merge 메서드는 키, 값, 함수를 인수로 받으며, 주어진 키가 맵 안에 아직 없다면 주어진 키, 값 쌍을 그대로 저장한다.

반대로 키가 이미 있다면 함수를 현재 값과 주어진 값에 적용한 다음, 그 결과로 현재 값을 덮어 쓴다.

깔끔해보이는 코드지만 아직도 거추장스러운 부분이 남아 있다.

매개변수인 count, incr은 크게 하는 일 없이 공간을 꽤 차지한다. 자바 8이 되면서 Integer 클래스는 이 람다와 기능이 같은 정적 메서드 sum을 제공하기 시작했다.

따라서 람다 대신 이 메서드의 참조를 전달하면 똑같은 결과를 더 보기 좋게 얻을 수 있다.

map.merge(key, 1, Integer::sum);

IDE들은 람다를 메서드 참조로 대체하라고 권할 것이다. IDE의 권고를 따르는 게 보통은 이득이지만, 항상 그런 것은 아니다. 때론 람다가 메서드 참조보다 간결할 때가 있다.

주로 메서드와 람다가 같은 클래스에 있을 때 그렇다.

예를 들어 다음 코드가 GoshThisClassNameIsHumongous 클래스 안에 있다고 해보자.

service.execute(GoshThisClassNameIsHumongous::action);

이를 람다로 대체하면 다음처럼 된다.

service.execute(() -> action());

메서드 참조 쪽은 더 짧지도, 더 명확하지도 않다. 따라서 람다 쪽이 낫다.

같은 선상에서 java.util.function 패키지가 제공하는 제네릭 정적 팩터리 메서드인 Function.identity()를 사용하기 보다는 똑같은 기능의 람다 (x → x)를 직접 사용하는 편이 코드도 짧고 명확하다.

메서드 참조의 유형은 다섯 가지로, 가장 흔한 유형은 앞의 예에서 본 것처럼 정적 메서드를 가리키는 메서드 참조다. 이제 나머지 유형 네 가지를 살펴보자

메서드 참조 유형같은 기능을 하는 람다
정적Integer::parseIntstr → Integer.parseInt(str)
한정적(인스턴스)Instant.now()::isAfterInstant then = Instant.now();
t → then.isAfter(t)
비한정적(인스턴스)String::toLowerCasestr → str.toLowerCase()
클래스 생성자TreeMap<K,V>::new() → new TreeMap<K,V>()
배열 생성자int[]::newlen → new int[len]

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

자바가 람다를 지원하면서 API를 작성하는 모범 사례도 크게 바뀌었다. 예컨대 상위 클래스의 기본 메서드를 재정의해 원하는 동작을 구현하는 템플릿 메서드 패턴의 매력이 크게 줄었다.

이를 대체하는 현대적인 해법은 같은 효과의 함수 객체를 받는 정적 팩터리나 생성자를 제공하는 것이다.

이 내용을 일반화해서 말하면 함수 객체를 매개변수로 받는 생성자와 메서드를 더 많이 만들어야 한다.

이 때 함수현 매개변수 타입을 올바르게 선택해야 한다.

LinkedHashMap의 다음 메서드를 살펴보자

protected boolean removeEldestEntry(Map.Entry<K,V> eldest {
	return size > 100;
}

가장 오래된 원소를 제거할 때 호출하는 위 메서드는 아래와 같이 변경할 수 있다.

@FunctionalInterface interface EldestEntryRemovalFunction<K, V>  {
	boolean remove(Map<K,V> map, Map.Entry<K,V> eldest);
}

이 인터페이스도 잘 동작하지만, 표준 인터페이스가 있기 때문에 굳이 작성할 필요는 없다.

필요한 용도에 맞는 게 있다면, 직접 구현하지 말고 표준 함수형 인터페이스를 활용하라.

@FunctionalInterface 어노테이션이 달려있는 것은 3가지 역할을 한다.

첫 번째, 해당 클래스의 코드나 설명 문서를 읽을 이에게 그 인터페이스가 람다용으로 설계된 것임을 알려준다.

두 번째, 해당 인터페이스가 추상 메서드를 오직 하나만 가지고 있어야 컴파일되게 해준다.

세 번째, 그 결과 유지보수 과정에서 누군가 실수로 메서드를 추가하지 못하게 막아준다.

그러니 직접 만든 함수형 인터페이스에는 항상 @FunctionalInterface 어노테이션을 사용하라.

마지막으로, 함수형 인터페이스를 API에서 사용할 때의 주의점을 일러둔다.

서로 다른 함수형 인터페이스를 같은 위치의 인수로 받는 메서드들을 다중 정의해서는 안 된다.

클라이언트에게 불필요한 모호함을 안겨줄 뿐이며, 이 모호함으로 인해 실제로 문제가 일어나기도 한다.

ExecutorService의 submit 메서드는 Callable<T>를 받는 것과 Runnable을 받는 것을 다중정의했다.

그래서 올바른 메서드를 알려주기 위해 형변환해야 할 때가 왕왕 생긴다.

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

스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 자바 8에 추가되었다.

이 API가 제공하는 추상 개념 중 핵심은 두 가지다.

그 첫 번째인 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.

두 번째인 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.

스트림의 원소들은 어디로부터든 올 수 있다.

대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매쳐, 난수 생성기, 혹은 다른 스트림이 있다.

스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다. 기본 타입 값으로는 int, long, double 이렇게 세 가지를 지원한다.

스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 하나 이상의 중간 연산이 있을 수 있다.

각 중간 연산은 스트림을 어떠한 방식으로 변환한다.

예컨대 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있다.

스트림 파이프라인은 지연 평가 된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.

이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠다.

종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.

스트림 API는 메서드 연쇄를 지원한다.

기본적으로 스트림 파이프라인은 순차적으로 수행된다. 파이프라인을 병렬로 실행햐려면 parallel메서드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다.

스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.

스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라

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

스트림은 그저 또 하나의 API가 아닌 함수형 프로그래밍에 기초한 패러다임이다. 스트림이 제공하는 표현력, 속도, (상황에 따라서는) 병렬성을 얻으려면 이 패러다임까지 함께 받아들여야 한다.

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.

이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.

순수 함수란 오직 입력만이 결과에 영향을 주는 함수를 말한다.

다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다.

이렇게 하려면 (중간 단계든 종단 단계든) 스트림 연산에 건네는 함수 객체는 모두 부작용이 없어야 한다.

다음은 주위에서 종종 볼 수 있는 스트림 코드로, 텍스트 파일에서 단어별 수를 세어 빈도표를 만드는 일을 한다.

Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
	words.forEach(word -> {
		freq.merge(word.toLowerCase(), 1L, Long::sum);
	});
}

무엇이 문제인지 보이는가? 스트림, 람다, 메서드 참조를 사용했고, 결과도 올바르다.

하지만 절대 스트림 코드라 할 수 없다. 스트림 코드를 가장한 반복적 코드다.

스트림 API의 이점을 살리지 못하여 같은 기능의 반복적 코드보다 길고, 읽기 어렵고, 유지보수에도 좋지 않다.

이 코드의 모든 작업이 종단 연산인 forEach에서 일어나는데, 이때 외부 상태를 수정하는 람다를 실행하면서 문제가 생긴다.

forEach가 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 하는 것 (이 예에서는 람다가 상태를 수정함)을 보니 나쁜 코드일 것 같은 냄새가 난다.

이제 올바르게 작성한 모습을 살펴보자.

Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
	freq = words
			.collect(groupingBy(String::toLowerCase, counting()));
}

앞서와 같은 일을 하지만, 이번엔 스트림 API를 제대로 사용했다. 그뿐만 아니라 짧고 명확하다.

forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

이 코드는 수집기(collector)를 사용하는데, 스트림을 사용하려면 꼭 배워야 하는 새로운 개념이다.

java.util.stream.Collectors 클래스는 메서드를 39개나 가지고 있고, 그 중에는 타입 매개변수가 5개나 되는 것도 있다.

수집기는 총 세 가지로, toList(), toSet(), toCollection(CollectionFactory)가 그 주인공이다. 이들은 차례로 리스트, 집합, 프로그래머가 지정한 컬렉션 타입을 반환한다.

가장 간단한 맵 수집기는 toMap(keyMapper, valueMapper)로 보다시피 스트림 원소를 키에 매핑하는 함수와 값에 매핑하는 함수를 인수로 받는다.

가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

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

원소 시퀀스, 즉 일련의 원소를 반환하는 메서드는 수없이 많다. 자바 7까지는 이런 메서드의 반환 타입으로 Collection, Set, List 같은 컬렉션 인터페이스, 혹은 Iterable이나 배열을 썼다.

이 중 가장 적합한 타입을 선택하기란 그다지 어렵지 않았다. 기본은 컬렉션 인터페이스다.

for-each 문에서만 쓰이거나 반환된 원소 시퀀스가 일부 Collection 메서드를 구현할 수 없을 때는 Iterable 인터페이스를 썼다.

반환 원소들이 기본 타입이거나 성능에 민감한 상황이라면 배열을 썼다. 그런데 자바 8이 스트림이라는 개념을 들고 오면서 이 선택이 아주 복잡한 일이 되어버렸다.

원소 시퀀스를 반환할 때는 당연히 스트림을 사용해야 한다는 이야기를 들어봤을지 모르겠지만, 아이템 45에서 이야기했듯이 스트림은 반복을 지원하지 않는다.

따라서 스트림과 반복을 알맞게 조합해야 좋은 코드가 나온다.

API를 스트림만 반환하도록 짜놓으면 반환된 스트림을 for-each로 반복하길 원하는 사용자는 당연히 불만을 토로할 것이다.

여기서 재미난 사실 하나!

사실 Stream 인터페이스는 Iterable 인터페이스가 정의한 추상 메서드를 전부 포함할 뿐만 아니라, Iterable 인터페이스가 정의한 방식대로 동작한다,

그럼에도 for-each로 스트림을 반복할 수 없는 까닭은 바로 Stream이 Iterable을 확장하지 않아서다.

반환된 값을 for-each로 반복하고 싶은 사용자는 Iterable로 반환하기를 원할 것이고, 스트림 파이프라인에 사용하려는 사용자는 스트림으로 반환하길 원할 것이다.

보통의 상황에서는 모두를 배려해서 반환타입을 선택해야 한다.

Collection 인터페이스는 Iterable의 하위 타입이고 stream 메서드도 제공하니 반복과 스트림을 동시에 지원한다.

따라서 원소 시퀀스를 반환하는 공개 API의 반환 타입에는 Collection이나 그 하위 타입을 쓰는 게 일반적으로 최선이다.

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

주류 언어 중, 동시성 프로그래밍 측면에서 자바는 항상 앞서갔다. 처음 릴리스된 1996년부터 스레드, 동기화, wait/notify를 지원했다.

자바 5부터는 동시성 컬렉션인 java.util.concurrent 라이브러리와 실행자(Executor) 프레임워크를 지원했다.

자바 7부터는 고성능 병렬 분해 프레임워크인 포크-조인 패키지를 추가했다.

그리고 자바 8부터는 parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원했다.

이처럼 자바로 동시성 프로그램을 작성하기가 점점 쉬워지고는 있지만, 이를 올바르고 빠르게 작성하는 일은 여전히 어려운 작업이다.

동시성 프로그래밍을 할 때는 안전성과 응답 가능 상태를 유지하기 위해 애써야 하는데, 병렬 스트림 파이프라인 프로그래밍에서도 다를 바 없다.

스트림 라이브러리가 파이프라인을 병렬화하는 방법을 찾아내지 못하면 아무것도 하지 못하고 CPU만 잡아먹는 상태가 무한히 지속될 수도 있게된다.

환경이 아무리 좋더라도 데이터 소스가 Stream.iterate 거나 중간 연산으로 limit을 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

대체로 스트림의 소스가 ArrayList, HashMap, HashSet, ConcurrentHashMap 의 인스턴스거나 배열, int 범위, long 범위일 때 병렬화의 효과가 가장 좋다.

이 자료구조들은 모두 데이터를 원하는 크기로 정확하고 손쉽게 나눌 수 있어서 일을 다수의 스레드에 분배하기에 좋다는 특징이 있다. 나누는 작업은 Spliterator가 담당한다.

이 자료구조들의 또 다른 중요한 공통점은 원소들을 순차적으로 실행할 때의 참조 지역성이 뛰어나다는 것이다.

이웃한 원소의 참조들이 메모리에 연속되어 저장되어 있다는 뜻이다.

하지만 참조들이 가리키는 실제 객체가 메모리에서 서로 덜어져 있을 수 있는데, 그러면 참조 지역성이 나빠진다.

참조 지역성이 낮으면 스레드는 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분 시간을 멍하게 보내게 된다.

따라서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화할 때 아주 중요한 요소로 작용한다.

참조 지역성이 가장 뛰어난 자료구조는 기본 타입의 배열이다. 데이터 자체가 메모리에 연속해서 저장되기 때문이다.

스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

심지어 데이터 소스 스트림이 효율적으로 나눠지고, 병렬화하거나 빨리 끝나는 종단 연산을 사용하고, 함수 객체들도 간섭하지 않더라도, 파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있다.

실제로 성능이 향상될지를 추정해보는 간단한 방법이 있다.

스트림 안의 원소 수와 원소당 수행되는 코드 줄 수를 곱해보자. 이 값이 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.

스트림 병렬화는 오직 성능 최적화 수단임을 기억해야 한다.

다른 최적화와 마찬가지로 변경 전후로 반드시 성능을 테스트하여 병렬화를 사용할 가치가 있는지 확인해야 한다.

앞으로 여러분이 스트림 파이프라인을 병렬화할 일이 적어질 것처럼 느껴졌다면, 그건 진짜 그렇기 때문이다.

스트림을 많이 사용하는 수백만 줄짜리 코드를 여러 개 관리하는 프로그래머는 그중 스트림 병렬화가 효과를 보는 경우가 많지 않음을 알게 된다.

그렇다고 스트림을 병렬화하지 말라는 뜻은 아니다.

조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례한 성능 향상을 만끽할 수 있다.