■ 벡터 - 이동의 토대
Unity에서 물체를 이동시키는 방법을 이해하려면 먼저 벡터를 알아야 한다.

벡터(Vector)를 쉽게 말하면 “어느 방향으로, 얼마나”를 나타낸 값이다. 즉 방향과 크기를 한데 묶은 값으로, 화살표를 떠올리면 된다.
- 누군가 우체국이 어디예요?라고 물어봤을 때 저쪽 방향으로 10분(얼마나) 정도 걸어가면 됩니다. 이런 개념이 벡터인 것이다.

2차원의 벡터라면 2개의 요소(스칼라)로 표현되고, 3차원의 벡터라면 3개의 요소로 표현된다.
여기서 스칼라는 크기만 있는 값 하나(예: 속도 5, 온도 20)를 의미하고, 벡터는 이 스칼라를 여러 개 묶어 방향까지 표현한 것이다.
- 위 벡터는 x축으로 3, y축으로 2만큼 뻗은 화살표이다.
1. 위치 벡터와 방향 벡터

이름에서 알 수 있듯이 위치 벡터는 시작점이 "원점 (0,0)"에서 임의의 점 P = (a, b)으로 향하는 벡터를 의미하며, 방향 벡터는 시작점이 임의의 점이라는 점이다.
즉, 위치 벡터와 방향 벡터의 차이점은 원점에 묶여 있느냐, 아니냐의 차이이다.

위치 벡터가 원점$O = (0,0)$ 에서 시작한다면, 방향 벡터는 임의의 점에서 시작할 수 있다. 주황색으로 표시한 3개의 방향 벡터 모두 위치만 다르지 같은 벡터$(3,2)$이다.
🤔점과 벡터
Vector3 position = transform.position; // 점으로 해석(위치)
Vector3 direction = transform.forward; // 화살표로 해석(방향)
점도 벡터도 Unity에서 전부 똑같은 Vector3 타입이다. 타입이 따로 있는 게 아닌, 그 숫자 세 개를 점으로 보느냐, 화살표로 보느냐는 순전히 맥락이다.
이 구분이 중요한 이유는 연산할 때 의미가 갈리기 때문이다. 점(위치)에 화살표(방향)를 더하면 "그 방향으로 이동한 새 위치"가 된다. 우리가 캐릭터를 움직일 때 하는 일이 바로 이것이다.
// 현재 위치(점)에 forward 방향(화살표)을 더해 앞으로 이동
transform.position = transform.position + transform.forward;
■ 벡터의 연산
1. 벡터 연산
벡터끼리의 연산과 스칼라와의 연산에 대해 알아보자.
▶ 점 + 방향(벡터) = 이동
점과 방향 벡터의 덧셈은, 점을 방향 벡터만큼 이동시키는 연산이다.

- 점 $p_1=(1,1)$ 을 방향 벡터 $\vec v=(3,2)$ 만큼 이동시킨 새 위치가 결과다.
- 계산은 각 요소끼리 더한다. x는 x끼리 (1+3), y는 y끼리 (1+2).
우리가 위에서 봤던 코드 transform.position = transform.position + transform.forward; 가 점 + 벡터 연산이며, 이 연산의 결과는 점(위치)이다.
- 점 + 벡터 = 점
▶ 점 − 점 = 방향(벡터)
점 $p_1$에서 점 $p_2$를 빼면 점 $p_2$를 시작점으로 하여 $p_1$으로 향하는 방향 벡터가 생성된다.

여기서 중요한 점은, $p_1 - p_2$의 결과가 $p_1$에서 $p_2$로 향하는 벡터가 아니라 $p_2$에서 $p_1$으로 향하는 벡터라는 것이다. 즉 “도착 - 시작 = 시작에서 도착으로 향하는 방향”으로 기억하면 헷갈리지 않는다.
// 플레이어(시작) -> 적(도착) 방향.
Vector3 dir = enemy.transform.position - player.transform.position;
- 이 점 − 점 연산에 (뒤에서 설명할) 크기나 정규화를 적용하면, 적이 플레이어로부터 얼마나 떨어져 있는지(거리 = 크기)와 어느 방향에 있는지(방향 = 정규화)를 구할 수 있다.
▶ 스칼라 곱 (벡터 × 숫자 = 길이 조절)
벡터와 스칼라의 곱은 방향은 그대로 두고 길이(크기)만 조절하는 연산이다. 이동에서 가장 자주 쓰이는 연산이다.

각 요소에 똑같이 숫자를 곱한다. 그래서 방향은 변하지 않고 길이만 k배가 된다. (k가 음수면 방향이 반대로 뒤집힌다.)
// 방향(forward)에 speed를 곱해 "속도"를 만들고,
// deltaTime을 곱해 프레임 독립적으로 만든 뒤 현재 위치에 더한다.
transform.position += transform.forward * speed * Time.deltaTime;
여기서 transform.forward(방향, 길이 1)에 speed(숫자)를 곱하는 부분이 바로 스칼라 곱이다. "어느 방향으로(forward) × 얼마나 빠르게(speed)"를 합쳐 이동량을 만드는 것이다.
- position + (forward * speed * deltaTime) = 새 position
(점) (방향 = 이동량) (점)
2. 크기와 정규화
벡터는 "크기"와 "방향"을 가진다고 했다. 여기서 벡터의 크기가 뭘 의미하는지 살펴보자.

벡터의 크기를 구하기 위해선 벡터를 “빗변”으로 가지는 직각삼각형을 생각해보자. 그럼 밑변의 길이는 $x$, 높이는 $y$가 되는데 피타고라스 정리로 빗변의 길이를 구할 수 있다.

- 이 빗변의 길이가 벡터의 크기가 되며, $|\vec v|$로 표기한다. 벡터의 크기는 노름(Norm)이라 부른다.
- Unity에선 .magnitude 로 벡터의 크기를 구할 수 있다.
▶ 정규화
벡터의 크기를 1로 만드는 것이 정규화(normalize)다. 정규화는 크기 정보는 버리고 오로지 방향 정보만 필요할 때 사용한다. 왜 그런지 이동 코드로 살펴보자.
Vector3 dir = target.position - transform.position; // 방향 + 거리
transform.position += dir * speed * Time.deltaTime;
적을 향해 이동시키는 코드다. dir의 크기는 곧 타겟까지의 거리이므로, 물체가 적에 가까워질수록 dir이 작아져 속도가 점점 느려진다. 거리가 속도에 섞여버리는 것이다.
Vector3 dir = (target.position - transform.position).normalized; // 방향만 (크기 1)
transform.position += dir * speed * Time.deltaTime;
dir의 크기를 1로 만들면 순수한 "방향 정보"만 남는다. 따라서 적이 멀든 가깝든 똑같은 속도로 일정하게 이동한다.
- 앞서 설명한 스칼라 곱을 생각해 보자. speed에 곱해지는 벡터의 크기는 1이어야 거리에 상관없이 일정한 속도를 유지한다.
- Unity에선. normalized로 정규화된 벡터를 얻는다. (원본은 그대로 두고 정규화된 복사본을 돌려준다.)
■ Unity에서 물체를 이동시키는 방법
벡터에 대해 알아봤으니, 이제 Unity에서 물체를 이동시키는 방법을 알아보자. Unity의 Input 시스템은 구식이지만, 입력 자체는 이 글의 핵심이 아니므로 간단한 legacy Input을 사용하겠다.

- 만약 Unity 6 이상을 사용해 InvalidOperationException이 발생한다면, Project Settings → Player → Other Settings의 Active Input Handling을 Both로 바꾸고 에디터를 재시작하자.
▶ 직접 이동(transform.position)
using System;
using UnityEngine;
public class PlayerMoveController : MonoBehaviour
{
[SerializeField] private float speed = 10.0f;
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
var dir = new Vector3(h, 0, v).normalized;
transform.position += dir * (speed * Time.deltaTime);
}
}
PlayerMoveController 스크립트를 만들고, 캡슐이나 아무 오브젝트에 붙여주자. 우리가 앞서 배운 개념을 그대로 적용한 코드다.
- "Horizontal"은 A/D(또는 ←/→) 입력으로, A를 누르면 h = -1, D를 누르면 h = 1이 된다.
- "Vertical"은 W/S(또는 ↑/↓) 입력으로, S를 누르면 v = -1, W를 누르면 v = 1이 된다.
- 이렇게 만든 (h, 0, v)가 입력 방향 벡터이며, 여기에. normalized를 적용해 대각선 이동도 같은 속도가 되게 한다.
🤔 속도에 Time.deltaTime을 곱하는 이유
Update()는 매 프레임마다 호출되는데, 프레임 간격이 컴퓨터 성능에 따라 다르다.
- 60 fps라면? → 1초에 60번 호출 → 프레임 간격 약 0.0167초
- 144 fps라면? → 1초에 144번 호출 → 프레임 간격 약 0.0069초
transform.position += dir * speed; // deltaTime 없음
만약 deltaTime 없이 위처럼 사용하면 매 프레임 speed 만큼 통째로 더해진다. 즉,
- 60 fps → 초당 speed x 60
- 144 fps → 초당 speed x 144
프레임 속도가 다르다면, 프레임 속도에 따라 이동 속도가 빨라지거나 느려진다.
Time.deltaTime은 직전 프레임부터 지금까지 실제로 흐른 시간(초)다. 이걸 곱하면 한 프레임에 가는 양이 시간에 비례하게 맞춰진다.
- 60 fps: 한 프레임에 speed × 0.0167 이동 × 60번 = 초당 speed × 1
- 144 fps: 한 프레임에 speed × 0.0069 이동 × 144번 = 초당 speed × 1

하지만 이 방식은 좌표를 직접 바꾸므로, 벽이 있어도 그냥 통과해 버린다. 따라서 총알처럼 빠르고 단순하게 등속으로 움직이는 물체에 주로 사용하며, 충돌은 별도의 충돌 감지(예: Raycast나 트리거)로 처리해 벽이나 적에 닿으면 파괴되도록 만든다.
정리하면 직접 이동은 "물리가 필요 없거나, 오히려 물리가 방해되는" 경우에 쓴다. 연출용 이동, UI 요소, 그리고 위의 총알처럼 정해진 궤적으로만 움직이면 되는 오브젝트가 여기 해당한다. 반대로 벽에 부딪혀 멈추거나 밀려나야 한다면, 다음에 살펴볼 Rigidbody가 필요하다.
▶ 리지드바디(Rigidbody)

Rigidbody는 오브젝트를 Unity의 물리 엔진에 등록하는 컴포넌트다.
Rigidbody가 붙는 순간, 그 오브젝트의 위치는 더 이상 내가 직접 정하는 게 아니라 물리 엔진이 계산해서 정한다. 중력, 충돌, 관성, 마찰이 전부 자동으로 적용된다.
- Rigidbody가 붙은 오브젝트를 transform.position으로 직접 옮기면, 나와 물리 엔진이 같은 위치를 두고 충돌해 떨리거나 벽을 뚫는다.
1. MovePosition()
public class PlayerMoveController : MonoBehaviour
{
[SerializeField] private Rigidbody rb;
[SerializeField] private float speed = 10.0f;
private Vector3 _dir;
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
_dir = new Vector3(h, 0, v).normalized;
}
private void FixedUpdate()
{
rb.MovePosition(rb.position + _dir * (speed * Time.fixedDeltaTime));
}
}
rb.MovePosition()은 목표 위치를 직접 지정하되 물리 충돌을 우선한다. 앞서 본 직접 이동과 사용감은 똑같지만, 벽이나 물체에 막혀 충돌 처리가 된다는 점이 다르다.
여기서 중요한 점은, Rigidbody 이동은 Update가 아니라 FixedUpdate에서 수행해야 한다는 것이다. 따라서 시간 보정도 Time.fixedDeltaTime을 사용한다.
- 물리 엔진이 고정된 간격(기본 0.02초)으로 갱신되기 때문이다. FixedUpdate는 그 물리 갱신 주기에 맞춰 호출되므로, 물리 연산은 여기에 넣어야 충돌·이동이 정확하게 계산된다.
- 그래서 입력은 Update에서 받고(프레임마다 빠르게 감지), 이동은 FixedUpdate에서 적용(물리 주기에 맞춤)하는 위 구조가 RB 이동의 기본 패턴이다.
⚠️ 단, MovePosition은 중력의 영향을 받지 않는다. 위치를 직접 지정하는 방식이라 물리 엔진의 속도(velocity) 계산을 우회하기 때문이다. 따라서 캐릭터를 공중에 두면 떨어지지 않고 그 자리에 떠 있는다.


MovePosition은 위치만 옮기지만, Rigidbody의 회전은 여전히 물리 엔진이 제어한다.
따라서 이동하는 물체가 벽이나 다른 물체에 부딪히면, 그 충돌 힘 때문에 물리 엔진이 오브젝트를 회전시켜 캐릭터가 쓰러지듯 넘어진다.
- X·Z 축은 앞뒤·좌우로 넘어지는 회전이므로 얼린다.
- Y축은 캐릭터가 좌우로 방향을 바꾸는(바라보는 쪽을 트는) 회전이므로 풀어둔다. 이걸 얼리면 회전 이동을 못 하게 된다.
2. linearVelocity
using System;
using UnityEngine;
public class PlayerMoveController : MonoBehaviour
{
[SerializeField] private Rigidbody rb;
[SerializeField] private float speed = 10.0f;
private Vector3 _dir;
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
_dir = new Vector3(h, 0, v).normalized;
}
private void FixedUpdate()
{
var v = _dir * speed;
v.y = rb.linearVelocity.y;
rb.linearVelocity = v;
}
}
linearVelocity는 Rigidbody의 속도를 직접 지정하는 방식이다. MovePosition이 "이 위치로 가라"라면, linearVelocity는 "이 속도로 계속 가라"라고 명령하는 것이다.
여기서 중요한 두 지점이 있다.
-> Time.fixedDeltaTime을 곱하지 않는다.
linearVelocity에 넣는 값은 위치(이동량)가 아니라 속도(초당 이동량) 그 자체다. 시간 보정은 물리 엔진이 알아서 처리하므로, 우리가 fixedDeltaTime을 곱할 필요가 없다.
-> y값 보존
var v = _dir * speed;
v.y = rb.linearVelocity.y;
rb.linearVelocity = v;
linearVelocity에 새 벡터를 통째로 대입하면 x, y, z가 전부 덮어써진다.
그런데 중력은 매 물리 프레임마다 velocity의 y값을 깎아 낙하를 만든다. 만약 v.y를 그대로(0인 채로) 대입하면, 중력이 쌓아놓은 낙하 속도를 매 프레임 0으로 지워버려 물체가 떨어지지 않는다.
- 따라서 수평 속도(x, z)만 입력으로 정하고, 수직 속도(y)는 물리 엔진이 만든 값을 그대로 유지한다.
- MovePosition과 달리 중력과 함께 쓸 수 있다. 낙하·점프가 필요한 캐릭터라면 이 방식이 적합하다.
3. AddForce()
AddForce는 순간적인 힘을 가해 물체를 이동시킬 수 있다. AddForce(force, ForceMode)에서 두 번째 인자로 힘을 어떻게 해석할지를 정하는데, 4가지 종류가 있다.
| ForceMode | 질량 적용 | 시간 적용 | 용도 |
| Force (기본) | O | O | 지속적인 힘 (바람, 엔진 추진) |
| Acceleration | X | O | 질량을 무시한 지속 가속(중력 등) |
| Impulse | O | X | 순간 충격(점프, 폭발, 타격) |
| VelocityChange | X | X | 질량을 무시한 순간 속도 변화 |
public class PlayerMoveController : MonoBehaviour
{
[SerializeField] private Rigidbody rb;
[SerializeField] private float speed = 10.0f;
private Vector3 _dir;
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
_dir = new Vector3(h, 0, v).normalized;
}
private void FixedUpdate()
{
rb.AddForce(_dir * speed);
}
}

linearVelocity는 "속도를 5로 만들어"라고 결과를 박는 거고, AddForce는 "이쪽으로 밀어"라고 힘만 준다.
그 힘으로 얼마나 빨라질지는 질량(mass)·기존 속도·마찰을 엔진이 계산한다. 그래서 즉시 최고속도가 아니라 서서히 가속되고, 입력을 떼도 관성으로 미끄러진다.
AddForce는 일반적인 캐릭터 이동에는 잘 사용되지 않고, 점프나 폭발 반동처럼 순간적인 힘을 줄 때 주로 사용된다.
- 이런 순간적인 힘은 ForceMode.Impulse로 적용한다.
다만 차량이나 공처럼 묵직하게 가속되고 미끄러지는 물리 기반 오브젝트라면, AddForce를 주 이동 수단으로 쓰기도 한다. 즉 "딱딱한 조작감"이 필요하면 MovePosition을, "묵직한 물리 느낌"이 필요하면 AddForce를 선택하는 식이다.
| 항목 | MovePosition | linearVelocity | AddForce |
| 내가 정하는 것 | 위치 | 속도 | 힘 |
| 명령 의미 | "여기로 가라" | "이 속도로 가라" | "이쪽으로 밀어라" |
| 조작감 | 딱딱·정밀 | 일정·관성 | 묵직·미끄러움 |
| 시간 보정 | fixedDeltaTime 곱함 | 안 곱함 | 안 곱함 (Force 모드) |
| 중력 | ❌ 안 받음 | ⚠️ y 보존해야 받음 | ✅ 받음 |
| 질량(mass) | 무시 | 무시 | 반영됨 |
| 주 용도 | 캐릭터 본체 | 등속 이동·점프 | 차량·공, 점프·폭발 |
■ CharacterController(CC)
Transform 이동 방식은 충돌/물리 영향을 받지 않고, Rigidbody는 충돌과 물리 영향을 모두 받는다.
CharacterController(CC)는 이 둘의 중간으로, 충돌은 받되 물리는 받지 않는다. 즉 벽은 통과하지 못하지만, 중력·관성·질량 같은 물리 효과는 자동으로 일어나지 않는다.
- 그래서 CC는 물리에 휘둘리지 않고 입력한 대로 정확히 움직이는 캐릭터를 구현할 때 사용한다. 사람형 캐릭터처럼 "키를 누른 만큼만 딱 움직이되, 벽은 못 뚫는" 비물리적 정밀 조작이 필요한 경우”에 사용.


CC 컴포넌트를 추가하면 위와 같이 캡슐 형태의 콜라이더가 함께 붙는다. 이 콜라이더는 컴포넌트의 Center / Radius / Height 값으로 조정한다.
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
_dir = new Vector3(h, 0, v).normalized;
cc.Move(_dir * (speed * Time.deltaTime));
}
CC로 이동할 땐 Move() 메서드에 이번 프레임의 이동량을 넘겨주면 된다. 그런데 CC에는 한 가지 특징이 있는데, 경사면을 탈 수 있다는 것이다.

단, 경사면을 올라가는 것은 중력이 아니라 "충돌 해소"다. 평지를 이동하다 캡슐이 경사면과 충돌하면, CC는 캡슐을 표면 바깥쪽으로 밀어낸다. 그 밀려나는 방향이 경사면을 따라 위쪽이라서, 수평으로 준 입력이 경사면을 타고 올라가는 것이다.
그렇다면 경사면을 올라가다 뒤로 빠지면 어떻게 될까? 밑에서 막아주던 경사면이 사라지므로 캡슐을 밀어낼 충돌이 없어지고, 결국 y는 고정된 채 공중에 붕 뜬 상태로 수평 이동만 하게 된다. CC가 중력을 자동으로 처리하지 않기 때문에 생기는 현상이다. 그래서 중력을 직접 코드로 더해줘야 한다.
private Vector3 _dir;
private Vector3 _velocity = Vector3.zero;
private float _gravity = -9.81f;
private void Update()
{
var h = Input.GetAxisRaw("Horizontal");
var v = Input.GetAxisRaw("Vertical");
var input = new Vector3(h, 0, v);
// 수평 이동
var horizontal = input * speed;
// 중력 누적
if(cc.isGrounded && _velocity.y < 0f)
_velocity.y = -2f; // 캐릭터를 살짝 눌러두는 값
else
_velocity.y += _gravity * Time.deltaTime;
cc.Move((horizontal + _velocity) * Time.deltaTime);
}
cc.isGrounded로 캐릭터가 바닥에 있는지 확인한 뒤, 바닥이 없다면(공중) _velocity.y에 중력을 누적해 캐릭터가 떨어지도록 만든다. 반대로 바닥에 있을 땐 _velocity.y를 작은 음수(-2f)로 유지해, 캐릭터를 지면에 살짝 눌러둔다. 이 값이 내리막에서 캐릭터가 지면에서 떨어지지 않게 붙잡아주는 역할도 한다.
여기서 한 가지 주의할 점은 방향과 속도는 단위가 다르다는 것이다. horizontal은 input * speed로 이미 "초당 이동 거리(속도)"가 되었고, _velocity 역시 중력을 누적한 속도다. 둘 다 속도이므로 합친 뒤 Time.deltaTime을 한 번만 곱하면 이동량이 된다.
'Unity,C# > Unity 정보' 카테고리의 다른 글
| [Unity] 총알 시스템으로 알아보는 Object Pool (1) | 2026.05.07 |
|---|---|
| [Unity, C#] 델리게이트에 대한 모든것(Action, Func, Lambda) (0) | 2026.05.07 |
| [Unity, C#] SOLID 원칙 (1) | 2025.11.21 |
| [Unity] UI Toolkit 기본 사용법 (0) | 2025.11.21 |
| [Unity, C#] ScriptableObject - 스크립터블 오브젝트 (6) | 2025.08.02 |