Java Stream을 공부하다 보면 distinct()와 Collectors.toList()는 거의 한 세트처럼 자주 등장합니다. 특히 List<Integer>, List<String>처럼 단순한 리스트뿐 아니라, 나중에는 사용자 정의 객체 리스트에서 중복을 제거할 때도 같은 흐름을 사용하게 됩니다. 다만 처음 배울 때는 “중복이 왜 사라지는지”, “원본 리스트가 바뀌는지”, “객체에서는 무엇을 기준으로 같은 값이라고 보는지”를 제대로 구분하지 못하는 경우가 많습니다.
예제는 단순한 숫자 리스트에서 시작하되, 실제 Java 코드에서 자주 만나는 객체 리스트까지 자연스럽게 이어서 보겠습니다.
먼저 코드로 확인하는 distinct()의 기본 흐름
아래 리스트에는 같은 숫자가 여러 번 들어 있습니다.
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5);
목표는 중복을 제거해서 다음과 같은 새 리스트를 만드는 것입니다.
[1, 2, 3, 4, 5]
전체 코드는 다음과 같습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StreamDistinctExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5);
System.out.println("원본 리스트: " + numbers);
List<Integer> uniqueNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println("중복 제거 후: " + uniqueNumbers);
}
}
실행 결과는 다음과 같습니다.
원본 리스트: [1, 2, 2, 3, 3, 3, 4, 4, 4, 4, 5]
중복 제거 후: [1, 2, 3, 4, 5]
여기서 눈여겨볼 부분은 원본 리스트가 그대로 남아 있다는 점입니다. distinct()가 기존 numbers 리스트 안의 값을 직접 삭제하는 것이 아니라, Stream 처리 과정에서 중복을 걸러낸 뒤 새로운 결과를 만들어 냅니다.
이 차이를 이해하지 못하면 “왜 원본 리스트를 다시 출력했는데 그대로 나오지?” 하고 헷갈릴 수 있습니다.
stream(), distinct(), collect()는 각각 맡은 역할이 다르다
아래 한 줄을 조금 천천히 풀어보겠습니다.
List<Integer> uniqueNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
numbers.stream()은 리스트를 Stream으로 바꿉니다. Stream은 데이터를 하나씩 흘려보내면서 필터링, 변환, 정렬, 중복 제거 같은 연산을 연결할 수 있게 해주는 처리 방식입니다.
distinct()는 Stream 안에서 중복된 요소를 제거하는 중간 연산입니다. 중간 연산이라는 말은, 이 메서드만 호출한다고 바로 결과 리스트가 만들어지는 것은 아니라는 뜻입니다.
마지막의 collect(Collectors.toList())는 Stream으로 처리된 결과를 다시 List 형태로 모읍니다. 즉, distinct()를 적용한 뒤 그 결과를 변수에 저장하고 싶다면 이런 최종 연산이 필요합니다.
정리하면 흐름은 다음과 같습니다.
List → Stream → 중복 제거 → List로 수집
Java Stream을 처음 배울 때는 메서드 이름을 외우기보다 “지금 데이터가 List 상태인지, Stream 상태인지, 다시 List로 돌아왔는지”를 계속 확인하는 편이 좋습니다.
distinct()는 순서를 정렬하지 않는다
distinct()를 사용하면 중복이 사라지기 때문에, 초보 단계에서는 정렬까지 같이 되는 것으로 오해하기도 합니다. 하지만 distinct()는 중복 제거만 담당합니다.
예를 들어 다음 코드를 보겠습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class DistinctOrderExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(5, 3, 3, 1, 2, 5, 4, 2);
List<Integer> uniqueNumbers = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueNumbers);
}
}
실행 결과는 다음과 같습니다.
[5, 3, 1, 2, 4]
결과를 보면 [1, 2, 3, 4, 5]가 아닙니다. 처음 등장한 순서가 유지됩니다. 5가 먼저 나왔기 때문에 결과에서도 5가 앞에 남습니다.
정렬까지 필요하다면 sorted()를 추가해야 합니다.
List<Integer> sortedUniqueNumbers = numbers.stream()
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println(sortedUniqueNumbers);
실행 결과는 다음과 같습니다.
[1, 2, 3, 4, 5]
즉, distinct()와 sorted()는 목적이 다릅니다.
distinct(): 중복 제거sorted(): 정렬collect(Collectors.toList()): Stream 결과를 리스트로 수집
이 세 메서드를 같이 쓰면 중복이 제거된 정렬 리스트를 만들 수 있습니다.
filter()와 함께 쓰면 조건에 맞는 값만 중복 제거할 수 있다
실제 코딩에서는 전체 데이터의 중복을 제거하기보다, 특정 조건에 맞는 값만 남긴 뒤 중복을 제거하는 경우가 많습니다.
예를 들어 숫자 리스트에서 짝수만 남기고, 그중 중복을 제거하고 싶다면 filter()와 distinct()를 함께 사용할 수 있습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class FilterDistinctExample {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 4, 4, 5, 6, 6, 7);
List<Integer> evenUniqueNumbers = numbers.stream()
.filter(number -> number % 2 == 0)
.distinct()
.collect(Collectors.toList());
System.out.println(evenUniqueNumbers);
}
}
실행 결과는 다음과 같습니다.
[2, 4, 6]
처리 순서는 다음처럼 이해하면 됩니다.
전체 리스트
→ 짝수만 통과
→ 중복 제거
→ List로 수집
이때 filter()와 distinct()의 순서를 바꾸면 결과가 항상 달라지는 것은 아니지만, 성능이나 의도를 읽는 관점에서는 차이가 생길 수 있습니다. 보통은 “먼저 걸러낼 수 있는 조건”이 있다면 filter()를 앞에 두는 편이 읽기 쉽습니다.
문자열 리스트에서도 같은 방식으로 동작한다
숫자뿐 아니라 문자열 리스트도 같은 방식으로 처리할 수 있습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StringDistinctExample {
public static void main(String[] args) {
List<String> words = Arrays.asList("Java", "Stream", "Java", "List", "Stream");
List<String> uniqueWords = words.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueWords);
}
}
실행 결과는 다음과 같습니다.
[Java, Stream, List]
Integer, String 같은 기본적으로 많이 쓰는 클래스는 중복 판단 기준이 이미 잘 정의되어 있습니다. 그래서 같은 숫자, 같은 문자열이면 자연스럽게 중복으로 처리됩니다.
문제가 조금 더 깊어지는 지점은 직접 만든 객체를 리스트에 넣었을 때입니다.
객체 리스트에서 distinct()가 기대대로 안 되는 이유
예를 들어 학생 정보를 담는 Student 클래스가 있다고 하겠습니다.
class Student {
private final int id;
private final String name;
public Student(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public String toString() {
return "Student{id=" + id + ", name='" + name + "'}";
}
}
그리고 다음처럼 같은 id, 같은 name을 가진 객체를 두 번 넣어 보겠습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ObjectDistinctWrongExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student(1, "Kim"),
new Student(1, "Kim"),
new Student(2, "Lee")
);
List<Student> uniqueStudents = students.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueStudents);
}
}
겉보기에는 첫 번째 Student(1, "Kim")과 두 번째 Student(1, "Kim")이 같아 보입니다. 하지만 equals()와 hashCode()를 재정의하지 않으면 Java는 두 객체를 서로 다른 인스턴스로 볼 수 있습니다.
그래서 distinct()가 중복이라고 판단하지 못할 수 있습니다.
객체 리스트에서 distinct()를 제대로 사용하려면 “이 객체가 같다는 기준”을 코드로 알려줘야 합니다.
import java.util.Objects;
class Student {
private final int id;
private final String name;
public Student(int id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Student student)) return false;
return id == student.id && Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(id, name);
}
@Override
public String toString() {
return "Student{id=" + id + ", name='" + name + "'}";
}
}
이제 같은 id와 같은 name을 가진 학생은 같은 객체로 판단됩니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class ObjectDistinctExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(
new Student(1, "Kim"),
new Student(1, "Kim"),
new Student(2, "Lee")
);
List<Student> uniqueStudents = students.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(uniqueStudents);
}
}
실행 결과는 다음과 비슷하게 나옵니다.
[Student{id=1, name='Kim'}, Student{id=2, name='Lee'}]
여기서 중요한 점은 distinct() 자체가 객체의 모든 필드를 자동으로 비교해서 중복을 제거하는 기능은 아니라는 것입니다. 중복 판단은 객체의 equals()와 hashCode() 설계에 영향을 받습니다.
수업에서 자주 본 실수: “값이 같아 보이는데 왜 안 지워지나요?”
수업이나 코드 리뷰 상황에서 distinct()를 설명할 때 가장 많이 나오는 질문은 객체 리스트와 관련되어 있었습니다. 숫자와 문자열 예제에서는 대부분 바로 이해하지만, new Student(1, "Kim")을 두 번 만들면 당연히 같은 값이라고 생각하는 경우가 많았습니다.
그런데 Java 입장에서는 “내용이 같아 보이는 두 객체”와 “정말 같은 객체로 비교되도록 설계된 객체”가 다릅니다. 이 구분을 놓치면 distinct()를 아무리 붙여도 중복이 제거되지 않는 것처럼 보입니다.
개인적으로는 이 개념을 설명할 때 항상 다음 순서로 확인하게 합니다.
1. 리스트에 들어 있는 값이 기본 타입 래퍼나 문자열인지 확인한다. 2. 사용자 정의 객체라면 equals()가 재정의되어 있는지 본다. 3. equals()를 재정의했다면 hashCode()도 함께 재정의했는지 확인한다. 4. 중복 기준이 전체 필드인지, 특정 필드인지 먼저 정한다.
특히 네 번째가 중요합니다. 같은 학생을 id만으로 판단할지, id와 name을 함께 비교할지는 프로그램 요구사항에 따라 달라집니다. distinct()를 쓰기 전에 “무엇을 중복이라고 볼 것인가”를 먼저 정해야 합니다.
실습 문제: 중복 제거 후 정렬까지 한 번에 처리하기
다음 요구사항을 만족하는 코드를 작성해 보겠습니다.
- 숫자 리스트에서 짝수만 남긴다.
- 중복을 제거한다.
- 오름차순으로 정렬한다.
- 결과를
List<Integer>로 저장한다.
입력 데이터는 다음과 같습니다.
List<Integer> numbers = Arrays.asList(10, 4, 4, 3, 2, 10, 8, 1, 2, 6);
풀이 코드는 다음과 같습니다.
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
public class StreamPractice {
public static void main(String[] args) {
List<Integer> numbers = Arrays.asList(10, 4, 4, 3, 2, 10, 8, 1, 2, 6);
List<Integer> result = numbers.stream()
.filter(number -> number % 2 == 0)
.distinct()
.sorted()
.collect(Collectors.toList());
System.out.println(result);
}
}
실행 결과입니다.
[2, 4, 6, 8, 10]
이 예제는 filter(), distinct(), sorted(), collect(Collectors.toList())가 각각 어떤 역할을 맡는지 한 번에 확인하기 좋습니다.
다만 무조건 이 순서만 가능한 것은 아닙니다. 예를 들어 sorted()를 distinct()보다 앞에 둘 수도 있습니다. 하지만 중복을 먼저 줄이고 정렬하는 편이 의도를 읽기 쉽고, 데이터 양이 많을 때는 불필요한 정렬 대상을 줄이는 데 도움이 될 수 있습니다. 실제 성능은 데이터 크기와 실행 환경에 따라 달라질 수 있으므로, 중요한 코드라면 직접 측정하는 것이 안전합니다.
Collectors.toList()를 사용할 때 알아두면 좋은 점
Collectors.toList()는 Stream 결과를 리스트로 모을 때 널리 쓰입니다.
List<Integer> result = numbers.stream()
.distinct()
.collect(Collectors.toList());
초보 단계에서는 “Stream 결과를 List로 변환한다” 정도로 이해해도 충분합니다. 다만 조금 더 깊게 보면, 공식 문서에서는 Collectors.toList()가 반환하는 리스트의 구체적인 구현체나 변경 가능 여부를 강하게 보장하지 않는다는 점을 확인할 수 있습니다.
실무 코드에서 반환된 리스트를 수정해야 하는지, 읽기 전용으로 다룰 것인지가 중요하다면 이 부분을 문서와 함께 확인하는 습관이 좋습니다. Java 버전에 따라 stream().toList() 같은 선택지도 있지만, 이 메서드는 반환 리스트의 변경 가능성 측면에서 Collectors.toList()와 다르게 동작할 수 있습니다.
처음 Stream을 배우는 단계라면 다음 구분만 먼저 잡아도 충분합니다.
distinct()까지만 쓰면 아직 Stream 처리 흐름이다.- 결과를 변수에 리스트로 저장하려면 최종 연산이 필요하다.
collect(Collectors.toList())는 그 최종 연산 중 하나다.
직접 확인해 볼 만한 변형 예제
아래 예제들을 직접 실행해 보면 distinct()의 동작 기준이 더 분명해집니다.
첫 번째는 순서 확인입니다.
List<Integer> numbers = Arrays.asList(3, 1, 3, 2, 1, 4);
List<Integer> result = numbers.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(result);
예상 결과는 다음과 같습니다.
[3, 1, 2, 4]
정렬된 결과가 아니라, 처음 등장한 순서가 유지된다는 점을 확인할 수 있습니다.
두 번째는 문자열 대소문자입니다.
List<String> words = Arrays.asList("java", "Java", "JAVA", "java");
List<String> result = words.stream()
.distinct()
.collect(Collectors.toList());
System.out.println(result);
실행 결과는 다음과 같습니다.
[java, Java, JAVA]
"java", "Java", "JAVA"는 서로 다른 문자열입니다. 대소문자를 무시하고 중복 제거하려면 별도의 변환 기준을 만들어야 합니다.
예를 들어 모두 소문자로 바꾼 뒤 중복 제거할 수 있습니다.
List<String> result = words.stream()
.map(String::toLowerCase)
.distinct()
.collect(Collectors.toList());
System.out.println(result);
실행 결과입니다.
[java]
이처럼 중복 제거는 단순히 메서드 하나의 문제가 아니라, 어떤 기준으로 데이터를 비교할 것인지와 연결됩니다.
기억해 둘 판단 기준
Java Stream에서 distinct()를 사용할 때는 다음 정도를 기준으로 점검하면 실수를 줄일 수 있습니다.
- 원본 리스트를 바꾸려는 것인지, 새 리스트를 만들려는 것인지 구분한다.
- 숫자와 문자열은 비교 기준이 이미 잘 정의되어 있다.
- 사용자 정의 객체는
equals()와hashCode()구현 여부를 확인한다. - 정렬이 필요하면
sorted()를 별도로 사용한다. - 조건 필터링이 필요하면
filter()와 함께 조합한다. - 결과를 리스트로 보관하려면
collect(Collectors.toList())같은 최종 연산이 필요하다.
distinct()는 보기에는 간단한 메서드지만, 객체 비교 기준까지 들어가면 Java의 중요한 기본기와 연결됩니다. 단순히 중복 제거 코드 한 줄을 외우기보다, Stream의 처리 흐름과 객체의 동등성 비교를 함께 이해해 두면 이후 객체 리스트 처리에서 훨씬 덜 헤매게 됩니다.
참고 자료
- [Oracle Java Stream API](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html)

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


