Java8이 나오게 되면서 Stream API이란 개념이 도입되었습니다. 많은 사람들이 Java I/O에서 InputStream 과 OutputStream과 헷갈려 하였는데 이 두개에서의 스트림과는 전혀 다른 것입니다. Java8에서 나온 Stream은 함수형 프로그래밍을 자바로 가져오는데 큰 역할을 한 Monad입니다.
함수형 프로그래밍에서 모나드는 일련의 단계로 정의된 연산을 나타내는 구조입니다. 모나드 구조가 있는 유형은 "연산을 연결하거나 해당 유형의 함수를 함께 중첩하는 것"을 정의 합니다. 정말 어렵죠 말이 .. 한 번 직접 코드를 보면서 이해해보는 시간을 가져보도록 합시다! 🤫
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 이전의 코드는
Collections.sort(arrays);
하지만 Java 8 이후 Stream API를 통한 코드는
이렇게 일련의 연산을 하나로 묶어 '데이터의 흐름'을 제공하는 모습을 확인할 수 있습니다.
또 하나의 스트림(Stream)의 장점은 병렬 처리(multi-threading)이 가능하다는 점입니다. 그렇기 때문에 스트림을 사용하면 많은 작업들을 빠르게 할 수 있다는 장점을 가지고 있습니다.
스트림을 이해하기 위해선 3가지 동작을 이해해야 합니다.
스트림 인스턴스는 대표적으로 배열이나 컬렉션을 통해 만들 수 있습니다. 이 외에도 다양한 방법으로 스트림 인스턴스를 만들 수 있는 방법이 존재합니다. 스트림 인스턴스를 생성할 수 있는 다양한 방법! 하나하나씩 알아봅시다☺️
배열로 스트림을 생성할 때에는 Arrays.stream
메소드를 사용합니다.
Integer[] arr = new Integer[]{1,2,3};
Stream<Integer> streamArray = Arrays.stream(arr);
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();
스트림을 생성할 때 만약 배열이나 컬렉션의 요소의 갯수가 0개이거나, null 일 경우, 비어 있는 스트림을 생성할 수 있습니다.
//empty Stream
List<Integer> array = new ArrayList<>();
Stream<Integer> stream = array.isEmpty()? Stream.empty() : array.stream();
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]
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개 들어간 스트림이 생성됩니다.
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);
) 반복하여 스트림을 생성할 수 있습니다.
위의 방식으로 생성된 스트림의 데이터에서 원하는 데이터를 만드는 중간 작업(Intermediate Operations) 을 진행할 수 있습니다. 이 중간 작업의 결과는 스트림을 반환(Return) 해주기 때문에 Operation을 지속적으로 이어 붙일 수 있습니다.(Chaining)
중간 작업은 크게 4가지 행위로 나눠볼 수 있습니다.
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();
필터(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 초과인 조건으로 필터링을 진행했습니다.
결과가 정상적으로 출력되어지는 것을 확인할 수 있습니다.
맵핑(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 값에 "슈퍼"를 붙여 설정하여 맵핑을 진행하였습니다.
결과가 정상적으로 출력되어지는 것을 확인할 수 있습니다.
정렬(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 에 맞게 출력되는 것을 확인할 수 있습니다.
중간 작업에서의 반복(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() 메서드의 연산 결과를 확인할 수 있습니다.
위의 단계들로
을 해보았습니다.
그렇다면 이제 우리는 가공한 스트림을 통해 결과값을 도출하는 최종 작업(terminal operation)을 수행해야 합니다.
앞으로 설명드릴 최종 작업에는 다음과 같은 행위를 수행 할 수 있습니다.
이번 최종 작업 실습에서도 중간 작업 때 했던 예시코드로 진행해보도록 하겠습니다.😊
최종 작업 실습 - 샘플 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();
스트림 API에선 수학 연산(Calculating)에 대한 기본적인 기능을 제공합니다.
1) Sum
int sumValue = userStream.mapToInt(User::getAge).sum();
System.out.println("sum Value : "+sumValue);
//sum Value : 88
2) Count
long countValue = userStream.count();
System.out.println("count Value : "+countValue);
// count Value : 3
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
위 코드의 동작을 순서대로 보면 다음과 같습니다.
userStream.mapToInt(User::getAge)
.average()
.ifPresent(System.out::println)
스트림에서 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);
각 매개변수의 역할은 다음과 같습니다.
예시 코드 - 매개 변수 3개 reduce()
?
최종 작업 메서드인 collect 메소드는 Collector 타입의 인자를 받아 스트림의 요소를 원하는 결과로 만들어 주는 메소드인데요.
어떤 Collector의 행위를 인자로 넘겨주냐에 따라 결과값이 다릅니다.
최종 작업에서 Matching 은 해당 조건의 만족 여부를 반환해주는 함수형 인터페이스인 Predicate의 람다식을 받아 결과를 리턴해줍니다.
Matching을 수행하는 메서드는 3가지가 있습니다.
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를 반환합니다.
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}