자바 스트림 정리 - 3. 결과 구하기

원하는 형태로 가공한 스트림에서 결과를 구하는 연산의 종류에는 무엇이 있을까?

#java #stream


목차


스트림 종료 연산

이제 마지막으로 가공한 스트림을 결과로 만들어내는 단말 연산(Terminal Operations)입니다. 다양한 형태로 결과 값을 구할 수 있습니다. 어떤 연산이 있는지 알아봅시다.


순회(iterate)

forEach 메서드를 사용하여 스트림을 순회할 수 있습니다.

List<Integer> list = List.of(3, 2, 1, 5, 7);
list.stream().forEach(System.out::println);

다만 forEach 메서드는 병렬 스트림을 사용했을 때 순서를 보장할 수 없습니다. 따라서 스트림을 순서대로 순회하고 싶은 경우 forEachOrdered 메서드를 사용해야 합니다.

List<Integer> list = List.of(3, 1, 2);

// 매 실행마다 출력 결과가 동일하지 않다.
list.parallelStream().forEach(System.out::println);

// 매 실행마다 동일한 출력 결과
list.parallelStream().forEachOrdered(System.out::println);


결과 합치기(reduce)

reduce 연산을 이용해 모든 스트림 요소를 처리하여 결과를 구할 수 있습니다. 이 메서드는 아래와 같이 세 가지 형태로 오버로딩(overloading)되어 있습니다.

// 형태1
Optional<T> reduce(BinaryOperator<T> accumulator); 

// 형태2
T reduce(T identity, BinaryOperator<T> accumulator);

//형태3
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator,
            BinaryOperator<U> combiner);

먼저 인자가 하나만 있는 형태입니다. 인자로는 BinaryOperator를 사용하는데 이는 두 개의 같은 타입 요소를 인자로 받아 동일한 타입의 결과를 반환하는 함수형 인터페이스를 사용합니다.

List<Integer> list = List.of(1, 2, 3);
Optional<Integer> result = list.stream().reduce((a, b) -> a + b); // 6
// list.stream().reduce(Integer::sum);

다음으로 두 개의 인자를 받는 형태는 항등값과 BinaryOperator를 받습니다. 아래와 같이 초기값을 줄 수도 있습니다.

List<Integer> list = List.of(1, 2, 3);
Integer result = list.stream().reduce(1, Integer::sum);

// 7
System.out.println(result);

마지막으로 세 개의 인자를 받는 형태입니다. 항등값, BiFunction, BinaryOperator를 받습니다. 값을 누적하는 연산의 경우 병렬 연산의 결과를 결합해야 하는데, 여기서 세 번째 인자가 그 역할을 합니다. 그러니까 병렬 처리를 하는 경우에 각자 다른 스레드의 결과를 합쳐줍니다.

List<Integer> list = List.of(3, 7, 9);
Integer result = list.parallelStream()
        .reduce(1, Integer::sum, (a, b) -> {
            System.out.println("in combiner");
            return a + b;
        });

System.out.println(result);
// 출력 결과
// in combiner a:8 b:10
// in combiner a:4 b:18
// 22

일반 스트림에서는 combiner가 수행되지 않으므로 결과값도 다릅니다. 즉 병렬 스트림에서만 동작합니다. 초기값 1에 스트림의 요소 값을 더한 값을 계산합니다. (1+3=4, 1+9=10, 1+7=8) 그리고 다음 과정에서 combiner 연산에서는 여러 스레드에서 나누어 연산한 값을 합칩니다. (8+10=18, 4+18=22)


계산하기: 최솟값, 총합, 평균 등

스트림 API에서 값을 구하는 연산을 이용하면 간단하게 최솟값 또는 최댓값을 구할 수 있습니다.

// Optional을 리턴한다.
OptionalDouble min = DoubleStream.of(4.1, 3.4, -1.3, 3.9, -5.7).min();
min.ifPresent(System.out::println);

// 5
int max = IntStream.of(2, 4, 5, 3).max().getAsInt();

요소의 개수를 구할 수 있습니다.

// 결과 4
long count = IntStream.of(2, 4, 1, 3).count()

요소의 총합을 구하거나 평균을 구할 수 있습니다. 다만 기본형에 특화된 IntStream, LongStream, DoubleStream 에만 기본적으로 메서드가 제공됩니다.

// 결과 7.1
double sum = DoubleStream.of(3.1, 2.6, 1.4).sum();

// // Optional을 반환한다.
OptionalDouble average = IntStream.of(3, 2, 1).average();

// 결과 2.0
average.ifPresent(System.out::println);


결과 모으기(Collect)

스트림을 List, Set 그리고 Map과 같은 다른 형태의 결과로 변환해줍니다. 아래와 같은 클래스가 있다고 가정하고 여러 가지 collect 연산을 진행해봅시다.

class Food {
    public Food(String name, int cal) {
        this.name = name;
        this.cal = cal;
    }

    private String name;
    private int cal;
    
    @Override
    public String toString() {
        return String.format("name: %s, cal: %s", name, cal);
    }

    // getter, setter 생략
}

List<Food> list = new ArrayList<>();
list.add(new Food("burger", 520));
list.add(new Food("chips", 230));
list.add(new Food("coke", 143));
list.add(new Food("soda", 143));


  • Collectors.toList: 작업 결과를 리스트로 반환
List<String> nameList = list.stream()
        .map(Food::getName) // name 얻기
        .collect(Collectors.toList()); // list로 수집


  • 숫자값의 합, 평균 등 구하기

스트림 내 요소들의 합, 평균 등을 구할 수 있습니다.

// name 길이의 합 구하기
Integer summingName = list.stream()
        .collect(Collectors.summingInt(s -> s.getName().length()));
    
// mapToInt 메서드로 칼로리(cal) 합 구하기
int sum = list.stream().mapToInt(Food::getCal).sum();

// 평균 구하기: averagingInt
Double averageInt = list.stream()
        .collect(Collectors.averagingInt(Food::getCal));

// 평균 구하기: averagingDouble
Double averageDouble = list.stream()
        .collect(Collectors.averagingDouble(Food::getCal));

위에서 살펴본 값들은 summarizingInt와 같은 통계를 얻을 수 있는 메서드를 이용하면 한번에 그 정보를 담을 수 있습니다.

IntSummaryStatistics summaryStatistics = list.stream()
        .collect(Collectors.summarizingInt(Food::getCal));

summaryStatistics.getAverage(); // 평균
summaryStatistics.getCount(); // 개수
summaryStatistics.getMax(); // 최댓값
summaryStatistics.getMin(); // 최솟값
summaryStatistics.getSum(); // 합계


  • 스트림 연산 결과를 하나의 문자열로 만들기

스트림의 연산 결과를 하나의 문자열로 합칠 수 있습니다. 3개의 오버로딩된 메서드를 제공하며 아래와 같이 여러 가지 방법으로 사용할 수 있습니다.

// without arguments
String defaultJoining = list.stream()
        .map(Food::getName).collect(Collectors.joining());

// burgerchipscokesoda
System.out.println(defaultJoining);

구분자(delimiter)를 인자로 받아서 처리할 수 있습니다. 이어지는 문자열 사이에 위치하게 됩니다.

// delimiter
String delimiterJoining = list.stream()
        .map(Food::getName).collect(Collectors.joining(","));

// burger,chips,coke,soda
System.out.println(delimiterJoining);

구분자와 prefix, suffix를 같이 사용할 수 있습니다. 결과의 맨 앞과 맨 뒤에 붙일 문자를 지정합니다.

// delimiter, prefix, suffix
String combineJoining = list.stream()
        .map(Food::getName).collect(Collectors.joining(",", "[", "]"));

// [burger,chips,coke,soda]
System.out.println(combineJoining);


  • 특정 조건으로 그룹 짓기

스트림 내의 요소들을 주어진 조건에 맞추어 그룹핑(Grouping)할 수 있습니다.

// 칼로리(cal)로 그룹 만들기
Map<Integer, List<Food>> calMap = list.stream()
        .collect(Collectors.groupingBy(Food::getCal));

// { 230=[name: chips, cal: 230],
//   520=[name: burger, cal: 520],
//   143=[name: coke, cal: 143, name: soda, cal: 143]}
System.out.println(calMap);


  • 참, 거짓으로 그룹 짓기

partitioningBy는 인자로 Predicate 함수형 인터페이스를 받습니다. Predicate는 인자를 받아서 참 또는 거짓을 반환하기 때문에 boolean 값으로 그룹핑합니다.

// 200 칼로리가 넘는 지로 구분
Map<Boolean, List<Food>> partitionMap = list.stream()
        .collect(Collectors.partitioningBy(o -> o.getCal() > 200));

// { false=[name: coke, cal: 143, name: soda, cal: 143],
//   true=[name: burger, cal: 520, name: chips, cal: 230]}
System.out.println(partitionMap);


  • Map으로 결과 모으기

음식의 칼로리(cal)를 key, 이름을 value 값으로 맵을 생성해봅시다. 아래와 같이 Collectors.toMap 메서드를 사용해서 쉽게 구현할 수 있습니다.

// Exception 발생!
Map<Integer, String> map = list.stream()
        .collect(Collectors.toMap(
                o -> o.getCal(),
                o -> o.getName()
        ));
System.out.println(map);

다만 위 메서드를 수행하면 아래와 같은 오류를 볼 수 있습니다. java.lang.IllegalStateException: Duplicate key 143 (attempted merging values coke and soda)

키에 값이 2개 이상 존재하게 되는 경우 컬렉터는 IllegalStateException을 던집니다. 따라서 키가 중복되는 예외 상황을 해결하기 위해 BinaryOperator 인자를 추가할 수 있습니다.

// 동일한 키가 있는 경우 새 값으로 대체한다.
Map<Integer, String> map = list.stream()
        .collect(Collectors.toMap(
                o -> o.getCal(),
                o -> o.getName(),
                (oldValue, newValue) -> newValue));

// {230=chips, 520=burger, 143=soda}
System.out.println(map);


  • collect 후에 연산 추가하기

collectingAndThen 메서드를 이용하면 특정 타입의 형태로 수집(collect)한 이후에 추가 연산을 진행할 수 있습니다.

// 칼로리(cal)가 가장 높은 객체 반환
Food food = list.stream()
        .collect(Collectors.collectingAndThen(
                Collectors.maxBy(Comparator.comparing(Food::getCal)),
                (Optional<Food> o) -> o.orElse(null)));

// name: burger, cal: 520
System.out.println(food);


  • 직접 Collector를 만들기

그 밖의 로직을 위해서 직접 Collector를 만들어서 사용할 수 있습니다.

// 직접 collector 생
Collector<Food, StringJoiner, String> foodNameCollector = Collector.of(
        () -> new StringJoiner(" | "), // supplier: collector 생성
        (a, b) -> a.add(b.getName()), // accumulator: 두 값을 가지고 계산
        (a, b) -> a.merge(b), // combiner: 계산 결과 수집(합치기)
        StringJoiner::toString); // finisher
        
//만들 컬렉터를 스트림에 적용
String foodNames = list.stream().collect(foodNameCollector);

// burger | chips | coke | soda
System.out.println(foodNames);


조건 체크(Matching)

Predicate 조건식을 인자로 받아서 해당 조건을 만족하는 요소가 있는지 체크할 수 있습니다.

  • 하나라도 만족하는가? (anyMatch)
// 300 칼로리가 넘는 것이 하나라도 있는가?
boolean anyMatch = list.stream()
        .anyMatch(food -> food.getCal() > 300);
  • 모두 조건을 만족하는가? (allMatch)
// 모두 100 칼로리가 넘는가?
boolean allMatch = list.stream()
        .allMatch(food -> food.getCal() > 100);
  • 모두 조건을 만족하지 않는가? (noneMatch)
// 모두 1000 칼로리가 넘지 않는가?
boolean noneMatch = list.stream()
        .noneMatch(food -> food.getCal() < 1000);


이어서

여러 가지 연산을 적용한 스트림으로부터 원하는 결과를 얻는 방법을 알아보았습니다. 이어지는 포스팅에서는 스트림 API를 사용하는 여러가지 예제에 대해서 알아봅니다.