DEV-STUDY/Java

[Java] Stream - 기본 & 중간 연산 메서드

HwangJerry 2023. 7. 4. 12:36

자바 8 이후부터 람다와 스트림을 이용한 함수형 프로그래밍이 적극적으로 도입되었습니다. 이번에는 이 중에서 stream에 대하여 집중적으로 학습해보려 합니다.

 

스트림이란?

스트림은 foreach iterator를 사용하기보다, 메서드 체이닝을 활용하여 원하는 연산을 수행할 수 있도록 구현한 것입니다. 메서드 체이닝을 통해서 좀 더 직관적으로 어떠한 연산을 수행하는지 표시하기 때문에 유지보수성이 올라간다는 큰 장점이 있습니다.

import java.util.List;
import java.util.stream.*;

public class Main {

  public static void main(String[] args) {
    List<Integer> list = Arrays.asList(1, 2, 3);
    Stream<Integer> stream = list.stream();
    stream.forEach(System.out::println);
  }
}

위 코드에서 확인할 수 있듯이, 스트림을 사용한다는 것은 .stream()메서드를 시작으로 Stream<>이라는 제네릭 클래스를 생성하여 원하는 연산을 직관적이고 간단하게 수행할 수 있도록 하는 것입니다.

 

스트림을 보다 보면 마치 택배 유통 과정에서 정말 많은 물품이 담긴 박스들을 받고(stream), 이 박스들 중 특정 지역으로 가야 하는 박스들을 분별(filter)하고, 해당 박스들을 어떤 택배차에 싣고(map), 해당 상자들 중 목적지에 해당하는 박스들만 반환해주는 것(collect)과 같이 일련의 데이터 처리 과정을 메서드 체이닝을 통해 간결하게 구현한 것이라는 느낌을 받습니다.

 

스트림은 다음과 같은 특징이 있습니다.

(1) 람다식과 메서드 참조를 이용하여 코드가 간결합니다.

(2) 내부 반복자를 사용하여 병렬 처리가 간단합니다.

내부 반복자란, 개발자가 for문이나 while문(외부 반복자)을 통해 각각의 요소를 직접 호출하여 연산을 명령하는 방식이 아니라, 각 요소 별로 어떠한 연산을 수행할 건지만 선언해두면 컬렉션 내부에서 알아서 요소들을 순회하면서 개발자가 선언한 연산 코드를 수행하는 방식을 의미합니다.

(3) 중간 처리와 최종 처리가 존재하여, 단계별로 필요한 연산을 수행하여 결과값을 도출할 수 있습니다.

 

 

오리지널 스트림 생성하기

1. 자바 컬렉션의 스트림 생성

우리가 주로 사용하는 List의 경우에는 .stream()으로 생성이 가능합니다.

Stream<Member> stream = members.stream();

2. 배열의 스트림 생성

배열은 몇 가지 방식으로 스트림을 생성할 수 있습니다.

// 가변 인자로 스트림 생성 : Stream.of()
Stream<String> stream = Stream.of("hello", "world", "exclamation point");

// int 타입의 가변 인자로 스트림 생성 : IntStream.of()
IntStream stream = IntStream.of(1,2,3);

// 이미 선언되어 있는 배열로 스트림 생성 : Arrays.stream()
Stream<String> stream = Arrays.stream(arr);

 

3. 숫자 범위로 스트림 생성

일정 범위의 정수가 담긴 스트림을 생성하는 방법은 아래 방식이 가장 간단합니다. `range(a,b)`는 [a,b)으로 끝이 열린 구간이며, `rangeClosed(a,b)`는 [a,b]로 끝이 닫힌 구간을 나타냅니다. 즉, 아래의 두 stream은 모두 동일한 숫자를 담고 있는 겁니다.

IntStream stream1 = IntStream.range(1, 100);
IntStream stream2 = IntStream.rangeClosed(1, 99);

 

4. 빈 스트림 생성

제너릭 타입은 아무거나 들어가도 상관 없습니다.

Stream<String> stream = Stream.empty();

 

5. 람다식으로 스트림 생성 - iterate(시작값, 종료조건, 연산식)

종료 조건은 optional인데, 만약 종료 조건도 없고, .limit()도 이용하지 않는다면 종료가 되지 않아 내부 순회가 무한으로 이루어지게 되고, 무한의 값이 stream에 할당되게 될 겁니다. 이를 주의하여 사용하면 됩니다.

// 종료 조건(optional) 대신 .limit()를 이용하여 순회 종료
Stream<Integer> stream = Stream.iterate(0, n -> n + 1)
        .limit(5); // 0, 1, 2, 3, ...


// 종료 조건을 이용하여 순회 종료
Stream<Integer> stream = Stream.iterate(0, n -> n < 5, n -> n + 1);

 

6. 람다식으로 스트림 생성 - generate()

Random random = new Random();

IntStream.generate(() -> random.nextInt(45) + 1)
        .limit(6);

 

스트림 파이프라인

스트림 연산 과정은 중간 연산 메서드와 최종 연산 메서드를 통해 결과값을 도출하는 과정을 메서드 체이닝을 통해 표현합니다. 이를 학습하기 위해 인위적으로 반환 변수를 확인하고자 아래와 같이 나타낼 수도 있습니다.

import java.util.*;
import java.util.stream.*;

public class Main {

  public static void main(String[] args) {
    List<Member> list = Arrays.asList(
        new Member("이산", Member.MALE, 30),
        new Member("진영", Member.MALE, 26),
        new Member("별찬", Member.MALE, 23),
        new Member("민주", Member.FEMALE, 21)
    );
	
    // 메서드 체이닝을 사용하지 않고, 단계별로 스트림 연산의 반환값을 확인한 결과
    Stream<Member> maleFemaleStream = list.stream();
    Stream<Member> maleStream = maleFemaleStream.filter(m -> m.getSex() == Member.MALE);
    IntStream ageStream = maleStream.mapToInt(Member::getAge);
    OptionalDouble optionalDouble = ageStream.average();
    double ageAvg = optionalDouble.getAsDouble();
    
    // 실제 스트림을 사용할 때 보여지는 메서드 체이닝의 모습
    double ageAvg = list.stream()
    	.filter(m -> m.getSex() == Member.MALE)
    	.mapToInt(Member::getAge)
    	.average()
    	.getAsDouble();

    System.out.println(ageAvg);
  }

}

여기서 `.filter()`, `mapToInt()`는 중간 연산 메서드이고, .average()는 최종 연산 메서드입니다. 이처럼 스트림은 메서드 체이닝을 통해 연산을 간결하고 직관적으로 표현할 수 있는 것이 가장 큰 장점입니다.

 

이후 optional 객체에서 double 값을 뽑아내기 위해 사용되는 getAsDouble()은 stream과는 상관 없는 optional 객체의 메서드입니다.

 

1. 중간 연산 메서드

중간 연산 메서드는 연산을 수행한 뒤 스트림을 반환하며, 필터링, 매핑, 정렬, 루핑 4가지로 구성됩니다.

 

필터링

distinct() : 중복 제거를 수행합니다.

public class Main {

  public static void main(String[] args) {
    List<String> names = Arrays.asList("바코", "덱스", "토니", "토니", "김씨");
    names.stream()
      .distinct()
      .forEach(System.out::println); // 바코 덱스 토니 김씨
  }
}

 

filter() : 원하는 기준으로 요소를 필터링합니다.

import java.util.*;

public class Main {

  public static void main(String[] args) {
    List<String> names = Arrays.asList("바코", "덱스", "토니", "토니", "김씨", "바코", "바코");
    names.stream()
        .distinct() // 중복 제거
        .filter(name -> name.equals("바코")) // 바코만 남도록 필터링
        .forEach(System.out::println); // 바코 (1개; 중복을 제거하였으므로)
  }
}

 

매핑

flatMapXXX() : 컬렉션이나 배열로 감싸져 있는 원소 하나하나를 단일 원소로 반환합니다.

import java.util.*;

public class Main {

    public static void main(String[] args) {
        List<String> inputList = Arrays.asList("Hello", "world");
        
        // 각 요소 출력
        inputList.stream()
                .flatMap(data -> Arrays.stream(data.split("")))
                .forEach(o -> System.out.print(o)); // Helloworld
		
        // 각 요소 개수 확인
        long count = inputList.stream()
                .flatMap(data -> Arrays.stream(data.split("")))
                .count();
        System.out.println(count); // 10

    }
}

mapXXX() : 오리지널 스트림 내부의 요소 하나하나에 접근해서 개발자가 파라미터로 입력한 함수를 실행한 뒤, 최종연산에서 지정한 형식으로 반환해주는 메서드입니다.

import java.util.*;

public class Main {
  public static void main(String[] args) {
    List<Member> memberList = Arrays.asList(
        new Member("바코", Member.MALE, 99),
        new Member("덱스", Member.MALE, 20),
        new Member("토니", Member.MALE, 20)
    );
/*
아래 예시에서는 멤버 단위로 3개의 요소가 있는 리스트에서 각 요소의 나이만 매핑하여 추출한 뒤,
해당 3개의 나이를 기존과 같이 하나의 스트림으로 묶고 연산을 이어나가고 있습니다.
*/
    memberList.stream()
        .mapToInt(Member::getAge)
        .forEach(System.out::println); // 99 20 20
  }
}

 

asDoubleStream(), asLongStream() : 각각 지정되는 타입으로 변환하여 stream을 생성하는 메서드

import java.util.*;
import java.util.stream.*;

public class Main {
  public static void main(String[] args) {
    int[] intArray = {1, 2, 3, 4, 5};

    IntStream intStream = Arrays.stream(intArray);
    intStream
        .asDoubleStream() // DoubleStream 생성
        .forEach(System.out::println);
    System.out.println();
  }
}

boxed() : int, long, double 요소를 wrapper class인 Integer, Long, Double로 변환하여 stream을 생성하는 메서드

import java.util.*;
import java.util.stream.*;

public class Main {
  public static void main(String[] args) {
    int[] intArray = {1, 2, 3, 4, 5};

    intStream = Arrays.stream(intArray);
    intStream
        .boxed() // Stream<Integer> 생성
        .forEach(obj -> System.out.println(obj.intValue()));
  }
}

 

정렬

sorted() : 객체를 comparable 구현 방법에 따라 정렬합니다.

    public static void main(String[] args) {
        List<Member> memberList = Arrays.asList(
                new Member("바코", 22),
                new Member("덱스", 23),
                new Member("진영", 20)
        );

        memberList.stream()
                .sorted((m1, m2) -> m1.getAge() - m2.getAge()) // 오름차순 정렬
                .forEach(m -> System.out.println(m.getName())); // 진영 바코 덱스
    }

 

 

reference: https://steady-coding.tistory.com/309