반응형

[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%는 피할 수 있습니다.

C# 소스 코드: a == b (같아 보이는 한 줄)

2.2 세 카테고리 한눈 보기

카테고리 연산자 반환 타입 오버로딩 가능
산술 + - * / % 숫자
관계·동등 == != < > <= >= bool 예 (단, ==/!=, </>, <=/>=는 쌍으로)
논리 && || ! bool &&, ||는 간접적으로만 (후술)
연산자 오버로딩 (Operator overloading) 타입이 자기 자신에 대한 연산자의 동작을 재정의하는 기능. C#에서는 public static T operator +(T a, T b) 형식의 정적 메서드로 선언한다. 예를 들어 string==는 내부 문자의 값 비교로 오버로딩되어 있고, Unity의 Vector3.==는 거리 제곱 비교로 오버로딩되어 있다.

2.3 기본 산술 — 가장 먼저 만나는 함정

C#
// 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.
C#
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 매핑

C# 연산자 → IL 명령어 매핑

실제 IL을 봅니다. 아래는 /il-analysis로 실제 컴파일한 결과입니다.

C#
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; } }
IL
.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. DoubleDivideconv.r8div 앞에 붙는다.

(double)a가 IL에서 conv.r8로 번역되고, 이 덕분에 뒤의 div는 부동소수점 나눗셈이 됩니다. 한 줄짜리 C# 코드에서 캐스트 위치가 달라지면 IL 순서가 달라지고 결과가 달라집니다. Unity 스탯 계산에서 (float)currentHp / maxHp로 비율을 구할 때 캐스트 위치를 헷갈리면 항상 0이 나옵니다.

3. addadd.ovf는 단 세 글자 차이지만 런타임 의미는 전혀 다르다.

checked 블록은 IL을 add.ovf로 바꿔 오버플로 시 OverflowException을 던집니다. Unity Android/iOS에서 IL2CPP(Unity의 AOT 컴파일러)는 add.ovf를 C++ 코드의 오버플로 체크로 번역하므로, 성능이 중요한 Update 핫패스에서 불필요한 checked를 남발하면 모바일 CPU에서 분기 예측 실패가 누적됩니다. 반대로 골드/경험치 같은 누적 값에는 일부러 checked를 써서 오버플로 버그를 폭파시키는 편이 안전합니다.

3.2 ==의 세 얼굴 — 같은 기호, 다른 IL

C#
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;
}
IL
.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 == stringcall로 정적 메서드를 호출한다.

op_Equalitystring 타입이 정의한 == 오버로드입니다. 컴파일 타임에 피연산자의 정적 타입이 string이라서 컴파일러가 이 메서드 호출로 바꿔줍니다. 내부 구현은 문자별 비교(Ordinal)로 되어 있어 "내용이 같다"를 판별합니다.

3. 박싱된 object끼리 ==는 참조 비교로 떨어진다.

object a = 1은 값 타입 int를 힙의 "박스"에 포장합니다. box 명령어가 매번 새 힙 할당을 일으킵니다. 이후 a == b는 정적 타입이 object이므로 op_Equality 오버로드가 존재하지 않고 평범한 ceq — 즉 박스 주소끼리의 비교입니다. 두 박스는 서로 다른 힙 객체이므로 결과는 false. Unity Update 루프에서 object 파라미터로 int를 넘기는 이벤트 시스템을 만들면 매 프레임 박싱이 발생해 GC 스파이크를 유발합니다.

3.3 && (단락 평가) 대 & (전체 평가)

C#
public static bool ShortCircuit(string? s) => s != null && s.Length > 0;
public static bool FullEval(string? s)     => s != null & s.Length > 0;
IL
.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 분기 명령어로 구현된다.

왼쪽 피연산자가 falsebrfalse.s IL_000f로 점프해서 오른쪽 식(s.Length > 0) 자체를 평가하지 않습니다. snull일 때 get_Length()가 호출될 일이 없으므로 NullReferenceException이 안 납니다.

2. &는 양쪽을 먼저 계산한 뒤 and로 합친다.

cgt.un(null 비교)으로 왼쪽을 계산한 뒤 곧바로 callvirt get_Length()가 나옵니다. snull이라면 이 callvirt에서 NullReferenceException이 터집니다. &bool에 쓰면 단락 평가의 안전망이 완전히 사라집니다.

3. callvirt는 수신자의 null 을 검사한 뒤 호출한다.

callvirt는 메서드 호출 전에 "수신자 객체가 null 이 아닌가"를 CLR 차원에서 확인합니다. null 이면 NullReferenceException. 이 점이 & 사용 시 NRE가 결정적으로 발생하는 이유입니다. call(정적)이라면 상황이 다르겠지만, 인스턴스 메서드는 대부분 callvirt입니다.


4. 실전 적용 — Before / After 로 보는 10대 함정

앞에서 메커니즘을 봤으니 이제 실전에서 어떻게 쓰는지 짚습니다. Before(잘못된 패턴) → After(올바른 패턴)입니다.

4.1 정수 나눗셈과 비율 계산

C#
// 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;
}

currentHp30, maxHp100이면 Before30 / 100 = 0float으로 승격 → 0f. After30f / 100 = 0.3f. IL은 앞에서 본 IntDivideDoubleDivide 차이 그대로입니다.

4.2 나머지 연산자의 부호 — 순환 버퍼 주의

C#
// ❌ 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 부동소수점 비교 — 절대로 == 를 쓰지 않는다

C#
// ❌ 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.Approximatelya, b가 0 근처일 때 매우 엄격합니다. 물리 시뮬레이션 임계값은 도메인에 맞는 epsilon을 직접 정하는 편이 낫습니다.

4.4 NaN 탐지 — == 로는 안 된다

double.NaN — Not-a-Number 0.0 / 0.0이나 Math.Sqrt(-1)같은 정의되지 않은 실수 연산 결과. IEEE 754 규격상 NaN과의 모든 비교(==, <, >)는 false, 유일하게 !=true 가 된다.
C#
double value = ComputeSomething();

// ❌ Before — NaN == NaN 은 항상 false 이므로 절대 참이 되지 않는다
if (value == double.NaN) { HandleNaN(); }

// ✅ After — 전용 메서드를 쓴다
if (double.IsNaN(value)) { HandleNaN(); }

보너스 팁: x != xtruex는 NaN입니다. 이 한 줄짜리 트릭으로 NaN을 빠르게 탐지할 수 있지만, 가독성을 위해 double.IsNaN을 권장합니다. Unity에서는 Mathf.Abs(float.NaN) 같은 연산이 조용히 NaN을 퍼뜨리므로, 네트워크로 받은 위치값·입력값은 항상 IsNaN으로 검증한 뒤 사용합니다.

4.5 박싱된 값 타입의 == 트랩

C#
// ❌ 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을 명시한다

C#
// ❌ 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

C#
// ❌ 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 연산자 우선순위 + 숫자 승격

C#
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 비교 연산자 체이닝은 없다

C#
// ❌ 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가 깨집니다.

C#
// ❌ 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 — == nullis null이 다르다

Unity의 UnityEngine.Object(= GameObject, Component, MonoBehaviour, ScriptableObject의 부모)는 C#의 관리 힙에 존재하는 래퍼 객체(wrapper)와 C++ 엔진 쪽의 네이티브 객체(native), 두 쪽으로 나뉩니다. Destroy(go)는 네이티브 쪽을 즉시 파괴하지만, C# 관리 힙의 래퍼는 GC가 수거할 때까지 남습니다.

Destroy(go) 이후 go 의 상태
C#
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 ==는 거리 제곱 비교다

C#
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"는 느리다

C#
void Update()
{
    foreach (var hit in hits)
    {
        // ❌ Before — tag getter 는 상황에 따라 문자열 할당 → GC 압박
        if (hit.gameObject.tag == "Enemy") { ... }

        // ✅ After — 내부 해시 비교, 할당 없음
        if (hit.gameObject.CompareTag("Enemy")) { ... }
    }
}

CompareTaggameObject.tag를 호출하지 않고 엔진 내부의 태그 ID로 비교합니다. Update 루프의 태그 비교는 예외 없이 CompareTag입니다.

5.4 Mathf.Approximately의 비대칭성

C#
// 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 비교 최적화

C#
// 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==를 자동 생성

C#
// 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>

C#
// 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 연산자

C#
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이 바뀌면 의미가 바뀐다.

반응형

+ Recent posts