자바 스트림(Stream) API 이해하기


최근 자바 개발을 하면서 자바8에서 도입된 스트림(Stream) API를 자주 사용하게 되는데, 처음에는 익숙하지 않아서 기존의 for문이나 iterator를 사용하던 습관이 남아있었다. 하지만 스트림을 제대로 이해하고 사용해보니 코드가 훨씬 간결해지고 가독성이 좋아지는 걸 체감하게 됐다.

스트림(Stream)이란?

스트림은 데이터의 흐름을 의미한다. 배열이나 컬렉션 같은 데이터 소스에서 스트림을 생성하고, 이를 통해 데이터를 처리하는 방식이다. 기존의 반복문을 사용한 명령형 프로그래밍과 달리, 스트림은 선언형 프로그래밍을 가능하게 해준다.

스트림의 특징

1. 한 번만 사용 가능

스트림은 한 번 사용하면 소비되어 버린다. 다시 사용하려면 새로운 스트림을 생성해야 한다.

List<String> names = Arrays.asList("김철수", "이영희", "박민수", "최지영");

Stream<String> stream = names.stream();
stream.forEach(System.out::println); // 정상 동작
stream.forEach(System.out::println); // IllegalStateException 발생!

2. 중간 연산과 최종 연산

스트림 연산은 **중간 연산(Intermediate Operation)**과 **최종 연산(Terminal Operation)**으로 나뉜다.

  • 중간 연산: filter, map, sorted 등 - 스트림을 반환
  • 최종 연산: forEach, collect, reduce 등 - 결과를 반환
names.stream()
    .filter(name -> name.length() > 2)  // 중간 연산
    .map(String::toUpperCase)           // 중간 연산
    .forEach(System.out::println);      // 최종 연산

실제 사용 예시

1. 기존 방식 vs 스트림 방식

기존 for문 방식:

List<String> result = new ArrayList<>();
for (String name : names) {
    if (name.length() > 2) {
        result.add(name.toUpperCase());
    }
}

스트림 방식:

List<String> result = names.stream()
    .filter(name -> name.length() > 2)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

2. 숫자 처리 예시

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 짝수만 필터링하고 제곱한 후 합계 구하기
int sum = numbers.stream()
    .filter(n -> n % 2 == 0)    // 짝수만 필터링
    .mapToInt(n -> n * n)       // 제곱
    .sum();                     // 합계

System.out.println("짝수의 제곱 합: " + sum); // 220 출력

3. 객체 리스트 처리

class Person {
    private String name;
    private int age;
    
    // 생성자, getter, setter 생략
    
    public boolean isAdult() {
        return age >= 20;
    }
}

List<Person> people = Arrays.asList(
    new Person("김철수", 25),
    new Person("이영희", 18),
    new Person("박민수", 30),
    new Person("최지영", 16)
);

// 성인만 필터링하고 이름만 추출
List<String> adultNames = people.stream()
    .filter(Person::isAdult)
    .map(Person::getName)
    .collect(Collectors.toList());

자주 사용하는 스트림 메서드들

중간 연산

  • filter(Predicate<T>): 조건에 맞는 요소만 필터링
  • map(Function<T, R>): 요소를 다른 형태로 변환
  • sorted(): 정렬
  • distinct(): 중복 제거
  • limit(long n): 처음 n개만 선택
  • skip(long n): 처음 n개를 건너뛰기

최종 연산

  • forEach(Consumer<T>): 각 요소에 대해 작업 수행
  • collect(Collector<T, A, R>): 결과를 컬렉션으로 수집
  • reduce(): 요소들을 결합하여 하나의 결과 생성
  • count(): 요소 개수 반환
  • anyMatch(), allMatch(), noneMatch(): 조건 검사

성능 고려사항

스트림이 항상 좋은 것은 아니다. 실제 성능 테스트를 해보면 상황에 따라 차이가 크다.

간단한 반복문의 경우

// 테스트 데이터: 1부터 1,000,000까지의 숫자
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
    .boxed()
    .collect(Collectors.toList());

// for문 방식
long startTime = System.currentTimeMillis();
int sum1 = 0;
for (Integer num : numbers) {
    sum1 += num;
}
long forTime = System.currentTimeMillis() - startTime;

// 스트림 방식
startTime = System.currentTimeMillis();
int sum2 = numbers.stream()
    .mapToInt(Integer::intValue)
    .sum();
long streamTime = System.currentTimeMillis() - startTime;

System.out.println("for문 시간: " + forTime + "ms");
System.out.println("스트림 시간: " + streamTime + "ms");

실행 결과 (평균):

  • for문: 약 15-25ms
  • 스트림: 약 45-65ms
  • 스트림이 약 2-3배 느림

복잡한 데이터 처리의 경우

// 복잡한 필터링과 변환 작업
List<String> result1 = new ArrayList<>();
for (Integer num : numbers) {
    if (num % 2 == 0 && num > 1000) {
        result1.add("Number: " + num);
    }
}

List<String> result2 = numbers.stream()
    .filter(num -> num % 2 == 0)
    .filter(num -> num > 1000)
    .map(num -> "Number: " + num)
    .collect(Collectors.toList());

실행 결과 (평균):

  • for문: 약 35-50ms
  • 스트림: 약 40-55ms
  • 거의 비슷한 성능

병렬 처리의 경우

// 병렬 스트림 사용
int sum3 = numbers.parallelStream()
    .mapToInt(Integer::intValue)
    .sum();

실행 결과 (8코어 환경):

  • 순차 스트림: 약 45-65ms
  • 병렬 스트림: 약 15-25ms
  • 병렬 스트림이 약 2-3배 빠름

성능 최적화 팁

  1. 작은 데이터셋 (1000개 미만): for문이 더 빠름
  2. 중간 데이터셋 (1000-10000개): 스트림과 for문 성능 비슷
  3. 큰 데이터셋 (10000개 이상): 병렬 스트림 고려
  4. 복잡한 연산: 스트림이 가독성 측면에서 유리

메모리 사용량

스트림은 중간 연산을 위해 추가 메모리를 사용한다:

  • for문: O(1) 추가 메모리
  • 스트림: O(n) 추가 메모리 (중간 결과 저장)

하지만 가독성과 유지보수성 측면에서는 스트림이 훨씬 유리하다:

// 복잡한 데이터 처리의 경우 - 스트림이 더 유리
List<String> result = data.stream()
    .filter(condition1)
    .map(transformation1)
    .filter(condition2)
    .map(transformation2)
    .collect(Collectors.toList());

결론: 성능이 중요한 경우에는 실제 테스트를 해보고, 대부분의 경우 코드 가독성을 우선시하는 것이 좋다.

마무리

스트림 API는 처음에는 익숙하지 않지만, 한 번 익숙해지면 코드 작성이 훨씬 편해진다. 특히 함수형 프로그래밍의 개념을 자바에서도 사용할 수 있게 해주는 강력한 도구다.

처음에는 간단한 예제부터 시작해서 점진적으로 복잡한 로직에 적용해보는 것을 추천한다. 스트림을 잘 활용하면 코드의 가독성과 유지보수성을 크게 향상시킬 수 있다.


참고 자료:



Written by@[namu]
모바일, 스마트폰, 금융, 재테크, 생활 정보 등