Compare commits
No commits in common. "main" and "feature/dashboard-phase-1" have entirely different histories.
main
...
feature/da
@ -44,7 +44,21 @@
|
|||||||
- `@Builder` 허용
|
- `@Builder` 허용
|
||||||
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
|
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
|
||||||
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
|
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
|
||||||
- `@Slf4j` 로거 사용
|
|
||||||
|
## 로깅
|
||||||
|
- `@Slf4j` (Lombok) 로거 사용
|
||||||
|
- SLF4J `{}` 플레이스홀더에 printf 포맷 사용 금지 (`{:.1f}`, `{:d}`, `{%s}` 등)
|
||||||
|
- 숫자 포맷이 필요하면 `String.format()`으로 변환 후 전달
|
||||||
|
```java
|
||||||
|
// 잘못됨
|
||||||
|
log.info("처리율: {:.1f}%", rate);
|
||||||
|
// 올바름
|
||||||
|
log.info("처리율: {}%", String.format("%.1f", rate));
|
||||||
|
```
|
||||||
|
- 예외 로깅 시 예외 객체는 마지막 인자로 전달 (플레이스홀더 불필요)
|
||||||
|
```java
|
||||||
|
log.error("처리 실패: {}", id, exception);
|
||||||
|
```
|
||||||
|
|
||||||
## 예외 처리
|
## 예외 처리
|
||||||
- 비즈니스 예외는 커스텀 Exception 클래스 정의
|
- 비즈니스 예외는 커스텀 Exception 클래스 정의
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
"$schema": "https://json.schemastore.org/claude-code-settings.json",
|
||||||
"env": {
|
"env": {
|
||||||
"CLAUDE_BOT_TOKEN": "ac15488ad66463bd5c4e3be1fa6dd5b2743813c5"
|
"CLAUDE_BOT_TOKEN": "4804f9f63e799e25d9a8b381e89c8bff11471b7a"
|
||||||
},
|
},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
|
|||||||
@ -46,94 +46,72 @@ curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
|||||||
### 3. .claude/ 디렉토리 구성
|
### 3. .claude/ 디렉토리 구성
|
||||||
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
|
||||||
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
|
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + env(CLAUDE_BOT_TOKEN 등) + hooks 섹션 (4단계 참조)
|
||||||
|
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
|
||||||
|
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
|
||||||
|
|
||||||
⚠️ 팀 규칙(.claude/rules/), 에이전트(.claude/agents/), 스킬 6종, 스크립트는 12단계(sync-team-workflow)에서 자동 다운로드된다. 여기서는 settings.json만 설정한다.
|
### 4. Hook 스크립트 생성
|
||||||
|
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
|
||||||
|
|
||||||
### 3.5. Gitea 토큰 설정
|
- `.claude/scripts/on-pre-compact.sh`:
|
||||||
|
|
||||||
**CLAUDE_BOT_TOKEN** (팀 공용): `settings.json`의 `env` 필드에 이미 포함되어 있음 (3단계에서 설정됨). 별도 조치 불필요.
|
|
||||||
|
|
||||||
**GITEA_TOKEN** (개인): `/push`, `/mr`, `/release` 등 Git 스킬에 필요한 개인 토큰.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 현재 GITEA_TOKEN 설정 여부 확인
|
#!/bin/bash
|
||||||
if [ -z "$GITEA_TOKEN" ]; then
|
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
|
||||||
echo "GITEA_TOKEN 미설정"
|
INPUT=$(cat)
|
||||||
|
cat <<RESP
|
||||||
|
{
|
||||||
|
"systemMessage": "컨텍스트 압축이 시작됩니다. 반드시 다음을 수행하세요:\n\n1. memory/MEMORY.md - 핵심 작업 상태 갱신 (200줄 이내)\n2. memory/project-snapshot.md - 변경된 패키지/타입 정보 업데이트\n3. memory/project-history.md - 이번 세션 변경사항 추가\n4. memory/api-types.md - API 인터페이스 변경이 있었다면 갱신\n5. 미완료 작업이 있다면 TodoWrite에 남기고 memory에도 기록"
|
||||||
|
}
|
||||||
|
RESP
|
||||||
|
```
|
||||||
|
|
||||||
|
- `.claude/scripts/on-post-compact.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
INPUT=$(cat)
|
||||||
|
CWD=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('cwd',''))" 2>/dev/null || echo "")
|
||||||
|
if [ -z "$CWD" ]; then
|
||||||
|
CWD=$(pwd)
|
||||||
|
fi
|
||||||
|
PROJECT_HASH=$(echo "$CWD" | sed 's|/|-|g')
|
||||||
|
MEMORY_DIR="$HOME/.claude/projects/$PROJECT_HASH/memory"
|
||||||
|
CONTEXT=""
|
||||||
|
if [ -f "$MEMORY_DIR/MEMORY.md" ]; then
|
||||||
|
SUMMARY=$(head -100 "$MEMORY_DIR/MEMORY.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="컨텍스트가 압축되었습니다.\\n\\n[세션 요약]\\n${SUMMARY}"
|
||||||
|
fi
|
||||||
|
if [ -f "$MEMORY_DIR/project-snapshot.md" ]; then
|
||||||
|
SNAP=$(head -50 "$MEMORY_DIR/project-snapshot.md" | python3 -c "import sys;print(sys.stdin.read().replace('\\\\','\\\\\\\\').replace('\"','\\\\\"').replace('\n','\\\\n'))" 2>/dev/null)
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n[프로젝트 최신 상태]\\n${SNAP}"
|
||||||
|
fi
|
||||||
|
if [ -n "$CONTEXT" ]; then
|
||||||
|
CONTEXT="${CONTEXT}\\n\\n위 내용을 참고하여 작업을 이어가세요. 상세 내용은 memory/ 디렉토리의 각 파일을 참조하세요."
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"${CONTEXT}\"}}"
|
||||||
|
else
|
||||||
|
echo "{\"hookSpecificOutput\":{\"additionalContext\":\"컨텍스트가 압축되었습니다. memory 파일이 없으므로 사용자에게 이전 작업 내용을 확인하세요.\"}}"
|
||||||
fi
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
**GITEA_TOKEN이 없는 경우**, 다음 안내를 **AskUserQuestion**으로 표시:
|
- `.claude/scripts/on-commit.sh`:
|
||||||
|
|
||||||
**질문**: "GITEA_TOKEN이 설정되지 않았습니다. Gitea 개인 토큰을 생성하시겠습니까?"
|
|
||||||
- 옵션 1: 토큰 생성 안내 보기 (추천)
|
|
||||||
- 옵션 2: 이미 있음 (토큰 입력)
|
|
||||||
- 옵션 3: 나중에 하기
|
|
||||||
|
|
||||||
**토큰 생성 안내 선택 시**, 다음 내용을 표시:
|
|
||||||
|
|
||||||
```
|
|
||||||
📋 Gitea 토큰 생성 방법:
|
|
||||||
|
|
||||||
1. 브라우저에서 접속:
|
|
||||||
https://gitea.gc-si.dev/user/settings/applications
|
|
||||||
|
|
||||||
2. "Manage Access Tokens" 섹션에서 "Generate New Token" 클릭
|
|
||||||
|
|
||||||
3. 입력:
|
|
||||||
- Token Name: "claude-code" (자유롭게 지정)
|
|
||||||
- Repository and Organization Access: ✅ All (public, private, and limited)
|
|
||||||
|
|
||||||
4. Select permissions (아래 4개만 설정, 나머지는 No Access 유지):
|
|
||||||
|
|
||||||
┌─────────────────┬──────────────────┬──────────────────────────────┐
|
|
||||||
│ 항목 │ 권한 │ 용도 │
|
|
||||||
├─────────────────┼──────────────────┼──────────────────────────────┤
|
|
||||||
│ issue │ Read and Write │ /fix-issue 이슈 조회/코멘트 │
|
|
||||||
│ organization │ Read │ gc 조직 리포 접근 │
|
|
||||||
│ repository │ Read and Write │ /push, /mr, /release API 호출 │
|
|
||||||
│ user │ Read │ API 사용자 인증 확인 │
|
|
||||||
└─────────────────┴──────────────────┴──────────────────────────────┘
|
|
||||||
|
|
||||||
5. "Generate Token" 클릭 → ⚠️ 토큰이 한 번만 표시됩니다! 반드시 복사하세요.
|
|
||||||
```
|
|
||||||
|
|
||||||
표시 후 **AskUserQuestion**: "생성한 토큰을 입력하세요"
|
|
||||||
- 옵션 1: 토큰 입력 (Other로 입력)
|
|
||||||
- 옵션 2: 나중에 하기
|
|
||||||
|
|
||||||
**토큰 입력 시**:
|
|
||||||
|
|
||||||
1. Gitea API로 유효성 검증:
|
|
||||||
```bash
|
```bash
|
||||||
curl -sf "https://gitea.gc-si.dev/api/v1/user" \
|
#!/bin/bash
|
||||||
-H "Authorization: token <입력된 토큰>"
|
INPUT=$(cat)
|
||||||
```
|
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
|
||||||
- 성공: `✅ <login> (<full_name>) 인증 확인` 출력
|
if echo "$COMMAND" | grep -qE 'git commit'; then
|
||||||
- 실패: `❌ 토큰이 유효하지 않습니다. 다시 확인해주세요.` 출력 → 재입력 요청
|
cat <<RESP
|
||||||
|
|
||||||
2. `.claude/settings.local.json`에 저장 (이 파일은 .gitignore에 포함, 리포 커밋 안됨):
|
|
||||||
```json
|
|
||||||
{
|
{
|
||||||
"env": {
|
"hookSpecificOutput": {
|
||||||
"GITEA_TOKEN": "<입력된 토큰>"
|
"additionalContext": "커밋이 감지되었습니다. 다음을 수행하세요:\n1. docs/CHANGELOG.md에 변경 내역 추가\n2. memory/project-snapshot.md에서 변경된 부분 업데이트\n3. memory/project-history.md에 이번 변경사항 추가\n4. API 인터페이스 변경 시 memory/api-types.md 갱신\n5. 프로젝트에 lint 설정이 있다면 lint 결과를 확인하고 문제를 수정"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
RESP
|
||||||
|
else
|
||||||
|
echo '{}'
|
||||||
|
fi
|
||||||
```
|
```
|
||||||
|
|
||||||
기존 `settings.local.json`이 있으면 `env.GITEA_TOKEN`만 추가/갱신.
|
|
||||||
|
|
||||||
**나중에 하기 선택 시**: 경고 표시 후 다음 단계로 진행:
|
|
||||||
```
|
|
||||||
⚠️ GITEA_TOKEN 없이는 /push, /mr, /release 스킬을 사용할 수 없습니다.
|
|
||||||
나중에 토큰을 생성하면 .claude/settings.local.json에 다음을 추가하세요:
|
|
||||||
{ "env": { "GITEA_TOKEN": "your-token-here" } }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Hook 스크립트 설정
|
|
||||||
|
|
||||||
⚠️ `.claude/scripts/` 스크립트 파일은 12단계(sync-team-workflow)에서 서버로부터 자동 다운로드된다.
|
|
||||||
여기서는 `settings.json`에 hooks 섹션만 설정한다.
|
|
||||||
|
|
||||||
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@ -221,20 +199,6 @@ chmod +x .githooks/*
|
|||||||
*.local
|
*.local
|
||||||
```
|
```
|
||||||
|
|
||||||
**팀 워크플로우 관리 경로** (sync로 생성/관리되는 파일, 리포에 커밋하지 않음):
|
|
||||||
```
|
|
||||||
# Team workflow (managed by /sync-team-workflow)
|
|
||||||
.claude/rules/
|
|
||||||
.claude/agents/
|
|
||||||
.claude/skills/push/
|
|
||||||
.claude/skills/mr/
|
|
||||||
.claude/skills/create-mr/
|
|
||||||
.claude/skills/release/
|
|
||||||
.claude/skills/version/
|
|
||||||
.claude/skills/fix-issue/
|
|
||||||
.claude/scripts/
|
|
||||||
```
|
|
||||||
|
|
||||||
### 8. Git exclude 설정
|
### 8. Git exclude 설정
|
||||||
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
|
||||||
|
|
||||||
@ -278,14 +242,7 @@ curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/dev
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12. 팀 워크플로우 최신화
|
### 12. 검증 및 요약
|
||||||
|
|
||||||
`/sync-team-workflow`를 자동으로 1회 실행하여 최신 팀 파일(rules, agents, skills 6종, scripts, hooks)을 서버에서 다운로드하고 로컬에 적용한다.
|
|
||||||
|
|
||||||
이 단계에서 `.claude/rules/`, `.claude/agents/`, `.claude/skills/push/` 등 팀 관리 파일이 생성된다.
|
|
||||||
(이 파일들은 7단계에서 .gitignore에 추가되었으므로 리포에 커밋되지 않음)
|
|
||||||
|
|
||||||
### 13. 검증 및 요약
|
|
||||||
- 생성/수정된 파일 목록 출력
|
- 생성/수정된 파일 목록 출력
|
||||||
- `git config core.hooksPath` 확인
|
- `git config core.hooksPath` 확인
|
||||||
- 빌드 명령 실행 가능 확인
|
- 빌드 명령 실행 가능 확인
|
||||||
|
|||||||
@ -30,43 +30,6 @@ CAN_PUSH=$(echo "$PERMISSIONS" | python3 -c "import sys,json; print(json.load(sy
|
|||||||
|
|
||||||
- `CAN_PUSH`가 `False`이면: "MR 생성 권한이 필요합니다. 프로젝트 관리자에게 요청하세요." 안내 후 종료
|
- `CAN_PUSH`가 `False`이면: "MR 생성 권한이 필요합니다. 프로젝트 관리자에게 요청하세요." 안내 후 종료
|
||||||
|
|
||||||
### 0.5. 팀 워크플로우 최신화 확인
|
|
||||||
|
|
||||||
`.claude/workflow-version.json`이 존재하지 않으면 이 단계를 건너뛴다 (팀 프로젝트가 아닌 경우).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 로컬 설정 읽기
|
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null)
|
|
||||||
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null)
|
|
||||||
CUSTOM_PRECOMMIT=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('custom_pre_commit', False))" 2>/dev/null)
|
|
||||||
|
|
||||||
# 서버 해시 조회 (custom_pre_commit이면 pre-commit 제외 해시 사용)
|
|
||||||
SERVER_VER=$(curl -sf --max-time 5 "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes_custom_precommit',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
else
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 해시 계산 (custom_pre_commit이면 .githooks/pre-commit 제외)
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f ! -path '.githooks/pre-commit' 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
else
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**비교 결과 처리**:
|
|
||||||
- **서버 조회 실패** (`SERVER_HASH` 비어있음): "⚠️ 서버 연결 불가, 워크플로우 체크를 건너뜁니다" 경고 후 다음 단계 진행
|
|
||||||
- **일치** (`LOCAL_HASH == SERVER_HASH`): 다음 단계 진행
|
|
||||||
- **불일치**: "⚠️ 팀 워크플로우가 최신이 아닙니다. 동기화를 실행합니다..." 출력 → **sync-team-workflow 절차를 자동 실행** → 완료 후 원래 작업 계속
|
|
||||||
|
|
||||||
### 1. 사전 검증
|
### 1. 사전 검증
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -30,43 +30,6 @@ CAN_PUSH=$(echo "$PERMISSIONS" | python3 -c "import sys,json; print(json.load(sy
|
|||||||
|
|
||||||
- `CAN_PUSH`가 `False`이면: "push 권한이 필요합니다. 프로젝트 관리자에게 요청하세요." 안내 후 종료
|
- `CAN_PUSH`가 `False`이면: "push 권한이 필요합니다. 프로젝트 관리자에게 요청하세요." 안내 후 종료
|
||||||
|
|
||||||
### 0.5. 팀 워크플로우 최신화 확인
|
|
||||||
|
|
||||||
`.claude/workflow-version.json`이 존재하지 않으면 이 단계를 건너뛴다 (팀 프로젝트가 아닌 경우).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 로컬 설정 읽기
|
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null)
|
|
||||||
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null)
|
|
||||||
CUSTOM_PRECOMMIT=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('custom_pre_commit', False))" 2>/dev/null)
|
|
||||||
|
|
||||||
# 서버 해시 조회 (custom_pre_commit이면 pre-commit 제외 해시 사용)
|
|
||||||
SERVER_VER=$(curl -sf --max-time 5 "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes_custom_precommit',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
else
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 해시 계산 (custom_pre_commit이면 .githooks/pre-commit 제외)
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f ! -path '.githooks/pre-commit' 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
else
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**비교 결과 처리**:
|
|
||||||
- **서버 조회 실패** (`SERVER_HASH` 비어있음): "⚠️ 서버 연결 불가, 워크플로우 체크를 건너뜁니다" 경고 후 다음 단계 진행
|
|
||||||
- **일치** (`LOCAL_HASH == SERVER_HASH`): 다음 단계 진행
|
|
||||||
- **불일치**: "⚠️ 팀 워크플로우가 최신이 아닙니다. 동기화를 실행합니다..." 출력 → **sync-team-workflow 절차를 자동 실행** → 완료 후 원래 작업 계속
|
|
||||||
|
|
||||||
### 1. 현재 상태 수집
|
### 1. 현재 상태 수집
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -29,43 +29,6 @@ IS_ADMIN=$(echo "$PERMISSIONS" | python3 -c "import sys,json; print(json.load(sy
|
|||||||
|
|
||||||
- `IS_ADMIN`이 `False`이면: "릴리즈는 프로젝트 관리자만 실행할 수 있습니다." 안내 후 종료
|
- `IS_ADMIN`이 `False`이면: "릴리즈는 프로젝트 관리자만 실행할 수 있습니다." 안내 후 종료
|
||||||
|
|
||||||
### 0.5. 팀 워크플로우 최신화 확인
|
|
||||||
|
|
||||||
`.claude/workflow-version.json`이 존재하지 않으면 이 단계를 건너뛴다 (팀 프로젝트가 아닌 경우).
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 로컬 설정 읽기
|
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null)
|
|
||||||
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null)
|
|
||||||
CUSTOM_PRECOMMIT=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('custom_pre_commit', False))" 2>/dev/null)
|
|
||||||
|
|
||||||
# 서버 해시 조회 (custom_pre_commit이면 pre-commit 제외 해시 사용)
|
|
||||||
SERVER_VER=$(curl -sf --max-time 5 "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes_custom_precommit',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
else
|
|
||||||
SERVER_HASH=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('content_hashes',{}).get('${PROJECT_TYPE}',''))" 2>/dev/null)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 로컬 해시 계산 (custom_pre_commit이면 .githooks/pre-commit 제외)
|
|
||||||
if [ "$CUSTOM_PRECOMMIT" = "True" ]; then
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f ! -path '.githooks/pre-commit' 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
else
|
|
||||||
LOCAL_HASH=$(find .claude/rules .claude/agents .claude/scripts .githooks \
|
|
||||||
.claude/skills/push .claude/skills/mr .claude/skills/create-mr \
|
|
||||||
.claude/skills/release .claude/skills/version .claude/skills/fix-issue \
|
|
||||||
-type f 2>/dev/null | sort | xargs cat 2>/dev/null | shasum -a 256 | cut -d' ' -f1)
|
|
||||||
fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**비교 결과 처리**:
|
|
||||||
- **서버 조회 실패** (`SERVER_HASH` 비어있음): "⚠️ 서버 연결 불가, 워크플로우 체크를 건너뜁니다" 경고 후 다음 단계 진행
|
|
||||||
- **일치** (`LOCAL_HASH == SERVER_HASH`): 다음 단계 진행
|
|
||||||
- **불일치**: "⚠️ 팀 워크플로우가 최신이 아닙니다. 동기화를 실행합니다..." 출력 → **sync-team-workflow 절차를 자동 실행** → 완료 후 원래 작업 계속
|
|
||||||
|
|
||||||
### 1. 사전 검증
|
### 1. 사전 검증
|
||||||
|
|
||||||
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
|
- 커밋되지 않은 변경 사항이 있으면 경고 ("먼저 /push로 커밋하세요")
|
||||||
|
|||||||
@ -3,163 +3,123 @@ name: sync-team-workflow
|
|||||||
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
|
||||||
---
|
---
|
||||||
|
|
||||||
팀 글로벌 워크플로우의 최신 파일을 서버에서 다운로드하여 로컬에 적용합니다.
|
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
|
||||||
호출 시 항상 서버 기준으로 전체 동기화합니다 (버전 비교 없음).
|
|
||||||
|
|
||||||
## 수행 절차
|
## 수행 절차
|
||||||
|
|
||||||
### 1. 사전 조건 확인
|
### 1. 글로벌 버전 조회
|
||||||
|
Gitea API로 template-common 리포의 workflow-version.json 조회:
|
||||||
`.claude/workflow-version.json` 존재 확인:
|
|
||||||
- 없으면 → "/init-project를 먼저 실행해주세요" 안내 후 종료
|
|
||||||
|
|
||||||
설정 읽기:
|
|
||||||
```bash
|
```bash
|
||||||
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
GITEA_URL=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('gitea_url', 'https://gitea.gc-si.dev'))" 2>/dev/null || echo "https://gitea.gc-si.dev")
|
||||||
PROJECT_TYPE=$(python3 -c "import json; print(json.load(open('.claude/workflow-version.json')).get('project_type', ''))" 2>/dev/null || echo "")
|
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
|
||||||
```
|
```
|
||||||
|
|
||||||
프로젝트 타입이 비어있으면 자동 감지:
|
### 2. 버전 비교
|
||||||
1. `pom.xml` → java-maven
|
로컬 `.claude/workflow-version.json`의 `applied_global_version` 필드와 비교:
|
||||||
2. `build.gradle` / `build.gradle.kts` → java-gradle
|
- 버전 일치 → "최신 버전입니다" 안내 후 종료
|
||||||
3. `package.json` + `tsconfig.json` → react-ts
|
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
|
||||||
4. 감지 실패 → 사용자에게 선택 요청
|
|
||||||
|
### 3. 프로젝트 타입 감지
|
||||||
|
자동 감지 순서:
|
||||||
|
1. `.claude/workflow-version.json`의 `project_type` 필드 확인
|
||||||
|
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
|
||||||
|
|
||||||
### Gitea 파일 다운로드 URL 패턴
|
### Gitea 파일 다운로드 URL 패턴
|
||||||
⚠️ Gitea raw 파일은 반드시 **web raw URL** 사용:
|
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
|
||||||
```bash
|
```bash
|
||||||
|
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
|
||||||
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
# common 파일: ${GITEA_URL}/gc/template-common/raw/branch/develop/<파일경로>
|
||||||
# 타입별 파일: ${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/<파일경로>
|
# 타입별 파일: ${GITEA_URL}/gc/template-<타입>/raw/branch/develop/<파일경로>
|
||||||
|
# 예시:
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md"
|
||||||
|
curl -sf "${GITEA_URL}/gc/template-react-ts/raw/branch/develop/.editorconfig"
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. 디렉토리 준비
|
### 4. 파일 다운로드 및 적용
|
||||||
|
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
|
||||||
|
|
||||||
필요한 디렉토리가 없으면 생성:
|
#### 4-1. 규칙 파일 (덮어쓰기)
|
||||||
```bash
|
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
|
||||||
mkdir -p .claude/rules .claude/agents .claude/scripts
|
|
||||||
mkdir -p .claude/skills/push .claude/skills/mr .claude/skills/create-mr
|
|
||||||
mkdir -p .claude/skills/release .claude/skills/version .claude/skills/fix-issue
|
|
||||||
mkdir -p .githooks
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 서버 파일 다운로드 + 적용
|
|
||||||
|
|
||||||
각 파일을 `curl -sf` 로 다운로드하여 프로젝트 루트의 동일 경로에 저장.
|
|
||||||
다운로드 실패한 파일은 경고 출력 후 건너뜀.
|
|
||||||
|
|
||||||
#### 3-1. template-common 파일 (덮어쓰기)
|
|
||||||
|
|
||||||
**규칙 파일**:
|
|
||||||
```
|
```
|
||||||
.claude/rules/team-policy.md
|
.claude/rules/team-policy.md
|
||||||
.claude/rules/git-workflow.md
|
.claude/rules/git-workflow.md
|
||||||
.claude/rules/release-notes-guide.md
|
.claude/rules/release-notes-guide.md
|
||||||
.claude/rules/subagent-policy.md
|
.claude/rules/subagent-policy.md
|
||||||
|
.claude/rules/code-style.md (타입별)
|
||||||
|
.claude/rules/naming.md (타입별)
|
||||||
|
.claude/rules/testing.md (타입별)
|
||||||
```
|
```
|
||||||
|
|
||||||
**에이전트 파일**:
|
#### 4-1b. 에이전트 파일 (덮어쓰기)
|
||||||
```
|
```
|
||||||
.claude/agents/explorer.md
|
.claude/agents/explorer.md
|
||||||
.claude/agents/implementer.md
|
.claude/agents/implementer.md
|
||||||
.claude/agents/reviewer.md
|
.claude/agents/reviewer.md
|
||||||
```
|
```
|
||||||
|
|
||||||
**스킬 파일 (6종)**:
|
#### 4-2. settings.json (부분 갱신)
|
||||||
```
|
|
||||||
.claude/skills/push/SKILL.md
|
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
|
||||||
.claude/skills/mr/SKILL.md
|
```bash
|
||||||
.claude/skills/create-mr/SKILL.md
|
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json"
|
||||||
.claude/skills/release/SKILL.md
|
|
||||||
.claude/skills/version/SKILL.md
|
|
||||||
.claude/skills/fix-issue/SKILL.md
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hook 스크립트**:
|
다운로드한 최신 settings.json과 로컬 settings.json을 비교하여 부분 갱신:
|
||||||
|
- `env`: 글로벌 최신으로 교체 (CLAUDE_BOT_TOKEN 등 팀 공통 환경변수)
|
||||||
|
- `deny` 목록: 글로벌 최신으로 교체
|
||||||
|
- `allow` 목록: 기존 사용자 커스텀 유지 + 글로벌 기본값 병합
|
||||||
|
- `hooks`: init-project SKILL.md의 hooks JSON 블록을 참조하여 교체 (없으면 추가)
|
||||||
|
- SessionStart(compact) → on-post-compact.sh
|
||||||
|
- PreCompact → on-pre-compact.sh
|
||||||
|
- PostToolUse(Bash) → on-commit.sh
|
||||||
|
|
||||||
|
#### 4-3. 스킬 파일 (덮어쓰기)
|
||||||
|
```
|
||||||
|
.claude/skills/create-mr/SKILL.md
|
||||||
|
.claude/skills/fix-issue/SKILL.md
|
||||||
|
.claude/skills/sync-team-workflow/SKILL.md
|
||||||
|
.claude/skills/init-project/SKILL.md
|
||||||
|
.claude/skills/push/SKILL.md
|
||||||
|
.claude/skills/mr/SKILL.md
|
||||||
|
.claude/skills/release/SKILL.md
|
||||||
|
.claude/skills/version/SKILL.md
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
|
||||||
|
|
||||||
|
`commit-msg`, `post-checkout`은 **항상 팀 표준으로 교체** (팀 커뮤니케이션 규칙 + 인프라).
|
||||||
|
|
||||||
|
`pre-commit`은 `.claude/workflow-version.json`의 `custom_pre_commit` 플래그를 확인:
|
||||||
|
- `"custom_pre_commit": true` → pre-commit 건너뜀 (프로젝트 커스텀 유지), "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
|
||||||
|
- 플래그 없거나 false → 팀 표준으로 교체
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x .githooks/*
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4-5. Hook 스크립트 갱신
|
||||||
|
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
|
||||||
```
|
```
|
||||||
.claude/scripts/on-pre-compact.sh
|
.claude/scripts/on-pre-compact.sh
|
||||||
.claude/scripts/on-post-compact.sh
|
.claude/scripts/on-post-compact.sh
|
||||||
.claude/scripts/on-commit.sh
|
.claude/scripts/on-commit.sh
|
||||||
```
|
```
|
||||||
|
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
|
||||||
|
|
||||||
**Git Hooks** (commit-msg, post-checkout은 항상 교체):
|
### 5. 로컬 버전 업데이트
|
||||||
```
|
`.claude/workflow-version.json` 갱신:
|
||||||
.githooks/commit-msg
|
|
||||||
.githooks/post-checkout
|
|
||||||
```
|
|
||||||
|
|
||||||
다운로드 예시:
|
|
||||||
```bash
|
|
||||||
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/.claude/rules/team-policy.md" -o ".claude/rules/team-policy.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3-2. template-{type} 파일 (타입별 덮어쓰기)
|
|
||||||
|
|
||||||
```
|
|
||||||
.claude/rules/code-style.md
|
|
||||||
.claude/rules/naming.md
|
|
||||||
.claude/rules/testing.md
|
|
||||||
```
|
|
||||||
|
|
||||||
**pre-commit hook**:
|
|
||||||
`.claude/workflow-version.json`의 `custom_pre_commit` 플래그 확인:
|
|
||||||
- `"custom_pre_commit": true` → pre-commit 건너뜀, "⚠️ pre-commit은 프로젝트 커스텀 유지" 로그
|
|
||||||
- 플래그 없거나 false → `.githooks/pre-commit` 교체
|
|
||||||
|
|
||||||
다운로드 예시:
|
|
||||||
```bash
|
|
||||||
curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/rules/code-style.md" -o ".claude/rules/code-style.md"
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3-3. 실행 권한 부여
|
|
||||||
```bash
|
|
||||||
chmod +x .githooks/* 2>/dev/null
|
|
||||||
chmod +x .claude/scripts/*.sh 2>/dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. settings.json 부분 머지
|
|
||||||
|
|
||||||
⚠️ settings.json은 **타입별 템플릿**에서 다운로드 (template-common에는 없음):
|
|
||||||
```bash
|
|
||||||
SERVER_SETTINGS=$(curl -sf "${GITEA_URL}/gc/template-${PROJECT_TYPE}/raw/branch/develop/.claude/settings.json")
|
|
||||||
```
|
|
||||||
|
|
||||||
다운로드한 최신 settings.json과 로컬 `.claude/settings.json`을 비교하여 부분 갱신:
|
|
||||||
- `env`: 서버 최신으로 교체
|
|
||||||
- `deny` 목록: 서버 최신으로 교체
|
|
||||||
- `allow` 목록: 기존 사용자 커스텀 유지 + 서버 기본값 병합
|
|
||||||
- `hooks`: 서버 최신으로 교체
|
|
||||||
|
|
||||||
### 5. workflow-version.json 갱신
|
|
||||||
|
|
||||||
서버의 최신 `workflow-version.json` 조회:
|
|
||||||
```bash
|
|
||||||
SERVER_VER=$(curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json")
|
|
||||||
SERVER_VERSION=$(echo "$SERVER_VER" | python3 -c "import sys,json; print(json.load(sys.stdin).get('version',''))")
|
|
||||||
```
|
|
||||||
|
|
||||||
`.claude/workflow-version.json` 업데이트:
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"applied_global_version": "<서버 version>",
|
"applied_global_version": "새버전",
|
||||||
"applied_date": "<현재날짜>",
|
"applied_date": "오늘날짜",
|
||||||
"project_type": "<프로젝트타입>",
|
"project_type": "감지된타입",
|
||||||
"gitea_url": "<GITEA_URL>"
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
기존 필드(`custom_pre_commit` 등)는 보존.
|
|
||||||
|
|
||||||
### 6. 변경 보고
|
### 6. 변경 보고
|
||||||
|
- `git diff`로 변경 내역 확인
|
||||||
- 다운로드/갱신된 파일 목록 출력
|
- 업데이트된 파일 목록 출력
|
||||||
- 서버 `workflow-version.json`의 `changes` 중 최신 항목 표시
|
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
|
||||||
- 결과 형태:
|
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)
|
||||||
```
|
|
||||||
✅ 팀 워크플로우 동기화 완료
|
|
||||||
버전: v1.6.0
|
|
||||||
갱신 파일: 22개 (rules 7, agents 3, skills 6, scripts 3, hooks 3)
|
|
||||||
settings.json: 부분 갱신 (env, deny, hooks)
|
|
||||||
```
|
|
||||||
|
|
||||||
## 필요 환경변수
|
|
||||||
|
|
||||||
없음 (Gitea raw URL은 인증 불필요)
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"applied_global_version": "1.6.1",
|
"applied_global_version": "1.5.0",
|
||||||
"applied_date": "2026-03-08",
|
"applied_date": "2026-03-01",
|
||||||
"project_type": "java-maven",
|
"project_type": "java-maven",
|
||||||
"gitea_url": "https://gitea.gc-si.dev"
|
"gitea_url": "https://gitea.gc-si.dev"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -109,8 +109,8 @@ jobs:
|
|||||||
echo "--- Starting service ---"
|
echo "--- Starting service ---"
|
||||||
systemctl start signal-batch
|
systemctl start signal-batch
|
||||||
|
|
||||||
# 5단계: 기동 확인 (최대 180초 — 64GB 힙 AlwaysPreTouch + 캐시 워밍업)
|
# 5단계: 기동 확인 (최대 90초 — 64GB 힙 AlwaysPreTouch)
|
||||||
for i in $(seq 1 180); do
|
for i in $(seq 1 90); do
|
||||||
if curl -sf "$BASE_URL/actuator/health/liveness" > /dev/null 2>&1; then
|
if curl -sf "$BASE_URL/actuator/health/liveness" > /dev/null 2>&1; then
|
||||||
echo "Service started successfully (${i}s)"
|
echo "Service started successfully (${i}s)"
|
||||||
curl -s "$BASE_URL/actuator/health"
|
curl -s "$BASE_URL/actuator/health"
|
||||||
|
|||||||
@ -4,139 +4,5 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## [2026-03-27.3]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- 비정상 궤적 포함 저장 플래그 (`include-abnormal-in-tracks`) — 강화학습 데이터 수집용
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- REST API 경로 client_id 수집 누락 수정 — JWT 쿠키 파싱 공용 메서드 추출
|
|
||||||
|
|
||||||
## [2026-03-27.2]
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- Top 클라이언트 IP/ID 토글 활성 상태 구분 및 표시 오류 수정
|
|
||||||
- 쿼리 이력(메트릭 페이지)에 사용자 ID 컬럼 추가
|
|
||||||
|
|
||||||
## [2026-03-27]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- WebSocket 리플레이 쿼리 L1/L2 캐시 통합 — HOURLY/5MIN 구간 DB 의존 제거, 당일 쿼리 100% 캐시
|
|
||||||
- 쿼리 메트릭 사용자 ID 수집 — GC_SESSION JWT에서 인증된 사용자 email 추출
|
|
||||||
- 대시보드 Top 클라이언트 IP/ID 토글 — groupBy 파라미터로 IP 기준 또는 사용자 ID 기준 전환
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- vessel info SQL 컬럼명 오류 수정 (ship_nm → name) — 선박 정보 조회 실패("bad SQL grammar") 해결
|
|
||||||
|
|
||||||
## [2026-03-19]
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- CI/CD 배포 health check 대기 90초→180초 확장 — 64GB 힙 기동 타임아웃 대응
|
|
||||||
|
|
||||||
### 기타
|
### 기타
|
||||||
- AIS API 접속 계정 변경
|
- settings.json에 CLAUDE_BOT_TOKEN 환경변수 추가
|
||||||
|
|
||||||
## [2026-03-18]
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- AIS Import Job 스케줄 :15초→:45초 변경 — API 서버 데이터 적재 타이밍 변경으로 빈 응답(0건) 빈발 대응
|
|
||||||
|
|
||||||
## [2026-03-17]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- 최근 선박 위치 상세 조회 API (`POST /api/v1/vessels/recent-positions-detail`) — 공간 필터(폴리곤/원) + AIS 상세 필드(callSign, status, destination, eta, draught, length, width)
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- AIS API WebClient 버퍼 50MB→100MB 확장 — 피크 시 DataBufferLimitException 대응
|
|
||||||
|
|
||||||
## [2026-03-13]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- 다중구역/STS API 최적화 — AreaSearch/VesselContact 동시성·메모리 관리 통합, 순차 통과 SQL 동적 N-구역(2~10) 확장, chnPrmShipOnly 파라미터 추가
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- 성능 최적화 — ArrayList 사전 할당, JTS Coordinate 재사용, equirectangular 거리 근사, stream→단일 루프 전환
|
|
||||||
- DataPipeline 대시보드 차트 시각화 개선
|
|
||||||
|
|
||||||
## [2026-03-10]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- 쿼리 메트릭 수집 확장 + 대시보드 성능 차트 — client IP 수집(REST/WS), 응답 크기 추정, timeseries API, 대시보드 쿼리 성능 차트 5종(응답시간·볼륨·캐시경로·응답크기·Top 클라이언트)
|
|
||||||
- API/WS 쿼리 메트릭 이력 조회 기능 — BufferService(batch flush) + /history, /summary API + 프론트엔드 요약카드·필터·페이지네이션
|
|
||||||
|
|
||||||
## [2026-03-09]
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- queryWithCache 단일 소스(DB/캐시) 응답 소실 버그 수정 — mergeTracksByVessel() 참조 공유 시 allTracks.clear()로 결과 파괴
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- 운영 로그 레벨 정리 — CACHE-MONITOR 루틴 로그(putAll/get) DEBUG 전환, 중요 이벤트(removeRange/simplify) INFO 유지
|
|
||||||
- Spring Batch/HikariCP 로그 INFO→WARN 하향
|
|
||||||
|
|
||||||
### 기타
|
|
||||||
- t_vessel_tracks_daily 파티션 영구 보존 설정 추가 (기본 3개월→무한)
|
|
||||||
|
|
||||||
## [2026-03-08]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- L3 Daily 캐시 DP(Douglas-Peucker) 사전 간소화 — tolerance 0.001(~100m)로 직선 구간 제거, 방향 변화 보존
|
|
||||||
- Daily 캐시 인메모리 보관 기간 7일→14일 확대 (maxMemory 6→10GB)
|
|
||||||
- 간소화 후 Haversine 기반 속도 재계산 (recalculateSpeeds)
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- Query DataSource: work_mem 256MB + synchronous_commit off 세션 튜닝
|
|
||||||
- Batch DataSource: synchronous_commit off 세션 튜닝
|
|
||||||
|
|
||||||
### 기타
|
|
||||||
- 팀 워크플로우 v1.5.0→v1.6.1 동기화
|
|
||||||
|
|
||||||
## [2026-03-02]
|
|
||||||
|
|
||||||
### 추가
|
|
||||||
- React 19 SPA Dashboard (7페이지: Dashboard, JobMonitor, DataPipeline, AreaStats, ApiExplorer, AbnormalTracks, ApiMetrics)
|
|
||||||
- 다계층 인메모리 캐시(L1/L2/L3) 조회 통합 + CACHE-MONITOR 로그
|
|
||||||
- Ship-GIS 기능 이관 — 최근위치/선박항적/뷰포트 리플레이
|
|
||||||
- 다중구역이동 항적 분석 + STS 접촉 분석 프론트엔드 이관
|
|
||||||
- 구역분석/STS 보고서 모달 + 이미지 저장
|
|
||||||
- 항적/리플레이 선종 아이콘 + Raw Data 패널
|
|
||||||
- DataPipeline 일별 차트 시각화 개선 — Stacked Bar + Duration Bar
|
|
||||||
- ChnPrmShip 전용 DB 이력 + API enrichment + ShipImage V2
|
|
||||||
- 중국허가선박 최신 위치 조회 API
|
|
||||||
- recent-positions IMO 필드 + 선박사진 보유 목록 API + 사진 enrichment
|
|
||||||
- Stale 데이터 비정상 궤적 전환 — 과거 timestamp 수신 시 정보 보존
|
|
||||||
- L1/L2/L3 캐시 O(1) 키 기반 직접 조회 (전체 스캔 O(n) 대체)
|
|
||||||
- 64GB JVM 메모리 예산 논리적 파티셔닝 (캐시 35GB / 쿼리 20GB / 시스템 9GB)
|
|
||||||
- L2 HourlyTrackCache 6시간 경과 엔트리 Nth-point 간소화 스케줄러
|
|
||||||
- 메모리 예산 모니터링 API (`GET /api/monitoring/cache/budget`)
|
|
||||||
|
|
||||||
### 수정
|
|
||||||
- cancelQuery idempotent 처리 — 완료된 쿼리 취소 시 에러 대신 정상 응답
|
|
||||||
- parseTimestamp 실패 로깅 추가, isNightTimeContact 야간 판정 로직 단순화
|
|
||||||
- ST_AsText WKT 공백 불일치로 인한 daily merge 전량 필터 수정
|
|
||||||
- L2 워밍업 범위 확장 — Daily Job 전 기동 시 어제 데이터 포함
|
|
||||||
- html2canvas oklch/oklab 색상 파싱 에러 수정
|
|
||||||
- 항적 조회 500 에러 + 리플레이 쿼리 무반응 수정
|
|
||||||
- shipimg 경로 충돌 수정 — /{imo} 숫자 패턴 제약 추가
|
|
||||||
- UTC 타임존 변환 + Daily 캐시 부분 fallback 추가
|
|
||||||
- V2 캐시 조회 시 누락 MMSI DB fallback 추가
|
|
||||||
- 캐시 maxSize 설정 경로 수정 — application.yml이 실제 소스
|
|
||||||
- 해구 통계 ROUND 함수 타입 캐스팅 오류 수정
|
|
||||||
- 해구 조회 ST_Contains 제거 — 바운딩 박스 조인으로 간소화
|
|
||||||
- Dashboard API 연동 오류 수정 — 캐시 모니터링 + 렌더링 안전성
|
|
||||||
- MonitoringController 레거시 타일 쿼리 → AIS 위치/항적 기반 전환
|
|
||||||
|
|
||||||
### 변경
|
|
||||||
- SignalKindCode 매핑 규칙 개선 — aton/tug/tender→DEFAULT, shipName BUOY 검출 추가
|
|
||||||
- 응답 경로 signal_kind_code 치환 1회화 — 캐시 저장 시 치환, 응답 시 DB/캐시 값 직접 사용
|
|
||||||
- ChunkedTrackStreamingService 전수 최적화 — isQueryCancelled 버그수정, QueryContext 스레드 안전성, 쿼리 메트릭 DB 저장, 데드코드 400줄 삭제, VesselInfo N+1 해소
|
|
||||||
- API 응답 크기 최적화 — gzip 압축, NON_NULL, 정밀도 제한
|
|
||||||
- API 응답 최적화 + 점진적 렌더링 + 해구 choropleth 지도
|
|
||||||
- Hourly Job 인메모리 병합 전환 — N+1 SQL 제거
|
|
||||||
- Daily Job 인메모리 캐시 기반 최적화 — N+1 SQL 제거
|
|
||||||
- L1/L2 캐시 maxSize 실측 기반 상향 (L2 3.5M→7M)
|
|
||||||
- SNP API 전환 및 레거시 코드 전면 정리
|
|
||||||
|
|
||||||
### 기타
|
|
||||||
- Gitea Actions CI/CD 파이프라인 + systemd 서비스 구성
|
|
||||||
- 팀 워크플로우 v1.2.0→v1.5.0 동기화
|
|
||||||
- Swagger UI 현행화 — 서버 URL, DTO @Schema, @Parameter
|
|
||||||
|
|||||||
@ -6,10 +6,6 @@ import type {
|
|||||||
HaeguStat,
|
HaeguStat,
|
||||||
MetricsSummary,
|
MetricsSummary,
|
||||||
ProcessingDelay,
|
ProcessingDelay,
|
||||||
QueryMetricsPage,
|
|
||||||
QueryMetricsParams,
|
|
||||||
QueryMetricsSummary,
|
|
||||||
QueryMetricsTimeSeries,
|
|
||||||
ThroughputMetrics,
|
ThroughputMetrics,
|
||||||
} from './types.ts'
|
} from './types.ts'
|
||||||
|
|
||||||
@ -49,26 +45,4 @@ export const monitorApi = {
|
|||||||
getHaeguStats(): Promise<Record<string, unknown>[]> {
|
getHaeguStats(): Promise<Record<string, unknown>[]> {
|
||||||
return fetchJson('/admin/haegu/stats')
|
return fetchJson('/admin/haegu/stats')
|
||||||
},
|
},
|
||||||
|
|
||||||
getQueryMetricsHistory(params: QueryMetricsParams): Promise<QueryMetricsPage> {
|
|
||||||
const qs = new URLSearchParams()
|
|
||||||
if (params.queryType) qs.set('queryType', params.queryType)
|
|
||||||
if (params.dataPath) qs.set('dataPath', params.dataPath)
|
|
||||||
if (params.status) qs.set('status', params.status)
|
|
||||||
if (params.elapsedMsMin != null) qs.set('elapsedMsMin', String(params.elapsedMsMin))
|
|
||||||
if (params.elapsedMsMax != null) qs.set('elapsedMsMax', String(params.elapsedMsMax))
|
|
||||||
qs.set('page', String(params.page ?? 0))
|
|
||||||
qs.set('size', String(params.size ?? 20))
|
|
||||||
qs.set('sortBy', params.sortBy ?? 'created_at')
|
|
||||||
qs.set('sortDir', params.sortDir ?? 'desc')
|
|
||||||
return fetchJson(`/api/monitoring/query-metrics/history?${qs}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
getQueryMetricsSummary(hours = 24): Promise<QueryMetricsSummary> {
|
|
||||||
return fetchJson(`/api/monitoring/query-metrics/summary?hours=${hours}`)
|
|
||||||
},
|
|
||||||
|
|
||||||
getQueryMetricsTimeSeries(days = 7, groupBy: 'ip' | 'id' = 'ip'): Promise<QueryMetricsTimeSeries> {
|
|
||||||
return fetchJson(`/api/monitoring/query-metrics/timeseries?days=${days}&groupBy=${groupBy}`)
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -187,97 +187,6 @@ export interface ThroughputMetrics {
|
|||||||
partitionSizes: PartitionSize[]
|
partitionSizes: PartitionSize[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Query Metrics (쿼리 이력) */
|
|
||||||
|
|
||||||
export interface QueryMetricRow {
|
|
||||||
query_id: string
|
|
||||||
query_type: string
|
|
||||||
created_at: string
|
|
||||||
data_path: string
|
|
||||||
status: string
|
|
||||||
zoom_level: number | null
|
|
||||||
requested_mmsi: number
|
|
||||||
unique_vessels: number
|
|
||||||
total_points: number
|
|
||||||
points_after_simplify: number
|
|
||||||
total_chunks: number
|
|
||||||
response_bytes: number
|
|
||||||
elapsed_ms: number
|
|
||||||
db_query_ms: number
|
|
||||||
simplify_ms: number
|
|
||||||
cache_hit_days: number
|
|
||||||
db_query_days: number
|
|
||||||
client_ip: string | null
|
|
||||||
client_id: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryMetricsPage {
|
|
||||||
content: QueryMetricRow[]
|
|
||||||
totalElements: number
|
|
||||||
totalPages: number
|
|
||||||
currentPage: number
|
|
||||||
pageSize: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryMetricsSummary {
|
|
||||||
total_queries: number
|
|
||||||
avg_elapsed_ms: number
|
|
||||||
p95_elapsed_ms: number
|
|
||||||
max_elapsed_ms: number
|
|
||||||
ws_count: number
|
|
||||||
rest_count: number
|
|
||||||
cache_only_count: number
|
|
||||||
db_only_count: number
|
|
||||||
hybrid_count: number
|
|
||||||
completed_count: number
|
|
||||||
failed_count: number
|
|
||||||
avg_vessels: number
|
|
||||||
avg_points_before: number
|
|
||||||
avg_points_after: number
|
|
||||||
avg_response_size_bytes: number
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Query Metrics TimeSeries */
|
|
||||||
|
|
||||||
export interface TimeSeriesBucket {
|
|
||||||
bucket: string
|
|
||||||
query_count: number
|
|
||||||
avg_elapsed_ms: number
|
|
||||||
max_elapsed_ms: number
|
|
||||||
avg_response_bytes: number
|
|
||||||
ws_count: number
|
|
||||||
rest_count: number
|
|
||||||
cache_count: number
|
|
||||||
db_count: number
|
|
||||||
hybrid_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TopClient {
|
|
||||||
client: string
|
|
||||||
client_ip?: string
|
|
||||||
query_count: number
|
|
||||||
avg_elapsed_ms: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryMetricsTimeSeries {
|
|
||||||
buckets: TimeSeriesBucket[]
|
|
||||||
topClients: TopClient[]
|
|
||||||
granularity: 'HOURLY' | 'DAILY'
|
|
||||||
groupBy?: 'ip' | 'id'
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QueryMetricsParams {
|
|
||||||
queryType?: string
|
|
||||||
dataPath?: string
|
|
||||||
status?: string
|
|
||||||
elapsedMsMin?: number
|
|
||||||
elapsedMsMax?: number
|
|
||||||
page?: number
|
|
||||||
size?: number
|
|
||||||
sortBy?: string
|
|
||||||
sortDir?: 'asc' | 'desc'
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Monitor — Data Quality */
|
/* Monitor — Data Quality */
|
||||||
|
|
||||||
export interface DataQuality {
|
export interface DataQuality {
|
||||||
|
|||||||
@ -21,7 +21,6 @@ interface LineChartProps {
|
|||||||
xKey: string
|
xKey: string
|
||||||
height?: number
|
height?: number
|
||||||
label?: string
|
label?: string
|
||||||
yFormatter?: (value: number) => string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LineChart({
|
export default function LineChart({
|
||||||
@ -30,7 +29,6 @@ export default function LineChart({
|
|||||||
xKey,
|
xKey,
|
||||||
height = 240,
|
height = 240,
|
||||||
label,
|
label,
|
||||||
yFormatter,
|
|
||||||
}: LineChartProps) {
|
}: LineChartProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -48,7 +46,6 @@ export default function LineChart({
|
|||||||
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
|
tick={{ fontSize: 12, fill: 'var(--sb-text-muted)' }}
|
||||||
axisLine={false}
|
axisLine={false}
|
||||||
tickLine={false}
|
tickLine={false}
|
||||||
tickFormatter={yFormatter}
|
|
||||||
/>
|
/>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
contentStyle={{
|
contentStyle={{
|
||||||
@ -57,7 +54,6 @@ export default function LineChart({
|
|||||||
borderRadius: 'var(--sb-radius)',
|
borderRadius: 'var(--sb-radius)',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
}}
|
}}
|
||||||
formatter={yFormatter ? (v: number) => yFormatter(v) : undefined}
|
|
||||||
/>
|
/>
|
||||||
{series.length > 1 && (
|
{series.length > 1 && (
|
||||||
<Legend
|
<Legend
|
||||||
|
|||||||
@ -16,10 +16,6 @@ interface DataTableProps<T> {
|
|||||||
onRowClick?: (row: T) => void
|
onRowClick?: (row: T) => void
|
||||||
emptyMessage?: string
|
emptyMessage?: string
|
||||||
pageSize?: number
|
pageSize?: number
|
||||||
// Server-side pagination (optional)
|
|
||||||
totalElements?: number
|
|
||||||
currentPage?: number
|
|
||||||
onPageChange?: (page: number) => void
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<T>({
|
export default function DataTable<T>({
|
||||||
@ -29,19 +25,14 @@ export default function DataTable<T>({
|
|||||||
onRowClick,
|
onRowClick,
|
||||||
emptyMessage,
|
emptyMessage,
|
||||||
pageSize = 20,
|
pageSize = 20,
|
||||||
totalElements,
|
|
||||||
currentPage,
|
|
||||||
onPageChange,
|
|
||||||
}: DataTableProps<T>) {
|
}: DataTableProps<T>) {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortAsc, setSortAsc] = useState(true)
|
const [sortAsc, setSortAsc] = useState(true)
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
const isServerSide = totalElements != null && currentPage != null && onPageChange != null
|
|
||||||
|
|
||||||
const sorted = useMemo(() => {
|
const sorted = useMemo(() => {
|
||||||
if (isServerSide || !sortKey) return data
|
if (!sortKey) return data
|
||||||
return [...data].sort((a, b) => {
|
return [...data].sort((a, b) => {
|
||||||
const av = (a as Record<string, unknown>)[sortKey]
|
const av = (a as Record<string, unknown>)[sortKey]
|
||||||
const bv = (b as Record<string, unknown>)[sortKey]
|
const bv = (b as Record<string, unknown>)[sortKey]
|
||||||
@ -49,12 +40,10 @@ export default function DataTable<T>({
|
|||||||
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
const cmp = av < bv ? -1 : av > bv ? 1 : 0
|
||||||
return sortAsc ? cmp : -cmp
|
return sortAsc ? cmp : -cmp
|
||||||
})
|
})
|
||||||
}, [data, sortKey, sortAsc, isServerSide])
|
}, [data, sortKey, sortAsc])
|
||||||
|
|
||||||
const effectivePage = isServerSide ? currentPage! : page
|
const totalPages = Math.ceil(sorted.length / pageSize)
|
||||||
const total = isServerSide ? totalElements! : sorted.length
|
const paged = sorted.slice(page * pageSize, (page + 1) * pageSize)
|
||||||
const totalPages = Math.ceil(total / pageSize)
|
|
||||||
const paged = isServerSide ? sorted : sorted.slice(effectivePage * pageSize, (effectivePage + 1) * pageSize)
|
|
||||||
|
|
||||||
const handleSort = (key: string) => {
|
const handleSort = (key: string) => {
|
||||||
if (sortKey === key) {
|
if (sortKey === key) {
|
||||||
@ -65,14 +54,6 @@ export default function DataTable<T>({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
if (isServerSide) {
|
|
||||||
onPageChange!(newPage)
|
|
||||||
} else {
|
|
||||||
setPage(newPage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="sb-table-wrapper">
|
<div className="sb-table-wrapper">
|
||||||
@ -86,7 +67,7 @@ export default function DataTable<T>({
|
|||||||
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
style={{ textAlign: col.align ?? 'left', cursor: col.sortable !== false ? 'pointer' : 'default' }}
|
||||||
>
|
>
|
||||||
{col.label}
|
{col.label}
|
||||||
{sortKey === col.key && (sortAsc ? ' ▲' : ' ▼')}
|
{sortKey === col.key && (sortAsc ? ' \u25B2' : ' \u25BC')}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
@ -121,19 +102,19 @@ export default function DataTable<T>({
|
|||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
<div className="mt-3 flex items-center justify-between text-sm text-muted">
|
||||||
<span>
|
<span>
|
||||||
{total}{t('common.items')} {t('common.of')} {effectivePage * pageSize + 1}-{Math.min((effectivePage + 1) * pageSize, total)}
|
{sorted.length}{t('common.items')} {t('common.of')} {page * pageSize + 1}-{Math.min((page + 1) * pageSize, sorted.length)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(Math.max(0, effectivePage - 1))}
|
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||||
disabled={effectivePage === 0}
|
disabled={page === 0}
|
||||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{t('common.prev')}
|
{t('common.prev')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePageChange(Math.min(totalPages - 1, effectivePage + 1))}
|
onClick={() => setPage(p => Math.min(totalPages - 1, p + 1))}
|
||||||
disabled={effectivePage >= totalPages - 1}
|
disabled={page >= totalPages - 1}
|
||||||
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
className="rounded border border-border px-2 py-1 disabled:opacity-40"
|
||||||
>
|
>
|
||||||
{t('common.next')}
|
{t('common.next')}
|
||||||
|
|||||||
@ -49,16 +49,6 @@ const en = {
|
|||||||
'dashboard.hits': 'Hits',
|
'dashboard.hits': 'Hits',
|
||||||
'dashboard.misses': 'Misses',
|
'dashboard.misses': 'Misses',
|
||||||
'dashboard.dailyVolume': 'Daily Processing Volume',
|
'dashboard.dailyVolume': 'Daily Processing Volume',
|
||||||
'dashboard.queryPerformance': 'Query Performance',
|
|
||||||
'dashboard.responseTimeTrend': 'Response Time Trend',
|
|
||||||
'dashboard.queryVolume': 'Query Volume',
|
|
||||||
'dashboard.cachePathRatio': 'Cache Path Ratio',
|
|
||||||
'dashboard.responseSizeTrend': 'Response Size Trend',
|
|
||||||
'dashboard.topClients': 'Top Clients',
|
|
||||||
'dashboard.avgElapsed': 'Avg',
|
|
||||||
'dashboard.maxElapsed': 'Max',
|
|
||||||
'dashboard.queries': 'queries',
|
|
||||||
'dashboard.noChartData': 'No chart data available',
|
|
||||||
|
|
||||||
// Job Monitor
|
// Job Monitor
|
||||||
'jobs.title': 'Job Monitor',
|
'jobs.title': 'Job Monitor',
|
||||||
@ -180,26 +170,8 @@ const en = {
|
|||||||
'metrics.cacheHitSummary': 'Cache Hit Summary',
|
'metrics.cacheHitSummary': 'Cache Hit Summary',
|
||||||
'metrics.hits': 'Hits',
|
'metrics.hits': 'Hits',
|
||||||
'metrics.misses': 'Misses',
|
'metrics.misses': 'Misses',
|
||||||
'metrics.queryHistory': 'Query History',
|
'metrics.dbMetricsPlaceholder': 'API/WS History Metrics (Coming Soon)',
|
||||||
'metrics.totalQueries': 'Total Queries',
|
'metrics.dbMetricsDesc': 'REST/WebSocket request history, response sizes, latency DB storage + query',
|
||||||
'metrics.avgElapsed': 'Avg Response',
|
|
||||||
'metrics.p95Elapsed': 'P95 Response',
|
|
||||||
'metrics.cacheHitRate': 'Cache Hit Rate',
|
|
||||||
'metrics.queryType': 'Type',
|
|
||||||
'metrics.dataPath': 'Path',
|
|
||||||
'metrics.queryStatus': 'Status',
|
|
||||||
'metrics.queryTime': 'Time',
|
|
||||||
'metrics.vessels': 'Vessels',
|
|
||||||
'metrics.pointsBefore': 'Points(Before)',
|
|
||||||
'metrics.pointsAfter': 'Points(After)',
|
|
||||||
'metrics.simplification': 'Reduction',
|
|
||||||
'metrics.chunks': 'Chunks',
|
|
||||||
'metrics.elapsed': 'Elapsed',
|
|
||||||
'metrics.allTypes': 'All',
|
|
||||||
'metrics.allPaths': 'All',
|
|
||||||
'metrics.resetFilters': 'Reset Filters',
|
|
||||||
'metrics.responseSize': 'Size',
|
|
||||||
'metrics.clientIp': 'IP',
|
|
||||||
|
|
||||||
// Time Range
|
// Time Range
|
||||||
'range.1d': '1D',
|
'range.1d': '1D',
|
||||||
|
|||||||
@ -49,16 +49,6 @@ const ko = {
|
|||||||
'dashboard.hits': '히트',
|
'dashboard.hits': '히트',
|
||||||
'dashboard.misses': '미스',
|
'dashboard.misses': '미스',
|
||||||
'dashboard.dailyVolume': '일별 처리량',
|
'dashboard.dailyVolume': '일별 처리량',
|
||||||
'dashboard.queryPerformance': '쿼리 성능',
|
|
||||||
'dashboard.responseTimeTrend': '응답시간 추이',
|
|
||||||
'dashboard.queryVolume': '쿼리 볼륨',
|
|
||||||
'dashboard.cachePathRatio': '캐시/경로 비율',
|
|
||||||
'dashboard.responseSizeTrend': '응답 크기 추이',
|
|
||||||
'dashboard.topClients': 'Top 클라이언트',
|
|
||||||
'dashboard.avgElapsed': '평균',
|
|
||||||
'dashboard.maxElapsed': '최대',
|
|
||||||
'dashboard.queries': '건',
|
|
||||||
'dashboard.noChartData': '차트 데이터가 없습니다',
|
|
||||||
|
|
||||||
// Job Monitor
|
// Job Monitor
|
||||||
'jobs.title': 'Job 모니터',
|
'jobs.title': 'Job 모니터',
|
||||||
@ -180,26 +170,8 @@ const ko = {
|
|||||||
'metrics.cacheHitSummary': '캐시 히트 요약',
|
'metrics.cacheHitSummary': '캐시 히트 요약',
|
||||||
'metrics.hits': '히트',
|
'metrics.hits': '히트',
|
||||||
'metrics.misses': '미스',
|
'metrics.misses': '미스',
|
||||||
'metrics.queryHistory': '쿼리 이력',
|
'metrics.dbMetricsPlaceholder': 'API/WS 이력 메트릭 (향후 구현)',
|
||||||
'metrics.totalQueries': '총 쿼리',
|
'metrics.dbMetricsDesc': 'REST/WebSocket 요청 이력, 응답 크기, 소요시간 DB 저장 + 조회',
|
||||||
'metrics.avgElapsed': '평균 응답',
|
|
||||||
'metrics.p95Elapsed': 'P95 응답',
|
|
||||||
'metrics.cacheHitRate': '캐시 적중률',
|
|
||||||
'metrics.queryType': '유형',
|
|
||||||
'metrics.dataPath': '경로',
|
|
||||||
'metrics.queryStatus': '상태',
|
|
||||||
'metrics.queryTime': '시각',
|
|
||||||
'metrics.vessels': '선박',
|
|
||||||
'metrics.pointsBefore': '포인트(전)',
|
|
||||||
'metrics.pointsAfter': '포인트(후)',
|
|
||||||
'metrics.simplification': '간소화',
|
|
||||||
'metrics.chunks': '청크',
|
|
||||||
'metrics.elapsed': '응답시간',
|
|
||||||
'metrics.allTypes': '전체',
|
|
||||||
'metrics.allPaths': '전체',
|
|
||||||
'metrics.resetFilters': '필터 초기화',
|
|
||||||
'metrics.responseSize': '응답 크기',
|
|
||||||
'metrics.clientIp': 'IP',
|
|
||||||
|
|
||||||
// Time Range
|
// Time Range
|
||||||
'range.1d': '1일',
|
'range.1d': '1일',
|
||||||
|
|||||||
@ -1,22 +1,12 @@
|
|||||||
import { useState, useCallback } from 'react'
|
|
||||||
import { usePoller } from '../hooks/usePoller.ts'
|
import { usePoller } from '../hooks/usePoller.ts'
|
||||||
import { useCachedState } from '../hooks/useCachedState.ts'
|
import { useCachedState } from '../hooks/useCachedState.ts'
|
||||||
import { useI18n } from '../hooks/useI18n.ts'
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
import { monitorApi } from '../api/monitorApi.ts'
|
import { monitorApi } from '../api/monitorApi.ts'
|
||||||
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails, QueryMetricsPage, QueryMetricsSummary, QueryMetricsParams, QueryMetricRow } from '../api/types.ts'
|
import type { MetricsSummary, CacheStats, ProcessingDelay, CacheDetails } from '../api/types.ts'
|
||||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||||
import DataTable, { type Column } from '../components/common/DataTable.tsx'
|
import { formatNumber } from '../utils/formatters.ts'
|
||||||
import { formatNumber, formatBytes } from '../utils/formatters.ts'
|
|
||||||
|
|
||||||
const POLL_INTERVAL = 10_000
|
const POLL_INTERVAL = 10_000
|
||||||
const QUERY_POLL_INTERVAL = 30_000
|
|
||||||
|
|
||||||
const ELAPSED_RANGES = [
|
|
||||||
{ label: '< 1s', min: undefined, max: 999 },
|
|
||||||
{ label: '1-5s', min: 1000, max: 5000 },
|
|
||||||
{ label: '5-30s', min: 5000, max: 30000 },
|
|
||||||
{ label: '> 30s', min: 30000, max: undefined },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export default function ApiMetrics() {
|
export default function ApiMetrics() {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@ -25,13 +15,6 @@ export default function ApiMetrics() {
|
|||||||
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
|
const [cacheDetails, setCacheDetails] = useCachedState<CacheDetails | null>('api.cacheDetail', null)
|
||||||
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
|
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('api.delay', null)
|
||||||
|
|
||||||
// Query History state
|
|
||||||
const [filter, setFilter] = useState<QueryMetricsParams>({
|
|
||||||
page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc',
|
|
||||||
})
|
|
||||||
const [historyData, setHistoryData] = useState<QueryMetricsPage | null>(null)
|
|
||||||
const [summaryData, setSummaryData] = useState<QueryMetricsSummary | null>(null)
|
|
||||||
|
|
||||||
usePoller(() => {
|
usePoller(() => {
|
||||||
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
|
monitorApi.getMetricsSummary().then(setMetrics).catch(() => {})
|
||||||
monitorApi.getCacheStats().then(setCache).catch(() => {})
|
monitorApi.getCacheStats().then(setCache).catch(() => {})
|
||||||
@ -39,109 +22,10 @@ export default function ApiMetrics() {
|
|||||||
monitorApi.getDelay().then(setDelay).catch(() => {})
|
monitorApi.getDelay().then(setDelay).catch(() => {})
|
||||||
}, POLL_INTERVAL)
|
}, POLL_INTERVAL)
|
||||||
|
|
||||||
const fetchQueryData = useCallback(() => {
|
|
||||||
monitorApi.getQueryMetricsHistory(filter).then(setHistoryData).catch(() => {})
|
|
||||||
monitorApi.getQueryMetricsSummary(24).then(setSummaryData).catch(() => {})
|
|
||||||
}, [filter])
|
|
||||||
|
|
||||||
usePoller(fetchQueryData, QUERY_POLL_INTERVAL, [filter])
|
|
||||||
|
|
||||||
const updateFilter = (patch: Partial<QueryMetricsParams>) => {
|
|
||||||
setFilter(prev => ({ ...prev, page: 0, ...patch }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetFilters = () => {
|
|
||||||
setFilter({ page: 0, size: 20, sortBy: 'created_at', sortDir: 'desc' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const memUsed = metrics?.memory.used ?? 0
|
const memUsed = metrics?.memory.used ?? 0
|
||||||
const memMax = metrics?.memory.max ?? 1
|
const memMax = metrics?.memory.max ?? 1
|
||||||
const memPct = Math.round((memUsed / memMax) * 100)
|
const memPct = Math.round((memUsed / memMax) * 100)
|
||||||
|
|
||||||
// Summary computed values
|
|
||||||
const totalQueries = summaryData?.total_queries ?? 0
|
|
||||||
const cacheHitRate = totalQueries > 0
|
|
||||||
? ((summaryData?.cache_only_count ?? 0) / totalQueries * 100).toFixed(1)
|
|
||||||
: '0.0'
|
|
||||||
|
|
||||||
const historyColumns: Column<QueryMetricRow>[] = [
|
|
||||||
{
|
|
||||||
key: 'created_at', label: t('metrics.queryTime'), sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
if (!row.created_at) return '-'
|
|
||||||
const d = new Date(row.created_at)
|
|
||||||
// UTC → KST (+9h)
|
|
||||||
const kst = new Date(d.getTime() + 9 * 60 * 60 * 1000)
|
|
||||||
const mm = String(kst.getUTCMonth() + 1).padStart(2, '0')
|
|
||||||
const dd = String(kst.getUTCDate()).padStart(2, '0')
|
|
||||||
const hh = String(kst.getUTCHours()).padStart(2, '0')
|
|
||||||
const mi = String(kst.getUTCMinutes()).padStart(2, '0')
|
|
||||||
const ss = String(kst.getUTCSeconds()).padStart(2, '0')
|
|
||||||
return `${mm}-${dd} ${hh}:${mi}:${ss}`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'query_type', label: t('metrics.queryType'), sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
const isWs = row.query_type === 'WEBSOCKET'
|
|
||||||
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${isWs ? 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300' : 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'}`}>{isWs ? 'WS' : 'REST'}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'data_path', label: t('metrics.dataPath'), sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
const path = row.data_path ?? ''
|
|
||||||
const color = path === 'CACHE' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300'
|
|
||||||
: path === 'DB' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300'
|
|
||||||
: 'bg-violet-100 text-violet-700 dark:bg-violet-900 dark:text-violet-300'
|
|
||||||
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${color}`}>{path}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'status', label: t('metrics.queryStatus'), sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
const ok = row.status === 'COMPLETED'
|
|
||||||
return <span className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${ok ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300' : 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300'}`}>{row.status}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ key: 'unique_vessels', label: t('metrics.vessels'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => formatNumber(row.unique_vessels) },
|
|
||||||
{ key: 'total_points', label: t('metrics.pointsBefore'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => formatNumber(row.total_points) },
|
|
||||||
{ key: 'points_after_simplify', label: t('metrics.pointsAfter'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => formatNumber(row.points_after_simplify) },
|
|
||||||
{
|
|
||||||
key: 'reduction', label: t('metrics.simplification'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
const before = row.total_points || 0
|
|
||||||
const after = row.points_after_simplify || 0
|
|
||||||
if (before === 0) return '-'
|
|
||||||
return `${((1 - after / before) * 100).toFixed(0)}%`
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ key: 'total_chunks', label: t('metrics.chunks'), align: 'right' as const, sortable: false },
|
|
||||||
{
|
|
||||||
key: 'elapsed_ms', label: t('metrics.elapsed'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => {
|
|
||||||
const ms = row.elapsed_ms || 0
|
|
||||||
const color = ms < 1000 ? 'text-success' : ms < 5000 ? 'text-warning' : 'text-danger'
|
|
||||||
return <span className={`font-mono font-medium ${color}`}>{ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`}</span>
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'response_bytes', label: t('metrics.responseSize'), align: 'right' as const, sortable: false,
|
|
||||||
render: (row) => row.response_bytes ? formatBytes(row.response_bytes) : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'client_ip', label: t('metrics.clientIp'), sortable: false,
|
|
||||||
render: (row) => row.client_ip ? <span className="font-mono text-xs">{row.client_ip}</span> : '-',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'client_id', label: 'ID', sortable: false,
|
|
||||||
render: (row) => row.client_id ? <span className="font-mono text-xs">{row.client_id}</span> : '-',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 fade-in">
|
<div className="space-y-6 fade-in">
|
||||||
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
|
<h1 className="text-2xl font-bold">{t('metrics.title')}</h1>
|
||||||
@ -294,114 +178,12 @@ export default function ApiMetrics() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Query History Section */}
|
{/* Placeholder for future DB-based metrics */}
|
||||||
<div className="sb-card">
|
<div className="sb-card border-dashed">
|
||||||
<div className="sb-card-header">{t('metrics.queryHistory')}</div>
|
<div className="py-6 text-center text-sm text-muted">
|
||||||
|
<p>{t('metrics.dbMetricsPlaceholder')}</p>
|
||||||
{/* Summary Cards */}
|
<p className="mt-1 text-xs opacity-60">{t('metrics.dbMetricsDesc')}</p>
|
||||||
<div className="mb-4 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
||||||
<MetricCard
|
|
||||||
title={t('metrics.totalQueries')}
|
|
||||||
value={summaryData ? formatNumber(totalQueries) : '-'}
|
|
||||||
subtitle={summaryData ? `WS:${summaryData.ws_count} / REST:${summaryData.rest_count}` : undefined}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title={t('metrics.avgElapsed')}
|
|
||||||
value={summaryData ? `${((summaryData.avg_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title={t('metrics.p95Elapsed')}
|
|
||||||
value={summaryData ? `${((summaryData.p95_elapsed_ms ?? 0) / 1000).toFixed(1)}s` : '-'}
|
|
||||||
/>
|
|
||||||
<MetricCard
|
|
||||||
title={t('metrics.cacheHitRate')}
|
|
||||||
value={summaryData ? `${cacheHitRate}%` : '-'}
|
|
||||||
subtitle={summaryData ? `C:${summaryData.cache_only_count}/DB:${summaryData.db_only_count}/H:${summaryData.hybrid_count}` : undefined}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm">
|
|
||||||
{/* Query Type toggle */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted mr-1">{t('metrics.queryType')}:</span>
|
|
||||||
{[undefined, 'WEBSOCKET', 'REST_V2'].map((val) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={val ?? 'all'}
|
|
||||||
onClick={() => updateFilter({ queryType: val })}
|
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition ${
|
|
||||||
filter.queryType === val
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val == null ? t('metrics.allTypes') : val === 'WEBSOCKET' ? 'WS' : 'REST'}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Data Path toggle */}
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted mr-1">{t('metrics.dataPath')}:</span>
|
|
||||||
{[undefined, 'CACHE', 'DB', 'HYBRID'].map((val) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={val ?? 'all'}
|
|
||||||
onClick={() => updateFilter({ dataPath: val })}
|
|
||||||
className={`rounded px-2 py-1 text-xs font-medium transition ${
|
|
||||||
filter.dataPath === val
|
|
||||||
? 'bg-primary text-white'
|
|
||||||
: 'bg-surface-secondary text-muted hover:bg-surface-tertiary'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{val ?? t('metrics.allPaths')}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Elapsed Time select */}
|
|
||||||
<select
|
|
||||||
title={t('metrics.elapsed')}
|
|
||||||
value={filter.elapsedMsMin != null ? `${filter.elapsedMsMin}-${filter.elapsedMsMax ?? ''}` : ''}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (!e.target.value) {
|
|
||||||
updateFilter({ elapsedMsMin: undefined, elapsedMsMax: undefined })
|
|
||||||
} else {
|
|
||||||
const range = ELAPSED_RANGES.find(r =>
|
|
||||||
`${r.min ?? ''}-${r.max ?? ''}` === e.target.value
|
|
||||||
)
|
|
||||||
if (range) updateFilter({ elapsedMsMin: range.min, elapsedMsMax: range.max })
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="rounded border border-border bg-surface px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
<option value="">{t('metrics.elapsed')}: {t('metrics.allTypes')}</option>
|
|
||||||
{ELAPSED_RANGES.map((r) => (
|
|
||||||
<option key={r.label} value={`${r.min ?? ''}-${r.max ?? ''}`}>{r.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
|
|
||||||
{/* Reset */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={resetFilters}
|
|
||||||
className="rounded border border-border px-2 py-1 text-xs text-muted hover:bg-surface-secondary"
|
|
||||||
>
|
|
||||||
{t('metrics.resetFilters')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* History Table */}
|
|
||||||
<DataTable<QueryMetricRow>
|
|
||||||
columns={historyColumns}
|
|
||||||
data={historyData?.content ?? []}
|
|
||||||
keyExtractor={(row) => row.query_id}
|
|
||||||
pageSize={filter.size ?? 20}
|
|
||||||
totalElements={historyData?.totalElements}
|
|
||||||
currentPage={historyData?.currentPage}
|
|
||||||
onPageChange={(p) => setFilter(prev => ({ ...prev, page: p }))}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState } from 'react'
|
||||||
import { usePoller } from '../hooks/usePoller.ts'
|
import { usePoller } from '../hooks/usePoller.ts'
|
||||||
import { useCachedState } from '../hooks/useCachedState.ts'
|
import { useCachedState } from '../hooks/useCachedState.ts'
|
||||||
import { useI18n } from '../hooks/useI18n.ts'
|
import { useI18n } from '../hooks/useI18n.ts'
|
||||||
@ -10,13 +10,11 @@ import type {
|
|||||||
DailyStats,
|
DailyStats,
|
||||||
MetricsSummary,
|
MetricsSummary,
|
||||||
ProcessingDelay,
|
ProcessingDelay,
|
||||||
QueryMetricsTimeSeries,
|
|
||||||
RunningJob,
|
RunningJob,
|
||||||
} from '../api/types.ts'
|
} from '../api/types.ts'
|
||||||
import MetricCard from '../components/charts/MetricCard.tsx'
|
import MetricCard from '../components/charts/MetricCard.tsx'
|
||||||
import StatusBadge from '../components/common/StatusBadge.tsx'
|
import StatusBadge from '../components/common/StatusBadge.tsx'
|
||||||
import BarChart from '../components/charts/BarChart.tsx'
|
import BarChart from '../components/charts/BarChart.tsx'
|
||||||
import LineChart from '../components/charts/LineChart.tsx'
|
|
||||||
import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx'
|
import TimeRangeSelector from '../components/common/TimeRangeSelector.tsx'
|
||||||
import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts'
|
import { formatDuration, formatNumber, formatDateTime, formatPercent } from '../utils/formatters.ts'
|
||||||
|
|
||||||
@ -30,20 +28,7 @@ export default function Dashboard() {
|
|||||||
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('dash.delay', null)
|
const [delay, setDelay] = useCachedState<ProcessingDelay | null>('dash.delay', null)
|
||||||
const [daily, setDaily] = useCachedState<DailyStats | null>('dash.daily', null)
|
const [daily, setDaily] = useCachedState<DailyStats | null>('dash.daily', null)
|
||||||
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
|
const [running, setRunning] = useCachedState<RunningJob[]>('dash.running', [])
|
||||||
const [queryTs, setQueryTs] = useCachedState<QueryMetricsTimeSeries | null>('dash.queryTs', null)
|
|
||||||
const [days, setDays] = useState(7)
|
const [days, setDays] = useState(7)
|
||||||
const [clientGroupBy, setClientGroupBy] = useState<'ip' | 'id'>('ip')
|
|
||||||
const [isQueryChartsOpen, setIsQueryChartsOpen] = useState(() =>
|
|
||||||
localStorage.getItem('dashboard-query-charts') !== 'collapsed',
|
|
||||||
)
|
|
||||||
|
|
||||||
const toggleQueryCharts = useCallback(() => {
|
|
||||||
setIsQueryChartsOpen(prev => {
|
|
||||||
const next = !prev
|
|
||||||
localStorage.setItem('dashboard-query-charts', next ? 'expanded' : 'collapsed')
|
|
||||||
return next
|
|
||||||
})
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
usePoller(() => {
|
usePoller(() => {
|
||||||
batchApi.getStatistics(days).then(setStats).catch(() => {})
|
batchApi.getStatistics(days).then(setStats).catch(() => {})
|
||||||
@ -52,8 +37,7 @@ export default function Dashboard() {
|
|||||||
monitorApi.getDelay().then(setDelay).catch(() => {})
|
monitorApi.getDelay().then(setDelay).catch(() => {})
|
||||||
batchApi.getDailyStats().then(setDaily).catch(() => {})
|
batchApi.getDailyStats().then(setDaily).catch(() => {})
|
||||||
batchApi.getRunningJobs().then(setRunning).catch(() => {})
|
batchApi.getRunningJobs().then(setRunning).catch(() => {})
|
||||||
monitorApi.getQueryMetricsTimeSeries(days, clientGroupBy).then(setQueryTs).catch(() => {})
|
}, POLL_INTERVAL, [days])
|
||||||
}, POLL_INTERVAL, [days, clientGroupBy])
|
|
||||||
|
|
||||||
const memUsage = metrics
|
const memUsage = metrics
|
||||||
? Math.round((metrics.memory.used / metrics.memory.max) * 100)
|
? Math.round((metrics.memory.used / metrics.memory.max) * 100)
|
||||||
@ -230,165 +214,6 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Query Performance Charts */}
|
|
||||||
<div className="sb-card">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="sb-card-header flex w-full items-center justify-between cursor-pointer"
|
|
||||||
onClick={toggleQueryCharts}
|
|
||||||
>
|
|
||||||
<span>{t('dashboard.queryPerformance')}</span>
|
|
||||||
<svg
|
|
||||||
className={`h-5 w-5 text-muted transition-transform ${isQueryChartsOpen ? 'rotate-180' : ''}`}
|
|
||||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
|
||||||
>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isQueryChartsOpen && (
|
|
||||||
<div className="space-y-6 pt-2">
|
|
||||||
{queryTs && queryTs.buckets.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{/* Row 1: Response Time + Query Volume */}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<LineChart
|
|
||||||
label={t('dashboard.responseTimeTrend')}
|
|
||||||
data={queryTs.buckets.map(b => ({
|
|
||||||
time: formatBucket(b.bucket, queryTs.granularity),
|
|
||||||
avg: Math.round(b.avg_elapsed_ms),
|
|
||||||
max: Math.round(b.max_elapsed_ms),
|
|
||||||
}))}
|
|
||||||
series={[
|
|
||||||
{ dataKey: 'avg', color: 'var(--sb-primary)', name: t('dashboard.avgElapsed') },
|
|
||||||
{ dataKey: 'max', color: 'var(--sb-danger)', name: t('dashboard.maxElapsed') },
|
|
||||||
]}
|
|
||||||
xKey="time"
|
|
||||||
height={220}
|
|
||||||
yFormatter={v => `${v}ms`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<BarChart
|
|
||||||
label={t('dashboard.queryVolume')}
|
|
||||||
data={queryTs.buckets.map(b => ({
|
|
||||||
time: formatBucket(b.bucket, queryTs.granularity),
|
|
||||||
WS: b.ws_count,
|
|
||||||
REST: b.rest_count,
|
|
||||||
}))}
|
|
||||||
xKey="time"
|
|
||||||
height={220}
|
|
||||||
series={[
|
|
||||||
{ dataKey: 'WS', color: 'var(--sb-primary)', name: 'WebSocket', stackId: 'q' },
|
|
||||||
{ dataKey: 'REST', color: 'var(--sb-success)', name: 'REST', stackId: 'q' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Row 2: Cache Path + Response Size */}
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<BarChart
|
|
||||||
label={t('dashboard.cachePathRatio')}
|
|
||||||
data={queryTs.buckets.map(b => ({
|
|
||||||
time: formatBucket(b.bucket, queryTs.granularity),
|
|
||||||
Cache: b.cache_count,
|
|
||||||
DB: b.db_count,
|
|
||||||
Hybrid: b.hybrid_count,
|
|
||||||
}))}
|
|
||||||
xKey="time"
|
|
||||||
height={220}
|
|
||||||
series={[
|
|
||||||
{ dataKey: 'Cache', color: 'var(--sb-success)', stackId: 'p' },
|
|
||||||
{ dataKey: 'DB', color: 'var(--sb-warning)', stackId: 'p' },
|
|
||||||
{ dataKey: 'Hybrid', color: 'var(--sb-primary)', stackId: 'p' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<LineChart
|
|
||||||
label={t('dashboard.responseSizeTrend')}
|
|
||||||
data={queryTs.buckets.map(b => ({
|
|
||||||
time: formatBucket(b.bucket, queryTs.granularity),
|
|
||||||
size: Math.round(b.avg_response_bytes / 1024),
|
|
||||||
}))}
|
|
||||||
series={[
|
|
||||||
{ dataKey: 'size', color: 'var(--sb-primary)', name: 'KB' },
|
|
||||||
]}
|
|
||||||
xKey="time"
|
|
||||||
height={220}
|
|
||||||
yFormatter={v => `${v}KB`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Top Clients */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-muted">{t('dashboard.topClients')}</span>
|
|
||||||
<div className="flex overflow-hidden rounded-md border border-[var(--border-primary)] text-xs">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 transition-colors ${clientGroupBy === 'ip' ? 'bg-[var(--accent-primary)] text-white font-medium' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]'}`}
|
|
||||||
onClick={() => setClientGroupBy('ip')}
|
|
||||||
>IP</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`px-2 py-0.5 transition-colors ${clientGroupBy === 'id' ? 'bg-[var(--accent-primary)] text-white font-medium' : 'bg-[var(--bg-secondary)] text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]'}`}
|
|
||||||
onClick={() => setClientGroupBy('id')}
|
|
||||||
>ID</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{queryTs.topClients.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{queryTs.topClients.map((c, i) => {
|
|
||||||
const maxCount = queryTs.topClients[0].query_count
|
|
||||||
const pct = maxCount > 0 ? (c.query_count / maxCount) * 100 : 0
|
|
||||||
const label = c.client ?? c.client_ip ?? '-'
|
|
||||||
return (
|
|
||||||
<div key={label + i} className="flex items-center gap-3 text-sm">
|
|
||||||
<span className="w-40 truncate font-mono text-xs" title={label}>{label}</span>
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="h-4 rounded bg-surface-hover">
|
|
||||||
<div
|
|
||||||
className="h-4 rounded bg-primary"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="w-20 text-right text-xs text-muted">
|
|
||||||
{c.query_count}{t('dashboard.queries')} · {Math.round(c.avg_elapsed_ms)}ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="py-4 text-center text-xs text-muted">
|
|
||||||
{clientGroupBy === 'id' ? '사용자 ID 데이터가 없습니다' : '클라이언트 데이터가 없습니다'}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="py-8 text-center text-sm text-muted">{t('dashboard.noChartData')}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBucket(bucket: string, granularity: 'HOURLY' | 'DAILY'): string {
|
|
||||||
if (granularity === 'HOURLY') {
|
|
||||||
// "2026-03-10T14:00:00" → "14:00"
|
|
||||||
const timePart = bucket.includes('T') ? bucket.split('T')[1] : bucket
|
|
||||||
return timePart.slice(0, 5)
|
|
||||||
}
|
|
||||||
// "2026-03-10" → "03-10"
|
|
||||||
return bucket.slice(5, 10)
|
|
||||||
}
|
|
||||||
|
|||||||
@ -62,9 +62,6 @@ public class DailyAggregationStepConfig {
|
|||||||
@Value("${vessel.batch.chunk-size:5000}")
|
@Value("${vessel.batch.chunk-size:5000}")
|
||||||
private int chunkSize;
|
private int chunkSize;
|
||||||
|
|
||||||
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
|
|
||||||
private boolean includeAbnormalInTracks;
|
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public Step mergeDailyTracksStep() {
|
public Step mergeDailyTracksStep() {
|
||||||
log.info("Building mergeDailyTracksStep with cache-based in-memory merge");
|
log.info("Building mergeDailyTracksStep with cache-based in-memory merge");
|
||||||
@ -113,9 +110,7 @@ public class DailyAggregationStepConfig {
|
|||||||
return new CompositeTrackWriter(
|
return new CompositeTrackWriter(
|
||||||
vesselTrackBulkWriter,
|
vesselTrackBulkWriter,
|
||||||
abnormalTrackWriter,
|
abnormalTrackWriter,
|
||||||
"daily",
|
"daily"
|
||||||
null,
|
|
||||||
includeAbnormalInTracks
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -69,9 +69,6 @@ public class HourlyAggregationStepConfig {
|
|||||||
@Value("${vessel.batch.chunk-size:5000}")
|
@Value("${vessel.batch.chunk-size:5000}")
|
||||||
private int chunkSize;
|
private int chunkSize;
|
||||||
|
|
||||||
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
|
|
||||||
private boolean includeAbnormalInTracks;
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Step 1: 5분 → 시간 병합 (인메모리 캐시 기반)
|
// Step 1: 5분 → 시간 병합 (인메모리 캐시 기반)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@ -125,8 +122,7 @@ public class HourlyAggregationStepConfig {
|
|||||||
vesselTrackBulkWriter,
|
vesselTrackBulkWriter,
|
||||||
abnormalTrackWriter,
|
abnormalTrackWriter,
|
||||||
"hourly",
|
"hourly",
|
||||||
hourlyTrackCache,
|
hourlyTrackCache
|
||||||
includeAbnormalInTracks
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -97,10 +97,10 @@ public class VesselBatchScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* S&P AIS API 수집 (매 1분 45초)
|
* S&P AIS API 수집 (매 1분 15초)
|
||||||
* API 서버 데이터 적재 완료 후 안정 구간(:45초~)에 요청
|
* 캐시에 최신 위치 저장 → 5분 집계 Job에서 활용
|
||||||
*/
|
*/
|
||||||
@Scheduled(cron = "45 * * * * *")
|
@Scheduled(cron = "15 * * * * *")
|
||||||
public void runAisTargetImport() {
|
public void runAisTargetImport() {
|
||||||
if (!schedulerEnabled || shutdownRequested || aisTargetImportJob == null) {
|
if (!schedulerEnabled || shutdownRequested || aisTargetImportJob == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@ -96,9 +96,6 @@ public class VesselTrackStepConfig {
|
|||||||
@Value("${vessel.batch.chunk-size:1000}")
|
@Value("${vessel.batch.chunk-size:1000}")
|
||||||
private int chunkSize;
|
private int chunkSize;
|
||||||
|
|
||||||
@Value("${vessel.batch.track.include-abnormal-in-tracks:false}")
|
|
||||||
private boolean includeAbnormalInTracks;
|
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
// 5분 Job의 이름을 명시적으로 설정
|
// 5분 Job의 이름을 명시적으로 설정
|
||||||
@ -206,21 +203,18 @@ public class VesselTrackStepConfig {
|
|||||||
log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}",
|
log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}",
|
||||||
abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm());
|
abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm());
|
||||||
saveAbnormalTrack(track, abnormalReason);
|
saveAbnormalTrack(track, abnormalReason);
|
||||||
if (includeAbnormalInTracks) {
|
|
||||||
filteredTracks.add(track); // 플래그 true → 정상 테이블+캐시에도 포함
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
filteredTracks.add(track);
|
filteredTracks.add(track);
|
||||||
}
|
|
||||||
|
|
||||||
// 궤적의 종료 위치 저장 (캐시 업데이트용) — 비정상 포함 시에도 위치 추적
|
// 정상 궤적의 종료 위치 저장 (캐시 업데이트용)
|
||||||
if (filteredTracks.contains(track) && track.getEndPosition() != null) {
|
if (track.getEndPosition() != null) {
|
||||||
currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder()
|
currentBucketEndPositions.put(track.getMmsi(), VesselBucketPositionDto.builder()
|
||||||
.mmsi(track.getMmsi())
|
.mmsi(track.getMmsi())
|
||||||
.endLon(track.getEndPosition().getLon())
|
.endLon(track.getEndPosition().getLon())
|
||||||
.endLat(track.getEndPosition().getLat())
|
.endLat(track.getEndPosition().getLat())
|
||||||
.endTime(track.getEndPosition().getTime())
|
.endTime(track.getEndPosition().getTime())
|
||||||
.build());
|
.build());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,7 @@ public class AisTargetCacheManager {
|
|||||||
@Value("${app.cache.ais-target.ttl-minutes:120}")
|
@Value("${app.cache.ais-target.ttl-minutes:120}")
|
||||||
private long ttlMinutes;
|
private long ttlMinutes;
|
||||||
|
|
||||||
@Value("${app.cache.ais-target.max-size:500000}")
|
@Value("${app.cache.ais-target.max-size:300000}")
|
||||||
private int maxSize;
|
private int maxSize;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
|
|||||||
@ -107,7 +107,7 @@ public class ChnPrmShipCacheWarmer implements ApplicationRunner {
|
|||||||
entities.forEach(entity -> {
|
entities.forEach(entity -> {
|
||||||
if (entity.getSignalKindCode() == null) {
|
if (entity.getSignalKindCode() == null) {
|
||||||
SignalKindCode kindCode = SignalKindCode.resolve(
|
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||||
entity.getVesselType(), entity.getExtraInfo(), entity.getName());
|
entity.getVesselType(), entity.getExtraInfo());
|
||||||
entity.setSignalKindCode(kindCode.getCode());
|
entity.setSignalKindCode(kindCode.getCode());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -60,7 +60,7 @@ public class FiveMinTrackCache {
|
|||||||
for (VesselTrack track : tracks) {
|
for (VesselTrack track : tracks) {
|
||||||
put(track);
|
put(track);
|
||||||
}
|
}
|
||||||
log.debug("[CACHE-MONITOR] L1.putAll: input={}, cacheBefore={}, cacheAfter={}, stats=[{}]",
|
log.info("[CACHE-MONITOR] L1.putAll: input={}, cacheBefore={}, cacheAfter={}, stats=[{}]",
|
||||||
tracks.size(), beforeSize, cache.estimatedSize(), getStats());
|
tracks.size(), beforeSize, cache.estimatedSize(), getStats());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -89,55 +89,11 @@ public class FiveMinTrackCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
||||||
log.debug("[CACHE-MONITOR] L1.getTracksInRange [{}, {}): mmsi={}, tracks={}, cacheTotal={}",
|
log.info("[CACHE-MONITOR] L1.getTracksInRange [{}, {}): mmsi={}, tracks={}, cacheTotal={}",
|
||||||
start, end, result.size(), totalTracks, cache.estimatedSize());
|
start, end, result.size(), totalTracks, cache.estimatedSize());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청된 MMSI 키로 직접 O(1) 조회 — mmsi×5minBucket 조합으로 Caffeine getIfPresent() 호출
|
|
||||||
* 기존 getTracksInRange()의 전체 스캔(O(n)) 대비 대폭 성능 개선.
|
|
||||||
* 예: 1시간 × 100 MMSI = 1,200회 get() vs 최대 1.5M 엔트리 스캔
|
|
||||||
*/
|
|
||||||
public Map<String, List<VesselTrack>> getTracksForVessels(
|
|
||||||
LocalDateTime start, LocalDateTime end, Set<String> mmsiKeys) {
|
|
||||||
if (mmsiKeys == null || mmsiKeys.isEmpty()) {
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, List<VesselTrack>> result = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
// 5분 단위 버킷 정렬 (start를 가장 가까운 5분 바닥으로 정렬)
|
|
||||||
int startMinute = (start.getMinute() / 5) * 5;
|
|
||||||
LocalDateTime bucket = start.withMinute(startMinute).withSecond(0).withNano(0);
|
|
||||||
|
|
||||||
int lookupCount = 0;
|
|
||||||
int hitCount = 0;
|
|
||||||
|
|
||||||
while (!bucket.isAfter(end) && bucket.isBefore(end)) {
|
|
||||||
for (String mmsi : mmsiKeys) {
|
|
||||||
String key = buildKey(mmsi, bucket);
|
|
||||||
VesselTrack track = cache.getIfPresent(key);
|
|
||||||
lookupCount++;
|
|
||||||
if (track != null) {
|
|
||||||
result.computeIfAbsent(mmsi, k -> new ArrayList<>()).add(track);
|
|
||||||
hitCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bucket = bucket.plusMinutes(5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MMSI별 시간순 정렬
|
|
||||||
for (List<VesselTrack> tracks : result.values()) {
|
|
||||||
tracks.sort(Comparator.comparing(VesselTrack::getTimeBucket));
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
|
||||||
log.debug("[CACHE-MONITOR] L1.getTracksForVessels [{}, {}): requestedMmsi={}, lookups={}, hits={}, resultMmsi={}, tracks={}",
|
|
||||||
start, end, mmsiKeys.size(), lookupCount, hitCount, result.size(), totalTracks);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지정 시간 범위의 캐시 항목 제거 (hourly merge 완료 후 호출)
|
* 지정 시간 범위의 캐시 항목 제거 (hourly merge 완료 후 호출)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
|
|||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -32,9 +31,6 @@ public class HourlyTrackCache {
|
|||||||
|
|
||||||
private Cache<String, VesselTrack> cache;
|
private Cache<String, VesselTrack> cache;
|
||||||
|
|
||||||
// 간소화 완료 추적 (시간 버킷 단위, 중복 간소화 방지)
|
|
||||||
private final Set<LocalDateTime> simplifiedBuckets = ConcurrentHashMap.newKeySet();
|
|
||||||
|
|
||||||
@Value("${app.cache.hourly-track.ttl-hours:26}")
|
@Value("${app.cache.hourly-track.ttl-hours:26}")
|
||||||
private long ttlHours;
|
private long ttlHours;
|
||||||
|
|
||||||
@ -64,7 +60,7 @@ public class HourlyTrackCache {
|
|||||||
for (VesselTrack track : tracks) {
|
for (VesselTrack track : tracks) {
|
||||||
put(track);
|
put(track);
|
||||||
}
|
}
|
||||||
log.debug("[CACHE-MONITOR] L2.putAll: input={}, cacheBefore={}, cacheAfter={}, stats=[{}]",
|
log.info("[CACHE-MONITOR] L2.putAll: input={}, cacheBefore={}, cacheAfter={}, stats=[{}]",
|
||||||
tracks.size(), beforeSize, cache.estimatedSize(), getStats());
|
tracks.size(), beforeSize, cache.estimatedSize(), getStats());
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -92,52 +88,11 @@ public class HourlyTrackCache {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
||||||
log.debug("[CACHE-MONITOR] L2.getTracksInRange [{}, {}): mmsi={}, tracks={}, cacheTotal={}",
|
log.info("[CACHE-MONITOR] L2.getTracksInRange [{}, {}): mmsi={}, tracks={}, cacheTotal={}",
|
||||||
start, end, result.size(), totalTracks, cache.estimatedSize());
|
start, end, result.size(), totalTracks, cache.estimatedSize());
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청된 MMSI 키로 직접 O(1) 조회 — mmsi×hourBucket 조합으로 Caffeine getIfPresent() 호출
|
|
||||||
* 기존 getTracksInRange()의 전체 스캔(O(n)) 대비 대폭 성능 개선.
|
|
||||||
* 예: 24시간 × 100 MMSI = 2,400회 get() vs 최대 7M 엔트리 스캔
|
|
||||||
*/
|
|
||||||
public Map<String, List<VesselTrack>> getTracksForVessels(
|
|
||||||
LocalDateTime start, LocalDateTime end, Set<String> mmsiKeys) {
|
|
||||||
if (mmsiKeys == null || mmsiKeys.isEmpty()) {
|
|
||||||
return Collections.emptyMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, List<VesselTrack>> result = new LinkedHashMap<>();
|
|
||||||
LocalDateTime bucket = start.withMinute(0).withSecond(0).withNano(0);
|
|
||||||
|
|
||||||
int lookupCount = 0;
|
|
||||||
int hitCount = 0;
|
|
||||||
|
|
||||||
while (!bucket.isAfter(end) && bucket.isBefore(end)) {
|
|
||||||
for (String mmsi : mmsiKeys) {
|
|
||||||
String key = buildKey(mmsi, bucket);
|
|
||||||
VesselTrack track = cache.getIfPresent(key);
|
|
||||||
lookupCount++;
|
|
||||||
if (track != null) {
|
|
||||||
result.computeIfAbsent(mmsi, k -> new ArrayList<>()).add(track);
|
|
||||||
hitCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bucket = bucket.plusHours(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// MMSI별 시간순 정렬
|
|
||||||
for (List<VesselTrack> tracks : result.values()) {
|
|
||||||
tracks.sort(Comparator.comparing(VesselTrack::getTimeBucket));
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalTracks = result.values().stream().mapToInt(List::size).sum();
|
|
||||||
log.debug("[CACHE-MONITOR] L2.getTracksForVessels [{}, {}): requestedMmsi={}, lookups={}, hits={}, resultMmsi={}, tracks={}",
|
|
||||||
start, end, mmsiKeys.size(), lookupCount, hitCount, result.size(), totalTracks);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 지정 시간 범위의 캐시 항목 제거 (daily merge 완료 후 호출)
|
* 지정 시간 범위의 캐시 항목 제거 (daily merge 완료 후 호출)
|
||||||
*/
|
*/
|
||||||
@ -154,74 +109,6 @@ public class HourlyTrackCache {
|
|||||||
start, end, before - after, before, after, getStats());
|
start, end, before - after, before, after, getStats());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 6시간 이상 경과한 캐시 엔트리의 WKT LineStringM을 간소화.
|
|
||||||
* 매 sampleRate번째 포인트만 유지 (첫/마지막 항상 보존).
|
|
||||||
* 이미 간소화된 시간 버킷은 스킵하여 중복 간소화 방지.
|
|
||||||
*
|
|
||||||
* @param hoursAgo 간소화 대상 경과 시간 (시)
|
|
||||||
* @param sampleRate 샘플링 비율 (2 = 매 2번째 포인트만 유지 → ~50% 감소)
|
|
||||||
* @return 간소화된 엔트리 수
|
|
||||||
*/
|
|
||||||
public int simplifyOlderThan(int hoursAgo, int sampleRate) {
|
|
||||||
LocalDateTime threshold = LocalDateTime.now().minusHours(hoursAgo);
|
|
||||||
int simplified = 0;
|
|
||||||
int totalOriginal = 0;
|
|
||||||
int totalAfter = 0;
|
|
||||||
int skipped = 0;
|
|
||||||
|
|
||||||
for (Map.Entry<String, VesselTrack> entry : cache.asMap().entrySet()) {
|
|
||||||
VesselTrack track = entry.getValue();
|
|
||||||
if (track.getTimeBucket() == null || !track.getTimeBucket().isBefore(threshold)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 이미 간소화된 시간 버킷이면 스킵
|
|
||||||
if (simplifiedBuckets.contains(track.getTimeBucket())) {
|
|
||||||
skipped++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String wkt = track.getTrackGeom();
|
|
||||||
if (wkt == null || track.getPointCount() == null || track.getPointCount() <= 3) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
int originalCount = track.getPointCount();
|
|
||||||
String simplifiedWkt = simplifyLineStringM(wkt, sampleRate);
|
|
||||||
if (simplifiedWkt != null && !simplifiedWkt.equals(wkt)) {
|
|
||||||
track.setTrackGeom(simplifiedWkt);
|
|
||||||
int newCount = countWktPoints(simplifiedWkt);
|
|
||||||
totalOriginal += originalCount;
|
|
||||||
totalAfter += newCount;
|
|
||||||
track.setPointCount(newCount);
|
|
||||||
simplified++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 간소화 완료된 시간 버킷 기록 (threshold 이전 모든 정각 버킷)
|
|
||||||
LocalDateTime bucket = threshold.withMinute(0).withSecond(0).withNano(0);
|
|
||||||
LocalDateTime oldest = LocalDateTime.now().minusHours(ttlHours + 1);
|
|
||||||
while (!bucket.isBefore(oldest)) {
|
|
||||||
simplifiedBuckets.add(bucket);
|
|
||||||
bucket = bucket.minusHours(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 만료된 버킷 추적 정리
|
|
||||||
simplifiedBuckets.removeIf(b -> b.isBefore(oldest));
|
|
||||||
|
|
||||||
if (simplified > 0) {
|
|
||||||
double reduction = totalOriginal > 0 ? (1 - (double) totalAfter / totalOriginal) * 100 : 0;
|
|
||||||
log.info("[CACHE-SIMPLIFY] L2 간소화: entries={}, skipped={}, points {} -> {} ({}% 감소), threshold={}h",
|
|
||||||
simplified, skipped, totalOriginal, totalAfter,
|
|
||||||
String.format("%.1f", reduction), hoursAgo);
|
|
||||||
} else {
|
|
||||||
log.debug("[CACHE-SIMPLIFY] L2 간소화 대상 없음: skipped={}, threshold={}h", skipped, hoursAgo);
|
|
||||||
}
|
|
||||||
|
|
||||||
return simplified;
|
|
||||||
}
|
|
||||||
|
|
||||||
public long size() {
|
public long size() {
|
||||||
return cache.estimatedSize();
|
return cache.estimatedSize();
|
||||||
}
|
}
|
||||||
@ -249,48 +136,4 @@ public class HourlyTrackCache {
|
|||||||
private String buildKey(String mmsi, LocalDateTime timeBucket) {
|
private String buildKey(String mmsi, LocalDateTime timeBucket) {
|
||||||
return mmsi + "::" + timeBucket.format(KEY_FORMATTER);
|
return mmsi + "::" + timeBucket.format(KEY_FORMATTER);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* WKT LineStringM에서 매 sampleRate번째 포인트만 유지.
|
|
||||||
* 첫 포인트와 마지막 포인트는 항상 보존.
|
|
||||||
*
|
|
||||||
* 입력 형식: "LINESTRING M(lon1 lat1 m1,lon2 lat2 m2,...)"
|
|
||||||
* 또는 "LINESTRINGM(lon1 lat1 m1,lon2 lat2 m2,...)"
|
|
||||||
*/
|
|
||||||
static String simplifyLineStringM(String wkt, int sampleRate) {
|
|
||||||
if (wkt == null || sampleRate <= 1) return wkt;
|
|
||||||
|
|
||||||
int openParen = wkt.indexOf('(');
|
|
||||||
int closeParen = wkt.lastIndexOf(')');
|
|
||||||
if (openParen < 0 || closeParen < 0 || closeParen <= openParen + 1) return wkt;
|
|
||||||
|
|
||||||
String prefix = wkt.substring(0, openParen + 1);
|
|
||||||
String coords = wkt.substring(openParen + 1, closeParen);
|
|
||||||
|
|
||||||
String[] points = coords.split(",");
|
|
||||||
if (points.length <= 3) return wkt;
|
|
||||||
|
|
||||||
StringBuilder sb = new StringBuilder(prefix);
|
|
||||||
for (int i = 0; i < points.length; i++) {
|
|
||||||
if (i == 0 || i == points.length - 1 || i % sampleRate == 0) {
|
|
||||||
if (sb.length() > prefix.length()) {
|
|
||||||
sb.append(',');
|
|
||||||
}
|
|
||||||
sb.append(points[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.append(')');
|
|
||||||
|
|
||||||
return sb.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
static int countWktPoints(String wkt) {
|
|
||||||
if (wkt == null) return 0;
|
|
||||||
int openParen = wkt.indexOf('(');
|
|
||||||
int closeParen = wkt.lastIndexOf(')');
|
|
||||||
if (openParen < 0 || closeParen < 0 || closeParen <= openParen + 1) return 0;
|
|
||||||
String coords = wkt.substring(openParen + 1, closeParen);
|
|
||||||
if (coords.isBlank()) return 0;
|
|
||||||
return coords.split(",").length;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,46 +0,0 @@
|
|||||||
package gc.mda.signal_batch.batch.reader;
|
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* L2 HourlyTrackCache 간소화 스케줄러
|
|
||||||
*
|
|
||||||
* 6시간 이상 경과한 캐시 엔트리의 WKT LineStringM을 Nth-point 샘플링으로 간소화.
|
|
||||||
* 기본 스케줄: 06:30, 12:30, 18:30 (1일 3회)
|
|
||||||
*
|
|
||||||
* 간소화 효과: sampleRate=2 기준 ~50% 포인트 감소 → L2 메모리 절약
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Component
|
|
||||||
@ConditionalOnProperty(name = "vessel.batch.cache.hourly-simplification.enabled", havingValue = "true")
|
|
||||||
public class HourlyTrackSimplifier {
|
|
||||||
|
|
||||||
private final HourlyTrackCache hourlyTrackCache;
|
|
||||||
|
|
||||||
@Value("${vessel.batch.cache.hourly-simplification.hours-ago:6}")
|
|
||||||
private int hoursAgo;
|
|
||||||
|
|
||||||
@Value("${vessel.batch.cache.hourly-simplification.sample-rate:2}")
|
|
||||||
private int sampleRate;
|
|
||||||
|
|
||||||
public HourlyTrackSimplifier(HourlyTrackCache hourlyTrackCache) {
|
|
||||||
this.hourlyTrackCache = hourlyTrackCache;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Scheduled(cron = "${vessel.batch.cache.hourly-simplification.cron:0 30 6,12,18 * * *}")
|
|
||||||
public void scheduledSimplification() {
|
|
||||||
log.info("[HourlySimplifier] 스케줄 간소화 시작 — hoursAgo={}, sampleRate={}, cacheSize={}",
|
|
||||||
hoursAgo, sampleRate, hourlyTrackCache.size());
|
|
||||||
long start = System.currentTimeMillis();
|
|
||||||
|
|
||||||
int simplified = hourlyTrackCache.simplifyOlderThan(hoursAgo, sampleRate);
|
|
||||||
|
|
||||||
long elapsed = System.currentTimeMillis() - start;
|
|
||||||
log.info("[HourlySimplifier] 스케줄 간소화 완료 — simplified={}, elapsed={}ms, cacheSize={}",
|
|
||||||
simplified, elapsed, hourlyTrackCache.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -35,10 +35,9 @@ public class AisTargetCacheWriter implements ItemWriter<AisTargetEntity> {
|
|||||||
List<? extends AisTargetEntity> items = chunk.getItems();
|
List<? extends AisTargetEntity> items = chunk.getItems();
|
||||||
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
|
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
|
||||||
|
|
||||||
// 1. SignalKindCode 치환 (vesselType + extraInfo + shipName 기반, 캐시 저장 시 1회만)
|
// 1. SignalKindCode 치환
|
||||||
items.forEach(item -> {
|
items.forEach(item -> {
|
||||||
SignalKindCode kindCode = SignalKindCode.resolve(
|
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
|
||||||
item.getVesselType(), item.getExtraInfo(), item.getName());
|
|
||||||
item.setSignalKindCode(kindCode.getCode());
|
item.setSignalKindCode(kindCode.getCode());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -25,24 +25,21 @@ public class CompositeTrackWriter implements ItemWriter<AbnormalDetectionResult>
|
|||||||
private final AbnormalTrackWriter abnormalTrackWriter;
|
private final AbnormalTrackWriter abnormalTrackWriter;
|
||||||
private final String targetTable;
|
private final String targetTable;
|
||||||
private final HourlyTrackCache hourlyTrackCache; // nullable (daily writer는 미사용)
|
private final HourlyTrackCache hourlyTrackCache; // nullable (daily writer는 미사용)
|
||||||
private final boolean includeAbnormalInTracks;
|
|
||||||
|
|
||||||
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
|
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
|
||||||
AbnormalTrackWriter abnormalTrackWriter,
|
AbnormalTrackWriter abnormalTrackWriter,
|
||||||
String targetTable,
|
String targetTable,
|
||||||
HourlyTrackCache hourlyTrackCache,
|
HourlyTrackCache hourlyTrackCache) {
|
||||||
boolean includeAbnormalInTracks) {
|
|
||||||
this.vesselTrackBulkWriter = vesselTrackBulkWriter;
|
this.vesselTrackBulkWriter = vesselTrackBulkWriter;
|
||||||
this.abnormalTrackWriter = abnormalTrackWriter;
|
this.abnormalTrackWriter = abnormalTrackWriter;
|
||||||
this.targetTable = targetTable;
|
this.targetTable = targetTable;
|
||||||
this.hourlyTrackCache = hourlyTrackCache;
|
this.hourlyTrackCache = hourlyTrackCache;
|
||||||
this.includeAbnormalInTracks = includeAbnormalInTracks;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
|
public CompositeTrackWriter(VesselTrackBulkWriter vesselTrackBulkWriter,
|
||||||
AbnormalTrackWriter abnormalTrackWriter,
|
AbnormalTrackWriter abnormalTrackWriter,
|
||||||
String targetTable) {
|
String targetTable) {
|
||||||
this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null, false);
|
this(vesselTrackBulkWriter, abnormalTrackWriter, targetTable, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@BeforeStep
|
@BeforeStep
|
||||||
@ -69,11 +66,9 @@ public class CompositeTrackWriter implements ItemWriter<AbnormalDetectionResult>
|
|||||||
abnormalResults.add(result);
|
abnormalResults.add(result);
|
||||||
|
|
||||||
// 정정된 궤적이 있으면 정상 궤적으로 저장
|
// 정정된 궤적이 있으면 정상 궤적으로 저장
|
||||||
// null이면 전체 궤적이 비정상이므로 제외 (플래그 true면 원본 포함)
|
// null이면 전체 궤적이 비정상이므로 제외
|
||||||
if (result.getCorrectedTrack() != null) {
|
if (result.getCorrectedTrack() != null) {
|
||||||
normalTracks.add(result.getCorrectedTrack());
|
normalTracks.add(result.getCorrectedTrack());
|
||||||
} else if (includeAbnormalInTracks) {
|
|
||||||
normalTracks.add(result.getOriginalTrack());
|
|
||||||
} else {
|
} else {
|
||||||
log.debug("비정상 궤적 전체 제외: vessel={}",
|
log.debug("비정상 궤적 전체 제외: vessel={}",
|
||||||
result.getOriginalTrack().getVesselKey());
|
result.getOriginalTrack().getVesselKey());
|
||||||
@ -91,7 +86,7 @@ public class CompositeTrackWriter implements ItemWriter<AbnormalDetectionResult>
|
|||||||
if (hourlyTrackCache != null) {
|
if (hourlyTrackCache != null) {
|
||||||
long l2Before = hourlyTrackCache.size();
|
long l2Before = hourlyTrackCache.size();
|
||||||
hourlyTrackCache.putAll(normalTracks);
|
hourlyTrackCache.putAll(normalTracks);
|
||||||
log.debug("[CACHE-MONITOR] CompositeTrackWriter → L2.putAll: tracks={}, L2 before={}, after={}",
|
log.info("[CACHE-MONITOR] CompositeTrackWriter → L2.putAll: tracks={}, L2 before={}, after={}",
|
||||||
normalTracks.size(), l2Before, hourlyTrackCache.size());
|
normalTracks.size(), l2Before, hourlyTrackCache.size());
|
||||||
}
|
}
|
||||||
} else if ("daily".equals(targetTable)) {
|
} else if ("daily".equals(targetTable)) {
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import gc.mda.signal_batch.domain.gis.dto.VesselContactRequest;
|
|||||||
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
|
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
|
||||||
import gc.mda.signal_batch.domain.gis.service.AreaSearchService;
|
import gc.mda.signal_batch.domain.gis.service.AreaSearchService;
|
||||||
import gc.mda.signal_batch.domain.gis.service.VesselContactService;
|
import gc.mda.signal_batch.domain.gis.service.VesselContactService;
|
||||||
import gc.mda.signal_batch.global.exception.QueryTimeoutException;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.media.Content;
|
import io.swagger.v3.oas.annotations.media.Content;
|
||||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||||
@ -220,11 +219,4 @@ public class AreaSearchController {
|
|||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||||
.body(Map.of("error", e.getMessage()));
|
.body(Map.of("error", e.getMessage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(QueryTimeoutException.class)
|
|
||||||
public ResponseEntity<Map<String, String>> handleQueryTimeout(QueryTimeoutException e) {
|
|
||||||
log.warn("Area search query timeout: {}", e.getMessage());
|
|
||||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
|
||||||
.body(Map.of("error", e.getMessage()));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,11 +6,8 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
|
|||||||
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailRequest;
|
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailResponse;
|
|
||||||
import gc.mda.signal_batch.domain.gis.service.GisService;
|
import gc.mda.signal_batch.domain.gis.service.GisService;
|
||||||
import gc.mda.signal_batch.domain.vessel.service.VesselPositionService;
|
import gc.mda.signal_batch.domain.vessel.service.VesselPositionService;
|
||||||
import gc.mda.signal_batch.domain.vessel.service.VesselPositionDetailService;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -31,7 +28,6 @@ public class GisController {
|
|||||||
|
|
||||||
private final GisService gisService;
|
private final GisService gisService;
|
||||||
private final VesselPositionService vesselPositionService;
|
private final VesselPositionService vesselPositionService;
|
||||||
private final VesselPositionDetailService vesselPositionDetailService;
|
|
||||||
|
|
||||||
@GetMapping("/haegu/boundaries")
|
@GetMapping("/haegu/boundaries")
|
||||||
@Operation(summary = "해구 경계 조회", description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환")
|
@Operation(summary = "해구 경계 조회", description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환")
|
||||||
@ -101,20 +97,4 @@ public class GisController {
|
|||||||
|
|
||||||
return vesselPositionService.getRecentVesselPositions(minutes);
|
return vesselPositionService.getRecentVesselPositions(minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/vessels/recent-positions-detail")
|
|
||||||
@Operation(
|
|
||||||
summary = "최근 위치 상세 조회 (공간 필터 지원)",
|
|
||||||
description = "AIS 캐시에서 지정 시간 내 선박의 상세 정보를 공간 필터(폴리곤/원)와 함께 조회합니다. "
|
|
||||||
+ "coordinates(폴리곤)와 center+radiusNm(원) 중 하나를 지정하거나, 둘 다 생략하면 전체 조회합니다."
|
|
||||||
)
|
|
||||||
public List<RecentPositionDetailResponse> getRecentPositionsDetail(
|
|
||||||
@RequestBody RecentPositionDetailRequest request) {
|
|
||||||
|
|
||||||
if (request.getMinutes() <= 0 || request.getMinutes() > 1440) {
|
|
||||||
throw new IllegalArgumentException("Minutes must be between 1 and 1440");
|
|
||||||
}
|
|
||||||
|
|
||||||
return vesselPositionDetailService.getRecentPositionsDetail(request);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -18,7 +18,6 @@ import io.swagger.v3.oas.annotations.media.Schema;
|
|||||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
@ -189,22 +188,8 @@ public class GisControllerV2 {
|
|||||||
required = true,
|
required = true,
|
||||||
content = @Content(schema = @Schema(implementation = VesselTracksRequest.class))
|
content = @Content(schema = @Schema(implementation = VesselTracksRequest.class))
|
||||||
)
|
)
|
||||||
@RequestBody VesselTracksRequest request,
|
@RequestBody VesselTracksRequest request) {
|
||||||
HttpServletRequest httpRequest) {
|
return gisServiceV2.getVesselTracksV2(request);
|
||||||
return gisServiceV2.getVesselTracksV2(request, getClientIp(httpRequest), getClientId(httpRequest));
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getClientId(HttpServletRequest request) {
|
|
||||||
return gc.mda.signal_batch.global.config.WebSocketStompConfig.extractClientIdFromRequest(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getClientIp(HttpServletRequest request) {
|
|
||||||
String[] headers = {"X-Forwarded-For", "X-Original-Forwarded-For", "X-Real-IP"};
|
|
||||||
for (String header : headers) {
|
|
||||||
String ip = request.getHeader(header);
|
|
||||||
if (ip != null && !ip.isBlank()) return ip.split(",")[0].trim();
|
|
||||||
}
|
|
||||||
return request.getRemoteAddr();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/vessels/recent-positions")
|
@GetMapping("/vessels/recent-positions")
|
||||||
|
|||||||
@ -42,10 +42,6 @@ public class AreaSearchRequest {
|
|||||||
@Schema(description = "탐색 대상 폴리곤 영역 목록 (1~10개)", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "탐색 대상 폴리곤 영역 목록 (1~10개)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private List<SearchPolygon> polygons;
|
private List<SearchPolygon> polygons;
|
||||||
|
|
||||||
@Schema(description = "true 시 중국허가선박(~1,400척)만 분석 대상으로 필터링", example = "false")
|
|
||||||
@Builder.Default
|
|
||||||
private boolean chnPrmShipOnly = false;
|
|
||||||
|
|
||||||
@Schema(description = "검색 모드 (폴리곤이 2개 이상일 때 적용)")
|
@Schema(description = "검색 모드 (폴리곤이 2개 이상일 때 적용)")
|
||||||
public enum SearchMode {
|
public enum SearchMode {
|
||||||
@Schema(description = "합집합: 어느 한 영역이라도 통과한 선박")
|
@Schema(description = "합집합: 어느 한 영역이라도 통과한 선박")
|
||||||
|
|||||||
@ -47,10 +47,6 @@ public class VesselContactRequest {
|
|||||||
@Schema(description = "최대 접촉 판정 거리 (미터, 50~5000)", example = "1000", requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(description = "최대 접촉 판정 거리 (미터, 50~5000)", example = "1000", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
private Double maxContactDistanceMeters;
|
private Double maxContactDistanceMeters;
|
||||||
|
|
||||||
@Schema(description = "true 시 중국허가선박만 대상으로 접촉 분석", example = "false")
|
|
||||||
@Builder.Default
|
|
||||||
private boolean chnPrmShipOnly = false;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
|
|||||||
@ -16,10 +16,10 @@ import java.util.List;
|
|||||||
@Schema(description = "비정상 접촉 선박 탐색 응답")
|
@Schema(description = "비정상 접촉 선박 탐색 응답")
|
||||||
public class VesselContactResponse {
|
public class VesselContactResponse {
|
||||||
|
|
||||||
@Schema(description = "접촉 선박 쌍 목록 — 동일 선박 쌍이 시간 갭(20분 이상)으로 분리된 여러 접촉 세그먼트를 가질 수 있음")
|
@Schema(description = "접촉 선박 쌍 목록")
|
||||||
private List<VesselContactPair> contacts;
|
private List<VesselContactPair> contacts;
|
||||||
|
|
||||||
@Schema(description = "관련 선박의 전체 기간 항적 — 선박당 1건으로 중복 제거됨 (CompactVesselTrack)")
|
@Schema(description = "관련 선박의 전체 기간 항적 (CompactVesselTrack)")
|
||||||
private List<CompactVesselTrack> tracks;
|
private List<CompactVesselTrack> tracks;
|
||||||
|
|
||||||
@Schema(description = "탐색 요약 정보")
|
@Schema(description = "탐색 요약 정보")
|
||||||
|
|||||||
@ -6,14 +6,9 @@ import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchPolygon;
|
|||||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
|
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
|
||||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.AreaSearchSummary;
|
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.AreaSearchSummary;
|
||||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.PolygonHitDetail;
|
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.PolygonHitDetail;
|
||||||
import gc.mda.signal_batch.batch.reader.ChnPrmShipProperties;
|
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||||
import gc.mda.signal_batch.global.exception.QueryTimeoutException;
|
|
||||||
import gc.mda.signal_batch.global.util.TrackMemoryEstimator;
|
|
||||||
import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
|
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager.DailyTrackData;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager.DailyTrackData;
|
||||||
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.locationtech.jts.geom.*;
|
import org.locationtech.jts.geom.*;
|
||||||
@ -33,9 +28,6 @@ import java.util.stream.Collectors;
|
|||||||
public class AreaSearchService {
|
public class AreaSearchService {
|
||||||
|
|
||||||
private final DailyTrackCacheManager cacheManager;
|
private final DailyTrackCacheManager cacheManager;
|
||||||
private final ActiveQueryManager activeQueryManager;
|
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
|
||||||
private final ChnPrmShipProperties chnPrmShipProperties;
|
|
||||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -53,115 +45,82 @@ public class AreaSearchService {
|
|||||||
return buildEmptyResponse(request, startMs);
|
return buildEmptyResponse(request, startMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 동시성·메모리 관리 (데이터 로딩 전 슬롯/예산 확보)
|
// 3. 다일 데이터 → 선박별 단일 트랙 병합
|
||||||
String queryId = "area-search-" + Long.toHexString(System.nanoTime());
|
Map<String, CompactVesselTrack> mergedTracks = mergeMultipleDays(targetDates);
|
||||||
boolean slotAcquired = false, memoryReserved = false;
|
if (mergedTracks.isEmpty()) {
|
||||||
try {
|
return buildEmptyResponse(request, startMs);
|
||||||
if (!activeQueryManager.tryAcquireQuerySlotImmediate(queryId)) {
|
|
||||||
if (!activeQueryManager.tryAcquireQuerySlot(queryId)) {
|
|
||||||
throw new QueryTimeoutException("서버 과부하: area-search 슬롯 대기 타임아웃");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slotAcquired = true;
|
|
||||||
|
|
||||||
long estimatedBytes = TrackMemoryEstimator.estimateQueryBytes(targetDates.size(), 2000);
|
|
||||||
memoryBudgetManager.reserveQueryMemory(queryId, estimatedBytes, 30_000L);
|
|
||||||
memoryReserved = true;
|
|
||||||
|
|
||||||
// 4. 다일 데이터 → 선박별 단일 트랙 병합
|
|
||||||
Map<String, CompactVesselTrack> mergedTracks = mergeMultipleDays(targetDates);
|
|
||||||
if (mergedTracks.isEmpty()) {
|
|
||||||
return buildEmptyResponse(request, startMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4-1. ChnPrmShip 필터링
|
|
||||||
if (request.isChnPrmShipOnly()) {
|
|
||||||
int totalBefore = mergedTracks.size();
|
|
||||||
Set<String> chnPrmMmsiSet = chnPrmShipProperties.getMmsiSet();
|
|
||||||
mergedTracks.entrySet().removeIf(e -> !chnPrmMmsiSet.contains(e.getKey()));
|
|
||||||
log.debug("ChnPrmShip 필터 적용: {} → {} 선박", totalBefore, mergedTracks.size());
|
|
||||||
if (mergedTracks.isEmpty()) {
|
|
||||||
return buildEmptyResponse(request, startMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. 좌표 → JTS Polygon 변환
|
|
||||||
List<Polygon> jtsPolygons = convertToJtsPolygons(request.getPolygons());
|
|
||||||
|
|
||||||
// 6. 병합된 트랙으로 STRtree 빌드
|
|
||||||
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
|
||||||
|
|
||||||
// 7. 각 폴리곤별 히트 선박 + 개별 방문(trip) 수집
|
|
||||||
List<Map<String, List<PolygonHitDetail>>> perPolygonHits = new ArrayList<>();
|
|
||||||
for (int i = 0; i < jtsPolygons.size(); i++) {
|
|
||||||
Polygon polygon = jtsPolygons.get(i);
|
|
||||||
SearchPolygon searchPolygon = request.getPolygons().get(i);
|
|
||||||
Map<String, List<PolygonHitDetail>> hits = findHitsForPolygon(
|
|
||||||
polygon, searchPolygon, mergedTracks, spatialIndex);
|
|
||||||
perPolygonHits.add(hits);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. 모드별 결과 합산
|
|
||||||
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
|
|
||||||
Map<String, List<PolygonHitDetail>> resultHits;
|
|
||||||
switch (mode) {
|
|
||||||
case ALL:
|
|
||||||
resultHits = processAllMode(perPolygonHits);
|
|
||||||
break;
|
|
||||||
case SEQUENTIAL:
|
|
||||||
resultHits = processSequentialMode(perPolygonHits);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
resultHits = processAnyMode(perPolygonHits);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 결과 선박의 전체 기간 트랙 + 히트 메타 반환
|
|
||||||
List<CompactVesselTrack> resultTracks = resultHits.keySet().stream()
|
|
||||||
.map(mergedTracks::get)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
long totalPoints = resultHits.values().stream()
|
|
||||||
.flatMap(Collection::stream)
|
|
||||||
.mapToLong(h -> h.getHitPointCount() != null ? h.getHitPointCount() : 0)
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
int totalCachedVessels = targetDates.stream()
|
|
||||||
.mapToInt(d -> {
|
|
||||||
DailyTrackData data = cacheManager.getDailyTrackData(d);
|
|
||||||
return data != null ? data.getVesselCount() : 0;
|
|
||||||
})
|
|
||||||
.sum();
|
|
||||||
|
|
||||||
long elapsedMs = System.currentTimeMillis() - startMs;
|
|
||||||
log.info("Area search completed: mode={}, polygons={}, hitVessels={}, totalPoints={}, chnPrmOnly={}, elapsed={}ms",
|
|
||||||
mode, request.getPolygons().size(), resultHits.size(), totalPoints, request.isChnPrmShipOnly(), elapsedMs);
|
|
||||||
|
|
||||||
return AreaSearchResponse.builder()
|
|
||||||
.tracks(resultTracks)
|
|
||||||
.hitDetails(resultHits)
|
|
||||||
.summary(AreaSearchSummary.builder()
|
|
||||||
.totalVessels(resultHits.size())
|
|
||||||
.totalPoints(totalPoints)
|
|
||||||
.mode(mode)
|
|
||||||
.polygonIds(request.getPolygons().stream()
|
|
||||||
.map(SearchPolygon::getId)
|
|
||||||
.collect(Collectors.toList()))
|
|
||||||
.processingTimeMs(elapsedMs)
|
|
||||||
.cachedDates(targetDates.stream()
|
|
||||||
.map(LocalDate::toString)
|
|
||||||
.collect(Collectors.toList()))
|
|
||||||
.totalCachedVessels(totalCachedVessels)
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new QueryTimeoutException("area-search 슬롯 대기 중 인터럽트");
|
|
||||||
} finally {
|
|
||||||
if (memoryReserved) memoryBudgetManager.releaseQueryMemory(queryId);
|
|
||||||
if (slotAcquired) activeQueryManager.releaseQuerySlot(queryId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. 좌표 → JTS Polygon 변환
|
||||||
|
List<Polygon> jtsPolygons = convertToJtsPolygons(request.getPolygons());
|
||||||
|
|
||||||
|
// 5. 병합된 트랙으로 STRtree 빌드
|
||||||
|
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
||||||
|
|
||||||
|
// 6. 각 폴리곤별 히트 선박 + 개별 방문(trip) 수집
|
||||||
|
List<Map<String, List<PolygonHitDetail>>> perPolygonHits = new ArrayList<>();
|
||||||
|
for (int i = 0; i < jtsPolygons.size(); i++) {
|
||||||
|
Polygon polygon = jtsPolygons.get(i);
|
||||||
|
SearchPolygon searchPolygon = request.getPolygons().get(i);
|
||||||
|
Map<String, List<PolygonHitDetail>> hits = findHitsForPolygon(
|
||||||
|
polygon, searchPolygon, mergedTracks, spatialIndex);
|
||||||
|
perPolygonHits.add(hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 모드별 결과 합산
|
||||||
|
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
|
||||||
|
Map<String, List<PolygonHitDetail>> resultHits;
|
||||||
|
switch (mode) {
|
||||||
|
case ALL:
|
||||||
|
resultHits = processAllMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
case SEQUENTIAL:
|
||||||
|
resultHits = processSequentialMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
resultHits = processAnyMode(perPolygonHits);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. 결과 선박의 전체 기간 트랙 + 히트 메타 반환
|
||||||
|
List<CompactVesselTrack> resultTracks = resultHits.keySet().stream()
|
||||||
|
.map(mergedTracks::get)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
long totalPoints = resultHits.values().stream()
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.mapToLong(h -> h.getHitPointCount() != null ? h.getHitPointCount() : 0)
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
int totalCachedVessels = targetDates.stream()
|
||||||
|
.mapToInt(d -> {
|
||||||
|
DailyTrackData data = cacheManager.getDailyTrackData(d);
|
||||||
|
return data != null ? data.getVesselCount() : 0;
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
|
||||||
|
long elapsedMs = System.currentTimeMillis() - startMs;
|
||||||
|
log.info("Area search completed: mode={}, polygons={}, hitVessels={}, totalPoints={}, elapsed={}ms",
|
||||||
|
mode, request.getPolygons().size(), resultHits.size(), totalPoints, elapsedMs);
|
||||||
|
|
||||||
|
return AreaSearchResponse.builder()
|
||||||
|
.tracks(resultTracks)
|
||||||
|
.hitDetails(resultHits)
|
||||||
|
.summary(AreaSearchSummary.builder()
|
||||||
|
.totalVessels(resultHits.size())
|
||||||
|
.totalPoints(totalPoints)
|
||||||
|
.mode(mode)
|
||||||
|
.polygonIds(request.getPolygons().stream()
|
||||||
|
.map(SearchPolygon::getId)
|
||||||
|
.collect(Collectors.toList()))
|
||||||
|
.processingTimeMs(elapsedMs)
|
||||||
|
.cachedDates(targetDates.stream()
|
||||||
|
.map(LocalDate::toString)
|
||||||
|
.collect(Collectors.toList()))
|
||||||
|
.totalCachedVessels(totalCachedVessels)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 입력 검증 ──
|
// ── 입력 검증 ──
|
||||||
@ -285,11 +244,9 @@ public class AreaSearchService {
|
|||||||
|
|
||||||
// 여러 날짜 병합
|
// 여러 날짜 병합
|
||||||
CompactVesselTrack first = trackList.get(0);
|
CompactVesselTrack first = trackList.get(0);
|
||||||
int totalPoints = trackList.stream()
|
List<double[]> geo = new ArrayList<>();
|
||||||
.mapToInt(t -> t.getPointCount() != null ? t.getPointCount() : 0).sum();
|
List<String> ts = new ArrayList<>();
|
||||||
List<double[]> geo = new ArrayList<>(totalPoints);
|
List<Double> sp = new ArrayList<>();
|
||||||
List<String> ts = new ArrayList<>(totalPoints);
|
|
||||||
List<Double> sp = new ArrayList<>(totalPoints);
|
|
||||||
double totalDist = 0;
|
double totalDist = 0;
|
||||||
double maxSpeed = 0;
|
double maxSpeed = 0;
|
||||||
int pointCount = 0;
|
int pointCount = 0;
|
||||||
@ -390,13 +347,10 @@ public class AreaSearchService {
|
|||||||
long currentExit = 0;
|
long currentExit = 0;
|
||||||
int currentHitCount = 0;
|
int currentHitCount = 0;
|
||||||
int visitIndex = 0;
|
int visitIndex = 0;
|
||||||
Coordinate reusable = new Coordinate();
|
|
||||||
|
|
||||||
for (int i = 0; i < geometry.size(); i++) {
|
for (int i = 0; i < geometry.size(); i++) {
|
||||||
double[] coord = geometry.get(i);
|
double[] coord = geometry.get(i);
|
||||||
reusable.x = coord[0];
|
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
|
||||||
reusable.y = coord[1];
|
|
||||||
Point point = GEOMETRY_FACTORY.createPoint(reusable);
|
|
||||||
boolean isInside = prepared.contains(point);
|
boolean isInside = prepared.contains(point);
|
||||||
|
|
||||||
if (isInside) {
|
if (isInside) {
|
||||||
@ -484,7 +438,6 @@ public class AreaSearchService {
|
|||||||
try {
|
try {
|
||||||
return Long.parseLong(timestamps.get(index));
|
return Long.parseLong(timestamps.get(index));
|
||||||
} catch (NumberFormatException e) {
|
} catch (NumberFormatException e) {
|
||||||
log.warn("Invalid timestamp at index {}: {}", index, timestamps.get(index));
|
|
||||||
return 0L;
|
return 0L;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
|
|||||||
import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse;
|
import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||||
|
import gc.mda.signal_batch.global.util.SignalKindCode;
|
||||||
import gc.mda.signal_batch.global.util.TrackSimplificationUtils;
|
import gc.mda.signal_batch.global.util.TrackSimplificationUtils;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
@ -603,11 +604,9 @@ public class GisService {
|
|||||||
Map<String, String> vesselInfo = getVesselInfo(mmsi);
|
Map<String, String> vesselInfo = getVesselInfo(mmsi);
|
||||||
String shipName = vesselInfo.get("ship_name");
|
String shipName = vesselInfo.get("ship_name");
|
||||||
String shipType = vesselInfo.get("ship_type");
|
String shipType = vesselInfo.get("ship_type");
|
||||||
String signalKindCode = vesselInfo.get("signal_kind_code");
|
|
||||||
|
|
||||||
String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null;
|
String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null;
|
||||||
String shipKindCode = (signalKindCode != null && !signalKindCode.isEmpty())
|
String shipKindCode = SignalKindCode.resolve(shipType, null).getCode();
|
||||||
? signalKindCode : "000027";
|
|
||||||
|
|
||||||
return CompactVesselTrack.builder()
|
return CompactVesselTrack.builder()
|
||||||
.vesselId(mmsi)
|
.vesselId(mmsi)
|
||||||
@ -629,7 +628,7 @@ public class GisService {
|
|||||||
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
||||||
try {
|
try {
|
||||||
String sql = """
|
String sql = """
|
||||||
SELECT ship_nm as ship_name, vessel_type as ship_type, signal_kind_code
|
SELECT ship_nm as ship_name, vessel_type as ship_type
|
||||||
FROM signal.t_ais_position
|
FROM signal.t_ais_position
|
||||||
WHERE mmsi = ?
|
WHERE mmsi = ?
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
|
|||||||
@ -9,17 +9,12 @@ import gc.mda.signal_batch.domain.vessel.model.AisTargetEntity;
|
|||||||
import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
|
import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest;
|
||||||
import gc.mda.signal_batch.domain.vessel.model.VesselTrack;
|
import gc.mda.signal_batch.domain.vessel.model.VesselTrack;
|
||||||
import gc.mda.signal_batch.global.exception.MemoryBudgetExceededException;
|
|
||||||
import gc.mda.signal_batch.global.exception.QueryTimeoutException;
|
import gc.mda.signal_batch.global.exception.QueryTimeoutException;
|
||||||
import gc.mda.signal_batch.global.util.TrackConverter;
|
import gc.mda.signal_batch.global.util.TrackConverter;
|
||||||
import gc.mda.signal_batch.global.util.TrackMemoryEstimator;
|
|
||||||
import gc.mda.signal_batch.global.util.VesselTrackToCompactConverter;
|
import gc.mda.signal_batch.global.util.VesselTrackToCompactConverter;
|
||||||
import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
|
import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.CacheTrackSimplifier;
|
import gc.mda.signal_batch.global.websocket.service.CacheTrackSimplifier;
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
|
||||||
import gc.mda.signal_batch.monitoring.service.QueryMetricsBufferService;
|
|
||||||
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
@ -57,8 +52,6 @@ public class GisServiceV2 {
|
|||||||
private final VesselTrackToCompactConverter vesselTrackToCompactConverter;
|
private final VesselTrackToCompactConverter vesselTrackToCompactConverter;
|
||||||
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
|
||||||
private final ChnPrmShipProperties chnPrmShipProperties;
|
private final ChnPrmShipProperties chnPrmShipProperties;
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
|
||||||
private final QueryMetricsBufferService queryMetricsBufferService;
|
|
||||||
|
|
||||||
@Value("${rest.v2.query.timeout-seconds:30}")
|
@Value("${rest.v2.query.timeout-seconds:30}")
|
||||||
private int restQueryTimeout;
|
private int restQueryTimeout;
|
||||||
@ -79,9 +72,7 @@ public class GisServiceV2 {
|
|||||||
FiveMinTrackCache fiveMinTrackCache,
|
FiveMinTrackCache fiveMinTrackCache,
|
||||||
VesselTrackToCompactConverter vesselTrackToCompactConverter,
|
VesselTrackToCompactConverter vesselTrackToCompactConverter,
|
||||||
ChnPrmShipCacheManager chnPrmShipCacheManager,
|
ChnPrmShipCacheManager chnPrmShipCacheManager,
|
||||||
ChnPrmShipProperties chnPrmShipProperties,
|
ChnPrmShipProperties chnPrmShipProperties) {
|
||||||
TrackMemoryBudgetManager memoryBudgetManager,
|
|
||||||
QueryMetricsBufferService queryMetricsBufferService) {
|
|
||||||
this.queryDataSource = queryDataSource;
|
this.queryDataSource = queryDataSource;
|
||||||
this.activeQueryManager = activeQueryManager;
|
this.activeQueryManager = activeQueryManager;
|
||||||
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
this.dailyTrackCacheManager = dailyTrackCacheManager;
|
||||||
@ -92,8 +83,6 @@ public class GisServiceV2 {
|
|||||||
this.vesselTrackToCompactConverter = vesselTrackToCompactConverter;
|
this.vesselTrackToCompactConverter = vesselTrackToCompactConverter;
|
||||||
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
|
||||||
this.chnPrmShipProperties = chnPrmShipProperties;
|
this.chnPrmShipProperties = chnPrmShipProperties;
|
||||||
this.memoryBudgetManager = memoryBudgetManager;
|
|
||||||
this.queryMetricsBufferService = queryMetricsBufferService;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -285,28 +274,13 @@ public class GisServiceV2 {
|
|||||||
/**
|
/**
|
||||||
* 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment)
|
* 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment)
|
||||||
*/
|
*/
|
||||||
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request, String clientIp, String clientId) {
|
public List<CompactVesselTrack> getVesselTracksV2(VesselTracksRequest request) {
|
||||||
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
|
String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8);
|
||||||
long startMs = System.currentTimeMillis();
|
|
||||||
boolean slotAcquired = false;
|
boolean slotAcquired = false;
|
||||||
boolean memoryReserved = false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
slotAcquired = acquireSlotWithWait(queryId);
|
slotAcquired = acquireSlotWithWait(queryId);
|
||||||
|
|
||||||
// 쿼리 메모리 사전 예약
|
|
||||||
int days = (int) java.time.Duration.between(request.getStartTime(), request.getEndTime()).toDays() + 1;
|
|
||||||
long estimatedBytes = TrackMemoryEstimator.estimateQueryBytes(days, request.getVessels().size());
|
|
||||||
try {
|
|
||||||
memoryBudgetManager.reserveQueryMemory(queryId, estimatedBytes,
|
|
||||||
memoryBudgetManager.getProperties().getQueueTimeoutSeconds() * 1000L);
|
|
||||||
memoryReserved = true;
|
|
||||||
} catch (MemoryBudgetExceededException e) {
|
|
||||||
log.warn("[MemoryBudget] REST 쿼리 메모리 예약 실패: queryId={}, estimated={}MB — {}",
|
|
||||||
queryId, estimatedBytes / (1024 * 1024), e.getMessage());
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CompactVesselTrack> result;
|
List<CompactVesselTrack> result;
|
||||||
|
|
||||||
if (dailyTrackCacheManager.isEnabled() &&
|
if (dailyTrackCacheManager.isEnabled() &&
|
||||||
@ -329,14 +303,9 @@ public class GisServiceV2 {
|
|||||||
result.size(), request.getVessels().size(),
|
result.size(), request.getVessels().size(),
|
||||||
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
|
dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip());
|
||||||
|
|
||||||
enqueueRestMetric(queryId, request, result, startMs, clientIp, clientId);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
if (memoryReserved) {
|
|
||||||
memoryBudgetManager.releaseQueryMemory(queryId);
|
|
||||||
}
|
|
||||||
if (slotAcquired) {
|
if (slotAcquired) {
|
||||||
activeQueryManager.releaseQuerySlot(queryId);
|
activeQueryManager.releaseQuerySlot(queryId);
|
||||||
if (activeQueryManager.isHeapPressureHigh()) {
|
if (activeQueryManager.isHeapPressureHigh()) {
|
||||||
@ -346,34 +315,6 @@ public class GisServiceV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void enqueueRestMetric(String queryId, VesselTracksRequest request,
|
|
||||||
List<CompactVesselTrack> result, long startMs, String clientIp, String clientId) {
|
|
||||||
try {
|
|
||||||
int totalPoints = result.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
|
||||||
long responseBytes = (long) result.size() * 200 + (long) totalPoints * 40;
|
|
||||||
queryMetricsBufferService.enqueue(QueryMetricsService.QueryMetric.builder()
|
|
||||||
.queryId(queryId)
|
|
||||||
.queryType("REST_V2")
|
|
||||||
.startTime(request.getStartTime())
|
|
||||||
.endTime(request.getEndTime())
|
|
||||||
.requestedMmsi(request.getVessels().size())
|
|
||||||
.dataPath(dailyTrackCacheManager.isEnabled() ? "HYBRID" : "DB")
|
|
||||||
.uniqueVessels(result.size())
|
|
||||||
.totalTracks(result.size())
|
|
||||||
.totalPoints(totalPoints)
|
|
||||||
.pointsAfterSimplify(totalPoints)
|
|
||||||
.totalChunks(1)
|
|
||||||
.responseBytes(responseBytes)
|
|
||||||
.elapsedMs(System.currentTimeMillis() - startMs)
|
|
||||||
.status("COMPLETED")
|
|
||||||
.clientIp(clientIp)
|
|
||||||
.clientId(clientId)
|
|
||||||
.build());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.debug("Failed to enqueue REST metric: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 캐시 조회 로직 ──
|
// ── 캐시 조회 로직 ──
|
||||||
|
|
||||||
private List<CompactVesselTrack> queryWithCache(VesselTracksRequest request) {
|
private List<CompactVesselTrack> queryWithCache(VesselTracksRequest request) {
|
||||||
@ -387,16 +328,24 @@ public class GisServiceV2 {
|
|||||||
|
|
||||||
Set<String> requestedMmsis = new HashSet<>(request.getVessels());
|
Set<String> requestedMmsis = new HashSet<>(request.getVessels());
|
||||||
|
|
||||||
// 1. L3 캐시에서 요청 MMSI만 O(1) 직접 조회 + 누락 MMSI 부분 DB fallback
|
// 1. 캐시에서 조회 (캐시된 날짜) + 누락 MMSI 부분 DB fallback
|
||||||
if (split.hasCachedData()) {
|
if (split.hasCachedData()) {
|
||||||
List<CompactVesselTrack> filteredCached =
|
List<CompactVesselTrack> cachedTracks =
|
||||||
dailyTrackCacheManager.getCachedTracksForVessels(split.getCachedDates(), requestedMmsis);
|
dailyTrackCacheManager.getCachedTracksMultipleDays(split.getCachedDates());
|
||||||
|
|
||||||
|
int totalCachedCount = cachedTracks.size();
|
||||||
|
List<CompactVesselTrack> filteredCached = cachedTracks.stream()
|
||||||
|
.filter(t -> requestedMmsis.contains(t.getVesselId()))
|
||||||
|
.map(t -> t.toBuilder().build())
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
cachedTracks.clear();
|
||||||
|
|
||||||
allTracks.addAll(filteredCached);
|
allTracks.addAll(filteredCached);
|
||||||
log.debug("[CacheQuery] cached {} days -> {} tracks (key-based lookup, {} MMSI requested)",
|
log.debug("[CacheQuery] cached {} days -> {} tracks (filtered from {})",
|
||||||
split.getCachedDates().size(), filteredCached.size(), requestedMmsis.size());
|
split.getCachedDates().size(), filteredCached.size(), totalCachedCount);
|
||||||
|
|
||||||
// Daily 캐시에 없는 MMSI → DB fallback
|
// Daily 캐시에 없는 MMSI → DB fallback (hourly/5min 계층 조회)
|
||||||
Set<String> cachedMmsis = filteredCached.stream()
|
Set<String> cachedMmsis = filteredCached.stream()
|
||||||
.map(CompactVesselTrack::getVesselId)
|
.map(CompactVesselTrack::getVesselId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
@ -434,22 +383,23 @@ public class GisServiceV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3-a. hourly 범위 → L2 캐시 O(1) 키 기반 조회 → DB fallback (누락 MMSI)
|
// 3-a. hourly 범위 → L2 캐시 → DB fallback (누락 MMSI 부분 fallback 포함)
|
||||||
if (split.hasHourlyRange()) {
|
if (split.hasHourlyRange()) {
|
||||||
DailyTrackCacheManager.DateRange hr = split.getHourlyRange();
|
DailyTrackCacheManager.DateRange hr = split.getHourlyRange();
|
||||||
Map<String, List<VesselTrack>> hourlyTracks =
|
Map<String, List<VesselTrack>> hourlyTracks =
|
||||||
hourlyTrackCache.getTracksForVessels(hr.getStart(), hr.getEnd(), requestedMmsis);
|
hourlyTrackCache.getTracksInRange(hr.getStart(), hr.getEnd());
|
||||||
|
|
||||||
if (!hourlyTracks.isEmpty()) {
|
if (!hourlyTracks.isEmpty()) {
|
||||||
List<CompactVesselTrack> converted = vesselTrackToCompactConverter.convert(hourlyTracks);
|
Map<String, List<VesselTrack>> filtered = filterByMmsi(hourlyTracks, requestedMmsis);
|
||||||
|
List<CompactVesselTrack> converted = vesselTrackToCompactConverter.convert(filtered);
|
||||||
allTracks.addAll(converted);
|
allTracks.addAll(converted);
|
||||||
int totalPts = converted.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
int totalPts = converted.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
||||||
log.info("[CACHE-MONITOR] queryWithCache L2 HIT [{}, {}): resultVessels={}, compactTracks={}, points={}",
|
log.info("[CACHE-MONITOR] queryWithCache L2 HIT [{}, {}): cacheVessels={}, filteredVessels={}, compactTracks={}, points={}",
|
||||||
hr.getStart(), hr.getEnd(), hourlyTracks.size(), converted.size(), totalPts);
|
hr.getStart(), hr.getEnd(), hourlyTracks.size(), filtered.size(), converted.size(), totalPts);
|
||||||
|
|
||||||
// 캐시에 없는 MMSI → DB fallback
|
// 캐시에 없는 MMSI → DB fallback
|
||||||
Set<String> missingMmsis = new HashSet<>(requestedMmsis);
|
Set<String> missingMmsis = new HashSet<>(requestedMmsis);
|
||||||
missingMmsis.removeAll(hourlyTracks.keySet());
|
missingMmsis.removeAll(filtered.keySet());
|
||||||
if (!missingMmsis.isEmpty()) {
|
if (!missingMmsis.isEmpty()) {
|
||||||
VesselTracksRequest fallbackReq = VesselTracksRequest.builder()
|
VesselTracksRequest fallbackReq = VesselTracksRequest.builder()
|
||||||
.startTime(hr.getStart()).endTime(hr.getEnd())
|
.startTime(hr.getStart()).endTime(hr.getEnd())
|
||||||
@ -457,7 +407,7 @@ public class GisServiceV2 {
|
|||||||
List<CompactVesselTrack> dbResult = gisService.getVesselTracks(fallbackReq);
|
List<CompactVesselTrack> dbResult = gisService.getVesselTracks(fallbackReq);
|
||||||
allTracks.addAll(dbResult);
|
allTracks.addAll(dbResult);
|
||||||
log.info("[CACHE-MONITOR] queryWithCache L2 PARTIAL → DB fallback: cacheHit={}, cacheMiss={}, dbTracks={}",
|
log.info("[CACHE-MONITOR] queryWithCache L2 PARTIAL → DB fallback: cacheHit={}, cacheMiss={}, dbTracks={}",
|
||||||
hourlyTracks.size(), missingMmsis.size(), dbResult.size());
|
filtered.size(), missingMmsis.size(), dbResult.size());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
VesselTracksRequest hourlyReq = VesselTracksRequest.builder()
|
VesselTracksRequest hourlyReq = VesselTracksRequest.builder()
|
||||||
@ -470,22 +420,23 @@ public class GisServiceV2 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3-b. 5min 범위 → L1 캐시 O(1) 키 기반 조회 → DB fallback (누락 MMSI)
|
// 3-b. 5min 범위 → L1 캐시 → DB fallback (누락 MMSI 부분 fallback 포함)
|
||||||
if (split.hasFiveMinRange()) {
|
if (split.hasFiveMinRange()) {
|
||||||
DailyTrackCacheManager.DateRange fr = split.getFiveMinRange();
|
DailyTrackCacheManager.DateRange fr = split.getFiveMinRange();
|
||||||
Map<String, List<VesselTrack>> fiveMinTracks =
|
Map<String, List<VesselTrack>> fiveMinTracks =
|
||||||
fiveMinTrackCache.getTracksForVessels(fr.getStart(), fr.getEnd(), requestedMmsis);
|
fiveMinTrackCache.getTracksInRange(fr.getStart(), fr.getEnd());
|
||||||
|
|
||||||
if (!fiveMinTracks.isEmpty()) {
|
if (!fiveMinTracks.isEmpty()) {
|
||||||
List<CompactVesselTrack> converted = vesselTrackToCompactConverter.convert(fiveMinTracks);
|
Map<String, List<VesselTrack>> filtered = filterByMmsi(fiveMinTracks, requestedMmsis);
|
||||||
|
List<CompactVesselTrack> converted = vesselTrackToCompactConverter.convert(filtered);
|
||||||
allTracks.addAll(converted);
|
allTracks.addAll(converted);
|
||||||
int totalPts = converted.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
int totalPts = converted.stream().mapToInt(CompactVesselTrack::getPointCount).sum();
|
||||||
log.info("[CACHE-MONITOR] queryWithCache L1 HIT [{}, {}): resultVessels={}, compactTracks={}, points={}",
|
log.info("[CACHE-MONITOR] queryWithCache L1 HIT [{}, {}): cacheVessels={}, filteredVessels={}, compactTracks={}, points={}",
|
||||||
fr.getStart(), fr.getEnd(), fiveMinTracks.size(), converted.size(), totalPts);
|
fr.getStart(), fr.getEnd(), fiveMinTracks.size(), filtered.size(), converted.size(), totalPts);
|
||||||
|
|
||||||
// 캐시에 없는 MMSI → DB fallback
|
// 캐시에 없는 MMSI → DB fallback
|
||||||
Set<String> missingMmsis = new HashSet<>(requestedMmsis);
|
Set<String> missingMmsis = new HashSet<>(requestedMmsis);
|
||||||
missingMmsis.removeAll(fiveMinTracks.keySet());
|
missingMmsis.removeAll(filtered.keySet());
|
||||||
if (!missingMmsis.isEmpty()) {
|
if (!missingMmsis.isEmpty()) {
|
||||||
VesselTracksRequest fallbackReq = VesselTracksRequest.builder()
|
VesselTracksRequest fallbackReq = VesselTracksRequest.builder()
|
||||||
.startTime(fr.getStart()).endTime(fr.getEnd())
|
.startTime(fr.getStart()).endTime(fr.getEnd())
|
||||||
@ -493,7 +444,7 @@ public class GisServiceV2 {
|
|||||||
List<CompactVesselTrack> dbResult = gisService.getVesselTracks(fallbackReq);
|
List<CompactVesselTrack> dbResult = gisService.getVesselTracks(fallbackReq);
|
||||||
allTracks.addAll(dbResult);
|
allTracks.addAll(dbResult);
|
||||||
log.info("[CACHE-MONITOR] queryWithCache L1 PARTIAL → DB fallback: cacheHit={}, cacheMiss={}, dbTracks={}",
|
log.info("[CACHE-MONITOR] queryWithCache L1 PARTIAL → DB fallback: cacheHit={}, cacheMiss={}, dbTracks={}",
|
||||||
fiveMinTracks.size(), missingMmsis.size(), dbResult.size());
|
filtered.size(), missingMmsis.size(), dbResult.size());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
VesselTracksRequest fiveMinReq = VesselTracksRequest.builder()
|
VesselTracksRequest fiveMinReq = VesselTracksRequest.builder()
|
||||||
@ -508,6 +459,7 @@ public class GisServiceV2 {
|
|||||||
|
|
||||||
// 4. 동일 선박 병합 (캐시 + DB 결과)
|
// 4. 동일 선박 병합 (캐시 + DB 결과)
|
||||||
List<CompactVesselTrack> merged = mergeTracksByVessel(allTracks);
|
List<CompactVesselTrack> merged = mergeTracksByVessel(allTracks);
|
||||||
|
allTracks.clear();
|
||||||
|
|
||||||
return merged;
|
return merged;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,10 @@
|
|||||||
package gc.mda.signal_batch.domain.gis.service;
|
package gc.mda.signal_batch.domain.gis.service;
|
||||||
|
|
||||||
import gc.mda.signal_batch.batch.reader.ChnPrmShipProperties;
|
|
||||||
import gc.mda.signal_batch.domain.gis.dto.VesselContactRequest;
|
import gc.mda.signal_batch.domain.gis.dto.VesselContactRequest;
|
||||||
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
|
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse;
|
||||||
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse.*;
|
import gc.mda.signal_batch.domain.gis.dto.VesselContactResponse.*;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||||
import gc.mda.signal_batch.global.exception.QueryTimeoutException;
|
|
||||||
import gc.mda.signal_batch.global.util.TrackMemoryEstimator;
|
|
||||||
import gc.mda.signal_batch.global.websocket.service.ActiveQueryManager;
|
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.locationtech.jts.geom.*;
|
import org.locationtech.jts.geom.*;
|
||||||
@ -29,9 +24,6 @@ public class VesselContactService {
|
|||||||
|
|
||||||
private final AreaSearchService areaSearchService;
|
private final AreaSearchService areaSearchService;
|
||||||
private final DailyTrackCacheManager cacheManager;
|
private final DailyTrackCacheManager cacheManager;
|
||||||
private final ActiveQueryManager activeQueryManager;
|
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
|
||||||
private final ChnPrmShipProperties chnPrmShipProperties;
|
|
||||||
|
|
||||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
||||||
private static final double EARTH_RADIUS_M = 6_371_000.0;
|
private static final double EARTH_RADIUS_M = 6_371_000.0;
|
||||||
@ -57,133 +49,103 @@ public class VesselContactService {
|
|||||||
return buildEmptyResponse(request, targetDates, startMs);
|
return buildEmptyResponse(request, targetDates, startMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. 동시성·메모리 관리
|
Map<String, CompactVesselTrack> mergedTracks = areaSearchService.mergeMultipleDays(targetDates);
|
||||||
String queryId = "contact-search-" + Long.toHexString(System.nanoTime());
|
if (mergedTracks.isEmpty()) {
|
||||||
boolean slotAcquired = false, memoryReserved = false;
|
return buildEmptyResponse(request, targetDates, startMs);
|
||||||
try {
|
|
||||||
if (!activeQueryManager.tryAcquireQuerySlotImmediate(queryId)) {
|
|
||||||
if (!activeQueryManager.tryAcquireQuerySlot(queryId)) {
|
|
||||||
throw new QueryTimeoutException("서버 과부하: contact-search 슬롯 대기 타임아웃");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slotAcquired = true;
|
|
||||||
|
|
||||||
long estimatedBytes = TrackMemoryEstimator.estimateQueryBytes(targetDates.size(), 2000);
|
|
||||||
memoryBudgetManager.reserveQueryMemory(queryId, estimatedBytes, 30_000L);
|
|
||||||
memoryReserved = true;
|
|
||||||
|
|
||||||
Map<String, CompactVesselTrack> mergedTracks = areaSearchService.mergeMultipleDays(targetDates);
|
|
||||||
if (mergedTracks.isEmpty()) {
|
|
||||||
return buildEmptyResponse(request, targetDates, startMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3-1. ChnPrmShip 필터링
|
|
||||||
if (request.isChnPrmShipOnly()) {
|
|
||||||
int totalBefore = mergedTracks.size();
|
|
||||||
Set<String> chnPrmMmsiSet = chnPrmShipProperties.getMmsiSet();
|
|
||||||
mergedTracks.entrySet().removeIf(e -> !chnPrmMmsiSet.contains(e.getKey()));
|
|
||||||
log.debug("ChnPrmShip 필터 적용: {} → {} 선박", totalBefore, mergedTracks.size());
|
|
||||||
if (mergedTracks.isEmpty()) {
|
|
||||||
return buildEmptyResponse(request, targetDates, startMs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. JTS Polygon + PreparedGeometry
|
|
||||||
VesselContactRequest.SearchPolygon poly = request.getPolygon();
|
|
||||||
Polygon jtsPolygon = areaSearchService.toJtsPolygon(poly.getCoordinates());
|
|
||||||
PreparedGeometry prepared = PreparedGeometryFactory.prepare(jtsPolygon);
|
|
||||||
|
|
||||||
// 5. STRtree 후보 필터링 + 폴리곤 내부 포인트 수집
|
|
||||||
STRtree spatialIndex = areaSearchService.buildSpatialIndex(mergedTracks);
|
|
||||||
Envelope mbr = jtsPolygon.getEnvelopeInternal();
|
|
||||||
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
List<String> candidates = spatialIndex.query(mbr);
|
|
||||||
|
|
||||||
long minDurationSec = request.getMinContactDurationMinutes() * 60L;
|
|
||||||
double maxDistanceMeters = request.getMaxContactDistanceMeters();
|
|
||||||
|
|
||||||
Map<String, List<InsidePosition>> insidePositions = new HashMap<>();
|
|
||||||
for (String vesselId : candidates) {
|
|
||||||
CompactVesselTrack track = mergedTracks.get(vesselId);
|
|
||||||
if (track == null || track.getGeometry() == null) continue;
|
|
||||||
|
|
||||||
List<InsidePosition> inside = collectInsidePositions(track, prepared);
|
|
||||||
if (!inside.isEmpty()) {
|
|
||||||
insidePositions.put(vesselId, inside);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
int totalVesselsInPolygon = insidePositions.size();
|
|
||||||
log.info("Vessel contact: merged={}, insidePolygon={}, chnPrmOnly={}, dates={}",
|
|
||||||
mergedTracks.size(), totalVesselsInPolygon, request.isChnPrmShipOnly(), targetDates.size());
|
|
||||||
|
|
||||||
// 6. 시간 범위 겹침 사전 필터 + 선박 쌍별 접촉 판정
|
|
||||||
List<String> vesselIds = new ArrayList<>(insidePositions.keySet());
|
|
||||||
List<VesselContactPair> contactPairs = new ArrayList<>();
|
|
||||||
Set<String> involvedVessels = new HashSet<>();
|
|
||||||
|
|
||||||
for (int i = 0; i < vesselIds.size(); i++) {
|
|
||||||
String idA = vesselIds.get(i);
|
|
||||||
List<InsidePosition> posA = insidePositions.get(idA);
|
|
||||||
long minTsA = posA.get(0).timestamp;
|
|
||||||
long maxTsA = posA.get(posA.size() - 1).timestamp;
|
|
||||||
|
|
||||||
for (int j = i + 1; j < vesselIds.size(); j++) {
|
|
||||||
String idB = vesselIds.get(j);
|
|
||||||
List<InsidePosition> posB = insidePositions.get(idB);
|
|
||||||
long minTsB = posB.get(0).timestamp;
|
|
||||||
long maxTsB = posB.get(posB.size() - 1).timestamp;
|
|
||||||
|
|
||||||
// 시간 겹침 사전 필터 (minContactDuration 반영)
|
|
||||||
long overlap = Math.min(maxTsA, maxTsB) - Math.max(minTsA, minTsB);
|
|
||||||
if (overlap < minDurationSec) continue;
|
|
||||||
|
|
||||||
// Two-pointer 접촉 판정
|
|
||||||
List<VesselContactPair> pairs = detectContacts(
|
|
||||||
idA, posA, idB, posB,
|
|
||||||
mergedTracks.get(idA), mergedTracks.get(idB),
|
|
||||||
minDurationSec, maxDistanceMeters);
|
|
||||||
|
|
||||||
if (!pairs.isEmpty()) {
|
|
||||||
contactPairs.addAll(pairs);
|
|
||||||
involvedVessels.add(idA);
|
|
||||||
involvedVessels.add(idB);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 7. 관련 선박 트랙 수집
|
|
||||||
List<CompactVesselTrack> resultTracks = involvedVessels.stream()
|
|
||||||
.map(mergedTracks::get)
|
|
||||||
.filter(Objects::nonNull)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
|
|
||||||
long elapsedMs = System.currentTimeMillis() - startMs;
|
|
||||||
log.info("Vessel contact completed: pairs={}, vessels={}, elapsed={}ms",
|
|
||||||
contactPairs.size(), involvedVessels.size(), elapsedMs);
|
|
||||||
|
|
||||||
return VesselContactResponse.builder()
|
|
||||||
.contacts(contactPairs)
|
|
||||||
.tracks(resultTracks)
|
|
||||||
.summary(VesselContactSummary.builder()
|
|
||||||
.totalContactPairs(contactPairs.size())
|
|
||||||
.totalVesselsInvolved(involvedVessels.size())
|
|
||||||
.totalVesselsInPolygon(totalVesselsInPolygon)
|
|
||||||
.processingTimeMs(elapsedMs)
|
|
||||||
.polygonId(poly.getId())
|
|
||||||
.cachedDates(targetDates.stream()
|
|
||||||
.map(LocalDate::toString)
|
|
||||||
.collect(Collectors.toList()))
|
|
||||||
.build())
|
|
||||||
.build();
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new QueryTimeoutException("contact-search 슬롯 대기 중 인터럽트");
|
|
||||||
} finally {
|
|
||||||
if (memoryReserved) memoryBudgetManager.releaseQueryMemory(queryId);
|
|
||||||
if (slotAcquired) activeQueryManager.releaseQuerySlot(queryId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. 병합된 트랙을 직접 사용 (단일 수집원이므로 필터 불필요)
|
||||||
|
Map<String, CompactVesselTrack> filtered = mergedTracks;
|
||||||
|
|
||||||
|
// 4. JTS Polygon + PreparedGeometry
|
||||||
|
VesselContactRequest.SearchPolygon poly = request.getPolygon();
|
||||||
|
Polygon jtsPolygon = areaSearchService.toJtsPolygon(poly.getCoordinates());
|
||||||
|
PreparedGeometry prepared = PreparedGeometryFactory.prepare(jtsPolygon);
|
||||||
|
|
||||||
|
// 5. STRtree 후보 필터링 + 폴리곤 내부 포인트 수집
|
||||||
|
STRtree spatialIndex = areaSearchService.buildSpatialIndex(filtered);
|
||||||
|
Envelope mbr = jtsPolygon.getEnvelopeInternal();
|
||||||
|
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
List<String> candidates = spatialIndex.query(mbr);
|
||||||
|
|
||||||
|
long minDurationSec = request.getMinContactDurationMinutes() * 60L;
|
||||||
|
double maxDistanceMeters = request.getMaxContactDistanceMeters();
|
||||||
|
|
||||||
|
Map<String, List<InsidePosition>> insidePositions = new HashMap<>();
|
||||||
|
for (String vesselId : candidates) {
|
||||||
|
CompactVesselTrack track = filtered.get(vesselId);
|
||||||
|
if (track == null || track.getGeometry() == null) continue;
|
||||||
|
|
||||||
|
List<InsidePosition> inside = collectInsidePositions(track, prepared);
|
||||||
|
if (!inside.isEmpty()) {
|
||||||
|
insidePositions.put(vesselId, inside);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int totalVesselsInPolygon = insidePositions.size();
|
||||||
|
log.info("Vessel contact: filtered={}, insidePolygon={}, dates={}",
|
||||||
|
filtered.size(), totalVesselsInPolygon, targetDates.size());
|
||||||
|
|
||||||
|
// 6. 시간 범위 겹침 사전 필터 + 선박 쌍별 접촉 판정
|
||||||
|
List<String> vesselIds = new ArrayList<>(insidePositions.keySet());
|
||||||
|
List<VesselContactPair> contactPairs = new ArrayList<>();
|
||||||
|
Set<String> involvedVessels = new HashSet<>();
|
||||||
|
|
||||||
|
for (int i = 0; i < vesselIds.size(); i++) {
|
||||||
|
String idA = vesselIds.get(i);
|
||||||
|
List<InsidePosition> posA = insidePositions.get(idA);
|
||||||
|
long minTsA = posA.get(0).timestamp;
|
||||||
|
long maxTsA = posA.get(posA.size() - 1).timestamp;
|
||||||
|
|
||||||
|
for (int j = i + 1; j < vesselIds.size(); j++) {
|
||||||
|
String idB = vesselIds.get(j);
|
||||||
|
List<InsidePosition> posB = insidePositions.get(idB);
|
||||||
|
long minTsB = posB.get(0).timestamp;
|
||||||
|
long maxTsB = posB.get(posB.size() - 1).timestamp;
|
||||||
|
|
||||||
|
// 시간 겹침 사전 필터 (minContactDuration 반영)
|
||||||
|
long overlap = Math.min(maxTsA, maxTsB) - Math.max(minTsA, minTsB);
|
||||||
|
if (overlap < minDurationSec) continue;
|
||||||
|
|
||||||
|
// Two-pointer 접촉 판정
|
||||||
|
List<VesselContactPair> pairs = detectContacts(
|
||||||
|
idA, posA, idB, posB,
|
||||||
|
filtered.get(idA), filtered.get(idB),
|
||||||
|
minDurationSec, maxDistanceMeters);
|
||||||
|
|
||||||
|
if (!pairs.isEmpty()) {
|
||||||
|
contactPairs.addAll(pairs);
|
||||||
|
involvedVessels.add(idA);
|
||||||
|
involvedVessels.add(idB);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 관련 선박 트랙 수집
|
||||||
|
List<CompactVesselTrack> resultTracks = involvedVessels.stream()
|
||||||
|
.map(mergedTracks::get)
|
||||||
|
.filter(Objects::nonNull)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
long elapsedMs = System.currentTimeMillis() - startMs;
|
||||||
|
log.info("Vessel contact completed: pairs={}, vessels={}, elapsed={}ms",
|
||||||
|
contactPairs.size(), involvedVessels.size(), elapsedMs);
|
||||||
|
|
||||||
|
return VesselContactResponse.builder()
|
||||||
|
.contacts(contactPairs)
|
||||||
|
.tracks(resultTracks)
|
||||||
|
.summary(VesselContactSummary.builder()
|
||||||
|
.totalContactPairs(contactPairs.size())
|
||||||
|
.totalVesselsInvolved(involvedVessels.size())
|
||||||
|
.totalVesselsInPolygon(totalVesselsInPolygon)
|
||||||
|
.processingTimeMs(elapsedMs)
|
||||||
|
.polygonId(poly.getId())
|
||||||
|
.cachedDates(targetDates.stream()
|
||||||
|
.map(LocalDate::toString)
|
||||||
|
.collect(Collectors.toList()))
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 입력 검증 ──
|
// ── 입력 검증 ──
|
||||||
@ -211,13 +173,10 @@ public class VesselContactService {
|
|||||||
List<double[]> geometry = track.getGeometry();
|
List<double[]> geometry = track.getGeometry();
|
||||||
List<String> timestamps = track.getTimestamps();
|
List<String> timestamps = track.getTimestamps();
|
||||||
List<InsidePosition> inside = new ArrayList<>();
|
List<InsidePosition> inside = new ArrayList<>();
|
||||||
Coordinate reusable = new Coordinate();
|
|
||||||
|
|
||||||
for (int i = 0; i < geometry.size(); i++) {
|
for (int i = 0; i < geometry.size(); i++) {
|
||||||
double[] coord = geometry.get(i);
|
double[] coord = geometry.get(i);
|
||||||
reusable.x = coord[0];
|
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
|
||||||
reusable.y = coord[1];
|
|
||||||
Point point = GEOMETRY_FACTORY.createPoint(reusable);
|
|
||||||
if (prepared.contains(point)) {
|
if (prepared.contains(point)) {
|
||||||
long ts = parseTimestamp(timestamps, i);
|
long ts = parseTimestamp(timestamps, i);
|
||||||
inside.add(new InsidePosition(ts, coord[0], coord[1]));
|
inside.add(new InsidePosition(ts, coord[0], coord[1]));
|
||||||
@ -273,7 +232,7 @@ public class VesselContactService {
|
|||||||
long diff = Math.abs(a.timestamp - b.timestamp);
|
long diff = Math.abs(a.timestamp - b.timestamp);
|
||||||
|
|
||||||
if (diff <= SYNC_TOLERANCE_SEC) {
|
if (diff <= SYNC_TOLERANCE_SEC) {
|
||||||
double dist = equirectangularMeters(a.lat, a.lon, b.lat, b.lon);
|
double dist = haversineMeters(a.lat, a.lon, b.lat, b.lon);
|
||||||
long ts = Math.min(a.timestamp, b.timestamp) + diff / 2; // 중간 시각
|
long ts = Math.min(a.timestamp, b.timestamp) + diff / 2; // 중간 시각
|
||||||
matched.add(new MatchedPoint(ts, dist, a, b));
|
matched.add(new MatchedPoint(ts, dist, a, b));
|
||||||
pA++;
|
pA++;
|
||||||
@ -319,19 +278,13 @@ public class VesselContactService {
|
|||||||
long contactEnd = segment.get(segment.size() - 1).timestamp;
|
long contactEnd = segment.get(segment.size() - 1).timestamp;
|
||||||
long durationMin = (contactEnd - contactStart) / 60;
|
long durationMin = (contactEnd - contactStart) / 60;
|
||||||
|
|
||||||
// 단일 루프로 거리/중심점 동시 계산
|
DoubleSummaryStatistics distStats = segment.stream()
|
||||||
double minDist = Double.MAX_VALUE, maxDist = 0, sumDist = 0;
|
.mapToDouble(p -> p.distanceMeters)
|
||||||
double sumCenterLon = 0, sumCenterLat = 0;
|
.summaryStatistics();
|
||||||
for (MatchedPoint p : segment) {
|
|
||||||
if (p.distanceMeters < minDist) minDist = p.distanceMeters;
|
// 접촉 중심점 계산
|
||||||
if (p.distanceMeters > maxDist) maxDist = p.distanceMeters;
|
double centerLon = segment.stream().mapToDouble(p -> (p.posA.lon + p.posB.lon) / 2).average().orElse(0);
|
||||||
sumDist += p.distanceMeters;
|
double centerLat = segment.stream().mapToDouble(p -> (p.posA.lat + p.posB.lat) / 2).average().orElse(0);
|
||||||
sumCenterLon += (p.posA.lon + p.posB.lon) / 2;
|
|
||||||
sumCenterLat += (p.posA.lat + p.posB.lat) / 2;
|
|
||||||
}
|
|
||||||
double avgDist = sumDist / segment.size();
|
|
||||||
double centerLon = sumCenterLon / segment.size();
|
|
||||||
double centerLat = sumCenterLat / segment.size();
|
|
||||||
|
|
||||||
// 각 선박의 접촉 구간 내 inside 포인트로 추정 속도 계산
|
// 각 선박의 접촉 구간 내 inside 포인트로 추정 속도 계산
|
||||||
double speedA = estimateAvgSpeed(insidePosA, contactStart, contactEnd);
|
double speedA = estimateAvgSpeed(insidePosA, contactStart, contactEnd);
|
||||||
@ -346,9 +299,9 @@ public class VesselContactService {
|
|||||||
.contactStartTimestamp(contactStart)
|
.contactStartTimestamp(contactStart)
|
||||||
.contactEndTimestamp(contactEnd)
|
.contactEndTimestamp(contactEnd)
|
||||||
.contactDurationMinutes(durationMin)
|
.contactDurationMinutes(durationMin)
|
||||||
.minDistanceMeters(Math.round(minDist * 10.0) / 10.0)
|
.minDistanceMeters(Math.round(distStats.getMin() * 10.0) / 10.0)
|
||||||
.avgDistanceMeters(Math.round(avgDist * 10.0) / 10.0)
|
.avgDistanceMeters(Math.round(distStats.getAverage() * 10.0) / 10.0)
|
||||||
.maxDistanceMeters(Math.round(maxDist * 10.0) / 10.0)
|
.maxDistanceMeters(Math.round(distStats.getMax() * 10.0) / 10.0)
|
||||||
.contactCenterPoint(new double[]{
|
.contactCenterPoint(new double[]{
|
||||||
Math.round(centerLon * 1_000_000.0) / 1_000_000.0,
|
Math.round(centerLon * 1_000_000.0) / 1_000_000.0,
|
||||||
Math.round(centerLat * 1_000_000.0) / 1_000_000.0})
|
Math.round(centerLat * 1_000_000.0) / 1_000_000.0})
|
||||||
@ -407,15 +360,27 @@ public class VesselContactService {
|
|||||||
* 접촉 구간이 22:00~06:00 KST에 포함되는지 판단.
|
* 접촉 구간이 22:00~06:00 KST에 포함되는지 판단.
|
||||||
*/
|
*/
|
||||||
private boolean isNightTimeContact(long contactStartSec, long contactEndSec) {
|
private boolean isNightTimeContact(long contactStartSec, long contactEndSec) {
|
||||||
ZonedDateTime startKst = Instant.ofEpochSecond(contactStartSec).atZone(KST);
|
Instant startInstant = Instant.ofEpochSecond(contactStartSec);
|
||||||
ZonedDateTime endKst = Instant.ofEpochSecond(contactEndSec).atZone(KST);
|
Instant endInstant = Instant.ofEpochSecond(contactEndSec);
|
||||||
|
|
||||||
// 각 날짜의 야간 구간(22:00~익일 06:00)과 접촉 구간 겹침 체크
|
ZonedDateTime startKst = startInstant.atZone(KST);
|
||||||
|
ZonedDateTime endKst = endInstant.atZone(KST);
|
||||||
|
|
||||||
|
// 접촉 구간 내 모든 날짜에 대해 야간 시간대 겹침 체크
|
||||||
LocalDate day = startKst.toLocalDate();
|
LocalDate day = startKst.toLocalDate();
|
||||||
while (!day.isAfter(endKst.toLocalDate())) {
|
LocalDate lastDay = endKst.toLocalDate().plusDays(1);
|
||||||
ZonedDateTime nightStart = day.atTime(22, 0).atZone(KST);
|
|
||||||
ZonedDateTime nightEnd = day.plusDays(1).atTime(6, 0).atZone(KST);
|
while (!day.isAfter(lastDay)) {
|
||||||
if (startKst.isBefore(nightEnd) && endKst.isAfter(nightStart)) {
|
// 해당 날짜의 야간: 전날 22:00 ~ 당일 06:00
|
||||||
|
ZonedDateTime nightStart = day.atTime(LocalTime.of(22, 0)).atZone(KST).minusDays(1);
|
||||||
|
ZonedDateTime nightEnd = day.atTime(LocalTime.of(6, 0)).atZone(KST);
|
||||||
|
|
||||||
|
// 당일 22:00 ~ 다음날 06:00
|
||||||
|
ZonedDateTime nightStart2 = day.atTime(LocalTime.of(22, 0)).atZone(KST);
|
||||||
|
ZonedDateTime nightEnd2 = day.plusDays(1).atTime(LocalTime.of(6, 0)).atZone(KST);
|
||||||
|
|
||||||
|
if (isOverlapping(startKst, endKst, nightStart, nightEnd)
|
||||||
|
|| isOverlapping(startKst, endKst, nightStart2, nightEnd2)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
day = day.plusDays(1);
|
day = day.plusDays(1);
|
||||||
@ -423,6 +388,11 @@ public class VesselContactService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean isOverlapping(ZonedDateTime s1, ZonedDateTime e1,
|
||||||
|
ZonedDateTime s2, ZonedDateTime e2) {
|
||||||
|
return s1.isBefore(e2) && s2.isBefore(e1);
|
||||||
|
}
|
||||||
|
|
||||||
// ── 추정 속도 계산 ──
|
// ── 추정 속도 계산 ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -454,16 +424,16 @@ public class VesselContactService {
|
|||||||
return totalHours > 0 ? totalDistNm / totalHours : 0.0;
|
return totalHours > 0 ? totalDistNm / totalHours : 0.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 거리 계산 ──
|
// ── Haversine 거리 계산 ──
|
||||||
|
|
||||||
/**
|
private double haversineMeters(double lat1, double lon1, double lat2, double lon2) {
|
||||||
* Equirectangular 근사 — 접촉 거리 판정용 (10km 이내 오차 < 0.1%)
|
|
||||||
* Haversine 대비 ~2배 빠름 (Math.cos 1회 + Math.sqrt 1회)
|
|
||||||
*/
|
|
||||||
private double equirectangularMeters(double lat1, double lon1, double lat2, double lon2) {
|
|
||||||
double dLat = Math.toRadians(lat2 - lat1);
|
double dLat = Math.toRadians(lat2 - lat1);
|
||||||
double dLon = Math.toRadians(lon2 - lon1) * Math.cos(Math.toRadians((lat1 + lat2) / 2));
|
double dLon = Math.toRadians(lon2 - lon1);
|
||||||
return EARTH_RADIUS_M * Math.sqrt(dLat * dLat + dLon * dLon);
|
double a = Math.sin(dLat / 2) * Math.sin(dLat / 2)
|
||||||
|
+ Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2))
|
||||||
|
* Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
||||||
|
double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||||
|
return EARTH_RADIUS_M * c;
|
||||||
}
|
}
|
||||||
|
|
||||||
private double haversineNm(double lat1, double lon1, double lat2, double lon2) {
|
private double haversineNm(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
|||||||
@ -72,10 +72,10 @@ public class SequentialPassageController {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
results = trackingService.findSequentialGridPassages(
|
results = trackingService.findSequentialGridPassages(
|
||||||
haeguNumbers, request.getStartTime(), request.getEndTime(), request.isChnPrmShipOnly());
|
haeguNumbers, request.getStartTime(), request.getEndTime());
|
||||||
} else {
|
} else {
|
||||||
results = trackingService.findSequentialAreaPassages(
|
results = trackingService.findSequentialAreaPassages(
|
||||||
request.getZoneIds(), request.getStartTime(), request.getEndTime(), request.isChnPrmShipOnly());
|
request.getZoneIds(), request.getStartTime(), request.getEndTime());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 응답 구성
|
// 응답 구성
|
||||||
|
|||||||
@ -57,10 +57,6 @@ public class SequentialPassageRequest {
|
|||||||
@Schema(description = "순차 통과 여부 (true: 순서대로 통과, false: 모든 구역 통과)", example = "true", defaultValue = "true")
|
@Schema(description = "순차 통과 여부 (true: 순서대로 통과, false: 모든 구역 통과)", example = "true", defaultValue = "true")
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
private Boolean sequentialOnly = true;
|
private Boolean sequentialOnly = true;
|
||||||
|
|
||||||
@Schema(description = "true 시 중국허가선박만 대상으로 순차 통과 조회", example = "false")
|
|
||||||
@Builder.Default
|
|
||||||
private boolean chnPrmShipOnly = false;
|
|
||||||
|
|
||||||
public enum PassageType {
|
public enum PassageType {
|
||||||
GRID, AREA
|
GRID, AREA
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package gc.mda.signal_batch.domain.passage.service;
|
package gc.mda.signal_batch.domain.passage.service;
|
||||||
|
|
||||||
import gc.mda.signal_batch.batch.reader.ChnPrmShipProperties;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
@ -9,10 +8,8 @@ import org.springframework.stereotype.Service;
|
|||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.sql.Timestamp;
|
import java.sql.Timestamp;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 순차 구역 통과 선박 조회 최적화 서비스
|
* 순차 구역 통과 선박 조회 최적화 서비스
|
||||||
@ -25,140 +22,120 @@ import java.util.Set;
|
|||||||
public class SequentialAreaTrackingService {
|
public class SequentialAreaTrackingService {
|
||||||
|
|
||||||
private final DataSource queryDataSource;
|
private final DataSource queryDataSource;
|
||||||
private final ChnPrmShipProperties chnPrmShipProperties;
|
|
||||||
|
|
||||||
public SequentialAreaTrackingService(@Qualifier("queryDataSource") DataSource queryDataSource,
|
public SequentialAreaTrackingService(@Qualifier("queryDataSource") DataSource queryDataSource) {
|
||||||
ChnPrmShipProperties chnPrmShipProperties) {
|
|
||||||
this.queryDataSource = queryDataSource;
|
this.queryDataSource = queryDataSource;
|
||||||
this.chnPrmShipProperties = chnPrmShipProperties;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 순차적으로 지정된 구역들을 통과한 선박 조회 (Grid)
|
* 순차적으로 지정된 구역들을 통과한 선박 조회 (Grid)
|
||||||
* 동적 N-구역 SQL JOIN 생성 (2~10개)
|
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> findSequentialGridPassages(
|
public List<Map<String, Object>> findSequentialGridPassages(
|
||||||
List<Integer> haeguNumbers,
|
List<Integer> haeguNumbers,
|
||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime) {
|
||||||
boolean chnPrmShipOnly) {
|
|
||||||
|
|
||||||
int n = haeguNumbers.size();
|
|
||||||
if (n < 2 || n > 10) {
|
|
||||||
throw new IllegalArgumentException("구역은 2~10개까지 지정 가능합니다: " + n);
|
|
||||||
}
|
|
||||||
|
|
||||||
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
||||||
|
|
||||||
StringBuilder sql = new StringBuilder();
|
// MATERIALIZED CTE 사용으로 중간 결과 고정
|
||||||
sql.append("WITH vessel_passages AS (\n");
|
String sql = """
|
||||||
sql.append(" SELECT DISTINCT mmsi, haegu_no,\n");
|
WITH vessel_passages AS (
|
||||||
sql.append(" FIRST_VALUE(time_bucket) OVER (PARTITION BY mmsi, haegu_no ORDER BY time_bucket) as entry_time,\n");
|
SELECT DISTINCT
|
||||||
sql.append(" LAST_VALUE(time_bucket) OVER (PARTITION BY mmsi, haegu_no ORDER BY time_bucket ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as exit_time\n");
|
mmsi,
|
||||||
sql.append(" FROM signal.t_grid_vessel_tracks\n");
|
haegu_no,
|
||||||
sql.append(" WHERE time_bucket BETWEEN ? AND ?\n");
|
FIRST_VALUE(time_bucket) OVER (
|
||||||
sql.append(" AND haegu_no = ANY(ARRAY[?]::integer[])\n");
|
PARTITION BY mmsi, haegu_no
|
||||||
if (chnPrmShipOnly) {
|
ORDER BY time_bucket
|
||||||
sql.append(" AND mmsi = ANY(ARRAY[?]::varchar[])\n");
|
) as entry_time,
|
||||||
}
|
LAST_VALUE(time_bucket) OVER (
|
||||||
sql.append(")\n");
|
PARTITION BY mmsi, haegu_no
|
||||||
|
ORDER BY time_bucket
|
||||||
// SELECT 컬럼 동적 생성
|
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
||||||
sql.append("SELECT v1.mmsi");
|
) as exit_time
|
||||||
for (int i = 1; i <= n; i++) {
|
FROM signal.t_grid_vessel_tracks
|
||||||
sql.append(String.format(", v%d.entry_time as haegu%d_entry, v%d.exit_time as haegu%d_exit", i, i, i, i));
|
WHERE time_bucket BETWEEN ? AND ?
|
||||||
}
|
AND haegu_no = ANY(ARRAY[?]::integer[])
|
||||||
sql.append("\nFROM vessel_passages v1\n");
|
)
|
||||||
|
SELECT
|
||||||
// JOIN 동적 생성 (v2~vN)
|
v1.mmsi,
|
||||||
for (int i = 2; i <= n; i++) {
|
v1.entry_time as haegu1_entry,
|
||||||
sql.append(String.format("JOIN vessel_passages v%d ON v%d.mmsi = v1.mmsi AND v%d.haegu_no = ? AND v%d.entry_time > v%d.exit_time\n",
|
v1.exit_time as haegu1_exit,
|
||||||
i, i, i, i, i - 1));
|
v2.entry_time as haegu2_entry,
|
||||||
}
|
v2.exit_time as haegu2_exit,
|
||||||
sql.append("WHERE v1.haegu_no = ?\n");
|
v3.entry_time as haegu3_entry,
|
||||||
sql.append("ORDER BY v1.entry_time");
|
v3.exit_time as haegu3_exit
|
||||||
|
FROM vessel_passages v1
|
||||||
// 파라미터 구성
|
JOIN vessel_passages v2 ON v1.mmsi = v2.mmsi
|
||||||
List<Object> params = new ArrayList<>();
|
AND v2.haegu_no = ? AND v2.entry_time > v1.exit_time
|
||||||
params.add(Timestamp.valueOf(startTime));
|
JOIN vessel_passages v3 ON v2.mmsi = v3.mmsi
|
||||||
params.add(Timestamp.valueOf(endTime));
|
AND v3.haegu_no = ? AND v3.entry_time > v2.exit_time
|
||||||
params.add(haeguNumbers.toArray(Integer[]::new));
|
WHERE v1.haegu_no = ?
|
||||||
if (chnPrmShipOnly) {
|
ORDER BY v1.entry_time
|
||||||
Set<String> mmsiSet = chnPrmShipProperties.getMmsiSet();
|
""";
|
||||||
params.add(mmsiSet.toArray(String[]::new));
|
|
||||||
}
|
return jdbcTemplate.queryForList(sql,
|
||||||
// v2~vN의 haegu_no 파라미터
|
Timestamp.valueOf(startTime),
|
||||||
for (int i = 1; i < n; i++) {
|
Timestamp.valueOf(endTime),
|
||||||
params.add(haeguNumbers.get(i));
|
haeguNumbers.toArray(Integer[]::new),
|
||||||
}
|
haeguNumbers.get(1),
|
||||||
// v1의 haegu_no WHERE 조건
|
haeguNumbers.get(2),
|
||||||
params.add(haeguNumbers.get(0));
|
haeguNumbers.get(0)
|
||||||
|
);
|
||||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 순차적으로 지정된 구역들을 통과한 선박 조회 (Area)
|
* 순차적으로 지정된 구역들을 통과한 선박 조회 (Area)
|
||||||
* 동적 N-구역 SQL JOIN 생성 (2~10개)
|
|
||||||
*/
|
*/
|
||||||
public List<Map<String, Object>> findSequentialAreaPassages(
|
public List<Map<String, Object>> findSequentialAreaPassages(
|
||||||
List<String> areaIds,
|
List<String> areaIds,
|
||||||
LocalDateTime startTime,
|
LocalDateTime startTime,
|
||||||
LocalDateTime endTime,
|
LocalDateTime endTime) {
|
||||||
boolean chnPrmShipOnly) {
|
|
||||||
|
|
||||||
int n = areaIds.size();
|
|
||||||
if (n < 2 || n > 10) {
|
|
||||||
throw new IllegalArgumentException("구역은 2~10개까지 지정 가능합니다: " + n);
|
|
||||||
}
|
|
||||||
|
|
||||||
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
|
||||||
|
|
||||||
StringBuilder sql = new StringBuilder();
|
String sql = """
|
||||||
sql.append("WITH area_passages AS (\n");
|
WITH area_passages AS (
|
||||||
sql.append(" SELECT DISTINCT mmsi, area_id,\n");
|
SELECT DISTINCT
|
||||||
sql.append(" FIRST_VALUE(time_bucket) OVER (PARTITION BY mmsi, area_id ORDER BY time_bucket) as entry_time,\n");
|
mmsi,
|
||||||
sql.append(" LAST_VALUE(time_bucket) OVER (PARTITION BY mmsi, area_id ORDER BY time_bucket ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as exit_time\n");
|
area_id,
|
||||||
sql.append(" FROM signal.t_area_vessel_tracks\n");
|
FIRST_VALUE(time_bucket) OVER (
|
||||||
sql.append(" WHERE time_bucket BETWEEN ? AND ?\n");
|
PARTITION BY mmsi, area_id
|
||||||
sql.append(" AND area_id = ANY(ARRAY[?]::varchar[])\n");
|
ORDER BY time_bucket
|
||||||
if (chnPrmShipOnly) {
|
) as entry_time,
|
||||||
sql.append(" AND mmsi = ANY(ARRAY[?]::varchar[])\n");
|
LAST_VALUE(time_bucket) OVER (
|
||||||
}
|
PARTITION BY mmsi, area_id
|
||||||
sql.append(")\n");
|
ORDER BY time_bucket
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
|
||||||
// SELECT 컬럼 동적 생성
|
) as exit_time
|
||||||
sql.append("SELECT a1.mmsi");
|
FROM signal.t_area_vessel_tracks
|
||||||
for (int i = 1; i <= n; i++) {
|
WHERE time_bucket BETWEEN ? AND ?
|
||||||
sql.append(String.format(", a%d.entry_time as area%d_entry, a%d.exit_time as area%d_exit", i, i, i, i));
|
AND area_id = ANY(ARRAY[?]::varchar[])
|
||||||
}
|
)
|
||||||
sql.append("\nFROM area_passages a1\n");
|
SELECT
|
||||||
|
a1.mmsi,
|
||||||
// JOIN 동적 생성 (a2~aN)
|
a1.entry_time as area1_entry,
|
||||||
for (int i = 2; i <= n; i++) {
|
a1.exit_time as area1_exit,
|
||||||
sql.append(String.format("JOIN area_passages a%d ON a%d.mmsi = a1.mmsi AND a%d.area_id = ? AND a%d.entry_time > a%d.exit_time\n",
|
a2.entry_time as area2_entry,
|
||||||
i, i, i, i, i - 1));
|
a2.exit_time as area2_exit,
|
||||||
}
|
a3.entry_time as area3_entry,
|
||||||
sql.append("WHERE a1.area_id = ?\n");
|
a3.exit_time as area3_exit
|
||||||
sql.append("ORDER BY a1.entry_time");
|
FROM area_passages a1
|
||||||
|
JOIN area_passages a2 ON a1.mmsi = a2.mmsi
|
||||||
// 파라미터 구성
|
AND a2.area_id = ? AND a2.entry_time > a1.exit_time
|
||||||
List<Object> params = new ArrayList<>();
|
JOIN area_passages a3 ON a2.mmsi = a3.mmsi
|
||||||
params.add(Timestamp.valueOf(startTime));
|
AND a3.area_id = ? AND a3.entry_time > a2.exit_time
|
||||||
params.add(Timestamp.valueOf(endTime));
|
WHERE a1.area_id = ?
|
||||||
params.add(areaIds.toArray(String[]::new));
|
ORDER BY a1.entry_time
|
||||||
if (chnPrmShipOnly) {
|
""";
|
||||||
Set<String> mmsiSet = chnPrmShipProperties.getMmsiSet();
|
|
||||||
params.add(mmsiSet.toArray(String[]::new));
|
return jdbcTemplate.queryForList(sql,
|
||||||
}
|
Timestamp.valueOf(startTime),
|
||||||
// a2~aN의 area_id 파라미터
|
Timestamp.valueOf(endTime),
|
||||||
for (int i = 1; i < n; i++) {
|
areaIds.toArray(String[]::new),
|
||||||
params.add(areaIds.get(i));
|
areaIds.get(1),
|
||||||
}
|
areaIds.get(2),
|
||||||
// a1의 area_id WHERE 조건
|
areaIds.get(0)
|
||||||
params.add(areaIds.get(0));
|
);
|
||||||
|
|
||||||
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,61 +0,0 @@
|
|||||||
package gc.mda.signal_batch.domain.vessel.dto;
|
|
||||||
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.NoArgsConstructor;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 선박 위치 상세 조회 요청
|
|
||||||
*
|
|
||||||
* 공간 필터 사용법:
|
|
||||||
* - 폴리곤/사각형: coordinates에 닫힌 좌표 배열 전달
|
|
||||||
* - 원: center + radiusNm 전달 (서버에서 64점 폴리곤으로 변환)
|
|
||||||
* - 전체 조회: coordinates와 center 모두 null
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
@NoArgsConstructor
|
|
||||||
@AllArgsConstructor
|
|
||||||
@Schema(description = "최근 선박 위치 상세 조회 요청 (공간 필터 지원)")
|
|
||||||
public class RecentPositionDetailRequest {
|
|
||||||
|
|
||||||
@Schema(description = "조회 시간 범위 (분 단위, 1~1440)", example = "5")
|
|
||||||
@Builder.Default
|
|
||||||
private int minutes = 5;
|
|
||||||
|
|
||||||
@Schema(description = "폴리곤/사각형 좌표 배열 [[lon,lat],...] — 첫점과 끝점 동일",
|
|
||||||
example = "[[125,33],[130,33],[130,37],[125,37],[125,33]]")
|
|
||||||
private List<double[]> coordinates;
|
|
||||||
|
|
||||||
@Schema(description = "원 중심 좌표 [lon, lat]", example = "[129, 35]")
|
|
||||||
private double[] center;
|
|
||||||
|
|
||||||
@Schema(description = "원 반경 (해리, NM)", example = "50")
|
|
||||||
private Double radiusNm;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 공간 필터가 지정되었는지 확인
|
|
||||||
*/
|
|
||||||
public boolean hasSpatialFilter() {
|
|
||||||
return (coordinates != null && !coordinates.isEmpty())
|
|
||||||
|| (center != null && radiusNm != null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원형 필터인지 확인
|
|
||||||
*/
|
|
||||||
public boolean isCircleFilter() {
|
|
||||||
return center != null && center.length == 2 && radiusNm != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 폴리곤/사각형 필터인지 확인
|
|
||||||
*/
|
|
||||||
public boolean isPolygonFilter() {
|
|
||||||
return coordinates != null && coordinates.size() >= 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,87 +0,0 @@
|
|||||||
package gc.mda.signal_batch.domain.vessel.dto;
|
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
|
||||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 선박 위치 상세 응답
|
|
||||||
*
|
|
||||||
* 기존 RecentVesselPositionDto 전체 필드 + AIS 상세 정보 확장
|
|
||||||
*/
|
|
||||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
|
||||||
@Schema(description = "최근 선박 위치 상세 정보 (AIS 확장 필드 포함)")
|
|
||||||
public record RecentPositionDetailResponse(
|
|
||||||
|
|
||||||
// ── 기존 필드 (RecentVesselPositionDto 호환) ──
|
|
||||||
|
|
||||||
@Schema(description = "MMSI", example = "440113620")
|
|
||||||
String mmsi,
|
|
||||||
|
|
||||||
@Schema(description = "IMO 번호", example = "9141833")
|
|
||||||
Long imo,
|
|
||||||
|
|
||||||
@Schema(description = "경도 (WGS84)", example = "127.0638")
|
|
||||||
Double lon,
|
|
||||||
|
|
||||||
@Schema(description = "위도 (WGS84)", example = "34.227527")
|
|
||||||
Double lat,
|
|
||||||
|
|
||||||
@Schema(description = "대지속도 (knots)", example = "10.4")
|
|
||||||
BigDecimal sog,
|
|
||||||
|
|
||||||
@Schema(description = "대지침로 (도)", example = "215.3")
|
|
||||||
BigDecimal cog,
|
|
||||||
|
|
||||||
@Schema(description = "선박명", example = "SAM SUNG 2HO")
|
|
||||||
String shipNm,
|
|
||||||
|
|
||||||
@Schema(description = "선박 유형 (AIS ship type)", example = "74")
|
|
||||||
String shipTy,
|
|
||||||
|
|
||||||
@Schema(description = "선박 종류 코드", example = "000023")
|
|
||||||
String shipKindCode,
|
|
||||||
|
|
||||||
@Schema(description = "국가 코드 (MID 기반)", example = "KR")
|
|
||||||
String nationalCode,
|
|
||||||
|
|
||||||
@Schema(description = "최종 업데이트 시간", example = "2026-03-17 12:05:00")
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
|
||||||
LocalDateTime lastUpdate,
|
|
||||||
|
|
||||||
@Schema(description = "선박 사진 썸네일 경로")
|
|
||||||
String shipImagePath,
|
|
||||||
|
|
||||||
@Schema(description = "선박 사진 수")
|
|
||||||
Integer shipImageCount,
|
|
||||||
|
|
||||||
// ── 확장 필드 (AIS 상세) ──
|
|
||||||
|
|
||||||
@Schema(description = "침로 (0~360도)", example = "215.0")
|
|
||||||
Double heading,
|
|
||||||
|
|
||||||
@Schema(description = "호출 부호", example = "HLBQ")
|
|
||||||
String callSign,
|
|
||||||
|
|
||||||
@Schema(description = "항해 상태", example = "Under way using engine")
|
|
||||||
String status,
|
|
||||||
|
|
||||||
@Schema(description = "목적지", example = "BUSAN")
|
|
||||||
String destination,
|
|
||||||
|
|
||||||
@Schema(description = "도착 예정시간", example = "2026-03-18 08:00:00")
|
|
||||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
|
||||||
LocalDateTime eta,
|
|
||||||
|
|
||||||
@Schema(description = "흘수 (m)", example = "6.5")
|
|
||||||
Double draught,
|
|
||||||
|
|
||||||
@Schema(description = "선박 길이 (m)", example = "180")
|
|
||||||
Integer length,
|
|
||||||
|
|
||||||
@Schema(description = "선박 폭 (m)", example = "28")
|
|
||||||
Integer width
|
|
||||||
) {}
|
|
||||||
@ -1,189 +0,0 @@
|
|||||||
package gc.mda.signal_batch.domain.vessel.service;
|
|
||||||
|
|
||||||
import gc.mda.signal_batch.batch.reader.AisTargetCacheManager;
|
|
||||||
import gc.mda.signal_batch.domain.ship.service.ShipImageService;
|
|
||||||
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
|
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailRequest;
|
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailResponse;
|
|
||||||
import gc.mda.signal_batch.domain.vessel.model.AisTargetEntity;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.locationtech.jts.geom.*;
|
|
||||||
import org.locationtech.jts.geom.prep.PreparedGeometry;
|
|
||||||
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.time.OffsetDateTime;
|
|
||||||
import java.time.ZoneId;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 선박 위치 상세 조회 서비스
|
|
||||||
*
|
|
||||||
* AisTargetCacheManager(~33K, 1분 갱신)에서 직접 조회하여
|
|
||||||
* 시간 필터 + 공간 필터(폴리곤/원) 적용 후 상세 정보 반환
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class VesselPositionDetailService {
|
|
||||||
|
|
||||||
private final AisTargetCacheManager aisTargetCacheManager;
|
|
||||||
private final ShipImageService shipImageService;
|
|
||||||
|
|
||||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
|
||||||
private static final int CIRCLE_POINTS = 64;
|
|
||||||
private static final double EARTH_RADIUS_NM = 3440.065;
|
|
||||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 선박 위치 상세 조회
|
|
||||||
*/
|
|
||||||
public List<RecentPositionDetailResponse> getRecentPositionsDetail(RecentPositionDetailRequest request) {
|
|
||||||
long startMs = System.currentTimeMillis();
|
|
||||||
|
|
||||||
Collection<AisTargetEntity> allEntities = aisTargetCacheManager.getAllValues();
|
|
||||||
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(request.getMinutes());
|
|
||||||
|
|
||||||
// 공간 필터 준비 (null이면 전체)
|
|
||||||
PreparedGeometry spatialFilter = buildSpatialFilter(request);
|
|
||||||
|
|
||||||
// 단일 루프: 시간 필터 + 공간 필터 + 변환
|
|
||||||
List<RecentPositionDetailResponse> results = new ArrayList<>(1000);
|
|
||||||
Coordinate reusable = new Coordinate();
|
|
||||||
|
|
||||||
for (AisTargetEntity entity : allEntities) {
|
|
||||||
// 시간 필터
|
|
||||||
if (entity.getMessageTimestamp() == null || entity.getMessageTimestamp().isBefore(threshold)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 위치 필수
|
|
||||||
if (entity.getLat() == null || entity.getLon() == null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 공간 필터
|
|
||||||
if (spatialFilter != null) {
|
|
||||||
reusable.x = entity.getLon();
|
|
||||||
reusable.y = entity.getLat();
|
|
||||||
Point point = GEOMETRY_FACTORY.createPoint(reusable);
|
|
||||||
if (!spatialFilter.contains(point)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
results.add(toResponse(entity));
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug("recent-positions-detail: {}건 / {}ms (전체: {}, minutes: {})",
|
|
||||||
results.size(), System.currentTimeMillis() - startMs,
|
|
||||||
allEntities.size(), request.getMinutes());
|
|
||||||
|
|
||||||
return results;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청에서 공간 필터(PreparedGeometry) 생성
|
|
||||||
*/
|
|
||||||
private PreparedGeometry buildSpatialFilter(RecentPositionDetailRequest request) {
|
|
||||||
if (!request.hasSpatialFilter()) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Polygon polygon;
|
|
||||||
if (request.isCircleFilter()) {
|
|
||||||
polygon = createCirclePolygon(
|
|
||||||
request.getCenter()[0], request.getCenter()[1],
|
|
||||||
request.getRadiusNm());
|
|
||||||
} else if (request.isPolygonFilter()) {
|
|
||||||
polygon = createPolygonFromCoordinates(request.getCoordinates());
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return PreparedGeometryFactory.prepare(polygon);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 좌표 배열 → JTS Polygon
|
|
||||||
*/
|
|
||||||
private Polygon createPolygonFromCoordinates(List<double[]> coordinates) {
|
|
||||||
Coordinate[] coords = new Coordinate[coordinates.size()];
|
|
||||||
for (int i = 0; i < coordinates.size(); i++) {
|
|
||||||
double[] c = coordinates.get(i);
|
|
||||||
coords[i] = new Coordinate(c[0], c[1]);
|
|
||||||
}
|
|
||||||
return GEOMETRY_FACTORY.createPolygon(coords);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 원 → 64점 폴리곤 변환 (equirectangular 근사)
|
|
||||||
*/
|
|
||||||
private Polygon createCirclePolygon(double centerLon, double centerLat, double radiusNm) {
|
|
||||||
double radiusRad = radiusNm / EARTH_RADIUS_NM;
|
|
||||||
double cosLat = Math.cos(Math.toRadians(centerLat));
|
|
||||||
|
|
||||||
Coordinate[] coords = new Coordinate[CIRCLE_POINTS + 1];
|
|
||||||
for (int i = 0; i < CIRCLE_POINTS; i++) {
|
|
||||||
double angle = 2.0 * Math.PI * i / CIRCLE_POINTS;
|
|
||||||
double dLat = Math.toDegrees(radiusRad * Math.cos(angle));
|
|
||||||
double dLon = Math.toDegrees(radiusRad * Math.sin(angle) / cosLat);
|
|
||||||
coords[i] = new Coordinate(centerLon + dLon, centerLat + dLat);
|
|
||||||
}
|
|
||||||
coords[CIRCLE_POINTS] = coords[0]; // 닫기
|
|
||||||
return GEOMETRY_FACTORY.createPolygon(coords);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AisTargetEntity → RecentPositionDetailResponse 변환
|
|
||||||
*/
|
|
||||||
private RecentPositionDetailResponse toResponse(AisTargetEntity e) {
|
|
||||||
String mmsi = e.getMmsi();
|
|
||||||
String nationalCode = mmsi != null && mmsi.length() >= 3 ? mmsi.substring(0, 3) : "000";
|
|
||||||
String shipKindCode = e.getSignalKindCode() != null ? e.getSignalKindCode() : "000027";
|
|
||||||
Long imo = e.getImo() != null && e.getImo() > 0 ? e.getImo() : null;
|
|
||||||
|
|
||||||
// ShipImage enrichment
|
|
||||||
ShipImageSummary img = shipImageService.getImageSummary(imo);
|
|
||||||
|
|
||||||
return new RecentPositionDetailResponse(
|
|
||||||
mmsi,
|
|
||||||
imo,
|
|
||||||
round6(e.getLon()),
|
|
||||||
round6(e.getLat()),
|
|
||||||
scaleDecimal(e.getSog(), 1),
|
|
||||||
scaleDecimal(e.getCog(), 1),
|
|
||||||
e.getName(),
|
|
||||||
e.getVesselType(),
|
|
||||||
shipKindCode,
|
|
||||||
nationalCode,
|
|
||||||
toLocalDateTime(e.getMessageTimestamp()),
|
|
||||||
img != null ? img.thumbnailPath() : null,
|
|
||||||
img != null ? img.imageCount() : null,
|
|
||||||
// 확장 필드
|
|
||||||
e.getHeading(),
|
|
||||||
e.getCallsign(),
|
|
||||||
e.getStatus(),
|
|
||||||
e.getDestination(),
|
|
||||||
toLocalDateTime(e.getEta()),
|
|
||||||
e.getDraught(),
|
|
||||||
e.getLength(),
|
|
||||||
e.getWidth()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Double round6(Double value) {
|
|
||||||
return value != null ? Math.round(value * 1_000_000) / 1_000_000.0 : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static BigDecimal scaleDecimal(Double value, int scale) {
|
|
||||||
return value != null ? BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static LocalDateTime toLocalDateTime(OffsetDateTime odt) {
|
|
||||||
return odt != null ? odt.atZoneSameInstant(KST).toLocalDateTime() : null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,6 +3,7 @@ package gc.mda.signal_batch.domain.vessel.service;
|
|||||||
import gc.mda.signal_batch.domain.ship.service.ShipImageService;
|
import gc.mda.signal_batch.domain.ship.service.ShipImageService;
|
||||||
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
|
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
|
||||||
|
import gc.mda.signal_batch.global.util.SignalKindCode;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@ -123,7 +124,6 @@ public class VesselPositionService {
|
|||||||
cog,
|
cog,
|
||||||
name as ship_nm,
|
name as ship_nm,
|
||||||
vessel_type as ship_ty,
|
vessel_type as ship_ty,
|
||||||
signal_kind_code,
|
|
||||||
last_update
|
last_update
|
||||||
FROM signal.t_ais_position
|
FROM signal.t_ais_position
|
||||||
WHERE last_update >= NOW() - INTERVAL '%d minutes'
|
WHERE last_update >= NOW() - INTERVAL '%d minutes'
|
||||||
@ -145,9 +145,8 @@ public class VesselPositionService {
|
|||||||
String mmsi = rs.getString("mmsi");
|
String mmsi = rs.getString("mmsi");
|
||||||
String shipTy = rs.getString("ship_ty");
|
String shipTy = rs.getString("ship_ty");
|
||||||
|
|
||||||
// shipKindCode: DB에 저장된 치환값 사용
|
// shipKindCode 계산 (vesselType 기반, extraInfo 없음)
|
||||||
String signalKindCode = rs.getString("signal_kind_code");
|
String shipKindCode = SignalKindCode.resolve(shipTy, null).getCode();
|
||||||
String shipKindCode = signalKindCode != null ? signalKindCode : "000027";
|
|
||||||
|
|
||||||
// nationalCode 계산 (MMSI 앞 3자리 = MID)
|
// nationalCode 계산 (MMSI 앞 3자리 = MID)
|
||||||
String nationalCode = mmsi != null && mmsi.length() >= 3
|
String nationalCode = mmsi != null && mmsi.length() >= 3
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient;
|
|||||||
*
|
*
|
||||||
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
|
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
|
||||||
* 인증: Basic Authentication
|
* 인증: Basic Authentication
|
||||||
* 버퍼: 100MB (AIS GetTargets 응답 ~20MB+, 피크 시 50MB 초과 대응)
|
* 버퍼: 50MB (AIS GetTargets 응답 ~20MB+)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@ -37,7 +37,7 @@ public class AisApiWebClientConfig {
|
|||||||
.defaultHeaders(headers -> headers.setBasicAuth(aisApiUsername, aisApiPassword))
|
.defaultHeaders(headers -> headers.setBasicAuth(aisApiUsername, aisApiPassword))
|
||||||
.codecs(configurer -> configurer
|
.codecs(configurer -> configurer
|
||||||
.defaultCodecs()
|
.defaultCodecs()
|
||||||
.maxInMemorySize(100 * 1024 * 1024))
|
.maxInMemorySize(50 * 1024 * 1024))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
package gc.mda.signal_batch.global.config;
|
|
||||||
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.Setter;
|
|
||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 항적 데이터 메모리 예산 설정
|
|
||||||
*
|
|
||||||
* 64GB JVM 힙 기준 파티셔닝:
|
|
||||||
* 캐시 35GB (55%) — L1/L2/L3
|
|
||||||
* 쿼리 20GB (31%) — REST/WebSocket 동시 쿼리
|
|
||||||
* 시스템 9GB (14%) — GC, 스레드스택, Spring 컨텍스트 (미추적)
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Setter
|
|
||||||
@Component
|
|
||||||
@ConfigurationProperties(prefix = "track.memory-budget")
|
|
||||||
public class TrackMemoryBudgetProperties {
|
|
||||||
|
|
||||||
/** 전체 JVM 힙 예산 (GB) */
|
|
||||||
private int totalBudgetGb = 64;
|
|
||||||
|
|
||||||
/** 캐시 전용 예산 (GB) — L1+L2+L3 전체 */
|
|
||||||
private int cacheBudgetGb = 35;
|
|
||||||
|
|
||||||
/** 쿼리 응답 전용 예산 (GB) */
|
|
||||||
private int queryBudgetGb = 20;
|
|
||||||
|
|
||||||
/** 단일 쿼리 최대 메모리 (GB) */
|
|
||||||
private int maxSingleQueryGb = 5;
|
|
||||||
|
|
||||||
/** 메모리 추정 보정 계수 (실측 기반) */
|
|
||||||
private double estimationCorrectionFactor = 1.8;
|
|
||||||
|
|
||||||
/** 쿼리 메모리 대기 큐 타임아웃 (초) */
|
|
||||||
private int queueTimeoutSeconds = 60;
|
|
||||||
|
|
||||||
/** 예산 경고 임계값 (0.0~1.0) */
|
|
||||||
private double warningThreshold = 0.8;
|
|
||||||
|
|
||||||
/** 예산 위험 임계값 (0.0~1.0) */
|
|
||||||
private double criticalThreshold = 0.95;
|
|
||||||
}
|
|
||||||
@ -22,10 +22,8 @@ import org.springframework.http.server.ServletServerHttpRequest;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
|
||||||
import jakarta.servlet.http.Cookie;
|
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import java.security.Principal;
|
import java.security.Principal;
|
||||||
import java.util.Base64;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
@ -182,18 +180,11 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
String clientIp = extractClientIp(request);
|
String clientIp = extractClientIp(request);
|
||||||
attributes.put("CLIENT_IP", clientIp);
|
attributes.put("CLIENT_IP", clientIp);
|
||||||
|
|
||||||
|
// User-Agent 추출
|
||||||
if (request instanceof ServletServerHttpRequest) {
|
if (request instanceof ServletServerHttpRequest) {
|
||||||
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
|
HttpServletRequest servletRequest = ((ServletServerHttpRequest) request).getServletRequest();
|
||||||
|
|
||||||
// User-Agent 추출
|
|
||||||
String userAgent = servletRequest.getHeader("User-Agent");
|
String userAgent = servletRequest.getHeader("User-Agent");
|
||||||
attributes.put("USER_AGENT", userAgent);
|
attributes.put("USER_AGENT", userAgent);
|
||||||
|
|
||||||
// GC_SESSION 쿠키에서 JWT email 추출 (guide 서비스 인증)
|
|
||||||
String clientId = extractEmailFromJwtCookie(servletRequest);
|
|
||||||
if (clientId != null) {
|
|
||||||
attributes.put("CLIENT_ID", clientId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@ -234,45 +225,5 @@ public class WebSocketStompConfig implements WebSocketMessageBrokerConfigurer {
|
|||||||
// ServletServerHttpRequest가 아닌 경우 기본값
|
// ServletServerHttpRequest가 아닌 경우 기본값
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
private String extractEmailFromJwtCookie(HttpServletRequest request) {
|
|
||||||
return extractClientIdFromRequest(request);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GC_SESSION 쿠키에서 JWT payload의 email 클레임 추출 (REST/WebSocket 공용).
|
|
||||||
* JWT 검증은 nginx auth_request에서 이미 완료 — 여기서는 payload 디코딩만 수행.
|
|
||||||
*/
|
|
||||||
public static String extractClientIdFromRequest(HttpServletRequest request) {
|
|
||||||
Cookie[] cookies = request.getCookies();
|
|
||||||
if (cookies == null) return null;
|
|
||||||
|
|
||||||
String token = null;
|
|
||||||
for (Cookie cookie : cookies) {
|
|
||||||
if ("GC_SESSION".equals(cookie.getName())) {
|
|
||||||
token = cookie.getValue();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (token == null || token.isEmpty()) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
String[] parts = token.split("\\.");
|
|
||||||
if (parts.length < 2) return null;
|
|
||||||
|
|
||||||
String payload = new String(Base64.getUrlDecoder().decode(parts[1]));
|
|
||||||
int emailIdx = payload.indexOf("\"email\"");
|
|
||||||
if (emailIdx < 0) return null;
|
|
||||||
|
|
||||||
int colonIdx = payload.indexOf(':', emailIdx);
|
|
||||||
int quoteStart = payload.indexOf('"', colonIdx + 1);
|
|
||||||
int quoteEnd = payload.indexOf('"', quoteStart + 1);
|
|
||||||
if (quoteStart < 0 || quoteEnd < 0) return null;
|
|
||||||
|
|
||||||
return payload.substring(quoteStart + 1, quoteEnd);
|
|
||||||
} catch (Exception e) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,16 +0,0 @@
|
|||||||
package gc.mda.signal_batch.global.exception;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.web.bind.annotation.ResponseStatus;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메모리 예산 초과 시 발생하는 예외 (503 Service Unavailable)
|
|
||||||
*
|
|
||||||
* 단일 쿼리 상한 초과, 대기 큐 타임아웃, 전체 쿼리 예산 부족 시 발생.
|
|
||||||
*/
|
|
||||||
@ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE)
|
|
||||||
public class MemoryBudgetExceededException extends RuntimeException {
|
|
||||||
public MemoryBudgetExceededException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,11 +6,10 @@ import lombok.RequiredArgsConstructor;
|
|||||||
/**
|
/**
|
||||||
* MDA 선종 범례코드
|
* MDA 선종 범례코드
|
||||||
*
|
*
|
||||||
* S&P Global AIS API의 vesselType + extraInfo + shipName을 기반으로
|
* S&P Global AIS API의 vesselType + extraInfo를 기반으로
|
||||||
* MDA 범례코드(signalKindCode)로 치환한다.
|
* MDA 범례코드(signalKindCode)로 치환한다.
|
||||||
*
|
*
|
||||||
* 치환은 캐시 저장 시(AisTargetCacheWriter) 1회만 수행하며,
|
* ShipKindCodeConverter를 대체하며, SNP-Batch-1의 치환 로직을 이식.
|
||||||
* API 응답 시에는 캐시 또는 DB의 signal_kind_code를 직접 사용한다.
|
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@ -29,32 +28,18 @@ public enum SignalKindCode {
|
|||||||
private final String koreanName;
|
private final String koreanName;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* vesselType + extraInfo → MDA 범례코드 치환 (하위 호환용)
|
* vesselType + extraInfo → MDA 범례코드 치환
|
||||||
* shipName 기반 BUOY 검출 불가 — 캐시 저장 시에는 3-파라미터 버전 사용 권장.
|
|
||||||
*/
|
|
||||||
public static SignalKindCode resolve(String vesselType, String extraInfo) {
|
|
||||||
return resolve(vesselType, extraInfo, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* vesselType + extraInfo + shipName → MDA 범례코드 치환
|
|
||||||
*
|
*
|
||||||
* 치환 우선순위:
|
* 치환 우선순위:
|
||||||
* 1. shipName 기반 BUOY 검출 ('.' '_' 문자가 2개 이상 → 부이/항로표지)
|
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등)
|
||||||
* 2. vesselType 단독 매칭 (Cargo, Tanker, Passenger 등)
|
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
|
||||||
* 3. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등)
|
* 3. fallback → DEFAULT (000027)
|
||||||
* 4. fallback → DEFAULT (000027)
|
|
||||||
*/
|
*/
|
||||||
public static SignalKindCode resolve(String vesselType, String extraInfo, String shipName) {
|
public static SignalKindCode resolve(String vesselType, String extraInfo) {
|
||||||
// 1. shipName 기반 BUOY 검출: '.' 또는 '_' 문자가 2개 이상
|
|
||||||
if (hasBuoyNamePattern(shipName)) {
|
|
||||||
return BUOY;
|
|
||||||
}
|
|
||||||
|
|
||||||
String vt = normalizeOrEmpty(vesselType);
|
String vt = normalizeOrEmpty(vesselType);
|
||||||
String ei = normalizeOrEmpty(extraInfo);
|
String ei = normalizeOrEmpty(extraInfo);
|
||||||
|
|
||||||
// 2. vesselType 단독 매칭
|
// 1. vesselType 단독 매칭
|
||||||
switch (vt) {
|
switch (vt) {
|
||||||
case "cargo":
|
case "cargo":
|
||||||
return CARGO;
|
return CARGO;
|
||||||
@ -63,7 +48,7 @@ public enum SignalKindCode {
|
|||||||
case "passenger":
|
case "passenger":
|
||||||
return FERRY;
|
return FERRY;
|
||||||
case "aton":
|
case "aton":
|
||||||
return DEFAULT;
|
return BUOY;
|
||||||
case "law enforcement":
|
case "law enforcement":
|
||||||
return GOV;
|
return GOV;
|
||||||
case "search and rescue":
|
case "search and rescue":
|
||||||
@ -75,19 +60,19 @@ public enum SignalKindCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// vesselType 그룹 매칭
|
// vesselType 그룹 매칭
|
||||||
if (matchesAny(vt, "pilot boat", "anti pollution", "medical transport")) {
|
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
|
||||||
return GOV;
|
return GOV;
|
||||||
}
|
}
|
||||||
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
|
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
|
||||||
return FERRY;
|
return FERRY;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. "Vessel" + extraInfo 조합
|
// 2. "Vessel" + extraInfo 조합
|
||||||
if ("vessel".equals(vt)) {
|
if ("vessel".equals(vt)) {
|
||||||
return resolveVesselExtraInfo(ei);
|
return resolveVesselExtraInfo(ei);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. "N/A" + extraInfo 조합
|
// 3. "N/A" + extraInfo 조합
|
||||||
if ("n/a".equals(vt)) {
|
if ("n/a".equals(vt)) {
|
||||||
if (ei.startsWith("hazardous cat")) {
|
if (ei.startsWith("hazardous cat")) {
|
||||||
return CARGO;
|
return CARGO;
|
||||||
@ -95,7 +80,7 @@ public enum SignalKindCode {
|
|||||||
return DEFAULT;
|
return DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. fallback
|
// 4. fallback
|
||||||
return DEFAULT;
|
return DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,32 +91,18 @@ public enum SignalKindCode {
|
|||||||
if ("military operations".equals(extraInfo)) {
|
if ("military operations".equals(extraInfo)) {
|
||||||
return GOV;
|
return GOV;
|
||||||
}
|
}
|
||||||
|
if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) {
|
||||||
|
return GOV;
|
||||||
|
}
|
||||||
|
if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) {
|
||||||
|
return FISHING;
|
||||||
|
}
|
||||||
if (extraInfo.startsWith("hazardous cat")) {
|
if (extraInfo.startsWith("hazardous cat")) {
|
||||||
return CARGO;
|
return CARGO;
|
||||||
}
|
}
|
||||||
return DEFAULT;
|
return DEFAULT;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* shipName에 '.' 또는 '_' 문자가 2개 이상 포함되면 부이/항로표지로 판정
|
|
||||||
*/
|
|
||||||
static boolean hasBuoyNamePattern(String shipName) {
|
|
||||||
if (shipName == null || shipName.isBlank()) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
int count = 0;
|
|
||||||
for (int i = 0; i < shipName.length(); i++) {
|
|
||||||
char c = shipName.charAt(i);
|
|
||||||
if (c == '.' || c == '_') {
|
|
||||||
count++;
|
|
||||||
if (count >= 2) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static boolean matchesAny(String value, String... candidates) {
|
private static boolean matchesAny(String value, String... candidates) {
|
||||||
for (String candidate : candidates) {
|
for (String candidate : candidates) {
|
||||||
if (candidate.equals(value)) {
|
if (candidate.equals(value)) {
|
||||||
|
|||||||
@ -1,45 +0,0 @@
|
|||||||
package gc.mda.signal_batch.global.util;
|
|
||||||
|
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
|
||||||
import lombok.experimental.UtilityClass;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CompactVesselTrack의 Heap 점유량을 바이트 단위로 추정
|
|
||||||
*
|
|
||||||
* 포인트당 메모리 근거:
|
|
||||||
* double[2]: 32B (header 16B + data 16B) + ArrayList entry 8B = 40B
|
|
||||||
* String timestamp: ~48B (object 16B + char[] ~24B + ref 8B)
|
|
||||||
* Double speed: 24B (object 16B + double 8B)
|
|
||||||
* 합계: ~112B per point
|
|
||||||
*/
|
|
||||||
@UtilityClass
|
|
||||||
public class TrackMemoryEstimator {
|
|
||||||
|
|
||||||
private static final long BYTES_PER_POINT = 112L;
|
|
||||||
private static final long OBJECT_OVERHEAD = 300L;
|
|
||||||
|
|
||||||
public static long estimateTrackBytes(CompactVesselTrack track) {
|
|
||||||
if (track == null) return 0;
|
|
||||||
int points = track.getPointCount();
|
|
||||||
return OBJECT_OVERHEAD + (long) points * BYTES_PER_POINT;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static long estimateListBytes(List<CompactVesselTrack> tracks) {
|
|
||||||
if (tracks == null || tracks.isEmpty()) return 0;
|
|
||||||
long total = 0;
|
|
||||||
for (CompactVesselTrack track : tracks) {
|
|
||||||
total += estimateTrackBytes(track);
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 사전 추정: 일 평균 500포인트 기준
|
|
||||||
* days × vessels × 500 × 112B
|
|
||||||
*/
|
|
||||||
public static long estimateQueryBytes(int days, int estimatedVessels) {
|
|
||||||
return (long) days * estimatedVessels * 500 * BYTES_PER_POINT;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -122,18 +122,16 @@ public class VesselTrackToCompactConverter {
|
|||||||
int pointCount = geometry.size();
|
int pointCount = geometry.size();
|
||||||
double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0;
|
double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0;
|
||||||
|
|
||||||
// 선박 정보 설정 (캐시에 이미 치환된 signalKindCode 사용)
|
// 선박 정보 설정
|
||||||
String shipName = null;
|
String shipName = null;
|
||||||
String shipType = null;
|
String shipType = null;
|
||||||
String shipKindCode = null;
|
String shipKindCode = null;
|
||||||
if (vesselInfo != null) {
|
if (vesselInfo != null) {
|
||||||
shipName = vesselInfo.getName();
|
shipName = vesselInfo.getName();
|
||||||
shipType = vesselInfo.getVesselType();
|
shipType = vesselInfo.getVesselType();
|
||||||
shipKindCode = vesselInfo.getSignalKindCode() != null
|
shipKindCode = SignalKindCode.resolve(vesselInfo.getVesselType(), vesselInfo.getExtraInfo()).getCode();
|
||||||
? vesselInfo.getSignalKindCode()
|
|
||||||
: SignalKindCode.DEFAULT.getCode();
|
|
||||||
} else {
|
} else {
|
||||||
shipKindCode = SignalKindCode.DEFAULT.getCode();
|
shipKindCode = SignalKindCode.resolve(null, null).getCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi;
|
String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi;
|
||||||
|
|||||||
@ -71,19 +71,6 @@ public class StompTrackController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 세션 속성에서 CLIENT_IP, CLIENT_ID 추출
|
|
||||||
String clientIp = null;
|
|
||||||
String clientId = null;
|
|
||||||
Map<String, Object> sessionAttrs = headerAccessor.getSessionAttributes();
|
|
||||||
if (sessionAttrs != null) {
|
|
||||||
if (sessionAttrs.containsKey("CLIENT_IP")) {
|
|
||||||
clientIp = (String) sessionAttrs.get("CLIENT_IP");
|
|
||||||
}
|
|
||||||
if (sessionAttrs.containsKey("CLIENT_ID")) {
|
|
||||||
clientId = (String) sessionAttrs.get("CLIENT_ID");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 비동기 스트리밍 시작 - 청크 모드 체크
|
// 비동기 스트리밍 시작 - 청크 모드 체크
|
||||||
if (request.isChunkedMode()) {
|
if (request.isChunkedMode()) {
|
||||||
chunkedTrackStreamingService.streamChunkedTracks(
|
chunkedTrackStreamingService.streamChunkedTracks(
|
||||||
@ -91,9 +78,7 @@ public class StompTrackController {
|
|||||||
queryId,
|
queryId,
|
||||||
sessionId,
|
sessionId,
|
||||||
chunk -> sendChunkedDataToUser(userId, chunk),
|
chunk -> sendChunkedDataToUser(userId, chunk),
|
||||||
statusCallback,
|
statusCallback
|
||||||
clientIp,
|
|
||||||
clientId
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
trackStreamingService.streamTracks(
|
trackStreamingService.streamTracks(
|
||||||
@ -128,9 +113,10 @@ public class StompTrackController {
|
|||||||
trackStreamingService.cancelQuery(queryId);
|
trackStreamingService.cancelQuery(queryId);
|
||||||
chunkedTrackStreamingService.cancelQuery(queryId);
|
chunkedTrackStreamingService.cancelQuery(queryId);
|
||||||
activeSessions.remove(sessionId);
|
activeSessions.remove(sessionId);
|
||||||
|
return QueryResponse.cancelled(queryId);
|
||||||
}
|
}
|
||||||
// 세션 없어도 취소 성공 반환 (idempotent — 이미 완료/취소된 쿼리)
|
|
||||||
return QueryResponse.cancelled(queryId);
|
return QueryResponse.error(queryId, "Query not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -316,11 +316,4 @@ public class ActiveQueryManager {
|
|||||||
public int getMaxConcurrentGlobal() {
|
public int getMaxConcurrentGlobal() {
|
||||||
return maxConcurrentGlobal;
|
return maxConcurrentGlobal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 대기열 타임아웃 (초)
|
|
||||||
*/
|
|
||||||
public int getQueueTimeoutSeconds() {
|
|
||||||
return queueTimeoutSeconds;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -117,9 +117,6 @@ public class CacheTrackSimplifier {
|
|||||||
|
|
||||||
track.setPointCount(afterZoom);
|
track.setPointCount(afterZoom);
|
||||||
|
|
||||||
// 간소화 후 속도 재계산 (포인트 간 거리/시간 기반)
|
|
||||||
recalculateSpeeds(track);
|
|
||||||
|
|
||||||
// 처음 5개 선박 상세 로그 (debug 레벨)
|
// 처음 5개 선박 상세 로그 (debug 레벨)
|
||||||
if (simplifiedCount < 5) {
|
if (simplifiedCount < 5) {
|
||||||
log.debug("[CacheSimplify] vessel={} original={} -> DP={} -> distTime={} -> zoom={} (avg={} kn)",
|
log.debug("[CacheSimplify] vessel={} original={} -> DP={} -> distTime={} -> zoom={} (avg={} kn)",
|
||||||
@ -142,43 +139,6 @@ public class CacheTrackSimplifier {
|
|||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── L3 캐시 저장용: DP-only 사전 간소화 ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DP(Douglas-Peucker)만 적용하는 사전 간소화 (L3 캐시 저장용).
|
|
||||||
* 방향 변화를 보존하여 어선 조업 패턴(원형, ㄹ자) 유지.
|
|
||||||
* 거리/시간 필터는 적용하지 않아 직선 구간만 제거.
|
|
||||||
*/
|
|
||||||
public void simplifyDpOnly(List<CompactVesselTrack> tracks, double dpTolerance) {
|
|
||||||
if (tracks == null || tracks.isEmpty()) return;
|
|
||||||
|
|
||||||
long startTime = System.currentTimeMillis();
|
|
||||||
int totalOriginal = 0;
|
|
||||||
int totalAfter = 0;
|
|
||||||
int simplifiedCount = 0;
|
|
||||||
|
|
||||||
for (CompactVesselTrack track : tracks) {
|
|
||||||
if (track.getGeometry() == null || track.getGeometry().size() <= 2) continue;
|
|
||||||
|
|
||||||
int before = track.getGeometry().size();
|
|
||||||
totalOriginal += before;
|
|
||||||
|
|
||||||
applyDouglasPeucker(track, dpTolerance);
|
|
||||||
recalculateSpeeds(track);
|
|
||||||
track.setPointCount(track.getGeometry().size());
|
|
||||||
|
|
||||||
totalAfter += track.getGeometry().size();
|
|
||||||
simplifiedCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
long elapsed = System.currentTimeMillis() - startTime;
|
|
||||||
if (simplifiedCount > 0) {
|
|
||||||
double reduction = (1 - (double) totalAfter / totalOriginal) * 100;
|
|
||||||
log.info("[DpPreSimplify] {} tracks, {} -> {} pts ({}% 감소), {}ms",
|
|
||||||
simplifiedCount, totalOriginal, totalAfter, Math.round(reduction), elapsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 1단계: Douglas-Peucker (ST_Simplify 대체) ──
|
// ── 1단계: Douglas-Peucker (ST_Simplify 대체) ──
|
||||||
|
|
||||||
private void applyDouglasPeucker(CompactVesselTrack track, double tolerance) {
|
private void applyDouglasPeucker(CompactVesselTrack track, double tolerance) {
|
||||||
@ -452,55 +412,6 @@ public class CacheTrackSimplifier {
|
|||||||
if (sampledSpd != null) track.setSpeeds(sampledSpd);
|
if (sampledSpd != null) track.setSpeeds(sampledSpd);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 간소화 후 속도 재계산 ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 간소화된 포인트 간 속도 재계산.
|
|
||||||
* 간소화 후 남은 포인트에 대해 인접 좌표 간 Haversine 거리/시간차로 계산.
|
|
||||||
*/
|
|
||||||
private void recalculateSpeeds(CompactVesselTrack track) {
|
|
||||||
List<double[]> geometry = track.getGeometry();
|
|
||||||
List<String> timestamps = track.getTimestamps();
|
|
||||||
if (geometry == null || geometry.size() < 2 ||
|
|
||||||
timestamps == null || timestamps.size() != geometry.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int size = geometry.size();
|
|
||||||
List<Double> speeds = new ArrayList<>(size);
|
|
||||||
speeds.add(0.0); // 첫 포인트는 이전 포인트가 없으므로 0
|
|
||||||
|
|
||||||
for (int i = 1; i < size; i++) {
|
|
||||||
double[] prev = geometry.get(i - 1);
|
|
||||||
double[] curr = geometry.get(i);
|
|
||||||
try {
|
|
||||||
long prevTs = parseEpochSeconds(timestamps.get(i - 1));
|
|
||||||
long currTs = parseEpochSeconds(timestamps.get(i));
|
|
||||||
double timeDiffHours = (currTs - prevTs) / 3600.0;
|
|
||||||
if (timeDiffHours > 0) {
|
|
||||||
double distNm = calculateDistance(prev[1], prev[0], curr[1], curr[0]);
|
|
||||||
speeds.add(distNm / timeDiffHours); // knots
|
|
||||||
} else {
|
|
||||||
speeds.add(0.0);
|
|
||||||
}
|
|
||||||
} catch (Exception e) {
|
|
||||||
speeds.add(0.0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
track.setSpeeds(speeds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private long parseEpochSeconds(String tsStr) {
|
|
||||||
if (tsStr == null) throw new IllegalArgumentException("null timestamp");
|
|
||||||
if (tsStr.matches("\\d{10,}")) {
|
|
||||||
return Long.parseLong(tsStr);
|
|
||||||
}
|
|
||||||
return LocalDateTime.parse(tsStr, TIMESTAMP_FORMATTER)
|
|
||||||
.atZone(java.time.ZoneId.systemDefault())
|
|
||||||
.toEpochSecond();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 거리 계산 (Haversine, 해리 단위) ──
|
// ── 거리 계산 (Haversine, 해리 단위) ──
|
||||||
|
|
||||||
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
private double calculateDistance(double lat1, double lon1, double lat2, double lon2) {
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -40,13 +40,8 @@ public class DailyTrackCacheManager {
|
|||||||
NOT_STARTED, LOADING, PARTIAL, READY, DISABLED
|
NOT_STARTED, LOADING, PARTIAL, READY, DISABLED
|
||||||
}
|
}
|
||||||
|
|
||||||
/** L3 사전 간소화 DP tolerance (~100m) — 항적 형상 유지하면서 직선 구간만 제거 */
|
|
||||||
private static final double L3_DP_TOLERANCE = 0.001;
|
|
||||||
|
|
||||||
private final DataSource queryDataSource;
|
private final DataSource queryDataSource;
|
||||||
private final DailyTrackCacheProperties cacheProperties;
|
private final DailyTrackCacheProperties cacheProperties;
|
||||||
private final TrackMemoryBudgetManager memoryBudgetManager;
|
|
||||||
private final CacheTrackSimplifier cacheTrackSimplifier;
|
|
||||||
|
|
||||||
// 날짜별 캐시 (D-1 ~ D-N)
|
// 날짜별 캐시 (D-1 ~ D-N)
|
||||||
private final ConcurrentHashMap<LocalDate, DailyTrackData> cache = new ConcurrentHashMap<>();
|
private final ConcurrentHashMap<LocalDate, DailyTrackData> cache = new ConcurrentHashMap<>();
|
||||||
@ -59,13 +54,9 @@ public class DailyTrackCacheManager {
|
|||||||
|
|
||||||
public DailyTrackCacheManager(
|
public DailyTrackCacheManager(
|
||||||
@Qualifier("queryDataSource") DataSource queryDataSource,
|
@Qualifier("queryDataSource") DataSource queryDataSource,
|
||||||
DailyTrackCacheProperties cacheProperties,
|
DailyTrackCacheProperties cacheProperties) {
|
||||||
TrackMemoryBudgetManager memoryBudgetManager,
|
|
||||||
CacheTrackSimplifier cacheTrackSimplifier) {
|
|
||||||
this.queryDataSource = queryDataSource;
|
this.queryDataSource = queryDataSource;
|
||||||
this.cacheProperties = cacheProperties;
|
this.cacheProperties = cacheProperties;
|
||||||
this.memoryBudgetManager = memoryBudgetManager;
|
|
||||||
this.cacheTrackSimplifier = cacheTrackSimplifier;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -174,19 +165,13 @@ public class DailyTrackCacheManager {
|
|||||||
DailyTrackData data = loadDay(targetDate);
|
DailyTrackData data = loadDay(targetDate);
|
||||||
|
|
||||||
if (data != null && data.getVesselCount() > 0) {
|
if (data != null && data.getVesselCount() > 0) {
|
||||||
// 메모리 한도 체크 (DailyTrackCacheProperties 자체 한도)
|
// 메모리 한도 체크
|
||||||
if (totalMemory + data.getMemorySizeBytes() > maxMemoryBytes) {
|
if (totalMemory + data.getMemorySizeBytes() > maxMemoryBytes) {
|
||||||
log.warn("Cache memory limit reached: {}GB / {}GB. Stopping at D-{}",
|
log.warn("Cache memory limit reached: {}GB / {}GB. Stopping at D-{}",
|
||||||
totalMemory / (1024 * 1024 * 1024), cacheProperties.getMaxMemoryGb(), daysBack);
|
totalMemory / (1024 * 1024 * 1024), cacheProperties.getMaxMemoryGb(), daysBack);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 메모리 예산 매니저에 등록
|
|
||||||
if (!memoryBudgetManager.registerCacheMemory(targetDate, data.getMemorySizeBytes())) {
|
|
||||||
log.warn("[MemoryBudget] 캐시 예산 초과로 D-{} ({}) 로드 중단", daysBack, targetDate);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
cache.put(targetDate, data);
|
cache.put(targetDate, data);
|
||||||
totalMemory += data.getMemorySizeBytes();
|
totalMemory += data.getMemorySizeBytes();
|
||||||
loadedCount++;
|
loadedCount++;
|
||||||
@ -316,9 +301,8 @@ public class DailyTrackCacheManager {
|
|||||||
|
|
||||||
double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0;
|
double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0;
|
||||||
|
|
||||||
// shipKindCode: 캐시 저장 시 치환된 값 사용 (DB fallback 포함)
|
// shipKindCode 계산
|
||||||
String shipKindCode = acc.signalKindCode != null
|
String shipKindCode = SignalKindCode.resolve(acc.shipType, null).getCode();
|
||||||
? acc.signalKindCode : SignalKindCode.DEFAULT.getCode();
|
|
||||||
|
|
||||||
// nationalCode 계산 (MMSI 앞 3자리 = MID)
|
// nationalCode 계산 (MMSI 앞 3자리 = MID)
|
||||||
String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi;
|
String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi;
|
||||||
@ -343,23 +327,6 @@ public class DailyTrackCacheManager {
|
|||||||
|
|
||||||
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
|
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
|
||||||
|
|
||||||
// DP 사전 간소화: 직선 구간만 제거, 방향 변화(어선 조업 패턴) 보존
|
|
||||||
long memoryBeforeDp = estimatedMemory;
|
|
||||||
List<CompactVesselTrack> trackList = new ArrayList<>(tracks.values());
|
|
||||||
cacheTrackSimplifier.simplifyDpOnly(trackList, L3_DP_TOLERANCE);
|
|
||||||
|
|
||||||
// 간소화 후 메모리 재추정
|
|
||||||
estimatedMemory = trackList.stream()
|
|
||||||
.mapToLong(t -> t.getPointCount() * 40L)
|
|
||||||
.sum();
|
|
||||||
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
|
|
||||||
|
|
||||||
if (memoryBeforeDp > 0) {
|
|
||||||
long reduction = memoryBeforeDp > 0 ? Math.round((1 - (double) estimatedMemory / memoryBeforeDp) * 100) : 0;
|
|
||||||
log.info("[DailyLoadDay] {} DP pre-simplification: {}MB -> {}MB ({}% reduction, tolerance={})",
|
|
||||||
date, memoryBeforeDp / (1024 * 1024), estimatedMemory / (1024 * 1024), reduction, L3_DP_TOLERANCE);
|
|
||||||
}
|
|
||||||
|
|
||||||
// STRtree 공간 인덱스 빌드
|
// STRtree 공간 인덱스 빌드
|
||||||
STRtree spatialIndex = buildSpatialIndex(tracks);
|
STRtree spatialIndex = buildSpatialIndex(tracks);
|
||||||
estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드
|
estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드
|
||||||
@ -454,76 +421,6 @@ public class DailyTrackCacheManager {
|
|||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 요청된 MMSI 키로 직접 O(1) 조회 — dayTracks.get(mmsi) 호출
|
|
||||||
* 기존 getCachedTracksMultipleDays()의 전체 스캔 대비 대폭 성능 개선.
|
|
||||||
* 예: 7일 × 100 MMSI = 700회 get() vs 7일 × 50K 선박 = 350K 엔트리 스캔
|
|
||||||
*/
|
|
||||||
public List<CompactVesselTrack> getCachedTracksForVessels(
|
|
||||||
List<LocalDate> dates, Set<String> mmsiKeys) {
|
|
||||||
if (mmsiKeys == null || mmsiKeys.isEmpty()) {
|
|
||||||
return Collections.emptyList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, CompactVesselTrack.CompactVesselTrackBuilder> merged = new HashMap<>();
|
|
||||||
int lookupCount = 0;
|
|
||||||
int hitCount = 0;
|
|
||||||
|
|
||||||
for (LocalDate date : dates) {
|
|
||||||
DailyTrackData data = cache.get(date);
|
|
||||||
if (data == null) continue;
|
|
||||||
|
|
||||||
Map<String, CompactVesselTrack> dayTracks = data.getTracks();
|
|
||||||
|
|
||||||
for (String mmsi : mmsiKeys) {
|
|
||||||
CompactVesselTrack track = dayTracks.get(mmsi);
|
|
||||||
lookupCount++;
|
|
||||||
if (track == null) continue;
|
|
||||||
hitCount++;
|
|
||||||
|
|
||||||
CompactVesselTrack.CompactVesselTrackBuilder builder = merged.get(mmsi);
|
|
||||||
if (builder == null) {
|
|
||||||
builder = CompactVesselTrack.builder()
|
|
||||||
.vesselId(mmsi)
|
|
||||||
.nationalCode(track.getNationalCode())
|
|
||||||
.shipName(track.getShipName())
|
|
||||||
.shipType(track.getShipType())
|
|
||||||
.shipKindCode(track.getShipKindCode())
|
|
||||||
.geometry(new ArrayList<>(track.getGeometry()))
|
|
||||||
.timestamps(new ArrayList<>(track.getTimestamps()))
|
|
||||||
.speeds(new ArrayList<>(track.getSpeeds()))
|
|
||||||
.totalDistance(track.getTotalDistance())
|
|
||||||
.avgSpeed(track.getAvgSpeed())
|
|
||||||
.maxSpeed(track.getMaxSpeed())
|
|
||||||
.pointCount(track.getPointCount());
|
|
||||||
merged.put(mmsi, builder);
|
|
||||||
} else {
|
|
||||||
CompactVesselTrack existing = builder.build();
|
|
||||||
List<double[]> geo = new ArrayList<>(existing.getGeometry());
|
|
||||||
geo.addAll(track.getGeometry());
|
|
||||||
List<String> ts = new ArrayList<>(existing.getTimestamps());
|
|
||||||
ts.addAll(track.getTimestamps());
|
|
||||||
List<Double> sp = new ArrayList<>(existing.getSpeeds());
|
|
||||||
sp.addAll(track.getSpeeds());
|
|
||||||
|
|
||||||
builder.geometry(geo)
|
|
||||||
.timestamps(ts)
|
|
||||||
.speeds(sp)
|
|
||||||
.totalDistance(existing.getTotalDistance() + track.getTotalDistance())
|
|
||||||
.maxSpeed(Math.max(existing.getMaxSpeed(), track.getMaxSpeed()))
|
|
||||||
.pointCount(existing.getPointCount() + track.getPointCount());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info("[CACHE-MONITOR] L3.getCachedTracksForVessels: dates={}, requestedMmsi={}, lookups={}, hits={}, resultVessels={}",
|
|
||||||
dates.size(), mmsiKeys.size(), lookupCount, hitCount, merged.size());
|
|
||||||
|
|
||||||
return merged.values().stream()
|
|
||||||
.map(CompactVesselTrack.CompactVesselTrackBuilder::build)
|
|
||||||
.collect(Collectors.toList());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 요청 범위를 캐시 구간 / DB 구간으로 분리
|
* 요청 범위를 캐시 구간 / DB 구간으로 분리
|
||||||
*/
|
*/
|
||||||
@ -636,7 +533,6 @@ public class DailyTrackCacheManager {
|
|||||||
try {
|
try {
|
||||||
DailyTrackData data = loadDay(yesterday);
|
DailyTrackData data = loadDay(yesterday);
|
||||||
if (data != null && data.getVesselCount() > 0) {
|
if (data != null && data.getVesselCount() > 0) {
|
||||||
memoryBudgetManager.registerCacheMemory(yesterday, data.getMemorySizeBytes());
|
|
||||||
cache.put(yesterday, data);
|
cache.put(yesterday, data);
|
||||||
log.info("Cache refreshed for {}: {} vessels, {} MB",
|
log.info("Cache refreshed for {}: {} vessels, {} MB",
|
||||||
yesterday, data.getVesselCount(), data.getMemorySizeBytes() / (1024 * 1024));
|
yesterday, data.getVesselCount(), data.getMemorySizeBytes() / (1024 * 1024));
|
||||||
@ -654,7 +550,6 @@ public class DailyTrackCacheManager {
|
|||||||
for (LocalDate d : toRemove) {
|
for (LocalDate d : toRemove) {
|
||||||
DailyTrackData removed = cache.remove(d);
|
DailyTrackData removed = cache.remove(d);
|
||||||
if (removed != null) {
|
if (removed != null) {
|
||||||
memoryBudgetManager.releaseCacheMemory(d);
|
|
||||||
log.info("Evicted cache for {}: {} vessels, {} MB",
|
log.info("Evicted cache for {}: {} vessels, {} MB",
|
||||||
d, removed.getVesselCount(), removed.getMemorySizeBytes() / (1024 * 1024));
|
d, removed.getVesselCount(), removed.getMemorySizeBytes() / (1024 * 1024));
|
||||||
}
|
}
|
||||||
@ -747,7 +642,7 @@ public class DailyTrackCacheManager {
|
|||||||
|
|
||||||
try (Connection conn = queryDataSource.getConnection()) {
|
try (Connection conn = queryDataSource.getConnection()) {
|
||||||
String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(","));
|
String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(","));
|
||||||
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty, signal_kind_code " +
|
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty " +
|
||||||
"FROM signal.t_ais_position " +
|
"FROM signal.t_ais_position " +
|
||||||
"WHERE mmsi IN (" + placeholders + ")";
|
"WHERE mmsi IN (" + placeholders + ")";
|
||||||
|
|
||||||
@ -763,7 +658,6 @@ public class DailyTrackCacheManager {
|
|||||||
if (acc != null) {
|
if (acc != null) {
|
||||||
acc.shipName = rs.getString("ship_nm");
|
acc.shipName = rs.getString("ship_nm");
|
||||||
acc.shipType = rs.getString("ship_ty");
|
acc.shipType = rs.getString("ship_ty");
|
||||||
acc.signalKindCode = rs.getString("signal_kind_code");
|
|
||||||
enriched++;
|
enriched++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -807,7 +701,6 @@ public class DailyTrackCacheManager {
|
|||||||
String mmsi;
|
String mmsi;
|
||||||
String shipName;
|
String shipName;
|
||||||
String shipType;
|
String shipType;
|
||||||
String signalKindCode;
|
|
||||||
List<double[]> geometry = new ArrayList<>(500);
|
List<double[]> geometry = new ArrayList<>(500);
|
||||||
List<String> timestamps = new ArrayList<>(500);
|
List<String> timestamps = new ArrayList<>(500);
|
||||||
List<Double> speeds = new ArrayList<>(500);
|
List<Double> speeds = new ArrayList<>(500);
|
||||||
|
|||||||
@ -1,300 +0,0 @@
|
|||||||
package gc.mda.signal_batch.global.websocket.service;
|
|
||||||
|
|
||||||
import gc.mda.signal_batch.global.config.TrackMemoryBudgetProperties;
|
|
||||||
import gc.mda.signal_batch.global.exception.MemoryBudgetExceededException;
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.util.*;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
import java.util.concurrent.locks.Condition;
|
|
||||||
import java.util.concurrent.locks.ReentrantLock;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 항적 데이터 메모리 예산 관리자
|
|
||||||
*
|
|
||||||
* 캐시 영역과 쿼리 영역의 메모리를 논리적으로 파티셔닝하여
|
|
||||||
* 대형 쿼리가 배치 Job/캐시를 압박하는 것을 방지.
|
|
||||||
*
|
|
||||||
* 쿼리 예산: ReentrantLock(fair=true) + Condition 기반 FIFO 대기 큐.
|
|
||||||
* 캐시 예산: AtomicLong 기반 즉시 등록/해제.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class TrackMemoryBudgetManager {
|
|
||||||
|
|
||||||
@Getter
|
|
||||||
private final TrackMemoryBudgetProperties properties;
|
|
||||||
|
|
||||||
// 캐시 메모리 추적
|
|
||||||
private final AtomicLong cacheUsedBytes = new AtomicLong(0);
|
|
||||||
private final ConcurrentHashMap<String, Long> cacheAllocations = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
// 쿼리 메모리 추적
|
|
||||||
private final AtomicLong queryUsedBytes = new AtomicLong(0);
|
|
||||||
private final ConcurrentHashMap<String, Long> queryAllocations = new ConcurrentHashMap<>();
|
|
||||||
private final AtomicInteger waitingQueryCount = new AtomicInteger(0);
|
|
||||||
|
|
||||||
// FIFO 대기 메커니즘
|
|
||||||
private final ReentrantLock queryLock = new ReentrantLock(true); // fair=true
|
|
||||||
private final Condition queryBudgetAvailable = queryLock.newCondition();
|
|
||||||
|
|
||||||
// 로그 중복 방지
|
|
||||||
private volatile long lastPressureLogTime = 0;
|
|
||||||
|
|
||||||
public TrackMemoryBudgetManager(TrackMemoryBudgetProperties properties) {
|
|
||||||
this.properties = properties;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
public void init() {
|
|
||||||
log.info("TrackMemoryBudgetManager 초기화 — total: {}GB, cache: {}GB, query: {}GB, maxSingleQuery: {}GB, correctionFactor: {}",
|
|
||||||
properties.getTotalBudgetGb(), properties.getCacheBudgetGb(),
|
|
||||||
properties.getQueryBudgetGb(), properties.getMaxSingleQueryGb(),
|
|
||||||
properties.getEstimationCorrectionFactor());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 캐시 메모리 관리 ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 메모리 등록 (날짜 기반 — L3 DailyTrackCache)
|
|
||||||
* @return true: 예산 내 등록 성공, false: 예산 초과
|
|
||||||
*/
|
|
||||||
public boolean registerCacheMemory(LocalDate date, long bytes) {
|
|
||||||
return registerCacheMemory("daily::" + date, bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 메모리 등록 (키 기반 — L1/L2 Caffeine 버킷)
|
|
||||||
*/
|
|
||||||
public boolean registerCacheMemory(String key, long bytes) {
|
|
||||||
long budgetBytes = (long) properties.getCacheBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
long currentUsed = cacheUsedBytes.get();
|
|
||||||
|
|
||||||
if (currentUsed + bytes > budgetBytes) {
|
|
||||||
log.warn("[MemoryBudget] 캐시 예산 초과: key={}, requested={}MB, used={}MB, budget={}MB",
|
|
||||||
key, bytes / (1024 * 1024), currentUsed / (1024 * 1024), budgetBytes / (1024 * 1024));
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Long previous = cacheAllocations.put(key, bytes);
|
|
||||||
if (previous != null) {
|
|
||||||
cacheUsedBytes.addAndGet(bytes - previous);
|
|
||||||
} else {
|
|
||||||
cacheUsedBytes.addAndGet(bytes);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 메모리 해제 (날짜 기반)
|
|
||||||
*/
|
|
||||||
public void releaseCacheMemory(LocalDate date) {
|
|
||||||
releaseCacheMemory("daily::" + date);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 캐시 메모리 해제 (키 기반)
|
|
||||||
*/
|
|
||||||
public void releaseCacheMemory(String key) {
|
|
||||||
Long released = cacheAllocations.remove(key);
|
|
||||||
if (released != null) {
|
|
||||||
cacheUsedBytes.addAndGet(-released);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public long getAvailableCacheBudget() {
|
|
||||||
long budgetBytes = (long) properties.getCacheBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
return Math.max(0, budgetBytes - cacheUsedBytes.get());
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 쿼리 메모리 관리 (FIFO 대기 큐) ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메모리 예약 — 예산 부족 시 FIFO 대기
|
|
||||||
*
|
|
||||||
* @param queryId 쿼리 식별자
|
|
||||||
* @param estimatedBytes 추정 메모리 (보정 전 raw 값)
|
|
||||||
* @param maxWaitMs 최대 대기 시간 (밀리초)
|
|
||||||
* @throws MemoryBudgetExceededException 단일 쿼리 상한 초과 또는 타임아웃
|
|
||||||
*/
|
|
||||||
public void reserveQueryMemory(String queryId, long estimatedBytes, long maxWaitMs) {
|
|
||||||
long correctedBytes = applyCorrection(estimatedBytes);
|
|
||||||
long maxSingleBytes = (long) properties.getMaxSingleQueryGb() * 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
// 단일 쿼리 상한 체크
|
|
||||||
if (correctedBytes > maxSingleBytes) {
|
|
||||||
throw new MemoryBudgetExceededException(
|
|
||||||
String.format("단일 쿼리 메모리 상한 초과: estimated=%dMB, max=%dMB",
|
|
||||||
correctedBytes / (1024 * 1024), maxSingleBytes / (1024 * 1024)));
|
|
||||||
}
|
|
||||||
|
|
||||||
queryLock.lock();
|
|
||||||
try {
|
|
||||||
// 즉시 예약 가능한지 확인
|
|
||||||
if (canReserveQuery(correctedBytes)) {
|
|
||||||
doReserve(queryId, correctedBytes);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 대기 큐 진입
|
|
||||||
waitingQueryCount.incrementAndGet();
|
|
||||||
long deadline = System.nanoTime() + maxWaitMs * 1_000_000L;
|
|
||||||
try {
|
|
||||||
while (!canReserveQuery(correctedBytes)) {
|
|
||||||
long remainingNanos = deadline - System.nanoTime();
|
|
||||||
if (remainingNanos <= 0) {
|
|
||||||
throw new MemoryBudgetExceededException(
|
|
||||||
String.format("쿼리 메모리 대기 타임아웃: %dms, queryUsed=%dMB, budget=%dMB",
|
|
||||||
maxWaitMs, queryUsedBytes.get() / (1024 * 1024),
|
|
||||||
(long) properties.getQueryBudgetGb() * 1024));
|
|
||||||
}
|
|
||||||
queryBudgetAvailable.awaitNanos(remainingNanos);
|
|
||||||
}
|
|
||||||
doReserve(queryId, correctedBytes);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
throw new MemoryBudgetExceededException("쿼리 메모리 대기 중 인터럽트 발생");
|
|
||||||
} finally {
|
|
||||||
waitingQueryCount.decrementAndGet();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
queryLock.unlock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메모리 해제 후 대기 쿼리 시그널
|
|
||||||
*/
|
|
||||||
public void releaseQueryMemory(String queryId) {
|
|
||||||
Long released = queryAllocations.remove(queryId);
|
|
||||||
if (released != null) {
|
|
||||||
queryUsedBytes.addAndGet(-released);
|
|
||||||
queryLock.lock();
|
|
||||||
try {
|
|
||||||
queryBudgetAvailable.signalAll();
|
|
||||||
} finally {
|
|
||||||
queryLock.unlock();
|
|
||||||
}
|
|
||||||
log.debug("[MemoryBudget] 쿼리 메모리 해제: queryId={}, released={}MB, remaining={}MB",
|
|
||||||
queryId, released / (1024 * 1024), queryUsedBytes.get() / (1024 * 1024));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메모리 중간 업데이트 (실제 사용량이 추정과 다를 때)
|
|
||||||
*/
|
|
||||||
public void updateQueryMemory(String queryId, long actualBytes) {
|
|
||||||
long corrected = applyCorrection(actualBytes);
|
|
||||||
Long previous = queryAllocations.put(queryId, corrected);
|
|
||||||
if (previous != null) {
|
|
||||||
queryUsedBytes.addAndGet(corrected - previous);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 모니터링 ──
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 예산 현황 (모니터링 API용)
|
|
||||||
*/
|
|
||||||
public Map<String, Object> getBudgetStatus() {
|
|
||||||
Map<String, Object> status = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
long cacheUsed = cacheUsedBytes.get();
|
|
||||||
long queryUsed = queryUsedBytes.get();
|
|
||||||
long totalUsed = cacheUsed + queryUsed;
|
|
||||||
long cacheBudget = (long) properties.getCacheBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
long queryBudget = (long) properties.getQueryBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
|
|
||||||
// 전체
|
|
||||||
Map<String, Object> total = new LinkedHashMap<>();
|
|
||||||
total.put("totalGb", properties.getTotalBudgetGb());
|
|
||||||
total.put("usedMb", totalUsed / (1024 * 1024));
|
|
||||||
total.put("usagePercent", String.format("%.1f", totalUsed * 100.0 / ((long) properties.getTotalBudgetGb() * 1024 * 1024 * 1024)));
|
|
||||||
total.put("status", getUsageStatus(totalUsed, (long) properties.getTotalBudgetGb() * 1024 * 1024 * 1024));
|
|
||||||
status.put("totalBudget", total);
|
|
||||||
|
|
||||||
// 캐시
|
|
||||||
Map<String, Object> cacheInfo = new LinkedHashMap<>();
|
|
||||||
cacheInfo.put("budgetGb", properties.getCacheBudgetGb());
|
|
||||||
cacheInfo.put("usedMb", cacheUsed / (1024 * 1024));
|
|
||||||
cacheInfo.put("usagePercent", cacheBudget > 0 ? String.format("%.1f", cacheUsed * 100.0 / cacheBudget) : "0.0");
|
|
||||||
cacheInfo.put("allocations", cacheAllocations.size());
|
|
||||||
status.put("cacheBudget", cacheInfo);
|
|
||||||
|
|
||||||
// 쿼리
|
|
||||||
Map<String, Object> queryInfo = new LinkedHashMap<>();
|
|
||||||
queryInfo.put("budgetGb", properties.getQueryBudgetGb());
|
|
||||||
queryInfo.put("usedMb", queryUsed / (1024 * 1024));
|
|
||||||
queryInfo.put("usagePercent", queryBudget > 0 ? String.format("%.1f", queryUsed * 100.0 / queryBudget) : "0.0");
|
|
||||||
queryInfo.put("activeReservations", queryAllocations.size());
|
|
||||||
queryInfo.put("waitingCount", waitingQueryCount.get());
|
|
||||||
status.put("queryBudget", queryInfo);
|
|
||||||
|
|
||||||
// JVM 힙
|
|
||||||
Runtime runtime = Runtime.getRuntime();
|
|
||||||
long usedHeap = runtime.totalMemory() - runtime.freeMemory();
|
|
||||||
long maxHeap = runtime.maxMemory();
|
|
||||||
Map<String, Object> heap = new LinkedHashMap<>();
|
|
||||||
heap.put("usedMb", usedHeap / (1024 * 1024));
|
|
||||||
heap.put("maxMb", maxHeap / (1024 * 1024));
|
|
||||||
heap.put("usagePercent", String.format("%.1f", usedHeap * 100.0 / maxHeap));
|
|
||||||
status.put("heapInfo", heap);
|
|
||||||
|
|
||||||
return status;
|
|
||||||
}
|
|
||||||
|
|
||||||
public boolean isBudgetPressureHigh() {
|
|
||||||
long totalUsed = cacheUsedBytes.get() + queryUsedBytes.get();
|
|
||||||
long totalBudget = (long) properties.getTotalBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
double ratio = (double) totalUsed / totalBudget;
|
|
||||||
if (ratio >= properties.getWarningThreshold()) {
|
|
||||||
logBudgetPressure(ratio);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 내부 메서드 ──
|
|
||||||
|
|
||||||
private boolean canReserveQuery(long bytes) {
|
|
||||||
long budgetBytes = (long) properties.getQueryBudgetGb() * 1024 * 1024 * 1024;
|
|
||||||
return queryUsedBytes.get() + bytes <= budgetBytes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void doReserve(String queryId, long correctedBytes) {
|
|
||||||
queryAllocations.put(queryId, correctedBytes);
|
|
||||||
queryUsedBytes.addAndGet(correctedBytes);
|
|
||||||
log.debug("[MemoryBudget] 쿼리 메모리 예약: queryId={}, reserved={}MB, queryTotal={}MB",
|
|
||||||
queryId, correctedBytes / (1024 * 1024), queryUsedBytes.get() / (1024 * 1024));
|
|
||||||
}
|
|
||||||
|
|
||||||
private long applyCorrection(long rawEstimate) {
|
|
||||||
return (long) (rawEstimate * properties.getEstimationCorrectionFactor());
|
|
||||||
}
|
|
||||||
|
|
||||||
private String getUsageStatus(long used, long total) {
|
|
||||||
if (total == 0) return "UNKNOWN";
|
|
||||||
double ratio = (double) used / total;
|
|
||||||
if (ratio >= properties.getCriticalThreshold()) return "CRITICAL";
|
|
||||||
if (ratio >= properties.getWarningThreshold()) return "WARNING";
|
|
||||||
return "NORMAL";
|
|
||||||
}
|
|
||||||
|
|
||||||
private void logBudgetPressure(double ratio) {
|
|
||||||
long now = System.currentTimeMillis();
|
|
||||||
if (now - lastPressureLogTime > 5000) {
|
|
||||||
lastPressureLogTime = now;
|
|
||||||
log.warn("[MemoryBudget] 예산 압박: usage={}, cache={}MB, query={}MB, waiting={}",
|
|
||||||
String.format("%.1f%%", ratio * 100),
|
|
||||||
cacheUsedBytes.get() / (1024 * 1024),
|
|
||||||
queryUsedBytes.get() / (1024 * 1024),
|
|
||||||
waitingQueryCount.get());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -5,7 +5,6 @@ import gc.mda.signal_batch.batch.reader.FiveMinTrackCache;
|
|||||||
import gc.mda.signal_batch.batch.reader.HourlyTrackCache;
|
import gc.mda.signal_batch.batch.reader.HourlyTrackCache;
|
||||||
import gc.mda.signal_batch.domain.vessel.service.VesselLatestPositionCache;
|
import gc.mda.signal_batch.domain.vessel.service.VesselLatestPositionCache;
|
||||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||||
import gc.mda.signal_batch.global.websocket.service.TrackMemoryBudgetManager;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
@ -46,9 +45,6 @@ public class CacheMonitoringController {
|
|||||||
@Autowired(required = false)
|
@Autowired(required = false)
|
||||||
private VesselLatestPositionCache latestPositionCache;
|
private VesselLatestPositionCache latestPositionCache;
|
||||||
|
|
||||||
@Autowired
|
|
||||||
private TrackMemoryBudgetManager memoryBudgetManager;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캐시 통계 조회 (Dashboard 표시용 — 전체 캐시 집계)
|
* 캐시 통계 조회 (Dashboard 표시용 — 전체 캐시 집계)
|
||||||
*/
|
*/
|
||||||
@ -193,13 +189,4 @@ public class CacheMonitoringController {
|
|||||||
health.put("latestPosition", latestPositionCache != null ? "UP" : "DISABLED");
|
health.put("latestPosition", latestPositionCache != null ? "UP" : "DISABLED");
|
||||||
return ResponseEntity.ok(health);
|
return ResponseEntity.ok(health);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 메모리 예산 현황 (캐시 + 쿼리 파티셔닝 + JVM 힙)
|
|
||||||
*/
|
|
||||||
@GetMapping("/budget")
|
|
||||||
@Operation(summary = "메모리 예산 현황", description = "캐시/쿼리 메모리 예산 사용량, 대기 큐, JVM 힙 정보를 조회합니다")
|
|
||||||
public ResponseEntity<Map<String, Object>> getMemoryBudgetStatus() {
|
|
||||||
return ResponseEntity.ok(memoryBudgetManager.getBudgetStatus());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,210 +0,0 @@
|
|||||||
package gc.mda.signal_batch.monitoring.controller;
|
|
||||||
|
|
||||||
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.web.bind.annotation.*;
|
|
||||||
|
|
||||||
import java.util.*;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메트릭 조회 API
|
|
||||||
*
|
|
||||||
* WebSocket/REST 쿼리 실행 이력 및 성능 통계를 제공한다.
|
|
||||||
* ApiMetrics 프론트엔드 페이지의 데이터 소스.
|
|
||||||
*/
|
|
||||||
@RestController
|
|
||||||
@RequestMapping("/api/monitoring/query-metrics")
|
|
||||||
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
|
|
||||||
public class QueryMetricsController {
|
|
||||||
|
|
||||||
private final QueryMetricsService queryMetricsService;
|
|
||||||
private final JdbcTemplate queryJdbcTemplate;
|
|
||||||
|
|
||||||
private static final Set<String> ALLOWED_SORT_COLUMNS = Set.of(
|
|
||||||
"created_at", "elapsed_ms", "unique_vessels", "total_points"
|
|
||||||
);
|
|
||||||
|
|
||||||
public QueryMetricsController(
|
|
||||||
QueryMetricsService queryMetricsService,
|
|
||||||
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
|
||||||
this.queryMetricsService = queryMetricsService;
|
|
||||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping
|
|
||||||
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
|
|
||||||
public ResponseEntity<List<Map<String, Object>>> getRecentMetrics(
|
|
||||||
@RequestParam(defaultValue = "50") int limit) {
|
|
||||||
return ResponseEntity.ok(queryMetricsService.getRecentMetrics(Math.min(limit, 200)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/stats")
|
|
||||||
@Operation(summary = "쿼리 메트릭 통계", description = "기간별 쿼리 성능 통계 (평균 응답시간, 캐시 비율, 느린 쿼리 등)")
|
|
||||||
public ResponseEntity<Map<String, Object>> getStats(
|
|
||||||
@RequestParam(defaultValue = "7") int days) {
|
|
||||||
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/history")
|
|
||||||
@Operation(summary = "쿼리 이력 조회 (페이지네이션)", description = "필터 + 서버사이드 페이지네이션")
|
|
||||||
public Map<String, Object> getQueryHistory(
|
|
||||||
@Parameter(description = "쿼리 유형 (WEBSOCKET, REST_V2)") @RequestParam(required = false) String queryType,
|
|
||||||
@Parameter(description = "데이터 경로 (CACHE, DB, HYBRID)") @RequestParam(required = false) String dataPath,
|
|
||||||
@Parameter(description = "상태 (COMPLETED, CANCELLED, ERROR, TIMEOUT)") @RequestParam(required = false) String status,
|
|
||||||
@Parameter(description = "응답시간 최소 (ms)") @RequestParam(required = false) Integer elapsedMsMin,
|
|
||||||
@Parameter(description = "응답시간 최대 (ms)") @RequestParam(required = false) Integer elapsedMsMax,
|
|
||||||
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
|
|
||||||
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size,
|
|
||||||
@Parameter(description = "정렬 컬럼") @RequestParam(defaultValue = "created_at") String sortBy,
|
|
||||||
@Parameter(description = "정렬 방향 (asc, desc)") @RequestParam(defaultValue = "desc") String sortDir) {
|
|
||||||
|
|
||||||
if (!ALLOWED_SORT_COLUMNS.contains(sortBy)) {
|
|
||||||
sortBy = "created_at";
|
|
||||||
}
|
|
||||||
String direction = "asc".equalsIgnoreCase(sortDir) ? "ASC" : "DESC";
|
|
||||||
size = Math.min(size, 100);
|
|
||||||
|
|
||||||
StringBuilder where = new StringBuilder("WHERE 1=1");
|
|
||||||
List<Object> params = new ArrayList<>();
|
|
||||||
|
|
||||||
if (queryType != null && !queryType.isEmpty()) {
|
|
||||||
where.append(" AND query_type = ?");
|
|
||||||
params.add(queryType);
|
|
||||||
}
|
|
||||||
if (dataPath != null && !dataPath.isEmpty()) {
|
|
||||||
where.append(" AND data_path = ?");
|
|
||||||
params.add(dataPath);
|
|
||||||
}
|
|
||||||
if (status != null && !status.isEmpty()) {
|
|
||||||
where.append(" AND status = ?");
|
|
||||||
params.add(status);
|
|
||||||
}
|
|
||||||
if (elapsedMsMin != null) {
|
|
||||||
where.append(" AND elapsed_ms >= ?");
|
|
||||||
params.add(elapsedMsMin);
|
|
||||||
}
|
|
||||||
if (elapsedMsMax != null) {
|
|
||||||
where.append(" AND elapsed_ms <= ?");
|
|
||||||
params.add(elapsedMsMax);
|
|
||||||
}
|
|
||||||
|
|
||||||
String whereClause = where.toString();
|
|
||||||
|
|
||||||
// COUNT 쿼리
|
|
||||||
String countSql = "SELECT COUNT(*) FROM signal.t_query_metrics " + whereClause;
|
|
||||||
Integer totalElements = queryJdbcTemplate.queryForObject(countSql, Integer.class, params.toArray());
|
|
||||||
if (totalElements == null) totalElements = 0;
|
|
||||||
|
|
||||||
// 데이터 쿼리
|
|
||||||
String dataSql = """
|
|
||||||
SELECT id, query_id, query_type, created_at, data_path, status,
|
|
||||||
zoom_level, requested_mmsi, unique_vessels, total_tracks,
|
|
||||||
total_points, points_after_simplify, total_chunks,
|
|
||||||
response_bytes, elapsed_ms, db_query_ms, simplify_ms,
|
|
||||||
cache_hit_days, db_query_days, client_ip, client_id
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
""" + whereClause +
|
|
||||||
" ORDER BY " + sortBy + " " + direction +
|
|
||||||
" LIMIT ? OFFSET ?";
|
|
||||||
|
|
||||||
List<Object> dataParams = new ArrayList<>(params);
|
|
||||||
dataParams.add(size);
|
|
||||||
dataParams.add(page * size);
|
|
||||||
|
|
||||||
List<Map<String, Object>> content = queryJdbcTemplate.queryForList(dataSql, dataParams.toArray());
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("content", content);
|
|
||||||
result.put("totalElements", totalElements);
|
|
||||||
result.put("totalPages", (int) Math.ceil((double) totalElements / size));
|
|
||||||
result.put("currentPage", page);
|
|
||||||
result.put("pageSize", size);
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/summary")
|
|
||||||
@Operation(summary = "쿼리 메트릭 요약", description = "최근 N시간 요약 통계 (P95 포함)")
|
|
||||||
public Map<String, Object> getSummary(
|
|
||||||
@Parameter(description = "조회 기간 (시간)") @RequestParam(defaultValue = "24") int hours) {
|
|
||||||
|
|
||||||
String sql = """
|
|
||||||
SELECT
|
|
||||||
COUNT(*) as total_queries,
|
|
||||||
COALESCE(AVG(elapsed_ms), 0) as avg_elapsed_ms,
|
|
||||||
COALESCE(PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY elapsed_ms), 0) as p95_elapsed_ms,
|
|
||||||
COALESCE(MAX(elapsed_ms), 0) as max_elapsed_ms,
|
|
||||||
COUNT(CASE WHEN query_type = 'WEBSOCKET' THEN 1 END) as ws_count,
|
|
||||||
COUNT(CASE WHEN query_type LIKE 'REST%%' THEN 1 END) as rest_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'CACHE' THEN 1 END) as cache_only_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'DB' THEN 1 END) as db_only_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) as hybrid_count,
|
|
||||||
COUNT(CASE WHEN status = 'COMPLETED' THEN 1 END) as completed_count,
|
|
||||||
COUNT(CASE WHEN status != 'COMPLETED' THEN 1 END) as failed_count,
|
|
||||||
COALESCE(AVG(unique_vessels), 0) as avg_vessels,
|
|
||||||
COALESCE(AVG(total_points), 0) as avg_points_before,
|
|
||||||
COALESCE(AVG(points_after_simplify), 0) as avg_points_after,
|
|
||||||
COALESCE(AVG(response_bytes), 0) as avg_response_size_bytes
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '%d hours'
|
|
||||||
""".formatted(Math.min(hours, 720));
|
|
||||||
|
|
||||||
return queryJdbcTemplate.queryForMap(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
@GetMapping("/timeseries")
|
|
||||||
@Operation(summary = "쿼리 메트릭 시계열", description = "시간별/일별 버킷 집계 + Top 10 클라이언트")
|
|
||||||
public Map<String, Object> getTimeSeries(
|
|
||||||
@Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days,
|
|
||||||
@Parameter(description = "Top 클라이언트 그룹 기준 (ip | id)") @RequestParam(defaultValue = "ip") String groupBy) {
|
|
||||||
|
|
||||||
days = Math.min(days, 90);
|
|
||||||
String granularity = days <= 7 ? "HOURLY" : "DAILY";
|
|
||||||
String bucketExpr = days <= 7 ? "DATE_TRUNC('hour', created_at)" : "DATE(created_at)";
|
|
||||||
|
|
||||||
String bucketSql = """
|
|
||||||
SELECT %s AS bucket,
|
|
||||||
COUNT(*) AS query_count,
|
|
||||||
COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms,
|
|
||||||
COALESCE(MAX(elapsed_ms), 0) AS max_elapsed_ms,
|
|
||||||
COALESCE(AVG(response_bytes), 0) AS avg_response_bytes,
|
|
||||||
COUNT(CASE WHEN query_type = 'WEBSOCKET' THEN 1 END) AS ws_count,
|
|
||||||
COUNT(CASE WHEN query_type LIKE 'REST%%' THEN 1 END) AS rest_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'CACHE' THEN 1 END) AS cache_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'DB' THEN 1 END) AS db_count,
|
|
||||||
COUNT(CASE WHEN data_path = 'HYBRID' THEN 1 END) AS hybrid_count
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '%d days'
|
|
||||||
GROUP BY bucket ORDER BY bucket
|
|
||||||
""".formatted(bucketExpr, days);
|
|
||||||
|
|
||||||
List<Map<String, Object>> buckets = queryJdbcTemplate.queryForList(bucketSql);
|
|
||||||
|
|
||||||
boolean groupById = "id".equalsIgnoreCase(groupBy);
|
|
||||||
String clientColumn = groupById ? "client_id" : "client_ip";
|
|
||||||
String topClientsSql = """
|
|
||||||
SELECT %s AS client, COUNT(*) AS query_count,
|
|
||||||
COALESCE(AVG(elapsed_ms), 0) AS avg_elapsed_ms
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= NOW() - INTERVAL '%d days'
|
|
||||||
AND %s IS NOT NULL
|
|
||||||
GROUP BY %s
|
|
||||||
ORDER BY query_count DESC LIMIT 10
|
|
||||||
""".formatted(clientColumn, days, clientColumn, clientColumn);
|
|
||||||
|
|
||||||
List<Map<String, Object>> topClients = queryJdbcTemplate.queryForList(topClientsSql);
|
|
||||||
|
|
||||||
Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("buckets", buckets);
|
|
||||||
result.put("topClients", topClients);
|
|
||||||
result.put("granularity", granularity);
|
|
||||||
result.put("groupBy", groupById ? "id" : "ip");
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
package gc.mda.signal_batch.monitoring.service;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.scheduling.annotation.Scheduled;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.sql.Timestamp;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메트릭 벌크 INSERT 버퍼 서비스
|
|
||||||
*
|
|
||||||
* ConcurrentLinkedQueue로 lock-free 수집 후 10초 간격으로 batchUpdate.
|
|
||||||
* 1요청 = 1레코드 보장: WebSocket은 쿼리 완료 시 1회, REST는 호출당 1회 enqueue.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class QueryMetricsBufferService {
|
|
||||||
|
|
||||||
private static final int MAX_FLUSH_SIZE = 500;
|
|
||||||
|
|
||||||
private static final String INSERT_SQL = """
|
|
||||||
INSERT INTO signal.t_query_metrics (
|
|
||||||
query_id, session_id, query_type, created_at,
|
|
||||||
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
|
|
||||||
data_path, cache_hit_days, db_query_days, db_conn_total,
|
|
||||||
unique_vessels, total_tracks, total_points, points_after_simplify,
|
|
||||||
total_chunks, response_bytes,
|
|
||||||
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
|
|
||||||
status, client_ip, client_id
|
|
||||||
) VALUES (
|
|
||||||
?, ?, ?, now(),
|
|
||||||
?, ?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?,
|
|
||||||
?, ?, ?, ?,
|
|
||||||
?, ?, ?
|
|
||||||
)
|
|
||||||
""";
|
|
||||||
|
|
||||||
private final JdbcTemplate queryJdbcTemplate;
|
|
||||||
private final ConcurrentLinkedQueue<QueryMetricsService.QueryMetric> buffer = new ConcurrentLinkedQueue<>();
|
|
||||||
|
|
||||||
public QueryMetricsBufferService(
|
|
||||||
@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
|
||||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void ensureClientIpColumn() {
|
|
||||||
try {
|
|
||||||
queryJdbcTemplate.execute("""
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at);
|
|
||||||
END IF;
|
|
||||||
END $$
|
|
||||||
""");
|
|
||||||
log.info("t_query_metrics client_ip column ensured");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to ensure client_ip column: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void ensureClientIdColumn() {
|
|
||||||
try {
|
|
||||||
queryJdbcTemplate.execute("""
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_id'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_id VARCHAR(100);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_id ON signal.t_query_metrics(client_id, created_at);
|
|
||||||
END IF;
|
|
||||||
END $$
|
|
||||||
""");
|
|
||||||
log.info("t_query_metrics client_id column ensured");
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to ensure client_id column: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 메트릭 레코드를 버퍼에 추가 (lock-free)
|
|
||||||
*/
|
|
||||||
public void enqueue(QueryMetricsService.QueryMetric metric) {
|
|
||||||
if (metric == null) return;
|
|
||||||
buffer.offer(metric);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 10초 간격으로 버퍼 flush → batchUpdate
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 10_000)
|
|
||||||
public void flush() {
|
|
||||||
if (buffer.isEmpty()) return;
|
|
||||||
|
|
||||||
List<QueryMetricsService.QueryMetric> batch = new ArrayList<>(MAX_FLUSH_SIZE);
|
|
||||||
QueryMetricsService.QueryMetric metric;
|
|
||||||
while (batch.size() < MAX_FLUSH_SIZE && (metric = buffer.poll()) != null) {
|
|
||||||
batch.add(metric);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (batch.isEmpty()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
List<Object[]> args = batch.stream()
|
|
||||||
.map(this::toArgs)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
queryJdbcTemplate.batchUpdate(INSERT_SQL, args);
|
|
||||||
|
|
||||||
log.debug("Flushed {} query metrics to DB (remaining: {})", batch.size(), buffer.size());
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to flush query metrics ({} records): {}", batch.size(), e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object[] toArgs(QueryMetricsService.QueryMetric m) {
|
|
||||||
return new Object[]{
|
|
||||||
m.getQueryId(), m.getSessionId(), m.getQueryType(),
|
|
||||||
m.getStartTime() != null ? Timestamp.valueOf(m.getStartTime()) : null,
|
|
||||||
m.getEndTime() != null ? Timestamp.valueOf(m.getEndTime()) : null,
|
|
||||||
m.getZoomLevel(), m.getViewportBounds(), m.getRequestedMmsi(),
|
|
||||||
m.getDataPath(), m.getCacheHitDays(), m.getDbQueryDays(), m.getDbConnTotal(),
|
|
||||||
m.getUniqueVessels(), m.getTotalTracks(), m.getTotalPoints(), m.getPointsAfterSimplify(),
|
|
||||||
m.getTotalChunks(), m.getResponseBytes(),
|
|
||||||
m.getElapsedMs(), m.getDbQueryMs(), m.getSimplifyMs(), m.getBackpressureEvents(),
|
|
||||||
m.getStatus(), m.getClientIp(), m.getClientId()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 현재 버퍼 크기 (모니터링용)
|
|
||||||
*/
|
|
||||||
public int getBufferSize() {
|
|
||||||
return buffer.size();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
package gc.mda.signal_batch.monitoring.service;
|
|
||||||
|
|
||||||
import lombok.Builder;
|
|
||||||
import lombok.Getter;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
import java.time.LocalDateTime;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 실행 메트릭 조회 서비스
|
|
||||||
*
|
|
||||||
* 적재는 QueryMetricsBufferService가 담당 (ConcurrentLinkedQueue + 10초 batch flush).
|
|
||||||
* 이 서비스는 조회 전용 + QueryMetric DTO 정의.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
|
||||||
@Service
|
|
||||||
public class QueryMetricsService {
|
|
||||||
|
|
||||||
private final JdbcTemplate queryJdbcTemplate;
|
|
||||||
|
|
||||||
public QueryMetricsService(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
|
||||||
this.queryJdbcTemplate = queryJdbcTemplate;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 최근 쿼리 메트릭 조회
|
|
||||||
*/
|
|
||||||
public List<Map<String, Object>> getRecentMetrics(int limit) {
|
|
||||||
return queryJdbcTemplate.queryForList("""
|
|
||||||
SELECT query_id, session_id, query_type, created_at,
|
|
||||||
start_time, end_time, zoom_level, viewport_bounds,
|
|
||||||
data_path, cache_hit_days, db_query_days, db_conn_total,
|
|
||||||
unique_vessels, total_tracks, total_points, points_after_simplify,
|
|
||||||
total_chunks, response_bytes,
|
|
||||||
elapsed_ms, db_query_ms, simplify_ms, backpressure_events, status
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
""", limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 기간별 쿼리 메트릭 통계
|
|
||||||
*/
|
|
||||||
public Map<String, Object> getStats(int days) {
|
|
||||||
Map<String, Object> stats = new LinkedHashMap<>();
|
|
||||||
|
|
||||||
// 전체 통계
|
|
||||||
Map<String, Object> summary = queryJdbcTemplate.queryForMap("""
|
|
||||||
SELECT
|
|
||||||
COUNT(*) AS total_queries,
|
|
||||||
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
|
|
||||||
MAX(elapsed_ms) AS max_elapsed_ms,
|
|
||||||
ROUND(AVG(unique_vessels)) AS avg_vessels,
|
|
||||||
ROUND(AVG(total_points)) AS avg_points,
|
|
||||||
SUM(CASE WHEN data_path = 'CACHE' THEN 1 ELSE 0 END) AS cache_only,
|
|
||||||
SUM(CASE WHEN data_path = 'HYBRID' THEN 1 ELSE 0 END) AS hybrid,
|
|
||||||
SUM(CASE WHEN data_path = 'DB' THEN 1 ELSE 0 END) AS db_only,
|
|
||||||
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
|
||||||
SUM(CASE WHEN status = 'CANCELLED' THEN 1 ELSE 0 END) AS cancelled,
|
|
||||||
SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS errors,
|
|
||||||
SUM(CASE WHEN status = 'TIMEOUT' THEN 1 ELSE 0 END) AS timeouts
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= now() - INTERVAL '%d days'
|
|
||||||
""".formatted(days));
|
|
||||||
stats.put("summary", summary);
|
|
||||||
|
|
||||||
// 일별 추이
|
|
||||||
List<Map<String, Object>> daily = queryJdbcTemplate.queryForList("""
|
|
||||||
SELECT
|
|
||||||
DATE(created_at) AS date,
|
|
||||||
COUNT(*) AS query_count,
|
|
||||||
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
|
|
||||||
ROUND(AVG(unique_vessels)) AS avg_vessels,
|
|
||||||
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
|
||||||
SUM(CASE WHEN status != 'COMPLETED' THEN 1 ELSE 0 END) AS failed
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= now() - INTERVAL '%d days'
|
|
||||||
GROUP BY DATE(created_at)
|
|
||||||
ORDER BY date DESC
|
|
||||||
""".formatted(days));
|
|
||||||
stats.put("dailyTrend", daily);
|
|
||||||
|
|
||||||
// 느린 쿼리 TOP 10
|
|
||||||
List<Map<String, Object>> slowQueries = queryJdbcTemplate.queryForList("""
|
|
||||||
SELECT query_id, created_at, elapsed_ms, unique_vessels, total_points,
|
|
||||||
data_path, db_conn_total, zoom_level, status
|
|
||||||
FROM signal.t_query_metrics
|
|
||||||
WHERE created_at >= now() - INTERVAL '%d days'
|
|
||||||
ORDER BY elapsed_ms DESC
|
|
||||||
LIMIT 10
|
|
||||||
""".formatted(days));
|
|
||||||
stats.put("slowQueries", slowQueries);
|
|
||||||
|
|
||||||
return stats;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 쿼리 메트릭 데이터 클래스
|
|
||||||
*/
|
|
||||||
@Getter
|
|
||||||
@Builder
|
|
||||||
public static class QueryMetric {
|
|
||||||
private final String queryId;
|
|
||||||
private final String sessionId;
|
|
||||||
private final String queryType;
|
|
||||||
private final LocalDateTime startTime;
|
|
||||||
private final LocalDateTime endTime;
|
|
||||||
private final Integer zoomLevel;
|
|
||||||
private final String viewportBounds;
|
|
||||||
private final int requestedMmsi;
|
|
||||||
private final String dataPath;
|
|
||||||
private final int cacheHitDays;
|
|
||||||
private final int dbQueryDays;
|
|
||||||
private final int dbConnTotal;
|
|
||||||
private final int uniqueVessels;
|
|
||||||
private final int totalTracks;
|
|
||||||
private final int totalPoints;
|
|
||||||
private final int pointsAfterSimplify;
|
|
||||||
private final int totalChunks;
|
|
||||||
private final long responseBytes;
|
|
||||||
private final long elapsedMs;
|
|
||||||
private final long dbQueryMs;
|
|
||||||
private final long simplifyMs;
|
|
||||||
private final int backpressureEvents;
|
|
||||||
private final String status;
|
|
||||||
private final String clientIp;
|
|
||||||
private final String clientId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -48,7 +48,7 @@ spring:
|
|||||||
validation-timeout: 5000
|
validation-timeout: 5000
|
||||||
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
||||||
# PostGIS 함수를 위해 public 스키마를 search_path에 명시적으로 추가
|
# PostGIS 함수를 위해 public 스키마를 search_path에 명시적으로 추가
|
||||||
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog; SET work_mem = '256MB'; SET synchronous_commit = 'off';"
|
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog;"
|
||||||
statement-cache-size: 250
|
statement-cache-size: 250
|
||||||
data-source-properties:
|
data-source-properties:
|
||||||
prepareThreshold: 3
|
prepareThreshold: 3
|
||||||
@ -68,7 +68,7 @@ spring:
|
|||||||
idle-timeout: 600000
|
idle-timeout: 600000
|
||||||
max-lifetime: 1800000
|
max-lifetime: 1800000
|
||||||
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초)
|
||||||
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public; SET synchronous_commit = 'off';"
|
connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;"
|
||||||
|
|
||||||
# Request 크기 설정
|
# Request 크기 설정
|
||||||
servlet:
|
servlet:
|
||||||
@ -87,12 +87,19 @@ spring:
|
|||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
root: INFO
|
root: INFO
|
||||||
gc.mda.signal_batch: INFO
|
gc.mda.signal_batch: DEBUG
|
||||||
gc.mda.signal_batch.monitoring: INFO
|
gc.mda.signal_batch.global.util: INFO
|
||||||
org.springframework.batch: WARN
|
gc.mda.signal_batch.global.websocket.service: INFO
|
||||||
|
gc.mda.signal_batch.batch.writer: INFO
|
||||||
|
gc.mda.signal_batch.batch.reader: INFO
|
||||||
|
gc.mda.signal_batch.batch.processor: INFO
|
||||||
|
gc.mda.signal_batch.domain: INFO
|
||||||
|
gc.mda.signal_batch.monitoring: DEBUG
|
||||||
|
gc.mda.signal_batch.monitoring.controller: INFO
|
||||||
|
org.springframework.batch: INFO
|
||||||
org.springframework.jdbc: WARN
|
org.springframework.jdbc: WARN
|
||||||
org.postgresql: WARN
|
org.postgresql: WARN
|
||||||
com.zaxxer.hikari: WARN
|
com.zaxxer.hikari: INFO
|
||||||
|
|
||||||
# 개발 환경 배치 설정 (성능 최적화)
|
# 개발 환경 배치 설정 (성능 최적화)
|
||||||
vessel: # spring 하위가 아닌 최상위 레벨
|
vessel: # spring 하위가 아닌 최상위 레벨
|
||||||
@ -173,7 +180,6 @@ vessel: # spring 하위가 아닌 최상위 레벨
|
|||||||
|
|
||||||
# 궤적 비정상 검출 설정
|
# 궤적 비정상 검출 설정
|
||||||
track:
|
track:
|
||||||
include-abnormal-in-tracks: true # 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용)
|
|
||||||
abnormal-detection:
|
abnormal-detection:
|
||||||
large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함
|
large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함
|
||||||
extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots)
|
extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots)
|
||||||
@ -205,9 +211,6 @@ vessel: # spring 하위가 아닌 최상위 레벨
|
|||||||
max-size: 60000 # 최대 60,000척
|
max-size: 60000 # 최대 60,000척
|
||||||
refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려)
|
refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려)
|
||||||
|
|
||||||
# L2 HourlyTrackCache 간소화 (운영 환경 활성화)
|
|
||||||
hourly-simplification:
|
|
||||||
enabled: true # 운영 환경: 활성화
|
|
||||||
|
|
||||||
# 비정상 궤적 검출 설정 (개선됨)
|
# 비정상 궤적 검출 설정 (개선됨)
|
||||||
abnormal-detection:
|
abnormal-detection:
|
||||||
@ -261,10 +264,8 @@ vessel: # spring 하위가 아닌 최상위 레벨
|
|||||||
retention-days: 60 # 구역별 선박 항적: 60일
|
retention-days: 60 # 구역별 선박 항적: 60일
|
||||||
t_grid_vessel_tracks:
|
t_grid_vessel_tracks:
|
||||||
retention-days: 30 # 해구별 선박 항적: 30일
|
retention-days: 30 # 해구별 선박 항적: 30일
|
||||||
t_vessel_tracks_daily:
|
|
||||||
retention-months: 0 # 일별 항적: 영구 보관
|
|
||||||
t_abnormal_tracks:
|
t_abnormal_tracks:
|
||||||
retention-months: 0 # 비정상 항적: 영구 보관
|
retention-months: 0 # 비정상 항적: 무한 보관
|
||||||
|
|
||||||
# S&P AIS API 캐시 TTL (운영: 120분)
|
# S&P AIS API 캐시 TTL (운영: 120분)
|
||||||
app:
|
app:
|
||||||
@ -272,29 +273,17 @@ app:
|
|||||||
ais-target:
|
ais-target:
|
||||||
ttl-minutes: 120
|
ttl-minutes: 120
|
||||||
ais-api:
|
ais-api:
|
||||||
username: 86b30c84-5d17-41ac-8c4f-2aa20d791114
|
username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade
|
||||||
password: KHZQVc2tMBGtNxvG
|
password: 2LLzSJNqtxWVD8zC
|
||||||
|
|
||||||
# 일일 항적 데이터 인메모리 캐시
|
# 일일 항적 데이터 인메모리 캐시
|
||||||
cache:
|
cache:
|
||||||
daily-track:
|
daily-track:
|
||||||
enabled: true
|
enabled: true
|
||||||
retention-days: 14 # D-1 ~ D-14 (2주, DP 간소화로 메모리 절감)
|
retention-days: 7 # D-1 ~ D-7 (오늘 제외)
|
||||||
max-memory-gb: 10 # 최대 10GB (DP 간소화 후 일 ~400MB × 14일 ≈ 6GB + 여유)
|
max-memory-gb: 6 # 최대 6GB (일 평균 ~720MB × 7일 = ~5GB)
|
||||||
warmup-async: true # 비동기 워밍업 (서버 시작 차단 없음)
|
warmup-async: true # 비동기 워밍업 (서버 시작 차단 없음)
|
||||||
|
|
||||||
# 항적 데이터 메모리 예산 (64GB JVM 기준)
|
|
||||||
track:
|
|
||||||
memory-budget:
|
|
||||||
total-budget-gb: 64 # 전체 JVM 힙
|
|
||||||
cache-budget-gb: 35 # L1+L2+L3 캐시 (L3 5GB + L2 ~14GB + L1 ~3GB + 여유 13GB)
|
|
||||||
query-budget-gb: 20 # REST/WebSocket 동시 쿼리 (동시 60쿼리 × ~300MB)
|
|
||||||
max-single-query-gb: 5 # 단일 쿼리 상한
|
|
||||||
estimation-correction-factor: 1.8 # 실측 기반 보정 계수
|
|
||||||
queue-timeout-seconds: 60
|
|
||||||
warning-threshold: 0.8
|
|
||||||
critical-threshold: 0.95
|
|
||||||
|
|
||||||
# WebSocket 부하 제어 설정
|
# WebSocket 부하 제어 설정
|
||||||
websocket:
|
websocket:
|
||||||
query:
|
query:
|
||||||
|
|||||||
@ -159,8 +159,6 @@ vessel:
|
|||||||
page-size: ${BATCH_PAGE_SIZE:10000}
|
page-size: ${BATCH_PAGE_SIZE:10000}
|
||||||
partition-size: ${BATCH_PARTITION_SIZE:24}
|
partition-size: ${BATCH_PARTITION_SIZE:24}
|
||||||
skip-limit: 100
|
skip-limit: 100
|
||||||
track:
|
|
||||||
include-abnormal-in-tracks: false # true: 비정상 궤적도 정상 테이블+캐시에 포함 (강화학습 데이터 수집용)
|
|
||||||
retry-limit: 3
|
retry-limit: 3
|
||||||
# Reader 설정
|
# Reader 설정
|
||||||
use-cursor-reader: true # Cursor Reader 사용 여부
|
use-cursor-reader: true # Cursor Reader 사용 여부
|
||||||
@ -274,13 +272,6 @@ vessel:
|
|||||||
ttl-minutes: 120 # 캐시 TTL: 120분 (위성 AIS 30~60분 간격 고려)
|
ttl-minutes: 120 # 캐시 TTL: 120분 (위성 AIS 30~60분 간격 고려)
|
||||||
max-size: 100000 # 최대 선박 수: 100,000척 (2시간 누적 고려)
|
max-size: 100000 # 최대 선박 수: 100,000척 (2시간 누적 고려)
|
||||||
|
|
||||||
# L2 HourlyTrackCache 간소화 설정
|
|
||||||
hourly-simplification:
|
|
||||||
enabled: false # 기본값: 비활성화 (프로파일별로 활성화)
|
|
||||||
cron: "0 30 6,12,18 * * *" # 06:30, 12:30, 18:30 실행
|
|
||||||
hours-ago: 6 # 6시간 이상 경과 엔트리 대상
|
|
||||||
sample-rate: 2 # 매 2번째 포인트만 유지 (~50% 감소)
|
|
||||||
|
|
||||||
# ==================== S&P Global AIS API 설정 ====================
|
# ==================== S&P Global AIS API 설정 ====================
|
||||||
app:
|
app:
|
||||||
ais-api:
|
ais-api:
|
||||||
@ -293,7 +284,7 @@ app:
|
|||||||
cache:
|
cache:
|
||||||
ais-target:
|
ais-target:
|
||||||
ttl-minutes: 120 # 기본 TTL (프로파일별 오버라이드)
|
ttl-minutes: 120 # 기본 TTL (프로파일별 오버라이드)
|
||||||
max-size: 500000 # 최대 캐시 크기 (50만 건)
|
max-size: 300000 # 최대 캐시 크기 (30만 건)
|
||||||
|
|
||||||
five-min-track:
|
five-min-track:
|
||||||
ttl-minutes: 75 # TTL 75분 (1시간 + 15분 여유)
|
ttl-minutes: 75 # TTL 75분 (1시간 + 15분 여유)
|
||||||
@ -310,18 +301,6 @@ app:
|
|||||||
warmup-enabled: true
|
warmup-enabled: true
|
||||||
warmup-days: 7
|
warmup-days: 7
|
||||||
|
|
||||||
# 항적 데이터 메모리 예산 (논리적 파티셔닝)
|
|
||||||
track:
|
|
||||||
memory-budget:
|
|
||||||
total-budget-gb: 64 # 전체 JVM 힙 예산
|
|
||||||
cache-budget-gb: 35 # L1/L2/L3 캐시 (55%)
|
|
||||||
query-budget-gb: 20 # REST/WebSocket 동시 쿼리 (31%)
|
|
||||||
max-single-query-gb: 5 # 단일 쿼리 상한
|
|
||||||
estimation-correction-factor: 1.8 # 메모리 추정 보정 계수
|
|
||||||
queue-timeout-seconds: 60 # 쿼리 대기 큐 타임아웃
|
|
||||||
warning-threshold: 0.8 # 예산 경고 임계값 (80%)
|
|
||||||
critical-threshold: 0.95 # 예산 위험 임계값 (95%)
|
|
||||||
|
|
||||||
# Swagger/OpenAPI 설정
|
# Swagger/OpenAPI 설정
|
||||||
springdoc:
|
springdoc:
|
||||||
api-docs:
|
api-docs:
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
-- 쿼리 실행 메트릭 테이블
|
|
||||||
-- WebSocket/REST 쿼리의 성능 지표를 기록하여 ApiMetrics 페이지에서 조회
|
|
||||||
CREATE TABLE IF NOT EXISTS signal.t_query_metrics (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
query_id VARCHAR(64) NOT NULL,
|
|
||||||
session_id VARCHAR(64),
|
|
||||||
query_type VARCHAR(20) NOT NULL, -- 'WEBSOCKET' | 'REST_V1' | 'REST_V2'
|
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
|
||||||
|
|
||||||
-- 요청 파라미터
|
|
||||||
start_time TIMESTAMP,
|
|
||||||
end_time TIMESTAMP,
|
|
||||||
zoom_level INTEGER,
|
|
||||||
viewport_bounds VARCHAR(200), -- "minLon,minLat,maxLon,maxLat"
|
|
||||||
requested_mmsi INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- 처리 경로
|
|
||||||
data_path VARCHAR(10), -- 'CACHE' | 'DB' | 'HYBRID'
|
|
||||||
cache_hit_days INTEGER DEFAULT 0,
|
|
||||||
db_query_days INTEGER DEFAULT 0,
|
|
||||||
db_conn_total INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- 결과 통계
|
|
||||||
unique_vessels INTEGER DEFAULT 0,
|
|
||||||
total_tracks INTEGER DEFAULT 0,
|
|
||||||
total_points INTEGER DEFAULT 0,
|
|
||||||
points_after_simplify INTEGER DEFAULT 0,
|
|
||||||
total_chunks INTEGER DEFAULT 0,
|
|
||||||
response_bytes BIGINT DEFAULT 0,
|
|
||||||
|
|
||||||
-- 성능
|
|
||||||
elapsed_ms BIGINT DEFAULT 0,
|
|
||||||
db_query_ms BIGINT DEFAULT 0,
|
|
||||||
simplify_ms BIGINT DEFAULT 0,
|
|
||||||
backpressure_events INTEGER DEFAULT 0,
|
|
||||||
|
|
||||||
-- 결과 상태
|
|
||||||
status VARCHAR(20) DEFAULT 'COMPLETED' -- 'COMPLETED' | 'CANCELLED' | 'ERROR' | 'TIMEOUT'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- client_ip 컬럼 추가 (idempotent)
|
|
||||||
DO $$
|
|
||||||
BEGIN
|
|
||||||
IF NOT EXISTS (
|
|
||||||
SELECT 1 FROM information_schema.columns
|
|
||||||
WHERE table_schema = 'signal' AND table_name = 't_query_metrics' AND column_name = 'client_ip'
|
|
||||||
) THEN
|
|
||||||
ALTER TABLE signal.t_query_metrics ADD COLUMN client_ip VARCHAR(45);
|
|
||||||
END IF;
|
|
||||||
END $$;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_created ON signal.t_query_metrics(created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_type ON signal.t_query_metrics(query_type, created_at);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_query_metrics_client_ip ON signal.t_query_metrics(client_ip, created_at);
|
|
||||||
@ -12,25 +12,6 @@ import static org.junit.jupiter.api.Assertions.*;
|
|||||||
|
|
||||||
class SignalKindCodeTest {
|
class SignalKindCodeTest {
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("shipName 기반 BUOY 검출")
|
|
||||||
class ShipNameBuoy {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY (vesselType 무시)")
|
|
||||||
void resolve_buoyByName() {
|
|
||||||
assertEquals("000028", SignalKindCode.resolve("Cargo", null, "BUOY_01_23").getCode());
|
|
||||||
assertEquals("000028", SignalKindCode.resolve("Tanker", null, "AIS.BUOY.01").getCode());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("'.' 또는 '_' 1개 이하 → vesselType 기준")
|
|
||||||
void resolve_notBuoyByName() {
|
|
||||||
assertEquals("000023", SignalKindCode.resolve("Cargo", null, "M.V CARGO").getCode());
|
|
||||||
assertEquals("000024", SignalKindCode.resolve("Tanker", null, "OIL_TANKER").getCode());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("vesselType 단독 매칭")
|
@DisplayName("vesselType 단독 매칭")
|
||||||
class VesselTypeDirect {
|
class VesselTypeDirect {
|
||||||
@ -40,7 +21,7 @@ class SignalKindCodeTest {
|
|||||||
"Cargo, 000023",
|
"Cargo, 000023",
|
||||||
"Tanker, 000024",
|
"Tanker, 000024",
|
||||||
"Passenger, 000022",
|
"Passenger, 000022",
|
||||||
"AtoN, 000027",
|
"AtoN, 000028",
|
||||||
"Law Enforcement, 000025",
|
"Law Enforcement, 000025",
|
||||||
"Search and Rescue, 000021",
|
"Search and Rescue, 000021",
|
||||||
"Local Vessel, 000020"
|
"Local Vessel, 000020"
|
||||||
@ -57,11 +38,11 @@ class SignalKindCodeTest {
|
|||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@CsvSource({
|
@CsvSource({
|
||||||
|
"Tug, 000025",
|
||||||
"Pilot Boat, 000025",
|
"Pilot Boat, 000025",
|
||||||
|
"Tender, 000025",
|
||||||
"Anti Pollution, 000025",
|
"Anti Pollution, 000025",
|
||||||
"Medical Transport, 000025",
|
"Medical Transport, 000025",
|
||||||
"Tug, 000027",
|
|
||||||
"Tender, 000027",
|
|
||||||
"High Speed Craft, 000022",
|
"High Speed Craft, 000022",
|
||||||
"Wing in Ground-effect, 000022"
|
"Wing in Ground-effect, 000022"
|
||||||
})
|
})
|
||||||
@ -79,13 +60,13 @@ class SignalKindCodeTest {
|
|||||||
@CsvSource({
|
@CsvSource({
|
||||||
"Vessel, Fishing, 000020",
|
"Vessel, Fishing, 000020",
|
||||||
"Vessel, Military Operations, 000025",
|
"Vessel, Military Operations, 000025",
|
||||||
"Vessel, Towing, 000027",
|
"Vessel, Towing, 000025",
|
||||||
"Vessel, Towing (Large), 000027",
|
"Vessel, Towing (Large), 000025",
|
||||||
"Vessel, Dredging/Underwater Ops, 000027",
|
"Vessel, Dredging/Underwater Ops, 000025",
|
||||||
"Vessel, Diving Operations, 000027",
|
"Vessel, Diving Operations, 000025",
|
||||||
"Vessel, Pleasure Craft, 000027",
|
"Vessel, Pleasure Craft, 000020",
|
||||||
"Vessel, Sailing, 000027",
|
"Vessel, Sailing, 000020",
|
||||||
"Vessel, N/A, 000027",
|
"Vessel, N/A, 000020",
|
||||||
"Vessel, Hazardous Cat A, 000023",
|
"Vessel, Hazardous Cat A, 000023",
|
||||||
"Vessel, Hazardous Cat B, 000023",
|
"Vessel, Hazardous Cat B, 000023",
|
||||||
"Vessel, Unknown, 000027"
|
"Vessel, Unknown, 000027"
|
||||||
|
|||||||
@ -14,34 +14,6 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@DisplayName("SignalKindCode - MDA 선종 범례코드 치환")
|
@DisplayName("SignalKindCode - MDA 선종 범례코드 치환")
|
||||||
class SignalKindCodeTest {
|
class SignalKindCodeTest {
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("shipName 기반 BUOY 검출 (최우선)")
|
|
||||||
class ShipNameBuoy {
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "shipName={0} → BUOY")
|
|
||||||
@ValueSource(strings = {"BUOY_01_23", "AIS.BUOY.01", "LIGHT__HOUSE", "A.B.C"})
|
|
||||||
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY")
|
|
||||||
void resolve_buoyByName(String shipName) {
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
|
|
||||||
.isEqualTo(SignalKindCode.BUOY);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "shipName={0} → vesselType 기준")
|
|
||||||
@ValueSource(strings = {"M.V CARGO", "SHIP_ONE", "NORMAL SHIP", "ABC"})
|
|
||||||
@DisplayName("'.' 또는 '_' 1개 이하 → shipName 무시, vesselType 기준")
|
|
||||||
void resolve_notBuoyByName(String shipName) {
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
|
|
||||||
.isEqualTo(SignalKindCode.CARGO);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("shipName null → vesselType 기준")
|
|
||||||
void resolve_nullShipName() {
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, null))
|
|
||||||
.isEqualTo(SignalKindCode.CARGO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@DisplayName("vesselType 단독 매칭")
|
@DisplayName("vesselType 단독 매칭")
|
||||||
class VesselTypeDirect {
|
class VesselTypeDirect {
|
||||||
@ -51,6 +23,7 @@ class SignalKindCodeTest {
|
|||||||
"cargo, CARGO",
|
"cargo, CARGO",
|
||||||
"tanker, TANKER",
|
"tanker, TANKER",
|
||||||
"passenger, FERRY",
|
"passenger, FERRY",
|
||||||
|
"aton, BUOY",
|
||||||
"law enforcement, GOV",
|
"law enforcement, GOV",
|
||||||
"search and rescue, KCGV",
|
"search and rescue, KCGV",
|
||||||
"local vessel, FISHING"
|
"local vessel, FISHING"
|
||||||
@ -60,12 +33,6 @@ class SignalKindCodeTest {
|
|||||||
SignalKindCode result = SignalKindCode.resolve(vesselType, null);
|
SignalKindCode result = SignalKindCode.resolve(vesselType, null);
|
||||||
assertThat(result.name()).isEqualTo(expectedName);
|
assertThat(result.name()).isEqualTo(expectedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("aton → DEFAULT (부이가 아닌 일반 장비)")
|
|
||||||
void resolve_aton() {
|
|
||||||
assertThat(SignalKindCode.resolve("aton", null)).isEqualTo(SignalKindCode.DEFAULT);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
@Nested
|
||||||
@ -73,19 +40,12 @@ class SignalKindCodeTest {
|
|||||||
class VesselTypeGroup {
|
class VesselTypeGroup {
|
||||||
|
|
||||||
@ParameterizedTest(name = "vesselType={0} → GOV")
|
@ParameterizedTest(name = "vesselType={0} → GOV")
|
||||||
@ValueSource(strings = {"pilot boat", "anti pollution", "medical transport"})
|
@ValueSource(strings = {"tug", "pilot boat", "tender", "anti pollution", "medical transport"})
|
||||||
@DisplayName("GOV 그룹 매칭")
|
@DisplayName("GOV 그룹 매칭")
|
||||||
void resolve_govGroup(String vesselType) {
|
void resolve_govGroup(String vesselType) {
|
||||||
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV);
|
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "vesselType={0} → DEFAULT")
|
|
||||||
@ValueSource(strings = {"tug", "tender"})
|
|
||||||
@DisplayName("tug, tender → DEFAULT")
|
|
||||||
void resolve_tugTenderDefault(String vesselType) {
|
|
||||||
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "vesselType={0} → FERRY")
|
@ParameterizedTest(name = "vesselType={0} → FERRY")
|
||||||
@ValueSource(strings = {"high speed craft", "wing in ground-effect"})
|
@ValueSource(strings = {"high speed craft", "wing in ground-effect"})
|
||||||
@DisplayName("FERRY 그룹 매칭")
|
@DisplayName("FERRY 그룹 매칭")
|
||||||
@ -110,18 +70,18 @@ class SignalKindCodeTest {
|
|||||||
assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV);
|
assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "Vessel + {0} → DEFAULT")
|
@ParameterizedTest(name = "Vessel + {0} → GOV")
|
||||||
@ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"})
|
@ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"})
|
||||||
@DisplayName("Vessel + 해양작업 → DEFAULT")
|
@DisplayName("Vessel + 해양작업 → GOV")
|
||||||
void resolve_vesselMarineOps(String extraInfo) {
|
void resolve_vesselMarineOps(String extraInfo) {
|
||||||
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
|
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.GOV);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ParameterizedTest(name = "Vessel + {0} → DEFAULT")
|
@ParameterizedTest(name = "Vessel + {0} → FISHING")
|
||||||
@ValueSource(strings = {"pleasure craft", "sailing", "n/a"})
|
@ValueSource(strings = {"pleasure craft", "sailing", "n/a"})
|
||||||
@DisplayName("Vessel + 레저/기타 → DEFAULT")
|
@DisplayName("Vessel + 레저/기타 → FISHING")
|
||||||
void resolve_vesselLeisure(String extraInfo) {
|
void resolve_vesselLeisure(String extraInfo) {
|
||||||
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
|
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.FISHING);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -204,32 +164,4 @@ class SignalKindCodeTest {
|
|||||||
assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028");
|
assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nested
|
|
||||||
@DisplayName("shipName BUOY 판정 (resolve 3-param 통합 검증)")
|
|
||||||
class BuoyNamePattern {
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} → BUOY")
|
|
||||||
@ValueSource(strings = {"A.B.C", "BUOY_01_02", "._", "A.B_C"})
|
|
||||||
@DisplayName("2개 이상 특수문자 → BUOY")
|
|
||||||
void resolve_buoyPattern(String name) {
|
|
||||||
// vesselType과 무관하게 BUOY로 치환
|
|
||||||
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.BUOY);
|
|
||||||
}
|
|
||||||
|
|
||||||
@ParameterizedTest(name = "{0} → not BUOY")
|
|
||||||
@ValueSource(strings = {"ABC", "A.B", "A_B", "NORMAL"})
|
|
||||||
@DisplayName("1개 이하 특수문자 → shipName 무시")
|
|
||||||
void resolve_notBuoyPattern(String name) {
|
|
||||||
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.DEFAULT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
@DisplayName("null/blank shipName → vesselType 기준")
|
|
||||||
void resolve_nullBlankName() {
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, null)).isEqualTo(SignalKindCode.CARGO);
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, "")).isEqualTo(SignalKindCode.CARGO);
|
|
||||||
assertThat(SignalKindCode.resolve("Cargo", null, " ")).isEqualTo(SignalKindCode.CARGO);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user