← 문제로

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 값 타입은 이를 상속하는 값 타입. HasFlagEnum 매개변수를 받는다는 것 자체가 "값 타입 인스턴스를 참조 타입 매개변수에 전달" 하는 상황이라 필연적으로 박싱을 유발한다. 이는 object 매개변수에 값 타입을 넘길 때와 동일한 메커니즘이다.
  • 밑바탕 타입(underlying type). enum 은 기본적으로 int 를 밑바탕 타입으로 쓰지만 enum StatusFlags : byte 처럼 byte/sbyte/short/ushort/int/uint/long/ulong 으로 바꿀 수 있다. 플래그 개수가 적어 1바이트로 충분하면 배열/대량 저장 시 메모리를 아낄 수 있지만, 대부분의 CPU 레지스터/정렬 특성상 실행 성능 차이는 미미하고 주로 메모리 풋프린트(수백만 개 저장 시) 관점에서 의미가 있다.
  • HasFlag 의 내부 구현은 "같은 밑바탕 타입인지 확인 후 값 비교" 로, 단순 산술 연산이 아니라 런타임에 Enum 값들의 타입 호환성을 검사하는 좀 더 일반적인(그래서 느린) 경로를 탄다. 이 일반성(어떤 enum 이든 동작해야 함) 때문에 컴파일 시점에 인라인 정수 연산으로 치환되지 못한다.

단계별 내부 동작

  1. status.HasFlag(StatusFlags.Stunned) 호출 시점에 컴파일러는 status(값 타입)를 Enum 타입 인스턴스 메서드 호출의 리시버로 쓰기 위해 박싱한다(힙에 타입 정보 + 값을 담은 객체 생성, status 는 이제 그 객체를 가리키는 참조로 취급됨).
  2. 인자 StatusFlags.StunnedHasFlag(Enum flag)Enum 매개변수에 맞추기 위해 박싱된다.
  3. HasFlag 내부는 (개략적으로) 두 Enum 객체의 밑바탕 타입이 같은지 확인하고, 언박싱한 정수 값끼리 (thisValue & flagValue) == flagValue 에 준하는 비교를 수행한다 — 결과 자체는 비트 AND 와 같지만, 그 앞뒤로 타입 검사·박싱·언박싱 비용이 추가된다.
  4. (status & StatusFlags.Stunned) == StatusFlags.Stunned 는 컴파일러가 enum 값을 밑바탕 정수 타입으로 취급해 IL 상에서 and/ceq 같은 정수 연산으로 직접 컴파일한다 — 박싱 대상이 되는 매개변수 타입이 애초에 없으므로 할당이 발생하지 않는다.
  5. CountStunned 반복문에서 useHasFlag=true 경로를 타면 매 반복마다 1~2단계가 반복되어 할당이 누적 → GC 가 더 자주 Gen0 을 수거해야 한다. useHasFlag=false 경로는 4단계만 반복되어 할당이 없다.

흔한 오해·함정

  • "HasFlag 가 더 읽기 좋으니 항상 써도 된다" ✗ — 가독성과 성능은 트레이드오프다. 드물게 호출되는 코드(설정 파싱, 로그 등)에서는 무해하지만, 매 틱/매 패킷처럼 자주 호출되는 핫 패스에서는 박싱 누적이 실측 가능한 GC 오버헤드로 이어진다.
  • "릴리스 빌드면 JIT 이 박싱을 없애준다" ✗ — 이 박싱은 HasFlag 의 시그니처(Enum 매개변수)에 근본적으로 내재된 것이라, JIT 최적화로 제거되지 않는다. 없애려면 코드를 바꿔 애초에 박싱을 유발하지 않는 경로(비트 연산)를 써야 한다.
  • "enum 은 참조 타입이라 항상 힙에 있다" ✗ — 정반대다. enum 은 값 타입이며 기본적으로 스택/인라인 필드에 저장된다. 힙에 올라가는 건 Enum(또는 object) 으로 박싱될 때만 이다.
  • "밑바탕 타입을 byte 로 줄이면 HasFlag 도 빨라진다" ✗ — 밑바탕 타입 변경은 메모리 풋프린트에는 영향을 주지만, HasFlag 의 박싱·타입 검사 오버헤드 자체는 밑바탕 타입과 무관하게 그대로 남는다.

면접 포인트 (예상 질문)

  1. Enum.HasFlag 를 호출할 때 왜 박싱이 일어나는가? 몇 번 일어나는가?
  2. 비트 AND 방식과 HasFlag 방식이 논리적으로 같은 결과를 내면서 성능이 다른 이유는 무엇인가?
  3. 매 틱 수만 엔티티를 검사하는 게임서버 핫 패스에서 어떤 방식을 선택해야 하며, 그 근거는 GC 관점에서 무엇인가?
  4. enum 의 밑바탕 타입을 바꾸는 것이 이 성능 문제와 무관한 이유는?