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.
판단 도식. 출발점은 a + b 같은 int 연산. 질문: 결과가 int 에 들어가는가? 예면 정상 값. 아니오면 문맥에 따라 갈린다. int.MaxValue + 1 같은 상수식은 컴파일할 때 error CS0220. checked 문맥에서는 OverflowException. 기본값인 unchecked 문맥에서는 값이 한 바퀴 돌아 int.MaxValue + 1 이 -2147483648 이 된다.
int 결과가 범위를 넘으면 어떻게 되는가(.NET 10에서 확인). 상수식은 컴파일 오류, checked 는 예외, 기본값인 unchecked 는 조용히 한 바퀴 돈다.

컴파일러가 오버플로를 잡아 주는 것은 상수식뿐입니다. 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

눈여겨볼 점은 세 가지입니다.

  1. 시프트 횟수는 잘립니다. int·uint는 횟수의 하위 5비트만, long·ulong은 하위 6비트만 씁니다. 그래서 1 << 32는 1 << 0이고, 1L << 64도 1이었습니다. 상수로 int c = 1 << 32;를 써도 오류도 경고도 없이 1이 됩니다. 비트 폭을 변수로 받아 마스크를 만든다면 폭을 직접 검사해야 합니다.
  2. 부호 있는 형식의 >>는 산술 시프트라 부호 비트를 복사합니다. -16 >> 2는 -4입니다. C# 11에서 0을 채우는 부호 없는 오른쪽 시프트 >>>가 추가되어 -16 >>> 2는 1073741820입니다. 그 전에는 uint로 캐스트해야 했습니다.
  3. ~는 모든 비트를 뒤집습니다. ~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일 때만 대입합니다. 지연 초기화의 관용구입니다.

우선순위 함정

*가 +보다 먼저인 것처럼 대부분의 우선순위는 수학과 같아서 문제가 되지 않습니다. 버그를 만드는 것은 우선순위가 낮은 연산자들입니다.

우선순위 순서로 상자 12개를 이은 그림: 기본(x.y, x?.y, f(x), a[i], x++) → 단항(!, ~, -x, ++x, 캐스트) → 곱셈류(*, /, %) → 덧셈류(+, -) → 시프트(<<, >>, >>>) → 관계·형식 검사(<, >, <=, >=, is, as) → 같음(==, !=) → 비트 AND, XOR, OR 순 → 조건 AND, 조건 OR 순 → null 병합(??) → 조건 연산자(c ? a : b) → 대입과 람다(=, +=, ??=, =>).
C# 연산자 우선순위. 왼쪽 위가 가장 강하고 오른쪽 아래가 가장 약하다. 화살표 앞쪽 연산자가 먼저 피연산자와 묶인다.
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이 나옵니다.

  • #C#
  • #연산자
  • #Operators
  • #오버플로
  • #checked

t.laboratory

직접 만들고 운영하는 앱·서버·서비스에서 겪은 문제와, 그 해결에 쓴 코드를 기록하는 기술 블로그입니다.

운영자 소개 · 앱

↑