1. 소개
상상해봐: 너가 인터넷 쇼핑몰 시스템을 만들고 있는데, 지금까지 팔린 모든 상품의 유일한 코드 목록을 저장해야 해. 아니면, 소셜 네트워크 앱을 만들고 있는데, 이미 있는 사용자 이름인지 빠르게 확인해야 해. 또는, 긴 텍스트에서 유일한 단어가 몇 개인지 세고 싶을 수도 있지?
이런 상황에서는 각 요소가 딱 한 번만 나오는 집합이 필요해. 바로 여기서 HashSet<T>가 등장하지!
HashSet<T>는 순서 없는 유일한 요소들의 집합을 저장하는 컬렉션이야. 여기서 제일 중요한 건 유일함이야. 이미 HashSet에 있는 요소를 또 추가하려고 하면, 그냥 무시하고 중복은 안 넣어줘. 마치 "유일한 사람만 입장 가능"인 클럽 같아: 이미 들어왔으면 두 번은 못 들어와.
HashSet<T>의 주요 특징:
- 유일함: 컬렉션에 각 요소가 딱 한 번만 존재하는 걸 보장해.
- 성능: 요소의 존재 여부 확인, 추가, 삭제가 엄청 빨라. 평균적으로 이런 작업들은 요소 개수와 상관없이 상수 시간(O(1))에 끝나! 이건 해싱이라는 메커니즘 덕분이야.
- 순서 없음: List<T>랑 다르게, HashSet<T>의 요소들은 어떤 순서로 저장되지 않아. 인덱스로(예: "다섯 번째 요소") 접근할 수 없어.
- 해시 테이블 기반: HashSet<T> 내부적으로 해시 테이블을 써서 요소를 저장해. 그래서 성능이 좋은 거지. 해시 테이블이 어떻게 동작하는지는(이건 좀 더 고급 강의에서 다룰 거야) 지금은 깊게 안 들어가고, 그냥 각 요소가 특별한 숫자 코드(해시)로 "변환"돼서, 그걸로 엄청 빠르게 찾는다고 생각하면 돼.
만약 유일한 요소를 List<T>로 저장한다고 생각해봐: 매번 추가하기 전에 리스트 전체를 다 뒤져서 없는지 확인해야 해. 요소가 많아지면 엄청 느려지지. HashSet<T>는 이걸 순식간에 해줘!
2. HashSet<T>가 개발자한테 왜 필요할까?
유일한 컬렉션의 비밀
프로그래밍하다 보면 중복 없이 요소를 저장해야 하는 일이 진짜 많아. 예를 들어, 앱 사용자들의 이메일 주소 리스트를 파싱해서 중복이 없게 하고 싶다거나, 폴더에서 읽어온 파일 이름들을 유일하게 모으고 싶을 때. 제일 간단한 해결책은, 이미 있는 건 두 번 못 넣는 컬렉션을 쓰는 거지.
물론 List<T>로도 할 수는 있어. 추가하기 전에 직접 요소가 있는지 확인하는 거지:
var users = new List<string>();
if (!users.Contains("vasya@example.com"))
users.Add("vasya@example.com");
근데 이 방법은 데이터가 많아지면 별로야 — Contains는 List의 모든 요소를 다 봐야 하거든. 만약 사용자가 수천 명이면, 프로그램이 Windows XP 돌리는 구형 컴퓨터처럼 느려질 거야.
HashSet<T>가 해주는 일
HashSet<T>는 반대로, 각 요소가 딱 한 번만 저장되는 걸 보장해. 해시 테이블(딕셔너리랑 비슷해) 기반이라서, 추가, 검색, 삭제가 엄청 빨라 — 보통 상수 시간에 끝나고, 모든 요소를 다 볼 필요도 없어.
3. HashSet<T> 기본 사용법
선언과 생성
시작하려면 별도의 라이브러리를 추가할 필요 없어 — 이미 System.Collections.Generic 네임스페이스에 있어.
using System.Collections.Generic;
var emails = new HashSet<string>();
생성자에 초기값을 바로 넣어서 만들 수도 있어:
var fruits = new HashSet<string> { "사과", "바나나", "배", "바나나" };
// "바나나"가 두 번 나오지만, 한 번만 저장돼!
요소 추가하기
요소 추가는 Add 메서드로 해. 아직 없는 요소면 true를 리턴하고, 이미 있으면 아무 일도 안 하고 false를 리턴해.
bool added = emails.Add("vasya@example.com"); // true, 요소 추가됨
added = emails.Add("vasya@example.com"); // false, 이미 있어서 추가 안 됨
꿀팁: 똑같은 값으로 Add를 백 번 불러도 HashSet은 아무렇지 않게 중복을 무시해.
존재 여부 확인: Contains
요소가 있는지 확인하려면 Contains 메서드를 써:
if (emails.Contains("vasya@example.com"))
Console.WriteLine("이미 있는 email이야!");
요소 삭제하기
삭제도 빠르게 돼:
emails.Remove("vasya@example.com");
만약 요소가 없으면 — 아무 일도 안 하고 false만 리턴해.
4. 실전 예제
우리가 강의 내내 발전시키는 학생 CRM을 조금 더 복잡하게 만들어보자.
요구사항
우리 시스템에서는 각 사용자가 유일한 사용자 이름(로그인)을 가져야 해. 새 사용자를 추가하기 전에 중복인지 확인하고, 중복이면 알려줘야 해.
코드 예제
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
// 유일한 로그인 저장용 컬렉션
var userNames = new HashSet<string>();
while (true)
{
Console.Write("사용자 이름을 입력하세요 (종료하려면 leave): ");
string name = Console.ReadLine();
if (name == "leave")
break;
if (userNames.Add(name))
{
Console.WriteLine("이름이 성공적으로 추가됐어!");
}
else
{
Console.WriteLine("오류: 이 이름은 이미 사용 중이야, 다른 걸 시도해봐.");
}
}
Console.WriteLine("사용자 목록:");
foreach (var user in userNames)
Console.WriteLine($"- {user}");
// 참고! 출력 순서는 랜덤일 수 있어.
}
}
이렇게 하면 유일함이 자동으로 보장돼. 직접 확인할 필요 없이 HashSet이 다 해줘.
5. HashSet<T> 내부 구조? 해시 코드가 왜 필요할까
비유: 저장 칸
상상해봐, 로그인 카드가 엄청 많고, 0부터 1000까지 번호가 붙은 칸이 있는 책상이 있어. 각 로그인은 (GetHashCode) 함수로 칸 번호를 계산해서 넣어. 만약 카드가 같으면 같은 칸에 들어가고, 이미 있으면 바로 중복임을 알 수 있지.
GetHashCode 함수
HashSet<T>는 요소를 단순히 값으로만 비교하지 않고, 먼저 GetHashCode() 메서드로 해시 코드를 계산해. 대부분의 내장 타입(int, string, double 등)은 이미 최적화돼 있어.
꿀팁: 직접 만든 클래스 타입을 HashSet<T>에 저장하려면, 반드시 올바른 비교와 고유 코드(메서드 Equals와 GetHashCode)를 구현해야 유일함이 제대로 동작해. 이건 다음 강의에서 더 자세히 다룰 거야.
HashSet<T> 쓸 때 흔한 실수들
유일한 값 컬렉션을 처음 쓸 때 자주 하는 실수: HashSet<T>가 요소를 추가한 순서대로 저장한다고 생각하는 거야. 절대 아니야! 해시셋은 순서를 보장하지 않아, 출력 순서가 랜덤일 수 있어. 순서가 중요하면 SortedSet<T> 같은 다른 컬렉션을 써야 해. 이건 또 다른 얘기지.
두 번째로 많은 실수 — 인덱스를 쓰려고 하는 거:
string name = userNames[0]; // 오류! HashSet<T>에는 인덱스가 없어.
배열이나 리스트랑 달리, 여기선 번호로 요소를 가져올 수 없어. foreach로만 순회할 수 있어.
세 번째로 헷갈리는 건, 해시셋을 파일로 저장하거나 직렬화할 때 — 순서가 정해져 있지 않아서, 프로그램을 다시 실행하면 요소 순서가 달라질 수 있다는 거야.
6. 집합 연산: 합집합, 교집합, 차집합
HashSet<T>는 수학에서 집합 다루듯이 쓸 수 있는 여러 메서드를 제공해. 합집합, 교집합, 차집합, 대칭 차집합 같은 거지.
주요 메서드는 이래:
| 메서드 | 설명 |
|---|---|
|
other의 모든 요소를 해시셋에 추가해. |
|
여기랑 other 둘 다에 있는 요소만 남겨. |
|
현재 집합에서 other에 있는 요소를 빼. |
|
여기나 other 중 한쪽에만 있는 요소만 남겨. 둘 다 있으면 제외. |
예제: 교집합과 합집합
예를 들어, 두 그룹의 이름이 있다고 해보자:
var groupA = new HashSet<string> { "안야", "보리스", "베라" };
var groupB = new HashSet<string> { "베라", "글렙", "다샤" };
// 두 그룹 모두에 있는 사람 찾기
var common = new HashSet<string>(groupA); // 복사해서 써야 groupA가 안 바뀜!
common.IntersectWith(groupB);
Console.WriteLine("두 그룹 모두에 있음:");
foreach (var name in common)
Console.WriteLine(name); // "베라" 출력
// 두 그룹 학생 모두 합치기, 아무도 빠지지 않게:
var all = new HashSet<string>(groupA);
all.UnionWith(groupB);
Console.WriteLine("모든 학생:");
foreach (var name in all)
Console.WriteLine(name); // "안야", "보리스", "베라", "글렙", "다샤"
7. 추가 메서드와 속성
Count — 집합에 요소가 몇 개인지 알 수 있어:
Console.WriteLine(userNames.Count);
Clear — 전부 삭제(실생활에서 CTRL+A, DELETE 같은 느낌):
userNames.Clear();
SetEquals, IsSubsetOf, IsSupersetOf — 집합이 같은지, 한쪽이 다른 쪽에 포함되는지 등등을 확인할 수 있어. 수학자 놀이(혹은 그런 프로그램)를 할 때 유용하지.
if (groupA.IsSubsetOf(groupB))
Console.WriteLine("A 그룹의 모든 사람이 B 그룹에 있어");
8. HashSet<T>에 직접 만든 객체 저장하기
아까도 잠깐 말했지만, 기본 타입들은 이미 해시랑 비교가 잘 돼.
근데 만약 사용자를 객체로 저장하고 싶으면, 뭔가 기준(예: 로그인)으로 비교할 수 있게 만들어야 해:
class User
{
public string Login { get; set; }
public override bool Equals(object obj)
{
if (obj is User other)
return Login == other.Login;
return false;
}
public override int GetHashCode()
{
return Login.GetHashCode();
}
}
// 이제 이렇게 쓸 수 있어:
var users = new HashSet<User>();
users.Add(new User { Login = "vasya" });
users.Add(new User { Login = "petya" });
users.Add(new User { Login = "vasya" }); // 추가 안 됨!
Equals와 GetHashCode를 오버라이드하지 않으면, HashSet은 모든 인스턴스를 다 다르다고 생각해(로그인이 같아도), 왜냐면 기본적으로 메모리 주소로 비교하거든.
GO TO FULL VERSION