5. 참조타입과 값 타입에서의 메모리 관리
프로그램적인 관점에서 메모리는 크게 2가지 종류가 있을 수 있습니다. 바로 Heap과 Stack이 그것인데 대부분의 운영체제에서 Heap과 Stack은 물리적 메모리나 가상 메모리 어디든 존재할 수 있습니다.
Stack메모리는 빠른 속도로 데이터를 입출력할 수 있습니다. 이는 last-in, first-out매커니즘을 구현하고 있기 때문이며 심지어 어떤 Data의 경우에는 CPU의 L1이나 L2 캐시에 담길 수도 있기 때문입니다. 하지만 상대적으로 풍부한 용량을 가진 Heap에 비해서는 아주 제한된 용량을 가지지만 속도는 Heap이 훨씬 느립니다.
Stack과 Heap에 대한 메모리차이를 언급한 이유는 Type에 따라 사용하는 메모리의 종류가 이 2가지로 나뉘어 지기 때문입니다.
C#에서 객체를 정의하기 위해 사용되는 키워드로는 class, record, struct가 존재하며 3가지 모두 Method와 Field 같은 Member를 가질 수 있지만 메모리에 할당되는 방식에서 차이가 존재할 수 있습니다.
record나 class를 사용하게 되면 이것은 참조타입을 정의하는 것이며 Type을 저장하기 위해 Heap메모리를 사용하게 됩니다. 그리고 Heap에 저장된 Type의 주소만이 Stack에 저장되며 이 과정에서 약간의 오버헤드를 발생시키게 됩니다.
반면 record struct 나 struct를 사용하면 이것은 값 타입을 정의하는 것이며 Type을 저장하기 위해 Stack메모리를 사용하게 됨을 의미합니다. 다만 struct에서 struct type이 아닌 field type을 사용하게 된다면 이들 field의 데이터는 Heap에 저장됩니다. 이것은 객체의 데이터가 Heap과 Stack모두에 저장된다는 것을 의미합니다.
struct type으로 정의될 수 있는 형식으로는 다음과 같은 것들이 있습니다.
숫자를 다루는 System Type | byte, sbyte, short, ushort, int, uint, long, ulong, float, double, decimal |
그외 | char, DateTime, bool |
System.Drawing 형식 | Color, Point, Rectangle |
위의 형식이외에 sring을 포함한 거의 모든 형식은 class에 해당합니다.
● 참조형식과 값 형식의 메모리 저장 방식
프로그램에서 아래와 같은 Type이 사용되었을 때
int i = 10;
double d = 12.3;
Car sedan = new();
Car truck;
System.Drawing.Point p = new(x: 100, y: 200);
DateTime dt = new( year:2020, month: 12, day: 1 );
우선 i는 변수에 직접적으로 10의 값이 저장되는데 int형식(정확히는 struct)이므로 값 형식에 해당합니다. 또한 32bit이므로 최종적으로는 Stack메모리에 4byte의 공간을 할당받게 됩니다.
변수 d는 저장되는 용량과 값에만 차이가 존재할뿐 궁극적으로는 i와 동일하므로 Stack에서 8byte의 공간을 할당받습니다.
Point p 또한 값 형식에 해당합니다. 역시 Stack에 8byte(x와 y는 int형이며 2개를 모두 합쳐 저장되므로) 공간을 할당받습니다.
sedan은 class이므로 참조 형식에 해당합니다. 따라서 64bit 메모리 주소(운영체제가 64bit인 경우)를 위한 8byte 크기의 Stack메모리를 할당받으며 Car의 인스턴스인 sedan을 저장하기 위한 Heap메모리를 별도로 할당받아 저장하게 됩니다.
truck의 경우에는 64bit 메모리 주소를 위한 8byte의 Stack메모리를 할당받게 되지만 아직 인스턴스가 생성되지 않았으므로 Heap메모리를 위한 공간을 할당받지 않습니다. 이러한 경우 truck은 null상태가 됩니다.
참조 타입의 경우 저장되는 모든 데이터는 Heap이 됩니다. 참조 타입에서 DateTime과 같은 값 타입이 사용되는 경우에도 DateTime의 값은 Heap에 저장됩니다.
그런데 값 타입의 경우 참조 타입에 해당하는 field를 가지게 되는 경우라면 참조타입에 해당하는 필드 값만이 Heap에 저장됩니다. Point p에서 Point는 값 타입에 해당합니다. 내부적으로 사용되는 x와 y도 모두 int형으로서 값타입에 해당하므로 p의 값은 Stack에 저장되지만 만약 string과 같은 참조형을 가지게 된다면 string byte는 Heap에 할당될 것입니다.
● Type의 비교
일반적으로 값을 비교하기 위해 사용되는 '=='과 '!='연산자는 동작하는 방법에서 값 타입인가 참조 타입인가에 따라 차이가 발생합니다.
값타입인 경우 실제 Stack에 들어간 값을 비교하여 그것이 참인지 거짓인지를 반환하지만
using mylibrary;
namespace myapp;
class Program
{
static void Main(string[] args)
{
int i = 10;
int j = 20;
Console.WriteLine($"결과 {i == j}");
//결과 False
}
}
참조타입인 경우에는 동일하게 Stack에 있는 값을 비교하게 되므로 실제 데이터가 아닌 메모리 주소를 비교하게 됩니다. 따라서 아래의 예제의 경우
namespace mylibrary;
public class Car
{
public int Speed { get; set; }
}
using mylibrary;
namespace myapp;
class Program
{
static void Main(string[] args)
{
Car sedan = new() { Speed = 100 };
Car truck = new() { Speed = 100 };
Console.WriteLine($"결과 : {sedan == truck}");
}
}
//결과 : False
sedan과 truck둘다 동일한 Speed값을 가지고 있지만 결과는 False가 됩니다. sedan과 truck은 서로 다른 메모리 주소를 갖고 있기 때문입니다.
static void Main(string[] args)
{
Car sedan = new() { Speed = 100 };
Car truck = sedan;
Console.WriteLine($"결과 : {sedan == truck}");
}
//결과 : True
따라서 위에서 처럼 2개의 객체가 같은 메모리주소를 참조하고 있는 경우라야 만 True를 반환받을 수 있습니다. 다만 예외적인 사항이 있는데 우선 string의 경우 참조 타입이지만 비교 연산자가 작동할 때 값 타입처럼 작동할 수 있도록 기능이 오버라이드 되었습니다.
static void Main(string[] args)
{
string s1 = "abc";
string s2 = "abc";
Console.WriteLine($"결과 : {s1 == s2}");
}
//결과 : True
또 다른 하나는 record입니다. record는 분명 참조 타입이지만 비교 연산자가 작동하는 경우 실제 값을 비교해 결과를 반환합니다.
namespace mylibrary;
public record Car
{
public string? Number { get; set; }
}
static void Main(string[] args)
{
Car sedan = new() { Number = "1234" };
Car truck = new() { Number = "1234" };
Console.WriteLine($"결과 : { sedan == truck }");
}
//결과 : True
● struct 사용
struck는 값 타입이지만 참조 타입인 class와 비슷하게 구성될 수 있습니다.
namespace mylibrary;
public struct Brik
{
public int Hole;
public Brik(int hole)
{
this.Hole = hole;
}
public static Brik operator +(Brik b1, Brik b2)
{
return new Brik(b1.Hole + b2.Hole);
}
}
예제에서 Brick은 struct형식이며 class와 마찬가지로 생성자를 가지고 +연산자도 적용되어 새로운 Brick을 반환할 수 있도록 되어 있습니다.
using mylibrary;
namespace myapp;
class Program
{
static void Main(string[] args)
{
Brik b1 = new Brik(2);
Brik b2 = new(3);
Brik b3 = b1 + b2;
Console.WriteLine(b1.Hole);
Console.WriteLine(b2.Hole);
Console.WriteLine(b3.Hole);
}
}
// 2
// 3
// 5
struct는 값 형식으로 class보다 더 빠른 동작을 수행할 수 있지만 마이크로소프트는 전체 Type의 크기가 16byte 이하이면서 값 형식의 Field만을 가지고 있고, 상속이 필요 없는 경우에만 사용할 것을 권장하고 있습니다.
● record struct 사용
C#10부터는 Type에서 struct와 함께 record키워드를 사용할 수 있게 되었습니다. 따라서 위 예제에서 Brik은 아래와 같이 바뀔 수 있습니다.
public record struct Brik
다만 record만을 사용할 경우 record class가 되는데 마이크로소프트는 class가 선택적으로 사용될 수 있어도 record class처럼 모든 키워드를 사용해 명시적으로 Type을 지정할 것을 권장하고 있습니다.
● 비관리 리소스의 해제
Type은 Field를 초기화하기 위한 생성자를 하나 이상 가질 수 있습니다. 이때 생성자가 운영체제에서 직접적으로 제어되는 파일이나 뮤 텍스와 같은 비관리 리소스를 할당하는 경우 이러한 자원들은 가비지 컬렉션과 같이 .NET에서 제어할 수 없으므로 명시적으로 해제되어야 할 필요가 있습니다.
public class Car
{
public Car()
{
}
~Car()
{
}
}
이를 위해 Type은 위 예제에서와 같이 하나의 finalizer를 가질 수 있으며 이 finalizer는 리소스의 해제가 필요할때 .NET Runtime에 의해 호출될 수 있습니다. finalizer는 앞에 ~문자만 붙어서 Type과 동일한 이름으로 정의됩니다.
소멸자로 알려진 finalizer를 Deconstruct 메서드와 혼동할 수 있는데 소멸자는 리소스를 해제해 객체를 메모리에서 제거하지만 Deconstruct 메서드는 객체를 구성하고 있는 구성요소를 나누어 반환한다는 차이가 있습니다.
예제는 비관리 리소스를 해제하기 위한 방법을 보여주고 있지만 finalizer만을 제공함으로써 생길 수 있는 문제는 해당 Type에서 할당된 자원을 완전히 해제하기 위해 .NET 가비지 컬렉터는 두번의 가비지 수집이 필요하다는 것입니다.
물론 선택적이긴 하지만 해당 Type을 사용하는 개발자가 명시적으로 자원해제를 위해 호출할 수 있는 메서드를 제공하게 되면 가비지 컬렉터는 파일과 같은 비관리 리소스중에서 관리되는 리소스를 즉시 해제할 수 있게 되고 그러면 단 한번의 가비지 수집으로 객체의 메모리를 해제할 수 있게 됩니다.
IDisposable인터페이스를 상속받아 Type을 구현하면 실제 이것을 실행하기 위한 기본적인 매커니즘을 구현할 수 있습니다.
public class Car : IDisposable
{
public Car()
{
}
~Car()
{
Dispose(false);
}
bool disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposed)
return;
//비관리 리소스 할당 해제 구현
if (disposing)
{
//관리 리소스 할당 해제 구현
}
disposed = true;
}
}
예제에서는 public 한정자를 가진 Dispose()메서드와 protected virtual한정자를 가진 Dispose()메서드 2개가 있습니다. public void Dispose()는 Type을 사용하는 개발자가 호출하는 메서드이며 메서드가 호출될때는 비관리와 관리 리소스 모두 할당 해제를 필요로 합니다.
protected virtual void Dispose()는 내부적으로 리소스의 할당해제를 위해 사용되는 bool형식의 매개변수가 존재하는데 메서드안에서 disposed와 disposing이 2개의 값에 대한 확인을 수행하고 있습니다. 이는 이미 finalizer thread가 동작했고 ~Car()메서드를 호출했다면 그저 비관리 리소스에 대한 해제만이 필요하기 때문입니다.
또한 예제에서는 GC.SuppressFinalize(this)메서드를 호출하는 부분도 존재하는데 이 메서드를 호출하는건 가비지 컬렉터에게 더이상 소멸자(finalizer)가 호출될 필요가 없음을 알림으로서 두번째 가비지 수집의 필요성을 제거하도록 하기 위한 것입니다.
● Dispose() 메서드 호출
위에서 처럼 IDisposable구현된 Type을 사용하는 경우 리소스 해제를 위해 Dispose() 메서드를 명시적으로 호출할 수 있지만 사용하고자 하는 Type이 특정 범위 안에서만 유효한 경우 해당 범위를 벗어나면 자동으로 Dispose() 메서드가 호출될 수 있도록 구현할 수 있습니다.
using (Car c = new())
{
}
이와 같이 Type의 인스턴스를 생성하는 부분에서 using문을 통해 감싸게 되면 using의 중괄호({})를 벗어나는 순간 Dispose()가 명시되지 않더라도 Dispose() 메서드를 호출하게 됩니다.
사실 위와 같은 구현은 컴파일러가 아래와 같이 코드를 바꾸게 되는데
Car c = new();
try
{
//어떤 처리
}
finally
{
if (c != null)
c.Dispose();
}
보시는 것처럼 finally로 Dispose()를 호출하도록 함으로써 만약 예외가 발생하더라도 반드시 Dispose() 메서드가 호출됨을 보증할 수 있습니다.
'.NET > C#' 카테고리의 다른 글
[C#] 인터페이스(Interface)와 상속(Inheriting) - 6. 상속(Inheriting) (0) | 2022.06.24 |
---|---|
[C#] 인터페이스(Interface)와 상속(Inheriting) - 5. NULL (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 3. 인터페이스(Interface) (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 2. 제네릭(generic) (0) | 2022.06.24 |
[C#] 인터페이스(Interface)와 상속(Inheriting) - 1. 메서드(Method)와 이벤트(Event) (0) | 2022.06.24 |