2 minute read

Stream API

다량의 데이터 처리 작업을 위해 Java 8부터 추가되었다.

Stream API는 스트림과 스트림 파이프라인의 2가지 추상 개념으로 구성되며, 메서드 연쇄를 지원하는 fluent API다.

스트림

데이터 원소의 유한한 / 무한한 시퀀스

  • 원소들은 어디에서든 올 수 있다. ex) 컬렉션, 배열, 파일, regex matcher, 난수 생성기, 다른 스트림 등…
  • 내부 테이터 원소들은 객체 참조나 기본 타입 값(int, long, double)이다.

스트림 파이프라인

스트림 원소들로 수행하는 연산 단계

  • 소스 스트림에서 시작해, 하나 이상의 중간 연산을 거쳐 종단 연산으로 끝난다.
  • 각 중간 연산은 한 스트림을 다른 스트림으로 변환할 수 있다.
  • 지연 평가 : 평가는 종단 연산이 호출될 때 이루어진다.
  • 순차적으로 수행된다.

사용 예시

제대로 사용하면 프로그램이 짧고 깔끔해지지만, 잘못 사용하면 가독성이 떨어지고 유지보수 또한 힘들다.

다음은 반복문을 사용해 File I/O로 Anagram을 구현한 예제이고, 출력은 다음과 같다.

public class Anagrams {  
    public static void main(String[] args) {  
        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);  
            }  
        } catch (FileNotFoundException e) {  
            e.printStackTrace();  
        }  
  
        for(Set<String> group : groups.values()) {  
            if(group.size() >= minGroupSize) {  
                System.out.println(group.size() + ": " + group);  
            }  
        }  
    }  
  
    static String alphabetize(String s) {  
        char[] a = s.toCharArray();  
        Arrays.sort(a);  
        return new String(a);  
    }  
}

동일한 프로그램을 Stream API를 사용해 구현할 수 있다.

다음은 Stream을 과하게 사용한 예시이다.

public class StreamAnagram {
    public static void main(String[] args) throws IOException {
        File dectionary = new File(args[0]);
        int minGroupSize = Integer.parseInt(args[1]);

// depth가 2 이상이 될 경우, 코드가 장황해지고 이해하기 어렵다.
        try(Stream<String> words = Files.lines(dectionary.toPath())) {
            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 NewAnagram {  
  
    public static void main(String[] args) throws IOException {  
        File dectionary = new File(args[0]);  
        int minGroupSize = Integer.parseInt(args[1]);  

// depth가 최대 2를 넘어가지 않도록 코드를 작성하였다.
// alphabetize() 함수를 사용해 가독성을 높였다.
        try(Stream<String> words = Files.lines(dectionary.toPath())) {  
            words.collect(groupingBy(word -> alphabetize(word)))  
                    .values().stream()  
                    .filter(group -> group.size() >= minGroupSize)  
                    .forEach(group -> System.out.println(group.size() + ": " + group));  
        }  
    }  
}

반복문 vs Stream

반복문은 반복문 내의 코드 블록을 사용해 표현하고, Stream 파이프라인은 되풀이되는 계산을 함수 객체 (주로 람다 혹은 메서드 참조)로 표현한다.

반복문에서는 다음과 같은 기능을 수행할 수 있다.

  • 범위 내 지역변수를 읽거나 수정할 수 있다.
    • 람다에서는 final이거나 사실상 final에 가까운 변수만 읽어올 수 있다. 수정은 불가능하다.
  • return, break, continue 등을 사용할 수 있다.
    • 람다 혹은 메서드 참조에서는 불가능하다.

반면 Stream은 원소들의 시퀀스에서 다음과 같은 기능을 한다.

  • 필터링
  • 일관되게 변환
  • 하나의 연산을 사용해 결합 (최대•최솟값)
  • 컬렉션에 모음
  • 특정 조건 만족하는 원소 찾기

결론

Stream API와 반복문 각각의 장단점이 있으므로 이에 맞게 구현 방식을 선택하자. 어떤 방식으로 구현해도 상관 없는 경우에는, 보다 이해도가 높고 선호하는 방식을 따르자.

Leave a comment