Java에서 두 리스트 비교하는 방법 (공통·누락·동일 요소)
Java에서는 두 리스트를 비교할 일이 자주 있습니다. 데이터베이스 레코드를 동기화하거나, 테스트에서 API 응답을 확인하거나, 두 번의 내보내기 사이에 추가·삭제된 ID를 찾을 때입니다. 알고 싶은 것에 따라 써야 할 방법이 다릅니다.
- 두 리스트가 같은가? – 순서와 중복을 고려하는지 여부에 따라
- 두 리스트에 모두 있는 요소는? – 교집합
- 한쪽에만 있는 요소는? – 차집합
이 가이드는 표준 라이브러리(Stream.toList()와 record는 Java 16 이상)로 각 경우를 설명하고, Apache Commons와 Guava를 이용한 대안도 소개합니다. 모든 코드 예제는 다음 import를 사용합니다.
import java.util.*;
import java.util.stream.*;
두 리스트를 한 번만 비교하면 된다면 무료 두 목록 비교 도구에 붙여넣으세요. 코드 없이 A에만, B에만, 양쪽 모두에 있는 항목을 확인할 수 있습니다.
1. 두 리스트가 같은지 확인하기
List.equals()는 두 리스트가 같은 요소를 같은 순서로 가지고 있을 때만 true를 반환합니다.
List<Integer> a = List.of(1, 2, 3);
List<Integer> b = List.of(1, 2, 3);
List<Integer> c = List.of(3, 2, 1);
System.out.println(a.equals(b)); // true
System.out.println(a.equals(c)); // false – same elements, different order
요소는 각자의 equals() 메서드로 비교되므로 문자열, 숫자, equals()를 올바르게 구현한 모든 클래스에 사용할 수 있습니다. 구현 클래스가 달라도 동작하므로, 같은 요소를 가진 ArrayList와 LinkedList는 같다고 판단됩니다.
2. 순서를 무시하고 리스트 비교하기
순서를 무시하는 방법은 중복이 의미가 있는지에 따라 달라집니다.
같은 요소가 같은 횟수만큼(중복이 중요) – 복사본을 정렬해 비교합니다.
List<Integer> x = Arrays.asList(3, 1, 2, 2);
List<Integer> y = Arrays.asList(2, 3, 2, 1);
List<Integer> sortedX = new ArrayList<>(x);
List<Integer> sortedY = new ArrayList<>(y);
Collections.sort(sortedX);
Collections.sort(sortedY);
System.out.println(sortedX.equals(sortedY)); // true
요소를 정렬할 수 없다면 빈도 맵을 비교합니다.
Map<Integer, Long> countX = x.stream()
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
Map<Integer, Long> countY = y.stream()
.collect(Collectors.groupingBy(i -> i, Collectors.counting()));
System.out.println(countX.equals(countY)); // true
고유 요소가 같음(중복 무시) – Set으로 비교합니다.
List<Integer> z = List.of(1, 2, 3);
System.out.println(new HashSet<>(x).equals(new HashSet<>(z))); // true
3. retainAll로 공통 요소(교집합) 찾기
retainAll()은 다른 컬렉션에 없는 요소를 모두 제거합니다. 호출한 리스트 자체를 바꾸므로 복사본에서 작업하세요.
List<String> listA = List.of("apple", "banana", "cherry", "date");
List<String> listB = List.of("banana", "date", "fig");
List<String> common = new ArrayList<>(listA);
common.retainAll(new HashSet<>(listB));
System.out.println(common); // [banana, date]
새 ArrayList로 복사하는 데는 또 다른 이유도 있습니다. List.of()나 Arrays.asList()로 만든 리스트는 삭제를 지원하지 않아서, retainAll()을 호출하면 UnsupportedOperationException이 발생합니다.
4. removeAll로 누락된 요소(차집합) 찾기
removeAll()은 반대로 다른 컬렉션에 있는 요소를 모두 제거해, 첫 번째 리스트에만 있는 요소를 남깁니다.
List<String> onlyInA = new ArrayList<>(listA);
onlyInA.removeAll(new HashSet<>(listB));
System.out.println(onlyInA); // [apple, cherry]
List<String> onlyInB = new ArrayList<>(listB);
onlyInB.removeAll(new HashSet<>(listA));
System.out.println(onlyInB); // [fig]
두 메서드 모두 시작한 리스트의 원래 순서를 유지하며, 남은 요소의 중복도 그대로 둡니다.
5. 스트림으로 같은 작업 하기
스트림은 원본 리스트를 바꾸지 않으며, 추가 조건으로 거를 때도 읽기 쉽습니다.
Set<String> setB = new HashSet<>(listB);
List<String> onlyInA = listA.stream()
.filter(item -> !setB.contains(item))
.toList(); // Java 16+; use collect(Collectors.toList()) before that
List<String> inBoth = listA.stream()
.filter(setB::contains)
.toList();
System.out.println(onlyInA); // [apple, cherry]
System.out.println(inBoth); // [banana, date]
두 리스트 중 한쪽에만 있는 요소(대칭 차집합)를 얻으려면 양방향의 차이를 합칩니다.
Set<String> setA = new HashSet<>(listA);
List<String> inOnlyOne = Stream.concat(
listA.stream().filter(item -> !setB.contains(item)),
listB.stream().filter(item -> !setA.contains(item)))
.toList();
System.out.println(inOnlyOne); // [apple, cherry, fig]
대소문자를 구분하지 않고 비교하려면 Set을 만들기 전에 값을 정규화하고(예: listB.stream().map(String::toLowerCase).collect(Collectors.toSet())), 확인하는 항목도 소문자로 바꾸세요.
6. 객체 리스트 비교하기
컬렉션은 객체를 equals()로 비교하고, 해시 기반 컬렉션은 hashCode()도 사용합니다. Java record는 필드로부터 둘 다 자동 생성하므로 바로 사용할 수 있습니다.
record Customer(int id, String name) {}
List<Customer> oldCustomers = List.of(new Customer(1, "Ann"), new Customer(2, "Bob"));
List<Customer> newCustomers = List.of(new Customer(2, "Bob"), new Customer(3, "Cy"));
Set<Customer> newSet = new HashSet<>(newCustomers);
List<Customer> removed = oldCustomers.stream()
.filter(c -> !newSet.contains(c))
.toList();
System.out.println(removed); // [Customer[id=1, name=Ann]]
일반 클래스라면 equals()와 hashCode()를 둘 다 재정의하세요. equals()만 재정의하면 HashSet에서 일치하는 객체를 찾지 못합니다.
이름이 바뀌어도 다른 고객으로 취급되지 않도록, 모든 필드가 아니라 키로 객체를 비교하고 싶을 때가 많습니다.
Set<Integer> newIds = newCustomers.stream()
.map(Customer::id)
.collect(Collectors.toSet());
List<Customer> removedById = oldCustomers.stream()
.filter(c -> !newIds.contains(c.id()))
.toList();
System.out.println(removedById); // [Customer[id=1, name=Ann]]
7. 라이브러리: Apache Commons와 Guava
프로젝트에서 이미 이 라이브러리들을 쓰고 있다면 집합 연산을 한 줄로 처리할 수 있습니다.
Apache Commons Collections(org.apache.commons.collections4.CollectionUtils):
Collection<String> onlyInA = CollectionUtils.subtract(listA, listB); // [apple, cherry]
Collection<String> inBoth = CollectionUtils.intersection(listA, listB); // banana, date (any order)
Collection<String> inOnlyOne = CollectionUtils.disjunction(listA, listB); // apple, cherry, fig (any order)
Guava(com.google.common.collect.Sets)는 Set을 대상으로 하며 실시간 뷰를 반환합니다.
Set<String> setA = new HashSet<>(listA);
Set<String> setB = new HashSet<>(listB);
Set<String> onlyInA = Sets.difference(setA, setB);
Set<String> inBoth = Sets.intersection(setA, setB);
Set<String> inOnlyOne = Sets.symmetricDifference(setA, setB);
CollectionUtils는 각 요소가 몇 번 나오는지 고려하지만, Guava의 Sets는 고유 요소만 다룹니다. 기반 Set이 나중에 바뀐다면 new HashSet<>(view)로 Guava 뷰를 복사하세요.
8. 성능: 반복문 안에서 List를 검색하지 마세요
list.contains()는 요소를 하나씩 확인하므로, ArrayList.removeAll(otherList)와 filter(otherList::contains)는 한 리스트의 모든 요소를 다른 리스트의 모든 요소와 비교합니다. 10만 개짜리 리스트 두 개라면 최대 100억 번의 비교가 필요할 수 있습니다.
위의 모든 예제처럼 다른 리스트를 먼저 HashSet으로 감싸면 각 조회가 상수 시간이 되어, 전체 비교가 약 10만 단계로 끝납니다.
// Slow: O(n × m)
listA.stream().filter(listB::contains).toList();
// Fast: O(n + m)
Set<String> lookup = new HashSet<>(listB);
listA.stream().filter(lookup::contains).toList();
요약
| 목표 | 추천 방법 | 순서 유지? | 참고 |
|---|---|---|---|
| 같음(같은 순서) | a.equals(b) |
– | List 구현이 달라도 동작 |
| 같음(순서 무관, 중복 포함) | 복사본 정렬 또는 빈도 맵 비교 | – | 맵은 정렬할 수 없는 요소에도 사용 가능 |
| 같음(순서 무관, 중복 무시) | new HashSet<>(a).equals(new HashSet<>(b)) |
– | 반복 요소 무시 |
| 공통 요소 | copy.retainAll(new HashSet<>(b)) |
예 | 먼저 복사; List.of는 변경 불가 |
| A에만 있음 | copy.removeAll(new HashSet<>(b)) 또는 스트림 필터 |
예 | 스트림 방식은 아무것도 변경하지 않음 |
| 한쪽에만 있음 | 양방향 차이를 Stream.concat |
예 | 또는 CollectionUtils.disjunction |
| 객체 리스트 | record 또는 equals() + hashCode() |
예 | 또는 id 같은 키로 비교 |
HashSet 조회와 스트림 필터로 시작하세요. 빠르고, 리스트를 바꾸지 않으며, 거의 모든 경우에 맞습니다. 코드 밖에서 빠르게 확인하려면 온라인 목록 비교 도구가 브라우저에서 같은 일을 해 줍니다. Python을 쓰고 있다면 Python에서 두 리스트 비교하는 방법을 참고하세요.