본문 바로가기

게시글 저장소 집합 연산

합집합·교집합·차집합의 파괴적 변경을 복사본으로 격리하고 게시글 태그·공개 상태에 적용합니다.

Set은 중복 제거 도구를 넘어 “어느 그룹에 속하는가”를 직접 표현합니다.

addAll은 합집합, retainAll은 교집합, removeAll은 차집합을 만들지만 수신 Set을 변경합니다.

원본을 이후에도 써야 한다면 먼저 복사본을 만들고 연산해야 합니다.

이 절은 두 게시글 카테고리의 제목 집합을 기준으로 API의 변경 효과와 결과 검산을 연결합니다.


retainAll의 원본 훼손

공개된 추천 제목을 구하려고 공개 목록에 직접 retainAll을 호출하면 공개 이력 자체가 교집합으로 축소됩니다.

다음 단계에서 전체 공개 수를 출력할 때 잘못된 값이 나옵니다.

lab/DestructiveIntersectionBug.java
import java.util.HashSet;
import java.util.Set;

public final class DestructiveIntersectionBug {
    public static void main(String[] args) {
        Set<String> published = new HashSet<>(Set.of("array", "hash", "thread"));
        Set<String> featured = Set.of("hash", "thread", "lock");
        published.retainAll(featured);
        System.out.println("common=" + published);
        System.out.println("published-count=" + published.size());
    }
}

공통 값 두 개는 맞지만 원래 공개 수 3을 잃었습니다.

API 이름만 보고 새 Set을 반환한다고 가정한 것이 원인입니다.

세 메서드의 반환값은 새 집합이 아니라 수신 집합의 원소 구성이 바뀌었는지를 알리는 boolean입니다. 위 retainAll은 array를 지워 true를 반환합니다. 같은 호출을 한 번 더 하면 더 지울 원소가 없어 false를 반환하지만, 공개 목록은 여전히 2개입니다. 반환값이 false라고 해서 원본이 복구되는 것은 아닙니다. Collection API의 반환 계약을 기준으로 읽으세요.

new HashSet<>(published) 복사본에 retainAll을 적용하면 원본 소유권을 보존할 수 있습니다. Set.of(...)로 만든 집합에 직접 이 변경 연산을 호출하면 UnsupportedOperationException이 발생하므로, 수정 가능한 복사본을 만드는 단계도 필요합니다.


세 연산의 입력·출력 불변식

src/SetOperationToolkit.java
import java.util.HashSet;
import java.util.Set;

public final class SetOperationToolkit {
    public static void main(String[] args) {
        Set<String> java = Set.of("array", "hash", "thread");
        Set<String> spring = Set.of("http", "thread", "database");
        System.out.println("union=" + sorted(union(java, spring)));
        System.out.println("intersection=" + sorted(intersection(java, spring)));
        System.out.println("java-only=" + sorted(difference(java, spring)));
        System.out.println("inputs=" + java.size() + "/" + spring.size());
    }

    static <E> Set<E> union(Set<? extends E> left, Set<? extends E> right) {
        Set<E> result = new HashSet<>(left);
        result.addAll(right);
        return Set.copyOf(result);
    }

    static <E> Set<E> intersection(Set<? extends E> left, Set<?> right) {
        Set<E> result = new HashSet<>(left);
        result.retainAll(right);
        return Set.copyOf(result);
    }

    static <E> Set<E> difference(Set<? extends E> left, Set<?> right) {
        Set<E> result = new HashSet<>(left);
        result.removeAll(right);
        return Set.copyOf(result);
    }

    private static <E extends Comparable<? super E>> java.util.List<E> sorted(Set<E> values) {
        return values.stream().sorted().toList();
    }
}
각 복사본에서 구한 합집합·교집합·차집합

두 입력 집합은 보존하고 Java 집합의 복사본마다 다른 연산을 적용하여 합집합 다섯 개, 교집합 한 개, Java 전용 두 개를 구합니다.

집합 연산 · 각자의 복사본에서 계산

각 복사본에서 구한 합집합·교집합·차집합

왼쪽 집합의 새 복사본에 오른쪽 원소를 더하거나 남기거나 제거합니다. 세 연산을 해도 입력 두 집합은 각각 3개로 유지됩니다.

두 집합의 공통 영역과 각자의 원소 두원은원입력java3원소와spring3원소입니다. 공통영역thread와java만array/hash,spring만http/database의소속을표시합니다. 전체5/공통1/java차집합2이며각연산은별도복사본을바꾸므로입력3/3이유지됩니다. java spring array hash thread http database 합집합: 두 원의 전체5개 교집합: 공통 thread1개 java 차집합: array, hash2개 각 연산은 별도 복사본 사용 원본 크기는 3/3 그대로
두 집합의 공통 영역과 각자의 원소 두원은원입력java3원소와spring3원소입니다. 공통영역thread와java만array/hash,spring만http/database의소속을표시합니다. 전체5/공통1/java차집합2이며각연산은별도복사본을바꾸므로입력3/3이유지됩니다. java spring array hash thread http database 합집합: 두 원의 전체5개 교집합: 공통 thread1개 java 차집합: array, hash2개 각 연산은 별도 복사본 사용 원본 크기는 3/3 그대로
각 복사본에서 구한 합집합·교집합·차집합
복사본에 적용한 연산실제 정렬 출력결과 크기
addAll(right)[array, database, hash, http, thread]5
retainAll(right)[thread]1
removeAll(right)[array, hash]2
addAll(right)
실제 정렬 출력
[array, database, hash, http, thread]
결과 크기
5
retainAll(right)
실제 정렬 출력
[thread]
결과 크기
1
removeAll(right)
실제 정렬 출력
[array, hash]
결과 크기
2

입력은 java={array, hash, thread}, spring={http, thread, database}입니다. 각 함수가 새 HashSet<>(left)를 바꾸고 Set.copyOf(result)를 반환하므로 inputs=3/3을 유지합니다. 표의 순서는 출력용 sorted()가 만듭니다.

세 메서드는 입력을 변경하지 않고 원소의 추가·삭제가 불가능한 결과를 반환합니다. 원소 객체를 깊게 복사하는 것은 아닙니다.

이 도우미는 null이 아닌 원소를 다룬다는 전제로 사용합니다. HashSet 자체는 null을 담을 수 있어도 마지막 Set.copyOf는 결과에 남은 null을 거부합니다. 따라서 null도 유효한 원소인 별도 도메인에 그대로 옮기려면 입력·반환 정책부터 정해야 합니다.

합집합 크기는 두 입력 크기의 합보다 클 수 없고, 교집합은 더 작은 입력보다 클 수 없으며, 차집합과 교집합은 left를 겹치지 않게 나눕니다.

출력 정렬은 테스트 가능한 표현을 위한 것입니다.


중복 제거 시 순서 보존 기준

new HashSet<>(list)는 중복을 제거하지만 첫 등장 순서를 약속하지 않습니다.

입력 순서를 보존한 결과가 필요하면 LinkedHashSet을 거쳐 새 List로 만듭니다.

정렬 결과가 목표면 TreeSet 또는 distinct 후 sorted를 사용합니다.

HashSet과 LinkedHashSet은 equals와 hashCode를 사용하고, TreeSet은 정렬 비교 결과가 0인 원소를 같은 원소로 취급합니다.

제목의 대소문자와 공백을 무시하려면 Set에 넣기 전에 정규화합니다.

원본 문자열을 보존해야 한다면 정규화 키와 표시 값을 Map으로 나누는 편이 낫습니다.

src/OrderedDuplicateRemoval.java
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;

public final class OrderedDuplicateRemoval {
    public static void main(String[] args) {
        List<String> input = List.of("array", "hash", "array", "set", "hash");
        List<String> unique = new ArrayList<>(new LinkedHashSet<>(input));
        System.out.println("input=" + input);
        System.out.println("unique=" + unique);
        System.out.println("first-array=" + unique.indexOf("array"));
    }
}

unique는 배열, 해시, set 순서입니다.

Set으로 바꾼 순간 index 기반 목록 규칙은 사라지고, 다시 List로 만들 때 LinkedHashSet의 반복 순서가 사용됩니다.


게시글 저장소의 공개 대기 제목

제출 목록과 공개 목록의 차집합이 공개 대기 제목입니다.

공개 목록에 제출 이력에 없는 제목이 있을 수 있으므로 단순히 크기만 빼면 안 됩니다.

같은 크기의 두 집합 비교

제출이 {array, hash}, 공개가 {hash, orphan}이라면 두 크기는 모두 2입니다. 이 경우 유효 공개·대기·미등록 제목을 각각 구하고 단순 크기 차이가 대기 수를 알려 주는지 판단하세요.

원소의 소속으로 검산

유효 공개는 교집합 {hash}, 대기는 제출에서 공개를 뺀 {array}, 미등록은 공개에서 제출을 뺀 {orphan}입니다. 크기 차이 2−2=0과 달리 대기는 1개입니다. 제출은 유효 공개 1개와 대기 1개로 겹치지 않게 나뉘며, orphan은 그 분할에 포함하지 않습니다. 원소 수뿐 아니라 어느 원소가 포함되는지 확인해야 합니다.

교집합으로 유효한 공개 제목을 구하고 제출 목록의 차집합으로 대기 제목을 만듭니다.

app/BoardPublicationSetCli.java
import java.util.LinkedHashSet;
import java.util.Set;

public final class BoardPublicationSetCli {
    public static void main(String[] args) {
        Set<String> submitted =
                new LinkedHashSet<>(java.util.List.of("array", "hash", "set", "map"));
        Set<String> published = Set.of("array", "hash", "orphan");
        Set<String> validPublished = copy(submitted);
        validPublished.retainAll(published);
        Set<String> pending = copy(submitted);
        pending.removeAll(published);
        Set<String> unknown = copy(published);
        unknown.removeAll(submitted);
        System.out.println("published=" + validPublished);
        System.out.println("pending=" + pending);
        System.out.println("unknown=" + unknown);
        System.out.println("published-count=" + validPublished.size() + "/" + submitted.size());
    }

    private static <E> Set<E> copy(Set<E> source) {
        return new LinkedHashSet<>(source);
    }
}
공개·대기·미등록 제목을 분리한 세 복사본

제출한 네 제목 중 공개된 두 개와 대기 두 개를 나누고 제출 이력이 없는 orphan은 별도 결과로 분리합니다.

원본 보존 · 공개·대기·미등록 분리

공개·대기·미등록 제목을 분리한 세 복사본

제출 목록과 공개 목록을 각각 복사해 교집합과 차집합을 구합니다. 등록되지 않은 orphan을 공개 개수에서 따로 분리합니다.

공개·대기·미등록의 원소 소속 영역 제출원의4원소array/hash/set/map과공개원의3원소array/hash/orphan에서공통array/hash는유효공개,제출만set/map은대기,공개만orphan은미등록입니다. 원넓이는정확한수량비율측정이아니며원소와수량라벨을읽습니다. 원본집합은보존됩니다. submitted published set map array hash orphan 공통: 유효 공개 array, hash 제출만: 대기 set, map 공개만: 미등록 orphan 원소 수: 제출4 / 공개3 원의 면적은 수량 측정 아님
공개·대기·미등록의 원소 소속 영역 제출원의4원소array/hash/set/map과공개원의3원소array/hash/orphan에서공통array/hash는유효공개,제출만set/map은대기,공개만orphan은미등록입니다. 원넓이는정확한수량비율측정이아니며원소와수량라벨을읽습니다. 원본집합은보존됩니다. submitted published set map array hash orphan 공통: 유효 공개 array, hash 제출만: 대기 set, map 공개만: 미등록 orphan 원소 수: 제출4 / 공개3 원의 면적은 수량 측정 아님

validPublished

복사본과 실제 연산
submitted의 LinkedHashSet 복사본
retainAll(published)
실제 출력
published=[array, hash]

pending

복사본과 실제 연산
submitted의 LinkedHashSet 복사본
removeAll(published)
실제 출력
pending=[set, map]

unknown

복사본과 실제 연산
published의 LinkedHashSet 복사본
removeAll(submitted)
실제 출력
unknown=[orphan]

제출 순서는 [array, hash, set, map]이고 공개 원소는 {array, hash, orphan}입니다. 원본은 보존되며 published-count=2/4입니다. 앞의 두 결과는 제출 순서를 따르고 unknown은 공개 집합의 복사본에서 나온 한 원소입니다.

submitted의 LinkedHashSet 복사본으로 만든 validPublished와 pending은 게시글 제출 순서를 유지합니다.

orphan은 유효한 공개 수에 포함되지 않고 unknown 진단으로 분리됩니다.

원본 제출 목록과 공개 목록은 어느 연산에서도 변경되지 않습니다.


구현 선택의 최종 점검

검색과 중복 방지가 중심이고 순서가 없다면 HashSet, 최초 등장 순서를 결과 형식으로 보존하면 LinkedHashSet, 정렬·범위 질의가 지속적으로 필요하면 TreeSet을 선택합니다.

집합 연산은 대부분 수신 객체를 바꾸므로 소유권이 불명확한 입력에는 복사본을 사용합니다.

해시 기반 집합에서 equals와 hashCode 규칙이 잘못되면 합집합에서 중복이 남고 교집합에서 같은 값을 놓칠 수 있습니다.

따라서 집합 연산 결과 오류는 연산 코드뿐 아니라 원소의 동등성 구현까지 거슬러 확인합니다.


연습 문제

세 게시판의 태그 Set 모두에 포함된 공통 태그와 적어도 한 게시판에 포함된 전체 태그를 구하세요.

원본 세 Set은 연산 뒤에도 같은 크기여야 합니다.

이 연습에서는 a={java, hash, set}, b={java, set, map}, c={java, map, queue}를 사용합니다. 각 입력 크기는 3입니다. 공통 결과와 전체 결과의 원소를 먼저 적고, 각 원본을 어느 단계에서 복사할지 정한 뒤 코드로 옮기세요.

정답과 해설

교집합은 첫 Set 복사본에 나머지 두 Set을 차례로 retainAll합니다.

합집합은 빈 LinkedHashSet에 a, b, c를 차례로 addAll합니다. 각 Set.of의 반복 순서는 보장되지 않으므로 전체 태그는 출력할 때 정렬합니다.

exercise/ThreeBoardTagSetSolution.java
import java.util.LinkedHashSet;
import java.util.Set;

public final class ThreeBoardTagSetSolution {
    public static void main(String[] args) {
        Set<String> a = Set.of("java", "hash", "set");
        Set<String> b = Set.of("java", "set", "map");
        Set<String> c = Set.of("java", "map", "queue");
        Set<String> common = new LinkedHashSet<>(a);
        common.retainAll(b);
        common.retainAll(c);
        Set<String> all = new LinkedHashSet<>();
        all.addAll(a);
        all.addAll(b);
        all.addAll(c);
        System.out.println("common=" + common);
        System.out.println("all=" + all.stream().sorted().toList());
        System.out.println("unchanged=" + a.size() + "/" + b.size() + "/" + c.size());
    }
}

공통은 java 하나이고 전체는 hash, java, map, queue, set입니다.

입력 크기는 모두 3으로 유지됩니다.

복사 위치가 소유권 범위를 결정한다는 점까지 답에 포함합니다.

15장의 종합 기준은 해시 코드와 그룹 index를 구분하고, 충돌 후보를 equals로 판단하며, 순서·정렬·가변성 요구에 맞는 Set 구현을 고르는 것입니다.

게시글 저장소의 공개 대기 계산이 원본 이력을 보존하면 이 세 축이 함께 작동합니다.