스트림이란?
스트림(stream)은 자바 8 API에 새로 추가된 기능이다. 스트림을 이용하면 선언형(즉, 데이터를 처리하는 임시 구현 코드 대신 질의로 표현할 수 있다.)으로 컬렉션 데이터를 처리할 수 있다.
스트림을 왜 사용하는가?
다음과 같은 이유에서 스트림을 사용한다.
- 간결함
- 병렬 처리시 성능 향상
간결하다.
스트림을 사용하게 되면 코드가 간결해진다.
- 스트림을 사용하지 않은 연산
import java.util.ArrayList;
import java.util.List;
public class TraditionalExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> squares = new ArrayList<>();
for (Integer number : numbers) {
if (number % 2 == 0) {
squares.add(number * number);
}
}
System.out.println(squares); // 출력: [4, 16, 36]
}
}
- 스트림을 사용한 연산
import java.util.List;
import java.util.stream.Collectors;
public class StreamExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
List<Integer> squares = numbers.stream()
.filter(number -> number % 2 == 0) // 짝수만 필터링
.map(number -> number * number) // 제곱
.collect(Collectors.toList()); // 리스트로 수집
System.out.println(squares); // 출력: [4, 16, 36]
}
}
스트림을 사용하면 데이터 처리의 흐름이 간결해지고, 메서드 체이닝으로 연결되어 직관적으로 이해할 수 있다.
병렬 처리시 성능 향상
- 기존의 방법(병렬 처리 없음)
import java.util.List;
public class TraditionalExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
long startTime = System.currentTimeMillis();
for (Integer number : numbers) {
// 각 숫자의 제곱을 계산 (가상의 작업을 위해 sleep)
try {
Thread.sleep(100); // 가상의 작업 시간
} catch (InterruptedException e) {
e.printStackTrace();
}
int square = number * number;
System.out.println(square);
}
long endTime = System.currentTimeMillis();
System.out.println("소요 시간: " + (endTime - startTime) + "ms");
}
}
- 스트림을 활용한 병렬 연산
import java.util.List;
import java.util.stream.Collectors;
public class ParallelStreamExample {
public static void main(String[] args) {
List<Integer> numbers = List.of(1, 2, 3, 4, 5, 6);
long startTime = System.currentTimeMillis();
List<Integer> squares = numbers.parallelStream() // 병렬 스트림 생성
.filter(number -> number % 2 == 0) // 짝수 필터링
.map(number -> {
try {
Thread.sleep(100); // 가상의 작업 시간
} catch (InterruptedException e) {
e.printStackTrace();
}
return number * number; // 제곱
})
.collect(Collectors.toList()); // 리스트로 수집
squares.forEach(System.out::println);
long endTime = System.currentTimeMillis();
System.out.println("소요 시간: " + (endTime - startTime) + "ms");
}
}
스트림의 parallelStream()
을 사용하면 여러 스레드에서 동시에 작업을 수행하므로, CPU의 멀티코어 성능을 활용할 수 있다. 사실 이 부분은 내가 아직 병렬처리를 필요로 하는 상황에 있지 못하고, 경험이 부족해서 크게 와닫지 않는다.(아마 최소 자바 5년차 이상은 되야 100% 스트림을 활용할 수 있지 않을까 생각한다…)
스트림 파이프라인
스트림에서 파이프라인(pipeline)은 여러 개의 데이터 처리 단계를 연결하여 데이터를 처리하는 방법을 의미한다. 각 단계는 입력 데이터 스트림을 받아서 처리한 후, 다음 단계로 결과를 전달하는 구조이다.
스트림 파이프라인의 구성 요소
스트림 파이프라인의 구성 요소에는 크게 3가지가 있다.
소스(Stream Source) 는 데이터 스트림의 시작점으로, 일반적으로 컬렉션, 배열, 파일, 또는 I/O 채널이 될 수 있다. 예를 들어, List
, Set
, 또는 Arrays.stream()
등을 사용하여 소스를 생성한다.
중간 연산(Intermediate Operations) 은 데이터 스트림에 대한 변환 및 필터링을 수행하는 단계이다. 중간 연산은 새로운 스트림을 반환하며, 연산은 지연(lazy) 처리되어 최종 연산이 호출될 때까지 실행되지 않는다. 예를 들어, filter()
, map()
, distinct()
, sorted()
등이 있다.
최종 연산(Terminal Operations) 은 스트림 파이프라인의 종료 단계로, 결과를 생성하거나 부수 효과(side effect)를 발생시키는 연산이다. 최종 연산이 호출되면 모든 중간 연산이 실행된다. 예를 들어, collect()
, forEach()
, count()
, reduce()
등이 있다.
중간 연산(Intermediate Operations)
중간 연산은 스트림을 다른 스트림으로 변환하는 연산이다. 중간 연산의 특징은 지연(lazy) 연산이라는 점이다. 즉, 중간 연산을 호출하는 것만으로는 연산이 수행되지 않고, 최종 연산이 호출되어야 실제로 연산이 이루어진다.
주요 중간 연산의 종류는 다음과 같다.
filter()
: 조건에 맞는 요소만 필터링
List<String> names = List.of("김철수", "이영희", "박민수", "김영호");
names.stream()
.filter(name -> name.startsWith("김"))
.forEach(System.out::println); // 출력: 김철수, 김영호
map()
: 각 요소를 다른 형태로 변환
List<String> names = List.of("kim", "lee", "park");
names.stream()
.map(String::toUpperCase)
.forEach(System.out::println); // 출력: KIM, LEE, PARK
distinct()
: 중복 제거
List<Integer> numbers = List.of(1, 2, 2, 3, 3, 4);
numbers.stream()
.distinct()
.forEach(System.out::println); // 출력: 1, 2, 3, 4
sorted()
: 정렬
List<Integer> numbers = List.of(5, 2, 8, 1, 9);
numbers.stream()
.sorted()
.forEach(System.out::println); // 출력: 1, 2, 5, 8, 9
최종 연산(Terminal Operations)
최종 연산은 스트림 파이프라인에서 결과를 도출하는 마지막 단계이다. 최종 연산이 수행되면 스트림은 소비되어 더 이상 사용할 수 없게 된다.
주요 최종 연산의 종류는 다음과 같다.
collect()
: 스트림의 요소들을 컬렉션으로 변환
List<String> names = List.of("kim", "lee", "park");
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList()); // 결과: [KIM, LEE, PARK]
forEach()
: 각 요소에 대해 특정 작업 수행
List<Integer> numbers = List.of(1, 2, 3);
numbers.stream()
.forEach(num -> System.out.println("숫자: " + num));
reduce()
: 스트림의 요소들을 하나의 결과로 줄임
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b); // 결과: 15
count()
,min()
,max()
: 요소 개수 또는 최소/최대값 찾기
List<Integer> numbers = List.of(1, 2, 3, 4, 5);
long count = numbers.stream().count(); // 결과: 5
Optional<Integer> min = numbers.stream().min(Integer::compareTo); // 결과: 1
Optional<Integer> max = numbers.stream().max(Integer::compareTo); // 결과: 5
최종 연산은 스트림 파이프라인의 실행을 트리거하는 역할을 한다. 따라서 스트림에서 실제 결과를 얻기 위해서는 반드시 최종 연산이 필요하다. 이는 마치 SQL 쿼리를 실행하는 것과 비슷한데, 쿼리를 작성만 하고 실행하지 않으면 결과를 얻을 수 없는 것과 같은 원리이다.
스트림의 실용적인 활용
실무에서 자주 사용되는 스트림 활용 예시를 살펴보자.
객체 리스트 처리
실제 개발에서는 주로 객체 리스트를 다루게 된다. 다음은 사용자 객체 리스트를 처리하는 예시이다.
class User {
private String name;
private int age;
private String role;
// 생성자, getter, setter 생략
}
List<User> users = List.of(
new User("김철수", 25, "ADMIN"),
new User("이영희", 30, "USER"),
new User("박민수", 28, "USER")
);
// 나이가 25세 이상인 일반 사용자의 이름 목록 조회
List<String> userNames = users.stream()
.filter(user -> user.getAge() >= 25)
.filter(user -> "USER".equals(user.getRole()))
.map(User::getName)
.collect(Collectors.toList());
그룹화와 통계
Collectors
클래스는 매우 유용한 그룹화와 통계 기능을 제공한다.
// 역할별 사용자 그룹화
Map<String, List<User>> usersByRole = users.stream()
.collect(Collectors.groupingBy(User::getRole));
// 역할별 평균 나이 계산
Map<String, Double> averageAgeByRole = users.stream()
.collect(Collectors.groupingBy(
User::getRole,
Collectors.averagingInt(User::getAge)
));
복잡한 조건의 필터링
실무에서는 여러 조건을 조합해야 하는 경우가 많다.
// Predicate 조합
Predicate<User> isAdult = user -> user.getAge() >= 20;
Predicate<User> isAdmin = user -> "ADMIN".equals(user.getRole());
List<User> adultAdmins = users.stream()
.filter(isAdult.and(isAdmin))
.collect(Collectors.toList());
스트림 사용 시 주의사항
스트림은 강력한 기능이지만, 잘못 사용하면 오히려 성능이 저하되거나 가독성이 떨어질 수 있다.
1. 스트림 재사용 불가
스트림은 한 번 사용하면 재사용할 수 없다. 이는 스트림의 내부 설계와 관련이 있다.
Stream<String> stream = names.stream();
stream.forEach(System.out::println);
// 아래 코드는 IllegalStateException 발생
stream.forEach(System.out::println); // 에러!
재사용이 불가능한 이유
- 일회성 소비(One-time Consumption)
- 스트림은 데이터 소스를 ‘소비’하는 방식으로 동작한다.
- 마치 실제 물이 흐르는 것처럼, 한 번 지나간 데이터는 다시 되돌릴 수 없다.
- 내부 상태 관리
- 스트림은 내부적으로 소비된 상태를 관리한다.
- 최종 연산이 수행되면 스트림은 ‘closed’ 상태가 되어 더 이상 사용할 수 없다.
해결 방법
재사용이 필요한 경우, 새로운 스트림을 생성해야 한다.
List<String> names = List.of("김철수", "이영희", "박민수");
// 올바른 방법
names.stream().forEach(System.out::println);
names.stream().forEach(System.out::println); // 새로운 스트림 생성
// 또는 공통 로직이 있다면 메서드로 분리
public void processNames(List<String> names) {
names.stream()
.filter(name -> name.startsWith("김"))
.forEach(System.out::println);
}
이러한 설계는 스트림의 안전성과 예측 가능성을 보장하기 위한 것이다. 만약 스트림이 재사용 가능했다면, 이전 연산의 결과가 다음 연산에 영향을 미칠 수 있고, 이는 예기치 않은 버그를 발생시킬 수 있다.
2. 과도한 체이닝은 피하기
너무 많은 중간 연산을 체이닝하면 가독성이 떨어진다.
// 안 좋은 예
users.stream()
.filter(u -> u.getAge() > 20)
.filter(u -> "USER".equals(u.getRole()))
.map(User::getName)
.map(String::toUpperCase)
.map(name -> name + "_PROCESSED")
.collect(Collectors.toList());
// 좋은 예
Predicate<User> isAdult = u -> u.getAge() > 20;
Predicate<User> isUser = u -> "USER".equals(u.getRole());
users.stream()
.filter(isAdult.and(isUser))
.map(user -> user.getName().toUpperCase() + "_PROCESSED")
.collect(Collectors.toList());
앞으로, 스트림의 개념은 여기에 정리해놓을 예정이다. 그 외에 실용적인 용도로 사용되는 건 새로 포스팅하려 한다.
출처
- 모던 자바인 액션