Python & AI Tutorials Logo
Python 프로그래밍

17. 세트(Sets): 중복 및 순서 없는 데이터 다루기

이전 장들에서는 리스트(list)(순서가 있고 가변인 컬렉션)와 딕셔너리(dictionary)(키-값 매핑)를 다뤘습니다. 이제는 고유한 항목을 저장하고 수학적 집합 연산을 효율적으로 수행하도록 특별히 설계된 Python의 컬렉션 타입인 세트(set)를 살펴보겠습니다.

세트는 중복을 제거하거나, 멤버십을 빠르게 테스트하거나, 컬렉션 간 공통 원소를 찾는 같은 연산을 수행해야 할 때 특히 강력합니다. 리스트와 달리 세트는 순서가 없고 중복 값을 포함할 수 없으며, 같은 항목을 두 번 추가하려고 해도 아무런 효과가 없습니다.

17.1) 세트 생성과 기본 연산

17.1.1) 중괄호로 세트 만들기

세트를 만드는 가장 일반적인 방법은 쉼표로 구분된 값과 함께 중괄호 {}를 사용하는 것입니다:

python
# 프로그래밍 언어 세트 생성
languages = {"Python", "JavaScript", "Java", "C++"}
print(languages)  # Output: {'Python', 'JavaScript', 'Java', 'C++'}
print(type(languages))  # Output: <class 'set'>

중요: 세트를 출력할 때 요소의 순서는 입력한 순서와 다를 수 있습니다. 세트는 순서 없는 컬렉션이므로 Python은 특정한 순서를 유지하지 않습니다:

python
numbers = {5, 2, 8, 1, 9}
print(numbers)  # Output might be: {1, 2, 5, 8, 9} or another order

출력 순서는 Python 실행 및 버전에 따라 달라질 수 있습니다. 세트가 특정 순서를 유지한다고 절대 기대하지 마세요—순서가 중요하다면 대신 리스트를 사용하세요.

17.1.2) 세트는 자동으로 중복을 제거합니다

세트의 가장 유용한 속성 중 하나는 중복 값을 자동으로 제거한다는 점입니다. 중복 항목으로 세트를 만들려고 하면 각 고유 값의 사본 하나만 유지됩니다:

python
# 중복 값이 있는 세트 생성
student_ids = {101, 102, 103, 102, 101, 104}
print(student_ids)  # Output: {101, 102, 103, 104}
 
# 이 속성 덕분에 세트는 중복 제거에 완벽합니다
grades = [85, 90, 85, 78, 90, 92, 78, 85]
unique_grades = set(grades)
print(unique_grades)  # Output: {78, 85, 90, 92}

이 자동 중복 제거는 세트가 각 요소가 한 번만 나타날 수 있는 수학적 집합 모델을 사용하기 때문에 발생합니다. 이미 존재하는 값을 추가하면 세트는 그 중복을 단순히 무시합니다.

17.1.3) set() 생성자로 세트 만들기

set() 생성자를 사용하면 다른 이터러블(iterable)로부터 세트를 만들 수 있습니다. 이는 리스트, 튜플, 문자열을 세트로 변환할 때 특히 유용합니다:

python
# 리스트로부터 세트 생성
colors_list = ["red", "blue", "green", "red", "yellow"]
colors_set = set(colors_list)
print(colors_set)  # Output: {'red', 'blue', 'green', 'yellow'}
 
# 문자열로부터 세트 생성(각 문자가 요소가 됨)
letters = set("programming")
print(letters)  # Output: {'p', 'r', 'o', 'g', 'a', 'm', 'i', 'n'}
 
# 튜플로부터 세트 생성
coordinates = set((10, 20, 30, 20, 10))
print(coordinates)  # Output: {10, 20, 30}

문자열로 세트를 만들면 고유한 각 문자가 별도의 요소가 됩니다. 이는 텍스트에서 서로 다른 모든 문자를 찾는 데 유용합니다:

python
text = "Mississippi"
unique_chars = set(text.lower())
print(unique_chars)  # Output: {'m', 'i', 's', 'p'}
print(f"The word contains {len(unique_chars)} unique letters")
# Output: The word contains 4 unique letters

17.1.4) 빈 세트 만들기

여기에는 중요한 함정이 있습니다: {}로는 빈 세트를 만들 수 없습니다. Python은 이를 빈 딕셔너리로 해석하기 때문입니다. 대신 set()을 사용해야 합니다:

python
# WRONG - 이것은 세트가 아니라 빈 딕셔너리를 만듭니다
empty_dict = {}
print(type(empty_dict))  # Output: <class 'dict'>
 
# CORRECT - 이것은 빈 세트를 만듭니다
empty_set = set()
print(type(empty_set))  # Output: <class 'set'>
print(empty_set)  # Output: set()

이 구분이 존재하는 이유는 딕셔너리가 세트보다 먼저 Python에 추가되어 {}가 이미 빈 딕셔너리를 위해 예약되어 있었기 때문입니다. 빈 세트를 출력하면 Python은 혼동을 피하기 위해 set()으로 표시합니다.

초보자들이 흔히 헷갈리는 부분: 변수를 사용해 요소가 하나인 세트를 만들면, 세트에는 변수 이름이 아니라 변수의 이 들어갑니다:

python
# 변수로 세트 생성 이해하기
x = 5
my_set = {x}  # {'x'}가 아니라 {5}를 생성합니다
print(my_set)  # Output: {5}
 
# 문자열 'x'를 담는 세트를 원한다면:
my_set = {'x'}
print(my_set)  # Output: {'x'}
 
# 이는 어떤 표현식에도 적용됩니다
result = 10 + 5
my_set = {result}  # {15}를 생성합니다
print(my_set)  # Output: {15}

17.1.5) 세트의 기본 속성과 연산

세트는 데이터 처리에 유용하게 만드는 여러 가지 기본 연산을 지원합니다:

python
# 고유 요소의 개수 확인
website_visitors = {"alice", "bob", "charlie", "alice", "david"}
print(f"Unique visitors: {len(website_visitors)}")
# Output: Unique visitors: 4
 
# 'in'으로 멤버십 확인(세트에서 매우 빠름)
if "alice" in website_visitors:
    print("Alice visited the website")
# Output: Alice visited the website
 
# 비멤버십 확인
if "eve" not in website_visitors:
    print("Eve has not visited yet")
# Output: Eve has not visited yet

in을 사용한 멤버십 테스트는 세트의 핵심 장점 중 하나입니다. 큰 컬렉션에서는 세트에서 항목이 존재하는지 확인하는 것이 리스트에서 확인하는 것보다 훨씬 빠릅니다. 왜 이것이 중요한지는 17.5절에서 살펴보겠습니다.

17.2) 세트에 요소 추가/삭제하기

튜플(tuple)(불변)과 달리 세트는 가변(mutable)이라서 생성 후에도 요소를 추가하고 제거할 수 있습니다. 하지만 요소 자체는 불변 타입이어야 합니다(이 제한은 17.7절에서 살펴보겠습니다).

17.2.1) add()로 단일 요소 추가하기

세트에 개별 요소를 추가하는 것은 add() 메서드로 간단하게 할 수 있습니다. 요소가 이미 존재한다면 세트는 변경되지 않습니다—오류도 발생하지 않고, 중복도 생성되지 않습니다:

python
# 완료된 작업 세트 만들기
completed_tasks = {"task1", "task2"}
print(completed_tasks)  # Output: {'task1', 'task2'}
 
# 새 작업 추가
completed_tasks.add("task3")
print(completed_tasks)  # Output: {'task1', 'task2', 'task3'}
 
# 중복 추가는 아무 효과가 없습니다
completed_tasks.add("task1")
print(completed_tasks)  # Output: {'task1', 'task2', 'task3'}

이 동작 덕분에 세트는 고유 발생을 추적하는 데 이상적입니다. 요소가 이미 존재하는지 확인하지 않고도 안전하게 add()를 호출할 수 있으며, 중복은 세트가 자동으로 처리합니다.

17.2.2) update()로 여러 요소 추가하기

여러 요소를 한 번에 추가하려면 update()를 사용하세요. update()는 어떤 이터러블(리스트, 튜플, 다른 세트 등)이든 받아 그 요소를 모두 세트에 추가합니다:

python
# 작은 스킬 세트로 시작
skills = {"Python", "SQL"}
print(skills)  # Output: {'Python', 'SQL'}
 
# 리스트에서 여러 스킬 추가
new_skills = ["JavaScript", "Docker", "Python"]
skills.update(new_skills)
print(skills)  # Output: {'Python', 'SQL', 'JavaScript', 'Docker'}

원래 세트와 추가되는 리스트 양쪽에 "Python"이 있었지만, 세트에는 여전히 한 번만 들어 있습니다. update() 메서드는 여러 이터러블을 인자로 받을 수도 있습니다:

python
# 여러 출처의 스킬을 결합
current_skills = {"Python"}
course_skills = ["JavaScript", "HTML"]
job_requirements = {"SQL", "Python", "Docker"}
 
current_skills.update(course_skills, job_requirements)
print(current_skills)
# Output: {'Python', 'JavaScript', 'HTML', 'SQL', 'Docker'}

17.2.3) remove()로 요소 삭제하기

요소를 제거할 때는 주의가 필요합니다. remove() 메서드는 세트에서 요소를 삭제하지만, 요소가 존재하지 않으면 KeyError를 발생시킵니다:

python
# 활성 사용자 관리
active_users = {"alice", "bob", "charlie", "david"}
 
# 로그아웃한 사용자 제거
active_users.remove("bob")
print(active_users)  # Output: {'alice', 'charlie', 'david'}
 
# 존재하지 않는 요소를 제거하려 하면 오류가 발생합니다
# active_users.remove("eve")  # Raises: KeyError: 'eve'

remove()는 누락된 요소에 대해 오류를 발생시키므로, 요소가 존재한다고 확신할 때 사용하거나, 존재하지 않을 때의 오류를 잡고 싶을 때 사용하는 것이 좋습니다:

python
# 오류 처리를 통한 안전한 제거(try/except는 28장에서 더 배웁니다)
users = {"alice", "bob", "charlie"}
user_to_remove = "david"
 
if user_to_remove in users:
    users.remove(user_to_remove)
    print(f"Removed {user_to_remove}")
else:
    print(f"{user_to_remove} was not in the set")
# Output: david was not in the set

17.2.4) discard()로 안전하게 요소 삭제하기

오류를 발생시키지 않는 더 안전한 제거를 원한다면 discard()가 관대한 대안을 제공합니다. 요소가 있으면 제거하지만, 없으면 아무것도 하지 않습니다:

python
# 장바구니 관리
cart_items = {"apple", "banana", "orange"}
 
# 안전하게 항목 제거(항목이 없어도 오류 없음)
cart_items.discard("banana")
print(cart_items)  # Output: {'apple', 'orange'}
 
cart_items.discard("grape")  # grape가 세트에 없어도 오류가 없습니다
print(cart_items)  # Output: {'apple', 'orange'}

요소가 원래 있었는지와 관계없이, 세트에 해당 요소가 없도록 보장하고 싶을 때는 discard()를 사용하세요. 요소가 없다는 것이 오류 상황을 의미하며 이를 잡고 싶을 때는 remove()를 사용하세요.

17.2.5) pop()으로 임의의 요소를 제거하고 반환하기

pop() 메서드는 세트에서 임의의 요소를 제거하고 반환합니다. 세트는 순서가 없으므로 어떤 요소가 제거될지 예측할 수 없습니다:

python
# 대기 중인 작업 큐 처리(순서는 중요하지 않음)
pending_tasks = {"email", "report", "meeting", "review"}
 
# 작업 하나 처리(무엇이든 상관없음)
task = pending_tasks.pop()
print(f"Processing: {task}")  # Output: Processing: email (or another task)
print(f"Remaining: {pending_tasks}")
# Output: Remaining: {'report', 'meeting', 'review'} (without the popped task)

빈 세트에서 pop()을 호출하면 KeyError가 발생합니다:

python
empty_set = set()
# empty_set.pop()  # Raises: KeyError: 'pop from an empty set'

pop() 메서드는 세트의 모든 요소를 처리해야 하지만 순서는 상관없을 때 유용합니다:

python
# 세트의 모든 항목 처리하기
items_to_process = {"item1", "item2", "item3"}
 
while items_to_process:
    item = items_to_process.pop()
    print(f"Processing {item}")
    # 항목 처리...
 
print("All items processed")
# Output:
# Processing item1
# Processing item2
# Processing item3
# All items processed

17.2.6) clear()로 모든 요소 제거하기

clear() 메서드는 세트의 모든 요소를 제거하여 빈 상태로 만듭니다:

python
# 세션 데이터 초기화
session_data = {"user_id", "timestamp", "ip_address"}
print(session_data)  # Output: {'user_id', 'timestamp', 'ip_address'}
 
session_data.clear()
print(session_data)  # Output: set()
print(len(session_data))  # Output: 0

같은 세트 객체를 재사용하려는 경우, 새 빈 세트를 만드는 것보다 더 효율적입니다.

세트 수정 메서드

요소 추가

요소 제거

add element: 단일 항목

update iterable: 여러 항목

remove element: 없으면 오류

discard element: 없으면 오류 없음

pop: 임의 요소 제거

clear: 모든 요소 제거

요소가 반드시 존재해야 할 때 사용

존재 여부가 확실하지 않을 때 사용

순서가 중요하지 않을 때 사용

17.3) 세트 연산: 합집합, 교집합, 차집합, 대칭 차집합

세트는 컬렉션을 효율적으로 결합하고, 비교하고, 분석할 수 있는 수학적 집합 연산을 지원합니다. 이러한 연산은 집합론의 기본이며 데이터 처리에서 다양한 실용적 활용이 있습니다.

17.3.1) 합집합(Union): 세트 결합하기

합집합이 왜 중요한지 이해하기 위해 실용적인 시나리오부터 시작해 보겠습니다. 서로 다른 과목에 걸친 학생 등록을 관리한다고 상상해 보세요:

python
# 서로 다른 과목에 등록한 학생들
python_students = {"alice", "bob", "charlie"}
javascript_students = {"bob", "david", "eve"}
 
# 어느 과목이든(또는 둘 다) 듣는 모든 학생 찾기
all_students = python_students | javascript_students
print(all_students)
# Output: {'alice', 'bob', 'charlie', 'david', 'eve'}

두 세트의 합집합(union)은 어느 한쪽 세트(또는 양쪽)에 나타나는 모든 요소를 포함합니다. Python은 합집합을 계산하는 두 가지 방법을 제공합니다: | 연산자(위에서 사용)와 union() 메서드입니다:

python
# union() 메서드를 사용해도 같은 결과
all_students = python_students.union(javascript_students)
print(all_students)
# Output: {'alice', 'bob', 'charlie', 'david', 'eve'}

union() 메서드는 여러 세트를 인자로 받을 수 있어 여러 출처의 데이터를 결합할 때 편리합니다:

python
# 세 과목에 있는 학생들
python_students = {"alice", "bob"}
javascript_students = {"bob", "charlie"}
sql_students = {"charlie", "david"}
 
# 모든 과목의 전체 학생
all_students = python_students.union(javascript_students, sql_students)
print(all_students)
# Output: {'alice', 'bob', 'charlie', 'david'}

합집합의 또 다른 예는 부서별 이메일 리스트를 결합하는 것입니다:

python
# 서로 다른 부서의 이메일 리스트 결합
marketing_contacts = {"alice@company.com", "bob@company.com"}
sales_contacts = {"bob@company.com", "charlie@company.com"}
support_contacts = {"david@company.com", "alice@company.com"}
 
# 부서 전체에서 고유한 연락처
all_contacts = marketing_contacts | sales_contacts | support_contacts
print(f"Total unique contacts: {len(all_contacts)}")
# Output: Total unique contacts: 4

17.3.2) 교집합(Intersection): 공통 요소 찾기

여러 세트에 모두 등장하는 요소를 이해하는 것은 많은 데이터 분석 작업에서 중요합니다. 교집합(intersection) 연산은 “이 세트들이 공통으로 가진 것은 무엇인가?”라는 질문에 답합니다.

python
# 두 제품을 모두 구매한 고객 찾기
customers_product_a = {101, 102, 103, 104, 105}
customers_product_b = {103, 104, 105, 106, 107}
 
# 두 제품을 모두 구매한 고객
both_products = customers_product_a & customers_product_b
print(f"Bought both: {both_products}")
# Output: Bought both: {103, 104, 105}

교집합에는 두 세트에 모두 등장하는 요소만 포함됩니다. 여러 세트를 받을 수 있는 intersection() 메서드를 사용할 수도 있습니다:

python
# 세 과목 모두 수강하는 학생 찾기
python_students = {"alice", "bob", "charlie"}
javascript_students = {"bob", "charlie", "david"}
sql_students = {"charlie", "eve", "bob"}
 
# 세 과목을 모두 듣는 학생
all_three = python_students.intersection(javascript_students, sql_students)
print(all_three)  # Output: {'bob', 'charlie'}

여러 창고에 모두 있는 제품을 찾는 실용적 예는 다음과 같습니다:

python
# 여러 창고에 모두 있는 제품 찾기
warehouse_a = {"laptop", "mouse", "keyboard", "monitor"}
warehouse_b = {"mouse", "keyboard", "printer", "scanner"}
warehouse_c = {"keyboard", "monitor", "mouse", "desk"}
 
# 모든 창고에 있는 제품
available_everywhere = warehouse_a & warehouse_b & warehouse_c
print(f"Available in all locations: {available_everywhere}")
# Output: Available in all locations: {'mouse', 'keyboard'}

17.3.3) 차집합(Difference): 한 세트에만 있고 다른 세트에는 없는 요소 찾기

때로는 한 컬렉션에만 고유한 것이 무엇인지 식별해야 합니다. 차집합(difference) 연산은 첫 번째 세트에는 있지만 두 번째 세트에는 없는 요소를 찾습니다:

python
# 재고 관리: 불일치 찾기
expected_items = {"item001", "item002", "item003", "item004"}
actual_items = {"item001", "item003", "item005"}
 
# 재고에서 누락된 항목
missing = expected_items - actual_items
print(f"Missing items: {missing}")
# Output: Missing items: {'item002', 'item004'}
 
# 재고에 예상치 못하게 있는 항목
unexpected = actual_items - expected_items
print(f"Unexpected items: {unexpected}")
# Output: Unexpected items: {'item005'}

difference() 메서드를 사용할 수도 있습니다:

python
# Python 과목에만 있는 학생(JavaScript에는 없음)
python_students = {"alice", "bob", "charlie"}
javascript_students = {"bob", "david", "eve"}
 
python_only = python_students.difference(javascript_students)
print(python_only)  # Output: {'alice', 'charlie'}

중요: 차집합 연산은 교환법칙이 성립하지 않습니다—순서가 중요합니다:

python
python_students = {"alice", "bob", "charlie"}
javascript_students = {"bob", "david", "eve"}
 
# Python에는 있지만 JavaScript에는 없는 학생
python_only = python_students - javascript_students
print(f"Python only: {python_only}")
# Output: Python only: {'alice', 'charlie'}
 
# JavaScript에는 있지만 Python에는 없는 학생
javascript_only = javascript_students - python_students
print(f"JavaScript only: {javascript_only}")
# Output: JavaScript only: {'david', 'eve'}

17.3.4) 대칭 차집합(Symmetric Difference): 둘 중 하나에만 있고 둘 다에는 없는 요소

대칭 차집합(symmetric difference)은 두 세트 중 하나에는 있지만 둘 다에는 없는 요소를 찾습니다. 이 연산은 두 버전 간 변경 사항을 식별하는 데 특히 유용합니다:

python
# 설정의 두 버전 비교
old_settings = {"debug", "logging", "cache", "compression"}
new_settings = {"logging", "cache", "monitoring", "security"}
 
# 변경된 설정(추가 또는 제거)
changes = old_settings ^ new_settings
print(f"Changed settings: {changes}")
# Output: Changed settings: {'debug', 'compression', 'monitoring', 'security'}
 
# 구체적으로 무엇이 추가/제거되었는지 보려면:
removed = old_settings - new_settings
added = new_settings - old_settings
print(f"Removed: {removed}")  # Output: Removed: {'debug', 'compression'}
print(f"Added: {added}")  # Output: Added: {'monitoring', 'security'}

symmetric_difference() 메서드를 사용할 수도 있습니다:

python
# 정확히 한 과목에만 있는 학생(둘 다는 아님)
python_students = {"alice", "bob", "charlie"}
javascript_students = {"bob", "david", "eve"}
 
one_course_only = python_students.symmetric_difference(javascript_students)
print(one_course_only)
# Output: {'alice', 'charlie', 'david', 'eve'}

차집합과 달리 대칭 차집합은 교환법칙이 성립합니다—순서가 중요하지 않습니다:

python
result1 = python_students ^ javascript_students
result2 = javascript_students ^ python_students
print(result1 == result2)  # Output: True

세트 연산

합집합: A | B

교집합: A & B

차집합: A - B

대칭 차집합: A ^ B

어느 한쪽에라도 있는 모든 요소

양쪽 모두에 있는 요소만

A에는 있고 B에는 없는 요소

둘 중 하나에만 있고 둘 다에는 없는 요소

17.4) 부분집합/상위집합 관계(issubset, issuperset, isdisjoint)

세트를 결합하는 것 외에도, 우리는 종종 세트들 사이의 관계를 이해해야 합니다. Python은 한 세트가 다른 세트에 포함되는지, 다른 세트를 포함하는지, 또는 서로 공통 요소가 없는지 테스트하는 메서드를 제공합니다.

17.4.1) issubset()<=로 부분집합 테스트하기

세트 A의 모든 요소가 세트 B에도 있으면, 세트 A는 세트 B의 부분집합(subset)입니다. 다시 말해, B는 A의 모든 요소(그리고 아마 더 많은 요소)를 포함합니다.

python
# 과목 선수 조건
basic_skills = {"reading", "writing"}
intermediate_skills = {"reading", "writing", "analysis"}
 
# 기본 스킬이 중급 스킬의 부분집합인지 확인
print(basic_skills.issubset(intermediate_skills))  # Output: True
print(basic_skills <= intermediate_skills)  # Output: True (같은 결과)

세트는 언제나 자기 자신에 대한 부분집합입니다:

python
skills = {"Python", "SQL", "JavaScript"}
print(skills.issubset(skills))  # Output: True
print(skills <= skills)  # Output: True

진부분집합(proper subset)(A는 B의 부분집합이지만 B와 같지는 않음)을 테스트하려면 < 연산자를 사용하세요:

python
basic_skills = {"reading", "writing"}
intermediate_skills = {"reading", "writing", "analysis"}
 
# 진부분집합: basic은 intermediate의 부분집합이고, 둘은 같지 않습니다
print(basic_skills < intermediate_skills)  # Output: True
 
# 자기 자신에 대한 진부분집합은 아님(같기 때문)
print(basic_skills < basic_skills)  # Output: False

부분집합 테스트의 실용적 예는 권한이나 요구 사항을 확인하는 것입니다:

python
# 사용자 권한 시스템
required_permissions = {"read", "write"}
user_permissions = {"read", "write", "delete", "admin"}
 
# 사용자가 필요한 모든 권한을 가졌는지 확인
if required_permissions.issubset(user_permissions):
    print("Access granted")
else:
    print("Access denied - missing permissions")
# Output: Access granted
 
# 권한이 부족한 다른 사용자
limited_user = {"read"}
if required_permissions.issubset(limited_user):
    print("Access granted")
else:
    missing = required_permissions - limited_user
    print(f"Access denied - missing: {missing}")
# Output: Access denied - missing: {'write'}

17.4.2) issuperset()>=로 상위집합 테스트하기

세트 A가 세트 B의 모든 요소를 포함하면, 세트 A는 세트 B의 상위집합(superset)입니다. 이는 부분집합의 반대 관계로, A가 B의 부분집합이라면 B는 A의 상위집합입니다.

python
# 스킬 레벨
basic_skills = {"reading", "writing"}
advanced_skills = {"reading", "writing", "analysis", "research"}
 
# 고급 스킬이 기본 스킬의 상위집합인지 확인
print(advanced_skills.issuperset(basic_skills))  # Output: True
print(advanced_skills >= basic_skills)  # Output: True (같은 결과)

부분집합과 마찬가지로, 세트는 언제나 자기 자신에 대한 상위집합입니다:

python
skills = {"Python", "SQL"}
print(skills.issuperset(skills))  # Output: True

진상위집합(proper superset)(A는 B의 상위집합이지만 B와 같지는 않음)에는 > 연산자를 사용하세요:

python
basic_skills = {"reading", "writing"}
advanced_skills = {"reading", "writing", "analysis"}
 
# 진상위집합: advanced는 basic의 모든 것을 포함하고 더 많이 가집니다
print(advanced_skills > basic_skills)  # Output: True
 
# 자기 자신에 대한 진상위집합은 아님
print(advanced_skills > advanced_skills)  # Output: False

17.4.3) isdisjoint()로 서로소 세트 테스트하기

두 세트가 공통 요소를 전혀 가지지 않으면 서로소(disjoint)입니다—교집합이 빈 집합입니다. isdisjoint() 메서드는 두 세트가 공통 요소를 공유하지 않으면 True를 반환합니다:

python
# 스케줄링 충돌 확인
morning_classes = {"math", "english", "history"}
afternoon_classes = {"science", "art", "music"}
 
# 충돌이 있는지 확인(두 세션에 같은 수업이 있는지)
if morning_classes.isdisjoint(afternoon_classes):
    print("No scheduling conflicts")
else:
    conflicts = morning_classes & afternoon_classes
    print(f"Conflicts: {conflicts}")
# Output: No scheduling conflicts

세트가 서로소가 아닐 때는 다음과 같습니다:

python
morning_classes = {"math", "english", "history"}
afternoon_classes = {"science", "math", "music"}
 
if morning_classes.isdisjoint(afternoon_classes):
    print("No scheduling conflicts")
else:
    conflicts = morning_classes & afternoon_classes
    print(f"Conflicts: {conflicts}")
# Output: Conflicts: {'math'}

빈 세트는 모든 세트(다른 빈 세트 포함)와 서로소입니다:

python
empty = set()
numbers = {1, 2, 3}
 
print(empty.isdisjoint(numbers))  # Output: True
print(empty.isdisjoint(empty))  # Output: True

17.5) 리스트 대신 세트를 사용해야 할 때

세트와 리스트 중 언제 무엇을 사용할지 이해하는 것은 효율적인 Python 코드를 작성하는 데 매우 중요합니다. 둘 다 항목의 컬렉션을 저장하지만, 서로 다른 특성을 가지며 각각 다른 작업에 더 적합합니다.

17.5.1) 빠른 멤버십 테스트에 세트를 사용하세요

세트의 가장 큰 장점 중 하나는 멤버십 테스트 속도입니다. 세트에서 항목이 존재하는지 확인하는 것은 리스트에서 확인하는 것보다 훨씬 빠르며, 특히 큰 컬렉션일수록 차이가 큽니다:

python
# 큰 컬렉션에서 사용자가 있는지 확인하기
active_users_list = []
for i in range(10000):
    active_users_list.append("user" + str(i))
 
# 리스트 사용(큰 컬렉션에서는 느림)
print("user5000" in active_users_list)  # 찾을 때까지 각 요소를 확인
 
active_users_set = set()
for i in range(10000):
    active_users_set.add("user" + str(i))
 
# 세트 사용(크기에 상관없이 빠름)
print("user5000" in active_users_set)  # 직접 조회

둘 다 같은 결과를 내지만, 큰 컬렉션에서는 세트 버전이 훨씬 빠릅니다. 이는 세트가 내부적으로 해시 테이블(hash table)을 사용해 크기와 관계없이 거의 즉시 조회할 수 있는 반면, 리스트는 각 요소를 순차적으로 검사해야 하기 때문입니다.

17.5.2) 중복 제거에 세트를 사용하세요

컬렉션에서 중복을 제거해야 한다면, 세트로 변환하는 것이 가장 간단한 방법입니다:

python
# 사용자 입력에서 중복 항목 제거
survey_responses = [
    "yes", "no", "yes", "maybe", "yes", "no", "maybe", "yes"
]
 
# 고유 응답 얻기
unique_responses = set(survey_responses)
print(unique_responses)  # Output: {'yes', 'no', 'maybe'}
 
# 다시 리스트가 필요하다면(중복 제거 후)
unique_list = list(unique_responses)
print(unique_list)  # Output: ['yes', 'no', 'maybe'] (order may vary)

17.5.3) 수학적 집합 연산에 세트를 사용하세요

공통 요소, 차이, 합집합 등을 컬렉션 간에 찾아야 할 때 세트는 명확하고 효율적인 연산을 제공합니다:

python
# 고객 구매 패턴 분석
customers_product_a = {101, 102, 103, 104, 105}
customers_product_b = {103, 104, 105, 106, 107}
 
# 두 제품을 모두 구매한 고객
both_products = customers_product_a & customers_product_b
print(f"Bought both: {both_products}")
# Output: Bought both: {103, 104, 105}
 
# A 제품만 구매한 고객
only_a = customers_product_a - customers_product_b
print(f"Only product A: {only_a}")
# Output: Only product A: {101, 102}
 
# 최소 하나의 제품을 구매한 모든 고객
all_customers = customers_product_a | customers_product_b
print(f"Total customers: {len(all_customers)}")
# Output: Total customers: 7

17.5.4) 순서가 중요할 때는 리스트를 사용하세요

세트는 순서가 없으므로 요소의 순서가 중요하다면 반드시 리스트를 사용해야 합니다:

python
# WRONG - 세트는 순서를 보존하지 않습니다
task_order = {"wake up", "breakfast", "work", "lunch", "work", "dinner"}
print(task_order)  # 순서를 예측할 수 없고 "work"는 한 번만 나타납니다
 
# CORRECT - 순서가 중요하다면 리스트 사용
task_order = ["wake up", "breakfast", "work", "lunch", "work", "dinner"]
print(task_order)
# Output: ['wake up', 'breakfast', 'work', 'lunch', 'work', 'dinner']

17.5.5) 중복이 의미가 있을 때는 리스트를 사용하세요

중복 값이 정보(빈도나 여러 번 발생)를 담고 있다면 리스트를 사용하세요:

python
# 퀴즈 점수 기록(중복은 각 점수를 받은 학생 수를 보여줌)
quiz_scores = [85, 90, 85, 78, 90, 92, 85, 88]
 
# 리스트에서는 발생 횟수를 셀 수 있습니다
score_85_count = quiz_scores.count(85)
print(f"Students who scored 85: {score_85_count}")
# Output: Students who scored 85: 3
 
# 세트를 쓰면 이 정보를 잃게 됩니다
unique_scores = set(quiz_scores)
print(unique_scores)  # Output: {78, 85, 88, 90, 92}
# 각 점수를 받은 학생 수는 알 수 없습니다

17.5.6) 인덱싱이 필요할 때는 리스트를 사용하세요

세트는 순서가 없기 때문에 인덱싱을 지원하지 않습니다. 위치로 요소에 접근해야 한다면 리스트를 사용하세요:

python
# WRONG - 세트는 인덱싱을 지원하지 않습니다
colors = {"red", "blue", "green"}
# first_color = colors[0]  # Raises: TypeError: 'set' object is not subscriptable
 
# CORRECT - 인덱스로 접근하려면 리스트 사용
colors = ["red", "blue", "green"]
first_color = colors[0]
print(first_color)  # Output: red

세트의 장점

빠른 멤버십 테스트

자동 중복 제거

집합 연산

리스트의 장점

순서 보존

중복 허용

인덱싱 지원

17.6) 프로즌세트(Frozenset)와 불변 세트

지금까지는 일반 세트를 다뤘는데, 일반 세트는 가변이라서 생성 후 요소를 추가하거나 제거할 수 있습니다. Python은 또한 세트의 불변 버전인 프로즌세트(frozenset)도 제공합니다. 프로즌세트는 한 번 생성되면 수정할 수 없습니다.

17.6.1) 프로즌세트 만들기

프로즌세트는 frozenset() 생성자를 사용해 만들며, set()으로 일반 세트를 만드는 것과 비슷합니다:

python
# 리스트로부터 프로즌세트 생성
colors = frozenset(["red", "blue", "green"])
print(colors)  # Output: frozenset({'red', 'blue', 'green'})
print(type(colors))  # Output: <class 'frozenset'>
 
# 튜플로부터 프로즌세트 생성
numbers = frozenset((1, 2, 3, 4, 5))
print(numbers)  # Output: frozenset({1, 2, 3, 4, 5})
 
# 빈 프로즌세트 생성
empty = frozenset()
print(empty)  # Output: frozenset()

일반 세트처럼 프로즌세트도 자동으로 중복을 제거합니다:

python
# 중복이 제거됩니다
values = frozenset([1, 2, 2, 3, 3, 3, 4])
print(values)  # Output: frozenset({1, 2, 3, 4})

17.6.2) 프로즌세트는 불변입니다

프로즌세트는 한 번 생성되면 수정할 수 없습니다. add(), remove(), discard(), pop(), clear() 같은 메서드는 프로즌세트에 존재하지 않습니다:

python
# 프로즌세트 생성
languages = frozenset(["Python", "JavaScript", "Java"])
 
# 수정 시도는 오류를 발생시킵니다
# languages.add("C++")  # AttributeError: 'frozenset' object has no attribute 'add'
# languages.remove("Java")  # AttributeError: 'frozenset' object has no attribute 'remove'

이 불변성이 프로즌세트의 결정적 특징입니다. 프로즌세트를 “수정”해야 한다면 새로 만들어야 합니다:

python
# 원본 프로즌세트
original = frozenset([1, 2, 3])
 
# 요소를 추가한 새 프로즌세트 생성
modified = frozenset(list(original) + [4])
print(original)  # Output: frozenset({1, 2, 3})
print(modified)  # Output: frozenset({1, 2, 3, 4})

17.6.3) 프로즌세트에서도 집합 연산이 동작합니다

프로즌세트는 일반 세트와 동일한 모든 집합 연산(합집합, 교집합, 차집합 등)을 지원합니다:

python
# 프로즌세트의 집합 연산
set_a = frozenset([1, 2, 3, 4])
set_b = frozenset([3, 4, 5, 6])
 
# 합집합
print(set_a | set_b)  # Output: frozenset({1, 2, 3, 4, 5, 6})
 
# 교집합
print(set_a & set_b)  # Output: frozenset({3, 4})
 
# 차집합
print(set_a - set_b)  # Output: frozenset({1, 2})
 
# 대칭 차집합
print(set_a ^ set_b)  # Output: frozenset({1, 2, 5, 6})

연산에서 일반 세트와 프로즌세트를 섞어 쓸 수도 있습니다:

python
regular_set = {1, 2, 3}
frozen_set = frozenset([3, 4, 5])
 
# 일반 세트와 프로즌세트 사이의 연산
result = regular_set | frozen_set
print(result)  # Output: {1, 2, 3, 4, 5}
print(type(result))  # Output: <class 'set'> (result는 일반 세트입니다)

17.6.4) 왜 프로즌세트를 사용할까요?

프로즌세트를 사용하는 주된 이유는 일반 세트로는 할 수 없는, 딕셔너리 키나 다른 세트의 요소로 사용할 수 있기 때문입니다:

python
# WRONG - 일반 세트는 딕셔너리 키가 될 수 없습니다
# regular_set = {1, 2, 3}
# my_dict = {regular_set: "value"}  # TypeError: unhashable type: 'set'
 
# CORRECT - 프로즌세트는 딕셔너리 키가 될 수 있습니다
frozen_set = frozenset([1, 2, 3])
my_dict = {frozen_set: "value"}
print(my_dict)  # Output: {frozenset({1, 2, 3}): 'value'}
print(my_dict[frozen_set])  # Output: value

프로즌세트를 딕셔너리 키로 사용하는 실용적 예는 다음과 같습니다:

python
# 좌표 쌍에 대한 정보 저장
# 각 좌표는 (x, y) 값의 프로즌세트입니다
location_data = {
    frozenset([0, 0]): "origin",
    frozenset([1, 0]): "east",
    frozenset([1, 1]): "northeast"
}
 
# 위치 조회
point = frozenset([1, 0])
print(location_data[point])  # Output: east

프로즌세트는 다른 세트의 요소가 될 수도 있습니다:

python
# WRONG - 일반 세트는 세트의 요소가 될 수 없습니다
# set_of_sets = {{1, 2}, {3, 4}}  # TypeError: unhashable type: 'set'
 
# CORRECT - 프로즌세트는 세트의 요소가 될 수 있습니다
set_of_frozensets = {
    frozenset([1, 2]),
    frozenset([3, 4]),
    frozenset([5, 6])
}
print(set_of_frozensets)
# Output: {frozenset({1, 2}), frozenset({3, 4}), frozenset({5, 6})}

그룹을 표현하는 실용적 예는 다음과 같습니다:

python
# 각 팀을 선수 ID의 프로즌세트로 표현
tournament_teams = {
    frozenset([101, 102, 103]),  # Team A
    frozenset([201, 202, 203]),  # Team B
    frozenset([301, 302, 303])   # Team C
}
 
# 특정 팀이 등록되어 있는지 확인
team_to_check = frozenset([101, 102, 103])
if team_to_check in tournament_teams:
    print("Team is registered")
else:
    print("Team not found")
# Output: Team is registered

17.6.5) 세트와 프로즌세트 간 변환하기

일반 세트와 프로즌세트는 쉽게 서로 변환할 수 있습니다:

python
# 일반 세트를 프로즌세트로 변환
regular = {1, 2, 3, 4}
frozen = frozenset(regular)
print(frozen)  # Output: frozenset({1, 2, 3, 4})
 
# 프로즌세트를 일반 세트로 변환
frozen = frozenset([5, 6, 7, 8])
regular = set(frozen)
print(regular)  # Output: {5, 6, 7, 8}
 
# 이제 일반 세트를 수정할 수 있습니다
regular.add(9)
print(regular)  # Output: {5, 6, 7, 8, 9}

세트 타입

일반 세트: 가변

프로즌세트: 불변

요소 추가/제거 가능

딕셔너리 키가 될 수 없음

세트의 요소가 될 수 없음

생성 후 수정 불가

딕셔너리 키 가능

세트의 요소 가능

17.7) 해시 가능(Hashable)과 해시 불가(Unhashable) 타입: 무엇이 딕셔너리 키 또는 세트 요소가 될 수 있는가(그리고 해싱에 대한 짧은 메모)

이 장 전체에서 세트가 어떤 타입의 객체는 담을 수 있지만 어떤 것은 담을 수 없다는 것을 봤습니다. 예를 들어 정수나 문자열로 세트를 만들 수는 있지만, 리스트로 세트를 만들 수는 없습니다. 이러한 제한이 존재하는 이유는 세트의 요소(그리고 16장에서 배운 딕셔너리 키)가 해시 가능(hashable)해야 하기 때문입니다.

17.7.1) "해시 가능"의 의미는 무엇인가요?

해시 가능(hashable) 객체란, 생애 동안 절대 변하지 않는 해시 값을 가진 객체입니다. Python은 hash()라는 내장 함수로 이 해시 값을 계산합니다:

python
# 해시 가능한 타입은 해시 값을 가집니다
print(hash(42))  # Output: 42
print(hash("Python"))  # Output: (some large integer)
print(hash((1, 2, 3)))  # Output: (some large integer)

해시 값은 Python이 세트와 딕셔너리에서 객체를 빠르게 찾기 위해 내부적으로 사용하는 정수입니다. Python이 효율적으로 대상을 찾을 수 있게 도와주는 주소나 인덱스처럼 생각해도 좋습니다.

핵심 속성: 객체가 해시 가능하려면, 해시 값이 생애 동안 일정해야 합니다. 즉 객체 자체가 불변이어야 합니다—객체가 바뀔 수 있다면 해시 값도 바뀌어야 하는데, 그러면 세트와 딕셔너리가 깨지게 됩니다.

17.7.2) 불변 타입은 해시 가능합니다

Python의 모든 불변 내장 타입은 해시 가능하며, 세트 요소 또는 딕셔너리 키로 사용할 수 있습니다:

python
# 정수는 해시 가능합니다
numbers = {1, 2, 3, 4, 5}
print(numbers)  # Output: {1, 2, 3, 4, 5}
 
# 문자열은 해시 가능합니다
words = {"apple", "banana", "cherry"}
print(words)  # Output: {'apple', 'banana', 'cherry'}
 
# 튜플은 해시 가능합니다(해시 가능한 요소만 포함하는 경우)
coordinates = {(0, 0), (1, 1), (2, 2)}
print(coordinates)  # Output: {(0, 0), (1, 1), (2, 2)}
 
# 프로즌세트는 해시 가능합니다
frozen_sets = {frozenset([1, 2]), frozenset([3, 4])}
print(frozen_sets)  # Output: {frozenset({1, 2}), frozenset({3, 4})}
 
# 불리언과 None은 해시 가능합니다
mixed = {True, False, None, 42, "text"}
print(mixed)  # Output: {False, True, None, 42, 'text'}

17.7.3) 가변 타입은 해시 불가입니다

리스트, 일반 세트, 딕셔너리 같은 가변 타입은 내용이 바뀔 수 있기 때문에 해시 가능하지 않습니다:

python
# 리스트는 해시 가능하지 않습니다
# my_set = {[1, 2, 3]}  # TypeError: unhashable type: 'list'
 
# 일반 세트는 해시 가능하지 않습니다
# set_of_sets = {{1, 2}, {3, 4}}  # TypeError: unhashable type: 'set'
 
# 딕셔너리는 해시 가능하지 않습니다
# my_set = {{"key": "value"}}  # TypeError: unhashable type: 'dict'

왜 가변성이 중요할까요? 만약 리스트를 세트에 추가할 수 있다고 가정해 봅시다:

python
# 가상의 시나리오(실제로는 동작하지 않음)
# my_list = [1, 2, 3]
# my_set = {my_list}  # 이게 된다고 가정해 봅시다
# 
# # Python이 [1, 2, 3] 기반으로 해시를 계산합니다
# # 이제 리스트를 수정합니다:
# my_list.append(4)  # 이제 [1, 2, 3, 4]가 됩니다
# 
# # 해시 값이 틀리게 됩니다! 세트가 손상될 것입니다.

이것이 Python이 가변 객체가 세트에 들어가거나 딕셔너리 키로 사용되는 것을 막는 이유입니다—내부 데이터 구조가 깨지기 때문입니다.

초보자들이 흔히 헷갈리는 부분: 세트 자체는 가변이지만(요소를 추가/제거할 수 있음), 요소는 반드시 불변이어야 합니다. 초보자는 이 개념적 구분을 모르고 세트에 넣은 후 객체를 수정하려고 시도하는 경우가 있습니다:

python
# 흔한 혼동: 세트는 가변이지만, 요소는 불변이어야 합니다
# 세트는 가변 - 내용물을 바꿀 수 있습니다
fruits = {'apple', 'banana'}
fruits.add('orange')     # ✓ Works
fruits.remove('apple')   # ✓ Works
 
# 하지만 요소는 불변이어야 합니다 - 변경될 수 없습니다
my_list = [1, 2, 3]
# my_set = {my_list}  # ✗ TypeError: unhashable type: 'list'
# Why? my_list를 추가한 뒤 수정할 수 있다면, 세트의 내부
# 구조가 손상될 것입니다.
 
# 튜플은 불변이라서 동작합니다
my_tuple = (1, 2, 3)
my_set = {my_tuple}  # ✓ Works - 튜플은 수정할 수 없습니다

17.7.4) 튜플의 특수한 경우

튜플은 모든 요소가 해시 가능할 때만 해시 가능합니다. 가변 객체를 포함한 튜플은 해시 가능하지 않습니다:

python
# 불변 요소만 있는 튜플 - 해시 가능
good_tuple = (1, 2, "three")
my_set = {good_tuple} # Works: good_tuple은 해시 가능
print(my_set)  # Output: {(1, 2, 'three')}
 
# 리스트를 포함하는 튜플 - 해시 불가
bad_tuple = (1, 2, [3, 4])
# my_set = {bad_tuple}  # TypeError: unhashable type: 'list'

이는 타당합니다. 튜플 자체는 불변(무엇을 담는지 바꿀 수 없음)이라도, 그 안의 객체 중 하나가 가변이면 튜플의 전체 “값”이 변할 수 있기 때문입니다:

python
# 가변 요소를 가진 튜플을 해시할 수 없는 이유 보여주기
inner_list = [1, 2]
my_tuple = (inner_list, 3)
 
# 튜플 구조는 고정이지만, 내부의 리스트는 바뀔 수 있습니다
inner_list.append(3)  # 이제 inner_list는 [1, 2, 3]
# 튜플은 같은 객체지만 이제 "다른" 데이터를 포함합니다

17.7.5) 해시 가능 여부 테스트하기

객체의 해시 가능 여부는 해시를 계산해 보며 테스트할 수 있습니다:

python
# 해시 가능 여부 테스트
def is_hashable(obj):
    """Check if an object is hashable."""
    try:
        hash(obj)
        return True
    except TypeError:
        return False
 
# 다양한 타입 테스트
print(is_hashable(42))  # Output: True
print(is_hashable("text"))  # Output: True
print(is_hashable((1, 2, 3)))  # Output: True
print(is_hashable([1, 2, 3]))  # Output: False
print(is_hashable({1, 2, 3}))  # Output: False
print(is_hashable({"key": "value"}))  # Output: False

17.7.6) 해시 가능 타입 요약

해시 가능(세트 요소 또는 dict 키 가능):

  • 정수: 42
  • 실수: 3.14
  • 문자열: "text"
  • 튜플(모든 요소가 해시 가능할 때): (1, 2, "three")
  • 프로즌세트: frozenset([1, 2, 3])
  • 불리언: True, False
  • None: None

해시 불가(세트 요소 또는 dict 키 불가):

  • 리스트: [1, 2, 3]
  • 일반 세트: {1, 2, 3}
  • 딕셔너리: {"key": "value"}
  • 해시 불가 요소를 포함한 튜플: (1, [2, 3])

해시 가능성을 이해하면 올바른 자료구조를 선택하고, 세트와 딕셔너리를 다룰 때 흔히 겪는 오류를 피하는 데 도움이 됩니다. 핵심 원칙은 간단합니다: 객체가 바뀔 수 있다면 해시할 수 없고, 해시할 수 없다면 세트에 들어가거나 딕셔너리 키로 사용할 수 없습니다.

© 2025. Primesoft Co., Ltd.
support@primesoft.ai