본문 바로가기
Java/Stream

Java 스트림(Stream)이란? Stream API

by wone_yam 2024. 1. 9.

자바에서는 많은 양의 데이터를 그룹화하고 처리하는 우리의 편리를 위해 컬렉션(Collections)이라는 기능을 인터페이스로 정의하고 제공하고 있다. 즉 우리가 자주 사용하는 자료구조를 미리 정의하고 사용하기 편리하게 제공된다.

 

하지만 SQL쿼리와는 다르게 반복자, 누적자 등을 사용하여 관리하기 때문에 다소 코드의 간결성, 가독성이 떨어질뿐더러 데이터를 가공하고 적용시킬 때 구체적인 필터링 조건(반복자, 누적자를 사용해야 함)을 명시해야 한다. 이에 반해 SQL쿼리는

SELECT name FROM students WHERE grade > 80;

 

위의 예시처럼 쿼리문을 어떻게 방식으로 구현해야 할지 명시할 필요가 없으며 자동으로 구현해 준다. SQL쿼리 자체에서 우리가 기대하는 것이 무엇인지 직접 표현이 가능하며 매우 간단하며 직관적이다.

 

프로그래머의 귀중한 시간을 절약하고 편리한 삶을 누리기 위해 자바 언어 설계자들이 자바8 API에 스트림이란 기능을 추가했다. 그래서 스트림 API를 사용하면 선언형으로 코드를 구현할 수 있다. 루프와 if 조건문 등의 제어문을 사용해서 어떻게 동작을 구현할지 고민을 해야 했다면 "성적이 80점 이상인 학생만 선택해 줘"와 같은 동작의 수행만을 지정해 주면 된다.

 

1. 스트림이 뭐야?

 스트림이란 함수형 프로그래밍 스타일을 적용하여 filter, map, reduce와 같은 연산을 지원하며 컬렉션, 배열, I/O 자원을 일련의 연산을 적용하고 원하는 형태로 가공해 준다. 스트림을 통해 컬렉션, 배열 등을 SQL쿼리와 같이 간단명료하게 코드를 작성할 수 있게 된다.

 

2. 스트림의 특징

스트림에는 중요한 특징이 존재한다.

1. 스트림은 한번 소비하면 끝

2. 내부반복을 사용

 

1. 스트림은 한 번 탐색이 되면 요소가 소비가 된다. 그렇기 때문에 스트림은 한 번만 탐색이 가능하다.

List<String> name = List.of("paul","john","kelly");
Stream<String> stream = name.stream();
stream.forEach(System.out::println);
stream.forEach(System.out::println);

위의 코드를 실행하게 되면 java.lang.IllegalStateException이 발생하면서 이미 스트림이 소비되었다고 한다.

List<String> name = List.of("paul","john","kelly");
        
Stream<String> stream = name.stream();
stream.forEach(System.out::println);
        
Stream<String> s = name.stream();
s.forEach(System.out::println);

한 번 탐색한 요소를 다시 탐색하고 싶으면 새로운 스트림을 생성하면 된다.

 

 

2. 컬렉션 인터페이스를 사용하려면 개발자가 for, while 반복을 통해 직접 요소를 반복해야 한다. 반면 스트림 라이브러리는 반복을 알아서 처리하고 결과 스트림값을 어딘가에 저장해 주는 내부반복을 통해 반복이 된다.

List<String> name = List.of("paul","john","kelly","kevin","tom");
List<String> startKname = new ArrayList<>();

for(int i = 0; i < name.size(); i++){
    if(name.get(i).startsWith("k")){
        startKname.add(name.get(i));
    }
}

 

해당 코드를

List<String> name = List.of("paul","john","kelly","kevin","tom");
List<String> startKname = name.stream().filter(el -> el.startsWith("k")).toList();

내부반복을 통해 이렇게 줄일 수 있다. 엄청 직관적이고 간단하고 가독성도 좋은 거 같다. 평소 for를 자주 썼던 나로서 혁명 같은 일이다..

 

사실 이런 코드의 간결성 직관성도 있지만 내부반복이 최적화가 더 잘 되어 있고 특히나 병렬성 구현을 자동으로 선택해 준다. for문을 이용한 외부반복의 경우 병렬성을 스스로 관리해야 한다. 병렬성을 포기하든, synchronized를 사용하든 하지만 스트림을 통한 내부반복은 알.아.서 잘해준다.

 

3. 스트림 연산

스트림 연산에는 중간연산과 최종연산으로 나뉘는데 중간연산의 반환값은 다른 스트림을 반환한다. filter, map, reduce등이 중간연산에 해당하고 collect, count 등이 최종연산에 해당한다. 중간연산에서 중요한 부분은 바로 게으름이다 중간 연산은 최종연산이 되기 전까지 연산을 계속 미루다가 중간연산을 합친 다음 중간연산을 최종연산으로 한 번에 처리한다.

 

4. 스트림 게으름 연산 및 최적화 효과

스트림의 게으름 연산 때문에 얻을 수 있는 최적화 효과가 있다. 바로 쇼트서킷과 루프 퓨전이다. 우선 게으름 연산이란 연산을 계속 미루다가 최종연산 때 한 번에 처리하는 것을 말하는데 결과가 필요할 때까지 연산을 늦춤으로써 우리는 실행을 최적화시킬 수 있다는 이점을 얻게 된다. JVM은 연산을 수행하기 전에 스트림 파이프라인이 어떤 중간연산과 최종연산으로 구성되어 있는지를 파악한 뒤 어떤 방식으로 최적화를 진행할지를 미리 계획하고, 그 계획에 따라 스트림의 개별 요소에 대한 연산을 수행한다. 

 

루프퓨전(loop fusion)
루프 퓨전이란 파이프라인에서 연속적으로 체이닝된 복수의 스트림 연산을 하나의 연산 과정으로 병합시키는 걸 의미한다. 스트림이 내부적으로 여러 중간연산을 하나의 연산으로 병합하는 최적화를 진행한다.

 

쇼트서킷(short circuit)
쇼트 서킷이란 불필요한 연산을 생략해 성능을 개선하는 연산 방식을 말한다. 전체 스트림을 처리하지 않아도 결과를 반환할 수 있으며 이러한 상황을 쇼트서킷이라고 부른다. 예를 들어, limit의 경우 모든 연산을 수행하지 않아도 원하는 요소를 찾았을 경우 즉시 결과를 반환받을 수 있다. 이러한 전략 덕분에 우린 최적화된 연산을 수행할 수 있게 된다.

allMatch, noneMatch, findFirst, findAny, limit등이 그 쇼트서킷의 예시이다.

 

 

 

어떤 연산을 추가적으로 더 추가하고 원하는 형태로 가공하기 위해서 현재 수행하고 있는 연산의 결과를 예상하면서 연산을 쭉 따라가는 것을 추천한다!

 

 

 

참고자료

- 모던 자바 인 액션