반응형

[PART11.입출력 기본(3/7)] 입력 파싱 — int.Parse / int.TryParse — 사용자 입력은 왜 항상 TryParse인가

Parse는 실패 시 예외, TryParse는 false / 예외의 비용은 실측으로 1,000배 이상 / out 변수 인라인 선언과 ReadOnlySpan 오버로드 / 사용자 문화권에 따라 같은 코드가 다르게 동작하는 함정


1. 문제 제기 — int.Parse(input) 한 줄이 게임을 죽인다

C# 신입 개발자가 콘솔이나 Unity InputField에서 받은 문자열을 숫자로 바꿀 때, 가장 먼저 손이 가는 코드는 보통 다음과 같습니다.

C#
Console.Write("나이를 입력하세요: ");
string? input = Console.ReadLine();
int age = int.Parse(input);    // ← 사용자가 "abc"를 입력하면?

이 코드는 사용자가 정확한 숫자를 입력한 경우에만 동작합니다. 빈 문자열·공백·"pizza"·범위를 벗어난 큰 숫자가 들어오면 FormatException·OverflowException·ArgumentNullException 이 던져지면서 콘솔 앱은 크래시, Unity 게임은 그 프레임 이후 모든 로직이 정지합니다.

이 글에서는 다음 질문에 답합니다.

  • Parse 대신 TryParse가 권장되는가? IL 레벨에서 무엇이 다른가?
  • 예외 한 번이 정말로 일반 함수 호출 1,000배의 비용인가?
  • int.TryParse(s, out int n)out 변수는 왜 C# 7부터 한 줄에 쓸 수 있게 되었나?
  • 사용자 문화권 때문에 같은 입력이 다르게 파싱되는 버그를 어떻게 막는가?
  • 모바일 핫패스에서 Substring(...).Parse(...)가 GC 쓰레기를 만드는 이유와 회피 방법은?

2. 개념 정의 — Parse는 예외, TryParse는 bool

2.1 두 메서드의 시그니처

Parse vs TryParse — 실패 시 동작 비교
C#
// Parse — 실패하면 예외
int n1 = int.Parse("123");      // 123
int n2 = int.Parse("abc");      // FormatException 발생!

// TryParse — 실패해도 false만 돌아옴
bool ok = int.TryParse("123", out int v1);  // ok=true,  v1=123
bool ng = int.TryParse("abc", out int v2);  // ng=false, v2=0

2.2 C# 7 이전 vs 이후 — out 변수의 진화

C# 6 이하에서는 out 매개변수에 넘길 변수를 미리 선언해야 했습니다.

out — 출력 매개변수 키워드 (Output parameter) 메서드가 인자로 받은 변수에 값을 "써서 돌려주는" 용도임을 명시. 호출 측은 호출 전에 변수를 선언만 하면 되고, 메서드 내부에서 반드시 값을 할당해야 한다.
예시: int.TryParse("3", out int n)n에 변환 결과가 들어가고, 메서드는 bool을 반환
C#
// C# 6 이하 — 변수를 먼저 선언
int n;
if (int.TryParse(input, out n))
{
    // n 사용
}

// C# 7+ — 인라인 선언
if (int.TryParse(input, out int n))
{
    // n은 이 if 블록 안에서 사용 가능
}

// C# 7+ + 패턴 매칭 — 더 짧게
if (int.TryParse(input, out var n) && n > 0)
{
    // 양수일 때만
}

세 코드는 IL 레벨에서 같은 형태로 컴파일됩니다. 컴파일러가 인라인 선언을 메서드 시작부의 .locals init 슬롯으로 끌어올려 줄 뿐, 런타임 동작은 동일합니다.

2.3 NumberStyles — 무엇을 허용할지 명시

기본 Parse는 정수형의 경우 앞뒤 공백 + 부호만 허용합니다. 16진수·천 단위 쉼표·통화 기호 등을 허용하려면 NumberStyles 비트 플래그로 명시합니다.

C#
using System.Globalization;

int hex = int.Parse("FF", NumberStyles.HexNumber);              // 255
int neg = int.Parse("(123)", NumberStyles.AllowParentheses);    // -123
double money = double.Parse("$1,234.56", NumberStyles.Currency, CultureInfo.GetCultureInfo("en-US"));
NumberStyles 허용하는 표기
Integer (기본 정수) 부호 + 공백
Number 부호 + 공백 + 소수점 + 천 단위 구분자
Float 부호 + 공백 + 소수점 + 지수 표기(1.5e10)
HexNumber 16진수만 ("1A2B")
Currency 통화 기호 + 천 단위 + 괄호 음수

2.4 IFormatProvider — 누구의 문화권으로 읽을 것인가

Parse/TryParse는 마지막 인자로 IFormatProvider(주로 CultureInfo)를 받을 수 있습니다. 같은 "1,234.56" 문자열이 미국 문화권과 독일 문화권에서 완전히 다른 값으로 파싱됩니다.

C#
using System.Globalization;

double a = double.Parse("1,234.56", CultureInfo.GetCultureInfo("en-US"));   // 1234.56
double b = double.Parse("1,234.56", CultureInfo.GetCultureInfo("de-DE"));   // 1.23456 ← 함정!
double c = double.Parse("1234.56",  CultureInfo.InvariantCulture);          // 1234.56 (항상 동일)

세이브 파일·서버 통신·로그처럼 기계가 읽는 문자열은 항상 CultureInfo.InvariantCulture 로 강제합니다.


3. 내부 동작 — 예외 vs bool, IL 두 줄짜리 차이

3.1 Parse + try-catch

C#
public static int ParseWithTry(string input)
{
    try
    {
        return int.Parse(input);
    }
    catch (FormatException)
    {
        return 0;
    }
}
IL
.method public hidebysig static int32 ParseWithTry (string input) cil managed
{
    .maxstack 1
    .locals init ([0] int32)

    IL_0000: nop
    .try                                                              // ★ 예외 처리 영역 시작
    {
        IL_0001: nop
        IL_0002: ldarg.0
        IL_0003: call int32 [System.Runtime]System.Int32::Parse(string)  // 실패 시 예외 던짐
        IL_0008: stloc.0
        IL_0009: leave.s IL_0011                                     // try 블록 정상 탈출
    }
    catch [System.Runtime]System.FormatException
    {
        IL_000b: pop                                                 // 스택의 예외 객체 제거
        IL_000c: nop
        IL_000d: ldc.i4.0
        IL_000e: stloc.0
        IL_000f: leave.s IL_0011                                     // catch 정상 탈출
    }

    IL_0011: ldloc.0
    IL_0012: ret
}

3.2 TryParse

C#
public static int ParseWithTryParse(string input)
{
    if (int.TryParse(input, out int n))
    {
        return n;
    }
    return 0;
}
IL
.method public hidebysig static int32 ParseWithTryParse (string input) cil managed
{
    .maxstack 2
    .locals init (
        [0] int32,                                            // n (out 인라인 선언이 슬롯으로 변환됨)
        [1] bool,                                             // TryParse 반환값
        [2] int32                                             // 함수 반환값 임시
    )

    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldloca.s 0                                       // ★ n의 주소를 스택에 (out 매개변수)
    IL_0004: call bool [System.Runtime]System.Int32::TryParse(string, int32&)  // ★ bool 반환
    IL_0009: stloc.1
    IL_000a: ldloc.1
    IL_000b: brfalse.s IL_0012                                // false면 즉시 분기

    IL_000d: nop
    IL_000e: ldloc.0                                          // n 로드
    IL_000f: stloc.2
    IL_0010: br.s IL_0016

    IL_0012: ldc.i4.0
    IL_0013: stloc.2
    IL_0014: br.s IL_0016

    IL_0016: ldloc.2
    IL_0017: ret
}

3.3 IL 분석 포인트

1. .try { } catch { } 블록의 무게

Parse + try-catch.try ... catch 메타데이터 영역과 leave.s 명령어로 컴파일됩니다. 실행 자체는 try 안에서 예외가 안 터지면 그냥 흘러가지만, 예외가 던져지면 CLR이 스택을 거꾸로 훑어 catch 핸들러를 찾고, 스택 트레이스 객체를 만들어 힙에 올립니다. 이 과정이 단순 함수 호출 대비 수백~수천 배의 비용이 들어가는 핵심입니다.

2. bool TryParse(string, int32&) — 단순 메서드 호출

TryParse는 그냥 함수 하나 부르고 bool을 받는 것이라 IL이 평범합니다. int32&int의 참조(by-ref) 타입으로, out int n이 컴파일된 모습입니다. CLR 내부에서 변환에 실패해도 예외를 던지지 않고 false를 돌려줍니다.

3. ldloca.s 0 — out 변수의 주소 전달

out int n이라고 선언했지만 호출부에서는 변수의 주소를 넘깁니다. TryParse 안에서 그 주소에 결과를 직접 써 넣습니다. 박싱·할당이 없는 zero-allocation 패턴입니다.

4. out int n 인라인 선언의 정체

C# 7에서 추가된 out int n 인라인 선언은 IL 레벨에서 메서드 시작부의 .locals init 슬롯에 미리 잡혀 있는 변수입니다. 컴파일러가 단지 가독성을 위해 호출부에서 선언처럼 보이게 해 줄 뿐이라, 성능 차이는 0입니다.

3.4 NumberStyles + InvariantCulture를 명시하면

C#
public static double ParsePrice(string input)
{
    return double.Parse(input, NumberStyles.Number, CultureInfo.InvariantCulture);
}
IL
.method public hidebysig static float64 ParsePrice (string input) cil managed
{
    .maxstack 3
    .locals init ([0] float64)

    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldc.i4.s 111                                  // ★ NumberStyles.Number == 111
    IL_0004: call class CultureInfo CultureInfo::get_InvariantCulture()  // ★ 정적 프로퍼티 호출
    IL_0009: call float64 Double::Parse(string, NumberStyles, IFormatProvider)
    IL_000e: stloc.0
    IL_0011: ldloc.0
    IL_0012: ret
}

5. NumberStyles.Number == 111

열거형은 IL 레벨에서 정수 상수입니다. NumberStyles.Number는 비트 OR 결합값 111(= AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign | AllowTrailingSign | AllowDecimalPoint | AllowThousands)으로 펼쳐집니다. 옵션을 명시한다고 추가 객체 할당이 발생하지는 않습니다.


4. 실전 적용 — Before/After 패턴

4.1 사용자 입력은 무조건 TryParse

C#
// ❌ Before — Unity InputField에서 들어온 텍스트를 Parse로 변환
public void OnSubmit(string ageText)
{
    int age = int.Parse(ageText);     // 빈 문자열·"pizza"·"99999999999" 모두 예외
    player.Age = age;
}
C#
// ✅ After — TryParse로 검증 + UI 피드백
public void OnSubmit(string ageText)
{
    if (!int.TryParse(ageText, out int age))
    {
        ShowError("숫자만 입력해 주세요.");
        return;
    }
    if (age is < 1 or > 120)
    {
        ShowError("1~120 사이로 입력해 주세요.");
        return;
    }
    player.Age = age;
}

핵심은 사용자 입력은 신뢰할 수 없는 문자열이라는 사고방식입니다. 콘솔이든 InputField든 네트워크 페이로드든, 외부에서 들어온 모든 문자열은 TryParse 또는 검증 후에야 로직에 넣습니다.

4.2 핫패스에서 Substring 회피 — ReadOnlySpan 오버로드

URL이나 큰 로그 라인에서 일부분만 파싱해야 할 때, Substringstring 객체를 힙에 만듭니다. 매 프레임 호출되면 GC 압박이 됩니다.

C#
// ❌ Before — Substring이 새 string 할당
int id = int.Parse(url.Substring(url.Length - 4));

// ✅ After — ReadOnlySpan<char>로 할당 회피
int id = int.Parse(url.AsSpan(url.Length - 4));
ReadOnlySpan<char> — 읽기 전용 스팬 (Read-only memory span) 메모리 영역의 일부분을 가리키는 ref struct. 새 객체를 만들지 않고 기존 메모리의 "창문"만 보여 준다. 스택 전용이라 클래스 필드·async에 저장 불가.
예시: "hello world".AsSpan(6)"world"를 가리키지만 새 string을 만들지 않는다
IL
.method public hidebysig static int32 ParseFromSpan (string url) cil managed
{
    .maxstack 3
    .locals init ([0] int32)

    IL_0000: nop
    IL_0001: ldarg.0
    IL_0002: ldarg.0
    IL_0003: callvirt instance int32 String::get_Length()
    IL_0008: ldc.i4.4
    IL_0009: sub                                           // url.Length - 4
    IL_000a: call valuetype ReadOnlySpan`1<char> MemoryExtensions::AsSpan(string, int32)  // ★ 할당 없음
    IL_000f: ldc.i4.7                                       // NumberStyles.Integer == 7
    IL_0010: ldnull                                         // IFormatProvider == null
    IL_0011: call int32 Int32::Parse(ReadOnlySpan`1<char>, NumberStyles, IFormatProvider)
    IL_0016: stloc.0
    IL_0019: ldloc.0
    IL_001a: ret
}

6. AsSpan(string, int32) — 새 객체 없는 슬라이스

AsSpan은 기존 string의 내부 char 버퍼를 가리키는 ReadOnlySpan<char> 구조체를 반환합니다. struct이므로 스택에 잡히고, 힙 할당이 없습니다. Substring과 비교해 GC 압박이 0에 수렴합니다.

7. Int32::Parse(ReadOnlySpan<char>, ...) 오버로드

.NET Core 2.1부터 int.ParseReadOnlySpan<char>를 받는 오버로드가 추가되었습니다. 부분 문자열을 파싱할 때 새 string을 만들지 않는 표준 패턴입니다.

4.3 try-catch 비용 측정 — 정말 1,000배인가

마이크로 벤치마크에서 같은 작업(잘못된 문자열을 처리)을 두 방식으로 1만 회 반복하면 다음과 같은 차이가 납니다(.NET 8, 64bit, Release).

방식 1만 회 소요 시간(대략) 상대 비용
int.TryParse("abc", out _) ~0.5 ms
try { int.Parse("abc"); } catch { } ~500 ms 1,000×

CLR이 FormatException 인스턴스를 만들고 스택 트레이스를 수집하고 GC 추적 큐에 등록하는 비용이 누적된 결과입니다. 유효한 입력만 들어오는 코드라면 두 방식의 차이는 무시할 수 있지만, 잘못된 입력이 자주 들어오는 경로(사용자 UI·네트워크 파싱)에서는 TryParse가 압도적으로 유리합니다.


5. 함정과 주의사항

5.1 ❌ 빈 문자열을 null처럼 취급

C#
// ❌ 빈 입력에 0이 들어가지만, 실제로는 ArgumentNullException(input이 null) 또는 FormatException(input이 "")
int age = int.Parse(input);
C#
// ✅ 빈/null 분기 명시
if (string.IsNullOrWhiteSpace(input)) {
    return defaultAge;
}
if (!int.TryParse(input, out int age)) {
    return defaultAge;
}
return age;

5.2 ❌ 문화권 가정

C#
// ❌ 한국 사용자 PC에서 잘 되던 코드가 독일 사용자 PC에서 1.5 → 15 로 폭주
double speed = double.Parse(text);    // 사용자 문화권에 따라 다른 결과
C#
// ✅ 데이터 파싱은 항상 InvariantCulture 강제
double speed = double.Parse(text, CultureInfo.InvariantCulture);

// ✅ TryParse 버전
double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out double speed);

게임 세이브 파일·JSON 페이로드·URL 쿼리 파라미터 — 컴퓨터끼리 주고받는 모든 숫자는 Invariant 문화권으로 통일합니다.

5.3 ❌ 16진수를 그냥 Parse

C#
// ❌ "FF"는 16진수처럼 보이지만 기본 Parse는 10진수만 허용 → FormatException
int color = int.Parse("FF");

// ✅ NumberStyles.HexNumber 명시
int color = int.Parse("FF", NumberStyles.HexNumber);                  // 255

// ✅ TryParse 버전
int.TryParse("FF", NumberStyles.HexNumber, CultureInfo.InvariantCulture, out int color);

5.4 ❌ OverflowException 무시

C#
// ❌ "99999999999"처럼 int 범위를 넘는 숫자는 OverflowException
int n = int.Parse("99999999999");
C#
// ✅ TryParse는 범위 초과도 false로 반환
if (!int.TryParse("99999999999", out int n)) {
    // 범위 초과 또는 형식 오류 모두 여기로
}

// 또는 더 큰 타입 사용
long big = long.Parse("99999999999");

5.5 ❌ Unity 핫패스에서 Substring + Parse

C#
// ❌ 매 프레임 새 string 2개 할당
void Update() {
    string url = networkSession.LastUrl;
    string idStr = url.Substring(url.LastIndexOf('/') + 1);   // 새 string
    int id = int.Parse(idStr);
}
C#
// ✅ Span 슬라이스 + Parse(ReadOnlySpan) — 할당 0
void Update() {
    string url = networkSession.LastUrl;
    int slash = url.LastIndexOf('/');
    int id = int.Parse(url.AsSpan(slash + 1));
}

int.Parse(ReadOnlySpan<char>) 오버로드는 .NET Core 2.1+에서 사용 가능하며, Unity의 모던 .NET 호환 모드에서 그대로 작동합니다. IL2CPP 빌드에서도 SubstringAsSpan으로 바꾸는 것만으로 핫패스 GC가 사라집니다.


6. C# 버전별 변화

C# 버전 변경점
7.0 (2017) out 변수 인라인 선언 — int.TryParse(s, out int n)
7.0 (2017) out var — 타입 추론까지
7.0 (2017) out _ — 결과를 버리는 discard
11.0 (2022) IParsable<T>, ISpanParsable<T> 인터페이스 — 제네릭 파싱 가능
11.0 (2022) IUtf8SpanParsable<T> — UTF-8 바이트에서 직접 파싱

C# 11에서 도입된 IParsable<T>제네릭 메서드 안에서 T.Parse(...) 를 호출할 수 있게 해 줍니다.

C#
// C# 11+ — 제네릭 파싱
static T ParseLine<T>(string line) where T : IParsable<T>
{
    return T.Parse(line, CultureInfo.InvariantCulture);
}

int n = ParseLine<int>("42");
double d = ParseLine<double>("3.14");

또한 IUtf8SpanParsable<T>UTF-8 바이트(ReadOnlySpan<byte>)에서 직접 숫자로 파싱합니다. 네트워크 패킷이나 파일 스트림처럼 원본이 UTF-8인 경우 string(UTF-16) 변환 비용을 건너뛸 수 있어, 서버·게임 네트워크 코드의 핫패스 최적화에 쓰입니다.


7. 정리 — 이것만 기억하라

  • 외부에서 들어온 문자열은 항상 TryParse 다. Parse는 신뢰할 수 있는 내부 데이터에만 쓴다.
  • 예외 한 번의 비용은 일반 호출의 수백~수천 배 다. 잦은 실패가 예상되면 TryParse로 갈아탄다.
  • out int n 인라인 선언은 컴파일러가 .locals 슬롯으로 끌어올린다. 성능 차이는 0이며, 가독성을 위한 문법이다.
  • NumberStyles는 비트 플래그. 16진수·통화·괄호 음수처럼 비기본 표기는 명시적으로 켠다.
  • 기계가 읽을 데이터는 CultureInfo.InvariantCulture 로 강제한다. 사용자 문화권에 따라 같은 코드가 다르게 동작하는 함정을 막는다.
  • 부분 문자열 파싱은 Substring 대신 AsSpan + Parse(ReadOnlySpan<char>). 새 string 할당이 없어 Unity 핫패스에서 GC를 일으키지 않는다.
  • 빈 문자열·범위 초과·16진수는 흔한 함정. TryParse는 모두 false로 처리해 주므로 사용자 피드백 분기를 단순화한다.
  • C# 11의 IParsable<T>, IUtf8SpanParsable<T> 는 제네릭 코드와 UTF-8 직접 파싱을 가능케 한 새 표준이다.
반응형

+ Recent posts