Home Item45 (스트림은 주의해서 사용하라)
Post
Cancel

Item45 (스트림은 주의해서 사용하라)

스트림과 Optional 정리

스트림은 주의해서 사용하라

스트림 API는 다량의 데이터 처리 작업을 돕고자 Java 8부터 도입되었다.

이 스트림 API는 두 가지 추상 개념을 제공한다.

  • 스트림
    • 데이터 원소의 유한 혹은 무한 시퀀스 개념
  • 스트림 파이프라인
    • 원소들로 수행하는 연산 단계를 표현하는 개념

스트림

스트림의 원소들은 어디로부터든 올 수 있다. 컬렉션, 배열, 파일, matcher, 다른 스트림 등등 그리고 이 원소들은 객체 참조나 기본 타입 값(int, long, double 지원)이다.

스트림 파이프라인

스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝난다. 그 사이에 하나 이상의 중간 연산이 있을 수 있다.

  • 중간 연산은 스트림을 변환하는 역할을 한다.
    • 원소에 함수를 적용하거나 특정 조건을 만족하지 못하는 원소를 거르는 등의 것을 뜻한다.
  • 종단 연산은 마지막 중간 연산이 내놓은 스트림에 최종적인 연산을 가한다.
    • 원소를 정렬해 컬렉션에 담거나, 특정 원소를 하나 선택하거나, 모든 원소를 출력하는 등의 것을 뜻한다.

그리고 스트림 파이프라인은 지연 평가된다.

평가는 종단 연산이 호출될 때 이루어지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다.

이러한 지연 평가가 무한 스트림을 다룰 수 있게 해준다. 종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않으므로 주의하자.

스트림 API

  • 메서드 연쇄를 지원하는 플루언트 API (Fluent API) 다.
    • 파이프라인 하나를 구성하는 모든 호출을 연결해 단 하나의 표현식으로 완성할 수 있다.
    • 파이프라인 여러 개를 연결해 표현식 하나로 만드는 것도 가능하다.
  • 기본적으로 스트림 파이프라인은 순차적으로 수행된다.
    • 파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 된다.
    • 단, 효과를 볼 수 있는 상황은 많지 않다.
  • 스트림 API는 다재다능하다. 어떠한 계산도 할 수 있다.
    • 스트림을 제대로 사용하면 프로그램이 깔끔해진다.
    • 그러나 반드시 스트림을 써야한다는 뜻은 아니다. 단지 할 수 있다는 뜻이다.
      • 스트림을 잘못 사용하면 오히려 읽기 어렵고 유지보수도 힘들다.
  • 스트림을 언제 써야하는 지에 대해 규정하는 규칙은 없다.
    • 참고할만한 노하우 정도가 존재한다.

스트림은 주의해서 사용해야 한다.

사전 파일에서 단어를 읽어 사용자가 지정한 값보다 원소 수가 많은 아나그램(구성하는 알파벳은 같고 순서만 다른 단어들. ex - apple, elppa) 그룹을 출력하는 프로그램을 예시로 보자.

스트림 없이 구현한 코드

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
27
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);
	}
}
  • 맵 안에 키가 있는지 찾고, 있으면 해당 키에 매핑된 값을 반환한다. (Set 반환)
    • 만약 키가 없다면, 건네진 함수 객체를 키에 적용해 값을 계산 후 그 키와 값을 매핑해놓고 계산된 값을 반환한다.
    • 즉, 키가 있다면 그 키에 매핑된 Set에 word를 추가하고, 키가 기존에 없다면 새로운 Set을 만들고 거기에 word를 추가한다.

스트림을 과하게 활용한 코드

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)) { //복습: try with resources
			// 사전을 여는 부분을 제외하고 프로그램 전체가 단 하나의 표현식으로 처리
			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);
		}
	}
}
  • 코드 자체는 짧지만 추출한 word를 어떻게 조작하고 그룹화하는지 잘 읽히지 않는다.
    • 심지어 우리는 어떤 프로그램을 만드는 지 아는데도 읽기 힘들다.
    • 스트림에 익숙하지 않은 사람에게는 더더욱 어려울 것이다.

이렇게 스트림을 과하게 사용하면 오히려 가독성이 떨어지고 유지보수하기도 힘들다.

스트림을 적절히 활용한 코드

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(Anagrams::alphabetize)) // alphabetize 메서드로 단어들을 그룹화함
					.values().stream()
					.filter(anagramGroup -> anagramGroup.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);
	}
  • 도우미 메서드를 잘 활용해야 한다.
    • alphabetize() 메서드를 이용하여 단어들을 아나그램 조건에 맞게 그룹화했다.
    • 스트림 파이프라인에서는 타입 정보가 명시되지 않거나 임시 변수를 자주 사용하기 때문이다.
  • 그 값들에서 또 스트림 파이프라인을 더 만들어서 그 그룹의 size가 문턱값보다 작다면 거르고, 출력했다.
  • 확실히 스트림을 적절히 사용한 경우는 읽기도 편하고 코드도 깔끔하다.
  • 람다의 매개변수 이름을 잘 지어야 파이프라인의 가독성이 유지된다.
    • ex) anagramGroup

스트림을 모르는 사람도 어느정도 이해할 수 있는 코드가 되었다. 한 번 스트림 코드를 명확히 설명해보자.

  • 첫 스트림의 파이프라인에서는 중간 연산이 없고 종단 연산으로 groupingBy를 통해 각 단어를 Map으로 수집했다.
  • 그리고 이 Map을 통해 새로운 스트림을 열고 그 스트림에서 중간 연산으로 필터링을 하고 종단 연산을 통해 출력하는 것이다.

스트림을 적절히 활용하면 코드가 짧아짐은 물론 명확해진다. 가독성도 좋다.

주의: 자바는 기본 타입인 char용 스트림을 지원하지 않는다.

위 예시에서 alphabetize() 메서드를 스트림을 사용해 다르게 구현할 수도 있다.

그러나 그렇게 하면 명확성이 떨어지고 잘못 구현할 가능성이 있다. 심지어 느려질 수도 있다. 자바가 char용 스트림을 지원하지 않기 때문이다.

1
"Hello World!".chars().forEach(System.out::print);
  • 위 코드의 동작은 Hello World!를 출력하지 않는다.
    • 72101108108111328711111410810033을 출력한다.
  • 스트림의 원소가 char가 아닌 int값이기 때문이다. 이름은 chars() 인데 int 스트림을 반환하니 헷갈린다.
    • 올바르게 출력하려면 형변환을 명시적으로 해주어야 한다.

따라서 char 값들을 처리할 때는 스트림을 사용하지 않는 것이 낫다.

기존 코드의 리팩토링

스트림을 보면 기존 코드의 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 생길 수 있다.

하지만 위 예시에서 보다시피 스트림을 사용한다고 무조건 더 나은 코드가 되는 것은 아니다. 스트림과 반복문을 적절히 조합해야 한다.

가져가야 하는 기조로는 기존 코드는 스트림을 사용하도록 리팩토링을 하되, 새 코드가 더 나아 보일 때만 반영하도록 해야 한다. 가 될 것이다.

코드 블록 vs 람다 블록

되풀이되는 계산을 할 때 스트림은 함수 객체로 표현하고, 반복문은 코드블록을 사용해 표현한다.

1
2
3
4
5
6
for (int i = 1; i <= 10; i++) { 
	if (i % 2 == 0) { 
		int square = i * i; 
		System.out.println(square); 
	} 
}
1
2
3
4
IntStream.rangeClosed(1, 10) 
		.filter(i -> i % 2 == 0) 
		.map(i -> i * i) 
		.forEach(System.out::println);

여기서 코드 블록만 할 수 있는 일이 있다.

  • 범위 안의 지역변수를 읽고, 수정이 가능하다.
    • 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있다.
    • 지역 변수를 수정하는 것은 불가능하다.
  • return을 사용해 메서드에서 빠져나갈 수 있다.
  • break나 continue문으로 블록 바깥의 반복문을 종료하거나 반복을 한 번 건너뛸 수 있다.
  • 메서드 선언에 명시된 검사 예외를 던질 수 있다.

람다는 위에 명시된 내용을 아무 것도 할 수 없다.

따라서 이런 부분을 고려해서 둘 중 어느 것을 사용하는게 더 깔끔한 코드가 될 지 판단해서 더 나은 것을 선택해야 한다.

스트림에 어울리는 일과 어울리지 않는 일

  • 스트림으로 처리하기에 좋은 일
    • 원소들의 시퀀스를 일관되게 변환하기 (ex- 문자열 리스트의 원소들을 대문자로 변경하기)
    • 원소들의 시퀀스를 필터링하기
    • 원소들의 시퀀스를 하나의 연산을 사용해 결합하기 (ex - 원소 전체를 더한 값 구하기)
    • 원소들의 시퀀스를 컬렉션에 모으기
    • 원소들의 시퀀스에서 특정 조건을 만족하는 원소 찾기 ( findFirst() )

그렇다면 스트림으로 처리하기 어려운 일들에는 무엇이 있을까?

  • 스트림으로 처리하기 어려운 일
    • 파이프라인의 여러 단계에서의 값들에 동시에 접근해야 하는 경우
      • 스트림 파이프라인은 한 값을 다른 값에 매핑하고 나면 원래의 값을 잃는 구조이다.
1
2
3
static Stream<BigInteger> primes() {
	return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
  • 무한 스트림을 반환하는 메서드이다.
  • iterate() 정적 팩토리는 매개변수 2개를 받는다.
    • 첫 번째 매개변수는 스트림의 첫 번째 원소이다.
    • 두 번째 매개변수는 스트림에서 다음 원소를 생성해주는 메서드이다.
1
2
3
4
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
		.filter(mersenne -> mersenne.isProbablePrime(50))
		.limit(20)
		.forEach(System.out::println);
  • 첫 20개의 메르센 소수를 출력하는 프로그램이다.
    • 메르센 수는 2^p - 1 형태의 수이다.
    • 이 때 p가 소수이면 메르센 수도 소수일 수 있는데 이 조건을 만족하는 수를 메르센 소수라고 한다.

이 예시에서 만약 각 메르센 소수의 앞에 지수 p를 출력하길 원한다고 가정해보자. 위 예시에서는 단지 메르센 소수만 출력이 가능하다. 종단 연산에서 접근할 수 없다. 스트림에서 어떻게 p에 접근해 출력할 수 있을까?

  • 중간 연산에서 수행한 매핑을 거꾸로 수행하여 지수를 계산하는 방법이 있다.

이렇게 파이프라인의 여러 단계에서의 값들에 모두 접근해야 하는 경우에는 확실히 스트림을 활용하기 어렵다. 반복문을 사용하면 훨씬 쉽게 구현할 수 있을 것이다.

정리

스트림을 사용한다고 해서 무조건 가독성이 좋아지고 코드가 깔끔해지지 않는다. 오히려 과도하게 사용하면 읽고 이해하기 어렵다.

스트림과 반복문은 각각에게 알맞은 일이 있으므로 더 나은 방법을 찾아 서로 조합하면 가장 깔끔한 코드가 될 것이다.

만약 스트림과 반복문 중 어느것이 더 나은지 판단할 수 없다면 직접 해보고 더 나은 쪽을 택하면 된다.

This post is licensed under CC BY 4.0 by the author.

Item44 (표준 함수형 인터페이스를 사용하라)

Item46 (스트림에서는 부작용 없는 함수를 사용하라)