Java

Java Stream API를 사용해보자!

달려LIKE추추 2022. 5. 29. 10:48
Java Stream API의 주요 기능에 대한 설명과 사용법에 관해 작성한 글입니다.

 

Java Stream

 

Java Stream API는 Java8 부터 등장하여 사용 방법이 다른 Collection Framework 들을 표준화하여 다루는 것을 가능하게 해 주었다. 우리는 데이터 소스를 추상화하여 데이터 소스(List, Map, Set, 배열 등등)가 무엇이든 간에 같은 방식으로 다룰 수 있게 되었고, 이는 코드의 재사용성을 높이는 결과를 가져오게 되었다.

 

// 기존
int lotto[] = new int [6];

for(int i=0; i<6; i++) {
    lotto[i] = (int)(Math.random() * 45) + 1;
    for(int j=0; j<i; j++) {
        if(lotto[i] == lotto[j]) {
            i--;
            break;
        }
    }
    System.out.print(lotto[i] + " ");
}

//Stream 사용
IntStream intStream = new Random().ints(1,46);  		
intStream.distinct().limit(6).sorted()                          
        .forEach(i-> System.out.println(i + " ,"));

 

기존의 코드 방식은 for문, 필터링을 위한 분기 if문을 사용해야 구현할 수 있었던 반면, Stream을 이용한 코드 방식은 비교적 짧은 코드로 구현할 수 있다. 즉, 불필요한 코딩(for, if 문법)을 걷어낼 수 있고 직관적이기 때문에 가독성이 좋아진다. 이 점이 Stream의 장점이자 목적이다.

 

 

Stream API의 특징

  • 원본의 데이터를 변경하지 않는다.
  • 일회용이다.
  • 지연된 연산처리를 한다.
  • 작업을 내부 반복으로 처리한다.
  • 스트림의 작업을 병렬로 처리한다.
  • 기본형 스트림을 제공한다.

 

원본의 데이터를 변경하지 않는다.

스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐 변경하지 않는다

List<Integer> list = Arrays.asList(3, 1, 5, 4, 2);
List<Integer> sortedList = list.stream().sorted()   // list를 정렬
        .collect(Collectors.toList());              // 새로운 List에 저장
System.out.println(list);       // [3, 1, 5, 4, 2]
System.out.println(sortedList); // [1, 2, 3, 4, 5]

 

일회용이다.

Stream API는 일회용이기 때문에 한번 사용이 끝나면 재사용이 불가능하다. Stream이 또 필요한 경우에는 Stream을 다시 생성해주어야 한다. 만약 닫힌 Stream을 다시 사용한다면 IllegalStateException이 발생하게 된다.

strStream.forEach(System.out::println);   // 모든 요소를 화면에 출력(최종연산)
Long numOfStr = strStream.count();        // IllegalStateException 스트림이 이미 닫힘

 

지연된 연산처리를 한다.

최종 연산 전까지 중간 연산이 수행되지 않는다

IntStream intStream = new Random().ints(1,46);  		// 1~45범위의 무한 스트림
intStream.distinct().limit(6).sorted()                          // 중간 연산
        .forEach(i-> System.out.println(i + " ,"));             // 최종 연산

 

작업을 내부 반복으로 처리한다.

Stream을 이용하면 코드가 간결해지는 이유 중 하나는 '내부 반복' 때문이다. 기존에는 반복문을 사용하기 위해서 for이나 while 등과 같은 문법을 사용해야 했지만, stream에서는 그러한 반복 문법을 메소드 내부에 숨기고 있기 때문에, 보다 간결한 코드의 작성이 가능하다. 

strArrStrm.forEach(System.out::println); // 반복문이 forEach라는 함수 내부에 숨겨져 있다.

 

스트림의 작업을 병렬로 처리한다.

병렬 스트림 (멀티쓰레드) - 손쉽게 병렬 처리가 가능해져 성능을 개선할 수 있다.

Stream<String> strStream = Stream.of("a","babb","aaccdd","aaddddd");
long cnt = strStream.parallel().filter(x -> x.contains("a")).count();
System.out.println(cnt);

 

기본형 스트림을 제공한다.

오토박싱 및 언박싱의 비효율이 제거되었고(Stream<Integer> 대신 IntStream사용) 숫자와 관련된 유용한 메서드를 Stream<T>보다 더 많이 제공한다.

IntStream intStream = IntStream.of(1, 2, 3);
intStream.average();
intStream.sum();
intStream.max();

 


Stream API의 구조

Stream은 데이터를 처리하기 위해 다양한 연산들을 지원한다. Stream이 제공하는 연산을 이용하면 복잡한 작업들을 간단히 처리할 수 있는데, Stream에 대한 연산은 크게 스트림 생성, 중간 연산, 최종 연산 3가지 단계로 나눌 수 있다.

 

1. Stream 생성

2. 중간 연산

3. 최종 연산

 

List<String> strList = Arrays.asList("abc", "bcd", "bdh", "def", "efg");
strList.stream()   //스트림 생성
    .filter(s -> s.startsWith("b")) // 중간 연산
    .map(String::toUpperCase)   //중간 연산
    .sorted()   //중간 연산
    .count();   //최종 연산

 

1. Stream 생성

 

[ Collection Stream 생성 ]

Collection 인터페이스에는 stream()이 정의되어 있기 때문에, Collection 인터페이스를 구현한 객체들(List, Set 등)은 모두 이 메소드를 이용해 Stream을 생성할 수 있다. stream()을 사용하면 해당 Collection의 객체를 소스로 하는 Stream을 반환한다.

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

 

[ 배열 Stream 생성 ]

// 배열로부터 스트림을 생성
Stream<String> stream = Stream.of("a", "b", "c"); //가변인자
Stream<String> stream = Stream.of(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"});
Stream<String> stream = Arrays.stream(new String[] {"a", "b", "c"}, 0, 3); //end범위 포함 x

 

[ 원시 Stream 생성 ]

위와 같이 객체를 위한 Stream 외에도 int와 long 그리고 double과 같은 원시 자료형들을 사용하기 위한 특수한 종류의 Stream(IntStream, LongStream, DoubleStream) 들도 사용할 수 있다.

IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
IntStream intStream = IntStream.of(new int[] {1, 2, 3, 4, 5});
IntStream intStream = Arrays.stream(new int[] {1, 2, 3, 4, 5});
IntStream intStream = Arrays.stream(new int[] {1, 2, 3, 4, 5}, 0, 3);

//특정 범위의 정수를 요소로 갖는 스트림 생성할 수 있다 (IntStream, LongStream)
IntStream intStream = IntStream.range(1, 5);      // 1,2,3,4
IntStream intStream = IntStream.rangeClosed(1, 5);      // 1,2,3,4,5

 

[ 파일을 소스로 하는 Stream 생성 ]

File[] fileArr = { new File("Ex1.java"), new File("Ex1.bak")
        ,new File("Ex2.java"), new File("Ex1"), new File("Ex1.txt")};

Stream<File> fileStream = Stream.of(fileArr);

 

 

2. Stream의 중간 연산

Stream의 대표적인 중간 연산의 종류는 다음과 같다.

 

 

[ Stream 자르기 - skip(), limit() ]

skip() - 앞에서부터 n개 건너뛰기

limit() - maxSize 이후의 요소는 잘라냄

IntStream intStream = IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); // 12345678910
intStream.skip(3).limit(5).forEach(System.out::println); //45678

 

 

[ Stream의 요소 걸러내기 - filter(), distinct() ]

filter() - 조건에 맞지 않는 요소 제거

IntStream intStream = IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); //12345678910
intStream.filter(i -> i % 2 == 0).forEach(System.out::println);     //246810

 

distinct() - 중복 제거

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

 

 

[ Stream 정렬하기 - sorted() ]

Stream의 요소들을 정렬하기 위해서는 sorted를 사용해야 하며, 파라미터로 Comparator를 넘길 수도 있다. Comparator 인자 없이 호출할 경우에는 오름차순으로 정렬이 되며, 내림차순으로 정렬하기 위해서는 Comparator의 reverseOrder를 이용하면 된다. 예를 들어 어떤 Stream의 String 요소들을 정렬하기 위해서는 다음과 같이 sorted를 활용할 수 있다.

 

List<String> list = Arrays.asList("IronMan", "Hulk", "Thor", "Steven", "Groot");

list.stream().sorted().forEach(System.out::println); 
// [Groot, Hulk, IronMan, Steven, Thor ]

list.stream().sorted(Comparator.reverseOrder()).forEach(System.out::println); 
// [Thor, Steven, IronMan, Hulk, Groot ]

 

 

[ Stream 변환하기 ]

작업을 하다 보면 일반적인 Stream 객체를 원시 Stream으로 바꾸거나 그 반대로 하는 작업이 필요한 경우가 있다. 이러한 경우를 위해서, 일반적인 Stream 객체는 mapToInt(), mapToLong(), mapToDouble()이라는 특수한 Mapping 연산을 지원하고 있으며, 그 반대로 원시객체는 mapToObject를 통해 일반적인 Stream 객체로 바꿀 수 있다.

// IntStream -> Stream<Integer>
IntStream.range(1, 4)
        .mapToObj(i -> "a" + i);

// Stream<Double> -> IntStream -> Stream<String>
Stream.of(1.0, 2.0, 3.0)
        .mapToInt(Double::intValue)
        .mapToObj(i -> "a" + i);

 

[ Stream의 요소 변환하기 - map() ]

File[] fileArr = { new File("IronMan.java"), new File("Hulk.bak")
        ,new File("Steven.java"), new File("Groot"), new File("Thor.txt")};

Stream<File> fileStream = Stream.of(fileArr);
Stream<String> filenameString = fileStream.map(File::getName); 
//map()으로 Stream<File>을 Stream<String>으로 변환

 

 

[ 스트림의 스트림을 스트림으로 변환 - flatMap() ]

만약 우리가 처리해야 하는 데이터가 2중 배열 또는 2중 리스트로 되어 있고, 이를 1차원으로 처리해야 한다면 어떻게 해야 할까? 이러한 경우에 map을 이용해도 결과는 2중 Stream의 형태일 것이다. 이처럼 중첩 구조를 한 단계 제거하기 위한 중간 연산이 필요한데, 이것이 바로 flatMap이다. flatMap은 Function 함수형 인터페이스를 매개 변수로 받고 있다.

예를 들어 다음과 같이 2중 리스트가 존재한다고 할 때, 이를 1중 리스트로 변환하기 위해서 flatMap을 이용할 수 있다.

 

Stream<String[]> strArrStrm = Stream.of(
        new String[]{"abc", "def", "jkl"},
        new String[]{"ABC", "DEF", "JKL"}
);

Stream<Stream<String>> stringStream = strArrStrm.map(Arrays::stream);
stringStream.forEach(System.out::println);
//java.util.stream.ReferencePipeline$Head@3b9a45b3
//java.util.stream.ReferencePipeline$Head@7699a589

 

Stream<String> stringStream = strArrStrm.flatMap(Arrays::stream);
stringStream.forEach(System.out::println);
//abc def jkl ABC DEF JKL

 

[ 스트림의 요소를 소비하지 않고 엿보기 - peek() ]

주로 중간 연산을 종료하지 않고 디버깅하는 용도로 값을 확인하기 위하여 쓰인다.

File[] fileArr = { new File("IronMan.java"), new File("Hulk.bak")
        ,new File("Steven.java"), new File("Groot"), new File("Thor.txt")};

Stream<File> fileStream = Stream.of(fileArr);

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))
        .map(String::toUpperCase)
        .distinct()
        .forEach(System.out::println);

 

3. Stream의 최종 연산

Stream의 대표적인 최종 연산의 종류는 다음과 같다.

 

 

[ Stream의 모든 요소에 지정된 작업을 수행 - forEach(), forEachOrdered() ]

// 병렬스트림인 경우 순서가 보장되지 않음 (forEach)
// 병렬스트림인 경우에도 순서가 보장됨    (forEachOrdered)
IntStream.range(1, 10).forEach(System.out::print); //123456789
IntStream.range(1, 10).forEachOrdered(System.out::print); //123456789

IntStream.range(1, 10).parallel().forEach(System.out::print); //468579312
IntStream.range(1, 10).parallel().forEachOrdered(System.out::print); //123456789

 

 

[ 최댓값/최솟값/총합/평균/갯수 - Max/Min/Sum/Average/Count ]

Stream의 요소들을 대상으로 최솟값이나 최댓값 또는 총합을 구하기 위한 최종 연산들이 존재한다.  min이나 max 또는 average는 Stream이 비어있는 경우에 값을 특정할 수 없다. 그렇기 때문에 다음과 같이 Optional로 값이 반환된다.

총합이나 개수 같은 경우에는 비어있을 경우 0으로 값을 특정할 수 있기 때문에 Stream API는 해당 메소드에 대해 Optional이 아닌 원시 값을 반환하도록 구현해두었다.

OptionalInt min = IntStream.of(1, 2, 3, 4, 5).min();
int max = IntStream.of().max().orElse(0);
IntStream.of(1, 2, 3, 4, 5).average().ifPresent(System.out::println);
long count = IntStream.of(1, 2, 3, 4, 5).count();
long sum = LongStream.of(1, 2, 3, 4, 5).sum();

 

 

[ 조건 검사 - Match ]

Stream의 요소들이 특정한 조건을 충족하는지 검사하고 싶은 경우에는 match 함수를 이용할 수 있다. match 함수는 함수형 인터페이스 Predicate를 받아서 해당 조건을 만족하는지 검사를 하게 되고, 검사 결과를 boolean으로 반환한다. match 함수에는 크게 다음의 3가지가 있다.

  • anyMatch: 1개의 요소라도 해당 조건을 만족하는가
  • allMatch: 모든 요소가 해당 조건을 만족하는가
  • nonMatch: 모든 요소가 해당 조건을 만족하지 않는가
List<String> names = Arrays.asList("apple", "banana", "chocolate");

boolean anyMatch = names.stream().anyMatch(name -> name.contains("a")); //true
boolean allMatch = names.stream().allMatch(name -> name.length() > 3);  //true
boolean noneMatch = names.stream().noneMatch(name -> name.endsWith("s"));   //true

 

 

[ 조건에 일치하는 요소 찾기 - Find ]

  • findFirst: 첫 번째 요소를 반환. 순차 스트림에 사용
  • findAny: 아무거나 하나를 반환. 병렬 스트림에 사용
Optional<String> result1 = names.stream().filter(s -> s.length() > 5).findFirst();
Optional<String> result2 = names.parallelStream().filter(s -> s.length() > 5).findAny();

 

 

[ Stream의 요소를 하나씩 줄여가며 누적 연산 수행 - reduce ]

 

매개변수 타입이 BinaryOperator<T>이기 때문에 처음 두 요소를 가지고 연산한 결과를 가지고 그다음 요소와 연산한다. 이 과정에서 스트림 요소를 소모하게 된다.

identity는 초기값으로 초기값을 주면 stream요소가 없을 때 identity를 반환한다. 또한 초기값과 첫 번째 요소로 연산을 시작한다. 반면 초기값 설정을 해주지 않으면 요소가 없는 스트림일 경우 null을 반환할 수 있기 때문에 identity가 없는 accumulator만 매개변수로 받는 reduce()는 리턴타입이 Optional<T>이다. accumulator는 수행할 연산을 입력해 주는 것이다. 또한 연산을 하는 count()와 sum()과 같은 메소드들은 내부적으로 모두 reduce()를 이용해서 작성된 것이다.

reduce()가 중요한 이유는 스트림의 최종 연산을 다 reduce()를 이용해 만들기 때문이다.

int sum = IntStream.of(1, 2, 3, 4, 5, 6).reduce(0, (a, b) -> a + b);        //21
int count = IntStream.of(1, 2, 3, 4, 5, 6, 7, 8, 9).reduce(0, (a, b) -> a + 1); //9

 

 

[ 데이터 수집 - collect ] 

Stream의 요소들을 List나 Set, Map, 등 다른 종류의 결과로 수집하고 싶은 경우에는 collect 함수를 이용할 수 있다. collect 함수는 어떻게 Stream의 요소들을 수집할 것인가를 정의한 Collector 타입을 인자로 받아서 처리한다. 일반적으로 List로 Stream의 요소들을 수집하는 경우가 많은데, 이렇듯 자주 사용하는 작업은 Collectors 객체에서 static 메소드로 제공하고 있다. 원하는 것이 없는 경우에는 Collector 인터페이스를 직접 구현하여 사용할 수도 있다.

  • collect() : 스트림의 최종 연산, 매개변수로 Collector를 필요로 한다.
  • Collector : 인터페이스, collect의 파라미터는 이 인터페이스를 구현해야 한다.
  • Collectors : 클래스, static메소드로 미리 작성된 컬렉터를 제공한다.

아래와 같은 데이터를 정의하고, 다양한 방식으로 수집해볼 것이다.

더보기
Student[] stuArr = {
        new Student("나자바", true,  1, 1, 300), //name, isMale, hak, ban , score
        new Student("김지미", false, 1, 1, 250),
        new Student("김자바", true,  1, 1, 200),
        new Student("이지미", false, 1, 2, 150),
        new Student("남자바", true,  1, 2, 100),
        new Student("안지미", false, 1, 2,  50),
        new Student("황지미", false, 1, 3, 100),
        new Student("강지미", false, 1, 3, 150),
        new Student("이자바", true,  1, 3, 200),
        new Student("나자바", true,  2, 1, 300),
        new Student("김지미", false, 2, 1, 250),
        new Student("김자바", true,  2, 1, 200),
        new Student("이지미", false, 2, 2, 150),
        new Student("남자바", true,  2, 2, 100),
        new Student("안지미", false, 2, 2,  50),
        new Student("황지미", false, 2, 3, 100),
        new Student("강지미", false, 2, 3, 150),
        new Student("이자바", true,  2, 3, 200)
};

 

1. Collectors.toList() 

Stream의 결과를 학생의 이름으로 변환하여 List로 반환 받고 있다.

String joiningName = Stream.of(stuArr).map(Student::getName).
        collect(Collectors.joining(", ","[","]"));
        //[나자바, 김지미, 김자바, 이지미, 남자바, 안지미, 황지미, 강지미, 이자바, 나자바,
        // 김지미, 김자바, 이지미, 남자바, 안지미, 황지미, 강지미, 이자바]

 

2. Collectors.joining()

결과를 이어 붙일 때 사용한다. 파라미터가 순차적으로 세 개가 올 수 있는데 요소를 구분시켜주는 구분자(delimiter), 요소 맨 앞에 올 문자 (prefix), 요소 맨뒤에 올 문자(suffix)가 파라미터로 올 수 있다.

String joiningName = Arrays.stream(stuArr).map(Student::getName).
        collect(Collectors.joining(", ","[","]"));
        //[나자바, 김지미, 김자바, 이지미, 남자바, 안지미, 황지미, 강지미, 이자바, 나자바,
        // 김지미, 김자바, 이지미, 남자바, 안지미, 황지미, 강지미, 이자바]

 

3. Collectors.averagingInt(), Collectors.summingInt()

Stream에서 작업한 결과의 평균값이나 총합 등을 구하기 위해서는 Collectors.averagingInt()와 Collectors.summingInt()를 이용할 수 있다. 앞서 살펴보았던 최종 연산들이 쉽게 제공하는 통계 정보를 collect()로 똑같이 얻을 수 있다. collect()를 사용하지 않고도 쉽게 얻을 수 있지만, collect()를 통해서 그룹별 counting이 가능하기 때문에 사용한다.

Integer summingScore = Stream.of(stuArr).collect(Collectors.summingInt(Student::getScore));
Double averagingScore = Stream.of(stuArr).collect(Collectors.averagingInt(Student::getScore));

 

4. Collectors.partitioningBy()

Collectors.partitioningBy()는 함수형 인터페이스 Predicate를 받아 Boolean을 Key값으로 partitioning 한다.

// 성별로 분할
Map<Boolean, List<Student>> stuBySex = Stream.of(stuArr).collect(partitioningBy(Student::isMale));
List<Student> maleStudent = stuBySex.get(true);
List<Student> femaleStudent = stuBySex.get(false);

 

5. Collectors.groupingBy()

 

Stream에서 작업한 결과를 특정 그룹별로 묶어서 작업할 경우가 생긴다. 이러한 경우에 Collectors.groupingBy()를 이용할 수  있으며, 결과는 Map으로 반환받는다. groupingBy는 매개변수로 함수형 인터페이스 Function을 필요로 한다.

// 반별로 분할
Map<Integer, List<Student>> stuByBan = Stream.of(stuArr).collect(groupingBy(Student::getBan));

for (List<Student> ban : stuByBan.values()) {
    for (Student s: ban){
        System.out.println(s);
    }
}