개요
우아한 프리코스를 진행하면서 가장 많이 변한건 더 이상 스트림을 두려워하지 않는 제 자신이었습니다.
오늘은 자바 스트림에 대해 학습한 내용들을 정리해보고자 합니다!
1. 스트림이란?
스트림 API는 람다와 마찬가지로 Java 8에 새롭게 추가된 기능입니다. 선언형으로 컬렉션 데이터를 처리할 수 있으며, filter, sorted, map, collect 같은 여러 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프 라인을 만들 수 있습니다.
2. Iterator에 비해서 Stream이 가지는 강점
Iterator 와 Stream 모두 반복적인 연산을 줄이기 위해 사용됩니다. 하지만 최근 추세는 Stream을 선호하고 있는데요, 이를 간단한 예시를 통해 알아보도록 하겠습니다.
예를 들어, 다음과 같은 리스트가 있다고 가정해봅시다.
List<String> strings = Arrays.asList("Apple", "Orange", "Banana", "Avocado");
Java
복사
그리고 문자열 중에 ‘A’로 시작하는 단어를 찾아보려고 합니다.
Iterator를 사용하면 다음과 같이 구현할 수 있습니다.
Iterator<String> iterator = strings.iterator();
while (iterator.hasNext()) {
String str = iterator.next();
if (str.startsWith("A")) {
System.out.println(str.toUpperCase());
}
}
Java
복사
Stream 예시는 다음과 같습니다.
List<String> streamList = strings.stream()
.filter(s -> s.startsWith("A"))
.map(String::toUpperCase)
.collect(Collectors.toList());
Java
복사
두 코드를 본다면 Stream을 사용한 예제에서는 코드가 더 직관적이며, 필터링과 매핑이 한 줄에 체인으로 연결되어 있어 가독성이 뛰어납니다. 반면에 Iterator 코드는 Stream에 비해 직관적이지 않습니다.
3. 스트림과 컬렉션
Stream과 Collection 모두 자바에서 데이터 집합을 다루기 위해 사용됩니다. 하지만 둘 사이에는 차이점이 있습니다.
Collection은 메모리에 데이터를 모두 저장하지만, 스트림은 요청 시 데이터를 계산합니다.
즉, Collection은 모든 데이터를 메모리에 보유하지만, Stream은 일반적으로 내부적으로 작은 버퍼를 사용하여 데이터를 처리하고, 필요할 때만 데이터를 소비합니다. 저는 이 둘을 다회성과 일회성으로 비교하고 싶습니다.
실생활의 예제인 동영상으로 비유하자면 다음과 같습니다.
컬렉션은 동영상을 모두 다운로드 후에 볼 수 있다. 반면에 스트림은 전체가 아닌 특정 구간만 다운받아서 볼 수 있는 스트리밍 서비스이다. 스트림(스트리밍)은 한번 사용하면 소멸되지만, 동영상은 재차 다시 볼 수 있다.
3.1 외부 반복과 내부 반복
Collection은 외부 반복을 통해 반복을 컨트롤 합니다. 외부 반복에는 for-each 문 등이 있습니다.
List<String> carNames = new ArrayList<>();
for(Car car : cars) {
carNames.add(car.getName());
}
Java
복사
외부 반복의 특징으로는 사용자가 직접 외부 반복을 처리해줘야 한다는 것이 있습니다.
반면에, Stream은 내부 반복을 사용합니다.
내부반복은 반복자를 사용할 필요가 없으며, 병렬 처리를 따로 신경 쓸 필요가 없습니다. 즉, 내부에서 자동으로 처리해줍니다. 또한 filter, map, reduce, find, sort 등의 다양한 추가 연산자를 제공합니다.
4. 스트림의 특징
Stream의 특징은 다음과 같이 정리할 수 있습니다.
4.1 윈본 데이터를 변경하지 않는다
Stream은 데이터 소스로 부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않습니다. 필요에 따라 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수 있습니다.
List<String> carNames = carList.sorted().collect(Collections.toList());
Java
복사
4.2 스트림은 일회용이다
Stream은 Iterator처럼 일회용입니다. 즉, 한번 사용하고 나면 재 사용이 불가능합니다.
cars.sorted().forEach(System.out::print);
int carSize = cars.count(); // [ERROR]
Java
복사
4.3 스트림은 작업을 내부 반복으로 처리한다
Stream은 내부 반복을 사용합니다. 즉, 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미합니다.
4.4 스트림은 병렬처리가 가능하다
병렬 Stream 은 내부적으로 fork & join 프레임워크 를 이용해서 자동적으로 병렬 연산을 수행합니다. 따라서 그저 parallel() 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 됩니다.
병렬로 처리되지 않게 하는 방법에는 sequential()를 호출하는 방법이 있습니다.모든 스트림의 디폴트임으로 따로 호출할 필요는 없습니다.
5. 스트림의 연산
스트림은 크게 두 가지의 연산으로 구분됩니다.
•
중개 연산
중간 연산의 특징은 단말 연산을 스트림 파이프라인에 실행하기 전까지 아무 연산도 수행하지 않습니다. 예시로는 filter, map, limit, sorted, distinct 등이 있습니다.
•
최종 연산
최종 연산은 스트림 파이프라인에서 결과를 도출합니다. 예시로는 forEach, collect, count 등이 있습니다.
예제 코드로 설명을 해보겠습니다. 중간 연산만 적용한 예시입니다.
public static void main(String[] args) {
List<String> items = Arrays.asList("Apple", "Ango", "Banana", "Apricot");
Stream<String> stream = items.stream()
.filter(s -> s.startsWith("A")) // 'A'로 시작하는 요소 필터링
.map(String::toUpperCase) // 모든 문자를 대문자로 변환
.limit(2); // 처음 2개 요소만 가져옴
System.out.println(stream.count()); // 2를 출력
}
Java
복사
중간 연산은 반환 값으로 Stream을 반환합니다. 하지만 이를 유용하게 사용하기 위해선 최종 연산이 필요합니다.
public static void main(String[] args) {
List<String> items = Arrays.asList("Apple", "Mango", "Banana", "Apricot");
List<String> result = items.stream()
.filter(s -> s.startsWith("A"))
.map(String::toUpperCase)
.limit(2)
.collect(Collections.toList()); // 결과를 리스트로 수집
System.out.println(result.count()); // [APPLE, ANGO]
}
Java
복사
collect가 호출되면 그제서야 전체 파이프라인이 실행되어 결과가 도출됩니다.
5.1 스트림의 지연 연산
Stream은 필터-맵(filter-map) 기반의 API를 사용하여 지연 연산을 합니다. 지연 연산은 통상적으로 성능을 최적화 하는데 도움을 줍니다.
지연 연산이란 말 그대로 최종연산전 까지 연산을 계속 지연 시키는 것입니다. 즉, 중간 연산은 실행되지 않습니다.
Stream은 어디까지나 연산을 위한 객체로서 그 자체로 자료구조의 역할을 하지 않기 때문입니다. 이는 앞전에 말했던 컬렉션과 스트림의 큰 차이 중 하나인 데이터의 저장 유무와 관련 있습니다.
따라서 최종연산을 진행해야만 스트림의 연산이 의미 있어집니다.
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s = stream.peek(System.out::println);
Java
복사
peek()메서드는 forEach()처럼 스트림의 요소를 순회하며 소비(Consumer<T>)하는 메서드입니다. forEach()와 한가지 다른 점은 중간연산이라는 점인데, 위 코드를 실행하면 실행문은 출력되지 않습니다.
반면 다음 코드는 최종 연산이 호출될 때 정상적으로 실행됩니다.
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s = stream.peek(System.out::println);
s.collect(toList());
Java
복사
6. 직접 사용해보면서 알아보는 스트림
Stream은 다양한 데이터 소스로 부터 사용할 수 있습니다.
소스는 다음과 같습니다.
1.
컬렉션
2.
배열
3.
가변 매개변수
4.
지정된 범위의 연속된 정수
5.
특정 타입의 난수들
6.
람다 표현식
7.
파일
8.
빈 스트림
// 컬렉션에서 스트림 생성
ArrayList<Integer> list = new ArrayList<Integer>();
Stream<Integer> stream = list.stream();
stream.forEach(System.out::println); // forEach() 메소드를 이용한 스트림 요소의 순차 접근
// 배열에서 스트림 생성
String[] arr = new String[]{"넷", "둘", "셋", "하나"};
Stream<String> stream1 = Arrays.stream(arr);
stream1.forEach(e -> System.out.print(e + " "));
System.out.println();
// 배열의 특정 부분만을 이용한 스트림 생성
Stream<String> stream2 = Arrays.stream(arr, 1, 3);
stream2.forEach(e -> System.out.print(e + " "));
// 가변 매개변수에서 스트림 생성
Stream<Double> stream = Stream.of(4.2, 2.5, 3.1, 1.9);
stream.forEach(System.out::println);
// 지정된 범위의 연속된 정수에서 스트림 생성
IntStream stream1 = IntStream.range(1, 4);
stream1.forEach(e -> System.out.print(e + " "));
System.out.println();
IntStream stream2 = IntStream.rangeClosed(1, 4);
stream2.forEach(e -> System.out.print(e + " "));
// 특정 타입의 난수로 이루어진 스트림 생성
IntStream stream = new Random().ints(4);
stream.forEach(System.out::println);
// 람다 표현식
IntStream stream = Stream.iterate(2, n -> n + 2); // 2, 4, 6, 8, 10, ...
// 파일
String<String> stream = Files.lines(Path path); // 라인 단위로 접근
// 빈 스트림 생성
Stream<Object> stream = Stream.empty();
System.out.println(stream.count()); // 스트림의 요소의 총 개수를 출력함.
Java
복사
6.1 중개연산
앞서 알아보았던 중개 연산에 대해 다시 알아보겠습니다. 중개 연산은 스트림을 다루는 여러 연산 중에서 데이터의 변환 또는 데이터에 대한 조작을 담당하는 연산을 의미합니다. 이 연산들은 스트림을 입력으로 받아 다른 스트림을 반환하는데, 이 특성 덕분에 여러 중개 연산을 연쇄적으로 연결할 수 있으며, 이를 스트림 파이프라인이라고 부릅니다.
대표적인 중개 연산 메소드는 다음과 같습니다.
필터링 : filter(), distinct
IntStream stream1 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);
stream1.distinct().forEach(e -> System.out.print(e + " ")); // 출력: 7 5 2 1 3 4 6
IntStream stream2 = IntStream.of(7, 5, 5, 2, 1, 2, 3, 5, 4, 6);
stream2.filter(n -> n % 2 != 0).forEach(e -> System.out.print(e + " ")); // 출력: 7 5 5 1 3 5
Java
복사
변환 : map(), flatMap()
Stream<String> stream3 = Stream.of("HTML", "CSS", "JAVA", "JAVASCRIPT");
stream3.map(String::toLowerCase).forEach(System.out::println);
String[] arr = {"I study hard", "You study JAVA", "I am hungry"};
Stream<String> stream4 = Arrays.stream(arr);
stream4.flatMap(s -> Stream.of(s.split(" +"))).forEach(System.out::println);
Java
복사
제한 : limit(), skip()
IntStream stream5 = IntStream.range(0, 10);
stream5.skip(4).forEach(n -> System.out.print(n + " ")); // 출력: 4 5 6 7 8 9
System.out.println();
IntStream stream6 = IntStream.range(0, 10);
stream6.limit(5).forEach(n -> System.out.print(n + " ")); // 출력: 0 1 2 3 4
System.out.println();
IntStream stream7 = IntStream.range(0, 10);
stream7.skip(3).limit(5).forEach(n -> System.out.print(n + " ")); // 출력: 3 4 5 6 7
Java
복사
정렬 : sorted()
Stream<String> stream8 = Stream.of("JAVA", "HTML", "JAVASCRIPT", "CSS");
stream8.sorted().forEach(s -> System.out.print(s + " ")); // 출력: CSS HTML JAVA JAVASCRIPT
System.out.println();
Stream<String> stream9 = Stream.of("JAVA", "HTML", "JAVASCRIPT", "CSS");
stream9.sorted(Comparator.reverseOrder()).forEach(s -> System.out.print(s + " ")); // 출력: JAVASCRIPT JAVA HTML CSS
Java
복사
최종 연산
최종 연산은 앞서 중개 연산을 통해 만들어졌던 스트림을 소모하여 결과를 도출하는 것을 의미합니다. 즉, 지연 됐던 모든 중개 연산들이 최종 연산 시에 모두 수행되는 것입니다.
대표적인 최종 연산의 종류는 다음과 같습니다
출력 : forEach()
Stream<String> stream = Stream.of("넷", "둘", "셋", "하나");
stream.forEach(System.out::println);
/*
* 넷
* 둘
* 셋
* 하나
*/
Java
복사
소모 : reduce()
Stream<String> stream1 = Stream.of("넷", "둘", "셋", "하나");
Optional<String> result1 = stream1.reduce((s1, s2) -> s1 + "++" + s2);
result1.ifPresent(System.out::println); // 넷++둘++셋++하나
Stream<String> stream2 = Stream.of("넷", "둘", "셋", "하나");
String result2 = stream2.reduce("시작", (s1, s2) -> s1 + "++" + s2);
System.out.println(result2); // 시작++넷++둘++셋++하나
Java
복사
검색 : findFirst(), findAny()
IntStream stream1 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
OptionalInt firstResult = stream1.sorted().findFirst(); // 스트림 정렬 후 첫 번째 요소 찾기
firstResult.ifPresent(System.out::println); // 1
IntStream stream2 = IntStream.of(4, 2, 7, 3, 5, 1, 6);
OptionalInt anyResult = stream2.sorted().findAny(); // 스트림 정렬 후 아무 요소나 찾기
anyResult.ifPresent(System.out::println); // 1 (병렬 스트림에서는 다를 수 있음)
Java
복사
검사 : anyMatch(), allMatch(), noneMatch()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
System.out.println(stream1.anyMatch(n -> n > 80)); // true, 조건에 맞는 요소가 하나라도 있는지
IntStream stream2 = IntStream.of(30, 90, 70, 10);
System.out.println(stream2.allMatch(n -> n > 80)); // false, 모든 요소가 조건에 맞는지
Java
복사
통계 : count(), min(), max()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
System.out.println(stream1.count()); // 4, 전체 요소 수
IntStream stream2 = IntStream.of(30, 90, 70, 10);
stream2.max().ifPresent(System.out::println); // 90, 최대값
Java
복사
연산 : sum(), average()
IntStream stream1 = IntStream.of(30, 90, 70, 10);
System.out.println(stream1.sum()); // 200, 전체 합계
DoubleStream stream2 = DoubleStream.of(30.3, 90.9, 70.7, 10.1);
stream2.average().ifPresent(System.out::println); // 50.5, 평균
Java
복사
수집 : collect()
Stream<String> stream3 = Stream.of("넷", "둘", "하나", "셋");
List<String> list = stream3.collect(Collectors.toList()); // 스트림을 리스트로 변환
list.forEach(e -> System.out.print(e + " ")); // 넷 둘 하나 셋
Java
복사
7. 왜 Stream을 사용해야 할까
7.1 가독성과 유연성
처음 Stream을 접했을 땐 굉장히 어려움을 많이 느꼈습니다. 하지만 계속해서 스트림을 사용하다보니 가독성 측면에서 뛰어나다는 느낌을 많이 접할 수 있었는데요, 이는 제가 사용할 때 뿐만 아니라 남들의 코드를 읽을 때도 마찬가지였습니다.
또한 여러 중개 함수로 유연하게 대처할 수 있습니다. 예를 들어, 스트림과 람다를 사용해서 효과적으로 필터링을 진행할 수 있습니다.