스트림 API는 순차적이든 병렬적이든 다량의 데이터 처리 작업을 돕고자 자바 8에 추가되었다.

이 API가 제공하는 추상 개념 중 핵심은 두 가지다.

  1. 스트림(Stream)은 데이터 원소의 유한 혹은 무한 시퀀스(Sequence)를 뜻한다.
  2. 스트림 파이프라인(Stream Pipeline)은 이 원소들로 수행하는 연산 단계를 표현하는 개념이다.

스트림의 원소는 어디로부터든 올 수 있다. 대표적으로 컬렉션, 배열, 파일, 정규표현식 패턴 매처, 난수 생성기, 혹은 다른 스트림이 있다. 스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값이다.

기본 타입 값으로는 int, long, double 이렇게 세 가지를 지원한다.

 

스트림 API는 메서드 연쇄를 지원하는 플루언트 API이다.

파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있다. 파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있다.

 

스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있다. 하지만 할 수 있다는 뜻이지 해야한다는 뜻은 아니다.

스트림을 제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 읽기 어렵고 유지보수도 힘들어진다.

스트림을 언제 써야 하는지를 규정하는 확고부동의 규칙은 없지만 참고할 만한 노하우는 있다.

 

다음 코드를 보자. 이 프로그램은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램(anagram) 그룹을 출력한다. 아나그램이란 철자를 구성하는 알파벳이 같고 순서만 다른 단어를 말한다.

즉, 'stapel'의 키는 'aelpst'가 되고 'petals'의 키도 'aelpst'가 된다.

따라서 이 두 단어는 아나그램이고, 아나그램끼리는 같은 키를 공유한다.

맵의 값은 같은 키를 공유한 단어들을 담은 집합이다. 마지막으로 이 프로그램은 맵의 values() 메서드를 통하여 아나그램 집합들을 얻어 원소 수가 문턱값보다 많은 집합들을 출력한다.

 

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 를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있다.

 

 

이제 다음 프로그램을 살펴보자.

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

위 프로그램은 어떤가? 코드를 이해하기 어려운가?

걱정 말자. 다른 사람도 마찬가지이다.

이 코드는 확실히 짧기는 하지만 읽기는 어렵다. 특히 스트림에 익숙하지 않은 프로그래머라면 더욱 그럴 것이다.

이처럼 스트림을 과용하면 프로그램이 읽거나 유지보수하기 어려워진다.

 

오히려 아래와 같이 첫 번째와 두 번째 프로그램의 절충 지점을 찾아 적절히 섞어주면 더 깔끔해 진다.

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

스트림을 전에 본 적이 없더라도 이 코드는 이해하기 훨씬 쉬울 것이다. try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻는다.

 

스트림 변수의 이름을 words로 지어 스트림 안의 각 원소가 단어 (word)임을 명확히 했다.

이 스트림의 파이프라인에는 중간 연산은 없으며, 종단 연산에서는 모든 단어를 수집해 맵으로 모은다.

그 다음으로 이 맵의 values()가 반환한 값으로부터 새로운 Steam<List<String>> 스트림을 연다.

 

이 스트림의 원소는 아나그램 리스트이다. 그 스트림들 주 원소가 minGroupSize보다 적은 것은 필터링되어 무시된다.

마지막으로 종단 연산인 forEach는 살아남은 리스트를 출력한다.

 

 

한편, 스트림으로 처리하기 어려운 일도 있다. 대표적인 예로, 한 데이터가 파이프라인의 여러 단계(stage)를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우다.

스트림 파이프라인은 일단 한 값을 다른 값에 매핑하고 나면 원래의 값은 잃는 구조이기 때문이다.

 

스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많다. 카드 덱을 초기화하는 작업을 생각해보자.

카드는 순자와 무늬를 묶은 불변 값 클래스이고 숫자와 무늬는 모두 열거 타입이라고 하자.이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제이다. 수학자들은 이를 두 집합의 데카르트 곱이라고 부른다.

 

다음은 for-each 반복문을 중첩해서 구현한 코드로, 스트림에 익숙하지 않은 사람에게 친숙한 방법이다.

pricate static List<Card> newDeck(){
	List<Card> result = new ArrayList<>();
    	for(Suit suit : Suit.values())
    		for(Rank rank : Rank.values())
        		result.add(new Card(suit, rank));
	return result;
}

 

다음은 스트림으로 구현한 코드다. 중간 연산으로 사용한 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합친다. 이를 평탄화라고도 한다. 이 구현에서는 중첩된 람다를 사용했음을 주의하자.

 

private static List<Card> newDeck(){
	return Stream.of(Suit.values())
    		.flatMap(suit ->
        		Stream.of(Rank.values())
            		.map(rank->new Card(suit, rank))) // 중첩람다
		.collect(toList());
}

어느 newDeck이 좋아 보이는가? 결국은 개인 취향과 프로그래밍 환경의 문제이다.

 

스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞은 일도 있다.
그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다.
어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다.
어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다.
스트림과 반복 중 어느쪽이 나은지 확신하기 어렵다면 둘다 해보고 더 나은 쪽을 택하라.

+ Recent posts