[PART2.변수와 기본 데이터 타입(2/13)] 정수형 — int · long · short · byte
32비트의 벽 / 오버플로가 감겨버리는 이유 / byte와 short가 IL에서 int로 확장되는 까닭 / Unity 모바일에서 타입 하나로 메모리 MB를 절약하는 방법
목차
1. 문제 제기 — 정수 하나 잘못 골라서 서버가 멈춘다
"정수면 다 int 아닌가요?"
입문 단계에서는 자주 나오는 질문입니다. 하지만 실제로 현장에서는 이 한 글자(int → long) 차이로 서비스가 멈추고, 저장 용량이 4배로 늘고, 모바일 게임이 힙을 통째로 날려 버리기도 합니다. 대표적인 사례 세 가지입니다.
- 게임 출시 25일째 프레임이 튀는 현상 — 서버 루프에서
Environment.TickCount(int반환)로 경과 시간을 계산했는데, 약 24.9일(= 2,147,483,647ms)이 지나자 값이 음수로 감겨 버립니다. 경과 시간이 음수가 되니 모든 쿨다운 로직이 무너집니다. - 이벤트 보상 집계 서버가 "음수"를 리턴 — 전 유저의 코인 합계를
int로 누적했는데, 유저가 2,000만 명을 넘어가자 21억을 초과해 오버플로가 발생합니다. 누적값이 음수가 되어 경제 시스템이 전면 재집계됩니다. - 1,000만 개 Entity의 상태 배열이 40MB — 각 Entity의 상태 플래그(최대 200개 이하)를
int[]로 저장했습니다. 실제로는byte한 개면 충분합니다. 같은 정보를byte[]로 바꾸면 40MB → 10MB, 캐시 효율도 4배 개선됩니다.
세 경우 모두 타입 선택의 문제입니다. C#의 기본 정수형 4종(byte · short · int · long)이 어떤 범위를 담고, 내부적으로 어떻게 처리되며, Unity 모바일에서 어떻게 다르게 써야 하는지를 알아야 이런 사고를 피할 수 있습니다.
2. 개념 정의 — 네 개의 정수 "그릇"
2.1. 비유 — 각자 크기가 다른 상자
정수형을 "숫자를 담는 상자"라고 생각하면 쉽습니다. 상자마다 담을 수 있는 범위가 다릅니다.

각 상자는 메모리에서 차지하는 공간이 다릅니다. byte는 1칸, short는 2칸, int는 4칸, long은 8칸. 담을 수 있는 숫자의 범위도 그만큼 차이가 납니다. C# 키워드는 모두 .NET 공통 형식 시스템(CTS, Common Type System, .NET이 정의한 표준 타입 집합) 구조체의 별칭입니다.
| C# 키워드 | .NET 타입 | 비트수 | 범위 | 부호 |
|---|---|---|---|---|
sbyte |
System.SByte |
8 | -128 ~ 127 | 있음 |
byte |
System.Byte |
8 | 0 ~ 255 | 없음 |
short |
System.Int16 |
16 | -32,768 ~ 32,767 | 있음 |
ushort |
System.UInt16 |
16 | 0 ~ 65,535 | 없음 |
int |
System.Int32 |
32 | -2,147,483,648 ~ 2,147,483,647 | 있음 |
uint |
System.UInt32 |
32 | 0 ~ 4,294,967,295 | 없음 |
long |
System.Int64 |
64 | 약 -9.22×10¹⁸ ~ 9.22×10¹⁸ | 있음 |
ulong |
System.UInt64 |
64 | 0 ~ 약 1.84×10¹⁹ | 없음 |
숫자 뒤에 u가 붙은 이름은 부호 없는(unsigned) 변형입니다. 음수를 쓰지 않는 대신 양수 영역이 2배로 늘어납니다.
2.2. 기본 코드 — 리터럴 접미사와 범위 상수
// 기본 리터럴은 int — 접미사로 다른 타입 명시
int a = 100;
long b = 100L; // L = long
uint c = 100U; // U = uint
ulong d = 100UL; // UL = ulong
// 범위 상수 — 각 타입의 최대/최솟값
Console.WriteLine(int.MaxValue); // 2147483647
Console.WriteLine(int.MinValue); // -2147483648
Console.WriteLine(long.MaxValue); // 9223372036854775807
Console.WriteLine(byte.MaxValue); // 255
Console.WriteLine(short.MaxValue); // 32767
// 작은 타입은 명시 캐스팅 필요 — int 리터럴을 byte에 넣으려면 캐스팅
byte small = 200; // OK: 컴파일러가 상수 범위 확인 후 허용
// byte small2 = 300; // 컴파일 에러: 300 > 255
byte small3 = (byte)300; // OK: 명시 캐스팅 — 300을 256으로 나눈 나머지 44
L·U·UL— 정수 리터럴 접미사 숫자 리터럴에 붙여 타입을 명시한다. 접미사가 없으면 기본이int이므로,long.MaxValue + 1L처럼 큰 값을 다룰 때는 반드시L을 붙여야 한다.
예시:long bigValue = 3_000_000_000L;L없이3_000_000_000만 쓰면int한도(약 21억) 초과로 컴파일 에러 발생
이 코드는 "C# 기본 숫자 리터럴이 int이다" 라는 점이 중요합니다. byte나 long을 쓰려면 명시적으로 접미사를 붙이거나 캐스팅해야 합니다.
3. 내부 동작 — IL이 보여주는 타입별 차이
정수형이 IL(Intermediate Language, .NET 컴파일러가 C#을 변환한 중간 언어) 레벨에서 어떻게 다르게 처리되는지 직접 확인합니다. 이 차이를 알아야 "왜 byte로 바꿔도 빨라지지 않을 수 있는가"를 이해할 수 있습니다.
3.1. 네 타입의 덧셈 IL 비교
public static int AddInt(int a, int b) => a + b;
public static long AddLong(long a, long b) => a + b;
public static byte AddByte(byte a, byte b) => (byte)(a + b);
public static short AddShort(short a, short b) => (short)(a + b);
네 함수의 IL이 어떻게 다른지 실제 컴파일 결과입니다.
.method public hidebysig static int32 AddInt(int32 a, int32 b) cil managed
{
IL_0001: ldarg.0 // 인수 a 적재
IL_0002: ldarg.1 // 인수 b 적재
IL_0003: add // 32비트 정수 덧셈 — 오버플로 wrap around
IL_0008: ret
}
.method public hidebysig static int64 AddLong(int64 a, int64 b) cil managed
{
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: add // 64비트 정수 덧셈 (피연산자 타입으로 판별)
IL_0008: ret
}
.method public hidebysig static uint8 AddByte(uint8 a, uint8 b) cil managed
{
IL_0001: ldarg.0 // a 적재 — 자동으로 int32로 확장
IL_0002: ldarg.1 // b 적재 — 자동으로 int32로 확장
IL_0003: add // int32 덧셈
IL_0004: conv.u1 // 결과를 다시 uint8로 자름 — 추가 명령
IL_0009: ret
}
.method public hidebysig static int16 AddShort(int16 a, int16 b) cil managed
{
IL_0001: ldarg.0 // int32로 확장
IL_0002: ldarg.1 // int32로 확장
IL_0003: add // int32 덧셈
IL_0004: conv.i2 // 결과를 int16으로 자름 — 추가 명령
IL_0009: ret
}
IL 분석 포인트:
byte와short는 "자기 산술 연산"이 없다 — IL에는add.i1이나add.i2같은 명령이 존재하지 않습니다. CLR(Common Language Runtime, .NET 런타임) 평가 스택의 최소 슬롯 크기가 32비트이기 때문에, 8비트·16비트 값을 계산할 때는 먼저 32비트로 승격(promote) 한 뒤add로 더합니다. 그 결과 역시 32비트이므로,byte나short에 다시 담으려면conv.u1/conv.i2명령으로 상위 비트를 잘라내야 합니다.int와long은 "일급 시민" — IL 명령어가 피연산자 타입을 그대로 활용합니다. 명령어 자체는 같은add이지만, 평가 스택에 올라간 값의 타입에 따라 32비트 덧셈인지 64비트 덧셈인지 CLR이 결정합니다.byte·short로 산술 하면 명령이 더 많다 — 위 IL에서 확인되듯이byte·short는 결과 재축소를 위한conv.*명령이 한 개씩 더 붙습니다. 연산 속도 자체는int가 가장 빠르거나 동등합니다.byte·short의 이점은 "계산 속도"가 아니라 "저장 공간" 에 있습니다.- long 연산은 32비트 플랫폼에서 느려진다 — 64비트 CPU(요즘 모바일 모두 해당)는
add(i8) 한 명령이지만, 오래된 32비트 ARM 디바이스에서는 64비트 덧셈이 하드웨어적으로 두 번의 명령으로 나뉩니다.
3.2. 오버플로 — 어떻게 "감겨 버리는가"

C#의 정수는 2의 보수(Two's complement, 음수를 "모든 비트를 반전한 뒤 1을 더한 값"으로 표현하는 방식) 로 저장됩니다. 그래서 최상위 비트(MSB, Most Significant Bit)가 1이면 음수로 해석됩니다. int.MaxValue(비트가 0 1111...1)에 1을 더하면 비트가 1 0000...0이 되어, 이를 2의 보수로 해석한 값이 바로 int.MinValue입니다.
기본 동작은 unchecked — 오버플로가 발생해도 예외가 나지 않고 조용히 감겨 버립니다. 성능을 위한 선택입니다.
int max = int.MaxValue;
int result = max + 1;
Console.WriteLine(result); // -2147483648 (침묵의 오버플로)
3.3. checked · unchecked — 오버플로를 예외로 바꾸는 스위치
의도치 않은 오버플로는 조용히 데이터를 망가뜨립니다. 금융·누적 집계·인덱스 계산에서는 차라리 예외를 던지는 편이 안전합니다.
checked·unchecked— 오버플로 검사 키워드 정수 연산에서 오버플로가 발생했을 때 예외를 던질지(checked) 그냥 감길지(unchecked)를 결정한다. 블록 또는 표현식 단위로 사용 가능하며, 기본 동작은unchecked이다.
예시:int r = checked(a + b);오버플로 발생 시System.OverflowException예외 발생
public static int AddChecked(int a, int b)
{
checked
{
return a + b; // 오버플로 시 OverflowException
}
}
public static int AddUnchecked(int a, int b)
{
unchecked
{
return a + b; // 기본과 동일 — wrap around
}
}
.method public hidebysig static int32 AddChecked(int32 a, int32 b) cil managed
{
IL_0002: ldarg.0
IL_0003: ldarg.1
IL_0004: add.ovf // 오버플로 감지 덧셈 — 넘치면 System.OverflowException
IL_0009: ret
}
.method public hidebysig static int32 AddUnchecked(int32 a, int32 b) cil managed
{
IL_0002: ldarg.0
IL_0003: ldarg.1
IL_0004: add // 기본 덧셈 — 오버플로 무시
IL_0009: ret
}
IL 분석 포인트:
addvsadd.ovf— 한 글자 차이지만 동작이 완전히 다릅니다.add.ovf는 CPU의 오버플로 플래그를 확인해 예외를 던지는 명령입니다. 부호 없는 정수(uint·ulong·byte·ushort)에는add.ovf.un이 쓰입니다.- checked는 느리다 — CPU 플래그 검사가 추가되므로 극한의 루프(예: 1억 번 반복되는 Unity Update)에서는 수 % 정도 느려질 수 있습니다. 대신 버그 잡기 훨씬 쉽습니다.
- 프로젝트 전체를 checked 로 켤 수 있다 —
.csproj에<CheckForOverflowUnderflow>true</CheckForOverflowUnderflow>를 추가하면 전역 기본이checked로 바뀌고, 의도적 wrap-around가 필요한 구간만unchecked로 감쌉니다.
4. 실전 적용 — 언제 어떤 타입을 골라야 하는가
4.1. 기본 원칙 — "Int32가 집에 돌아가기 좋은 곳"
C#과 CPU 모두 32비트 정수(int)를 가장 자연스럽게 다룹니다. 아래 네 경우를 제외하면 그냥 int를 쓰세요.
| 상황 | 선택 | 이유 |
|---|---|---|
| 값이 21억을 넘을 가능성 있음 | long |
int 한계 초과 |
값이 int 한도 이내지만 대량(수천만~수억 개)으로 저장 |
byte / short |
메모리/대역폭 절약 |
| 비트 연산, 해시, 체크섬 (음수 의미 없음) | uint / ulong |
wrap-around 활용, 쉬프트 명확 |
| 네트워크/파일 프로토콜의 고정 크기 필드 | byte / short / 고정 크기 int |
규격이 비트수를 결정 |
4.2. long이 반드시 필요한 Unity 실전 케이스
// Before — int로 경과 시간 저장 (24.9일 후 오버플로)
int startTickMs = Environment.TickCount; // 32비트 밀리초
void Update()
{
int elapsedMs = Environment.TickCount - startTickMs;
if (elapsedMs > COOLDOWN_MS) // 25일 후 음수가 되어 항상 false
{
// 쿨다운 끝 ...
}
}
// After — long으로 전환 (약 29억 년 여유)
long startTickMs = Environment.TickCount64; // .NET Core 3.0+ / Unity 2021 LTS+
void Update()
{
long elapsedMs = Environment.TickCount64 - startTickMs;
if (elapsedMs > COOLDOWN_MS)
{
// 쿨다운 끝 ...
}
}
Stopwatch.ElapsedMilliseconds, DateTime.Ticks, FileInfo.Length 등 시간·용량을 반환하는 .NET API는 대부분 long입니다. 이걸 int 변수에 받으면 컴파일 에러가 나는데, 해결책은 "캐스팅"이 아니라 "변수 타입을 long으로 맞추는 것"입니다.
4.3. byte로 메모리 4배 절약 — SOA 패턴
100만 개의 적 Entity가 있고, 각각 "상태 플래그(최대 16종)"를 저장해야 한다고 가정합니다. 상태는 0~15 범위이므로 byte 한 개로 충분합니다.
// Before — int로 상태 저장 (4바이트 × 100만 = 4MB)
public class EnemyData
{
public int state; // 0~15 값만 쓰지만 int[]로 4MB
}
int[] states = new int[1_000_000];
// After — byte로 상태 저장 (1바이트 × 100만 = 1MB)
byte[] states = new byte[1_000_000];
// 상태 읽기
byte s = states[i]; // byte — 0~255
if (s == 3)
{
// ...
}
IL 분석 포인트:
byte[]에서 원소를 읽으면 IL은ldelem.u1명령으로 1바이트를 가져와 자동으로 int32 슬롯에 확장합니다. 산술 연산은 여전히 int32로 수행되지만, 배열에 저장되는 실제 크기는 1바이트입니다.- CPU 캐시 라인(L1 캐시 한 줄은 보통 64바이트)당 4배 더 많은 원소가 들어가므로, 순차 순회 속도가 실측으로 2~4배 빨라지는 경우가 많습니다.
- 이 패턴이 SOA(Structure of Arrays, 구조체 배열 대신 필드별로 배열을 분리하는 레이아웃) 의 핵심 아이디어입니다. Unity DOTS(Data-Oriented Technology Stack)가 추구하는 메모리 레이아웃이기도 합니다.
4.4. 부호 없는 정수(uint·ulong)가 자연스러운 곳
// 해시 계산 — wrap around을 의도적으로 활용
public static uint FnvHash(string s)
{
uint hash = 2166136261u; // FNV-1a 초기값
foreach (char c in s)
{
hash ^= c;
hash *= 16777619u; // 오버플로가 자연스럽게 발생
}
return hash;
}
// 네트워크 프로토콜 — 플레이어 ID는 음수가 없으므로 uint가 더 적절
public struct PlayerPacket
{
public uint playerId; // 0 ~ 42억 — 음수가 없어서 ID 공간 2배
public ulong timestamp; // 서버 기준 에폭 나노초 — int64로도 부족한 정밀도
}
주의: CLS(Common Language Specification, 여러 .NET 언어가 상호 운용하려고 지키는 최소 규격)는 byte를 제외한 unsigned 정수를 권장하지 않습니다. 라이브러리·SDK로 배포하는 public API의 파라미터·반환 타입에는 int / long을 쓰는 편이 안전합니다. 내부 구현(비트 연산, 해시)에서는 자유롭게 uint / ulong을 써도 괜찮습니다.
5. 함정과 주의사항
5.1. int 두 개 곱해서 long에 저장해도 이미 늦다
가장 흔한 함정입니다.
// ❌ Before — int × int 가 먼저 int로 계산된 뒤 long에 대입
int width = 100_000;
int height = 100_000;
long pixelCount = width * height; // 10_000_000_000 이어야 하지만...
Console.WriteLine(pixelCount); // 1410065408 (오버플로 wrap)
IL을 보면 왜 이런 결과가 나오는지 명확합니다.
IL_0001: ldloc.0 // width 적재 (int32)
IL_0002: ldloc.1 // height 적재 (int32)
IL_0003: mul // int32 곱셈 — 여기서 이미 오버플로, wrap 발생
IL_0004: conv.i8 // 그 뒤 long으로 확장해도 이미 깨진 값
IL_0005: stloc.2
// ✅ After — 피연산자 중 하나를 long으로 만들어 long 곱셈으로 승격
int width = 100_000;
int height = 100_000;
long pixelCount = (long)width * height; // long × int → long × long 승격
Console.WriteLine(pixelCount); // 10000000000 (올바름)
대응되는 IL입니다.
IL_0001: ldloc.0
IL_0002: conv.i8 // 피연산자 하나를 먼저 int64로 확장
IL_0003: ldloc.1
IL_0004: conv.i8 // 다른 피연산자도 int64로 확장
IL_0005: mul // int64 곱셈 — 안전
IL_0006: stloc.2
규칙은 단순합니다: 두 정수를 곱할 때 결과가 int 한도를 넘을 가능성이 있으면, 곱셈 이전에 최소 한 쪽을 long으로 만들어라. 대입 대상 타입은 연산 이후를 결정할 뿐 연산 자체를 바꾸지 못합니다.
5.2. Environment.TickCount 는 25일 타임밤
// ❌ Before — TickCount (int) 사용
int gameStartMs = Environment.TickCount;
// ... 게임이 25일 넘게 돌아간 시점 ...
int elapsed = Environment.TickCount - gameStartMs;
// elapsed가 음수가 되어 어떤 로직도 제대로 동작 안 함
// ✅ After — TickCount64 (long) 사용
long gameStartMs = Environment.TickCount64; // .NET Core 3.0+
long elapsed = Environment.TickCount64 - gameStartMs;
// 약 29억 년까지 안전
서버처럼 오래 실행되는 프로세스(클라우드 게임 서버, Unity 헤드리스 서버)에서 특히 위험합니다. Unity 클라이언트도 세션 간 누적 시간을 계산할 때 같은 함정에 빠집니다.
5.3. byte 반복 카운터는 256에서 멈춘다
// ❌ Before — byte 루프 카운터
for (byte i = 0; i < 256; i++) // 무한 루프!
{
// i는 255에서 다음이 0이 되므로 조건이 계속 참
DoSomething(i);
}
// ✅ After — int 루프 카운터
for (int i = 0; i < 256; i++)
{
DoSomething((byte)i); // 필요하면 넘길 때만 캐스팅
}
byte로 0부터 255까지 세고 나면 다음 값은 256이 아니라 0입니다(wrap around). 반복 조건 i < 256이 항상 참이 되어 무한 루프가 됩니다. 루프 카운터는 언제나 int 가 정석입니다.
5.4. 작은 타입이 Unity 핫패스에서 오히려 느려지는 경우
byte·short로 산술을 계속 하면 IL 레벨의 conv.u1 / conv.i2 명령이 매 연산마다 추가됩니다. Unity Update() 같은 핫패스에서 수백만 번 반복되면 무시할 수 없습니다.
// ❌ Before — 루프 내부에서 계속 byte 로 축소
byte acc = 0;
for (int i = 0; i < 1_000_000; i++)
{
acc = (byte)(acc + data[i]); // 매 반복마다 conv.u1 실행
}
// ✅ After — 내부 계산은 int 로 하고 저장만 byte
int acc = 0;
for (int i = 0; i < 1_000_000; i++)
{
acc += data[i]; // int 산술 — 추가 명령 없음
}
byte result = (byte)(acc & 0xFF); // 끝에서 한 번만 축소
원칙: 저장은 작은 타입으로, 계산은 int로. 배열·필드의 타입을 byte로 고정해 메모리를 아끼되, 산술은 int 지역 변수로 끌어올려 계산합니다.
5.5. IL2CPP 빌드에서도 결과는 동일
Unity 모바일 빌드의 IL2CPP(IL을 C++로 변환해 네이티브 컴파일하는 백엔드)는 위 IL 규칙을 그대로 C++ 코드로 옮깁니다. 즉 byte / short의 자동 int 확장, add.ovf 의 오버플로 체크 같은 동작은 에디터(Mono)와 실기(IL2CPP)에서 동일하게 관찰됩니다. 타입 선택 전략을 에디터에서 검증하면 실기에서도 그대로 적용된다는 뜻입니다.
IL 레벨에서 특이사항 없음. IL2CPP는 conv.u1 을 그대로 (uint8_t) 캐스팅으로 옮깁니다.
6. C# 버전별 변화
정수형 자체는 C# 1.0부터 지금까지 개념이 거의 바뀌지 않은 드문 영역입니다. 다만 주변 기능이 꾸준히 보강되었습니다.
| C# 버전 | 변화 |
|---|---|
| C# 7.0 (2017) | 숫자 리터럴에 구분자 _ 허용 — 1_000_000, 0x_FFFF_0000 |
| C# 7.2 (2017) | 리터럴 앞머리에도 _ 허용 — 0x_FF |
| C# 9.0 (2020) | nint / nuint — 네이티브 크기 정수 도입 (아키텍처에 따라 32/64) |
| C# 11.0 (2022) | nint와 IntPtr가 완전히 같은 타입으로 통합, 리터럴 대입 nint x = 10; 지원 |
| .NET 7 / C# 11 | IBinaryInteger<T>, INumber<T> 제네릭 인터페이스 — 정수 타입을 제네릭으로 추상화 |
구분자 문법 비교
// ❌ Before — C# 6 이전: 리터럴을 읽기 어려움
int salary = 10000000; // 일천만 원인지 한눈에 안 보임
int hex = 0xFF00FF00; // 바이트 경계가 모호
// ✅ After — C# 7 이후: 구분자 `_` 로 자리수 표시
int salary = 10_000_000;
int hex = 0xFF_00_FF_00;
long bytes = 1_000_000_000_000L; // 1 TB
구분자는 순수히 가독성 목적이며 컴파일 결과 IL은 완전히 동일합니다(ldc.i4 10000000).
숫자 제네릭 (C# 11+) — 입문 단계에서는 참고만
// C# 11 이후 — 정수 제네릭 메서드
public static T Sum<T>(T[] values) where T : INumber<T>
{
T total = T.Zero;
foreach (var v in values)
total += v;
return total;
}
int[] ints = { 1, 2, 3 };
long[] longs = { 1L, 2L, 3L };
Console.WriteLine(Sum(ints)); // 6
Console.WriteLine(Sum(longs)); // 6
"타입이 뭐든 상관없이 정수 컬렉션 합계" 같은 라이브러리를 만들 때 유용합니다. 실전에서는 int·long을 직접 다루는 경우가 압도적으로 많으니, 이 섹션은 "C#이 계속 발전하고 있다"는 정도로만 알고 넘어갑니다.
7. 정리 — 이것만 기억하라
- 기본 선택은
int— 32비트는 CPU·IL 모두가 가장 자연스럽게 다루는 단위. 21억 이하 값이면int. - 21억을 넘는 값이면
long— 타임스탬프·파일 크기·누계·전체 사용자 수.Environment.TickCount64,DateTime.Ticks,FileInfo.Length모두long. - 대량 저장에는
byte/short— 값 범위가 작고(0~255 / -32768~32767) 수천만 개 이상 저장하면 메모리·캐시 효율이 실측으로 유의미. - 오버플로는 침묵한다 — 기본이
unchecked. 금융·집계·인덱스처럼 데이터 정확성이 중요한 구간은checked블록으로 감싼다. int * int→long함정 — 결과를long에 담아도 곱셈 자체는 이미int에서 오버플로. 한 쪽을(long)로 캐스팅해 연산 승격.- 루프 카운터는
int, 저장은 작은 타입 —byte i < 256같은 무한 루프 함정을 피한다. 산술은int로, 저장은byte[]로. - unsigned는 내부 구현용 —
uint/ulong은 해시·비트 연산·네트워크 프로토콜 같은 내부 구현에 쓰고, public API에는int/long을 쓴다. - IL2CPP에서도 규칙은 동일 — 에디터(Mono)에서 확인한 IL 레벨 동작이 모바일 실기에서도 그대로 재현된다.