본문 바로가기
안드로이드/자바

자바의 스트림에 대하여(2) - java 8

by 나이아카 2021. 10. 5.

 저번 글에서 자바의 스트림이 뭔지 간단하게 다뤘습니다. 사실 사용할 줄 아는 것과 쓰는 것, 그리고 그게 무엇인지 설명할 줄 아는 것은 명확하게 다르기 때문에 추가적으로 보완해야할 부분이 아직은 많다고 생각이 됩니다. 더욱이 잘 사용하지 못하면 안쓰는 것만 못할 것은 분명하니 아직 공부가 더 필요합니다!

 그래서 이번 글은 스트림이 가지고 있는 각각의 연산들을 살펴보려고 합니다. 저번 글에서 설명했다시피 자바 8의 스트림은 스트림의 생산, 중간 연산, 그리고 결과값의 도출로 이루어져 있기 때문에, 그에 맞춰서 살펴보겠습니다. 최대한 있는대로 넣어보려는데 가능할지는 모르겠네요.


 먼저 stream의 생성 과정부터 살펴보겠습니다.

 String 스트림

//배열 부분의 stream
String[] list = new String[]{"apple", "bun", "circle"};
Stream<String> stream = Arrays.stream(list); //1
Stream<String> stream = Stream.of(list); //2
Stream<String> streamOfArrayPart = Arrays.stream(list, 0, 1);

//collection 타입의 stream
List<String> list = Arrays.asList("apple", "bun", "circle");
Stream<String> stream = list.stream();
Stream<String> parallelStream = list.parallelStream();

 

 IntStream

//collection 타입의 stream
List<Integer> list = Arrays.asList(1,2,3,4,5);
Stream<Integer> intStream = list.stream();

//배열 타입의 IntStream
int[] data = {1,2,3,4,5};
IntStream result = Arrays.stream(data); //1
IntStream result = IntStream.of(data); //2

//범위만큼 생성
IntStream result = IntStream.range(1,5); // 1, 2, 3 ,4 가 원소로 들어있는 스트림 생성
IntStream result = IntStream.rangeClosed(1, 5) // 1 ,2 ,3 ,4 ,5 가 원소로 들어있는 스트림 생성

 

동일한 방식으로 DoubleStream도 있습니다. IntStream과 사용 방식이 거의 유사하기 때문에 생략하겠습니다. 또한 CharacterStream은 존재하지 않습니다. Stream<Character>로만 사용이 가능합니다.

List<Character> list = Arrays.asList('a', 'b', 'c');
Stream<Character> stream = list.stream();

 이런식으로 사용할 수 있습니다. 혹은 Intstream으로 변경시켜서 index 참조 방식으로 사용하는 것도 가능합니다.

 

 위와 같은 특정한 변수를 가진 스트림 말고, 랜덤한 변수를 가진 스트림도 생성할 수 있습니다. 생성 방식은 여러가지가 존재합니다. 또한 지원하지 않더라도 filter, map 등의 나중에 작성할 중간연산을 이용하면 원하는 랜덤변수를 생성할 수 있습니다.

//조건 없이 임의의 수 생성
IntStream iRand = new Random().ints();
DoubleStream dRand = new Random().doubles();
LongStream lRand = new Random().longs();

//개수 제한
IntStream iRand = new Random().ints(count);
DoubleStream dRand = new Random().doubles(count);
LongStream lRand = new Random().longs(count);

//start -> end까지(end 미포함) 범위 지정
IntStream iRand = new Random().ints(start, end);
DoubleStream dRand = new Random().doubles(start, end);
LongStream lRand = new Random().longs(start, end);

//개수 지정 및 범위 지정
IntStream iRand = new Random().ints(count, start, end);
DoubleStream dRand = new Random().doubles(count, start, end);
LongStream lRand = new Random().longs(count, start, end);

 

 물론 빈 스트림도 생성이 가능합니다. 사실상 null 대신 사용한다고 볼 수 있는데, 이는 반환값에서 바로 중간연산 및 최종 결과를 표출하기 위해 작업을 할 때, NullPointException이 뜨는 것을 방지해주는 역할을 합니다.

Stream<Integer> data = Stream.empty();

 

 여기까지가 제가 정리한 생성 부분입니다. 생성시 알아둬야 할 점은 Stream<Integer>보다는 IntStream이 오토 boxing/unboxing이 진행되지 않아 더 효율적이라는 점과 랜덤함수를 생성할 때 size를 입력하지 않거나 limit을 두지 않으면 무한으로 생성된다는 점입니다.

 또 parallelStream은 병렬 연산을 두어 더 빠르게 연산이 가능합니다. 이렇게 스트림의 생성 작업이 끝났다면 그 다음은 중간 연산 작업에 들어가야 합니다.

 아래에서부터 stream의 중간연산에는 어떤 메소드들이 있는지 살펴보겠습니다.

map, mapToInt, mapToDouble, mapToLong

List<String> names = Arrays.asList("book", "crystal", "gin");

//map
Stream<String> stream = names.stream()
                .map(String::toUpperCase);

//mapToInt
Stream<String> intStream = names.stream()
                .map(s -> s.length);

 먼저 매핑연산입니다. 매핑 연산은 각 리스트의 데이터를 매핑하는 연산으로 .map을 이용하면 차례대로 book, crystal, gin을 가져오면서 map의 파라미터로 받은 람다함수를 실행합니다. 위와 같은 코드의 경우 순서대로 book을 가져오면 BOOK으로, crystal은 CRYSTAL로 변환되어 map 메소드 아래부터 적용되기 시작합니다. 파라미터 안에 조건문이나 다른 람다 함수를 사용하게 되면 원하는 형태로 작성할 수 있습니다. mapToInt와 같은 함수는 람다의 끝을 int로 변환하는 것이라고 볼 수 있습니다.

 

flatMap

String[][] array = new String[][]{
        {"one-one", "one-two"}, {"two-one", "two-two"}};
        
Arrays.stream(array)
    .flatMap(list -> Arrays.stream(list))
    .forEach(System.out::println);

flatMap은 2차원 배열을 1차원배열처럼 사용하고 싶을 때 사용하는 매핑 메소드입니다. 저같은 경우에는 2차원 배열은 보통 2차원 배열로 저장하는 이유가 있기 때문에 잘 사용하지 않을 것 같지만 분명 존재하는데에는 이유가 있다고 생각을 합니다.

 

filter

IntStream intStream = IntStream.rangeClosed(1, 10);
intStream.filter(i -> i % 2 == 0).forEach(System.out::print);

 필터링 중간 연산은 스트림의 변수에 특정 조건에 맞는 변수만을 남기는 기능을 합니다. 위와 같은 코드는 1~10까지의 숫자를 가진 intStream에서 짝수만을 골라 print하는 코드입니다. filter를 통해 람다 함수의 반환값이 true가 되는(i%2==0인) 값만을 다음 연산으로 전달해줍니다.

 

distinct

List<String> strings =
        Arrays.asList("google", "apple", "google", "apple", "samsung");

Stream<String> stream = strings.stream()
                               .distinct();
stream.forEach(System.out::println);

 이 중간 연산은 중복을 제거해주는 연산입니다. 커스텀 클래스 객체에 사용하고 싶은 경우 클래스 내부의 hashCode()와 equals() 연산을 오버라이딩해서 조건을 맞춰주시면 사용할 수 있습니다.

 

limit

List<String> strings =
        Arrays.asList("google", "apple", "google", "apple", "samsung");

Stream<String> stream = strings.stream()
                               .limit(2);
                               .forEach(System.out::println);

//google, apple 출력

 limit 연산은 stream의 갯수를 제한하는 연산입니다. 위와 같은 코드에서는 stream의 변수들 중 2개만 가지고 오는 것을 의미합니다.

 

skip

List<String> strings =
        Arrays.asList("google", "apple", "google", "apple", "samsung");

Stream<String> stream = strings.stream()
                               .skip(4);
                               .forEach(System.out::println);

//samsung 출력

 skip은 앞에서부터 파라미터로 받은 개수만큼을 생략하고 스트림을 만드는 중간연산입니다.

 

boxed

Stream<Integer> boxedStream = IntStream.range(0, 3).boxed();

 Intstream, DoubleStream등의 언박싱된 변수를 가지는 스트림들을 다시 Stream 객체로 박싱해주는 중간 연산입니다. 이는 Intstream, DoubleStream으로 사용하던 스트림을 다시 Stream으로 변경해서 코드를 작성해야 하는 경우에 사용됩니다.

 

peek

Stream.of("one", "two", "three", "four")
      .filter(e -> e.length() > 3)
      .peek(e -> System.out.println("Filtered value: " + e))
      .map(String::toUpperCase)
      .peek(e -> System.out.println("Mapped value: " + e))
      .collect(Collectors.toList());

 peek 연산은 api에서의 소개에 의하면 중간 연산의 디버깅을 위해 사용됩니다. 중간에 연산이 제대로 이루어졌는지 확인하고 싶은 경우 peek을 이용해 확인할 수 있습니다. peek 코드는 주의해야 할 점이 중간연산 메소드이기 때문에 최종 결과를 호출하는 연산을 사용하지 않으면 스트림의 지연 연산의 특성상 실행자체가 이루어지지 않아 코드의 실행여부를 확인할 수 없습니다.

 

sorted

List<Integer> list = Arrays.asList(1,2,3,4,5);

list.stream()
    .sorted(); //1, 2, 3, 4, 5

list.stream()
    .sorted(Comparator.reverseOrder()); //5, 4, 3, 2, 1
    
IntStream.range(0, 3)
        .boxed() // 박싱하지 않으면 sorted안에 comparator를 사용할 수 없음!
        .sorted(Comparator.reverseOrder()); //2, 1, 0

 정렬 연산입니다. 이를 이용하면 내림차순, 오름차순 등의 정렬을 할 수 있습니다. 이는 Arrays.sort()나 Collections.sort()와 같은 결과를 나타낸다는 것을 알 수 있습니다. 위 코드에 적어놨듯 IntStream이나 DoubleStream의 경우는 Stream<T> 객체로 변환 후 사용해야 합니다. 이는 크기 비교를 하는 메소드가 클래스 내부에 존재하기 때문에 primitive한 변수를 객체로 박싱한 후 계산해야 하기 때문입니다!

 

 위에까지가 중간 연산 관련 코드였습니다. 중간연산의 가장 큰 특징은 역시 결과를 도출하지 않으면 실행하지 않는다는 점이고, 이 사용할 때 연산을 시작한다는 지연연산의 특성 덕분에 사용하지 않는 코드를 실행하는 불상사는 일어나지 않는다는 장점이 있네요. 그럼 마지막으로 stream의 결과를 위한 연산에는 어떤 메소드들이 있는지 살펴보겠습니다.

 forEach, forEachordered

List<Integer> list = List.of(3, 2, 1, 5, 7);
list.stream().forEach(System.out::println);

list.parallelStream().forEachOrdered(System.out::println);

 먼저 순회연산입니다. 이는 반복문처럼 리스트의 변수를 모두 한 번 순회하는 연산을 의미합니다. 람다함수를 이용해 순회하면서 어떤 행동을 취할지 파라미터로 받게 됩니다. forEachOrdered는 parallelStream을 통해 만든 스트림은 forEach를 사용하면 어떤 순서로 순회할 지 보장할 수 없는데, 이 때 forEachOrdered를 사용하게 되면 순서를 보장할 수 있게 됩니다.

 

sum, max, min, count, average

IntStream iRand = new Random().ints();
List<Integer> intList = Arrays.asList(2, 3, 6, 4, 23, 10);

int max = iRand.limit(4).max().getAsInt(); // getasInt 없이 작성할 경우 Integer로 받을 수 있음
int max = intList.stream()
                 .max(Comparator.comparing(x -> x)); //Stream 이용시
                 
int min = iRand.limit(6).min().getAsInt(); // getasInt 없이 작성할 경우 Integer로 받을 수 있음
int min = intList.stream()
                 .min(Comparator.comparing(x -> x)); //Stream 이용시

long count = iRand.limit(3).count();
long count = intList.stream()
                    .filter(i -> i < 20)
                    .count(); //5

int sum = iRand.limit(4).sum();
int sum = intList.stream()
                 .filter(i -> i < 20)
                 .mapToInt(x -> x)
                 .sum(); //mapToInt를 이용해 Stream에 sum 사용
                 
double average = iRand.limit(5)
                      .average()
                      .getAsDouble(); // getAsDouble이 없는 경우 OptionalDouble로 받아야 함
double average = intList.stream()
                        .filter(i -> i < 20)
                        .mapToDouble(x -> x)
                        .average().getAsDouble();

 각 연산들은 기본적으로 IntStream, DoubleStream, LongStream에 사용되지만, mapToInt, mapToDouble, mapToLong 등을 이용하면 사용할 수 있습니다. 또한 각 연산의 반환값이 Optional 변수라는 것을 기억해야 합니다.

 

reduce

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

Optional<Integer> sum = numbers.reduce((x, y) -> x + y);
int sum = numbers.reduce(Integer::sum).get();

int initSum = numbers.reduce(10, Integer::sum);

System.out.println(sum); //55
System.out.println(initSum); //65

 reduce 연산은 결과값은 참 간단한데 묘하게 설명하기는 어려운 그런 연산입니다. 일단 첫 번째 값과 두 번째 값을 불러와 연산을 한 후, 그 연산값과 세 번째(방금 연산을 끝낸 값의 다음 값) 값을 다시 연산하는 개념입니다.

 순서대로 보자면 처음에는 x가 1, y가 2라서 x+y인 1+2를 연산하고, 그 다음으로 1+2 값을 x에 넣고 y에는 리스트의 다음 값인 3을 불러옵니다. 그렇게 또 3+3을 하고.... 이런 순서로 반복하게 됩니다. 그렇게 최종적으로 값이 1개가 남게 되면 반환합니다. 또한 리스트에 아무런 값도 없다면 NoSuchElementException을 띄우게 되는데 이는 Optional<Integer>를 사용할 경우 아래와 같은 코드를 작성함으로서 방지할 수 있습니다.

Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Optional<Integer> initSum = numbers.reduce(Integer::sum);
initSum.ifPresent(System.out::println);

 

anyMatch, allMatch, noneMatch

List<String> strings =
        Arrays.asList("google", "apple", "google", "apple", "samsung");

boolean anyMatch = list.stream()
        .anyMatch(str -> str.length > 3);

 anyMatch는 결과 연산 속 람다 함수가 하나라도 true인지 판단하는 연산이고, allMatch는 모든 연산이 true를 반환하는지, noneMatch는 모든 연산이 false를 반환하는지 판별하는 결과 연산입니다. allMatch에 빈 스트림을 넣어 판단하는 경우, 명제의 Vacuous Truth 에 의해 항상 true를 반환하게 되므로 이를 고려해서 코드를 작성해야 합니다!(이 부분은 이해하고 코드를 작성하면 좋으나 수학적인 영역이기 때문에 원인에 대한 결과 정도만 알고 가도 문제는 없을 것 같습니다. 물론 뭐든 앎은 좋은 것이니 시간날 때 수학상식 느낌으로 알아두면 좋습니다.)

 

collect

List<String> fruitList = Stream.of("banana", "apple", "mango", "kiwi", "peach", "cherry", "lemon")
      .filter(i -> i.length() > 4)
      .collect(Collectors.toList());

 collect 결과 연산은 결과값을 list, set 등의 Collector로 변환해주는 결과 연산입니다. 이를 이용하면 stream을 이용해 사용해 만들었던 결과들을 리스트에 담아 처리할 수 있습니다.


 자바8에 도입된 stream을 확인해 보았습니다. 새로 도입된 기능이라 숙련도도 낮고 배워야 할 것도 많지만, 천천히 이용하다보면 어느새 기존 자바코드처럼 이용할 수 있겠죠. 확실히 기존 자바코드보다는 코딩을 하는 입장에서 간단한 것은 확실합니다. 그러나 stream이 항상 기존 코드에 비해 성능상 우위를 점하지는 않기 때문에 이 점을 유의해서 적절하게 더 좋은 코드를 작성할 줄도 알아야 합니다. 갈 길이 머네요.

 이 게시글에 있는 메소드들은 전부 간단하게만 소개해두었습니다. 자세한 사용법은 필요할 때 구글링을 통해 추가적으로 학습하고 사용할 수 있도록 키워드들을 나열해놓은 느낌입니다만, 수준이 더 올라가면 이정도 예제만을 가지고 응용할 수 있는 능력을 지니게 되지 않을까 희망해 봅니다.

'안드로이드 > 자바' 카테고리의 다른 글

스레드(Thread)란?  (1) 2022.08.26
requireContext() vs getContext()  (0) 2022.05.10
자바의 스트림에 대하여(1) - java 8  (0) 2021.09.27
자바의 인터페이스란?  (0) 2021.08.10
(java) StringBuilder vs StringBuffer  (0) 2021.08.01

댓글