안녕하세요. 개발이 즐거운 stark입니다!
이번에는 자바 스트림(stream)의 동작 방식에 대해 소개드리고자 합니다.
스트림(Stream)은 자바 컬렉션의 데이터를 쉽고 간결하게 처리하기 위해 도입된 기능입니다. 이를 통해 컬렉션 데이터를 순차적으로 접근하고 가공할 수 있으며, 더불어 코드의 가독성을 높일 수 있습니다. List, Set과 같은 컬렉션을 처리할 때 반복문 대신 스트림을 사용하면, 코드가 간결하고 직관적으로 변하게 됩니다.
또한 스트림은 데이터 처리 파이프라인으로 이해할 수 있습니다. 스트림에 작성된 각 연산을 통해 데이터가 흐르듯 처리됩니다. 이러한 파이프라인 방식은 다양한 작업(메서드)을 체이닝(Chaining)하여 마치 물줄기가 흐르듯 일련의 작업을 연결해서 처리할 수 있다는 특징을 가지고 있습니다.
이런 스트림은 실제로 어떻게 구성되고 동작할까요? 지금부터 함께 알아봅시다.
스트림 체이닝의 동작 방식
스트림 체이닝은 여러 개의 중간 연산을 순차적으로 연결하여 작업을 수행하는 방식입니다.
예를 들어, 다음 코드를 봅시다.
List<String> names = List.of("Alice", "Bob", "Charlie", "David");
names.stream() // names 리스트의 데이터를 스트림으로 변환
.filter(name -> name.length() > 3) // 1단계 필터링: 길이가 3보다 큰 이름
.filter(name -> name.startsWith("A")) // 2단계 필터링: 'A'로 시작하는 이름
.forEach(System.out::println); // 최종 연산: 결과 출력
먼저 names.stream()을 호출해서 names 리스트의 데이터를 스트림으로 변환합니다.
그다음 name.length() > 3을 통해 이름의 길이가 3보다 큰 요소만 남깁니다. 이 단계에서 "Bob"은 제외됩니다.
이어서 name.startsWith("A") 조건을 적용하여 "Alice"만 남게 됩니다.
마지막으로 forEach를 사용하여 조건을 통과한 이름을 출력합니다.
즉, 스트림의 각 필터링은 순차적으로 이루어지며, 각 단계에서 조건을 통과하지 못한 요소들은 다음 단계로 전달되지 않습니다. 여기서 알 수 있는 체이닝의 핵심은 데이터가 순서대로 처리되며, 각 중간 연산의 결과가 다음 연산으로 전달된다는 점입니다.
stream()과 forEach()를 같이 사용하는 게 맞나요?
많은 사람들이 stream().forEach()와 컬렉션의 forEach()의 차이에 대해 혼란을 겪습니다. 저도 개발하면서 로직을 작성하다 보면 이 두 가지를 같이 사용하게 되는 경우가 있습니다. 사실 두 연산은 단순 반복의 측면에서는 동일한 기능을 수행합니다. 아래 두 코드는 동일한 결과를 출력합니다.
names.stream().forEach(System.out::println);
names.forEach(System.out::println); // 동일한 결과
그러나 스트림의 진정한 가치는 중간 연산을 포함한 데이터 처리에 있습니다. 만약 단순 출력 로직이라면 forEach()만으로도 충분하나, 필터링이나 매핑과 같은 중간 연산을 사용해야 한다면 스트림의 강력함이 드러납니다. 예를 들어 다음과 같은 코드가 있습니다.
List<String> names = List.of("Alice", "Bob", "Charlie", "David", "Amanda");
names.stream()
.filter(name -> name.length() > 3) // 길이가 3보다 큰 이름 필터링
.map(String::toUpperCase) // 이름을 대문자로 변환
.forEach(System.out::println); // 결과 출력
위 코드에서는 스트림의 중간 연산인 filter와 map을 통해 데이터를 가공하고, 최종 연산인 forEach를 통해 처리된 데이터를 출력합니다. 이렇게 중간 연산을 사용하면 데이터를 원하는 형태로 가공하고, 조건에 맞는 데이터를 효율적으로 처리할 수 있습니다. forEach()만으로는 이처럼 중간에 데이터를 필터링하거나 변환하는 작업을 수행하기 어렵습니다.
스트림은 스트림 파이프라인을 통해 여러 개의 중간 연산을 체이닝 하고 최종 연산을 호출하는 구조이기 때문에 데이터 처리의 유연성이 높습니다. 반면 컬렉션의 forEach()는 단순 반복 처리를 목적으로 하기 때문에 이러한 유연성을 제공하지 않습니다.
정리하자면, stream().forEach()는 단순한 반복 이상의 역할을 수행합니다. 스트림 파이프라인 내에서 중간 연산들과 결합하여 데이터를 필터링하고 가공한 후 최종적으로 소비하는 데 사용되기 때문에, 단순히 forEach()만을 사용할 때보다 훨씬 강력하고 유연하게 데이터의 처리 및 변환이 가능합니다.
스트림의 중간 연산과 최종 연산 (feat. 지연 연산)
스트림에서 가장 중요한 부분은 중간 연산과 최종 연산입니다.
이 두 가지의 차이점을 확실하게 이해해야만 스트림을 제대로 활용할 수 있습니다.
중간 연산에는 filter, map, sorted 등이 있으며, 이들은 데이터를 변환하거나 필터링하는 역할을 합니다. 이들은 새로운 스트림을 반환하고, 지연 연산(lazy evaluation, 하단의 목차 확인)되기 때문에 실제 연산은 최종 연산이 호출될 때까지 이루어지지 않습니다.
반면 forEach, collect, count 같은 최종 연산은 스트림을 종료하고 결과를 반환하는 연산입니다. 최종 연산이 호출될 때, 앞에서 작성한 모든 중간 연산들이 한 번에 실행됩니다. 최종 연산이 호출되면 스트림은 더 이상 사용할 수 없게 되며, 이를 통해 데이터 처리가 완료됩니다.
예를 들어, 최종 연산으로 collect를 사용하면 스트림의 요소들을 하나의 컬렉션으로 변환할 수 있고, count를 사용하면 스트림 요소의 개수를 반환할 수 있습니다. 아래의 코드는 count를 사용하는 예시입니다.
List<String> names = List.of("Alice", "Bob", "Charlie", "David", "Amanda");
long count = names.stream()
.filter(name -> name.startsWith("A")) // 중간 연산: 'A'로 시작하는 이름 필터링
.map(String::toUpperCase) // 중간 연산: 이름을 대문자로 변환
.count(); // 최종 연산: 조건에 맞는 요소 개수 반환
System.out.println("Count of names starting with A: " + count);
위 코드에서 filter와 map은 중간 연산이며, 최종 연산인 count가 호출될 때 모든 중간 연산이 실행됩니다. 이로 인해 'A'로 시작하는 이름("Alice", "Amanda")이 대문자로 변환되고, 그 수가 출력됩니다.
중간 연산은 체이닝을 통해 여러 개를 연달아 사용할 수 있고, 최종 연산이 호출되기 전까지는 실제로 데이터가 처리되지 않습니다. 이러한 방식은 불필요한 데이터 처리를 피하고, 필요한 데이터만을 효율적으로 처리하는 데 큰 장점을 가집니다.
스트림의 지연 연산(lazy evaluation)이 뭘까?
지연(Lazy) 연산은 스트림에서 중간 연산이 최종 연산이 호출될 때까지 실제로 수행되지 않는다는 개념입니다.
스트림의 중간 연산은 단지 데이터 처리의 방법을 정의할 뿐, 그 자체로는 즉시 실행되지 않습니다. 최종 연산이 호출되기 전까지 모든 중간 연산은 지연되고, 최종 연산이 호출되는 시점에서 비로소 모든 중간 연산이 한 번에 처리됩니다.
예를 들어, 다음 코드를 봅시다.
List<String> fruits = List.of("apple", "banana", "cherry", "date", "elderberry");
fruits.stream()
.filter(fruit -> {
System.out.println("Filtering: " + fruit);
return fruit.length() > 5;
})
.map(fruit -> {
System.out.println("Mapping: " + fruit);
return fruit.toUpperCase();
})
.forEach(System.out::println);
이 코드에서 filter와 map은 중간 연산이고, forEach는 최종 연산입니다. forEach가 호출되기 전까지는 filter와 map이 실제로 실행되지 않습니다. 최종 연산(forEach)이 호출되는 순간, 스트림 파이프라인이 한 번에 실행되면서 각 요소에 대해 필터링(filter)과 매핑(map)이 이루어지게 됩니다. 이 과정에서 '지연 연산(lazy evaluation)'이 이루어지므로 불필요한 계산을 줄이고 효율적인 데이터 처리가 가능합니다.
스트림과 for문의 비교
스트림과 for문은 무슨 차이가 있을까요? 단순히 자바 8에서 생긴 스트림이 좋다는 말에 사용하고 있으시지는 않으신가요?
스트림과 for문을 비교해 보고 어떤 상황에 스트림을 사용하는 것이 더 좋은 지도 알아봅시다.
스트림의 연산은 마치 전체 연산 계획을 세우는 것과 비슷한 방식으로 동작합니다.
스트림의 중간 연산 (filter, map)은 실제 데이터를 처리하는 것이 아니라, '이 데이터를 어떻게 처리할 것인가'를 정의하는 단계에 불과합니다. 이 단계에서 수행된 연산은 실제로 데이터를 변경하거나 계산하지 않고, 단지 연산 계획으로만 남습니다. 최종 연산 (findFirst, forEach 등)이 호출되기 전까지는 실제로 연산이 수행되지 않으며, 필요한 시점에서 한 번에 모든 연산이 수행됩니다.
1. 스트림 생성 (ex: list.stream())
2. 중간 연산 정의 (ex: filter(), map())
- 이 시점에서는 실제 연산이 수행되지 않음
3. 최종 연산 호출 (ex: collect(), findFirst())
- 이때 정의된 모든 작업이 실행됨
위 흐름에서 볼 수 있듯이, 스트림의 중간 연산들은 최종 연산이 호출되기 전까지 지연(Lazy) 상태로 남아 있습니다. 이 방식은 실제로 데이터 처리가 이루어질 때 필요한 연산만을 수행하기 때문에, 불필요한 연산을 최소화하는 데 큰 장점을 가집니다. 이러한 지연 평가 덕분에 스트림은 매우 큰 데이터셋도 효율적으로 처리할 수 있는 성능 최적화를 제공합니다.
반면, for문은 각 요소를 즉시 평가하고 조건을 확인하면서 연산을 수행하는 방식입니다.
for문은 각 요소를 순차적으로 처리하는 방식입니다. 모든 연산이 즉시 실행되며, 여러 단계의 처리가 필요할 때는 중간 결과를 저장하기 위한 추가 컬렉션이 필요합니다.
1. 반복문 시작
2. 각 요소에 대해 즉시 연산 수행
3. 결과가 필요한 경우 별도 컬렉션에 저장
4. 다음 단계 처리를 위해 새로운 반복문 실행
만약 단순한 반복이나 기본적인 필터링의 경우, for문과 스트림의 성능 차이는 크지 않습니다. 스트림의 장점은 다음과 같은 상황에서 더 잘 드러납니다.
1. 무한한 데이터 처리
// 무한 스트림에서 처음 5개의 짝수 찾기
List<Integer> collect = Stream.iterate(1, n -> n + 1)
.filter(n -> n % 2 == 0)
.limit(5)
.collect(Collectors.toList());
// collect에 저장된 데이터
[2, 4, 6, 8, 10]
스트림의 가장 강력한 특징 중 하나는 무한한 데이터 소스를 다룰 수 있다는 점입니다.
위 예제는 1부터 시작하여 무한히 증가하는 수열에서 짝수만 필터링하여 처음 5개를 가져오는 작업을 수행합니다. Stream.iterate()를 사용하여 1부터 시작하고 이전 값에 1을 더하는 무한한 수열을 만들고, 이를 filter로 짝수만 걸러낸 뒤, limit(5)로 처음 5개의 결과만 가져와 리스트로 변환합니다.
만약 이러한 작업을 for문으로 구현하려면 아래 코드처럼 무한 루프와 카운터 변수를 사용해야 합니다.
List<Integer> result = new ArrayList<>();
int number = 1;
while (result.size() < 5) { // 5개를 찾을 때까지 반복
if (number % 2 == 0) { // 짝수인 경우
result.add(number);
}
number++;
}
2. 복잡한 연산 체이닝
// 스트림: 중간 결과 저장 불필요
items.stream()
.filter(item -> item.getPrice() > 1000)
.map(Item::getName)
.sorted()
.collect(Collectors.toList());
스트림은 모든 중간 연산을 하나의 파이프라인으로 처리할 수 있습니다. 즉, 1000원이 넘는 상품을 필터링하고, 이름을 추출하고, 정렬하는 일련의 과정을 중간 결과를 별도로 저장하지 않고도 한 번에 정의하고 실행할 수 있습니다. 이는 코드를 더 간결하고 가독성 있게 만들어줄 뿐만 아니라, 메모리 사용 측면에서도 효율적입니다.
만약 이러한 작업을 for문으로 구현하려면 아래 코드처럼 List를 선언해서 중간 결과를 저장해줘야만 합니다.
// for문: List를 선언해서 중간 결과 저장 필요
List<Item> expensive = new ArrayList<>();
for (Item item : items) {
if (item.getPrice() > 1000) {
expensive.add(item);
}
}
// for문: List를 선언해서 중간 결과 저장 필요
List<String> names = new ArrayList<>();
for (Item item : expensive) {
names.add(item.getName());
}
Collections.sort(names);
반면 for문을 사용하면 각 단계마다 중간 결과를 저장할 새로운 리스트가 필요합니다. 먼저 1000원이 넘는 상품을 담을 리스트(expensive)를 만들고, 다시 그 상품들의 이름만 담을 새로운 리스트(names)가 필요합니다. 이는 데이터의 양이 많을수록 더 많은 메모리를 사용하게 되며, 코드도 길어지고 복잡해집니다.
이러한 차이는 데이터 처리 단계가 많아질수록 더욱 두드러집니다. 스트림은 연산을 추가하더라도 단순히 체인에 메서드를 추가하기만 하면 되지만, for문은 매 단계마다 새로운 리스트와 반복문이 필요하게 됩니다.
지연 연산(lazy evaluation)과 단축 연산(short-circuit)의 장점
스트림의 핵심적인 장점 중 하나는 지연 연산(lazy evaluation)입니다. (상단의 목차에서 설명한 내용입니다.)
지연 연산은 최종 연산이 호출될 때까지 스트림의 중간 연산(예: filter, map)을 실제로 실행하지 않고 기다립니다. 또한, 스트림은 단축 연산(short-circuit)을 통해 조건이 만족되면 연산을 멈추고 종료할 수 있습니다.
단축 연산은 특정 조건이 만족되면 더 이상 연산을 진행하지 않고 스트림을 종료하는 방식입니다. 예를 들어, findFirst(), anyMatch() 같은 최종 연산이 단축 연산의 대표적인 예입니다. 이러한 연산은 조건을 만족하는 요소를 찾으면 즉시 연산을 종료하여 불필요한 추가 연산을 피할 수 있게 합니다. 이 덕분에 필요 없는 추가 작업을 피할 수 있습니다.
반면에, for 문은 모든 데이터를 순차적으로 처리하고, 조건을 명시적으로 검사하며, 필요시 개발자가 직접 break나 continue를 사용해 반복문을 제어해야 합니다. 이 과정은 데이터의 상태를 수동으로 관리해야 하기 때문에 '복잡한 조건'이 추가될수록 코드의 가독성이 떨어지고 유지보수도 어려워집니다.
쉽게 이해하기 위해 스트림 코드를 살펴봅시다.
List<String> words = List.of("apple", "banana", "cherry", "date", "elderberry");
Optional<String> result = words.stream()
.filter(word -> {
System.out.println("Filter: " + word);
return word.length() > 5;
})
.map(word -> {
System.out.println("Map: " + word);
return word.toUpperCase();
})
.findFirst(); // 최종 연산
여기서 filter와 map은 중간 연산이고, findFirst는 최종 연산입니다. 스트림은 첫 번째로 조건을 만족하는 요소("banana")를 찾으면 이후의 연산을 모두 건너뛰고 종료합니다. 지연 연산 덕분에 필요한 연산만 최소한으로 수행하게 됩니다.
다음으로 for 문을 살펴봅시다.
List<String> words = List.of("apple", "banana", "cherry", "date", "elderberry");
String result = null;
for (String word : words) {
System.out.println("Filter: " + word);
// 필터 조건 검사
if (word.length() > 5) {
// 필터 조건에 맞는 경우에만 매핑 수행
String upperWord = word.toUpperCase();
System.out.println("Map: " + upperWord);
// 조건을 만족하면 반복 종료
result = upperWord;
break;
}
}
for 문에서는 각 단계별로 조건을 명시적으로 검사하고 제어해야 합니다. 조건을 만족하는 순간 break로 반복을 종료하지만, 이는 수동적인 제어 방식입니다.
이 내용을 정리해 보면 다음과 같습니다.
- 스트림의 중간 연산 최적화
- 스트림의 중간 연산들은 결합되어 한 번에 적용됩니다. 예를 들어, filter와 map이 결합된 경우 조건을 만족하는 첫 번째 요소를 찾으면 필요한 연산만 수행하고 종료합니다. 이는 불필요한 데이터 연산을 방지하며, 코드가 더욱 간결해지고 읽기 쉬워집니다.
- 스트림의 중간 연산들은 결합되어 한 번에 적용됩니다. 예를 들어, filter와 map이 결합된 경우 조건을 만족하는 첫 번째 요소를 찾으면 필요한 연산만 수행하고 종료합니다. 이는 불필요한 데이터 연산을 방지하며, 코드가 더욱 간결해지고 읽기 쉬워집니다.
- for문과의 비교
- for 문에서는 각 단계마다 조건을 명시적으로 검사하고, 데이터를 변환해야 합니다. 여러 조건이 있을 경우, 이러한 제어 구조가 코드에 반복적으로 나타나기 때문에 가독성과 유지보수성이 저하됩니다.
- for 문에서는 각 단계마다 조건을 명시적으로 검사하고, 데이터를 변환해야 합니다. 여러 조건이 있을 경우, 이러한 제어 구조가 코드에 반복적으로 나타나기 때문에 가독성과 유지보수성이 저하됩니다.
- 지연 평가의 이점
- 스트림은 지연 평가 덕분에 최종 연산을 호출하기 전까지 중간 연산이 실행되지 않습니다. 이로 인해 조건을 만족할 때까지 필요한 최소한의 연산만 수행하여, 불필요한 데이터 처리를 방지합니다. 반면, for 문은 조건을 검사하고 데이터를 처리하는 과정이 즉각적으로 실행되며, 명시적으로 관리해야 합니다. 따라서 복잡한 조건이 추가될수록 코드의 제어가 어려워집니다.
만약 조건이 복잡하다면 스트림과 for문의 차이점이 더 두드러지게 나타날까요?
복잡한 조건을 다루는 스트림과 for문 비교
복잡한 조건이 여러 개 있을 때, 스트림을 사용하면 체이닝을 통해 코드를 훨씬 더 간결하고 가독성 있게 작성할 수 있습니다. 스트림은 각 조건을 연결하여 내부적으로 최적화된 방식으로 처리하기 때문에 조건을 만족하는 순간 이후의 연산을 생략할 수 있는 장점이 있습니다.
먼저 스트림을 사용한 코드입니다.
List<String> words = List.of("apple", "banana", "cherry", "date", "elderberry", "fig", "grape");
Optional<String> result = words.stream()
.filter(word -> {
System.out.println("Filter: " + word);
return word.length() > 5;
})
.map(word -> {
System.out.println("Map: " + word);
return word.toUpperCase();
})
.filter(word -> {
System.out.println("Second Filter: " + word);
return word.startsWith("B");
})
.findFirst();
위 코드는 스트림을 사용하여 여러 조건을 체이닝 하여 처리합니다. 조건을 만족하는 첫 번째 요소인 "banana"를 찾으면 이후의 연산은 생략되고 스트림 처리가 종료됩니다. 즉, "banana" 이후의 요소들은 평가되지 않습니다.
다음은 같은 로직을 for문을 사용해 구현한 코드입니다.
String result = null;
for (String word : words) {
System.out.println("Filter: " + word);
// 첫 번째 필터 조건
if (word.length() > 5) {
// 매핑 수행
String upperWord = word.toUpperCase();
System.out.println("Map: " + upperWord);
// 두 번째 필터 조건
if (upperWord.startsWith("B")) {
System.out.println("Second Filter: " + upperWord);
result = upperWord;
break;
}
}
}
for문을 사용한 코드는 각 단계를 명시적으로 제어해야 하며, 모든 조건을 일일이 검사해야 합니다. 조건을 만족하는 요소를 찾으면 break를 사용해 반복을 종료하지만, 이러한 방식은 코드가 복잡해지고 가독성이 떨어질 수 있습니다.
반면, 스트림을 사용하면 이러한 과정을 내부적으로 최적화하여 불필요한 연산을 최소화하고, 코드를 간결하게 작성할 수 있습니다. 특히 여러 조건을 처리할 때, 스트림의 체이닝 방식은 가독성을 높이고 유지보수를 쉽게 만들어 줍니다.
마무리하며
이번 포스팅을 적게 된 숨겨진 이유가 있습니다.
저는 자바로 개발하면서 stream을 정말 자주 쓰는데 지금까지 이걸 왜 사용하는지 그리고 어떻게 동작하는지에 대해서도 모르고 있었습니다. 생각해 보면 '자바 8 이상부터는 사용할 줄 알아야 한다. 스트림을 사용하는 것이 멋있다. 취업하려면 스트림은 사용할 줄 알아야 한다.'
이런 말들을 보고 들으며 급한 마음에 배워서 사용했던 것 같습니다.
이번 기회에 저도 stream의 동작을 공부하면서 조금 더 stream을 제대로 활용할 수 있을 것 같다는 생각이 들었습니다.
개인적으로는 개발할 때 내가 만든 코드가 동작하는 것이 가장 중요하지만 그다음으로 중요한 것은 추후 누군가 내 코드를 읽었을 때 쉽게 이해가 되는 것 즉, 가독성이 좋은 것도 굉장히 중요하다고 생각합니다.
반복문의 가독성을 엄청나게 향상시켜준 stream에게 감사 인사를 남기며 이번 포스팅을 마칩니다.
'JAVA' 카테고리의 다른 글
화살표 if문을 DDD로 우아하게 리팩토링하기 (1) | 2024.10.29 |
---|---|
Java 클래스 상속의 자유도와 주의점 (1) | 2024.10.27 |
전략 패턴(Strategy Pattern)이란? (2) | 2024.10.06 |
커맨드 패턴(Command Pattern)이란? (4) | 2024.10.04 |
[Java] List를 Optional로 처리할 때 고려해야 할 사항 (1) | 2024.10.01 |