Java 개발을 하다 보면 한 번쯤 이런 이야기를 듣는다.
"Stream 쓰면 느려요. 그냥 for문 쓰세요."
반대로 이런 이야기도 있다.
"요즘 누가 for문 써요? Stream이 훨씬 깔끔하죠."
둘 중 어느 말이 맞을까?
결론부터 말하면 둘 다 맞을 수도 있고, 둘 다 틀릴 수도 있다.
나는 Java로 오랫동안 개발하면서 for문도 정말 많이 사용했고, Java 8 이후에는 Stream 역시 자주 사용하고 있다.
지금은 어느 한쪽을 고집하지 않는다.
내가 중요하게 보는 것은
"어떤 코드가 지금 이 로직을 가장 명확하게 표현하는가?"
이다.
물론 성능이 정말 중요한 코드라면 이야기가 달라진다.
이번 글에서는 Stream과 for문의 단순 문법 비교가 아니라, 실무에서 어떤 기준으로 둘 중 하나를 선택하는지 정리해보려고 한다.
먼저 결론부터
내 기준은 대략 이렇다.
Stream을 주로 사용하는 경우
- Collection을 필터링할 때
- 데이터를 다른 형태로 변환할 때
- 특정 값을 찾을 때
- 합계나 평균 등을 계산할 때
- grouping이나 mapping처럼 데이터 가공 목적이 명확할 때
- 코드가 한눈에 읽힐 때
for문을 사용하는 경우
- 반복문 안에서 여러 상태를 변경해야 할 때
- 복잡한 분기문이 들어갈 때
- 중간에 continue나 break가 많이 필요할 때
- 디버깅이 중요한 복잡한 비즈니스 로직일 때
- 극단적으로 성능이 중요한 반복 처리일 때
- Stream으로 작성했더니 오히려 읽기 어려워질 때
결국 내 기준은
Stream = 데이터의 흐름
for = 처리 과정
에 가깝다.
1. Stream이 무조건 for문보다 느린가?
가장 많이 나오는 질문이다.
일반적인 순차 처리에서 단순 for문은 매우 직접적이다.
List<Order> paidOrders = new ArrayList<>();
for (Order order : orders) {
if (order.isPaid()) {
paidOrders.add(order);
}
}
반면 Stream으로 작성하면 다음과 같다.
List<Order> paidOrders = orders.stream()
.filter(Order::isPaid)
.toList();
결과는 같다.
하지만 내부적으로 실행되는 방식은 다르다.
Stream은
Collection
→ Stream 생성
→ filter
→ terminal operation
이라는 파이프라인을 구성한다.
람다 호출이나 Stream 파이프라인 처리 등의 비용이 있기 때문에 아주 단순한 반복 작업에서 for문보다 Stream이 무조건 빠를 것이라고 기대하면 안 된다.
그렇다고
Stream은 느리니까 쓰면 안 된다.
라고 결론 내리는 것도 지나치다.
Oracle의 Stream API 자체가 중간 연산을 lazy하게 처리하도록 설계돼 있다. filter, map 같은 intermediate operation은 실제 데이터를 바로 처리하지 않고 terminal operation이 호출됐을 때 실행된다.
예를 들어
Order order = orders.stream()
.filter(Order::isPaid)
.findFirst()
.orElse(null);
에서는 조건에 맞는 첫 번째 데이터를 찾으면 이후 모든 데이터를 반드시 끝까지 처리해야 하는 것은 아니다.
즉 Stream도 단순히
전체 데이터 → 전체 데이터 → 전체 데이터
식으로 매 단계마다 새로운 Collection을 만드는 구조는 아니다.
2. 그런데 웹 서비스에서 그 성능 차이가 정말 중요할까?
이게 내가 더 중요하게 보는 부분이다.
예를 들어 API 하나가 있다고 하자.
Controller
↓
Service
↓
Database
↓
외부 API
↓
Redis
전체 응답 시간이 200ms인데,
Collection 100개를 처리하는 for문과 Stream의 차이가 극히 작은 수준이라면?
그 차이를 줄이기 위해 코드 가독성을 포기하는 것이 과연 의미가 있을까?
대부분의 일반적인 웹 서비스에서는 Stream과 for문의 차이보다
- 잘못된 SQL
- N+1 Query
- Index 부재
- 외부 API 응답
- 네트워크
- 불필요한 DB 호출
- 대량 객체 생성
- 잘못된 캐싱
같은 부분이 훨씬 큰 성능 문제를 만든다.
그래서 나는 일반적인 비즈니스 로직에서는
성능 차이가 존재할 수 있다는 것과, 그 차이가 서비스에서 의미 있다는 것은 다른 문제다.
라고 생각한다.
성능이 의심된다면 감으로 결정할 것이 아니라 실제 측정을 하는 것이 맞다.
3. 단순한 데이터 가공은 Stream이 확실히 읽기 좋다
예를 들어 주문 목록에서 결제가 완료된 주문만 가져온다고 해보자.
for문을 사용하면
List<Order> paidOrders = new ArrayList<>();
for (Order order : orders) {
if (order.isPaid()) {
paidOrders.add(order);
}
}
Stream은
List<Order> paidOrders = orders.stream()
.filter(Order::isPaid)
.toList();
이다.
나는 이런 경우라면 거의 Stream을 선택한다.
코드를 읽으면 그대로 의미가 전달되기 때문이다.
orders에서
paid인 것만
List로 가져온다.
Stream의 장점은 단순히 코드가 짧다는 것이 아니다.
무엇을 하려는 코드인지 표현하기 쉽다는 것이다.
4. map 역시 Stream이 잘 어울린다
Entity 목록을 DTO 목록으로 변환한다고 해보자.
for문이라면
List<OrderDTO> result = new ArrayList<>();
for (Order order : orders) {
result.add(OrderDTO.from(order));
}
Stream이라면
List<OrderDTO> result = orders.stream()
.map(OrderDTO::from)
.toList();
나는 후자가 더 명확하다고 생각한다.
특히
orders.stream()
.filter(Order::isPaid)
.map(OrderDTO::from)
.toList();
정도라면 굉장히 자연스럽다.
주문 중
결제된 주문만 골라
DTO로 변환한다.
코드가 처리 과정 자체를 설명하고 있다.
이런 것이 Stream의 가장 큰 장점이라고 생각한다.
5. 하지만 Stream이 길어지기 시작하면 생각이 달라진다
문제는 사람들이 Stream을 쓰기 시작하면서 모든 것을 Stream으로 해결하려고 할 때 생긴다.
예를 들어 이런 코드가 있다고 해보자.
orders.stream()
.filter(order -> order.getStatus() != null)
.filter(order -> {
if (order.isCanceled()) {
log.info("취소 주문 : {}", order.getId());
return false;
}
if (order.getPayment() == null) {
return false;
}
return order.getPayment().isSuccess();
})
.map(order -> {
OrderDTO dto = new OrderDTO();
dto.setOrderId(order.getId());
dto.setUserName(order.getUser().getName());
if (order.hasCoupon()) {
dto.setDiscount(order.getCoupon().getDiscount());
}
return dto;
})
.toList();
Stream이긴 하다.
하지만 과연 읽기 좋은가?
나는 이런 코드를 만나면 Stream 사용 자체를 다시 생각한다.
Stream의 장점은 데이터 흐름을 선언적으로 보여주는 것인데 람다 안에 비즈니스 로직이 잔뜩 들어가기 시작하면 그 장점이 사라진다.
그럴 바에는 차라리
for (Order order : orders) {
if (order.getStatus() == null) {
continue;
}
if (order.isCanceled()) {
log.info("취소 주문 : {}", order.getId());
continue;
}
if (!isValidPayment(order)) {
continue;
}
OrderDTO dto = createOrderDTO(order);
result.add(dto);
}
쪽이 훨씬 읽기 쉬울 수도 있다.
6. Stream 코드가 길어지면 메서드로 빼는 것도 방법이다
그렇다고 무조건 for문으로 바꿀 필요는 없다.
조건을 의미 있는 메서드로 분리하면 된다.
List<OrderDTO> result = orders.stream()
.filter(this::isValidOrder)
.filter(this::isPaymentSuccess)
.map(this::createOrderDTO)
.toList();
이 정도라면 다시 읽기가 쉬워진다.
유효한 주문을 찾고
결제 성공 여부를 확인한 뒤
DTO로 변환한다.
여기서 중요한 것은 Stream이냐 for문이냐가 아니다.
비즈니스 로직의 의미가 코드에서 보이느냐다.
7. Stream 내부에서 외부 상태를 바꾸는 코드는 조심한다
내가 Stream에서 별로 좋아하지 않는 형태 중 하나다.
List<OrderDTO> result = new ArrayList<>();
orders.stream()
.filter(Order::isPaid)
.forEach(order -> {
result.add(OrderDTO.from(order));
});
동작은 한다.
하지만 굳이 이렇게 할 이유가 없다.
List<OrderDTO> result = orders.stream()
.filter(Order::isPaid)
.map(OrderDTO::from)
.toList();
이면 된다.
Stream은 가능하면
입력
→ 가공
→ 결과
형태로 사용하는 것이 좋다.
외부 변수의 상태를 계속 바꾸기 시작하면 Stream을 사용하면서 얻을 수 있는 장점이 줄어든다.
특히 병렬 Stream까지 사용하게 되면 공유 상태 변경은 훨씬 더 위험해질 수 있다.
8. for문이 압도적으로 편한 순간도 있다
예를 들어 처리 도중 특정 조건에서 바로 빠져나와야 한다고 해보자.
for (Order order : orders) {
if (order.isCanceled()) {
continue;
}
if (order.hasError()) {
log.error("주문 오류 : {}", order.getId());
break;
}
process(order);
}
이 코드는 이해하기 어렵지 않다.
반대로 이것을 무조건 Stream으로 바꾸려고 하면 코드가 오히려 이상해질 수 있다.
Stream에는 filter, findFirst, takeWhile 등의 다양한 방법이 있지만
사용할 수 있다는 것과 사용하는 것이 좋은 것은 다르다.
나는 명확한 break, continue 흐름이 필요한 로직이라면 for문을 사용하는 데 전혀 거부감이 없다.
오히려 더 적절하다고 생각한다.
9. 디버깅이 복잡한 로직도 for문이 편하다
실무에서는 코드가 항상 예쁘게만 동작하지 않는다.
운영 장애가 발생하면 결국 디버거를 걸어야 한다.
for (Order order : orders) {
Payment payment = paymentService.find(order);
if (payment == null) {
continue;
}
Coupon coupon = couponService.find(order);
int amount = calculate(order, payment, coupon);
process(order, amount);
}
이런 코드는 한 줄씩 breakpoint를 걸면서
order
payment
coupon
amount
값을 확인하기 편하다.
Stream 안에서 이것저것 처리하다 보면 디버깅할 때 흐름을 따라가기 불편해지는 경우가 있다.
그래서 복잡한 비즈니스 처리 로직은 굳이 Stream으로 만들지 않는다.
10. primitive 데이터를 처리한다면 IntStream도 생각해볼 수 있다
이런 코드를 보자.
int sum = 0;
for (int value : values) {
sum += value;
}
Stream으로 바꾸면서 무조건
Stream<Integer>
를 사용할 필요는 없다.
Java에는 primitive 타입을 위한
IntStream
LongStream
DoubleStream
이 따로 있다.
예를 들어
int sum = IntStream.of(values)
.sum();
처럼 사용할 수 있다.
Stream<Integer>를 사용하면 primitive int와 Integer 사이의 boxing/unboxing이 발생할 수 있기 때문에 숫자 연산이 많다면 primitive stream을 활용하는 것도 좋은 선택이다.
11. parallelStream은 더 빠르지 않을까?
Stream 이야기를 하면 거의 반드시 등장하는 것이 있다.
parallelStream()
예를 들어
orders.parallelStream()
.filter(Order::isPaid)
.map(OrderDTO::from)
.toList();
이렇게 한 줄만 바꾸면 병렬 처리가 된다.
그러면 훨씬 빠른 것 아닐까?
반드시 그렇지는 않다.
Oracle 문서에서도 parallel stream이 자동으로 sequential stream보다 빠른 것은 아니며, 데이터의 양이나 CPU 코어 수, 작업 특성 등이 적절해야 실제 이점이 있다고 설명한다.
병렬화를 하려면 데이터를 나누고,
작업 분할
→ 여러 Thread에서 처리
→ 결과 결합
하는 과정이 필요하다.
이 비용보다 실제 작업 비용이 작다면 오히려 느려질 수도 있다.
12. 나는 웹 서버에서 parallelStream을 특히 조심한다
Spring Boot 같은 웹 애플리케이션에서는 더 조심한다.
서버 하나에서 요청 하나만 처리하는 것이 아니기 때문이다.
사용자 A 요청
사용자 B 요청
사용자 C 요청
사용자 D 요청
...
가 동시에 들어온다.
각 요청이 parallelStream()을 적극적으로 사용하기 시작하면 내가 예상한 것과 다른 형태로 Thread 자원을 소비할 수 있다.
특히 그 내부에서
DB 호출
외부 API 호출
파일 처리
같은 작업까지 한다면 더 신중해야 한다.
parallelStream()이라는 한 줄이 너무 간단해서 그렇지,
병렬 처리는 원래 간단한 문제가 아니다.
나는 실제 성능 측정 없이
"병렬이니까 빠르겠지."
라는 이유만으로 parallelStream()을 사용하는 것을 추천하지 않는다.
13. Stream.toList() 사용 시 하나 알아둘 점
요즘 Java 코드에서는 이런 코드를 자주 사용한다.
List<Order> result = orders.stream()
.filter(Order::isPaid)
.toList();
깔끔하다.
그런데 하나 알아둘 것이 있다.
Stream.toList()가 반환하는 List는 수정할 수 없는 List다.
따라서
result.add(order);
같은 코드를 실행하면 UnsupportedOperationException이 발생한다.
결과 List를 수정해야 한다면 목적에 맞게
.collect(Collectors.toCollection(ArrayList::new));
같은 방법을 사용할 수 있다.
Stream 문법만 아는 것보다 Stream이 어떤 결과를 만들어내는지 이해하고 사용하는 것이 중요하다.
14. 성능이 정말 중요하다면 어떻게 해야 할까?
여기서 중요한 질문이다.
for문이 빠른가?
Stream이 빠른가?
인터넷 검색으로 결론을 내리지 않았으면 한다.
실제로 해당 코드가 성능상 중요한 지점이라면 측정해야 한다.
Java에서 마이크로 벤치마크를 제대로 하려면 JMH 같은 도구를 사용하는 것이 일반적이다.
단순히
long start = System.currentTimeMillis();
을 앞뒤로 넣고 몇 번 실행해서
"for문이 20ms 빠르다."
라고 판단하면 JVM warm-up, JIT compilation, GC 등 여러 요소 때문에 잘못된 결론을 내릴 수도 있다.
그리고 반드시 실제 서비스 관점에서도 생각해야 한다.
Stream을 for문으로 바꿔 0.1ms를 줄였는데 DB Query 하나가 500ms 걸리고 있다면 우리가 먼저 고쳐야 하는 곳은 반복문이 아니다.
15. 그렇다면 나는 실제로 어떻게 선택할까?
내 기준은 굉장히 단순하다.
조건을 걸러낸다
orders.stream()
.filter(Order::isPaid)
.toList();
Stream
데이터를 변환한다
orders.stream()
.map(OrderDTO::from)
.toList();
Stream
특정 데이터를 찾는다
orders.stream()
.filter(order -> order.getId().equals(orderId))
.findFirst();
Stream
그룹핑한다
orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
Stream
여러 조건과 상태 변경이 섞여 있다
for (Order order : orders) {
if (!order.isPaid()) {
continue;
}
Payment payment = getPayment(order);
if (payment == null) {
handlePaymentError(order);
continue;
}
updateOrder(order, payment);
sendNotification(order);
}
for문
중간에 빠져나가야 한다
for (Order order : orders) {
if (order.hasError()) {
break;
}
process(order);
}
for문
정말 성능이 중요하다
먼저 측정한다.
그리고 그 결과에 따라 결정한다.
16. 결국 중요한 것은 Stream을 쓰는 것이 아니다
오래 개발하다 보면 특정 기술이나 문법을 사용하는 것 자체가 목적이 되는 경우를 종종 본다.
Stream을 써야 모던 Java다.
혹은
for문이 가장 빠르니까 for문만 써야 한다.
나는 둘 다 좋은 기준이라고 생각하지 않는다.
좋은 코드는 새로운 문법을 많이 사용한 코드도 아니고, 몇 나노초를 무조건 줄인 코드도 아니다.
다른 개발자가 코드를 봤을 때
아, 여기서 이걸 하는구나.
를 빠르게 이해할 수 있어야 한다.
그리고 문제가 발생했을 때 쉽게 수정할 수 있어야 한다.
17. 15년 개발하면서 내린 결론
Java를 오래 사용하면서 오히려 문법에 대한 고집은 줄어들었다.
예전에는
어떤 방식이 더 좋은가?
를 많이 고민했다면 지금은
이 상황에서는 어떤 방식이 더 적절한가?
를 더 많이 고민한다.
Stream은 정말 좋은 기능이다.
특히
filter
map
reduce
grouping
find
처럼 데이터를 어떻게 가공할 것인지 표현할 때 굉장히 강력하다.
반대로 여러 상태가 변하고 복잡한 비즈니스 흐름을 처리해야 한다면 평범한 for문이 훨씬 좋은 코드가 될 수 있다.
그래서 내가 사용하는 기준을 한 문장으로 정리하면 이렇다.
데이터의 흐름을 표현하고 싶으면 Stream, 처리 과정을 표현하고 싶으면 for문을 먼저 생각한다.
그리고 성능이 정말 중요하다면?
그때는 취향으로 결정하지 않는다.
측정하고 결정한다.
한 줄 정리
Stream이 for문보다 느릴 수 있다.
하지만 그것만으로 for문을 선택할 이유는 아니다.
읽기 쉬운 코드를 먼저 선택하고,
성능이 문제가 되는 순간에는 측정한 뒤 결정하자.