Java Stream API?
저번 시간에는 Java 8에서 도입된 람다식에 대해서 알아보았습니다.
2024.08.25 - [Study/Java] - [Java] Lambda 훑어보기
[Java] Lambda 훑어보기
Lambda Expressions? 람다 표현식은 Java 8에서 도입되었습니다. 함수형 프로그래밍 스타일을 Java에 도입하면서 코드를 간결, 명확하게 작성할 수 있도록 한 것이죠. 익명 함수(anonymous function)를 정의하
claris.tistory.com
이번 시간에는 람다 표현식과 함께 도입된 Stream API에 대해서 훑어보겠습니다. 스트림(Stream)은 말 그대로 데이터의 연속된 흐름이라 볼 수 있습니다. 이 API는 컬렉션(List, Set 등), 배열과 같은 데이터 소스를 효율적으로 처리하기 위한 기능을 제공합니다. 스트림은 데이터를 저장하지 않고 일련의 연산(중간 연산, 최종 연산)을 적용하여 처리하고 있습니다.
앞서 작성한 람다 게시글에서 잠깐 스트림을 사용했었습니다. 이런 방식으로 스트림은 주로 람다 표현식과 함께 사용되며, 스트림 API를 통해 데이터의 필터링, 매핑, 축소(reducing) 등의 작업을 용이하게 할 수 있습니다. 핵심은 함수형 프로그래밍 스타일을 제공하면서, 코드의 가독성을 높이고 병렬 처리를 간편하게 지원한다는 점이라 볼 수 있습니다.
Stream의 특징
Stream의 특징을 하나씩 예제를 통해 알아보겠습니다.
1. 연속된 요소 (Sequence of Elements)
import java.util.Arrays;
import java.util.List;
public class StreamExample {
public static void main(String[] args) {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 스트림 생성 및 요소 출력
names.stream()
.forEach(System.out::println);
}
}
스트림은 데이터를 처리하기 위한 요소들의 연속된 흐름이라고 했었죠. 스트림을 생성한 후에 forEach 메서드를 통해 연속된 데이터를 출력하는 형태입니다.
2. 내부 반복 (Internal Iteration)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 외부 반복: for-each 사용
for (int number : numbers) {
System.out.println(number);
}
// 내부 반복: 스트림 사용
numbers.stream()
.forEach(System.out::println);
스트림은 내부적으로 반복을 처리하여 개발자가 직접 반복문을 작성하지 않아도 됩니다. 전통적인 외부 반복(external iteration)과 달리, 내부 반복은 스트림 라이브러리 내부에서 요소를 순회하고 처리합니다.
3. 중간 연산과 최종 연산 (Intermediate and Terminal Operations)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 중간 연산: filter, map
// 최종 연산: collect
List<String> filteredNames = names.stream()
.filter(name -> name.length() > 3) // 이름의 길이가 3 초과인 요소 필터링
.map(String::toUpperCase) // 모든 이름을 대문자로 변환
.collect(Collectors.toList()); // 결과를 리스트로 수집
System.out.println(filteredNames); // 출력: [ALICE, CHARLIE, DAVID]
스트림 연산은 중간 연산과 최종 연산으로 나뉩니다. 중간 연산은 스트림을 반환하여 연산을 연결할 수 있게 하고(메서드 체이닝 가능), 최종 연산은 스트림을 닫고 결과를 반환합니다. 여기서 filter와 map은 중간 연산이고, collect는 최종 연산입니다.
4. 게으른 연산 (Lazy Evaluation)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 게으른 연산 예시
names.stream()
.filter(name -> {
System.out.println("필터링: " + name);
return name.length() > 3;
})
.map(name -> {
System.out.println("매핑: " + name);
return name.toUpperCase();
});
// 최종 연산이 없으므로 출력 없음
// 최종 연산을 추가하여 실행
names.stream()
.filter(name -> {
System.out.println("필터링: " + name);
return name.length() > 3;
})
.map(name -> {
System.out.println("매핑: " + name);
return name.toUpperCase();
})
.forEach(System.out::println); // 출력 수행
스트림의 중간 연산은 지연 평가(Lazy Evaluation) 됩니다. 지연 평가는 최종 연산이 호출될 때까지 실제로 수행되지 않음을 의미합니다. 이 특성 덕분에 스트림은 효율적으로 작동하며 불필요한 연산을 피할 수 있습니다.
5. 불변성 (Immutability)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 스트림 사용하여 변환
List<String> modifiedNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
System.out.println("원본 리스트: " + names); // 출력: [Alice, Bob, Charlie, David]
System.out.println("변환된 리스트: " + modifiedNames); // 출력: [ALICE, BOB, CHARLIE, DAVID]
스트림은 원본 데이터를 변경하지 않습니다. 모든 스트림 연산은 새로운 스트림을 반환하며, 원본 컬렉션은 변하지 않습니다. 이는 데이터의 불변성을 유지하여 부작용을 방지합니다.
6. 병렬 처리 지원 (Parallel Processing Support)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 병렬 스트림 생성
names.parallelStream()
.forEach(name -> System.out.println(Thread.currentThread().getName() + ": " + name));
스트림 API는 병렬 스트림을 쉽게 생성할 수 있어 멀티코어 프로세서를 활용한 병렬 처리를 지원합니다. 이를 통해 큰 데이터 세트를 효율적으로 처리할 수 있습니다.
Stream의 종류
1. 참조형 스트림 (Reference Streams)
참조형 스트림은 객체를 요소로 가지는 스트림입니다. Stream<T>가 기본 형태이며, List, Set, Map과 같은 컬렉션을 대상으로 스트림을 생성할 때 주로 사용됩니다. 참조형 스트림은 모든 종류의 객체를 처리할 수 있으며, 가장 일반적으로 사용되는 스트림 타입입니다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David");
// 참조형 스트림 생성 및 사용
names.stream()
.filter(name -> name.startsWith("A")) // 'A'로 시작하는 이름 필터링
.forEach(System.out::println); // 결과 출력
2. 기본형 스트림 (Primitive Streams)
기본형 스트림은 Java의 원시 데이터 타입을 처리할 수 있는 스트림입니다. 이러한 스트림은 성능을 최적화하고, 박싱(boxing) 및 언박싱(unboxing)에 대한 오버헤드를 줄이기 위해 사용됩니다. Java는 세 가지 주요 기본형 스트림이 있습니다. IntStream, LongStream, DoubleStream. 각각 int, long, double 타입의 요소를 처리하기 위한 스트림이죠.
이 스트림들은 기본적으로 숫자를 처리하는 데 사용되며, sum(), average(), min(), max()와 같은 기본형에 특화된 메서드를 제공합니다. 사용 예시는 가볍게 훑고 지나가겠습니다.
1) IntStream
import java.util.stream.IntStream;
public class IntStreamExample {
public static void main(String[] args) {
// IntStream 생성 및 사용
IntStream.range(1, 5) // 1부터 4까지의 정수 스트림 생성
.forEach(System.out::println); // 출력: 1, 2, 3, 4
// IntStream을 사용하여 합계 계산
int sum = IntStream.rangeClosed(1, 5) // 1부터 5까지의 정수 스트림 생성
.sum(); // 모든 요소의 합 계산
System.out.println("Sum: " + sum); // 출력: Sum: 15
}
}
2) LongStream
import java.util.stream.LongStream;
public class LongStreamExample {
public static void main(String[] args) {
// LongStream 생성 및 사용
long product = LongStream.rangeClosed(1, 5) // 1부터 5까지의 long 스트림 생성
.reduce(1, (a, b) -> a * b); // 모든 요소의 곱 계산
System.out.println("Product: " + product); // 출력: Product: 120
}
}
3) DoubleStream
import java.util.stream.DoubleStream;
public class DoubleStreamExample {
public static void main(String[] args) {
// DoubleStream 생성 및 사용
double average = DoubleStream.of(1.0, 2.0, 3.0, 4.0, 5.0) // DoubleStream 생성
.average() // 모든 요소의 평균 계산
.orElse(0.0); // 결과가 없을 경우 기본값 0.0 사용
System.out.println("Average: " + average); // 출력: Average: 3.0
}
}
3. 병렬 스트림 (Parallel Streams)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");
// 병렬 스트림 사용
names.parallelStream()
.forEach(name -> System.out.println(Thread.currentThread().getName() + ": " + name));
병렬 스트림은 스트림의 요소를 여러 CPU 코어에서 동시에 처리할 수 있도록 하여 대용량 데이터를 효율적으로 처리할 수 있습니다.
개발자가 직접 스레드/스레드풀을 생성할 필요 없이 parallelStream()을 사용하면 Fork/Join Framework을 통해 작업을 분할하고 병렬적으로 처리할 수 있습니다. 출력 화면에서 여러 스레드가 동시에 작업하고 있음을 확인할 수 있습니다. 하지만 병렬 스트림은 데이터 처리 속도를 크게 향상시킬 수 있지만, 작업의 순서가 중요할 땐 유의해야겠죠?
4. 빈 스트림 (Empty Streams)
import java.util.stream.Stream;
public class EmptyStreamExample {
public static void main(String[] args) {
// 빈 스트림 생성
Stream<String> emptyStream = Stream.empty();
// 빈 스트림을 사용한 예
emptyStream.forEach(System.out::println); // 출력 없음
}
}
빈 스트림은 데이터를 처리할 필요가 없거나 초기화된 상태에서 빈 스트림을 사용해야 할 때 유용합니다. 굳이 왜 이게 있냐 싶겠지만은, 스트림 연산을 수행한 결과가 없을 때 null 대신 사용하기 위해 존재합니다.
중간 연산과 최종 연산
스트림의 종류에 대해서도 알아봤으니, 스트림에서 사용하는 주요 메서드에 대해서도 알아보겠습니다.
1. 중간 연산(Intermediate Operations)
위에서 한번 언급을 하긴 했지만, 중간 연산은 스트림을 반환하여 다른 중간 연산이나 최종 연산과 연결될 수 있습니다. 중간 연산은 지연 평가(lazy evaluation)되며, 최종 연산이 호출될 때까지 실행되지 않습니다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
System.out.println(filteredNames);
---
출력: [Alice]
filter(Predicate<T> predicate) : 조건에 맞는 요소만 필터링
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Integer> nameLengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
System.out.println(nameLengths);
---
출력: [5, 3, 7]
map(Function<T, R> mapper) : 요소를 다른 형태로 변환
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5);
List<Integer> distinctNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(distinctNumbers);
---
출력: [1, 2, 3, 4, 5]
distinct() : 중복된 요소를 제거
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> sortedNames = names.stream()
.sorted()
.collect(Collectors.toList());
System.out.println(sortedNames);
---
출력: [Alice, Bob, Charlie]
sorted(Comparator<T> comparator) : 요소 정렬
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
List<Integer> limitedNumbers = numbers.stream()
.limit(3)
.collect(Collectors.toList());
System.out.println(limitedNumbers);
---
출력: [1, 2, 3]
limit(long maxSize) : 스트림 자르기
2. 최종 연산(Terminal Operations)
최종 연산은 스트림을 닫으며, 스트림이 아닌 결과를 반환합니다.
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> filteredNames = names.stream()
.filter(name -> name.startsWith("A"))
.collect(Collectors.toList());
System.out.println(filteredNames);
---
출력: [Alice]
collect(Collector<T, A, R> collector) : 스트림의 요소들을 컬렉션이나 다른 형식으로 변환
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.stream()
.forEach(System.out::println);
---
출력:
Alice
Bob
Charlie
forEach(Consumer<T> action) : 스트림의 각 요소에 대해 주어진 동작 수행
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
System.out.println(sum);
---
출력: 15
reduce(BinaryOperator<T> accumulator) : 스트림의 요소를 하나의 값으로 합산
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
long count = names.stream().count();
System.out.println(count);
---
출력: 3
count() : 스트림의 요소 개수 반환
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
Optional<String> firstName = names.stream().findFirst();
System.out.println(firstName.orElse("없음"));
---
출력: Alice
findFirst(): 스트림의 첫 번째 요소 반환
Stream 장단점?
람다의 장점과 마찬가지로 스트림 역시 코드의 간결성을 챙길 수 있습니다. 내부 반복을 사용하기 때문이죠. 가독성, 병렬 처리도 좋구요. 다양한 데이터 처리 작업을 효과적으로 수행할 수 있는 연산 메서드들도 있다는 장점을 가지고 있습니다.
단점도.. 람다랑 비슷한 결인 것 같습니다. 스트림 연산 체인은 매우 간결하지만, 중간 연산이 여러 개 중첩될 경우 디버깅이 어려울 수 있습니다. 또한, 스트림은 일회성으로 사용되는 데이터 구조라 한번 사용된 스트림을 재사용할 수 없습니다. 그리고 매우 큰 데이터 세트를 메모리에 로드한 후에 스트림 연산을 수행하면 메모리 사용량이 증가할 수도 있어 메모리 사용량도 유념해야 합니다.
etc.
아, 참고로 알고리즘 문제 풀 때 사용하는 BufferedReader에서 인자로 들어가는 InputStreamReader하고는 전혀 관련이 없습니다! 자바에서 Stream이라는 단어는 두 가지 서로 다른 맥락에서 사용됩니다.
Java I/O 스트림
Java I/O 스트림은 데이터의 연속적인 흐름을 의미하며, 파일, 네트워크 소켓, 메모리 등에서 데이터를 바이트나 문자 단위로 읽고 쓰는 기능을 제공합니다. 여기서 바이트 스트림과 문자 스트림으로 나뉘죠.
InputStreamReader는 바이트 스트림을 문자 스트림으로 변환해주는 브리지 클래스로, Java의 I/O 시스템에서 사용되는 I/O 스트림의 일종입니다.
Java 8 Stream API
Java 8 Stream API는 데이터 컬렉션 (List, Set, 배열 등)을 효율적으로 처리하기 위해 도입된 함수형 프로그래밍 도구입니다. 여기서 말하는 "스트림"은 데이터의 흐름을 의미하며, 데이터 자체를 변환하거나 처리하는 파이프라인 작업을 수행하는 것을 의미합니다.
결론적으로, InputStreamReader와 같은 I/O 스트림 클래스는 Java 8의 Stream API와는 관련이 없지만, 모두 데이터 흐름을 다루기 때문에 넓은 의미에서 "스트림"이라는 용어가 사용됩니다. 그러니 용어 혼동에 주의!
'Study > Java' 카테고리의 다른 글
[Java] HashSet과 HashMap 성능 차이 (0) | 2024.09.14 |
---|---|
[Java] String 객체 생성법 탐구 (with String Pool) (0) | 2024.09.08 |
[Java] Lambda 훑어보기 (0) | 2024.08.25 |
[Java] Java로 풀면 KMP를 써야하는 브론즈 문제가 있다? (0) | 2024.08.18 |
[Java] 컬렉션 시간 복잡도 (0) | 2023.05.04 |