class Clazz<T> {
private Collection<T> collection;
public Clazz(Collection<T> collection) {
this.collection = collection;
}
public Clazz<T> method() {
Collection<T> newCollection = new Collection<>();
..
return new Clazz<T>(newCollection);
}
}
Stream이란 단어 그대로 흐르는 물처럼
함수형 인터페이스를 매개변수로 받는 다양한 기능을
메서드 체이닝 방식을 활용하여 연쇄적으로 기능을 적용할 수 있는 객체를 의미한다.
각 메서드가 반환 값으로 데이터를 포함한 새로운 Stream 객체를 반환하기 때문에
메서드 체이닝 방식을 사용할 수 있다.
new Clazz(parameter)
new Clazz(parameter1, parameter2)
객체를 생성할 때에는 보통 생성자를 사용하여 객체를 생성하게 된다.
하지만 생성자가 여러 개가 있는 경우, 각 생성자의 의미를 이름으로 부여할 수 없기 때문에
각 생성자가 무엇을 어떻게 생성하는지 쉽게 구분할 수 없다.
class Clazz {
private Clazz() { };
// ** 정적 팩토리 메서드 **
public static Clazz createClazz() {
return new Clazz();
}
}
정적 팩토리 메서드는 생성자를 private로 선언하여 외부에서 사용하지 못하도록 막고,
객체를 생성하는 메서드에 static 키워드를 선언하여 사용하는 방법이다.
메서드에는 이름을 붙일 수 있기 때문에 정적 팩토리 메서드를 사용하면 가독성을 높일 수 있다.
또한 클래스 내부에서 객체를 생성하여 이미 사용하고 있는 객체를 반환할 수 있어
메모리를 절약할 수 있고 인스턴스 생성 없이 접근할 수 있다.
스트림은 대부분 정적 팩토리 메서드를 통해서 객체를 생성한다.
자바는 Stream API라는 이름으로 Stream 관련 기능을 제공한다.
Stream은 일반적인 객체와 다른 몇 가지 특징을 가지고 있다.
List<T> list = List.of(1, 2, 3);
list.stream(). .. // 데이터 Stream 생성 후 연산 적용
System.out.println(list); // ** 원본 데이터 불변, [1, 2, 3] **
Stream에 적용한 연산은 원본 데이터에 영향을 미치지 않고 새로운 결과를 생성한다.
Stream 연산 적용 전과 후의 원본 데이터는 변경되지 않는다.
Stream.of. .. // 연산 1회 실행
Stream.of. .. // ** 추가 연산 실행, 예외 발생 **
Stream은 1회만 사용 가능하여 한번 사용된 Stream은 다시 사용할 수 없다.
다시 연산을 적용하고 싶은 경우 새롭게 Stream을 생성해야 한다.
Stream을 새로 생성하지 않고 새롭게 연산을 수행하는 경우 예외가 발생한다.
List<Integer> list = List.of(1, 2, 3);
list.stream().method1().method2().toList();
// Stream
1 → method1 → method2 → new List add() // 데이터 하나씩 모든 메서드 적용
2 → method1 → method2 → new List add()
3 → method1 → method2 → new List add()
// Java
1 → method1, 2 → method1, 3 → method1 // 모든 데이터에 메서드 하나씩 적용
1 → method2, 2 → method2, 3 → method2
1 → new List add, 2 → new List add, 3 → new List add
파이프라인 처리 방식이란 데이터 별로 모든 메서드 단계를 거친 후에
다음 데이터를 메서드를 적용하는 방식을 의미한다.
Stream은 파이프라인 처리 방식을 사용한다.
모든 데이터를 순차적으로 메서드에 하나씩 적용하는 Java의 일괄 처리 방식과 대조된다.
List<Integer> list = List.of(1, 2, 3);
// 중간 연산 : filter
// 최종 연산 : toList
list.stream().filter(); // ** filter 메서드를 수행하지 않음 **
list.stream().filter().toList(); // ** filter 메서드 수행 **
Stream API 기능은 중간 연산과 최종 연산으로 구분된다.
Stream 데이터는 최종 연산이 실행되기 전까지 중간 연산을 수행하지 않는다.
최종 연산이 실행될 때까지 연산을 미룬다고 해서 지연 연산이라고 부른다.
Java의 즉시 연산은 중간 계산을 저장하기 위해 별도의 자료 구조가 필요하지만,
지연 연산은 즉시 결과를 컬렉션에 저장하기 때문에 메모리를 효율적으로 사용할 수 있다.
List<Integer> list = List.of(1, 2, 3);
// findFirst : 첫번째 데이터를 반환하는 메서드
// Stream
list.stream().filter().findFirst(); // ** 첫번째 데이터를 찾는 즉시 연산 종료 **
// Java
list.filter().findFirst(); // ** filter의 연산을 모두 수행한 후 findFirst 호출 **
단축 평가란 조건에 만족하는 결과를 찾으면 연산을 진행하지 않는 방식이다.
지연 연산으로 이전에 호출한 메서드를 먼저 호출하지 않고,
파이프라인 방식으로 데이터를 하나씩 연산에 적용하기 때문에
단축 평가 같은 연산에서 불필요한 연산을 생략하는 최적화가 가능하다.
예를 들어 Stream에서 첫번째 데이터를 반환하는 findFisrt() 최종 연산을 호출한 경우,
데이터 1개 획득 시 최종 연산의 조건이 만족했기 때문에
filter() 연산을 끝까지 수행하지 않고 바로 연산이 종료되어 성능을 최적화한다.
하지만 Java의 첫번째 데이터 1개를 획득하는 메서드를 호출하더라도
이전에 먼저 호출된 메서드를 모두 수행한 이후에 호출되어
filter() 메서드를 모두 수행한 이후에 연산이 종료되어 불필요한 연산이 발생한다.
Stream API의 Stream 생성, 중간 연산, 최종 연산 기능을 살펴보자.
// 컬렉션
Stream<T> stream = Collection.stream();
// 배열
Stream<T> stream = Arrays.stream(array);
// 직접 입력
Stream<T> stream = Stream.of(data);
Stream 생성은 컬렉션과 배열로 구분하여 생성할 수 있고
정적 팩토리 메서드인 of() 메서드 호출을 통해서도 생성할 수 있다.
// iterate : 초기 값을 설정하고 값을 생성하는 UnaryOperator 전달
Stream<T> stream = Stream.iterate(T startValue, UnaryOperator<T> unaryOperator);
// generate : 초기값 없이 값을 생성하는 Supplier<T> 전달
Stream<T> stream = Stream.generate(Supplier<T> supplier);
// 사용 시, 무한으로 생성하는 데이터의 제약을 위한 limit() 메서드 필수 사용
stream.limit(n)
정해진 컬렉션이나 배열이 아닌 무한으로 생성하는 Stream도 선언할 수 있다.
초기값 설정이 필요한 경우 iterate() 메서드를,
초기값 없이 값을 생성하는 경우 generate()를 사용하고
사용 시에는 무한으로 생성하는 데이터의 제약을 위해 limit() 메서드와 함께 사용해야 한다.
// filter : 조건에 맞는 데이터만 추출
collection.stream().filter(Predicate<T> predicate);
// map : 데이터를 다른 형태로 변환
collection.stream().map(Function<T, R> function)
// flatMap : 중첩 구조의 Stream을 단일 구조의 Stream으로 변환
// List.of(List.of(1), List.of(2), List.of(3)) → List.of(1, 2, 3)
// map을 사용하여 Stream으로 변경하는 경우, Stream 이중 구조 발생
collection.stream().flatMap(Function<T, Stream<R>> function)
// distinct : 중복 데이터 제거
collection.stream().distinct();
// sorted : 오름차순 정렬, Comparator로 정렬 기준 설정 가능
collection.stream().sorted();
collection.stream().sorted(Comparator.method());
// peek : Side effect 없이 디버깅, 로깅 용으로 사용 권장, 데이터 변경 지양
// peek(System.out::println)
collection.stream().peek(Consumer<T> consumer);
// limit : 앞에서 n개의 데이터만 추출
collection.stream().limit(n);
// skip : 앞에서 n개의 데이터를 건너뛰고 추출
collection.stream().skip(n);
// takeWhile, dropWhile은 Java 9부터 사용 가능
// 조건을 만족하지 않다가 뒤에서 조건을 다시 만족하더라도 무시됨, 정렬된 Stream에서 유용
// takeWhile : 조건을 만족하는 동안 데이터 추출
collection.stream().takeWhile(Predicate<T> predicate)
// dropWhile : 조건을 만족하는 동안 데이터를 건너뛰고 추출
collection.stream().dropWhile(Predicate<T> predicate)
// collect : Collectors를 사용하여 결과 수집
Collection.stream().collect(Collectors.method());
// toList : 불변 리스트로 수집
Collection.stream().toList();
// toArray : 배열로 수집
Collection.stream().toArray(ArrayType[]::new);
// forEach : 반환 값 없이 각 데이터에 대해 동작 수행
Collection.stream().forEach(Consumer<T> consumer);
// count : 데이터 개수 반환
long count = Collection.stream().count();
// reduce : 누적 함수를 사용하여 모든 데이터를 하나의 결과로 합치기
// 초깃값이 없으면 결과가 없을 수 있기 때문에 Optional로 반환
Collection.stream().reduce(startValue, BinaryOperator<T> binaryOperator);
// min, max : 최솟값, 최댓값을 Optional로 반환
Optional<T> result = Collection.stream().min(Comparator<T> comparator);
// findFirst : 조건에 맞는 첫 번째 데이터 Optional로 반환
Optional<T> result = Collection.stream().findFirst();
// findAny : 조건에 맞는 아무 데이터나 Optional로 반환
Optional<T> result = Collection.stream().findAny();
// anyMatch : 하나의 데이터라도 조건을 만족하는지 여부
boolean result = Collection.stream().anyMatch(Predicate<T> predicate)
// allMatch : 모든 데이터가 조건을 만족하는지 여부
boolean result = Collection.stream().allMatch(Predicate<T> predicate)
// noneMatch : 하나도 조건을 만족하지 않는지 여부
boolean result = Collection.stream().noneMatch(Predicate<T> predicate)
최종 연산의 collect() 메서드를 호출하면
Collectors를 사용하여 다양한 형태로 결과를 수집할 수 있다.
Collectors는 사용 시 static import를 사용하여 깔끔하게 작성할 수 있다.
Collectors 객체가 가지는 기능에 대해 살펴보자.
// List 수집
// toList : 수정 가능, toUnbodifiableList : 수정 불가능
List<T> list = Stream.of().collect(Collectors.toList());
List<T> unmodifiableList = Stream.of().collect(Collectors.toUnmodifiableListT());
unmodifiableList.add(value); // ** 예외 발생 **
// Set 수집
// toSet : Set 반환, toCollection : 특정 Set 반환
Set<T> set = Stream.of().collect(Collectors.toSet());
Set<T> hashSet = Stream.of().collect(Collectors.toCollection(HashSet::new));
// Map 수집
// toMap : Map 반환
// key 값(필수), value 값(필수), key 값 중복 시 처리 로직, Map 타입 지정
// key 값 중복 시 처리 로직이 없을 때, 값이 중복되는 경우 예외 발생
LinkedHashMap<T1, T2> map = Stream.of().collect(Collectors.toMap(
key -> keyData,
value -> valueData,
(duplicatedValue1, duplicatedValue2) -> IntegratedValue,
LinkedHashMap::new));
// groupingBy : 기준 함수에 따라 분류
// partitioningBy : true, false 두 그룹으로 분류
// ** down Stream Method 지정 가능 **
Map<T, List<R>> groupingBy =
Stream.of().collect(Collectors.groupingBy(Function<T, R> function));
Map<Boolean, List<R>> partitioningBy =
Stream.of().collect(Collectors.partitioningBy(Predicate<R> predicate));
// maxBy, minBy : 최댓값, 최솟값
Optional<T> max = Stream.of().collect(Collectors.maxBy(Comparator<T> comparator));
// counting : 개수
long count = Stream.of().collect(Collectors.counting());
// averagingInt : 값을 int로 변환 후 평균 구하기
double average =
Stream.of().collect(Collectors.averagingInt(ToIntFunction<T> function));
// summarizingInt : 통계 정보를 가진 IntSummaryStatistics 반환
IntSummaryStatistics iss =
Stream.of().collect(Collectors.summarizingInt(ToIntFunction<T> function));
// reducing : 누적 연산
// Stream API에 reduce() 메서드가 있기 때문에 주로 다운 스트림에 활용
T result = Stream.of().collect(Collectors.reducing(BinaryOperator<T> binaryOperator);
// joining : 문자열 전용 기능, 구분자로 분리하여 문자열 합치기
String result = Stream.of().collect(Collectors.joining(delimiter));
Collectors 객체에서 groupingBy(), partitioningBy()로 그룹화 기능을 사용할 수 있다.
다운 스트림 컬렉터는 그룹화 이후 추가적인 연산을 정의하는 기능이다.
두 메서드의 두 번째 인자에 Collectors의 메서드를 추가로 전달하여 사용한다.
다운 스트림 컬렉터를 정의하지 않으면 기본적으로
Collectors.toList() 메서드가 호출되어 그룹별 데이터를 List로 반환한다.
// toList : 그룹별 데이터를 List로 반환
Map<T, List<R>> toList = Stream.of().collect(Collectors.groupingBy(
Function<T, R> function,
Collectors.toList()));
// mapping : 각 데이터를 다른 값으로 변환하고 다른 Collecotr로 수집
Map<R1, List<R2>> mapping = Stream.of().collect(Collectors.groupingBy(
Function<T, R1> function,
Collectors.mapping(Function<R1, R2> function,
Collectors.toList())));
// collectiongAndThen : 결과 그룹 데이터 후처리
Map<R1, R2> mapping = Stream.of().collect(Collectors.groupingBy(
Function<T, R1> function,
Collectors.collectingAndThen(
Collector<T, ?, U> collector,
Function<U, R2> function)));
// counting : 개수
Map<T, Long> counting = Stream.of().collect(Collectors.groupingBy(
Function<T, R> function,
Collectors.counting()));
// ** 기본형 특화 Stream : IntStream, LongStream, DoubleStream **
// ** 생성 **
IntStream.of(data);
IntStream.range(startValue, endValue); // endValue -1 까지 생성
IntStream.range(StartValue, endValue); // endValue 까지 생성
IntStream intStream = mapToInt(ToIntFunction<T> toIntFunction);
// sum : 합계
int sum = IntStream.of().sum();
// average : 평균, OptionalDobule을 반환하여 getAsDouble() 메서드를 호출하여 값 추출
double average = IntStream.of().average().getAsDouble();
// min, max : 최솟값, 최댓값
OptionalInt min = IntStream.of().min();
// count : 개수
long count = IntStream.of().count();
// summaryStatistics : 최솟값, 최댓값, 합계, 개수, 평균 등이 담긴 IntSummaryStatistics 객체 반환
IntSummaryStatistics iss = IntStream.of().summaryStatistics();
OptionalInt min = iss.getMin();
OptionalInt max = iss.getMax();
long sum = iss.getSum();
long count = iss.getCount();
double average = iss.getAverage();
// mapToObj : 객체 스트림(참조형) 변환
IntStream.of().mapToObj(IntFunction<R> intFunction);
// boxed : 박싱 객체 스트림 변환
IntStream.of().boxed();
for문으로 직접 제어 구조를 작성하는 것이 성능이 가장 빠르다.
for문과 Stream을 비교하면 for문의 성능이 약 2배 빠르다.
Stream의 경우 박싱 및 언박싱 비용이 필요하기 때문이다.
for문과 기본형 특화 Stream을 비교하면 for문의 성능이 약 20% 우수하다.
대규모 데이터 처리, 반복 횟수가 많은 경우, 박싱 및 언박싱이 자주 필요한 경우를 제외하면
세 가지 경우의 수에 대한 성능의 차이는 거의 없기 때문에
가독성과 유지 보수 차원에서 Stream API를 사용하는 것이 권장된다.
풋살 하러 가야지
람다와 람다를 활용한 Stream을 배우고 나서는
일반적인 지식을 습득했다는 느낌도 있지만 코딩 스타일,
코드를 표현하는 철학적인 부분을 더 성찰하게 되는 학습이었던 것 같다.
왜 람다와 스트림을 사용하게 되었을지에 대한 생각,
가독성이 좋은 코드를 작성하는 것에 대한 중요성과
이를 현실로 만들어낸 기술에 입이 벌어지게 된다.
람다와 Stream을 능숙하게 사용한다는 것은 아주 큰 무기가 될 것이라 생각한다.
Stream의 가지고 있는 특성을 이해하고
코드를 작성할 때 항상 의식적으로 람다와 Stream의 존재를 떠올리고 활용해보도록 하자.