chore: 프로젝트 초기 구성

- frontend: React 19 + Vite 7 + Leaflet + Tailwind + Zustand
- backend: Express + better-sqlite3 + TypeScript
- database: PostgreSQL 초기화 스크립트
- .gitignore: 대용량 참고자료(scat, 참고용) 및 바이너리 파일 제외
- .env.example: API 키 템플릿

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-27 11:06:21 +09:00
커밋 fb556fad9e
1240개의 변경된 파일45990개의 추가작업 그리고 0개의 파일을 삭제

30
.claude/settings.local.json Executable file
파일 보기

@ -0,0 +1,30 @@
{
"permissions": {
"allow": [
"Bash(npm install:*)",
"Bash(npx tailwindcss init:*)",
"Bash(/Users/nankyunglee/Documents/wing/LayerList_New.csv:*)",
"Bash(/Users/nankyunglee/Documents/wing/backend/src/utils/layerIcons.ts:*)",
"Bash(/Users/nankyunglee/Documents/wing/transform_layers.js:*)",
"Bash(__NEW_LINE_5781bf96384c4503__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
"Bash(__NEW_LINE_2dfc1a230be907fa__ node /Users/nankyunglee/Documents/wing/transform_layers.js)",
"Bash(npm run db:seed:*)",
"Bash(npm run dev:*)",
"Bash(lsof:*)",
"Bash(xargs kill:*)",
"Bash(npm start)",
"Bash(npm run build:*)",
"Bash(curl:*)",
"WebFetch(domain:www.khoa.go.kr)",
"WebFetch(domain:opendrift.github.io)",
"WebFetch(domain:www.windy.com)",
"Bash(npx tsc:*)",
"Bash(brew install:*)",
"Bash(python3:*)",
"Bash(npx vite build:*)",
"Bash(pdftotext:*)",
"Bash(wc:*)",
"Bash(ls:*)"
]
}
}

66
.gitignore vendored Executable file
파일 보기

@ -0,0 +1,66 @@
# Dependencies
node_modules/
# Build output
dist/
dist-ssr/
# Environment variables (use .env.example as template)
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# IDE
.vscode/
.idea/
# Logs
*.log
npm-debug.log*
# Database files
backend/data/*.db
backend/data/*.db-shm
backend/data/*.db-wal
# Large reference data (keep locally, do not commit)
_reference/
scat/
참고용/
논문/
# Binary / archive files
*.pdf
*.xlsx
*.xlsm
*.hwpx
*.tar.gz
*.zip
*.gif
# HTML mockup / report files
방제과개선안_*.html
meris_*.html
# Source archive (regenerate from repo)
wing-frontend-src.zip
wing_source_*.tar.gz
# Python
__pycache__/
*.pyc
# HNS manual images (large binary)
frontend/public/hns-manual/pages/
frontend/public/hns-manual/images/
# Claude Code (team workflow tracked, override global gitignore)
!.claude/
# Lock files (keep for reproducible builds)
!frontend/package-lock.json
!backend/package-lock.json

30
README.md Normal file
파일 보기

@ -0,0 +1,30 @@
# WING-OPS
해양 방제 운영 지원 시스템
## 구조
```
frontend/ React 19 + Vite 7 + TypeScript + Tailwind
backend/ Express + better-sqlite3 + TypeScript
database/ SQL 초기화 스크립트
docs/ 설치 가이드, 문서
```
## 실행
```bash
# frontend
cd frontend && npm install && npm run dev
# backend
cd backend && npm install && npm run dev
```
## 환경 설정
```bash
cp frontend/.env.example frontend/.env
cp backend/.env.example backend/.env
# .env 파일에 API 키 입력
```

5
backend/.env.example Normal file
파일 보기

@ -0,0 +1,5 @@
# Database
DATABASE_URL="file:./data/wing.db"
# Server
PORT=3001

198
backend/API.md Executable file
파일 보기

@ -0,0 +1,198 @@
# Backend API 문서
## 시뮬레이션 API
### POST /api/simulation/run
오일 확산 시뮬레이션을 실행합니다.
#### 요청
```json
{
"model": "OpenDrift", // "KOSPS" | "POSEIDON" | "OpenDrift" | "앙상블"
"lat": 34.7312, // 사고 위도
"lon": 127.6845, // 사고 경도
"duration_hours": 48, // 예측 시간 (시간)
"oil_type": "벙커C유", // 유종
"spill_amount": 100, // 유출량 (kL)
"spill_type": "연속" // 유출 형태
}
```
#### 응답
```json
{
"success": true,
"model": "OpenDrift",
"parameters": {
"lat": 34.7312,
"lon": 127.6845,
"duration_hours": 48,
"oil_type": "벙커C유",
"spill_amount": 100,
"spill_type": "연속"
},
"trajectory": [
{
"lat": 34.7312,
"lon": 127.6845,
"time": 0,
"particle": 0
},
// ... more points
],
"metadata": {
"particle_count": 20,
"time_steps": 49,
"generated_at": "2026-02-16T16:45:00.000Z"
}
}
```
#### 오류 응답
```json
{
"error": "Missing required parameters",
"required": ["model", "lat", "lon", "duration_hours"]
}
```
---
### GET /api/simulation/status/:jobId
시뮬레이션 작업 상태를 확인합니다 (향후 비동기 작업용).
#### 응답
```json
{
"jobId": "abc123",
"status": "completed", // "pending" | "running" | "completed" | "failed"
"progress": 100, // 0-100
"message": "Simulation completed"
}
```
---
## OpenDrift 통합 계획
### 1단계: 환경 설정
```bash
# Python 가상 환경 생성
python3 -m venv venv
source venv/bin/activate # Linux/Mac
# venv\Scripts\activate # Windows
# OpenDrift 설치
pip install opendrift
# 필요한 기상/해양 데이터 라이브러리
pip install netCDF4 xarray
```
### 2단계: 데이터 소스 연동
OpenDrift는 다음 데이터 소스가 필요합니다:
- **바람 데이터**: WindSpeed, WindDirection
- 소스: ERA5, ECMWF, NCEP 등
- 형식: NetCDF (CF-compliant)
- **해류 데이터**: CurrentSpeed, CurrentDirection
- 소스: CMEMS, HYCOM, RTOFS 등
- 형식: NetCDF
- **파고 데이터**: WaveHeight (선택)
- 소스: WaveWatch III
### 3단계: TypeScript에서 Python 호출
```typescript
// backend/src/routes/simulation.ts
import { spawn } from 'child_process'
function runOpenDriftPython(params: SimulationRequest): Promise<ParticlePoint[]> {
return new Promise((resolve, reject) => {
const python = spawn('python3', [
'./src/utils/opendrift_runner.py',
JSON.stringify(params)
])
let stdout = ''
let stderr = ''
python.stdout.on('data', (data) => {
stdout += data.toString()
})
python.stderr.on('data', (data) => {
stderr += data.toString()
console.error('OpenDrift stderr:', data.toString())
})
python.on('close', (code) => {
if (code === 0) {
const result = JSON.parse(stdout)
resolve(result.trajectory)
} else {
reject(new Error(`OpenDrift failed with code ${code}: ${stderr}`))
}
})
})
}
```
### 4단계: 비동기 작업 큐 (선택)
장시간 시뮬레이션의 경우 작업 큐 사용:
```bash
npm install bull redis
```
```typescript
import Queue from 'bull'
const simulationQueue = new Queue('simulation', {
redis: { host: 'localhost', port: 6379 }
})
simulationQueue.process(async (job) => {
const result = await runOpenDriftPython(job.data)
return result
})
```
---
## 레이어 API
### GET /api/layers/tree/all
전체 레이어 트리 구조를 반환합니다.
#### 응답
```json
[
{
"id": "해도",
"name": "해도",
"type": "group",
"children": [
{
"id": "기본해도",
"name": "기본해도",
"type": "layer",
"wmsLayer": "khoa:base_chart"
}
]
}
]
```

2072
backend/package-lock.json generated Executable file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

27
backend/package.json Executable file
파일 보기

@ -0,0 +1,27 @@
{
"name": "backend",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc",
"start": "node dist/server.js",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"better-sqlite3": "^11.9.1",
"cors": "^2.8.5",
"express": "^4.21.2",
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/helmet": "^0.0.48",
"@types/node": "^22.13.5",
"tsx": "^4.19.2",
"typescript": "^5.7.3"
}
}

14
backend/prisma.config.ts Executable file
파일 보기

@ -0,0 +1,14 @@
// This file was generated by Prisma, and assumes you have installed the following:
// npm install --save-dev prisma dotenv
import "dotenv/config";
import { defineConfig } from "prisma/config";
export default defineConfig({
schema: "prisma/schema.prisma",
migrations: {
path: "prisma/migrations",
},
datasource: {
url: process.env["DATABASE_URL"],
},
});

14
backend/prisma/schema.prisma Executable file
파일 보기

@ -0,0 +1,14 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Looking for ways to speed up your queries, or scale easily with your serverless or edge functions?
// Try Prisma Accelerate: https://pris.ly/cli/accelerate-init
generator client {
provider = "prisma-client"
output = "../generated/prisma"
}
datasource db {
provider = "postgresql"
}

30
backend/src/db/database.ts Executable file
파일 보기

@ -0,0 +1,30 @@
import Database from 'better-sqlite3'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const dbPath = join(__dirname, '../../data/layers.db')
export const db = new Database(dbPath)
// 데이터베이스 초기화
export function initDatabase() {
db.exec(`
CREATE TABLE IF NOT EXISTS layers (
cmn_cd TEXT PRIMARY KEY,
up_cmn_cd TEXT,
cmn_cd_full_nm TEXT NOT NULL,
cmn_cd_nm TEXT NOT NULL,
cmn_cd_level INTEGER NOT NULL,
clnm TEXT,
FOREIGN KEY (up_cmn_cd) REFERENCES layers(cmn_cd)
);
CREATE INDEX IF NOT EXISTS idx_up_cmn_cd ON layers(up_cmn_cd);
CREATE INDEX IF NOT EXISTS idx_cmn_cd_level ON layers(cmn_cd_level);
`)
}
export default db

91
backend/src/db/seed.ts Executable file
파일 보기

@ -0,0 +1,91 @@
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
import db, { initDatabase } from './database.js'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
async function seedDatabase() {
console.log('데이터베이스 초기화 중...')
initDatabase()
// 기존 데이터 삭제
db.exec('DELETE FROM layers')
// CSV 파일 읽기
const csvPath = path.join(__dirname, '../../../LayerList.csv')
const csvContent = fs.readFileSync(csvPath, 'utf-8')
// CSV 파싱
const lines = csvContent.split('\n')
const headers = lines[0].split(',').map(h => h.replace(/"/g, '').trim())
const insert = db.prepare(`
INSERT INTO layers (cmn_cd, up_cmn_cd, cmn_cd_full_nm, cmn_cd_nm, cmn_cd_level, clnm)
VALUES (?, ?, ?, ?, ?, ?)
`)
const insertMany = db.transaction((rows: any[]) => {
for (const row of rows) {
insert.run(row)
}
})
const rows = []
for (let i = 1; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
// CSV 파싱 (쉼표로 구분, 따옴표 처리)
const values = []
let current = ''
let inQuotes = false
for (let j = 0; j < line.length; j++) {
const char = line[j]
if (char === '"') {
inQuotes = !inQuotes
} else if (char === ',' && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
// NULL 값 처리
const row = values.map(v => {
if (v === 'NULL' || v === '') return null
return v.replace(/"/g, '')
})
if (row.length >= 6) {
rows.push([
row[0], // cmn_cd
row[1], // up_cmn_cd
row[2], // cmn_cd_full_nm
row[3], // cmn_cd_nm
parseInt(row[4] || '0'), // cmn_cd_level
row[5], // clnm
])
}
}
console.log(`${rows.length}개의 레이어 데이터 삽입 중...`)
insertMany(rows)
console.log('시드 완료!')
// 결과 확인
const count = db.prepare('SELECT COUNT(*) as count FROM layers').get() as { count: number }
console.log(`${count.count}개의 레이어가 저장되었습니다.`)
db.close()
}
seedDatabase().catch(console.error)

파일 보기

@ -0,0 +1,166 @@
import { Request, Response, NextFunction } from 'express'
/**
* (sanitize)
* SQL XSS /
*/
// HTML 특수문자 이스케이프 (XSS 방지)
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#x27;')
}
// SQL 인젝션에 사용되는 위험 패턴 탐지
const SQL_INJECTION_PATTERNS = [
/(\b(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|EXEC|EXECUTE|UNION|TRUNCATE|GRANT|REVOKE)\b)/i,
/(--|#|\/\*|\*\/)/, // SQL 주석
/(\b(OR|AND)\b\s+\d+\s*=\s*\d+)/i, // OR 1=1, AND 1=1
/(;[\s]*$)/, // SQL 종료자
/(\bxp_\w+)/i, // SQL Server xp_ 프로시저
/(\bsp_\w+)/i, // SQL Server sp_ 프로시저
]
// XSS 공격에 사용되는 위험 패턴 탐지
const XSS_PATTERNS = [
/<script\b[^>]*>/i,
/javascript\s*:/i,
/on\w+\s*=/i, // onclick=, onerror= 등
/eval\s*\(/i,
/expression\s*\(/i,
/vbscript\s*:/i,
/data\s*:\s*text\/html/i,
]
// 문자열에서 위험 패턴 검사
export function containsSqlInjection(input: string): boolean {
return SQL_INJECTION_PATTERNS.some(pattern => pattern.test(input))
}
export function containsXss(input: string): boolean {
return XSS_PATTERNS.some(pattern => pattern.test(input))
}
// 문자열 살균: 위험 HTML 태그와 특수문자 제거
export function sanitizeString(input: string): string {
return input
.replace(/<[^>]*>/g, '') // HTML 태그 제거
.replace(/[<>"'`;]/g, '') // 위험 특수문자 제거
.trim()
}
// 숫자 검증
export function isValidNumber(value: unknown, min?: number, max?: number): value is number {
if (typeof value !== 'number' || isNaN(value) || !isFinite(value)) return false
if (min !== undefined && value < min) return false
if (max !== undefined && value > max) return false
return true
}
// 위도 검증 (-90 ~ 90)
export function isValidLatitude(lat: unknown): lat is number {
return isValidNumber(lat, -90, 90)
}
// 경도 검증 (-180 ~ 180)
export function isValidLongitude(lon: unknown): lon is number {
return isValidNumber(lon, -180, 180)
}
// 허용된 값 목록 검증 (화이트리스트)
export function isAllowedValue<T>(value: T, allowedValues: T[]): boolean {
return allowedValues.includes(value)
}
// 문자열 길이 검증
export function isValidStringLength(str: string, maxLength: number): boolean {
return typeof str === 'string' && str.length <= maxLength
}
/**
* (body)
* XSS/SQL
*/
export function sanitizeBody(req: Request, res: Response, next: NextFunction): void {
if (req.body && typeof req.body === 'object') {
for (const [key, value] of Object.entries(req.body)) {
if (typeof value === 'string') {
// XSS 패턴 검사
if (containsXss(value)) {
res.status(400).json({
error: '유효하지 않은 입력값',
field: key,
message: '허용되지 않는 문자가 포함되어 있습니다.'
})
return
}
// SQL 인젝션 패턴 검사
if (containsSqlInjection(value)) {
res.status(400).json({
error: '유효하지 않은 입력값',
field: key,
message: '허용되지 않는 문자가 포함되어 있습니다.'
})
return
}
}
}
}
next()
}
/**
* (params)
* URL
*/
export function sanitizeParams(req: Request, res: Response, next: NextFunction): void {
for (const [key, value] of Object.entries(req.params)) {
if (typeof value === 'string') {
if (containsXss(value) || containsSqlInjection(value)) {
res.status(400).json({
error: '유효하지 않은 파라미터',
field: key,
message: '허용되지 않는 문자가 포함되어 있습니다.'
})
return
}
}
}
next()
}
/**
* (query)
*/
export function sanitizeQuery(req: Request, res: Response, next: NextFunction): void {
for (const [key, value] of Object.entries(req.query)) {
if (typeof value === 'string') {
if (containsXss(value) || containsSqlInjection(value)) {
res.status(400).json({
error: '유효하지 않은 쿼리 파라미터',
field: key,
message: '허용되지 않는 문자가 포함되어 있습니다.'
})
return
}
}
}
next()
}
/**
* JSON ( 100kb)
*/
export const BODY_SIZE_LIMIT = '100kb'
/**
*
*/
export function removeServerInfo(req: Request, res: Response, next: NextFunction): void {
res.removeHeader('X-Powered-By')
next()
}

146
backend/src/routes/layers.ts Executable file
파일 보기

@ -0,0 +1,146 @@
import express from 'express'
import db from '../db/database.js'
import { enrichLayerWithMetadata } from '../utils/layerIcons.js'
import {
sanitizeParams,
sanitizeString,
isValidNumber,
isValidStringLength,
} from '../middleware/security.js'
const router = express.Router()
interface Layer {
cmn_cd: string
up_cmn_cd: string | null
cmn_cd_full_nm: string
cmn_cd_nm: string
cmn_cd_level: number
clnm: string | null
}
// 모든 라우트에 파라미터 살균 적용
router.use(sanitizeParams)
// 모든 레이어 조회
router.get('/', (_req, res) => {
try {
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
const enrichedLayers = layers.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
} catch {
res.status(500).json({ error: '레이어 조회 실패' })
}
})
// 계층 구조로 변환된 레이어 트리 조회
router.get('/tree/all', (_req, res) => {
try {
const layers = db.prepare('SELECT * FROM layers ORDER BY cmn_cd').all() as Layer[]
const enrichedLayers = layers.map(enrichLayerWithMetadata)
const layerMap = new Map<string, any>()
enrichedLayers.forEach(layer => {
layerMap.set(layer.cmn_cd, { ...layer, children: [] })
})
const rootLayers: any[] = []
enrichedLayers.forEach(layer => {
const layerNode = layerMap.get(layer.cmn_cd)!
if (layer.up_cmn_cd === null) {
rootLayers.push(layerNode)
} else {
const parent = layerMap.get(layer.up_cmn_cd)
if (parent) {
parent.children.push(layerNode)
}
}
})
res.json(rootLayers)
} catch {
res.status(500).json({ error: '레이어 트리 조회 실패' })
}
})
// WMS 레이어만 조회
router.get('/wms/all', (_req, res) => {
try {
const layers = db.prepare('SELECT * FROM layers WHERE clnm IS NOT NULL ORDER BY cmn_cd').all() as Layer[]
const enrichedLayers = layers.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
} catch {
res.status(500).json({ error: 'WMS 레이어 조회 실패' })
}
})
// 특정 레벨의 레이어만 조회
router.get('/level/:level', (req, res) => {
try {
const level = parseInt(req.params.level, 10)
// 입력 검증: 레벨은 1~10 범위의 정수
if (!isValidNumber(level, 1, 10)) {
return res.status(400).json({
error: '유효하지 않은 레벨값',
message: '레벨은 1~10 범위의 정수여야 합니다.'
})
}
// 파라미터화된 쿼리 사용 (SQL 인젝션 방지)
const layers = db.prepare('SELECT * FROM layers WHERE cmn_cd_level = ? ORDER BY cmn_cd').all(level) as Layer[]
const enrichedLayers = layers.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
} catch {
res.status(500).json({ error: '레벨별 레이어 조회 실패' })
}
})
// 특정 부모의 자식 레이어 조회
router.get('/children/:parentId', (req, res) => {
try {
const parentId = req.params.parentId
// 입력 검증: 코드 형식 확인 (영숫자, 언더스코어, 하이픈만 허용)
if (!parentId || !isValidStringLength(parentId, 50) || !/^[a-zA-Z0-9_-]+$/.test(parentId)) {
return res.status(400).json({
error: '유효하지 않은 부모 ID',
message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.'
})
}
const sanitizedId = sanitizeString(parentId)
const layers = db.prepare('SELECT * FROM layers WHERE up_cmn_cd = ? ORDER BY cmn_cd').all(sanitizedId) as Layer[]
const enrichedLayers = layers.map(enrichLayerWithMetadata)
res.json(enrichedLayers)
} catch {
res.status(500).json({ error: '자식 레이어 조회 실패' })
}
})
// 특정 레이어 조회
router.get('/:id', (req, res) => {
try {
const id = req.params.id
// 입력 검증: ID 형식 확인
if (!id || !isValidStringLength(id, 50) || !/^[a-zA-Z0-9_-]+$/.test(id)) {
return res.status(400).json({
error: '유효하지 않은 레이어 ID',
message: 'ID는 영숫자, 언더스코어, 하이픈만 허용됩니다.'
})
}
const sanitizedId = sanitizeString(id)
const layer = db.prepare('SELECT * FROM layers WHERE cmn_cd = ?').get(sanitizedId) as Layer | undefined
if (!layer) {
return res.status(404).json({ error: '레이어를 찾을 수 없습니다' })
}
const enrichedLayer = enrichLayerWithMetadata(layer)
res.json(enrichedLayer)
} catch {
res.status(500).json({ error: '레이어 조회 실패' })
}
})
export default router

227
backend/src/routes/simulation.ts Executable file
파일 보기

@ -0,0 +1,227 @@
import { Router, Request, Response } from 'express'
import {
isValidLatitude,
isValidLongitude,
isValidNumber,
isAllowedValue,
isValidStringLength,
escapeHtml,
} from '../middleware/security.js'
const router = Router()
// 허용된 모델 목록 (화이트리스트)
const ALLOWED_MODELS = ['KOSPS', 'POSEIDON', 'OpenDrift', '앙상블'] as const
type AllowedModel = typeof ALLOWED_MODELS[number]
// 허용된 유종 목록
const ALLOWED_OIL_TYPES = ['원유', '벙커C유', '경유', '휘발유', '등유', '윤활유', '기타'] as const
// 허용된 유출 유형 목록
const ALLOWED_SPILL_TYPES = ['연속유출', '순간유출'] as const
interface ParticlePoint {
lat: number
lon: number
time: number
particle: number
}
/**
* POST /api/simulation/run
*
*
* :
* -
* - ( -90~90, -180~180)
* - (duration, spill_amount)
* -
*/
router.post('/run', async (req: Request, res: Response) => {
try {
const { model, lat, lon, duration_hours, oil_type, spill_amount, spill_type } = req.body
// 1. 필수 파라미터 존재 검증
if (model === undefined || lat === undefined || lon === undefined || duration_hours === undefined) {
return res.status(400).json({
error: '필수 파라미터 누락',
required: ['model', 'lat', 'lon', 'duration_hours']
})
}
// 2. 모델명 화이트리스트 검증
if (!isAllowedValue(model, [...ALLOWED_MODELS])) {
return res.status(400).json({
error: '유효하지 않은 모델',
message: `허용된 모델: ${ALLOWED_MODELS.join(', ')}`,
})
}
// 3. 위도/경도 범위 검증
if (!isValidLatitude(lat)) {
return res.status(400).json({
error: '유효하지 않은 위도',
message: '위도는 -90 ~ 90 범위의 숫자여야 합니다.'
})
}
if (!isValidLongitude(lon)) {
return res.status(400).json({
error: '유효하지 않은 경도',
message: '경도는 -180 ~ 180 범위의 숫자여야 합니다.'
})
}
// 4. 예측 시간 범위 검증 (1~720시간 = 최대 30일)
if (!isValidNumber(duration_hours, 1, 720)) {
return res.status(400).json({
error: '유효하지 않은 예측 시간',
message: '예측 시간은 1~720 범위의 숫자여야 합니다.'
})
}
// 5. 선택적 파라미터 검증
if (oil_type !== undefined) {
if (typeof oil_type !== 'string' || !isValidStringLength(oil_type, 50)) {
return res.status(400).json({ error: '유효하지 않은 유종' })
}
}
if (spill_amount !== undefined) {
if (!isValidNumber(spill_amount, 0, 1000000)) {
return res.status(400).json({
error: '유효하지 않은 유출량',
message: '유출량은 0~1,000,000 범위의 숫자여야 합니다.'
})
}
}
if (spill_type !== undefined) {
if (typeof spill_type !== 'string' || !isValidStringLength(spill_type, 50)) {
return res.status(400).json({ error: '유효하지 않은 유출 유형' })
}
}
// 검증 완료 - 시뮬레이션 실행
const trajectory = generateDemoTrajectory(
lat,
lon,
duration_hours,
model,
20
)
res.json({
success: true,
model: escapeHtml(String(model)),
parameters: {
lat,
lon,
duration_hours,
oil_type: oil_type ? escapeHtml(String(oil_type)) : undefined,
spill_amount,
spill_type: spill_type ? escapeHtml(String(spill_type)) : undefined,
},
trajectory,
metadata: {
particle_count: 20,
time_steps: duration_hours + 1,
generated_at: new Date().toISOString()
}
})
} catch {
// 내부 오류 메시지 노출 방지
res.status(500).json({
error: '시뮬레이션 실행 실패',
message: '서버 내부 오류가 발생했습니다.'
})
}
})
/**
*
*/
function generateDemoTrajectory(
startLat: number,
startLon: number,
hours: number,
model: string,
particleCount: number
): ParticlePoint[] {
const trajectory: ParticlePoint[] = []
const modelFactors: Record<string, number> = {
'KOSPS': 0.004,
'POSEIDON': 0.006,
'OpenDrift': 0.005,
'앙상블': 0.0055
}
const spreadFactor = modelFactors[model] || 0.005
const windSpeed = 5.5
const windDirection = 135
const currentSpeed = 0.55
const currentDirection = 120
const waveHeight = 2.2
const windRadians = (windDirection * Math.PI) / 180
const currentRadians = (currentDirection * Math.PI) / 180
const windWeight = 0.03
const currentWeight = 0.07
const mainDriftLat =
Math.sin(windRadians) * windSpeed * windWeight +
Math.sin(currentRadians) * currentSpeed * currentWeight
const mainDriftLon =
Math.cos(windRadians) * windSpeed * windWeight +
Math.cos(currentRadians) * currentSpeed * currentWeight
const dispersal = waveHeight * 0.001
for (let p = 0; p < particleCount; p++) {
const initialSpread = 0.001
const randomAngle = Math.random() * Math.PI * 2
let particleLat = startLat + Math.sin(randomAngle) * initialSpread * Math.random()
let particleLon = startLon + Math.cos(randomAngle) * initialSpread * Math.random()
for (let h = 0; h <= hours; h++) {
const mainMovementLat = mainDriftLat * h * 0.01
const mainMovementLon = mainDriftLon * h * 0.01
const turbulence = Math.sin(h * 0.3 + p * 0.5) * dispersal * h
const turbulenceAngle = (h * 0.2 + p * 0.7) * Math.PI
trajectory.push({
lat: particleLat + mainMovementLat + Math.sin(turbulenceAngle) * turbulence,
lon: particleLon + mainMovementLon + Math.cos(turbulenceAngle) * turbulence,
time: h,
particle: p
})
}
}
return trajectory
}
/**
* GET /api/simulation/status/:jobId
*
*/
router.get('/status/:jobId', async (req: Request, res: Response) => {
const { jobId } = req.params
// jobId 형식 검증 (영숫자, 하이픈만 허용)
if (!jobId || !/^[a-zA-Z0-9-]+$/.test(jobId) || jobId.length > 50) {
return res.status(400).json({ error: '유효하지 않은 작업 ID' })
}
res.json({
jobId: escapeHtml(jobId),
status: 'completed',
progress: 100,
message: 'Simulation completed'
})
})
export default router

157
backend/src/server.ts Executable file
파일 보기

@ -0,0 +1,157 @@
import express from 'express'
import cors from 'cors'
import helmet from 'helmet'
import rateLimit from 'express-rate-limit'
import { initDatabase } from './db/database.js'
import layersRouter from './routes/layers.js'
import simulationRouter from './routes/simulation.js'
import {
sanitizeBody,
sanitizeQuery,
removeServerInfo,
BODY_SIZE_LIMIT
} from './middleware/security.js'
const app = express()
const PORT = process.env.PORT || 3001
// ============================================================
// 보안 미들웨어
// ============================================================
// 1. Helmet: HTTP 보안 헤더 설정
// - X-Content-Type-Options: nosniff (MIME 스니핑 방지)
// - X-Frame-Options: DENY (클릭재킹 방지)
// - X-XSS-Protection: 1 (브라우저 XSS 필터)
// - Strict-Transport-Security (HTTPS 강제)
// - Content-Security-Policy (컨텐츠 보안 정책)
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'", "http://localhost:*", "https://*.data.go.kr", "https://*.khoa.go.kr"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
frameSrc: ["'none'"],
}
},
crossOriginEmbedderPolicy: false, // API 서버이므로 비활성
}))
// 2. 서버 정보 제거 (공격자에게 기술 스택 노출 방지)
app.use(removeServerInfo)
app.disable('x-powered-by')
// 3. CORS: 허용된 출처만 접근 가능
const allowedOrigins = [
'http://localhost:5173', // Vite dev server
'http://localhost:5174',
'http://localhost:3000',
process.env.FRONTEND_URL, // 운영 환경 프론트엔드 URL
].filter(Boolean) as string[]
app.use(cors({
origin: (origin, callback) => {
// 서버-to-서버 요청 (origin 없음) 또는 허용된 출처
if (!origin || allowedOrigins.includes(origin)) {
callback(null, true)
} else {
callback(new Error('CORS 정책에 의해 차단되었습니다.'))
}
},
methods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
credentials: true,
maxAge: 86400, // preflight 캐시 24시간
}))
// 4. 요청 속도 제한 (Rate Limiting) - DDoS/브루트포스 방지
const generalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15분
max: 200, // IP당 최대 200요청
standardHeaders: true,
legacyHeaders: false,
message: {
error: '요청 횟수 초과',
message: '너무 많은 요청을 보냈습니다. 15분 후 다시 시도하세요.'
}
})
const simulationLimiter = rateLimit({
windowMs: 60 * 1000, // 1분
max: 10, // IP당 최대 10요청 (시뮬레이션은 비용이 큰 작업)
message: {
error: '시뮬레이션 요청 횟수 초과',
message: '시뮬레이션 요청은 1분당 10회로 제한됩니다.'
}
})
app.use(generalLimiter)
// 5. JSON 본문 파서 (크기 제한 적용)
app.use(express.json({ limit: BODY_SIZE_LIMIT }))
app.use(express.urlencoded({ extended: false, limit: BODY_SIZE_LIMIT }))
// 6. 입력값 살균 미들웨어
app.use(sanitizeBody)
app.use(sanitizeQuery)
// ============================================================
// 데이터베이스 초기화
// ============================================================
initDatabase()
// ============================================================
// 라우트
// ============================================================
// 루트 경로
app.get('/', (_req, res) => {
res.json({
name: 'WING Backend API',
version: '1.0.0',
status: 'running',
})
})
// API 라우트
app.use('/api/layers', layersRouter)
app.use('/api/simulation', simulationLimiter, simulationRouter)
// 헬스 체크
app.get('/health', (_req, res) => {
res.json({ status: 'ok' })
})
// ============================================================
// 404 처리
// ============================================================
app.use((_req, res) => {
res.status(404).json({ error: '요청한 리소스를 찾을 수 없습니다.' })
})
// ============================================================
// 전역 에러 핸들러 (내부 오류 메시지 노출 방지)
// ============================================================
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
// 운영 환경에서는 내부 오류 메시지를 노출하지 않음
const isDev = process.env.NODE_ENV !== 'production'
if (isDev) {
console.error('서버 오류:', err.message)
}
res.status(500).json({
error: '서버 내부 오류가 발생했습니다.',
...(isDev && { detail: err.message })
})
})
// ============================================================
// 서버 시작
// ============================================================
app.listen(PORT, () => {
console.log(`서버가 포트 ${PORT}에서 실행 중입니다.`)
})

90
backend/src/utils/layerIcons.ts Executable file
파일 보기

@ -0,0 +1,90 @@
// 레이어 아이콘 및 카운트 매핑
export interface LayerMetadata {
icon?: string
count?: number
}
const layerMetadataMap: Record<string, LayerMetadata> = {
// 최상위 레이어 (Level 1)
'LYR001001': { icon: '🐟', count: 17129 }, // 어장정보 -> 해양생물자원
'LYR001002': { icon: '🌊', count: 3947 }, // 민감자원
'LYR001003': { icon: '📊' }, // 민감도평가
'LYR001004': { icon: '⚓' }, // 해경관할구역
'LYR001005': { icon: '🚓' }, // 해경서소
'LYR001006': { icon: '🚫' }, // 금지구역
'LYR001007': { icon: '🛡' }, // 북방한계선
// Level 2 - 민감자원 하위
'LYR001002001': { icon: '🌿' }, // 환경생태
'LYR001002002': { icon: '💰' }, // 사회경제
// Level 3 - 환경생태 하위
'LYR001002001001': { icon: '🌊' }, // 블루카본
'LYR001002001002': { icon: '🏖' }, // ESI
'LYR001002001003': { icon: '🐾' }, // 생물종
'LYR001002001004': { icon: '🏡' }, // 서식지
'LYR001002001005': { icon: '🛡' }, // 보호지역
// Level 4 - 블루카본 하위
'LYR001002001001001': { icon: '🪨' }, // 갯벌
'LYR001002001001002': { icon: '🌿' }, // 염생식물
'LYR001002001001003': { icon: '📍' }, // 염생식물조사정점
// Level 4 - 생물종 하위
'LYR001002001003001': { icon: '🐋' }, // 포유류
'LYR001002001003002': { icon: '🐦' }, // 조류
'LYR001002001003003': { icon: '🐢' }, // 양서파충류
'LYR001002001003004': { icon: '🦀' }, // 저서생물
// Level 4 - 서식지 하위
'LYR001002001004001': { icon: '🪨' }, // 갯벌
'LYR001002001004002': { icon: '🌿' }, // 해조류서식지
'LYR001002001004003': { icon: '🐟' }, // 바다목장
'LYR001002001004004': { icon: '🌳' }, // 바다숲
'LYR001002001004005': { icon: '🪸' }, // 산호류서식지
'LYR001002001004006': { icon: '🪸' }, // 인공어초
'LYR001002001004007': { icon: '🌿' }, // 해초류서식지
// Level 3 - 사회경제 하위
'LYR001002002001': { icon: '🎣' }, // 수산자원
'LYR001002002002': { icon: '🏖' }, // 관광자원
'LYR001002002003': { icon: '🏭' }, // 산업자원
// Level 4 - 관광자원 하위
'LYR001002002002001': { icon: '🏖' }, // 해수욕장
'LYR001002002002002': { icon: '🎣' }, // 낚시터
'LYR001002002002003': { icon: '⛵' }, // 마리나항
'LYR001002002002004': { icon: '🐟' }, // 수산시장
// Level 5 - 낚시터 하위
'LYR001002002002002001': { icon: '🪨' }, // 갯바위낚시
'LYR001002002002002002': { icon: '🚤' }, // 선상낚시
'LYR001002002002002003': { icon: '🎣' }, // 유어장
// Level 4 - 산업자원 하위
'LYR001002002003001': { icon: '💧' }, // 해수취수시설
'LYR001002002003002': { icon: '⚓' }, // 항만및어항
// Level 5 - 해수취수시설 하위
'LYR001002002003001001': { icon: '⚡' }, // LNG
'LYR001002002003001002': { icon: '🔌' }, // 발전소
'LYR001002002003001003': { icon: '🏭' }, // 임해공단
// Level 5 - 항만및어항 하위
'LYR001002002003002001': { icon: '⚓' }, // 국가어항
'LYR001002002003002002': { icon: '🚢' }, // 무역항
'LYR001002002003002003': { icon: '⛵' }, // 연안항
'LYR001002002003002004': { icon: '🎣' }, // 지방어항
}
export function getLayerMetadata(layerId: string): LayerMetadata {
return layerMetadataMap[layerId] || {}
}
export function enrichLayerWithMetadata(layer: any) {
const metadata = getLayerMetadata(layer.cmn_cd)
return {
...layer,
...metadata,
}
}

파일 보기

@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""
OpenDrift 시뮬레이션 실행 스크립트
TODO: 실제 OpenDrift 라이브러리 연동 구현
"""
import sys
import json
from datetime import datetime, timedelta
def run_opendrift_simulation(params):
"""
OpenDrift 시뮬레이션 실행
Args:
params (dict): 시뮬레이션 파라미터
- lat: 시작 위도
- lon: 시작 경도
- duration_hours: 예측 시간 (시간)
- oil_type: 유종
- spill_amount: 유출량
- spill_type: 유출 형태
Returns:
dict: 시뮬레이션 결과 (입자 궤적 데이터)
"""
# TODO: 실제 OpenDrift 구현
# from opendrift.readers import reader_netCDF_CF_generic
# from opendrift.models.oceandrift import OceanDrift
# 현재는 데모 응답 반환
print(f"OpenDrift 시뮬레이션 시작: {params}", file=sys.stderr)
# 입자 수와 시간 스텝
particle_count = 1000
time_steps = params['duration_hours']
# 기상/해양 데이터 로드 (TODO: 실제 데이터 소스 연동)
# - 바람 데이터 (WindSpeed, WindDirection)
# - 해류 데이터 (CurrentSpeed, CurrentDirection)
# - 파고 데이터 (WaveHeight)
# OpenDrift 모델 설정
# o = OceanDrift(loglevel=20)
#
# # 환경 데이터 추가
# reader_wind = reader_netCDF_CF_generic.Reader(wind_file)
# reader_current = reader_netCDF_CF_generic.Reader(current_file)
# o.add_reader([reader_wind, reader_current])
#
# # 초기 입자 시드
# o.seed_elements(
# lon=params['lon'],
# lat=params['lat'],
# number=particle_count,
# time=datetime.now()
# )
#
# # 시뮬레이션 실행
# o.run(
# duration=timedelta(hours=params['duration_hours']),
# time_step=3600 # 1시간 간격
# )
#
# # 결과 추출
# trajectory = []
# for i in range(particle_count):
# lons, lats, times = o.get_trajectory(i)
# for j, (lon, lat, time) in enumerate(zip(lons, lats, times)):
# trajectory.append({
# 'lat': float(lat),
# 'lon': float(lon),
# 'time': j,
# 'particle': i
# })
# 데모 응답 (실제 구현 시 제거)
trajectory = generate_demo_trajectory(
params['lat'],
params['lon'],
params['duration_hours'],
particle_count
)
return {
'success': True,
'trajectory': trajectory,
'metadata': {
'particle_count': particle_count,
'time_steps': time_steps,
'model': 'OpenDrift',
'generated_at': datetime.now().isoformat()
}
}
def generate_demo_trajectory(start_lat, start_lon, hours, particle_count):
"""데모 궤적 생성 (실제 구현 시 제거)"""
import math
trajectory = []
spread_factor = 0.005
for p in range(particle_count):
angle = (2 * math.pi * p) / particle_count
for h in range(hours + 1):
distance = h * spread_factor
drift = math.sin(h * 0.2) * 0.002
trajectory.append({
'lat': start_lat + distance * math.cos(angle) + drift,
'lon': start_lon + distance * math.sin(angle) + drift,
'time': h,
'particle': p
})
return trajectory
if __name__ == '__main__':
# 표준 입력으로 파라미터 받기
if len(sys.argv) > 1:
params_json = sys.argv[1]
params = json.loads(params_json)
else:
params = json.loads(sys.stdin.read())
# 시뮬레이션 실행
result = run_opendrift_simulation(params)
# 결과 출력 (JSON)
print(json.dumps(result))

17
backend/tsconfig.json Executable file
파일 보기

@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"lib": ["ES2022"],
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

802
database/init.sql Executable file
파일 보기

@ -0,0 +1,802 @@
-- ================================================================
-- WING 해양환경 위기대응 통합시스템 데이터베이스
-- 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 기준 적용
-- PostgreSQL 16 + PostGIS 3.4
-- ================================================================
--
-- [표준화 적용 원칙]
-- 1. 표준단어: 한글명 → 영문약어명 매핑 (별도 사전 참조)
-- 2. 표준도메인: 분류명 + 데이터타입 + 길이 규칙
-- 3. 표준용어: 컬럼 물리명 = 영문약어명 조합 (언더스코어 구분, 30자 이내)
-- 4. 저장형식: 날짜 YYYYMMDD(C8), 일시 TIMESTAMP, 여부 Y/N(C1)
-- 5. 표준코드: 공통코드 테이블로 관리
--
-- ================================================================
-- 표준단어 사전 (WING 도메인)
-- ================================================================
-- | 한글명 | 영문약어명 | 영문전체명 |
-- |-------------|----------|----------------------|
-- | 사고 | ACDNT | Accident |
-- | 활성 | ACTV | Active |
-- | 알고리즘 | ALGO | Algorithm |
-- | 시작 | BGNG | Beginning |
-- | 오일펜스 | BOOM | Boom |
-- | 코드 | CD | Code |
-- | 공통 | CMN | Common |
-- | 완료 | CMPL | Completion |
-- | 내용 | CN | Content |
-- | 해안 | CST | Coast |
-- | 설명 | DC | Description |
-- | 배치 | DPLY | Deployment |
-- | 일시 | DTM | DateTime |
-- | 효율 | EFCNC | Efficiency |
-- | 오류 | ERR | Error |
-- | 실행 | EXEC | Execution |
-- | 예측 | FCST | Forecast |
-- | 지오메트리 | GEOM | Geometry |
-- | 그룹 | GRP | Group |
-- | 시간 | HR | Hour |
-- | 아이디 | ID | Identifier |
-- | 정보 | INFO | Information |
-- | 길이 | LEN | Length |
-- | 라인 | LINE | Line |
-- | 위치 | LOC | Location |
-- | 미터 | M | Meter |
-- | 수정 | MDFCN | Modification |
-- | 메시지 | MSG | Message |
-- | 명 | NM | Name |
-- | 번호 | NO | Number |
-- | 발생 | OCCRN | Occurrence |
-- | 유종 | OILTP | OilType |
-- | 조직 | ORG | Organization |
-- | 비밀번호 | PSWD | Password |
-- | 퍼센트 | PCT | Percent |
-- | 사진 | PHOTO | Photo |
-- | 예측 | PRED | Prediction |
-- | 우선순위 | PRIORT | Priority |
-- | 양 | QTY | Quantity |
-- | 등록 | REG | Registration |
-- | 등록자 | RGTR | Registrant |
-- | 직급 | RNKP | RankPosition |
-- | 역할 | ROLE | Role |
-- | 보고 | RPT | Report |
-- | 책임 | RSPNS | Responsible |
-- | 결과 | RSLT | Result |
-- | SCAT | SCAT | SCAT |
-- | 초 | SEC | Second |
-- | 구간 | SECT | Section |
-- | 순번 | SN | SequenceNumber |
-- | 유출 | SPIL | Spill |
-- | 조사 | SRVY | Survey |
-- | 상태 | STTS | Status |
-- | 심각도 | SVRT | Severity |
-- | 촬영 | TAKNG | Taking |
-- | 유형 | TP | Type |
-- | 단위 | UNIT | Unit |
-- | 상위 | UPPER | Upper |
-- | 사용자 | USER | User |
-- | 계정 | ACNT | Account |
-- | 여부 | YN | YesNo |
-- | 일자 | YMD | YearMonthDay |
-- | 구역 | ZONE | Zone |
-- | 민감도 | SNSTVT | Sensitivity |
-- | 오염 | POLUT | Pollution |
-- | 정렬 | SORT | Sort |
-- | 순서 | ORD | Order |
-- | 사용 | USE | Use |
-- | 해시 | HASH | Hash |
-- | 약칭 | ABBR | Abbreviation |
-- | 데이터 | DATA | Data |
-- | 소요 | REQD | Required |
-- | 경로 | PATH | Path |
-- | 파일 | FILE | File |
-- | 비고 | RMK | Remark |
-- | 관할 | JRSD | Jurisdiction |
-- | 기관 | INST | Institution |
-- | 위도 | LAT | Latitude |
-- | 경도 | LON | Longitude |
-- ================================================================
-- 표준도메인 정의
-- ================================================================
-- | 도메인명 | 데이터타입 | 설명 |
-- |-----------------|-------------------|------------------------|
-- | 순번N10 | SERIAL/INTEGER | 순차번호 10자리 |
-- | 아이디V36 | UUID | UUID 식별자 36자리 |
-- | 코드V10 | VARCHAR(10) | 코드 10자리 이내 |
-- | 코드V20 | VARCHAR(20) | 코드 20자리 이내 |
-- | 코드V50 | VARCHAR(50) | 코드 50자리 이내 |
-- | 명칭V20 | VARCHAR(20) | 명칭 20자리 이내 |
-- | 명칭V30 | VARCHAR(30) | 명칭 30자리 이내 |
-- | 명칭V50 | VARCHAR(50) | 명칭 50자리 이내 |
-- | 명칭V100 | VARCHAR(100) | 명칭 100자리 이내 |
-- | 명칭V200 | VARCHAR(200) | 명칭 200자리 이내 |
-- | 내용V255 | VARCHAR(255) | 내용 255자리 이내 |
-- | 내용V500 | VARCHAR(500) | 내용 500자리 이내 |
-- | 내용TEXT | TEXT | 대용량 텍스트 |
-- | 수량N5,2 | NUMERIC(5,2) | 수량 소수점2자리 |
-- | 수량N10 | INTEGER | 정수 수량 |
-- | 수량N10,2 | NUMERIC(10,2) | 수량 소수점2자리 |
-- | 수량N12,2 | NUMERIC(12,2) | 수량 소수점2자리 |
-- | 여부C1 | CHAR(1) | Y/N 여부 |
-- | 연월일C8 | CHAR(8) | YYYYMMDD |
-- | 연월일시분초D | TIMESTAMPTZ | 타임스탬프(시간대포함) |
-- | 경도N13,10 | NUMERIC(13,10) | 경도 소수점10자리 |
-- | 위도N12,10 | NUMERIC(12,10) | 위도 소수점10자리 |
-- | 지오메트리PNT | GEOMETRY(Point) | 포인트 공간정보 |
-- | 지오메트리LINE | GEOMETRY(LineStr) | 라인 공간정보 |
-- | JSON | JSONB | JSON 바이너리 |
-- | 정렬순서N5 | INTEGER | 정렬순서 5자리 |
-- ================================================================
-- ============================================================
-- 확장 설치
-- ============================================================
CREATE EXTENSION IF NOT EXISTS postgis;
CREATE EXTENSION IF NOT EXISTS postgis_topology;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ============================================================
-- 1. 공통코드 관리
-- ============================================================
-- 공통코드그룹 (CMN_CD_GRP)
-- 시스템 전체에서 사용되는 코드를 그룹 단위로 관리
CREATE TABLE CMN_CD_GRP (
CMN_CD_GRP_ID VARCHAR(20) NOT NULL, -- 공통코드그룹아이디
CMN_CD_GRP_NM VARCHAR(100) NOT NULL, -- 공통코드그룹명
GRP_DC VARCHAR(500), -- 그룹설명
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_CMN_CD_GRP PRIMARY KEY (CMN_CD_GRP_ID),
CONSTRAINT CK_CMN_CD_GRP_USE_YN CHECK (USE_YN IN ('Y','N'))
);
COMMENT ON TABLE CMN_CD_GRP IS '공통코드그룹';
COMMENT ON COLUMN CMN_CD_GRP.CMN_CD_GRP_ID IS '공통코드그룹아이디';
COMMENT ON COLUMN CMN_CD_GRP.CMN_CD_GRP_NM IS '공통코드그룹명';
COMMENT ON COLUMN CMN_CD_GRP.GRP_DC IS '그룹설명';
COMMENT ON COLUMN CMN_CD_GRP.USE_YN IS '사용여부 (Y:사용, N:미사용)';
COMMENT ON COLUMN CMN_CD_GRP.REG_DTM IS '등록일시';
-- 공통코드 (CMN_CD)
-- 각 그룹 내 개별 코드값을 관리
CREATE TABLE CMN_CD (
CMN_CD_GRP_ID VARCHAR(20) NOT NULL, -- 공통코드그룹아이디
CMN_CD VARCHAR(20) NOT NULL, -- 공통코드
CMN_CD_NM VARCHAR(100) NOT NULL, -- 공통코드명
CD_DC VARCHAR(500), -- 코드설명
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
USE_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 사용여부
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_CMN_CD PRIMARY KEY (CMN_CD_GRP_ID, CMN_CD),
CONSTRAINT FK_CMN_CD_GRP FOREIGN KEY (CMN_CD_GRP_ID) REFERENCES CMN_CD_GRP(CMN_CD_GRP_ID),
CONSTRAINT CK_CMN_CD_USE_YN CHECK (USE_YN IN ('Y','N'))
);
COMMENT ON TABLE CMN_CD IS '공통코드';
COMMENT ON COLUMN CMN_CD.CMN_CD_GRP_ID IS '공통코드그룹아이디';
COMMENT ON COLUMN CMN_CD.CMN_CD IS '공통코드';
COMMENT ON COLUMN CMN_CD.CMN_CD_NM IS '공통코드명';
COMMENT ON COLUMN CMN_CD.CD_DC IS '코드설명';
COMMENT ON COLUMN CMN_CD.SORT_ORD IS '정렬순서';
COMMENT ON COLUMN CMN_CD.USE_YN IS '사용여부 (Y:사용, N:미사용)';
COMMENT ON COLUMN CMN_CD.REG_DTM IS '등록일시';
-- ============================================================
-- 2. 조직 (ORG)
-- ============================================================
CREATE TABLE ORG (
ORG_SN SERIAL NOT NULL, -- 조직순번
ORG_NM VARCHAR(100) NOT NULL, -- 조직명
ORG_ABBR_NM VARCHAR(20) NOT NULL, -- 조직약칭명
ORG_TP_CD VARCHAR(20) NOT NULL, -- 조직유형코드
UPPER_ORG_SN INTEGER, -- 상위조직순번
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_ORG PRIMARY KEY (ORG_SN),
CONSTRAINT FK_ORG_UPPER FOREIGN KEY (UPPER_ORG_SN) REFERENCES ORG(ORG_SN)
);
COMMENT ON TABLE ORG IS '조직';
COMMENT ON COLUMN ORG.ORG_SN IS '조직순번';
COMMENT ON COLUMN ORG.ORG_NM IS '조직명 (기관 전체 명칭)';
COMMENT ON COLUMN ORG.ORG_ABBR_NM IS '조직약칭명 (기관 약칭)';
COMMENT ON COLUMN ORG.ORG_TP_CD IS '조직유형코드 (ORG_TP: headquarters, regional, station, agency)';
COMMENT ON COLUMN ORG.UPPER_ORG_SN IS '상위조직순번 (상위 기관 참조)';
COMMENT ON COLUMN ORG.REG_DTM IS '등록일시';
-- ============================================================
-- 3. 사용자 (USER_INFO)
-- ※ USER는 PostgreSQL 예약어이므로 USER_INFO 사용
-- ============================================================
CREATE TABLE USER_INFO (
USER_ID UUID NOT NULL DEFAULT uuid_generate_v4(), -- 사용자아이디
USER_ACNT VARCHAR(50) NOT NULL, -- 사용자계정
PSWD_HASH VARCHAR(255) NOT NULL, -- 비밀번호해시
USER_NM VARCHAR(50) NOT NULL, -- 사용자명
RNKP_NM VARCHAR(30), -- 직급명
ORG_SN INTEGER, -- 조직순번
ROLE_CD VARCHAR(20) NOT NULL DEFAULT 'USER', -- 역할코드
ACTV_YN CHAR(1) NOT NULL DEFAULT 'Y', -- 활성여부
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_USER_INFO PRIMARY KEY (USER_ID),
CONSTRAINT UK_USER_ACNT UNIQUE (USER_ACNT),
CONSTRAINT FK_USER_ORG FOREIGN KEY (ORG_SN) REFERENCES ORG(ORG_SN),
CONSTRAINT CK_USER_ROLE_CD CHECK (ROLE_CD IN ('ADMIN','MANAGER','USER')),
CONSTRAINT CK_USER_ACTV_YN CHECK (ACTV_YN IN ('Y','N'))
);
COMMENT ON TABLE USER_INFO IS '사용자정보';
COMMENT ON COLUMN USER_INFO.USER_ID IS '사용자아이디 (UUID)';
COMMENT ON COLUMN USER_INFO.USER_ACNT IS '사용자계정 (로그인 ID)';
COMMENT ON COLUMN USER_INFO.PSWD_HASH IS '비밀번호해시 (bcrypt 해시값)';
COMMENT ON COLUMN USER_INFO.USER_NM IS '사용자명';
COMMENT ON COLUMN USER_INFO.RNKP_NM IS '직급명';
COMMENT ON COLUMN USER_INFO.ORG_SN IS '조직순번 (소속 조직)';
COMMENT ON COLUMN USER_INFO.ROLE_CD IS '역할코드 (ADMIN, MANAGER, USER)';
COMMENT ON COLUMN USER_INFO.ACTV_YN IS '활성여부 (Y:활성, N:비활성)';
COMMENT ON COLUMN USER_INFO.REG_DTM IS '등록일시';
-- ============================================================
-- 4. 사고 (ACDNT)
-- ============================================================
CREATE TABLE ACDNT (
ACDNT_SN SERIAL NOT NULL, -- 사고순번
ACDNT_CD VARCHAR(20) NOT NULL, -- 사고코드
ACDNT_NM VARCHAR(200) NOT NULL, -- 사고명
ACDNT_TP_CD VARCHAR(50) NOT NULL, -- 사고유형코드
ACDNT_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- 사고상태코드
LOC_GEOM GEOMETRY(Point, 4326) NOT NULL, -- 위치지오메트리
LOC_DC VARCHAR(200), -- 위치설명
OCCRN_DTM TIMESTAMPTZ NOT NULL, -- 발생일시
RPT_DTM TIMESTAMPTZ, -- 보고일시
RSPNS_ORG_SN INTEGER, -- 책임조직순번
SVRT_CD VARCHAR(10), -- 심각도코드
RGTR_ID UUID, -- 등록자아이디
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
CONSTRAINT PK_ACDNT PRIMARY KEY (ACDNT_SN),
CONSTRAINT UK_ACDNT_CD UNIQUE (ACDNT_CD),
CONSTRAINT FK_ACDNT_ORG FOREIGN KEY (RSPNS_ORG_SN) REFERENCES ORG(ORG_SN),
CONSTRAINT FK_ACDNT_RGTR FOREIGN KEY (RGTR_ID) REFERENCES USER_INFO(USER_ID),
CONSTRAINT CK_ACDNT_STTS CHECK (ACDNT_STTS_CD IN ('ACTIVE','MONITORING','CLOSED')),
CONSTRAINT CK_ACDNT_SVRT CHECK (SVRT_CD IN ('DANGER','ALERT','CAUTION','INTEREST'))
);
COMMENT ON TABLE ACDNT IS '사고';
COMMENT ON COLUMN ACDNT.ACDNT_SN IS '사고순번';
COMMENT ON COLUMN ACDNT.ACDNT_CD IS '사고코드 (예: INC-2025-0042)';
COMMENT ON COLUMN ACDNT.ACDNT_NM IS '사고명';
COMMENT ON COLUMN ACDNT.ACDNT_TP_CD IS '사고유형코드 (ACDNT_TP: 유조선충돌, 좌초 등)';
COMMENT ON COLUMN ACDNT.ACDNT_STTS_CD IS '사고상태코드 (ACTIVE:진행, MONITORING:감시, CLOSED:종료)';
COMMENT ON COLUMN ACDNT.LOC_GEOM IS '위치지오메트리 (EPSG:4326 Point)';
COMMENT ON COLUMN ACDNT.LOC_DC IS '위치설명 (텍스트 위치 표기)';
COMMENT ON COLUMN ACDNT.OCCRN_DTM IS '발생일시';
COMMENT ON COLUMN ACDNT.RPT_DTM IS '보고일시';
COMMENT ON COLUMN ACDNT.RSPNS_ORG_SN IS '책임조직순번 (담당 기관)';
COMMENT ON COLUMN ACDNT.SVRT_CD IS '심각도코드 (DANGER:위험, ALERT:경계, CAUTION:주의, INTEREST:관심)';
COMMENT ON COLUMN ACDNT.RGTR_ID IS '등록자아이디';
COMMENT ON COLUMN ACDNT.REG_DTM IS '등록일시';
COMMENT ON COLUMN ACDNT.MDFCN_DTM IS '수정일시';
-- ============================================================
-- 5. 유출정보 (SPIL_DATA)
-- ============================================================
CREATE TABLE SPIL_DATA (
SPIL_DATA_SN SERIAL NOT NULL, -- 유출정보순번
ACDNT_SN INTEGER NOT NULL, -- 사고순번
OIL_TP_CD VARCHAR(50) NOT NULL, -- 유종코드
SPIL_QTY NUMERIC(12,2), -- 유출량
SPIL_UNIT_CD VARCHAR(10) DEFAULT 'KL', -- 유출단위코드
SPIL_TP_CD VARCHAR(20), -- 유출유형코드
SPIL_LOC_GEOM GEOMETRY(Point, 4326), -- 유출위치지오메트리
FCST_HR INTEGER, -- 예측시간
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_SPIL_DATA PRIMARY KEY (SPIL_DATA_SN),
CONSTRAINT FK_SPIL_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE
);
COMMENT ON TABLE SPIL_DATA IS '유출정보';
COMMENT ON COLUMN SPIL_DATA.SPIL_DATA_SN IS '유출정보순번';
COMMENT ON COLUMN SPIL_DATA.ACDNT_SN IS '사고순번 (사고 참조)';
COMMENT ON COLUMN SPIL_DATA.OIL_TP_CD IS '유종코드 (OIL_TP: 벙커C유, 경유, 원유 등)';
COMMENT ON COLUMN SPIL_DATA.SPIL_QTY IS '유출량';
COMMENT ON COLUMN SPIL_DATA.SPIL_UNIT_CD IS '유출단위코드 (KL:킬로리터, L:리터, BBL:배럴)';
COMMENT ON COLUMN SPIL_DATA.SPIL_TP_CD IS '유출유형코드 (SPIL_TP: 연속유출, 순간유출 등)';
COMMENT ON COLUMN SPIL_DATA.SPIL_LOC_GEOM IS '유출위치지오메트리 (EPSG:4326 Point)';
COMMENT ON COLUMN SPIL_DATA.FCST_HR IS '예측시간 (시간 단위)';
COMMENT ON COLUMN SPIL_DATA.REG_DTM IS '등록일시';
-- ============================================================
-- 6. 예측실행 (PRED_EXEC)
-- ============================================================
CREATE TABLE PRED_EXEC (
PRED_EXEC_SN SERIAL NOT NULL, -- 예측실행순번
SPIL_DATA_SN INTEGER NOT NULL, -- 유출정보순번
ALGO_CD VARCHAR(20) NOT NULL, -- 알고리즘코드
EXEC_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- 실행상태코드
BGNG_DTM TIMESTAMPTZ, -- 시작일시
CMPL_DTM TIMESTAMPTZ, -- 완료일시
REQD_SEC INTEGER, -- 소요시간초
RSLT_DATA JSONB, -- 결과데이터
ERR_MSG TEXT, -- 오류메시지
CONSTRAINT PK_PRED_EXEC PRIMARY KEY (PRED_EXEC_SN),
CONSTRAINT FK_PRED_SPIL FOREIGN KEY (SPIL_DATA_SN) REFERENCES SPIL_DATA(SPIL_DATA_SN) ON DELETE CASCADE,
CONSTRAINT CK_PRED_STTS CHECK (EXEC_STTS_CD IN ('PENDING','RUNNING','COMPLETED','FAILED'))
);
COMMENT ON TABLE PRED_EXEC IS '예측실행';
COMMENT ON COLUMN PRED_EXEC.PRED_EXEC_SN IS '예측실행순번';
COMMENT ON COLUMN PRED_EXEC.SPIL_DATA_SN IS '유출정보순번 (유출정보 참조)';
COMMENT ON COLUMN PRED_EXEC.ALGO_CD IS '알고리즘코드 (ALGO: GNOME, OSCAR 등)';
COMMENT ON COLUMN PRED_EXEC.EXEC_STTS_CD IS '실행상태코드 (PENDING:대기, RUNNING:실행중, COMPLETED:완료, FAILED:실패)';
COMMENT ON COLUMN PRED_EXEC.BGNG_DTM IS '시작일시';
COMMENT ON COLUMN PRED_EXEC.CMPL_DTM IS '완료일시';
COMMENT ON COLUMN PRED_EXEC.REQD_SEC IS '소요시간초 (실행 소요 시간, 초 단위)';
COMMENT ON COLUMN PRED_EXEC.RSLT_DATA IS '결과데이터 (JSON 형식 예측 결과)';
COMMENT ON COLUMN PRED_EXEC.ERR_MSG IS '오류메시지';
-- ============================================================
-- 7. 오일펜스배치 (BOOM_LINE)
-- ============================================================
CREATE TABLE BOOM_LINE (
BOOM_SN SERIAL NOT NULL, -- 오일펜스순번
ACDNT_SN INTEGER NOT NULL, -- 사고순번
BOOM_NM VARCHAR(100) NOT NULL, -- 오일펜스명
PRIORT_CD VARCHAR(20), -- 우선순위코드
LEN_M NUMERIC(10,2), -- 길이미터
LINE_GEOM GEOMETRY(LineString, 4326), -- 라인지오메트리
DPLY_STTS_CD VARCHAR(20) NOT NULL DEFAULT 'PLANNED', -- 배치상태코드
EFCNC_PCT NUMERIC(5,2), -- 효율퍼센트
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
CONSTRAINT PK_BOOM_LINE PRIMARY KEY (BOOM_SN),
CONSTRAINT FK_BOOM_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN) ON DELETE CASCADE,
CONSTRAINT CK_BOOM_PRIORT CHECK (PRIORT_CD IN ('CRITICAL','HIGH','MEDIUM','LOW')),
CONSTRAINT CK_BOOM_STTS CHECK (DPLY_STTS_CD IN ('PLANNED','DEPLOYING','DEPLOYED','REMOVED'))
);
COMMENT ON TABLE BOOM_LINE IS '오일펜스배치';
COMMENT ON COLUMN BOOM_LINE.BOOM_SN IS '오일펜스순번';
COMMENT ON COLUMN BOOM_LINE.ACDNT_SN IS '사고순번 (사고 참조)';
COMMENT ON COLUMN BOOM_LINE.BOOM_NM IS '오일펜스명';
COMMENT ON COLUMN BOOM_LINE.PRIORT_CD IS '우선순위코드 (CRITICAL:긴급, HIGH:높음, MEDIUM:보통, LOW:낮음)';
COMMENT ON COLUMN BOOM_LINE.LEN_M IS '길이미터 (오일펜스 총 길이, 미터)';
COMMENT ON COLUMN BOOM_LINE.LINE_GEOM IS '라인지오메트리 (EPSG:4326 LineString)';
COMMENT ON COLUMN BOOM_LINE.DPLY_STTS_CD IS '배치상태코드 (PLANNED:계획, DEPLOYING:배치중, DEPLOYED:배치완료, REMOVED:회수)';
COMMENT ON COLUMN BOOM_LINE.EFCNC_PCT IS '효율퍼센트 (방제 효율, %)';
COMMENT ON COLUMN BOOM_LINE.REG_DTM IS '등록일시';
COMMENT ON COLUMN BOOM_LINE.MDFCN_DTM IS '수정일시';
-- ============================================================
-- 8. 해안조사구역 (CST_SRVY_ZONE)
-- ============================================================
CREATE TABLE CST_SRVY_ZONE (
CST_SRVY_ZONE_SN SERIAL NOT NULL, -- 해안조사구역순번
ZONE_CD VARCHAR(20) NOT NULL, -- 구역코드
ZONE_NM VARCHAR(100) NOT NULL, -- 구역명
JRSD_ORG_SN INTEGER, -- 관할조직순번
ZONE_DC VARCHAR(500), -- 구역설명
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_CST_SRVY_ZONE PRIMARY KEY (CST_SRVY_ZONE_SN),
CONSTRAINT UK_ZONE_CD UNIQUE (ZONE_CD),
CONSTRAINT FK_ZONE_ORG FOREIGN KEY (JRSD_ORG_SN) REFERENCES ORG(ORG_SN)
);
COMMENT ON TABLE CST_SRVY_ZONE IS '해안조사구역';
COMMENT ON COLUMN CST_SRVY_ZONE.CST_SRVY_ZONE_SN IS '해안조사구역순번';
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_CD IS '구역코드 (예: JJAW, JJAN 등)';
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_NM IS '구역명 (예: 제주시 서부, 서귀포시 동부 등)';
COMMENT ON COLUMN CST_SRVY_ZONE.JRSD_ORG_SN IS '관할조직순번 (관할 해경 기관)';
COMMENT ON COLUMN CST_SRVY_ZONE.ZONE_DC IS '구역설명';
COMMENT ON COLUMN CST_SRVY_ZONE.REG_DTM IS '등록일시';
-- ============================================================
-- 9. 해안구간 (CST_SECT)
-- ============================================================
CREATE TABLE CST_SECT (
CST_SECT_SN SERIAL NOT NULL, -- 해안구간순번
CST_SRVY_ZONE_SN INTEGER NOT NULL, -- 해안조사구역순번
SECT_CD VARCHAR(20) NOT NULL, -- 구간코드
SECT_NM VARCHAR(100), -- 구간명
CST_TP_CD VARCHAR(20), -- 해안유형코드
LEN_M NUMERIC(10,2), -- 길이미터
SNSTVT_CD VARCHAR(10), -- 민감도코드
SRVY_STTS_CD VARCHAR(20) DEFAULT 'PENDING', -- 조사상태코드
BGNG_LAT NUMERIC(12,10), -- 시작위도
BGNG_LON NUMERIC(13,10), -- 시작경도
END_LAT NUMERIC(12,10), -- 종료위도
END_LON NUMERIC(13,10), -- 종료경도
BGNG_LOC_GEOM GEOMETRY(Point, 4326), -- 시작위치지오메트리
END_LOC_GEOM GEOMETRY(Point, 4326), -- 종료위치지오메트리
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
MDFCN_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 수정일시
CONSTRAINT PK_CST_SECT PRIMARY KEY (CST_SECT_SN),
CONSTRAINT UK_SECT_CD UNIQUE (SECT_CD),
CONSTRAINT FK_SECT_ZONE FOREIGN KEY (CST_SRVY_ZONE_SN) REFERENCES CST_SRVY_ZONE(CST_SRVY_ZONE_SN)
);
COMMENT ON TABLE CST_SECT IS '해안구간';
COMMENT ON COLUMN CST_SECT.CST_SECT_SN IS '해안구간순번';
COMMENT ON COLUMN CST_SECT.CST_SRVY_ZONE_SN IS '해안조사구역순번 (구역 참조)';
COMMENT ON COLUMN CST_SECT.SECT_CD IS '구간코드 (예: JJAW-1, JJAW-2 등)';
COMMENT ON COLUMN CST_SECT.SECT_NM IS '구간명';
COMMENT ON COLUMN CST_SECT.CST_TP_CD IS '해안유형코드 (CST_TP: 암반, 자갈, 모래, 혼합 등)';
COMMENT ON COLUMN CST_SECT.LEN_M IS '길이미터 (해안구간 길이, 미터)';
COMMENT ON COLUMN CST_SECT.SNSTVT_CD IS '민감도코드 (SNSTVT: HIGH, MEDIUM, LOW)';
COMMENT ON COLUMN CST_SECT.SRVY_STTS_CD IS '조사상태코드 (PENDING:미조사, PROGRESS:진행중, COMPLETED:완료)';
COMMENT ON COLUMN CST_SECT.BGNG_LAT IS '시작위도';
COMMENT ON COLUMN CST_SECT.BGNG_LON IS '시작경도';
COMMENT ON COLUMN CST_SECT.END_LAT IS '종료위도';
COMMENT ON COLUMN CST_SECT.END_LON IS '종료경도';
COMMENT ON COLUMN CST_SECT.BGNG_LOC_GEOM IS '시작위치지오메트리 (EPSG:4326 Point)';
COMMENT ON COLUMN CST_SECT.END_LOC_GEOM IS '종료위치지오메트리 (EPSG:4326 Point)';
COMMENT ON COLUMN CST_SECT.REG_DTM IS '등록일시';
COMMENT ON COLUMN CST_SECT.MDFCN_DTM IS '수정일시';
-- ============================================================
-- 10. SCAT조사 (SCAT_SRVY)
-- ============================================================
CREATE TABLE SCAT_SRVY (
SCAT_SRVY_SN SERIAL NOT NULL, -- SCAT조사순번
CST_SECT_SN INTEGER NOT NULL, -- 해안구간순번
ACDNT_SN INTEGER, -- 사고순번
SRVY_YMD CHAR(8), -- 조사일자 (YYYYMMDD)
SRVY_TP_CD VARCHAR(20), -- 조사유형코드
SRVY_PHASE_CD VARCHAR(20), -- 조사단계코드
POLUT_CD VARCHAR(20), -- 오염도코드
SRVYR_ID UUID, -- 조사자아이디
RMK VARCHAR(500), -- 비고
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_SCAT_SRVY PRIMARY KEY (SCAT_SRVY_SN),
CONSTRAINT FK_SCAT_SECT FOREIGN KEY (CST_SECT_SN) REFERENCES CST_SECT(CST_SECT_SN),
CONSTRAINT FK_SCAT_ACDNT FOREIGN KEY (ACDNT_SN) REFERENCES ACDNT(ACDNT_SN),
CONSTRAINT FK_SCAT_SRVYR FOREIGN KEY (SRVYR_ID) REFERENCES USER_INFO(USER_ID)
);
COMMENT ON TABLE SCAT_SRVY IS 'SCAT조사';
COMMENT ON COLUMN SCAT_SRVY.SCAT_SRVY_SN IS 'SCAT조사순번';
COMMENT ON COLUMN SCAT_SRVY.CST_SECT_SN IS '해안구간순번 (해안구간 참조)';
COMMENT ON COLUMN SCAT_SRVY.ACDNT_SN IS '사고순번 (관련 사고 참조)';
COMMENT ON COLUMN SCAT_SRVY.SRVY_YMD IS '조사일자 (YYYYMMDD 저장형식)';
COMMENT ON COLUMN SCAT_SRVY.SRVY_TP_CD IS '조사유형코드 (SRVY_TP: Pre-SCAT, SCAT, Post-SCAT)';
COMMENT ON COLUMN SCAT_SRVY.SRVY_PHASE_CD IS '조사단계코드 (SRVY_PHASE: 1차, 2차, 3차 등)';
COMMENT ON COLUMN SCAT_SRVY.POLUT_CD IS '오염도코드 (POLUT: HEAVY, MODERATE, LIGHT, CLEAN)';
COMMENT ON COLUMN SCAT_SRVY.SRVYR_ID IS '조사자아이디';
COMMENT ON COLUMN SCAT_SRVY.RMK IS '비고';
COMMENT ON COLUMN SCAT_SRVY.REG_DTM IS '등록일시';
-- ============================================================
-- 11. SCAT조사사진 (SCAT_SRVY_PHOTO)
-- ============================================================
CREATE TABLE SCAT_SRVY_PHOTO (
SCAT_PHOTO_SN SERIAL NOT NULL, -- SCAT조사사진순번
SCAT_SRVY_SN INTEGER NOT NULL, -- SCAT조사순번
PHOTO_FILE_PATH VARCHAR(500) NOT NULL, -- 사진파일경로
PHOTO_DC VARCHAR(200), -- 사진설명
TAKNG_DTM TIMESTAMPTZ, -- 촬영일시
SORT_ORD INTEGER DEFAULT 0, -- 정렬순서
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_SCAT_PHOTO PRIMARY KEY (SCAT_PHOTO_SN),
CONSTRAINT FK_PHOTO_SRVY FOREIGN KEY (SCAT_SRVY_SN) REFERENCES SCAT_SRVY(SCAT_SRVY_SN) ON DELETE CASCADE
);
COMMENT ON TABLE SCAT_SRVY_PHOTO IS 'SCAT조사사진';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SCAT_PHOTO_SN IS 'SCAT조사사진순번';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SCAT_SRVY_SN IS 'SCAT조사순번 (SCAT조사 참조)';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.PHOTO_FILE_PATH IS '사진파일경로';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.PHOTO_DC IS '사진설명';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.TAKNG_DTM IS '촬영일시';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.SORT_ORD IS '정렬순서';
COMMENT ON COLUMN SCAT_SRVY_PHOTO.REG_DTM IS '등록일시';
-- ============================================================
-- 12. HNS물질정보 (HNS_SBST)
-- ============================================================
CREATE TABLE HNS_SBST (
HNS_SBST_SN SERIAL NOT NULL, -- HNS물질순번
SBST_NM VARCHAR(200) NOT NULL, -- 물질명
SBST_ENG_NM VARCHAR(200), -- 물질영문명
CAS_NO VARCHAR(20), -- CAS번호
UN_NO VARCHAR(10), -- UN번호
RISK_GRD_CD VARCHAR(10), -- 위험등급코드
PHYS_STATE_CD VARCHAR(20), -- 물리상태코드
DNST NUMERIC(10,4), -- 밀도
VAPOR_PRSR NUMERIC(12,4), -- 증기압
WATER_SLBLT_CD VARCHAR(20), -- 수용성코드
TOXICITY_CD VARCHAR(20), -- 독성코드
SBST_DC TEXT, -- 물질설명
REG_DTM TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- 등록일시
CONSTRAINT PK_HNS_SBST PRIMARY KEY (HNS_SBST_SN)
);
COMMENT ON TABLE HNS_SBST IS 'HNS물질정보';
COMMENT ON COLUMN HNS_SBST.HNS_SBST_SN IS 'HNS물질순번';
COMMENT ON COLUMN HNS_SBST.SBST_NM IS '물질명 (한글명)';
COMMENT ON COLUMN HNS_SBST.SBST_ENG_NM IS '물질영문명';
COMMENT ON COLUMN HNS_SBST.CAS_NO IS 'CAS번호 (화학물질등록번호)';
COMMENT ON COLUMN HNS_SBST.UN_NO IS 'UN번호 (위험물 식별번호)';
COMMENT ON COLUMN HNS_SBST.RISK_GRD_CD IS '위험등급코드';
COMMENT ON COLUMN HNS_SBST.PHYS_STATE_CD IS '물리상태코드 (SOLID, LIQUID, GAS)';
COMMENT ON COLUMN HNS_SBST.DNST IS '밀도 (g/cm³)';
COMMENT ON COLUMN HNS_SBST.VAPOR_PRSR IS '증기압 (mmHg)';
COMMENT ON COLUMN HNS_SBST.WATER_SLBLT_CD IS '수용성코드 (SOLUBLE, PARTIAL, INSOLUBLE)';
COMMENT ON COLUMN HNS_SBST.TOXICITY_CD IS '독성코드 (HIGH, MEDIUM, LOW)';
COMMENT ON COLUMN HNS_SBST.SBST_DC IS '물질설명';
COMMENT ON COLUMN HNS_SBST.REG_DTM IS '등록일시';
-- ============================================================
-- 13. 인덱스
-- ============================================================
-- 공간 인덱스 (GIST)
CREATE INDEX IDX_ACDNT_LOC_GEOM ON ACDNT USING GIST(LOC_GEOM);
CREATE INDEX IDX_SPIL_LOC_GEOM ON SPIL_DATA USING GIST(SPIL_LOC_GEOM);
CREATE INDEX IDX_BOOM_LINE_GEOM ON BOOM_LINE USING GIST(LINE_GEOM);
CREATE INDEX IDX_SECT_BGNG_GEOM ON CST_SECT USING GIST(BGNG_LOC_GEOM);
CREATE INDEX IDX_SECT_END_GEOM ON CST_SECT USING GIST(END_LOC_GEOM);
-- 상태 코드 인덱스
CREATE INDEX IDX_ACDNT_STTS ON ACDNT (ACDNT_STTS_CD);
CREATE INDEX IDX_PRED_STTS ON PRED_EXEC (EXEC_STTS_CD);
CREATE INDEX IDX_BOOM_STTS ON BOOM_LINE (DPLY_STTS_CD);
CREATE INDEX IDX_SECT_SRVY_STTS ON CST_SECT (SRVY_STTS_CD);
-- 외래키 인덱스
CREATE INDEX IDX_USER_ORG_SN ON USER_INFO (ORG_SN);
CREATE INDEX IDX_ACDNT_RSPNS_ORG ON ACDNT (RSPNS_ORG_SN);
CREATE INDEX IDX_SPIL_ACDNT_SN ON SPIL_DATA (ACDNT_SN);
CREATE INDEX IDX_PRED_SPIL_SN ON PRED_EXEC (SPIL_DATA_SN);
CREATE INDEX IDX_BOOM_ACDNT_SN ON BOOM_LINE (ACDNT_SN);
CREATE INDEX IDX_SECT_ZONE_SN ON CST_SECT (CST_SRVY_ZONE_SN);
CREATE INDEX IDX_SCAT_SECT_SN ON SCAT_SRVY (CST_SECT_SN);
CREATE INDEX IDX_SCAT_ACDNT_SN ON SCAT_SRVY (ACDNT_SN);
CREATE INDEX IDX_PHOTO_SRVY_SN ON SCAT_SRVY_PHOTO (SCAT_SRVY_SN);
-- 코드 테이블 인덱스
CREATE INDEX IDX_CMN_CD_GRP_USE ON CMN_CD_GRP (USE_YN);
CREATE INDEX IDX_CMN_CD_USE ON CMN_CD (USE_YN);
-- 텍스트 검색 인덱스
CREATE INDEX IDX_ACDNT_NM_TRGM ON ACDNT USING GIN(ACDNT_NM gin_trgm_ops);
CREATE INDEX IDX_HNS_SBST_NM_TRGM ON HNS_SBST USING GIN(SBST_NM gin_trgm_ops);
-- ============================================================
-- 14. 공통코드 데이터
-- ============================================================
-- 조직유형 (ORG_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('ORG_TP', '조직유형', '조직의 유형을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('ORG_TP', 'HEADQUARTERS', '본청', 1),
('ORG_TP', 'REGIONAL', '지방청', 2),
('ORG_TP', 'STATION', '해양경찰서', 3),
('ORG_TP', 'AGENCY', '유관기관', 4);
-- 사용자역할 (USER_ROLE)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('USER_ROLE', '사용자역할', '사용자의 시스템 역할을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('USER_ROLE', 'ADMIN', '관리자', 1),
('USER_ROLE', 'MANAGER', '운영자', 2),
('USER_ROLE', 'USER', '일반사용자', 3);
-- 사고유형 (ACDNT_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('ACDNT_TP', '사고유형', '해양사고의 유형을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('ACDNT_TP', 'COLLISION', '충돌', 1),
('ACDNT_TP', 'GROUNDING', '좌초', 2),
('ACDNT_TP', 'SINKING', '침몰', 3),
('ACDNT_TP', 'LEAK', '누출', 4),
('ACDNT_TP', 'EXPLOSION', '폭발', 5),
('ACDNT_TP', 'ETC', '기타', 99);
-- 사고상태 (ACDNT_STTS)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('ACDNT_STTS', '사고상태', '사고의 진행 상태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('ACDNT_STTS', 'ACTIVE', '진행중', 1),
('ACDNT_STTS', 'MONITORING', '감시중', 2),
('ACDNT_STTS', 'CLOSED', '종료', 3);
-- 심각도 (SVRT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SVRT', '심각도', '사고의 심각도를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SVRT', 'DANGER', '위험', 1),
('SVRT', 'ALERT', '경계', 2),
('SVRT', 'CAUTION', '주의', 3),
('SVRT', 'INTEREST', '관심', 4);
-- 유종 (OIL_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('OIL_TP', '유종', '유출유의 종류를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('OIL_TP', 'CRUDE', '원유', 1),
('OIL_TP', 'BUNKER_C', '벙커C유', 2),
('OIL_TP', 'DIESEL', '경유', 3),
('OIL_TP', 'GASOLINE', '휘발유', 4),
('OIL_TP', 'KEROSENE', '등유', 5),
('OIL_TP', 'LUBE', '윤활유', 6),
('OIL_TP', 'ETC', '기타', 99);
-- 유출단위 (SPIL_UNIT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SPIL_UNIT', '유출단위', '유출량의 측정 단위를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SPIL_UNIT', 'KL', '킬로리터', 1),
('SPIL_UNIT', 'L', '리터', 2),
('SPIL_UNIT', 'BBL', '배럴', 3),
('SPIL_UNIT', 'TON', '', 4);
-- 유출유형 (SPIL_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SPIL_TP', '유출유형', '유출의 형태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SPIL_TP', 'CONTINUOUS', '연속유출', 1),
('SPIL_TP', 'INSTANTANEOUS','순간유출', 2);
-- 예측알고리즘 (ALGO)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('ALGO', '예측알고리즘', '유출 확산 예측에 사용되는 알고리즘 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('ALGO', 'GNOME', 'GNOME 모델', 1),
('ALGO', 'OSCAR', 'OSCAR 모델', 2),
('ALGO', 'MOHID', 'MOHID 모델', 3);
-- 실행상태 (EXEC_STTS)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('EXEC_STTS', '실행상태', '예측 실행의 상태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('EXEC_STTS', 'PENDING', '대기', 1),
('EXEC_STTS', 'RUNNING', '실행중', 2),
('EXEC_STTS', 'COMPLETED', '완료', 3),
('EXEC_STTS', 'FAILED', '실패', 4);
-- 오일펜스우선순위 (BOOM_PRIORT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('BOOM_PRIORT', '오일펜스우선순위', '오일펜스 배치 우선순위를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('BOOM_PRIORT', 'CRITICAL', '긴급', 1),
('BOOM_PRIORT', 'HIGH', '높음', 2),
('BOOM_PRIORT', 'MEDIUM', '보통', 3),
('BOOM_PRIORT', 'LOW', '낮음', 4);
-- 오일펜스배치상태 (DPLY_STTS)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('DPLY_STTS', '배치상태', '오일펜스의 배치 상태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('DPLY_STTS', 'PLANNED', '계획', 1),
('DPLY_STTS', 'DEPLOYING', '배치중', 2),
('DPLY_STTS', 'DEPLOYED', '배치완료', 3),
('DPLY_STTS', 'REMOVED', '회수', 4);
-- 해안유형 (CST_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('CST_TP', '해안유형', '해안의 지형 유형을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('CST_TP', 'ROCK', '암반해안', 1),
('CST_TP', 'GRAVEL', '자갈해안', 2),
('CST_TP', 'SAND', '모래해안', 3),
('CST_TP', 'MUD', '갯벌', 4),
('CST_TP', 'MIXED', '혼합해안', 5),
('CST_TP', 'SEAWALL', '인공구조물', 6),
('CST_TP', 'TETRAPOD', '테트라포드', 7);
-- 민감도 (SNSTVT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SNSTVT', '민감도', '해안구간의 환경 민감도를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SNSTVT', 'HIGH', '', 1),
('SNSTVT', 'MEDIUM', '', 2),
('SNSTVT', 'LOW', '', 3);
-- 조사상태 (SRVY_STTS)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SRVY_STTS', '조사상태', '해안구간 조사의 진행 상태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SRVY_STTS', 'PENDING', '미조사', 1),
('SRVY_STTS', 'PROGRESS', '진행중', 2),
('SRVY_STTS', 'COMPLETED', '완료', 3);
-- 조사유형 (SRVY_TP)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SRVY_TP', '조사유형', 'SCAT 조사의 유형을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SRVY_TP', 'PRE_SCAT', 'Pre-SCAT', 1),
('SRVY_TP', 'SCAT', 'SCAT', 2),
('SRVY_TP', 'POST_SCAT', 'Post-SCAT', 3);
-- 조사단계 (SRVY_PHASE)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('SRVY_PHASE', '조사단계', 'SCAT 조사의 단계를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('SRVY_PHASE', 'PHASE_1', '1차 조사', 1),
('SRVY_PHASE', 'PHASE_2', '2차 조사', 2),
('SRVY_PHASE', 'PHASE_3', '3차 조사', 3);
-- 오염도 (POLUT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('POLUT', '오염도', '해안구간의 오염 정도를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('POLUT', 'HEAVY', '심각', 1),
('POLUT', 'MODERATE', '보통', 2),
('POLUT', 'LIGHT', '경미', 3),
('POLUT', 'CLEAN', '깨끗', 4);
-- 물리상태 (PHYS_STATE)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('PHYS_STATE', '물리상태', '화학물질의 물리적 상태를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('PHYS_STATE', 'SOLID', '고체', 1),
('PHYS_STATE', 'LIQUID', '액체', 2),
('PHYS_STATE', 'GAS', '기체', 3);
-- 수용성 (WATER_SLBLT)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('WATER_SLBLT', '수용성', '화학물질의 수용성 정도를 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('WATER_SLBLT', 'SOLUBLE', '수용성', 1),
('WATER_SLBLT', 'PARTIAL', '부분용해', 2),
('WATER_SLBLT', 'INSOLUBLE', '불용성', 3);
-- 독성 (TOXICITY)
INSERT INTO CMN_CD_GRP (CMN_CD_GRP_ID, CMN_CD_GRP_NM, GRP_DC) VALUES
('TOXICITY', '독성', '화학물질의 독성 수준을 구분하는 코드');
INSERT INTO CMN_CD (CMN_CD_GRP_ID, CMN_CD, CMN_CD_NM, SORT_ORD) VALUES
('TOXICITY', 'HIGH', '고독성', 1),
('TOXICITY', 'MEDIUM', '중독성', 2),
('TOXICITY', 'LOW', '저독성', 3);
-- ============================================================
-- 15. 샘플 데이터
-- ============================================================
-- 조직
INSERT INTO ORG (ORG_NM, ORG_ABBR_NM, ORG_TP_CD) VALUES
('해양경찰청', '해경청', 'HEADQUARTERS'),
('남해지방해양경찰청', '남해청', 'REGIONAL'),
('제주지방해양경찰청', '제주청', 'REGIONAL'),
('여수해양경찰서', '여수서', 'STATION'),
('서귀포해양경찰서', '서귀포서', 'STATION'),
('제주해양경찰서', '제주서', 'STATION');
-- 상위조직 설정
UPDATE ORG SET UPPER_ORG_SN = 1 WHERE ORG_SN IN (2, 3);
UPDATE ORG SET UPPER_ORG_SN = 2 WHERE ORG_SN = 4;
UPDATE ORG SET UPPER_ORG_SN = 3 WHERE ORG_SN IN (5, 6);
-- 관리자 (비밀번호: admin1234)
INSERT INTO USER_INFO (USER_ACNT, PSWD_HASH, USER_NM, RNKP_NM, ORG_SN, ROLE_CD) VALUES
('admin', '$2b$10$rN3qJ9qZ5yH.KfxYvYvW0.VxJ8K5vN8qJ9qZ5yH.KfxYvYvW0.VxJ8', '관리자', '경정', 1, 'ADMIN');
-- 샘플 사고 (여수 앞바다)
INSERT INTO ACDNT (ACDNT_CD, ACDNT_NM, ACDNT_TP_CD, LOC_GEOM, LOC_DC, OCCRN_DTM, RSPNS_ORG_SN, SVRT_CD) VALUES
('INC-2025-0042', '여수 앞바다 유조선 충돌', 'COLLISION',
ST_SetSRID(ST_MakePoint(127.8, 34.5), 4326),
'여수 돌산 남방 5NM', '2025-02-10 06:30:00+09', 4, 'DANGER');
-- ============================================================
-- 데이터베이스 코멘트
-- ============================================================
COMMENT ON DATABASE wing IS 'WING 해양환경 위기대응 통합시스템 - 공공데이터베이스 표준화 관리 매뉴얼(2021.06) 적용';

39
docker-compose.yml Executable file
파일 보기

@ -0,0 +1,39 @@
version: '3.8'
services:
postgres:
image: postgis/postgis:16-3.4
container_name: wing-db
restart: unless-stopped
environment:
POSTGRES_DB: wing
POSTGRES_USER: wing_admin
POSTGRES_PASSWORD: wing_secure_2026
TZ: Asia/Seoul
ports:
- "5432:5432"
volumes:
- wing_data:/var/lib/postgresql/data
- ./database/database_init.sql:/docker-entrypoint-initdb.d/01-init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U wing_admin -d wing"]
interval: 10s
timeout: 5s
retries: 5
pgadmin:
image: dpage/pgadmin4:latest
container_name: wing-pgadmin
restart: unless-stopped
environment:
PGADMIN_DEFAULT_EMAIL: admin@wing.kr
PGADMIN_DEFAULT_PASSWORD: admin1234
ports:
- "5050:80"
depends_on:
postgres:
condition: service_healthy
volumes:
wing_data:
driver: local

186
docs/INSTALL_GUIDE.md Executable file
파일 보기

@ -0,0 +1,186 @@
# WING 해양환경 위기대응 통합시스템 - 설치 매뉴얼
## 1. 필수 소프트웨어
| 소프트웨어 | 최소 버전 | 용도 | 다운로드 |
|-----------|----------|------|---------|
| Node.js | v20 이상 (권장 v25) | 프론트엔드/백엔드 실행 | https://nodejs.org |
| npm | v10 이상 | 패키지 관리 | Node.js에 포함 |
| Docker Desktop | v4.0 이상 | PostgreSQL + pgAdmin 실행 | https://www.docker.com/products/docker-desktop |
> **오프라인 환경**: 인터넷이 안 되는 망에서는 `node_modules`가 포함된 압축 파일을 사용하세요 (아래 "오프라인 설치" 참고).
---
## 2. 프로젝트 구조
```
wing/
├── frontend/ # React + Vite 프론트엔드 (포트 5173)
│ ├── src/
│ │ ├── components/ # UI 컴포넌트
│ │ ├── data/ # 정적 데이터
│ │ ├── hooks/ # 커스텀 훅
│ │ ├── types/ # TypeScript 타입 정의
│ │ ├── utils/ # 유틸리티 함수
│ │ └── store/ # 상태관리
│ └── package.json
├── backend/ # Express 백엔드 API (포트 3001)
│ ├── src/
│ │ ├── routes/ # API 라우트
│ │ ├── middleware/ # 미들웨어 (보안 등)
│ │ ├── db/ # DB 시드 데이터
│ │ └── server.ts # 서버 엔트리
│ └── package.json
├── database/ # DB 초기화 SQL
│ ├── database_init.sql
│ └── init.sql
├── docker-compose.yml # PostgreSQL + pgAdmin 컨테이너
└── INSTALL_GUIDE.md # 이 파일
```
---
## 3. 온라인 설치 (인터넷 가능한 환경)
### 3-1. 의존성 설치
```bash
# 프론트엔드
cd wing/frontend
npm install
# 백엔드
cd ../backend
npm install
```
### 3-2. 데이터베이스 실행 (Docker)
```bash
cd wing
docker compose up -d
```
실행 확인:
- PostgreSQL: `localhost:5432` (ID: `wing_admin` / PW: `wing_secure_2026`)
- pgAdmin: `http://localhost:5050` (ID: `admin@wing.kr` / PW: `admin1234`)
### 3-3. 백엔드 실행
```bash
cd wing/backend
npm run dev
```
`http://localhost:3001` 에서 API 서버 시작
### 3-4. 프론트엔드 실행
```bash
cd wing/frontend
npm run dev
```
`http://localhost:5173` 에서 웹 앱 시작
---
## 4. 오프라인 설치 (폐쇄망/다른 망)
인터넷이 안 되는 환경에서는 `npm install`이 불가능합니다.
이 경우 **node_modules 포함 압축 파일**을 사용하세요.
### 4-1. 압축 해제
```bash
# wing_full.tar.gz 파일을 작업 폴더에 복사한 뒤:
tar -xzf wing_full.tar.gz
```
### 4-2. Node.js 설치
대상 PC에 Node.js가 없으면 오프라인 설치 파일(.msi 또는 .pkg)을 미리 준비하여 설치합니다.
- Windows: `node-v25.x.x-x64.msi`
- macOS: `node-v25.x.x.pkg`
### 4-3. Docker 이미지 (선택)
DB를 Docker로 실행하려면 이미지를 미리 저장해서 가져갑니다.
**원래 PC에서 내보내기:**
```bash
docker pull postgis/postgis:16-3.4
docker pull dpage/pgadmin4:latest
docker save postgis/postgis:16-3.4 dpage/pgadmin4:latest -o wing_docker_images.tar
```
**대상 PC에서 불러오기:**
```bash
docker load -i wing_docker_images.tar
docker compose up -d
```
> Docker 없이도 PostgreSQL을 직접 설치하여 `database/database_init.sql`을 실행하면 됩니다.
### 4-4. 실행
node_modules가 이미 포함되어 있으므로 바로 실행 가능합니다.
```bash
# 터미널 1 - 백엔드
cd wing/backend
npm run dev
# 터미널 2 - 프론트엔드
cd wing/frontend
npm run dev
```
---
## 5. 접속 정보 요약
| 서비스 | URL | 비고 |
|--------|-----|------|
| 프론트엔드 (WING) | http://localhost:5173 | Vite dev server |
| 백엔드 API | http://localhost:3001 | Express |
| PostgreSQL | localhost:5432 | DB: `wing`, User: `wing_admin` |
| pgAdmin | http://localhost:5050 | Email: `admin@wing.kr` |
---
## 6. 주요 명령어
```bash
# 프론트엔드 빌드 (배포용)
cd frontend && npm run build # dist/ 폴더에 정적 파일 생성
# 백엔드 빌드
cd backend && npm run build # dist/ 폴더에 JS 생성
# DB 시드 데이터 입력
cd backend && npm run db:seed
# TypeScript 타입 체크
cd frontend && npx tsc --noEmit
# Docker 중지
docker compose down
# Docker 데이터 포함 삭제
docker compose down -v
```
---
## 7. 트러블슈팅
| 증상 | 해결 |
|------|------|
| `npm run dev` 실행 시 포트 충돌 | `lsof -i :5173` 또는 `lsof -i :3001`로 확인 후 프로세스 종료 |
| `EACCES` 권한 오류 | `sudo chown -R $(whoami) wing/` |
| Docker 컨테이너 안 뜸 | `docker compose logs` 로 로그 확인 |
| 프론트엔드에서 API 호출 실패 | 백엔드(`localhost:3001`)가 실행 중인지 확인 |
| `MODULE_NOT_FOUND` 오류 | `npm install` 재실행 (온라인) 또는 node_modules 포함 압축본 사용 |

153
docs/README.md Executable file
파일 보기

@ -0,0 +1,153 @@
# Wing - 해양 레이어 관리 시스템
## 프로젝트 구조
```
wing/
├── backend/ # Express + SQLite 백엔드
│ ├── src/
│ │ ├── db/ # 데이터베이스 설정 및 시드
│ │ ├── routes/ # API 라우트
│ │ └── server.ts # 메인 서버
│ └── data/ # SQLite 데이터베이스 파일
├── frontend/ # React + Vite 프론트엔드
│ └── src/
│ ├── components/
│ ├── data/ # 레이어 데이터 타입
│ ├── hooks/ # React Query 훅
│ └── services/ # API 클라이언트
└── LayerList.csv # 원본 레이어 데이터
```
## 설치 및 실행
### 1. 백엔드 설정
```bash
cd backend
# 의존성 설치
npm install
# 데이터베이스 시드 (CSV를 DB로 변환)
npm run db:seed
# 개발 서버 실행
npm run dev
```
백엔드 서버는 `http://localhost:3001`에서 실행됩니다.
### 2. 프론트엔드 설정
```bash
cd frontend
# 의존성 설치 (아직 안 했다면)
npm install
# 개발 서버 실행
npm run dev
```
프론트엔드는 `http://localhost:5173`에서 실행됩니다.
## API 엔드포인트
### 레이어 관리
- `GET /api/layers` - 모든 레이어 조회
- `GET /api/layers/:id` - 특정 레이어 조회
- `GET /api/layers/tree/all` - 계층 구조 레이어 트리 조회
- `GET /api/layers/wms/all` - WMS 레이어만 조회
- `GET /api/layers/level/:level` - 특정 레벨의 레이어 조회
- `GET /api/layers/children/:parentId` - 특정 부모의 자식 레이어 조회
## 데이터베이스 스키마
```sql
CREATE TABLE layers (
cmn_cd TEXT PRIMARY KEY, -- 레이어 코드
up_cmn_cd TEXT, -- 부모 레이어 코드
cmn_cd_full_nm TEXT NOT NULL, -- 전체 이름
cmn_cd_nm TEXT NOT NULL, -- 레이어 이름
cmn_cd_level INTEGER NOT NULL, -- 레벨 (1-7)
clnm TEXT, -- WMS 레이어 이름
FOREIGN KEY (up_cmn_cd) REFERENCES layers(cmn_cd)
);
```
## 프론트엔드에서 사용하기
### React Query 훅 사용
```typescript
import { useLayers, useWMSLayers } from './hooks/useLayers'
function MyComponent() {
const { data: layers, isLoading, error } = useLayers()
const { data: wmsLayers } = useWMSLayers()
if (isLoading) return <div>로딩 중...</div>
if (error) return <div>오류: {error.message}</div>
return (
<div>
{layers?.map(layer => (
<div key={layer.id}>{layer.name}</div>
))}
</div>
)
}
```
### API 직접 호출
```typescript
import { fetchAllLayers, fetchWMSLayers } from './services/api'
async function loadLayers() {
const layers = await fetchAllLayers()
const wmsLayers = await fetchWMSLayers()
console.log(layers, wmsLayers)
}
```
## CSV 데이터 업데이트
LayerList.csv 파일을 수정한 후:
```bash
cd backend
npm run db:seed
```
이렇게 하면 데이터베이스가 새 CSV 데이터로 업데이트됩니다.
## 기술 스택
### 백엔드
- Node.js + TypeScript
- Express.js
- Better-SQLite3
### 프론트엔드
- React 19
- TypeScript
- Vite
- React Query (TanStack Query)
- Axios
- Leaflet (지도)
- Tailwind CSS
## 환경 변수
### 프론트엔드 (.env)
```
VITE_API_URL=http://localhost:3001/api
```
### 백엔드
```
PORT=3001 # 기본값
```

7
frontend/.env.example Normal file
파일 보기

@ -0,0 +1,7 @@
VITE_API_URL=http://localhost:3001/api
# 기상청 / 국립해양조사원 API Key (https://www.data.go.kr)
VITE_DATA_GO_KR_API_KEY=your_api_key_here
# AIS 선박 위치 API Key
VITE_AIS_API_KEY=your_api_key_here

73
frontend/README.md Executable file
파일 보기

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Executable file
파일 보기

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
frontend/index.html Executable file
파일 보기

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;600;700;900&family=JetBrains+Mono:wght@400;500;600&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4816
frontend/package-lock.json generated Executable file

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

42
frontend/package.json Executable file
파일 보기

@ -0,0 +1,42 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
"axios": "^1.13.5",
"leaflet": "^1.9.4",
"lucide-react": "^0.564.0",
"ol": "^10.8.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-leaflet": "^5.0.0",
"socket.io-client": "^4.8.3",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/leaflet": "^1.9.21",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.24",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

6
frontend/postcss.config.js Executable file
파일 보기

@ -0,0 +1,6 @@
import tailwindcss from 'tailwindcss'
import autoprefixer from 'autoprefixer'
export default {
plugins: [tailwindcss, autoprefixer],
}

File diff suppressed because one or more lines are too long

파일 보기

@ -0,0 +1,596 @@
{
"1": [
"p001_img01.jpg",
"p001_img02.jpg",
"p001_img03.jpg",
"p001_img04.jpg",
"p001_img05.jpg",
"p001_img06.jpg"
],
"2": [
"p002_img02.jpg",
"p002_img03.jpg"
],
"5": [
"p005_img01.jpg",
"p005_img02.jpg",
"p005_img03.jpg"
],
"17": [
"p017_img01.jpg",
"p017_img02.jpg",
"p017_img03.jpg",
"p017_img04.jpg",
"p017_img05.jpg",
"p017_img06.jpg",
"p017_img10.jpg",
"p017_img12.jpg",
"p017_img13.jpg",
"p017_img14.jpg"
],
"19": [
"p019_img01.jpg",
"p019_img02.jpg"
],
"21": [
"p021_img01.jpg"
],
"22": [
"p022_img02.jpg",
"p022_img03.jpg",
"p022_img04.jpg",
"p022_img07.jpg",
"p022_img08.jpg",
"p022_img10.jpg",
"p022_img11.jpg",
"p022_img12.jpg",
"p022_img13.jpg",
"p022_img16.jpg",
"p022_img17.jpg"
],
"23": [
"p023_img01.jpg"
],
"24": [
"p024_img01.jpg"
],
"25": [
"p025_img01.jpg",
"p025_img04.jpg",
"p025_img07.jpg",
"p025_img08.jpg",
"p025_img11.jpg",
"p025_img12.jpg",
"p025_img14.jpg"
],
"26": [
"p026_img01.jpg"
],
"27": [
"p027_img01.jpg",
"p027_img03.jpg",
"p027_img04.jpg",
"p027_img05.jpg",
"p027_img06.jpg",
"p027_img07.jpg",
"p027_img08.jpg",
"p027_img09.jpg",
"p027_img10.jpg",
"p027_img12.jpg",
"p027_img14.jpg",
"p027_img17.jpg",
"p027_img18.jpg",
"p027_img19.jpg"
],
"28": [
"p028_img01.jpg"
],
"30": [
"p030_img01.jpg",
"p030_img02.jpg"
],
"32": [
"p032_img01.jpg",
"p032_img02.jpg"
],
"33": [
"p033_img01.jpg"
],
"34": [
"p034_img01.jpg",
"p034_img02.jpg",
"p034_img06.jpg",
"p034_img10.jpg",
"p034_img12.jpg",
"p034_img13.jpg",
"p034_img16.jpg",
"p034_img17.jpg"
],
"35": [
"p035_img02.jpg",
"p035_img03.jpg",
"p035_img04.jpg"
],
"36": [
"p036_img03.jpg"
],
"37": [
"p037_img01.jpg",
"p037_img02.jpg"
],
"41": [
"p041_img01.jpg"
],
"43": [
"p043_img01.jpg"
],
"45": [
"p045_img01.jpg",
"p045_img02.jpg",
"p045_img03.jpg"
],
"46": [
"p046_img01.jpg"
],
"47": [
"p047_img01.jpg"
],
"48": [
"p048_img01.jpg"
],
"49": [
"p049_img01.jpg"
],
"51": [
"p051_img01.jpg"
],
"53": [
"p053_img01.jpg"
],
"56": [
"p056_img01.jpg"
],
"60": [
"p060_img01.jpg"
],
"61": [
"p061_img01.jpg"
],
"62": [
"p062_img01.jpg"
],
"63": [
"p063_img01.jpg"
],
"65": [
"p065_img01.jpg"
],
"66": [
"p066_img01.jpg",
"p066_img02.jpg"
],
"69": [
"p069_img01.jpg",
"p069_img02.jpg"
],
"74": [
"p074_img01.jpg",
"p074_img02.jpg",
"p074_img05.jpg"
],
"78": [
"p078_img01.jpg"
],
"79": [
"p079_img01.jpg"
],
"80": [
"p080_img01.jpg"
],
"82": [
"p082_img01.jpg",
"p082_img02.jpg"
],
"84": [
"p084_img01.jpg"
],
"85": [
"p085_img01.jpg"
],
"88": [
"p088_img01.jpg",
"p088_img02.jpg"
],
"90": [
"p090_img01.jpg"
],
"92": [
"p092_img01.jpg"
],
"93": [
"p093_img01.jpg"
],
"99": [
"p099_img01.jpg"
],
"100": [
"p100_img01.jpg"
],
"102": [
"p102_img01.jpg"
],
"108": [
"p108_img03.jpg",
"p108_img07.jpg",
"p108_img09.jpg"
],
"110": [
"p110_img01.jpg"
],
"111": [
"p111_img01.jpg"
],
"114": [
"p114_img01.jpg"
],
"117": [
"p117_img01.jpg"
],
"121": [
"p121_img01.jpg",
"p121_img02.jpg"
],
"122": [
"p122_img01.jpg"
],
"127": [
"p127_img01.jpg"
],
"129": [
"p129_img01.jpg"
],
"130": [
"p130_img01.jpg"
],
"133": [
"p133_img01.jpg",
"p133_img02.jpg",
"p133_img10.jpg",
"p133_img12.jpg",
"p133_img19.jpg",
"p133_img20.jpg"
],
"134": [
"p134_img01.jpg",
"p134_img02.jpg",
"p134_img03.jpg",
"p134_img04.jpg",
"p134_img05.jpg",
"p134_img06.jpg"
],
"135": [
"p135_img01.jpg"
],
"136": [
"p136_img01.jpg"
],
"140": [
"p140_img02.jpg"
],
"141": [
"p141_img01.jpg"
],
"143": [
"p143_img03.jpg",
"p143_img04.jpg",
"p143_img07.jpg"
],
"144": [
"p144_img01.jpg"
],
"150": [
"p150_img01.jpg"
],
"151": [
"p151_img01.jpg"
],
"155": [
"p155_img01.jpg"
],
"156": [
"p156_img01.jpg",
"p156_img02.jpg",
"p156_img03.jpg",
"p156_img04.jpg",
"p156_img05.jpg"
],
"158": [
"p158_img01.jpg"
],
"160": [
"p160_img01.jpg",
"p160_img02.jpg"
],
"161": [
"p161_img01.jpg",
"p161_img02.jpg"
],
"170": [
"p170_img05.jpg"
],
"180": [
"p180_img01.jpg",
"p180_img03.jpg"
],
"181": [
"p181_img05.jpg",
"p181_img06.jpg"
],
"190": [
"p190_img01.jpg"
],
"192": [
"p192_img01.jpg",
"p192_img02.jpg"
],
"195": [
"p195_img01.jpg"
],
"196": [
"p196_img01.jpg",
"p196_img02.jpg",
"p196_img03.jpg"
],
"198": [
"p198_img01.jpg"
],
"200": [
"p200_img01.jpg"
],
"202": [
"p202_img01.jpg"
],
"204": [
"p204_img01.jpg"
],
"206": [
"p206_img01.jpg"
],
"207": [
"p207_img01.jpg"
],
"208": [
"p208_img01.jpg"
],
"209": [
"p209_img01.jpg",
"p209_img02.jpg"
],
"210": [
"p210_img01.jpg"
],
"212": [
"p212_img01.jpg",
"p212_img02.jpg",
"p212_img03.jpg",
"p212_img04.jpg"
],
"213": [
"p213_img01.jpg"
],
"214": [
"p214_img01.jpg",
"p214_img02.jpg",
"p214_img03.jpg",
"p214_img04.jpg"
],
"217": [
"p217_img01.jpg"
],
"219": [
"p219_img01.jpg",
"p219_img02.jpg",
"p219_img03.jpg",
"p219_img04.jpg"
],
"226": [
"p226_img01.jpg",
"p226_img02.jpg"
],
"227": [
"p227_img01.jpg"
],
"228": [
"p228_img02.jpg"
],
"229": [
"p229_img01.jpg"
],
"230": [
"p230_img01.jpg"
],
"231": [
"p231_img09.jpg"
],
"236": [
"p236_img03.jpg"
],
"237": [
"p237_img01.jpg",
"p237_img02.jpg"
],
"238": [
"p238_img01.jpg",
"p238_img02.jpg",
"p238_img03.jpg",
"p238_img04.jpg",
"p238_img05.jpg",
"p238_img06.jpg"
],
"239": [
"p239_img01.jpg",
"p239_img02.jpg",
"p239_img03.jpg"
],
"242": [
"p242_img02.jpg",
"p242_img03.jpg",
"p242_img04.jpg"
],
"244": [
"p244_img01.jpg"
],
"245": [
"p245_img01.jpg"
],
"248": [
"p248_img01.jpg",
"p248_img02.jpg",
"p248_img03.jpg",
"p248_img04.jpg",
"p248_img05.jpg",
"p248_img08.jpg",
"p248_img09.jpg",
"p248_img10.jpg",
"p248_img11.jpg",
"p248_img12.jpg",
"p248_img13.jpg",
"p248_img14.jpg"
],
"249": [
"p249_img01.jpg"
],
"250": [
"p250_img01.jpg"
],
"254": [
"p254_img01.jpg"
],
"257": [
"p257_img01.jpg",
"p257_img02.jpg",
"p257_img03.jpg",
"p257_img04.jpg"
],
"259": [
"p259_img01.jpg"
],
"262": [
"p262_img01.jpg"
],
"263": [
"p263_img04.jpg"
],
"264": [
"p264_img01.jpg",
"p264_img02.jpg"
],
"266": [
"p266_img01.jpg",
"p266_img02.jpg"
],
"267": [
"p267_img03.jpg",
"p267_img04.jpg",
"p267_img05.jpg"
],
"268": [
"p268_img01.jpg"
],
"272": [
"p272_img01.jpg",
"p272_img02.jpg",
"p272_img03.jpg",
"p272_img04.jpg"
],
"273": [
"p273_img01.jpg"
],
"274": [
"p274_img01.jpg"
],
"275": [
"p275_img01.jpg",
"p275_img02.jpg"
],
"276": [
"p276_img01.jpg",
"p276_img02.jpg"
],
"278": [
"p278_img01.jpg",
"p278_img02.jpg",
"p278_img03.jpg"
],
"279": [
"p279_img01.jpg"
],
"280": [
"p280_img01.jpg"
],
"281": [
"p281_img01.jpg"
],
"283": [
"p283_img01.jpg",
"p283_img02.jpg",
"p283_img03.jpg",
"p283_img04.jpg",
"p283_img05.jpg"
],
"286": [
"p286_img01.jpg",
"p286_img02.jpg"
],
"287": [
"p287_img01.jpg",
"p287_img02.jpg",
"p287_img03.jpg",
"p287_img04.jpg"
],
"290": [
"p290_img01.jpg",
"p290_img02.jpg",
"p290_img03.jpg"
],
"293": [
"p293_img03.jpg"
],
"294": [
"p294_img01.jpg",
"p294_img02.jpg",
"p294_img03.jpg",
"p294_img04.jpg"
],
"298": [
"p298_img01.jpg",
"p298_img02.jpg"
],
"306": [
"p306_img01.jpg",
"p306_img02.jpg"
],
"307": [
"p307_img03.jpg"
],
"309": [
"p309_img01.jpg",
"p309_img02.jpg"
],
"312": [
"p312_img01.jpg"
],
"314": [
"p314_img01.jpg"
],
"315": [
"p315_img01.jpg"
],
"316": [
"p316_img01.jpg"
],
"337": [
"p337_img01.jpg",
"p337_img02.jpg"
]
}

Binary file not shown.

After

Width:  |  Height:  |  크기: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 294 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 195 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 351 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 447 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 343 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 331 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 498 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 425 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 428 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 310 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 298 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 516 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 515 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 368 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 393 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 478 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 526 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 439 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 394 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 301 KiB

Some files were not shown because too many files have changed in this diff Show More