[Effective Java] 아이템 46 : 스트림에서는 부작용 없는 함수를 사용하라

Loopy·2022년 9월 24일
0

이펙티브 자바

목록 보기
45/76
post-thumbnail

스트림은 단순한 또 하나의 API가 아닌, 함수형 프로그램에 기초하고 있다.

☁️ 스트림 패러다임

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분이다.
이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수여야 한다.

🔖 순수 함수란?
오직 입력만이 결과게 영향을 주는 함수. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않아야함

따라서 순수 함수가 되기 위해서는, 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다. 부수 효과란, 자신의 스코프 밖의 변수 상태 등을 변경하는 메소드, 프로시저를 의미한다.

☁️ ForEach 연산

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 에서 일어나는데, 이때 외부 상태(빈도표)를 수정하는 람다를 실행하고 있기 때문이다.

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

앞서와 같을 하지만 짧고 명확하며 스트림 API를 제대로 활용하게 바꾸었다.

결론적으로 ForEach 연산은 종단 연산 중 기능이 가장 적고 병렬화할 수도 없어서 덜 스트림 답기 때문에, 계산 결과를 보고할 때만 사용하고 계산하는 데는 쓰지 말아야 한다.

☁️ 병렬 처리와 부작용(side-effect)

이번에는 병렬 처리에서 왜 상태 변경 행위를 하면 안되는지 예시를 들어보자.

forEach와 병렬 처리

forEach 에서 외부 변수인 matched 의 상태를 바꾸려고 하는데, 여러 쓰레드가 matched에 접근하고 있고 별다른 lock 이 걸려있지 않기 때문에 matched size 가 매 실행마다 다른 것을 결과에서 볼 수 있다.

collect() 를 통해 병렬 스트림에서의 스트림 연산을 스레드 안전하게 사용할 수 있다.

상태 변경 작업과 병렬 처리

첫 번째에서는 스트림 내에서 외부 변수에 값을 저장해주었고, 후자에서는 collect 를 사용하였다.

둘 다 병렬 스트림을 사용하였지만 두 번째 방법에서 스레드 안전성이 보장되는 이유는, 병렬 스트림의 값 모음이 실제로 단일 Collection 개체로 수집되지 않기 때문이다. 내부적으로 각 스레드가 자체 데이터를 수집한 다음 모든 하위 결과가 하나의 최종 컬렉션 개체로 병합된다.

그렇다면 왜 병렬로 수행하였음에도 순서가 보장되었던 것일까?

https://stackoverflow.com/questions/29709140/why-parallel-stream-get-collected-sequentially-in-java-8/29713386#29713386

상태 변경 위험성

스트림은 지연 평가의 특성을 가지고 있으므로, reduce() 종단 연산이 수행되는 타이밍에서 앞선 중간 연산이 실행되게 된다.

원칙적으로 다른 스레드가 컬렉션을 반복하는 동안 한 스레드가 컬렉션을 수정하는 것은 일반적으로 허용되지 않는다.

그러니 당연히 여러 스레드가 reduce() 를 호출하는 시점에서, 값을 추가하려고 하니 이미 iterator 가 진행중인 Collection 을 수정하려고 할 때 발생하는 ConcurrentModificationException 이 발생하게 되는 것이다.

만약 동기화 리스트로 변경했다면, UnspportedOperationException 이 발생한다.

https://docs.oracle.com/javase/tutorial/collections/streams/parallelism.html#side_effects
https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html#Statelessness

☁️ Stream Collector(수집기)

위의 예제 처럼, 스트림을 올바르고 안전하게 사용하기 위해서는 수집기를 사용해야 한다. 수집기는 쉽게 말해, 축소(reduction) 전략을 캡슐화한 블랙박스 객체라고 할 수 있다.

🔖 축소?
스트림의 원소들을 객체 하나에 취합하는 것을 의미

수집기가 생성하는 객체는 일반적으로 컬렉션이기 때문에, 손쉽게 스트림의 원소를 컬렉션으로 모을 수 있다. 수집기의 종류는 다음과 같이 세가지가 존재한다.

  1. toList()
  2. toSet()
  3. toCollection(collectionFactory)

☁️ toList

List<String> topTen = freq.keySet().stream()
      .sorted(comparing(freq::get).reversed())
      .limit(10)
      .collect(toList());   // Collectors.toList() : 정적 import

comparing() 메서드는 키 추출 함수를 받는 비교자 생성 메서드(아이템 14)이다. freq::get 은 입력받은 단어(키)를 빈도표에서 찾아 빈도를 반환하고, 흔한 단어가 위로 오도록 비교자를 역순으로 정렬하고 있다.

☁️ toMap

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

private static final Map<String, Operation> stringToEnum = 
	Stream.of(values()).collect(
		toMap(Object::toString, e -> e));

위와 같이 간단한 형태는 스트림의 각 원소가 '고유한' 키에 매핑되어 있을 때 적합하며, 만약 스트림 원소 다수가 같은 키를 사용한다면 충돌이 일어나 파이프라인이 IllegalStateException 을 던지며 종료될 것이다.

인수 3개를 받는 형태

키와 키에 연관된 원소들 중 하나를 골라 연관 짓는 맵을 만들때 유용하다.

// 다양한 음악가의 앨범들을 담은 스트림을 가지고, 음악가와 베스트 앨범을 연관 짓고 있는 수집기
Map<Artist, Album> topHits = albums.collect(
	toMap(Album::artist, a->a, maxBy(comparing(Album::sales))));  //BinaryOperator.maxBy()

maxBy는 Comparator<T> 를 입력받아 BinaryOperator<T> 를 돌려준다.

마지막에 쓴 값을 취하는 수집기

또한, 인수가 3개인 toMap 은 충돌이 나면 마지막 값을 취하는 수집기를 만들때도 유용하다.

toMap(keyMapper, valueMapper, (oldVal, newVal) -> newVal)

☁️ groupingBy

1. 리스트 형태

이 메서드는 입력으로 분류 함수(classifier)를 받고, 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다. 즉 분류 함수가 입력받은 원소가 속하는 카테고리를 반환하고 이 카테고리가 해당 원소의 맵 키로 쓰이게 된다.

words.collect(groupingBy(word -> alphabetize(word))); 

위의 코드는, 알파벳화한 단어를 알파벳화 결과가 같은 단어들의 리스트로 매핑하는 맵을 생성하고 있다.

2. DownStream : 리스트 외의 형태

만약 groupingBy 가 반환하는 수집기가 리스트 외의 값을 갖는 맵을 생성하게 하려면, 분류 함수와 함께 다운스트림(downstream) 수집기도 명시해야 한다.

🔖 다운 스트림 수집기 역할
해당 카테고리의 모든 원소를 담은 스트림으로부터 값을 생성

1️⃣ toSet()
groupingBy는 원소들의 리스트가 아닌 집합 값을 갖는 맵을 만들어 낸다.

2️⃣ toCollection(collectionFactory)
리스트나 집합 대신 컬렉션을 값으로 갖는 맵을 생성하여 유연성이 증가한다.

3️⃣ counting()
각 칵테고리(키)를 해당 카테고리에 속하는 원소의 개수(값)과 매핑한 맵을 얻을 수 있다.(원소를 담은 컬렉션 X)

Map<String, Long> freq = words.collect(groupingBy(String::toLowerCase, counting()));

주의할 점은 스트림에는 count() 메서드를 직접 사용하여 같은 기능을 수행할 수 있으니 collect(counting())과 같은 형태로 사용하지 말아야 한다. 이외에도 summing, averaging, summarizing과 같은 메서드들도 마찬가지이다.

☁️ joining

해당 메서드는 문자열 등의 CharSequence 인스턴스의 스트림에만 적용 가능하다.

1) 매개변수가 없는 경우

단순히 원소들을 연결하는 수집기를 반환한다.

2) 매개변수가 하나인 경우

CharSequence 타입의 구분문자(delimiter)를 받아 연결 부위에 구분문자를 삽입해준다.

3) 매개변수가 세개인 경우

구분문자와 함께 접두문자(prefix)와 접미문자(suffix)를 받는다.

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글