쉽게 변하는 기술보다는 느리게 변하는 기술을 우선적으로 학습하는 것이 중요하다는 것을 들은적이 있습니다.
여기서 쉽게 변하는 기술은 프레임워크, 라이브러리 등 유행을 타는 기술들을 의미하고 느리게 변하는 기술은 프로그래밍 언어, 프로그래밍 패러다임, 알고리즘 등으로 분류할 수 있습니다.
개발 공부를 시작할 때 기초를 다질 시간이 부족한 상태에서 급하게 다양한 프로젝트를 하면서 배워왔다 보니, 쉽게 변하는 기술을 학습하는 것에는 익숙하고 재미를 붙여왔지만, 느리게 변하는 기술을 공부하는 건 자꾸 미루게 되었고 생각만 해도 따분하다는 생각이 듭니다.
서론이 길었는데, 미뤄왔던 느리게 변하는 기술인 Java 언어 자체에 대한 복습을 하는 중 Stream에 대한 내용을 정리해보면 좋겠다 싶어서 글을 작성하게 되었다는 말을 하고 싶었습니다.
# 00. About Stream
stream의 사전적 정의를 찾아보면 다음과 같습니다.
1. 명사 개울, 시내 (→downstream, upstream, the Gulf Stream)
2. 명사 (액체·기체의) 줄기 (→bloodstream)
3. 동사 줄줄[계속] 흐르다[흘러나오다]
4. 동사 줄을 지어[줄줄이] 이어지다[이동하다]
인터넷에서 음악이나 동영상을 실시간으로 재생하는 기술 스트리밍도,
데이터를 chunks로 나누고 준비가 되는대로 서버에서 클라이언트로 점진적으로 전송하는 기술 스트리밍도
마치 개울에서 물이 흐르듯 데이터가 파이프라인을 통해 흐르는 방식으로 처리된다는 점에서 비롯되었습니다.
자바의 Stream API도 비슷한 맥락입니다.
컬렉션이나 배열처럼 저장된 데이터를 여러 중간 연산을 체인 형태로 연결하여 데이터의 연속적인 흐름을 처리합니다.
구체적으로 스트림을 정의해 보자면 스트림은 인메모리 데이터 소스를 추상화하고 데이터를 다루는데 자주 사용되는 메서드들을 정의해 놓은 API입니다.
# 01. 스트림을 사용하는 이유
전통적으로 컬렉션을 다룰 때는 for 루프를 사용하여 명령형 방식으로 요소를 순회하고 처리합니다.
이때 개발자는 데이터를 "어떻게" 처리할지를 직접 명시해야 합니다.
List<Person> persons = getPersons();
List<String> emails = new ArrayList<>();
for (Person person : persons) {
// 18세 이상인지 확인
if (person.getAge() > 18) {
// 활성 상태인지 확인
if (person.isActive()) {
String email = person.getEmail();
// 이메일이 null이 아니면 소문자로 변환 후 추가
if (email != null) {
emails.add(email.toLowerCase());
}
}
}
}
반면 스트림 API를 사용하면 filter, map, reduce 등과 같은 중간 연산을 체이닝 하는 방식으로 "무엇을" 처리할지 선언적 방식으로 표현할 수 있습니다.
이를 통해 코드를 더 간결하게 작성할 수 있고 가독성이 향상됩니다.
List<Person> persons = getPersons();
List<String> emails = persons.stream()
.filter(person -> person.getAge() > 18) // 18세 이상 필터링
.filter(Person::isActive) // 활성 상태 필터링
.map(Person::getEmail) // 이메일 추출
.filter(Objects::nonNull) // null이 아닌 이메일만 선택
.map(String::toLowerCase) // 소문자로 변환
.collect(Collectors.toList()); // 결과를 리스트로 수집
또한 스트림은 지연 평가 방식으로 동작합니다.
컬렉션이 각 요소에 대해 작업을 수행할 때 즉각 실행되고 모든 요소를 순회하거나 처리하는 즉시 결과가 반영되는 반면,
스트림의 경우 중간 연산을 지연 평가하여 최종 연산이 호출되기 전까지는 실제 연산이 수행되지 않습니다. (중간 연산과 최종 연산에 대해서는 이후 설명합니다.)
이렇게 함으로써 필요한 경우에만 연산을 실행하여 불필요한 계산을 피하고 성능 최적화를 도모할 수 있습니다.
# 02. 스트림 생성하기
일반적으로 List, Set, Queue 등의 Collection 인터페이스는 기본 메서드로 stream() 메서드가 있어 컬렉션의 요소들을 순차 스트림으로 처리할 수 있습니다.
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> intStream = list.stream();
배열의 경우 Arrays.stream() 혹은 Stream.of()와 같은 static 메서드를 통해 스트림으로 처리할 수 있습니다.
// Arrays.stream() 사용
String[] arr = {"aaa", "bbb", "ccc"}
Stream<String> stream = Arrays.stream(arr);
// Stream.of() 사용
String[] arr = {"aaa", "bbb", "ccc"}
Stream<String> stream = Arrays.stream(arr);
자바 8에서는 Steram.iterate()와 Stream.generate() 메서드를 통해 무한 스트림을 생성할 수 있습니다.
무한 스트림은 주로 limit()과 같은 최종 연산과 함께 사용하여 원하는 개수만큼만 처리합니다.
// Stream.iterate()를 사용하여 스트림 생성
Stream<Integer> iterateStream = Stream.iterate(0, n -> n + 2).limit(10);
// Stream.generate()를 사용하여 스트림 생성
Stream<Double> generateStream = Stream.generate(Math::random).limit(5);
iterate()는 seed로 지정된 값부터 시작해서, 람다식에 의해 계산된 결과를 다시 seed값으로 해서 계산을 반복합니다.
반면 generate()의 경우 이전 결과를 이용해서 다음 요소를 계산하지 않습니다.
파일의 경우 자바 NIO의 Files.lines() 메서드를 사용하여 각 라인을 스트림으로 읽어올 수 있습니다.
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.stream.Stream;
import java.io.IOException;
try (Stream<String> fileStream = Files.lines(Paths.get("data.txt"))) {
fileStream.forEach(System.out::println);
} catch (IOException e) {
e.printStackTrace();
}
# 03. 스트림의 중간 연산과 최종 연산
스트림의 연산은 중간 연산과 최종 연산으로 분류됩니다.
중간 연산은 결과가 스트림인 연산이며, 스트림에 연속해서 중간 연산을 할 수 있습니다.
최종 연산은 결과가 스트림이 아닌 연산으로, 스트림의 요소를 소모하기 때문에 단 한 번만 사용할 수 있습니다.
stream
.distinct() // 중간 연산
.limit(5) // 중간 연산
.sorted() // 중간 연산
.forEach(System.out::println); // 최종 연산
위에서 말씀드렸듯이 스트림은 중간 연산에 대해 지연 연산이 적용됩니다.
최종 연산인 forEach() 연산이 수행되기 전까지는 distinct()나 limit()과 같은 중간 연산은 선언되어있기만 할 뿐 실제로 수행되지 않고 있습니다.
자주 사용되는 스트림의 중간 연산과 최종 연산 메서드들은 다음과 같습니다.
-중간 연산-
중간 연산 메서드 | 설명 | 예제 코드 |
filter(Predicate) | 조건에 맞는 요소만 선택 | stream.filter(x -> x > 10) |
map(Function) | 각 요소를 다른 값으로 1:1 변환 | stream.map(String::length) |
flatMap(Function) | 각 요소를 스트림으로 변환한 후, 결과 스트림들을 하나로 평탄화 | stream.flatMap(x -> x.stream()) |
distinct() | 중복된 요소를 제거 | stream.distinct() |
sorted() | 자연 순서 또는 제공된 Comparator 기준으로 정렬 | stream.sorted() 또는 stream.sorted(Comparator.naturalOrder()) |
peek(Consumer) | 스트림의 요소를 소비하지 않고 중간에서 확인용으로 처리 | stream.peek(System.out::println) |
limit(long n) | 상위 n개의 요소만 선택 | stream.limit(5) |
skip(long n) | 처음 n개의 요소를 건너뜀 | stream.skip(3) |
-최종 연산-
최종 연산 메서드 | 설명 | 예제 코드 |
forEach(Consumer) | 각 요소에 대해 지정한 작업을 수행 (순서 보장이 없는 경우가 있음) | stream.forEach(System.out::println) |
forEachOrdered(Consumer) | 순차 스트림에서 요소를 순서대로 처리 | stream.forEachOrdered(System.out::println) |
toArray() | 스트림의 요소들을 배열로 변환 | stream.toArray() |
reduce(BinaryOperator) | 스트림 요소를 단일 값으로 축소 (예: 합계, 곱, 최대/최소 등) | stream.reduce(0, Integer::sum) |
collect(Collector) | 스트림의 요소들을 컬렉션(List, Set, 등) 또는 다른 컨테이너로 수집 | stream.collect(Collectors.toList()) |
min(Comparator) | 스트림 요소 중 최소값을 선택 | stream.min(Comparator.naturalOrder()) |
max(Comparator) | 스트림 요소 중 최대값을 선택 | stream.max(Comparator.naturalOrder()) |
count() | 스트림의 요소 개수를 반환 | stream.count() |
anyMatch(Predicate) | 조건을 만족하는 요소가 하나라도 있는지 확인 | stream.anyMatch(x -> x > 10) |
allMatch(Predicate) | 모든 요소가 조건을 만족하는지 확인 | stream.allMatch(x -> x > 10) |
noneMatch(Predicate) | 어떤 요소도 조건을 만족하지 않는지 확인 | stream.noneMatch(x -> x > 10) |
findFirst() | 첫 번째 요소를 반환 (존재하면 Optional로 감싸서 반환) | stream.findFirst() |
findAny() | 임의의 요소를 반환 (병렬 스트림에서 유용) | stream.findAny() |
# 04. 스트림 잘 사용하기
스트림 패러다임의 핵심은 데이터를 선언적으로 변환하고 집계하는 것입니다.
때문에 스트림이 연산 과정에서 외부 상태를 수정하고자 한다면, 이는 스트림을 잘 사용했다고 볼 수 없습니다.
먼저 스트림을 잘못 사용하는 코드를 보겠습니다.
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
words.forEach(word -> {
freq.merge(word.toLowerCase(), 1L, Long::sum);
});
}
위 코드는 각 단어마다 반복하면서 매번 단어를 소문자로 바꾸고 map에 업데이트를 수행합니다.
문제는 스트림 내부에서 외부 가변 객체 freq을 직접 수정한다는 점입니다.
이는 함수형 프로그래밍의 개념이 적용되어 사이드 이펙트에 유리하다는 스트림의 장점을 전혀 활용하지 못한 것이며,
컬렉션을 반복문을 사용해서 순회하는 코드와 다를 것이 없다고 볼 수 있습니다.
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()) {
freq = words.collect(groupingBy(String::toLowerCase, counting()));
}
반면 위 코드는 어떤 작업을 수행할 것인지 표현만 하고 집계를 Collector가 대신 수행합니다.
스트림 파이프라인 내부에서 각 단어가 소문자로 변환되고, 같은 단어끼리 그룹화되어 카운트가 누적된 최종 결과가 map으로 한 번에 만들어지는 것입니다.
freq을 조작하는 동작을 선언만 해놓고 최종 연산이 끝난 후 freq을 업데이트하기 때문에 스트림 패러다임의 장점을 잘 활용했다고 볼 수 있습니다.
Stream은 평소에도 자주 사용하던 api입니다.
특히 객체를 다른 계층에서 사용하는 객체로 변환할 때 거의 필수적으로 사용하곤 했는데, 제가 코드를 작성할 때마다 얼마나 이해 없이 기계적으로 두들겨 왔는지 느껴지게 됩니다.
-Ref-
Java의 정석 3rd Edition - 남궁성
Effective Java 3/E - Joshua Bloch
Oracle Help Center - Stream (Java Platform SE 8)
'Java' 카테고리의 다른 글
JVM 메모리 구조와 동작 과정 파헤쳐보기! (0) | 2025.06.04 |
---|