반응형

[PART3.연산자와 표현식(9/11)] checked · unchecked — 정수 오버플로 검사 제어

왜 점수가 갑자기 음수로 바뀌는가 / wrap around의 정체 / 안전과 성능을 저울질하는 키워드


1. 문제 제기 — 9,999,999에서 1을 더했더니 음수가 됐다

Unity로 무한 러너 게임을 만들고 있다고 생각해 봅니다. 플레이어가 오래 달릴수록 점수가 누적되고, int currentScore에 계속 값을 더합니다. 평범한 로직입니다.

C#
public class ScoreManager : MonoBehaviour
{
    private int currentScore;

    public void AddScore(int gained)
    {
        currentScore += gained;
        Debug.Log($"현재 점수: {currentScore}");
    }
}

QA에서 버그가 올라옵니다.

"점수가 21억 넘어가면 갑자기 음수 표시됩니다. -2147483648처럼요."

코드 어디에도 음수를 대입하는 부분은 없습니다. Mathf.Abs() 같은 처리도 안 했는데 어떻게 값이 음수로 바뀌는 걸까요. 예외도 뜨지 않고, 로그에도 경고가 없습니다. 그냥 조용히 값만 뒤집혀 있습니다.

이게 바로 정수 오버플로(Integer Overflow) 입니다. C#은 기본적으로 정수 연산이 범위를 넘어가도 아무 말도 하지 않고 값을 한 바퀴 감아버립니다. 이 "조용한 악마"를 깨울지 재울지 정하는 키워드가 바로 checkedunchecked입니다.

이 글에서 다음을 풀어 보겠습니다.

  • int.MaxValue + 1이 왜 int.MinValue가 되는가
  • checked가 런타임에 무엇을 감시하고, IL이 어떻게 바뀌는가
  • 프로젝트 전체 기본값을 바꾸는 .csproj 한 줄
  • C# 11이 사용자 정의 타입에도 열어 준 operator checked +
  • Unity IL2CPP에서 OverflowException 처리 비용이 왜 비싼가

2. 개념 정의 — 수도계량기 비유와 두 가지 모드

2-1. 비유 — 수도계량기는 끝에서 0으로 돌아간다

오래된 아날로그 수도계량기는 숫자 바퀴가 돌아갑니다. 9999 다음에는 10000이 아니라 0000으로 돌아갑니다. 바퀴 자리가 4개뿐이라 더 큰 수를 표현할 방법이 없기 때문입니다. 계량기는 "수가 넘쳤다"고 알려 주지 않습니다. 그냥 돌아갑니다.

C#의 int도 똑같습니다. int는 32비트 공간에 값을 저장하는데, 표현 가능한 최댓값은 2,147,483,647(약 21억)이고, 그 다음 값은 2,147,483,648이 아니라 최솟값 -2,147,483,648입니다. 계량기가 0000으로 돌아가듯, 정수도 최솟값으로 돌아갑니다. 이 감기 현상을 wrap around(감기, 또는 2의 보수 감기)라고 부릅니다.

checked — 체크된 연산자 (Checked operator) 그 안의 정수 연산에서 오버플로가 발생하면 런타임에 OverflowException을 던지게 한다. 식 형태(checked(expr))와 블록 형태(checked { ... }) 둘 다 지원한다.
예시: int c = checked(a + b); a + bint 범위를 넘으면 예외 발생
unchecked — 체크되지 않은 연산자 (Unchecked operator) 그 안의 정수 연산에서 오버플로가 발생해도 예외 없이 wrap around를 허용한다. 프로젝트 기본이 checked일 때 특정 구간만 끄는 데 사용한다.
예시: int hash = unchecked(x * 397 ^ y); 곱셈 결과가 넘쳐도 그대로 진행

2-2. 기본 동작 시각화

int 값의 원형 구조 — wrap around의 정체

int는 표현 가능한 값이 원형으로 이어져 있다고 보면 됩니다. 한 바퀴 돌아 MaxValue 다음으로 넘어가면 MinValue가 나옵니다. C#의 기본 모드는 이 점프를 허용하고, checked 모드는 이 점프 순간을 포착해 예외로 알려 줍니다.

2-3. 가장 단순한 예시

C#
using System;

public class OverflowDemo
{
    public static void Main()
    {
        // 기본 모드 — 조용히 감김
        int a = int.MaxValue;      // 2,147,483,647
        int b = a + 1;
        Console.WriteLine(b);       // -2147483648 (int.MinValue)

        // checked 식 — 런타임 예외
        try
        {
            int c = checked(a + 1);
            Console.WriteLine(c);
        }
        catch (OverflowException)
        {
            Console.WriteLine("오버플로 감지!");
        }
    }
}

위 코드가 컴파일되어 만들어지는 IL을 보면 두 연산이 서로 다른 명령어를 쓰는 걸 확인할 수 있습니다.

IL
// DefaultAdd (a + b) — 기본 모드
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add                 // 단순 덧셈, 오버플로 무시
IL_0004: stloc.0

// CheckedExprAdd (checked(a + b))
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add.ovf             // 오버플로 검사 덧셈
IL_0004: stloc.0

IL 해설: add 명령은 그냥 두 정수를 더하고 끝입니다. add.ovf(add with overflow check)는 결과가 대상 타입의 범위를 벗어나면 CPU 플래그를 읽어 CLR이 OverflowException을 던집니다. C# 소스 코드에는 + 연산자 하나뿐이지만, 컴파일러가 컨텍스트(checked / unchecked)에 따라 전혀 다른 IL 명령어를 생성합니다. 이게 이 두 키워드의 실체입니다.


3. 내부 동작 — 컴파일러가 만드는 IL 명령어의 두 세계

3-1. 체크 명령어 패밀리

IL 명령어 짝 — 오버플로 검사 여부에 따른 대응

CIL(Common Intermediate Language, .NET의 중간 언어) 명령어에는 .ovf 접미사가 붙는 "체크 버전"이 쌍으로 준비돼 있습니다. checked 컨텍스트에서는 컴파일러가 항상 .ovf 접미사 명령어를 선택하고, unchecked 컨텍스트에서는 일반 명령어를 선택합니다. 산술뿐 아니라 타입 변환(conv) 에도 체크 버전이 따로 있다는 점이 중요합니다.

3-2. 블록 형태와 식 형태는 같은 IL로 변환된다

C#
// 식 형태
public static int CheckedExprAdd(int a, int b)
{
    return checked(a + b);
}

// 블록 형태
public static int CheckedBlockMul(int a, int b)
{
    checked
    {
        return a * b;
    }
}
IL
// CheckedExprAdd
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add.ovf       // 덧셈 + 오버플로 검사
IL_0004: stloc.0
IL_0008: ret

// CheckedBlockMul
IL_0002: ldarg.0
IL_0003: ldarg.1
IL_0004: mul.ovf       // 곱셈 + 오버플로 검사
IL_0005: stloc.0
IL_0009: ret

IL 해설: 식 형태 checked(expr)와 블록 형태 checked { ... }는 문법적 편의일 뿐, 생성되는 IL 수준에서는 완전히 동일한 전략입니다. 컴파일러는 checked 컨텍스트 안에 있는 정수 산술·변환 명령어만 .ovf 버전으로 바꿉니다. 블록 안에 있어도 함수 호출이나 참조 타입 연산에는 영향이 없습니다.

3-3. 타입 변환(캐스팅) 체크

C#
public static byte UncheckedToByte(int value)
{
    return unchecked((byte)value);
}

public static byte CheckedToByte(int value)
{
    return checked((byte)value);
}
IL
// UncheckedToByte
IL_0001: ldarg.0
IL_0002: conv.u1           // int → byte, 상위 비트 그냥 버림
IL_0003: stloc.0

// CheckedToByte
IL_0001: ldarg.0
IL_0002: conv.ovf.u1       // int → byte, 범위 초과 시 예외
IL_0003: stloc.0

IL 해설: (byte)500처럼 범위를 벗어나는 값을 캐스팅할 때도 동일한 원리가 적용됩니다. conv.u1은 상위 바이트를 잘라내 500 & 0xFF = 244를 반환합니다. conv.ovf.u1은 "500은 byte 범위를 벗어난다"고 감지하고 OverflowException을 던집니다. 캐스팅도 연산의 일부이기 때문에 checked/unchecked의 지배를 받습니다.


4. 실전 적용 — 언제 켜고 언제 끄는가

4-1. 프로젝트 전역 기본값 바꾸기

매번 checked 블록을 쓰기 번거롭다면, .csproj에 한 줄만 추가해서 프로젝트 전체의 기본 컨텍스트를 checked로 바꿀 수 있습니다.

<!-- Game.csproj -->
<PropertyGroup>
  <!-- 프로젝트 전역: 정수 오버플로 검사 기본 활성화 -->
  <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>
<!-- Debug 빌드에서만 켜는 패턴 (권장) -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
  <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>

<PropertyGroup Condition="'$(Configuration)' == 'Release'">
  <CheckForOverflowUnderflow>false</CheckForOverflowUnderflow>
</PropertyGroup>

개발 중에는 checked로 예상치 못한 오버플로 버그를 조기에 잡고, 릴리스 빌드에서는 꺼서 성능을 회수하는 방식이 널리 쓰입니다. 이 설정을 쓸 때도 unchecked 키워드로 특정 구간만 감쌀 수 있다는 점은 그대로 유효합니다.

4-2. Before / After — 점수 누적 로직

Before — 조용한 오버플로 (Unity 실무에서 자주 나오는 실수)

C#
// 무한 러너의 콤보 점수 — 기본 unchecked
public class ScoreManager : MonoBehaviour
{
    public long totalScore;       // 누적 점수
    public int comboMultiplier;   // 콤보 배율 (초반 1, 이벤트 시 100배 등)

    public void OnEnemyKilled(int baseScore)
    {
        // 문제: 배율이 커지면 int 연산이 먼저 오버플로
        int gained = baseScore * comboMultiplier;  // 먼저 int로 계산됨
        totalScore += gained;                       // 그다음 long에 더함
    }
}
IL
// OnEnemyKilled 발췌 (개념)
IL_0001: ldarg.0
IL_0002: ldfld int32 ScoreManager::comboMultiplier
IL_0007: ldarg.1
IL_0008: mul                     // int 곱셈, 오버플로 감지 없음
IL_0009: stloc.0
IL_000a: conv.i8                 // int → long 확장 (이미 망가진 값)

IL 해설: baseScore * comboMultiplier는 두 피연산자가 모두 int이므로 결과도 int로 먼저 계산됩니다. mul 명령어는 오버플로 감지를 하지 않기 때문에, 배율이 커서 int 범위를 넘기는 순간 값이 감긴 상태로 long에 확장됩니다. long totalScore가 있어도 아무 소용이 없습니다. 이미 int 단계에서 값이 망가졌기 때문입니다.

After — checked로 즉시 감지 + 명시적 타입 확장

C#
public class ScoreManager : MonoBehaviour
{
    public long totalScore;
    public int comboMultiplier;

    public void OnEnemyKilled(int baseScore)
    {
        try
        {
            // (long)으로 먼저 확장해 64비트 공간에서 계산
            long gained = checked((long)baseScore * comboMultiplier);
            totalScore = checked(totalScore + gained);
        }
        catch (OverflowException)
        {
            // 점수 최대치에 도달 — 상한으로 고정
            totalScore = long.MaxValue;
            Debug.LogWarning("점수 상한 도달");
        }
    }
}
IL
// 핵심 부분 (개념)
IL_0001: ldarg.1
IL_0002: conv.i8                 // int → long, 먼저 확장
IL_0003: ldarg.0
IL_0004: ldfld int32 ScoreManager::comboMultiplier
IL_0009: conv.i8                 // int → long
IL_000a: mul.ovf                 // long 곱셈 + 오버플로 검사

IL 해설: (long)baseScore로 먼저 64비트로 확장한 뒤 곱하면, 이후 연산은 전부 long 범위에서 진행됩니다. checked가 붙으면 mul.ovf 명령어가 사용돼 long 범위(약 922경)를 넘는 순간에도 예외로 알려 줍니다. 실제로는 이 범위를 넘기기 매우 어렵지만, "조용히 망가지지 않는다"는 보증이 생깁니다.

4-3. GetHashCodeunchecked가 반드시 필요한 곳

해시 코드를 계산할 때는 오히려 오버플로가 정상적인 동작입니다. 해시 함수는 비트를 고르게 섞기 위해 일부러 큰 곱셈과 XOR을 반복하고, 결과가 int 범위를 빈번히 벗어납니다. 프로젝트 전역 설정이 checked일 경우 여기서 예외가 발생해 버립니다.

C#
public readonly struct GridCell
{
    public readonly int X;
    public readonly int Y;

    public override int GetHashCode()
    {
        // 해시 코드: 오버플로는 정상 — 반드시 unchecked
        return unchecked((X * 397) ^ Y);
    }
}
IL
// GetHashCode 발췌
IL_0001: ldarg.0
IL_0002: ldfld int32 GridCell::X
IL_0007: ldc.i4 397
IL_000c: mul                    // 오버플로 무시
IL_000d: ldarg.0
IL_000e: ldfld int32 GridCell::Y
IL_0013: xor

IL 해설: X * 397은 X가 조금만 커도 쉽게 int 범위를 넘지만, 해시 함수에서는 이 비트 섞임이 목적입니다. unchecked 블록 안이므로 mul 명령어가 그대로 사용돼 예외 없이 감긴 값이 반환됩니다. GetHashCode에서 checked를 쓰면 Dictionary 키로 쓰는 순간 무작위로 예외가 터지는 재앙이 됩니다.

4-4. 성능 특성 — 얼마나 비싼가

  • add.ovf 계열은 add보다 명령어 1~2개 분량의 비용이 추가됩니다. CPU의 오버플로 플래그를 읽고 조건부 분기를 거칩니다.
  • JIT(Just-In-Time 컴파일러, 런타임에 IL을 기계어로 변환하는 엔진)는 루프 인덱스처럼 범위를 정적으로 증명할 수 있는 변수에 대해서는 체크 명령어를 자주 제거합니다. 단, 이 최적화가 항상 적용되는 건 아닙니다.
  • 대부분의 게임 로직에서는 체감하기 어렵지만, 매 프레임 수백만 번 도는 수치 핫패스(물리 시뮬레이션, 대규모 파티클 등)에서는 누적 비용이 프로파일러에 잡힐 수 있습니다.

판단 기준:

상황 권장
비즈니스 로직·점수·재화 checked — 안전이 우선
해시·체크섬·비트 연산 unchecked — 감기가 의도된 동작
매 프레임 돌아가는 수치 핫패스 프로파일링 후 unchecked 고려
Debug 빌드 전반 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>

5. 함정과 주의사항

5-1. 함정 1 — 부동소수점에는 영향이 없다

checked정수 계열에만 적용됩니다. float·double·decimal에서는 checked가 아무 일도 하지 않습니다. 부동소수점은 IEEE 754 규격에 따라 오버플로 시 InfinityNaN을 조용히 만들어 냅니다.

C#
// ❌ 오해 — checked가 막아줄 거라는 착각
public static double FloatAdd(double a, double b)
{
    return checked(a + b);   // checked 적용 안 됨
}
IL
// FloatAdd
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add                 // 일반 add, .ovf 버전 없음
IL_0004: stloc.0

IL 해설: 소스 코드에 checked가 붙어 있어도 IL 명령어는 그냥 add입니다. 부동소수점 add에는 .ovf 변형 자체가 없습니다. 따라서 double.MaxValue + double.MaxValue는 예외 없이 double.PositiveInfinity를 반환합니다.

C#
// ✅ 부동소수점은 직접 검사
public static double SafeDiv(double a, double b)
{
    double result = a / b;
    if (double.IsInfinity(result) || double.IsNaN(result))
        throw new ArgumentException("결과가 유효한 실수가 아닙니다");
    return result;
}

부동소수점의 이상 상태는 double.IsInfinity()·double.IsNaN()·double.IsFinite()로 직접 검사해야 합니다.

5-2. 함정 2 — 상수 표현식은 컴파일 타임에 검사된다

변수 연산은 런타임에 checked 컨텍스트에 따라 검사되지만, 상수(리터럴)로만 이뤄진 표현식checked/unchecked 키워드와 무관하게 컴파일 타임에 검사됩니다.

C#
// ❌ 컴파일 에러 — CS0220
// "연산이 컴파일 타임에 checked 모드에서 오버플로가 발생합니다"
const int bad = int.MaxValue + 1;

// ✅ 의도적 래핑이 필요하면 unchecked
const int intentional = unchecked(int.MaxValue + 1);  // -2147483648

// ✅ 변수 연산은 기본적으로 런타임 unchecked
int x = int.MaxValue;
int y = x + 1;  // -2147483648, 컴파일 통과

컴파일러는 상수끼리의 연산을 미리 계산해 두기 때문에, 명백한 오버플로를 발견하면 바로 에러로 막아 줍니다. 의도된 래핑이라면 unchecked로 감싸 컴파일러에게 의사를 명확히 밝혀야 합니다.

5-3. 함정 3 — Unity IL2CPP에서 OverflowException은 비싸다

Unity 모바일 빌드(iOS/Android)는 기본적으로 IL2CPP(Intermediate Language To C++, C# IL을 C++로 변환해 네이티브로 AOT 컴파일하는 Unity 기술)를 사용합니다. IL2CPP 환경에서는 C# 예외가 C++ 예외 처리 경로를 타는데, .NET의 JIT 환경에 비해 예외 던지기·잡기 비용이 상대적으로 큽니다.

C#
// ❌ 매 프레임 돌아가는 Update에서 checked로 감싸고 catch를 쓰면 비싸다
void Update()
{
    try
    {
        // 수백 개 적 개별 업데이트, 매 프레임
        foreach (var enemy in enemies)
            enemy.accumulated = checked(enemy.accumulated + enemy.damage);
    }
    catch (OverflowException)
    {
        // 이 catch가 자주 발생하면 IL2CPP에서 프레임 드롭 원인
    }
}
C#
// ✅ 핫패스에서는 사전 검증으로 예외를 회피
void Update()
{
    foreach (var enemy in enemies)
    {
        // 예외가 아닌 조건 분기로 처리
        if (enemy.accumulated > int.MaxValue - enemy.damage)
            enemy.accumulated = int.MaxValue;
        else
            enemy.accumulated += enemy.damage;
    }
}

차이점: 위 패턴은 예외를 던지지 않습니다. 값이 상한에 가까워지면 Mathf.Clamp 느낌으로 고정해 둡니다. checked를 쓰되 예외가 자주 터지지 않도록 사전 방어하는 것이 IL2CPP에 친화적입니다.

5-4. 함정 4 — checked는 메서드 호출을 따라가지 않는다

C#
public static int Add(int a, int b) => a + b;  // 이 내부는 unchecked

public static void Caller()
{
    // ❌ 착각: 전체가 checked라 Add 안까지 보호될 것 같다
    int result = checked(Add(int.MaxValue, 1));
    // 실제: Add의 `a + b` 는 Add의 컨텍스트(기본 unchecked)를 따른다
    // 결과: 예외 없이 -2147483648 반환
}

checked 컨텍스트는 어휘적(lexical) 범위 안에서만 유효합니다. 호출한 메서드 내부의 연산은 그 메서드 자체의 컨텍스트를 따릅니다. 한 줄 더 보태자면, checked는 "이 코드 영역의 연산자 선택"을 바꾸는 것이지, 실행 흐름 전체를 감싸는 기능이 아닙니다. 안전이 필요한 로직은 해당 메서드 내부에서 checked를 써야 합니다.


6. C# 버전별 변화 — 사용자 정의 checked 연산자 (C# 11)

C# 11 이전에는 checked/unchecked내장 타입의 연산에만 영향을 줬습니다. 개발자가 만든 struct+ 연산자를 오버로딩해도, 호출부에서 checked(a + b)라고 써도 사용자 타입의 동작은 변하지 않았습니다.

C# 11부터는 사용자 정의 타입의 연산자도 "일반 버전"과 "checked 버전"을 따로 정의할 수 있습니다. 컨텍스트에 따라 컴파일러가 호출 대상을 자동으로 바꿉니다.

6-1. Before (C# 10 이하) — 연산자 하나만 존재

C#
// C# 10 이하 — + 연산자는 하나뿐
public readonly struct Money
{
    public int Cents { get; }
    public Money(int c) { Cents = c; }

    // 일반 + 만 존재. checked 컨텍스트에서도 이 연산자가 호출됨
    public static Money operator +(Money a, Money b)
        => new Money(a.Cents + b.Cents);
}

// 호출부
var m = checked(new Money(int.MaxValue) + new Money(1));
// 기대: 예외 발생
// 실제: 예외 없이 Money(int.MinValue) 반환 — operator +의 내부 계산이 그 자체로 unchecked 이므로

6-2. After (C# 11+) — operator checked + 추가

C#
public readonly struct Money
{
    public int Cents { get; }
    public Money(int c) { Cents = c; }

    // 일반 + : unchecked 컨텍스트에서 호출됨
    public static Money operator +(Money a, Money b)
        => new Money(a.Cents + b.Cents);

    // checked + : checked 컨텍스트에서 호출됨 (C# 11+)
    public static Money operator checked +(Money a, Money b)
        => new Money(checked(a.Cents + b.Cents));
}

// 호출부
var m1 = new Money(int.MaxValue) + new Money(1);           // op_Addition 호출
var m2 = checked(new Money(int.MaxValue) + new Money(1));  // op_CheckedAddition 호출 → 예외
IL
// C# 11 컴파일 결과 — 두 메서드가 따로 생성된다
.method public hidebysig specialname static
    valuetype Money op_Addition (
        valuetype Money a, valuetype Money b
    ) cil managed
{
    IL_0000: ldarga.s a
    IL_0002: call instance int32 Money::get_Cents()
    IL_0007: ldarga.s b
    IL_0009: call instance int32 Money::get_Cents()
    IL_000e: add                 // 오버플로 검사 없음
    IL_000f: newobj instance void Money::.ctor(int32)
    IL_0014: ret
}

.method public hidebysig specialname static
    valuetype Money op_CheckedAddition (   // 새 이름!
        valuetype Money a, valuetype Money b
    ) cil managed
{
    IL_0000: ldarga.s a
    IL_0002: call instance int32 Money::get_Cents()
    IL_0007: ldarga.s b
    IL_0009: call instance int32 Money::get_Cents()
    IL_000e: add.ovf             // 오버플로 검사
    IL_000f: newobj instance void Money::.ctor(int32)
    IL_0014: ret
}

IL 해설: C# 11의 operator checked +는 IL 수준에서 op_CheckedAddition이라는 완전히 별도의 메서드로 생성됩니다. 호출부에서 checked(a + b)라고 쓰면 컴파일러가 op_Addition 대신 op_CheckedAddition을 호출하는 IL을 내보냅니다. "같은 + 연산자인데 컨텍스트에 따라 다른 구현을 고른다"는 약속이 IL 수준에서 실체로 드러납니다.

6-3. Generic Math — INumberBase<T>와의 결합

.NET 7과 C# 11은 INumberBase<T> 같은 제네릭 수학 인터페이스를 도입해, "임의의 숫자 타입"에 대해 산술 연산이 가능한 제네릭 메서드를 쓸 수 있게 했습니다. 여기에 operator checked를 결합하면, 호출 컨텍스트가 제네릭 경계를 넘어 올바르게 전달됩니다.

C#
using System.Numerics;

public static class SafeMath
{
    // T는 INumberBase<T>를 구현한 모든 숫자 타입
    public static T Sum<T>(T a, T b) where T : INumberBase<T>
    {
        return a + b;   // 호출 컨텍스트에 따라 op_Addition / op_CheckedAddition 결정
    }
}

// 호출 예
int r1 = SafeMath.Sum(int.MaxValue, 1);            // 기본 unchecked → wrap
int r2 = checked(SafeMath.Sum(int.MaxValue, 1));   // checked → OverflowException
Money r3 = checked(SafeMath.Sum(new Money(int.MaxValue), new Money(1)));  // 사용자 타입도 동일

라이브러리 작성자는 operator + 한 가지만 제공하던 과거와 달리, checked 버전을 함께 제공해 정확도를 원하는 사용자와 속도를 원하는 사용자 모두에게 선택지를 줄 수 있게 됐습니다.


7. 정리 — 이것만 기억하면 됩니다

  • [ ] C#의 기본 모드는 unchecked입니다. int.MaxValue + 1은 예외 없이 int.MinValue로 감깁니다.
  • [ ] checked(expr)checked { ... } 블록은 안에서 오버플로가 나면 OverflowException을 던집니다. IL에서는 add.ovf·mul.ovf·conv.ovf.* 명령어로 바뀝니다.
  • [ ] unchecked는 반대 방향. 프로젝트 전역을 checked로 켜 둔 상태에서 해시 코드·체크섬처럼 감기가 의도된 구간을 예외 없이 돌릴 때 씁니다.
  • [ ] 전역 설정: .csproj<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>. Debug만 켜고 Release는 끄는 방식이 무난합니다.
  • [ ] 상수 표현식은 키워드와 무관하게 컴파일 타임에 검사됩니다. 의도적으로 래핑하려면 unchecked로 감쌉니다.
  • [ ] 부동소수점은 무관합니다. double의 오버플로는 Infinity·NaN으로 표현되며, double.IsFinite로 직접 검사합니다.
  • [ ] checked는 어휘적 범위에만 적용됩니다. 호출한 메서드 내부까지 따라가지 않습니다.
  • [ ] Unity IL2CPP에서 OverflowException은 비쌉니다. 핫패스에서는 예외가 자주 터지지 않도록 사전 조건 검사로 보호합니다.
  • [ ] C# 11부터 사용자 정의 타입도 operator checked +를 따로 정의해 일반/체크 버전을 분리 제공할 수 있습니다. IL에서는 op_Additionop_CheckedAddition으로 나뉩니다.
  • [ ] 판단 기준: 점수·재화·계산 정확도 → checked. 해시·비트·의도된 감기 → unchecked.
반응형

+ Recent posts