fix: ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시

- ChnPrmShipCacheManager: isAfter → !isBefore (동일 타임스탬프도 갱신)
- ScheduleService: Quartz 트리거에서 previousFireTime 실제 조회
- README.md: 빌드/배포 가이드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-19 09:49:08 +09:00
부모 50badbe2bb
커밋 41b06beeec
5개의 변경된 파일153개의 추가작업 그리고 20개의 파일을 삭제

파일 보기

@ -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 클래스 정의

파일 보기

@ -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

105
README.md Normal file
파일 보기

@ -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 자동 설치 스킵 |

파일 보기

@ -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());
}
}

파일 보기

@ -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);
}
}