본문 바로가기
Language/Java

[Java 8] Stream - 4 (flatMap, collect)

by ocwokocw 2021. 2. 11.

- 출처: https://www.oracle.com/java/technologies/architect-streams-pt2.html

- flatMap과 collect

  • flatMap - "map"과 "flatten" 동작을 결합한 중간 연산자
  • collect - stream의 요소를 결과로 계산하기 위해 "collectos" 라고 불리는 여러 동작을 인자로 축적하는 종결 연산자

위의 두 연산자들은 더 복잡한 질의를 표현하기에 아주 유용한 연산자들이다. 예를 들어 flatMap과 collect를 이용하여 단어의 stream 에서 각 알파벳 문자가 몇번 나타나는지를 Map 으로 나타낼 수 있다.

 

Stream<String> words = Stream.of("Java", "Magazine", "is", "the", "best");
Map<String, Long> letterToCount = words
	.map(word -> word.split(""))
	.flatMap(Arrays::stream)
	.collect(Collectors.groupingBy(Function.identity(), Collectors.counting()));
			
System.out.println("letterToCount: " + letterToCount);

 

위의 코드를 수행하면 결과는 아래와 같다.

 

letterToCount: {a=4, b=1, e=3, g=1, h=1, i=2, J=1, M=1, n=1, s=2, t=2, v=1, z=1}

 

flatMap이 복잡하다고 생각하거나 헷갈린다고 하여 낙담하지 않길 바란다. 개념을 이해하는게 어렵지는 않지만, 막상 쓰려고 하면 헷갈린다. flatMap과 collect 에 대해 자세히 알아보자.


- flatMap

만약 파일안에서 나타나는 단어를 중복없이 추출해내는 프로그램을 만든다고 가정해보자.

 

동작을 코드로 기술하는것 자체는 크게 어렵지 않다. 이전 글에서 살펴본 Files.lines() 를 이용하면 file의 line 들을 Stream 형태로 반환한다. 그런 다음 map() 을 이용하여 line을 words 형태로 쪼개고, distinct() 연산을 이용하여 중복을 제거하면 된다.

 

Files.lines(Paths.get("stuff.txt"))
	.map(line -> line.split("\\s+"))
	.distinct()
	.forEach(System.out::println);

 

돌려보면 알겠지만 결과는 아래와 같이 나온다.

 

[Ljava.lang.String;@119d7047
[Ljava.lang.String;@776ec8df

 

왜 이렇게 결과가 나왔을까? 우선 map() 메소드부분이 우리가 의도하던것과는 다르다. 구별된 단어 처리를 위해서 Stream<String> 으로 연산을 했어야 하는데, map의 동작은 문자열 line을 split 하여 Stream<String[]> 형태로 반환해버렸다. 이럴때 바로 flatMap을 사용해야 한다. 우선 배열의 stream이 아닌 단어의 stream이 필요하다. Arrays.stream() 은 배열을 인자로 취하여 stream을 반환해준다.

 

String[] arrayOfWords = {"Java", "Magazine"};
Stream<String> streamOfwords = Arrays.stream(arrayOfWords);

 

그럼 이제 Arrays.stream() 을 이용하여 mapping 을 해주자. 그런데 아래와 같이 map을 사용해버리면 한가지 의문이 들수도 있다. flatMap이 굳이 왜 필요한가? map을 이용하면 다 변환가능한것이 아닌가? 라고 말이다.

 

Files.lines(Paths.get("stuff.txt"))
	.map(line -> line.split("\\s+"))
	.map(Arrays::stream)
	.distinct()
	.forEach(System.out::println);

 

위와 같이 Arrays::stream 으로 mapping 하면 코드가 정상적으로 수행될까? 그렇지 않다. map(Arrays::stream) 에서는 우리의 의도와 다르게 stream의 stream(<Stream<Stream<String>>)을 반환해버렸다. Arrays.stream()이 String[]을 인자로 취하여 Stream<String> 으로 반환을 하였는데, map 메소드가 중간연산자라서 그 반환형을 다시 Stream 으로 감쌌기 때문이다.

 

Files.lines(Paths.get("stuff.txt"))
	.map(line -> line.split("\\s+"))
	.flatMap(Arrays::stream)
	.distinct()
	.forEach(System.out::println);

 

결국 위와 같이 flatMap을 사용함으로써 코드가 완성된다. map(Arrays::stream) 을 사용했을 때 발생한 모든 stream 들을 1개의 stream으로 병합하여 "평탄화" 해준다. 결국 flatMap은 stream의 각 값을 다른 stream으로 변환시켜주고, 모든 발생한 stream 들을 1개의 stream 으로 연결해준다.


- collect 연산자

이미 collect 연산자를 사용해보았겠지만 더 자세히 알아보자. collect 메소드는 종결 연산자이며 stream을 list 로 변환시켜준다. 예를 들어 일정나이 이상인 사람이름의 목록이 필요하다면 아래처럼 코드를 작성하면 된다. (People 생성자는 첫번쨰 인자로 사람의 이름을 2번째 인자로 나이를 받는다고 가정한다.)

 

List<People> persons = Arrays.asList(
		new People("a", 1),
		new People("b", 30),
		new People("c", 32),
		new People("d", 70),
		new People("e", 22));
	
List<String> names = persons.stream()
	.filter(person -> person.getAge() >= 30)
	.map(People::getName)
	.collect(Collectors.toList());
    
names.forEach(System.out::println);

 

collect 메소드에 넘겨지는 인자는 java.util.stream.Collector 타입의 객체이다. Collector 객체는 본질적으로 stream의 요소들을 마지막 결과로 축적하기 위한 방법을 기술한다. 앞서 사용된 Collectos.toList() 팩토리 메소드는 어떻게 stream을 list로 축적하는지 묘사하는 Collector를 반환한다. 이런 비슷한 동작을 하는 내장된 Collectors 들이 많이 있다.


- stream을 다른 collections 로 수집: 예를 들어 toSet() 을 사용하면 stream을 중복요소를 제서하는 Set으로 변환할 수 있다. 아래 코드는 사람들의 나이를 set을 뽑는 코드이다.

 

List<People> persons = Arrays.asList(
		new People("a", 10),
		new People("b", 20),
		new People("c", 20),
		new People("d", 30),
		new People("e", 10));
	
Set<Integer> names = persons.stream()
	.map(People::getAge)
	.collect(Collectors.toSet());
	
names.forEach(System.out::println);

 

위의 코드는 Set의 어떤 타입이냐를 보장해주지는 않는다. toCollection() 메소드를 이용하면 더 세밀한 제어가 되는데, HashSet을 원한다면 아래와 같은 코드를 작성하면 된다.

 

Set<Integer> names = persons.stream()
	.map(People::getAge)
	.collect(Collectors.toCollection(HashSet::new));

 

collect와 Collector로 할 수 있는건 이게 다가 아니다. 극히 일부분을 코드로 작성해보았고 다음과 같은 동작도 가능하다.

  • 어떤 거래들의 Currency를 그룹화 했을 때 값들의 합계(Map<Currency, Integer>)
  • 일정값 기준으로 거래들을 2그룹으로 나누는 연산(Map<Boolean, List<Transaction>>)
  • 2레벨 이상의 그룹화, 예를 들어 도시들의 거래로 1단계 그룹화를 한 후 일정값 기준으로 2개의 그룹으로 그룹화(Map<String, Map<Boolean, List<Transaction>>>)

- Summarizing

앞에서는 reduce 연산자를 이용하여 기본 stream 형들의 최대, 최소, 평균값들을 계산해보았다. 하지만 이런 연산들은 우리가 사용할 수 있도록 잘 정의되어 있다. 우선 개수를 세는 couting() 메소드는 목록들의 개수를 세아려준다.

 

List<People> persons = Arrays.asList(
		new People("a", 10),
		new People("b", 20),
		new People("c", 20),
		new People("d", 30),
		new People("e", 10));
	
Long countOfPersons = persons.stream()
	.collect(Collectors.counting());
	
System.out.println(countOfPersons);

 

summing 을 이용하면 stream 요소의 속성 합계를 구할 수 있다. summingDouble(), summingInt(), summingLong() 메소드를 지원하며 각각 Doble, Int, Long 속성일때 이용한다. 나이의 합계를 구해보자.

 

Integer countOfPersons = persons.stream()
	.collect(Collectors.summingInt(People::getAge));

 

averaging 도 summing 과 유사한 방법으로 사용하면 된다.

 

Double countOfPersons = persons.stream()
	.collect(Collectors.averagingDouble(People::getAge));

 

또 maxBy(), minBy() 를 이용하면 stream 원소의 최대, 최소값을 구할 수 있다. 이때 순서로 비교할것인지를 정해야 한다. 그래서 maxBy과 minBy를 이용할 때에는 Comparator 객체를 인자로 넘겨야 한다.

 

comparing() 정적 메소드를 이용하면 해당 함수로 부터 Comparator 객체를 생성시킬 수 있다. 이 함수는 stream 원소로 부터 비교 키를 추출하는데 사용된다. 이 경우에 비교 key 로서 나이를 사용하여 가장 나이 많은 사람을 알 수 있다.

 

Optional<People> highestPeople = persons.stream()
	.collect(Collectors.maxBy(
		Comparator.comparing(People::getAge)));

 

reducing() 컬렉터는 결과를 도출할때까지 stream의 모든 원소들에 반복적으로 연산을 적용한다. 개념적으로 앞에서 봤떤 reduce 메소드와 비슷하다. 나이의 합을 reducing() 메소드를 이용해서 구할수도있다.

 

Integer sumAge = persons.stream()
	.collect(Collectors.reducing(0, People::getAge, Integer::sum));

 

reducing() 은 3개의 인자를 갖는다.

  • 초기값
  • stream의 각 요소에 적용할 함수, 여기서는 각 People 객체로 부터 나이를 뽑는데 사용하였다.
  • 추출 함수로 부터 생산된 2개의 값을 결합하는 연산, 여기서는 값들을 더하는 연산을 적용하였다.

이미 기존정의된 함수로 다 할 수 있는데 왜 이 메소드가 필요한지 의하할 수 있겠지만 더 복잡한 연산에 적용할 경우가 있다.


- Grouping

일반적인 DB 쿼리에서는 속성을 이용하여 데이터를 그룹화하는 경우가 많다. 예를 들어 나이별로 사람들의 정보를 모은다고 가정하고 java 8 이전의 코드로 작성하면 아래와 같이 복잡해진다.

 

List<People> persons = Arrays.asList(
	new People("a", 10),
	new People("b", 20),
	new People("c", 20),
	new People("d", 30),
	new People("e", 10));

Map<Integer, List<People>> summaryInfoNotFp = new HashMap<>();
	
for(People people : persons) {
		
	Integer age = people.getAge();
		
	if(summaryInfoNotFp.containsKey(age)) {
		List<People> peoplesByAge = summaryInfoNotFp.get(age);
		peoplesByAge.add(people);
	}
	else {
		List<People> firstPerson = new ArrayList<>();
		firstPerson.add(people);
		summaryInfoNotFp.put(age, firstPerson);
	}
}

 

우선 나이별 사람들의 정보가 모일 Map을 생성한다. 그리고 사람들의 정보를 반복하면서 각 사람들의 나이를 추출한다. Map의 값으로 사람정보를 더하기 전에 이미 기존에 해당 나이로 생성된 사람목록이 있는지 체크해야 한다.

 

이때 colletor 의 groupingBy()를 사용하면 코드가 한결 간결해진다. 동작은 동일하게 수행하면서 코드가 문제만을 해결하는 코드에 더 가까워졌다. (복잡한 코드에서 문제와 직접적인 관련이 없는 해당 나이에 대해 기존 사람 목록이 존재하는가? 와 같은 코드를 기술할 필요가 없다.)

 

Map<Integer, List<People>> summaryInfo = persons.stream()
	.collect(Collectors.groupingBy(People::getAge));

 

groupingBy() 팩토리 메소드는 인자로 분류할 key를 추출하기 위한 함수를 인자로 받는다. 이런 함수를 classification(분류자) 함수라고 부른다. 예제의 경우에는 나이별로 사람들의 목록을 분류하기 위해 People::getAge 메소드 레퍼런스를 사용하였다.


- Partitioning

partitioningBy() 로 불리는 팩토리 메소드가 있다. 해당 메소드는 groupingBy() 의 특별한 케이스라고 볼 수 있다. 인자로서 predicate 함수를 취해서, stream의 원소들을 predicate의 매칭 여부에 따라 그룹화 한다. 다시 말해서 사람 정보의 stream을 partitioning 하여 Map<Boolean, List<People>> 로 구성한다. 만약 사람 목록에서 일정 나이 기준으로 젊은 층과 나이가 많은 그룹으로 나눈다고 하면 partitiongBy 컬렉터를 아래와 같이 사용할 수 있다.

 

Map<Boolean, List<People>> youngAndOld = persons.stream()
	.collect(Collectors.partitioningBy(people -> people.getAge() >= 30));

- Composing collectors

SQL을 조금 사용해봤다면 GROUP BY 와 함께 COUNT(), SUM()을 사용해본적이 있을 것이다. 특정 기준으로 그룹화 한 후 그들의 합계와 같은 것을 구하는것이다. Stream API 도 이와 같은 연산을 지원한다. 사실 이미 앞서 사용한 groupingBy() 함수의 오버로드된 버전(2번째 인자가 있는 버전)을 이용하면 된다.

 

각 나이대별로 사람의 수가 있는지를 계산해보자. 우선 나이대별(GROUP BY)의 기준으로 getAge()를 classification 함수로 이용할것이다. 그러면 결과 Map의 key는 Integer가 된다. 

 

Map<Integer, Long> countPerAge = persons.stream()
	.collect(Collectors.groupingBy(People::getAge, Collectors.counting()));

 

사실 우리가 앞서봤던 groupingBy로 1번째 인자만 사용했던 메소드는 Collectors.toList()를 2번째 인자로 넘겨준것과 다를바가 없다.

 

1개의 기준이 아니라 더 복잡한 다중 레벨 기준으로 그룹핑 하는것도 가능하다. groupingBy 는 자기자신도 컬렉터여서 2번째 인자로 groupingBy 컬렉터를 넘겨주면 다중 레벨 그룹핑이 가능해진다. 아래와 같이 2번째 인자가 Collector 인데 반환형도 Collector 이다.

 

public static <T, K, A, D>
    Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,
                                          Collector<? super T, A, D> downstream) {
        return groupingBy(classifier, HashMap::new, downstream);
    }

 

아래는 나이로 1차 그룹을 만들고 이름의 글자수로 2차 그룹을 만든 예제이다.

 

List<People> persons = Arrays.asList(
		new People("a", 10),
		new People("bb", 20),
		new People("cc", 20),
		new People("ddd", 20),
		new People("e", 30),
		new People("ff", 10));

Map<Integer, Map<Integer, Long>> summaryInfo = persons.stream()
	.collect(Collectors.groupingBy(People::getAge, 
				Collectors.groupingBy((People people) -> people.getName().length(), 
					Collectors.counting())));
	
System.out.println(summaryInfo);

'Language > Java' 카테고리의 다른 글

[Java 8] Default Method  (0) 2021.02.11
[Java 8] Optional  (0) 2021.02.11
[Java 8] Stream - 3 (Numeric, Stream 생성)  (1) 2021.02.11
[Java 8] Stream - 2 (기본 연산)  (0) 2021.02.11
[Java 8] Stream - 1 (Stream 개요)  (0) 2021.02.11

댓글