Stream

Socra·2025년 1월 3일
0

Stream API는 컬렉션(List, Map, Set 등)의 데이터를 처리하는 데 사용되는 도구로, 데이터 변환 및 필터링 작업을 간결하고 효율적으로 수행할 수 있게 해준다.

Java8부터 도입되었으며, 병렬 처리, 함수형 프로그래밍 스타일, 데이터 스트림 조작에 초점이 맞춰져 있다.

명령형 방식

  • for, if
  • ‘어떻게’ 할 것인지에 집중한다.

선언형 방식

  • stream
  • ‘무엇’을 할 것인지 집중한다.
public class Main {
    public static void main(String[] args) {

        int[] arr = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

        // 명령형 방식으로 전부 출력
        // 직관적이고, 자유도가 높다.
        // 실수할 확률이 높아진다. 코드가 조금 복잡해지면 의도를 파악하기 어렵다.
        System.out.println("\n=== 명령형 ===");
        for (int i = 0; i < arr.length; i++) {
            System.out.print(arr[i]);
        }

        // 선언형 방식으로 전부 출력
        // 선언형: 뭘 하겠다고 선언 (어떻게 할지는 자바가)
        // forEach: 요소들을 각각 꺼내서 동작
        System.out.println("\n=== 선언형 ===");
        Arrays.stream(arr).forEach(System.out::print);
        // forEach 안에는 만들어져 있는 명령 => `메서드 레퍼런스`를 넣어줘야 한다.

        // System.out.println("hello"); // 호출/실행
        // System.out::println; // 메서드 레퍼런스

        Arrays.stream(arr).forEach(Main::test);

        // 1부터 100까지 짝수만 출력하기
        // 숫자 스트림을 바로 만들 수 있음
        IntStream.rangeClosed(1, 100).forEach(Main::even);

        // 람다 -> 익명함수
        // 함수를 정의하지 않고, 일회용으로 사용할 수 있다.
        // (`매개변수`) -> {`함수`}
        IntStream.rangeClosed(1, 100).forEach((num) -> {
            if (num % 2 == 0) {
                System.out.println(num);
            }
        });
        // 기본은 람다를 쓰되, 람다 안에서 사용하는게 재사용성이 높거나 가독성이 떨어지는 복잡한 코드일 때
        // -> 함수 정의
    }

    public static void test(int num) {
        System.out.print("\n숫자 : " + num);
    }

    public static void even(int num) {
        if (num % 2 == 0) {
            System.out.println(num);
        }
    }
}

Stream의 주요 특징

  1. 데이터 파이프라인
  • 데이터의 원천(Source)으로부터 처리 단계를 걸쳐 결과를 생성
    • Source → 중간 연산 → 최종 연산
  1. Lazy Evaluation
  • Stream 연산은 필요할 때만 실행된다.
  • 중간 연산은 실제로 데이터 처리를 수행하지 않고 파이프라인을 정의한다.
  1. 불변성
  • Stream은 기존 데이터를 변경하지 않는다. 결과로 새 데이터를 반환한다.
  1. 병렬 처리
  • parallelStream()을 사용해 멀티코어 프로세서를 활용한 병렬 처리를 수행할 수 있다.

주요 구성 요소

  1. Source
    • 컬렉션, 배열, 파일 등에서 Stream 객체를 생성한다.

List list = Arrays.asList(1, 2, 3); Stream stream = list.stream();

  1. Intermediate Operation (중간 연산)
    • Stream을 변환하거나 필터링하며, 새 Stream을 반환한다.
    • Lazy하게 실행된다.

filter(), map(), sorted()

  1. Terminal Operation (최종 연산)
  • Stream 파이프라인을 실행하고 결과를 반환한다.
  • 실행 후 Stream은 더 이상 사용할 수 없다.

forEach(), collect(), count()

Stream의 주요 메서드

  1. 중간 연산
  • filter(Predicate) : 조건에 맞는 요소만 선택
    • list.stream().filter(x -> x > 2).forEach(System.out::println);
  • map(Function) : 데이터를 변환
    • list.stream().map(x -> x * 2).forEach(System.out::println);
  • sorted(Comparator) : 요소를 정렬
    • list.stream().sorted().forEach(System.out::println);
  • distinct() : 중복 제거
    • list.stream().distinct().forEach(System.out::println);
  1. 최종 연산
  • collect(Collectors) : Stream을 List, Set 등으로 변환
    • List<Integer> result = list.stream().filter(x -> x > 2).collect(Collectors.toList());
  • forEach(Consumer) : 각 요소를 반복 처리
    • list.stream().forEach(System.out::println);
  • count() : 요소의 개수를 반환
    • long count = list.stream().filter(x -> x > 2).count();
  • reduce(BinaryOperator) : 요소를 하나로 합침
    • int sum = list.stream().reduce(0, Integer::sum);

연습하기

public class Main {

    public static void main(String[] args) {
        String[] strNums = {"1번", "2번", "3번", "4번", "5번"};

        // 홀수만 뽑아서 int 배열 만들기
        int[] numbers = Arrays.stream(strNums)
                .map(str -> str.replace("번", ""))
                .mapToInt(str -> Integer.parseInt(str))
                .filter(num -> num % 2 == 1)
                .toArray();

        Arrays.stream(numbers).forEach(System.out::println);
    }
}

병렬 스트림 (Parallel Stream)

데이터를 병렬로 처리하는 기능. 데이터를 여러 스레드로 분할하여 동시에 처리함으로써 처리 속도를 향상시킨다. 일반적인 순차 스트림은 한 번에 한 요소씩 처리하지만, 병렬 스트림은 데이터 소스를 여러 청크로 나누어 병렬로 처리한다.

  1. 순차 스트림
  • 기본적으로 단일 스레드에서 작업을 처리한다.
  • 작업의 순서가 보장된다.
  • List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); numbers.stream().forEach(System.out::println);
  1. 병렬 스트림
  • 내부적으로 Fork/Join 프레임워크를 사용해 여러 스레드에서 병렬로 작업을 처리한다.
  • 작업 순서가 보장되지 않는다.
  • List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5); numbers.parallelStream().forEach(System.out::println);

병렬 스트림의 동작 원리

  • Fork/Join 프레임워크 사용
    • 데이터 소스를 분할(Chunk)하여 여러 CPU 코어에서 병렬로 처리한다.
    • [1, 2, 3, 4, 5] → [1, 2], [3, 4], [5]
  • 각 청크는 별도의 스레드에서 처리되며, 모든 작업이 완료되면 결과를 합친다.

병렬 스트림이 적합한 경우

  • 데이터량이 많고 처리 시간이 오래 걸리는 경우
  • 데이터의 처리 순서가 중요하지 않은 경우
  • 멀티코어 프로세서를 최대한 활용하려는 경우

병렬 스트림을 사용하지만 출력의 순서를 보장해야 하는 경우

  • forEachOrdered() 를 사용해 순서 보장

names.parallelStream().forEachOrdered(System.out::println);

  • 데이터 처리 자체는 병렬로 이루어지지만, 결과를 정렬해 출력한다.
  • 데이터 처리는 병렬로 이루어지지만 결과를 순서대로 출력하기 위한 정렬 비용이 발생한다.

eachOrdered() 병렬 스트림, eachOrdered 순차 스트림 성능 비교

public class Main {

    public static void main(String[] args) {
        long n = 10_000_000;

        // 순차 스트림
        long start = System.currentTimeMillis();
        LongStream.range(1, n)
                .forEachOrdered(x -> {}); // 반복
        long end = System.currentTimeMillis();
        System.out.println("순차 스트림 : " + (end - start) + "ms");

        // 병렬 스트림
        start = System.currentTimeMillis();
        LongStream.range(1, n)
                .parallel()
                .forEachOrdered(x -> {}); // 반복
        end = System.currentTimeMillis();
        System.out.println("병렬 스트림 : " + (end - start) + "ms");
    }
}

실행 결과

순차 스트림 : 6ms
병렬 스트림 : 29ms


명령형(for), 순차 스트림, 병렬 스트림 성능 비교

데이터 크기가 작을 때

import java.util.stream.LongStream;

public class Main {

    public static void main(String[] args) {
        long n = 10_000; // 작은 데이터

        // 명령형 방식
        long start = System.nanoTime();
        long sumImperative = imperativeSum(n);
        long end = System.nanoTime();
        System.out.println("명령형 방식 합계: " + sumImperative);
        System.out.println("명령형 방식 시간: " + (end - start) + "ns");

        // 순차 스트림
        start = System.nanoTime();
        long sumSequential = sequentialStreamSum(n);
        end = System.nanoTime();
        System.out.println("순차 스트림 합계: " + sumSequential);
        System.out.println("순차 스트림 시간: " + (end - start) + "ns");

        // 병렬 스트림
        start = System.nanoTime();
        long sumParallel = parallelStreamSum(n);
        end = System.nanoTime();
        System.out.println("병렬 스트림 합계: " + sumParallel);
        System.out.println("병렬 스트림 시간: " + (end - start) + "ns");
    }

    public static long imperativeSum(long n) {
        long sum = 0;
        for (long i = 1; i <= n; i++) {
            sum += i;
        }
        return sum;
    }

    public static long sequentialStreamSum(long n) {
        return LongStream.rangeClosed(1, n).sum();
    }

    public static long parallelStreamSum(long n) {
        return LongStream.rangeClosed(1, n).parallel().sum();
    }
}

실행 결과

명령형 방식 합계: 50005000
명령형 방식 시간: 108292ns
순차 스트림 합계: 50005000
순차 스트림 시간: 966209ns
병렬 스트림 합계: 50005000
병렬 스트림 시간: 1514208ns

데이터 크기가 클 때

import java.util.stream.LongStream;

public class StreamPerformanceComparison {
    public static void main(String[] args) {
        long n = 100_000_000L; // 큰 데이터

        // 명령형 방식
        long start = System.currentTimeMillis();
        long sumImperative = imperativeSum(n);
        long end = System.currentTimeMillis();
        System.out.println("명령형 방식 합계: " + sumImperative);
        System.out.println("명령형 방식 시간: " + (end - start) + "ms");

        // 순차 스트림
        start = System.currentTimeMillis();
        long sumSequential = sequentialStreamSum(n);
        end = System.currentTimeMillis();
        System.out.println("순차 스트림 합계: " + sumSequential);
        System.out.println("순차 스트림 시간: " + (end - start) + "ms");

        // 병렬 스트림
        start = System.currentTimeMillis();
        long sumParallel = parallelStreamSum(n);
        end = System.currentTimeMillis();
        System.out.println("병렬 스트림 합계: " + sumParallel);
        System.out.println("병렬 스트림 시간: " + (end - start) + "ms");
    }

    public static long imperativeSum(long n) {
        long sum = 0;
        for (long i = 1; i <= n; i++) {
            sum += i;
        }
        return sum;
    }

    public static long sequentialStreamSum(long n) {
        return LongStream.rangeClosed(1, n).sum();
    }

    public static long parallelStreamSum(long n) {
        return LongStream.rangeClosed(1, n).parallel().sum();
    }
}

실행 결과

명령형 방식 합계: 5000000050000000
명령형 방식 시간: 32ms
순차 스트림 합계: 5000000050000000
순차 스트림 시간: 36ms
병렬 스트림 합계: 5000000050000000
병렬 스트림 시간: 9ms


성능 비교 분석

  1. 명령형 방식
  • 가장 단순하며 JVM 최적화가 잘 이루어진다.
  • 작은 데이터 세트에서 효율적이다.
  • 멀티 코어를 활용하지 못해 데이터 크기가 커질수록 느려진다.
  • 병렬 처리가 필요할 때 코드를 직접 작성해 복잡해진다.
  1. 순차 스트림
  • 선언형 코드로 가독성이 좋고, 유지보수가 용이하다.
  • 명령형 방식과 유사한 성능으로, 직관적으로 표현할 수 있다.
  • 단일 스레드에서 실행되어 대규모 데이터 처리에서 성능이 떨어진다.
  1. 병렬 스트림
  • 멀티코어 CPU를 활용해 대규모 데이터 세트를 빠르게 처리한다.
  • 선언형 스타일을 유지하면서 성능 최적화가 가능하다.
  • 데이터 크기가 작으면 스레드 관리 오버헤드로 인해 성능이 저하된다.
  • 병렬 처리에 따른 스레드 안정성과 데이터 분할 문제를 주의해야 한다.

0개의 댓글

관련 채용 정보