---
title: "점프가 포물선이 되는 이유"
description: "중력은 위치가 아니라 속도를 바꿉니다. 매 프레임 속도에 중력을, 위치에 속도를 더하면 포물선이 나옵니다. 속도 · 가속도 · 포물선을 작은 숫자로 처음부터 따라가고, 계산 간격이 크면 점프 높이가 달라지는 이유와 원하는 높이로 뛰게 하는 처음 속도 √(2gh) 까지 봅니다."
author: "유니얼"
pubDate: 2026-11-05
category: "game-dev/math"
tags: ["Unity", "기초"]
canonical: https://blog.unialgames.com/posts/math-velocity/
---
CharacterController 로 점프를 만들려고 점프 키를 누르면 y 를 2 만큼 올리고, 매 프레임 조금씩 내렸더니 캐릭터가 순간이동하듯 올라갔다가 엘리베이터처럼 일정하게 내려옵니다.
진짜 점프는 빠르게 솟았다가 꼭대기에서 잠깐 멈칫하고, 점점 빨라지며 떨어집니다.

중력이 **위치가 아니라 속도를 바꾸기** 때문입니다. 점프 키는 위로 향한 속도를 한 번 주고, 중력은 매 프레임 그 속도를 조금씩 아래로 바꿉니다.
속도가 일정하게 줄어드니, 위치는 처음엔 많이 오르다가 점점 덜 오르고, 꼭대기를 지나면 점점 더 많이 떨어집니다. 그렇게 그려지는 곡선이 포물선입니다.

> **매 프레임 `속도 += 중력 × 시간`, `위치 += 속도 × 시간` 두 줄이면 포물선이 나옵니다.
> 이 계산은 한 번에 맡는 시간(계산 간격)이 크면 오차가 커져서, fps 가 다르면 점프 높이도 조금씩 달라집니다.
> 원하는 높이 h 로 뛰게 하려면 처음 속도를 √(2 × 중력 × h) 로 줍니다.**

## 중력은 속도를 바꾸는 가속도다

**속도**는 위치가 1초에 얼마나 바뀌나이고, 단위는 m/s(미터 매 초)입니다. 속도가 위로 8 이면 1초에 8m 오르고, 0.5초면 4m 오릅니다.
속도가 1초에 얼마나 바뀌는지는 **가속도**라 부르고, 단위는 m/s²(1초마다 m/s 가 얼마)입니다. 가속도가 아래로 10 이면 **속도가** 1초마다 10 씩 아래로 바뀝니다. 위로 8 이던 속도는 0.5초 뒤 3, 0.8초 뒤 0, 1초 뒤 −2(아래로 2) 가 됩니다.

중력이 바로 이 가속도입니다. 지구에서는 아래로 약 9.81 이고, 유니티의 기본 중력 `Physics.gravity` 도 (0, −9.81, 0) 입니다. 아래 계산은 암산하기 쉽게 −10 으로 둡니다.

속도와 가속도는 둘 다 방향이 있으니 **벡터**(방향과 길이를 가진 화살표, 유니티에서는 `Vector3`)로 씁니다.
게임은 시간을 프레임(화면에 그리는 그림 한 장) 단위로 끊어서 계산합니다. 계산 한 번이 맡는 시간을 dt(델타 타임)라고 부르고, `Update` 안이면 지난 프레임이 걸린 초인 `Time.deltaTime` 이 그것입니다. dt 초가 지날 때마다 이렇게 바꿉니다.

```text
속도 = 속도 + 가속도 × dt
위치 = 위치 + 속도 × dt
```

## 0.2초씩 계산하면

처음 속도 (2, 8) 로 뛰었다고 합니다. 가로로 1초에 2, 위로 1초에 8 입니다. 중력은 (0, −10), 계산 간격 dt 는 눈으로 따라가기 쉽게 0.2초로 크게 잡습니다.
가로 속도는 아무것도 바꾸지 않으니 계속 2 이고, 세로 속도만 0.2초마다 10 × 0.2 = 2 씩 줄어듭니다. 속도를 먼저 바꾸고, 바뀐 속도로 위치를 옮깁니다.

| 시각 | 속도 y | 이번에 오른 양 (속도 y × 0.2) | 높이 |
| --- | --- | --- | --- |
| 0 | 8 | | 0 |
| 0.2 | 6 | 1.2 | 1.2 |
| 0.4 | 4 | 0.8 | 2.0 |
| 0.6 | 2 | 0.4 | 2.4 |
| 0.8 | 0 | 0 | 2.4 |
| 1.0 | −2 | −0.4 | 2.0 |
| 1.2 | −4 | −0.8 | 1.2 |
| 1.4 | −6 | −1.2 | 0 |

**오르는 양이 1.2, 0.8, 0.4, 0 으로 일정하게 줄고, 내려오는 양이 0.4, 0.8, 1.2 로 일정하게 늘어납니다.** 속도가 일정하게 바뀌면 위치는 이렇게 휘어집니다.

<x-vec>
<script type="application/json">
{
  "label": "0.2초마다 속도와 자리를 계산하면",
  "view": [-0.5, -0.7, 3.6, 3.7],
  "dwell": 2.2,
  "steps": [
    { "caption": "처음. 자리 (0, 0), 속도 (2, 8). 화살표는 속도의 방향과 크기입니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "accent"},
      {"id": "v", "from": [0, 0], "to": [0.3, 1.2], "color": "warn", "label": "v (2, 8)", "labelAt": [0.75, 1.35]}
    ] },
    { "caption": "0.2초 뒤. 속도의 y 가 먼저 중력만큼 줄어 8 − 10 × 0.2 = 6, 그 속도로 0.2초 가서 자리는 (0.4, 1.2).", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "accent"},
      {"id": "v", "from": [0.4, 1.2], "to": [0.7, 2.1], "color": "warn", "label": "v (2, 6)", "labelAt": [1.15, 2.25]}
    ] },
    { "caption": "0.4초. 속도 y 는 6 − 2 = 4, 자리는 1.2 + 4 × 0.2 = 2.0. 올라가는 양이 매번 0.4 씩 줄어듭니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "accent"},
      {"id": "v", "from": [0.8, 2.0], "to": [1.1, 2.6], "color": "warn", "label": "v (2, 4)", "labelAt": [1.55, 2.75]}
    ] },
    { "caption": "0.6초. 속도 y 2, 자리 2.4.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "accent"},
      {"id": "v", "from": [1.2, 2.4], "to": [1.5, 2.7], "color": "warn", "label": "v (2, 2)", "labelAt": [1.95, 2.85]}
    ] },
    { "caption": "0.8초. 속도 y 가 0. 이번 0.2초 동안은 위로 가지 않아 자리가 2.4 그대로입니다. 꼭대기입니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "muted"},
      {"id": "p4", "type": "point", "at": [1.6, 2.4], "color": "accent"},
      {"id": "v", "from": [1.6, 2.4], "to": [1.9, 2.4], "color": "warn", "label": "v (2, 0)", "labelAt": [2.35, 2.55]}
    ] },
    { "caption": "1.0초. 속도 y 가 −2 로 아래를 향해, 내려오기 시작합니다. 자리 2.0.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "muted"},
      {"id": "p4", "type": "point", "at": [1.6, 2.4], "color": "muted"},
      {"id": "p5", "type": "point", "at": [2.0, 2.0], "color": "accent"},
      {"id": "v", "from": [2.0, 2.0], "to": [2.3, 1.7], "color": "warn", "label": "v (2, −2)", "labelAt": [2.75, 1.85]}
    ] },
    { "caption": "1.2초. 속도 y −4, 자리 1.2. 내려오는 양이 매번 0.4 씩 늘어납니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "muted"},
      {"id": "p4", "type": "point", "at": [1.6, 2.4], "color": "muted"},
      {"id": "p5", "type": "point", "at": [2.0, 2.0], "color": "muted"},
      {"id": "p6", "type": "point", "at": [2.4, 1.2], "color": "accent"},
      {"id": "v", "from": [2.4, 1.2], "to": [2.7, 0.6], "color": "warn", "label": "v (2, −4)", "labelAt": [3.15, 0.75]}
    ] },
    { "caption": "1.4초. 바닥 0 에 닿았습니다. 가로로는 속도 2 가 그대로라 0.4 씩 일정하게 갔습니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "muted"},
      {"id": "p4", "type": "point", "at": [1.6, 2.4], "color": "muted"},
      {"id": "p5", "type": "point", "at": [2.0, 2.0], "color": "muted"},
      {"id": "p6", "type": "point", "at": [2.4, 1.2], "color": "muted"},
      {"id": "p7", "type": "point", "at": [2.8, 0.0], "color": "accent"},
      {"id": "v", "from": [2.8, 0.0], "to": [3.1, -0.9], "color": "warn", "label": "v (2, −6)", "labelAt": [3.55, -0.75]}
    ] },
    { "caption": "「정확한 포물선」 이라 적힌 선이 시간을 잘게 쪼개 계산한 포물선입니다. 0.2초씩 계산한 점들은 모양은 같지만 꼭대기가 2.4 로 낮고 더 일찍 떨어졌습니다. 계산 간격이 클수록 오차가 큽니다.", "items": [
      {"id": "p0", "type": "point", "at": [0, 0], "color": "muted"},
      {"id": "p1", "type": "point", "at": [0.4, 1.2], "color": "muted"},
      {"id": "p2", "type": "point", "at": [0.8, 2.0], "color": "muted"},
      {"id": "p3", "type": "point", "at": [1.2, 2.4], "color": "muted"},
      {"id": "p4", "type": "point", "at": [1.6, 2.4], "color": "muted"},
      {"id": "p5", "type": "point", "at": [2.0, 2.0], "color": "muted"},
      {"id": "p6", "type": "point", "at": [2.4, 1.2], "color": "muted"},
      {"id": "p7", "type": "point", "at": [2.8, 0.0], "color": "muted"},
      {"id": "e0", "type": "line", "from": [0.0, 0.0], "to": [0.2, 0.75], "color": "accent2"},
      {"id": "e1", "type": "line", "from": [0.2, 0.75], "to": [0.4, 1.4], "color": "accent2"},
      {"id": "e2", "type": "line", "from": [0.4, 1.4], "to": [0.6, 1.95], "color": "accent2"},
      {"id": "e3", "type": "line", "from": [0.6, 1.95], "to": [0.8, 2.4], "color": "accent2"},
      {"id": "e4", "type": "line", "from": [0.8, 2.4], "to": [1.0, 2.75], "color": "accent2"},
      {"id": "e5", "type": "line", "from": [1.0, 2.75], "to": [1.2, 3.0], "color": "accent2"},
      {"id": "e6", "type": "line", "from": [1.2, 3.0], "to": [1.4, 3.15], "color": "accent2"},
      {"id": "e7", "type": "line", "from": [1.4, 3.15], "to": [1.6, 3.2], "color": "accent2"},
      {"id": "e8", "type": "line", "from": [1.6, 3.2], "to": [1.8, 3.15], "color": "accent2"},
      {"id": "e9", "type": "line", "from": [1.8, 3.15], "to": [2.0, 3.0], "color": "accent2"},
      {"id": "e10", "type": "line", "from": [2.0, 3.0], "to": [2.2, 2.75], "color": "accent2"},
      {"id": "e11", "type": "line", "from": [2.2, 2.75], "to": [2.4, 2.4], "color": "accent2"},
      {"id": "e12", "type": "line", "from": [2.4, 2.4], "to": [2.6, 1.95], "color": "accent2"},
      {"id": "e13", "type": "line", "from": [2.6, 1.95], "to": [2.8, 1.4], "color": "accent2"},
      {"id": "e14", "type": "line", "from": [2.8, 1.4], "to": [3.0, 0.75], "color": "accent2"},
      {"id": "e15", "type": "line", "from": [3.0, 0.75], "to": [3.2, 0.0], "color": "accent2"},
      {"id": "t1", "type": "text", "at": [1.6, 3.45], "color": "accent2", "label": "정확한 포물선: 꼭대기 3.2"},
      {"id": "t2", "type": "text", "at": [1.4, 2.75], "color": "muted", "label": "0.2초씩: 꼭대기 2.4"}
    ] }
  ]
}
</script>
</x-vec>

## 포물선과 t²: 잘게 쪼개면 나오는 정확한 식

**포물선**은 공을 비스듬히 던졌을 때 그리는 곡선입니다. 꼭대기를 가운데에 두고 왼쪽과 오른쪽이 같은 모양으로 휘어 있습니다.
계산 간격을 아주 잘게 쪼개면 높이가 어떤 식이 되는지, 평균 속도로 구할 수 있습니다.

속도가 일정하게 바뀔 때는 **그동안의 평균 속도가 처음 속도와 나중 속도의 한가운데**입니다. 8 에서 0 으로 고르게 줄었다면 평균은 4 입니다.
처음 0.8초 동안 속도는 8 에서 0 으로 줄었으니 평균 4 로 0.8초를 간 셈이고, 오른 높이는 4 × 0.8 = **3.2** 입니다. 이것이 정확한 꼭대기입니다.

아무 시각 t 초에 대해서도 같은 방법이 됩니다.

| 단계 | 계산 |
| --- | --- |
| t 초 뒤 속도 | 8 − 10t |
| 그동안의 평균 속도 | (8 + 8 − 10t) ÷ 2 = 8 − 5t |
| t 초 동안 오른 높이 = 평균 속도 × t | (8 − 5t) × t = **8t − 5t²** |

t² 은 t × t 입니다(같은 수를 두 번 곱한 것, "t 의 제곱"). 이 식이 물리 교과서의 `높이 = 처음 속도 × t − ½ × 중력 × t²` 입니다. 5 가 ½ × 10 입니다.

| t | 8t | 5t² | 높이 |
| --- | --- | --- | --- |
| 0.2 | 1.6 | 0.2 | 1.4 |
| 0.4 | 3.2 | 0.8 | 2.4 |
| 0.8 | 6.4 | 3.2 | **3.2** (꼭대기) |
| 1.6 | 12.8 | 12.8 | 0 (바닥) |

8t 는 시간에 비례해 곧게 늘어나지만 5t² 은 시간이 2배면 4배로 늘어납니다. 처음엔 8t 가 이겨서 오르다가, 나중엔 5t² 이 따라잡아 끌어내립니다. 이렇게 t² 이 들어간 식의 그래프가 포물선입니다.

## 계산이 정확한 값과 어긋나는 이유

0.2초씩 계산한 꼭대기는 2.4 였고, 정확한 꼭대기는 3.2 입니다. 0.8 이 모자랍니다.
원인은 **0.2초 동안 속도가 바뀌는데, 계산은 그 0.2초 내내 한 가지 속도만 쓴다**는 데 있습니다.
첫 0.2초 동안 실제 속도는 8 에서 6 으로 서서히 줄어 평균 7 이지만, 계산은 줄어든 뒤의 6 으로 0.2초를 다 간 것으로 칩니다. 그래서 매번 조금씩 덜 오릅니다. 간격을 줄이면 오차도 줄어듭니다.

| 계산 간격 | 꼭대기 높이 | 정확한 값과의 차이 |
| --- | --- | --- |
| 0.2초 | 2.4 | −0.8 |
| 0.05초 | 3.0 | −0.2 |
| 아주 잘게 | 3.2 | 0 |

이렇게 짧은 간격마다 "바뀌는 양 × 간격" 을 더해 모아 가는 계산을 **수치 적분**이라고 부릅니다. 적분은 "잘게 나눠 더해 모은다" 는 뜻입니다. 위의 가장 단순한 방식은 수학자 오일러의 이름을 따 **오일러 방법**이라고 합니다.

**두 줄의 순서도 결과를 바꿉니다.** 위치를 먼저 옮기면 줄기 전의 속도를 쓰게 됩니다.

| 순서 | 이름 | 첫 0.2초에 쓰는 속도 | 0.2초 간격 꼭대기 |
| --- | --- | --- | --- |
| 속도 먼저, 새 속도로 위치 | 반암시적(semi-implicit) 오일러 | 6 | 2.4 (0.8 낮음) |
| 위치 먼저, 옛 속도로 위치 | 명시적(explicit) 오일러 | 8 | 4.0 (0.8 높음) |

점프에서는 둘 다 0.8 씩 어긋나지만, 용수철처럼 되돌아오는 힘이 있으면 차이가 커집니다. 명시적 오일러는 흔들림이 갈수록 커지며 에너지가 불어나 터지고, 반암시적 오일러는 오래 돌려도 흔들림의 크기를 대체로 지킵니다.
그래서 게임 물리는 보통 **속도를 먼저 바꾸고 그 속도로 위치를 옮기는** 순서를 씁니다. Glenn Fiedler 의 「Integration Basics」 도 대부분의 상용 게임 물리 엔진이 이 방식을 쓴다고 정리합니다.

이 오차는 게임에서 그대로 드러납니다. 아래 코드로 2m 점프를 만들면 꼭대기가 144fps 에서 약 1.98m, 60fps 에서 1.95m, 30fps 에서 1.90m 입니다. **프레임 시간이 긴 기계일수록 간격이 큰 계산이 되어 점프가 낮아집니다.**
유니티의 물리 엔진이 화면 프레임과 따로 **늘 같은 간격**(기본 0.02초)으로 도는 이유 중 하나가 이것입니다. 간격이 같으면 기계가 달라도 같은 높이로 뜁니다.

## CharacterController 로 점프하기

`CharacterController` 는 벽과 바닥에 막히며 움직이는 기능만 있고 중력은 없어서, 위의 두 줄을 직접 씁니다.

```csharp
public float gravity = -9.81f;
public float jumpHeight = 2f;

CharacterController controller;
Vector3 velocity;   // 속도는 프레임을 넘어 쌓여야 하니 필드로 둔다

void Start()
{
    controller = GetComponent<CharacterController>();
}

void Update()
{
    if (controller.isGrounded && velocity.y < 0f)
    {
        velocity.y = -1f;   // 땅에 있으면 떨어지는 속도가 끝없이 쌓이지 않게 작게 눌러 둔다
    }

    if (controller.isGrounded && Input.GetButtonDown("Jump"))
    {
        // 원하는 높이에 닿는 처음 속도. 다음 절의 식
        velocity.y = Mathf.Sqrt(2f * -gravity * jumpHeight);
    }

    velocity.y += gravity * Time.deltaTime;       // 속도 += 가속도 × dt
    controller.Move(velocity * Time.deltaTime);   // 위치 += 속도 × dt
}
```

점프 키는 속도를 한 번 바꿀 뿐이고, 그 뒤의 오르내림은 전부 중력이 속도를 바꾼 결과입니다.
`Rigidbody` 를 쓰면 물리 엔진이 이 두 줄을 0.02초 간격으로 대신 계산합니다. 그때도 점프는 위치가 아니라 속도를 넣는 것이라, 위로 향한 속도를 같은 식으로 줍니다.

## 원하는 높이로 뛰게 하는 처음 속도

"2m 높이로 뛰게 하고 싶다" 가 보통의 요구입니다. 처음 속도를 몇으로 줘야 할까요. 꼭대기에서 속도가 0 이 된다는 것과 평균 속도를 씁니다.

| 단계 | 식 | v = 8, g = 10 일 때 |
| --- | --- | --- |
| 위로 v 로 출발해 1초에 g 씩 줄면, 0 이 될 때까지 | 시간 = v ÷ g | 8 ÷ 10 = 0.8초 |
| 그동안 평균 속도 (v 와 0 의 한가운데) | v ÷ 2 | 4 |
| 오른 높이 = 평균 속도 × 시간 | (v ÷ 2) × (v ÷ g) = v² ÷ (2g) | 4 × 0.8 = 3.2 |

높이 h = v² ÷ (2g) 를 거꾸로 풀면 v² = 2 × g × h 입니다. v 는 "두 번 곱해서 2gh 가 되는 수" 이고, 이것을 **제곱근**이라 부르며 √ 로 씁니다. √64 = 8 입니다(8 × 8 = 64). 코드에서는 `Mathf.Sqrt` 입니다.

```text
처음 속도 v = √(2 × g × 높이)
```

| 원하는 높이 | 중력 10 일 때 처음 속도 | 확인: v² ÷ 20 |
| --- | --- | --- |
| 3.2 | √64 = 8 | 64 ÷ 20 = 3.2 |
| 2 | √40 ≈ 6.32 | 40 ÷ 20 = 2 |
| 0.8 | √16 = 4 | 16 ÷ 20 = 0.8 |

이 식으로 처음 속도를 정하면 중력을 바꿔도 점프 높이는 그대로입니다. 중력을 키우면 같은 높이를 더 빨리 오르내려 묵직해지고, 줄이면 둥실 뜹니다. 높이와 느낌을 따로 조절할 수 있습니다.

## 정리

- 속도는 위치가 1초에 바뀌는 양, 가속도는 속도가 1초에 바뀌는 양입니다. 중력은 아래로 향한 가속도(유니티 기본 9.81)입니다
- 중력은 위치가 아니라 속도를 바꿉니다. 매 프레임 `속도 += 중력 × dt`, `위치 += 속도 × dt` 입니다
- 속도가 일정하게 바뀌면 높이는 `처음 속도 × t − ½ × 중력 × t²` 이 되어 포물선을 그립니다. 평균 속도 × 시간으로 구할 수 있습니다
- 계산 간격 동안 속도를 하나로 고정해서 오차가 생깁니다. 간격이 크면 꼭대기가 낮아지고, 그래서 fps 가 다르면 점프 높이도 다릅니다
- 속도를 먼저 바꾸고 그 속도로 위치를 옮기는 반암시적 오일러가 게임 물리의 기본 순서입니다
- 원하는 높이 h 로 뛰려면 처음 속도를 √(2 × g × h) 로 줍니다