
우테코를 진행하면서 테코톡 발표를 하게 되었는데, 많은 사람들에게 공감을 받을 수 있는 주제를 고민하다가 스트림을 발표하기로 했다. 하지만 스트림은 너무 유명해서 이전기수 테코톡 자료도 많았는데, 나는 조금 특별한 발표가 하고싶었다. 남들이 잘 알려주지 않는 스트림만의 재미있는 이야기가 하고 싶었다.
그러던 중 스트림 관련 해외 컨퍼런스 세미나를 접했는데 내용이 너무 유익한 나머지 자막도 없는 영상에 음성인식과 번역을 거쳐 대본을 만들었고, 1시간짜리 영상을 끝까지 스킵없이 시청했다. 그 유익한 내용을 조금이라도 블로그에도 정리해보려고 한다.
Stream API는 컬렉션을 함수형 인터페이스를 통해 직관적으로 처리할 수 있도록 Java 8부터 제공하는 API이다.
하지만 스트림은 일반적인 반복문(for, while 등)에 비해 성능이 느리다. 심지어 간단한 로직은 반복문에 비해 10배 이상 느리기도 하다.
문제가 생겼을 때 디버깅해보기도 불편하다. 보통은 중단점을 찍고 한 줄씩 실행해볼텐데, 스트림을 사용하면 복잡해진다. IntelliJ 기준으로는 람다 중단점 기능을 통해 스트림 소스 각각에 대해 수행하는 연산을 디버깅할 수 있지만, 불편한 것은 감수해야 한다.
그렇다면 기존의 반복문에 비해 어떤 장점이 있길래 stream을 많이 사용하고, 또 사용하기를 권장하는 걸까?
반복문과 스트림의 성능 차이
스트림이 반복문에 비해 느리다고 했는데 얼마나, 왜 느릴까?
원시 타입 배열의 합을 구하는 로직과 같은 간단한 로직은 스트림이 반복문에 비해 10~15배나 느리다. 하지만 이 로직이 래퍼 타입의 컬렉션을 사용하도록 바꾸기만 해도 둘의 성능 차이는 20% 정도로 확연히 줄어든다. 스트림에 비해 출시된지 오래된 기존 반복문은 컴파일러나 JVM, 옵티마이저 등에 의해 오랜 시간 최적화가 되어왔기에 간단한 원시타입 연산은 성능 차이가 크지만 로직이 복잡해질수록 이 이점이 줄어들기 때문이다.또한 내부 연산이 복잡해질수록 둘의 성능 차이는 무의미한 수준으로 줄어든다. 그래서 현업에서는 스트림을 사용할 때 성능 걱정을 크게 하지 않는 것 같다.
람다를 사용하는 가장 큰 이유는 예쁘기 때문이다.(진지)
정확히는 코드가 예뻐진다. 어떻게 할 것인지를 정의하는 기존 반복문의 명령형 프로그래밍 방식과 달리, 어떻게 할 것인지는 내부 구현에 맡겨두고 무엇을 할 것인지에만 집중하는 스트림의 선언형 프로그래밍이 훨씬 코드를 간결하고 예쁘게 만들어준다.

기존 반복문을 스트림으로 변환한 모습. 누가봐도 스트림을 사용한 코드가 훨씬 예쁘다.
void description() {
Arrays.stream(crews) // 스트림 생성
.filter(crew -> crew.name().length() == 2) // 중간 연산
.filter(crew -> crew.group() == 26) // 중간 연산
.findFirst(); // 최종 연산
}
스트림은 스트림 생성으로 시작해서 최종 연산으로 끝난다. 그리고 그 사이에 무수히 많은 중간 연산이 올 수 있다.(물론 중간 연산이 오지 않는 것도 가능하다)
스트림 소스를 여러 중간 연산을 통해 가공하여 결과를 반환하는 것이다.
위 코드에 대해 예시 상황을 통해 연산 과정을 살펴보자.

Arrays.stream()).filter()).filter()).findFirst())이렇게 보니 모든 스트림 소스를 두고 코드를 한 줄씩 실행하는 것 같다. 하나의 중간 연산을 수행할 때는 모든 요소에 대해 연산을 수행하고, 그 이후에 다음 중간 연산으로 넘어가는 것 같다. 마치 수평적으로 연산하는 구조처럼 보인다. 스트림은 실제로 이렇게 연산할까?
그렇지 않다!! 다음 그림을 살펴보자.

스트림은 내부 요소 각각에 대해 중간 연산 및 최종 연산을 수직적으로 처리한다. 요소 하나에 대한 연산을 끝까지 수행한 뒤에야 다음 요소로 넘어간다. 이런 연산 구조를 가져감으로써 얻을 수 있는 메리트가 있는데, 바로 지연처리다.
사실 위 연산 그림은 조금 잘못되었고, 지연처리 특성을 적용한 실제 스트림 실행 과정은 다음과 같이 표현할 수 있다.

위 그림에서는 모코가 최종 연산까지 도달했다. 하지만 최종 연산 결과는 이미 정해졌기 때문에(findFirst의 결과는 모코로 고정되었다) 이후 남은 요소들에 대해 중간 연산을 수행하더라도 결과는 바뀌지 않는다. 그래서 최종 연산에 필요하지 않은 중간 연산은 건너뛸 수 있고, 이 특성이 바로 지연처리이다.
지연처리(Lazy Evaluation)란?
중간 연산은 최종 연산 실행에 필요한(영향을 미치는) 경우에만 호출된다. 최종 연산에 영향을 미치지 못하는 중간 연산은 실행되지 않는다.
다음 코드와 같이 최종 연산이 명시되지 않은 스트림은 어떨까?
void lazyEvaluation() {
Stream<Crew> stream = crews.stream() // 스트림 생성
.filter(crew -> crew.name().length() == 2) // 중간 연산
.filter(crew -> crew.group() == 26) // 중간 연산
; // 최종 연산
}
최종 연산 자체가 수행되지 않는다면, 스트림 생성을 비롯한 모든 중간 연산은 최종 연산 실행에 필요하지 않게 되어버린다. 즉, 위 스트림은 실제 처리가 진행되지 않는다. 변수화된 저 스트림을 참조하여 최종 연산을 호출한다면 그 때 실제 처리가 진행되는 것이다.
스트림의 특성을 잘 활용하면 연산을 조금이라도 최적화할 수 있다. 간단하게 알아보자.
.filter()와 같이 모수를 줄일 수 있는 중간 연산이 있고 .map()과 같이 모든 스트림 소스에 대해 수행하는 중간 연산이 있다. 순서에 무관하다면 모수를 줄일 수 있는 연산을 가능한 앞에 배치하는 것이 불필요한 중간 연산을 최소화하여 실행 시간을 단축시키는 데 도움이 된다.
최종 연산이 지연처리를 활용할 수 있는(일찍 끝날 수 있는) 연산이라면 최종 연산의 대상이 될 수 있는 가능성이 높은 요소를 스트림 소스의 앞부분에 배치하여 시간을 단축시킬 수 있다. 실용성은 조금 떨어지는 것 같지만 알아두면 도움이 될 날이 오지 않을까?
지금까지 집중해서 읽었다면 다음 문제들은 어렵지 않게 풀 수 있을 것이다. 답은 포스트 맨 밑에 적어둘 테니 한번씩 풀어보고 넘어가자.
각 문제의 예상 출력을 맞추면 된다.
void quiz1() {
Stream.of("포비", "워니", "네오")
.filter(name -> {
System.out.println("filter() 호출됨");
return name.equals("워니");
});
}
void quiz2() {
Stream.of("포비", "워니", "네오")
.filter(name -> {
System.out.println("filter() 호출됨");
return name.equals("워니");
}).map(name -> {
System.out.println("map() 호출됨");
return name + " 멋있다!!";
}).toList();
}
void quiz3() {
Stream.of("포비", "워니", "네오")
.filter(name -> {
System.out.println("filter() 호출됨");
return name.equals("워니");
}).map(name -> {
System.out.println("map() 호출됨");
return name + " 멋있다!!";
}).findFirst();
}
스트림이 좋다고는 하지만, 항상 스트림만 고집하는 것은 좋지 않다. 로직이 매우 단순하거나 중간 연산이 필요없는 단순 반복인 경우에는 오히려 일반적인 루프(for, while 등)를 사용하는 것이 더 직관적이다.
또한 스트림보다 일반적인 루프가 성능이 조금은 더 좋기 때문에, 성능이 매우 중요한 경우라면 스트림 대신 일반 루프를 사용하는 것이 더 좋을 수도 있다.
void doNotUseStreamVariable() {
Stream<Integer> stream = Stream._of_(1, 2, 3, 4, 5)
.filter(i -> i > 3);
stream.toList(); // OK
stream.toList(); // IllegalStateException!!
}
스트림은 변수로 선언하여 재사용하는 것이 가능하다. 하지만 스트림은 최종 연산을 한번이라도 수행하면 닫히는데, 닫힌 스트림을 재사용하면 IllegalStateException이 발생한다. 이 예외는 RuntimeException이라 컴파일 시점이 잡을 수도 없어서 개발자의 실수로 문제가 생기기 딱 좋다.
따라서 스트림을 사용할 때는 변수로 선언하지 말고 되도록 메서드 체인을 통해 바로 사용해버리자.
이전에 스트림이 일반 루프(for, while 등)에 비해 성능이 떨어진다고 언급했다. 하지만 병렬 스트림을 사용하면 일반 루프에 비해 훨씬 뛰어난 성능을 보여줄 수도 있다. 한번 알아보자.
개발자가 스레드 관리같은 복잡한 절차 없이도 병렬 처리를 쉽게 사용할 수 있도록 스트림과 함께 Java 8에서 제공한 기능이다.
스트림에 parallelStream() 혹은 parallel()을 명시하면 병렬 스트림으로 동작한다.
병렬 스트림은 내부적으로 자바의 병렬 처리 프레임워크인 Fork / Join Framework를 사용한다. 큰 작업을 작은 작업으로 분할(Fork)하고 작은 작업들을 각각 병렬로 처리하여 다시 큰 작업으로 결합(Join)하는 방식으로, 분할 정복 기법과 유사하다.
병렬처리라고 하면 마냥 좋아보이지만 그렇다고 무작정 순차 스트림을 병렬로 전환하여 사용하면 큰 낭패를 볼 수 있다. 어떤 상황이 병렬 스트림을 사용하기 적합할까?
분할 정복을 위해서는 중간 인덱스의 요소에 접근해야 한다. 배열 기반 스트림 소스에서는 인덱스 계산만 하면 바로 접근할 수 있지만 연결 리스트 기반 스트림 소스에서는 각 요소를 거쳐 비싼 비용으로 해당 요소에 접근해야 한다.
단, 수행하는 로직이 CPU 집약적인 경우에는 연결리스트라도 순차 스트림에 비해 좋은 성능을 보여주기도 한다.
보통 스트림 소스가 적어도 10,000개 이상일 때 사용하기를 권장한다. 이보다 적을 경우, 병렬 처리를 위한 오버헤드가 병렬 처리의 이점을 상쇄해버려 오히려 비효율적일 수 있다.
단, 수행하는 로직이 매우 CPU 집약적인 경우에는 스트림 소스가 적더라도 순차 스트림에 비해 좋은 성능을 보여주기도 한다.
상태 저장 연산이면 병렬 스트림을 사용하지 않는 것이 좋다. 상태를 메모리에 기록해두고 이를 참조해야 하는 연산이라는 것인데, 병렬 연산을 수행하면 가변 공유 자원에 접근하는 상황이 생겨서 속도가 느려진다.
단, 수행하는 로직이 매우 CPU 집약적인 경우에는 상태 저장 연산이라고 해도 순차 스트림에 비해 좋은 성능을 보여주기도 한다.
순서 유지(ordered) vs 순서 비유지(unordered)
distinct()함수는 기본적으로 순서를 유지하며 중복을 제거한다. 하지만 이는 병렬 스트림의 성능을 크게 떨어뜨리기 때문에unordered()를 수식하여 순서를 유지하지 않는 중복 제거를 하는 것이 좋다.
CPU 집약적인 연산을 수행할 경우 순차 스트림에 비해 좋은 성능을 보여준다. 당연한 말이다. 각 연산이 CPU를 오래 잡아둘 정도로 복잡한 연산이라면 여러 프로세서를 사용할수록 속도가 훨씬 빨라질테니 말이다. 그렇기에 병렬 스트림을 사용하기 위해 고려해야 할 가장 중요한 특성이기도 하다.
이 특성은 너무너무 중요해서, 앞에서 언급한 다른 조건들을 전부 무시해도 연산만 매우 CPU 집약적이라면 병렬 스트림을 사용하는 것이 훨씬 이득이다. 물론 그래도 앞의 조건들을 지키고자 한다면 더 효율적인 사용이 가능해질 것이다.
CPU 집약적인 연산(CPU Intensive)이란?
CPU의 연산 능력을 집중적으로 사용하는 연산(ex. 복잡한 수학 연산, 영상 렌더링 등). I/O 작업, DB 조회 등은 오래걸리더라도 CPU보다 다른 작업에 의존적이므로 CPU 집약적이지 않다.
병렬 스트림은 상황에 따라 순차 스트림보다 좋을 수도, 안좋을 수도 있기 때문에 신중하게 결정해야 한다. 기왕이면 사용하고자 하는 환경에서 순차 스트림과 병렬 스트림 모두 벤치마킹해보고 더 좋은 쪽을 선택하자.
발표 준비를 하기 전까지는 스트림이 별 거 없는 줄 알았다. 그냥 다양한 함수만 외우면 충분히 잘 사용할 수 있을 줄 알았다. 하지만 재밌는 발표를 준비하려다 보니 처음보는 개념들을 접할 수 있었고, 스트림에 대해 많은 것을 배울 수 있었다.
발표 시간이 10분인 테코톡에서 Q&A만 20분은 진행한 것 같은데, 그 정도로 다들 몰입해서 봐준 것이 고맙고 도움을 준 것 같아서 만족스러웠다. 테코톡이 끝나고 많은 크루들이 잘 들었다는 인사를 전해줬고, 먼 강의장에서 테코톡을 듣던 크루가 질문거리를 정리해서 와줬다는 걸 들었을 때는 너무 뿌듯했다.
영어를 못하는 내가 원어민 컨퍼런스를 찾고 번역기까지 돌려가며 본 의미가 있었다. 앞으로도 유익한 내용을 학습하고 공유하기 위해 꾸준히 노력해야겠다.
+ 2025.06.07) 테코톡 영상 링크
Conference Video - GeeCon 2015 - The Performance Model of Streams in Java 8 - Angelika Langer
Java Stream API는 왜 for-loop보다 느릴까? : 위 컨퍼런스에 대한 한국어 정리글
When to use parallel streams
Java 스트림 Stream (1) 총정리
Java 스트림 Stream (2) 고급
Java Stream API 튜토리얼 - Baeldung
(출력없음)
filter() 호출됨
filter() 호출됨
map() 호출됨
filter() 호출됨
filter() 호출됨
filter() 호출됨
map() 호출됨
어디까지 강해질셈이야