---
title: "빠른 총알이 벽을 뚫고 지나가는 이유"
description: "물리는 0.02초마다 그 순간의 자리만 보고 겹쳤는지 검사합니다. 한 걸음에 가는 거리가 벽 두께보다 크면, 벽 앞과 벽 뒤만 보고 부딪힌 적이 없다고 판단합니다. 연속 충돌 검사와 레이캐스트로 그 사이를 메웁니다."
author: "유니얼"
pubDate: 2026-11-05
category: "game-dev/math"
tags: ["Unity", "기초"]
canonical: https://blog.unialgames.com/posts/math-tunneling/
---
총알에 Rigidbody(물체를 물리 엔진에 맡기는 컴포넌트)를 붙이고 빠른 속도를 줬더니, 얇은 벽을 그냥 통과합니다. 천천히 쏘면 잘 막히는데, 빠르게 쏘면 가끔 뚫고 아주 빠르면 거의 늘 뚫습니다.
이렇게 부딪혀야 할 벽이나 바닥을 건너뛰어 지나가는 것을 **터널링**이라고 부릅니다.

물리 엔진(유니티에서 중력 · 충돌을 계산하는 부분)은 0.02초마다 **그 순간의 자리**에서 겹쳤는지를 봅니다. 그 사이에 어디를 지나갔는지는 보지 않습니다.
한 걸음에 가는 거리가 벽 두께보다 크면, 이번 걸음은 벽 앞, 다음 걸음은 벽 뒤에 있어서 **어느 순간에도 벽과 겹치지 않습니다.** 엔진은 부딪힌 적이 없다고 보고 그대로 보냅니다.

> **한 걸음의 거리 = 속도 × 0.02초 입니다. 이것이 벽 두께와 물체 크기를 더한 것보다 크면 뚫릴 수 있습니다.
> Rigidbody 의 Collision Detection 을 Continuous 계열로 바꾸거나, 이번 걸음에 갈 길을 레이캐스트로 검사해 그 사이를 메웁니다.**

## 한 걸음에 얼마나 가나

물리는 0.02초마다 한 걸음씩 계산합니다. 이 한 걸음의 길이가 `Time.fixedDeltaTime`(기본 0.02초, 1초에 50걸음)이고, 걸음마다 `FixedUpdate` 함수가 불립니다.
속도는 1초에 가는 거리라 5m/s 면 1초에 5m 를 가고, 한 걸음에 가는 거리는 **속도 × 0.02** 입니다. 걷는 캐릭터(5m/s)는 5 × 0.02 = 0.1m, 총알(300m/s)은 300 × 0.02 = 6m 입니다.

두께 0.4m 벽에 크기 0.1m 물체를 보낸다고 합시다. 물체의 가운데가 벽 앞면보다 0.05m(물체 크기의 절반) 앞에서부터 벽 뒷면보다 0.05m 뒤까지에 있으면, 물체 끝이 벽에 걸쳐 겹칩니다.
그 구간의 폭이 0.05 + 0.4 + 0.05 = **0.5m**, 곧 **벽 두께 + 물체 크기** 입니다.
한 걸음이 이 0.5m 안에 떨어지면 막히고, 0.5m 를 통째로 건너뛰면 뚫립니다. 걸음이 어디서 시작했는지는 매번 다르니, 막힐 확률은 대략 0.5 ÷ 한 걸음 입니다.

| 무엇 | 속도 (m/s) | 한 걸음에 | 막힐 확률 (0.4m 벽, 0.1m 물체) |
| --- | --- | --- | --- |
| 걷는 캐릭터 | 5 | 0.1m | 늘 막힘 (걸음이 0.5m 보다 짧음) |
| 떨어지는 공 (3초 낙하) | 30 | 0.6m | 0.5 ÷ 0.6 ≈ 83% |
| 화살 | 60 | 1.2m | 0.5 ÷ 1.2 ≈ 42% |
| 총알 | 300 | 6m | 0.5 ÷ 6 ≈ 8% |

천천히 쏘면 막히고, 빠르면 가끔, 아주 빠르면 거의 늘 뚫리는 것이 이 표 그대로입니다.
떨어지는 공은 처음엔 느리다가 중력으로 1초에 9.81m/s 씩 빨라지니(3초면 약 30m/s), 높은 곳에서 떨어질수록 바닥 근처에서 한 걸음이 커집니다. 잘 되다가 어느 높이부터 뚫리는 이유입니다.

## 벽 앞과 벽 뒤만 보면 부딪힌 적이 없다

1초에 300m 로 날아가는 총알과, 8m 앞의 두께 0.4m 벽입니다. 한 걸음에 6m 를 갑니다.
걸음마다 그 순간 두 물체가 서로 파고들어 있는지만 보는 것을 **겹침 검사**라고 하고, 물리 엔진의 기본 충돌 검사입니다.

<x-vec>
<script type="application/json">
{
  "label": "300m/s 총알과 0.4m 벽",
  "view": [-1, -2, 14, 2],
  "cases": [
    {
      "name": "자리만 검사 (Discrete)",
      "steps": [
        { "caption": "총알이 0m 에 있습니다. 한 걸음(0.02초)에 6m 를 갑니다. 벽은 8m 에서 8.4m 사이입니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "b", "type": "point", "at": [0, 0], "color": "accent", "label": "0m", "labelAt": [0, -0.6] },
          { "id": "v", "from": [0, 0], "to": [6, 0], "color": "muted", "dash": true, "label": "한 걸음 6m", "labelAt": [3, 0.5] } ] },
        { "caption": "한 걸음 뒤 6m. 이 순간의 자리만 보고 겹침 검사를 합니다. 벽 앞이라 겹치지 않았습니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "p0", "type": "point", "at": [0, 0], "color": "muted" },
          { "id": "b", "type": "point", "at": [6, 0], "color": "accent", "label": "6m", "labelAt": [6, -0.6] } ] },
        { "caption": "두 걸음 뒤 12m. 벽 뒤라 이번에도 겹치지 않았습니다. 엔진이 본 자리 0m, 6m, 12m 가 모두 벽 밖이라, 부딪힌 적이 없다고 판단하고 그대로 보냅니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "p0", "type": "point", "at": [0, 0], "color": "muted" },
          { "id": "p1", "type": "point", "at": [6, 0], "color": "muted" },
          { "id": "b", "type": "point", "at": [12, 0], "color": "warn", "label": "12m 통과", "labelAt": [12, -0.6] } ] }
      ]
    },
    {
      "name": "걸음 사이의 길을 검사 (Continuous)",
      "steps": [
        { "caption": "같은 총알, 같은 벽. 이번에는 걸음마다 그 걸음에 지나갈 길 전체를 검사합니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "b", "type": "point", "at": [0, 0], "color": "accent", "label": "0m", "labelAt": [0, -0.6] } ] },
        { "caption": "첫 걸음. 0m 에서 6m 까지의 길을 검사합니다. 길 위에 아무것도 없으니 6m 로 갑니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "s1", "from": [0, 0], "to": [6, 0], "color": "accent2" },
          { "id": "b", "type": "point", "at": [6, 0], "color": "accent", "label": "6m", "labelAt": [6, -0.6] } ] },
        { "caption": "둘째 걸음. 6m 에서 12m 까지의 길을 검사하면 8m 에서 벽에 닿습니다. 6m 길의 (8 − 6) ÷ 6 = 0.33 지점입니다. 12m 로 보내지 않고 8m 에서 부딪힌 것으로 처리합니다.", "items": [
          { "id": "w1", "type": "line", "from": [8, -1.5], "to": [8, 1.5], "color": "muted", "label": "벽", "labelAt": [8.2, 1.7] },
          { "id": "w2", "type": "line", "from": [8.4, -1.5], "to": [8.4, 1.5], "color": "muted" },
          { "id": "s1", "from": [0, 0], "to": [6, 0], "color": "muted" },
          { "id": "s2", "from": [6, 0], "to": [8, 0], "color": "accent2", "label": "닿음", "labelAt": [7, 0.5] },
          { "id": "x", "type": "line", "from": [8, 0], "to": [12, 0], "color": "muted" },
          { "id": "b", "type": "point", "at": [8, 0], "color": "warn", "label": "8m 에서 부딪힘", "labelAt": [8, -0.6] } ] }
      ]
    }
  ]
}
</script>
</x-vec>

자리만 보는 방식에서 막히려면, 엔진이 본 자리 중 하나가 겹치는 구간 안에 있어야 합니다. 이 벽에 크기 0.1m 총알이면 가운데가 7.95m 에서 8.45m 사이인 0.5m 구간입니다.
0m, 6m, 12m 는 모두 이 구간 밖이라 막히지 않았습니다. 한 걸음이 0.5m 보다 길면 이렇게 구간을 통째로 건너뛸 수 있고, 속도로 바꾸면 0.5 ÷ 0.02 = **25m/s** 를 넘을 때부터 뚫릴 수 있습니다.

## Rigidbody 의 Collision Detection 바꾸기

Rigidbody 의 Collision Detection 칸이 걸음 사이를 볼지 정합니다. Continuous 계열을 통틀어 **연속 충돌 검사**(CCD)라고 부릅니다.

충돌은 눈에 보이는 메시가 아니라 따로 붙인 **콜라이더**로 따집니다. 콜라이더는 상자(Box) · 공(Sphere) · 캡슐(Capsule) 같은 충돌용 모양이고, Rigidbody 없이 콜라이더만 붙어 벽 · 바닥처럼 움직이지 않는 것을 **정적 콜라이더**라고 부릅니다.

방식은 둘입니다. **스윕**은 위 그림처럼 콜라이더 모양을 이번 걸음의 길을 따라 밀어 보며 처음 닿는 곳을 찾습니다. **추측(speculative)**은 다음 걸음에 갈 만큼 검사 범위를 넓혀, 그 안에 있는 것들을 미리 접촉 후보로 넣어 둡니다.

| 설정 | 하는 일 | 비용 |
| --- | --- | --- |
| `Discrete` (기본) | 걸음마다 그 자리만 겹침 검사 | 가장 쌈 |
| `Continuous` | 정적 콜라이더(벽 · 바닥)에 대해서만 스윕. 다른 Rigidbody 와는 Discrete | 비쌈 |
| `Continuous Dynamic` | 정적 콜라이더에 더해, Continuous 나 Continuous Dynamic 인 Rigidbody 와도 스윕 | 가장 비쌈 |
| `Continuous Speculative` | 정적 콜라이더와 Rigidbody 모두에 추측 방식. 회전도 셈에 넣음 | 스윕보다 쌈 |

어떻게 고르나는 이렇습니다.

- **빠른 것이 벽 · 바닥에 부딪힌다**면 그 빠른 것을 `Continuous` 로
- **빠른 것끼리 부딪힌다**면 빠른 쪽을 `Continuous Dynamic` 으로, 부딪힐 상대를 `Continuous` 로
- **빠르게 도는 것**(돌아가는 문, 휘두르는 막대)이나 **Kinematic**(힘을 안 받고 코드로만 움직이는 Rigidbody)이면 `Continuous Speculative`. 스윕은 곧게 미는 것만 검사해서 회전으로 생기는 충돌을 못 잡고, Kinematic 에는 Speculative 만 쓸 수 있습니다

장단점도 있습니다.

- **비용.** 모든 Rigidbody 를 연속 검사로 바꾸면 물리 비용이 크게 늡니다. 빠른 물체 여럿이 가까이 몰려 있으면 스윕 횟수가 빠르게 늘어납니다. **빠른 것만** 바꿉니다
- **Speculative 의 부작용.** 미리 넣어 둔 후보 때문에 닿기도 전에 튕기거나 모서리에 걸리는 일(유령 충돌)이 생기고, 후보를 걸음 처음에 한 번만 모으니 그 걸음 동안 새로 생긴 접촉은 놓쳐 여전히 뚫릴 수 있습니다

## 레이캐스트로 직접 메우기

총알처럼 아주 빠르고 많은 것은 Rigidbody 대신 **레이캐스트**로 처리하는 경우가 많습니다.
레이캐스트는 한 점에서 한 방향으로 정한 길이만큼 보이지 않는 선을 쏘아, 처음 닿는 콜라이더를 찾는 함수입니다. 이번 걸음에 갈 길 전체를 선으로 검사하면 사이를 건너뛰는 일이 없습니다. 위 그림의 Continuous 와 같은 생각을 직접 하는 것입니다.

```csharp
public float speed = 300f;
Vector3 velocity;

void Start()
{
    velocity = transform.forward * speed;
}

void FixedUpdate()
{
    // 이번 걸음에 갈 화살표. 속도 × 시간
    Vector3 move = velocity * Time.fixedDeltaTime;

    // 지금 자리에서 이번 걸음의 끝까지 선으로 검사한다
    bool hitSomething = Physics.Raycast(transform.position, move.normalized, out RaycastHit hit, move.magnitude);

    if (hitSomething)
    {
        // 닿은 자리에서 멈춤
        transform.position = hit.point;
        OnHit(hit);
        return;
    }

    transform.position += move;
}
```

`move.normalized` 가 쏘는 방향(길이 1 로 줄인 화살표), `move.magnitude` 가 이번 걸음의 길이(6m)라서, 이번 걸음에 지나갈 거리 안에 있는 것만 잡습니다.
선은 굵기가 없어서 크기가 있는 물체라면 `Physics.SphereCast`(공을 밀어 보는 검사)를 씁니다. 총알 자신에게 콜라이더가 있으면 자기 자신을 맞히지 않게 레이어 마스크로 거릅니다.

## 그 밖의 방법

| 방법 | 효과 | 치르는 것 |
| --- | --- | --- |
| 벽과 바닥 콜라이더를 두껍게 | 위의 0.5m 가 넓어져 뚫리는 속도가 올라감 | 보이는 벽과 충돌 벽이 달라짐 |
| 최고 속도를 제한 | 한 걸음이 일정 이하 | 게임 느낌이 바뀜 |
| `fixedDeltaTime` 을 줄임 | 모든 물체의 한 걸음이 짧아짐 | 물리 계산이 늘어남. 0.01 이면 두 배 |
| Continuous 계열 | 빠른 것만 정확히 | 그 물체의 검사 비용 |
| 레이캐스트 | 아주 빠른 것도 정확히 | 충돌 처리를 직접 해야 함 |

## 정리

- 물리는 걸음마다 그 순간의 자리만 보고 겹쳤는지 검사합니다. 사이에 지나간 길은 보지 않습니다
- 한 걸음의 거리 = 속도 × `fixedDeltaTime` 이 벽 두께 + 물체 크기보다 크면 벽을 통째로 건너뛸 수 있습니다. 0.4m 벽과 0.1m 물체면 25m/s 부터입니다
- 떨어지는 물체는 점점 빨라져서, 어느 높이부터 바닥을 뚫기 시작합니다
- `Continuous` 는 정적 콜라이더에 대해, `Continuous Dynamic` 은 연속 검사 Rigidbody 에 대해서도 지나간 길을 스윕합니다. `Continuous Speculative` 는 더 싸고 회전과 Kinematic 에 쓰지만 유령 충돌이 생길 수 있습니다
- 연속 검사는 비싸니 빠른 것만 바꿉니다
- 아주 빠른 총알은 이번 걸음의 길이만큼 레이캐스트를 쏘아 처리합니다