
Input System 패키지 전환 후 입력 제어 중앙화하기
Unity Input System으로 옮긴 뒤 입력 처리가 여러 스크립트에 흩어지는 문제를 줄이는 방법을 정리한다. 입력 수집과 게임 규칙 적용을 분리해 재바인딩, UI 전환, 플레이어 교체에 대응하기 쉬운 구조를 만든다.
왜 입력을 중앙화해야 할까
기존 Input Manager에서는 Input.GetAxis나 Input.GetKey를 각 컴포넌트에서 바로 호출하기 쉽다. Input System 패키지로 전환한 뒤에도 같은 방식으로 콜백을 여러 스크립트에 연결하면 입력 액션의 활성화 상태와 구독 해제를 추적하기 어려워진다.
특히 플레이 중 UI를 열었을 때 이동을 막거나 컷신에서 모든 조작을 끄거나 로컬 멀티플레이어의 입력 대상을 바꿔야 할 때 문제가 드러난다. 입력을 읽는 위치와 캐릭터를 움직이는 위치가 섞여 있기 때문이다.
중앙화의 핵심은 간단하다. 입력 시스템과 직접 통신하는 코드는 한곳에 두고 게임플레이 코드는 정규화된 명령값만 받도록 분리한다.
목표 구조
입력 액션은 물리 키나 패드 버튼을 뜻하지 않는다. Move, Look, Jump, Pause처럼 게임의 의도를 표현해야 한다. 키보드의 WASD와 게임패드의 왼쪽 스틱은 모두 Move 액션에 바인딩할 수 있다.
flowchart LR
D[키보드·게임패드] --> A[Input Actions]
A --> R[InputReader]
R --> C[게임 명령값]
C --> M[PlayerMotor]
C --> U[UI·메뉴]
C --> G[게임 상태]
InputReader는 Input System 이벤트를 받아 현재 프레임에 필요한 입력 상태를 보관하고 필요하면 게임플레이 코드에 이벤트를 알린다. 반면 PlayerMotor는 어떤 장치에서 입력이 왔는지 알 필요가 없다.

Input Actions를 먼저 설계한다
Player와 UI처럼 목적이 다른 액션 맵을 분리한다. 게임플레이 중에는 Player를 메뉴를 열었을 때는 UI를 활성화하는 식이다. 같은 버튼을 쓰더라도 액션 맵이 다르면 문맥에 맞게 처리할 수 있다.
예를 들어 Player 맵에는 다음 액션을 둔다.
Move: Value / Vector2Look: Value / Vector2Jump: ButtonAttack: ButtonPause: Button
에셋 생성 옵션에서 C# 클래스를 생성해 두면 문자열 기반의 액션 검색을 줄일 수 있다. 아래 예시는 생성된 클래스 이름이 GameInputActions라고 가정한다.
입력을 읽는 전용 객체 만들기
입력은 장면의 특정 오브젝트보다 게임의 실행 흐름에 가까운 관심사다. 작은 프로젝트에서는 MonoBehaviour로 시작해도 충분하지만 다른 시스템에서 참조하기 쉽도록 인터페이스를 먼저 정해 두면 교체와 테스트가 편하다.
using System;
using UnityEngine;
using UnityEngine.InputSystem;
public interface IPlayerInput
{
Vector2 Move { get; }
Vector2 Look { get; }
event Action JumpPressed;
event Action PausePressed;
}
public sealed class InputReader : MonoBehaviour, IPlayerInput
{
private GameInputActions actions;
public Vector2 Move { get; private set; }
public Vector2 Look { get; private set; }
public event Action JumpPressed;
public event Action PausePressed;
private void Awake()
{
actions = new GameInputActions();
}
private void OnEnable()
{
actions.Player.Enable();
actions.Player.Move.performed += OnMove;
actions.Player.Move.canceled += OnMove;
actions.Player.Look.performed += OnLook;
actions.Player.Look.canceled += OnLook;
actions.Player.Jump.performed += OnJump;
actions.Player.Pause.performed += OnPause;
}
private void OnDisable()
{
actions.Player.Move.performed -= OnMove;
actions.Player.Move.canceled -= OnMove;
actions.Player.Look.performed -= OnLook;
actions.Player.Look.canceled -= OnLook;
actions.Player.Jump.performed -= OnJump;
actions.Player.Pause.performed -= OnPause;
actions.Player.Disable();
Move = Vector2.zero;
Look = Vector2.zero;
}
private void OnDestroy()
{
actions.Dispose();
}
private void OnMove(InputAction.CallbackContext context)
{
Move = context.ReadValue<Vector2>();
}
private void OnLook(InputAction.CallbackContext context)
{
Look = context.ReadValue<Vector2>();
}
private void OnJump(InputAction.CallbackContext context)
{
JumpPressed?.Invoke();
}
private void OnPause(InputAction.CallbackContext context)
{
PausePressed?.Invoke();
}
}
performed만 구독하면 스틱을 놓았을 때 이전 이동값이 남을 수 있다. 연속 값 액션은 canceled에서도 값을 읽거나 명시적으로 Vector2.zero를 대입해야 한다. 반대로 점프처럼 한 번 실행하면 되는 버튼은 performed 이벤트가 자연스럽다.
캐릭터는 입력 장치를 몰라야 한다
이동 컴포넌트는 Keyboard.current나 InputAction을 직접 참조하지 않는다. IPlayerInput에서 받은 값을 이동 규칙에 적용한다.
using UnityEngine;
public sealed class PlayerMotor : MonoBehaviour
{
[SerializeField] private InputReader input;
[SerializeField] private float moveSpeed = 5f;
private void OnEnable()
{
input.JumpPressed += TryJump;
}
private void OnDisable()
{
input.JumpPressed -= TryJump;
}
private void Update()
{
Vector2 move = input.Move;
Vector3 direction = new Vector3(move.x, 0f, move.y);
transform.position += direction * moveSpeed * Time.deltaTime;
}
private void TryJump()
{
// 지면 여부, 스태미나, 상태 전환 등 게임 규칙을 검사한다.
}
}
이 구조에서는 자동 테스트나 디버그 이동을 위해 IPlayerInput 구현체만 바꿀 수 있다. 캐릭터 코드에 키보드 예외 처리를 추가할 필요가 없다.
액션 맵 전환은 게임 상태가 결정한다
입력 리더가 Player와 UI 액션 맵을 모두 직접 판단하기 시작하면 다시 책임이 커진다. 메뉴가 열렸는지 컷신인지, 조작이 잠겼는지는 게임 상태 또는 UI 흐름이 결정하고 입력 리더에는 활성화 요청만 전달하는 편이 명확하다.
public void SetPlayerInputEnabled(bool enabled)
{
if (enabled)
{
actions.Player.Enable();
}
else
{
actions.Player.Disable();
Move = Vector2.zero;
Look = Vector2.zero;
}
}
public void SetUiInputEnabled(bool enabled)
{
if (enabled)
{
actions.UI.Enable();
}
else
{
actions.UI.Disable();
}
}
플레이어 입력을 끌 때 이동값도 초기화해야 한다. 그렇지 않으면 버튼을 누른 상태에서 메뉴를 연 뒤 돌아왔을 때 마지막 방향으로 이동하는 것처럼 보일 수 있다.

PlayerInput 컴포넌트와의 선택 기준
Unity의 PlayerInput 컴포넌트는 플레이어별 입력, 제어 스킴, 로컬 멀티플레이어 설정에 유용하다. 단일 플레이어 게임에서도 쓸 수 있지만 메시지 이름이나 UnityEvent에 게임 로직을 많이 연결하면 호출 관계가 Inspector에 흩어질 수 있다.
다음 기준으로 선택하면 된다.
- 단일 플레이어이고 코드 흐름을 분명히 관리하고 싶다면 생성된 액션 클래스와
InputReader를 직접 사용한다. - 여러 플레이어의 장치 페어링과 참여 처리가 필요하다면
PlayerInput또는PlayerInputManager를 중심으로 구성한다. - 어느 방식을 쓰든 캐릭터와 UI가
InputAction을 직접 읽지 않게 하는 원칙은 유지한다.
전환 과정에서 자주 놓치는 점
이벤트 구독 해제
OnEnable에서 구독했다면 OnDisable에서 반드시 해제한다. 비활성화와 재활성화가 반복되는 오브젝트에서 해제하지 않으면 같은 입력에 핸들러가 여러 번 실행될 수 있다.
Update와 물리 처리의 경계
입력값은 Update에서 보관하고 Rigidbody를 움직이는 코드는 FixedUpdate에서 그 값을 사용한다. 입력 이벤트 안에서 곧바로 물리력을 적용하면 프레임과 물리 스텝의 차이 때문에 제어가 불안정해질 수 있다.
액션 이름을 문자열로 찾지 않기
FindAction("Jump")는 빠르게 만들기에는 편하지만 이름 변경을 컴파일러가 잡아 주지 못한다. 가능하면 Input Actions 에셋의 C# 래퍼를 생성해 actions.Player.Jump처럼 접근한다.
입력과 규칙을 혼동하지 않기
Jump 입력은 점프 시도일 뿐 점프 성공을 뜻하지 않는다. 실제 점프 가능 여부는 캐릭터 상태가 판단한다. 이 구분을 지키면 입력 장치와 게임 규칙이 서로 얽히지 않는다.
정리
Input System 전환의 효과는 새 API를 호출하는 데서 끝나지 않는다. 액션을 의도 중심으로 설계하고 입력 수집을 한곳에 모으고 게임플레이 코드는 명령값만 소비하게 만들면 장치 추가와 UI 전환, 재바인딩 요구에 훨씬 안정적으로 대응할 수 있다.


