[PART11.입출력 기본(3/7)] 입력 파싱 — int.Parse / int.TryParse — 사용자 입력은 왜 항상 TryParse인가
Parse는 실패 시 예외, TryParse는 false / 예외의 비용은 실측으로 1,000배 이상 / out 변수 인라인 선언과 ReadOnlySpan 오버로드 / 사용자 문화권에 따라 같은 코드가 다르게 동작하는 함정
목차
1. 문제 제기 — int.Parse(input) 한 줄이 게임을 죽인다
C# 신입 개발자가 콘솔이나 Unity InputField에서 받은 문자열을 숫자로 바꿀 때, 가장 먼저 손이 가는 코드는 보통 다음과 같습니다.
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 — 실패하면 예외
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# 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 비트 플래그로 명시합니다.
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" 문자열이 미국 문화권과 독일 문화권에서 완전히 다른 값으로 파싱됩니다.
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
public static int ParseWithTry(string input)
{
try
{
return int.Parse(input);
}
catch (FormatException)
{
return 0;
}
}
.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
public static int ParseWithTryParse(string input)
{
if (int.TryParse(input, out int n))
{
return n;
}
return 0;
}
.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를 명시하면
public static double ParsePrice(string input)
{
return double.Parse(input, NumberStyles.Number, CultureInfo.InvariantCulture);
}
.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
// ❌ Before — Unity InputField에서 들어온 텍스트를 Parse로 변환
public void OnSubmit(string ageText)
{
int age = int.Parse(ageText); // 빈 문자열·"pizza"·"99999999999" 모두 예외
player.Age = age;
}
// ✅ 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이나 큰 로그 라인에서 일부분만 파싱해야 할 때, Substring은 새 string 객체를 힙에 만듭니다. 매 프레임 호출되면 GC 압박이 됩니다.
// ❌ 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을 만들지 않는다
.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.Parse에 ReadOnlySpan<char>를 받는 오버로드가 추가되었습니다. 부분 문자열을 파싱할 때 새 string을 만들지 않는 표준 패턴입니다.
4.3 try-catch 비용 측정 — 정말 1,000배인가
마이크로 벤치마크에서 같은 작업(잘못된 문자열을 처리)을 두 방식으로 1만 회 반복하면 다음과 같은 차이가 납니다(.NET 8, 64bit, Release).
| 방식 | 1만 회 소요 시간(대략) | 상대 비용 |
|---|---|---|
int.TryParse("abc", out _) |
~0.5 ms | 1× |
try { int.Parse("abc"); } catch { } |
~500 ms | 1,000× |
CLR이 FormatException 인스턴스를 만들고 스택 트레이스를 수집하고 GC 추적 큐에 등록하는 비용이 누적된 결과입니다. 유효한 입력만 들어오는 코드라면 두 방식의 차이는 무시할 수 있지만, 잘못된 입력이 자주 들어오는 경로(사용자 UI·네트워크 파싱)에서는 TryParse가 압도적으로 유리합니다.
5. 함정과 주의사항
5.1 ❌ 빈 문자열을 null처럼 취급
// ❌ 빈 입력에 0이 들어가지만, 실제로는 ArgumentNullException(input이 null) 또는 FormatException(input이 "")
int age = int.Parse(input);
// ✅ 빈/null 분기 명시
if (string.IsNullOrWhiteSpace(input)) {
return defaultAge;
}
if (!int.TryParse(input, out int age)) {
return defaultAge;
}
return age;
5.2 ❌ 문화권 가정
// ❌ 한국 사용자 PC에서 잘 되던 코드가 독일 사용자 PC에서 1.5 → 15 로 폭주
double speed = double.Parse(text); // 사용자 문화권에 따라 다른 결과
// ✅ 데이터 파싱은 항상 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
// ❌ "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 무시
// ❌ "99999999999"처럼 int 범위를 넘는 숫자는 OverflowException
int n = int.Parse("99999999999");
// ✅ TryParse는 범위 초과도 false로 반환
if (!int.TryParse("99999999999", out int n)) {
// 범위 초과 또는 형식 오류 모두 여기로
}
// 또는 더 큰 타입 사용
long big = long.Parse("99999999999");
5.5 ❌ Unity 핫패스에서 Substring + Parse
// ❌ 매 프레임 새 string 2개 할당
void Update() {
string url = networkSession.LastUrl;
string idStr = url.Substring(url.LastIndexOf('/') + 1); // 새 string
int id = int.Parse(idStr);
}
// ✅ 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 빌드에서도 Substring을 AsSpan으로 바꾸는 것만으로 핫패스 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# 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 직접 파싱을 가능케 한 새 표준이다.
