
본 프로젝트는 현대 해운 물류 및 해양 데이터 시각화의 핵심인 실시간성과 확장성을 확보하는 데 목적이 있습니다.
단순히 지도를 보여주는 것을 넘어, 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)
- 데이터 수집: NestJS 서버가 주기적으로 외부 AIS 데이터 제공 API를 호출합니다.
- 데이터 가공 및 저장: 수집된 원시 데이터를 Prisma를 통해 MySQL에 저장하거나, 실시간성이 중요한 데이터는 가공 후 즉시 클라이언트로 전달합니다.
- API 제공: NestJS 컨트롤러가 Nuxt 3 클라이언트에게 JSON 형태로 데이터를 제공합니다.
- 시각화: 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 EC2나 Google Cloud Run 등을 활용합니다. PM2를 사용하여 프로세스가 죽지 않도록 관리합니다.
- CI/CD: GitHub Actions를 연동하여 코드를 Push하면 자동으로 빌드 및 배포가 진행되도록 파이프라인을 구축합니다.
4. 모니터링 및 유지보수
서비스 가동 이후의 관리를 준비합니다.
- 에러 트래킹: Sentry 등을 연동하여 사용자 기기에서 발생하는 JS 에러나 서버 에러를 실시간으로 수집합니다.
- 로그 관리: NestJS의 내장 로거를 활용해 외부 AIS API 호출 성공 여부와 DB 업데이트 상태를 기록합니다.
🏁 3부작 DB 설계 요약 (Full View)
- 1부: 선박의 물리적 제원과 실시간 좌표 중심의 기초 체력 확보.
- 2부: **공간 연산(Polygon)**을 통한 자동 통행 세션 및 지오펜싱 로직 구축.
- 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);
});
🏁 전체 개발 가이드 마무리
이로써 기획-설계-구현-최적화에 이르는 모든 과정이 끝났습니다.
- MySQL 공간 데이터를 활용한 강력한 DB 레이어
- NestJS의 안정적인 수집 및 통신 엔진
- Nuxt 3와 Google Maps의 직관적인 UI
이 스택은 실제 해운 물류 시스템이나 차량 관제 시스템으로 바로 확장 가능한 수준의 구조입니다.
'IT 개발,관리,연동,자동화' 카테고리의 다른 글
| LM Studio : 클라우드라는 거인을 내 PC 속으로 이식하는 로컬 AI (1) | 2026.04.20 |
|---|---|
| 인공지능 운영체제: 설치하는 OS에서 생성하는 OS로 (0) | 2026.04.19 |
| 안 쓰는 스마트폰이 일하는 AI 비서가 된다? AI 터미널 구축기 (1) | 2026.04.11 |
| 🚀 AI Edu-Hub(가칭) 소개 (0) | 2026.03.22 |
| 서랍 속 안드로이드 폰의 화려한 변신: 공인 IP 없이도 가능한 나만의 웹 서버 구축하기 (3) | 2026.03.18 |