---
title: "T 는 여기에 타입이 들어온다는 빈칸이다"
description: "object 로 다 받으면 타입 검사가 사라지고 박싱이 일어납니다. 제네릭은 타입을 나중에 정하면서 둘 다 막습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-generics/
---
`List<int>` 를 처음 쓸 때 꺾쇠 안이 "담을 타입을 적는 자리" 라고만 하고 넘어갔습니다.
이제 그게 무엇인지 봅니다.

정수만 담는 목록을 직접 만든다고 해 봅시다.

```csharp
class IntBox
{
    private int value;
    public void Set(int v) { value = v; }
    public int Get() { return value; }
}
```

문자열도 담고 싶으면 `StringBox` 를 또 만듭니다. `double` 도, `Dog` 도요.
**하는 일이 완전히 같은데 타입만 다른 클래스**가 계속 늘어납니다.

## object 로 받으면 되지 않나

모든 타입은 `object` 를 물려받는다고 했습니다. 그럼 `object` 로 받으면 다 되겠네요.

```csharp
class Box
{
    private object value;
    public void Set(object v) { value = v; }
    public object Get() { return value; }
}
```

됩니다. 그런데 두 가지가 망가집니다.

**첫째, 타입 검사가 사라집니다.**

```csharp
Box b = new Box();
b.Set(42);

string s = (string)b.Get();      // 컴파일은 됩니다. 실행하면 터집니다
```

정수를 넣고 문자열로 꺼냈습니다. `object` 로 받았으니 컴파일러는 무엇이 들었는지 모릅니다.
꺼낼 때마다 캐스팅해야 하고, 그 캐스팅이 맞는지는 **실행해 봐야** 압니다.

앞에서 컴파일 에러가 실행 중 에러보다 낫다고 여러 번 이야기했습니다.
`object` 로 받는 것은 그 안전장치를 스스로 끄는 일입니다.

**둘째, 값 타입이면 박싱이 일어납니다.**

```csharp
b.Set(42);      // int 를 object 자리에 넣음 → 힙에 상자를 만들어 담는다
```

앞에서 본 박싱입니다. 넣을 때마다 힙에 자리가 생기고 나중에 GC 가 치웁니다.
목록에 만 개를 담으면 만 번 일어납니다.

> **타입을 나중에 정하되, 정해지고 나면 컴파일러가 확인하게 하면 됩니다.**

## 제네릭은 이렇게 생겼다

```csharp
class Box<T>
{
    private T value;
    public void Set(T v) { value = v; }
    public T Get() { return value; }
}
```

`object` 였던 자리가 전부 `T` 가 됐습니다.
`T` 는 타입 이름이 아니라 **"여기에 타입이 들어온다" 는 빈칸**입니다.

쓸 때 빈칸을 채웁니다.

```csharp
Box<int> a = new Box<int>();
a.Set(42);
int n = a.Get();          // 캐스팅이 필요 없습니다

Box<string> b = new Box<string>();
b.Set("안녕");
b.Set(42);                // 컴파일 에러
```

`Box<int>` 를 쓰는 순간 그 안의 `T` 가 전부 `int` 가 됩니다.
`Set` 은 `int` 만 받고 `Get` 은 `int` 를 돌려줍니다.

**틀린 타입을 넣으면 컴파일 에러**입니다. 실행해 볼 필요가 없습니다.
꺼낼 때 캐스팅도 없습니다. 박싱도 없습니다. `object` 로 받았을 때 망가진 두 가지가 다 돌아왔습니다.

<figure class="diagram">
<svg viewBox="0 0 680 200" role="img" aria-label="Box of T 라는 하나의 정의에서 Box of int 와 Box of string 이 만들어지는 그림">
<g fill="var(--bg)" stroke="var(--border)">
<rect x="24" y="66" width="180" height="70" rx="8"></rect>
<rect x="380" y="24" width="200" height="60" rx="8"></rect>
<rect x="380" y="116" width="200" height="60" rx="8"></rect>
</g>
<g font-family="var(--font-mono)" font-size="14">
<text x="42" y="98" fill="var(--accent)">Box&lt;T&gt;</text>
<text x="42" y="120" fill="var(--text-muted)">T value;</text>
<text x="398" y="50" fill="var(--text)">Box&lt;int&gt;</text>
<text x="398" y="70" fill="var(--text-muted)">int value;</text>
<text x="398" y="142" fill="var(--text)">Box&lt;string&gt;</text>
<text x="398" y="162" fill="var(--text-muted)">string value;</text>
</g>
<g stroke="var(--text-muted)" stroke-width="1.4" fill="none">
<path d="M204 90 L370 54"></path>
<path d="M204 112 L370 146"></path>
</g>
<g fill="var(--text-muted)" stroke="none">
<path d="M370 54 l-10 -1 l4 9 z"></path>
<path d="M370 146 l-4 -9 l10 -1 z"></path>
</g>
<text x="24" y="46" font-size="13" fill="var(--text-muted)">정의는 한 벌</text>
<text x="380" y="196" font-size="13" fill="var(--text-muted)">쓸 때 빈칸을 채우면 그 타입 전용이 된다</text>
</svg>
<figcaption>한 번 적고 타입마다 쓴다. 대신 타입 검사는 그대로 남는다.</figcaption>
</figure>

`T` 라는 이름은 관습입니다. `Type` 의 첫 글자입니다.
두 개면 `<TKey, TValue>` 처럼 뜻이 드러나게 적습니다.
`Dictionary<string, int>` 의 꺾쇠 두 칸이 그것이었습니다.

## 메서드에도 붙는다

클래스 전체가 아니라 메서드 하나만 제네릭으로 만들 수도 있습니다.

```csharp
void Swap<T>(ref T a, ref T b)
{
    T temp = a;
    a = b;
    b = temp;
}

int x = 1, y = 2;
Swap(ref x, ref y);
```

`int` 든 `string` 이든 `Dog` 든 같은 코드로 바꿔치기합니다.
넘긴 값을 보고 `T` 가 무엇인지 컴파일러가 알아내므로 `Swap<int>(...)` 라고 안 적어도 됩니다.

## 아무 타입이나 들어오면 할 수 있는 게 없다

`T` 는 아무 타입이나 될 수 있습니다. 그래서 안에서 할 수 있는 일이 별로 없습니다.

```csharp
T Max<T>(T a, T b)
{
    if (a > b) return a;      // 컴파일 에러
    return b;
}
```

`T` 에 `Dog` 가 들어올 수도 있는데 개끼리 크기를 비교할 수는 없습니다.
컴파일러는 `>` 를 쓸 수 있다는 보장이 없으니 막습니다.

이럴 때 **조건을 붙입니다.**

```csharp
T Max<T>(T a, T b) where T : IComparable<T>
{
    if (a.CompareTo(b) > 0) return a;
    return b;
}
```

`where T : IComparable<T>` 는 "`T` 는 비교할 수 있는 것이어야 한다" 는 뜻입니다.
앞에서 인터페이스는 약속 목록이라고 했습니다. 그 약속을 조건으로 건 것입니다.

이제 안에서 `CompareTo` 를 쓸 수 있습니다. 그 약속을 지킨 타입만 들어오니까요.
약속을 안 지킨 타입을 넣으면 컴파일 에러입니다.

자주 쓰는 조건들입니다.

| 조건 | 뜻 |
| --- | --- |
| `where T : class` | 참조 타입만 |
| `where T : struct` | 값 타입만 |
| `where T : new()` | 매개변수 없는 생성자가 있어야 |
| `where T : 인터페이스` | 그 약속을 지킨 것만 |

## 우리가 쓰던 것들이 전부 이것이었다

```csharp
List<int>
Dictionary<string, int>
HashSet<string>
Stack<int>
```

전부 제네릭입니다. `List` 를 만든 사람이 `IntList`, `StringList` 를 따로 만든 것이 아닙니다.
`List<T>` 한 벌을 만들어 뒀고, 우리가 쓸 때 빈칸을 채우는 것입니다.

그래서 `List<int>` 에 문자열을 넣으면 컴파일 에러가 났고, 꺼낼 때 캐스팅이 필요 없었습니다.
그리고 `int` 를 담아도 박싱이 안 일어났습니다.

`TryGetValue` 의 `out` 타입이 딱 맞게 나오던 것도 같은 이유입니다.
`Dictionary<string, int>` 의 `TValue` 가 `int` 로 정해져 있으니까요.

## 정리

- `object` 로 다 받으면 타입 검사가 사라지고 값 타입은 박싱이 일어납니다.
- 제네릭은 타입을 빈칸으로 두고 쓸 때 채웁니다. `T` 가 그 빈칸입니다.
- 채우고 나면 그 타입 전용이 됩니다. 틀린 타입은 컴파일 에러입니다.
- 캐스팅이 필요 없고 박싱도 안 일어납니다.
- 클래스뿐 아니라 메서드 하나만 제네릭으로 만들 수도 있습니다.
- 아무 타입이나 들어오면 안에서 할 수 있는 일이 없습니다. `where` 로 조건을 붙입니다.
- `List`, `Dictionary`, `HashSet` 이 전부 제네릭이었습니다.