[PART3.연산자와 표현식(2/11)] 단락 평가(Short-Circuit) — &&와 ||가 뒤 조건을 평가 안 하는 경우
왼쪽 결과만으로 답이 정해지면 오른쪽을 건너뛴다 / 널 가드와 성능의 기본기 / IL 레벨에서 brfalse.s·brtrue.s로 분기
목차
1. 문제 제기 — 왜 이 개념이 없으면 NullReferenceException이 터지는가
Unity에서 GetComponent<Rigidbody>()를 호출하면 컴포넌트가 없을 때 null이 돌아옵니다. 그리고 바로 다음 줄에서 rb.velocity를 읽는 순간 NullReferenceException(널 참조 예외, 이하 NRE)이 터지고 프레임이 멈춥니다. 그래서 우리는 거의 반사적으로 이렇게 씁니다.
if (rb != null && rb.velocity.magnitude < 5f)
rb.AddForce(Vector3.up * 10f);
이 한 줄은 두 개의 조건이 하나로 묶여 있지만, rb가 null일 때 뒤쪽의 rb.velocity는 "실행되면 안 됩니다." 만약 왼쪽이 false인데도 오른쪽이 실행되면 NRE가 터집니다. 즉 이 코드가 안전하게 돌아가는 이유는 && 연산자가 왼쪽이 false면 오른쪽을 평가하지 않는다는 규칙을 갖고 있기 때문입니다. 이 규칙을 단락 평가(Short-Circuit Evaluation) 라고 부릅니다.
반대로 같은 코드를 &(앰퍼샌드 하나)로 쓰면 어떻게 될까요?
if (rb != null & rb.velocity.magnitude < 5f) // 연산자 하나만 바꿔도
rb.AddForce(Vector3.up * 10f); // rb가 null이면 크래시
컴파일 에러는 없습니다. &는 bool에도 쓸 수 있기 때문입니다. 그런데 실행하면 rb가 null인 순간 NRE가 납니다. 왜냐하면 &는 단락 평가를 하지 않고 양쪽을 전부 평가하기 때문입니다.
이 글에서 다룰 질문은 명확합니다.
&&·||가 정확히 언제 오른쪽을 생략하는가- 같은 자리에
&·|를 쓰면 왜 "무조건 양쪽 실행"이 되는가 - 삼항
? :는 이 동작과 어떻게 다른가 - Unity에서 왜 단락 평가를 잘못 쓰면
GetComponent를 두 번 호출하고, 왜?.를 쓰면 "파괴된 객체"를 놓치는가 - IL(Intermediate Language, CLR이 실행하는 중간 언어) 레벨에서 컴파일러가 실제로 무엇을 만드는가
결론부터 말하면, 단락 평가는 컴파일러가 IL에 brfalse.s/brtrue.s라는 조건부 점프 명령을 끼워 넣어 오른쪽 코드 블록을 통째로 건너뛰게 만드는 것이며, 이는 런타임의 마법이 아니라 컴파일 타임에 결정되는 제어 흐름입니다.
2. 개념 정의 — "왼쪽만 봐도 답이 나오면 오른쪽은 본다 하지 않는다"
2.1 비유: 통과 투표와 기각 투표
회의에서 두 사람 모두 찬성해야 안건이 통과된다고 가정합니다(&&와 동일). 첫 번째 사람이 반대표를 던지는 순간, 두 번째 사람에게 물어볼 필요가 없습니다. 어차피 부결이기 때문입니다. &&의 단락 평가가 정확히 이 동작입니다.
반대로 둘 중 한 명이라도 찬성하면 통과되는 안건이 있다면(||), 첫 번째 사람이 찬성하는 순간 두 번째 사람의 의견은 결과에 영향을 주지 못합니다. 이것이 ||의 단락 평가입니다.
2.2 시각화: &&와 ||의 분기 흐름

2.3 기본 코드: "생략됐다"는 사실을 눈으로 확인
백 마디 설명보다 실행해서 Console.WriteLine이 호출되는지 보는 게 빠릅니다.
using System;
class Demo
{
static bool A() { Console.WriteLine("A 호출됨"); return false; }
static bool B() { Console.WriteLine("B 호출됨"); return true; }
static void Main()
{
Console.WriteLine("[1] A() && B()");
bool r1 = A() && B();
Console.WriteLine("\n[2] A() || B()");
bool r2 = A() || B();
Console.WriteLine("\n[3] B() && A()");
bool r3 = B() && A();
}
}
실행 결과:
[1] A() && B()
A 호출됨 ← B는 호출되지 않음 (A가 false여서 단락)
[2] A() || B()
A 호출됨
B 호출됨 ← A가 false라 답이 아직 확정 안 됨, B도 평가
[3] B() && A()
B 호출됨 ← B가 true니까 일단 계속
A 호출됨 ← A 평가까지 가야 답이 확정
단락 평가는 "언제나 한쪽을 생략"하는 게 아닙니다. 왼쪽의 결과로 전체 답이 확정됐을 때만 생략합니다. &&는 왼쪽이 false일 때, ||는 왼쪽이 true일 때가 그 조건입니다.
2.4 IL 분석: brfalse.s로 점프해 오른쪽을 건너뛴다
bool IsValidUser(string s) => s != null && s.Length > 0; 를 컴파일한 IL입니다.
.method public hidebysig static bool IsValidUser (string s)
{
IL_0000: ldarg.0 // 스택에 s
IL_0001: brfalse.s IL_000d // s == null 이면 IL_000d로 점프 (오른쪽 건너뜀)
IL_0003: ldarg.0
IL_0004: callvirt int32 System.String::get_Length() // s.Length
IL_0009: ldc.i4.0
IL_000a: cgt // length > 0
IL_000c: ret // 결과 반환
IL_000d: ldc.i4.0 // false를 스택에 올리고
IL_000e: ret // 바로 반환
}
핵심 명령은 brfalse.s IL_000d 한 줄입니다. "스택 최상단 값이 false(또는 null, 0)면 짧은 오프셋 IL_000d로 분기하라"는 뜻입니다. 컴파일러는 &&를 만나면 왼쪽 평가 직후에 이 점프 명령을 끼워 넣어 오른쪽 블록(IL_0003~IL_000c)을 통째로 건너뛸 수 있게 만듭니다. 이것이 단락 평가의 실체입니다 — 런타임이 "똑똑해서" 생략하는 게 아니라, 컴파일러가 애초에 생략할 수 있는 제어 흐름을 IL에 박아 넣습니다.
brfalse.s/brtrue.s— 조건부 짧은 분기 (Branch if false/true, short form) 스택 최상단 값이 각각false/0/null(또는true/0이 아닌 값/null이 아닌 참조)이면 지정된 오프셋으로 점프한다..s접미사는 1바이트 오프셋(-128~+127)을 쓰는 짧은 형식이다. 점프 거리가 멀면brfalse/brtrue(4바이트 오프셋)가 대신 나온다.
예시:brfalse.s IL_000d— 스택 값이 false면 IL_000d 레이블로 이동, 중간 명령 전부 스킵.
3. 내부 동작 — 컴파일러가 bool 표현식을 IL로 옮기는 방식
3.1 "어차피 bool이면 그냥 and/or 쓰면 되지 않나?"
놀랍게도 컴파일러는 상황에 따라 단락 평가를 생략합니다. 아래 두 함수를 비교해 봅니다.
// 케이스 A: 피연산자가 이미 평가된 bool 지역변수/파라미터
public static bool AndShort(bool x, bool y) => x && y;
// 케이스 B: 오른쪽에 메서드 호출이 있음
public static bool IsValidUser(string s) => s != null && s.Length > 0;
실제 IL:
// AndShort
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: and ← 분기 없음! 비트 AND 하나로 끝
IL_0003: ret
// IsValidUser
IL_0000: ldarg.0
IL_0001: brfalse.s IL_000d ← 분기 있음
IL_0003: ldarg.0
IL_0004: callvirt ... ← s.Length 호출
...
관찰 사실: 오른쪽이 "이미 메모리에 있는 bool 값"일 뿐 부수 효과가 없으면, 컴파일러는 분기 비용을 아끼고 and 한 줄로 대체합니다. 이 경우 "단락"이 일어나지 않은 것처럼 보이지만, 의미론적으로 차이가 없습니다(양쪽 다 단순 값 로드라 어느 쪽을 먼저 읽든 결과 동일). 오른쪽에 메서드 호출이나 필드 접근 같은 부수 효과를 가진 표현식이 오면 그때 brfalse.s/brtrue.s가 등장합니다.
이는 C# 언어 스펙이 요구하는 동작은 아닙니다. 언어 차원에서&&는 항상 단락이어야 합니다. 다만 옵티마이저가 "부수 효과가 없으면 어차피 결과 동일"이라고 판단해 비분기 형태로 만드는 것이며, 개발자가&&를 쓰는 한 부수 효과가 있는 오른쪽은 반드시 생략됩니다. 위AndShort는 그 생략의 대상이 없는 특수 케이스일 뿐입니다.
3.2 3중 &&의 IL: 분기 체인
public static bool CanAttack(Player p) =>
p != null && p.IsAlive && p.Hp > 0;
IL_0000: ldarg.0
IL_0001: brfalse.s IL_0015 ← p == null 이면 공통 탈출구로 점프
IL_0003: ldarg.0
IL_0004: ldfld bool Player::IsAlive
IL_0009: brfalse.s IL_0015 ← IsAlive == false 여도 같은 탈출구
IL_000b: ldarg.0
IL_000c: ldfld int32 Player::Hp
IL_0011: ldc.i4.0
IL_0012: cgt ← Hp > 0
IL_0014: ret
IL_0015: ldc.i4.0 ← 공통 false 반환 지점
IL_0016: ret
핵심 관찰: 여러 개의 &&가 연결되면 모든 단락 분기가 같은 "false 반환" 지점(IL_0015) 을 가리킵니다. 이것이 가드 패턴의 효율이 좋은 구조적 이유입니다 — 어느 조건에서 떨어지든 한 곳으로 모이고, Hp 같은 마지막 필드 접근은 앞 조건이 전부 통과됐을 때만 실행됩니다.
3.3 ||의 IL: brtrue.s로 위쪽으로 점프
public static bool OrExample(string s) => s == null || s.Length == 0;
컴파일러는 ||를 보면 왼쪽 평가 직후에 brtrue.s를 끼워 "이미 true니까 결론 블록으로 점프"시킵니다. &&의 brfalse.s를 거꾸로 뒤집은 모양입니다.
4. 실전 적용 — Unity에서 안 쓰면 안 되는 패턴과 쓰면 안 되는 패턴
4.1 가드 패턴: != null && 멤버 접근
이 패턴이 있기 때문에 우리는 Unity에서 널 체크와 멤버 접근을 한 줄에 쓸 수 있습니다.

Before(단락 평가 없이 & 사용):
public class Player { public bool IsAlive; }
public static bool CheckBit(Player p) => p != null & p.IsAlive;
// p가 null이면 p.IsAlive에서 NullReferenceException
IL:
IL_0000: ldarg.0
IL_0001: ldnull
IL_0002: cgt.un ← p != null 계산 (결과 스택에 남김)
IL_0003: ldarg.0
IL_0004: ldfld bool Player::IsAlive ← 여기서 p가 null이면 NRE
IL_0009: and
IL_000a: ret
점프 명령이 하나도 없습니다. cgt.un 직후 바로 ldfld가 실행되기 때문에 p가 null이어도 필드 접근을 피할 길이 없습니다.
After(&& 사용):
public static bool CheckShort(Player p) => p != null && p.IsAlive;
IL:
IL_0000: ldarg.0
IL_0001: brfalse.s IL_000a ← p가 null이면 IL_000a로 점프 → false 반환
IL_0003: ldarg.0
IL_0004: ldfld bool Player::IsAlive
IL_0009: ret
IL_000a: ldc.i4.0
IL_000b: ret
brfalse.s가 p == null을 감지해 ldfld를 건너뜁니다. 연산자 하나만 바꿔도 컴파일러가 만드는 IL이 완전히 달라집니다.
4.2 비싼 함수 호출을 왼쪽으로
단락 평가는 "왼쪽부터" 평가합니다. 이 순서에 따라 성능이 크게 바뀝니다.
// 나쁜 예: 비싼 작업을 먼저 함
if (LoadItemsFromDatabase().Count > 100 && isAdmin) { ... }
// ↑ 매번 DB 호출됨, isAdmin이 false여도 이미 호출 끝남
// 좋은 예: 값싼 조건을 왼쪽에
if (isAdmin && LoadItemsFromDatabase().Count > 100) { ... }
// ↑ isAdmin이 false면 DB 호출 자체가 생략됨
Unity 핫패스(Update·FixedUpdate 같이 매 프레임 호출되는 메서드)에서는 이 순서 하나로 프레임 타임이 달라집니다. 단락 평가를 전제로 "값싼 조건 → 비싼 조건" 순으로 배치하는 것이 기본기입니다.
4.3 GetComponent 체인의 함정
// 나쁜 예: GetComponent를 두 번 호출
if (GetComponent<Rigidbody>() != null && GetComponent<Rigidbody>().velocity.y < 0f)
{
// Rigidbody가 있으면 GetComponent가 두 번 실행됨
}
GetComponent<T>()는 컴포넌트 배열을 순회하는 작업이라 핫패스에서 Unity가 직접 "캐시하라"고 권고하는 호출입니다. 위 코드는 단락 평가 덕분에 안전하지만, 성능이 최악입니다.
After:
var rb = GetComponent<Rigidbody>();
if (rb != null && rb.velocity.y < 0f)
{
// GetComponent 한 번만
}
// 더 좋은 예 (Unity 2019.2+): TryGetComponent
if (TryGetComponent<Rigidbody>(out var rb2) && rb2.velocity.y < 0f)
{
// null 반환 관련 관리 할당도 없음
}
IL 관점: var rb = GetComponent<...>()는 결과를 지역 변수에 저장(stloc.0 등)하고, 이후 ldloc.0으로 꺼내 쓰기 때문에 call이 한 번만 나옵니다. 단락 평가는 그대로 유지됩니다(rb != null → brfalse.s → 필드 접근).
4.4 LayerMask 체크
// Unity에서 흔한 LayerMask 비트 체크
if (hit.collider != null && ((1 << hit.collider.gameObject.layer) & layerMask) != 0)
{
// 여기서 hit.collider.gameObject는 null-safe
}
이 패턴은 단락 평가 없이는 성립하지 않습니다. hit.collider가 null일 때 뒤쪽의 .gameObject.layer가 먼저 평가되면 NRE가 납니다.
5. 함정과 주의사항
5.1 &·|로 실수로 바꿔 쓰기
가장 흔한 타이핑 실수입니다. 특히 bool 타입에 쓸 수 있다 보니 컴파일 에러가 나지 않습니다.
// ❌ 잘못된 예
if (player != null & player.IsAlive) { ... } // player가 null이면 NRE
// ✅ 올바른 예
if (player != null && player.IsAlive) { ... } // 단락 평가로 안전
IL로 확인한 차이는 4.1의 예제 그대로입니다. & 쪽에는 brfalse.s가 없고, && 쪽에는 있습니다.
5.2 부수 효과를 기대하고 &&를 쓰기
// ❌ 잘못된 예: 오른쪽 메서드가 상태를 바꾸는데 단락 평가로 호출이 생략됨
if (IsValid() && LogAccess()) // IsValid가 false면 LogAccess 호출 안 됨
{ ... }
// ✅ 올바른 예: 부수 효과를 분리
bool valid = IsValid();
bool logged = LogAccess();
if (valid && logged) { ... }
// 또는 정말 양쪽 모두 실행하고 싶다면 (드물게) & 사용
if (IsValid() & LogAccess()) { ... }
지침: 조건식 오른쪽에 상태를 바꾸는 메서드 호출을 넣지 않습니다. 단락 평가가 예고 없이 생략할 수 있습니다. 부수 효과가 필요한 연산은 조건식 밖으로 빼냅니다.
5.3 Unity의 "가짜 널"과 ?. 연산자
?.— 널 조건부 연산자 (Null-conditional operator) 왼쪽 피연산자가 null이면 전체 표현식이 null이 되고 오른쪽 멤버 접근은 평가되지 않는다.&&로 묶은 널 가드와 동일한 "단락" 의미를 더 짧게 표현한다.
예시:obj?.Method()— obj가 null이면 Method 호출 없이 null 반환.
일반적인 C# 클래스라면 obj != null && obj.Method() 와 obj?.Method() 는 의미가 같습니다. 그런데 UnityEngine.Object(즉 GameObject, Component, MonoBehaviour 등)는 예외입니다.
Unity 엔진은 C# 래퍼 객체와 네이티브 C++ 객체를 쌍으로 관리합니다. Destroy() 호출 후 C# 가비지 컬렉터가 아직 래퍼를 회수하지 않은 순간, 이 객체는 "파괴됐지만 C# 참조상 null은 아닌" 상태가 됩니다. Unity는 이를 위해 UnityEngine.Object의 == 연산자를 오버로딩해서 "네이티브가 파괴됐으면 null처럼 취급"하도록 만들었습니다. 그래서 if (go != null) 은 기대대로 동작합니다.
하지만 ?. 는 연산자 오버로딩을 타지 않고 순수한 CLR 참조가 null인지만 봅니다.
// ❌ Unity에서 위험한 예
GameObject target = someReference;
Destroy(target);
// 한 프레임 뒤...
target?.SetActive(false);
// target은 "파괴"되었지만 CLR 참조는 null이 아님
// → ?. 가 "null 아님"으로 판단 → SetActive 호출 → MissingReferenceException
// ✅ Unity 안전한 예
if (target != null) // 오버로딩된 ==가 파괴 상태를 감지
target.SetActive(false);
관찰 사실: target != null && target.SetActive(...) 은 == 오버로딩 덕분에 안전합니다. 반면 target?.SetActive(...) 은 오버로딩을 무시하므로 UnityEngine.Object에는 쓰지 않습니다. 일반 C# 클래스(예: List<T>, 직접 만든 데이터 클래스)에는 ?.가 그대로 안전합니다.
5.4 단락 평가와 삼항 연산자를 혼동
삼항 c ? A : B 도 "한쪽만 평가한다"는 점에서 단락 평가와 비슷해 보입니다. 실제 IL도 brtrue.s로 분기합니다.
public static int Ternary(bool c) => c ? A1() : B1();
IL_0000: ldarg.0
IL_0001: brtrue.s IL_0009 ← c가 true면 A1 호출 블록으로 점프
IL_0003: call int32 B1()
IL_0008: ret
IL_0009: call int32 A1()
IL_000e: ret
차이점은 두 가지입니다.
| 구분 | && / ` |
` | ? : |
|
|---|---|---|---|---|
| 반환 타입 | 반드시 bool |
임의의 타입 | ||
| 목적 | 논리 조건을 이어 붙이는 것 — 참/거짓 확정 | 두 값 중 하나를 고르는 것 | ||
| 생략되는 쪽 | 결과가 이미 확정된 뒤의 오른쪽 | 선택되지 않은 분기 |
// ❌ 잘못된 예: 삼항으로 단락 평가 흉내
bool result = player != null ? player.IsAlive : false; // 장황
// ✅ 단락 평가가 더 자연스러움
bool result = player != null && player.IsAlive;
역으로 "두 값 중 하나를 고르는" 상황에 &&를 쓰면 읽기 힘듭니다.
// ❌
string label = isAdmin && true || false; // 의도 불분명
// ✅
string label = isAdmin ? "관리자" : "일반";
5.5 ?? 와 헷갈리기
??(널 병합)은 "왼쪽이 null이면 오른쪽"을 고르는 연산자입니다. 이것도 단락 평가입니다(왼쪽이 non-null이면 오른쪽 평가 생략). 단, 반환이 bool이 아니라 피연산자 타입 자체입니다.
string name = input ?? "익명"; // input이 null이 아니면 "익명"은 평가 안 됨
int? x = maybe ?? ComputeDefault(); // maybe가 non-null이면 ComputeDefault 호출 안 됨
Unity Object의 "가짜 null" 함정은 ?? 에도 동일하게 적용됩니다. ?? 는 CLR 참조 null만 봅니다.
6. C# 버전별 변화
단락 평가 연산자 &&/|| 자체는 C# 1.0부터 지금까지 동작이 동일합니다. 다만 "널을 다루는 관용구"가 C# 버전마다 개선되며 단락 평가와 결합하는 방식이 바뀌었습니다.
C# 1.0~5: 고전적인 && 가드
if (player != null && player.IsAlive && player.Hp > 0) { ... }
가장 단순하고 Unity에서도 여전히 표준입니다.
C# 6.0: ?. 와 ?.[] 도입 — 일반 C# 클래스에서만 사용
// C# 6.0
int? count = list?.Count; // list가 null이면 null, 아니면 list.Count
// C# 6.0 이전
int? count = list != null ? (int?)list.Count : null;
Unity 컴포넌트에는 이미 언급한 이유로 주의해서 씁니다.
C# 7.0: 패턴 매칭과 is — 단락 평가와 자연스럽게 결합
// C# 7.0+
if (obj is Player p && p.IsAlive) { ... }
// is 연산자가 true일 때 p에 캐스트 결과가 대입됨.
// 왼쪽이 false면 && 단락 평가로 p.IsAlive는 평가되지 않음.
IL 수준에서 is Player p는 isinst(타입 검사) + stloc(지역변수 대입) 조합으로 풀리고, 뒤에 brfalse.s가 붙어 동일하게 단락합니다.
C# 8.0: ??= 널 병합 대입
// C# 8.0
_cached ??= ExpensiveInit(); // _cached가 null일 때만 오른쪽 호출
오른쪽 함수는 좌변이 null일 때만 실행됩니다. && 가드의 또 다른 사용 패턴("null이면 초기화")을 축약한 형태입니다.
C# 9.0: 패턴 매칭 확장 — 논리 패턴
// C# 9.0+
if (player is { IsAlive: true, Hp: > 0 }) { ... }
이 패턴은 내부적으로 player != null·IsAlive == true·Hp > 0를 &&로 엮은 것과 동일한 IL로 컴파일됩니다. 즉, 단락 평가의 문법적 설탕입니다. 가독성이 좋지만 동작은 3중 &&와 같습니다.
C# 10~13: 동작 변경 없음
단락 평가 연산자의 의미론은 초기 설계 이후 바뀐 적이 없습니다. 개선된 것은 주변 문법(패턴·널 조건부·is not null)이지 &&/|| 자체가 아닙니다.
7. 정리
&&는 왼쪽이false면 오른쪽을 평가하지 않습니다.||는 왼쪽이true면 오른쪽을 평가하지 않습니다. 이것이 단락 평가입니다.- 컴파일러는 이 의미론을 IL의
brfalse.s(거짓이면 점프) /brtrue.s(참이면 점프) 명령으로 구현합니다. 오른쪽 블록이 통째로 스킵됩니다. &·|는bool에도 동작하지만 단락 평가가 아닙니다 — 양쪽 모두 평가합니다. 타이핑 실수로 NRE가 나는 가장 흔한 원인입니다.- 삼항
? :도 한쪽만 평가하지만, 목적이 다릅니다. "논리 조건의 조기 확정"은&&/||, "두 값 중 하나 선택"은? :. - Unity에서
if (obj != null && obj.Member)패턴은==오버로딩 + 단락 평가의 조합으로 안전합니다. 반면obj?.Member는 오버로딩을 우회하므로UnityEngine.Object에는 쓰지 않습니다. - 성능을 위해 조건식은 값싼 것을 왼쪽에, 비싼 것을 오른쪽에 배치합니다.
GetComponent같은 호출은 변수에 캐시하고 그 변수로 단락 평가를 겁니다. - C# 9 이후의
{ IsAlive: true, Hp: > 0 }패턴은&&체인의 문법 설탕입니다 — 같은 IL로 내려갑니다.
체크리스트:
- [ ] 조건식에 메서드 호출 있으면
&가 아니라&&를 쓰는가? - [ ] 왼쪽이 널 가드 역할을 하고 있는가? 오른쪽은 왼쪽이 통과했을 때만 안전한가?
- [ ] 비싼 호출(
GetComponent·DB·파일 I/O)이 오른쪽에 오도록 배치했는가? - [ ] Unity
Object에?.을 쓰고 있지 않은가? 대신!= null &&인가? - [ ] 조건식 오른쪽에 상태를 바꾸는 부수 효과가 들어 있진 않은가?