본문 바로가기

C# 콘솔에서 멀티 스레드로 키 입력 처리하기

@코야딩구2025. 7. 10. 22:06

1. 멀티스레드 키 입력이 필요한 이유

- C# 콘솔에서는 기본적으로 Console.ReadKey()가 입력이 올 때까지 대기한다.
- 하지만 게임은 매 프레임마다 움직임을 처리해야 하므로, 키 입력을 기다리는 동안 멈추면 게임도 멈춘다.

- 해결방법으로는 메인 스레드에서는 루프를 돌며 계속 움직임을 처리하고, 키 입력별도의 스레드에서 처리하여, 입력과 움직임을 분리하면 된다.

- 아래는 스네이크 게임 구현을 기반으로, C# 콘솔에서 멀티스레드 키 입력을 처리하는 방법에 대해 얘기해 보려 한다.

2.  기본 구조: 메인 루프 + 입력 스레드

- 프로그램 시작 시, 메인 스레드에서 입력 전용 스레드를 생성한다.

- 메인 스레드는 10ms 주기로 게임을 갱신하며 게임 로직(움직임)출력만 담당한다.

- 입력은 입력 스레드에서 ReadInput() 함수를 통해 따로 감지한다.

int dir = 3; // 0: ↑, 1: ↓, 2: ←, 3: →
volatile bool canInput = true;

void main()
{
    Thread inputThread = new Thread(ReadInput);
    inputThread.IsBackground = true;
    inputThread.Start();

    while (true)
    {
        PlayerMove();
		ShowGameScene();
        Thread.Sleep(10); // 게임 프레임 조절, 현재 목표 FPS 100
    }
}
void ReadInput() { // while() 키 입력 받기}

3. 방향 입력을 1회만 받기: canInput 플래그

- 스네이크 게임의 특성상, 뱀의 움직임마다 1번만 방향 입력을 허용해야 한다.

- 여러 방향키를 빠르게 누르면 다음과 같은 문제가 생긴다:

  예: 뱀이 왼쪽(←)으로 가는 중, 사용자가 위(↑) → 오른쪽(→) 연속 입력, 결과적으로 뱀이 오른쪽으로 회전하고, 자기 몸에 충돌하여 즉사한다.

- 이 문제를 해결하기 위해 canInput이라는 플래그를 도입했다.

  1) 입력을 받으면 canInput = false

  2) 뱀이 움직인 후에야 canInput = true 로 다시 입력 허용

- 또한, canInput에는 volatile 키워드를 붙여, 스레드 간 최신 값 동기화를 보장한다.

void ReadInput()
{
    while (while (gameOverSte == GameOverState.Playing) // 게임이 끝나면 이 쓰레드도 끝날 수 있도록)
    {
        if (!canInput)
        {
            Thread.Sleep(1); // CPU 과다 점유 방지
            continue;
        }
        // 키가 눌렸을 때만 키 입력 받기
		if (!Console.KeyAvailable) continue;
        
        var key = Console.ReadKey(true).Key;
        switch (key) // 반대 방향 키 입력을 금지하여, 뱀 증사 방지
        {
            case ConsoleKey.UpArrow:
                if (dir != 1) dir = 0;
                break;
            case ConsoleKey.DownArrow:
                if (dir != 0) dir = 1;
                break;
            case ConsoleKey.LeftArrow:
                if (dir != 3) dir = 2;
                break;
            case ConsoleKey.RightArrow:
                if (dir != 2) dir = 3;
                break;
        }
        canInput = false; // 이번 움직임 입력 처리 완료
    }
}

4.  입력 허용 시점: PlayerMove()에서 canInput 해제

- PlayerMove()는 메인 루프에서 호출되며, 뱀의 위치를 갱신하고 나서 다시 입력을 받을 수 있도록 canInput = true 로 설정한다.

- 이 구조로 인해 움직임당 1회 입력이 보장되고, 빠른 연속 입력으로 반대 방향 회전에 인한 즉사 상황이 방지된다.

void PlayerMove()
{
	// 프레임 마다 움직이는 게 아닌, 시간 단위로 움직임(ex. 200ms마다)
    
    int tmpDir = dir; // 혹시라도 중간에 값 변경 안되도록, 값 복사

    // 방향에 따라 새로운 위치 계산, 충돌 검사 등등...
    // 예: (ny, nx) = 기존 위치 + 방향
    (int ny, int nx) = (player.First.Value.y + dirList[tmpDir].y, player.First.Value.x + dirList[tmpDir].x);
    switch (map[ny][nx]) // 다음 이동 지점 파악하여
    {
        case MapState.Wall: // 벽이면 죽음
        case MapState.Player: // 자기 자신도 죽음
            break;
        case MapState.None: // 빈 공간이면 이동
            break;
        case MapState.Food: // 음식이면 앞으로 자라나고 먹이 세팅
            break;
        default:
            break;
    }
    canInput = true; // 한번 움직이면 입력 받을 수 있도록 세팅
}

5. 멀티스레드 환경에서의 값 동기화

- dir 값은 입력 스레드에서 수정되고, 메인 루프에서 읽기 때문에, PlayerMove()에서는 중간 변경을 막기 위해, 임시 변수 tmpDir에 값을 복사해 사용한다.

- canInput은 값 형식이므로 volatile로 선언하여, 캐시가 아닌 메인 메모리에서 값을 직접 읽고 쓰도록 보장한다.

6. 전체 구조 요약

구성 요소 역할
메인 스레드 움직임 계산, 화면 출력, canInput 차단 해제
입력 스레드 키 입력 감지, 방향 설정, canInput 차단 설정
공유 변수 dir 현재 방향 저장 (프레임 간 공유)
공유 변수 canInput 뱀 한 번 움직임당 입력 1회 보장

7. 마무리

- 이번 구조는 volatile 키워드와 간단한 조건 분기만으로, 뮤텍스나 세마포어 같은 동기화 도구 없이도 안정적인 멀티스레드 입력 처리를 구현할 수 있었다.

- 입력 스레드와 메인 스레드가 동일한 영역의 값을 동시에 수정하지 않아, 생각보다 단순한 방식으로도 멀티스레드 환경에서 발생할 수 있는 문제들을 회피할 수 있었다.

- 콘솔 환경에서도 입력과 게임 로직을 스레드로 나누어 처리하면, 더 부드럽고 즉각적인 입력 반응을 구현할 수 있다는 점에서 의미 있는 구조라 생각한다.

 

목차