본문으로 건너뛰기

Ch 14.1 스트림(Stream) 개요

자바 8부터 컬렉션(배열, List, Set, Map 등)의 요소를 하나씩 참조하며 함수형 인터페이스(람다식)를 적용해 반복적으로 처리할 수 있도록 해주는 스트림(Stream) 기능이 도입되었습니다.

스트림은 기존의 for문이나 Iterator를 사용하던 방식의 복잡성과 보일러플레이트 코드를 획기적으로 줄이고, 데이터를 데이터베이스 쿼리처럼 선언적으로 가공할 수 있게 해줍니다.

1. 컬렉션 vs 스트림

import java.util.*;
import java.util.stream.*;

public class CollectionVsStreamExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(5, 3, 8, 1, 9, 2, 7, 4, 6);

// 기존 방식: 명령형 (How에 집중)
List<Integer> result1 = new ArrayList<>();
for (int n : numbers) {
if (n > 5) {
result1.add(n * 2);
}
}
Collections.sort(result1);
System.out.println("기존 방식: " + result1);

// 스트림 방식: 선언형 (What에 집중)
List<Integer> result2 = numbers.stream()
.filter(n -> n > 5)
.map(n -> n * 2)
.sorted()
.collect(Collectors.toList());
System.out.println("스트림 방식: " + result2);
}
}
구분컬렉션스트림
목적데이터 저장 및 관리데이터 처리 파이프라인
반복 방식외부 반복 (for, iterator)내부 반복 (자동)
데이터 수정가능 (add, remove 등)불가 (원본 변경 없음)
재사용가능불가 (1회용)
지연 연산없음있음 (Lazy)

2. 스트림의 3단계 파이프라인

스트림 연산은 파이프에 물이 흐르는 것처럼 여러 단계를 거쳐 처리됩니다.

소스(Source) → 중간 연산(Intermediate) → 최종 연산(Terminal)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

long count = names.stream() // 1. 소스: 스트림 생성
.filter(n -> n.length() > 3) // 2. 중간 연산: 필터링
.map(String::toUpperCase) // 2. 중간 연산: 변환
.count(); // 3. 최종 연산: 개수 세기

System.out.println("글자수 3 초과 이름 수: " + count); // 3

3. 지연 연산 (Lazy Evaluation)

스트림의 가장 중요한 특징입니다. 최종 연산이 호출되기 전까지는 실제로 어떤 중간 연산도 수행되지 않습니다.

import java.util.stream.Stream;

public class LazyEvaluationExample {
public static void main(String[] args) {
System.out.println("스트림 파이프라인 구성 중...");

Stream<Integer> stream = Stream.of(1, 2, 3, 4, 5)
.filter(n -> {
System.out.println(" filter 실행: " + n);
return n > 2;
})
.map(n -> {
System.out.println(" map 실행: " + n);
return n * 10;
});

System.out.println("최종 연산 전까지 아무것도 실행되지 않음!");

System.out.println("\n--- 최종 연산 호출 ---");
stream.forEach(n -> System.out.println("결과: " + n));
}
}

출력 결과:

스트림 파이프라인 구성 중...
최종 연산 전까지 아무것도 실행되지 않음!

--- 최종 연산 호출 ---
filter 실행: 1
filter 실행: 2
filter 실행: 3
map 실행: 3
결과: 30
filter 실행: 4
map 실행: 4
결과: 40
filter 실행: 5
map 실행: 5
결과: 50
노트

지연 연산 덕분에 filterfalse를 반환한 요소는 map이 실행되지 않습니다. 요소별로 파이프라인을 통과하며, 불필요한 계산을 피합니다.

4. 스트림 소스 만들기

컬렉션과 배열에서

import java.util.*;
import java.util.stream.*;

public class StreamSourceExample {
public static void main(String[] args) {
// 1. List에서 스트림 생성
List<String> list = Arrays.asList("Java", "Python", "Go");
Stream<String> listStream = list.stream();
listStream.forEach(System.out::println);

// 2. 배열에서 스트림 생성
String[] arr = {"A", "B", "C"};
Stream<String> arrayStream = Arrays.stream(arr);

// 배열의 일부만 스트림으로
Stream<String> partialStream = Arrays.stream(arr, 1, 3); // index 1~2

// 3. Stream.of() 직접 생성
Stream<Integer> directStream = Stream.of(1, 2, 3, 4, 5);
directStream.forEach(n -> System.out.print(n + " "));
System.out.println();

// 4. 빈 스트림
Stream<String> emptyStream = Stream.empty();
System.out.println("빈 스트림 크기: " + emptyStream.count()); // 0
}
}

무한 스트림 생성

import java.util.stream.*;

public class InfiniteStreamExample {
public static void main(String[] args) {
// Stream.iterate(): 이전 값에서 다음 값 생성
System.out.println("피보나치 수열 (10개):");
Stream.iterate(new long[]{0, 1}, f -> new long[]{f[1], f[0] + f[1]})
.limit(10)
.map(f -> f[0])
.forEach(n -> System.out.print(n + " "));
System.out.println();

// Stream.generate(): 매번 새로운 값 생성 (공급자)
System.out.println("랜덤 5개:");
Stream.generate(Math::random)
.limit(5)
.map(n -> String.format("%.3f", n))
.forEach(n -> System.out.print(n + " "));
System.out.println();

// Java 9+ Stream.iterate with predicate (종료 조건 추가)
System.out.println("2의 거듭제곱 (1000 미만):");
Stream.iterate(1, n -> n < 1000, n -> n * 2)
.forEach(n -> System.out.print(n + " "));
System.out.println();
}
}

5. 기본형 스트림 (Primitive Streams)

제네릭 Stream<Integer> 대신 기본형 스트림을 사용하면 오토박싱/언박싱 비용을 제거합니다.

import java.util.stream.*;

public class PrimitiveStreamExample {
public static void main(String[] args) {
// IntStream: int 전용 스트림
IntStream intStream = IntStream.range(1, 6); // 1, 2, 3, 4, 5
IntStream closedStream = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5

// 합계, 평균, 최댓값, 최솟값
IntStream.rangeClosed(1, 10).sum(); // 55
IntStream.rangeClosed(1, 10).average(); // OptionalDouble[5.5]
IntStream.rangeClosed(1, 10).max(); // OptionalInt[10]
IntStream.rangeClosed(1, 10).min(); // OptionalInt[1]

// LongStream, DoubleStream도 동일
LongStream longStream = LongStream.rangeClosed(1L, 1_000_000L);
System.out.println("1~100만 합: " + longStream.sum()); // 500000500000

// 기본형 스트림 ↔ 객체 스트림 변환
IntStream intS = IntStream.range(1, 4);
Stream<Integer> boxed = intS.boxed(); // int → Integer
Stream<String> stringS = IntStream.range(1, 4).mapToObj(i -> "항목-" + i);

// Stream<Integer>에서 IntStream으로
Stream<Integer> objStream = Stream.of(1, 2, 3, 4, 5);
int sum = objStream.mapToInt(Integer::intValue).sum();
System.out.println("합계: " + sum);
}
}

6. 스트림의 특징 요약

일회성 (One-shot)

Stream<String> stream = Stream.of("A", "B", "C");
stream.forEach(System.out::println); // 정상 실행
// stream.forEach(System.out::println); // IllegalStateException! 스트림 재사용 불가

원본 불변

List<String> original = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> upperCase = original.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());

System.out.println("원본: " + original); // [a, b, c] (변경 없음)
System.out.println("변환: " + upperCase); // [A, B, C]

7. 실전 예제: 학생 리스트 평균 점수 구하기

import java.util.*;
import java.util.stream.*;

class Student {
private String name;
private String major;
private int score;
private int grade;

public Student(String name, String major, int score, int grade) {
this.name = name;
this.major = major;
this.score = score;
this.grade = grade;
}

public String getName() { return name; }
public String getMajor() { return major; }
public int getScore() { return score; }
public int getGrade() { return grade; }

@Override
public String toString() {
return String.format("Student{%s, %s, %d점, %d학년}",
name, major, score, grade);
}
}

public class StudentStreamExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("Alice", "컴퓨터공학", 95, 3),
new Student("Bob", "수학", 78, 2),
new Student("Charlie", "컴퓨터공학", 88, 1),
new Student("David", "물리학", 65, 4),
new Student("Eve", "컴퓨터공학", 92, 2),
new Student("Frank", "수학", 83, 3),
new Student("Grace", "물리학", 71, 1)
);

// 전체 평균 점수
OptionalDouble avgScore = students.stream()
.mapToInt(Student::getScore)
.average();
System.out.printf("전체 평균: %.1f점%n", avgScore.getAsDouble());

// 컴퓨터공학 학생의 평균
double csAvg = students.stream()
.filter(s -> s.getMajor().equals("컴퓨터공학"))
.mapToInt(Student::getScore)
.average()
.orElse(0.0);
System.out.printf("컴퓨터공학 평균: %.1f점%n", csAvg);

// 80점 이상 학생 이름 목록 (정렬)
List<String> highScorers = students.stream()
.filter(s -> s.getScore() >= 80)
.sorted(Comparator.comparingInt(Student::getScore).reversed())
.map(Student::getName)
.collect(Collectors.toList());
System.out.println("80점 이상 (내림차순): " + highScorers);

// 학년별 최고 점수
Map<Integer, Optional<Student>> topByGrade = students.stream()
.collect(Collectors.groupingBy(
Student::getGrade,
Collectors.maxBy(Comparator.comparingInt(Student::getScore))
));
topByGrade.forEach((grade, top) ->
System.out.printf("%d학년 최고: %s%n", grade, top.map(Student::getName).orElse("없음"))
);
}
}

고수 팁: 스트림은 내부적으로 Spliterator를 사용합니다. 병렬 스트림(parallelStream())을 사용하면 멀티코어 CPU를 활용한 병렬 처리가 가능하지만, 공유 상태가 없고 데이터가 충분히 많을 때만 성능 향상이 있습니다. 무조건 parallelStream()이 빠르지는 않습니다.