스트림 API
Stream API는 잘 알려진 map-filter-reduce 알고리즘을 JDK에 구현한 것이다. 컬렉션 프레임워크는 JVM의 메모리에 데이터를 저장하고 구성하는 역할을 한다. 반면, Stream API는 이 데이터를 효율적으로 처리할 수 있게 도와주는 보조 프레임워크라고 볼 수 있다. 실제로, 컬렉션에 대해 스트림을 열어 그 안의 데이터를 처리할 수 있다. 하지만 Stream API는 여기서 멈추지 않는다. 단지 컬렉션의 데이터만 처리하는 것이 아니라, 입출력(I/O) 소스를 포함한 다양한 소스에 대해 스트림을 생성할 수 있는 여러 패턴을 JDK에서 제공한다. 게다가, 자신만의 데이터 소스를 손쉽게 정의하여 필요에 딱 맞는 스트림을 만들 수도 있다. Stream API를 잘 다루게 되면, 매우 표현력 있는 코드를 작성할 수 있게 된다.
Map-Filter-Reduce 알고리즘
이 알고리즘은 데이터를 처리할 때 매우 고전적이고 널리 쓰이는 방식이다.
public record Sale(String product, LocalDate date, int amount) {
// this class already contains a
// constructor, getters, setters
// equals, hashCode, toString
}
Sale 객체가 위와 같을 때, 10월 판매 총액을 계산해야 한다고 가정해 보자. 아마 아래와 같은 코드를 작성할 것이다.
List<Sale> sales = List.of(
new Sale("Orange", LocalDate.of(2022, Month.JANUARY, 2), 4),
new Sale("Pumpkin", LocalDate.of(2022, Month.OCTOBER, 18), 12),
new Sale("Butternut", LocalDate.of(2022, Month.OCTOBER, 03), 6),
new Sale("Apple", LocalDate.of(2022, Month.AUGUST, 28), 3)
);
int amountSoldInOctober = 0;
for (Sale sale: sales) {
if (sale.date().getMonth() == Month.OCTOBER) {
amountSoldInOctober += sale.amount();
}
}
System.out.println("Amount sold in October: " + amountSoldInOctober);
이 간단한 데이터 처리 알고리즘에서는 세 가지 단계를 볼 수 있다. 첫 번째 단계는 3월에 발생한 판매만을 고려하는 것이다. 즉, 주어진 기준에 따라 처리 중인 일부 요소들을 걸러내는 것으로, 이것이 바로 필터링 단계다. 두 번째 단계는 판매 객체에서 특정 속성을 추출하는 것이다. 전체 객체에는 관심이 없고, 필요한 것은 amount 속성이다. 즉, 판매 객체를 금액(int 값)으로 매핑하는 과정이다. 이것이 바로 매핑 단계이며, 처리 중인 객체를 다른 객체나 값으로 변환하는 과정이다. 마지막 단계는 이 금액들을 하나의 값으로 합산하는 것이다. SQL 언어에 익숙하다면, 이 마지막 단계가 집계와 유사하다는 것을 알 수 있을 것이다. 실제로도 동일한 작업입니다. 이 합산은 각각의 금액들을 하나로 축소하는 과정이다.
알고리즘을 프로그래밍하는 대신 결과를 지정하기
SQL에서는, 우리가 작성하는 것이 원하는 결과에 대한 설명이라는 점을 알 수 있다. 예를 들어, “3월에 이루어진 모든 판매의 금액 합계”라는 결과를 선언적으로 표현하는 것이다. 그리고 그 결과를 어떻게 효율적으로 계산할지는 데이터베이스 서버의 책임이다.
반면, Java 코드에서 이 금액을 계산하는 방식은 그 금액이 어떻게 계산되는지를 단계별로 구체적으로 명령하는 것이다. 즉, 명령형(imperative) 방식으로 기술되며, Java 런타임이 이 계산을 최적화할 수 있는 여지가 거의 없다. Stream API는 이런 문제를 해결하기 위해 다음 두 가지 목표를 가지고 설계되었다.
- 더 읽기 쉽고 표현력 있는 코드를 작성할 수 있도록 하고
- Java 런타임이 계산을 최적화할 수 있는 유연성을 제공하는 것이다.
1:N 관계 처리를 위한 스트림의 flatMap
스트림을 루프 안에서 사용하는 방식은 map-reduce 패턴에 잘 어울리지 않으며, 보기에도 좋지 않다. 이때 사용하는 것이 바로 flatMap 연산자다. 이 연산자는 객체 간 1:N 관계를 펼쳐서 스트림으로 만들어주는 역할을 한다. flatMap() 메서드는 인자로 Stream을 반환하는 함수를 받는다. 클래스 간 관계를 이 함수가 정의하는 것이다.
flatMap() 메서드는 두 단계를 거친다.
- 스트림의 각 요소에 함수를 적용하여
Stream<Stream<T>>구조를 만든다. - 이중 스트림을 평탄화하여 하나의 스트림으로 만든다.
record City(String name, int population) {}
record Country(String name, List<City> cities) {}
City newYork= new City("New York", 8_258);
City losAngeles = new City("Los Angeles", 3_821);
Country usa = new Country("USA", List.of(newYork, losAngeles));
City london = new City("London", 8_866);
City manchester = new City("Manchester", 568);
Country uk = new Country("United Kindgom", List.of(london, manchester));
City paris = new City("Paris", 2_103);
City marseille = new City("Marseille", 877);
Country france = new Country("France", List.of(paris, marseille));
List<Country> countries = List.of(usa, uk, france);
int totalPopulation = 0;
for (Country country: countries) {
totalPopulation += country.cities().stream().mapToInt(City::population).sum();
}
System.out.println("Total population = " + totalPopulation);
위와 같이 루프 안에서 스트림을 사용하는 코드를 flatMap 메서드를 사용하면 아래와 같이 수정할 수 있다.
record City(String name, int population) {}
record Country(String name, List<City> cities) {}
City newYork= new City("New York", 8_258);
City losAngeles = new City("Los Angeles", 3_821);
Country usa = new Country("USA", List.of(newYork, losAngeles));
City london = new City("London", 8_866);
City manchester = new City("Manchester", 568);
Country uk = new Country("United Kindgom", List.of(london, manchester));
City paris = new City("Paris", 2_103);
City marseille = new City("Marseille", 877);
Country france = new Country("France", List.of(paris, marseille));
List<Country> countries = List.of(usa, uk, france);
int totalPopulation =
countries.stream()
.flatMap(country -> country.cities().stream())
.mapToInt(City::population)
.sum();
System.out.println("Total population = " + totalPopulation);
flatMap과 mapMulti를 이용한 요소 변환 및 검증
flatMap 연산은 스트림의 요소를 변환하면서 유효성 검사를 수행할 때 유용하게 사용할 수 있다. 예를 들어, 정수를 나타내는 문자열 스트림이 있다고 가정해 보자. 이 문자열들을 Integer.parseInt()로 정수로 변환해야 한다. 하지만 일부 문자열은 손상되어 있을 수 있다. 이런 경우에는 NumberFormatException이 발생하게 된다. 이러한 문제를 피하려고 filter를 사용할 수도 있지만, 가장 안전한 방법은 try-catch 패턴을 활용하는 것이다.
Predicate<String> isANumber = s -> {
try {
int i = Integer.parseInt(s);
return true;
} catch (NumberFormatException e) {
return false;
}
};
이 방식의 문제점은 다음과 같다.
- 실제로 파싱을 두 번 수행해야 함: 한 번은
filter에서, 한 번은map에서 catch블록에서 값을 반환하는 건 권장되지 않는 패턴임
flatMap을 사용하는 올바른 방식
Function<String, Stream<Integer>> flatParser = s -> {
try {
return Stream.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
}
return Stream.empty();
};
List<String> strings = List.of("1", " ", "2", "3 ", "", "3");
List<Integer> ints =
strings.stream()
.flatMap(flatParser)
.collect(Collectors.toList());
System.out.println("ints = " + ints);
정상적인 문자열은 Stream.of(정수)를 반환하고, 그렇지 않은 경우는 Stream.empty()를 반환한다. 손상된 문자열은 조용히 제거된다. 그러나 이 방식은 각 요소마다 새로운 스트림 객체를 생성하므로 성능 면에서 부담이 있다.
Java 16부터 도입된 mapMulti() 사용
Java 16부터는 flatMap의 이 오버헤드를 줄이기 위해 mapMulti()라는 메서드가 도입되었다. 이 메서드는 스트림을 생성하지 않고도 유사한 처리를 할 수 있게 해 준다.
mapMulti()는 BiConsumer를 인자로 받으며, 다음 두 요소를 사용한다.
- 스트림의 원소
- 결과를 추가하기 위한
Consumer
Consumer.accept()를 호출하면 해당 값이 결과 스트림에 추가되며, 그렇지 않으면 아무것도 추가되지 않는다.
List<String> strings = List.of("1", " ", "2", "3 ", "", "3");
List<Integer> ints =
strings.stream()
.<Integer>mapMulti((string, consumer) -> {
try {
consumer.accept(Integer.parseInt(string));
} catch (NumberFormatException ignored) {
}
})
.toList();
System.out.println("ints = " + ints);
스트림 연결하기
Stream API는 여러 개의 스트림을 하나로 연결하는 다양한 패턴을 제공한다. 가장 명확한 방법은 Stream 인터페이스에 정의된 팩토리 메서드인 concat()을 사용하는 것이다. 이 메서드는 두 개의 스트림을 받아, 첫 번째 스트림의 요소들 다음에 두 번째 스트림의 요소들을 이어 붙인 스트림을 생성한다. 이 메서드가 왜 여러 개의 스트림을 받을 수 있도록 vararg를 사용하지 않는지 궁금할 수 있다. 그 이유는 두 개의 스트림을 연결할 때는 이 메서드를 사용하는 것이 괜찮지만, 두 개를 초과하는 스트림을 연결해야 하는 경우에는 JavaDoc API 문서에서 flatMap을 사용하는 또 다른 패턴을 사용하라고 권장하기 때문이다.
flatMap() 방식이 더 나은 이유는 concat()은 연결 과정에서 중간 스트림들을 생성하기 때문이다. Stream.concat()을 사용할 경우, 두 스트림을 연결하기 위해 새로운 스트림이 생성된다. 만약 세 개 이상의 스트림을 연결하려 한다면, 첫 번째 연결을 위한 스트림 하나, 두 번째 연결을 위한 또 다른 스트림이 필요하게 된다. 즉, 연결할 때마다 새로운 스트림이 생성되고, 연산이 끝나면 버려지게 된다. 반면 flatMap 패턴을 사용하면, 모든 스트림을 담는 단일 스트림을 만든 후, 이를 flatMap으로 펼치기만 하면 된다. 따라서 오버헤드가 훨씬 낮다.
그렇다면 왜 이 두 가지 패턴이 모두 존재하는 걸까? 겉보기에는 concat()이 별로 유용하지 않아 보일 수 있다. 하지만 사실 concat()과 flatMap()이 만들어내는 스트림에는 미묘한 차이가 있다. 연결하는 두 스트림의 원본 크기를 알고 있다면, 결과 스트림의 크기도 알 수 있다. 실제로 두 스트림의 크기를 더한 값이 된다. 하지만 flatMap을 사용할 경우, 결과 스트림에서 처리할 요소의 정확한 개수는 예측할 수 없다. Stream API는 결과 스트림에서 처리할 요소 개수를 추적하지 못하게 된다. 다시 말해 concat()은 SIZED 스트림을 생성하지만, flatMap()은 그렇지 않다.
스트림의 특성
Stream API는 Spliterator 인터페이스의 특별한 객체에 의존한다. 이 인터페이스의 이름은 split과 iterator를 합친 것으로, Stream API에서 spliterator의 역할이 Collection API의 iterator와 유사하기 때문이다. 또한 Stream API는 병렬 처리를 지원하므로, spliterator 객체는 스트림의 요소들을 병렬 처리를 위해 여러 CPU에 어떻게 분할할지 제어하는 역할도 한다. spliterator 객체가 스트림의 특성을 담고 있다는 점은 알고 있어야 한다. 이러한 특성들은 자주 사용되지는 않지만, 어떤 경우에는 더 나은 성능과 효율적인 스트림 파이프라인을 작성하는 데 도움이 된다.
| 특성 (Characteristic) | 설명 (Comment) |
|---|---|
| ORDERED | 스트림 요소가 처리되는 순서가 중요함 |
| DISTINCT | 스트림에 중복된 요소가 없음 |
| NONNULL | 스트림에 null 요소가 없음 |
| SORTED | 스트림의 요소가 정렬되어 있음 |
| SIZED | 스트림이 처리할 요소의 수를 알고 있음 |
| SUBSIZED | 스트림을 분할해도 각각의 스트림도 SIZED 특성을 가짐 |
모든 스트림은 생성될 때 이러한 특성들을 설정하거나 설정하지 않은 상태로 가진다. 스트림은 다음 두 가지 방식으로 생성된다.
- 데이터 소스로부터 직접 생성
- 기존 스트림에 중간 연산을 호출하여 새 스트림 생성
스트림의 특성은 해당 스트림이 어떤 소스로부터 생성되었는지 또는 어떤 연산을 통해 기존 스트림으로부터 생성되었는지에 따라 달라진다. 특정 스트림이 특정 특성을 가지고 있는지 확인하려면, 스트림의 spliterator 내부의 비트 플래그를 검사해야 한다. 다음은 ORDERED 특성이 있는지 확인하는 자바 코드다.
Predicate<Stream<?>> isOrdered =
stream -> ((stream.spliterator().characteristics() & Spliterator.ORDERED) != 0) ;
Stream<Integer> stream = List.of(1, 2, 3).stream();
boolean ordered = isOrdered.test(stream);
System.out.println("ordered = " + ordered);
Ordered Streams
ORDERED 스트림은 순서가 정의된 데이터 소스로부터 생성된다. 가장 대표적인 예는 List 인터페이스의 인스턴스다. 그 외에도 Files.lines(path)나 Pattern.splitAsStream(string) 역시 ORDERED 스트림을 생성한다. 스트림 요소의 순서를 유지하는 것은 병렬 스트림에서는 오버헤드를 유발할 수 있다. 만약 이러한 순서 특성이 필요하지 않다면, 기존 스트림에 unordered() 중간 연산을 호출하여 이 특성을 제거할 수 있다. 이 메서드는 ORDERED 특성이 제거된 새로운 스트림을 반환한다.
왜 이런 작업이 필요할까? 스트림의 ORDERED 특성을 유지하면 성능이 저하될 수 있기 때문이다. 특히 병렬 스트림을 사용할 때 이러한 특성은 비용이 클 수 있다.
Sorted Streams
SORTED 스트림은 정렬된 스트림을 의미한다. 이 스트림은 다음과 같은 방식으로 생성될 수 있다.
TreeSet과 같이 정렬된 소스로부터 생성되는 경우- 스트림에서
sorted()메서드를 호출하여 정렬한 경우
스트림 구현은 스트림이 이미 정렬된 것을 알고 있다면, 불필요한 정렬을 피함으로써 최적화할 수 있다. 하지만 항상 이 최적화가 적용되는 것은 아니다. 그 이유는 이미 정렬된 스트림(SORTED)이라도 다른 비교자를 사용하여 다시 정렬할 수 있기 때문이다.
또한 몇몇 중간 연산은 SORTED 특성을 제거한다. 다음 예제를 보면 이 점을 확인할 수 있다.
List<String> strings = List.of("abc", "bcd", "cde");
Stream<String> filteredSortedStrings = strings.stream().sorted().filter(s -> s.length() > 2);
Stream<Integer> lengths = filteredSortedStrings.map(String::length);
strings.stream()→ 소스가List라 순서가 있는ORDERED스트림이지만SORTED는 아님filteredSortedStrings→sorted()호출로 인해SORTED스트림이 됨lengths→map()연산은SORTED특성을 제거하므로 더 이상SORTED가 아님
이처럼 일부 중간 연산은 정렬 특성을 무효화시키므로, SORTED 특성은 상황에 따라 유지되지 않을 수 있다.
Distinct Streams
DISTINCT 스트림이란, 중복된 요소가 없는 스트림을 말한다. 이 특성은 다음의 경우에 획득된다.
HashSet과 같이 중복을 허용하지 않는 자료구조로부터 스트림을 생성한 경우distinct()중간 연산을 사용한 경우
특성 유지 여부
filter()를 사용해 요소를 걸러내는 경우 →DISTINCT특성이 유지된다.map()또는flatMap()을 사용하는 경우 →DISTINCT특성이 제거된다.
Predicate<Stream<?>> isDistinct =
stream -> ((stream.spliterator().characteristics() & Spliterator.DISTINCT) != 0);
List<String> strings = List.of("one", "two", "two", "three", "four", "five");
System.out.println("Is strings distinct? " + isDistinct.test(strings.stream()));
Stream<String> distinct = strings.stream().distinct();
System.out.println("Is distinct sorted? " + isDistinct.test(distinct));
Stream<String> filtered = strings.stream().distinct().filter(s -> s.length() < 5);
System.out.println("Is filteredStrings sorted? " + isDistinct.test(filtered));
Stream<Integer> lengths = strings.stream().distinct().filter(s -> s.length() < 5).map(String::length);
System.out.println("Is lengths sorted? " + isDistinct.test(lengths));
strings.stream()→List로부터 생성되어 중복 요소가 있을 수 있으므로DISTINCT아님.strings.stream().distinct()→distinct()를 호출했기 때문에DISTINCT특성 가짐.filter()는 요소를 제거만 하므로 중복을 새로 만들지 않아DISTINCT유지됨.map()은 새로운 값으로 변환하므로, 중복 여부를 보장할 수 없어DISTINCT특성이 제거됨.
Non-Null Streams
NONNULL 스트림은 null 값을 포함하지 않는 스트림을 의미한다. 다음과 같은 자료구조나 스트림 생성 방식은 null 값을 허용하지 않기 때문에 NONNULL 특성을 갖는다.
ArrayDeque,ArrayBlockingQueue,ConcurrentSkipListSet,ConcurrentHashMap.newKeySet()같은 동시성 컬렉션Files.lines(path)Pattern.splitAsStream(line)Map.values()등 일부 컬렉션은 구현 방식상null값을 생성하지 않는다.
Predicate<Stream<?>> isNonNull =
stream -> ((stream.spliterator().characteristics() & Spliterator.NONNULL) != 0);
Map<Integer, String> hashMap = new HashMap<>();
Collection<String> values = hashMap.values();
System.out.println("Values from hash map is non null? " + isNonNull.test(values.stream()));
Collection<String> queue = new ArrayDeque<String>();
System.out.println("ArrayDeque is non null? " + isNonNull.test(queue.stream()));
HashMap.values()는 내부적으로는null값을 허용할 수 있기 때문에NONNULL특성을 갖지 않음ArrayDeque는null요소를 허용하지 않으므로NONNULL스트림이 생성됨
Sized and Subsized Streams
Sized Streams
이 특성은 병렬 스트림을 사용할 때 매우 중요하다. SIZED 스트림은 처리할 요소의 개수를 미리 알고 있는 스트림을 의미한다. Collection 인터페이스의 인스턴스로부터 생성된 스트림은 SIZED 스트림이다. 왜냐하면 Collection은 size() 메서드를 가지고 있어서 요소의 수를 쉽게 알 수 있기 때문이다. 반면, 스트림이 유한한 개수의 요소를 처리한다는 것을 알고 있더라도, 그 정확한 개수는 스트림을 직접 처리해보지 않으면 알 수 없는 경우도 있다. 예를 들어 Files.lines(path)로 생성한 스트림이 그렇다. 텍스트 파일의 바이트 크기는 알 수 있지만, 그 안에 몇 줄이 있는지는 파일을 분석해야만 알 수 있다. 또한 Pattern.splitAsStream(line)으로 생성한 스트림도 마찬가지다. 분석 대상 문자열의 문자 수를 안다고 해서, 이 패턴이 몇 개의 요소를 만들어낼지는 알 수 없다.
Subsized Streams
SUBSIZED 특성은 병렬 스트림이 여러 부분으로 분할될 때 각 부분이 얼마만큼의 데이터를 가질지 미리 알 수 있는지 여부와 관련이 있다. 병렬 처리에서는 스트림을 두 부분으로 나눠서, 사용 가능한 CPU 코어에 작업을 분배한다. 이 스트림 분할은 Spliterator라는 내부 도구에 의해 수행되며, 이는 데이터 소스의 타입에 따라 달라진다. 예를 들어 ArrayList로부터 스트림을 생성한다고 해보자. ArrayList는 내부적으로 빈틈없는 배열로 데이터를 저장한다. 요소를 삭제하면 뒤에 있는 모든 요소를 왼쪽으로 한 칸씩 이동시키므로, 배열에는 빈칸이 없다. 따라서 ArrayList의 스트림을 나누는 것은 간단하다. 그냥 배열을 반으로 나누면 된다. 이처럼 분할 후 각 부분의 요소 개수를 미리 알 수 있으므로, ArrayList의 스트림은 SUBSIZED이다. 반면 HashSet의 경우는 다르다. HashSet도 배열을 사용하지만, 배열의 각 칸에 여러 요소가 들어갈 수 있다. 배열을 반으로 나눈다고 해도, 각 절반에 들어 있는 요소 개수를 미리 알 수 없다. 그래서 HashSet으로부터 생성된 스트림은 SIZED이지만 SUBSIZED는 아니다.
스트림을 변환하면, 그 스트림이 SIZED 또는 SUBSIZED 특성을 유지할 수도 있고 잃을 수도 있다.
map(),sorted()같은 연산은SIZED및SUBSIZED특성을 유지한다.flatMap(),filter(),distinct()같은 연산은 이 특성을 없애버린다.
Sized Streams 과 Subsized Streams 예제
Predicate<Stream<?>> isSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SIZED) != 0);
Predicate<Stream<?>> isSubSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SUBSIZED) != 0);
List<String> strings = new ArrayList<>();
System.out.println("Array list is sized? " + isSized.test(strings.stream()));
System.out.println("Array list is subsized? " + isSubSized.test(strings.stream()));
Array list is sized? true
Array list is subsized? true
Predicate<Stream<?>> isSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SIZED) != 0);
Predicate<Stream<?>> isSubSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SUBSIZED) != 0);
Set<String> strings = new HashSet<>();
System.out.println("Hash set is sized? " + isSized.test(strings.stream()));
System.out.println("Hash set is subsized? " + isSubSized.test(strings.stream()));
Hash set is sized? true
Hash set is subsized? false
Predicate<Stream<?>> isSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SIZED) != 0);
Predicate<Stream<?>> isSubSized =
stream -> ((stream.spliterator().characteristics() & Spliterator.SUBSIZED) != 0);
System.out.println("Pattern split as stream is sized? " +
isSized.test(Pattern.compile(" ").splitAsStream("Hello duke!")));
System.out.println("Pattern split as stream is subsized? " +
isSubSized.test(Pattern.compile(" ").splitAsStream("Hello duke!")));
Pattern split as stream is sized? false
Pattern split as stream is subsized? false
groupingBy를 이용한 Map 수집
그룹화 결과를 후처리 하기
groupingBy는 하위 수집기를 인자로 받을 수 있으며, 이를 이용해 그룹화된 리스트에 대해 추가 처리를 할 수 있다.
Map<Integer, Long> map =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.counting()));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
Map<Integer, String> map =
strings.stream()
.collect(
Collectors.groupingBy(
String::length,
Collectors.joining(", ")));
map.forEach((key, value) -> System.out.println(key + " :: " + value));
결과를 생성할 수 없는 메서드를 지원하기
값을 생성할 수 없는 경우에 Optional 클래스를 사용할 수 있다는 점은 에러 처리 측면에서 개선된 방법을 제공해 준다. 이는 Optional 객체를 사용해야 하는 주요 이유이기도 하다. 즉, 특정 상황에서 메서드가 결과를 생성하지 못할 수 있음을 명확히 표현하는 것이 목적이다. 하지만 Optional은 필드에 저장하거나, 리스트나 맵에 저장하거나, 메서드 인자로 전달하는 용도로 만들어진 것이 아니다. Optional을 반환하는 메서드를 설계하거나 Optional을 변수에 저장해야 할 경우, null을 반환하거나 null로 설정해서는 안 된다. 대신, 해당 Optional이 비어 있을 수 있음을 활용해야 한다. 간단히 말해, Optional 클래스는 참조형을 감싸는 래퍼 클래스다: Optional<T> 또는 OptionalInt, OptionalLong, OptionalDouble 등이다. 기존의 래퍼 타입(Integer, Long, Double 등)과 다른 점은, Optional 객체는 “비어 있을 수 있다”는 것이다. 즉, 아무것도 감싸지 않은 상태로 존재할 수 있다. 메서드에서 “값이 없다”는 의미를 반환해야 하고, null을 반환하면 NullPointerException 등의 오류가 발생할 수 있는 경우, Optional 객체를 반환하는 방식을 고려해야 한다. 이 경우 빈 Optional(empty optional)을 반환하는 것이 좋다.
Optional을 올바르게 사용하기 위한 규칙
- 규칙 1:
Optional변수나 반환값에 절대null을 사용하지 말 것. - 규칙 2:
Optional이 비어 있지 않다는 확신이 없으면orElseThrow()나get()을 호출하지 말 것. - 규칙 3:
ifPresent(),orElseThrow(),get()보다는 대체 가능한 방법을 선호할 것. - 규칙 4: 참조가
null인지 검사하지 않기 위해Optional을 만들지 말 것. - 규칙 5: 필드, 메서드 파라미터, 컬렉션, 맵에서는
Optional을 사용하지 말 것. - 규칙 6:
Optional객체에 대해 참조 동등성 비교,identityhash code, 동기화와 같은 정체성에 민감한 연산을 하지 말 것. - 규칙 7:
Optional객체는 직렬화할 수 없다는 점을 잊지 말 것.
'자바' 카테고리의 다른 글
| Stream mapMulti 메서드 (0) | 2025.07.02 |
|---|---|
| IO Stream 정리 (0) | 2025.07.01 |
| The Collections Framework 정리 (3) | 2025.06.13 |
| Generics 정리 (2) | 2025.06.12 |
| NotNull과 NonNull (0) | 2025.06.10 |