C# 연산자: 오버플로·?? ·음수 %·시프트 함정을 .NET 10에서 확인
C# 산술·관계·논리·비트 연산자를 .NET 10에서 재검증. 오버플로와 checked, ??·??=, 음수 나머지, 정수 나눗셈, 시프트 횟수 마스킹을 실제 출력과 오류 코드로 정리하고 checked 비용도 측정.
2026년 11월 보강: 2021년 연산자 강좌의 예제를 .NET 10.0.14(C# 14)에서 모두 다시 컴파일했고, 결과는 2021년과 똑같았습니다. 2021년 글에 빠져 있던 것은 실무에서 연산자가 문제를 일으키는 지점입니다. 조용히 한 바퀴 도는 정수 오버플로, 음수와
%,1 << 32,??와 문자열 연결의 우선순위 함정을 실제 출력과 함께 추가했습니다. 원문 표에--,-대신—,–가 찍혀 있던 것은 블로그 편집기의 문자 변환 탓이었고, 아래 코드는 실제 문자로 고쳤습니다.
이 글은 C# 입문자, 그리고 기본 연산자는 알지만 "오류 없이 틀린 숫자를 돌려주는 연산자가 무엇인지" 알고 싶은 개발자를 위한 글입니다. 결론부터 말하면, 정수 +, -, *는 checked를 쓰지 않는 한 넘치면 한 바퀴 돌고, 정수끼리 /는 소수점을 버리며, %는 왼쪽 피연산자의 부호를 따르고, int의 시프트 횟수는 5비트로 잘립니다. 하나씩 실제 출력으로 보이고, checked의 비용도 측정했습니다.
산술·증감·할당 연산자: 2021년 예제를 .NET 10에서
연산자 분류는 2021년 표와 같습니다. 산술(+ - * / %), 증감(++ --), 할당(= += -= *= /= %= 등), 논리(&& || !), 관계(< > == != >= <=), 비트(& | ^)이고, 원문 표에 없던 ~ << >> >>>도 비트 연산자입니다.
원문의 산술 예제를 최상위 문으로 옮기면 다음과 같습니다.
int a = 55, b = 6;
Console.WriteLine(a + b);
Console.WriteLine(a - b);
Console.WriteLine(a * b);
Console.WriteLine(a / b);
Console.WriteLine(a % b);
61
49
330
9
1
증감 예제도 2021년과 같은 네 줄을 출력했습니다.
int a = 1;
Console.WriteLine(++a); // 2
Console.WriteLine(a++); // 2, 그다음 a는 3
Console.WriteLine(--a); // 2
Console.WriteLine(a--); // 2, 그다음 a는 1
원문은 전위형이 "문장을 실행하기 전에", 후위형이 "문장을 실행한 뒤에" 값을 바꾼다고 설명했습니다. 더 정확히 말하면 두 형식 모두 그 자리에서 바로 값을 바꾸고, 차이는 식의 값입니다. ++a는 바뀐 뒤의 값, a++는 바뀌기 전의 값이 됩니다. 이 차이는 i = i++ + ++i; 같은 코드에서 드러나는데, 5에서 시작하면 12(5 + 7)가 나옵니다. 이런 코드는 줄을 나눠 쓰는 것이 맞습니다.
할당 예제도 110 50 3000 50 50으로 같았습니다. 복합 할당에는 알아 둘 점이 하나 있습니다. byte 변수에 b += 1은 컴파일되지만 b = b + 1은 error CS0266입니다. b + 1은 int인데 복합 할당은 캐스트를 자동으로 넣어 주기 때문입니다. 그 캐스트가 오버플로도 숨깁니다. 255가 든 byte에 b++를 하면 0이 됩니다.
정수 나눗셈, %, 그리고 음수
정수끼리 나누면 결과도 정수이고, 소수점 아래는 0 방향으로 버려집니다.
int seven = 7;
Console.WriteLine($"{seven / 2} {-seven / 2} {seven / 2.0} {(double)seven / 2} {(double)(seven / 2)}");
3 -3 3.5 3.5 3
마지막 값이 흔한 버그입니다. (double)(seven / 2)는 정수 나눗셈을 먼저 하고 그 결과 3을 캐스트합니다. 결과가 아니라 피연산자 하나를 캐스트해야 합니다.
나눗셈이 0 방향으로 버리므로 %의 결과는 왼쪽 피연산자의 부호를 따릅니다.
-7 % 3 = -1
7 % -3 = 1
-7 % -3 = -1
5.5 % 2 = 1.5
-5.5 % 2 = -1.5
Python에서는 -7 % 3이 2지만 C#은 C, Java, JavaScript와 같이 -1입니다. 실무에서 생기는 문제는 두 가지입니다.
x % 2 == 1은 음수에서 홀수 판정이 안 됩니다.-7 % 2는-1입니다.x % 2 != 0이나int.IsOddInteger(x)(.NET 7 이상)를 쓰세요. 2021년 if·switch 글의switch (a % 2)예제도a = -13이면 어느 case에도 걸리지 않았습니다.- 원형 버퍼처럼 인덱스를 감아야 하면
((i % n) + n) % n을 씁니다.-7과3이면2가 나왔습니다.
0으로 나누기는 형식에 따라 다릅니다. int 변수로 7 / 0을 하면 DivideByZeroException: Attempted to divide by zero.이 발생하고, 상수끼리면 컴파일러가 error CS0020: Division by constant zero로 막습니다. double은 예외 없이 1.0 / 0이 ∞, 0.0 / 0이 NaN이었습니다. 하나 더, int.MinValue / -1과 int.MinValue % -1은 둘 다 OverflowException이었습니다. 수학적으로 맞는 결과 2,147,483,648이 int에 들어가지 않기 때문입니다.
오버플로: checked, unchecked, 그리고 컴파일러
실무 버그로 가장 자주 이어지는 연산자 동작입니다. C#의 정수 연산은 기본이 unchecked라서, 결과가 형식 너비에 맞게 잘리고 아무것도 알려 주지 않습니다.
int max = int.MaxValue;
Console.WriteLine(max + 1);
Console.WriteLine(unchecked(max + 1));
Console.WriteLine(checked(max + 1));
-2147483648
-2147483648
Unhandled exception. System.OverflowException: Arithmetic operation resulted in an overflow.
컴파일러가 오버플로를 잡아 주는 것은 상수식뿐입니다. int b = int.MaxValue + 1;은 error CS0220: The operation overflows at compile time in checked mode이고, unchecked(...)로 감싸면 컴파일되어 한 바퀴 돈 값이 들어갑니다.
실제로 자주 보는 형태는 long에 담기 전에 이미 넘치는 경우입니다.
int count = 50_000, size = 50_000;
long wrong = count * size; // int * int 후에 long으로 넓힘
long right = (long)count * size;
Console.WriteLine($"{wrong} {right}");
-1794967296 2500000000
테스트하면서 확인한 다른 오버플로 사례입니다.
| 식 | .NET 10 결과 |
|---|---|
(int)3_000_000_000L |
-1294967296(상위 비트 버림) |
(int)3e10(double → int) |
2147483647(포화) |
(int)double.NaN |
0 |
checked((int)3e10) |
OverflowException |
Math.Abs(int.MinValue) |
OverflowException: Negating the minimum value of a twos complement number is invalid. |
-int.MinValue |
-2147483648 |
double → int 행은 주의가 필요합니다. .NET 9부터 범위를 벗어난 부동소수점 → 정수 변환이 모든 플랫폼에서 포화(saturate)하도록 바뀌어서, 예전 글에서 -2147483648 같은 엉뚱한 값을 보여 줬다면 그것은 바뀌기 전의 x86/x64 동작입니다. (int)double.NaN을 상수로 직접 쓰면 컴파일러가 error CS0221: Constant value 'NaN' cannot be converted to a 'int' (use 'unchecked' syntax to override)를 냅니다.
실측: checked는 얼마나 느린가
checked를 피하는 흔한 이유는 속도입니다. 원소 100만 개짜리 int 배열을 300번 합산(덧셈 3억 번)하는 루프를 unchecked(s + d[i])와 checked(s + d[i])로 각각 [MethodImpl(NoInlining)] 메서드에 넣고, Release 빌드, Apple Silicon Mac(M1 Pro)에서 워밍업 1회 후 5회 측정했습니다.
| 루프 | 1회 | 2회 | 3회 | 4회 | 5회 |
|---|---|---|---|---|---|
unchecked int 합 |
129.2ms | 128.3ms | 109.3ms | 106.2ms | 107.5ms |
checked int 합 |
133.7ms | 102.2ms | 99.6ms | 97.0ms | 99.1ms |
이 루프에서는 오버플로 검사 비용이 측정 오차에 묻혔습니다. 덧셈마다 붙는, 한 번도 실행되지 않는 조건 분기 하나는 메모리 읽기에 비하면 거의 공짜입니다. 모든 루프에서 공짜라고 단정하지는 않겠지만, "느리니까"는 checked를 빼는 기본 이유가 되기 어렵습니다. 금액·크기·ID를 세는 코드라면 프로젝트 전체에 <CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>를 켜고, 해시 함수처럼 한 바퀴 도는 것이 의도된 곳에만 unchecked를 쓰는 방법을 고려해 볼 만합니다.
비트 연산자와 시프트 횟수 함정
원문의 비트 예제는 지금도 5 & 10, 5 | 10, 5 ^ 10에 대해 0, 15, 15를 출력합니다. 원문에서 다루지 않은 연산자는 다음과 같습니다.
int one = 1, s32 = 32, s33 = 33, neg = -16;
Console.WriteLine($"{one << 31} {one << s32} {one << s33} {1L << s32}");
Console.WriteLine($"{neg >> 2} {neg >>> 2} {~5} {5 & ~1}");
-2147483648 1 2 4294967296
-4 1073741820 -6 4
눈여겨볼 점은 세 가지입니다.
- 시프트 횟수는 잘립니다.
int·uint는 횟수의 하위 5비트만,long·ulong은 하위 6비트만 씁니다. 그래서1 << 32는1 << 0이고,1L << 64도1이었습니다. 상수로int c = 1 << 32;를 써도 오류도 경고도 없이1이 됩니다. 비트 폭을 변수로 받아 마스크를 만든다면 폭을 직접 검사해야 합니다. - 부호 있는 형식의
>>는 산술 시프트라 부호 비트를 복사합니다.-16 >> 2는-4입니다. C# 11에서 0을 채우는 부호 없는 오른쪽 시프트>>>가 추가되어-16 >>> 2는1073741820입니다. 그 전에는uint로 캐스트해야 했습니다. ~는 모든 비트를 뒤집습니다.~5는-6,x & ~1은 최하위 비트를 지웁니다.
bool에 쓰는 &, |는 양쪽을 항상 평가하는 논리 연산자이고, &&, ||는 단락 평가를 합니다. 호출 횟수를 세는 Hit() 메서드로 확인하면 false && Hit() 뒤에는 0회, false & Hit() 뒤에는 1회였습니다.
null 연산자: ??, ??=, ?.
2021년 입문 강좌들이 기준으로 삼던 문법 이후에 추가됐지만, 지금은 거의 모든 코드에 등장하는 연산자입니다.
string? name = null;
Console.WriteLine(name ?? "guest"); // guest
Console.WriteLine(name?.Length); // 빈 줄(null)
int? len = name?.Length; // int?, HasValue = False
List<int>? cache = null;
cache ??= new List<int>(); // null일 때만 대입(C# 8)
cache.Add(1);
cache ??= new List<int> { 9, 9 }; // null이 아니므로 무시
Console.WriteLine(cache.Count); // 1
a ?? b는a가 null이 아니면a를 돌려줍니다. null이 될 수 없는 값 형식에 쓰면error CS0019: Operator '??' cannot be applied to operands of type 'int' and 'int'입니다.x?.Member,arr?[0]은 왼쪽이 null이면 예외 대신 null을 돌려줍니다. 결과 형식도 nullable이 되어name?.Length는int?입니다.x ??= y(C# 8)는x가 null일 때만 대입합니다. 지연 초기화의 관용구입니다.
우선순위 함정
*가 +보다 먼저인 것처럼 대부분의 우선순위는 수학과 같아서 문제가 되지 않습니다. 버그를 만드는 것은 우선순위가 낮은 연산자들입니다.
int? x = 1; int y = 10, z = 5;
Console.WriteLine(x ?? y + z); // 1 (x ?? (y + z))
Console.WriteLine((x ?? y) + z); // 6
Console.WriteLine("Sum: " + 1 + 2); // Sum: 12
Console.WriteLine($"Sum: {1 + 2}"); // Sum: 3
Console.WriteLine(1 + 2 << 1); // 6 (+가 <<보다 먼저)
bool t = true, f = false;
Console.WriteLine(t || f && f); // True (&&가 ||보다 먼저)
??는 산술 연산자보다 약합니다.count ?? 0 + 1은(count ?? 0) + 1이 아니라count ?? 1입니다.- 왼쪽에 문자열이 있으면 이어지는
+가 왼쪽부터 모두 문자열 연결이 됩니다. 문자열 보간을 쓰세요. x & 1 == 0은(x & 1) == 0이 아닙니다.==가 더 강해서x & (1 == 0)이 되고error CS0019: Operator '&' cannot be applied to operands of type 'int' and 'bool'이 납니다.(x & 1) == 0으로 써야 합니다.&&는||보다 강합니다. 괄호 없이 섞어도 문법상 문제는 없지만 리뷰하기 어렵습니다.
헷갈리면 괄호를 넣으세요. 괄호는 컴파일러가 지우고, 리뷰어는 고마워합니다.
정리: 상황별 대응
| 상황 | 대응 |
|---|---|
| 결과가 형식 범위를 넘을 수 있음 | checked(...) 또는 <CheckForOverflowUnderflow>, 곱하기 전에 넓히기((long)a * b) |
| 정수의 평균·비율 | 피연산자 하나를 캐스트: (double)sum / count |
| 홀짝 판정 | x % 2 != 0 또는 int.IsOddInteger(x)(== 1 금지) |
| 감기는 인덱스 | ((i % n) + n) % n |
| 폭이 변수인 비트 마스크 | long을 시프트(1L << n)하고 n 검사 |
int의 부호 없는 오른쪽 시프트 |
>>>(C# 11) |
| null 기본값 | ??, 지연 초기화는 ??= |
??, +, &, ==를 섞을 때 |
괄호 |
형식과 범위는 이전 단계인 C# 변수·데이터 형식·상수에서, 관계·논리 연산자의 실제 쓰임은 if와 switch에서, OverflowException 같은 예외 처리는 C# 예외 처리에서 다룹니다.
테스트 환경: .NET SDK 10.0.403 / 런타임 10.0.14, C# 14(net10.0 기본값), macOS, Apple Silicon Mac(M1 Pro).
자주 묻는 질문
C#에서 int.MaxValue + 1이 음수가 되는 이유는 무엇인가요?
정수 연산은 기본이 unchecked라서 범위를 넘은 결과가 한 바퀴 돕니다. .NET 10에서 int.MaxValue + 1은 -2147483648이었습니다. checked(...)로 감싸거나 프로젝트에 <CheckForOverflowUnderflow>를 켜면 OverflowException이 발생합니다. 컴파일 시점 상수식이면 컴파일러가 error CS0220으로 막습니다.
C#에서 -7 % 3이 2가 아니라 -1인 이유는 무엇인가요?
C#의 정수 나눗셈은 0 방향으로 버리기 때문에(-7 / 3은 -2) 나머지는 왼쪽 피연산자의 부호를 따릅니다. 실측 결과 -7 % 3은 -1, 7 % -3은 1이었습니다. 항상 0~n-1 사이 값이 필요하면 ((a % n) + n) % n을 씁니다.
int에서 1 << 32가 1이 되는 이유는 무엇인가요?
int와 uint는 시프트 횟수의 하위 5비트만 씁니다(long·ulong은 하위 6비트). 32 & 31은 0이라 1 << 32는 1 << 0 = 1이고, 1 << 33은 2입니다. 상수 1 << 32로 써도 컴파일러 경고가 없습니다. 64비트 결과가 필요하면 1L << 32처럼 long을 시프트하면 4294967296이 나옵니다.