스트림 API는 다량의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 자바 8에 추가되었다. 이 API가 제공하는 추상 개념 중 핵심은 두 가지인데, 첫 번째인 스트림(stream)은 데이터 원소의 유한 혹은 무한 시퀀스(sequence)를 뜻한다. 두 번째인 스트림 파이프라인(stream pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다. 스트림의 원소들은 어디로부터든 올 수 있고, 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다.
스트림 파이프라인은 소스 스트림에서 시작해 종단 연산(terminal operation)으로 끝나며, 그 사이에 하나 이상의 중간 연산(intermediate operation)이 있을 수 있다. 각 중간 연산은 스트림을 어떠한 방식으로 변환(transform)한다. 예컨대 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있다. 증간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있다. 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가한다. 원소를 정렬해 컬렉션에 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력하는 식이다.
스트림 파이프라인은 지연 평가(lazy evaluation)된다. 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠이며, 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산을 빼먹는 일이 절대 없도록 하자.
스트림 API는 메소드 연쇄를 지원하는 플루언트 API(fluent APU)다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다. 기본적으로 스트림 파이프라인은 순차적으로 수행된다. 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메소드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않다.
다음 프로그램은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램(anagram, 철자를 구성하는 알파벳이 같고 순서만 다른 단어) 그룹을 출력한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 | // 사전 하나를 훑어 원소 수가 많은 아나그램 그룹들을 출력한다. 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(dicrionary)) { 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); } } | cs |
이 메소드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환한다. 키가 없으면 건네진 함수 객체를 키에 적용하여 값을 게산해낸 다음 그 키와 값을 매핑해놓고, 계산된 값을 반환한다. 이처럼 computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다.
다음 프로그램은 앞의 코드와 같은 일을 하지만 스트림을 과하게 활용한다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | // 스트림을 과하게 사용했다. - 따라 하지 말 것! 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 -> 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); } } } | cs |
이 코드는 확실히 짧지만 읽기는 어렵다. 특히 스트림에 익숙하지 않은 프로그래머라면 더욱 그럴 것이다. 이처럼 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.
다행히 절충 지점이 있다. 다음 프로그램도 앞서의 두 프로그램과 기능은 같지만 스트림을 적당히 사용했다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | // 스트림을 적절히 활용하면 깔끔하고 명료해진다. 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 -> abphabetize(word))) .values().stream() .filter(group -> group.size() >= 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); } } | cs |
try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다. 이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다. 마지막으로, 종단 연산인 forEach는 살아남은 리스트를 출력한다.
alphabetize 메소드도 스트림을 사용해 다르게 구현할 수 있다. 하지만 그렇게하면 명확성이 떨어지고 잘못 구현할 가능성이 커진다. 자바 기본 타입인 char용 스트림을 지원하지 않기 때문에 심지어 느려질 수도 있다. 따라서 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 일겠지만, 서두르지 않는 게 좋다. 스트림으로 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있기 때문이다. 중간 정도 복잡한 작업에도 스트림과 반복문을 적절히 조합하는 게 최선이다. 그러니 기존 코드는 스트림을 사용하도록 리팩토링하되, 새 코드가 더 나아 보일 때만 반영하자.
스트림 파이프라인은 되풀이되는 계산을 함수 객체(주로 람다나 메소드 참조)로 표현한다. 다음 일들 중 하나를 수행하는 로직이라면 스트림을 적용하기에 좋은 후보다.
1. 원소들이 시퀀스를 일관되게 변환한다.
2. 원소들의 시퀀스를 필터링한다.
3. 원소들의 시퀀스를 하나의 연산을 사용해 결합한다(더하기, 연결하기, 최솟값 구하기 등).
4. 원소들의 시퀀스를 컬렉션에 모은다.
5. 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는다.
스트림 파이프라인이 함수 객체를 사용함에 반해, 반복 코드에서는 코드 블록을 사용해 표현한다. 따라서 함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들은 스트림과는 맞지 않는다. 다음이 그 예다.
1. 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
하지만 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역변수를 수정하는 건 불가능하다.
2. 코드 블록에서는 return 문을 사용해 메소드에서 빠져나가거나, break나 continue 문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
또한 메소드 선언에 명시된 검사 예외를 던질 수 있다.
하지만 람다로는 이 중 어떤 것도 할 수 없다.
또한 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근해야하는 경우는 스트림과 어울리지 않는다. 스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 읽는 구조기 때문이다.
스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다. 그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다. 어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다. 어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다. 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.