Skip to main content
Summary

제네릭이 필요한 이유

6장에서 List<T>와 Dictionary<TKey, TValue>를 처음 보았습니다. 여기서 꺾쇠괄호 안의 T, TKey, TValue가 바로 제네릭입니다.

제네릭은 "자료형을 나중에 정할 수 있는 코드"를 만드는 문법입니다. 예를 들어 List<int>는 정수 목록이고, List<string>은 문자열 목록입니다. 둘 다 같은 List 구조를 쓰지만, 담을 값의 타입은 다릅니다.

제네릭이 없던 방식은 보통 object를 사용했습니다. object는 C#의 거의 모든 값을 담을 수 있는 최상위 타입입니다. 하지만 너무 자유롭기 때문에 실수가 늦게 발견됩니다.

using System.Collections;

ArrayList values = new ArrayList();
values.Add(10);
values.Add("20");

int sum = 0;

foreach (object value in values)
{
    sum += (int)value;
}

Console.WriteLine(sum);

실행 결과는 정상 출력이 아니라 오류입니다.

Unhandled exception. System.InvalidCastException: ...

"20"은 문자열입니다. 코드는 모든 값을 int로 바꾸려고 하므로 실행 중에 실패합니다. 컴파일은 되지만 실행해야만 문제를 알 수 있습니다.

제네릭 컬렉션을 사용하면 이런 실수를 컴파일 단계에서 막을 수 있습니다.

List<int> values = new List<int>();
values.Add(10);
// values.Add("20"); // 컴파일 오류

int sum = 0;

foreach (int value in values)
{
    sum += value;
}

Console.WriteLine(sum);

실행 결과:

10

List<int>에는 int만 넣을 수 있습니다. 그래서 값을 꺼낼 때도 강제 형 변환이 필요 없습니다. 이것이 제네릭의 가장 큰 장점입니다.

  • 잘못된 타입을 컴파일 단계에서 발견한다.

  • 값을 꺼낼 때 형 변환 코드가 줄어든다.

  • 같은 구조를 여러 타입에 재사용할 수 있다.

.NET의 대표적인 제네릭 컬렉션은 System.Collections.Generic 네임스페이스에 들어 있으며, List<T>, Dictionary<TKey, TValue>, Queue<T>, Stack<T> 등이 여기에 포함됩니다.1

제네릭과 컬렉션을 더 깊이 사용하기AI 생성 이미지

T는 정해지지 않은 타입의 자리표시자다

T는 특별한 타입이 아닙니다. 관례적으로 사용하는 이름입니다. Type의 첫 글자라고 생각하면 쉽습니다.

List<int> numbers = new List<int>();
List<string> names = new List<string>();

여기서 List<T>라는 설계도에 실제 타입을 넣으면 다음처럼 해석할 수 있습니다.

List<T>      → 아직 어떤 타입의 목록인지 모름
List<int>    → int 목록
List<string> → string 목록

타입 매개변수 이름은 꼭 T일 필요는 없습니다. Dictionary<TKey, TValue>는 키 타입과 값 타입이 따로 필요하므로 더 구체적인 이름을 씁니다.

Dictionary<string, int> scores = new Dictionary<string, int>();

scores["민지"] = 90;
scores["준호"] = 85;

Console.WriteLine(scores["민지"]);

실행 결과:

90

이 코드는 "키는 string, 값은 int인 사전"을 뜻합니다. 그래서 scores["민지"]의 결과도 int로 안전하게 다룰 수 있습니다.

제네릭 클래스 직접 만들기

제네릭은 C# 표준 라이브러리만의 기능이 아닙니다. 우리가 직접 클래스나 메서드를 만들 때도 사용할 수 있습니다.

먼저 값을 하나 보관하는 간단한 Box<T> 클래스를 만들어 보겠습니다.

class Box<T>
{
    public T Value { get; }

    public Box(T value)
    {
        Value = value;
    }

    public void Print()
    {
        Console.WriteLine(Value);
    }
}

사용할 때 실제 타입을 정합니다.

Box<int> numberBox = new Box<int>(100);
Box<string> messageBox = new Box<string>("안녕하세요");

numberBox.Print();
messageBox.Print();

실행 결과:

100
안녕하세요

Box<T>는 값 하나를 보관한다는 구조만 정의합니다. 어떤 값을 보관할지는 사용하는 쪽에서 결정합니다. 그래서 int 상자도 만들 수 있고, string 상자도 만들 수 있습니다.

만약 제네릭을 쓰지 않았다면 IntBox, StringBox처럼 타입마다 비슷한 클래스를 반복해서 만들어야 했을 것입니다. 제네릭은 이런 중복을 줄입니다.

제네릭 메서드 만들기

메서드에도 제네릭을 사용할 수 있습니다. 다음 메서드는 목록의 첫 번째 값을 반환합니다.

static T First<T>(List<T> items)
{
    if (items.Count == 0)
    {
        throw new InvalidOperationException("목록이 비어 있습니다.");
    }

    return items[0];
}

이제 int 목록에도, string 목록에도 같은 메서드를 사용할 수 있습니다.

List<int> numbers = new List<int> { 10, 20, 30 };
List<string> names = new List<string> { "민지", "준호" };

Console.WriteLine(First(numbers));
Console.WriteLine(First(names));

실행 결과:

10
민지

호출할 때 First<int>(numbers)처럼 타입을 직접 써도 되지만, 대부분은 컴파일러가 인수를 보고 타입을 추론합니다. 그래서 First(numbers)처럼 간단히 쓸 수 있습니다.

여기서 T와 var를 헷갈리지 않아야 합니다.

  • T는 클래스나 메서드를 정의할 때 사용하는 타입 매개변수입니다.

  • var는 지역 변수를 선언할 때 오른쪽 값을 보고 타입을 추론하게 하는 키워드입니다.

var firstNumber = First(numbers); // firstNumber는 int로 추론됨

Stack<T>: 마지막에 넣은 값을 먼저 꺼낸다

Stack<T>는 LIFO 구조입니다. Last In, First Out의 줄임말로, 마지막에 넣은 값이 먼저 나옵니다.

실생활에서는 책 더미를 떠올리면 됩니다. 책을 위에 쌓고, 꺼낼 때도 맨 위 책부터 꺼냅니다.

Stack<string> undo = new Stack<string>();

undo.Push("문장 작성");
undo.Push("굵게 표시");
undo.Push("색상 변경");

Console.WriteLine(undo.Pop());
Console.WriteLine(undo.Peek());
Console.WriteLine(undo.Count);

실행 결과:

색상 변경
굵게 표시
2

주요 메서드는 다음과 같습니다.

  • Push(value): 값을 위에 넣는다.

  • Pop(): 맨 위 값을 꺼내고 제거한다.

  • Peek(): 맨 위 값을 확인만 하고 제거하지 않는다.

Stack<T>는 실행 취소 기능, 괄호 검사, 최근 작업 기록처럼 "가장 최근 값"을 먼저 처리해야 할 때 어울립니다.

Queue<T>: 먼저 넣은 값을 먼저 꺼낸다

Queue<T>는 FIFO 구조입니다. First In, First Out의 줄임말로, 먼저 들어온 값이 먼저 나갑니다.

은행 대기줄이나 프린터 출력 대기열을 떠올리면 됩니다.

Queue<string> waiting = new Queue<string>();

waiting.Enqueue("민지");
waiting.Enqueue("준호");
waiting.Enqueue("서연");

Console.WriteLine(waiting.Dequeue());
Console.WriteLine(waiting.Peek());
Console.WriteLine(waiting.Count);

실행 결과:

민지
준호
2

주요 메서드는 다음과 같습니다.

  • Enqueue(value): 값을 뒤에 넣는다.

  • Dequeue(): 가장 앞의 값을 꺼내고 제거한다.

  • Peek(): 가장 앞의 값을 확인만 하고 제거하지 않는다.

Queue<T>는 요청 처리, 작업 대기열, 메시지 처리처럼 들어온 순서대로 처리해야 하는 상황에 적합합니다.

주의할 점도 있습니다. Stack<T>에서 비어 있을 때 Pop()을 호출하거나, Queue<T>에서 비어 있을 때 Dequeue()를 호출하면 예외가 발생합니다. 꺼내기 전에 Count를 확인하는 습관이 좋습니다.

if (waiting.Count > 0)
{
    string next = waiting.Dequeue();
    Console.WriteLine(next);
}

HashSet<T>: 중복 없는 집합

HashSet<T>는 중복을 허용하지 않는 컬렉션입니다. 값이 이미 들어 있는지 빠르게 확인해야 할 때 유용합니다.

HashSet<string> tags = new HashSet<string>();

Console.WriteLine(tags.Add("csharp"));
Console.WriteLine(tags.Add("dotnet"));
Console.WriteLine(tags.Add("csharp"));

Console.WriteLine(tags.Contains("dotnet"));
Console.WriteLine(tags.Count);

실행 결과:

True
True
False
True
2

Add는 값이 새로 추가되면 true, 이미 있어서 추가되지 않으면 false를 반환합니다. 세 번째 Add("csharp")가 false인 이유는 이미 같은 값이 있기 때문입니다.

HashSet<T>는 다음 상황에 잘 맞습니다.

  • 중복 제거

  • 이미 처리한 값인지 확인

  • 특정 값이 포함되어 있는지 자주 검사

단, HashSet<T>는 순서를 표현하기 위한 컬렉션이 아닙니다. 출력했을 때 넣은 순서대로 나온다고 기대하지 않는 편이 좋습니다. 순서가 중요하면 List<T>를 선택해야 합니다.

컬렉션을 선택하는 기준

컬렉션은 "여러 값을 담는다"는 점에서는 비슷하지만 목적이 다릅니다. 처음부터 완벽히 외울 필요는 없습니다. 다음 질문을 기준으로 고르면 됩니다.

상황적합한 컬렉션
순서가 중요하고 인덱스로 접근하고 싶다List<T>
키로 값을 빠르게 찾고 싶다Dictionary<TKey, TValue>
중복을 허용하지 않고 포함 여부를 자주 확인한다HashSet<T>
먼저 들어온 순서대로 처리한다Queue<T>
가장 최근에 넣은 값부터 처리한다Stack<T>

예를 들어 학생 이름을 입력 순서대로 출력해야 한다면 List<string>이 자연스럽습니다.

List<string> students = new List<string> { "민지", "준호", "서연" };

학생 이름으로 점수를 찾아야 한다면 Dictionary<string, int>가 더 적합합니다.

Dictionary<string, int> scores = new Dictionary<string, int>
{
    ["민지"] = 90,
    ["준호"] = 85
};

if (scores.TryGetValue("민지", out int score))
{
    Console.WriteLine(score);
}

실행 결과:

90

dictionary[key] 방식은 키가 없으면 예외가 발생합니다. 키가 있을 수도 있고 없을 수도 있다면 TryGetValue를 사용하는 편이 안전합니다.

nullable 참조 형식과 컬렉션의 null

최신 C# 프로젝트에서는 nullable 참조 형식을 켜고 사용하는 경우가 많습니다. 프로젝트 파일에 <Nullable>enable</Nullable> 설정이 있으면 컴파일러가 null 가능성을 더 적극적으로 검사합니다.2

컬렉션에서 null을 다룰 때는 두 가지를 구분해야 합니다.

List<string>? names1;
List<string?> names2 = new List<string?>();

두 선언은 의미가 다릅니다.

  • List<string>? names1: 목록 변수 자체가 null일 수 있다.

  • List<string?> names2: 목록은 null이 아니지만, 안에 들어 있는 요소가 null일 수 있다.

다음 코드는 요소가 null일 수 있음을 명확히 표현합니다.

List<string?> nicknames = new List<string?>
{
    "Mina",
    null,
    "Joon"
};

foreach (string? nickname in nicknames)
{
    if (nickname is null)
    {
        Console.WriteLine("(별명 없음)");
        continue;
    }

    Console.WriteLine(nickname.ToUpper());
}

실행 결과:

MINA
(별명 없음)
JOON

nickname is null로 먼저 검사했기 때문에 그 아래에서는 안전하게 ToUpper()를 호출할 수 있습니다.

반대로 List<string>은 "null이 아닌 문자열만 담겠다"는 의도를 나타냅니다.

List<string> names = new List<string>();
names.Add("민지");
// names.Add(null); // nullable 검사에서는 경고가 발생할 수 있음

중요한 점이 있습니다. nullable 참조 형식은 런타임에서 완전히 다른 타입을 만드는 기능이 아닙니다. 컴파일러가 null 가능성을 분석해 경고를 주는 기능에 가깝습니다. 따라서 경고를 무시하면 여전히 실행 중 NullReferenceException이 발생할 수 있습니다.

컬렉션을 설계할 때는 의도를 분명히 표현하세요.

  • 컬렉션 자체가 없을 수 있는가? 그러면 List<T>?

  • 컬렉션은 있지만 요소가 없을 수 있는가? 그러면 빈 리스트 new List<T>()

  • 요소 자체가 null일 수 있는가? 그러면 List<T?>

대부분의 경우에는 컬렉션 변수는 null로 두지 않고 빈 컬렉션으로 초기화하는 편이 코드가 단순합니다.

List<string> names = new List<string>();

foreach (string name in names)
{
    Console.WriteLine(name);
}

값이 없으면 반복문이 한 번도 실행되지 않을 뿐입니다. null 검사도 필요 없습니다.

흔한 실수

첫째, List<T>를 모든 상황에 사용합니다. 순서가 필요하면 List<T>가 좋지만, 포함 여부를 자주 검사한다면 HashSet<T>가 더 알맞을 수 있습니다. 키로 값을 찾는다면 Dictionary<TKey, TValue>를 먼저 생각하세요.

둘째, 비어 있는 Stack<T>나 Queue<T>에서 값을 꺼냅니다. Pop, Dequeue, Peek를 호출하기 전에 Count를 확인하세요.

셋째, 딕셔너리에서 없는 키를 바로 읽습니다.

// 키가 없으면 예외 발생 가능
// int score = scores["서연"];

확실하지 않다면 TryGetValue를 사용합니다.

넷째, nullable 경고를 무시합니다. string? 값을 검사하지 않고 바로 메서드를 호출하면 null일 때 실패할 수 있습니다. 경고는 귀찮은 표시가 아니라 가능한 오류를 미리 알려 주는 신호입니다.

다섯째, T를 실제 타입처럼 생각합니다. T는 아직 정해지지 않은 타입의 이름입니다. Box<int>처럼 사용할 때 실제 타입이 들어갑니다.

작은 연습 문제

  1. Box<T>를 수정해 Update(T newValue) 메서드로 값을 바꿀 수 있게 만들어 보세요.

  2. First<T>와 비슷하게 목록의 마지막 값을 반환하는 Last<T> 메서드를 작성해 보세요. 목록이 비어 있으면 예외를 던지세요.

  3. 문자열을 입력 순서대로 처리하는 Queue<string> 예제를 만들어 보세요. 세 개의 작업을 넣고 하나씩 꺼내 출력해 보세요.

  4. HashSet<string>을 사용해 중복된 이메일 주소를 제거하는 코드를 작성해 보세요.

  5. List<string?>에 null과 문자열을 섞어 넣고, null이면 "이름 없음", 문자열이면 대문자로 출력해 보세요.

이번 장의 핵심은 제네릭이 타입 안정성과 재사용성을 동시에 높인다는 점입니다. List<T>, Dictionary<TKey, TValue> 같은 컬렉션은 제네릭을 이해하면 훨씬 자연스럽게 읽힙니다. 다음 장에서는 메서드를 값처럼 전달하는 델리게이트, 이벤트, 람다식을 배우며 C# 코드의 표현력을 한 단계 더 넓혀 보겠습니다.

참고

1Generic Collections in .NET - Microsoft Learn

2Nullable reference types - Microsoft Learn

책 목록

C# 학습: 기초 문법부터 작은 프로젝트까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기