본문 바로가기
Book/모던 자바 인 액션

[정리] 모던 자바 인 액션 - 스트림 소개

by 따라쟁이개발자 2023. 1. 6.

스트림은 자바 8 API에 새로 추가된 기능으로,

스트림을 사용하면 선언형(구현코드 대신 질의의 형태)로 컬렉션 데이터를 처리할 수 있다. 

선언형의 예로 데이터베이스 질의를 들 수 있는데, SELECT name FROM disches WHERE calorie < 400  ORDER BY calorie는

칼로리가 400 미만인 요리명을 출력하라는 SQL질의이며 결과를 얻기 위해 질의할 뿐 구체적인 구현 코드를 전달하진 않는다.

 

만약 자바 7 코드로 위 예제를 구현하려 했다면 아래와 같았을 것이다.

// 400 칼로리보다 아래인 dish들만 수집
List<Dish> lowCaloricDishes = new ArrayList<>();
for(Dish dish: menu) {
	if(dish.getCalories < 400){
    	lowCaloricDishes.add(dish);
    }
}

// dish 리스트를 칼로리 기준으로 정렬
Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
	public int compare(Dish dish1, Dish dish2) {
    	return Integer.compare(dish1.getCalories(), dish.getCalories());
    }
};

// 정렬된 리스트에서 dish name만 추출하여 새 리스트 작성
List<String> lowCaloricDishesName = new ArrayList<>();
for(Dish dish: lowCaloricDishes){
	lowCaloricDishesName.add(dish.getName());
}

 

자바 8에서 위 문제를 구현한다면 아래와 같이 쉽게 풀릴 수 있다.

filter 메서드의 결과는 sorted 메서드로, 다시 sorted 결과는 map 메서드로, map 메서드 결과는 collect로 연결된다.

즉, 각 블록 연산들을 파이프라인으로 연결해 하나의 데이터 처리 파이프라인을 만들 수 있게 된다.

List<String> lowCaloricDishesName = 
	menu.stream()
    	.filter(d -> d.getCalories() < 400)
        .sorted(comparing(Dish::getCalories))
        .map(Dish::getName)
        .collect(toList());

 

스트림을 사용하면 일반 루프를 이용해 처리할 필요 없이 데이터를 병렬적으로 처리할 수 있다.

 

스트림의 정의와 특징

스트림은 '데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소'라고 정의될 수 있다.

  • 연속된 요소 : 특정 요소 형식으로 이루어진 연속된 값 집합의 인터페이스를 제공. 컬렉션은 주제가 데이터라면 스트림은 계산이 주제
  • 소스 : 컬렉션, 배열, I/O 자원 등의 데이터 제공 소스로부터 데이터를 소비
  • 데이터 처리 연산 : 데이터 베이스와 비슷한 연산을 지원

 

또한 스트림은 두 가지 중요한 특징을 가진다

  • 파이프라이닝 : 스트림 연산끼리 연결해서 커타란 파이프 라인을 구성할 수 있음.
  • 내부 반복 : 반복자를 이용해 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원

 

스트림과 컬렉션

컬렉션과 스트림은 모두 연속된 요소 형식의 값을 저장하는 자료구조의 인터페이스를 제공한다.

'연속된' 이란, 순서와 상관없이 아무 값에나 접속하는 것이 아니라 순차적으로 값에 접근한다는 것을 의미한다.

 

스트림과 컬렉션은 DVD와 스트리밍 비디오에 비유할 수 있다.

DVD는 전체 영화가 저장되어 있어야 하는 반면, 스트리밍 비디오는 재생 시 사용자가 시청할 몇 프레임만 미리 받아놓고 재생시킨다.

모든 프레임을 다 내려받고 재생을 하게 되면, 재생 시작시간까지 오랜 시간이 걸릴 것이다.

 

즉, 데이터를 언제 계산하느냐가 컬렉션과 스트림의 차이라고 볼 수 있다.

컬렉션은 현재 자료구조가 포함하는 모든 값을 메모리에 저장하는 구조라면, 스트림은 요청할 때에만 요소를 계산하는 자료구조다.

따라서 컬렉션은 팔기도 전에 창고를 가득 채워놓는 '적극적 생성'을 하고 (= 모든 값을 계산할 때까지 기다린다.)

스트림은 필요할 때만 값을 채워놓는 '게으른 생성'을 한다.

 

외부 반복과 내부 반복

스트림도 반복자와 마찬가지로 한 번만 탐색할 수 있다. 반복자가 끝난 후 다시 계산을 하기 위해 반복자를 다시 셋팅하듯

스트림도 한번 끝난 스트림을 다시 계산하려면 새로 스트림을 생성해야 한다.

List<String> title = Arrays.asList("Java8", "In", "Action");
Stream<String> s = title.stream();
s.forEach(System.out::println);	// 출력 가능
s.forEach(System.out::println); // 에러 발생 (이미 스트림이 소비되었거나 닫힘)

 

또한, 컬렉션은 사용자가 반복자를 통해 직접 요소를 반복해야 했던 외부 반복(external iteration)인 반면

스트림 라이브러리는 내부적으로 알아서 반복을 처리하고 스트림값을 저장해주는 내부 반복(internal iteration)을 사용한다.

// 컬렉션 for-each 루프를 이용한 외부 반복
List<String> names = new ArrayList<>();
for(Dish dish: menu){
    names.add(dish.getName());
}

// 컬렉션 내부적으로 숨겨졌던 반복자를 사용한 외부 반복
List<String> names = new ArrayList<>();
Iterator<String> iterator = menu.iterator();
while (iterator.hasNext()) {
    Dish dish = iterator.next();
    names.add(dish.getName());
}

// 스트림 내부 반복
List<String> names = menu.stream()
        .map(Dish::getName)
        .collect(toList());	// 파이프라인을 실행할 뿐 반복자는 필요 없다.

내부 반복을 이용하면 작업을 투명하게 병렬로 처리하고, 더 최적화된 다양한 순서로 처리할 수 있다.

외부 반복. 즉, for-each를 사용자가 직접 명시하여 사용할 경우엔 병렬성을 사용자가 직접 스스로 관리해야 한다. (synchronized 사용)

 

스트림 연산

스트림 인터페이스의 연산은 크게 두 가지로 나눌 수 있다.

  • 중간 연산 : 연결 가능한 스트림 (ex. filter, map, limit)
  • 최종 연산 : 스트림을 닫는 연산 (ex. collect)

filter, map 과 같은 중간 연산은 연산 결과로 다른 스트림을 반환한다. 

그리고, 중간 연산은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않는다. (즉, lazy 하다)

중간 연산은 최종 연산으로 한번에 처리되기 때문이다.

 

최종 연산은 스트림 파이프라인에서 결과를 도출한다.

보통 최종 연산에 의해 List, Integer, void 등 스트림 외 결과가 반환된다. 

 

마치며

  • 스트림은 소스에서 추출된 연속 요소로, 데이터 처리 연산을 지원한다.
  • 스트림은 내부 반복을 지원한다. (반복을 추상화한다)
  • 스트림에는 중간 연산과 최종 연산이 있다.
  • 중간 연산은 filter, map 처럼 스트림을 반환하면서 다른 연산과 연결되는 연산이다. 중간 연산으로는 어떠한 결과도 생성할 수 없다.
  • 최종 연산은 forEach, count 처럼 스트림 파이프라인을 처리해 스트림이 아닌 결과를 반환한다.
  • 스트림의 요소는 요청할 때 게으르게 (lazily) 계산된다.

댓글