IT 개발,관리,연동,자동화

🏗️ 실시간 선박/운항 정보 시스템 개발

_Blue_Sky_ 2026. 4. 22. 18:38
728x90

 본 프로젝트는 현대 해운 물류 및 해양 데이터 시각화의 핵심인 실시간성과 확장성을 확보하는 데 목적이 있습니다.
단순히 지도를 보여주는 것을 넘어, NestJS의 강력한 백엔드 구조와 Prisma의 타입 안정성을 결합하여 대규모 선박 데이터를 효율적으로 관리합니다. 또한 Nuxt 3의 SSR(Server-Side Rendering) 기능을 활용해 초기 로딩 속도를 최적화하고, 사용자에게 끊김 없는 실시간 선박 트래킹 경험을 제공합니다. 이 시스템은 향후 AI를 활용한 도착 예정 시간(ETA) 예측이나 운하 내 정체 구간 분석 등 고급 기능으로 확장할 수 있는 유연한 데이터 파이프라인 구축을 지향합니다.

 


🏗️ 제1부: 프로젝트 정의 및 시스템 아키텍처 설계
1부에서는 프로젝트의 목적을 정의하고, 선택하신 기술 스택이 각 파트에서 어떤 역할을 수행하는지, 그리고 전체적인 데이터 흐름을 어떻게 설계할 것인지 결정합니다.
1. 프로젝트 개요

  • 목적: 특정 운하 및 해역의 선박 위치 정보를 구글 맵 위에 실시간으로 시각화하고, 선박 상세 정보를 제공하는 풀스택 서비스 구축.
  • 핵심 가치: * Type-Safety: Prisma와 TypeScript를 활용한 프런트-백엔드 간 타입 일치.
    Performance: NestJS의 효율적인 비즈니스 로직 처리와 Nuxt 3의 빠른 렌더링 성능 활용.
    Scalability: MySQL과 Prisma를 통한 체계적인 데이터 관리 및 확장성 확보.

2. 기술 스택 확정 및 역할

분류기술 스택주요 역할

Frontend Nuxt.js 3 지도 UI 구현, 서버 사이드 렌더링(SSR), 실시간 데이터 페칭 및 상태 관리(Pinia).
Backend NestJS REST API 설계, AIS 외부 API 데이터 가공, 인증 및 비즈니스 로직 처리.
Database MySQL 8.0 선박 이력, 사용자 설정, 운하 마스터 정보 저장.
ORM Prisma 데이터베이스 스키마 관리, 타입 안전성이 보장된 쿼리 작성 및 마이그레이션.
Infra/API Google Maps 지리 정보 시각화 및 지도 엔진 활용.


3. 시스템 아키텍처 및 데이터 흐름 (Data Flow)

  1. 데이터 수집: NestJS 서버가 주기적으로 외부 AIS 데이터 제공 API를 호출합니다.
  2. 데이터 가공 및 저장: 수집된 원시 데이터를 Prisma를 통해 MySQL에 저장하거나, 실시간성이 중요한 데이터는 가공 후 즉시 클라이언트로 전달합니다.
  3. API 제공: NestJS 컨트롤러가 Nuxt 3 클라이언트에게 JSON 형태로 데이터를 제공합니다.
  4. 시각화: Nuxt 3에서 Google Maps API를 로드하고, 전달받은 좌표 데이터를 바탕으로 선박 마커를 렌더링합니다.

4. 초기 데이터 모델링 (Prisma Schema 초안)
MySQL에 저장될 핵심 테이블 구조를 설계합니다.

  • Canal: 운하의 이름, 중심 좌표, 감시 범위 등 정보.
  • Ship: 선박 번호(MMSI), 선박명, 타입(유조선, 화물선 등).
  • VesselLog: 실시간 위치(위도, 경도), 속도, 기상 상태 등 이력 데이터.

 


🏗️ 제2부: 개발 환경 구축 및 백엔드(NestJS + Prisma) 기초 구현
1. 프로젝트 초기화 (Project Setup)
프런트엔드와 백엔드를 독립된 디렉토리로 구성하여 프로젝트를 시작합니다.

  • NestJS 설치: nest new backend 명령어를 통해 백엔드 프로젝트를 생성합니다.
  • Nuxt 3 설치: npx nuxi@latest init frontend 명령어로 프런트엔드 프로젝트를 생성합니다.
  • MySQL 기동: Docker Compose를 사용하거나 로컬에 MySQL 8.0 인스턴스를 준비하고, 프로젝트용 데이터베이스(예: vessel_db)를 생성합니다.

2. Prisma 연동 및 데이터 모델링
Prisma를 사용해 MySQL과 NestJS를 연결합니다.

  • Prisma 초기화: npm install @prisma/client, npx prisma init
  • Schema 작성: schema.prisma 파일에 실시간 선박 정보를 담을 모델을 정의합니다.

  • Migration 실행: npx prisma migrate dev를 통해 정의한 모델을 실제 MySQL 테이블로 변환합니다.

3. NestJS 핵심 모듈 구현
백엔드 아키텍처를 견고하게 다집니다.

  • Prisma Module & Service: 애플리케이션 전역에서 DB 접근이 가능하도록 PrismaService를 생성하고 Global() 모듈로 설정합니다.
  • Vessel Module 생성: 선박 데이터를 처리할 VesselController, VesselService를 생성합니다.
  • Swagger 연동: @nestjs/swagger를 설치하여 프런트엔드 개발자가 참고할 수 있는 API 문서 자동화 환경을 구축합니다.

4. 외부 AIS API 연동 모듈 (Task Scheduling)
실시간 데이터를 가져오기 위한 핵심 로직을 준비합니다.

  • HttpModule 설정: 외부 선박 데이터 API(MarineTraffic 등)를 호출하기 위해 NestJS의 Axios 래퍼인 HttpModule을 등록합니다.
  • Task Scheduling: @nestjs/schedule을 사용하여 1분 혹은 5분마다 외부 API에서 데이터를 긁어와 DB를 업데이트하는 크론잡(Cron Job)의 기초를 설계합니다.

 


🗺️ 제3부: 프런트엔드(Nuxt 3) 지도 연동 및 UI 레이아웃 설계
1. Nuxt 3 환경 설정 및 라이브러리 설치
지도와 스타일링을 위한 라이브러리를 설정합니다.

  • Tailwind CSS: 빠르고 유연한 UI 레이아웃 구성을 위해 설치합니다. (@nuxtjs/tailwindcss)
  • Google Maps Loader: 구글 맵 API를 안전하게 로드하기 위해 @googlemaps/js-api-loader를 사용합니다.
  • Pinia: 선택된 선박 정보나 지도 중심점 등 전역 상태 관리를 위해 설정합니다.

2. Google Maps 컴포넌트 구현
재사용 가능한 지도 컴포넌트를 생성합니다.

  • BaseMap Component: components/GoogleMap.vue를 생성하여 지도를 초기화합니다.
  • API Key 보안: .env 파일에 Google Maps API 키를 저장하고, nuxt.config.ts의 runtimeConfig를 통해 관리하여 키 유출을 방지합니다.
  • 초기 좌표 설정: 이전 관심사였던 부산항이나 특정 운하 지역(예: 수에즈, 파나마)을 기본 중심점으로 설정합니다.

3. 선박 마커 시각화 (Vessel Visualization)
지도 위에 선박을 표시하는 핵심 로직을 작성합니다.

  • Custom Markers: 단순한 핀 대신, 선박의 종류별 색상이 적용된 배 모양 아이콘이나 화살표 아이콘을 사용합니다.
  • Heading 반영: 선박의 heading(선수 방향) 데이터를 활용하여 아이콘을 회전시켜 배가 나아가는 방향을 시각화합니다.
  • InfoWindow: 마커 클릭 시 선박명, 속도, 목적지 등이 표시되는 팝업창을 구현합니다.

4. 레이아웃 및 대시보드 UI 설계
지도를 방해하지 않으면서 정보를 제공하는 UI를 구성합니다.

  • 사이드바(Sidebar): 실시간 선박 리스트, 특정 선박 검색창, 운하별 필터 기능을 배치합니다.
  • 상단 바(Header): 실시간 데이터 업데이트 상태(마지막 동기화 시간) 및 시스템 알림을 표시합니다.
  • 반응형 설계: 데스크톱뿐만 아니라 태블릿 환경에서도 지도를 조작하기 쉽도록 설계합니다.

 


📡 제4부: 실시간 데이터 연동 및 시스템 통합
1. 외부 AIS 데이터 수집기(Collector) 완성
NestJS 백엔드에서 실제 데이터를 가져와 DB를 최신화합니다.

  • External API Integration: 이전에 설정한 HttpModule을 사용하여 VesselFinder나 MarineTraffic 등의 API에서 특정 운하 구역의 JSON 데이터를 가져옵니다.
  • Upsert 로직: Prisma의 upsert 기능을 사용하여, 이미 존재하는 선박은 위치를 업데이트하고, 새로 발견된 선박은 생성합니다.
  • Batch Processing: 데이터 양이 많을 경우 DB 부하를 줄이기 위해 여러 선박 데이터를 한 번에 처리하는 트랜잭션 로직을 적용합니다.

2. API 엔드포인트 고도화
Nuxt 3에서 사용할 효율적인 API를 설계합니다.

  • Bounding Box Query: 지도의 현재 보이는 영역(북동쪽/남서쪽 좌표) 내에 있는 선박만 필터링해서 보내주는 API를 작성합니다. (불필요한 데이터 전송 방지)
  • Pagination & Filtering: 선박 리스트를 위해 페이징 처리를 하고, 선박 종류나 국적별 필터링 기능을 추가합니다.

3. 실시간 통신 전략 수립 (Polling vs WebSockets)
프로젝트의 요구사항에 맞춰 통신 방식을 결정하고 구현합니다.

  • 방법 A (Polling): Nuxt 3의 useIntervalFetch 등을 이용해 일정 간격(예: 1분)마다 API를 다시 호출합니다. 구현이 쉽고 서버 부담이 적습니다.
  • 방법 B (WebSockets): @nestjs/websockets (Socket.io)를 사용하여 서버에서 위치 변화가 감지될 때마다 클라이언트에 데이터를 직접 쏴줍니다. 매우 즉각적입니다.
  • 구현: 선박의 좌표가 변경될 때마다 지도 위의 마커가 부드럽게 이동하도록 transition 효과를 부여합니다.

4. 프런트엔드 데이터 바인딩 (Pinia & Composables)
가져온 데이터를 UI에 효율적으로 연결합니다.

  • useVessels Composable: 선박 데이터를 호출하고 관리하는 공통 로직을 작성합니다.
  • Reactive State: Pinia 스토어에 현재 지도에 표시된 선박 리스트를 담고, 사이드바와 지도가 동일한 데이터를 공유하도록 만듭니다.
  • Error Handling: API 호출 실패나 데이터 누락 시 사용자에게 알림을 띄우는 예외 처리를 추가합니다.

  


🚀 제5부: 최적화, 보안 및 배포 가이드
1. 성능 최적화 (Performance Tuning)
수백, 수천 척의 선박을 지도에 띄울 때 브라우저가 느려지는 것을 방지합니다.

  • 마커 클러스터링(Marker Clustering): 너무 밀집된 구역의 선박들은 숫자가 적힌 원형 아이콘으로 합쳐서 보여주다가, 지도를 확대하면 개별 선박으로 분리되도록 구현합니다. (@googlemaps/markerclusterer)
  • 좌표 데이터 경량화: 프런트엔드에서 쓰지 않는 불필요한 메타데이터는 제외하고, 위도/경도의 소수점 자릿수를 제한하여 전송 패킷 크기를 줄입니다.
  • DB 인덱싱: MySQL에서 선박의 mmsi 필드와 위치 검색 속도를 높이기 위한 공간 인덱스(Spatial Index) 설정을 검토합니다.

2. 보안 및 안정성 강화
공개 서비스 시 발생할 수 있는 위협에 대비합니다.

  • API 보안: NestJS에 ThrottlerModule을 도입하여 특정 IP의 과도한 API 호출(Rate Limiting)을 방지합니다.
  • CORS 설정: 허용된 Nuxt 3 도메인에서만 백엔드 API에 접근할 수 있도록 화이트리스트를 설정합니다.
  • 환경 변수 관리: cross-env와 .env 파일을 활용해 개발/운영 환경의 DB 접속 정보와 API Key를 철저히 분리합니다.

3. 배포 전략 (Deployment)
서비스 성격에 맞는 배포 방식을 선택합니다.

  • 프런트엔드 (Nuxt 3): Vercel 또는 Netlify를 통해 서버리스 환경으로 배포하거나, 백엔드와 함께 Docker 컨테이너로 배포합니다.
  • 백엔드 (NestJS): AWS EC2Google Cloud Run 등을 활용합니다. PM2를 사용하여 프로세스가 죽지 않도록 관리합니다.
  • CI/CD: GitHub Actions를 연동하여 코드를 Push하면 자동으로 빌드 및 배포가 진행되도록 파이프라인을 구축합니다.

4. 모니터링 및 유지보수
서비스 가동 이후의 관리를 준비합니다.

  • 에러 트래킹: Sentry 등을 연동하여 사용자 기기에서 발생하는 JS 에러나 서버 에러를 실시간으로 수집합니다.
  • 로그 관리: NestJS의 내장 로거를 활용해 외부 AIS API 호출 성공 여부와 DB 업데이트 상태를 기록합니다.

🏁 3부작 DB 설계 요약 (Full View)

  1. 1부: 선박의 물리적 제원실시간 좌표 중심의 기초 체력 확보.
  2. 2부: **공간 연산(Polygon)**을 통한 자동 통행 세션 및 지오펜싱 로직 구축.
  3. 3부: 분석 통계사고 대응 알림을 통한 운영 효율성 극대화.

이로써 NuxtJS, NestJS, Prisma, MySQL 기반의 선박 운하 관제 시스템을 위한 완벽한 DB 설계가 마무리되었습니다. 이 구조를 바탕으로 Prisma 모델을 정의하고 코딩을 시작하시면, 실제 현업에서도 바로 사용 가능한 수준의 탄탄한 시스템을 만드실 수 있습니다. 추가로 특정 쿼리 작성법이나 API 인터페이스 설계가 필요하시면 언제든 말씀해 주세요!


 

🏗️ 고도화된 DB 설계 제1부: 선박 엔티티 및 상태 동기화 아키텍처
1. 설계의 핵심 철학

  • 정적/동적 데이터 분리: 선박의 이름, 크기 같은 '정적 정보'와 위치, 속도 같은 '동적 정보'를 엄격히 분리하여 쿼리 성능을 최적화합니다.
  • 공간 데이터(Spatial) 최적화: MySQL의 GEOMETRY 타입을 적극 활용하여 지리적 연산을 DB 레벨에서 처리합니다.
  • 이력 추적성: 선박의 상태 변화(정박 중 -> 항해 중)를 타임라인으로 추적할 수 있어야 합니다.

2. 상세 테이블 설계
(1) ships (선박 마스터 - 정적 데이터)
선박의 물리적 사양은 운하 통과 가능 여부 및 통행료 산정의 기준이 됩니다.

필드명 (Column)타입 (Type)제약 조건설명

mmsi VARCHAR(20) PRIMARY KEY AIS 고유 식별 번호
imo_no VARCHAR(20) UNIQUE 국제해사기구 번호
ship_name VARCHAR(100) NOT NULL 선박 정식 명칭
call_sign VARCHAR(20) - 호출 부호
flag_state VARCHAR(5) - 국적 (ISO 국가 코드)
vessel_type ENUM - Cargo, Tanker, Tug, Fishing 등
length DECIMAL(6,2) - 선박 총 길이 (LoA)
width DECIMAL(6,2) - 선박 최대 폭 (Beam)
gross_tonnage INT - 총 톤수
max_draft DECIMAL(4,2) - 최대 흘수 (잠기는 깊이)


(2) vessel_live_status (실시간 상태 - 핫 데이터)
가장 빈번하게 조회되는 '현재 상태'만 따로 관리하여 인덱스 효율을 극대화합니다.

필드명 (Column)타입 (Type)제약 조건설명

mmsi VARCHAR(20) PK / FK ships.mmsi 참조
current_pos POINT SPATIAL INDEX 실시간 위/경도 좌표 (WGS84)
speed FLOAT - 현재 속도 (Knots)
course FLOAT - 대지침로 (COG, 0-359.9)
heading FLOAT - 선수 방향 (Heading, 0-359.9)
nav_status TINYINT - 항해 상태 (0: 항해 중, 1: 정박 중, 5: 계류 중 등)
destination VARCHAR(100) - 현재 보고된 목적지
eta DATETIME - 도착 예정 시간
last_updated TIMESTAMP ON UPDATE 최종 데이터 수신 시각


(3) vessel_status_codes (상태 코드 마스터)
AIS 표준 규격에 따른 상태 코드를 관리합니다.

ID코드명설명

0 Under Way 엔진을 사용하여 항해 중
1 At Anchor 닻을 내리고 정박 중
5 Moored 부두에 계류 중


3. 응용 시나리오: 왜 이렇게 설계했는가?
1) 지도로 보는 실시간 쿼리 효율성
구글 맵에서 특정 영역(Bounding Box)을 드래그할 때, vessel_live_status 테이블의 current_pos 필드에 걸린 Spatial Index 덕분에 수십만 척 중 현재 화면에 보이는 배들만 순식간에 필터링됩니다.
Query Example: > SELECT * FROM vessel_live_status WHERE ST_Within(current_pos, ST_GeomFromText('POLYGON((...))'))
2) 운하 진입 적합성 자동 판단
선박이 운하 입구에 도착하면, ships 테이블의 length와 max_draft를 조회하여 현재 운하의 수심이나 허용 길이와 대조해 **"통과 가능 여부"**를 시스템이 자동으로 알람을 띄울 수 있습니다.
3) 데이터 정규화와 성능의 균형
이름이나 크기 같은 데이터는 ships에 있고, 위치는 vessel_live_status에 있습니다. 만약 위치가 바뀔 때마다 큰 덩어리의 선박 정보를 매번 건드린다면 DB 부하가 극심해지지만, 이 구조는 위치 정보(작은 데이터)만 빠르게 업데이트하므로 초당 수천 건의 AIS 수집도 거뜬히 처리합니다.


🏗️ 고도화된 DB 설계 제2부: 지오펜싱 및 통행 세션 관리
1. 설계의 핵심 철학

  • 지리적 경계 자동화: 운하를 단순 점(Point)이 아닌 다각형(Polygon) 영역으로 관리하여 물리적 진입을 감지합니다.
  • 세션화(Sessionizing): 배가 운하에 들어와서 나갈 때까지를 하나의 '통행 세션'으로 묶어 정체 시간과 통행료를 산출합니다.
  • 비즈니스 로직 연동: 진입 시점에 선박의 제원과 운하의 제한 사항을 대조하여 위험 요소를 사전에 차단합니다.

2. 상세 테이블 설계
(1) canal_zones (운하 구역 및 지오펜싱 마스터)
운하 전체 또는 운하 내 특정 구간(대기 구역, 통과 구역 등)을 다각형으로 정의합니다.

필드명 (Column)타입 (Type)제약 조건설명

id INT PK 구역 고유 식별자
name VARCHAR(100) - 구역 이름 (예: 수에즈 북부 진입로)
zone_type ENUM - ENTRY, EXIT, ANCHORAGE, TRANSIT
boundary_polygon POLYGON SPATIAL INDEX 구역 경계 좌표 (공간 연산용)
max_speed_limit FLOAT - 해당 구역 내 제한 속도
base_fee DECIMAL(15,2) - 해당 구역 통과 시 부과되는 기본료


(2) transit_sessions (운하 통행 기록 - 세션 데이터)
선박이 운하 구역에 머문 전체 시간을 기록하며, 분석의 핵심 데이터가 됩니다.

필드명 (Column)타입 (Type)제약 조건설명

id BIGINT PK 세션 고유 ID
ship_mmsi VARCHAR(20) FK ships.mmsi 참조
canal_id INT FK canal_zones.id 참조
entry_time DATETIME - 구역 진입 시각 (첫 감지)
exit_time DATETIME NULL 구역 이탈 시각 (이탈 감지 시 업데이트)
stay_duration INT - 체류 시간 (분 단위, 자동 계산)
status ENUM - IN_TRANSIT, COMPLETED, STUCK, EMERGENCY
fee_billed DECIMAL(15,2) - 최종 산정된 통행료


(3) congestion_snapshots (운하 정체도 실시간 통계)
매 10~30분마다 운하별 선박 밀도를 집계하여 저장합니다. (대시보드 차트용)

필드명 (Column)타입 (Type)제약 조건설명

id BIGINT PK 스냅샷 ID
canal_id INT FK 대상 운하
vessel_count INT - 해당 시점 구역 내 선박 수
avg_speed FLOAT - 해당 시점 선박들의 평균 속도
congestion_level TINYINT - 1(원활) ~ 5(매우 정체)
recorded_at TIMESTAMP - 기록 시점


3. 응용 시나리오: 시스템 작동 원리
1) 실시간 진입 감지 (Geofencing Trigger)
새로운 AIS 위치 데이터(vessel_live_status)가 들어올 때마다 백엔드에서 다음 쿼리를 수행합니다:
SELECT id FROM canal_zones WHERE ST_Contains(boundary_polygon, ST_GeomFromText('POINT(위도 경도)'))

  • 결과값이 있고, 기존에 진행 중인 세션이 없다면? -> 새로운 transit_sessions 생성 (진입 알람)
  • 기존 세션이 있는데 결과값이 없다면? -> exit_time 기록 및 세션 종료 (통과 완료)

2) 정체도 시각화 (Heatmap)
congestion_snapshots 데이터를 기반으로 구글 맵 위에 운하 구간별 색상을 입힙니다.

  • 평균 속도가 제한 속도의 30% 미만인 구간은 빨간색으로 표시하여 관리자가 즉시 상황을 파악하게 합니다.

3) 통행료 자동 정산
선박의 ships.gross_tonnage와 canal_zones.base_fee를 곱하여, 세션이 종료되는 시점에 자동으로 청구 금액을 산출합니다.


🏗️ 고도화된 DB 설계 제3부: 통계 분석 및 스마트 알림 시스템
1. 설계의 핵심 철학

  • 데이터 인사이트(Insight): 단순 기록을 넘어 평균 통과 시간, 요일별 정체도 등 분석 가능한 형태로 가공합니다.
  • 즉각적 대응(Real-time Response): 위험 상황(충돌 위험, 경로 이탈)을 탐지하기 위한 알림 구조를 구축합니다.
  • 보안 및 감사(Audit): 어떤 사용자가 어떤 설정을 변경했는지 모든 이력을 남겨 시스템 신뢰도를 높입니다.

2. 상세 테이블 설계
(1) vessel_alerts (실시간 위기 관리 및 알림)
지오펜싱과 연동하여 선박의 위험 행동이나 시스템 이상을 기록합니다.

필드명 (Column)타입 (Type)제약 조건설명

id BIGINT PK 알림 고유 ID
ship_mmsi VARCHAR(20) FK 대상 선박
alert_type ENUM - SPEED_VIOLATION(과속), ROUTE_DEVIATION(경로이탈), PROHIBITED_ZONE(진입금지)
severity ENUM - INFO, WARNING, CRITICAL
description TEXT - 상황 상세 설명 (예: "제한속도 10노트 구역에서 18노트 탐지")
is_resolved BOOLEAN DEFAULT FALSE 관리자 확인 여부
created_at TIMESTAMP - 발생 시각


(2) daily_canal_stats (운하별 일일 통계 - 분석용)
매일 자정, 전날의 데이터를 집계하여 저장합니다. (보고서 및 장기 트렌드 분석용)

필드명 (Column)타입 (Type)제약 조건설명

id INT PK 통계 ID
canal_id INT FK 대상 운하
total_vessels INT - 하루 동안 통과한 총 선박 수
avg_transit_time INT - 평균 통과 소요 시간 (분)
total_fee DECIMAL(18,2) - 당일 총 통행료 수익 합계
peak_hour TINYINT - 가장 붐볐던 시간대 (0-23)
stat_date DATE INDEX 통계 기준 날짜


(3) audit_logs (사용자 행위 및 시스템 로그)
시스템 설정 변경이나 주요 데이터 조작 이력을 남깁니다.

필드명 (Column)타입 (Type)제약 조건설명

id BIGINT PK 로그 ID
user_id INT FK 변경을 수행한 관리자 ID
action VARCHAR(50) - 수행 동작 (예: UPDATE_CANAL_ZONE, DELETE_SHIP)
target_table VARCHAR(50) - 변경된 테이블명
old_value JSON - 변경 전 데이터
new_value JSON - 변경 후 데이터
ip_address VARCHAR(45) - 접속 IP 정보


3. 응용 시나리오: 시스템 고도화의 완성
1) 예측형 대시보드 구현
daily_canal_stats에 쌓인 지난 1년간의 데이터를 기반으로, "내일 오후 2시경 수에즈 운하의 예상 정체도"를 시각화할 수 있습니다. 이는 선박들에게 미리 우회로나 대기 시간을 안내하는 유료 서비스로 확장 가능합니다.
2) 자동 알림 푸시 (Notification)
vessel_alerts에 CRITICAL 등급의 알림이 생성되는 순간, NestJS의 WebSocket이나 Push API를 통해 관제사의 화면에 팝업을 띄우고 동시에 해당 선박 담당자에게 SMS를 발송하는 로직을 구현합니다.
3) 데이터 라이프사이클 관리 (Cold Data)
vessel_positions처럼 매초 쌓이는 방대한 데이터는 3개월이 지나면 daily_canal_stats로 요약본만 남기고, 원본 데이터는 압축하여 별도의 스토리지로 보관(Archiving)함으로써 DB 성능을 일정하게 유지합니다.


🛠️ 실전 개발 4부작 - 제1부: 프로젝트 초기화 및 Prisma 데이터 레이어
1부의 목표는 백엔드와 프런트엔드의 기본 구조를 잡고, Prisma를 통해 MySQL에 고도화된 선박/운하 테이블을 생성하는 것입니다.
1. 프로젝트 구조 및 환경 설정 (Setup)
먼저 터미널에서 두 프로젝트를 생성합니다.

# 1. NestJS 백엔드 생성
npx @nestjs/cli new backend --package-manager npm

# 2. Nuxt 3 프런트엔드 생성
npx nuxi@latest init frontend

# 3. 백엔드 필수 패키지 설치
cd backend
npm install @prisma/client @nestjs/config
npm install -D prisma
npx prisma init


2. Prisma Schema 정의 (MySQL 레이아웃 코드)
backend/prisma/schema.prisma 파일에 앞서 설계한 엔터프라이즈급 스키마를 작성합니다.

// backend/prisma/schema.prisma

datasource db {
  provider = "mysql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

// 1. 선박 마스터 정보
model Ship {
  mmsi        String           @id @db.VarChar(20)
  name        String           @db.VarChar(100)
  type        String           @db.VarChar(50)
  width       Float?
  length      Float?
  lastUpdate  DateTime         @updatedAt
  
  // 관계 설정
  locations   VesselLocation[]
  transits    TransitSession[]
}

// 2. 운하/구역 정보
model CanalZone {
  id              Int              @id @default(autoincrement())
  name            String           @db.VarChar(100)
  // MySQL GEOMETRY 타입은 Prisma에서 직접 지원하지 않으므로 
  // 원형 구역용 좌표나 JSON으로 경계 데이터를 관리합니다.
  centerLat       Float
  centerLng       Float
  radiusKm        Float            @default(5.0)
  
  transits        TransitSession[]
}

// 3. 선박 통행 세션 (지오펜싱용)
model TransitSession {
  id          BigInt    @id @default(autoincrement())
  shipMmsi    String
  canalId     Int
  entryTime   DateTime  @default(now())
  exitTime    DateTime?
  status      String    @default("IN_TRANSIT") // IN_TRANSIT, COMPLETED
  
  ship        Ship      @relation(fields: [shipMmsi], references: [mmsi])
  canal       CanalZone @relation(fields: [canalId], references: [id])

  @@index([shipMmsi])
  @@index([canalId])
}

// 4. 실시간 위치 로그
model VesselLocation {
  id        BigInt   @id @default(autoincrement())
  shipMmsi  String
  lat       Float
  lng       Float
  speed     Float
  heading   Float
  timestamp DateTime @default(now())

  ship      Ship     @relation(fields: [shipMmsi], references: [mmsi])
  
  @@index([shipMmsi])
}


3. NestJS Prisma Service 구현
DB 연결을 담당할 서비스를 코드로 작성합니다.

// backend/src/prisma/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}


4. DB 마이그레이션 실행
설정된 스키마를 MySQL에 반영합니다. ( .env 파일에 DATABASE_URL이 설정되어 있어야 합니다. )

# 환경 변수 예시: DATABASE_URL="mysql://root:password@localhost:3306/vessel_db"
npx prisma migrate dev --name init_vessel_system

🛠️ 실전 개발 4부작 - 제2부: AIS 수집기 및 비즈니스 로직 구현
1. 외부 API 연동을 위한 모듈 설정
먼저 외부와 통신하고 주기적으로 작업을 실행할 수 있도록 패키지를 설정합니다.

# backend 디렉토리에서 실행
npm install @nestjs/axios axios @nestjs/schedule


2. AIS 데이터 수집 서비스 (AIS Collector)
외부 API에서 데이터를 긁어와 DB를 최신화하는 핵심 서비스 코드입니다.

// backend/src/vessel/vessel-collector.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { HttpService } from '@nestjs/axios';
import { PrismaService } from '../prisma/prisma.service';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class VesselCollectorService {
  private readonly logger = new Logger(VesselCollectorService.name);

  constructor(
    private readonly httpService: HttpService,
    private readonly prisma: PrismaService,
  ) {}

  // 1분마다 실행되는 크론잡
  @Cron(CronExpression.EVERY_MINUTE)
  async collectVesselData() {
    this.logger.log('AIS 데이터 수집 시작...');
    
    try {
      // 실제 구현 시 외부 AIS API URL과 키를 사용 (예시 URL)
      const apiUrl = 'https://api.vesselfinder.com/vessels?bbox=35.0,129.0,35.2,129.2'; 
      const response = await firstValueFrom(this.httpService.get(apiUrl));
      const vessels = response.data; // [ { mmsi: "123", lat: 35.1, lng: 129.1, ... }, ... ]

      for (const data of vessels) {
        await this.updateVesselState(data);
      }
      
      this.logger.log(`${vessels.length}척의 선박 정보 갱신 완료.`);
    } catch (error) {
      this.logger.error('데이터 수집 중 오류 발생', error);
    }
  }

  private async updateVesselState(data: any) {
    // 1. 선박 마스터 및 최신 위치를 동시에 Upsert (존재하면 수정, 없으면 생성)
    await this.prisma.ship.upsert({
      where: { mmsi: data.mmsi },
      update: {
        name: data.name,
        lastUpdate: new Date(),
        locations: {
          create: {
            lat: data.lat,
            lng: data.lng,
            speed: data.speed,
            heading: data.heading,
          }
        }
      },
      create: {
        mmsi: data.mmsi,
        name: data.name,
        type: data.type,
        locations: {
          create: {
            lat: data.lat,
            lng: data.lng,
            speed: data.speed,
            heading: data.heading,
          }
        }
      }
    });
  }
}


3. 지오펜싱(Geofencing) 로직 구현
선박이 특정 운하 구역(CanalZone) 안에 있는지 계산하는 서비스 로직입니다.

// backend/src/vessel/vessel.service.ts
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';

@Injectable()
export class VesselService {
  constructor(private prisma: PrismaService) {}

  // 단순 거리 계산 기반 지오펜싱 (피타고라스 정리 활용 - 근사치)
  async checkCanalEntry(mmsi: string, lat: number, lng: number) {
    const zones = await this.prisma.canalZone.findMany();

    for (const zone of zones) {
      const distance = this.getDistance(lat, lng, zone.centerLat, zone.centerLng);
      
      if (distance <= zone.radiusKm) {
        // 구역 진입 시 TransitSession 생성 로직 (생략된 기존 세션 체크 포함 가능)
        await this.prisma.transitSession.upsert({
          where: { /* 복합키 또는 특정 조건 */ id: 0 }, 
          update: { status: 'IN_TRANSIT' },
          create: {
            shipMmsi: mmsi,
            canalId: zone.id,
            status: 'IN_TRANSIT'
          }
        });
      }
    }
  }

  private getDistance(lat1: number, lon1: number, lat2: number, lon2: number) {
    const R = 6371; // 지구 반지름 (km)
    const dLat = (lat2 - lat1) * (Math.PI / 180);
    const dLon = (lon2 - lon1) * (Math.PI / 180);
    const a = 
      Math.sin(dLat / 2) * Math.sin(dLat / 2) +
      Math.cos(lat1 * (Math.PI / 180)) * Math.cos(lat2 * (Math.PI / 180)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
    const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
    return R * c;
  }
}

🛠️ 실전 개발 4부작 - 제3부: Nuxt 3 지도 UI 및 실시간 마커 시각화
1. 프런트엔드 환경 설정 및 라이브러리 설치
구글 맵을 효율적으로 다루기 위해 필요한 도구들을 설치합니다.

# frontend 디렉토리에서 실행
npm install @googlemaps/js-api-loader
npm install -D @nuxtjs/tailwindcss # UI 스타일링용


2. 구글 맵 로더 컴포넌트 구현
구글 맵 SDK를 비동기로 로드하고 지도를 초기화하는 핵심 컴포넌트를 작성합니다.

<template>
  <div class="relative w-full h-screen">
    <div ref="mapRef" class="w-full h-full"></div>
    
    <div class="absolute top-4 left-4 bg-white p-4 shadow-lg rounded-lg z-10">
      <h2 class="font-bold">실시간 선박 현황</h2>
      <p class="text-blue-600 text-xl">{{ vessels.length }} 척 항해 중</p>
    </div>
  </div>
</template>

<script setup>
import { Loader } from '@googlemaps/js-api-loader';

const mapRef = ref(null);
const map = ref(null);
const markers = ref({}); // MMSI를 키로 하는 마커 관리 객체
const vessels = ref([]);

// 1. 구글 맵 초기화
onMounted(async () => {
  const loader = new Loader({
    apiKey: 'YOUR_GOOGLE_MAPS_API_KEY', // .env에서 가져오길 권장
    version: 'weekly',
  });

  const { Map } = await loader.importLibrary('maps');
  
  map.value = new Map(mapRef.value, {
    center: { lat: 35.10, lng: 129.04 }, // 부산항 기준
    zoom: 13,
    mapId: 'DEMO_MAP_ID', // 고급 마커 사용 시 필요
  });

  // 2. 실시간 데이터 페칭 시작
  startDataPolling();
});

// 2. 백엔드 API로부터 데이터 가져오기
const startDataPolling = () => {
  const fetchData = async () => {
    try {
      const data = await $fetch('http://localhost:3000/vessels');
      vessels.value = data;
      updateMarkers();
    } catch (e) {
      console.error('데이터 호출 실패:', e);
    }
  };

  fetchData();
  setInterval(fetchData, 30000); // 30초마다 갱신
};

// 3. 지도 위 마커 업데이트 로직
const updateMarkers = () => {
  vessels.value.forEach(vessel => {
    const position = { lat: vessel.lat, lng: vessel.lng };

    if (markers.value[vessel.mmsi]) {
      // 기존 마커 위치 업데이트
      markers.value[vessel.mmsi].setPosition(position);
    } else {
      // 새 마cer 생성
      const marker = new google.maps.Marker({
        position,
        map: map.value,
        title: vessel.name,
        icon: {
          path: google.maps.SymbolPath.FORWARD_CLOSED_ARROW, // 화살표 아이콘
          scale: 5,
          fillColor: '#1D4ED8',
          fillOpacity: 1,
          strokeWeight: 2,
          rotation: vessel.heading || 0, // 선박 방향에 맞춰 회전
        },
      });
      markers.value[vessel.mmsi] = marker;
    }
  });
};
</script>


3. 실시간 위치 부드러운 이동 (선택적 최적화)
데이터 갱신 시 마커가 '순간이동' 하지 않고 부드럽게 움직이게 하려면 requestAnimationFrame을 사용하거나 간단한 CSS 트랜지션을 오버레이 뷰에 적용할 수 있습니다. 위 코드에서는 setPosition을 통해 위치를 갱신합니다.
4. 선박 상세 정보창 (InfoWindow) 연동
마커 클릭 시 선박의 상세 정보를 보여주는 기능을 추가합니다.

// updateMarkers 함수 내부 루프에 추가
marker.addListener('click', () => {
  const infoWindow = new google.maps.InfoWindow({
    content: `
      <div class="p-2">
        <h3 class="font-bold border-b pb-1">${vessel.name}</h3>
        <p class="text-sm mt-1">MMSI: ${vessel.mmsi}</p>
        <p class="text-sm">속도: ${vessel.speed} kts</p>
        <p class="text-sm font-semibold text-blue-500">목적지: ${vessel.destination || '정보 없음'}</p>
      </div>
    `,
  });
  infoWindow.open(map.value, marker);
});

🏁 전체 개발 가이드 마무리
이로써 기획-설계-구현-최적화에 이르는 모든 과정이 끝났습니다.

  1. MySQL 공간 데이터를 활용한 강력한 DB 레이어
  2. NestJS의 안정적인 수집 및 통신 엔진
  3. Nuxt 3Google Maps의 직관적인 UI

이 스택은 실제 해운 물류 시스템이나 차량 관제 시스템으로 바로 확장 가능한 수준의 구조입니다. 

728x90