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

@023· February 05, 2025 · 12 min read

스트림이란?

스트림 API는 다야의 데이터 처리 작업(순차적이든 병렬적이든)을 돕고자 Java8부터 추가되었다. 여기서 이 API가 제공하는 핵심 추상 개념은 다음 두 가지다.

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

스트림의 원소들은 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 혹은 다른 스트림 등 어디로부터든 원소들이 올 수 있다. 보통 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값인데, 기본 타입 값으로는 int, long, double 이렇게 세 가지를 지원한다.

스트림은 이제 알겠는데 그럼 스트림 파이프라인은 무엇일까? 스트림 파이프라인은 소스 스트림에서 시작해 종단 연산으로 끝나며, 그 사이에 중간 연산이 연결되는데, 이때 각 중간 연산은 변환을 한다. 그래서 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고, 다를 수도 있다.

추가 설명을 하자면 중간 연산은 특정 조건을 만족 못하는 원소를 걸러내는 연산인 filter나 map 같은 연산을, 종단 연산은 원소를 정렬해 컬렉션에 담거나 특정 원소 하나를 선택하거나 모든 원소를 출력하는 연산을 의미한다고 보면 된다.

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

스트림 평가는 종단 연산이 호출될 때 이뤄지며, 종단 연산에 쓰이지 않는 데이터 원소는 계산에 쓰이지 않는다. 즉, 종단 연산이 없는 파이프 라인은 아무 일도 하지 않는 no-op같은 명령어이다. 그러니, 종단 연산을 빼먹는 일이 절대 없도록 해야한다.

스트림 API

스트림 API는 메서드 연쇄를 지원하는 플루언트 API다. 즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 그래서 파이프라인 여러 개를 갖다가 연결해서 표현식 하나로 만들 수도 있다.

기본적으로 스트림 파이프라인은 순차적으로 수행되는데, 이때 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel을 호출하면 된다.

import java.util.stream.Stream;

public class Main {
    public static void main(String[] args) {
        Stream.of(1, 2, 3, 4, 5)
                .parallel()
                .forEach(System.out::println);
    }
}

하지만, 효과를 볼 수 있는 상황은 많지 않다.

스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있다. 하지만 할 수 있다는 뜻이지, 해야한다는 뜻은 아니다. 그 이유는 스트림을 제대로만 사용하면 코드가 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다. 그리고 이러한 스트림을 언제 써야 하는지를 규정하는 확고부동한 규칙이 없다(그래도 노하우는 있으니 안심하자).

왜 주의해야 할까?

책에서는 아나그램 프로그램 코드를 이용해 케이스에 맞게 예시를 보여주며 교훈을 주고 있다..

스트림 없이 구현한 코드

public class Anagrams {
	public static void main(String[] args) throws IOException {
		File dictionary = new File(args[0]); // 사전 파일
		int minGroupSize = Integer.parseInt(args[1]); // 사용자가 지정한 원소 수 문턱값

		// key : 알파벳 순으로 정렬한 값, value : 같은 키를 공유한 단어들을 담은 집합
		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);
			}
		}

		//minGroupSize 보다 원소 수가 많은 아나그램 그룹 출력
		for (Set<String> group : groups.values()) {
			if (group.size() >= minGroupSize) {
				System.out.println(group.size() + " : " + group);
			}
		}
	}

	// 아나그램 그룹의 key를 만들어줌
	private static String alphabetize(String s) {
		char[] a = s.toCharArray();
		Arrays.sort(a);
		return new String(a);
	}
}

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

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);
		}
	}
}

확실히 짧아졌지만, 읽기가 어렵다. 그리고 이는 스트림에 익숙치 않은 프로그래머에게는 더 어려울 것이다.

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

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(Anagrams::alphabetize)) // alphabetize 메서드로 단어들을 그룹화함
					.values().stream()
					.filter(group -> group.size() >= minGroupSize) // 문턱값보다 작은 것을 걸러냄
					.forEach(g -> System.out.println(g.size() + " : " + g)); // 필터링이 끝난 리스트 출력
		}
	}

	// 아나그램 그룹의 key를 만들어줌
	private static String alphabetize(String s) {
		char[] a = s.toCharArray();
		Arrays.sort(a);
		return new String(a);
	}
}

이처럼 스트림을 적절히 활용하면 깔끔하고 명료해진다. 이때 주의할 점은 람다에서 타입 이름을 자주 생략하기 때문에 네이밍을 잘 해주어야한다. 위 코드에서 보면 forEach() 안에 g의 경우, group이라고 네이밍해주면 된다. 이렇게 람다의 매개변수도 네이밍을 적절히 해주면 파이프라인의 가독성이 스트림을 사용해도 떨어지지 않는다.

아래 alphabetize 메소드에서는 스트림을 사용하지 않았다. 그 이유는 char의 스트림은 자바는 지원하지 않고 있기 때문이다. 여기서 알 수 있는 것은 char는 스트림을 삼가는 것이 좋다는 것이다.

리팩토링 관점에서의 스트림

원래 반복문을 사용하고 있는 코드에 대해서는 최대한 스트림으로 리팩토링을 하지만 새로운 스트림 코드가 더 나을때만 진행하는 것이 좋다. 책에서는 코드 블록과 람다 블록을 비교하며, 코드 블록이 더 나을 때는 스트림을 사용하지 않는 것이 좋다고 한다.

그럼 코드 블록이 나을 때가 언제일까? 람다에서는 final이거나 사실상 final인 변수만 읽을 수 있고, 지역 변수를 수정하는 건 불가능하다. 하지만, 코드블록은 범위 안의 지역 변수를 읽고 수정할 수 있다. 또 있다. 코드 블록은 return, break, continue 등을 사용하거나 메서드 선언에 명시된 검사 예외를 던질 수 있지만, 람다로는 이중 무엇도 할 수가 없다. 이처럼 람다로는 할 수 없는 것들이 많기 때문에 코드 블록이 더 나을 때는 코드 블록을 사용하는 것이 좋다.

그럼 언제 람다를 사용해야 할까? 스트림으로 처리하기에 딱인 경우 또한 존재한다. 다음은 스트림으로 처리하기에 적합한 경우이다.

  • 원소들의 시퀀스를 일관되게 변환하는 경우
  • 원소들의 시퀀스를 필터링하는 경우
  • 원소들의 시퀀스를 하나의 연산을 사용해 결합하는 경우
  • 원소들의 시퀀스를 하나의 컬렉션에 모아 넣는 경우
  • 원소들의 시퀀스에서 특정 조건을 만족하는 원소를 찾는 경우

한편,스트림으로 처리하기 어려운 경우도 존재한다. 대표적으로, 한 데이터가 파이프 라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시 접근하기 어렵다. 이유는 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다. 원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 복잡하기 때문에 사용하지 않는 것이 좋을 것이다.

스트림과 반복 중 어느 쪽을 써야 할까?

코딩을 하다보면 스트림과 반복문 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많다. 그럼 어떻게 해야 할까? 스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다. 그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다. 처음에 말했듯이 어느쪽을 선택하는 확고부동한 규칙은 없지만, 방법은 있다고 했다. 책에서는 다음과 같은 방법을 제시한다. 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.

@023
focus and hustle