제네릭이 필요한 이유
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);실행 결과:
10List<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
2Add는 값이 새로 추가되면 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);
}실행 결과:
90dictionary[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
(별명 없음)
JOONnickname 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>처럼 사용할 때 실제 타입이 들어갑니다.
작은 연습 문제
Box<T>를 수정해Update(T newValue)메서드로 값을 바꿀 수 있게 만들어 보세요.First<T>와 비슷하게 목록의 마지막 값을 반환하는Last<T>메서드를 작성해 보세요. 목록이 비어 있으면 예외를 던지세요.문자열을 입력 순서대로 처리하는
Queue<string>예제를 만들어 보세요. 세 개의 작업을 넣고 하나씩 꺼내 출력해 보세요.HashSet<string>을 사용해 중복된 이메일 주소를 제거하는 코드를 작성해 보세요.List<string?>에 null과 문자열을 섞어 넣고, null이면"이름 없음", 문자열이면 대문자로 출력해 보세요.
이번 장의 핵심은 제네릭이 타입 안정성과 재사용성을 동시에 높인다는 점입니다. List<T>, Dictionary<TKey, TValue> 같은 컬렉션은 제네릭을 이해하면 훨씬 자연스럽게 읽힙니다. 다음 장에서는 메서드를 값처럼 전달하는 델리게이트, 이벤트, 람다식을 배우며 C# 코드의 표현력을 한 단계 더 넓혀 보겠습니다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.