자바 map 메서드와 flatMap 메서드의 차이

자바 8에서 추가된 map 메서드와 flatMap 메서드의 차이는 무엇일까?

#java #java8 #map #flatmap


들어가기 앞서

이번 포스팅은 스트림 클래스를 기준으로 map 메서드와 flatMap 메서드의 차이를 소개합니다. 따라서 자바 8에서 새롭게 추가된 스트림에 대한 지식이 조금 필요할 수 있습니다. 혹시나 자바의 스트림이 처음이시라면, 아래의 스트림 관련 포스팅을 먼저 보고 오시면 더 좋습니다.


map 메서드 살펴보기

map 메서드를 사용하면 단일 스트림 안의 요소를 원하는 특정 형태로 변환할 수 있습니다. 아래 코드로 살펴봅시다. Person 이라는 클래스의 객체가 담긴 리스트에서 문자열인 name 필드만 Set 자료구조에 담은 후 출력하는 코드입니다.

// import 생략

class Person {
    private String name;
    private Integer age;

    // constructor, getter, setter 생략
}

public class MapMethodTest {
    public static void main(String[] args) {
        List<Person> personList = Arrays.asList(new Person("Kimtaeng", 30),
                new Person("Madplay", 29));

        Set<String> names = personList.stream()
                .map(Person::getName)
                .collect(Collectors.toSet());

        // Kimtaeng, Madplay 출력
        names.forEach(System.out::println);
    }
}

위 코드에서 stream과 동시에 람다식(Lambda Expression)메서드 참조(Method References)가 사용되었는데요. 하나씩 적용해보면 아래와 같습니다. 코드가 조금씩 간결해지는 것을 볼 수 있습니다.

// 리스트 생성
List<Person> personList = Arrays.asList(new Person("Kimtaeng", 30),
        new Person("Madplay", 29));

// 초기 형태
personList.stream().map(new Function<Person, String>() {
    @Override
    public String apply(Person person) {
        return person.getName();
    }
}).collect(Collectors.toSet());

// 람다식 적용
personList.stream().map(person -> person.getName())
    .collect(Collectors.toSet());
    
// 메서드 참조 적용
personList.stream().map(Person::getName)
    .collect(Collectors.toSet());


flatMap 메서드 살펴보기

flatMap 메서드는 스트림의 형태가 배열과 같을 때, 모든 원소를 단일 원소 스트림으로 반환할 수 있습니다. 아래 코드로 살펴봅시다. 2차원 배열에서 문자열의 길이가 3 보다 큰 문자열을 출력하는 코드입니다.

String[][] namesArray = new String[][]{
        {"kim", "taeng"}, {"mad", "play"},
        {"kim", "mad"}, {"taeng", "play"}};
        
Set<String> namesWithFlatMap = Arrays.stream(namesArray)
        .flatMap(innerArray -> Arrays.stream(innerArray))
        .filter(name -> name.length() > 3)
        .collect(Collectors.toSet());
        
// play, taeng 출력
namesWithFlatMap.forEach(System.out::println);

flatMap의 결과로 단일 원소 스트림을 반환하기 때문에 filter 메서드를 바로 체이닝하여 사용할 수 있습니다. 초기에 생성된 스트림이 배열인 경우에 매우 유용합니다.


어떻게 다른지 비교해보자!

이제 조금 더 자세히 map메서드와 flatMap 메서드가 어떻게 다른지 살펴봅시다. 먼저 바로 위에서 살펴본 flatMap 메서드 예제 코드를 다시 살펴봅시다.

String[][] namesArray = new String[][]{
        {"kim", "taeng"}, {"mad", "play"},
        {"kim", "mad"}, {"taeng", "play"}};

Set<String> namesWithFlatMap = Arrays.stream(namesArray)
        .flatMap(innerArray -> Arrays.stream(innerArray))
        .filter(name -> name.length() > 3)
        .collect(Collectors.toSet());

위의 예제를 flatMap 메서드를 사용하지 않는 방법으로 구성해봅시다. flatMap 메서드가 없더라도 배열 형태의 스트림을 다룰 수 있습니다. 다만 조금 더 복잡해질 뿐이지요.

// 2차원 배열 선언 생략
Set<String> namesWithMap = Arrays.stream(namesArray)
        .map(innerArray -> Arrays.stream(innerArray)
                .filter(name -> name.length() > 3)
                .collect(Collectors.toSet()))
        .collect(HashSet::new, Set::addAll, Set::addAll);

이번 예제에서 사용한 map 메서드의 결과를 collect 하는 코드가 조금 생소할 수 있는데요. 메서드 정의를 보면 아래와 같습니다.

<R> R collect(Supplier<R> supplier,
              BiConsumer<R, ? super T> accumulator,
              BiConsumer<R, R> combiner);

supplier는 새로운 결과 컨테이너를 만듭니다. 여기서는 HashSet 이고요. 두 번째 인자인 accumulator는 결과에 추가 요소를 통합하기 위한 역할을 하며, 마지막 인자 combiner는 계산 결과를 결합하는 역할을 담당합니다.

한편 Collector를 직접 정의하게 되는 경우 Collector.of 메서드를 이용하게 되는데요. 이때는 combiner의 형태가 BinaryOperator 입니다. 아래와 같이 작성할 수 있습니다.

// 예제에서 사용한 자료구조는 Set 입니다. 
// 따라서 중복이 허용되지 않습니다.
// Map 등을 이용하는 경우, 중복되는 값이 있을 때 oldValue, newValue 중 선택해서 넣으면 됩니다.
Set<String> namesWithMap = Arrays.stream(namesArray)
    .map(names -> Arrays.stream(names)
            .filter(name -> name.length() > 3)
            .collect(Collectors.toSet()))
    .collect(Collector.of(HashSet::new, Set::addAll, (oldValue, newValue) -> oldValue));


다른 예제로 또 비교해보자!

다른 예제를 가지고 map메서드와 flatMap메서드의 차이를 비교해봅시다. 2차원으로 이루어진 문자열 배열에서 특정 문자만 출력하는 코드입니다.

String[][] namesArray = new String[][]{
        {"kim", "taeng"}, {"mad", "play"}};

// flatMap
Arrays.stream(namesArray)
        .flatMap(inner -> Arrays.stream(inner))
        .filter(name -> name.equals("taeng"))
        .forEach(System.out::println);

// map
Arrays.stream(namesArray)
        .map(inner -> Arrays.stream(inner))
        .forEach(names -> names.filter(name -> name.equals("taeng"))
            .forEach(System.out::println));

단순히 출력하는 코드만으로 비교해보면 차이는 조금 더 명확해집니다. flatMap은 결과를 스트림으로 반환하기 때문에 flatMap의 결과를 가지고 바로 forEach 메서드를 체이닝하여 모든 요소를 출력할 수 있습니다.

반면에 map의 경우에는 단일 요소로 리턴되기 때문에 map의 결과를 가지고 forEach메서드로 루프를 진행한 후 그 내부에서 다시 한 번 forEach 메서드를 체이닝하여 사용해야 합니다. 이후는 동일하게 메서드 참조 형태로 표준 출력합니다.

마지막으로 조금 더 간단한 로직으로 비교해봅시다. 단순히 2차원 배열의 모든 요소를 출력하는 코드입니다.

String[][] namesArray = new String[][]{
        {"kim", "taeng"}, {"mad", "play"}};

// flatMap
Arrays.stream(namesArray)
    .flatMap(inner -> Arrays.stream(inner))
    .forEach(System.out::println);

// map
Arrays.stream(namesArray)
    .map(inner -> Arrays.stream(inner))
    .forEach(names -> names.forEach(System.out::println));


마치며

지금까지 자바 8에서 추가된 map 메서드와 flatMap 메서드를 살펴보았는데요. Stream 클래스를 기준으로 설명했지만 Optional 클래스에도 map과 flatMap 메서드가 있습니다. 역할도 동일하고요.

// Stream
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);

// Optional
public<U> Optional<U> map(Function<? super T, ? extends U> mapper)
public<U> Optional<U> flatMap(Function<? super T, Optional<U>> mapper)

포스팅 예제를 기준으로 보았을 때, map 메서드는 스트림의 스트림을 반환하는 반면에 flatMap 메서드는 스트림을 반환한다고 보면 됩니다. 특히 스트림의 형태가 배열인 경우 또는 입력된 값을 또 다시 스트림의 형태로 반환하고자 할 때는 flatMap이 유용합니다.