반응형

[PART3.연산자와 표현식(2/11)] 단락 평가(Short-Circuit) — &&||가 뒤 조건을 평가 안 하는 경우

왼쪽 결과만으로 답이 정해지면 오른쪽을 건너뛴다 / 널 가드와 성능의 기본기 / IL 레벨에서 brfalse.s·brtrue.s로 분기


1. 문제 제기 — 왜 이 개념이 없으면 NullReferenceException이 터지는가

Unity에서 GetComponent<Rigidbody>()를 호출하면 컴포넌트가 없을 때 null이 돌아옵니다. 그리고 바로 다음 줄에서 rb.velocity를 읽는 순간 NullReferenceException(널 참조 예외, 이하 NRE)이 터지고 프레임이 멈춥니다. 그래서 우리는 거의 반사적으로 이렇게 씁니다.

C#
if (rb != null && rb.velocity.magnitude < 5f)
    rb.AddForce(Vector3.up * 10f);

이 한 줄은 두 개의 조건이 하나로 묶여 있지만, rbnull일 때 뒤쪽의 rb.velocity는 "실행되면 안 됩니다." 만약 왼쪽이 false인데도 오른쪽이 실행되면 NRE가 터집니다. 즉 이 코드가 안전하게 돌아가는 이유는 && 연산자가 왼쪽이 false면 오른쪽을 평가하지 않는다는 규칙을 갖고 있기 때문입니다. 이 규칙을 단락 평가(Short-Circuit Evaluation) 라고 부릅니다.

반대로 같은 코드를 &(앰퍼샌드 하나)로 쓰면 어떻게 될까요?

C#
if (rb != null & rb.velocity.magnitude < 5f)  // 연산자 하나만 바꿔도
    rb.AddForce(Vector3.up * 10f);             // rb가 null이면 크래시

컴파일 에러는 없습니다. &bool에도 쓸 수 있기 때문입니다. 그런데 실행하면 rbnull인 순간 NRE가 납니다. 왜냐하면 &는 단락 평가를 하지 않고 양쪽을 전부 평가하기 때문입니다.

이 글에서 다룰 질문은 명확합니다.

  • &&·||가 정확히 언제 오른쪽을 생략하는가
  • 같은 자리에 &·|를 쓰면 왜 "무조건 양쪽 실행"이 되는가
  • 삼항 ? : 는 이 동작과 어떻게 다른가
  • Unity에서 왜 단락 평가를 잘못 쓰면 GetComponent를 두 번 호출하고, 왜 ?.를 쓰면 "파괴된 객체"를 놓치는가
  • IL(Intermediate Language, CLR이 실행하는 중간 언어) 레벨에서 컴파일러가 실제로 무엇을 만드는가

결론부터 말하면, 단락 평가는 컴파일러가 IL에 brfalse.s/brtrue.s라는 조건부 점프 명령을 끼워 넣어 오른쪽 코드 블록을 통째로 건너뛰게 만드는 것이며, 이는 런타임의 마법이 아니라 컴파일 타임에 결정되는 제어 흐름입니다.


2. 개념 정의 — "왼쪽만 봐도 답이 나오면 오른쪽은 본다 하지 않는다"

2.1 비유: 통과 투표와 기각 투표

회의에서 두 사람 모두 찬성해야 안건이 통과된다고 가정합니다(&&와 동일). 첫 번째 사람이 반대표를 던지는 순간, 두 번째 사람에게 물어볼 필요가 없습니다. 어차피 부결이기 때문입니다. &&의 단락 평가가 정확히 이 동작입니다.

반대로 둘 중 한 명이라도 찬성하면 통과되는 안건이 있다면(||), 첫 번째 사람이 찬성하는 순간 두 번째 사람의 의견은 결과에 영향을 주지 못합니다. 이것이 ||의 단락 평가입니다.

2.2 시각화: &&||의 분기 흐름

A && B  (논리곱)

2.3 기본 코드: "생략됐다"는 사실을 눈으로 확인

백 마디 설명보다 실행해서 Console.WriteLine이 호출되는지 보는 게 빠릅니다.

C#
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입니다.

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 쓰면 되지 않나?"

놀랍게도 컴파일러는 상황에 따라 단락 평가를 생략합니다. 아래 두 함수를 비교해 봅니다.

C#
// 케이스 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:

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: 분기 체인

C#
public static bool CanAttack(Player p) =>
    p != null && p.IsAlive && p.Hp > 0;
IL
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로 위쪽으로 점프

C#
public static bool OrExample(string s) => s == null || s.Length == 0;

컴파일러는 ||를 보면 왼쪽 평가 직후에 brtrue.s를 끼워 "이미 true니까 결론 블록으로 점프"시킵니다. &&brfalse.s를 거꾸로 뒤집은 모양입니다.


4. 실전 적용 — Unity에서 안 쓰면 안 되는 패턴과 쓰면 안 되는 패턴

4.1 가드 패턴: != null && 멤버 접근

이 패턴이 있기 때문에 우리는 Unity에서 널 체크와 멤버 접근을 한 줄에 쓸 수 있습니다.

❌ Before — & 사용

Before(단락 평가 없이 & 사용):

C#
public class Player { public bool IsAlive; }

public static bool CheckBit(Player p) => p != null & p.IsAlive;
// p가 null이면 p.IsAlive에서 NullReferenceException

IL:

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가 실행되기 때문에 pnull이어도 필드 접근을 피할 길이 없습니다.

After(&& 사용):

C#
public static bool CheckShort(Player p) => p != null && p.IsAlive;

IL:

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.sp == null을 감지해 ldfld를 건너뜁니다. 연산자 하나만 바꿔도 컴파일러가 만드는 IL이 완전히 달라집니다.

4.2 비싼 함수 호출을 왼쪽으로

단락 평가는 "왼쪽부터" 평가합니다. 이 순서에 따라 성능이 크게 바뀝니다.

C#
// 나쁜 예: 비싼 작업을 먼저 함
if (LoadItemsFromDatabase().Count > 100 && isAdmin) { ... }
//  ↑ 매번 DB 호출됨, isAdmin이 false여도 이미 호출 끝남

// 좋은 예: 값싼 조건을 왼쪽에
if (isAdmin && LoadItemsFromDatabase().Count > 100) { ... }
//  ↑ isAdmin이 false면 DB 호출 자체가 생략됨

Unity 핫패스(Update·FixedUpdate 같이 매 프레임 호출되는 메서드)에서는 이 순서 하나로 프레임 타임이 달라집니다. 단락 평가를 전제로 "값싼 조건 → 비싼 조건" 순으로 배치하는 것이 기본기입니다.

4.3 GetComponent 체인의 함정

C#
// 나쁜 예: GetComponent를 두 번 호출
if (GetComponent<Rigidbody>() != null && GetComponent<Rigidbody>().velocity.y < 0f)
{
    // Rigidbody가 있으면 GetComponent가 두 번 실행됨
}

GetComponent<T>()는 컴포넌트 배열을 순회하는 작업이라 핫패스에서 Unity가 직접 "캐시하라"고 권고하는 호출입니다. 위 코드는 단락 평가 덕분에 안전하지만, 성능이 최악입니다.

After:

C#
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 != nullbrfalse.s → 필드 접근).

4.4 LayerMask 체크

C#
// Unity에서 흔한 LayerMask 비트 체크
if (hit.collider != null && ((1 << hit.collider.gameObject.layer) & layerMask) != 0)
{
    // 여기서 hit.collider.gameObject는 null-safe
}

이 패턴은 단락 평가 없이는 성립하지 않습니다. hit.collidernull일 때 뒤쪽의 .gameObject.layer가 먼저 평가되면 NRE가 납니다.


5. 함정과 주의사항

5.1 &·|로 실수로 바꿔 쓰기

가장 흔한 타이핑 실수입니다. 특히 bool 타입에 쓸 수 있다 보니 컴파일 에러가 나지 않습니다.

C#
// ❌ 잘못된 예
if (player != null & player.IsAlive) { ... }  // player가 null이면 NRE
C#
// ✅ 올바른 예
if (player != null && player.IsAlive) { ... } // 단락 평가로 안전

IL로 확인한 차이는 4.1의 예제 그대로입니다. & 쪽에는 brfalse.s가 없고, && 쪽에는 있습니다.

5.2 부수 효과를 기대하고 &&를 쓰기

C#
// ❌ 잘못된 예: 오른쪽 메서드가 상태를 바꾸는데 단락 평가로 호출이 생략됨
if (IsValid() && LogAccess())  // IsValid가 false면 LogAccess 호출 안 됨
{ ... }
C#
// ✅ 올바른 예: 부수 효과를 분리
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인지만 봅니다.

C#
// ❌ Unity에서 위험한 예
GameObject target = someReference;
Destroy(target);
// 한 프레임 뒤...
target?.SetActive(false);
// target은 "파괴"되었지만 CLR 참조는 null이 아님
// → ?. 가 "null 아님"으로 판단 → SetActive 호출 → MissingReferenceException
C#
// ✅ 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로 분기합니다.

C#
public static int Ternary(bool c) => c ? A1() : B1();
IL
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 임의의 타입
목적 논리 조건을 이어 붙이는 것 — 참/거짓 확정 두 값 중 하나를 고르는 것
생략되는 쪽 결과가 이미 확정된 뒤의 오른쪽 선택되지 않은 분기
C#
// ❌ 잘못된 예: 삼항으로 단락 평가 흉내
bool result = player != null ? player.IsAlive : false;  // 장황
C#
// ✅ 단락 평가가 더 자연스러움
bool result = player != null && player.IsAlive;

역으로 "두 값 중 하나를 고르는" 상황에 &&를 쓰면 읽기 힘듭니다.

C#
// ❌
string label = isAdmin && true || false;  // 의도 불분명

// ✅
string label = isAdmin ? "관리자" : "일반";

5.5 ?? 와 헷갈리기

??(널 병합)은 "왼쪽이 null이면 오른쪽"을 고르는 연산자입니다. 이것도 단락 평가입니다(왼쪽이 non-null이면 오른쪽 평가 생략). 단, 반환이 bool이 아니라 피연산자 타입 자체입니다.

C#
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: 고전적인 && 가드

C#
if (player != null && player.IsAlive && player.Hp > 0) { ... }

가장 단순하고 Unity에서도 여전히 표준입니다.

C# 6.0: ?.?.[] 도입 — 일반 C# 클래스에서만 사용

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#
// C# 7.0+
if (obj is Player p && p.IsAlive) { ... }
// is 연산자가 true일 때 p에 캐스트 결과가 대입됨.
// 왼쪽이 false면 && 단락 평가로 p.IsAlive는 평가되지 않음.

IL 수준에서 is Player pisinst(타입 검사) + stloc(지역변수 대입) 조합으로 풀리고, 뒤에 brfalse.s가 붙어 동일하게 단락합니다.

C# 8.0: ??= 널 병합 대입

C#
// C# 8.0
_cached ??= ExpensiveInit();  // _cached가 null일 때만 오른쪽 호출

오른쪽 함수는 좌변이 null일 때만 실행됩니다. && 가드의 또 다른 사용 패턴("null이면 초기화")을 축약한 형태입니다.

C# 9.0: 패턴 매칭 확장 — 논리 패턴

C#
// 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 && 인가?
  • [ ] 조건식 오른쪽에 상태를 바꾸는 부수 효과가 들어 있진 않은가?
반응형

+ Recent posts