💡 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 나은 쪽을 택하라
📌 Stream API
다량의 데이터 처리 작업(순차 혹은 병렬)을 돕고자 자바 8에 추가되었다.
collection.stream() // 소스 스트림
.filter(x -> x.flag == true) // 중간 연산
.count(); // 종단 연산
- stream
- 데이터 원소의 유한 혹은 무한 시퀀스(sequence)
- 스트림 원소는 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림 등 어디로부터든 올 수 있다.
- 일반적인 stream은 객체 참조에 대한 Stream
- 기본 타입 값으로 int, long, double 세 가지를 지원 (intStream, longStream, doubleString)
- stream pipeline
- stream 원소들로 수행하는 연산 단계
- '소스 스트림 - 중간 연산(가공) - 종단 연산' 3단계로 구분한다.
✅ 특징
- 메서드 연쇄를 지원하는 플루언트 API(fluent API)
- 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성 가능
- 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.
- 스트림 파이프라인은 순차적으로 수행
- 병렬로 실행하려면 paraller 메서드가 있긴 하나, 효과를 볼 수 있는 상황은 별로 없다. (Item 48)
- 다재다능하여 사실상 어떠한 계산이든 가능
- 할 수 있지만, 해야하는 것은 아니다.
- 스트림을 제대로 사용하지 못하면 오히려 나쁜 코드가 될 수 있다.
✒️ Fluent API (fluent interface)
• method chaining : OOP에서 여러 메서드를 이어서 호출하는 문법
• Fluent API는 method chaining에 상당 부분 기반한 객체 지향 API 설계 메서드
Fluent API는 마치 코드가 영어 문장처럼 읽히는 API이며, DSL(Domain-specific Language)와 밀접한 연관이 있다.
<state name="idle">
<action command="unlockDoor"/>
<action command="lockPanel"/>
</state>
위의 XML은 프로그래머가 아닌 사람이 아닌 사람이 작성할 법한 전형적인 xml dsl이다.
정확하게는 외부 DSL이라 하는데, 이것과 같은 형식을 지키면서 자바 코드로 작성하면 어떻게 할까?
State idle;
Events unlockDoor,lockPanel;
idle
.action (unlockDoor,lockPanel)
;
구현을 하지는 않았지만, 대충 이런 식으로 표현하고 싶다.
그래야 더 나은 구조를 통해, 일반 프로그래머들조차 쉽게 사용할 수 있도록 만들 수 있는 것이다.
📌 Stream pipeline
collection.stream() // 소스 스트림
.filter(x -> x.flag == true) // 중간 연산
.count(); // 종단 연산
1. 소스 스트림(Source stream)
- 참조하는 값을 통해 스트림을 생성한다.
2. 중간 연산(intermediate operation)
- 소스 스트림과 종단 연산 사이에는 하나 이상의 중간 연산이 있을 수 있다.
- 중간 연산은 스트림을 어떠한 방식으로 변환(transform)한다.
- 변환된 스트림의 원소 타입은 변환 전과 같을 수도, 다를 수도 있다.
- 자주 사용하는 중간 연산
정의 | 설명 |
filter(Predicate<? super T> predicate) | predicate 함수에 맞는 요소만 사용하도록 필터링 |
map(Function<? Super T, ? extends R> function) | 요소 각각에 function 적용 |
flatMap(Function<? Super T, ? extends R> function) | 스트림을 하나의 스트림으로 변경 |
distinct() | 중복 제거 |
sort() | 정렬 |
sort(Comparator<? super T> comparator) | comparator 함수를 이용하여 정렬 |
skip(long n | n개 만큼의 스트림 요소 건너뜀 |
limit(long maxSize) | maxSize 갯수만큼만 출력 |
3. 종단 연산(terminal operation)
- 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다.
- 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 택하거나, 모든 원소를 출력하는 등
- 스트림 파이프라인은 지연 평가된다. (평가는 종단 연산 호출될 때 수행)
- 종단 연산을 빼먹으면 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니 꼭 붙여라.
- 자주 사용하는 종단 연산
정의 | 설명 |
forEach(Consumer<? super T> consumer) | Stream 요소 순회 |
count() | 스트림 내의 요소 수 반환 |
max(Comparator<? super T> comparator) | 스트림 내의 최댓값 반환 |
min(Comparator<? super T> comparator) | 스트림 내의 최솟값 반환 |
allMatch(Predicate<? super T> predicate) | 스트림 내의 모든 요소가 predicate 함수 만족할 경우 true |
anyMatch(Predicate<? super T> predicate) | 스트림 내의 하나의 요소라도 predicate 함수 만족할 경우 true |
noneMatch(Predicate<? super T> predicate) | 스트림 내의 모든 요소가 predicate 함수 만족하지 않는 경우 true |
sum() | 스트림 내의 요소 합 |
average() | 스트림 내의 요소 평균 |
collect() | 스트림 인터페이스에 대해서 list나 set으로 바꿈 |
✒️ 지연 평가(lazy evaluation)
호출되는 순간과 해당 스니펫에서 사용하는 변수와 문장이 실행되는 시점이 일치하는 것을 Eager code라 한다.
하지만 때로는 즉시 수행하는 것보다는 실질적 작동 시점을 지연시키는 것이 성능 향상에 도움이 된다.
그 중 하나가 Lambda의 lazy evaluation에 해당한다. (반대는 엄격한 평가, strict evaluation)
하나의 코드가 어떤 식으로 평가되는지 비교해보면 알기 쉽다.
const arr = [0, 1, 2, 3, 4, 5]
const result = arr
.map(num => num + 10)
.filter(num => num % 2)
.slice(0, 2)
console.log(result)
strict evaluation에서는 각각의 계산이 모두 종료되어야 다음 단계를 수행한다.
1. 모든 배열 원소에 대해 10을 더한다. → [10, 11, 12, 13, 14, 15]
2. 모든 배열 원소에 대해 홀수만 구한다. → [11, 13, 15]
3. 모든 배열 원소에 대해 2개만 추출한다. → [11, 13]
따라서 map 6번 + filter 6번 + slice 2번으로 총 14번의 연산이 수행된다.
lazy evaluation 방식은 왼쪽에서 오른쪽이 아닌 "위에서 아래로" 흐른다.
원소 3까지 평가가 완료된 시점에 이미 원하는 결과가 나오므로 4와 5에 대한 연산은 수행하지 않는다.
따라서 연산 횟수는 map 4번 + filter 4번 + slice 2번으로 총 10번의 연산이 수행된다.
.map().filter().limit()...를 차례로 실행하여 특정 데이터를 가지고 오는 것은 많은 시간이 소모된다.
하지만 Lambda가 관여하면, map()과 filter() 같은 중간 연산자는 람다 표현식을 저장하고 다음 호출에게 람다 표현식을 전달하여 마지막 종단 연산이 호출할 때 사용한다.
따라서 스트림에서 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
📌 주의 사항
1️⃣ 과도한 스트림 사용은 피하라
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = new File(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
// 1. Map 사용
Map<String, Set<String>> groups = new HashMap<>();
try (Scanner sc = new Scanner(dictionary)) {
while (sc.hasNext()) {
String word = sc.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(...)는 맵 안의 키가 있으면 돌려주고, 없으면 함수 객체를 키에 적용하여 값을 계산한다.
public class Anagrams {
public static void main(String[] args) throws IOException {
File dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
// 2. Stream 사용 - 과하다!
try (Stream<String> words = Files.lines(dictionary.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);
}
...
}
...
}
위 방법을 Stream 방식을 사용했더니 짧기는 하지만, 오히려 가독성이 떨어지고 복잡해졌다.
아래는 두 프로그램과 기능은 같지만 스트림을 적당히 사용하여, 절충한 코드에 해당한다.
public class Anagrams {
public static void main(String[] args) throws IOException {
Path dictionary = Paths.get(args[0]);
int minGroupSize = Integer.parseInt(args[1]);
// 3. 절충
try (Stream<String> words = Files.lines(dictionary2)) {
words.collect(groupingBy(word -> alphabetize(word)))
.values().stream()
.filter(group -> group.size() >= minGroupSize)
.map(group -> group.size() + ": " + group)
.forEach(System.out::println);
}
}
...
}
- try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.
- 스트림 변수의 이름을 words로 지어 스트림 안의 각 원소가 단어(word)임을 명시했다.
- 중간 연산은 없으며, 종단 연산에서 모든 단어를 수집해 맵으로 모은다.
- 이 맵의 values()가 반환한 값으로 새로운 Stream<List<String>>을 연다.
- 리스트들 중 원소가 minGroupSize보다 적은 것을 필터링한다.
- 종단 연산 forEach로 살아남은 리스트들을 출력한다.
이처럼 적절한 Stream 사용은 개념을 잘 모르는 사람이라도 쉽게 이해할 수 있다.
✒️ 가독성을 높이는 방법
① 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어주어야 한다.
② 도우미 메서드를 적절히 활용하라.
• 단어 철자를 알파벳 순 정렬하는 alphabetize를 빼서 가독성을 높였다.
• 도우미 메서드를 적절히 활용하는 일은 일반 반복 코드보다 스트림 파이프라인에서 훨씬 중요하다.
2️⃣ char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
alphabetize 메서드도 스트림을 사용해 다르게 구현할 수 있지만, 그렇게 하지 않는 것이 좋다.
- 명확성이 떨어지고 잘못 구현할 가능성이 커진다.
- 자바가 기본 타입인 char용 스트림을 지원하지 않기 때문에 느려질 수도 있다. (char 스트림 지원은 애초에 불가능한 일이었다.)
"Hello Char".char().forEach(Systeom.out::print); // 72101108108111321191111410810
"Hello Char".char()가 반환하는 스트림 원소는 char가 아닌 int 값이므로 원하는 결과가 나오지 않는다.
"Hello Char".char().forEach(x -> System.out.printlin((char) x));
명시적으로 형변환을 해주면 원하는 값을 얻을 수는 있지만, 그냥 쓰지 마라.
3️⃣ 기존 코드는 스트림을 사용하여 리팩터링하되, 새 코드가 더 나아 보일 때만 반영하라.
함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들이 있다.
- 스트림 안에서는 final 변수(사실상 final인 변수들까지)만 읽을 수 있다. 즉, 지역 변수 수정이 불가능하다.
- 외부 변수를 lambda 안의 지역 변수로 사용하면, 해당 외부 변수를 복사한 형태로 사용한다.
- 지역 변수는 스택 영역에서 생성되며, 해당 block이 끝나면 스택에서 사라지기 때문
- 지역 변수를 관리하는 Thread와 Lambda가 실행되는 Thread가 다를 수 있다. (람다에서의 Thread는 공유되지 않는 다는 특성때문이다.)
int num = 10;
Runable runnable = () -> System.out.println("number : " + num);
runnable.run(); // OK
int num = 10;
Runnable runnable = () -> {
num ++;
System.out.println("number: " + num);
} // Error : java : local variable referenced from a lambda expression must be final or effectivly final
- 람다로는 return, break, continue, exception가 불가능하다.
📌 Stream을 사용하면 좋은 경우
- 원소들의 시퀀스를 일관되게 변환
- 원소들의 시퀀스를 필터링
- 원소들의 시퀀스를 하나의 연산을 사용해 결합 (더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션으로 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
📌 Steam으로 처리하기 어려운 경우
1️⃣ 파이프라인의 여러 단계(stage)를 통과할 때, 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어렵다.
public class MersennePrime {
private static final BigInteger TWO = BigInteger.valueOf(2);
public static void main(String[] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(BigInteger.ONE))
.filter(mersenne -> mersenne.isProbablePrime(50))
.limit(20)
.forEach(mp -> System.out.println(mp.bitLength() + ": " + mp));
}
static Stream<BigInteger> primes() { // 스트림 반환 메서드는 복수명사가 좋다
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
}
2: 3
3: 7
5: 31
7: 127
13: 8191
17: 131071
19: 524287
...
- 위 코드에서는 메르센 소수 앞에 지수(p)를 출력하기 위해 mp를 역산하여 얻어냈다.
- 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래 값을 잃는다.
- 기존 값이 필요한 경우 매핑해서 우회할 수는 있지만 원래 목적을 잃게 된다.
2️⃣ 스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 작업
// 1. 반복문을 사용한 구현
private static List<Card> mewDeck() {
List<Card> result = new ArrayList<>();
for (Suit suit : Suit.values())
for (Rank rank : Rank.values())
result.add(new Card(suit, rank));
return result;
}
// 2. 스트림을 이용한 구현
private static List<Card> mewDeck() {
return Stream.of(Suit.values())
.flatMap(suit -> Stream.of(Rank.values())
.map(rank -> new Card(suit, rank)))
.collect(toList());
}
- 개인 취향 문제에 해당하므로, 사용하고 싶은 것을 사용해도 좋다.
- 만약, 협업을 하는 사람들이 선호하는 방식이 있다면, 그것을 선택하는 것이 더 좋을 것이다.