23. [Flags] enum 의 내부 표현과 Enum.HasFlag 의 박싱 비용
난이도 중내 리뷰 · C#
해설 · C#
해설 — [Flags] enum 의 내부 표현과 Enum.HasFlag 의 박싱 비용
난이도: 중상
답변 프레임워크: 요약 → 핵심 개념 → 단계별 내부 동작 → 흔한 오해·함정 → 면접 포인트
요약
enum 은 CLR 상 정수 기반 값 타입(값의 실체는 그냥 int 등) 이지만, Enum 클래스는
System.ValueType 을 상속하는 참조 타입 추상 기반 클래스다. HasFlag(Enum flag) 의
매개변수 타입이 Enum 이므로, status.HasFlag(StatusFlags.Stunned) 를 호출할 때마다
리시버(status)와 인자(StatusFlags.Stunned) 둘 다 Enum 으로 박싱되어 힙에 할당
된다. 게다가 HasFlag 내부는 리플렉션에 준하는 방식으로 밑바탕 타입을 확인하고 값을
비교하므로 단순 비트 AND 보다 훨씬 느리다. 반면 (status & StatusFlags.Stunned) == StatusFlags.Stunned 는 컴파일러가 enum 을 그 밑바탕 정수 타입으로 다뤄 박싱 없이 순수
정수 AND/비교 로 컴파일된다 — 값 타입 그대로 스택/레지스터에서 처리된다. 매 틱 수만
엔티티를 검사하는 핫 패스에서 HasFlag 를 쓰면 틱마다 수만 개의 단명 객체가 힙에 쌓여
GC(특히 Gen0) 압박이 커진다. 이 오버헤드는 HasFlag 의 구현 자체(런타임 타입 비교 +
박싱)에서 오는 것이라 릴리스 빌드 최적화로도 제거되지 않는다.
예측
IsStunnedViaHasFlag호출마다status박싱 1회,StatusFlags.Stunned리터럴 박싱 1회 — 호출당 최소 2회의 힙 할당이 발생한다(오버로드/런타임 버전에 따라 최적화 정도 차이는 있으나 근본적으로 박싱 소요는 남는다).IsStunnedViaBitwise는 힙 할당이 전혀 없다 — 정수 비교 몇 개로 인라인될 수 있는 수준.CountStunned(allEntities, useHasFlag: true)를 수만 엔티티에 대해 매 틱 호출하면 틱마다 수만 건의 단명 박싱 객체가 생성 → Gen0 GC 빈도가 눈에 띄게 증가한다.useHasFlag: false버전은 이런 할당이 없어 GC 압박이 사실상 0에 가깝다.- 두 방식의 논리적 결과(반환값)는 동일하다 — 차이는 오직 성능/할당 특성이다.
핵심 개념
- enum 의 실체는 정수.
StatusFlags값은 메모리상 그냥 4바이트 정수(기본 밑바탕 타입int)이며, 비트 OR 로 여러 플래그를 합친 값도 그냥 정수 값일 뿐이다. Enum은 참조 타입(추상 클래스), enum 값 타입은 이를 상속하는 값 타입.HasFlag가Enum매개변수를 받는다는 것 자체가 "값 타입 인스턴스를 참조 타입 매개변수에 전달" 하는 상황이라 필연적으로 박싱을 유발한다. 이는object매개변수에 값 타입을 넘길 때와 동일한 메커니즘이다.- 밑바탕 타입(underlying type).
enum은 기본적으로int를 밑바탕 타입으로 쓰지만enum StatusFlags : byte처럼byte/sbyte/short/ushort/int/uint/long/ulong으로 바꿀 수 있다. 플래그 개수가 적어 1바이트로 충분하면 배열/대량 저장 시 메모리를 아낄 수 있지만, 대부분의 CPU 레지스터/정렬 특성상 실행 성능 차이는 미미하고 주로 메모리 풋프린트(수백만 개 저장 시) 관점에서 의미가 있다. - HasFlag 의 내부 구현은 "같은 밑바탕 타입인지 확인 후 값 비교" 로, 단순 산술 연산이 아니라 런타임에 Enum 값들의 타입 호환성을 검사하는 좀 더 일반적인(그래서 느린) 경로를 탄다. 이 일반성(어떤 enum 이든 동작해야 함) 때문에 컴파일 시점에 인라인 정수 연산으로 치환되지 못한다.
단계별 내부 동작
status.HasFlag(StatusFlags.Stunned)호출 시점에 컴파일러는status(값 타입)를Enum타입 인스턴스 메서드 호출의 리시버로 쓰기 위해 박싱한다(힙에 타입 정보 + 값을 담은 객체 생성,status는 이제 그 객체를 가리키는 참조로 취급됨).- 인자
StatusFlags.Stunned도HasFlag(Enum flag)의Enum매개변수에 맞추기 위해 박싱된다. HasFlag내부는 (개략적으로) 두Enum객체의 밑바탕 타입이 같은지 확인하고, 언박싱한 정수 값끼리(thisValue & flagValue) == flagValue에 준하는 비교를 수행한다 — 결과 자체는 비트 AND 와 같지만, 그 앞뒤로 타입 검사·박싱·언박싱 비용이 추가된다.(status & StatusFlags.Stunned) == StatusFlags.Stunned는 컴파일러가 enum 값을 밑바탕 정수 타입으로 취급해 IL 상에서and/ceq같은 정수 연산으로 직접 컴파일한다 — 박싱 대상이 되는 매개변수 타입이 애초에 없으므로 할당이 발생하지 않는다.CountStunned반복문에서useHasFlag=true경로를 타면 매 반복마다 1~2단계가 반복되어 할당이 누적 → GC 가 더 자주 Gen0 을 수거해야 한다.useHasFlag=false경로는 4단계만 반복되어 할당이 없다.
흔한 오해·함정
- "HasFlag 가 더 읽기 좋으니 항상 써도 된다" ✗ — 가독성과 성능은 트레이드오프다. 드물게 호출되는 코드(설정 파싱, 로그 등)에서는 무해하지만, 매 틱/매 패킷처럼 자주 호출되는 핫 패스에서는 박싱 누적이 실측 가능한 GC 오버헤드로 이어진다.
- "릴리스 빌드면 JIT 이 박싱을 없애준다" ✗ — 이 박싱은
HasFlag의 시그니처(Enum매개변수)에 근본적으로 내재된 것이라, JIT 최적화로 제거되지 않는다. 없애려면 코드를 바꿔 애초에 박싱을 유발하지 않는 경로(비트 연산)를 써야 한다. - "enum 은 참조 타입이라 항상 힙에 있다" ✗ — 정반대다. enum 은 값 타입이며 기본적으로
스택/인라인 필드에 저장된다. 힙에 올라가는 건
Enum(또는object) 으로 박싱될 때만 이다. - "밑바탕 타입을 byte 로 줄이면 HasFlag 도 빨라진다" ✗ — 밑바탕 타입 변경은 메모리
풋프린트에는 영향을 주지만,
HasFlag의 박싱·타입 검사 오버헤드 자체는 밑바탕 타입과 무관하게 그대로 남는다.
면접 포인트 (예상 질문)
Enum.HasFlag를 호출할 때 왜 박싱이 일어나는가? 몇 번 일어나는가?- 비트 AND 방식과 HasFlag 방식이 논리적으로 같은 결과를 내면서 성능이 다른 이유는 무엇인가?
- 매 틱 수만 엔티티를 검사하는 게임서버 핫 패스에서 어떤 방식을 선택해야 하며, 그 근거는 GC 관점에서 무엇인가?
- enum 의 밑바탕 타입을 바꾸는 것이 이 성능 문제와 무관한 이유는?