본문 바로가기
츄Log/기타 끄적

Java 스트림 (Stream)의 특징

by 츄츄🦭 2023. 12. 8.
728x90

 

안녕하세요!

오늘은 Java8에 추가된 Stream에서 중요한 키워드 몇가지를 살펴보려고 합니다.


먼저 스트림이란 무엇일까요?

스트림이란 선언형으로 컬렉션 데이터를 처리할 수 있는 기능입니다.

소스에서 추출되는 연속된 요소를 가지고 스트림API가 제공해주는 많은 빌딩 블록 연산을 연결해서 복잡한 데이터 처리 파이프라인을 만들 수 있습니다. 

또한 스트림을 사용하면 멀티스레드 코드를 구현하지 않아도 데이터를 tranperant하게 병렬로 처리할 수 있습니다.

(Transparency, 투명성 : 어떤 시스템이나 기능을 사용할 때 사용 방법이 내부 복잡성을 숨기고 명확하게 제공되는 경우)

 

정리하면, 스트림 API의 특징은

  • 선언형(declarative) : 간결하고 가독성이 좋아집니다.
  • 조립할 수 있음(pipelining) : 유연성이 좋아집니다.
  • 병렬화(parallel) : 성능이 좋아집니다. (항상은 아니고 어떤 연산이냐에 따라 다릅니다. 오히려 성능에 좋지 않을 수도 있습니다.)

간단하게 스트림API의 개요 대해 알아보았습니다.

이제 스트림을 공부할 때 꼭 기억해둬야할 키워드를 알아보겠습니다.

출처 : java modern in action

 

1. 중간(중개)연산(intermediate operation) / 최종연산(terminal operation)

스트림은 많은 연산을 정의합니다. 스트림 연산을 크게 두 가지로 구분할 수 있습니다. 

  • 중간연산 : 서로 연결되어 파이프라인을 형성하고 Stream을 반환하는 연산입니다.
  • 최종연산 : 스트림을 닫는 연산으로 Stream을 반환하지 않습니다.

중간연산은 기본적으로 lazy합니다.

여기서 lazy하다는 것은 중간연산은 최종연산이 오기 전까지는 수행되지 않는다는 뜻입니다. 

중간연산만 주구장창 넣어주고 최종연산을 넣어주지 않으면 단순 정의한 것에 불과합니다. 

 

JVM은 스트림의 lazy한 특성을 효과적으로 지원하고 최적화합니다.

스트림 파이프라인을 실행하면 JVM은 지연연산을 위해 준비작업을 합니다.

그리고 어떤 중간연산과 최종연산으로 구성되어있는지 검사를 하여 최적화를 수행하여 스트림 연산을 수행합니다.

이로써 필요하지 않은 작업이나 계산이 피해질 뿐만 아니라, 스트림 연산을 병렬로 처리하는 경우에도 최적화된 실행이 가능하게 됩니다.

 

이런 lazy한 특성 덕분에 얻을 수 있는 두 가지의 최적화 효과를 알아보겠습니다. 

 

1. 쇼트서킷 (short circuit)

쇼트서킷은 or 조건 연산에서 많이 들어본 용어(A || B 에서 A가 true면 B조건은 확인하지 않음)입니다.

스트림에서도 이와 같은 의미로 사용됩니다.

 

'어떤 처리를 해야하는지 미리 평가된다면, 필요 없는 작업은 하지 않아도 된다'는 개념입니다.

 

스트림의 소스는 방대한 데이터일 수 있고 이 소스는 스트리밍 데이터처럼 무제한의 데이터일 수도 있습니다.

원한다면 무한 스트림을 생성할 수도 있습니다.

(Stream.iterate / Stream.generate으로 생성가능하며, infinite stream 또는 unbounded stream 이라고 합니다.)

 

이 때 대표적인 쇼트서킷 연산인 limit() 연산을 통해 몇 개 까지의 데이터를 처리할지 제한을 할 수 있습니다. 

limit()처럼 미리 제한을 할 수 있고 또는 결과를 찾는 즉시 반환(ex. findFirst(), anyMatch())하는 등 전체 스트림을 처리하지 않습니다. 

 

2. 루프퓨전 (loop fusion) 

루프퓨전은 여러개의 연결된 스트림 연산을 하나의 연산으로 병합시키는 것입니다.

어떤 스트림 연산이 수행될 지 미리 안다면, 연산별로 스트림의 요소를 순회하지 않고 한 번 접근할 때 여러개의 연산을 순회하여

스트림의 각 요소에 접근하는 횟수를 줄일 수 있습니다.

 

코드를 보겠습니다.

public class Chupin {
    public static void main(String[] args) {
        final List<String> names = Arrays.asList("chupin", "tech");
        names.stream()
            .filter(name -> {
                System.out.println(name);
                return name.length() > 1;
            })
            .map(name -> {
                System.out.println(name);
                return name.length();
            })
            .collect(Collectors.toList());
    }
}

// 기대
chupin
tech
chupin
tech

// 결과
chupin
chupin
tech
tech

loop fusion으로 인해 filter와 map이 하나의 과정으로 병합되었습니다. 

 

개별 스트림 요소에 대한 접근을 최소화하여 각 스트림 요소에 대한 접근을 4번에서 2번으로 최적화했고,

중간 결과를 메모리에 저장되지 않아 효율적으로 실행이 가능해졌습니다.

 

여러 연산을 하나의 최적화된 단일 루프로 통합하는 루프퓨전은 모든 경우에 일어나는 것은 아닙니다.

 

스트림 연산이 무엇을 해주는지 생각해보고, 

쇼트서킷이 발생하는지 루프퓨전이 발생할지 추측해 볼 수 있습니다. 

 

몇 가지 예시를 드리겠습니다.

// 1. loop fusion 발생
public class Chupin {
    public static void main(String[] args) {
        final List<String> names = Arrays.asList("chupin", "tech");
        names.stream()
            .filter(name -> {
                System.out.println(name);
                return name.length() > 1;
            })
            .map(name -> {
                System.out.println(name);
                return name.length();
            })
            .collect(Collectors.toList());
    }
}

// 결과
chupin
chupin
tech
tech

// 2. loop fusion 발생, short circuit 발생
public class Chupin {
    public static void main(String[] args) {
        final List<String> names = Arrays.asList("chupin", "tech");
        names.stream()
            .filter(name -> {
                System.out.println(name);
                return name.length() > 1;
            })
            .map(name -> {
                System.out.println(name);
                return name.length();
            })
            .limit(1)
            .collect(Collectors.toList());
    }
}
// 결과
chupin
chupin

// 3. short circuit 발생
public class Chupin {
    public static void main(String[] args) {
        final List<String> names = Arrays.asList("chupin", "tech");
        names.stream()
            .filter(name -> {
                System.out.println(name);
                return name.length() > 1;
            })
            .sorted()
            .limit(1)
            .map(name -> {
                System.out.println(name);
                return name.length();
            })
            .collect(Collectors.toList());
    }
}

// 결과
chupin
tech
chupin

// 4. 아무것도 발생x 
public class Chupin {
    public static void main(String[] args) {
        final List<String> names = Arrays.asList("chupin", "tech");
        names.stream()
            .filter(name -> {
                System.out.println(name);
                return name.length() > 1;
            })
            .sorted()
            .map(name -> {
                System.out.println(name);
                return name.length();
            })
            .collect(Collectors.toList());
    }
}

// 결과
chupin
tech
chupin
tech

 

쇼트서킷과 루프퓨전을 통한 최적화는 JVM이 알아서 해주는 영역이어서 JVM이 잘 해주길 믿고 위임하지만,

한 가지 주의할 점이 있습니다.

 

무한 스트림의 경우 연산의 종류와 순서에 따라 연산 종료가 되지 않고 무한루프에 빠질 수 있습니다. 

// 서버 다운 코드
public class Chupin {
    public static void main(String[] args) {
        Stream.generate(() -> 100)
            .sorted()
            .limit(1)
            .collect(Collectors.toList());
    }
}

// 우리가 만들어야할 코드
public class Chupin {
    public static void main(String[] args) {
        Stream.generate(() -> 100)
            .limit(1)
            .sorted()
            .collect(Collectors.toList());
    }
}

이것을 꼭 인지하고 개발할 필요가 있습니다.


오늘은 Stream에서 꼭 알고 있어야할 키워드를 알아보았습니다.

다음에는 Stream 병렬처리에 대해 포스팅해볼까 합니다!

 

궁금한 게 있다면 댓글 남겨주세요 :) 

 

도움 : Modern Java in Action 

728x90