perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌

- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
2026-10-16 11:21:51 +09:00
co-authored by Claude Opus 5.5
parent 1fcdf5aa4c
commit e0051bff72
11 changed files with 830 additions and 131 deletions
+38 -5
View File
@@ -18,6 +18,7 @@ import {
API_TIMEOUT_MS,
} from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
@@ -244,17 +245,49 @@ export async function listSurfaceInputFiles(
});
}
/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */
export async function fetchSurfacePointCloud(
projectId: string,
filter?: string,
): Promise<SurfacePointCloudSampleResponse> {
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud${query}`,
{
method: "GET",
},
const bytes = await fetchCachedBytes(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`,
);
return { status: "success", project_id: projectId, ...decodePointPreview(bytes) };
}
/**
* 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`.
* [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n].
*/
export function decodePointPreview(
bytes: ArrayBuffer,
): Omit<SurfacePointCloudSampleResponse, "status" | "project_id"> {
const headLength = new DataView(bytes).getUint32(0, true);
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as {
point_count: number;
sampled_count: number;
bounds: SurfaceBounds;
has_rgb: boolean;
};
const count = head.sampled_count;
const offset = Math.ceil((4 + headLength) / 4) * 4;
const xyz = new Float32Array(bytes, offset, count * 3);
const points: [number, number, number][] = new Array(count);
for (let index = 0; index < count; index += 1) {
points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]];
}
let rgb: [number, number, number][] | undefined;
if (head.has_rgb) {
const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3);
rgb = new Array(count);
for (let index = 0; index < count; index += 1) {
rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]];
}
}
return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb };
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
+9
View File
@@ -334,6 +334,15 @@ def run_surface_analysis(
):
continue
cache_ground_points(structured_path, filter_key, mask)
# 점군 미리보기 고정 표본(이진) — 원천이 그대로면 머리만 보고 건너뜀
try:
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
ensure_point_preview(structured_path, None, data["xyz"])
for filter_key in masks:
ensure_point_preview(processed_dir / f"ground_points_{filter_key}.npz", filter_key)
except Exception as exc: # noqa: BLE001 — 미리보기 실패가 분석을 막지 않음
logger.warning("B04 점군 미리보기 이진 생성 실패: %s", exc)
# 3. 지표면 5종 모델 빌드
_report(70, "surface_model", "지표면 모델 생성 중")
@@ -0,0 +1,97 @@
"""B04 점군 미리보기 — 고정 표본 이진 파일(전처리 때 한 번 · ETag 로 내보냄).
JSON(27.8 MB · 요청마다 점군 풀고 표본)을 대신한다. 표본 규칙은 JSON 길과 같다
(`sample_points` · 같은 씨앗) — 같은 점 · 같은 float32 값.
파일 = [머리 길이 uint32 LE][머리 JSON utf-8][0 채움 → 4 배수][xyz float32 × 3n][rgb uint16 × 3n].
머리 = {version, source(원천 npz mtime_ns:크기), point_count, sampled_count, bounds, has_rgb}.
TS 짝(읽기) = `B04_PreProcess_Api_Fetch.ts` `decodePointPreview`.
"""
from __future__ import annotations
import json
import struct
from pathlib import Path
from typing import Any
import numpy as np
from common_util.common_util_atomic import atomic_write_bytes
POINT_CLOUD_SAMPLE_LIMIT = 500_000
PREVIEW_VERSION = 1
_SAMPLE_SEED = 20260710
def sample_points(xyz: np.ndarray, rgb: np.ndarray | None) -> tuple[np.ndarray, np.ndarray | None]:
"""화면용 고정 표본 — 상한을 넘으면 같은 씨앗으로 고른다(열 때마다 같은 그림)."""
source_count = int(len(xyz))
if rgb is not None and (rgb.ndim == 0 or len(rgb) != source_count):
rgb = None
if source_count <= POINT_CLOUD_SAMPLE_LIMIT:
return xyz, rgb
indexes = np.random.default_rng(_SAMPLE_SEED).choice(
source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False
)
return xyz[indexes], (rgb[indexes] if rgb is not None else None)
def preview_path(processed_dir: Path, filter_key: str | None) -> Path:
return processed_dir / f"preview_points_{filter_key or 'raw'}.bin"
def _stamp(path: Path) -> str:
stat = path.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
def read_preview_header(path: Path) -> dict[str, Any] | None:
"""머리만 읽는다 — 없거나 깨졌으면 None."""
try:
with path.open("rb") as file:
(length,) = struct.unpack("<I", file.read(4))
return json.loads(file.read(length).decode("utf-8"))
except (OSError, ValueError, struct.error):
return None
def ensure_point_preview(source_path: Path, filter_key: str | None, xyz_source: Any = None) -> Path:
"""원천 npz(structured · ground_points_*) 그대로면 있는 파일 · 아니면 한 번 만든다.
`xyz_source` — 이미 메모리에 있는 xyz(또는 쓸 때 푸는 대리자) · 없으면 npz 에서 푼다.
"""
target = preview_path(source_path.parent, filter_key)
stamp = _stamp(source_path)
header = read_preview_header(target)
if header and header.get("version") == PREVIEW_VERSION and header.get("source") == stamp:
return target
with np.load(source_path) as data:
xyz = np.asarray(data["xyz"] if xyz_source is None else xyz_source, dtype=np.float32)
bounds = np.asarray(data["bounds"], dtype=np.float64)
point_count = int(data["point_count"]) if "point_count" in data else int(len(xyz))
rgb = np.asarray(data["rgb"]) if "rgb" in data else None
sample, rgb_sample = sample_points(xyz, rgb)
header = {
"version": PREVIEW_VERSION,
"source": stamp,
"point_count": point_count,
"sampled_count": int(len(sample)),
"bounds": {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
},
"has_rgb": rgb_sample is not None,
}
head = json.dumps(header).encode("utf-8")
pad = (-(4 + len(head))) % 4
parts = [struct.pack("<I", len(head)), head, b"\0" * pad]
parts.append(np.ascontiguousarray(sample, dtype="<f4").tobytes())
if rgb_sample is not None:
parts.append(np.ascontiguousarray(rgb_sample, dtype="<u2").tobytes())
atomic_write_bytes(target, b"".join(parts))
return target
+64 -50
View File
@@ -8,8 +8,8 @@ from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_PreProcess.B04_PreProcess_Engine import (
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
project_epsg_from_prj,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
@@ -63,6 +64,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
)
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
@@ -77,7 +79,6 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 500_000
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
@@ -298,43 +299,70 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
)
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
async with get_db_pool().acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
if filter is None:
return structured_path
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
return source_path
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
async def get_surface_point_cloud_binary(
project_id: UUID, request: Request, filter: str | None = None
) -> Response:
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
try:
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
return cached_file_response(request, target, "application/octet-stream")
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
filter: str | None = None,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
pool = get_db_pool()
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
source_path = structured_path
if filter is not None:
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
with np.load(source_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
@@ -342,22 +370,8 @@ async def get_surface_point_cloud(
point_count = (
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
)
source_count = int(len(xyz))
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rng = np.random.default_rng(20260710)
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
sample = xyz[indexes]
else:
sample = xyz
rgb_sample = None
if "rgb" in structured:
rgb_arr = np.asarray(structured["rgb"])
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rgb_sample = rgb_arr[indexes]
else:
rgb_sample = rgb_arr
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
sample, rgb_sample = sample_points(xyz, rgb)
return SurfacePointCloudSampleResponse(
project_id=str(project_id),
+19 -16
View File
@@ -161,25 +161,28 @@ async def get_project_geojson(
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
# 원본이 새로 받혔으면(전처리 다시) 단순화본도 다시 만든다.
fresh = (
simplified_filepath.exists()
and simplified_filepath.stat().st_mtime_ns >= filepath.stat().st_mtime_ns
)
if not fresh:
try:
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception:
pass
import geopandas as gpd
try:
import geopandas as gpd
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
fresh = True
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
if fresh:
filepath = simplified_filepath
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
return json.loads(filepath.read_text(encoding="utf-8"))
# 파일 그대로 + ETag — 다시 묶지 않고 두 번째부터 304.
return cached_file_response(request, filepath, "application/geo+json")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
@@ -7,6 +7,9 @@
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch";
import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender";
export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
@@ -45,6 +48,37 @@ export const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
도엽_옹벽석축: false,
};
/** 받는 중인 층 — 같은 층을 겹쳐 받지 않는다. */
const inFlight = new Map<string, Promise<GeoJsonCollection | null>>();
/** 레이어 하나 받기 — 도엽은 보관함(IndexedDB) · 나머지는 서버 ETag. 실패면 null.
* 꺼진 층은 켤 때 부른다(처음 열기에 도엽 등고선 37 MB 를 받지 않음). */
export function fetchGisLayer(
projectId: string,
layer: GisLayer,
): Promise<GeoJsonCollection | null> {
const key = `${projectId}|${layer}`;
const pending = inFlight.get(key) ?? downloadLayer(projectId, layer);
inFlight.set(key, pending);
void pending.finally(() => inFlight.delete(key));
return pending;
}
async function downloadLayer(
projectId: string,
layer: GisLayer,
): Promise<GeoJsonCollection | null> {
try {
return (
layer.startsWith("도엽_")
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
: await fetchGisGeoJson(projectId, layer)
) as GeoJsonCollection;
} catch {
return null;
}
}
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
export const CONTOUR_LABEL_DEFAULT_ON = false;
+20 -20
View File
@@ -1,9 +1,7 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
fetchGisGeoJson,
fetchPlannedRoute,
fetchVWorldMeta,
getVWorldMapUrl,
@@ -19,6 +17,7 @@ import {
CONTOUR_LABEL_KEYS,
GIS_DEFAULT_ON,
GIS_LAYERS,
fetchGisLayer,
gisLayerColor,
type BackgroundLayer,
type GisLayer,
@@ -36,7 +35,6 @@ import {
drawPreparedLayer,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PlanBounds,
@@ -330,6 +328,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
backgroundImages.forEach((image, layer) => {
image.hidden = !activeBackgrounds.has(layer);
});
activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer));
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
scheduleDraw();
}
@@ -453,6 +452,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
});
}
/** 켠 층이 아직 없으면 그때 받는다(같은 층 겹쳐 받기는 fetchGisLayer 가 막음). */
async function loadLayerOnDemand(layer: GisLayer): Promise<void> {
const projectId = currentProjectId;
if (!projectId || !meta || preparedLayers.has(layer)) return;
const data = await fetchGisLayer(projectId, layer);
if (!data || projectId !== currentProjectId || !normalizer) return;
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
scheduleDraw();
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
@@ -471,26 +480,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
fetchVWorldMeta(projectId, "satellite"),
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
]);
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
// 켜진 층만 처음에 받는다(꺼진 층은 켤 때) — 끝나는 대로 진행률을 올린다.
const firstLayers = GIS_LAYERS.filter((layer) => activeGisLayers.has(layer));
let done = 0;
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
const data = (
layer.startsWith("도엽_")
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
: await fetchGisGeoJson(projectId, layer)
) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
} finally {
done += 1;
if (sequence === loadSequence) {
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
}
firstLayers.map(async (layer) => {
const data = await fetchGisLayer(projectId, layer);
done += 1;
if (sequence === loadSequence) {
showProgress(done / firstLayers.length, `도엽 레이어 ${done}/${firstLayers.length}`);
}
return [layer, data] as const;
}),
);
if (sequence !== loadSequence) return;
@@ -9,8 +9,7 @@
import * as THREE from "three";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
const MAX_CONTOUR_LABELS = 40;
@@ -43,7 +42,7 @@ export async function loadContourLinesInto(
): Promise<boolean> {
const interval = parseFloat(ctx.intervalInput.value) || 1.0;
const projectId = ctx.projectId();
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
const contourUrl = contourAssetUrl(projectId, modelId, interval, isSmooth, recalculate);
try {
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.