Programming/Java

함수형 프로그래밍을 위한 Java Stream 기본 개념

Jan92 2021. 9. 11. 19:42

Java Stream

 

Java는 객체지향 언어이기 때문에 기본적으로 함수형 프로그래밍이 불가능합니다. 하지만 JDK 8부터 Stream API와 람다식, 함수형 인터페이스 등을 지원하면서 Java에서도 함수형 프로그래밍이 가능하게 되었습니다.

 

JDK 8 이전에는 배열 또는 컬렉션 인스턴스를 다룰 때 for문 또는 for each 문을 돌면서 요소를 하나씩 꺼내서 다루는 방법을 사용했습니다. 간단한 경우는 상관이 없지만 로직이 복잡해질 경우 코드 양이 많아져서 여러 로직이 섞이게 되고, 메서드를 나눌 경우 루프를 여러 번 도는 경우가 발생합니다.

 

Stream은 '데이터의 흐름' 입니다. Stream을 사용하면 배열 또는 컬렉션 인스턴스에 함수 여러 개를 조합해서 원하는 결과를 필터링하여 가공된 결과를 얻을 수 있습니다. 또한 람사를 이용해서 코드 양을 줄이고 간결하게 표현할 수 있습니다.

 

결론적으로 스트림을 사용해서 배열과 컬렉션을 함수형으로 처리할 수 있게 되었습니다.

 

또한 스트림은 쓰레드를 이용해 하나의 작업을 둘 이상의 작업으로 나눠서 동시에 진행하는 병렬 처리 (parallel processing)가 가능하다는 장점이 있습니다.

 

 

 

Stream API의 특징

 

  • 원본 데이터를 변경하지 않는다.
    : Stream API는 원본의 데이터를 조회하여 원본의 데이터가 아닌 별도의 요소들로 Stream을 생성합니다. 그렇기 때문에 원본의 데이터로부터 읽기만 할 뿐이며, 정렬이나 필터링 등의 작업은 별도의 Stream 요소들에서 처리가 됩니다.
  • 일회용입니다.
    : Stream은 일회용이기 때문에 한번 사용이 끝나면 재사용이 불가능합니다. Stream이 다시 필요한 경우는 재생성해서 사용해야 합니다. 만약 사용이 끝난(닫힌) Stream을 다시 사용한다면 IllegalStateException이 발생합니다.
  • 내부 반복으로 작업을 처리합니다.
    : 스트림을 이용하면 코드가 간결해지는 이유 중 하나는 '내부 반복' 때문입니다. 기존에는 반복문을 사용하기 위해서 for, while 등과 같은 문법을 사용했지만 Stream에서는 그러한 반복 문법을 메서드 내부에 숨기고 있기 때문에 보다 간결한 코드의 작성이 가능합니다.
// for문을 사용하여 반복 작업
for (String str : list) {
    if (str.contains("a")) {
        return true;
    }
}

// stream을 통한 내부 반복 작업
boolean isExist = list.stream().anyMatch(element -> element.contains("a"));

(내부 반복 작업 예시)

 

 


 

 

// 생성하기
Stream<String> stream = Stream.of("ab", "bc", "ad", "ca", "bb", "aa");

// 가공하기
stream.filteer(s -> s.startsWith("a"))
      .map(String::toUpperCase)
      .sorted();
      
// 결과 만들기
Long count = stream.count();

 

스트림은 크게 '생성하기', '가공하기', '결과 만들기'로 나눌 수 있습니다.

 

 


 

 

1. 생성하기

 

Stream 연산을 하기 위해서는 Stream 객체를 생성해주어야 합니다. 스트림은 배열 또는 컬렉션 요소로 부터 stream() 및 of() 메서드를 사용하여 만들 수 있습니다.

 

* 주의할 점은 연산이 끝나면 Stream이 닫히기 때문에 Stream이 닫혔을 경우 다시 사용하기 위해서는 재생성해야한다는 점입니다. (일회용)

 

 

String[] arr = new String[] {"1", "2", "3"};
Stream<String> stream = Arrays.stream(arr);

* 배열로 스트림 생성하는 방법

 

 

List<String> list = Arrays.asList("1", "2", "3");
Stream<String> stream = list.stream();

* 컬렉션으로 스트림 생성하는 방법

 

 

Stream<String> stream = Stream.of("1", "2", "3");

* of() 메서드를 사용하면 스트림 객체를 바로 생성할 수 있습니다.

 

 

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

* 빈 리스트

 

 

Stream<String> builderStream = Stream.<String>builder()
	.add("1")
	.add("2")
	.add("3")
	.build();
// [1, 2, 3]

* builder() 메서드를 사용하면 사용자가 원하는 값을 입력할 수 있습니다. builder() 사용 후, 마지막에 build() 메서드로 Stream을 리턴합니다.

 

 

Stream<String> generatedStream = Stream.generate(() -> "1").limit(3);
// [1, 1, 1]

* generate() 메서드를 사용하면 파라미터에 람다를 입력하여 람다에서 리턴하는 값으로 스트림을 구성합니다. 생성된 스트림의 크기는 무제한이기 때문에 사이즈를 제한하는 것이 필요합니다.

 

 

Stream<Integer> iteratedSteam = Stream.iterate(1, n -> n + 2).limit(1);
// [1, 3, 5, 7... 19]

* iterate() 메서드를 이용하면 초기값과 해당 값을 다루는 람다를 이용해서 스트림에 들어갈 요소를 만듭니다.

 

 

IntSteam intStream = IntStream.range(1, 5);
// [1, 2, 3, 4]

* 제네릭을 사용하지 않고 원시 Stream을 생성하여 직접 해당 타입의 스트림을 다룰 수 도 있습니다. 제네릭을 사용하지 않기 때문에 오토박싱이 발생하지 않습니다. 만약 필요하다면 boxed() 메서드를 사용할 수 있습니다.

 

 


 

2. 가공하기

 

스트림은 내가 원하는 값을 출력하거나 타입을 변경하는 등의 가공을 할 수 있습니다. 이러한 작업은 스트림을 다시 반환하기 때문에 여러 작업을 붙여서 체이닝 형식으로 사용할 수 있습니다.

 

 

Stream<String> stream = Stream
		.of("1", "2", "3", "123")
		.filter(number -> number.contaions("1"));
// [1, 123]

* filtering

filter는 Stream에서 조건에 맞는 데이터만을 정제하여 더 작은 컬렉션을 만들어내는 연산입니다.

 

 

Stream<String> stream = Stream
		.of("a", "b", "c", "d")
		.map(String::toUpperCase);
// [A, B, C, D]

* mapping

map은 기존의 스트림 요소들을 변환하여 새로운 스트림을 형성하는 연산입니다. 저장된 값을 특정한 형태로 변환하는데 주로 사용되며, 이때 값을 변환하기 위해 람다식을 인자로 받습니다.

 

 

Stream<Integer> stream = Stream
		.of(1, 5, 3, 4, 2)
		.sorted();
// [1, 2, 3, 4, 5]

* sorting

Stream의 요소들을 정렬하기 위해서는 sorted를 사용해야 하며, 인자가 없을 경우 오름차순으로 정렬합니다.

 

 

int numbers = IntStream
		.of(1, 2, 3, 4, 5)
		.peek(System.out::println)

// 1
// 2
// 3
// 4
// 5

* peek

스트림 내 요소들 각각을 대상으로 특정 연산을 수행하는 메소드입니다. peek는 특정 결과를 반환하지 않는 함수형 인터페이스 Consumer를 인자로 받습니다. 정의된 작업만 수행하고 결과에는 영향을 주지 않는다는 특징이 있습니다.

 

 


 

 

3. 결과처리

 

 

long count = IntStream.of(1, 3, 5, 7, 9).count();
long sum = IntStream.of(1, 3, 5, 67, 9).sum();

* Calculating

최소, 최대, 합, 평균 등을 계산할 수 있습니다. count() 메서드는 스트림 내의 요소 개수를 반환하고, sum() 메서드는 합계를 반환합니다. 스트림이 비어있는 경우는 0을 반환합니다.

 

 

OptionalInt min = IntStream.of(1, 3, 5, 7, 9).min();
OptionalInt max = IntStream.of(1, 3, 5, 7, 9).max();
OptionalDouble average = IntStream.of(1, 3, 5, 7, 9).average();

// ifPresent() 메서드 사용 예시
IntStream.of(1, 3, 5, 7, 9)
	.min()
	.ifPresent(System.out::println);

* Calculating

min(), max(), average() 같은 경우는 스트림이 비어있는 경우를 표현할 수 없기 때문에 Optional을 이용해서 리턴합니다. 그리고 이때 ifPresent() 메서드를 사용하여 Optional을 바로 표현할 수 있습니다.

 

 

 

// accumulator
Optional<T> reduce(BinaryOperator<T> accumulator);

// identity, accumulator
T reduce(T identity, BinaryOperator<T> accumulator);

// identity, accumulator, combiner
<U> U reduce(U identity, BiFunction<U, ? super T, U> accumulator, BinaryOperator<U> combiner);

* Reduction

reduce() 메서드는 accumulator, identity, combiner 세 가지 파라미터를 받을 수 있습니다.

  • accumulator : 각 요소를 처리하는 계산 로직으로 각 요소가 올 때마다 중간 결과를 생성합니다.
  • identity : 계산을 위한 초기값으로 스트림이 비어서 계산할 내용이 없더라도 이 값은 리턴됩니다.
  • combiner : 병렬(parallel) 스트림에서 나눠서 계산한 결과를 하나로 합치는 동작을 하는 로직입니다.

 

OptionalInt reduced = 
	IntStream.range(1, 4)   //   [1, 2, 3]
	.reduce((a, b) -> {
		return Integer.sum(a, b);
	}
    
// 결과 : 6 (1 + 2 + 3)

- accumulator 하나만 인자로 받는 경우

 

 

int reduced =
	IntStream.range(1, 4)   //   [1, 2, 3]
 	.reduce(10, Integer::sum);
    
// 결과 : 16 (10 + 1 + 2 + 3)

- identity, accumulator 두 개의 인자를 받는 경우

 

 

Integer reduced = Arrays.asList(1, 2, 3)
	.parallelStream()
	.reduce(10, Integer::sum, (a, b) -> {
		return a + b;
	});

- identity, accumulator, combiner

초기값 10에 각 스트림 값을 더한 세 개의 값 (10+1=11, 10+2=12, 10+3=13)이 먼저 계산되고, combiner는 identity, accumulator를 가지고 여러 스레드에서 나눠 계산한 결과를 합치는 역할을 합니다. 

12 + 13 = 25, 25 + 11 =36 두 번 호출되어 최종 결과는 36이 됩니다.

 

 

List<String> list = Arrays.asList("a", "b", "c", "d", "e", "f");

boolean isValid1 = list.stream().anyMatch(element -> element.contains("f")); 
// true
boolean isValid2 = list.stream().allMatch(element -> element.contains("f"));
// false
boolean isValid3 = list.stream().noneMatch(element -> element.contains("f")); 
// false

* Matching (anyMatch, allMatch, noneMatch)

매칭은 조건식 람다 Predicate를 받아서 해당 조건을 만족하는 요소가 있는지 체크한 결과를 리턴합니다.

  • anyMatch : 조건을 만족하는 요소가 하나라도 있는지
  • allMatch : 모두 조건을 만족하는지
  • noneMatch : 조건을 모두 만족하지 않는지

 

List<String> list = Arrays.asList("a", "b", "c", "b", "a");

Stream<String> stream = list.stream()
		.distinct();

// [a, b, c]

* Distinct

요소들에 중복된 데이터가 존재하는 경우, 중복을 제거하기 위해 사용됩니다.

 

 

IntSummaryStatistics info = Stream
	.of("1", "2", "3", "4", "5")
	.collect(Collectors.summarizingInt(Integer::parseInt));

// IntSummaryStatistics {count = 5, sum = 15, min = 1, average = 3.00000, max = 5}

( Collectors.summarizingInt() 메서드 예시 )

 

* Collecting

Collector 타입의 인자를 받아서 처리하며 보통 자주 사용하는 작업은 Collectors 객체에서 제공합니다.

 

  • Collectors.toList() : 스트림의 작업 결과를 리스트로 반환합니다.
  • Collectors.joining() : 스트림의 작업 결과를 하나의 스트링으로 반환합니다.
  • Collectors.averageInt() : 평균값을 반환합니다.
  • Collectors.summarizingInt() : 평균, 합계, 개수 등을 한 번에 구할 때 사용합니다.
  • Collectors.groupingBy() : 특정 조건으로 요소들을 그룹화할 수 있는 메서드입니다.
  • Collectors.partitioningBy() : Predicate 함수형 인터페이스를 사용하여 스트림의 요소를 분할하는 메서드입니다.

 

 

참고자료

https://www.baeldung.com/java-8-streams-introduction
https://brownbears.tistory.com/533