| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | ||||
| 4 | 5 | 6 | 7 | 8 | 9 | 10 |
| 11 | 12 | 13 | 14 | 15 | 16 | 17 |
| 18 | 19 | 20 | 21 | 22 | 23 | 24 |
| 25 | 26 | 27 | 28 | 29 | 30 | 31 |
- 알고리즘공부
- 개발공부
- 개발자취업
- 자료구조
- 코딩테스트팁
- 객체지향
- 파이썬
- 자바프로그래밍
- 자바기초
- JVM
- 개발자팁
- Java
- 멀티스레드
- 예외처리
- 프로그래밍기초
- 클린코드
- 코딩인터뷰
- 알고리즘
- HashMap
- 코딩테스트
- 정렬
- 코딩테스트준비
- 자바
- 메모리관리
- 자바공부
- 코딩공부
- 가비지컬렉션
- 백준
- 자바개발
- 프로그래머스
- Today
- Total
코드 한 줄의 기록
Java 스트림 API 완벽 정리: 중간연산·최종연산·파이프라인 총정리 본문
Java를 사용하다 보면 컬렉션 데이터를 처리할 일이 정말 많다. 예전에는 for문이나 Iterator를 써서 하나씩 처리했는데, Java 8부터 도입된 Stream API를 알게 된 후로는 코드가 훨씬 간결해지고 읽기도 편해졌다. 오늘은 내가 공부하면서 정리한 내용을 바탕으로, Stream의 중간 연산과 최종 연산, 그리고 파이프라인이 어떻게 동작하는지 차근차근 설명해보려고 한다.
Stream API란?
Stream은 컬렉션, 배열 등의 데이터를 함수형 프로그래밍 방식으로 처리할 수 있게 해주는 도구다. 기존의 for문 방식과 비교하면 확실히 다른 점들이 보인다.
기존 방식 (for문)
List<String> names = Arrays.asList("홍길동", "김철수", "이영희", "박민수");
List<String> filteredNames = new ArrayList<>();
for (String name : names) {
if (name.length() == 3) {
filteredNames.add(name);
}
}Stream 방식
List<String> filteredNames = names.stream()
.filter(name -> name.length() == 3)
.collect(Collectors.toList());코드가 훨씬 간결해진 게 보이지? 이게 바로 Stream의 장점이다.
Stream의 주요 특징
- 원본 데이터 변경 없음: Stream은 원본 컬렉션을 건드리지 않는다. 항상 새로운 Stream을 반환한다.
- 일회용: 한 번 사용한 Stream은 재사용할 수 없다. 다시 사용하려면 새로 생성해야 한다.
- 지연 평가(Lazy Evaluation): 중간 연산은 즉시 실행되지 않고, 최종 연산이 호출될 때 비로소 실행된다. 이 부분이 Stream의 성능 최적화에서 핵심이다.
Stream 파이프라인의 구조
Stream을 사용할 때는 항상 세 단계를 거친다.
1단계: Stream 생성
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream();2단계: 중간 연산 (Intermediate Operations)
stream.filter(n -> n % 2 == 0)
.map(n -> n * 2)3단계: 최종 연산 (Terminal Operations)
.collect(Collectors.toList());이 세 단계가 연결되어 하나의 파이프라인을 구성한다. 중요한 건 최종 연산이 호출되기 전까지는 중간 연산이 실제로 수행되지 않는다는 점이다.
중간 연산 상세 분석
중간 연산은 Stream을 반환하기 때문에 여러 개를 체이닝해서 사용할 수 있다. 내가 자주 쓰는 중간 연산들을 하나씩 정리해봤다.
filter() - 조건 필터링
데이터 중에서 특정 조건에 맞는 것만 골라낼 때 사용한다.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
List<Integer> evenNumbers = numbers.stream()
.filter(n -> n % 2 == 0) // 짝수만 필터링
.collect(Collectors.toList());
// 결과: [2, 4, 6, 8, 10]실무에서는 주로 특정 조건을 만족하는 객체를 찾을 때 많이 쓴다.
List<User> activeUsers = users.stream()
.filter(user -> user.isActive())
.filter(user -> user.getAge() >= 18)
.collect(Collectors.toList());map() - 데이터 변환
각 요소를 다른 형태로 변환할 때 사용한다. 1:1 매핑이라고 생각하면 된다.
List<String> names = Arrays.asList("kim", "lee", "park");
List<String> upperNames = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// 결과: ["KIM", "LEE", "PARK"]객체의 특정 필드만 추출할 때도 유용하다.
List<String> userNames = users.stream()
.map(User::getName)
.collect(Collectors.toList());flatMap() - 중첩 구조 평탄화
중첩된 구조를 펼칠 때 사용한다. 처음에는 이해하기 어려웠는데, 예제를 보면 명확해진다.
List<List<String>> nestedList = Arrays.asList(
Arrays.asList("a", "b", "c"),
Arrays.asList("d", "e", "f"),
Arrays.asList("g", "h", "i")
);
List<String> flatList = nestedList.stream()
.flatMap(List::stream)
.collect(Collectors.toList());
// 결과: ["a", "b", "c", "d", "e", "f", "g", "h", "i"]실무에서는 주문 내역에서 모든 상품을 추출하는 등의 작업에 활용한다.
List<Item> allItems = orders.stream()
.flatMap(order -> order.getItems().stream())
.collect(Collectors.toList());distinct() - 중복 제거
중복된 요소를 제거할 때 사용한다.
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 5);
List<Integer> distinctNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
// 결과: [1, 2, 3, 4, 5]sorted() - 정렬
요소를 정렬할 때 사용한다. 기본 정렬 또는 커스텀 정렬이 가능하다.
// 기본 정렬 (오름차순)
List<Integer> sorted = numbers.stream()
.sorted()
.collect(Collectors.toList());
// 역순 정렬
List<Integer> reversed = numbers.stream()
.sorted(Comparator.reverseOrder())
.collect(Collectors.toList());
// 객체 정렬
List<User> sortedUsers = users.stream()
.sorted(Comparator.comparing(User::getAge))
.collect(Collectors.toList());limit()과 skip() - 개수 제한
데이터의 일부만 가져올 때 유용하다.
// 처음 5개만 가져오기
List<Integer> limited = numbers.stream()
.limit(5)
.collect(Collectors.toList());
// 처음 3개 건너뛰고 나머지 가져오기
List<Integer> skipped = numbers.stream()
.skip(3)
.collect(Collectors.toList());
// 페이징 구현
List<Item> page = items.stream()
.skip(page * pageSize)
.limit(pageSize)
.collect(Collectors.toList());peek() - 중간 확인
디버깅할 때 중간 결과를 확인하기 좋다.
List<Integer> result = numbers.stream()
.filter(n -> n % 2 == 0)
.peek(n -> System.out.println("필터 후: " + n))
.map(n -> n * 2)
.peek(n -> System.out.println("변환 후: " + n))
.collect(Collectors.toList());최종 연산 상세 분석
최종 연산은 Stream을 소비하고 결과를 반환한다. 최종 연산이 호출되어야 비로소 중간 연산들이 실행된다는 점이 중요하다.
forEach() - 각 요소에 작업 수행
가장 많이 쓰는 최종 연산 중 하나다.
numbers.stream()
.filter(n -> n % 2 == 0)
.forEach(n -> System.out.println(n));순서가 중요한 경우 forEachOrdered()를 사용한다.
numbers.parallelStream()
.forEachOrdered(System.out::println); // 순서 보장collect() - 결과 수집
Stream의 요소를 다양한 형태로 수집할 수 있다.
// List로 수집
List<String> list = stream.collect(Collectors.toList());
// Set으로 수집
Set<String> set = stream.collect(Collectors.toSet());
// Map으로 수집
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, user -> user));
// 문자열 결합
String joined = names.stream()
.collect(Collectors.joining(", "));
// 결과: "kim, lee, park"reduce() - 값 축소
요소들을 하나의 값으로 줄일 때 사용한다.
// 합계
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b);
// 최댓값
Optional<Integer> max = numbers.stream()
.reduce((a, b) -> a > b ? a : b);
// 문자열 연결
String concatenated = words.stream()
.reduce("", (a, b) -> a + b);조건 검사 메서드
조건을 만족하는지 확인할 때 유용하다.
// 모든 요소가 조건을 만족하는가?
boolean allEven = numbers.stream()
.allMatch(n -> n % 2 == 0);
// 하나라도 조건을 만족하는가?
boolean hasEven = numbers.stream()
.anyMatch(n -> n % 2 == 0);
// 모든 요소가 조건을 만족하지 않는가?
boolean noneNegative = numbers.stream()
.noneMatch(n -> n < 0);요소 찾기
// 첫 번째 요소 찾기
Optional<Integer> first = numbers.stream()
.filter(n -> n > 5)
.findFirst();
// 아무거나 하나 (병렬 스트림에서 유용)
Optional<Integer> any = numbers.parallelStream()
.filter(n -> n > 5)
.findAny();통계 연산
long count = numbers.stream().count();
OptionalInt max = numbers.stream().mapToInt(Integer::intValue).max();
OptionalInt min = numbers.stream().mapToInt(Integer::intValue).min();
double average = numbers.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
int sum = numbers.stream()
.mapToInt(Integer::intValue)
.sum();지연 평가(Lazy Evaluation)의 동작 원리
Stream의 가장 중요한 특징 중 하나가 지연 평가다. 이게 뭔지 예제로 확인해보자.
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream()
.filter(n -> {
System.out.println("filter: " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("map: " + n);
return n * 2;
});
System.out.println("Stream 생성 완료!");
// 여기까지는 아무것도 출력되지 않음
List<Integer> result = stream.collect(Collectors.toList());
// collect가 호출되는 순간 filter와 map이 실행됨이 코드를 실행하면 "Stream 생성 완료!"가 먼저 출력되고, 그 다음에 filter와 map의 출력이 나온다. 즉, 최종 연산이 호출되기 전까지는 중간 연산이 실제로 수행되지 않는다.
더 흥미로운 건 수직적 실행 방식이다.
numbers.stream()
.filter(n -> {
System.out.println("filter: " + n);
return n % 2 == 0;
})
.map(n -> {
System.out.println("map: " + n);
return n * 2;
})
.forEach(n -> System.out.println("result: " + n));
// 출력:
// filter: 1
// filter: 2
// map: 2
// result: 4
// filter: 3
// filter: 4
// map: 4
// result: 8
// filter: 5각 요소가 파이프라인 전체를 거쳐간다. 이런 방식 덕분에 불필요한 연산을 줄일 수 있다.
고급 활용: Collectors 활용
Collectors는 collect() 메서드와 함께 사용하여 다양한 결과를 만들 수 있다.
groupingBy() - 그룹화
데이터를 특정 기준으로 그룹화할 때 사용한다.
// 나이별로 그룹화
Map<Integer, List<User>> usersByAge = users.stream()
.collect(Collectors.groupingBy(User::getAge));
// 도시별로 그룹화
Map<String, List<User>> usersByCity = users.stream()
.collect(Collectors.groupingBy(User::getCity));
// 그룹화 + 카운팅
Map<String, Long> countByCity = users.stream()
.collect(Collectors.groupingBy(
User::getCity,
Collectors.counting()
));partitioningBy() - 분할
boolean 기준으로 데이터를 두 그룹으로 나눈다.
Map<Boolean, List<User>> partition = users.stream()
.collect(Collectors.partitioningBy(user -> user.getAge() >= 20));
List<User> adults = partition.get(true);
List<User> minors = partition.get(false);병렬 스트림(Parallel Stream)
대용량 데이터를 처리할 때 병렬 스트림을 사용하면 성능을 높일 수 있다.
// 순차 스트림
long sum1 = numbers.stream()
.mapToLong(Integer::longValue)
.sum();
// 병렬 스트림
long sum2 = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();하지만 병렬 스트림이 항상 빠른 건 아니다. 데이터가 적거나, 연산이 간단한 경우 오히려 오버헤드 때문에 느려질 수 있다. 또한 공유 상태를 변경하는 작업에서는 동기화 문제가 발생할 수 있으니 주의해야 한다.
Stream 사용 시 주의사항
내가 실수했던 것들을 정리해봤다.
Stream 재사용 불가
Stream<Integer> stream = numbers.stream();
stream.forEach(System.out::println); // OK
stream.forEach(System.out::println); // IllegalStateException 발생!Stream은 한 번 사용하면 끝이다. 재사용하려면 다시 생성해야 한다.
중간 연산 순서의 중요성
// 비효율적
list.stream()
.map(s -> s.toUpperCase()) // 모든 요소를 변환
.filter(s -> s.startsWith("A")) // 그 후 필터링
.collect(Collectors.toList());
// 효율적
list.stream()
.filter(s -> s.startsWith("A")) // 먼저 필터링
.map(s -> s.toUpperCase()) // 필요한 것만 변환
.collect(Collectors.toList());filter를 먼저 하면 처리할 데이터가 줄어들어 성능이 좋아진다.
최종 연산 누락
numbers.stream()
.filter(n -> n % 2 == 0)
.map(n -> n * 2);
// 아무 일도 일어나지 않음! 최종 연산이 없어서최종 연산이 없으면 중간 연산들이 실행되지 않는다.
무한 스트림 주의
// 잘못된 예
Stream.iterate(0, i -> i + 1)
.distinct() // 무한히 대기
.limit(10)
.forEach(System.out::println);
// 올바른 예
Stream.iterate(0, i -> i + 1)
.limit(10) // 먼저 제한
.distinct()
.forEach(System.out::println);peek()의 함정
peek()은 디버깅 용도로만 사용해야 한다. 최종 연산이 없으면 실행되지 않는다.
numbers.stream()
.peek(System.out::println); // 아무것도 출력되지 않음
numbers.stream()
.peek(System.out::println)
.collect(Collectors.toList()); // 이제 출력됨실무 활용 예제
마지막으로 실무에서 자주 쓰는 패턴들을 정리해봤다.
복잡한 필터링과 변환
List<OrderDto> orderDtos = orders.stream()
.filter(order -> order.getStatus() == OrderStatus.COMPLETED)
.filter(order -> order.getTotalAmount() >= 10000)
.map(order -> new OrderDto(
order.getId(),
order.getCustomerName(),
order.getTotalAmount()
))
.collect(Collectors.toList());중첩 객체 처리
List<String> productNames = orders.stream()
.flatMap(order -> order.getOrderItems().stream())
.map(item -> item.getProduct().getName())
.distinct()
.collect(Collectors.toList());통계 계산
DoubleSummaryStatistics stats = orders.stream()
.collect(Collectors.summarizingDouble(Order::getTotalAmount));
System.out.println("평균: " + stats.getAverage());
System.out.println("최대: " + stats.getMax());
System.out.println("최소: " + stats.getMin());
System.out.println("합계: " + stats.getSum());Optional과 함께 사용
Optional<User> user = users.stream()
.filter(u -> u.getId().equals(userId))
.findFirst();
user.ifPresent(u -> System.out.println(u.getName()));
String name = user.map(User::getName)
.orElse("Unknown");Stream API는 처음에는 익숙하지 않을 수 있지만, 사용하다 보면 정말 편리하다는 걸 느낄 수 있다. 특히 복잡한 데이터 처리 로직을 간결하고 읽기 쉽게 만들어준다.
중요한 건 무조건 Stream을 쓰는 게 아니라, 상황에 맞게 적절히 사용하는 것이다. 간단한 반복문은 for문이 더 나을 수 있고, 대용량 데이터는 병렬 스트림을 고려해볼 수 있다. 또 성능이 중요한 경우라면 프로파일링을 통해 실제로 측정해보는 게 좋다.
이 글이 Stream API를 이해하고 활용하는 데 도움이 되었으면 좋겠다. 실무에서 직접 써보면서 자신만의 패턴을 만들어가는 것도 중요하다. 코드를 작성할 때 "이 부분을 Stream으로 바꾸면 어떨까?" 하고 생각해보는 습관을 들이면, 점점 더 깔끔한 코드를 작성할 수 있을 것이다.
Java 함수형 인터페이스와 메서드 레퍼런스 완벽 가이드
자바 8이 출시된 지 꽤 시간이 지났지만, 여전히 함수형 인터페이스와 메서드 레퍼런스를 제대로 활용하지 못하는 개발자들이 많습니다. 저도 처음에는 이 개념들이 다소 낯설었는데요, 실무에
byteandbit.tistory.com
'JAVA' 카테고리의 다른 글
| Java Stream Collectors 완전 정복: groupingBy와 partitioningBy로 데이터 그룹화하기 (0) | 2025.11.06 |
|---|---|
| Java Optional로 NPE를 잡아보자 - 실무 활용 가이드 (0) | 2025.11.05 |
| Java 함수형 인터페이스와 메서드 레퍼런스 완벽 가이드 (0) | 2025.10.29 |
| Java 제네릭 완벽 가이드: 타입 파라미터부터 와일드카드까지 실전 총정리 (0) | 2025.10.28 |
| Java Iterator와 Fail-Fast, 컬렉션 수정 시 주의할 점들 (0) | 2025.10.27 |