Notice
Recent Posts
Recent Comments
Link
«   2026/03   »
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
Tags more
Archives
Today
Total
관리 메뉴

프로그래밍 공부

[자바] #20. 스트림(Stream) - 1편 (+ 오토 박싱 / 언박싱) 본문

자바

[자바] #20. 스트림(Stream) - 1편 (+ 오토 박싱 / 언박싱)

하 냥 2025. 2. 21. 13:26

📚 스트림 이란?

컬렉션(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) 수행 시 없음
🧱 메모리 사용량 추가 객체 생성 → 메모리 사용량 증가 메모리 사용량 최소화
🚀 성능 연산마다 박싱/언박싱으로 성능 저하 높은 성능 유지
🎯 사용 권장 상황 참조 타입 사용이 필요한 경우 대용량 데이터 처리, 수치 계산 등