스트림(Stream)

최준호·2021년 11월 29일
1

java

목록 보기
20/25

스트림

컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해 for문과 iterator를 이용해서 코드를 작성했다. 그러나 이러한 방식은 코드의 독해성이 떨어지고 재사용성도 떨어진다. 또한 Collection과 Iterator는 같은 기능의 메서드들이 중복해서 정의되어 있다. 예를 들어 List를 정렬할때 Collections.sort()를 사용해야하고 배열을 정렬할 때는 Arrays.sort()를 사용해야한다.

이러한 문제점을 해결하기 위해 만든것이 Stream이다. 배열이나 컬렉션 뿐 아니라 파일에 저장된 데이터도 모두 같은 방식으로 다룰 수 있다.

스트림의 특징으로는

스트림은 데이터 소스를 변경하지 않는다.
스트림은 데이터 소스로 부터 데이터를 읽기만 할 뿐, 경하지 앟는다.

스트림은 일회용이다.
스트림은 Iterator처럼 일회용이다. 한번 사용한 뒤 재사용이 필요하다면 스트림을 다시 생성해야한다.

스트림은 작업을 내부 반복으로 처리한다/
스트림 작업이 간결한 비결중 하나가 내부반복이다. 내부반복이란 반복문을 메서드의 니부에 숨길 수 있다는 것을 의미한다. forEach()는 스트림에 정의된 메서드 중의 하나로 매개변수에 대입된 람다식을 데이터 소스의 모든 요소에 적용한다.

for(String str : String[] strs)
	System.out.println(str);

일반 for문

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

stream 사용

Stream 연산

스트림이 제공하는 연산은 DB Select문으로 질의하는 것과 같은 느낌이다. 스트림이 제공하는 연산은 중간 연산최종 연산으로 분류할 수 있는데, 중간 연산은 연산결과를 스트림으로 반환하기 때문에 중간 연산을 연속해서 연결할 수 있다. 반면 최종 연산은 스트림의 요소를 소모하면서 연산을 수행하므로 단 한번만 연산이 가능하다.

stream.distinct().limit(5).sorted().forEach(System.out::println)

distinct(), limit(), sorted() = 중간 연산
forEach() = 최종 연산

지연된 연산

스트림 연산에서 중요한 점은 최종 연산이 수행되기 전까지 중간 연산이 수행되지 않는다는 것이다. 중간 연산을 호출해도 즉각적인 연산이 수행되는 것이 아니라 최종 연산이 수행되어야 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모되는 것이다.

기본형 스트림

스트림은 기본적으로 Stream<>의 형태지만 오토박싱, 언박싱으로 인한 비효율을 줄이기 위해 기본형 스트림 IntStream, LongStream, DoubleStream,이 제공된다.

병렬 스트림

스트림의 장점 중 하나로 병렬 처리가 쉽다는 것이다. 병렬 스트림은 내부적으로 이 프레임웍을 사용하여 자동적으로 연산을 병렬로 수행한다. parallel()이라는 메서드를 호출해서 병렬로 연산을 수행하도록 지시하면 stream에서 자동으로 병렬 처리를 해준다. 또한 병렬 처리를 취소하기 위해서는 sequentail()를 호출하면 된다.

스트림 만들기

스트림을 각 생성하는 방법에 대해 알아보자

컬렉션

컬렉션의 최고 부모인 Collection에 stream()이 정의되어 있어 List와 Set을 구현한 클래스들은 모두 스트림을 생성할 수 있다.

Collection.stream();

stream을 생성하는 Collection

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

과 같이 생성할 수 있다.

배열

배열을 스트림으로 생성하는 메서드는 Arrays에 static 메서드로 정의되어 있다.

Stream<String> stream = Stream.of("a","b","c");
Stream<String> stream = Stream.of(new String[]{"a","b","c"});
Stream<String> stream = Arrays.stream(new String[]{"a","b","c"});
Stream<String> stream = Arrays.stream(new String[]{"a","b","c"}, 0, 2);

와 같이 생성할 수 있다.

IntStream IntStream.of();
IntStream Arrays.stream(int[])

또한 기본형 stream으로 생성할 수 있다.

특정 범위 수

IntStream과 LongStream은 지정된 범위의 연속된 정수를 스트림으로 생성해서 반환하는 range()와 rangeClosed를 가진다.

IntStream.range(1.5);	//1,2,3,4
IntStream.rangeClosed(1,5)	//1,2,3,4,5

Int의 범위보다 큰 Long을 사용하기 위해서는 LongStream을 사용하면 된다.

임의의 수

난수를 생성하는데 사용하는 Random 클래스에 아래와 같은 인스턴스 메서드들이 포함되어 있다.

IntStream ints()
LongStream longs()
DoubleStream doubles()

이 메서드들이 반환하는 스트림은 크기가 정해지지 않은 무한 스트림이다. limit()을 함께 사용하여 스트림의 크기를 제한해주어야한다.

IntStream intStream = new Random().ints();
intStream.limit(5).forEach(System.out::println);

5개의 int범위의 임의의 숫자들이 출력된다.
지정된 범위의 난수를 발생시키는 스트림을 얻는 메서드는

IntStream ints(int begin, int end);
LongStream longs(int begin, int end);
DoubleStream doubles(int begin, int end);

IntStream ints(long streamSize, int begin, int end);
LongStream longs(long streamSize, int begin, int end);
DoubleStream doubles(long streamSize, int begin, int end);

람다식 iterate(), generate()

Stream 클래스의 iterate()와 generate는 람다식을 배개변수로 받아서 반환되는 값들을 요소로 하는 무한 스트림을 생성한다.

iterate()는 seed로 지정된 값부터 시작해서 람다식에 의해 계산된 결과를 다시 seed값으로 계산하여 반복한다.

generate()는 매개변수 타입이 Supplier<>이므로 매개변수가 없는 람다식만 허용된다.

두 메서드의 주의점은 두 메서드에 의해 생성된 스트림은 기본형 스트림 타입의 참조변수로 다룰 수 없다는 것이다.

빈 스트림

null을 반환하기 보다 빈 스트림을 반환하는게 나은 상황이 있다.

Stream empty = Stream.empty();

로 생성할 수 있다.

두 스트림의 연결

concat()은 같은 타입의 두 스트림을 연결해준다.

Stream<String> str1 = Stream.of("a","b","c");
Stream<String> str2 = Stream.of("d","e","f");
Stream<String> str3 = Stream.concat(str1, str2);

스트림의 중간 연산

stream 자르기 - skip(), limit()

skip() 처음의 요소를 지정된 수만큼 건너뛴다.
limit() 스트림의 요소를 제한한다.

IntStream intStream = IntStream.rangeClosed(1,10);
intStream.skip(3).limit(5).forEach(System.out::println);
//결과 값 4,5,6,7,8

stream 걸러내기 - filter(), distinct()

distinct() 중복 제거
filter() 조건(Predicate)에 맞지 않는 요소를 걸러냄

IntStream intStream = IntStream.of(1,2,3,3,2,4,5,5,6,1);
intStream.distinct().forEach(System.out::print);
//결과 값 123456

중복 제거 distinct()를 사용한 예시

filter()는 매개변수로 Predicate(=boolean 연산)을 필요로 하는데, 아래와 같이 연산 결과가 boolean인 람다식을 사용할 수도 있다.

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

filter()는 한번만 사용하는 것이 아닌 여러번 중복해서 사용도 가능하다.

정렬 - sorted()

sorted() 스트림 정렬
sorted()는 지정된 Comparator로 스트림을 정렬하는데, Comparator 대신 int값을 반환하는 람다식을 사용하는 것도 가능하다.

stream.sorted() 기본정렬
stream.sorted(Comparator.naturalOrder()) 기본정렬
stream.sorted((s1,s2)->s1.compareTo(s2)) 람다식 정렬
stream.sorted(String::compareTo) 위 정렬과 동일

stream.sorted(Comparator.reverseOrder()) 역순 정렬
stream.sorted(Comparator.<String>naturalOrder().reversed()) 역순 정렬

stream.sorted(String.CASE_INSENSITIVE_ORDER) 대소문자 구분하지 않는 정렬
stream.sorted(String.CASE_INSENSITIVE_ORDER.reversed()) 대소문자 구분하지 않는 역순 정렬

stream.sorted(Comparator.comparing(String::length)) 길이 순 정렬
stream.sorted(Comparator.comparingInt(String::length)) 길이 순 정렬

stream.sorted(Comparator.comparing(String::length).reversed) 길이 역순 정렬

위와 같은 간단한 사용 방법들이 존재한다.

정렬에 사용되는 기본적인 Comparator의 static 메서드로는

naturalOrder()
reverseOrder()
comparing(Function<T,U> keyExtractor)
comparing(Function<T,U> keyExtractor, Comparator<U> keyComparator)
comparingInt(ToIntFunction<T> keyExtractor)
comparingLong(ToIntFunction<T> keyExtractor)
comparingDouble(ToIntFunction<T> keyExtractor)
nullsFirst(Comparator<T> comparator)
nullsLast(Comparator<T> comparator)

들이 존재하지만 가장 기본적인 메서드는 comparing()이다. 또한 정렬 조건을 추가할 때는 thenComparing()을 사용하여 여러 조건으로 비교하여 정렬할 수 있다.

변환 - map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야할 경우 사용하는 것이 map()이다. map()은 중간연산으로 filter()처럼 여러번 적용할 수 있다.

stream.map(File::getName).filter(s->s.indexOf('.' != -1)
.map(s->s.subString(s.indexOf('.')+1))
.map(String::toUpperCase)
.distinct()
.forEach(System.out::print);

과 같이 사용할 수 있다.

조회 - peek()

연산과 연산 사이에 올바른 연산이 적용되고 있는지 확인하고 싶다면 peek()를 사용할 수 있는데 peek()는 스트림의 요소를 소모하지 않으므로 연산 사이에 여러번 사용하여도 스트림이 종료되지 않는다.

stream.map(File::getName)
.filter(s->s.indexOf('.') != -1)
.peek(s->System.out.printf("filename = %s%n", s))
.map(s->s.subString(s.indexOf('.')+1))
.peek(s->System.out.printf("exe = %s%n", s))
.forEach(System.out::println);

mapToInt(), mapToLong(), mapToDouble()

map()의 연산 결과가 기본형 스트림으로 반환해야할 경우 위 기본형 스트림 반환 map을 사용할 수 있다. 기본형 스트림의 경우 기본 제공하는
sum() 스트림 요소의 총합
average() 스트림 요소의 평균
max() 스트림 요소 중 가장 큰 값
min() 스트림 요소 중 가장 작은 값
의 메서드를 사용할 수 있다.

IntStream sutudentScoreStream = studentStream.mapToInt(Student::getTotalScore);
int allTotalScore = sutudentScoreStream.sum();

flatMap()

스트림의 요소가 배열이거나 map()의 연산결과가 배열인 경우 Stream<[]>을 Stream<>으로 변경하여 다루는 것이 더 편한 경우가 있다. 그럴때는 map() 대신 flatMap()을 사용하면 된다.

Stream<String[]> strStream = Stream.of(
	new String[]{"a","b","c"},
    new String[]{"d","e","f"}
);

가 있을 때 Stream<>으로 만들려면 flatMap()을 사용하면 된다.

이때 map()으로 Stream<>을 만들려고 하면

Stream<Stream<String>> streamStream = strStream.map(Arrays::stream);

과 같이 스트림 안에 다시 스트림이 들어가는 형태가 된다.
하지만 flatMap()을 사용하면

Stream<String> stringStream = strStream.flatMap(Arrays::stream);

다음과 같이 깔끔하게 두개의 스트림을 하나의 스트림으로 변경하여 뽑아낼 수 있다.

Optional<>

Optional<>은 지네릭 클래스로 T타입의 객체를 감싸는 래퍼 클래스이다. Optional 타입의 객체에는 모든 타입의 참조변수를 담을 수 있다.

public final class Optional<T>{
	private final T value;	//T타입의 참조변수
}

최종 연산의 결과를 그냥 반환하는게 아니라 Optional 객체에 담아서 반환하는 것이다.

Optional 생성

생성하는 메서드는 of() 또는 ofNullable()을 사용한다. of()는 일반적인 객체를 생성하는 방법과 같으며 ofNullable은 참조변수 값이 null 일 가능성이 있을 때 NullPointerException이 발생하지 않도록 해주는 생성 방법이다.

public static void main(String[] args){
    Optional<String> optVal = Optional.ofNullable(null);
    System.out.println("optVal = " + optVal);
}

실제 위의 코드를 실행했을 때 NullPointerException 대신 출력되는 값이다. 또한 Optional을 생성하여 초기화 값을 주고 싶다면 null로 초기화하기 보다

Optional<String> optVal = Optional.empty;

과 같이 초기화 해주면 ofNullable로 생성한 값과 같은 결과를 얻을 수 있다.

Optional 객체 값 가져오기

Optional 객체에 저장된 값을 가져올 때는 get()을 사용한다. Optional이 empty 일때는 NoSuchEelementException이 발생하며 이를 대비하여 orElse()로 대체 값을 지정하여 가져올 수도 있다. Optional이 empty가 대체값을 람다식으로 작성하여 반환할 수 있는 메서드 orElseGet()과 지정된 예외를 방생시키는 orElseThrow()도 있다.

public static void main(String[] args){
    Optional<String> optVal = Optional.ofNullable("abc");
    String str = optVal.get();
    System.out.println("str = " + str);
}
public static void main(String[] args){
    Optional<String> optVal = Optional.ofNullable(null);
    String str = optVal.orElse("emp");
    System.out.println("str = " + str);
}
public static void main(String[] args){
    Optional<String> optVal = Optional.ofNullable(null);
    String str = optVal.orElseGet(String::new);
    System.out.println("str = " + str);
}
public static void main(String[] args){
    Optional<String> optVal = Optional.ofNullable(null);
    String str = optVal.orElseThrow(RuntimeException::new);
    System.out.println("str = " + str);
}

여기서 주의점은 null이 아닌 empty라는 것이다. of(null)로 생성하면 NullPointerException이 발생하기 때문에 ofNullble()이나 empty()로 초기화한 Optional 객체에 사용한다는 것을 명심하자.

Optional 객체에도 Stream처럼 filter(), map(), flatMap()을 사용할 수 있다. 자세한 설명은 위에 적혀있으니 간단히 넘어가고 그 중 isPresent() 메서드는 Optional 객체의 값이 empty면 false를 아니면 true를 반환하고 ifPresent()는 값이 있으면 주어진 람다식을 실행하고 없으면 아무 일도 하지 않는다. 이를 사용하여 NullPointerException이 발생하지 않도록 람다식을 작성할 수도 있다.

public static void main(String[] args){
    Optional.ofNullable(null).ifPresent(System.out::print);
}

of(null)은 당연히 NullPointerException이 발생한다.

그 외에도 Optional 또한 기본형 Optional로 OptionalInt OptionalLong OptionalDouble 을 제공하며 기본형으로 반환하는 메서드는 getAsInt() getAsLong() getAsDouble()이 존재한다.

그러면 위에서 학습한 내용을 바탕으로 궁금한 점이 생길수 있다. 그것은 Integer는 초기화 값이 0인데 Optional.of(0)과 Optional.ofNullable()로 생성된 0을 가진 Optional 두개의 객체가 같은 것이라고 생각할 수 있을까? 출력 값만 보면 같다고 생각할 수 있지만 바로 전 학습한 isPresent()로 결과를 출력해보면 알수 있다.

public static void main(String[] args){
    Optional<Integer> integer1 = Optional.of(0);
    Optional<Integer> integer2 = Optional.empty();

    System.out.println("integer1 = " + integer1.isPresent());
    System.out.println("integer2 = " + integer2.isPresent());
}

위 코드를 실행하면

결과값을 얻을 수 있고 get()메서드로 값을 가져올 경우 integer2의 경우 NoSuchElementException이 발생한다.

Optional.nullable(null)로 생성한 객체와 Optional.empty()로 생성한 객체는 동일하게 취급한다.

스트림 최종 연산

최종 연산은 스트림의 요소를 소모해서 결과를 만들어낸다. 그래서 최종 연산후에는 스트림이 닫히고 더이상 사용할 수 없다.

최종 연산 종류

반복문

void forEach(Consumer<? super T> action)

조건 검사

boolean allMath(Predicate<? super T> predicate)
boolean anyMath(Predicate<? super T> predicate)
boolean noneMath(Predicate<? super T> predicate)
boolean noFailed = stuStream.anyMatch(s->s.getTotalScore() <= 100)

과 같이 사용할 수 있다. 그리고 findFirst()findAny()도 존재하는데

stuStream.filter(s->s.getTotalScore <= 100).findFirst();
stuStream.filter(s->s.getTotalScore <= 100).findAny();

와 같이 사용할 수 있다.

통계

long count();
int sum();
OptionalDouble average();

Optional<T> max(Comparator<? super T> comparator)
Optional<T> min(Comparator<? super T> comparator)

sum과 average는 IntStream의 경우 사용이 가능하다.

리듀싱

reduce()는 스트림의 요소를 줄여나가면서 연산을 수행하고 최종결과를 반환한다. 그래서 매개변수 타입이 BinaryOperator<>인것인데, 처음 두 요소를 가지고 연산한 결과를 갖고 다음 요소를 연산한다.

collect()

스트림 최종 연산 중 가장 복잡하지만 유용한 것이 collect()이다. collect()는 스트림 요소를 수집하면서 어떻게 수집할 것인가에 대한 벙법이 정의되어 있어야 하는데, 이를 바로 Collector 인터페이스로 구현하여 사용할 수 있다.

collect() 스트림의 최종연산, 매개변수로 컬렉터를 필요로 한다.
Collector 인터페이스, 컬렉터는 이 인터페이스를 구현해야한다.
Collectors 클래스, static 메서드로 미리 작성된 컬렉터들을 제공한다.

스트림을 컬렉션과 배열로 변환

toList() toSet() toMap() toCollection() toArray() 와 같은 메서드들을 사용하면 된다.

List<String> names = stuStream.map(Student::getName).collect(Collectors.toList());
ArrayList<String list = names.stream().collect(Collectors.toCollection(ArrayList::new));

와 같이 List와 ArrayList로 변형할 수 있고 Map은 키와 값을 지정해주어야하므로

Map<String, Student> map = stuStream.collect(Collectors.toMap(p->p.getNumber, p->p));

와 같이 학생의 번호를 key값으로 가지고 학생 객체를 value로 가지는 map을 생성할 수 있다.

그리고 스트림에 저장된 요소들을 T[] 배열로 반환하려면 toArray()를 사용하면 된다. 단, 해당 타입의 생성자 참조를 매개변수로 지정해줘야 한다. 만약 매개변수를 지정하지 않으면 반환 배열 타입은 Object[]가 된다.

Student[] arr1 = stream.toArray(Student[]::new);
Student[] arr2 = stream.toArray();	//반환 타입을 지정하지 않아서 에러
Object[] arr3 = stream.toArray();	//반환 타입을 지정하지 않으면 Object[]로 반환

통계

counting() summingInt() averagingInt maxBy minBy

public static void main(String[] args){
    //count
    Stream<Student> stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    long count1 = stuStream.count();
    System.out.println("count1 = " + count1);

    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    long count2 = stuStream.collect(Collectors.counting());
    System.out.println("count2 = " + count2);

    //sum
    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    long totalScore1 = stuStream.mapToInt(Student::getTotalSocre).sum();
    System.out.println("totalScore1 = " + totalScore1);

    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    long totalScore2 = stuStream.collect(Collectors.summingInt(Student::getTotalSocre));
    System.out.println("totalScore2 = " + totalScore2);

    //max
    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    OptionalInt topScore = stuStream.mapToInt(Student::getTotalSocre).max();
    System.out.println("topScore = " + topScore.getAsInt());

    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    Optional<Student> topScore1 = stuStream.max(Comparator.comparingInt(Student::getTotalSocre));
    System.out.println("topScore1 = " + topScore1.get().getTotalSocre());

    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    Optional<Student> topScore2 = stuStream.collect(Collectors.maxBy(Comparator.comparingInt(Student::getTotalSocre)));
    System.out.println("topScore2 = " + topScore2.get().getTotalSocre());

    //summary
    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    IntSummaryStatistics stat1 = stuStream.mapToInt(Student::getTotalSocre).summaryStatistics();
    System.out.println("stat1 = " + stat1);

    stuStream = Stream.of(new Student(100),new Student(90),new Student(70));
    IntSummaryStatistics stat2 = stuStream.collect(Collectors.summarizingInt(Student::getTotalSocre));
    System.out.println("stat2 = " + stat2);
}

다음과 같은 코드로 직접 실행해 볼수 있다. stream의 최종연산이므로 각 stream을 소비하고 나서 항상 다시 stream을 생성해주어야한다. 또한 summary는 해당 class의 분석 값이라고 할 수 있는데

다음과 같은 결과값을 확인하면 이해하기 쉬울 것이다.

reducing()

reduce 역시 collect()로 가능한데 IntStream에는 매개변수 3개짜리 collect()만 정의되어 있으므로 boxed()를 통해 IntStreamStream<Integer>로 변환해야 매개변수 1개짜리 collect()를 사용할 수 있다.

public static void main(String[] args){
    //1~46 중 중복되지 않는 6개의 수를 뽑기
    IntStream intStream = new Random().ints(1, 46).distinct().limit(6);

    //max
    OptionalInt max1 = intStream.reduce(Integer::max);
    System.out.println("max1 = " + max1.getAsInt());

    intStream = new Random().ints(1, 46).distinct().limit(6);
    Optional<Integer> max2 = intStream.boxed().collect(Collectors.reducing(Integer::max));
    System.out.println("max2 = " + max2.get());

    //sum
    intStream = new Random().ints(1, 46).distinct().limit(6);
    int sum1 = intStream.reduce(0, (a, b) -> a + b);
    System.out.println("sum1 = " + sum1);

    intStream = new Random().ints(1, 46).distinct().limit(6);
    int sum2 = intStream.boxed().collect(Collectors.reducing(0, (a, b) -> a + b));
    System.out.println("sum2 = " + sum2);
}

문자열 결합 joining()

문자열 스트림의 모든 요소를 하나의 문자열로 연결해서 변환하는 메서드이다. 스트림의 요소가 String이나 StringBuffer처럼 CharSequenece의 자손인 경우에만 결합이 가능하므로 스트림의 요소가 문자열이 아닌 경우에는 map()으로 먼저 문자여롤 변경 후 사용해야한다.

public static void main(String[] args){
    Stream<String> stream = Stream.of("java","자바","출력");
    String collect = stream.collect(Collectors.joining(",","[","]"));

    System.out.println("collect = " + collect);
}

다음과 같은 결과를 얻을 수 있다.

그룹화와 분할 groupingBy(), partitioningBy()

groupingBy()는 스트림의 요소를 Function으로, partitioningBy()는 predicate()로 분류한다. 두개의 그룹으로 나누기 위해서는 partitioningBy()에 predicate로 빠르게 분할하면 되고 그 외에는 groupingBy()로 나누어 사용하면 된다.

profile
코딩을 깔끔하게 하고 싶어하는 초보 개발자 (편하게 글을 쓰기위해 반말체를 사용하고 있습니다! 양해 부탁드려요!) 현재 KakaoVX 근무중입니다!

0개의 댓글