새로운 내용을 공부할 때
새로운 내용의 공부를 시작할 때 용어의 정의를 이해하지 못하거나 정확하게 알지 못한다면 그 용어가 포함된 문장을 이해하지 못합니다.
작은 단어 하나가 내용을 이해하지 못하게 하기 때문에 용어를 정확하게 이해하는 것이 중요합니다.

2 분 소요

스트림은 별도로 분리해서 학습을 정리하겠습니다.

자세한 스트림 사용 방법이 아니라 스트림의 정의에 대해서만 학습했습니다.

스트림

제가 생각하는 Stream은 다양한 순차(sequence) 자료를 연산을 통해 변환하고 최종 연산을 사용자가 선택하여 비즈니스 상황에 맞는 결과로 처리하는 도구입니다.

컬렉션은 데이터를 어디서 사용하는지에 따라 효율적으로 관리하기 위해 도와주는 프레임 워크라고 생각됩니다.
만약 복잡한 연산이나 그룹화가 필요하다면 그만큼 반복문을 외부에 노출하게 됩니다.

스트림은 그런 과정을 람다 표현식이나 체이닝 메서드를 통해서 가독성과 의미를 파악할 수 있게 도와줍니다.

스트림이란 ? 데이터 처리 연산을 지원하도록 소스에서 추출된 연속된 요소로 정의할 수 있습니다.

키워드

  1. Lazy Evaluation
  2. Pipelining

특징

  • 선언형: 메서드를 통해 제어하므로 간결하고 가독성이 좋아집니다.
  • 조립이 가능하다: 스트림 API중 중간에 다른 스트림 API를 변경할 수 있습니다.
  • 병렬화 : 간단하게 API를 추가하여 병렬 처리가 가능합니다.
    단, 병렬 성능이 높아지려면 참조의 지역성이 좋아야합니다. 즉, 캐시 히트가 높아야합니다.

  • 파이프라이닝: 스트림 연산은 스트림 연산끼리 연결하여 거대한 파이프 라인을 구성할 수 있습니다. 덕분에 laziness(게으름),short-circuiting(쇼킷서킷)과 같은 최적화가 가능합니다.

컬렉션과 스트림의 차이

컬렉션과 스트림은 모두 연속적인 요소 형식의 값을 저장하는 자료 구조 인터페이스를 제공합니다.

컬렉션은 현재 자료 구조가 포함하는 모든 값을 메모리에 저장하는 자료 구조입니다. 요소를 컬렉션에 저장하기 전에 요소는 메모리에 저장되어있어야합니다.

반면 스트림은 이론적으로 요청할 때만 요소를 계산하는 자료 구조로 게으르게 만들어지는 컬렉션과 유사합니다.

필요한 영역만 지목하면 스트림은 그 부분만 연산하여 가져온다고 생각합니다.

연산 방식의 차이

컬렉션은 외부에 개발자가 직접 요소를 반복해서 명령어를 작성하지만, 스트림은 내부 반복을 통해 반복 과정을 개발자가 정하지 않아도 됩니다.

스트림 연산

중간연산 : 파이프 라인으로 형성할 수 있으며 실제 연산이 발생하지 않습니다.

최종연산: 스트림을 닫으며 실제 연산이 발생합니다.

예제

public static void main(String[] args) {
    List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

    // 스트림 생성 및 중간 연산 정의
    names.stream()
            .filter(name -> {
                System.out.println("Filtering: " + name);
                return name.startsWith("A");
            })
            .map(name -> {
                System.out.println("Mapping: " + name);
                return name.toUpperCase();
            })
            // 최종 연산
            .forEach(name -> System.out.println("Final result: " + name));
}

스트림은 데이터베이스 SQL과 매우 유사하다고 생각합니다.

이유는 데이터베이스도 조건에 만족하면 거기서 더 연산을 하지 않은 최적화 방식을 사용합니다.

Filtering: Alice
Mapping: Alice
Final result: ALICE
Filtering: Bob
Filtering: Charlie
Filtering: David
Filtering: Eve

최종 결과를 보면 요소 하나가 최종연산까지 도착하고 다음 연산으로 넘어가는 것을 확인할 수 있습니다.

만약 여기서 limit(1)을 추가하게 된다면 나머지 데이터는 확인하지도 않습니다.

names.stream()
    .filter(name -> {
        System.out.println("Filtering: " + name);
        return name.startsWith("A");
    })
    .map(name -> {
        System.out.println("Mapping: " + name);
        return name.toUpperCase();
    })
    .limit(1)
    // 최종 연산
    .forEach(name -> System.out.println("Final result: " + name));
Filtering: Alice
Mapping: Alice
Final result: ALICE

이 방식이 지연 연산의 장점입니다.

특히 쇼트 서킷, 결과를 찾는 즉시 반환하여 전체 스트림을 처리하지 않는 최종 연산이 있습니다.

findFirst,findAny와 같은 경우를 말합니다.

내부 상태가 있는 API

내부 상태를 가지는 스트림은 병렬 처리시 성능 저하가 발생할 수 있습니다. 여러 스레드가 동시에 내부 상태에 접근하거나 수정하려고 할 경우 동기화 문제가 발생할 수 있기 때문입니다.

reduce, sum, max, sorted, distinct 처럼 비교를 하기 위해 내부 상태가 필요하기 때문입니다.

데이터 베이스에서도 정렬이나, 중복제거, 그룹화를 하게 되면 별도의 메모리 공간에 저장하거나 데이터가 클 경우 디스크에 데이터를 보관하는 것과 동일한 이치라고 생각합니다.

댓글남기기