✅ 최소 지식 원칙은 자신이 직접 알고 있는 친한 객체와만 상호작용하라는 원칙으로,
점('.' 참조)을 2개 이상 연달아서 사용하지 말라는 원칙으로도 잘 알려져 있습니다.
❓ Stream 을 사용하다 보면 메서드 체이닝을 통해 참조를 여러번 거듭하게 됩니다.
이는 최소 지식 원칙에 위배되는 것일까요?
✅ 그렇지 않습니다.
스트림의 중간 연산은 기존 스트림을 변경하지 않으면서, 그 연산이 적용된 새로운 스트림을 반환합니다.
중간 연산을 거듭하면서 반환하는 객체는 서로 다르더라도, 모두 주체인 Stream 클래스와 동일한 Stream 클래스를 반환합니다.
즉 자기 자신의 클래스와 상호작용 하는 것이며, 이는 직접 알고있는 친한 객체에 부합합니다.
❓ 그럼에도 불구하고 스트림을 사용하면 코드 한줄이 매우 길어지고 가독성이 떨어지는 것은 사실입니다.
✅ 이를 해결하는 방법은 다음과 같이 생각해 볼 수 있습니다.
스트림은 최종 연산과 함께 스트림의 요소를 소모합니다.
따라서 최종 연산이 수행된 스트림은 닫혀 다시 사용할 수 없습니다.
❓ 만약 복잡한 중간연산 후에 여러가지의 최종연산을 수행하고 싶다면 어떻게 해야 할까요?
스트림을 깊은 복사 할 수 있을까요?
✅ 스트림은 Collection 이 아니며, 내부 상태도 복사 불가능합니다.
스트림은 기본적으로 데이터가 아니라 연산의 파이프라인입니다.
중간 연산은 실제로 아무 일도 하지 않으며, 최종 연산 시에만 실행됩니다.
이를 Lazy Evaluation (지연된 연산) 이라고 합니다.
✅ 최종 연산을 여러변 하고 싶다면, 다음과 같은 해결방안을 생각해 볼 수 있습니다.
새로운 스트림 만들기
스트림 내용을 캐싱
❓ 만일 동일한 데이터 소스로 부터 서로 다른 스트림의 중간 결과를 통합하고 싶다면 어떻게 할까요?
예를 들어 OR 연산을 하고 싶다면 보통 다음과 같은 방식을 사용하지만
List<String> source = List.of("apple", "banana", "apple", "cherry", "banana");
// or in single stream
Stream<String> stream = source.stream()
.filter(s -> s.startsWith("a") || s.startsWith("b"))
.distinct();
✅ concat() 과 distinct() 를 통해 서로 다른 스트림으로 부터 OR 연산을 할 수 있습니다.
// OR from two different stream
// using concat, distinct
Stream<String> streamA = source.stream().filter(s -> s.startsWith("a"));
Stream<String> streamB = source.stream().filter(s -> s.startsWith("b"));
Stream<String> combined = Stream.concat(streamA, streamB)
.distinct();
✅ flatMap() 과 distinct() 를 통해 OR 연산을 할 수도 있습니다.
// using flatMap, distinct
Stream<String> combined = Stream.of(
source.stream().filter(s -> s.startsWith("a")),
source.stream().filter(s -> s.startsWith("b"))
)
.flatMap(Function.identity())
.distinct();
❓Stream 클래스의 sorted 메서드는 왜 이름이 sort 가 아니라 sorted 일까요?
✅ stream 은 원본을 변경하지 않고 새로운 스트림이나 결과를 생성해서 반환합니다.
sort something 과 sorted version of something 의 의미 차이를 내포하려고 한 것입니다.
스트림의 최종 연산인 forEach 는 데이터 소스를 추상화한 이점을 활용해
내부 반복을 수행합니다.
이를 통해 반복하는 행위의 캡슐화와 코드가 간결해지는 효과도 얻었습니다.
❓ 그렇다면 Stream 은 Iterator 가 없을까요?
✅ Stream 혹은 기본형 스트림의 조상인 BaseStream 인터페이스에는 iterator() 메서드가 정의되어 있습니다.
iterator() 메서드도 forEach 와 마찬가지로 최종 연산입니다.
❓ 동일한 데이터 소스에 대해 forEach 와 iterator, 둘 중 뭐가 더 효율적일까요?
✅ stream 보다 iterator 나 for 를 사용하는 방식이 더 효율적일 때도 있습니다.
OOP 의 개념들은 때로는 실행 효율과 상반될 수도 있습니다.
그러나 때때로 발생하는 실행 비효율 보다는 stream 을 통해 달성한 추상화, 캡슐화, 코드 간결화를 통해 얻어지는 확장성, 재사용성, 유지보수성 등 넓은 범위의 효율을 더 높개 평가한 결과입니다.
✅ 사실 stream 의 강점은 병렬처리에 있습니다.
Stream 의 parallel() 메서드를 통해 기본 sequential() 이었던 스트림을 병렬 스트림으로 바꿀 수 있습니다.
병렬 스트림은 스트림이 내부적으로 요소별 작업을 멀티 쓰레드를 사용해 병렬로 처리합니다.
❓ 병렬 스트림의 forEachOrdered 는 직렬 스트림의 forEach() 보다 효율적일까요?
✅ 요소의 수가 작거나 정렬 비용이 높다면 오히려 직렬보다 느릴 수 있습니다.
반대로 요소 수가 많거나 처리비용이 높으면 병렬이 유리합니다.
Stream<T> 제네릭에는 기본형을 사용할 수 없는 자바의 한계 때문에
데이터 소스를 완벽하게 추상화하는 것이 불가능했고
그에따라 Stream 외에 기본형 스트림을 만들게 되었습니다.
(이들을 한 계층 더 추상화 한 것이 BaseStream 입니다.)
반면, 특정 타입에 종속적이게 되면서 추가적인 기능을 제공할 수 있는 이점이 생겼습니다.
Boxing / Unboxing 의 부담을 없애고 추가적인 기능을 이용하려면 기본형 스트림을 사용하면 됩니다.
public class BasicStreamTest {
public static void main(String[] args) {
int[] intArray = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(intArray);
// Stream<Integer> boxedIntegerStream = Arrays.stream(intArray); // error
Stream<Integer> boxedIntegerStream = Arrays.stream(intArray).boxed(); // boxing
Integer[] integerArray = {1, 2, 3, 4, 5};
Stream<Integer> integerStream = Arrays.stream(integerArray);
// IntStream unboxedIntStream = Arrays.stream(integerArray); // error
IntStream unboxedIntStream = Arrays.stream(integerArray).mapToInt(Integer::intValue);
// IntStream iterStream = Stream.iterate(1, i -> i+1); // error
IntStream iterStream = Stream.iterate(1, i -> i+1).mapToInt(Integer::intValue); // unboxing
// IntStream genStream = Stream.generate(() -> 1); // error
IntStream genStream = Stream.generate(() -> 1).mapToInt(Integer::intValue); // error
}
}
Optional<T> 는 wrapper class 입니다.
멤버로 T value 를 가지고 있습니다.
Stream 을 사용하면서 참조 연산자를 연속해서 사용하게 되는데,
최종 연산의 결과는 Stream 이 아니며, null 이 될 수도 있습니다.
⚠️ 이렇게 null 을 직접 다루게 되면
NullPointerException 이 발생할 위험이 있습니다.
그럴 때마다 null check 를 하는 것은 번거로운 일입니다.
✅ 이를 해결하는 것이 Optional 입니다.
✅ orElse(), orElseGet(), orElseThrow() 를 통해 Optional 로 부터 값을 가져오면 됩니다.
get() 메서드가 있지만, 여전히 예외를 피하지 못하므로 사용하지 않습니다.
마찬가지로 제네릭을 사용하는 Optional 은 기본형 Optional 이 존재합니다.
기본형 변수는 기본 초기화 값이 있고,
이를 빈 Optional 상태와 구별할 수 없기에
isPresent 멤버를 가지고 있습니다.
보통은 최종 연산의 결과로 받은 Optional 을 사용하지만,
빈 Optional 을 만들어야 한다면 Optional.<T>empty() 혹은 Optional.<T>ofNullable()를 사용합니다.
이를 통해 Optional 은 null 이 될 수 없다는 일관성을 지킬 수 있습니다.
// Optional<String> optional = null; // not recommended
Optional<String> optional = Optional.<String>empty();
Object o = null;
Optional<Object> optOfNullable = Optional.ofNullable(o); // same result
Optional<Object> optOfEmpty = Optional.empty(); // same result