JAVA/모던 자바 인 액션

모던 자바 인 액션 06. 스트림

kujaHn 2021. 8. 17. 12:37

스트림

거의 모든 자바 애플리케이션에서 컬렉션을 만들고 처리합니다. 큰 범위로 처리하는 데에는 정말 강력한 기능을 보여주나, 디테일한 면에서 조금 아쉬운 부분들이 많습니다. 또한 병렬 처리에서도 부족한점이 있었습니다. 이러한 아쉬운 기능들을 보완하기 위해서 스트림이 등장했습니다.

 

스트림의 장점

1. 선언형 코드로 컬렉션 데이터를 처리 가능.

먼저 루프와 조건문을 이용해 무게가 600kg이 넘고, 나이가 30개월이 넘는 소들을 무게 순으로 나열한 이름 리스트를 만들어 봅시다. 총 세 번의 과정이 필요합니다.

 

// 600kg이 넘는 소
Predicate<Cow> onlyHeavyCow = (Cow c1) -> c1.getWeight() >= 600;
        
// 600kg이 넘으면서, 30개월이 넘는 소 추가
Predicate<Cow> heavyAndOld = onlyHeavyCow.and(cow -> cow.getAge() >= 30);

// 무게순으로 정렬
Comparator<Cow> heavyDescending = Comparator.comparing(Cow::getWeight).reversed();

// 600kg이 넘으면서, 30개월이 넘는 소들을 무게순으로 정렬
private static List<Integer> heavyOldCowIdList(List<Cow> cows, Predicate filtering, Comparator comparator){
    List<Cow> firstFiltering = filteringCowV1(cows, filtering);
    firstFiltering.sort(comparator);
    
    List<Integer> cowIdList = new ArrayList<>();
    
    for (Cow cow : firstFiltering) {
        cowIdList.add(cow.getId());
    }
    return cowIdList;
}

 

간단한 코드이긴 하나 이 코드들의 아쉬운 점들이 있습니다.

 

1) 루프, 조건문 등 제어 블록들을 만들 때마다 코드가 복잡해지고 가독성이 떨어진다.

지금이야 조건이 3가지밖에 되지 않아 괜찮지만, 점점 조건이 늘어날 때마다 코드는 기하급수적으로 늘어날 것입니다!

그렇게 되면 될수록 코드의 가독성도 떨어질 것입니다!

 

2) 복잡한 데이터 파이프라인을 가지는 로직을 짤 때 실수할 가능성이 높아진다.

조건이 늘어나면서, 파이프라인이 점점 복잡해질 것입니다. 따로 컴파일러로 잡아내지도 못해서 로직을 짤 때 실수를 한다면, 찾기가 어려워집니다.

 

그러면 앞으로 배울 스트림을 이용해서 코드를 짜 봅시다.

private static List<Integer> heavyOldCowIdListV2(List<Cow> cows) {
    return cows.stream().filter(c -> c.getWeight() >= 600 & c.getAge() >= 30)
        	.sorted(Comparator.comparing(Cow::getWeight).reversed())
        	.map(Cow::getId)
        	.collect(Collectors.toList());
}

private static List<Integer> heavyOldCowIdListV3(List<Cow> cows) {
    return cows.parallelStream().filter(c -> c.getWeight() >= 600 & c.getAge() >= 30)
        	.sorted(Comparator.comparing(Cow::getWeight).reversed())
        	.map(Cow::getId)
        	.collect(Collectors.toList());
}

달라진 점이 눈에 확 보입니다.

1) 선언형 코드 덕분에 루프, 조건문 등 제어블록으로 동작을 구현하지 않았습니다.

 

2) 빌딩 블록을 이용한 빌더 형식으로 복잡한 데이터 파이프라인을 단순하게 만들 수 있게

    되었습니다!

  이런 기능을 파이프라이닝이라 하는데, 덕분에 지연과 쇼트서킷과 같은 최적화를 얻을 수 있습니다.

  

3) 'stream( )'을 'parallelStream( )'으로 바꾸면 병렬 처리 또한 가능합니다!

 

2. 내부 반복

외부 반복을 지원하는 컬렉션(Iterator)과 달리, 스트림은 내부 반복을 지원합니. 덕분에 멀티스레드 코드를 구현하지 않아도 데이터를 병렬로 처리할 수 있습니다.

(기존 멀티스레드 코드를 구현하려면, synchronized를 사용하고 thread에서 설정을 하는 등 복잡한 절차가 필요.)
스트림에서는 앞서 설명했다시피 stream()을 parallelStream()으로 바꿔주기만 하면 멀티스레드에 대한 스트레스를 해소할 수 있습니다. 데이터 처리 과정을 병렬화하면 스레드와 락을 걱정할 필요가 없습니다.


스트림 시작하기

우선 용어부터 살펴봅시다. 스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소입니다.

 

1. 데이터 처리 연산

  • 특히 스트림은 함수형 프로그래밍 언어에서 일반적으로 지원하는 연산과, DB와 비슷한 연산을 합니다.
  • 추가적으로 순차적('stream( )'), 병렬('parallelStream( )')로 실행할 수 있습니다.
    • 예시 : filter, map, reduce, find, match, sort

2. 소스

  • 스트림은 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비합니다.
  • 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지됩니다.

 

정리

private static List<Integer> heavyOldCowIdListV3(List<Cow> cows) {
    return cows.parallelStream() 			// 데이터 소스로부터 스트림 얻기
    		.filter(c -> c.getWeight() >= 600 	// 파이프라인 연산 시작
            		   & c.getAge() >= 30)
        	.sorted(comparing(Cow::getWeight).reversed())	// 무게를 기준으로 정렬
        	.map(Cow::getId)			// id 매핑
        	.collect(Collectors.toList());		// 스트림을 리스트로 변환

컬렉션 vs 스트림

시작하기 앞서...

  • 스트림은 컬렉션과 같이 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공합니다.
  • 컬렉션은 시간, 공간의 복잡성과 관련된 요소 저장 및 접근 연산이 주.
  • 스트림은 'filter', 'sorted', 'map'처럼 표현 계산식이 주.
    • 결론적으로 컬렉션은 데이터 위주이고, 스트림은 계산 위주

1. 데이터 계산 시점

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

 

컬렉션

현재 자료구조가 포함하는 모든 값을 메모리에 저장되기 때문에, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 합니다. 이 말은 아무리 긴 연산 이어도 계산이 완료될 때까지는 결과를 알지 못한다는 것입니다.

 

스트림

이론적으로는 요청할 때만 요소를 계산합니다. 즉 사용자가 요청하는 값만 스트림에서 추출할 수 있다는 것입니다. 

 

2. 탐색

컬렉션과 스트림 모두 딱 한 번만 탐색이 가능하다. (소비성 데이터)

탐색된 스트림의 요소는 소비되어 없어지기 때문에 다시 탐색하려면 새로운 스트림을 생성해야 합니다.

그렇기 때문에 초기 데이터는 컬렉션처럼 반복 가능한 데이터 소스여야 한다. (변화되서는 안 됨.)

 

3. 데이터 반복 처리 방법

  • 컬렉션 인터페이스를 사용하려면 사용자가 직접 요소를 반복해야 함. 이것을 외부 반복이라 함. (ex: for-each 등등)

 

  • 스트림 라이브러리는 반복을 알아서 처리하는 내부 반복을 사용한다. 이점은 다음과 같다.
    • 작업을 투명하게 병렬로 처리하거나, 더 최적화된 다양한 순서로 처리할 수 있다.
    • 외부 반복도 가능하나 스스로 관리해야 함. (synchronized를 통해서..)

스트림 연산

  • 스트림은 파이프라이닝의 강점을 가진다 했습니다. 이 파이프라인의 구성요소를 알아봅시다.
    • 중간 연산 : 계속해서 파이프라인을 연결할 수 있는 연산입니다.
    • 최종 연산 : 스트림을 닫는 연산입니다. 

 

  • 'filter( )' 'sorted( )' 같은 중간 연산은 최종 연산을 위한 다른 스트림을 반환을 합니다.
void lazyLoadingTest() {
	List<String> highCaloricMenu = menu.stream() 
    	.filter(dish -> {
        	System.out.println("filtering : " + dish.getName());
                return dish.getCalories() > 300; 
        }) 
        .map(dish -> {
        	System.out.println("mapping : " + dish.getName());
        	return dish.getName(); 
        }) 
        .limit(3)
        .collect(toList());
   System.out.println(highCaloricMenu); 
}
  • 스트림의 특징은 중간 연산들을 합친 다음 최종 연산으로 한 번에 처리하는 것입니다. 이를 지연성(Lazy)이 있다라고 합니다.
    • 결과
      filtering : pork
      mapping : pork
      filtering : beef
      mapping : beef
      filtering : chicken
      mapping : chicken
      [pork, beef, chicken]
  •  

이 결과를 통해, 중간 연산들을 한 번에 처리한 후 리스트로 변환하는 것을 알 수 있습니다.

요약

  • 스트림의 장점
    • 선언형 코드
      • 기존의 조건식, 반복문 블록들을 선언형 코드로 정리가 된다.
      • 선언형 코드 덕분에 파이프라이닝을 더욱 간단하게 할 수 있다. (깔끔해지는 것은 덤.)
    • 내부 반복
      • 최적화에 도움을 준다.
      • parallelStream()을 사용하면 synchronized나 스레드 락으로부터 자유로워진다.
  • 스트림 연산
    • 스트림은 중간 연산과 최종 연산으로 이루어져 있다.
      • 중간 연산 : 다른 스트림을 반환
      • 최종 연산 : 스트림이 아닌 결과를 반환 ex: collect(toList())
      • 중간 연산으로는 어떤 결과도 생성할 수 없다.

 

참고 자료

  • 모던 자바 인 액션
  • JAVA Reference