|

자바 Stream은 for문보다 항상 좋을까: 읽기 쉬운 코드와 느려지는 코드의 경계

자바 Stream은 for문보다 항상 좋을까: 읽기 쉬운 코드와 느려지는 코드의 경계
Stream은 데이터 흐름을 선언적으로 표현할 때 강하지만, 모든 반복문을 대체하는 정답은 아닙니다.

자바 Stream은 컬렉션 처리 코드를 짧고 선언적으로 만들 수 있습니다. 하지만 Stream을 썼다는 이유만으로 코드가 더 읽기 쉬워지거나 더 빨라지는 것은 아닙니다.

핵심은 반복의 목적이 데이터 흐름인지, 단계별 제어인지 먼저 나누는 것입니다. 이 글은 Java Stream 공식 문서를 기준으로 Stream과 for문을 선택하는 실무 기준을 정리합니다.

자바 Stream과 for문 선택 기준 요약 카드
Stream과 for문은 표현 방식이 다르므로 코드의 목적에 맞춰 고르는 편이 좋습니다.

자바 Stream을 한 문장으로 정리하면

Stream은 컬렉션을 직접 하나씩 제어하기보다, 데이터가 어떤 흐름으로 필터링되고 변환되고 모이는지 표현하는 API입니다. 그래서 조건과 변환 흐름이 선명한 코드에서 장점이 큽니다.

List<String> activeNames = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .toList();

위 코드는 active user만 고르고 이름 리스트로 바꾼다는 목적이 잘 보입니다. 반복 변수, 임시 리스트, add 호출이 드러나지 않아도 의미가 유지됩니다.

for문이 더 나은 경우도 분명하다

반복 중에 여러 상태를 바꾸거나, 조건마다 다른 처리가 필요하거나, 중간에 break/continue가 중요한 경우에는 for문이 더 읽기 쉽습니다.

List<String> names = new ArrayList<>();
for (User user : users) {
    if (!user.isActive()) {
        continue;
    }
    if (user.isBlocked()) {
        audit(user);
        continue;
    }
    names.add(user.getName());
}

이 코드는 Stream으로도 바꿀 수 있습니다. 하지만 억지로 바꾸면 `peek`, 외부 상태 변경, 복잡한 람다가 섞여 오히려 읽기 어려워질 수 있습니다.

중간 연산과 최종 연산을 나눠 이해하자

Stream에는 `filter`, `map`, `sorted` 같은 중간 연산과 `toList`, `count`, `forEach` 같은 최종 연산이 있습니다. 중간 연산은 흐름을 설명하고, 최종 연산은 결과를 실제로 만듭니다.

long count = orders.stream()
    .filter(order -> order.totalPrice() >= 100_000)
    .count();

이 구조를 이해하면 Stream 코드를 읽을 때 ‘무엇을 고르고, 무엇으로 바꾸고, 어떤 결과를 만드는지’ 순서대로 볼 수 있습니다.

부작용이 많으면 Stream의 장점이 줄어든다

Stream은 가능하면 stateless하고 non-interfering한 연산으로 다루는 편이 좋습니다. 외부 리스트를 바꾸거나, 람다 안에서 상태를 계속 누적하면 Stream의 흐름이 불투명해집니다.

// 피하는 편이 좋다
List<String> names = new ArrayList<>();
users.stream()
    .filter(User::isActive)
    .forEach(user -> names.add(user.getName()));

// 더 자연스럽다
List<String> names = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .toList();

Stream을 쓴다면 데이터를 흘려보내 결과를 만드는 방식으로 끝까지 유지하는 편이 좋습니다. 중간에 외부 상태를 계속 건드리면 for문이 더 솔직합니다.

성능은 단정하지 말고 병목을 확인한다

Stream은 일반 for문보다 항상 빠르지도, 항상 느리지도 않습니다. 작은 컬렉션에서는 차이가 의미 없을 수 있고, 복잡한 boxing/unboxing이나 불필요한 객체 생성이 있으면 손해가 날 수 있습니다.

성능이 중요한 코드는 먼저 읽기 좋은 형태로 작성한 뒤 실제 입력 크기와 병목 지점에서 측정해야 합니다. 병목이 아니라면 팀이 더 빨리 이해할 수 있는 코드가 보통 더 좋은 선택입니다.

실무 체크리스트

  1. 필터링, 변환, 수집이 한 흐름이면 Stream을 검토한다
  2. break, continue, 복잡한 예외 처리가 중요하면 for문을 검토한다
  3. 람다 안에서 외부 상태를 바꾸고 있다면 구조를 다시 본다
  4. 성능 주장은 측정 없이 단정하지 않는다
  5. 팀 코드 리뷰에서 읽기 쉬운 쪽을 선택한다

정리

Stream를 이해할 때는 문법을 짧게 쓰는 것보다, 코드가 표현해야 하는 의도를 먼저 보는 편이 좋습니다. 오늘 글의 기준은 ‘읽는 사람이 실수를 줄일 수 있는가’입니다.

함께 보면 좋은 내부 글은 자바 람다에서 effectively final은 왜 필요할까: 지역 변수를 마음대로 바꿀 수 없는 이유, 자바 Optional은 null을 완전히 없애줄까: 써야 할 곳과 피해야 할 곳, 자바 equals hashCode 규약: 왜 같이 구현해야 컬렉션 버그가 줄어들까입니다. 외부 기준은 Java SE Docs – Stream, Java SE Docs – Package java.util.stream, Java Tutorials – Aggregate Operations를 확인했습니다.

함께보면 좋은 글