Java

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

jwKim96 2023. 2. 10. 08:13

0. 들어가며

스트림에서 '부작용 없는 함수'를 사용하라는 말은 무슨 뜻 일까요?
'부작용' 이라고 번역된 단어는 원서는 'Side effect' 입니다.

즉, 이 함수의 인해 '다른곳에 영향을 끼치지 않는 함수'를 스트림에서 사용해야 한다는 뜻이 됩니다.

1. 순수함수

다른곳에 영향을 끼치지 않는 함수를 함수형 프로그래밍에서는 '순수 함수'라고 하는데요.
이는 조금 바꿔서 말하면 '같은 입력이 주어지면, 항상 같은 결과를 내는 함수'라고 생각할 수 있습니다.

이를 코드로 한번 살펴볼텐데요.
먼저 순수함수가 아닌 예를 먼저 살펴봅시다.

class Something {
    private static int num = 1;
    public static void main(String[] args) {
        IntStream.range(0,10).forEach(HelloWorld::plusNumber);
        System.out.println(num); // 46
    }

    private static void plusNumber(int i) {
        num += i;
    }
}

plusNumber 라는 함수는 주어진 i 를 Something 이라는 클래스의 num 에 더하는 역할을 합니다.
이 함수는 i가 같은 값이 들어오더라도, num 이 달라지면 다른 결과를 만들어냅니다.
그래서 이 함수는 순수함수가 아니죠.

그래서 보통 순수함수는 외부 요소를 참조하지 않거나, 참조하더라도 변경시키지 않는 경우가 많습니다.
만약 변경된 결과가 필요하다면, 새로 생성하여 반환해야 합니다.

2. 짧고 명확하게

책에서는 스트림을 잘 사용하면 표현력, 속도, (상황에 따라)병렬성을 얻을 수 있다고 합니다.
하지만 스트림을 부작용이 있는 함수 혹은 길고 복잡한 로직과 함께 사용한다면 그 진가를 발휘하지 못한다고 합니다.

import java.util.Map;
import java.util.stream.Stream;

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

위 로직은 두 가지 문제가 있습니다.

  1. forEach 에서 외부(freq)에 영향을 주는 부작용 있는 로직이 포함되어 있음
  2. 코드가 간결하지 않습니다.

그래서 책에서는 Stream 에서 제공하는 API 들을 통해 아래와 같이 간결한 코드로 만들었습니다.

import static java.util.stream.Collectors.counting;
import static java.util.stream.Collectors.groupingBy;

import java.util.Map;
import java.util.stream.Stream;

Map<String, Long> freq;
try (Stream<String> words = Stream.of("abcdefg", "hijklmn", "opqrstu", "vwxyz", "opqrstu")) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

하지만 저는 스트림에 익숙하지 않아서, 이 코드를 보고 오히려 직관적으로 이해가 되진 않았는데요.
그래서 잘 활용하기 위해서는 스트림이 제공하는 다양한 기능들을 미리 파악하고 있어야 '스트림 답게' 사용할 수 있겠구나 생각이 들었습니다.

3. 스트림이 제공하는 API 들

  • Filter : 스트림의 내용을 일부 필터링 하는데 사용된다.
    • ex) words.filter(w -> w.length() > 5); // 5자 이상인 문자들만 뽑음
  • Sorted
    • ex) words.sorted(comparing(String::length)); // 길이가 작은 순으로 정렬
  • Map
    • ex) words.map(Word::new); // String 을 Word 클래스로 변환
  • Collect
    • ex) words.map(Word::new).collect(toList()); // Word 클래스 리스트로 변환

위처럼 다양하게 활용 가능한데요.
이 처럼 스트림을 조작하거나 손쉽게 다른 컬렉션으로 변환하거나, 축소하여 저장할 수 있도록 도와줍니다.
간편하게 변환을 지원하는 컬렉션은 List, Set, Collection 입니다.

  • List : Collectors.toList()
  • Set : Collectors.toSet()
  • Collection : Collectors.toCollection(collectionFactory)

이 외에도 groupingBy 라는 메서드로 Map 으로 변환할 수도 있습니다만 이는 단순 변환이 아닌 '축소'에 해당합니다.

이 대표적인 예가 2번에서 살펴봤던 변경 후 예제 코드인데요.

import static java.util.stream.Collectors.counting;
import static java.util.stream.Collectors.groupingBy;

import java.util.Map;
import java.util.stream.Stream;

Map<String, Long> freq;
try (Stream<String> words = Stream.of("abcdefg", "hijklmn", "opqrstu", "vwxyz", "opqrstu")) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
}

이를 보면 문자열 스트림은 총 5개의 문자열을 가지고 있습니다.
하지만 groupingBy 를 통해서 단어를 key 로 단어의 중복 개수를 value 로 하는 Map 을 반환합니다.
이렇게 되면 key, value 4쌍으로 축소되며 Map 으로 변환되었죠.

4. 결론

이 처럼 Stream 을 이용하면 간결하고 명확한 코드를 빠르게 생산해낼 수 있습니다.
그래서 Stream 이 제공하는 기능들에 익숙해 진다면 생산성이 향상될 것이라 생각됩니다.

하지만 팀원들이 모두 익숙한 상황이 아닌 상태라면 협업 관점에서는 좋은 영향이 있을지는 조금 더 고민해봐야할 것 같습니다.