본문 바로가기
컴퓨터/JAVA

[JAVA] 스트림을 사용해보자

by 도도새 도 2023. 11. 9.

자바 스트림을 사용하자!

 

자바 스트림(Stream)은 자바 8부터 도입된 기능으로, 컬렉션을 함수형 프로그래밍 스타일로 처리할 수 있는 강력한 기능을 지원한다.

스트림은 데이터의 흐름이다. 배열, 컬렉션 등에 여러가지 함수를 적용, 조합하여 원하는 결과를 얻을 수 있게 된다.

 

스트림의 특징

  • 스트림은 데이터 소스를 변경하지 않는다. 스트림은 데이터 소스로부터 데이터를 읽기만 할 뿐, 데이터 소스를 변경하지 않는다.
  • 스트림은 일회용이다. 스트림은 한번 사용하면 닫혀서 재사용이 불가능하다.
  • 지연 처리(Lazy invocation)_ 스트림의 최종 결과는 최종작업(최종 연산)이 이루어 질 때 계산된다. 즉, 예를 들어 스트림 내부에서 외부의 데이터를 변경했을 경우, 해당 변경하는 함수는 스트림의 최종작업이 이뤄질 때 계산된다.

 

간단한 stream 사용 예시는 아래와 같다.

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

int sum = numbers.stream()
                .filter(n -> n % 2 == 0)  // 중간 연산: 짝수만 필터링
                .map(n -> n * n)           // 중간 연산: 각 숫자를 제곱
                .reduce(0, Integer::sum);  // 최종 연산: 합계 구하기

(함수형 프로그래밍은 프로그램을 함수의 조합으로 작성하고, 상태 변경 및 가변 데이터보다는 불변 데이터와 함수를 강조하는 프로그래밍 패러다임)

 

스트림을 사용하는 방법

 

스트림의 사용은 아래 순서를 따른다

스트림 생성 > 중간 연산 > 최종 연산

 

스트림 생성

 

스트림 생성

컬렉션(Collection)으로부터 스트림을 생성한다. 여기서 컬렉션이란 자바 컬렉션의 하위 클래스를 모두 포함한다. Map의 경우 Collection 인터페이스를 상속받지 않으나 Colleciton으로 분류한다.

 

출처:https://data-flair.training/blogs/collection-framework-in-java/

아래의 방법을 이용하여 컬렉션을 스트림으로 변환 할 수 있다.

 

.stream()

컬렉션 클래스에서 제공하는 .stream()메서드를 사용하여 컬렉션을 스트림으로 변화시킬 수 있다.

List<String> list = Arrays.asList("apple", "banana", "orange");
Stream<String> stream = list.stream();

 

Stream.of()

정적 메서드를 사용하여 스트림을 생성할 수 있다.

Stream<String> stream = Stream.of("apple", "banana", "orange");

 

IntStream();

IntStream, LongStream, DoubleStream등을 사용하여 숫자 범위에서 스트림을 생성할 수 있다.

IntStream.range(1, 5);  // 1부터 4까지의 정수 스트림

 

Stream.generate()

Supplier를 사용하여 무한한 스트림을 생성한다. Supplier<T>에 해당하는 람다로 값을 넣을 수 있게 된다. 이때 limit를 사용하여 크기를 제한할 수 있다.

 

예를 들어 아래코드는 에러를 뱉는다

Stream<String> genStream = Stream.generate(()->"hello world");
System.out.println(genStream.toList().get(2));

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

 

즉, 무한히 스트림이 생성되고 있음을 알 수 있다. 따라서 아래와 같은 형태로 사용한다.

Stream<String> genStream = Stream.generate(()->"hello world").limit(5);
System.out.println(genStream.toList().get(2));

 

Stream.iterate()

iterate 메서드는 초기값과 해당 값을 다루는 람다를 이용하여 스트림을 생성하게 된다.

Stream<Integer> itrStream = Stream.iterate(20, val->val-1).limit(100);
System.out.println(itrStream.toList().get(15)); //5

 

Stream.concat()

두 개의 스트림을 연결하여 새로운 스트림을 만들 수 있다.

Stream<Integer> s1 = Stream.of(1, 2, 3, 4, 5);
Stream<Integer> s2 = Stream.of(6, 7, 8, 9, 10);
Stream<Integer> s3 = Stream.concat(s1, s2);
//1, 2, 3, 4, 5, 6, 7, 8, 9, 10

 

중간 연산

 

중간 연산 반환 값으로 다른 스트림을 반환하는 특징을 가진다. 따라서 이어지는 체이닝이 가능하게 된다.

 

.filter()

조건에 부합하는 값만 걸러낸다. 메개변수로는 Predicate가 들어가게 된다. 이는 함수형 인터페이스이므로 아래의 형태로 사용한다. 즉, boolean을 리턴하는 함수형 인터페이스로 평가식이 들어간다.

Stream<Integer> s1 = Stream.of(1, 2, 3, 4, 5, 6)
				 .filter(n->n%2==0);
		 
s1.forEach(System.out::println);//2 4 6

 

.map()

맵은 스트림 내의 각 요소들을 특정 값으로 반환해준다. 인자로 Function 인터페이스를 받게 된다. 이 역시 함수형 인터페이스이므로 람다식을 매개변수에 삽입하게 된다.

즉, map은 각 인자에 람다 함수의 내용을 적용한 결과를 새로운 스트림으로 반환해주게 된다.

Stream<Integer> s1 = Stream.of(1, 2, 3)
				 .map(n->n+100);
		 
s1.forEach(System.out::println);//101 102 103

 

아래 형태를 이용하면 DTO에서 특정 값만을 뽑아 올 수도 있다. price만을 취하는 예시이다.

Stream<Integer> s1 = productDtoList.stream()
				 .map(Product::getPrice);

 

.sorted()

sorted함수를 이용하면 내부 매개변수로 전달하는 Comparator에 따라 정렬을 하게 된다.

Stream<Integer> s1 = Stream.of(1, 5, 2, 8, 3)
				 .sorted();
s1.forEach(System.out::println);//1 2 3 5 8
		 
Stream<Integer> s2 = Stream.of(1, 5, 2, 8, 3)
				 .sorted(Comparator.reverseOrder());
s2.forEach(System.out::println);//8 5 3 2 1

 

.distinct()

스트림에서 중복된 요소를 제거하는 역할을 한다.

Stream<Integer> s1 = Stream.of(1, 1, 2, 4, 5)
				 .distinct();
s1.forEach(System.out::println);//1 2 4 5

 

.skip()

스트림에서 처음 N개의 요소를 건너뛰고 나머지 요소들을 새로운 스트림으로 반환한다.

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

List<Integer> result = numbers.stream()
                              .skip(3) // 처음 3개의 요소를 건너뜀
                              .collect(Collectors.toList());

System.out.println(result); // 출력: [4, 5, 6, 7, 8, 9, 10]

스트림은 한 요소씩 수직정으로 실행된다. 따라서 skip, distinct등 스트림의 크기를 줄이는 연산을 앞에서 실행하게 되면 성능이 향상된다.

 

.peek()

각 요소에 특정한 동작을 할 때 사용한다. 이때 map과 다르게 반환값이 없다. 즉 요소의 원본을 변경하지 않는다. 보통 디버깅 로깅 등에 사용한다. 단, peek은 최종 연산이 아니기에 다음 최종 연산이 이어져야지 동작된다.

아래 코드는 아무 일도 일어나지 않는다.

Stream.of(1, 2, 4, 5)
				 .peek(System.out::println);

아래 코드는 콘솔에 1 2 4 5를 찍어준다.

Stream.of(1, 2, 4, 5)
				 .peek(System.out::println)
				 .toList();

 

 

최종 연산

 

앞선 과정에서 가공한 스트림으로 결과를 만들어 내는 단계이다. 이를 최종 작업(terminal operations)이라고 한다. 최종연산이 적용된 스트림은 닫히게 되며 다시 사용할 수 없다.

 

reduce()

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

reduce는 스트림의 요소를 반복하면서 각 요소를 결합하여 최종 결과를 생성한다. 위 형태에서 accumulator는 동일한 두개의 타입을 받아 하나의 결과를 반환하게 된다. 즉 누적 연산을 하는 함수이다.

아래 reduce 메서드에서 a는 이전 요소, b는 현재 처리 중인 요소를 나타낸다.

Optional<Integer> sum = Stream.of(1, 2, 4, 5)
				 .reduce((a, b)->{
					System.out.println("a :" + a + " b: " + b);
				 	return a + b;
				 	});

System.out.println(sum);

결과:
a :1 b: 2
a :3 b: 4
a :7 b: 5
Optional[12]

이외에도 accumulator에 하나의 인자, 두 개의 인자, 세 개의 인자를 넘길 수 있다.

 

.collect()

.collect 메서드는 내부에 들어오는 인자에 따라 형변환을 하며 스트림을 종료하게 된다. 이어 사용할 .toList()보다 더욱 일반적인 방법으로 사용할 수 있다. 즉, .collect는 toList의 동작을 포함할 수 있다.

 

.collect(Collectors.toList())

List<String> words = List.of("apple", "banana", "orange", "grape", "melon");

		List<String> collectedList = words.stream()
		                                 .filter(word -> word.length() > 5)
		                                 .collect(Collectors.toList());
		collectedList.forEach(System.out::println);

결과:
banana
orange

 

.collect(Collectors.toMap())

맵으로 반환한다. 맵은 키값이 유니크해야한다는 규칙이 있기 때문에 유니크한 키가 아닐 경우 에러가 발생한다. 이때 세 번째 인자로 넘기는 함수를 넘기면, 동일한 키 값이 존재할 때 어떤 것을 선택할지 선택할 수 있다.

List<Product> productList = List.of(
				new Product("aa", 2500),
				new Product("aa", 3000),
				new Product("bb", 3200),
				new Product("cc", 3500),
				new Product("dd", 8000));
				
Map<String, Integer> productMap = productList.stream()
		.collect(Collectors.toMap(Product::getProduct, Product::getPrice, (pre, af)->pre));
		
System.out.println(productMap); 

결과:
{dd=8000, cc=3500, bb=3200, aa=2500}

위 Map 자료구조의 특징을 이용해서 아래처럼 유니크한 값만 남기는 처리를 할 수 있다. 아래 코드는 리스트에서 유니크한 값만 남기는 리스트를 반환하는 코드다

 

List<Product> productList = List.of(
				new Product("aa", 2500),
				new Product("aa", 3000),
				new Product("bb", 3200),
				new Product("cc", 3500),
				new Product("dd", 8000));
				
List<Product> uniqProduct = productList.stream()
				.collect(Collectors.
						toMap(Product::getProduct,
							product->product,
							(pre, af)->pre))
				.values()//map에서 collection으로 변경
				.stream()
				.toList();
		
		uniqProduct.forEach((ele)->System.out.println(ele.getProduct()));

결과값
dd
cc
bb
aa

리스트를 스트림으로 변경한 후 map으로 변경하여 유니크 값들만 취한 후 이를 다시 collection으로 변경 한 후 스티림으로 변경한 후 리스트로 변경하였다. 마지막에는 collect(Collectors.toList())로 사용해도 동일한 결과이다.

 

collector.joining()

각 요소를 모두 합쳐 하나의 문자열로 만들어준다.

1개 혹은 3개의 파라미터를 넘길 수 있다.

public static Collector<CharSequence, ?, String> joining(CharSequence delimiter,
                                                             CharSequence prefix,
                                                             CharSequence suffix)
public static Collector<CharSequence, ?, String> joining(CharSequence delimiter)
  • 1개: 각 문자열을 나눌 delim으로 사용할 문자열을 넘긴다.
  • 3개: delim, prefix, suffix를 넘긴다. prefix와 sufix는 결과로 나올 문자열의 앞 뒤에 붙일 문자열이다.

 

List<String> fruits = List.of("Strawberry", "Pear", "Grape", "Apple");
		
String fruit_s = fruits.stream()
	.collect(Collectors.joining());

System.out.println(fruit_s);

결과:
StrawberryPearGrapeApple

 

List<String> fruits = List.of("Strawberry", "Pear", "Grape", "Apple");
		
String fruit_s = fruits.stream()
	.collect(Collectors.joining(",", "I'am ", " Haha~"));

System.out.println(fruit_s);

결과:
I'am Strawberry,Pear,Grape,Apple Haha~

 

Match()

Predicate를 파라미터로 받아 조건을 통과하는 요소가 있는지 검사한다.

boolean anyMatch(Predicate<? super T> predicate);
boolean allMatch(Predicate<? super T> predicate);
boolean noneMatch(Predicate<? super T> predicate);

 

anyMatch

  • 스트림의 요소 중 하나라도 주어진 조건을 만족하면 true를 반환하고, 모든 요소가 조건을 만족하지 않으면 false를 반환

allMatch

  • 스트림의 모든 요소가 주어진 조건을 만족하면 true를 반환하고, 하나라도 조건을 만족하지 않으면 false를 반환합니다.

noneMatch

  • 스트림의 모든 요소가 주어진 조건을 만족하지 않으면 true를 반환하고, 하나라도 조건을 만족하면 false를 반환합니다.

 

List<String> fruits = List.of("Strawberry", "Pear", "Grape", "Apple");
		
		boolean hasB = fruits.stream()
				.anyMatch(fruit->fruit.contains("b"));
		
		boolean allHasB = fruits.stream()
				.allMatch(fruit->fruit.contains("b"));
		
		boolean noneHasZ = fruits.stream()
				.noneMatch(fruit->fruit.contains("z"));
		
		System.out.println(hasB); //true
		System.out.println(allHasB); //false
		System.out.println(noneHasZ); //true

reference:

https://madplay.github.io/post/java-streams-intermediate-operations

https://velog.io/@gryoh/javaStream01

댓글