본문으로 건너뛰기

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 실행)
}
}

성능 최적화 원칙:

  1. filter를 앞에 배치하여 처리 대상 수를 줄이세요.
  2. 비용이 큰 연산(map, sort)은 최대한 뒤에 배치하세요.
  3. 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);
}
}