자바에서 배열이나 컬렉션을 다루기 위해 흔히 for문이나 Iterator를 사용하곤 했습니다. 하지만 다음과 같은 문제점이 존재합니다.
| 기존 방식의 문제점 | 설명 |
|---|---|
| 코드 가독성 저하 | 반복문이 중첩되거나 조건문이 섞이면 코드가 길고 복잡해짐 |
| 데이터 소스별 API 차이 | List, Set, 배열 등 각 데이터 소스마다 다른 방식으로 접근해야 함 |
| 재사용 어려움 | 반복문 기반 로직은 추상화가 어려워 재사용성이 낮음 |
이러한 문제를 해결하고자 도입된 것이 바로 Stream API입니다.
스트림은 자바 8부터 도입된 기능으로, 데이터 소스를 추상화하여 일관된 방식으로 처리할 수 있도록 도와주는 API입니다. 배열, 컬렉션, 파일 등 다양한 소스를 대상으로 동일한 연산이 가능하도록 합니다.
데이터 소스: 처리 대상 (예: 배열, 컬렉션, 파일 등)
중간 연산: map(), filter(), sorted() 등. 연산 결과로 새로운 스트림 반환
최종 연산: forEach(), collect(), count() 등. 연산을 종료하고 결과를 반환
| 특징 | 설명 |
|---|---|
| 데이터 변경 없음 | 스트림은 원본 데이터를 변경하지 않고 읽기만 함 |
| 일회성 사용 | 스트림은 한 번 소비되면 재사용 불가 |
| 내부 반복 | 반복 로직을 메서드 내부로 숨겨 코드 간결화 |
| 지연 연산(Lazy Evaluation) | 최종 연산이 수행되기 전까지 중간 연산은 실행되지 않음 |
| 기본형 스트림 제공 | 오토박싱/언박싱 비용을 줄이기 위해 IntStream, LongStream 등 지원 |
| 병렬 처리 지원 | parallel() 호출 시 병렬 스트림 생성 (Fork/Join 프레임워크 기반) |
컬렉션의 최고 조상인 Collection에 stream()이 정의되어 있다. 그래서 Collection의 자손인 List와 Set을 구현한 컬렉션 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다.
List<Integer> list = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream();
배열을 소스로 하는 스트림을 생성하는 메서드는 Stream과 Arrays에 static 메서드로 정의되어 있다.
Stream<String> strStream = Stream.of("a", "b", "c");
Stream<String> strStream = Stream.of(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"});
Stream<String> strStream = Arrays.stream(new String[]{"a", "b", "c"}, 0, 3);
int, long, double과 같은 기본형 배열을 소스로 하는 스트림을 생성하는 메서드도 있다. (IntStream, LongStream, DoubleStream)
IntStream IntStream.of(int ...values)
IntStream IntStream.of(int[])
IntStream Arrays.stream(int[])
IntStream Arrays.stream(int[] array, int startInclusive, int endExclusive)
난수를 생성하는데 사용하는 Random 클래스에는 해당 타입의 난수들로 이루어진 스트림을 반환하는 인스턴스 메서드들이 포함되어 있다.
ints() : Integer.MIN_VALUE ~ Integer.MAX_VALUE
longs() : Long.MIN_VALUE ~ Long.MAX_VALUE
doubles() : 0.0 ~ 1.0
// 무한 스트림
IntStream ints();
LongStream longs();
DoubleStream doubles();
IntStream intStream = new Random().ints();
intStream.limit(5).forEach(System.out::println); // 무한 스트림을 5개로 제한
// 유한 스트림
IntStream ints(long streamSize);
LongStream longs(long streamSize);
DoubleStream doubles(long streamSize);
IntStream intStream = new Random().ints(5);
IntStream과 LongStream은 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed()를 가지고 있다.
IntStream IntStream.range(int begin, int end) // end 포함 x
IntStream IntStream.rangeClosed(int begin, int end) // end 포함 o
IntStream.range(1, 5) // 1, 2, 3, 4
IntStream.rangeClosed(1, 5) // 1, 2, 3, 4, 5
지정된 범위의 난수를 발생시키는 스트림을 얻는 메서드도 있다.
IntStream ints(int begin, int end);
LongStream longs(long begin, long end);
DoubleStream doubles(double begin, double end);
IntStream ints(long streamSize, int begin, int end);
LongStream longs(long streamSize, long begin, long end);
DoubleStream doubles(long streamSize, double begin, double end);
Stream 클래스의 iterate()와 generate()는 람다식을 매개변수로 받아서, 이 람다식에 의해 계산되는 값들을 요소로 하는 무한 스트림을 생성한다.
static <T> Stream<T> iterate(T seed, UnaryOperator<T> f)
Stream<Integer> eventStream = Stream.iterate(0, n -> n + 2); // 0, 2, 4, 6, ...
static <T> Stream<T> generate(Supplier<T> s)
Stream<Double> randomStream = Stream.generate(Math::random);
Stream<Integer> oneStream = Stream.generate(() -> 1);
한 가지 주의할 점은 iterate()와 generate()에 의해 생성된 스트림을 아래와 같이 기본형 스트림 타입의 참조변수로 다룰 수 없다는 것이다. 굳이 필요하다면, mapToInt()와 같은 메서드로 변환을 해야 한다.
IntStream eventStream = Stream.iterate(0, n -> n + 2).mapToInt(Integer::valueOf);
Stream<Integer> stream = eventStream.boxed(); // IntStream -> Stream<Integer>
요소가 하나도 없는 비어있는 스트림을 생성할 수도 있다. 스트림에 연산을 수행한 결과가 하나도 없을 때, null보다 빈 스트림을 반환하는 것이 낫다.
Stream emptyStream = Stream.empty();
long count = emptyStream.count(); // 0
스트림이 제공하는 다양한 연산을 이용하면 복잡한 작업들을 간단히 처리할 수 있다. 스트림에 정의된 메서드 중에서 데이터 소스를 다루는 작업을 수행하는 것을 연산(operation)이라고 한다. 스트림이 제공하는 연산은 중간 연산과 최종 연산으로 분류할 수 있다.
중간 연산 : 연산결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다.
최종 연산 : 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.
stream.distinct().limit(5).sorted().forEach(System.out::println)
// 중간 연산 : distinct, limit, sorted
// 최종 연산 : forEach
스트림을 정렬할 때 사용한다.
Stream<T> sorted()
Stream<T> sorted(Comparator<? super T> comparator)
지정된 Comparator로 스트림을 정렬하는데, Comparator 대신 int 값을 반환하는 람다식을 사용하는 것도 가능하다.
Comparator를 지정하지 않으면 스트림 요소의 기본 정렬 기준(Comparable)으로 정렬한다. 단, 스트림의 요소가 Comparable을 구현한 클래스가 아니면 예외가 발생한다.
Stream<string> strStream = Stream.of("dd", "aaa", "CC", "cc", "b");
strStream.sorted().forEach(System.out::print) // CCaaabccdd
JDK1.8부터 Comparator 인터페이스에 정렬 관련 static 메서드와 디폴트 메서드가 많이 추가되었다. 이 메서드들은 모두 Comparator<T> 를 반환한다. 가장 기본적인 메서드는 comparing()이다.
// 스트림의 요소가 Comparable을 구현한 경우
comparing(Function<T, U> keyExtractor)
// 스트림의 요소가 Comparable을 구현하지 않은 경우
comparing(Function<T, U> keyExtractor, Comparator<U> keyComparator)
비교대상이 기본형인 경우, 오토박싱과 언박싱 과정이 없어서 더 효율적인 메서드를 사용한다.
comparingInt(ToIntFunction<T> keyExtractor);
comparingLong(ToLongFunction<T> keyExtractor);
comparingDouble(ToDoubleFunction<T> keyExtractor);
정렬 조건을 추가할 때는 thenComparing()을 사용한다. Comparator.naturalOrder()은 기본 정렬(Comparable)을 사용한다.
studentStream.sorted(Comparator.comparing(Student::getBan)
.thenComparing(Comparator.naturalOrder()))
.forEach(System.out::println);
연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶을 때 사용한다. forEach()와 달리 스트림 요소를 소모하지 않는다.
fileStream.map(File::getName)
.filter(s -> s.indexOf('.') != -1)
.peek(s -> System.out.printf("filename=%s%n", s))
.map(s -> s.substring(s.indexOf('.') + 1))
.peek(s -> System.out.printf("extension=%s%n", s))
.forEach(System.out::println);
스트림의 타입이 Stream<T[]> 인 경우, Stream<T> 로 변환해야 할 때 사용한다. (평탄화)
Stream<String[]> strArrStrm = Stream.of(
new String[]{"abc", "def", "jkl"},
new String[]{"ABC", "GHI", "JKL"}
);
Stream<String> strStrm = strArrStrm.flatMap(Arrays::stream);
Optional<T> 은 T타입의 객체를 감싸는 래퍼 클래스이다. 제네릭 타입이므로 Optional 타입의 객체에는 모든 타입의 객체를 담을 수 있다. java.util.Optional은 JDK1.8부터 추가되었다. 최종 연산을 Optional 객체에 담아서 반환하면 결과가 null인지 매번 if문으로 체크하는 대신 Optional에 정의된 메서드를 통해서 간단히 처리할 수 있다.
public final class Optional<T> {
private final T value;
}
Optional 객체를 생성할 때는 of() 또는 ofNullable()을 사용한다. 참조변수의 값이 null일 가능성이 있으면 ofNullable()을 사용한다. of()는 매개변수의 값이 null이면 NullPointerException이 발생한다.
Optional<String> optVal = Optional.of("abc");
Optional<String> optVal = Optional.ofNullable(null);
Optional<T> 타입의 참조변수를 기본값으로 초기화할 때는 empty()를 사용한다.
Optional<String> optVal = null // 가능하나 바람직하지 않음
Optional<String> optVal = Optional.empty();
Optional 객체에 지정된 값을 가져올 때는 get()을 사용한다. 값이 null일 때는 NoSuchElementException이 발생하며, 이를 대비해서 orElse()로 대체할 값을 지정할 수 있다.
optVal.get().orElse("");
orElse()의 변형으로는 null을 대체할 값을 반환하는 람다식을 지정할 수 있는 orElseGet()과 null일 때 지정된 예외를 발생시키는 orElseThrow()가 있다.
optVal.get().orElseGet(String::new);
optVal.get().orElseThrow(NullPointerException::new);
isPresent()는 Optional 객체의 값이 null이면 false를, 아니면 true를 반환한다. ifPresent()은 값이 있으면 주어진 람다식을 실행하고, 없으면 아무 일도 하지 않는다.
// isPresent()
if (Optional.ofNullable(str).isPresent()) {
System.out.println(str);
}
// ifPresent()
Optional.ofNullable(str).ifPresent(System.out::println);
IntStream과 같은 기본형 스트림의 최종 연산의 일부는 Optional 대신 기본형을 값으로 하는 OptionalInt, OptionalLong, OptionalDouble을 반환한다.
// IntStream에 정의된 메서드 일부
OptionalInt findAny()
OptionalInt findFirst()
OptionalInt reduce(IntBinaryOperator op)
OptionalInt max()
OptionalInt min()
OptionalDouble average()
기본형 Optional에서 값을 가져올 때는 다음과 같은 메서드를 사용한다.
| Optional 클래스 | 값을 반환하는 메서드 |
|---|---|
| OptionalInt | int getAsInt() |
| OptionalLong | long getAsLong() |
| OptionalDouble | double getAsDouble() |
기본형 int의 기본값이 0 이라고 해도 0을 지정한 것과 empty()를 통해 기본값을 지정한 것과는 다르다.
// OptionalInt 코드 일부
public final class OptionalInt {
...
private final boolean isPresent;
private final int value;
OptionalInt opt = OptionalInt.of(0); // 0을 저장
OptionalInt opt2 = OptionalInt.empty(); // 0을 저장
opt.isPresent() // true;
opt2.isPresent() // false;
조건검사 관련 메서드들은 아래와 같다.
boolean allMatch (Predicate<? super T> predicate) // 모든 요소가 일치하면 참
boolean anyMatch (Predicate<? super T> predicate) // 하나의 요소라도 일치하면 참
boolean noneMatch (Predicate<? super T> predicate) // 모든 요소가 불일치하면 참
// 예시
boolean noFailed = stuStream.anyMatch(s -> s.getTotalScore() <= 100);
스트림의 요소 중에서 조건에 일치하는 첫 번째 요소를 반환하는 findFirst()가 있는데, 주로 filter()와 함께 사용되어 조건에 맞는 스트림의 요소가 있는지 확인하는데 사용된다. 병렬 스트림인 경우에는 findFirst()대신 findAny()를 사용해야 한다.
Optional<Student> stu = stuStream.filter(s -> s.getTotalScore() < 100)
.findFirst();
Optional<Student> stu = parallelStream.filter(s -> s.getTotalScore() < 100)
.findAny();
매개변수가 1개일 때는 처음 두 요소를 가지고 연산한 결과를 가지고 그 다음 요소와 연산한다. 반환 타입은 Optional<T> 이다.
Optional<T> reduce(BinaryOperator<T> accumulator)
// 예시
String[] strArr = {
"Inheritance", "Java", "Lambda", "stream",
"OptionalDouble", "IntStream", "count", "sum"
};
IntStream intStream = Stream.of(strArr).mapToInt(String::length);
OptionalInt max = intStream.reduce(Integer::max);
매개변수가 2개일 때는 초기값(identity)를 지정할 수 있으며, 초기값과 스트림의 첫 번째 요소로 연산을 시작한다. 이 경우 스트림의 요소가 하나도 없는 경우, 초기값이 반환되므로, 반환 타입이 T이다.
T reduce(T identity, BinaryOperator<T> accumulator)
// 예시
int count = intStream.reduce(0, (a,b) -> a + 1);
| 구분 | 설명 | 형태 | 비고 |
|---|---|---|---|
collect() | 스트림의 최종 연산으로, 요소들을 가공해 하나의 결과로 수집 | 메서드 | Stream<T>에서 호출 가능 |
Collector | collect()에서 사용할 수 있는 수집 전략을 정의한 인터페이스 | 인터페이스 | 직접 구현 가능 (고급 활용 시) |
Collectors | Collector 구현체들을 정적 메서드 형태로 제공하는 유틸리티 클래스 | 클래스 | toList(), joining(), groupingBy() 등 제공 |
스트림의 모든 요소를 컬렉션에 수집하려면, Collectors 클래스의 toList()와 같은 메서드를 사용하면 된다. List나 Set이 아닌 특정 컬렉션을 지정하려면, toCollection()에 원하는 컬렉션의 생성자 참조를 매개변수로 넣어주면 된다.
// toList
List<String> names = stuStream.map(Student::getName)
.collect(Collectors.toList());
// toCollection
ArrayList<String> list = names.stream()
.collect(Collectors.toCollection(ArrayList::new));
Map은 키와 값의 쌍으로 저장해야하므로 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 지정해줘야 한다.
// toMap
Map<String, Person> map = personStream
.collect(Collectors.toMap(p -> p.getRegId(), p -> p));
스트림에 저장된 요소들을 T[] 타입의 배열로 변환하려면, toArray()를 사용하면 된다. 해당 타입의 생성자 참조를 매개변수로 지정해줘야 하며, 지정하지 않으면 반환되는 배열의 타입은 Object[] 이다.
// 매개변수 지정
Student[] stuNames = studentStream.toArray(Student[]::new);
// 매개변수 지정 X
Object[] stuNames = studentStream.toArray();