반응형

[PART2.변수와 기본 데이터 타입(6/13)] bool과 char — 참·거짓 한 비트와 유니코드 한 조각

true/false의 1바이트 진실 · UTF-16 코드 유닛의 함정 · IL에서는 둘 다 int32


1. 문제 제기 — "문자 하나, 참/거짓 하나" 가 이렇게 복잡할 일인가

Unity 모바일 게임에 채팅을 붙였다고 가정해 보겠습니다. 신입 개발자가 이렇게 코드를 썼습니다.

C#
// 닉네임 10자 제한
if (nickname.Length > 10)
    nickname = nickname.Substring(0, 10);

// 입력 유효 여부
bool isValid = char.IsLetterOrDigit(nickname[0]);

빌드하고 실기기에서 테스트해 보니 두 가지 문제가 터집니다.

  1. 이모지 닉네임이 깨져서 들어갑니다. 유저가 "플레이어😀"를 입력하면 눈으로는 5글자지만 Length는 6입니다(한글 4 + 이모지 서로게이트 페어 2). 10자 제한을 넘는 긴 이모지 닉네임은 자르는 순간 마름모(◇) 모양으로 깨집니다.
  2. 서버에 보낸 bool 플래그 12개가 object[]에 담겨 프로토콜을 타면서 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 알로케이션이 프레임마다 박스(box) 단위로 찍힙니다.

"bool은 참·거짓, char는 문자 하나" — 교과서적으론 그렇습니다. 하지만 실전에서는 다음 질문이 꼬리를 뭅니다.

  • bool이 1비트면 되지 않나요? 왜 1바이트를 잡고, IL(Intermediate Language, CLR이 실행하는 중간 코드)에서는 왜 int32로 나오나요?
  • char가 "문자 하나"라면 이모지 😀는 왜 char 두 개를 잡아먹나요?
  • 'A'"A"는 눈으로 보면 같은데 왜 한쪽은 char이고 한쪽은 string인가요?
  • char.ToUpperchar.ToUpperInvariant는 왜 따로 있나요?

이번 글은 이 네 개 질문에 바이트 수준까지 내려가서 답합니다. Unity 핫패스(Update·OnGUI·입력 루프)에서 bool·char를 다룰 때 박싱과 서로게이트 페어를 피하는 데 필요한 모든 근거를 IL 레벨에서 실측한 뒤 정리합니다.


2. 개념 정의 — bool은 1바이트, char는 2바이트(UTF-16)

한 줄 비유

  • bool = 전등 스위치 — 켜짐(true) / 꺼짐(false) 두 가지 상태만 있습니다. 스위치 자체는 물리적으로 크기를 가져야 해서 메모리 1바이트를 잡지만, 담고 있는 정보는 1비트짜리입니다.
  • char = 유니코드 문자 카탈로그의 번호표 한 장 — 번호표 한 장은 16비트 숫자입니다. 대부분의 문자는 한 장이면 되지만, 이모지처럼 "번호표 한 장으로는 표현 못 하는 글자"는 번호표 두 장을 붙여 씁니다(서로게이트 페어).

구조 시각화

bool — 1바이트 저장, IL 스택에서는 int32

기본 C# 예시 — 'A'"A"는 다른 타입

C#
char  ch   = 'A';   // 2바이트 값 타입, 스택
string str = "A";   // 참조 타입, 힙에 object 헤더 + length + char[] 할당
char   c2  = ch + 0;  // 오케이 — 단, 'A' + 1 의 결과 타입은 int
// char c3 = "A";    // 컴파일 에러 — string은 char가 아님
// string s2 = 'A';  // 컴파일 에러 — char는 string이 아님
'x' vs "x" — 리터럴 따옴표 차이 작은따옴표 ' 'char 리터럴 — 정확히 유니코드 코드 유닛 하나, 값 타입. 큰따옴표 " "string 리터럴 — 문자 배열과 길이·헤더를 포함하는 참조 객체. 길이가 1이어도 내부 구조는 문자열 전체와 동일합니다.
예시: 'A' == 65 (참), "A" == "A" (참, 내부적으로 문자열 비교 연산자 오버로드 호출)

IL 증거 — 'A'ldc.i4.s 65, trueldc.i4.1

BoolBranch·IsUpperA·NewLine·HexA·UnicodeA 메서드를 컴파일한 뒤 ilspycmd로 디컴파일한 실측 결과입니다.

C#
public static int BoolBranch(bool flag)
{
    if (flag) return 1;
    return 0;
}

public static bool IsUpperA(char c) => c == 'A';

public static char NewLine()   => '\n';
public static char HexA()      => '\x41';
public static char UnicodeA()  => 'A';
IL
.method public static int32 BoolBranch(bool flag)
{
    .locals init ([0] bool, [1] int32)
    IL_0001: ldarg.0
    IL_0002: stloc.0
    IL_0003: ldloc.0
    IL_0004: brfalse.s IL_000a   // flag가 0이면 else로 분기 — bool은 스택에서 정수
    IL_0006: ldc.i4.1            // true 리턴 — 정수 1을 스택에 로드
    IL_0007: stloc.1
    IL_0008: br.s     IL_000e
    IL_000a: ldc.i4.0            // false 리턴 — 정수 0을 스택에 로드
    IL_000b: stloc.1
    IL_000e: ldloc.1
    IL_000f: ret
}

.method public static bool IsUpperA(char c)
{
    IL_0001: ldarg.0
    IL_0002: ldc.i4.s 65         // 'A' == 65 — char 리터럴이 정수로 박힘
    IL_0004: ceq                  // 같으면 1, 다르면 0 (스택의 int를 비교)
    IL_0006: stloc.0
    IL_0009: ldloc.0
    IL_000a: ret
}

.method public static char NewLine()
{
    IL_0000: ldc.i4.s 10         // '\n' == 10 — 이스케이프 시퀀스도 그냥 정수
    IL_0002: ret
}

.method public static char HexA()
{
    IL_0000: ldc.i4.s 65         // '\x41' == 65 — \x든 \u든 ASCII 'A'든 IL에서는 동일
    IL_0002: ret
}

.method public static char UnicodeA()
{
    IL_0000: ldc.i4.s 65         // 'A' 와 동일 — 컴파일러가 이미 int로 변환
    IL_0002: ret
}

핵심 관찰:

  • bool 리터럴 → ldc.i4.1 / ldc.i4.0 (32비트 정수 1/0 로드).
  • char 리터럴 → ldc.i4.s 65 (32비트 정수로 스택에 올림. s는 signed byte 축약 형식).
  • 세 메서드(NewLine·HexA·UnicodeA)의 IL이 한 글자도 다르지 않습니다. 이스케이프 시퀀스는 컴파일 타임에 해석되어 같은 정수 상수로 박힙니다. '\x41'을 쓸지 'A'를 쓸지는 가독성 선택이지 성능 차이가 없습니다.
  • 조건 분기는 brfalse(스택 최상단이 0이면 분기) / brtrue(0이 아니면 분기)로 컴파일됩니다. if (flag)실제로는 "flag == 0 이면 else로 가라" 로 뒤집혀 구현됩니다.

3. 내부 동작 — IL 스택은 32비트, 저장소는 실제 크기

스택과 저장소의 분리

CLR(Common Language Runtime, .NET 프로그램을 실행하는 런타임)의 평가 스택(Evaluation Stack)은 32비트 슬롯을 기본 단위로 씁니다. 이유는 x86/x64 CPU 레지스터가 32비트·64비트 정렬에서 가장 빠르기 때문입니다. 그래서 bool(1바이트)도 char(2바이트)도 스택 위에서는 int32로 취급됩니다.

반면 필드나 배열 원소로 저장될 때는 실제 크기를 씁니다. bool은 1바이트, char는 2바이트. 이 분리 때문에 "메모리는 1바이트인데 왜 IL에선 int32냐"는 혼란이 생깁니다.

시각화 — 값 흐름

저장소 (필드·배열·로컬 변수)

박싱이 일어나는 순간

값 타입을 object에 담으면 박싱(boxing)이 일어납니다. 힙에 System.Boolean 또는 System.Char 인스턴스를 새로 할당하고, 스택의 값을 그리로 복사합니다.

C#
public static object BoxBool(bool flag) => flag;

public static int CharPlusOne(char c) => c + 1;  // 비교용 — 박싱 아님
IL
.method public static object BoxBool(bool flag)
{
    IL_0000: ldarg.0
    IL_0001: box [System.Runtime]System.Boolean  // ← 힙 할당 발생
    IL_0006: ret
}

.method public static int32 CharPlusOne(char c)
{
    IL_0000: ldarg.0
    IL_0001: ldc.i4.1
    IL_0002: add          // int 연산 — 결과 타입도 int32
    IL_0003: ret
}

관찰:

  • box는 한 줄짜리 IL이지만 힙 할당 1회 + GC 대상 증가 1회입니다. Unity Update 루프에서 프레임마다 object로 담으면 60fps × 1회 = 초당 60번의 박싱이 쌓입니다.
  • char + 1의 결과 타입은 int입니다. 다시 char로 저장하려면 (char)(c + 1) 캐스트가 필요합니다. C# 컴파일러가 "char는 산술이 가능한 타입"으로 보고 자동으로 int 승격을 걸기 때문입니다.

4. 실전 적용 — Unity 핫패스에서 bool·char 쓰기

4-1. 숫자 카운트 루프 — char.IsDigit 정적 호출

채팅 입력에서 숫자 개수를 세는 함수를 보겠습니다.

C#
public static int CountDigits(string s)
{
    int count = 0;
    foreach (char c in s)
    {
        if (char.IsDigit(c)) count++;
    }
    return count;
}
IL
.method public static int32 CountDigits(string s)
{
    .locals init ([0] int32, [1] string, [2] int32, [3] char, [4] bool, [5] int32)
    IL_0001: ldc.i4.0
    IL_0002: stloc.0                                                        // count = 0
    ...
    // loop start (head: IL_0028)
        IL_000a: ldloc.1
        IL_000b: ldloc.2
        IL_000c: callvirt instance char System.String::get_Chars(int32)    // s[i] → char
        IL_0011: stloc.3
        IL_0013: ldloc.3
        IL_0014: call bool System.Char::IsDigit(char)                       // static 호출 — 박싱 없음
        IL_0019: stloc.s 4
        IL_001b: ldloc.s 4
        IL_001d: brfalse.s IL_0023                                          // false면 증가 스킵
        IL_001f: ldloc.0
        IL_0020: ldc.i4.1
        IL_0021: add
        IL_0022: stloc.0                                                    // count++
        ...
        IL_0028: ldloc.2
        IL_0029: ldloc.1
        IL_002a: callvirt instance int32 System.String::get_Length()
        IL_002f: blt.s IL_000a                                              // i < Length면 반복
}

관찰:

  • char.IsDigitcall(정적 호출)이라 가상 디스패치 비용도, 박싱도 없습니다. System.Char 구조체의 정적 메서드라 직접 점프합니다.
  • foreach (char c in string)get_Chars(int) + get_Length() 조합의 인덱스 루프로 컴파일됩니다. stringIEnumerable<char>도 구현하지만 컴파일러가 인덱스 루프를 특수 선택해 열거자 할당을 피합니다.
  • 이 패턴은 Unity Update 루프에서 실행해도 GC 할당이 0바이트입니다 — 안전합니다.

4-2. 문자열 일치 검사 — char 오버로드 선호

C#
// 나쁜 예 — 문화권 비교 경로로 들어가서 느림
if (nickname.Contains("@")) { /* 이메일 형식이네 */ }

// 좋은 예 — char 오버로드
if (nickname.Contains('@')) { /* 같은 의미, SIMD·정수 비교 경로 */ }
char 오버로드가 왜 더 빠른가 .NET Core 2.1+ 부터 string.Contains(char)·StartsWith(char)·IndexOf(char) 오버로드가 추가되었습니다. char 오버로드는 내부적으로 1문자 탐색을 SIMD 또는 단순 정수 비교로 처리하고, string 오버로드처럼 문화권 비교 옵션을 확인할 필요가 없습니다. Unity 2021+·IL2CPP에서도 대부분 지원됩니다.

4-3. 대소문자 — Invariant vs Culture

C#
public static char UpperCulture(char c)    => char.ToUpper(c);
public static char UpperInvariant(char c)  => char.ToUpperInvariant(c);
IL
.method public static char UpperCulture(char c)
{
    IL_0000: ldarg.0
    IL_0001: call char System.Char::ToUpper(char)            // 현재 스레드 문화권 조회
    IL_0006: ret
}

.method public static char UpperInvariant(char c)
{
    IL_0000: ldarg.0
    IL_0001: call char System.Char::ToUpperInvariant(char)   // 문화권 독립 — 더 빠름
    IL_0006: ret
}

관찰:

  • IL은 한 줄 차이지만 런타임 동작이 다릅니다. ToUpperCultureInfo.CurrentCulture를 조회하고, 튀르키예어 환경에서는 'i''İ'(점 있는 대문자) 변환이 일어납니다.
  • 파일명·ID·프로토콜 문자열 비교라면 반드시 ToUpperInvariant를 씁니다. Unity 빌드가 서버에서 전달한 "player_id"·"channel_name"을 매칭할 때 튀르키예어 기기에서만 조용히 실패하는 버그가 실제로 보고됩니다.

5. 함정과 주의사항

5-1. 이모지 — string.Length는 거짓말

잘못된 패턴 — 서로게이트 페어를 반으로 자릅니다.

C#
public static string TruncateBad(string s, int maxLen)
{
    if (s.Length <= maxLen) return s;
    return s.Substring(0, maxLen);
}
IL
.method public static string TruncateBad(string s, int32 maxLen)
{
    IL_0001: ldarg.0
    IL_0002: callvirt instance int32 System.String::get_Length()   // char 개수 (코드 유닛)
    IL_0007: ldarg.1
    IL_0008: cgt
    IL_000a: ldc.i4.0
    IL_000b: ceq
    IL_000d: stloc.0
    IL_000e: ldloc.0
    IL_000f: brfalse.s IL_0015
    IL_0011: ldarg.0
    IL_0012: stloc.1
    IL_0013: br.s IL_0020
    IL_0015: ldarg.0
    IL_0016: ldc.i4.0
    IL_0017: ldarg.1
    IL_0018: callvirt instance string System.String::Substring(int32, int32)
    IL_001d: stloc.1
    ...
}

"abc😀d"는 사람 눈에 5글자이지만 s.Length는 6(a, b, c, 상위 서로게이트, 하위 서로게이트, d). Substring(0, 4)를 부르면 상위 서로게이트까지만 잘라 이모지 반쪽만 남습니다. TextMeshPro는 이 조각난 문자를 U+FFFD(대체 문자, ◇)로 렌더링합니다.

올바른 패턴StringInfo로 텍스트 요소 단위 자르기.

C#
using System.Globalization;

public static string TruncateGood(string s, int maxLen)
{
    var si = new StringInfo(s);
    if (si.LengthInTextElements <= maxLen) return s;
    return si.SubstringByTextElements(0, maxLen);
}
IL
.method public static string TruncateGood(string s, int32 maxLen)
{
    .locals init ([0] class System.Globalization.StringInfo, [1] bool, [2] string)
    IL_0001: ldarg.0
    IL_0002: newobj instance void System.Globalization.StringInfo::.ctor(string)  // 힙 할당 1회
    IL_0007: stloc.0
    IL_0008: ldloc.0
    IL_0009: callvirt instance int32 System.Globalization.StringInfo::get_LengthInTextElements()
    IL_000e: ldarg.1
    IL_000f: cgt
    ...
    IL_001c: ldloc.0
    IL_001d: ldc.i4.0
    IL_001e: ldarg.1
    IL_001f: callvirt instance string System.Globalization.StringInfo::SubstringByTextElements(int32, int32)
    ...
}

관찰:

  • TruncateGoodnewobjStringInfo 객체를 1개 할당합니다 — Update 루프에서 매 프레임 호출하면 GC 압박이 옵니다. 입력 이벤트(닉네임 변경, 채팅 전송)처럼 빈도가 낮은 곳에서만 쓰거나, .NET Core 3.0+Rune(System.Text.Rune) 열거로 대체합니다.
  • .NET 5+(Unity 2021 LTS+) 환경이라면 string.EnumerateRunes()를 쓰면 StringInfo 할당 없이 서로게이트 페어를 안전하게 처리할 수 있습니다.

5-2. bool 플래그 파라미터 지옥

잘못된 패턴 — 호출부에서 어느 플래그가 뭔지 알 수 없습니다.

C#
public static void AttackBad(bool crit, bool aoe, bool ignoreDef, bool lifesteal)
{
    if (crit) { /* ... */ }
    if (aoe) { /* ... */ }
    if (ignoreDef) { /* ... */ }
    if (lifesteal) { /* ... */ }
}

// 호출부
AttackBad(true, false, true, false);  // 이게 뭔지 읽어서 알 수 없음
IL
.method public static void AttackBad(bool crit, bool aoe, bool ignoreDef, bool lifesteal)
{
    .locals init ([0] bool, [1] bool, [2] bool, [3] bool)
    IL_0001: ldarg.0
    IL_0002: stloc.0
    IL_0003: ldloc.0
    IL_0004: brfalse.s IL_0008
    ...
    IL_0008: ldarg.1
    IL_0009: stloc.1
    IL_000a: ldloc.1
    IL_000b: brfalse.s IL_000f
    ...
    // 같은 패턴 4번 반복 — 분기 4개
}

bool 4개 → brfalse 분기 4개 → CPU 분기 예측 실패 빈도가 올라가고, 의미가 코드에서 사라집니다.

올바른 패턴[Flags] enum으로 묶어 단일 int 비트 연산.

C#
[System.Flags]
public enum AttackFlags
{
    None         = 0,
    Critical     = 1 << 0,
    AoE          = 1 << 1,
    IgnoreDefense = 1 << 2,
    Lifesteal    = 1 << 3,
}

public static void AttackGood(AttackFlags flags)
{
    if ((flags & AttackFlags.Critical) != 0) { /* ... */ }
    // ...
}

// 호출부
AttackGood(AttackFlags.Critical | AttackFlags.IgnoreDefense);  // 의미가 드러남

IL은 brfalse 분기 4개로 여전히 비슷하지만, 호출부 가독성플래그 조합의 수학적 표현(and/or)을 얻습니다. Unity 인스펙터에서도 [Flags] enum은 체크박스로 편하게 편집됩니다.

5-3. bool을 object에 담기 — 박싱 함정

Unity의 SendMessage·AnalyticsEvent·Dictionary<string, object> 같은 API는 파라미터를 object로 받습니다. bool·char를 그대로 넣으면 매번 박싱입니다.

C#
// ❌ 호출마다 box 발생
var dict = new Dictionary<string, object>();
dict["hasDiamond"] = true;        // box System.Boolean → 힙 할당 1회
dict["initial"]    = 'A';          // box System.Char → 힙 할당 1회

// ✅ 대안 — bool은 int로, char는 int 또는 string으로 미리 변환해 전용 컬렉션 사용
var flags = new Dictionary<string, int>();
flags["hasDiamond"] = 1;

5-4. '\xABC' 의 탐욕적 파싱

\x 이스케이프는 가변 길이(1~4자리 16진수)입니다. 파서가 뒤 문자까지 hex로 인식 가능하면 먹어버립니다.

C#
string s1 = "\xABC";      // U+0ABC 한 글자 (3자리 전부 hex로 해석)
string s2 = "\xABG";      // U+00AB 한 글자 + 'G' (G는 hex 아님 → 2자리에서 멈춤)
string s3 = "«C";    // U+00AB 한 글자 + 'C' — \u는 항상 4자리 고정이라 안전

이 때문에 문자열 리터럴에서는 \x 대신 \u(4자리 고정)를 쓰는 것이 안전합니다. \xchar 리터럴('\x41')처럼 구분이 명확한 곳에서만 씁니다.


6. C# 버전별 변화

C# 1.0 (2002)

  • bool·char·기본 이스케이프 시퀀스.
  • char.IsDigit·char.IsLetter 등 정적 헬퍼.

C# 2.0 — nullable

  • bool?(Nullable&lt;bool&gt;), char? 등장. DB·UI 입력 대응.

C# 6.0 (2015) — 문자열 보간과 nameof

  • $"..."char 섞기 자연스러워짐. 내부적으로 string.Formatstring.Concat 최적화 경로.

C# 7.0 (2017) — 패턴 매칭

  • switch의 case label에 char 직접 사용 가능, is bool b 패턴으로 박싱 없이 추출.
C#
// 과거
if (obj is bool) { bool b = (bool)obj; /* unbox */ }

// C# 7.0+
if (obj is bool b) { /* b 사용 — 컴파일러가 단일 unbox.any로 처리 */ }
IL
// C# 7.0+ 패턴 매칭의 IL (개념 — 실제 코드는 위에서 실측한 CountDigits 참조)
ldloc.0
isinst [System.Runtime]System.Boolean
dup
brfalse.s ...
unbox.any [System.Runtime]System.Boolean

isinst + unbox.any 조합이 기존의 is + 캐스트 2단계보다 IL이 짧아졌습니다.

.NET Core 2.1 (2018) / C# 7.2

  • string.Contains(char)·StartsWith(char)·IndexOf(char) 오버로드 추가. string 오버로드 대비 측정상 2~5배 빠름(MSFT 블로그 벤치마크 기준).

.NET Core 3.0 (2019) / C# 8.0

  • System.Text.Rune 도입. 32비트 유니코드 스칼라 값을 1개의 값 타입으로 안전히 표현. 서로게이트 페어 문제 해결.
C#
// C# 8.0+ — Rune 열거
foreach (var rune in "abc😀d".EnumerateRunes())
{
    Console.WriteLine(rune.Value);  // 0x61, 0x62, 0x63, 0x1F600, 0x64
}

Unity 2021 LTS 이상(.NET Standard 2.1 프로파일)에서 사용 가능합니다.

C# 11 (2022) — raw string literal

  • """...""" 로 이스케이프 해방. 정규식·JSON 문자열에서 \·" 지옥 해소. char와 직접 관련은 없지만 문자열과 혼용 시 실수 감소.

7. 정리

이 글에서 확인한 것들입니다.

  1. bool은 1바이트 저장 · IL 스택에서는 int32. true/falseldc.i4.1 / ldc.i4.0으로 컴파일됩니다.
  2. char는 UTF-16 코드 유닛 2바이트. BMP 바깥 문자(이모지 등)는 서로게이트 페어로 char 두 개를 차지합니다.
  3. 'A'char(값, 스택), "A"string(참조, 힙). 리터럴 문법이 그대로 타입 결정자입니다.
  4. 이스케이프 시퀀스는 컴파일 타임에 정수로 접힙니다. '\n'·'\x41'·'A' 모두 동일한 IL을 만듭니다. 단, 문자열 리터럴에서 \x는 탐욕적 파싱 때문에 \u 4자리 고정이 안전합니다.
  5. 조건 분기는 brfalse·brtrue로 뒤집혀 컴파일됩니다. if (flag)는 "flag == 0이면 else로 가라"입니다.
  6. 박싱을 피하세요. objectbool·char를 담는 순간 box IL이 발생하고 힙에 인스턴스가 찍힙니다.
  7. Unity에서 조심할 것들:
    • string.Length로 이모지 닉네임 자르기 금지 → StringInfo 또는 EnumerateRunes.
    • char.ToUpper 대신 char.ToUpperInvariant를 기본 선택(파일명·ID 비교).
    • Contains("x") 대신 Contains('x') — char 오버로드가 더 빠릅니다.
    • bool 플래그가 3개 이상이면 [Flags] enum으로 묶어 가독성과 인스펙터 표현을 얻으세요.
  8. char + 1의 결과 타입은 int. 다시 char에 넣으려면 명시적 캐스트((char)(c + 1))가 필요합니다.
이것만 기억하세요 bool·char는 작아 보이지만, object로 담는 순간 박싱이고, char는 "한 글자"가 아니라 "한 코드 유닛"입니다. Unity 핫패스에서 이 두 개만 지키면 대부분의 GC 스파이크와 이모지 깨짐은 막을 수 있습니다.
반응형

+ Recent posts