Java Stream API 에 대해 알아보자

JUN·2024년 11월 15일
0

CS

목록 보기
3/4

Java 8에서는 대량의 데이터를 효율적으로 처리하기 위해 Stream API가 도입되었다. 이는 데이터를 선언형 프로그래밍 방식으로 처리할 수 있게 하며, 다양한 데이터 연산을 간결하고 효율적으로 수행할 수 있는 기능을 제공한다.


Stream과 for 루프의 성능 비교

1. Stream의 성능

Stream은 대용량 데이터를 처리할 때 더 효율적이다. 특히 병렬 처리가 가능하기 때문에 성능상의 이점을 제공한다.
또한 종단 연산이 호출될때에 실행하고 (지연 연산) 중간 연산 과정에서 조건에 맞지 않는 데이터들이 추려지기 때문에 조건에 맞지 않는 데이터들은 이후 계산에 쓰이지 않으므로 대규모 데이터셋에 효율적일 수 있따.

2. for 루프와의 비교

그에 비해 for 루프는 작은 데이터셋이나 단순 연산을 수행할 때 빠르다. 이유는 오버헤드가 적기 때문이다.
for 루프는 별도의 객체 생성이나 함수 호출이 없어 실행 비용이 Stream보다 낮다.

오버헤드가 적다? 왜?

for 루프는 별도의 추가 객체 생성이나 함수의 호출 없이 작동하기 때문에 실행시의 부가적인 비용이 stream에 비해 덜든다. ← 컴파일 시점에서 적용

Stream은 스트림객체를 생성하고 연산 체인을 설정하여 내부적으로 람다나 함수형 인터페이스를 사용하므로 초기세팅 비용에서 오버헤드가 발생할 수 있다는 뜻이다.

Stream의 병렬 처리

Stream은 parallel() 메서드를 통해 병렬 처리가 가능하다. 그러나 병렬 처리를 사용할 때는 몇 가지 주의사항이 있다.

1. 스레드풀 공유 문제

parallel() 메서드는 별도의 스레드풀을 생성하지 않고, 공유 스레드풀(ForkJoinPool.commonPool)을 사용한다.
여러 병렬 스트림이 하나의 스레드풀을 공유하면 병목 현상이 발생할 수 있다.

2. 최적의 데이터 구조

병렬 처리 성능을 극대화하려면 다음과 같은 참조 지역성이 높은 자료구조를 사용하는 것이 좋다:

  • ArrayList, HashMap, HashSet, ConcurrentHashMap
  • 원시 타입 배열(int[], long[] 등)

이들은 메모리에서 연속적으로 저장되므로, 캐싱 효율이 향상되어 처리 속도가 빨라진다.

캐싱 효율 향상된다?
메모리에서 연속적임으로 Cache HitRate 가 높다는 뜻이다. CPU 캐시 메모리는 인접한 메모리 공간을 함께 불러오기 때문에 배열 요소에 순차적으로 접근할 때에 메모리 접근 속도가 더 빠르다는 뜻이다.

3. 잘못된 병렬 처리

parallel 사용 시 잘못된 병렬 처리를 할 경우 성능 향상은 커녕 성능이 내려갈 수 도 있다.
특히 limit() 메서드를 함께 사용하는 경우 병렬 처리 성능이 저하될 수 있다.
그 이유는 parallel로 병렬 작업 수행 시 limit(n)이 n개의 결과만 가져오지 않기 때문이다.

예를 들어 메르센 소수 문제 를 둘 수 있다. 메르센 소수가 무엇인지는 궁금한 사람이 찾아보시라.
우리는 n번째 메르센 소수를 찾는 일이 1부터 n-1까지 메르센 소수를 찾는 일보다 훨씬 많은 비용이 드는 연산임을 알고만 있으면 된다.

아래는 20개의 메르센 소수를 구하는 JAVA코드이다.

public static void main(String[] args) {
    primes()
        .map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) // 메르센 수 생성: 2^p - 1
        .filter(mersenne -> mersenne.isProbablePrime(50)) // 메르센 수가 소수인지 확인
        .limit(20) // 상위 20개의 메르센 소수만 선택
        .forEach(System.out::println); // 결과 출력
}

해당 코드에서 성능을 높이기 위해서 스트림 파이프 라인의 parallel() 을 호출하면 병렬로 코드를 진행하여 성능이 좋아질까?

public static void main(String[] args) {
    primes()
		.parallel() // 병렬 스트림으로 변환
        .map(p -> TWO.pow(p.intValueExact()).subtract(ONE)) // 메르센 수 생성: 2^p - 1
        .filter(mersenne -> mersenne.isProbablePrime(50)) // 메르센 수가 소수인지 확인
        .limit(20) // 상위 20개의 메르센 소수만 선택
        .forEach(System.out::println); // 결과 출력
}

아쉽게도 성능은 좋아지지 않는다. 오히려 CPU자원을 더 많이 잡아먹으면서 결과물은 나오지 않는다.
이유는 스트림 파이프 라인을 병렬화할때에 CPU코어가 남는다면 원소를 몇 개 더 처리한 후에 제한된 개수 이후의 결과를 버리는 알고리즘으로 돌아가기 때문이다.

해당 문제를 해결하려면 스레드에 분배하기 좋은 앞서 말했던 참조지역성이 좋은 자료구조를 채택하면 좋다. 또한 Spliterator 를 사용하면 작업할 스레드를 병렬적으로 나눌 수 있다.

import java.util.Spliterator;
import java.util.stream.IntStream;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

public class ParallelSpliteratorExample {
    public static void main(String[] args) {
        // 1부터 100까지의 숫자를 포함하는 스트림 생성
        Stream<Integer> stream = IntStream.range(1, 101).boxed();

        // 스트림에서 Spliterator 객체를 가져옴
        Spliterator<Integer> spliterator = stream.spliterator();

        // Spliterator를 사용하여 데이터를 병렬로 나누기
        Spliterator<Integer> split1 = spliterator.trySplit(); // 첫 번째 분할
        Spliterator<Integer> split2 = spliterator.trySplit(); // 두 번째 분할

        // 각 분할된 Spliterator로 병렬 스트림 생성
        Stream<Integer> parallelStream1 = StreamSupport.stream(spliterator, true);
        Stream<Integer> parallelStream2 = StreamSupport.stream(split1, true);
        Stream<Integer> parallelStream3 = StreamSupport.stream(split2, true);

        // 병렬 스트림의 각 요소를 처리하며 스레드 이름 출력
        System.out.println("Processing with spliterator 1:");
        parallelStream1.forEach(i -> System.out.println("Thread " + Thread.currentThread().getName() + " is processing number: " + i));

        System.out.println("\nProcessing with spliterator 2:");
        parallelStream2.forEach(i -> System.out.println("Thread " + Thread.currentThread().getName() + " is processing number: " + i));

        System.out.println("\nProcessing with spliterator 3:");
        parallelStream3.forEach(i -> System.out.println("Thread " + Thread.currentThread().getName() + " is processing number: " + i));
    }
}

Stream에서 사용 가능한 함수형 인터페이스

Stream API는 함수형 인터페이스를 통해 다양한 연산을 지원한다. 주요 인터페이스는 다음과 같다:

  1. Predicate

    • 입력값을 테스트하여 boolean 결과를 반환한다.
    • filter() 메서드에서 사용된다.
  2. Function<T, R>

    • 입력값을 다른 타입으로 변환한다.
    • map() 메서드에서 사용된다.
  3. Consumer

    • 입력값을 소비하고 반환값이 없다.
    • forEach() 메서드에서 사용된다.
  4. Supplier

    • 입력 없이 값을 생성한다.
    • Stream 초기화 시 사용된다.
  5. BiFunction<T, U, R>

    • 두 개의 입력값을 받아 결과를 반환한다.
  6. UnaryOperator

    • 같은 타입의 입력값을 변환한다.
  7. BinaryOperator

    • 같은 타입의 두 입력값을 연산한다.
    • reduce() 메서드에서 사용된다.

Stream에서 외부 변수 사용: final 키워드

Stream이나 람다식에서 외부 변수를 사용할 때는 반드시 해당 변수가 final 또는 사실상 final(effectively final)이어야 한다.
이는 스레드 안전성클로저(Closure) 개념 때문이다.

이유:

람다 표현식이 외부 변수를 사용할 경우, 해당 변수는 변경되지 않아야 한다. 변경될 가능성이 있다면 스레드 간 데이터 경합이 발생할 수 있다.

꼭 final을 붙여야 할까?

  • 명시적으로 final을 붙이지 않아도, 변수가 변경되지 않으면 사실상 final로 간주되어 사용 가능하다.

예시 코드:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// 사실상 final인 변수
String prefix = "Hello, ";

names.stream()
    .map(name -> prefix + name) // prefix 사용
    .forEach(System.out::println);

위 예시에서 prefix는 변경되지 않으므로 람다식에서 안전하게 사용할 수 있다.


결론

Java Stream은 대량 데이터를 효율적으로 처리할 수 있는 강력한 도구이다.
성능을 극대화하려면 데이터 구조, 스레드에 대한 이해, 함수형 인터페이스를 잘 활용해야 한다.
또한 외부 변수를 사용할 때는 final 키워드와 스레드 안전성을 고려하는 것이 중요하다.

Stream API를 적절히 활용하면 간결하면서도 성능이 뛰어난 코드를 작성할 수 있을 것이다.

참고

  1. Java 스트림과 병렬 스트림
  2. 스레드의 구조와 병렬 처리
  3. 프로세스와 스레드 차이
  4. ArrayList vs Vector 동기화 차이
  5. Effective JAVA - 람다와 스트림
profile
순간은 기록하고 반복은 단순화하자 🚀

0개의 댓글