1. 스트림 API
다량의 데이터 처리 작업을 돕고자 자바 8에 스트림 API 가 추가되었다. 스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처 등 어디로부터든 올 수 있고, 타입은 객체 참조나 int, long, double 세 기본 타입을 지원한다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream() // 스트림 생성
.filter(name -> name.startsWith("A")) // 'A'로 시작하는 요소 필터링
.forEach(System.out::println); // 출력
스트림 파이프라인은 소스 스트림 → 중간 연산 (변환) → 종단 연산으로 끝난다.

중간 연산인 변환 과정에서, 예를 들어 각 원소의 특정 조건을 만족 못하는 원소를 걸러낼 수 있다. 그리고, A 스트림에서 B 스트림으로 변환할 때에 A, B 스트림의 원소 타입이 같을 수도 있고, 다를 수도 있다. 종단 연산은 중간 연산이 내놓은 스트림에 최후의 연산을 가한다. 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력할 수 있다.
스트림 파이프라인은 종단 연산이 호출 될 때 평가된다. (지연평가)
즉, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않게 된다. 이렇게 지연된 평가가 무한 스트림을 다룰 수 있게 해준다. 무한으로 늘어나는 모든 요소를 즉시 계산하지 않고, 필요한 때 계산을 할 수 있기 때문이다. 참고로 종단 연산이 없으면 아무 일도 하지 않으니, 꼭 빼먹지 말자.
스트림 API 는 메서드 연쇄를 지원하는 플루언트 API 다.
파이프라인 하나를 구성하는 모든 호출을 연결해 단 하나의 표현식으로 완성되고, 파이프라인 여러 개를 연결해 표현식을 하나로 만들 수도 있다. 그리고, 기본적으로 스트림 파이프라인은 parallel 메서드를 호출해주지 않는 이상 순차적으로 수행된다.
2. 스트림 API 는 언제 써야하는가? (예시)
아래 예시를 살펴보자. 사전 파일에서 단어를 읽고, 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력할건데, 아나그램은 철자를 구성하는 알파벳은 같은데 순서만 다른 단어를 말한다. 키(알파벳 정렬)가 "aelpst" 일 때, "staple", "petals" 과 같은 단어들이 값이 된다.
// 스트림을 사용하지 않고 구현
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner s = new Scanner(dictionary)) {
while (s.hasNext()) {
String word = s.next();
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word); //존재하지 않는다면 새롭게 추가
}
}
for (Set<String> group : groups.values())
if (group.size() >= minGroupSize)
System.out.println(group.size() + ": " + group);
}
private static String alphabetize(String s) {
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
groups.computeIfAbsent(alphabetize(word), (unused) -> new TreeSet<>()).add(word); 이 구문에 주목하자. 자바 8에 추가된 computeIfAbsent 메서드를 사용하여, 키가 있으면 값에 매핑한 결과를 반환하고, 없으면 새롭게 키와 값을 매핑한다. 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다. 반면, 아래 스트림을 과하게 구현한 예시를 살펴보자. 따라하진 말자.
// 스트림을 과용하여 구현
public static void main(String[] args) throws IOException {
File dectionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
try(Stream<String> words = Files.lines(dectionary.toPath())) {
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 Anagrams {
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))) // 터미널 작업, 모든 단어를 Map으로 수집
.values().stream() // 맵에서 value 모음을 가져오고 새 스트림을 만듬
.filter(group -> group.size() >= minGroupSize) // 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);
}
}
파일을 열어 모든 라인으로 구성된 스트림을 얻고, 로직 (알파벳정렬) 을 수행 후에 종단 연산에서 Map 으로 수집한다. 사실 상 끝난거다. 그 다음, 맵의 values() 가 반환한 값으로부터 사용자가 지정한 문턱값과 비교해 살아남은 리스트만 출력한다. 위와 차이점은 alphabetize 메서드를 따로 분리했다는 사실인데, 스트림으로 구현할 수 있음에도 하지 않았다. 스트림을 사용한다면 명확성이 떨어지고 잘못 구현할 가능성이 커지고, 심지어 char용 스트림을 지원하지 않기 때문에 느려질 수도 있다.
스트림과 반복문을 적절히 조합하고, 기존 코드는 스트림을 사용하도록 리팩터링하되 새 코드가 나아보일 때만 반영하자.
3. 스트림 API 를 사용해야 할 때, 하지말아야 할 때
되풀이 되는 계산을 할 때, 함수 객체 (람다) 또는 반복 코드 블록을 통해 사용한다. 코드 블록에서는 지역 변수를 읽고 수정할 수 있지만, 람다는 final 만 된다. 그리고, 코드 블록에서는 return, break, continue, 메서드 선언에 명시된 예외를 던질 수 있지만, 람다는 그럴 수 없다. 코드 블록에 특화된 일을 해야할 때는, 스트림과는 맞지 않는다. 그럼 언제 스트림을 사용해야할까?
원소들의 시퀀스를 일관되게 변환하거나, 필터링하거나, 연산을 사용해 결합하거나, 컬렉션에 모을 때.
그렇다면 언제 스트림으로 처리하기 어려울까? 바로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 각 단계의 값에 동시 접근이 어려울 때이다. 아래 처음 20개의 메르센 소수를 출력하는 프로그램 예시를 통해 살펴보자.
static Stream<BigInteger> primes() { // 2부터 다음 확률적 소수를 순차적으로 생성하는 무한 스트림을 반환
return Stream.iterator(TWO, BigInteger::nextProbablePrime);
}
먼저 메르센 수와 메르센 소수가 무엇인가? (2의 p제곱 - 1) 형태의 수가 메르센 수인데, p 가 소수일 때 메르센 수도 소수면 이 수를 메르센 소수라고 한다. 이를 구하기 위해 첫 스트림으로 사용할 모든 소수를 반환하는 primes 메서드가 있다. Stream.iterate 정적팩터리는 매개변수를 두 개 받는데, 스트림의 첫 번째 원소와 스트림에서 다음 원소를 생성해주는 함수 두 매개개변수가 필요하다.이제, 처음 20개의 메르센 소수를 출력하는 프로그램을 살펴보자.
public static void main(String[] args) { // 메르센 소수를 20개를 출력하는 프로그램
primes().map(p -> TWO.pow(p.intValueExact().subtract(ONE))) // 메르센 소수로 변환
.filter(mersenne -> mersenne.isProbablePrime(50)) // 메르센 수가 소수인지 필터링
.limit(20) // 20개만 선택, 무한 스트림이므로 프로그램은 끝나지 않음
.forEach(System.out::println); // forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
}
소수들을 사용해 메르센 수를 계산하고, 결과값이 소수인 경우만 남긴 다음에 원소 수를 20개로 제한하고, 결과를 출력한다.
이제, 메르센 소수의 지수(p) 를 출력하길 원한다면?
메르센 소수를 생성한 후에 이전 단계에서 생성된 소수에 접근할 수 없기 때문에, 첫 번째 중간연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 계산해내야 한다. 지수는 숫자를 이진수로 표현한 다음, 몇 비트인지 세면 나온다.
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
'1️⃣ 백앤드 > 이펙티브 자바' 카테고리의 다른 글
| [아이템47] 반환 타입으로는 스트림보다 컬렉션이 낫다 (0) | 2024.11.19 |
|---|---|
| [아이템46] 스트림에서는 부작용 없는 함수를 사용하라 (0) | 2024.11.18 |
| [아이템44] 표준 함수형 인터페이스를 사용하라 (1) | 2024.11.13 |
| [아이템43] 람다보다는 메서드 참조를 사용하라 (0) | 2024.11.12 |
| [아이템42] 익명 클래스보다는 람다를 사용하라 (2) | 2024.11.11 |