ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 제네릭 - 박싱/언박싱 - 스택/힙
    카테고리 없음 2024. 11. 22. 13:33

    제네릭이란?

    C#의 제네릭(Generic)은 데이터 타입을 미리 지정하지 않고

    코드 작성 시 타입을 파라미터화하여 다양한 데이터 타입을 처리할 수 있게 하는 기능이다.

     

    타입 매개변수 (T)를 기반으로 지정타입과 호환되는 타입만 처리하도록 하고,

    그렇지 않으면 컴파일 에러를 발생시킨다.

    (사실 T(Type)는 관례적으로 사용하는 거고, 아무렇게나 이름 붙일 수 있다)

     

     

    제네릭의 사용 예시

    제네릭 클래스

    public class GenericClass<T>
    {
        private T data;
    
        public void SetData(T value)
        {
            data = value;
        }
    
        public T GetData()
        {
            return data;
        }
    }
    
    // 사용
    var intInstance = new GenericClass<int>();
    intInstance.SetData(42);
    Console.WriteLine(intInstance.GetData());  // 출력: 42
    
    var stringInstance = new GenericClass<string>();
    stringInstance.SetData("Hello");
    Console.WriteLine(stringInstance.GetData());  // 출력: Hello

     

    제네릭 메서드

    public class Utility
    {
        public static void PrintType<T>(T input)
        {
            Console.WriteLine($"입력 값: {input}, 타입: {typeof(T)}");
        }
    }
    
    // 사용
    Utility.PrintType(42);  // 출력: 입력 값: 42, 타입: System.Int32
    Utility.PrintType("Hello");  // 출력: 입력 값: Hello, 타입: System.String

     

    제네릭 인터페이스

    public interface IRepository<T>
    {
        void Add(T item);
        T Get(int id);
    }
    
    public class Repository<T> : IRepository<T>
    {
        private List<T> items = new List<T>();
    
        public void Add(T item)
        {
            items.Add(item);
        }
    
        public T Get(int id)
        {
            return items[id];
        }
    }
    
    // 사용
    var stringRepo = new Repository<string>();
    stringRepo.Add("Test");
    Console.WriteLine(stringRepo.Get(0));  // 출력: Test

     

    이외에도 구조체, 델리게이트, 이벤트 등 다양한 곳에서 사용가능하다.


    사실 우리는 제네릭을 알게모르게 사용해봤다.
    List<int> iList = new List<int>();
    List<string> sList = new List<string>();
    Dictionary<string, float> dic = new Dictionary<string, float>();

    이런게 다 System.Collections.Generic 네임스페이스가 가진 제네릭 기반 컬렉션 클래스이다.

     

     

    그래서 제네릭 왜 씀?

    제네릭이 등장하기 이전에..

    C#의 제네릭은 C# 2.0과 함께 등장했는데, 제네릭 등장 이전에는 비제네릭 컬렉션인 ArrayList와 Hashtable을 사용했다. 이들은 데이터를 내부적으로 object 타입으로 저장한다.

    object는 C#에서 모든 데이터 타입의 부모 타입으로, 데이터가 값 형식이든 참조 형식이든 타입에 상관없이 데이터를 저장하고 처리할 수 있다.

    .NET 타입 계층구조를 보면 object가 모든 데이터 타입의 부모 클래스라는 의미를 명확히 알 수 있다.

    System.Object
       ├── System.ValueType (값 형식의 부모)
       │      ├── int
       │      ├── float
       │      ├── bool
       │      ├── char
       │      ├── double
       │      ├── decimal
       │      ├── struct (사용자 정의 값 형식)
       │      │      ├── DateTime
       │      │      ├── Guid
       │      ├── enum
       │             ├── ConsoleColor
       │             ├── DayOfWeek
       │             ├── 사용자 정의 열거형
       │
       ├── 참조 형식
              ├── string
              ├── Array
              ├── 사용자 정의 클래스
              ├── System.Delegate (델리게이트의 부모)
              ├── System.Exception (예외 클래스의 부모)


    값 형식(Value Type)은 내부적으로 System.ValueType에서 파생되고, System.ValueType은 object를 상속받는다. 참조 형식은 직접적으로 object를 상속받는다. (object도 참조 형식이다)

    다시 돌아와서, 비제네릭 컬렉션이 모든 데이터를 참조 형식인 object로 데이터를 저장한다는 것은, 여기에 값 형식을 추가하거나 가져올 때 박싱/언박싱이 필요하다는 것을 말한다.

     

     

    박싱/언박싱은 또 뭔데?

    박싱(Boxing)은 값 형식 데이터를 참조 형식으로 변환하는 과정을 말하고,
    언박싱(Unboxing)은 참조 형식 데이터를 값 형식으로 변환하는 과정을 말한다.

     

     

    그냥 값-참조 바꾸면 되는건데 왜 문제가 생김?

    값 형식의 데이터는 스택에 저장되고, 참조 형식의 데이터는 힙에 저장되기 때문에 
    박싱(값->참조)은 힙에 메모리 할당을 새로 해야하고,
    언박싱(참조->값)은 힙에서 스택으로 복사하는 비용이 발생한다.

     

     

    값 형식과 참조 형식을 스택과 힙에 따로 저장하는 이유는 뭐야?

    그걸 알기 위해서는 또 스택 영역과 힙 영역에 대한 특성을 알아야 한다. 

    자세한 정리는 다른 글에서 하고 여기서는 간단하게 특징만 알아보겠다.


    스택의 특징

    - LIFO(Last In First Out) 구조로 데이터를 관리, 데이터를 빠르게 추가 제거할 수 있음.
    - 정적 메모리 영역으로, 메모리 크기가 컴파일 시점에 고정됨.
    - 메모리 할당과 해제가 매우 빠름.

     

    힙의 특징

    - 동적으로 메모리를 관리함. 필요할 때 메모리를 요청(할당)하고 사용이 끝나면 반환(해제)함.
    - 데이터 크기가 가변적일 수 있고, 프로그램 실행 중 메모리를 동적으로 관리 가능.
    - 참조를 통해 접근(데이터를 힙에 저장하고, 데이터에 대한 주소를 스택에 저장해 스택으로 접근)
    - 메모리 할당과 해제에 시간이 오래 걸림.


    참조 형식 데이터(문자열, 배열, 객체)는 크기가 가변적일 수 있다.
    힙은 동적으로 메모리를 관리하므로, 참조 형식 같이 크기가 가변적인 데이터를 저장하는 데 적합하다.
    스택은 빠르다. 크기 변하지 않는 값 형식의 경우 스택에 저장하는 것이 좋다.
    그래서 값 형식의 데이터는 스택에, 참조 형식의 데이터는 힙에 데이터가 저장된다.

     

     

    다시 돌아와서 박싱/언박싱이 일어나면 왜 안좋은지 알아보자

    값 형식과 참조 형식의 저장방식이 다르기 때문에,

    비제네릭 클래스(ArrayList, Hashtable)에 값 형식을 저장/반환 할 때 박싱/언박싱이 발생한다.

     

    이런 박싱/언박싱 과정은 성능의 저하를 일으킨다.

    ArrayList list = new ArrayList();
    
    // 값 형식 추가: 박싱 발생
    int number = 42;  // 값 형식
    list.Add(number); // 박싱 (값 형식 → 참조 형식)
    
    // 값 가져오기: 언박싱 발생
    int retrievedNumber = (int)list[0]; // 언박싱 (참조 형식 → 값 형식)

     

    이렇게 값 형식을 비제네릭 클래스에 저장하면, 내부적으로는 아래와 같은 동작이 발생한다. 

    1. number라는 값 형식 데이터(42)가 힙 메모리에 복사됨.
    2. 힙 메모리에 저장된 값을 참조하는 object 타입의 참조가 생성됨.
    3. list.Add()는 이 참조를 저장함.

     

    반대로 참조 형식을 값 형식으로 가져오려면 아래와 같은 동작이 발생한다.

    1. 참조 형식(object)이 힙 메모리에서 값을 가져옴.
    2. 힙에 있는 데이터를 스택으로 복사.
    3. 명시적으로 캐스팅((int))하여 원래 값 형식으로 변환.

    박싱 과정에서 힙 메모리를 할당해야하고, 언박싱 과정에서 힙의 데이터를 스택에 복사하는 작업이 필요하므로 성능이 저하된다. 반복적으로 이런 작업을 해야한다면 성능 문제가 심각해질 수 있다.

     

    또한, 박싱/언박싱 과정은 컴파일러가 타입을 체크하지 못하기 때문에 타입 안정성이 떨어진다.

    object obj = 42;        // 박싱
    string text = (string)obj;  // InvalidCastException 발생

     

    언박싱 시 잘못된 타입으로 캐스팅하면 컴파일시에는 오류를 찾을 수 없기 때문에 런타임 오류가 발생한다.

     

     

    제네릭은 타입 매개변수(T)를 사용하여 컴파일 시점에 데이터 타입을 고정하여

    이런 박싱/언박싱 과정 없이 데이터를 저장/관리 할 수 있게 해준다.

     

    그래서 성능과 타입 안정성이 높아졌고, C# 2.0에서 제네릭이 도입된 이후 잘 사용하고 있다.

     

     

     

    정리하자면..

    제네릭은 데이터 타입에 의존하지 않고 재사용 가능한 클래스, 메서드, 인터페이스, 델리게이트 등을 작성할 수 있도록 지원하는 기능이다. 제네릭은 타입 매개변수(T)를 사용하여 컴파일 시점에 데이터 타입을 고정하기 때문에 타입 안정성이 올라가고, 값 형식을 저장할 때 박싱/언박싱이 발생하지 않아 성능이 향상된다.

     




    *근데 C#말고 다른 언어에는 제네릭 같은거 없음?
    C는 그런거 없고 C++은 std에서 유사한 기능을 제공함.

Designed by Tistory.