[자바] 스트림

injoon2019·2021년 2월 16일
0

자바

목록 보기
34/34

스트림이란?

지금까지 우리는 많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 Iterator를 이용해서 코드를 작성해왔다. 그러나 이러한 방식으로 작성된 코드는 너무 길고 알아보기 어렵다. 그리고 재사용성도 떨어진다.

또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 것이다. Collection이나 Iterator 같은 인터페이스를 이용해서 컬렉션을 다루는 방식을 표준화하기는 했지만, 각 컬렉션 클래스에는 같은 기능의 메서드들이 중복해서 정의되어 있다. 예를 들어 List를 정렬할 때는 Collections.sort()를 사용해야하고, 배열을 Collections.sort()를 사용해야하고, 배열을 정렬할 때는 Arrays.sort()를 사용해야 한다.

이러한 문제점들을 해결하기 위해서 만든 것이 스트림(Stream)이다. 스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓았다. 데이터 소스를 추상화하였다는 것은 데이터 소스가 무엇이던 간에 같은 방식으로 다룰 수 있게 되었다는 것과 코드의 재사용성이 높아진다는 것을 의미한다. 스트림을 이용하면, 배열이나 컬레션뿐만 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.

예를 들어, 문자열 배열과 같은 내용의 문자열을 저장하는 List가 있을 때,

String[] strArr = {"aaa", "ddd", "ccc"};
List<String> strList = Arrays.asList(strArr);

이 두 데이터 소스를 기반으로 하는 스트림은 다음과 같이 생성한다.

Stream<String> strStream1 = strList.stream();
Stream<String> strStream2 = Arryas.stream(strArr);

이 두 스트림으로 데이터 소스의 데이터를 읽어서 정렬하고 화면에 출력하는 방법은 다음과 같다. 데이터 소스가 정렬되는 것은 아니라는 것에 유의하자.

strStream1.sorted().forEach(System.out::println);
strStream2.sorted().forEach(System.out::println);

두 스트림의 데이터 소스는 서로 다르지만, 정렬하고 출력하는 방법은 완전히 동일하다.

Arrays.sort(strArr);
Collections.sort(strList);

for (String str : strArr)
    System.out.println(str);

for (String str : strList)
    System.out.println(str);

스트림은 데이터 소스를 변경하지 않는다

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다는 차이가 있다. 필요하다면, 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수도 있다.

//정렬된 결과를 새로운 List에 담아서 반환한다.
List<String> sortedList = strStream2.sorted().collect(Collectors.toList());

스트림은 일회용이다

스트림은 Iterator처럼 일회용이다. Iterator로 컬렉션의 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, 스트림도 한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하다면 스트림을 다시 생성해야 한다.

strStream1.sorted().forEach(System.out::println);
int numOfStr = strStream1.count(); //에러. 스트림이 이미 닫혔음.

스트림은 작업을 내부 반족으로 처리한다.

스트림을 이용한 작업이 간결할 수 있는 비결중의 하나가 바로 '내부 반복'이다. 내부 반복이라는 것은 반복문을 메서드의 내부에 숨길 수 있다는 것을 의마한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용한다.

스트림의 연산

스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있는데, 중간 연산은 연산결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면에 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.

Stream<Integer>와 IntStream

요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱&언박싱으로 인한 비효율을 줄이기 위해 데이터 소스의 요소를 기본형으로 다루는 스트림, IntStream, LongStream, DoubleStream이 제공된다.

병렬 스트림

스트림으로 데이터를 다룰 때의 장점 중 하나가 바로 병렬 처리가 쉽다는 것이다. 병렬 스트림은 내부적으로 fork&join 프레임웍을 이용해서 자동적으로 연산을 병렬로 수행한다. 우리가 할일이라고는 그저 스트림에 parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 될 뿐이다. 반대로 병렬로 처리되지 않게 하려면 sequential()을 호출하면 된다. 모든 스트림은 기본적으로 병렬 스트림이 아니므로 sequential()을 호출할 필요가 없다. 이 메서드는 parallel()을 호출한 것을 취소할 때만 사용한다.

스트림 만들기

스트림의 소스가 될 수 있는 대상은 배열, 컬렉션, 임의의 수 등 다양하다.

컬렉션

컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다. stream()은 해당 컬렉셔ㅑㄴ을 소스로 하는 스트림을 반환한다.

Stream<T> Collection.stream()

예를 들어 List로부터 스트림을 생성하는 코드는 다음과 같다.

List<Integer> List = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream();

forEach()는 지정된 작업을 스트림의 모든 요소에 대해 수행한다.

intStream.forEach(System.out::println); //스트림의 모든 요소를 출력한다.
intStream.forEach(System.out::println); //에러. 스트림이 이미 닫혔다. 

배열

Stream<String> strStream = Stream.of("a", "b", "c"); //가변인자
Stream<String> strStream = Stream.of(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"}, 0, 3);

특정 범위의 정수

IntStream과 LongStream은 다음과 같이 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다.

IntStream  IntStream.range(int begin, int end)
IntStream  IntStream.rangeClosed(int begin, int end)

range()의 경우 경계의 끝인 end가 범위에 포함되지 않고, rangeClose()의 경우는 포함된다.

IntStream intStream = IntStream.range(1,5); //1,2,3,4
IntStream intStream = IntStream.rangeClosed(1,5); //1,2,3,4,5

임의의 수

난수를 생성하는데 사용하는 Random 클래스에는 아래와 같은 인스턴스 메서드들이 포함되어 있다. 이 메서드들은 해당 타입의 난수들로 이루어진 스트림을 반환한다.

IntStream ints()
LongStream longs()
DoubleStream doubles()

이 메서드들이 반환하는 스트림은 크기가 정해지지 않은 '무한 스트림(infinite stream)'이므로 limit()도 같이 사용해서 스트림의 크기를 제한해 주어야 한다. limit()은 스트림의 개수를 지정하는데 사용되며, 무한 스트림을 유한 스트림으로 만들어 준다.

IntStream intStream = new Random().ints(); //무한 스트림
intStream.limit(5).forEach(System.out::println); //5개의 요소만 출력

람다식 - iterate(), generate()

Stream클래스의 iterate()와 generate()는 람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.

static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> Stream<T> generate(Supplier<T> s)

iterate()는 씨앗값(seed)으로 지정된 값부터 시작해서, 람다식 f에 의해 계산된 결과를 다시 seed값으로 해서 계산을 반복한다. 아래의 evenStream은 0부터 시작해서 값이 2씩 계속 증가한다.

Stream<Integer> evenStream = Stream.iterate(0, n -> n+2); //0, 2, 4, 6

generate()도 iterate()처럼, 람다식에 의해 계산되는 값을 요소로하는 무한 스트림을 생성해서 반환하지만, iterate()와 달리, 이전 결과를 사용해서 다음 요소를 계산하지 않는다.

Stream<Double> randomStream = Stream.generate(Math::random);
Stream<Integer> oneStream = Stream.generate(() -> 1);

그리고 generate()에 정의된 매개변수의 타입은 Supplier<T>이므로 매개변수가 없는 람다식만 허용된다. 한 가지 주의할 점은 iterate()와 generate()에 의해 생서된 스트림을 아래와 같이 기본형 스트림 타입의 참조변수로 다룰 수 없다는 것이다.

IntStream evenStream  = Stream.iterate(0, n->n+2); //에러
DoubleStream randomStream = Stream.generate(Math::random);	//에러

굳이 필요하다면, 아래와 같이 mapToInt()와 같은 메서드로 변환을 해야한다.

IntStream evenStream = Stream.iterate(0, n->n+2).mapToInt(Integer::valueOf);
Stream<Integer> stream = evenStream.boxed(); //IntStream -> Stream<Integer>

반대로 IntStream타입의 스트림을 Stream<Integer>타입으로 변환하려면, boxed()를 사용하면 된다.

빈 스트림

요소가 하나도 없는 빈 스트림을 생성할 수도 있다. 스트림에 연산을 수행한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는 것이 낫다.

Stream emptyStream = Stream.empty(); //empty()는 빈 스트림을 생성해서 반환한다.
long count = emptyStream.count();

count()는 스트림 요소의 개수를 반환하며, 위의 문장에서 변수 count 값은 0이 된다.

두 스트림의 연결

Stream의 static 메서드인 concat(0을 사용하면 두 스트림을 하나로 연결할 수 있다. 물론 연결하려는 두 스트림의 요소는 같은 타입이어야 한다.

String[] str1 = {"123", "456", "789"};
String[] str2 = {"ABC", "abc", "DEF"};

Stream<String> strs1 = Stream.of(str1);
Stream<String> strs2 = Stream.of(str2);
Stream<String> strs3 = Stream.concat(strs1, strs2); //두 스트림을 하나로 연결

스트림의 중간 연산

스트림 자르기 - skip(), limit()

skip()과 limit()은 스트림의 일부를 잘라낼 때 사용하며, 사용법은 아주 간단하다. skip(3)은 처음 3개의요소를 건너뛰고, limit(5)는 스트림의 요소를 5개로 제한한다.

예를 들어 10개의 요소를 가진 스트림에 skip(3)과 limit(5)을 순서대로 적용하면 4번쨰 요소부터 5개의 요소를 가진 스트림이 반환된다.

IntStream intStream = IntStream.rangeClosed(1, 10); //1~10의 요소를 가진 스트림
intStream.skip(3).limit(5).forEach(System.out::print); //45678

스트림의 요소 걸러내기 - filter(), distinct()

distinct()는 스트림에서 중복된 요소들을 제거하고, filter()는 주어진 조건(Predicate)에 맞지 않는 요소를 걸러낸다.

Stream<T> filter(Predicate<? super T> predicate)
Stream<T> distinct()

distinct()의 사용 방법은 간단하다.

IntStream intStream = IntStream.of(1,2,2,3,3,3,4,5,5,6);
itnStream.distinct().forEach(System.out::print); //123456

filter()는 매개변수로 Predicate를 필요로 하는데, 아래와 같이 연산결과가 boolean인 람다식을 사용해도 된다.

IntStream intStream = IntStream.rangeClosed(1, 10); //1~10
intStream.filter(i -> i%2 == 0).forEach(System.out::print); //246810

필요하다면 filter()를 다른 조건으로 어려 번 사용할 수도 있따.

//아래의 두 문장은 동일한 결과를 얻는다
intStream.filter(i -> i%2 != 0 && i % 3 != 0).forEach(System.out::print); // 157
intStream.filter(i -> i%2).filter(i->i%3 !=0).forEach(System.out::print); // 157

정렬

스트림을 정렬할 때는 sorted()를 사용하면 된다.

Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)

sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator대신 int값을 반환하는 람다식을 사용하는 것도 가능하다. Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 단, 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.

Stream<String> strStream = Stream.of("dd", "aaa", "CC", "cc", "b");
strStream.sorted().forEach(System.out::print); //CCaaabccdd

정렬 조건을 추가할 때는 thenComparing()을 사용한다. 예를 들어 학생 스트림(studentSteam)을 반별, 성적순, 그리고 이름순으로 정렬하여 출력하려면 다음과 같다.

studentStream.sorted(Comparator.comparing(Student::getBan)
			.thenComparing(Student::getTotalScore)
            .thenComparing(Student::getName)
            .forEach(System.out.println);

다음의 예제는 학생의 성적을 반별 오름차순, 총점별 내림차순으로 정렬하여 출력한다.

import java.util.*;
import java.util.stream.*;

class StreamEx1 {
    public static void main(String[] args) {
        Stream<Student> studentStream = Stream.of(
                       new Student("이자바", 3, 100),
                       new Student("김자바", 1, 200),
                       new Student("안자바", 2, 100),
                       new Student("박자바", 2, 150),
                       new Student("소자바", 1, 200),
                       new Student("나자바", 3, 290),
                       new Student("감자바", 3, 180)
                    );
        studentStream.sorted(Comparator.comparing(Student::getBan)
              .thenComparing(Comparator.naturalOrder()))
              .forEach(System.out::println);
    }
}
class Student implements Comparable<Student> {
    String name;
    int ban;
    int totalScore;
    
    Student(String name, int ban, int totalScore) {
        this.name = name;
        this.ban = ban;
        this.totalScore = totalScore;
    }
    
    public String toString() {
        return String.format("[%s, %d, %d]", name, ban, totalScore);
    }
    
    String getName() {
        return name;
    }
    
    int getBan() {
        return ban;
    }
    
    int getTotalScore() {
        return totalScore;
    }
    
    //총점 내림차순을 기본정렬로 한다.
    public int CompareTo(Student s) {
        return s.totalScore - this.totalScore;
    }
}

Comparable을 구현해서 총점별 내림차순 정렬이 STudent 클래스의 기본 정렬이 되도록 했다.

변환 - map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다. 이 때 사용하는 것이 바로 map()이다.

Stream<<R> map (Function< ? super T, ? extends R> mapper)\

예를 들어 File의 스트림에서 파일의 이름만 뽑아서 출력하고 싶을 때, 아래와 같이 map()을 이용하면 File객체에서 파일의 이름(String)만 간단히 뽑아낼 수 있다.

Stream<File> fileStream = Stream.of(new File("Ex1.java"), new File("Ex1"), new File("Ex1.bak"), new File("Ex2.java"), new File("Ex1.txt"));
//map()dmfh Stream<File>을 Stream<String>으로 변환
Stream<String> filenameStream = fileStream.map(File::getName);
filenameStream.forEach(System.out::println); //스트림의 모든 파일이름을 출력

map()역시 중간 연산이므로 연산결과는 String을 요소로하는 스트림이다. map()으로 Stream<File>을 Stream<String>으로 변환했다고 볼 수 있다.
그리고 map()도 filter()처럼 하나의 스트림에 여러번 적용할 수 있다. 다음의 문장은 File의 스트림에서 파일의 확장자만을 뽑은 다음 중복을 제거해서 출력한다.

fileStream.map(File::getName) //Stream<File> -> Stream<String>
    .filter(s -> s.indexOf('.')!= -1) //확장자가 없는 것은 제외
    .map(s -> s.substring(s.indexOf('.')+1)) //Stream<String>->Stream<String>
    .map(String::toUpperCase) //모두 대문자로 변환
    .distinct() //중복제거
    .forEach(System.out::print);

조회 - peek()

연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면, peek()를 사용하자. forEach()와 달리 스트림의 요소를 소모하지 않으므로 연산 사이에 여러 번 끼워 넣어도 문제가 되지 않는다.

fileStream.map(File::getName)
    filter(s -> s.indexOf('.') != -1) //확장자가 없는 것은 제외
    .peek(s->System.out.printf("filename = %s%n", s)) //파일명을 출력한다
    .map(s -> s.substring(s.indexOf('.')+1) //확장자만 추출
    .peek(s -> System.out.printf("extension = %s%n", s)) //확장자만 출력
    .forEach(System.out::println);

mapToInt(), mapToLong(), mapToDouble()

map()은 연산의 결과로 Stream<T>타입의 스트림을 반환하는데, 스트림의 요소를 숫자로 반환하는 경우 IntStream과 같은 기본형 스트림으로 변환하는 것이 더 유용할 수 있다. 앞서 사용했던 studentStream에서, 스트림에 포함된 모든 학생의 성적을 합산해야 한다면, map()으로 학생의 총점을 뽑아서 새로운 스트림을 만들어 낼 수 있다.

Stream<Integer> studentScoreStream = studentStream.map(Student::getTotalScore);

그러나 이럴 때는 애초부터 mapToInt()를 사용해서 Stream<Intger>가 아닌 IntStream 타입의 스트림을 생성해서 사용하는 것이 더 효율적이다. 성적을 더할 때, Integer를 int로 변환할 필요가 없기 때문이다.

count()만 지원하는 Stream<T>와 달리 IntStream과 같은 기본형 스트림은 아래와 같이 숫자를 다루는데 편리한 메서드들을 제공한다.

int sum()
OptionalDouble average()
OptionalInt max()
OptionalInt min()

스트림의 요소가 하나도 없을 때, sum()은 0을 반환하면 그만이지만 다른 메서드들은 단순히 0을 반환할 수 없다. 여러 요소들을 합한 평균이 0일 수도 있기 때문이다. 이를 구분하기 위해 단순히 double 값을 반환하는 대신, double 타입의 값을 내부적으로 가지고 있는 OptionalDouble을 반환하는 것이다.

그리고 이 메서드들은 최종연산이기 때문에 호출 후에 스트림이 닫힌다. 아래는 로또번호를 생성해서 출력하는 코드인데, mapToObj()를 이용해서 IntStream을 Stream<String>으로 변환하였다.

IntStream intStream = new Random.ints(1, 46);
Strea<String> lottoStream = intStream.distinct().limit(6).sorted().mapToObj(i -> i + ",");
lottoSTream.forEach(System.out::print);
import java.util.*;
import java.util.stream.*;

class StreamEx3 {
    public static void main(String[] args) {
        Student[] stuArr = {
            new Student("이자바", 3, 300),
            new Student("김자바", 1, 100),
            new Student("안자바", 2, 200),
            new Student("박자바", 3, 300),
            new Student("나자바", 1, 300),
            new Student("소자바", 3, 100)
        };
        
        Stream<Student> stuStream = Stream.of(stuArr);
        
        stuStream.sorted(Comparator.comparing(Student::getBan)
                    .thenComparing(Comparator.naturalOrder()))
                    .forEach(System.out::println);
        
        stuStream = Stream.of(stuArr); //스트림 다시 생성
        IntStream = stuScoreStream = stuStream.mapToInt(Student::getTotalScore);
        
        IntSummaryStatistics stat = stuScoreStream.summaryStatistics();
        System.out.println("count=" + stat.getCount());
        System.out.println("sum=" + stat.getSum());
        System.out.println("average=%.2f%n", stat.getAverage());
        System.out.println("min="+stat.getMin());
        System.out.println("max="+stat.getMax());
    }
}

class Student implements Comparable<Stundent> {
    String name;
    int ban;
    int totalScore;
    Student (String name, int ban, int totalScore) {
        this.name = name;
        this.ban = ban;
        this.totalScore = totalScore;
    }
    
    public String toSTring() {
        return String.format("[%s, %d, %d]", name, ban, totalScore).toString();
    }
    
    String getName() {
        return name; 
    }
    int getBan() {
        return ban;
    }
    int getTotalScore() {
        return totalScore;
    }
    
    public int compareTo(Student s) {
        return s.totalScore - this.totalScore;
    }
}

flatMap() - Stream<T[]>를 Stream<T>로 변환

스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우, 즉 스트림의 타입이 Stream<T[]>인 경우, Stream<T>로 다루는 것이 더 편리할 때가 있다. 그럴 때는 map()대신 flatMap()을 사용하면 된다.

예를 들어 아래와 같이 요소가 문자열 배열(String[])인 스트림이 있을 때,

Stream<String[]> strArrStrm = Stream.of(
    new String[]{"abc", "def", "ghi"},
    new String[]{"ABC", "GHI", "JKLMN"}
);

각 요소의 문자열들을 합쳐서 문자열이 요소인 스트림, 즉 Stream으로 만들려면 어떻게 해야 할까?

먼저 스트림의 요소를 변환해야하니까 일단 map()을 써야할 것이고 여기에 배열을 스트림으로 만들어주는 Arrays.stream(T[])를 함께 사용해보자.

Stream<Stream<String>> strStrStream = strArrStrm.map(Arrays:stream);

예상한 것과 달리 Stream<String[]>을 map(Arrays::stream>으로 변환한 결과는 Stream<String>이 아닌 Stream<Stream<String>>이다. 즉, 스트림의 스트림인 것이다. 이때 간단히 map()을 아래와 같이 flatMap()으로 바꾸기만하면 우리가 원하는 결과를 얻을 수 있다.

Stream<String> strStrStream = strArrStrm.flatMap(Arrays:stream);

Optional<T>와 OptionalInt

관심 있을 만한 포스트

0개의 댓글