Stream API와 groupingBy, Collectors.toList() 정리

우리가 수업 시간에 Java Stream, Stream API를 처음 배울 때는 filter(), map(), Collectors.toList(), groupingBy를 각각 따로 외우게 되기 쉽습니다. 그런데 실제 작업 코드에서는 이 메서드들이 분리되어 등장하지 않습니다. 조건으로 고르고, 필요한 값으로 바꾸고, 경우에 따라 그룹으로 묶은 뒤 최종 결과를 컬렉션으로 받는 흐름이 한 번에 이어집니다. 그래서 문법을 따로 암기하는 방식보다 “데이터를 어떤 질문으로 처리할 것인가”를 중심으로 통합해서 보는 편이 훨씬 오래 남습니다.

반복문만으로도 같은 일을 할 수 있는데 왜 굳이 Stream API를 쓰느냐는 질문도 자주 나옵니다. 제 기준에서는 코드가 짧아져서라기보다 의도가 분리되어 보인다는 점이 더 큽니다. 무엇을 걸러낼지, 무엇으로 바꿀지, 어떤 기준으로 묶을지 단계가 나뉘면 나중에 수정할 때도 덜 흔들립니다. 특히 학습 단계에서는 “결과 타입이 지금 List인지 Map인지”를 눈으로 확인하며 익히기 좋습니다.

Stream API를 통합해서 봐야 실무와 수업에서 덜 헷갈린다

초반에는 stream()을 호출하는 순간 뭔가 새로운 리스트가 만들어진다고 느끼기 쉽습니다. 하지만 Stream은 보통 원본 컬렉션을 직접 바꾸는 도구가 아니라, 데이터를 흘려보내며 처리하는 파이프라인으로 이해하는 편이 맞습니다. 즉, 대체로 다음 순서로 읽으면 됩니다.

1. 원본 데이터가 있다. 2. stream()으로 처리 흐름을 시작한다. 3. filter()로 남길 대상을 정한다. 4. map()으로 필요한 형태로 바꾼다. 5. collect()로 리스트나 맵 같은 최종 결과를 만든다.

이 흐름을 놓치면 초보자가 자주 하는 실수가 바로 나옵니다. filter()는 조건 판별인데 그 안에서 변환까지 하려 하거나, map()으로 바꾼 결과를 다시 원본이 바뀐 것으로 오해하는 경우가 있습니다. 문법보다 역할 구분이 먼저 잡혀야 하는 이유가 여기에 있습니다.

숫자 리스트로 보는 filter, map, Collectors.toList() 연결 방식

가장 단순한 예제로 정수 리스트를 보겠습니다. 10 이상인 숫자만 남기고, 그 값을 2배로 바꾼 다음, 다시 리스트로 수집합니다.

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

public class FilterMapBasicExample {
    public static void main(String[] args) {
        List<Integer> numbers = Arrays.asList(5, 15, 3, 20, 8, 25, 12);

        System.out.println("원본 숫자: " + numbers);

        List<Integer> result = numbers.stream()
                .filter(n -> n >= 10)
                .map(n -> n * 2)
                .collect(Collectors.toList());

        System.out.println("처리 결과: " + result);
        System.out.println("원본 유지 확인: " + numbers);
    }
}

실행 결과:

원본 숫자: [5, 15, 3, 20, 8, 25, 12]
처리 결과: [30, 40, 50, 24]
원본 유지 확인: [5, 15, 3, 20, 8, 25, 12]

이 예제에서 확인할 것은 세 가지입니다.

  • filter()는 남길 데이터를 고른다.
  • map()은 통과한 데이터를 바꾼다.
  • Collectors.toList()는 최종 결과를 다시 List로 받는다.

여기서 Collectors.toList()가 중요한 이유는 결과를 재사용할 수 있기 때문입니다. 단순 출력만 할 거라면 forEach()도 가능하지만, 이후 다른 작업으로 넘겨야 한다면 리스트로 받는 편이 자연스럽습니다. 실습 중간 결과를 저장해 두면 디버깅도 쉬워집니다.

groupingBy는 묶는 기준을 선언하는 수집 방식이다

이제 groupingBy를 보겠습니다. 문자열 목록에서 길이 3 이상인 단어만 남기고, 길이별로 묶으면 groupingBy가 어떤 역할인지 감이 빨리 옵니다.

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

public class FilterGroupingByExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("Java", "is", "cool", "stream", "lambda", "a");

        System.out.println("원본 데이터: " + words);

        Map<Integer, List<String>> grouped = words.stream()
                .filter(word -> word.length() >= 3)
                .collect(Collectors.groupingBy(String::length));

        System.out.println("그룹화 결과: " + grouped);
        System.out.println("원본 유지 확인: " + words);
    }
}

실행 결과 예시:

원본 데이터: [Java, is, cool, stream, lambda, a]
그룹화 결과: {4=[Java, cool], 6=[stream, lambda]}
원본 유지 확인: [Java, is, cool, stream, lambda, a]

여기서 결과 타입은 Map<Integer, List<String>>입니다. 처음 보면 길어 보여도 구조는 단순합니다.

  • Integer: 그룹의 기준값, 여기서는 문자열 길이
  • List<String>: 그 길이에 속한 문자열들

즉, groupingBy는 “어떤 기준으로 나눌 것인가”를 선언하는 수집 방식입니다. 반대로 Collectors.toList()는 “하나의 목록으로 다시 받을 것인가”를 결정합니다.

한 가지 주의할 점도 있습니다. groupingBy() 결과의 키 순서는 항상 고정이라고 생각하면 안 됩니다. 출력 예시가 일정해 보여도, 구현과 환경에 따라 순서가 달라질 수 있습니다. 순서가 중요하면 별도로 정렬 기준을 둬야 합니다.

개인적 관찰: 학생들이 가장 많이 막히는 지점은 문법이 아니라 결과 타입이었다

수업이나 스터디에서 자주 본 실수는 filter()나 map() 문법을 틀리는 경우보다 “지금 손에 쥔 결과가 무엇인지”를 놓치는 경우였습니다. 예를 들어 map() 뒤의 결과를 바로 List라고 생각하거나, groupingBy를 썼는데도 List<Student>로 받으려는 식입니다. 직접 코드를 실행해 보면 filter() 뒤에도 여전히 Stream이고, collect()를 만나기 전까지는 실제 컬렉션 결과가 없다는 사실이 훨씬 분명해집니다.

직접 정리하면서도 느낀 점은, Stream API 학습은 메서드 이름을 많이 아는 것보다 “최종 자료구조를 먼저 떠올리는 습관”이 더 중요하다는 것입니다. 이름 목록이 필요하면 Collectors.toList(), 분류표가 필요하면 groupingBy, 그룹별 개수가 필요하면 counting()을 붙이는 식으로 접근하면 코드가 훨씬 덜 복잡해집니다.

하나의 작업 흐름에서 filter, map, groupingBy를 함께 읽는 법

각 메서드를 따로 배우면 비슷한 내용을 여러 번 외우는 느낌이 납니다. 실제 작업에서는 보통 아래 질문 순서로 생각합니다.

  • 무엇을 남길까 → filter()
  • 남긴 것을 무엇으로 바꿀까 → map()
  • 어떤 기준으로 묶을까 → groupingBy()
  • 최종 결과를 어떤 형태로 받을까 → Collectors.toList(), counting() 등

문자열을 소문자로 통일하고, 중복을 제거하고, 길이 3 이상만 남긴 뒤, 마지막에 길이별로 묶는 코드를 보겠습니다.

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

public class CombinedStreamExample {
    public static void main(String[] args) {
        List<String> words = Arrays.asList("Java", "java", "is", "cool", "cool", "stream", "lambda", "a");

        Map<Integer, List<String>> grouped = words.stream()
                .map(String::toLowerCase)
                .distinct()
                .filter(word -> word.length() >= 3)
                .collect(Collectors.groupingBy(String::length));

        System.out.println(grouped);
    }
}

이런 식으로 보면 Stream API는 단순 필터링 문법이 아니라, 정제와 변환, 그룹화까지 이어지는 데이터 처리 흐름입니다. 작업이라는 관점에서 보면 메서드 암기보다 “단계별 의도”가 훨씬 중요합니다.

객체 리스트에서 Java Stream의 가치가 더 분명해진다

문자열과 숫자는 입문용으로 좋지만, Stream의 장점은 객체 리스트에서 더 잘 드러납니다. 학생 목록에서 점수 60점 이상인 학생만 남기고, 반별로 묶는 예제를 보겠습니다.

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

class Student {
    private final String name;
    private final int score;
    private final String classroom;

    public Student(String name, int score, String classroom) {
        this.name = name;
        this.score = score;
        this.classroom = classroom;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public String getClassroom() {
        return classroom;
    }

    @Override
    public String toString() {
        return name + "(" + score + ")";
    }
}

public class StudentGroupingExample {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
                new Student("민수", 85, "A"),
                new Student("지현", 55, "A"),
                new Student("서준", 72, "B"),
                new Student("하은", 91, "B"),
                new Student("도윤", 40, "A")
        );

        Map<String, List<Student>> grouped = students.stream()
                .filter(student -> student.getScore() >= 60)
                .collect(Collectors.groupingBy(Student::getClassroom));

        System.out.println(grouped);
    }
}

실행 결과 예시:

{A=[민수(85)], B=[서준(72), 하은(91)]}

이번에는 결과 타입이 Map<String, List<Student>>입니다. 문자열 길이 예제와 구조는 같고, 그룹 기준만 length에서 classroom으로 바뀌었습니다.

또 같은 데이터에서 이름만 뽑고 싶다면 map()을 붙여서 이렇게 작성할 수 있습니다.

List<String> passedNames = students.stream()
        .filter(student -> student.getScore() >= 60)
        .map(Student::getName)
        .collect(Collectors.toList());

즉, 같은 원본 컬렉션에서도 최종 목적에 따라 groupingBy와 Collectors.toList()의 선택이 달라집니다.

수업 실습 문제와 풀이 코드

이번 실습은 filter, map, groupingBy, Collectors.toList()를 한 문제 안에서 함께 다룹니다.

문제:

  • 학생 목록에서 점수 60점 이상인 학생만 고른다.
  • 통과 학생 이름 목록을 List<String>으로 만든다.
  • 통과 학생을 반별로 그룹화한다.
  • 반별 인원 수도 함께 확인한다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

class Student {
    private final String name;
    private final int score;
    private final String classroom;

    public Student(String name, int score, String classroom) {
        this.name = name;
        this.score = score;
        this.classroom = classroom;
    }

    public String getName() {
        return name;
    }

    public int getScore() {
        return score;
    }

    public String getClassroom() {
        return classroom;
    }

    @Override
    public String toString() {
        return name + "(" + score + ")";
    }
}

public class StreamPracticeSolution {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
                new Student("민수", 85, "A"),
                new Student("지현", 55, "A"),
                new Student("서준", 72, "B"),
                new Student("하은", 91, "B"),
                new Student("도윤", 40, "A"),
                new Student("예린", 67, "A")
        );

        List<String> passedNames = students.stream()
                .filter(student -> student.getScore() >= 60)
                .map(Student::getName)
                .collect(Collectors.toList());

        Map<String, List<Student>> groupedByClass = students.stream()
                .filter(student -> student.getScore() >= 60)
                .collect(Collectors.groupingBy(Student::getClassroom));

        Map<String, Long> countByClass = students.stream()
                .filter(student -> student.getScore() >= 60)
                .collect(Collectors.groupingBy(
                        Student::getClassroom,
                        Collectors.counting()
                ));

        System.out.println("통과 학생 이름 목록: " + passedNames);
        System.out.println("반별 그룹화: " + groupedByClass);
        System.out.println("반별 인원 수: " + countByClass);
        System.out.println("원본 학생 목록: " + students);
    }
}

실행 결과 예시:

통과 학생 이름 목록: [민수, 서준, 하은, 예린]
반별 그룹화: {A=[민수(85), 예린(67)], B=[서준(72), 하은(91)]}
반별 인원 수: {A=2, B=2}
원본 학생 목록: [민수(85), 지현(55), 서준(72), 하은(91), 도윤(40), 예린(67)]

이 문제의 포인트는 같은 조건식을 사용하더라도 마지막 수집 방식에 따라 결과 타입이 완전히 달라진다는 점입니다.

  • 이름 목록 → List<String>
  • 반별 그룹화 → Map<String, List<Student>>
  • 반별 인원 수 → Map<String, Long>

실습할 때는 출력만 보고 끝내지 말고, 변수 선언 타입까지 같이 읽어 보는 편이 좋습니다. 이 과정이 익숙해지면 새로운 Stream 코드를 만나도 훨씬 빨리 해석할 수 있습니다.

직접 실행하며 확인하면 좋은 체크 지점

실전에서는 문법보다 데이터 상태를 먼저 점검하는 편이 안전합니다.

첫째, null 가능성이 있다면 필드 접근 전에 걸러야 합니다.

.filter(java.util.Objects::nonNull)

둘째, groupingBy()의 결과가 Map이라는 사실을 잊지 않는 것이 좋습니다. 그룹화 결과를 다시 정렬해서 출력해야 할 수도 있고, 특정 키가 없는 경우도 생길 수 있습니다.

셋째, 모든 작업을 한 줄에 몰아 넣으면 처음에는 짧아 보여도 수정이 어려워집니다. 학습 단계나 디버깅 중이라면 중간 결과를 변수로 분리해 타입과 내용을 확인하는 방법이 더 실용적입니다.

넷째, 병렬 처리나 성능 최적화는 별도 검토 주제입니다. Stream을 쓴다고 항상 더 빠른 것은 아니며, 데이터 크기와 작업 특성에 따라 차이가 납니다. 성능 판단이 필요하면 실제 입력 크기와 실행 조건에서 측정하는 것이 가장 믿을 만합니다.

어떤 제목의 예제를 만나도 마지막 자료구조를 먼저 보면 이해가 빨라진다

Java Stream 관련 글이나 수업 제목을 보면 map 예제, groupingBy 예제, Collectors.toList() 정리처럼 항목별로 나뉘어 있는 경우가 많습니다. 학습 입장에서는 도움이 되지만, 실제 작업에 적용하려면 마지막에 필요한 자료구조를 먼저 보는 습관이 더 중요합니다.

  • 목록이 필요하다 → Collectors.toList()
  • 기준별 묶음이 필요하다 → groupingBy
  • 그룹별 개수가 필요하다 → groupingBy(..., counting())

이 기준만 분명해도 Stream API를 통합해서 읽기가 훨씬 쉬워집니다. 특히 코드 리뷰나 유지보수 상황에서는 “이 파이프라인이 무엇을 만들려는가”가 먼저 보여야 수정 실수가 줄어듭니다.

참고 자료

  • [Oracle Stream API 문서](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html)
  • [Oracle Collectors API 문서](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html)
  • [Oracle Package java.util.stream 문서](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/package-summary.html)

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다