[Java] - Java Stream API

Java 언어가 벌써 11 버전이 나오고 있네요. Java는 역사가 깊고, 오래된 언어이자 비난도 많이 받은 언어입니다. 그렇지만 아직도 많은 곳에서 사용되고 있고, 대체하는 곳도 있습니다.

오늘은 Java 8에서 등장한 Stream에 대해 이야기해보고자 합니다.

 

Stream API

Stream ? 혹시 그거, Buffer 보다 속도가 겁나 빠른 그 Stream ? 네, 그건 아닙니다. Java에서 Stream은 함수형 프로그래밍을 구현하기 위한 기술 중 하나로, Java 8에서 새로이 등장하였습니다.

Java 8 이전에는 배열이나 Collections의 자료 구조 인스턴스를 다루기 위해 for 문이나 foreach 문을 사용하여 요소를 하나씩 꺼내었지요. 간단한 알고리즘을 짜는 것이라면, 큰 상관이 없겠지만 코드가 복잡해지면, 난잡해지고 지저분해지는 건 코드의 양이 풍선처럼 부풀어 오르고, 그러면 가독성도 떨어지게 되죠.

Stream은 이러한 문제점을 개선하고자 등장하였습니다. Stream이 가지고 있는 특징은 아래와 같습니다.

  • 다양한 자료구조 인스턴스를 여러 개 결합하여 결과 도출 가능.
  • Lambda (Java의 함수형 프로그래밍 기법)와 병행 사용하여 코드 간결화.
  • 다중 스레드(Thread)를 이용한 병렬 처리 지원 (Parallel Processing)

특히 저는 첫 번째 특징을 많이 이용하는 편입니다. 물론 가능하면 사용하지 않으려고 하지만, 그 이유는 병렬 처리를 지원한다 하더라도 계산량이 많아지기 때문입니다. 

 

Stream의 기본 사용법

이제 Stream을 어떻게 사용하는지에 대해 알아보겠습니다. Stream을 사용할 때는 아래의 3가지 과정을 따릅니다.

  1. Stream 인스턴스 생성.
  2. 데이터의 가공
  3. 최종 결과 도출.

글로만 설명하면 잘 모를 수 있으니, 몇 가지 예제를 가지고 한 번 알아보도록 하겠습니다. 먼저 다음과 같이 배열로 데이터를 한 개 만들어보도록 하죠.

List<Integer> list = new ArrayList<>();
for (int i = 0; i < 10; i++) {
	list.addAll(List.of(1, 2, 3, 4));
}

ArrayList를 사용하여 위와 같이 1, 2, 3, 4 데이터를 10번 반복하여 넣어봤습니다. 이 데이터들 중 우리는 특정 숫자 몇 개만 뽑아보고 싶다고 한다면, 아래처럼 구상할 것입니다.

  1. 반복문을 사용하여, 리스트를 1번 순회한다.
  2. 결과를 출력하기 위해 새로운 자료 구조를 생성한다.
  3. 반복문 내에 특정 조건을 주고, 해당 조건에 부합하면 해당 자료구조에 값을 삽입한다.

물론 현재는 간단한 자료구조이고, 알고리즘이기 때문에 이런 방법이 크게 지장을 받지 않겠지만, 이 로직이 커지고, 복잡해지고, 데이터가 커지면 난잡해지겠죠?

그래서 우리는 Stream을 사용해 데이터를 가공해야 할 필요가 생깁니다. Stream API를 사용하여 데이터를 가공하면 위의 3가지 과정을 쉽게 마무리 지을 수 있습니다.

list.stream().filter(num -> num > 2)

Stream에서 제공하는 filter 메소드에 Lambda 기법을 사용하면 이렇게 해당하는 숫자 중에서 2보다 큰 숫자들만 필터링 할 수 있습니다. 

그런데, 나는 Stream을 사용하고, 이거를 베열과 같은 자료구조로 도출하고 싶다면? 

Stream<Integer> s = list.stream().filter(num -> num > 2);
System.out.println(Arrays.toString(s.mapToInt(Integer::intValue).toArray()));

그럴 때는 toArray라는 메소드를 사용하여 Array로 변환할 수 있는 방법이 있습니다.

단, 우리는 여기서 데이터 타입을 Integer 객체를 사용했고, 일반적으로 Java Array에서 Integer 데이터 타입을 가질 수 없기 때문에, 우리는 map을 사용해서 이를 intValue 형태로 바꿔주도록 합니다. 이 역시 위 코드와 같이 Lambda를 사용하면 훨씬 깔끔해집니다.

어떤가요? Stream과 Lambda가 있기 전에는 코드가 엄청 복잡해지는 거 때문에 망설였지만, 이렇게 간단하게 Stream을 사용해서 짤 수 있다는 것은 Java 8 이상 버전에서 가지고 있는 매력이죠.

 

Stream을 생성하는 다양한 방법

이 포스트에서는 List를 사용하여 Stream을 생성하고, 이를 필터링하는 거까지 아주 간단하게 알아봤습니다. 하지만 Stream에는 다양한 종류가 있고, 이에 따라서 다양하게 사용될 수 있습니다.

 

1. 배열 스트림 (Array Stream)

배열 스트림은 위에서 사용한 스트림과 비슷하게, 배열을 생성 후,  Arrays.stream 메소드를 호출하여 사용할 수 있습니다.

String[] arr = new String[]{"a", "b", "c"};
Stream<String> stream = Arrays.stream(arr);
Stream<String> streamOfArrayPart = 
Arrays.stream(arr, 1, 3); // 1~2 요소 [b, c]

 

2. 컬렉션 스트림 (Collection Stream)

Java에서 컬렉션은 Collection, List, Set 등의 자료구조를 가지고 있고, 이들은 전부 인터페이스에 추가된 메소드를 통하여 스트림을 만들 수 있습니다.

public interface Collection<E> extends Iterable<E> {
  default Stream<E> stream() {
    return StreamSupport.stream(spliterator(), false);
  } 
  // ...
}
List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
Stream<String> parallelStream = list.parallelStream(); // 병렬 처리 스트림

Collections에서 제공하는 기본 스트림은 위와 같이 정의되어 있기 때문에, 사용하실 때 참고하면 아주 도움이 될 것 같네요.

 

3. 빈 스트림 (Empty Stream)

가끔 코딩할 때, NULL로 되어 있는 자료구조를 생성할 필요가 생기죠. Stream 역시 사용할 수 있습니다.

public Stream<String> streamOf(List<String> list) {
  return list == null || list.isEmpty() 
    ? Stream.empty() 
    : list.stream();
}

그럼 이렇게 빈 값으로 뒀다가 나중에 어떻게 초기화 할 수 있을까요? 그 방법도 여러가지 존재하는데, 하나씩 살펴보도록 하죠.

Stream<String> builderStream = 
  Stream.<String>builder()
    .add("Eric").add("Elena").add("Java")
    .build(); // [Eric, Elena, Java]

먼저 Stream에서 제공하는 builder 메소드를 사용하는 방법입니다. 마지막에 build 메소드를 최종적으로 호출함으로써 스트림을 반환하게 되기 때문에, add 이후에 반드시 참고하도록 합시다.

public static<T> Stream<T> generate(Supplier<T> s) { ... }

generate 메소드를 사용하면, Generic을 사용해서 원하는 데이터 타입의 값을 Lambda 식을 이용하여 삽입할 수 있습니다. 

Stream<String> generatedStream = 
  Stream.generate(() -> "gen").limit(5); // [el, el, el, el, el]

이 때, 생성되는 스트림은 크기가 정해져 있지 않고, 무한하기 때문에, 특정 사이즈를 정의해줘야 한다는 점을 명심해야 합니다.

Stream<Integer> iteratedStream = 
  Stream.iterate(30, n -> n + 2).limit(5); // [30, 32, 34, 36, 38]

마지막으로 iterate 메소드를 사용한 방법입니다. 초기값과 해당 값을 다루는 Lambda를 이용해서 스트림에 들어갈 요소를 만들어주는 메소드입니다. 숫자를 다룰 때 가장 편한 방법이지만, 이도  generate와 같이 스트림이 무한이기 때문에 특정 사이즈를 정의해줘야 합니다.

 

4. 기본 타입형 스트림 (Default-type Stream)

사실 제네릭을 사용하게 되면, 기본 타입을 제공하는 스트림이 필요없지 않을까 싶은데요. 나는 제네릭이 싫다거나 쓰기가 어려워서 기피하고 있다. 하시는 분들(?)이 계신다면, 제네릭을 사용하지 않고도 스트림을 만들 수 있습니다.

IntStream intStream = IntStream.range(1, 5); // [1, 2, 3, 4]
LongStream longStream = LongStream.rangeClosed(1, 5); // [1, 2, 3, 4, 5]

단, 제네릭을 사용하지 않기 때문에 오토박싱(auto-boxing)은 일어나지 않겠죠? 그렇기 때문에 필요할 때는 boxed 메소드를 제공해주므로, 이를 사용하면 됩니다.

Stream<Integer> boxedIntStream = IntStream.range(1, 5).boxed();

Java 8에서 제공하는 Random 클래스는 난수를 가지고 3가지 타입의 스트림 (Int, Long, Double)을 만들 수 있어 굉장히 이점이 큰데요. 무엇보다 난잡한 코드 없이도 난수를 생성할 수 있다는 점은 참 매력있는 개선이죠.

DoubleStream doubles = new Random().doubles(3); // 난수 3개 생성

 

5. 문자열 스트림 (String Stream)

혹시 C++ 언어를 사용하고 계신다면, StringStream이 좀 많이 익숙하실텐데, 사실 저는 C++의 StringStream을 StringBuilder 대용으로 사용하고 있고, Java 8에서 제공하는 String Stream은 이와 다릅니다.

Stream<String> stringStream = 
  Pattern.compile(", ").splitAsStream("Eric, Elena, Java");
  // [Eric, Elena, Java]

간단히 생성에 대해서만 이야기 하자면, 정규식을 이용하여 문자열 자르고, 각 요소들로 스트림을 만들 수 있습니다. 혹은 lines 메소드를 이용하거나 chars 메소드를 이용해서 IntStream으로 변환하는 것도 가능합니다.

IntStream charsStream = 
  "Stream".chars(); // [83, 116, 114, 101, 97, 109]

위의 코드가 가능한 이유는 아시다시피 Java의 char 데이터가 아스키 코드를 따르고 있다는 점입니다.

 

6. 파일 스트림 (File Stream)

Java NIO의 File 클래스의 lines 메소드는 해당 파일의 각 라인을 스트림 타입의 스트림으로 만들어줍니다.

Stream<String> lineStream = 
  Files.lines(Paths.get("file.txt"), 
              Charset.forName("UTF-8"));

사실 Java 8 이전 버전에서 파일의 내용을 가져오려고 할 때, 사용했던 방법은 보통 BufferedReader를 사용하는 방식이었습니다. BufferedReader는 Scanner에서 키보드 입력을 받아올 때보다도 그 처리 속도가 빠른 편이어서 사용했었는데요.

그런데, BufferedReader는 .try-catch 문을 사용해서 Exception 처리를 해줘야 하는 등 코드가 복잡해지기 때문에 사실상 어떻게 보면 파일 스트림은 굉장히 유용하다고 볼 수 있을 것 같네요.

 

7. 병렬 스트림 (Parallel Stream)

마지막으로 병렬 스트림은 여러 개의 스레드를 사용하여 스트림을 처리하는 클래스입니다. JVM 내에서는 Java 7에서 도입된 Fork/Join Framework를 사용합니다.

// 병렬 스트림 생성
Stream<Product> parallelStream = productList.parallelStream();

// 병렬 여부 확인
boolean isParallel = parallelStream.isParallel();

리스트에 들어있는 데이터가 작으면 프로세스 모니터로 확인이 어렵기 때문에 이 때는 Stream에서 제공하는 isParallel 메소드를 통해 병렬 스트림인지 확인할 수 있습니다.

boolean isMany = parallelStream
  .map(product -> product.getAmount() * 10)
  .anyMatch(amount -> amount > 200);

더 많은 데이터를 움직여보기 위해서는 위 코드를 실행해보면 좋을 것 같네요.

Arrays.stream(arr).parallel();

배열을 사용한다면, 위와 같이 parallelStream이 아닌 stream에 parallel 메소드를 호출하여 사용할 수 있습니다.

IntStream intStream = IntStream.range(1, 150).parallel();
boolean isParallel = intStream.isParallel();

반대로 컬렉션이나 배열 어느쪽에도 속하지 않는다면, 위 코드를 사용하면 됩니다.

IntStream intStream = intStream.sequential();
boolean isParallel = intStream.isParallel();

병렬 처리를 더 이상 하지 않고 싶다면, sequential 메소드를 사용하면 끝. 생각보다 처리가 쉽군요.

 

Stream으로 데이터를 가공하는 방법.

여러 가지 Stream에 대해 알아봤습니다. 그들 Stream은 우리가 한 데이터 타입을 사용할 수 있도록 하고, 또한 병렬 처리를 할 수 있도록 도와줍니다. 

그런데, 스트림에서 병렬 처리는 굉장히 유용하였지만, 다른 것은 Collections나 Array에 비해 별반 다를 게 없었죠. 하지만 Stream의 꽃은 바로 가공과 결과 도출입니다. 

특히 우리가 데이터를 가공할 때는 많은 반복문과 복잡한 로직들 때문에 코드의 가독성을 점점 떨어뜨리고, 어려운 코딩을 수행해야 했죠. 하지만 Stream은 간단한 문법과 단순하고 직관적인 Lambda와 함께 사용한다면, 데이터의 가공도 쉽게 할 수 있습니다.

 

1. Filtering

필터는 Collections, Arrays에서 내가 원하는 요소만을 뽑아줄 수 있는 아주 유용한 가공 방법입니다. 파라미터로 받게 되는 Predicate는 boolean 값을 리턴 받는 함수형 인터페이스입니다.

Stream<T> filter(Predicate<? super T> predicate);
Stream<String> stream = 
  names.stream()
  .filter(name -> name.contains("a"));
// [Elena, Java]

기본적으로 각 모든 스트림에 제공되기 때문에 위의 예제가 아니고도 Array나 ArrayList에서 원하는 값을 추출할 수 있습니다. 대표적으로 기본 사용법에 기재했던 방식대로 Filtering 하는 것도 가능합니다.

 

2. Mapping

map 또한 기본 예제에서 봤다시피 특정 데이터 타입으로 데이터를 변형해주거나 특정 데이터로 변형시킬 수 있는 역할을 합니다.

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

값을 변환하기 위한 인자값은 Lambda로 받게 되며 스트림에 들어있는 값이 input 값이 되고, 특정 로직을 거친 후, output 되어 반환하는 데, 이 때 새로운 스트림이 생성되어 반환됩니다. 

Stream<String> stream = 
  names.stream()
  .map(String::toUpperCase);
// [ERIC, ELENA, JAVA]

문자열 처리에 대해서는 위와 같이 대문자로 값을 변경할 수 있습니다. 여기서 String::toUpperCase는 Lambda에서 클래스의 메소드를 불러오기 위한 Lambda 식을 쓴 것입니다.

Stream<Integer> stream = 
  productList.stream()
  .map(Product::getAmount);
// [23, 14, 13, 23, 13]

더 나아가서는 객체의 메소드를 불러와서 Getter에도 응용할 수 있습니다.

<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

map보다도 복잡한 flatMap도 존재합니다. 인자로 mapper를 받고 있으며, 리턴 타입이 Stream입니다. 새로운 스트림을 생성하여, 리턴하는 Lambda 식을 작성해야 한다는 점이 기존 Map과 다릅니다.

flatMap은 중첩 구조를 한 단계 제거하고, 단일 컬렉션으로 만들어주는 역할을 합니다. 이러한 작업을 Flattening이라 한다.

List<List<String>> list = 
  Arrays.asList(Arrays.asList("a"), 
                Arrays.asList("b"));
// [[a], [b]]

예를 들어, 이렇게 리스트가 중첩되어 있다고 한다면,

List<String> flatList = 
  list.stream()
  .flatMap(Collection::stream)
  .collect(Collectors.toList());
// [a, b]

flatMap을 사용해서 중첩 구조를 제거한 후, 작업할 수 있습니다. 사실 Stream을 중첩된 자료구조에서 이용한 적이 많이 없었는데, 꽤 유용해 보입니다.

students.stream()
  .flatMapToInt(student -> 
                IntStream.of(student.getKor(), 
                             student.getEng(), 
                             student.getMath()))
  .average().ifPresent(avg -> 
                       System.out.println(Math.round(avg * 10)/10.0));

이를 객체에도 응용할 수 있는데, 객체에 존재하는 국어, 영어, 수학의 점수를 평균을 구하는 식으로, 사실상 데이터 자체로 중첩이 되기 때문에, 일반적인 Map으로는 구현이 불가능하지만, flatMap에서는 이렇게 구현할 수 있죠.

 

3. Sorting

Stream에서는 정렬 기능도 제공합니다. 일반적으로 사용하는 Java의 정렬과 마찬가지로 Comparator를 이용합니다.

Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);
IntStream.of(14, 11, 20, 39, 23)
  .sorted()
  .boxed()
  .collect(Collectors.toList());
// [11, 14, 20, 23, 39]

따라서 인자 없이 호출하면 그냥 오름차순으로 정렬합니다.

List<String> lang = 
  Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift");

lang.stream()
  .sorted()
  .collect(Collectors.toList());
// [Go, Groovy, Java, Python, Scala, Swift]

lang.stream()
  .sorted(Comparator.reverseOrder())
  .collect(Collectors.toList());
// [Swift, Scala, Python, Java, Groovy, Go]

인자를 넘길 경우에는 어떨까요? lang 리스트에서 알파벳 순으로 정렬한 코드와 Comparator를 넘겨서 역순으로 정렬한 코드를 비교해보면, 정렬 알고리즘을 직접 작성하거나 할 경우를 제외하고, 오름차순으로 정렬할 때는 그렇게 많은 차이가 있지 않네요.

int compare(T o1, T o2)

그럼 우리가 직접 구현해본다면 어떻게 해야할까요? 일단 Comparator의 기본 사용법과 동일하기 때문에, o1과 o2를 비교하는 식으로 작성하면 될 것입니다.

lang.stream()
  .sorted(Comparator.comparingInt(String::length))
  .collect(Collectors.toList());
// [Go, Java, Scala, Swift, Groovy, Python]

lang.stream()
  .sorted((s1, s2) -> s2.length() - s1.length())
  .collect(Collectors.toList());
// [Groovy, Python, Scala, Swift, Java, Go]

문자열의 길이로 계산하여 그것이 긴 순으로 나열할 떄는 이렇게 쓰면 되겠군요.

 

4. Iterating

스트림 내 요소들 각각을 대상으로 특정 연산을 수행하는 메소드로는 peek이 있습니다. 아마 Java에서 제공하는 Stack을 사용해봤다면, 이 peek과 pop에 대해서 헷갈려하는 분들이 많으실 것입니다. peek 메소드는 'Just Checking'이라는 의미 그대로, 특정 결과를 반환하지 않는 함수형 인터페이스 Consumer를 인자로 받게 됩니다.

Stream<T> peek(Consumer<? super T> action);

따라서 스트림 내 요소들 각각에 특정 작업을 수행할 뿐 결과에 영향을 미치지 않습니다. 

int sum = IntStream.of(1, 3, 5, 7, 9)
  .peek(System.out::println)
  .sum();

peek을 주게 되면, peek 내에는 이후 취하게 될 Lambda 식을 작성하면 됩니다. 위와 같이 진행할 경우, 스트림 내에 있는 요소를 모두 출력한 다음, 합계를 출력하게 됩니다.

 

Stream으로 가공된 결과를 계산.

이제 원하는 데이터들로만 가공하였으니, 결과를 도출하기 위해 이들을 처리해봅시다. 이 이후에는 스트림을 더 이상 사용하지 않게 되고, 기본 예제에서 봤듯이 Array, List 등 타 자료구조들로 변환할 수 있습니다.

 

1. Calculating

단순 계산으로 최소, 최대, 합, 평균 등 기본형 타입으로 결과를 만들어 낼 수 있습니다.

long count = IntStream.of(1, 3, 5, 7, 9).count();
long sum = LongStream.of(1, 3, 5, 7, 9).sum();

만약 스트림에 아무런 데이터도 없는 경우, 이들은 0으로 표시됩니다.

OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min();
OptionalInt max = IntStream.of(1, 3, 5, 7, 9).max();

하지만 min, max 메소드는 최솟값, 최댓값을 표현하는 것이기 때문에 대상이 없으므로 Optional 인터페이스를 이용해 리턴해야겠네요.

Optional

Java 8에서 제공하는 클래스 중 하나로, NPE(NullPointerException)을 방지하기 위해 사용하는 클래스입니다.
제네릭을 가지고 있어, 다양한 데이터 타입에 이용할 수 있고, try-Catch, if 조건문에 비해 Lambda 식을 사용할 수 있어, 직관적인 문법을 표현할 수 있습니다.
DoubleStream.of(1.1, 2.2, 3.3, 4.4, 5.5)
  .average()
  .ifPresent(System.out::println);

더불어 Stream에서 사용가능한 ifPresent 메소드를 같이 사용한다면, 그야말로 금상첨화라 할 수 있죠.

 

2. Reduction

Stream은 reduce라는 메소드를 이용해 결과를 만들어내는데, reduce는 총 3가지의 파라미터를 받을 수 있습니다.

  • accumulator: 각 요소를 처리하는 계산 로직으로, 각 요소가 올 때마다 중간 결과를 생성함.
  • identity: 계산을 위한 초기값으로 스트림이 비어서 계산할 내용이 없더라도 쓰레기값을 리턴.
  • combiner: 병렬(parallel)스트림에서 나눠 계산한 결과를 하나로 합치는 로직.
// 1개 (accumulator)
Optional<T> reduce(BinaryOperator<T> accumulator);

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

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

 

인자를 모두 넣어야 하는 것은 아닙니다. 단, BinaryOperator는 필수인데, BinaryOperator는 같은 타입의 인자를 두 개 받아, 같은 타입의 결과를 반환하는 함수형 인터페이스로 결과를 도출하기 위한 파라미터입니다.

OptionalInt reduced = 
  IntStream.range(1, 4) // [1, 2, 3]
  .reduce((a, b) -> {
    return Integer.sum(a, b);
  });

IntStream에서 제공하는 range 메소드를 이용해 1~4까지의 데이터를 생성하고, 이들을 더하게 될 경우, 중간에 결과를 생성하기 때문에 a + b 가 이루어진 합, 이를 a로 주고, 다시 a + b 이렇게 계산이 되는 것이죠.

int reducedTwoParams = 
  IntStream.range(1, 4) // [1, 2, 3]
  .reduce(10, Integer::sum); // method reference

두 개의 인자를 받게 될 경우에는, 초기값을 정하게 됩니다. 바로 Identity인데요. 여기에 숫자를 넣으면 10 + 1 + 2 + 3의 결과가 나오게 됩니다.

Integer reducedParams = Stream.of(1, 2, 3)
  .reduce(10, // identity
          Integer::sum, // accumulator
          (a, b) -> {
            System.out.println("combiner was called");
            return a + b;
          });

마지막으로 Combiner 파라미터는 병렬 스트림에서만 동작하는 파라미터입니다. 각자 다른 스레드에서 실행한 결과를 마지막에 합치게 됩니다.

 

반드시 알아둬야할 것은 병렬 처리가 작은 데이터에 있어서는 오히려 비효율적일 수 있다는 것입니다. 이는 R이 되었든 Python에서 데이터 처리를 하든 똑같이 적용됩니다.

 

3. Collecting

바로 이 메소드들이 위에서 처리한 계산을 List나 Array로 변환시키는 등의 역할을 하게 됩니다. Collector 타입의 인자를 받아서 처리하게 되고, 자주 사용하는 작업은 Collectors 객체에서 제공하고 있습니다.

List<Product> productList = 
  Arrays.asList(new Product(23, "potatoes"),
                new Product(14, "orange"),
                new Product(13, "lemon"),
                new Product(23, "bread"),
                new Product(13, "sugar"));

예를 들어, 위와 같이 Product 객체를 만들었고, 이 객체에는 수량(amount)와 이름(name)이 담겨져 있다고 가정하고, 이 객체에 대한 리스트를 만들어봤습니다.

List<String> collectorCollection =
  productList.stream()
    .map(Product::getName)
    .collect(Collectors.toList());
// [potatoes, orange, lemon, bread, sugar]

Stream에서 작업한 결과를 리스트로 반환한 코드인데요. 먼저 스트림으로 불러와서, 이를 이름만 가져와 리스트로 저장한 것입니다.

String listToString = 
 productList.stream()
  .map(Product::getName)
  .collect(Collectors.joining());
// potatoesorangelemonbreadsugar

이 외에도 리스트에 있는 이름들을 이렇게 하나의 String 형태로 붙일 수도 있습니다. 그런데, 그냥 붙이는 기능만 있는 걸까요? 그렇지는 않고, joining 메소드에서, 3개의 인자를 담을 수가 있는데, 이를 이용해서 좀 더 이쁘게 처리할 수 있죠.

String listToString = 
 productList.stream()
  .map(Product::getName)
  .collect(Collectors.joining(", ", "<", ">"));
// <potatoes, orange, lemon, bread, sugar>
  • delimiter: 각 요소 중간에 들어갈 구분자.
  • prefix: 결과 맨 앞에 붙는 접두사.
  • suffix: 결과 맨 뒤에 붙는 접미사

이렇게, 한 요소에 있는 이름을 데이터로 도출해낼 수도 있습니다.

Integer summingAmount = 
 productList.stream()
  .collect(Collectors.summingInt(Product::getAmount));
// 86
Double averageAmount = 
 productList.stream()
  .collect(Collectors.averagingInt(Product::getAmount));
// 17.2

숫자의 합과 평균을 나타낼 수도 있습니다.

Integer summingAmount = 
  productList.stream()
  .mapToInt(Product::getAmount)
  .sum(); // 86

더욱이 리스트에 들어가게 되면, 수량(amount)는 Integer가 되어지기 때문에 수량에 대한 값을 가져오게 된다면, 반드시 mapping 과정을 통해서 Int로 매핑한 후, 총합을 구해야겠죠?

IntSummaryStatistics statistics = 
 productList.stream()
  .collect(Collectors.summarizingInt(Product::getAmount));

사실 이보다 더 좋은 방법으로, 위의 정보들을 한 번에 합계와 평균, 갯수, 최대, 최소값을 한 줄에 담아줍니다. 이렇게 받아온 IntSummaryStatistics 객체에는 아래와 같이 출력됩니다.

IntSummaryStatistics {count=5, sum=86, min=13, average=17.200000, max=23}
  • 개수 getCount()
  • 합계 getSum()
  • 평균 getAverage()
  • 최소 getMin()
  • 최대 getMax()

이 점이 좋은 점은 collect 전에 map을 호출할 필요가 없죠. average나 sum, summarizing은 각 기본 타입인 int, long, double로 제공합니다.

Map<Integer, List<Product>> collectorMapOfLists =
 productList.stream()
  .collect(Collectors.groupingBy(Product::getAmount));

특정 조건으로 요소들을 그룹지을 수 있는 Grouping도 제공합니다. 여기서 받는 인자는 함수형 인터페이스 Function입니다. 따라서  groupingBy 파라미터에 Lambda 식을 넣어주면 되겠죠.

{23=[Product{amount=23, name='potatoes'}, 
     Product{amount=23, name='bread'}], 
 13=[Product{amount=13, name='lemon'}, 
     Product{amount=13, name='sugar'}], 
 14=[Product{amount=14, name='orange'}]}

결과는 Map으로 반환하게 되고, 만약, 같은 수량인 경우 그 value가 리스트로 보여집니다.

Map<Boolean, List<Product>> mapPartitioned = 
  productList.stream()
  .collect(Collectors.partitioningBy(el -> el.getAmount() > 15));

groupingBy가 어떤 데이터 타입에 의헤 묶어졌다면, partitioningBy는 어떤 조건에 의해 묶어지는 메소드입니다. 사용 방법은 GroupingBy랑 똑같이 Function 인터페이스이고, Lambda 식을 넣어주면 됩니다.

{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'}]}

반환 결과 또한 GroupingBy와 같이 Map으로 반환하며 이 때, 조건에 부합한 경우, true 그렇지 않으면 false 쪽을 바라봅니다.

public static<T,A,R,RR> Collector<T,A,RR> collectingAndThen(
  Collector<T,A,R> downstream,
  Function<R,RR> finisher) { ... }

try-Catch 다음에는 finally가 있죠. Stream에서도 Collecting 다음에 이어서 또 다른 작업을 추가로 할 수 있습니다. 그 메소드가 바로 collectingAndThen입니다.

위 함수를 보면, collect 이후에 finisher라는 파라미터를 추가하여 함수를 정의할 수 있죠.

Set<Product> unmodifiableSet = 
 productList.stream()
  .collect(Collectors.collectingAndThen(Collectors.toSet(),
                                        Collections::unmodifiableSet));

Collectors.toSet을 이용해서 결과를 Set으로 collect 한 후, 수정 불가능한 Set으로 변환하는 작업을 초가로 실행할 수 있습니다.

public static<T, R> Collector<T, R, R> of(
  Supplier<R> supplier, // new collector 생성
  BiConsumer<R, T> accumulator, // 두 값을 가지고 계산
  BinaryOperator<R> combiner, // 계산한 결과를 수집하는 함수.
  Characteristics... characteristics) { ... }

마지막으로 직접 Collector를 만들 수도 있습니다. 여기에 있는 accumulator와 combiner는 reduce 메소드와 동일합니다.

Collector<Product, ?, LinkedList<Product>> toLinkedList = 
  Collector.of(LinkedList::new, 
               LinkedList::add, 
               (first, second) -> {
                 first.addAll(second);
                 return first;
               });

Product라는 클래스를 담고 있는 Collector를 생성하는 코드입니다.

코드를 잘 보면, LinkedList를 하나 생성하고, 이를 add 메소드를 사용하여 추가하고, 다음에 나오는 Combiner의 파라미터는 first는 LinkedList, second는 Product 객체가 되겠습니다. 그리고, 반환하는 것을 LinkedList로 해주면 Collector가 완성이 되는 것이죠.

따라서 이 Collector는 스트림 각 요소에 대해 LinkedList를 만들고, 요소를 추가합니다. 그리고 마지막 Combiner를 통해 이들을 결합하게 되죠. 

LinkedList<Product> linkedListOfPersons = 
  productList.stream()
  .collect(toLinkedList);

이렇게 collect 메소드에 위에서 만든 커스텀 Collector를 넣어주면, 결과가 담긴 LinkedList가 반환되겠죠. 좀 더 효율적으로 코드를 작성한다면, 커스텀 Collector도 매우 쓸만하겠군요.

 

4. Matching

Matching은 조건식에 충족하는 값만을 불러오기 위한 메소드로 조건식 Lambda인 Predicate를 받아내 해당 조건을 만족하는 요소가 있는지를 체크합니다.

다음과 같은 세 가지 메소드가 있습니다.

  • 하나라도 조건을 만족할 경우 (anyMatch)
  • 모두 조건을 만족할 경우 (allMatch)
  • 모두 조건을 만족하지 않을 경우 (noneMatch)
boolean anyMatch(Predicate<? super T> predicate);
boolean allMatch(Predicate<? super T> predicate);
boolean noneMatch(Predicate<? super T> predicate);

아주 간단하기 때문에 조건만 잘 맞춰준다면, 유용하게 쓸 수 있습니다.

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"));

String에 있는 기본 메소드만을 사용하였습니다. 1번째는 a 알파벳이 포함된 단어가 있을 경우, 두 번째는 3글자 이상, 세 번째는 s로 끝나는 알파벳이 있는 경우이죠.

 

5. Iterating

Iterating은 스트림 내에 있는 데이터들을 foreach 문을 돌면서 실행하는 최종 작업입니다.

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

음 그런데, 이 작업은 peek 메소드를 사용한 거랑 똑같지 않나요? 아닙니다. peek은 계산한 결과의 중간 과정을 도출하는 작업이었디면, 이 작업은 모든 계산 결과에서 최종 값을 도출해주기 때문에, 만약 데이터를 사용해보지 않고, 결과가 제대로 나오는지 확인할 때 주로 사용합니다.

물론, 여기서 마지막 작업으로 한 번 더 진행하는 것으로 Finally 할 수 있지만, 그렇게는 보통 사용하지 않습니다.

 

마치며...

여기까지 Java 8의 Stream에 대해서 알아봤습니다. 정말 길면서도 유용한 점이 많았던 스트림입니다만 잘 알고 쓰는 것과 모르고 쓰는 것은 엄연히 차이가 있기 때문에 가능한 많은 내용을 다뤄봤습니다.

특히 Stream에 대해서는 예전부터 계속 써오고 있었지만 너무나 많은 내용들이 담겨져 있어 정리하기도 어려웠고, 어떻게 장점을 잘 표현할 수 있을까도 많이 고민이 되었던 파트였습니다. 이 글을 통해서 Java를 좀 더 쉽고, 직관적으로 코딩할 수 있도록 하는 데 이바지 되었으면 좋겠습니다. 

 

Ref: Java 스트림 Stream (1) 총정리

comments powered by Disqus

Tistory Comments 0