제네릭 (Generic)
클래스나 메서드를 설계할 때 자료형을 구체적으로 지정하지 않고, 형식 매개변수(Type Parameter)로 열어둔 뒤, 실제 사용할 때 원하는 자료형을 지정할 수 있도록 하는 기능이다.
쉽게 말해 타입을 나중에 정하는 설계 기법 이라고 이해하면 된다.
예를 들어 Cup이라는 클래스와, Cup에 담을 수 있는 것들을 정의한 Milk, Coffee, Water, PaperClip 이라는 클래스를 정의했다고 가정해보자
class Milk
{
public string ExpireDate { get; set; }
}
class Coffee
{
public string Type { get; set; }
}
class Water
{
public string Name { get; set; }
}
class PaperClip
{
public string Material { get; set; }
}
class Cup
{
private Milk _contents;
private float _fillMount;
public Milk GetContents()
{
return _contents;
}
}
class Program
{
static void Main(string[] args)
{
}
}
여기서 우리는 상속과 다형성에 대해서 배웠기 때문에 Liquid라는 부모 클래스를 만들어서 묶으면 편하겠다는 생각을 자연스럽게 할 수 있다.
하지만 잘 보면 PaperClip은 Liquid가 아니다.
그야 녹이면 Liquid가 될 수 있지만 그래서야 더이상 클립이 아니게 되니까...
이렇게 공통적으로 어떠한 클래스에 담고 싶지만 그 공통점을 단순하게 표현하기 애매한 경우가 발생할 수 있다.
이럴 때 제네릭을 사용해서 처리해줄 수 있다.
class Milk
{
public string ExpireDate { get; set; }
}
class Coffee
{
public string Type { get; set; }
}
class Water
{
public string Name { get; set; }
}
class PaperClip
{
public string Material { get; set; }
}
class Cup<T>
{
private T _contents;
private float _fillMount;
public T GetContents()
{
return _contents;
}
}
class Program
{
static void Main(string[] args)
{
Cup<Water> wCup = new Cup<Water>();
var a = wCup.GetContents();
a.Name = "물컵";
Cup<Milk> milkCup = new Cup<Milk>();
var a2 = milkCup.GetContents();
a2.ExpireDate = "2023";
}
}
Cup<T> 는 T라는 제네릭 타입 배개변수를 받아 실제 사용하고자 하는 곳에 타입을 지정해준다.
T인 이유는 관례일 뿐이고 아무거나 넣어도 좋지만 관례는 어지간하면 지키는 게 좋다.
보통 T와 U가 쓰인다고 한다.
제네릭 제약 조건
이런 제네릭의 자유로운 동작에는 단점이라고 할까? 문제점이 하나 발생할 수 있다.
Cup<int> intCup = new Cup<int>(); // 정체가 대체..?
Console.WriteLine(intCup); // 심지어 출력도 됨
바로 T의 자리에 아무거나 들어갈 수 있다는 것이다.
이렇게 int 자료형도 넣을 수 있으면서 심지어 출력도 가능하다.
컵에 숫자를 왜 넣는다는 기괴한 모양이 됐다.
그래서 제네릭에 where을 통해 제약 조건을 걸 수 있다.
class Cup<T> where T : class
{
private T _contents;
private float _fillMount;
public T GetContents()
{
return _contents;
}
}
이렇게 옆에 where을 통해 T 에 class 만 넣겠다고 선언하면 class가 아니라 struct인 값타입은 들어갈 수 없어진다.
반대로 struct를 넣거나 new()으로 제약 조건을 넣는 것도 가능하다.
- struct 제약은 말그대로 구조체, 값 타입만 허용한다는 의미이며
- new() 제약은 기본 생성자가 있는 타입만 허용한다는 의미다.
또한 여러 조건을 동시에 사용할 수도 있다.
class Cup<T, U>
where T : class, new()
where U : struct
{
private T _contents;
private U _fillMount;
}
이렇게 T와 U에 각각 제약 조건을 거는 방식으로 말이다.
제네릭의 공변성 / 반공변성 ??
Milk a = new StMilk(); // 부모 타입 변수에 자식 객체 저장 가능
Cup<Milk> mug = new Cup<StMilk>(); // 불가능
제네릭으로 어디까지 할 수 있나 이것저것 코드를 테스트 해보다가 특이한 것을 발견했다.
Milk의 자식 클래스로 StMilk를 만들어 주고서, 인스턴스를 위 코드처럼 생성할 수 있다.
하지만 제네릭에서는 불가능한 모습을 보인다.
이것에 관련되어 이유를 조사하던 중 공변성 / 반공변성 이라는 키워드를 찾았다.
공변성 / 반공변성은 더 심화된 내용이라 나중에 다루는 게 좋다는 답변을 받았지만 일단 짧게 정리해보자면...
공변성(Covariance) 은 제네릭 타입에서 자식 타입을 부모 타입으로 변환할 수 있는 성질을 말하며, 주로 반환값(출력) 시나리오에서 활용된다.
반공변성(Contravariance) 은 제네릭 타입에서 부모 타입을 자식 타입으로 변환할 수 있는 성질을 말하며, 주로 매개변수(입력) 시나리오에서 활용된다.
다음에 다시 자세히 알아보도록 하자.
object, 박싱 / 언박싱
C#의 모든 타입은 object에서 파생되었고 한다.
그렇다는 말은 반대로 모든 데이터는 object에 담을 수 있다는 말이기도 하다.
이렇게만 보면 object가 최고 아니야? 완전 편한거 아니야? 라고 생각할 수 있지만 꼭 그렇지만은 않다.
object를 갑자기 왜 들먹이냐면 제네릭의 위대함을 설명하기 위해서 들고 온 것이다.
우선 박싱 언박싱에 대해 알아야 한다.
- 박싱 (Boxing) - 값 타입(스택의 값)을 힙의 참조 타입으로 감싸는 것
- 언박싱 (UnBoxing) - 힙에 있느 참조를 다시 값 타입으로 꺼내오는 것.
object obInt = 42; // 박싱
int number = (int)obInt; // 언박싱
문제는 박싱/언박싱이 잦으면 성능 저하 + 런타임 오류 위험이 생긴다.
우선 우리는 무엇이든 담을 수 있는 자료형을 이미 알고있긴 하다.
Car myCar = new Car();
var a = myCar;
Car 클래스인 myCar를 받음으로서 var 자체가 Car 클래스로 변한다.
var는 그래서 선언과 동시에 초기화하여 데이터 타입을 정해줘야만 했다.
int myInt = 42;
Car myCar = new Car();
float myFloat = 21.23f;
string myStr = "KameHameHa!!";
var a = myCar; // var가 클래스 Car 돼있음. var 자체가 변했음.
object obInt = myInt; // object는 저렇게 변하는 var과는 달리 독립적인 하나의 자료형임.
// 힙 공간에 정수형을 담을 수 있는 공간을 만들고,
// myInt속 내용인 42를 복사해서 힙에 담은 후,
// obInt에는 해당 주소값을 담아둠
object obCar = myCar;
// Car의 주소가 현재 myCar에 적혀있는데, 이걸 힙에다 기억시키고 그 위치를
// obCar에는 해당 주소값을 담아둠
object obFloat = myFloat;
// int와 같이 float형도 값형식이기에, 이 코드를 보면 힙공간에 float형을 담을 수 있는 공간이
// 만들어진 후, 21.23f이 그 힙공간에 복사가 됨.
object obStr = myStr;
Console.WriteLine(obStr); // KameHameHa!!
myStr = "KIN";
Console.WriteLine(obStr); // KameHameHa!! - 왜?? 왜 안 바뀌어?
일단 object는 var과는 비슷하지만 많이 다르다.
var은 컴파일러가 우항에 있는 코드를 보고 타입 추론하여 var 대신 그 자리에 자료형을 바꿔주는 거고
object는 그 자체가 자료형인 것에서부터 근본적인 차이가 있다.
// 오브젝트에 담는 순간 이미 박싱이 일어남
object obj = 32;
obj++; // 안됨
int number = (int)obj;
// 오브젝트 형에서 형 변환을 통해 스택에 쓸 수 있는 형태로 두는 것 - 언박싱
number++;
그리고 오브젝트에 담는 시점에서 이미 박싱이 일어났고, 오브젝트는 박싱 된 시점의 힙에있는 데이터의 주소값을 보유하고 있는 것이기 때문에 연산자들을 쓸 수 없다.
굳이 쓰고싶다면 형변환을 해야하는데 안그래도 박싱/언박싱 비용을 소모했는데 형변환 비용까지 소모하면서 연산을 해야하는 낭비가 발생한다.
object obj = "123";
int number = (int)obj;
number++;
// 에러남. 스트링을 int로 바꾸는게 안됨
// 파싱도 안됨
잦은 형변환과 박싱 언박싱은 성능 저하를 유발하며 심지어 코드가 위험해진다.
그렇게 때문에 오브젝트는 최대한 안 쓰는게 좋은데...
어쩔 수 없이 여러 데이터를 담으려면 항상 이런 리스크를 감당해야한다 생각하면면 너무 불편할 것이라 생각하는게 당연하다.
그리고 당연하게도 우리의 조상 프로그래머 분들께서는 이미 다 생각이 있으셨고
그래서 제네릭을 통해 이런 문제를 해결할 수 있는 것이다.
class Cup<T>
{
public T contents;
}
class Cup2
{
public object contents;
}
Cup2 cup2 = new Cup2();
cup2.contents = new Milk();
cup2.contents = new Water(); // 위험 — 타입이 뒤섞임
// cup2.contents++; // 연산 불가
Cup<int> myCup = new Cup<int>();
myCup.contents = 42;
myCup.contents++; // 타입 안정성 보장, 연산 가능
정리
제네릭의 장점은 다음으로 요약할 수 있다.
- 타입 안정성(Type Safety) → 잘못된 타입을 컴파일 시점에서 걸러냄
- 성능 이점 → 박싱/언박싱 없이 값 타입 그대로 사용 가능
- 가독성과 재사용성 → 코드 중복을 줄이고, 다양한 자료형에 대응 가능
제네릭은 타입을 나중에 지정하는 설계방식이며 제약조건을 통해 클래스, 구조체, 기본 생성자, 특정 부모 클래스 등으로 제한 할 수 있다.
object는 모든 걸 담지만 박싱/언박싱으로 성능 저하를 유발하며 타입 안정성이 부족하다.
제네릭은 타입 안정성 + 성능 최적화 + 재사용성을 동시에 해결한 것이다.
'C#' 카테고리의 다른 글
| C# - 인터페이스 문제 풀이 (0) | 2025.09.22 |
|---|---|
| C# - 인터페이스 (Interface) (0) | 2025.09.21 |
| C# - 추상 클래스 실습 문제 풀이 (0) | 2025.09.21 |
| C# - Null 조건부 연산자 ?. (0) | 2025.09.21 |
| C# - 추상 클래스 (Abstract Class) (0) | 2025.09.21 |