다량의 데이터 처리 작업을 지원하고자 자바8에 스트림 API가 추가되었다.
이 API가 제공하는 추상 개념 중 핵심은 두 가지다.
- 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻한다.
- 스트림 파이프라인(stream pipleline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
스트림 파이프라인은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝나며, 그 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있다.
각 중간 연산들은 스트림을 어떠한 방식으로 변환(transform)하여 다른 스트림으로 변환한다.
또한 스트림 파이프라인은 지연 평가(lazy evaluation)이 되며, 평가는 종단 연산이 호출될 때 이뤄진다. 종단 연산에 쓰이지 않는 데이터 원소는 계산이 쓰이지 않는다.
스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어 진다.
예를 살펴보자 (이펙티브 자바 참고).
아래는 아나그램관련 프로그램이다.
⇒ 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값(minGroupSize)보다 원소 수가 많은 아나그램그룹을 출력한다.
아나그램 : 단어나 문장을 구성하고 있는 문자의 순서를 바꾸어 다른 단어나 문장을 만드는 놀이
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 (참고)
이 메소드는 map 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다.
키가 없으면 건네진 함수 객체를 계산하여 그 키에 해당하는 값으로 매핑한 후 계산된 값을 반환한다.
그러면 이제 위의 코드를 같은 일을 하는 스트림을 사용한 코드로 변경해보자. (과한 스트림을 사용)
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);
}
}
}
위의 코드는 확실히 짧지만 읽기는 어렵다. 이처럼 스트림을 과용하면 프로그램을 읽거나 유지보수하기 어려워진다.
다행히 절충 지점이 있는데 아래와 같이 스트림을 적당히 사용하면 코드가 짧아질 뿐만아니라 명확해지기까지 한다.
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과 궁합이 맞는 경우)
- 원소들의 시퀀스를 일관되게 변환한다.
- 원소들의 시퀀스를 필터링한다.
- 원소들의 시퀀스를 하나의 연산을 사용해 결합한다. (더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모은다.
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
람다에서 매개변수 이름 지을때 주의!!
람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지된다.