본문 바로가기
개발

Java 8 Stream API 사용법

by autocat 2024. 10. 24.

Understanding the Stream API in Java 8

Java 8에서 Stream API는 대량 데이터를 처리하는 방식을 혁신했다. Stream은 데이터를 추상화하여 필터링, 변환, 축소 등의 작업을 선언형으로 수행할 수 있도록 한다. 이는 컬렉션(Collection) API와 함께 자주 사용되며, 더 나은 가독성과 효율적인 코드를 작성하는 데 큰 도움이 된다.

What is a Stream?

Stream은 데이터의 흐름을 나타내며, 원본 데이터를 변경하지 않고 다양한 중간 연산과 최종 연산을 통해 데이터를 처리할 수 있다.
Stream API는 두 가지 연산으로 나뉜다.

  1. 중간 연산 (Intermediate Operations): 필터링, 매핑 등으로 데이터를 변환하거나 추출한다. 이 연산은 지연 평가(lazy evaluation) 방식으로, 최종 연산이 호출될 때까지 실제로 실행되지 않는다.
  2. 최종 연산 (Terminal Operations): 스트림을 처리해 결과를 반환하는 연산이다. 최종 연산이 호출되면 스트림은 닫히고 더 이상 사용할 수 없다.

Stream API의 특징

  1. 선언형 코드: 반복문을 사용하지 않고, 선언적으로 데이터 흐름을 표현할 수 있다.
  2. 지연 평가: 중간 연산은 최종 연산이 호출되기 전까지 실제로 실행되지 않아 성능을 향상시킨다.
  3. 병렬 처리: 멀티코어 환경에서 병렬로 데이터를 처리할 수 있어 성능을 극대화할 수 있다.

Stream API Examples

Example 1: Filtering and Collecting

아래 코드는 List<String>에서 문자열 길이가 3인 문자열을 필터링한 후, 결과를 새로운 리스트로 수집하는 예시다.

@Test  
void filtering_with_stream(){  
    List<String> names = Arrays.asList("John", "Jane", "Tom", "Jerry");  

    List<String> filteredNames = names.stream()  
            .filter(name -> name.length() == 3)  
            .toList();  

    assertEquals("Tom", filteredNames.get(0));  
}

여기서 Collectors.toList()Collector 인터페이스의 구현체로, 스트림의 요소들을 리스트로 수집하는 데 사용된다. CollectorsStream API의 여러 최종 연산에서 사용되는 다양한 수집 연산을 제공한다. 이를 통해 데이터를 특정 컬렉션 형태로 변환할 수 있다.

Example 2: Mapping and Sorting with Collectors

Stream의 map() 연산은 데이터를 변환하는 강력한 도구이다. 아래 예시는 문자열 리스트를 대문자로 변환하고, 알파벳 순으로 정렬한 후 이를 다시 리스트로 수집하는 예시다.

@Test  
void mapping_and_sorting(){  
    List<String> names = Arrays.asList("John", "Jane", "Tom", "Jerry");  

    List<String> sortedNames = names.stream()  
            .map(String::toUpperCase) // 중간 연산: 대문자로 변환  
            .sorted() // 중간 연산: 알파벳 순으로 정렬  
            .toList();  

    assertEquals("JANE", sortedNames.get(0));  
    assertEquals("JERRY", sortedNames.get(1));  
    assertEquals("JOHN", sortedNames.get(2));  
    assertEquals("TOM", sortedNames.get(3));  
}

이 코드에서 .collect(Collectors.toList())는 최종 연산으로, 대문자로 변환되고 정렬된 결과를 리스트로 수집한다. Collectors 클래스는 여러 종류의 수집 연산을 제공하며, 예를 들어 toList(), toSet(), toMap() 등 다양한 데이터 구조로 결과를 수집할 수 있다. 이를 통해 스트림을 매우 유연하게 처리할 수 있다.

Sorting with Comparator

위 예시에서는 기본 알파벳 순서로 정렬했지만, Comparator를 사용하면 정렬 기준을 사용자 정의할 수 있다. Comparator는 Java에서 객체를 비교하고 정렬하는 데 사용되는 인터페이스이다.

@Test  
void mapping_and_reverse_sorting_with_comparator(){  
    List<String> names = Arrays.asList("John", "Jane", "Tom", "Jerry");  

    List<String> sortedNames = names.stream()  
            .map(String::toUpperCase)  
            .sorted(Comparator.reverseOrder())  
            .toList();  

    assertEquals("TOM", sortedNames.get(0));  
    assertEquals("JOHN", sortedNames.get(1));  
    assertEquals("JERRY", sortedNames.get(2));  
    assertEquals("JANE", sortedNames.get(3));  
}

Comparator의 다양한 사용 방법

기본 정렬: .sorted()는 요소의 자연 정렬(알파벳 순 등)을 따른다.
내림차순 정렬: .sorted(Comparator.reverseOrder())는 기본 정렬을 뒤집어 내림차순으로 정렬한다.
사용자 정의 정렬: Comparator.comparing()을 사용하면 객체의 특정 필드를 기준으로 정렬할 수 있다.
예를 들어, 아래는 문자열의 길이를 기준으로 정렬하는 예시이다.

@Test  
void mapping_and_sorting_by_field(){  
    List<String> names = Arrays.asList("James", "Jane", "Tom", "Verstapen");  

    List<String> sortedNames = names.stream()  
            .map(String::toUpperCase)  
            .sorted(Comparator.comparingInt(String::length))  
            .toList();  

    assertEquals("TOM", sortedNames.get(0));  
    assertEquals("JANE", sortedNames.get(1));  
    assertEquals("JAMES", sortedNames.get(2));  
    assertEquals("VERSTAPEN", sortedNames.get(3));  
}

여기서 Comparator.comparingInt()는 문자열의 길이를 기준 오름차순으로 정렬한다.

Stream의 병렬 처리 (Parallel Streams)

Java 8의 Stream API병렬 처리를 매우 쉽게 구현할 수 있는 기능을 제공한다. 병렬 처리는 데이터를 여러 스레드에서 동시에 처리할 수 있게 하여 대용량 데이터 처리 성능을 크게 향상시킨다. parallelStream()을 사용하면 스트림이 자동으로 병렬 처리되어 여러 CPU 코어를 활용할 수 있다. 다음 예시는 병렬 스트림을 통해 데이터를 효율적으로 처리하는 예시다.

@Test  
void stream_with_parallel_streams(){  
    List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);  

    int sum = numbers.parallelStream() // 병렬 스트림 생성  
            .filter(n -> n % 2 == 0) // 중간 연산: 짝수 필터링  
            .mapToInt(n -> n) // 중간 연산: int로 변환  
            .sum(); // 최종 연산: 합계 계산  

    assertEquals(30, sum);  
}

위 예시에서는 parallelStream()을 사용하여 스트림을 병렬로 처리한다. 병렬 스트림은 내부적으로 데이터를 여러 스레드에서 나누어 처리하기 때문에 대량의 데이터를 빠르게 처리할 수 있다.

병렬 스트림의 장점과 주의점

병렬 스트림을 사용하면 대량의 데이터를 처리할 때 성능을 크게 향상시킬 수 있다. 하지만 모든 경우에 병렬 처리가 최적의 성능을 제공하는 것은 아니다. 다음과 같은 상황에서는 주의가 필요하다:

  1. 작은 데이터셋: 데이터셋이 작을 경우 병렬 처리 오버헤드가 발생할 수 있다. 오히려 성능이 느려질 수 있다.
  2. 데이터 순서: 병렬 스트림을 사용할 경우 데이터의 순서가 보장되지 않을 수 있다. 정렬이 중요한 경우에는 .sorted()와 같은 메서드를 추가로 사용해야 한다.
  3. 상태 공유: 병렬 처리는 여러 스레드에서 동시에 실행되기 때문에 공유된 상태를 변경하는 작업을 피해야 한다. 그렇지 않으면 동기화 문제가 발생할 수 있다.

Conclusion

Java 8의 Stream API는 데이터를 선언적으로 처리할 수 있는 강력한 도구이다. 필터링, 매핑, 정렬, 수집 등의 작업을 간결하게 처리할 수 있으며, 특히 병렬 스트림을 통해 성능을 최적화할 수 있다. Collectors를 사용해 다양한 방식으로 데이터를 수집하고 가공할 수 있다는 점도 매우 유용하다. Comparator를 통해 정렬 기준을 사용자 정의하여 더욱 유연하게 데이터를 다룰 수 있다. Stream API를 적절하게 활용하면 더 가독성 높고 효율적인 코드를 작성할 수 있다.