[PART11.입출력 기본(1/7)] Console.WriteLine · Console.Write · 포맷 — 콘솔에 어떻게 출력되는가
Console.Write/WriteLine의 줄바꿈 차이 / 합성 포맷팅 "{0}"의 박싱 비용 / 문자열 보간 $"x={x}"이 컴파일러에서 어떻게 변환되는가 / Unity 모바일에서 Debug.Log가 GC를 부르는 이유
목차
1. 문제 제기 — "그냥 Console.WriteLine 쓰면 안 되나요?"
Console.WriteLine($"플레이어 HP: {hp}") 한 줄, 처음 보면 평범한 디버그 코드입니다. 그런데 같은 한 줄도 어떻게 쓰느냐에 따라 결과가 달라집니다.
// (1) 합성 포맷팅 — params object[] 오버로드를 거친다
Console.WriteLine("플레이어 HP: {0}", hp);
// (2) 문자열 보간 — C# 10 이후 완전히 다른 IL을 만든다
Console.WriteLine($"플레이어 HP: {hp}");
겉으로 보면 둘 다 같은 결과를 출력합니다. 그러나 IL(Intermediate Language, C# 컴파일 결과로 만들어지는 .NET 중간 언어) 레벨에서 (1)은 int 값을 박싱(boxing, 값 타입을 힙에 올려 object로 감싸는 동작)해서 객체 배열에 넣고, (2)는 박싱을 하지 않습니다.
Unity 모바일 게임에서 Update() 안에 이런 로그가 들어가면 어떻게 될까요. 60fps 기준 1초에 60번, 게임 한 시간이면 수십만 번 호출됩니다. 박싱이 일어나는 코드는 매번 힙(heap, GC가 관리하는 동적 메모리 영역)에 쓰레기를 만들고, 어느 순간 GC(Garbage Collector, 사용하지 않는 객체를 자동으로 회수하는 런타임 구성요소)가 작동하면서 프레임이 뚝 떨어집니다.
이 글은 단순히 "콘솔에 출력하는 법"을 넘어, 콘솔 출력이 내부적으로 어떻게 동작하고, 포맷팅 방식 하나하나가 메모리에 어떤 영향을 주는지 다룹니다. C# 입문 단계에서 이 내부 구조를 한 번이라도 들여다본 사람과 그렇지 않은 사람은, Unity 핫패스를 만질 때부터 코드 품질이 갈립니다.
2. 개념 정의 — Write와 WriteLine, 그리고 "콘솔이라는 가상의 종이"
2.1 Write vs WriteLine — 줄바꿈 한 글자 차이
Console.Write와 Console.WriteLine의 차이는 출력 끝에 줄바꿈 문자를 붙이느냐 안 붙이느냐입니다.

Console.Write("Hello, ");
Console.Write("World!");
Console.WriteLine(); // 줄바꿈만 출력
Console.WriteLine("New line."); // 텍스트 + 줄바꿈
// 출력:
// Hello, World!
// New line.
WriteLine이 붙이는 줄바꿈 문자는 운영체제마다 다른 값입니다.
| 플랫폼 | Environment.NewLine |
|---|---|
| Windows | \r\n (CR + LF) |
| Linux / macOS / Android / iOS | \n (LF) |
직접 \n을 박아 넣지 않고 WriteLine을 쓰는 이유는, 같은 코드가 PC 빌드와 모바일 빌드에서 모두 정상적으로 줄바꿈되기를 바라기 때문입니다.
2.2 합성 포맷팅 — {0} + {1} = {2}
합성 포맷팅(Composite Formatting) 은 포맷 문자열 안의 {N} 자리에 인자를 순서대로 끼워 넣는 방식입니다. .NET의 가장 오래된 포맷팅 방식이며, 내부적으로 string.Format이 처리합니다.
int a = 3, b = 5;
Console.WriteLine("{0} + {1} = {2}", a, b, a + b);
// 출력: 3 + 5 = 8
자릿수·통화·16진수 같은 형식은 콜론(:) 뒤의 포맷 지정자로 제어합니다.
Console.WriteLine("{0:N2}", 1234.5678); // 1,234.57 (천 단위 쉼표 + 소수 2자리)
Console.WriteLine("{0:X4}", 255); // 00FF (16진수 4자리, 앞을 0으로 패딩)
Console.WriteLine("{0:C}", 1500); // ₩1,500 (현재 문화권의 통화)
Console.WriteLine("{0,10}", "Hi"); // Hi (오른쪽 정렬, 10칸)
Console.WriteLine("{0,-10}|", "Hi"); // Hi | (왼쪽 정렬, 10칸)
2.3 문자열 보간 — $"x={x}"
문자열 보간(String Interpolation) 은 C# 6에서 추가된 문법으로, 변수를 문자열 안에 직접 박아 넣을 수 있게 해 줍니다.
$— 문자열 보간 접두사 (String interpolation prefix) 문자열 리터럴 앞에$를 붙이면{}안의 식이 평가되어 그 자리에 박힙니다. C# 6에서 도입.
예시:$"x = {x}"—x의 현재 값이x =뒤에 들어간다
int a = 3, b = 5;
Console.WriteLine($"{a} + {b} = {a + b}");
// 출력: 3 + 5 = 8
포맷 지정자도 그대로 쓸 수 있습니다.
double price = 1234.5678;
Console.WriteLine($"{price:N2}"); // 1,234.57
Console.WriteLine($"{255:X4}"); // 00FF
문법만 보면 합성 포맷팅과 별 차이가 없어 보이지만, 컴파일러가 만드는 IL은 두 방식이 완전히 다릅니다. 그 차이를 4번 섹션에서 IL 레벨에서 직접 비교합니다.
3. 내부 동작 — Console은 사실 TextWriter다
3.1 Console.Out 추상화 계층
Console.WriteLine은 정적 메서드처럼 보이지만, 내부적으로는 Console.Out이라는 TextWriter 객체에 위임됩니다.

각 계층의 역할:
System.Console—Out,In,Error세 개의 정적 프로퍼티를 노출하는 정적 클래스.WriteLine은 단지Out.WriteLine의 단축 표현입니다.SyncTextWriter—TextWriter를 한 번 더 감싼 동기화 래퍼. 호출마다lock을 걸어 여러 스레드에서 동시에 호출해도 한 줄이 다른 스레드 출력에 끼어들지 않게 막습니다.- 표준 출력 스트림 — 운영체제의
stdout핸들. 콘솔 창에 표시되거나, 리다이렉션됐다면 파일·파이프로 흘러갑니다.
Console.SetOut을 호출하면 이 흐름의 가장 위쪽 Out을 다른 TextWriter로 갈아끼울 수 있습니다.
using var sw = new StreamWriter("log.txt");
Console.SetOut(sw);
Console.WriteLine("이 줄은 콘솔이 아니라 log.txt 로 들어갑니다.");
Unity의 Debug.Log도 비슷한 구조로, 내부적으로 메시지를 어디로 보낼지(에디터 콘솔, 플레이어 로그 파일, 모바일 logcat) 결정하는 라우팅 계층이 있다고 생각하면 됩니다.
3.2 스레드 안전성은 줄 단위만
SyncTextWriter가 lock을 걸어 주긴 하지만, 보장하는 것은 하나의 WriteLine 호출이 끊기지 않는 것까지입니다. 두 줄의 출력 순서는 스레드 스케줄링에 따라 뒤바뀔 수 있습니다.
Task.Run(() => Console.WriteLine("[A] 시작"));
Task.Run(() => Console.WriteLine("[B] 시작"));
// 출력 순서는 [A]→[B] 또는 [B]→[A]
// 단, 한 줄이 "[A] [B] 시작 시작" 식으로 섞이지는 않음
4. 실전 적용 — 합성 포맷팅 vs 문자열 보간, IL이 보여주는 결정적 차이
4.1 같은 결과, 다른 IL
같은 출력을 만드는 두 코드를 IL로 비교해 봅니다. 이 차이가 Unity 모바일 핫패스에서 GC 압박을 결정합니다.
// Before — 합성 포맷팅
public static void CompositeFormat()
{
int a = 3;
int b = 5;
Console.WriteLine("{0} + {1} = {2}", a, b, a + b);
}
// After — 문자열 보간 (C# 10+)
public static void Interpolation()
{
int a = 3;
int b = 5;
Console.WriteLine($"{a} + {b} = {a + b}");
}
이 두 메서드를 컴파일해 IL을 추출하면 다음과 같습니다.
.method public hidebysig static void CompositeFormat () cil managed
{
.maxstack 5
.locals init (
[0] int32,
[1] int32
)
IL_0000: nop
IL_0001: ldc.i4.3 // a = 3
IL_0002: stloc.0
IL_0003: ldc.i4.5 // b = 5
IL_0004: stloc.1
IL_0005: ldstr "{0} + {1} = {2}" // 포맷 문자열을 스택에 로드
IL_000a: ldloc.0
IL_000b: box [System.Runtime]System.Int32 // ★ a를 힙에 박싱
IL_0010: ldloc.1
IL_0011: box [System.Runtime]System.Int32 // ★ b를 힙에 박싱
IL_0016: ldloc.0
IL_0017: ldloc.1
IL_0018: add
IL_0019: box [System.Runtime]System.Int32 // ★ a+b를 힙에 박싱
IL_001e: call void [System.Console]System.Console::WriteLine(string, object, object, object)
IL_0023: nop
IL_0024: ret
}
.method public hidebysig static void Interpolation () cil managed
{
.maxstack 3
.locals init (
[0] int32,
[1] int32,
[2] valuetype [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler // 핸들러를 스택에 둔다
)
IL_0000: nop
IL_0001: ldc.i4.3
IL_0002: stloc.0
IL_0003: ldc.i4.5
IL_0004: stloc.1
IL_0005: ldloca.s 2 // 핸들러 주소를 스택에 로드
IL_0007: ldc.i4.6 // literalLength = 6 ( " + " + " = " 길이 )
IL_0008: ldc.i4.3 // formattedCount = 3
IL_0009: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_000e: ldloca.s 2
IL_0010: ldloc.0
IL_0011: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // ★ 박싱 없음
IL_0017: ldloca.s 2
IL_0019: ldstr " + "
IL_001e: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0024: ldloca.s 2
IL_0026: ldloc.1
IL_0027: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // ★ 박싱 없음
IL_002d: ldloca.s 2
IL_002f: ldstr " = "
IL_0034: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_003a: ldloca.s 2
IL_003c: ldloc.0
IL_003d: ldloc.1
IL_003e: add
IL_003f: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // ★ 박싱 없음
IL_0045: ldloca.s 2
IL_0047: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()
IL_004c: call void [System.Console]System.Console::WriteLine(string)
IL_0052: ret
}
4.2 IL 분석 포인트
1. box [System.Runtime]System.Int32 (합성 포맷팅에 3번 등장)
Console.WriteLine(string, object, object, object) 오버로드가 인자를 object로 받기 때문에, 값 타입인 int를 object로 변환하기 위해 힙에 새 int 객체를 만들고 그 안에 값을 복사합니다. 매 호출마다 객체 3개가 새로 생기는 셈입니다. Unity Update 루프에서 60fps × 3개 = 초당 180개의 박스 객체가 GC 큐에 쌓입니다.
2. DefaultInterpolatedStringHandler (보간식의 핵심)
C# 10부터 컴파일러가 $"..."를 만나면 이 구조체를 만들고 AppendLiteral(리터럴 조각)·AppendFormatted<T>(변수 조각)로 한 조각씩 누적한 뒤 마지막에 ToStringAndClear()로 결과 string을 뽑아냅니다. 핸들러는 valuetype(즉, struct)이라 스택 위에서 처리되며, 내부 버퍼도 미리 길이를 계산해 한 번에 잡아 줍니다.
3. AppendFormatted<int32>(!!0) — 박싱이 사라진 결정적 이유
AppendFormatted는 제네릭 메서드입니다. int를 넘기면 T = int32로 추론되어 박싱 없이 바로 값이 전달됩니다. 합성 포맷팅의 params object[]와 결정적으로 다른 부분입니다.
4. Console.WriteLine(string, object, object, object) vs Console.WriteLine(string)
합성 포맷팅은 4개 인자 오버로드를, 보간식은 단순한 1개 인자(string) 오버로드를 호출합니다. 보간식 쪽은 핸들러가 이미 완성된 string을 만들어 넘기기 때문입니다.
4.3 Unity 실전: 핫패스에서 어느 쪽을 쓸 것인가
// ❌ Before — Update에서 매 프레임 박싱 3회
void Update() {
if (debugMode)
Debug.Log(string.Format("HP: {0} / MP: {1}", player.hp, player.mp));
}
// ✅ After — C# 10 보간식, 박싱 없음
void Update() {
if (debugMode)
Debug.Log($"HP: {player.hp} / MP: {player.mp}");
}
주의: 보간식의 박싱 제거는 C# 10(.NET 6) 컴파일러부터입니다. Unity 버전마다 지원 가능한 C# 언어 버전이 다르므로(예: Unity 2022.3 LTS는 C# 9 기준), 프로젝트의langVersion이나Edit > Project Settings > Player > Other Settings > Api Compatibility Level을 먼저 확인하세요. C# 10 미만 환경에서는 보간식도 내부적으로string.Format위임이라 박싱이 그대로 발생합니다.
핫패스에서 더 강한 처방이 필요하면 [Conditional] 속성으로 호출 자체를 컴파일 타임에 제거합니다.
using System.Diagnostics;
public static class Log {
[Conditional("UNITY_EDITOR")]
public static void DevOnly(string msg) {
UnityEngine.Debug.Log(msg);
}
}
// 릴리스 빌드에서는 호출 코드 자체가 IL에서 사라진다
Log.DevOnly($"HP: {player.hp}");
5. 함정과 주의사항
5.1 ❌ 인덱스 불일치 — 합성 포맷팅의 런타임 예외
// ❌ 컴파일은 통과하지만 실행하는 순간 FormatException
Console.WriteLine("{0} + {1} = {2}", 3, 5); // 인자 2개인데 {2}를 요구
합성 포맷팅은 인덱스가 인자 개수를 넘으면 런타임에 FormatException을 던집니다. 컴파일러는 잡아 주지 않습니다.
// ✅ 보간식은 컴파일 단계에서 변수가 검증된다
Console.WriteLine($"{a} + {b} = {a + b}");
5.2 ❌ 문화권 의존 버그 — 같은 코드, 다른 결과
{0:N2}, {0:C} 같은 포맷 지정자는 현재 스레드의 CultureInfo 를 따라갑니다. 같은 코드라도 한국어 OS와 독일어 OS에서 결과가 다릅니다.
double price = 1234.5;
// 한국 사용자 PC
Console.WriteLine($"{price:N2}"); // 1,234.50
// 독일 사용자 PC
Console.WriteLine($"{price:N2}"); // 1.234,50 (소수점이 쉼표!)
게임 서버 통신·세이브 파일·로그처럼 기계가 읽어야 하는 출력은 반드시 CultureInfo.InvariantCulture를 명시해야 합니다.
using System.Globalization;
// ❌ 사용자 문화권에 따라 "1.234,50" 같은 값이 들어가 파싱 실패
File.WriteAllText("save.txt", $"{score:N2}");
// ✅ 항상 같은 형식
File.WriteAllText("save.txt", string.Create(CultureInfo.InvariantCulture, $"{score:N2}"));
string.Create(IFormatProvider, ref handler) 오버로드는 컴파일러가 보간 핸들러를 그대로 이어 주면서 문화권만 강제합니다. IL을 보면 AppendFormatted<float64>(!!0, "N2") 호출이 그대로 살아 있어, 문화권 지정에도 박싱이 없다는 것을 확인할 수 있습니다.
IL_0014: ldloca.s 1
IL_0016: ldloc.0
IL_0017: ldstr "N2"
IL_001c: call instance void DefaultInterpolatedStringHandler::AppendFormatted<float64>(!!0, string)
5.3 ❌ 비활성 로그도 비용을 지불한다
// ❌ Logger 내부에서 IsEnabled를 검사해도, 보간 문자열은 이미 만들어진 뒤다
logger.LogDebug($"플레이어 좌표: {transform.position}");
LogDebug가 내부에서 "Debug 레벨이 꺼져 있으면 무시"한다고 해도, 메서드를 호출하기 전에 이미 $"..." 문자열이 평가됩니다. 보간식이 박싱은 없을지언정, string 자체는 매번 새로 만들어지므로 GC 입장에서는 여전히 쓰레기입니다.
// ✅ 메시지 템플릿 패턴 — 비활성 로그는 인자조차 평가하지 않는다
logger.LogDebug("플레이어 좌표: {Position}", transform.position);
// ✅ Unity 핫패스라면 [Conditional]로 호출 자체 제거
[Conditional("UNITY_EDITOR")]
static void DebugLog(string msg) => UnityEngine.Debug.Log(msg);
5.4 ❌ Unity 모바일에서 Debug.Log 그대로 두기
릴리스 빌드에서도 Debug.Log는 사라지지 않습니다. 단말기에는 어딘가 로그가 쌓이고, 문자열 보간 평가도 매번 일어납니다. 출시 전에 다음 중 하나를 적용해야 합니다.
| 방법 | 효과 |
|---|---|
Debug.unityLogger.logEnabled = false |
호출은 살아 있지만 출력만 막음 (문자열 평가 비용은 그대로) |
자체 [Conditional] 래퍼 |
호출 자체를 컴파일러가 제거 (가장 깨끗) |
| Player Settings → Stack Trace → "None" | 스택 트레이스 수집 비용 차단 |
6. C# 버전별 변화 — 보간식이 진화한 흔적
| C# 버전 | 출시 | 보간식의 컴파일 결과 |
|---|---|---|
| 6.0 (2015) | 보간식 도입 | $"x = {x}" → string.Format("x = {0}", x)로 변환. 박싱 발생 |
| 10.0 (2021, .NET 6) | 핸들러 패턴 도입 | DefaultInterpolatedStringHandler로 변환. 박싱 제거 + 단일 할당 |
| 11.0 (2022) | 줄바꿈 허용 | 보간식 안에서 멀티라인·표현식 분리 가능 |
Before — C# 9 이전의 보간식
C# 9에서 같은 코드는 사실상 합성 포맷팅과 동일한 IL을 만들었습니다.
int a = 3, b = 5;
Console.WriteLine($"{a} + {b} = {a + b}");
는 컴파일러가 다음과 같이 변환했습니다.
Console.WriteLine(string.Format("{0} + {1} = {2}", (object)a, (object)b, (object)(a + b)));
→ IL 레벨에서는 4번 섹션의 CompositeFormat 코드와 똑같이 box 명령어 3번이 등장했습니다.
After — C# 10 이후의 보간식
C# 10부터 컴파일러는 다음과 같이 변환합니다.
var handler = new DefaultInterpolatedStringHandler(literalLength: 6, formattedCount: 3);
handler.AppendFormatted(a);
handler.AppendLiteral(" + ");
handler.AppendFormatted(b);
handler.AppendLiteral(" = ");
handler.AppendFormatted(a + b);
Console.WriteLine(handler.ToStringAndClear());
→ 4번 섹션의 Interpolation IL과 동일하게 박싱이 완전히 사라집니다.
이 변화는 단순한 문법 설탕이 아니라 사용자 정의 핸들러까지 확장 가능한 메커니즘으로, 로깅 라이브러리나 SQL 빌더에서 "필요할 때만 문자열을 만드는" 최적화의 토대가 됐습니다.
7. 정리 — 이것만 기억하라
Console.Write는 줄바꿈 없이,Console.WriteLine은Environment.NewLine을 붙여 출력한다. 플랫폼 의존 줄바꿈은 직접 박지 말고WriteLine에 맡긴다.Console.WriteLine은Console.Out(SyncTextWriter)에 위임한다. 줄 단위 스레드 안전은 보장되지만 줄 사이 순서는 보장되지 않는다.- 합성 포맷팅
"{0}"은string.Format을 거쳐params object[]로 박싱한다. 값 타입을 넘길 때마다 힙 객체가 생긴다. - C# 10 이상의 보간식
$"..."은DefaultInterpolatedStringHandler구조체를 통해 박싱 없이 처리된다. 같은 출력에도 IL은 완전히 다르다. {0:N2},{0:C}같은 포맷 지정자는 현재 스레드의CultureInfo를 따른다. 기계가 읽어야 하는 출력은CultureInfo.InvariantCulture로 강제한다.- Unity 모바일 핫패스에서는 보간식조차도 매 프레임
string을 만든다. 디버그용 로그는[Conditional]속성으로 호출 자체를 빌드에서 제거한다. - 로그 라이브러리는 메시지 템플릿 패턴(
"좌표: {Position}", pos)을 사용해 비활성 레벨에서는 문자열 평가 자체를 건너뛰게 한다.
