[PART2.변수와 기본 데이터 타입(6/13)] bool과 char — 참·거짓 한 비트와 유니코드 한 조각
true/false의 1바이트 진실 · UTF-16 코드 유닛의 함정 · IL에서는 둘 다 int32
목차
1. 문제 제기 — "문자 하나, 참/거짓 하나" 가 이렇게 복잡할 일인가
Unity 모바일 게임에 채팅을 붙였다고 가정해 보겠습니다. 신입 개발자가 이렇게 코드를 썼습니다.
// 닉네임 10자 제한
if (nickname.Length > 10)
nickname = nickname.Substring(0, 10);
// 입력 유효 여부
bool isValid = char.IsLetterOrDigit(nickname[0]);
빌드하고 실기기에서 테스트해 보니 두 가지 문제가 터집니다.
- 이모지 닉네임이 깨져서 들어갑니다. 유저가 "플레이어😀"를 입력하면 눈으로는 5글자지만
Length는 6입니다(한글 4 + 이모지 서로게이트 페어 2). 10자 제한을 넘는 긴 이모지 닉네임은 자르는 순간 마름모(◇) 모양으로 깨집니다. - 서버에 보낸
bool플래그 12개가object[]에 담겨 프로토콜을 타면서 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 알로케이션이 프레임마다 박스(box) 단위로 찍힙니다.
"bool은 참·거짓, char는 문자 하나" — 교과서적으론 그렇습니다. 하지만 실전에서는 다음 질문이 꼬리를 뭅니다.
bool이 1비트면 되지 않나요? 왜 1바이트를 잡고, IL(Intermediate Language, CLR이 실행하는 중간 코드)에서는 왜int32로 나오나요?char가 "문자 하나"라면 이모지 😀는 왜char두 개를 잡아먹나요?'A'와"A"는 눈으로 보면 같은데 왜 한쪽은char이고 한쪽은string인가요?char.ToUpper와char.ToUpperInvariant는 왜 따로 있나요?
이번 글은 이 네 개 질문에 바이트 수준까지 내려가서 답합니다. Unity 핫패스(Update·OnGUI·입력 루프)에서 bool·char를 다룰 때 박싱과 서로게이트 페어를 피하는 데 필요한 모든 근거를 IL 레벨에서 실측한 뒤 정리합니다.
2. 개념 정의 — bool은 1바이트, char는 2바이트(UTF-16)
한 줄 비유
bool= 전등 스위치 — 켜짐(true) / 꺼짐(false) 두 가지 상태만 있습니다. 스위치 자체는 물리적으로 크기를 가져야 해서 메모리 1바이트를 잡지만, 담고 있는 정보는 1비트짜리입니다.char= 유니코드 문자 카탈로그의 번호표 한 장 — 번호표 한 장은 16비트 숫자입니다. 대부분의 문자는 한 장이면 되지만, 이모지처럼 "번호표 한 장으로는 표현 못 하는 글자"는 번호표 두 장을 붙여 씁니다(서로게이트 페어).
구조 시각화

기본 C# 예시 — 'A'와 "A"는 다른 타입
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, true는 ldc.i4.1
위 BoolBranch·IsUpperA·NewLine·HexA·UnicodeA 메서드를 컴파일한 뒤 ilspycmd로 디컴파일한 실측 결과입니다.
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';
.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 인스턴스를 새로 할당하고, 스택의 값을 그리로 복사합니다.
public static object BoxBool(bool flag) => flag;
public static int CharPlusOne(char c) => c + 1; // 비교용 — 박싱 아님
.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 정적 호출
채팅 입력에서 숫자 개수를 세는 함수를 보겠습니다.
public static int CountDigits(string s)
{
int count = 0;
foreach (char c in s)
{
if (char.IsDigit(c)) count++;
}
return count;
}
.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.IsDigit은call(정적 호출)이라 가상 디스패치 비용도, 박싱도 없습니다.System.Char구조체의 정적 메서드라 직접 점프합니다.foreach (char c in string)은get_Chars(int)+get_Length()조합의 인덱스 루프로 컴파일됩니다.string은IEnumerable<char>도 구현하지만 컴파일러가 인덱스 루프를 특수 선택해 열거자 할당을 피합니다.- 이 패턴은 Unity Update 루프에서 실행해도 GC 할당이 0바이트입니다 — 안전합니다.
4-2. 문자열 일치 검사 — char 오버로드 선호
// 나쁜 예 — 문화권 비교 경로로 들어가서 느림
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
public static char UpperCulture(char c) => char.ToUpper(c);
public static char UpperInvariant(char c) => char.ToUpperInvariant(c);
.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은 한 줄 차이지만 런타임 동작이 다릅니다.
ToUpper는CultureInfo.CurrentCulture를 조회하고, 튀르키예어 환경에서는'i'→'İ'(점 있는 대문자) 변환이 일어납니다. - 파일명·ID·프로토콜 문자열 비교라면 반드시
ToUpperInvariant를 씁니다. Unity 빌드가 서버에서 전달한 "player_id"·"channel_name"을 매칭할 때 튀르키예어 기기에서만 조용히 실패하는 버그가 실제로 보고됩니다.
5. 함정과 주의사항
5-1. 이모지 — string.Length는 거짓말
❌ 잘못된 패턴 — 서로게이트 페어를 반으로 자릅니다.
public static string TruncateBad(string s, int maxLen)
{
if (s.Length <= maxLen) return s;
return s.Substring(0, maxLen);
}
.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로 텍스트 요소 단위 자르기.
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);
}
.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)
...
}
관찰:
TruncateGood은newobj로StringInfo객체를 1개 할당합니다 — Update 루프에서 매 프레임 호출하면 GC 압박이 옵니다. 입력 이벤트(닉네임 변경, 채팅 전송)처럼 빈도가 낮은 곳에서만 쓰거나,.NET Core 3.0+의Rune(System.Text.Rune) 열거로 대체합니다..NET 5+(Unity 2021 LTS+) 환경이라면string.EnumerateRunes()를 쓰면StringInfo할당 없이 서로게이트 페어를 안전하게 처리할 수 있습니다.
5-2. bool 플래그 파라미터 지옥
❌ 잘못된 패턴 — 호출부에서 어느 플래그가 뭔지 알 수 없습니다.
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); // 이게 뭔지 읽어서 알 수 없음
.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 비트 연산.
[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를 그대로 넣으면 매번 박싱입니다.
// ❌ 호출마다 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로 인식 가능하면 먹어버립니다.
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자리 고정)를 쓰는 것이 안전합니다. \x는 char 리터럴('\x41')처럼 구분이 명확한 곳에서만 씁니다.
6. C# 버전별 변화
C# 1.0 (2002)
bool·char·기본 이스케이프 시퀀스.char.IsDigit·char.IsLetter등 정적 헬퍼.
C# 2.0 — nullable
bool?(Nullable<bool>),char?등장. DB·UI 입력 대응.
C# 6.0 (2015) — 문자열 보간과 nameof
$"..."에char섞기 자연스러워짐. 내부적으로string.Format→string.Concat최적화 경로.
C# 7.0 (2017) — 패턴 매칭
switch의 case label에char직접 사용 가능,is bool b패턴으로 박싱 없이 추출.
// 과거
if (obj is bool) { bool b = (bool)obj; /* unbox */ }
// C# 7.0+
if (obj is bool b) { /* b 사용 — 컴파일러가 단일 unbox.any로 처리 */ }
// 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# 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. 정리
이 글에서 확인한 것들입니다.
bool은 1바이트 저장 · IL 스택에서는 int32.true/false는ldc.i4.1/ldc.i4.0으로 컴파일됩니다.char는 UTF-16 코드 유닛 2바이트. BMP 바깥 문자(이모지 등)는 서로게이트 페어로char두 개를 차지합니다.'A'는char(값, 스택),"A"는string(참조, 힙). 리터럴 문법이 그대로 타입 결정자입니다.- 이스케이프 시퀀스는 컴파일 타임에 정수로 접힙니다.
'\n'·'\x41'·'A'모두 동일한 IL을 만듭니다. 단, 문자열 리터럴에서\x는 탐욕적 파싱 때문에\u4자리 고정이 안전합니다. - 조건 분기는
brfalse·brtrue로 뒤집혀 컴파일됩니다.if (flag)는 "flag == 0이면 else로 가라"입니다. - 박싱을 피하세요.
object에bool·char를 담는 순간boxIL이 발생하고 힙에 인스턴스가 찍힙니다. - Unity에서 조심할 것들:
string.Length로 이모지 닉네임 자르기 금지 →StringInfo또는EnumerateRunes.char.ToUpper대신char.ToUpperInvariant를 기본 선택(파일명·ID 비교).Contains("x")대신Contains('x')— char 오버로드가 더 빠릅니다.- bool 플래그가 3개 이상이면
[Flags] enum으로 묶어 가독성과 인스펙터 표현을 얻으세요.
char + 1의 결과 타입은int. 다시char에 넣으려면 명시적 캐스트((char)(c + 1))가 필요합니다.
이것만 기억하세요bool·char는 작아 보이지만, object로 담는 순간 박싱이고,char는 "한 글자"가 아니라 "한 코드 유닛"입니다. Unity 핫패스에서 이 두 개만 지키면 대부분의 GC 스파이크와 이모지 깨짐은 막을 수 있습니다.
'C# 기초' 카테고리의 다른 글
| [PART2.변수와 기본 데이터 타입(8/13)] var — 타입 추론의 기본 사용법 (1) | 2026.04.24 |
|---|---|
| [PART2.변수와 기본 데이터 타입(7/13)] string — 문자의 묶음 (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(5/13)] 실수형 — float · double · decimal (1) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(4/13)] 네이티브 정수 — nint · nuint (C# 9 / 11에서 키워드 승격) (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(3/13)] 숫자 리터럴 표기법 — 2진수·16진수·구분자 (C# 7) (1) | 2026.04.24 |