Sealed 클래스란
C#에는 더 이상 상속할 수 없음을 나타내는 sealed 키워드가 있다.
프레임워크를 개발하거나 할 때 sealed를 붙여서 상속을 금지해, 이를 사용하는 개발자가 잘못된 확장하는 것을 막을 수 있다.
다만 .NET 디자인 지침에서는 확장성을 위해 sealed를 남발하지 말고
문제가 생길 위험이 있거나 값(value)처럼 다루는 타입에만 제한적으로 사용하라고 권장하고 있다.
.NET 컴파일 최적화
.NET 컴파일러는 다양한 최적화를 진행하는데, 그 중 일부는 클래스가 sealed일 때만 적용된다.
원래대로라면 가상 메서드를 호출할 때 가상 메서드 테이블(vtable)을 조회해 실제 타입의 메서드를 찾아야한다.
하지만 sealed 클래스라면 "더 이상 파생 타입이 없다는 것이 확실"하므로, 컴파일러는 vtable 조회를 건너뛰고 바로 메서드를 호출할 수 있다.
가상 메서드 호출 뿐 아니라 배열 공변성 검사와 같이 추가적인 타입 검사가 필요한 경우에도
sealed 클래스는 더 이상 상속이 없다는 것이 확실하기 때문에 생략 가능한 처리들이 있어 성능이 조금 더 좋아진다.
.NET 6 벤치마크
아래 블로그 글에서 .NET 6으로 sealed에 대한 프로파일링 결과를 확인해볼 수 있다.
Performance benefits of sealed class in .NET - Meziantou's blog
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 입장에서는 이 클래스가 진짜로 상속이 없는지 전체 코드를 다 볼 수 없기 때문에 확신할 수 없고
따라서 런타임에 완벽히 최적화는 불가능하다.
최적화 방법
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
3안 - sealed 사용
Performance benefits of sealed class in .NET - Meziantou's blog
위 블로그 글에서 제안했듯이 Meziantou.Analyzer 패키지를 사용하면 sealed 할 수 있는 클래스를 쉽게 찾을 수 있다.
이를 통해 자주 가상함수가 핫 패스에서 자주 호출될 것으로 기대되는 클래스를 sealed 처리할 수 있다.
.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 키워드 사용을 고려할 만하다.