JAVA Stream

포모·2020년 12월 4일
0

JAVA API

목록 보기
1/5

🎯 스트림이란?

Stream이란 '데이터의 흐름'으로, 배열 또는 컬렉션 인스턴스에 함수 여러개를 조합하여 원하는 결과를 필터링하고 가공된 결과를 얻을 수 있습니다.
즉, 배열과 컬렉션을 함수형으로 처리할 수 있습니다.


또한, 간단하게 병렬처리(multi-threading)이 가능하다는 점입니다.
하나의 작업을 둘 이상의 작업으로 잘게 나눠서 동시에 진행하는 것을 병렬처리(parallel processing)이라고 하는데요,
쓰레드를 이용하여 많은 요소들을 빠르게 처리할 수 있습니다.


스트림의 특징

String[] strArr = {"스타벅스", "할리스", "투썸"};
List<String> strList = Arrays.asList(strArr);

Stream<String> arrayStream = Arrays.stream(strArr);
Stream<String> listStream = strList.stream();

// 출력
listStream.sorted().forEach(System.out::println);	
arrayStream.sorted().forEach(System.out::println);
  • 데이터 소스를 변경하지 않는다.

데이터 소스로부터 데이터를 읽기만할 뿐, 데이터 소스를 변경하지 않습니다.
필요하다면 정렬된 결과를 컬렉션이나 배열에 담아서 반환할 수 있습니다.

List<String> sortedList = listStream.sorted().collect(Collectors.toList());
  • 일회용이다.

Iterator와 같은 일회용입니다. Iterator로 컬렉션 요소를 모두 읽고 나면 다시 사용할 수 없는 것처럼, stream도 한번 사용하면 닫혀서 다시 사용할 수 없습니다.

  • 작업을 내부 반복으로 처리한다.

스트림을 이용한 작업이 간결한 이유 중 하나는 바로 내부 반복입니다.
내부 반복이란 반복문을 메서드의 내부에 숨길 수 있다는 것을 의미합니다.


스트림의 내용은 크게 세가지로 나눠집니다.


1. 생성하기 : 스트림 인스턴스 생성
2. 가공하기 : 필터링 및 맵핑 등 원하는 결과를 만들어가는 중간 작업
3. 결과 만들기 : 최종적으로 결과를 만들어내는 작업


1. 생성하기

배열 스트림

스트림은 배열 또는 컬렉션 인스턴스를 이용해 생성할 수 있습니다. Arrays.stream 메소드를 사용합니다.

String[] arr = new String[]{"김", "이", "박"} 
Stream<String> stream = Arrays.stream(arr);
// { "김", "이", "박" }
Stream<String> streamOfArrayPart = Arrays.stream(arr, 1, 3);
// { "이", "박" } 

CF. 출력하기

stream.forEach(name -> System.out.print(name+" "));
// 김 이 박

컬렉션 스트림

컬렉션 타입(Collection, List, Set)의 경우 인터페이스에 추가된 디폴트 메소드 stream이용해서 스트림을 만들 수 있습니다.

public interface Collection<E> extends Iterable<E> {
	default Stream<E> stream() {
 		return StreamSupport.stream(spliteratro(), false);
	}
}   

다음과 같이 컬렉션 스트림을 생성할 수 있습니다.

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

비어있는 스트림

비어있는 스트림(empty streams)도 생성할 수 있습니다. 빈 스트림은 요소가 없을 때 null 대신 사용할 수 있습니다.

public Stream<String> streamOf(List<String> list) {
	return list == null || list.isEmpty()
		? Stream.empty()
		: list.stream();

Stream.generate()

gernerate 메소드를 이용하면 Supplier<T>에 해당하는 람다로 값을 넣을 수 있습니다. Supplier<T>는 인자는 없고 리턴값만 있는 함수형 인터페이스로, 람다에선 리턴하는 값이 들어갑니다

public static<T> Stream<T> generate(Supplier<T> s) { ... }

생성되는 스트림은 크기가 정해져있지 않고 무한하기 때문에 특정 사이즈로 최대 크기를 제한해야합니다.

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

Stream.iterate()

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

Stream<Integer> interatedStream = 
		Stream.iterate(30, n->n+2).limit(5);
// [30, 32, 34, 36, 38]

기본 타입형 스트림

제네릭을 사용하면 리스트나 배열을 이용해 기본 타입(int, long, double) 스트림을 생성할 수 있습니다. 하지만 제네릭을 사용하지 않고 해당 타입의 스트림을 다룰 수도 있습니다.
rangerangeClosed는 두번째 인자인 종료지점이 포함되는지 되지 않는지에 대한 범위의 차이를 가집니다.

IntStream intStream = IntStream.range(1, 5);
// 1 2 3 4 
LongStream longStream = LongStream.rangeClosed(1, 5);
// 1 2 3 4 5 

제네릭을 사용하지 않기 때문에 불필요한 오토박싱(auto-boxing)이 일어나지 않습니다. 필요한 경우엔 boxed 메소드를 통해 boxing할 수 있습니다.

IntStream intStream = IntStream.range(1, 5).boxed();

문자열 스트링

스트링의 각 문자(char)를 IntStream으로 변환하는 예제를 만들어 보겠습니다.

IntStream charsStream = "stream".chars();
charsStream.forEach(character -> System.out.print(character+" "));
// 115 116 114 101 97 109 

이번에는 특정 문자를 기준으로 문자열을 잘라 각 요소들로 스트림을 만들어보겠습니다.

Stream<String> stringStream =
                Pattern.compile(", ").splitAsStream("스타벅스, 할리스, 투썸");
stringStream.forEach(name -> System.out.print(name+" "));

파일 스트림

Files 클래스의 lines 메소드는 해당 파일의 각 라인을 스트링 타입의 스트림으로 만들어줍니다.

Stream<String> lineStream =
                Files.lines(Paths.get("file.txt"), Charset.forName("UTF-8"));
lineStream.forEach(name -> System.out.print(name+" "));

병렬 스트림 Parallel Stream

스트림 생성 시 사용하는 stream 대신 parallelStream 메소드를 사용해서 병렬 스트림을 쉽게 생성할 수 있습니다.

String[] intArr = {"1", "2", "3"};
Stream<String> intStream = Arrays.stream(intArr);
        
int sum = strStream.parallel()
                   .mapToInt(s -> s.length())
                   .sum();
// 병렬 스트림 생성
Stream<Product> parallelStream = productList.parallelStream();

// 병렬 여부 확인
boolean isParallel = parallelStream.isParallel();

boolean isMany = parallelStream
  .map(product -> product.getAmount() * 10)
  .anyMatch(amount -> amount > 200);
  • 배열을 이용해 parallel stream을 생성합니다.
Arrays.stream(arr).parallel();
  • 컬렉션과 배열이 아닌 경우 다음과 같이 parallel 메소드를 이용해 처리합니다
IntStream intStream = IntStream.range(1, 150).parallel();
boolean isParallel = intStream.isParallel();
  • sequntial 모드로 돌리고 싶다면 sequntial 메소드를 사용합니다. (병렬 스트림이 반드시 좋은 것은 아니기 때문입니다..)
IntStream intStream = intStream.sequential();
boolean isParallel = intStream.isParallel();

스트림 연결하기

Stream.concat 메소드를 이용하여 두 개의 스트림을 연결해 새로운 스트림을 만들 수 있습니다.

Stream<String> stream1 = Stream.of("Java", "Scala", "Groovy");
Stream<String> stream2 = Stream.of("Python", "Go", "Swift");
Stream<String> concat = Stream.concat(stream1, stream2);

2. 가공하기

스트림을 가공하여 전체 요소 중에서 내가 원하는 것만 뽑아낼 수 있습니다.
이러한 가공 단계를 중간 작업(itermediate operations) 라고 하는데, 이러한 작업은 스트림을 리턴하기 때문에 여러 작업을 이어붙여(chaining) 작성할 수 있습니다.

Filtering

filter는 스트림 내 요소들을 하나씩 평가해 걸러내는 작업입니다.
인자인 Predicateboolean을 리턴하는 함수형 인터페이스로, 평가식이 들어갑니다.

Stream<T> filter(Predicate<? super T> predicate);
String[] awardArr = {"금메달", "은메달", "동메달", "참가상"};
List<String> awardList = Arrays.asList("금메달", "은메달", "동메달", "참가상");
        
Stream<String> strStream = Arrays.stream(awardArr)
				 .filter(name -> name.contains("메달"));;
strStream.forEach(name->System.out.print(name+" "));

Stream<String> stream = awardList.stream()
                		 .filter(name -> name.contains("메달"));
stream.forEach(name -> System.out.print(name + " "));


// 출력(두 출력이 동일) : 금메달 은메달 동메달

Mapping

Map은 스트림 내 요소들을 하나씩 특정 값으로 변환해줍니다. 이때 값을 변환하기 위한 람다를 인자로 받습니다.

<R> Stream<R> map(Function<? super T, ? extends R> mapper);

Mapping은 스트림의 input 값이 특정 로직을 거친후 리턴되어 새로운 스트림에 담기게 되는 작업을 의미합니다.

String[] awardArr = {"apple", "banana", "watermelon", "lemon"};
List<String> awardList = Arrays.asList("apple", "banana", "watermelon", "lemon");

Stream<String> strStream = Arrays.stream(awardArr)
        			 .map(String::toUpperCase);
strStream.forEach(name->System.out.print(name+" "));

Stream<String> stream = awardList.stream()
        			 .map(String::toUpperCase);
stream.forEach(name -> System.out.print(name + " "));

// APPLE BANANA WATERMELON LEMON

사용자가 구현한 클래스 내의 함수를 이용할 수도 있습니다.

Stream<Integer> stream = productList.stream()
 				    .map(Product::getAmount);

flatMap 메소드는 인자로 mapper를 받고 있는데, 리턴 타입이 Stream 입니다. 즉, 새로운 스트림을 생성해서 리턴하는 람다를 넘겨야합니다.
flatMap중첩 구조를 한단계 제거하고 단일 컬렉션으로 만들어주는 작업인 Flattening을 수행합니다.

// [[1], [2], [3]]
List<List<Integer>> list = Arrays.asList(Arrays.asList(1),
                                            Arrays.asList(2),
                                            Arrays.asList(3));

List<Integer> flatList = list.stream()
        .flatMap(Collection::stream)
        .collect(Collectors.toList());

flatList.forEach(e -> System.out.print(e+" "));
// [1, 2, 3]

Sorting

정렬을 Comparator를 이용합니다.

Stream<T> sorted();
Stream<T> sorted(Comparator<? super T> comparator);

인자 없이 그냥 호출할 경우 오름차순으로 정렬합니다.

List<Integer> intstream = IntStream.of(7, 3, 1, 9, 6)
                                    .sorted()		// 역순 정렬 : sorted(Comparator.reverseOrder())
                                    .boxed()
                                    .collect(Collectors.toList());

Comparatorcompare 메소드는 두 인자를 비교하여 값을 리턴합니다.

String[] shop = {"스타벅스", "투썸", "할리스"};
List<String> shopList = Arrays.asList(shop);

List<String> sortedList = shopList.stream()
                                  .sorted((o1, o2) -> o2.length() - o1.length())
                                  .collect(Collectors.toList());

/*
List<String> sortedList = sortedList.stream()
                                    .sorted(Comparator.comparingInt(String::length))
                                    .collect(Collectors.toList());
*/


sortedList.forEach(System.out::println);
// 스타벅스 할리스 투썸

Iterating

스트림 내 요소들 각각을 대상으로 특정 연산을 수행하는 메소드로 peek이 있습니다.

Stream<T> peek(Consumer<? super T> action);

스트림 내 요소들을 각각 특정 작업을 수행할 뿐 결과에 영향을 미치지 않스빈다.

int sum = IntStream.of(1, 3, 5, 6, 7, 9)
        .peek(System.out::println)
        .sum();

3. 결과 만들기

Calculating

스트림 API를 이용해 최소, 최대, 합, 평균 등 기본형 타입으로 결과를 만들어낼 수 있습니다.
스트림이 비어있는 경우 countsum0을 출력합니다.

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

평균, 최소, 최대의 경우 표현이 불가능하기 때문에 Optional을 이용해 리턴합니다.

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

System.out.print(sum);		// 스트림이 비어있는 경우 OptionalInt.empty를 출력합니다.

Reduction

reduce는 스트림에 있는 여러 요소의 총합을 낼 수 있습니다.

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

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

// 3개 (combiner)
<U> U reduce(U identity,
  BiFunction<U, ? super T, U> accumulator,
  BinaryOperator<U> combiner);
  • 같은 타입 인자 두개를 받아 같은 타입의 결과를 반환하는 함수
// example 1
OptionalInt reduced = IntStream.range(1, 4) // [1, 2, 3]
                                .reduce((a, b) -> {
                                  return Integer.sum(a, b);
                                });
                                
/// example 2
int reducedTwoParams = IntStream.range(1, 4) // [1, 2, 3]
				.reduce(10, Integer::sum); // method reference
  • 세 개의 인자를 받는 경우
Integer reducedParams = Stream.of(1, 2, 3)
  			      .reduce(10, // identity
          			      Integer::sum, // accumulator
                                      (a, b) -> {
                                        System.out.println("combiner was called");
                                        return a + b;
                                      });
// 출력 : 16
  • Combiner병렬 처리 시 각자 다른 스레드에서 실행한 결과를 마지막에 합치는 단계로, 병렬 스트림에서만 동작합니다.
Integer reducedParallel = Arrays.asList(1, 2, 3)
  .parallelStream()
  .reduce(10,
          Integer::sum,
          (a, b) -> {
            System.out.println("combiner was called");
            return a + b;
          });
          
/*
combiner was called
combiner was called
36
*/

10+1, 10+2, 10+3 이 동작하여 총 36이 나옵니다.
12+13=25, 25+11=36이 두 번 호출되어 combiner was called가 출력됩니다.

병렬 스트림이 무조건 sequntial보다 좋은 것은 아니라고 합니다. 이런 부가처리 필요하기 때문에 오히려 느린 경우도 있습니다.


Collecting

collect 메소드는 Collector 타입의 인자를 받아서 처리하는데 자주 사용하는 작업은 Collectors 객체에서 제공하고 있습니다.


아래의 리스트를 사용하여 Collecting에 대해 다뤄보겠습니다.

List<Student> stdList = Arrays.asList(new Student("학생1", 23),
                                        new Student("학생2", 25),
                                        new Student("학생3", 20),
                                        new Student("학생4", 29));

  • Collcetors.toList() : 스트림에서 작업한 결과를 담은 리스트로 반환합니다.
    map으로 각 요소의 이름을 가져온 후 Collectors.toList를 이용해서 리스트로 결과를 가져옵니다.
List<String> streamList = stdList.stream()
                                .map(Student::getName)
                                .collect(Collectors.toList());
                                
// 학생1 학생2 학생3 학생4

  • Collectors.joining() : 스트림에서 작업한 결과를 하나의 스트링으로 이어 붙일 수 있습니다.
String stdString = stdList.stream()
                    .map(Student::getName)
                    .collect(Collectors.joining());

// 학생1학생2학생3학생4

Collectors.joining은 3개의 인자를 받을 수 있습니다. (아래의 순서대로 들어갑니다.)

  • delmiter : 각 요소 중간에 들어가는 구분자
  • prefix : 맨 앞에 붙는 문자
  • suffix : 맨 뒤에 붙는 문자
String stdString = stdList.stream()
                    .map(Student::getName)
                    .collect(Collectors.joining(", ", "<", ">"));
                    
// <학생1, 학생2, 학생3, 학생4>

  • Collectors.averageingInt() : 숫자 값(Integer value)의 평균을 냅니다.
Double stdAge = stdList.stream()
                       .collect(Collectors.averagingInt(Student::getAge));

// 24.25

  • Collectors.summingInt()
int stdAge = stdList.stream()	--- (1)
                    .collect(Collectors.summingInt(Student::getAge));
                    
int stdAge = stdList.stream()	--- (2)
                    .mapToInt(Student::getAge)
                    .sum();
                    
// 97

(1) : 숫자의 합을 구합니다.

(2) : IntStream으로 바꿔주는 mapToInt 메소드를 사용해서 간단하게 표현할 수 있습니다.


  • Collectors.summarizingInt() : 합계와 평균을 한번에 얻을 수 있습니다.
IntSummaryStatistics statistics = stdList.stream()
                                         .collect(Collectors.summarizingInt(Student::getAge));
                                         
// IntSummaryStatistics{count=4, sum=97, min=20, average=24.250000, max=29}
  • 개수 getCount()
  • 합계 getSum()
  • 평균 getAverage()
  • 최소 getMin()
  • 최대 getMax()

  • Collectors.groupingBy() : 특정 조건으로 요소들을 그룹지을 수 있습니다. 여기서 받는 인자는 함수형 인터페이스 Function입니다.
// 이 예시에는 new Student("학생5", 25)를 하나 더 추가하였습니다.
Map<Integer, List<Student>> stdMap = stdList.stream()
                                            .collect(Collectors.groupingBy(Student::getAge));
           
System.out.println(stdMap);

// {20=[Student{name='학생3', age=20}], 23=[Student{name='학생1', age=23}], 25=[Student{name='학생2', age=25}, Student{name='학생5', age=25}], 29=[Student{name='학생4', age=29}]}

이 예시에서, Student 클래스에 toString()이 없다면 주소값이 들어가게 됩니다. 따라서 위와 같이 출력하기 위해서는 toString()이 있어야합니다. (한참 해맸...😭)


  • Collectors.partitioningBy() : groupingBy는 특정 값을 Function을 이용해 묶었다면, partitioningBy는 함수형 인터페이스 Predicate를 사용하여 boolean을 리턴합니다.
Map<Boolean, List<Student>> map = stdList.stream()
        .collect(Collectors.partitioningBy(el -> el.getAge() > 24));
        
// {false=[Student{name='학생1', age=23}, Student{name='학생3', age=20}], 
// true=[Student{name='학생2', age=25}, Student{name='학생4', age=29}, Student{name='학생5', age=25}]}

  • Collectors.collectingAndThen() : collect한 이후에 추가 작업이 필요한 경우에 사용할 수 있습니다. finshercollect를 한 후 실행할 작업을 의미합니다.
public static<T,A,R,RR> Collector<T,A,RR> collectingAndThen(
  Collector<T,A,R> downstream,
  Function<R,RR> finisher) { ... }
  • Collectros.toSet을 이용해 결과를 Set으로 collect한 후 Set으로 변환하는 작업을 추가로 실행
Set<Student> stdSet = stdList.stream()
                             .collect(Collectors.collectingAndThen(Collectors.toSet(),
                                                                    Collections::unmodifiableSet));

// [Student{name='학생1', age=23}, Student{name='학생3', age=20}, Student{name='학생4', age=29}, Student{name='학생2', age=25}, Student{name='학생5', age=25}]

  • Collector.of() : 이외의 필요한 로직이 있다면 collector를 직접 만들 수 있습니다.
Collector<Student, ?, LinkedList<Student>> toLinkedList = Collector.of(LinkedList::new,			// LinkedList의 생성자를 넘겨줍니다.
                                                                        LinkedList::add,			// list를 추가하는 add()메서드
                                                                        (first, second) -> {		// 결과를 조합 : 생성된 리스트를 하나의 리스트로 합칩니다.
                                                                            first.addAll(second);
                                                                            return first;
                                                                        });
LinkedList<Student> linkedList = stdList.stream().collect(toLinkedList);

System.out.println(linkedList);

// [Student{name='학생1', age=23}, Student{name='학생2', age=25}, Student{name='학생3', age=20}, Student{name='학생4', age=29}, Student{name='학생5', age=25}]

Matching

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

  • 하나라도 조건을 만족하는 요소가 있는지(anyMatch)
  • 모두 조건을 만족하는지(allMatch)
  • 모두 조건을 만족하지 않는지(noneMatch)
boolean anyMatch(Predicate<? super T> predicate);
boolean allMatch(Predicate<? super T> predicate);
boolean noneMatch(Predicate<? super T> predicate);

Iterating

foreach는 요소를 돌며 실행되는 최종작업입니다.

stdList.stream().forEach(System.out::println);

/*
Student{name='학생1', age=23}
Student{name='학생2', age=25}
Student{name='학생3', age=20}
Student{name='학생4', age=29}
Student{name='학생5', age=25}
*/

Stream은 항상 옳다?

Stream을 이용하는 것이 되려 비효율적인 경우도 있다고 합니다.
바로 for-each 문을 사용하는 경우인데요,

종료 조건이 있는 for-each 문을 구현하는 경우에 주의해야합니다.

for (int i=0; i<300; i++) {		--- (1) 100번 수행
	if ( i > 100 ) {
		break;
	}
}

IntStream.range(1, 300).forEach(i -> {	--- (2) 300번 수행
	if ( i > 100 ) {
		return;
	}
});

조금 더 확인해보기 위하여 아래와 같은 코드를 실행해보았습니다.

IntStream.range(1, 100).forEach(i -> {
    if (i > 50) {
        System.out.println(i + " if문 방문");
        return;
    }
    System.out.println(i);
});

/*
출력
1
2
3
4
...
50
51 if문 방문
52 if문 방문
...
99 if문 방문	
*/

보시는 바와 같이 분명 i>50에서 return 했음에도 불구하고 100번이 모두 실행되는 것을 확인할 수 있었습니다.
이는 최종 연산을 기준으로 중간 연산이 실행되는 lazy 연산 때문이라고 보실 수 있습니다.

이렇기에 streamfor-each문을 종료문에 사용하실 때는 각별히 주의하셔야합니다.

stream은 직관적이고 쉽게 병렬처리가 가능하고, 보통 map()floatMap() 등을 이용해야할 때 권장된다고 합니다.
하지만 단순하게 for, while의 대체로 사용해서는 안된다고 합니다.


🛴 마무리

스트림을 잘 활용하면 많은 코드를 사용하지 않고, 코드를 작성할 수 있습니다.
하지만 이해하는 것도 쉽지 않겠네요.. 계속 수정하면서 좀 더 쉽게 정리할 수 있도록 하겠습니다! 😤


참고

0개의 댓글