C# - 제네릭 (Generic)

2025. 9. 21. 18:54·C#

제네릭 (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
'C#' 카테고리의 다른 글
  • C# - 인터페이스 문제 풀이
  • C# - 인터페이스 (Interface)
  • C# - 추상 클래스 실습 문제 풀이
  • C# - Null 조건부 연산자 ?.
ellug
ellug
Web / App / Game Dev
  • ellug
    dev.ellug
    ellug
  • 전체
    오늘
    어제
    • 분류 전체보기 (95)
      • Javascript (React, Next) (1)
      • C# (66)
      • Graphics (1)
      • Unity (25)
      • 프로그래머스 (0)
        • Lv.0 (0)
        • Lv.1 (0)
        • Lv.2 (0)
        • Lv.3 (0)
      • AI Agent (0)
      • StableDiffusion (0)
      • ETC (1)
      • 잡담 (0)
      • 유머 (0)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Design Pattern
    class
    게임
    Abstract
    console
    firebase
    struct
    function
    delegate
    클래스
    네트워크
    큐
    useState
    스택
    유니티
    stack
    터미널
    최적화
    추상 클래스
    list
    자료구조
    interface
    Queue
    콘솔
    C#
    unity
    알고리즘
    inputsystem
    Network
    Photon
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
ellug
C# - 제네릭 (Generic)
상단으로

티스토리툴바