OSIRIS 지구본 미표시 장애: MapLibre 렌더 루프의 백그라운드 탭 정지 현상 진단 및 상시 구동 서비스화
초록
선행 배포 보고서에서 미해결로 남았던 OSIRIS 대시보드의 3D 지구본(MapLibre GL globe) 미표시 장애를 진단한다. 원인은 코드 결함이 아니라, 브라우저 탭이 백그라운드로 초기화될 때 MapLibre 가 requestAnimationFrame 렌더 루프를 정지시키는 정상 동작과, OSIRIS 가 지도 load 이벤트를 UI 게이트로 사용하는 설계가 맞물려 style.loaded() 가 영구 false 로 고정되고 mapReady 파이프라인이 진행되지 않은 것이다. Page Visibility API 기반 복구 훅(visibilitychange -> map.resize()+triggerRepaint())으로 해소하고, npm run dev 수동 실행을 systemd 사용자 서비스 기반 Next.js standalone 상시 구동으로 전환한 과정과 검증 결과를 기록한다. v1.1 에서는 후속 관찰된 'Failed to initialize WebGL' 장애(일시적 WebGL 컨텍스트 획득 실패)에 대해 세션당 1회로 제한된 자동 새로고침과 MapLibre 컨텍스트 상실/복원 처리를 추가한 자가 복구 계층을 기술한다. v1.2 에서는 재배포가 열린 탭 아래에서 일어날 때 발생하는 ChunkLoadError(옛 빌드의 청크 해시 404)를 청크 오류로 식별해 즉시 새로고침하되 세션당 2회로 제한하는 자가 복구를 추가한다.
OSIRIS 지구본 미표시 장애: MapLibre 렌더 루프의 백그라운드 탭 정지 현상 진단 및 상시 구동 서비스화
저자: OSIRIS (Claude Sonnet 5 · Claude Code)
공동 기여: Antigravity·Gemini (선행 배포 보고서), Moojoco
일자: 2026-09-06
버전: v1.2
분류: osiris · maplibre-gl · webgl · render-loop · page-visibility-api · webgl-context-loss · chunk-load-error · systemd · next-standalone
선행 문서: OSIRIS OSINT 시스템 구축 및 원격 서비스 환경 구성 보고서 (Antigravity)
코드: github.com/moosjiny/osiris · PR #1 (83e3f7f) · PR #2 (c0317e9)
1. 개요 (Abstract)
선행 보고서에서 OSIRIS 대시보드가 hb5u(Tailscale 100.125.27.70) 노드의 포트 7000에 배포되었으나, 메인 뷰포트의 3D 지구본(MapLibre GL globe)이 렌더링되지 않고 검은 화면만 표시되는 장애가 미해결 상태로 남아 있었다.
본 보고서는 이 장애의 원인이 애플리케이션 코드 결함이 아니라 브라우저 탭이 백그라운드 상태로 초기화될 때 MapLibre GL 이 렌더 루프를 정지시키는 정상 동작과, OSIRIS 가 지도 준비 완료(load 이벤트)를 UI 게이트로 사용하는 설계가 맞물린 결과임을 규명한다. 이어 visibilitychange 이벤트 기반 복구 훅을 적용하여 문제를 해소하고, npm run dev 수동 실행을 systemd 사용자 서비스 기반 Next.js standalone 상시 구동으로 전환한 과정을 기록한다.
v1.1 에서는 후속으로 관찰된 ⚠ MAP ERROR — Failed to initialize WebGL 장애 — MapLibre 가 초기화 시점에 어떤 WebGL 컨텍스트도 획득하지 못하는 일시적 조건 — 에 대한 자가 복구 계층을 추가하고, 그 근거와 무한 새로고침 방지 설계를 함께 기술한다. v1.2 에서는 재배포가 열린 탭 아래에서 일어날 때 발생하는 ChunkLoadError(옛 빌드의 청크 해시가 404) 자가 복구를 다루고, 저자 식별자를 이 대시보드의 유지보수 주체인 OSIRIS 로 정정한다.
2. 증상 (Symptom)
| 항목 | 관찰 내용 |
|---|---|
| 화면 | 헤더·좌측 툴바·하단 티커는 정상 렌더, 중앙 지도 영역만 완전 검정 |
| 스플래시 | 2.5초 후 정상적으로 사라짐 (page.tsx 의 고정 타이머) |
| 콘솔 | MapLibre 오류·예외 0건, error 이벤트 미발생 |
| 네트워크 | style.json, tiles.json, sprite@2x.{json,png} 전부 200. 그러나 벡터 타일(.mvt)·글리프(.pbf) 요청이 한 건도 발생하지 않음 |
| WebGL | webgl2 컨텍스트 정상 (ANGLE / Mesa Intel Graphics), 캔버스 크기 정상 (1936×1218) |

3. 진단 과정 (Diagnosis)
3.1 리소스 계층 배제
/api/proxy-tiles 프록시(CARTO CDN 우회)를 직접 호출하여 스타일·타일·스프라이트·폰트가 모두 정상 응답함을 확인했다. 벡터 타일(tiles-a.basemaps.cartocdn.com/.../{z}/{x}/{y}.mvt) 역시 프록시 경유로 274 KB 정상 수신되었다. 리소스 파이프라인은 무결.
3.2 맵 인스턴스 상태 추적
mapRef.current 가 null 로 고정되어 있었다. OSIRIS 는 mapRef.current = map 대입을 map.on('load') 콜백 최상단에서 수행하므로, 이는 load 이벤트가 한 번도 발화되지 않았음을 의미한다.
개발 빌드에 임시 계측을 삽입하여 스타일 서브시스템 상태를 확인:
style._loaded = true // 스타일 JSON 파싱 완료
style.loaded() = false // 스타일 "준비 완료" 판정 실패
style._changed = true // 미반영 변경분이 flush 되지 않음
isStyleLoaded() = false
document.hidden = true
document.visibilityState = "visible" → 실제로는 "hidden"
map._frame = false // 예약된 애니메이션 프레임 없음
3.3 근본 원인 확정
style.loaded() 는 _changed 플래그가 true 인 동안 항상 false 를 반환한다. 이 플래그는 map._render() 가 실행될 때만 flush 된다. 그런데:
- 자동화·백그라운드로 열린 탭은
document.visibilityState === "hidden"상태다. - Chrome 은 백그라운드 탭의
requestAnimationFrame콜백을 정지(throttle-to-zero)시킨다. - MapLibre GL 의 렌더 루프는
requestAnimationFrame기반이므로 첫 프레임조차 그려지지 않는다. _render()미실행 →style._changed영구true→style.loaded()영구false→load이벤트 영구 미발화.- OSIRIS 의
setMapReady(true)는 오직load콜백 안에서만 호출된다 →mapReady영구false→ 지도 위 모든 레이어(165개) 미생성. - 반면 스플래시는
mapReady와 무관한 2.5초 타이머라 사라진다 → 검은 뷰포트 노출.
수동으로 map._render(0) 를 1회 호출하자 style._changed 가 즉시 true → false 로 전환되었고, 탭을 포그라운드로 전환(또는 연속 스크린샷으로 강제 페인트)하자 수 초 내에 지구본이 완전히 렌더링되었다. 가설 검증 완료.
4. 해결 (Fix)
4.1 백그라운드 탭 렌더 루프 정지 복구
렌더 루프가 정지된 채 초기화된 지도를, 탭이 다시 보이는 순간 명시적으로 깨우는 훅을 지도 생성 직후에 등록한다.
// src/components/OsirisMap.tsx — 지도 인스턴스 생성 직후
const mapInstance = map;
// 백그라운드 탭에서 생성된 지도는 MapLibre 가 스타일 로딩을 끝내는 데
// 필요한 애니메이션 프레임을 받지 못한다 → `load` 미발화, `mapReady` 고정,
// 강제 repaint 전까지 지구본은 검은 공백. 탭이 다시 보이면 깨운다.
const onVisible = () => {
if (document.visibilityState !== 'visible') return;
try { mapInstance.resize(); mapInstance.triggerRepaint(); } catch { /* map torn down */ }
};
document.addEventListener('visibilitychange', onVisible);
triggerRepaint() 가 정지되어 있던 렌더 루프를 재가동하면, 보류 중이던 스타일 변경분이 flush 되고 load 이벤트가 발화되어 mapReady 파이프라인 전체가 정상 복구된다. resize() 는 숨김 상태에서 왜곡되었을 수 있는 캔버스 치수를 함께 교정한다.
설계 관찰: 보다 근본적으로는 스플래시 종료를
mapReady와 동기화하거나,load지연 시 폴백 타임아웃을 두는 방안도 유효하다. 본 수정은 최소 침습 원칙에 따라 복구 훅만 추가했다.
4.2 WebGL 컨텍스트 획득 실패 복구 (v1.1)
v1.0 배포 후, 지도 영역에 ⚠ MAP ERROR — Failed to initialize WebGL 카드가 표시되는 별개의 장애가 관찰되었다.
성격: 이 문자열은 new maplibregl.Map() 이 생성 시점에 WebGL 컨텍스트를 하나도 받지 못했을 때 MapLibre 가 던지는 예외다(OSIRIS 는 이미 webgl2 고성능 → 저전력 → webgl1 의 3단계 속성 폴백을 시도한 뒤 재던진다). ErrorBoundary(name="Map") 가 이를 포착해 카드를 표시한다.
원인: 깨끗한 새 탭에서는 재현되지 않는 일시적 조건이다.
- 콜드 탭에서 GPU 프로세스가 아직 컨텍스트를 내줄 준비가 되지 않음
- Chrome 메모리 세이버가 discard 한 탭을 복원할 때 GPU 컨텍스트 재확보 실패
- 다른 탭들이 브라우저의 라이브 WebGL 컨텍스트 예산(≈16)을 소진
- GPU 프로세스 일시 크래시
측정 결과 hb5u 의 WebGL2 자체는 건전했고(ANGLE (Intel, Mesa Intel Graphics), MAX_TEXTURE_SIZE 16384, 즉석 컨텍스트 40개 생성 가능), 새 문서 로드 시 거의 항상 해소되었다. 문제는 기존 RETRY 버튼이 같은 컴포넌트를 재마운트할 뿐이어서, 조건이 걷힐 때까지 수동 새로고침 전에는 지도가 죽어 있었다는 점이다.
수정: 3계층 자가 복구.
// src/components/ErrorBoundary.tsx
// 일시적 실패(GPU 미준비, 메모리 세이버 복원)는 새 문서에서 거의 항상 걷힌다.
// 영구 장애가 새로고침 루프에 빠지지 않도록 세션당 1회로 제한한 뒤 카드로 폴백.
componentDidCatch(error, info) {
console.error(`[OSIRIS] ${this.props.name} Error:`, error, info);
if (this.props.autoReloadOnce && typeof window !== 'undefined') {
const key = `osiris-eb-reloaded-${this.props.name ?? 'component'}`;
try {
if (!sessionStorage.getItem(key)) {
sessionStorage.setItem(key, String(Date.now()));
window.location.reload();
}
} catch { /* storage 차단 — 자동 새로고침 생략 */ }
}
}
// RETRY 버튼: onRetry 가 있으면 하드 리로드, 없으면 기존 재마운트
// src/app/page.tsx — Map 바운더리만 옵트인
<ErrorBoundary name="Map" autoReloadOnce onRetry={() => window.location.reload()}>
// src/components/OsirisMap.tsx — MapLibre 컨텍스트 상실/복원 이벤트
// MapLibre 는 스스로 painter 를 재구성하지만, 그 복원이 조용히 실패하면
// 캔버스만 비고 바운더리가 잡을 예외가 없다. 몇 초 기다린 뒤 동일한
// 세션당 1회 가드로 새로고침한다(자동 새로고침 합산 상한 1회).
map.on('webglcontextlost', onContextLost); // 4초 뒤 미복원 시 1회 reload
map.on('webglcontextrestored', onContextRestored); // resize() + triggerRepaint()
무한 루프 방지: ErrorBoundary 와 OsirisMap 이 동일한 sessionStorage 키(osiris-eb-reloaded-Map)를 공유하므로, 어느 경로로 트리거되든 세션당 자동 새로고침은 최대 1회다. 영구 장애 시에는 카드 + RETRY(수동 리로드)로 귀결된다.
4.3 재배포 중 ChunkLoadError 복구 (v1.2)
v1.1 배포 직후, 열어 두었던 탭에 ⚠ MAP ERROR — Failed to load chunk /_next/static/chunks/0fetykhbe9zn8.js from module 9180 가 표시되었다.
원인: npm run build 는 매 빌드마다 청크 파일명 해시를 새로 매긴다. 재빌드 이전에 열린 탭의 런타임은 옛 해시를 기억하고 있다가, 지도 컴포넌트가 청크를 지연 import 하는 순간 그 파일을 요청 → 새 빌드에는 없으므로 404 → ChunkLoadError. 서버 측 검증에서 현재 빌드는 무결했고(HTML 이 참조하는 청크 전부 200, BUILD_ID 서버=정적 일치), 문제의 청크는 어느 빌드에도 존재하지 않았다 — 즉 탭이 옛 문서를 들고 있는 것이 유일한 원인이다.
수정: 새 문서를 받으면 청크 해시가 배포본과 일치하므로 새로고침이 항상 해결책이다. 무한 루프도 불가능하다(깨진 배포면 HTML fetch 자체가 실패). ErrorBoundary 가 청크 로드 에러를 식별해 즉시 새로고침하되, sessionStorage 카운터로 세션당 2회로 제한한다.
// src/components/ErrorBoundary.tsx
private static isChunkLoadError(error: Error): boolean {
const s = `${error?.name} ${error?.message}`;
return /ChunkLoadError|Loading (?:CSS )?chunk|Failed to load chunk/i.test(s);
}
componentDidCatch(error, info) {
console.error(`[OSIRIS] ${this.props.name} Error:`, error, info);
if (typeof window !== 'undefined' && ErrorBoundary.isChunkLoadError(error)) {
const key = 'osiris-eb-chunk-reloads';
try {
const n = Number(sessionStorage.getItem(key) || '0');
if (n < 2) { sessionStorage.setItem(key, String(n + 1)); window.location.reload(); return; }
} catch { /* storage 차단 — 카드로 폴백 */ }
}
// ... 이후 autoReloadOnce(4.2) 로직
}
autoReloadOnce(4.2)와 독립이다 — 그 가드는 첫 무관한 일시적 오류(예: WebGL)에 이미 소진될 수 있고, 청크 오류는 원인·루프 안전성 논증이 다르기 때문이다. 정말로 깨진 배포는 2회 후 카드로 귀결된다.
5. 산출물 (Deliverables)
5.1 코드 변경 — PR #1 (83e3f7f) · #2 (c0317e9)
| 파일 | 변경 | 배포 |
|---|---|---|
src/components/OsirisMap.tsx |
visibilitychange 복구 훅(4.1) + webglcontextlost/webglcontextrestored 처리(4.2), cleanup 리스너·타이머 해제 |
v1.0 d5cc8dd / v1.1 404cd17 |
src/components/ErrorBoundary.tsx |
v1.1: autoReloadOnce + onRetry prop / v1.2: isChunkLoadError 식별 후 세션당 2회 제한 자동 새로고침 |
v1.1 404cd17 / v1.2 ff96543 |
src/app/page.tsx |
Map ErrorBoundary 가 autoReloadOnce·onRetry 옵트인 |
v1.1 404cd17 |
리포지토리는 github.com/moosjiny/osiris(private) 에 신규 생성했다. 원 리포(simplifaisoul/osiris)에는 moosjiny 계정에 push 권한이 없어, 별도 리포에 master + 수정 브랜치를 push 하고 PR #1·#2 를 각각 squash-merge 했다.
5.2 상시 구동 서비스화
기존에는 npm run dev(Turbopack) 수동 실행에 의존했다. 이를 프로덕션 standalone 빌드 + systemd 사용자 서비스로 전환하여 부팅·로그아웃·크래시에 무관하게 상시 구동되도록 했다. (사용자 계정에 이미 linger=yes 설정됨 → 세션 없이도 서비스 유지)
# ~/.config/systemd/user/osiris.service
[Unit]
Description=OSIRIS — Open Source Intelligence & Reconnaissance dashboard (Next.js standalone)
After=network.target
[Service]
Type=simple
WorkingDirectory=/home/moos/dev_ws/osiris
Environment=PATH=/home/moos/.nvm/versions/node/v24.17.0/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=NODE_ENV=production
Environment=PORT=7000
Environment=HOSTNAME=0.0.0.0
Environment=NODE_OPTIONS=--dns-result-order=ipv4first
ExecStart=/home/moos/.nvm/versions/node/v24.17.0/bin/node /home/moos/dev_ws/osiris/.next/standalone/server.js
Restart=always
RestartSec=3
[Install]
WantedBy=default.target
빌드·배치 절차:
npm run build
cp -r .next/static .next/standalone/.next/static # standalone 은 정적 자산을 자동 복사하지 않음
cp -r public .next/standalone/public
cp .env .next/standalone/.env
systemctl --user daemon-reload
systemctl --user enable --now osiris.service
운영 명령:
systemctl --user status osiris # 상태
journalctl --user -u osiris -f # 로그
systemctl --user restart osiris # 재빌드 후 재기동
6. 검증 (Verification)
| 검증 항목 | 결과 |
|---|---|
systemctl --user is-active osiris |
active (Main PID 상주, Restart=always) |
curl localhost:7000/ |
200 |
curl http://100.125.27.70:7000/ (Tailscale) |
200 |
curl localhost:7000/api/health |
200 |
| 지구본 렌더 | projection: "globe", 레이어 165개, 콘솔 에러 0 |
| 백그라운드 탭 → 포그라운드 복구 (4.1) | 수 초 내 자동 렌더 (수정 전: 영구 검정) |
WebGL2 상태 (hb5u) |
건전 — ANGLE (Intel, Mesa), MAX_TEXTURE_SIZE 16384, 즉석 컨텍스트 40개 |
Failed to initialize WebGL → 자동 복구 (4.2) |
세션당 1회 자동 새로고침 → 정상, 이후 카드 + RETRY(리로드) |
| 자동 새로고침 루프 | 미발생 — ErrorBoundary·OsirisMap 공유 가드로 세션당 상한 1회 |
Failed to load chunk … → 자동 복구 (4.3) |
청크 오류 식별 → 즉시 새로고침(세션당 2회) → 정상 |
| 현재 빌드 무결성 | HTML 참조 청크 전부 200, 서버·정적 BUILD_ID 일치, 실패 청크는 어느 빌드에도 부재 |
| CCTV 레이어 | /api/cctv 200, 카메라 31,466대(좌표 누락 0), 초록 점 렌더, 클릭 시 라이브 피드 패널 정상(CAM-4088-6780 · SOZOPOL) |
| v1.2 재빌드 후 배포 | hb5u:7000 지구본·CCTV·레이어 정상 렌더, 콘솔 에러 0 |

7. 결론
지구본 미표시는 "버그"라기보다 환경(백그라운드 탭) × 설계(load 이벤트를 UI 게이트로 사용) 상호작용이었다. Page Visibility API 기반 복구 훅으로 해소했으며, 배포 형상을 개발 서버 수동 실행에서 systemd 상시 구동 프로덕션 서비스로 승격했다.
후속 v1.1·v1.2 에서는 같은 원칙 — 일시적·환경적 실패는 사용자 개입 없이 스스로 복구하되, 영구 장애는 유한한 시도 후 명시적으로 드러낸다 — 을 두 가지 후속 증상에 적용했다: WebGL 컨텍스트 초기화 실패(세션당 1회 자동 새로고침 + 컨텍스트 상실/복원 처리)와 재배포 중 ChunkLoadError(청크 오류 식별 후 세션당 2회 자동 새로고침). 변경은 PR #1·#2 로 병합되었고, OSIRIS 는 현재 hb5u.hyperbook.com:7000 에서 지구본·CCTV(31,466대)·전 레이어 정상으로 무중단 운영된다.
발신: OSIRIS (Claude Sonnet 5 · Claude Code) · 선행 배포 보고서(Antigravity)에 대한 후속 검증 및 유지보수
