모던자바인액션 Chapter 5_스트림 활용

woply·2022년 9월 23일
0

Modern Java In Action

목록 보기
5/7

Chaptet 5 미리보기

스트림 API가 지원하는 다양한 기능과 활용 방법을 소개한다. 일반적인 컬렉션 스트림 뿐만 아니라, 파일이나 배열 등 다양한 데이터 소스를 활용하여 스트림을 만드는 방법이나 무한 스트림을 다루는 경우도 함께 살펴보자.

1. 필터링

스트림의 요소를 선택하는 방법이다. 크게 Predicate 필터링 방식과 고유 요소만 필터링하는 방법이 있다. Predicate 필터링은 filter()를 사용한다. 불리언을 반환한하는 메서드 로직을 인자로 받고, true를 반환하는 모든 요소를 새로운 스트림으로 반환한다. 고유 요소의 필터링이 필요한 경우 distinct 메서드를 활용할 수 있다. hashCode나 equals를 이용해 중복을 제거한 요소를 새로운 스트림으로 반환한다.

1-1. 스트림 슬라이싱

스트림의 요소를 선택하거나 스킵하는 방법으로 일부만 잘라낸다. Predicate를 이용하는 방법과 스트림의 일부를 건너 뛰는 방법, 특정한 크기로 스트림을 제한하는 방법을 사용할 수 있다.

Predicate를 이용한 슬라이싱은 TAKEWHILE과 DROPWHILE을 사용할 수 있다. TAKEWHILE은 filter 조건에 일치하는 경우 다음 요소로 넘어간다. 만약 false가 나올 경우 스트림은 멈추고 현재까지의 요소를 새로운 스트림으로 만든다. 리스트가 정렬된 경우 사용하기 편하다. DROPWHILE은 완전히 반대로 동작한다. filter의 조건이 True가 나오기 전까지의 요소는 무시하고, True에 해당하는 요소부터 나머지 전부를 새로운 스트림으로 반환한다.

스트림을 축소하는데 사용하는 limit(n) 메서드는 n개에 해당하는 스트림을 반환한다. limit(n)이 순차적으로 갯수를 제한하는 방식이라면 skip(n)은 반대로 동작한다. 처음 n개의 요소를 제외한 나머지 요소를 스트림으로 반환한다. n개 이하의 요소를 포함하고 있는 스트림에 skip(n)을 호출하면 빈 스트림이 반환된다.

1-2. 맵핑

맵핑은 스트림의 요소를 다른 요소로 변환한다. 대표적인 매핑 api는 map과 flatmap이 있다. map을 사용하면 스트림의 각 요소를 공통된 방식으로 변환할 수 있다. 인수로 제공된 함수는 각 요소에 적용되며 함수를 적용한 결과가 새로운 요소로 변환된다. 기존의 값을 변경하는 것이 아니라, 새로운 스트림을 생성하기 때문에 여러 map()를 체이닝 하는 것이 가능하다.

flatMap은 여러 개의 스트림 요소를 하나의 스트림으로 평면화 한다. 예를들어 배열이 스트림의 요소라면, 여러개의 배열을 하나의 스트림 콘텐츠로 매핑한다. map(Arrays::stream)과 달리 flatMap은 하나의 평면화된 스트림을 반환한다. flatMap 메서드는 스트림의 요소를 새로운 스트림으로 만들고, 그렇게 만들어진 모든 스트림을 하나의 스트림으로 연결한다.

1-3. 검색과 매칭

특정한 조건으로 데이터를 검색하거나 매칭하여 분류하는 처리를 스트림 api로 처리할 수 있다. 스트림 api는 allMatch, anyMatch, noneMatch, findFirst, findAny 등 다양한 유틸리티성 메서드를 제공한다. anyMatch, allMatch, noneMatch는 스트림 쇼트서킷(일치하면 동작 그만) 기법, 즉 자바의 &&||와 같은 연산을 활용한다.

anyMatch

Predicate가 주어진 스트림에서 적어도 한 요소와 일치하는지 확인할 때 anyMatch()를 이용한다. anyMatch()는 Boolean을 반환하므로 터미널 연산(최종 연산)에 해당한다.

allMatch

스트림의 모든 요소가 주어진 Predicate와 일치하는지 검사한다.

noneMatch

noneMatch는 allMatch와 반대 연산을 수행한다. 즉, 주어진 Predicate와 일치하는 요소가 없는지 확인한다.

findAny

findAny 메서드는 현재 스트림에서 임의의 요소를 반환한다. findAny 메서드를 다른 스트림 연산과 연결해서 사용할 수 있다. findAny는 아무 요소도 반환하지 않을 수 있다. 이렇게 null은 쉽게 에러를 일으킬 수 있으므로 자바 8 라이브러리 설계자는 Optional를 만들었다. Optional은 값이 존재하는지 확인하고 값이 없을 때 어떻게 처리할지 강제하는 기능을 제공한다.

Optional

  • isPresent() : Optional이 값을 포함하면 true, 값을 포함하고 있지 않으면 false 반환
  • ifPresent(Consumer block) : 값이 있으면 주어진 블록을 실행
  • T get() : 값이 존재하면 값 반환, 없으면 NoSuchElementException 발생
  • T orElse(T other) : 값이 있으면 값을 반환, 없으면 기본값 반환

findFirst

리스트 또는 정렬된 연속 데이터를 가진 스트림은 논리적인 아이템 순서가 정해져 있을 수 있다. findFirst 메서드는 이런 스트림에서 첫 번째 요소를 찾는데 사용한다.

2. 리듀싱

모든 스트림 요소를 처리하여 필요한 결과 값으로 결과를 도출하는 질의를 리듀스 연산이라고 한다. 함수형 프로그래밍에서는 이 과정이 마치 종이를 작은 조각이 될 때까지 반복해서 접는 것과 비슷하다는 의미로 폴드(fold)라고 부른다.

2-1. 요소의 합

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

// reduce({초기값 0}, {두 요소를 조합해서 새로운 값을 만드는 BinaryOperator<T>})
int sum = numbers.stream().reduce(0, (a, b) -> a + b); // 모든 요소의 합
int sum = numbers.stream().reduce(0, Integer::sum);
Optional<T> reduce(BinaryOperator<T> accumulator);

Optional<Integer> sum = numbers.stream().reduce((a, b)(a + b));

초기값을 받지 않도록 오버로드된 reduce의 경우에는 Optional 객체를 반환한다. 스트림에 아무 요소도 없는 상황이라면 초기값이 없으므로 reduce는 값을 반환할 수 없기 때문이다.

2-2. 최댓값과 최솟값

// 최댓값
Optional<Integer> max = numbers.stream().reduce(Integer::max);
// 최솟값
Optional<Integer> min = numbers.stream().reduce(Integer::min);

reduce를 이용하면 Integer 타입을 다루는 숫자 스트림의 최대값과 최소값을 쉽게 구할 수 있다.

reduce()의 장점과 병렬화
기존의 단계적 반복으로 합계를 구하는 것과 reduce를 이용해서 구하는 것은 어떤 차이가 있을까? 반복적인 합계에서는 sum 변수를 공유해야 하므로 쉽게 병렬화하기 어렵다. 강제적으로 동기화시키더라도 결국 병렬화로 얻어야 할 이득이 스레드 간의 소모적인 경쟁 때문에 상쇄되어 버린다. 이를 가변 누적자 패턴이라고 한다. 반면, reduce를 이용하면 내부 반복이 추상화되면서 내부 구현에서 병렬로 reduce를 실행할 수 있게 된다. 즉, stream()을 parallelStream()으로 바꿔 스트림 작업을 병렬로 수행할 수 있다. 단, 람다의 상태가 바뀌지 말아야 하며, 연산이 어떤 순서로 실행되어도 결과가 바뀌지 않아야 한다.

스트림 연산 : 상태 없음과 상태 있음
map, filter 등은 입력 스트림에서 각 요소를 받아 0 또는 결과를 출력 스트림으로 보낸다. 따라서 사용자가 제공한 람다나 메서드 참조가 내부적인 가변 상태를 갖지 않는다는 가정 하에 이들은 보통 상태가 없는, 즉 내부 상태를 갖지 않는 연산이다. 쉽게 말해, 하나씩 값만 바꿔서 새로운 스트림을 만든다. 중간에 데이터를 기억할 필요가 없다. 반면, reduce, sum, max 같은 연산은 결과를 누적할 내부 상태가 필요하다. sorted나 distinct 같은 연산은 스트림의 요소를 정렬하거나 중복을 제거하려면 과거의 이력을 알고 있어야 한다. 이러한 연산을 내부 상태를 갖는 연산이라고 한다. 스트림에서 처리하는 요소 수와 관계없이 내부 상태의 크기는 한정(bound)되어 있다. sorted나 dintinct 같은 연산은 어떤 요소를 출력 스트림으로 추가하려면 모든 요소가 버퍼에 추가되어 있어야 한다. 데이터 스트림의 크기가 크거나 무한이라면 문제가 생길 수 있다. (ex. 모든 소수를 포함하는 스트림을 역순 정렬할 건데 무한이라면 → 첫 번째 요소로 가장 큰 소수가 와야하는데 값이 무한대라 끝없는 연산을 해야한다.)

3.숫자형 스트림

스트림 API는 숫자 스트림을 효율적으로 처리할 수 있도록 기본 특화 스트림(primitive stream specialization)을 제공한다. 자바 8에서는 박싱 비용을 피할 수 있도록 세 가지 기본형 특화 스트림을 제공한다. 각각의 인터페이스는 숫자 스트림의 합계를 계산하는 sum, 최댓값 요소를 검색하는 max처럼 자주 사용되는 숫자 관련 리듀싱 연산 수행 메서드를 제공한다. 또한 필요할 때 다시 객체 스트림으로 복원하는 기능도 제공한다. 특화 스트림은 박싱 과정에서 일어나는 효율성과 관련 있을 뿐, 추가 기능을 제공하는 것은 아니다.

  • int 요소에 특화된 IntStream
  • double 요소에 특화된 DoubleStream
  • long 요소에 특화된 LongStream

3-1.숫자형 스트림으로 변환하기

스트림을 특화 스트림으로 변환할 때는 mapToInt, mapToDouble, mapToLong 세 가지 메서드를 가장 많이 사용한다. map과 정확히 같은 기능을 수행하지만, Stream 대신 특화된 스트림을 반환한다.

// mapToInt() 시그니처
IntStream mapToInt(ToIntFunction<? super T> mapper);

// IntStream의 sum() 시그니처
int sum();

int calories = menu.stream()
                   .mapToInt(Dish::getCalories)
                   .sum();

위 코드를 보면 mapToInt는 Stream가 아닌 IntStream을 반환한다. IntStream가 제공하는 sum()를 이용해 칼로리 합계를 구할 수 있다.

특화형 숫자 스트림을 다시 일반 스트림으로 복원할수도 있다. 다음 예제처럼 boxed 메서드를 이용해서 특화 스트림을 일반 스트림으로 변환할 수 있다.

// IntStream의 구현체인 IntPipeline의 boxed() 내부 구현
abstract class IntPipeline<E_IN> extends AbstractPipeline<E_IN, Integer, IntStream> implements IntStream {
		...
		@Override
    public final Stream<Integer> boxed() {
        return mapToObj(Integer::valueOf);
    }
		...
}

IntStream intStream = menu.stream().mapToInt(Dish::getCalories); // 스트림을 숫자 스트림으로 변환
Stream<Integer> stream = intStream.boxed(); // 숫자 스트림을 스트림으로 변환

3-2. 숫자형 스트림의 Optional

스트림에 요소가 없는 상황과 실제 최댓값이 0인 상황을 어떻게 구별할 수 있을까? Optional을 Integer, String 등의 형식으로 파라미터화할 수 있다. 또한 OptionalInt, OptionalDouble, OptionalLong 세 가지 기본형 특화 스트림 버전도 제공한다.

OptionalInt maxCalories = menu.stream()
                              .mapToInt(Dish::getCalories)
                              .max();

// 컴파일 에러 -> Instream의 max()의 리턴 타입은 OptionalInt
Optional<Integer> maxCalories = menu.stream()
                              .mapToInt(Dish::getCalories)
                              .max();

// 값이 없을 때 기본 최댓값을 명시적으로 설정
int max = maxCalories.orElse(1);

3-3.range와 rangeClosed

자바 8의 IntStream과 LongStream에서는 range와 rangeClosed라는 두 가지 정적 메서드를 제공한다. 첫 번째 인수로 시작값을, 두 번째 인수로 종료값을 갖는다. range()는 시작값과 종료값이 결과에 포함되지 않는다. rangeClosed()는 시작값과 종료값이 결과에 포함된다.

4. 스트림 만들기

값, 배열, 파일, 함수를 이용해 스트림을 만들 수 있다. 값으로 스트림을 만들 경우, 아래와 같이 임의의 수를 인수로 받는 정적 메서드 Stream.of()를 활용할 수 있다.

/**
     * Returns a sequential {@code Stream} containing a single element.
     *
     * @param t the single element
     * @param <T> the type of stream elements
     * @return a singleton sequential stream
     */
    public static<T> Stream<T> of(T t) {
        return StreamSupport.stream(new Streams.StreamBuilderImpl<>(t), false);
    }

4-1. 값으로 스트림 만들기

// 스트림의 모든 문자열을 대문자로 변환 후 출력
Stream<String> stream = Stream.of("Modern", "Java", "In", "Action");
stream.map(String::toUpperCase)
			.forEach(System.out::println);

자바 9에서는 null이 될 수 있는 개체를 스트림으로 만들 수 있도록 Stream.ofNullable()가 추가됐다.

// 자바9 이전. null을 명시적으로 확인했어야 했음
String homeValue = System.getProperty("home");
Stream<String> homeValueStream = homeValue == null ? Stream.empty() : Stream.of(homeValue);
        
// 자바9 이후
Stream<String> homeValueStream = Stream.ofNullable(System.getProperty("home"));

flapMap과 함께 사용하는 상황에서는 아래와 같은 패턴으로 유용하게 사용 가능하다.

Stream<String> values = Stream.of("config", "home", "user")
                              .flatMap(key -> Stream.ofNullable(System.getProperty(key)));

System.getProperty() 사용법
자바를 실행할 때, 실행되는 곳의 정보를 얻어오거나 운영체제의 정보가 필요할 때가 있다. 실행 위치에 있는 파일을 읽어드려야 하는데, 현재 위치를 알 수 있는 방법 등 시스템의 정보를 가져올 때, System.getProperty()를 사용한다.System.getProperty()으로 괄호 안에 주어진 특정 문자를 적어넣으면 그 값이 String 으로 출력된다.
ex)
String dir = System.getProperty("user.home");
System.out.println(dir);
// 리눅스 인 경우 -> /home/유저명/
// macOS인 경우 -> /Users/유저명/

4-2. 배열로 스트림 만들기

배열을 인수로 받는 정적 메서드 Arrays.stream()을 이용해서 스트림을 만들 수 있다. 아래는 IntStream을 리턴하는 메서드지만 long, double을 사용할 경우 특화 시트림으로 오버로딩 된다.

int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();

파일을 처리하는 등의 I/O 연산에 사용하는 자바의 NIO API(비블록 I/O)도 스트림 API를 활용할 수 있도록 업데이트 되었다. Stream 인터페이스는 자원을 자동으로 해제할 수 있는 AutoCloseable 인터페이스를 구현하므로 finally에서 자원을 닫을 필요가 없다.

long uniqueWords = 0;
try (Stream<String> lines = Files.lines(Path.get("data.txt"), Charset.defaultCharset())) {
		uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
									     .distinct()
                       .count();
}
catch (IOException e) {
}

4-3. 함수로 무한 스트림 만들기

스트림 API는 함수에서 스트림을 만들 수 있는 두 정적 메서드 Stream.iterate()와 Stream.generate()를 제공한다. 두 연산을 이용해서 무한 스트림(infinite stream), 즉 크기가 고정되지 않은 스트림을 만들 수 있다. 무한 스트림을 만들 때 주의할 점은 크기의 명시적 제한이 필요하다는 것이다. 무한 스트림을 무한으로 만들면 계산이 반복되고, 결국 결과를 도출할 수 없게 된다.

iterate()

iterate 메서드는 초깃값과 람다를 인수로 받아서 새로운 값을 끊임없이 생산할 수 있다. 기존 결과에 의존해서 순차적으로 연산을 수행한다. 이러한 스트림을 언바운드 스트림(unbounded stream)이라고 한다. 아래 예제는 iterate()를 이용해 짝수 스트림을 생성한다. 무한 연산을 막기 위해 limit()를 이용해 10개로 갯수를 제한했다.

Stream.iterate(0, n -> n + 2)
			.limit(10)
      .forEach(System.out::println);

자바 9의 iterate 메서드는 프레디케이트를 지원한다.

public static IntStream iterate(int seed, IntPredicate hasNext, IntUnaryOperator next) {
	...
}

IntStream.iterate(0, n -> n < 100, n -> n + 4)
         .forEach(System.out::println);

generate()

generate는 Supplier를 인수로 받아서 새로운 값을 생산한다. 예를 들어, 0에서 1 사이에서 임의의 더블 숫자 다섯 개를 만드는 코드를 다음과 같이 작성 가능하다.

Stream.generate(Math::random)
      .limit(5)
      .forEach(System.out::println);
profile
7년간 마케터로 일했고, 현재는 헤렌에서 백엔드 개발자로 일하고 있습니다. 고객 가치를 설계하는 개발자를 지향하며, 개발, 독서, 글쓰기를 좋아합니다. 업이 심오한 놀이이길 바라는 덕업일치 주의자입니다.

0개의 댓글