Sealed 클래스란
C#에는 더 이상 상속할 수 없음을 나타내는 sealed 키워드가 있다.
프레임워크처럼 다른 개발자가 내 클래스를 사용할 때 잘못된 확장을 막기 위해 sealed를 붙여 상속을 차단할 수 있다.
다만 .NET 디자인 지침에서는 확장성을 위해 굳이 sealed를 남발하지 말고
문제가 생길 위험이 있거나 값(value)처럼 다루는 타입에만 제한적으로 사용하라고 권장한다.
.NET 컴파일 최적화
.NET 컴파일러에는 여러 최적화가 들어가는데, 이 중 일부는 클래스가 sealed일 때만 적용된다.
원래 가상 메서드를 호출하면 가상 메서드 테이블(vtable)을 조회해 실제 타입의 메서드를 찾는다.
하지만 sealed 클래스라면 "더 이상 파생 타입이 없다는 것이 확실"하므로, 컴파일러는 vtable 조회를 건너뛰고 바로 메서드를 호출할 수 있다.
배열 공변성 검사 등 추가 타입 검사가 필요한 경우에도 sealed 클래스는 검사를 생략해 성능이 조금 더 좋아진다.
.NET 6 벤치마크
Performance benefits of sealed class in .NET - Meziantou's blog 에서 .NET 6으로 측정한 결과는 아래와 같았다.
sealed를 붙인 쪽이 훨씬 빠른 것을 확인할 수 있다.
Method | Mean | Error | StdDev |
---|---|---|---|
NonSealed | 0.4465 ns | 0.0276 ns | 0.0258 ns |
Sealed | 0.0107 ns | 0.0160 ns | 0.0150 ns |
"그냥 컴파일러가 알아서 최적화 해주면 안 돼?"
.NET 프로그램은 먼저 IL(중간 언어)로 컴파일되고, 실행 시 JIT가 기계어로 번역한다.
JIT 입장에서는 이 클래스가 진짜로 상속이 없는지 전체 코드를 다 보지 않으면 확신할 수 없다.
따라서 vtable을 완전히 생략한 코드를 만들기 어렵다.
해결 방법
1안 - AOT 사용
AOT(Ahead-of-Time) 컴파일은 실행 전에 전체 코드를 한 번에 기계어로 변환한다.
이때 컴파일러가 모든 타입 관계를 알 수 있으므로 상속이 없는 클래스를 과감하게 최적화한다.
단, AOT 배포는 제한 사항이 있어 모든 프로젝트에서 쓸 수 있는 것은 아니다.
2안 - PGO 사용
.NET 8부터 PGO(Profile-Guided Optimization)가 기본 활성화됐다.
실행 중에 프로파일링을 진행하여 실시간으로 최적화된 코드를 컴파일하는 방식이다.
PGO가 하는 최적화 중에는 Devirtualization도 있다.
이를 통해 더 이상 상속이 없을 것이라고 생각되는 가상 호출을 직접 호출로 바꾼다.
가상화가 해제되면 vtable 조회가 사라지고 인라인 기회도 늘어나 성능이 향상된다.
다만 더 이상 상속이 없다는 것은 확신할 수 없으므로 Guarded Devirtualization와 같은 런타임 타입 검사를 보호 장치로 사용한다.
따라서 직접 호출만큼 빠르지는 않지만 그래도 가상 호출보다는 빠른 코드를 생성할 수 있게 된다.
- runtime/docs/design/coreclr/jit/GuardedDevirtualization.md at main · dotnet/runtime · GitHub
- Dynamic PGO | .NET Conf 2023 - YouTube
.NET 6 vs .NET 10 벤치마크
아래는 동일한 코드로 .NET 6과 최신 .NET 10에서 측정한 결과다.
.NET 10에서는 NonSealed의 가상 메서드 호출도 꽤 빨라졌지만,
호출이 잦은 클래스라면 여전히 직접 sealed를 설정해주는 게 이득일 수 있다.
Method | Runtime | Mean | Error | StdDev |
---|---|---|---|---|
NonSealed | .NET 6.0 | 115.06 us | 2.299 us | 3.369 us |
Sealed | .NET 6.0 | 28.83 us | 0.549 us | 0.513 us |
NonSealed | .NET 10.0 | 25.44 us | 0.503 us | 0.826 us |
Sealed | .NET 10.0 | 23.52 us | 0.415 us | 0.368 us |
[SimpleJob(RuntimeMoniker.Net60)]
[SimpleJob(RuntimeMoniker.Net10_0)]
public class SealedCall
{
readonly NonSealedType nonSealedType = new();
readonly SealedType sealedType = new();
const int TestNum = 100000;
[Benchmark]
public long NonSealed()
{
nonSealedType.num = 0;
for (int i = 0; i < TestNum; i++)
{
nonSealedType.Method();
}
return nonSealedType.num;
}
[Benchmark]
public long Sealed()
{
sealedType.num = 0;
for (int i = 0; i < TestNum; i++)
{
sealedType.Method();
}
return sealedType.num;
}
}
internal class BaseType
{
public long num = 0;
public virtual void Method() { }
}
internal class NonSealedType : BaseType
{
public override void Method() => num++;
}
internal sealed class SealedType : BaseType
{
public override void Method() => num++;
}
정리
- sealed는 vtable 조회·타입 검사 제거 등으로 성능에 도움을 준다.
- PGO 덕분에 비 sealed 클래스도 빨라졌지만, 가상 메서드 호출이 빈번할 경우 여전히 sealed 키워드 사용을 고려할 만하다.