최근에 본 기술면접에서 Stream에 대한 질문이 있었다. Stream사용 경험과 사용시 불편한 점 혹은 주의할 점에 대한 질문이었다. Stream에 대해 제대로 학습해본 경험이 없던 나는 이에 대한 명확한 답변을 하지 못했고, Stream이 왜 primitive가 아닌 wrapper클래스를 사용하는지에 대한 기본적인 질문도 제대로 답변하지 못했다.
그때의 경험이 너무 아쉬워서 이번 기회에 Stream에 대해 제대로 학습하고, 그 당시 질문의 답변을 생각해보려고 한다.
스트림 API란?
- 자바 8부터 추가된 기능으로 데이터의 흐름을 추상화해서 다루는 도구이다.
- 컬렉션(Collection) 또는 배열 등의 요소들을 연산 파이프라인을 통해 연속적인 형태로 처리할 수 있게 해준다.
- 파이프라인: 여러 연산을 체이닝하여 데이터를 변환, 필터링, 계산하는 구조
스트림을 통해 어떻게 반복할지를 신경쓰기보다, 어떤 작업으로 결과가 어떻게 변환되는지에 집중할 수 있다. (선언형 프로그래밍)
Stream 구성
스트림 생성
- List의 stream()메서드를 사용하여 리스트를 자바가 제공하는 스트림으로 생성한다.
중간 연산 (Intermediate Operations)
- 스트림에서 필터, 매핑(다른 형태로 변환) 하는 단계이다.
- 메서드 참조를 통해 람다 표현식으로 데이터에 대한 연산을 수행한다.
최종 연산(Terminate Operation)
- Stream의 중간 연산에서 정의한 연산의 결과를 만들어 반환하는것.
특징
- 데이터 소스를 변경하지 않음 (Immutable)
- 원본 컬렉션(List, Set)을 변경하지 않고 결과만 새로 생성한다.
import java.util.*;
import java.lang.*;
import java.io.*;
class Main {
public static void main(String[] args) {
List<Integer> originList = List.of(1, 2, 3, 4);
System.out.println("origin = " + originList);
List<Integer> filteredList = originList.stream()
.filter(n -> n % 2 == 0)
.toList();
System.out.println("filteredList = " + filteredList);
System.out.println("origin = " + originList);
}
}
- 일회성
- 한 번 사용(소비)된 스트림은 다시 사용할 수 없음. 매번 새로 스트림을 생성해야함
- IllegalStateException
import java.util.*;
import java.lang.*;
import java.io.*;
class Main {
public static void main(String[] args) {
Stream<Integer> stream = Stream.of(1, 2, 3, 4);
stream.forEach(System.out::println); // 최초 실행
stream.forEach(System.out::println); // 중복 실행 (오류 발생)
}
}
- 파이프라인 구성
- 중간 연산(map, filter 등)들이 체이닝 되고, 최종 연산(forEach, collect, toArray...)을 만나면 연산이 수행되고 종료된다.
- 지연 연산
- 중간 연산은 필요할 때까지 실제로 동작하지 않고, 최종 연산이 실행될 때 한 번에 처리된다.
- 병렬 처리 용이
- 스트림으로 병렬 스트림을 만들어 멀티코어 환경에서 병렬 연산을 비교적 단순한 코드로 작성 가능함
파이프라인 구성 / 지연 연산
Stream은 중간 연산이 체이닝되며, 지연연산되는 특징이 있다고 했다. 이에 대한 예제를 통해 실제 Stream이 어떻게 동작하는지 보고자 한다.
예제 1.
해당 코드는 filter와 map에 로그를 남겨 어느 시점에 실행되는지 보고자 하는 코드이다.
import java.util.*;
import java.lang.*;
import java.io.*;
import java.util.stream.Stream;
class Main {
public static void main(String[] args) {
List<Integer> result = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
.filter(i -> {
boolean isEven = i % 2 == 0;
System.out.println("filter() 실행 : " + i + "("+isEven + ")");
return isEven;
})
.map(i -> {
int mapped = i * 10;
System.out.println("map() 실행 : " + i + " -> "+mapped);
return mapped;
})
.toList();
}
}
아래에는 코드를 실행한 결과이다. 결과를 자세히 보면, 일반적으로 생각하는 방식인 filter전부 실행 후 map을 실행하는것이 아니다. 각 원소별로 filter실행 후 다음 단계로 넘어갈 수 있다면 다음 중간 연산을 실행하는 방식이다. 이것이 파이프라이닝과 지연연산의 실제 동작 과정인 것이다.
filter() 실행 : 1(false)
filter() 실행 : 2(true)
map() 실행 : 2 -> 20
filter() 실행 : 3(false)
filter() 실행 : 4(true)
map() 실행 : 4 -> 40
filter() 실행 : 5(false)
filter() 실행 : 6(true)
map() 실행 : 6 -> 60
filter() 실행 : 7(false)
filter() 실행 : 8(true)
map() 실행 : 8 -> 80
filter() 실행 : 9(false)
filter() 실행 : 10(true)
map() 실행 : 10 -> 100
예제 2.
다음 예제는 스트림을 정의했을때 실질적으로 어느 시점에 연산이 되는가에 대한 예제이다. Stream의 특징 중 지연 연산의 내용을 보면, 최종 연산이 실행될 때 한 번에 실행된다고 되어있다. 이를 검증하기 위해 아래 코드를 보면 Stream에 최종 연산을 제외하고 정의한 후 나중에 최종연산을 정의한 모습이다.
import java.util.*;
import java.lang.*;
import java.io.*;
import java.util.stream.Stream;
class Main {
public static void main(String[] args) {
System.out.println("=== Stream 정의 ===");
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
.filter(i -> {
boolean isEven = i % 2 == 0;
System.out.println("filter() 실행 : " + i + "("+isEven + ")");
return isEven;
})
.map(i -> {
int mapped = i * 10;
System.out.println("map() 실행 : " + i + " -> "+mapped);
return mapped;
});
System.out.println("=== Stream 정의 종료 ===");
System.out.println("=== Stream 최종 연산 정의 ===");
stream.toList();
System.out.println("=== Stream 최종 연산 종료 ===");
}
}
결과를 보면 최종 연산이 호출된 후 Stream의 모든 중간 연산들이 실행되는것을 볼 수 있다.
=== Stream 정의 ===
=== Stream 정의 종료 ===
=== Stream 최종 연산 정의 ===
filter() 실행 : 1(false)
filter() 실행 : 2(true)
map() 실행 : 2 -> 20
filter() 실행 : 3(false)
filter() 실행 : 4(true)
map() 실행 : 4 -> 40
filter() 실행 : 5(false)
filter() 실행 : 6(true)
map() 실행 : 6 -> 60
filter() 실행 : 7(false)
filter() 실행 : 8(true)
map() 실행 : 8 -> 80
filter() 실행 : 9(false)
filter() 실행 : 10(true)
map() 실행 : 10 -> 100
=== Stream 최종 연산 종료 ===
정리하자면, Stream API는 최종 연산이 정의된 시점에 Collection의 요소 하나씩 연산 체이닝 순서대로 처리한다.
지연 연산과 최적화
지연 연산의 특징을 통해 얻을 수 있는 이점이 있다.
- 최종 연산 미호출시 불필요한 연산 방지
- 결과를 내는 최소한의 연산만 수행함 (단축 평가/short-circuit)
- 조건을 만족하는 결과를 찾으면 더 이상 연산을 진행하지 않음
- findFirst()를 최종 연산으로 하는 경우, 결과로 발생하는 첫번째 요소까지만 연산하고 나머지는 연산하지 않음.
- 메모리 사용 효율
- 중간 연산 결과를 단계마다 별도의 자료구조에 저장하지 않고, 최종 연산 때까지 필요할 때만 가져와서 처리한다.
- Stream은 따로 저장공간을 가지지 않음, 원본 Collection을 참조함
Stream 사용 방법
Stream 생성
방법 | 코드 | 특징 |
컬렉션 | list.stream() | 컬렉션에서 메소드로 스트림 생성 |
배열 | Arrays.stream(arr) | 배열에서 스트림 생성 |
Stream.of(...) | Stream.of("a", "b", "c") | 직접 요소 입력하여 스트림 생성 |
무한 스트림(iterate) | Stream.iterate(0, n -> n + 2) | 초기값 + 함수를 규칙으로 무한 스트림 생성 |
무한 스트림(generate) | Stream.generate(Math::random) | Supplier 사용하여 무한 스트림 생성 |
BufferedReader | BufferedReader br = new ...... Stream<String> lines = br.lines() |
파일/콘솔 입력을 한 줄씩 읽어 스트림으로 생성 |
그 외 | BitSet, Pattern, JarFile 등 | JDK의 여러 클래스가 자체적으로 스트림 제공 |
무한스트림이란?
말 그대로 무한의 값을 가진 스트림을 말한다. 실제로 limit이 걸려있지 않은 무한 스트림에 forEach로 출력해보면, 끝 없이 값이 출력된다. 무한 스트림에 limit()를 통해 최대 크기를 제한하여 사용 가능.
중간 연산 (Intermediate Operation)
연산 | 설명 | 예시 |
filter | 조건에 맞는 요소만 남김 | stream.filter(n -> n > 5) |
map | 요소를 다른 형태로 변환 | stream.map(n -> n * 2) |
flatMap | 중첩 구조 스트림을 일차원으로 평탄화 | stream.floatMap(list -> list.stream()) |
distinct | 중복 요소 제거 | stream.distinct() |
sorted | 요소 정렬 | stream.sorted() stream.sorted(Coparator.reverseOrder()) |
peek | 중간 처리 (로깅, 디버깅) | stream.peek(System.out::println) |
limit | 앞 부터 N개 요소 추출 | stream.limit(5) |
skip | 앞에서 N개 요소 건너뛰고 이후 요소만 추출 | stream.skip(5) |
takeWhile | 조건을 만족하는 동안 요소 추출 (Java 9+) | stream.takeWhile(n -> n < 5) |
dropWhile | 조건을 만족하는 동안 요소를 버리고, 이후 요소만 추출 | stream.dropWhile(n -> n < 5) |
takeWhile, dropWhile
두 메소드는 Java9 이후 등장한 메소드로 조건이 "한 번"이라도 false가 되는 경우 전부 무시하거나 추출하는 특징을 가진 메소드이다.
takeWhile은 조건이 거짓이 되는 순간 스트림을 멈추기 때문에, 원하는 목적을 빨리 달성하면 성능을 최적화 할 수 있다.
dropWhile은 조건을 만족하는 동안 요소를 버리고, 처음 거짓이 되는 지점부터 스트림을 구성한다.
두 기능은 정렬된 스트림에서 사용할 때 유용하고, 정렬되지 않은 경우 예측하기 어려운 특징이 있다.
이에 대한 예제는 아래와 같다.
import java.util.*;
import java.lang.*;
import java.io.*;
import java.util.stream.Stream;
class Main {
public static void main(String[] args) {
System.out.println("=== takeWhile 시작 ====");
Stream.of(1, 2, 3, 4, 5, 1, 2, 3, 4)
.takeWhile(n -> n < 5)
.forEach(System.out::println);
System.out.println("=== DropWhile 시작 ====");
Stream.of(1, 2, 3, 4, 5, 1, 2, 3, 4)
.dropWhile(n -> n < 5)
.forEach(System.out::println);
}
}
=== takeWhile 시작 ====
1
2
3
4
=== DropWhile 시작 ====
5
1
2
3
4
Stateless/Stateful operations
중간 연산은 Stateless operation과 Stateless operation으로 나뉜다. Stateless operation은 요소를 처리할 때 이전에 확인된 요소의 상태를 유지하지 않고, Stateful operation은 이전에 확인된 요소의 상태를 유지한다.
즉, Stateless operation은 요소에 대한 연산이 독립적으로 처리될 수 있고, Stateful operation은 전체 요소에 대한 입력이 주어져야 처리가 가능하다.
그러므로 Stateless operation은 멀티 스레드로(ParallelStream)동작 되어도 안정성과 성능을 보장하지만, Stateful operation은 안정성과 성능을 보장하지 않으니 사용시 유의해야한다.
종류 | 메소드 |
Stateful operation | distinct(), sorted(), limit(), skip() |
Stateless operation | 그 외 나머지 연산 |
최종 연산 (Terminal Operation)
연산 | 설명 | 예시 |
collect | Collector를 사용하여 결과 수집 (다양한 형태로 변환 가능. list, set, collection...) |
stream.collect(Collectors.toList()) |
toList (Java16+) | 스트림을 불변 리스트로 수집 | stream.toList() |
toArray | 스트림을 배열로 변환 | stream.toArray(Integer[]::new) |
forEach | 각 요소에 대해 동작 수행 (반환값 없음) | stream.forEach(Syste,.out::println) |
count | 요소 개수 반환 | long count = stream.count(); |
reduce | 함수를 사용해 모든 요소를 단일 결과로 합침 초기값이 없다면 Optional로 반환 |
int sum = stream.reduce(0, Integer::sum) |
min/max | 최소값, 최대값 반환 (Optional) | stream.min(Integer::compareTo) stream.max(Integer::compareTo) |
findFirst | 조건에 맞는 첫 번째 요소 반환(Optional) | stream.findFirst() |
findAny | 조건에 맞는 아무 요소 반환(Optional) | stream.findAny() |
anyMatch | 하나라도 조건을 만족하는지 boolean 반환 | stream.anyMatch(n -> n > 5) |
allMatch | 모두 조건을 만족하는지 boolean 반환 | stream.allMatch(n -> n > 5) |
noneMatch | 하나도 조건을 만족하지 않는지 boolean 반환 | stream.noneMatch(n -> n > 5) |
기본형 특화 스트림
IntStream, LongStream, DoubleStream 클래스로 기본 자료형(int, long, double)에 특화된 기능을 사용할 수 있다. 특히, 기본 Stream의 경우 primitive타입에 대한 오토 박싱으로 인한 오버헤드가 발생하지만, 기본형 특화 스트림에는 발생하지 않는다.
- 기본형 특화 스트림을 사용하면 statistics 메소드를 사용하여 간편하게 처리 가능하고, 박싱/언박싱 오버헤드를 줄여 성능상의 이점도 얻을 수 있다.
- range(), rangeClosed()로 범위를 쉽게 다룰 수 있다.
- 객체 스트림과 기본형 특화 스트림을 자유롭게 오가며 다양한 작업을 가능하다.
오토 박싱과 기본형 특화 스트림에는 오버헤드가 발생하지 않는 이유
Java(1.5이상)에서는 기본 타입과 Wrapper클래스를 컴파일러가 필요한 상황에 자동으로 변환을 해준다. 그리고 이때 내부적으로 추가 연산 작업으로 인해 오버헤드가 발생한다. primitive는 스택에 저장되고, wrapper 객체는 힙에 저장되어 gc 관리를 받기 때문에 메모리 할당 과정 그리고 GC부담 및 메모리 낭비가 발생한다.
Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5); // 오토 박싱 발생
그러나 기본형 특화 스트림은 내부 구현이 primitive타입으로 동작하도록 구현되어있다. 즉, 내부 연산 과정에서 wrapper클래스를 사용하지 않으므로 오토 박싱이 발생하지 않는것이고, 동일한 연산을 오버헤드 없이 수행 가능한 것이다.
아래의 코드처럼 동일한 작업을 기본형 특화 스트림을 통해 수행할 수 있다.
Stream<Integer> stream = Stream.of(1, 2, 3, 4);
int sum = stream.reduce(0, Integer::sum); // reduce 과정에서 unboxing 반복
IntStream intStream = IntStream.of(1, 2, 3, 4);
int sum = intStream.sum(); // 원시 연산 그대로 처리
종류
스트림 타입 | 대상 원시 타입 | 생성 예시 |
IntStream | int | IntStream.of(,,,) IntStream.range(시작, 종료) stream.mapToInt() |
LongStream | long | LongStream.of(,,,) LongStream.range(시작, 종료) stream.mapToLong() |
DoubleStream | double | DoubleStream.of(,,,) DoubleStream.generate(Math::random) stream.mapToDouble() |
range와 rangeClosed
기본형 특화 스트림에서 범위 안의 값을 자동으로 값을 생성하는 방법으로 range와 rangeClosed가 있다.
메소드 | 설명 |
range(int startInclusive, int endExclusive) | 시작값 이상, 끝값 미만 |
rangeCLOSED(int startInclusive, int endExclusive) | 시작값 이상, 끝값 이하 |
주요 메서드
메서드/기능 | 설명 | 예시 |
sum(), min(), max(), count() |
모든 요소의 합계, 최소, 최대, 개수를 구함 | int total = IntStream.of(1, 2, 3).sum() |
average() | 모든 요소의 평균을 구함. OptionalDouble을 반환 |
double avg = IntStream.range(1, 5).summaryStatistics(); |
summaryStatistics() | 최소값, 최대값, 합계, 개수, 평균 등이 담긴 Int(Long/Double)SummaryStatistics 객체 반환 |
IntSummaryStatisitcs stats = IntStream.range(1, 5).summaryStatistics(); |
mapToInt() mapToLong() mapToDouble() |
타입 변환 | LongStream ls = IntStream.of(1, 2).mapToLong()i -> i * 1L); IntStream is = Stream.of("1", "2").mapToInt(Integer::parseInt); |
mapToObj() | 객체 스트림으로 변환 기본형 -> 참조형 (기본 스트림임) |
Stream<String> s = IntStream.range(1, 5).mapToObj(Integer::toString); |
boxed() | 기본형 특화 스트림을 박싱(Wrapper)된 객체 스트림으로 변환 | Stream<Integer> s = IntStrea.range(1, 5).boxed(); |
Stream forEach 성능
Stream을 사용하는 이유는 연속되는 원소에 대한 연산 후 결과를 만들어내기 위함이다. 이때 요소 조회를 위해 forEach를 사용하게되는데 for-loop를 사용하는것보다 느리다고 한다.
실험 코드 (누적합 구현)
import java.util.*;
import java.lang.*;
import java.util.stream.IntStream;
class Main {
public static void main(String[] args) {
int[] arr = IntStream.rangeClosed(1, 100000).toArray();
long startTime = System.nanoTime();
int sum = 0;
for(int i = 0; i < arr.length; i++){
sum += arr[i];
}
long endTime = System.nanoTime();
System.out.println("for-loop : " + (endTime - startTime));
startTime = System.nanoTime();
sum = Arrays.stream(arr).sum();
endTime = System.nanoTime();
System.out.println("IntStream : " + (endTime - startTime));
}
}
결과
for-loop : 864250
IntStream : 2170166
실험 결과 약 2.5배 차이가 발생했다. 대용량의 데이터를 처리한다면 이보다 더 큰 차이가 발생할 것이다.
성능 차이가 발생한 이유는 다음과 같다.
- JIT Compiler
- JVM의 실행 엔진 중 JIT 컴파일러는 내부적으로 for-loop를 loop unscrolling기법으로 최적화 하고있으며 오랜 기간 축적된 최적화 노화우로 기계어 근접한 성능을 낸다고 한다.
- 그와 반면 forEach는 비교적 최근에 등장하여 JIT 컴파일러가 for loop보다 최적화 하지 못한다.
- 인덱스 기반 메모리 접근
- 배열은 직접적으로 인덱스 기반 메모리에 접근하지만, forEach는 참조 레이어가 존재하기때문에 이에 대한 오버헤드가 존재한다.
기술 면접 실패 경험
Stream의 불편한 점 / 주의할 점
내가 했던 답
Stream을 사용하며 겪은 불편한 점으로 primitive타입이 아닌 wrapper타입으로 인해 박싱/언박싱 하는 과정이 필요하다는 것이 가장 불편하다고 답변했었다.
그러나 이 답변은 stream에 대해 모르고 사용하는 전형적인 사용자 관점의 답변이었다. Stream의 어떤 특성이나, 성격으로 인한 불편한 점/ 주의할 점이 아닌, 그저 Stream 숙련도 부족으로 인한 불편함을 이야기했었다.
Stream을 학습 한 후 답변
Stream을 학습한 후 생각하는 불편한 점 / 주의할 점은 다음과 같다.
- 재사용 불가
- Stream의 특징으로 재사용 불가가 있다.
- 이미 최종 연산이 호출된 stream은 재사용이 불가능하기때문에, 결과를 따로 저장하거나 다시 생성해야한다.
- 최종 연산 누락
- Stream은 지연 연산 특성으로 최종 연산이 실행되기 전까지는 중간 연산이 실행되지 않는다.
- stream 남용
- 너무 많은 중간 연산 혹은 복잡한 중간 연산을 사용하면 오히려 for-loop를 사용했을때 보다 가독성이 떨어진다.
- 박싱/언박싱 오버헤드
- 자바 Stream은 내부적으로 오토박싱이 일어나는데, 이에 대한 오버헤드에 주의해야한다.
- 만약 int, long, double과 같은 기본형 스트림이 존재하는 경우, 대체하여 사용하는것이 좋다.
- 의도치 않은 무한스트림
- iterate연산에 정의한 내용이 무한 스트림을 제한하려고 등록한 limit()의 조건에 도달하지 못 할수도 있다.
- 연산 순서에 따른 성능 혹은 결과 차이
- Stream의 지연 연산에 따른 앞 단의 operation 결과 수가 성능에 영향을 줄 수 있다.
- 중간 연산에서 limit(), findFirst()등의 연산은 앞 단의 연산 결과에 따라 다른 값을 반환 할 수 있다.
- 병렬스트림
- 병렬스트림에 대한 정리는 따로 할 예정이지만, 병렬스트림 사용시에도 주의점이 필요하다.
- Stateful operation, 스레드 오버헤드 등
Stream은 왜 Wrapper class를 사용하는가?
정답을 먼저 말하자면 제너릭(Generic) 때문이다.
당시 이 질문을 받고서는 제대로 된 답변을 하지 못했다. 앞의 질문에서 stream에 대한 지식이 없다는것을 보여주었다는 생각에 긴장하고, 횡설수설 했다. 전달 하고자 하는 의도는 제너릭 타입이기 때문인데, "primitive타입은 기능이 없고... wrapper엔 기능이 있고"와 같은 답변을 했고, 내가 무슨 말을 하는지도 모르는 상태였다. 면접이 끝나고 복기하며 이 부분이 너무나 아쉬웠다.
제너릭(Generic)
java.util.stream JDK 문서를 보면 interface summary에 Stream<T>으로 되어있다. <T>에 해당하는 부분이 제너릭 타입을 의미하고, 이것 때문에 Wrapper class를 사용하도록 되어있어서 박싱/언박싱의 귀찮은 과정을 하도록 하는것이다.
제너릭에 대한 설명을 하기엔 주제가 벗어나니, 나중에 제너릭에 대해 정리할 예정이다.
References
- https://www.inflearn.com/course/%EA%B9%80%EC%98%81%ED%95%9C%EC%9D%98-%EC%8B%A4%EC%A0%84-%EC%9E%90%EB%B0%94-%EA%B3%A0%EA%B8%89-3/dashboard
- https://docs.oracle.com/javase/8/docs/api/java/util/stream/package-summary.html
- https://inpa.tistory.com/entry/JAVA-%E2%98%95-wrapper-class-Boxing-UnBoxing
- https://devm.io/java/java-performance-tutorial-how-fast-are-the-java-8-streams-118830
- https://hamait.tistory.com/547
'CS' 카테고리의 다른 글
[JAVA] Stream collect메소드와 Collector, Collectors 개념 및 사용방법 정리 (+ 다운 스트림) (2) | 2025.06.06 |
---|---|
Gradle과 Gradlew 그리고 Gradlew 동작 과정 (1) | 2025.04.30 |
[JAVA] 배열(Array) 개념과 Arrays사용방법 정리 (0) | 2025.03.21 |
정규화와 반정규화(비정규화)란? (1) | 2025.03.17 |
OSI 7계층과 계층별 역할 (1) | 2025.01.19 |