devseop08 님의 블로그

[API] 람다와 스트림 : Mordern Java - 5. 스트림 활용 본문

Language/Java

[API] 람다와 스트림 : Mordern Java - 5. 스트림 활용

devseop08 2025. 5. 31. 21:27

5.1 필터링

5.1.1 프레디케이트로 필터링

  • 스트림 인터페이스는 filter 메서드를 지원한다.
  • filter 메서드는 프레디케이트를 인수로 받아서 프레디케이트와 일치하는 모든 요소를 포함하는 스트림을 반환한다.
List<Dish> vegetarianMenu = menu.stream()
                                .filter(Dish::isVegitarian)
                                .collect(toList());

5.1.2 고유 요소 필터링

  • 스트림은 고유 요소로 이루어진 스트림을 반환하는 distinct 메서드도 지원한다.
  • 고유 여부는 스트림에서 만든 hashCode, equals로 결정된다.
List<Integer> numbers = Array.asList(1, 2, 1, 3, 3, 2, 4);
numbers.stream()
        .filter(i->i%2 == 0)
        .distinct()
        .forEach(System.out::println);      

5.2 스트림 슬라이싱

5.2.1 프레디케이트를 이용한 슬라이싱

  • 자바 9은 스트림의 요소를 효과적으로 선택할 수 있도록 takeWhile, dropWhile두 가지 새로운 메서드를 지원한다.
  • TAKEWHILE 활용
    • filter 메서드는 정렬된 리스트로부터 만들어진 스트림의 전체 스트림을 반복하면서 각 요소에 프레디케이트를 적용하게 된다.
    • 리스트가 이미 정렬되어 있다는 사실을 이용해 전체 스트림을 반복하지 않고 반복 작업 도중에 프레디케이트가 false를 반환하면 반복을 중단하고 나머지 스트림 요소들을 버리는 takeWhile을 활용하여 빠르게 스트림 연산을 처리할 수 있다.
    • 기존 filter 메서드를 사용하면 전체 스트림을 반복한다.
    • List<Dish> filteredMenu = specialMenu.stream() .filter(dish -> dish.getCalories() < 320) .collect(toList());
    • takeWhile 메서드를 사용하면 모든 스트림에 프레디케이트를 적용해 스트림을 슬라이스할 수 있다.
    • List<Dish> slicedMenu1 = speacialMenu.stream() .takeWhile(dish -> dish.getCalories() < 320) .collect(toList());
  • DROPWHILE 활용
    • dropWhile은 takeWhile과 정반대의 작업을 수행한다.
    • dropWhile은 프레디케이트가 처음으로 거짓이 되는 지점까지 발견된 요소를 버린다.
    • 프레디케이트가 거짓이 되면 그 지점에서 작업을 중단하고 남은 요소를 모두 반환한다.5.2.2 스트림 축소
    • List<Dish> slicedMenu2 = speacialMenu.stream() .dropWhile(dish -> dish.getCalories() < 320) .collect(toList());
  • 스트림은 주어진 값 이하의 크기를 갖는 새로운 스트림을 반환하는 limit(n) 메서드를 지원한다.
  • 스트림이 정렬되어 있으면 최대 요소 n개를 반환할 수 있다.
    • 프레디케이트와 일치하는 처음 세 요소를 선택한 다음에 즉시 결과를 반환한다.
    • 정렬되지 않은 스트림(소스가 Set)에도 limit을 사용할 수 있다. 소스가 정렬되어 있지 않았다면 limit의 결과도 정렬되지 않은 상태로 반환된다.
  • List<Dish> dishes = specialMenu.stream() .filter(dish -> dish.getCalories() > 300) .limit(3) .collect(toList());

5.2.3 요소 건너뛰기

  • 스트림은 처음 n개 요소를 제외한 스트림을 반환하는 skip(n) 메서드를 지원한다.
     List<Dish> dishes = menu.stream()
                            .filter(d -> d.getCalories() > 300)
                            .skip(2)
                            .collect(toList());

5.3 매핑

5.3.1 스트림의 각 요소에 함수 적용하기

  • 스트림은 함수를 인수로 받는 map 메서드를 지원한다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 매핑된다.
List<String> words = Arrays.asList("Modern", "Java", "In", "Action");
List<Integer> wordLengths = words.stream()
                                 .map(String::length)
                                 .collect(toList());
List<Integer> dishNameLengths =  menu.stream()
                                     .map(Dish::getName)
                                     .map(String::length)
                                     .collect(toList());

5.3.2 스트림 평면화

  • 단어 리스트로부터 고유 문자로 이루어진 리스트를 반환해보자.
  • [ "Hello", "World" ] 리스트가 있다면, 결과로 [ "H", "e", "l", "o", "W", "r", "d"]를 포함하는 리스트가 반환되어야 한다.
  • map 메서드를 이용해 리스트에 있는 각 단어를 문자 하나씩으로 매핑해서 stream을 평면화 시킨 다음에 distinct로 중복된 문자를 필터링해서 쉽게 문제를 해결할 수 있다고 생각할 수도 있지만
words.stream()
     .map(word -> word.split(""))
     .distinct()
     .collect(toList());
  • 위 코드에서 map으로 전달한 람다는 각 단어의 String[ ] (문자열 배열)을 반환한다는 점이 문제다. 따라서 map 메서드가 반환한 스트림의 형식은 Stream< String[] >이다.
  • 스트림 평면화 실패

  • map과 Arrays.stream 활용
    • 우선 문제 해결을 위해선 구성 요소가 문자열인 배열로부터, 스트림의 구성 요소가 문자열 배열이 아닌 문자열인 스트림을 만들어 내야 한다.
    • 그러기 위해선 배열을 인수로 받아 배열의 요소를 스트림의 요소가 되도록 스트림을 만들어 반환하는 Arrays.stream() 메서드를 활용해야한다.
String[] arrayOfWords = {"Goodbye", "World"};
Stream<String> streamOfWords = Arrays.stream(arrayOfWords);
words.stream()
     .map(word -> word.split("")) // Stream<String[]> 반환
     .map(Arrays::stream) // Stream<Stream<String>> 반환
     .distinct()    
     .collect(toList());
  • 위 코드는 문자열 배열을 구성 요소로 하는 스트림의 각 구성 요소인 문자열 배열 하나씩만을 Arrays.stream 메서드의 인자로 넣어주기 때문에 스트림의 구성 요소 하나씩만에 대한 문자열 스트림(Stream<Stream< String >>)을 만들어내게 되어 제일 처음 스트림에 대한 평면화를 하지 못한다.
  • flatMap 사용
    • Arrays::stream을 메서드 참조하는 부분의 map 메서드를 flatMap 메서드로 수정하면 문제를 해결할 수 있다.
    • flatMap은 각 배열을 스트림이 아니라 스트림의 콘텐츠로 매핑한다. 즉 map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다.
List<String> uniqueCharacters = 
    words.stream()
         .map(word -> word.split(""))
         .flatMap(Arrays::stream)
         .distinct()
         .collect(toList());

5.4 검색과 매칭

5.4.1 프레디케이트가 적어도 한 요소와 일치하는지 확인

  • 프레디케이트가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할 때 anyMatch 메서드를 이용한다.
if(menu.stream().anyMatch(Dish::isVegetarian)){
    System.out.println("The menu is (somewhat) vegetarian friendly!");
}

5.4.2 프레디케이트가 모든 요소와 일치하는 지 검사

  • allMatch 메서드는 anyMatch 메서드와 달리 스트림의 모든 요소가 주어진 프레디케이트와 일치하는지 검사한다.
boolean isHealthy = menu.stream()
                        .allMatch(dish -> dish.getCalories() < 1000);
  • NONEMATCH
    • noneMatch는 allMatch와 반대 연산을 수행한다.
    • noneMatch는 주어진 프레디케이트와 일치하는 요소가 없는지 확인한다.
boolean isHealthy = menu.stream()
                        .noneMatch(d -> d.getCalories() >= 1000);    
  • anyMatch, allMatch, noneMatch 세 메서드는 스트림 쇼트서킷 기법, 즉 자바의 &&, ||와 같은 연산을 활용한다.
  • 5.4.3 요소 검색
  • findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림 연산과 연결해서 사용할 수 있다.
Optional<Dish> dish = 
    menu.stream()
        .filter(Dish::isVegetarian)
        .findAny();
  • 스트림 파이프라인은 내부적으로 단일 과정으로 실행할 수 있도록 최적화된다. 즉, 쇼트 서킷을 이용해서 결과를 찾는 즉시 실행을 종료한다.
  • Optional이란?
    • Optional< T > 클래스는 값의 존재나 부재 여부를 표현하는 컨테이너 클래스다.
    • findAny() 메서드는 아무 요소도 반환하지 않을 수도 있기 때문에 null에 의한 에러를 방지 하기 위해 Optional을 만들어 값이 존재하는지 확인하고 값이 없을 때 어떻게 처리할지 강제하는 기능을 제공한다.
    • isPresent() 메서드 : Optional이 값을 포함하면 참을 반환하고 값을 포함하지 않으면 거짓을 반환한다.
    • ifPresent(Consumer< T > block): 값이 있으면 주어진 블록을 실행한다.
    • T get()은 값이 존재하면 값을 반환하고, 값이 없으면 NoSuchElementException을 일으킨다.
    • T orElse(T other)는 값이 있으면 값을 반환하고 값이 없으면 기본값을 반환한다.
menu.stream()
    .filter(Dish::isVegetarian)
    .findAny()
    .ifPresent(dish -> System.out.println(dish.getName()));

5.4.4 첫 번째 요소찾기

  • findFirst
  • List<Integer> someNumbers = Arrays.asList(1, 2, 3, 4, 5); Optional<Integer> firstSquareDivisibleByThree = someNumbers.stream() .map(n -> n * n) .filter(n -> n % 3 == 0) .findFirst();
  • findFirst와 findAny가 모두 필요한 이유는 병렬성 때문이다. 병렬 실행에서는 첫 번째 요소를 찾기가 어렵다. 따라서 요소의 반환 순서가 상관없다면 병렬 스트림에서는 제약이 적은 findAny를 사용한다.

5.5 리듀싱

  • 리듀싱 연산은 모든 스트림 요소를 처리해서 값으로 도출하는 연산이다.5.5.1 요소의 합
int sum = numbers.stream().reduce(0, (a,b) -> a + b);

  • 초기값을 받지 않도록 오버로드된 reduce 메서드는 Optional 객체를 반환한다.
Optional<Integer> sum = numbers.stream().reduce((a,b) -> (a + b));

5.5.2 최댓값과 최솟값

Optional<Integer> max = numbers.stream().reduce(Integer::max);
Optional<Integer> min = numbers.stream().reduce(Integer::min);

reduce 메서드의 장점과 병렬화

  • reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다.
  • 반복적인 합계에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기가 어렵다.
  • 강제적으로 동기화시키더라도 결국 병렬화로 얻어야 할 이득이 스레드 간의 소모적인 경쟁 때문에 상쇄되어 버린다는 사실을 알게 된다.
  • stream()을 parallelStream()으로 바꾸면 스트림의 모든 요소를 더하는 코드를 병렬로 만들 수 있다.
int sum = numbers.parallelStream().reduce(0, Integer::sum);

스트림 연산: 상태 없음과 상태 있음

  • map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다. 따라서 이들은 보통 상태가 없는, 즉 내부 상태를 갖지 않는 연산이다.
  • reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. 스트림에서 처리하는 요소 수와 관계없이 내부 상태의 크기는 한정되어 있다.
  • sorted나 distinct 같은 연산은 filter나 map과 같이 스트림을 입력으로 받아 다른 스트림을 출력하는 것처럼 보일 수 있으나 스트림의 요소를 정렬하거나 중복을 제거하려면 과거의 이력을 알고있어야 한다. 즉, 내부 상태를 갖는 연산인 것이다.

5.6 실전 연습

5.6.1 거래자와 트랜재션

  1. 2011년에 일어난 모든 트랜잭션(거래)를 찾아 값을 오름차순으로 정리하시오.
  2. 거래자가 근무하는 모든 도시를 중복없이 나열하시오.
  3. 케임브리지에서 근무하는 모든 거래자를 찾아서 이름 순으로 정렬하시오.
  4. 모든 거래자의 이름을 알파벳순으로 정렬해서 반환하시오
  5. 밀라노에 거래자가 있는가?
  6. 케임브리지에 거주하는 거래자의 모든 트랜잭션 값을 출력하시오.
  7. 전체 트랜잭션 중 최댓값은 얼마인가?
  8. 전체 트랜잭션 중 최솟값을 얼마인가?
  • 거래자 클래스
class Trader
{
    private final String name; 
    private final String city;
    public Trader(String n, String c){
        this.name = n;
        this.city = c;
    }

    public String getName(){
        return this.name;
    }

    public String getCity(){
        return this.city;
    }

    public String toString(){
        return "Trader:" + this.name + " in " + this.city;
    }
}
  • 거래 내역 클래스
class Transaction
{
    private final Trader trader;
    private final int year;
    private final int value;

    public Transaction(Trader trader, int year, int value){
        this.trader = trader;
        this.year = year;
        this.value = value;
    }

    public Trader getTrader(){
        return this.trader;
    }

    public int getYear(){
        return this.year;
    }

    public int getValue(){
        return this.value;
    }

    public String toString(){
        return "{" + 
                this.trader + ", " +
                "year : " + this.year + ", " +
                "value : " + this.value + 
                "}"
    }
}
  • 거래자 생성
Trader raoul = new Trader("Raoul", "Cambridge");
Trader mario = new Trader("Mario", "Milan");
Trader alan = new Trader("Alan", "Cambridge");
Trader brian = new Trader("Brian", "Cambridge");
  • 거래 내역 리스트 생성
List<Transaction> transactions = Arrays.asList(
    new Transaction(brian, 2011, 300),
    new Transaction(raoul, 2012, 1000),
    new Transaction(raoul, 2011, 400),
    new Transaction(mario, 2012, 710),
    new Transaction(mario, 2012, 700),
    new Transaction(alan, 2012, 950)
);

5.6.2 실전 연습 정답

  1. 2011년에 일어난 모든 트랜잭션(거래)를 찾아 값을 오름차순으로 정리하시오
List<Transaction> selectedTransasctions = 
                transactions.stream()
                            .filter(t -> t.getYear() == 2011)
                            .sorted(comparing(Transaction::getYear))
                            .collect(toList()); 
  1. 거래자가 근무하는 모든 도시를 중복없이 나열하시오.
List<String> workingCities = 
                    transactions.stream()
                                .map(Transaction::getTrader)
                                .map(Trader::getCity)
                                .distinct()
                                .collect(toList());

workingCities.forEach(System.out::println);
  1. 케임브리지에서 근무하는 모든 거래자를 찾아서 이름 순으로 정렬하시오.
List<String> tradersInCambridge = 
    transactions.stream()
                .filter(t -> "Cambridge".equals(t.getTrader().getCity()))
                .map(Transaction::getTrader)
                .sorted(comparing(Trader::getName))
                .distinct()
                .collect(toList());
  1. 모든 거래자의 이름을 알파벳순으로 정렬해서 반환하시오
List<String> traders =
    transactions.stream()
                .map(t -> t.getTrader().getName())
                .distinct()
                .sorted(comparing((name) -> name))
                .collect(toList());
  1. 밀라노에 거래자가 있는가?
boolean isMilan = 
    transactions.stream()
                .anyMatch(t -> "Milan".equals(t.getTrader().getCity()));
  1. 케임브리지에 거주하는 거래자의 모든 트랜잭션 값을 출력하시오.
transactions.stream()
            .filter(t -> "Cambridge".equals(t.getTrader().getCity()))
            .map(Transaction::getValue)
            .forEach(System.out::println);
  1. 전체 트랜잭션 중 최댓값은 얼마인가?
transactions.stream()
            .map(Transaction::getValue)
            .reduce(Integer::max)
            .ifPresent(max -> System.out.println("max : " + max););
  1. 전체 트랜잭션 중 최솟값을 얼마인가?
transactions.stream()
            .reduce((t1, t2) -> {t1.getValue() < t2.getValue() ? t1 : t2;})
            .ifPresent(t -> System.out.println("min : " + t.getValue()););

5.7 숫자형 스트림

5.7.1 기본형 특화 스트림

  • 자바 8에서는 세 가지 기본형 특화 스트림을 제공한다.
  • 스트림 API는 박싱 비용을 피할 수 있도록 'int 요소에 특화된 IntStream', 'double 요소에 특화된 DoubleStream', 'long 요소에 특화된 LongStream'을 제공한다.
  • 각각의 인터페이스는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max같이 자주 사용하는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다.
  • 필요할 때 다시 객체 스트림으로 복원하는 기능도 제공한다.
  • 특화 스트림은 오직 박싱 과정에서 일어나는 효율성과 관련 있으며 스트림에 추가 기능을 제공하지는 않는다는 사실을 기억하자.
  • 숫자 스트림으로 매핑
  • 스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다.
  • map과 정확히 같은 기능을 수행하지만, Stream< T > 대신 특화된 스트림(IntStream, DoubleStream, LongStream)을 반환한다.
  • IntStream은 max, min, average 등 다양한 유틸리티 메서드도 지원한다.
int calories = menu.stream()
                   .mapToInt(Dish::getCalories)
                   .sum();
객체 스트림으로 복원하기
IntStream intStream = menu.stream().mapToInt(Dish::getCalories);
Stream<Integer> stream = intStream.boxed(); // 객체 스트림으로 복원
기본값: OptionalInt
  • 숫자 스트림에 요소가 없는 상황과 실제 최댓값이 0인 상황을 구별할 필요가 있다.
  • 값이 존재하는지 여부를 가리킬 수 있는 컨테이너 클래스 Optional
  • Optional을 Integer, String 등의 참조 형식으로 파라미터화할 수 있다.
  • OptionalInt, OptionalDouble, OptionalLong 세 가지 기본형 특화 스트림 버전도 제공한다.
OptionalInt maxCalories = menu.stream() 
                              .mapToInt(Dish::getCalories)
                              .max();
  • OptionalInt를 이용해서 만약 최댓값이 없는 상황일 때 사용할 기본값을 명시적으로 정의할 수 있다.
int max = maxCalories.orElse(1);

5.7.2 숫자 범위

  • 자바 8의 IntStream과 LongStream에서는 range와 rangeClosed라는 두 가지 정적 메서드를 제공한다.
  • range 메서드는 종료값이 결과에 포함되지 않는 반면,
  • rangeClosed 메서드는 종료값이 결과에 포함된다.
IntStream evenNumbers = IntStream.rangeClosed(1, 100)
                                 .filter(i -> i % 2 == 0);
System.out.println(evenNumbers.count());

5.8 스트림 만들기

  • 다양한 방식으로 스트림을 만들 수 있다.
  • 일련의 값, 배열, 파일, 심지어 함수를 이용한 무한 스트림을 만들 수도 있다.

5.8.1 값으로 스트림 만들기

Stream<String> stream = Stream.of("Modern", "Java", "In", "Action");
stream.map(String::toUpperCase).forEach(System.out::println);
Stream<String> emptyStream = Stream.empty();

5.8.2 null이 될 수 있는 객체로 스트림 만들기

  • null이 될 수 있는 객체를 스트림으로 만들어야할 수도 있다.
String homeValue = System.getProperty("home");
Stream<String> homeValueStream = 
                home == null ? Stream.empty() : Stream.of(homeValue);

Stream<String> homeValueStream 
            =Stream.ofNullable(System.getProperty("home"));

5.8.3 배열로 스트림 만들기

  • 배열을 인수로 받는 정적 메서드 Arrays.stream을 이용해서 스트림을 만들 수 있다.
int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();// int로 이루어진 배열을 IntStream으로 변환

5.8.4 파일로 스트림 만들기

  • 파일을 처리하는 등의 I/O 연산에 사용하는 자바의 Non-Blocking I/O API도 스트림 API를 활용할 수 있도록 업데이트되었다.
  • java.nio.file.Files의 많은 정적 메서드가 스트림을 반환한다.
  • Files.lines는 주어진 파일의 행 스트림을 문자열로 반환한다.
long uniqueWords = 0;

try(
    Stream<String> lines =
            Files.lines(Paths.get("data.txt"), Charset.defaultCharset())
){
    uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
                       .distinct()
                       .count(); 
}catch(IOException e){

}finally{

}
Path dir = "";
Stream<Path> pathStream = Files.list(dir); // Path(파일 또는 디렉토리), 지정된 경로
                                           // 안 의 파일 또는 디렉토리들을 스트림으로 

5.8.5 함수로 무한 스트림 만들기

IntStream intStream = new Random().ints();
intStream.forEach(System.out::println);

intStream = new Random().ints(5);
intStream.forEach(System.out::println);
Stream 인터페이스의 정적 메서드 iterate => 요청할 때마다 값을 생성 => 무한 스트림, 언바운드 스트림(한정이 없는 스트림)
  • iterate 메서드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다.
  • iterate 메서드는 이전 요소를 seed로 해서 다음 요소를 계산한다.
Stream.iterate(0, n -> n + 2)
      .limit(10)
      .forEach(System.out::println);
  • 자바 9의 iterate 메서드는 프레디케이트를 지원한다.
IntStream.iterate(0, n -> n < 100, n -> n + 4)
         .forEach(System.out::println);
IntStream.iterate(0, n -> n + 4)
         .takeWhile(n -> n < 100)
         .forEach(System.out::println);
Stream 인터페이스의 정적 메서드 generate 메서드
  • iterate와 비슷하게 generate도 요구할 때 값을 계산하는 무한 스트림을 만들 수 있다.
  • iterate와 달리 generate는 생산된 각 값을 연속적으로 계산하지 않는다.
  • generate는 Supplier< T >를 인수로 받아서 새로운 값을 생산한다.
  • 이전 요소를 seed로 사용하지 않는다.
Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println) 

5.9 마치며

  • 스트림 API를 이용하면 복잡한 데이터 처리 질의를 표현할 수 있다.
  • filter, distinct, takeWhile, dropWhile, skip, limit 메서드로 스트림을 필터링하거나 자를 수 있다.
  • 소스가 정렬되어 있다는 사실을 알고 있을 때, takeWhile과 dropWhile 메소드를 효과적으로 사용할 수 있다. (반복 수를 줄일 수 있다.)
  • map, flatMap 메서드로 스트림의 요소를 추출하거나 변환할 수 있다.
  • findFirst, findAny 메서드로 스트림의 요소를 검색할 수 있다(Optional 컨테이너 타입 결과 반환). allMatch, noneMatch, anyMatch 메서드를 이용해서 주어진 프레디케이트와 일치하는 요소를 스트림에서 검색할 수 있다.(boolean 반환)
  • 이러한 검색 메서드들은 쇼트서킷, 즉 결과를 찾는 즉시 반환하며, 전체 스트림을 처리하지는 않는다.
  • reduce 메서드로 스트림의 모든 요소를 반복 조합하며 값을 도출할 수 있다.(Optional 반환)
  • filter, map 등은 상태를 저장하지 않는 상태 없는 연산이다.
  • reduce 같은 연산은 값을 계산하는 데 필요한 상태를 저장하며, sorted, distinct 등의 메서드는 새로운 스트림을 반환하기에 앞서 스트림의 모든 요소를 버퍼에 저장한다. 이런 메서드를 상태 있는 연산이라 한다.
  • IntStream, DoubleStream, LongStream은 기본형 특화 스트림이다. 이들 연산은 각각의 기본형에 맞게 특화되어 있다.
  • 컬렉션 뿐 아니라 값, 배열, 파일, iterate와 generate 같은 메서드로도 스트림을 만들 수 있다.