본문 바로가기

개발 언어 및 알고리즘 기초/JAVA 기초

[Java] 스트림(Stream) 요소 처리

자바 공부 중 Stream에 대한 내용이 복잡하다고 느껴, 정리한 것에 대한 기록이다. 아래 책을 공부 중이다.

신용권, 임경균, 『이것이 자바다』, 한빛미디어(2023), p120-121.

 

 

 

 Stream(스트림) 이란? 

 Stream(스트림)은 for문이나 Iterator 외에 컬렉션 및 배열의 요소를 반복 처리 하기 위한 또 다른 방법 중 하나이다. Stream과 Iterator는 유사하지만 아래와 같은 차이를 가진다.

1. 외부 반복자인 Iterator 및 for문과 달리 '내부 반복자'로 처리 속도가 빠르고 병렬 처리에 효율적이다.
2. 람다식으로 다양한 요소 처리를 정의할 수 있다.
3. 중간 처리와 최종 처리를 수행하도록 파이프 라인을 형성할 수 있다. 

 

 차이점들을 하나씩 보자. 우선 내부 반복자와 외부 반복자에 어떤 차이로 인해 내부 반복자의 처리 속도가 더 빠른 것일까? 외부 반복자는 컬렉션 요소들을 컬렉션 바깥쪽으로 반복해서 가져와 처리한다. 하지만 스트림은 요소 처리 방법 자체를 컬렉션 내부로 주입시켜서 반복 처리 한다. 이러한 특징은 병렬 처리도 더 효율적으로 처리할 수 있게 한다. 외부 반복자는 요소를 하나씩 가져와서 처리해야 하지만, 내부 반복자는 처리 방법 자체가 컬렉션에 들어가기 때문에, 특정한 기준으로 요소들을 코어 별로 나누어서 처리하는 병렬 처리에 더 효과적인 것이다. 

 그리고 람다식을 사용할 수 있다고 하는데, 람다식은 흔히 아는 것처럼 익명 함수를 말한다. forEach(), filter() 등과 같은 메소드 내에서 람다식을 이용해서 간단히 처리할 수 있다. 예를 들어 반복하며, 각각의 요소를 출력하고 싶은 상황이라고 해보자. 

streamVariable.forEach(item -> System.out.println(item));

 

위와 같은 람다식을 이용한 간단한 코드로 요소를 하나씩 출력할 수 있다. 

 마지막으로 중간 처리와 최종 처리를 수행하도록 파이프라인을 형성할 수 있다는 점인데, 이는 이후 파트에서 더 자세히 다루려 한다. 

 

 

 

 Stream 얻기  

1. Collection으로부터 스트림 얻기

 java.util.Collection 인터페이스는 stream()과 parallelStream() 메소드를 모두 가지고 있다. 따라서 자식 인터페이스인 List와 Set 인터페이스를 구현한 모든 Colleciton에서 객체 스트림을 얻을 수 있다. 간단한 코드는 아래와 같다. 

List<String> list = new ArrayList<String>();
Stream<String> listStream = list.stream();
Set<String> set = new HashSet<String>();
Stream<String> setStream = set.stream();

 

2. 배열로부터 스트림 얻기

 java.util.Arrays 클래스를 이용하면 다양한 종류의 배열로부터 스트림을 얻을 수 있다. 간단한 코드 예시는 다음과 같다. 

IntStream intStream = Arrays.stream(intArray);
LongStream longStream = Arrays.stream(longArray);
DoubleStream doubleStream = Arrays.stream(doubleArray);

Stream<String> strStream = Arrays.stream(strArray);

 

3. 숫자 범위로부터 스트림 얻기

 IntStream 혹은 LongStream의 정적 메소드인 range()와 rangeClosed() 메소드를 이용하면 특정 범위의 정수 스트림을 얻을 수 있다. 끝 수를 포함하는 경우 range, 아닌 경우 rangeClosed를 사용한다. 간단한 코드 예시는 다음과 같다.

IntStream intStream = IntStream.range(1, 100);	// 1~99
LongStream LongStream = LongStream.rangeClosed(1, 100);	// 1~100

 

4. 파일로부터 스트림 얻기

 java.nio.file.Files의 lines() 메소드를 이용하면 텍스트 파일의 행 단위 스트림을 얻을 수 있다. 간단한 코드 예시는 다음과 같다. 

Path path = Paths.get(ExampleClass.class.getResource("test.txt").toURI());

// path로부터 파일을 열고 한 행씩 읽으면서 문자열 스트림 생성
Stream<String> stream = Files.lines(path, Charset.defaultCharset());

 

5. 병렬처리 스트림 얻기

 요소 병렬 처리(Parallel Operating)란 전체 요소를 각각의 코어에 분할하여 할당하여 병렬적으로 처리하도록 하는 것을 말한다. 멀티 스레드는 하나의 코어에서 번갈아 가며 수행되는 것이기 때문에, 한 시점에 하나의 작업만 수행(동시성, Concurrency)되지만, 병렬 처리를 하게 되면 여러 개의 코어에서 동시에 여러 작업이 수행(병렬성, Parallelism)된다. 

 병렬처리를 위해서 자바 병렬 스트림은 PorkJoin Framework를 사용한다. 이는 Fork 단계에서 스트림의 전체 요소를 4개의 서브 요소셋으로 분할하여 각각 개별 코어에서 처리하고 Join 단계에서 3번의 결합 과정을 거쳐 최종 결과를 산출하는 과정을 말한다. 

 병렬 스트림은 다음과 같이 얻을 수 있다. 

// 컬렉션으로부터 병렬 처리 스트림 얻기
Stream<Integer> parallelStream = intList.parallelStream();

// 스트림에서 병렬 처리 스트림 얻기
Stream<Integer> parallelStream = stream.parallel();

 

 

 중간 처리와 최종 처리 

 중간 처리와 최종 처리는 스트림이 하나 이상 연결될 수 있다는 특징을 이용하여 pipeline을 형성하는 작업이다. 중간 처리에는 필터링, 매핑(요소 변환), 정렬 등의 작업이 포함된다. 최종 처리에는 반복, 집계(count, sum, average) 등의 작업이 포함된다. 주의해야 할 점은 최종 처리 없이 오리지널 스트림 혹은 중간 처리 만으로는 해당 스트림이 동작하지 않는다는 점이다.

 

 

 먼저 최종 처리 기능들부터 살펴보겠다. 앞서 언급했듯, 최종 처리 기능에는 반복, 집계 등의 작업이 있다.

기능 리턴 타입 메소드 설명
루핑 void forEach(Consumer<? super T> action) T 반복
매칭 boolean allMatch(Predicate<T> predicate) 모든 요소가 만족하는지 여부
boolean anyMatch(Predicate<T> predicate) 최소 하나의 요소가 만족하는지 여부
boolean noneMatch(Predicate<T> predicate) 모든 요소가 만족하지 않는지 여부
기본
집계
long count() 요소 개수
OptionalXXX findFirst() 첫 번째 요소
Optional<T>
OptionalXXX
max(Comparator<T>)
max()
최대 요소
Optional<T>
OptionalXXX
min(Comparator<T>)
min()
최소 요소
OptionalDouble average() 요소 평균
int, long, double sum() 요소 총합
커스텀
집계

Optional<T> reduce(BinaryOperator<T> accumulator) 두 개의 매개값을 받아 accumulator를
수행하여 하나의 값으로 만드는 것 반복

T reduce(T identity, BinaryOperator<T> accumulator)
요소 수집 R collect(Collector<T, A, R> collector) T 요소를 A 누적기가 R에 저장

 

1. looping (루핑)

 looping은 스트림에서 요소를 하나씩 반복해서 가져와 처리하는 기능이다. 매개타입 Consumer는 함수형 인터페이스로 매개값을 처리(소비)하는 accept() 메소드를 가지고 있어 람다식으로 표현이 가능하다. 간단한 사용 예시는 아래와 같다. 아래 코드를 실행하면 스트림 내 요소들이 하나씩 출력된다.

stream.forEach(item -> System.out.println(item));

 

2. matching (매칭)

 matching은 요소 조건이 만족하는지 여부를 확인하는 기능이다. 총 세 가지의 매칭 메소드가 있는데, allMatch()는 스트림 내 모든 요소의 predicate가 true를 리턴해야 true를 리턴하고, anyMatch()는 요소 중 하나라도 predicate가 true를 리턴하면 true를 리턴하며, noneMatch()는 모든 요소의 predicate가 false를 리턴해야 true를 리턴한다. 간단한 코드 예시는 아래와 같다.

// 모든 학생이 남학생인지 확인
boolean res = studentList.stream()
		.allMatch(s -> s.getSex().equals("남"));
            
// 남학생이 한 명이라도 있는지 확인
res = studentList.stream()
		.anyMatch(s -> s.getSex().equals("남"));

// 남학생이 한 명도 없는지 확인
res = studentLsit.stream()
		.noneMatch(s -> s.getSex().equals("남"));

 

3. 기본 집계 

 기본 집계 기능에는 우리에게 익숙한 다양한 처리 방법이 포함되어 있다. 그 중에서도 findFirst(), max(), min(), average()의 리턴값이 Optional<T> 혹은 OptionalXXX라는 독특한 리턴값을 가지는데, 이는 최종값을 저장하는 객체로, get(), getAsXXX()을 호출하여 최종값을 얻을 수 있다. 굳이 int, double, long 등의 타입이 아니라 Optional 타입을 이용하는 이유는 집계값이 존재하지 않을 경우 디폴트 값을 설정하거나 집계값을 처리하는 Consumer를 등록할 수 있기 때문이다.

 컬렉션에 요소가 없을 경우 집계값을 산출해낼 수 없고, 이렇게 되면 NoSuchElementException이 발생하게 된다. 이를 방지하기 위해 다음 코드와 같은 방식을 이용하여 디폴트 값을 설정하거나 집계값을 다른 방식으로 처리할 수 있도록 한다.

// 방법1: isPresent() 메소드가 true를 리턴할 때만 집계값 얻기
OptionalDouble avg = stream.average();
if (avg.isPresent())
	System.out.println("평균: " + avg);
else
	System.out.println("평균: 0.0");
    
// 방법2: orElse() 메소드로 디폴트 값 설정
double avg = stream
	.average()
    .orElse(0.0);

// 방법3: ifPresent() 메소드로 집계값이 있는 경우만 동작하는 람다식 제공
stream
	.average()
    .ifPresent(avg -> System.out.println("평균: " + avg));

 

4. 커스텀 집계 

 커스텀 집계 기능은 매개 변수로 받은 BinaryOperator를 이용하여 두 요소를 계산하는 것을 반복하여 결국 하나의 값을 도출해 내는 집계이다. BinaryOperator가 요구하는 계산이 더하기라면 결국 리턴값은 모든 요소를 더한 값이 될 것이다. identity 매개값이 있는 경우 Optional이 아닌 타입을 리턴하는 이유는 identity가 디폴트 값의 역할을 하기 때문이다. 이용 예시는 아래와 같다.

// sum()과 동일한 결과 산출
int sum = Arrays.stream(intAry)
	.reduce((a, b) -> a+b);

 

5. 요소 수집 

 요소를 수집한다는 것은 필터링 혹은 매핑 한 후의 요소들을 새로운 컬렉션에 수집하여 리턴하는 것을 말한다. 매개 타입으로 Collector가 주어져 있는데, 이에 대해서 이해하고 나면 요소 수집 자체에 대해서도 이해하기 더 수월하다. 

 Collector 클래스의 정적 메소드는 다음과 같다. 아래와 같은 정적 메소드들을 활용하여, 어떤 타입으로 수집될지 결정되어 변환되고 최종적으로 리턴되는 것이다. 

리턴 타입 메소드 설명
Collector<T, ?, List<T>> toList() T를 List에 저장
Collcetor<T, ?, Set<T>> toSet() T를 Set에 저장
Collector<T, ?, Map<K, U>> toMap(                                               
      Function<T, K> keyMapper,     
      Function<T, U> valueMapper))
T를 K와 U로 매핑하여 K를 키로,
U를 값으로 Map에 저장
Collector<T, ?, Map<K, List<T>>> groupingB(Function<T, K> classifier) T를 K로 매핑하고 K를 키로 해
List<T>를 값으로 갖는 Map 컬렉션 생성

 

 아래 예시 코드는 성별이 남자인 학생들만 필터링하여 List 컬렉션으로 수집하는 코드이다. List 컬렉션인 경우는 방법2처럼 스트림에 바로 toList()를 사용하여 더 간편하게 나타낼 수 있다. 

// 방법 1
List<Student> maleList = studentList.stream()
				.filter(s -> s.getSex().equals("남")
				.collect(Collectors.toList());

// 방법 2
List<Student> maleList = studentList.stream()
				.filter(s -> s.getSex().equals("남")
				.toList();

 

 다음 예시는 성별로 학생들을 grouping하는 경우이다. 이때 Collectors.groupintBy() 메소드는 grouping 후 매핑 및 집계를 수행할 수 있도록, 두 번째 매개값 Collector를 가질 수 있다. 다음 정적 메소드들로 두 번째 매개값으로 사용될 Collector를 얻을 수 있다. 

리턴 타입 메소드 설명
Collector mapping(Function, Collector) 매핑
averagingDouble(ToDoubleFunction) 평균값
counting() 요소 수
maxBy(Comparator)
minBy(Comparator)
최대/최소값
reducing(BinaryOperator<T>)
reducing(T identity, BinaryOperator<T>)
커스텀 집계값

 

이를 활용하여 작성된 예시 코드는 아래와 같다. 아래 코드는 성별로 학생들을 grouping한 후 평균값을 Map의 value로 저장한다.

// 성별로 grouping한 그대로 Map에 저장
Map<String, List<String>> map1 = studentList.stream()
			.collect(
				Collectors.groupingBy(s -> s.getSex()));

// 성별로 grouping하고 그 평균 점수를 Map에 저장
Map<String, Double> map2 = studentList.stream()
			.collect(
				Collectors.groupingBy(
					s -> s.getSex(),
					Collectors.averagingDouble(s -> s.getScore())
				)
			);

 

 

 

 다음은 중간 처리 기능이다. 중간 처리 기능에는 필터링, 매핑, 정렬 등의 작업이 있다.

기능 리턴 타입 메소드 설명
필터링 Stream
IntStream
LongStream
DoubleStream
distinct() - 중복 제거
filter(Predicate<T>) - 조건 필터링
- 인자 Predicate의 경우 조건 설정을 위한 함수형 인터페이스로 람다식 사용 가능
매핑
(요소변환)
Stream map(Function<T, R>) T -> R로 변환
XXXStream mapToXXX(ToXXXFunction<T>) T -> XXX 로 변환
기본 타입 및 wrapper 간 매핑
[Long/Double]Stream as[Long/Double]Stream() int -> long
int/long -> double
Stream<Integer/Long/Double> boxed() 기본타입 -> Wrapper 객체 요소
복수 개
요소로

Stream<R> flatMap(Function<T, Stream<R>>) T -> Stream<R>
XXXStream flatMapToXXX(Function<T, XXXStream>>) T -> XXXStream
정렬
Stream<T> sorted() comparable 요소를 정렬한 새 스트림 생성
Stream<T> sorted(Comparator<T>) Comparator에 따라 정렬한 새 스트림 생성
루핑 Stream<T> peek(Consumer<? super T>) T 반복

 

1. 필터링

 필터링의 기능을 가지는 중간 처리 메소드는 distinct()와 filter()가 있다. distinct() 메소드는 객체 스트림일 경우 equals() 메소드의 리턴값이 true인 요소들을 동일한 요소로 판단해 제거한다. 

list.stream()
	.distinct()
    .forEach(n -> System.out.println(n));

 

 filter() 메소드의 경우 매개값으로 주어지는 Predicate가 true를 리턴하는 요소만 필터링한다. 

// 리스트 내 2의 배수만 남겨서 출력
list.stream()
	.filter(n -> n % 2 == 0)
    .forEach(n -> System.out.println());
    
// 리스트 내 a로 시작하는 string만 남겨서 출력
list.stream()
	.filter(s -> s.stratsWith("a"))
    .forEach(s -> System.out.println(s));

 

2. 매핑

 매핑(mapping)은 스트림의 요소를 다른 요소로 변환하는 기능이다. 매핑 메소드들은 mapToXXX의 형태를 가지는데, 매개 타입인 Function은 함수형 인터페이스로 매개값을 리턴값으로 변환하는 applyXXX 메소드를 가진다. 간단한 코드 예시는 아래와 같다. 아래 코드는 score과 name 필드를 가지는 student 리스트에서 score 값으로 매핑하여 이를 출력하는 기능을 한다. 

studentList.stream()
	.mapToInt(s -> s.getScore())
    .forEach(score -> System.out.println(score));

 

 혹은 기본 타입 간 매핑이나, 기본 타입을 Wrapper 객체 요소로 매핑하는 경우 아래와 같이 보다 간단하게 코드를 작성 가능하다. 

int[] intAry = { 1, 2, 3, 4, 5 };

intAry.stream()
	.asDoubleStream()
    .forEach(d -> System.out.println(d));
    
intAry.stream()
	.boxed()
    .forEach(obj -> System.out.println(obj.intValue));

 

3. 복수 개 요소로

 flatMapXXX() 메소드는 하나의 요소를 복수 개 요소로 변환하는 역할을 한다. 예를 들어 단어 여러 개로 구성된 하나의 문자열을 여러 개의 단어로 쪼개는 등의 역할인 것이다. 

// 문장 스트림을 단어 스트림으로 
strList.stream()
	.flatMap(data -> Arrays.stream(data.split(" "))
    .forEach(word -> System.out.println(word));

// 문자열 숫자 목록 스트림을 숫자 스트림으로
strList.stream()
	.flatMapToInt(data -> {
    	String[] strAry = data.split(" ");
        int[] intAry = new int[strAry.length];
        for (int i = 0; i < strAry.length; i++)
        	intAry[i] = Integer.parseInt(strAry[i].trim());
        return Arrays.strem(intAry);
      })
     .forEach(number -> System.out.println(number));

 

4. 정렬

 정렬을 할 때는 스트림의 요소가 객체일 경우 객체가 Comparable을 구현하고 있어야 하고, 그렇지 않으면 ClassCastException이 발생한다. Comparable이 구현된 객체를 정렬하는 간단한 코드 예시는 아래와 같다.

// 오름차순 정렬
intAry.stream()
	.sorted()
    .forEach(i -> System.out.println(i));
    
// 내림차순 정렬
intAry.stream()
	.sorted(Comparator.reverseOrder())
    .forEach(i -> System.out.println());

 

 Comparable이 구현되어 있지 않다면, 구현하여 이용하는 방법도 있지만 비교자(Comparator)를 제공하는 방식응로도 사용 가능하다. 비교자는 람다식으로도 작성할 수 있다. 아래 코드는 name과 score 필드를 가진 student 클래스를 score 기준으로 비교하여 정렬하는 코드이다. 

studentList.stream()
	.sorted((s1, s2) -> Integer.compare(s1.getScore(), s2.getScore()))
    .forEach(s -> System.out.println(s.name + ": " + s.score));

 

4. looping (루핑)

 루핑은 요소를 하나씩 반복해서 가져와 처리하는 것이다. 앞서서 본 최종 처리자인 forEach()와 동일한 역할을 하지만 중간처리자인 peek()은 최종 처리자가 아니기 때문에 peek 이후 최종 처리자가 필요하다는 것이 유일한 차이점이다. 

int total = Arrays.stream(intAry)
	.filter(a -> a % 2 == 0)
    .peek(n -> System.out.println(n))
    .sum();	// 최종 처리 sum()이 없으면 동작하지 않음
728x90