Post

[Effective Java] - 스트림에서는 부작용 없는 함수를 사용하라

[Effective Java] - 스트림에서는 부작용 없는 함수를 사용하라

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

들어가며

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

스트림 패러다임의 핵심은 계산을 일련의 변환으로 재구성하는 부분 이다. 이때 각 변환 단계는 가능한 한 이전 단계의 결과를 받아 처리하는 순수 함수 여야 한다. 순수 함수란 오직 입력만이 결과에 영향을 주는 함수다. 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는다. 이를 위해서는 스트림 연산에 건네는 함수 객체는 모두 부작용(side effect)이 없어야 한다.


잘못된 스트림 코드

먼저 스트림을 잘못 사용한 예시를 보자. 텍스트 파일에서 단어별 빈도를 세는 코드다.

1
2
3
4
5
6
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에서 일어나는데, 이때 외부 상태(freq)를 수정하는 람다를 실행하면서 문제가 생긴다.

forEach가 그저 스트림이 수행한 연산 결과를 보여주는 일 이상을 하고 있다. forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자. 물론 가끔은 스트림 계산 결과를 기존 컬렉션에 추가하는 등의 다른 용도로도 쓸 수 있다.


올바른 스트림 코드

이제 올바르게 작성한 코드를 보자.

1
2
3
4
5
6
// 부작용 없는 함수 사용
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}

같은 일을 하지만 스트림 API를 제대로 사용했다. 짧고 명확하다. forEach 대신 collector를 사용 했다. Collector를 사용하면 스트림의 원소들을 손쉽게 컬렉션으로 모을 수 있다.

부작용이란 무엇인가?

부작용(side effect) 은 함수가 자신의 결과값 반환 외에 외부 상태를 변경하는 것을 의미한다.

1
2
3
4
5
6
7
// 부작용 O - 외부 변수 수정
int sum = 0;  // 외부 상태
numbers.forEach(n -> sum += n);  // 컴파일 에러 (final이 아니므로)

// 부작용 X - 순수 함수만 사용
int sum = numbers.stream()
    .reduce(0, Integer::sum);  // 외부 상태에 손대지 않음

첫 번째 코드는 sum이라는 외부 변수를 람다 안에서 수정하려 한다. 실제로는 람다에서 사용하는 외부 지역변수는 final이거나 사실상 final이어야 하므로 컴파일조차 되지 않는다.

두 번째 코드가 올바른 스트림 사용법이다. reduce는 두 값을 받아 새로운 값을 반환할 뿐, 외부의 어떤 것도 변경하지 않는다.


forEach는 언제 쓰나?

forEach 연산은 종단 연산 중 기능이 가장 적고 가장 덜 스트림답다. 대놓고 반복적이라서 병렬화할 수도 없다. forEach 연산은 스트림 계산 결과를 보고할 때만 사용하고, 계산하는 데는 쓰지 말자.

1
2
3
4
5
6
7
8
9
10
11
12
13
// forEach의 올바른 사용 - 계산 결과 보고
Map<String, Long> freq = words.stream()
    .collect(groupingBy(String::toLowerCase, counting()));

// 계산된 결과를 출력만 함
freq.forEach((word, count) -> 
    System.out.println(word + ": " + count));

// 올바른 사용 - 단순 출력
List<String> names = Arrays.asList("철수", "영희", "민수");
names.stream()
    .filter(name -> name.length() <= 2)
    .forEach(System.out::println);


Collector - 스트림의 진정한 힘

Collector를 사용하면 스트림의 원소를 손쉽게 컬렉션으로 모을 수 있다. Collector는 총 39개의 메서드를 가진 방대한 API지만, 익숙해지면 스트림을 자유자재로 다룰 수 있다.

toList, toSet, toCollection

가장 기본적인 수집기다.

1
2
3
4
5
6
7
8
9
10
11
12
13
List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());  // List로 수집

// Set으로 수집
Set<String> uniqueWords = words.stream()
    .map(String::toLowerCase)
    .collect(toSet());

// 특정 Collection 구현체 지정
TreeSet<String> sortedWords = words.stream()
    .collect(toCollection(TreeSet::new));

toMap - 맵으로 수집

toMap은 스트림 원소를 키와 값에 매핑하는 함수를 인수로 받는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 가장 간단한 형태 - 키와 값 지정
Map<String, Integer> nameToLength = names.stream()
    .collect(toMap(
        name -> name,           // Key: 이름
        name -> name.length()   // Value: 이름 길이
    ));

// 메서드 참조로 더 간결하게
Map<String, Integer> nameToLength = names.stream()
    .collect(toMap(
        Function.identity(),    // Key: 자기 자신
        String::length          // Value: 길이
    ));

더 복잡한 형태로는 충돌을 처리하는 병합(merge) 함수 를 제공할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 앨범 스트림에서 아티스트와 그의 베스트 앨범을 연결
Map<String, Album> topAlbums = albums.stream()
    .collect(toMap(
        Album::artist,                           // Key: 아티스트
        album -> album,                          // Value: 앨범
        (oldVal, newVal) -> 
            oldVal.sales() > newVal.sales() ? oldVal : newVal  // 충돌 시 판매량 높은 것
    ));

// maxBy 사용으로 더 간결하게
Map<String, Album> topAlbums = albums.stream()
    .collect(toMap(
        Album::artist,
        Function.identity(),
        maxBy(comparing(Album::sales))
    ));

toMap의 세 가지 형태

toMap은 세 가지 형태로 제공된다.

1
2
3
4
5
6
7
8
// 1. 기본형 - 키 중복 시 IllegalStateException
toMap(keyMapper, valueMapper)

// 2. 병합 함수 지정 - 키 충돌 처리
toMap(keyMapper, valueMapper, mergeFunction)

// 3. 맵 구현체 지정 - TreeMap, LinkedHashMap 등
toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)

toMap은 파라미터 개수에 따라 세 가지 형태로 제공된다. 각각을 명확한 예시로 살펴보자.

1. toMap(keyMapper, valueMapper)

키가 중복되지 않을 때 사용한다. 키가 중복되면 IllegalStateException이 발생한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 예시: 학생 ID → 학생 이름 매핑
class Student {
    int id;
    String name;
    
    Student(int id, String name) {
        this.id = id;
        this.name = name;
    }
    
    int getId() { return id; }
    String getName() { return name; }
}

List students = Arrays.asList(
    new Student(1, "철수"),
    new Student(2, "영희"),
    new Student(3, "민수")
);

// ID를 키로, 이름을 값으로 하는 Map 생성
Map idToName = students.stream()
    .collect(toMap(
        Student::getId,    // Key: ID (중복 없음)
        Student::getName   // Value: 이름
    ));
// 결과: {1=철수, 2=영희, 3=민수}


// 만약 ID가 중복되면
List duplicateIds = Arrays.asList(
    new Student(1, "철수"),
    new Student(1, "영희")  // ID 중복
);

// IllegalStateException 발생
Map map = duplicateIds.stream()
    .collect(toMap(Student::getId, Student::getName));

2. toMap(keyMapper, valueMapper, mergeFunction)

키가 중복될 수 있을 때 사용한다. 세 번째 파라미터인 병합 함수가 충돌을 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// 예시: 같은 이름의 사람이 여러 명일 때, 나이가 많은 사람 선택
class Person {
    String name;
    int age;
    
    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    String getName() { return name; }
    int getAge() { return age; }
}

List people = Arrays.asList(
    new Person("철수", 25),
    new Person("영희", 30),
    new Person("철수", 28)  // 이름 중복!
);


// 이름이 중복되면 나이가 많은 사람 선택하기
Map olderByName = people.stream()
    .collect(toMap(
        Person::getName,              // Key: 이름
        p -> p,                       // Value: Person 객체
        (person1, person2) ->         // 충돌 시: 나이 많은 사람
            person1.getAge() > person2.getAge() ? person1 : person2
    ));
// 결과: {철수=Person(철수,28), 영희=Person(영희,30)}
// 철수(25)와 철수(28) 중 28살이 선택됨


// 마지막 값으로 덮어쓰기
Map lastAgeByName = people.stream()
    .collect(toMap(
        Person::getName,
        Person::getAge,
        (oldAge, newAge) -> newAge  // 나중 값으로 덮어쓰기
    ));
// 결과: {철수=28, 영희=30}

3. toMap(keyMapper, valueMapper, mergeFunction, mapSupplier)

특정 Map 구현체가 필요할 때 사용한다. TreeMap으로 정렬하거나 LinkedHashMap으로 순서를 유지할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 예시: 알파벳 순으로 정렬된 맵이 필요할 때
List words = Arrays.asList("cherry", "apple", "banana", "apricot");

// TreeMap으로 생성하여 자동 정렬
Map sortedMap = words.stream()
    .collect(toMap(
        word -> word,                // Key: 단어
        String::length,              // Value: 길이
        (len1, len2) -> len1,        // 충돌 시: 첫 번째 값 유지
        TreeMap::new                 // TreeMap으로 생성
    ));
// 결과: {apple=5, apricot=7, banana=6, cherry=6}


// 비교: HashMap을 사용하면 순서가 보장되지 않음
Map unsortedMap = words.stream()
    .collect(toMap(
        word -> word,
        String::length,
        (len1, len2) -> len1
    ));
// 결과: {banana=6, cherry=6, apple=5, apricot=7}  // 순서 무작위

// LinkedHashMap으로 입력 순서 유지
Map orderedMap = words.stream()
    .collect(toMap(
        word -> word,
        String::length,
        (len1, len2) -> len1,
        LinkedHashMap::new           // LinkedHashMap으로 생성
    ));
// 결과: {cherry=6, apple=5, banana=6, apricot=7}
// 입력 순서 그대로 유지

groupingBy - 카테고리별로 모으기

groupingBy는 입력으로 분류 함수(classifier) 를 받고, 출력으로는 원소들을 카테고리별로 모아 놓은 맵을 담은 수집기를 반환한다.

1
2
3
4
5
6
7
8
9
// 알파벳별로 단어 그룹핑
Map<Character, List<String>> wordsByFirstLetter = words.stream()
    .collect(groupingBy(word -> word.charAt(0)));
// {a=[apple, apricot, avocado], b=[banana, berry], ...}

// 단어 길이별로 그룹핑
Map<Integer, List<String>> wordsByLength = words.stream()
    .collect(groupingBy(String::length));
// {5=[apple, grape], 6=[banana, cherry], ...}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 각 카테고리의 원소 개수를 세기
Map<Character, Long> letterCount = words.stream()
    .collect(groupingBy(
        word -> word.charAt(0),
        counting()
    ));
// {a=3, b=2, c=2, ...}

// 각 카테고리에서 가장 긴 단어 찾기
Map<Character, Optional<String>> longestByLetter = words.stream()
    .collect(groupingBy(
        word -> word.charAt(0),
        maxBy(comparing(String::length))
    ));

다운스트림 수집기로 counting()을 건네면 각 카테고리를 해당 카테고리에 속하는 원소의 개수와 매핑한 맵을 얻는다. maxByminBy를 사용하면 각 카테고리에서 최댓값 또는 최솟값을 가진 원소를 찾을 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 학년별 학생 수
Map<Integer, Long> studentCountByGrade = students.stream()
    .collect(groupingBy(
        Student::getGrade,
        counting()
    ));

// 학년별 평균 점수
Map<Integer, Double> avgScoreByGrade = students.stream()
    .collect(groupingBy(
        Student::getGrade,
        averagingInt(Student::getScore)
    ));

// 학년별 최고 점수 학생
Map<Integer, Optional<Student>> topStudentByGrade = students.stream()
    .collect(groupingBy(
        Student::getGrade,
        maxBy(comparing(Student::getScore))
    ));

groupingBy의 다운스트림 수집기들

다운스트림 수집기를 사용하면 그룹핑 후 추가 연산을 수행할 수 있다. 주요 다운스트림 수집기들을 표로 정리하면 다음과 같다.

다운스트림 수집기 역할 반환 타입 사용 예시
counting() 각 그룹의 개수 Map<K, Long> 학년별 학생 수
summingInt() 각 그룹의 합계 Map<K, Integer> 학년별 총점
averagingInt() 각 그룹의 평균 Map<K, Double> 학년별 평균 점수
maxBy(comparator) 각 그룹의 최댓값 Map<K, Optional<T>> 학년별 최고 점수 학생
minBy(comparator) 각 그룹의 최솟값 Map<K, Optional<T>> 학년별 최저 점수 학생
mapping(mapper, downstream) 변환 후 수집 Map<K, List<R>> 학년별 학생 이름만 모으기
filtering(predicate, downstream) 필터링 후 수집 (Java 9+) Map<K, List<T>> 학년별 80점 이상 학생
flatMapping(mapper, downstream) 평탄화 후 수집 (Java 9+) Map<K, Set<R>> 학년별 모든 수강 과목
toList() List로 수집 Map<K, List<T>> 기본 그룹핑 (생략 가능)
toSet() Set으로 수집 Map<K, Set<T>> 중복 제거하여 그룹핑
summarizingInt() 통계 정보 Map<K, IntSummaryStatistics> 학년별 점수 통계 (개수/합계/평균/최소/최대)
joining(delimiter) 문자열 연결 Map<K, String> 학년별 학생 이름을 쉼표로 연결
collectingAndThen(downstream, finisher) 수집 후 변환 변환 결과에 따라 다름 Optional 제거, 불변 컬렉션 변환
groupingBy(classifier) 중첩 그룹핑 Map<K, Map<K2, List<T>>> 학년별 → 성별로 다시 그룹핑

partitioningBy - 참/거짓으로 나누기

partitioningBy는 분류 함수 대신 프레디케이트(predicate) 를 받아 키가 Boolean인 맵을 반환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 소수와 합성수로 분리
Map<Boolean, List<Integer>> partitioned = numbers.stream()
    .collect(partitioningBy(n -> isPrime(n)));
// {false=[4, 6, 8, 9, 10], true=[2, 3, 5, 7]}

// 나이로 분리
Map<Boolean, List<Person>> byAdult = people.stream()
    .collect(partitioningBy(p -> p.getAge() >= 18));

// 합격/불합격으로 나누고 각 그룹의 수 세기
Map<Boolean, Long> passFailCount = students.stream()
    .collect(partitioningBy(
        s -> s.getScore() >= 60,
        counting()
    ));
// {false=5, true=15}  // 5명 불합격, 15명 합격


joining - 문자열 연결

joining은 CharSequence 인스턴스의 스트림에만 적용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 단순 연결
String result = words.stream()
    .collect(joining());
// "applabananacherry"

// 구분자 지정
String result = words.stream()
    .collect(joining(", "));
// "apple, banana, cherry"

// 접두사, 구분자, 접미사 모두 지정
String result = words.stream()
    .collect(joining(", ", "[", "]"));
// "[apple, banana, cherry]"


실전 예제: 빈도표에서 상위 10개 추출

처음의 예제로 돌아가서, 빈도표에서 가장 흔한 단어 10개를 뽑아내는 완전한 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
// 파일에서 단어를 읽어 빈도표 작성
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
    freq = words
        .collect(groupingBy(String::toLowerCase, counting()));
}

// 빈도표에서 가장 흔한 단어 10개 추출
List<String> topTen = freq.keySet().stream()
    .sorted(comparing(freq::get).reversed())
    .limit(10)
    .collect(toList());

이 코드에서 comparing(freq::get).reversed()는 맵의 값(빈도)을 기준으로 역순 정렬하는 비교자다. comparing 메서드는 키 추출 함수를 받아 그 키를 기준으로 순서를 정하는 비교자를 반환한다.


Collectors의 다른 메서드들

Collectors에는 이 외에도 유용한 메서드들이 많다.

reducing

모든 수집기를 reducing으로 구현할 수 있다. 하지만 가독성과 성능 면에서 전용 수집기를 사용하는 것이 낫다.

1
2
3
4
5
6
7
8
// reduce로 합계 구하기 (권장하지 않음)
Integer sum = numbers.stream()
    .collect(reducing(0, (i, j) -> i + j));

// 전용 메서드 사용 (권장)
int sum = numbers.stream()
    .mapToInt(i -> i)
    .sum();

collectingAndThen

수집이 끝난 후 결과를 변환한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 가장 긴 단어 찾기 - Optional 제거
String longest = words.stream()
    .collect(collectingAndThen(
        maxBy(comparing(String::length)),
        optional -> optional.orElse("없음")
    ));

// 리스트를 불변으로 변환
List<String> immutableList = words.stream()
    .collect(collectingAndThen(
        toList(),
        Collections::unmodifiableList
    ));


마치며

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수 객체 에 있다. 스트림뿐 아니라 스트림 관련 객체에 건네지는 모든 함수 객체가 부작용이 없어야 한다.

종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용해야 한다. 계산 자체에는 이용하지 말자. 스트림을 올바로 사용하려면 Collector를 잘 알아둬야 한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

Collector를 사용하면 스트림의 원소들을 손쉽게 컬렉션으로 모을 수 있다. 39개의 메서드가 있지만, 자주 쓰이는 핵심 메서드들만 익혀도 충분히 강력한 스트림 코드를 작성할 수 있다. “무엇을”만 선언하고 “어떻게”는 라이브러리에 맡기는 선언형 프로그래밍 이 스트림의 본질이다.


References

  • 이펙티브 자바 3/E
This post is licensed under CC BY 4.0 by the author.