Item45. 스트림은 주의해서 사용하라!
Intro
- 스트림 API는 다량의 데이터 처리 작업(순차적, 병렬적)을 돕고자 자바 8에 추가되었다.
- 스트림 API가 제공하는 추상적 개념은 두 가지다.
- 스트림은 데이터 원소의 유한 혹은 무한 시퀀스를 뜻한다.
- 스트림 파이프라인은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.
- 대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수생성기 등이 있다.
- 스트림 안의 데이터 원소들은 객체 참조나 기본 타입(int, long, double) 값이다.
스트림 파이프라인
소스 스트림 -> 중간 연산(intermediate operation) -> 종단 연산(terminal operation)
- 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산에서 끝난다.
- 그 사이에 하나 이상의 중간 연산이 있을 수 있다.
- 각 중간 연산은 스트림을 변환(transform) 한다.
예시
1
Stream.of(br.readLine().split(" ")).mapToInt(Integer::parseInt).toArray();
- BufferedReader(br)로 부터 공백을 기준으로 분리한 문자열 스트림의 각 원소를 int 타입으로 변환하는 중간 연산을 거친 후, toArray를 통해 모든 원소를 배열에 담아 반환하는 종단 연산을 수행하는 예이다.
지연 평가(lazy evaluation)
- 스트림 파이프라인은
지연 평가
된다. - 평가는 종단 연산이 호출될 때 이뤄진다. 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.
- 이러한 지연 평가는 무한 스트림을 다룰 수 있게 해주는 열쇠다.
- 종단 연산이 없으면 스트림 파이프라인은 아무 일도 하지 않으니, 종단 연산을 빼먹지 말자.
fluent API
- 스트림 API는 메서드 연쇄를 지원하는 플루언트 API다.
- 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성하는 것이 가능하다.
- 기본적으로 스트림 파이프라인은 순차적으로 호출되니, 병렬적 사용을 원하면 parallel 메서드를 호출해주기만 하면 된다.
- 실제로 병렬적 사용의 효과를 볼 수 있는 상황이 많지는 않다.
예제
다전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력하는 예제다.
아나그램 : 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.
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
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(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); } }
- 맵에 각 단어를 삽입할 때 자바 8에서 추가된
computeIfAbsent
메서드를 사용했다.- 이 메서드는 맵 안에 키가 있는지 찾은 후, 있으면 단순히 그 키에 매핑된 값을 반환한다. 없으면 건네진 함수 객체를 키에 적용 후 매핑 및 계산값을 반환한다.
computeIfAbsent
메서드 사용을 통해 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현 가능하다.
- 맵에 각 단어를 삽입할 때 자바 8에서 추가된
과도한 스트림 사용은 프로그램을 읽거나 유지보수하기 어려워진다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
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); } } }
- 코드를 한눈에 이해하는 것이 어렵다. 확실히 짧지만, 그럼에도 가독성이 좋지는 못하다.
스트림을 적절히 사용해 깔끔하고 명료하게 구성해보자.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
public class Anagrams { private static String alphabetize(String s) { char[] a = s.toCharArray(); Arrays.sort(a); return new String(a); } 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)); } } }
- 가독성이 훨씬 좋아졌다.
- 스트림을 모르더라도, 단어를 받아
alphabetize
메서드를 적용해 그루핑하고 있음을 이해하는 것이 가능하다. alphabetize
메서드도 스트림을 사용해 구현할 수 있지만, 그렇게 하면 명확성이 떨어지고 잘못 구현할 가능성이 커진다. 심지어 느려질 수도 있다.- 그 이유는 자바가 기본 타입인 char 용의 스트림을 지원하지 않기 때문이다.
- 그러니 char 값들을 처리할 때는 스트림을 삼가는 편이 낫다.
스트림을 사용하도록 리펙터링
- 스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 일겠지만, 서두르지 말자.
- 코드 가독성과 유지보수 측면을 고려하자.
- 중간 정도의 복잡도를 보이는 작업에도 스트림과 반복문을 조합한 사용이 최선이다.
- 그러니 기존 코드는 스트림을 사용하도록 리펙터링하되, 새 코드가 더 나아 보일 때만 반영하자.
코드 블록과 함수 객체의 비교(스트림의 한계점)
- 코드 블록에서는 범위 안의 지역변수를 읽고 수정할 수 있다.
- 하지만 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있다. 지역변수 수정을 불가하다.
왜 final 혹은 effectively final 제한자를 사용해야 하는가?
간략히 말하자면 쓰레드 한정 기법을 위배하지 않기 위해서다. (멀티 쓰레드의 안전성을 유지하기 위함)
자유 변수(외부의 지역변수)의 경우 람다표현식 리턴 후 해당 쓰레드의 스택 영역에서 사라질 수도 있어 람다 캡쳐링(자유변수의 복사본을 만들어 접근을 허용하는 기법)에 의해서 복사해 사용한다. 이 덕분에 다른 스레드에서 해당 자유 변수를 참조할 수도 있다. 그런데 이 참조값이 람다 실행 시점에 따라 바뀐다고 생각해보자. 참조되는 값의 예측이 불가능할 것이다. 그렇기 때문에 Sync를 맞춰줄 필요가 있게 되어 final 혹은 effectively final을 사용하는 것이다.
- 코드 블록에서는 return 문을 사용해 메서드 탈출이 가능하고, break, continue 문으로 블록 바깥의 반복문을 종료, 건너뜀이 가능하다. 또 메서드 선언에 명시된 검사 예외를 던질 수 있다.
- 람다로는 이 중 어떠한 것도 할 수 없다.
스트림이 안성맞춤인 경우
- 원소들의 시퀀스를 일관되게 변환하는 경우
- 원소들의 시퀀스를 필터링하는 경우
- 원소들의 시퀀스를 하나의 연산을 사용해 결합하는 경우(더하기, 연결하기, 최솟값 구하기 등)
- 원소들의 시퀀스를 컬렉션에 모으는 경우(공통 속성을 기준으로 그룹핑)
- 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 탐색하는 경우
스트림으로 처리하기 어려운 경우
- 한 데이터가 파이프라인의 여러 단계를 통과할 때, 이 데이터의 각 단계에서의 값들에 동시 접근하기 어려운 경우
- 스트림 파이프라인은 일단 한 값을 다른 값게 매핑하면 원본 값을 잃는 구조이기 때문이다.
스트림과 반복문 비교
- 본인의 취향에 따라 선택하면 된다.
- 반복문을 사용한 코드가 익숙하면 그것을 이용하고, 스트림과 함수형 프로그래밍에 익숙하다면 스트림 방식을 사용하자.
핵심 정리
- 스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다.
- 대체로 이 둘을 조합했을 때 가장 멋지게 해결된다.
- 스트림과 반복 중 어느 것을 선택해야 하는지에 대한 확고부동한 규칙은 없다. 하지만 참고할만한 지침 정도는 있다.
- 앞서 적은 내용을 확인해보자.
- 어느 것을 사용해야 하는지 확고하지 않다면, 둘 다 사용해보고 더 나은 쪽을 택하자.
Comments powered by Disqus.