스트림 API는 다량의 데이터 처리 작업(순차적/병렬적 모두)을 돕고자 자바 8에서 추가되었다.
이 API가 제공하는 추상 개념의 핵심은 다음의 2가지다.
스트림의 원소들을 컬렉션
, 배열
, 파일
, 정규표현식 패턴 매처(matcher)
, 난수 생성기
, 다른 스트림
등 어디로부터든 올 수 있다.
스트림 안의 데이터 원소들은 객체 참조
나 기본 타입 중 int
, long
, double
의 값이다.
스트림 파이프라인은 다음과 같이 구성된다.
소스 스트림 → 0개 이상의 중간 연산(intermediate operation) → 종단 연산(terminal operation)
스트림 API는 메서드 연쇄를 지원하는 플루언트 API(fluent API)
다.
즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다.
또한 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
순차적
으로 수행된다.parallel
메서드를 호출해주기만 하면 된다. 그러나 효과를 볼 수 있는 상황은 많지 않다 (아이템 48).반복 코드를 스트림으로 바꾸는 게 가능하더라도 모든 반복문을 스트림으로 바꾸지는 않는 것이 좋다.
스트림을 사용했을 때 코드 가독성과 유지보수 측면에서 손해를 볼 수 있기 때문이다.
중간 정도 복잡한 작업에도 스트림과 반복문을 적절히 조합하는 게 최선이다.
따라서 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하자.
스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메서드 참조)
로 표현한다.
반면 반복 코드에서는 코드 블록
을 사용해 표현한다.
함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들은 다음과 같다.
지역 변수의 수정
코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
반면, 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는건 불가능하다.
종료 혹은 건너뛰기 (return, break, continue), 예외
코드 블록에서는 return 문을 사용해 메서드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다. 또한 메서드 선언에 명시된 검사 예외를 던질 수 있다.
반면, 람다로는 이 중 어떤 것도 할 수 없다.
스트림을 적절히 활용하면 깔끔하고 명료해지지만, 너무 과하게 사용하는 경우 코드를 읽기 어렵고 유지보수하기 어려워진다.
// 스트림을 과하게 사용한 경우
public class StreamAnagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(
groupingBy(word -> word.chars().sorted()
.collect(StringBuilder::new,
(sb, c) -> sb.append((char) c),
StringBuilder::append).toString()))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
}
스트림을 적절히 사용하면 깔끔하고 명확하게 표현할 수 있다.
// 스트림을 적절히 활용한 경우
public class HybridAnagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try (Stream<String> words = Files.lines(dictionary)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.forEach(g -> System.out.println(g.size() + ": " + g));
}
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
도우미 메서드를 적절히 활용하는 것은 일반 반복 코드에서보다 스트림 파이프라인에서 훨씬 중요하다.
파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문에, 람다에서 세부 구현을 주 프로그램 로직 밖으로 빼내는 것은 전체적인 가독성을 높여준다.
자바에서는 char용 스트림을 지원하지 않는다.
예를 들어, 다음과 같은 코드를 실행하면 721011081081113211911111410810033 라는 값이 출력된다.
이것은 "Hello world!".chars()가 반환하는 스트림의 원소가 int 값이기 때문이다.
명시적 형변환을 하면 제대로 출력이 되기는 하지만 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
"Hello world!".chars().forEach(System.out::print);
스트림으로 처리하기 어려운 일 중 대표적인 것이 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기 어려운 경우다.
스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값을 읽는 구조이기 때문이다.
가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법을 사용하자.
다음은 처음 20개의 메르센 소수(Mersenne prime)을 출력하는 예제다.
메르센 소수를 계산하기 전 지수(p)를 종단 연산에서 출력하기 위해, 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해주었다.
public class MersennePrimes {
static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
}
}
📌 핵심 정리
스트림
을 사용해야 멋지게 처리할 수 있는 일이 있고,반복
방식이 더 알맞는 일이 있다.
그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다.
어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다.
어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다.
스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.