Item45. Stream은 주의해서 사용하라

Stream API 개념

Stream API는 Jdk 8부터 다량의 데이터 처리 작업 (순차적,병렬적) 을 도와주기 위해 추가되었다.

Stream API의 핵심 개념은 다음과 같다.

  • Stream : 유한 또는 무한개의 데이터 원소 시퀀스이다.
  • Stream pipeline : 원소들로부터 수행하는 각 연산단계를 표현하는 개념

Stream API 특징

  1. 데이터를 담고 있는 저장소가 아니다.
  2. Stream안의 데이터 원소들은 객체 참조나 기본 타입 값 (int,long,double 3가지 ) 이다
  3. Functional in nature. 원본 데이터를 변경하지 않는다.
  4. Laziness-seeking.
    stream API는 2가지 연산으로 나뉜다. map,filer 와 같은 intermediate operation (중개 연산) , collect와 같은 terminal operation (종료 연산)이 있다.
    중개 연산은 stream을 반환하고 종료 연산은 stream 타입을 반환하지 않는다. 중개 연산은 lazy evalutation(지연 평가) 된다고 공식 문서에 적혀 있는데,
    이는 종료형 연산( 예를 들면 .collect(toList())) 이 오기전까지는 중개 연산이 실행자체가 되지 않기 떄문이다.
1
2
3
4
5
List.of("a", "b", "c").stream().map((s) -> {
System.out.println("s = " + s);
return s.toUpperCase();
});
// terminal operation이 수행되기 전까지 실행되지 않음 (lazy)
  1. Stream으로 처리되는 데이터는 오직 한번만 처리된다 (각각의 stream 내 operator를 한번씩만 지나간다. )
  2. 스트림내 데이터 원소가 무제한일수도 있다. (short circuit method를 사용해서 제한할 수 있다. )
  3. 병렬처리가 가능하다.
1
2
3
4
5
6
7
8
// parallelStream : Jvm 이 병렬처리해줌 
// 데이터가 적은 경우에 불필요하게 병렬처리시에는 thread context swithing overhead 가 있어 오히려 느려지는 경우도 있음
foods.parallelStream().forEach((food)-> System.out.println("current thread : " + Thread.currentThread().getName()));
/*
current thread : ForkJoinPool.commonPool-worker-5
current thread : ForkJoinPool.commonPool-worker-19
current thread : main
*/

보다 자세한 stream에 대한 설명은 아래 reference 에서 확인할 수 있다.

(https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html)

Stream 적용 예시

기존에 다음과 같이 anagram을 찾아서 출력해주는 프로그램이 있다고 가정하자, 아래 프로그램은 Stream API를 사용하지 않은 코드이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static void reportAnagrams(String[] args) throws FileNotFoundException {
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(orderByAlphabet(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 orderByAlphabet(String s){
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}

사용자는 매개변수로 파일과 , Anagram의 최소 단어 수를 넘겨주면 파일의 단어를 while loop를 돌면서 computeIfAbsent method를 호출한다.
computeIfAbsent method는 키가 없으면 새로운 키를 집어넣고, 함수 객체를 실행해서 반환받은 값을 넣어준다. 그리고 값을 반환해준다.
반면 키가 있다면 키에 매핑된 값을 반환한다.

1
2
3
4
5
6
7
8
9
//computeIfAbsent API 학습테스트 
@ParameterizedTest
@CsvSource({"inputStr"})
void testComputeIfAbsent(String input){
Map<String,Integer> map = new HashMap<>();
map.put(input,input.length());
assertThat(map.computeIfAbsent("newKey",(key)->key.length())).isEqualTo(6);
assertThat(map.computeIfAbsent(input,(key)->key.length())).isEqualTo(input.length());
}

computeIfAbsent method로 키(정렬된 단어)가 없다면 키를 집어넣고, 값으로 쓰일 빈 TreeSet을 반환해주고, TreeSet에 단어를 넣어준다.

마지막으로 for loop를 돌면서 사용자가 매개변수로 넘긴 Anagram 최소 단어 수보다 긴 Anagram만 출력해준다.

이를 Stream API를 과용하는 형태로 바꾸면 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// stream API 활용 
private static void reportAnagrams(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);
}
}

코드가 짧아지긴 했지만 Stream 를 과용해서 오히려 이해하기 힘들어졌다. Stream을 적절하게 활용한 예는 다음과 같다.

1
2
3
4
5
6
7
8
9
10
11
private static void reportAnagrams(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->alphabetSize(word)))
.values().stream()
.filter(group->group.size() >= minGroupSize)
.forEach(group -> System.out.println(group.size() + ":" + group));
}
}

Stream vs 코드 블록 반복문

stream pipeline은 주로 되풀이 되는 계산을 함수 객체로 표현한다. 반면 반복 코드에는 코드 블록을 사용해 표현한다.

두 방식의 차이점은 다음과 같다

  1. 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다. 하지만 람다에서는 final이거나 effectively final인 변수만 읽을 수 있고, 지역변수를 수정하는 것은 불가능하다.
  2. 코드 블록에서는 return 문을 이용해 method를 빠져나가거나, break, continue 문 사용이 가능하고, 예외를 던질 수도 있다. 하지만 람다에서는 불가능하다.

따라서 위와 같은 코드 블록에서만 할 수 있는 일이 필요하다면 반복 코드를 사용해야 한다.
또 데이터가 stream pipeline의 각 단계(연산)에서 나온 값들을 동시에 접근하고자 할떄도, stream을 사용하기 어렵다. stream은 한 원소를 다른 값에 매핑하고 나면 원래 값은 잃는 구조이기 때문이다.

어떨때 Stream을 써야하는가?

  1. 원소들의 sequence를 일관되게 변환한다.
  2. 원소들의 sequence를 필터링한다.
  3. 원소들의 sequence를 하나의 연산을 사용해서 결합한다.
  4. 원소들의 sequence를 collection에 모은다.
  5. 원소들의 sequence에서 특정 조건을 만족하는 원소를 찾는다.

stream과 반복중에 어떤 쪽을 써야할지 애매한 경우도 있다 예를 들면 다음과 같이 카드의 숫자(rank)와 무늬(suit) 열거타입이 있고, 두조합으로 만들 수 있는 카드를 모두 반환하는 method가 있다고 가정하자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 반복 method
public static List<Card> newDeck(){
ArrayList<Card> result = new ArrayList<>();
for (Suit suit : Suit.values()) {
for (Rank value : Rank.values()) {
result.add(new Card(suit,rank));
}
}
return result;
}
// Stream API
private static List<card> newDeck(){
return Stream.of(Suit.values())
.flatMap(suit ->
Stream.of(Rank.values())
.map(rank -> new Card(suit,rank)))
.collect(toList();
}

추가로 flatMap 중간 연산은 stream의 원소 각각을 하나의 stream으로 매핑한 다음, 그 stream들을 다시 하나의 stream으로 합친다. 이를 평탄화(flattening) 이라고 한다.

개인마다 반복문을 쓰거나, streamAPI를 사용하는 쪽으로 취향이 호불호가 갈린다. 협업 개발자와 익숙한 문법으로 선택하거나 , 둘 다 해보고 더 나은 족을 선택하는 방법도 있다.

Comments