Java Functional Programming - Stream API 심화: flatMap과 Collectors

Java 8 이상의 Stream API에서 flatMap과 Collectors를 사용하여 복잡한 중첩 데이터 구조를 효율적으로 처리하고 집계하는 Functional Programming 기법을 다룹니다. 불변성(Immutability), 순수 함수(Pure Function), 고차 함수(Higher-Order Function) 등의 핵심 개념을 바탕으로, 명령형 코드 대비 선언적 코드의 이점(유지보수성, 병렬 처리 안전성)을 강조하며 groupingBy, partitioningBy를 이용한 실무 수준의 데이터 분석 패턴을 제시합니다.

이 글은 Functional Programming 개념 및 활용법을 자바 기반으로 공부하기 위해 Gemini, Claude의 도움을 받아 작성하였습니다.

Stream API의 flatMap과 Collectors를 활용한 복잡한 데이터 파이프라인 구축

Java Stream API의 flatMap 연산과 Collectors 클래스는 Functional Programming의 핵심 원칙인 데이터 변환과 집계를 선언적으로 처리하도록 돕는 고급 도구입니다. 이 기술들을 통해 개발자는 중첩된 데이터 구조를 유연하게 다루고, 복잡한 비즈니스 요구사항에 따른 그룹화, 분할, 통계 작업을 간결하게 구현할 수 있습니다.


선언적 처리와 유지보수성의 극대화

기존의 명령형(Imperative) 프로그래밍 스타일에서는 중첩된 컬렉션을 처리하거나 데이터를 그룹별로 집계할 때, 다중 for 루프와 임시 Map 객체를 수동으로 생성하고 관리해야 했습니다. 이러한 방식은 코드가 길어지고, 상태 변화(Mutations)가 빈번하여 예측이 어려우며, 부수 효과(Side Effect) 발생 가능성이 높습니다. 특히 멀티 스레드 환경에서는 안정성을 보장하기 어렵습니다.

flatMapCollectors를 사용하면, 데이터 처리 로직을 불변성(Immutability)을 기반으로 하는 함수들의 파이프라인 형태로 구성할 수 있습니다. 이는 코드의 의도를 명확히 드러내어 가독성테스트 용이성을 높이고, Stream API의 구조적 장점을 활용하여 병렬 처리 시에도 안전하고 효율적인 성능 이점을 얻을 수 있게 합니다. 이 역량은 복잡한 데이터 분석 및 리포팅 시스템을 개발하는 데 필수적입니다.


1. flatMap: 중첩 구조의 평탄화 (Flattening) 원리

map 연산이 Stream의 각 요소를 1:1로 변환한다면, flatMap은 Stream의 각 요소를 1:N으로 변환한 후, 생성된 모든 Stream들을 단일 Stream으로 평탄하게 결합(Flattening)하는 고차 함수(Higher-Order Function)입니다.

flatMap은 인자로 Function<T, Stream<? extends R>> 형태의 순수 함수(Pure Function)를 받으며, 이 함수는 입력 요소당 하나의 Stream을 반환해야 합니다. flatMap의 핵심 역할은 이 반환된 Stream들을 모아서 하나의 최종 Stream으로 연결하는 것입니다.

import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;

// 중첩된 List 구조: 프로젝트별 담당 업무
List<List<String>> projectTasks = Arrays.asList(
    Arrays.asList("UI 디자인", "백엔드 개발"),
    Arrays.asList("데이터베이스 모델링", "성능 테스트"),
    Arrays.asList("배포 자동화")
);

// flatMap을 사용하여 모든 업무를 하나의 Stream으로 합치는 작업
List<String> allTasks = projectTasks.stream()
    // flatMap(List::stream): 각 List<String>을 Stream<String>으로 변환 후, 이 모든 Stream을 하나로 평탄화
    .flatMap(List::stream)
    .collect(Collectors.toList());

System.out.println(allTasks);
// 출력: [UI 디자인, 백엔드 개발, 데이터베이스 모델링, 성능 테스트, 배포 자동화]

위 예시에서 flatMap은 중첩 루프 없이도 모든 업무 항목에 대해 단일 처리를 가능하게 하며, 데이터의 불변성을 유지한 채 최종 컬렉션으로 변환합니다.


2. Collectors를 활용한 복잡한 집계 및 그룹핑

collect 최종 연산은 Stream의 요소들을 원하는 형태의 데이터 구조(컬렉션, 맵, 요약 통계)로 수집하는 역할을 수행하며, 이때 Collectors 클래스는 핵심적인 기능을 제공합니다.

2.1. groupingBy: 다차원 그룹핑 연산

groupingBy는 데이터를 특정 키(Key)를 기준으로 순수 함수를 사용하여 묶어 Map 형태로 집계하는 연산입니다. 이 메서드는 고차 함수의 특성을 가지며, 두 번째 인자로 또 다른 Collector(다운스트림 Collector)를 받아 그룹 내 요소에 대한 추가적인 변환 및 집계 작업을 정의합니다.

class User {
    String name;
    String department;
    int salary;
    // 생성자, Getter 생략
}
List<User> users = Arrays.asList(
    new User("Alice", "HR", 6000),
    new User("Bob", "IT", 8000),
    new User("Charlie", "HR", 7000)
);

// 부서별 사용자 이름 목록과 평균 연봉을 동시에 그룹핑
java.util.Map<String, java.util.Map<String, ?>> departmentSummary = users.stream()
    .collect(Collectors.groupingBy(
        User::getDepartment, // 키 추출 (부서명)
        Collectors.teeing( // 다운스트림: 두 개의 결과를 하나의 Map으로 병합
            Collectors.mapping(User::getName, Collectors.toList()), // 1. 그룹 내 이름 리스트
            Collectors.averagingInt(User::getSalary), // 2. 그룹 내 평균 연봉 계산
            (names, avgSalary) -> {
                java.util.Map<String, Object> innerMap = new java.util.HashMap<>();
                innerMap.put("Names", names);
                innerMap.put("AvgSalary", avgSalary);
                return innerMap;
            }
        )
    ));

System.out.println(departmentSummary);
// 출력: {IT={Names=[Bob], AvgSalary=8000.0}, HR={Names=[Alice, Charlie], AvgSalary=6500.0}}

이 복잡한 groupingByteeing의 결합은 명령형 코드에서 여러 번의 루프와 수동적인 Map 관리가 필요한 작업을 단 하나의 선언적인 파이프라인으로 대체합니다. 모든 과정은 순수 함수에 의해 수행되므로 불변성이 유지되며, 데이터 집계 로직이 매우 간결해집니다.

2.2. partitioningBy: 논리적 분할 연산

partitioningByPredicate (불리언 조건을 검사하는 함수)를 사용하여 데이터를 true 그룹과 false 그룹, 단 두 그룹으로만 분할할 때 사용됩니다. 결과는 항상 Map<Boolean, List<T>> 형태입니다.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8);

// 특정 임계값(5)을 초과하는지 여부에 따라 파티셔닝
java.util.Map<Boolean, List<Integer>> greaterThanFive = numbers.stream()
    // Predicate: n -> n > 5
    .collect(Collectors.partitioningBy(n -> n > 5));

System.out.println(greaterThanFive);
// 출력: {false=[1, 2, 3, 4, 5], true=[6, 7, 8]}

partitioningBy는 데이터를 분할하는 로직 자체를 조건 함수(Predicate)로 명확히 정의하여, 분할 로직을 변경하거나 테스트하기 용이하게 만듭니다.


결론

flatMapCollectors를 활용하는 것은 데이터를 직접 조작하는 명령형 방식 대신, 데이터를 정의된 함수(순수 함수)의 흐름에 따라 변환하고 집계하는 선언적 Functional Programming 스타일을 채택하는 것입니다. 이 방식은 복잡한 데이터 파이프라인을 단 몇 줄의 코드로 명확하게 표현할 수 있게 하여, 유지보수성이 높고 오류 발생 가능성이 낮은 견고한 소프트웨어 개발을 가능하게 합니다.




    Enjoy Reading This Article?

    Here are some more articles you might like to read next:

  • Gemini CLI 와 Supabase MCP 연결하기
  • Java Functional Programming - 병렬 스트림과 순수 함수로 고성능 스레드 안전성 확보
  • Java Functional Programming - Optional로 NullPointerException 완벽 해결하기
  • Java Functional Programming - Stream API 기초 및 활용(filter, map, reduce)
  • Java Functional Programming - Java record 활용