울산항 3D 관제 시스템 — 시스템 아키텍처¶
2026 스마트해운물류 × ICT 멘토링 공모전 제출용 기술 문서
프로젝트:
ulsan-port-3d| 팀: [팀명 기입] | 작성일: 2026-04-24
1. 프로젝트 개요¶
울산항 공공데이터(선박 위치, 선석 현황, 기상, 화물 통계 등)를 실시간으로 수집·정규화하고, 온톨로지 기반 지식 그래프와 3D WebGL 관제 화면으로 시각화하는 풀스택 웹 애플리케이션이다.
핵심 차별점¶
| 항목 | 설명 |
|---|---|
| 온톨로지 우선 설계 | 모든 도메인 엔티티(항구, 구역, 선석, 선박, 항차 등)를 OWL 스타일 클래스/관계로 먼저 정의하고, DB 스키마와 API가 이를 반영 |
| 3D 실감 관제 | THREE.js + React Three Fiber로 울산만 실제 지형(해안선, 방파제, 태화강)과 선박을 프로시저럴 렌더링 |
| 실시간 스트리밍 | WebSocket + Redis Pub/Sub로 선박 위치·알림을 밀리초 단위 전파 |
| AI 시나리오 분석 | 혼잡도·기상·화물 데이터 결합 → 규칙 엔진 + LLM 요약 → 상황별 시나리오 프레임 생성 |
2. 기술 스택¶
프론트엔드 (apps/frontend)¶
| 기술 | 버전 | 용도 |
|---|---|---|
| React | 19 | UI 프레임워크 |
| TypeScript | 5.x | 타입 안전성 |
| THREE.js | 0.171 | 3D 렌더링 엔진 |
| @react-three/fiber | 9 | React 선언적 3D |
| @react-three/drei | 10 | 3D 유틸리티 (카메라, 환경맵 등) |
| Zustand | 5 | 상태 관리 (3개 스토어) |
| Tailwind CSS | 4 | 유틸리티 CSS |
| Vite | 6.4 | 번들러 + HMR |
백엔드 (apps/backend)¶
| 기술 | 용도 |
|---|---|
| FastAPI | REST API + WebSocket |
| SQLAlchemy 2.0 | ORM (async) |
| PostgreSQL + PostGIS | 공간 데이터 저장 |
| Alembic | DB 마이그레이션 |
| Redis | Pub/Sub 실시간 메시지 브로커 |
| Pydantic v2 | 요청/응답 검증 |
ETL 파이프라인 (etl/)¶
| 기술 | 용도 |
|---|---|
| httpx | 비동기 HTTP 클라이언트 |
| APScheduler | 주기적 수집 스케줄링 |
| 자체 normalizer | 원본 API → 정규화 레코드 변환 |
공유 패키지 (packages/)¶
| 패키지 | 용도 |
|---|---|
@ulsan-port/ontology |
온톨로지 클래스·관계 정의 (source of truth) |
@ulsan-port/shared-types |
프론트-백엔드 공유 TypeScript 타입 |
@ulsan-port/ui |
공통 UI 컴포넌트 |
인프라¶
| 기술 | 용도 |
|---|---|
| Docker Compose | 로컬 개발 환경 (DB, Redis, Backend) |
| GitHub Actions | CI (빌드·린트·테스트) + CD (GitHub Pages 배포) |
| GitHub Pages | 프론트엔드 데모 호스팅 |
2.6 기술 스택 구성 비중¶
pie title 기술 스택 구성
"프론트엔드" : 8
"백엔드" : 6
"ETL" : 3
"공유 패키지" : 3
"인프라" : 3
3. 시스템 아키텍처 다이어그램¶
flowchart TB
subgraph External ["외부 공공 API"]
API["울산항만공사 OPEN API\n(선박위치, 선석현황, 기상, 화물통계 등)"]
end
External -- "HTTP (fetch_with_retry, 3회 재시도, 지수 백오프)" --> ETL
subgraph ETL ["ETL 파이프라인 (etl/)"]
direction TB
Collectors["Collectors\n(8개 수집기)"]
Raw["Raw Storage\n(data/raw/)"]
Normalizers["Normalizers\n(vessel, berth, weather, common)"]
Upsert["DB Upsert (async)\nINSERT ON CONFLICT"]
Collectors --> Raw
Collectors --> Normalizers
Raw -.-> Normalizers
Normalizers --> Upsert
end
ETL -- "SQLAlchemy Async" --> DB
subgraph DB ["PostgreSQL + PostGIS 데이터베이스"]
direction LR
Static["정적 테이블\n- port_zone\n- berth\n- operator\n- route_segment\n- tank_terminal\n- cargo_type"]
TimeSeries["시계열 테이블\n- vessel_position (히스토리)\n- latest_vessel_position (최신)\n- vessel_event\n- berth_status / latest_berth_status\n- weather_observation\n- arrival_stat / cargo_stat_monthly"]
end
DB --> Backend
subgraph Backend ["백엔드 API (FastAPI)"]
direction TB
Router["HTTP 라우터\n(/vessels, /berths, /weather, /stats, ...)\nWebSocket 라우터\n(/ws/live, /ws/events)"]
Service["서비스 레이어\n(vessels, berths, weather, stats, graph, ...)\n(alert_engine, rule_engine, scenario_generator, ...)"]
Router <--> Service
end
Backend -- "HTTP + WebSocket" --> Frontend
subgraph Frontend ["프론트엔드 (React + THREE.js)"]
direction TB
subgraph Stores ["Zustand 스토어"]
dataStore["dataStore\n(도메인)"]
mapStore["mapStore\n(3D 씬)"]
uiStore["uiStore\n(UI 상태)"]
end
subgraph Canvas ["PortScene (Canvas)"]
StaticScene["StaticScene (memo)\n· SeaPlane\n· LandMass (지형)\n· PortGeometry"]
DynamicLayers["DynamicLayers\n· VesselLayer (선박)\n· BerthStatusLayer (선석)\n· RouteLayer (항로)"]
end
UI["UI 패널\n(선박상세, 선석상세, 기상, 통계, 온톨로지그래프, 알림배너, 필터, 헤더)"]
Stores --> Canvas
Canvas --> UI
end
4. 모노레포 구조¶
ulsan-port-3d/
├── apps/
│ ├── frontend/ # React + THREE.js 프론트엔드
│ │ ├── src/
│ │ │ ├── components/
│ │ │ │ ├── scene/ # 3D 씬 컴포넌트 (7개)
│ │ │ │ │ ├── PortScene.tsx # 메인 Canvas, 정적/동적 레이어 분리
│ │ │ │ │ ├── LandMass.tsx # 울산만 실제 해안선·방파제·태화강
│ │ │ │ │ ├── PortGeometry.tsx # 부두 구조물·크레인·저유탱크
│ │ │ │ │ ├── VesselLayer.tsx # 프로시저럴 선박 모델 (5종)
│ │ │ │ │ ├── BerthStatusLayer.tsx# 선석 상태 오버레이
│ │ │ │ │ ├── RouteLayer.tsx # 항로 라인 렌더링
│ │ │ │ │ ├── SeaPlane.tsx # 해수면 평면
│ │ │ │ │ └── portLayout.ts # 부두 배치 데이터 (12개 부두)
│ │ │ │ ├── panels/ # UI 패널 (7개)
│ │ │ │ │ ├── VesselDetailPanel.tsx
│ │ │ │ │ ├── BerthDetailPanel.tsx
│ │ │ │ │ ├── WeatherPanel.tsx
│ │ │ │ │ ├── StatsPanel.tsx
│ │ │ │ │ ├── OntologyGraphPanel.tsx
│ │ │ │ │ ├── AlertBanner.tsx
│ │ │ │ │ └── FilterPanel.tsx
│ │ │ │ └── layout/ # 레이아웃
│ │ │ │ ├── MainLayout.tsx
│ │ │ │ └── Header.tsx
│ │ │ ├── stores/ # Zustand 상태 관리 (3개)
│ │ │ │ ├── dataStore.ts # 도메인 데이터 (선박, 선석, 기상)
│ │ │ │ ├── mapStore.ts # 3D 씬 상태 (카메라, 레이어, 선택)
│ │ │ │ └── uiStore.ts # UI 상태 (패널 토글, 타임라인)
│ │ │ ├── api/
│ │ │ │ └── client.ts # HTTP API 클라이언트
│ │ │ ├── mock/ # 데모 모드 Mock 시스템
│ │ │ │ ├── data.ts # Mock 데이터 (선박 15척, 선석 12개)
│ │ │ │ ├── mockClient.ts # Mock API 클라이언트
│ │ │ │ ├── mockWebSocket.ts # Mock WebSocket 시뮬레이터
│ │ │ │ └── index.ts # enableMocks() 진입점
│ │ │ ├── hooks/
│ │ │ │ └── useWebSocket.ts # WebSocket 연결·재연결 훅
│ │ │ ├── utils/
│ │ │ │ └── coordinates.ts # WGS84 ↔ 3D 로컬 좌표 변환
│ │ │ ├── App.tsx # 앱 루트 (Mock/실환경 분기)
│ │ │ └── main.tsx # 엔트리포인트
│ │ └── (설정 파일: vite, tailwind, tsconfig, eslint, postcss)
│ │
│ └── backend/ # FastAPI 백엔드
│ ├── app/
│ │ ├── main.py # FastAPI 앱 생성·미들웨어·라우터 등록
│ │ ├── core/
│ │ │ ├── config.py # 환경 변수 설정
│ │ │ ├── database.py # SQLAlchemy async 세션
│ │ │ └── errors.py # RFC 7807 Problem Detail 에러 핸들러
│ │ ├── models/ # SQLAlchemy ORM 모델
│ │ │ ├── static.py # 정적 테이블 (Zone, Berth, Operator...)
│ │ │ ├── timeseries.py # 시계열 테이블 (Position, Event, Weather...)
│ │ │ ├── documents.py # 문서 테이블 (HazardDoc, MSDS...)
│ │ │ └── base.py # 베이스 모델
│ │ ├── routers/ # API 라우터 (12개)
│ │ │ ├── vessels.py # /vessels, /vessels/live
│ │ │ ├── berths.py # /berths
│ │ │ ├── weather.py # /weather/current
│ │ │ ├── stats.py # /stats
│ │ │ ├── graph.py # /graph/{type}/{id} (온톨로지)
│ │ │ ├── scenarios.py # /scenarios
│ │ │ ├── insights.py # /insights (AI 인사이트)
│ │ │ ├── websocket.py # /ws/live, /ws/events
│ │ │ └── (health, port, docs)
│ │ ├── schemas/ # Pydantic 요청/응답 스키마
│ │ └── services/ # 비즈니스 로직 (18개 서비스)
│ │ ├── graph.py # 온톨로지 그래프 탐색 엔진
│ │ ├── scenario_generator.py # AI 시나리오 프레임 생성
│ │ ├── alert_engine.py # 복합 알림 엔진
│ │ ├── rule_engine.py # 규칙 기반 추론 엔진
│ │ ├── insight_rules.py # 인사이트 규칙 정의
│ │ ├── llm_summary.py # LLM 기반 상황 요약
│ │ ├── pubsub.py # Redis Pub/Sub 브릿지
│ │ └── (vessels, berths, weather, stats, seed, common, docs, port)
│ └── alembic/ # DB 마이그레이션
│
├── etl/ # ETL 파이프라인
│ ├── collectors/ # 8개 데이터 수집기
│ │ ├── vessel_position.py # 선박 위치 (AIS)
│ │ ├── vessel_event.py # 선박 이벤트 (입출항)
│ │ ├── berth_status.py # 선석 현황
│ │ ├── berth_facility.py # 선석 시설 정보
│ │ ├── weather.py # 기상 관측
│ │ ├── statistics.py # 화물·입항 통계
│ │ ├── route_gis.py # 항로 GIS 데이터
│ │ └── tank_terminal.py # 유류 터미널
│ ├── normalizers/ # 정규화 모듈 (4개)
│ │ ├── vessel.py, berth.py, weather.py, common.py
│ ├── common.py # HTTP 클라이언트, 재시도, raw 저장
│ ├── database.py # ETL용 async DB 세션
│ ├── scheduler.py # APScheduler 기반 스케줄러
│ └── config.py # ETL 설정 (API 키, 주기 등)
│
├── packages/ # 공유 패키지
│ ├── ontology/src/index.ts # 온톨로지 클래스·관계 정의
│ ├── shared-types/src/index.ts # 공유 TypeScript 타입 (145줄)
│ └── ui/src/index.ts # UI 패키지 (확장 예정)
│
├── docs/ # 문서
│ ├── prd.md # 제품 요구사항 정의서
│ ├── ontology.md # 온톨로지 명세서
│ ├── api-spec.md # API 스펙 문서
│ └── architecture-ko.md # ← 본 문서
│
├── .github/workflows/
│ ├── ci.yml # CI: 빌드 + 린트 + 테스트
│ └── deploy-pages.yml # CD: GitHub Pages 자동 배포
│
├── docker-compose.yml # 로컬 개발 환경 (PostgreSQL, Redis)
├── AGENTS.md # 개발 규칙·컨벤션 (불변 제약조건 포함)
└── README.md # 프로젝트 소개
프로젝트 규모: 115개 소스 파일, 약 9,500줄
5. 데이터 파이프라인 상세¶
5.1 수집 (ETL Collectors)¶
8개 수집기가 울산항만공사 공공 API를 주기적으로 호출한다.
flowchart TD
A["외부 공공 API 호출\nfetch_with_retry()\n(3회 재시도, 지수백오프, 30초 타임아웃)"]
B["원본 저장\nsave_raw_snapshot()\n→ data/raw/{source}/{date}/{timestamp}.json"]
C["정규화\nextract_items()\n→ normalize_*()\n(원본 API 형식 → DB 레코드 형식)"]
D["DB 적재\nINSERT ON CONFLICT\n(upsert)\n→ 히스토리 + 최신 스냅샷 동시 기록"]
A --> B
B --> C
C --> D
5.1.1 수집 흐름 시퀀스¶
sequenceDiagram
participant API as 울산항 공공 API
participant Col as Collector
participant Raw as Raw Storage
participant Norm as Normalizer
participant DB as PostgreSQL
Col->>API: HTTP 요청 (재시도 3회)
API-->>Col: JSON 응답
Col->>Raw: save_raw_snapshot()
Col->>Norm: extract_items()
Norm->>Norm: normalize_*()
Norm->>DB: INSERT ON CONFLICT (upsert)
DB-->>Norm: OK
5.2 수집기 목록¶
| 수집기 | 대상 데이터 | DB 테이블 |
|---|---|---|
| vessel_position | AIS 선박 위치 | vessel_position, latest_vessel_position |
| vessel_event | 입출항 이벤트 | vessel_event |
| berth_status | 선석 가동 현황 | berth_status, latest_berth_status |
| berth_facility | 선석 시설 마스터 | berth (static) |
| weather | 기상 관측 | weather_observation |
| statistics | 입항·화물 월간 통계 | arrival_stat_monthly, cargo_stat_monthly |
| route_gis | 항로 GIS 라인 | route_segment (static) |
| tank_terminal | 유류 터미널 현황 | tank_terminal (static) |
5.3 정규화 규칙¶
- 좌표: WGS84 (EPSG:4326)로 통일 저장 → PostGIS GEOMETRY 컬럼
- 시간: UTC로 통일 저장, ISO-8601 형식 → 프론트에서 Asia/Seoul 변환 표시
- 식별자: 선박 =
call_sign + arrival_year + voyage_no, 선석 =facility_code
6. 온톨로지 설계¶
6.1 클래스 계층¶
온톨로지는 packages/ontology/src/index.ts에서 단일 소스로 정의된다.
| 도메인 | 클래스 | 설명 |
|---|---|---|
| 공간(Spatial) | Port, Zone, Berth, Buoy, RouteSegment, Terminal, TankTerminal, Operator | 항만 물리 인프라 |
| 운항(Operational) | Vessel, VoyageCall, VesselPosition, VesselEvent, BerthStatus, CongestionStat | 선박 운항·선석 상태 |
| 화물(Cargo) | CargoType, LiquidCargoStat, ArrivalStat | 화물 분류·통계 |
| 환경(Environmental) | WeatherObservation, WeatherForecast, TideObservation, HazardDoc, MsdsDoc, SafetyManual | 기상·안전 문서 |
| UI | Alert, Insight, ScenarioFrame | 시스템 생성 객체 |
6.2 관계 (Predicates)¶
graph TD
Port["Port"] -- "hasZone" --> Zone["Zone"]
Zone -- "hasBerth" --> Berth["Berth"]
Zone -- "hasBuoy" --> Buoy["Buoy"]
Zone -- "hasRouteSegment" --> RouteSegment["RouteSegment"]
Zone -- "hasWeather" --> WeatherObservation["WeatherObservation"]
Zone -- "hasForecast" --> WeatherForecast["WeatherForecast"]
Zone -- "hasTide" --> TideObservation["TideObservation"]
Operator["Operator"] -- "operates" --> Berth
Operator -- "operates" --> TankTerminal["TankTerminal"]
Operator -- "hasHazardDoc" --> HazardDoc["HazardDoc"]
TankTerminal -- "locatedIn" --> Zone
TankTerminal -- "stores" --> CargoType["CargoType"]
Vessel["Vessel"] -- "hasVoyageCall" --> VoyageCall["VoyageCall"]
Vessel -- "hasPosition" --> VesselPosition["VesselPosition"]
VoyageCall -- "usesFacility" --> Berth
VoyageCall -- "hasEvent" --> VesselEvent["VesselEvent"]
Berth -- "hasStatus" --> BerthStatus["BerthStatus"]
Berth -- "handlesCargo" --> CargoType
CargoType -- "hasMsds" --> MsdsDoc["MsdsDoc"]
6.3 온톨로지 → DB 매핑¶
온톨로지 클래스가 DB 테이블에 1:1 또는 1:N으로 매핑된다:
- 정적 테이블 (models/static.py): PortZone, Berth, Operator, RouteSegment, TankTerminal, CargoType
- 시계열 테이블 (models/timeseries.py): VesselPosition, VesselEvent, BerthStatus, WeatherObservation
- 문서 테이블 (models/documents.py): HazardDoc, MsdsDoc, SafetyManual
6.4 그래프 탐색 API¶
/graph/{entity_type}/{entity_id} 엔드포인트는 온톨로지 관계를 따라 연결된 엔티티를 그래프로 반환한다.
{
"center": { "type": "Vessel", "id": "KC-2026-001", "label": "한빛호" },
"relations": [
{ "type": "hasVoyageCall", "target": { "type": "VoyageCall", "id": "vc-123", "label": "2026-04 항차" } },
{ "type": "hasPosition", "target": { "type": "VesselPosition", "id": "pos-456", "label": "35.50°N 129.38°E" } }
]
}
7. 3D 렌더링 파이프라인¶
7.1 좌표 변환 시스템¶
WGS84 (위도/경도) ──latLonToLocal()──▶ THREE.js 로컬 좌표
기준점: 35.500°N, 129.380°E (울산항 중심)
스케일: 1 THREE.js 단위 = 100m
축 규칙:
X축 → 동쪽 (East)
Y축 → 고도 (Altitude, 위로)
Z축 → 남쪽 (South, 음수=북쪽)
7.2 씬 레이어 분리 (불변 규칙)¶
3D 씬은 정적 레이어와 동적 레이어로 엄격히 분리된다. 이는 프로젝트 불변 규칙(AGENTS.md)이며, 위반 시 차단 결함(blocking defect)으로 처리한다.
| 레이어 | 컴포넌트 | 갱신 주기 | 내용 |
|---|---|---|---|
| StaticScene (memo) | SeaPlane, LandMass, PortGeometry | 없음 (1회 렌더) | 해수면, 육지·해안선, 부두·크레인·탱크 |
| DynamicLayers | VesselLayer, BerthStatusLayer, RouteLayer | 실시간 (WS/HTTP) | 선박 위치·방향, 선석 상태 색상, 항로 |
7.2.1 선박 렌더링 상태도¶
stateDiagram-v2
[*] --> 대기중: 데이터 수신 전
대기중 --> 위치수신: WebSocket 메시지
위치수신 --> 좌표변환: latLonToLocal()
좌표변환 --> 메쉬생성: ship_type별 분기
메쉬생성 --> 렌더링: THREE.js Scene 추가
렌더링 --> 위치갱신: 새 위치 데이터
위치갱신 --> 좌표변환
렌더링 --> 선택됨: 사용자 클릭
선택됨 --> 상세패널: VesselDetailPanel 표시
상세패널 --> 렌더링: 패널 닫기
7.3 지형 렌더링 (LandMass)¶
울산만의 실제 해안선 좌표(WGS84)를 기반으로 다음 지형을 ExtrudeGeometry로 생성:
| 지형 요소 | 높이(Y) | 색상 | 설명 |
|---|---|---|---|
| 서쪽 본토 | 0.6 | 다크 시그린 | 울산만 서안, 모든 부두 배후지 |
| 북쪽 곶 | 0.6 | 다크 시그린 | 미포항 방면, 만 입구 북측 |
| 남쪽 반도 (장생포) | 0.6 | 다크 시그린 | 만 입구 남측 |
| 항만 산업구역 | 0.65 | 블루 그레이 | 부두와 해안선 사이 |
| 북방파제 | 0.8 | 콘크리트 그레이 | 만 입구 방파제 |
| 남방파제 | 0.8 | 콘크리트 그레이 | 만 입구 방파제 |
| 태화강 | 0.62 | 연한 블루 | 본토를 가로지르는 하천 |
| 해수면 | 0.0 | 블루 | 2000×2000 평면 |
7.4 선박 렌더링 (VesselLayer)¶
선박은 ship_type별로 프로시저럴(절차적) 메쉬를 생성한다:
| 선종 | 길이 | 특징 |
|---|---|---|
| Container | 3.5 | 컨테이너 적재 스택 |
| Tanker | 3.2 | 원형 탱크 돔 |
| Cargo | 2.8 | 화물창 덮개 |
| Passenger | 3.0 | 객실 블록 + 창문 |
| Tug | 1.0 | 소형 예인선 |
각 선박은 다음 파츠로 구성된다: - 선체(Hull): ExtrudeGeometry (선종별 공유 지오메트리, 모듈 스코프) - 수선(Waterline): 흰색 띠 - 갑판(Deck): 평면 박스 - 브릿지(Bridge): 상부 박스 + 창문 - 마스트(Mast): 수직 기둥 - 선종별 디테일: 컨테이너 스택 / 탱크 돔 / 크레인
방위 변환: AIS 방위(0°=북) → THREE.js Y축 회전(yaw = π/2 - headingRad)
7.5 부두 렌더링 (PortGeometry)¶
12개 부두가 portLayout.ts의 좌표·크기·회전·구역 데이터로 배치된다:
| 구역 | 부두 코드 | 색상 | 부속 구조물 |
|---|---|---|---|
| 일반(general) | ULS-B01~B05 | 그레이 | 크레인 2대 |
| 벌크(bulk) | ULS-S01~S02 | 브라운 | 크레인 3대 |
| 석유(oil) | ULS-OA1, OB1 | 오렌지 | 저유탱크 3기 |
| 컨테이너(container) | ULS-KT1~KT2 | 그린 | 크레인 4대 + 컨테이너 야드 |
| 여객(passenger) | ULS-P01 | 퍼플 | 여객 터미널 건물 |
8. 상태 관리 설계¶
Zustand를 사용하여 3개의 독립 스토어로 분리한다.
8.1 스토어 경계¶
| 스토어 | 범위 | 주요 상태 |
|---|---|---|
| dataStore | 도메인 데이터 | vessels[], berths[], weather |
| mapStore | 3D 씬 상태 | cameraPosition, activeLayerIds, selectedEntity, zoomLevel |
| uiStore | UI 상태 | 패널 토글, 탭, 타임라인, 재생 |
8.1.1 스토어 클래스 구조¶
classDiagram
class dataStore {
+Vessel[] vessels
+Berth[] berths
+Weather weather
+setVessels()
+setBerths()
+setWeather()
}
class mapStore {
+Vector3 cameraPosition
+string[] activeLayerIds
+Entity selectedEntity
+number zoomLevel
+selectEntity()
+setCamera()
}
class uiStore {
+boolean panelVisible
+string activeTab
+number timelinePosition
+boolean isPlaying
+togglePanel()
+setTab()
}
dataStore <.. mapStore : 엔티티 참조
mapStore <.. uiStore : 선택 상태 반영
8.2 데이터 흐름¶
flowchart TD
subgraph Update ["상태 업데이트"]
A["API/WebSocket 응답"] --> B["dataStore.setVessels()\n/ setBerths()\n/ setWeather()"]
B --> C["PortScene → DynamicLayers\n(useDataStore 구독)"]
C --> D["VesselLayer / BerthStatusLayer\n즉시 리렌더"]
end
subgraph Interaction ["사용자 인터랙션"]
E["사용자 인터랙션\n(선박 클릭)"] --> F["mapStore.selectEntity\n('vessel', id)"]
F --> G["VesselDetailPanel\n(useMapStore 구독)"]
G --> H["상세 정보 표시"]
end
9. Mock 데모 시스템¶
GitHub Pages에서 백엔드 없이 동작하는 데모를 위해 Mock 시스템을 구현했다.
9.1 활성화 방법¶
9.2 동작 원리¶
flowchart TD
Start["main.tsx"] --> MockCheck{"VITE_USE_MOCK === 'true'?"}
MockCheck -- "YES" --> Enable["enableMocks()"]
Enable --> Replace["Object.assign(apiClient, mockApiClient)\n(모든 API 메서드를 Mock으로 교체)"]
Replace --> StartWS["App.tsx → startMockWebSocket()\n(5초마다 선박 위치 업데이트 시뮬레이션)"]
MockCheck -- "NO" --> Real["실제 apiClient + useWebSocket 사용"]
9.3 Mock 데이터 규모¶
| 데이터 | 규모 |
|---|---|
| 선박 (vessels) | 15척 (5종 × 3척) |
| 선석 (berths) | 12개 |
| 기상 (weather) | 1건 (현재 관측) |
| 알림 (alerts) | 복수 건 |
| 온톨로지 그래프 | 노드·관계 포함 |
| 시나리오 | 복수 프레임 |
10. API 엔드포인트 요약¶
HTTP 엔드포인트¶
| 메서드 | 경로 | 설명 |
|---|---|---|
| GET | /health | 헬스체크 |
| GET | /port | 항만 개요 |
| GET | /vessels | 전체 선박 목록 |
| GET | /vessels/live | 실시간 선박 위치 |
| GET | /vessels/{id} | 선박 상세 |
| GET | /berths | 선석 목록 |
| GET | /weather/current | 현재 기상 |
| GET | /stats | 통계 데이터 |
| GET | /graph/{type}/{id} | 온톨로지 그래프 탐색 |
| GET | /graph/explore | 그래프 전체 탐색 |
| GET | /scenarios | 시나리오 목록 |
| GET | /scenarios/{id}/frames | 시나리오 프레임 |
| GET | /insights | AI 인사이트 |
| GET | /docs | 문서 검색 |
WebSocket 엔드포인트¶
| 경로 | 설명 |
|---|---|
| /ws/live | 선박 위치 실시간 스트림 |
| /ws/events | 이벤트 (입출항, 알림) 스트림 |
11. 불변 제약조건 (INVIOLABLE)¶
프로젝트 전체에서 반드시 준수해야 하는 규칙:
| 규칙 | 설명 | 위반 시 |
|---|---|---|
| 좌표 규약 | 모든 지리 데이터는 WGS84 (EPSG:4326)로 저장 | 차단 결함 |
| 시간 규약 | 모든 타임스탬프는 UTC 저장, ISO-8601 형식 | 차단 결함 |
| 3D 레이어 분리 | 정적 배경 씬과 동적 실시간 오버레이는 별도 레이어 | 차단 결함 |
| 온톨로지 우선 | 새 엔티티는 packages/ontology에 먼저 정의 | 리뷰 차단 |
| 테스트 보존 | 실패 테스트를 삭제하지 않고 근본 원인 수정 | CI 차단 |
12. 배포 파이프라인¶
12.1 브랜치 전략¶
gitGraph
commit id: "init"
branch feat/15-rest-api
commit id: "feat: add vessel router"
commit id: "feat: add berth router"
checkout main
merge feat/15-rest-api id: "squash merge"
branch fix/22-berth-color
commit id: "fix: berth status color"
checkout main
merge fix/22-berth-color id: "squash merge fix"
commit id: "deploy to Pages"
12.2 CI/CD 워크플로우¶
flowchart TD
Push["개발자 Push → main 브랜치"]
Push --> CI["CI (ci.yml)"]
subgraph CI_Process ["CI 프로세스"]
CI1["pnpm install"] --> CI2["TypeScript type-check"]
CI2 --> CI3["ESLint"]
CI3 --> CI4["pnpm build"]
end
Push --> CD["CD (deploy-pages.yml)"]
subgraph CD_Process ["CD 프로세스"]
CD1["pnpm build (프론트엔드)"] --> CD2["dist/ 아티팩트 업로드"]
CD2 --> CD3["GitHub Pages 배포"]
end
CD3 --> URL["https://yeongseon.github.io/ulsan-port-3d/"]
13. 시스템 컨텍스트¶
시스템과 외부 사용자/시스템 간의 관계를 보여준다.
graph TB
subgraph Users["사용자"]
OP["항만 운영자"]
LM["물류 관리자"]
DEV["개발자/관리자"]
end
subgraph UlsanPort3D["울산항 3D 관제 시스템"]
FE["React + THREE.js<br/>3D 관제 대시보드"]
BE["FastAPI Backend<br/>REST + WebSocket API"]
DB[("PostgreSQL<br/>+ PostGIS")]
RD[("Redis<br/>Pub/Sub")]
ETL["ETL 파이프라인<br/>8개 수집기"]
end
subgraph External["외부 시스템"]
UPA["울산항만공사 API<br/>선박/선석/시설"]
KMA["기상청 API<br/>기상/조위 관측"]
GH["GitHub Pages<br/>정적 호스팅"]
end
OP --> FE
LM --> FE
DEV --> BE
FE <-->|"REST + WebSocket"| BE
BE <--> DB
BE <--> RD
ETL --> DB
ETL -->|"HTTP (3회 재시도)"| UPA
ETL -->|"HTTP"| KMA
FE -.->|"데모 배포"| GH
14. 운영 모드¶
시스템은 두 가지 실행 모드를 지원한다.
14.1 Full-Stack 모드¶
flowchart TB
subgraph ETL_Layer["ETL 파이프라인"]
SCHED["APScheduler<br/>주기적 실행"]
COL["Collectors (8개)"]
NORM["Normalizers (4개)"]
SCHED --> COL --> NORM
end
subgraph Backend["FastAPI 백엔드"]
API["REST Routers (12개)"]
SVC["Services (18개)"]
WS_MGR["WebSocket Manager"]
PUBSUB["Redis PubSub Bridge"]
API --> SVC
SVC --> WS_MGR
PUBSUB --> WS_MGR
end
subgraph Data["데이터 계층"]
PG[("PostgreSQL + PostGIS")]
REDIS[("Redis")]
end
subgraph Frontend["React 프론트엔드"]
WSC["useWebSocket Hook"]
HTTP["apiClient (axios)"]
STORES["Zustand Stores (3개)"]
SCENE["PortScene (THREE.js)"]
PANELS["UI 패널 (7개)"]
WSC --> STORES
HTTP --> STORES
STORES --> SCENE
STORES --> PANELS
end
NORM --> PG
SVC --> PG
SVC --> REDIS
WS_MGR -->|"JSON broadcast"| WSC
PANELS -->|"HTTP GET"| API
14.2 Static / Demo 모드 (GitHub Pages)¶
백엔드 없이 브라우저에서 동작하는 데모 모드이다.
flowchart LR
subgraph Browser["브라우저"]
MOCK_EN["enableMocks()<br/>main.tsx에서 호출"]
MOCK_CLI["mockApiClient<br/>API 메서드 교체"]
MOCK_WS["mockWebSocket<br/>5초 간격 위치 시뮬레이션"]
MOCK_DATA["Mock 데이터<br/>선박 15척, 선석 12개"]
STORES2["Zustand Stores"]
UI2["React UI + THREE.js"]
MOCK_EN --> MOCK_CLI
MOCK_EN --> MOCK_WS
MOCK_CLI --> MOCK_DATA
MOCK_DATA --> STORES2
MOCK_WS --> STORES2
STORES2 --> UI2
end
두 모드의 코드 공유
프론트엔드 컴포넌트는 apiClient 객체를 통해 데이터에 접근한다. Full-Stack 모드에서는 api/client.ts의 axios HTTP 클라이언트를, Static 모드에서는 mock/mockClient.ts의 인메모리 데이터를 사용한다. enableMocks()가 Object.assign으로 메서드를 교체하므로 컴포넌트 코드 변경이 불필요하다.
15. 데이터 흐름 상세¶
15.1 선박 위치 데이터 수명주기¶
sequenceDiagram
participant UPA as 울산항만공사 API
participant COL as vessel_position 수집기
participant RAW as data/raw/ 저장소
participant NORM as vessel normalizer
participant DB as PostgreSQL
participant API as FastAPI
participant WS as WebSocket
participant FE as React 프론트엔드
loop ETL 주기 (5분)
COL->>UPA: HTTP GET (fetch_with_retry)
UPA-->>COL: JSON 응답
COL->>RAW: save_raw_snapshot()
COL->>NORM: extract_items() + normalize_vessel()
NORM->>DB: INSERT ON CONFLICT (vessel_position)
NORM->>DB: UPSERT latest_vessel_position
end
FE->>API: GET /vessels/live
API->>DB: SELECT latest_vessel_position
DB-->>API: 선박 목록
API-->>FE: JSON 응답
FE->>WS: WebSocket 연결 (/ws/events)
loop 실시간 업데이트
DB-->>API: 새 데이터 감지
API->>WS: Redis Pub/Sub 브로드캐스트
WS-->>FE: JSON 메시지
FE->>FE: dataStore.setVessels()
FE->>FE: VesselLayer 리렌더
end
15.2 알림 생성 흐름¶
sequenceDiagram
participant FE as 프론트엔드
participant API as FastAPI
participant ALERT as alert_engine
participant RULE as rule_engine
participant INSIGHT as insight_rules
participant LLM as llm_summary
participant DB as PostgreSQL
FE->>API: POST /alerts/evaluate
API->>ALERT: evaluate_alerts(db)
ALERT->>DB: 기상/선석/혼잡 데이터 조회
DB-->>ALERT: 현재 상태
ALERT->>ALERT: 임계값 판정
alt 알림 조건 충족
ALERT->>DB: INSERT alert
ALERT-->>API: new_alerts[]
end
FE->>API: GET /insights/current
API->>RULE: evaluate_rules(db)
RULE->>INSIGHT: 복합 조건 분석
INSIGHT-->>RULE: rule_based_insights[]
API->>LLM: generate_llm_summary(insights)
LLM-->>API: 자연어 요약
API-->>FE: insights + llm_summary
16. 모듈 의존성¶
16.1 백엔드 모듈 관계¶
graph TD
MAIN["main.py"] --> CORE_CFG["core/config.py"]
MAIN --> CORE_DB["core/database.py"]
MAIN --> CORE_ERR["core/errors.py"]
MAIN --> SEED["services/seed.py"]
MAIN --> R_VESSEL["routers/vessels"]
MAIN --> R_BERTH["routers/berths"]
MAIN --> R_WEATHER["routers/weather"]
MAIN --> R_STATS["routers/stats"]
MAIN --> R_GRAPH["routers/graph"]
MAIN --> R_SCEN["routers/scenarios"]
MAIN --> R_INSIGHT["routers/insights"]
MAIN --> R_WS["routers/websocket"]
MAIN --> R_PORT["routers/port"]
MAIN --> R_DOCS["routers/docs"]
MAIN --> R_HEALTH["routers/health"]
R_VESSEL --> S_VESSEL["services/vessels"]
R_BERTH --> S_BERTH["services/berths"]
R_WEATHER --> S_WEATHER["services/weather"]
R_STATS --> S_STATS["services/stats"]
R_GRAPH --> S_GRAPH["services/graph"]
R_SCEN --> S_SCEN["services/scenarios"]
R_PORT --> S_PORT["services/port"]
R_DOCS --> S_DOCS["services/docs"]
R_WS --> S_PUBSUB["services/pubsub"]
R_INSIGHT --> S_ALERT["services/alert_engine"]
R_INSIGHT --> S_RULE["services/rule_engine"]
R_INSIGHT --> S_LLM["services/llm_summary"]
S_RULE --> S_INSIGHT_R["services/insight_rules"]
S_VESSEL --> M_TS["models/timeseries"]
S_BERTH --> M_ST["models/static"]
S_WEATHER --> M_TS
S_GRAPH --> M_ST
S_GRAPH --> M_TS
S_DOCS --> M_DOC["models/documents"]
CORE_DB --> M_BASE["models/base"]
M_ST --> M_BASE
M_TS --> M_BASE
M_DOC --> M_BASE
S_PUBSUB --> REDIS[("Redis")]
style MAIN fill:#f96,stroke:#333
style S_GRAPH fill:#9f6,stroke:#333
style M_BASE fill:#69f,stroke:#333
style S_PUBSUB fill:#ff9,stroke:#333
16.2 프론트엔드 모듈 관계¶
graph TD
ENTRY["main.tsx"] --> APP["App.tsx"]
APP --> HEADER["Header"]
APP --> LAYOUT["MainLayout"]
APP --> DS["dataStore"]
APP --> API["api/client.ts<br/>(axios)"]
APP --> WS_HOOK["useWebSocket"]
LAYOUT --> SCENE["PortScene"]
LAYOUT --> PANELS["패널 영역"]
SCENE --> SEA["SeaPlane"]
SCENE --> LAND["LandMass"]
SCENE --> PORT_GEO["PortGeometry"]
SCENE --> VESSEL_L["VesselLayer"]
SCENE --> BERTH_L["BerthStatusLayer"]
SCENE --> ROUTE_L["RouteLayer"]
SCENE --> DS
SCENE --> MS["mapStore"]
VESSEL_L --> COORD["utils/coordinates<br/>latLonToLocal()"]
VESSEL_L --> MS
BERTH_L --> COORD
BERTH_L --> MS
BERTH_L --> PL["portLayout.ts"]
LAND --> COORD
PORT_GEO --> COORD
PORT_GEO --> PL
ROUTE_L --> COORD
PANELS --> VP["VesselDetailPanel"]
PANELS --> BP["BerthDetailPanel"]
PANELS --> WP["WeatherPanel"]
PANELS --> SP["StatsPanel"]
PANELS --> OP["OntologyGraphPanel"]
PANELS --> FP["FilterPanel"]
PANELS --> AB["AlertBanner"]
VP --> DS
VP --> MS
BP --> DS
BP --> MS
WP --> DS
SP --> DS
SP --> API
OP --> API
OP --> DS
OP --> MS
FP --> MS
FP --> US["uiStore"]
AB --> API
subgraph Mock["Mock 시스템"]
MOCK_IDX["mock/index.ts<br/>enableMocks()"]
MOCK_CLI["mock/mockClient.ts"]
MOCK_WS["mock/mockWebSocket.ts"]
MOCK_DATA["mock/data.ts"]
MOCK_CLI --> MOCK_DATA
MOCK_WS --> MOCK_DATA
end
ENTRY -.->|"VITE_USE_MOCK=true"| MOCK_IDX
MOCK_IDX -.->|"Object.assign"| API
style ENTRY fill:#f96
style DS fill:#9f6
style MS fill:#9f6
style US fill:#9f6
style COORD fill:#ff9
style MOCK_IDX fill:#f9f
17. 상태 관리 아키텍처¶
Zustand 3개 스토어의 상세 데이터 흐름을 보여준다.
17.1 스토어 상세 구조¶
classDiagram
class dataStore {
+Vessel[] vessels
+Berth[] berths
+WeatherData weather
+setVessels(vessels)
+setBerths(berths)
+setWeather(weather)
}
class mapStore {
+Object3D cameraPosition
+string[] activeLayerIds
+SelectedEntity selectedEntity
+number zoomLevel
+selectEntity(type, id)
+clearSelection()
+toggleLayer(layerId)
+setZoomLevel(level)
}
class uiStore {
+boolean vesselPanelOpen
+boolean berthPanelOpen
+boolean weatherPanelOpen
+boolean statsPanelOpen
+boolean filterPanelOpen
+boolean ontologyPanelOpen
+string activeTab
+togglePanel(panelName)
+setActiveTab(tab)
}
dataStore <.. mapStore : "엔티티 ID 참조"
mapStore <.. uiStore : "선택 시 패널 자동 열기"
17.2 데이터 → UI 렌더링 흐름¶
flowchart LR
subgraph Sources["데이터 소스"]
HTTP["apiClient<br/>HTTP GET"]
WS["WebSocket<br/>실시간"]
MOCK["Mock Client<br/>데모"]
end
subgraph Stores["Zustand 스토어"]
DS["dataStore<br/>vessels, berths, weather"]
MS["mapStore<br/>camera, selection, layers"]
US["uiStore<br/>panels, tabs, filters"]
end
subgraph Scene["3D 씬"]
VL["VesselLayer<br/>선박 마커"]
BL["BerthStatusLayer<br/>선석 상태 색상"]
RL["RouteLayer<br/>항로 라인"]
end
subgraph Panels["UI 패널"]
VDP["선박 상세"]
BDP["선석 상세"]
WPL["기상"]
SPL["통계"]
OGP["온톨로지 그래프"]
end
HTTP --> DS
WS --> DS
MOCK --> DS
DS -->|"vessels[]"| VL
DS -->|"berths[]"| BL
MS -->|"activeLayerIds"| VL
MS -->|"activeLayerIds"| BL
MS -->|"activeLayerIds"| RL
MS -->|"selectedEntity"| VDP
MS -->|"selectedEntity"| BDP
DS -->|"weather"| WPL
US -->|"panelVisible"| VDP
US -->|"panelVisible"| BDP
18. 이벤트 구동 아키텍처¶
시스템은 WebSocket + Redis Pub/Sub 기반의 이벤트 구동 패턴을 사용한다.
18.1 이벤트 유형¶
| 이벤트 | 발생 시점 | 전달 채널 | 소비자 |
|---|---|---|---|
sensor_update |
ETL 수집 완료 시 | Redis → WebSocket | 선박/선석/기상 레이어 |
vessel_position |
선박 위치 갱신 시 | Redis → WebSocket | VesselLayer, VesselDetailPanel |
berth_status_change |
선석 상태 전환 시 | Redis → WebSocket | BerthStatusLayer, AlertBanner |
weather_alert |
기상 임계값 초과 시 | REST + WebSocket | WeatherPanel, AlertBanner |
congestion_alert |
혼잡 임계값 초과 시 | REST | AlertBanner |
18.2 WebSocket 메시지 흐름¶
sequenceDiagram
participant ETL as ETL 수집기
participant DB as PostgreSQL
participant REDIS as Redis
participant API as FastAPI
participant WS as WebSocket Router
participant FE as 프론트엔드
ETL->>DB: 새 데이터 적재
API->>REDIS: PUBLISH (채널, 이벤트)
Note over WS: RedisPubSubService 구독 중
REDIS-->>WS: 메시지 수신
WS-->>FE: JSON broadcast
FE->>FE: useWebSocket onMessage
FE->>FE: dataStore.setVessels() 등
FE->>FE: React 리렌더 트리거
18.3 이벤트 생성 조건¶
stateDiagram-v2
[*] --> 데이터수신: ETL 수집 완료
state 기상판정 {
[*] --> 풍속체크: wind_speed 확인
풍속체크 --> 경고: > 임계값
풍속체크 --> 정상
[*] --> 파고체크: wave_height 확인
파고체크 --> 경고: > 임계값
파고체크 --> 정상
[*] --> 시정체크: visibility 확인
시정체크 --> 경고: < 임계값
시정체크 --> 정상
}
state 선석판정 {
[*] --> 점유확인: 구역별 선석 상태
점유확인 --> 비가용: 전체 점유
점유확인 --> 가용: 여유 있음
}
state 혼잡판정 {
[*] --> 대기확인: 대기 선박 수
대기확인 --> 혼잡: > 임계값
대기확인 --> 원활
}
데이터수신 --> 기상판정
데이터수신 --> 선석판정
데이터수신 --> 혼잡판정
경고 --> 단일알림: 1개 조건
비가용 --> 단일알림
혼잡 --> 단일알림
경고 --> 복합알림: 2개+ 동시
비가용 --> 복합알림
혼잡 --> 복합알림
단일알림 --> [*]
복합알림 --> [*]
19. 확장 포인트¶
| 확장 영역 | 현재 구현 | 확장 방법 |
|---|---|---|
| 데이터 소스 | 울산항만공사 공공 API (Mock) | 실제 API 키 연동, 추가 공공데이터 소스 |
| 3D 렌더링 | 프로시저럴 THREE.js 메쉬 | Spark 2.0 포토리얼리스틱, 실제 선박 3D 모델 |
| 데이터베이스 | PostgreSQL + PostGIS | 읽기 전용 복제본, 커넥션 풀 최적화 |
| 실시간 | Redis Pub/Sub + WebSocket | Redis Streams, 메시지 영속화 |
| AI/ML | 규칙 엔진 + LLM 요약 | 혼잡 예측 모델, 최적 입항 스케줄링 |
| 인증 | 없음 (공개 데모) | FastAPI OAuth2 + JWT |
| 알림 | UI 배너 전용 | Webhook → SMS/Email/Push 알림 |
| 모바일 | 데스크톱 전용 | 반응형 UI + 터치 제스처 3D 컨트롤 |
| 다국어 | 한국어 (데이터) + 영어 (코드) | i18n 프레임워크 → UI 다국어 전환 |
| 타 항만 | 울산항 전용 | 온톨로지 기반 설계로 부산항/인천항 확장 가능 |
20. 향후 발전 방향¶
| 영역 | 계획 |
|---|---|
| 실시간 연동 | 울산항만공사 실제 API 키 연동 → Mock에서 실데이터 전환 |
| AI 고도화 | LLM 기반 상황 요약 → 혼잡 예측 모델 + 최적 입항 스케줄링 |
| 3D 고도화 | Spark 2.0 포토리얼리스틱 배경, LOD 최적화, 선박 3D 모델 교체 |
| 모바일 대응 | 반응형 UI + 터치 제스처 3D 컨트롤 |
| 다국어 | 한국어/영어 UI 전환 |