Java Stream API 완전 정복 – 함수형 프로그래밍 스타일로 코드 작성하기






Java Stream API 완전 정복 – 함수형 프로그래밍 스타일로 코드 작성하기

Java Stream API 완전 정복 – 함수형 프로그래밍 스타일로 코드 작성하기

code, html, digital, coding, web, programming, computer, technology, internet, design, development, website, web developer, web development, programming code, data, page, computer programming, software, site, css, script, web page, website development, www, information, java, screen, code, code, code, html, coding, coding, coding, coding, coding, web, programming, programming, computer, technology, website, website, web development, software

프로그래밍을 하다 보면 데이터 컬렉션을 다루는 일이 정말 많습니다. 특히 Java 개발자라면 Collection API를 빈번하게 사용하죠. 하지만 전통적인 반복문 기반의 코드는 복잡하고 가독성이 떨어지는 경우가 많습니다. 이러한 문제를 해결하고, 함수형 프로그래밍 스타일을 Java에서도 적용할 수 있도록 해주는 강력한 도구가 바로 Java Stream API입니다.

이번 글에서는 Java Stream API를 처음 접하는 분들도 쉽게 이해하고 활용할 수 있도록, 개념부터 실전 예제까지 꼼꼼하게 다뤄보겠습니다. 함수형 프로그래밍의 장점을 살려 코드를 더욱 간결하고 효율적으로 만들 수 있도록 안내해 드릴게요. 함께 Java Stream API의 세계로 떠나볼까요?

Stream API란 무엇일까요?

Stream API는 Java 8에 도입된 기능으로, 데이터 컬렉션을 처리하는 새로운 방법을 제시합니다. 기존의 반복문 기반 코드와 달리, Stream API는 데이터를 처리하는 과정을 선언적으로 표현하여 코드의 가독성을 높이고, 병렬 처리를 쉽게 구현할 수 있도록 돕습니다. 마치 데이터 처리 과정을 레시피처럼 작성하는 것과 같습니다.

Stream API의 핵심 개념

Stream API는 ‘데이터 스트림’이라는 추상화를 통해 데이터를 처리합니다. 데이터 스트림은 데이터의 흐름을 나타내며, 이 흐름을 따라 다양한 연산을 수행할 수 있습니다. Stream API의 핵심은 중간 연산(Intermediate Operations)최종 연산(Terminal Operations)으로 나눌 수 있다는 점입니다.

중간 연산은 스트림을 변환하는 역할을 합니다. 예를 들어, 필터링, 매핑, 정렬 등이 중간 연산에 해당합니다. 중요한 점은 중간 연산은 스트림을 반환하므로, 여러 중간 연산을 연결하여 연속적으로 적용할 수 있다는 것입니다.

최종 연산은 스트림의 결과를 반환하는 역할을 합니다. 예를 들어, 합계 계산, 최대/최소값 찾기, 리스트로 변환 등이 최종 연산에 해당합니다. 최종 연산은 스트림을 소비하며, 스트림을 한 번 소비하면 더 이상 사용할 수 없습니다.

Stream API의 장점

Stream API를 사용하면 다음과 같은 장점을 얻을 수 있습니다.

  • 코드 간결성: 반복문 기반 코드보다 훨씬 간결하고 읽기 쉬운 코드를 작성할 수 있습니다.
  • 가독성 향상: 데이터 처리 과정을 선언적으로 표현하여 코드의 의도를 명확하게 드러낼 수 있습니다.
  • 병렬 처리 용이성: 병렬 스트림을 사용하면 멀티코어 환경에서 데이터 처리를 병렬로 수행하여 성능을 향상시킬 수 있습니다.
  • 유연성: 다양한 중간 연산과 최종 연산을 조합하여 복잡한 데이터 처리 로직을 쉽게 구현할 수 있습니다.

Stream API 사용법 완전 정복

이제 Stream API를 실제로 사용하는 방법을 자세히 알아보겠습니다. Stream 생성부터 중간 연산, 최종 연산까지 단계별로 살펴보고, 예제 코드를 통해 이해를 돕겠습니다.

Stream 생성하기

Stream API를 사용하려면 먼저 데이터 소스로부터 스트림을 생성해야 합니다. 다양한 방법으로 스트림을 생성할 수 있습니다.

  • Collection으로부터 생성: `Collection.stream()` 메서드를 사용하여 컬렉션으로부터 스트림을 생성할 수 있습니다.
  • 배열로부터 생성: `Arrays.stream()` 메서드를 사용하여 배열로부터 스트림을 생성할 수 있습니다.
  • 직접 생성: `Stream.of()` 메서드를 사용하여 직접 스트림을 생성할 수 있습니다.
  • Stream Builder 사용: `Stream.builder()`를 사용하여 스트림을 생성하고 요소를 추가할 수 있습니다.

예를 들어, 다음과 같이 리스트로부터 스트림을 생성할 수 있습니다.


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

중간 연산 (Intermediate Operations)

중간 연산은 스트림을 변환하는 역할을 합니다. 대표적인 중간 연산은 다음과 같습니다.

  • filter(): 주어진 조건에 맞는 요소만 필터링합니다.
  • map(): 각 요소를 변환합니다.
  • flatMap(): 각 요소를 스트림으로 변환한 후, 모든 스트림을 하나의 스트림으로 연결합니다.
  • distinct(): 중복된 요소를 제거합니다.
  • sorted(): 요소를 정렬합니다.
  • peek(): 스트림의 각 요소에 대해 특정 작업을 수행하지만, 스트림 자체는 변경하지 않습니다. (디버깅에 유용합니다.)

예를 들어, 다음과 같이 이름이 “A”로 시작하는 요소만 필터링할 수 있습니다.


names.stream()
     .filter(name -> name.startsWith("A"))
     .forEach(System.out::println); // Alice 출력

또는, 각 이름의 길이를 구하는 매핑 연산을 수행할 수 있습니다.


names.stream()
     .map(String::length)
     .forEach(System.out::println); // 5 3 7 출력

최종 연산 (Terminal Operations)

최종 연산은 스트림의 결과를 반환하는 역할을 합니다. 대표적인 최종 연산은 다음과 같습니다.

  • forEach(): 각 요소에 대해 특정 작업을 수행합니다.
  • toArray(): 스트림의 요소를 배열로 변환합니다.
  • collect(): 스트림의 요소를 컬렉션으로 변환합니다. (List, Set, Map 등)
  • count(): 스트림의 요소 개수를 반환합니다.
  • reduce(): 스트림의 요소를 하나의 값으로 축소합니다.
  • min()/max(): 스트림에서 최소/최대값을 찾습니다.
  • anyMatch()/allMatch()/noneMatch(): 주어진 조건에 맞는 요소가 있는지 확인합니다.
  • findFirst()/findAny(): 스트림에서 첫 번째/임의의 요소를 찾습니다.

예를 들어, 스트림의 모든 요소를 리스트로 변환할 수 있습니다.


List<String> filteredNames = names.stream()
                                 .filter(name -> name.length() > 3)
                                 .collect(Collectors.toList());
System.out.println(filteredNames); // [Alice, Charlie] 출력

Stream API 실전 활용 예제

이제 Stream API를 사용하여 실제 문제를 해결하는 예제를 살펴보겠습니다. 쇼핑몰에서 상품 목록을 필터링하고 정렬하는 기능을 구현해 보겠습니다.

상품 필터링 및 정렬

다음과 같은 상품 클래스가 있다고 가정해 봅시다.


class Product {
    private String name;
    private int price;
    private String category;

    // 생성자, getter, setter 생략
}

상품 목록에서 특정 카테고리에 속하고, 특정 가격 이하인 상품만 필터링하고, 가격을 기준으로 정렬하는 기능을 Stream API를 사용하여 구현할 수 있습니다.


List<Product> products = getProducts(); // 상품 목록을 가져오는 메서드

List<Product> filteredAndSortedProducts = products.stream()
                                                .filter(product -> product.getCategory().equals("Electronics"))
                                                .filter(product -> product.getPrice() <= 1000)
                                                .sorted(Comparator.comparingInt(Product::getPrice))
                                                .collect(Collectors.toList());

위 코드는 "Electronics" 카테고리에 속하고, 가격이 1000 이하인 상품만 필터링한 후, 가격을 기준으로 오름차순으로 정렬합니다. 코드의 가독성이 매우 뛰어나다는 것을 확인할 수 있습니다.

그룹핑 및 통계 계산

Stream API를 사용하면 데이터를 그룹핑하고 통계를 계산하는 것도 매우 간단합니다. 예를 들어, 상품 목록을 카테고리별로 그룹핑하고, 각 카테고리별 상품의 평균 가격을 계산할 수 있습니다.


Map<String, Double> averagePriceByCategory = products.stream()
                                                  .collect(Collectors.groupingBy(Product::getCategory,
                                                                                 Collectors.averagingInt(Product::getPrice)));

`Collectors.groupingBy()` 메서드는 주어진 분류 함수를 기준으로 데이터를 그룹핑하고, `Collectors.averagingInt()` 메서드는 각 그룹의 평균 가격을 계산합니다. 이처럼 Stream API는 복잡한 데이터 처리 로직을 간결하게 표현할 수 있도록 돕습니다.

Java Stream API 사용 시 주의사항 및 팁

Stream API는 강력한 도구이지만, 사용할 때 주의해야 할 점들이 있습니다. 몇 가지 주의사항과 팁을 알려드릴게요.

Stream은 재사용이 불가능합니다.

Stream은 한 번 소비되면 다시 사용할 수 없습니다. 즉, 최종 연산을 수행한 스트림에 대해서는 다른 연산을 수행할 수 없습니다. 만약 스트림을 재사용해야 한다면, 데이터 소스로부터 새로운 스트림을 생성해야 합니다.

개인적으로는 이 부분이 처음 Stream API를 사용할 때 가장 헷갈렸던 부분입니다. Stream을 한 번 사용하고 다시 사용하려고 하면 에러가 발생하거든요.

병렬 스트림 사용 시 동기화 문제

병렬 스트림을 사용하면 멀티코어 환경에서 데이터 처리를 병렬로 수행하여 성능을 향상시킬 수 있지만, 공유 자원에 대한 접근 시 동기화 문제를 고려해야 합니다. 특히, 중간 연산이나 최종 연산에서 외부 변수를 변경하는 경우, race condition이 발생할 수 있으므로 주의해야 합니다.

성능 고려

Stream API는 코드의 가독성을 높여주지만, 항상 성능이 좋은 것은 아닙니다. 특히, 복잡한 중간 연산을 많이 사용하는 경우, 반복문 기반 코드보다 성능이 떨어질 수 있습니다. 따라서, 성능이 중요한 경우에는 Stream API와 전통적인 반복문 기반 코드를 비교하여 최적의 방법을 선택해야 합니다.

제 경험상, 간단한 필터링이나 매핑 연산은 Stream API가 더 빠르지만, 복잡한 연산은 반복문이 더 효율적인 경우가 있었습니다. 실제로 사용해보니 상황에 따라 성능이 달라지더라구요.

디버깅 팁

Stream API 코드를 디버깅하는 것은 다소 까다로울 수 있습니다. `peek()` 메서드를 활용하면 스트림의 각 요소에 대해 특정 작업을 수행하면서 중간 결과를 확인할 수 있습니다. 예를 들어, 다음과 같이 `peek()` 메서드를 사용하여 스트림의 중간 결과를 출력할 수 있습니다.


names.stream()
     .filter(name -> name.length() > 3)
     .peek(System.out::println) // 필터링된 요소 출력
     .map(String::toUpperCase)
     .forEach(System.out::println);

결론

지금까지 Java Stream API의 개념부터 실전 활용 예제, 주의사항까지 자세히 알아보았습니다. Stream API는 함수형 프로그래밍 스타일로 코드를 작성할 수 있도록 해주며, 코드의 가독성을 높이고 병렬 처리를 쉽게 구현할 수 있도록 돕습니다.

이번 글을 통해 Java Stream API에 대한 기본적인 이해를 얻으셨기를 바랍니다. 이제 여러분도 Stream API를 활용하여 코드를 더욱 간결하고 효율적으로 만들어 보세요! 다음 단계로는 Stream API의 다양한 연산들을 더 깊이 있게 학습하고, 실제 프로젝트에 적용해 보는 것을 추천합니다.

다음에는 더욱 흥미로운 IT 주제로 찾아뵙겠습니다. 궁금한 점이나 의견이 있으시면 언제든지 댓글로 남겨주세요!


답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다