ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [모던 자바 인 액션] 스트림
    자바 2024. 10. 21. 15:57

    우리는 데이터를 저장할 때 컬렉션을 사용한다. 우리가 어떤 컬렉션을 이용하여 모든 값의 합을 구한다고 가정하자. 만약 이런 목적을 데이터 베이스에서 한다면 "select sum(d.price) from Data d" 이런 질의를 통해서 한번에 구할 수 있을 것이다. 하지만 자바에서는 어떻게 구할까? 바로 컬렉션을 하나 하나 탐색하면서 price 값을 더해서 계산을 해야한다. 데이터 베이스 쿼리처럼 한번에 선언형으로 데이터를 처리할 수 없을까? 그것을 가능하도록 도와준 것이 스트림이다.

     

    스트림이란?

    스트림은 자바 8 API에 새로 추가된 기능이다. 스트림을 이용하면 선언형(즉, 데이터를 처리하는 임시 구현 코드 개신 질의로 표현할 수 있다.)으로 컬렉션 데이터를 처리할 수 있다. 간단히 쉽게 말하면 스트림은 컬렉션 반복을 기가 막히게 처리하는 기능이라고 생각하면된다. 또한 스트림을 사용하면 멀티스레드 코드를 구현하지 않더라도 데이터를 투명하게 병렬로 처리가 가능하다. 예를 들어 100개의 데이터가 들어있는 리스트가 있다고 하자. 이 모든 리스트 합을 구하고 싶은데 이를 10개씩 쪼개서 각 10개씩 쪼갠 것을 다른 쓰레드로 계산 후 마지막에 합친다. 이렇게 하면 하나씩 더하는거보다 훨씬 빠른 성능을 보여준다. 자바 컬렉션을 이용한 코드와 스트림을 이용한 코드를 보고 차이점을 눈으로 대략 보자!

    // 칼로리가 400 이하인 Dish 고르기
    // 자바 컬렉션
    List<Dish> lowCaloricDishes = new ArrayList<>();
    for(Dist dish: menu) {
    	if(dish.getCalories() < 400) {
        	lowCaloricDishes.add(dish);
        }
    }
    
    // 스트림
    // 만약 병렬 처리하고 싶으면 stream() -> parallelStream()으로 교체
    List<Dish> lowCaloricDishes = menu.stream().filter(dish -> dish.getCalories() < 400).collect(toList());

     

    스트림의 정의

    스트림의 정확한 정의는 "데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소" 이다. 이 정의를 하나씩 알아보면 다음과 같다.

    • 연속된 요소 : 컬랙션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공한다.
    • 소스 : 스트림은 컬랙션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비한다.
    • 데이터 처리 연산 : 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과 데이터 베이스와 비슷한 연산을 지원한다.

    스트림의 특징

    스트림은 두 가지 중요 특징이 있다.

    • 파이프라이닝 : 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프 라인을 구성할 수 있도록 스트림 자신을 반환한다. 연산 파이프라인은 데이터 소스에 적용하는 데이터 베이스 질의와 비슷하다.
    • 내부 반복 : 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원한다.

     

    스트림 vs 컬렉션

    그렇다면 스트림과 컬렉션은 구체적으로 어떤 차이가 있을까?

     

    1. 데이터 저장 방식  vs 데이터 처리 방식

    컬렉션은 데이터 저장소이다. 컬렉션은 메모리에 모든 요소를 저장하고, 그 요소를 언제든지 접근할 수 있다. 즉, 컬렉션에 저장된 데이터는 즉시 계산되어 있어야하고, 필요한 경우 데이터에 반복적으로 접근이 가능하다. 예시를 들면 다음과 같이 List 컬렉션이 있다.

    List<Integer> numbers = Arrays.asList(1,2,3,4,5);

     

    위 리스트는 1,2,3,4,5 값이 모두 메모리에 즉시 저장이 된다. 리스트의 데이터를 가져오거나 수정하는 것은 바로 처리할 수 있다.

    하지만 스트림은 다르다. 스트림은 데이터 처리를 위한 도구로 데이터를 일관된 방식으로 처리하는데 중점을 둔다. 즉, 데이터를 직접 저장하지 않고 데이터 흐름을 표현을 한다. 지연 계산으로 마지막 최종 연산 때 계산된다. 무슨 말이 이해가 잘 안된다면 다음 예시를 보자!

    // 여기서 스트림은 아무 것도 실행하지 않음
    Stream<Integer> stream = Stream.of(1,2,3,4,5);
    	.filter(n -> n > 2)
        .map(n -> n * 2);
    
    // 스트림을 통해 최종 연산 collect을 하면 그때서야 연산이 시작
    List<Integer> result = stream.collect(Collectors.toList());

     

    위 코드에서 filter와 map은 즉시 실행되지 않고, 단지 데이터를 이렇게 처리하겠다는 흐름을 정의를 한다. 최종 연산인 collect(최종 연산에 대해서는 잠시후에 설명하겠다.)가 호출되는 순간에야 스트림은 실제로 필터링하고 매핑된 값을 계산하여 리스트에 수집한다. 이를 지연 계산이라고 한다.

     

    2. 한 번만 사용 가능 vs 여러 번 사용 가능

    컬렉션은 데이터를 여러 번 반복해서 사용할 수 있습니다. 한 번 저장된 데이터는 필요할 떄마다 접근할 수 있고, 데이터를 반복해서 처리할 수 있다. 예를 들면 list.get(0)이다. 이 메서드를 여러번 호출하여 언제든지 접근을 할 수 있다.

    하지만 스트림은 한 번만 사용 가능하다. 스트림은 데이터 흐름을 처리하는 일회성 도구이기 때문에, 최종 연산을 통해 계산이 되면 재사용이 불가능하다. 예를 들어, 위의 코드 처럼 stream.collect()를 호출하면 해당 스트림은 다음에 사용이 불가능하다.

     

    3. 병렬 처리

    이전에 언급했듯이 컬렉션은 자체 병렬 처리를 지원하지 않는다. 즉, 개발자가 직접 병렬 처리 관련 코드를 작성해야한다.

    그에 반에 스트림은 병렬 처리를 쉽게 할 수 있도록 설계되어 있다. parallelStream()을 사용하면 간단하게 데이터를 병렬로 처리할 수 있다. 병렬 스트림을 사용하면 여러 스레드가 동시에 데이터를 처리할 수 있다.

     

    4. 외부 반복 vs 내부 반복

    컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야한다. 예를 들면 for-each문을 사용해서 컬렉션의 모든 데이터를 접근해야한다. 이런 방식을 외부 반복이라고 한다. 반면 스트림 라이브러리는 내부 반복을 사용한다. 내부 반복은 반복을 알아서 처리하고 처리 스트림 값을 어딘가에 저장해주는 방식이다. 내부 반복은 작업을 투명하게 병렬로 처리하거나 더 최적화된 다양한 순서로 처리가 가능하기에 외부 반복보다 더 좋다.

     

    스트림 연산

    지금까지 스트림의 특징을 자세히 살펴보았다. 이제 아까 언급한 최종 연산이 뭔지 알아보고 스트림이 어떤 식으로 크게 동작하는 큰 그림을 살펴볼 것이다.

    List<String> names = menu.stream()
    			.filter(dish -> dish.getCalories() > 300)
                            .map(Dish::getName)
                            .limit(3)
                            .collect(toList());

     

    위의 연산은 크게 두 가지 그룹으로 구분할 수 있다. filter, map, limit은 서로 연결되어 파이프라인을 형성하고, collect로 파이프라인을 실행한 다음 닫는다. 이렇게 두 가지로 나눌 수 있다. 이렇게 filter, map, limit 처럼 연결할 수 있는 스트림 연산을 중간 연산, 스트림을 닫는 연산을 최종 연산이라고 한다.

     

    왜 이렇게 중간 연산과 최종 연산 두 가지로 나눌까? 바로 지연 계산을 위해서이다. 중간 연산의 가장 중요한 트징은 단말 연산을 스트림 파이프라인에서 실행하기 전까지는 아무 연산도 수행하지 않는다는 것이다. 최종 연산으로 파이프라인에서 결과를 도출해낸다.

     

    간단하게 스트림이 특징과 구성 요소에 대해 간단히 알아보았다. 다음에는 스트림을 활용하는 여러 가지 메서드를 천천히 확인해 볼 것이다!

     

Designed by Tistory.