티스토리 뷰

스트림 (Stream)

스트림은 다량의 데이터 처리 작업을 위해 자바 8에서 등장했다. 컬렉션 처리를 위한 새로운 API라고 할 수도 있다.


스트림 구조

스트림은 초기 데이터→ 중간 연산 → 최종 연산 의 구조로 이루어져 있다.

메서드 체이닝 방식을 사용.

파이프라인 하나를 구성하는 모든 호출을 연결하여, 단 하나의 표현식으로 완성할 수 있다.

Cars cars = new Cars(inputView.readCarNames()
                    .stream()
                    .map(Car::new)
                    .collect(Collectors.toList()));

 

parallelStream 메서드를 이용해 병렬 실행도 할 수 있다.

병렬 처리와 같은 부분은 성능적인 이슈가 반드시 따라오기 때문에, 스스로 잘 고려해보고 사용하는 것이 좋다.

numbers.parallelStream()
        .filter(number -> number > 3)
        .filter(number -> number < 9)
        .map(number -> number * 2)
        .filter(number -> number > 10)
        .findFirst();

스트림의 특징

1. 간결하고 직관적인 코드 제공

List<String> sortedCrewNames = crews.stream()
                .filter(crew -> crew.getAge() < 20)
                .map(crew -> crew.getName())
                .sorted()
                .collect(Collectors.toList());

2. 데이터베이스 쿼리를 작성하듯 무엇에 집중한 코드

SELECT name
FROM Crew
WHERE age < 20
ORDER BY name ASC

3. 원본 데이터를 훼손하지 않는다.

 

스트림은 원본 객체의 값을 사용하기만 할 뿐 변경하지 않는다.

최종 연산를 통해 원본과 무관한 새로운 객체를 생성한다.

 

4. 스트림 파이프라인은 지연 연산 제공


지연 연산(Lazy evaluation)

1부터 10까지의 수에서, 3보다 크고 9보다 작은 값 중 2배 했을 때 10보다 큰 가장 작은수를 찾고 싶다고 하자.

 

for문

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Integer result = null;

for (Integer number : numbers) {         // 1, 2, 3, 4, 5, 6     - 6번
    if (3 < number && number < 9) {      //          4, 5, 6     - 3번
        Integer newNumber = number * 2;  //          4, 5, 6     - 3번
        if (newNumber > 10) {            //          8, 10, 12   - 3번
            result = newNumber;
            break;                       
        }
    }
}

System.out.println("result = " + result); // result = 12

스트림을 사용하지 않고 for문을 돌리는 경우를 생각해보면

결과를 찾기까지 (break 가 걸리기 까지) 총 15번의 연산이 수행된다.

 

스트림으로 표현

List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

numbers.stream()
            .filter(number -> number > 3) // 1, 2, 3, 4, 5, 6, 7, 8, 9, 10
            .filter(number -> number < 9) //          4, 5, 6, 7, 8, 9, 10
            .map(number -> number * 2)    //          4, 5, 6, 7, 8 
            .filter(number -> number > 10)//          4, 5, 6, 7, 8
            .findFirst();

스트림을 사용하는 경우를 생각해보자.

 

첫 번째 filter(number -> number > 3) 에서 1부터 10까지 모든 수를 확인하고 → 10회

두 번째 filter(number -> number < 9) 에서는 첫 번째에서 걸러진 4부터 10까지 → 7회

세 번째 map(number -> number * 2) 에서는 두 번째에서 걸러진 4부터 8까지 → 5회

네 번째 filter(number -> number > 10) 에서는 세 번째에서 연산된 4(8)부터 8(16)까지 →5회

 

총 27회를 연산한 후, [12, 14, 16] 중에서 첫 번째 값인 12를 반환하지 않을까? 라는 추측을 할 수 있다.

 

💡 그렇다면 스트림 연산이 더 느리지 않나? 라고 충분히 의문을 가질 수 있다.

 

확인해보자

numbers.stream()
                .filter(number -> {
                    System.out.println("number > 3");
                    return number > 3;
                })
                .filter(number -> {
                    System.out.println("number < 9");
                    return number < 9;
                })
                .map(number -> {
                    System.out.println("number * 2");
                    return number * 2;
                })
                .filter(number -> {
                    System.out.println("number > 10");
                    return number > 10;
                })
                .findFirst();

 

numbers 스트림의 람다식에 출력문을 추가한 후, 몇 번의 연산이 수행되는지 찾아보면 for문과 동일하게 15회 출력되는 것을 볼 수 있다 !

 

 

💡 즉, 스트림은 자체적으로 지연 연산을 수행한다는 것이다.

 

💡 평가는 최종 연산이 호출될 때서야 비로소 이뤄지며, 최종 연산에 필요없는 데이터는 쓰이지 않는다.

 

그렇다고 스트림이 for문 보다 무조건 좋은 것은 아니다.

 

반드시 성능적인 부분을 고려해보고 사용해야하는데, 간단하게 이야기해보자면

탐색하는 비용과 연산하는 비용 2가지 측면에서 고려해야한다.

 

예를 들어 1부터 1억까지 자연수를 순회하며 최대 값을 가져오는 경우는 연산 비용이 순회 비용에 비해 매우 작기 때문에 for문으로 탐색하는 것이 빠르다.  왜냐하면 primitive 타입은 자바 메모리의 스택 영역에 저장되기 때문에 heap 영역을 참조하는 비용 없이 빠르게 값을 찾아올 수 있기 때문이다. 반면에 IntStream을 이용해 값을 찾는다면 Integer와 같은 참조 타입을 사용하기 때문에 stack에서 heap영역을 참조해 값을 가져와야한다.


🤔 그렇다면, 항상 스트림을 쓰는 것이 좋을까?

지나친 스트림 사용은 오히려 가독성을 매우 떨어뜨린다.

이번 블랙잭 미션에서 카드를 생성하는 과정을 살펴보자.

List<Card> cards = Stream.of(Symbol.values())
                        .flatMap(symbol -> 
                                Stream.of(Number.values())
                                        .map(number -> new Card(symbol, number)))
                        .collect(Collectors.toList());

flatMap을 잘 이해하고 있는 사람은 쉽게 이해할 수도 있지만, 한 눈에 보기에도 어떤 일을 하고 있는지 이해하기 쉽지 않다.

 

List<Card> cards = new ArrayList();

for (Symbol symbol : Symbol.values()) {
    for (Number number : Number.values()) {
        cards.add(new Card(symbol, number));
    }
}

 

오히려 2중 for문으로 작성하는 것이 가독성이 더 좋다.

스트림을 처음 접하게 되면 모든 반목문을 스트림으로 바꾸고 싶은 마음이 든다 !

어느 정도 복잡한 로직은 스트림과 반복문을 적절히 조합하는게 최선이다.


🤨 그럼 언제 쓰면 좋은데?

원소들의 시퀀스를 일관되게 반환할 때

원소들의 시퀀스를 필터링할 때

원소들의 시퀀스를 컬렉션에 모을 때

원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾을 때

 

반환할 때, 필터링할 때, 모을 때, 찾을 때 의 단어를 조합해보면

원본 데이터를 손상시키거나 변형시키지 않는 작업 이라는 공통점이 보인다.

요약하면, 계산이나 값을 변형하는 로직이 아닌 경우에 사용하는 것이 좋다는 것이다.


정리

스트림을 사용해야 깔끔하게 처리할 수 있는 작업이 있고, 단순 반복 방식이 더 알맞은 일도 있다.

어느 정도 규모가 있는 작업이라면, 이 둘을 조합했을 때 가장 멋지게 해결된다.

 

스트림과 반복문 중, 어느 쪽이 나은지 확신하기 어렵다면 일단 둘 다 해보자
그리고 더 나은 쪽을 택하자.

 

 

 

Reference

 

Effective Java 3/E

댓글