반응형

[PART8.상속과 인터페이스 사용법(11/11)] 업캐스트와 다운캐스트 — 안전한 타입 변환

자식→부모는 왜 무료이고 / 부모→자식은 왜 검사가 필요한가 / castclass vs isinst vs unbox.any의 IL 차이


1. [문제 제기] 부모 타입으로 묶었더니 자식 기능을 못 쓴다

Unity에서 적 캐릭터를 한꺼번에 관리하려고 부모 클래스로 묶는 코드는 흔합니다.

C#
public class Enemy : MonoBehaviour { public int Hp; }
public class Boss : Enemy { public void SpawnMinions() { } }

public class EnemyManager : MonoBehaviour
{
    public List<Enemy> enemies = new();

    void Start()
    {
        Boss boss = new Boss();
        enemies.Add(boss);     // ← 자식을 부모 리스트에 넣는다 (업캐스트)
    }

    void Update()
    {
        foreach (Enemy e in enemies)
        {
            // boss.SpawnMinions()를 호출하고 싶다 — 그런데 e는 Enemy 타입이라 못 부른다!
        }
    }
}

여기서 두 가지 의문이 생깁니다.

  1. enemies.Add(boss)는 왜 캐스팅 키워드 없이 그냥 통과할까요? boss는 분명 Boss 타입인데 Enemy 리스트에 들어갑니다.
  2. 반대로 Enemy 참조를 다시 Boss로 되돌리려면 왜 (Boss)e 같은 명시적 변환이 필요할까요? 게다가 잘못 쓰면 게임이 터집니다.
C#
// ❌ 모든 적을 Boss로 다운캐스트 시도
foreach (Enemy e in enemies)
{
    Boss b = (Boss)e;        // 일반 Enemy면 InvalidCastException → 게임 강제 종료!
    b.SpawnMinions();
}

자식 → 부모는 언제나 안전하지만, 부모 → 자식은 런타임 검사가 필요합니다. 이 차이를 모르면 디버그 빌드에서는 멀쩡하다가 라이브 서비스에서 크래시 리포트가 쌓이는 일이 벌어집니다.

이 글에서는 두 방향의 캐스팅이 IL 레벨에서 얼마나 다른지, 그리고 다운캐스트 세 가지 방법 — (T) · as · is T t — 을 어떻게 구분해 써야 하는지 짚어봅니다.


2. [개념 정의] 한 객체, 두 가지 시야

2.1. 비유 — 명함 한 장 vs 신분증 한 장

당신이 회사에서 "프론트엔드 개발자"이면서 동시에 회사의 일원인 "직원"입니다. 같은 사람인데 누가 보느냐에 따라 다른 이름표가 붙습니다.

  • 업캐스트(자식 → 부모): 프론트엔드 개발자 명함 → 직원 명함. 아래로 내려놓는 거라 누구나 안전합니다. "직원입니다"라고 말해도 거짓말이 아니죠.
  • 다운캐스트(부모 → 자식): 직원 명함 → 프론트엔드 개발자 명함. 이 직원이 정말 프론트엔드 개발자인지 확인해야 합니다. 백엔드 개발자에게 프론트엔드 명함을 주면 곤란해집니다.

핵심 비유:

객체의 메모리(실체)는 그대로입니다. 변하는 건 그 객체를 어떤 타입(시야)으로 바라보는가 뿐입니다.

2.2. 시각화 — 한 객체를 두 시야로 보다

업캐스트와 다운캐스트 — 같은 객체를 다른 타입으로 본다

2.3. 기본 코드 — 양방향 변환

C#
public class Animal { public string Name = "동물"; }
public class Dog : Animal { public void Bark() { } }

// 1) 업캐스트 — 암시적, 캐스팅 키워드 없음
Dog d = new Dog();
Animal a = d;                  // Dog는 Animal이다 (IS-A)

// a.Bark();                   // ❌ 컴파일 에러: Animal에는 Bark가 없음
Console.WriteLine(a.Name);     // ✅ Animal의 멤버는 접근 가능

// 2) 다운캐스트 — 명시적 캐스팅 필요
Dog d2 = (Dog)a;               // 런타임 검사 후 통과
d2.Bark();                     // ✅ 이제 Bark 호출 가능
업캐스트 (Upcast) — 자식 클래스 참조를 부모 클래스 참조로 변환하는 것. C#에서는 암시적으로 처리되며 캐스팅 키워드가 필요 없다. 실패할 수 없다.
예시: Animal a = new Dog(); Dog 인스턴스를 Animal 변수에 직접 대입 가능
다운캐스트 (Downcast) — 부모 클래스 참조를 자식 클래스 참조로 변환하는 것. 명시적 캐스팅이 필요하며 실패할 수 있어 런타임 검사가 동반된다.
예시: Dog d = (Dog)a; a가 실제로 Dog 인스턴스가 아니면 InvalidCastException

업캐스트는 시야를 좁힙니다(DogBark가 안 보임). 다운캐스트는 시야를 넓히되(DogBark가 보임), 그 시야가 실제 객체와 맞는지 CLR이 확인합니다.

2.4. IL 분석 — 업캐스트는 IL에 흔적조차 없다

IL(Intermediate Language) — C# 컴파일러가 만들어내는 중간 언어. CPU 명령어가 아니라 .NET 런타임이 해석·실행하는 가상 명령어 집합이다. CIL(Common Intermediate Language)이라고도 부른다.
C#
// 업캐스트 메서드
public static Animal TestUpcast()
{
    Dog d = new Dog();
    Animal a = d;
    return a;
}

// 다운캐스트 메서드
public static Dog TestExplicitDowncast(Animal a)
{
    return (Dog)a;
}

이 두 메서드를 컴파일한 IL입니다.

IL
// TestUpcast — Dog를 만들어 Animal로 반환
IL_0000: newobj instance void Dog::.ctor()   // 새 Dog 인스턴스 생성
IL_0005: ret                                  // 그대로 반환 — 캐스팅 명령어 없음!

// TestExplicitDowncast — Animal을 받아 Dog로 캐스팅
IL_0000: ldarg.0                              // 인자 a를 스택에 로드
IL_0001: castclass Dog                        // ← Dog 호환성 검사 (실패 시 예외)
IL_0006: ret                                  // 검증된 참조 반환

핵심 발견:

  • 업캐스트는 IL에 명령어가 없다. Dog 인스턴스의 메모리는 그대로이고, 어차피 같은 참조이므로 변환할 게 없습니다. 컴파일러는 단지 정적 타입을 Animal로 추적할 뿐입니다.
  • 다운캐스트는 castclass 명령어 한 개가 추가됩니다. 이 한 개 명령어가 객체의 타입 포인터를 따라가 상속 트리를 탐색하고, 호환되지 않으면 InvalidCastException을 던집니다.

업캐스트가 "공짜"인 이유, 다운캐스트가 "비용이 있는" 이유가 IL 레벨에서 그대로 드러납니다.


3. [내부 동작] 다운캐스트 세 가지 방법의 IL 비교

다운캐스트에는 세 가지 표현이 있고, IL이 모두 다릅니다.

3.1. 시각화 — 세 가지 다운캐스트의 분기 흐름

다운캐스트 세 방법 — IL 명령어와 실패 처리

3.2. 코드와 IL 한꺼번에 비교

C#
// 1) 명시적 캐스팅
public static Dog TestExplicitDowncast(Animal a) => (Dog)a;

// 2) as 연산자
public static Dog TestAsDowncast(Animal a) => a as Dog;

// 3) is 패턴
public static int TestIsPattern(Animal a)
{
    if (a is Dog d) return 1;
    return 0;
}

세 메서드의 IL을 나란히 보면 차이가 분명합니다.

IL
// (Dog)a — castclass 사용
IL_0000: ldarg.0
IL_0001: castclass Dog       // ← 호환되지 않으면 즉시 InvalidCastException
IL_0006: ret

// a as Dog — isinst 사용
IL_0000: ldarg.0
IL_0001: isinst Dog          // ← 호환되면 참조, 아니면 null을 스택에 푸시
IL_0006: ret

// if (a is Dog d) — isinst + stloc + brfalse
IL_0000: ldarg.0
IL_0001: isinst Dog          // ← 호환되면 참조, 아니면 null
IL_0006: brfalse.s IL_000a   // null이면 if 블록 건너뛰기
IL_0008: ldc.i4.1            // d는 isinst 결과를 그대로 사용 (별도 캐스팅 없음)
IL_0009: ret
IL_000a: ldc.i4.0
IL_000b: ret
castclass vs isinst — 둘 다 객체의 실제 타입을 검사하는 명령어다. 차이는 실패 처리다. castclass는 즉시 예외를 던지고, isinst는 null을 스택에 남긴다.

핵심 발견:

  • asis T t는 같은 명령어 isinst를 사용한다. 차이는 그 결과를 어떻게 후처리하느냐 — as는 그대로 반환, is T t는 brfalse로 분기.
  • (T)castclass를 사용한다. 실패 = 예외 분기 = 비싼 길.
  • is T t 패턴은 캐스팅을 한 번만 한다. isinst 결과를 그대로 변수 d에 담아 사용하므로 추가 검사가 없습니다.

isinst는 "확인용", castclass는 "단정용"이라고 외우면 됩니다.

3.3. 값 타입 다운캐스트 — unbox.any

값 타입(int, struct 등)을 object로 담았다가 다시 꺼낼 때는 또 다른 IL 명령어를 씁니다.

C#
public static object TestBoxing()
{
    int x = 42;
    object o = x;        // 박싱 — 힙에 새 객체 생성
    return o;
}

public static int TestUnboxing(object o)
{
    return (int)o;       // 언박싱 — 박스 안의 값 추출
}

IL을 보면:

IL
// TestBoxing — int → object
IL_0000: ldc.i4.s 42
IL_0002: box [System.Runtime]System.Int32   // ← 힙에 박스 객체 할당, 42 복사
IL_0007: ret

// TestUnboxing — object → int
IL_0000: ldarg.0
IL_0001: unbox.any [System.Runtime]System.Int32  // ← 박스 검사 + 값 복사
IL_0006: ret
박싱 (Boxing) — 값 타입을 참조 타입(object 또는 인터페이스)으로 변환하는 과정. 힙에 새 객체를 할당하고 값을 복사한다.
언박싱 (Unboxing) — 박싱된 객체에서 원래 값 타입을 꺼내는 과정. 명시적 다운캐스트 문법이 필요하며 IL unbox.any 명령어로 처리된다.

값 타입 다운캐스트의 특징:

  • 반드시 박싱된 정확한 타입으로만 언박싱 가능. long으로 박싱된 것을 int로 언박싱하면 InvalidCastException.
  • as 연산자는 값 타입에 사용할 수 없습니다. int? x = o as int? 처럼 Nullable 형태로만 가능합니다.
  • 박싱은 GC 할당을 발생시킵니다. Unity 핫패스에서는 절대 피해야 할 패턴입니다.
C#
long l = 99;
object o = l;
// int i = (int)o;       // ❌ InvalidCastException — long 박스를 int로 못 꺼냄
long l2 = (long)o;       // ✅ 정확한 타입이면 OK

4. [실전 적용] Unity에서 다운캐스트는 언제 필요한가

4.1. Before/After — 적 군중에서 보스만 추리기

Before — 이중 캐스팅 안티패턴

C#
public void DamageBosses(List<Enemy> enemies, int damage)
{
    foreach (Enemy e in enemies)
    {
        if (e is Boss)              // ← 1차 검사
        {
            Boss b = (Boss)e;       // ← 2차 검사 (중복!)
            b.TakeBossDamage(damage);
        }
    }
}

이 코드의 IL을 보면 검사가 두 번 일어납니다.

IL
IL_0000: ldarg.0
IL_0001: isinst Dog          // ← 1차: is 검사
IL_0006: brfalse.s IL_0011   // null이면 if 끝
IL_0008: ldarg.0
IL_0009: castclass Dog       // ← 2차: 강제 캐스팅 (또 검사!)
IL_000e: pop                 // 결과 사용 안 하면 pop
IL_000f: ldc.i4.1
IL_0010: ret
IL_0011: ldc.i4.0
IL_0012: ret

isinst Dog로 한 번 확인한 뒤 castclass Dog로 또 한 번 검사합니다. 같은 객체에 대해 상속 트리를 두 번 탐색합니다. 1000마리의 적을 매 프레임 순회하는 핫패스에서는 이 중복 검사가 누적됩니다.

After — is T t 패턴 한 번에 처리

C#
public void DamageBosses(List<Enemy> enemies, int damage)
{
    foreach (Enemy e in enemies)
    {
        if (e is Boss b)            // ← 검사 + 캐스팅 + 변수 선언 한 번
        {
            b.TakeBossDamage(damage);
        }
    }
}

IL은 isinst Dog 한 번으로 끝납니다.

IL
IL_0011: ldloc.1
IL_0012: isinst Dog          // 한 번만 검사
IL_0017: stloc.2             // 결과를 b에 저장
IL_0018: ldloc.2
IL_0019: brfalse.s IL_0023   // null이면 건너뛰기
IL_001b: ldloc.2             // b를 그대로 사용 (추가 캐스팅 없음)
IL_001c: callvirt instance void Dog::Bark()

같은 동작을 절반의 비용으로 처리합니다.

4.2. Before/After — 가상 메서드로 다운캐스트 자체를 없애기

더 좋은 답은 다운캐스트가 필요하지 않게 설계하는 것입니다.

Before — 타입별 분기

C#
public class Game
{
    public void HandleEnemies(List<Enemy> enemies)
    {
        foreach (Enemy e in enemies)
        {
            if (e is Boss b) b.SpawnMinions();
            else if (e is Goblin g) g.Steal();
            else if (e is Dragon d) d.BreatheFire();
            // 새 적 추가할 때마다 여기에 분기 추가 → 확장성 X (OCP 위반)
        }
    }
}

새 적 클래스(Slime, Skeleton...)가 추가될 때마다 이 메서드를 수정해야 합니다. 개방-폐쇄 원칙(OCP, Open/Closed Principle — 확장에 열려 있고 수정에 닫혀 있어야 한다) 위반입니다.

After — 가상 메서드로 다형성 활용

C#
public abstract class Enemy
{
    public abstract void PerformSpecialAction();   // 각 자식이 알아서 구현
}

public class Boss : Enemy
{
    public override void PerformSpecialAction() => SpawnMinions();
}

public class Goblin : Enemy
{
    public override void PerformSpecialAction() => Steal();
}

public class Game
{
    public void HandleEnemies(List<Enemy> enemies)
    {
        foreach (Enemy e in enemies)
        {
            e.PerformSpecialAction();   // 다운캐스트 없이 다형성
        }
    }
}

Slime 적이 생기면 Slime만 만들면 됩니다. HandleEnemies는 한 줄도 건드리지 않습니다.

요약: 다운캐스트 분기가 보이면 가상 메서드로 대체할 수 있는지 먼저 검토합니다. 다운캐스트는 진짜로 자식만의 고유 동작이 필요하거나, 외부 API(object로 반환되는 LINQ·리플렉션 결과 등)를 다룰 때만 합리화됩니다.

4.3. Unity의 GetComponent<T>() — 다운캐스트가 아니다

신입 개발자가 자주 헷갈리는 지점입니다.

C#
Rigidbody rb = GetComponent<Rigidbody>();

이 코드는 다운캐스트가 아닙니다. Unity의 GetComponent<T>()는:

  1. GameObject에 부착된 컴포넌트 배열을 순회
  2. T 타입(또는 그 자식)과 일치하는 컴포넌트를 찾아 반환
  3. 없으면 null 반환

C# 다운캐스트는 "이 객체 참조의 정체를 다시 본다"는 행위지만, GetComponent는 "다른 객체를 찾아온다"는 검색입니다. 비용도 다릅니다.

동작 비용
(Dog)animal IL castclass 한 번 — CLR 메타데이터 한 번 조회
GetComponent<Rigidbody>() 컴포넌트 배열 순회 — Unity 네이티브 코드 호출

핫패스에서 매번 GetComponent를 호출하면 안 되는 이유가 여기에 있습니다.

Before — 매 프레임 GetComponent 호출

C#
public class PlayerController : MonoBehaviour
{
    void FixedUpdate()
    {
        // ❌ 매 프레임 호출 — 비싼 검색을 60번/초 반복
        GetComponent<Rigidbody>().AddForce(Vector3.forward * 10f);
    }
}

After — Awake에서 캐싱

C#
public class PlayerController : MonoBehaviour
{
    private Rigidbody rb;

    void Awake()
    {
        rb = GetComponent<Rigidbody>();   // 시작 시 한 번
    }

    void FixedUpdate()
    {
        rb.AddForce(Vector3.forward * 10f);   // ✅ 캐싱된 참조 사용
    }
}

같은 원리로 다운캐스트 결과도 핫패스 진입 전에 캐싱합니다. 매 프레임 같은 객체를 다운캐스트하지 않습니다.

4.4. 공변성/반공변성 — 제네릭에서 캐스팅을 컴파일러에 맡기기

C#은 제네릭 인터페이스에서도 안전한 업캐스트/다운캐스트를 지원합니다. out(공변)과 in(반공변) 키워드를 통해서입니다.

공변성 (Covariance, out) — 제네릭 인터페이스의 타입 매개변수가 출력 위치(반환값)로만 쓰일 때 허용. IEnumerable<Dog>IEnumerable<Animal>에 직접 대입할 수 있게 한다.
예시: IEnumerable<Dog> dogs = new List<Dog>(); IEnumerable<Animal> animals = dogs;
반공변성 (Contravariance, in) — 타입 매개변수가 입력 위치(매개변수)로만 쓰일 때 허용. Action<Animal>Action<Dog>에 대입할 수 있게 한다.
예시: Action<Animal> feed = a => Feed(a); Action<Dog> feedDog = feed;
C#
// 공변 — 출력만 가능 (안전한 업캐스트)
IEnumerable<Dog> dogs = new List<Dog> { new Dog() };
IEnumerable<Animal> animals = dogs;       // ✅ Dog는 Animal이므로 OK
foreach (Animal a in animals) { /* Animal로 꺼냄 */ }

// 반공변 — 입력만 가능 (안전한 다운캐스트)
Action<Animal> feedAnimal = a => Console.WriteLine($"먹이를 줍니다: {a.Name}");
Action<Dog> feedDog = feedAnimal;          // ✅ Dog도 Animal로 처리 가능
feedDog(new Dog());                        // 실행 OK

Unity에서의 활용: IReadOnlyList<T>, IEnumerable<T>, Func<...>, Action<...> 같은 제네릭 타입의 매개변수에 자식 타입 컬렉션이나 부모 타입 콜백을 자유롭게 넘길 수 있습니다. 명시적 캐스팅 코드 없이 컴파일 타임에 안전성이 보장됩니다.

C#
public void FeedAll(IEnumerable<Animal> animals)
{
    foreach (var a in animals) Feed(a);
}

// 사용처: List<Dog>를 그대로 넘긴다
List<Dog> myDogs = new List<Dog> { new Dog(), new Dog() };
FeedAll(myDogs);   // ✅ IEnumerable<Dog>가 IEnumerable<Animal>로 공변 변환

out/in은 깊이 들어가면 별도 주제지만, "제네릭 컬렉션에 자식 리스트를 부모 매개변수에 넘길 수 있는 이유"가 바로 이것이라는 점만 기억하면 됩니다.


5. [함정과 주의사항]

5.1. ❌ try-catch로 캐스팅 실패를 잡지 마라

❌ 안티패턴

C#
public Dog ConvertToDog(object obj)
{
    try
    {
        return (Dog)obj;          // 실패하면 castclass가 예외 던짐
    }
    catch (InvalidCastException)
    {
        return null;
    }
}

이 코드의 IL은 try { castclass Dog; ... } catch { ldnull; ... } 구조로 컴파일됩니다. 예외 발생 시 CLR이 스택 트레이스를 만들고 catch 블록을 찾는 비용이 매우 큽니다. Unity 핫패스에서 1000번/초 호출되면 GC 스파이크와 프레임 드롭이 따라옵니다.

✅ 올바른 코드

C#
public Dog ConvertToDog(object obj)
{
    return obj as Dog;            // 실패하면 null — IL isinst 한 줄
}

as는 IL isinst 한 줄로 끝나며 예외 비용이 없습니다.

5.2. ❌ as 결과의 null 체크를 빠뜨리지 마라

❌ 위험한 코드

C#
void OnHit(object collider)
{
    Enemy e = collider as Enemy;
    e.TakeDamage(10);             // collider가 Enemy가 아니면 NullReferenceException!
}

as는 실패해도 예외를 던지지 않기 때문에, null 체크를 빼먹으면 다음 줄에서 참조 예외가 터집니다. 위치가 한 줄 미루어져 디버깅이 까다로워집니다.

✅ 올바른 코드 — is T t 패턴

C#
void OnHit(object collider)
{
    if (collider is Enemy e)      // 검사 자체가 분기 조건
    {
        e.TakeDamage(10);         // 여기서 e는 절대 null이 아님
    }
}

타입 패턴은 검사가 분기 조건이므로 null 체크를 빼먹을 수 없습니다. C# 7 이상이라면 as + null 체크보다 is T t 패턴을 우선합니다.

5.3. ❌ 핫패스에서 박싱·언박싱을 반복하지 마라

❌ Unity에서 자주 보이는 안티패턴

C#
public class ScoreManager
{
    private List<object> scores = new();   // ← object 컬렉션!

    public void AddScore(int score)
    {
        scores.Add(score);                 // ❌ 박싱 — 힙 할당
    }

    public int GetTotal()
    {
        int total = 0;
        foreach (object s in scores)
        {
            total += (int)s;               // ❌ 언박싱 — 매번 unbox.any
        }
        return total;
    }
}

이 코드는 점수 추가마다 힙 할당이 발생하고, 합산할 때마다 unbox.any 명령어로 검사를 반복합니다. Unity의 GC는 이런 미세한 할당이 누적되면 GC 스파이크를 일으킵니다.

✅ 제네릭으로 박싱 회피

C#
public class ScoreManager
{
    private List<int> scores = new();      // ← 값 타입 그대로

    public void AddScore(int score)
    {
        scores.Add(score);                 // ✅ 박싱 없음
    }

    public int GetTotal()
    {
        int total = 0;
        foreach (int s in scores)          // ✅ 언박싱 없음
        {
            total += s;
        }
        return total;
    }
}

List<int>는 내부적으로 int[] 배열을 사용해 값 타입을 직접 저장합니다. 박싱·언박싱이 모두 사라지고, IL에는 unbox.any가 나타나지 않습니다.

5.4. ❌ Unity 객체에는 as보다 TryGetComponent를 우선하라

Unity의 UnityEngine.Object는 C# 객체 수명과 네이티브 객체 수명이 다릅니다. as로 받은 참조가 null은 아니지만 네이티브 측은 이미 파괴된 "좀비 객체"일 수 있습니다.

❌ 위험한 코드

C#
void OnTriggerEnter(Collider other)
{
    Enemy enemy = other.GetComponent<Enemy>() as Enemy;
    enemy.TakeDamage(10);          // 좀비 객체일 수 있음 — MissingReferenceException 가능
}

✅ 권장 코드

C#
void OnTriggerEnter(Collider other)
{
    if (other.TryGetComponent(out Enemy enemy))   // out 변수로 한 번에
    {
        enemy.TakeDamage(10);                     // 좀비 검사 통과한 참조
    }
}

TryGetComponent는 Unity 2019.2부터 도입된 API로 GetComponent 두 번 호출과 좀비 검사를 한 번에 처리합니다.


6. [C# 버전별 변화]

다운캐스트의 도구는 C# 버전마다 더 안전하고 간결해졌습니다.

6.1. C# 1~6 — 분리된 검사와 캐스팅

C#
// C# 6 이전
if (obj is Enemy)                  // 1차 검사 (bool 반환만)
{
    Enemy e = (Enemy)obj;          // 2차 캐스팅 (또 검사)
    e.Hp = 100;
}
// 또는
Enemy e = obj as Enemy;
if (e != null) { e.Hp = 100; }

이 시기엔 is가 단순 bool 반환이고, 변수 선언을 못 했습니다. 두 단계가 강제됐습니다.

6.2. C# 7 (2017) — 타입 패턴

C#
// C# 7
if (obj is Enemy e)                // 검사 + 캐스팅 + 선언 한 번
{
    e.Hp = 100;
}

// switch 문도 패턴 매칭 지원
switch (obj)
{
    case Boss b: b.SpawnMinions(); break;
    case Enemy e: e.TakeDamage(10); break;
}

이중 캐스팅이 사라지고 IL이 isinst 한 번으로 줄었습니다.

6.3. C# 8 (2019) — switch 표현식

C#
// C# 8
string desc = obj switch
{
    Boss b   => $"보스 (HP: {b.Hp})",
    Enemy e  => $"적 (HP: {e.Hp})",
    null     => "없음",
    _        => "알 수 없음"
};

타입 패턴이 표현식으로 격상돼 변수 대입에 직접 쓸 수 있게 됐습니다.

6.4. C# 9 (2020) — 속성 패턴, 논리 패턴

C#
// C# 9
if (obj is not null) { /* ... */ }                       // 논리 패턴
if (age is > 0 and < 130) { /* ... */ }                  // 관계 + 논리
if (obj is Enemy { Hp: > 1000, IsAlive: true } boss)     // 속성 패턴 + 변수 선언
{
    boss.Berserk();
}

타입 + 속성 + 변수 선언을 한 줄에 표현 가능합니다. IL에는 여전히 isinst 한 번 + 필드 비교 명령어로 인라인 분기 처리됩니다.

6.5. C# 10~11 (2021~2022) — 확장 속성 패턴, 리스트 패턴

C#
// C# 10 — 확장 속성 패턴 (점 표기)
if (player is { Inventory.Gold: > 100 } richPlayer) { /* ... */ }

// C# 11 — 리스트 패턴
int[] arr = { 1, 2, 3 };
if (arr is [1, 2, 3]) { /* ... */ }
if (arr is [1, .., 3]) { /* 첫 1, 마지막 3 */ }

핵심 흐름: C# 7 이후 다운캐스트의 모든 길은 is T t 또는 switch 패턴으로 통합되었습니다. 이중 캐스팅 안티패턴은 C# 6 이전 코드에서나 보입니다. 새 코드를 짤 때 (T)는 "실패가 곧 버그"인 상황에만 쓰고, 나머지는 모두 is T t 패턴으로 대체합니다.


7. [정리]

핵심 체크리스트:

  • [ ] 업캐스트는 IL에 명령어가 없다Animal a = new Dog();은 단지 정적 타입만 변경, 메모리·참조·CLR 검사 없음
  • [ ] 다운캐스트는 런타임 검사 필요 — 부모 참조가 실제로 자식 객체인지 CLR이 확인해야 안전
  • [ ] (T)castclass — 실패 시 InvalidCastException, "이건 반드시 T다"라는 단정용
  • [ ] asisinst — 실패 시 null, 참조 타입과 Nullable에만 사용
  • [ ] is T tisinst + stloc — 검사+캐스팅+변수 선언을 한 번에, C# 7 이후 기본 도구
  • [ ] 이중 캐스팅 안티패턴 금지if (a is T) { (T)a; }isinst + castclass 두 번, is T t로 대체
  • [ ] try-catch + (T) 안티패턴 금지 — 예외 비용은 매우 비쌈, asis T t로 대체
  • [ ] 값 타입은 unbox.any(int)obj는 박싱된 정확한 타입으로만 가능, Nullable이 아니면 as 불가
  • [ ] 다운캐스트 분기가 보이면 가상 메서드/인터페이스로 대체 검토 — OCP 위반은 코드 스멜
  • [ ] Unity GetComponent<T>()는 다운캐스트가 아니라 검색 — 비용이 더 크므로 Awake에서 캐싱
  • [ ] TryGetComponent(out T)로 좀비 객체 안전성 확보 — Unity 객체에는 as보다 우선
  • [ ] 공변(out)/반공변(in)으로 제네릭 캐스팅을 컴파일러에 맡기기IEnumerable<Dog>IEnumerable<Animal> 자동
  • [ ] 핫패스에서 박싱·언박싱 반복 금지List<object>보다 List<int> 같은 제네릭으로 박싱 회피

업캐스트는 컴파일러의 정적 타입 추적일 뿐이고, 다운캐스트는 CLR이 객체 헤더를 따라가는 런타임 행위입니다. IL 한 줄(castclass 또는 isinst)의 차이가 게임의 안정성과 성능을 가릅니다. 결론: 다운캐스트는 의심스러우면 is T t, 확신할 때만 (T)를 쓴다. 그리고 다운캐스트가 보이면 먼저 가상 메서드로 대체할 수 있는지부터 묻는다.

반응형

+ Recent posts