티스토리 뷰

Java

Java 8부터 지원되는 Stream API

파미페럿 2021. 9. 14. 15:44

스트림(Stream)이란?

FileInputStream과 같은 I/O 스트림과는 다른 개념이다.

(I/O 스트림은 데이터 가져오기와 내보내기를 하는 일종의 통로 역할을 하는 것이다.)

 

스트림은 간단하게 한 줄로 요약하면 데이터 집합을 읽는 객체라고 생각하면 된다.

데이터들을 모두 읽는 객체인만큼 데이터들을 다루는데 공통적으로 쓰일만한 기능들(정렬, 필터링, 반복문 등)을 가지고 있다.

list.stream().forEach(System.out::println);

 

참고로 스트림은 자바 8부터 지원하고 있다.

 

 

스트림(Stream) 특징

스트림은 Stream이라는 별도 객체로 기능을 이용할 수 있으며 3가지의 큰 특징이 있다.

이 특징들 때문에 컬렉션 함수를 사용하지 않고 스트림을 사용한다.

 

 

1. 데이터 소스를 변경하지 않는다.

우선 스트림은 데이터를 읽는 것이므로 원본 데이터, 즉 데이터 소스를 변경하지 않는다.

스트림 연산을 통해서 아무리 데이터를 조작한다 해도 스트림에서는 데이터를 변경하지 않는다. (데이터 소스에 직접적으로 CRUD를 하지 않는 이상)

 

예를 들어 아래와 같이 'cat', 'dog', 'rabbit', 'elephant' 순으로 텍스트가 들어가 있는 리스트가 있다고 하자. 그것을 스트림 연산 중 하나인 sorted()를 이용해서 정렬을 한 후 원래 리스트와 정렬을 한 리스트를 출력했을 때 원래 리스트의 데이터의 순서는 그대로인 것을 확인할 수 있다.

List<String> list = Arrays.asList(new String[]{"cat", "dog", "rabbit", "elephant"});

List<String> list2 = list.stream().sorted().collect(Collectors.toList());

System.out.println(list);
System.out.println(list2);

 

 

이러한 특성 때문에 자바 코딩을 할 때 스트림을 사용한다.

코딩을 할 때 원본 컬렉션을 수정하지 않고 원본 컬렉션을 통해 새로운 컬렉션을 만들어내야할 때가 많다. (원본 데이터는 유지하는 방향으로)

그럴 때 원본 데이터를 깊은 복사 할 필요 없이 또는 번잡하게 반복문을 구현할 필요 없이 스트림 연산을 사용해서 좀 더 쉽고 간편하게 새로운 컬렉션을 만들어낼 수 있는 것이다.

 

 

2. 일회용이다.

이 것은 스트림을 처음 사용하거나 사용하는데 익숙하지 않은 사람들이 실수할 수 있는 스트림의 특징이다.

스트림은 데이터를 모두 읽고나면 사라지는 일회용이다.

즉 아래와 같이 아예 스트림 객체를 만들어서 그것을 한 번 사용한 후 다시 사용했을 경우 컴파일에서는 해당 스트림 객체가 있으므로 에러가 발생하지 않지만 java.lang.IllegalStateException: stream has already been operated upon or closed 런타임 에러가 발생한다.

...

Stream<String> listStream = list.stream();
List<String> list2 = listStream.sorted().collect(Collectors.toList());

...

listStream.forEach(System.out::print); // 런타임 에러 발생

 

 

그래서 보통 스트림 객체를 별도로 생성해서 사용한다기보다는 stream()함수를 뒤에 스트림 연산을 그대로 붙여서 사용한다. (어차피 일회용이니)

그게 코드 줄도 줄이고 뭔가 스트림을 호출하고 바로 뒤에 스트림 연산을 사용해서 좀 더 이해하기 쉽기도 하다.

(물론, 스트림 연산이 많이 붙거나 스트림 연산 안의 callback 함수가 복잡할 경우 스트림을 사용하지 않은 것보다 더 가독성이 떨어진다.)

...

List<String> list2 = list.stream().sorted().collect(Collectors.toList());

...

 

 

3. 내부적으로 반복 작업을 처리한다.

이것은 '데이터 소스를 변경하지 않는다'라는 특징과 함께 스트림을 사용하는 이유 중 하나이다.

스트림을 사용한다는 것은 데이터가 하나가 아니라 여러 개라는 것이다. 그 여러 개의 데이터들을 처리하려면 보통 반복문을 사용하거나 컬렉션의 forEach를 사용한다.

그러한 귀찮은 반복문 구현을 스트림을 사용하면 귀찮게 내가 로직을 짤 필요 없이 스트림 객체의 이미 구현되어 있는 유용한 연산들로 해결할 수 있다.

따라서 반복문 로직을 짜야하는 리소스 투자가 없어지고 이미 구현되어 있는 연산 함수를 사용하면 되므로 코드도 좀 더 간경해진다.

 

 

 

병렬 스트림(Parallel Stream)

또한 위의 특징에는 적어놓지 않았지만 스트림은 기본적으로 병렬로 연산을 처리하지 않는다.

하지만 스트림에서 제공하는 parallel() 메소드를 호출할 경우 연산을 병렬로 수행하게 된다.

 

 

예를 들어 아래와 같이 텍스트 리스트를 스트림을 통해 하나 씩 한 줄로 출력을 하면 아래와 같이 리스트에 들어 있는 순서 그대로 출력되는 것을 확인할 수 있다.

List<String> list = Arrays.asList(new String[]{"blue", "red", "green", "black"});
list.stream().forEach(System.out::println);

 

 

하지만 스트림 뒤에 parallel() 함수를 덧붙이면 병렬로 forEach() 연산이 수행되어 아래와 같이 원래 리스트에 있던 순서와는 다르게 뒤죽박죽으로 출력되는 것을 확인할 수 있다.

 

 

병렬로 연산이 처리되지 않게 하려면 parallel()와 반대 기능을 하는 sequential()를 호출하면 되지만 이미 스트림은 기본적으로 병렬 처리를 하지 않으므로 sequential()는 잘 사용하지 않는다.

 

 

주요 스트림 연산

스트림에서는 forEach()와 같이 데이터를 가지고 무언가 하는 함수를 연산이라고 한다.

연산은 또 반환하는 것이 스트림이냐 아니냐에 따라 아래와 같이 나뉜다.

 

- 중간 연산: 스트림을 반환해서 그 스트림을 가지고 또 스트림 연산을 할 수 있다.

- 최종 연산: 스트림 요소를 소모하며 연산하기 때문에 결과를 가지고 또 스트림 연산을 할 수 없다.

 

아래는 코딩하면서 많이 사용하는 연산을 정리한 것이다.

 

1. of

컬렉션에서는 stream() 함수를 호출하면 스트림 객체가 생성된다. 하지만 데이터가 컬렉션 형태가 아니라 그냥 데이터 그대로 있다면?

of()와 같은 스트림 static 함수를 이용해서 데이터들을 스트림 객체로 만들어줘야 한다.

비슷한 Stream static 함수로는 generate(), iterate()도 있다.

String<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7);

 

 

2. sorted

간단하다. 말 그대로 읽어들인 데이터들을 정렬하는 연산이다. 정렬을 하고나면 정렬된 데이터에 따른 스트림을 다시 반환하므로 정렬된 데이터들을 가지고 스트림 연산을 이어서 수행할 수 있다.(중간 연산)

기본 오름차순 정렬이고 만일 읽어들인 데이터가 Map이나 별도 필드를 가지고 있는 커스텀 객체일 경우 어떤 필드로 정렬해야하는지 정의를 위해 Comparator.comparing(객체:필드)로 기준 필드를 정의해줘야 한다. 내림자순은 Comparator.comparing(객체:필드).reverseed()를 통해 할 수 있다.

employList.stream().sorted(Comparator.comparing(Employ::getName).reversed());

 

 

3. forEach

컬렉션에도 있는 함수이다. 스트림이 읽어들인 데이터 각각에 대해 수행해야 하는 것이 있을 때 사용한다. 

참고로 forEach는 스트림 요소를 소모하면서 하는 연산으로 forEach를 수행하고 나면 아무런 반환 값이 없다.

 

그렇다면 컬렉션의 forEach와는 뭐가 다른 것일까?

이 부분에 대해서는 위의 원본 데이터를 건드리냐 마느냐의 유무 말고 하나 더 있다.

마로 스트림의 forEach는 CPU를 좀 더 많이 사용한다. 기능 면에서는 크게 차이가 없지만 이러한 리소스 면에서 차이가 있어서 자신이 개발하는 곳에서 컬렉션의 forEach와 스트림의 forEach를 사용했을 때 얼마나 CPU 사용량이 차이가 나는지 비교해보고 사용하면 된다.

(그래도 역시 데이터 소스를 건드리지 않아야 하는 로직이라면 스트림 사용을 권장한다.)

 

 

4. map

어찌보면 forEach와 비슷하다. 데이터들을 다 읽어들여서 데이터들 하나 하나에 대해 수행해야할 것이 있을 때 사용한다.

단, 차이점은 map()은 스트림을 반환해서 반환 받은 스트림 객체를 이용해서 새로운 컬렉션을 만들 수 있다는 것이다.

forEach()는 함수 안에서 기능을 수행하면 끝나는 것과 달리 map()은 계속해서 스트림 연산을 이어나갈 수 있는 것이다.

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6, 7);
        
List<Integer> plueOneList = list.stream().map(n -> {return n + 1;}).collect(Collectors.toList());

 

 

즉, forEach() 연산은 단순 읽어들인 모든 데이터를 통해 무언가 해야할 때 사용하고 map() 연산은 읽어들인 모든 데이터들을 통해 무언가 해내고 거기에 다른 결과 데이터들을 다른 컬렉션으로 받아야할 때 사용한다.

 

 

5. flatMap

map() 연산을 보다보면 같이 딸려서 나오는 것이 flatMap() 이다.

자주 사용하지는 않지만 혹시 사용할 일이 있을까 싶어 정리를 해본다.

flatMap은 컬렉션 안에 컬렉션이 있어서 스트림 안에 스트림을 생성해서 스트림 연산을 사용해야할 때 유용하게 사용할만한 스트림 연산이다.

 

예를 들어 아래와 같이 리스트 안에 배열이 있으면 겉의 List대한 큰 스트림 하나와 안의 Integer[]에 대한 스트림이 안에 또 생성될 수 있다.

즉, Stream<Stream> 형태인 것이다.

따라서 map() 연산을 사용했을 경우 안의 배열까지 같이 사용하기 위해서 안의 배열들을 스트림 객체로 만든 후 그것에 대해서 스트림 연산을 수행한 결과를 반환하고 그 결과를 받아서 겉 List에 대한 스트림 연산에서 마무리 작업을 해줘야 한다.

List<Integer[]> list = new ArrayList<>();
list.add(new Integer[]{3, 12, 6, 7, 8});
list.add(new Integer[]{25, 27, 30, 42, 6, 8});

Set<Integer> set = list.stream().map(innerArr -> Arrays.stream(innerArr).collect(Collectors.toSet()))
                .collect(HashSet::new, Set::addAll, Set::addAll);

// 두 정수 배열의 중복되는 값을 없앤 set 만들어서 반환

 

하지만 이것을 flatMap() 연산은 안의 데이터에 대한 스트림을 만들어서 반환해주므로 좀 더 코드가 간결해진다.

Set<String> set = list.stream().flatMap(innerArray -> Arrays.stream(innerArray))
                                .collect(Collectors.toSet());

 

flatMap()은 요약하면 Stream<Stream> 형태인 것을 하나의 스트림으로 합쳐서 반환해주는 역할을 한다고 보면 된다.

 

 

6. filter

말 그대로 데이터들을 가지고 필터링하는 것이다. 특정 설정한 조건에 맞는 데이터만 모아서 스트림을 반환한다.

filter() 연산 안에서는 데이터를 통해서 true/false을 반환하면 된다.

아래는 간단하게 리스트에 들어 있는 숫자 중에서 7이하의 숫자를 뽑아서 새로운 리스트를 만드는 코드이다.

 

수많은 데이터를 이용해 필터링을 거쳐 새로운 데이터 컬렉션을 만들어야할 때 유용하게 사용할 수 있다.

List<Integer> list = Arrays.asList(1, 2, 3, 6, 4, 7, 1, 11, 8, 5);

List<Integer> listLessThan7 = list.stream().filter(n -> n <= 7).collect(Collectors.toList());

System.out.println(listLessThan5); // [1, 2, 3, 4, 7, 1, 5]

 

 

7. collect

위의 스트림 연산들을 보면 collect() 연산을 많이 사용한 것을 볼 수 있다.

collect()는 스트림을 통해 컬렉션을 만들어주는 연산이다. 즉, 스트림으로 새로운 컬렉션을 만들어서 반환해준다.

collect() 연산 안에는 Collectors를 넣어서 어떤 컬렉션으로 만들지 정의한다.

List<String> newList = list.stream().collect(Collectors.toList());

 

 

8. reduce

reduce()는 최종 연산으로 스트림 안의 요소들을 소모하면서 연산을 수행한다. 그리고 결과 값으로 스트림이 아닌 연산의 결과 값을 내놓는다.

스트림에 여러 데이터가 들어 있을 경우 reduce() 연산은 맨 처음에는 데이터1, 데이터2로 reduce() 안에 정의해놓은 연산을 수행한다. 그리고 그 결과 값이랑 데이터3을 다시 똑같은 연산을 수행한다. 그렇게 데이터 끝까지 모든 연산을 수행한 후 최종 결과를 반환한다.

즉, 처음 요소 2개로 연산한 결과 값과 그 다음 요소를 연산하고 또 그 결과 값과 다음 요소를 연산하는 식으로 연산이 데이터 순서대로 이어진다고 생각하면 된다.

아래 코드는 reduce()를 이용해서 숫자 리스트에서 제일 큰 값을 뽑아내는 코드이다.

List<Integer> list = Arrays.asList(1, 2, 3, 6, 4, 7, 1, 11, 8, 5);

System.out.println(list.stream().reduce(Integer::max).get()); // 11

 

 

 

 

자바 스트림. 자바 8을 공부하고 익히는 제일 큰 장점인 스트림을 이제야 사용해보고 있다.

앞으로 데이터 소스를 유지하면서 해당 데이터 소스를 이용해서 무언가 연산을 해야할 때 꼭 스트림을 사용해봐야겠다.

그리고 더불어 스트림 연산과 많이 사용되는 람다식에 대해서도 익혀봐야겠다. (System.out::println 가 어떤 방법인지에 대해서도...)

 

 

 

 

✋ Oracle의 Java 8 Stream 공식 문서

https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

 

Stream (Java Platform SE 8 )

A sequence of elements supporting sequential and parallel aggregate operations. The following example illustrates an aggregate operation using Stream and IntStream: int sum = widgets.stream() .filter(w -> w.getColor() == RED) .mapToInt(w -> w.getWeight())

docs.oracle.com

 

 

 

 

반응형
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/04   »
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
글 보관함