반응형

[PART3.연산자와 표현식(6/11)] 삼항(조건) 연산자 — 조건 ? A : B

값을 반환하는 if-else / 표현식 vs 문의 경계 / 중첩의 가독성 함정 / Target-typed (C# 9) / switch 표현식과의 분업 / IL brtrue.s로 본 내부 동작


1. 문제 제기 — "한 줄짜리 if-else"가 정말 필요한가

Unity 신입 개발자가 플레이어 이동 스크립트를 처음 만들면, 쉬프트 키를 누를 때 속도를 두 배로 올리고 싶다는 요구가 거의 100% 나옵니다. 머릿속에 떠오르는 첫 번째 코드는 보통 이렇습니다.

C#
float speed;
if (Input.GetKey(KeyCode.LeftShift))
{
    speed = 10f;
}
else
{
    speed = 5f;
}
transform.position += transform.forward * speed * Time.deltaTime;

동작에는 문제가 없습니다. 하지만 한 가지 값을 고르기 위해 6줄을 쓰는 건 과하게 느껴집니다. 같은 파일에 비슷한 분기가 열 개쯤 생기면 Update() 메서드가 금세 화면을 넘어갑니다.

C#은 이런 상황을 위해 값을 직접 반환하는 한 줄짜리 분기 문법을 준비해 두었습니다. 바로 삼항 조건 연산자 ?:입니다.

C#
float speed = Input.GetKey(KeyCode.LeftShift) ? 10f : 5f;

6줄이 1줄로 줄었고, 변수 선언과 값 결정이 한 번에 끝났습니다. 하지만 이 간결함에는 대가가 있습니다. 중첩하면 가독성이 급락하고, 양쪽 분기 타입이 어긋나면 "best common type" 오류가 쏟아지고, C# 9 이전에는 null과 함께 쓰는 순간 컴파일이 안 됐습니다.

이 글은 언제 ?:를 쓰고 언제 피해야 하는지를 이해 가능한 규칙으로 풀어놓는 것이 목표입니다. 문법의 핵심은 "값을 내뿜는 표현식"이라는 한마디에 모두 들어 있습니다. 이 한마디가 왜 이토록 중요한지부터 시작하겠습니다.


2. 개념 정의 — 삼항은 "값을 내는 표현식"이다

비유: if-else는 "지시문", 삼항은 "답안지"

if-else?:의 관계는 회의실에서 "A안으로 갈지 B안으로 갈지 판단하고 실행해"라는 지시문(문, statement)과 "A안입니다"라고 적힌 답안지(표현식, expression)의 차이와 같습니다. 지시문은 뭔가를 하게 만들지만 그 자체가 결과가 되진 못합니다. 답안지는 그 자체가 하나의 값이라 다른 자리에 그대로 끼워 넣을 수 있습니다.

?: — 조건(삼항) 연산자 (Conditional / Ternary operator) 조건이 true면 첫 번째 값, false면 두 번째 값을 반환하는 표현식. C#에서 유일하게 피연산자가 3개인 연산자라서 "삼항"이라는 별명이 붙었다.
예시: int max = a > b ? a : b; a와 b 중 큰 값을 max에 대입

구조 시각화

삼항 연산자의 평가 흐름

조건 한 개가 두 갈래 중 하나만 실행하고, 그 결과가 같은 자리의 값으로 합류합니다. 주황색 "하나의 결과값"이 표현식의 본질입니다 — 값이 나오기 때문에 이걸 변수에 대입할 수도, 메서드 인자로 넘길 수도, return 뒤에 바로 쓸 수도 있습니다.

기본 C# 코드

C#
public static int TernaryBasic(bool cond)
{
    return cond ? 10 : 20;
}

Unity에서는 이런 상황이 생깁니다 — "조건에 따라 값 하나만 고르고 싶은데, 별도 변수나 if 블록을 쓰기는 아깝다."

IL 분석

실제 C# 컴파일러가 위 메서드를 어떻게 번역하는지 확인합니다. IL(Intermediate Language, .NET이 실행 전에 거치는 중간 언어)은 CPU가 직접 실행하지는 않지만, 컴파일러가 C# 코드를 어떻게 이해했는지를 가장 정확히 보여주는 "설계도"입니다.

IL
.method public hidebysig static 
    int32 TernaryBasic (
        bool cond
    ) cil managed 
{
    .maxstack 1
    .locals init (
        [0] int32                  // 결과를 담는 지역 변수 1개
    )

    IL_0000: nop
    IL_0001: ldarg.0               // cond 값을 스택에 로드
    IL_0002: brtrue.s IL_0008      // cond == true 면 IL_0008 (true 분기)로 점프

    IL_0004: ldc.i4.s 20           // false 분기: 20을 스택에
    IL_0006: br.s IL_000a          // 결과 합류 지점(IL_000a)으로 무조건 점프

    IL_0008: ldc.i4.s 10           // true 분기: 10을 스택에
                                   // (이어서 fall-through로 IL_000a로 감)

    IL_000a: stloc.0               // 합류: 스택 top을 지역변수[0]에 저장
    IL_000b: br.s IL_000d
    IL_000d: ldloc.0               // 결과 값을 스택에
    IL_000e: ret                   // 반환
}

세 가지만 봐 두시면 됩니다.

  1. brtrue.s IL_0008 — 조건이 true면 true 분기 위치로 점프. "삼항이 어떻게 분기되는가"의 실제 답.
  2. 한쪽만 실행brtrue.s로 점프하면 ldc.i4.s 20(false 분기)은 건너뜁니다. 점프하지 않으면 바로 false 분기 코드가 실행되고 br.s IL_000a로 true 쪽을 뛰어넘어갑니다. 한 방향만 실행되는 것이 단락 평가(뒤에서 다룸)의 IL 레벨 증거입니다.
  3. 같은 스택 슬롯으로 합류 — true 분기든 false 분기든 모두 IL_000a: stloc.0에 도착합니다. 두 분기의 결과 타입이 같아야 한다는 제약(CS0173)이 여기서 나옵니다. 서로 다른 타입이면 stloc.0에 뭘 저장해야 할지 정할 수 없습니다.

3. 내부 동작 — 삼항과 if-else는 IL에서 얼마나 다른가

결론부터: 거의 차이가 없습니다. 하지만 "거의"라는 단서를 이해해 두면 추후 성능 걱정을 이유 없이 하지 않을 수 있습니다.

if-else와 삼항의 IL 비교

C#
// 삼항 버전
public static int TernaryBasic(bool cond) => cond ? 10 : 20;

// if-else 버전
public static int IfElseBasic(bool cond)
{
    if (cond) return 10;
    else return 20;
}

두 메서드의 IL을 나란히 놓으면 차이가 눈에 들어옵니다.

IL
// === TernaryBasic ===
.locals init ( [0] int32 )         // 지역변수 1개

IL_0001: ldarg.0
IL_0002: brtrue.s IL_0008          // true 분기로 점프
IL_0004: ldc.i4.s 20
IL_0006: br.s IL_000a
IL_0008: ldc.i4.s 10
IL_000a: stloc.0
IL_000d: ldloc.0
IL_000e: ret
IL
// === IfElseBasic ===
.locals init ( [0] bool, [1] int32 )   // 지역변수 2개 (bool이 하나 더)

IL_0001: ldarg.0
IL_0002: stloc.0                   // cond를 지역변수에 저장
IL_0003: ldloc.0                   // 다시 로드
IL_0004: brfalse.s IL_000c         // false 분기로 점프
IL_0006: nop                       // Debug 빌드의 중단점용 no-op
IL_0007: ldc.i4.s 10
IL_0009: stloc.1
IL_000a: br.s IL_0012
IL_000c: nop
IL_000d: ldc.i4.s 20
IL_000f: stloc.1
IL_0010: br.s IL_0012
IL_0012: ldloc.1
IL_0013: ret
Debug 빌드 기준이라 nop과 여분의 stloc/ldloc이 보입니다. Release 빌드에서는 JIT 최적화로 이런 잡동사니가 대부분 사라지고 두 버전이 거의 같은 기계어로 수렴합니다.

관찰할 점:

  • 분기 명령어 방향이 반대일 뿐 — 삼항은 brtrue.s(true면 점프), if-else는 brfalse.s(false면 점프)를 썼습니다. 컴파일러가 "본문 → else" 순으로 코드를 깐 탓이지 본질적인 차이는 아닙니다.
  • 지역 변수 수가 다름 — if-else 쪽이 bool 슬롯을 하나 더 씁니다. 이 역시 Debug 빌드의 부산물이고 Release에서는 사라집니다.
  • 두 버전 모두 "같은 스택 슬롯으로 합류" — 삼항은 stloc.0, if-else는 stloc.1로, 두 분기가 마지막에 한 슬롯에 값을 저장합니다.

성능상 차이는 무시해도 되는 수준입니다. 성능이 아니라 가독성이 선택 기준입니다.

단락 평가 — IL로 본 "한쪽만 실행"

삼항은 &&/||처럼 선택된 분기만 평가합니다. 부수 효과가 있는 호출도 한쪽만 실행됩니다.

C#
public static int TernaryShortCircuit(bool cond)
{
    return cond ? GetA() : GetB();
}
public static int GetA() => 1;
public static int GetB() => 2;

Unity에서는 "계산 비용이 높은 쪽을 조건부로만 실행하고 싶다"는 상황이 흔합니다. 예를 들어 FindObjectOfType<T>()처럼 무거운 호출.

IL
.method public hidebysig static 
    int32 TernaryShortCircuit (bool cond) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: brtrue.s IL_000b

    IL_0004: call int32 Program::GetB()   // false 분기: GetB()만 call
    IL_0009: br.s IL_0010

    IL_000b: call int32 Program::GetA()   // true 분기: GetA()만 call

    IL_0010: stloc.0
    IL_0013: ldloc.0
    IL_0014: ret
}

call GetA()call GetB()는 각각 다른 경로에 놓여 있습니다. true면 IL_000b로 점프해서 GetB를 건너뛰고, false면 GetB를 실행한 뒤 br.s IL_0010으로 GetA를 건너뜁니다. 두 호출이 동시에 일어나는 경로는 존재하지 않습니다. 이것이 단락 평가가 언어 차원의 "규칙"이 아니라 IL 수준의 구조적 보장이라는 증거입니다.

이 보장이 있기 때문에 아래 패턴이 안전합니다:

C#
// enemy가 null이어도 NullReferenceException이 나지 않음
int hp = enemy != null ? enemy.Hp : 0;

enemynull일 때 enemy.Hp평가조차 되지 않습니다. 실제로 뒤 섹션의 GetEnemyHp IL에서 이를 다시 확인합니다.


4. 실전 적용 — 언제 쓰고 언제 빼는가

4-1. 쓰면 좋은 자리 — 값이 필요한 위치

삼항이 진짜 힘을 내는 곳은 "값이 필요한 자리"입니다. 이런 자리에는 if-else가 들어갈 수 없거나 매우 장황해집니다.

Unity에서 이런 상황이 자주 나옵니다 — 인자로 값을 전달하거나, LINQ 안에서 분기하고 싶을 때.

C#
// Before: 분기 때문에 임시 변수 + if 블록이 생김
float volume;
if (gateOpen)
    volume = maxVolume;
else
    volume = 0f;
audioSource.volume = volume;

// After: 표현식이라 대입 오른쪽에 그대로
audioSource.volume = gateOpen ? maxVolume : 0f;

LINQ 프로젝션에서도 자연스럽습니다.

C#
// 각 적의 HP를 상태에 따라 두 배로 계산
var displayHps = enemies.Select(e => e.IsElite ? e.Hp * 2 : e.Hp).ToList();

Before/After 쌍 — 속도 곱셈

C#
// Before — if-else로 변수에 담고 사용
public float ComputeSpeedBefore(bool isSprinting, float runSpeed, float walkSpeed)
{
    float speed;
    if (isSprinting)
        speed = runSpeed;
    else
        speed = walkSpeed;
    return speed;
}

// After — 삼항으로 한 줄
public float ComputeSpeedAfter(bool isSprinting, float runSpeed, float walkSpeed)
{
    return isSprinting ? runSpeed : walkSpeed;
}

After 버전의 IL은 핵심이 13바이트밖에 안 됩니다.

IL
.method public hidebysig static 
    float32 ComputeSpeed (
        bool isSprinting, float32 runSpeed, float32 walkSpeed
    ) cil managed 
{
    IL_0001: ldarg.0               // isSprinting 로드
    IL_0002: brtrue.s IL_0007      // true면 runSpeed 분기

    IL_0004: ldarg.2               // false: walkSpeed
    IL_0005: br.s IL_0008

    IL_0007: ldarg.1               // true: runSpeed

    IL_0008: stloc.0
    IL_000b: ldloc.0
    IL_000c: ret
}

ldarg.1(runSpeed)과 ldarg.2(walkSpeed) 둘 중 하나만 스택에 올립니다. 값 계산이 아닌 단순 선택이라, Release 빌드의 JIT은 이걸 종종 분기 없는 조건부 이동(cmov) 한 줄로 합쳐버립니다. Unity 핫패스(Update, FixedUpdate)에서 부담 없이 써도 됩니다.

4-2. null 안전 접근 — 삼항의 고전적 활용

C#
public static int GetEnemyHp(Enemy enemy)
{
    return enemy != null ? enemy.Hp : 0;
}

이 패턴은 null 참조 예외를 피하는 Unity 관용구입니다. 실제 IL이 "enemy가 null이면 Hp 접근을 완전히 건너뛴다"는 것을 보여줍니다.

IL
.method public hidebysig static 
    int32 GetEnemyHp (class Enemy enemy) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: brtrue.s IL_0007      // enemy != null 이면 IL_0007로

    IL_0004: ldc.i4.0              // null: 0만 로드하고 끝
    IL_0005: br.s IL_000d

    IL_0007: ldarg.0               // null 아님: enemy 로드
    IL_0008: callvirt instance int32 Enemy::get_Hp()  // Hp getter 호출

    IL_000d: stloc.0
    IL_0010: ldloc.0
    IL_0011: ret
}

null일 때 실행되는 명령은 ldc.i4.0(0을 로드)뿐입니다. callvirt아예 도달하지 않습니다. 이게 조건 연산자의 단락 평가가 런타임 예외를 막아주는 원리입니다.

주의 — Unity UnityEngine.Object의 null 체크 Unity가 제공하는 GameObject, MonoBehaviour, Component 같은 타입은 == 연산자가 오버로딩되어 있어, "파괴된(destroyed) 객체"도 null로 취급합니다. 이 동작은 편리하지만 일반 C# 참조 비교보다 조금 더 느립니다. 반복 호출되는 핫패스에서는 캐싱한 결과를 재사용하거나 ?.(null 조건 접근) 사용을 검토해야 할 수 있습니다.

4-3. UI 토글

C#
healthText.color = hp > 30 ? Color.white : Color.red;
healthText.text  = hp > 0 ? $"HP {hp}" : "<color=red>DEAD</color>";

두 줄 모두 대입 오른쪽에 삼항이 쓰였습니다. 분기에 필요한 건 뿐이고 동작이 아니므로 삼항이 가장 깔끔합니다.


5. 함정과 주의사항

함정 1 — 중첩 삼항의 가독성

?:의 오른쪽 결합성 a ? b : c ? d : e는 컴파일러가 a ? b : (c ? d : e)로 해석한다. 왼쪽부터가 아니라 오른쪽부터 묶인다는 뜻.

Unity에서 이런 상황이 나옵니다 — 점수 구간별로 등급을 매기거나, 상태 머신이 3개 이상의 상태로 분기할 때.

C#
// ❌ 한 줄로 욱여넣은 중첩 삼항 — 읽기 어렵고 유지보수 지옥
public string GetGrade(int score)
{
    return score >= 90 ? "A" : score >= 80 ? "B" : score >= 70 ? "C" : "F";
}

들여쓰기로 "사다리 형태"를 만들면 조금 낫습니다:

C#
// ⚠️ 사다리 형태 — 읽히긴 하지만 조건이 늘어나면 한계
public string GetGrade(int score)
{
    return score >= 90 ? "A"
         : score >= 80 ? "B"
         : score >= 70 ? "C"
         : "F";
}

IL을 보면 컴파일러 입장에서는 중첩이 곧 여러 번의 분기입니다.

IL
.method public hidebysig static 
    string GetGrade (int32 score) cil managed 
{
    IL_0001: ldarg.0
    IL_0002: ldc.i4.s 90
    IL_0004: bge.s IL_0025       // score >= 90 이면 "A"로

    IL_0006: ldarg.0
    IL_0007: ldc.i4.s 80
    IL_0009: bge.s IL_001e       // score >= 80 이면 "B"로

    IL_000b: ldarg.0
    IL_000c: ldc.i4.s 70
    IL_000e: bge.s IL_0017       // score >= 70 이면 "C"로

    IL_0010: ldstr "F"           // 모두 false: "F"
    IL_0015: br.s IL_001c
    IL_0017: ldstr "C"
    IL_001c: br.s IL_0023
    IL_001e: ldstr "B"
    IL_0023: br.s IL_002a
    IL_0025: ldstr "A"
    IL_002a: stloc.0
    IL_002d: ldloc.0
    IL_002e: ret
}

bge.s(같거나 크면 분기)가 3번 연달아 나오고, 각 분기가 서로 다른 문자열 리터럴로 흘러간 뒤 공통 합류 지점(stloc.0)에 도달합니다. 기능상 문제는 없지만, 나중에 "80점 이상 B" 구간을 "85점 이상 B"로 바꾸려면 어느 삼항을 건드려야 할지 매번 눈으로 재검색해야 합니다.

C#
// ✅ switch 표현식 (C# 8+)으로 교체 — 한눈에 읽힘
public string GetGrade(int score) => score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    _     => "F"
};

switch 표현식의 핵심은 "왼쪽 열이 조건, 오른쪽 열이 값"이라는 표 구조입니다. 조건이 늘어도 행만 추가되므로 시각적 부담이 일정합니다.

규칙: 분기가 2갈래를 넘어가면 switch 표현식을 우선 고려하세요. 삼항을 중첩해 3갈래 이상을 처리하는 건 거의 항상 나쁜 선택입니다.

함정 2 — 타입 규칙 CS0173

C#
// ❌ int와 string은 공통 타입이 없음
var result = cond ? 10 : "Hello";
// CS0173: Type of conditional expression cannot be determined because
//         there is no implicit conversion between 'int' and 'string'

// ❌ 형제 클래스 간에도 공통 타입을 못 찾음 (C# 8까지)
class Animal { }
class Dog : Animal { }
class Cat : Animal { }

Animal a = cond ? new Dog() : new Cat();   // C# 8까지 CS0173

컴파일러는 두 분기를 각각 독립적으로 판별한 뒤, "한쪽에서 다른 쪽으로 암시적 변환이 되는가?"만 확인합니다. Dog ↔ Cat은 서로 변환 불가이므로 공통 부모가 있어도 못 찾습니다.

C#
// ✅ 한쪽을 명시적으로 부모 타입으로 캐스트
Animal a = cond ? (Animal)new Dog() : new Cat();

// ✅ 양쪽 타입이 같은 경우는 문제 없음
double x = cond ? 1 : 2.0;   // int → double 암시적 변환 존재 → OK

함정 3 — C# 9 이전: null과의 조합

C# 8까지는 "한쪽이 null 리터럴"인 조건식이 바로 오류였습니다.

C#
// ❌ C# 8까지는 CS0173
int? x = cond ? 10 : null;

이유: 10의 타입은 int, null은 "타입 없음"이라 둘의 공통 타입을 찾지 못합니다. 대입 대상이 int?인 건 컴파일러가 고려하지 않았습니다.

C# 9부터 target-typed conditional expression이 도입되어 해결됐습니다(다음 섹션 참조).

함정 4 — ?:??를 혼동하지 않기

?? — 널 병합 연산자 (Null-coalescing operator) 왼쪽이 null이 아니면 그 값, null이면 오른쪽 값을 반환한다. "null일 때 기본값"이라는 단 한 가지 경우에만 특화된 연산자.
예시: string name = input ?? "Guest"; input이 null이면 "Guest"
C#
// ❌ 장황한 삼항 (좌변 name이 두 번 등장)
string displayName = name == null ? "Guest" : name;

// ✅ 같은 의미를 ??로
string displayName = name ?? "Guest";

a ?? b는 IL 레벨에서도 a != null ? a : b와 동등합니다. 문법만 짧아지는 게 아니라 의도가 명확해집니다. "null이냐 아니냐만 확인하고 싶다"는 게 코드로 바로 드러납니다.

반대 방향도 마찬가지로 혼동하지 말아야 합니다.

C#
// ❌ "빈 문자열도 기본값으로"를 ??로 하려고 하면 틀림
string display = name ?? "Guest";         // name == "" 이면 ""가 그대로 대입됨

// ✅ 일반 조건이 필요하므로 삼항
string display = string.IsNullOrEmpty(name) ? "Guest" : name;

경계선: "null이냐만 본다" → ??. "null 이외의 일반 조건" → ?:.


6. C# 버전별 변화

C# 1.0 — 기본 삼항 연산자 도입

C#
int max = a > b ? a : b;

C 언어의 ?:를 거의 그대로 계승. 이 시점부터 "양쪽 타입의 best common type"이라는 규칙이 확립됩니다.

C# 8 (2019) — switch 표현식 등장으로 "삼항의 역할 분담"

C#
// C# 8 이전: 3갈래 분기는 중첩 삼항이 거의 유일
string tier = rating >= 90 ? "S" : rating >= 70 ? "A" : rating >= 50 ? "B" : "C";

// C# 8+: switch 표현식이 "다갈래 값 분기"를 흡수
string tier = rating switch
{
    >= 90 => "S",
    >= 70 => "A",
    >= 50 => "B",
    _     => "C"
};

이 시점부터 2갈래는 삼항, 3갈래 이상은 switch 표현식이라는 업계 관행이 자리 잡았습니다.

C# 9 (2020) — Target-typed 조건 표현식

C# 9는 조건식에서 가장 아프던 두 가지 실용 문제를 해결했습니다:

Before — C# 8까지 (컴파일 오류)

C#
int? x = cond ? 10 : null;
// CS0173: between 'int' and '<null>'

ReadOnlySpan<byte> data = cond ? arr1 : arr2;
// byte[] 간 공통 타입은 byte[]이지만,
// byte[] → ReadOnlySpan<byte> 변환은 best common type 단계에서 고려되지 않음

Animal a = cond ? new Dog() : new Cat();
// CS0173: between 'Dog' and 'Cat'

After — C# 9+ (전부 OK)

C#
// 모두 C# 9부터 컴파일 통과
int? x = cond ? 10 : null;
ReadOnlySpan<byte> data = cond ? arr1 : arr2;
Animal a = cond ? new Dog() : new Cat();

규칙 차이: C# 8까지는 "두 분기의 공통 타입" → "대입 대상 타입으로 변환" 순서였지만, C# 9부터는 "대입 대상 타입이 먼저 보이면, 각 분기를 그 타입으로 변환 가능한지 확인"이 추가됐습니다.

Target-typed 조건식의 실제 IL을 보면, 컴파일러가 각 분기를 독립적으로 대상 타입(int?)으로 변환해 둔다는 걸 확인할 수 있습니다.

C#
public static int? TargetTypedNullable(bool cond)
{
    int? x = cond ? 10 : null;
    return x;
}
IL
.method public hidebysig static 
    valuetype [System.Runtime]System.Nullable`1<int32> TargetTypedNullable (
        bool cond
    ) cil managed 
{
    .locals init (
        [0] valuetype System.Nullable`1<int32>,
        [1] valuetype System.Nullable`1<int32>,
        [2] valuetype System.Nullable`1<int32>
    )

    IL_0001: ldarg.0
    IL_0002: brtrue.s IL_000f              // true면 true 분기로

    // ------- false 분기: null을 int? 로 만들어 내기 -------
    IL_0004: ldloca.s 1
    IL_0006: initobj valuetype System.Nullable`1<int32>   // Nullable<int>의 0-초기화(= HasValue=false)
    IL_000c: ldloc.1
    IL_000d: br.s IL_0016

    // ------- true 분기: 10을 int? 로 포장 -------
    IL_000f: ldc.i4.s 10
    IL_0011: newobj instance void valuetype System.Nullable`1<int32>::.ctor(!0)

    IL_0016: stloc.0                       // 결과 저장
    IL_0017: ldloc.0
    IL_001c: ret
}

핵심 관찰:

  1. false 분기initobj System.Nullable<int32>로 "값 없음" 상태의 Nullable<int>를 만들어 스택에 올립니다. null 리터럴이 int?에 맞춰 자동으로 변환된 것입니다.
  2. true 분기newobj Nullable<int>::.ctor(10)으로 "값 있음" 상태의 Nullable<int>를 만듭니다. int10int?로 승격된 것이죠.
  3. 양쪽 결과 모두 System.Nullable<int32> 타입 — 스택에서 합류하고 stloc.0에 저장됩니다.

변환을 컴파일러가 각 분기에 개별적으로 적용해 줬다는 점이 target-typed 조건식의 실체입니다. 이전 버전에서는 두 분기의 공통 타입을 먼저 뽑으려다 실패했습니다.

주의 — var 에는 target type이 없다 ``csharp var y = cond ? 10 : null; // ❌ 여전히 CS0173 ` var`는 "오른쪽 표현식의 타입으로 변수를 만든다"는 의미라 대상 타입이 없습니다. target-typed 조건식은 대상 타입이 미리 정해진 자리(대입 좌변, 반환 타입, 메서드 인자 등)에서만 동작합니다.

7. 정리 — 이것만 기억하세요

  • [ ] 삼항은 표현식이다. 값이 필요한 자리(대입 오른쪽, return, 메서드 인자, LINQ)에 바로 꽂아 넣을 수 있다. 이게 if-else와의 본질적 차이.
  • [ ] 성능은 if-else와 사실상 동일하다. IL에서 brtrue.s/brfalse.s로 내려가고 같은 스택 슬롯으로 합류. Release 빌드에서는 JIT이 조건부 이동으로 합치는 경우도 흔함.
  • [ ] 선택된 분기만 평가된다 (단락 평가). enemy != null ? enemy.Hp : 0이 안전한 이유. IL 상 두 분기가 서로 다른 경로에 놓임.
  • [ ] 중첩은 2갈래까지, 3갈래 이상이면 switch 표현식. 중첩 삼항은 오른쪽 결합이라 a ? b : c ? d : ea ? b : (c ? d : e)로 해석됨.
  • [ ] 두 분기 타입은 공통 타입이 있어야 한다. CS0173 오류는 거의 항상 타입 불일치. 형제 클래스 간에도 C# 8까지는 직접 허용되지 않음.
  • [ ] C# 9+의 target-typed 조건식을 기억하라. int? x = cond ? 10 : null;, Animal a = cond ? new Dog() : new Cat();이 C# 9부터 허용. 단, var에는 적용되지 않음.
  • [ ] ??와 역할을 분담하라. "null이냐 아니냐"만 본다면 ??. 일반 조건이면 ?:. 혼동해서 string.IsNullOrEmpty(name) ? "Guest" : namename ?? "Guest"로 줄여 쓰면 빈 문자열 버그가 생긴다.
  • [ ] Unity 핫패스에서도 마음껏 써도 된다. 박싱도, 추가 할당도, 숨은 호출도 없다. 단, 양쪽 분기가 무거운 호출이라면 함수 분리를 먼저 고려하라.
반응형

+ Recent posts