스트림(Streams) : 소개 & 활용

HeoSeungYeon·2021년 8월 16일
1

Java Study

목록 보기
5/9
post-thumbnail

개요


Java8이 나오게 되면서 Stream API이란 개념이 도입되었습니다. 많은 사람들이 Java I/O에서 InputStream 과 OutputStream과 헷갈려 하였는데 이 두개에서의 스트림과는 전혀 다른 것입니다. Java8에서 나온 Stream은 함수형 프로그래밍을 자바로 가져오는데 큰 역할을 한 Monad입니다.
함수형 프로그래밍에서 모나드는 일련의 단계로 정의된 연산을 나타내는 구조입니다. 모나드 구조가 있는 유형은 "연산을 연결하거나 해당 유형의 함수를 함께 중첩하는 것"을 정의 합니다. 정말 어렵죠 말이 .. 한 번 직접 코드를 보면서 이해해보는 시간을 가져보도록 합시다! 🤫

0. 스트림(Streams)


Java 8에서 추가한 스트림(Streams)이전 포스팅에서 배운 람다를 활용할 수 있는 기술 중 하나입니다. 스트림은 배열이나 컬렉션 인스턴스를 함수형으로 처리하기 위한 기술인데, '데이터의 흐름'을 여러 개의 함수를 통해서 결과를 도출할 수 있으며, 람다 표현식을 통해 표현을 간결하게 만들 수 있습니다.

Java 8 이전에도 여러 개의 함수로 결과를 도출할 수 있었다고요?

맞습니다, 하지만 결이 다릅니다. 예시 코드를 보면서 설명드리겠습니다.

Pre Java 8 VS Post Java 8

// Pre Java 8
Integer[] numbers ={1,2,3,4,5};
List<Integer> arrays = Arrays.asList(numbers);

Collections.sort(arrays);

for (int i = 0; i < arrays.size(); i++) {
    System.out.println(arrays.get(i));
}

//Post Java 8
arrays.stream().sorted().forEach(System.out::println);

보이시나요?

Java 8 이전의 코드는

  1. 정렬을 한다. Collections.sort(arrays);
  2. 반복문을 통해 출력을 한다.

하지만 Java 8 이후 Stream API를 통한 코드는

  1. 정렬을 하고, 출력을 한다.

이렇게 일련의 연산을 하나로 묶어 '데이터의 흐름'을 제공하는 모습을 확인할 수 있습니다.

또 하나의 스트림(Stream)의 장점은 병렬 처리(multi-threading)이 가능하다는 점입니다. 그렇기 때문에 스트림을 사용하면 많은 작업들을 빠르게 할 수 있다는 장점을 가지고 있습니다.

1. 스트림(Stream)의 동작


스트림을 이해하기 위해선 3가지 동작을 이해해야 합니다.

  1. 생성하기
    • 스트림 인스턴스를 생성합니다.
  2. 가공하기
    • 필터링(filtering) 및 맵핑(mapping) 등 원하는 결과를 만들기 위한 중간 작업(Intermediate Operations)을 수행합니다.
  3. 결과 만들기
    • 최종적으로 결과를 만들어 내는 작업입니다. (Terminal Operation)

1-1) 스트림 인스턴스 생성하기


스트림 인스턴스는 대표적으로 배열이나 컬렉션을 통해 만들 수 있습니다. 이 외에도 다양한 방법으로 스트림 인스턴스를 만들 수 있는 방법이 존재합니다. 스트림 인스턴스를 생성할 수 있는 다양한 방법! 하나하나씩 알아봅시다☺️

(1) Create 스트림 by 배열


배열로 스트림을 생성할 때에는 Arrays.stream 메소드를 사용합니다.

Integer[] arr = new Integer[]{1,2,3};
Stream<Integer> streamArray = Arrays.stream(arr);

(2) Create 스트림 by 컬렉션


Collection 타입(Collection, List, Set)의 경우엔 인터페이스에 추가된 디폴트 메소드인 stream() 을 이용해서 스트림을 생성할 수 있습니다.

Collection Interface

public interface Collection<E> extends Iterable<E> {
	default Stream<E> stream() {
	        return StreamSupport.stream(spliterator(), false);
	}
}

예시 코드

Integer[] numbers ={1,2,3,4,5};
List<Integer> arrays = Arrays.asList(numbers);
      
Stream<Integer> streamList = arrays.stream();

(3) Create Empty 스트림


스트림을 생성할 때 만약 배열이나 컬렉션의 요소의 갯수가 0개이거나, null 일 경우, 비어 있는 스트림을 생성할 수 있습니다.

//empty Stream
List<Integer> array = new ArrayList<>();

Stream<Integer> stream = array.isEmpty()? Stream.empty() : array.stream();

(4) Create 스트림 by Stream.builder()


Stream 클래스의 builder() 메서드를 이용하면 개발자가 직접 스트림에 원하는 값을 삽입할 수 있습니다.

public static<T> Builder<T> builder() {
    return new Streams.StreamBuilderImpl<>();
}

builder() 를 통해 생성된 스트림은 build() 메서드를 통해 반환 할 수 있습니다.

//Stream builder 
Stream<Integer> builderStream =
        Stream.<Integer>builder()
                .add(1).add(2).add(3)
                .build(); // [1,2,3]

(5) Create 스트림 by Stream.generate()


Stream 클래스의 generate() 메서드를 이용하면 Suuplier 에서 반환 해주는 함수형 인터페이스를 통해 값을 삽입할 수 있습니다.

public static<T> Stream<T> generate(Supplier<? extends T> s) {
        Objects.requireNonNull(s);
        return StreamSupport.stream(
                new StreamSpliterators.InfiniteSupplyingSpliterator.OfRef<>(Long.MAX_VALUE, s), false);
    }

generate() 메서드에선 스트림의 크기를 Long.MAX_VALUE로 설정되어있기 때문에, 직접 최대 크기를 제한할 필요가 있습니다.

//Stream Generater
Stream<Integer> generatedStream =
        Stream.generate(() -> 1).limit(5); // [1,1,1,1,1]

크기를 5로 제한하여 1이 5개 들어간 스트림이 생성됩니다.

(6) Create 스트림 by Stream.iterate()


Stream의 iterate 메서드를 이용하면 초기값과, 초기값을 다루는 람다식을 통해 스트림에 데이터를 삽입할 수 있습니다.

public static<T> Stream<T> iterate(final T seed, final UnaryOperator<T> f) {
    Objects.requireNonNull(f);
    Spliterator<T> spliterator = new Spliterators.AbstractSpliterator<>(Long.MAX_VALUE,
           Spliterator.ORDERED | Spliterator.IMMUTABLE) {
        T prev;
        boolean started;

        @Override
        public boolean tryAdvance(Consumer<? super T> action) {
            Objects.requireNonNull(action);
            T t;
            if (started)
                t = f.apply(prev);
            else {
                t = seed;
                started = true;
            }
            action.accept(prev = t);
            return true;
        }
    };
    return StreamSupport.stream(spliterator, false);
}

마찬가지로 iterate 메서드도 스트림의 크기를 초기에 Long.MAX_VALUE 로 설정하기 때문에 크기를 제한해주어야 합니다.

//Stream Iterate
Stream<Integer> iteratedStream =
        Stream.iterate(1, n -> n + 1).limit(5); // [1,2,3,4,5]

초기값을 1로 설정하고(Stream.iterate(1,)

매개변수 n에 1을 더한 값을 return 해주는 람다식(n -> n + 1)을 통해 데이터를 삽입하고 ****

스트림의 크기가 5일 때까지(.limit(5);) 반복하여 스트림을 생성할 수 있습니다.

1-2) 스트림 인스턴스 가공하기


위의 방식으로 생성된 스트림의 데이터에서 원하는 데이터를 만드는 중간 작업(Intermediate Operations) 을 진행할 수 있습니다. 이 중간 작업의 결과스트림반환(Return) 해주기 때문에 Operation을 지속적으로 이어 붙일 수 있습니다.(Chaining)

중간 작업은 크게 4가지 행위로 나눠볼 수 있습니다.

  • Filtering
  • Mapping
  • Sorting
  • Iterating

4가지 행위의 중간 작업을 하기에 앞서 대상이 되는 Stream 인스턴스를 픽스하고 이 인스턴스를 통해 실습을 해보도록 하겠습니다!

User Class

public class User {
    private String name;
    private String nickname;
    private String email;
    private int age;

    public User(String name, String nickname, String email, int age) {
        this.name = name;
        this.nickname = nickname;
        this.email = email;
        this.age = age;
    }
...
}

Sample Stream Instance

List<User> users = new ArrayList<>();
users.add(new User("허승연", "햄찌", "dia0312@naver.com", 26));
users.add(new User("손흥민", "소니", "sonny@naver.com", 30));
users.add(new User("권지용", "지드래곤", "gdragon@naver.com", 32));

Stream<User> userStream = users.stream();

(1) Intermediate Operation - Filtering


필터(Filter)는 스트림의 전체 요소를 개발자가 원하는 조건으로 필터링하여 스트림 객체를 반환해주는 중간 작업입니다.

이 때, 원하는 조건으로 필터링은 "조건을 통해 평가한 결과의 true Or False 값을 반환해주는 함수형 인터페이스" Predicate 를 통해 구현할 수 있습니다.

Stream<T> filter(Predicate<? super T> predicate);

예시 코드

Stream<User> userStream = users.stream();

Stream<User> filteredUserStream = userStream.filter(user -> user.getAge()>28); 

filteredUserStream.forEach(user->System.out.println(user.getName()));
// 손흥민
// 권지용

User 객체의 Age 값이 28 초과인 조건으로 필터링을 진행했습니다.

결과가 정상적으로 출력되어지는 것을 확인할 수 있습니다.

(2) Intermediate Operation - Mapping


맵핑(Mapping)은 스트림의 전체 요소를 개발자가 원하는 특정 값으로 변환하여 스트림 객체를 반환해주는 중간 작업입니다.

이 때, 원하는 특정 값으로 변환은 값을 변환하기 위한 람다식을 매개변수로 받습니다.

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

예시 코드

Stream<User> mappedUserStream = userStream.map(user -> {
    user.setNickname("슈퍼"+user.getNickname());
    return user;
});

mappedUserStream.forEach(user->System.out.println(user.getNickname()));

// 슈퍼햄찌
// 슈퍼소니
// 슈퍼지드래곤

User 객체의 NickName 값에 "슈퍼"를 붙여 설정하여 맵핑을 진행하였습니다.

결과가 정상적으로 출력되어지는 것을 확인할 수 있습니다.

(3) Intermediate Operation - Sorting


정렬(Sorting)은 스트림의 전체 요소를 개발자가 원하는 순서로 정렬하여 스트림 객체를 반환해주는 중간 작업입니다.

이 때, 원하는 순서로 정렬은 다른 정렬과 동일하게 Comparator을 이용합니다.

Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);

만약 Comparator 매개변수가 없을 경우, default 정렬 조건은 오름차순으로 됩니다.

현재는 정렬할려는 대상이 User 객체들이므로 compareTo 메서드를 오버라이딩한 뒤 sorted()를 진행해보도록 하겠습니다.

1) sorted() 예시 코드


User - compareTo

**@Override
public int compareTo(User other) {
    if(this.getAge()<other.getAge()){
        return 1;
    }
    else if(this.getAge()>other.getAge()){
        return -1;
    }
    else{
        return 0;
    }
}**

Main.class

Stream<User> sortedUserStream = userStream.sorted();

sortedUserStream.forEach(user->System.out.println(user.getNickname()+"의 나이 : "+user.getAge()));

// 지드래곤의 나이 : 32
// 소니의 나이 : 30
// 햄찌의 나이 : 26

다음과 같이 User 클래스에 정의된 compareTo에 맞게 결과가 출력되는 것을 확인할 수 있습니다.

2) sorted(Comparator<? super T> comparator); 예시 코드


이번엔 Comparator 를 인자로 넘겨주어 sorted 메서드를 실행시켜보았습니다.

Stream<User> sortedUserStream = userStream.sorted(Comparator.comparing(User::getAge));

sortedUserStream.forEach(user->System.out.println(user.getNickname()+"의 나이 : "+user.getAge()));

// 햄찌의 나이 : 26
// 소니의 나이 : 30
// 지드래곤의 나이 : 32

구현된 Comparator 에 맞게 출력되는 것을 확인할 수 있습니다.

(4) Intermediate Operation - Iterating


중간 작업에서의 반복(Iterating)peek() 메서드를 통해 수행합니다.

Stream에서의 반복(Iterating)은 중간 작업 메서드인 peek()과 최종 작업 메서드인 forEach()를 통해 수행할 수 있습니다. 스트림의 전체 요소를 하나하나씩 반복하여 개발자가 정한 연산을 수행하는 것은 동일하지만 수행 시점에 있어서 차이점이 있습니다.

중간 작업 메서드의 경우, 최종 작업 메서드가 실행되어야지만 동작할 수 있습니다.

이 때 사용되는 peek() 메서드원하는 작업을 수행하고 확인 하기 위해 특정 결과를 반환하지 않는 함수형 인터페이스인 Consumer을 매개변수로 전달받습니다.

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

예시 코드

Stream<User> iteratedUserStream = userStream.peek(user -> System.out.println(user.getEmail()));

iteratedUserStream.forEach(user -> System.out.println(user.getName()));

// sonny@naver.com
// 손흥민
// dia0312@naver.com
// 허승연
// gdragon@naver.com
// 권지용

peek() 메서드는 중간작업을 수행하기 때문에 결과를 만드는 메서드를 사용하기 전까진 연산이 수행되지 않습니다.

예시코드에서 보는 것과 같이 forEach와 같은 결과 연산을 수행해야지만 peek() 메서드의 연산 결과를 확인할 수 있습니다.

1-3) 스트림 인스턴스 결과 만들기


위의 단계들로

  • 스트림 인스턴스 생성
  • 스트림 가공( 중간 작업 )

을 해보았습니다.

그렇다면 이제 우리는 가공한 스트림을 통해 결과값을 도출하는 최종 작업(terminal operation)을 수행해야 합니다.

앞으로 설명드릴 최종 작업에는 다음과 같은 행위를 수행 할 수 있습니다.

  • Calculating
  • Reduction
  • Collecting
  • Matching
  • Iterating

이번 최종 작업 실습에서도 중간 작업 때 했던 예시코드로 진행해보도록 하겠습니다.😊

최종 작업 실습 - 샘플 Stream Instance

List<User> users = new ArrayList<>();
users.add(new User("허승연", "햄찌", "dia0312@naver.com", 26));
users.add(new User("손흥민", "소니", "sonny@naver.com", 30));
users.add(new User("권지용", "지드래곤", "gdragon@naver.com", 32));

Stream<User> userStream = users.stream();

(1) Terminal Operation - Calculating


스트림 API에선 수학 연산(Calculating)에 대한 기본적인 기능을 제공합니다.

1) Sum


int sumValue = userStream.mapToInt(User::getAge).sum();

System.out.println("sum Value : "+sumValue);

//sum Value : 88 
  • Sum 연산을 해야 하므로 스트림의 전체 요소가 "숫자"여야지만 가능합니다.
  • User 객체의 나이의 합을 계산한 뒤, 출력합니다.

2) Count


long countValue = userStream.count();

System.out.println("count Value : "+countValue);

// count Value : 3
  • Stream 에 있는 요소의 갯수를 센 뒤, 출력합니다.
  • 만약 요소가 0개 일 경우, 0이 출력 됩니다.

3) Min , Max


min() 예시 코드

OptionalInt minValue = userStream.mapToInt(User::getAge).min();

System.out.println("min Value : "+minValue);

// min Value : OptionalInt[26]

max() 예시 코드


OptionalInt maxValue = userStream.mapToInt(User::getAge).max();

System.out.println("max Value : "+maxValue);

// max Value : OptionalInt[32]

4) Average


average() 예시 코드

OptionalDouble average = userStream.mapToInt(User::getAge).average();

System.out.println("average Value : "+ average);

// average Value : OptionalDouble[29.333333333333332]

min(), max(), average() 메서드의 경우 빈 스트림일 경우, 값을 표현할 수 없게 됩니다. 그렇기 때문에 Optional 을 이용하여 값을 반환할 수 있습니다.

Optional 을 처리하기 위해 Stream에서는 ifPresent 메소드를 지원합니다.

public void ifPresent(DoubleConsumer action) {
    if (isPresent) {
        action.accept(value);
    }
}

ifPresent 메서드는 값이 있을 경우에만 동작을 수행합니다.

userStream.mapToInt(User::getAge).average().ifPresent(System.out::println);

// 29.333333333333332

위 코드의 동작을 순서대로 보면 다음과 같습니다.

  • user 객체의 age 값으로 스트림 요소를 매핑하고,
    • userStream.mapToInt(User::getAge)
  • age 들의 평균을 구한 뒤,
    • .average()
  • 평균 값이 주어졌을 경우, 해당 값을 출력하는 동작을 수행하라.
    • .ifPresent(System.out::println)

(2) Terminal Operation - Reduction


스트림에서 reduce() 메서드를 통해서 원하는 결과를 만들어낼 수 있습니다.

reduce()는 다음과 같이 파라미터의 갯수에 따라 3가지 종류가 있습니다.

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

각 매개변수의 역할은 다음과 같습니다.

  • accumulator : 요소에 따른 연산을 정의합니다.
  • identity : 연산의 초기값 혹은 스트림이 비어서 연산을 할 수 없을 경우 사용되는 값으로 정의합니다.
  • combiner : 병렬 스트림에서 나누어 계산한 결과를 하나로 합칠 때 수행되는 연산을 정의합니다.

예시 코드 - 매개 변수 3개 reduce()

?

(3) Terminal Operation - Collecting


최종 작업 메서드인 collect 메소드는 Collector 타입의 인자를 받아 스트림의 요소를 원하는 결과로 만들어 주는 메소드인데요.

어떤 Collector의 행위를 인자로 넘겨주냐에 따라 결과값이 다릅니다.

  • Collectors.toList()
  • Collectors.joining()
  • Collectors.averageingInt()
  • Collectors.summingInt()
  • Collectors.summarizingInt()
  • Collectors.groupingBy()
  • Collectors.partitioningBy()
  • Collectors.collectingAndThen()
  • Collector.of()

(4) Terminal Operation - Matching


최종 작업에서 Matching 은 해당 조건의 만족 여부를 반환해주는 함수형 인터페이스인 Predicate람다식을 받아 결과를 리턴해줍니다.

Matching을 수행하는 메서드는 3가지가 있습니다.

  • anyMatch : 하나라도 조건을 만족하는 요소가 있으면 True 리턴
  • allMatch : 모두 조건을 만족하면 True 리턴
  • noneMatch : 모두 조건을 만족하지 않으면 True 리턴
boolean anyMatch(Predicate<? super T> predicate);
boolean allMatch(Predicate<? super T> predicate);
boolean noneMatch(Predicate<? super T> predicate);

예시 코드를 통해 확인해봅시다.

1) anyMatch 예시 코드

boolean anyMatch = userStream.anyMatch(user -> user.getAge()>30);

System.out.println("anyMatch = " + anyMatch);

2) allMatch 예시 코드

boolean allMatch = userStream.allMatch(user -> user.getAge()>23);

System.out.println("allMatch = " + allMatch);

3) noneMatch 예시 코드

boolean noneMatch = userStream.noneMatch(user -> user.getAge()>35);

System.out.println("noneMatch = " + noneMatch);

위 3가지 연산 결과는 모두 True를 반환합니다.

(5) Terminal Operation - Iterating


1-2에서 중간 작업의 반복(Iterating)은 peek() 메서드를 통해 이루어진다고 배웠습니다.

이번엔 최종 작업의 반복(Iterating)을 담당하는 foreach() 메서드입니다.

중간 작업의 peek() 과 달리 각 요소를 순회하며 foreach() 에 들어온 인자의 동작을 수행합니다.

foreach() 메서드를 하기에 앞서 User 클래스에 toString() 메서드를 오버라이딩 하였습니다.

User.class - toString()

**@Override
public String toString() {
    return "User{" +
            "name='" + name + '\'' +
            ", nickname='" + nickname + '\'' +
            ", email='" + email + '\'' +
            ", age=" + age +
            '}';
}**

foreach() 예시 코드

userStream.forEach(user -> System.out.println(user.toString()));

// User{name='손흥민', nickname='소니', email='sonny@naver.com', age=30}
// User{name='허승연', nickname='햄찌', email='dia0312@naver.com', age=26}
// User{name='권지용', nickname='지드래곤', email='gdragon@naver.com', age=32}

참고자료


Java 스트림 Stream (1) 총정리

2-03.함수형 프로그래밍 - Monad

Java 8 Stream Tutorial

0개의 댓글