안녕하세요 코드 아키텍트입니다. C# 기초를 다지면서 checked 키워드를 만났습니다. 어떤 용도인지 알아보도록 하겠습니다.
문제: 조용한 오버플로우
C#에서 int.MaxValue에 1을 더하면 어떻게 될까요?
int max = int.MaxValue; // 2,147,483,647
int result = max + 1; // -2,147,483,648 ← 예외 없음!
Console.WriteLine(result); // -2,147,483,648 출력
예외도 없고, 경고도 없습니다. 양수의 최댓값에 1을 더했는데 음수의 최솟값이 됩니다. 프로그램은 아무 일 없다는 듯이 계속 실행됩니다.
Microsoft 공식 문서에 따르면, C#의 기본 산술 연산 컨텍스트는 unchecked입니다. 이 컨텍스트에서는 결과가 대상 타입에 맞지 않을 경우 상위 비트(high-order bits)가 버려지고 하위 비트만 남는 비트 절삭(truncation)이 일어납니다 [[1]].
금융 계산, 카운터, 배열 인덱스 같은 곳에서 이런 일이 생기면 데이터 오염이 발생하고, 원인을 추적하기 극도로 어렵습니다. 이게 "유령 버그"입니다.
쉽게 비유하면, 1+1=2가 되어야하지만 컴퓨터의 내부 계산 체계때문에 1+1이 -1이 되어버리는 것과 같습니다. 그리고 이를 C#에서는 의도된 행위로 보고 기본적으론느 체크하지 않는 것입니다.
checked의 동작
checked 컨텍스트에서는 정수 오버플로우가 발생하면 System.OverflowException이 던져집니다. 상수(int) 표현식의 경우에는 컴파일 타임 오류가 발생합니다 [[1]].
두 가지 형태로 사용할 수 있습니다.
블록(Statement) 형태
checked
{
int result = a * b; // 오버플로우 시 OverflowException
}
표현식(Operator) 형태
int result = checked(a * b); // 이 표현식에만 적용
두 형태 모두 IL(중간 언어) 수준에서 다른 명령어를 생성합니다. 일반적인 mul 대신 mul.ovf, add 대신 add.ovf가 사용되어 CPU의 오버플로우 플래그를 검사하게 됩니다.
그러니까.. dotnet에서는 CIL(Common Intermediate Language)이라는 것으로 C#언어가 변경된 후에 CPU 명령어로 바뀌는데 여기에서 mul이 그냥 곱셈이라면 mul.ovf는 overflow를 검사하는 곱셈이고, 마찬가지로 add는 그냥 더하기라면 add.ovf는 overflow를 검사하는 더하기가 됩니다.
정리하면 이렇습니다:
int.MaxValue + 1→ unchecked:-2,147,483,648(조용한 오류 😱)int.MaxValue + 1→ checked:OverflowException(명확한 실패 ✅)
실전 예제 — 팩토리얼 계산
팩토리얼은 폭발적으로 증가합니다. 12! = 479,001,600으로 int 범위 안에 있지만, 13! = 6,227,020,800으로 int.MaxValue(≈21억)를 초과합니다.
static int Factorial(int number)
{
if (number < 0)
{
throw new ArgumentOutOfRangeException(
message: $"The factorial function is defined for non-negative integers only. Input:{number}",
paramName: nameof(number));
}
else if (number == 0)
{
return 1;
}
else
checked // ← 오버플로우 감지
{
return number * Factorial(number - 1);
}
}
호출부에서는 이렇게 처리합니다:
for (int i = -2; i <= 15; i++)
{
try
{
Console.WriteLine($"{i}! = {Factorial(i):N0}");
}
catch (OverflowException)
{
Console.WriteLine($"{i}! is too big for a 32-bit integer.");
}
catch (Exception ex)
{
Console.WriteLine($"{i}! throws {ex.GetType()}: {ex.Message}");
}
}
checked가 없다면 Factorial(13)은 의미 없는 음수를 반환하고, 프로그램은 그 값을 정상으로 취급합니다. checked가 있기 때문에 OverflowException이 발생하고, 호출부에서 이를 우아하게 처리할 수 있습니다.
핵심 원칙: "틀린 답보다 명확한 실패가 낫다."
checked는 보이지 않는 데이터 오염을 눈에 보이는 예외로 바꿔줍니다.
스코프 규칙 — 가장 중요한 함정
많은 개발자가 놓치는 부분입니다. checked와 unchecked는 해당 블록이나 괄호 안에 텍스트적으로(textually) 포함된 연산에만 영향을 줍니다. 메서드 호출을 통해 전파되지 않습니다 [[1]].
int Multiply(int a, int b) => a * b;
checked
{
// Multiply 내부의 a * b는 checked가 아님!
Console.WriteLine(Multiply(2, int.MaxValue)); // -2 (조용한 오버플로우)
// 이 표현식은 checked 블록 안에 텍스트적으로 존재하므로 예외 발생
Console.WriteLine(Multiply(2, 2 * int.MaxValue)); // OverflowException!
}
Raymond Chen(Microsoft 엔지니어)이 이 개념을 정확히 짚었습니다. checked/unchecked의 스코프는 동적(dynamic)이 아니라 정적(static)입니다. 특정 산술 연산이 checked인지 unchecked인지는 런타임이 아닌 컴파일 타임에 결정됩니다 [[2]].
그러면 팩토리얼 코드에서는 어떻게 동작할까요? Factorial 함수에서 checked가 number * Factorial(number - 1)을 감싸고 있습니다. 재귀 호출로 들어가면 다시 그 프레임의 checked 블록을 만나므로, 모든 재귀 단계의 곱셈이 보호됩니다. 만약 checked를 호출부에만 두었다면, 내부 곱셈은 보호되지 않았을 것입니다.
네 가지 오버플로우 컨텍스트
| 컨텍스트 | 오버플로우 동작 |
|---|---|
기본값 (unchecked) |
조용한 비트 절삭 (wraparound) |
checked 블록/표현식 |
OverflowException 발생 |
unchecked 블록/표현식 |
명시적으로 wraparound 허용 |
프로젝트 설정 <CheckForOverflowUnderflow>true |
프로젝트 전체를 checked로 설정 |
오버플로우 검사 컨텍스트를 지정하지 않으면, CheckForOverflowUnderflow 컴파일러 옵션이 기본 컨텍스트를 결정합니다. 이 옵션의 기본값은 설정되어 있지 않으며(unset), 정수 타입 산술 연산은 unchecked 컨텍스트에서 실행됩니다 [[1]][[3]].
한 가지 중요한 뉘앙스가 있습니다. 상수 표현식(constant expressions)은 기본적으로 checked 컨텍스트에서 평가되며, 오버플로우 시 컴파일 타임 오류가 발생합니다 [[1]].
// 컴파일 오류! 상수 표현식은 기본적으로 checked
const int x = int.MaxValue + 1; // CS0220
// unchecked로 명시하면 허용됨
const int y = unchecked(int.MaxValue + 1); // -2,147,483,648
영향을 받는 연산과 받지 않는 연산
checked/unchecked는 정수 타입(integral types)에 주로 적용됩니다. 공식 문서 기준으로, 내장 산술 연산자(++, --, 단항 -, +, -, *, /) 중 피연산자가 정수 타입, char 타입, 또는 enum 타입인 경우, 그리고 정수 타입 간의 명시적 변환 또는 float/double에서 정수 타입으로의 명시적 변환이 해당됩니다 [[1]].
float / double은 영향을 받지 않습니다
부동소수점 타입은 IEEE 754 표준을 따르며, 오버플로우 시 Infinity로 포화됩니다. float, double, Half은 PositiveInfinity와 NegativeInfinity라는 포화 값을 갖고 있어 unchecked 컨텍스트에서도 오버플로우를 감지할 수 있습니다 [[1]].
checked
{
double d = double.MaxValue * 2; // Infinity — 예외 없음
int i = int.MaxValue + 1; // OverflowException ✓
}
decimal은 특별합니다
decimal 값을 정수 타입으로 변환할 때 결과가 범위를 벗어나면, 오버플로우 검사 컨텍스트와 관계없이 항상 OverflowException이 발생합니다 [[1]].
실무 가이드라인
checked를 사용해야 할 때: 비즈니스 로직, 금융 계산, 카운터, 인덱스 계산 등 잘못된 답이 크래시보다 나쁜 모든 경우에 사용합니다. 위의 팩토리얼 함수가 교과서적인 예입니다.
unchecked를 사용해야 할 때: 해시 함수, CRC 계산, 의도적인 비트 조작에서 사용합니다. GetHashCode() 오버라이드가 대표적인 사례입니다 [[4]].
프로젝트 수준 설정 전략: Debug 빌드에서는 프로젝트 전체에 checked를 활성화하고, Release에서는 비활성화한 뒤, 특정 블록에서 명시적 checked/unchecked를 사용하는 것이 일반적인 관행입니다.
<!-- Debug 빌드에서만 checked 활성화 -->
<PropertyGroup Condition="'$(Configuration)' == 'Debug'">
<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>
</PropertyGroup>
성능에 대하여
checked는 현대 CPU에서 거의 제로에 가까운 성능 비용을 갖습니다. CPU는 오버플로우 플래그를 산술 연산과 동시에 설정하므로, checked는 단지 조건 점프 명령(jo — jump on overflow) 하나를 추가하여 그 플래그를 검사할 뿐입니다. 핫 패스가 아닌 코드에서 checked를 사용하지 않을 이유는 거의 없습니다.
References
- [1] Microsoft Learn — The checked and unchecked statements (C# reference)
- [2] Raymond Chen — The scope of the C# checked/unchecked keyword is static, not dynamic (The Old New Thing)
- [3] Microsoft Learn — Compiler Options: CheckForOverflowUnderflow
- [4] RipTutorial — C# Language: checked, unchecked
끝