[PART8.상속과 인터페이스 사용법(4/11)] sealed 개요 — 상속을 막아 얻는 안정성과 성능
왜 .NET 표준 라이브러리의 string·StringBuilder는 모두 sealed인가 / sealed class와 sealed override의 차이 / JIT의 devirtualization을 끌어내는 한 줄짜리 키워드
목차
1. [문제 제기] 열어두면 누가 어떻게 쓸지 아무도 모릅니다
Unity에서 적의 사망 처리를 담당하는 Enemy 클래스를 만들었다고 상상해 보겠습니다. Die() 메서드는 점수 추가, 드랍 아이템 생성, 사운드 재생까지 한 번에 처리합니다. 동료가 Enemy를 상속해 BossEnemy를 만들고, Die()를 재정의하면서 점수 추가 부분만 슬쩍 건너뛰었습니다. 며칠 뒤 QA가 보스를 잡아도 점수가 오르지 않는다고 보고합니다. 누가 잘못한 걸까요? Enemy 작성자는 "내 클래스를 상속받을 수 있게 만든 적이 없다"고 말하고, BossEnemy 작성자는 "C#이 상속을 허용하니 했을 뿐"이라고 답합니다.
같은 일이 .NET 라이브러리에도 일어날 수 있었습니다. 만약 string이 상속 가능했다면 누군가 MaliciousString을 만들어 ToString()을 재정의하고, 인증 토큰 비교 로직을 우회시킬 수 있었을 것입니다. 그래서 string, StringBuilder, Type, Unity의 Transform까지 모두 sealed class로 선언되어 있습니다.
문제는 단순합니다. C#은 class를 선언하면 기본적으로 상속 가능합니다. Java도 그렇고, C#도 그렇습니다. 하지만 클래스를 처음 만들 때 "이 클래스는 어떻게 확장될 수 있는가"를 끝까지 설계한 사람은 거의 없습니다. 설계되지 않은 확장은 곧 깨진 약속, 보안 취약점, JIT 최적화 손실로 이어집니다. sealed는 "이 클래스는 더 이상 상속하지 마세요"라고 컴파일러에게 강제로 약속을 받는 키워드입니다.
이 글에서는 sealed가 정확히 무엇을 막는지, 컴파일러와 JIT가 sealed를 어떻게 활용하는지, 언제 sealed를 적극 써야 하는지를 IL 레벨까지 따라갑니다.
2. [개념 정의] sealed는 "여기서 끝"이라는 봉인 도장입니다
sealed— 봉인 키워드 (Sealed modifier) 클래스 앞에 붙이면 그 클래스를 다른 클래스가 상속할 수 없게 막고, 메서드 앞에 붙이면(반드시override와 함께) 자식 클래스가 그 메서드를 더 이상 재정의하지 못하게 막는다. 상속 사다리의 마지막 칸을 봉인하는 도장 같은 역할이다.
예시:public sealed class Bullet { ... }다른 누구도Bullet을 상속할 수 없음
2.1 비유 — 우편함의 봉인 스티커
택배 박스를 보낼 때 봉인 스티커를 붙이면 그 박스는 더 이상 열고 무언가를 추가하거나 바꿔치기할 수 없습니다. sealed class는 클래스 자체에 봉인 스티커를 붙이는 일이고, sealed override는 박스 안의 특정 칸 하나만 봉인하는 일입니다. 봉인되지 않은 칸은 여전히 열고 닫을 수 있습니다.
2.2 sealed class와 sealed override의 차이

sealed class는 클래스 전체에 봉인을 거는 강력한 형태입니다. sealed override는 더 섬세한 형태로, 클래스 자체는 상속 가능하되 특정 메서드의 다형성 체인만 종료시킵니다.
2.3 기본 코드 예시
// Unity 슈팅 게임에서 총알을 봉인하는 예
public sealed class Bullet
{
public float Speed = 10f;
public void Move() { /* 이동 로직 */ }
}
// 컴파일 에러: CS0509
// 'SpecialBullet': cannot derive from sealed type 'Bullet'
public class SpecialBullet : Bullet { }
// sealed override — 클래스는 열어두되 특정 메서드만 봉인
public abstract class Enemy
{
public abstract void Die();
}
public class Goblin : Enemy
{
// Goblin의 Die 동작은 더 이상 자식이 바꿀 수 없게 봉인
public sealed override void Die()
{
// 점수 +10, 드랍 아이템 생성, 사망 사운드 재생
}
}
// Goblin은 상속 가능
public class Hobgoblin : Goblin
{
// 컴파일 에러: CS0239
// 'Hobgoblin.Die()' cannot override 'Goblin.Die()' because it is sealed.
public override void Die() { }
}
두 키워드 모두 컴파일 타임에 차단됩니다. 런타임 검사가 아니라 컴파일러가 직접 거부하므로 추가 런타임 비용은 0입니다.
2.4 IL에서 sealed가 어떻게 표현되는가
위 sealed 클래스 코드를 ilspycmd로 디컴파일하면 클래스 메타데이터에 sealed 플래그가 붙어 있습니다.
// 일반 클래스 (NormalDerived)
.class public auto ansi beforefieldinit NormalDerived
extends NormalBase
// sealed 클래스 (SealedDerived)
.class public auto ansi sealed beforefieldinit SealedDerived
extends NormalBase
sealed 한 단어가 클래스 정의 줄에 추가됐을 뿐입니다. 이 플래그가 CLR에게 "이 타입의 자식 타입은 존재하지 않는다"는 보장을 제공하고, JIT는 이 보장을 최적화에 활용합니다.
sealed override 메서드는 메서드 자체에 final 플래그가 붙습니다.
// 일반 override (NormalDerived::Hit)
.method public hidebysig virtual
instance void Hit () cil managed
// sealed override (SealedMethodDerived::Hit)
.method public final hidebysig virtual
instance void Hit () cil managed
final이 더 붙었다는 점이 핵심입니다. 클래스의 sealed와 메서드의 final은 IL 레벨에서 다른 키워드지만, C#에서는 둘 다 sealed라는 동일한 키워드로 표현됩니다.
흥미롭게도 C#의static class는 IL에서abstract sealed로 표현됩니다. 인스턴스를 만들 수 없고(abstract) 상속도 못 한다(sealed)는 두 가지 제약이 합쳐져 "정적 클래스"라는 의미가 됩니다.
3. [내부 동작] JIT는 sealed를 보고 callvirt를 call로 바꿉니다
3.1 callvirt와 call의 차이
C# 컴파일러는 인스턴스 메서드 호출에 거의 항상 callvirt를 사용합니다. 메서드가 virtual이 아니어도 그렇습니다. 그 이유는 callvirt가 호출 직전에 수신자(receiver)가 null인지 자동으로 검사해주기 때문입니다. 반면 call은 null 검사를 하지 않습니다.

callvirtvscall(IL 명령어)callvirt는 가상 메서드 호출용 명령어로, 호출 전에 수신자가 null인지 검사하고 vtable에서 실제 타입의 메서드를 찾아 호출한다.call은 정적·비가상 메서드 호출용으로 null 검사 없이 컴파일 타임에 결정된 메서드를 직접 호출한다. 둘은 IL 레벨의 명령어이며 C# 코드에는 직접 나타나지 않는다.
3.2 호출부 IL은 항상 callvirt지만 JIT가 바꿉니다
위에서 보여준 일반/sealed/sealed-override 세 클래스를 호출하는 코드를 컴파일하면, 세 호출 모두 IL 레벨에서는 callvirt로 동일합니다.
// CallNormal: NormalDerived 인스턴스의 Hit() 호출
IL_0000: ldarg.0
IL_0001: callvirt instance void NormalBase::Hit() // 일반: callvirt
IL_0006: ret
// CallSealed: SealedDerived(sealed class) 인스턴스의 Hit() 호출
IL_0000: ldarg.0
IL_0001: callvirt instance void NormalBase::Hit() // sealed class도 callvirt
IL_0006: ret
// CallSealedMethod: SealedMethodDerived(sealed override) 인스턴스의 Hit() 호출
IL_0000: ldarg.0
IL_0001: callvirt instance void NormalBase::Hit() // sealed override도 callvirt
IL_0006: ret
C# 컴파일러는 sealed 여부를 호출부에서 신경 쓰지 않고 일관되게 callvirt를 발행합니다. 차이는 그 다음 단계인 JIT 컴파일러가 만듭니다. JIT는 메타데이터에서 수신자 타입이 sealed 플래그를 가지고 있거나, 호출 대상 메서드가 final(C#의 sealed override) 플래그를 가지고 있으면, callvirt를 일반 직접 호출(call)로 바꿉니다. 이 변환을 devirtualization(탈가상화)이라고 부릅니다.
devirtualization이 일어나면 다음 두 가지 효과가 따라옵니다.
- vtable 조회 제거 — 함수 포인터 간접 참조가 사라져 분기 예측 미스 가능성이 줄어듭니다.
- 인라이닝 가능 — 직접 호출이 된 메서드가 작으면 JIT가 본문을 호출 위치에 그대로 펼칩니다. 함수 호출 자체가 사라지는 가장 강력한 최적화입니다.
Devirtualization (탈가상화) JIT가 가상 메서드 호출을 비가상 직접 호출로 변환하는 최적화. sealed 클래스, sealed override, 또는 정적 분석으로 호출 대상이 한 가지뿐임이 증명될 때 발생한다. .NET 6부터 더 적극적으로 수행되며 .NET 8에서는 PGO(Profile-Guided Optimization)와 결합해 동적으로도 시도된다.
3.3 struct는 암시적 sealed
값 타입은 상속이 불가능합니다. 그래서 IL에서 모든 struct는 자동으로 sealed 플래그를 갖습니다.
// 일반 record class — sealed 없음
.class public auto ansi beforefieldinit OpenRecord
// sealed record — 명시적 sealed
.class public auto ansi sealed beforefieldinit SealedRecord
// struct — 암시적 sealed (sequential 키워드는 메모리 레이아웃 옵션)
.class public sequential ansi sealed beforefieldinit MyStruct
이 사실 덕분에 struct의 메서드 호출은 일반적으로 devirtualization 대상이 됩니다. 단, 박싱이 일어나면 참조 타입처럼 동작해 이 이점이 사라집니다. Unity 핫패스에서 struct를 다룰 때 박싱을 피해야 하는 이유 중 하나입니다.
또한 record class는 기본적으로 sealed가 아닙니다. record가 처음 등장한 C# 9에서 의도적으로 상속을 허용했기 때문입니다. record에도 sealed를 붙일 수 있고, 의도가 명확하면 붙이는 편이 좋습니다.
4. [실전 적용] 어디에 sealed를 붙이고 어디는 열어둘 것인가
4.1 기본 원칙: "default to sealed"
.NET 팀, Eric Lippert(C# 컴파일러 전 팀원), Jeffrey Richter(CLR via C# 저자)가 공통적으로 권하는 원칙입니다.
상속을 위해 명시적으로 설계되고 문서화된 클래스가 아니라면, 기본적으로 sealed로 만들어라.
이 원칙을 거꾸로 뒤집으면 더 분명합니다. "이 클래스가 어떻게 상속될 수 있을지 끝까지 설계할 자신이 없다면, 처음에는 봉인해라. 나중에 상속이 필요해지면 sealed를 떼면 된다(하위 호환). 반대로 처음 열어뒀다가 나중에 sealed로 바꾸는 것은 파괴적 변경이다."
4.2 Before — 평범한 핫패스 컴포넌트
Unity 모바일 게임에서 매 프레임 수백 번 호출되는 총알 매니저를 가정해 보겠습니다.
// ❌ Before: 무심코 열어둔 컴포넌트
public class Bullet : MonoBehaviour
{
public float Speed = 10f;
public virtual void Move()
{
transform.position += transform.forward * Speed * Time.deltaTime;
}
}
public class BulletManager : MonoBehaviour
{
public List<Bullet> activeBullets;
void Update()
{
// 핫패스: 매 프레임 수백 번 가상 호출
foreach (var bullet in activeBullets)
{
bullet.Move(); // callvirt + vtable 조회
}
}
}
Bullet이 일반 class이고 Move가 virtual이므로, JIT는 Bullet을 상속한 다른 자식 타입(HomingBullet, LaserBullet 등)이 존재할 가능성을 배제할 수 없습니다. devirtualization을 시도조차 하지 않습니다.
4.3 After — sealed로 봉인한 핫패스
// ✅ After: sealed로 의도를 못 박는다
public sealed class Bullet : MonoBehaviour
{
public float Speed = 10f;
// virtual을 뗀다 — 어차피 자식이 없으므로 의미가 없다
public void Move()
{
transform.position += transform.forward * Speed * Time.deltaTime;
}
}
public class BulletManager : MonoBehaviour
{
public List<Bullet> activeBullets;
void Update()
{
foreach (var bullet in activeBullets)
{
bullet.Move(); // JIT가 devirtualize → 인라이닝 가능
}
}
}
After 버전의 호출 IL은 여전히 callvirt이지만(C# 컴파일러는 똑같이 발행), JIT가 Bullet이 sealed임을 보고 Move의 호출 대상이 단 하나뿐임을 확신하면 직접 호출로 바꾸고 본문을 인라인합니다. 수백 발의 총알이 매 프레임 호출되는 모바일 환경에서 누적 효과가 측정 가능한 수준으로 나타날 수 있습니다.
Unity 모바일과 핫패스 Unity의Update()·FixedUpdate()·LateUpdate()처럼 매 프레임 호출되는 코드 경로를 핫패스(hot path)라고 한다. 한 번 호출에 절약되는 시간은 나노초 단위라도, 매 프레임 수백~수천 번 누적되면 프레임 드랍과 직결된다. 보급형 안드로이드 기기에서 60FPS를 유지하려면 한 프레임당 16.6ms 안에 모든 작업을 끝내야 하므로 이 누적이 의미 있는 차이를 만든다.
4.4 라이브러리 클래스에는 사실상 필수
실무에서 직접 만든 라이브러리·NuGet 패키지·공유 코드의 public 클래스에는 sealed가 더욱 중요합니다. 한 번 public으로 열어두고 외부 사용자가 상속해서 쓰기 시작하면, 이후 내부 구현을 바꿀 때마다 외부 자식 클래스를 깨뜨릴 수 있습니다. 처음부터 sealed로 잠가두면 외부에서 상속하지 못하게 막아 자유롭게 내부 구현을 리팩터링할 수 있습니다.
.NET BCL의 string, StringBuilder, Type, Regex, DateTime(struct이므로 자동 sealed) 같은 핵심 타입이 모두 sealed인 이유입니다. Unity의 Transform도 마찬가지로 public sealed class Transform입니다.
4.5 sealed override의 활용
클래스 전체를 봉인할 수는 없지만 특정 메서드만 더 이상 재정의되지 않게 하고 싶을 때 사용합니다.
public abstract class GameObject2D
{
public abstract void Render();
public virtual void OnUpdate() { }
}
public class Sprite : GameObject2D
{
// Sprite 단계에서 Render의 정의를 확정 — 자식이 더 이상 바꾸지 못함
public sealed override void Render()
{
// SpriteBatch.Draw(this);
}
// OnUpdate는 자식이 계속 확장 가능하게 열어둔다
public override void OnUpdate() { /* 기본 동작 */ }
}
public class AnimatedSprite : Sprite
{
// Render는 봉인되어 재정의 불가
public override void OnUpdate() { /* 애니메이션 프레임 갱신 */ }
}
Render처럼 라이브러리 사용자가 절대 손대면 안 되는 핵심 동작에는 sealed override를 붙이고, OnUpdate처럼 확장 의도가 있는 메서드는 열어두는 식으로 세밀하게 제어할 수 있습니다.
5. [함정과 주의사항] sealed는 만능이 아닙니다
5.1 ❌ 함정 1: 모킹 라이브러리가 sealed를 모킹하지 못합니다
Moq, NSubstitute, FakeItEasy 같은 표준 모킹 라이브러리는 테스트 대상 클래스를 런타임에 상속해 가짜 객체(proxy)를 생성하는 방식으로 동작합니다. sealed 클래스는 상속이 불가능하므로 모킹할 수 없습니다.
// ❌ 모킹 불가
public sealed class InventoryService
{
public int GetItemCount(int playerId) => /* DB 조회 */;
}
// 테스트 코드
var mock = new Mock<InventoryService>(); // 런타임 예외 발생
// System.NotSupportedException: Type to mock must be an interface,
// a delegate, or a non-sealed, non-static class.
// ✅ 인터페이스 추출로 우회
public interface IInventoryService
{
int GetItemCount(int playerId);
}
public sealed class InventoryService : IInventoryService
{
public int GetItemCount(int playerId) => /* DB 조회 */;
}
// 테스트 코드
var mock = new Mock<IInventoryService>(); // 정상 동작
mock.Setup(m => m.GetItemCount(1)).Returns(5);
이 "함정"은 사실 좋은 설계로 인도하는 강제 장치입니다. 의존성 역전 원칙(DIP)에 따라 구체 클래스가 아닌 인터페이스에 의존하게 만들면 sealed와 테스트 가능성을 동시에 얻을 수 있습니다.
5.2 ❌ 함정 2: virtual + sealed class는 의미가 없습니다
// ❌ 모순된 의도
public sealed class Bullet
{
public virtual void Move() { } // 경고: CS0628
// 'Bullet.Move()': new protected member declared in sealed type
// (sealed 클래스에 virtual을 선언해도 누구도 override할 수 없다)
}
// ✅ sealed면 virtual을 떼라
public sealed class Bullet
{
public void Move() { }
}
sealed 클래스의 virtual 메서드는 정의는 되지만 누구도 override할 수 없으므로 vtable 슬롯만 낭비합니다. sealed로 바꾸기로 마음먹었다면 안에 있는 virtual도 함께 정리하는 편이 깔끔합니다.
5.3 ❌ 함정 3: record를 sealed 없이 두면 의도와 다르게 상속됩니다
// ❌ 의도치 않게 상속을 허용한 record
public record PlayerData(string Name, int Level);
// 다른 개발자가 다음과 같이 상속하고 등호 비교 동작이 깨질 수 있음
public record AdminPlayerData(string Name, int Level, string AdminToken)
: PlayerData(Name, Level);
var p1 = new PlayerData("A", 1);
var p2 = (PlayerData)new AdminPlayerData("A", 1, "secret");
Console.WriteLine(p1 == p2);
// false — record의 동등성 비교는 EqualityContract(런타임 타입)까지 본다
// ✅ DTO·값 객체 record는 sealed로 잠가라
public sealed record PlayerData(string Name, int Level);
record는 데이터 묶음으로 쓰일 때 가장 안전합니다. 상속까지 허용하면 동등성 비교의 동작이 헷갈리고(EqualityContract 비교 때문), 자식 record가 추가 필드를 가지면 구조적 불변성이 깨집니다. DTO·메시지·이벤트 데이터로 쓰이는 record는 기본적으로 sealed로 두는 편이 안전합니다.
5.4 ❌ 함정 4: Unity의 MonoBehaviour를 sealed로 만들어도 되는가
결론부터 말하면 됩니다. Unity 엔진은 사용자 컴포넌트를 상속하지 않으며, 상속하더라도 인스펙터·SerializeField·라이프사이클 메서드 호출 모두 sealed 컴포넌트에서 정상 동작합니다. 직접 작성한 컴포넌트가 자식 컴포넌트를 가질 의도가 없다면 sealed를 붙여도 안전합니다.
// ✅ Unity 컴포넌트도 안전하게 sealed
public sealed class PlayerController : MonoBehaviour
{
[SerializeField] private float moveSpeed = 5f;
void Update()
{
// 이동 로직
}
}
다만 베이스 컴포넌트를 만들고 여러 자식 컴포넌트로 분기시키는 패턴(예: 다양한 무기 컴포넌트가 공통 WeaponBase를 상속)이라면 베이스 클래스에는 sealed를 붙일 수 없습니다. 그 경우에도 잎 노드(leaf) 클래스에는 sealed를 붙이는 습관을 들이면 좋습니다.
6. [C# 버전별 변화] sealed 자체는 거의 변하지 않았습니다
sealed 키워드는 C# 1.0부터 존재했고 의미도 거의 그대로입니다. 다만 다른 언어 기능이 추가되면서 sealed의 활용 범위가 자연스럽게 넓어졌습니다.
| 버전 | 변화 | sealed에 미친 영향 |
|---|---|---|
| C# 1.0 | sealed class, sealed override 도입 | 기본 의미 정착 |
| C# 6.0~ | 표현식 본문 멤버 등 문법 추가 | 직접 영향 없음 |
| C# 9.0 | record class 도입 | record는 기본 sealed가 아님 — 명시적으로 sealed record 선언 필요 |
| C# 10.0 | record struct 도입 | struct이므로 자동 sealed (변경 불가) |
| .NET 6+ | JIT의 devirtualization 강화 | sealed의 성능 효과가 더 자주 실현됨 |
| .NET 8+ | Dynamic PGO 확장 | sealed가 아니어도 일부 호출은 devirtualize되지만, sealed는 여전히 가장 확실한 보장 |
6.1 record와 sealed의 변화
// C# 9.0: record class — 기본 상속 가능
public record User(string Name);
public record Admin(string Name, string Token) : User(Name); // 가능
// 권장 패턴
public sealed record User(string Name); // 잎 노드는 sealed
// C# 10.0: record struct — 자동 sealed
public record struct Point(int X, int Y);
// 상속 시도 시 컴파일 에러 (struct이므로)
6.2 정적 클래스와 sealed의 관계
C# 2.0에서 static class가 도입되면서 IL 레벨에서 정적 클래스는 abstract sealed의 조합으로 표현됩니다. 인스턴스를 만들 수 없고(abstract) 상속도 못 한다(sealed)는 두 제약을 합쳐 "정적 클래스"라는 의미를 만든 것입니다. 직접 abstract sealed class를 쓰는 것은 C# 문법으로 금지되어 있고, static class를 사용해야 합니다.
6.3 partial class와 sealed의 조합
소스 생성기(Source Generator)가 보편화되면서 partial sealed class 조합이 자주 쓰입니다.
// 사용자 작성 부분
public sealed partial class PlayerStats
{
public int Level { get; set; }
}
// 소스 생성기가 추가하는 부분
public sealed partial class PlayerStats
{
public string Serialize() { /* 자동 생성 */ }
}
partial로 쪼갠 모든 조각이 동일한 modifier(sealed, public 등)를 가져야 합니다.
7. [정리] 핵심 체크리스트
- [ ] default to sealed — 새 클래스를 만들 때 상속 의도가 없으면 일단 sealed로 시작합니다. 나중에 떼는 것은 안전하지만, 나중에 붙이는 것은 파괴적 변경입니다.
- [ ] sealed class — 클래스 전체에 봉인을 거는 강한 형태. IL에서
sealed메타데이터 플래그로 표현됩니다. - [ ] sealed override — 특정 메서드의 다형성 체인만 종료시키는 섬세한 형태. IL에서 메서드의
final플래그로 표현됩니다. - [ ] JIT devirtualization — sealed가 있으면 JIT가
callvirt를 직접 호출로 바꾸고 인라이닝까지 시도합니다. 호출부 IL은 그대로지만 머신 코드가 달라집니다. - [ ] struct는 암시적 sealed — 모든 값 타입은 자동으로 sealed이며 박싱만 피하면 항상 devirtualize됩니다.
- [ ] record는 명시적 sealed가 필요 — 데이터 묶음이라면 sealed를 붙여 잎 노드로 잠그는 편이 안전합니다.
- [ ] 모킹 호환성은 인터페이스로 해결 — 구체 클래스를 sealed로 잠그고 인터페이스에 의존하면 sealed와 테스트 가능성을 모두 얻습니다.
- [ ] Unity 컴포넌트도 sealed 가능 — 상속이 불필요한 잎 노드 컴포넌트에는 sealed를 붙여 핫패스 성능을 챙깁니다.
- [ ] virtual은 sealed 클래스에서 의미 없음 — sealed로 바꾸기로 했다면 내부 virtual도 함께 정리합니다.
