본문 바로가기

진리는어디에/C#

[C#] C# 구조체(struct)로 메모리 절약하기

들어가며

C++에 익숙한 사용자라면 class와 struct의 차이라고 해봐야 멤버에 대한 기본 접근한정이 private이냐 public이냐 정도차이 라고 알고 있을 것이다. 하지만 C#에서는 class와 struct의 차이가 매우 크다. 이 포스트에서는 C#에서 struct와 class에 대한 차이를 설명하고 struct를 이용해 많은 메모리를 절약한 시나리오에 대해 알아 보도록 하겠다.

메모리의 타입(유형)

코드에서 객체를 생성할 때 마다 객체는 어느 정도의 메모리를 필요하게 된다. 객체에 대한 메모리는 사용 중이 아닌 영역에 할 당되어야 하며, 이 할당 되어야 하는 메모리는 크게 '스택'과 '힙'이라는 두 가지 종류가 있다. 본론에 들어가기 앞서 이 두가지 유형의 메모리에 대해 간단히 살펴 보도록 하겠다.

스택(Stack)

스택은 매우 단순하고 균일한 방식으로 할당되는 연속적인 메모리 영역이다. 메모리는 스택의 하위 메모리 주소에서 상위 메모리 주소로 할당 된다. 가장 최근에 할당된 메모리만 해제 할 수 있으며, 당연히 스택 맨 아래에 할당 된(먼저 할당 된) 메모리를 해제하려면 위에 할당된 메모리를 모두 해제해야만 한다.

스택에 할당 되지 않은 메모리는 단순한 메모리 포인터로 추적된다. 스택 영역에 메모리가 할당 되면 포인터가 적절한 양만큼 위로 이동한다. 반대로 메모리가 해제되면 포인터가 다시 아래로 이동한다. 실제 스택 영역에서 메모리가 해제 된다고 하더라도 메모리에 저장되어 있는 값을 지우는 것이 아닌 단순히 포인터만 이동하고, 해당 메모리는 필요 할 때 덮어 쓰여지게 된다. 이런 단순한 메커니즘으로 스택 영역에서의 메모리 할당과 해제는 다음에 설명 될 힙 영역 할당 해제와 비교하여 매우 빠르다.

C++이나 C# 프로그램의 동작 방식을 생각해보면, main함수에서 시작하여 다른 함수들을 호출하는 방식이다. 이 호출 된 함수들은 또 다른 함수들을 호출하고, 전체적으로 이런 방식으로 진행 된다. 궁극적으로 각 함수들이 리턴하게 되면 호출자에게 반환 되며 이러한 구조는 스택 자료 구조에 적합하다.

좀 더 자세한 예를 들자면, 함수 A가 호출 되면 함수 A에 대한 지역 변수들이 스택에 할당 된다. 다시 함수 B가 호출되면 함수 B에 대한 지역 변수들이 스택의 맨위에 할당되게 된다. 함수 B가 종료되고 함수 A로 돌아갈 때 함수 B의 스택에 할당된 지역 변수들은 버려지고, A의 지역 변수들이 스택의 맨 위에 위치하게 된다. 스택 영역을 사용할 때 일반으로 쉽게 발생하는 실수로 이미 해제된 B에 관련된 메모리에 대한 참조를 유지하는 것이 있다. 메모리는 잠시동안(누군가가 덮어 쓰지 전엔) 여전히 유효할 수 있지만 스택 세그먼트가 재할당 되면 정의 되지 않은 결과가 발생 할 수 있다(정상 동작을 할 수도 에러가 발생할 수도 있다는 뜻이다).

이와 같이 스택 할당은 '함수의 로컬 데이터'에 적합한 구조다. 데이터가 단일 함수에서만 사용되는 경우 함수의 로컬 변수로 선언 될 수 있고(이런 변수는 스택 영역에 할당 됨), 스택 영역의 특성에 따라 메모리 단편화와 같은 문제를 발생 시키지 않는다.

힙(Heap)

스택은 해당 메모리를 사용하는 방법과 메모리에 저장된 객체가 유효한 상태로 유지되는 기간에 대한 제약사항을 두기 때문에 매우 간단하고 균일한 방식으로 작동한다고 위에서 언급했다. 하지만 때로 이러한 제약사항을 따를 수 없는 데이터를 저장할 장소가 필요하다. 이 때 사용되는 것이 바로 힙이다.

힙 메모리 영역은 동적 할당을 위한 메모리 공간이다. 스택과 달리 이것은 단일 함수의 수명을 넘어 존재해야 하는 객체를 할당하기 위한 것이다. 힙 메모리는 스택처럼 메모리 포인터를 선형으로 이동하며 할당하는 것이 아닌 필요 공간을 확보하고 임의의 위치에 할당하는 것이므로 지속적인 할당 해제가 발생 했을 때 조각화(framentation)의 가능성이 있다. 예를 들어, 사용가능한 총 메모리가 1MB지만 이 메모리 블록 중간에 32KB의 메모리가 이미 할당 되어 있다면 사용 가능한 연속 메모리 공간이 충분하지 않아 600KB의 할당에 실패 할 수 있다.

스택은 기본적으로 함수의 생명 주기가 끝나면 할당 된 메모리가 자체적으로 정리되지만, 힙에 할당 된 객체는 함수의 생명 주기와는 관계가 없으므로 더 이상 '필요하지 않을 때' 정리 되어야 한다. C++에서는 이것에 대한 처리를 프로그래머에게 맡기지만 C#은 이것을 계속 추적하고 더 이상 사용되지 않은 메모리를 해제하기 위해 '가비지 콜렉터(garbage collector)'를 실행한다.

'참조' 타입과 '값' 타입(Reference Types and Value Types)

'참조 타입(reference type)'이란 인스턴스가 힙 영역에 할당 되고 해당 유형의 변수가 힙에 할당 되어 있는 객체를 가리키고 있는 포인터임을 의미한다. C#에서 class는 참조 타입으로 생성되며 모두 힙 영역에 메모리가 할당 된다.

C#은 프로그래밍의 단순함을 위해 C++과 같은 프로그래밍 언어에서 사용되는 포인터를 없애려고하지만 실제로는 C++보다 더 광범위하게 포인터를 사용한다. 아이러니하게도 포인터를 없애려는 욕망은 대다수의 변수가 - 내부적으로 - 포인터역할을 하는 시스템을 만들었다. class 유형인 모든 변수는 실제로 포인터이며 이것이 대부분의 C# 변수에 대해 null 검사를 해야하는 이유다.

객체 자체가 메모리를 차지하는것 외에도 class 에는 몇가지 추가 메모리 오버헤드가 있다. class 객체에 대한 모든 변수는 실제로는 포인터이므로 해당 포인터는 64비트 프로그램에서 8바이트를 차지한다. 또한 일부 데이터(64비트 프로그램에서는 16바이트)는 가비지 콜렉팅등을 위한 내부적인 목적으로 추가 저장된다.

반면에 C#에서 구조체(struct)는 '값 타입(value type)'이다. 값 타입의 변수는 객체에 대한 포인터가 아니라 객체 자체다. struct를 함수의 로컬 변수로 생성하면 해당 객체는 스택 메모리 영역에 할당 된다. 만일 구조체가 클래스의 멤버 변수로써 생성된다면 클래스와 같이 힙 메모리에 클래스 객체의 일부로써 할당 된다.

C#의 구조체(struct)는 C++의 값 타입(비포인터 변수)과 매우 비슷하게 작동한다. 할당 작업을 수행하면 복사본이 만들어지며, 함수에 인자로써 전달될 때 역시 참조로써 전달하지 않으면 복사복이 생성되어 전달 된다. 물론 복사복을 수정해도 원본은 수정되지 않느다. 구조체 변수는 null 검사가 필요하지 않다. 흥미롭게도 C#은 포인터를 지원하지 않기 때문에 구조체 객체에 대한 '포인터'를 가질 수 없다(함수에 참조로 전달하는 것 제외).

"new" 키워드의 모호성

C++에서는 new 키워드를 사용한다는 것은 힙 메모리 영역에 객체를 할당하겠다는 의미다. 값 타입 변수는 new 키워드 없이 할당 된다.

// C++ 할당 예제
void Example()
{
    MyClass* classPtr = new MyClass(); // 힙 영역에 할당
    MyClass classValue; // 스택 영역에 할당
}

하지만 C#에서는 이것이 좀 모호하다. C#에서 구조체(struct)와 클래스(class) 객체는 모두 new 키워드를 이용해 생성된다. 따라서 C#에서 new 키워드는 스택 또는 힙에 할당하는지 여부에 대한 구분을 제공하지 않는다. 알 수 있는 유일한 방법은 할당된 객체가 struct인지 class인지를 보는 수 밖에 없다.

// C# 할당 예제
void Example()
{
    Foo foo = new Foo(); // Foo가 클래스라고 가정. 힙 영역에 할당 된다.
    Bar bar = new Bar(); // Bar가 구조체라고 가정. 스택 영역에 할당 된다.
}

여기에는 몇가지 문제가 있다. 첫번째는 객체가 할당된 메모리 영역이 힙인지 스택인지를 구분 할 수 있는 유일한 단서가 Foo와 Bar의 선언을 살펴 보는 수 밖에 없다는 것이다. 둘째, C#은 당신의 메모리 할당 구분 능력을 제거하여 단순화함으로써 유연성 또한 같이 제한 할 수 있다는 것이다.

C#에서 클래스는 힙 영역에 할당 되고 
구조체는 로컬 변수인 경우 스택에, 클래스의 멤버인 경우 클래스의 일부로 힙에 할당 된다.

구조체의 장점

그래서 앞의 장황한 설명들의 요점이 무엇인가? C#에서 클래스와 구조체에 따른 메모리 할당 메커니즘을 이해하면 코드의 효율성을 향상 시킬 수 있다. 이것이 큰 차이를 만든 사례를 한번 살펴 보도록 하겠다.

과거 한동안 Unity는 구조체에 대한 직렬화를 지원하지 않았으므로 직렬화가 필요한 모든 구조적인 데이터 타입은 클래스여야만 했다. 그래서 프로그램 전반적으로 사용되는 직렬화 필요한 매우 작은 구조를 갖춘 데이터의 경우 모두 클래스로 작성되어야만 했다.

[System.Serializable]
public class VarOrNum
{
    public float number;
    public byte flags;
    public byte index;
}

예를 들어 위와 같은 작은 데이터가 프로그램 전체에서 사용된다고 가정하자. 프로그램이 실행 중일 때 일반적으로 위 클래스의 인스턴스가 약 2,000,000개 정도 생성된다고 생각해보자. 그리고 이 클래스 객체들은 생성 이후 딱 한번만 참조 된다(한번 사용 되고 버려진다는 뜻이다).

위의 경우 실제 프로파일링을 통해 약 15MB정도의 메모리가 낭비되고 있는 것을 발견 할 수 있다. 그리고 이 15MB는 아래와 같이 class를 struct로 바꿔주는 간단한 작업만으로도 아껴질 수 있었다.

[System.Serializable]
public struct VarOrNum
{
    public float number;
    public byte flags;
    public byte index;
}

앞의 코드와 유일한 차이점은 class에서 struct로, 즉, 참조 타입에서 값타입으로 바꾼것 뿐이다. 어떻게 이런 간단한 작업이 그 정도의 메모리를 절약 할 수 있었을지 이제 부터 살펴 보도록하겠다.

먼저 클래스를 사용할 때 2,000,000개의 인스턴스가 모두 참조 유형이었으며, 한번만 참조 된다는 것을 상기하자. 64비트 프로그램의 경우 객체를 가리키는 포인터의 크기는 8바이트이므로 인스턴스를 가리키는 포인터에 사용된 메모리만도 15.26MB(8 * 2000000)가 된다.

둘째, 이 클래스가 얼마나 클것이라고 생각 되는가? 클래스의 크기만 보면 float 하나와 byte 두개면 6바이트다. 메모리 정렬을 위한 바이트 패딩이 더 해지면 8바이트 정도가 된다. 그러나 위에서 잠깐 언급했던것 처럼 C# 클래스에는 가비지 콜렉팅 같은 목적을 위해 내부적으로 16바이트 정도의 오버헤드가 추가 된다. 따라서 인스턴스의 실제 크기는 16 + 8 = 24 바이트가 된다.

6바이트 자료구조 하나를 생성하는데 24바이트가 필요하고, 추가로 해당 인스턴스를 가리키는 8바이트 포인터가 필요하다는 것은 다소 놀랄만큼의 낭비로 보인다. 실제 저 자료구조는 포인터를 가리키는 정도의 사이즈면 충분한데 말이다.

그래서 class를 struct로 변환하면서 아래와 같이 두가지 이점을 취할 수 있다.

  1. 각 인스턴스 마다 할당 되는 16바이트 추가 오버헤드는 더 이상 필요하지 않게 되었다.
  2. 힙 메모리에 생성된 객체를 가리키는 포인터로 8바이트를 사용하는 대신 구조체 데이터 자체를 8바이트 만큼 스택 영역에 저장할 수 있다.

또한 구조체 버전은 클래스 내에서 연속적인 메모리로 할당 되기 때문에 이러한 객체를 사용할 때 '포인터 역참조'가 하나 줄어들어 메모리 액세스 패턴과 CPU 캐싱을 개선 할 수 있다. 포인터를 따라 가면 캐시 누락이 발생 할 수 있으므로 "포인터 홉"이 적을 수록 성능에 도움이 된다.

그래서 모든 곳에 구조체를 사용하면 좋은가?

위에 언급된 메모리 절약을 고려했을 때 구조체를 사용하고 싶지 않은 경우가 있을까? 불행하게도 C# 구조체는 여러가지 사용성에 제약이 있다.

C#의 값 타입은 기본적으로 함수에 인수로 전달되거나 함수에서 반환 될 때 복사 된다. ref 키워드를 사용하여 값 타입 인수를 참조 형태로 전달 할 수 있긴 하지만 리턴 할 때는 값 타입 자체로 리턴하는 방법외에는 없다(개인적으로 이것은 프로그램의 오류를 막는 아주 훌륭한 방법이라고 생각한다. 필자가 학부 시절 밤을 새면서 씨름 했던 문제의 원인은 C++ 로컬 변수의 참조를 리턴 한것 때문이었다). (수정 : 최신 버전의 C#은 참조에 의한 반환을 허용한다. 젠장)

복사본 생성 때문에 ref 키워드 없이 인자로 struct 객체를 인자로 넘긴다거나, 함수에서 반환 되는 큰 객체에 대해서는 struct를 사용하고 싶지 않을 것이다.

특히 '복사'는 종종 예상치 못했던 오류의 원인이 되기도 한다. 실수로 객체의 복사본을 만들기 쉽고(특히 C#클래를 구조체로 변환하기 위해 이전 코드를 리팩토링하는 경우), 이 의도하지 않은 복사본은 일반적으로 복사본을 수정하고 원본이 수정되지 않았다는 사실을 깨닫지 못하고 버그로 이어지는 경우가 많다.

또한 C++과 달리 C#에는 값 타입에 대한 참조나 포인터를 저장할 수 있는 방법이 없다. 결과적으로 같은 구조체를 가리키는 두개의 변수를 원하면...그렇게 할 수 없다!! 우리가 할 수 있는 유일한 방법은 각각 고유한 메모리가 있고 정확히 동일한 값을 포함하는 두개의 변수를 만드는것 뿐이다. 이것은 작은 객체라면 문제가 없을 수도 있다. 하지만 객체의 크기가 크다면? 문제가 된다.

다른 문제로, 두 변수가 개념적으로 하나의 객체를 참조해야만 하는 경우 머리가 점점 복잡해진다. 이런 경우 구조체를 사용한다면 개발하면서 많은 원지 않는 상황을 마주하게 될 것이다.

결론

C++에서는 구조체와 클래스의 구분에 대한 논의가 그리 큰 의미가 없을 수도 있다. 하지만 C#의 경우 단순해 보이는 이 선택이 엄청난 메모리를 절약할 수도, 버그 찍어내는 공장이 될 수도 있으므로 각 특성을 이해하고 현명하게 선택 해야 할 것이다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!