BibleFlow는 성경의 시대를 고르면 그 시대의 주요 장소와 인물의 이동 경로를 지도 위에 보여 주는 서비스다. 아브라함의 여정이나 다윗의 피신 경로를 고르고 "출발"을 누르면 작은 인물 아이콘이 경로를 따라 걸어간다. 이 글은 그 애니메이션과 지도 스타일을 어떻게 만들었는지 정리한다. 지도는 react-map-gl의 MapLibre 바인딩을 사용했다.

1920×1080 화면에서 BibleFlow의 출애굽 시대 경로를 재생하는 장면. 이집트 고센에서 시내산을 거쳐 가나안으로 이어지는 점선 경로 위를 인물 아이콘이 이동하고, 오른쪽에는 출애굽 여정 정보 패널이 보인다

경로 데이터는 GeoJSON LineString

각 여정은 좌표 배열을 가진 GeoJSON LineString이다. 좌표는 [경도, 위도] 순서다. 경로는 MapLibre의 line 레이어로 그리고, 출발점과 도착점에는 "1차 출발", "1차 도착" 같은 라벨 마커를 단다.

const routeData: GeoJSON.FeatureCollection = {
  type: "FeatureCollection",
  features: routes.map((event) => ({
    type: "Feature",
    properties: { id: event.id, color: event.color },
    geometry: event.geometry, // LineString
  })),
};

각 경로에는 근거 본문(예: 창세기 12:1–9)과 신뢰도("추정", "논쟁 중")가 함께 들어 있다. 고대 경로는 본문에 모든 지점이 나오지 않기 때문에, 지도가 확정된 사실처럼 보이지 않도록 데이터 단계에서부터 신뢰도를 기록해 두었다.

경로 위의 한 점 구하기: 거리로 보간한다

인물 아이콘을 움직이려면 "경로 전체의 몇 % 지점은 어디인가"를 계산하는 함수가 필요하다. 가장 단순한 방법은 좌표 배열의 인덱스로 나누는 것이다. 좌표가 10개면 10%마다 다음 좌표로 넘어가는 식이다. 하지만 이렇게 하면 좌표 사이 간격이 다를 때 속도가 들쭉날쭉해진다. 짧은 구간은 순식간에 지나가고 긴 구간은 느릿느릿 간다.

그래서 실제 거리 비율로 보간했다.

function interpolateRoute(coordinates: [number, number][], progress: number): [number, number] {
  if (coordinates.length < 2) return coordinates[0] ?? [35.21, 31.77];

  // 구간마다 길이를 구한다. 경도 차이에는 위도에 따른 보정을 곱한다.
  const lengths = coordinates.slice(1).map((point, index) => {
    const previous = coordinates[index];
    const latitudeScale = Math.cos(((point[1] + previous[1]) / 2) * Math.PI / 180);
    return Math.hypot((point[0] - previous[0]) * latitudeScale, point[1] - previous[1]);
  });

  const total = lengths.reduce((sum, length) => sum + length, 0);
  let target = Math.max(0, Math.min(1, progress)) * total;

  for (let index = 0; index < lengths.length; index += 1) {
    if (target <= lengths[index]) {
      const ratio = lengths[index] === 0 ? 0 : target / lengths[index];
      return [
        coordinates[index][0] + (coordinates[index + 1][0] - coordinates[index][0]) * ratio,
        coordinates[index][1] + (coordinates[index + 1][1] - coordinates[index][1]) * ratio,
      ];
    }
    target -= lengths[index];
  }
  return coordinates.at(-1) ?? coordinates[0];
}

핵심은 latitudeScale이다. 경도 1도의 실제 거리는 적도에서 가장 길고 극으로 갈수록 짧아진다. 위도 φ에서 경도 1도의 길이는 적도의 cos φ배다. 성경 지역은 대략 북위 30도 안팎이라 cos 30° ≈ 0.87이다. 이 보정 없이 경도와 위도 차이를 그대로 피타고라스 정리에 넣으면, 동서 방향 이동이 남북 방향보다 15% 정도 길게 계산되어 동서로 움직일 때만 아이콘이 빨라진다.

엄밀하게는 대원 거리(하버사인 공식)를 써야 하지만, 이 정도 범위의 지역에서는 "두 점 위도의 평균으로 경도를 보정한 평면 거리"로도 눈에 띄는 차이가 없다. 계산이 훨씬 가볍기도 하다.

또 하나, 경로 위에 작은 점 37개를 일정한 거리 간격으로 미리 뿌려 둔다. 진행률 0, 1/36, 2/36, … 1에 해당하는 위치를 같은 함수로 계산한 것이다. 아이콘이 지나간 구간의 점을 강조하면 "어디까지 왔는지"가 자연스럽게 보인다.

requestAnimationFrame으로 진행률 관리하기

애니메이션 상태는 세 값으로 표현했다.

const [journey, setJourney] = useState<{ routeId: string; progress: number; runId: number } | null>(null);

progress는 0에서 1까지의 진행률이고, runId는 같은 경로를 다시 재생할 때 애니메이션을 처음부터 시작하기 위한 번호다.

useEffect(() => {
  if (!journey || journey.progress >= 1) return;
  let frame = 0;
  // 예수님의 여정처럼 긴 경로는 더 오래, 나머지는 7초
  const duration = journey.routeId === "jesus-journey-1" ? 16000
    : journey.routeId === "jesus-journey-2" ? 14000
    : journey.routeId === "jesus-journey-3" ? 12000
    : 7000;
  const startedAt = performance.now() - journey.progress * duration;
  const animate = (now: number) => {
    const progress = Math.min((now - startedAt) / duration, 1);
    setJourney((current) => current ? { ...current, progress } : null);
    if (progress < 1) frame = requestAnimationFrame(animate);
  };
  frame = requestAnimationFrame(animate);
  return () => cancelAnimationFrame(frame);
// 같은 경로를 다시 눌러도 runId가 바뀌어 새 애니메이션 루프가 시작된다.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [journey?.routeId, journey?.runId]);

const startJourney = (routeId: string) => setJourney((current) => ({
  routeId, progress: 0, runId: (current?.runId ?? 0) + 1,
}));

몇 가지 설계 포인트가 있다.

진행률은 "흐른 시간 ÷ 전체 시간"으로 계산한다. 프레임마다 일정량을 더하는 방식은 프레임이 떨어지거나 화면 주사율이 다르면 속도가 달라진다. 60Hz 모니터와 120Hz 모니터에서 같은 속도로 움직이게 하려면 시간 기준이어야 한다.

이펙트 의존성은 경로와 실행 번호뿐이다. progress를 의존성에 넣으면 매 프레임 이펙트가 정리되고 다시 등록된다. 진행률은 루프 안에서 갱신하고, 이펙트는 "어떤 경로를 몇 번째로 재생하는가"가 바뀔 때만 새로 시작하게 했다. 린트 경고를 끈 이유를 주석으로 남겨 두었다.

같은 경로를 다시 눌러도 처음부터. routeId만 의존성으로 두면 같은 경로를 다시 눌렀을 때 값이 바뀌지 않아 애니메이션이 시작되지 않는다. runId를 하나씩 늘려 이 문제를 해결했다.

startedAt은 현재 진행률을 반영한다. performance.now() - progress * duration으로 계산하기 때문에, 중간 진행률에서 루프를 다시 시작해도 그 지점부터 이어진다.

개선할 점도 있다. 지금은 매 프레임 setJourney로 React 상태를 바꾸기 때문에 지도 컴포넌트 전체가 초당 60번 다시 렌더링된다. 지역, 경로, 장소 목록을 매번 다시 계산하는 셈이다. 아이콘 위치만 바뀌므로, 위치 계산과 마커만 별도 컴포넌트로 분리하거나 무거운 계산을 useMemo로 감싸면 저사양 기기에서 더 부드러워질 것이다.

실제로 얼마나 부담이 되는지 경로 재생 중의 프레임을 쟀다. 출애굽 시대를 열고 "출발"을 눌러 6초 동안 requestAnimationFrame이 불리는 시각을 기록했다.

  • 환경: Chromium 156.0.8078.4(Playwright 1.64.1, headless, 소프트웨어 WebGL), macOS 26.6.2 arm64(Apple M5 Pro), Node v22.22.2, BibleFlow 운영 빌드(out/), 화면 1280×800과 1920×1080, 2026-10-23 측정
화면 CPU 6초 동안 프레임 수 프레임 간격 평균 95번째 백분위 최대 50ms 넘는 프레임 긴 작업(50ms 이상)
1280×800 제한 없음 365 16.7ms 16.7ms 33.3ms 0개 0개
1280×800 4배 느리게 365 16.7ms 16.7ms 16.8ms 0개 0개
1920×1080 제한 없음 371 16.8ms 16.8ms 33.4ms 0개 0개
1920×1080 4배 느리게 370 16.8ms 16.8ms 33.4ms 0개 0개

네 조건 모두 6초 동안 60fps에 해당하는 365~371프레임이 그려졌고 끊김은 없었다. 이 측정만 보면 매 프레임 상태를 바꾸는 구조가 지금 당장 문제를 일으키지는 않는다. 다만 실제 저사양 휴대폰은 측정하지 못했고, 소프트웨어 WebGL이라 실기기의 GPU 환경과도 다르므로 더 느린 기기에서는 결과가 달라질 수 있다.

줌에 따라 바뀌는 고지도 느낌의 배경

BibleFlow의 지도는 일반 지도 앱처럼 보이면 안 됐다. 현대의 도로와 도시 이름이 가득하면 고대 경로가 묻힌다. 그래서 배경 스타일을 직접 구성했다.

layers: [
  { id: "atlas-land-base", type: "background", paint: { "background-color": "#e8ddc2" } },
  // 멀리서 볼 때: 자연 지형 위주의 Natural Earth 래스터, 줌 5부터 서서히 사라짐
  { id: "atlas-basemap", type: "raster", source: "naturalEarth", minzoom: 0, maxzoom: 6.4,
    paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 0, 1, 5, 0.92, 6.35, 0] } },
  // 가까이 볼 때: 상세 래스터를 채도를 크게 낮춰 서서히 등장
  { id: "atlas-high-detail", type: "raster", source: "highDetail", minzoom: 5.6, maxzoom: 19,
    paint: { "raster-opacity": ["interpolate", ["linear"], ["zoom"], 5.6, 0, 6.3, 0.72, 19, 0.72],
             "raster-saturation": -0.72 } },
  // 벡터 타일에서 숲·풀·모래·물만 골라 고지도 색으로 칠한다
  { id: "atlas-water", type: "fill", source: "openmaptiles", "source-layer": "water",
    paint: { "fill-color": "#6eb5d1" } },
  // ...
]

멀리서 볼 때는 지형 위주의 Natural Earth 래스터를, 가까이 확대하면 상세 지도를 보여 준다. 두 레이어를 줌 5.6~6.35 구간에서 교차 페이드시켜 한쪽이 사라지는 동안 다른 쪽이 나타나게 했다. raster-opacity에 줌 기반 interpolate 식을 넣으면 확대·축소하는 동안 투명도가 연속적으로 바뀐다. 상세 지도는 채도를 크게 낮춰 경로 색이 항상 더 눈에 띄도록 했다.

벡터 타일에서는 숲, 풀밭, 모래, 물, 강, 국경만 골라 직접 색을 입혔다. 도로와 현대 도시 라벨 레이어는 아예 넣지 않았다. 지도 스타일을 "무엇을 보여 줄까"보다 **"무엇을 빼야 경로가 보일까"**로 접근한 것이다.

외부 타일 서버를 쓸 때는 각 제공자의 이용 정책과 출처 표시 의무를 지켜야 한다. BibleFlow는 스타일의 attribution에 출처를 명시했다. 사용 중인 제공자 두 곳의 안내를 확인했다. OpenFreeMap 이용 약관은 서비스를 "있는 그대로(as-is)" 무료로 제공하며 가용성을 보장하지 않고 예고 없이 중단할 수 있다고 밝힌다. 일반적인 지도 사용에 대한 요청 한도나 API 키는 약관에 나와 있지 않고, 숫자로 정한 제한은 오프라인 다운로드(3,000타일 또는 50MB 이하, 동시 요청 4개 이하)에만 있다(OpenFreeMap 약관). OSM France의 HOT 타일은 OpenStreetMap 위키의 래스터 타일 제공자 목록에 "기부로 운영되는 무료"로 올라 있고, 위키는 각 서버의 이용 정책을 따르고 OpenStreetMap을 표기하라고 안내한다(OSM 위키: Raster tile providers). HOT 타일의 세부 이용 정책은 이 글에서 확인하지 못했다. 방문자가 크게 늘면 두 곳 모두 가용성이 보장되지 않는다는 점을 염두에 두고 자체 타일이나 유료 제공자를 고려할 수 있다.

시대에 따라 바뀌는 정보량

시대를 고르면 그 시대에 중요한 장소만 표시된다. 예를 들어 "아브라함 시대"에는 우르, 하란, 세겜, 벧엘, 헤브론, 브엘세바만 보이고, "예수님의 생애"에는 갈릴리 주변의 열일곱 장소가 보인다. 이집트, 가나안, 아람, 메소포타미아 같은 고대 지역 이름도 해당 연도에만 나타난다. 모든 지명을 한 번에 띄우면 지도는 정확해지지만 읽을 수 없게 된다. 지도 시각화에서 가장 중요한 결정은 결국 정보를 덜어 내는 기준이었다.

정리

  • 경로 애니메이션은 인덱스가 아닌 거리 비율로 보간해야 속도가 일정하다. 위도에 따라 경도 거리를 cos φ로 보정하면 좁은 지역에서는 충분히 정확하다.
  • 진행률은 프레임 수가 아닌 경과 시간으로 계산하고, 재시작은 실행 번호로 트리거한다.
  • 배경 지도는 줌 기반 투명도 식으로 교차 페이드하고, 경로를 가리는 정보는 과감히 뺀다.