[Java 심화] 효율적인 데이터 처리 - Stream API 활용

Kyung Jae, Cheong·2024년 10월 12일
0
post-thumbnail

Stream API

  • Stream API는 Java 8에서 도입된 기능으로, 데이터를 효율적으로 처리하고, 함수형 프로그래밍의 강력한 도구로 활용될 수 있는 중요한 요소입니다.
  • Stream은 데이터의 흐름을 의미하며, 주어진 데이터 소스로부터 연속적인 처리를 지원합니다.
  • 이 API는 데이터를 필터링, 변환, 집계 등의 작업을 쉽게 수행할 수 있게 해주며, 특히 대규모 데이터 처리에서 그 진가를 발휘합니다.

0. 개요

0.1 Stream API의 개념과 중요성

  • Stream API는 데이터의 흐름을 함수형 프로그래밍 방식으로 처리할 수 있게 도와줍니다.
    • 이를 통해 우리는 데이터 변환 및 처리 작업을 간결하고 직관적인 코드로 작성할 수 있습니다.
    • 람다 표현식과 함께 사용되며, 기존의 복잡한 코드 구조를 단순화하여 가독성과 유지보수성을 높이는 데 중요한 역할을 합니다.
  • Stream은 컬렉션뿐만 아니라 배열, 파일, I/O 작업, 그리고 다양한 데이터 소스에서 데이터를 처리하는 데 사용됩니다.
  • 지연 처리(Lazy Evaluation)와 병렬 처리를 지원하여, 성능 향상과 효율적인 자원 관리를 가능하게 합니다.

Stream의 핵심 개념

  • 불변성: Stream은 데이터의 원본을 변경하지 않고, 새로운 데이터를 생성해 처리합니다.
  • 지연 처리: 중간 연산(예: filter(), map())은 실제로 종료 연산(예: forEach(), collect())이 호출될 때까지 실행되지 않으므로, 필요한 데이터만 처리하는 효율성을 갖습니다.
  • 함수형 프로그래밍: Stream API는 람다 표현식과 결합해 더욱 간결하고 선언적인 방식으로 데이터를 처리합니다.

0.2 Stream API가 도입된 이유

  • Java 8 이전에는 데이터를 처리할 때 반복문이나 조건문을 통해 데이터를 하나씩 순회하면서 필터링, 변환 등의 작업을 수행했습니다.
  • 이러한 방법은 다음과 같은 문제점들이 있었습니다:
    • 반복 코드: 데이터를 처리하기 위해 반복문을 사용하는 것은 코드의 중복을 유발하고, 가독성을 저하시킵니다.
    • 병렬 처리의 어려움: 병렬 처리를 직접 구현하는 것은 복잡하며, 개발자가 자원 관리와 동기화를 일일이 처리해야 했습니다.
    • 복잡한 코드 구조: 데이터를 필터링하거나 변환하는 과정에서 다중 조건을 처리하는 코드가 길어지고 복잡해지며, 유지보수성이 떨어집니다.
  • Stream API는 이러한 문제점을 해결하고, 선언적이고 직관적인 방식으로 데이터를 처리할 수 있도록 도입되었습니다.
    • 이를 통해 개발자는 데이터를 "어떻게 처리할지"에 집중할 수 있게 되었으며, 내부적으로 효율적인 동작과 병렬 처리를 쉽게 구현할 수 있습니다.

0.3 전통적인 반복문과의 차이점

  • 전통적인 반복문과 비교할 때, Stream API는 선언적이고 함수형 프로그래밍 방식을 따릅니다.
    • 반복문은 명령적(Imperative) 프로그래밍 방식으로, 데이터를 어떻게 처리할지를 세부적으로 명시해야 합니다.
    • 반면, Stream API는 "무엇을 할지"에만 집중하여 코드를 더욱 간결하게 만듭니다.

전통적인 반복문 예시

List<String> names = Arrays.asList("Kim", "Lee", "Park");
List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.startsWith("K")) {
        result.add(name.toUpperCase());
    }
}
System.out.println(result);  // 출력: [KIM]

Stream API 활용 예시

List<String> names = Arrays.asList("Kim", "Lee", "Park");
List<String> result = names.stream()
    .filter(name -> name.startsWith("K"))
    .map(String::toUpperCase)
    .collect(Collectors.toList());
System.out.println(result);  // 출력: [KIM]

0.4 데이터를 처리하는 효율적인 방식으로서의 Stream API

  • Stream API는 대용량 데이터를 다룰 때 특히 유용합니다.
    • Stream을 통해 데이터를 필터링, 정렬, 집계하는 작업을 매우 간결하게 수행할 수 있으며, 지연 평가를 통해 필요한 데이터만 효율적으로 처리할 수 있습니다.
    • 또한, 병렬 스트림을 사용하면 멀티 코어 환경에서 자동으로 데이터를 병렬로 처리하여 성능을 극대화할 수 있습니다.
  • Stream API의 주요 특징
    • 간결성: 선언적인 구문을 사용해 복잡한 데이터를 쉽게 처리할 수 있습니다.
    • 지연 처리: 필요한 데이터만 처리하므로 성능이 최적화됩니다.
    • 병렬 처리: parallelStream()을 사용하면 자동으로 데이터를 병렬로 처리할 수 있습니다.

1. Stream의 기본 구조 및 생성(Source)

1.1 Stream의 주요 특징과 기본 구조

  • Stream의 주요 특징을 다시 정리해보면 다음과 같습니다.
    • 일회성: Stream은 한 번 사용되면 재사용할 수 없습니다. 한 번의 처리가 끝나면 새로운 Stream을 생성해야 합니다.
    • 지연 처리: 중간 연산(filter(), map())은 즉시 실행되지 않고, 최종 연산(Terminal Operations)이 호출될 때 실행됩니다.
    • 불변성: Stream 자체는 데이터를 변경하지 않습니다. 원본 데이터를 변경하지 않고, 변경된 데이터를 새롭게 생성하여 처리합니다.
    • 병렬 처리: Stream API는 병렬 처리를 쉽게 구현할 수 있도록 도와줍니다. parallelStream()을 사용하면 데이터를 병렬로 처리할 수 있습니다.
    • 선언적: Stream API는 명령형(Imperative) 코드 대신 선언형(Declarative) 코드를 사용하여, 무엇을 할 것인지에만 집중할 수 있게 합니다.

Stream의 세 가지 처리 단계

  • Stream API의 핵심은 파이프라인(Pipeline)을 통해 데이터를 처리하는 것입니다.
  • 스트림은 크게 세 가지 단계로 처리됩니다:
    • 생성 (Source): 데이터 소스로부터 Stream을 생성합니다. (예: stream() 메서드로 리스트에서 스트림 생성)
    • 중간 연산 (Intermediate Operations): 데이터를 변환하거나 필터링하는 연산입니다. 중간 연산은 지연 처리(Lazy Evaluation)로, 종료 연산이 실행될 때까지 실제로 수행되지 않습니다.
    • 종료 연산 (Terminal Operations): Stream의 연산을 마무리하고, 최종 결과를 도출하는 단계입니다. 이때 Stream은 소모되며 더 이상 사용되지 않습니다.

1.2 Stream 생성 방법 (Source)

  • Stream은 컬렉션(Collection), 배열(Array), 파일 I/O 등 여러 가지 데이터 소스에서 생성할 수 있습니다.
  • 가장 일반적인 방법은 컬렉션에서 Stream을 생성하는 것입니다.
  • Collection 인터페이스에는 stream()parallelStream() 메서드가 추가되어있어서, 이를 통해 쉽게 Stream을 만들 수 있습니다.

1.2.1 컬렉션에서 Stream 생성

  • 컬렉션에서 Stream을 생성하는 방법은 매우 간단합니다.
  • stream() 메서드를 호출하여 Stream을 생성하고, 필요한 작업을 적용할 수 있습니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// 스트림 생성
Stream<String> nameStream = names.stream();

// 스트림을 사용한 데이터 처리
nameStream.filter(name -> name.startsWith("K"))
          .forEach(System.out::println);  // 출력: Kim
  • 위 코드에서 names.stream()을 통해 리스트로부터 스트림을 생성하고, filter()forEach()를 사용하여 데이터를 처리했습니다.
    • 참고로 .filter().forEach() 처럼 한줄에 메서드를 이어 붙이는 것을 메서드 체이닝(Method Chaining)이라고 합니다.

1.2.2 배열에서 Stream 생성

  • 배열에서도 Arrays.stream() 메서드를 사용하여 Stream을 생성할 수 있습니다. - 배열을 직접 Stream으로 변환할 수 있는 방법으로 제공됩니다.
String[] nameArray = {"Kim", "Lee", "Park"};

// 배열로부터 스트림 생성
Stream<String> nameStream = Arrays.stream(nameArray);

// 스트림을 사용한 데이터 처리
nameStream.filter(name -> name.startsWith("P"))
          .forEach(System.out::println);  // 출력: Park
  • 또는, Stream.of() 메서드를 사용하여 배열을 Stream으로 변환할 수도 있습니다.
Stream<String> stream = Stream.of("Kim", "Lee", "Park");
stream.forEach(System.out::println);

1.2.3 파일 I/O에서 Stream 생성

  • Stream은 파일의 내용을 처리하는 데도 사용할 수 있습니다.
  • Java의 NIO 패키지 (java.nio.file)를 사용하면, 파일에서 Stream을 생성하여 각 줄을 읽고 처리할 수 있습니다.
Path path = Paths.get("file.txt");

// 파일에서 스트림 생성
Stream<String> lines = Files.lines(path);

// 스트림을 사용한 데이터 처리
lines.filter(line -> line.contains("Java"))
     .forEach(System.out::println);
  • 이 코드는 file.txt 파일의 각 줄을 스트림으로 읽어, "Java"라는 단어가 포함된 줄을 출력합니다.

1.2.4 숫자 스트림 생성

  • Java에서는 IntStream, LongStream, DoubleStream과 같은 숫자형 스트림을 제공하여, 기본형 (원시) 타입의 숫자를 효율적으로 처리할 수 있습니다.
  • 숫자 스트림은 range(), rangeClosed()와 같은 메서드를 사용하여 생성할 수 있습니다.
IntStream.range(1, 5)  // 1, 2, 3, 4
         .forEach(System.out::println);
  • 이 코드는 1부터 4까지의 숫자를 출력합니다.
    • 1부터 5까지의 숫자는 rangeClosed() 메서드로 사용하시면 됩니다.

2. 중간 연산 (Intermediate Operations)

  • 중간 연산(Intermediate Operations)은 Stream 파이프라인의 중요한 부분으로, 데이터를 변환하거나 필터링하는 작업을 수행합니다.
  • 중간 연산은 항상 지연 처리(Lazy Evaluation)로 실행되며, 종료 연산이 실행될 때까지 실제로 수행되지 않습니다.
  • 이 섹션에서는 Stream API에서 사용되는 주요 중간 연산 메서드를 소개하고, 각 메서드의 특징과 사용법을 예시와 함께 설명하겠습니다.

2.1 중간 연산의 특징

  • 지연 처리(Lazy Evaluation): 중간 연산은 스트림을 변환하지만, 실제 데이터 처리는 종료 연산이 호출될 때 이루어집니다.
  • 무제한 체이닝: 여러 중간 연산을 체인으로 연결하여 사용할 수 있으며, 각 연산의 결과가 다음 연산에 입력됩니다.
  • 스트림 불변성: 스트림 자체는 변하지 않고, 변환된 데이터를 새로운 스트림으로 생성합니다.

2.2 주요 중간 연산 메서드

  • 아래 표는 Stream API에서 자주 사용되는 중간 연산 메서드를 정리한 것입니다.
메서드설명반환 타입사용 예시
filter()조건에 맞는 요소만 필터링합니다.Stream<T>stream.filter(x -> x > 10)
map()각 요소를 주어진 함수에 따라 변환합니다.Stream<R>stream.map(String::toUpperCase)
flatMap()각 요소를 스트림으로 변환하고,
이를 하나의 스트림으로 평탄화합니다.
Stream<R>stream.flatMap(List::stream)
distinct()중복된 요소를 제거합니다.Stream<T>stream.distinct()
sorted()스트림의 요소를 정렬합니다.Stream<T>stream.sorted(
Comparator.reverseOrder())
peek()각 요소를 소비하지 않고 보기만 합니다.
디버깅이나 로그를 출력할 때 유용합니다.
Stream<T>stream.peek(System.out::println)
limit()스트림에서 처음 n개의 요소만 반환합니다.Stream<T>stream.limit(5)
skip()처음 n개의 요소를 건너뜁니다.Stream<T>stream.skip(3)
takeWhile()조건이 참인 동안 스트림의 요소를 계속 반환합니다.
(Java 9 이상)
Stream<T>stream.takeWhile(x -> x < 10)
dropWhile()조건이 참인 동안 스트림의 요소를 건너뛰고,
조건이 거짓이 될 때부터 남은 요소를 반환합니다.
(Java 9 이상)
Stream<T>stream.dropWhile(x -> x < 10)

2.3 중간 연산 사용 예시

2.3.1 filter()

  • filter()는 주어진 조건에 맞는 요소만을 선택하여 스트림을 필터링하는 중간 연산입니다.
  • parameter : 함수형 인터페이스 Predicate<T>
    • Predicate<T>는 하나의 인수를 받아서 boolean 값을 반환하는 함수형 인터페이스입니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park", "Choi");
names.stream()
     .filter(name -> name.startsWith("K"))
     .forEach(System.out::println);  // 출력: Kim

2.3.2 map()

  • map()은 각 요소를 주어진 함수에 따라 변환하는 중간 연산입니다.
  • parameter : 함수형 인터페이스 Function<T, R>
    • Function<T, R>T 타입의 입력을 받아 R 타입의 결과를 반환하는 함수형 인터페이스입니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");
names.stream()
     .map(String::toUpperCase)
     .forEach(System.out::println);  // 출력: KIM, LEE, PARK

2.3.3 flatMap()

  • flatMap()은 각 요소를 스트림으로 변환하고, 여러 스트림을 하나로 평탄화하여 하나의 스트림으로 반환합니다.
  • parameter : 함수형 인터페이스 Function<T, Stream<R>>
    • flatMap()은 각 요소를 스트림으로 변환한 후, 그 스트림을 하나의 스트림으로 평탄화하는 Function<T, Stream<R>>을 인수로 받습니다.
List<List<String>> listOfLists = Arrays.asList(
    Arrays.asList("a", "b", "c"),
    Arrays.asList("d", "e", "f")
);
listOfLists.stream()
           .flatMap(List::stream)
           .forEach(System.out::println);  // 출력: a, b, c, d, e, f

2.3.4 distinct()

  • distinct()는 중복된 요소를 제거하는 중간 연산입니다.
  • parameter : 없음
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5);
numbers.stream()
       .distinct()
       .forEach(System.out::println);  // 출력: 1, 2, 3, 4, 5

2.3.5 sorted()

  • sorted()는 스트림의 요소를 정렬하는 중간 연산입니다.
  • parameter : 함수형 인터페이스 Comparator<T> or 없음
    • sorted()는 요소를 정렬할 때 Comparator<T>를 인수로 받을 수 있습니다.
    • Comparator<T>는 두 개의 인수를 비교하여 정렬 순서를 결정합니다.
    • 파라미터를 지정하지 않으면 자연순서(주로 오름차순)로 정의된 Comparator가 이용됩니다.
List<String> names = Arrays.asList("Lee", "Kim", "Park");
names.stream()
     .sorted()
     .forEach(System.out::println);  // 출력: Kim, Lee, Park

2.3.6 peek()

  • peek()는 스트림의 각 요소를 소비하지 않고, 중간에 검사하거나 디버깅 목적으로 사용할 수 있는 중간 연산입니다.
  • parameter : 함수형 인터페이스 Consumer<T>
    • peek()는 각 요소를 "소비"하지 않고 보기만 하므로 Consumer<T>를 인수로 받습니다.
    • Consumer<T>는 입력을 받아 처리하지만 반환값이 없는 함수형 인터페이스입니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");
names.stream()
     .peek(name -> System.out.println("Processing: " + name))
     .map(String::toUpperCase)
     .forEach(System.out::println);  // 출력: Processing: Kim, KIM, Processing: Lee, LEE, Processing: Park, PARK

2.3.7 limit()와 skip()

  • limit()은 스트림의 처음 n개의 요소만 반환하고, skip()은 처음 n개의 요소를 건너뛰는 중간 연산입니다.
  • parameter : 없음
    • limit()skip()은 단순히 스트림의 요소를 제한하거나 건너뛰는 연산을 수행하며, 함수형 인터페이스를 사용하지 않습니다.
IntStream.range(1, 10)
         .limit(5)
         .forEach(System.out::println);  // 출력: 1, 2, 3, 4, 5

IntStream.range(1, 10)
         .skip(5)
         .forEach(System.out::println);  // 출력: 6, 7, 8, 9

2.3.8 takeWhile()와 dropWhile() (Java 9 이상)

  • takeWhile()은 조건이 참인 동안 요소를 반환하고, dropWhile()은 조건이 참인 동안 요소를 건너뜁니다.
  • parameter : 함수형 인터페이스 Predicate<T>
    • takeWhile()dropWhile()은 조건을 만족하는 동안 요소를 반환하거나 건너뛰므로, Predicate<T>를 인수로 받습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

// takeWhile 사용 예시
numbers.stream()
       .takeWhile(n -> n < 5)
       .forEach(System.out::println);  // 출력: 1, 2, 3, 4

// dropWhile 사용 예시
numbers.stream()
       .dropWhile(n -> n < 5)
       .forEach(System.out::println);  // 출력: 5, 6, 7, 8, 9

2.4 스트림 파이프라인과 지연 처리 (Lazy Evaluation)

  • 스트림 파이프라인은 중간 연산과 종료 연산이 연속적으로 연결된 구조를 말합니다.
  • 중간 연산은 실제로 처리되지 않고, 종료 연산이 실행될 때 한꺼번에 처리됩니다.
    • 이를 지연 처리(Lazy Evaluation)라고 하며, 성능 최적화에 중요한 역할을 합니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

List<String> result = names.stream()
     .filter(name -> name.length() > 3)    // 필터링
     .map(String::toUpperCase)             // 대문자로 변환
     .collect(Collectors.toList());        // 종료 연산을 통해 리스트로 수집

System.out.println(result);  // 출력: PARK
  • 위의 스트림 파이프라인에서는 filter()map()은 중간 연산으로, 지연 처리되며, collect()라는 종료 연산이 실행될 때 실제로 모든 연산이 수행됩니다.

3. 종료 연산 (Terminal Operations)

  • 종료 연산(Terminal Operations)은 스트림의 마지막에 위치하여 스트림을 소비하고, 데이터를 실제로 처리하여 결과를 도출하는 역할을 합니다.
  • 종료 연산이 호출되면, 그때 비로소 스트림 파이프라인 전체가 실행됩니다.
  • 종료 연산을 통해 스트림은 소모되며, 한 번 종료 연산이 호출된 스트림은 다시 사용할 수 없습니다.
  • 종료 연산의 결과는 컬렉션(Collection), 값(Value), 또는 부수 효과(Side Effect)를 통해 출력되며, 종료 연산을 통해 최종 데이터를 추출하게 됩니다.

3.1 종료 연산 메서드 목록

  • 아래 표는 Stream API에서 사용되는 주요 종료 연산 메서드를 정리한 것입니다.
메서드설명반환 타입사용 예시
forEach()각 요소에 대해 주어진 동작을 수행하며,
반환값이 없습니다.
voidstream.forEach(
System.out::println)
forEachOrdered()스트림의 요소들을 순서대로 처리합니다
(병렬 스트림에서도 순서를 보장합니다).
voidstream.forEachOrdered(
System.out::println)
collect()스트림의 요소들을 다른 컬렉션이나
자료 구조로 변환하여 반환합니다.
Collector<T, A, R>stream.collect(
Collectors.toList())
reduce()스트림의 요소들을 결합하여
하나의 결과를 도출합니다.
Optional<T>stream.reduce(
(a, b) -> a + b)
toArray()스트림의 요소들을
배열로 변환하여 반환합니다.
T[]stream.toArray(
String[]::new)
min()스트림의 요소 중에서 최소값을 반환합니다.
(Comparator를 인수로 받음)
Optional<T>stream.min(
Comparator.naturalOrder())
max()스트림의 요소 중에서 최대값을 반환합니다.
(Comparator를 인수로 받음)
Optional<T>stream.max(
Comparator.naturalOrder())
count()스트림의 요소 개수를 반환합니다.longstream.count()
anyMatch()스트림의 요소 중 하나라도
조건을 만족하는지 여부를 반환합니다.
(Predicate를 인수로 받음)
booleanstream.anyMatch(
x -> x > 10)
allMatch()스트림의 모든 요소가
조건을 만족하는지 여부를 반환합니다.
(Predicate를 인수로 받음)
booleanstream.allMatch(
x -> x > 10)
noneMatch()스트림의 모든 요소가
조건을 만족하지 않는지 여부를 반환합니다.
(Predicate를 인수로 받음)
booleanstream.noneMatch(
x -> x > 10)
findFirst()스트림의 첫 번째 요소를 반환합니다.Optional<T>stream.findFirst()
findAny()스트림의 요소 중 하나를 반환합니다.
(병렬 스트림에서는 아무 요소나 반환 가능)
Optional<T>stream.findAny()

3.2 주요 종료 연산 사용 예시

3.2.1 forEach()

  • 함수형 인터페이스: Consumer<T>
    • forEach()는 스트림의 각 요소에 대해 주어진 동작을 수행하며, 주로 데이터를 출력하거나 처리할 때 사용됩니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// forEach()는 Consumer<T>를 인수로 받음
names.stream()
     .forEach(System.out::println);  // 출력: Kim, Lee, Park

3.2.2 collect()

  • 함수형 인터페이스: Collector<T, A, R>
    • collect()는 스트림의 요소들을 다른 자료 구조로 변환하여 반환합니다.
    • 대표적으로 리스트, 셋, 맵 등으로 변환할 수 있으며, Collectors 클래스를 통해 쉽게 구현할 수 있습니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// collect()는 Collector<T, A, R>을 인수로 받음
List<String> filteredNames = names.stream()
     .filter(name -> name.startsWith("K"))
     .collect(Collectors.toList());  // List로 수집

System.out.println(filteredNames);  // 출력: [Kim]

3.2.3 reduce()

  • 함수형 인터페이스: BinaryOperator<T>
    • reduce()는 스트림의 요소들을 결합하여 하나의 결과를 도출하는 함수형 인터페이스입니다.
    • 주로 값을 축약하거나 집계할 때 사용됩니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// reduce()는 BinaryOperator<T>를 인수로 받음
Optional<Integer> sum = numbers.stream()
     .reduce((a, b) -> a + b);

System.out.println(sum.get());  // 출력: 15

3.2.4 toArray()

  • toArray()는 스트림의 요소들을 배열로 변환하여 반환합니다.
    • 배열로 변환할 때는 배열 생성자를 메서드 참조로 전달합니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// toArray()는 스트림의 요소를 배열로 변환
String[] nameArray = names.stream()
     .toArray(String[]::new);

System.out.println(Arrays.toString(nameArray));  // 출력: [Kim, Lee, Park]

3.2.5 min()과 max()

  • 함수형 인터페이스: Comparator<T>
    • min()max()는 스트림에서 최소값과 최대값을 찾는 메서드로, Comparator<T>를 인수로 받습니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// min()은 Comparator<T>를 인수로 받음
Optional<Integer> min = numbers.stream()
     .min(Integer::compare);

System.out.println(min.get());  // 출력: 1

3.2.6 count()

  • count()는 스트림의 요소 개수를 반환합니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// count()는 스트림의 요소 개수를 반환
long count = names.stream()
     .count();

System.out.println(count);  // 출력: 3

3.2.7 anyMatch(), allMatch(), noneMatch()

  • 함수형 인터페이스: Predicate<T>
    • anyMatch()는 스트림의 요소 중 하나라도 조건을 만족하는지 확인합니다.
    • allMatch()는 모든 요소가 조건을 만족하는지 확인합니다.
    • noneMatch()는 모든 요소가 조건을 만족하지 않는지를 확인합니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// anyMatch()는 Predicate<T>를 인수로 받음
boolean hasEven = numbers.stream()
     .anyMatch(x -> x % 2 == 0);

System.out.println(hasEven);  // 출력: true

3.2.8 findFirst()와 findAny()

  • findFirst()는 스트림의 첫 번째 요소를 반환하고, findAny()는 스트림에서 아무 요소나 하나를 반환합니다.
  • 두 메서드는 Optional<T>로 값을 반환하므로, 값을 안전하게 처리할 수 있습니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park");

// findFirst()는 첫 번째 요소를 반환
Optional<String> first = names.stream()
     .findFirst();

System.out.println(first.get());  // 출력: Kim

3.3 종료 연산과 스트림 소모

  • 종료 연산이 실행되면 스트림은 소모되며, 더 이상 사용할 수 없습니다.
  • 종료 연산 후 다시 스트림을 사용하려면 새로운 스트림을 생성해야 합니다.
Stream<String> stream = Stream.of("Kim", "Lee", "Park");

// 종료 연산 후 스트림 재사용 불가
stream.forEach(System.out::println);  // 스트림 사용

// 다음 코드는 에러 발생: IllegalStateException
stream.forEach(System.out::println);  // 에러: 스트림이 이미 소모됨
  • 스트림은 한 번 사용되면 재사용할 수 없기 때문에, 스트림을 처리한 후에 새로운 스트림이 필요하면 다시 생성해야 합니다.

4. 병렬 스트림 (Parallel Streams)

  • Java 8에서 도입된 병렬 스트림(Parallel Stream)은 멀티코어 CPU 환경에서 데이터를 병렬로 처리할 수 있도록 해주는 기능입니다.
  • 병렬 스트림을 사용하면 여러 코어에서 데이터를 동시에 처리하므로, 처리 속도를 높일 수 있습니다.
    • 하지만 병렬 스트림은 모든 상황에서 유리하지 않으며, 성능 이점을 누리기 위해서는 신중한 사용이 필요합니다.

4.1 parallelStream()의 사용법

  • 병렬 스트림은 parallelStream() 메서드를 사용하여 생성하거나, 기존 스트림에서 parallel() 메서드를 호출하여 변환할 수 있습니다.
  • 병렬 스트림을 사용하면 내부적으로 ForkJoinPool을 통해 멀티스레드 환경에서 데이터를 처리하게 됩니다.

4.1.1 parallelStream()으로 병렬 스트림 생성

  • 컬렉션에서 직접 병렬 스트림을 생성하는 방법입니다.
  • parallelStream() 메서드를 호출하여 병렬 처리를 시작할 수 있습니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park", "Choi");

// parallelStream()으로 병렬 스트림 생성
names.parallelStream()
     .forEach(System.out::println);

4.1.2 parallel() 메서드를 통한 병렬 스트림 변환

  • 기존 스트림을 병렬 스트림으로 변환하는 방법입니다.
  • stream()으로 생성한 스트림에 parallel() 메서드를 호출하여 병렬 처리로 전환할 수 있습니다.
List<String> names = Arrays.asList("Kim", "Lee", "Park", "Choi");

// stream() 생성 후 parallel()로 병렬 스트림으로 변환
names.stream()
     .parallel()
     .forEach(System.out::println);

4.1.3 sequential()로 순차 스트림으로 변환

  • 병렬 스트림을 사용한 후에도 필요할 경우 sequential() 메서드를 호출하여 다시 순차 스트림으로 변환할 수 있습니다.
names.parallelStream()
     .sequential()  // 다시 순차 스트림으로 변환
     .forEach(System.out::println);

4.2 병렬 스트림의 장단점 및 주의사항

장점

  • 병렬 스트림의 가장 큰 장점은 데이터 병렬 처리를 통한 성능 향상입니다.
    • 대규모 데이터를 처리할 때 병렬 스트림을 사용하면 여러 스레드에서 작업을 나눠 처리하므로, 특히 대용량 데이터를 다룰 때 유리합니다.
  • 성능 향상: 병렬 처리를 통해 멀티코어 CPU 자원을 효과적으로 사용하며, 대용량 데이터 처리에 유리합니다.
  • 사용 편리성: 기존 코드에서 단순히 parallelStream() 메서드를 호출하거나 parallel() 메서드를 호출하는 것만으로 병렬 처리를 적용할 수 있습니다.
  • 자동 스레드 관리: 병렬 스트림은 내부적으로 ForkJoinPool을 사용하여 스레드 관리를 자동으로 처리하므로, 개발자가 직접 스레드 관리를 할 필요가 없습니다.

단점

  • 병렬 스트림은 상황에 따라 성능 이점을 주기도 하지만, 무조건 빠른 것은 아닙니다.
  • 과도한 스레드 생성: 작은 데이터셋에서는 병렬 처리의 오버헤드가 더 커져서 오히려 성능이 떨어질 수 있습니다.
    • 병렬 스트림은 기본적으로 스레드를 분할하여 처리하므로, 스레드 생성 및 스위칭에 따른 오버헤드가 발생합니다.
  • 공유 자원 관리의 어려움: 병렬 스트림에서 처리하는 데이터가 공유 자원일 경우, 스레드 간 동기화 문제가 발생할 수 있습니다.
    • 이로 인해 데이터 불일치나 성능 저하가 발생할 수 있습니다.
  • 순서 보장 안됨: 병렬 스트림은 기본적으로 요소들의 순서를 보장하지 않으므로, 순서가 중요한 작업에서는 문제가 될 수 있습니다.
    • 이 경우 forEachOrdered()를 사용해야 합니다.
names.parallelStream()
     .forEachOrdered(System.out::println);  // 순서를 보장하며 병렬 처리

병렬 스트림 사용시 주의사항

  • 작업의 크기: 병렬 스트림은 대용량 데이터 처리에 적합하며, 작은 데이터셋에는 오히려 성능이 떨어질 수 있습니다.
    • 데이터셋이 작을 경우, 병렬 스트림의 장점이 발휘되지 않을 수 있습니다.
  • 순차 작업을 피하기: 병렬 스트림의 성능을 극대화하려면, 순차적인 작업(예: I/O 작업, 순차 정렬 등)을 최소화해야 합니다.
    • 병렬 작업이 순차적으로 처리되면 병렬 처리의 이점이 사라집니다.
  • 스레드 안전성: 병렬 스트림을 사용할 때는 공유 자원에 대해 스레드 안전성을 고려해야 합니다.
    • 공유 자원에 동시 접근하는 상황에서는 동기화 처리가 필요합니다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// 병렬 스트림에서 병렬 작업을 수행할 경우, 스레드 안전성을 고려해야 함
numbers.parallelStream()
       .forEach(number -> {
           synchronized (System.out) {
               System.out.println(number);
           }
       });

5. Stream API 사용 시 주의점

  • Stream API는 데이터를 선언적이고 효율적으로 처리할 수 있도록 해주지만, 사용 시에 몇 가지 주의할 점이 있습니다.
    • 스트림을 잘못 사용하면 성능 저하를 초래할 수 있고, 병렬 스트림은 오히려 예상치 못한 결과를 낳을 수 있습니다.
    • 이번에는 Stream API를 사용할 때 고려해야 할 몇 가지 중요한 유의사항을 다루겠습니다.

5.1 스트림은 한 번만 소비될 수 있음

  • Stream은 일회성으로 설계되어 한 번 사용되면 다시 사용할 수 없습니다.
    • 스트림을 한 번 소비하는 종료 연산이 실행되면, 그 스트림은 더 이상 사용할 수 없으므로 새로운 스트림을 생성해야 합니다.
Stream<String> stream = Stream.of("Kim", "Lee", "Park");

// 첫 번째 종료 연산 (정상 작동)
stream.forEach(System.out::println);

// 두 번째 종료 연산 (에러 발생)
stream.forEach(System.out::println);  // IllegalStateException 발생
  • 주의사항: 스트림을 한 번 소비하고 나면 더 이상 사용할 수 없으므로, 필요하다면 동일한 소스로 새로운 스트림을 생성해야 합니다.
  • 해결 방법: 스트림을 여러 번 처리해야 하는 경우, 반복해서 스트림을 생성하거나 원본 데이터를 다시 호출하는 방식으로 해결할 수 있습니다.

5.2 병렬 스트림 사용 시 유의사항

  • 병렬 스트림은 대규모 데이터를 효율적으로 처리할 수 있는 방법이지만, 모든 상황에서 성능 이점을 보장하지는 않습니다. 병렬 스트림을 사용할 때는 다음 사항에 주의해야 합니다.
    • 데이터 크기에 따른 성능 차이
      • 작은 데이터셋에서는 병렬 스트림의 스레드 관리 비용이 성능 이점을 상쇄할 수 있습니다.
      • 병렬 스트림은 데이터가 클수록 유리하며, 작은 데이터에서는 오히려 순차 스트림이 더 나은 성능을 보일 수 있습니다.
    • 공유 자원 처리 문제
      • 병렬 스트림에서 공유 자원에 접근할 경우 스레드 안전성 문제가 발생할 수 있습니다.
      • 병렬로 실행되는 작업에서 동기화되지 않은 공유 자원에 접근하면 예상치 못한 결과가 나올 수 있으며, 이로 인해 데이터 손상 또는 성능 저하가 발생할 수 있습니다.
      • Collections.synchronizedList()를 사용하거나, synchronized 블록을 사용하여 안전하게 처리할 수 있습니다.
    • 순서가 중요한 작업
      • 병렬 스트림에서는 기본적으로 요소의 처리 순서가 보장되지 않으므로, 순서가 중요한 작업에서는 문제가 발생할 수 있습니다.
      • 순차적인 작업을 수행해야 할 경우 forEach() 대신 forEachOrdered()를 사용하여 처리 순서를 보장할 수 있습니다.

5.3 성능 저하를 유발하는 무분별한 스트림 사용

  • 스트림을 남용하면 오히려 성능이 떨어질 수 있습니다.
  • 특히 작은 데이터셋이나 반복문보다 간단한 연산에 스트림을 사용하는 것은 오버헤드를 초래할 수 있습니다.

5.3.1 반복문과 비교한 스트림의 성능

  • 스트림은 지연 처리(Lazy Evaluation)와 체이닝 덕분에 큰 데이터셋에서 복잡한 데이터 처리를 최적화할 수 있지만, 작은 데이터셋이나 단순한 연산에서는 성능 저하가 발생할 수 있습니다.
// 작은 데이터셋에서는 오히려 for-each가 더 나은 성능을 보일 수 있음
for (String name : names) {
    System.out.println(name);
}

// 스트림을 과도하게 사용할 경우 오히려 성능이 떨어질 수 있음
names.stream()
     .forEach(System.out::println);

5.3.2 스트림 연산의 과도한 체이닝

  • 스트림 연산이 너무 많이 체이닝되면 가독성이 떨어질 뿐만 아니라 성능 저하를 유발할 수 있습니다.
  • 불필요한 중간 연산은 피하고, 가능한 간결한 형태로 스트림을 구성하는 것이 좋습니다.
// 불필요하게 복잡한 스트림 체이닝 (비효율적)
names.stream()
     .filter(name -> name.length() > 3)
     .map(String::toLowerCase)
     .sorted()
     .distinct()
     .forEach(System.out::println);

// 간결한 스트림 처리 (효율적)
names.stream()
     .filter(name -> name.length() > 3)
     .forEach(System.out::println);

5.4 종료 연산과 스트림의 소모

  • 종료 연산이 호출되면 스트림은 소모되며, 더 이상 사용할 수 없습니다.
    • 이를 인지하지 못하고 같은 스트림을 여러 번 사용하려고 시도하면 예외가 발생할 수 있습니다.
  • 스트림을 여러 번 사용해야 하는 경우, 매번 새로운 스트림을 생성하는 방식으로 문제를 해결할 수 있습니다.
Stream<String> stream = Stream.of("Kim", "Lee", "Park");

// 스트림을 재사용할 필요가 있다면, 새로운 스트림을 생성
Stream<String> newStream = Stream.of("Kim", "Lee", "Park");
newStream.forEach(System.out::println);

5.5 데이터 구조에 따른 스트림 성능 차이

  • 스트림의 성능은 데이터 구조에 따라 다르게 나타납니다.
    • ArrayList와 같은 랜덤 접근이 가능한 데이터 구조에서는 스트림 성능이 좋지만, LinkedList와 같은 순차 접근이 필요한 구조에서는 성능이 떨어질 수 있습니다.
    • 따라서 데이터 구조에 맞게 스트림을 사용하는 것이 중요합니다.
List<String> arrayList = new ArrayList<>(Arrays.asList("Kim", "Lee", "Park"));
List<String> linkedList = new LinkedList<>(Arrays.asList("Kim", "Lee", "Park"));

// ArrayList는 랜덤 접근이 가능하여 스트림 성능이 좋음
arrayList.stream().forEach(System.out::println);

// LinkedList는 순차 접근이 필요하여 스트림 성능이 떨어질 수 있음
linkedList.stream().forEach(System.out::println);

마무리

  • Stream API는 Java 8에서 도입된 이후, Java 개발자들에게 강력한 도구로 자리 잡았습니다.
    • 데이터를 필터링, 변환, 집계하는 복잡한 작업을 간결하고 효율적으로 처리할 수 있게 도와주며, 특히 람다 표현식과 함께 사용하면 선언적인 프로그래밍 스타일을 가능하게 해줍니다.
  • Stream API의 주요 장점
    • 간결성: 복잡한 데이터 처리 작업을 짧고 직관적인 코드로 작성할 수 있습니다.
    • 병렬 처리: 멀티코어 환경에서 성능을 극대화할 수 있는 병렬 스트림 기능을 통해 대규모 데이터를 효율적으로 처리할 수 있습니다.
    • 지연 처리: 필요한 데이터만 처리하여 성능 최적화가 가능하며, 중간 연산이 지연 처리되므로 최종 결과를 도출할 때만 연산이 이루어집니다.
  • 실무에서 Stream API를 효율적으로 사용하는 방법
    • 적절한 경우에 사용: 작은 데이터셋이나 단순한 반복 작업에서는 Stream API를 남용하지 않는 것이 중요합니다. Stream API는 복잡한 데이터 처리나 대용량 데이터 작업에서 성능을 발휘하므로, 사용 상황을 신중하게 고려해야 합니다.
    • 병렬 스트림의 활용: 병렬 스트림은 멀티코어 환경에서 성능을 극대화할 수 있지만, 항상 성능 향상을 보장하는 것은 아닙니다. 데이터의 크기, 작업의 특성에 따라 병렬 스트림이 적합한지 판단해야 합니다.
    • 코드 가독성 유지: Stream API를 사용할 때 지나치게 복잡한 체이닝을 피하고, 간결하고 가독성 높은 코드를 작성하는 것이 중요합니다.
  • 실무에서 Stream API를 적절히 활용하면, 코드의 품질을 한 단계 높일 수 있을 것입니다.
  • 다음 포스팅에서는 자바에서 많이 쓰이는 Iterator와 Comparator에 대해 정리해보도록 하겠습니다.
profile
일 때문에 포스팅은 잠시 쉬어요 ㅠ 바쁘다 바빠 모두들 화이팅! // Machine Learning (AI) Engineer & BackEnd Engineer (Entry)

0개의 댓글