다량의 데이터 처리 작업을 돕고자 자바8에 스트림 API가 추가되었다.
이 API가 제공하는 추상 개념 중 핵심은 두 가지다.
- 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻한다.
- 스트림 파이프라인(stream pipleline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
스트림 파이프라인 은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝나며, 그 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있다. 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform)한다. (중간 연산들은 모두 한 스트림을 다른 스트림으로 변환한다.)
또한 스트림 파이프라인은 지연 평가(lazy evaluation)이 되며, 평가는 종단 연산이 호출될 때 이뤄진다. 종단 연산에 쓰이지 않는 데이터 원소는 계산이 쓰이지 않는다.
스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어 진다.
예를 살펴보자, 아래는 아나그램관련 프로그램이다.
⇒ 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값(minGroupSize)보다 원소 수가 많은 아나그램그룹을 출력한다.
아나그램 참고 : https://ko.wikipedia.org/wiki/어구전철
public class Anagrams {
public static void main(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(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);
}
}
computeIfAbsent (참고)
이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다.
키가 없으면 건네진 함수 객체를 계산하여 그 키에 해당하는 값으로 매핑한 후 계산된 값을 반환한다.
그러면 이제 위의 코드를 같은 일을 하는 스트림을 사용한 코드로 변경해보자.(과하게 스트림을 사용)
public class AnagramsStream {
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);
}
}
}
(이펙티브자바의 예제코드를 따라쳤는데, groupingBy부터.. 해석하기가 너무 복잡하다..)
위의 코드는 확실히 짧지만 읽기는 어렵다.. 이처럼 스트림을 과용하면 프로그램을 읽거나 유지보수하기 어려워진다.
다행히 절충 지점이 있는데 아래와 같이 스트림을 적당히 사용하면 코드가 짧아질 뿐만아니라 명확해지기까지 한다.
public class AnagramsEasyStream {
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(group -> System.out.println(group.size() + ": " + group));
}
}
private static String alphabetize(String s){
char[] a = s.toCharArray();
Arrays.sort(a);
return new String(a);
}
}
짜잔~~
람다 매개변수의 이름은 주의해서 정할 것
⇒ 람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.
결론
스트림을 처음 사용하게 되면 모든 반복문을 스트림으로 바꾸고 싶은 유혹
이 일게 된다고 한다. 나 또한 그랬다. 하지만 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서 손해를 볼 수 있기 때문에.. 기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아 보일때만 반영 해야 한다.
꼭 for문, stream을 써야하는 경우가 정해져 있는 것은 아니나, 특정상황에서 for문 또는 stream을 써야만하는 경우가 존재한다.
for문을 써야하는 경우(stream에서 할 수 없는 것들)
- 코드블록안에서 지역변수를 수정해야할 때 (람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.)
- 중간에 return 문으로 메서드를 빠져나가는 경우나, break continue 문으로 블록 바깥의 반복문을 종료하거나 건너띄는 경우.
stream을 써야하는 경우(stream과 궁합이 맞는 경우)
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다. (더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
'프로그래밍 노트 > Effective 시리즈' 카테고리의 다른 글
매개변수가 유효한지 검사하자 (0) | 2020.01.09 |
---|---|
null이 아닌, 빈 컬렉션이나 배열을 반환해야 한다. (2) | 2020.01.07 |
표준 함수형 인터페이스를 사용하자. (0) | 2020.01.01 |
람다보다는 메서드 참조를 사용하라. (0) | 2019.12.26 |
익명 클래스보다는 람다를 사용하라. (0) | 2019.12.26 |