남궁성 저자의 Java의 정석 3판 스터디
스트림이란 ?
많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고,
for 문이나 Iterator 를 이용해 코드를 작성했었다.
하지만 이런 방식의 코드는 너무 길고 가독성이 떨어진다. (재사용성도)
또 다른 문제는 데이터 소스마다 다른 방식으로 다뤄야한다는 것이다.
예를들어 List 를 정렬할 때는 Collections.sort() 를 사용해야 하고,
배열을 정렬할 때는 Arrays.sort() 를 사용해야 한다.
이런 문제점들을 해결하기 위해서 만든 것이 스트림(stream)
이다 !
스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메소드들을 정의해놓았다.
데이터 소스가 무엇이던 같은 방식으로 다룰 수 있게 되었고, 코드의 재사용성이 높아진다 !
예를 들어 문자열 배열과 같은 내용의 문자열을 저장하는 List 가 있을 때,
String[] strArr = {"aaa", "bbb", "ccc"};
List<String> strList = Arrays.asList(strArr);
이 두 데이터 소스를 기반으로 하는 스트림은 다음과 같이 생성한다.
Stream<String> strStream1 = strList.stream(); // 스트림 생성
Stream<String> strStream2 = Arrays.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 처럼 일회용이다.
한번 사용하면 닫혀서 다시 사용할 수 없다. 필요하면 스트림을 다시 생성해서 사용하자.
스트림은 작업을 내부 반복으로 처리한다.
forEach()는 스트림에 정의된 메소드 중의 하나로 매개변수에 대입된 람다식을
데이터 소스의 모든 요소에 적용한다.
즉, forEach()는 메소드 안으로 for문을 넣은 것이다.
void forEach(Consumer<? super T> action) {
Objects.requireNonNull(action); // 매개변수의 null 체크
for(T t : src) {
action.accept(T);
}
}
스트림이 제공하는 다양한 연산을 이용해서 복잡한 작업들을 간단히 처리할 수 있다.
스트림이 제공하는 연산은 중간 연산과 최종 연산이 있다.
- 중간 연산 : 연산결과가 스트림. 스트림에 연속해서 중간 연산 가능
- 최종 연산 : 연산결과가 스트림이 아닌 연산. 스트림의 요소를 소모하므로 딱 한번 가능
아래는 그 예시다.
String[] strArr = { "aa", "bbb", "cc", "ddd" };
Stream<String> stream = Stream.of(strArr); //문자열 배일이 소스인 스트림
Stream<String> filteredStream = stream.filter(); //걸러내기 (중간연산)
Stream<String> distinctedStream = stream.distinct(); //중복제거 (중간연산)
Stream<String> sortedStream = stream.sort(); //정렬 (중간연산)
Stream<String> limitedStream = stream.limit(5); //stream 자르기 (중간연산)
int total = stream.count(); //요소 개수 세기 (최종연산)
Stream 에 정의된 연산들 중에 핵심 연산 몇 가지를 나열해보았다.
//중간연산
Stream<T> distinct() //중복 제거
Stream<T> filter(Predicate<T> predicate) //조건에 안 맞는 요소 제외
Stream<T> sorted() //스트림 요소 정렬
Stream<T> sorted(Comparator<T> comoparator) //스트림 요소 정렬
Stream<T> map(Function<T,R> mapper) //스트림의 요소 변환
Stream<T> flatMap(Function<T,Stream<R>> mapper) //스트림의 요소 변환
//최종연산
Optional<T> reduce(BinaryOperator<T> accumulator) // 스트림의 요소를 하나씩 줄여가면서 계산
R collect(Collector<T,A,R> collector) //스트림의 요소 수집
중간연산
하나씩 사용법을 간단히 살펴보자.
distinct()
는 아래처럼 사용하면 된다.
IntStream intStream = IntStream.of(1,2,2,3,3,3,4,5,5,6);
intStream.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).filter(i -> i%3 != 0).forEach(System.out::print);
스트림을 정렬할 때는 sorted()
를 사용하면 된다.
sorted()
는 지정된 Comparator 로 스트림을 정렬하는데
Comparator 대신 int 값을 반환하는 람다식을 사용하는 것도 가능하다.
Comparator 인터페이스에서 제공하는 메소드들을 사용하면 정렬이 쉬워지는데,
이 메소드들은 Comparator 를 공부하면서 다시 알아보자 😊
Stream<String> strStream = Stream.of("aaa", "bbb", "ccc");
strStream.sorted().forEach(System.out::print);
스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나
특정 형태로 변환해야 할 때가 있다.
이 때 map()
을 사용하면 된다 !
매개변수로 T 타입을 R 타입으로 변환해서 반환하는 함수를 지정해야 한다.
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() 으로 Stream<File> 을 Stream<String> 으로 변환
Stream<String> fileNameStream = fileStream.map(File::getName);
fileStream.forEach(System.out::println); //스트림의 모든 파일 이름 출력
map()
도 하나의 스트림에 여러 번 적용할 수 있다.
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); //JAVABAKTXT
스트림의 타입이 Stream<T[]> 인 것을 Stream 로 바꾸고 싶을 때는 flatMap()
을 사용하면 된다.
Stream<String[]> strArrStrm = Stream.of(
new String[] { "abc", "def", "ghi" },
new String[] { "ABC", "DEF", "GHI" }
);
// 각 요소의 문자열들을 합쳐서 문자열이 요소인 Stream<String> 으로 만들어보자.
Stream<String> strStrm = strArrStrm.flatMap(Arrays::stream);
최종연산
중간연산에 대해 알아봤으니, 스트림의 최종연산 중에서 몇가지를 살펴보자.
최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다.
최종 연산 후에는 스트림이 닫히게 되고 더 이상 사용할 수 없다.
먼저 위에서 많이 보였던 forEach()
인데, 예제를 통해 많이 사용해봤으니 생략 !
void forEach(Consumer<? super T> action)
스트림의 요소에 대해 지정된 조건을 검사할 수 있다.
이 메소드들은 모두 매개변수로 Predicate 를 요구하고, 결과로 boolean 을 반환한다.
boolean allMatch(Predicate<? super T> predicate)
boolean anyMatch(Predicate<? super T> predicate)
boolean noneMatch(Predicate<? super T> predicate)
예를 들어 학생들의 성적 정보 스트림 studStream 에서
총점이 100 이하인 학생이 있는지 확인하는 방법은 다음과 같다.
boolean noFailed = studStream.anyMatch(s -> s.getTotalScore() <= 100)
이외에도 조건에 일치하는 첫 번째 것을 반환하는 findFirst()
가 있는데
주로 filter()
와 함께 사용된다.
병렬스트림인 경우에는 findFirst()
대신 findAny()
를 사용해야 한다.
Optional<Student> stud = studStream.filter(s -> s.getTotalScore() <= 100).findFirst();
Optional<Student> stud = parallelStream.filter(s -> s.getTotalScore() <= 100).findAny();
IntStream 과 같은 기본형 스트림에는 스트림 요소들에 대한 통계 정보를 얻을 수 있는 메소드들이 있다.
아래 3가지 밖에 없다.
long count()
Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)
대부분은 위 메소드를 쓰기 보다는 기본형 스트림으로 변환하거나,
reduce()
와 collect()
를 사용해서 통계 정보를 얻는다 !
reduce()
는 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환한다.
매개변수의 타입은 BinaryOperator 이다.
처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다.
이 과정에서 스트림의 요소를 하나씩 소모하고, 모든 요소를 소모하면 결과를 반환한다.
Optional<T> reduce(BinaryOperator<T> accumulator)
BinaryOperator 는 BiFunction 의 자손 !
BiFunction<T,T,T> 와 동등하다
이외에도 연산결과의 초기값(identity)을 가지는 reduce()
도 있는데,
초기값과 스트림의 첫 요소로 연산을 시작한다.
스트림의 요소가 하나도 없으면 초기값이 반환되기 때문에 반환타입은 Optional 가 아니라 T 이다.
T reduce(T identity, BinaryOperator<T> accumulator)
U reduce(U identity, BiFunction<U,T,U> accumulator, BinaryOperator<U> combiner)
두 번째 메소드의 마지막 매개변수 combiner 는 병렬 스트림에 의해 처리된 결과를 합칠 때 사용 !
위에서 소개한 count()
등은 내부적으로 모두 reduce()
를 이용해서 아래와 같이 작성된 것 !
int count = intStream.reduce(0, (a,b) -> a + 1); // count()
int sum = intStream.reduce(0, (a,b) -> a + b); //sum()
int max = intStream.reduce(Integer.MIN_VALUE, (a,b) -> a > b ? a : b); //max()
int min = intStream.reduce(Integer.MAX_VALUE, (a,b) -> a < b ? a : b); //min()
max()
와 min()
의 경우 초기값이 필요없기 때문에
Optional 를 반환하는 reduce()
를 사용하는 것이 낫다.
단 intStream 의 타입이 IntStream 인 경우 OptionalInt 를 사용해야 함.
IntStream 에 정의된 reduce() 의 반환타입이 OptionalInt !
다음은 복잡하면서도 유용한 collect()
이다.
collect()
가 스트림의 요소를 수집하려면 어떻게 수집할 건지에 대한 방법이 있어야 하는데,
이 방법을 정의한 것이 바로 collector
이다.
collect() // 스트림의 최종연산, 매개변수로 컬렉터를 필요로 함
Collector // 인터페이스, 컬릭터는 이 인터페이스를 구현해야 함
Collectors// 클래스, static 메소드로 미리 작성된 컬렉터를 제공
collect()
는 매개변수의 타입이 Collector 이다.
collect()
는 이 객체에 구현된 방법대로 스트림의 요소들을 수집한다.
Object collect(Collector collector)
스트림의 모든 요소를 컬렉션에 수집하려면,
Collectors 클래스의 toList()
와 같은 메소드를 사용하면 된다.
List 나 Set 이 아닌 특정 컬렉션을 지정하려면,
toCollection()
에 해당 컬렉션의 생성자 참조를 매개변수로 넣어주면 된다 !
List<String> names = studStream.map(Student::getName)
.collect(Collectors.toList());
ArrayList<String> list = names.stream()
.collect(Collectors.toCollection(ArrayList::new));
Map<String, Person> map = personStream()
.collect(Collectors.toMap(p -> p.getRegId(), p -> p));
지연된 연산
스트림 연산은 최종 연산이 수행되기 전까지는 중간 연산이 수행되지 않는다 ‼️
distinct() 나 sort() 같은 중간 연산을 호출해도 즉각적인 연산이 수행되지는 않는다.
중간 연산은 단지 어떤 작업이 수행되어야 하는지를 지정해주는 것이고,
최종 연산이 수행되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다 !
병렬 스트림
스트림으로 데이터를 다룰 때의 장점 중 하나가 바로 병렬 처리가 쉽다는 것 !
스트림에 parallel()
이라는 메서드를 호출하면 병렬로 연산이 수행된다.
반대로, 병렬로 처리되지 않게 하려면 sequential()
을 호출하면 된다.
기본적으로 모든 스트림은 병렬 스트림이 아니므로 sequential()
을 호출할 필요가 없다.
parallel()
을 취소할 때만 사용한다.
int sum = strStream.parallel() //strStream을 병렬 스트림으로 전환
.mapToInt(s -> s.length())
.sum();
스트림 만들기
Collection
에 stream()
이 정의되어 있다.
Collection 의 자손인 List 와 Set 을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림 생성 가능 !
Stream<T> Collection.stream()
예를 들어 List 에서 스트림을 생성하는 코드를 보자.
List<Integer> list = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream();
intStream.forEach(System.out::println); //스트림의 모든 요소 출력
람다식
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 값으로 해서 계산을 반복한다.
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>
이므로
매개변수가 없는 람다식만 허용된다 !
이렇게 생성된 스트림은 기본형 스트림 타입의 참조변수로 다룰 수 없다.
필요하다면 mapToInt()
와 같은 메소드로 변환을 해야 한다.