diff --git a/.claude/rules/code-style.md b/.claude/rules/code-style.md index 0cb1563..f5f0203 100644 --- a/.claude/rules/code-style.md +++ b/.claude/rules/code-style.md @@ -44,7 +44,21 @@ - `@Builder` 허용 - `@Data` 사용 금지 (명시적으로 필요한 어노테이션만) - `@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 클래스 정의 diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 93bb350..67be0a9 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -20,9 +20,10 @@ fi # Conventional Commits 정규식 # type(scope): subject # - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수) -# - scope: 영문, 숫자, 한글, 점, 밑줄, 하이픈 허용 (선택) -# - subject: 1~72자, 한/영 혼용 허용 (필수) -PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([a-zA-Z0-9가-힣._-]+\))?: .{1,72}$' +# - scope: 괄호 제외 모든 문자 허용 — 한/영/숫자/특수문자 (선택) +# - subject: 1자 이상 (길이는 바이트 기반 별도 검증) +PATTERN='^(feat|fix|docs|style|refactor|test|chore|ci|perf)(\([^)]+\))?: .+$' +MAX_SUBJECT_BYTES=200 # UTF-8 한글(3byte) 허용: 72문자 ≈ 최대 216byte FIRST_LINE=$(head -1 "$COMMIT_MSG_FILE") @@ -58,3 +59,13 @@ if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then echo "" exit 1 fi + +# 길이 검증 (바이트 기반 — UTF-8 한글 허용) +MSG_LEN=$(echo -n "$FIRST_LINE" | wc -c | tr -d ' ') +if [ "$MSG_LEN" -gt "$MAX_SUBJECT_BYTES" ]; then + echo "" + echo " ✗ 커밋 메시지가 너무 깁니다 (${MSG_LEN}바이트, 최대 ${MAX_SUBJECT_BYTES})" + echo " 현재 메시지: $FIRST_LINE" + echo "" + exit 1 +fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..5b31905 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# SNP-Batch (snp-batch-validation) + +해양 데이터 통합 배치 시스템. Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스합니다. + +## 기술 스택 + +- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0 +- PostgreSQL, Quartz Scheduler, Caffeine Cache +- React 19 + Vite + Tailwind CSS 4 (관리 UI) +- frontend-maven-plugin (프론트엔드 빌드 통합) + +## 사전 요구사항 + +| 항목 | 버전 | 비고 | +|------|------|------| +| JDK | 17 | `.sdkmanrc` 참조 (`sdk env`) | +| Maven | 3.9+ | | +| Node.js | 20+ | 프론트엔드 빌드용 | +| npm | 10+ | Node.js에 포함 | + +## 빌드 + +> **주의**: frontend-maven-plugin의 Node 호환성 문제로, 프론트엔드와 백엔드를 분리하여 빌드합니다. + +### 터미널 + +```bash +# 1. 프론트엔드 빌드 +cd frontend && npm install && npm run build && cd .. + +# 2. Maven 패키징 (프론트엔드 빌드 스킵) +mvn clean package -DskipTests -Dskip.npm -Dskip.installnodenpm +``` + +빌드 결과: `target/snp-batch-validation-1.0.0.jar` + +### VSCode + +`Cmd+Shift+B` (기본 빌드 태스크) → 프론트엔드 빌드 + Maven 패키징 순차 실행 + +개별 태스크: `Cmd+Shift+P` → "Tasks: Run Task" → 태스크 선택 + +> 태스크 설정: [.vscode/tasks.json](.vscode/tasks.json) + +### IntelliJ IDEA + +1. **프론트엔드 빌드**: Terminal 탭에서 `cd frontend && npm run build` +2. **Maven 패키징**: Maven 패널 → Lifecycle → `package` + - VM Options: `-DskipTests -Dskip.npm -Dskip.installnodenpm` + - 또는 Run Configuration → Maven → Command line에 `clean package -DskipTests -Dskip.npm -Dskip.installnodenpm` + +## 로컬 실행 + +### 터미널 + +```bash +mvn spring-boot:run -Dspring-boot.run.profiles=local +``` + +### VSCode + +Run/Debug 패널(F5) → "SNP-Batch (local)" 선택 + +> 실행 설정: [.vscode/launch.json](.vscode/launch.json) + +### IntelliJ IDEA + +Run Configuration → Spring Boot: +- Main class: `com.snp.batch.SnpBatchApplication` +- Active profiles: `local` + +## 서버 배포 + +```bash +# 1. 빌드 (위 빌드 절차 수행) + +# 2. JAR 전송 +scp target/snp-batch-validation-1.0.0.jar {서버}:{경로}/ + +# 3. 실행 +java -jar snp-batch-validation-1.0.0.jar --spring.profiles.active=dev +``` + +## 접속 정보 + +| 항목 | URL | +|------|-----| +| 관리 UI | `http://localhost:8041/snp-api/` | +| Swagger | `http://localhost:8041/snp-api/swagger-ui/index.html` | + +## 프로파일 + +| 프로파일 | 용도 | DB | +|----------|------|----| +| `local` | 로컬 개발 | 개발 DB | +| `dev` | 개발 서버 | 개발 DB | +| `prod` | 운영 서버 | 운영 DB | + +## Maven 빌드 플래그 요약 + +| 플래그 | 용도 | +|--------|------| +| `-DskipTests` | 테스트 스킵 | +| `-Dskip.npm` | npm install/build 스킵 | +| `-Dskip.installnodenpm` | Node/npm 자동 설치 스킵 | diff --git a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java index 6f2e373..52b6e3e 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/chnprmship/ChnPrmShipCacheManager.java @@ -60,7 +60,7 @@ public class ChnPrmShipCacheManager { } AisTargetEntity existing = cache.getIfPresent(item.getMmsi()); - if (existing == null || isNewer(item, existing)) { + if (existing == null || isNewerOrEqual(item, existing)) { cache.put(item.getMmsi(), item); updated++; } @@ -119,13 +119,13 @@ public class ChnPrmShipCacheManager { ); } - private boolean isNewer(AisTargetEntity candidate, AisTargetEntity existing) { + private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) { if (candidate.getMessageTimestamp() == null) { return false; } if (existing.getMessageTimestamp() == null) { return true; } - return candidate.getMessageTimestamp().isAfter(existing.getMessageTimestamp()); + return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp()); } } diff --git a/src/main/java/com/snp/batch/service/ScheduleService.java b/src/main/java/com/snp/batch/service/ScheduleService.java index d92ed5b..1a4df68 100644 --- a/src/main/java/com/snp/batch/service/ScheduleService.java +++ b/src/main/java/com/snp/batch/service/ScheduleService.java @@ -328,24 +328,27 @@ public class ScheduleService { .createdBy(entity.getCreatedBy()) .updatedBy(entity.getUpdatedBy()); - // 다음 실행 시간 계산 (Cron 표현식 기반) + // Quartz 트리거에서 실행 시간 정보 조회 if (entity.getActive() && entity.getCronExpression() != null) { try { - // Cron 표현식으로 임시 트리거 생성 (DB 조회 없이 계산) - CronTrigger tempTrigger = TriggerBuilder.newTrigger() - .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression())) - .build(); + TriggerKey triggerKey = new TriggerKey(entity.getJobName() + "-trigger", "batch-triggers"); + Trigger quartzTrigger = scheduler.getTrigger(triggerKey); - Date nextFireTime = tempTrigger.getFireTimeAfter(new Date()); - if (nextFireTime != null) { - builder.nextFireTime(nextFireTime); + if (quartzTrigger != null) { + builder.nextFireTime(quartzTrigger.getNextFireTime()); + builder.previousFireTime(quartzTrigger.getPreviousFireTime()); + builder.triggerState( + scheduler.getTriggerState(triggerKey).name()); + } else { + // 트리거 미등록 시 Cron 표현식 기반 계산 + CronTrigger tempTrigger = TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression())) + .build(); + builder.nextFireTime(tempTrigger.getFireTimeAfter(new Date())); + builder.triggerState("NONE"); } - - // Trigger 상태는 active인 경우 NORMAL로 설정 - builder.triggerState("NORMAL"); - } catch (Exception e) { - log.debug("Cron 표현식 기반 다음 실행 시간 계산 실패: {}", entity.getJobName(), e); + log.debug("Quartz 트리거 정보 조회 실패: {}", entity.getJobName(), e); } }