Java

Stream API(임시저장용)

shininghyunho 2024. 8. 8. 02:48

Stream 이란?

람다식이 편리한 방법 정도라고 생각된다면,

스트림은 완전히 새로운 패러다임아다.

데이터를 파이프라이닝하여 마치 액체가 흐르듯이 요소들을 처리한다.

Stream 도입 배경

Stream은 Java 언어에 함수형 프로그래밍을 제공하기 위해 도입되었다.

 

그렇다면 다시

함수형 프로그래밍은 무엇인가?

위키백과에서는 다음과 같이 정의한다.

함수형 프로그래밍(函數型 프로그래밍, functional programming)은 자료 처리를 수학적 함수의 계산으로 취급하고
상태와 가변 데이터를 멀리하는 프로그래밍 패러다임의 하나이다.

 

핵심은 수학적 함수다. (이를 순수 함수라고도 한다.)

근데 우리는 이미 (프로그래밍에서의) 함수를 사용하고있지 않나?

수학적 함수와 프로그래밍의 함수의 가장 큰 차이는 부수효과(side effect)다.

// 프로그래밍 함수
int getRandom() {
	return random()*random();
}

// 수학적 함수
f(x) = x + 1

 

수학적 함수의 결과는 오직 입력의 영향만을 받는다.

하지만 프로그래밍 함수는 외부의 영향을 받는다.

이는 함수를 반복 실행했을때 어떤 결과가 나오는지를 알 수 없게한다.

그리고 이는 수많은 부작용을 일으키는 주범이다.

 

그래서!

함수형 프로그래밍의 목적은 순수 함수(부작용이 없는 함수)를 만드는것이다.

외부와의 상호작용 없이 오롯이 결과는 오직 입력의 영향만 받는다.

 

특정한 상태를 가지지 않기때문에 함수의 결과는

굳이 실행해보지 않아도 예측이 가능해진다.

이렇게 함수가 예측 가능해진 상태를 참조 투명성이라고 한다.

(비유를 하자면 믿음직한 사람이다. 일을 시키면 반드시 완수할거라고 보장할 수 있는 사람이다.)

 

+ 함수형 프로그래밍을 쓰면 객체지향 프로그래밍을 쓸 수 없나?

아니다.

두 방식은 서로 베타 보완적이다.

함수형은 부수효과를 없애는게 목적이고 객체지향은 사물을 객체로 만들어 표현하는 방식이다.

그래서 객체지향을 사용하며 함수형을 써도되고 안써도 된다.

반대도 마찬가지로 함수형을 쓰며 객체지향을 써도 안써도 된다.

 

Stream 과 기존 방식

그렇다면 Stream 사용했을때 어떤점이 달라질까?

 

100 이하의 자연수중에서 짝수들의 제곱을 모두 합해보자.

// 기존 방식
int sum = 0;
for (int i = 1; i <= 100; i++) {
    if (i % 2 == 0) {
        sum += i * i;
    }
}
System.out.println(sum);

 

전통적인 방식에는 for 문 안의 로직을 직접 구현한다.

그래서 how 어떻게 동작하는지에 관심을 두고있다.

 

반면 stream을 사용하면 다음과 같다.

// stream 방식
int sum = IntStream.range(1, 101)
    .filter(i -> i % 2 == 0)
    .map(i -> i * i)
    .sum();
System.out.println(sum);

 

stream을 사용하면 filter, map 같이 추상화된 함수를 사용하여 결과를 얻어낸다.

그래서 what 무엇을 원하는지에만 관심을 두면된다.

이를 선언적이라고도 한다.

 

지금은 예제가 간단하지만 복잡하고 반복되는 for 문일수록 stream 의 추상화된 함수들은 더욱 간결해진다.

 

+ 구체적인 사용방법은 다음을 참고하자.

The Java 8 Stream API Tutorial | Baeldung

Stream (Java Platform SE 8 ) (oracle.com)

[Java] Stream API의 활용 및 사용법 - 기초 (3/5) - MangKyu's Diary (tistory.com)

Stream 연산의 특징

stream 에 대한 감을 익혔으니 구체적인 특징을 살펴보자.

 

병렬처리

stream api는 병렬처리 연산을 지원한다.

그래서 대용량 데이터 처리에 유리한 측면이 있다.

사용법도 아래와 같이 단순하다.

// 병렬처리
int sum = IntStream.range(1, 101)
    .parallel()
    .filter(i -> i % 2 == 0)
    .map(i -> i * i)
    .sum();
System.out.println(sum);

 

 

그러나 병렬처리에서는 동기화 문제가 발생한다.

공유 자원에 대해 여러 스레드가 동시에 처리하면 안되니까.

근데 stream은 함수형 프로그래밍이니 문제가 없을까?

 

우선 다음 개념을 먼저 살펴봐야한다.

stateful vs stateless

state는 상태다.

stateful 은 그래서 뭔가 값을 유지해야할거같고

stateless는 독자적으로 연산이 가능해보인다.

 

아래의 코드를 보자.

// 병렬처리
int sum = IntStream.range(1, 101)
    .filter(i -> i % 2 == 0)
    .map(i -> i * i)
    .sum();
System.out.println(sum);

 

1부터 100까지 수 중에서 짝수만 제곱해서 더한다.

 

그렇다면 총 1~100 까지 100번의 연산을 할텐데,

순서가 중요할까?

총 10개의 쓰레드가 1~10, 11~20, ..., 91~100 이렇게 나눠서 계산하면

하나의 쓰레드가 계산한 결과와 다를까?

결과는 같다. 상태가 없기 때문이다. stateless

 

실제 쓰레드를 출력해보면 각자 독립적으로 실행이 되고있다.

 

예제를 약간 수정해보자.

// 병렬처리
int sum = IntStream.range(1, 101)
    .filter(i -> i % 2 == 0)
    .skip(10)
    .map(i -> i * i)
    .sum();
System.out.println(sum);

 

중간에 skip 메서드가 들어갔다.

연산의 결과를 10번까지는 skip 하겠다는 말이다. (즉 11번째 짝수인 22부터 계산한다는 말이다.)

다시 동일하게 생각해보자.

총 10개의 쓰레드가 1~10, 11~20, ..., 91~100 이렇게 나눠서 계산하면

하나의 쓰레드가 계산한 결과와 다를까?

 

각 쓰레드가 계산할때 처음 10개를 빼고 계산해야하는데

다른 쓰레드가 어디까지 계산했는지 알수가 없다.

그래서 무작정 map 메서드에서 제곱을 할 수가 없다.

 

skip(n) 연산이 어떻게 동작할까?

1번 연산할때마다 n 을 줄여나n며 0이 될때 넘어가게 하면 될것이다.

여기서 n이라는 변수를 state다. 그래서 stateful 하다.

n 변수를 여러 쓰레드가 동기화해야하기 때문에 skip 함수는 병렬처리가 불가능하다.

 

실제로 출력해보면 filter를 모두 수행하고 동기화 후 map을 실행한다.

 

다행히 stateful 함수는 몇가지없다.

  • skip() : n 이상부터 연산
  • distinct() : set 처럼 중복 x
  • limit() : n 이하까지만 연산
  • sorted() : 정렬
  • reduce() : 익명함수를 통해 하나의 값으로 합침

내부 구현을 보지 않아도 여러개의 쓰레드가 동시에 수행하기 힘든 작업들이다.

그래서 stateful 메서드를 병렬처리로 사용할땐 동기화 문제에 신경써야한다.

이 밖에 나머지 함수들은 stateless 하다.

 

bounded vs unbounded

stateful 한 함수는 다시 bounded vs unbounded 로 나뉜다.

연산갯수가 확정적이면 bounded 이고

연산갯수가 확정적이지 못하면 unbounded다.

 

대표적인 bounded에는 skip, limit 함수가 있다. n 번의 연산만 뛰어넘거나 계산하면 된다.

반대로 unbounded에는 sorted, distinct가 있다. 데이터의 특성에 따라 몇번 계산할지 확정할 수 없다.

 

stream을 사용하더라도 성능상 문제가 발생할 수 있는데

주로 stateful 한 함수에서 특히 unbounded한 함수에서 문제가 생길 수 있다.

 

지연(Lazy) 연산

stream은 파이프라인을 만들어둔 흐름이다. 그래서 stream 자체만으로는 계산을 하진 않는다.

종결 함수를 만나서 결과가 필요할때가 되서야 실제 계산을 수행한다.

(마치 숙제 해왔냐고 물어봤을때는 했다고 대답하지만

실제로 보여줘 했을때, 그제서야 숙제를 푸는 아이라고 생각하면 된다.)

 

실제 연산을 확인해보자.

IntStream.range(1, 101)
      .map(i -> {
          System.out.format("map : %d \n",i);
          return i * i;
      })
      .filter(i -> {
          System.out.format("filter : %d\n",i);
          return i<100;
      })
      .limit(5)
      .sum();

 

각 연산은

  1. 제곱하고
  2. 100 미만만 걸러내고
  3. 5개까지만 계산해서
  4. 더한다.

그러면 stream은 어떻게 동작할까?

연산 결과를 보면 수직 방향으로 계산한다.

그래서 각 필터에서 걸러지면 계산을 멈추고

다음 수를 계산한다.

 

만약 순차적으로 계산했다면(eager한 방식으로 연산되었다면) 100까지 모든 수를 제곱해보고

제곱이 100 미만인 수들을 걸러낸 후

그중에서 처음 5개만 걸러냈을것이다.

 

하지만 실제 동작에서는 map -> filter -> limit 순서로 계산해 총 5번의 연산만 수행한다.

이러하 Lazy 연산은 불필요한 연산을 방지해 성능상 큰 이점을 준다.

 

루프 퓨전과 쇼트 서킷

lazy 연산이 효율적으로 동작하는데에는 두가지 방식을 활용한다.

  • 루프 퓨전 : 여러개의 루프를 하나의 루프처럼 퓨전하는것.
  • 쇼트 서킷 : limit처럼 조건을 달성했다면 끝까지 수행하지 않고 종료.

다시 위 예제를 가져와 설명해보자.

IntStream.range(1, 101)
      .map(i -> {
          System.out.format("map : %d \n",i);
          return i * i;
      })
      .filter(i -> {
          System.out.format("filter : %d\n",i);
          return i<100;
      })
      .limit(5)
      .sum();

 

여기서 루프 퓨전은 마치 map, filter가 독립된 연산이 아닌 하나의 연산처럼 동작하는것을 의미하고,

쇼트 서킷은 limit(5)를 달성하면 끝까지 동작하지 않는것을 의미한다.

 

만약 stateful 함수를 사용하면

이때는 순서가 중요해지니 동기화를 시켜야한다.

그래서 stateless 함수끼리 연산을 쭉 하다가 모두 stateful 함수에서 만나 연산하고,

다시 stateless 연산을 수행한다.

 

위 예제에서 stateful한 메서드 정렬을 추가해보자.

IntStream.range(1, 101)
        .map(i -> {
            System.out.format("map : %d\n", i);
            return i*i;
        })
        .sorted()
        .filter(i -> {
            System.out.format("filter : %d\n", i);
            return i<100;
        })
        .skip(5)
        .sum();

 

정렬을하기 위해서는 모든 자원이 필요하니 개별적으로 동작을 수행할 수가 없다.

그래서 map에서 바로 filter로 넘어가지 않고

stateless 동작인 map을 모두 수행하고 이후 stateless 동작인 filter를 수행한다.

 

아래의 결과를 확인해보면 map 동작을 100까지 모두 수행하고

filter 동작을 수행한다.

+모든 stateful 함수를 기다리는건 아니다. limit 함수라면 갯수만 체크하고 수직적으로 연산한다.

 

Stream의 단점

Stream이 모든 상황에서 우수한것은 아니다.

 

for 문보다 항상 우수한가? - 성능 차이 비교.

Java Stream API는 왜 for-loop보다 느릴까? | by Sigrid Jin | Medium

병렬처리 오버헤드

병렬처리 - forkJoin Framework 사용. - 병렬처리는 언제 사용해야하는가?

[Java] 병렬 스트림(ParallelStream) 사용 방법 및 주의사항 (tistory.com)

'Java' 카테고리의 다른 글

[객체지향설계원칙] DIP  (0) 2024.05.31
[객체지향설계원칙] ISP  (0) 2024.05.31
[객체지향설계원칙] LSP  (0) 2024.05.29
[객체지향설계원칙] OCP  (0) 2024.05.23
추상클래스, 인터페이스의 문법적,의미적 차이  (0) 2024.05.22