티스토리 뷰

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

 

자바 8에 추가된 기술 [5] Stream

자바 8에는 다양한 기술들을 살펴보려고 하는데, 그 다섯 번째는 Stream이다. 데이터를 담고 있는 저장소(컬렉션)이 아니다. 연속된 데이터를 처리하는 operation의 모임이라고 생각하면 된다. 스트

kkoon9.tistory.com

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

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

스트림의 원소들은 어디로부터든 올 수 있습니다.

대표적으로는 컬렉션, 배열, 파일, 정규표현식 패턴 매처(matcher), 난수 생성기, 혹은 다른 스트림이 있습니다.

스트림 안의 데이터 원소들은 객체 참조나 기본 타입 값입니다.

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

 

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

각 중간 연산은 스트림을 어떠한 방식으로 변환합니다.

예를 들어 각 원소에 함수를 적용하거나 특정 조건을 만족 못하는 원소를 걸러낼 수 있습니다.

중간 연산들은 모두 한 스트림을 다른 스트림으로 변환하는데, 변환된 스트림의 원소 타입은 변환 전 스트림의 원소 타입과 같을 수도 있고 다를 수도 있습니다.

종단 연산은 마지막 중간 연산이 내놓은 스트림에 최후의 연산을 가합니다.

예를 들어, 원소를 정렬해 컬렉션을 담거나, 특정 원소 하나를 선택하거나, 모든 원소를 출력할 수 있습니다.

 

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

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

이러한 지연 평가가 무한 스트림을 다룰 수 있게 해주는 열쇠입니다.

종단 연산이 없는 스트림 파이프라인은 아무 일도 하지 않는 명령어인 no-op과 같으니, 종단 연산은 필수입니다.

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

즉, 파이프라인 하나를 구성하는 모든 호출을 연결하여 단 하나의 표현식으로 완성할 수 있습니다.

파이프라인 여러 개를 연결해 표현식 하나로 만들 수도 있습니다.

 

기본적으로 스트림 파이프라인은 순차적으로 수행됩니다.

파이프라인을 병렬로 실행하려면 파이프라인을 구성하는 스트림 중 하나에서 parallel 메서드를 호출해주기만 하면 되나, 효과를 볼 수 있는 상황은 많지 않습니다. (아이템 48)

 

스트림 API는 다재다능하여 사실상 어떠한 계산이라도 해낼 수 있습니다.

하지만 할 수 있다는 뜻이지, 해야 한다는 뜻은 아닙니다.

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

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

다음 코드를 봅시다.

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

이 프로그램은 사전 파일에서 단어를 읽어 사용자가 지정한 문턱값보다 원소 수가 많은 아나그램 그룹을 출력합니다.

이 프로그램은 사용자가 명시한 사전 파일에서 각 단어를 읽어 맵에 저장합니다.

맵의 키는 그 단어를 구성하는 철자들을 알파벳순으로 정렬한 값입니다.

즉 "staple"의 키는 "aelpst"가 되고, "petals"의 키도 "aelpst"가 됩니다.

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

맵의 값은 같은 키를 공유한 단어들을 담은 집합입니다.

사전 하나를 모두 처리하고 나면 각 집합은 사전에 등재된 아나그램들을 모두 담은 상태가 됩니다.

마지막으로 이 프로그램은 맵의 values() 메서드로 아나그램 집합들을 얻어 원소 수가 문턱값보다 많은 집합들을 출력합니다.

맵에 각 단어를 삽입할 때 자바 8에 추가된 computeIfAbsent 메서드를 사용했습니다.

이 메서드는 맵 안에 키가 있는지 찾은 다음, 있으면 단순히 그 키에 매핑된 값을 반환합니다.

이처럼 computeIfAbsent를 사용하면 각 키에 다수의 값을 매핑하는 맵을 쉽게 구현할 수 있습니다.

이제 다음 프로그램을 봅시다.

앞의 코드와 같은 일을 하지만 스트림을 과하게 활용합니다.

사전 파일을 여는 부분만 제외하면 프로그램 전체가 단 하나의 표현식으로 처리됩니다.

사전을 여는 작업을 분리한 이유는 그저 try-with-resources 문을 사용해 사전 파일을 제대로 닫기 위해서입니다.

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(dictonary)) {
            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(dictonary)) {
            words.collect(groupingBy(word -> alphabetize(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);
    }
}

스트림을 전에 본 적 없더라도 이 코드는 이해하기 쉬울 것입니다.

try-with-resources 블록에서 사전 파일을 열고, 파일의 모든 라인으로 구성된 스트림을 얻습니다.

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

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

이 맵은 단어들을 아나그램끼리 묶어놓은 것으로(아이템 46), 앞선 두 프로그램이 생성한 맵과 실질적으로 같습니다.

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

이 스트림의 원소는 물론 아나그램 리스트입니다.

그 리스트들 중 원소가 minGroupSize보다 적은 것은 필터링돼 무시됩니다.

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

람다에서는 타입 이름을 자주 생략하므로 매개변수 이름을 잘 지어야 스트림 파이프라인의 가독성이 유지됩니다.
도우미 메서드를 적절히 활용하는 일의 중요성은 일반 반복 코드에서보다는 스트림 파이프라인에서 훨씬 큽니다.

alphabetize 메서드도 스트림을 사용해 다르게 구현할 수 있습니다.

하지만 그렇게 하면 명확성이 떨어지고 잘못 구현할 가능성이 커집니다.

심지어 느려질 수도 있습니다.

자바가 기본 타입인 char용 스트림을 지원하지 않기 때문입니다.

char 값들을 처리할 때에는 스트림을 삼가는 편이 낫습니다.

스트림을 처음 쓰기 시작하면 모든 반복문을 스트림으로 바꾸고 싶은 유혹이 있지만, 서두르지 않는 게 좋습니다.

스트림은 바꾸는 게 가능할지라도 코드 가독성과 유지보수 측면에서는 손해를 볼 수 있기 때문입니다.

중간 정도 복잡한 작업에도 스트림과 반복문을 적절히 조합하는 게 최선입니다.

그러니 기존 코드는 스트림을 사용하도록 리팩터링하되, 새 코드가 더 나아 보일 때에만 반영합시다.

이번 아이템에서 보여준 프로그램처럼 스트림 파이프라인은 되풀이되는 계산을 함수 객체로 표현합니다.

반면 반복 코드에서는 코드 블록을 사용해 표현합니다.

그런데 함수 객체로는 할 수 없지만 코드 블록으로는 할 수 있는 일들이 있습니다.

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

계산 로직에서 위의 일들을 수행해야 한다면 스트림과는 맞지 않을 것입니다.

반대로 다음 일에는 스트림이 아주 안성맞춤입니다.

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

이러한 일 중 하나를 수행하는 로직이라면 스트림을 적용하기에 좋은 후보입니다.

 

한편, 스트림으로 처리하기 어려운 일도 있습니다.

대표적인 예로, 한 데이터가 파이프라인의 여러 단계를 통과할 때 이 데이터의 각 단계에서의 값들에 동시에 접근하기는 어려운 경우입니다.

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

원래 값과 새로운 값의 쌍을 저장하는 객체를 사용해 매핑하는 우회 방법도 있지만, 그리 만족스러운 해법은 아닐 것입니다.

매핑 객체가 필요한 단계가 여러 곳이라면 특히 더 그렇습니다.

이런 방식은 코드 양도 많고 지저분하여 스트림을 쓰는 주목적에서 완전히 벗어납니다.

가능한 경우라면, 앞 단계의 값이 필요할 때 매핑을 거꾸로 수행하는 방법이 나을 것입니다.

예를 들어 처음 20개의 메르센 소수를 출력하는 프로그램을 작성해봅시다.

메르센 수는 2^p - 1 형태의 수입니다.

여기서 p가 소수이면 해당 메르센 수도 소수일 수 있는데, 이 때의 수를 메르센 소수라 합니다.

이 파이프라인의 첫 스트림으로는 모든 소수를 사용할 것입니다.

 

다음 코드는 (무한) 스트림을 반환하는 메서드입니다.

BigInteger의 정적 멤버들은 정적 임포트하여 사용한다고 가정합니다.

static Stream<BigInteger> primes() {
    return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}

메서드 이름 primes는 스트림의 원소가 소수임을 말해줍니다.

스트림을 반환하는 메서드 이름은 이처럼 원소의 정체를 알려주는 복수 명사로 쓰기를 강력히 추천합니다.

스트림 파이프라인의 가독성이 크게 좋아질 것입니다.

이 메서드가 이용하는 Stream.iterate라는 정적 팩터리는 매개변수를 2개 받습니다.

  • 스트림의 첫 번째 원소
  • 스트림에서 다음 원소를 생성해주는 함수

이제 처음 20개의 메르센 소수를 출력하는 프로그램을 봅시다.

public static void main(String[] args) {
    primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
        .filter(mersenne -> mersenne.isProbablePrime(50))
        .limit(20)
        .forEach(System.out::println);
}

이 코드는 앞서의 설명을 정직하게 구현했습니다.

소수들을 사용해 메르센 수를 계산하고, 결과값이 소수인 경우만 남긴 다음 결과 스트림의 원소 수를 20개로 제한해놓고, 작업이 끝나면 결과를 출력합니다.

이제 우리가 각 메르센 소수의 앞에 지수(p)를 출력하길 원한다고 해봅시다.

이 값은 초기 스트림에만 나타나므로 결과를 출력하는 종단 연산에서는 접근할 수 없습니다.

하지만 다행히 첫 번째 중간 연산에서 수행한 매핑을 거꾸로 수행해 메르센 수의 지수를 쉽게 계산해낼 수 있습니다.

지수는 단순히 숫자를 이진수로 표현한 다음 몇 비트인지를 세면 나오므로, 종단 연산을 다음처럼 작성하면 원하는 결과를 얻을 수 있습니다.

.forEach(mp -> System.out.println(mp.bitLength() + " : " + mp));

스트림과 반복 중 어느 쪽을 써야 할지 바로 알기 어려운 작업도 많습니다.

카드 덱을 초기화하는 작업을 생각해봅시다.

카드는 숫자(rank)와 무늬(suit)를 묶은 불변 값 클래스이고, 숫자와 무늬는 모두 열거 타입이라 해봅시다.

이 작업은 두 집합의 원소들로 만들 수 있는 가능한 모든 조합을 계산하는 문제입니다.

수학자들은 이를 두 집합의 데카르트 곱이라고 부릅니다.

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

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

다음은 스트림으로 구현한 코드입니다.

중간 연산으로 사용한 flatMap은 스트림의 원소 각각을 하나의 스트림으로 매핑한 다음 그 스트림들을 다시 하나의 스트림으로 합칩니다.

이를 평탄화(flattening)라고도 합니다.

이 구현에서는 중첩된 람다를 사용했음에 주의합시다.

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

결국은 개인 취향과 프로그래밍 환경의 문제입니다.

스트림 방식이 조금 더 친숙한 프로그래머라면 두 번째를, 그렇지 않다면 첫 번째 방식을 쓰는게 좋습니다.

정리

  • 스트림을 사용해야 멋지게 처리할 수 있는 일이 있고, 반복 방식이 더 알맞는 일도 있다.
  • 그리고 수많은 작업이 이 둘을 조합했을 때 가장 멋지게 해결된다.
  • 어느 쪽을 선택하는 확고부동한 규칙은 없지만 참고할 만한 지침 정도는 있다.
  • 어느 쪽이 나은지가 확연히 드러나는 경우가 많겠지만, 아니더라도 방법은 있다.
  • 스트림과 반복 중 어느 쪽이 나은지 확신하기 어렵다면 둘 다 해보고 더 나은 쪽을 택하라.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
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 28 29 30
글 보관함