// post
Stream API란
2022년 12월 7일
Stream API에 대한 소개 Java는 객체지향 언어이기 때문에 기본적으로 함수형 프로그래밍이 불가능하다.하지만 JDK8부터 Stream API와 람다식,함수형 인터페이스 등을 지원하면서 Java를 이용해 함수형으로 프로그래밍할
[Stream API에 대한 소개]Java는 객체지향 언어이기 때문에 기본적으로 함수형 프로그래밍이 불가능하다.하지만 JDK8부터 Stream API와 람다식,함수형 인터페이스 등을 지원하면서 Java를 이용해 함수형으로 프로그래밍할 수 있는 API 들을 제공해주고 있다.그 중에서 Stream API는 데이터를 추상화하고,처리하는데 자주 사용되는 함수들을 정의해두었다.여기서 데이터를 추상화하였다는 것은 데이터의 종류에 상관 없이 같은 방식으로 데이터를 처리할 수 있다는 것을 의미하며,그에 따라 재사용성을 높일 수 있다.예를 들어 주어진 배열이나 리스트의 데이터를 정렬된 상태로 출력하고자 한다고 하자.Stream API를 사용하지 않은 경우 다음과 같이 코드를 작성할 수 있다.
// Stream 사용 전
String[] nameArr = {"IronMan", "Captain", "Hulk", "Thor"}
List<String> nameList = Arrays.asList(nameArr);
// 원본의 데이터가 직접 정렬됨
Arrays.sort(nameArr);
Collections.sort(nameList);
for (String str: nameArr) {
System.out.println(str);
}
for (String str : nameList) {
System.out.println(str);
}
위의 코드들도 충분히 괜찮은 코드들이다.하지만 이를 더욱 간결하고 가독성있게 정리할 수 있으며,원본의 데이터에 변형을 가하지 않는 방법이 있다면 그것은 분명 더욱 좋은 코드일 것이다.Java의 Stream API는 원본의 데이터에 변경 없이 위의 코드를 더욱 간략하게 작성하는 방법을 제공하고 있다.위의 내용을 함수형으로 리팩토링하면 다음과 같이 작성할 수 있다.
// Stream 사용 후
String[] nameArr = {"IronMan", "Captain", "Hulk", "Thor"}
List<String> nameList = Arrays.asList(nameArr);
// 원본의 데이터가 아닌 별도의 Stream을 생성함
Stream<String> nameStream = nameList.stream();
Stream<String> arrayStream = Arrays.stream(nameArr);
// 복사된 데이터를 정렬하여 출력함
nameStream.sorted().forEach(System.out::println);
arrayStream.sorted().forEach(System.out::println);
이처럼 Stream API를 활용하면 코드의 라인수도 줄이고,가독성도 높일 수 있다.이러한 많은 장점을 지닌 Stream API에 대해 자세히 살펴보도록 하자.[Stream API의 특징]
- 원본의 데이터를 변경하지 않는다.Stream API는 원본의 데이터를 조회하여 원본의 데이터가 아닌 별도의 요소들로 Stream을 생성한다.그렇기 때문에 원본의 데이터로부터 읽기만 할 뿐이며,정렬이나 필터링 등의 작업은 별도의 Stream 요소들에서 처리가 된다.
List<String> sortedList = nameStream.sorted().collect(Collections.toList());
- Stream은 일회용이다.Stream API는 일회용이기 때문에 한번 사용이 끝나면 재사용이 불가능하다.Stream이 또 필요한 경우에는 Stream을 다시 생성해주어야 한다.만약 닫힌 Stream을 다시 사용한다면 IllegalStateException이 발생하게 된다.
userStream.sorted().forEach(System.out::print);
// 스트림이 이미 사용되어 닫혔으므로 에러 발생 int count = userStream.count(); // IllegalStateException 발생 java.lang.IllegalStateException: stream has already been operated upon or closed at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229) at java.util.stream.ReferencePipeline.noneMatch(ReferencePipeline.java:459)
내부 반복으로 작업을 처리한다.Stream을 이용하면 코드가 간결해지는 이유 중 하나는'내부 반복'때문이다.기존에는 반복문을 사용하기 위해서 for이나 while 등과 같은 문법을 사용해야 했지만,stream에서는 그러한 반복 문법을 메소드 내부에 숨기고 있기 때문에,보다 간결한 코드의 작성이 가능하다.
// 반복문이 forEach라는 함수 내부에 숨겨져 있다. nameStream.forEach(System.out::println);Stream API의 연산 종류
[Stream API의 3가지 단계]Stream은 데이터를 처리하기 위해 다양한 연산들을 지원한다.Stream이 제공하는 연산을 이용하면 복잡한 작업들을 간단히 처리 할 수 있는데,스트림에 대한 연산은 크게 생성하기,가공하기,결과만들기 3가지 단계로 나눌 수 있다.생성하기Stream 객체를 생성하는 단계Stream은 재사용이 불가능하므로,닫히면 다시 생성해주어야 한다.
Stream 연산을 하기 위해서는 먼저 Stream 객체를 생성해주어야 한다.배열,컬렉션,임의의 수,파일 등 거의 모든 것을 가지고 스트림을 생성할 수 있다.여기서 주의할 점은 연산이 끝나면 Stream이 닫히기 때문에,Stream이 닫혔을 경우 다시 Stream을 생성해야 한다는 것이다.가공하기
원본의 데이터를 별도의 데이터로 가공하기 위한 중간 연산연산 결과를 Stream으로 다시 반환하기 때문에 연속해서 중간 연산을 이어갈 수 있다.가공하기 단계는 원본의 데이터를 별도의 데이터로 가공하기 위한 중간 연산의 단계이다.어떤 객체의 Stream을 원하는 형태로 처리할 수 있으며,중간 연산의 반환값은 Stream이기 때문에 필요한 만큼 중간 연산을 연결하여 사용할 수 있다.결과 만들기
가공된 데이터로부터 원하는 결과를 만들기 위한 최종 연산Stream의 요소들을 소모하면서 연산이 수행되기 때문에 1번만 처리가능하다.[Stream 연산 예시 코드]ListmyList = Arrays.asList("a1", "a2", "b1", "c2", "c1"); myList .stream() // 생성하기 .filter(s -> s.startsWith("c")) // 가공하기 .map(String::toUpperCase) // 가공하기 .sorted() // 가공하기 .count(); // 결과만들기 위의 코드에서는 먼저 stream()을 통해 Stream 객체를 생성하고 있다.그리고 원하는 데이터를 필터링하고,변형하고,정렬하는 중간 연산을 진행하고 있다.이렇듯 중간 연산이 세미콜론 없이 여러 번 연결되는 것은 해당 중간 연산이 Stream을 반환하기 때문이다.이렇게 Stream 연산이 연결된 것을 연산 파이프라인이라고 하기도 한다.최종 연산에서는 필요한 결과를 만들 수 있다.위의 예제에서는 count()를 통해 남아 있는 요소의 갯수를 최종적으로 반환하도록 되어 있다.물론 forEach와 같이 값을 반환하지 않는 최종 연산도 존재한다.위의 예시 코드에서 확인 가능하듯 Stream 연산들은 매개변수로 함수형 인터페이스(Functional Interface)를 받도록 되어있다.그렇기 때문에 Stream API의 연산들에 대해 알아보기 전에 함수형 인터페이스와 람다식을 먼저 자세히 알아보도록 하자.Stream 연산들은 매개변수로 함수형 인터페이스(Functional Interface)를 받도록 되어있다.그리고 람다식은 반환값으로 함수형 인터페이스를 반환하고 있다.그렇기 때문에 우리는 Stream API를 정확히 이해하기 위해 람다식과 함수형 인터페이스에 대해 알고 있어야 한다.[람다식(Lambda Expression)이란?]람다식(Lambda Expression)이란 함수를 하나의 식(expression)으로 표현한 것이다.함수를 람다식으로 표현하면 메소드의 이름이 필요 없기 때문에,람다식은 익명 함수(Anonymous Function)의 한 종류라고 볼 수 있다.익명함수(Anonymous Function)란 함수의 이름이 없는 함수로,익명함수들은 모두 일급 객체이다.일급 객체가 무엇인지는 앞선 포스팅에서 자세히 다루었다.일급 객체인 함수는 변수처럼 사용가능하며 매개 변수로 전달이 가능하는 등의 특징을 가지고 있다.기존의 방식에서는 함수를 선언할 때 다음과 같이 선언하였다.// 기존의 방식 반환티입 메소드명 (매개변수, ...) { 실행문 } // 예시 public String hello() { return "Hello World!"; } 하지만 람다 방식으로는 위와 같이 메소드 명이 불필요하며,다음과 같이 괄호()와 화살표->를 이용해 함수를 선언하게 된다.// 람다 방식 (매개변수, ... ) -> { 실행문 ... } // 예시 () -> "Hello World!"; 이렇게 람다식이 등장하게 된 이유는 불필요한 코드를 줄이고,가독성을 높이기 위함이다.그렇기 때문에 함수형 인터페이스의 인스턴스를 생성하여 함수를 변수처럼 선언하는 람다식에서는 메소드의 이름이 불필요하다고 여겨져서 이를 사용하지 않는다.대신 컴파일러가 문맥을 살펴 타입을 추론한다.또한 람다식으로 선언된 함수는 1급 객체이기 때문에 Stream API의 매개변수로 전달이 가능해진다.[람다식(Lambda Expression)의 특징]람다식(Lambda Expression)의 특징람다식 내에서 사용되는 지역변수는 final이 붙지 않아도 상수로 간주된다.람다식으로 선언된 변수명은 다른 변수명과 중복될 수 없다.람다식(Lambda Expression)의 장점-코드를 간결하게 만들 수 있다.-식에 개발자의 의도가 명확히 드러나 가독성이 높아진다.-함수를 만드는 과정없이 한번에 처리할 수 있어 생산성이 높아진다.-병렬프로그래밍이 용이하다.람다식(Lambda Expression)의 단점-람다를 사용하면서 만든 무명함수는 재사용이 불가능하다.-디버깅이 어렵다.-람다를 남발하면 비슷한 함수가 중복 생성되어 코드가 지저분해질 수 있다.-재귀로 만들경우에 부적합하다.결국 무조건 람다가 좋다는 보장은 없다.상황에 따라 필요에 맞는 방법을 사용하는 것이 중요하다. 함수형 인터페이스(Functional Interface)란?
[함수형 인터페이스(Functional Interface)란?]이제 우리는 람다식으로 순수 함수를 선언할 수 있게 되었다.하지만 Java는 기본적으로 객체지향 언어이기 때문에 순수 함수와 일반 함수를 다르게 취급하고 있으며,Java에서는 이를 구분하기 위해 함수형 인터페이스가 등장하게 되었다.함수형 인터페이스란 함수를 1급 객체처럼 다룰 수 있게 해주는 어노테이션으로,인터페이스에 선언하여 단 하나의 추상 메소드만을 갖도록 제한하는 역할을 한다.함수형 인터페이스를 사용하는 이유는 Java의 람다식이 함수형 인터페이스를 반환하기 때문이다.예를 들어 우리가 두 값 중 큰 값을 구하는 익명 함수를 개발하였다고 하자.그러면 우리는 지금까지 다음과 같이 개발을 하였을 것이다.public class Lambda{public static void main(String[]args){//기존의 익명함수System.out.println(new MyLambdaFunction(){public int max(int a,int b){return a>b?a:b;}}.max(3,5));}}하지만 함수형 인터페이스의 등장으로 우리는 함수를 변수처럼 선언할 수 있게 되었고,코드 역시 간결하게 작성할 수 있게 되었다.함수형 인터페이스를 구현하기 위해서는 인터페이스를 개발하여 그 내부에는 1개 뿐인 abstract 함수를 선언하고,위에는@FunctionalInterface 어노테이션을 붙여주면 된다.위의 코드를 람다식으로 변경하면 다음과 같다.@FunctionalInterface interface MyLambdaFunction { int max(int a, int b); } public class Lambda { public static void main(String[] args) { // 람다식을 이용한 익명함수 MyLambdaFunction lambdaFunction = (int a, int b) -> a > b ? a : b; System.out.println(lambdaFunction.max(3, 5)); } } 이제 우리는 Java8 이전에 사용했던 익명함수들을 람다식으로 변경해 코드를 줄일 수 있게 되었고,여기서 놓치지 말아야 하는 것은 람다식으로 생성된 순수 함수는 함수형 인터페이스로만 선언이 가능하다는 점이다.또한@FunctionalInterface는 해당 인터페이스가 1개의 함수만을 갖도록 제한하기 때문에,여러 개의 함수를 선언하면 컴파일 에러가 발생할 것이라는 점이다.[Java에서 제공하는 함수형 인터페이스]Java에는 자주 사용될 것 같은 함수형 인터페이스가 이미 정의되어 있으며,총 4가지 함수형 인터페이스를 지원하고 있다.SupplierConsumer Function<T,R>Predicate Supplier
Supplier는 매개변수 없이 반환값 만을 갖는 함수형 인터페이스이다.Supplier는 T get()을 추상 메소드로 갖고 있다.
// 정의 @FunctionalInterface public interface Supplier{ T get(); } // 사용 예시 Supplier supplier = () -> "Hello World!"; System.out.println(supplier.get()); Consumer
Consumer는 객체 T를 매개변수로 받아서 사용하며,반환값은 없는 함수형 인터페이스이다.
Consumer는 void accept(T t)를 추상메소드로 갖는다.또한 Consumer는 andThen이라는 함수를 제공하고 있는데,이를 통해 하나의 함수가 끝난 후 다음 Consumer를 연쇄적으로 이용할 수 있다.아래의 예제에서는 먼저 accept로 받아들인 Consumer를 먼저 처리하고,andThen으로 받은 두 번째 Consumer를 처리하고 있다.함수형에서 함수는 값의 대입 또는 변경 등이 없기 때문에 첫 번째 Consumer가 split으로 데이터를 변경하였다 하더라도 원본의 데이터는 유지된다.// 정의 @FunctionalInterface public interface Consumer{ void accept(T t); default Consumer andThen(Consumer<? super T> after) { Objects.requireNonNull(after); return (T t) -> { accept(t); after.accept(t); }; } } // 예시 Consumer consumer = (str) -> System.out.println(str.split(" ")[0]); consumer.andThen(System.out::println).accept("Hello World"); //출력HelloHello World Function<T,R>
Function은 객체 T를 매개변수로 받아서 처리한 후 R로 반환하는 함수형 인터페이스다.Function은 R apply(T t)를 추상메소드로 갖는다.또한 Function은 Consumer와 마찬가지로 andThen을 제공하고 있으며,추가적으로 compose를 제공하고 있다.앞에서 andThen은 첫 번째 함수가 실행된 이후에 다음 함수를 연쇄적으로 실행하도록 연결해준다고 하였다.하지만 compose는 첫 번째 함수 실행 이전에 먼저 함수를 실행하여 연쇄적으로 연결해준다는 점에서 차이가 있다.또한 identity 함수가 존재하는데,이는 자기 자신을 반환하는 static 함수이다.// 정의 @FunctionalInterface public interface Function<T, R> { R apply(T t); defaultFunction<V, R> compose(Function<? super V, ? extends T> before) { Objects.requireNonNull(before); return (V v) -> apply(before.apply(v)); } default Function<T, V> andThen(Function<? super R, ? extends V> after) { Objects.requireNonNull(after); return (T t) -> after.apply(apply(t)); } static Function<T, T> identity() { return t -> t; } } // 예시, 메소드 참조로 간소화 가능(String::length;) Function<String, Integer> function = str -> str.length(); function.apply("Hello World"); Predicate
Predicate는 객체 T를 매개 변수로 받아 처리한 후 Boolean을 반환한다.Predicate는 Boolean test(T t)을 추상 메소드로 갖고 있다.
// 정의 @FunctionalInterface public interface Predicate{ boolean test(T t); default Predicate and(Predicate<? super T> other) { Objects.requireNonNull(other); return (t) -> test(t) && other.test(t); } default Predicate negate() { return (t) -> !test(t); } default Predicate or(Predicate<? super T> other) { Objects.requireNonNull(other); return (t) -> test(t) || other.test(t); } static Predicate isEqual(Object targetRef) { return (null == targetRef) ? Objects::isNull : object -> targetRef.equals(object); } } // 예시 Predicate predicate = (str) -> str.equals("Hello World"); predicate.test("Hello World"); [메소드 참조(Method Reference)]메소드 참조란 함수형 인터페이스를 람다식이 아닌 일반 메소드를 참조시켜 선언하는 방법이다.일반 메소드를 참조하기 위해서는 다음의 3가지 조건을 만족해야 한다.함수형 인터페이스의 매개변수 타입=메소드의 매개변수 타입함수형 인터페이스의 매개변수 개수=메소드의 매개변수 개수함수형 인터페이스의 반환형=메소드의 반환형참조가능한 메소드는 일반 메소드,Static 메소드,생성자가 있으며 클래스이름::메소드이름 으로 참조할 수 있다.이렇게 참조를 하면 함수형 엔터페이스로 반환이 된다.3가지의 메소드에 대해 메소드 참조 예시를 자세히 살펴보도록 하자. 일반 메소드 참조
예를 들어 위에서 보여준 Function에 메소드 참조를 적용한다고 하자.우선 해당 메소드(length)가 위의 3가지 조건을 만족하는지 살펴보아야 한다.매개변수 없음매개변수 개수=0개반환형=intString의 length 함수는 매개변수가 없으며,반환형이 int로 동일하기 때문에 String::length로 다음과 같이 메소드 참조를 적용할 수 있다.// 기존의 람다식 Function<String, Integer> function = (str) -> str.length(); function.apply("Hello World"); // 메소드 참조로 변경 Function<String, Integer> function = String::length; function.apply("Hello World"); 또한 추가로 예시를 살펴보자.System.out.println()메소드는 반환형이 void이며,파라미터로 String을 받는 메소드이다.그렇기 때문에 우리는 Consumer에System.out.println()메소드를 참조시킬 수 있다.// 일반 메소드를 참조하여 Consumer를 선언한다. Consumerconsumer = System.out::println; consumer.accept("Hello World!!"); // 메소드 참조를 통해 Consumer를 매개변수로 받는 forEach를 쉽게 사용할 수 있다. List list = Arrays.asList("red", "orange", "yellow", "green", "blue"); list.forEach(System.out::println); //interface Iterable default void forEach(Consumer<? super T> action) { Objects.requireNonNull(action); for (T t : this) { action.accept(t); } } Static 메소드 참조Static 메소드 역시 메소드 참조가 가능하다.예를 들어 Objects의 isNull은 반환값이 Boolean이며,매개변수 값은 1개이고,매개 변수가 Object이므로 Predicate로 다음과 같이 메소드 참조가 가능하다.
Predicatepredicate = Objects::isNull; // isNull 함수 public static boolean isNull(Object obj) { return obj == null; } 생성자 참조생성자도 메소드 참조를 할 수 있다.생성자는 new로 생성해주므로 클래스이름::new로 참조할 수 있다.Supplier는 매개변수가 없이 반환값만을 갖는 인터페이스이기 때문에,매개변수 없이 String 객체를 새롭게 생성하는 String의 생성자를 참조하여 Supplier로 선언할 수 있다.
Suppliersupplier = String::new; Stream 생성하기
앞서 설명한대로 Stream API를 사용하기 위해서는 먼저 Stream을 생성해주어야 한다.사용하려는 객체들마다 Collection을 생성하는 방법이 다른데,여기서는 Collection과 Array에 대해서 Stream을 생성하는 방법에 대해 알아보도록 하자.[Collection의 Stream 생성]Collection 인터페이스에는 stream()이 정의되어 있기 때문에,Collection 인터페이스를 구현한 객체들(List,Set 등)은 모두 이 메소드를 이용해 Stream을 생성할 수 있다.stream()을 사용하면 해당 Collection의 객체를 소스로 하는 Stream을 반환한다.// List로부터 스트림을 생성 Listlist = Arrays.asList("a", "b", "c"); Stream listStream = list.stream(); [배열의 Stream 생성]배열의 원소들을 소스로하는 Stream을 생성하기 위해서는 Stream의 of 메소드 또는 Arrays의 stream 메소드를 사용하면 된다.// 배열로부터 스트림을 생성 Stream stream = Stream.of("a", "b", "c"); //가변인자 Stream stream = Stream.of(new String[] {"a", "b", "c"}); Stream stream = Arrays.stream(new String[] {"a", "b", "c"}); Stream stream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3); //end범위 포함 x [원시 Stream 생성]위와 같이 객체를 위한 Stream 외에도 int와 long 그리고 double과 같은 원시 자료형들을 사용하기 위한 특수한 종류의 Stream(IntStream,LongStream,DoubleStream)들도 사용할 수 있으며,Intstream같은 경우 range()함수를 사용하여 기존의 for문을 대체할 수 있다.// 4이상 10 이하의 숫자를 갖는 IntStream IntStream stream = IntStream.range(4, 10); Stream 가공하기(중간연산)
생성한 Stream 객체에서 요소들을 가공하기 위해서는 중간연산이 필요하다.가공하기 단계의 파라미터로는 앞서 설명하였던 함수형 인터페이스들이 사용되며,여러 개의 중간연산이 연결되도록 반환값으로 Stream을 반환한다.[필터링-Filter]Filter는 Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어내는 연산이다.Java에서는 filter 함수의 인자로 함수형 인터페이스 Predicate를 받고 있기 때문에,boolean을 반환하는 람다식을 작성하여 filter 함수를 구현할 수 있다.예를 들어 어떤 String의 stream에서 a가 들어간 문자열만을 포함하도록 필터링하는 예제는 다음과 같이 작성할 수 있다.Streamstream = list.stream() .filter(name -> name.contains("a")); [데이터 변환-Map]Map은 기존의 Stream 요소들을 변환하여 새로운 Stream을 형성하는 연산이다.저장된 값을 특정한 형태로 변환하는데 주로 사용되며,Java에서는 map 함수의 인자로 함수형 인터페이스 function을 받고 있다.예를 들어 String을 요소들로 갖는 Stream을 모두 대문자 String의 요소들로 변환하고자 할 때 map을 이용할 수 있다.Stream stream = names.stream() .map(s -> s.toUpperCase()); 위의 map 함수의 람다식은 메소드 참조를 이용해 변경이 가능하다.이번에는 메소드 참조를 이용하여 파일의 Stream을 파일 이름의 Stream으로 변경해보자.Stream fileStream = Stream.of(new File("Test1.java"), new File("Test2.java"), new File("Test3.java")); //Stream --> Stream 변환 Stream fileNameStream = fileStream.map(File::getName); [정렬-Sorted]Stream의 요소들을 정렬하기 위해서는 sorted를 사용해야 하며,파라미터로 Comparator를 넘길 수도 있다.Comparator 인자 없이 호출할 경우에는 오름차순으로 정렬이 되며,내림차순으로 정렬하기 위해서는 Comparator의 reverseOrder를 이용하면 된다.예를 들어 어떤 Stream의 String 요소들을 정렬하기 위해서는 다음과 같이 sorted를 활용할 수 있다.List list = Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift"); Stream stream = list.stream() .sorted() // [Go, Groovy, Java, Python, Scala, Swift] Stream stream = list.stream() .sorted(Comparator.reverseOrder()) // [Swift, Scala, Python, Java, Groovy, Go] [중복 제거-Distinct]Stream의 요소들에 중복된 데이터가 존재하는 경우,중복을 제거하기 위해 distinct를 사용할 수 있다.distinct는 중복된 데이터를 검사하기 위해 Object의 equals()메소드를 사용한다.예를 들어 중복된 Stream의 요소들을 제거하기 위해서는 아래와 같이 distinct()를 사용할 수 있다.List list = Arrays.asList("Java", "Scala", "Groovy", "Python", "Go", "Swift", "Java"); Stream stream = list.stream() .distinct() // [Java, Scala, Groovy, Python, Go, Swift] 만약 우리가 생성한 클래스를 Stream으로 사용한다고 하면 equals와 hashCode를 오버라이드 해야만 distinct()를 제대로 적용할 수 있다.equals와 hashCode에 대해서는 여기에서 자세히 다루었다.만약 다음과 같은 Employee 클래스가 있다고 하자.public class Employee { private String name; public Employee(String name) { this.name = name; } public String getName() { return name; } } 위의 Employee 클래스는 equals와 hashCode를 오버라이드하지 않았기 때문에,아래의 코드를 실행해도 중복된 데이터가 제거되지 않고,size 값으로 2를 출력하게 된다.import java.util.*; public class Main { public static void main(String[] args) { Employee e1 = new Employee("MangKyu"); Employee e2 = new Employee("MangKyu"); List employees = new ArrayList<>(); employees.add(e1); employees.add(e2); int size = employees.stream().distinct().collect(Collectors.toList()).size(); System.out.println(size); } } 그렇기 때문에 우리는 아래와 같이 equals와 hashCode를 오버라이드하여 이러한 문제를 해결해야 한다.import java.util.Objects; public class Employee { private String name; public Employee(String name) { this.name = name; } public String getName() { return name; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Employee employee = (Employee) o; return Objects.equals(name, employee.name); } @Override public int hashCode() { return Objects.hash(name); } } 위와 같은 코드를 추가하고 main 함수를 다시 실행시키면 size는 1이 된다.[특정 연산 수행-Peek]Stream의 요소들을 대상으로 Stream에 영향을 주지 않고 특정 연산을 수행하기 위한 peek 함수가 존재한다.'확인해본다'라는 뜻을 지닌 peek 단어처럼,peek 함수는 Stream의 각각의 요소들에 대해 특정 작업을 수행할 뿐 결과에 영향을 주지 않는다.또한 peek 함수는 파라미터로 함수형 인터페이스 Consumer를 인자로 받는다.예를 들어 어떤 stream의 요소들을 중간에 출력하기를 원할 때 다음과 같이 활용할 수 있다.int sum = IntStream.of(1, 3, 5, 7, 9) .peek(System.out::println) .sum(); [원시 Stream<->Stream]작업을 하다 보면 일반적인 Stream 객체를 원시 Stream으로 바꾸거나 그 반대로 하는 작업이 필요한 경우가 있다.이러한 경우를 위해서,일반적인 Stream 객체는 mapToInt(),mapToLong(),mapToDouble()이라는 특수한 Mapping 연산을 지원하고 있으며,그 반대로 원시객체는 mapToObject를 통해 일반적인 Stream 객체로 바꿀 수 있다.// IntStream -> Stream IntStream.range(1, 4) .mapToObj(i -> "a" + i) // Stream -> IntStream -> Stream Stream.of(1.0, 2.0, 3.0) .mapToInt(Double::intValue) .mapToObj(i -> "a" + i) Stream 결과 만들기(최종 연산)중간 연산을 통해 생성된 Stream을 바탕으로 이제 결과를 만들 차례이다.결과를 만들기 위한 최종 연산에는 다음과 같은 것들이 존재한다.
[최댓값/최솟값/총합/평균/갯수-Max/Min/Sum/Average/Count]Stream의 요소들을 대상으로 최솟값이나 최댓값 또는 총합을 구하기 위한 최종 연산들이 존재한다.최솟값이나 최댓값을 구하기 위해서는 max와 min을 이용해야 하며,총합 또는 평균 또는 개수를 구하기 위해서는 sum과 average,count를 이용해야 한다.min이나 max 또는 average는 Stream이 비어있는 경우에 값을 특정할 수 없다.그렇기 때문에 다음과 같이 Optional로 값이 반환된다.OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min(); int max = IntStream.of().max().orElse(0); IntStream.of(1, 3, 5, 7, 9).average().ifPresent(System.out::println); 반면에 총합이나 갯수의 경우에는 값이 비어있는 경우 0으로 값을 특정할 수 있다.그렇기 때문에 Stream API는 sum 메소드와 count 메소드에대해 Optional이 아닌 원시 값을 반환하도록 구현해두었다.당연히 Stream이 비어있을 경우에는 0을 반환하게 된다.long count = IntStream.of(1, 3, 5, 7, 9).count(); long sum = LongStream.of(1, 3, 5, 7, 9).sum(); [데이터 수집-collect]Stream의 요소들을 List나 Set,Map,등 다른 종류의 결과로 수집하고 싶은 경우에는 collect 함수를 이용할 수 있다.collect 함수는 어떻게 Stream의 요소들을 수집할 것인가를 정의한 Collector 타입을 인자로 받아서 처리한다.일반적으로 List로 Stream의 요소들을 수집하는 경우가 많은데,이렇듯 자주 사용하는 작업은 Collectors 객체에서 static 메소드로 제공하고 있다.원하는 것이 없는 경우에는 Collector 인터페이스를 직접 구현하여 사용할 수도 있다.collect():스트림의 최종연산,매개변수로 Collector를 필요로 한다.Collector:인터페이스,collect의 파라미터는 이 인터페이스를 구현해야한다.Collectors:클래스,static메소드로 미리 작성된 컬렉터를 제공한다.// collect의 파라미터로 Collector의 구현체가 와야 한다. Object collect(Collector collector) collect()를 응용한 다양한 예제들을 살펴보기 위해 다음과 같은 데이터가 사전에 정의되어 있다고 하자.Product 객체는 수량(amount)와 이름(name)을 변수로 가지며,주어진 데이터를 다양한 방식으로 수집해볼 것이다.ListproductList = Arrays.asList( new Product(23, "potatoes"), new Product(14, "orange"), new Product(13, "lemon"), new Product(23, "bread"), new Product(13, "sugar")); Collectors.toList()Stream에서 작업한 결과를 List로 반환받을 수 있다.아래의 예제에서는 Stream의 요소들을 Product의 이름으로 변환하여,그 결과를 List로 반환받고 있다.
ListnameList = productList.stream() .map(Product::getName) .collect(Collectors.toList()); 만약 해당 결과를 set으로 반환받기를 원한다면Collectors.toSet()을 사용하면 된다. Collectors.joining()
Stream에서 작업한 결과를 1개의 String으로 이어붙이기를 원하는 경우에Collectors.joining()을 이용할 수 있다.Collectors.joining()은 총 3개의 인자를 받을 수 있는데,이를 이용하면 간단하게 String을 조합할 수 있다.delimiter:각 요소 중간에 들어가 요소를 구분시켜주는 구분자prefix:결과 맨 앞에 붙는 문자suffix:결과 맨 뒤에 붙는 문자String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining()); // potatoesorangelemonbreadsugar String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining(" ")); // potatoes orange lemon bread sugar String listToString = productList.stream() .map(Product::getName) .collect(Collectors.joining(", ", "<", ">")); // <potatoes, orange, lemon, bread, sugar>Collectors.averagingInt(),Collectors.summingInt(),Collectors.summarizingInt()
Stream에서 작업한 결과의 평균값이나 총합 등을 구하기 위해서는 Collectors.averagingInt()와Collectors.summingInt()를 이용할 수 있다.물론 총합의 경우 이를 구현할 수 있는 방법이 그 외에도 많이 있다.Double averageAmount = productList.stream() .collect(Collectors.averagingInt(Product::getAmount)); // 86 Integer summingAmount = productList.stream() .collect(Collectors.summingInt(Product::getAmount)); // 86 Integer summingAmount = productList.stream() .mapToInt(Product::getAmount) .sum(); 하지만 만약 1개의 Stream으로부터 갯수,합계,평균,최댓값,최솟값을 한번에 얻고 싶은 경우에는 어떻게 할 수 있을까?동일한 Stream 작업을 여러 번 실행하는 것은 그렇게 좋지 못한 방법이기 때문에,이러한 경우에는Collectors.summarizingInt()를 이용하는 것이 좋다.이를 이용하면 IntSummaryStatistics 객체가 반환되며,필요한 값에 대해 get 메소드를 이용하여 원하는 값을 꺼내면 된다.개수:getCount()합계:getSum()평균:getAverage()최소:getMin()최대:getMax()IntSummaryStatistics statistics = productList.stream() .collect(Collectors.summarizingInt(Product::getAmount)); //IntSummaryStatistics {count=5, sum=86, min=13, average=17.200000, max=23}Collectors.groupingBy()
Stream에서 작업한 결과를 특정 그룹으로 묶기를 원할 수 있다.이러한 경우에는Collectors.groupingBy()를 이용할 수 있으며,결과는 Map으로 반환받게 된다.groupingBy는 매개변수로 함수형 인터페이스 Function을 필요로 한다.예를 들어 수량을 기준으로 grouping을 원하는 경우에 다음과 같이 작성할 수 있으며,같은 수량일 경우에는 List로 묶어서 값을 반환받게 된다.Map<Integer, List> collectorMapOfLists = productList.stream() .collect(Collectors.groupingBy(Product::getAmount)); /* {23=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}], 13=[Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}], 14=[Product{amount=14, name='orange'}]} */ Collectors.partitioningBy()
Collectors.groupingBy()가 함수형 인터페이스 Function을 사용해서 특정 값을 기준으로 Stream 내의 요소들을 그룹핑하였다면,Collectors.partitioningBy()는 함수형 인터페이스 Predicate를 받아 Boolean을 Key값으로 partitioning한다.예를 들어 제품의 갯수가 15보드 큰 경우와 그렇지 않은 경우를 나누고자 한다면 다음과 같이 코드를 작성할 수 있다.Map<Boolean, List> mapPartitioned = productList.stream() .collect(Collectors.partitioningBy(p -> p.getAmount() > 15)); /* {false=[Product{amount=14, name='orange'}, Product{amount=13, name='lemon'}, Product{amount=13, name='sugar'}], true=[Product{amount=23, name='potatoes'}, Product{amount=23, name='bread'}]} */ [조건 검사-Match]Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match 함수를 이용할 수 있다.match 함수는 함수형 인터페이스 Predicate를 받아서 해당 조건을 만족하는지 검사를 하게 되고,검사 결과를 boolean으로 반환한다.match 함수에는 크게 다음의 3가지가 있다.anyMatch:1개의 요소라도 해당 조건을 만족하는가allMatch:모든 요소가 해당 조건을 만족하는가nonMatch:모든 요소가 해당 조건을 만족하지 않는가예를 들어 다음과 같은 예시 코드가 있다고 할 때,아래의 경우 모두 true를 반환하게 된다.List names = Arrays.asList("Eric", "Elena", "Java"); boolean anyMatch = names.stream() .anyMatch(name -> name.contains("a")); boolean allMatch = names.stream() .allMatch(name -> name.length() > 3); boolean noneMatch = names.stream() .noneMatch(name -> name.endsWith("s")); [특정 연산 수행-forEach]Stream의 요소들을 대상으로 어떤 특정한 연산을 수행하고 싶은 경우에는 forEach 함수를 이용할 수 있다.앞에서 살펴본 비슷한 함수로 peek()가 있다.peek()는 중간 연산으로써 실제 요소들에 영향을 주지 않은 채로 작업을 진행하고,Stream을 반환하는 함수였다.하지만 forEach()는 최종 연산으로써 실제 요소들에 영향을 줄 수 있으며,반환값이 존재하지 않는다.예를 들어 요소들을 출력하기를 원할 때 다음과 같이 forEach를 사용할 수 있다.names.stream() .forEach(System.out::println); Stream API 고급 활용 및 사용 시 주의할 점1.FlatMap을 통한 중첩 구조 제거[FlatMap이란?]만약 우리가 처리해야 하는 데이터가 2중 배열 또는 2중 리스트로 되어 있고,이를 1차원으로 처리해야 한다면 어떻게 해야 할까?이러한 경우에 map을 이용해도 결과는 2중 Stream의 형태일 것이다.이처럼 중첩 구조를 한 단계 제거하기 위한 중간 연산이 필요한데,이것이 바로 flatMap이다.flatMap은 Function 함수형 인터페이스를 매개 변수로 받고 있다.예를 들어 다음과 같이 2중 리스트가 존재한다고 할 때,이를 1중 리스트로 변환하기 위해서 flatMap을 이용할 수 있다.// flatMap 함수 Stream flatMap(Function<? super T, ? extends Stream<? extends R>> mapper); // [[a], [b]] List<List > list = Arrays.asList(Arrays.asList("a"), Arrays.asList("b")); // [a, b] List flatList = list.stream() .flatMap(Collection::stream) .collect(Collectors.toList()); 마찬가지로 2차원 배열의 경우에도 flatMap을 이용해 1차원 배열의 Stream으로 차원을 낮출 수 있다.Stream<String[]> strStream = Stream.of( new String[] {"a", "b", "c"}, new String[] {"d", "e", "f"}); // map을 사용하면 2중 Stream이 반환됨 Stream<Stream > stream = strStream.map(Arrays::stream); // flatMap을 사용하면 1중 Stream으로 차원을 낮출 수 있음 Stream stream = strStream.flatMap(Arrays::stream); [FlatMap의 동작 방식]이번에는 FlatMap의 동작 방식에 대해 이해해보도록 하자.예를 들어["Hello","World"]를 갖는 String의 List가 존재한다고 할 때,이를 1개의 알파벳씩을 갖도록 String으로 나누고,중복된 알파벳을 갖지 않는 List로 변환하는 작업을 한다고 하자.이러한 경우 Map을 이용하면 해결이 불가능하고 flatMap을 이용해야만 하는데,두 함수의 동작 방식에 대해 자세히 알고 넘어가도록 하자. Map을 적용할 경우
Hello가 split("")에 의해["H","e","l","l","o"]로 분리되고,World가 split("")에 의해["W","o","r","l","d"]로 분리된다.map에 의해 Stream의 소스가["H","e","l","l","o"]와["W","o","r","l","d"]로 변환된다.distinct()에 의해 중복된 소스가 제거된다.(해당 사항 없음)2개의["H","e","l","l","o"]와["W","o","r","l","d"]가 collect(toList())에 의해 수집된다.위와 같은 처리 과정을 통해 2개의 String[]을 요소로 갖는 리스트 List<String[]>가 생성된다.하지만 우리가 원했던 List이 아니며,이러한 경우에는 map으로 해결이 불가능하다.위의 과정에서 필요한 것은 map에 의해 변환된 Stream의 소스인 String[]를 String으로 flat하게 나열하는 것이며 이러한 필요에 의해 flatMap이 등장하게 되었다. flatMap을 적용할 경우
Hello가 split("")에 의해["H","e","l","l","o"]로 분리되고,World가 split("")에 의해["W","o","r","l","d"]로 분리된다.Arrays.stream(T[]array)를 사용해["H","e","l","l","o"]와["W","o","r","l","d"]를 각각 Stream으로 만든다.flatMap()을 사용해 여러 개의 Stream 을 1개의 Stream 으로 평평하게 합치고,Stream의 소스는["H","e","l","l","o",W","o","r","l","d"]가 된다.distinct()에 의해 중복된 소스(l,o)가 제거된다.중복이 제거된["H","e","l","o","W","r","d"]가 collect(toList())에 의해 수집된다.3번의 과정에서 map을 사용하면 평평하게 펼치지 못하므로 Stream<Stream >이 생성되었다.하지만 flatMap을 이용함으로써 알파벳 String을 요소로 갖는 리스트 List 를 생성할 수 있었다.[FlatMap의 활용 예제]예를 들어 국어 점수,영어 점수,수학 점수를 갖는 Student 클래스가 존재한다고 하자.Student 객체들이 저장된 student 리스트에서 모든 학생들의 모든 과목의 평균을 구해야하는 상황이라고 할 때,이러한 flatMap을 이용하면 이를 쉽게 해결할 수 있다.이 예제에서 Student 리스트는 Student보다 한 차원 높게 있으므로,모든 점수들을 수평하게 갖는 Stream을 생성하기 위해 flatMap을 활용해 줄 수 있다.추가로 이 예제에서는 모든 점수들이 int 값이므로 flatMapToInt를 활용할 수 있다.이를 코드로 작성하면 다음과 같다.import java.util.Arrays; import java.util.List; import java.util.stream.IntStream; class Student { private int kor; private int eng; private int math; public Student(int kor, int eng, int math) { this.kor = kor; this.eng = eng; this.math = math; } public int getKor() { return kor; } public int getEng() { return eng; } public int getMath() { return math; } } public class Main { public static void main(String[] args) { List students = Arrays.asList( new Student(80, 90, 75), new Student(70, 100, 75), new Student(85, 90, 85), new Student(80, 100, 90) ); students.stream().flatMapToInt(student -> IntStream.of(student.getKor(), student.getEng(), student.getMath())) .average() .ifPresent(avg -> System.out.println(Math.round(avg * 10) / 10.0)); } } 또한 flatMap을 이용하면 계층 구조를 지닌 클래스에서 Null 검사와 관련된 코드를 보다 가독성있게 사용할 수 있다.예를 들어 아래와 같은 계층 구조를 지닌 Class가 있다고 하자.class Outer { Nested nested; } class Nested { Inner inner; } class Inner { String foo; } Outer로부터 String foo를 꺼내기 위해서는 NullPointerException을 막기 위해 아래와 같은 여러 번의 null 검사가 불가피하다.하지만 이러한 부분 역시 flatMap을 사용하면 다음과 같이 코드를 가독성있게 작성할 수 있다.// flatMap 적용 전 Outer outer = new Outer(); if (outer != null && outer.nested != null && outer.nested.inner != null) { System.out.println(outer.nested.inner.foo); } // flatMap 적용 후 Optional.of(new Outer()) .flatMap(o -> Optional.ofNullable(o.nested)) .flatMap(n -> Optional.ofNullable(n.inner)) .flatMap(i -> Optional.ofNullable(i.foo)) .ifPresent(System.out::println); Reduce를 통한 결과 생성
[Reduce란?]Reduce는 누산기(Accumulator)와 연산(Operation)으로 컬렉션에 있는 값을 처리하여 더 작은 컬렉션이나 단일 값을 만드는 작업이다.예를 들어 다음과 같이 List에서 총합을 구하는 연산은 sum 함수 말고 reduce로 처리할 수 있다.또한 이러한 작업은 위의 그림에서 확인할 수 있듯이 Accumulator와 Operation를 사용한다.list.stream() .reduce(Integer::sum) .get(); Stream API의 reduce 함수는 여러 요소들을 통해 새로운 결과를 만들어내는데,reduce 함수는 최대 3가지의 매개변수를 받을 수 있다.Accumulator:각 요소를 계산한 중간 결과를 생성하기 위해 사용Identity:계산을 처리하기 위한 초기값Combiner:Parlallel Stream에서 나누어 계산된 결과를 하나로 합치기 위한 로직 reduce(accumulator)
1개의 파라미터만을 갖는 reduce함수는 BinaryOperator를 매개변수로 받는다.BinaryOperator는 같은 타입의 인자 2개를 받아 같은 타입의 결과를 반환하는 함수형 인터페이스이다.예를 들어 모든 요소의 값을 더하는 경우에는 두 파라미터를 더한 값을 람다식으로 작성해주어야 하는데,이에 대한 예제 코드는 아래와 같다.// 1개 (accumulator) Optional reduce(BinaryOperator accumulator); OptionalInt reduced = IntStream.range(1, 4) // [1, 2, 3] .reduce((a, b) -> { return Integer.sum(a, b); }); 위의 코드를 실행하면 6(1+2+3)이 나오게 된다.1개의 매개변수를 갖는 경우 Stream이 비어있을 수 있으므로 Optional을 반환하고 있다. reduce(identity,accumulator)
2개의 파라미터를 갖는 reduce함수는 Generic 타입의 identity와 BinaryOperator를 매개변수로 받는다.새롭게 추가된 identity는 계산을 처리하기 위한 초기값을 의미한다.예를 들어 위의 총합 로직에 10을 초기값으로 두고 싶다면 다음과 같이 코드를 수정할 수 있다.(추가로 메소드 참조도 적용해보도록 하자.)// 2개 (identity, accumulator) T reduce(T identity, BinaryOperatoraccumulator); int reduced = IntStream.range(1, 4) // [1, 2, 3] .reduce(10, (a, b) -> { return Integer.sum(a, b); }); 위의 코드를 실행하면 초기값 10이 더해져 16(10+1+2+3)이 나오게 된다.매개변수로 초기값 Identity를 갖는 경우 Optional을 반환할 필요가 없기 때문에 원시값을 반환하고 있다. reduce(identity,accumulator,combiner)
3개의 파라미터를 갖는 reduce함수는 Generic 타입의 identity와 BiFunction,BinaryOperator를 매개변수로 받는다.BiFunction은 2개의 파라미터 타입과 1개의 반환형 모두를 Generic Type으로 갖지만,BinaryOperator는 BiFunction을 구현(Implements)하여 1개의 반환형만을 Generic Type으로 갖는다는 점에서 차이가 있을 뿐 거의 동일하다.새롭게 추가된 combiner는 병렬 처리 시에 각자 쓰레드에서 실행된 결과를 마지막에 합치는 작업을 한다.그렇기 때문에 기존의 코드에 combiner 코드를 추가하여도 ParallelStream으로 실행하지 않으면 combiner는 호출되지 않는다.(IntStream에는 3개의 파라미터를 갖는 reduce가 존재하지 않으므로 Stream.of()로 Stream을 생성해야 한다.)// 3개 U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator combiner); int reduced = Stream.of(1, 2, 3) .reduce(10, Integer::sum, (a, b) -> { System.out.println("combiner was called"); return a + b; }); 위의 코드를 실행하면 Parallel Stream이 아니기 때문에 Combiner 관련 출력이 찍히지 않는다.그렇기 때문에 reduce를 병렬로 실행시키기 위해서는 parallel()함수를 추가해주어야 한다.int reduced = Stream.of(1, 2, 3) .parallel() .reduce(10, Integer::sum, (a, b) -> { System.out.println("combiner was called"); return a + b; }); parallel()을 추가하여 해당 코드를 실행하면 Combiner는 2번 호출되고,총합의 결과 reduced는 36이 된다.그러한 이유는 reduce 로직이 병렬로 실행되는데,초기값 10 역시 병렬로 갖게 되기 때문이다.병렬로 더해진 각각의 값은 11(10+1),12(10+2),13(10+3)이 되고,Combiner가 이를 합치게 된다.Combiner는 역순으로 12+13=25를 먼저 더하고,그 다음 25+11=36을 더하여 총 2번 호출되며 최종적으로 36을 반환하게 된다.그렇기 때문에 초기값이 모든 reduce 단계에 필요한 경우에는 초기 값을 Identity에 적어주고,전체 단계 중 1회만 필요한 경우에는 초기값으로 0과 같은 값을 넘기고,연산된 결과에 10을 더하도록 해야 한다.int reduced = 10 + Stream.of(1, 2, 3) .parallel() .reduce(0, Integer::sum, (a, b) -> { System.out.println("combiner was called"); return a + b; }); 여기서 또 주목해야 할 것은 ParallelStream의 경우 추가적인 연산인 Combiner가 처리된다는 것이다.그렇기 때문에 간단한 작업에 ParallelStream을 적용하면 오히려 처리속도가 느려질 수 있음을 고려해야 한다.Null-Safe한 Stream 생성하기
[Null-Safe Stream 생성]Java를 이용해 개발을 하다 보면 NPE(NullPointerException)가 매우 자주 발생하곤 한다.물론 NPE를 방지하기 위해 null 여부를 검사하는 코드를 작성해줄 수 있지만,이러한 코드는 상당히 가독성이 떨어지기 마련이다.이러한 문제를 해결하기 위해 Java8부터는 Optional이라는 Wrapper 클래스를 제공하여 Null 관련 코드를 가독성있게 처리할 수 있도록 도와주고 있으며,Stream API 역시 Optional의 도움을 받아 Null-Safe한 Stream을 생성할 수 있다.Null-Safe한 Stream을 생성하기 위한 함수는 다음과 같다.publicStream collectionToStream(Collection collection) { return Optional .ofNullable(collection) .map(Collection::stream) .orElseGet(Stream::empty); } collectionToStream 함수는 매개변수로 받은 Collection 객체를 이용해서 Optional 객체를 만들고 Stream을 생성한 후 반환하도록 하고 있다.만약 파라미터로 받은 Collection이 null이라면 빈 Stream을 반환하므로 어떠한 경우에서도 Null이 발생하지 않는다.이를 적용하여 작성한 코드를 살펴보면 아래와 같다.List nullList = null; // NPE 발생 nullList.stream() .filter(str -> str.contains("a")) .map(String::length) .forEach(System.out::println); // NPE! // 빈 Stream으로 처리 collectionToStream(nullList) .filter(str -> str.contains("a")) .map(String::length) .forEach(System.out::println); // [] 물론 Optional은 코드의 가독성을 높여주지만 Wrapper 클래스를 사용하는 것일 뿐이다.Stream을 생성해야 하는 대상이 Null이 발생할 확률이 높을 경우에 이러한 코드를 적용해주는 것이 의미있을 것으며,무의미하게 Optional을 남발하는 것은 바람직하지 않다. 실행 순서에 대한 고려
[Stream API의 실행 순서]Stream API를 정확히 알고 사용하지 못하면 처리 속도의 지연을 야기할 수 있다.그렇기 때문에 우리가 작성한 Stream API 코드가 어떻게 동작할 것인지 정확히 이해하고 있는 것이 중요하다.예를 들어 다음과 같은 코드가 있다고 할 때,이를 실행한 결과를 한번 예측해보고 확인해보도록 하자.Stream.of("a", "b", "c", "d", "e") .filter(s -> { System.out.println("filter: " + s); return true; }) .forEach(s -> System.out.println("forEach: " + s)); /* filter: a forEach: a filter: b forEach: b filter: c forEach: c filter: d forEach: d filter: e forEach: e / 결과를 확인해보면 예상했던 것과 다를 것이다.왜냐하면 모든 데이터에 대해 filter가 진행되고 forEach가 실행되는 수평적 구조로 순회하는 것이 아니라,각각의 데이터에 대해 filter와 forEach가 먼저 수행하는 수직적 구조로 순회하기 때문이다.Stream API가 수직적 구조로 순회하는 이유는 다음의 코드를 보면 쉽게 이해할 수 있을 것이다.Stream.of("a", "b", "c", "d", "e") .map(s -> { System.out.println("map: " + s); return s.toUpperCase(); }) .anyMatch(s -> { System.out.println("anyMatch: " + s); return s.startsWith("A"); }); / map: a anyMatch: A / 만약 위와 같은 코드가 있을 때,수평적인 구조로 처리된다면 몇번 실행이 될까?위의 코드는 우선 map에 따라 모든 데이터를 대문자로 변환하고,변환된 데이터를 기준으로"A"로 시작하는 문자열을 찾을 것이므로 map 5번+anyMatch 1번=총 6번 실행이 될 것이다.하지만 실제로(수직적인 구조)위의 코드를 실행해보면 a를 대문자로 변환하고 바로 anyMatch를 실행할 것이므로 map 1번+anyMatch 1번=총 2번 실행 될 것이다.이러한 처리 방식은 각각의 원소에 대해 실제로 실행되는 연산의 수를 줄여줄 수 있다.[실행 순서를 고려해야 하는 이유]Stream API는 수직적인 구조로 진행이 되기 때문에 실행 순서를 고려하는 것이 상당히 중요하다.잘못된 실행 속도는 연산의 횟수를 불필요하게 증가시키기 때문이다.예를 들어 다음과 같은 코드가 존재한다고 할 때,이를 실행한 결과는 어떻게 되겠는지 한번 예측해보자.Stream.of("a", "b", "c", "d", "e") .map(s -> { System.out.println("map: " + s); return s.toUpperCase(); }) .filter(s -> { System.out.println("filter: " + s); return s.startsWith("A"); }) .forEach(s -> System.out.println("forEach: " + s)); / map: a filter: A forEach: A map: b filter: B map: c filter: C map: d filter: D map: e filter: E / 우리가 예상했듯이 map과 filter는 모든 문자열에 대해 각각 5번 불러졌고,forEach는 1번만 불러졌다.위의 코드는 최선인 것 처럼 보이지만 filter를 앞으로 당김으로써 실제 실행되는 연산의 수를 줄일 수 있다.위의 코드를 다음과 같이 수정하여 실행해보자.Stream.of("a", "b", "c", "d", "e") .filter(s -> { System.out.println("filter: " + s); return s.startsWith("a"); }) .map(s -> { System.out.println("map: " + s); return s.toUpperCase(); }) .forEach(s -> System.out.println("forEach: " + s)); / filter: a map: a forEach: A filter: b filter: c filter: d filter: e */ 위와 같이 수정된 코드는 filter가 5번,map과 forEach가 각각 1번씩 수행되었고,동일한 입력과 결과에 대해 더 적은 연산으로 처리할 수 있게 되었다.만약 처리해야 하는 데이터의 크기가 훨씬 많아지면 이는 성능의 차이를 야기할 것이다.그렇기 때문에 Stream API를 사용할 때에는 반드시 연산 순서를 고려하여 코드를 작성해야 한다.병렬 스트림(Parallel Stream)의 활용
[병렬 스트림(Parallel Stream)이란?]Stream은 아주 많은 양의 데이터를 처리해야 하는 경우에 런타임 성능을 높이기 위해 병렬로 실행할 수 있는 기능인 병렬 스트림(Parallel Stream)을 제공하고 있다.Parallel Stream은 내부적으로 fork&join을 사용하고 있으며,ForkJoinPool.commonPool()을 통해 사용가능한 공통의 ForkJoinPool의 갯수를 확인할 수 있다.내재되어 있는 ThreadPool의 갯수는 최대 5개이며,사용가능한 물리적인 CPU 코어의 수에 따라 다르게 설정된다.ForkJoinPool commonPool = ForkJoinPool.commonPool(); System.out.println(commonPool.getParallelism()); 또한 이 값은 다음과 같은 JVM의 매개변수를 통해 별도로 설정해줄 수 있다.-Djava.util.concurrent.ForkJoinPool.common.parallelism=5Collection은 원소들의 Parallel Stream을 생성하기 연산으로 parallelStream()메소드를 제공하고 있다.또한 순차 Stream으로 진행하는 중에 일부 연산만을 병렬로 처리하기 위해 중간 연산으로 parallel()메소드 역시 제공하고 있다.다음의 예제는 Parallel Stream의 실행 동작을 이해하기 위해 해당 로직을 처리한 쓰레드의 정보를 출력하고 있다.Arrays.asList("a", "b", "c", "d", "e") .parallelStream() .filter(s -> { System.out.format("filter: %s [%s]\n", s, Thread.currentThread().getName()); return true; }) .map(s -> { System.out.format("map: %s [%s]\n", s, Thread.currentThread().getName()); return s.toUpperCase(); }) .forEach(s -> System.out.format("forEach: %s [%s]\n", s, Thread.currentThread().getName())); /* filter: c [main] filter: e [ForkJoinPool.commonPool-worker-3] map: e [ForkJoinPool.commonPool-worker-3] filter: a [ForkJoinPool.commonPool-worker-2] map: a [ForkJoinPool.commonPool-worker-2] filter: b [ForkJoinPool.commonPool-worker-1] forEach: A [ForkJoinPool.commonPool-worker-2] forEach: E [ForkJoinPool.commonPool-worker-3] map: c [main] filter: d [ForkJoinPool.commonPool-worker-2] map: d [ForkJoinPool.commonPool-worker-2] map: b [ForkJoinPool.commonPool-worker-1] forEach: D [ForkJoinPool.commonPool-worker-2] forEach: C [main] forEach: B [ForkJoinPool.commonPool-worker-1] / 출력 결과를 확인하면 어느 쓰레드가 실제 Stream 연산을 수행하였는지 확인할 수 있다.위에서 확인할 수 있듯이 Parallel Stream은 Stream연산을 실행하기 위해 공통의 ForkJoinPool에서 사용가능한 모든 쓰레드를 활용하고 있다.또한 어떠한 쓰레드가 어떠한 작업을 할 지는 비결정적이기 때문에 실행에 따라 출력 결과는 달라질 수 있다.[병렬 스트림(Parallel Stream)의 정렬(Sort)]Stream에서 정렬이 어떻게 동작하는지 확인해보기 위해 위의 코드에 정렬을 위한 sorted를 추가하여 실행해보도록 하자.Arrays.asList("a", "b", "c", "d", "e") .parallelStream() .filter(s -> { System.out.format("filter: %s [%s]\n", s, Thread.currentThread().getName()); return true; }) .map(s -> { System.out.format("map: %s [%s]\n", s, Thread.currentThread().getName()); return s.toUpperCase(); }) .sorted((s1, s2) -> { System.out.format("sort: %s <> %s [%s]\n", s1, s2, Thread.currentThread().getName()); return s1.compareTo(s2); }) .forEach(s -> System.out.format("forEach: %s [%s]\n", s, Thread.currentThread().getName())); / filter: c [main] map: c [main] filter: e [ForkJoinPool.commonPool-worker-3] map: e [ForkJoinPool.commonPool-worker-3] filter: a [ForkJoinPool.commonPool-worker-2] map: a [ForkJoinPool.commonPool-worker-2] filter: b [ForkJoinPool.commonPool-worker-1] map: b [ForkJoinPool.commonPool-worker-1] filter: d [main] map: d [main] sort: B <> A [main] sort: C <> B [main] sort: D <> C [main] sort: E <> D [main] forEach: C [main] forEach: E [ForkJoinPool.commonPool-worker-2] forEach: A [ForkJoinPool.commonPool-worker-1] forEach: B [ForkJoinPool.commonPool-worker-3] forEach: D [main] */ 위의 코드를 여러 번 실행하면 비결정적인 Parallel Stream에 따라 출력 결과가 항상 바뀌어야 한다.다른 출력 결과는 항상 바뀌지만 sort는 main Thread에서 순차적으로 실행되는 것을 확인할 수 있다.이러한 이유는 Parallel Stream에서의 sort가 내부적으로 Java8의 새로운 메소드인Arrays.parallelSort()를 사용하기 때문이다.Javadoc을 살펴보면 다음과 같이 내용이 작성되어 있다.The method uses a threshold value and any array of size lesser than the threshold value is sorted using the Arrays#sort()API(i.e sequential sorting).And the threshold is calculated considering the parallelism of the machine,size of the array and is calculated as:이 메소드는 임계값을 사용하고 배열의 크기가 임계값보다 작으면 순차 정렬 방식인 Arrays의 sort()API를 대신 사용한다.그리고 임계값은 컴퓨터의 병렬성,배열의 크기 등에 따라 다음과 같이 계산된다.즉,어떤 임계값(Threshold)를 계산하여 배열의 길이가 그 크기보다 작으면 순차적인 정렬 방식(Sequential Sorting)인Arrays.sort()를 이용한다는 것이다.여기서 임계값은 결국 프로세스에 할당 가능한 배열의 길이일 것이다.위의 예제에서는Arrays.sort()가 사용되었지만,만약 배열의 크기를 상당히 크게 한다면 parallelSort()가 사용될 것이다.앞서 설명한대로 Parallel Stream의 경우 Combiner 등과 같은 추가적인 연산이 필요로 하게 된다.그렇기 때문에 적절한 상황에 Parallel 하게 처리하도록 하는 것이 중요하다.
'Java' 카테고리의 다른 글
| | 객체지향 프로그래밍이란? / 함수형 프로그래밍이란?(2)| 2022.12.07
| | JAVA 언어 과제(별 삼각형 찍기 / 구구단 가로 세로 설정)(0)| 2022.12.02
이 글은 제 Tistory 블로그에 처음 게시(2022-12-07)된 글입니다.