Ch 14.2 스트림의 중간 연산 (Intermediate Operations)
중간 연산은 스트림 파이프라인을 구성하며, 어떠한 가공 로직을 적용할지를 명시합니다. 중간 연산의 결과는 항상 스트림을 반환 하므로, 다른 중간 연산 메서드들을 체이닝(Chaining)하여 여러 번 연결할 수 있습니다.
중간 연산은 최종 연산이 호출될 때까지 실제로 실행되지 않는 게으른(Lazy) 연산 입니다.
1. filter() - 조건 필터링
Predicate<T>를 받아 조건이 true인 요소만 통과시킵니다.
import java.util.*;
import java.util.stream.*;
public class FilterExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 짝수만 필터링
numbers.stream()
.filter(n -> n % 2 == 0)
.forEach(n -> System.out.print(n + " ")); // 2 4 6 8 10
System.out.println();
// 여러 조건 조합 (&&, ||, !)
List<String> words = Arrays.asList("Java", "Python", "Go", "JavaScript", "Kotlin", "Rust");
words.stream()
.filter(w -> w.length() >= 4) // 4글자 이상
.filter(w -> w.contains("a")) // 'a' 포함
.filter(w -> !w.startsWith("J")) // J로 시작하지 않음
.forEach(System.out::println); // Python, Kotlin
}
}
// 객체 스트림에서의 filter 활용
record Product(String name, String category, int price, boolean inStock) {}
public class FilterObjectExample {
public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product("노트북", "전자제품", 1_200_000, true),
new Product("마우스", "전자제품", 35_000, true),
new Product("키보드", "전자제품", 80_000, false),
new Product("책상", "가구", 250_000, true),
new Product("의자", "가구", 180_000, true)
);
// 재고 있고 전자제품이며 10만원 이하인 상품
products.stream()
.filter(Product::inStock)
.filter(p -> p.category().equals("전자제품"))
.filter(p -> p.price() <= 100_000)
.forEach(p -> System.out.println(p.name() + ": " + p.price() + "원"));
// 마우스: 35000원
}
}
2. map() - 변환
Function<T, R>을 받아 각 요소를 다른 값으로 변환합니다.
import java.util.*;
import java.util.stream.*;
public class MapExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "cherry");
// 문자열 대문자 변환
words.stream()
.map(String::toUpperCase)
.forEach(System.out::println); // APPLE, BANANA, CHERRY
// 문자열 → 길이 변환 (타입 변환)
words.stream()
.map(String::length)
.forEach(n -> System.out.print(n + " ")); // 5 6 6
System.out.println();
// 객체에서 특정 필드 추출
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 이름의 첫 글자만 추출
names.stream()
.map(name -> name.charAt(0))
.forEach(c -> System.out.print(c + " ")); // A B C
System.out.println();
// 숫자 제곱
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
numbers.stream()
.map(n -> n * n)
.forEach(n -> System.out.print(n + " ")); // 1 4 9 16 25
System.out.println();
}
}
mapToInt, mapToDouble, mapToLong
객체 스트림을 기본형 스트림으로 변환합니다. 이후 sum(), average() 등 통계 메서드를 사용할 수 있습니다.
record Employee(String name, double salary) {}
public class MapToIntExample {
public static void main(String[] args) {
List<Employee> employees = Arrays.asList(
new Employee("Alice", 5_500_000),
new Employee("Bob", 4_200_000),
new Employee("Charlie", 6_800_000)
);
// Stream<Employee> → DoubleStream
double totalSalary = employees.stream()
.mapToDouble(Employee::salary)
.sum();
System.out.printf("총 급여: %,.0f원%n", totalSalary);
double avgSalary = employees.stream()
.mapToDouble(Employee::salary)
.average()
.orElse(0.0);
System.out.printf("평균 급여: %,.0f원%n", avgSalary);
}
}
3. flatMap() - 중첩 컬렉션 펼치기
중첩된 컬렉션 구조를 단일 스트림으로 펼칩니다.
import java.util.*;
import java.util.stream.*;
public class FlatMapExample {
public static void main(String[] args) {
// 2중 리스트를 단일 스트림으로
List<List<Integer>> nestedNumbers = Arrays.asList(
Arrays.asList(1, 2, 3),
Arrays.asList(4, 5),
Arrays.asList(6, 7, 8, 9)
);
nestedNumbers.stream()
.flatMap(Collection::stream) // 각 리스트를 스트림으로 변환 후 합침
.forEach(n -> System.out.print(n + " ")); // 1 2 3 4 5 6 7 8 9
System.out.println();
// 문자열 배열 → 단어로 분리
List<String> sentences = Arrays.asList(
"Hello World",
"Java Stream API",
"FlatMap Example"
);
sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.map(String::toLowerCase)
.distinct()
.sorted()
.forEach(word -> System.out.print(word + " "));
System.out.println();
}
}
// map vs flatMap 비교
public class MapVsFlatMap {
public static void main(String[] args) {
List<String> words = Arrays.asList("Hello", "World");
// map: Stream<Stream<String>> (원하지 않는 결과)
// 각 단어를 문자 배열 스트림으로 변환하면 중첩 스트림이 됨
Stream<Stream<String>> mapResult = words.stream()
.map(word -> Arrays.stream(word.split("")));
System.out.println("map 결과 타입: Stream<Stream<String>>");
// flatMap: Stream<String> (원하는 결과 - 모든 문자가 하나의 스트림으로)
words.stream()
.flatMap(word -> Arrays.stream(word.split("")))
.distinct()
.sorted()
.forEach(c -> System.out.print(c + " ")); // H W d e l o r
System.out.println();
}
}
4. distinct() - 중복 제거
public class DistinctExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5, 1);
numbers.stream()
.distinct()
.forEach(n -> System.out.print(n + " ")); // 1 2 3 4 5
System.out.println();
// 객체의 경우 equals()와 hashCode()가 올바르게 구현되어야 함
// record 는 자동으로 equals/hashCode 구현
record Point(int x, int y) {}
List<Point> points = Arrays.asList(
new Point(1, 2), new Point(3, 4), new Point(1, 2), new Point(5, 6)
);
points.stream()
.distinct()
.forEach(p -> System.out.print(p + " ")); // Point[1,2] Point[3,4] Point[5,6]
System.out.println();
}
}
5. sorted() - 정렬
import java.util.*;
import java.util.stream.*;
public class SortedExample {
public static void main(String[] args) {
// 기본 정렬 (Comparable 구현 필요)
List<Integer> numbers = Arrays.asList(5, 3, 8, 1, 9, 2, 7);
numbers.stream()
.sorted()
.forEach(n -> System.out.print(n + " ")); // 1 2 3 5 7 8 9
System.out.println();
// 역순 정렬
numbers.stream()
.sorted(Comparator.reverseOrder())
.forEach(n -> System.out.print(n + " ")); // 9 8 7 5 3 2 1
System.out.println();
// 객체 정렬 (Comparator 사용)
record Person(String name, int age) {}
List<Person> people = Arrays.asList(
new Person("Charlie", 25),
new Person("Alice", 30),
new Person("Bob", 20),
new Person("David", 25)
);
// 나이 오름차순 정렬
people.stream()
.sorted(Comparator.comparingInt(Person::age))
.forEach(p -> System.out.println(p.name() + ": " + p.age()));
System.out.println();
// 나이 오름차순 → 같으면 이름 오름차순 (다중 정렬 기준)
people.stream()
.sorted(Comparator.comparingInt(Person::age)
.thenComparing(Person::name))
.forEach(p -> System.out.println(p.name() + ": " + p.age()));
}
}
6. limit() 와 skip() - 크기 제한과 건너뛰기
public class LimitSkipExample {
public static void main(String[] args) {
// limit: 최대 n개만 가져오기
Stream.iterate(1, n -> n + 1)
.limit(5)
.forEach(n -> System.out.print(n + " ")); // 1 2 3 4 5
System.out.println();
// skip: 처음 n개 건너뛰기
IntStream.rangeClosed(1, 10)
.skip(3)
.forEach(n -> System.out.print(n + " ")); // 4 5 6 7 8 9 10
System.out.println();
// 페이지네이션 구현 (페이지당 3개, 2페이지)
List<String> items = Arrays.asList("A", "B", "C", "D", "E", "F", "G", "H", "I");
int pageSize = 3;
int pageNum = 2; // 0-based
List<String> page = items.stream()
.skip((long) pageNum * pageSize) // 앞 페이지 건너뛰기
.limit(pageSize) // 현재 페이지 크기만큼
.collect(Collectors.toList());
System.out.println("2페이지: " + page); // [G, H, I]
}
}
7. peek() - 중간 디버깅
forEach와 비슷하지만 스트림을 반환하므로 중간에 삽입하여 디버깅용으로 사용합니다.
public class PeekExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);
List<Integer> result = numbers.stream()
.peek(n -> System.out.println("원본: " + n))
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println(" filter 통과: " + n))
.map(n -> n * 3)
.peek(n -> System.out.println(" map 결과: " + n))
.collect(Collectors.toList());
System.out.println("최종 결과: " + result);
}
}
경고
peek()은 디버깅 전용으로만 사용하세요. 부작용(side effect)을 일으키는 코드를 peek()에 넣으면 병렬 스트림에서 예측 불가능한 동작이 발생할 수 있습니다.
8. 연산 순서에 따른 성능 차이
중간 연산 순서는 성능에 영향을 줍니다.
import java.util.*;
import java.util.stream.*;
public class OperationOrderExample {
static int filterCount = 0;
static int mapCount = 0;
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
// 비효율적인 순서: map 먼저 → 10번 map 실행 후 filter
filterCount = 0; mapCount = 0;
numbers.stream()
.map(n -> { mapCount++; return n * 2; })
.filter(n -> { filterCount++; return n > 10; })
.collect(Collectors.toList());
System.out.println("map 먼저 - map 횟수: " + mapCount + ", filter 횟수: " + filterCount);
// map: 10, filter: 10
// 효율적인 순서: filter 먼저 → 통과한 요소만 map
filterCount = 0; mapCount = 0;
numbers.stream()
.filter(n -> { filterCount++; return n > 5; })
.map(n -> { mapCount++; return n * 2; })
.collect(Collectors.toList());
System.out.println("filter 먼저 - filter 횟수: " + filterCount + ", map 횟수: " + mapCount);
// filter: 10, map: 5 (절반만 map 실행)
}
}
팁
성능 최적화 원칙:
filter를 앞에 배치하여 처리 대상 수를 줄이세요.- 비용이 큰 연산(
map,sort)은 최대한 뒤에 배치하세요. limit+findFirst/findAny를 함께 쓰면 단락 평가(Short-circuit)로 모든 요소를 처리하지 않습니다.
9. 실전 예제: 상품 목록 필터링/변환/정렬 파이프라인
import java.util.*;
import java.util.stream.*;
record Product(String name, String category, int price, double rating, boolean inStock) {}
public class ProductPipelineExample {
public static void main(String[] args) {
List<Product> products = Arrays.asList(
new Product("노트북 Pro", "전자제품", 1_800_000, 4.8, true),
new Product("무선 마우스", "전자제품", 45_000, 4.5, true),
new Product("USB 허브", "전자제품", 28_000, 4.2, false),
new Product("인체공학 의자", "가구", 450_000, 4.9, true),
new Product("스탠딩 책상", "가구", 680_000, 4.7, true),
new Product("모니터 27인치", "전자제품", 520_000, 4.6, true),
new Product("키보드", "전자제품", 150_000, 4.3, true),
new Product("웹캠", "전자제품", 85_000, 4.1, false)
);
System.out.println("=== 재고 있는 전자제품 (가격 10~200만원, 평점 4.4+, 가격 오름차순) ===");
products.stream()
.filter(Product::inStock) // 재고 있음
.filter(p -> p.category().equals("전자제품")) // 전자제품
.filter(p -> p.price() >= 100_000 && p.price() <= 2_000_000) // 가격 범위
.filter(p -> p.rating() >= 4.4) // 평점 4.4 이상
.sorted(Comparator.comparingInt(Product::price)) // 가격 오름차순
.map(p -> String.format("%-15s | %,8d원 | ★%.1f",
p.name(), p.price(), p.rating()))
.forEach(System.out::println);
System.out.println("\n=== 카테고리별 상품명 목록 ===");
Map<String, List<String>> byCategory = products.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.mapping(Product::name, Collectors.toList())
));
byCategory.forEach((cat, names) ->
System.out.println(cat + ": " + names));
System.out.println("\n=== 상위 3개 고가 상품 이름 ===");
products.stream()
.sorted(Comparator.comparingInt(Product::price).reversed())
.limit(3)
.map(Product::name)
.forEach(System.out::println);
}
}