IT공부

[C#] .NET 9 decimal 구조체 톺아보기

shine94 2025. 6. 16. 11:51

* decimal 구조체의 최신 원본 코드(공식 레파짓토리)

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Decimal.cs?utm_source=chatgpt.com

 

runtime/src/libraries/System.Private.CoreLib/src/System/Decimal.cs at main · dotnet/runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. - dotnet/runtime

github.com

https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Decimal.DecCalc.cs#L1561

 

runtime/src/libraries/System.Private.CoreLib/src/System/Decimal.DecCalc.cs at main · dotnet/runtime

.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps. - dotnet/runtime

github.com

 

* 내부 구성 요소

// lo, mid, hi, flags 필드는 Decimal 값을 표현한다.
// lo, mid, hi 필드는 96비트 정수(integer) 부분을 저장한다.
// flags 필드:
//  - 비트 0~15: 사용하지 않으며 반드시 0이어야 한다.
//  - 비트 16~23: 0~28 사이의 값을 가져야 하며,
//    이는 96비트 정수를 10의 거듭제곱으로 나누는 지수를 나타낸다.
//  - 비트 24~30: 사용하지 않으며 반드시 0이어야 한다.
//  - 비트 31: Decimal 값의 부호 (0 = 양수, 1 = 음수)
//
// NOTE: 필드의 순서와 타입은 변경하면 안 된다.
//       이 구조는 Win32 DECIMAL 타입과 동일해야 한다.
private readonly int _flags;   // 지수와 부호 등 메타정보
private readonly uint _hi32;   // 상위 32비트 (hi)
private readonly ulong _lo64;  // 하위 64비트 (lo + mid)

 

필드명 크기 역할
_flags flags 32bit 부호(Sign) → 0 양수, 1 음수
소수점 자리수(Scale)
_hi32 hi 32bit 상위 32비트 정수
_lo64 mid 32bit 중간 32비트 정수
lo 32bit 하위 32비트 정수

 

   실제 정수 데이터는 hi + mid + lo 총 96비트

   flags는 부호와 스케일만 관리

 

* Decimal 구조체는 불변(immutable)이다

   내부 필드 _flags, _hi32, _lo64 모두 private readonly로 선언되어 있어,

   생성자에서만 값 할당이 가능하며 이후에는 값 변경이 불가능하다

 

   값 타입이므로 복사 시 전체가 복제되며,

   내부 값이 변경 가능하다면 예상치 못한 버그나 부작용이 발생할 수 있기 때문이다

 

   정밀성과 안정성을 위해 고정 소수점 연산을 선택했으므로,

   불편 구조로 설계하여 스레드 안정성과 신뢰성 높은 연산 결과를 보장한다

 

   (+) 안정성과 신뢰성 높은 결과를 위해 string도 동일한 이유로 불변이며,

         특히, 동기화 문제 해결을 위해 참조 타입임에도 불구하고 불변으로 설계됨

 

* 개념

   가변 소수점이 아니라 고정 소수점(fixed-point)으로 동작

   96비트 정수 + 소수점 자리수 + 부호 정보를 나눠서 관리

   128비트(16바이트) 크기

   double 보단 정밀도는 높지만 표현할 수 있는 전체 범위는 좁다

 

* 예시

   123.456789

부호 = 0
자리수(scale) = 6
내부 정수 = 123456789 (10진수)

 

필드 2진수
flag 0000 0000 0000 0110 0000 0000 0000 0000
hi 0000 0000 0000 0000 0000 0000 0000 0000
mid 0000 0000 0000 0000 0000 0000 0000 0000  
lo 0000 0111 0101 1011 1100 1101 0001 0101

 

 * Decimal flags 마스크와 시프트

 

(1) SignMask : 부호 비트 추출

   flags 필드에서 부호 정보를 추출하기 위한 마스트

   즉, 31번째 비트를 뽑아내기 위함

// Sign mask for the flags field.
// flags 필드에서 부호(양수/음수)를 나타내는 비트의 마스크.
// 31번 비트가 0이면 양수, 1이면 음수.
//
// Windows COM의 DECIMAL_NEG 상수와 동일한 구조임.
private const int SignMask = unchecked((int)0x80000000);

 

(2) ScaleMask : 소수점 자리수 비트 추출

   flags 필드에서 소수점 자리수 정보를 추출하기 위한 마스크

   즉, 16 ~ 23번째 비트를 뽑아내기 위함

// Scale mask for the flags field.
// flags 필드에서 '소수점 자리수'를 나타내는 바이트 부분의 마스크.
// 소수점 자리수(scale)는 반드시 0~28 범위여야 함.
private const int ScaleMask = 0x00FF0000;

 

(3) ScaleShift : 자리수 비트 위치 이동

   flags 비트에서 소수점 자리수 정보를 변경하기 위한 시프트

   즉, 소수점 자리수 정보를 16비트 이동하여 세팅하기 위함

// Number of bits scale is shifted by.
// scale 값을 flags 비트에서 가져오거나 넣을 때 몇 비트를 이동시켜야 하는지 나타냄.
private const int ScaleShift = 16;

 

* Decimal은 struct로 정의되어 있지만,

   C#에서는 struct에도 메서드를 정의할 수 있으므로 코드를 보면 필드와 메서드가 함께 존재한다

 

* Decimal의 생성자들

// 정수 값으로부터 Decimal을 만든다.
public Decimal(int value)
{
    // 값이 양수이면 부호 플래그는 0
    if (value >= 0)
    {
        _flags = 0;
    }
    else
    {
        // 음수이면 부호 마스크 설정하고 값을 양수로 바꾼다.
        _flags = SignMask;
        value = -value;
    }
    // lo64에 uint로 값 저장, hi32는 0
    _lo64 = (uint)value;
    _hi32 = 0;
}

// 부호 없는 정수 값으로부터 Decimal을 만든다.
[CLSCompliant(false)]
public Decimal(uint value)
{
    _flags = 0;
    _lo64 = value;
    _hi32 = 0;
}

// long 값으로부터 Decimal을 만든다.
public Decimal(long value)
{
    if (value >= 0)
    {
        _flags = 0;
    }
    else
    {
        _flags = SignMask;
        value = -value;
    }
    _lo64 = (ulong)value;
    _hi32 = 0;
}

// 부호 없는 long 값으로부터 Decimal을 만든다.
[CLSCompliant(false)]
public Decimal(ulong value)
{
    _flags = 0;
    _lo64 = value;
    _hi32 = 0;
}

// float 값으로부터 Decimal을 만든다.
public Decimal(float value)
{
    DecCalc.VarDecFromR4(value, out AsMutable(ref this));
}

// double 값으로부터 Decimal을 만든다.
public Decimal(double value)
{
    DecCalc.VarDecFromR8(value, out AsMutable(ref this));
}

// SerializationInfo로부터 Decimal을 만든다.
private Decimal(SerializationInfo info, StreamingContext context)
{
    // 인자가 null이면 예외 던짐
    ArgumentNullException.ThrowIfNull(info);

    // 저장된 플래그 가져오기
    _flags = info.GetInt32("flags");
    // 상위 32비트
    _hi32 = (uint)info.GetInt32("hi");
    // lo64는 lo + (mid << 32)
    _lo64 = (uint)info.GetInt32("lo") + ((ulong)info.GetInt32("mid") << 32);
}

 

* 구조체 생성자 선언을 하고 싶다면 new 생성을 이용해야 한다

 

* 위 생성자 내부 로직을 보고 궁금했던 점

   처음에는 decimal 자료형을 사용하면 무조건 오차가 없을 것이라 생각했다

   하지만 직접 확인해보니

   접미사를 붙여 decimal 리터럴로 생성하느냐, new 키워드로 직접 생성자 호출을 하느냐에 따라 오차 발생 여부가 달라졌다

   

   float나 double 리터럴 그대로 decimal에 대입하면 암시적 변환이 불가능하며,

   반드시 사용자 정의 생성자를 호출해 초기화해야 한다

 

   m 접미사를 붙이면 컴파일러가 decimal 리터럴 상수로 만들어주고, 이때 안전한 대입이 가능하다

   IL 코드를 분석해보면 예상한 대로 컴파일러가 특수 생성자 .ctor를 호출해 내부 필드를 초기화한 것이다

   (이는 C# 소스 코드에는 드러나지 않고, 컴파일러가 자동으로 생성자 호출 IL 코드를 삽입한 결과임)

   [ .ctor] 컴파일러가 직접 생성자 호출 코드를 IL에 만들어 넣는 것을 말한다

 

   float와 double을 사용자 정의 생성자 호출로 생성하면

   이미 생긴 부동 소수점 근사값이 그대로 복사되어 오차가 남을 수 있다

   (부동소수점 → decimal 변환은 내부에서 VarDecFromR4, VarDecFromR8을 호출해 고정 소수점으로 변환)

 

(+) ldc = load constant

     .i4 = int32

     .i8 = int64

     .r4 = float
     .r8 = double