프로그래밍/JAVA Spring

[JAVA 자바] 스트림(Stream)

hectick 2023. 3. 9. 13:47

 

🐣 스트림의 정의

 

스트림이란  데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소 라 정의할 수 있다. 

각 요소에 대해서 부연 설명을 하자면 다음과 같다.

 

1. 데이터 처리 연산

스트림은 filter, map, reduce, find, match 등의 연산으로 데이터를 조작할 수 있게 한다.

 

2. 소스

스트림 소스가 될 수 있는 대상으로는 배열, 컬렉션, 임의의 수 등 다양하다.

스트림은 이러한 데이터 제공 소스로부터 데이터를 소비한다. 

 

3. 연속된 요소

데이터 소스는 연속된 요소를 스트림에 제공한다.

연속된의 의미는 순차적으로 값에 접근한다는 뜻이다.

 

 

🐣 스트림의 장점

 

1. 가독성

스트림은 기존의 for문과 if문을 조합해서 코드를 짜는 것보다, 더 간결하고 가독성이 좋다. 이는 스트림이 작업을 내부 반복으로 처리하기 때문인데, 사용자가 직접 요소들을 반복해줄 필요가 없다. 그저 어떤 작업을 수행할지만 지정해 주면 모든 것이 알아서 처리된다.

 

다음 두 코드는 같은 동작을 하지만, 가독성에서 차이가 난다.

    public boolean hasUnfinisedPlayer() {
        for (Player player : players) {
            if (!player.isFinished()) {
                return true;
            }
        }
        return false;
    }
    public boolean hasUnfinishedPlayer() {
        return players.stream()
                .anyMatch(player -> !player.isFinished());
    }

물론 모든 것을 스트림으로 처리하려다 보면 가독성이 오히려 더 구려질 수도 있다. 상황에 따라 적절히 for문과 스트림을 섞어 쓰면 되겠다.

 

 

2. 범용성

스트림은 데이터를 다루는 데 자주 사용되는 메서드들을 정의해 놓았다. 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 해주고, 코드의 재사용성을 높여준다. 스트림을 이용하면 배열이나 컬렉션 뿐 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다. 즉, 어떤 데이터 소스가 오든 같은 방식으로 데이터를 처리할 수 있게 된다.

 

        String[] strArr = {"aa", "bb", "cc", "dd"};
        List<String> strList = Arrays.asList(strArr);

        Stream<String> strStream1 = strList.stream(); // 리스트에서 스트림을 생성
        Stream<String> strStream2 = Arrays.stream(strArr); //배열에서 스트림을 생성

위에서는 리스트와 배열로부터 스트림을 생성한다. 두 스트림의 데이터 소스는 서로 다르지만, 다음처럼 정렬하고 출력하는 방법은 완전히 동일하다.

        strStream1.sorted().forEach(System.out::println);
        strStream2.sorted().forEach(System.out::println);

 

 

🐣 중간연산과 최종연산

 

중간연산

연산 결과가 스트림인 연산으로, 연속해서 연결할 수 있다.

모든 중간 연산의 결과는 스트림이지만, 연산 전의 스트림과는 다른 스트림이다.

 

중간 연산은 게으르다. 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다.

게으른 특성 덕분에 최적화 효과가 나타나는데, 그게 바로 쇼트 서킷과 루프 퓨전이다.

 

쇼트 서킷?
표현식에서 하나라도 거짓이라는 결과가 나오면 나머지 표현식의 결과와 상관없이 전체 결과도 거짓이 된다. 어떤 중간연산들은 스트림의 모든 요소를 처리하지 않았더라도, 원하는 요소를 찾았으면 즉시 결과를 반환할 수 있다. 이게 바로 쇼트 서킷이다.

 

루프 퓨전?
서로 다른 연산이 한 과정으로 병합되는 것을 루프 퓨전(loop fusion) 이라고 한다. filter, map은 서로 다른 연산이지만 실제로는 한 과정으로 병합되는데, 이게 바로 루프 퓨전이다.

 

 

최종연산

연산 결과로 스트림이 닫히는 연산으로, 스트림의 요소를 소모하므로 단 한번만 가능하다.

스트림은 닫히면 다시 사용할 수 없는 일회용이다. 필요하면 스트림을 다시 생성해야 한다.

보통 최종 연산에 의해서 list, integer, void 등 스트림 이외의 결과가 반환된다.

 

 

🐣 스트림의 이용과정

 

스트림의 이용과정은 다음처럼 정리할 수 있다.

1. 데이터 소스로부터 스트림을 생성한다.

2. 중간 연산들을 연속해서 연결한다.

3. 최종 연산으로 결과를 만든다.

 

다음 예제에서는 menu라는 데이터 소스에서 stream()을 통해 스트림을 생성한다.

filter, map, limit이 중간 연산이고, collect가 최종 연산이다.

        List<String> names = menu.stream() //스트림 생성
                .filter(dish -> dish.getCalories() > 300) // 중간연산
                .map(Dish::getName) // 중간연산
                .limit(3) // 중간연산
                .collect(toList()); // 최종연산

 

 그럼 이제 스트림 활용하는 방법을 본격적으로 알아보겠다.

 

 

🐣 스트림의 활용

 

스트림 생성

1. Collection으로 생성

컬렉션의 최고 조상인 Collection에는 stream()이 정의되어 있다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 stream()으로 스트림을 생성할 수 있다.

 

다음 예시는 particiapnts.getPlayers()로 List<Player>를 가져와서 스트림을 생성한다.

        List<String> playerNames = participants.getPlayers().stream() // 스트림 생성
                .map(player -> player.getName().getName()) // 중간 연산
                .collect(Collectors.toList()); // 최종 연산

 

2. 배열로 생성

배열을 소스로 하는 스트림을 생성하는 메서드는 Arrays에 static 메서드로 정의되어 있다.

        Stream<String> strStream1 = Arrays.stream(new String[]{"a", "b", "c"});
        Stream<String> strStream2 = Arrays.stream(new String[]{"a", "b", "c"}, 0, 3); // 배열, startInclusive, endExclusive

 

다음 처럼 값으로도 생성 가능하다.

strStream4 처럼 쓸 수도 있지만, 이렇게 하면 인텔리제이가 strStream3 처럼 쓰는 게 더 낫다고 바꾸라고 한다.

        Stream<String> strStream3 = Stream.of("a", "b", "c"); // 가변 인자
        Stream<String> strStream4 = Stream.of(new String[]{"a", "b", "c"});

 

 

중간연산

 

1. 필터링 filter, distinct

filter: Predicate를 인수로 받아서, Predicate와 일치하는 모든 요소를 포함하는 스트림을 반환한다.

        long dealerWin = playerResult.values().stream()
                .filter(value -> value.equals(Result.LOSE))
                .count();

 

distinct: 고유 요소로 이루어진 스트림을 반환한다. 여기서 고유 여부는 hashCode, equals로 결정된다.

다음 예시는 PlayerName으로 중복되는 값이 있는지 검사하여, 중복되는 값이 존재하면 예외를 반환한다.

    private void validateDuplicatedNames(List<PlayerName> names) {
        if (names.size() != names.stream().distinct().count()) {
            throw new IllegalArgumentException(ExceptionMessage.EXCEPTION_DUPLICATED_NAME.getMessage());
        }
    }

 

2. 슬라이싱 limit, skip, takeWhile, dropWhile

limit: 주어진 사이즈 n 이하의 크기를 갖는 새로운 스트림을 반환한다. 

다음 예시는 Predicate와 일치하는 처음 세 요소를 선택한 다음에 즉시 결과를 반환한다.

        List<Dish> dishes = specialMenu.stream()
                .filter(dish -> dish.getCalories() > 300)
                .limit(3)
                .collect(toList());

 

skip: 처음 n개 요소를 제외한 스트림을 반환한다.

다음 예시는 Predicate와 일치하는 처음 2개의 요소를 건너뛰고 나머지 요리를 반환한다.

만약 2개 이하의 요소를 포함하는 스트림이었다면, 빈 스트림이 반환된다.

        List<Dish> dishes = specialMenu.stream()
                .filter(dish -> dish.getCalories() > 300)
                .skip(2)
                .collect(toList());

 

takeWhile과 dropWhile은 자바 8에는 없었는데, 자바9에서 추가되었다.

 

takeWhile: Predicate가 처음으로 거짓이 되는 지점까지 발견된 요소를 선택한다. Predicate가 거짓이 되면 그 지점에서 작업을 중단한다.

        List<Dish> dishes = specialMenu.stream()
                .takeWhile(dish -> dish.getCalories() < 320)
                .collect(toList());

 

dropWhile: Predicate가 처음으로 거짓이 되는 지점까지 발견된 요소를 버린다. Predicate가 거짓이 되면 그 지점에서 작업을 중단하고 남은 모든 요소를 반환한다. 

        List<Dish> dishes = specialMenu.stream()
                .dropWhile(dish -> dish.getCalories() < 320)
                .collect(toList());

 

 

3. 매핑 map, flatMap

스트림의 요소를 추출하거나 변환할 수 있다.

 

map: 함수를 인수로 받아서, 스트림에 각 요소에 함수를 적용 한 결과가 새로운 요소로 매핑된다.

        List<String> carNames = cars.stream()
                .map(Car::getName)
                .collect(Collectors.toList());

 

flatMap: 스트림의 각 값을 다른 스트림으로 만든 다음에 모든 스트림을 하나의 스트림으로 연결하는 기능을 수행한다.

다음의 이중 for문을 stream으로 바꿔보도록 하자.

        List<Card> cards = new ArrayList<>();
        for (CardSuit suit : CardSuit.values()) {
            for (CardNumber number : CardNumber.values()) {
                cards.add(Card.of(suit, number));
            }
        }

 

        List<Card> cards = Arrays.stream(CardSuit.values())
                .flatMap(suit->Arrays.stream(CardNumber.values())
                        .map(number->Card.of(suit, number))) // Stream<Card>반환
                .collect(Collectors.toList());

위와 아래의 코드를 비교해보자. 아래는 flatMap 대신 map을 쓴 것이다.

        Arrays.stream(CardSuit.values())
                .map(suit->Arrays.stream(CardNumber.values())
                        .map(number->Card.of(suit, number))) // Stream<Stream<Card>>반환
                .collect(Collectors.toList());

차이점은 주석으로 달아놓았다. map을 쓰면 Stream<Stream<Card>>가 반환되지만, flatMap을 쓰면 Stream<Card>가 반환된다. 즉, map대신 flatMap을 쓰면 스트림이 1차원으로 "평면화" 되는 것이다. flat의 뜻은 "평평한"이다. 이름이 flatMap인 것이 이해가 잘 되었으면 좋겠다.

 

 

최종연산

 

1. forEach

스트림의 각 요소를 소비하면서 람다를 적용하고 void를 반환한다.

    public void printPlayerNames(List<String> playerNames) {
        playerNames.stream().forEach(name -> System.out.printf("%-6s", name));
    }

하지만 위에처럼 쓰면 인텔리제이에서는 ''stream().forEach()'' can be replaced with 'forEach()'' (may change semantics) 문구를 보여준다. 결국 코드는 다음 처럼 간단해 질 수 있다.

    public void printPlayerNames(List<String> playerNames) {
        playerNames.forEach(name -> System.out.printf(OUTPUT_WORD_FORMAT, name));
    }

[Collection.forEach와 Stream.forEach는 뭐가 다를까?]를 참고하면 위 둘의 미묘한 차이를 알 수 있다. 

 

forEach를 사용하면서 주의할 것은 for문과 이름이 비슷하다고 신나서 남용하면 안된다는 점이다. forEach 연산은 print와 같이 스트림 계산 결과를 보고할 때만 사용하는 게 좋다. 자세한 내용은 [Stream의 foreach 와 for-loop 는 다르다.]를 참고하자.

 

2. count

스트림의 요소 개수를 long으로 반환한다.

다음 예시처럼 filter 안의 Predicate를 만족하는 요소의 개수를 셀 때도 유용하게 쓸 수 있다.

        long dealerWin = playerResult.values().stream()
                .filter(value -> value.equals(Result.LOSE))
                .count();

 

3. collect

스트림으로 리스트, 맵, 정수 형식의 컬렉션을 만들어 반환한다.

            List<Reward> rewards = inputView.readRewards().stream()
                    .map(Reward::new)
                    .collect(Collectors.toList());

 

4. 검색 findAny, findFirst

스트림의 요소를 검색할 수 있다.

findAny: 스트림에서 임의의 요소를 반환한다.

findFirst: 스트림에서 첫번째 요소를 반환한다.

    public Player getUnfinishedPlayer() {
        return players.stream()
                .filter(player -> !player.isFinished())
                .findFirst()
                .orElseThrow(() -> new IllegalStateException("카드를 뽑을 수 있는 플레이어가 더 이상 없습니다."));
    }

 

5. 매칭 anyMatch, allMatch, noneMatch

특정 속성이 스트림에 있는지 여부를 확인할 때 사용할 수 있다.

 

anyMatch: 스트림에서 적어도 한 요소가 Predicate와 일치하는지 검사하여 boolean을 반환한다.

    public boolean hasUnfinishedPlayer() {
        return players.stream()
                .anyMatch(player -> !player.isFinished());
    }

 

allMatch: 스트림의 모든 요소가 Predicate와 일치하는지 검사하여 boolean을 반환한다.

    public boolean isGameFinished() {
        return players.stream()
                .allMatch(player -> player.isFinished());
    }

 

noneMatch: 스트림에서 모든 요소가 Predicate와 일치하지 않는지 검사하여 boolean을 반환한다.

    public boolean isGameFinished() {
        return players.stream()
                .noneMatch(player -> player.isPlayable());
    }

 

allMatch, noneMoatch, findFirst, findAny 등의 연산은 모든 스트림의 요소를 처리하지 않고도 결과를 반환할 수 있기 때문에 쇼트서킷 연산이다.

 

6. 리듀싱 reduce

모든 스트림의 요소를 처리해서 값으로 도출한다.

스트림 요소들 중 최댓값, 최소값이나 모든 요소의 합계를 계산할 수 있다.

다음 예시는 모든 카드 점수의 합을 구하는 코드이다.

	int score = cards.stream()
                .map(card -> card.getNumber().getScore())
                .reduce(0, Integer::sum);

 

 

 

기본형 특화 스트림

기본형 특화 스트림으로는 IntStream, DoubleStream, LongStream가 있다. 이들 연산은 각각의 기본형에 맞게 특화되어 있다. 각각은 숫자 스트림의 합계를 계산하는 sum, 최댓값을 검색하는 max 같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. 또한 필요할 때 다시 객체 스트림으로 복원하는 기능도 제공한다. 

 

한번 int 요소에 특화된 IntStream을 사용해 보자.

 

프로그램에서는 특정 범위의 숫자를 이용해야 하는 상황이 자주 발생한다. 이 때 range와 rangeClosed라는 두 가지 메서드를 사용할 수 있는데, 두 메서드 모두 첫 번째 인수로 시작값을, 두 번째 인수로 종료값을 갖고, 시작값은 범위에 포함된다. 하지만 range는 종료값이 범위에 포함되지 않고, rangeClosed는 종료값이 범위에 포함된다.

다음 예시는 IntStream을 이용해 0부터 10까지의 합을 구하는 예시이다.

        System.out.println(IntStream.range(1,10).sum()); // 45
        System.out.println(IntStream.rangeClosed(1,10).sum()); // 55

 

IntStream을 좀 더 응용해 보자.

앞서, reduce를 소개할 때 다음과 같이 스트림의 요소들의 합을 구하는 코드가 있었다.

	int score = cards.stream()
                .map(card -> card.getNumber().getScore()) //Stream<Integer> 반환
                .reduce(0, Integer::sum);

하지만 여기에는 스트림을 만들 때 int를 Integer로 박싱해주고, 다시 요소들을 합할 때 각 Integer들을 int로 언박싱을 하는 과정이 포함되어 있다. 뭔가 불편한 기분이 든다.

 

여기에 기본형 int에 특화된 IntStream을 사용해볼 수 있다. 중간에 IntStream으로 매핑 해주면 된다. 그러면 Stream<Integer> 대신 IntStream을 반환한다. 이렇게 사용하면 int를 Integer로 박싱하는 과정을 피할 수 있다.

        int score = cards.stream()
                .mapToInt(card -> card.getNumber().getScore()) //IntStream 반환
                .sum();

 

혹시 다시 Stream<Integer>로 복원하고 싶다면 boxed()를 달아줄 수 있다.

	int score = cards.stream()
                .mapToInt(card -> card.getNumber().getScore()) //IntStream
                .boxed(); //Stream<Integer>

 

 

 

 


혹시 내용에 오류가 있다면 댓글로 알려주시면 감사하겠습니다.