Stream

김회민·2023년 2월 12일
0

Java

목록 보기
15/16

스트림 Stream

Java 8에서 추가된 인터페이스 중 하나이다.
기존에 배열과 Collection 들을 다룰땐, for이나 foreach를 통해 반복하는 방법을 사용했다.
간단한 경우라면 상관없지만, 로직이 복잡해질수록 코드의 양이 많아지고 섞이게 되면서 한눈에 알아보기도 쉽지 않고, 메서드를 통해 역할을 나눌 경우 여러 루프가 중복되는 문제가 생긴다.
스트림은 메서드를 통한 사용과, Lazy Evaluation( 지연 연산 )을 통해 이를 해결하고자 했다.

스트림의 특징

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐 변경하지 않는다. Read-Only

List<Integer> list = Arrays.asList(3, 1, 5, 4, 2);
List<Integer> sortedList = list.stream().sorted().toList();

System.out.println("list = " + list);
System.out.println("sortedList = " + sortedList);

// list = [3, 1, 5, 4, 2]
// sortedList = [1, 2, 3, 4, 5]

스트림은 일회용이다. 최종 연산을 실행하면 스트림이 닫히게 된다.

List<String> strList = Arrays.asList("김", "수", "한", "무");
Stream<String> strStream = strList.stream();

strStream.forEach(System.out::println); // 모든 요소를 화면에 출력 ( 최종 연산 )
System.out.println("strStream.count() = " + strStream.count()); // 에러

새로운 스트림을 다시 생성해서 작성해야 한다.

최종 연산 전까지 중간 연산이 수행되지 않는다. 지연 연산 ( Lazy Evaluation )

IntStream intStream = new Random().ints(1, 46);    // 1 ~ 45 범위의 무한 스트림
intStream.distinct().limit(6).sorted()             // 중간 연산
        .forEach(i -> System.out.print(i + ", ")); // 최종 연산

무한 스트림을 distinct() 로 중복을 제거하는 것은 얼핏 보면 말이 안되지만,

지연 연산을 통해 중간 연산에 해당하는 메서드를 실행하지않고 등록만 해둔뒤, JVM에서 사전에 등록된 최적화 방식으로 코드를 정리하여 최종적인 실행을 하게 한다.

이에 대한 자세한 설명은 이 블로그에서 확인할 수 있다.

내부 반복으로 처리한다.

for(String str: strList) { System.out.println(str); }
stream.forEach(System.out::println);

로직을 메서드안에 숨겨, 프로그램을 작성할 때 필요한 메서드만을 호출함으로써 작업이 가능하도록 한 것을 의미한다. 이로 인해, 성능은 다소 하락했을지 몰라도, 가독성이 증가하게 되었다.

스트림의 작업을 병렬로 처리할 수 있다. 병렬스트림

// 병렬 스트림으로 전환
list.stream().parallel();
list.parallelStream();

// 일반 스트림으로 전환
stream.sequential();

기본적으로 참조형만 가능하나, 기본형 스트림도 제공된다.

기본형으로 선언된 ArrayList를 스트림화 하게 되면, 내부적으로 참조형으로 변환하는 작업을 하게 된다(오토박싱). 또한, 결과물을 반환할 때 참조형에서 기본형으로 변환하는 작업을 수행하게 된다(언박싱).

위와 같은 과정을 줄이기 위해 기본형만을 위한 스트림을 제공한다.

  • IntStream, LongStream, DoubleStream …

스트림을 그럼 어떻때 사용해야 할까?

Effective Java - Item 45. 에서 발췌

스트림에 안성 맞춤인 일

  • 원소들의 시퀀스를 일관되게 변환한다.
  • 원소들의 시퀀스를 필터링한다.
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합한다.
  • 원소들의 시퀀스를 컬렉션에 모은다.
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.

스트림으로 처리하기 어려운 일

한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서 값들을 동시에 접근하기 어려운 경우

스트림을 작성하는 방법

스트림은 크게 스트림 생성 → 중간 연산 → 최종 연산 순으로 작성된다.
각 요소 별로 굉장히 많은 메소드를 제공하며, 이를 목적에 맞게 작성하여 최종적인 결과물을 얻어낸다.
스트림 메소드에 대한 자세한 설명은 이 블로그이 블로그에서 확인할 수 있다.

스트림 만들기

배열 스트림 Arrays.stream

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

컬렉션 스트림 Collection.stream

List<String> list = Arrays.asList("a", "b", "c");
Stream<String> stream = list.stream();
Stream<String> parallelStream = list.parallelStream(); // 병렬 처리 스트림

Stream.of

// [1, 2, 3]
Stream<String> stream = Stream.of("1", "2", "3");

비어 있는 스트림 Stream.empty

Stream<String> stream  = Stream.empty();

Stream.builder

["a", "b", "c"]
Stream.builder()
       .add("a").add("b").add("c").build()
       .toList();

Stream.generate

// ["Hello", "Hello", "Hello"]
Stream.generate(() -> "Hello") // 무한 스트림
       .limit(3)
       .toList();

Stream.iterate

// [30, 32, 34, 36, 38]
Stream.iterate(30, n -> n + 2) // 초기값, Step 람다 식, 무한 스트림
       .limit(5)
       .toList();

중간 연산

filter

// [1, 123]
Stream<String> stream = Stream
        .of("1", "2", "3", "123")
        .filter(name -> name.contains("1")); // 1이 포함된 문자열인지

각 요소를 순회하면서 반환값이 true, false인지 판단하고,
true면 남기고 false면 무시하여 새로운 스트림을 생성한다.

map

// [A, B, C, D]
Stream<String> stream = Stream
        .of("a", "b", "c", "d")
        .map(String::toUpperCase); // 모든 문자열을 대문자로 변환

모든 요소를 순회하면서 해당 람다식의 반환값으로 변경한다.

sorted

// [1, 2, 3, 4, 5]
Stream<Integer> stream = Stream
        .of(1, 5, 3, 2, 4)
        .sorted();

모든 요소를 오름차순( 기본값 )으로 정렬한다.
Comparator 람다식( (T, T) -> int )을 넣어 정렬 기준을 새로 만들 수 있다.

peek

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

각 요소를 순회하며 특정한 작업을 수행하지만, 결과에는 영향을 미치지 않는 메서드.
Consumer 람다식( T -> void )을 넣어 사용한다.

최종 연산

ifPresent

// 출력안됨
IntStream
        .of()
        .min()
        .ifPresent(System.out::println);

// 1
IntStream
        .of(1, 2, 3, 4, 5)
        .min()
        .ifPresent(System.out::println);

해당 스트림이 비어있는지 아닌지 판단하여, 비어있는 경우 해당 메서드를 실행하지 않고 종료한다.

reduce

reduce는 총 3개의 파라미터를 받는다.

  • identity: 계산을 위한 초기값.
  • accumulator: 각 요소를 처리하는 로직, (T, T) -> T 형식을 받는다.
  • combiner: 병렬 스트림에서 나눠 계산한 결과를 하나로 합치는 동작.

reduce의 기본 동작 방식은 다음과 같다.

IntStream.range(1, 4) // [1, 2, 3, 4]
        .reduce((a, b) -> a + b); 

실행 1: (1, 2) → 1 + 2
배열의 첫 번째 요소와 두 번째 요소를 더한 값을 리턴한다.

실행 2: (3, 3) → 3 + 3
실행 1에서 리턴된 값을 a 변수에 넣고, 배열의 세 번째 요소를 b 변수에 넣는다. 그 후 더한 값을 리턴한다.

실행 3: (6, 4) → 6 + 4
실행 2의 반복

이를 단순화하면 다음과 같은 형태로 볼 수 있다.

int[] arr = { 1, 2, 3, 4 };

int reduce(Integer identity = null, int acc) {
  // identity가 인자로 들어왔는가?
  boolean isNotNullId = identity != null;

  // 인자로 들어왔다면 초기값을 설정, 아니면 배열의 첫번째 요소를 설정
  int result = isNotNullId ? identity : arr[0];

  // 순회하면서 인자로 들어온 함수의 결과물을 저장
  for( int i = isNotNullId ? 0 : 1; i < arr.size() - 1; i++ ) {
    result += acc( arr[i], arr[i + 1] );
  } 

  // 결과물 반환
  return result;
}

matching

List<String> names = Arrays.asList("1", "2", "3", "4", "5");

boolean anyMatch = names.stream()
        .anyMatch(name -> name.contains("1"));
boolean allMatch = names.stream()
        .allMatch(name -> !name.isBlank());
boolean noneMatch = names.stream()
        .noneMatch(name -> name.equals("10"));

System.out.println(anyMatch); // true
System.out.println(allMatch); // true
System.out.println(noneMatch); // true

Predicate 람다식( T -> boolean )을 인자로 받는다.

총 3가지 종류의 메서드가 있다.

  • anyMatch: 하나라도 조건을 만족하는 요소가 있는가?
  • allMatch: 모든 요소가 조건을 만족하는가?
  • noneMatch: 모든 요소가 조건을 만족하지 않는가?

Collectors.toList

// 가변 객체
Stream.iterate(1, n -> n + 1)
      .limit(45)
      .collect(Collectors.toList()); 

// 불변 객체 ( Java 10 )
// Null을 허용하지 않는다. -> NPE 발생 가능성
Stream.iterate(1, n -> n + 1)
      .limit(45)
      .collect(Collectors.toUnmodifiableList());

// 불변 객체 ( Java 16 )
Stream.iterate(1, n -> n + 1)
      .limit(45)
      .toList(); 

작업 결과를 List로 반환한다.

Collectors.toSet

// 가변 객체
Stream.iterate(1, n -> n + 1)
      .limit(45)
      .collect(Collectors.toSet()); 

// 불변 객체 ( Java 10 )
// Null을 허용하지 않는다. -> NPE 발생 가능성
Stream.iterate(1, n -> n + 1)
      .limit(45)
      .collect(Collectors.toUnmodifiableSet());

작업 결과를 Set으로 반환한다.

Collectors.joining

// "abcd"
String str = Stream
        .of("a", "b", "c", "d")
        .collect(Collectors.joining());

작업 결과를 하나의 스트링으로 반환한다.

Collectors.groupingBy

productList.stream()
    .collect(Collectors.groupingBy(Product::getAmount));

Function 람다식( T -> R )을 인자로 받는다.
반환값을 Key, 각 요소를 Value로 한 MultiValueMap을 반환한다.

Collectors.partitioningBy

productList.stream()
     .collect(Collectors.partitioningBy(el -> el.getAmount() > 15));

Predicate 람다식( T -> boolean )을 인자로 받는다.
GroupBy와 동일하게 반환값을 Key, 각 요소를 Value로 한 MultiValueMap을 반환하지만, 반환값이 boolean이므로 조건에 맞는지 안맞는지 확인하는 용도로 사용된다.

Collectors.collectingAndThen

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

첫 번째 인자는 어떤 형식으로 Collect 할지 정하는 용도로 사용된다. 두 번째 인자는 Function 람다식( T -> R )을 인자로 받는다.
첫 번째 인자로 Collect 한 후, 두 번째 인자에 넣은 함수를 추가 실행한다.

간단한 예제

간단하게, 1 ~ 9 까지 저장된 배열에서 3의 배수만 뽑고 싶다고 가정해보자.

for

public class StreamTest {
    List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);
    
    @Test
    void forTest() {
        List<Integer> result = new ArrayList<>();
        for( int num : numbers ) {
            if(num % 3 == 0) {
                result.add( num );
            }
        }
        System.out.println("result = " + result);
    }
}
// result = [3, 6, 9]
  1. 새로운 빈 리스트를 생성한다: List<Integer> result = new ArrayList<>();
  2. 처음부터 끝까지 iterator를 이용해 순회한다: for( int num : numbers ) { ... }
  3. 3의 배수가 맞다면 리스트에 넣는다: if(num % 3 == 0) { result.add( num ); }
  4. 결과를 출력한다: System.out.println("result = " + result);

Stream

public class StreamTest {
    List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9);

    @Test
    void streamTest() {
        List<Integer> result = numbers.stream()
                .filter(num -> num % 3 == 0)
                .toList();
        System.out.println("result = " + result);
    }
}
// result = [3, 6, 9]
  1. 새로운 스트림을 생성한다: numbers.stream()
  2. filter 메소드와 람다를 통해 3의 배수를 걸러낸다: .filter(num -> num % 3 == 0)
  3. 스트림의 결과를 List 로 변환한다: .toList();
  4. 결과를 출력한다: System.out.println("result = " + result);

좀 더 추가된 예제

위와 같이 간단한 예제로는 와닿기 힘드니, 좀 더 복잡한 예제로 해보자. 요구사항은 아래와 같다.

1 ~ 45 까지의 숫자 중 랜덤한 6개의 숫자를 중복없이 뽑는 프로그램.

for

public class StreamTest {
    @Test
    void lottoTestByFor() {
        // 1. 빈 배열 생성
        List<Integer> lottoNumber = new ArrayList<>();
        List<Integer> result = new ArrayList<>();

        // 2. 1 ~ 45 까지 숫자로 초기화
        for(int i = 1; i <= 45; i++) {
            lottoNumber.add(i);
        }

        // 3. 섞기
        Collections.shuffle(lottoNumber);

        // 4. 앞에서 순서대로 6개까지만 반복
        for(int i = 0; i < 6; i++) {
            // 5. 빈 배열에 넣기
            result.add(lottoNumber.get(i));
        }

        // 6. 결과 출력
        System.out.println("result = " + result);
    }
}
// result = [2, 12, 45, 25, 10, 24]

Stream

public class StreamTest {
    @Test
    void lottoTestByStream() {
        // 1. 1 ~ 45 까지 숫자가 담긴 List 생성 ( 가변 객체 )
        List<Integer> lottoNumber = Stream
                .iterate(1, n -> n + 1)   // start: 1, step: n + 1
                .limit(45)                // 45개로 제한
                .collect(Collectors.toList());  // 가변 리스트로 변환

        // 2. 섞기
        Collections.shuffle(lottoNumber);

        // 3. 6개로 제한 후 List 생성 ( 불변 객체 )
        List<Integer> result = lottoNumber.stream()
                .limit(6)  // 6개로 제한
                .toList(); // 불변 리스트로 변환

        // 4. 결과 출력
        System.out.println("result = " + result);
    }
}
// result = [23, 38, 35, 45, 3, 10]

Stream 2

public class StreamTest {
    @Test
    void lottoTestByStream2() {
        List<Integer> result = Stream
                .generate(() -> new Random().nextInt(1, 46)) // 1 ~ 45 사이의 랜덤한 숫자 생성
                .limit(100) // 100개로 제한
                .distinct() // 중복 제거
                .limit(6)   // 6개로 제한
                .toList();  // 불변 객체로 변환

        System.out.println("result = " + result);
        System.out.println("result.size() = " + result.size());
    }
}
// result = [19, 35, 10, 9, 29, 37]
// result.size() = 6

추가로 알아보면 좋은 것.

출저

profile
백엔드 개발자 지망생

0개의 댓글