[PART3.연산자와 표현식(1/11)] 산술·관계·논리 연산자 — 가장 흔하게 밟는 입문자 함정 정리
정수 나눗셈의 truncation / % 의 부호 규칙 / 부동소수점·NaN 비교 / == 오버로딩 / 단락 평가 / Unity Fake Null 까지
목차
1. 문제 제기
하루 종일 잘 돌아가던 Unity 프로젝트에서 다음과 같은 버그가 하나씩 올라옵니다.
- 아이템 10개를 4명이 나눠 가지는 코드
int share = items / players;가2.5가 아니라2를 반환해서 아이템이 두 개 증발한다. Vector3위치 비교를transform.position == target으로 했는데 플레이어가 목표점에 도착해도 조건이true가 되지 않는다.Destroy(enemy)호출 직후에enemy?.TakeDamage()를 안전하게 호출했다고 생각했는데MissingReferenceException이 터진다.- 저장 시스템이
if (user != null & user.Name.Length > 0)을 통과하다가 첫 로그인 사용자에게NullReferenceException을 던진다.
네 버그의 공통점은 전부 연산자에 있습니다. 문법이 틀린 게 아니라 연산자가 IL(Intermediate Language, C# 컴파일러가 생성하는 중간 언어) 수준에서 무엇을 하는지를 몰라서 생긴 오해입니다. 산술·관계·논리 연산자는 C# 시험문제에서만큼은 누구나 답을 맞히지만, 실무에서는 이 짧은 기호 하나가 프로젝트 절반을 무너뜨립니다.
이 글은 C#의 산술 연산자(+ - * / %), 관계·동등 연산자(== != < > <= >=), 논리 연산자(&& || !)를 대상으로 합니다. 문법 설명은 빠르게 넘기고, 입문자가 실무에서 반드시 밟게 되는 함정 10가지와 그 함정이 생기는 이유를 CLR/IL 수준에서 추적합니다. 마지막은 Unity에서만 일어나는 고유 함정(Fake Null, Vector3 ==)까지 이어집니다.
2. 개념 정의
2.1 연산자는 함수처럼 취급하라
C#의 연산자는 수학 기호가 아니라 컴파일러가 특정 IL 명령어 또는 정적 메서드 호출로 바꿔주는 문법 설탕입니다. 같은 ==라도 타입에 따라 다른 IL이 생성됩니다. 이 사실 하나만 기억해도 함정의 90%는 피할 수 있습니다.

2.2 세 카테고리 한눈 보기
| 카테고리 | 연산자 | 반환 타입 | 오버로딩 가능 |
|---|---|---|---|
| 산술 | + - * / % |
숫자 | 예 |
| 관계·동등 | == != < > <= >= |
bool |
예 (단, ==/!=, </>, <=/>=는 쌍으로) |
| 논리 | && || ! |
bool |
&&, ||는 간접적으로만 (후술) |
연산자 오버로딩 (Operator overloading) 타입이 자기 자신에 대한 연산자의 동작을 재정의하는 기능. C#에서는public static T operator +(T a, T b)형식의 정적 메서드로 선언한다. 예를 들어string의==는 내부 문자의 값 비교로 오버로딩되어 있고, Unity의Vector3.==는 거리 제곱 비교로 오버로딩되어 있다.
2.3 기본 산술 — 가장 먼저 만나는 함정
// Unity 시나리오: 파티 4명이 아이템 10개를 나눈다
int items = 10;
int players = 4;
int share = items / players; // 2 — 0.5 는 조용히 사라진다
"당연히 2.5 아닌가?"라고 기대하면 버그입니다. 정수끼리의 나눗셈은 소수부를 잘라버립니다(truncation, 내림이 아님). -7 / 2는 수학적으로 기대하는 -4(내림, floor)가 아니라 -3 (0 방향으로 잘림)이 됩니다.
참고: 언어 스펙 기준 C#/는 정수형에서 "0 쪽으로 버림(truncated toward zero)"이다.Math.Floor와 달리 음수에서 결과가 다르다.-7 / 2→-3,Math.Floor(-7.0 / 2)→-4.
int share1 = items / players; // 2
double share2 = (double)items / players; // 2.5
double share3 = items / (double)players; // 2.5
double share4 = items / players; // 2.0 (먼저 int 나눗셈 뒤 double 승격 — 버그!)
share4가 왜 2.0이냐면, items / players가 먼저 int 나눗셈으로 2가 된 뒤에 double로 확장되기 때문입니다. 피연산자 중 최소 하나가 double/float/decimal 이어야 비로소 부동소수점 나눗셈이 됩니다.
다음 절에서 이걸 IL로 직접 확인합니다.
3. 내부 동작 — IL 수준에서 본 연산자
3.1 산술 연산자의 IL 매핑

실제 IL을 봅니다. 아래는 /il-analysis로 실제 컴파일한 결과입니다.
public static int IntDivide(int a, int b) => a / b;
public static double DoubleDivide(int a, int b) => (double)a / b;
public static int Remainder(int a, int b) => a % b;
public static int UncheckedAdd(int a) => a + 1;
public static int CheckedAdd(int a) { checked { return a + 1; } }
.method public hidebysig static int32 IntDivide (int32 a, int32 b) cil managed
{
.maxstack 2
.locals init ([0] int32)
IL_0000: nop
IL_0001: ldarg.0 // 인자 a를 스택에 푸시
IL_0002: ldarg.1 // 인자 b를 스택에 푸시
IL_0003: div // 정수 나눗셈 — 소수부 truncation
IL_0004: stloc.0
IL_0007: ldloc.0
IL_0008: ret
}
.method public hidebysig static float64 DoubleDivide (int32 a, int32 b) cil managed
{
.maxstack 2
.locals init ([0] float64)
IL_0000: nop
IL_0001: ldarg.0
IL_0002: conv.r8 // int → double 로 변환 (명시적 (double) 캐스트)
IL_0003: ldarg.1
IL_0004: conv.r8 // 다른 피연산자도 double 로 승격
IL_0005: div // 부동소수점 나눗셈
IL_0006: stloc.0
IL_0009: ldloc.0
IL_000a: ret
}
.method public hidebysig static int32 Remainder (int32 a, int32 b) cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: rem // 나머지 연산 — 결과의 부호는 피제수 a 를 따름
IL_0004: stloc.0
IL_0008: ret
}
.method public hidebysig static int32 UncheckedAdd (int32 a) cil managed
{
IL_0001: ldarg.0
IL_0002: ldc.i4.1
IL_0003: add // 기본 add — 오버플로 시 Wraparound
IL_0004: stloc.0
IL_0008: ret
}
.method public hidebysig static int32 CheckedAdd (int32 a) cil managed
{
IL_0002: ldarg.0
IL_0003: ldc.i4.1
IL_0004: add.ovf // checked 블록 — 오버플로 시 OverflowException
IL_0005: stloc.0
IL_0009: ret
}
IL 분석 포인트
1. div는 피연산자 타입에 따라 의미가 달라진다.
같은 div 명령어지만 스택에 int32가 올라오면 정수 나눗셈, float64가 올라오면 부동소수점 나눗셈입니다. 피연산자가 int인데 결과를 double에 담아도 IL은 이미 div로 정수 나눗셈을 한 뒤에야 conv.r8이 옵니다. 따라서 "결과 변수 타입"이 아니라 "피연산자 타입"이 결과를 좌우합니다.
2. DoubleDivide는 conv.r8이 div 앞에 붙는다.
(double)a가 IL에서 conv.r8로 번역되고, 이 덕분에 뒤의 div는 부동소수점 나눗셈이 됩니다. 한 줄짜리 C# 코드에서 캐스트 위치가 달라지면 IL 순서가 달라지고 결과가 달라집니다. Unity 스탯 계산에서 (float)currentHp / maxHp로 비율을 구할 때 캐스트 위치를 헷갈리면 항상 0이 나옵니다.
3. add와 add.ovf는 단 세 글자 차이지만 런타임 의미는 전혀 다르다.
checked 블록은 IL을 add.ovf로 바꿔 오버플로 시 OverflowException을 던집니다. Unity Android/iOS에서 IL2CPP(Unity의 AOT 컴파일러)는 add.ovf를 C++ 코드의 오버플로 체크로 번역하므로, 성능이 중요한 Update 핫패스에서 불필요한 checked를 남발하면 모바일 CPU에서 분기 예측 실패가 누적됩니다. 반대로 골드/경험치 같은 누적 값에는 일부러 checked를 써서 오버플로 버그를 폭파시키는 편이 안전합니다.
3.2 ==의 세 얼굴 — 같은 기호, 다른 IL
public static bool IntEquals(int a, int b) => a == b;
public static bool StringEquals(string a, string b) => a == b;
public static bool ObjectBoxingEquals()
{
object a = 1;
object b = 1;
return a == b;
}
.method public hidebysig static bool IntEquals (int32 a, int32 b) cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: ceq // int == int → 단일 CPU 비교 명령어
IL_0005: stloc.0
IL_0009: ret
}
.method public hidebysig static bool StringEquals (string a, string b) cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: call bool [System.Runtime]System.String::op_Equality(string, string)
// string.== 는 오버로딩된 정적 메서드 호출
IL_0008: stloc.0
IL_000c: ret
}
.method public hidebysig static bool ObjectBoxingEquals () cil managed
{
.locals init ([0] object, [1] object, [2] bool)
IL_0001: ldc.i4.1
IL_0002: box [System.Runtime]System.Int32 // int 1 을 힙에 박싱 (박스 A)
IL_0007: stloc.0
IL_0008: ldc.i4.1
IL_0009: box [System.Runtime]System.Int32 // int 1 을 또 힙에 박싱 (박스 B)
IL_000e: stloc.1
IL_000f: ldloc.0
IL_0010: ldloc.1
IL_0011: ceq // 박스 A 와 박스 B 의 주소를 비교 → false
IL_0013: stloc.2
IL_0017: ret
}
IL 분석 포인트
1. int == int는 IL 명령어 하나(ceq)로 끝난다.
값 타입 비교는 CLR이 직접 CPU 비교 명령어로 번역하므로 극도로 빠릅니다. 메서드 호출 비용도, 가상 디스패치도 없습니다.
2. string == string은 call로 정적 메서드를 호출한다.
op_Equality는 string 타입이 정의한 == 오버로드입니다. 컴파일 타임에 피연산자의 정적 타입이 string이라서 컴파일러가 이 메서드 호출로 바꿔줍니다. 내부 구현은 문자별 비교(Ordinal)로 되어 있어 "내용이 같다"를 판별합니다.
3. 박싱된 object끼리 ==는 참조 비교로 떨어진다.
object a = 1은 값 타입 int를 힙의 "박스"에 포장합니다. box 명령어가 매번 새 힙 할당을 일으킵니다. 이후 a == b는 정적 타입이 object이므로 op_Equality 오버로드가 존재하지 않고 평범한 ceq — 즉 박스 주소끼리의 비교입니다. 두 박스는 서로 다른 힙 객체이므로 결과는 false. Unity Update 루프에서 object 파라미터로 int를 넘기는 이벤트 시스템을 만들면 매 프레임 박싱이 발생해 GC 스파이크를 유발합니다.
3.3 && (단락 평가) 대 & (전체 평가)
public static bool ShortCircuit(string? s) => s != null && s.Length > 0;
public static bool FullEval(string? s) => s != null & s.Length > 0;
.method public hidebysig static bool ShortCircuit (string s) cil managed
{
IL_0001: ldarg.0
IL_0002: brfalse.s IL_000f // s 가 null 이면 오른쪽 식을 평가하지 않고 점프
IL_0004: ldarg.0
IL_0005: callvirt instance int32 [System.Runtime]System.String::get_Length()
IL_000a: ldc.i4.0
IL_000b: cgt // length > 0
IL_000d: br.s IL_0010
IL_000f: ldc.i4.0 // 점프 도착지: false 를 스택에 푸시
IL_0010: stloc.0
IL_0014: ret
}
.method public hidebysig static bool FullEval (string s) cil managed
{
IL_0001: ldarg.0
IL_0002: ldnull
IL_0003: cgt.un // s != null 계산 (unsigned 비교로 참조 비교)
IL_0005: ldarg.0
IL_0006: callvirt instance int32 [System.Runtime]System.String::get_Length()
// ★ s 가 null 이어도 무조건 호출 → NRE
IL_000b: ldc.i4.0
IL_000c: cgt
IL_000e: and // 두 bool 을 비트 AND
IL_000f: stloc.0
IL_0013: ret
}
IL 분석 포인트
1. &&는 brfalse 분기 명령어로 구현된다.
왼쪽 피연산자가 false면 brfalse.s IL_000f로 점프해서 오른쪽 식(s.Length > 0) 자체를 평가하지 않습니다. s가 null일 때 get_Length()가 호출될 일이 없으므로 NullReferenceException이 안 납니다.
2. &는 양쪽을 먼저 계산한 뒤 and로 합친다.
cgt.un(null 비교)으로 왼쪽을 계산한 뒤 곧바로 callvirt get_Length()가 나옵니다. s가 null이라면 이 callvirt에서 NullReferenceException이 터집니다. &를 bool에 쓰면 단락 평가의 안전망이 완전히 사라집니다.
3. callvirt는 수신자의 null 을 검사한 뒤 호출한다.
callvirt는 메서드 호출 전에 "수신자 객체가 null 이 아닌가"를 CLR 차원에서 확인합니다. null 이면 NullReferenceException. 이 점이 & 사용 시 NRE가 결정적으로 발생하는 이유입니다. call(정적)이라면 상황이 다르겠지만, 인스턴스 메서드는 대부분 callvirt입니다.
4. 실전 적용 — Before / After 로 보는 10대 함정
앞에서 메커니즘을 봤으니 이제 실전에서 어떻게 쓰는지 짚습니다. Before(잘못된 패턴) → After(올바른 패턴)입니다.
4.1 정수 나눗셈과 비율 계산
// Unity 상황: HP 바를 비율로 표시한다
public class HealthBar : MonoBehaviour
{
public int currentHp, maxHp;
// ❌ Before — 항상 0 또는 1만 나온다
public float Ratio() => currentHp / maxHp;
// ✅ After — 최소 한쪽은 float 로 만든다
public float RatioFixed() => (float)currentHp / maxHp;
}
currentHp가 30, maxHp가 100이면 Before는 30 / 100 = 0 → float으로 승격 → 0f. After는 30f / 100 = 0.3f. IL은 앞에서 본 IntDivide 대 DoubleDivide 차이 그대로입니다.
4.2 나머지 연산자의 부호 — 순환 버퍼 주의
// ❌ Before — i 가 음수면 음수 인덱스가 나와 IndexOutOfRangeException
int Wrap(int i, int n) => i % n;
// ✅ After — 항상 [0, n) 범위로 맞춘다
int WrapFixed(int i, int n) => ((i % n) + n) % n;
Wrap(-1, 10)은 -1입니다. C# %는 피제수의 부호를 따르므로 수학의 modulo가 아닙니다. 원형 배열·링버퍼·tiled map의 좌표 환산처럼 음수가 섞일 수 있는 상황에서는 반드시 ((i % n) + n) % n 패턴을 씁니다.
4.3 부동소수점 비교 — 절대로 == 를 쓰지 않는다
// ❌ Before — 0.1 + 0.2 는 0.30000000000000004 라서 false 가 나온다
if (a + b == c) { ... }
// ✅ After — 허용 오차(epsilon) 와 비교
static bool NearlyEquals(double x, double y, double eps = 1e-9)
=> Math.Abs(x - y) < eps;
if (NearlyEquals(a + b, c)) { ... }
Unity에서 단일 float 비교는 Mathf.Approximately(a, b)를 쓰면 상대 오차 기반으로 처리됩니다. 단, Mathf.Approximately는 a, b가 0 근처일 때 매우 엄격합니다. 물리 시뮬레이션 임계값은 도메인에 맞는 epsilon을 직접 정하는 편이 낫습니다.
4.4 NaN 탐지 — == 로는 안 된다
double.NaN— Not-a-Number0.0 / 0.0이나Math.Sqrt(-1)같은 정의되지 않은 실수 연산 결과. IEEE 754 규격상 NaN과의 모든 비교(==,<,>)는false, 유일하게!=만true가 된다.
double value = ComputeSomething();
// ❌ Before — NaN == NaN 은 항상 false 이므로 절대 참이 되지 않는다
if (value == double.NaN) { HandleNaN(); }
// ✅ After — 전용 메서드를 쓴다
if (double.IsNaN(value)) { HandleNaN(); }
보너스 팁: x != x가 true면 x는 NaN입니다. 이 한 줄짜리 트릭으로 NaN을 빠르게 탐지할 수 있지만, 가독성을 위해 double.IsNaN을 권장합니다. Unity에서는 Mathf.Abs(float.NaN) 같은 연산이 조용히 NaN을 퍼뜨리므로, 네트워크로 받은 위치값·입력값은 항상 IsNaN으로 검증한 뒤 사용합니다.
4.5 박싱된 값 타입의 == 트랩
// ❌ Before — event bus 가 object 로 값을 전달한다
public void Publish(object payload1, object payload2)
{
if (payload1 == payload2) { ... } // int 를 넘기면 늘 false
}
// ✅ After — 제네릭으로 박싱 자체를 막는다
public void Publish<T>(T payload1, T payload2) where T : IEquatable<T>
{
if (payload1.Equals(payload2)) { ... } // int.Equals 는 박싱 없음
}
제네릭 + IEquatable<T> 조합은 IL에서 constrained. 접두사가 붙은 메서드 호출로 번역되어 박싱이 사라집니다. Unity에서 C# 이벤트를 제네릭으로 뽑기 어려우면, 적어도 비교는 EqualityComparer<T>.Default.Equals(a, b)로 대체합니다.
4.6 문자열 비교 — Ordinal을 명시한다
// ❌ Before — 현재 Culture 에 의존한다 (터키어 로케일에서 "i" != "I")
if (filename.ToUpper() == "SAVE.DAT") { ... }
// ✅ After — Ordinal 명시, ToUpper 도 피한다
if (string.Equals(filename, "SAVE.DAT", StringComparison.OrdinalIgnoreCase)) { ... }
ToUpper()는 string을 매 호출마다 새로 할당합니다. Unity Update 루프에서 문자열 태그를 비교할 때 gameObject.tag == "Enemy"는 매 프레임 문자열 비교를 수행하지만, gameObject.CompareTag("Enemy")는 내부에서 해시 비교를 써서 더 빠릅니다.
StringComparison— 문자열 비교 규칙을 지정하는 열거형Ordinal/OrdinalIgnoreCase는 바이트 단위 비교(빠르고 예측 가능).CurrentCulture/InvariantCulture는 언어 규칙 기반 비교(정렬·UI 표시용). 프로그램 내부 식별자(파일 경로, JSON 키, HTTP 헤더, 태그 문자열 등)는 예외 없이Ordinal이다.
4.7 && 대신 &를 쓴 NRE
// ❌ Before — & 는 양쪽을 모두 평가 → user 가 null 이면 NRE
if (user != null & user.Name.Length > 0) { SaveProfile(user); }
// ✅ After — && 는 단락 평가, user 가 null 이면 오른쪽을 평가하지 않음
if (user != null && user.Name.Length > 0) { SaveProfile(user); }
IL로 보면 &는 callvirt get_Name()을 먼저 호출하고 나서 and를 수행합니다. &&는 brfalse로 즉시 점프해서 오른쪽 접근 자체를 막습니다. "양쪽을 반드시 실행해야 하는" 예외적 상황(예: 두 함수가 부수 효과를 반드시 가져야 함)이 아니면 bool에 &/|를 쓸 일은 없습니다.
4.8 연산자 우선순위 + 숫자 승격
int max = int.MaxValue;
// ❌ Before — 앞의 max + max 가 먼저 int 오버플로를 일으키고 그 뒤 long 으로 캐스팅
long r1 = max + max + 1L; // -1
// ✅ After — 1L 이 먼저 long 으로 승격을 끌어올려 오버플로 없음
long r2 = max + 1L + max; // 4294967295 (= 2 * int.MaxValue + 1)
+는 왼쪽→오른쪽으로 평가되고, 숫자 승격은 각 이항 연산마다 개별 적용됩니다. 큰 숫자를 다룰 때는 우선순위보다 승격을 일으키는 리터럴(1L, 1UL)을 왼쪽으로 끌어올립니다.
4.9 비교 연산자 체이닝은 없다
// ❌ Before — 컴파일 에러: bool < int 는 불가능
if (0 < x < 10) { ... }
// ✅ After — && 로 두 조건을 잇는다
if (0 < x && x < 10) { ... }
수학 표기와 C# 문법은 다릅니다. (0 < x)의 결과 bool에 다시 < 10을 적용하려는 시도는 컴파일 에러입니다. Range 패턴(is >= 0 and < 10)을 써도 좋습니다(C# 9.0+).
4.10 사용자 정의 타입의 == 오버로드
값 동등성을 원한다면 ==와 Equals, GetHashCode를 함께 오버로드해야 합니다. 하나만 바꾸면 HashSet·Dictionary가 깨집니다.
// ❌ Before — == 만 오버로드, Equals/GetHashCode 는 방치
public class Coord
{
public int X, Y;
public static bool operator ==(Coord a, Coord b) => a.X == b.X && a.Y == b.Y;
public static bool operator !=(Coord a, Coord b) => !(a == b);
}
// → HashSet<Coord> 에 같은 좌표를 넣어도 중복 감지 실패
// ✅ After — record 로 대체하면 컴파일러가 다 만들어준다
public record Coord(int X, int Y);
// → == / Equals / GetHashCode / ToString 자동 생성
record(C# 9.0)는 이런 보일러플레이트를 완전히 제거합니다. IL 수준에서 record는 컴파일러가 합성한 op_Equality, Equals(Coord), GetHashCode()가 들어간 클래스/구조체로 번역됩니다.
5. 함정과 주의사항 — Unity에서만 일어나는 고유 함정
C# 언어 함정은 앞에서 다 짚었습니다. 이제 Unity 엔진이 C# 연산자에 덧씌운 고유 동작을 봅니다. 이 둘을 섞으면 순수 C# 개발자도 당황합니다.
5.1 Fake Null — == null과 is null이 다르다
Unity의 UnityEngine.Object(= GameObject, Component, MonoBehaviour, ScriptableObject의 부모)는 C#의 관리 힙에 존재하는 래퍼 객체(wrapper)와 C++ 엔진 쪽의 네이티브 객체(native), 두 쪽으로 나뉩니다. Destroy(go)는 네이티브 쪽을 즉시 파괴하지만, C# 관리 힙의 래퍼는 GC가 수거할 때까지 남습니다.

public class FakeNullDemo : MonoBehaviour
{
public GameObject enemy;
void Update()
{
if (someCondition) Destroy(enemy);
// ❌ Before — ?. 는 언어 차원 null 검사라 Unity 의 오버로드를 무시한다
// enemy 가 Fake Null 이어도 is-null 체크를 통과 → 파괴된 객체 접근 → MissingReferenceException
enemy?.SetActive(false);
// ❌ Before — ?? 도 동일 문제
var target = enemy ?? fallbackEnemy; // enemy 가 Fake Null 이어도 그대로 enemy 를 반환
// ✅ After — UnityEngine.Object 에는 무조건 == null 을 쓴다
if (enemy != null) enemy.SetActive(false);
var target2 = enemy != null ? enemy : fallbackEnemy;
}
}
규칙: UnityEngine.Object 파생 타입에는 ?., ??, is null, is not null을 쓰지 않는다. 순수 C# 객체(List<T>, 사용자 클래스 등)에는 자유롭게 써도 됩니다.
5.2 Vector3 ==는 거리 제곱 비교다
public class VectorEqualityTrap : MonoBehaviour
{
void Update()
{
Vector3 pos = transform.position;
Vector3 target = new Vector3(100000f, 0, 0);
// ❌ Before — 큰 좌표에서는 float ULP 가 커져 "거의 같은" 두 점도 다르다고 판정될 수 있다
// 또는 반대로, 꽤 떨어진 두 점이 같다고 판정될 수도 있다
if (pos == target) DoArrive();
// ✅ After — 도메인에 맞는 거리 임계값으로 명시 비교
if ((pos - target).sqrMagnitude < 0.01f) DoArrive();
}
}
Vector3.==는 내부적으로 sqrMagnitude < 9.99999944e-11f (약 (1e-5)^2) 기준입니다. 오픈월드의 먼 좌표에서는 이 임계값이 부적절하므로, 핫패스에서는 명시적 sqrMagnitude 비교를 권장합니다. Distance(제곱근 포함)보다 sqrMagnitude가 빠릅니다.
5.3 gameObject.tag == "Enemy"는 느리다
void Update()
{
foreach (var hit in hits)
{
// ❌ Before — tag getter 는 상황에 따라 문자열 할당 → GC 압박
if (hit.gameObject.tag == "Enemy") { ... }
// ✅ After — 내부 해시 비교, 할당 없음
if (hit.gameObject.CompareTag("Enemy")) { ... }
}
}
CompareTag는 gameObject.tag를 호출하지 않고 엔진 내부의 태그 ID로 비교합니다. Update 루프의 태그 비교는 예외 없이 CompareTag입니다.
5.4 Mathf.Approximately의 비대칭성
// a, b 가 모두 0 에 매우 가까우면 Mathf.Approximately 는 매우 엄격하다
bool close = Mathf.Approximately(0.00000001f, 0.00000002f); // false 가 나올 수 있음
Mathf.Approximately는 "두 값 중 큰 쪽의 크기에 비례한 epsilon"으로 판정합니다. 두 값이 모두 0 근처면 절대값 기준 매우 엄격해집니다. 물리 엔진이나 애니메이션처럼 0 근처 값을 자주 다루는 코드에서는 도메인 고정 epsilon(예: 0.001f)을 쓰는 편이 안전합니다.
6. C# 버전별 변화
C#의 연산자 자체는 1.0부터 거의 변하지 않았습니다. 다만 연산자 주변 문법이 계속 확장되어 왔으며, 실전에서 체감이 큰 변화만 뽑습니다.
6.1 C# 7.2 — in 파라미터로 큰 struct 비교 최적화
// Before (C# 7.1 이하) — Vector3 같은 큰 struct 를 by value 로 넘기면 복사 비용
public static bool Near(Vector3 a, Vector3 b) => (a - b).sqrMagnitude < 0.01f;
// After (C# 7.2+) — in 으로 복사를 피함 (읽기 전용 참조 전달)
public static bool Near(in Vector3 a, in Vector3 b) => (a - b).sqrMagnitude < 0.01f;
IL 수준에서 in 파라미터는 &(주소 전달)가 되어 ldobj 복사가 사라집니다. Unity의 Vector3(12바이트), Matrix4x4(64바이트) 등을 핫패스에서 비교할 때 효과가 큽니다.
6.2 C# 9.0 — record가 ==를 자동 생성
// Before — 수동으로 모두 구현
public class Coord
{
public int X, Y;
public override bool Equals(object obj)
{
if (obj is not Coord c) return false;
return X == c.X && Y == c.Y;
}
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(Coord a, Coord b) => a?.Equals(b) ?? b is null;
public static bool operator !=(Coord a, Coord b) => !(a == b);
}
// After — record 로 한 줄
public record Coord(int X, int Y);
컴파일러가 op_Equality, Equals(Coord), GetHashCode(), ToString()을 합성합니다. record struct(C# 10)를 쓰면 값 타입 버전이 됩니다.
6.3 C# 11 — Generic Math의 IEqualityOperators<T, T, bool>
// Before — 제네릭에서 숫자 비교가 불가능했음
public static bool Equal<T>(T a, T b) where T : struct => a.Equals(b); // 박싱 위험
// After (C# 11+) — 정적 추상 연산자를 제약으로 사용
public static bool Equal<T>(T a, T b) where T : IEqualityOperators<T, T, bool>
=> a == b; // T.== 가 정적 디스패치로 호출됨
숫자 알고리즘을 제네릭으로 작성할 수 있는 토대입니다. 게임 로직보다는 라이브러리 개발에서 유용합니다.
6.4 C# 11 — 사용자 정의 checked 연산자
public struct Score
{
public int Value;
public static Score operator +(Score a, Score b) => new() { Value = a.Value + b.Value };
public static Score operator checked +(Score a, Score b)
=> new() { Value = checked(a.Value + b.Value) }; // checked 블록에서만 이 오버로드 선택
}
checked/unchecked를 연산자 오버로드까지 전파할 수 있게 되었습니다.
7. 정리
한눈에 보는 "기억해야 할 10가지":
- [ ] 정수 나눗셈은 truncation:
5 / 2 = 2. 비율 계산은 최소 한쪽을float/double로. - [ ]
%의 부호는 피제수를 따른다: 순환 인덱스는((i % n) + n) % n. - [ ] 부동소수점
==금지:Math.Abs(a - b) < epsilon또는Mathf.Approximately. - [ ] NaN 탐지는
double.IsNaN:x == NaN은 무조건 false. - [ ]
==는 정적 타입 기준:object로 박싱되면 값 비교가 사라진다. - [ ] 박싱은 힙 할당이다: 핫패스에서
object파라미터 사용 주의. - [ ] 문자열 비교는
StringComparison.Ordinal을 명시: 내부 식별자·태그·경로는 예외 없이 Ordinal. - [ ]
&&/||만 사용:&/|는 단락 평가가 없다 → null 방어 실패. - [ ] 사용자 정의 타입은
record:==/Equals/GetHashCode를 한꺼번에 얻는다. - [ ] Unity
UnityEngine.Object에는== null:?.,??,is null은 Fake Null을 감지하지 못한다.
한 줄 요약: 연산자는 기호가 아니라 IL 명령어의 별칭이다. 타입이 바뀌면 IL이 바뀌고, IL이 바뀌면 의미가 바뀐다.