[Java] 자바 스트림(Streams)의 개념과 사용 방법

출처 https://devbksheen.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EC%8A%A4%ED%8A%B8%EB%A6%BCStream%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80

1. 자바 스트림(Streams)이란?

기존의 배열 또는 컬렉션 인스턴스는 for, foreach 같은 반복문을 통해 하나씩 핸들링하는 방식이었기에 로직이 복잡할수록 코드양도 많아지고 루프를 여러 번 도는 경우도 발생하였다. 그에 비해 자바 8에서 추가된 스트림(Streams)은 람다를 사용할 수 있는 기술 중 하나로 다음과 같은 특징을 가지고 있다.

  • 스트림은 데이터의 흐름이라는 뜻으로 컬렉션에 저장되어 있는 요소들을 하나씩 순회하면서 처리할 수 있는 코드패턴이다.
  • 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산관 데이터베이스와 비슷한 연산을 지원한다.
  • 배열/컬렉션의 함수 여러 개를 조합하여 원하는 결과를 필터링, 가공된 결과 추출 가능하며 람다식으로 코드를 간결하게 표현할 수 있다.
  • 하나의 작업을 둘 이상 작업으로 잘게 쪼개 동시에 처리하며 스레드를 이용하여 많은 요소들을 빠르게 처리 가능한다. 
  • 컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조인데 비해, 스트림은 요청할 때만 요소를 계산하는 고정된 자료 구조이다.

스트림에 사용에 대한 상세한 내용은 다음 3가지로 나눌 수 있다.

  • 스트림 생성 - 스트림 인스턴스를 생성한다.
  • 스트림 가공 - 필터링/매핑 등 원하는 결과를 가공한다.
  • 스트림 결과 생성 - 스트림 결과를 만들어 내는 작업을 한다.

2. 스트림 생성

2-1. 배열 스트림 (Array Streams)

Array.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-2. 컬렉션 스트림 (Collection Streams)

컬렉션 인터페이스의 Stream 메서드로 스트림을 생성할 수 있다.

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(); // 병렬 처리 스트림

 

2-3. 비어있는 스트림 (Empty Streams)

요소가 없을 때 null 대신 사용 가능한 비어있는 스트림도 생성이 가능하다. 

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

2-4. 빌더 (Builder)

배열이나 컬렉션을 통한 생성이 아닌 직접 원하는 값을 넣어 생성할 수도 있다. build 메서드를 통해 스트림을 리턴한다.

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

2-5. Generator

Stream.generate()를 사용하면 Supplier <T>에 해당하는 람다로 값을 넣을 수 있다. 생성되는 스트림의 크기는 무한대 이기 때문에, 특정 사이즈로 최대 크기를 제한해야 한다. "Hi"라는 문자열을 5개 만들어 내는 스트림이다. (제한을 5로 걸지 않는다면 무한대로 생성할 것이다.)

Stream<String> generatedStream = 
  Stream.generate(() -> "Hi").limit(5);

2-5. iterate

Stream.iterate()를 사용하면 초기값과 해당값을 다루는 람다를 사용하여 스트림에 들어간 요소를 만든다. 이 또한 generator와 동일하게 크기가 무한대이기에 특정 사이즈로 제한해야 한다. 초기값 30부터 + 2씩 증가하는 스트림이다.

Stream<Integer> iteratedStream = 
  Stream.iterate(30, n -> n + 2).limit(5);

 

2-6. 기본 타입형 Streams

원시타입 (int, long, double) 스트림을 제네릭을 사용하지 않고 직접 다룰 수도 있다. range, rangeClose는 범위의 차이이다. 자바 8의 Randm 클래스는 세 가지 타입의 스트림(IntStream, LongStream, DoubleStream)이 생성 가능하다.

IntStream intStream = IntStream.range(1, 5); // [1, 2, 3, 4]
LongStream longStream = LongStream.rangeClosed(1, 5); // [1, 2, 3, 4, 5]
DoubleStream doubles = new Random().doubles(3); // 난수 3개 생성

2-7. 파일 스트림 (File Streams)

자바 NIO의 Files 클래스는 파일의 각 라인을 스트링타입의 스트림으로 만들어 준다. 다음은 file.txt 파일의 데이터를 utf-8로 인코딩하여 줄 단위로 읽는 스트림이다.

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

2-8.  병렬 스트림 (Parallel Streams)

Stream 대신 parallelStream 메서드를 사용하면 병렬 스트림을 바로 생성할 수 있다. 

// 병렬 스트림 생성
Stream<Product> parallelStream = productList.parallelStream();
// 배열을 이용한 병렬 스트림 생성
Arrays.stream(arr).parallel();

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

 

컬렉션과 배열이 아닌 경우는 parallel 메서드를 사용하여 처리가능하다.

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

혹은 sequentail 메서드로 다시 되돌릴 수 있다. 

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

 

2-9. 스트림 연결 (Concat)

Concat 메서드를 통해 스트림을 합쳐 새로운 스트림을 생성할 수 있다.

Stream<String> stream1 = Stream.of("Java", "Scala", "Groovy");
Stream<String> stream2 = Stream.of("Python", "Go", "Swift");
Stream<String> concat = Stream.concat(stream1, stream2);

3. 스트림 가공

3-1. Filter

// 인자로 받는 predicate는 boolean형을 리턴하는 평가식이 들어가야한다.
Stream<T> filter(Predicate<? super T> predicate);

스트림 내의 요소를 "필터링"하여 원하는 결과만 걸러내는 작업이다. 조건식에 부합하는 요소만 선별한 스트림을 리턴한다. 다음은 "a"를 포함한 데이터만 뽑아낸 스트림 객체를 리턴하는 예제이다.

Stream<String> stream = 
  names.stream()
  .filter(name -> name.contains("a"));

 

3-2. Map

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

스트림 내의 요소를 하나씩 특정값으로 변환한다. 변환하기 위한 람다를 인자로 받는다. 스트림 내의 값이 input이 되어 특정 로직을 거친 후 리턴 스트림에 담기게 된다. 다음은 스트림 내의 요소를 대문자로 치환한 스트림을 리턴한다.

Stream<String> stream = 
  names.stream()
  .map(String::toUpperCase);

3-3. flatMap

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

flatMap 메서드의 인자로 받는 mapper는 리턴 타입이 리턴 타입이 Stream이다. 중첩구조를 한 단계 제거하고 단일 컬렉션으로 만들어주는 역할을 한다. 다음은 중첩된 리스트 예제이다.

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

flatMap으로 한 껍데기를 벗겨내서 조금 플랫 한 리스트로 변경, 중첩 구조를 제거할 수 있다. 

List<String> flatList = 
  list.stream()
  .flatMap(Collection::stream) // (e) -> collection.stream(e)의 축약
  .collect(Collectors.toList());

 

혹은 객체에 적용하게 되면 다음과 같다. 다음은 학생 객체를 가진 스트림에서 점수만 뽑아 새로운 스트림을 만들어 평균을 구하는 작업으로 map 만으로는 한 번에 할 수 없는 기능이다.

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

 

3-4. sorted

Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator); //정렬 시 별도 비교 로직이 있다면

sorted 메서드를 통해 요소를 정렬할 수 있다. 다음과 같이 그냥 호출할 경우 오름차순으로 정렬된다. 정렬할 때 값을 비교하는 별도 로직이 있다면 Comparator를 인자로 넘겨주면 된다.

IntStream.of(14, 11, 20, 39, 23)
  .sorted()
  .boxed()
  .collect(Collectors.toList());

 

3-5. peek

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

특정 결과를 반환하지 않고 스트림 내 요소들 각각에 특정 작업을 수행할 뿐이다. 결과에 영향을 주지 않기에 중간중간 결과를 확인할 때 사용 가능하다.

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

다음과 같은 방식으로 중간 처리과정을 로깅하는 것도 가능하다.

4. 스트림 결과 생성

4-1. 통계값

최소, 최대, 합, 평균 등 기본 형 타입으로 결과를 생성할 수 있다.

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

int max = IntStream.range(1, 10).max();
int min = IntStream.range(1, 10).min();
int avg = IntStream.range(1, 10).average();

스트림이 비어있는 경우 count와 sum은 0을 출력하게 되지만, 최대 최소는 표현할 수 없기에 Optional을 이용해 리턴한다.

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

 

혹은 스트림에서 ifPresent를 사용해 바로 처리할 수 있다.

DoubleStream.of(1.1, 2.2, 3.3, 4.4, 5.5)
  .average()
  .ifPresent(System.out::println);

 

4-2. Reduce

중간 연산을 거친 값들은 reduce 메서드로 결괏값을 생성한다.

reduce는 다음의 3가지 파라미터를 받을 수 있다.

  • accumulator - 각 요소를 처리하는 계산 로직
  • identity - 계산을 위한 초기값 (스트림이 비어서 계산할 내용이 없어도 이 값은 리턴됨)
  • combiner - 병렬 스트림에서 나눠 계산한 결과를 하나로 합치는 로직
// 스트림에서 나오는 값들을 accumulator 함수로 누적
Optional<T> reduce(BinaryOperator<T> accumulator);

// 동일하게 accumulator 함수로 누적하지만 초기값(identity)이 있음
T reduce(T identity, BinaryOperator<T> accumulator);

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

 

먼저 인자를 1개만(accumulator) 받는 경우를 보면, 

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

다음 예제에서는 두 값을 더하는 람다를 넘겨주고 있기에, 배열의 모든 합을 더한 6이 결과가 된다.

다음은 인자가 2개인(accumulator, identity) 경우를 보면

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

최초 값이 10, 스트림 내의 합계 총합계를 더하기에 16을 이 결과가 된다.

 

마지막으로 인자가 3개인(accumulator, identity, combiner) 경우를 보면

Integer reducedParallel = Arrays.asList(1, 2, 3)
  .parallelStream()
  .reduce(10,
          Integer::sum,
          (a, b) -> {
            System.out.println("combiner was called");
            return a + b;
          });

먼저 초기값 10에 각 스트림의 값인 1,2, 3용을 더하여 11,12,13을 생성한다. Combiner는 identity와 accumulator를 가지고 여러 스레드에서 나눠 계산한 결과를 합치기에 11+12+13 = 36을 결과로 반환한다. Combiner는 병렬 처리 시 각 스레드에서 실행한 결과를 마지막에 합치기에 병렬 스트림에서만 작동한다.

4-3. Collect

자바 스트림을 이용하는 가장 많은 패턴 중 하나로써, 요소의 일부를 필터링하고 값을 변형하여 새로운 Collection을 생성한다.

  • Collectors.toList() - 작업결과를 담은 리스트를 반환한다.
  • Collectors.joining() - 스트림 작업 결과를 하나의 String으로 이어서 반환한다. delimeter, prefix, suffix 등을 사용해 문자열을 조합할 수 있다. 다음은 스트림 요소를 [] 안에 쉼표를 기준으로 연결한 스트링을 반환하는 예제이다.
String listToString = 
 productList.stream()
  .map(Product::getName)
  .collect(Collectors.joining(", ", "[", "]"));
  • Collectors.averageingInt() - 숫자값의 평균을 반환한다. 
Double averageAmount = 
 productList.stream()
  .collect(Collectors.averagingInt(Product::getAmount));
  • Collectors.summingInt() - 숫자 값의 합을 반환한다.
  • Collectors.summarizingInt() - 합계와 평균에 대한 정보를 한 번에 반환한다. (IntSummaryStatistics 객체에는 개수, 합계, 평균, 최소, 최대에 대한 정보가 담겨있다.)
IntSummaryStatistics statistics = 
 productList.stream()
  .collect(Collectors.summarizingInt(Product::getAmount));
  • Collectors.groupingBy() - 특정 조건으로 요소들을 그룹 지을 수 있다. 어떤 요소가 얼마나 많이 분포하고 있는지 Map타입으로 반환한다. 같은 수량이면 리스트로 묶어서 반환한다.

4-4. Foreach

스트림에서 반환된 각각의 값에 대해 작업을 하고 싶을 때 사용한다. 특정 값을 리턴하지는 않는다. 다음은 1~999 중 짝수만 출력하는 예제이다. 

Set<Integer> evenNumber = IntStream.range(1, 1000).boxed()
                                    .filter(n -> (n%2 == 0))
                                    .forEach(System.out::println);

4-5. Matching

조건식마다 predicate를 받아서 만족하는 요소가 있는지를 체크한 결과를 리턴한다.

// 하나라도 만족하는 요소가 있는지
boolean anyMatch(Predicate<? super T> predicate);
// 모든 조건을 만족하는지
boolean allMatch(Predicate<? super T> predicate);
// 모든 조건을 만족하지 않는지
boolean noneMatch(Predicate<? super T> predicate);

 

 

 

 

 

 

 

 

참고 

https://futurecreator.github.io/2018/08/26/java-8-streams/

https://hbase.tistory.com/171

https://devbksheen.tistory.com/entry/%EB%AA%A8%EB%8D%98-%EC%9E%90%EB%B0%94-%EC%8A%A4%ED%8A%B8%EB%A6%BCStream%EC%9D%B4%EB%9E%80-%EB%AC%B4%EC%97%87%EC%9D%B8%EA%B0%80