본문 바로가기
개발자 준비/JAVA

[JAVA 더 깊게] Stream(feat. 함수형 인터페이스 & Comparator)

by osul_world 2022. 3. 7.
728x90

Stream(feat. 함수형 인터페이스 & Comparator)


개요


서비스를 진행하다보면 데이터 군집(고객리스트,신청자 리스트 등) 가지고 가공해서 서비스를 완성시켜야 하는 경우아주 많다.

이밴트 참여자 객체 군집들을 150점 이하는 제거하고 제거된 군집중 랜덤으로 3명 뽑기 등등



이런경우 반복문과 다양한 변환작업을 피할수가없다.

작업이 많을수록 코드의 가독성은 심각해진다..


스트림은 이럴때 꼭 필요한 기능이다.

전과 달리 이제는 기능도 많이 향상되서 빠르게 처리된다고한다.

그리고 코테에서도 입력리스트를 다룰때 효과적으로 사용할수있다.



스트림은 수많은 라이브러리들의 조합으로 엄청난 기능들을 만들어낼수있다. 그만큼 숙련도가 요구되는 부분이다.



Stream이란?


자바에서 많은 수의 데이터 군집을 다룰때, 컬랙션 혹은 배열에 데이터를 담아 사용한다.

이 컬랙션 혹은 배열을 조작하고 결과를 얻기위해선 for문 또는 Iterator을 사용해 왔다.

자바는 Iterator 이나Collection 같은 인터페이스를 이용해 데이터 군집을 다루는걸 표준화하기는 했지만,

많은 기능들이 중복정의 되어있고 종류에 따라 사용법이 다르다.

//컬랙션 정렬 할때
Collections.sort()

//배열 정렬 할때
Arrays.sort();




  • 무분별한 for문 혹은 반복자 사용은 코드를 길고 가독성은 떨어지며 재사용성도 떨어지게 만든다.
  • 배열과 컬랙션 등 데이터 소스에 따라 사용해야하는 코드가 다 다르다.


이런 문제점들을 해결하기 위해 만든것이 바로 Stream(스트림) 이다.

무분별한 반복코드를 줄이고 데이터 소스에 상관없이 같은 방법으로 다룰수있게 해준다.



스트림은 데이터 소스가 무엇이던 간에 같은 방식으로 다룰수 있다.

배열, 컬랙션,파일데이터 등등 데이터에 상관없이 같은 방법으로 다룰수있다.




중간연산


  • 스트림을 가공한다.
  • 스트림을 소모하지 않는다.


유용한 중간연산


img중간연산



filter

  • 함수형 인터페이스를 매개변수로 받는다. (따라서 람다식 사용)
  • Predicate 즉 조건에 맞지않는 요소를 걸러낸다.
stream.filter(s -> s.equals("김철")).forEach(System.out::print); //김철 오철 -> 김철


distinct

  • 스트림 요소의 중복을 삭제한다.
stream.distinct().forEach(System.out::print); //1 2 2 3 -> 1 2 3


skip

  • 매개변수 만큼 요소를 건너뛴다.
stream.skip(3).forEach(System.out::print); //1 2 3 4 -> 4


limit

  • 스트림 사이즈를 제한한다.
  • 무한 스트림 사용시 꼭 필요하다.
stream.limit(5).forEach(System.out::print); // 1 1 1 1 ... 1 -> 1 1 1 1 1


map

  • 스트림 요소를 원하는 형태로 변환한다.
  • 원하는 필드만 뽑아내거나 다른 타입으로 변경할때 사용한다.
//stream 에 Person 객체가 담겨있다
stream.map( p - > p.getName()).forEach(System.out::print);  //Stream<Person> -> Stream<String> 
  • mapToInt , mapToDouble 등 기본형 스트림으로 바꾸는 map도 있다.

기본형 스트림은 sum, average같은 편리한 연산 메서드를 제공함으로 기본형으로 다룰경우 사용하자



flatMap

  • 스트림 요소가 배열이거나 map의 연산결과가 배열인 경우 이 배열을 스트림으로 바꾸고 싶을때 사용한다.


sorted

  • Comparable 혹은 Comparator을 정렬기준으로 해당 스트림을 정렬한다.
  • Comparator를 파라미터로 전달하지 않으면 스트림 요소의 Comparable 을 기준으로 정렬한다.
  • 이때, 해당 요소 객체는 반드시 Comparable 를 구현해야한다. (안하면 에러발생)
Stream<Integer> stream = list.stream();

//Comparable 사용: 요소인 Integer가 구현한 Comparable을 기준으로 사용
stream.sorted()

//Comparator 사용: 전달한 Comparator를 기준으로 사용
stream.sorted((s1,s2) -> s1.compare(s2)*-1)

https://onlyforus-blog.tistory.com/170

이전에 정리한 Comparable & Comparator 게시글을 꼭 참고하자



  • String이 요소인 경우 String class가 상수형태로 제공하는 다양한 Comparator를 사용하자



  • [중요] Comparator의 default & static 메서드를 적극 이용하자.
stream.sorted((s1,s2) -> s1.compare(s2)*-1)

이런식으로 람다를 이용해 매번 Comparator를 넘겨주는것도 매우 힘들고 복잡한 기능을 구현하기 번거롭다.


Comparator은 그래서 다양한 default & static 메서드를 제공한다.



Comparator (Java SE 11 & JDK 11 ) (oracle.com)

공식 문서에서 default 메서드와 static 메서드를 확인하자.



여기서 가장 잘 활용되는 메서드가 comparing 그리고 thenComparing 이다.

정렬에 사용되는 메서드의 개수가 많지만, 가장 기본적인 메서드는 comparing()이다.

comparing (Function<T, U> keyExtractor) 
comparing (Function<T, U> keyExtractor, Comparator<U> keyComparator)

스트림의 요소가 Comparable을 구현한 경우, 매개변수 하나짜리를 사용하면 되고 그렇지 않은 경우, 추가적인 매개변수로 정렬기준(Comparator)을 따로 지정해 줘야한다.


comparingInt (ToIntFunction<T> keyExtractor) 
comparingLong (ToLongFunction<T> keyExtractor) 
comparingDouble (ToDoubleFunction<T> keyExtractor)

비교대상이 기본형인 경우, comparing()대신 위의 메서드를 사용하면 오토박싱과 언박싱 과정이 없어서 더 효율적이다.


그리고 정렬 조건을 추가할 때는 thenComparing()을 사용 한다.

thenComparing (Comparator<T> other) 
thenComparing (Function<T, U> keyExtractor) 
thenComparing (Function<T, U> keyExtractor, Comparator<U> keyComp)


예를 들어 학생 스트림(studentStream)을 반(ban)별, 성적(totalScore)순, 그리고 이름 (name)순으로 정렬하여 출력하려면 다음과 같이 한다.

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

반을 기준으로 Student에 정의된 Comparable 즉, 기본 정렬기준으로 정렬하고 동일한 요소들은 TotalScore로 정렬 마지막으로 이름 식이다.


만약 Student가 Comparable을 구현하지 않았다면

studentStream.sorted(Comparator.comparing(Student::getBan,(s1,s2) -> -1)
					....

이렇게 매개변수가 2개인것을 사용해 두번째 인자로 Comparator를 넘겨줘야한다.




중간연산 정리

중간연산을 잘 활용해 스트림을 적절하게 가공할수있다.

특히 sorted와 Comparator을 능숙하게 다루도록 연습하자.



최종연산


  • 스트림을 소모해서 결과물을 만든다.
  • 최종 연산의 결과는 총합 같은 단일 값 or 배열 or 컬랙션 or 옵셔널 등 연산에 따라 다양하다.

옵셔널은 npe 방어를 위한 래퍼클래스 이다.

https://onlyforus-blog.tistory.com/117?category=985817

이전에 다룬바 있다.



  • 최종연산 메서드의 매개변수는 대부분 자바가 제공하는 함수형 인터페이스를 사용함으로 람다에 대해 알아두어야한다.

https://onlyforus-blog.tistory.com/168?category=985817




img최종연산



forEach

  • 자바가 제공하는 함수형인터페이스 Consumer을 이용한다.
  • 주로 요소를 출력하는데 사용된다.
stream.forEach(s -> System.out.println(s));
stream.forEach(System.out::println);



allMatch

  • 자바가 제공하는 함수형인터페이스 Predicate를 이용한다.
  • 반환 타입은 boolean
  • 모든 요소들이 매개값(Predicate)으로 주어진 조건을 만족하는지 조사
boolean result = stream.allMatch(s -> s.getScore() <= 80); //한명이라도 80미만이면 false


anyMatch

  • 자바가 제공하는 함수형인터페이스 Predicate를 이용한다.
  • 반환 타입은 boolean
  • 최소한 한 개의 요소가 주어진 조건에 만족하는지 조사
boolean result = stream.anyMatch(s -> s.getScore() <= 80); //한명이라도 80미만이면 true


noneMatch

  • 모든 요소들이 주어진 조건을 만족하지 않는지 조사
  • 위 Match들과 같은 맥락



findFirst

  • Optional을 반환타입으로 갖는다.
  • 매개변수가 없어서 중간연산 filter와 함께 주로 사용된다.
  • 스트림의 남은 요소중 첫번째를 옵셔널로 반환
Optional<Studen> op = stream.filter(s -> s.getAge() == 10).findFirst(); //10세들중 1번째 옵셔널로 반환


findAny

  • 스트림의 남은 요소중 아무거나 옵셔널로 반환
  • 매개변수가 없어서 중간연산 filter와 함께 주로 사용된다.
Optional<Studen> op = stream.filter(s -> s.getAge() == 10).findAny(); //10세들중 아무거나 옵셔널로 반환


둘다 스트림 요소가 없을때는 비어있는 옵셔널 객체를 반환한다.



count , sum, average , max , min

  • 기본형 스트림은 이와 같은 다양한 연산을 위한 최종연산 메서드를 제공한다.
  • 사용법은 공식문서를 보자, 몇개는 Compartor로 정렬기준을 제시해야한다.
  • 하지만 보통 이 메서드 들을 사용하기 보다 reduce, collect를 이용해 이들을 구현해 사용한다.



reduce

  • 스트림 요소를 줄여나가면서 연산을 수행하고 최종 결과를 반환한다.
  • 자바가 제공하는 함수형 인터페이스 BinaryOperator 를 매개변수로 갖는다.
  • reduce는 두가지 종류가 있다.
Optional<T> reduce(BinaryOperator<T> accumulator)//처음 두 요소를 가지고 연산, 스트림이 빈경우 빈 Optional 반환

T reduce(T identity, BinaryOperator<T> accumulator)//identity와 처음 요소를 가지고 연산 , 스트림이 빈경우 identity 반환
  • 둘의 반환값이 다른걸 꼭 인지하자.



List<Integer> list = new ArrayList<>();

list.add(1);
list.add(2);

Stream<Integer> stream = list.stream();

Optional<Integer> opt = stream.reduce((a, b)->a+b ); //Integer는 int로 autoUnboxing되어 산술연산을 한다.

System.out.println(opt.get()); //3  
  • opt.get()은 NoSuchElementException를 발생시킬 위험이 있어 npe 방어 로직을 짜야하지만 여기서는 생략한다.



에초에 산술연산을 하는경우 기본형 스트림으로 만들고 반환타입이 T인 reduce를 사용해서 결과를 받는게 효과적이다.

int result = IntStream.reduce(0,(a,b)->a+b);



toArray

  • 스트림에 저장된 요소들을 T[] 타입 배열로 변환할때 사용한다.
//toArray
Integer[] itarr = Arrays.stream(arr).boxed().toArray(Integer[]::new); //toArray는 해당 참조의 생성자를 선언하지 않으면 Object[]로 반환된다


collect

  • 최종연산중 가장 유용한 메서드이다.
  • 사실 위에 모든 것들을 collect를 통해 할수있다.
  • 매개변수를 기준으로 스트림의 요소를 수집한다.
  • 매개변수는 Collector 인터페이스를 사용한다.
R collect(Collector collector) //Collector를 구현한 클래스를 매개변수로 받는다.
R collect(collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner)

매개변수 3개짜리는 Collector 인터페이스를 구현하지 않고 람다식으로 간편하게 사용할때 유용하다.



[*중요1]Collector를 매개변수로 넘겨줌으로써 Collector를 기준으로 스트림 요소를 수집해 반환한다.


  • [*중요2] 자바는 이미 Collectors라는 클래스에 static메서드로 Collector 인터페이스를 구현해 주요 기능들을 제공하고있다.

이것만 잘 활용해도 충분하다.

이 static 메서드들의 반환타입은 Collectors 이다.



Collectors에 대해 한번 알아보자



Collectors의 static methods (1) - 유용한 메서드


toList, toSet, toMap, toCollection

  • 요소를 컬랙션들 또는 배열로 수집한다.
//toList
List<String> list = stream.map(student->student.getName()).collect(Collectors.toList); 

//toMap
Map<String,Student> map = stream.collect(Collectors.toMap(s->s.getName, s -> s)); //map은 key와 value를 지정해줘야한다.


couting, summingInt, averageInt, maxBy, minBy

  • 요소들을 가지고 연산할때 사용한다.
int score = stream.collect(Collectors.summingInt(student -> student.getScore()));

summingDouble 등 다양한 형태가있다. average도 마찮가지



joining

  • 문자열 요소를 갖는 스트림에서 사용할수있다.
joining(CharSequence delimiter) //구분자joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix) // 접미사 접두사 구분자
String str = stream.map(student -> student.getName()).collect(Collectors.joining("/" , "[" , "]" )) // [김일영/홍길동/박장군/김군]


Collectors의 static methods (2) -그룹화와 분할

  • collect를 좀더 유용하게 알아보자

  • groupingBy: 스트림의 요소를 특정 기준으로 그룹화 한다.

    • 기준을 의미하는, 함수형 인터페이스 Function을 매개변수로 갖는다.
  • partitioningBy: 스트림의 요소를 두 가지로 분할한다. (조건에 만족하는 그룹, 조건에 만족하지 않는 그룹)

    • 조건을 의미하는, 함수형 인터페이스 Predicate를 매개변수로 갖는다.



  • Collectors.groupingBy & Collectors.partitioningBy 메서드 살펴보기
//Collectors.groupingBy
Collector groupingBy(Function classifier) //Function을 기준으로 스트림의 요소를 분할
Collector groupingBy(Function classifier, Collector downstream) //Collector 인터페이스는 요소가 수집되는 모습을 정의
Collector groupingBy(Function classifier, Supplier mapFactory, Collector downstream)  
    
//Collectors.partitioningBy
Collector partitioningBy(Predicate predicate)
Collector partitioningBy(Predicate predicate, Collector downstream) 

스트림을 2개로 나눠야 할때는 partitioningBy가 효율적이고 나머지 경우는 groupingBy를 쓰면 된다.

그룹화와 분할의 결과는 모두 Map에 담긴다.

인자가 1개인 메서드를 사용하면 둘다 기본적으로 List를 value에 담는다.



  • partitioningBy
//매개변수 1개:  Map에 collect 되는 value가 List임을 주목
Map<Boolean, List<Student>> studentBySex = Stream.of(studentArr)
            .collect(Collectors.partitioningBy(Student::isMale)); // 학우들을 성별로 분류

List<Student> maleStudent   = studentBySex.get(true);   // Map에서 남학우 목록 얻기
List<Student> femaleStudent = studentBySex.get(false);  // Map에서 여학우 목록 얻기

//매개변수 2개: Map에 collect 되는 value가 long임에 주목 
Map<Boolean, Long> studentNumberBySex = Stream.of(studentArr)
.collect(Collectors.partitioningBy(Student::isMale, Collectors.counting())); 

long countMale   = studentNumberBySex.get(true);  // 8 : 남학우 수
long countFemale = studentNumberBySex.get(false); // 6 : 여학우 수

//응용
Map<Boolean, Long> scoreSumBySex = Stream.of(studentArr).collect(Collectors.partitioningBy(Student::isMale,Collectors.summingLong(student::getScore())));

long scoreSumMale = scoreSumBySex.get(true);    - 1450 : 남학우 점수 총점
long scoreSumFemale = scoreSumBySex.get(false); - 1240 : 여학우 점수 총점
    
//이중분할: 2번째 인자에 partitioningBy를 한번더 적용해서 2중분할도 가능하다.  //반환값: Collector이기 때문 
Map<Boolean, Map<Boolean, List<Student>>> failedStudentBySex = Stream.of(studentArr).collect(Collectors.partitioningBy(Student::isMale,Collectors.partitioningBy(student -> student.getScore() <= 100)));

List<Student> failedMaleStudents   = failedStudentBySex.get(true).get(false);



  • groupingBy
//매개변수 1개: 2번째 인자없으면 기본적으로 List<T>로 value 담음Map<Integer, List<Student>> studentByGroupSet = Stream.of(studentArr).collect(Collectors.groupingBy(Student::getBan));//매개변수 2개: toList, toSet , toCollection 등등 가능Map<Integer, Set<Student>> studentByGroupSet = Stream.of(studentArr).collect(Collectors.groupingBy(Student::getBan, Collectors.toSet()));//다수준 그룹화: key: HIGH MIDDLE LOW / value: countingMap<Student.Level, Long> studentCountByLevel = Stream.of(studentArr)	.collect(Collectors.groupingBy(student -> {             if(student.getScore() >= 250) return Student.Level.HIGH;        else if(student.getScore() >= 150) return Student.Level.MIDDLE;        else                               return Student.Level.LOW;    }, Collectors.counting()));



Collector 인터페이스 직접 구현하기

  • 그동안 Collectors 클래스가 Collector를 구현해 제공하는 static method 들을 사용해서 collect를 사용했지만
  • 직접 Collector인터페이스를 구현해서 만들수도있다.




스트림의 변환


 

img




스트림의 단점


디버그가 힘들다.

한줄로 모두 동작하기때문에 stream 중간에 stop point를 지정할수가없다.



스트림은 일회용이라 재활용이 불가능하다.


코드를 최적으로 짠경우 for문이 stream보다 빠르다.

다만, 코드를 최적으로 짜는 경우가 드물기 때문에 일반적으로 stream이 더 빠르다.



정리


자바가 제공하는 함수형인터페이스, 람다, Comparator 인터페이스 등을 잘 이해하고있으면 이해하는데는 어렵지않다.

하지만 워낙 많은 종류의 라이브러리에 따른 반환타입,정제과정 등등은 능숙하게 사용하기 어렵다.

효과적으로 사용하기엔 시간이 걸릴듯...



데이터 군집을 다뤄야할때 스트림으로 다뤄보려는 연습을 하면서 숙련도를 천천히 쌓아야 할것같다.

 

 

728x90