[Java] Java 8 에서 자주 만나는 것들 2

Stream

Java에서 배열이나 컬렉션을 사용하는 경우가 많은데, 항목에 접근할 때 반복문이나 반복자(iterator)를 사용한다. 이는 코드를 길어지게 하고, 가독성이 떨어지고, 재사용이 거의 불가능하다.

따라서 Java 8 부터는 Stream API를 도입한다. Stream은 다음과 같은 특징을 가진다.

Stream의 특징

No storage. A stream is not a data structure that stores elements; instead, it conveys elements from a source such as a data structure, an array, a generator function, or an I/O channel, through a pipeline of computational operations.

Stream은 항목을 저장하지 않는 대신 전달한다. 연산 pipeline을 통해서.

Functional in nature. An operation on a stream produces a result, but does not modify its source. For example, filtering a Stream obtained from a collection produces a new Stream without the filtered elements, rather than removing elements from the source collection.

원본 데이터를 수정하지 않는다.

Laziness-seeking. Many stream operations, such as filtering, mapping, or duplicate removal, can be implemented lazily, exposing opportunities for optimization. For example, “find the first String with three consecutive vowels” need not examine all the input strings. Stream operations are divided into intermediate (Stream-producing) operations and terminal (value- or side-effect-producing) operations. Intermediate operations are always lazy.

게으름을 피운다. 가 아니고, 지연(lazy) 연산을 통해 성능을 최적화한다. 예를 들어 “3개 연속 모음(a, i, u, e, o)이 있는 문자열 찾기”에서 모든 문자열을 검사할 필요가 없다. Stream은 중개 연산과 최종 연산이 있는데, 중개 연산은 항상 게으르다.

Possibly unbounded. While collections have a finite size, streams need not. Short-circuiting operations such as limit(n) or findFirst() can allow computations on infinite streams to complete in finite time.

무한할 수 있다. 무한 스트림은 limit(n) 또는 findFirst()로 유한한 시간에 끝낼 수 있다.

Consumable. The elements of a stream are only visited once during the life of a stream. Like an Iterator, a new stream must be generated to revisit the same elements of the source.

스트림은 1회용이다. 재사용할 수 없다.

Stream 생성

Stream의 생성 방법이 다양한데 예제와 함께 알아보자.

Collections 인터페이스의 stream()

1
2
3
4
5
6
7
8
9
10
11
private static void streamPractice1() {
  ArrayList<Integer> list = new ArrayList<>();

  list.add(4);
  list.add(2);
  list.add(3);
  list.add(1);

  Stream<Integer> stream = list.stream();
  stream.map(i -> i + " ").forEach(System.out::print);
}
1
4 2 3 1

stream()Collection 인터페이스에 있는 메서드로 ArrayList, HashSet 등을 Stream으로 만들 수 있다.

Arrays 클래스의 stream()

1
2
3
4
5
6
7
8
9
private static void streamPractice2() {
  String[] arr = new String[] {"넷", "둘", "셋", "하나"};

  Stream<String> stream = Arrays.stream(arr);
  stream.forEach(e -> System.out.print(e + " "));
  System.out.println();

  Arrays.stream(arr, 1, 3).forEach(e -> System.out.print(e + " "));
}
1
2
딸기 당근 수박 참외 메론
당근 수박

배열에 대해서는 Arrays 클래스의 stream()을 사용하며, 또한 특정 부분만을 Stream으로 생성할 수 있다.

Stream 클래스의 of()

1
2
3
4
5
6
7
8
9
private static void streamPractice3() {
  Stream<Double> stream1 = Stream.of(3.14, 1.57, 2.0, 5.55555, 3.11);
  stream1.map(d -> d + " ").forEach(System.out::print);

  System.out.println();

  Stream<Integer> stream2 = Stream.of(100, 200);
  stream2.map(d -> d + " ").forEach(System.out::print);
}
1
2
3.14 1.57 2.0 5.55555 3.11
100 200

Stream 클래스의 of()를 사용하여 가변 매개변수를 사용할 수도 있다.

IntStream 인터페이스의 range()

1
2
3
4
private static void streamPractice4() {
  IntStream stream1 = IntStream.range(1, 5);
  stream1.forEach(System.out::print);
}
1
1234

지정된 범위의 연속된 정수를 스트림으로 생성하기 위해서 IntStream 인터페이스의 range()를 사용할 수 있다.

다른 인터페이스로 LongStream이나 DoubleStream도 있다.

Random 클래스의 ints()

1
2
3
4
private static void streamPractice5() {
  IntStream stream = new Random().ints(4);
  stream.forEach(System.out::println);
}
1
2
3
4
2058464775
-328584945
-326803370
817906855

Random 클래스에는 ints() 뿐 아니라 longs(), doubles()와 같은 메서드도 있다. 이 메서드들의 매개변수는 개수를 의미하며, 이게 없으면 무한 스트림(infinite stream)을 생성한다. 무한 스트림은 limit() 메서드를 사용하여 개수를 제한할 수 있다.

Stream 클래스의 iterate()

1
2
3
4
5
6
7
8
9
private static void streamPractice6() {
  Stream<Integer> stream1 = Stream.iterate(2, n -> n+2);
  stream1.limit(5).forEach((i) -> System.out.print(i + " "));

  System.out.println();

  Stream<Integer> stream2 = Stream.generate(() -> 5);
  stream2.limit(5).forEach((i) -> System.out.print(i + " "));
}
1
2
2 4 6 8 10
5 5 5 5 5

iterate()는 람다 표현식에서 반환된 값을 다시 사용하는 방식으로 무한 스트림을 생성한다.

generate()는 람다 표현식에서 반환된 값을 계속 사용하는 무한 스트림을 생성한다.

Stream 중개 연산

스트림을 또 다른 스트림으로 변환할 수 있다. 여러 개 연결해서 사용할 수도 있다. 필터-맵(filter-map) 기반의 API를 사용함으로써 지연(lazy) 연산을 통해 성능을 최적화할 수 있다.

  1. 필터링 : filter(), distinct()
  2. 변환 : map(), flatMap()
  3. 제한 : limit(), skip()
  4. 정렬 : sort()
  5. 연산 결과 확인 : peek()

필터링: filter(), distinct()

1
2
3
4
5
6
7
8
9
private static void streamPractice7() {
  IntStream stream1 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);
  IntStream stream2 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);

  stream1.distinct().forEach(e -> System.out.print(e + " "));
  System.out.println();

  stream2.filter(n -> n%2 != 0).forEach(e -> System.out.print(e + " "));
}
1
2
7 5 2 1 3 4 6 
7 5 5 1 3 5 

distinct()는 중복을 제거한다. filter()는 조건을 만족하는 항목만 통과시킨다. 예제의 filter() 내부에 람다 표현식을 사용하는데, boolean 타입을 리턴하는 함수를 정의한다.

변환: map(), flatMap()

1
2
3
4
private static void streamPractice8() {
  Stream<String> stream = Stream.of("HTML", "CSS", "JAVA", "JAVASCRIPT");
  stream.map(String::length).forEach(e -> System.out.print(e + " "));
}
1
4 3 4 10 

map()은 각 항목에 대한 로직을 작성할 수 있다. 예제 에서는 간단하게 문자열의 길이를 리턴하게 했지만, 반복문 대신 스트림을 사용한다면 map() 메서드를 가장 많이 사용하게 되지 않을까 싶다. 내부 로직이 길어진다면 보기 안 좋으니 함수로 빼는 것이 좋겠다.

1
2
3
4
5
6
private static void streamPractice8() {
  String[] arr = {"Hello", "World", "Happy", "Meet", "Develop"};
  Arrays.stream(arr)
          .flatMap(str -> Stream.of(str.split("")))
          .forEach(c -> System.out.print(c + " "));
}
1
H e l l o W o r l d H a p p y M e e t D e v e l o p 

flatMap()은 항목이 배열이라면 그걸 풀어헤치는 역할을 한다. 예제에서 문자열을 split()을 통해 문자들이 하나하나 분열된 스트림을 만들었고 이를 flatMap()이 하나의 스트림으로 만든다.

제한: limit(), skip()

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void streamPractice10() {
  IntStream stream1 = IntStream.range(0, 10);
  IntStream stream2 = IntStream.range(0, 10);
  IntStream stream3 = IntStream.range(0, 10);

  stream1.skip(4).forEach(n -> System.out.print(n + " "));
  System.out.println();

  stream2.limit(5).forEach(n -> System.out.print(n + " "));
  System.out.println();

  stream3.skip(3).limit(5).forEach(n -> System.out.print(n + " "));
}
1
2
3
4 5 6 7 8 9 
0 1 2 3 4 
3 4 5 6 7 

skip()은 첫 번째부터 전달받은 파라미터만큼 건너뛴 스트림을 만든다.

limit()은 첫 번째부터 전달받은 파라미터만큼의 길이로 이루어진 스트림을 만든다.

정렬: sorted()

1
2
3
4
5
6
7
8
9
private static void streamPractice11() {
  Stream<String> stream1 = Stream.of("Apple", "Zebra", "Banana", "Cup");
  Stream<String> stream2 = Stream.of("Apple", "Zebra", "Banana", "Cup");

  stream1.sorted().forEach(s -> System.out.print(s + " "));
  System.out.println();

  stream2.sorted(Comparator.reverseOrder()).forEach(s -> System.out.print(s + " "));
}
1
2
Apple Banana Cup Zebra 
Zebra Cup Banana Apple 

sorted()는 스트림을 정렬한다. Comparator 인터페이스를 이용하여 정렬할 수도 있다.

연산 결과 확인: peek()

1
2
3
4
5
6
7
8
9
10
11
12
private static void streamPractice12() {
  Stream<String> stream = Stream.of("A", "B", "C", "D", "E", "F", "G");

  stream.peek(s -> System.out.println("원본 : " + s))
          .skip(2)
          .peek(s -> System.out.println("skip(2) 후 : " + s))
          .limit(3)
          .peek(s -> System.out.println("limit(3) 후 : " + s))
          .sorted(Collections.reverseOrder())
          .peek(s -> System.out.println("sorted() 후 : " + s))
          .forEach(System.out::println);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
원본 : A
원본 : B
원본 : C
skip(2) 후 : C
limit(3) 후 : C
원본 : D
skip(2) 후 : D
limit(3) 후 : D
원본 : E
skip(2) 후 : E
limit(3) 후 : E
sorted() 후 : E
E
sorted() 후 : D
D
sorted() 후 : C
C

peek() 메서드는 스트림의 연산과 연산 사이에 결과를 확인(디버깅)할 때 주로 사용한다. 처음에 저 아웃풋이 무엇을 의미하는 건지 이해하기 힘들었다. 연산별로 보면 각 연산 이후의 스트림이 뭐가 되는지 알 수 있었다. 근데 이걸 매 연산 사이마다 peek() 메서드를 실행하니 복잡했다.

저 복잡한 흐름을 자세히 보니 신기한 점이 있다. skip()limit()은 스트림의 항목별로, 그러니까 "A" 따로 "B" 따로 이렇게 지나가는 듯 했다. sorted()는 항목을 쌓아두었다가 정렬하고 통과시키는 느낌이었다.(당연히 정렬하려면 그래야 겠지만) 이와 관련해서 Oracle Java 문서에서 아래의 글을 발견했다.

Intermediate operations are further divided into stateless and stateful operations. Stateless operations, such as filter and map, retain no state from previously seen element when processing a new element – each element can be processed independently of operations on other elements. Stateful operations, such as distinct and sorted, may incorporate state from previously seen elements when processing new elements.

중개연산은 stateless와 stateful로 나뉘어진다. stateless는 filter와 map이 있다. 이것들은 새 항목을 처리할 때 이전 항목의 상태를 유지할 필요 없다. 각 항목들은 독립적으로 처리된다. stateful 연산은 distinct와 sorted가 있다. 이것들은 새 항목을 처리할 때 이전 항목의 상태가 필요할 수 있다.

Stateful operations may need to process the entire input before producing a result. For example, one cannot produce any results from sorting a stream until one has seen all elements of the stream. As a result, under parallel computation, some pipelines containing stateful intermediate operations may require multiple passes on the data or may need to buffer significant data. Pipelines containing exclusively stateless intermediate operations can be processed in a single pass, whether sequential or parallel, with minimal data buffering.

stateful 연산은 결과를 생성하기 전에 전체 입력을 처리해야 한다. 예를 들어, 모든 항목을 보기 전까지 정렬 결과를 얻을 수 없다. 따라서, 병렬 연산을 하려면 중요한 데이터에 대한 multiple pass 또는 buffer가 필요하다. stateless 연산만 있는 pipeline은 순차적이든 병렬적이든 single pass로 처리할 수 있다.

그러니까, sorted()는 stateful 연산이니 모든 항목을 기다린다는 것이다. 그러면 peek()을 통해서 확인한 결과가 설명된다.

Stream의 최종 연산

최종 연산은 Stream의 각 항목들을 모두 소모하여 결과를 준다. 즉, 지연(lazy)되었던 모든 중개 연산들이 여기서 수행된다.

  1. 순회 : forEach()
  2. 소모 : reduce()
  3. 검색 : findFirst(), findAny()
  4. 검사 : anyMatch(), allMatch(), noneMatch()
  5. 통계 : count(), min(), max()
  6. 연산 : sum(), average()
  7. 수집 : collect()

순회: forEach()

forEach()는 앞서 있던 예시들에서도 많이 사용한 메서드이다. 반환 타입이 void이다.

1
2
3
4
private static void streamPractice13() {
  Stream<String> stream = Stream.of("알파", "베타", "찰리", "델타", "에코");
  stream.forEach(System.out::println);
}
1
2
3
4
5
알파
베타
찰리
델타
에코

소모: reduce()

reduce()는 1번 항목하고 2번 항목에 대해서 연산을 하고 그 결과와 다음 항목을 연산하는 방식으로 Stream의 항목들을 소모한다.

1
2
3
4
5
6
7
8
9
private static void streamPractice14() {
  Stream<Integer> stream1 = Stream.of(1, 2, 3, 4, 5);
  Optional<Integer> result1 = stream1.reduce((i, j) -> i + j);
  result1.ifPresent(System.out::println);

  Stream<Integer> stream2 = Stream.of(1, 2, 3, 4, 5);
  Integer result2 = stream2.reduce(0, (i, j) -> i + j);
  System.out.println(result2);
}
1
2
15
15

위 예제는 1부터 5까지 누적한다. 첫 reduce() 사용과 두 번째 reduce() 사용이 다른게 보이는가?

처음 reduce()는 연산에 대해서만 주어졌고, 두 번째 reduce()는 파라미터가 하나 더 있다. 그리고 반환하는 타입도 다르다. reduce()의 파라미터가 2개 일 때, 첫 번째 인자는 초기값으로 본다.

따라서 첫 번째 reduce()는 연산이 어떻냐에 따라서 반환하는 값이 null일 수도 있다. 그래서 Optional을 쓴다. 그에 반해 두 번째 reduce()초기값이 있기 때문에 null을 반환할 일이 없다.

검색: findFirst(), findAny()

findFirst()findAny()나 둘 다 스트림의 첫 번째 항목을 가져오는 메서드이다. Stream에서 어떤 조건을 만족하는 항목을 검색하려고 한다면 filter 메서드와 함께 쓰이게 된다. 검색된 항목이 null일 수도 있으므로(Stream이 비어있을 수 있으므로), Optional객체를 반환한다.

아래는 배열 중 가장 먼저 등장하는 홀수를 찾는 예제이다.

1
2
3
4
5
6
7
8
9
10
private static void streamPractice15() {
  IntStream stream1 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
  IntStream stream2 = IntStream.of(4, 2, 7, 3, 5, 1, 6);

  OptionalInt result1 = stream1.filter((i) -> i % 2 == 1).findFirst();
  System.out.println(result1.getAsInt());

  OptionalInt result2 = stream2.filter((i) -> i % 2 == 1).findAny();
  System.out.println(result2.getAsInt());
}
1
2
7
7

그러면 findFirst()findAny()의 차이는 무엇인가? 이름이 다르다. findFirst()는 말 그대로 첫 번째 항목을 찾는다. findAny()는 말 그대로 아무거나(any) 찾는다.

방금 위에서 설명으로 “첫 번째 항목”을 가져온다고 했지만, 사실 그렇지 않을 수도 있다. Stream을 병렬로 처리한다면 findAny()의 결과는 항상 첫 번째임을 보장하지 않는다.

1
2
3
4
5
6
7
8
9
10
private static void streamPractice15_v2() {
  IntStream stream1 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
  IntStream stream2 = IntStream.of(4, 2, 7, 3, 5, 1, 6);

  OptionalInt result1 = stream1.parallel().filter((i) -> i % 2 == 1).findFirst();
  System.out.println(result1.getAsInt());

  OptionalInt result2 = stream2.parallel().filter((i) -> i % 2 == 1).findAny();
  System.out.println(result2.getAsInt());
}
1
2
7
1

검사: anyMatch(), allMatch(), noneMatch()

  1. anyMatch() : 일부 항목이 조건을 만족할 경우 true
  2. allMatch() : 모든 항목이 조건을 만족할 경우 true
  3. noneMatch() : 모든 항목이 조건을 만족하지 않을 경우 true

세 메서드 모두 Predicate 객체를 인자로 받으며, 결과는 boolean으로 반환한다. Predicate는 매개변수 하나 주면 boolean을 리턴하는 함수형 인터페이스이다. 말로하면 어려우니 예제를 봐보자.

1
2
3
4
5
6
7
private static void streamPractice16() {
  IntStream stream1 = IntStream.of(30, 90, 70, 10);
  IntStream stream2 = IntStream.of(30, 90, 70, 10);

  System.out.println(stream1.anyMatch(n -> n > 80));
  System.out.println(stream2.allMatch(n -> n > 80));
}
1
2
true
false

인자로 받는 Predicate 객체를 보면 boolean을 리턴하는 람다 표현식을 볼 수 있다. Stream의 항목들이 저 람다 표현식의 n으로 들어가면서 조건을 만족하는지 확인하는 것이다. 위 예제에서는 n이 80이상인지에 대한 조건을 걸어서, 하나라도 만족하는지(5번 라인) 또는 모두 만족하는지(6번 라인) 확인하고 있다.

통계: count(), min(), max()

Stream 항목의 개수, 최대, 최소를 구할 수 있다.

1
2
3
4
5
6
7
8
9
private static void streamPractice17() {
  IntStream stream1 = IntStream.of(30, 90, 70, 10);
  IntStream stream2 = IntStream.of(30, 90, 70, 10);
  Stream<Integer> stream3 = Stream.of(30, 90, 70, 10);

  System.out.println(stream1.count());
  System.out.println(stream2.max().getAsInt());
  System.out.println(stream3.min(Integer::compare).get());
}
1
2
3
4
90
10

위 예제는 어렵지 않게 이해할 수 있다. 주목할 만한 점은 IntStreamStream<Integer>의 차이이다. stream2max()에서 인자를 받지 않지만, stream3min() 에서 Comparator를 인자로 받는다.

정수만을 비교하는 예제이기 때문에 IntStream이 유용하지만, 실제로는 클래스를 사용하는 일이 많기 때문에 직접 Comparator를 구현할 일이 많을 것이다.

max()min()Optional 객체를 반환하므로 위 예제처럼 하면 warning이 발생한다. Optional 객체에 대해서 null 검사 없이 바로 값을 가져오기 때문이다. warning을 지우고 싶다면 출력하기 전에 null인지 확인하는 코드를 넣어주면 된다.

연산: sum(), average()

Stream 항목의 합계, 평균을 구할 수 있다. 위에서 reduce() 메서드를 통해서 합계를 계산했었는데, sum()을 이용하여 간단하게 구할 수 있다.

1
2
3
4
5
6
7
private static void streamPractice18() {
  IntStream stream1 = IntStream.of(30, 90, 70, 10);
  DoubleStream stream2 = DoubleStream.of(30.3, 90.9, 70.7, 10.1);

  System.out.println(stream1.sum());
  System.out.println(stream2.average().getAsDouble());
}
1
2
200
50.5

여기서 average() 메서드 역시 Optional 객체를 반환하기 때문에 warning이 발생한다. 만약 warning을 피하고 싶다면 6번 라인을 stream2.average().ifPresent(System.out::println) 이렇게 수정해야한다.

수집: collect()

collect() 메서드는 Stream의 항목을 수집한다. 갑자기 수집? 이라고 할 수 있지만, Stream을 자바의 컬렉션(Collection)으로 변환한다는 말이다.

  1. 배열이나 컬렉션으로 변환 : toArray(), toCollection(), toList(), toSet(), toMap()
  2. 항목의 통계와 연산 메서드와 같은 동작을 수행 : counting(), maxBy(), minBy(), summingInt(), averagingInt()
  3. 항목의 소모와 같은 동작을 수행 : reducing(), joining()
  4. 항목의 그룹화와 분할 : groupingBy(), partitioningBy()

이번엔 예제가 좀 길다. 각 Collectors의 메서드들에 대해서 간단한 예제들을 살펴보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
private static void streamPractice19() {
  Stream<String> stream = Stream.of("알파", "베타", "찰리", "델타", "에코", "찰리");
  List<String> list = stream.collect(Collectors.toList());
  for (String s : list) System.out.print(s + " ");
  System.out.println();

  Stream<String> stream2 = Stream.of("알파", "베타", "찰리", "델타", "에코", "찰리");
  Set<String> set = stream2.collect(Collectors.toSet());
  for (String s : set) System.out.print(s + " ");
  System.out.println();

  Stream<Integer> stream3 = Stream.of(2, 4, 6, 8, 10);
  System.out.println(stream3.collect(Collectors.counting()));  // == stream3.count()

  Stream<Integer> stream4 = Stream.of(2, 4, 6, 8, 10);
  System.out.println(stream4.collect(Collectors.maxBy(Integer::compare)).get());  // == stream4.max(Integer::compare).get()

  Stream<String> stream5 = Stream.of("2", "4", "6", "8", "10");
  System.out.println(stream5.collect(Collectors.averagingInt(Integer::parseInt)));

  Stream<String> stream6 = Stream.of("알파", "베타", "찰리", "델타", "에코", "찰리");
  System.out.println(stream6.collect(Collectors.joining("/")));

  Stream<String> stream7 = Stream.of("A0", "B1", "A2", "B0", "A1", "B2");
  Map<Boolean, List<String>> partition = stream7.collect(Collectors.partitioningBy(s -> s.startsWith("A")));
  List<String> trueList = partition.get(true);
  List<String> falseList = partition.get(false);
  System.out.println(trueList + " " + falseList);

  Stream<String> stream8 = Stream.of("A0", "B1", "A2", "B0", "A1", "B2", "C1");
  Map<Character, List<String>> group = stream8.collect(Collectors.groupingBy(s -> s.charAt(0)));
  List<String> aList = group.get('A');
  List<String> bList = group.get('B');
  List<String> cList = group.get('C');
  System.out.println(aList + " " + bList + " " + cList);
}
1
2
3
4
5
6
7
8
알파 베타 찰리 델타 에코 찰리 
찰리 베타 알파 에코 델타 
5
10
6.0
알파/베타/찰리/델타/에코/찰리
[A0, A2, A1] [B1, B0, B2]
[A0, A2, A1] [B1, B0, B2] [C1]

각 메서드가 어떤 역할을 하는지 예제 코드와 출력되는 것을 보고 쉽게 알 수 있으리라 생각하고 추가설명은 하지 않는다.

위 코드를 보다보니 groupingBy()partitioningBy()를 대체할 수 있을 것으로 보인다. groupingBy()가 있는데 partitioningBy() 쓸 일이 있을까? 이와 관련해서 StackOverflow의 답변을 요약하면 다음과 같다.

큰 차이는 없으나, partitioningBy()는 항상 2개의 entry를 가지는 map을 리턴한다. 만약 빈 스트림이 주어진 경우 partitioningBy()는 2개의 entry를 가진 map을 리턴하고, groupingBy()는 빈 map을 리턴한다.

댓글 남기기