반응형

개요

 스트림을 처음 접하면 이해하기 어렵고, 스트림으로 뭔가를 구현하더라도 이로인한 이점을 파악하기 어렵다. 스트림은 함수형 프로그래밍에 기초한 패러다임이기 때문이다. 스트림의 이점을 이해하고 사용하려면 이 패러다임까지 함께 이해해야한다.

 


함수형 프로그래밍이란?

함수형 프로그래밍(functional programming)은 자료 처리를 함수의 계산으로 취급하고
상태와 가변 데이터를 멀리하는 프로그래밍 패러다임

 

데이터 처리를 원시적인 계산 코드가 아닌 정형화된 함수로 처리하는 것이다. 사용하는 함수는 상태 값이나 가변 데이터를 멀리하도록 구현해야하는데 이러한 함수를 '순수 함수'라고 한다.

 

입력만이 결과에 영향을 주는 함수. 즉, 다른 가변 상태를 참조하지 않고, 함수 스스로도 다른 상태를 변경하지 않는 함수를 순수 함수라 한다. - 이펙티브 자바 中

 

 

순수 함수는 가변 및 상태 값을 참조하지 않기에 입력 값에 의해 결과 값이 정해지는 특성을 갖는다. 만약 상태 값을 참조하게 된다면 입력 값에 의해 결과 값이 달라지는 '부작용'이 발생할 수 있다. 결국 이번 주제인 '부작용 없는 함수''순수 함수'를 말한다.

 

순수 함수는 개발자가 커스텀하여 만들 수도 있지만, 스트림 API에서 제공하는 함수를 사용하는 것도 좋은 방법이다. 스트림에서 제공하는 공식적인 함수이므로 부작용이 없고, 성능 최적화가 되어있으며, 40가지 이상의 다양한 함수를 제공하기 때문이다.

 

먼저 스트림 API를 사용했지만, 저자는 스트림 코드라고 인정하지 않는 코드를 살펴보자.


단어 빈도표 예제

 

다음은 텍스트 파일에서 단어별 수를 세어 빈도표로 만드는 코드이다. 스트림, 람다, 메서드 참조를 사용했고, 결과도 올바르지만 이를 스트림 코드라 하지 않는다. 스트림을 잘못 사용했고, 사용하지 않았을 때보다 가독성이 떨어지기 때문이다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

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

 

 

문제는 외부 상태인 빈도 수(freq)를 수정하는 람다 부분이다. 이 코드의 모든 데이터 처리 작업이 최종 연산(종단 연산)인 forEach 구문에서 일어나고 있는데, forEach는 스트림 계산 결과를 보고할 때만 사용하는 것이 권장된다. 연산 결과를 보여주는 일 이상을 하니 좋은 코드라 할 수 없다.

 


스트림의 최종 연산이 뭔가요?

 

최종 연산 (종단 연산)
스트림의 요소를 '소비' 해가며 결과를 만들어내는 연산이다. 소비를 하기 때문에 최종 연산 후 스트림은 재사용할 수 없는 빈 상태가 된다.

 

더 이상 추가적인 연산을 할 수 없는 상태이기에 최종 연산이라고 한다. 최종 연산에 사용되는 대표 함수를 몇개 기재한다.

함수 내용
void forEach() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 X)
void forEachOrdered() 요소를 하나씩 소비해가며 지정된 작업 수행(병렬 스트림에서 순서 보장 O)
long count() 요소 개수 반환
Optional max() 요소 중 최대 값을 참조하는 Optional 반환
Optional min() 요소 중 최소 값을 참조하는 Optional 반환
Optional findFirst() 첫번째 요소를 참조하는 Optional 반환
Optional findAny() 첫번째 요소를 참조하는 Optional 반환 (병렬 스트림에서는 첫번째 요소 보장 X)
boolean allMatch() 모든 요소가 특정 조건을 만족하는지 여부를 반환
boolean anyMatch() 하나의 요소라도 특정 조건을 만족하는지 여부를 반환
boolean noneMatch() 모든 요소가 특정 조건을 불만족 하는지에 대한 여부를 반환
reduce() 요소를 하나씩 빼며 지정된 연산 처리 후 결과를 반환
collect() 요소들을 컬렉션으로 반환

 

 


 

최종 연산이 나와서 말인데... 중간 연산은 뭔가요?

 

중간 연산
스트림 생성부터 시작해서 최종 연산 직전까지의 연산이다. 최종 연산과 달리 체이닝 메서드 방식으로 여러 개의 중간 연산이 수행될 수 있다.

 

함수 내용
Stream<T> distinct() 요소의 중복 제거
Stream<T> filter() 요소에 대한 필터링 조건 추가
Stream<T> limit() 요소 개수 제한
Stream<T> skip() 처음 n개의 요소 건너뛰기 
Stream<T> sorted() 요소 정렬
Stream<T> peek() 요소에 대한 작업 수행, forEach와는 다르게 요소를 소비하지 않음

 


다시 단어 빈도표 예제로

다시 돌아가서 위 코드를 올바른 스트림 코드로 작성한다면 아래와 같다.

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

try(Stream<String> words = new Scanner(file).tokens()) {
    freq = words.collect(groupingBy(String::toLowerCase, counting()));
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

 이 코드는 collector를 사용하는데 collector는 스트림을 사용하려면 꼭 배워야하는 개념이다. 참고로 collector는 java.util.stream.Collectors 클래스를 말하며 groupingBy는 Collectors 클래스가 지원하는 static 메서드이다. 앞서 언급했던 '부작용' 없는 함수 중 하나인 것이다. groupingBy 메서드는 요소들을 그룹핑하여 Map 타입으로 반환한다. counting 메서드는 동일한 단어의 수를 반환하는데, 최종적으로 key는 소문자 단어, value는 단어의 수를 가진 Map이 생성된다.

 

collector는 이러한 메서드를 40개 이상 지원한다. 복잡한 세부 사항을 잘 몰라도 사용 가능하다.

이러한 메서드를 활용해 빈도표에서 가장 흔한 단어 10개를 뽑아내는 스트림 파이프라인을 작성해보자.

 

File file = new File("C:\\Users\\sim\\effectiveJava\\effectivaJava\\src\\main\\java\\org\\ssk\\item46\\usecase1\\myFile.txt");

Map<String, Long> freq = new HashMap<>();

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

    List<String> list = freq.keySet().stream()
            .sorted(comparing(freq::get).reversed())
            .limit(10)
            .collect(Collectors.toList());

    list.forEach(System.out::println);
} catch (FileNotFoundException e) {
    throw new RuntimeException(e);
}

 

comparing 메서드는 비교할 키를 받아 비교자를 생성하는 메서드이며, freq의 value 값을 비교 키로 하기 위해 freq::get 메서드를 파라미터로 전달하고 있다. 그 후 내림차순 정렬을 위해 reversed() 메서드를 호출한다.

 


toMap 사용해보기

collector에서 제공하는 메서드 중 toMap(keyMapper, valueMapper)은 스트림으로 가장 간단하게 맵을 만들 수 있는 메서드이다. 사용법도 쉬운게 키에 매핑하는 함수, 값에 매핑하는 함수를 인수로 넘기면 된다.

List<String> values = List.of("one","two","three","four");
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

키 함수는 s -> s 로 List의 원소 값이 그대로 들어가고, 벨류 함수는 String::length 로 원소의 길이가 들어가도록 하였다. 하지만 만약 키가 중복될 경우 파이프라인에서 예외가 발생한다.

List<String> values = List.of("one","two","three","four","one");// 키 중복
Map<String, Integer> map = values.stream().collect(Collectors.toMap(s -> s, String::length));

for(Map.Entry<String, Integer> entry : map.entrySet()){ // IllegalStateException 발생 !
    System.out.println(entry.getKey());
    System.out.println(entry.getValue());
}

 

이러한 충돌을 다루는 전략으로 머지 함수를 제공한다. 함수의 형태는 BinaryOperator<U> 이며, 여기서 U는 해당 맵의 값 타입이다. 같은 키를 공유하는 값들은 이 병합 함수를 사용해 기존 값에 합쳐진다. 예컨데 병합 함수가 새로운 값으로 대체하는 함수라면 기존 값 대신 새로운 값으로 대체되도록 하려면 아래와 같이 작성할 수 있다.

Map<String, Integer> map = values.stream()
	.collect(Collectors.toMap(s -> s, String::length, (oldVal, newVal) -> newVal));

 

groupingBy는 입력으로 분류 함수(classifier)를 받고 출력으로 원소들을 카테고리별로 모아 놓은 맵을 담은 collector를 반환한다. 분류 함수는 입력 받은 원소가 속하는 카테고리를 반환한다. 그리고 이 카테고리가 해당 원소의 맵 키로 쓰이며, 해당 카테고리가 속하는 원소들을 담은 리스트는 값으로 쓰이게 된다.

List<String> values = List.of("one","two","three","four","five","six","seven");

Map<Integer,List<String>> map = values.stream().collect(groupingBy(s -> s.length()));

for(Map.Entry<Integer, List<String>> entry : map.entrySet()){
    System.out.println(entry.getKey());

    for(String str : entry.getValue()){
        System.out.println(str);
    }
}

 


정리

스트림 파이프라인 프로그래밍의 핵심은 부작용 없는 함수에 있다. 이러한 함수는 직접 만들어도 되지만 스트림에서 제공하는 함수가 매우 다양하기 때문에 이를 사용하는 것도 좋은 방법이다.

 종단 연산 중 forEach는 스트림이 수행한 계산 결과를 보고할 때만 이용하고 계산 자체에는 이용하지 말자.

 마지막으로 스트림을 잘 사용하려면 collector를 잘 알아둬야한다. 가장 중요한 수집기 팩터리는 toList, toSet, toMap, groupingBy, joining이다.

반응형

+ Recent posts