Unity Input System Action Map으로 키보드·게임패드 동적 바인딩과 핫플러깅 구현하기

Unity Input System의 Action Map과 Control Scheme을 이용해 키보드·게임패드 입력을 동적으로 전환하고 장치 연결·해제 시 UI와 게임플레이를 안전하게 갱신하는 구현 방법을 정리합니다.

✓ 복사 완료!
챕터게임플레이 로직
난이도중급
상태● 검토 완료
대상 엔진
Unity
최종 업데이트
Unity Input System Action Map으로 키보드·게임패드 동적 바인딩과 핫플러깅 구현하기

핵심 요약

Unity Input System에서는 입력 키를 직접 분기하기보다 InputActionAsset의 Action Map, Binding Group, Control Scheme을 분리해 관리하는 편이 안전하다. 런타임 리바인딩은 PerformInteractiveRebinding()으로 저장하고 장치 핫플러깅은 InputSystem.onDeviceChange와 PlayerInput.onControlsChanged에서 처리하면 키보드와 게임패드가 바뀌어도 UI와 게임플레이 상태를 일관되게 유지할 수 있다.

Action Map과 Control Scheme은 무엇이 다를까?

Action Map은 입력의 문맥을 나눈다. 예를 들어 Gameplay, UI, Menu 맵을 만들면 메뉴를 열었을 때 이동 액션을 끄고 UI 액션만 켤 수 있다. 반면 Control Scheme은 같은 액션을 어떤 장치 조합으로 수행할지 정의한다.

구분역할예시
Input Action게임 의도Move, Submit, Pause
Action Map입력 문맥Gameplay, UI
Binding실제 컨트롤 경로<Keyboard>/w, <Gamepad>/leftStick
Binding Group바인딩 분류KeyboardMouse, Gamepad
Control Scheme장치 조합키보드+마우스, 게임패드

Move는 Value / Vector2 액션으로 만들고 키보드는 2D Vector 컴포지트 바인딩, 게임패드는 <Gamepad>/leftStick 바인딩을 추가한다. 각 바인딩의 Group을 해당 Control Scheme 이름과 일치시킨다.

Unity Input Actions 에셋에서 Gameplay Action Map과 KeyboardMouse·Gamepad 바인딩 그룹을 구성한 예시

키보드와 게임패드 동적 바인딩은 어떻게 구현할까?

1. 입력 에셋과 PlayerInput을 연결한다

PlayerInput 컴포넌트에 Input Actions 에셋을 지정하고 기본 맵을 Gameplay로 설정한다. 로컬 멀티플레이가 아니라면 일반적으로 PlayerInput 하나가 한 명의 플레이어와 페어링된 장치를 담당하도록 두는 편이 관리하기 쉽다.

코드에서는 생성된 C# 래퍼 대신 PlayerInput.actions를 사용하면 현재 플레이어가 사용하는 액션 에셋을 기준으로 처리할 수 있다.

using UnityEngine;
using UnityEngine.InputSystem;

public sealed class PlayerMovementInput : MonoBehaviour
{
    [SerializeField] private PlayerInput playerInput;

    private InputAction moveAction;

    private void Awake()
    {
        moveAction = playerInput.actions["Gameplay/Move"];
    }

    private void OnEnable()
    {
        moveAction.Enable();
    }

    private void OnDisable()
    {
        moveAction.Disable();
    }

    private void Update()
    {
        Vector2 move = moveAction.ReadValue<Vector2>();
        // move 값을 캐릭터 이동 로직에 전달한다.
    }
}

액션을 Update()마다 이름으로 찾지 않는다. InputAction 참조를 한 번 캐시하면 문자열 탐색과 실수 가능성을 줄일 수 있다.

2. 마지막으로 사용한 장치에 맞춰 안내 문구를 갱신한다

게임플레이 입력 자체는 두 장치 바인딩이 모두 활성화된 상태에서도 문제없이 동작한다. 다만 버튼 안내는 현재 컨트롤 스킴에 맞춰 표시해야 한다. PlayerInput.onControlsChanged는 장치 페어링이나 컨트롤 스킴이 바뀔 때 호출되므로 UI 갱신 지점으로 적합하다.

using UnityEngine;
using UnityEngine.InputSystem;

public sealed class InputPromptPresenter : MonoBehaviour
{
    [SerializeField] private PlayerInput playerInput;

    private void OnEnable()
    {
        playerInput.onControlsChanged += Refresh;
        Refresh(playerInput);
    }

    private void OnDisable()
    {
        playerInput.onControlsChanged -= Refresh;
    }

    private void Refresh(PlayerInput input)
    {
        bool usesGamepad = input.currentControlScheme == "Gamepad";
        // usesGamepad에 따라 "A 버튼" 또는 "Enter 키" 아이콘을 표시한다.
    }
}

currentControlScheme이 비어 있을 수 있으므로 실제 UI 코드에서는 빈 문자열일 때 키보드 기본 안내를 보이거나 마지막 유효 스킴을 유지하는 정책을 정한다.

3. 런타임 리바인딩 결과를 저장하고 복원한다

PerformInteractiveRebinding()은 사용자가 다음에 누르는 컨트롤을 지정 바인딩에 적용한다. 저장할 때는 전체 InputActionAsset의 override JSON을 사용하면 여러 액션의 변경 사항을 한 번에 보관할 수 있다.

using UnityEngine;
using UnityEngine.InputSystem;

public sealed class RebindService : MonoBehaviour
{
    [SerializeField] private PlayerInput playerInput;
    private const string RebindKey = "InputBindingOverrides";

    public void StartRebind(string actionPath, int bindingIndex)
    {
        InputAction action = playerInput.actions[actionPath];
        action.Disable();

        action.PerformInteractiveRebinding(bindingIndex)
            .WithCancelingThrough("<Keyboard>/escape")
            .OnComplete(operation =>
            {
                operation.Dispose();
                action.Enable();
                Save();
            })
            .OnCancel(operation =>
            {
                operation.Dispose();
                action.Enable();
            })
            .Start();
    }

    public void Save()
    {
        string json = playerInput.actions.SaveBindingOverridesAsJson();
        PlayerPrefs.SetString(RebindKey, json);
        PlayerPrefs.Save();
    }

    public void Load()
    {
        string json = PlayerPrefs.GetString(RebindKey, string.Empty);
        if (!string.IsNullOrEmpty(json))
            playerInput.actions.LoadBindingOverridesFromJson(json);
    }
}

bindingIndex는 에셋 편집 후 바뀔 수 있다. 장기적으로는 바인딩의 id를 저장하거나 액션별로 키보드·게임패드 바인딩을 찾는 별도 규칙을 두는 것이 유지보수에 유리하다. 또한 PlayerPrefs는 로컬 설정 저장에는 간편하지만 계정 동기화나 변조 방지가 필요한 게임에는 별도 저장 계층이 필요하다.

게임패드 핫플러깅은 왜 별도 처리해야 할까?

게임패드가 연결되거나 분리되면 액션 시스템은 장치를 감지하지만 게임은 여전히 이전 장치 기준의 UI를 표시하거나 일시정지 상태를 놓칠 수 있다. 특히 게임패드가 유일한 조작 장치였을 때 분리되면 플레이어에게 명확한 복구 경로를 제공해야 한다.

flowchart TD
    A[장치 연결 또는 해제] --> B[InputSystem.onDeviceChange]
    B --> C{현재 플레이어 장치인가?}
    C -- 아니오 --> D[무시]
    C -- 예 --> E[PlayerInput 컨트롤 변경]
    E --> F[onControlsChanged]
    F --> G[버튼 안내와 입력 상태 갱신]

전역 장치 이벤트는 InputSystem.onDeviceChange에서 구독한다. 여기서는 장치 목록 변화에 반응하고 실제 플레이어별 표시 갱신은 앞서 본 onControlsChanged에 맡기는 방식이 역할을 분리하기 좋다.

using UnityEngine;
using UnityEngine.InputSystem;

public sealed class DeviceHotplugWatcher : MonoBehaviour
{
    private void OnEnable()
    {
        InputSystem.onDeviceChange += OnDeviceChange;
    }

    private void OnDisable()
    {
        InputSystem.onDeviceChange -= OnDeviceChange;
    }

    private static void OnDeviceChange(InputDevice device, InputDeviceChange change)
    {
        if (device is not Gamepad)
            return;

        if (change == InputDeviceChange.Added || change == InputDeviceChange.Reconnected)
        {
            Debug.Log($"게임패드 연결: {device.displayName}");
        }
        else if (change == InputDeviceChange.Removed || change == InputDeviceChange.Disconnected)
        {
            Debug.Log($"게임패드 해제: {device.displayName}");
            // 필요하면 입력 대기 오버레이 또는 일시정지 요청을 발행한다.
        }
    }
}

Removed와 Disconnected는 같은 의미로 취급하면 안 된다. Disconnected 장치는 재연결될 여지가 있지만 Removed는 Input System 장치 목록에서 제거된 상태다. 플랫폼과 드라이버에 따라 실제 발생 이벤트가 다를 수 있으므로 대상 플랫폼에서 로그를 확인해 처리 정책을 검증한다.

Action Map 전환 시 입력이 겹치지 않게 하려면?

메뉴를 열 때 Gameplay와 UI 맵을 동시에 켜면 Submit, Pause, 이동 같은 액션이 의도치 않게 함께 실행될 수 있다. 특히 동일 키가 UI와 게임플레이에 중복되어 있다면 한 프레임에 두 시스템이 모두 반응하는 문제가 생긴다.

  1. 게임플레이 진입 시 playerInput.SwitchCurrentActionMap("Gameplay")를 호출한다.
  2. 메뉴를 열기 직전에 게임 상태를 변경하고 playerInput.SwitchCurrentActionMap("UI")를 호출한다.
  3. 메뉴를 닫을 때 포커스와 선택 상태를 정리한 뒤 Gameplay로 되돌린다.
public void OpenPauseMenu(PlayerInput playerInput)
{
    playerInput.SwitchCurrentActionMap("UI");
    // Time.timeScale, EventSystem 선택 항목, 패널 표시를 순서대로 처리한다.
}

public void ClosePauseMenu(PlayerInput playerInput)
{
    playerInput.SwitchCurrentActionMap("Gameplay");
}

UI 입력은 InputSystemUIInputModule과 같은 액션 에셋을 공유하도록 구성한다. UI 모듈에 별도의 하드코딩 키 입력을 섞으면 리바인딩 후 게임플레이와 UI의 조작 방식이 달라질 수 있다.

동적 바인딩에서 확인할 테스트 항목

상황확인할 결과
키보드와 게임패드를 번갈아 입력마지막 사용 장치에 맞는 버튼 안내가 표시된다.
게임패드 분리잘못된 게임패드 안내가 남지 않고 필요한 경우 입력 대기 상태가 표시된다.
게임패드 재연결기존 바인딩과 안내가 복구되거나 사용자에게 재선택 경로가 제공된다.
리바인딩 취소기존 바인딩이 보존되고 액션이 다시 활성화된다.
메뉴 전환UI 액션과 게임플레이 액션이 중복 실행되지 않는다.
앱 재시작저장한 binding override가 로드된다.

자주 묻는 질문 (FAQ)

Action Map을 여러 개 동시에 Enable해도 되나요?

가능하지만 같은 컨트롤을 공유하는 액션은 동시에 실행될 수 있다. 게임플레이와 메뉴처럼 상호 배타적인 문맥은 SwitchCurrentActionMap()으로 하나만 활성화하는 편이 안전하다.

리바인딩한 키를 원래 값으로 되돌리려면 어떻게 하나요?

특정 액션에는 InputAction.RemoveBindingOverride(), 전체 에셋에는 InputActionAsset.RemoveAllBindingOverrides()를 사용한 뒤 저장된 override JSON도 함께 삭제하거나 갱신한다.

핫플러깅 때 게임패드를 자동으로 현재 플레이어에게 연결해야 하나요?

싱글 플레이어는 자동 전환이 편리할 수 있지만 로컬 멀티플레이에서는 다른 플레이어의 장치를 빼앗을 위험이 있다. 멀티플레이에서는 PlayerInputManager의 조인 정책과 장치 페어링 규칙을 명시적으로 설계해야 한다.

정리

동적 입력 지원의 핵심은 키 코드가 아니라 액션 중심으로 게임을 설계하는 것이다. Action Map으로 입력 문맥을 분리하고 Control Scheme과 Binding Group으로 장치별 바인딩을 구성하며 리바인딩 override와 핫플러그 이벤트를 별도 흐름으로 관리하자. 그러면 키보드와 게임패드가 바뀌는 상황에서도 게임플레이 로직을 수정하지 않고 일관된 입력 경험을 제공할 수 있다.