반응형

[PART11.입출력 기본(2/7)] Console.ReadLine · Console.ReadKey · Console.Read — 콘솔 입력의 세 가지 모양

ReadLine은 string, Read는 int, ReadKey는 ConsoleKeyInfo / Read가 char가 아닌 int를 반환하는 진짜 이유 / null·-1·intercept 같은 EOF·echo 패턴 / Unity에서는 왜 절대 쓰면 안 되는가


1. 문제 제기 — 입력을 받는 메서드가 왜 세 개나 있을까

C#에서 콘솔 입력을 받는 방법은 한 가지가 아닙니다. 비슷해 보이는 세 메서드가 있고, 반환 타입조차 모두 다릅니다.

C#
string? line = Console.ReadLine();          // 한 줄을 string으로
int     code = Console.Read();              // 한 글자를 int로
ConsoleKeyInfo info = Console.ReadKey();    // 키 한 개를 구조체로

이런 의문이 자연스럽게 따라옵니다.

  • Read는 한 글자를 읽는다는데, 왜 char가 아니라 int를 반환할까?
  • ReadLine이 가끔 null을 반환한다는데, 사용자가 빈 줄을 입력하는 것과 뭐가 다른가?
  • 비밀번호처럼 화면에 안 찍히는 입력은 어떻게 만드는가?
  • Unity 신입 개발자가 Console.ReadLine을 게임에 쓰면 어떻게 될까?

이 글은 세 메서드가 각각 어떤 추상화 위에 서 있고, 내부적으로 어떻게 동작하는지, 그리고 왜 Unity 메인 스레드에 그대로 가져다 쓰면 게임이 통째로 멈추는지를 한 번에 정리합니다.


2. 개념 정의 — 한 줄, 한 글자, 한 키

2.1 세 메서드 비교

Console 입력 메서드 3종 비교
메서드 반환 타입 읽는 단위 EOF 신호 입력 리다이렉션 가능
Console.ReadLine() string? 한 줄 (Enter까지) null
Console.Read() int 한 글자 -1
Console.ReadKey() ConsoleKeyInfo 키 한 개 (Enter 불필요) 없음 (예외 발생)

2.2 ReadLine — 가장 자주 쓰는 입력

C#
Console.Write("이름을 입력하세요: ");
string? name = Console.ReadLine();
Console.WriteLine($"안녕하세요, {name}!");

ReadLine은 사용자가 Enter를 누를 때까지 기다렸다가, 그동안 입력된 문자열을 반환합니다. 반환 타입에 ?가 붙은 것은 NRT(Nullable Reference Types, C# 8에서 도입된 nullable 참조 타입 검사 기능) 기준으로 입력 스트림이 끝나면 null을 반환할 수 있기 때문입니다.

? (참조 타입 뒤) — 널 허용 참조 타입 (Nullable reference type) 변수가 null이 될 수 있다고 컴파일러에게 명시. NRT 가 켜져 있으면 null 가능 변수에 null 검사 없이 접근하면 경고가 발생한다.
예시: string? line = Console.ReadLine(); — line은 null일 수 있어 line?.Length 또는 if (line != null) 검사가 필요

2.3 Read — 한 글자를 int로

C#
int code = Console.Read();
if (code == -1) {
    Console.WriteLine("EOF 도달");
} else {
    char ch = (char)code;
    Console.WriteLine($"입력: {ch} (코드 {code})");
}

Read가 반환하는 것은 char가 아니라 int입니다. EOF(End Of File, 입력 스트림이 끝났음을 의미하는 신호)를 표현하기 위해서는 모든 유효한 문자 코드 값에 더해 추가로 "끝났다"는 값이 필요한데, char는 16비트라서 모든 비트를 유효 코드 포인트에 사용해 버립니다. int는 32비트라서 -1처럼 별개의 값을 EOF로 쓸 여유가 있습니다.

2.4 ReadKey — 키 한 개를 즉시

C#
Console.Write("아무 키나 누르세요... ");
ConsoleKeyInfo info = Console.ReadKey();
Console.WriteLine();
Console.WriteLine($"눌린 키: {info.Key}");           // ConsoleKey.A
Console.WriteLine($"문자: {info.KeyChar}");           // 'a'
Console.WriteLine($"수정자: {info.Modifiers}");        // Shift, Control 등

ReadKeyEnter 없이 즉시 반환됩니다. 게임 메뉴·실시간 단축키처럼 빠른 반응이 필요할 때 씁니다. intercept: true 매개변수를 주면 누른 키가 콘솔 화면에 표시되지 않아 비밀번호 입력에 쓸 수 있습니다.

C#
ConsoleKeyInfo info = Console.ReadKey(intercept: true);  // 화면에 안 찍힘

3. 내부 동작 — Console.In과 TextReader 추상화

3.1 Console.In은 사실 TextReader다

Console.WriteLineConsole.Out (TextWriter)에 위임하듯, 입력 메서드도 마찬가지로 Console.In이라는 TextReader에 위임됩니다.

콘솔 입력의 추상화 계층

이 추상화 덕분에 Console.SetIn(new StringReader("test\n")) 한 줄로 입력 소스를 메모리 문자열로 바꿀 수 있습니다. 단위 테스트에서 콘솔 입력을 시뮬레이션할 때 자주 쓰는 패턴입니다.

C#
using var sr = new StringReader("Alice\n42\n");
Console.SetIn(sr);

string? name = Console.ReadLine();   // "Alice"
string? age  = Console.ReadLine();   // "42"

3.2 ReadLine은 줄바꿈을 만날 때까지 버퍼링한다

키보드에서 한 글자씩 OS 호출을 하면 너무 비싸기 때문에, 내부 버퍼에 일정 분량을 쌓아 두고 ReadLine은 그 안에서 \n 또는 \r\n을 만날 때까지 문자를 누적한 뒤 한 번에 string으로 만들어 반환합니다.

이 때문에 Console.Read()가 한 글자만 받는 것 같아도 사용자는 보통 Enter를 눌러야 글자가 프로그램에 도달합니다. 키 자체는 OS 라인 버퍼에 머물고 있다가 Enter가 눌리는 순간 한꺼번에 .NET 버퍼로 흘러 들어옵니다.

3.3 ReadKey는 다른 길을 간다

ReadKeyConsole.In(TextReader)을 거치지 않습니다. 콘솔 호스트(Windows의 conhost, Unix의 termios)와 직접 통신하여 키 이벤트를 받습니다. 그래서 입력 리다이렉션이 일어나면 "키를 누른다"는 개념이 사라져 InvalidOperationException이 발생합니다.

C#
// 파이프로 실행: echo hello | dotnet run
Console.ReadKey();
// → System.InvalidOperationException:
//   Cannot read keys when either application does not have a console
//   or when console input has been redirected.

4. 실전 적용 — 멀티라인 처리와 패턴 매칭

4.1 EOF까지 읽는 두 가지 패턴

파이프나 리다이렉션된 파일에서 여러 줄을 읽어 처리하는 코드는 .NET 입문 단계에서 가장 먼저 만나는 관용 패턴입니다.

C#
// Before — 전통적 null 체크 패턴
public static void ClassicLoop()
{
    string? line;
    while ((line = Console.ReadLine()) != null)
    {
        Console.WriteLine(line);
    }
}

// After — C# 8 패턴 매칭
public static void PatternLoop()
{
    while (Console.ReadLine() is string line)
    {
        Console.WriteLine(line);
    }
}
is string line — 패턴 매칭 + 변수 선언 (Type pattern with declaration) 우변의 값이 해당 타입이면(즉, null이 아니면) line이라는 새 변수에 그 값이 들어가고 조건은 참이 된다. C# 7에서 도입.
예시: if (obj is string s) { use(s); }objstring일 때만 s로 안전하게 사용 가능

두 코드는 같은 일을 하지만 Before는 변수에 먼저 대입하고 null과 비교, After는 패턴 매칭으로 한 줄에 처리합니다. IL을 보면 컴파일러가 두 형태를 거의 같은 IL로 변환한다는 사실을 확인할 수 있습니다.

IL
.method public hidebysig static void ClassicLoop () cil managed
{
    .maxstack 2
    .locals init (
        [0] string,                                  // line
        [1] bool                                     // 비교 결과
    )

    IL_0000: nop
    IL_0001: br.s IL_000c                            // 처음에는 조건부터 검사
    // loop start
        IL_0003: nop
        IL_0004: ldloc.0
        IL_0005: call void [System.Console]System.Console::WriteLine(string)
        IL_000a: nop
        IL_000b: nop

        IL_000c: call string [System.Console]System.Console::ReadLine()  // ★ ReadLine 호출
        IL_0011: dup                                                      // 결과를 한 번 더 복제
        IL_0012: stloc.0                                                  // line 에 저장
        IL_0013: ldnull
        IL_0014: cgt.un                                                   // != null 검사
        IL_0016: stloc.1
        IL_0017: ldloc.1
        IL_0018: brtrue.s IL_0003
    IL_001a: ret
}
IL
.method public hidebysig static void PatternLoop () cil managed
{
    .maxstack 2
    .locals init (
        [0] string,                                  // line (패턴 매칭으로 도입된 변수)
        [1] bool
    )

    IL_0000: nop
    // loop start
        IL_0001: call string [System.Console]System.Console::ReadLine()  // ★ ReadLine 호출
        IL_0006: stloc.0                                                  // line 에 저장 (dup 없음)
        IL_0007: ldloc.0
        IL_0008: ldnull
        IL_0009: cgt.un                                                   // null 검사 == 타입 패턴 검사
        IL_000b: stloc.1
        IL_000c: ldloc.1
        IL_000d: brfalse.s IL_001a

        IL_000f: nop
        IL_0010: ldloc.0
        IL_0011: call void [System.Console]System.Console::WriteLine(string)
        IL_0016: nop
        IL_0017: nop
        IL_0018: br.s IL_0001
    IL_001a: ret
}

4.2 IL 분석 포인트

1. call string [System.Console]System.Console::ReadLine()string 직접 반환

보간식·합성 포맷팅과 달리 ReadLine은 박싱 없이 곧장 string을 돌려줍니다. 참조 타입이라 별도 변환이 없습니다.

2. 패턴 매칭은 dup 1개 차이뿐

컴파일러가 만든 IL을 보면 is string line 패턴은 단순 대입 + null 비교로 변환되어 있고, 전통적 패턴은 dup → stloc → ldnull → cgt.un 형태로 한 줄 안에서 같은 값을 두 번 쓰기 위해 dup을 사용합니다. 성능 차이는 사실상 없으며, 가독성을 위한 선택입니다.

3. cgt.un (unsigned greater than)으로 null 비교

참조 타입의 null 검사는 cgt.un 명령어로 처리됩니다. 참조 자체를 unsigned int로 비교해 0(null)보다 크면 1을 스택에 올립니다.

4.3 Read의 EOF 패턴 — int를 char로 변환

C#
public static void ReadByChar()
{
    int c;
    while ((c = Console.Read()) != -1)
    {
        char ch = (char)c;
        Console.Write(ch);
    }
}
IL
.method public hidebysig static void ReadByChar () cil managed
{
    .maxstack 2
    .locals init (
        [0] int32,                                   // c (Read 반환값)
        [1] char,                                    // ch (캐스팅 결과)
        [2] bool
    )

    IL_0000: nop
    IL_0001: br.s IL_000f
    // loop start
        IL_0003: nop
        IL_0004: ldloc.0
        IL_0005: conv.u2                             // ★ int32 → uint16(char) 명시 변환
        IL_0006: stloc.1
        IL_0007: ldloc.1
        IL_0008: call void [System.Console]System.Console::Write(char)
        IL_000d: nop
        IL_000e: nop

        IL_000f: call int32 [System.Console]System.Console::Read()  // ★ int32 반환
        IL_0014: dup
        IL_0015: stloc.0
        IL_0016: ldc.i4.m1                                          // ★ -1 (EOF) 비교
        IL_0017: ceq
        IL_0019: ldc.i4.0
        IL_001a: ceq                                                // != -1 의 이중 부정
        IL_001c: stloc.2
        IL_001d: ldloc.2
        IL_001e: brtrue.s IL_0003
    IL_0020: ret
}

4. int32 Read() 반환 + ldc.i4.m1로 -1 비교

Readint를 반환하기 때문에 m1 상수(IL의 -1)와 비교할 수 있습니다. char였다면 -1을 표현할 자리가 없어 EOF 신호를 줄 수 없었을 겁니다.

5. conv.u2 — int → char 명시 변환

(char)c 캐스팅이 IL에서 conv.u2로 나타납니다. unsigned 16비트로 잘라내는 변환입니다.

4.4 Unity 신입이 만나는 가장 큰 함정

C#
// ❌ Before — Unity의 MonoBehaviour 안에서 Console.ReadLine을 호출
public class Player : MonoBehaviour
{
    void Start()
    {
        string name = Console.ReadLine();  // ← 메인 스레드 정지!
        Debug.Log($"이름: {name}");
    }
}

이 코드는 컴파일은 되지만 Unity 에디터·빌드 모두에서 게임 화면이 통째로 멈춥니다. Console.ReadLine은 입력이 들어올 때까지 호출 스레드를 블로킹하는데, Unity의 메인 스레드가 멈추면 렌더링·물리 시뮬레이션·다른 모든 스크립트가 함께 정지합니다. 모바일에서는 워치독에 잡혀 앱이 강제 종료될 수 있습니다.

C#
// ✅ After — Unity는 폴링 기반 입력을 쓴다
public class Player : MonoBehaviour
{
    string name = "Player";

    void Update()
    {
        if (Input.GetKeyDown(KeyCode.Return))
        {
            // UI 또는 InputField에서 텍스트를 읽어 처리
            Debug.Log($"이름: {name}");
        }
    }
}

Unity의 Input.GetKeyDown매 프레임 "이번 프레임에 키가 눌렸는가?"를 묻는 폴링 방식이라 절대 블로킹하지 않습니다. 콘솔 앱에서 익숙해진 사고방식을 그대로 옮기지 말아야 합니다.


5. 함정과 주의사항

5.1 ❌ ReadLine null 체크 누락

C#
// ❌ NullReferenceException 위험
string line = Console.ReadLine();
int len = line.Length;          // EOF면 line이 null → 폭발

NRT가 켜진 프로젝트라면 컴파일러가 경고를 띄워 줍니다. 그래도 무시하고 !(null-forgiving 연산자)로 막으면 런타임에서 터집니다.

C#
// ✅ null을 명시적으로 처리
string? line = Console.ReadLine();
if (line is null) {
    Console.WriteLine("입력 없음 (EOF)");
    return;
}
Console.WriteLine($"길이: {line.Length}");

5.2 ❌ Read의 -1 무시

C#
// ❌ EOF 검사 없이 (char) 캐스팅
int c = Console.Read();
char ch = (char)c;  // EOF면 c == -1 → ch == '￿' (잘못된 글자)

EOF에서 -1을 그대로 char로 캐스팅하면 ￿가 되어 정상 문자처럼 처리됩니다. 반드시 -1 검사를 먼저 합니다.

5.3 ❌ ReadKey를 파이프 입력에서 호출

C#
// 실행: echo "hello" | dotnet run
ConsoleKeyInfo info = Console.ReadKey();
// → InvalidOperationException

CLI 도구는 종종 셸 파이프라인에서 호출되므로, 파이프 가능성이 있는 코드라면 ReadKey 대신 ReadLine 또는 Read를 사용합니다. 굳이 ReadKey를 써야 한다면 Console.IsInputRedirected로 먼저 확인합니다.

C#
if (Console.IsInputRedirected)
{
    string? line = Console.ReadLine();   // 안전 경로
}
else
{
    ConsoleKeyInfo info = Console.ReadKey();  // 대화형 콘솔에서만
}

5.4 ❌ 비밀번호를 ReadLine으로 받기

C#
// ❌ 입력이 화면에 그대로 찍힌다
Console.Write("비밀번호: ");
string? pwd = Console.ReadLine();

ReadLine은 echo 차단 옵션이 없습니다. 비밀번호는 ReadKey(intercept: true)로 한 글자씩 받아 StringBuilder에 누적합니다.

C#
// ✅ echo 차단 + Backspace 처리
Console.Write("비밀번호: ");
var pwd = new StringBuilder();
while (true)
{
    ConsoleKeyInfo k = Console.ReadKey(intercept: true);
    if (k.Key == ConsoleKey.Enter) break;
    if (k.Key == ConsoleKey.Backspace && pwd.Length > 0)
    {
        pwd.Length--;
        Console.Write("\b \b");          // 백스페이스 한 글자 지우기
    }
    else if (!char.IsControl(k.KeyChar))
    {
        pwd.Append(k.KeyChar);
        Console.Write('*');
    }
}
Console.WriteLine();

이 함수의 IL을 보면 Console.ReadKeyConsoleKeyInfo 구조체를 박싱 없이 스택 슬롯에 저장하는 것을 확인할 수 있습니다.

IL
IL_0001: ldc.i4.1                                                      // intercept = true
IL_0002: call valuetype System.ConsoleKeyInfo Console::ReadKey(bool)
IL_0007: stloc.0                                                       // ★ 스택의 struct를 지역 변수에 그대로 저장
IL_0008: ldloca.s 0                                                    // ★ 주소를 로드(복사 회피)
IL_000a: call instance valuetype System.ConsoleKey ConsoleKeyInfo::get_Key()
IL_000f: ldc.i4.s 13                                                   // ConsoleKey.Enter == 13
IL_0011: ceq

6. ldloca.s로 struct 주소 로드

ConsoleKeyInfo는 struct이므로 메서드를 호출할 때 ldloc(값 복사) 대신 ldloca(주소 로드)를 씁니다. 박싱이 없는 핫패스 패턴입니다.

5.5 ❌ Ctrl+C에 무방비

콘솔 도구가 파일을 쓰는 도중에 사용자가 Ctrl+C를 누르면 데이터가 손상될 수 있습니다. Console.CancelKeyPress 이벤트로 우아한 종료를 구현합니다.

C#
var cts = new CancellationTokenSource();

Console.CancelKeyPress += (s, e) =>
{
    e.Cancel = true;             // 기본 종료 동작 취소
    cts.Cancel();                // 취소 토큰 발동
    Console.WriteLine("\n종료 중... 자원 정리");
};

while (!cts.IsCancellationRequested)
{
    string? line = Console.ReadLine();
    if (line is null) break;
    // 처리
}

6. C# 버전별 변화

C# 버전 변경점
8.0 (2019) NRT 도입 — Console.ReadLine()의 반환 타입이 string?로 표기됨 (런타임 동작은 동일)
8.0 (2019) is string line 같은 타입 패턴 + 변수 선언 — 멀티라인 입력의 가독성 향상
11.0 (2022) 패턴 매칭 확장 — is { Length: > 0 } line 으로 빈 줄 제외 가능
C#
// C# 11 — 빈 줄 자동 제외
while (Console.ReadLine() is { Length: > 0 } line)
{
    // line은 null도 아니고 빈 문자열도 아닌 경우만 들어온다
    Console.WriteLine(line);
}

Console.ReadLine·Read·ReadKey 자체의 시그니처는 .NET 1.0부터 거의 변하지 않았습니다. 변한 것은 언어가 이들을 호출할 때 작성하는 패턴의 표현력입니다.


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

  • 세 메서드의 목적이 다르다. 한 줄은 ReadLine(string), 한 글자는 Read(int), 한 키는 ReadKey(ConsoleKeyInfo).
  • Readint를 반환하는 이유는 EOF(-1)을 표현하기 위해서다. char 16비트 안에는 EOF 자리가 없다.
  • ReadLine은 EOF에서 null을 반환한다. NRT를 켜고 항상 null 검사를 한다.
  • Console.InTextReader 다. 단위 테스트에서는 Console.SetIn(new StringReader(...)) 으로 입력을 주입한다.
  • ReadKey는 콘솔 호스트와 직접 통신한다. 입력이 리다이렉트되면 InvalidOperationException. 파이프 가능성이 있으면 Console.IsInputRedirected를 먼저 검사한다.
  • 비밀번호는 ReadKey(intercept: true) 로 한 글자씩 받아 StringBuilder에 누적한다. ReadLine은 echo 차단 옵션이 없다.
  • Unity 메인 스레드에서 Console.ReadLine을 호출하면 게임이 통째로 멈춘다. Unity는 폴링 기반 Input.GetKeyDown/Input System을 쓴다.
  • 콘솔 도구는 Console.CancelKeyPress 로 Ctrl+C를 가로채 우아한 종료 경로를 만든다. e.Cancel = true를 잊지 않는다.
반응형

+ Recent posts