스트림이란?

많은 수의 데이터를 다룰 때, 컬렉션이나 배열에 데이터를 담고 원하는 결과를 얻기 위해

for문이나 Iterator를 이용해 코드를 작성해왔다.

 

그러나 이러한 코드는 너무 길고, 알아보기 힘들며, 재사용성이 떨어진다.

또한 데이터 소스마다 다른 방식으로 데이터를 다뤄야한다.

 

이러한 문제점을 해결하기 위해 만든 것이 스트림이다.

스트림은 데이터 소스를 추상화하고, 데이터를 다루는데 자주 사용되는 메서드들을 정의해놓았다.

String[] strArr = {"aaa", "ccc", "bbb"};
List<String> strList = Arrays.asList(strArr);

Stream<String> stream1 = Arrays.stream(strArr); //스트림 생성
Stream<String> stream2 = strList.stream(); //스트림 생성

 

두 스트림으로 데이터를 정렬하고 화면에 출력하는 방법은 다음과 같다.

stream1.sorted().forEach(System.out::println);
stream2.sorted().forEach(System.out::println);

 

두 스트림의 데이터 소스는 다르지만, 정렬하고 출력하는 방법이 완전히 동일해졌다.

또한 코드가 간결하고 이해하기 쉬워졌다.

 

스트림의 특징

1. 스트림은 데이터 소스를 변경하지 않는다.

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

String[] strArr = {"aaa", "ccc", "bbb"};
Stream<String> stream1 = Arrays.stream(strArr); //스트림 생성

stream1.sorted().forEach(System.out::println);

for(String s : strArr){
  System.out.println(s); // aaa ccc bbb
}

필요하다면 정렬된 결과를 컬렉션이나 배열에 다시 담아 반환할 수 있다.

List<String> sortedList = stream1.sorted().collect(Collectors.toList());

 

2. 스트림은 일회용이다.

stream1.sorted().forEach(System.out::println);

int num = stream1.count(); //에러. 스트림이 이미 닫혔음

스트림은 Iterator처럼 일회용이다. 스트림도 한 번 사용한 후에는 다시 사용할 수 없다.

필요하다면 스트림을 다시 생성해야한다.

 

3. 스트림은 작업을 내부 반복으로 처리한다.

스트림은 반복문을 메서드 내부로 숨길수 있다.

forEach()메서드는 매개변수에 대입된 람다식을 데이터 소스 요소에 적용하는데

메서드 안에 for문을 넣어놓은 것이다.(수행할 작업을 람다식 매개변수로 받는다)

 

스트림의 연산

스트림은 다양한 연산을 제공해, 복잡한 작업들을 간단히 처리할 수 있다.

 

스트림이 제공하는 연산은 중간 연산최종 연산으로 구분할 수 있다.

 

중간 연산은 연산 결과를 스트림으로 반환한다. 따라서 중간 연산을 연속해서 연결할 수 있다.

최종 연산은 연산 결과가 스트림이 아니다. 스트림의 요소를 소모해 연산을 수행해 한 번 만 가능하다.

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

distinct(), limit(), sorted()가 중간연산이며, forEach()가 최종 연산이라고 할 수 있다.

 

지연된 연산

스트림에서 중요한 점은, 최종 연산이 수행되기 전까지 중간 연산이 수행되지 않는다는 것이다.

이게 뭔소리냐면, distinct(), sort()같은 중간 연산이 호출되도 즉각적으로 수행되는 것이 아니다.

 

중간 연산을 호출하는 것은, 어떤 작업을 수행해야하는지를 지정해주는 것이다.

최종 연산이 호출되어야 비로소 스트림의 요소들이 중간 연산을 거쳐 최종 연산에서 소모된다.

 

Stream<Integer>과 IntStream

요소의 타입이 T인 스트림은 기본적으로 Stream<T>이지만, 오토박싱&언박싱은 비효율적이기 때문에

기본형을 다루는 스트림 IntStream, LongStream, DoubleStream이 제공된다.

 

이러한 기본형 스트림을 Stream으로 바꾸거나 반대로 연산하려면

mapToInt(), boxed()와 같은 형변환 메서드를 사용해야한다.

 

병렬 스트림

스트림으로 데이터를 다룰때 얻는 또 다른 장점은 병렬 처리가 쉽다는 것이다.

스트림에 parallel()이라는 메서드를 호출하면 병렬로 연산을 수행하도록 할 수 있다.

int sum = stream1.parallel()
	.mapToInt(s -> s.length())
	.sum()

그러나 병렬처리가 항상 더 빠른 결과를 얻게 해주는 것이 아니라는 것을 명심하자.

 

스트림 만들기

컬렉션은 최고 조상인 Collection에 stream()이 정이되어 있다.

Collection과 자손인 List, Set을 구현한 클래스들은 모두 이 메서드로 스트림을 생성할 수 있다.

List<String> strList = new ArrayList<> ();

Stream<String> stream = strList.stream(); //스트림 생성

 

배열을 소스로 하는 스트림을 생성하는 메서드는

다음과 같이 Stream과 Arrays에 static 메서드로 정의되어 있다.

Stream<T> Stream.of(T... values)
Stream<T> Stream.of(T[])
Stream<T> Arrays.stream(T[])
Stream<T> Arrays.stream(T[] array, int startInclusive, int endExclusive)

 

특정 범위의 정수로 스트림 만들기

IntStream.range(int begin, int end) //end 미포함
IntStream.rangeClosed(int begin, int end) //end도 포함

 

임의의 수로 스트림 만들기

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

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

 

이렇게 생성한 스트림은 크기가 정해지지 않은 무한 스트림이므로 limit()을 사용해서 크기를 제한해줘야한다.

또는 메서드에 매개변수를 넣어서 유한 스트림을 생성할수도 있다.

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

 

람다식 - iterate(), generate()

static <T> stream<T> iterate(T seed, UnaryOperator<T> f)
static <T> stream<T> generate(Supplier<T> s)

iterate()는 seed로 지정된 값부터 시작해, 람다식 f에 의해 계산된 결과를 반복하는 무한 스트림을 생성한다.

generate()도 iterate()처럼 람다식에 의해 반복되는 값을 요소로 하는 무한 스트림을 생성하지만

iterate()처럼 이전 결과로 다음 요소를 계산하지 않는다.

Stream<Integer> evenStream = Stream.iterate(0, n->n+2); //0, 2, 4, 6, 8, ...
Stream<Integer> oneStream = Stream.genarate(() -> 1); //1, 1, 1, 1, 1, ...

 

빈 스트림

요소가 하나도 없는 비어있는 스트림을 생성하는 것도 가능하다.

책에선 스트림에 연산을 수행한 결과가 하나도 없다면, null보다 빈 스트림을 반환하는 것이 더 낫다고 이야기한다.

Stream emptyStream = Stream.empty();
long count = emptyStream.count() //0

 

두 스트림의 연결

Stream의 static 메서드인 concat()을 사용하면, 같은 타입의 요소를 가지는 두 스트림을 하나로 연결할 수 있다.

Stream<String> stream1 = Stream.of("aaa", "bbb", "ccc");
Stream<String> stream2 = Stream.of("ddd", "eee", "fff");
Stream<String> stream3 = Stream.concat(stream1, stream2); //두 스트림 하나로 합침

 

스트림의 중간 연산

skip(), limit()

skip()과 limit()은 스트림의 일부를 잘라낼 때 사용한다.

skip(int n)은 처음 n개의 요소를 건너뛰며, limit(int n)은 맨 앞 n개만 가져온다

IntStream stream1 = IntStream.range(1,10); //1~9까지 요소 가짐
IntStream stream2 = stream1.skip(3); // 맨앞 3개 제외, 4~9까지
IntStream stream3 = stream1.limit(5) // 1~5까지

 

filter(), distinct()

distinct()는 스트림에서 중복된 요소를 걸러내며, filter()는 주어진 조건에 맞지 않는 요소를 걸러낸다.

Stream<Integer> stream = Stream.generate(() -> 1); //1, 1, 1, 1, ...
stream.limit(10).distinct().forEach(System.out::println); // 중복 제거 -> 1

IntStream stream = IntStream.range(1,10); //1, 2, 3, ...8, 9
stream.limit(10).filter(n -> n%2 == 0).forEach(System.out::println); //2 4 6 8

sorted()

스트림을 정렬할 때는 sorted()를 사용하면 된다.

sorted()는 지정된 Comparator로 스트림을 정렬하고, int를 반환하는 Comparator를 직접 지정해 사용하는 것도 가능하다.

List<User> userList = new ArrayList<> ();
userList.add(new User(1, "aaa", UserGrade.BASIC));
userList.add(new User(2, "bbb", UserGrade.BASIC));
userList.add(new User(3, "ccc", UserGrade.VIP));
userList.add(new User(4, "aaa", UserGrade.BASIC));
        
userList.stream().sorted(Comparator.comparing(User::getGrade)
	.thenComparing(User::getId)
	.thenComparing(User::getName))
	.forEach(System.out::println);

 

map()

스트림의 요소에 저장된 값 중에서 원하는 필드만 뽑아내거나 특정 형태로 변환해야 할 때가 있다.

이때 map() 메서드를 사용하면 편하게 변환할 수 있다.

Stream<String> names = userList.stream().map(u -> u.getName());

flatMap()

Stream<T[]> 를 Stream<T>로 변환해준다.

스트림의 요소가 배열일 경우, 그냥 요소들을 다 꺼내 스트림으로 다루는 것이 더 편할때가 있는데 그럴때 flatMap()을 사용한다.

 

peek()

연산과 연산 사이에 올바르게 처리되었는지 확인하고 싶다면 peek()를 사용하자.

forEach문과 달리 스트림의 요소를 소모하지 않는다. 즉 연산 사이에 여러번 사용해도 문제가 되지 않는다.

사용법은 forEach와 똑같다.

 

스트림의 최종연산

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

최종 연산의 결과는 스트링 요소의 합 같은 단일값 또는 스트림의 요소가 담긴 배열, 컬렉션 등이 나올 수 있다.

(forEach(), count()는 위에서 계속 사용했으므로 생략한다.)

 

조건 검사 - allMatch(), anyMatch(), noneMatch(), findFirst(), findAny()

스트림의 요소에 대해 지정된 조건에 모든 요소가 일치하는지, 일부가 일치하는지, 아니면 어떤 요소도 일치하지 않는지 확인하는데 사용할 수 있는 메서드들이다.

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

boolean basicUsers = userList.stream().anyMatch(u -> u.getGrade() == UserGrade.BASIC)

allMatch(), anyMatch(), noneMatch() 메서드는 boolean 값을 반환한다.

스트림의 요소를 확인하며 모두 조건을 만족하는지, 만족하는 요소가 있는지, 아무것도 만족하지 않는지 확인한다.

Optional<User> findUser = userList.stream()
	.filter(u -> u.getName() == "kim")
	.findFirst();

findFirst()는 스트림의 요소중에서 조건에 맞는 첫번째 요소를 반환한다.

주로 filter와 함께 사용되며, 병렬 스트림의 경우 findAny()를 사용한다.

 

reduce()

reduce라는 이름에 맞체, 스트림의 요소를 점차 줄여나가면서 연산을 수행해 최종 결과를 반환한다.

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

스트림의 처음 두 요소를 가지고 연산을 수행해 나온 값으로 다음 값과 연산을 수행한다.

스트림의 모든 요소를 소모하면 결과를 반환한다.

매개변수로 초기값을 설정해주는 것도 가능하다.

IntStream stream = IntStream.range(1,10);
OptionalInt sum = stream.reduce((a,b) -> a+b);
System.out.println(sum.getAsInt());

collect()

스트림의 최종 연산중에서 가장 복잡하지만 유용하게 활용될 수 있는 연산이다.

  • collect() : 최종 연산 메서드, 매개변수로 collector를 필요로한다.
  • Collector : 인터페이스, collector 매개변수로 들어가는 collecotr는 이 인터페이스를 구현해야한다.
  • Collectors : 클래스, static 메서드로 미리 작성된 collector를 제공한다.

collect()는 스트림의 요소를 수집하는 최종 연산으로 앞서 배운 reducing과 유사하다고 이야기한다.

collect()가 요소를 수집할때는 어떻게 수집할 것인지 방법을 정의해줘야하는데, 이것이 collector이다.

collector는 Collectors 인터페이스를 구현한 것으로, 직접 구현하거나, 구현된 것을 사용할 수도 있다.

 

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

toList(), toSet(), toMap(), toCollection(), toArray() 메서드는 Collectors의 static 메서드로

스트림을 각각의 컬렉션과 배열로 변환해준다.

List<String> userNameList = userList.stream()
	.map(User::getName)
	.collect(Collectors.toList())

ArrayList<User> list = userList.stream()
	.collect(Collectors.toCollection(ArrayList::new));

 

List나 Set이 아닌 특정 컬렉션을 지정하려면 toCollection에 해당 컬랙션의 생성자 참조를 매개변수로 넣어주면 된다.

Map은 키와 값의 쌍으로 저장해야하므로, 객체의 어떤 필드를 키로 사용할지와 값으로 사용할지를 넣어줘야한다.

Map<Integer,User> userMap = userList.stream()
                             .collect(Collectors.toMap(u -> u.getId(), u->u));

 

joining()

문자열 스트림의 모든 요소를 하나의 문자열로 결합해서 반환한다.

구분자를 지정할 수도 있고, 접두사와 접미사도 지정가능하다.

스트림 요소가 String일때만 결합이 가능하므로, map을 통해 변환해줘야 한다.

userList.stream()
	.map(User::getName)
	.collect(Collectors.joining(",")); //구분자 지정

userList.stream()
	.map(User::getName)
	.collect(Collectors.joining("," , "[" , "]")); //구분자, 접두사, 접미사

 

통계 - counting(), summing(), averagingint(), maxBy(), minBy()

int count = userStream.count();
int count = userString.collect(Collectors.counting());

int totalScore = userStream.mapToInt(User::getScore()).sum();
int totalScore = userStream.collect(summingInt(User::getScore));

count(), sum(), average() 처럼 collect()와 결합하여 사용하지 않는 최종연산으로도 다음의 통계 기능들을 수행할 수 있다. 이 메서드들은 후술할 groupingBy()와 함께 사용할때 주로 사용된다.

 

groupingBy(), partitioningBy()

그룹화는 스트림의 요소를 특정 기준으로 그룹화하는 것을 의미하며,

분할은 스트림의 요소를 지정된 조건에 일치하는 그룹과, 일치하지 않는 그룹으로 나누는 것을 의미한다.

 

그룹화와 분할의 결과는 Map에 담겨 반환된다.

책에선 스트림을 두 개의 그룹으로 나눠야한다면 partitioningBy()가 더 빠르며 그 외에는 groupingBy()를 쓰라고 이야기한다.

Map<Boolean, List<User>> userByVIP = userStream
	.collect(Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP));

List<User> vipUserList = userByVIP.get(true);
List<User> basicUserList = userByVIP.get(false);

Map<Boolean, Set<User>> userByVIP = userList.stream().collect(
Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP,
							Collectors.toSet()));

collect() 안에 partitioningBy(조건식)을 넣으면 List<T> 형태로 맵에 저장되어 나온다.

기본은 List 형식이지만 Collectors.toSet(), toCollection()등의 메서드로 다른 형태로도 저장할 수 있다.

 

partitioningBy에 조건식 외에도 통계 메서드를 넣어 사용할 수도 있다.

Map<Boolean, Long> userByVIP = userStream
	.collect(Collectors.partitioningBy(u -> u.getGrade() == UserGrade.VIP ,
		Collectors.counting()));

만약 UserGrade 값이 BASIC, VIP처럼 2개가 아닌 BRONZE, SILVER, GOLD 처럼 여러개라면 groupingBy()를 사용하자.

Map<UserGrade, List<User>> userByGrade = userList.stream()
            .collect(Collectors.groupingBy(User::getGrade));

System.out.println(userByGrade.get(UserGrade.GOLD));

Collector 구현하기

지금까지 Collectors 클래스가 제공하는 collector를 사용했지만.

우리가 직접 collector를 구현해서 사용하는 것도 가능하다.

Collector 인터페이스는 다음과 같이 정의되어 있다.

public interface Collector<T, A, R>{
	Supplier<A> supplier();
	BiConsumer<A, T> accmulator();
  BinaryOperator<A> combiner();
  Function<A,R> finisher();

  Set<Characteristics> characteristics(); //컬렉터의 특성이 담긴 set 반환
}
  • supplier() : 작업 결과를 저장할 공간 제공
  • accumlator() : 스트림 요소를 수집할 방법을 제공
  • combiner() : 두 저장공간을 병합할 방법을 제공 (병렬 스트림)
  • finisher() : 결과를 최종적으로 반환할 방법 제공

characteristics()는 컬렉터가 수행하는 작업의 속성에 대한 정보를 제공하기 위한 것으로

  • Characteristics.CONCURRENT : 병렬로 처리 가능한 작업
  • Characteristics.UNORDERED : 스트림의 요소가 순서가 유지될 필요가 없는 작업
  • Characteristics.IDENEITY_FINISH : finisher()가 항등함수인 작업

위의 3가지 요소중 해당되는 것을 set에 담아서 반환하도록 구현하면 되며

아무 요소도 넣고 싶지 않을 경우 Collections.emptySet()을 반환하면 된다.

+ Recent posts