1. union 연산 (합집합)
Java에서는 집합을 표현할 때 인터페이스 Set<T>를 사용합니다. List와 달리, 집합은 원소의 고유성을 보장하며 일반적으로 순서를 보장하지 않습니다(특수 구현을 사용하지 않는 한, 예: LinkedHashSet). 가장 널리 쓰이는 구현은 HashSet과 TreeSet입니다. 핵심 목적은 원소 존재 여부를 빠르게 판별하고 중복을 허용하지 않는 것입니다.
언제 Set이 필요할까요?
- 고유성이 중요할 때: 예를 들어, 사이트를 방문한 모든 고유 사용자 목록.
- 원소 존재 여부를 빠르게 확인해야 할 때: HashSet의 contains는 일반적으로 상수 시간에 동작합니다.
- 집합에 대한 전형적인 연산이 필요할 때: 합집합, 교집합, 차집합.
Union은 둘 이상의 집합을 합치는 연산으로, 결과에는 양쪽(또는 모든) 집합의 모든 원소가 중복 없이 포함됩니다.
실전 예제
“로보틱스”와 “프로그래밍” 동아리에 다니는 학생들의 두 집합이 있다고 가정해 봅시다:
Set<String> robotics = Set.of("Anya", "Boris", "Vika");
Set<String> programming = Set.of("Vika", "Gleb", "Dasha");
적어도 하나의 동아리에 다니는 모든 학생들의 집합을 구해야 합니다.
Stream API로 해결하기
가장 직관적인 방법은 두 스트림을 합쳐 Set으로 수집하는 것입니다:
Set<String> all = Stream.concat(
robotics.stream(),
programming.stream()
).collect(Collectors.toSet());
System.out.println(all); // [Anya, Boris, Vika, Gleb, Dasha]
설명:
- Stream.concat은 두 스트림을 합칩니다.
- collect(Collectors.toSet())는 요소를 집합으로 수집하며(자동으로 중복 제거).
대안: 두 개 초과의 집합
세 개 이상의 동아리가 있다면 Stream.of와 flatMap을 사용합니다:
Set<String> math = Set.of("Zhenya", "Vika", "Boris");
Set<String> all = Stream.of(robotics, programming, math)
.flatMap(Set::stream)
.collect(Collectors.toSet());
System.out.println(all); // [Anya, Boris, Vika, Gleb, Dasha, Zhenya]
왜 Set일까요?
Set은 자동으로 중복을 제거하기 때문입니다. List로 수집하면 같은 이름이 여러 번 나타납니다.
2. intersection 연산 (교집합)
Intersection은 두 집합에 동시에 존재하는 원소들입니다.
실전 예제
“로보틱스”와 “프로그래밍” 모두에 다니는 학생을 찾습니다:
Set<String> robotics = Set.of("Anya", "Boris", "Vika");
Set<String> programming = Set.of("Vika", "Gleb", "Dasha");
Stream API로 해결:
Set<String> both = robotics.stream()
.filter(programming::contains)
.collect(Collectors.toSet());
System.out.println(both); // [Vika]
설명:
“로보틱스” 참가자를 순회하면서 “프로그래밍”에도 있는 사람만 필터링합니다. 결과는 두 동아리에 모두 속한 이름들의 집합입니다.
대안(Stream API 없이)
변경 가능한 내장 메서드 retainAll을 사용할 수도 있습니다(현재 집합을 변경):
Set<String> intersection = new HashSet<>(robotics);
intersection.retainAll(programming);
System.out.println(intersection); // [Vika]
다만 이 강의는 Stream API 주제이므로 스트림에 초점을 맞춥니다.
3. difference 연산 (차집합)
Difference는 첫 번째 집합에는 있지만 두 번째 집합에는 없는 원소들입니다.
실전 예제
“로보틱스”에만 다니고 “프로그래밍”에는 다니지 않는 학생을 찾습니다:
Set<String> robotics = Set.of("Anya", "Boris", "Vika");
Set<String> programming = Set.of("Vika", "Gleb", "Dasha");
Stream API로 해결:
Set<String> onlyRobotics = robotics.stream()
.filter(name -> !programming.contains(name))
.collect(Collectors.toSet());
System.out.println(onlyRobotics); // [Anya, Boris]
설명:
“로보틱스” 참가자 중 “프로그래밍”에 없는 사람만 남기도록 필터링합니다.
대안(Stream API 없이)
Set<String> difference = new HashSet<>(robotics);
difference.removeAll(programming);
System.out.println(difference); // [Anya, Boris]
4. 실전 과제: 학생 목록 처리
과제 1: 한 개의 동아리에만 다니는 학생 찾기
“로보틱스” 또는 “프로그래밍” 중 하나에만 다니고 둘 다에는 다니지 않는 학생을 찾아야 합니다. 이는 대칭 차집합(집합의 xor)입니다:
Set<String> onlyOne = Stream.concat(
robotics.stream().filter(name -> !programming.contains(name)),
programming.stream().filter(name -> !robotics.contains(name))
).collect(Collectors.toSet());
System.out.println(onlyOne); // [Anya, Boris, Gleb, Dasha]
과제 2: 여러 동아리에서 모든 고유 학생 목록
Set<String> all = Stream.of(robotics, programming, math)
.flatMap(Set::stream)
.collect(Collectors.toSet());
System.out.println(all); // [Anya, Boris, Vika, Gleb, Dasha, Zhenya]
과제 3: 어떤 동아리에도 다니지 않는 학생 찾기
반(클래스)의 전체 학생 목록이 있다고 가정해 봅시다:
Set<String> allStudents = Set.of("Anya", "Boris", "Vika", "Gleb", "Dasha", "Zhenya", "Igor’", "Katya");
어떤 동아리에도 다니지 않는 사람을 구합니다:
Set<String> attendees = Stream.of(robotics, programming, math)
.flatMap(Set::stream)
.collect(Collectors.toSet());
Set<String> notInAny = allStudents.stream()
.filter(name -> !attendees.contains(name))
.collect(Collectors.toSet());
System.out.println(notInAny); // [Igor’, Katya]
5. 중요 사항: equals, hashCode 및 성능
왜 equals와 hashCode를 올바르게 구현해야 할까요?
모든 집합(Set) 연산은 equals와 hashCode 메서드의 정확성에 의존합니다. 자체 클래스의 객체(예: Student)를 저장한다면, 이 메서드들을 반드시 재정의해야 합니다. 그렇지 않으면 비교가 올바르게 동작하지 않습니다.
예시:
class Student {
String name;
int age;
// equals와 hashCode를 반드시 재정의하세요!
}
이를 하지 않으면, 이름과 나이가 같은 두 학생도 Set 관점에서는 서로 다른 객체로 간주될 수 있습니다.
왜 List 대신 Set을 사용하는 것이 좋을까요?
- Set의 contains는 빠르게 동작합니다(보통 상수 시간).
- List에서 원소 검색은 선형 시간이 걸리므로 큰 컬렉션에서는 치명적일 수 있습니다.
- 집합 연산(union, intersection, difference)에는 Set이 훨씬 효율적이고 논리적입니다.
6. 집합 연산 작업 시 흔한 실수
오류 1: 집합 연산에 Set 대신 List를 사용. 요소를 List로 수집하면 중복이 제거되지 않고, contains도 느리게 동작합니다. union/intersection/difference에는 항상 Set을 사용하세요.
오류 2: 객체에 equals/hashCode가 구현되어 있지 않음. 자체 클래스를 Set에 저장하면서 equals와 hashCode를 재정의하지 않으면, 교집합과 차집합이 “이상하게” 동작합니다 — 의미상 동일한 객체가 동등하다고 간주되지 않을 수 있습니다.
오류 3: 스트림 처리 중 컬렉션 수정. 스트림 내부에서 원본 Set을 수정(예: 요소 추가/삭제)하려고 하면 ConcurrentModificationException이 발생할 수 있습니다. 항상 새로운 집합으로 작업하세요.
오류 4: 순서가 예상치 않게 사라짐. HashSet은 원소의 순서를 보장하지 않습니다. 순서가 중요하다면 LinkedHashSet이나 TreeSet을 사용하세요.
오류 5: 두 개를 초과하는 컬렉션에 Stream.concat 사용. Stream.concat은 두 스트림만 합칩니다. 더 많을 경우 Stream.of(...)와 flatMap을 사용하세요.
오류 6: null 처리 실수. 집합은 null 값을 선호하지 않습니다. 특히 Set.of(...)는 null을 허용하지 않습니다. null이 필요하다면 다른 구현을 사용하거나 사전에 값을 필터링하세요.
GO TO FULL VERSION