Programming/Java

자바 기초부터 모던 자바까지 - 스트림(stream)편

KOOCCI 2022. 7. 16. 23:33

목표 : 람다를 기준으로 스트림 문법을 이해한다.


이전 포스팅으로, JAVA가 인터페이스를 통해 람다식을 어떻게 만들어 왔는지 히스토리를 보았다.

그리고, java에서는 앞서 배운 함수형 인터페이스의 주요 표현들인 Consumer, Function, Predicate, Supplier들이 어떻게 사용되어 지는지 Stream을 보면서 이해해보도록 하자.

 

Stream(스트림) 부터 알아보자.

보통 Stream(스트림)이라는 단어는 어떤 데이터의 흐름을 뜻한다.

특히, java.io에서는 I/O 프로그래밍을 사용하는 클래스 명에 Stream이라는 단어를 사용하고 있다.

그러나, 지금 배우고자 하는 Stream은 주로 컬렉션 프레임워크나 이와 유사한 형태의 데이터를 처리할 때 도움을 줄 수 있는, 자바 8에서 새롭게 제안한 API다.

즉, Stream API의 주 목적람다 표현식과 메서드 참조 등의 기능과 결합해서 매우 복잡하고 어려운 데이터 처리 작업을 쉽게 처리하도록 도와주는 것에 있다.

Integer[] intArr = new Integer[] {1,2,3,4,4,4,5,7,8,8,8,8,8};
List<Integer> numberList = Arrays.asList(intArr);

for(int i = 0; i < numberList.size(); i++) {
    System.out.println(numberList.get(i));
}

이 코드의 문제점은 인덱스 변수인 i를 사용한 점이다.

물론, 초창기 방식이지만 여전히 보편적으로 많이 사용하는 방법이다.

다만, 매번 반복되는 코드이면서도 명확성이 흐려지고, List의 끝지점을 알기 위해서는 항상 i가 List 크기와 비교하는 작업이 선행된다.

for(Iterator<Integer> iter = numberList.iterator(); iter.hasNext();) {
    System.out.println(iter.next());
}

조금 깔끔해졌다.

Iterator를 통해 별도로 변숫값을 증가 시키는 로직을 삭제하고, next 메서드를 활용하고 있다.

다만, 매번 Iterator 객체를 생성하고 for 루프를 작성해야 하는 번거로움은 여전히 유사하게 남아있다.

for(Integer value : numberList) {
	System.out.println(value);
}

for each 구문은 자바5에서 새롭게 도입된 것으로 훨씬 깔끔해졌다.

numberList.forEach(System.out::println);

마지막으로 스트림 API를 통해 바로 결과를 출력하는 방식이다.

단지 메서드 참조로 println을 전달하였다.

 

스트림을 이용한 컬렉션 프레임워크의 가장 큰 특징은 기존 컬렉션 프레임워크처럼 개발자가 정의한 외부 코드로 for 루프를 실행하는 것이 아니라 스트림 내부에서 개발자가 정의한 코드가 반복적으로 실행된다는 것을 의미한다.

따라서, 개발자가 별도로 for 루프 구문을 만들고 인덱스 변수를 처리하거나 Iterator 객체를 생성하는 반복적이고 에러 확률을 높이는 수고를 하지 않아도 된다.

스트림 인터페이스

java.util.stream에는 자바 8의 스트림에 대해 정의되어 있다. 대부분 interface로 되어 있고, 몇가지 Util성 클래스가 있는 걸 볼 수 있다.

 

스트림 API가 주로 인터페이스로 구성되어 있는 이유실질적인 구현체는 데이터의 원천에 해당하는 컬렉션 프레임워크 기반의 클래스에 위임하고 있기 때문이다.

또한, 앞서 말했듯이 스트림은 람다 표현식이나 메서드 참조를 통해 구체적인 구현체를 전달받아 동작하기 때문에 함수형 인터페이스와 관련이 있다.

 

조금 세부적으로 들어가면, 스트림에서 가장 기본이 되는 인터페이스는 BaseStream이다.

선언문 부분만 간단히 보면, 다음과 같다.

public interface BaseStream<T, S extends BaseStream<T, S>> extends AutoCloseable {
    Iterator<T> iterator(); // 스트림 항목에 대한 인덱스를 Iterator 객체로 리턴
    Spliterator<T> spliterator(); // 스트림이 포함하고 있는 항목들을 분할하기 위한 Spliterator 객체 리턴
    boolean isParallel(); // 스트림이 병렬로 실행되었는지 여부를 리턴
    S sequential(); // 절차적으로 처리 가능한 스트림을 리턴
    S parallel(); // 병렬 처리 가능한 스트림을 리턴
    S unordered(); // 데이터가 정렬되지 않은 스트림을 리턴
    @Override
    void close(); // 스트림 종료. 해당 스트림의 파이프라인과 연결되어 있는 모든 핸들러를 종료
}
  • T: 스트림에서 처리할 데이터의 타입 (요소의 타입)
  • S: BaseStream을 구현한 스트림 구현체. 스트림을 자동으로 종료하기 위한 AutoCloseable도 구현되어야 함

BaseStream은 일반적으로 개발자가 쓰지 않고, 이를 상속한 Stream 인터페이스를 주로 사용한다.

그리고 자바 API 문서에는 이 Stream 인터페이스에 대한 내용은 설명이 많고, 제공하는 메서드도 많다.

그 중 대표적인 것만 먼저 보도록 하자.

메서드 설명
concat 입력 파라미터로 전달된 두 개의 스트림을 하나의 스트림으로 합친다. 합쳐진 스트림의 데이터는 첫번째 스트림을 먼저 처리하고 두번째 스트림을 뒤이어서 처리한다.
collect 스트림의 항목들을 컬렉션 프레임워크 기반의 객체로 리턴한다. 주로 List 객체로 리턴해서 많이 사용한다.
count 스트림에 포함된 항목의 수를 리턴한다.
distinct 중복된 항목을 제외하고 스트림 객체를 만들어서 리턴한다. 이 기능을 사용하기 위해서는 항목의 equals 메서드가 정확히 정의되어 있어야 한다.
filter 스트림 항목을 필터링한다. 필터 조건은 Function 인터페이스를 위한 람다 표현식 혹은 메서드 참조를 전달한다.
forEach 스트림 연산을 종료하고 최종 결과를 처리하기 위한 메서드다. 종료 연산의 일종이다.
limit 특정 개수만큼만 항목을 처리한다. 제한된 개수 한도에서 새로 생성한 스트림을 리턴한다.
reduce 람다 표현식을 기반으로 데이터를 소모하고 그 결과를 리턴하는 최종 연산이다. 다른 메서드가 특정한 기능에 제한적으로 지원되는 반면, 이 메서드는 다양하게 활용될 수 있다.
skip 스트림 처리중 특정한 숫자만큼 항목을 건너 뛴다.
sorted 스트림에 포함된 항목을 정렬한다.
toArray 스트림의 항목들을 배열로 만들어서 리턴한다.

주의할 점은 BaseStream 인터페이스와 Stream 인터페이스에 정의된 메서드들의 리턴 타입이 대부분 Stream(정확히는 제네릭에서 정의한 Stream 인터페이스의 구현 객체)이거나 void 인 것이다.

  • 리턴 타입이 Stream인 메서드들은 리턴 결과를 통해, 데이터를 중간에 변형하거나 필터링한 후 다시 Stream 객체를 만들어서 리턴한다. 이러한 작업을 반복적으로 할 수 있고, 중간 연산 메서드라고 한다.
  • 리턴 타입이 void인 메서드들은 주로 Stream을 이용해서 데이터를 최종적으로 소비한다. 이를 최종 연산 메서드라고 한다.

그리고 중요한 점은 Stream 객체는 불변성이 특징이다.

즉, Stream 객체의 메서드 호출 결과로 리턴 받은 Stream 객체는 원천 데이터를 수정한 것이 아니라 완전히 새롭게 생성한 데이터다.

이렇게 만들어진 이유는 중간 연산 작업과 함께 병렬 처리가 가능하기 때문에 데이터 정합성을 확보하기 위해서이며, 스트림 API 뿐아니라, 자바8 이후에 소개된 대부분의 API에서 공통적으로 보이는 특징이다.

 

스트림 연산 파이프라인

스트림의 중간 연산자와 최종 연산자는 함수형 인터페이스를 기반으로 하고 있어서, 개발자가 람다 표현식으로 동작을 정의할 수 있다는 장점이 있다.

 

다음 이미지를 보자.

스트림 연산
스트림 연산 파이프라인

스트림 API 기반의 연산 작업을 선호하는 이유는 리눅스의 파이프라인과 유사하게 기능을 조합할 수 있다는 점이다.

그리고, 그 과정에서 우리가 앞서 배운 함수형 인터페이스들이 기반이 되고 있는 것을 볼 수 있다.