CodeGym /행동 /JAVA 25 SELF /프리미티브 스트림과 박싱(boxing)의 비용

프리미티브 스트림과 박싱(boxing)의 비용

JAVA 25 SELF
레벨 33 , 레슨 0
사용 가능

1. 문제: 왜 일반 Stream이 항상 효율적이지 않은가

Java에서 숫자 컬렉션을 다루며 일반 스트림(Stream<Integer>, Stream<Double>)을 사용할 때, 내부적으로 프리미티브 값을 래퍼 객체(Integer, Double 등)로 “박싱”(boxing)하고 다시 “언박싱”(unboxing)합니다. 편리하지만 항상 효율적이진 않습니다:

  • Boxing — 프리미티브(int)를 객체(Integer)로 바꾸는 것.
  • Unboxing — 반대 연산: 객체를 프리미티브로 바꾸는 것.

문제:
Boxing/unboxing은 추가 연산과 메모리 소비를 유발합니다. 프로그램의 “핫”(자주 호출되는) 구간에서는 성능 저하가 눈에 띌 수 있으며, 특히 큰 배열의 숫자를 처리할 때 더욱 그렇습니다.

예시:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
int sum = numbers.stream().map(x -> x * 2).reduce(0, Integer::sum);

여기서 각 숫자는 Integer 객체이지, int 프리미티브가 아닙니다.

2. 프리미티브 스트림: IntStream, LongStream, DoubleStream

불필요한 boxing/unboxing을 피하기 위해 Java에는 프리미티브 스트림이 있습니다:

  • IntStreamint
  • LongStreamlong
  • DoubleStreamdouble

프리미티브만 다루며 불필요한 래퍼 객체를 만들지 않습니다.

프리미티브 스트림을 생성하는 방법

배열에서:

int[] arr = {1, 2, 3, 4, 5};
IntStream s = Arrays.stream(arr);

range/rangeClosed 사용:

IntStream.range(0, 10)          // 0..9
IntStream.rangeClosed(1, 5)     // 1..5 포함

난수 생성:

new Random().ints(5, 0, 100)    // 0 이상 100 미만의 난수 int 5개

예시: 배열 요소 합계

int[] arr = {1, 2, 3, 4, 5};
int sum = Arrays.stream(arr).sum(); // 15

3. Stream<T>와 프리미티브 스트림 간 변환

가끔은 일반 스트림이, 가끔은 프리미티브 스트림이 필요합니다. 변환에는 전용 메서드를 사용합니다:

  • mapToInt, mapToLong, mapToDouble — 일반 스트림을 프리미티브 스트림으로 변환.
  • boxed() — 프리미티브 스트림을 다시 객체 스트림으로 변환.

예시:

List<String> words = List.of("Java", "Stream", "API");
IntStream lengths = words.stream().mapToInt(String::length);
lengths.forEach(System.out::println); // 4 6 3

반대로:

IntStream ints = IntStream.range(1, 5);
Stream<Integer> boxed = ints.boxed();

주의:
boxed()는 역방향 연산으로, 다시 래퍼 객체(Integer, Double 등)를 생성합니다. 성능이 가장 중요하다면 “핫” 구간에서는 이를 피하세요.

4. 합계와 요약: sum, average, min, max, summaryStatistics

프리미티브 스트림에는 편리한 집계 메서드가 있습니다:

  • sum() — 모든 요소의 합
  • average() — 평균(반환값: OptionalDouble)
  • min(), max() — 최솟값과 최댓값(OptionalInt, OptionalLong, OptionalDouble)
  • summaryStatistics() — 합계, 평균, 최솟값, 최댓값, 개수를 담은 통계 객체를 반환

예시:

int[] arr = {1, 2, 3, 4, 5};
IntSummaryStatistics stats = Arrays.stream(arr).summaryStatistics();
System.out.println(stats.getSum());      // 15
System.out.println(stats.getAverage());  // 3.0
System.out.println(stats.getMin());      // 1
System.out.println(stats.getMax());      // 5
System.out.println(stats.getCount());    // 5

Collectors와 비교:
일반 스트림에서는 Collectors.summarizingInt를 사용할 수 있지만, 대체로 프리미티브 스트림 메서드보다 비효율적입니다:

List<Integer> nums = List.of(1, 2, 3, 4, 5);
IntSummaryStatistics stats = nums.stream().collect(Collectors.summarizingInt(x -> x));

5. 오토박싱 피하기: 언제 중요한가, 어떻게 측정할까

언제가 특히 중요할까?

  • 숫자를 대량으로 처리하는 루프나 스트림(예: 배열 처리, 통계, 수치 계산, 데이터 파싱).
  • “핫” 구간 — 자주 호출되어 성능에 영향을 주는 코드.

왜 중요할까?

  • 매번 boxing이 발생할 때마다 새로운 래퍼 객체(Integer, Double 등)가 생성됩니다.
  • GC 부하가 증가합니다.
  • 마이크로벤치마크에서는 차이가 몇 배까지 날 수 있습니다!

어떻게 측정할까?
정확한 비교에는 JMH(Java Microbenchmark Harness) 프레임워크를 사용합니다. 박싱 유무에 따른 코드 성능을 공정하게 비교할 수 있습니다.

예시(의사 코드):

@Benchmark
public int sumIntStream() {
    return IntStream.range(0, 1_000_000).sum();
}

@Benchmark
public int sumStreamInteger() {
    return Stream.iterate(0, n -> n + 1).limit(1_000_000).reduce(0, Integer::sum);
}

두 번째 방식은 Integer 객체를 지속적으로 생성하기 때문에 더 느립니다.

결론:
성능이 중요하다면 프리미티브 스트림을 사용하세요!

6. 프리미티브 스트림이 실제로 도움이 되는 경우와 과도한 복잡화를 피해야 할 때

다음과 같은 경우 사용하세요:

  • 큰 배열/컬렉션의 숫자를 다룰 때.
  • 합계, 평균, 최솟값, 최댓값을 빠르게 계산해야 할 때.
  • 밀리초 단위까지 중요한 코드(예: 실시간 데이터 처리)를 작성할 때.

다음과 같은 경우 굳이 신경 쓰지 않아도 됩니다:

  • 컬렉션이 작다(몇 개에서 수십 개 정도의 요소).
  • 타입 간 변환 때문에 코드가 과도하게 복잡해지는 경우.
  • 성능이 중요하지 않은 경우(예: 사용자 입력 처리).

예시:

List<Integer> smallList = List.of(1, 2, 3);
int sum = smallList.stream().mapToInt(x -> x).sum(); // 가능하지만 일반 reduce도 충분함

팁: 미세한 최적화를 위해 코드를 “정글”로 만들지 마세요. 정말 타당한 곳에서만 프리미티브 스트림을 사용하세요.

7. OptionalInt, OptionalDouble: 결과를 안전하게 꺼내기

프리미티브 스트림의 min(), max(), average() 메서드는 단순 숫자가 아니라 “래퍼” — OptionalInt, OptionalDouble 등을 반환합니다. 이는 빈 스트림(예: 배열이 비어 있음)을 안전하게 처리하기 위함입니다.

예시:

int[] arr = {};
OptionalInt min = Arrays.stream(arr).min();
if (min.isPresent()) {
    System.out.println("최솟값: " + min.getAsInt());
} else {
    System.out.println("배열이 비었습니다!");
}

일반 Optional과 비교:

  • OptionalIntint
  • OptionalDoubledouble
  • OptionalLonglong

왜 그냥 0을 반환하지 않을까?
0은 유효한 값일 수 있으며, 빈 스트림은 별개의 상황이기 때문입니다.

average 예시:

double[] arr = {};
OptionalDouble avg = Arrays.stream(arr).average();
double result = avg.orElse(Double.NaN); // 비어 있으면 NaN 반환

8. 실전: 프리미티브 스트림 사용 예

예제 1: 1부터 1000까지 제곱의 합

int sum = IntStream.rangeClosed(1, 1000)
                   .map(x -> x * x)
                   .sum();
System.out.println(sum);

예제 2: 짝수 필터링 및 개수 세기

int[] arr = {1, 2, 3, 4, 5, 6};
long count = Arrays.stream(arr)
                   .filter(x -> x % 2 == 0)
                   .count();
System.out.println("짝수 개수: " + count);

예제 3: 일반 Stream<Integer>와의 비교

List<Integer> list = IntStream.range(0, 1_000_000)
                              .boxed()
                              .collect(Collectors.toList());

long t1 = System.currentTimeMillis();
int sum1 = list.stream().mapToInt(x -> x).sum();
long t2 = System.currentTimeMillis();
System.out.println("Stream<Integer>: " + (t2 - t1) + " ms");

t1 = System.currentTimeMillis();
int sum2 = IntStream.range(0, 1_000_000).sum();
t2 = System.currentTimeMillis();
System.out.println("IntStream: " + (t2 - t1) + " ms");

대용량에서는 차이가 눈에 띕니다.

9. 프리미티브 스트림 사용 시 흔한 실수

실수 1: 타입 변환 시 boxing을 간과함.
boxed()를 사용하면 다시 래퍼 객체가 생성됩니다. 필요하지 않다면 사용하지 마세요.

실수 2: 객체 처리에 프리미티브 스트림을 사용함.
IntStreamint만 다룹니다. 객체를 다뤄야 한다면 일반 Stream<T>를 사용하세요.

실수 3: OptionalInt/OptionalDouble을 무시함.
min(), max(), average()를 호출했다면 결과가 있는지(isPresent()) 항상 확인하세요. 그렇지 않으면 예외가 발생할 수 있습니다.

실수 4: Stream<T>와 IntStream 사이를 과도하게 오가는 복잡한 변환.
mapToInt()boxed()mapToDouble()처럼 변환이 계속되어 코드가 읽기 어려워진다면, 로직을 단순화하는 것이 좋습니다.

실수 5: 작은 컬렉션에서 ‘마법 같은’ 속도 향상을 기대함.
작은 리스트에서는 StreamIntStream의 차이가 매우 작습니다. 미미한 절약을 위해 코드를 복잡하게 만들 필요는 없습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION