자바 8에서 도입된 스트림 API는 컬렉션이나 배열의 데이터를 효율적으로 처리할 수 있도록 도와주는 기능이다.
원본 데이터를 변경하지 않으며, 연산 파이프라인을 통해 중간 연산과 최종 연산을 체이닝하여 필터링, 변환, 계산 등을 수행할 수 있다.
이를 통해 코드의 가독성을 높이고, 간결하면서도 강력한 데이터 처리가 가능하다.
스트림 생성
스트림은 다양한 방법으로 생성할 수 있다.
1. 컬렉션에서 생성
public class Test {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "mango", "peach", "orange");
Stream<String> stream = fruits.stream();
}
}
2. 배열에서 생성
public class Test {
public static void main(String[] args) {
String[] array = {"a", "b", "c"};
Stream<String> stream = Arrays.stream(array);
}
}
3. 직접 생성
public class Test {
public static void main(String[] args) {
Stream<String> streamOf = Stream.of("a", "b", "c");
// Stream.builder() 사용
Stream<String> streamBuilder = Stream.<String>builder()
.add("a")
.add("b")
.add("c")
.build();
}
}
4. 무한 스트림 생성
public class Test {
public static void main(String[] args) {
// Stream.generate() - 무한 스트림 생성
Stream<Double> randomStream = Stream.generate(Math::random).limit(5);
// Stream.iterate() - 무한 순차 스트림 생성
Stream<Integer> iteratedStream = Stream.iterate(0, n -> n + 2).limit(5); // 0, 2, 4, 6, 8
}
}
1, 2, 3번의 경우 데이터 소스로부터 유한한 스트림을 생성하는데, iterate(), generate()는 별도의 종료 조건(limit)이 없으면 무한히 데이터를 만들어내는 스트림을 생성한다.
중간 연산과 최종 연산
중간 연산
중간 연산이란, 스트림의 데이터를 변환, 필터링, 정렬 등을 하여 또 다른 스트림을 반환하는 연산 단계를 의미한다.
필터링 |
filter(Predicate<T>) | 조건에 맞는 요소만 선택 |
distinct() | 중복인 요소 제거 | |
슬라이싱 |
takeWhile(Predicate<T>) | 조건이 처음으로 거짓이 되는 지점까지만 요소를 포함 (정렬되었을 때 유용) |
dropWhile(Predicate<T>) | 조건이 처음으로 거짓이 되는 지점부터 모든 요소를 포함 (정렬되었을 때 유용) | |
변환 | map(Function<T, R>) | 각 요소를 변환 |
flatMap(Function<T, Stream<R>>) | 중첩 컬렉션에서 각 요소를 스트림으로 변환하고 하나의 스트림으로 평탄화 | |
mapToInt(), mapToLong(), mapToDouble() | 기본 타입 스트림으로 변환 | |
제한 | limit(long n) | 최대 n개 요소로 제한 |
skip(long n) | 처음 n개 요소를 건너뜀 | |
정렬 | sorted() | 기본 정렬 |
sorted(Comparator<T>) | 주어진 비교자로 정렬 | |
탐색 | peek(Consumer<T>) | 각 요소를 소비하고 동일 요소를 반환 (디버깅 용) |
중간 연산은 파이프 라인 형태로 연결하며, 원본 데이터를 변경하지 않고 각 요소의 변경이 이루어진다. (불변)
중간 연산은 최종 연산이 실행될 때까지 실제로 처리하지 않는다. (지연 연산)
파이프 라인 방식과 지연 연산이 무엇인지는 뒤에서 설명하도록 하겠다.
최종 연산
최종 연산은 스트림 파이프 라인의 결과를 생성하는 연산으로, 스트림을 소비하여 실제 연산을 수행, 스트림이 아닌 결과를 반환한다.
순회 | forEach(Consumer<T>) | 각 요소에 작업 수행 |
요소 찾기 | findFirst() | 첫 번째 요소 반환 |
(Optional) findAny() | 아무 요소나 반환 (Optional, 병렬에서 유용) | |
결과 수집 | collect(Collector<T, A, R>) | 요소를 컬렉션이나 다른 결과로 수집 |
누적 연산 | reduce(BinaryOperator<T>) | 요소를 결합하여 하나의 값으로 누적 인자로 초기 값을 주지 않는다면 Optional을 반환 |
조건 검사 | anyMatch(Predicate<T>) | 조건을 만족하는 요소가 하나라도 있는지 |
allMatch(Predicate<T>) | 모든 요소가 조건을 만족하는지 | |
noneMatch(Predicate<T>) | 모든 요소가 조건을 만족하지 않는지 | |
집계 | count() | 요소 개수 반환 |
min(Comparator<T>), max(Comparator<T>) | 최소, 최대값 반환 | |
변환 |
toArray() | 배열로 변환 |
toList() | 불변 리스트로 변환 |
여기서 최종 연산에 해당하는 연산을 호출하면 모든 중간 연산이 한 번에 실행되면서 결과가 반환된다.
한 번 최종 연산을 수행한 스트림은 재사용이 불가능하다.
기본형 특화 스트림
기본형 특화 스트림이란 기본 데이터 타입(primitive types)을 위해 특별히 최적화된 스트림 인터페이스이다.
종류로는 IntStream, LongStream, DoubleStream 세 가지 형태를 제공하며, 이들은 객체가 아닌 기본형 값을 직접 다루기 때문에 성능상 이점을 제공한다.
제네릭 타입은 기본형 타입(primitive types)을 타입 매개변수로 사용할 수 없기 때문에 기본형을 제네릭 컬렉션이나 스트림에서 사용하려면 래퍼 클래스(Integer, Long, Double 등)로 박싱해야 한다.
기본형 특화 스트림은 위 과정에서 발생하는 박싱/언박싱 오버헤드를 감소시키며 숫자 데이터를 처리할 때 다양한 계산 메서드를 제공하여 일반 스트림보다 효율적인 처리가 가능하다.
public class Test {
public static void main(String[] args) {
// 직접 생성
IntStream intStream = IntStream.range(1, 5); // 1, 2, 3, 4
LongStream longStream = LongStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
DoubleStream doubleStream = DoubleStream.of(1.1, 2.2, 3.3);
// 일반 스트림에서 매핑
Stream<String> stream = Stream.of("1", "2", "3");
IntStream intStream2 = stream.mapToInt(Integer::parseInt);
// 배열에서 생성
int[] numbers = {1, 2, 3, 4, 5};
IntStream intStream3 = Arrays.stream(numbers);
// Random 클래스 사용
IntStream randomInts = new Random().ints(5); // 5개의 무작위 int 값
// 수치 계산 메서드
int sum = IntStream.rangeClosed(1, 100).sum();
OptionalDouble avg = IntStream.rangeClosed(1, 100).average();
OptionalInt max = IntStream.rangeClosed(1, 100).max();
OptionalInt min = IntStream.rangeClosed(1, 100).min();
// 통계 계산
IntSummaryStatistics stats = IntStream.rangeClosed(1, 100).summaryStatistics();
System.out.println("개수: " + stats.getCount());
System.out.println("합계: " + stats.getSum());
System.out.println("평균: " + stats.getAverage());
System.out.println("최댓값: " + stats.getMax());
System.out.println("최솟값: " + stats.getMin());
// IntStream → LongStream → DoubleStream
IntStream intStream4 = IntStream.range(1, 5);
LongStream longStream2 = intStream.asLongStream();
DoubleStream doubleStream2 = longStream.asDoubleStream();
// 객체 스트림으로 변환
Stream<Integer> boxedStream = IntStream.range(1, 5).boxed();
}
}
스트림의 특징
1. 한 번 사용한 스트림은 재사용할 수 없음
public class Test {
public static void main(String[] args) {
Stream<String> stream = Stream.of("a", "b", "c");
// 첫 번째 사용
long count = stream.count();
System.out.println("개수: " + count);
try {
// 이미 사용한 스트림을 재사용 시도
long count2 = stream.count();
} catch (Exception e) {
// IllegalStateException 발생
System.out.println("스트림 재사용 오류: " + e.getMessage());
}
}
}
// 개수: 3
// 스트림 재사용 오류: stream has already been operated upon or closed
2. 불변 - 원본 데이터를 변경하지 않고 결과를 새로 생성
public class Test {
public static void main(String[] args) {
List<String> original = List.of("apple", "banana", "orange");
// 스트림을 통해 대문자로 변환한 새 리스트 생성
List<String> upperCaseList = original.stream()
.map(String::toUpperCase)
.toList();
System.out.println("원본 리스트: " + original);
System.out.println("변환된 리스트: " + upperCaseList);
}
}
// 원본 리스트: [apple, banana, orange]
// 변환된 리스트: [APPLE, BANANA, ORANGE]
3. 지연 연산(Lazy Operation)
public class Test {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "mango", "peach", "orange");
// 파이프라인 구성: filter -> map -> forEach
Stream<String> stream = fruits.stream()
.filter(fruit -> {
boolean b = fruit.length() > 5;
System.out.println("5글자보다 긴 과일에 대한 filter() 실행 결과 : " + b);
return b;
}) // 중간 연산 1
.map(fruit -> {
String upperCase = fruit.toUpperCase();
System.out.println("모두 대문자로 변경하는 map() 실행 결과 : " + upperCase);
return upperCase;
});// 중간 연산 2
System.out.println("----- 중간 연산 실행 -----");
stream.forEach(s -> System.out.println("최종 연산 실행 결과 : " + s));
}
}
// ----- 중간 연산 실행 -----
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : BANANA
// 최종 연산 실행 결과 : BANANA
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : ORANGE
// 최종 연산 실행 결과 : ORANGE
위 결과를 보면 중간 연산(filter, map)을 정의하는 시점에는 실제 연산이 실행되지 않고 최종 연산인 forEach가 호출될 때 모든 연산이 실행되는 것을 확인할 수 있다.
이렇게 데이터를 사용하는 시점까지 연산을 미루는 것을 지연 연산이라고 한다.
4. 파이프 라인 방식
public class Test {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "mango", "peach", "orange");
// 파이프라인 구성: filter -> map -> forEach
fruits.stream()
.filter(fruit -> {
boolean b = fruit.length() > 5;
System.out.println("5글자보다 긴 과일에 대한 filter() 실행 결과 : " + b);
return b;
}) // 중간 연산 1
.map(fruit -> {
String upperCase = fruit.toUpperCase();
System.out.println("모두 대문자로 변경하는 map() 실행 결과 : " + upperCase);
return upperCase;
}) // 중간 연산 2
.forEach(word -> System.out.println("처리된 단어: " + word)); // 최종 연산
}
}
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : BANANA
// 처리된 단어: BANANA
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : ORANGE
// 처리된 단어: ORANGE
위 코드를 보면 filter → map → forEach 순서로 연산을 진행하였다.
근데 결과를 확인해보면 뭔가 이상한 점을 확인할 수 있다.
filter 과정에서 컬렉션 내부의 요소를 전부 처리한 뒤 그 다음 중간 연산 map으로 가는 것이 아니라 filter의 조건을 만족하는 요소가 나오면 해당 요소는 그 즉시 다음 중간 연산 map으로 넘어가는 것을 확인할 수 있다.
이렇게 데이터를 한 번에 일괄 처리하는 방식이 아닌 한 요소가 중간 연산을 통과하면 바로 다음 연산으로 넘어가는 체이닝 방식을 파이프 라인 방식이라고 한다.
그렇다면 자바 스트림이 파이프 라인 방식을 선택한 이유는 무엇일까?
public class Test {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "mango", "peach", "orange");
// 파이프라인 구성: filter -> map -> findFirst
Optional<String> first = fruits.stream()
.filter(fruit -> {
boolean b = fruit.length() > 5;
System.out.println("5글자보다 긴 과일에 대한 filter() 실행 결과 : " + b);
return b;
}) // 중간 연산 1
.map(fruit -> {
String upperCase = fruit.toUpperCase();
System.out.println("모두 대문자로 변경하는 map() 실행 결과 : " + upperCase);
return upperCase;
}) // 중간 연산 2
.findFirst(); // 최종 연산
System.out.println("결과 " + first);
}
}
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : BANANA
// 결과 Optional[BANANA]
최종 연산을 forEach가 아닌 findFirst로 바꿔서 실행하여 보았다.
처음 필터를 조건을 통과한 결과가 나오면 다음 요소에 대해선 실행을 하지 않는 것을 확인할 수 있다.
이렇게 결과를 찾은 시점에서 그 다음의 불필요한 연산을 줄일 수 있으며(쇼트 서킷), 지연 연산을 이용해 중간 연산의 결과를 저장할 필요가 없으므로 메모리를 효율적으로 사용할 수 있다는 장점이 있다.
5. 병렬 처리(Parallel)
public class Test {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "mango", "peach", "orange");
long startTime = System.currentTimeMillis();
fruits.stream()
.parallel()
.filter(fruit -> {
boolean b = fruit.length() > 5;
try {
Thread.sleep(100);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println("5글자보다 긴 과일에 대한 filter() 실행 결과 : " + b);
return b;
}) // 중간 연산 1
.map(fruit -> {
String upperCase = fruit.toUpperCase();
System.out.println("모두 대문자로 변경하는 map() 실행 결과 : " + upperCase);
return upperCase;
}) // 중간 연산 2
.forEach(s -> System.out.println("결과 : " + s)); // 최종 연산
long endTime = System.currentTimeMillis();
System.out.println("병렬 처리 시간: " + (endTime - startTime) + "ms");
}
}
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : false
// 모두 대문자로 변경하는 map() 실행 결과 : ORANGE
// 결과 : ORANGE
// 5글자보다 긴 과일에 대한 filter() 실행 결과 : true
// 모두 대문자로 변경하는 map() 실행 결과 : BANANA
// 결과 : BANANA
// 병렬 처리 시간: 107ms
여기서 처음 필터 과정이 100ms의 시간이 걸리는 작업이라고 가정해보자.
그럼 리스트의 요소가 5개이므로 500ms가 걸릴 것이라고 예상하지만 병렬 스트림(parallel())을 적용하였더니 107ms라는 짧은 시간이 걸린 것을 확인하였다.
이처럼 멀티 코어 환경에서 작업을 여러 스레드로 분할하여 동시에 처리하는 병렬 스트림 기능을 쉽게 사용할 수 있도록 지원한다.
성능 비교
그렇다면 for문을 통한 반복 대신 무조건 스트림을 사용하는 것이 편한 것 아닌가 생각할 수 있다.
따라서 아래 예시를 통해 for문과, 스트림, 기본형 스트림의 성능을 대략적으로 비교하였다.
public class Test {
public static void main(String[] args) {
int size = 50_000_000;
long[] primitiveArray = new long[size];
List<Long> wrapperList = new ArrayList<>();
// 배열 초기화
for (int i = 1; i <= size; i++) {
primitiveArray[i - 1] = i;
wrapperList.add((long) i);
}
// 1. 기본형 배열 - for문
measureTime("기본형 배열 - for문", () -> {
long sum = 0;
for (long l : primitiveArray) {
sum += l;
}
return sum;
});
// 2. 래퍼 클래스 컬렉션 - for문
measureTime("래퍼 클래스 컬렉션 - for문", () -> {
long sum = 0;
for (Long aLong : wrapperList) {
sum += aLong;
}
return sum;
});
// 3. 스트림 API
measureTime("래퍼 클래스 - Stream API", () -> wrapperList.stream().reduce(0L, Long::sum));
// 4. 기본형 스트림
measureTime("기본형 스트림", () -> LongStream.of(primitiveArray).sum());
}
private static void measureTime(String testName, TestFunction func) {
long start = System.currentTimeMillis();
long result = func.execute();
long end = System.currentTimeMillis();
System.out.printf("%s: 결과=%d, 실행 시간 = %sms%n", testName, result, (end - start));
}
@FunctionalInterface
interface TestFunction {
long execute();
}
}
//기본형 배열 - for문: 결과=1250000025000000, 실행 시간 = 24ms
//래퍼 클래스 컬렉션 - for문: 결과=1250000025000000, 실행 시간 = 167ms
//래퍼 클래스 - Stream API: 결과=1250000025000000, 실행 시간 = 277ms
//기본형 스트림: 결과=1250000025000000, 실행 시간 = 29ms
연산의 종류에 따라 성능 차이는 달라질 수 있지만, 일반적으로 for문이 가장 빠르며, 기본형 스트림은 for문과 거의 유사한 성능을 보인다.
스트림 API의 경우 해당 예제에서는 박싱/언박싱을 통한 오버헤드가 많이 발생하므로 기본형과 성능 차이가 큰 것을 확인하였지만, 래퍼 클래스의 연산과 비교하면 약 1.7배 정도 차이가 나는 것을 확인하였다.
이렇게 대규모 데이터를 처리하는 경우에는 일반 스트림의 성능이 아쉬울 수 있지만, 그렇지 않은 경우 이런 성능 차이는 미미하며, 스트림이 가독성과 유지보수성이 뛰어나기 때문에 스트림 API를 사용하는 것이 좋은 선택이 될 수 있다.
'JAVA' 카테고리의 다른 글
[자바] ForkJoinPool (0) | 2025.04.07 |
---|---|
[자바] 스트림 API - 활용 (0) | 2025.04.06 |
[자바] 람다식과 함수형 인터페이스 (0) | 2025.04.03 |
[자바] 디폴트 메서드 (0) | 2025.04.03 |
[자바] Optional (0) | 2025.04.03 |