[PART4.제어문: 분기와 반복(1/13)] if · else if · else
조건을 나누는 기본 / 중괄호 생략의 함정 / 중첩 if를 평평하게 만드는 early return 패턴
목차
1. 문제 제기 — "왜 이게 문법의 시작이자 끝인가"
Unity에서 캐릭터를 하나 만들어보면, Update() 안은 거의 if 문으로 가득 찹니다. "땅에 닿아 있는가", "체력이 0보다 큰가", "키 입력이 들어왔는가", "타깃이 살아 있는가". if는 너무 익숙해서 다 안다고 착각하기 쉬운 문법입니다.
그런데 실제 프로젝트에서 신입이 가장 많이 만드는 버그는 알고리즘이 아니라 if 주변에서 나옵니다.
// Unity의 흔한 사고 — 이 코드의 문제는 무엇일까요?
void Update()
{
if (player.IsDead)
PlayDeathAnimation();
Destroy(gameObject, 2f);
}
들여쓰기만 보면 두 줄 모두 IsDead 일 때만 실행될 것 같지만, 실제로는 Destroy 가 매 프레임 호출됩니다. 캐릭터가 살아 있는 동안 게임 오브젝트가 계속 사라지려고 시도하고, 결과적으로 0.0166...초마다 새 코루틴이 쌓입니다.
이런 사고는 단순한 오타가 아니라 if 의 문법적 특징을 제대로 이해하지 못한 결과입니다. 그래서 이 글에서는 다음 세 가지에 집중합니다.
- 조건을 나누는 기본 —
if/else if/else가 실제로 어떻게 평가되는가 - 중괄호 생략의 함정 — 왜 단 한 줄짜리에도
{}를 붙여야 하는가 - 중첩 if 를 평평하게 만드는 early return 패턴 — 가독성과 IL 양쪽에서 거의 손해 없는 리팩터링
이 세 가지는 단순한 "팁" 이 아니라, 컴파일러가 분기를 어떻게 IL 로 바꾸고, JIT 가 어떻게 최적화하며, CPU 가 어떻게 예측하는지와 직결됩니다. 하나씩 내려가 봅시다.
2. 개념 정의 — if/else if/else 의 평가 모델
2.1 갈래길에서 가장 가까운 표지판부터 읽는다
if/else if/else 는 갈래길에 늘어선 표지판 입니다. 차는 위에서부터 한 표지판씩 읽다가, 참(true)이라고 적힌 첫 표지판을 만나면 그 길로 들어가고 나머지 표지판은 아예 읽지 않습니다. 길이 정해졌으니 다음 갈래를 볼 이유가 없습니다.

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) 라고 부릅니다. 산문으로 풀면 "왼쪽이 답을 정해버리면 오른쪽은 안 본다" 입니다.
public static bool ShortCircuit(string s)
{
if (s != null && s.Length > 10) return true;
return false;
}
이 코드를 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++ 출신이라면 한 번쯤 다음을 시도해 봤을 겁니다.
int hp = 0;
// if (hp) { ... } // CS0029: 'int' 형식을 'bool' 형식으로 암시적으로 변환할 수 없습니다.
if (hp != 0) { /* OK */ }
C# 의 if 는 bool 만 받습니다. 이는 단순한 까다로움이 아니라, C/C++ 의 가장 흔한 버그 한 종류를 컴파일 타임에 통째로 차단 합니다.
bool isReady = false;
// if (isReady = true) { ... } // 컴파일은 되지만 IDE 가 경고
대입(=)과 비교(==)를 헷갈리는 실수는 C 에서는 제어 흐름 변경으로 직결되었지만, C# 에서는 bool 끼리만 가능한 실수로 줄어듭니다. 그리고 정수형에서는 아예 컴파일이 막힙니다 — if (x = 5) 는 int 가 bool 로 바뀌지 않아 빌드가 깨집니다.
3. 내부 동작 — 컴파일러가 if 를 어떻게 IL 로 바꾸는가
3.1 if-else if-else → 분기 명령(branch instruction) 의 사슬
if/else if/else 는 IL 레벨에서 여러 개의 조건부 분기와 무조건 분기로 분해됩니다. 추상적으로 들리지만 실제 컴파일 결과를 보면 더없이 직관적입니다.

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;
}
.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
}
세 가지 사실이 보입니다.
- 조건이 뒤집힌다. C# 에서
score >= 90인데 IL 은blt.s(branch if less than) 로 점프합니다. "참이면 다음 줄을 그대로 실행, 거짓이면 다음 가지로 점프" 가 더 단순한 구조라 컴파일러가 부정형 분기를 선호합니다. - 각 가지 끝의
ret이 무조건 점프(br)를 대체합니다. 본래if/else의 끝은 공통end레이블로br점프해야 하지만, 본문이 곧바로return이므로br명령조차 필요 없습니다. - 블록 진입은 "공짜" 다. 매칭된 가지에 들어갈 때는 점프가 없습니다 — 순차 흐름 그대로 다음 명령을 실행하기 때문입니다. 점프 비용은 선택되지 않은 가지로 넘어갈 때만 발생합니다.
여기서 분기 명령(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로 보낸다.
// 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 (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);
}
}
}
}
// 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 로 본 둘의 비용 차이
가독성 좋은 패턴이 성능 손해라면 트레이드오프가 있겠지만, 실제로는 거의 같습니다.
// 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
}
// 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/brtrue와blt/bge같은 조건부 분기로 똑같이 구성되고ret횟수도 비슷하며- 매칭된 가지에서는 점프가 0번이거나 1번에 불과합니다.
결론: early return 으로 평평하게 만든다고 해서 IL 비용이 의미 있게 늘지 않습니다. 가독성을 위해 성능을 양보할 일이 아니라 — 그냥 더 좋은 패턴입니다.
4.3 Unity 핫패스에서의 결합
Update() 같은 매 프레임 호출되는 메서드에서 가드 클로즈는 단순한 가독성 이상의 효과를 줍니다.
void Update()
{
// 가장 흔한 거절 케이스를 먼저 — 분기 예측이 안정화
if (!isActiveAndEnabled) return;
if (target == null) return;
if (Time.timeScale == 0f) return;
// 여기 도달했으면 모든 전제 조건 OK
Aim();
if (Input.GetButton("Fire1")) Fire();
}
이 패턴은 두 가지를 동시에 해결합니다.
- CPU 가 fall-through 경로를 hot path 로 학습. 대부분의 프레임에서
return으로 빠지지 않으므로, JIT 와 CPU 분기 예측기 모두 본문 경로를 디폴트로 잡습니다. - 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 ❌ 중괄호 생략 → ✅ 항상 중괄호
// ❌ — 처음에는 한 줄이라 멀쩡했다
if (player.IsDead)
PlayDeathAnimation();
Destroy(gameObject, 2f); // 들여쓰기와 무관하게 매 프레임 실행
// ✅ — 한 줄이라도 중괄호
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 에 묶입니다. 들여쓰기는 거짓말을 합니다.
// ❌ — 의도와 컴파일러 해석이 다르다
if (a)
if (b)
DoX();
else // ← 시각적으로는 외곽 if 의 else 처럼 보이지만
DoY(); // 실제로는 안쪽 if(b) 의 else
// ✅ — 의도를 중괄호로 명시
if (a)
{
if (b) DoX();
}
else
{
DoY();
}
C# 컴파일러는 else 를 가장 가까운 if 와 묶는 규칙(closest-match) 을 적용하므로, 위 ❌ 예시는 b == false 일 때 DoY() 가 호출됩니다. 직관과 다릅니다.
5.3 ❌ 부동소수점 == → ✅ 허용 오차 비교
// ❌
float x = 0.1f + 0.2f;
if (x == 0.3f) { /* 거의 절대 안 들어옵니다 */ }
float/double 은 이진 부동소수점이라 0.1, 0.3 같은 십진 분수를 정확히 표현하지 못합니다. 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 안전성까지는 잡아주지만 표현 오차에 의한 미세한 차이는 그대로 통과시킵니다.
// ✅ — 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
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# 7 이전 — 타입 검사와 캐스팅이 분리
void Interact_Old(object t)
{
if (t is Enemy)
{
Enemy e = (Enemy)t; // 같은 타입 검사가 한 번 더 일어남
e.TakeDamage(10);
}
}
// ✅ 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 로 비교하면 차이가 분명합니다.
// 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
}
// 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 가 더 적합합니다.
// ❌
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();
// ✅ — 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# 5 이전 — 명시적 null 체크
if (player != null)
{
if (player.Target != null)
{
player.Target.TakeDamage(10);
}
}
// C# 6+ — null 조건부 연산자
player?.Target?.TakeDamage(10);
// 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과 의미는 같지만, 사용자 정의==오버로딩(특히 UnityUnityEngine.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 문을 다룹니다.
'C# 기초' 카테고리의 다른 글
| [PART4.제어문: 분기와 반복(3/13)] switch 표현식 — 값을 돌려주는 분기 (0) | 2026.04.26 |
|---|---|
| [PART4.제어문: 분기와 반복(2/13)] switch 문 — 고전적 형태 (0) | 2026.04.26 |
| [PART3.연산자와 표현식(11/11)] 문자열 보간과 기본 포맷팅 — `$"..."` 한 줄이 만들어내는 차이 (0) | 2026.04.25 |
| [PART3.연산자와 표현식(10/11)] 사용자 정의 복합 할당 연산자 — `+=`·`-=` 를 직접 정의 (C# 14) (0) | 2026.04.25 |
| [PART3.연산자와 표현식(9/11)] checked · unchecked — 정수 오버플로 검사 제어 (0) | 2026.04.25 |