Java에서 여러 데이터를 기준별로 묶어야 할 때 Stream의 groupingBy는 생각보다 자주 등장합니다. 처음에는 정수 리스트처럼 단순한 예제로 시작하지만, 실제로는 객체 리스트를 상태별·학년별·카테고리별로 나누는 데 그대로 이어집니다. 다만 문법만 외우고 넘어가면 Map 구조를 잘못 이해하거나, filter()와 역할을 혼동하는 실수가 반복됩니다..
이번에는 홀수/짝수 그룹화처럼 쉬운 예제를 출발점으로 삼되, 왜 결과가 Map이 되는지, 객체에 적용할 때 무엇을 먼저 봐야 하는지까지 같이 정리해 보겠습니다.
숫자 예제로 보면 groupingBy가 왜 쉬우면서도 중요한지 보입니다
가장 단순한 형태는 숫자를 홀수와 짝수로 나누는 경우입니다. 입력 데이터가 아래와 같다고 해보겠습니다.
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
이 데이터를 두 그룹으로 나누면 우리가 원하는 결과는 대략 이런 모양입니다.
Even -> [2, 4, 6, 8, 10]
Odd -> [1, 3, 5, 7, 9]
여기서 중요한 점은 “조건에 맞는 값만 남기는 것”이 아니라, 모든 데이터를 분류 기준에 따라 나눠 담는 것이라는 점입니다. 그래서 groupingBy는 필터링과 닮아 보이지만 목적이 다릅니다.
코드는 아래처럼 작성할 수 있습니다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingByBasic {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1,2,3,4,5,6,7,8,9,10);
Map<String, List<Integer>> grouped = numbers.stream()
.collect(Collectors.groupingBy(n -> n % 2 == 0 ? "Even" : "Odd"));
System.out.println("원본: " + numbers);
grouped.forEach((key, value) -> System.out.println(key + " -> " + value));
}
}
실행 결과는 보통 다음과 비슷합니다.
원본: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Even -> [2, 4, 6, 8, 10]
Odd -> [1, 3, 5, 7, 9]
단, 출력 순서는 항상 같다고 기대하면 안 됩니다. 기본 groupingBy는 보통 HashMap 계열로 수집되므로 키 순서가 고정되지 않을 수 있습니다.
초보자가 많이 놓치는 포인트는 groupingBy가 List를 돌려주지 않는다는 점입니다
처음 Stream을 배우는 분들 코드를 보면, groupingBy를 사용하고도 결과를 List처럼 다루려는 경우가 많습니다. 그런데 반환 타입은 보통 아래 형태입니다.
Map<String, List<Integer>>
이 구조를 말로 바꾸면 이렇습니다.
- 키: 그룹 이름
- 값: 그 그룹에 속한 데이터 목록
즉 Even이라는 키에 짝수 리스트가 들어가고, Odd라는 키에 홀수 리스트가 들어갑니다. 그래서 출력할 때도 리스트의 forEach가 아니라 Map의 forEach를 사용하게 됩니다.
grouped.forEach((key, value) -> System.out.println(key + " -> " + value));
여기서 key는 "Even", "Odd" 같은 분류 기준이고, value는 각 그룹의 실제 데이터 목록입니다.
이 차이를 이해하지 못하면 아래처럼 잘못 생각하기 쉽습니다.
groupingBy결과도 그냥 순서대로 나열된 리스트일 것이다filter()여러 번 쓰는 것과 거의 같다- 키는 문자열로만 만들어야 한다
셋 다 자주 나오는 오해입니다.
객체 리스트처럼 데이터가 복잡해져도 방식은 거의 같습니다
정수 예제는 단순하지만, 실제로 손에 익혀야 하는 건 객체 그룹화입니다. 예를 들어 학생 목록이 있다고 해보겠습니다.
class Student {
private String name;
private int grade;
public Student(String name, int grade) {
this.name = name;
this.grade = grade;
}
public String getName() {
return name;
}
public int getGrade() {
return grade;
}
@Override
public String toString() {
return name;
}
}
이제 학생들을 학년별로 묶고 싶다면 기준만 객체 필드로 바꾸면 됩니다.
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingStudentExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("민수", 1),
new Student("지연", 2),
new Student("서준", 1),
new Student("하은", 3),
new Student("도윤", 2)
);
Map<Integer, List<Student>> groupedByGrade = students.stream()
.collect(Collectors.groupingBy(Student::getGrade));
groupedByGrade.forEach((grade, list) ->
System.out.println(grade + "학년 -> " + list));
}
}
실행 결과 예시는 아래처럼 볼 수 있습니다.
1학년 -> [민수, 서준]
2학년 -> [지연, 도윤]
3학년 -> [하은]
여기서 눈여겨볼 부분은 키 타입입니다. 문자열이 아니라 Integer가 키가 되었죠. groupingBy의 기준은 문자열일 필요가 없습니다. 분류 기준으로 사용할 수 있는 값이면 숫자, 문자열, enum, 불리언 등 모두 가능합니다.
수업에서 이 개념을 다룰 때 학생들이 가장 많이 실수하는 부분은 세 가지였습니다
직접 정리해 보니 groupingBy 자체가 어려운 것보다, 주변 개념이 섞이면서 헷갈리는 경우가 더 많았습니다.
첫 번째는 filter()와의 혼동입니다. 예를 들어 짝수만 뽑는 건 filter(n -> n % 2 == 0)로 충분합니다. 하지만 홀수와 짝수를 동시에 분리된 결과로 보관하려면 groupingBy가 더 자연스럽습니다. filter()는 남길 대상을 고르는 도구이고, groupingBy는 전체 데이터를 여러 바구니에 나누는 도구라고 기억하면 덜 헷갈립니다.
두 번째는 체이닝을 거꾸로 읽는 습관입니다. 초보자일수록 Collectors.groupingBy(...)를 먼저 보다가 전체 흐름을 놓칩니다. 실제로는 다음 순서로 읽는 편이 낫습니다.
1. numbers.stream()으로 스트림 생성 2. 각 요소를 어떤 기준으로 묶을지 결정 3. collect(...)로 최종 결과 수집
세 번째는 출력 순서를 당연하게 여기는 점입니다. 테스트 코드에서 "Even"이 먼저 찍혔다고 해서 항상 그렇게 보장되지는 않습니다. 화면 출력 순서가 중요한 상황이라면 그룹화 후 별도 정렬이나 적절한 맵 구현체를 고려해야 합니다. 실무에서는 이 부분을 먼저 확인하는 경우가 꽤 많습니다.
관련 메서드를 같이 봐야 Stream 흐름이 더 잘 잡힙니다
groupingBy만 따로 외우면 금방 잊기 쉽습니다. 보통은 filter()나 sorted()와 함께 이어서 쓰는 경우가 많기 때문입니다. 예를 들어 학생 객체 리스트에서 2학년 이상만 뽑고, 이름순으로 정렬한 뒤 학년별로 그룹화할 수도 있습니다.
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class GroupingWithFilterAndSorted {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student("민수", 1),
new Student("지연", 2),
new Student("서준", 1),
new Student("하은", 3),
new Student("도윤", 2)
);
Map<Integer, List<Student>> result = students.stream()
.filter(s -> s.getGrade() >= 2)
.sorted(Comparator.comparing(Student::getName))
.collect(Collectors.groupingBy(Student::getGrade));
result.forEach((grade, list) ->
System.out.println(grade + "학년 -> " + list));
}
}
이 코드는 다음 흐름으로 읽으면 쉽습니다.
- 먼저 2학년 이상만 남긴다
- 남은 학생을 이름순으로 정렬한다
- 그 결과를 학년별로 묶는다
즉 Stream은 메서드를 하나씩 따로 암기하는 것보다, 데이터를 어떤 순서로 가공할지 보는 쪽이 훨씬 실전에 가깝습니다.
직접 손으로 풀어보면 이해가 빨라지는 실습 문제
아래 문제는 groupingBy를 객체에 적용하는 감각을 익히기에 좋습니다.
실습 문제
주문 데이터를 상태별로 그룹화해 보세요.
- 상태는
READY,SHIPPING,DONE - 주문 번호와 상태를 가진 객체를 사용
DONE상태 주문만 따로filter()로 뽑는 방식과 비교해 보기
풀이 코드
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
class Order {
private String orderNo;
private String status;
public Order(String orderNo, String status) {
this.orderNo = orderNo;
this.status = status;
}
public String getOrderNo() {
return orderNo;
}
public String getStatus() {
return status;
}
@Override
public String toString() {
return orderNo;
}
}
public class GroupingOrderPractice {
public static void main(String[] args) {
List<Order> orders = Arrays.asList(
new Order("A100", "READY"),
new Order("A101", "DONE"),
new Order("A102", "SHIPPING"),
new Order("A103", "DONE"),
new Order("A104", "READY")
);
Map<String, List<Order>> grouped = orders.stream()
.collect(Collectors.groupingBy(Order::getStatus));
System.out.println("[groupingBy 결과]");
grouped.forEach((status, list) -> System.out.println(status + " -> " + list));
List<Order> doneOrders = orders.stream()
.filter(order -> "DONE".equals(order.getStatus()))
.collect(Collectors.toList());
System.out.println("\n[filter 결과]");
System.out.println("DONE -> " + doneOrders);
}
}
예상 출력은 다음과 비슷합니다.
[groupingBy 결과]
READY -> [A100, A104]
DONE -> [A101, A103]
SHIPPING -> [A102]
[filter 결과]
DONE -> [A101, A103]
이 비교가 중요한 이유는 분명합니다. filter()는 하나의 조건 결과를 꺼내는 데 적합하고, groupingBy는 전체 데이터를 여러 기준으로 나눠 관리할 때 더 잘 맞습니다.
점검할 때는 문법보다 분류 기준을 먼저 확인하면 좋습니다
코드를 다 쓴 뒤에는 아래 항목부터 확인해 보세요.
- 내가 원하는 게 “일부만 추리기”인지, “전체를 그룹으로 나누기”인지
- 결과를
List로 받을지Map으로 받을지 - 그룹 키 타입이
String인지Integer인지, 혹은 다른 타입인지 - 출력 순서가 중요한지
- 객체 리스트라면 어떤 필드를 기준으로 묶는지
이 다섯 가지를 먼저 체크하면 groupingBy 관련 오류의 상당수를 줄일 수 있습니다. 특히 객체가 들어가면 타입이 길어져서 복잡해 보이는데, 결국 보는 포인트는 “무엇을 기준으로 묶었는가” 하나입니다..
참고 자료
- [Oracle Java Stream API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html)
- [Oracle Comparator API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Comparator.html)

저는 대학에서 컴퓨터 관련 학과 학생들을 가르치고 있는 교수 김기태입니다. Spring Boot, React, Python 등을 활용한 풀스택 웹 개발과 최신 AI 서비스 설계를 주로 다루고 있으며, 학생들이 현업 실무에 바로 적용할 수 있는 기술 역량을 키울 수 있도록 강의와 실습을 설계하고 있습니다.


