[Java] Stream, Collector 왜 쓰는 거야?

devdo·2021년 5월 10일
0

Java

목록 보기
15/56
post-thumbnail

스트림의 정의

스트림은 람다와 마찬가지로 자바8에 추가된 것으로, 람다를 활용하여 데이터 처리 연산을 지원하도록 컬레션, 배열, I/O자원등에 대해 저장되어 있는 요소들을 추출하여 반복적인 처리를 가능케 하는 기능이다.


Iterator에서 Stream로 간 이유

iterator, stream 둘다 반복적인 연산을 줄여주기 위해 만든 구분자이다. 최근에는 iterator보단 stream을 많이 사용한다. 써보면 안다. 코드량도 줄어들고 중간처리를 할 수 있는 연산들이 stream가 더 편하다는 걸.

예시)

List<String> stringList = Arrays.asList("월", "화", "수", "목", "금");

이 stringList 객체에 요일 요소들을 저장해두고 요일을 제거하는 작업을 각자 해보자.


iterator 예시

        Iterator<String> iterator = stringList.iterator();

        while (iterator.hasNext()) {
            System.out.println("iterator.next(): "+iterator.next());

            var day = iterator.next();

            if ("수".equals(day)) {
                iterator.remove();  // NoSuchElementException
            }
            System.out.println("day: "+ day);
        }
        System.out.println("-----------------------");
        iterator = list.iterator();
        while ((iterator.hasNext())) {
            var day = iterator.next();
            System.out.println("day: "+ day);
        }

stream 예시

        Stream<String> stringStream = stringList.stream();

        stringStream.filter(s -> !"수".equals(s))
                .forEach(System.out::println);

코드만 봐도 stream으로 구분자를 두고 한 것이 길지 않고 직관적이다. stream은 중간처리과정도 있어 행여나 iterator에 생기는 elementException도 생기지 않는다고 한다.

이래서 iterator는 잘 안보이고 stream으로 많이 쓰는 것인가 보다. stream 많이 사용하자!


스트림과 컬렉션

스트림도 컬렉션과 마찬가지로 연속된 값 집합의 인터페이스를 제공한다.

데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이!

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 자료구조이다. 그 후, 계산이 가능하다.

그러나 스트림의 경우 요청할 때만 요소를 계산하는 고정된 자료구조이다.
이는, 게으른 연산(Lazy) 덕분이다. 이 특징은 뒤에 가서 다루겠다.

이를 동영상으로 비유하면, 컬렉션은 동영상을 모두 다운로드 후에 볼 수 있고, 스트림의 경우 전체가 아닌 해당 구간만 다운받아서 볼 수 있는 스트리밍 서비스이다. 스트림(스트리밍)은 한번 사용하면 소멸된다. 재사용도 불가능하다.

컬렉션은 외부 반복으로 (forEach)문 등을 통해 반복을 컨트롤한다.

// 컬렉션 외부 반복
List<String> names = new ArrayList<>();
for(Dish dish : menu) {
	names.add(dish.getName());
}

이처럼 외부반복에서는 Collection 인터페이스를 사용하기 위해서는 사용자가 직접 for-each문을 통해 반복문을 반들어 연산처리를 해야 한다.

반면에, 스트림은 내부 반복을 사용한다.

내부반복의 특징은 이렇다.

1) 반복자를 사용할 필요가 없다.
내부에서 자동으로 반복을 처리하기에 사용자가 직접 반복문을 쓸 필요가 없다.

2) 병렬성 처리
외부반복을 사용하는 경우, 병렬처리를 위해서는 스레드간의 공유자원에 대해 동기화(한순간에 한 스레드만 공유자원에 접근하는 코드를 실행하도록 예를 들어, schronize) 를 처리해줘야 하지만, 내부 반복은 이를 관리할 필요가 없다.

filter, map, reduce, find, sort 등의 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 같이 다양한 추가 연산자를 사용한다.


스트림의 특징

정리하면, 스트림의 특징은 이렇게 정리할 수 있다.

1) 스트림은 윈본 데이터를 변경하지 않는다

스트림은 데이터 소스로 부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않는다. 필요하다면, 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수 있다.

List<String> sortedList = listStream.sorted().collect(Collections.toList());

2) 스트림은 일회용이다

스트림은 Iterator처럼 일회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야한다.

listStream.sorted().forEach(System.out::print);
int numOfElement = listStream.count(); //에러. 스트림이 이미 닫힘

3) 스트림은 작업을 내부 반복으로 처리한다

스트림을 이용한 작업이 간결할 수 있는 비결중의 하나가 바로 '내부 반복'이다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미한다.

4) 스트림은 병렬처리가 가능하다

병렬 스트림은 내부적으로 fork & join 프레임웍을 이용해서 자동적으로 연산을 병렬로 수행한다. 우리가 할일이라고는 그저 스트름에 parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 된다. 병렬로 처리되지 않게 하려면 sequential()을 호출하면 된다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 sequential()을 호출할 필요가 없다. 이 메서드는 parallel()을 호출한 것을 취소할 때만 사용한다.

int sum = strStream.parallel()
                   .mapToInt(s -> s.length())
                   .sum();

스트림의 연산

스트림의 연산은 게으른(Lazy) 특성으로 2 가지로 나누어진다. 중간연산, 최종연산이다.

  • 중간 연산: 중간 연산의 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않느다. 즉, 리턴값이 없다. (filter, map, limit, sorted, distinct 등)
  • 최종 연산: 스트림 파이프라인에서 결과를 도출한다. (forEach, collect, count 등)

스트림의 게으르다(Lazy)?

수트림의 연산은 필터-맵(filter-map) 기반의 API를 사용하여 지연 연산을 한다. 그로 인해 성능을 최적화하는 것이다.

결론부터 말하자면 이 게으른 특성 때문에 최종연산이 연산이 없으면 중간연산은 실행되지 않는다.

왜냐하면 스트림은 어디까지나 연산을 위한 객체로 그 자체로 자료구조의 역할을 하지 않기 때문이다. 때문에 최종연산을 해야만 스트림의 연산이 의미가 있어진다. 예제를 보자.

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s = stream.peek(System.out::println);

peek()메서드는 forEach()처럼 스트림의 요소를 순회하며 소비(Consumer<T>)하는 메서드이다. forEach()와 한가지 다른 점은 중간연산이라는 점이다. 때문에 해당코드를 실행하면 실행문은 출력되지 않는다.

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s = stream.peek(System.out::println);
s.collect(toList());

최종연산이 호출될때 비로소 중간연산들도 실행되는걸 볼 수 있다.


스트림 예제

스트림 API는 다음과 같은 다양한 데이터 소스에서 생성할 수 있다.

1. 컬렉션
2. 배열
3. 가변 매개변수
4. 지정된 범위의 연속된 정수
5. 특정 타입의 난수들
6. 람다 표현식
7. 파일
8. 빈 스트림

// 컬렉션에서 스트림 생성
ArrayList<Integer> list = new ArrayList<Integer>();
Stream<Integer> stream = list.stream();
stream.forEach(System.out::println); // forEach() 메소드를 이용한 스트림 요소의 순차 접근



// 배열에서 스트림 생성
String[] arr = new String[]{"넷", "둘", "셋", "하나"};
Stream<String> stream1 = Arrays.stream(arr);
stream1.forEach(e -> System.out.print(e + " "));
System.out.println();



// 배열의 특정 부분만을 이용한 스트림 생성
Stream<String> stream2 = Arrays.stream(arr, 1, 3);
stream2.forEach(e -> System.out.print(e + " "));



// 가변 매개변수에서 스트림 생성
Stream<Double> stream = Stream.of(4.2, 2.5, 3.1, 1.9);
stream.forEach(System.out::println);



// 지정된 범위의 연속된 정수에서 스트림 생성
IntStream stream1 = IntStream.range(1, 4);
stream1.forEach(e -> System.out.print(e + " "));
System.out.println();
IntStream stream2 = IntStream.rangeClosed(1, 4);
stream2.forEach(e -> System.out.print(e + " "));



// 특정 타입의 난수로 이루어진 스트림 생성
IntStream stream = new Random().ints(4);
stream.forEach(System.out::println);



// 람다 표현식
IntStream stream = Stream.iterate(2, n -> n + 2); // 2, 4, 6, 8, 10, ...



// 파일
String<String> stream = Files.lines(Path path); // 라인 단위로 접근



// 빈 스트림 생성
Stream<Object> stream = Stream.empty();
System.out.println(stream.count()); // 스트림의 요소의 총 개수를 출력함.


// 출처 http://www.tcpschool.com/java/java_stream_intermediate 

중개연산

중개 연산은 Stream을 전달받아 Stream으로 반환하므로 중개 연산을 연속으로 사용할 수 있다. 또한, Stream의 중개연산은 필터-맵(filter-map)기반의 API를 사용함으로 지연(lazy) 연산을 통해 성능을 최적화할 수 있다.

대표적인 중개 연산과 그에 따른 메소드는 다음과 같다.

1. Stream 필터링 : filter(), distinct()
2. Stream 변환 : map(), flatMap()
3. Stream 제한 : limit(), skip()
4. Stream 정렬 ; sorted()
5. Stream 연산 결과 확인 : peek()

// filter(), distinct()
IntStream stream1 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);
IntStream stream2 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);
// stream에서 중복된 요소를 제거함.
stream1.distinct().forEach(e -> System.out.print(e + " "));	// 7 5 2 1 3 4 6 
System.out.println();
// stream에서 홀수만을 골라냄.
stream2.filter(n -> n % 2 != 0).forEach(e -> System.out.print(e + " "));	// 7 5 5 1 3 5 
System.out.println();



// map(), flatMap()
Stream<String> stream = Stream.of("HTML", "CSS", "JAVA", "JAVASCRIPT");
stream.map(s -> s.toLowerCase()).forEach(System.out::println); //해당 스트림의 요소들을 주어진 함수에 인수로 전달하여, 그 반환값들로 이루어진 새로운 스트림을 반환
	/*
	* html
	* css
	* java
	* javascript
	*/
String[] arr = {"I study hard", "You study JAVA", "I am hungry"};
Stream<String> stream = Arrays.stream(arr);
stream.flatMap(s -> Stream.of(s.split(" +"))).forEach(System.out::println); // 여러 문자열이 저장된 배열을 각 문자열에 포함된 단어로 이루어진 스트림으로 변환
	/*
	 * I
	 * study
	 * hard
	 * You
	 * study
	 * JAVA
	 * I
	 * am
	 * hungry
	 */



// limit(), skip()
IntStream stream1 = IntStream.range(0, 10);
IntStream stream2 = IntStream.range(0, 10);
IntStream stream3 = IntStream.range(0, 10);
stream1.skip(4).forEach(n -> System.out.print(n + " ")); // 스트림의 첫번째 요소부터 전달된 개수만큼 요소를 제외한 나머지 요소만으로 이루어진 새로운 스트림
System.out.println(); // 4 5 6 7 8 9 
stream2.limit(5).forEach(n -> System.out.print(n + " ")); // 첫 번째 요소부터 전달된 개수만큼의 요소만으로 이루어진 새로운 스트림
System.out.println(); // 0 1 2 3 4 
stream3.skip(3).limit(5).forEach(n -> System.out.print(n + " ")); // 3 4 5 6 7 



// sorted()
Stream<String> stream1 = Stream.of("JAVA", "HTML", "JAVASCRIPT", "CSS");
Stream<String> stream2 = Stream.of("JAVA", "HTML", "JAVASCRIPT", "CSS");
stream1.sorted().forEach(s -> System.out.print(s + " ")); // 해당 스트림을 주어진 비교자(comparator)를 이용하여 정렬
// CSS HTML JAVA JAVASCRIPT 
System.out.println();
stream2.sorted(Comparator.reverseOrder()).forEach(s -> System.out.print(s + " ")); // 역순으로 정렬
// JAVASCRIPT JAVA HTML CSS 


// 출처 http://www.tcpschool.com/java/java_stream_intermediate

최종 연산

최종 연산은 앞서 중개 연산을 통해 만들어진 stream에 있는 요소들에 대해 마지막으로 각 요소를 소모하며 최종 결과를 표시한다. 즉, 지연(lazy)되었던 모든 중개 연산들이 최종 연산 시에 모두 수행되는 것이다. 이렇게 최종 연산 시에 모든 요소를 소모한 해당 stream은 더 이상 사용할 수 없다.

대표적인 최종 연산과 그에 따른 메소드는 다음과 같다.

1. 요소의 출력 : forEach()
2. 요소의 소모 : reduce()
3. 요소의 검색 : findFirst(), findAny()
4. 요소의 검사 : anyMatch(), allMatch(), noneMatch()
5. 요소의 통계 : count(), min(), max()
6. 요소의 연산 : sum(), average()
7. 요소의 수집 : collect()

// forEach()
Stream<String> stream = Stream.of("넷", "둘", "셋", "하나");
stream.forEach(System.out::println); // 각 요소를 출력 
	 /*
  	  * 넷
  	  * 둘
  	  * 셋
  	  * 하나
 	  */
    
    

// reduce()
Stream<String> stream1 = Stream.of("넷", "둘", "셋", "하나");
Stream<String> stream2 = Stream.of("넷", "둘", "셋", "하나");
Optional<String> result1 = stream1.reduce((s1, s2) -> s1 + "++" + s2);
result1.ifPresent(System.out::println); // 넷++둘++셋++하나
String result2 = stream2.reduce("시작", (s1, s2) -> s1 + "++" + s2);
System.out.println(result2); // 시작++넷++둘++셋++하나



// findFirst(), findAny()
IntStream stream1 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
IntStream stream2 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
OptionalInt result1 = stream1.sorted().findFirst(); //stream의 모든 요소를 정렬한 후, 첫 번째에 위치한 요소를 출력
System.out.println(result1.getAsInt()); // 1
OptionalInt result2 = stream2.sorted().findAny(); // stream의 모든 요소를 정렬한 후, 첫 번째에 위치한 요소를 출력
System.out.println(result2.getAsInt()); // 1



// anyMatch(), allMatch(), noneMatch()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
IntStream stream2 = IntStream.of(30, 90, 70, 10);
System.out.println(stream1.anyMatch(n -> n > 80)); // 일부 요소에 대해 n > 80 인지 - true
System.out.println(stream2.allMatch(n -> n > 80)); // 모든 요소에 대해 n > 80 인지 - false



// count(), min(), max()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
IntStream stream2 = IntStream.of(30, 90, 70, 10);
System.out.println(stream1.count()); // 모든 요소의 갯수 - 4
System.out.println(stream2.max().getAsInt()); // 모든 요소의 최대값 - 90



// sum(), average()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
DoubleStream stream2 = DoubleStream.of(30.3, 90.9, 70.7, 10.1);
System.out.println(stream1.sum()); // 모든 요소의 총합 - 200
System.out.println(stream2.average().getAsDouble()); // 모든 요소의 평균 - 50.5



// collect()
Stream<String> stream = Stream.of("넷", "둘", "하나", "셋");
List<String> list = stream.collect(Collectors.toList()); // 스트림을 리스트로 변환
Iterator<String> iter = list.iterator();
while(iter.hasNext()) {
    System.out.print(iter.next() + " "); // 넷 둘 하나 셋
}

// Collectors 클래스의 partitioningBy()
Stream<String> stream = Stream.of("HTML", "CSS", "JAVA", "PHP");
Map<Boolean, List<String>> patition = stream.collect(Collectors.partitioningBy(s -> (s.length() % 2) == 0)); 
// 해당 stream의 각 요소별 글자 수에 따라 홀수와 짝수로 나누어 저장
List<String> oddLengthList = patition.get(false);
System.out.println(oddLengthList); // [CSS, PHP]
List<String> evenLengthList = patition.get(true);
System.out.println(evenLengthList); // [HTML, JAVA]

for-loop와 성능 차이

결론적으로 말하자면, for문이 stream 보다 배로 빠르다.

그렇다면 for문이 빠른 이유 그럼에도 stream을 적극 사용하는 이유는 무엇일까?

다음 블로그가 잘 정리해두었다.
https://pamyferret.tistory.com/49

정리하자면,

for문이 빠른 이유

  1. for문은 단순 인덱스 기반이다.

for문은 단순 인덱스 기반으로 도는 반복문으로 메모리 접근이기 때문에 Stream에 비해 빠르고 오버헤드도 없다.
stream의 경우는 JVM이 이것저것 처리해줘야하는 것들이 많아 실행 시 느릴 수 밖에 없다.

  1. for문은 컴파일러가 최적화를 시킨다.

stream은 java 8부터 지원한 것이고 for문은 그보다 훨씬 오래전부터 계속 사용해왔다.

그만큼 사용되는 컴파일러는 오래 사용된 for문에 대한 처리가 되어 있어 for문의 경우 미리 최적화를 시킬 수 있지만,

stream의 경우 신생(?)인 만큼 정보가 없어 for문과 같은 정교한 최적화를 수행할 수 없다.

그래서 만약 단순 forEach일 경우, stream이 아닌 for문을 사용하는 게 좋겠다. (또는 컬렉션의 forEach 함수를!)

🍀 그렇다면 왜 Stream을 사용하는 이유는,

  1. 가독성이 좋아진다.

이것도 개발자 역량에 따라 다르지만 stream을 사용하면 확실히 stream api에 포함되어 있는 여러 함수 들을 이용해 코드가 간결해진다.

물론 이것도 stream에 익숙하지 않은 사람은 그 의미를 일일이 해독하느라 코드를 읽는 시간디 더 걸릴 수 있지만,

stream을 한 두 번 보다보면 가독성이 좋다는 것이 느껴진다.

물론 이것도 무조건적인 장점은 아니다. 어떤 기능을 개발하느냐에 따라 for문이 가독성이 좋을 수 있고(특히 for문 안에서 중첩 반복 또는 조건문을 사용하는 경우) stream이 오히려 가독성이 안 좋을 수 있다.

  1. 코드로 작성해야하는 로직을 stream에서 제공해주는 함수로 간단하게 해결 가능하다.

이것은 특히 filter, reduce 등과 같이 단순 forEach가 아닌 함수들을 사용할 경우 유용하다.

데이터가 많지 않을 경우 stream을 사용해도 (for문보다 성능이 떨어지지만) 속도가 느리지 않다.

그러니 코드로 일일이 기능을 구현해야하는 것들을 stream api에서 제공한다면 당연히 stream을 사용하든 것이 좋다.



참고

profile
배운 것을 기록합니다.

0개의 댓글