[UMC] for VS stream

Backend·2025-11-26·by 1000hyehyang

Java에서 컬렉션을 돌릴 때 제일 먼저 떠오르는 건 아마 대부분 for 문일 거다. 본인도 가장 먼저 떠오르는 건 for문이다! 그런데 Java 8 이후에는 같은 일을 Stream API로도 할 수 있다.

이 글에서는 우선 for와 stream이 각각 어떤 식으로 작동하는지 간단히 짚고, 같은 문제를 두 방식으로 구현해 본 다음, 공식 문서와 여러 글에서 이야기하는 차이점을 정리해 보려고 한다. 마지막에는 “실제 서비스 코드에서는 언제 무엇을 고를까?”라는 질문까지 이어서 살펴본다.

 

for와 stream은 어떻게 다를까?

전통적인 for 문은 말 그대로 “반복문”이다. 조건을 직접 쓰고, 인덱스를 증가시키고, 언제 멈출지도 직접 적는다. Java 언어 스펙에서도 for는 그냥 제어문의 한 종류일 뿐이고, 컬렉션이든, 배열이든, 숫자 범위든 뭐든 우리가 원하는 만큼 반복하게 만들 수 있다.

int sum = 0;
for (int i = 0; i < numbers.size(); i++) {
    sum += numbers.get(i);
}

향상된 for(for-each)는 인덱스를 직접 관리할 필요는 없게 해줬지만, 기본적인 사고방식은 같다. “이 리스트의 원소들을 하나씩 꺼내면서 이런 일을 해라.”

int sum = 0;
for (int n : numbers) {
    sum += n;
}

반면 Stream API는 애초에 “데이터 처리”를 위한 별도의 추상화로 설계됐다.

저장소(컬렉션, 배열 등)에 담긴 데이터를 대상으로 filter, map, reduce 같은 연산을 연결해서, 선언적으로 처리할 수 있게 해 주는 API.

중요한 건 선언적이라는 표현이다. for 문이 “어떻게 반복할지”를 한 줄씩 적는 명령형 스타일이라면, stream은 “무엇을 하고 싶은지”를 위에서 아래로 나열하는 느낌에 가깝다.

예를 들어 양수만 골라서 합을 구하는 코드를 stream으로 쓰면 이렇게 된다.

int sum = numbers.stream()
                 .filter(n -> n > 0)
                 .mapToInt(Integer::intValue)
                 .sum();

여기서 stream()은 컬렉션을 스트림 파이프라인의 입력으로 바꾸고, filter, map, sum은 중간·최종 연산으로 이어진다.

 

같은 문제를 for와 stream으로 풀어보기

예시를 하나 정해보자. List<Item>에서 색깔이 RED인 아이템들의 weight 합을 구해야 한다고 해 보자.

class Item {
    private final Color color;
    private final int weight;
}

for 문으로 구현하면

가장 먼저 떠오르는 코드는 아마 이런 형태일 것이다.

int sumWeightOfRedItems(List<Item> items) {
    int sum = 0;
    for (Item item : items) {
        if (item.getColor() == Color.RED) {
            sum += item.getWeight();
        }
    }
    return sum;
}

로직은 매우 직관적이다.

  1. 합계를 저장할 변수를 0으로 초기화하고,
  2. 리스트를 앞에서부터 끝까지 돌면서,
  3. 빨간색이면 weight를 더한다.

이때 우리의 관점은 전적으로 “어떻게 돌릴지”에 맞춰져 있다.

어떤 범위를 돌지, 언제 멈출지, 어떤 조건에서 더하고 건너뛸지,

이런 제어 흐름을 모두 우리가 직접 쥐고 있는 구조다.

 

stream으로 구현하면

똑같은 일을 Stream API로 옮겨 보면 이렇게 바뀐다.

int sumWeightOfRedItemsStream(List<Item> items) {
    return items.stream()
            .filter(item -> item.getColor() == Color.RED)
            .mapToInt(Item::getWeight)
            .sum();
}

이쪽은 “데이터 파이프라인”처럼 읽힌다.

  • items.stream() : 리스트를 스트림으로 바꾼다.
  • filter(…) : 조건에 맞는 요소만 남긴다.
  • mapToInt(…) : Itemint로 투영한다.
  • sum() : 최종적으로 합계를 구한다.

for 문에서는 “반복문 안에서 이러이러한 일을 해라”라고 명령했다면, stream에서는 “빨간색만 남기고 → 무게만 꺼내서 → 합계를 구해라”라고 변환과 집계 과정을 순서대로 선언하고 있다.

 

무엇이 더 빠른가?

그렇다면 성능은 뭐가 더 빠를까?

  • 순차 스트림(sequential stream) 은 같은 일을 하는 전통적인 for-loop와 비교했을 때, 아주 간단한 연산 기준으로는 보통 약간 느리거나 비슷한 수준이다.
  • 이유는 간단하다. 스트림 파이프라인을 구성하는 과정, 람다/메서드 레퍼런스 호출, 내부 반복자(spliterator) 관리 같은 오버헤드가 있기 때문이다.
  • 데이터가 커지고, 각 원소에 대해 하는 일이 복잡해질수록 이 오버헤드는 전체 실행 시간에서 차지하는 비율이 줄어든다. 이때는 체감상 둘이 거의 비슷하게 느껴지는 경우도 많다.

그리고 여기서 또 자주 등장하는 키워드가 parallelStream()이다. 스트림은 설계 차원에서 “순차/병렬 모드” 개념을 가지고 있어서, 같은 파이프라인을 병렬 실행으로 돌릴 수 있다.

int sum = items.parallelStream()
               .filter(item -> item.getColor() == Color.RED)
               .mapToInt(Item::getWeight)
               .sum();

이론적으로는 “CPU 코어를 더 활용해서 성능을 끌어올릴 수 있다”가 맞다. 다만 실제로는 다음과 같은 변수들이 끼어든다.

  • 데이터 크기가 충분히 큰지
  • 각 원소를 처리하는 연산이 CPU 바운드인지, 아니면 IO 바운드인지
  • 공유 상태를 건드리지 않는 순수한 연산인지
  • 스레드 풀 경합, 컨텍스트 스위칭 비용이 얼마나 드는지

이 조건들이 잘 맞아 떨어지는 구간에서는 병렬 스트림이 확실히 이득이 될 수 있지만, 대부분의 웹 애플리케이션 비즈니스 로직에서는 “성능 때문에 parallelStream을 꼭 써야 하는 상황”이 생각보다 자주 나오지는 않는다. 오히려 잘못 섞어 쓰면 디버깅과 성능 튜닝이 더 어려워지기도 한다.

정리하자면, 일반적인 서비스 코드에서

  • 미세한 성능 차이만 놓고 보면 for 문이 유리한 경우가 많다.
  • 그러나 대다수의 비즈니스 로직에서는 그 차이가 눈에 띄지 않을 정도인 경우가 더 많고,
  • 정말 성능이 중요해지는 구간은 어차피 프로파일링/벤치마크를 통한 별도 최적화 대상이 된다.

그래서 실제 선택은 “성능”보다는 가독성과 유지보수성 쪽으로 무게가 기우는 경우가 많다.

 

for와 stream, 사고방식과 코드 구조의 차이

성능을 잠깐 옆으로 밀어두고, 이 둘이 코드를 어떻게 쓰게 만드는지를 보면 차이가 훨씬 분명해진다.

for 문은 제어 흐름을 직접 쥐고 있는 구조다. 반복 범위, 탈출 조건, 상태 변경, 예외 처리, 로깅까지 전부 루프 안에서 마음대로 할 수 있다. 그래서 한 번 쓰기 시작하면 이것저것 다 때려 넣게 된다.

for (Item item : items) {
    if (item == null) {
        log.warn("null item");
        continue;
    }

    if (!item.isActive()) {
        inactiveCount++;
        continue;
    }

    if (item.getColor() == Color.RED) {
        redWeights += item.getWeight();
    } else if (item.getColor() == Color.BLUE) {
        blueWeights += item.getWeight();
    }

    if (redWeights > limit) {
        break;
    }
}

상태를 여러 개 관리하고, 분기와 탈출 조건이 잔뜩 붙어 있다. 이 정도까지는 그래도 읽을 만하지만, 여기에 중첩 루프나 예외 처리, 추가 컬렉션 업데이트까지 섞이기 시작하면 금방 난이도가 올라간다.

반대로 stream은 “데이터를 한 번 흘려보내면서 필요에 따라 변형하고 걸러낸 뒤 결과를 만든다”라는 사고에 가깝다.

List<String> names = users.stream()
        .filter(User::isActive)
        .filter(u -> u.getAge() >= 20)
        .sorted(Comparator.comparing(User::getCreatedAt).reversed())
        .map(User::getName)
        .toList();

“활성 사용자 중 20살 이상, 생성일 역순으로 정렬, 이름만 추출”이라는 요구사항이 코드에 거의 그대로 드러난다. 중간에 filter/map 연산들을 적당히 메서드로 쪼개주면 더 읽기 쉬워진다.

이런 스타일은 특히 다음과 같은 상황에서 힘을 발휘한다.

  • 조건이 여러 개 겹쳐 있는 필터링 로직
  • 엔티티 → DTO 변환, DTO → 다른 DTO 변환처럼 변환 단계가 많은 경우
  • 데이터 집계/통계 로직 (grouping, partitioning, sum, average 등)

반대로, 반복 안에서 동시에 여러 상태를 업데이트하고, 특정 조건에서 일찍 탈출해야 하고, 예외 처리와 로깅을 중간중간 섞어야 하는 로직이라면 stream 쪽이 억지스럽게 느껴질 수 있다. 스트림 안에서 외부 상태를 막 변경하기 시작하면, “함수형 스타일”이라는 장점도 사라지고, 나중에 병렬 스트림을 고려할 때 문제가 되기도 한다.

 

언제 for, 언제 stream이 좋은가?

실제 서비스 코드에서, 이 둘을 어떤 기준으로 나눠 써야 할까?

일단, 로직의 성격이 ‘데이터 변환/집계 파이프라인’에 가깝다면 stream

예를 들어,

  • “특정 조건의 엔티티만 골라서 DTO 리스트로 만들고 싶다”
  • “주문 목록을 상태별로 그룹핑해서 Map으로 돌려주고 싶다”
  • “여러 단계의 가공을 거쳐 통계 값을 계산하고 싶다”

이런 경우에는 stream 파이프라인으로 한 번에 흐름을 잡는 쪽이 의도를 더 잘 드러내는 경우가 많다. 공식 문서가 스트림을 설계한 목적 자체가 이런 데이터 중심 작업을 더 선언적으로 표현하기 위한 것이기도 하고.

반대로, 제어 흐름이 복잡하고, 루프 안에서 여러 가지 부수효과를 동시에 처리해야 한다면 그냥 for가 더 낫다.

  • 루프 도중에 특정 조건에서 탈출해야 하고,
  • 동시에 몇 개의 다른 카운터/컬렉션을 업데이트해야 하며,
  • 중간중간 로그를 찍고, 예외 상황도 케이스별로 처리해야 한다면,

이걸 stream에 억지로 끼워 맞추는 건 오히려 가독성을 해칠 수 있다. 그럴 바엔 for 안에서 로직을 잘게 메서드로 나누는 쪽이, 팀원 입장에서 읽고 수정하기 편하다.

성능은 어디쯤 들어오느냐 하면, 핵심 구간이 아닌 이상 “for vs stream의 미세한 실행 시간 차이”만 보고 코드를 선택하는 경우는 생각보다 드물다. 실제로는, 먼저 읽기 좋은 쪽으로 구현해두고, 정말 성능이 문제가 되는 구간이 드러나면 그때 가서 둘을 바꿔가며 비교해 보는 게 현실적인 접근이다.

정리하면, 머릿속에 이런 기준을 하나 두고 있어도 괜찮다.

  • “이 코드는 데이터를 걸러서 → 변환하고 → 집계하는 흐름인가?” 그렇다면 stream으로 한 번 생각해 본다.
  • “이 코드는 반복 안에서 여러 상태를 동시에 바꾸고, 조건에 따라 탈출해야 하는 제어 흐름 위주인가?” 그렇다면 for가 더 솔직하고, 나중에 읽는 사람에게도 덜 고통스럽다.

그 위에 아주 얇게, 성능과 팀의 숙련도라는 레이어를 하나 더 얹는 정도가 현실적인 균형점인 것 같다. 팀 전체가 Stream API에 익숙하고, 데이터 변환 로직이 많은 서비스라면 stream이 코드베이스 전체 톤을 맞추는 데 도움이 될 수 있고, 반대로 스트림 문법이 아직 낯선 팀에서는 핵심 로직은 for 기반으로 두고, 서서히 stream을 도입하는 편이 더 자연스럽다.

결국 둘 다 Java가 제공하는 도구일 뿐이고, 중요한 건 “이 문제를 이 도구로 풀었을 때 코드가 더 명확해지는가?”다. 그 질문에 대한 답을 기준으로, for와 stream 사이에서 적당히 균형을 잡으면 된다.

댓글 로딩 중...