Stream API

Mugeon Kim·2023년 9월 9일
0

서론


  • 자바를 이용한 개발을 하면서 Stream을 자주 사용을 하였습니다. 최근 과제 전형을 하면서 기존에 자주 사용한 map, filter 이외에 추가적인 연산을 통해 쉽게 해결을 하여서 Stream에 대하여 더욱 자세하게 학습한 내용을 정리를 하기 위해서 작성을 하였습니다.

본론


1. Stream API

  • Stream은 자바 8부터 도입이 되었습니다. 스트림은 데이터 처리 및 변환 작업을 간편하게 수행할 수 있도록 도와주는 기능입니다.

  • Stream API는 다음과 같은 핵심 개념이 있습니다.

    1. 스트림 생성 : 스트림은 데이터 소스로부터 생성을 합니다. 자바의 컬렉션, 배열, 파일 등 다양한 데이터 소스에서 스트림을 생성할 수 있습니다.
    2. 스트림 연산 : 스트림에서 데이터를 처리하기 다양한 연산을 제공합니다. 이러한 연산은 중간 연산과 최종 연산으로 분리할 수 있습니다. 중간 연산은 스트림을 변환 , 필터를 하고 최종 연산은 스트림의 결과를 도출하거나 수집을 합니다.
    3. 병렬 처리 : Stream API는 멀티코어 프로세서를 활용하여 데이터를 병렬로 처리할 수 있는 기능을 제공합니다.
    4. 지연 연산 : 스트림 연산은 지연 연산을 통해 처리됩니다. 이는 필요한 경우에만 데이터를 처리하고 중간, 최종 연산을 적절하게 조합하여 성능을 최적화 가능하다.

2. Java Stream API 살펴보기

개인적인 번역을 해보고 이해한 내용을 기반으로 쉽게 바꾸어 작성을 하였습니다.

A sequence of elements supporting sequential and parallel aggregate operations. The following example illustrates an aggregate operation using Stream and IntStream:
In this example, widgets is a Collection. We create a stream of Widget objects via Collection.stream(), filter it to produce a stream containing only the red widgets, and then transform it into a stream of int values representing the weight of each red widget. Then this stream is summed to produce a total weight.

     int sum = widgets.stream()
                    .filter(w -> w.getColor() == RED)
                    .mapToInt(w -> w.getWeight())
                    .sum();
  • 순차 및 병렬 집계 작업을 지원하는 일렬의 요소이다. 이 예는 widgets에서 stream을 통해 객체 스트림을 생성하고 filter를 통해 필터링을 수행합니다. 여기서 == RED 조건을 만족하는 요소만 스트림에 포함하며 mapToInt를 통해 가중치를 추출을 합니다. 이때 int 형식으로 매핑하며 결과적으로 int 값들의 스트림을 생성합니다. 이후 int 값들의 스트림 내의 모든 요소의 총합을 합산한다.

To perform a computation, stream operations are composed into a stream pipeline. A stream pipeline consists of a source (which might be an array, a collection, a generator function, an I/O channel, etc), zero or more intermediate operations (which transform a stream into another stream, such as filter(Predicate)), and a terminal operation (which produces a result or side-effect, such as count() or forEach(Consumer)). Streams are lazy; computation on the source data is only performed when the terminal operation is initiated, and source elements are consumed only as needed.

  • 스트림 파이프라인은 계산을 수행하기 위한 일련의 단계를 나타냅니다. 이 단계에는 데이터를 가져오는 소스, 데이터를 변환하는 중간 작업, 그리고 결과를 생성하거나 부작용을 일으키는 종단 작업이 포함됩니다. 스트림은 게으르게 동작하며, 실제 계산은 종단 작업이 시작될 때만 수행되며 필요한 데이터만 처리합니다.

변역을 하면서 궁금한 부분
1. 부작용을 일으키는 종단 작업

  • 스트림 파이프라인의 종단 작업 중에서 결과를 생성하는 것뿐만 아니라, 추가적인 동작이나 변경을 일으킬 수 있는 작업을 가리킵니다. 이러한 작업은 주로 데이터를 처리하거나 출력하는 데 사용됩니다.
    ex) foreach, collect, toArray()
  • 즉 부작용을 일으키는 종단 작업은 스트림 파이프라인에서 최종 결과를 생성하거나 외부 상태를 변경하는 작업을 의미를 합니다.
  1. 스트림은 게으르게 동작
  • 스트림은 게으르게 동작은 쉽게 말하면 데이터 처리할 때 지연되어 실행을 의미를 합니다. 즉. 데이터를 한번에 처리하지 않고 필요한 시점에만 데이터를 처리하며, 그렇지 않으면 처리되지 않습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
               .filter(n -> n % 2 == 0)
               .mapToInt(Integer::intValue)
               .sum();
  • 이 코드에서 numbers 리스트를 스트림으로 변환 -> filter -> mapToInt와 같은 중간 작업을 수행 -> sum 종단 작업을 사용하여 짝수 숫자의 합을 계산을 합니다.

    • 스트림이 게으르게 동작하기 때문에 filter, mapToInt 작업은 필요한 순간에만 실행이 됩니다.
    • 필요한 순간에만 처리하고 불필요한 계산을 피한다.

Collections and streams, while bearing some superficial similarities, have different goals. Collections are primarily concerned with the efficient management of, and access to, their elements. By contrast, streams do not provide a means to directly access or manipulate their elements, and are instead concerned with declaratively describing their source and the computational operations which will be performed in aggregate on that source. However, if the provided stream operations do not offer the desired functionality, the BaseStream.iterator() and BaseStream.spliterator() operations can be used to perform a controlled traversal.

  • 컬렉션은 데이터 요소의 관리와 액세스에 중점을 두며, 스트림은 데이터 소스와 데이터에서 수행할 계산을 선언적으로 표현하는 데 중점을 둡니다. 하지만 필요한 경우 스트림의 제어된 순회 메서드를 사용하여 원하는 작업을 수행할 수 있습니다.

A stream should be operated on (invoking an intermediate or terminal stream operation) only once. This rules out, for example, "forked" streams, where the same source feeds two or more pipelines, or multiple traversals of the same stream. A stream implementation may throw IllegalStateException if it detects that the stream is being reused. However, since some stream operations may return their receiver rather than a new stream object, it may not be possible to detect reuse in all cases.

  • 스트림은 한 번만 작동해야 하며, 이를 중복 사용하면 IllegalStateException과 같은 오류가 발생할 수 있다. 이 부분은 코드를 통하여 설명을 하겠습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

Stream<Integer> evenNumbers = numbers.stream().filter(n -> n % 2 == 0);

long count = evenNumbers.count(); // 종단 작업

long sum = evenNumbers.mapToLong(Integer::intValue).sum(); // 에러! 스트림은 이미 사용되었으므로 재사용할 수 없음
  • 이 코드를 살펴보면 evenNumbers는 처음에 filter를 통해 중간 작업을 수행하며 count를 통해 종단 작업을 수행하여 하나의 Stream은 끝이다. 하지만 이후 mapToLong을 사용하여 중간 작업을 한번 더 수행을 합니다. 이걸 실행하면 Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed이 발생을 합니다.

  • 즉 종단 작업은 한번만 호출을 하며 종단 작업을 한 이후에 중간 작업을 수행할 수 없으며 만약에 수행을 하고 싶으면 Stream을 생성을 해야됩니다.

Stream pipelines may execute either sequentially or in parallel. This execution mode is a property of the stream. Streams are created with an initial choice of sequential or parallel execution. (For example, Collection.stream() creates a sequential stream, and Collection.parallelStream() creates a parallel one.) This choice of execution mode may be modified by the BaseStream.sequential() or BaseStream.parallel() methods, and may be queried with the BaseStream.isParallel() method.

  • 스트림 파이프라인은 데이터 처리 작업을 수행하기 위해 순차 또는 병렬로 실행될 수 있으며 이러한 방식은 스트림의 속성으로 설정이 된다. 이 선택은 메소드에 따라 달라진다.
    1. 순차 스트림
    • 순차 스트림은 데이터 처리 작업을 한 단계씩 순차적으로 처리를 합니다.
    1. 병렬 스트림
    • 데이터 작업을 병렬로 동시에 처리한다.
    • 컬렉션에서 .paralleStream()을 사용하여 여러 스레드를 사용하여 데이터를 병렬로 처리한다.
    	List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

        // 순차 스트림을 사용하여 각 요소를  출력
        System.out.println("순차 스트림 작업 결과:");
        Stream<Integer> sequentialStream = numbers.stream();
        sequentialStream.forEach(num -> {
            int square = num ;
            System.out.println(square);
        });

        // 병렬 스트림을 사용하여 각 요소를  출력
        System.out.println("\n병렬 스트림 작업 결과:");
        Stream<Integer> parallelStream = numbers.parallelStream();
        parallelStream.forEach(num -> {
            int square = num ;
            System.out.println(square);
        });
 
  순차 스트림 작업 결과:1,2,3,4,5
      

  병렬 스트림 작업 결과: 3,5,4,2,1

Java Stream API 정리하기

  1. 스트림은 데이터 소스 (배열, 컬렉션, i/o등)로부터 생성을 합니다.
  2. 스트림 연산 : 스트림 연산은 데이터 소스를 스트림을 생성하고 이후 중간, 종단의 작업을 수행하여 하나의 연산을 처리를 합니다. 이때 종단 작업을 수행한 이후 중간 작업을 수행하면 IllegalStateException이 발생을 합니다.
  3. 지연 연산 : 데이터 처리가 필요한 경우에만 데이터를 처리하고 중간, 최종 연산을 적절하게 조합하여 성능을 최적화 가능하다.
  4. Stream API는 멀티코어 프로세서를 활용하여 데이터를 병렬로 처리할 수 있는 기능을 제공합니다.

3. Java Stream API 특징

3-1. 데이터 소스를 변경하지 않는다.

  • 스트림을 생성하면 데이터를 읽는 것이므로 데이터 소스를 변경하지 않는다.
  • 스트림의 직접적인 작업을 하지 않는 이상 데이터가 변경되지 않는다.
  List<Integer> originList = numbers.stream().collect(Collectors.toList());
  List<Integer> sortedList = numbers.stream().sorted().collect(Collectors.toList());

  System.out.println("originList = " + originList);
  System.out.println("sortedList = " + sortedList);
  
  originList = [1, 3, 2, 5, 4]
  sortedList = [1, 2, 3, 4, 5]
  • 위에 코드를 살펴보면 stream을 생성하고 정렬한 이후 출력을 하면 순서만 변경이 있고 데이터는 똑같은걸 확인을 할 수 있습니다.

  • 코딩을 할 때 원본 컬렉션을 수정하지 않고 원본 컬렉션을 통해 새로운 컬렉션을 만들어내야할 때가 많다. (원본 데이터는 유지하는 방향으로)

  • 그럴 때 원본 데이터를 깊은 복사 할 필요 없이 또는 번잡하게 반복문을 구현할 필요 없이 스트림 연산을 사용해서 좀 더 쉽고 간편하게 새로운 컬렉션을 만들어낼 수 있는 것이다.

3-2. Stream은 일회용이다.

  • 스트림은 데이터를 최종 연산을처리하면 사라지는 일회용이다.
  List<Integer> numbers = Arrays.asList(1, 3, 2, 5, 4);
  
  Stream<Integer> stream = numbers.stream();
  List<Integer> result = stream.collect(Collectors.toList());

  stream.forEach(System.out::println); //.IllegalStateException
  stream.sorted(); //.IllegalStateException
  • 이 코드는 위에서 말했던 내용과 똑같은 내용이다. 최종 연산을 한 이후 중간 연산을 처리하면 IllegalStateException 에러가 발생을 합니다. 스트림 객체를 만들어서 최종 연산을 한 이후 컴파일에서 해당 스트림 객체가 있어서 발생은 하지 않지만 에러가 발생을 합니다.

  • 만약에 이 로직을 처리하고 싶으면 Stream을 하나 만들어서 처리를 하면 됩니다.

  List<Integer> numbers = Arrays.asList(1, 3, 2, 5, 4);
  Stream<Integer> stream = numbers.stream();
  stream.collect(Collectors.toList()).stream()
  .sorted().forEach(System.out::print);

4. 주요 스트림 연산

4-1. of

  • Stream은 데이터를 순차적으로 처리, 조작을 도와줍니다. 스트림은 컬렉션 데이터나 다른 데이터 소스에서 데이터를 읽고 변환하거나 조작하기 위한 기능을 제공합니다. 스트림 API는 데이터 처리를 간단하고 효율적으로 수행할 수 있도록 도와줍니다.
    of 메서드는 요소를 포함하는 스트림을 생성합니다.

    Stream<Integer> integerStream = Stream.of(1, 2, 3, 4, 5);

    Stream.of()와 Arrays.stream() 메서드의 차이점

  • Stream.of()
    사용: 요소를 직접 지정하여 스트림 생성.
    유형: 모든 데이터 유형에 사용 가능.
    예제: Stream.of(1, 2, 3)는 1, 2, 3 값을 가진 스트림 생성.

  • Arrays.stream()
    사용: 배열을 스트림으로 변환하여 생성.
    유형: 주로 배열과 관련 있음.
    예제: Arrays.stream(new int[]{1, 2, 3})는 정수 배열을 스트림으로 생성.
    간단히 말하면, Stream.of()는 요소를 직접 명시하고 다양한 데이터 유형을 다룰 때 사용하며, Arrays.stream()은 주로 배열을 스트림으로 변환할 때 사용합니다.

    https://www.techiedelight.com/ko/difference-stream-of-arrays-stream-java/

    4-2. sorted

  • sorted() 메서드는 스트림의 요소를 정렬하는 데 사용한다. 이 메서드를 사용하면 스트림 요소를 정렬된 순서를 반환하거나, 정렬된 순서로 작업을 수행을 할 수 있다.

  • 기본적으로 sorted() 메서드는 스트림의 요소가 Comparable 인터페이스를 구현하고 있거나 정렬 방법을 지정하는 Comparator를 사용을 해야된다.

    요소를 기본 정렬 순서로 정렬하기
    Stream<Integer> numbers = Stream.of(3, 1, 4, 1, 5, 9, 2, 6, 5, 3, 5);
    Stream<Integer> sortedNumbers = numbers.sorted();
    sortedNumbers.forEach(System.out::println);
    Comparator를 사용하여 요소 정렬하기
    Stream<String> names = Stream.of("유재석", "김종국", "하하", "송지효");
    Stream<String> sortedNames = names.sorted(Comparator.reverseOrder());
    sortedNames.forEach(System.out::println);
    

    4-3. filter

  • 스트림을 사용하면서 가장 많이 사용하는 메서드로 filter 메서드는 주어진 조건을 만족하는 요소만을 선택하여 새로운 스트림을 생성하는데 사용을 한다.

  • filter 메서드는 주로 데이터를 필터링하거나 원하는 조건을 만족하는 요소를 추출할 때 사용합니다.

  Stream<T> filter(Predicate<? super T> predicate)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수만을 필터링
        numbers.stream()
                .filter(num -> num % 2 == 0)
                .collect(Collectors.toList())
                .stream().forEach(System.out::println);

Predicate<? super T>

  • Predicate는 람다에서 사용되는 인터페이스
    • T 타입을 받아서 boolean을 리턴하는 함수 인터페이스로 조합용 메서드는 and, or, negate가 있다.
public class PracticeLamdba {
    public static void main(String[] args) {
      Predicate<Integer>predicate = integer -> integer%2==0;
        System.out.println(predicate.test(10));
    }
}

and()
public class PracticeLamdba {
    public static void main(String[] args) {
      Predicate<Integer>integerPredicate = integer -> integer%2==0;
      Predicate<Integer>predicate = num ->num==4;
      
      if(integerPredicate.and(predicate).test(4)){
          System.out.println("참");
      }else System.out.println("거짓");
      
    }
}
or()
public class PracticeLamdba {
    public static void main(String[] args) {
      Predicate<Integer>integerPredicate = integer -> integer%2==0;
      Predicate<Integer>predicate = num ->num==4;
      
      if(integerPredicate.or(predicate).test(4)){
          System.out.println("참");
      }else System.out.println("거짓");
      
    }
}
negate()
public class PracticeLamdba {
    public static void main(String[] args) {
      Predicate<Integer>integerPredicate = integer -> integer%2==0;
      Predicate<Integer>predicate = num ->num==4;
        System.out.println(predicate.negate().test(4));
    }
}

4-4. map

  • map 메서드는 스트림의 각 요소를 다른 값으로 변환하는 데 사용됩니다. map 메서드를 사용하면 스트림의 각 요소에 대해 주어진 함수를 적용하여 새로운 값을 생성하고, 이 새로운 값을 포함하는 새로운 스트림을 생성할 수 있습니다.

  • map 메서드의 기본 구문

<R> Stream<R> map(Function<? super T, ? extends R> mapper)
List<String> names = Arrays.asList("유재석", "하하", "귤", "코카콜라");

// 각 이름의 길이를 추출하여 새로운 스트림을 생성
Stream<Integer> nameLengths = names.stream()
                                   .map(name -> name.length());

// 변환된 결과를 출력
nameLengths.forEach(System.out::println);
  • 이거는 Function 변환 함수 인터페이스로 T타입의 요소를 받아서 R타입으로 변환을 합니다.

Function<T,R>

  • Function<T,R>은두개의 인자를 받아서 하나의 결과를 Return을 한다. 여기서 두개의 인자의 타입은 상관이 없다. 만약에 두개의 인자가 같은 타입이면 UnarayOperator를 사용
public class PracticeLamdba {
    public static void main(String[] args) {
        Function<Integer, Integer> plus = (number) -> number+10;
        
        Function<Integer , Integer> multiple = number2 ->number2*2;
        
        int result=plus.compose(multiple).apply(10);
				System.out.println(result);
        
    }
}

- 여기서 compose는 ()의 기능을 먼저 연산을 하고 그 뒤에 오는 연산을 처리한다.
-> multiple -> result -> result & plus

4-5. foreach

  • forEach 메서드는 스트림의 각 요소를 순회하면서 주어진 동작(액션)을 수행하는 데 사용됩니다. 이 메서드는 스트림의 요소를 하나씩 처리하며, 각 요소에 대해 지정된 동작을 수행합니다.
void forEach(Consumer<? super T> action)

Consumer - apply()

  • T 타입을 입력을 받아서 아무값도 리턴하지 않는 함수 인터페이스 이다.
public class PracticeLamdba {
    public static void main(String[] args) {
        Consumer<String>consumer = (name) -> System.out.println("내 이름은"+name+"입니다.");
        consumer.accept("김무건");
    }
}
List<String> fruits = Arrays.asList("Apple", "Banana", "Cherry", "Date");

// 각 과일 이름을 출력하는 동작을 정의하여 forEach로 적용
fruits.stream().forEach(fruit -> System.out.println("Fruit: " + fruit));

4-6. flatMap

  • flatMap 메서드는 자바 스트림의 연산 메서드로 요소를 하나 이상의 스트림으로 매핑하고 이러한 여러 스트림을 하나의 스트림으로 평탄화 하는 작업을 수행을 합니다. 이를 통해 중첩된 List, Map의 요소를 한 번에 처리할 수 있다.
List<List<Integer>> nestedList = Arrays.asList(
    Arrays.asList(1, 2, 3),
    Arrays.asList(4, 5),
    Arrays.asList(6, 7, 8)
);

// 중첩된 리스트를 평탄화하여 단일 스트림으로 변환
List<Integer> flatList = nestedList.stream()
                                   .flatMap(List::stream)
                                   .collect(Collectors.toList());

// 결과 출력
System.out.println(flatList); // 출력: [1, 2, 3, 4, 5, 6, 7, 8]

4-7. reduce

  • reduce는 아직 사용을 해보지 않는 메소드 이지만 유용하게 사용이 가능할거 같아서 정리를 한다.
  • 요소들을 결합하거나 줄이는데 사용되는 메서드로 reduce를 사용하면 요수를 하나의 값으로 합칠 수 있다.
  • 이 메서드는 중단 연산으로 스트림 처리 파이프라인의 최종 연산이다.
  • reduce의 기본 구문은 다음과 같다.
T reduce(T identity, BinaryOperator<T> accumulator)
  • identity: 초기 값(identity)으로 사용될 값을 나타냅니다. 이 값은 스트림이 비어있을 때 반환 값이 됩니다.
  • accumulator: 이항 연산자(BinaryOperator)로, 스트림의 요소를 조합하여 하나의 값으로 줄이는 동작을 정의합니다.
  • BinaryOperator는 위에 Funtion과 비슷한 역할을 하지만 타입이 같을 때 사용한다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// 초기 값 0을 사용하여 정수 리스트의 합계 구하기
int sum = numbers.stream()
                 .reduce(0, (a, b) -> a + b);

System.out.println("합계: " + sum); // 출력: 합계: 15

4-8. findFirst

  • findFirst 메서드는 스트림에서 첫 번째 요소를 찾아 반환하는 메서드입니다. 이 메서드를 호출하면 스트림의 요소 중 첫 번째 요소를 반환하며, 스트림이 비어있으면 빈 Optional을 반환합니다. 주로 첫 번째 요소를 찾을 때 사용됩니다.
List<String> names = Arrays.asList("A", "B", "C", "D");

Optional<String> first = names.stream().findFirst();
System.out.println(first.orElse("리스트가 비어있습니다.")); // 출력: A

4-9. findAny

  • findAny 메서드는 스트림에서 어떤 요소든 하나를 찾아 반환하는 메서드입니다. 병렬 처리된 스트림에서는 병렬 처리에 의해 임의의 요소가 반환됩니다. 주로 요소 중 아무거나 하나를 찾을 때 사용됩니다.

  • findAny, findFirst의 차이는 요소의 순서와 병렬 처리 여부에 따라 어떤 것을 선택할지 결정한다.

  • 요소 순서가 중요하지 않거나 병렬 처리할 때 findAny를 사용 / 요소의 순서가 중요하거나 병렬 스레드 간 동기화가 필요한 경우 findFirst를 처리한다.

4-10. concat

  • concat 메서드는 두 개의 스트림을 연결하여 하나의 스트림으로 만드는 메서드입니다. 첫 번째 스트림을 기반으로 두 번째 스트림이 연결됩니다.
Stream<String> stream1 = Stream.of("A", "B", "C");
Stream<String> stream2 = Stream.of("X", "Y", "Z");

Stream<String> concatenated = Stream.concat(stream1, stream2);
concatenated.forEach(System.out::print); // 출력: ABCXYZ

4-11. count

  • count 메서드는 스트림의 요소 수를 반환하는 메서드입니다. 스트림의 모든 요소를 세어 개수를 반환합니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

long count = numbers.stream().count();
System.out.println("요소 개수: " + count); // 출력: 요소 개수: 10

참고


https://pamyferret.tistory.com/43
https://www.techiedelight.com/ko/difference-stream-of-arrays-stream-java/
https://futurecreator.github.io/2018/08/26/java-8-streams/

profile
빠르게 실패하고 자세하게 학습하기

0개의 댓글