스트림(stream) 소개 스트림이란? 스트림 vs. 컬렉션 스트림 연산

Java8에서 새로 추가된 스트림의 기초를 정리합니다.

스트림이란?

스트림이란 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소 로 정의할 수 있다.

스트림을 사용하면 선언형(즉, 데이터를 처리하는 임시코드 대신 질의로 표현)으로 컬렉션 데이터를 처리할 수 있다.
따라서 데이터 컬렉션 반복을 멋지게 처리할 수 있으며, 별도의 멀티 스레드 코드를 구현하지 않더라도 데이터를 병렬로 처리할 수 있게 된다.

다음은 저칼로리의 요리명을 반환하고, 칼로리를 기준으로 정렬하는 로직을 Java7과 Java8로 비교하는 예시이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

List<Dish> lowCaloricDishes = new ArrayList<>(); // 컨테이너 역할만 하는 Gabage 변수!
for (Dish d : menu) {
if(d.getCalories() < 400) {
lowCaloricDishes.add(d);
}
}

Collections.sort(lowCaloricDishes, new Comparator<Dish>() {
public int (Dish d1, Dish d2) {
return Integer.compare(d1.getCalories(), d2.getCalories());
}
});

List<String> lowCaloricDishesName = new ArrayList<>();
for (Dish d : lowCaloricDishes) {
lowCaloricDishesName.add(d.getName());
}

// Java8
import static java.util.Comparator.comparing;
import static java.util.stream.Collections.toList;
List<String> lowCaloricDishesName = menu.stream()
.filter(d -> d.getCalories() < 400) // 400 칼로리 이하의 요리 선택
.sorted(comparing(Dish::getCalories)) // 칼로리로 요리 정렬
.map(Dish::getName) // 요리명 추출
.collect(toList()); // 모든 요리명을 리스트에 저장

// Java8 - 병렬 처리
import static java.util.Comparator.comparing;
import static java.util.stream.Collections.toList;
List<String> lowCaloricDishesName = menu.parallelStream()
.filter(d -> d.getCalories() < 400) // 400 칼로리 이하의 요리 선택
.sorted(comparing(Dish::getCalories)) // 칼로리로 요리 정렬
.map(Dish::getName) // 요리명 추출
.collect(toList()); // 모든 요리명을 리스트에 저장

위 예시를 통해 스트림이 소프트웨어 공학적으로 제공하는 이점을 확인할 수 있다. :

  • 선언형
    • 즉, 루프와 if 조건문 등의 제어 블록으로 어떻게 동작을 구현할지 지정하기 보다 원하는 동작의 수행을 직접적으로 지정할 수 있게 된다.
    • 코드가 더 간결하고 가독성이 좋아진다.
  • 조립할 수 있음
    • 여러 빌딩 블록 연산(filter, sorted, map, collect)을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있다.
    • 유연성이 좋아진다.
  • 병렬화
    • 성능이 좋아진다.
    • filter, sorted, map, collect 와 같은 연산은 고수준 빌딩 블록(High-Level Building Block) 으로 이루어져 있으므로 특정 스레드 모델에 제한되지 않는다. 따라서 병렬로 데이터 처리를 하면서 스레드와 락을 걱정할 필요가 없다.

스트림 vs. 컬렉션

Stream vs. Collection

데이터를 언제 계산하는가?

  • 컬렉션
    • 현재 자료구조가 포함하는 모든 값을 메모리에 저장.
      즉, 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되야 한다.
    • 생산자 중심(Supplier-Driven). 즉, 팔기도 전에 창고를 가득 채우며 적극적으로 생산 한다.
  • 스트림
    • 요청할 때만 요소를 계산하는 고정된 자료구조.
      따라서 스트림에 요소를 추가/삭제가 불가하다.
    • 사용자가 요청하는 값만 스트림에서 추출.
      생산자(Producer)와 소비자(Consumer) 관계.
    • 게으르게 만들어지는 컬렉션과 같다.
      즉, 사용자가 데이터를 요청할 때만 값을 계산한다. (요청 중심 제조(Demand-Driven Manufacturing) or 즉석 제조(Just-In-Time Manufacturing))

데이터 소스를 반복 사용할 수 있는가?

  • 컬렉션
    • 데이터를 반복해서 사용 할 수 있다.
  • 스트림
    • 데이터를 단 한번만 소비할 수 있다.

외부반복과 내부반복

  • 컬렉션 : 외부반복 (External Iteration)
  • 스트림 : 내부반복 (Internal Iteration)

스트림은 내부반복을 통해 작업을 투명하게, 그리고 하드웨어를 활용한 병렬설 구현을 자동으로 하거나, 최적화된 다양한 순서로 처리할 수 있게 해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 컬렉션 : for-each 루프를 이용하는 외부반복
List<String> names = new ArrayList<>();
for(Dish d : menu) {
names.add(d.getName());
}

// 컬렉션 : Iterator 객체를 이용하는 외부반복
List<String> names = new ArrayList<>();
Iterator<Dish> iterator = menu.iterator();
while(iterator.hasNext()) {
Dish d = iterator.next();
names.add(d.getName());
}

// 스트림 : 내부반복
List<String> names = menu.stream()
.map(Dish::getName)
.collect(toList());

스트림 연산

1
2
3
4
5
List<String> names = menu.stream()    // 스트림 리스트 얻기
.filter(d -> d.getCalories() > 300) // 중간 연산
.map(Dish::getName) // 중간 연산
.limit(3) // 중간 연산
.collect(toList()); // 스트림을 리스트로 변환

스트림 파이프라인은 빌더 패턴(Builder Pattern)과 비슷하다.
위 예제 코드를 보면 알 수 있듯이 스트림의 연산은 크게 2가지로 구분할 수 있다.

  • 중간 연산 (Intermediate Operation)
    • filter, map, limit 는 서로 연결되여 파이프라인을 형성.
    • 중간 연산은 다른 스트림을 반환한다. 따라서 서로 다른 여러 중간 연산을 연결/병합해서 질의를 만들 수 있다. (Loop Fusion)
    • 중간 연산 만으로는 결과를 생성할 순 없다. (최종 연산 필요)
    • filter, map, limit, sorted, distinct…
  • 최종 연산 (Terminal Operation)
    • collect 로 파이프라인을 실행한 후 종료.
      즉, 스트림 파이프라인에서 결과를 도출하여 List, Integer, void 등의 결과로 반환한다.
    • forEach, count, collect…