Chap 4, 5, 6 - 스트림 소개, 활용, 데이터 수집

728x90

스터디를 통해서 '모던 자바'에 대한 공부를 했었다. 하지만 배울 때는 분명 다 내것이 되었다고 생각했었는데, 막상 기억에 남는게 별로 없는 것 같다. 역시 복습은 선택이아닌 필수가 아닌가 한다. (= 인간은 망각의 동물이다) 당장 내일 응시해야하는 코딩테스트가 하나 있어서, 급하게 스트림의 활용에 대한 내용을 간략하게 정리해보자 한다. 이론적인 부분은 시간을 잡아서 제대로 정리하려고 한다(스트림 뿐만 아니라 모던 자바 인 액션 책 전체적으로 정리를 하려고 한다). 


스트림(Stream)은 자바 8에서 추가된 기능이고, 스트림을 이용하면 선언형(SQL의 쿼리와 같이 질의로 표현할 수 있다)으로 컬렉션 데이터를 처리할 수 있다. 또한 스트림을 이용하면 멀티스레드 코드를 구현하지 않아도 데이터를 투명하게 병렬로 처리할 수 있다(단순하게 stream() --> parallelStream()으로 바꾸기만 하면 사용이 가능하다. 물론 고려해야 할 요소가 있다... 일단은 넘어가자) 

자바 8의 스트림 API의 특징을 다음처럼 요약할 수 있다.

  • 선언형 : 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음 : 유연성이 좋아진다.
  • 병렬화 : 성능이 좋아진다.

스트림에는 다음과 같은 두 가지 중요 특징이 있다. 

  • 파이프라이닝(pipelining) : 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림 자신을 반환한다. 그 덕분에 게으름(laziness), 쇼트서킷(short-circuiting) 같은 최적화도 얻을 수 있다. 연산 파이프라인은 데이터 소스에 적용하는 데이터베이스 질의와 비슷하다.
  • 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

 

스트림에 filter, map, limit, collect로 이어지는 일련의 데이터 처리 연산을 적용한다. collect를 제외한 모든 연산은 서로 파이프라인을 형성할 수 있도록 스트림을 반환한다. 파이프라인은 소스에 적용하는 질의 같은 존재다. 마지막으로 collect 연산으로 파이프라인을 처리해서 결과를 반환한다. 마지막에 collect를 호출하기 전까지는 무엇도 선택되지 않으며 출력 결과도 없다. 즉, collect가 호출되기 전까지 메서드 호출이 저장되는 효과가 있다.

filter, map, limit, collect는 각각 다음 작업을 수행한다.

  • filter : 람다를 인수로 받아 스트림에서 특정 요소를 제외시킨다. 
  • map : 람다를 이용해서 한 요소를 다른 요소로 변환하거나 정보를 추출한다. 
  • limit : 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 축소 truncate한다. 
  • collect : 스트림을 다른 형식으로 변환한다. (일단은 collect가 다양한 변환 방법을 인수로 받아 스트림에 누적된 요소를 특정 결과로 변환시키는 기능을 수행하는 정도...로 이해하자) 

복잡한 데이터 처리 질의를 표현하는 스트림 기능을 자세히 살펴본다. 필터링, 슬라이싱, 검색, 매칭, 매핑, 리듀싱 등의 많은 패턴을 다룰 것이다. 

스트림은 중간연산자와 최종연산자로 나뉘는데.. (오늘은 좀 급하니까 자세한 설명은 생략하겠다. 중간연산자는 말그대로 파이프 라인의 중간 연산자 (리턴 타입이 스트림), 최종연산자는 파이프라인의 마지막이다.)

중간 연산

연산 형식 반환 형식 연산의 인수 함수 디스크립터
filter 중간 연산 Stream<T> Predicate<T> T -> boolean
map 중간 연산 Stream<R> Function<T, R> T -> R
limit 중간 연산 Stream<T>    
sorted 중간 연산 Stream<T> Comparator<T> (T, T) -> int
distinct 중간 연산 Stream<T>    

 

최종 연산

연산 형식 반환 형식 목적
forEach 최종 연산 void 스트림의 각 요소를 소비하면서 람다를 적용한다.
count 최종 연산 long(generic) 스트림의 요소 개수를 반환한다.
collect 최종 연산   스트림을 리듀스해서 리스트, 맵 정수 형식의 컬렉션을 만든다.

 

예제 

필터링

1) 프리디케이트 필터링 방법

List<Dish> vegetarianMenu = menu.stream()
                                .filter(Dish::isVegitarian)
                                .collect(toList()); 

2) 고유 요소 필터링 방법 

List<Integer> numbers = Arrays.asList(1,2,1,3,3,2,4);
numbers.stream()
       .filter(i -> i % 2 == 0)
       .distinct()
       .forEach(System.out::println);
       

 

스트림 슬라이싱

1) 프레디케이트를 이용한 슬라이싱 
1-1) takeWhile 활용 

List<Dish> filterdMenu = specialMenu.stream()
                                    .takeWhile(dish -> dish.getCalories() < 320)
                                    .collect(toList());

(filterdMenu가 이미 칼로리의 오름차순으로 정렬되어 있다는 가정에서는 takeWhile을 이용하면 그냥 filter를 이용한 것보다 더 효율적인 처리가 가능하다) takeWhile : 프리디케이트가 거짓이라면 반복 작업을 중단

1-2) dropWhile 활용

List<Dish> slicedMenu2 = specialMenu.stream()
                                    .dropWhile(dish -> dish.getCalories() < 320)
                                    .collect(toList());

dropWhile은 takeWhile과 정 반대의 작업을 수행한다. dropWhile은 프레디케이트가 처음으로 거짓이 되는 지점까지 발견된 요소를 버린다. (프레디케이트가 거짓이 되면 그 지점에서 작업을 중단하고 남은 모든 요소를 반환단다) 

2) 스트림 축소 

2-1) limit 활용

스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환는 limit(n) 메서드를 지원한다. 스트림이 정렬되어 있으면 최대 n개를 반환할 수 있다. 

List<Dish> dishes = specialMenu.stream()
                               .filter(dish -> dish.getCalories() > 300)
                               .limit(3)
                               .collect(toList());
                               

소스가 정렬되어 있지 않았다면 limit의 결과도 정렬되지 않은 상태를 반환된다. 

2-2) skip 활용 

스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다. n개 이하의 요소를 포함하는 스트림에 skip(n)을 호출하면 빈 스트림이 반환된다. 

List<Dish> dishes = menu.stream()
                        .filter(d -> d.getCalories() > 300)
                        .skip(2)
                        .collect(toList());
                        

 

3) 매핑

3-1) 스트림의 각 요소에 함수 적용하기 

List<String> dishNames = menu.stream()
                             .map(Dish::getName)
                             .collect(toList());
List<Integer> wordLengths = words.stream()
                                 .map(String::length)
                                 .collect(toList());
                                 

 

3-2) 스트림 평면화

(이부분은 볼때마다 헷갈리는 부분이라서 조금 더 자세히 적는다)
메서드 map을 이용해서 리스트의 각 단어의 길이를 반환하는 방법을 확인했다. 이를 응용해서 리스트에서 고유 문자로 이루어진 리스트를 반환해보자. 예를 들어 ["Hello", "World"] 리스트가 있다면 결과로 ["H", "e", "l", "o", "W", "r", "d"]를 포함하는 리스트가 반환되어야 한다. 

아래와 같이 리스트에 있는 각 단어를 문자로 매핑한 다음에 distinct로 중복된 문자를 필터링해서 쉽게 문제를 해결할 수 있다고 생각할 수 있지만.... 

words.stream()
     .map(word -> word.split(""))
     .distinct()
     .collect(toList());

위 코드에서 map으로 전달한 람다의 각 단어의 String[](배열) 을 반환한다는 점이 문제다. 따라서 map 메서드가 반환한 스트림의 형식을 Stream<String[]> 이다. 우리가 원하는 것은 문자열의 스트림을 표현할 Stream<String> 이다. 다행히 flatMap이라는 메서드를 이용해서 이 문제를 해결할 수 있다.

words.stream()
     .map(word -> word.split(""))
     .map(Arrays::stream)
     .distinct()
     .collect(toList());

결국 스트림 리스트가 만들어지면서 문제가 해결되지 않았다. 문제를 해결하려면 먼저 각 단어를 개별 문자열로 이루어진 배열로 만든 다음에 각 배열을 별도의 스트림으로 만들어야 한다. 

flatMap 의 사용 

flatMap을 사용하면 다음처럼 문제를 해결할 수 있다. 

List<String> uniqueCharacters = words.stream()
                                     .map(word -> word.split("")
                                     .flatMap(Arrays::stream)
                                     .distinct()
                                     .collect(toList());

flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 매핑한다. 즉, map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다. (요약하자면.. flatMap 메서드는 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 나의 스트림으로 연결하는 기능을 수행한다) 

4. 검색과 매칭

4-1) 프레디케이트가 적어도 한 요소와 일치하는지 확인 

if(menu.stream().anyMatch(Dish::isVegetarian)) {
    ....
} 

anyMatch 메서드를 이용한다. anyMatch는 불리언을 반환하므로 최종 연산이다. 

4-2) 프레디케이트가 모든 요소와 일치하는지 검사

allMatch 메서드는 anyMatch와 달리 스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사한다. 

boolean isHealthy = menu.stream()
                        .allMatch(dish -> dish.getCalories() < 1000);

 

NONEMATCH 

noneMatch는 allMatch와 반대 연산을 수행한다. 즉, noneMatch는 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다. 예를 들어 이전 이전 예제를 다음처럼 noneMatch로 다시 구현할 수 있다. 

boolean isHealthy = menu.stream()
                        .noneMatch(d -> d.getCalories() >= 1000);

anyMatch, allMatch, noneMatch 세 메서드는 스트림 쇼트서킷 기법, 즉 자바의 &&, ||와 같은 연산을 활용한다. 

4-3) 요소 검색 

findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림연산과 연결해서 사용할 수 있다. 예를 들어 다음 코드처럼 filter와 findAny를 이용해서 채식요리를 선택할 수 있다. 

Optional<Dish> dish = menu.stream()
                          .filter(Dish::isVegetarian)
                          .findAny(); 

스트림 파이프라인은 내부적으로 단일 과정으로 실행할 수 있도록 최적화된다. 즉, 쇼트서킷을 이용해서 결과를 찾는 즉시 실행을 종료한다. (Optional 또한 중요한 클래스인데.. 실제로 모던 자바 인 액션에서도 한 비중을 차지하고 있다. 지금은 Stream의 정리가 급한 상황이니 따로 깊게 들어가지는 않겠다. null 처리를 위한 요소라고 생각하면 좋을 것 같다. 어떤 필드가 Optional 타입이라면 이 값은 존재하지 않을 수도 있다는 전제가 깔려있다고 생각해도 좋다.  어떤 의미로는 가독성도 늘어난다.(물론 코드를 보는 사람도 Optional에 대한 이해를 하고 있다는 가정에서 ) )

 

4-4) 첫 번째 요소 찾기 

리스트 또는 정렬된 연속 데이터로부터 생성된 스트림처럼 일부 스트림에는 논리적인 아이템 순서가 정해져 있을 수 있다. 이런 스트림에서 첫 번째 요소를 찾으려면 어떻게 해야 할까? 예를 들어 숫자 리스트에서 3으로 나누어떨어지는 첫 번째 제곱값을 반환하는 다음 코드를 살펴본다. 

List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5);
Optional<Integer> firstSquareDivisibleByThree = someNumbers.stream()
                                                           .map(n -> n % 3 == 0)
                                                           .filter(n -> n % 3 == 0)
                                                           .findFirst(); //9

 

더보기

findFirst와 findAny의 사용 시기? 

왜 비슷해보이는 두 메서드가 모두 존재할까?  바로 병렬성 때문이다. 병렬 실행에서는 첫 번째 요소를 찾기가 어렵다. 따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다. 

 

5) 리듀싱

지금까지 살펴본 최종 연산은 불리언, void, 또는 Optional 객체를 반환했다. 또한 collect로 모든 스트림의 요소를 리스트로 모으는 방법도 살펴봤다. 

리듀스(reduce) 연산을 이용해서 스트림 요소를 조합해서 더 복잡한 질의를 표현하는 방법을 설명한다. 

5-1) 요소의 합 

리스트의 숫자 요소를 더하는 코드를 확인한다. numbers의 각 요소는 결과에 반복적으로 더해진다. 리스트에서 하나의 숫자가 남을 때까지 reduce 과정을 반복한다. 코드에는 파라미터를 두 개 사용했다. 

  • sum 변수의 초깃값0
  • 리스트의 모든 요소를 조합하는 연산(+)

이런 상황에서 reduce를 이용하면 애플리케이션의 반복된 패턴을 추상화할 수 있다. reduce를 이용해서 다음처럼 스트림의 모든 요소를 더할 수 있다. 

int sum = numbers.stream().reduce(0, (a,b) -> a + b);

reduce는 두 개의 인수를 갖는다. 

1) 초깃값 0 , 2) 두 요소를 조합해서 새로운 값을 만드는 BinaryOperator<T>. 예제에서는 람다 표현식 (a,b) -> a + b를 사용했다. 

reduce로 다른 람다, 즉 (a,b) -> a * b를 넘겨주면 모든 요소에 곱셈을 적용할 수 있다. 

int product = numbers.stream().reduce(1, (a, b) -> a * b);

 

(초깃값을 받지 않도록 오버로드된 reduce도 있다. 그러나 이 reduce는 Optional 객체를 반환 받는다. ) 

5-2) 최댓값과 최솟값 

최댓값과 최솟값을 찾을 때도 reduce를 활용할 수 있다. reduce를 이용해서 스트림에서 최댓값과 최솟값을 찾는 방법을 살펴보자. reduce는 두 인수를 받는다. 

  • 초깃값
  • 스트림의 두 요소를 합쳐서 하나의 값으로 만드는 데 사용할 람다
Optional<Intger> max = numbers.stream().reduce(Integer::max);

Integer.max 대신 Integer.min 대신 람다 표현식 (x, y) -> x < y ? x : y를 사용해도 되지만, 메서드 참조 표현이 더 읽기 쉽다. 

 

5-3) 숫자형 스트림 

reduce 메서드로 스트림 요소의 합을 구하는 예제를 살펴봤다. 예를 들어 다음처럼 메뉴의 칼로리 합계를 계산할 수 있다. 

int calories = menu.stream()
                   .map(Dish::getCalories)
                   .reduce(0, Integer::sum);

 

사실 위 코드에는 박싱 비용이 숨어있다. 내부적으로 합계를 계산하기 전에 Integer를 기본형으로 언박싱해야 한다. 다음 코드처럼 직접 sum메서드를 호출할 수 있다면 더 좋지 않을까? 이런 취지에서 바로 최종 연산자를 sum() 으로 호출하면 좋을텐데.. 직접 sum 메서드를 호출할 수는 없다. map 메서드가 Stream<T>를 생성하기 때문이다. 스트림의 요소 형식은 Integer지만 인터페이스에는 sum 메서드가 없다. 이를 해결하기 위해서는 기본 특화 스트림을 사용해야 한다. 

5-3-1) 기본형 특화 스트림 

int calories = menu.stream()
                   .mapToInt(Dish::getCalories)
                   .sum();
                   

 

mapToInt 메서드는 각 요리에서 모든 칼로리(Integer 형식)를 추출한 다음에 IntStream을 반환한다. 따라서 IntStream 인터페이스에서 제공하는 sum 메서드를 이용해서 칼로리 합계를 계산할 수 있다. 스트림이 비어있으면 sum은 기본값 0을 반환한다. IntStream은 max, min, averager 등 다양한 유틸리티 메서드도 지원한다. 

객체 스트림으로 복원하기

숫자 스트림을 만든 다음에, 원상태인 특화되지 않은 스트림으로 복원할 수 있을까? 간단하게 말하자면 boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다. 

Stream<Integer> stream = menu.stream()
                          .mapToInt(Dish::getCalories)
                          .boxed();

 

숫자 범위 

자바 8의 IntStream과 LongStream에서는 range와 rangeClosed라는 두 가지 정적 메서드를 제공한다. 두 메서드 모두 첫 번째 인수로 시작값을, 두 번째 인수로 종료값을 갖는다. range 메서드는 시작값과 종료값이 결과에 포함되지 않는 반면 rangeClosed는 시작값과 종료값이 결과에 포함된다는 점이 다르다. 

IntStream evenNumbers = IntStream.rangeClosed(1, 100)
                                 .filter(n -> n % 2 == 0);

 

스트림 만들기

이 절에서는 일련의 값, 배열, 파일, 심지어 함수를 이용한 무한 스트림 만들기 등 다양한 방식으로 스트림을 만드는 방법을 설명한다. 

 

값으로 스트림 만들기

임의의 수를 인수로 받는 정적 메서드 Stream.of를 이용해서 스트림을 만들 수 있다. 예를 들어 다음 코드는 Stream.of로 문자열 스트림을 만드는 예제다. 스트림의 모든 문자열을 대문자로 변환한 후 문자열을 하나씩 출력한다. 

Stream<String> stream = Stream.of("Modern", "Java", "In", "Action");
stream.map(String::toUpperCase).forEach(System.out.println); 

 

null이 될 수 있는 객체로 스트림 만들기 

자바 9에서는 null이 될 수 있는 개체를 스트림으로 만들 수 있는 새로운 메서드가 추가되었다. 때로는 null이 될 수 있는 객체를 스트림으로 만들어야 할 수 있다. 예를 들어 System.getProperty는 제공된 키에 대응하는 속성이 없으면 null을 반환한다. 이런 메서드를 스트림에 활용하려면 다음처럼 null을 명시적으로 확인해야 했다. 
(이부분은 잘 이해가 안되는 것 같다...) 

 

배열로 스트림 만들기 

배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해서 스트림을 만들 수 있다. 예를 들어 다음처럼 기본형 int로 이루어진 배열을 IntStream으로 변환할 수 있다. 

int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum(); 

 

함수로 무한 스트림 만들기

스트림 API는 함수에서 스트림을 만들 수 있는 두 정적 메서드 Stream.iterate와 Stream.generate를 제공한다. 두 연산을 이용해서 무한 스트림, 즉 고정된 컬렉션에서 고정된 크기로 스트림을 만들었던 것과는 달리 크기가 고정되지 않은 스트림을 만들 수 있다. iterate와 generater에서 만든 스트림은 요청할 때마다 주어진 함수를 이용해서 값을 만든다. 따라서 무제한으로 값을 계산할 수 있다. 하지만 보통 무한한 값을 출력하지 않도록 limit(n) 함수를 연결해서 사용한다. 

iterater 메서드

Stream.iterate(0, n -> n + 2)
      .limit(10)
      .forEach(System.out::println);

iterate 메서드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다. iterate는 요청할 때마다 값을 생산할 수 있으며 끝이 없으므로 무한 스트림을 만든다. 

generate 메서드

Stream.generate(Match::random)
      .limit(5)
      .forEach(System.out::println);

iterate와 비슷하게 generate도 요구할 때 값을 계산하는 무한 스트림을 만들 수 있다. 하지만 iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다. generate는 Supplier<T>를 인수로 받아서 새로운 값을 생산한다. 

 


 

스트림으로 데이터 수집 

reduce가 그랬던 것처럼 collect 역시 다양한 요소 누적 방식을 인수로 받아서 스트림을 최종 결과로 도출하는 리듀싱 연산을 수행할 수 있음을 설명한다. 다양한 요소 누적 방식은 Collector 인터페이스에 정의되어 있다. (지금부터 컬렉션(Collection), 컬렉터(Collector), collect를 헷갈리지 않도록 주의하자) 

 

컬렉터란 무엇인가?

collect 메서드로 Collector 인터페이스 구현을 전달했다. Collector 인터페이스 구현은 스트림의 요소를 어떤 식으로 도출할지 지정한다. 이전에는 toList를 Collectior 인터페이스의 구현으로 사용했다. 여기서는 groupingBy를 이용해서 맵(Map)을 만든다. 

고급 리듀싱 기능을 수행하는 컬렉터

훌륭하게 설계된 함수형 API의 또 다른 장점으로 높은 수준의 조합성과 재사용성을 꼽을 수 있다. collect로 결과를 수집하는 과정을 간단하면서도 유연한 방식으로 정의할 수 있다는 점이 컬렉터의 최대 강점이다. 보통 함수를 요소로 변환할 때는 컬렉터를 적용하며 최종 결과를 저장하는 자료구조에 값을 누적한다. Collectors 유틸리티 클래스는 자주 사용하는 컬렉터 인스턴스를 손쉽게 생성할 수 있는 정적 팩토리 메서드를 제공한다. 

 

미리 정의된 컬렉터 

Collectors에서 제공하는 메서드의 기능은 크게 세 가지로 구분할 수 있다. 

  • 스트림 요소를 하나의 값으로 리듀하고 요약
  • 요소 그룹화
  • 요소 분할

먼저 리듀싱과 요약관련 기능을 하는 컬렉터부터 살펴본다. 

리듀싱과 요약 

Collector 팩토리 클래스로 만든 컬렉터 인스턴스로 어떤 일을 할 수 있는지 살펴보자

1) counting() 

long howManyDishes = menu.stream()
                         .collect(Collectors.counting());

다음처럼 불필요한 과정을 생략할 수도 있다. 

long howManyDishes = menu.stream().count(); 

counting 컬렉터는 다른 컬렉터와 함께 사용할 때 위력을 발휘한다. 

스트림값에서 최댓값과 최솟값 검색 

Collectors.maxBy, Collectors.minBy 두 개의 메서드를 이용해서 스트림의 최댓값과 최솟값을 계산할 수 있다. 

Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream()
                                     .collect(maxBy(dishCaloriesComparator));

 

요약연산

Collectors 클래스는 Collectors.summingInt라는 특별한 요약 팩토리 메서드를 제공한다. summingInt는 객체를 int로 매핑하는 함수를 인수로 받는다. summingInt의 인수로 전달된 함수는 객체를 int로 매핑한 컬렉터를 반환한다. 

int totalCalories = menu.stream()
                        .collect(summingInt(Dish::getCalories));

총합

double avgCalories = menu.stream()
                         .collect(averagingInt(Dish::getCalories));

평균

IntSummaryStatistics menuStatistics = menu.stream()
                                          .collect(summarizingInt(Dish::getCalories));

종합 --> IntSummaryStatistics 클래스로 모든 정보가 수집된다. menuStatistics 객체를 출력하면 다음과 같은 정보를 확인할 수 있다.
IntSummaryStatistics{count=9, sum=4300, min=120, average=477.7777778, max=800}  

마찬가지로 int뿐 아니라 long이나 double에 대응하는 summarizingLong, summarizingDouble 메서드와 관련된 LongSummaryStatistics, DoubleSummaryStatistics 클래스도 있다. 

 

문자열 연결 

컬렉터에 joining 팩토리 메서드를 이용하면 스트림의 각 객체에 toString 메서드를 호출해서 추출한 모든 문자열을 하나의 문자열로 연결해서 반환한다. 

String shoryMenu = menu.stream()
                       .map(Dish::getName)
                       .collect(joining());

 

String shortMenu = menu.stream()
                       .map(Dish::getName)
                       .collect(joining(", ")); 

(구분자 ","를 넣어서 가독성을 높인다) 

범용 리듀싱 요약 연산

지금까지 살펴본 모든 컬렉터는 reducing 팩토리 메서드로도 정의할 수 있다. 즉, 범용 Collectors.reducing으로도 구현할 수 있다. (그럼에도 범용 팩토리 메서드 대신 특화된 컬렉터를 사용한 이유는 프로그래밍적 편의성 때문이다) 

int totalCalories = menu.stream()
                        .collect(reducing(0, Dish::getCalories, (i,j) -> i + j));

reducing은 인수 세 개를 받는다. 

  • 첫 번째 인수는 리듀싱 연산의 시작값이거나 스트림에 인수가 없을 때는 반환값이다(숫자 합계에서는 인수가 없을 때 반환값으로 0이 적합하다).
  • 두 번째 인수는 변환 함수다.
  • 세 번째 인수는 같은 종류의 두 항목을 하나의 값으로 더하는 BinaryOperator다.

다음처럼 한 개의 인수를 가진 reducing 버전을 이용해서 가장 칼로리가 높은 요리를 찾는 방법도 있다. 

Optional<Dish> mostCalorieDish = menu.stream()
                                     .collect(reducing((d1, d2)
                                     -> d1.getCalories() > d2.getCalories() ? d1 : d2);

 

컬렉션 프레임워크 유연성 : 같은 연산도 다양한 방식으로 수행할 수 있다.

reducing 컬렉터를 사용한 이전 예제에서 람다 표현식 대신 Integer 클래스의 sum 메서드 참조를 이용하면 코드를 좀 더 단순화할 수 있다. 

int totalCalories = menu.stream()
                        .collect(reducing(0, Dish::getCalories, Integer::sum));

 

그룹화 

데이터 집합을 하나 이상의 특성으로 분류해서 그룹화하는 연산도 데이터베이스에서 많이 수행되는 작업이다. 다음처럼 팩토리 메서드 Collectors.groupingBy를 이용해서 쉽게 메뉴를 그룹화할 수 있다. 

Map<Dish.Type, List<Dish>> dishesByType = menu.stream()
                                              .collect(groupingBy(Dish::getType));

이 함수를 기준으로 스트림이 그룹화되므로 이를 분류함수라고 부른다. 

단순한 속성 접근자 대신 더 복잡한 분류 기준이 필요한 상황에서는 메서드 참조를 분류 함수로 사용할 수 없다. 따라서 다음 예제처럼 람다 표현식으로 필요한 로직을 구현할 수 있다. 

Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream()
                                                         .collect(groupingBy(dish -> {
                                                             if (dish.getCalories() <= 400) return CaloricLevel.DIET;
                                                             else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
                                                             else return CaloricLevel.FAT;
                                                         }));

 

그룹화된 요소 조작 

요소가 없어도 키 값은 남아있게 하려면 아래의 두 예제를 잘 살펴보자 

Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
                                                     .filter(dish -> dish.getCalories() > 500)
                                                     .collect(groupingBy(Dish::getType));

 

Map<Dish.Type, List<Dish>> caloricDishesByType = menu.stream()
                                                     .collect(groupingBy(Dish::getType,
                                                         filtering(dish -> dish.getCalories() > 500, toList())));

 

위의 요소는 필터에 해당하는 값이 하나도 없는 key경우 아예 Map 컬렉션에 저장하지 않는다. 하지만 groupingBy 팩토리 메서드를 오버로드해 이 문제를 해결할 수 있다. 두 번째 Collector 안으로 필터 프리디케이트를 이동함으로써 요소가 하나도 없더라도 key 값이 Map 컬렉션에 저장된다. 

또한 그룹화된 항목을 조작하는 다른 유용한 기능 중 또 다른 하나로 맵핑 함수를 이용해 요소를 변환하는 작업이 있다. filtering 컬렉터와 같은 이유로 Collectors 클래스는 매핑 함수와 각 항목에 적용한 함수를 모으는 데 사용하는 또 다른 컬렉터를 인수로 받는 mapping 메서드를 제공한다. (예를 들어 이 함수를 이용해 그룹의 각 요리를 관련 이름 목록으로 변환할 수 있다) 

Map<Dish.Type, List<String>> dishNamesByType = menu.stream()
                                                   .collect(groupingBy(Dish::getType, mapping(Dish::getName, toList())));

flatMapping 또한 가능하지만... 생략하겠다. 

이외에 서브 그룹으로 나누는 내용도 있고 하지만... 코딩 테스트에서 그정도의 문제를 풀 일은 없을 것 같아 이정도로만 정리한다. 어짜피 모던 자바 인 액션 책은 1장부터 (1,2장은 생략하지 않을까?) 정리할 예정이다.(시간이 주어진다면..) 

 

728x90