Stream의 종류
java.util.stream 패키지에는 다양한 스트림 API들이 존재한다. 패키지 내용을 보면 BaseStream 인터페이스를 부모로 해서 자식 인터페이스들이 상속 관계를 이루고 있다.
BaseStream 인터페이스에는 모든 스트림에서 사용할 수 있는 추상 메소드들이 존재할 뿐 코드에서 직접적으로 사용하지는 않는다. 코드에 직접적으로 사용하는 것은 자식 인터페이스로는, Stream, IntStream, LongStream, DoubleStream이 있다. 그리고 이 중에서 Stream만 객체 요소를 처리한다.
Stream : 객체 요소를 처리하는 스트림
IntStream, LongStream, DoubleStream : 각자 기본 타입 요소를 처리하는 스트림
Stream 생성하기
앞서 설명한대로 Stream API를 사용하기 위해서는 먼저 Stream을 생성해주어야 한다. 타입에 따라 Stream을 생성하는 방법이 다른데, 이제 다양한 Stream 생성 방식을 알아보자.
Collection의 Stream 생성
Collection 인터페이스에는 stream()이 정의되어 있기 때문에, Collection 인터페이스를 구현한 객체들(List, Set 등)은 모두 이 메소드를 이용해 Stream을 생성할 수 있다. stream()을 사용하면 해당 Collection의 객체를 소스로 하는 Stream을 반환한다.
// List로부터 스트림을 생성
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> listStream = list.stream();
배열의 Stream 생성
배열의 원소들을 소스로하는 Stream을 생성하기 위해서는 Stream.of() 메소드 또는 Arrays.stream() 메소드를 사용하면 된다. Stream.of() 메소드는 즉석으로 원하는 값을 집어 넣음으로써 스트림을 생성할 수 있다.
// 배열로부터 스트림을 생성
Stream<String> stream = Stream.of("a", "b", "c"); //가변인자
Stream<String> stream = Stream.of(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3); //end범위 포함 x
원시 타입의 Stream 생성
위와 같이 객체를 위한 Stream 외에도 int와 long 그리고 double과 같은 원시 자료형들을 사용하기 위한 특수한 종류의 Stream(IntStream, LongStream, DoubleStream) 들도 사용할 수 있으며, Intstream같은 경우 range()함수 또는 rangeClosed()를 사용하여 기존의 for문을 대체할 수 있다.
range()는 [a, b)로 끝 구간이 열린 구간이지만, rangeClosed()는 [a, b]로 끝 구간이 닫힌 구간인 차이가 있다.
// 4이상 10 미만의 숫자를 갖는 IntStream
IntStream stream1 = IntStream.range(4, 10);
stream1.forEach(x -> System.out.print(x + " "));
// 4이상 10 이하의 숫자를 갖는 IntStream
IntStream stream2 = IntStream.rangeClosed(4, 10);
stream2.forEach(x -> System.out.print(x + " "));
빈 Stream 생성
일단 빈 스트림만 선언해 놓고 싶을 경우에는 아래 처럼 작성하면 된다.
Stream<String> stream = Stream.empty();
Object 타입으로 Stream이 생성되므로 Generic에 다양하게 타입 선언이 가능하다.
람다식으로 Stream 생성 - iterate()
iterate() 메소드는 for문과 비슷한 구조를 갖는데, 매개 변수로 초기값, Predicate<T>, UnaryOperator<T>를 사용한다. 여기서 Predicate<T>는 선택이다.
Stream<Integer> stream = Stream.iterate(0, n -> n + 1)
.limit(5);
Stream<Integer> stream = Stream.iterate(0, n -> n < 5, n -> n + 1);
다만, iterate() 메소드를 사용할 때 주의할 점이 있다. 바로, 제한 범위를 정해주어야 한다는 것이다. 만약 이를 설정하지 않는다면, 무한정 값이 할당된다
따라서, limit() 메소드를 사용하거나 Pridicate<T>를 이용하여 위 문제를 해결할 수 있다.
※ 참고
Predicate<T> Interface : 매개값을 받고 true / false 리턴
Operator<T> Interface : 매개값 계산해서 동일한 타입으로 리턴, Unary는 단항(연산이 1개)이라는 뜻
더 자세한 내용은 아래 포스팅 참고
[JAVA] 함수형 인터페이스 표준 API 총정리
람다식으로 Stream 생성 - generate()
generate() 메소드는 매개 변수로 오직 Supplier<T>만을 갖는다. 그런데, Supplier<T>는 매개 변수를 필요로 하지 않으므로 generate() 메소드는 결국 스트림 생성시 별도의 인수가 필요하지 않을 때 사용된다.
Random random = new Random();
IntStream.generate(() -> random.nextInt(45) + 1)
.limit(6);
Stream 가공하기(중간연산)
생성한 Stream 객체에서 요소들을 가공하기 위해서는 중간연산이 필요하다. 가공하기 단계의 파라미터로는 앞서 설명하였던 함수형 인터페이스들이 사용되며, 여러 개의 중간연산이 연결되도록 반환값으로 Stream을 반환한다.
위와 같이 리턴 타입이 스트림이라면 중간 처리 메소드이다. 또한, 소속된 인터페이스가 공통이라는 의미는 Stream, IntStream, LongStream, DoubleStream에서 모두 제공된다는 뜻이다.
종류로는 필터링, 매핑, 정렬, 루핑이 있는데 하나씩 살펴보도록 하겠다.
필터링
필터링은 중간 처리 기능으로 요소를 걸러내는 역할을 하는데, 필터링 메소드에는 distinct()와 filter()가 있다. 이 둘은 모든 스트림이 가지고 있는 공통 메소드이다.
distinct()
Stream의 요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 distinct를 사용할 수 있다. distinct는 중복된 데이터를 검사하기 위해 Object의 equals() 메소드를 사용한다. 나머지 IntStream, LongStream, DoubleStream은 동일값일 경우 중복을 제거한다.
아래는 distinct()를 사용한 예시 코드이다.
List<String> list = Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift", "Java");
Stream<String> stream = list.stream()
.distinct()
// [Java, Scala, Groovy, Python, Go, Swift]
만약 우리가 생성한 클래스를 Stream으로 사용한다고 하면 equals와 hashCode를 오버라이드 해야만 distinct()를 제대로 적용할 수 있다.
만약 다음과 같은 Employee 클래스가 있다고 하자.
public class Employee {
private String name;
public Employee(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
위의 Employee 클래스는 equals와 hashCode를 오버라이드하지 않았기 때문에, 아래의 코드를 실행해도 중복된 데이터가 제거되지 않고, size 값으로 2를 출력하게 된다.
import java.util.*;
public class Main {
public static void main(String[] args) {
Employee e1 = new Employee("WonSe");
Employee e2 = new Employee("WonSe");
List<Employee> employees = new ArrayList<>();
employees.add(e1);
employees.add(e2);
int size = employees.stream().distinct().collect(Collectors.toList()).size();
System.out.println(size);
}
}
그렇기 때문에 우리는 아래와 같이 equals와 hashCode를 오버라이드하여 이러한 문제를 해결해야 한다.
import java.util.Objects;
public class Employee {
private String name;
public Employee(String name) {
this.name = name;
}
public String getName() {
return name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee employee = (Employee) o;
return Objects.equals(name, employee.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
}
위와 같은 코드를 추가하고 main 함수를 다시 실행시키면 size는 1이 된다.
filter()
Filter는 Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어내는 연산이다. Java에서는 filter 함수의 인자로 함수형 인터페이스 Predicate를 받고 있기 때문에, boolean을 반환하는 람다식을 작성하여 filter 함수를 구현할 수 있다. 예를 들어 어떤 String의 stream에서 a가 들어간 문자열만을 포함하도록 필터링하는 예제는 다음과 같이 작성할 수 있다.
Stream<String> stream =
list.stream()
.filter(name -> name.contains("a"));
매핑
매핑은 스트림의 요소를 다른 요소로 대체하는 작업을 말한다. 스트림에서 제공하는 매핑 메소드는 flatMapXXX()와 mapXXX(), 그리고 asDOubleStream(), asLongStream(), boxed()가 있다.
flatMap()
flatMapXXX() 메소드는 Array나 Object로 감싸져 있는 모든 원소를 단일 원소 스트림으로 반환하는 역할을 하며, 매개변수로는 Function을 사용한다. 그리고 내부적으로 T를 Stream<R> 형식인 스트림 형태로 매핑한다.
아래는 flatMap을 사용한 예제 코드이다.
List<String> inputList = Arrays.asList("Hello", "world");
long res = inputList.stream()
.flatMap(data -> Arrays.stream(data.split("")))
.count();
System.out.println(res);
inputList에는 "Hello"와 "world"를 원소로 갖는다. 그리고 stream() 메소드를 통해 오리지널 스트림을 얻어오고 그 안에서 flatMap() 메소드를 사용하는 것을 알 수 있다.
현재 스트림의 요소로는 "Hello"와 "world"가 있는데, 각각 5개의 단일 원소 스트림으로 나뉘게 된다. 그래서 스트림 안에는 10개의 요소가 존재하게 된다. 그리고 출력 결과로는 10이 나오는 것을 알 수 있다.
map()
Map은 기존의 Stream 요소들을 변환하여 새로운 Stream을 형성하는 연산이다. 저장된 값을 특정한 형태로 변환하는데 주로 사용되며, Java에서는 map 함수의 인자로 함수형 인터페이스 function을 받고 있다. flatMap()과 다르게 스트림 외에 다른 형식으로 매핑이 가능하다.
예를 들어 String을 요소들로 갖는 Stream을 모두 대문자 String의 요소들로 변환하고자 할 때 map을 이용할 수 있다.
Stream<String> stream =
names.stream()
.map(s -> s.toUpperCase());
위의 map 함수의 람다식은 메소드 참조를 이용해 변경이 가능하다. 이번에는 메소드 참조를 이용하여 파일의 Stream을 파일 이름의 Stream으로 변경해보자.
Stream<File> fileStream = Stream.of(new File("Test1.java"), new File("Test2.java"), new File("Test3.java"));
//Stream<File> --> Stream<String> 변환
Stream<String> fileNameStream = fileStream.map(File::getName);
asDoubleStream(), asLongStream(), boxed()
asDoubleStream() 메소드는 IntStream의 int 요소 또는 LongStream의 long 요소를 double 요소로 타입 변환해서 DoubleStream을 생성한다. 마찬가지로 asLongStream() 메소드는 IntStream의 int 요소를 long 요소로 타입 변환해서 LongStream을 생성한다. boxed() 메소드는 int, long, double 요소를 Integer, Long, Double 요소로 박싱해서 Stream을 생성합니다.
여기서 주의할 점은 IntStream과 Stream<Integer>은 엄연히 다른 스트림이라는 것이다.
int[] intArray = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(intArray);
intStream
.asDoubleStream() // DoubleStream 생성
.forEach(System.out::println);
System.out.println();
intStream = Arrays.stream(intArray);
intStream
.boxed() // Stream<Integer> 생성
.forEach(obj -> System.out.println(obj.intValue()));
원시 Stream <-> Stream
작업을 하다 보면 일반적인 Stream 객체를 원시 Stream으로 바꾸거나 그 반대로 하는 작업이 필요한 경우가 있다. 이러한 경우를 위해서, 일반적인 Stream 객체는 mapToInt(), mapToLong(), mapToDouble()이라는 특수한 Mapping 연산을 지원하고 있으며, 그 반대로 원시객체는 mapToObject를 통해 일반적인 Stream 객체로 바꿀 수 있다.
// IntStream -> Stream<Integer>
IntStream.range(1, 4)
.mapToObj(i -> "a" + i)
// Stream<Double> -> IntStream -> Stream<String>
Stream.of(1.0, 2.0, 3.0)
.mapToInt(Double::intValue)
.mapToObj(i -> "a" + i)
정렬
Stream의 요소들을 정렬하기 위해서는 sorted를 사용해야 하며, 파라미터로 Comparator를 넘길 수도 있다. Comparator 인자 없이 호출할 경우에는 오름차순으로 정렬이 되며, 내림차순으로 정렬하기 위해서는 Comparator의 reverseOrder를 이용하면 된다.
정렬을 할 수 있는 스트림의 타입은 Stream<T>와 IntStream, DoubleStream, LongStream이 있는데, 이들을 나누어서 설명하겠습니다.
Stream<T>
sorted()를 사용하였을 경우 객체를 Comparable 구현 방법에 따라 정렬한다. Student 클래스가 있다고 하면, Comparable을 상속받아서 compareTo() 메소드를 오버라이드함으로써 정렬하는 기준을 정해줄 수 있다.
반면, sorted(Comparator<T>)를 사용하였을 경우 객체를 주어진 Comparator에 따라 정렬한다. Comparator을 따로 정의하고 이를 객체로 생성하여 sorted의 매개변수로 넘겨주어도 되고, Comparator의 내장된 메소드를 사용해도 되고, 람다식을 사용해도 무방하다.
아래는 람다식을 이용한 예제 코드이다.
List<Member> memberList = Arrays.asList(
new Member("이산", Member.MALE, 22),
new Member("진영", Member.MALE, 23),
new Member("별찬", Member.MALE, 20)
);
memberList.stream()
.sorted((m1, m2) -> m1.getAge() - m2.getAge())
.forEach(m -> System.out.println(m.getName()));
또한 예를 들어 어떤 Stream의 String 요소들을 정렬하기 위해서는 다음과 같이 sorted를 활용할 수 있다.
List<String> list = Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift");
Stream<String> stream = list.stream()
.sorted()
// [Go, Groovy, Java, Python, Scala, Swift]
Stream<String> stream = list.stream()
.sorted(Comparator.reverseOrder())
// [Swift, Scala, Python, Java, Groovy, Go]
IntStream, DoubleStream, LongStream
이들은 sorted() 메소드를 사용하여 요소를 오름차순 정렬할 수 있다. 만약, 매개변수를 통하여 정렬하는 기준을 설정해 주고 싶다면, 위에서 설명한대로 Comparator를 정의하여 넣어주면 된다.
특정 연산 수행 - Peek
Stream의 요소들을 대상으로 Stream에 영향을 주지 않고 특정 연산을 수행하기 위한 peek 함수가 존재한다.
'확인해본다'라는 뜻을 지닌 peek 단어처럼, peek 함수는 Stream의 각각의 요소들에 대해 특정 작업을 수행할 뿐 결과에 영향을 주지 않는다. 또한 peek 함수는 파라미터로 함수형 인터페이스 Consumer를 인자로 받는다.
예를 들어 어떤 stream의 요소들을 중간에 출력하기를 원할 때 다음과 같이 활용할 수 있다.
int sum = IntStream.of(1, 3, 5, 7, 9)
.peek(System.out::println)
.sum()
Stream 결과 만들기(최종 연산)
중간 처리 메소드의 리턴 타입은 스트림이었던 반면에, 최종 처리 메소드는 기본 타입이거나 Optiona 이다. 또한, 소속된 인터페이스가 공통이라는 의미는 Stream, IntStream, LongStream, DoubleStream에서 모두 제공된다는 뜻이다.
종류로는 루핑, 매칭, 집계, 수집이 있는데 이번 시간에는 집계까지만 살펴보도록 하겠습니다.
루핑
루핑은 특이하게도 중간 처리 메소드에서도 사용되고, 최종 처리 메소드에서도 사용된. 그렇다면, 이 둘의 차이는 무엇일까?
Stream의 요소들을 대상으로 어떤 특정한 연산을 수행하고 싶은 경우에는 forEach 함수를 이용할 수 있다. 앞에서 살펴본 비슷한 함수로 peek()가 있다. peek()는 중간 연산으로써 실제 요소들에 영향을 주지 않은 채로 작업을 진행하고, Stream을 반환하는 함수였다.
하지만 forEach()는 최종 연산으로써 실제 요소들에 영향을 줄 수 있으며, 반환값이 존재하지 않는다. 예를 들어 요소들을 출력하기를 원할 때 다음과 같이 forEach를 사용할 수 있다.
예제를 살펴보자.
int[] intArr = {1, 2, 3, 4, 5};
System.out.println("[peek()를 마지막에 호출한 경우]");
Arrays.stream(intArr)
.filter(a -> a % 2 == 0)
.peek(System.out::println); // 동작하지 않음.
System.out.println("[최종 처리 메소드를 마지막에 호출한 경우]");
int total = Arrays.stream(intArr)
.filter(a -> a % 2 == 0)
.peek(System.out::println) // 동작함.
.sum(); // 요소의 합을 구하는 최종 처리 메소드
System.out.println("총합 : " + total);
System.out.println("[forEach()를 마지막에 호출한 경우");
Arrays.stream(intArr)
.filter(a -> a % 2 == 0)
.forEach(System.out::println);
최종 처리 메소드없이 peek()만 호출하면 그 안에 있는 짝수 요소가 출력되지 않으며, 최종 처리 메소드가 존재할 때 peek()을 호출하면 짝수 요소가 출력된다. 또한, forEach()는 그 자체로 최종 처리 메소드이므로 마지막에 사용함으로써 짝수 요소를 출력하게 만들 수 있다.
조건 검사 - 매칭
Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match 함수를 이용할 수 있다. match 함수는 함수형 인터페이스 Predicate를 받아서 해당 조건을 만족하는지 검사를 하게 되고, 검사 결과를 boolean으로 반환한다.비슷한 역할로는 중간 처리 단계에서 필터링이 있다.
match 함수에는 크게 다음의 3가지가 있다.
- anyMatch: 1개의 요소라도 해당 조건을 만족하는가
- allMatch: 모든 요소가 해당 조건을 만족하는가
- nonMatch: 모든 요소가 해당 조건을 만족하지 않는가
예를 들어 다음과 같은 예시 코드가 있다고 할 때, 아래의 경우 모두 true를 반환하게 된다.
List<String> names = Arrays.asList("Eric", "Elena", "Java");
boolean anyMatch = names.stream()
.anyMatch(name -> name.contains("a"));
boolean allMatch = names.stream()
.allMatch(name -> name.length() > 3);
boolean noneMatch = names.stream()
.noneMatch(name -> name.endsWith("s"));
기본 집계
집계는 최종 처리 기능으로 요소들을 처리해서 카운팅, 합계, 평균값, 최댓값, 최솟값 등과 같이 하나의 값으로 산출하는 것을 말한다.
위와 같이 count()와 sum()을 제외하면 리턴 타입이 Optional이라는 것을 알 수 있다. 왜냐하면 min이나 max 또는 average는 Stream이 비어있는 경우에 값을 특정할 수 없다. 그렇기 때문에 다음과 같이 Optional로 값이 반환된다.
OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min();
int max = IntStream.of().max().orElse(0);
IntStream.of(1, 3, 5, 7, 9).average().ifPresent(System.out::println);
반면에 총합이나 갯수의 경우에는 값이 비어있는 경우 0으로 값을 특정할 수 있다. 그렇기 때문에 Stream API는 sum 메소드와 count 메소드에대해 Optional이 아닌 원시 값을 반환하도록 구현해두었다. 당연히 Stream이 비어있을 경우에는 0을 반환하게 된다.
long count = IntStream.of(1, 3, 5, 7, 9).count();
long sum = LongStream.of(1, 3, 5, 7, 9).sum();
Optional 클래스
Optional 클래스에 대해서는 더 자세하게 추후 포스팅을 작성하겠지만 지금은 Stream에 관련해서마나 정리하고자 한다.
OptionalXXX는 자바 8에서 추가한 java.util 패키지의 새로운 클래스 타입으로, 값을 저장하는 값 기반 클래스들이다. 이 객체에서 값을 얻기 위해서는 get(), getAsDouble(), getAsInt(), getAsLong()을 호출하면 된다.
Optional 클래스는 값을 저장하는 값 기반 클래스라고 하였는데, 사실 값만 저장하는 것이 아니라, 집계 값이 존재하지 않을 경우 디폴트 값을 설정할 수 있고, 집계 값을 처리하는 Consumer도 등록할 수 있다.
아래는 Optional 클래스에서 제공하는 메소드 목록이다.
값이 저장되어 있는지 여부가 필요한 경우는 많다. 컬렉션의 요소는 동적으로 추가되는 경우가 많은데, 만약 컬렉션의 요소가 추가되지 않아 저장된 요소가 없을 경우, 평균값을 구하거나 합을 구하는 작업을 수행할 수 없다.
이러한 예외(NoSuchElementException)를 막기 위한 방법은 총 3가지가 있다. 이해를 돕기 위하여 아래 코드와 같은 상황을 하나 설정하겠다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
// list에 요소가 없을 경우 NoSuchElementException 발생!
double avg = list.stream()
.mapToInt(Integer::intValue)
.average()
.getAsDouble();
}
}
(1) Optional 객체를 얻어서 isPresent() 메소드 사용
isPresent() 메소드는 값이 저장되어 있는지 확인하는 메소드로, 가장 기본적으로 if문을 활용하여 작성된다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
OptionalDouble optional = list.stream()
.mapToInt(Integer::intValue)
.average();
if (optional.isPresent()) {
System.out.println("평균 : " + optional.getAsDouble());
} else {
System.out.println("평균 : 0.0");
}
}
}
(2) orElse() 메소드로 디폴트 값 설정
orElse() 메소드는 값이 저장되어 있지 않을 경우 디폴트 값을 지정하는 역할을 한다. 참고로 orElse() 메소드를 사용할 경우, 디폴트 값을 지정하는 동시에 값을 반환한다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
double avg = list.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
System.out.println("평균 : " + avg);
}
}
(3) ifPresent() 메소드 사용
ifPresent() 메소드는 값이 저장되어 있을 경우 Consumer에서 연산을 수행하도록 한다. 이때, Consumer 안에서 람다식으로 하고자 하는 작업을 표현할 수 있다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.stream()
.mapToInt(Integer::intValue)
.average()
.ifPresent(a -> System.out.println("평균 : " + a));
}
}
커스텀 집계
스트림은 기본 집계말고도 reduce() 메소드라는 커스텀 집계를 제공한다. 직접 기준을 세워서 집계를 하는 것이다.
사용하는 인터페이스는 Stream, IntStreaem, LongStream, DoubleStream이 있고, 리턴 타입은 Optional 혹은 T로 되어 있다. Optional과 T로 리턴되는 기준은 바로 메소드의 매개변수 중 identity의 유무이다.
identity 매개값은 쉽게 말해서 디폴트값이라고 보면 된다. 자세한 reduce() 메소드의 동작 과정은 소스코드를 통해 살펴 봅시다.
import java.util.*;
public class Main {
public static void main(String[] args) {
List<Member> memberList = Arrays.asList(
new Member("이산", Member.MALE, 23),
new Member("진영", Member.MALE, 22),
new Member("별찬", Member.MALE, 21)
);
int sum = memberList.stream()
.map(Member::getAge)
.reduce(Integer::sum) // 스트림의 요소를 누적해서 더해 나감.
.orElse(0); // 디폴트값 설정하지 않으면 NoSuchElementException 발생!
System.out.println(sum);
sum = memberList.stream()
.map(Member::getAge)
.reduce(0, Integer::sum); // 디폴트값을 0으로 설정하고, 스트림의 요소를 누적해서 더해 나감.
System.out.println(sum);
}
}
전자는 identity 매개값을 설정하지 않은 것이고, 후자는 설정한 것이다. 그리고 전자는 위의 Optional 클래스에서 설명한대로 디폴트값에 대한 예외 처리를 해 주어야 한다. 반면, 후자는 이미 reduce() 메소드에서 디폴트값을 설정해 주었기때문에 리스트이 요소가 없어도 예외가 발생하지 않는다.
reduce는 기본적으로 스트림의 요소를 하나씩 방문하면서 개발자가 정한 기준에 따라서 누적 연산을 진행한다. 위 예제 코드에서는 덧셈 연산으로 설정하였기 때문에 멤버의 나이를 누적 덧셈을 하게 된다.
수집
Stream의 요소들을 List나 Set, Map, 등 다른 종류의 결과로 수집하고 싶은 경우에는 collect 함수를 이용할 수 있다. collect 함수는 어떻게 Stream의 요소들을 수집할 것인가를 정의한 Collector 타입을 인자로 받아서 처리한다. 일반적으로 List로 Stream의 요소들을 수집하는 경우가 많은데, 이렇듯 자주 사용하는 작업은 Collectors 객체에서 static 메소드로 제공하고 있다. 원하는 것이 없는 경우에는 Collector 인터페이스를 직접 구현하여 사용할 수도 있다.
collect() : 스트림의 최종연산, 매개변수로 Collector를 필요로 한다.
Collector : 인터페이스, collect의 파라미터는 이 인터페이스를 구현해야한다.
Collectors : 클래스, static메소드로 미리 작성된 컬렉터를 제공한다.
// collect의 파라미터로 Collector의 구현체가 와야 한다.
Object collect(Collector collector)
(1) 기본 컬렉션 (List, Set, ...)에 수집하기
스트림에서 collect() 메소드는 매개 변수에 따라 쓰임새가 달라지는데, 매개 변수를 Collector<T, A, R>을 취할 경우 주로 어떤 요소를 기본 컬렉션에 수집할 때 사용된다. Collector<T, A, R>에서 T는 요소이고, A는 누적기이며, R은 요소가 저장될 컬렉션이다. 즉, T 요소를 A 누적기가 R에 저장한다는 의미이다.
아래는 Collectors 클래스의 정적 메소드다. 이를 이용하여 Collectior의 구현 객체를 얻어낼 수 있다.
리턴값인 Collector를 보면 A(누적기)가 ?로 되어 있는데, 이것은 Collector가 R(컬렉션)에 T(요소)를 저장하는 방법을 알고 있어서 A가 필요 없기 때문이다.
※ 참고
Map과 ConcurrentMap의 차이점은 쓰레드의 안정성이다. Map이 쓰레드에 안전하지 않고, ConcurrentMap이 쓰레드에 안전하기 때문에 멀티쓰레드환경에서는 ConcurrentMap을 쓰는 것을 권장한다.
아래는 전체 학생 중에서 남학생만 필터링해서 별도의 List를 생성하는 코드이다.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE),
new Student("진영", 8, Student.Sex.MALE),
new Student("민주", 11, Student.Sex.FEMALE),
new Student("별찬", 10, Student.Sex.MALE)
);
List<Student> maleList = studentList.stream()
.filter(s -> s.getSex() == Student.Sex.MALE)
.collect(Collectors.toList());
같은 방식으로 Set 컬렉션도 얻어낼 수 있지만, HashSet 컬렉션을 얻고 싶다면 toCollection() 메소드를 작성해야한다. 그리고 이 메소드의 매개변수로는 Supplier<Collection<T>>를 넘겨주면 된다.
아래는 전체 학생 중에서 남학생만 필터링해서 별도의 HashSet을 생성하는 코드이다.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE),
new Student("진영", 8, Student.Sex.MALE),
new Student("민주", 11, Student.Sex.FEMALE),
new Student("민주", 10, Student.Sex.FEMALE),
new Student("별찬", 10, Student.Sex.MALE)
);
HashSet<Student> femaleSet = studentList.stream()
.filter(s -> s.getSex() == Student.Sex.FEMALE)
.collect(Collectors.toCollection(HashSet::new));
이때 HashSet 내에서 이러한 객체가 같은지 비교하려면 Student 클래스 내에 hashCode() 메소드와 equals() 메소드를 오버라이드 해줘야 한다.
(2) 사용자 정의 컨테이너에 수집하기
List, Set, Map과 같은 컬렉션이 아니라 사용자 정의 컨테이너 객체에 요소를 수집하는 방법도 존재한다. 위에서는 매개변수로 Collector을 전달하는 collect() 메소드를 사용하였지만, 이번에는 총 3개의 매개변수를 필요로 하는 collect() 메소드를 사용해야 한다.
첫 번째로, Supplier<T>는 요소들이 수집될 컨테이너 객체(R)을 생성하는 역할을 한다. 싱글 쓰레드 스트림에서는 단 한 번 Supplier가 실행되고 하나의 컨테이너 객체를 생성한다. 반면, 멀티 쓰레드 스트림에서는 여러 번 Supplier가 실행되고 쓰레드 별로 여러 개의 컨테이너 객체를 생성한다. 그리고 최종적으로 하나의 컨테이너 객체로 결합된다.
두 번째로, XXXConsumer는 컨테이너 객체(R)에 요소(T)를 수집하는 역할을 한다. 스트림에서 요소를 컨테이너에 수집할 때마다 XXXConsumer가 실행된다.
마지막으로, BiConsumer는 컨테이너 객체(R)을 결합하는 역할을 하는데, 싱글 쓰레드 스트림에서는 호출되지 않고, 병렬 처리 스트림에서만 호출되어 쓰레드 별로 생성된 컨테이너 객체를 결합해서 최종 컨테이너 객체를 완성한다.
리턴 타입 R은 요소들이 최종 수집된 컨테이너 객체를 의미한다. 싱글 쓰레드 스트림에서는 리턴 객체가 첫 번째 Supplier가 생성한 객체지만, 병렬 처리 스트림에서는 최종 결합된 컨테이너 객체가 된다.
이번 포스팅에서 싱글 쓰레드 스트림을 활용한 예제를 살펴보겠다.
이번 예제는 학생들 중에서 남학생만 수집하는 MaleStudent 컨테이너이다. 이를 먼저 정의하겠다.
public class MaleStudent {
private List<Student> list;
public MaleStudent() {
list = new ArrayList<>();
System.out.println("[" + Thread.currentThread().getName() + "] MaleStudent()");
}
// 요소를 수집
public void accumulate(Student student) {
list.add(student);
System.out.println("[" + Thread.currentThread().getName() + "] accumulate()");
}
// 두 MaleStudent를 결합. (병렬 처리 시에만 호출)
public void combine(MaleStudent other) {
list.addAll(other.getList());
System.out.println("[" + Thread.currentThread().getName() + "] combine()");
}
public List<Student> getList() {
return list;
}
}
public class Main {
public static void main(String[] args) {
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE),
new Student("진영", 8, Student.Sex.MALE),
new Student("민주", 10, Student.Sex.FEMALE),
new Student("별찬", 10, Student.Sex.MALE)
);
// Stream<Student> studentStream = studentList.stream();
// Stream<Student> maleStream = studentStream.filter(s -> s.getSex() == Student.Sex.MALE);
// Supplier<MaleStudent> supplier = MaleStudent::new;
// BiConsumer<MaleStudent, Student> accumulator = MaleStudent::accumulate;
// BiConsumer<MaleStudent, MaleStudent> combiner = MaleStudent::combine;
// MaleStudent maleStudent = maleStream.collect(supplier, accumulator, combiner);
MaleStudent maleStudent = studentList.stream()
.filter(s -> s.getSex() == Student.Sex.MALE)
.collect(MaleStudent::new, MaleStudent::accumulate, MaleStudent::combine);
///////////////////////////////////////////////////////////////////////////////
// HashSet을 Collector 없이 아래와 같이 표현이 가능함.
HashSet<Student> femaleSet = studentList.stream()
.filter(s -> s.getSex() == Student.Sex.FEMALE)
.collect(HashSet::new, HashSet::add, HashSet::addAll);
}
}
주석 처리를 하지 않은 부분은 첫 번째로 주석 처리한 코드를 간략하게 표현한 것이다. 그리고 두 번쨰로 주석 처리한 코드는 Collector 없이 사용자 정의 컨테이너에 요소를 수집하는 방식으로 작성된 것이다.
요소를 그룹핑해서 수집
collect() 메소드는 단순히 요소를 수집하는 기능 이외에 컬렉션의 요소들을 그룹핑해서 Map 객체를 생성하는 기능도 제공한다. 여기서 collect()를 호출할 때 매개 변수로 groupingBy() 또는 groupingByConcurrent() 메소드를 사용하면 된다. 전에 설명한 대로 전자는 쓰레드에 안전하지 않은 Map을 생성하지만, 후자는 쓰레드에 안전한 ConcurrentMap을 생성한다.
첫 번째는, 매개변수로 Function<T, K> classifier를 사용하는 groupingBy() 메소드이다. 이 메소드의 리턴 타입을 보면, Collector<T, ?, Map<K, List<T>>>로, T를 K로 매핑한 후, 키가 K이면서 T를 저장하는 요소를 값으로 갖는 Map을 생성한다.
두 번째는 매개변수로 Function<T, K> classifier, Collector<T, A, D> collector를 사용하는 groupingBy() 메소드이다. 이 메소드의 리턴 타입을 보면, Collector<T, ?, Map<K, D>>로, T를 K로 매핑한 후, 키가 K이면서 키에 저장된 D객체에 T를 누적한 Map을 생성한다.
세 번째는 두 번째의 매개 변수에서 Supplier가 추가된 형태이다. 사용 방식도 위와 유사한데, 다만 그냥 Map이 아니라 TreeMap같은 Supplier가 제공하는 Map을 사용한다.
그룹핑은 이렇게 설명만 들으면 이해가 바로 가질 않아서 예시 코드로 살펴보겠다.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE, Student.City.Seoul),
new Student("진영", 8, Student.Sex.FEMALE, Student.City.Pusan),
new Student("별찬", 9, Student.Sex.MALE, Student.City.Seoul),
new Student("민주", 11, Student.Sex.FEMALE, Student.City.Pusan)
);
Map<Student.Sex, List<Student>> mapBySex = studentList.stream()
.collect(Collectors.groupingBy(Student::getSex));
System.out.print("[남학생] ");
mapBySex.get(Student.Sex.MALE).forEach(s -> System.out.print(s.getName() + " "));
System.out.print("\n[여학생] ");
mapBySex.get(Student.Sex.FEMALE).forEach(s -> System.out.print(s.getName() + " "));
위 예시 코드는 첫 번째 groupingBy() 메소드를 사용한 것이고, 학생의 성별을 기준으로 그룹핑하였다. groupingBy() 메소드의 매개변수로는 Function<T, K>를 사용하여 T를 K로 매핑하였다. 이때, T는 Student이고, K는 Student.Sex이므로 Map<Student.Sex(K), List<Student(T)>>이 생성되는 것이다.
다음 예시 코드를 살펴보자.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE, Student.City.Seoul),
new Student("진영", 8, Student.Sex.FEMALE, Student.City.Pusan),
new Student("별찬", 9, Student.Sex.MALE, Student.City.Seoul),
new Student("민주", 11, Student.Sex.FEMALE, Student.City.Pusan)
);
Map<Student.City, List<String>> mapByCity = studentList.stream()
.collect(Collectors.groupingBy(
Student::getCity,
Collectors.mapping(Student::getName, Collectors.toList()))
);
System.out.print("[서울] ");
mapByCity.get(Student.City.Seoul).forEach(s -> System.out.print(s + " "));
System.out.print("\n[부산] ");
mapByCity.get(Student.City.Pusan).forEach(s -> System.out.print(s + " "));
위 예시 코드는 두 번째 groupingBy() 메소드를 사용한 것이고, 학생의 지역을 기준으로 그룹핑하였다. groupingBy() 메소드의 매개변수로는 Function<T, K>와 Collector<T, A, D> collector을 사용하였다. 여기서, mapByCity를 만드는 과정만 따로 자세히 살펴보겠습니다.
Function<Student, Student.City> classifier = Student::getCity;
Function<Student, String> mapper = Student::getName;
Collector<String, ?, List<String>> collector1 = Collectors.toList();
Collector<Student, ?, List<String>> collector2 = Collectors.mapping(mapper, collector1);
Collector<Student, ?, Map<Student.City, List<String>>> collector3 = Collectors
.groupingBy(classifier, collector2);
Map<Student.City, List<String>> mapByCity = studentList.stream().collect(collector3);
하나로 연결된 코드를 모두 분리해봤다. 먼저, Map의 Key를 얻어내기 위하여 T를 K로 매핑한다. 여기서 T는 Student이며, K는 Student.City가 될 것이고, 이 과정은 첫 번째 줄에 해당합니다.
그리고 Map의 Value에 해당하는 D를 얻어야 하는데, 이 부분이 복잡하다. 결과적으로, 우리는 Value가 List<Student>가 아니라, List<String>을 얻어야 한다. 여기서 String은 학생의 이름이 될것이다. 그래서 Student를 Student::getName으로 매핑하고, 이것을 다시 List<String>으로 매핑해야 한다. 이 과정이 2 ~ 4번째 줄이 되겠다.
참고로, Collectors.mapping() 메소드는 아래에서도 다룰 예정이지만, T를 U로 매핑한 후, U를 R에 수집하는 역할을 한다.
여기까지 classifier와 collector을 모두 정의하였으므로 이 두 개를 매개 변수로 넘겨서 mapByCity를 형성하면 된다.
이제 세 번째 groupingBy() 메소드가 남았다. 이것은 위의 groupingBy() 메소드를 형성하는 원리와 거의 유사하나, Map의 형태를 구체화하여 저장할 수 있다.
TreeMap<Student.City, List<String>> treeMapByCity = studentList.stream()
.collect(Collectors.groupingBy(
Student::getCity,
TreeMap::new,
Collectors.mapping(Student::getName, Collectors.toList()))
);
다음과 같이 Supplier가 들어갈 자리에 원하는 Map의 형태를 지정해 주는 것이다.
Collectors.partitioningBy()
Collectors.groupingBy()가 함수형 인터페이스 Function을 사용해서 특정 값을 기준으로 Stream 내의 요소들을 그룹핑하였다면, Collectors.partitioningBy()는 함수형 인터페이스 Predicate를 받아 Boolean을 Key값으로 partitioning한다.
예를 들어 제품의 갯수가 15보드 큰 경우와 그렇지 않은 경우를 나누고자 한다면 다음과 같이 코드를 작성할 수 있다.
Map<Boolean, List<Product>> mapPartitioned = productList.stream()
.collect(Collectors.partitioningBy(p -> p.getAmount() > 15));
/*
{false=[Product{amount=14, name='orange'}, Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}],
true=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}]}
*/
그룹핑 후 매핑 및 집계
Collectors.groupingBy() 메소드는 그룹핑 후, 매핑이나 집계를 할 수 있도록 두 번째 매개값으로 Collector를 가질 수 있다.
위 예제에서 매개 변수가 하나만 있는 groupingBy() 메소드도 있었지만, 2개 또는 3개가 있는 groupingBy() 메소드를 다뤘다. 이 중, 2개 또는 3개의 매개 변수를 갖는 groupingBy() 메소드가 그룹핑 후 매핑 및 집계를 사용한다. 위 예제에서는 mapping() 메소드를 활용하였지만, 집계를 통해서 Value 값을 무궁무진하게 변화시킬 수 있다.
이제, 위 메소드의 일부를 이용한 예제 코드를 작성할 것인데, 모두 2번째 groupingBy() 메소드를 사용할 것이다. 특히, Value 부분에서 T가 D로 매핑되는 과정에 집중해야한다.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE, Student.City.Seoul),
new Student("진영", 8, Student.Sex.FEMALE, Student.City.Pusan),
new Student("별찬", 9, Student.Sex.MALE, Student.City.Seoul),
new Student("민주", 11, Student.Sex.FEMALE, Student.City.Pusan)
);
Map<Student.Sex, Double> mapBySex = studentList.stream()
.collect(
Collectors.groupingBy(
Student::getSex,
Collectors.averagingDouble(Student::getScore)
)
);
System.out.println("남학생 평균 점수 : " + mapBySex.get(Student.Sex.MALE)); // 9.5
System.out.println("여학생 평균 점수 : " + mapBySex.get(Student.Sex.FEMALE)); // 9.5
위 코드는 성별을 기준으로 평균 점수를 저장하는 맵을 생성한다. maxBySex을 정의하는 과정만 자세히 살펴보자.
Function<Student, Student.Sex> classifier = Student::getSex;
ToDoubleFunction<Student> mapper = Student::getScore;
Collector<Student, ?, Double> collector1 = Collectors.averagingDouble(mapper);
Collector<Student, ?, Map<Student.Sex, Double>> collector2 = Collectors
.groupingBy(classifier, collector1);
Map<Student.Sex, Double> mapBySex = studentList.stream().collect(collector2);
Map의 Key는 첫 번째 줄을 통하여 얻어낼 수 있고, Value는 두, 세 번째 줄을 통하여 얻어낼 수 있다. 특히, Value는 Student를 Double로 매핑한다는 점을 주의 깊게 봐야한다.
List<Student> studentList = Arrays.asList(
new Student("이산", 10, Student.Sex.MALE, Student.City.Seoul),
new Student("진영", 8, Student.Sex.FEMALE, Student.City.Pusan),
new Student("별찬", 9, Student.Sex.MALE, Student.City.Seoul),
new Student("민주", 11, Student.Sex.FEMALE, Student.City.Pusan)
);
Map<Student.Sex, String> mapByName = studentList.stream()
.collect(
Collectors.groupingBy(
Student::getSex,
Collectors.mapping(
Student::getName,
Collectors.joining(", ")
)
)
);
System.out.println("남학생 전체 이름 : " + mapByName.get(Student.Sex.MALE));
System.out.println("여학생 전체 이름 : " + mapByName.get(Student.Sex.FEMALE));
이 예제는 학생들을 성별로 그룹핑한 다음 같은 그룹에 속하는 학생 이름을 쉼표로 구분해서 문자열을 만들고, 성별을 Key로, 문자열을 Value로 갖는 Map을 생성한다.
mapping() 메소드를 통하여 Student를 String으로 매핑한다. 'Student -> String(구분자 없음) -> String(구분자 있음)'으로 매핑된다는 사실을 기억하고, 구분자는 맨 마지막에는 첨가되지 않는다는 것에 유의해야 한다.
참고