[C++ to Java] 10. Stream
Java Stream에 대해 알아보자. 2022-01-31

Java Stream

우리는 많은 수의 데이터를 다룰 때, 전에 배웠던 Collection 클래스들이나, Array를 이용 하여 데이터들을 저장 해 왔습니다. 이 중에, 필요한 데이터를 뽑기 위해서 for 문을 이용 하기도 했죠. 이렇게 데이터들을 다루다 보면, 가독성이 떨어 진다는 문제가 있었습니다. 또한, Array 에서 다루는 sort() 와, Collection 에서 다루는 sort()가 달랐기 때문에, 코드의 통일성 문제도 대두 되었습니다.

그렇기 때문에 우리는 Stream 을 사용하여 이러한 문제들을 해결 하고자 합니다. Stream 을 통해, 우리는 함수형 프로그래밍스러운(?) 방법으로 가독성 있는 코드를 작성 하며, 데이터를 처리할 수 있습니다.

참고로 여기서 나온 StreamInputStream 같은 I/O Stream은 엄연히 다른 내용을 다루고 있으니, 참고 해 주시길 바랍니다.

일단, Stream을 어떻게 만들 수 있을까요? Array 같은 경우, java.util.Arrays내에 있는 Arrays.stream을 이용하여, 해당 타입에 맞는 Stream 객체를 만들 수 있고, List 같은 클래스에서는 기본적으로 스트림으로 바꿀 수 있는 stream() 메서드를 가지고 있습니다.

우리는 stream()을 통해서, 정렬, 필터링, 각 요소들에 대한 함수 실행 등을 실행 할 수 있습니다. 밑에 있는 예시는 sorted()forEach()를 이용 하여, 요소 정렬 후 이를 출력하는 예제 입니다.

import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr);
        stringStream.sorted().forEach(System.out::println);

        System.out.println("=====================");

        ArrayList<String> strList = new ArrayList<>(Arrays.asList(strArr));
        Stream<String> stringStream2 = strList.stream();
        stringStream2.sorted().forEach(System.out::println);
    }
}  
Everyone
Hello
Meet
Nice
To
You
=====================
Everyone
Hello
Meet
Nice
To
You

:: 이 문법은 뭔가요?

함수형 인터페이스 구현시에 람다식의 입력 파라미터받는 함수의 입력 파라미터가 동일 할 시 이를 축약하여 쓸 수 있습니다. 아래 둘은 동일한 기능을 수행 합니다.

x -> System.out.println(x);
System.out::println;

스트림의 특징들

스트림은 다음과 같은 특징을 갖습니다.

스트림은 데이터 소스를 변경하지 않는다.

스트림은, 데이터 소스들로부터 데이터를 읽기만 하고, 데이터 소스를 변경 하지 않습니다. 정렬, 필터링 된 값을 추출 하기 위해선, 이를 컬렉션이나 배열에 담을 수 있습니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr);
        List<String> stringList = stringStream.sorted().collect(Collectors.toList());
        System.out.println(stringList);
    }
}
[Everyone, Hello, Meet, Nice, To, You]

스트림은 일회용이다.

스트림은 일회용입니다. Iterator처럼 한 번 읽으면, 재사용이 불가능 합니다.

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr);
        stringStream.sorted().forEach(System.out::println);
        int len = stringStream.count();  // 이미 스트림을 사용 했기 때문에, 사용 불가능 합니다.
    }
}

스트림은 작업을 내부 반복으로 처리 합니다.

가장 스트림에서 중요한 것은 내부 반복을 메서드 내부에 숨긴다는 것 입니다. 사실 이 두 코드는 같은 결과를 보여줍니다.

for (String str : strList)
    System.out.println(str);

stream.forEach(System.out::println);

forEach 안에는 어떤 객체가 들어 갈까요? 바로, Consumer<? super T>을 구현 한 객체를 넣으면 됩니다. 저번 시간에 배웠듯, T를 입력 받고, 안에서 관련 로직을 처리하는 코드를 작성하면 됩니다.

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr);
        stringStream.sorted().forEach((str) -> System.out.print(str + ' '));
    }
}
Everyone Hello Meet Nice To You 

스트림의 연산

스트림에서 제공하는 연산들은 마치 SQL의 SELECT문을 사용 하는 듯한 경험을 줍니다. 스트림이 제공하는 연산은 두 가지로 나뉩니다.

  • 중간 연산: 연산 결과가 스트림으로 반환 됩니다. 연속하여 중간 연산이 가능 합니다.
  • 최종 연산: 연산 결과가 스트림이 아님, List, Array등 다른 객체로 반환 됩니다. 단 한번만 호출 가능합니다.

대충 stringStream.sorted().forEach(System.out::println); 에서, 중간 연산sorted(), 최종 연산forEach()가 될 수 있겠죠.

참고로 알아 두셔야 할 것은, 최종 연산이 호출 되기 전까지는, 중간 연산은 수행 되지 않습니다. 이를 지연된 연산이라고 합니다.

또한, 우리는 parallel() 메서드를 통해서, 스트림의 연산을 병렬 연산으로 전환 할 수 있습니다. 밑의 예제를 보면, sorted()를 호출 했음에도 불구, 출력된 순서가 뒤죽박죽인것을 확인 할 수 있습니다.

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr).parallel();
        stringStream.sorted().forEach(System.out::println);
    }
}
Nice
You
Everyone
To
Meet
Hello

스트림 생성

스트림을 생성하는 방법은 배열, 컬렉션을 이용 하거나, 특정 범위의 수를 이용 할 수 있습니다. 또한, 기본형인 int, long, double 같은 경우, Stream<T>를 이용 하지 않고, IntStream, LongStream, DoubleStream 같은 자료형을 이용 할 수 있습니다. 이를 사용하는 이유는 intInteger로 바꾸는데 사용 되는 오버헤드를 줄이기 위함 입니다.

import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import java.util.stream.LongStream;

public class Main {
    public static void main(String[] args) {
        IntStream intStream = IntStream.of(1,2,3,4);
        LongStream longStream = LongStream.of(1,2,3,4);
        DoubleStream doubleStream = DoubleStream.of(0.1, 0.2, 0.3, 0.4);
    }
}

배열을 이용하는 방법

배열을 이용하는 방법은 다음과 같습니다. Arrays.stream(T[])를 이용 하거나, Stream.of(T[])를 이용 하는 방법 입니다.

import java.util.Arrays;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        String[] strArr = {"Hello", "Everyone", "Nice", "To", "Meet", "You"};
        Stream<String> stringStream = Arrays.stream(strArr);
        Stream<String> stringStream2 = Arrays.stream(strArr, 0, 3);  // 시작 위치와 종료 위치를 잘라서 저장.
        Stream<String> stringStream3 = Stream.of(strArr);
        Stream<String> stringStream4 = Stream.of("Hello", "Everyone", "Nice", "To", "Meet", "You");  // 가변 인자
    }
}

컬렉션을 이용하는 방법

컬렉션 내부에는 stream()이 내장 되어 있으므로, 이를 이용하면 간편 합니다.

import java.util.ArrayList;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        ArrayList<String> strings = new ArrayList<>();
        strings.add("Hello");
        strings.add("World");

        Stream<String> strStream = strings.stream();
        strStream.sorted().forEach(System.out::println);
    }
}
Hello
World

특정 범위의 수로 초기화 하는 법

IntStreamLongStreamrange(int start, int end)를 이용하여 생성 할 수 있습니다.

import java.util.stream.IntStream;

public class Main {
    public static void main(String[] args) {
        IntStream intStream = IntStream.range(1, 10);
        intStream.forEach(System.out::println);
    }
}
1
2
3
4
5
6
7
8
9

무한 스트림

우리는 스트림의 크기가 무한대인 스트림을 만들 수 있습니다. 이는 사용 후, limit(long maxSize)을 이용하여, 길이를 제한 하여야 합니다.

  • new Random().ints(): Integer.MIN_VALUE ~ Integer.MAX_VALUE 범위의 난수를 가진 무한 스트림을 생성 합니다.
  • new Random().longs(): Long.MIN_VALUE ~ Long.MAX_VALUE 범위의 난수를 가진 무한 스트림을 생성 합니다.
  • new Random().doubles: 0.0 ~ 1.0 범위의 난수를 가진 무한 스트림을 생성 합니다.
  • Stream.iterate(T seed, UnaryOperator<T> f): seed 부터 시작하여, 1씩 증가하는 값들에 f를 적용한 무한 스트림을 생성 합니다.
  • Stream.generate(Supplier<T> f): f의 반환 값을 적용한 무한 스트림을 생성 합니다.
import java.util.Random;
import java.util.stream.DoubleStream;
import java.util.stream.IntStream;
import java.util.stream.LongStream;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        IntStream intStream = new Random().ints();
        LongStream longStream = new Random().longs();
        DoubleStream doubleStream = new Random().doubles();
        Stream<Integer> stream = Stream.iterate(0, x -> x * x);
        Stream<String> stringStream = Stream.generate(() -> "Hello!");

        doubleStream.limit(5).forEach(System.out::println);
    }
}
0.7880266847563269
0.023701740822943962
0.7399524663809257
0.8363361415677636
0.7629063130532491

스트림의 중간 연산

스트림의 중간 연산 메서드는 다음과 같습니다.

  • skip(long n): 시작부터 n개 만큼을 제외 하고, 그 이후 부터를 반환 합니다.
  • limit(long maxSize): 시작부터 maxSize개 만큼을 반환 합니다.
  • filter(Predicate<? super T> predicate): 주어진 조건인 predicate에 맞지 않는 값을 제거 후 반환 합니다.
  • distinct(): 스트림에서 중복된 요소를 제거 합니다.
  • sorted(), sorted(Comparator<? super T> comparator): 데이터를 오름차순, 혹은 comparator를 이용한 정렬 기준으로 정렬 합니다.
  • map(Function<? super T, ? extends R> mapper): T 타입의 객체를 R 타입으로 반환 합니다.
  • peek(Consumer<T> action): 스트림 내의 요소를 사용하지 않으며 action 내의 로직을 수행 합니다.
  • mapToInt(), mapToLong(), mapToDouble(): map()과 같은 역할을 하지만, 각각 IntStream, LongStream, DoubleStream을 반환 합니다. (파라미터로 들어가는 함수의 리턴 타입도 맞춰야 합니다.)
import java.util.stream.IntStream;

public class Main {
    public static void main(String[] args) {
        int[] array = {1, 1, 3, 3, 5, 5, 4, 4, 2, 2};
        IntStream.of(array).forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        IntStream.of(array).skip(2).forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        IntStream.of(array).limit(5).forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        IntStream.of(array).filter(x -> x < 4).forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        IntStream.of(array).distinct().forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        IntStream.of(array).sorted().forEach(x -> {System.out.print(x + ", ");});
        System.out.println("");
        int sum = IntStream.of(array).map(x -> x * 2).peek(x -> {System.out.println(x + ", ");}).sum();
        System.out.println("count: " + sum);
    }
}
1, 1, 3, 3, 5, 5, 4, 4, 2, 2, 
3, 3, 5, 5, 4, 4, 2, 2, 
1, 1, 3, 3, 5, 
1, 1, 3, 3, 2, 2, 
1, 3, 5, 4, 2, 
1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 
2, 
2, 
6, 
6, 
10, 
10, 
8, 
8, 
4, 
4, 
count: 60

스트림의 결과 연산

스트림의 결과 연산 메서드는 다음과 같습니다.

  • forEach(Consumer<? super T> action): 각 요소들에 action을 수행 합니다.
  • allMatch(Predicate<T> p): 모든 요소가 p를 만족 하는지 여부를 반환 합니다.
  • anyMatch(Predicate<T> p): 한 요소가 p를 만족 하는지 여부를 반환 합니다.
  • noneMatch(Predicate<T> p): 모든 요소가 p를 만족하지 않는지 여부를 반환 합니다.
  • findAny(): 스트림 내 아무 요소를 하나 반환 합니다.
  • findFirst(): 스트림 내 첫번째 요소를 하나 반환 합니다.
  • sum(): IntStream 같은 기본형 스트림에서 사용 가능합니다. 스트림 내 요소들의 총합을 반환 합니다.
  • count(): 스트림 내 요소의 갯수를 반환 합니다.
  • max(): 스트림 내 요소의 최대값을 반환 합니다.
  • min(): 스트림 내 요소의 최솟값을 반환 합니다.
  • average(): IntStream 같은 기본형 스트림에서 사용 가능합니다. 스트림 내 요소들의 평균을 반환 합니다.
  • reduce(T identity, BinaryOperator<T> accumulator): identity 값 부터 시작하여, accumulator의 연산을 각 요소마다 수행하여 순서대로 저장 한 결과 값을 반환 합니다.
import java.util.stream.IntStream;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of("aaa", "ccc", "bbb", "ddd").forEach(System.out::print);
        System.out.println();
        System.out.println("길이가 모두 3인가요?: " + Stream.of("aaa", "ccc", "bbb", "ddd").allMatch((String x) -> x.length() == 3));
        System.out.println("길이가 하나라도 4인가요?: " + Stream.of("aaa", "ccc", "bbb", "ddd").anyMatch((String x) -> x.length() == 4));
        System.out.println("길이가 모두 2가 아닌가요?: " + Stream.of("aaa", "ccc", "bbb", "ddd").noneMatch((String x) -> x.length() == 2));
        System.out.println("baa보다 사전순으로 큰 값?: " + Stream.of("aaa", "ccc", "bbb", "ddd").filter((String x) -> x.compareTo("baa") == 1).findAny());
        System.out.println("baa보다 사전순으로 큰 첫번째 값?: " + Stream.of("aaa", "ccc", "bbb", "ddd").filter((String x) -> x.compareTo("baa") == 1).findFirst());
        System.out.println("스트림의 요소의 합: " + IntStream.of(1, 2, 3, 4).sum());
        System.out.println("스트림의 요소의 갯수: " + IntStream.of(1, 2, 3, 4).count());
        System.out.println("스트림의 요소의 평균: " + IntStream.of(1, 2, 3, 4).average());
        System.out.println("스트림의 요소의 최댓값: " + IntStream.of(1, 2, 3, 4).max());
        System.out.println("스트림의 요소의 최솟값: " + IntStream.of(1, 2, 3, 4).min());
        System.out.println("스트림의 요소를 모두 곱한 값: " + IntStream.of(1, 2, 3, 4).reduce(1, (a, b) -> a * b));
    }
}
길이가 모두 3인가요?: true
길이가 하나라도 4인가요?: false
길이가 모두 2가 아닌가요?: true
baa보다 사전순으로 큰 값?: Optional[ccc]
baa보다 사전순으로 큰 첫번째 값?: Optional[ccc]
스트림의 요소의 합: 10
스트림의 요소의 갯수: 4
스트림의 요소의 평균: OptionalDouble[2.5]
스트림의 요소의 최댓값: OptionalInt[4]
스트림의 요소의 최솟값: OptionalInt[1]
스트림의 요소를 모두 곱한 값: 24

collect(Collector<T, A, R> collector)

스트림의 최종 연산중 가장 복잡한 collect()는 따로 다루겠습니다. collect()는 가장 복잡하면서도 스트림 처리에 있어 유용하게 처리 할 수 있습니다.

  • collect(): 스트림의 최종연산으로, 매개변수로 컬렉터를 필요로 합니다.
  • Collector: 인터페이스로, 컬렉터는 이 인터페이스를 구현 해야 합니다.
  • Collectors: 클래스로, static 메서드로 미리 작성 된 컬렉터를 제공 합니다.

Stream을 컬렉션 혹은 배열로 변환 하기

스트림을 컬렉션, 혹은 배열로 변환 하는 방법은, collect() 내의 매개 변수로 toList(), toSet(), toMap(), toCollection(), toArray()를 넣는 것 입니다. 단, toMap()키-값 쌍이 필요 하므로, toMap의 첫 번째 파라미터로는 키로 변환하는 함수가, 두 번째 파라미터로는 값으로 변환하는 함수가 들어 가야 합니다. 또한, toCollection 같은 경우도, 변환 하고자 하는 Collection의 생성자가 들어 가야 합니다.

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        List<String> list = Stream.of("aaa", "ccc", "bbb", "ddd").collect(Collectors.toList());
        Set<String> set = Stream.of("aaa", "ccc", "bbb", "ddd").collect(Collectors.toSet());
        Map<Character, String> map = Stream.of("aaa", "ccc", "bbb", "ddd").collect(Collectors.toMap(x -> x.charAt(0), x -> x));
        ArrayList<String> arrayList = Stream.of("aaa", "ccc", "bbb", "ddd").collect(Collectors.toCollection(ArrayList::new));
        String[] strings = Stream.of("aaa", "ccc", "bbb", "ddd").toArray(String[]::new);
    }
}

통계, 리듀싱, 문자열 결합

사실 해당 연산들은 이미 다른 스트림 연산으로 해결 할 수 있습니다. 하지만, collect()의 사용에 익숙해 지기 위해 알고 가는것이 좋을 것 같아 넣게 되었습니다. 아래 메서드를 collect()의 파라미터로 삽입하면 됩니다.

  • counting(): 스트림내 객체의 수를 반환 합니다.
  • summingInt(ToIntFunction<T>): 스트림 내 객체에 파라미터로 들어간 함수를 적용한 결과 값의 합을 반환 합니다.
  • averagingInt(ToIntFunction<T>): 스트림 내 객체에 파라미터로 들어간 함수를 적용한 결과 값의 평균을 반환 합니다.
  • maxBy(Comparator<T>): 스트림 내 객체에 파라미터로 들어간 Comparator의 정렬 기준으로 가장 큰 값을 반환 합니다.
  • minBy(Comparator<T>): 스트림 내 객체에 파라미터로 들어간 Comparator의 정렬 기준으로 가장 작은 값을 반환 합니다.
  • reducing(T identity, BinaryOperator<T> accumulator): reduce()와 동일한 역할을 합니다.
  • joining(String delimiter): 스트림 내 객체를 delimiter를 구분자로 이용, 병합합니다.
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;


class Student {
    String name;
    boolean isMale;
    int id;

    Student(String name, boolean isMale, int id) {
        this.name = name;
        this.isMale = isMale;
        this.id = id;
    }

    String getName() {return name;}
    boolean getIsMale() {return isMale;}
    int getId() { return id;}
    
    public String toString() {
        return String.format("[%s, %s, %d]", name, isMale ? "남자": "여자", id);
    }
}

public class Main {
    public static void main(String[] args) {
        Student[] students = new Student[]{
                new Student("Park", true, 1),
                new Student("Kim", true, 2),
                new Student("Choi", false, 3),
                new Student("Lee", false, 4)
        };

        System.out.println("Students의 길이: " + Stream.of(students).collect(Collectors.counting()));
        System.out.println("Students의 아이디의 합: " + Stream.of(students).collect(Collectors.summingInt(Student::getId)));
        System.out.println("Students의 아이디의 평균: " + Stream.of(students).collect(Collectors.averagingInt(Student::getId)));
        System.out.println("Students의 이름의 최댓값: " + Stream.of(students).collect(Collectors.maxBy(Comparator.comparing(Student::getName))));
        System.out.println("Students의 이름의 최솟값: " + Stream.of(students).collect(Collectors.minBy(Comparator.comparing(Student::getName))));
        System.out.println("Students의 이름 + 아이디 병합: " + Stream.of(students).collect(Collectors.reducing("", (Student s) -> s.getId() + s.getName(), (a, b) -> a + b)));
        System.out.println("Students의 이름 병합: " + Stream.of(students).map(Student::getName).collect(Collectors.joining(",")));
    }
}
Students의 길이: 4
Students의 아이디의 합: 10
Students의 아이디의 평균: 2.5
Students의 이름의 최댓값: Optional[[Park, 남자, 1]]
Students의 이름의 최솟값: Optional[[Choi, 여자, 3]]
Students의 이름 + 아이디 병합: 1Park2Kim3Choi4Lee
Students의 이름 병합: Park,Kim,Choi,Lee

그룹화와 분할 - groupingBy(), partitioningBy()

우리는 스트림에 있는 값들을 그룹화 하여 확인 할 수 있습니다.

  • Collector groupingBy(Function classifier), Collector groupingBy(Function classifier, Collector collector): 각 객체에 classifier를 적용하여, 같은 값을 가지는 것들을 기준으로 나눕니다. 만약 collector가 있다면, 해당 collector 연산을 이어서 진행 합니다.
  • Collector partitioningBy(Predicate predicate): 각 객체에 predicate를 적용하여, 같은 값을 반환하는 것들을 기준으로 나눕니다. 만약 collector가 있다면, 해당 collector 연산을 이어서 진행 합니다.
  • Collector collectingAndThen(Collector collector, Function finisher): collector연산 후 finisher를 적용, 최종적으로 반환 할 객체에 대한 연산을 적용 합니다.
import java.util.*;
import static java.util.stream.Collectors.*;
import java.util.stream.Stream;


class Student {
    String name;
    boolean isMale;
    int id;

    Student(String name, boolean isMale, int id) {
        this.name = name;
        this.isMale = isMale;
        this.id = id;
    }

    String getName() {return name;}
    boolean getIsMale() {return isMale;}
    int getId() { return id;}

    public String toString() {
        return String.format("[%s, %s, %d]", name, isMale ? "남자": "여자", id);
    }
}

public class Main {
    public static void main(String[] args) {
        Student[] students = new Student[]{
                new Student("Park", true, 1),
                new Student("Kim", true, 2),
                new Student("Choi", false, 3),
                new Student("Lee", false, 4)
        };

        Map<Boolean, List<Student>> studentByGender = Stream.of(students).collect(partitioningBy(Student::getIsMale));
        Map<Boolean, Long> studentByGenderCount = Stream.of(students).collect(partitioningBy(Student::getIsMale, counting()));
        Map<Boolean, Student> studentByGenderTopId = Stream.of(students).collect(
                partitioningBy(
                        Student::getIsMale, collectingAndThen(
                                maxBy(Comparator.comparingInt(Student::getId)), Optional::get
                        )
                )
        );
        Map<Boolean, Map<Boolean, List<Student>>> studentByGenderAndId = Stream.of(students).collect(
                partitioningBy(
                        Student::getIsMale, partitioningBy(
                                s -> s.getId() % 2 == 0
                        )
                )
        );

        System.out.println(studentByGender);
        System.out.println(studentByGenderCount);
        System.out.println(studentByGenderTopId);
        System.out.println(studentByGenderAndId);

        Map<String, List<Student>> stuByIdIsOdd = Stream.of(students).collect(
                groupingBy(s -> {
                    if (s.getId() % 2 == 0) return "ODD";
                    else return "EVEN";
                })
        );
        Map<String, Map<Boolean, List<Student>>> stuByMaleOdd = Stream.of(students).collect(groupingBy(s -> {
                if (s.getId() % 2 == 0) return "ODD";
                else return "EVEN";
            }, groupingBy(Student::getIsMale))
        );
        System.out.println(stuByIdIsOdd);
        System.out.println(stuByMaleOdd);
    }
}
{false=[[Choi, 여자, 3], [Lee, 여자, 4]], true=[[Park, 남자, 1], [Kim, 남자, 2]]}
{false=2, true=2}
{false=[Lee, 여자, 4], true=[Kim, 남자, 2]}
{false={false=[[Choi, 여자, 3]], true=[[Lee, 여자, 4]]}, true={false=[[Park, 남자, 1]], true=[[Kim, 남자, 2]]}}
{EVEN=[[Park, 남자, 1], [Choi, 여자, 3]], ODD=[[Kim, 남자, 2], [Lee, 여자, 4]]}
{EVEN={false=[[Choi, 여자, 3]], true=[[Park, 남자, 1]]}, ODD={false=[[Lee, 여자, 4]], true=[[Kim, 남자, 2]]}}

마치며

이렇게 Java에 대해서 기본적으로 중요 하다고 생각 되는 것 위주로 달려 보았습니다. 사실 부족한 점이 많지만, 이렇게 따라 와주신 여러분들에게 진심으로 감사 드립니다!