프로그래밍 공부
[자바] #20. 스트림(Stream) - 1편 (+ 오토 박싱 / 언박싱) 본문
📚 스트림 이란?
컬렉션(Collection) 등의 데이터를 선언적(declarative)으로 처리할 수 있도록 도와주는 강력한 도구.
스트림을 사용하면 데이터를 필터링, 변환, 집계 등의 작업을 간결하고 효율적으로 수행할 수 있다.
특히 함수형 프로그래밍 스타일로 데이터를 처리할 수 있게 해준다.
- 데이터의 흐름: 스트림은 데이터 저장소(예: 리스트, 배열)가 아닌, 데이터의 흐름을 처리한다.
- 선언형 프로그래밍: 반복문 대신 선언형 코드로 가독성 향상
- 중간 연산과 최종 연산: 스트림 연산은 중간 연산과 최종 연산으로 구분
- 지연 실행(Lazy Evaluation): 중간 연산은 최종 연산이 실행될 때까지 수행되지 않는다.
- 병렬 처리 지원: .parallelStream()을 사용해 손쉽게 병렬 처리 가능
🔨 스트림 생성 방법
✅ 컬렉션 대상으로 생성
List<String> list = Arrays.asList("Java", "Python", "C++");
Stream<String> stream = list.stream(); // 순차 스트림
Stream<String> parallelStream = list.parallelStream(); // 병렬 스트림
✅ 배열 대상으로 생성
String[] array = {"Java", "Python", "C++"};
Stream<String> stream = Arrays.stream(array);
✅ Stream.of() 사용
Stream<String> stream = Stream.of("Java", "Python", "C++");
🏃 스트림의 주요 연산
📌 중간 연산
스트림을 변환하지만 즉시 실행되지 않음
| 메서드 | 설명 | 예제 코드 |
| filter() | 조건에 맞는 요소만 선택 | stream.filter(s -> s.startsWith("J")) |
| map() | 요소 변환 | stream.map(String::toUpperCase) |
| flatMap() | 중첩된 스트림을 평면화 | stream.flatMap(List::stream) |
| sorted() | 정렬 | stream.sorted() |
| distinct() | 중복 제거 | stream.distinct() |
| limit() | 스트림 크기 제한 | stream.limit(5) |
| skip() | 첫 n개 요소를 건너뜀 | stream.skip(3) |
📌 최종 연산
스트림을 소비하고 결과를 반환
| 메서드 | 설명 | 반환 타입 | 예제 코드 |
| collect() | 결과를 컬렉션으로 수집 | 컬렉션 | stream.collect(Collectors.toList()) |
| count() | 요소 개수 반환 | long | stream.count() |
| forEach() | 각 요소에 대해 작업 수행 | void | stream.forEach(System.out::println) |
| reduce() | 요소를 하나의 값으로 결합 | Optional | stream.reduce("", String::concat) |
| toArray() | 배열로 변환 | 배열 | stream.toArray(String[]::new) |
| findFirst() | 첫 번째 요소 반환 | Optional | stream.findFirst() |
| findAny() | 임의의 요소 반환 | Optional | stream.findAny() |
| allMatch() | 모든 요소가 조건 만족 여부 | boolean | stream.allMatch(s -> s.length() > 3) |
| anyMatch() | 하나라도 조건 만족 여부 | boolean | stream.anyMatch(s -> s.equals("Java")) |
| noneMatch() | 아무 요소도 조건 만족 X | boolean | stream.noneMatch(s -> s.isEmpty()) |
💡 핵심 포인트: Stream에서의 forEach
default void forEach(Consumer<? super T> action) {
for (T t : this)
action.accept(t);
}
이 메서드는 Iterable 인터페이스의 default 메서드이다. 하지만, Stream의 forEach 메서드는 다르다.
list.stream().forEach(...)를 호출할 때 사용되는 forEach는 Stream 인터페이스에 정의된 메서드이다.
// java.util.stream.Stream 인터페이스의 forEach 정의
void forEach(Consumer<? super T> action);
Stream의 forEach는 Iterable의 forEach와 달리 default 메서드가 아니며, this를 참조할 만한 반복문이 존재하지 않는다.
내부적으로는 this가 존재!
🧐 this가 참조하는 것은?
- Stream의 forEach 메서드에서 this는 Stream 객체 자신을 참조한다.
- 즉, list.stream()이 반환하는 Stream<String> 인스턴스가 this이다.
list.forEach(...)의 경우: this는 list(Iterable 구현체)를 참조.
list.stream().forEach(...)의 경우: this는 Stream 객체(ReferencePipeline$Head와 같은 스트림 구현체)를 참조.
📚 필터링: filter() 메서드 개념
스트림의 각 요소에 대해 조건(Predicate)을 평가하고, 조건을 만족하는 요소만 포함하는 새로운 스트림을 반환한다.
✅ 예제: 문자열 리스트에서 길이가 5 이상인 단어 필터링
import java.util.*;
import java.util.stream.*;
public class FilterExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("apple", "banana", "kiwi", "avocado", "berry");
words.stream()
.filter(word -> word.length() >= 5) // 길이가 5 이상인 단어만 선택
.forEach(System.out::println);
}
}
🔍 내부 동작 이해 (명령형과 비교)
for (String word : words) {
if (word.length() >= 5) {
System.out.println(word);
}
}
✅ 예제: 조건 조합 필터링
List<String> fruits = Arrays.asList("apple", "banana", "apricot", "blueberry");
fruits.stream()
.filter(fruit -> fruit.startsWith("a") && fruit.length() > 5)
.forEach(System.out::println);
// 출력: apricot
✅ 예제: 객체 리스트 필터링
class User {
String name;
int age;
User(String name, int age) {
this.name = name;
this.age = age;
}
}
public class FilterObjectExample {
public static void main(String[] args) {
List<User> users = Arrays.asList(
new User("Alice", 23),
new User("Bob", 18),
new User("Charlie", 30)
);
users.stream()
.filter(user -> user.age >= 21) // 21세 이상 필터링
.forEach(user -> System.out.println(user.name + ": " + user.age));
}
}
✅ 예제: flatMap()과 결합한 필터링
List<List<String>> nestedList = Arrays.asList(
Arrays.asList("apple", "banana"),
Arrays.asList("kiwi", "avocado")
);
nestedList.stream()
.flatMap(Collection::stream) // 중첩 컬렉션 평면화
.filter(word -> word.startsWith("a")) // 'a'로 시작하는 단어만 필터링
.forEach(System.out::println);
// 출력: apple, avocado
// flatMap()을 사용하면 중첩된 데이터를 평면화하여 필터링 가능
*️️ 복잡한 조건 필터링 시 Predicate 조합 사용
Predicate는 and(), or(), negate() 등을 사용하여 복잡한 조건을 가독성 있게 조합할 수 있다.
Predicate<String> startsWithA = s -> s.startsWith("a");
Predicate<String> lengthGreaterThan5 = s -> s.length() > 5;
words.stream()
.filter(startsWithA.and(lengthGreaterThan5)) // 두 조건 모두 만족하는 경우
.forEach(System.out::println);
📚 맵핑: map() 메서드 개념
스트림에서 맵핑(Mapping)은 각 요소를 변환하거나 확장하는 과정이다.
즉, 스트림의 각 요소를 다른 값으로 맵핑하거나, 스트림 구조를 변환하는 역할을 한다.
맵핑에 사용되는 메소드는 다음과 같으며, 보이는 바와 같이 제네릭 메소드이다.
<R> Stream<R> map(Function<? super T, ? extends R> mapper)
❓ 왜 <? super T, ? extends R> 인가?
🔄 PECS 원칙: 핵심 개념
| 역할 | 키워드 | 의미 | 예시 |
| Producer (생산자) | extends | 데이터를 생산(제공)하는 경우, 상위 타입 참조 허용 | ? extends R |
| Consumer (소비자) | super | 데이터를 소비(사용)하는 경우, 하위 타입 참조 허용 | ? super T |
🔍 1️⃣ 왜 입력(T)은 소비자(Consumer)인가?
map 메서드에서 입력값(T)은 함수에 제공되는 것이 아니라,
함수가 "사용"하여 처리하므로 소비자(Consumer)에 해당한다.
🔍 2️⃣ 왜 출력(R)은 생산자(Producer)인가?
Function는 출력 후 출력값을 생성(생산)하여 반환하는데,
출력값은 클라이언트가 소비하므로 함수 입장에서는 생산자(Producer) 역할을 한다.
🔍 리덕션(Reduction)이란?
리덕션(Reduction)은 스트림의 요소들을 하나의 값으로 결합하는 과정.
예를 들어, 리스트의 숫자를 모두 더하거나, 문자열을 연결하거나, 최대값/최소값을 찾는 등의 작업이 리덕션
-> 결과가 단일 값으로 반환된다.
🔧 reduce 메서드란?
T reduce(T identity, BinaryOperator<T> accumulator);
- identity: 초기값 (예: 0, "", 1 등)
- accumulator: 두 값을 결합하는 함수
BinaryOperator<T> : T apply(T t1, T t2)
reduce 호출 시 apply 메서드에 대한 람다식을 인자로 전달해야 한다.
💡 예제
// 숫자 합산 예제
import java.util.*;
public class ReduceExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b); // 초기값 0, 덧셈 연산
System.out.println("합계: " + sum); // 출력: 합계: 15
}
}
// 문자열 길이 비교 예제
import java.util.*;
import java.util.function.BinaryOperator;
public class ReduceExample2 {
public static void main(String[] args) {
List<String> list = Arrays.asList("Simple", "Box", "Complex", "Robot");
BinaryOperator<String> ac = (s1, s2) -> {
if (s1.length() > s2.length)
return s1;
else
return s2;
};
String str = list.stream()
.reduce("", ac);
System.out.println(str); // 출력: Complex
}
}
❓ 여기서 reduce 메서드의 첫 번째 인자는 뭘까?
❕ 첫 번째 인자로 전달되는 값은, 스트림을 구성하는 데이터가 하나도 없을 때 반환이 된다.
즉, 첫 번째 예제에서는 numbers가 참조하는 컬렉션 인스턴스에 integer가 한 개도 없을 때 0이 반환되고,
두 번째 예제에서는 list가 참조하는 컬렉션 인스턴스에 문자열이 하나도 없을 때 빈 문자열이 반환된다.
💡 더 자세한 예제 : 총 결제 금액 계산
- 쇼핑몰에서 고객이 장바구니에 담은 상품들의 가격을 합산하여 총 결제 금액을 계산
import java.util.*;
import java.util.stream.*;
public class ShoppingCartExample {
public static void main(String[] args) {
List<Item> cart = Arrays.asList(
new Item("노트북", 1200000),
new Item("마우스", 30000),
new Item("키보드", 70000),
new Item("모니터", 300000)
);
int totalPrice = cart.stream()
.map(Item::getPrice) // 가격만 추출
.reduce(0, Integer::sum); // 가격 합산
System.out.println("총 결제 금액: " + totalPrice + "원");
}
}
class Item {
private String name;
private int price;
public Item(String name, int price) {
this.name = name;
this.price = price;
}
public int getPrice() {
return price;
}
}
😨 스트림에서 진행되는 오토 박싱 / 언박싱
1️⃣ 오토 박싱 / 언박싱이 발생하는 이유
- Java의 제네릭은 기본형(primitive) 타입을 지원하지 않는다.
- 따라서 Stream<T>는 T가 객체 타입이어야 한다. (Integer, Double, Long 등)
- 기본형(int, double, long)을 사용하면 자동으로 객체(wrapper class)로 변환하는 과정이 필요하다.
- 이 과정이 바로 오토 박싱(int → Integer)과 오토 언박싱(Integer → int)
2️⃣ 오토 박싱/언박싱이 발생하는 실제 코드 예제 1, 2
import java.util.*;
import java.util.stream.*;
public class BoxingExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// 오토 언박싱 발생 지점: mapToInt(Integer::intValue)
int sum = numbers.stream()
.mapToInt(Integer::intValue) // Integer → int (언박싱)
.sum(); // 기본형 연산
System.out.println("합계: " + sum);
}
}
💡 오토 박싱 / 언박싱 발생 위치:
- mapToInt(Integer::intValue) 호출 시 오토 언박싱 발생 (Integer → int)
- sum() 연산 시 기본형 연산 수행
import java.util.*;
import java.util.stream.*;
public class BoxingDuringReduce {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream() // Stream<Integer> 생성 (이미 Integer 형태)
.map(n -> n * 2) // Integer → int(언박싱), 연산 후 int → Integer(박싱)
.reduce(0, (a, b) -> a + b); // 언박싱 후 연산 반복
System.out.println("합계: " + sum);
}
}
💡 오토 박싱 / 언박싱 발생 위치:
1️⃣ numbers.stream() → Stream<Integer> 생성 (이미 Integer이므로 박싱은 없음)
2️⃣ .map(n -> n * 2)
- n * 2: n은 Integer, 연산(* 2) 시 언박싱(Integer → int)
- 연산 결과(int)를 다시 Stream<Integer>에 담기 위해 박싱(int → Integer)
3️⃣ .reduce(0, (a, b) -> a + b)
- a와 b는 Integer, 연산(+) 시 언박싱 발생 후 결과는 다시 박싱
3️⃣ IntStream으로 최적화 (오토 박싱/언박싱 제거)
import java.util.stream.*;
public class PrimitiveStreamOptimization {
public static void main(String[] args) {
// IntStream 사용 시 오토 박싱/언박싱 제거
int sum = IntStream.of(1, 2, 3, 4, 5)
.sum(); // 모든 연산이 int 기반으로 수행됨
System.out.println("합계: " + sum);
}
}
✅ 왜 오토 박싱/언박싱이 발생하지 않을까?
- IntStream.of(...)는 처음부터 int 타입을 다룸
- 모든 연산(sum())이 기본형 int로 수행되므로 객체 생성 과정 없음
- 성능 최적화 및 메모리 사용량 감소
4️⃣ mapToInt() 등의 메서드 사용
import java.util.*;
class MapToInt {
public static void main(String[] args) {
List<String> list = Arrays.asList("Box", "Robot", "Simple");
list.stream() // Stream<String> 생성
.mapToInt(s -> s.length()); // 각 문자열의 길이를 int로 매핑 -> IntStream 반환
.forEach(n -> System.out.print(n + " ")); // IntStream의 각 요소 출력
System.out.println();
}
}
list.stream()으로 생성된 Stream<String>은 mapToInt 메서드를 통해 IntStream으로 변환된다.
mapToInt 메서드는 각 요소(String)에 대해 int 값을 반환하는 함수를 적용하고,
결과적으로 IntStream을 반환한다.
즉, Stream<String>이 IntStream으로 "변환"되는 것!
- Stream<String>에서 mapToInt를 호출하면 IntStream이 반환된다.
- IntStream은 기본형 스트림(int 타입 전용)으로, Stream<Integer>보다 메모리 효율적이고 성능이 더 좋다.
- 이후에는 IntStream의 메서드(sum(), average(), max(), min() 등)를 사용할 수 있다.
✅ Stream<String>이 IntStream으로 완전히 바뀐다고 이해하면 된다!
(ls.stream()이 여전히 존재하는 게 아니라, 변환된 IntStream이 반환되는 것!)
5️⃣ 내부적으로 발생하는 오토 박싱/언박싱 구조
📈 Stream<Integer> vs IntStream 차이
| 작업 | Stream 처리 (오토 박싱/언박싱 발생) | IntStream 처리 (기본형 처리) |
| 스트림 생성 | int → Integer (오토 박싱) | int 그대로 사용 |
| 중간 연산 (map 등) | Integer → int (언박싱) : 산술 연산 전에 Integer가 int로 변환됨 int → Integer (박싱) : 연산 후 int가 다시 Integer로 변환되어 스트림에 저장됨 |
int로 연산, 변환 없음 |
| 최종 연산 (sum, reduce) | Integer → int (언박싱) 후 연산 수행 | 기본형 int로 바로 연산 수행 |
🧭 JVM 관점의 동작 방식:
- Stream<Integer>:
- 내부적으로 Integer.valueOf(int)를 호출하여 박싱
- Integer.intValue() 메서드를 호출하여 언박싱
- IntStream:
- 모든 연산이 int 수준에서 수행되어 추가적인 변환 없음
6️⃣ 최종 요약: 오토 박싱 / 언박싱 발생 지점과 기본형 스트림의 이점
| 구분 | Stream (박싱/언박싱 발생) | IntStream (박싱/언박싱 없음) |
| 💾 처리 타입 | Integer (객체) | int (기본형) |
| ⚡ 오토 박싱 발생 지점 | 연산(map 등) 수행 후 스트림에 다시 저장 시 | 없음 |
| 🛠 오토 언박싱 발생 지점 | 연산(reduce, sum) 수행 시 | 없음 |
| 🧱 메모리 사용량 | 추가 객체 생성 → 메모리 사용량 증가 | 메모리 사용량 최소화 |
| 🚀 성능 | 연산마다 박싱/언박싱으로 성능 저하 | 높은 성능 유지 |
| 🎯 사용 권장 상황 | 참조 타입 사용이 필요한 경우 | 대용량 데이터 처리, 수치 계산 등 |
'자바' 카테고리의 다른 글
| [자바] #22. 스트림(Stream) - 3편 (최종 연산 추가 내용) (0) | 2025.02.21 |
|---|---|
| [자바] #21. 스트림(Stream) - 2편 (병렬 스트림, 중간 연산 추가 내용) (0) | 2025.02.21 |
| [자바] #19. Optional 총정리 (0) | 2025.02.20 |
| [자바] #18. 메서드 참조(+ 람다식에서 접근 가능한 참조변수 제한) (0) | 2025.02.20 |
| [자바] #17. 람다(Lambda)와 함수형 인터페이스 (1) | 2025.02.19 |