스트림

스트림(stream)은 자바 API에 새로 추가된 기능으로 스트림을 이용하면 서술적(혹은 선언적)으로 컬렉션(Collections) 데이터를 처리할 수 있는 방법을 제공합니다. 단순하게 말해서 컬렉션 내부 순환을 손쉽게 처리할 수 있습니다. 스트림은 filter, sorted, map, collect 같은 다양한 빌딩 블록 연산(building block operation)을 연결해서 복잡한 데이터 처리가 가능한 파이프라인(pipeline)을 만들어서 문제를 해결할 수 있습니다. filter는 고수준 빌딩 블록(high-level building block)으로 구성되어 있으므로 특정 스레딩 모델에 제한되지 않고 자유롭게 어떤 상황에서든 사용할 수 있습니다.

컬렉션과 마찬가지로 스트림은 특정 요소 형식으로 이루어진 '값 집합'의 인터페이스를 제공하며 컬렉션은 자료구조입니다. 컬렉션은 시간과 공간의 복잡성과 관련된 요소 저장 및 접근 연산이 주를 이루는 반면, 스트림은 계산식이 주를 이루고 있습니다. 대부분의 스트림 연산은 스트림 연산끼리 연결해서 커다란 파이프라인을 구성할 수 있도록 스트림을 반환(Stream<T>)하며, 그 덕분에 지연(laziness), 쇼트서킷(short-circulting) 같은 최적화 결과를 얻을 수 있습니다.

스트림은 컬렉션, 배열, I/O 등의 데이터를 제공하는 곳에서 시작해서 데이터를 소비(consume)합니다. 정렬된 컬렉션으로 스트림을 생성하면 정렬이 그대로 유지되기 때문에 데이터를 조작할 때 스트림 연산은 순차적으로 또는 병렬로 실행할 수 있습니다. 반복자를 이용해서 명시적으로 반복하는 컬렉션과 달리 스트림은 내부 반복을 지원합니다. 반복자와 마찬가지로 스트림도 한 번만 탐색 가능하며 한 번 탐색된 요소를 다시 탐색하려면 초기 데이터 소스에서 새로운 스트림을 만들어야 합니다.

스트림 연산

컬렉션의 주체는 데이터이고 스트림의 주체는 계산이라 할 수 있는데, 데이터를 언제 계산하느냐가 컬렉션과 스트림의 가장 큰 차이점이라 할 수 있습니다. 컬렉션의 모든 요소는 컬렉션에 추가하기 전에 계산되어야 하는 반면, 스트림은 요청할 때 계산하는 고정된 자료구조라 할 수 있습니다(스트림에 요소를 추가하거나 스트림에 요소를 제거할 수 없습니다).

중간 연산

중간 연산(intermediate operation)은 계산결과로 다른 스트림을 반환하기 때문에 다양한 중간 연산을 연결해서 질의를 만들 수 있습니다. 중간 연산의 중요한 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않는 지연(lazy) 연산을 수행합니다. 대표적인 중간 연산인 filter는 람다 표현식을 인수로 받아 스트림에서 특정 요소를 제외시킵니다. map은 람다 표현식을 사용해서 다른 요소로 변환하거나 특정 정보를 추출하는데 사용합니다. limit는 정해진 개수 이상의 요소가 스트림에 저장되지 못하게 스트림 크기를 조정합니다. distinct(), skip(), flatMap(), sorted()도 스트림의 중간 연산입니다.

최종 연산

최종 연산(terminal operation)은 스트림 파이프라인에서 결과를 도출합니다. 최종 연산의 종류는 Collect(CollectionCollector, collect를 헷갈리지 않도록 주의!), anyMatch, noneMatch, allMatch, findAny, findFirst, forEach, Reduce, count 등이 사용됩니다.

명령형 v.s. 서술적(혹은 선언적)

스트림을 사용하기전에 Java 코드를 작성하는 스타일에 대해서 알아보도록 하겠습니다. 많은 교재에서 명령형에 비해서 서술적 코드는 훨씬~~(정말임?)~~ 읽기 쉽고 직관적이라 합니다. 서술적 스타일로 코드를 작성하면 가변 변수(mutable variable)를 사용하지 않고 반복코드가 외부로 드러나지 않으며 무엇보다 코드를 통해 의도를 설명할 수 있다는 장점은 "향후 코드의 유지보수를 더 쉽게 만들 수 있다"고(!?) 합니다. 아래 예제를 보면 서술적 코드가 명령형 코드보다 읽기 편하고 이해가 쉬운건 쉽게 받아들일 수 있습니다.


// 명령형(Imperative)
public static void findGundam(final List<MobileSuit> mobileSuitList) {
    boolean found = false;
    for (MobileSuit mobileSuit : mobileSuitList) {
        if (mobileSuit.equals("RX-78-2")) {
            found = true;
            break;
        }
    }
    log.info("Found Gundam?:" + found);
}

// 서술적(Declarative)
public static void findGundam(final List<String> mobileSuitList) {
    log.info("Found Gundam?:" + mobileSuitList.contains("RX-78-2"));
}

약간 더 복잡한 예를 들어, "파일럿의 계급이 아무로 보다 높은 파일럿 연봉의 합을 계산하라" 라는 코드를 서술적 스타일로 작성하면 아래와 같은 형태로 작성할 수 있습니다. 이제, 우리도 이런 '우아한' 방법을 배워보도록 하자!

pilotList.stream()
    .filter(pilot -> pilot.getRank() > amuro.getRank())
    .map(pilot -> pilot.getIncome())
    .reduce(BigDecimal.ZERO, BigDecimal::add);

리스트를 사용한 이터레이션의 변화

일단 불변(immutable) 컬렉션을 하나 생성하도록 하겠습니다.


// 불변 컬렉션 작성
List<String> cities = Arrays.asList("London", "Paris", "New York", "Tokyo", "Beijing");

누구나 사랑하는 for를 사용해서 해당 컬렉션을 출력해 보겠습니다. 누군가는 이런 패턴을 자해하는 패턴(self-inflicted wound pattern)이라고 부르긴 하지만, 초보 개발자가 가장 흔하게 사용하는 방법입니다.


// 누구나 사랑하는 for
for (for i=0; i < cities.size(); i++) log.info(cities.get(i));

요즘은 21세기니까 나름 깔끔한 방법을 사용해 본다면 아래와 같은 코드를 작성할 수 있습니다. 아래 코드는 내부적으로 iterator 인터페이스를 사용하고 hasNext()next() 메서드를 호출합니다.


// 새로운 모습으로 나타난 for
for (String city : cities) log.info(city);

어째든 for 사용하는 방법은 우리가 얻고자 하는 것과 하려는 방법을 혼합하여 사용합니다. 명시적으로 이터레이션을 제어 할 수 있고, 어느 원소에서 시작해서 어느 원소에서 끝날지 알 수 있습니다. 차이점이라면 새로운 형태의 for는 내부적으로 iterator 메서드를 사용한다는 점입니다. 대부분의 책에서 for문을 사용하는 방법은 JDK 8에 사용하지 않는 것이 좋다는 의견을 표명합니다.

JDK 8에서 Iterable 인터페이스에는 forEach()라는 메서드를 제공합니다. 이 메서드는 Consumer<T> 타입의 파라미터를 필요로 합니다. 앞에서 설명했듯이 Consumer<T> 타입은 accept()라는 추상 메서드를 정의하고 있으며, 해당 인스턴스는 accept()를 사용해서 자원에 접근합니다.


// forEach를 사용한 방법
cities.forEach(new Consumer<String>() {
    public void accept(final string city) {
        log.info(city);
    }
});

forEach() 메서드는 Consumer<T> 타입의 익명 클래스 인스턴스를 파라미터로 전달하고, 각 엘리먼트에 대해 주어진 accept() 메서드를 호출하고 원하는 작업을 수행합니다. JDK 8에서 도입한 함수 인터페이스를 사용한다는 점에서 굉장히 매력적으로 보이지만, 코드양이 많이 늘어났고 직관적이지 않습니다. 앞서 서술적 형태가 좋다고 칭찬을 한 것에 비해서 이런 형태의 코드가 좋을리 없어 보입니다. 대부분의 컬렉션이 Iterable 인터페이스를 구현하고 있기 때문에 forEach()를 잘 활용한다면 단일화된 형태의 반복구문을 작성할 수 있다는 약간의 장점은 생각해 볼 수 있습니다.

forEach()를 사용한 코드를 람다 표현식을 사용해서 수정하면 아래와 같은 형태로 구성할 수 있습니다.


// forEach와 람다 표현식
cities.forEach((final String city) -> log.info(city) )

forEach() 메서드는 람다 표현식 혹은 코드 블록을 인수로 받는 고차 함수입니다. 컴퓨터 프로그래밍에서 함수형 프로그래밍이란 함수나 메서드가 수학의 함수처럼 동작함을 의미합니다. 쉽게 말해서, 부작용 없이 함수가 동작함을 의미한다는 뜻 입니다. 함수형 언어 프로그래머는 함수형 프로그래밍이라는 용어를 좀 더 폭넓게 사용합니다. 즉, 함수를 마치 일반값처럼 사용해서 인수로 전달하거나, 결과로 반환받가거나, 자료 구조에 저장할 수 있음을 의미합니다. 이렇게 함수를 일반값처럼 취급 할 수 있는 함수를 일급 함수(first-class function)이라고 합니다. JDK 8이 이전 버전과 확연하게 구별되는 지점이 일급 함수를 지원한다는 점입니다.

스트림을 사용하면 기존의 자바의 문법보다 읽기 좋고, 직관적인 코드를 작성할 수 있습니다. 좀 더 자세한 내용은 자바 8 인 액이나 Functional Programming in Java 8 등을 참고하시면 좋을 듯 합니다.

참고