새소식

Java

[Java] Stream API 에 관하여

  • -
728x90

Stream 과 for 문의 성능 차이에 대한 질문을 받았습니다. 평소에 많이 사용하는 Stream API 에 대해 깊이 있게 정리할 필요성을 느껴 이를 글로 작성해보려고 합니다.

 

✅ Stream API 란

자바 8부터 도입된 Stream API 는 데이터를 선언형으로 처리할 수 있게 해주는 강력한 도구입니다. 반복문 위주의 전통적인 방식에서 벗어나 간결하고 가독성 좋은 코드를 작성할 수 있으며, 병렬 처리와 최적화에도 용이한 장점을 지니고 있습니다.

 

Stream 은 컬렉션(List, Set 등) 의 요소들을 함수형 스타일로 처리할 수 있도록 해주는 기능입니다. 여기서 말하는 Stream 은 I/O Stream 과는 다르며, 데이터의 흐름을 추상화한 것이라고 이해하면 됩니다.

 

왜 Stream 을 사용하는가 ? 

그렇다면 Stream 을 사용해서 얻을 수 있는 이점에 대해서 생각해 보아야 합니다. 기존 방식과 비교하면 다음과 같은 이점이 있습니다.

 

가독성

  • for 문보다 훨씬 간결한 구조

선언형 스타일

  • '무엇을 할 것인가' 에 집중합니다. (how -> what)

병렬 처리

  • parallelStream() 으로 손쉽게 병렬 연산 가능

메서드 체이닝

  • 연산을 파이프라인처럼 연결할 수 있습니다.

🟢 Stream 기본 구조

Stream API 는 크게 3단계로 구성됩니다.

 

  1. 생성 : 스트림 생성
  2. 중간 연산 : filter, map, sorted 등
  3. 최종 연산 : collect, foreach, reduce 등
List<String> names = List.of("홍길동", "이순신", "세종대왕");

List<String> result = names.stream()                   // 스트림 생성
                           .filter(name -> name.length() >= 3) // 중간 연산
                           .map(String::toUpperCase)   // 중간 연산
                           .collect(Collectors.toList()); // 최종 연산

 

주요 연산은 아래와 같습니다!

구분 메서드 설명
중간연산 filter 조건에 맞는 요소만 필터링
map 요소를 변환
sorted 정렬
distinct 중복 제거
최종 연산 collect 리스트/맵 등으로 수집
forEach 각 요소 처리(부수 효과)
count 요소 개수 반환
reduce 하나의 결과로 축소

 

🟢 Stream 의 메모리 구조

List<Integer> numbers = List.of(1, 2, 3, 4, 5);

numbers.stream()
       .filter(n -> n % 2 == 0)
       .map(n -> n * 2)
       .forEach(System.out::println);

 

위의 간단한 코드에 대해서 예시를 들어보겠습니다.

이 코드는 전체 컬렉션을 스트림 객체로 감싼 후, 각 요소를 하나씩 꺼내면서 파이프라인을 통과시켜 처리합니다. map(), filter() 는 중간 연산으로, 이들은 lazy 하게 실행됩니다. 즉, 중간 연산은 실행되지 않으며 최종 연산이 호출될 때만 동작합니다.

 

즉, 데이터를 스트림 전체로 메모리에 올리는 것이 아니라, 스트림을 통해 하나씩 처리하면서 흘려보내는 구조입니다.

 

 

지금까지는 기본적인 사용법에 대해 설명했습니다! 이제는 실제로 많은 어려움을 겪었던 요소들에 대해서 정리해보고자 합니다.

 

🟢  Map 과 FlatMap 에 대해서 이해하기

Java의 Stream을 사용하다 보면 map()flatMap()이 자주 등장합니다. 이름만 보면 비슷해 보이지만, 실제 동작은 꽤 다릅니다. 특히 컬렉션이나 중첩 구조를 다룰 때 이 둘의 차이를 명확히 알아야 코드를 효율적으로 구성할 수 있습니다.

 

Map()

  • 각 요소를 1:1로 변환합니다.
  • 요소를 다른 값으로 매핑합니다.

 

FlatMap()

  • 각 요소를 다수의 값(스트림)으로 변환하고, 이를 평탄화(flattern)합니다.

여기서 말하는 평탄화는 글로만 보기엔 이해가 어려울 수 있습니다. 이를 코드로 작성해보며 이해를 더 해봅시다.

 

List<String> words = List.of("apple", "banana", "cherry");

List<Integer> lengths = words.stream()
    .map(String::length) // 각 문자열을 길이로 변환
    .collect(Collectors.toList());

// 결과: [5, 6, 6]

 

Map 은 위의 코드처럼 각 요소를 하나의 값으로 변환하는데 사용됩니다. 하지만 컬렉션 내부의 컬렉션에 대한 공통 처리를 해야 한다면 어떻게 할 수 있을까요 ?

 

List<List<String>> nested = List.of(
    List.of("a", "b"),
    List.of("c", "d"),
    List.of("e")
);

List<String> flattened = nested.stream()
    .flatMap(Collection::stream) // 내부 리스트를 평탄화
    .collect(Collectors.toList());

// 결과: [a, b, c, d, e]

 

실무나 다양한 코드에선 List 내부에 각기 다른 수의 컬렉션이 존재할 수 있습니다. 내부 리스트를 평탄화한다는 개념은 이때 필요합니다.

위으 코드에서 내부 리스트를 모두 하나의 String 으로 List 화 할 수 있습니다. 

 

다른 예시를 들어봅시다. 사용자들의 주문 목록을 조회하는 코드가 필요하다고 가정합니다.

 

class User {
    String name;
    List<Order> orders;
}

 

List<User> users = getUsers();

List<List<Order>> result = users.stream()
    .map(User::getOrders) // User → List<Order>
    .collect(Collectors.toList());

// 결과: List<List<Order>>

 

Map 을 사용한다면 이런 식으로 표현할 수 있습니다. 하지만 여기서 FlatMap 을 사용한다면

 

List<Order> allOrders = users.stream()
    .flatMap(user -> user.getOrders().stream()) // User → Stream<Order>
    .collect(Collectors.toList());

// 결과: List<Order> (전체 사용자 주문 리스트 평탄화)

 

이런 식으로 평탄화가 가능합니다.

 

즉, map → flatMap이 필요한 대표 상황은 아래와 같습니다.

  • 중첩 리스트, 중첩 객체 필드를 하나의 리스트로 펼칠 때
  • 문자열을 단어 단위로 쪼갤 때
  • 예: "a b c", "d e"["a", "b", "c", "d", "e"]
  • 비동기 처리에서 CompletableFuture<Stream<T>>CompletableFuture<T> 등으로 변환할 때 (flatMap 개념의 비슷한 패턴)

🟢  Stream 의 성능

지금까지 stream 에 대해 간략하게 알아봤습니다. 그러면 for 문 대신 무조건 적으로 stream 을 사용하면 좋을까? 라는 의문이 듭니다. 이에 대해 자세히 알아보겠습니다.

 

우선 전제에 있어서 성능 비교는 "상황"에 따라 크게 달라집니다.

데이터가 작을 땐 차이가 거의 없습니다. 하지만 단순 반복이면 for이 빠르죠. 내부 로직이 무거울 수록 stream 이 오버헤드가 무시됩니다. 병렬 처리에 있어선 stream 은 parallelStream()으로 병렬 처리에 용이합니다.

 

그렇다면 데이터 크기가 클 땐 어떠한 차이가 있을까요 ? 예시로 10,000,000개의 숫자 합산에 대한 간단한 예시를 들어보겠습니다. 

 

final int SIZE = 10_000_000;

// for loop
long startFor = System.currentTimeMillis();
long sumFor = 0;
for (int i = 0; i < SIZE; i++) {
    sumFor += i;
}
long endFor = System.currentTimeMillis();
System.out.println("for문 합계: " + sumFor);
System.out.println("for문 처리 시간: " + (endFor - startFor) + "ms");

// stream
long startStream = System.currentTimeMillis();
long sumStream = IntStream.range(0, SIZE)
        .asLongStream()
        .sum();
long endStream = System.currentTimeMillis();
System.out.println("stream 합계: " + sumStream);
System.out.println("stream 처리 시간: " + (endStream - startStream) + "ms");

// parallelStream
long startParallel = System.currentTimeMillis();
long sumParallel = IntStream.range(0, SIZE)
        .parallel()
        .asLongStream()
        .sum();
long endParallel = System.currentTimeMillis();
System.out.println("parallelStream 합계: " + sumParallel);
System.out.println("parallelStream 처리 시간: " + (endParallel - startParallel) + "ms");

 

간단히 천만건의 데이터 합산을 3가지 방식을 통해서 처리해 보았습니다. 결과는 아래와 같습니다.

 

for문 합계: 49999995000000
for문 처리 시간: 7ms
stream 합계: 49999995000000
stream 처리 시간: 13ms
parallelStream 합계: 49999995000000
parallelStream 처리 시간: 14ms

 

for 문이 stream 보다 빠르며 parallelStream 이 제일 느린 것을 확인할 수 있습니다. 

 

♻️ for 문이 Stream 보다 왜 빠를까 ? 

for 문이 stream 보다 빠른 이유는 불필요한 객체 생성 없이, 직접 메모리를 제어합니다.

long sum = 0;
for (int i = 0; i < SIZE; i++) {
    sum += i;
}

 

변수는 로컬에 저장되고, 루프는 CPU 친화적이며 JIT 최적화에도 유리합니다. 즉, 최소한의 명령어, 최소한의 오버헤드로 실행됩니다.

 

IntStream.range(0, SIZE).sum();

 

Stream 이 느려지는 이유는 IntStream 을 생성하며, 내부적으로 iterator-like 구조를 생성합니다.

range(), map(), sum() 등이 함수형 람다식 기반으로 동작하면서

람다 객체 생성 > 메서드 호출 스택 증가 > 메모리 접근 계층 증가 > 결국 GC 압막 및 오버헤드가 증가하게 되는 상황이 발생합니다.

결과적으로 for 문보다 느려질 수 밖에 없습니다.

 

이렇게 직접 테스트해보며 for 문과 stream 의 성능 차이를 비교해 보았습니다. 평소에 쓰는 기술에 대한 깊이 있는 이해가 필요하다는 것을 한번 더 깨달을 수 있는 좋은 기회였던 것 같습니다!

728x90
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.