반응형

[PART8.상속과 인터페이스 사용법(10/11)] is · as · 타입 패턴 — 안전한 타입 검사 세 가지 방법

bool 반환 vs null 반환 vs 예외 / isinstcastclass의 차이 / C# 7+ 타입 패턴이 이중 캐스팅을 없애는 방식


1. [문제 제기] 다운캐스팅, 잘못 쓰면 게임이 멈춘다

Unity에서 이런 코드를 본 적이 있을 겁니다.

C#
void OnTriggerEnter(Collider other)
{
    Enemy enemy = (Enemy)other.gameObject.GetComponent<Component>();
    enemy.TakeDamage(10);
}

문제: other가 Enemy가 아니라 아이템 박스이거나 벽이면 어떻게 될까요? 런타임에 InvalidCastException이 터지고 게임이 멈춥니다. QA에서 잡지 못한 채 출시되면 유저는 충돌 직전에 강제 종료를 보게 됩니다.

이 문제를 피하기 위해 신입 개발자는 흔히 이렇게 씁니다.

C#
if (other.gameObject.GetComponent<Enemy>() != null)
{
    Enemy enemy = (Enemy)other.gameObject.GetComponent<Enemy>();
    enemy.TakeDamage(10);
}

코드는 안전해졌지만 GetComponent를 두 번 호출했습니다. Unity의 GetComponent는 컴포넌트 배열을 순회하는 비싼 호출이라 핫패스에서 두 번 호출하면 FPS가 떨어집니다. 타입 검사 + 변환 + 안전성 + 성능을 한 번에 처리하는 도구가 C#에 있습니다. 그것이 is, as, 그리고 C# 7+의 타입 패턴입니다.

세 도구는 비슷해 보이지만 실패 시 동작이 완전히 다릅니다.

도구 실패 시
obj is T false 반환 (예외 없음)
obj as T null 반환 (예외 없음)
(T)obj InvalidCastException 던짐

이 글에서는 세 가지의 IL 차이부터 C# 11까지의 패턴 매칭 진화, Unity의 == 오버로딩 함정까지 짚어봅니다.


2. [개념 정의] 세 가지 도구, 세 가지 성격

2.1. 비유 — 검문소 비유

세 도구는 공항 검문소의 세 가지 직원에 비유할 수 있습니다.

  • is 검문관: "이 사람 미국인 맞나요?" → "맞습니다 / 아닙니다" (bool 답)
  • as 안내원: "미국인이면 미국인 라운지로 안내해주세요" → 미국인이면 라운지로, 아니면 그냥 돌려보냄 (null)
  • (T) 강제집행관: "이 사람 미국인 라운지로 보내!" → 미국인이 아니면 그 자리에서 체포 (예외)

is는 답만 듣습니다. as는 시도하고 실패해도 조용합니다. (T)는 실패하면 큰 사건이 됩니다.

2.2. 시각화 — 세 도구의 분기 흐름

세 가지 타입 변환의 동작 흐름

2.3. 기본 코드

is — 타입 검사 연산자 (Type test operator) 왼쪽 객체가 오른쪽 타입과 호환되면 true, 아니면 false를 반환한다. 예외를 던지지 않으며 캐스팅도 하지 않는다.
예시: bool isDog = animal is Dog; animal이 Dog거나 Dog의 서브클래스면 true
as — 안전한 타입 변환 연산자 (Safe casting operator) 왼쪽 객체를 오른쪽 타입으로 변환을 시도한다. 성공하면 변환된 참조, 실패하면 null을 반환한다. 참조 타입 또는 Nullable 값 타입(int? 등)에만 사용 가능하다.
예시: Dog d = animal as Dog; animal이 Dog가 아니면 d는 null
C#
// 부모 클래스
public class Animal { }
public class Dog : Animal { public void Bark() { } }

object obj = new Dog();

// 1) is — 검사만 한다
bool isDog = obj is Dog;            // true
bool isCat = obj is Cat;            // false

// 2) as — 변환을 시도한다
Dog d1 = obj as Dog;                // Dog 인스턴스
Cat c1 = obj as Cat;                // null

// 3) (T) — 강제 캐스팅
Dog d2 = (Dog)obj;                  // 성공
// Cat c2 = (Cat)obj;               // InvalidCastException!

is는 결과를 알려줄 뿐 캐스팅하지 않습니다. as는 변환을 시도하되 실패해도 조용합니다. (T)는 실패를 용납하지 않습니다.

2.4. IL 분석 — isinst vs castclass

IL(Intermediate Language) — C# 컴파일러가 만들어내는 중간 언어. CPU 명령어가 아니라 .NET 런타임이 해석·실행하는 가상 명령어 집합이다. CIL(Common Intermediate Language)이라고도 부른다.

위 세 가지 표현이 컴파일되면 어떻게 변할까요?

IL
// bool TestIs(object obj) => obj is Dog;
IL_0000: ldarg.0           // 인자 obj를 스택에 로드
IL_0001: isinst Dog        // Dog 호환성 검사 (호환되면 참조, 아니면 null을 스택에)
IL_0006: ldnull             // null을 스택에
IL_0007: cgt.un             // 부호 없는 비교: 위 결과 > null 이면 1
IL_0009: ret                // bool 반환

// Dog TestAs(object obj) => obj as Dog;
IL_0000: ldarg.0
IL_0001: isinst Dog        // 호환되면 Dog 참조, 아니면 null - 그대로 반환
IL_0006: ret

// Dog TestCast(object obj) => (Dog)obj;
IL_0000: ldarg.0
IL_0001: castclass Dog     // 호환되지 않으면 즉시 InvalidCastException 발생
IL_0006: ret

핵심 발견:

  • isas는 같은 명령어 isinst를 사용한다. isinst는 실패해도 예외를 던지지 않고 null을 스택에 푸시한다. is는 그 결과를 null과 비교해 bool로 만들고, as는 결과를 그대로 반환한다.
  • (T)castclass를 사용한다. castclass는 호환되지 않으면 그 자리에서 예외 분기로 점프한다.

isinst는 "확인용", castclass는 "단정용"이라고 기억하면 됩니다.


3. [내부 동작] 이중 캐스팅 문제와 C# 7 타입 패턴

3.1. 이중 캐스팅 — C# 6 시절의 비효율

C# 6 이전에는 타입 확인 후 변환하려면 is + (T)를 따로 써야 했습니다.

C#
// C# 6 이전 — 이중 캐스팅
public static void OldWay(object obj)
{
    if (obj is Enemy)            // 1차: 타입 검사
    {
        Enemy e = (Enemy)obj;    // 2차: 강제 캐스팅
        e.Hp = 100;
    }
}

이 코드의 IL을 보면 검사를 두 번 합니다.

IL
IL_0000: ldarg.0
IL_0001: isinst Enemy        // 1차 검사 (isinst)
IL_0006: brfalse.s IL_0015   // null이면 if 끝으로 점프
IL_0008: ldarg.0
IL_0009: castclass Enemy     // 2차 검사 (castclass) — 또 한 번 타입 검증!
IL_000e: ldc.i4.s 100
IL_0010: stfld int32 Enemy::Hp
IL_0015: ret

문제: 같은 객체에 대해 타입 검사를 두 번 합니다. CLR(Common Language Runtime, .NET의 실행 환경) 입장에서는 객체의 타입 포인터를 따라가 상속 트리를 두 번 탐색해야 합니다. Unity의 Update()에서 매 프레임 1000개의 적을 순회한다면 5만 번/초의 추가 검사가 발생합니다.

3.2. C# 7 타입 패턴 — 이중 캐스팅을 한 줄로

C# 7부터 is 뒤에 변수 이름을 붙이면 검사와 할당을 동시에 합니다.

C#
// C# 7+ — 타입 패턴
public static void NewWay(object obj)
{
    if (obj is Enemy e)          // 검사 + 변수 선언 + 할당
    {
        e.Hp = 100;
    }
}

IL을 보면 검사가 한 번뿐입니다.

IL
.locals init (
    [0] class Enemy             // 지역변수 e 선언
)
IL_0000: ldarg.0
IL_0001: isinst Enemy           // 검사 한 번 (isinst)
IL_0006: stloc.0                // 결과를 e에 저장
IL_0007: ldloc.0
IL_0008: brfalse.s IL_0012      // e가 null이면 if 끝으로 점프
IL_000a: ldloc.0
IL_000b: ldc.i4.s 100
IL_000d: stfld int32 Enemy::Hp
IL_0012: ret

개선:

  • isinst 한 번으로 검사와 캐스팅을 모두 끝냄
  • castclass가 사라져 두 번째 타입 검증이 없어짐
  • 코드도 한 줄 짧아짐

3.3. 시각화 — 컴파일러 변환 비교

이중 캐스팅 vs 타입 패턴 — 컴파일러 변환 비교

핫패스에서 매 프레임 호출되는 분기라면 이 차이가 누적되어 GC 스파이크를 만들지는 않더라도 미세한 CPU 오버헤드를 만들어냅니다. 더 중요한 것은 가독성 — is Enemy e 한 줄이면 의도가 명확합니다.


4. [실전 적용] Unity에서 어떻게 쓰는가

4.1. Before/After — 충돌 처리

Unity의 OnTriggerEnter에서 부딪힌 대상이 무엇인지 검사하는 흔한 패턴.

Before — 이중 호출 + 캐스팅

C#
void OnTriggerEnter(Collider other)
{
    // GetComponent를 두 번 호출 (비효율)
    if (other.GetComponent<Enemy>() != null)
    {
        Enemy enemy = other.GetComponent<Enemy>();
        enemy.TakeDamage(10);
    }
}

문제:

  • GetComponent를 두 번 호출 — Unity 내부에서 컴포넌트 배열 순회를 두 번 함
  • null 체크와 캐스팅이 분리되어 가독성 낮음

After — is 타입 패턴 + TryGetComponent

C#
void OnTriggerEnter(Collider other)
{
    // TryGetComponent: out 변수로 한 번에 조회
    if (other.TryGetComponent(out Enemy enemy))
    {
        enemy.TakeDamage(10);
    }
}

또는 base 컴포넌트로 받아 다양한 타입을 분기 처리할 때:

C#
void OnTriggerEnter(Collider other)
{
    // 어떤 종류의 상호작용 객체인지 한 번에 판별
    Component target = other.GetComponent<IInteractable>() as Component;

    if (target is Enemy enemy)
    {
        enemy.TakeDamage(10);
    }
    else if (target is HealItem heal)
    {
        heal.Apply(this);
    }
    else if (target is Door door)
    {
        door.Open();
    }
}

이 패턴은 방문자 패턴(Visitor Pattern) 의 가벼운 대안으로, 타입별 분기가 적고 명확할 때 적절합니다.

4.2. Before/After — 적 군중 필터링

리스트에서 특정 타입의 적만 골라 처리하는 경우.

Before — is 검사 후 강제 캐스팅

C#
public void DamageBosses(List<Enemy> enemies, int damage)
{
    foreach (Enemy enemy in enemies)
    {
        if (enemy is BossEnemy)              // 1차 검사
        {
            BossEnemy boss = (BossEnemy)enemy; // 2차 캐스팅
            boss.TakeBossDamage(damage);
        }
    }
}

IL: isinst BossEnemy + castclass BossEnemy — 1000마리 적이라면 2000번 타입 검사.

After — 타입 패턴

C#
public void DamageBosses(List<Enemy> enemies, int damage)
{
    foreach (Enemy enemy in enemies)
    {
        if (enemy is BossEnemy boss)         // 검사 + 캐스팅 한 번
        {
            boss.TakeBossDamage(damage);
        }
    }
}

IL: isinst BossEnemy 한 번. 1000번의 검사로 끝.

4.3. C# 9 속성 패턴 — 타입 + 상태 동시 검사

Before

C#
public bool IsBossAndAlive(object obj)
{
    if (obj is Enemy)
    {
        Enemy e = (Enemy)obj;
        return e.Hp > 1000 && e.IsAlive;
    }
    return false;
}

After — C# 9 속성 패턴

C#
public bool IsBossAndAlive(object obj)
{
    return obj is Enemy { Hp: > 1000, IsAlive: true };
}
속성 패턴 (Property pattern) is 뒤에 { 속성: 값, 속성: 값 } 형태로 타입과 동시에 객체의 속성 조건도 검사한다. C# 9부터 사용 가능.
예시: if (player is { Health: > 0, IsInvincible: false }) player가 null이 아니면서 Health가 양수이고 IsInvincible이 false인 경우

이 코드의 IL:

IL
IL_0000: ldarg.0
IL_0001: isinst Enemy           // 타입 검사
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: brfalse.s IL_001e      // Enemy 아니면 false
IL_000a: ldloc.0
IL_000b: ldfld int32 Enemy::Hp  // Hp 필드 로드
IL_0010: ldc.i4 1000
IL_0015: ble.s IL_001e          // Hp <= 1000이면 false
IL_0017: ldloc.0
IL_0018: ldfld bool Enemy::IsAlive  // IsAlive 필드 로드
IL_001d: ret                    // 결과 반환
IL_001e: ldc.i4.0
IL_001f: ret                    // false 반환

타입 검사 → 속성 검사 → 부울 결합까지 컴파일러가 모두 풀어 인라인 분기로 처리합니다. 객체 생성도, 임시 캐스팅도 없습니다.


5. [함정과 주의사항]

5.1. ❌ Unity 객체에 is null을 쓰면 좀비 객체를 살아있다고 판단한다

대단히 중요한 Unity 함정입니다. 순수 C# 환경에서는 is null이 권장되지만 Unity에서는 다릅니다.

❌ 위험한 코드

C#
public class Player : MonoBehaviour
{
    public Enemy target;

    void Update()
    {
        // Destroy(target.gameObject) 후에도 이 검사가 false가 될 수 있다!
        if (target is not null)
        {
            target.Attack();   // ← MissingReferenceException!
        }
    }
}

문제: Unity의 Destroy(target.gameObject)는 C# 객체 자체는 GC가 수집할 때까지 살려둡니다. 대신 Unity는 == 연산자를 오버로딩해 "C++ 네이티브 객체가 파괴됐으면 == null이 true가 되도록" 처리합니다. is null은 이 오버로딩을 우회해 버리므로 좀비 객체를 정상으로 판단합니다.

✅ 올바른 코드

C#
void Update()
{
    // Unity는 == 연산자가 좀비 객체도 null로 처리해준다
    if (target != null)
    {
        target.Attack();
    }
}
요약: 순수 C# 클래스(object, string, 사용자 정의 class 등)에는 is null이 안전하다. UnityEngine.Object를 상속받는 모든 타입(MonoBehaviour, GameObject, Transform, ScriptableObject 등)에는 반드시 == null을 사용한다.

이 규칙은 Unity의 모든 코드 컨벤션 문서에 명시되어 있습니다.

5.2. ❌ as로 받고 null 체크를 빠뜨리지 마라

❌ 위험한 코드

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

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

✅ 올바른 코드 — is 타입 패턴

C#
void OnHit(object collider)
{
    if (collider is Enemy e)
    {
        e.TakeDamage(10);
    }
}

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

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

❌ 안티패턴

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

이 코드의 IL:

IL
.try
{
    IL_0000: ldarg.0
    IL_0001: castclass Enemy   // ← 실패하면 InvalidCastException 발생
    IL_0006: stloc.0
    IL_0007: leave.s IL_000e
}
catch [System.Runtime]System.Object
{
    IL_0009: pop
    IL_000a: ldnull            // null 반환을 위한 처리
    IL_000b: stloc.0
    IL_000c: leave.s IL_000e
}

문제: 예외는 매우 비쌉니다. CLR이 스택 트레이스를 만들고 catch 블록을 찾는 비용이 발생합니다. Unity 핫패스에서 1000번/초 호출되면 GC 스파이크와 프레임 드롭이 따라옵니다.

✅ 올바른 코드

C#
public Enemy ConvertToEnemy(object obj)
{
    return obj as Enemy;   // 실패하면 그냥 null
}

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

5.4. ❌ is null vs == null — 사용자 정의 클래스의 함정

순수 C# 클래스(Unity 객체가 아님)에서도 == 오버로딩이 있다면 차이가 납니다.

C#
public class Tricky
{
    public static bool operator ==(Tricky a, Tricky b) => false;  // 잘못된 구현!
    public static bool operator !=(Tricky a, Tricky b) => true;
    public override bool Equals(object o) => false;
    public override int GetHashCode() => 0;
}

Tricky t = null;

if (t == null) { /* 도달 안 함! 오버로딩이 무조건 false 반환 */ }
if (t is null) { /* 도달함 ✅ — is null은 오버로딩을 무시하고 참조만 비교 */ }

is null은 IL 레벨에서 ldnull + ceq로 컴파일되며 operator == 호출을 절대 거치지 않습니다. 따라서 사용자 정의 클래스의 null 체크는 is null이 안전합니다 (단, 다시 말하지만 Unity 객체 제외).


6. [C# 버전별 변화]

6.1. C# 1~6 — 분리된 타입 검사

C#
// C# 6 이전
if (obj is Enemy)
{
    Enemy e = (Enemy)obj;
    e.Hp = 100;
}
// 또는
Enemy e = obj as Enemy;
if (e != null) { e.Hp = 100; }

이 시기엔 is가 단순 bool 반환이고 변수 선언을 못 했습니다.

6.2. C# 7 — 타입 패턴 (변수 선언)

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

// switch에도 적용
switch (obj)
{
    case Enemy enemy:
        enemy.Attack();
        break;
    case Item item:
        item.Pickup();
        break;
}

이중 캐스팅 문제가 해결됐습니다.

6.3. C# 8 — switch 식 + 패턴 매칭 확장

C#
// C# 8 (2019)
string GetDescription(object obj) => obj switch
{
    Enemy e => $"적 (HP: {e.Hp})",
    Item _  => "아이템",
    null    => "없음",
    _       => "알 수 없음"
};

switch 표현식이 도입되어 is 타입 패턴과 결합 가능해졌습니다.

6.4. C# 9 — 논리·관계·속성 패턴

C#
// C# 9 (2020)
if (obj is not null) { /* ... */ }                  // 논리 패턴
if (age is > 0 and < 130) { /* ... */ }              // 관계 + 논리 패턴
if (obj is Enemy { Hp: > 1000 } boss) { /* ... */ } // 속성 패턴 + 변수 선언
is not null — 논리 패턴 (Logical pattern) not, and, or 키워드로 패턴을 결합한다. C# 9부터. obj != null보다 권장(연산자 오버로딩 우회).
예시: if (response is { Status: not "Error" }) response가 null이 아니고 Status가 "Error"가 아니면 통과

6.5. C# 10~11 — 확장 속성 패턴 + 리스트 패턴

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

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

C# 7 이후 패턴 매칭은 매년 확장되어 왔으며, 이제는 단순 타입 체커가 아니라 객체의 구조와 상태를 검사하는 종합 도구가 되었습니다.


7. [정리]

핵심 체크리스트:

  • [ ] is는 검사만: bool 반환, 캐스팅 안 함, IL isinst
  • [ ] as는 안전한 변환: 실패 시 null 반환, IL isinst, 참조 타입과 Nullable에만 사용
  • [ ] (T)는 강제 변환: 실패 시 InvalidCastException, IL castclass
  • [ ] C# 7+ 기본 도구는 타입 패턴 if (obj is Type t): 검사 + 캐스팅 + 변수 선언을 한 번에
  • [ ] is + (T) 이중 캐스팅 패턴은 사용하지 않는다 (isinst + castclass 두 번 검사 발생)
  • [ ] try-catch + 캐스팅은 안티패턴: as로 대체
  • [ ] 순수 C# 객체는 is null / is not null (연산자 오버로딩 우회)
  • [ ] Unity 객체(UnityEngine.Object 상속)는 == null (좀비 객체 처리를 위해)
  • [ ] Unity 컴포넌트 조회는 TryGetComponent(out T)GetComponent 두 번 호출 회피
  • [ ] C# 9+ 속성 패턴으로 타입 + 상태 검사를 한 줄에
  • [ ] 변환 실패가 정상 흐름이면 as나 타입 패턴, 변환 실패가 버그라면 (T)로 빠르게 실패시킴

세 도구는 비슷해 보이지만 IL 레벨에서 완전히 다른 길을 갑니다. isinst는 부드러운 검사, castclass는 단호한 단정 — 이 차이를 알면 어떤 도구를 언제 써야 하는지 자연스레 결정됩니다. 결론: 의심스러우면 is Type t를, 확신할 때만 (T)를 쓴다.

반응형

+ Recent posts