[PART8.상속과 인터페이스 사용법(10/11)] is · as · 타입 패턴 — 안전한 타입 검사 세 가지 방법
bool 반환 vs null 반환 vs 예외 / isinst와 castclass의 차이 / C# 7+ 타입 패턴이 이중 캐스팅을 없애는 방식
목차
1. [문제 제기] 다운캐스팅, 잘못 쓰면 게임이 멈춘다
Unity에서 이런 코드를 본 적이 있을 겁니다.
void OnTriggerEnter(Collider other)
{
Enemy enemy = (Enemy)other.gameObject.GetComponent<Component>();
enemy.TakeDamage(10);
}
문제: other가 Enemy가 아니라 아이템 박스이거나 벽이면 어떻게 될까요? 런타임에 InvalidCastException이 터지고 게임이 멈춥니다. QA에서 잡지 못한 채 출시되면 유저는 충돌 직전에 강제 종료를 보게 됩니다.
이 문제를 피하기 위해 신입 개발자는 흔히 이렇게 씁니다.
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
// 부모 클래스
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)이라고도 부른다.
위 세 가지 표현이 컴파일되면 어떻게 변할까요?
// 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
핵심 발견:
is와as는 같은 명령어isinst를 사용한다.isinst는 실패해도 예외를 던지지 않고null을 스택에 푸시한다.is는 그 결과를null과 비교해 bool로 만들고,as는 결과를 그대로 반환한다.(T)만castclass를 사용한다.castclass는 호환되지 않으면 그 자리에서 예외 분기로 점프한다.
isinst는 "확인용", castclass는 "단정용"이라고 기억하면 됩니다.
3. [내부 동작] 이중 캐스팅 문제와 C# 7 타입 패턴
3.1. 이중 캐스팅 — C# 6 시절의 비효율
C# 6 이전에는 타입 확인 후 변환하려면 is + (T)를 따로 써야 했습니다.
// C# 6 이전 — 이중 캐스팅
public static void OldWay(object obj)
{
if (obj is Enemy) // 1차: 타입 검사
{
Enemy e = (Enemy)obj; // 2차: 강제 캐스팅
e.Hp = 100;
}
}
이 코드의 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# 7+ — 타입 패턴
public static void NewWay(object obj)
{
if (obj is Enemy e) // 검사 + 변수 선언 + 할당
{
e.Hp = 100;
}
}
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. 시각화 — 컴파일러 변환 비교

핫패스에서 매 프레임 호출되는 분기라면 이 차이가 누적되어 GC 스파이크를 만들지는 않더라도 미세한 CPU 오버헤드를 만들어냅니다. 더 중요한 것은 가독성 — is Enemy e 한 줄이면 의도가 명확합니다.
4. [실전 적용] Unity에서 어떻게 쓰는가
4.1. Before/After — 충돌 처리
Unity의 OnTriggerEnter에서 부딪힌 대상이 무엇인지 검사하는 흔한 패턴.
Before — 이중 호출 + 캐스팅
void OnTriggerEnter(Collider other)
{
// GetComponent를 두 번 호출 (비효율)
if (other.GetComponent<Enemy>() != null)
{
Enemy enemy = other.GetComponent<Enemy>();
enemy.TakeDamage(10);
}
}
문제:
GetComponent를 두 번 호출 — Unity 내부에서 컴포넌트 배열 순회를 두 번 함- null 체크와 캐스팅이 분리되어 가독성 낮음
After — is 타입 패턴 + TryGetComponent
void OnTriggerEnter(Collider other)
{
// TryGetComponent: out 변수로 한 번에 조회
if (other.TryGetComponent(out Enemy enemy))
{
enemy.TakeDamage(10);
}
}
또는 base 컴포넌트로 받아 다양한 타입을 분기 처리할 때:
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 검사 후 강제 캐스팅
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 — 타입 패턴
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
public bool IsBossAndAlive(object obj)
{
if (obj is Enemy)
{
Enemy e = (Enemy)obj;
return e.Hp > 1000 && e.IsAlive;
}
return false;
}
After — C# 9 속성 패턴
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_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에서는 다릅니다.
❌ 위험한 코드
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은 이 오버로딩을 우회해 버리므로 좀비 객체를 정상으로 판단합니다.
✅ 올바른 코드
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 체크를 빠뜨리지 마라
❌ 위험한 코드
void OnHit(object collider)
{
Enemy e = collider as Enemy;
e.TakeDamage(10); // collider가 Enemy가 아니면 NullReferenceException!
}
as는 실패해도 예외를 던지지 않기 때문에, null 체크를 빼먹으면 다음 줄에서 참조 예외가 터집니다. 위치도 한 줄 미루어져 디버깅이 까다로워집니다.
✅ 올바른 코드 — is 타입 패턴
void OnHit(object collider)
{
if (collider is Enemy e)
{
e.TakeDamage(10);
}
}
타입 패턴은 검사 자체가 분기 조건이므로 null 체크를 빼먹을 수 없습니다. C# 7 이상이라면 as + null 체크보다 타입 패턴을 우선합니다.
5.3. ❌ try-catch로 캐스팅 실패를 잡지 마라
❌ 안티패턴
public Enemy ConvertToEnemy(object obj)
{
try
{
return (Enemy)obj; // 실패하면 castclass가 예외 던짐
}
catch
{
return null;
}
}
이 코드의 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 스파이크와 프레임 드롭이 따라옵니다.
✅ 올바른 코드
public Enemy ConvertToEnemy(object obj)
{
return obj as Enemy; // 실패하면 그냥 null
}
as는 IL isinst 한 줄로 끝나며 예외 비용이 없습니다.
5.4. ❌ is null vs == null — 사용자 정의 클래스의 함정
순수 C# 클래스(Unity 객체가 아님)에서도 == 오버로딩이 있다면 차이가 납니다.
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# 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# 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# 8 (2019)
string GetDescription(object obj) => obj switch
{
Enemy e => $"적 (HP: {e.Hp})",
Item _ => "아이템",
null => "없음",
_ => "알 수 없음"
};
switch 표현식이 도입되어 is 타입 패턴과 결합 가능해졌습니다.
6.4. C# 9 — 논리·관계·속성 패턴
// 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# 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 반환, 캐스팅 안 함, ILisinst - [ ]
as는 안전한 변환: 실패 시 null 반환, ILisinst, 참조 타입과 Nullable에만 사용 - [ ]
(T)는 강제 변환: 실패 시InvalidCastException, ILcastclass - [ ] 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)를 쓴다.
'C# 기초' 카테고리의 다른 글
| [PART9.컬렉션 기본 사용법(1/8)] List<T> — 가장 자주 쓰는 컬렉션 (0) | 2026.05.04 |
|---|---|
| [PART8.상속과 인터페이스 사용법(11/11)] 업캐스트와 다운캐스트 — 안전한 타입 변환 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(9/11)] 명시적 인터페이스 구현 — `IFoo.Method()` (1) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(8/11)] 정적 추상 인터페이스 멤버 — 타입 자체를 다형성에 끌어들이다 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(7/11)] 인터페이스 기본 구현 — 계약을 깨지 않고 진화시키는 도구 (0) | 2026.05.03 |
