CodeGym /행동 /C# SELF /집합: HashSet<T>

집합: HashSet<T>

C# SELF
레벨 27 , 레슨 4
사용 가능

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");

근데 이 방법은 데이터가 많아지면 별로야 — ContainsList의 모든 요소를 다 봐야 하거든. 만약 사용자가 수천 명이면, 프로그램이 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>에 저장하려면, 반드시 올바른 비교와 고유 코드(메서드 EqualsGetHashCode)를 구현해야 유일함이 제대로 동작해. 이건 다음 강의에서 더 자세히 다룰 거야.

HashSet<T> 쓸 때 흔한 실수들

유일한 값 컬렉션을 처음 쓸 때 자주 하는 실수: HashSet<T>가 요소를 추가한 순서대로 저장한다고 생각하는 거야. 절대 아니야! 해시셋은 순서를 보장하지 않아, 출력 순서가 랜덤일 수 있어. 순서가 중요하면 SortedSet<T> 같은 다른 컬렉션을 써야 해. 이건 또 다른 얘기지.

두 번째로 많은 실수 — 인덱스를 쓰려고 하는 거:

string name = userNames[0]; // 오류! HashSet<T>에는 인덱스가 없어.

배열이나 리스트랑 달리, 여기선 번호로 요소를 가져올 수 없어. foreach로만 순회할 수 있어.

세 번째로 헷갈리는 건, 해시셋을 파일로 저장하거나 직렬화할 때 — 순서가 정해져 있지 않아서, 프로그램을 다시 실행하면 요소 순서가 달라질 수 있다는 거야.

6. 집합 연산: 합집합, 교집합, 차집합

HashSet<T>는 수학에서 집합 다루듯이 쓸 수 있는 여러 메서드를 제공해. 합집합, 교집합, 차집합, 대칭 차집합 같은 거지.

주요 메서드는 이래:

메서드 설명
UnionWith(other)
other의 모든 요소를 해시셋에 추가해.
IntersectWith(other)
여기랑 other 둘 다에 있는 요소만 남겨.
ExceptWith(other)
현재 집합에서 other에 있는 요소를 빼.
SymmetricExceptWith(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" }); // 추가 안 됨!

EqualsGetHashCode를 오버라이드하지 않으면, HashSet은 모든 인스턴스를 다 다르다고 생각해(로그인이 같아도), 왜냐면 기본적으로 메모리 주소로 비교하거든.

1
설문조사/퀴즈
주요 컬렉션 개요, 레벨 27, 레슨 4
사용 불가능
주요 컬렉션 개요
컬렉션 타입과 generics
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION