반응형

[PART2.변수와 기본 데이터 타입(11/13)] 기본 형식 변환 — 암시적·명시적·Parse·TryParse·Convert

들어가며

C# 코드에서 타입이 다른 값을 만났을 때, 우리는 다섯 가지 도구 중 하나를 고릅니다. long total = intValue; 처럼 등호만 써도 되는 경우가 있고, (int)floatValue 처럼 괄호를 박아야 하는 경우가 있고, int.Parse(text)int.TryParse(text, out var n) 처럼 메서드를 호출해야 하는 경우가 있고, Convert.ToInt32(obj) 처럼 범용 클래스를 끼워넣는 경우도 있습니다.

같은 "변환" 이라는 이름으로 묶여 있지만, 이 다섯 가지는 컴파일러가 만들어내는 IL 명령어가 다르고, 실패했을 때의 결과도 다르며, 힙 할당 여부도 다릅니다. 잘못된 선택 하나가 Unity 의 프레임 스파이크가 되기도 하고, 사용자가 오타를 낼 때마다 예외가 뛰어 게임이 버벅이기도 합니다.

이 글은 다섯 가지 도구를 언제 쓰고 왜 그걸 써야 하는지 를 IL 수준에서 증명합니다. 실제 IL 명령어를 디컴파일해 비교하고, checked/unchecked 가 IL 을 어떻게 바꾸는지 보며, Unity 모바일 런타임에서 각 선택이 GC 와 프레임에 미치는 영향을 정리합니다.


1. 변환이란 — 값을 새 옷으로 갈아입히는 일

C# 에서 "형 변환" 이라는 말은 두 가지 전혀 다른 작업을 한 단어로 부르고 있어서 자주 혼란이 생깁니다.

① 비트 패턴을 실제로 바꾸는 변환: int 의 4바이트를 long 의 8바이트로 확장하거나, double 의 8바이트 부동소수점 표현을 int 의 4바이트 정수 표현으로 재배열하는 일입니다. CPU 레벨의 연산이 필요하며, IL 에서 conv.* 명령어 한 줄로 처리됩니다.

② 외부 표현을 내부 표현으로 파싱하는 변환: 문자열 "123" 이라는 유니코드 바이트 시퀀스를 읽어가며 실제로 숫자 123 의 비트 패턴을 만들어내는 일입니다. 이건 단순한 비트 재배열이 아니라 문자를 한 글자씩 해석하는 알고리즘이 돌아가야 하며, IL 에서는 call Int32::Parse(string) 같은 메서드 호출로 표현됩니다.

이 두 가지를 구분하지 못하면 "왜 (int)"10" 은 에러 나는데 (int)10.5 는 되는가?" 같은 질문에 답할 수 없습니다. 숫자 간 변환은 컴파일러의 영역, 문자열 변환은 라이브러리의 영역 이라고 기억하면 됩니다.

C# 형식 변환의 두 갈래

2. 암시적 변환 — 컴파일러가 책임지는 "안전한" 변환

암시적(implicit) 변환 은 값의 손실이 절대 없다고 컴파일러가 확신할 수 있을 때 자동으로 수행되는 변환입니다. 개발자가 아무 표시도 하지 않아도 됩니다.

C#
int a = 12345;
long b = a;        // 암시적 변환 — 등호만으로 OK
float f = 3.14f;
double d = f;      // 암시적 변환 — float → double

작은 상자에 있던 값을 큰 상자로 옮기는 일은 항상 안전합니다. int (4바이트, 약 ±21억 범위) 의 모든 값은 long (8바이트, 약 ±9.2 × 10^18 범위) 에 그대로 들어갑니다. float 의 모든 값은 double 로 정확히 옮길 수 있습니다. 이렇게 값 범위가 부분집합 관계 일 때 C# 스펙은 암시적 변환을 허용합니다.

IL 실측 — conv.i8 한 줄

C#
static long ImplicitIntToLong(int x) => x;
IL
.method private hidebysig static int64 ImplicitIntToLong(int32 x) cil managed
{
    IL_0000: ldarg.0
    IL_0001: conv.i8
    IL_0002: ret
}
  • ldarg.0: 파라미터 x 를 스택에 올립니다.
  • conv.i8: 스택 최상단 값을 int64 로 부호 확장 합니다. 원래 32비트 값이었다면 상위 32비트를 부호 비트로 채워 64비트로 만듭니다. CPU 명령어 한 번으로 끝나는 매우 저렴한 작업입니다.
  • ret: 반환.

CPU 입장에서 conv.i8 은 거의 무비용입니다. 레지스터 확장 명령 한 번이면 끝납니다. 그래서 암시적 변환에는 런타임 비용을 걱정할 필요가 없습니다. 부담 없이 씁니다.

주의할 함정 — 부호 있는 / 없는 타입 간 암시적 변환은 없다

intuint 는 둘 다 32비트지만 표현 범위가 달라서 서로 암시적 변환이 되지 않습니다. uint 는 0 ~ 약 42억, int 는 약 -21억 ~ 21억이라, 어느 쪽으로도 손실 없이 옮길 수 없기 때문입니다. 이 경우 반드시 명시적 캐스팅이 필요합니다.


3. 명시적 캐스팅 — 개발자가 "책임지겠다" 고 선언하는 변환

명시적(explicit) 캐스팅 은 손실 가능성이 있을 때 개발자가 (타입) 연산자로 "내가 책임진다" 고 선언하는 변환입니다. 컴파일러는 캐스트 없이는 컴파일 에러를 냅니다.

C#
double pi = 3.14;
int n = (int)pi;          // 3 — 소수부가 잘려나감 (truncation, 반올림 아님)

long big = 3_000_000_000L;
int small = (int)big;     // 오버플로우 — 의도하지 않은 음수

정수 방향 변환의 결과 를 정확히 기억해야 합니다. (int)3.14 는 3, (int)3.9 도 3, (int)-3.9 는 -3 입니다. 소수부를 반올림하지 않고 그냥 버립니다(truncation). 반올림이 필요하면 Math.Round(d) 또는 Convert.ToInt32(d) 를 써야 합니다 (뒤에서 다룹니다).

IL 실측 — conv.i4 한 줄 (하지만 동작이 다르다)

C#
static int ExplicitDoubleToInt(double d) => (int)d;
IL
.method private hidebysig static int32 ExplicitDoubleToInt(float64 d) cil managed
{
    IL_0000: ldarg.0
    IL_0001: conv.i4
    IL_0002: ret
}
  • ldarg.0: double 값을 스택에 올립니다.
  • conv.i4: 스택 최상단을 int32 로 변환. 원래 타입이 double 이면 소수부를 버리고 정수부만 취합니다. 원래 타입이 long 이면 하위 32비트를 취하고 상위 32비트를 그냥 버립니다(오버플로우 검사 없음).
  • ret: 반환.

conv.i4 한 줄에 3.14 → 3 변환과 3_000_000_000 → -1294967296 같은 오버플로우가 모두 들어 있습니다. IL 은 "검사 없이 바이트를 재배열하라" 만 지시하고, 올바른 값이 나오는지는 전적으로 개발자 책임입니다. 이것이 명시적 캐스팅의 본질입니다.

명시적 캐스팅 (int)3.14 의 IL 레벨 동작

4. checkedunchecked — 오버플로우를 IL 로 제어하기

C# 은 기본값이 unchecked 입니다. (int)3_000_000_000L 같은 코드는 런타임에 예외 없이 조용히 잘못된 값을 만들어냅니다. 이는 성능을 위한 선택이지만, 금융 계산이나 게임 점수처럼 "잘못된 값이 절대 들어가서는 안 되는" 곳에서는 위험합니다.

checked 키워드로 해당 블록 안에서만 오버플로우를 감지하게 만들 수 있습니다.

C#
long big = 3_000_000_000L;

int a = (int)big;          // unchecked (기본값) → -1294967296 (조용히 손상)

int b = checked((int)big); // checked → OverflowException

IL 실측 — conv.ovf.i4 로 바뀐다

C#
static int CheckedCast(long v) { checked { return (int)v; } }
IL
.method private hidebysig static int32 CheckedCast(int64 v) cil managed
{
    IL_0000: ldarg.0
    IL_0001: conv.ovf.i4
    IL_0002: ret
}
  • conv.ovf.i4: 오버플로우 감지 기능이 있는 int32 변환 명령. 값이 int 범위를 넘으면 OverflowException 을 던집니다. 일반 conv.i4 에 비해 런타임 검사 한 번이 추가됩니다.

IL 명령어가 conv.i4 에서 conv.ovf.i4 로 완전히 바뀐다는 점이 중요합니다. 컴파일러가 소스의 checked 를 해독해서 다른 IL 명령어를 발행 합니다. 런타임에 플래그를 보고 분기하는 게 아닙니다.

unchecked — 의도적 순환이 필요한 곳

해시 함수나 CRC 계산처럼 오버플로우가 정상 동작 인 코드도 있습니다. 프로젝트 전체 checked 옵션이 켜져 있어도 이런 블록은 unchecked 로 감쌉니다.

C#
unchecked
{
    int hash = 17;
    hash = hash * 31 + a.GetHashCode();
    hash = hash * 31 + b.GetHashCode();  // 곱셈에서 자주 넘치지만 의도된 동작
    return hash;
}

5. int.Parse — 예외로 실패를 알리는 고전적 파서

int.Parse(string)문자열을 정수로 해석 하는 메서드입니다. 성공하면 int 를 반환하고, 실패하면 예외를 던집니다.

C#
int a = int.Parse("123");             // 123
int b = int.Parse("abc");             // FormatException
int c = int.Parse("99999999999999");  // OverflowException
int d = int.Parse(null);              // ArgumentNullException

세 가지 예외를 기억합니다.

입력 예외
숫자 형식이 아님 ("abc", "1.5", "") FormatException
숫자지만 int 범위 초과 ("99999999999999") OverflowException
null ArgumentNullException

IL 실측 — 평범한 call

C#
static int ParseString(string s) => int.Parse(s);
IL
.method private hidebysig static int32 ParseString(string s) cil managed
{
    IL_0000: ldarg.0
    IL_0001: call int32 [System.Runtime]System.Int32::Parse(string)
    IL_0006: ret
}

IL 자체는 평범합니다. callInt32::Parse 를 호출할 뿐입니다. 성능 비용은 메서드 내부의 문자열 파싱 루프에서, 그리고 실패했을 때 던져지는 예외에서 발생 합니다.

예외의 진짜 비용

C# 에서 throw new FormatException(...) 는 매우 비쌉니다. 예외 객체를 힙에 할당하고, 현재 콜 스택 전체를 훑어서 스택 트레이스를 캡처하고, catch 지점까지 스택을 되감습니다. 일반적인 메서드 호출보다 수백~수천 배 느립니다. 그래서 Parse 는 "실패가 예외적 상황" 일 때만 써야 합니다. 사용자 입력처럼 실패가 일상적인 곳에서는 뒤에 나올 TryParse 를 씁니다.


6. int.TryParse — 예외 없이 bool 로 실패를 알리는 핫패스 표준

int.TryParse(string, out int)예외를 던지지 않는 변환 메서드입니다. 성공 여부는 bool 로 반환하고, 성공했으면 out 파라미터에 결과를 담아 줍니다.

C#
if (int.TryParse("123", out int n))
{
    // n == 123
}
else
{
    // n == 0 (실패 시 out 은 기본값)
}

실패해도 예외가 없으므로 사용자 입력, 설정 파일, JSON 필드 등 실패가 자주 일어나는 곳에서는 TryParse 가 표준 입니다.

IL 실측 — int32& 는 out 참조

C#
static bool TryParseString(string s, out int n) => int.TryParse(s, out n);
IL
.method private hidebysig static bool TryParseString(string s, [out] int32& n) cil managed
{
    IL_0000: ldarg.0
    IL_0001: ldarg.1
    IL_0002: call bool [System.Runtime]System.Int32::TryParse(string, int32&)
    IL_0007: ret
}
  • int32&: ref int 를 의미하는 IL 표기. out 키워드는 IL 수준에서는 ref 와 동일한 참조 전달입니다. 컴파일러가 "호출자가 쓰기 전에 읽지 못하도록" 검증해줄 뿐, 런타임 메커니즘은 같습니다.
  • 예외를 던지지 않으므로 try/catch 가 컴파일될 필요가 없습니다. 실패 시 단순히 false 를 반환합니다.

ParseTryParse 의 벤치마크적 감각

실패가 드문 경우 (대부분 성공): 두 방식 성능 차이는 거의 없습니다. 성공 시 내부 파싱 로직이 사실상 동일합니다.

실패가 잦은 경우 (예: 10번 중 3번 실패): Parse + try/catch 는 매 실패마다 예외 스택 트레이스 캡처 비용이 발생합니다. TryParse 는 그냥 false 만 반환합니다. 실패율이 1% 만 넘어도 TryParse 가 수십 배 빠릅니다.

Parse vs TryParse — 실패율에 따른 비용

7. Convert.ToInt32 — 다형적 범용 변환기 (그리고 반올림)

Convert.ToInt32(...)다양한 입력 타입을 모두 받아서 int 로 변환 하는 범용 메서드입니다. 오버로드가 많습니다.

C#
Convert.ToInt32("123");      // 123  — 내부적으로 int.Parse 호출
Convert.ToInt32(null);       // 0    — null 은 0 반환 (Parse 와 다름!)
Convert.ToInt32(true);       // 1    — bool → int
Convert.ToInt32(9.8);        // 10   — 반올림! (Banker's Rounding)
Convert.ToInt32((object)42); // 42   — object 언박싱

세 가지 핵심 동작 차이를 기억합니다.

null 은 예외가 아니라 0

int.Parse(null)ArgumentNullException 을 던지지만, Convert.ToInt32(null) 은 그냥 0 을 반환합니다. "null 은 0으로 취급" 이 의도가 맞으면 Convert 가 편리하고, null 을 오류로 봐야 한다면 Parse/TryParse 가 맞습니다.

② 실수 → 정수는 반올림

명시적 캐스팅 (int)9.89 (절사) 지만, Convert.ToInt32(9.8)10 (반올림) 입니다. 그것도 "Banker's Rounding" (짝수 반올림) 이어서 Convert.ToInt32(2.5) == 2, Convert.ToInt32(3.5) == 4 입니다.

입력 (int) 캐스팅 Convert.ToInt32
9.8 9 10
2.5 2 2 (짝수로)
3.5 3 4 (짝수로)
-1.5 -1 -2 (짝수로)

Unity 에서 Input.GetAxis 처럼 float 를 정수화할 때 이 차이가 치명적으로 드러납니다. 뒤에서 다룹니다.

object 인자 — 박싱/언박싱 발생

Convert.ToInt32((object)42) 처럼 object 를 받으면 내부에서 IConvertible 인터페이스를 통해 실제 타입을 확인하고 값을 꺼냅니다. 이 과정에서 박싱된 값 타입이 언박싱됩니다. 뒤의 8장에서 구체적으로 분석합니다.

IL 실측 — call 로 끝

C#
static int ConvertToInt(object o) => Convert.ToInt32(o);
IL
.method private hidebysig static int32 ConvertToInt(object o) cil managed
{
    IL_0000: ldarg.0
    IL_0001: call int32 [System.Runtime]System.Convert::ToInt32(object)
    IL_0006: ret
}

IL 자체는 메서드 호출 한 번입니다. 비용은 호출된 메서드 안에서, 박싱된 값을 언박싱하고 타입을 검사하고 변환 테이블을 타는 과정에서 발생합니다.


8. 박싱 / 언박싱 — 값 타입과 참조 타입의 경계를 넘는 일

C# 의 int, float, bool, struct값 타입 으로, 스택에 직접 저장되거나 다른 객체의 필드로 인라인 배치됩니다. 반면 object, string, 클래스 인스턴스는 참조 타입 으로, 힙에 할당되고 스택에는 참조만 들어갑니다.

값 타입을 object 자리에 넣으면 런타임이 힙에 "상자" 를 새로 할당 하고 값을 거기로 복사합니다. 이것이 박싱(boxing) 입니다. 반대로 object 에서 값 타입을 꺼내 스택으로 복사하면 언박싱(unboxing) 입니다.

C#
int v = 42;
object boxed = v;          // 박싱 — 힙 할당 + 복사
int unboxed = (int)boxed;  // 언박싱 — 타입 검사 + 복사

IL 실측 — boxunbox.any

C#
static object BoxInt(int v) => v;
static int UnboxInt(object o) => (int)o;
IL
.method private hidebysig static object BoxInt(int32 v) cil managed
{
    IL_0000: ldarg.0
    IL_0001: box [System.Runtime]System.Int32
    IL_0006: ret
}

.method private hidebysig static int32 UnboxInt(object o) cil managed
{
    IL_0000: ldarg.0
    IL_0001: unbox.any [System.Runtime]System.Int32
    IL_0006: ret
}
  • box [System.Int32]: 스택의 int 값을 힙에 Int32 박스 객체로 할당하고, 그 참조를 스택에 올립니다. 즉, 이 한 줄에 힙 할당 1회 + 값 복사 1회가 들어 있습니다.
  • unbox.any [System.Int32]: 스택의 object 참조가 실제로 Int32 박스인지 런타임 타입 검사 를 하고, 맞으면 박스 안의 값을 스택으로 복사합니다. 타입이 틀리면 InvalidCastException.

박싱은 new 만큼 비싸며, 힙 할당은 결국 GC 압박으로 돌아옵니다. Unity 모바일에서 박싱이 프레임당 수백 번 일어나면 GC 가 100~200ms 단위로 튀고, 이는 그대로 프레임 스파이크가 됩니다.

박싱 / 언박싱 — IL 과 메모리 레이아웃

9. 성능과 선택 기준 — 다섯 가지 도구를 언제 쓸까

종합하면 다음 표로 결정합니다.

상황 권장 도구 이유
intlong, floatdouble 등 확장 암시적 변환 IL 1줄, 무비용
doubleint 등 절사가 의도된 축소 명시적 캐스팅 (int)d IL 1줄, 빠름
doubleint 인데 반올림이 필요 Convert.ToInt32(d) 또는 Math.Round → 캐스팅 정책 선택
금융·점수 등 오버플로우 감지 필수 checked((int)x) conv.ovf.i4 로 예외
해시·CRC 등 의도적 순환 unchecked 블록 예외 억제
형식이 보장된 문자열 (설정 파일, 상수) int.Parse 간결, 실패는 버그
사용자 입력, 외부 데이터 int.TryParse 예외 비용 회피
object, null, 다양한 타입을 함께 처리 Convert.ToInt32 범용성, null→0
값 타입을 object 에 담기 가급적 피하기 (제네릭·in 파라미터로 회피) 박싱 = 힙 할당

핫패스에서 stringint 1000번 반복 (개념적 비용)

  • 성공만 하는 경우: ParseTryParse (내부 로직 동일)
  • 5% 실패하는 경우: TryParseParse+try 보다 대략 10~20배 빠름 (예외 스택 트레이스 비용)
  • 50% 실패하는 경우: TryParseParse+try 보다 100배 이상 빠름

정확한 숫자는 런타임 버전·JIT·스택 깊이에 따라 다르지만, 방향성은 위와 같이 단조증가 합니다.


10. Unity 실전 — 형식 변환이 만드는 프레임 스파이크와 GC 압박

① 사용자 입력은 무조건 TryParse

InputField 로 받은 문자열에 Parse 를 쓰면, 사용자가 빈 문자열이나 오타를 낼 때마다 예외가 뛰면서 프레임이 튑니다. 모바일에서는 특히 심각합니다.

DO:

C#
public InputField scoreInput;

public void OnSubmit()
{
    if (int.TryParse(scoreInput.text, out int score))
    {
        ApplyScore(score);
    }
    else
    {
        ShowToast("숫자만 입력해주세요");
    }
}

DON'T:

C#
// 사용자가 "abc" 를 치면 예외 + 스택 트레이스 캡처 → 모바일에서 프레임 스파이크
try
{
    int score = int.Parse(scoreInput.text);
    ApplyScore(score);
}
catch (FormatException)
{
    ShowToast("숫자만 입력해주세요");
}

Input.GetAxis → 타일 좌표: 절사와 반올림의 차이

Input.GetAxis("Horizontal")-1.0f ~ 1.0ffloat 입니다. 이를 타일 단위 이동 -1, 0, +1 로 만들 때, 명시적 캐스팅은 조작감을 크게 떨어뜨립니다.

C#
float h = Input.GetAxis("Horizontal");  // 0.7f

int a = (int)h;                    // 0 — 절사! 사용자가 반쯤 기울여도 정지
int b = Mathf.RoundToInt(h);       // 1 — 반올림, 자연스러움
int c = (int)Mathf.Sign(h);        // 1 — 부호만 취함, 격자 이동에 적합

DO:

C#
// 타일 격자 이동 — 부호만 쓰는 게 가장 명확
int dir = 0;
float h = Input.GetAxis("Horizontal");
if (Mathf.Abs(h) > 0.1f) dir = (int)Mathf.Sign(h);

DON'T:

C#
// 0.7 이 0 이 되므로 키를 반만 기울이면 "정지"
int dir = (int)Input.GetAxis("Horizontal");

③ 세이브/로드 — Dictionary<string, object> 는 박싱 지옥

게임 데이터를 유연하게 짠다고 Dictionary<string, object>gold = 100, hp = 80 을 넣으면, 값 타입이 모두 박싱 됩니다. 꺼낼 때마다 언박싱까지 발생합니다.

C#
// DON'T — 박싱 + 언박싱이 프레임마다 발생
private Dictionary<string, object> save = new();
save["gold"] = 100;              // box [Int32] → 힙 할당
int gold = (int)save["gold"];    // unbox.any [Int32]

DO — 타입이 명시된 클래스로 직렬화:

C#
[System.Serializable]
public class SaveData
{
    public int gold;
    public int hp;
    public float moveSpeed;
}

string json = JsonUtility.ToJson(saveData);
File.WriteAllText(path, json);

SaveData loaded = JsonUtility.FromJson<SaveData>(File.ReadAllText(path));
// 박싱 없음, 필드는 값 타입 그대로

Convert.ToInt32(obj) 는 피하기

SerializedData 나 Reflection 으로 object 를 받는 경우에도, 호출마다 박싱/언박싱이 섞이면 GC 가 튑니다. 가능한 한 제네릭 API 를 쓰거나 (JsonUtility.FromJson<T> 등), 타입 체크 후 직접 캐스팅 하는 쪽이 낫습니다.

C#
// 덜 나쁨
if (value is int i) UseInt(i);

// 최악 (object 받는 순간 박싱 확정, 꺼낼 때 언박싱)
int i = Convert.ToInt32(value);

checked 는 릴리즈 빌드에서 "감지 필요한 곳만"

Unity 모바일 릴리즈 빌드에서 프로젝트 전체 checked 옵션을 켜면 모든 산술이 conv.ovf.* 로 바뀝니다. 검증되지 않은 범위 초과를 잡기엔 좋지만, 핫루프에서는 성능 손실이 쌓입니다. 점수·결제 등 오버플로우가 곧 버그인 지점에만 checked((int)v) 로 명시 하는 쪽이 실무적으로 안전합니다.


마무리

형식 변환은 언뜻 "그냥 되는 것" 처럼 보이지만, IL 한 줄 차이가 런타임에서는 힙 할당 1회, 예외 1회, 반올림 정책 1가지의 차이로 쌓입니다.

  • 숫자 확장 은 암시적 변환 — conv.i8 한 줄, 무비용.
  • 숫자 축소·실수→정수 는 명시적 캐스팅 — conv.i4, 절사 임을 잊지 않기.
  • 오버플로우 감지가 필요한 캐스팅checkedconv.ovf.i4 로 바뀜.
  • 형식이 보장된 문자열int.Parse — 실패는 버그.
  • 사용자 입력·외부 데이터int.TryParse — 예외 비용 회피.
  • null·다양한 타입 섞인 범용 변환Convert.ToInt32 — 단, 반올림·박싱 주의.
  • 값 타입 ↔ objectbox/unbox.any — Unity 모바일에서 GC 의 주범, 제네릭으로 우회.

다음 글에서는 이 기본 타입들이 묶여서 배열과 컬렉션이 되는 과정을 살펴봅니다.

반응형

+ Recent posts