Java Stream - 놓치기 쉬운 개념들

이강현·2025년 5월 11일

놓치기 쉬운 개념들

목록 보기
18/19

최소 지식의 원칙 (a.k.a. Demeter의 원칙, 기차 충돌)

✅ 최소 지식 원칙은 자신이 직접 알고 있는 친한 객체와만 상호작용하라는 원칙으로,
점('.' 참조)을 2개 이상 연달아서 사용하지 말라는 원칙으로도 잘 알려져 있습니다.

❓ Stream 을 사용하다 보면 메서드 체이닝을 통해 참조를 여러번 거듭하게 됩니다.
이는 최소 지식 원칙에 위배되는 것일까요?

✅ 그렇지 않습니다.

스트림의 중간 연산은 기존 스트림을 변경하지 않으면서, 그 연산이 적용된 새로운 스트림을 반환합니다.

중간 연산을 거듭하면서 반환하는 객체는 서로 다르더라도, 모두 주체인 Stream 클래스와 동일한 Stream 클래스를 반환합니다.

즉 자기 자신의 클래스와 상호작용 하는 것이며, 이는 직접 알고있는 친한 객체에 부합합니다.

❓ 그럼에도 불구하고 스트림을 사용하면 코드 한줄이 매우 길어지고 가독성이 떨어지는 것은 사실입니다.

✅ 이를 해결하는 방법은 다음과 같이 생각해 볼 수 있습니다.

  • Method Extraction
    • 대부분 매개변수인 람다식이 상당한 길이를 차지합니다.
    • 람다식을 메서드 추출 해서, 메서드 작명을 잘 해둔다면 가독성을 올릴수 있습니다.
  • 중간 연산 분리
    • 메서드 체이닝을 끊고, 중간 연산의 결과 스트림을 변수로 받습니다.
    • 변수 작명을 잘 해둔다면 가독성을 올릴 수 있습니다.
  • 줄 바꿈과 주석
    • 메서드 체이닝 중간 중간에 들여쓰기와 줄 바꿈을 넣습니다.
    • 한줄 한줄 주석을 잘 달아둔다면 가독성을 올릴 수 있습니다.


분할과 통합

스트림은 최종 연산과 함께 스트림의 요소를 소모합니다.

따라서 최종 연산이 수행된 스트림은 닫혀 다시 사용할 수 없습니다.

❓ 만약 복잡한 중간연산 후에 여러가지의 최종연산을 수행하고 싶다면 어떻게 해야 할까요?
스트림을 깊은 복사 할 수 있을까요?

✅ 스트림은 Collection 이 아니며, 내부 상태도 복사 불가능합니다.

스트림은 기본적으로 데이터가 아니라 연산의 파이프라인입니다.

중간 연산은 실제로 아무 일도 하지 않으며, 최종 연산 시에만 실행됩니다.

이를 Lazy Evaluation (지연된 연산) 이라고 합니다.

✅ 최종 연산을 여러변 하고 싶다면, 다음과 같은 해결방안을 생각해 볼 수 있습니다.

  • 새로운 스트림 만들기

    • 복잡한 중복 연산이 코드 중복을 만들더라도 새로운 스트림을 만들어 해결합니다.
  • 스트림 내용을 캐싱

    • collect() 를 통해 중간 연산의 결과를 저장합니다.
    • 그 결과를 통해 새로운 스트림을 만들어 최종 연산들을 수행합니다.

❓ 만일 동일한 데이터 소스로 부터 서로 다른 스트림의 중간 결과를 통합하고 싶다면 어떻게 할까요?

예를 들어 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();



sorted()

❓Stream 클래스의 sorted 메서드는 왜 이름이 sort 가 아니라 sorted 일까요?

✅ stream 은 원본을 변경하지 않고 새로운 스트림이나 결과를 생성해서 반환합니다.

sort something 과 sorted version of something 의 의미 차이를 내포하려고 한 것입니다.


forEach() VS iterator()

스트림의 최종 연산인 forEach 는 데이터 소스를 추상화한 이점을 활용해
내부 반복을 수행합니다.

이를 통해 반복하는 행위의 캡슐화와 코드가 간결해지는 효과도 얻었습니다.

❓ 그렇다면 Stream 은 Iterator 가 없을까요?

✅ Stream 혹은 기본형 스트림의 조상인 BaseStream 인터페이스에는 iterator() 메서드가 정의되어 있습니다.

iterator() 메서드도 forEach 와 마찬가지로 최종 연산입니다.

❓ 동일한 데이터 소스에 대해 forEach 와 iterator, 둘 중 뭐가 더 효율적일까요?

✅ stream 보다 iterator 나 for 를 사용하는 방식이 더 효율적일 때도 있습니다.

OOP 의 개념들은 때로는 실행 효율과 상반될 수도 있습니다.

그러나 때때로 발생하는 실행 비효율 보다는 stream 을 통해 달성한 추상화, 캡슐화, 코드 간결화를 통해 얻어지는 확장성, 재사용성, 유지보수성 등 넓은 범위의 효율을 더 높개 평가한 결과입니다.


병렬 스트림

✅ 사실 stream 의 강점은 병렬처리에 있습니다.

Stream 의 parallel() 메서드를 통해 기본 sequential() 이었던 스트림을 병렬 스트림으로 바꿀 수 있습니다.

병렬 스트림은 스트림이 내부적으로 요소별 작업을 멀티 쓰레드를 사용해 병렬로 처리합니다.

  • 직렬 스트림
    • forEach(): 순차적으로 요소에 대해 작업을 수행합니다.
    • forEachOrdered(): 직렬에서는 forEach 와 차이가 없으며, 명시적으로 순서를 보장한다는 의미만 가지고 있습니다.
  • 병렬 스트림
    • forEach(): 순서를 보장하지 않고 요소에 대한 작업을 병렬로 처리해 통합합니다.
    • forEachOrdered(): 병렬 처리후 순서를 보장하기 위해 정렬 과정을 거칩니다.

❓ 병렬 스트림의 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

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



profile
백엔드 개발자 지망생입니다.

0개의 댓글