[PART8.상속과 인터페이스 사용법(3/11)] 공변 반환 형식 (Covariant return types, C# 9)
자식이 부모보다 더 좁은 타입을 반환할 수 있게 된 이유와 한계 / 컴파일러가 vtable에 어떻게 끼워 넣는가 / 팩토리·Clone·빌더에서의 실전 활용
목차
1. 문제 제기 — 팩토리에서 매번 캐스팅하는 게 왜 짜증날까
Unity에서 몬스터·아이템·상태 객체를 생성하는 팩토리를 만들어 본 적이 있다면, 비슷한 코드를 본 적이 있을 것입니다.
// 부모 팩토리: 일반 캐릭터를 생성
public abstract class CharacterFactory : MonoBehaviour
{
public abstract Character Create(Vector3 position);
}
// 자식 팩토리: 플레이어 전용
public class PlayerFactory : CharacterFactory
{
public Player playerPrefab;
// C# 8 이하 — 부모와 반환 타입이 같아야 함
public override Character Create(Vector3 position)
{
return Instantiate(playerPrefab, position, Quaternion.identity);
}
}
// 호출부
public class GameManager : MonoBehaviour
{
public PlayerFactory playerFactory;
void Start()
{
// 분명 PlayerFactory인데 반환 타입은 Character
Character c = playerFactory.Create(Vector3.zero);
// Player 고유 메서드를 쓰려면 다운캐스트 필수
Player p = (Player)c;
p.LevelUp();
}
}
PlayerFactory는 누가 봐도 Player만 만드는 팩토리인데, 반환 타입은 부모인 Character로 묶여 있습니다. 호출자는 Player 고유 메서드(LevelUp)를 쓰려고 매번 (Player)로 다운캐스트해야 합니다. 캐스팅은 단순히 거슬리는 게 아닙니다.
- 타입 안전성 손실 — 잘못 캐스팅하면
InvalidCastException이 런타임에 터집니다. - 가독성 저하 — "분명 Player를 만드는데 왜 Character로 받지?"라는 의문이 매번 생깁니다.
- API 사용성 저하 — 자동완성이 부모 타입 멤버만 보여줍니다.
C# 9는 이 문제를 언어 차원에서 해결했습니다. 자식 메서드가 부모보다 더 좁은(파생된) 타입을 반환하도록 허용하는 기능, 이것이 공변 반환 형식입니다.
2. 개념 정의 — 공변(Covariant)이란 무엇인가
공변 반환 형식 (Covariant return types) — C# 9 도입override메서드의 반환 타입을 부모 메서드 반환 타입의 더 구체적인 파생 타입으로 좁힐 수 있는 기능. 단, 두 타입 사이에 암시적 참조 변환(implicit reference conversion) 이 가능해야 한다.
예시:public override Dog Create()(부모는virtual Animal Create()) 부모는Animal을, 자식은 더 구체적인Dog를 반환합니다.
비유 — 자판기와 음료 자판기
자판기 회사가 "자판기는 음료를 판다"는 인터페이스를 정합니다. 그런데 콜라 전용 자판기가 새로 나왔습니다. 사용자 입장에서 "음료가 나옵니다"라고 안내해도 틀린 말은 아니지만, "콜라가 나옵니다"라고 좁혀 말해 주면 굳이 라벨을 확인하지 않아도 됩니다. 상위 약속은 깨지 않으면서, 더 구체적인 약속을 추가하는 것 — 이것이 공변(covariance)입니다.
구조 시각화

가장 단순한 예시
public class Animal { }
public class Dog : Animal
{
public void Bark() { }
}
public class Factory
{
public virtual Animal Create() => new Animal();
}
public class DogFactory : Factory
{
// C# 9 — 반환 타입을 Dog로 좁힘
public override Dog Create() => new Dog();
}
// 사용
DogFactory df = new DogFactory();
Dog d = df.Create(); // 캐스팅 불필요
d.Bark();
override— 가상 멤버 재정의 키워드 부모 클래스의virtual또는abstract메서드를 자식 클래스에서 재정의할 때 붙입니다. C# 9 이전에는 반환 타입이 부모와 정확히 같아야 했지만, C# 9부터는 더 파생된 타입으로 좁힐 수 있습니다.
예시:public override Dog Create() => new Dog();Factory.Create()(반환 타입Animal)을 자식에서Dog로 좁힙니다.
쉬운 설명: 부모는 "동물을 줄게"라고 약속했고, 자식은 "사실 개를 줄게"라고 더 구체적으로 약속한 셈입니다. 부모의 약속은 깨지 않았으므로(개도 동물이니까) 다형성도 그대로 유지됩니다.
기술 정의: 메서드의 반환 타입은 자식이 좁힐 수 있는 위치에 있다는 점에서 공변(covariant) 입니다. 자식이 약속한 좁은 타입은 부모 약속의 일부이므로 리스코프 치환 원칙(LSP)도 깨지 않습니다.
3. 내부 동작 — 컴파일러는 어떻게 vtable에 끼워 넣을까
공변 반환 형식이 신기한 이유는, .NET CLR(Common Language Runtime, .NET의 가상머신) 자체가 원래 이 기능을 알지 못한다는 점입니다. CLR의 vtable(virtual method table, 가상 메서드 테이블)은 부모 메서드의 시그니처와 자식 메서드의 시그니처가 정확히 일치할 것을 전제로 동작했습니다. 그렇다면 어떻게 자식이 더 좁은 타입을 반환할 수 있게 된 걸까요?
두 가지 IL 장치 — .override와 PreserveBaseOverridesAttribute

직접 IL을 떠 보면 답이 나옵니다.
public class Animal { }
public class Dog : Animal
{
public void Bark() { }
}
public class Factory
{
public virtual Animal Create() => new Animal();
}
public class DogFactory : Factory
{
public override Dog Create() => new Dog(); // C# 9 공변 반환
}
위 코드의 DogFactory.Create 를 ilspycmd로 디컴파일한 IL입니다.
.method public hidebysig newslot virtual // ① 자식이지만 newslot — 새로운 vtable 슬롯
instance class Dog Create () cil managed // ② 시그니처는 Dog 반환으로 직접 좁아짐
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.PreserveBaseOverridesAttribute::.ctor() = ( 01 00 00 00 ) // ③ 공변 마커
.override method instance class Animal Factory::Create() // ④ 부모의 어느 메서드를 override하는지 명시
IL_0000: newobj instance void Dog::.ctor()
IL_0005: ret
}
핵심은 ③과 ④ 두 줄입니다.
PreserveBaseOverridesAttribute— "이 메서드는 부모 메서드의 override이지만, 시그니처가 다르다"는 것을 런타임에 알리는 마커입니다. 런타임이 가상 디스패치를 처리할 때 이 마커를 보고 "부모 슬롯도 같이 채워라"고 동작합니다..override지시문 — 자식 메서드 정의 내부에서 "이 메서드는 부모의Animal Create()메서드를 override한다"고 명시적으로 가리킵니다. 시그니처가 다르므로 일반적인 이름·파라미터 매칭으로는 부모 슬롯을 못 찾기 때문입니다.
참고: 흔히 알려진 "modreq(IsCovariant)" 설명은 정확하지 않습니다. 실제 IL을 떠 보면 modreq가 아니라PreserveBaseOverridesAttribute어트리뷰트와.override지시문 으로 구현되어 있습니다.
호출자 IL — 변수 타입에 따라 다르게 컴파일된다
DogFactory df = new DogFactory();
Dog d = df.Create(); // 변수 타입 DogFactory
Factory baseFactory = df;
Animal a = baseFactory.Create(); // 변수 타입 Factory
이 두 호출은 동일한 객체에 대한 호출이지만, 컴파일러가 변수 타입에 따라 IL을 다르게 만듭니다.
// df.Create() — 변수 타입이 DogFactory
IL_0014: newobj instance void DogFactory::.ctor()
IL_0019: callvirt instance class Dog DogFactory::Create() // ← 반환 타입 Dog로 직접 호출
IL_001e: callvirt instance void Dog::Bark() // ← 캐스팅 없이 Dog 메서드 호출
callvirt 명령어는 가상 메서드 디스패치를 수행합니다. 변수 타입이 DogFactory이므로 컴파일러는 DogFactory::Create를 직접 가리키고 반환 타입도 Dog로 컴파일합니다. 다운캐스트가 IL에서 사라진다는 것 이 공변 반환의 실질적 이득입니다.
비교 — C# 8 패턴(자식이 Animal을 반환)에서는 호출부가 이렇게 됩니다.
IL_0000: newobj instance void DogFactoryV8::.ctor()
IL_0005: callvirt instance class Animal FactoryV8::Create()
IL_000a: castclass Dog // ← 다운캐스트 명령어가 추가됨
IL_000f: callvirt instance void Dog::Bark()
castclass Dog 한 줄이 추가됩니다. 이 명령어는 런타임 타입 검사를 동반하며, 잘못된 객체일 경우 InvalidCastException을 던집니다.
성능 — 그래서 더 빠른가
컴파일된 IL 자체는 거의 동일하거나 약간 더 짧습니다. callvirt 명령어 횟수는 같고, castclass 한 줄이 사라지기 때문입니다. 단, 이 차이는 나노초 수준이라 일반 코드의 핫패스(hot path, 매 프레임 또는 매 초 수십·수백만 번 실행되는 성능 민감 경로)에서는 측정되지 않습니다. 공변 반환의 진짜 가치는 성능이 아니라 타입 안전성과 코드 명료성에 있습니다.
4. 실전 적용 — 어떤 패턴에서 빛을 발하는가
Before / After: ScriptableObject 기반 아이템 Clone
Unity에서 ScriptableObject로 무기·갑옷 데이터를 만들고 인스턴스마다 복사본이 필요한 경우, Clone 패턴을 자주 씁니다.
Before — 호출자가 매번 다운캐스트
// Unity 신입 개발자가 흔히 작성하는 C# 8 이하 패턴
public abstract class ItemData : ScriptableObject
{
public string itemName;
public abstract ItemData Clone(); // 부모 타입으로 묶임
}
public class WeaponData : ItemData
{
public int damage;
public override ItemData Clone() // 반환 타입 ItemData 강제
{
WeaponData copy = ScriptableObject.CreateInstance<WeaponData>();
copy.itemName = this.itemName;
copy.damage = this.damage;
return copy;
}
}
// 호출부
WeaponData sword = ...;
WeaponData newSword = (WeaponData)sword.Clone(); // 매번 다운캐스트
newSword.damage += 5;
위 IL을 떠 보면 호출부에 castclass WeaponData 명령어가 들어갑니다.
IL_0001: callvirt instance class ItemData ItemData::Clone()
IL_0006: castclass WeaponData // ← 캐스팅
IL_000b: ldc.i4.5
IL_000c: callvirt instance void WeaponData::set_damage(int32)
After — C# 9 공변 반환
public abstract class ItemData : ScriptableObject
{
public string itemName;
public abstract ItemData Clone();
}
public class WeaponData : ItemData
{
public int damage;
// 반환 타입을 WeaponData로 좁힘
public override WeaponData Clone()
{
WeaponData copy = ScriptableObject.CreateInstance<WeaponData>();
copy.itemName = this.itemName;
copy.damage = this.damage;
return copy;
}
}
// 호출부
WeaponData sword = ...;
WeaponData newSword = sword.Clone(); // 캐스팅 없음
newSword.damage += 5;
IL에서 castclass가 사라집니다.
IL_0001: callvirt instance class WeaponData WeaponData::Clone() // 반환 타입이 이미 WeaponData
IL_0006: ldc.i4.5
IL_0007: callvirt instance void WeaponData::set_damage(int32)
다형성도 그대로 유지됩니다. ItemData 변수로 다룰 때는 부모의 약속(ItemData Clone())이 그대로 살아 있습니다.
// 다형성 컬렉션 — 여전히 동작
List<ItemData> items = new List<ItemData> { sword, armor };
foreach (var item in items)
{
ItemData copy = item.Clone(); // 부모 약속으로 받음 — 정상
}
빌더 패턴 — 메서드 체이닝의 자식 멤버 접근
빌더 패턴에서 메서드 체이닝 도중 자식 빌더 고유의 메서드를 쓰고 싶을 때, 공변 반환이 필수적입니다.
public abstract class CharacterBuilder
{
protected float health;
public virtual CharacterBuilder WithHealth(float h)
{
this.health = h;
return this;
}
public abstract Character Build();
}
public class PlayerBuilder : CharacterBuilder
{
private int level;
// 자기 자신을 PlayerBuilder로 좁힘
public override PlayerBuilder WithHealth(float h)
{
this.health = h;
return this;
}
// PlayerBuilder 고유 메서드
public PlayerBuilder WithLevel(int lv)
{
this.level = lv;
return this;
}
public override Player Build() => new Player(health, level);
}
// 사용 — 체이닝 도중에 자식 메서드를 그대로 호출
Player p = new PlayerBuilder()
.WithHealth(100) // PlayerBuilder 반환
.WithLevel(5) // PlayerBuilder의 고유 메서드 — 캐스팅 없이 호출 가능
.Build();
만약 WithHealth가 부모 타입 CharacterBuilder를 반환했다면, .WithLevel(5)을 호출하기 위해 ((PlayerBuilder)builder.WithHealth(100)).WithLevel(5) 처럼 매번 캐스팅했어야 합니다.
판단 기준 — 언제 공변 반환을 쓸 것인가
| 상황 | 공변 반환 사용? |
|---|---|
| 팩토리·Clone·빌더에서 호출자가 구체 타입을 자주 다룸 | ✅ 사용 |
다형성 컬렉션(List<Animal>)으로만 다루는 객체 |
사용 의미 없음 — 캐스팅 자체가 없으므로 |
struct·인터페이스 구현 |
❌ 언어 제약으로 불가 (5절 참조) |
| 부모 API 사용자가 자식을 알아야 하는 형태가 결합도를 높이는가? | 신중하게 판단 — 공변은 자식 타입 노출을 늘림 |
5. 함정과 주의사항 — 신입이 자주 막히는 지점
함정 1 — struct(값 타입)에는 적용되지 않는다
// ❌ 컴파일 오류
public class Spawner
{
public virtual object Spawn() => null;
}
public class IntSpawner : Spawner
{
// CS1715: 'IntSpawner.Spawn()': 반환 형식은 'object'여야 합니다
public override int Spawn() => 42;
}
이유는 명확합니다. 공변 반환은 암시적 참조 변환 이 가능한 타입 사이에만 성립합니다. int(값 타입)를 object로 변환하려면 박싱(boxing, 값 타입을 힙에 할당된 객체로 감싸는 변환)이 일어나야 하는데, 이는 참조 변환이 아니라 별도의 메모리 할당을 동반하는 변환입니다.
✅ 해결: 제네릭이나 명시적 박싱으로 대체
// 제네릭으로 우회
public class Spawner<T>
{
public virtual T Spawn() => default;
}
public class IntSpawner : Spawner<int>
{
public override int Spawn() => 42;
}
함정 2 — 인터페이스 구현은 공변 반환 불가
public interface IFactory
{
Animal Create();
}
// ❌ 컴파일 오류 — 인터페이스 구현은 시그니처 일치 필수
public class DogFactory : IFactory
{
public Dog Create() => new Dog();
// CS0738: 'DogFactory'은(는) 인터페이스 멤버 'IFactory.Create()'을(를) 구현하지 않습니다.
// 반환 형식이 'Animal'이 아니기 때문입니다.
}
C# 9의 공변 반환은 클래스의 virtual/abstract 메서드 override 에만 적용됩니다. 인터페이스 메서드 구현에서는 시그니처가 정확히 일치해야 합니다.
✅ 해결 1: 명시적 인터페이스 구현으로 우회
public class DogFactory : IFactory
{
// 인터페이스용 — 명시적 구현
Animal IFactory.Create() => Create();
// 공개 API — 좁은 반환 타입
public Dog Create() => new Dog();
}
// 사용
DogFactory df = new DogFactory();
Dog d = df.Create(); // Dog 반환
IFactory f = df;
Animal a = f.Create(); // Animal 반환 (다형성)
✅ 해결 2: 제네릭 인터페이스
public interface IFactory<out T> where T : Animal
{
T Create();
}
public class DogFactory : IFactory<Dog>
{
public Dog Create() => new Dog();
}
함정 3 — new 키워드와 헷갈리지 말 것
public class Factory
{
public virtual Animal Create() => new Animal();
}
public class DogFactory : Factory
{
// ❌ 잘못된 패턴 — override가 아니라 메서드 숨기기(method hiding)
public new Dog Create() => new Dog();
}
// 사용 결과
Factory f = new DogFactory();
Animal a = f.Create(); // → Animal! (override가 아니라 hiding이므로 부모 메서드 호출)
new는 부모 메서드를 숨기는 것일 뿐, 가상 디스패치에 참여하지 않습니다. Factory 변수로 호출하면 DogFactory의 Create가 아니라 부모의 Create가 호출되어 Animal이 반환됩니다. 반드시 override 를 사용해야 다형성이 유지된 채로 공변 반환이 작동합니다.
✅ 올바른 패턴
public class DogFactory : Factory
{
public override Dog Create() => new Dog(); // override + 좁은 반환 타입
}
함정 4 — Unity IL2CPP에서의 호환성
IL2CPP — Unity의 AOT(Ahead-Of-Time, 사전 컴파일) 백엔드. C#/IL을 C++로 변환한 뒤 네이티브 코드로 컴파일합니다. 모바일 빌드(iOS·Android)에서 사용됩니다.
Unity 2021.2 이상은 C# 9를 지원하며 IL2CPP에서도 공변 반환이 정상 동작합니다. 다만 다음을 기억합니다.
- Unity 2021.1 이하: 컴파일러가 C# 9를 받지 못하므로 사용 불가. C# 8 패턴으로 작성해야 합니다.
- 에셋 스토어 패키지의 호환성: 외부 패키지가 C# 8 시그니처(부모 타입 반환)에 의존하고 있다면, 공변 반환을 추가했을 때 부모 시그니처도 호출 가능해야 패키지가 동작합니다 — 다행히
PreserveBaseOverridesAttribute가 부모 시그니처도 유지해 주므로 호환성은 보존됩니다.
6. C# 버전별 변화
C# 1.0 ~ 8.0 — 반환 타입 일치 강제
public class Factory
{
public virtual Animal Create() => new Animal();
}
public class DogFactory : Factory
{
// C# 8까지 — 반환 타입이 부모와 동일해야 함
public override Animal Create() => new Dog();
}
이 시기에 같은 효과를 내려면 명시적 인터페이스 구현 + 공개 메서드 분리 패턴을 썼습니다.
// C# 8 이하의 우회법
public abstract class Cloneable<T> where T : Cloneable<T>
{
public abstract T Clone();
}
public class Circle : Cloneable<Circle>
{
public double Radius;
public override Circle Clone() => new Circle { Radius = this.Radius };
}
제네릭 자기 참조(F-bounded polymorphism, 자기 자신을 타입 인자로 넘기는 패턴)로 우회했지만, 코드가 복잡하고 다중 상속 단계에서 깨집니다.
C# 9.0 (2020.11) — 공변 반환 형식 도입
public abstract class Shape
{
public abstract Shape Clone();
}
public class Circle : Shape
{
public double Radius;
// 핵심: 자식에서 직접 자기 타입을 반환
public override Circle Clone() => new Circle { Radius = this.Radius };
}
IL 레벨 변화는 컴파일러가 PreserveBaseOverridesAttribute 와 .override 지시문을 자동으로 삽입하는 것입니다 — 개발자는 override 키워드만 그대로 쓰면 됩니다.
.method public hidebysig newslot virtual
instance class Circle Clone () cil managed
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices
.PreserveBaseOverridesAttribute::.ctor() // C# 9에서 추가
.override method instance class Shape Shape::Clone() // C# 9에서 추가
IL_0000: newobj instance void Circle::.ctor()
IL_000c: ret
}
C# 10 ~ 13 — 추가 변화 없음
C# 9 이후의 버전에서 공변 반환 형식 자체에 대한 문법 변화는 없습니다. 단, record 타입의 Clone 메서드(컴파일러 자동 생성)가 내부적으로 공변 반환과 동일한 메커니즘을 사용합니다.
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
Dog d = new Dog("Buddy", "Husky");
Dog d2 = d with { Breed = "Beagle" }; // record의 with 식 — 내부적으로 자기 타입 반환
record의 with 식은 컴파일러가 생성한 <Clone>$ 메서드를 호출하며, 이 메서드는 자식 record에서 자식 타입을 반환하도록 공변 메커니즘으로 구현되어 있습니다.
7. 정리 — 이것만 기억하라
| 항목 | 핵심 |
|---|---|
| 무엇 | C# 9부터 override 메서드의 반환 타입을 부모보다 좁은(파생된) 타입으로 지정 가능 |
| 언제 | 팩토리·Clone·빌더 패턴에서 호출자가 구체 타입을 자주 사용할 때 |
| 조건 | 자식 반환 타입에서 부모 반환 타입으로 암시적 참조 변환이 가능해야 함 |
| 불가 | struct(값 타입), 인터페이스 직접 구현, new 메서드 숨기기 |
| IL 구현 | PreserveBaseOverridesAttribute + .override 지시문 (modreq 아님) |
| vtable | 부모 슬롯은 그대로 — 다형성 유지, 호출자는 변수 타입에 따라 좁거나 넓게 받음 |
| 성능 | 다운캐스트(castclass) 명령어가 사라지지만 측정 가능한 차이는 미미 — 가치는 타입 안전성 |
| Unity | 2021.2+ / IL2CPP 호환 — 모바일 빌드도 정상 동작 |
체크리스트
- [ ]
override키워드를 빠뜨리지 않았는가? (실수로new쓰면 다형성이 깨진다) - [ ] 자식 반환 타입이 부모 반환 타입으로 암시적 참조 변환 가능한가?
- [ ]
struct/인터페이스 구현이 아닌 클래스의virtual/abstractoverride인가? - [ ] 호출부의
(자식타입)캐스팅을 모두 제거했는가? — 제거 후에도 컴파일되면 공변 반환이 작동 중인 것 - [ ] Unity 프로젝트라면 2021.2 이상인가?
공변 반환은 "캐스팅 한 줄 줄이는 사소한 기능"이 아닙니다. 팩토리·Clone·빌더처럼 자식이 부모를 specialize 하는 모든 패턴에서 타입 시스템이 자식의 약속을 올바르게 표현하게 해 주는 도구입니다. 다음 PART에서는 자식의 추가 약속을 막는 반대 방향의 도구, sealed를 살펴봅니다.
'C# 기초' 카테고리의 다른 글
| [PART8.상속과 인터페이스 사용법(5/11)] abstract 클래스와 abstract 메서드 — 미완성을 강제로 완성시키는 설계 도구 (1) | 2026.05.03 |
|---|---|
| [PART8.상속과 인터페이스 사용법(4/11)] sealed 개요 — 상속을 막아 얻는 안정성과 성능 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(2/11)] virtual · override · base — 부모를 갈아끼우는 세 개의 약속 (0) | 2026.05.03 |
| [PART8.상속과 인터페이스 사용법(1/11)] 상속 문법 — `:` (0) | 2026.05.03 |
| [PART7.클래스와 객체 입문(16/21)] ref 필드 — ref struct 안에서 참조를 필드로 보관 (C# 11) (0) | 2026.05.02 |
