.NET Sealed 클래스 성능 고려사항

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와 같은 런타임 타입 검사를 보호 장치로 사용한다.
따라서 직접 호출만큼 빠르지는 않지만 그래도 가상 호출보다는 빠른 코드를 생성할 수 있게 된다.

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 키워드 사용을 고려할 만하다.