반응형

[PART4.제어문: 분기와 반복(1/13)] if · else if · else

조건을 나누는 기본 / 중괄호 생략의 함정 / 중첩 if를 평평하게 만드는 early return 패턴


1. 문제 제기 — "왜 이게 문법의 시작이자 끝인가"

Unity에서 캐릭터를 하나 만들어보면, Update() 안은 거의 if 문으로 가득 찹니다. "땅에 닿아 있는가", "체력이 0보다 큰가", "키 입력이 들어왔는가", "타깃이 살아 있는가". if는 너무 익숙해서 다 안다고 착각하기 쉬운 문법입니다.

그런데 실제 프로젝트에서 신입이 가장 많이 만드는 버그는 알고리즘이 아니라 if 주변에서 나옵니다.

C#
// Unity의 흔한 사고 — 이 코드의 문제는 무엇일까요?
void Update()
{
    if (player.IsDead)
        PlayDeathAnimation();
        Destroy(gameObject, 2f);
}

들여쓰기만 보면 두 줄 모두 IsDead 일 때만 실행될 것 같지만, 실제로는 Destroy 가 매 프레임 호출됩니다. 캐릭터가 살아 있는 동안 게임 오브젝트가 계속 사라지려고 시도하고, 결과적으로 0.0166...초마다 새 코루틴이 쌓입니다.

이런 사고는 단순한 오타가 아니라 if 의 문법적 특징을 제대로 이해하지 못한 결과입니다. 그래서 이 글에서는 다음 세 가지에 집중합니다.

  1. 조건을 나누는 기본if/else if/else 가 실제로 어떻게 평가되는가
  2. 중괄호 생략의 함정 — 왜 단 한 줄짜리에도 {} 를 붙여야 하는가
  3. 중첩 if 를 평평하게 만드는 early return 패턴 — 가독성과 IL 양쪽에서 거의 손해 없는 리팩터링

이 세 가지는 단순한 "팁" 이 아니라, 컴파일러가 분기를 어떻게 IL 로 바꾸고, JIT 가 어떻게 최적화하며, CPU 가 어떻게 예측하는지와 직결됩니다. 하나씩 내려가 봅시다.


2. 개념 정의 — if/else if/else 의 평가 모델

2.1 갈래길에서 가장 가까운 표지판부터 읽는다

if/else if/else 는 갈래길에 늘어선 표지판 입니다. 차는 위에서부터 한 표지판씩 읽다가, 참(true)이라고 적힌 첫 표지판을 만나면 그 길로 들어가고 나머지 표지판은 아예 읽지 않습니다. 길이 정해졌으니 다음 갈래를 볼 이유가 없습니다.

if/else if/else 평가 흐름 (점수 = 85)
C#
public static string GradeOf(int score)
{
    if (score >= 90)
        return "A";
    else if (score >= 80)
        return "B";
    else if (score >= 70)
        return "C";
    else
        return "F";
}

순서가 결과를 결정합니다. 만약 score >= 70 가 맨 앞에 있다면 85점도 "C" 가 되어 버립니다. 가장 좁은 조건을 위에, 가장 넓은 조건을 아래에 두는 것이 기본 원칙입니다.

2.2 단락 평가 — &&, || 는 왼쪽이 결정나면 멈춘다

&& — 조건부 논리 AND (Conditional logical AND) 왼쪽 피연산자가 false 이면 오른쪽 피연산자를 평가하지 않고 false 를 반환한다. null 체크와 멤버 접근을 안전하게 묶을 때 자주 쓴다.
예시: if (s != null && s.Length > 10) s 가 null 이면 s.Length 는 평가되지 않아 NullReferenceException 이 발생하지 않는다.

이 동작을 단락 평가(short-circuit evaluation) 라고 부릅니다. 산문으로 풀면 "왼쪽이 답을 정해버리면 오른쪽은 안 본다" 입니다.

C#
public static bool ShortCircuit(string s)
{
    if (s != null && s.Length > 10) return true;
    return false;
}

이 코드를 IL 로 보면 단락 평가의 정체가 분명해집니다.

IL
.method public hidebysig static bool ShortCircuit (string s)
{
    IL_0000: ldarg.0                  // s 를 스택에 로드
    IL_0001: brfalse.s IL_000f        // s 가 null(0) 이면 IL_000f 로 점프 — Length 호출 안 함

    IL_0003: ldarg.0                  // s 를 다시 로드
    IL_0004: callvirt instance int32 [System.Runtime]System.String::get_Length()
    IL_0009: ldc.i4.s 10              // 상수 10 로드
    IL_000b: ble.s IL_000f            // Length <= 10 이면 false 분기로 점프
    IL_000d: ldc.i4.1                 // true 푸시
    IL_000e: ret

    IL_000f: ldc.i4.0                 // false 푸시
    IL_0010: ret
}

brfalse.s 가 핵심입니다. s 가 null 이면 그 즉시 IL_000f 로 점프해 false 를 반환합니다. s.Length 호출(callvirt)은 IL 위에 분명히 적혀 있지만 실행되는 경로 위에 있지 않습니다. 컴파일러가 단락 평가를 IL 분기 명령으로 직접 번역해 놓은 것이라, 런타임에 별도 검사가 일어나는 것이 아닙니다. 즉 && 의 안전성은 "약속" 이 아니라 CIL 명령어 배치 그 자체 입니다.

참고: & (단일 앰퍼샌드)도 bool 에 쓸 수는 있지만 단락 평가를 하지 않습니다. if (s & s.Length > 10) 은 컴파일은 되어도 NullReferenceException 으로 폭발합니다. 비트 연산이 필요한 게 아니라면 항상 &&, || 를 씁니다.

2.3 C# 의 bool 엄격성 — if (x) 에 정수가 못 들어가는 이유

C/C++ 출신이라면 한 번쯤 다음을 시도해 봤을 겁니다.

C#
int hp = 0;
// if (hp) { ... }  // CS0029: 'int' 형식을 'bool' 형식으로 암시적으로 변환할 수 없습니다.
if (hp != 0) { /* OK */ }

C# 의 ifbool 만 받습니다. 이는 단순한 까다로움이 아니라, C/C++ 의 가장 흔한 버그 한 종류를 컴파일 타임에 통째로 차단 합니다.

C#
bool isReady = false;
// if (isReady = true) { ... }  // 컴파일은 되지만 IDE 가 경고

대입(=)과 비교(==)를 헷갈리는 실수는 C 에서는 제어 흐름 변경으로 직결되었지만, C# 에서는 bool 끼리만 가능한 실수로 줄어듭니다. 그리고 정수형에서는 아예 컴파일이 막힙니다 — if (x = 5)intbool 로 바뀌지 않아 빌드가 깨집니다.


3. 내부 동작 — 컴파일러가 if 를 어떻게 IL 로 바꾸는가

3.1 if-else if-else → 분기 명령(branch instruction) 의 사슬

if/else if/else 는 IL 레벨에서 여러 개의 조건부 분기와 무조건 분기로 분해됩니다. 추상적으로 들리지만 실제 컴파일 결과를 보면 더없이 직관적입니다.

C# if-else 가 IL 분기 명령으로 변환되는 방식
C#
public static int BasicIf(int score)
{
    if (score >= 90)        return 1;
    else if (score >= 80)   return 2;
    else if (score >= 70)   return 3;
    else                    return 0;
}
IL
.method public hidebysig static int32 BasicIf (int32 score)
{
    // Code size: 23 (0x17) — Release 빌드
    .maxstack 8

    IL_0000: ldarg.0           // score 로드
    IL_0001: ldc.i4.s 90       // 상수 90
    IL_0003: blt.s IL_0007     // score < 90 이면 IL_0007 로 — "조건을 뒤집어" 점프

    IL_0005: ldc.i4.1
    IL_0006: ret               // return 1

    IL_0007: ldarg.0
    IL_0008: ldc.i4.s 80
    IL_000a: blt.s IL_000e     // score < 80 이면 IL_000e 로
    IL_000c: ldc.i4.2
    IL_000d: ret               // return 2

    IL_000e: ldarg.0
    IL_000f: ldc.i4.s 70
    IL_0011: blt.s IL_0015     // score < 70 이면 IL_0015 로
    IL_0013: ldc.i4.3
    IL_0014: ret               // return 3

    IL_0015: ldc.i4.0
    IL_0016: ret               // return 0
}

세 가지 사실이 보입니다.

  1. 조건이 뒤집힌다. C# 에서 score >= 90 인데 IL 은 blt.s (branch if less than) 로 점프합니다. "참이면 다음 줄을 그대로 실행, 거짓이면 다음 가지로 점프" 가 더 단순한 구조라 컴파일러가 부정형 분기를 선호합니다.
  2. 각 가지 끝의 ret 이 무조건 점프(br)를 대체합니다. 본래 if/else 의 끝은 공통 end 레이블로 br 점프해야 하지만, 본문이 곧바로 return 이므로 br 명령조차 필요 없습니다.
  3. 블록 진입은 "공짜" 다. 매칭된 가지에 들어갈 때는 점프가 없습니다 — 순차 흐름 그대로 다음 명령을 실행하기 때문입니다. 점프 비용은 선택되지 않은 가지로 넘어갈 때만 발생합니다.

여기서 분기 명령(branch instruction) 이라는 용어를 짚고 갑시다. IL 의 분기 명령은 스택 또는 두 값의 관계를 보고 다른 IL 주소로 실행 흐름을 옮기는 명령어 묶음입니다. if 가 IL 로 컴파일된다는 것은, 본질적으로 goto label 의 모음으로 바뀐다는 뜻입니다.

IL 명령 의미
brfalse.s L 스택 최상단이 0/null/false 면 L 로 점프
brtrue.s L 스택 최상단이 0 이 아니면 L 로 점프
beq.s L 두 값이 같으면 L 로 점프
bne.un.s L 두 값이 다르면 (unordered 포함) L 로 점프
blt.s / ble.s < / <= 일 때 점프
bgt.s / bge.s > / >= 일 때 점프
br.s L 무조건 L 로 점프

.s 접미사는 short-form 으로, 점프 거리(offset)가 1바이트 안에 들어갈 때 컴파일러가 선택합니다. 코드 크기가 줄어들 뿐 의미는 같습니다.

3.2 분기 예측(branch prediction) 과 hot path

CPU 는 if 명령어를 만났을 때 조건 결과가 나오기를 기다리지 않고 미리 한쪽 가지의 명령을 파이프라인에 채웁니다. 이를 분기 예측(branch prediction) 이라 부릅니다 — 옳게 예측했으면 비용이 거의 0이고, 틀렸으면 채워둔 명령을 모두 버리는 파이프라인 스톨(pipeline stall) 비용을 치릅니다.

JIT(Just-In-Time, 런타임에 IL 을 네이티브 기계어로 번역하는 .NET 컴파일러) 는 가능한 경우 hot path 를 fall-through 경로(점프 없이 흐르는 경로) 에 두려고 합니다. 사용자가 코드에서 도울 수 있는 부분은 단순합니다.

원칙: 매 프레임 99% 가 통과하는 조건을 if (정상_조건) 의 본문에 두고, 예외 케이스를 else 로 보낸다.
C#
// 99% 의 경우 살아 있다고 가정
void UpdateEnemy(Enemy e)
{
    if (e.IsAlive)        // hot path — 분기 예측이 거의 맞음
    {
        e.Tick();
    }
    else                  // cold path — 가끔만
    {
        e.OnDeath();
    }
}

다만 이 최적화는 이미 측정으로 병목임이 확인된 핫 루프 에서만 의미가 있습니다. 그렇지 않은 곳에서는 가독성이 우선입니다 — 섣부른 최적화는 잘 작동하는 코드를 망치는 가장 흔한 길입니다.


4. 실전 적용 — 중첩 if 를 평평하게 만드는 early return

4.1 Before/After: 파멸의 피라미드 vs 가드 클로즈

Unity 신입이 인벤토리·상점·전투 시스템을 만들 때 거의 무조건 한 번은 마주치는 모양이 있습니다. 통칭 파멸의 피라미드(pyramid of doom) 입니다.

Before — 중첩 if (피라미드)
C#
// Before — 중첩 if (Unity 상점에서 자주 보이는 모양)
void TryBuy_Bad(Item item, int gold, int price)
{
    if (item != null)
    {
        if (item.IsAvailable)
        {
            if (gold >= price)
            {
                player.Gold -= price;
                player.Inventory.Add(item);
            }
        }
    }
}
C#
// After — 가드 클로즈(guard clause), 또는 early return
void TryBuy_Good(Item item, int gold, int price)
{
    if (item == null) return;
    if (!item.IsAvailable) return;
    if (gold < price) return;

    player.Gold -= price;
    player.Inventory.Add(item);
}

핵심 로직이 같은 깊이(들여쓰기 1단계)에 모이고, 실패 조건은 위쪽에 일렬로 정렬됩니다. 코드 리뷰를 받을 때 시선이 어디서 멈추는지를 비교해 보면 차이가 분명합니다.

단일 출구 원칙(single exit point) 을 들어보셨다면, 가드 클로즈는 그 원칙을 일부러 깬 것입니다. 70~80년대 구조적 프로그래밍 책에서는 함수마다 return 하나만 두라고 가르쳤지만, 현대 C# 코드 베이스에서는 가독성이 더 중요한 가치 로 자리 잡았고 .NET 코딩 가이드도 가드 클로즈를 권장합니다.

4.2 IL 로 본 둘의 비용 차이

가독성 좋은 패턴이 성능 손해라면 트레이드오프가 있겠지만, 실제로는 거의 같습니다.

IL
// Before — Nested (중첩 if), Code size: 11 bytes
.method public hidebysig static int32 Nested (object item, int32 gold, int32 price)
{
    IL_0000: ldarg.0
    IL_0001: brfalse.s IL_0009     // item == null 이면 IL_0009 로
    IL_0003: ldarg.1
    IL_0004: ldarg.2
    IL_0005: blt.s IL_0009         // gold < price 이면 IL_0009 로
    IL_0007: ldc.i4.1
    IL_0008: ret
    IL_0009: ldc.i4.0
    IL_000a: ret
}
IL
// After — GuardClause (early return), Code size: 13 bytes
.method public hidebysig static int32 GuardClause (object item, int32 gold, int32 price)
{
    IL_0000: ldarg.0
    IL_0001: brtrue.s IL_0005      // item != null 이면 IL_0005 로 (다음 검사)
    IL_0003: ldc.i4.0
    IL_0004: ret                   // return 0  ← 여기서 일찍 반환
    IL_0005: ldarg.1
    IL_0006: ldarg.2
    IL_0007: bge.s IL_000b         // gold >= price 이면 IL_000b 로
    IL_0009: ldc.i4.0
    IL_000a: ret                   // return 0  ← 또 일찍 반환
    IL_000b: ldc.i4.1
    IL_000c: ret
}

11바이트 vs 13바이트. 중첩 쪽이 2바이트 작지만, JIT 가 네이티브 코드로 변환하고 나면 분기 예측·인라이닝의 영향이 훨씬 크기 때문에 이 정도 차이는 사실상 무의미합니다. 두 코드 모두

  • brfalse/brtrueblt/bge 같은 조건부 분기로 똑같이 구성되고
  • ret 횟수도 비슷하며
  • 매칭된 가지에서는 점프가 0번이거나 1번에 불과합니다.
결론: early return 으로 평평하게 만든다고 해서 IL 비용이 의미 있게 늘지 않습니다. 가독성을 위해 성능을 양보할 일이 아니라 — 그냥 더 좋은 패턴입니다.

4.3 Unity 핫패스에서의 결합

Update() 같은 매 프레임 호출되는 메서드에서 가드 클로즈는 단순한 가독성 이상의 효과를 줍니다.

C#
void Update()
{
    // 가장 흔한 거절 케이스를 먼저 — 분기 예측이 안정화
    if (!isActiveAndEnabled) return;
    if (target == null) return;
    if (Time.timeScale == 0f) return;

    // 여기 도달했으면 모든 전제 조건 OK
    Aim();
    if (Input.GetButton("Fire1")) Fire();
}

이 패턴은 두 가지를 동시에 해결합니다.

  1. CPU 가 fall-through 경로를 hot path 로 학습. 대부분의 프레임에서 return 으로 빠지지 않으므로, JIT 와 CPU 분기 예측기 모두 본문 경로를 디폴트로 잡습니다.
  2. GC 압박 회피. Unity 의 ==UnityEngine.Object 에 대해 오버로딩되어 매 호출마다 약간의 비용이 있는데, 가드 클로즈로 빠르게 돌려보내면 그 뒤의 더 무거운 호출들이 아예 실행되지 않습니다.
참고: Unity 의 target == null 은 일반 C# 의 참조 비교가 아닙니다. UnityEngine.Object== 를 오버로딩해 "C# 객체는 살아 있지만 네이티브 핸들이 파괴된 상태(fake null)" 까지 검사합니다. 따라서 target?.DoSomething() 같은 null 조건부 연산자는 이 fake null 을 잡지 못합니다 — Unity 객체에는 if (target == null) 또는 if (!target) 을 씁니다.

5. 함정과 주의사항

5.1 ❌ 중괄호 생략 → ✅ 항상 중괄호

C#
// ❌ — 처음에는 한 줄이라 멀쩡했다
if (player.IsDead)
    PlayDeathAnimation();
    Destroy(gameObject, 2f);   // 들여쓰기와 무관하게 매 프레임 실행
C#
// ✅ — 한 줄이라도 중괄호
if (player.IsDead)
{
    PlayDeathAnimation();
    Destroy(gameObject, 2f);
}

이런 사고는 신입의 부주의가 아니라 중괄호를 생략하는 습관 자체 의 비용입니다. 2014년 애플의 goto fail 보안 사고도 이 카테고리였습니다 — 중괄호 없는 if 안에 goto fail; 한 줄을 실수로 두 번 적었더니, 두 번째 goto fail; 이 조건과 무관하게 항상 실행되어 SSL 인증서 검증 전체가 우회되었습니다.

Visual Studio · Rider · OmniSharp 의 기본 분석기는 모두 중괄호 누락을 경고로 잡아주도록 설정할 수 있습니다(IDE0011 등). 켜 두는 편이 안전합니다.

5.2 ❌ Dangling else → ✅ 의도를 중괄호로 못 박기

else자기와 가장 가까운, 짝이 없는 if 에 묶입니다. 들여쓰기는 거짓말을 합니다.

C#
// ❌ — 의도와 컴파일러 해석이 다르다
if (a)
    if (b)
        DoX();
else                // ← 시각적으로는 외곽 if 의 else 처럼 보이지만
    DoY();          //    실제로는 안쪽 if(b) 의 else
C#
// ✅ — 의도를 중괄호로 명시
if (a)
{
    if (b) DoX();
}
else
{
    DoY();
}

C# 컴파일러는 else 를 가장 가까운 if 와 묶는 규칙(closest-match) 을 적용하므로, 위 ❌ 예시는 b == false 일 때 DoY() 가 호출됩니다. 직관과 다릅니다.

5.3 ❌ 부동소수점 == → ✅ 허용 오차 비교

C#
// ❌
float x = 0.1f + 0.2f;
if (x == 0.3f) { /* 거의 절대 안 들어옵니다 */ }

float/double 은 이진 부동소수점이라 0.1, 0.3 같은 십진 분수를 정확히 표현하지 못합니다. IL 로 보면 비교가 어떻게 컴파일되는지 분명합니다.

IL
// public static bool BadFloatCompare (float a, float b)
.method public hidebysig static bool BadFloatCompare (float32 a, float32 b)
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: bne.un.s IL_0006   // ← bne.un = "branch if not equal, unordered"
    IL_0004: ldc.i4.1
    IL_0005: ret
    IL_0006: ldc.i4.0
    IL_0007: ret
}

bne.un.s 가 정수 비교(bne.s) 와 다른 점은 un (unordered) 이 붙는다는 것입니다. NaN 같은 비순서 값까지 처리하기 위한 IEEE 754 부동소수점 비교 명령입니다. 즉 == 가 컴파일되어도 NaN 안전성까지는 잡아주지만 표현 오차에 의한 미세한 차이는 그대로 통과시킵니다.

C#
// ✅ — Unity
if (Mathf.Approximately(x, 0.3f)) { /* OK */ }

// ✅ — 일반 .NET
if (Math.Abs(x - 0.3f) < 1e-5f) { /* OK */ }

Unity 의 Mathf.Approximately 는 두 값의 절대 차이를 머신 엡실론 기반의 허용 오차와 비교합니다. 직접 임계값을 잡을 때는 도메인에 맞춰 정해야 합니다 — 위치 좌표라면 1e-3f 도 충분하지만, 누적된 시간 스칼라라면 더 큰 오차를 허용해야 할 수 있습니다.

5.4 ❌ 클래스 == 로 값 비교 → ✅ Equals 또는 record

C#
class Player { public string Name; public Player(string n){Name=n;} }
var a = new Player("Hero");
var b = new Player("Hero");
// ❌ 참조가 다르므로 false
if (a == b) { /* 안 들어옴 */ }
// ✅ 값 비교가 필요하면 Equals 를 오버라이드하거나 record 사용

기본적으로 클래스에서 ==참조 비교(reference equality) 입니다 — 두 변수가 메모리상 같은 객체를 가리키는지 확인합니다. 값(필드 내용) 으로 비교하려면 Equals 를 오버라이드하거나 C# 9 의 record 를 쓰면 컴파일러가 값 기반 Equals== 를 자동 생성합니다.

예외: string 은 클래스이지만 ==값 비교 로 오버로딩되어 있습니다. 워낙 자주 쓰이는 타입이라 BCL 차원에서 별도로 처리해 둔 것입니다. 이 예외 때문에 "클래스 == 는 참조 비교" 라는 규칙을 잊기 쉽습니다.

5.5 ❌ is + 캐스트 두 번 → ✅ 패턴 매칭으로 한 번에

C#
// ❌ C# 7 이전 — 타입 검사와 캐스팅이 분리
void Interact_Old(object t)
{
    if (t is Enemy)
    {
        Enemy e = (Enemy)t;   // 같은 타입 검사가 한 번 더 일어남
        e.TakeDamage(10);
    }
}
C#
// ✅ C# 7+ — 타입 패턴(type pattern)
void Interact_New(object t)
{
    if (t is Enemy e)         // 타입 검사 + 변수 바인딩 동시
    {
        e.TakeDamage(10);
    }
}
is — 타입 패턴 (Type pattern) C# 7+ 왼쪽 값이 지정한 타입과 호환되면 그 값을 새 변수에 안전하게 바인딩한다. 타입 검사와 캐스팅을 한 번에 처리하므로 InvalidCastException 위험이 사라진다.
예시: if (obj is Enemy e) e.TakeDamage(10); obj 가 Enemy 면 e 에 바인딩되어 본문에서 사용 가능.

IL 로 비교하면 차이가 분명합니다.

IL
// OldCast — 22 bytes (이전 방식)
.method public hidebysig static int32 OldCast (object target)
{
    IL_0000: ldarg.0
    IL_0001: isinst [System.Runtime]System.String   // ① 타입 검사
    IL_0006: brfalse.s IL_0014
    IL_0008: ldarg.0
    IL_0009: castclass [System.Runtime]System.String // ② 다시 캐스트 (타입 검사 한 번 더)
    IL_000e: callvirt instance int32 [System.Runtime]System.String::get_Length()
    IL_0013: ret
    IL_0014: ldc.i4.0
    IL_0015: ret
}
IL
// PatternMatch — 19 bytes (패턴 매칭)
.method public hidebysig static int32 PatternMatch (object target)
{
    .locals init ([0] string)
    IL_0000: ldarg.0
    IL_0001: isinst [System.Runtime]System.String   // ① 타입 검사 (캐스트 결과를 그대로 보관)
    IL_0006: stloc.0                                 // 로컬에 저장
    IL_0007: ldloc.0
    IL_0008: brfalse.s IL_0011                      // null 이면 점프
    IL_000a: ldloc.0
    IL_000b: callvirt instance int32 [System.Runtime]System.String::get_Length()
    IL_0010: ret
    IL_0011: ldc.i4.0
    IL_0012: ret
}

핵심 차이는 castclass 가 사라졌다는 것 입니다. isinst 는 타입 검사에 실패하면 null 을, 성공하면 캐스팅된 참조를 스택에 남깁니다. 패턴 매칭은 그 결과를 로컬 변수에 그대로 저장해 재사용하므로 타입 검사가 한 번만 일어납니다. Unity 의 핫 루프에서 인터페이스나 추상 타입을 자주 다룰 때 이 차이가 누적되면 의미를 가질 수 있습니다.

5.6 ❌ 거대한 if-else if 사슬 → ✅ switch 문/식

특정 변수 하나를 여러 상수와 비교하는 거대한 사슬은 switch 가 더 적합합니다.

C#
// ❌
if (state == PlayerState.Idle) Idle();
else if (state == PlayerState.Walk) Walk();
else if (state == PlayerState.Run)  Run();
else if (state == PlayerState.Jump) Jump();
else if (state == PlayerState.Fall) Fall();
C#
// ✅ — switch 문
switch (state)
{
    case PlayerState.Idle: Idle(); break;
    case PlayerState.Walk: Walk(); break;
    case PlayerState.Run:  Run();  break;
    case PlayerState.Jump: Jump(); break;
    case PlayerState.Fall: Fall(); break;
}

판단 기준은 단순합니다.

상황 선택
단일 변수 + 상수 여러 개 (enum, int, string) switch
변수가 서로 다르거나 범위 비교(>, <)가 섞임 if-else if
값을 만들어 반환하는 형태 (PlayerState → int) C# 8+ switch
패턴 매칭(타입·구조 분해) 여러 케이스 switch

switch 의 내부 동작과 점프 테이블 최적화는 이 시리즈의 다음 글에서 별도로 다룹니다.


6. C# 버전별 변화

if/else if/else 자체의 문법은 C# 1.0 이래로 바뀌지 않았습니다. 하지만 if 와 함께 자주 쓰는 주변 문법은 꾸준히 강화되었습니다.

버전 추가된 기능 효과
C# 6 (2015) ?. (null 조건부 연산자) if (x != null) x.M()x?.M() 으로 단순화
C# 7 (2017) is 타입 패턴 if (o is Enemy e) 로 타입 검사 + 캐스팅 1회
C# 8 (2019) switch 거대한 if-else if/switch 문 → 식으로
C# 9 (2020) is not / 논리 패턴(and/or) if (x is not null), if (x is > 0 and < 100)
C# 10 (2021) 확장 속성 패턴 if (player is { Inventory.Count: > 0 })

6.1 Before / After — null 체크가 짧아진 역사

C#
// C# 5 이전 — 명시적 null 체크
if (player != null)
{
    if (player.Target != null)
    {
        player.Target.TakeDamage(10);
    }
}
C#
// C# 6+ — null 조건부 연산자
player?.Target?.TakeDamage(10);
C#
// C# 9+ — is not null 패턴
if (player is not null && player.Target is not null)
    player.Target.TakeDamage(10);
is not null — 부정 패턴 (Negated pattern) C# 9+ is 패턴 매칭의 부정형. != null 과 의미는 같지만, 사용자 정의 == 오버로딩(특히 Unity UnityEngine.Object) 에 영향받지 않고 순수 참조 검사 를 보장한다.
예시: if (target is not null) target.DoSomething(); Unity 가 아닌 일반 .NET 코드에서 안전한 null 검사로 권장된다.

is not null!= null 의 차이는 미묘하지만 Unity 에서 의외로 중요합니다. Unity 의 UnityEngine.Object==/!= 를 오버로딩해 fake null 을 처리하지만, is not null 은 그 오버로딩을 거치지 않고 순수한 참조 비교 를 합니다. 의도가 무엇인지에 따라 골라 써야 합니다.

의도 사용
Unity 객체가 "사용 가능한 상태"인가 (네이티브 핸들 살아있음) if (target == null)
순수 .NET 객체가 null 인가 if (target is null)
일반 .NET 객체의 null 안전 멤버 호출 target?.M()

7. 정리

체크리스트 핵심
if/else if/else 평가 순서 위에서 아래로 순차, 첫 true 에서 종료, 나머지 평가 안 함
단락 평가 &&, `   는 결정나면 멈춤 — IL 의 brfalse` 로 직접 구현됨
bool 엄격성 if (정수) 불가 — C/C++ 의 대입 오타 류 버그를 컴파일 타임에 차단
중괄호 항상 붙인다. 한 줄짜리도 예외 없음. goto fail 류 사고 방지
Dangling else else 는 가장 가까운 if 와 묶임 — 의도가 다르면 중괄호로 명시
Early return 중첩 if 보다 가독성 우위, IL 비용 거의 동일 (11 vs 13 bytes)
Hot path 99% 통과 조건을 본문에, 예외를 else 로 — 분기 예측 친화
부동소수점 비교 == 금지, Mathf.Approximately 또는 Math.Abs(a-b) < ε
클래스 == 참조 비교가 기본. 값 비교는 Equals 오버라이드 또는 record
패턴 매칭 if (o is T t) — 타입 검사 + 캐스팅 1회로 IL 명령어도 줄어듦
switch 와의 선택 단일 변수 + 다수 상수 = switch, 그 외 = if

if 는 모든 제어 흐름의 출발점입니다. 잘 쓰면 보이지 않고, 잘못 쓰면 어디에나 박혀 있습니다. 다음 글에서는 if-else if 사슬이 일정 규모를 넘었을 때 자연스러운 다음 단계인 switch 문을 다룹니다.

반응형

+ Recent posts