---
title: "건너뛰어도 되는 칸은 건너뛴다"
description: "균일한 격자에서는 같은 비용의 길이 수십 개라서 탐색이 그만큼 넓어집니다. 그중 하나만 보게 만드는 점프 포인트 서치(JPS, Jump Point Search)의 규칙과, 이 방법이 통하지 않는 자리."
author: "유니얼"
pubDate: 2026-09-17
updatedDate: 2026-09-18
category: "programming/algorithm/graph"
tags: ["최적화", "격자"]
canonical: https://blog.unialgames.com/posts/algo-jps/
---
벽이 하나도 없는 빈 타일맵에서 길찾기를 돌려 보면, 목표 쪽으로 기울여 놨는데도 탐색이 꽤 넓게 퍼집니다.
막힌 곳이 없으니 헤맬 이유가 없어 보이는데도 그렇습니다.

이유는 벽이 아니라 **같은 값이 너무 많아서**입니다.

## 격자에서는 같은 길이 스무 개다

오른쪽으로 세 칸, 아래로 세 칸 떨어진 칸으로 갑니다. 가로세로로만 움직이면 어떤 순서로 가든 여섯 걸음입니다.
`R R R D D D` 를 섞는 가짓수는 스무 가지고, **그 스무 가지의 비용이 전부 같습니다.**

<figure class="diagram">
<svg viewBox="0 0 470 168" role="img" aria-label="오른쪽 세 칸 아래 세 칸 떨어진 칸으로 가는 길 세 가지. 걸음 수가 모두 같다">
<g fill="var(--bg)" stroke="var(--border)" stroke-width="1">
<rect x="20" y="34" width="26" height="26"></rect>
<rect x="46" y="34" width="26" height="26"></rect>
<rect x="72" y="34" width="26" height="26"></rect>
<rect x="98" y="34" width="26" height="26"></rect>
<rect x="20" y="60" width="26" height="26"></rect>
<rect x="46" y="60" width="26" height="26"></rect>
<rect x="72" y="60" width="26" height="26"></rect>
<rect x="98" y="60" width="26" height="26"></rect>
<rect x="20" y="86" width="26" height="26"></rect>
<rect x="46" y="86" width="26" height="26"></rect>
<rect x="72" y="86" width="26" height="26"></rect>
<rect x="98" y="86" width="26" height="26"></rect>
<rect x="20" y="112" width="26" height="26"></rect>
<rect x="46" y="112" width="26" height="26"></rect>
<rect x="72" y="112" width="26" height="26"></rect>
<rect x="98" y="112" width="26" height="26"></rect>
<rect x="180" y="34" width="26" height="26"></rect>
<rect x="206" y="34" width="26" height="26"></rect>
<rect x="232" y="34" width="26" height="26"></rect>
<rect x="258" y="34" width="26" height="26"></rect>
<rect x="180" y="60" width="26" height="26"></rect>
<rect x="206" y="60" width="26" height="26"></rect>
<rect x="232" y="60" width="26" height="26"></rect>
<rect x="258" y="60" width="26" height="26"></rect>
<rect x="180" y="86" width="26" height="26"></rect>
<rect x="206" y="86" width="26" height="26"></rect>
<rect x="232" y="86" width="26" height="26"></rect>
<rect x="258" y="86" width="26" height="26"></rect>
<rect x="180" y="112" width="26" height="26"></rect>
<rect x="206" y="112" width="26" height="26"></rect>
<rect x="232" y="112" width="26" height="26"></rect>
<rect x="258" y="112" width="26" height="26"></rect>
<rect x="340" y="34" width="26" height="26"></rect>
<rect x="366" y="34" width="26" height="26"></rect>
<rect x="392" y="34" width="26" height="26"></rect>
<rect x="418" y="34" width="26" height="26"></rect>
<rect x="340" y="60" width="26" height="26"></rect>
<rect x="366" y="60" width="26" height="26"></rect>
<rect x="392" y="60" width="26" height="26"></rect>
<rect x="418" y="60" width="26" height="26"></rect>
<rect x="340" y="86" width="26" height="26"></rect>
<rect x="366" y="86" width="26" height="26"></rect>
<rect x="392" y="86" width="26" height="26"></rect>
<rect x="418" y="86" width="26" height="26"></rect>
<rect x="340" y="112" width="26" height="26"></rect>
<rect x="366" y="112" width="26" height="26"></rect>
<rect x="392" y="112" width="26" height="26"></rect>
<rect x="418" y="112" width="26" height="26"></rect>
</g>
<rect x="20" y="34" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<rect x="98" y="112" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<polyline points="33,47 59,47 85,47 111,47 111,73 111,99 111,125" fill="none" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<rect x="180" y="34" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<rect x="258" y="112" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<polyline points="193,47 193,73 193,99 193,125 219,125 245,125 271,125" fill="none" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<rect x="340" y="34" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<rect x="418" y="112" width="26" height="26" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1"></rect>
<polyline points="353,47 379,47 379,73 405,73 405,99 431,99 431,125" fill="none" stroke="var(--accent)" stroke-width="3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<g font-size="12" fill="var(--text-muted)" text-anchor="middle">
<text x="72" y="156">바깥쪽으로</text>
<text x="232" y="156">아래로 먼저</text>
<text x="392" y="156">번갈아</text>
</g>
<text x="20" y="20" font-size="12" fill="var(--text-muted)">여섯 걸음으로 같은 칸에 닿는 길. 이런 길이 스무 개 있다</text>
</svg>
<figcaption>어떤 순서로 가든 여섯 걸음이다. 우선순위 큐는 이 셋을 구별하지 못한다.</figcaption>
</figure>

대각선을 허용해도 마찬가지입니다. 대각선 몇 번과 가로세로 몇 번을 어떤 순서로 섞든 값이 같습니다.
오히려 같은 값의 길이 더 늘어납니다.

우선순위 큐는 이 스무 개 사이에서 우열을 가릴 수 없습니다. 값이 같으니까요.
그래서 하나를 붙잡고 끝까지 가 보는 대신, 스무 개를 조금씩 다 펼쳐 놓고 번갈아 봅니다.
칸 하나가 여러 경로에 동시에 들어가고, 큐에 넣었다 빼는 일이 그만큼 늘어납니다.

같은 비용의 길이 여러 개 존재하는 이 성질을 **경로 대칭(path symmetry)** 이라고 부릅니다.
동점일 때 목표에 가까운 쪽을 먼저 꺼내게 해 두면 꽤 줄어들지만, 대칭 자체가 사라지지는 않습니다.
**줄 세우기를 바꾸는 것으로는 한계가 있습니다. 애초에 만들지 않는 편이 낫습니다.**

## 스무 개 중 하나만 만든다

**점프 포인트 서치(JPS, Jump Point Search)** 는 그 스무 개 중 **하나만** 만들도록 규칙을 정합니다.
나머지 열아홉 개는 탐색 중에 아예 생기지 않습니다.

방법은 이렇습니다. 이웃 한 칸씩 큐에 넣는 대신,
**한 방향으로 계속 나아가다가 「여기서 다시 판단해야 한다」 는 칸이 나올 때만 큐에 넣습니다.**
지나온 칸들은 큐에 들어가지 않습니다. 어차피 그 칸들에서 갈라질 이유가 없었기 때문입니다.

큐에 넣는 그 칸을 **점프 포인트(jump point)** 라고 부릅니다.

## 다시 판단해야 하는 칸은 벽이 만든다

빈 들판에서는 갈라질 이유가 없습니다. 어느 칸에서 꺾든 값이 같으니까요.
**갈라질 이유는 벽이 만듭니다.** 벽 때문에 「이 칸을 지나야만 닿는 칸」 이 생기면, 거기서는 판단이 필요합니다.
그런 이웃을 **강제 이웃(forced neighbor)** 이라고 부릅니다.

<x-tabs>
<figure class="diagram" data-tab="벽이 없으면">
<svg viewBox="0 0 466 176" role="img" aria-label="p 에서 오른쪽으로 갈 때. n 에서 새로 열리는 칸이 없다">
<defs>
<marker id="jp-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M0 0 L10 5 L0 10 z" fill="var(--accent)"></path>
</marker>
</defs>
<g fill="var(--bg)" stroke="var(--border)" stroke-width="1">
<rect x="20" y="30" width="40" height="40"></rect>
<rect x="60" y="30" width="40" height="40"></rect>
<rect x="100" y="30" width="40" height="40"></rect>
<rect x="140" y="30" width="40" height="40"></rect>
<rect x="180" y="30" width="40" height="40"></rect>
<rect x="20" y="70" width="40" height="40"></rect>
<rect x="60" y="70" width="40" height="40"></rect>
<rect x="100" y="70" width="40" height="40"></rect>
<rect x="140" y="70" width="40" height="40"></rect>
<rect x="180" y="70" width="40" height="40"></rect>
<rect x="20" y="110" width="40" height="40"></rect>
<rect x="60" y="110" width="40" height="40"></rect>
<rect x="100" y="110" width="40" height="40"></rect>
<rect x="140" y="110" width="40" height="40"></rect>
<rect x="180" y="110" width="40" height="40"></rect>
</g>
<rect x="140" y="30" width="40" height="40" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="4 3"></rect>
<rect x="140" y="110" width="40" height="40" fill="none" stroke="var(--accent)" stroke-width="2" stroke-dasharray="4 3"></rect>
<rect x="60" y="70" width="40" height="40" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5"></rect>
<rect x="100" y="70" width="40" height="40" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5"></rect>
<g fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" marker-end="url(#jp-head)">
<polyline points="80,90 120,50 146,50"></polyline>
<polyline points="80,90 120,130 146,130"></polyline>
</g>
<g font-family="var(--font-mono)" font-size="14" text-anchor="middle" fill="var(--text)">
<text x="80" y="95">p</text>
<text x="120" y="95">n</text>
</g>
<g font-size="12" fill="var(--text-muted)">
<text x="240" y="56">위아래 대각선 칸은</text>
<text x="240" y="78">n 을 거치지 않아도 닿는다.</text>
<text x="240" y="100">n 에서 새로 열리는 것이 없으니</text>
<text x="240" y="122">멈추지 않고 계속 간다</text>
</g>
</svg>
<figcaption>p 에서 오른쪽으로 갈 때. n 에서 새로 열리는 칸이 없다</figcaption>
</figure>
<figure class="diagram" data-tab="벽이 있으면">
<svg viewBox="0 0 466 176" role="img" aria-label="n 위가 벽일 때. 물음표 칸은 n 을 지나야만 닿는다">
<defs>
<marker id="jp-head" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="5" markerHeight="5" orient="auto">
<path d="M0 0 L10 5 L0 10 z" fill="var(--accent)"></path>
</marker>
</defs>
<g fill="var(--bg)" stroke="var(--border)" stroke-width="1">
<rect x="20" y="30" width="40" height="40"></rect>
<rect x="60" y="30" width="40" height="40"></rect>
<rect x="100" y="30" width="40" height="40"></rect>
<rect x="140" y="30" width="40" height="40"></rect>
<rect x="180" y="30" width="40" height="40"></rect>
<rect x="20" y="70" width="40" height="40"></rect>
<rect x="60" y="70" width="40" height="40"></rect>
<rect x="100" y="70" width="40" height="40"></rect>
<rect x="140" y="70" width="40" height="40"></rect>
<rect x="180" y="70" width="40" height="40"></rect>
<rect x="20" y="110" width="40" height="40"></rect>
<rect x="60" y="110" width="40" height="40"></rect>
<rect x="100" y="110" width="40" height="40"></rect>
<rect x="140" y="110" width="40" height="40"></rect>
<rect x="180" y="110" width="40" height="40"></rect>
</g>
<rect x="100" y="30" width="40" height="40" fill="var(--text-muted)" stroke="var(--text-muted)" opacity="0.55"></rect>
<rect x="140" y="30" width="40" height="40" fill="var(--accent)" stroke="var(--accent)"></rect>
<rect x="60" y="70" width="40" height="40" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5"></rect>
<rect x="100" y="70" width="40" height="40" fill="var(--accent-soft)" stroke="var(--accent)" stroke-width="1.5"></rect>
<g fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" marker-end="url(#jp-head)">
<polyline points="120,90 150,60"></polyline>
</g>
<g font-family="var(--font-mono)" font-size="14" text-anchor="middle" fill="var(--text)">
<text x="80" y="95">p</text>
<text x="120" y="95">n</text>
</g>
<text x="160" y="55" font-family="var(--font-mono)" font-size="14" text-anchor="middle" fill="var(--on-accent)">?</text>
<g font-size="12" fill="var(--text-muted)">
<text x="240" y="56">벽 때문에 ? 칸으로 가는 길이</text>
<text x="240" y="78">n 을 지나는 것뿐이다.</text>
<text x="240" y="100">여기서 다시 판단해야 하니</text>
<text x="240" y="122">n 이 점프 포인트가 된다</text>
</g>
</svg>
<figcaption>n 위가 벽일 때. 물음표 칸은 n 을 지나야만 닿는다</figcaption>
</figure>
</x-tabs>

규칙으로 적으면 세 줄입니다.

- **가로세로로 나아갈 때.** 진행 방향의 옆이 벽이고 그 벽 너머 대각선 칸이 뚫려 있으면, 지금 칸이 점프 포인트입니다
- **대각선으로 나아갈 때.** 한 칸 갈 때마다 **가로와 세로로 먼저 훑어 봅니다.** 그쪽에서 점프 포인트가 나오면 지금 칸이 점프 포인트입니다
- **목표 칸.** 무조건 점프 포인트입니다. 그냥 지나쳐 버리면 안 되니까요

대각선이 가로세로를 먼저 보는 이유는 순서 때문입니다.
대각선으로 쭉 가다가 옆으로 꺾어야 하는 상황이라면, 꺾을 자리를 대각선 위에서 미리 찾아 둬야 합니다.
그래서 대각선 한 칸마다 가로 한 줄과 세로 한 줄을 확인합니다. **비용이 가장 많이 드는 부분도 여기입니다.**

## 같은 격자에서 얼마나 줄어드는가

벽 한 줄만 세워 둔 14×8 격자입니다. 두 방식 모두 여덟 방향으로 움직이고 대각선 비용은 루트 2,
꺼내는 기준도 똑같이 `g + h` 입니다. 다른 것은 이웃을 보는 방식 하나뿐입니다.

<x-jump>
<script type="application/json">
{
  "label": "같은 격자를 두 방식으로",
  "grid": [
    "..............",
    "..............",
    "......#.......",
    "......#.......",
    "S.....#......G",
    "......#.......",
    "..............",
    ".............."
  ],
  "modes": ["astar", "jps"],
  "note": "두 방식이 찾아낸 길의 비용은 14.66 으로 같습니다. 다른 것은 그 길을 찾기까지 큐를 몇 번 건드렸느냐입니다"
}
</script>
</x-jump>

한 칸씩 보는 쪽은 큐에서 **26개**를 꺼냅니다. 건너뛰는 쪽은 **6개**입니다.
꺼낸 칸이 곧 힙에서 값을 빼고 다시 정렬하는 횟수이고, 길찾기 시간의 큰 몫을 차지하는 부분입니다.

길의 **비용**은 둘 다 14.66 입니다. 지나는 칸이 서로 다른데 값이 같은 것이 바로 경로 대칭입니다.
**답을 나쁘게 만들지 않으면서 큐 작업만 줄이는 것**이 이 방법의 전부입니다.

## 코드는 재귀 한 덩어리다

바뀌는 곳은 이웃을 만드는 자리 하나입니다. 큐도, `f = g + h` 도 그대로 씁니다.

```csharp
// (x, y) 에서 (dx, dy) 방향으로 나아가며 점프 포인트를 찾는다. 없으면 null
Vector2Int? Jump(int x, int y, int dx, int dy) {
    int nx = x + dx, ny = y + dy;
    if (!Walkable(nx, ny)) return null;                 // 벽이나 맵 밖
    if (nx == goal.x && ny == goal.y) return new Vector2Int(nx, ny);
    if (HasForced(nx, ny, dx, dy)) return new Vector2Int(nx, ny);

    if (dx != 0 && dy != 0) {                           // 대각선이면 가로세로를 먼저 본다
        if (Jump(nx, ny, dx, 0) != null) return new Vector2Int(nx, ny);
        if (Jump(nx, ny, 0, dy) != null) return new Vector2Int(nx, ny);
    }
    return Jump(nx, ny, dx, dy);                        // 같은 방향으로 계속
}

// 진행 방향 옆이 벽인데 그 너머 대각선 칸이 뚫려 있으면 여기서 갈라진다
bool HasForced(int x, int y, int dx, int dy) {
    if (dy == 0)
        return (!Walkable(x, y - 1) && Walkable(x + dx, y - 1))
            || (!Walkable(x, y + 1) && Walkable(x + dx, y + 1));
    if (dx == 0)
        return (!Walkable(x - 1, y) && Walkable(x - 1, y + dy))
            || (!Walkable(x + 1, y) && Walkable(x + 1, y + dy));

    return (!Walkable(x - dx, y) && Walkable(x - dx, y + dy))
        || (!Walkable(x, y - dy) && Walkable(x + dx, y - dy));
}
```

큐에 넣을 때는 건너뛴 거리를 한 번에 더합니다.

```csharp
foreach (var (dx, dy) in DirectionsFrom(cur, parent[cur])) {
    var jp = Jump(cur.x, cur.y, dx, dy);
    if (jp == null) continue;

    float ng = g[cur] + Octile(cur, jp.Value);   // 한 칸이 아니라 건너뛴 만큼
    if (ng >= Best(jp.Value)) continue;
    Push(jp.Value, ng + Heuristic(jp.Value));
}
```

`DirectionsFrom` 은 어디서 왔는지를 보고 볼 필요 없는 방향을 미리 걷어내는 부분입니다.
오른쪽에서 온 칸이면 왼쪽으로 되돌아갈 이유가 없고, 대각선으로 온 칸이면 뒤쪽 세 방향이 통째로 빠집니다.
이 걷어내기가 없으면 점프 포인트를 찾아 놓고도 같은 곳을 여러 번 훑게 됩니다.

재귀가 부담이면 가로세로 방향은 `while` 로 펴도 됩니다. 재귀가 꼭 필요한 쪽은 대각선뿐입니다.

## 들여다본 칸은 오히려 늘어난다

값을 치르는 곳이 있습니다. 위 격자에서 **실제로 읽어 본 칸**을 세어 보면 반대가 나옵니다.

| | 큐에서 꺼낸 칸 | 들여다본 칸 |
| --- | --- | --- |
| 한 칸씩 (A 스타) | 26 | 57 |
| 건너뛰며 (JPS) | 6 | 102 |

빈 칸이 108개인 맵에서 102개를 읽었습니다. **거의 전부입니다.**
건너뛴다는 말이 안 본다는 뜻이 아니기 때문입니다. 한 방향으로 끝까지 훑어 보고 나서야 「여기엔 아무것도 없다」 를 알 수 있습니다.

그런데도 빠른 이유는 두 일의 단가가 다르기 때문입니다.

- **큐에서 꺼내는 일은 비쌉니다.** 힙을 다시 정렬하고, 노드마다 부모와 비용을 기록하고, 메모리를 잡습니다
- **한 방향으로 훑는 일은 쌉니다.** 배열을 차례로 읽는 것이라 캐시에 잘 맞고 분기도 적습니다. 한 줄을 비트로 묶어 한 번에 검사하는 구현도 있습니다

**그래서 이득은 맵에 따라 다릅니다.** 벽이 거의 없는 열린 맵은 훑는 거리가 길어져서 이득이 줄고,
방과 복도가 많은 맵은 금방 벽에 걸려 멈추기 때문에 이득이 큽니다. 실제 맵으로 재 보지 않으면 모릅니다.

## 통하지 않는 자리

조건이 꽤 까다롭습니다. 하나라도 어긋나면 답이 틀리거나 아예 적용이 안 됩니다.

| 조건 | 어긋나면 |
| --- | --- |
| 칸마다 이동 비용이 같아야 한다 | 늪이나 도로처럼 비용이 다르면 **쓸 수 없습니다.** 건너뛴 칸의 비용을 모르니 더할 수가 없습니다 |
| 격자여야 한다 | 내비메시나 웨이포인트 그래프에는 해당이 없습니다. 「한 방향으로 계속」 이 정의되지 않습니다 |
| 이동 규칙이 고정이어야 한다 | 유닛마다 지나갈 수 있는 칸이 다르면 점프 포인트도 달라집니다 |

**대각선으로 벽 모서리를 스칠 수 있게 할 것인지**도 미리 정해야 합니다.
흔히 보는 강제 이웃 규칙은 모서리 통과를 허용한다는 전제로 만들어져 있습니다.
많은 게임이 그걸 막는데, 막는 쪽으로 가려면 강제 이웃 판정을 그 규칙에 맞게 다시 써야 합니다.
**규칙만 그대로 두고 이동만 막으면 길을 못 찾습니다.** 큐에 넣은 점프 포인트로 갈 방법이 없어지기 때문입니다.

비용이 다른 칸이 필요하다면 그냥 A 스타를 씁니다. 격자를 균일하게 유지할 수 있는 맵에서만 꺼내는 카드입니다.

## 나오는 경로는 점이 몇 개뿐이다

결과로 받는 것은 칸의 목록이 아니라 점프 포인트의 목록입니다. 위 격자에서는 여섯 점입니다.
칸 단위로 움직여야 한다면 점과 점 사이를 직선으로 채우면 되고, 채우는 비용은 한 칸씩 훑는 것과 같습니다.

점이 적다는 것 자체가 쓸모 있을 때도 있습니다.
유닛이 따라갈 목표점이 여섯 개면 프레임마다 확인할 것도 여섯 개고, 직선 구간은 이미 하나로 합쳐져 있습니다.

## 정리

균일한 격자에서 탐색이 넓어지는 원인은 벽이 아니라 **같은 비용의 길이 여러 개**라는 사실입니다.

- 점프 포인트 서치는 그 대칭인 길들 중 **하나만 만들도록** 규칙을 정한 방법입니다. 답은 그대로고 큐 작업만 줄어듭니다
- 멈추는 자리는 **벽이 만드는 강제 이웃**입니다. 빈 들판에서는 갈라질 이유가 없으니 계속 지나칩니다
- 대각선으로 갈 때는 한 칸마다 가로세로를 먼저 훑습니다. 여기가 가장 비쌉니다
- **읽는 칸은 오히려 늘어납니다.** 대신 비싼 큐 작업이 줄어드는 거래입니다. 이득은 맵 모양에 따라 달라집니다
- **칸마다 비용이 다르면 쓸 수 없습니다.** 균일한 격자라는 조건이 이 방법의 전부를 떠받치고 있습니다