Compare commits

...

121 커밋

작성자 SHA1 메시지 날짜
41b06beeec fix: ChnPrmShip 캐시 갱신 조건 완화 및 스케줄 이전 실행 시간 표시
- ChnPrmShipCacheManager: isAfter → !isBefore (동일 타임스탬프도 갱신)
- ScheduleService: Quartz 트리거에서 previousFireTime 실제 조회
- README.md: 빌드/배포 가이드 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:49:08 +09:00
50badbe2bb Merge branch 'feature/dead-code-cleanup' into develop 2026-02-17 13:40:05 +09:00
01df023966 refactor: 미사용 Dead Code 정리 (8파일, ~1,200 LOC 삭제)
- common/web 미사용 프레임워크 6개 삭제: BaseController, BaseService,
  BaseServiceImpl, BaseProxyService, BaseHybridService, BaseDto
  (구현체 0개, ApiResponse만 유지)
- common/util 미사용 유틸리티 2개 삭제: SafeGetDataUtil (private 메서드 결함),
  JsonChangeDetector (미호출)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 13:40:00 +09:00
d2c39009ac Merge branch 'feature/batch-web-ui-refactor' into develop
React SPA 전환 + 10대 기능 강화 + 다크모드
2026-02-17 12:54:02 +09:00
90ffe68be3 feat: 배치 모니터링 React SPA 전환 및 10대 기능 강화
Thymeleaf → React 19 + Vite + Tailwind CSS 4 SPA 전환
- frontend-maven-plugin으로 단일 JAR 배포 유지
- 6개 페이지 lazy 로딩, 5초/30초 폴링 자동 갱신

10대 신규 기능:
- F1: 강제 종료(Abandon) - stale 실행 단건/전체 강제 종료
- F2: Job 실행 날짜 파라미터 (startDate/stopDate)
- F3: Step API 호출 정보 표시 (apiUrl, method, calls)
- F4: 실행 이력 검색 (멀티 Job 필터, 날짜 범위, 페이지네이션)
- F5: Cron 표현식 도우미 (프리셋 + 다음 5회 미리보기)
- F6: 대시보드 실패 통계 (24h/7d, 최근 실패 목록, stale 경고)
- F7: Job 상세 카드 (마지막 실행 상태/시간 + 스케줄 cron)
- F8: 실행 통계 차트 (CSS-only 30일 일별 막대그래프)
- F9: 실패 로그 뷰어 (exitCode/exitMessage 모달)
- F10: 다크모드 (data-theme + CSS 변수 + Tailwind @theme)

추가 개선:
- 실행 이력 멀티 Job 선택 (체크박스 드롭다운 + 칩)
- 스케줄 카드 편집 버튼 (폼 자동 채움 + 수정 모드)
- 검색 모드 폴링 비활성화 (1회 조회 후 수동 갱신)
- pre-commit hook: 프론트엔드 빌드 스킵 플래그 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 12:53:54 +09:00
99b59f0ed5 Merge branch 'feature/signal-kind-code' into develop 2026-02-17 10:02:22 +09:00
71d95bd6fa feat: 중국 허가선박 전용 API 및 DB Sync 동적 범위 개선
- ChnPrmShip 전용 캐시/API 구현 (GET /api/ais-target/chnprmship)
  - ChnPrmShipCacheManager: Caffeine 캐시 (TTL 2일, 최대 2000건)
  - ChnPrmShipCacheWarmer: 기동 시 DB에서 최근 2일 데이터 워밍업
  - ChnPrmShipProperties: MMSI 목록 리소스 파일 로딩 (1,402척)
  - AisTargetDataWriter: 배치 수집 시 대상 MMSI 캐시 연동
- AisTargetRepositoryImpl: int8→Integer 안전 변환 (toInt/toLong)
- AisTargetRepository: findLatestByMmsiInSince 시간 범위 필터 추가
- DbSyncTasklet: 마지막 성공 시각 기반 동적 범위 + 최대 60분 캡
- 캐시 진단용 SQL 쿼리 파일 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 11:14:18 +09:00
ada5bbaa0f chore: develop 최신 변경 병합 (Swagger, Kafka 조건부 설정) 2026-02-16 10:41:19 +09:00
ce9244ca0a feat: Swagger 문서 보강, Kafka 조건부 설정, AIS 응답 DTO 개선
- Swagger @Operation/@Schema 상세 설명 추가 (검색, 필터, 폴리곤 API)
- Kafka 조건부 활성화 (KafkaAutoConfiguration exclude + @ConditionalOnProperty)
- kafka.enabled=false일 때 Kafka 빈 미생성 (@Nullable 처리)
- AisTargetResponseDto에 classType, core20Mmsi 필드 및 @Schema 추가
- ApiResponse에 @Schema 어노테이션 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 10:40:24 +09:00
b77df66b78 feat: AIS Target signalKindCode(MDA 범례코드) 치환 로직 추가
- SignalKindCode enum: vesselType + extraInfo → MDA 범례코드 치환 규칙 구현
- AisTargetEntity에 signalKindCode 필드 추가
- AisTargetDataWriter에서 캐시 저장 전 치환 수행
- AisTargetResponseDto에 signalKindCode 필드 및 @Schema 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 09:51:15 +09:00
cfc80bbb0d feat: Gitea 팀 프로젝트 워크플로우 구조 적용
- .claude/rules/: 팀 정책, Git 워크플로우, 코드 스타일, 네이밍, 테스트 규칙
- .claude/skills/: init-project, sync-team-workflow, create-mr, fix-issue
- .claude/settings.json: deny 규칙 + hooks
- .claude/workflow-version.json: v1.2.0 적용
- .githooks/: commit-msg(grep -P→-E macOS 호환), pre-commit, post-checkout
- .editorconfig, .sdkmanrc, .mvn/settings.xml (Nexus 미러)
- .gitignore: .claude/ 팀 파일 추적 전환
- CLAUDE.md: 프로젝트 루트로 이동

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 22:00:24 +09:00
0743fd4322 chore: 불필요 스크립트 삭제
- scripts/collect_signalkind_candidates.sh 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:54:29 +09:00
82d427bda2 chore: 불필요 문서 삭제
- DEVELOPMENT_GUIDE.md (49KB) 삭제 - CLAUDE.md로 대체
- SWAGGER_GUIDE.md (16KB) 삭제 - Swagger 자동 생성으로 대체

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:53:06 +09:00
290933f94f chore: Kafka topic명 변경 및 SignalKind 수집 스크립트 추가
- tp_SNP_AIS_Signal → tp_Global_AIS_Signal (3개 프로파일)
- scripts/collect_signalkind_candidates.sh 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:52:50 +09:00
LHT
178ac506bf feat: add AIS target Kafka producer pipeline 2026-02-13 03:10:38 +09:00
07368f18cb 🔥 application.yml 설정 변경 2026-02-12 10:41:27 +09:00
a93942d4d6
🔀 용어 표준화 반영 (AIS 제외) (#6)
* 🔧 Schema/Table 하드코딩 제거

* 🔥 BatchSchemaProperties.java 제거 및 @Value Schema 설정 방식 통일

* 🗃️ 용어 표준화

- Facility Port 
- Common Code
- Risk&Compliance
- Movement 
- Event 
- PSC 
- 선박제원정보
2026-02-12 10:27:22 +09:00
f53648290c
🔀 데이터 값 검증 컬럼 추가 (#4)
* 🗃️ PSC : 값 검증 컬럼 추가

* 🗃️ Facility : 값 검증 컬럼 추가

* 🔊 Facility : API Request 로그 추가

* 🗃️ Event : 값 검증 컬럼 추가

* 🗃️ Movement : 값 검증 컬럼 추가

* 🗃️ 공통코드 : 값 검증 컬럼 추가, API 로그 서비스 추가

* 🗃️ IMO 메타 수집 : 값 검증 컬럼 추가, API 로그 서비스 추가

* 🗃️ Risk&Compliance : 값 검증 컬럼 추가

* 🗃️ 선박제원정보 : 값 검증 컬럼 추가, 해시값 비교 프로세스 제거

* 🗃️ schema change : snp_data -> t_snp_data
2026-02-05 18:49:27 +09:00
6555c5e28f Merge branch 'main' into develop 2026-01-23 15:06:58 +09:00
3cbc2d2e94 Merge branch 'dev_movements' into develop 2026-01-21 14:36:14 +09:00
a59c91ae1f Merge branch 'dev_psc' into develop 2026-01-21 14:36:07 +09:00
30304de4e6 🗃️ ship_detail_data,additionalshipsdata : datasetversion 컬럼 수집 추가 2026-01-21 14:31:56 +09:00
7a1b24e381 🗃️ Dark Activity Confirmed : area_country 컬럼 수집 추가 2026-01-21 13:30:26 +09:00
8d2cd09725 🗃️ PSC 수집 제외 컬럼 반영 2026-01-21 13:20:53 +09:00
6c4ce9a536 🗃️ Terminal Call 수집 누락 컬럼 추가 2026-01-21 11:17:42 +09:00
9fed34e1bc 🔥 Risk&Compliance Current/History 수집 방식 변경 2026-01-20 10:09:59 +09:00
21368ffaff 🐛 Insert 쿼리 오류 수정 2026-01-19 15:30:13 +09:00
7ab53d1bbf 🔥 선박제원정보의 Company Compliance 수집 제거 2026-01-19 10:49:54 +09:00
613980c496 🔥 선박제원정보의 Company Compliance 수집 제거 2026-01-19 09:43:33 +09:00
e63607a69d Company Compliance 수집 JOB 추가 2026-01-16 17:12:04 +09:00
f4421fa455 선박제원정보 요청 단위 변경 2026-01-16 14:17:06 +09:00
43057d74fb Company Detail 수집 프로세스 추가 2026-01-16 14:15:00 +09:00
64a3a55e78 batch_api_log 관리 프로세스 추가 2026-01-15 15:58:20 +09:00
f2c4e0d14f 🔇 Web Services API Log Control 2026-01-12 15:11:05 +09:00
5305f61a41 🔇 Ships API Log Control 2026-01-12 14:41:08 +09:00
c3dabd370c Merge branch 'develop' into dev_shipdetail_sync 2026-01-09 16:07:28 +09:00
9c021f298c Add Ship Detail Sync Job 2026-01-09 16:07:00 +09:00
cbb53fd9f1 🗃️ Core 캐시 대상 변경 2026-01-09 14:59:20 +09:00
49d2de1965 AIS Target DB Sync Job 분리 (캐시→DB 15분 주기)
- AisTargetDataWriter: DB 저장 제거, 캐시 업데이트만 수행
- AisTargetDbSyncJob 신규 생성: 15분 주기 캐시→DB 동기화
- AisTargetDbSyncTasklet: 캐시에서 최근 15분 데이터 조회 후 UPSERT
- application.yml: ais-target-db-sync 설정 추가

데이터 흐름 변경:
- 기존: API(1분) → 캐시 + DB (매분 33K 건 저장)
- 변경: API(1분) → 캐시만, DB는 15분마다 MMSI별 최신 1건 저장

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 14:25:27 +09:00
1ab78e881f 🔊 API Response Error Log Update 2026-01-09 13:39:18 +09:00
4e79794750 chunk & batch size change 2026-01-09 10:21:10 +09:00
abe5ea1a1c Merge branch 'dev_batchflag' into develop 2026-01-08 15:59:01 +09:00
d8b8a40316 🗃️ remove batch_flag of new_snp schema 2026-01-08 15:57:46 +09:00
b842ec8d54 🗃️ Crew List Unique Index Change 2026-01-08 15:28:03 +09:00
e1fa48768e 💥 API 조회 기간 세팅 방식 변경 및 통일 2026-01-08 15:12:06 +09:00
87a9217853 🗃️ ais_target ddl update 2026-01-07 13:18:10 +09:00
6e70e921af 🗃️ AIS Target 변경으로 인한 데이터 및 컬럼추가 2026-01-05 17:42:53 +09:00
3fb133e367 🗃️ core20 컬럼 추가 : AIS 추가 컬럼 2026-01-05 15:04:07 +09:00
31262f5dda 🔇 로그 범위 변경 2025-12-31 13:59:23 +09:00
99fcd38d24 🗃️ procedure change 2025-12-31 12:38:07 +09:00
7360736cb0 🏗️ Movement Batch Package Rearrange 2025-12-31 10:53:31 +09:00
6aba0f55b0 🗃️ Event Table Name Change
- SQL Injection Prevent
2025-12-31 10:37:20 +09:00
1d2a3c53c8 Add Compliance History Value Change Manage Step 2025-12-31 09:59:25 +09:00
020f16035b Merge branch 'develop' of https://github.com/GC-IncheonService-KDN/SNP-Batch into develop 2025-12-29 18:02:31 +09:00
94f7d4b5c0 🔨 Multi Step Job Config 추가 2025-12-29 18:02:18 +09:00
Kim JiMyeung
0a5e2e56af Batch 파라미터 request 적용 2025-12-29 15:35:18 +09:00
32af369f23 🗃️ Last Postion Update 대상 스키마 변경 2025-12-24 14:24:17 +09:00
fcf1d74c38 Risk&Compliance Range Import Update 2025-12-24 14:15:13 +09:00
5683000024 Merge branch 'dev_event' into develop 2025-12-23 14:39:43 +09:00
Kim JiMyeung
a7cf1647f8 event속성들 snp_data 적재 -> new_snp 적재 2025-12-23 14:33:53 +09:00
6d7b7c9eea Merge branch 'dev_event' into develop 2025-12-23 12:36:48 +09:00
6885d41ba5 Merge branch 'dev_shipdetail' of https://github.com/GC-IncheonService-KDN/SNP-Batch into dev_shipdetail 2025-12-23 12:35:13 +09:00
7b1fe1d52c 🗃️ Ship Data 스키마 변경 2025-12-23 12:33:10 +09:00
bff4de17c7 🗃️ chunk size change 2025-12-23 11:28:17 +09:00
bda2d812ff 🗃️ Ship Data 스키마 변경 2025-12-23 11:23:29 +09:00
Kim JiMyeung
1124c2e84a risk, compliance잡 range형태로 수정 2025-12-23 09:42:50 +09:00
Kim JiMyeung
75531ab5e5 startDate, endDate로직처리 2025-12-22 13:11:25 +09:00
4700ec862b 💩 임시커밋 2025-12-19 17:13:40 +09:00
Kim JiMyeung
e7ea47b02c Merge branch 'dev_movement_daterange' into dev_event 2025-12-19 13:59:38 +09:00
Kim JiMyeung
63e9253d7f Movement Method Range형식으로 변경 2025-12-19 13:37:35 +09:00
acd76bd358 Event Detail 적재 프로세스 개발
- StartDate, EndDate 추출작업 필요
2025-12-19 10:57:40 +09:00
270b2a0b55 ⚰️ 불필요한 주석 제거 2025-12-16 16:02:08 +09:00
084be88b98 S&P 국가코드,선박유형코드 Import Job 2025-12-16 15:56:02 +09:00
fb10e3cc39 🦖 선박제원정보 테이블 변경 core20 > ship_detail_data 2025-12-16 10:20:46 +09:00
b2167d4ec7 Event Range 세팅방식 변경
- API_KET 세팅방식 변경
2025-12-15 13:31:42 +09:00
630c366a06 Merge branch 'dev_ship_movement' into develop 2025-12-15 10:16:25 +09:00
Kim JiMyeung
e7f4a9d912 AnchorageCalls, Berthcalls, DarkActivity, StsOperations, TerminalCalls Job 개발 2025-12-15 10:09:18 +09:00
1c491de9e2 🗃️ application.xml 수정 2025-12-12 15:34:02 +09:00
Kim JiMyeung
3118df3533 Merge remote-tracking branch 'origin/develop' into dev_ship_movement 2025-12-12 14:48:49 +09:00
090f009529 ShipDetailUpdateJob 개발
- CrewList
- StowageCommodity
- GroupBeneficialOwnerHistory
- ShipManagerHistory
- OperatorHistory
- TechnicalManagerHistory
- BareBoatCharterHistory
- NameHistory
- FlagHistory
- AdditionalInformation
- PandIHistory
- CallSignAndMmsiHistory
- IceClass
- SafetyManagementCertificateHistory
- ClassHistory
- SurveyDatesHistory
- SurveyDatesHistoryUnique
- SisterShipLinks
- StatusHistory
- SpecialFeature
- Thrusters
2025-12-12 13:12:40 +09:00
Kim JiMyeung
c46a62268c reader 수정 2025-12-12 11:20:13 +09:00
Kim JiMyeung
f2970872fd mvmn_type on conflict추가 2025-12-12 11:14:10 +09:00
Kim JiMyeung
ac78a1340a Merge branch 'dev_ship_movement' of https://github.com/GC-IncheonService-KDN/SNP-Batch into dev_ship_movement 2025-12-11 16:31:18 +09:00
Kim JiMyeung
3ee6ae1bf7 pscJob 2025-12-11 16:29:28 +09:00
2a0a80098d Merge branch 'develop' into dev_ship_movement 2025-12-10 12:33:57 +09:00
eb81be5f21 🗃️ application.xml 정리 2025-12-10 10:54:44 +09:00
655318e353 🗃️ Risk&Compliance 적재방식 변경 (이력데이터 적재) 2025-12-10 10:13:09 +09:00
2e509560de Merge branch 'ais/ship_position' into develop 2025-12-10 08:54:42 +09:00
fedd89c9ca [수정]
- GPU DB core20 테이블 정보 프로파일 추가
2025-12-10 08:46:15 +09:00
3dde3d0167 [추가]
- 실시간 선박 위치 조회 API Classtype 구분 파라미터 추가 (core20 테이블 imo 유무로 ClassA, ClassB 분류)
 - html PUT,DELETE, PATCH 메소드 제거 및 POST 메소드 사용 변경 (보안이슈)
2025-12-10 08:14:28 +09:00
Kim JiMyeung
6c98ebc24f Destination, Transits, CurrentlyAt 증분Job 2025-12-08 17:47:30 +09:00
Kim JiMyeung
18ab11068a 빈 배열 처리 로직추가 2025-12-08 13:33:57 +09:00
37f61fe924 Add Port Import Job, Event Import Job 2025-12-08 13:33:37 +09:00
e9b30f8817 🗃️ JPA 스키마 지정 (snp_data) 2025-12-08 13:33:23 +09:00
Kim JiMyeung
34ce85f33f Merge remote-tracking branch 'origin/develop' into dev_ship_movement 2025-12-08 13:17:06 +09:00
Kim JiMyeung
919b0fc21a AnchorageCalls, Berthcalls, DarkActivity, StsOperations, TerminalCalls 증분Job 2025-12-08 13:00:08 +09:00
Kim JiMyeung
7941396d62 ais/ship_position into dev_ship_movement 2025-12-05 11:00:28 +09:00
Kim JiMyeung
248e9c2c46 /snp-asi url추가 2025-12-05 10:17:08 +09:00
Kim JiMyeung
2671d613f3 merge devlop into dev_ship_movement 2025-12-05 09:44:20 +09:00
1b7fa47dbd Merge branch 'ais/ship_position' into develop 2025-12-05 09:33:59 +09:00
8d8ea53449 [추가]
- 프로세스 재기동 등으로 정상 종료되지 않은 Job 정리용 임시 sql 추가
2025-12-05 08:31:11 +09:00
322ecb12a6 [수정]
- url 하드코딩 제거
- bootstrap 로컬 저장, 참조수정
2025-12-04 15:38:01 +09:00
55d4dd5886 [수정]
- 파티션 관리 job 추가 (+3일 미리 생성, 14일 이전 파티션 자동drop 설정)
- (임시) GPU 운영 포트 9000번 변경
- ais_target 테이블 일일 파티션구조로 변경 (1일 데이터 약 20GB)
2025-12-04 13:05:00 +09:00
c842e982c8 Merge branch 'dev_ship_movement' into dev_ship_detail
# Conflicts:
#	src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java
2025-12-02 19:11:29 +09:00
44ae82e2fa Merge branch 'ais/ship_position' into dev_ship_detail
# Conflicts:
#	src/main/java/com/snp/batch/jobs/sanction/batch/reader/ComplianceDataReader.java
#	src/main/resources/application.yml
2025-12-02 19:10:15 +09:00
d6cf58d737 Add Port Import Job, Event Import Job 2025-12-02 18:26:54 +09:00
5857a4a822 [수정]
- 항해 조건 필터 검색 API (SOG/COG/Heading/Destination/Status)
- Swagger Status 필터 현행화
Under way sailing
N/A
AIS Sart
Restriced manoeuverability
Not under command
Engaged in fishing
Under way using engine
Anchored
Constrained by draught
Aground
Power Driven Towing Alongside
Power Driven Towing Astern
Moored
2025-12-02 16:44:14 +09:00
6af2fccbf0 [신규 기능]
- aisTargetImportJob: S&P Global AIS API 연동 (매 분 15초)
- AIS Target 조회 API (MMSI/시간/공간/폴리곤/WKT 검색)
- 항해 조건 필터 검색 API (SOG/COG/Heading/Destination/Status)
- Caffeine 캐시 적용 (TTL 120분, 최대 30만건)
- partitionManagerJob: 매일 1회 일별,월별 파티션 자동 생성

[개선]
- API context-path: /snp-api로 변경 (다른 API 서비스의 Proxy 설정 충돌 방지)
- BaseApiReader 상태 초기화 로직 추가 (재실행 시 0건 버그 수정)
- logback-spring.xml: 로그 파일 분리 및 롤링 정책 적용
2025-12-02 16:24:57 +09:00
Kim JiMyeung
c99b6993a7 빈 배열 처리 로직추가 2025-12-02 12:53:17 +09:00
b3cb4f6f19 🗃️ JPA 스키마 지정 (snp_data) 2025-12-02 12:26:49 +09:00
4282fc9106 🗃️ Risk&Compliance batch_flag 추가 2025-11-28 18:21:21 +09:00
8a3e9a973e 🗃️ Risk&Compliance 인덱스 변경 반영 2025-11-28 10:46:44 +09:00
68893f9657 🛂 운영서버 요청 URL 변경 2025-11-28 10:43:10 +09:00
5787fb5be0 Merge branch 'dev_ship_movement' into dev_ship_detail 2025-11-27 22:20:34 +09:00
4ed1070a37 Merge branch 'dev_ship_movement' into dev_ship_detail
# Conflicts:
#	src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java
2025-11-27 22:20:21 +09:00
f9b20bdc59 🗃️ 운영접속주소 수정 2025-11-27 22:03:09 +09:00
7a405bb969 swagger 운영 주소 추가 2025-11-27 22:00:26 +09:00
906611c9b8 Risk&Compliance Data Import Job 개발 2025-11-27 21:55:46 +09:00
Kim JiMyeung
e44637e1f3 movement 배치 2025-11-27 16:20:05 +09:00
6be90723b4 Core20 테이블 AIS 컬럼 추가 (COG, NavStat) 2025-11-25 18:39:30 +09:00
18fa95e903 🩹 OwnerHistory DataSetVersion 하드코딩 제거 2025-11-24 10:43:43 +09:00
259개의 변경된 파일15837개의 추가작업 그리고 13848개의 파일을 삭제

파일 보기

@ -0,0 +1,73 @@
# Java 코드 스타일 규칙
## 일반
- Java 17+ 문법 사용 (record, sealed class, pattern matching, text block 활용)
- 들여쓰기: 4 spaces (탭 사용 금지)
- 줄 길이: 120자 이하
- 파일 끝에 빈 줄 추가
## 클래스 구조
클래스 내 멤버 순서:
1. static 상수 (public → private)
2. 인스턴스 필드 (public → private)
3. 생성자
4. public 메서드
5. protected/package-private 메서드
6. private 메서드
7. inner class/enum
## Spring Boot 규칙
### 계층 구조
- Controller → Service → Repository 단방향 의존
- Controller에 비즈니스 로직 금지 (요청/응답 변환만)
- Service 계층 간 순환 참조 금지
- Repository에 비즈니스 로직 금지
### DTO와 Entity 분리
- API 요청/응답에 Entity 직접 사용 금지
- DTO는 record 또는 불변 클래스로 작성
- DTO ↔ Entity 변환은 매퍼 클래스 또는 팩토리 메서드 사용
### 의존성 주입
- 생성자 주입 사용 (필드 주입 `@Autowired` 사용 금지)
- 단일 생성자는 `@Autowired` 어노테이션 생략
- Lombok `@RequiredArgsConstructor` 사용 가능
### 트랜잭션
- `@Transactional` 범위 최소화
- 읽기 전용: `@Transactional(readOnly = true)`
- Service 메서드 레벨에 적용 (클래스 레벨 지양)
## Lombok 규칙
- `@Getter`, `@Setter` 허용 (Entity에서 Setter는 지양)
- `@Builder` 허용
- `@Data` 사용 금지 (명시적으로 필요한 어노테이션만)
- `@AllArgsConstructor` 단독 사용 금지 (`@Builder`와 함께 사용)
## 로깅
- `@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 클래스 정의
- `@ControllerAdvice`로 전역 예외 처리
- 예외 메시지에 컨텍스트 정보 포함
- catch 블록에서 예외 무시 금지 (`// ignore` 금지)
## 기타
- `Optional`은 반환 타입으로만 사용 (필드, 파라미터에 사용 금지)
- `null` 반환보다 빈 컬렉션 또는 `Optional` 반환
- Stream API 활용 (단, 3단계 이상 체이닝은 메서드 추출)
- 하드코딩된 문자열/숫자 금지 → 상수 또는 설정값으로 추출

파일 보기

@ -0,0 +1,84 @@
# Git 워크플로우 규칙
## 브랜치 전략
### 브랜치 구조
```
main ← 배포 가능한 안정 브랜치 (보호됨)
└── develop ← 개발 통합 브랜치
├── feature/ISSUE-123-기능설명
├── bugfix/ISSUE-456-버그설명
└── hotfix/ISSUE-789-긴급수정
```
### 브랜치 네이밍
- feature 브랜치: `feature/ISSUE-번호-간단설명` (예: `feature/ISSUE-42-user-login`)
- bugfix 브랜치: `bugfix/ISSUE-번호-간단설명`
- hotfix 브랜치: `hotfix/ISSUE-번호-간단설명`
- 이슈 번호가 없는 경우: `feature/간단설명` (예: `feature/add-swagger-docs`)
### 브랜치 규칙
- main, develop 브랜치에 직접 커밋/푸시 금지
- feature 브랜치는 develop에서 분기
- hotfix 브랜치는 main에서 분기
- 머지는 반드시 MR(Merge Request)을 통해 수행
## 커밋 메시지 규칙
### Conventional Commits 형식
```
type(scope): subject
body (선택)
footer (선택)
```
### type (필수)
| type | 설명 |
|------|------|
| feat | 새로운 기능 추가 |
| fix | 버그 수정 |
| docs | 문서 변경 |
| style | 코드 포맷팅 (기능 변경 없음) |
| refactor | 리팩토링 (기능 변경 없음) |
| test | 테스트 추가/수정 |
| chore | 빌드, 설정 변경 |
| ci | CI/CD 설정 변경 |
| perf | 성능 개선 |
### scope (선택)
- 변경 범위를 나타내는 짧은 단어
- 한국어, 영어 모두 허용 (예: `feat(인증): 로그인 기능`, `fix(auth): token refresh`)
### subject (필수)
- 변경 내용을 간결하게 설명
- 한국어, 영어 모두 허용
- 72자 이내
- 마침표(.) 없이 끝냄
### 예시
```
feat(auth): JWT 기반 로그인 구현
fix(배치): 야간 배치 타임아웃 수정
docs: README에 빌드 방법 추가
refactor(user-service): 중복 로직 추출
test(결제): 환불 로직 단위 테스트 추가
chore: Gradle 의존성 버전 업데이트
```
## MR(Merge Request) 규칙
### MR 생성
- 제목: 커밋 메시지와 동일한 Conventional Commits 형식
- 본문: 변경 내용 요약, 테스트 방법, 관련 이슈 번호
- 라벨: 적절한 라벨 부착 (feature, bugfix, hotfix 등)
### MR 리뷰
- 최소 1명의 리뷰어 승인 필수
- CI 검증 통과 필수 (설정된 경우)
- 리뷰 코멘트 모두 해결 후 머지
### MR 머지
- Squash Merge 권장 (깔끔한 히스토리)
- 머지 후 소스 브랜치 삭제

60
.claude/rules/naming.md Normal file
파일 보기

@ -0,0 +1,60 @@
# Java 네이밍 규칙
## 패키지
- 모두 소문자, 단수형
- 도메인 역순: `com.gcsc.프로젝트명.모듈`
- 예: `com.gcsc.batch.scheduler`, `com.gcsc.api.auth`
## 클래스
- PascalCase
- 명사 또는 명사구
- 접미사로 역할 표시:
| 계층 | 접미사 | 예시 |
|------|--------|------|
| Controller | `Controller` | `UserController` |
| Service | `Service` | `UserService` |
| Service 구현 | `ServiceImpl` | `UserServiceImpl` (인터페이스 있을 때만) |
| Repository | `Repository` | `UserRepository` |
| Entity | (없음) | `User`, `ShipRoute` |
| DTO 요청 | `Request` | `CreateUserRequest` |
| DTO 응답 | `Response` | `UserResponse` |
| 설정 | `Config` | `SecurityConfig` |
| 예외 | `Exception` | `UserNotFoundException` |
| Enum | (없음) | `UserStatus`, `ShipType` |
| Mapper | `Mapper` | `UserMapper` |
## 메서드
- camelCase
- 동사로 시작
- CRUD 패턴:
| 작업 | Controller | Service | Repository |
|------|-----------|---------|------------|
| 조회(단건) | `getUser()` | `getUser()` | `findById()` |
| 조회(목록) | `getUsers()` | `getUsers()` | `findAll()` |
| 생성 | `createUser()` | `createUser()` | `save()` |
| 수정 | `updateUser()` | `updateUser()` | `save()` |
| 삭제 | `deleteUser()` | `deleteUser()` | `deleteById()` |
| 존재확인 | - | `existsUser()` | `existsById()` |
## 변수
- camelCase
- 의미 있는 이름 (단일 문자 변수 금지, 루프 인덱스 `i, j, k` 예외)
- boolean: `is`, `has`, `can`, `should` 접두사
- 예: `isActive`, `hasPermission`, `canDelete`
## 상수
- UPPER_SNAKE_CASE
- 예: `MAX_RETRY_COUNT`, `DEFAULT_PAGE_SIZE`
## 테스트
- 클래스: `{대상클래스}Test` (예: `UserServiceTest`)
- 메서드: `{메서드명}_{시나리오}_{기대결과}` 또는 한국어 `@DisplayName`
- 예: `createUser_withDuplicateEmail_throwsException()`
- 예: `@DisplayName("중복 이메일로 생성 시 예외 발생")`
## 파일/디렉토리
- Java 파일: PascalCase (클래스명과 동일)
- 리소스 파일: kebab-case (예: `application-local.yml`)
- SQL 파일: `V{번호}__{설명}.sql` (Flyway) 또는 kebab-case

파일 보기

@ -0,0 +1,34 @@
# 팀 정책 (Team Policy)
이 규칙은 조직 전체에 적용되는 필수 정책입니다.
프로젝트별 `.claude/rules/`에 추가 규칙을 정의할 수 있으나, 이 정책을 위반할 수 없습니다.
## 보안 정책
### 금지 행위
- `.env`, `.env.*`, `secrets/` 파일 읽기 및 내용 출력 금지
- 비밀번호, API 키, 토큰 등 민감 정보를 코드에 하드코딩 금지
- `git push --force`, `git reset --hard`, `git clean -fd` 실행 금지
- `rm -rf /`, `rm -rf ~`, `rm -rf .git` 등 파괴적 명령 실행 금지
- main/develop 브랜치에 직접 push 금지 (MR을 통해서만 머지)
### 인증 정보 관리
- 환경변수 또는 외부 설정 파일(`.env`, `application-local.yml`)로 관리
- 설정 파일은 `.gitignore`에 반드시 포함
- 예시 파일(`.env.example`, `application.yml.example`)만 커밋
## 코드 품질 정책
### 필수 검증
- 커밋 전 빌드(컴파일) 성공 확인
- 린트 경고 0개 유지 (CI에서도 검증)
- 테스트 코드가 있는 프로젝트는 테스트 통과 필수
### 코드 리뷰
- main 브랜치 머지 시 최소 1명 리뷰 필수
- 리뷰어 승인 없이 머지 불가
## 문서화 정책
- 공개 API(controller endpoint)에는 반드시 설명 주석 작성
- 복잡한 비즈니스 로직에는 의도를 설명하는 주석 작성
- README.md에 프로젝트 빌드/실행 방법 유지

62
.claude/rules/testing.md Normal file
파일 보기

@ -0,0 +1,62 @@
# Java 테스트 규칙
## 테스트 프레임워크
- JUnit 5 + AssertJ 조합
- Mockito로 의존성 모킹
- Spring Boot Test (`@SpringBootTest`) 는 통합 테스트에만 사용
## 테스트 구조
### 단위 테스트 (Unit Test)
- Service, Util, Domain 로직 테스트
- Spring 컨텍스트 로딩 없이 (`@ExtendWith(MockitoExtension.class)`)
- 외부 의존성은 Mockito로 모킹
```java
@ExtendWith(MockitoExtension.class)
class UserServiceTest {
@InjectMocks
private UserService userService;
@Mock
private UserRepository userRepository;
@Test
@DisplayName("사용자 생성 시 정상 저장")
void createUser_withValidInput_savesUser() {
// given
// when
// then
}
}
```
### 통합 테스트 (Integration Test)
- Controller 테스트: `@WebMvcTest` + `MockMvc`
- Repository 테스트: `@DataJpaTest`
- 전체 플로우: `@SpringBootTest` (최소화)
### 테스트 패턴
- **Given-When-Then** 구조 사용
- 각 섹션을 주석으로 구분
- 하나의 테스트에 하나의 검증 원칙 (가능한 범위에서)
## 테스트 네이밍
- 메서드명: `{메서드}_{시나리오}_{기대결과}` 패턴
- `@DisplayName`: 한국어로 테스트 의도 설명
## 테스트 커버리지
- 새로 작성하는 Service 클래스: 핵심 비즈니스 로직 테스트 필수
- 기존 코드 수정 시: 수정된 로직에 대한 테스트 추가 권장
- Controller: 주요 API endpoint 통합 테스트 권장
## 테스트 데이터
- 테스트 데이터는 테스트 메서드 내부 또는 `@BeforeEach`에서 생성
- 공통 테스트 데이터는 TestFixture 클래스로 분리
- 실제 DB 연결 필요 시 H2 인메모리 또는 Testcontainers 사용
## 금지 사항
- `@SpringBootTest`를 단위 테스트에 사용 금지
- 테스트 간 상태 공유 금지
- `Thread.sleep()` 사용 금지 → `Awaitility` 사용
- 실제 외부 API 호출 금지 → WireMock 또는 Mockito 사용

78
.claude/settings.json Normal file
파일 보기

@ -0,0 +1,78 @@
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"Bash(./mvnw *)",
"Bash(mvn *)",
"Bash(java -version)",
"Bash(git status)",
"Bash(git diff*)",
"Bash(git log*)",
"Bash(git branch*)",
"Bash(git checkout*)",
"Bash(git add*)",
"Bash(git commit*)",
"Bash(git pull*)",
"Bash(git fetch*)",
"Bash(git merge*)",
"Bash(git stash*)",
"Bash(git remote*)",
"Bash(git config*)",
"Bash(git rev-parse*)",
"Bash(git show*)",
"Bash(git tag*)",
"Bash(curl -s *)",
"Bash(sdk *)"
],
"deny": [
"Bash(git push --force*)",
"Bash(git reset --hard*)",
"Bash(git clean -fd*)",
"Bash(git checkout -- .)",
"Bash(rm -rf /)",
"Bash(rm -rf ~)",
"Bash(rm -rf .git*)",
"Bash(rm -rf /*)",
"Read(./**/.env*)",
"Read(./**/secrets/**)",
"Read(./**/application-local.yml)"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
}
}

파일 보기

@ -0,0 +1,65 @@
---
name: create-mr
description: 현재 브랜치에서 Gitea MR(Merge Request)을 생성합니다
allowed-tools: "Bash, Read, Grep"
argument-hint: "[target-branch: develop|main] (기본: develop)"
---
현재 브랜치의 변경 사항을 기반으로 Gitea에 MR을 생성합니다.
타겟 브랜치: $ARGUMENTS (기본: develop)
## 수행 단계
### 1. 사전 검증
- 현재 브랜치가 main/develop이 아닌지 확인
- 커밋되지 않은 변경 사항 확인 (있으면 경고)
- 리모트에 현재 브랜치가 push되어 있는지 확인 (안 되어 있으면 push)
### 2. 변경 내역 분석
```bash
git log develop..HEAD --oneline
git diff develop..HEAD --stat
```
- 커밋 목록과 변경된 파일 목록 수집
- 주요 변경 사항 요약 작성
### 3. MR 정보 구성
- **제목**: 브랜치의 첫 커밋 메시지 또는 브랜치명에서 추출
- `feature/ISSUE-42-user-login``feat: ISSUE-42 user-login`
- **본문**:
```markdown
## 변경 사항
- (커밋 기반 자동 생성)
## 관련 이슈
- closes #이슈번호 (브랜치명에서 추출)
## 테스트
- [ ] 빌드 성공 확인
- [ ] 기존 테스트 통과
```
### 4. Gitea API로 MR 생성
```bash
# Gitea remote URL에서 owner/repo 추출
REMOTE_URL=$(git remote get-url origin)
# Gitea API 호출
curl -X POST "GITEA_URL/api/v1/repos/{owner}/{repo}/pulls" \
-H "Authorization: token ${GITEA_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"title": "MR 제목",
"body": "MR 본문",
"head": "현재브랜치",
"base": "타겟브랜치"
}'
```
### 5. 결과 출력
- MR URL 출력
- 리뷰어 지정 안내
- 다음 단계: 리뷰 대기 → 승인 → 머지
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰 (없으면 안내)

파일 보기

@ -0,0 +1,49 @@
---
name: fix-issue
description: Gitea 이슈를 분석하고 수정 브랜치를 생성합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "<issue-number>"
---
Gitea 이슈 #$ARGUMENTS 를 분석하고 수정 작업을 시작합니다.
## 수행 단계
### 1. 이슈 조회
```bash
curl -s "GITEA_URL/api/v1/repos/{owner}/{repo}/issues/$ARGUMENTS" \
-H "Authorization: token ${GITEA_TOKEN}"
```
- 이슈 제목, 본문, 라벨, 담당자 정보 확인
- 이슈 내용을 사용자에게 요약하여 보여줌
### 2. 브랜치 생성
이슈 라벨에 따라 브랜치 타입 결정:
- `bug` 라벨 → `bugfix/ISSUE-번호-설명`
- 그 외 → `feature/ISSUE-번호-설명`
- 긴급 → `hotfix/ISSUE-번호-설명`
```bash
git checkout develop
git pull origin develop
git checkout -b {type}/ISSUE-{number}-{slug}
```
### 3. 이슈 분석
이슈 내용을 바탕으로:
- 관련 파일 탐색 (Grep, Glob 활용)
- 영향 범위 파악
- 수정 방향 제안
### 4. 수정 계획 제시
사용자에게 수정 계획을 보여주고 승인을 받은 후 작업 진행:
- 수정할 파일 목록
- 변경 내용 요약
- 예상 영향
### 5. 작업 완료 후
- 변경 사항 요약
- `/create-mr` 실행 안내
## 필요 환경변수
- `GITEA_TOKEN`: Gitea API 접근 토큰

파일 보기

@ -0,0 +1,246 @@
---
name: init-project
description: 팀 표준 워크플로우로 프로젝트를 초기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
argument-hint: "[project-type: java-maven|java-gradle|react-ts|auto]"
---
팀 표준 워크플로우에 따라 프로젝트를 초기화합니다.
프로젝트 타입: $ARGUMENTS (기본: auto — 자동 감지)
## 프로젝트 타입 자동 감지
$ARGUMENTS가 "auto"이거나 비어있으면 다음 순서로 감지:
1. `pom.xml` 존재 → **java-maven**
2. `build.gradle` 또는 `build.gradle.kts` 존재 → **java-gradle**
3. `package.json` + `tsconfig.json` 존재 → **react-ts**
4. 감지 실패 → 사용자에게 타입 선택 요청
## 수행 단계
### 1. 프로젝트 분석
- 빌드 파일, 설정 파일, 디렉토리 구조 파악
- 사용 중인 프레임워크, 라이브러리 감지
- 기존 `.claude/` 디렉토리 존재 여부 확인
- eslint, prettier, checkstyle, spotless 등 lint 도구 설치 여부 확인
### 2. CLAUDE.md 생성
프로젝트 루트에 CLAUDE.md를 생성하고 다음 내용 포함:
- 프로젝트 개요 (이름, 타입, 주요 기술 스택)
- 빌드/실행 명령어 (감지된 빌드 도구 기반)
- 테스트 실행 명령어
- lint 실행 명령어 (감지된 도구 기반)
- 프로젝트 디렉토리 구조 요약
- 팀 컨벤션 참조 (`.claude/rules/` 안내)
### Gitea 파일 다운로드 URL 패턴
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
```bash
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
# common 파일: ${GITEA_URL}/gc/template-common/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"
```
### 3. .claude/ 디렉토리 구성
이미 팀 표준 파일이 존재하면 건너뜀. 없는 경우 위의 URL 패턴으로 Gitea에서 다운로드:
- `.claude/settings.json` — 프로젝트 타입별 표준 권한 설정 + hooks 섹션 (4단계 참조)
- `.claude/rules/` — 팀 규칙 파일 (team-policy, git-workflow, code-style, naming, testing)
- `.claude/skills/` — 팀 스킬 (create-mr, fix-issue, sync-team-workflow, init-project)
### 4. Hook 스크립트 생성
`.claude/scripts/` 디렉토리를 생성하고 다음 스크립트 파일 생성 (chmod +x):
- `.claude/scripts/on-pre-compact.sh`:
```bash
#!/bin/bash
# PreCompact hook: systemMessage만 지원 (hookSpecificOutput 사용 불가)
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
```
- `.claude/scripts/on-commit.sh`:
```bash
#!/bin/bash
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | python3 -c "import sys,json;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
if echo "$COMMAND" | grep -qE 'git commit'; then
cat <<RESP
{
"hookSpecificOutput": {
"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
```
`.claude/settings.json`에 hooks 섹션이 없으면 추가 (기존 settings.json의 내용에 병합):
```json
{
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
}
}
```
### 5. Git Hooks 설정
```bash
git config core.hooksPath .githooks
```
`.githooks/` 디렉토리에 실행 권한 부여:
```bash
chmod +x .githooks/*
```
### 6. 프로젝트 타입별 추가 설정
#### java-maven
- `.sdkmanrc` 생성 (java=17.0.18-amzn 또는 프로젝트에 맞는 버전)
- `.mvn/settings.xml` Nexus 미러 설정 확인
- `mvn compile` 빌드 성공 확인
#### java-gradle
- `.sdkmanrc` 생성
- `gradle.properties.example` Nexus 설정 확인
- `./gradlew compileJava` 빌드 성공 확인
#### react-ts
- `.node-version` 생성 (프로젝트에 맞는 Node 버전)
- `.npmrc` Nexus 레지스트리 설정 확인
- `npm install && npm run build` 성공 확인
### 7. .gitignore 확인
다음 항목이 .gitignore에 포함되어 있는지 확인하고, 없으면 추가:
```
.claude/settings.local.json
.claude/CLAUDE.local.md
.env
.env.*
*.local
```
### 8. Git exclude 설정
`.git/info/exclude` 파일을 읽고, 기존 내용을 보존하면서 하단에 추가:
```gitignore
# Claude Code 워크플로우 (로컬 전용)
docs/CHANGELOG.md
*.tmp
```
### 9. Memory 초기화
프로젝트 memory 디렉토리의 위치를 확인하고 (보통 `~/.claude/projects/<project-hash>/memory/`) 다음 파일들을 생성:
- `memory/MEMORY.md` — 프로젝트 분석 결과 기반 핵심 요약 (200줄 이내)
- 현재 상태, 프로젝트 개요, 기술 스택, 주요 패키지 구조, 상세 참조 링크
- `memory/project-snapshot.md` — 디렉토리 구조, 패키지 구성, 주요 의존성, API 엔드포인트
- `memory/project-history.md` — "초기 팀 워크플로우 구성" 항목으로 시작
- `memory/api-types.md` — 주요 인터페이스/DTO/Entity 타입 요약
- `memory/decisions.md` — 빈 템플릿 (# 의사결정 기록)
- `memory/debugging.md` — 빈 템플릿 (# 디버깅 경험 & 패턴)
### 10. Lint 도구 확인
- TypeScript: eslint, prettier 설치 여부 확인. 미설치 시 사용자에게 설치 제안
- Java: checkstyle, spotless 등 설정 확인
- CLAUDE.md에 lint 실행 명령어가 이미 기록되었는지 확인
### 11. workflow-version.json 생성
Gitea API로 최신 팀 워크플로우 버전을 조회:
```bash
curl -sf --max-time 5 "https://gitea.gc-si.dev/gc/template-common/raw/branch/develop/workflow-version.json"
```
조회 성공 시 해당 `version` 값 사용, 실패 시 "1.0.0" 기본값 사용.
`.claude/workflow-version.json` 파일 생성:
```json
{
"applied_global_version": "<조회된 버전>",
"applied_date": "<현재날짜>",
"project_type": "<감지된타입>",
"gitea_url": "https://gitea.gc-si.dev"
}
```
### 12. 검증 및 요약
- 생성/수정된 파일 목록 출력
- `git config core.hooksPath` 확인
- 빌드 명령 실행 가능 확인
- Hook 스크립트 실행 권한 확인
- 다음 단계 안내:
- 개발 시작, 첫 커밋 방법
- 범용 스킬: `/api-registry`, `/changelog`, `/swagger-spec`

파일 보기

@ -0,0 +1,98 @@
---
name: sync-team-workflow
description: 팀 글로벌 워크플로우를 현재 프로젝트에 동기화합니다
allowed-tools: "Bash, Read, Write, Edit, Glob, Grep"
---
팀 글로벌 워크플로우의 최신 버전을 현재 프로젝트에 적용합니다.
## 수행 절차
### 1. 글로벌 버전 조회
Gitea API로 template-common 리포의 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 || echo "https://gitea.gc-si.dev")
curl -sf "${GITEA_URL}/gc/template-common/raw/branch/develop/workflow-version.json"
```
### 2. 버전 비교
로컬 `.claude/workflow-version.json``applied_global_version` 필드와 비교:
- 버전 일치 → "최신 버전입니다" 안내 후 종료
- 버전 불일치 → 미적용 변경 항목 추출하여 표시
### 3. 프로젝트 타입 감지
자동 감지 순서:
1. `.claude/workflow-version.json``project_type` 필드 확인
2. 없으면: `pom.xml` → java-maven, `build.gradle` → java-gradle, `package.json` → react-ts
### Gitea 파일 다운로드 URL 패턴
⚠️ Gitea raw 파일은 반드시 **web raw URL**을 사용해야 합니다 (`/api/v1/` 경로 사용 불가):
```bash
GITEA_URL="${GITEA_URL:-https://gitea.gc-si.dev}"
# common 파일: ${GITEA_URL}/gc/template-common/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"
```
### 4. 파일 다운로드 및 적용
위의 URL 패턴으로 해당 타입 + common 템플릿 파일 다운로드:
#### 4-1. 규칙 파일 (덮어쓰기)
팀 규칙은 로컬 수정 불가 — 항상 글로벌 최신으로 교체:
```
.claude/rules/team-policy.md
.claude/rules/git-workflow.md
.claude/rules/code-style.md (타입별)
.claude/rules/naming.md (타입별)
.claude/rules/testing.md (타입별)
```
#### 4-2. settings.json (부분 갱신)
- `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
```
#### 4-4. Git Hooks (덮어쓰기 + 실행 권한)
```bash
chmod +x .githooks/*
```
#### 4-5. Hook 스크립트 갱신
init-project SKILL.md의 코드 블록에서 최신 스크립트를 추출하여 덮어쓰기:
```
.claude/scripts/on-pre-compact.sh
.claude/scripts/on-post-compact.sh
.claude/scripts/on-commit.sh
```
실행 권한 부여: `chmod +x .claude/scripts/*.sh`
### 5. 로컬 버전 업데이트
`.claude/workflow-version.json` 갱신:
```json
{
"applied_global_version": "새버전",
"applied_date": "오늘날짜",
"project_type": "감지된타입",
"gitea_url": "https://gitea.gc-si.dev"
}
```
### 6. 변경 보고
- `git diff`로 변경 내역 확인
- 업데이트된 파일 목록 출력
- 변경 로그(글로벌 workflow-version.json의 changes) 표시
- 필요한 추가 조치 안내 (빌드 확인, 의존성 업데이트 등)

파일 보기

@ -0,0 +1,6 @@
{
"applied_global_version": "1.2.0",
"applied_date": "2026-02-14",
"project_type": "java-maven",
"gitea_url": "https://gitea.gc-si.dev"
}

33
.editorconfig Normal file
파일 보기

@ -0,0 +1,33 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.{java,kt}]
indent_style = space
indent_size = 4
[*.{js,jsx,ts,tsx,json,yml,yaml,css,scss,html}]
indent_style = space
indent_size = 2
[*.md]
trim_trailing_whitespace = false
[*.{sh,bash}]
indent_style = space
indent_size = 4
[Makefile]
indent_style = tab
[*.{gradle,groovy}]
indent_style = space
indent_size = 4
[*.xml]
indent_style = space
indent_size = 4

71
.githooks/commit-msg Executable file
파일 보기

@ -0,0 +1,71 @@
#!/bin/bash
#==============================================================================
# commit-msg hook
# Conventional Commits 형식 검증 (한/영 혼용 지원)
#==============================================================================
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Merge 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
exit 0
fi
# Revert 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Revert "; then
exit 0
fi
# Conventional Commits 정규식
# type(scope): subject
# - type: feat|fix|docs|style|refactor|test|chore|ci|perf (필수)
# - 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")
if ! echo "$FIRST_LINE" | grep -qE "$PATTERN"; then
echo ""
echo "╔══════════════════════════════════════════════════════════════╗"
echo "║ 커밋 메시지가 Conventional Commits 형식에 맞지 않습니다 ║"
echo "╚══════════════════════════════════════════════════════════════╝"
echo ""
echo " 올바른 형식: type(scope): subject"
echo ""
echo " type (필수):"
echo " feat — 새로운 기능"
echo " fix — 버그 수정"
echo " docs — 문서 변경"
echo " style — 코드 포맷팅"
echo " refactor — 리팩토링"
echo " test — 테스트"
echo " chore — 빌드/설정 변경"
echo " ci — CI/CD 변경"
echo " perf — 성능 개선"
echo ""
echo " scope (선택): 한/영 모두 가능"
echo " subject (필수): 1~72자, 한/영 모두 가능"
echo ""
echo " 예시:"
echo " feat(auth): JWT 기반 로그인 구현"
echo " fix(배치): 야간 배치 타임아웃 수정"
echo " docs: README 업데이트"
echo " chore: Gradle 의존성 업데이트"
echo ""
echo " 현재 메시지: $FIRST_LINE"
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

25
.githooks/post-checkout Executable file
파일 보기

@ -0,0 +1,25 @@
#!/bin/bash
#==============================================================================
# post-checkout hook
# 브랜치 체크아웃 시 core.hooksPath 자동 설정
# clone/checkout 후 .githooks 디렉토리가 있으면 자동으로 hooksPath 설정
#==============================================================================
# post-checkout 파라미터: prev_HEAD, new_HEAD, branch_flag
# branch_flag=1: 브랜치 체크아웃, 0: 파일 체크아웃
BRANCH_FLAG="$3"
# 파일 체크아웃은 건너뜀
if [ "$BRANCH_FLAG" = "0" ]; then
exit 0
fi
# .githooks 디렉토리 존재 확인
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null)
if [ -d "${REPO_ROOT}/.githooks" ]; then
CURRENT_HOOKS_PATH=$(git config core.hooksPath 2>/dev/null || echo "")
if [ "$CURRENT_HOOKS_PATH" != ".githooks" ]; then
git config core.hooksPath .githooks
chmod +x "${REPO_ROOT}/.githooks/"* 2>/dev/null
fi
fi

33
.githooks/pre-commit Executable file
파일 보기

@ -0,0 +1,33 @@
#!/bin/bash
#==============================================================================
# pre-commit hook (Java Maven)
# Maven 컴파일 검증 — 컴파일 실패 시 커밋 차단
#==============================================================================
echo "pre-commit: Maven 컴파일 검증 중..."
# Maven Wrapper 사용 (없으면 mvn 사용)
if [ -f "./mvnw" ]; then
MVN="./mvnw"
elif command -v mvn &>/dev/null; then
MVN="mvn"
else
echo "경고: Maven이 설치되지 않았습니다. 컴파일 검증을 건너뜁니다."
exit 0
fi
# 컴파일 검증 (테스트 제외, 프론트엔드 빌드 제외)
$MVN compile -q -DskipTests -Dskip.npm -Dskip.installnodenpm 2>&1
RESULT=$?
if [ $RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ 컴파일 실패! 커밋이 차단되었습니다. ║"
echo "║ 컴파일 오류를 수정한 후 다시 커밋해주세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
echo "pre-commit: 컴파일 성공"

16
.gitignore vendored
파일 보기

@ -93,13 +93,15 @@ application-local.yml
# Logs
logs/
docs/
*.log.*
# Session continuity files (for AI assistants)
.claude/
CLAUDE.md
BASEREADER_ENHANCEMENT_PLAN.md
README.md
# Frontend (Vite + React)
frontend/node_modules/
frontend/node/
src/main/resources/static/assets/
src/main/resources/static/index.html
src/main/resources/static/vite.svg
nul
# Claude Code (개인 파일만 무시, 팀 파일은 추적)
.claude/settings.local.json
.claude/scripts/

22
.mvn/settings.xml Normal file
파일 보기

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.2.0
https://maven.apache.org/xsd/settings-1.2.0.xsd">
<servers>
<server>
<id>nexus</id>
<username>admin</username>
<password>Gcsc!8932</password>
</server>
</servers>
<mirrors>
<mirror>
<id>nexus</id>
<name>GC Nexus Repository</name>
<url>https://nexus.gc-si.dev/repository/maven-public/</url>
<mirrorOf>*</mirrorOf>
</mirror>
</mirrors>
</settings>

1
.sdkmanrc Normal file
파일 보기

@ -0,0 +1 @@
java=17.0.18-amzn

101
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,101 @@
# SNP-Batch-1 (snp-batch-validation)
해양 데이터 통합 배치 시스템. 외부 Maritime API에서 선박/항만/사건 데이터를 수집하여 PostgreSQL에 저장하고, AIS 실시간 위치정보를 캐시 기반으로 서비스.
## 기술 스택
- Java 17, Spring Boot 3.2.1, Spring Batch 5.1.0
- PostgreSQL (스키마: t_std_snp_data)
- Quartz Scheduler (JDBC Store)
- Spring Kafka (AIS Target → Kafka 파이프라인)
- WebFlux WebClient (외부 API 호출)
- Thymeleaf (배치 관리 Web GUI)
- Springdoc OpenAPI 2.3.0 (Swagger)
- Caffeine Cache, JTS (공간 연산)
- Lombok, Jackson
## 빌드 & 실행
```bash
# 빌드
sdk use java 17.0.18-amzn
mvn clean package -DskipTests
# 실행
mvn spring-boot:run
# 테스트
mvn test
```
## 서버 설정
- 포트: 8041
- Context Path: /snp-api
- Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
## 디렉토리 구조
```
src/main/java/com/snp/batch/
├── SnpBatchApplication.java # 메인 애플리케이션
├── common/ # 공통 프레임워크
│ ├── batch/ # 배치 베이스 클래스 (config, entity, processor, reader, writer)
│ ├── util/ # 유틸 (JsonChangeDetector, SafeGetDataUtil)
│ └── web/ # Web 베이스 (ApiResponse, BaseController, BaseService)
├── global/ # 글로벌 설정 & 배치 관리
│ ├── config/ # AsyncConfig, QuartzConfig, SwaggerConfig, WebClientConfig
│ ├── controller/ # BatchController (/api/batch), WebViewController
│ ├── dto/ # Dashboard, JobExecution, Schedule DTO
│ ├── model/ # BatchLastExecution, JobScheduleEntity
│ ├── partition/ # 파티션 관리 (PartitionManagerTasklet)
│ ├── projection/ # DateRangeProjection
│ └── repository/ # BatchApiLog, BatchLastExecution, JobSchedule, Timeline
├── jobs/ # 배치 Job 모듈 (도메인별)
│ ├── aistarget/ # AIS Target (실시간 위치 + 캐시 + REST API + Kafka 발행)
│ ├── aistargetdbsync/ # AIS Target DB Sync (캐시→DB)
│ ├── common/ # 공통코드 (FlagCode, Stat5Code)
│ ├── compliance/ # 규정준수 (Compliance, CompanyCompliance)
│ ├── event/ # 해양사건 (Event, EventDetail, Cargo, HumanCasualty)
│ ├── movements/ # 선박이동 (다수 하위 Job)
│ ├── psc/ # PSC 검사
│ ├── risk/ # 리스크 분석
│ └── ship*/ # 선박정보 (ship001~ship028, 30+ 테이블)
└── service/ # BatchService, ScheduleService
```
## 배치 Job 패턴
각 Job은 `common/batch/` 베이스 클래스를 상속:
- **BaseJobConfig** → Job/Step 설정 (chunk-oriented)
- **BaseApiReader** → 외부 Maritime API 호출 (WebClient)
- **BaseProcessor** → DTO→Entity 변환
- **BaseWriter** → PostgreSQL Upsert
- **BaseEntity** → 공통 필드 (dataHash, lastModifiedDate 등)
## 주요 API 경로 (context-path: /snp-api)
### Batch Management (/api/batch)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| POST | /jobs/{jobName}/execute | 배치 작업 실행 |
| GET | /jobs | 작업 목록 |
| GET | /jobs/{jobName}/executions | 실행 이력 |
| GET | /executions/{id}/detail | 실행 상세 (Step 포함) |
| POST | /executions/{id}/stop | 실행 중지 |
| GET/POST | /schedules | 스케줄 관리 (CRUD) |
| GET | /dashboard | 대시보드 |
| GET | /timeline | 타임라인 |
### AIS Target (/api/ais-target)
| 메서드 | 경로 | 설명 |
|--------|------|------|
| GET | /{mmsi} | MMSI로 최신 위치 |
| POST | /batch | 다건 MMSI 조회 |
| GET/POST | /search | 시간/공간 범위 검색 |
| POST | /search/filter | 조건 필터 검색 (SOG, COG 등) |
| POST | /search/polygon | 폴리곤 범위 검색 |
| POST | /search/wkt | WKT 형식 검색 |
| GET | /search/with-distance | 거리 포함 원형 검색 |
| GET | /{mmsi}/track | 항적 조회 |
| GET | /cache/stats | 캐시 통계 |
| DELETE | /cache | 캐시 초기화 |
## Lint/Format
- 별도 lint 도구 미설정 (checkstyle, spotless 없음)
- IDE 기본 포매터 사용

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

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

파일 보기

@ -1,517 +0,0 @@
# Swagger API 문서화 가이드
**작성일**: 2025-10-16
**버전**: 1.0.0
**프로젝트**: SNP Batch - Spring Batch 기반 데이터 통합 시스템
---
## 📋 Swagger 설정 완료 사항
### ✅ 수정 완료 파일
1. **BaseController.java** - 공통 CRUD Controller 추상 클래스
- Java import alias 오류 수정 (`as SwaggerApiResponse` 제거)
- `@Operation` 어노테이션 내 `responses` 속성으로 통합
- 전체 경로로 어노테이션 사용: `@io.swagger.v3.oas.annotations.responses.ApiResponse`
2. **ProductWebController.java** - 샘플 제품 API Controller
- Java import alias 오류 수정
- 커스텀 엔드포인트 Swagger 어노테이션 수정
3. **SwaggerConfig.java** - Swagger/OpenAPI 3.0 설정
- 서버 포트 동적 설정 (`@Value("${server.port:8081}")`)
- 상세한 API 문서 설명 추가
- Markdown 형식 설명 추가
4. **BatchController.java** - 배치 관리 API (이미 올바르게 구현됨)
---
## 🌐 Swagger UI 접속 정보
### 접속 URL
```
Swagger UI: http://localhost:8081/swagger-ui/index.html
API 문서 (JSON): http://localhost:8081/v3/api-docs
API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
```
### 제공되는 API 그룹
> **참고**: BaseController는 추상 클래스이므로 별도의 API 그룹으로 표시되지 않습니다.
> 상속받는 Controller(예: ProductWebController)의 `@Tag`로 모든 CRUD 엔드포인트가 그룹화됩니다.
#### 1. **Batch Management API** (`/api/batch`)
배치 작업 실행 및 스케줄 관리
**엔드포인트**:
- `POST /api/batch/jobs/{jobName}/execute` - 배치 작업 실행
- `GET /api/batch/jobs` - 배치 작업 목록 조회
- `GET /api/batch/jobs/{jobName}/executions` - 실행 이력 조회
- `POST /api/batch/executions/{executionId}/stop` - 실행 중지
- `GET /api/batch/schedules` - 스케줄 목록 조회
- `POST /api/batch/schedules` - 스케줄 생성
- `PUT /api/batch/schedules/{jobName}` - 스케줄 수정
- `DELETE /api/batch/schedules/{jobName}` - 스케줄 삭제
- `PATCH /api/batch/schedules/{jobName}/toggle` - 스케줄 활성화/비활성화
- `GET /api/batch/dashboard` - 대시보드 데이터
- `GET /api/batch/timeline` - 타임라인 데이터
#### 2. **Product API** (`/api/products`)
샘플 제품 데이터 CRUD (BaseController 상속)
**모든 엔드포인트가 "Product API" 그룹으로 통합 표시됩니다.**
**공통 CRUD 엔드포인트** (BaseController에서 상속):
- `POST /api/products` - 제품 생성
- `GET /api/products/{id}` - 제품 조회 (ID)
- `GET /api/products` - 전체 제품 조회
- `GET /api/products/page?offset=0&limit=20` - 페이징 조회
- `PUT /api/products/{id}` - 제품 수정
- `DELETE /api/products/{id}` - 제품 삭제
- `GET /api/products/{id}/exists` - 존재 여부 확인
**커스텀 엔드포인트**:
- `GET /api/products/by-product-id/{productId}` - 제품 코드로 조회
- `GET /api/products/stats/active-count` - 활성 제품 개수
---
## 🛠️ 애플리케이션 실행 및 테스트
### 1. 애플리케이션 빌드 및 실행
```bash
# Maven 빌드 (IntelliJ IDEA에서)
mvn clean package -DskipTests
# 애플리케이션 실행
mvn spring-boot:run
```
또는 IntelliJ IDEA에서:
1. `SnpBatchApplication.java` 파일 열기
2. 메인 메서드 왼쪽의 ▶ 아이콘 클릭
3. "Run 'SnpBatchApplication'" 선택
### 2. Swagger UI 접속
브라우저에서 다음 URL 접속:
```
http://localhost:8081/swagger-ui/index.html
```
### 3. API 테스트 예시
#### 예시 1: 배치 작업 목록 조회
```http
GET http://localhost:8081/api/batch/jobs
```
**예상 응답**:
```json
[
"sampleProductImportJob",
"shipDataImportJob"
]
```
#### 예시 2: 배치 작업 실행
```http
POST http://localhost:8081/api/batch/jobs/sampleProductImportJob/execute
```
**예상 응답**:
```json
{
"success": true,
"message": "Job started successfully",
"executionId": 1
}
```
#### 예시 3: 제품 생성 (샘플)
```http
POST http://localhost:8081/api/products
Content-Type: application/json
{
"productId": "TEST-001",
"productName": "테스트 제품",
"category": "Electronics",
"price": 99.99,
"stockQuantity": 50,
"isActive": true,
"rating": 4.5
}
```
**예상 응답**:
```json
{
"success": true,
"message": "Product created successfully",
"data": {
"id": 1,
"productId": "TEST-001",
"productName": "테스트 제품",
"category": "Electronics",
"price": 99.99,
"stockQuantity": 50,
"isActive": true,
"rating": 4.5,
"createdAt": "2025-10-16T10:30:00",
"updatedAt": "2025-10-16T10:30:00"
}
}
```
#### 예시 4: 페이징 조회
```http
GET http://localhost:8081/api/products/page?offset=0&limit=10
```
**예상 응답**:
```json
{
"success": true,
"message": "Retrieved 10 items (total: 100)",
"data": [
{ "id": 1, "productName": "Product 1", ... },
{ "id": 2, "productName": "Product 2", ... },
...
]
}
```
---
## 📚 Swagger 어노테이션 가이드
### BaseController에서 사용된 패턴
#### ❌ 잘못된 사용법 (Java에서는 불가능)
```java
// Kotlin의 import alias는 Java에서 지원되지 않음
import io.swagger.v3.oas.annotations.responses.ApiResponse as SwaggerApiResponse;
@ApiResponses(value = {
@SwaggerApiResponse(responseCode = "200", description = "성공")
})
```
#### ✅ 올바른 사용법 (수정 완료)
```java
// import alias 제거
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@Operation(
summary = "리소스 생성",
description = "새로운 리소스를 생성합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "생성 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@PostMapping
public ResponseEntity<ApiResponse<D>> create(
@Parameter(description = "생성할 리소스 데이터", required = true)
@RequestBody D dto) {
// ...
}
```
### 주요 어노테이션 설명
#### 1. `@Tag` - API 그룹화
```java
@Tag(name = "Product API", description = "제품 관리 API")
public class ProductWebController extends BaseController<ProductWebDto, Long> {
// ...
}
```
#### 2. `@Operation` - 엔드포인트 문서화
```java
@Operation(
summary = "짧은 설명 (목록에 표시)",
description = "상세 설명 (확장 시 표시)",
responses = { /* 응답 정의 */ }
)
```
#### 3. `@Parameter` - 파라미터 설명
```java
@Parameter(
description = "파라미터 설명",
required = true,
example = "예시 값"
)
@PathVariable String id
```
#### 4. `@io.swagger.v3.oas.annotations.responses.ApiResponse` - 응답 정의
```java
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "성공 메시지",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ProductDto.class)
)
)
```
---
## 🎯 신규 Controller 개발 시 Swagger 적용 가이드
### 1. BaseController를 상속하는 경우
```java
@RestController
@RequestMapping("/api/myresource")
@RequiredArgsConstructor
@Tag(name = "My Resource API", description = "나의 리소스 관리 API")
public class MyResourceController extends BaseController<MyResourceDto, Long> {
private final MyResourceService myResourceService;
@Override
protected BaseService<?, MyResourceDto, Long> getService() {
return myResourceService;
}
@Override
protected String getResourceName() {
return "MyResource";
}
// BaseController가 제공하는 CRUD 엔드포인트 자동 생성:
// POST /api/myresource
// GET /api/myresource/{id}
// GET /api/myresource
// GET /api/myresource/page
// PUT /api/myresource/{id}
// DELETE /api/myresource/{id}
// GET /api/myresource/{id}/exists
// 커스텀 엔드포인트 추가 시:
@Operation(
summary = "커스텀 조회",
description = "특정 조건으로 리소스를 조회합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공"
)
}
)
@GetMapping("/custom/{key}")
public ResponseEntity<ApiResponse<MyResourceDto>> customEndpoint(
@Parameter(description = "커스텀 키", required = true)
@PathVariable String key) {
// 구현...
}
}
```
### 2. 독립적인 Controller를 작성하는 경우
```java
@RestController
@RequestMapping("/api/custom")
@RequiredArgsConstructor
@Slf4j
@Tag(name = "Custom API", description = "커스텀 API")
public class CustomController {
@Operation(
summary = "커스텀 작업",
description = "특정 작업을 수행합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "작업 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@PostMapping("/action")
public ResponseEntity<Map<String, Object>> customAction(
@Parameter(description = "액션 파라미터", required = true)
@RequestBody Map<String, String> params) {
// 구현...
}
}
```
---
## 🔍 Swagger UI 화면 구성
### 메인 화면
```
┌─────────────────────────────────────────────────┐
│ SNP Batch REST API │
│ Version: v1.0.0 │
│ Spring Batch 기반 데이터 통합 시스템 REST API │
├─────────────────────────────────────────────────┤
│ Servers: │
│ ▼ http://localhost:8081 (로컬 개발 서버) │
├─────────────────────────────────────────────────┤
│ │
│ ▼ Batch Management API │
│ POST /api/batch/jobs/{jobName}/execute │
│ GET /api/batch/jobs │
│ ... │
│ │
│ ▼ Product API (9개 엔드포인트 통합 표시) │
│ POST /api/products │
│ GET /api/products/{id} │
│ GET /api/products │
│ GET /api/products/page │
│ PUT /api/products/{id} │
│ DELETE /api/products/{id} │
│ GET /api/products/{id}/exists │
│ GET /api/products/by-product-id/{...} │
│ GET /api/products/stats/active-count │
│ │
│ (Base API 그룹은 표시되지 않음) │
│ │
└─────────────────────────────────────────────────┘
```
### API 실행 화면 예시
각 엔드포인트 클릭 시:
- **Parameters**: 파라미터 입력 필드
- **Request body**: JSON 요청 본문 에디터
- **Try it out**: 실제 API 호출 버튼
- **Responses**: 응답 코드 및 예시
- **Curl**: curl 명령어 생성
---
## ⚠️ 문제 해결
### 1. Swagger UI 접속 불가
**증상**: `http://localhost:8081/swagger-ui/index.html` 접속 시 404 오류
**해결**:
1. 애플리케이션이 실행 중인지 확인
2. 포트 번호 확인 (`application.yml`의 `server.port`)
3. 다음 URL 시도:
- `http://localhost:8081/swagger-ui.html`
- `http://localhost:8081/swagger-ui/`
### 2. API 실행 시 401/403 오류
**증상**: "Try it out" 클릭 시 인증 오류
**해결**:
- 현재 인증이 설정되지 않음 (기본 허용)
- Spring Security 추가 시 Swagger 경로 허용 필요:
```java
.authorizeHttpRequests(auth -> auth
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
)
```
### 3. 특정 엔드포인트가 보이지 않음
**증상**: Controller는 작성했지만 Swagger UI에 표시되지 않음
**해결**:
1. `@RestController` 어노테이션 확인
2. `@RequestMapping` 경로 확인
3. Controller가 `com.snp.batch` 패키지 하위에 있는지 확인
4. 애플리케이션 재시작
---
## 📊 설정 파일
### application.yml (Swagger 관련 설정)
```yaml
server:
port: 8081 # Swagger UI 접속 포트
# Springdoc OpenAPI 설정 (필요 시 추가)
springdoc:
api-docs:
path: /v3/api-docs # OpenAPI JSON 경로
swagger-ui:
path: /swagger-ui.html # Swagger UI 경로
enabled: true
operations-sorter: alpha # 엔드포인트 정렬 (alpha, method)
tags-sorter: alpha # 태그 정렬
```
---
## 🎓 추가 학습 자료
### Swagger 어노테이션 공식 문서
- [OpenAPI 3.0 Annotations](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations)
- [Springdoc OpenAPI](https://springdoc.org/)
### 관련 파일 위치
```
src/main/java/com/snp/batch/
├── common/web/controller/BaseController.java # 공통 CRUD Base
├── global/config/SwaggerConfig.java # Swagger 설정
├── global/controller/BatchController.java # Batch API
└── jobs/sample/web/controller/ProductWebController.java # Product API
```
---
## ✅ 체크리스트
애플리케이션 실행 전 확인:
- [ ] Maven 빌드 성공
- [ ] `application.yml` 설정 확인
- [ ] PostgreSQL 데이터베이스 연결 확인
- [ ] 포트 8081 사용 가능 여부 확인
Swagger 테스트 확인:
- [ ] Swagger UI 접속 성공
- [ ] Batch Management API 표시 확인
- [ ] Product API 표시 확인
- [ ] "Try it out" 기능 동작 확인
- [ ] API 응답 정상 확인
---
## 📚 관련 문서
### 핵심 문서
- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드
- **[DEVELOPMENT_GUIDE.md](DEVELOPMENT_GUIDE.md)** - 신규 Job 개발 가이드 및 Base 클래스 사용법
- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성)
### 아키텍처 문서
- **[docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md)** - 프로젝트 아키텍처 상세 설계
- **[docs/architecture/PROJECT_STRUCTURE.md](docs/architecture/PROJECT_STRUCTURE.md)** - Job 중심 패키지 구조 가이드
### 구현 가이드
- **[docs/guides/PROXY_SERVICE_GUIDE.md](docs/guides/PROXY_SERVICE_GUIDE.md)** - 외부 API 프록시 패턴 구현 가이드
- **[docs/guides/SHIP_API_EXAMPLE.md](docs/guides/SHIP_API_EXAMPLE.md)** - Maritime API 연동 실전 예제
### 보안 문서
- **[docs/security/README.md](docs/security/README.md)** - 보안 전략 개요 (계획 단계)
---
**최종 업데이트**: 2025-10-16
**작성자**: Claude Code
**버전**: 1.1.0

24
frontend/.gitignore vendored Normal file
파일 보기

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

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

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

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

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

13
frontend/index.html Normal file
파일 보기

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3935
frontend/package-lock.json generated Normal file

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

33
frontend/package.json Normal file
파일 보기

@ -0,0 +1,33 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

49
frontend/src/App.tsx Normal file
파일 보기

@ -0,0 +1,49 @@
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { ToastProvider, useToastContext } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext';
import Navbar from './components/Navbar';
import ToastContainer from './components/Toast';
import LoadingSpinner from './components/LoadingSpinner';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Jobs = lazy(() => import('./pages/Jobs'));
const Executions = lazy(() => import('./pages/Executions'));
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
const Schedules = lazy(() => import('./pages/Schedules'));
const Timeline = lazy(() => import('./pages/Timeline'));
function AppLayout() {
const { toasts, removeToast } = useToastContext();
return (
<div className="min-h-screen bg-wing-bg text-wing-text">
<div className="max-w-7xl mx-auto px-4 py-6">
<Navbar />
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/executions" element={<Executions />} />
<Route path="/executions/:id" element={<ExecutionDetail />} />
<Route path="/schedules" element={<Schedules />} />
<Route path="/schedule-timeline" element={<Timeline />} />
</Routes>
</Suspense>
</div>
<ToastContainer toasts={toasts} onRemove={removeToast} />
</div>
);
}
export default function App() {
return (
<ThemeProvider>
<BrowserRouter basename="/snp-api">
<ToastProvider>
<AppLayout />
</ToastProvider>
</BrowserRouter>
</ThemeProvider>
);
}

파일 보기

@ -0,0 +1,308 @@
const BASE = import.meta.env.DEV ? '/snp-api/api/batch' : '/snp-api/api/batch';
async function fetchJson<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
async function postJson<T>(url: string, body?: unknown): Promise<T> {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`);
return res.json();
}
// ── Dashboard ────────────────────────────────────────────────
export interface DashboardStats {
totalSchedules: number;
activeSchedules: number;
inactiveSchedules: number;
totalJobs: number;
}
export interface RunningJob {
jobName: string;
executionId: number;
status: string;
startTime: string;
}
export interface RecentExecution {
executionId: number;
jobName: string;
status: string;
startTime: string;
endTime: string | null;
}
export interface RecentFailure {
executionId: number;
jobName: string;
status: string;
startTime: string;
endTime: string | null;
exitMessage: string | null;
}
export interface FailureStats {
last24h: number;
last7d: number;
}
export interface DashboardResponse {
stats: DashboardStats;
runningJobs: RunningJob[];
recentExecutions: RecentExecution[];
recentFailures: RecentFailure[];
staleExecutionCount: number;
failureStats: FailureStats;
}
// ── Job Execution ────────────────────────────────────────────
export interface JobExecutionDto {
executionId: number;
jobName: string;
status: string;
startTime: string;
endTime: string | null;
exitCode: string | null;
exitMessage: string | null;
}
export interface ApiCallInfo {
apiUrl: string;
method: string;
parameters: Record<string, unknown> | null;
totalCalls: number;
completedCalls: number;
lastCallTime: string;
}
export interface StepExecutionDto {
stepExecutionId: number;
stepName: string;
status: string;
startTime: string;
endTime: string | null;
readCount: number;
writeCount: number;
commitCount: number;
rollbackCount: number;
readSkipCount: number;
processSkipCount: number;
writeSkipCount: number;
filterCount: number;
exitCode: string;
exitMessage: string | null;
duration: number | null;
apiCallInfo: ApiCallInfo | null;
}
export interface JobExecutionDetailDto {
executionId: number;
jobName: string;
status: string;
startTime: string;
endTime: string | null;
exitCode: string;
exitMessage: string | null;
jobParameters: Record<string, string>;
jobInstanceId: number;
duration: number | null;
readCount: number;
writeCount: number;
skipCount: number;
filterCount: number;
stepExecutions: StepExecutionDto[];
}
// ── Schedule ─────────────────────────────────────────────────
export interface ScheduleResponse {
id: number;
jobName: string;
cronExpression: string;
description: string | null;
active: boolean;
nextFireTime: string | null;
previousFireTime: string | null;
triggerState: string | null;
createdAt: string;
updatedAt: string;
}
export interface ScheduleRequest {
jobName: string;
cronExpression: string;
description?: string;
active?: boolean;
}
// ── Timeline ─────────────────────────────────────────────────
export interface PeriodInfo {
key: string;
label: string;
}
export interface ExecutionInfo {
executionId: number | null;
status: string;
startTime: string | null;
endTime: string | null;
}
export interface ScheduleTimeline {
jobName: string;
executions: Record<string, ExecutionInfo | null>;
}
export interface TimelineResponse {
periodLabel: string;
periods: PeriodInfo[];
schedules: ScheduleTimeline[];
}
// ── F4: Execution Search ─────────────────────────────────────
export interface ExecutionSearchResponse {
executions: JobExecutionDto[];
totalCount: number;
page: number;
size: number;
totalPages: number;
}
// ── F7: Job Detail ───────────────────────────────────────────
export interface LastExecution {
executionId: number;
status: string;
startTime: string;
endTime: string | null;
}
export interface JobDetailDto {
jobName: string;
lastExecution: LastExecution | null;
scheduleCron: string | null;
}
// ── F8: Statistics ───────────────────────────────────────────
export interface DailyStat {
date: string;
successCount: number;
failedCount: number;
otherCount: number;
avgDurationMs: number;
}
export interface ExecutionStatisticsDto {
dailyStats: DailyStat[];
totalExecutions: number;
totalSuccess: number;
totalFailed: number;
avgDurationMs: number;
}
// ── API Functions ────────────────────────────────────────────
export const batchApi = {
getDashboard: () =>
fetchJson<DashboardResponse>(`${BASE}/dashboard`),
getJobs: () =>
fetchJson<string[]>(`${BASE}/jobs`),
getJobsDetail: () =>
fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`),
executeJob: (jobName: string, params?: Record<string, string>) =>
postJson<{ success: boolean; message: string; executionId?: number }>(
`${BASE}/jobs/${jobName}/execute`, params),
getJobExecutions: (jobName: string) =>
fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`),
getRecentExecutions: (limit = 50) =>
fetchJson<JobExecutionDto[]>(`${BASE}/executions/recent?limit=${limit}`),
getExecutionDetail: (id: number) =>
fetchJson<JobExecutionDetailDto>(`${BASE}/executions/${id}/detail`),
stopExecution: (id: number) =>
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/stop`),
// F1: Abandon
getStaleExecutions: (thresholdMinutes = 60) =>
fetchJson<JobExecutionDto[]>(`${BASE}/executions/stale?thresholdMinutes=${thresholdMinutes}`),
abandonExecution: (id: number) =>
postJson<{ success: boolean; message: string }>(`${BASE}/executions/${id}/abandon`),
abandonAllStale: (thresholdMinutes = 60) =>
postJson<{ success: boolean; message: string; abandonedCount?: number }>(
`${BASE}/executions/stale/abandon-all?thresholdMinutes=${thresholdMinutes}`),
// F4: Search
searchExecutions: (params: {
jobNames?: string[];
status?: string;
startDate?: string;
endDate?: string;
page?: number;
size?: number;
}) => {
const qs = new URLSearchParams();
if (params.jobNames && params.jobNames.length > 0) qs.set('jobNames', params.jobNames.join(','));
if (params.status) qs.set('status', params.status);
if (params.startDate) qs.set('startDate', params.startDate);
if (params.endDate) qs.set('endDate', params.endDate);
qs.set('page', String(params.page ?? 0));
qs.set('size', String(params.size ?? 50));
return fetchJson<ExecutionSearchResponse>(`${BASE}/executions/search?${qs.toString()}`);
},
// F8: Statistics
getStatistics: (days = 30) =>
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics?days=${days}`),
getJobStatistics: (jobName: string, days = 30) =>
fetchJson<ExecutionStatisticsDto>(`${BASE}/statistics/${jobName}?days=${days}`),
// Schedule
getSchedules: () =>
fetchJson<{ schedules: ScheduleResponse[]; count: number }>(`${BASE}/schedules`),
getSchedule: (jobName: string) =>
fetchJson<ScheduleResponse>(`${BASE}/schedules/${jobName}`),
createSchedule: (data: ScheduleRequest) =>
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(`${BASE}/schedules`, data),
updateSchedule: (jobName: string, data: { cronExpression: string; description?: string }) =>
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
`${BASE}/schedules/${jobName}/update`, data),
deleteSchedule: (jobName: string) =>
postJson<{ success: boolean; message: string }>(`${BASE}/schedules/${jobName}/delete`),
toggleSchedule: (jobName: string, active: boolean) =>
postJson<{ success: boolean; message: string; data?: ScheduleResponse }>(
`${BASE}/schedules/${jobName}/toggle`, { active }),
// Timeline
getTimeline: (view: string, date: string) =>
fetchJson<TimelineResponse>(`${BASE}/timeline?view=${view}&date=${date}`),
getPeriodExecutions: (jobName: string, view: string, periodKey: string) =>
fetchJson<JobExecutionDto[]>(
`${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`),
};

파일 보기

@ -0,0 +1,74 @@
interface BarValue {
color: string;
value: number;
}
interface BarData {
label: string;
values: BarValue[];
}
interface Props {
data: BarData[];
height?: number;
}
export default function BarChart({ data, height = 200 }: Props) {
const maxTotal = Math.max(...data.map((d) => d.values.reduce((sum, v) => sum + v.value, 0)), 1);
return (
<div className="w-full">
<div className="flex items-end gap-1" style={{ height }}>
{data.map((bar, i) => {
const total = bar.values.reduce((sum, v) => sum + v.value, 0);
const ratio = total / maxTotal;
return (
<div key={i} className="flex-1 flex flex-col justify-end h-full min-w-0">
<div
className="w-full rounded-t overflow-hidden"
style={{ height: `${ratio * 100}%` }}
title={bar.values.map((v) => `${v.color}: ${v.value}`).join(', ')}
>
{bar.values
.filter((v) => v.value > 0)
.map((v, j) => {
const segmentRatio = total > 0 ? (v.value / total) * 100 : 0;
return (
<div
key={j}
className={colorToClass(v.color)}
style={{ height: `${segmentRatio}%` }}
/>
);
})}
</div>
</div>
);
})}
</div>
<div className="flex gap-1 mt-1">
{data.map((bar, i) => (
<div key={i} className="flex-1 min-w-0">
<p className="text-[10px] text-gray-500 text-center truncate" title={bar.label}>
{bar.label}
</p>
</div>
))}
</div>
</div>
);
}
function colorToClass(color: string): string {
const map: Record<string, string> = {
green: 'bg-green-500',
red: 'bg-red-500',
gray: 'bg-gray-400',
blue: 'bg-blue-500',
yellow: 'bg-yellow-500',
orange: 'bg-orange-500',
indigo: 'bg-indigo-500',
};
return map[color] ?? color;
}

파일 보기

@ -0,0 +1,49 @@
interface Props {
open: boolean;
title?: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
confirmColor?: string;
onConfirm: () => void;
onCancel: () => void;
}
export default function ConfirmModal({
open,
title = '확인',
message,
confirmLabel = '확인',
cancelLabel = '취소',
confirmColor = 'bg-wing-accent hover:bg-wing-accent/80',
onConfirm,
onCancel,
}: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onCancel}>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-2">{title}</h3>
<p className="text-wing-muted text-sm mb-6 whitespace-pre-line">{message}</p>
<div className="flex justify-end gap-3">
<button
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors ${confirmColor}`}
>
{confirmLabel}
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,15 @@
interface Props {
icon?: string;
message: string;
sub?: string;
}
export default function EmptyState({ icon = '📭', message, sub }: Props) {
return (
<div className="flex flex-col items-center justify-center py-12 text-wing-muted">
<span className="text-4xl mb-3">{icon}</span>
<p className="text-sm font-medium">{message}</p>
{sub && <p className="text-xs mt-1">{sub}</p>}
</div>
);
}

파일 보기

@ -0,0 +1,35 @@
interface Props {
open: boolean;
title?: string;
children: React.ReactNode;
onClose: () => void;
}
export default function InfoModal({
open,
title = '정보',
children,
onClose,
}: Props) {
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay" onClick={onClose}>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4">{title}</h3>
<div className="text-wing-muted text-sm mb-6">{children}</div>
<div className="flex justify-end">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
</div>
</div>
);
}

파일 보기

@ -0,0 +1,7 @@
export default function LoadingSpinner({ className = '' }: { className?: string }) {
return (
<div className={`flex items-center justify-center py-12 ${className}`}>
<div className="w-8 h-8 border-4 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
</div>
);
}

파일 보기

@ -0,0 +1,54 @@
import { Link, useLocation } from 'react-router-dom';
import { useThemeContext } from '../contexts/ThemeContext';
const navItems = [
{ path: '/', label: '대시보드', icon: '📊' },
{ path: '/jobs', label: '작업', icon: '⚙️' },
{ path: '/executions', label: '실행 이력', icon: '📋' },
{ path: '/schedules', label: '스케줄', icon: '🕐' },
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
];
export default function Navbar() {
const location = useLocation();
const { theme, toggle } = useThemeContext();
const isActive = (path: string) => {
if (path === '/') return location.pathname === '/';
return location.pathname.startsWith(path);
};
return (
<nav className="bg-wing-glass-dense backdrop-blur-sm shadow-md rounded-xl mb-6 px-4 py-3 border border-wing-border">
<div className="flex items-center justify-between flex-wrap gap-2">
<Link to="/" className="text-lg font-bold text-wing-accent no-underline">
S&P
</Link>
<div className="flex gap-1 flex-wrap items-center">
{navItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={`px-3 py-1.5 rounded-lg text-sm font-medium no-underline transition-colors
${isActive(item.path)
? 'bg-wing-accent text-white'
: 'text-wing-muted hover:bg-wing-hover hover:text-wing-accent'
}`}
>
<span className="mr-1">{item.icon}</span>
{item.label}
</Link>
))}
<button
onClick={toggle}
className="ml-2 px-2.5 py-1.5 rounded-lg text-sm bg-wing-card text-wing-muted
hover:text-wing-text border border-wing-border transition-colors"
title={theme === 'dark' ? '라이트 모드' : '다크 모드'}
>
{theme === 'dark' ? '☀️' : '🌙'}
</button>
</div>
</div>
</nav>
);
}

파일 보기

@ -0,0 +1,40 @@
const STATUS_CONFIG: Record<string, { bg: string; text: string; label: string }> = {
COMPLETED: { bg: 'bg-emerald-100 text-emerald-700', text: '완료', label: '✓' },
FAILED: { bg: 'bg-red-100 text-red-700', text: '실패', label: '✕' },
STARTED: { bg: 'bg-blue-100 text-blue-700', text: '실행중', label: '↻' },
STARTING: { bg: 'bg-cyan-100 text-cyan-700', text: '시작중', label: '⏳' },
STOPPED: { bg: 'bg-amber-100 text-amber-700', text: '중지됨', label: '⏸' },
STOPPING: { bg: 'bg-orange-100 text-orange-700', text: '중지중', label: '⏸' },
ABANDONED: { bg: 'bg-gray-100 text-gray-700', text: '포기됨', label: '—' },
SCHEDULED: { bg: 'bg-violet-100 text-violet-700', text: '예정', label: '🕐' },
UNKNOWN: { bg: 'bg-gray-100 text-gray-500', text: '알수없음', label: '?' },
};
interface Props {
status: string;
className?: string;
}
export default function StatusBadge({ status, className = '' }: Props) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.UNKNOWN;
return (
<span className={`inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${config.bg} ${className}`}>
<span>{config.label}</span>
{config.text}
</span>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function getStatusColor(status: string): string {
switch (status) {
case 'COMPLETED': return '#10b981';
case 'FAILED': return '#ef4444';
case 'STARTED': return '#3b82f6';
case 'STARTING': return '#06b6d4';
case 'STOPPED': return '#f59e0b';
case 'STOPPING': return '#f97316';
case 'SCHEDULED': return '#8b5cf6';
default: return '#6b7280';
}
}

파일 보기

@ -0,0 +1,37 @@
import type { Toast as ToastType } from '../hooks/useToast';
const TYPE_STYLES: Record<ToastType['type'], string> = {
success: 'bg-emerald-500',
error: 'bg-red-500',
warning: 'bg-amber-500',
info: 'bg-blue-500',
};
interface Props {
toasts: ToastType[];
onRemove: (id: number) => void;
}
export default function ToastContainer({ toasts, onRemove }: Props) {
if (toasts.length === 0) return null;
return (
<div className="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
{toasts.map((toast) => (
<div
key={toast.id}
className={`${TYPE_STYLES[toast.type]} text-white px-4 py-3 rounded-lg shadow-lg
flex items-center justify-between gap-3 animate-slide-in`}
>
<span className="text-sm">{toast.message}</span>
<button
onClick={() => onRemove(toast.id)}
className="text-white/80 hover:text-white text-lg leading-none"
>
×
</button>
</div>
))}
</div>
);
}

파일 보기

@ -0,0 +1,26 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useTheme } from '../hooks/useTheme';
interface ThemeContextValue {
theme: 'dark' | 'light';
toggle: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: 'dark',
toggle: () => {},
});
export function ThemeProvider({ children }: { children: ReactNode }) {
const value = useTheme();
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useThemeContext() {
return useContext(ThemeContext);
}

파일 보기

@ -0,0 +1,29 @@
import { createContext, useContext, type ReactNode } from 'react';
import { useToast, type Toast } from '../hooks/useToast';
interface ToastContextValue {
toasts: Toast[];
showToast: (message: string, type?: Toast['type']) => void;
removeToast: (id: number) => void;
}
const ToastContext = createContext<ToastContextValue | null>(null);
export function ToastProvider({ children }: { children: ReactNode }) {
const { toasts, showToast, removeToast } = useToast();
return (
<ToastContext.Provider value={{ toasts, showToast, removeToast }}>
{children}
</ToastContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useToastContext(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToastContext must be used within a ToastProvider');
}
return ctx;
}

파일 보기

@ -0,0 +1,53 @@
import { useEffect, useRef } from 'react';
/**
*
* - 1 intervalMs
* - (document.hidden) ,
* - deps
*/
export function usePoller(
fn: () => Promise<void> | void,
intervalMs: number,
deps: unknown[] = [],
) {
const fnRef = useRef(fn);
fnRef.current = fn;
useEffect(() => {
let timer: ReturnType<typeof setInterval> | null = null;
const run = () => {
fnRef.current();
};
const start = () => {
run();
timer = setInterval(run, intervalMs);
};
const stop = () => {
if (timer) {
clearInterval(timer);
timer = null;
}
};
const handleVisibility = () => {
if (document.hidden) {
stop();
} else {
start();
}
};
start();
document.addEventListener('visibilitychange', handleVisibility);
return () => {
stop();
document.removeEventListener('visibilitychange', handleVisibility);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [intervalMs, ...deps]);
}

파일 보기

@ -0,0 +1,27 @@
import { useState, useEffect, useCallback } from 'react';
type Theme = 'dark' | 'light';
const STORAGE_KEY = 'snp-batch-theme';
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return 'dark';
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === 'light' || stored === 'dark') return stored;
return 'dark';
}
export function useTheme() {
const [theme, setTheme] = useState<Theme>(getInitialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
}, [theme]);
const toggle = useCallback(() => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
}, []);
return { theme, toggle } as const;
}

파일 보기

@ -0,0 +1,27 @@
import { useState, useCallback } from 'react';
export interface Toast {
id: number;
message: string;
type: 'success' | 'error' | 'warning' | 'info';
}
let nextId = 0;
export function useToast() {
const [toasts, setToasts] = useState<Toast[]>([]);
const showToast = useCallback((message: string, type: Toast['type'] = 'info') => {
const id = nextId++;
setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, 5000);
}, []);
const removeToast = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
return { toasts, showToast, removeToast };
}

3
frontend/src/index.css Normal file
파일 보기

@ -0,0 +1,3 @@
@import "tailwindcss";
@import "./theme/tokens.css";
@import "./theme/base.css";

10
frontend/src/main.tsx Normal file
파일 보기

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

파일 보기

@ -0,0 +1,507 @@
import { useState, useCallback, useEffect } from 'react';
import { Link } from 'react-router-dom';
import {
batchApi,
type DashboardResponse,
type DashboardStats,
type ExecutionStatisticsDto,
} from '../api/batchApi';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import BarChart from '../components/BarChart';
import { formatDateTime, calculateDuration } from '../utils/formatters';
const POLLING_INTERVAL = 5000;
interface StatCardProps {
label: string;
value: number;
gradient: string;
to?: string;
}
function StatCard({ label, value, gradient, to }: StatCardProps) {
const content = (
<div
className={`${gradient} rounded-xl shadow-md p-6 text-white
hover:shadow-lg hover:-translate-y-0.5 transition-all cursor-pointer`}
>
<p className="text-3xl font-bold">{value}</p>
<p className="text-sm mt-1 opacity-90">{label}</p>
</div>
);
if (to) {
return <Link to={to} className="no-underline">{content}</Link>;
}
return content;
}
export default function Dashboard() {
const { showToast } = useToastContext();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [loading, setLoading] = useState(true);
// Execute Job modal
const [showExecuteModal, setShowExecuteModal] = useState(false);
const [jobs, setJobs] = useState<string[]>([]);
const [selectedJob, setSelectedJob] = useState('');
const [executing, setExecuting] = useState(false);
const [startDate, setStartDate] = useState('');
const [stopDate, setStopDate] = useState('');
const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
const loadStatistics = useCallback(async () => {
try {
const data = await batchApi.getStatistics(30);
setStatistics(data);
} catch {
/* 통계 로드 실패는 무시 */
}
}, []);
useEffect(() => {
loadStatistics();
}, [loadStatistics]);
const loadDashboard = useCallback(async () => {
try {
const data = await batchApi.getDashboard();
setDashboard(data);
} catch (err) {
console.error('Dashboard load failed:', err);
} finally {
setLoading(false);
}
}, []);
usePoller(loadDashboard, POLLING_INTERVAL);
const handleOpenExecuteModal = async () => {
try {
const jobList = await batchApi.getJobs();
setJobs(jobList);
setSelectedJob(jobList[0] ?? '');
setShowExecuteModal(true);
} catch (err) {
showToast('작업 목록을 불러올 수 없습니다.', 'error');
console.error(err);
}
};
const handleExecuteJob = async () => {
if (!selectedJob) return;
setExecuting(true);
try {
const params: Record<string, string> = {};
if (startDate) params.startDate = startDate;
if (stopDate) params.stopDate = stopDate;
const result = await batchApi.executeJob(
selectedJob,
Object.keys(params).length > 0 ? params : undefined,
);
showToast(
result.message || `${selectedJob} 실행 요청 완료`,
'success',
);
setShowExecuteModal(false);
setStartDate('');
setStopDate('');
await loadDashboard();
} catch (err) {
showToast(`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`, 'error');
} finally {
setExecuting(false);
}
};
const handleAbandonAllStale = async () => {
setAbandoning(true);
try {
const result = await batchApi.abandonAllStale();
showToast(
result.message || `${result.abandonedCount ?? 0}건 강제 종료 완료`,
'success',
);
await loadDashboard();
} catch (err) {
showToast(
`강제 종료 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
'error',
);
} finally {
setAbandoning(false);
}
};
if (loading) return <LoadingSpinner />;
const stats: DashboardStats = dashboard?.stats ?? {
totalSchedules: 0,
activeSchedules: 0,
inactiveSchedules: 0,
totalJobs: 0,
};
const runningJobs = dashboard?.runningJobs ?? [];
const recentExecutions = dashboard?.recentExecutions ?? [];
const recentFailures = dashboard?.recentFailures ?? [];
const staleExecutionCount = dashboard?.staleExecutionCount ?? 0;
const failureStats = dashboard?.failureStats ?? { last24h: 0, last7d: 0 };
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-wing-text"></h1>
<button
onClick={handleOpenExecuteModal}
className="px-4 py-2 bg-wing-accent text-white font-semibold rounded-lg shadow
hover:bg-wing-accent/80 hover:shadow-lg transition-all text-sm"
>
</button>
</div>
{/* F1: Stale Execution Warning Banner */}
{staleExecutionCount > 0 && (
<div className="flex items-center justify-between bg-amber-100 border border-amber-300 rounded-xl px-5 py-3">
<span className="text-amber-800 font-medium text-sm">
{staleExecutionCount}
</span>
<button
onClick={handleAbandonAllStale}
disabled={abandoning}
className="px-4 py-1.5 text-sm font-medium text-white bg-amber-600 rounded-lg
hover:bg-amber-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{abandoning ? '처리 중...' : '전체 강제 종료'}
</button>
</div>
)}
{/* Stats Cards */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
<StatCard
label="전체 스케줄"
value={stats.totalSchedules}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
to="/schedules"
/>
<StatCard
label="활성 스케줄"
value={stats.activeSchedules}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
/>
<StatCard
label="비활성 스케줄"
value={stats.inactiveSchedules}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
/>
<StatCard
label="전체 작업"
value={stats.totalJobs}
gradient="bg-gradient-to-br from-violet-500 to-violet-600"
to="/jobs"
/>
<StatCard
label="실패 (24h)"
value={failureStats.last24h}
gradient="bg-gradient-to-br from-red-500 to-red-600"
/>
</div>
{/* Quick Navigation */}
<div className="flex flex-wrap gap-2">
<Link
to="/jobs"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/executions"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/schedules"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
<Link
to="/schedule-timeline"
className="px-3 py-1.5 bg-wing-card-alpha text-wing-text rounded-lg text-sm hover:bg-wing-hover transition-colors no-underline"
>
</Link>
</div>
{/* Running Jobs */}
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
{runningJobs.length > 0 && (
<span className="ml-2 text-sm font-normal text-wing-accent">
({runningJobs.length})
</span>
)}
</h2>
{runningJobs.length === 0 ? (
<EmptyState
icon="💤"
message="현재 실행 중인 작업이 없습니다."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{runningJobs.map((job) => (
<tr key={job.executionId} className="border-b border-wing-border/50">
<td className="py-3 font-medium text-wing-text">{job.jobName}</td>
<td className="py-3 text-wing-muted">#{job.executionId}</td>
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
<td className="py-3">
<StatusBadge status={job.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* Recent Executions */}
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text"> </h2>
<Link
to="/executions"
className="text-sm text-wing-accent hover:text-wing-accent no-underline"
>
&rarr;
</Link>
</div>
{recentExecutions.length === 0 ? (
<EmptyState
icon="📋"
message="실행 이력이 없습니다."
sub="작업을 실행하면 여기에 표시됩니다."
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border text-left text-wing-muted">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"></th>
</tr>
</thead>
<tbody>
{recentExecutions.slice(0, 5).map((exec) => (
<tr key={exec.executionId} className="border-b border-wing-border/50">
<td className="py-3">
<Link
to={`/executions/${exec.executionId}`}
className="text-wing-accent hover:text-wing-accent no-underline font-medium"
>
#{exec.executionId}
</Link>
</td>
<td className="py-3 text-wing-text">{exec.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
<td className="py-3 text-wing-muted">
{calculateDuration(exec.startTime, exec.endTime)}
</td>
<td className="py-3">
<StatusBadge status={exec.status} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* F6: Recent Failures */}
{recentFailures.length > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6 border border-red-200">
<h2 className="text-lg font-semibold text-red-700 mb-4">
<span className="ml-2 text-sm font-normal text-red-500">
({recentFailures.length})
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-red-200 text-left text-red-500">
<th className="pb-2 font-medium"> ID</th>
<th className="pb-2 font-medium"></th>
<th className="pb-2 font-medium"> </th>
<th className="pb-2 font-medium"> </th>
</tr>
</thead>
<tbody>
{recentFailures.map((fail) => (
<tr key={fail.executionId} className="border-b border-red-100">
<td className="py-3">
<Link
to={`/executions/${fail.executionId}`}
className="text-red-600 hover:text-red-800 no-underline font-medium"
>
#{fail.executionId}
</Link>
</td>
<td className="py-3 text-wing-text">{fail.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
{fail.exitMessage
? fail.exitMessage.length > 50
? `${fail.exitMessage.slice(0, 50)}...`
: fail.exitMessage
: '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
{/* F8: Execution Statistics Chart */}
{statistics && statistics.dailyStats.length > 0 && (
<section className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-wing-text">
( 30)
</h2>
<div className="flex gap-4 text-xs text-wing-muted">
<span>
<strong className="text-wing-text">{statistics.totalExecutions}</strong>
</span>
<span>
<strong className="text-emerald-600">{statistics.totalSuccess}</strong>
</span>
<span>
<strong className="text-red-600">{statistics.totalFailed}</strong>
</span>
</div>
</div>
<BarChart
data={statistics.dailyStats.map((d) => ({
label: d.date.slice(5),
values: [
{ color: 'green', value: d.successCount },
{ color: 'red', value: d.failedCount },
{ color: 'gray', value: d.otherCount },
],
}))}
height={180}
/>
<div className="flex gap-4 mt-3 text-xs text-wing-muted justify-end">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-emerald-500" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-red-500" />
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm bg-gray-400" />
</span>
</div>
</section>
)}
{/* Execute Job Modal */}
{showExecuteModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setShowExecuteModal(false)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-4"> </h3>
<label className="block text-sm font-medium text-wing-text mb-2">
</label>
<select
value={selectedJob}
onChange={(e) => setSelectedJob(e.target.value)}
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
>
{jobs.map((job) => (
<option key={job} value={job}>{job}</option>
))}
</select>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-wing-muted font-normal">()</span>
</label>
<input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-3"
/>
<label className="block text-sm font-medium text-wing-text mb-1">
<span className="text-wing-muted font-normal">()</span>
</label>
<input
type="datetime-local"
value={stopDate}
onChange={(e) => setStopDate(e.target.value)}
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent mb-4"
/>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowExecuteModal(false)}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
hover:bg-wing-hover transition-colors"
>
</button>
<button
onClick={handleExecuteJob}
disabled={executing || !selectedJob}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{executing ? '실행 중...' : '실행'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,335 @@
import { useState, useCallback } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { batchApi, type JobExecutionDetailDto, type StepExecutionDto } from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
const POLLING_INTERVAL_MS = 5000;
interface StatCardProps {
label: string;
value: number;
gradient: string;
icon: string;
}
function StatCard({ label, value, gradient, icon }: StatCardProps) {
return (
<div
className={`rounded-xl p-5 text-white shadow-md ${gradient}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}
interface StepCardProps {
step: StepExecutionDto;
}
function StepCard({ step }: StepCardProps) {
const stats = [
{ label: '읽기', value: step.readCount },
{ label: '쓰기', value: step.writeCount },
{ label: '커밋', value: step.commitCount },
{ label: '롤백', value: step.rollbackCount },
{ label: '읽기 건너뜀', value: step.readSkipCount },
{ label: '처리 건너뜀', value: step.processSkipCount },
{ label: '쓰기 건너뜀', value: step.writeSkipCount },
{ label: '필터', value: step.filterCount },
];
return (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-wing-text">
{step.stepName}
</h3>
<StatusBadge status={step.status} />
</div>
<span className="text-sm text-wing-muted">
{step.duration != null
? formatDuration(step.duration)
: calculateDuration(step.startTime, step.endTime)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
</div>
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div
key={label}
className="rounded-lg bg-wing-card px-3 py-2 text-center"
>
<p className="text-lg font-bold text-wing-text">
{value.toLocaleString()}
</p>
<p className="text-xs text-wing-muted">{label}</p>
</div>
))}
</div>
{/* API 호출 정보 */}
{step.apiCallInfo && (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-blue-500">URL:</span>{' '}
<span className="text-blue-900 font-mono break-all">{step.apiCallInfo.apiUrl}</span>
</div>
<div>
<span className="text-blue-500">Method:</span>{' '}
<span className="text-blue-900 font-semibold">{step.apiCallInfo.method}</span>
</div>
<div>
<span className="text-blue-500">:</span>{' '}
<span className="text-blue-900">{step.apiCallInfo.completedCalls} / {step.apiCallInfo.totalCalls}</span>
</div>
{step.apiCallInfo.lastCallTime && (
<div>
<span className="text-blue-500">:</span>{' '}
<span className="text-blue-900">{step.apiCallInfo.lastCallTime}</span>
</div>
)}
</div>
</div>
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
{step.exitMessage}
</p>
</div>
)}
</div>
);
}
export default function ExecutionDetail() {
const { id: paramId } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const executionId = paramId
? Number(paramId)
: Number(searchParams.get('id'));
const [detail, setDetail] = useState<JobExecutionDetailDto | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isRunning = detail
? detail.status === 'STARTED' || detail.status === 'STARTING'
: false;
const loadDetail = useCallback(async () => {
if (!executionId || isNaN(executionId)) {
setError('유효하지 않은 실행 ID입니다.');
setLoading(false);
return;
}
try {
const data = await batchApi.getExecutionDetail(executionId);
setDetail(data);
setError(null);
} catch (err) {
setError(
err instanceof Error
? err.message
: '실행 상세 정보를 불러오지 못했습니다.',
);
} finally {
setLoading(false);
}
}, [executionId]);
/* 실행중인 경우 5초 폴링, 완료 후에는 1회 로드로 충분하지만 폴링 유지 */
usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [
executionId,
]);
if (loading) return <LoadingSpinner />;
if (error || !detail) {
return (
<div className="space-y-4">
<button
onClick={() => navigate('/executions')}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
message={error || '실행 정보를 찾을 수 없습니다.'}
/>
</div>
);
}
const jobParams = Object.entries(detail.jobParameters);
return (
<div className="space-y-6">
{/* 상단 내비게이션 */}
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
{/* Job 기본 정보 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">
#{detail.executionId}
</h1>
<p className="mt-1 text-sm text-wing-muted">
{detail.jobName}
</p>
</div>
<StatusBadge status={detail.status} className="text-sm" />
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<InfoItem label="시작시간" value={formatDateTime(detail.startTime)} />
<InfoItem label="종료시간" value={formatDateTime(detail.endTime)} />
<InfoItem
label="소요시간"
value={
detail.duration != null
? formatDuration(detail.duration)
: calculateDuration(detail.startTime, detail.endTime)
}
/>
<InfoItem label="Exit Code" value={detail.exitCode} />
{detail.exitMessage && (
<div className="sm:col-span-2 lg:col-span-3">
<InfoItem label="Exit Message" value={detail.exitMessage} />
</div>
)}
</div>
</div>
{/* 실행 통계 카드 4개 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="읽기 (Read)"
value={detail.readCount}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<StatCard
label="쓰기 (Write)"
value={detail.writeCount}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<StatCard
label="건너뜀 (Skip)"
value={detail.skipCount}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<StatCard
label="필터 (Filter)"
value={detail.filterCount}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F50D;"
/>
</div>
{/* Job Parameters */}
{jobParams.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
Job Parameters
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium">Key</th>
<th className="px-6 py-3 font-medium">Value</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{jobParams.map(([key, value]) => (
<tr
key={key}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-3 font-mono text-wing-text">
{key}
</td>
<td className="px-6 py-3 text-wing-muted break-all">
{value}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Step 실행 정보 */}
<div>
<h2 className="text-lg font-semibold text-wing-text mb-4">
Step
<span className="ml-2 text-sm font-normal text-wing-muted">
({detail.stepExecutions.length})
</span>
</h2>
{detail.stepExecutions.length === 0 ? (
<EmptyState message="Step 실행 정보가 없습니다." />
) : (
<div className="space-y-4">
{detail.stepExecutions.map((step) => (
<StepCard
key={step.stepExecutionId}
step={step}
/>
))}
</div>
)}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
</div>
);
}

파일 보기

@ -0,0 +1,587 @@
import { useState, useMemo, useCallback } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi';
import { formatDateTime, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import StatusBadge from '../components/StatusBadge';
import ConfirmModal from '../components/ConfirmModal';
import InfoModal from '../components/InfoModal';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED' | 'STOPPED';
const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [
{ value: 'ALL', label: '전체' },
{ value: 'COMPLETED', label: '완료' },
{ value: 'FAILED', label: '실패' },
{ value: 'STARTED', label: '실행중' },
{ value: 'STOPPED', label: '중지됨' },
];
const POLLING_INTERVAL_MS = 5000;
const RECENT_LIMIT = 50;
const PAGE_SIZE = 50;
export default function Executions() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const jobFromQuery = searchParams.get('job') || '';
const [jobs, setJobs] = useState<string[]>([]);
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('ALL');
const [loading, setLoading] = useState(true);
const [stopTarget, setStopTarget] = useState<JobExecutionDto | null>(null);
// F1: 강제 종료
const [abandonTarget, setAbandonTarget] = useState<JobExecutionDto | null>(null);
// F4: 날짜 범위 필터 + 페이지네이션
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [useSearch, setUseSearch] = useState(false);
// F9: 실패 로그 뷰어
const [failLogTarget, setFailLogTarget] = useState<JobExecutionDto | null>(null);
const { showToast } = useToastContext();
const loadJobs = useCallback(async () => {
try {
const data = await batchApi.getJobs();
setJobs(data);
} catch {
/* Job 목록 로드 실패는 무시 */
}
}, []);
const loadSearchExecutions = useCallback(async (targetPage: number) => {
try {
setLoading(true);
const params: {
jobNames?: string[];
status?: string;
startDate?: string;
endDate?: string;
page?: number;
size?: number;
} = {
page: targetPage,
size: PAGE_SIZE,
};
if (selectedJobs.length > 0) params.jobNames = selectedJobs;
if (statusFilter !== 'ALL') params.status = statusFilter;
if (startDate) params.startDate = `${startDate}T00:00:00`;
if (endDate) params.endDate = `${endDate}T23:59:59`;
const data: ExecutionSearchResponse = await batchApi.searchExecutions(params);
setExecutions(data.executions);
setTotalPages(data.totalPages);
setTotalCount(data.totalCount);
setPage(data.page);
} catch {
setExecutions([]);
setTotalPages(0);
setTotalCount(0);
} finally {
setLoading(false);
}
}, [selectedJobs, statusFilter, startDate, endDate]);
const loadExecutions = useCallback(async () => {
// 검색 모드에서는 폴링하지 않음 (검색 버튼 클릭 시에만 1회 조회)
if (useSearch) return;
try {
let data: JobExecutionDto[];
if (selectedJobs.length === 1) {
data = await batchApi.getJobExecutions(selectedJobs[0]);
} else if (selectedJobs.length > 1) {
// 복수 Job 선택 시 search API 사용
const result = await batchApi.searchExecutions({
jobNames: selectedJobs, size: RECENT_LIMIT,
});
data = result.executions;
} else {
try {
data = await batchApi.getRecentExecutions(RECENT_LIMIT);
} catch {
data = [];
}
}
setExecutions(data);
} catch {
setExecutions([]);
} finally {
setLoading(false);
}
}, [selectedJobs, useSearch, page, loadSearchExecutions]);
/* 마운트 시 Job 목록 1회 로드 */
usePoller(loadJobs, 60_000, []);
/* 실행 이력 5초 폴링 */
usePoller(loadExecutions, POLLING_INTERVAL_MS, [selectedJobs, useSearch, page]);
const filteredExecutions = useMemo(() => {
// 검색 모드에서는 서버 필터링 사용
if (useSearch) return executions;
if (statusFilter === 'ALL') return executions;
return executions.filter((e) => e.status === statusFilter);
}, [executions, statusFilter, useSearch]);
const toggleJob = (jobName: string) => {
setSelectedJobs((prev) => {
const next = prev.includes(jobName)
? prev.filter((j) => j !== jobName)
: [...prev, jobName];
if (next.length === 1) {
setSearchParams({ job: next[0] });
} else {
setSearchParams({});
}
return next;
});
setLoading(true);
if (useSearch) {
setPage(0);
}
};
const clearSelectedJobs = () => {
setSelectedJobs([]);
setSearchParams({});
setLoading(true);
if (useSearch) {
setPage(0);
}
};
const handleStop = async () => {
if (!stopTarget) return;
try {
const result = await batchApi.stopExecution(stopTarget.executionId);
showToast(result.message || '실행이 중지되었습니다.', 'success');
} catch (err) {
showToast(
err instanceof Error ? err.message : '중지 요청에 실패했습니다.',
'error',
);
} finally {
setStopTarget(null);
}
};
// F1: 강제 종료 핸들러
const handleAbandon = async () => {
if (!abandonTarget) return;
try {
const result = await batchApi.abandonExecution(abandonTarget.executionId);
showToast(result.message || '실행이 강제 종료되었습니다.', 'success');
} catch (err) {
showToast(
err instanceof Error ? err.message : '강제 종료 요청에 실패했습니다.',
'error',
);
} finally {
setAbandonTarget(null);
}
};
// F4: 검색 핸들러
const handleSearch = async () => {
setUseSearch(true);
setPage(0);
await loadSearchExecutions(0);
};
// F4: 초기화 핸들러
const handleResetSearch = () => {
setUseSearch(false);
setStartDate('');
setEndDate('');
setPage(0);
setTotalPages(0);
setTotalCount(0);
setLoading(true);
};
// F4: 페이지 이동 핸들러
const handlePageChange = (newPage: number) => {
if (newPage < 0 || newPage >= totalPages) return;
setPage(newPage);
loadSearchExecutions(newPage);
};
const isRunning = (status: string) =>
status === 'STARTED' || status === 'STARTING';
return (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<p className="mt-1 text-sm text-wing-muted">
.
</p>
</div>
{/* 필터 영역 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-3">
{/* Job 멀티 선택 */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setJobDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
>
{selectedJobs.length === 0
? `전체 (최근 ${RECENT_LIMIT}건)`
: `${selectedJobs.length}개 선택됨`}
<svg className={`w-4 h-4 text-wing-muted transition-transform ${jobDropdownOpen ? '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>
{jobDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setJobDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
{jobs.map((job) => (
<label
key={job}
className="flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-wing-hover transition-colors"
>
<input
type="checkbox"
checked={selectedJobs.includes(job)}
onChange={() => toggleJob(job)}
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
/>
<span className="text-wing-text truncate">{job}</span>
</label>
))}
</div>
</>
)}
</div>
{selectedJobs.length > 0 && (
<button
onClick={clearSelectedJobs}
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
>
</button>
)}
</div>
{/* 선택된 Job 칩 */}
{selectedJobs.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{selectedJobs.map((job) => (
<span
key={job}
className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full"
>
{job}
<button
onClick={() => toggleJob(job)}
className="hover:text-wing-text transition-colors"
>
&times;
</button>
</span>
))}
</div>
)}
</div>
{/* 상태 필터 버튼 그룹 */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map(({ value, label }) => (
<button
key={value}
onClick={() => {
setStatusFilter(value);
if (useSearch) {
setPage(0);
}
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === value
? 'bg-wing-accent text-white shadow-sm'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* F4: 날짜 범위 필터 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
<span className="text-wing-muted text-sm">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSearch}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
>
</button>
{useSearch && (
<button
onClick={handleResetSearch}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
)}
</div>
</div>
</div>
{/* 실행 이력 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
{loading ? (
<LoadingSpinner />
) : filteredExecutions.length === 0 ? (
<EmptyState
message="실행 이력이 없습니다."
sub={
statusFilter !== 'ALL'
? '다른 상태 필터를 선택해 보세요.'
: selectedJobs.length > 0
? '선택한 작업의 실행 이력이 없습니다.'
: undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-6 py-3 font-medium"> ID</th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium"></th>
<th className="px-6 py-3 font-medium text-right">
</th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{filteredExecutions.map((exec) => (
<tr
key={exec.executionId}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-6 py-4 font-mono text-wing-text">
#{exec.executionId}
</td>
<td className="px-6 py-4 text-wing-text">
{exec.jobName}
</td>
<td className="px-6 py-4">
{/* F9: FAILED 상태 클릭 시 실패 로그 모달 */}
{exec.status === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(exec)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={exec.status} />
</button>
) : (
<StatusBadge status={exec.status} />
)}
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{formatDateTime(exec.startTime)}
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{formatDateTime(exec.endTime)}
</td>
<td className="px-6 py-4 text-wing-muted whitespace-nowrap">
{calculateDuration(
exec.startTime,
exec.endTime,
)}
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{isRunning(exec.status) ? (
<>
<button
onClick={() =>
setStopTarget(exec)
}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
</button>
{/* F1: 강제 종료 버튼 */}
<button
onClick={() =>
setAbandonTarget(exec)
}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-amber-700 bg-amber-50 rounded-lg hover:bg-amber-100 transition-colors"
>
</button>
</>
) : (
<button
onClick={() =>
navigate(
`/executions/${exec.executionId}`,
)
}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 결과 건수 표시 + F4: 페이지네이션 */}
{!loading && filteredExecutions.length > 0 && (
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
<div className="text-xs text-wing-muted">
{useSearch ? (
<> {totalCount}</>
) : (
<>
{filteredExecutions.length}
{statusFilter !== 'ALL' && (
<span className="ml-1">
( {executions.length} )
</span>
)}
</>
)}
</div>
{/* F4: 페이지네이션 UI */}
{useSearch && totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
<span className="text-xs text-wing-muted">
{page + 1} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
</div>
)}
</div>
)}
</div>
{/* 중지 확인 모달 */}
<ConfirmModal
open={stopTarget !== null}
title="실행 중지"
message={
stopTarget
? `실행 #${stopTarget.executionId} (${stopTarget.jobName})을 중지하시겠습니까?`
: ''
}
confirmLabel="중지"
confirmColor="bg-red-600 hover:bg-red-700"
onConfirm={handleStop}
onCancel={() => setStopTarget(null)}
/>
{/* F1: 강제 종료 확인 모달 */}
<ConfirmModal
open={abandonTarget !== null}
title="강제 종료"
message={
abandonTarget
? `실행 #${abandonTarget.executionId} (${abandonTarget.jobName})을 강제 종료하시겠습니까?\n\n강제 종료는 실행 상태를 ABANDONED로 변경합니다.`
: ''
}
confirmLabel="강제 종료"
confirmColor="bg-amber-600 hover:bg-amber-700"
onConfirm={handleAbandon}
onCancel={() => setAbandonTarget(null)}
/>
{/* F9: 실패 로그 뷰어 모달 */}
<InfoModal
open={failLogTarget !== null}
title={
failLogTarget
? `실패 로그 - #${failLogTarget.executionId} (${failLogTarget.jobName})`
: '실패 로그'
}
onClose={() => setFailLogTarget(null)}
>
{failLogTarget && (
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
Exit Code
</h4>
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
{failLogTarget.exitCode || '-'}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
Exit Message
</h4>
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{failLogTarget.exitMessage || '메시지 없음'}
</pre>
</div>
</div>
)}
</InfoModal>
</div>
);
}

273
frontend/src/pages/Jobs.tsx Normal file
파일 보기

@ -0,0 +1,273 @@
import { useState, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { batchApi } from '../api/batchApi';
import type { JobDetailDto } from '../api/batchApi';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import StatusBadge from '../components/StatusBadge';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import { formatDateTime } from '../utils/formatters';
const POLLING_INTERVAL = 30000;
export default function Jobs() {
const navigate = useNavigate();
const { showToast } = useToastContext();
const [jobs, setJobs] = useState<JobDetailDto[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
// Execute modal
const [executeModalOpen, setExecuteModalOpen] = useState(false);
const [targetJob, setTargetJob] = useState('');
const [executing, setExecuting] = useState(false);
const [startDate, setStartDate] = useState('');
const [stopDate, setStopDate] = useState('');
const loadJobs = useCallback(async () => {
try {
const data = await batchApi.getJobsDetail();
setJobs(data);
} catch (err) {
console.error('Jobs load failed:', err);
} finally {
setLoading(false);
}
}, []);
usePoller(loadJobs, POLLING_INTERVAL);
const filteredJobs = useMemo(() => {
if (!searchTerm.trim()) return jobs;
const term = searchTerm.toLowerCase();
return jobs.filter((job) => job.jobName.toLowerCase().includes(term));
}, [jobs, searchTerm]);
const handleExecuteClick = (jobName: string) => {
setTargetJob(jobName);
setStartDate('');
setStopDate('');
setExecuteModalOpen(true);
};
const handleConfirmExecute = async () => {
if (!targetJob) return;
setExecuting(true);
try {
const params: Record<string, string> = {};
if (startDate) params.startDate = startDate;
if (stopDate) params.stopDate = stopDate;
const result = await batchApi.executeJob(
targetJob,
Object.keys(params).length > 0 ? params : undefined,
);
showToast(
result.message || `${targetJob} 실행 요청 완료`,
'success',
);
setExecuteModalOpen(false);
} catch (err) {
showToast(
`실행 실패: ${err instanceof Error ? err.message : '알 수 없는 오류'}`,
'error',
);
} finally {
setExecuting(false);
}
};
const handleViewHistory = (jobName: string) => {
navigate(`/executions?job=${encodeURIComponent(jobName)}`);
};
if (loading) return <LoadingSpinner />;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<span className="text-sm text-wing-muted">
{jobs.length}
</span>
</div>
{/* Search Filter */}
<div className="bg-wing-surface rounded-xl shadow-md p-4">
<div className="relative">
<span className="absolute inset-y-0 left-3 flex items-center text-wing-muted">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</span>
<input
type="text"
placeholder="작업명 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute inset-y-0 right-3 flex items-center text-wing-muted hover:text-wing-text"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{searchTerm && (
<p className="mt-2 text-xs text-wing-muted">
{filteredJobs.length}
</p>
)}
</div>
{/* Job Cards Grid */}
{filteredJobs.length === 0 ? (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<EmptyState
icon="🔍"
message={searchTerm ? '검색 결과가 없습니다.' : '등록된 작업이 없습니다.'}
sub={searchTerm ? '다른 검색어를 입력해 보세요.' : undefined}
/>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredJobs.map((job) => (
<div
key={job.jobName}
className="bg-wing-surface rounded-xl shadow-md p-6
hover:shadow-lg hover:-translate-y-0.5 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="text-sm font-semibold text-wing-text break-all leading-tight">
{job.jobName}
</h3>
{job.lastExecution && (
<StatusBadge status={job.lastExecution.status} className="ml-2 shrink-0" />
)}
</div>
{/* Job detail info */}
<div className="mb-4 space-y-1">
{job.lastExecution ? (
<p className="text-xs text-wing-muted">
: {formatDateTime(job.lastExecution.startTime)}
</p>
) : (
<p className="text-wing-muted text-xs"> </p>
)}
{job.scheduleCron && (
<p className="text-xs text-wing-muted">
:{' '}
<span className="font-mono text-xs bg-wing-card px-2 py-0.5 rounded">
{job.scheduleCron}
</span>
</p>
)}
</div>
<div className="flex gap-2">
<button
onClick={() => handleExecuteClick(job.jobName)}
className="flex-1 px-3 py-2 text-xs font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors"
>
</button>
<button
onClick={() => handleViewHistory(job.jobName)}
className="flex-1 px-3 py-2 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg
hover:bg-wing-accent/15 transition-colors"
>
</button>
</div>
</div>
))}
</div>
)}
{/* Execute Modal (custom with date params) */}
{executeModalOpen && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-wing-overlay"
onClick={() => setExecuteModalOpen(false)}
>
<div
className="bg-wing-surface rounded-xl shadow-2xl p-6 max-w-md w-full mx-4"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold text-wing-text mb-2"> </h3>
<p className="text-wing-muted text-sm mb-4">
&quot;{targetJob}&quot; ?
</p>
{/* Date parameters */}
<div className="space-y-3 mb-6">
<div>
<label className="block text-xs font-medium text-wing-text mb-1">
()
</label>
<input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
/>
</div>
<div>
<label className="block text-xs font-medium text-wing-text mb-1">
()
</label>
<input
type="datetime-local"
value={stopDate}
onChange={(e) => setStopDate(e.target.value)}
className="w-full px-3 py-2 border border-wing-border rounded-lg text-sm
focus:ring-2 focus:ring-wing-accent focus:border-wing-accent outline-none"
/>
</div>
{(startDate || stopDate) && (
<p className="text-xs text-wing-muted">
.
</p>
)}
</div>
<div className="flex justify-end gap-3">
<button
onClick={() => setExecuteModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg
hover:bg-wing-hover transition-colors"
>
</button>
<button
onClick={handleConfirmExecute}
disabled={executing}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg
hover:bg-wing-accent/80 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{executing ? '실행 중...' : '실행'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,490 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { batchApi, type ScheduleResponse } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters';
import { useToastContext } from '../contexts/ToastContext';
import ConfirmModal from '../components/ConfirmModal';
import EmptyState from '../components/EmptyState';
import LoadingSpinner from '../components/LoadingSpinner';
import { getNextExecutions } from '../utils/cronPreview';
type ScheduleMode = 'new' | 'existing';
interface ConfirmAction {
type: 'toggle' | 'delete';
schedule: ScheduleResponse;
}
const CRON_PRESETS = [
{ label: '매 분', cron: '0 * * * * ?' },
{ label: '매시 정각', cron: '0 0 * * * ?' },
{ label: '매 15분', cron: '0 0/15 * * * ?' },
{ label: '매일 00:00', cron: '0 0 0 * * ?' },
{ label: '매일 12:00', cron: '0 0 12 * * ?' },
{ label: '매주 월 00:00', cron: '0 0 0 ? * MON' },
];
function CronPreview({ cron }: { cron: string }) {
const nextDates = useMemo(() => getNextExecutions(cron, 5), [cron]);
if (nextDates.length === 0) {
return (
<div className="md:col-span-2">
<p className="text-xs text-wing-muted"> ( )</p>
</div>
);
}
const fmt = new Intl.DateTimeFormat('ko-KR', {
month: '2-digit',
day: '2-digit',
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
return (
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
5
</label>
<div className="flex flex-wrap gap-2">
{nextDates.map((d, i) => (
<span
key={i}
className="inline-block bg-wing-accent/10 text-wing-accent text-xs font-mono px-2 py-1 rounded"
>
{fmt.format(d)}
</span>
))}
</div>
</div>
);
}
function getTriggerStateStyle(state: string | null): string {
switch (state) {
case 'NORMAL':
return 'bg-emerald-100 text-emerald-700';
case 'PAUSED':
return 'bg-amber-100 text-amber-700';
case 'BLOCKED':
return 'bg-red-100 text-red-700';
case 'ERROR':
return 'bg-red-100 text-red-700';
default:
return 'bg-wing-card text-wing-muted';
}
}
export default function Schedules() {
const { showToast } = useToastContext();
// Form state
const [jobs, setJobs] = useState<string[]>([]);
const [selectedJob, setSelectedJob] = useState('');
const [cronExpression, setCronExpression] = useState('');
const [description, setDescription] = useState('');
const [scheduleMode, setScheduleMode] = useState<ScheduleMode>('new');
const [formLoading, setFormLoading] = useState(false);
const [saving, setSaving] = useState(false);
// Schedule list state
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
const [listLoading, setListLoading] = useState(true);
// Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
// 폼 영역 ref (편집 버튼 클릭 시 스크롤)
const formRef = useRef<HTMLDivElement>(null);
const loadSchedules = useCallback(async () => {
try {
const result = await batchApi.getSchedules();
setSchedules(result.schedules);
} catch (err) {
showToast('스케줄 목록 조회 실패', 'error');
console.error(err);
} finally {
setListLoading(false);
}
}, [showToast]);
const loadJobs = useCallback(async () => {
try {
const result = await batchApi.getJobs();
setJobs(result);
} catch (err) {
showToast('작업 목록 조회 실패', 'error');
console.error(err);
}
}, [showToast]);
useEffect(() => {
loadJobs();
loadSchedules();
}, [loadJobs, loadSchedules]);
const handleJobSelect = async (jobName: string) => {
setSelectedJob(jobName);
setCronExpression('');
setDescription('');
setScheduleMode('new');
if (!jobName) return;
setFormLoading(true);
try {
const schedule = await batchApi.getSchedule(jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
} catch {
// 404 = new schedule
setScheduleMode('new');
} finally {
setFormLoading(false);
}
};
const handleSave = async () => {
if (!selectedJob) {
showToast('작업을 선택해주세요', 'error');
return;
}
if (!cronExpression.trim()) {
showToast('Cron 표현식을 입력해주세요', 'error');
return;
}
setSaving(true);
try {
if (scheduleMode === 'existing') {
await batchApi.updateSchedule(selectedJob, {
cronExpression: cronExpression.trim(),
description: description.trim() || undefined,
});
showToast('스케줄이 수정되었습니다', 'success');
} else {
await batchApi.createSchedule({
jobName: selectedJob,
cronExpression: cronExpression.trim(),
description: description.trim() || undefined,
});
showToast('스케줄이 등록되었습니다', 'success');
}
await loadSchedules();
// Reload schedule info for current job
await handleJobSelect(selectedJob);
} catch (err) {
const message = err instanceof Error ? err.message : '저장 실패';
showToast(message, 'error');
} finally {
setSaving(false);
}
};
const handleToggle = async (schedule: ScheduleResponse) => {
try {
await batchApi.toggleSchedule(schedule.jobName, !schedule.active);
showToast(
`${schedule.jobName} 스케줄이 ${schedule.active ? '비활성화' : '활성화'}되었습니다`,
'success',
);
await loadSchedules();
} catch (err) {
const message = err instanceof Error ? err.message : '토글 실패';
showToast(message, 'error');
}
setConfirmAction(null);
};
const handleDelete = async (schedule: ScheduleResponse) => {
try {
await batchApi.deleteSchedule(schedule.jobName);
showToast(`${schedule.jobName} 스케줄이 삭제되었습니다`, 'success');
await loadSchedules();
// Clear form if deleted schedule was selected
if (selectedJob === schedule.jobName) {
setSelectedJob('');
setCronExpression('');
setDescription('');
setScheduleMode('new');
}
} catch (err) {
const message = err instanceof Error ? err.message : '삭제 실패';
showToast(message, 'error');
}
setConfirmAction(null);
};
const handleEditFromCard = (schedule: ScheduleResponse) => {
setSelectedJob(schedule.jobName);
setCronExpression(schedule.cronExpression);
setDescription(schedule.description ?? '');
setScheduleMode('existing');
formRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="space-y-6">
{/* Form Section */}
<div ref={formRef} className="bg-wing-surface rounded-xl shadow-lg p-6">
<h2 className="text-lg font-bold text-wing-text mb-4"> / </h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Job Select */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex items-center gap-2">
<select
value={selectedJob}
onChange={(e) => handleJobSelect(e.target.value)}
className="flex-1 rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={formLoading}
>
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{job}
</option>
))}
</select>
{selectedJob && (
<span
className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold whitespace-nowrap ${
scheduleMode === 'existing'
? 'bg-blue-100 text-blue-700'
: 'bg-green-100 text-green-700'
}`}
>
{scheduleMode === 'existing' ? '기존 스케줄' : '새 스케줄'}
</span>
)}
{formLoading && (
<div className="w-5 h-5 border-2 border-wing-accent/30 border-t-wing-accent rounded-full animate-spin" />
)}
</div>
</div>
{/* Cron Expression */}
<div>
<label className="block text-sm font-medium text-wing-text mb-1">
Cron
</label>
<input
type="text"
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 0/15 * * * ?"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
{/* Cron Presets */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<div className="flex flex-wrap gap-2">
{CRON_PRESETS.map(({ label, cron }) => (
<button
key={cron}
type="button"
onClick={() => setCronExpression(cron)}
disabled={!selectedJob || formLoading}
className="px-3 py-1 text-xs font-medium bg-wing-card text-wing-text rounded-lg hover:bg-wing-accent/15 hover:text-wing-accent transition-colors disabled:opacity-50"
>
{label}
</button>
))}
</div>
</div>
{/* Cron Preview */}
{cronExpression.trim() && (
<CronPreview cron={cronExpression.trim()} />
)}
{/* Description */}
<div className="md:col-span-2">
<label className="block text-sm font-medium text-wing-text mb-1">
</label>
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="스케줄 설명 (선택)"
className="w-full rounded-lg border border-wing-border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-wing-accent focus:border-wing-accent"
disabled={!selectedJob || formLoading}
/>
</div>
</div>
{/* Save Button */}
<div className="mt-4 flex justify-end">
<button
onClick={handleSave}
disabled={!selectedJob || !cronExpression.trim() || saving || formLoading}
className="px-6 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
{/* Schedule List */}
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-bold text-wing-text">
{schedules.length > 0 && (
<span className="ml-2 text-sm font-normal text-wing-muted">
({schedules.length})
</span>
)}
</h2>
<button
onClick={loadSchedules}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
{listLoading ? (
<LoadingSpinner />
) : schedules.length === 0 ? (
<EmptyState message="등록된 스케줄이 없습니다" sub="위 폼에서 새 스케줄을 등록하세요" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{schedules.map((schedule) => (
<div
key={schedule.id}
className="border border-wing-border rounded-xl p-4 hover:shadow-md transition-shadow"
>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-bold text-wing-text truncate">
{schedule.jobName}
</h3>
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${
schedule.active
? 'bg-emerald-100 text-emerald-700'
: 'bg-wing-card text-wing-muted'
}`}
>
{schedule.active ? '활성' : '비활성'}
</span>
{schedule.triggerState && (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${getTriggerStateStyle(schedule.triggerState)}`}
>
{schedule.triggerState}
</span>
)}
</div>
</div>
{/* Cron Expression */}
<div className="mb-2">
<span className="inline-block bg-wing-card text-wing-text font-mono text-xs px-2 py-1 rounded">
{schedule.cronExpression}
</span>
</div>
{/* Description */}
{schedule.description && (
<p className="text-sm text-wing-muted mb-3">{schedule.description}</p>
)}
{/* Time Info */}
<div className="grid grid-cols-2 gap-2 text-xs text-wing-muted mb-3">
<div>
<span className="font-medium text-wing-muted"> :</span>{' '}
{formatDateTime(schedule.nextFireTime)}
</div>
<div>
<span className="font-medium text-wing-muted"> :</span>{' '}
{formatDateTime(schedule.previousFireTime)}
</div>
<div>
<span className="font-medium text-wing-muted">:</span>{' '}
{formatDateTime(schedule.createdAt)}
</div>
<div>
<span className="font-medium text-wing-muted">:</span>{' '}
{formatDateTime(schedule.updatedAt)}
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2 pt-2 border-t border-wing-border/50">
<button
onClick={() => handleEditFromCard(schedule)}
className="flex-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/20 transition-colors"
>
</button>
<button
onClick={() =>
setConfirmAction({ type: 'toggle', schedule })
}
className={`flex-1 px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
schedule.active
? 'text-amber-700 bg-amber-50 hover:bg-amber-100'
: 'text-emerald-700 bg-emerald-50 hover:bg-emerald-100'
}`}
>
{schedule.active ? '비활성화' : '활성화'}
</button>
<button
onClick={() =>
setConfirmAction({ type: 'delete', schedule })
}
className="flex-1 px-3 py-1.5 text-xs font-medium text-red-700 bg-red-50 rounded-lg hover:bg-red-100 transition-colors"
>
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Confirm Modal */}
{confirmAction?.type === 'toggle' && (
<ConfirmModal
open
title="스케줄 상태 변경"
message={`${confirmAction.schedule.jobName} 스케줄을 ${
confirmAction.schedule.active ? '비활성화' : '활성화'
}?`}
confirmLabel={confirmAction.schedule.active ? '비활성화' : '활성화'}
onConfirm={() => handleToggle(confirmAction.schedule)}
onCancel={() => setConfirmAction(null)}
/>
)}
{confirmAction?.type === 'delete' && (
<ConfirmModal
open
title="스케줄 삭제"
message={`${confirmAction.schedule.jobName} 스케줄을 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`}
confirmLabel="삭제"
confirmColor="bg-red-600 hover:bg-red-700"
onConfirm={() => handleDelete(confirmAction.schedule)}
onCancel={() => setConfirmAction(null)}
/>
)}
</div>
);
}

파일 보기

@ -0,0 +1,466 @@
import { useState, useCallback, useRef, useEffect } from 'react';
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
import { formatDateTime, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
import { getStatusColor } from '../components/StatusBadge';
import StatusBadge from '../components/StatusBadge';
import LoadingSpinner from '../components/LoadingSpinner';
import EmptyState from '../components/EmptyState';
type ViewType = 'day' | 'week' | 'month';
interface TooltipData {
jobName: string;
period: PeriodInfo;
execution: ExecutionInfo;
x: number;
y: number;
}
interface SelectedCell {
jobName: string;
periodKey: string;
periodLabel: string;
}
const VIEW_OPTIONS: { value: ViewType; label: string }[] = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
];
const LEGEND_ITEMS = [
{ status: 'COMPLETED', color: '#10b981', label: '완료' },
{ status: 'FAILED', color: '#ef4444', label: '실패' },
{ status: 'STARTED', color: '#3b82f6', label: '실행중' },
{ status: 'SCHEDULED', color: '#8b5cf6', label: '예정' },
{ status: 'NONE', color: '#e5e7eb', label: '없음' },
];
const JOB_COL_WIDTH = 200;
const CELL_MIN_WIDTH = 80;
const POLLING_INTERVAL = 30000;
function formatDateStr(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function shiftDate(date: Date, view: ViewType, delta: number): Date {
const next = new Date(date);
switch (view) {
case 'day':
next.setDate(next.getDate() + delta);
break;
case 'week':
next.setDate(next.getDate() + delta * 7);
break;
case 'month':
next.setMonth(next.getMonth() + delta);
break;
}
return next;
}
function isRunning(status: string): boolean {
return status === 'STARTED' || status === 'STARTING';
}
export default function Timeline() {
const { showToast } = useToastContext();
const [view, setView] = useState<ViewType>('day');
const [currentDate, setCurrentDate] = useState(() => new Date());
const [periodLabel, setPeriodLabel] = useState('');
const [periods, setPeriods] = useState<PeriodInfo[]>([]);
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
const [loading, setLoading] = useState(true);
// Tooltip
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Selected cell & detail panel
const [selectedCell, setSelectedCell] = useState<SelectedCell | null>(null);
const [detailExecutions, setDetailExecutions] = useState<JobExecutionDto[]>([]);
const [detailLoading, setDetailLoading] = useState(false);
const loadTimeline = useCallback(async () => {
try {
const dateStr = formatDateStr(currentDate);
const result = await batchApi.getTimeline(view, dateStr);
setPeriodLabel(result.periodLabel);
setPeriods(result.periods);
setSchedules(result.schedules);
} catch (err) {
showToast('타임라인 조회 실패', 'error');
console.error(err);
} finally {
setLoading(false);
}
}, [view, currentDate, showToast]);
usePoller(loadTimeline, POLLING_INTERVAL, [view, currentDate]);
const handlePrev = () => setCurrentDate((d) => shiftDate(d, view, -1));
const handleNext = () => setCurrentDate((d) => shiftDate(d, view, 1));
const handleToday = () => setCurrentDate(new Date());
const handleRefresh = async () => {
setLoading(true);
await loadTimeline();
};
// Tooltip handlers
const handleCellMouseEnter = (
e: React.MouseEvent,
jobName: string,
period: PeriodInfo,
execution: ExecutionInfo,
) => {
if (tooltipTimeoutRef.current) {
clearTimeout(tooltipTimeoutRef.current);
}
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setTooltip({
jobName,
period,
execution,
x: rect.left + rect.width / 2,
y: rect.top,
});
};
const handleCellMouseLeave = () => {
tooltipTimeoutRef.current = setTimeout(() => {
setTooltip(null);
}, 100);
};
// Clean up tooltip timeout
useEffect(() => {
return () => {
if (tooltipTimeoutRef.current) {
clearTimeout(tooltipTimeoutRef.current);
}
};
}, []);
// Cell click -> detail panel
const handleCellClick = async (jobName: string, periodKey: string, periodLabel: string) => {
// Toggle off if clicking same cell
if (selectedCell?.jobName === jobName && selectedCell?.periodKey === periodKey) {
setSelectedCell(null);
setDetailExecutions([]);
return;
}
setSelectedCell({ jobName, periodKey, periodLabel });
setDetailLoading(true);
setDetailExecutions([]);
try {
const executions = await batchApi.getPeriodExecutions(jobName, view, periodKey);
setDetailExecutions(executions);
} catch (err) {
showToast('구간 실행 이력 조회 실패', 'error');
console.error(err);
} finally {
setDetailLoading(false);
}
};
const closeDetail = () => {
setSelectedCell(null);
setDetailExecutions([]);
};
const gridTemplateColumns = `${JOB_COL_WIDTH}px repeat(${periods.length}, minmax(${CELL_MIN_WIDTH}px, 1fr))`;
return (
<div className="space-y-4">
{/* Controls */}
<div className="bg-wing-surface rounded-xl shadow-lg p-4">
<div className="flex flex-wrap items-center gap-3">
{/* View Toggle */}
<div className="flex rounded-lg border border-wing-border overflow-hidden">
{VIEW_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => {
setView(opt.value);
setLoading(true);
}}
className={`px-4 py-1.5 text-sm font-medium transition-colors ${
view === opt.value
? 'bg-wing-accent text-white'
: 'bg-wing-surface text-wing-muted hover:bg-wing-accent/10'
}`}
>
{opt.label}
</button>
))}
</div>
{/* Navigation */}
<div className="flex items-center gap-1">
<button
onClick={handlePrev}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
&larr;
</button>
<button
onClick={handleToday}
className="px-3 py-1.5 text-sm font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
<button
onClick={handleNext}
className="px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
&rarr;
</button>
</div>
{/* Period Label */}
<span className="text-sm font-semibold text-wing-text">
{periodLabel}
</span>
{/* Refresh */}
<button
onClick={handleRefresh}
className="ml-auto px-3 py-1.5 text-sm text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
</div>
{/* Legend */}
<div className="flex flex-wrap items-center gap-4 px-2">
{LEGEND_ITEMS.map((item) => (
<div key={item.status} className="flex items-center gap-1.5">
<div
className="w-4 h-4 rounded"
style={{ backgroundColor: item.color }}
/>
<span className="text-xs text-wing-muted">{item.label}</span>
</div>
))}
</div>
{/* Timeline Grid */}
<div className="bg-wing-surface rounded-xl shadow-lg overflow-hidden">
{loading ? (
<LoadingSpinner />
) : schedules.length === 0 ? (
<EmptyState message="타임라인 데이터가 없습니다" sub="등록된 스케줄이 없거나 해당 기간에 실행 이력이 없습니다" />
) : (
<div className="overflow-x-auto">
<div
className="grid min-w-max"
style={{ gridTemplateColumns }}
>
{/* Header Row */}
<div className="sticky left-0 z-20 bg-wing-card border-b border-r border-wing-border px-3 py-2 text-xs font-semibold text-wing-muted">
</div>
{periods.map((period) => (
<div
key={period.key}
className="bg-wing-card border-b border-r border-wing-border px-2 py-2 text-xs font-medium text-wing-muted text-center whitespace-nowrap"
>
{period.label}
</div>
))}
{/* Data Rows */}
{schedules.map((schedule) => (
<>
{/* Job Name (sticky) */}
<div
key={`name-${schedule.jobName}`}
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
title={schedule.jobName}
>
{schedule.jobName}
</div>
{/* Execution Cells */}
{periods.map((period) => {
const exec = schedule.executions[period.key];
const hasExec = exec !== null && exec !== undefined;
const isSelected =
selectedCell?.jobName === schedule.jobName &&
selectedCell?.periodKey === period.key;
const running = hasExec && isRunning(exec.status);
return (
<div
key={`cell-${schedule.jobName}-${period.key}`}
className={`border-b border-r border-wing-border/50 p-1 cursor-pointer transition-all hover:opacity-80 ${
isSelected ? 'ring-2 ring-yellow-400 ring-inset' : ''
}`}
onClick={() =>
handleCellClick(schedule.jobName, period.key, period.label)
}
onMouseEnter={
hasExec
? (e) => handleCellMouseEnter(e, schedule.jobName, period, exec)
: undefined
}
onMouseLeave={hasExec ? handleCellMouseLeave : undefined}
>
{hasExec && (
<div
className={`w-full h-6 rounded ${running ? 'animate-pulse' : ''}`}
style={{ backgroundColor: getStatusColor(exec.status) }}
/>
)}
</div>
);
})}
</>
))}
</div>
</div>
)}
</div>
{/* Tooltip */}
{tooltip && (
<div
className="fixed z-50 pointer-events-none"
style={{
left: tooltip.x,
top: tooltip.y - 8,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
<div className="font-semibold mb-1">{tooltip.jobName}</div>
<div className="space-y-0.5 text-gray-300">
<div>: {tooltip.period.label}</div>
<div>
:{' '}
<span
className="font-medium"
style={{ color: getStatusColor(tooltip.execution.status) }}
>
{tooltip.execution.status}
</span>
</div>
{tooltip.execution.startTime && (
<div>: {formatDateTime(tooltip.execution.startTime)}</div>
)}
{tooltip.execution.endTime && (
<div>: {formatDateTime(tooltip.execution.endTime)}</div>
)}
{tooltip.execution.executionId && (
<div> ID: {tooltip.execution.executionId}</div>
)}
</div>
{/* Arrow */}
<div className="absolute left-1/2 -translate-x-1/2 top-full w-0 h-0 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-gray-900" />
</div>
</div>
)}
{/* Detail Panel */}
{selectedCell && (
<div className="bg-wing-surface rounded-xl shadow-lg p-6">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-bold text-wing-text">
{selectedCell.jobName}
</h3>
<p className="text-xs text-wing-muted mt-0.5">
: {selectedCell.periodLabel}
</p>
</div>
<button
onClick={closeDetail}
className="px-3 py-1.5 text-xs text-wing-muted bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
</div>
{detailLoading ? (
<LoadingSpinner className="py-6" />
) : detailExecutions.length === 0 ? (
<EmptyState
message="해당 구간에 실행 이력이 없습니다"
icon="📭"
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-wing-border">
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
ID
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
</th>
<th className="text-left py-2 px-3 text-xs font-semibold text-wing-muted">
</th>
<th className="text-right py-2 px-3 text-xs font-semibold text-wing-muted">
</th>
</tr>
</thead>
<tbody>
{detailExecutions.map((exec) => (
<tr
key={exec.executionId}
className="border-b border-wing-border/50 hover:bg-wing-hover"
>
<td className="py-2 px-3 text-xs font-mono text-wing-text">
#{exec.executionId}
</td>
<td className="py-2 px-3">
<StatusBadge status={exec.status} />
</td>
<td className="py-2 px-3 text-xs text-wing-muted">
{formatDateTime(exec.startTime)}
</td>
<td className="py-2 px-3 text-xs text-wing-muted">
{formatDateTime(exec.endTime)}
</td>
<td className="py-2 px-3 text-xs text-wing-muted">
{calculateDuration(exec.startTime, exec.endTime)}
</td>
<td className="py-2 px-3 text-right">
<a
href={`/executions/${exec.executionId}`}
className="text-xs text-wing-accent hover:text-wing-accent font-medium"
>
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,25 @@
body {
font-family: 'Noto Sans KR', sans-serif;
background: var(--wing-bg);
color: var(--wing-text);
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Scrollbar styling for dark mode */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--wing-surface);
}
::-webkit-scrollbar-thumb {
background: var(--wing-muted);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--wing-accent);
}

파일 보기

@ -0,0 +1,66 @@
/* Dark theme (default) */
:root,
[data-theme='dark'] {
--wing-bg: #020617;
--wing-surface: #0f172a;
--wing-card: #1e293b;
--wing-border: #1e3a5f;
--wing-text: #e2e8f0;
--wing-muted: #64748b;
--wing-accent: #3b82f6;
--wing-danger: #ef4444;
--wing-warning: #f59e0b;
--wing-success: #22c55e;
--wing-glass: rgba(15, 23, 42, 0.92);
--wing-glass-dense: rgba(15, 23, 42, 0.95);
--wing-overlay: rgba(2, 6, 23, 0.42);
--wing-card-alpha: rgba(30, 41, 59, 0.55);
--wing-subtle: rgba(255, 255, 255, 0.03);
--wing-hover: rgba(255, 255, 255, 0.05);
--wing-input-bg: #0f172a;
--wing-input-border: #334155;
}
/* Light theme */
[data-theme='light'] {
--wing-bg: #e2e8f0;
--wing-surface: #ffffff;
--wing-card: #f1f5f9;
--wing-border: #94a3b8;
--wing-text: #0f172a;
--wing-muted: #64748b;
--wing-accent: #2563eb;
--wing-danger: #dc2626;
--wing-warning: #d97706;
--wing-success: #16a34a;
--wing-glass: rgba(255, 255, 255, 0.92);
--wing-glass-dense: rgba(255, 255, 255, 0.95);
--wing-overlay: rgba(0, 0, 0, 0.25);
--wing-card-alpha: rgba(226, 232, 240, 0.6);
--wing-subtle: rgba(0, 0, 0, 0.03);
--wing-hover: rgba(0, 0, 0, 0.04);
--wing-input-bg: #ffffff;
--wing-input-border: #cbd5e1;
}
@theme {
--color-wing-bg: var(--wing-bg);
--color-wing-surface: var(--wing-surface);
--color-wing-card: var(--wing-card);
--color-wing-border: var(--wing-border);
--color-wing-text: var(--wing-text);
--color-wing-muted: var(--wing-muted);
--color-wing-accent: var(--wing-accent);
--color-wing-danger: var(--wing-danger);
--color-wing-warning: var(--wing-warning);
--color-wing-success: var(--wing-success);
--color-wing-glass: var(--wing-glass);
--color-wing-glass-dense: var(--wing-glass-dense);
--color-wing-overlay: var(--wing-overlay);
--color-wing-card-alpha: var(--wing-card-alpha);
--color-wing-subtle: var(--wing-subtle);
--color-wing-hover: var(--wing-hover);
--color-wing-input-bg: var(--wing-input-bg);
--color-wing-input-border: var(--wing-input-border);
--font-sans: 'Noto Sans KR', sans-serif;
}

파일 보기

@ -0,0 +1,153 @@
/**
* Quartz Cron .
* 형식:
*/
export function getNextExecutions(cron: string, count: number): Date[] {
const parts = cron.trim().split(/\s+/);
if (parts.length < 6) return [];
const [secField, minField, hourField, dayField, monthField, dowField] = parts;
if (hasUnsupportedToken(dayField) || hasUnsupportedToken(dowField)) {
return [];
}
const seconds = parseField(secField, 0, 59);
const minutes = parseField(minField, 0, 59);
const hours = parseField(hourField, 0, 23);
const daysOfMonth = parseField(dayField, 1, 31);
const months = parseField(monthField, 1, 12);
const daysOfWeek = parseDowField(dowField);
if (!seconds || !minutes || !hours || !months) return [];
const results: Date[] = [];
const now = new Date();
const cursor = new Date(now.getFullYear(), now.getMonth(), now.getDate(), now.getHours(), now.getMinutes(), now.getSeconds() + 1);
cursor.setMilliseconds(0);
const limit = new Date(now.getTime() + 365 * 24 * 60 * 60 * 1000);
while (results.length < count && cursor.getTime() <= limit.getTime()) {
const month = cursor.getMonth() + 1;
if (!months.includes(month)) {
cursor.setMonth(cursor.getMonth() + 1, 1);
cursor.setHours(0, 0, 0, 0);
continue;
}
const day = cursor.getDate();
const dayMatches = daysOfMonth ? daysOfMonth.includes(day) : true;
const dowMatches = daysOfWeek ? daysOfWeek.includes(cursor.getDay()) : true;
const needDayCheck = dayField !== '?' && dowField !== '?';
const dayOk = needDayCheck ? dayMatches && dowMatches : dayMatches && dowMatches;
if (!dayOk) {
cursor.setDate(cursor.getDate() + 1);
cursor.setHours(0, 0, 0, 0);
continue;
}
const hour = cursor.getHours();
if (!hours.includes(hour)) {
cursor.setHours(cursor.getHours() + 1, 0, 0, 0);
continue;
}
const minute = cursor.getMinutes();
if (!minutes.includes(minute)) {
cursor.setMinutes(cursor.getMinutes() + 1, 0, 0);
continue;
}
const second = cursor.getSeconds();
if (!seconds.includes(second)) {
cursor.setSeconds(cursor.getSeconds() + 1, 0);
continue;
}
results.push(new Date(cursor));
cursor.setSeconds(cursor.getSeconds() + 1);
}
return results;
}
function hasUnsupportedToken(field: string): boolean {
return /[LW#]/.test(field);
}
function parseField(field: string, min: number, max: number): number[] | null {
if (field === '?') return null;
if (field === '*') return range(min, max);
const values = new Set<number>();
for (const part of field.split(',')) {
const stepMatch = part.match(/^(.+)\/(\d+)$/);
if (stepMatch) {
const [, base, stepStr] = stepMatch;
const step = parseInt(stepStr, 10);
let start = min;
let end = max;
if (base === '*') {
start = min;
} else if (base.includes('-')) {
const [lo, hi] = base.split('-').map(Number);
start = lo;
end = hi;
} else {
start = parseInt(base, 10);
}
for (let v = start; v <= end; v += step) {
if (v >= min && v <= max) values.add(v);
}
continue;
}
const rangeMatch = part.match(/^(\d+)-(\d+)$/);
if (rangeMatch) {
const lo = parseInt(rangeMatch[1], 10);
const hi = parseInt(rangeMatch[2], 10);
for (let v = lo; v <= hi; v++) {
if (v >= min && v <= max) values.add(v);
}
continue;
}
const num = parseInt(part, 10);
if (!isNaN(num) && num >= min && num <= max) {
values.add(num);
}
}
return values.size > 0 ? Array.from(values).sort((a, b) => a - b) : range(min, max);
}
function parseDowField(field: string): number[] | null {
if (field === '?' || field === '*') return null;
const dayMap: Record<string, string> = {
SUN: '0', MON: '1', TUE: '2', WED: '3', THU: '4', FRI: '5', SAT: '6',
};
let normalized = field.toUpperCase();
for (const [name, num] of Object.entries(dayMap)) {
normalized = normalized.replace(new RegExp(name, 'g'), num);
}
// Quartz uses 1=SUN..7=SAT, convert to JS 0=SUN..6=SAT
const parsed = parseField(normalized, 1, 7);
if (!parsed) return null;
return parsed.map((v) => v - 1);
}
function range(min: number, max: number): number[] {
const result: number[] = [];
for (let i = min; i <= max; i++) result.push(i);
return result;
}

파일 보기

@ -0,0 +1,58 @@
export function formatDateTime(dateTimeStr: string | null | undefined): string {
if (!dateTimeStr) return '-';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '-';
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
const s = String(date.getSeconds()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}:${s}`;
} catch {
return '-';
}
}
export function formatDateTimeShort(dateTimeStr: string | null | undefined): string {
if (!dateTimeStr) return '-';
try {
const date = new Date(dateTimeStr);
if (isNaN(date.getTime())) return '-';
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
const h = String(date.getHours()).padStart(2, '0');
const min = String(date.getMinutes()).padStart(2, '0');
return `${m}/${d} ${h}:${min}`;
} catch {
return '-';
}
}
export function formatDuration(ms: number | null | undefined): string {
if (ms == null || ms < 0) return '-';
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}시간 ${minutes}${seconds}`;
if (minutes > 0) return `${minutes}${seconds}`;
return `${seconds}`;
}
export function calculateDuration(
startTime: string | null | undefined,
endTime: string | null | undefined,
): string {
if (!startTime) return '-';
const start = new Date(startTime).getTime();
if (isNaN(start)) return '-';
if (!endTime) return '실행 중...';
const end = new Date(endTime).getTime();
if (isNaN(end)) return '-';
return formatDuration(end - start);
}

파일 보기

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
파일 보기

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

파일 보기

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

21
frontend/vite.config.ts Normal file
파일 보기

@ -0,0 +1,21 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
'/snp-api/api': {
target: 'http://localhost:8041',
changeOrigin: true,
},
},
},
base: '/snp-api/',
build: {
outDir: '../src/main/resources/static',
emptyOutDir: true,
},
})

37
pom.xml
파일 보기

@ -13,7 +13,7 @@
</parent>
<groupId>com.snp</groupId>
<artifactId>snp-batch</artifactId>
<artifactId>snp-batch-validation</artifactId>
<version>1.0.0</version>
<name>SNP Batch</name>
<description>Spring Batch project for JSON to PostgreSQL with Web GUI</description>
@ -111,6 +111,12 @@
<version>2.3.0</version>
</dependency>
<!-- Kafka -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
</dependency>
<!-- Caffeine Cache -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
@ -154,6 +160,35 @@
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>com.github.eirslett</groupId>
<artifactId>frontend-maven-plugin</artifactId>
<version>1.15.1</version>
<configuration>
<workingDirectory>frontend</workingDirectory>
<nodeVersion>v20.19.0</nodeVersion>
</configuration>
<executions>
<execution>
<id>install-node-and-npm</id>
<goals><goal>install-node-and-npm</goal></goals>
</execution>
<execution>
<id>npm-install</id>
<goals><goal>npm</goal></goals>
<configuration>
<arguments>install</arguments>
</configuration>
</execution>
<execution>
<id>npm-build</id>
<goals><goal>npm</goal></goals>
<configuration>
<arguments>run build</arguments>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>

파일 보기

@ -0,0 +1,149 @@
-- ============================================================
-- ChnPrmShip 캐시 검증 진단 쿼리
-- 대상: t_std_snp_data.ais_target (일별 파티션)
-- 목적: 최근 2일 내 대상 MMSI별 최종위치 캐싱 검증
-- ============================================================
-- ============================================================
-- 0. 대상 MMSI 임시 테이블 생성
-- ============================================================
CREATE TEMP TABLE tmp_chn_mmsi (mmsi BIGINT PRIMARY KEY);
-- psql에서 실행:
-- \copy tmp_chn_mmsi(mmsi) FROM 'chnprmship-mmsi.txt'
-- ============================================================
-- 1. 기본 현황: 대상 MMSI 중 최근 2일 내 데이터 존재 여부
-- ============================================================
SELECT
(SELECT COUNT(*) FROM tmp_chn_mmsi) AS total_target_mmsi,
COUNT(DISTINCT a.mmsi) AS mmsi_with_data_2d,
(SELECT COUNT(*) FROM tmp_chn_mmsi) - COUNT(DISTINCT a.mmsi) AS mmsi_without_data_2d,
ROUND(COUNT(DISTINCT a.mmsi) * 100.0
/ NULLIF((SELECT COUNT(*) FROM tmp_chn_mmsi), 0), 1) AS hit_rate_pct
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days';
-- ============================================================
-- 2. 워밍업 시뮬레이션: 최근 2일 내 MMSI별 최종위치
-- (수정 후 findLatestByMmsiIn 쿼리와 동일하게 동작)
-- ============================================================
SELECT COUNT(*) AS cached_count,
MIN(message_timestamp) AS oldest_cached,
MAX(message_timestamp) AS newest_cached,
NOW() - MAX(message_timestamp) AS newest_age
FROM (
SELECT DISTINCT ON (a.mmsi) a.mmsi, a.message_timestamp
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
ORDER BY a.mmsi, a.message_timestamp DESC
) latest;
-- ============================================================
-- 3. MMSI별 최종위치 상세 (최근 2일 내, 최신순 상위 30건)
-- ============================================================
SELECT DISTINCT ON (a.mmsi)
a.mmsi,
a.message_timestamp,
a.name,
a.vessel_type,
a.lat,
a.lon,
a.sog,
a.cog,
a.heading,
NOW() - a.message_timestamp AS data_age
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
ORDER BY a.mmsi, a.message_timestamp DESC
LIMIT 30;
-- ============================================================
-- 4. 데이터 없는 대상 MMSI (최근 2일 내 DB에 없는 선박)
-- ============================================================
SELECT t.mmsi AS missing_mmsi
FROM tmp_chn_mmsi t
LEFT JOIN (
SELECT DISTINCT mmsi
FROM t_std_snp_data.ais_target
WHERE mmsi IN (SELECT mmsi FROM tmp_chn_mmsi)
AND message_timestamp >= NOW() - INTERVAL '2 days'
) a ON t.mmsi = a.mmsi
WHERE a.mmsi IS NULL
ORDER BY t.mmsi;
-- ============================================================
-- 5. 시간대별 분포 (2일 기준 세부 확인)
-- ============================================================
SELECT
'6시간 이내' AS time_range,
COUNT(DISTINCT mmsi) AS distinct_mmsi
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '6 hours'
UNION ALL
SELECT '12시간 이내', COUNT(DISTINCT mmsi)
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '12 hours'
UNION ALL
SELECT '1일 이내', COUNT(DISTINCT mmsi)
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '1 day'
UNION ALL
SELECT '2일 이내', COUNT(DISTINCT mmsi)
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
WHERE a.message_timestamp >= NOW() - INTERVAL '2 days'
UNION ALL
SELECT '전체(무제한)', COUNT(DISTINCT mmsi)
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi;
-- ============================================================
-- 6. 파티션별 대상 데이터 분포
-- ============================================================
SELECT
tableoid::regclass AS partition_name,
COUNT(*) AS row_count,
COUNT(DISTINCT mmsi) AS distinct_mmsi,
MIN(message_timestamp) AS min_ts,
MAX(message_timestamp) AS max_ts
FROM t_std_snp_data.ais_target a
JOIN tmp_chn_mmsi t ON a.mmsi = t.mmsi
GROUP BY tableoid::regclass
ORDER BY max_ts DESC;
-- ============================================================
-- 7. 전체 ais_target 파티션 현황
-- ============================================================
SELECT
c.relname AS partition_name,
pg_size_pretty(pg_relation_size(c.oid)) AS table_size,
s.n_live_tup AS estimated_rows
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_stat_user_tables s ON s.relid = c.oid
WHERE i.inhparent = 't_std_snp_data.ais_target'::regclass
ORDER BY c.relname DESC;
-- ============================================================
-- 정리
-- ============================================================
DROP TABLE IF EXISTS tmp_chn_mmsi;

파일 보기

@ -2,10 +2,11 @@ package com.snp.batch;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@SpringBootApplication(exclude = KafkaAutoConfiguration.class)
@EnableScheduling
@ConfigurationPropertiesScan
public class SnpBatchApplication {

파일 보기

@ -43,4 +43,22 @@ public abstract class BaseEntity {
* 컬럼: updated_by (VARCHAR(100))
*/
private String updatedBy;
/**
* 배치 실행 ID
* 컬럼: job_execution_id (int8)
*/
private Long jobExecutionId;
/**
* 배치 공통 필드 설정을 위한 편의 메서드
*/
@SuppressWarnings("unchecked")
public <T extends BaseEntity> T setBatchInfo(Long jobExecutionId, String createdBy) {
this.jobExecutionId = jobExecutionId;
this.createdBy = createdBy;
// 필요시 생성일시 강제 설정 (JPA Auditing을 경우)
if (this.createdAt == null) this.createdAt = LocalDateTime.now();
return (T) this;
}
}

파일 보기

@ -170,6 +170,125 @@ public abstract class BaseApiReader<T> implements ItemReader<T> {
}
}
protected <T, R> List<R> executeWrapperApiCall(
String baseUrl,
String path,
Class<T> responseWrapperClass, // Stat5CodeApiResponse.class 등을 받음
Function<T, List<R>> listExtractor, // 결과 객체에서 리스트를 꺼내는 로직
BatchApiLogService logService) {
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
.path(path)
.build()
.toUriString();
long startTime = System.currentTimeMillis();
int statusCode = 200;
String errorMessage = null;
Long responseSize = 0L;
try {
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
// 1. List<R> 아닌 Wrapper 객체(T) 받아옵니다.
T response = webClient.get()
.uri(uriBuilder -> uriBuilder.path(path).build())
.retrieve()
.bodyToMono(responseWrapperClass)
.block();
// 2. 추출 함수(listExtractor) 사용하여 내부 리스트를 꺼냅니다.
List<R> result = (response != null) ? listExtractor.apply(response) : Collections.emptyList();
responseSize = (long) result.size();
return result;
} catch (WebClientResponseException e) {
statusCode = e.getStatusCode().value();
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
statusCode = 500;
errorMessage = String.format("System Error: %s", e.getMessage());
throw e;
} finally {
long duration = System.currentTimeMillis() - startTime;
logService.saveLog(BatchApiLog.builder()
.apiRequestLocation(getReaderName())
.requestUri(fullUri)
.httpMethod("GET")
.statusCode(statusCode)
.responseTimeMs(duration)
.responseCount(responseSize)
.errorMessage(errorMessage)
.createdAt(LocalDateTime.now())
.jobExecutionId(this.jobExecutionId)
.stepExecutionId(this.stepExecutionId)
.build());
}
}
protected <R> List<R> executeListApiCall(
String baseUrl,
String path,
ParameterizedTypeReference<List<R>> typeReference,
BatchApiLogService logService) {
String fullUri = UriComponentsBuilder.fromHttpUrl(baseUrl)
.path(path)
.build()
.toUriString();
long startTime = System.currentTimeMillis();
int statusCode = 200;
String errorMessage = null;
Long responseSize = 0L;
try {
log.info("[{}] API 요청 시작: {}", getReaderName(), fullUri);
List<R> result = webClient.get()
.uri(uriBuilder -> {
uriBuilder.path(path);
return uriBuilder.build();
})
.retrieve()
.bodyToMono(typeReference)
.block();
responseSize = (result != null) ? (long) result.size() : 0L;
return result;
} catch (WebClientResponseException e) {
// API 서버에서 응답은 왔으나 에러인 경우 (4xx, 5xx)
statusCode = e.getStatusCode().value();
errorMessage = String.format("API Error: %s", e.getResponseBodyAsString());
throw e;
} catch (Exception e) {
// 네트워크 오류, 타임아웃 기타 예외
statusCode = 500;
errorMessage = String.format("System Error: %s", e.getMessage());
throw e;
} finally {
// 성공/실패 여부와 관계없이 무조건 로그 저장
long duration = System.currentTimeMillis() - startTime;
logService.saveLog(BatchApiLog.builder()
.apiRequestLocation(getReaderName())
.requestUri(fullUri)
.httpMethod("GET")
.statusCode(statusCode)
.responseTimeMs(duration)
.responseCount(responseSize)
.errorMessage(errorMessage)
.createdAt(LocalDateTime.now())
.jobExecutionId(this.jobExecutionId) // 추가
.stepExecutionId(this.stepExecutionId) // 추가
.build());
}
}
/**
* API 호출 로그 적재 통합 메서드
* Response Json 구조 : { "data": [...] }

파일 보기

@ -29,9 +29,23 @@ public abstract class BaseJdbcRepository<T, ID> {
protected final JdbcTemplate jdbcTemplate;
/**
* 테이블명 반환 (하위 클래스에서 구현)
* 대상 스키마 이름 반환 (하위 클래스에서 구현)
* application.yml의 app.batch.target-schema.name 값을 @Value로 주입받아 반환
*/
protected abstract String getTableName();
protected abstract String getTargetSchema();
/**
* 테이블명만 반환 (스키마 제외, 하위 클래스에서 구현)
*/
protected abstract String getSimpleTableName();
/**
* 전체 테이블명 반환 (스키마.테이블)
* 하위 클래스에서는 getSimpleTableName() 구현하면
*/
protected String getTableName() {
return getTargetSchema() + "." + getSimpleTableName();
}
/**
* ID 컬럼명 반환 (기본값: "id")

파일 보기

@ -1,233 +0,0 @@
package com.snp.batch.common.util;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.security.MessageDigest;
import java.util.*;
public class JsonChangeDetector {
// Map으로 변환 사용할 ObjectMapper (표준 Mapper 사용)
private static final ObjectMapper MAPPER = new ObjectMapper();
// 해시 비교에서 제외할 필드 목록 (DataSetVersion )
// 목록은 모든 JSON 계층에 걸쳐 적용됩니다.
private static final java.util.Set<String> EXCLUDE_KEYS =
java.util.Set.of("DataSetVersion", "APSStatus", "LastUpdateDateTime");
// =========================================================================
// LIST_SORT_KEYS: 정적 초기화 블록을 사용한 Map 정의
// =========================================================================
private static final Map<String, String> LIST_SORT_KEYS;
static {
// TreeMap을 사용하여 키를 알파벳 순으로 정렬할 수도 있지만, 여기서는 HashMap을 사용하고 final로 만듭니다.
Map<String, String> map = new HashMap<>();
// List 필드명 // 정렬 기준 복합 (JSON 필드명, 쉼표로 구분)
map.put("OwnerHistory", "OwnerCode,EffectiveDate,Sequence");
map.put("CrewList", "LRNO,Shipname,Nationality");
map.put("StowageCommodity", "Sequence,CommodityCode,StowageCode");
map.put("GroupBeneficialOwnerHistory", "EffectiveDate,GroupBeneficialOwnerCode,Sequence");
map.put("ShipManagerHistory", "EffectiveDate,ShipManagerCode,Sequence");
map.put("OperatorHistory", "EffectiveDate,OperatorCode,Sequence");
map.put("TechnicalManagerHistory", "EffectiveDate,Sequence,TechnicalManagerCode");
map.put("BareBoatCharterHistory", "Sequence,EffectiveDate,BBChartererCode");
map.put("NameHistory", "Sequence,EffectiveDate");
map.put("FlagHistory", "FlagCode,EffectiveDate,Sequence");
map.put("PandIHistory", "PandIClubCode,EffectiveDate");
map.put("CallSignAndMmsiHistory", "EffectiveDate,SeqNo");
map.put("IceClass", "IceClassCode");
map.put("SafetyManagementCertificateHistory", "Sequence");
map.put("ClassHistory", "ClassCode,EffectiveDate,Sequence");
map.put("SurveyDatesHistory", "ClassSocietyCode");
map.put("SurveyDatesHistoryUnique", "ClassSocietyCode,SurveyDate,SurveyType");
map.put("SisterShipLinks", "LinkedLRNO");
map.put("StatusHistory", "Sequence,StatusCode,StatusDate");
map.put("SpecialFeature", "Sequence,SpecialFeatureCode");
map.put("Thrusters", "Sequence");
map.put("DarkActivityConfirmed", "Lrno,Mmsi,Dark_Time,Dark_Status");
map.put("CompanyComplianceDetails", "OwCode");
map.put("CompanyVesselRelationships", "LRNO");
map.put("CompanyDetailsComplexWithCodesAndParent", "OWCODE,LastChangeDate");
LIST_SORT_KEYS = Collections.unmodifiableMap(map);
}
// =========================================================================
// 1. JSON 문자열을 정렬 필터링된 Map으로 변환하는 핵심 로직
// =========================================================================
/**
* JSON 문자열을 Map으로 변환하고, 특정 키를 제거하며, 순서가 정렬된 상태로 만듭니다.
* @param jsonString API 응답 또는 DB에서 읽은 JSON 문자열
* @return 필터링되고 정렬된 Map 객체
*/
public static Map<String, Object> jsonToSortedFilteredMap(String jsonString) {
if (jsonString == null || jsonString.trim().isEmpty()) {
return Collections.emptyMap();
}
try {
// 1. Map<String, Object>으로 1차 변환합니다. (순서 보장 안됨)
Map<String, Object> rawMap = MAPPER.readValue(jsonString,
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {});
// 2. 재귀 함수를 호출하여 키를 제거하고 TreeMap( 순서 정렬)으로 깊은 복사합니다.
return deepFilterAndSort(rawMap);
} catch (Exception e) {
System.err.println("Error converting JSON to filtered Map: " + e.getMessage());
// 예외 발생 Map 반환
return Collections.emptyMap();
}
}
/**
* Map을 재귀적으로 탐색하며 제외 키를 제거하고 TreeMap(알파벳 순서)으로 변환합니다.
*/
private static Map<String, Object> deepFilterAndSort(Map<String, Object> rawMap) {
// Map을 TreeMap으로 생성하여 순서를 알파벳 순으로 강제 정렬합니다.
Map<String, Object> sortedMap = new TreeMap<>();
for (Map.Entry<String, Object> entry : rawMap.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
// 🔑 1. 제외할 값인지 확인
if (EXCLUDE_KEYS.contains(key)) {
continue; // 제외
}
// 2. 값의 타입에 따라 재귀 처리
if (value instanceof Map) {
// 재귀 호출: 하위 Map을 필터링하고 정렬
@SuppressWarnings("unchecked")
Map<String, Object> subMap = (Map<String, Object>) value;
sortedMap.put(key, deepFilterAndSort(subMap));
} else if (value instanceof List) {
// List 처리: List 내부의 Map 요소만 재귀 호출
@SuppressWarnings("unchecked")
List<Object> rawList = (List<Object>) value;
List<Object> filteredList = new ArrayList<>();
// 1. List 내부의 Map 요소들을 재귀적으로 필터링/정렬하여 filteredList에 추가
for (Object item : rawList) {
if (item instanceof Map) {
@SuppressWarnings("unchecked")
Map<String, Object> itemMap = (Map<String, Object>) item;
// List의 요소인 Map도 필터링하고 정렬 (Map의 필드 순서 정렬)
filteredList.add(deepFilterAndSort(itemMap));
} else {
filteredList.add(item);
}
}
// 2. 🔑 List 필드명에 따른 복합 순서 정렬 로직 (수정된 핵심 로직)
String listFieldName = entry.getKey();
String sortKeysString = LIST_SORT_KEYS.get(listFieldName); // 쉼표로 구분된 복합 문자열
if (sortKeysString != null && !filteredList.isEmpty() && filteredList.get(0) instanceof Map) {
// 복합 문자열을 개별 배열로 분리
final String[] sortKeys = sortKeysString.split(",");
// Map 요소를 가진 리스트인 경우에만 정렬 실행
try {
Collections.sort(filteredList, new Comparator<Object>() {
@Override
@SuppressWarnings("unchecked")
public int compare(Object o1, Object o2) {
Map<String, Object> map1 = (Map<String, Object>) o1;
Map<String, Object> map2 = (Map<String, Object>) o2;
// 복합 (sortKeys) 순서대로 순회하며 비교
for (String rawSortKey : sortKeys) {
// 키의 공백 제거
String sortKey = rawSortKey.trim();
Object key1 = map1.get(sortKey);
Object key2 = map2.get(sortKey);
// null 처리 로직
if (key1 == null && key2 == null) {
continue; // 값이 동일하므로 다음 키로 이동
}
if (key1 == null) {
// key1이 null이고 key2는 null이 아니면, key2가 크다고 ( 순서) 간주하고 1 반환
return 1;
}
if (key2 == null) {
// key2가 null이고 key1은 null이 아니면, key1이 크다고 ( 순서) 간주하고 -1 반환
return -1;
}
// 값을 문자열로 변환하여 비교 (String, Number, Date 타입 모두 처리 가능)
int comparisonResult = key1.toString().compareTo(key2.toString());
// 현재 키에서 순서가 결정되면 즉시 반환
if (comparisonResult != 0) {
return comparisonResult;
}
// comparisonResult == 0 이면 다음 키로 이동하여 비교를 계속함
}
// 모든 키를 비교해도 동일한 경우
// 경우 Map은 해시값 측면에서 동일한 것으로 간주되어야 합니다.
return 0;
}
});
} catch (Exception e) {
System.err.println("List sort failed for key " + listFieldName + ": " + e.getMessage());
// 정렬 실패 원래 순서 유지 (filteredList 상태 유지)
}
}
sortedMap.put(key, filteredList);
} else {
// String, Number 기본 타입은 그대로 추가
sortedMap.put(key, value);
}
}
return sortedMap;
}
// =========================================================================
// 2. 해시 생성 로직
// =========================================================================
/**
* 필터링되고 정렬된 Map의 문자열 표현을 기반으로 SHA-256 해시를 생성합니다.
*/
public static String getSha256HashFromMap(Map<String, Object> sortedMap) {
// 1. Map을 String으로 변환: TreeMap 덕분에 toString() 결과가 항상 동일한 순서를 가집니다.
String mapString = sortedMap.toString();
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(mapString.getBytes("UTF-8"));
// 바이트 배열을 16진수 문자열로 변환
StringBuilder hexString = new StringBuilder();
for (byte b : hash) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) hexString.append('0');
hexString.append(hex);
}
return hexString.toString();
} catch (Exception e) {
System.err.println("Error generating hash: " + e.getMessage());
return "HASH_ERROR";
}
}
// =========================================================================
// 3. 해시값 비교 로직
// =========================================================================
public static boolean isChanged(String previousHash, String currentHash) {
// DB 해시가 null인 경우 ( Insert) 변경된 것으로 간주
if (previousHash == null || previousHash.isEmpty()) {
return true;
}
// 해시값이 다르면 변경된 것으로 간주
return !Objects.equals(previousHash, currentHash);
}
}

파일 보기

@ -1,33 +0,0 @@
package com.snp.batch.common.util;
public class SafeGetDataUtil {
private String safeGetString(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
return value.trim();
}
private Double safeGetDouble(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
return Double.parseDouble(value);
} catch (NumberFormatException e) {
return null;
}
}
private Long safeGetLong(String value) {
if (value == null || value.trim().isEmpty()) {
return null;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return null;
}
}
}

파일 보기

@ -1,5 +1,6 @@
package com.snp.batch.common.web;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -14,26 +15,19 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "공통 API 응답 래퍼")
public class ApiResponse<T> {
/**
* 성공 여부
*/
@Schema(description = "성공 여부", example = "true")
private boolean success;
/**
* 메시지
*/
@Schema(description = "응답 메시지", example = "Success")
private String message;
/**
* 응답 데이터
*/
@Schema(description = "응답 데이터")
private T data;
/**
* 에러 코드 (실패 )
*/
@Schema(description = "에러 코드 (실패 시에만 존재)", example = "NOT_FOUND", nullable = true)
private String errorCode;
/**

파일 보기

@ -1,300 +0,0 @@
package com.snp.batch.common.web.controller;
import com.snp.batch.common.web.ApiResponse;
import com.snp.batch.common.web.service.BaseService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 모든 REST Controller의 공통 베이스 클래스
* CRUD API의 일관된 구조 제공
*
* 클래스는 추상 클래스이므로 @Tag를 붙이지 않습니다.
* 하위 클래스에서 @Tag를 정의하면 모든 엔드포인트가 해당 태그로 그룹화됩니다.
*
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
public abstract class BaseController<D, ID> {
/**
* Service 반환 (하위 클래스에서 구현)
*/
protected abstract BaseService<?, D, ID> getService();
/**
* 리소스 이름 반환 (로깅용)
*/
protected abstract String getResourceName();
/**
* 단건 생성
*/
@Operation(
summary = "리소스 생성",
description = "새로운 리소스를 생성합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "생성 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@PostMapping
public ResponseEntity<ApiResponse<D>> create(
@Parameter(description = "생성할 리소스 데이터", required = true)
@RequestBody D dto) {
log.info("{} 생성 요청", getResourceName());
try {
D created = getService().create(dto);
return ResponseEntity.ok(
ApiResponse.success(getResourceName() + " created successfully", created)
);
} catch (Exception e) {
log.error("{} 생성 실패", getResourceName(), e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 단건 조회
*/
@Operation(
summary = "리소스 조회",
description = "ID로 특정 리소스를 조회합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "리소스 없음"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@GetMapping("/{id}")
public ResponseEntity<ApiResponse<D>> getById(
@Parameter(description = "리소스 ID", required = true)
@PathVariable ID id) {
log.info("{} 조회 요청: ID={}", getResourceName(), id);
try {
return getService().findById(id)
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
.orElse(ResponseEntity.notFound().build());
} catch (Exception e) {
log.error("{} 조회 실패: ID={}", getResourceName(), id, e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 전체 조회
*/
@Operation(
summary = "전체 리소스 조회",
description = "모든 리소스를 조회합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@GetMapping
public ResponseEntity<ApiResponse<List<D>>> getAll() {
log.info("{} 전체 조회 요청", getResourceName());
try {
List<D> list = getService().findAll();
return ResponseEntity.ok(ApiResponse.success(list));
} catch (Exception e) {
log.error("{} 전체 조회 실패", getResourceName(), e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 페이징 조회 (JDBC 기반)
*
* @param offset 시작 위치 (기본값: 0)
* @param limit 조회 개수 (기본값: 20)
*/
@Operation(
summary = "페이징 조회",
description = "페이지 단위로 리소스를 조회합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "조회 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@GetMapping("/page")
public ResponseEntity<ApiResponse<List<D>>> getPage(
@Parameter(description = "시작 위치 (0부터 시작)", example = "0")
@RequestParam(defaultValue = "0") int offset,
@Parameter(description = "조회 개수", example = "20")
@RequestParam(defaultValue = "20") int limit) {
log.info("{} 페이징 조회 요청: offset={}, limit={}",
getResourceName(), offset, limit);
try {
List<D> list = getService().findAll(offset, limit);
long total = getService().count();
// 페이징 정보를 포함한 응답
return ResponseEntity.ok(
ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list)
);
} catch (Exception e) {
log.error("{} 페이징 조회 실패", getResourceName(), e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 단건 수정
*/
@Operation(
summary = "리소스 수정",
description = "기존 리소스를 수정합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "수정 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "리소스 없음"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@PutMapping("/{id}")
public ResponseEntity<ApiResponse<D>> update(
@Parameter(description = "리소스 ID", required = true)
@PathVariable ID id,
@Parameter(description = "수정할 리소스 데이터", required = true)
@RequestBody D dto) {
log.info("{} 수정 요청: ID={}", getResourceName(), id);
try {
D updated = getService().update(id, dto);
return ResponseEntity.ok(
ApiResponse.success(getResourceName() + " updated successfully", updated)
);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("{} 수정 실패: ID={}", getResourceName(), id, e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 단건 삭제
*/
@Operation(
summary = "리소스 삭제",
description = "기존 리소스를 삭제합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "삭제 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "404",
description = "리소스 없음"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@DeleteMapping("/{id}")
public ResponseEntity<ApiResponse<Void>> delete(
@Parameter(description = "리소스 ID", required = true)
@PathVariable ID id) {
log.info("{} 삭제 요청: ID={}", getResourceName(), id);
try {
getService().deleteById(id);
return ResponseEntity.ok(
ApiResponse.success(getResourceName() + " deleted successfully", null)
);
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
} catch (Exception e) {
log.error("{} 삭제 실패: ID={}", getResourceName(), id, e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage())
);
}
}
/**
* 존재 여부 확인
*/
@Operation(
summary = "리소스 존재 확인",
description = "특정 ID의 리소스가 존재하는지 확인합니다",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "200",
description = "확인 성공"
),
@io.swagger.v3.oas.annotations.responses.ApiResponse(
responseCode = "500",
description = "서버 오류"
)
}
)
@GetMapping("/{id}/exists")
public ResponseEntity<ApiResponse<Boolean>> exists(
@Parameter(description = "리소스 ID", required = true)
@PathVariable ID id) {
log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id);
try {
boolean exists = getService().existsById(id);
return ResponseEntity.ok(ApiResponse.success(exists));
} catch (Exception e) {
log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e);
return ResponseEntity.internalServerError().body(
ApiResponse.error("Failed to check existence: " + e.getMessage())
);
}
}
}

파일 보기

@ -1,33 +0,0 @@
package com.snp.batch.common.web.dto;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 모든 DTO의 공통 베이스 클래스
* 생성/수정 정보 공통 필드
*/
@Data
public abstract class BaseDto {
/**
* 생성 일시
*/
private LocalDateTime createdAt;
/**
* 수정 일시
*/
private LocalDateTime updatedAt;
/**
* 생성자
*/
private String createdBy;
/**
* 수정자
*/
private String updatedBy;
}

파일 보기

@ -1,202 +0,0 @@
package com.snp.batch.common.web.service;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.reactive.function.client.WebClient;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.Optional;
/**
* 하이브리드 서비스 Base 클래스 (DB 캐시 + 외부 API 프록시)
*
* 사용 시나리오:
* 1. 클라이언트 요청 DB 조회 (캐시 Hit)
* - 캐시 데이터 유효 즉시 반환
* 2. 캐시 Miss 또는 만료
* - 외부 서비스 API 호출
* - DB에 저장 (캐시 갱신)
* - 클라이언트에게 반환
*
* 장점:
* - 빠른 응답 (DB 캐시)
* - 외부 서비스 장애 시에도 캐시 데이터 제공 가능
* - 외부 API 호출 횟수 감소 (비용 절감)
*
* @param <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
/**
* WebClient 반환 (하위 클래스에서 구현)
*/
protected abstract WebClient getWebClient();
/**
* 외부 서비스 이름 반환
*/
protected abstract String getExternalServiceName();
/**
* 캐시 유효 시간 ()
* 기본값: 300초 (5분)
*/
protected long getCacheTtlSeconds() {
return 300;
}
/**
* 요청 타임아웃
*/
protected Duration getTimeout() {
return Duration.ofSeconds(30);
}
/**
* 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출
*
* @param id 조회
* @return DTO
*/
@Transactional
public D findByIdHybrid(ID id) {
log.info("[하이브리드] ID로 조회: {}", id);
// 1. DB 캐시 조회
Optional<D> cached = findById(id);
if (cached.isPresent()) {
// 캐시 유효성 검증
if (isCacheValid(cached.get())) {
log.info("[하이브리드] 캐시 Hit - DB에서 반환");
return cached.get();
} else {
log.info("[하이브리드] 캐시 만료 - 외부 API 호출");
}
} else {
log.info("[하이브리드] 캐시 Miss - 외부 API 호출");
}
// 2. 외부 API 호출
try {
D externalData = fetchFromExternalApi(id);
// 3. DB 저장 (캐시 갱신)
T entity = toEntity(externalData);
T saved = getRepository().save(entity);
log.info("[하이브리드] 외부 데이터 DB 저장 완료");
return toDto(saved);
} catch (Exception e) {
log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage());
// 4. 외부 API 실패 만료된 캐시라도 반환 (Fallback)
if (cached.isPresent()) {
log.warn("[하이브리드] Fallback - 만료된 캐시 반환");
return cached.get();
}
throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e);
}
}
/**
* 외부 API에서 데이터 조회 (하위 클래스에서 구현)
*
* @param id 조회
* @return DTO
*/
protected abstract D fetchFromExternalApi(ID id) throws Exception;
/**
* 캐시 유효성 검증
* 기본 구현: updated_at 기준으로 TTL 체크
*
* @param dto 캐시 데이터
* @return 유효 여부
*/
protected boolean isCacheValid(D dto) {
// BaseDto를 상속한 경우 updatedAt 체크
try {
LocalDateTime updatedAt = extractUpdatedAt(dto);
if (updatedAt == null) {
return false;
}
LocalDateTime now = LocalDateTime.now();
long elapsedSeconds = Duration.between(updatedAt, now).getSeconds();
return elapsedSeconds < getCacheTtlSeconds();
} catch (Exception e) {
log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage());
return false;
}
}
/**
* DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능)
*/
protected LocalDateTime extractUpdatedAt(D dto) {
// 기본 구현: 항상 캐시 무효 (외부 API 호출)
return null;
}
/**
* 강제 캐시 갱신 (외부 API 호출 강제)
*/
@Transactional
public D refreshCache(ID id) throws Exception {
log.info("[하이브리드] 캐시 강제 갱신: {}", id);
D externalData = fetchFromExternalApi(id);
T entity = toEntity(externalData);
T saved = getRepository().save(entity);
return toDto(saved);
}
/**
* 외부 API GET 요청
*/
protected <RES> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint);
return getWebClient()
.get()
.uri(uriBuilder -> {
uriBuilder.path(endpoint);
if (params != null) {
params.forEach(uriBuilder::queryParam);
}
return uriBuilder.build();
})
.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
}
/**
* 외부 API POST 요청
*/
protected <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
return getWebClient()
.post()
.uri(endpoint)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
}
}

파일 보기

@ -1,176 +0,0 @@
package com.snp.batch.common.web.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import java.time.Duration;
import java.util.Map;
/**
* 외부 API 프록시 서비스 Base 클래스
*
* 목적: 해외 외부 서비스를 국내에서 우회 접근할 있도록 프록시 역할 수행
*
* 사용 시나리오:
* - 외부 서비스가 해외에 있고 국내 IP에서만 접근 가능
* - 클라이언트 A 우리 서버 (국내) 외부 서비스 (해외) 응답 전달
*
* 장점:
* - 실시간 데이터 제공 (DB 캐시 없이)
* - 외부 서비스의 최신 데이터 보장
* - DB 저장 부담 없음
*
* @param <REQ> 요청 DTO 타입
* @param <RES> 응답 DTO 타입
*/
@Slf4j
public abstract class BaseProxyService<REQ, RES> {
/**
* WebClient 반환 (하위 클래스에서 구현)
* 외부 서비스별로 인증, Base URL 설정
*/
protected abstract WebClient getWebClient();
/**
* 외부 서비스 이름 반환 (로깅용)
*/
protected abstract String getServiceName();
/**
* 요청 타임아웃 (밀리초)
* 기본값: 30초
*/
protected Duration getTimeout() {
return Duration.ofSeconds(30);
}
/**
* GET 요청 프록시
*
* @param endpoint 엔드포인트 경로 (: "/api/ships")
* @param params 쿼리 파라미터
* @param responseType 응답 클래스 타입
* @return 외부 서비스 응답
*/
public RES proxyGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
try {
WebClient.RequestHeadersSpec<?> spec = getWebClient()
.get()
.uri(uriBuilder -> {
uriBuilder.path(endpoint);
if (params != null) {
params.forEach(uriBuilder::queryParam);
}
return uriBuilder.build();
});
RES response = spec.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
log.info("[{}] 응답 성공", getServiceName());
return response;
} catch (Exception e) {
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
}
}
/**
* POST 요청 프록시
*
* @param endpoint 엔드포인트 경로
* @param requestBody 요청 본문
* @param responseType 응답 클래스 타입
* @return 외부 서비스 응답
*/
public RES proxyPost(String endpoint, REQ requestBody, Class<RES> responseType) {
log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint);
try {
RES response = getWebClient()
.post()
.uri(endpoint)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
log.info("[{}] 응답 성공", getServiceName());
return response;
} catch (Exception e) {
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
}
}
/**
* PUT 요청 프록시
*/
public RES proxyPut(String endpoint, REQ requestBody, Class<RES> responseType) {
log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint);
try {
RES response = getWebClient()
.put()
.uri(endpoint)
.bodyValue(requestBody)
.retrieve()
.bodyToMono(responseType)
.timeout(getTimeout())
.block();
log.info("[{}] 응답 성공", getServiceName());
return response;
} catch (Exception e) {
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
}
}
/**
* DELETE 요청 프록시
*/
public void proxyDelete(String endpoint, Map<String, String> params) {
log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
try {
getWebClient()
.delete()
.uri(uriBuilder -> {
uriBuilder.path(endpoint);
if (params != null) {
params.forEach(uriBuilder::queryParam);
}
return uriBuilder.build();
})
.retrieve()
.bodyToMono(Void.class)
.timeout(getTimeout())
.block();
log.info("[{}] DELETE 성공", getServiceName());
} catch (Exception e) {
log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e);
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
}
}
/**
* 커스텀 요청 처리 (하위 클래스에서 오버라이드)
* 복잡한 로직이 필요한 경우 사용
*/
protected RES customRequest(REQ request) {
throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다");
}
}

파일 보기

@ -1,94 +0,0 @@
package com.snp.batch.common.web.service;
import java.util.List;
import java.util.Optional;
/**
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
* CRUD 기본 메서드 정의
*
* @param <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
public interface BaseService<T, D, ID> {
/**
* 단건 생성
*
* @param dto 생성할 데이터 DTO
* @return 생성된 데이터 DTO
*/
D create(D dto);
/**
* 단건 조회
*
* @param id 조회할 ID
* @return 조회된 데이터 DTO (Optional)
*/
Optional<D> findById(ID id);
/**
* 전체 조회
*
* @return 전체 데이터 DTO 리스트
*/
List<D> findAll();
/**
* 페이징 조회
*
* @param offset 시작 위치 (0부터 시작)
* @param limit 조회 개수
* @return 페이징된 데이터 리스트
*/
List<D> findAll(int offset, int limit);
/**
* 전체 개수 조회
*
* @return 전체 데이터 개수
*/
long count();
/**
* 단건 수정
*
* @param id 수정할 ID
* @param dto 수정할 데이터 DTO
* @return 수정된 데이터 DTO
*/
D update(ID id, D dto);
/**
* 단건 삭제
*
* @param id 삭제할 ID
*/
void deleteById(ID id);
/**
* 존재 여부 확인
*
* @param id 확인할 ID
* @return 존재 여부
*/
boolean existsById(ID id);
/**
* Entity를 DTO로 변환
*
* @param entity 엔티티
* @return DTO
*/
D toDto(T entity);
/**
* DTO를 Entity로 변환
*
* @param dto DTO
* @return 엔티티
*/
T toEntity(D dto);
}

파일 보기

@ -1,131 +0,0 @@
package com.snp.batch.common.web.service;
import com.snp.batch.common.batch.repository.BaseJdbcRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
/**
* BaseService의 기본 구현 (JDBC 기반)
* 공통 CRUD 로직 구현
*
* @param <T> Entity 타입
* @param <D> DTO 타입
* @param <ID> ID 타입
*/
@Slf4j
@Transactional(readOnly = true)
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
/**
* Repository 반환 (하위 클래스에서 구현)
*/
protected abstract BaseJdbcRepository<T, ID> getRepository();
/**
* 엔티티 이름 반환 (로깅용)
*/
protected abstract String getEntityName();
@Override
@Transactional
public D create(D dto) {
log.info("{} 생성 시작", getEntityName());
T entity = toEntity(dto);
T saved = getRepository().save(entity);
log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved));
return toDto(saved);
}
@Override
public Optional<D> findById(ID id) {
log.debug("{} 조회: ID={}", getEntityName(), id);
return getRepository().findById(id).map(this::toDto);
}
@Override
public List<D> findAll() {
log.debug("{} 전체 조회", getEntityName());
return getRepository().findAll().stream()
.map(this::toDto)
.collect(Collectors.toList());
}
@Override
public List<D> findAll(int offset, int limit) {
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
// 하위 클래스에서 제공하는 페이징 쿼리 실행
List<T> entities = executePagingQuery(offset, limit);
return entities.stream()
.map(this::toDto)
.collect(Collectors.toList());
}
/**
* 페이징 쿼리 실행 (하위 클래스에서 구현)
*
* @param offset 시작 위치
* @param limit 조회 개수
* @return Entity 리스트
*/
protected abstract List<T> executePagingQuery(int offset, int limit);
@Override
public long count() {
log.debug("{} 개수 조회", getEntityName());
return getRepository().count();
}
@Override
@Transactional
public D update(ID id, D dto) {
log.info("{} 수정 시작: ID={}", getEntityName(), id);
T entity = getRepository().findById(id)
.orElseThrow(() -> new IllegalArgumentException(
getEntityName() + " not found with id: " + id));
updateEntity(entity, dto);
T updated = getRepository().save(entity);
log.info("{} 수정 완료: ID={}", getEntityName(), id);
return toDto(updated);
}
@Override
@Transactional
public void deleteById(ID id) {
log.info("{} 삭제: ID={}", getEntityName(), id);
if (!getRepository().existsById(id)) {
throw new IllegalArgumentException(
getEntityName() + " not found with id: " + id);
}
getRepository().deleteById(id);
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
}
@Override
public boolean existsById(ID id) {
return getRepository().existsById(id);
}
/**
* Entity 업데이트 (하위 클래스에서 구현)
*
* @param entity 업데이트할 엔티티
* @param dto 업데이트 데이터
*/
protected abstract void updateEntity(T entity, D dto);
/**
* Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현)
*/
protected abstract ID extractId(T entity);
}

파일 보기

@ -15,9 +15,9 @@ import java.util.List;
* Swagger/OpenAPI 3.0 설정
*
* Swagger UI 접속 URL:
* - Swagger UI: http://localhost:8081/swagger-ui/index.html
* - API 문서 (JSON): http://localhost:8081/v3/api-docs
* - API 문서 (YAML): http://localhost:8081/v3/api-docs.yaml
* - Swagger UI: http://localhost:8041/snp-api/swagger-ui/index.html
* - API 문서 (JSON): http://localhost:8041/snp-api/v3/api-docs
* - API 문서 (YAML): http://localhost:8041/snp-api/v3/api-docs.yaml
*
* 주요 기능:
* - REST API 자동 문서화
@ -62,17 +62,19 @@ public class SwaggerConfig {
.description("""
## SNP Batch 시스템 REST API 문서
Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다.
해양 데이터 통합 배치 시스템의 REST API 문서입니다.
### 제공 API
- **Batch API**: 배치 Job 실행 관리
- **Product API**: 샘플 제품 데이터 CRUD (샘플용)
- **Batch Management API**: 배치 Job 실행, 이력 조회, 스케줄 관리
- **AIS Target API**: AIS 선박 위치 정보 조회 (캐시 기반, 공간/조건 검색)
### 주요 기능
- 배치 Job 실행 중지
- Job 실행 이력 조회
- 스케줄 관리 (Quartz)
- 제품 데이터 CRUD (샘플)
- AIS 선박 실시간 위치 조회 (MMSI 단건/다건, 시간/공간 범위 검색)
- 항해 조건 필터 검색 (SOG, COG, Heading, 목적지, 항행상태)
- 폴리곤/WKT 범위 검색, 거리 포함 검색, 항적 조회
### 버전 정보
- API Version: v1.0.0

파일 보기

@ -1,9 +1,6 @@
package com.snp.batch.global.controller;
import com.snp.batch.global.dto.JobExecutionDto;
import com.snp.batch.global.dto.JobLaunchRequest;
import com.snp.batch.global.dto.ScheduleRequest;
import com.snp.batch.global.dto.ScheduleResponse;
import com.snp.batch.global.dto.*;
import com.snp.batch.service.BatchService;
import com.snp.batch.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation;
@ -20,6 +17,8 @@ import org.springdoc.core.annotations.ParameterObject;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -101,7 +100,7 @@ public class BatchController {
})
@GetMapping("/jobs")
public ResponseEntity<List<String>> listJobs() {
log.info("Received request to list all jobs");
log.debug("Received request to list all jobs");
List<String> jobs = batchService.listAllJobs();
return ResponseEntity.ok(jobs);
}
@ -119,6 +118,16 @@ public class BatchController {
return ResponseEntity.ok(executions);
}
@Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다")
@GetMapping("/executions/recent")
public ResponseEntity<List<JobExecutionDto>> getRecentExecutions(
@Parameter(description = "조회 건수", example = "50")
@RequestParam(defaultValue = "50") int limit) {
log.debug("Received request to get recent executions: limit={}", limit);
List<JobExecutionDto> executions = batchService.getRecentExecutions(limit);
return ResponseEntity.ok(executions);
}
@GetMapping("/executions/{executionId}")
public ResponseEntity<JobExecutionDto> getExecutionDetails(@PathVariable Long executionId) {
log.info("Received request to get execution details for: {}", executionId);
@ -291,7 +300,7 @@ public class BatchController {
public ResponseEntity<com.snp.batch.global.dto.TimelineResponse> getTimeline(
@RequestParam String view,
@RequestParam String date) {
log.info("Received request to get timeline: view={}, date={}", view, date);
log.debug("Received request to get timeline: view={}, date={}", view, date);
try {
com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date);
return ResponseEntity.ok(timeline);
@ -303,7 +312,7 @@ public class BatchController {
@GetMapping("/dashboard")
public ResponseEntity<com.snp.batch.global.dto.DashboardResponse> getDashboard() {
log.info("Received request to get dashboard data");
log.debug("Received request to get dashboard data");
try {
com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData();
return ResponseEntity.ok(dashboard);
@ -327,4 +336,121 @@ public class BatchController {
return ResponseEntity.internalServerError().build();
}
}
// F1: 강제 종료(Abandon) API
@Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다")
@GetMapping("/executions/stale")
public ResponseEntity<List<JobExecutionDto>> getStaleExecutions(
@Parameter(description = "임계 시간(분)", example = "60")
@RequestParam(defaultValue = "60") int thresholdMinutes) {
log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes);
List<JobExecutionDto> executions = batchService.getStaleExecutions(thresholdMinutes);
return ResponseEntity.ok(executions);
}
@Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다")
@PostMapping("/executions/{executionId}/abandon")
public ResponseEntity<Map<String, Object>> abandonExecution(@PathVariable Long executionId) {
log.info("Received request to abandon execution: {}", executionId);
try {
batchService.abandonExecution(executionId);
return ResponseEntity.ok(Map.of(
"success", true,
"message", "Execution abandoned successfully"
));
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", e.getMessage()
));
} catch (Exception e) {
log.error("Error abandoning execution: {}", executionId, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to abandon execution: " + e.getMessage()
));
}
}
@Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다")
@PostMapping("/executions/stale/abandon-all")
public ResponseEntity<Map<String, Object>> abandonAllStaleExecutions(
@Parameter(description = "임계 시간(분)", example = "60")
@RequestParam(defaultValue = "60") int thresholdMinutes) {
log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes);
try {
int count = batchService.abandonAllStaleExecutions(thresholdMinutes);
return ResponseEntity.ok(Map.of(
"success", true,
"message", count + "건의 실행이 강제 종료되었습니다",
"abandonedCount", count
));
} catch (Exception e) {
log.error("Error abandoning all stale executions", e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "Failed to abandon stale executions: " + e.getMessage()
));
}
}
// F4: 실행 이력 검색 API
@Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)")
@GetMapping("/executions/search")
public ResponseEntity<ExecutionSearchResponse> searchExecutions(
@Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames,
@Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status,
@Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate,
@Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate,
@Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) {
log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}",
jobNames, status, startDate, endDate, page, size);
List<String> jobNameList = (jobNames != null && !jobNames.isBlank())
? java.util.Arrays.stream(jobNames.split(","))
.map(String::trim).filter(s -> !s.isEmpty()).toList()
: null;
LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null;
LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null;
ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size);
return ResponseEntity.ok(response);
}
// F7: Job 상세 목록 API
@Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다")
@GetMapping("/jobs/detail")
public ResponseEntity<List<JobDetailDto>> getJobsDetail() {
log.debug("Received request to get jobs with detail");
List<JobDetailDto> jobs = batchService.getJobsWithDetail();
return ResponseEntity.ok(jobs);
}
// F8: 실행 통계 API
@Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다")
@GetMapping("/statistics")
public ResponseEntity<ExecutionStatisticsDto> getStatistics(
@Parameter(description = "조회 기간(일)", example = "30")
@RequestParam(defaultValue = "30") int days) {
log.debug("Received request to get statistics: days={}", days);
ExecutionStatisticsDto stats = batchService.getStatistics(days);
return ResponseEntity.ok(stats);
}
@Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다")
@GetMapping("/statistics/{jobName}")
public ResponseEntity<ExecutionStatisticsDto> getJobStatistics(
@Parameter(description = "Job 이름", required = true) @PathVariable String jobName,
@Parameter(description = "조회 기간(일)", example = "30")
@RequestParam(defaultValue = "30") int days) {
log.debug("Received request to get statistics for job: {}, days={}", jobName, days);
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
return ResponseEntity.ok(stats);
}
}

파일 보기

@ -3,41 +3,18 @@ package com.snp.batch.global.controller;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
/**
* SPA(React) fallback 라우터
*
* React Router가 클라이언트 사이드 라우팅을 처리하므로,
* 모든 프론트 경로를 index.html로 포워딩한다.
*/
@Controller
public class WebViewController {
@GetMapping("/")
public String index() {
return "index";
}
@GetMapping("/jobs")
public String jobs() {
return "jobs";
}
@GetMapping("/executions")
public String executions() {
return "executions";
}
@GetMapping("/schedules")
public String schedules() {
return "schedules";
}
@GetMapping("/execution-detail")
public String executionDetail() {
return "execution-detail";
}
@GetMapping("/executions/{id}")
public String executionDetailById() {
return "execution-detail";
}
@GetMapping("/schedule-timeline")
public String scheduleTimeline() {
return "schedule-timeline";
@GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}",
"/execution-detail", "/schedules", "/schedule-timeline"})
public String forward() {
return "forward:/index.html";
}
}

파일 보기

@ -16,6 +16,9 @@ public class DashboardResponse {
private Stats stats;
private List<RunningJob> runningJobs;
private List<RecentExecution> recentExecutions;
private List<RecentFailure> recentFailures;
private int staleExecutionCount;
private FailureStats failureStats;
@Data
@Builder
@ -50,4 +53,26 @@ public class DashboardResponse {
private LocalDateTime startTime;
private LocalDateTime endTime;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class RecentFailure {
private Long executionId;
private String jobName;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
private String exitMessage;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class FailureStats {
private int last24h;
private int last7d;
}
}

파일 보기

@ -0,0 +1,21 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecutionSearchResponse {
private List<JobExecutionDto> executions;
private int totalCount;
private int page;
private int size;
private int totalPages;
}

파일 보기

@ -0,0 +1,33 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ExecutionStatisticsDto {
private List<DailyStat> dailyStats;
private int totalExecutions;
private int totalSuccess;
private int totalFailed;
private double avgDurationMs;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class DailyStat {
private String date;
private int successCount;
private int failedCount;
private int otherCount;
private double avgDurationMs;
}
}

파일 보기

@ -0,0 +1,30 @@
package com.snp.batch.global.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobDetailDto {
private String jobName;
private LastExecution lastExecution;
private String scheduleCron;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class LastExecution {
private Long executionId;
private String status;
private LocalDateTime startTime;
private LocalDateTime endTime;
}
}

파일 보기

@ -7,7 +7,7 @@ import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "batch_api_log", schema = "snp_data")
@Table(name = "batch_api_log", schema = "t_std_snp_data")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor

파일 보기

@ -3,10 +3,13 @@ package com.snp.batch.global.repository;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 타임라인 조회를 위한 경량 Repository
@ -33,6 +36,10 @@ public class TimelineRepository {
return tablePrefix + "JOB_INSTANCE";
}
private String getStepExecutionTable() {
return tablePrefix + "STEP_EXECUTION";
}
/**
* 특정 Job의 특정 범위 실행 이력 조회 (경량)
* Step Context를 조회하지 않아 성능이 매우 빠름
@ -112,7 +119,9 @@ public class TimelineRepository {
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
je.END_TIME as endTime,
je.EXIT_CODE as exitCode,
je.EXIT_MESSAGE as exitMessage
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
ORDER BY je.START_TIME DESC
@ -121,4 +130,263 @@ public class TimelineRepository {
return jdbcTemplate.queryForList(sql, limit);
}
// F1: 강제 종료(Abandon) 관련
/**
* 오래된 실행 Job 조회 (threshold 이상 STARTED/STARTING)
*/
public List<Map<String, Object>> findStaleExecutions(int thresholdMinutes) {
String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE je.STATUS IN ('STARTED', 'STARTING')
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
ORDER BY je.START_TIME ASC
""", getJobExecutionTable(), getJobInstanceTable(), thresholdMinutes);
return jdbcTemplate.queryForList(sql);
}
/**
* Job Execution 상태를 ABANDONED로 변경
*/
@Transactional
public int abandonJobExecution(long executionId) {
String sql = String.format("""
UPDATE %s
SET STATUS = 'ABANDONED',
EXIT_CODE = 'ABANDONED',
END_TIME = NOW(),
EXIT_MESSAGE = 'Force abandoned by admin'
WHERE JOB_EXECUTION_ID = ?
AND STATUS IN ('STARTED', 'STARTING')
""", getJobExecutionTable());
return jdbcTemplate.update(sql, executionId);
}
/**
* 해당 Job Execution의 Step Execution들도 ABANDONED로 변경
*/
@Transactional
public int abandonStepExecutions(long jobExecutionId) {
String sql = String.format("""
UPDATE %s
SET STATUS = 'ABANDONED',
EXIT_CODE = 'ABANDONED',
END_TIME = NOW(),
EXIT_MESSAGE = 'Force abandoned by admin'
WHERE JOB_EXECUTION_ID = ?
AND STATUS IN ('STARTED', 'STARTING')
""", getStepExecutionTable());
return jdbcTemplate.update(sql, jobExecutionId);
}
/**
* 오래된 실행 건수 조회
*/
public int countStaleExecutions(int thresholdMinutes) {
String sql = String.format("""
SELECT COUNT(*)
FROM %s je
WHERE je.STATUS IN ('STARTED', 'STARTING')
AND je.START_TIME < NOW() - INTERVAL '%d minutes'
""", getJobExecutionTable(), thresholdMinutes);
Integer count = jdbcTemplate.queryForObject(sql, Integer.class);
return count != null ? count : 0;
}
// F4: 실행 이력 검색 (페이지네이션)
/**
* 실행 이력 검색 (동적 조건 + 페이지네이션)
*/
public List<Map<String, Object>> searchExecutions(
List<String> jobNames, String status,
LocalDateTime startDate, LocalDateTime endDate,
int offset, int limit) {
StringBuilder sql = new StringBuilder(String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime,
je.EXIT_CODE as exitCode,
je.EXIT_MESSAGE as exitMessage
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE 1=1
""", getJobExecutionTable(), getJobInstanceTable()));
List<Object> params = new ArrayList<>();
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
sql.append(" ORDER BY je.START_TIME DESC LIMIT ? OFFSET ?");
params.add(limit);
params.add(offset);
return jdbcTemplate.queryForList(sql.toString(), params.toArray());
}
/**
* 실행 이력 검색 건수
*/
public int countExecutions(
List<String> jobNames, String status,
LocalDateTime startDate, LocalDateTime endDate) {
StringBuilder sql = new StringBuilder(String.format("""
SELECT COUNT(*)
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE 1=1
""", getJobExecutionTable(), getJobInstanceTable()));
List<Object> params = new ArrayList<>();
appendSearchConditions(sql, params, jobNames, status, startDate, endDate);
Integer count = jdbcTemplate.queryForObject(sql.toString(), Integer.class, params.toArray());
return count != null ? count : 0;
}
private void appendSearchConditions(
StringBuilder sql, List<Object> params,
List<String> jobNames, String status,
LocalDateTime startDate, LocalDateTime endDate) {
if (jobNames != null && !jobNames.isEmpty()) {
String placeholders = jobNames.stream().map(n -> "?").collect(Collectors.joining(", "));
sql.append(" AND ji.JOB_NAME IN (").append(placeholders).append(")");
params.addAll(jobNames);
}
if (status != null && !status.isBlank()) {
sql.append(" AND je.STATUS = ?");
params.add(status);
}
if (startDate != null) {
sql.append(" AND je.START_TIME >= ?");
params.add(startDate);
}
if (endDate != null) {
sql.append(" AND je.START_TIME < ?");
params.add(endDate);
}
}
// F6: 대시보드 실패 통계
/**
* 최근 실패 이력 조회
*/
public List<Map<String, Object>> findRecentFailures(int hours) {
String sql = String.format("""
SELECT
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime,
je.EXIT_MESSAGE as exitMessage
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE je.STATUS = 'FAILED'
AND je.START_TIME >= NOW() - INTERVAL '%d hours'
ORDER BY je.START_TIME DESC
LIMIT 10
""", getJobExecutionTable(), getJobInstanceTable(), hours);
return jdbcTemplate.queryForList(sql);
}
/**
* 특정 시점 이후 실패 건수
*/
public int countFailuresSince(LocalDateTime since) {
String sql = String.format("""
SELECT COUNT(*)
FROM %s je
WHERE je.STATUS = 'FAILED'
AND je.START_TIME >= ?
""", getJobExecutionTable());
Integer count = jdbcTemplate.queryForObject(sql, Integer.class, since);
return count != null ? count : 0;
}
// F7: Job별 최근 실행 정보
/**
* Job별 가장 최근 실행 정보 조회 (DISTINCT ON 활용)
*/
public List<Map<String, Object>> findLastExecutionPerJob() {
String sql = String.format("""
SELECT DISTINCT ON (ji.JOB_NAME)
ji.JOB_NAME as jobName,
je.JOB_EXECUTION_ID as executionId,
je.STATUS as status,
je.START_TIME as startTime,
je.END_TIME as endTime
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
ORDER BY ji.JOB_NAME, je.START_TIME DESC
""", getJobExecutionTable(), getJobInstanceTable());
return jdbcTemplate.queryForList(sql);
}
// F8: 실행 통계
/**
* 일별 실행 통계 (전체)
*/
public List<Map<String, Object>> findDailyStatistics(int days) {
String sql = String.format("""
SELECT
CAST(je.START_TIME AS DATE) as execDate,
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
FROM %s je
WHERE je.START_TIME >= NOW() - INTERVAL '%d days'
AND je.START_TIME IS NOT NULL
GROUP BY CAST(je.START_TIME AS DATE)
ORDER BY execDate
""", getJobExecutionTable(), days);
return jdbcTemplate.queryForList(sql);
}
/**
* 일별 실행 통계 (특정 Job)
*/
public List<Map<String, Object>> findDailyStatisticsForJob(String jobName, int days) {
String sql = String.format("""
SELECT
CAST(je.START_TIME AS DATE) as execDate,
SUM(CASE WHEN je.STATUS = 'COMPLETED' THEN 1 ELSE 0 END) as successCount,
SUM(CASE WHEN je.STATUS = 'FAILED' THEN 1 ELSE 0 END) as failedCount,
SUM(CASE WHEN je.STATUS NOT IN ('COMPLETED', 'FAILED') THEN 1 ELSE 0 END) as otherCount,
AVG(EXTRACT(EPOCH FROM (je.END_TIME - je.START_TIME)) * 1000) as avgDurationMs
FROM %s je
INNER JOIN %s ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID
WHERE ji.JOB_NAME = ?
AND je.START_TIME >= NOW() - INTERVAL '%d days'
AND je.START_TIME IS NOT NULL
GROUP BY CAST(je.START_TIME AS DATE)
ORDER BY execDate
""", getJobExecutionTable(), getJobInstanceTable(), days);
return jdbcTemplate.queryForList(sql, jobName);
}
}

파일 보기

@ -84,6 +84,14 @@ public class AisTargetEntity extends BaseEntity {
private OffsetDateTime receivedDate;
private OffsetDateTime collectedAt; // 배치 수집 시점
// ========== 선종 분류 정보 ==========
/**
* MDA 범례코드 (signalKindCode)
* - vesselType + extraInfo 기반으로 치환
* - : "000020"(어선), "000023"(카고), "000027"(일반/기타)
*/
private String signalKindCode;
// ========== ClassType 분류 정보 ==========
/**
* 선박 클래스 타입

파일 보기

@ -26,6 +26,14 @@ public interface AisTargetRepository {
*/
List<AisTargetEntity> findLatestByMmsiIn(List<Long> mmsiList);
/**
* 여러 MMSI의 최신 위치 조회 (시간 범위 필터)
*
* @param mmsiList 대상 MMSI 목록
* @param since 시점 이후 데이터만 조회
*/
List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since);
/**
* 시간 범위 특정 MMSI의 항적 조회
*/

파일 보기

@ -1,8 +1,8 @@
package com.snp.batch.jobs.aistarget.batch.repository;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Repository;
@ -19,111 +19,118 @@ import java.util.Optional;
/**
* AIS Target Repository 구현체
*
* 테이블: snp_data.ais_target
* 테이블: {targetSchema}.ais_target
* PK: mmsi + message_timestamp (복합키)
*/
@Slf4j
@Repository
@RequiredArgsConstructor
public class AisTargetRepositoryImpl implements AisTargetRepository {
private final JdbcTemplate jdbcTemplate;
private final String tableName;
private final String upsertSql;
private static final String TABLE_NAME = "snp_data.ais_target";
public AisTargetRepositoryImpl(JdbcTemplate jdbcTemplate,
@Value("${app.batch.target-schema.name}") String targetSchema) {
this.jdbcTemplate = jdbcTemplate;
this.tableName = targetSchema + ".ais_target";
this.upsertSql = buildUpsertSql(targetSchema);
}
// ==================== UPSERT SQL ====================
private String buildUpsertSql(String schema) {
return """
INSERT INTO %s.ais_target (
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
lat, lon, geom,
heading, sog, cog, rot,
length, width, draught, length_bow, length_stern, width_port, width_starboard,
destination, eta, status,
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
radio_status, regional, regional2, spare, spare2,
ais_version, position_fix_type, dte, band_flag,
received_date, collected_at, created_at, updated_at,
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
lpc_code, message_type, "source", station_id, zone_id
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, NOW(), NOW(),
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
imo = EXCLUDED.imo,
name = EXCLUDED.name,
callsign = EXCLUDED.callsign,
vessel_type = EXCLUDED.vessel_type,
extra_info = EXCLUDED.extra_info,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
geom = EXCLUDED.geom,
heading = EXCLUDED.heading,
sog = EXCLUDED.sog,
cog = EXCLUDED.cog,
rot = EXCLUDED.rot,
length = EXCLUDED.length,
width = EXCLUDED.width,
draught = EXCLUDED.draught,
length_bow = EXCLUDED.length_bow,
length_stern = EXCLUDED.length_stern,
width_port = EXCLUDED.width_port,
width_starboard = EXCLUDED.width_starboard,
destination = EXCLUDED.destination,
eta = EXCLUDED.eta,
status = EXCLUDED.status,
age_minutes = EXCLUDED.age_minutes,
position_accuracy = EXCLUDED.position_accuracy,
timestamp_utc = EXCLUDED.timestamp_utc,
repeat_indicator = EXCLUDED.repeat_indicator,
raim_flag = EXCLUDED.raim_flag,
radio_status = EXCLUDED.radio_status,
regional = EXCLUDED.regional,
regional2 = EXCLUDED.regional2,
spare = EXCLUDED.spare,
spare2 = EXCLUDED.spare2,
ais_version = EXCLUDED.ais_version,
position_fix_type = EXCLUDED.position_fix_type,
dte = EXCLUDED.dte,
band_flag = EXCLUDED.band_flag,
received_date = EXCLUDED.received_date,
collected_at = EXCLUDED.collected_at,
updated_at = NOW(),
tonnes_cargo = EXCLUDED.tonnes_cargo,
in_sts = EXCLUDED.in_sts,
on_berth = EXCLUDED.on_berth,
dwt = EXCLUDED.dwt,
anomalous = EXCLUDED.anomalous,
destination_port_id = EXCLUDED.destination_port_id,
destination_tidied = EXCLUDED.destination_tidied,
destination_unlocode = EXCLUDED.destination_unlocode,
imo_verified = EXCLUDED.imo_verified,
last_static_update_received = EXCLUDED.last_static_update_received,
lpc_code = EXCLUDED.lpc_code,
message_type = EXCLUDED.message_type,
"source" = EXCLUDED."source",
station_id = EXCLUDED.station_id,
zone_id = EXCLUDED.zone_id
""".formatted(schema);
}
private static final String UPSERT_SQL = """
INSERT INTO snp_data.ais_target (
mmsi, message_timestamp, imo, name, callsign, vessel_type, extra_info,
lat, lon, geom,
heading, sog, cog, rot,
length, width, draught, length_bow, length_stern, width_port, width_starboard,
destination, eta, status,
age_minutes, position_accuracy, timestamp_utc, repeat_indicator, raim_flag,
radio_status, regional, regional2, spare, spare2,
ais_version, position_fix_type, dte, band_flag,
received_date, collected_at, created_at, updated_at,
tonnes_cargo, in_sts, on_berth, dwt, anomalous,
destination_port_id, destination_tidied, destination_unlocode, imo_verified, last_static_update_received,
lpc_code, message_type, "source", station_id, zone_id
) VALUES (
?, ?, ?, ?, ?, ?, ?,
?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326),
?, ?, ?, ?,
?, ?, ?, ?, ?, ?, ?,
?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?,
?, ?, NOW(), NOW(),
?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, ?
)
ON CONFLICT (mmsi, message_timestamp) DO UPDATE SET
imo = EXCLUDED.imo,
name = EXCLUDED.name,
callsign = EXCLUDED.callsign,
vessel_type = EXCLUDED.vessel_type,
extra_info = EXCLUDED.extra_info,
lat = EXCLUDED.lat,
lon = EXCLUDED.lon,
geom = EXCLUDED.geom,
heading = EXCLUDED.heading,
sog = EXCLUDED.sog,
cog = EXCLUDED.cog,
rot = EXCLUDED.rot,
length = EXCLUDED.length,
width = EXCLUDED.width,
draught = EXCLUDED.draught,
length_bow = EXCLUDED.length_bow,
length_stern = EXCLUDED.length_stern,
width_port = EXCLUDED.width_port,
width_starboard = EXCLUDED.width_starboard,
destination = EXCLUDED.destination,
eta = EXCLUDED.eta,
status = EXCLUDED.status,
age_minutes = EXCLUDED.age_minutes,
position_accuracy = EXCLUDED.position_accuracy,
timestamp_utc = EXCLUDED.timestamp_utc,
repeat_indicator = EXCLUDED.repeat_indicator,
raim_flag = EXCLUDED.raim_flag,
radio_status = EXCLUDED.radio_status,
regional = EXCLUDED.regional,
regional2 = EXCLUDED.regional2,
spare = EXCLUDED.spare,
spare2 = EXCLUDED.spare2,
ais_version = EXCLUDED.ais_version,
position_fix_type = EXCLUDED.position_fix_type,
dte = EXCLUDED.dte,
band_flag = EXCLUDED.band_flag,
received_date = EXCLUDED.received_date,
collected_at = EXCLUDED.collected_at,
updated_at = NOW(),
tonnes_cargo = EXCLUDED.tonnes_cargo,
in_sts = EXCLUDED.in_sts,
on_berth = EXCLUDED.on_berth,
dwt = EXCLUDED.dwt,
anomalous = EXCLUDED.anomalous,
destination_port_id = EXCLUDED.destination_port_id,
destination_tidied = EXCLUDED.destination_tidied,
destination_unlocode = EXCLUDED.destination_unlocode,
imo_verified = EXCLUDED.imo_verified,
last_static_update_received = EXCLUDED.last_static_update_received,
lpc_code = EXCLUDED.lpc_code,
message_type = EXCLUDED.message_type,
"source" = EXCLUDED."source",
station_id = EXCLUDED.station_id,
zone_id = EXCLUDED.zone_id
""";
// ==================== RowMapper ====================
private final RowMapper<AisTargetEntity> rowMapper = (rs, rowNum) -> AisTargetEntity.builder()
.mmsi(rs.getLong("mmsi"))
.messageTimestamp(toOffsetDateTime(rs.getTimestamp("message_timestamp")))
.imo(rs.getObject("imo", Long.class))
.imo(toLong(rs, "imo"))
.name(rs.getString("name"))
.callsign(rs.getString("callsign"))
.vesselType(rs.getString("vessel_type"))
@ -133,45 +140,45 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
.heading(rs.getObject("heading", Double.class))
.sog(rs.getObject("sog", Double.class))
.cog(rs.getObject("cog", Double.class))
.rot(rs.getObject("rot", Integer.class))
.length(rs.getObject("length", Integer.class))
.width(rs.getObject("width", Integer.class))
.rot(toInt(rs, "rot"))
.length(toInt(rs, "length"))
.width(toInt(rs, "width"))
.draught(rs.getObject("draught", Double.class))
.lengthBow(rs.getObject("length_bow", Integer.class))
.lengthStern(rs.getObject("length_stern", Integer.class))
.widthPort(rs.getObject("width_port", Integer.class))
.widthStarboard(rs.getObject("width_starboard", Integer.class))
.lengthBow(toInt(rs, "length_bow"))
.lengthStern(toInt(rs, "length_stern"))
.widthPort(toInt(rs, "width_port"))
.widthStarboard(toInt(rs, "width_starboard"))
.destination(rs.getString("destination"))
.eta(toOffsetDateTime(rs.getTimestamp("eta")))
.status(rs.getString("status"))
.ageMinutes(rs.getObject("age_minutes", Double.class))
.positionAccuracy(rs.getObject("position_accuracy", Integer.class))
.timestampUtc(rs.getObject("timestamp_utc", Integer.class))
.repeatIndicator(rs.getObject("repeat_indicator", Integer.class))
.raimFlag(rs.getObject("raim_flag", Integer.class))
.radioStatus(rs.getObject("radio_status", Integer.class))
.regional(rs.getObject("regional", Integer.class))
.regional2(rs.getObject("regional2", Integer.class))
.spare(rs.getObject("spare", Integer.class))
.spare2(rs.getObject("spare2", Integer.class))
.aisVersion(rs.getObject("ais_version", Integer.class))
.positionFixType(rs.getObject("position_fix_type", Integer.class))
.dte(rs.getObject("dte", Integer.class))
.bandFlag(rs.getObject("band_flag", Integer.class))
.positionAccuracy(toInt(rs, "position_accuracy"))
.timestampUtc(toInt(rs, "timestamp_utc"))
.repeatIndicator(toInt(rs, "repeat_indicator"))
.raimFlag(toInt(rs, "raim_flag"))
.radioStatus(toInt(rs, "radio_status"))
.regional(toInt(rs, "regional"))
.regional2(toInt(rs, "regional2"))
.spare(toInt(rs, "spare"))
.spare2(toInt(rs, "spare2"))
.aisVersion(toInt(rs, "ais_version"))
.positionFixType(toInt(rs, "position_fix_type"))
.dte(toInt(rs, "dte"))
.bandFlag(toInt(rs, "band_flag"))
.receivedDate(toOffsetDateTime(rs.getTimestamp("received_date")))
.collectedAt(toOffsetDateTime(rs.getTimestamp("collected_at")))
.tonnesCargo(rs.getObject("tonnes_cargo", Integer.class))
.inSTS(rs.getObject("in_sts", Integer.class))
.tonnesCargo(toInt(rs, "tonnes_cargo"))
.inSTS(toInt(rs, "in_sts"))
.onBerth(rs.getObject("on_berth", Boolean.class))
.dwt(rs.getObject("dwt", Integer.class))
.dwt(toInt(rs, "dwt"))
.anomalous(rs.getString("anomalous"))
.destinationPortID(rs.getObject("destination_port_id", Integer.class))
.destinationPortID(toInt(rs, "destination_port_id"))
.destinationTidied(rs.getString("destination_tidied"))
.destinationUNLOCODE(rs.getString("destination_unlocode"))
.imoVerified(rs.getString("imo_verified"))
.lastStaticUpdateReceived(toOffsetDateTime(rs.getTimestamp("last_static_update_received")))
.lpcCode(rs.getObject("lpc_code", Integer.class))
.messageType(rs.getObject("message_type", Integer.class))
.lpcCode(toInt(rs, "lpc_code"))
.messageType(toInt(rs, "message_type"))
.source(rs.getString("source"))
.stationId(rs.getString("station_id"))
.zoneId(rs.getObject("zone_id", Double.class))
@ -181,7 +188,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
@Override
public Optional<AisTargetEntity> findByMmsiAndMessageTimestamp(Long mmsi, OffsetDateTime messageTimestamp) {
String sql = "SELECT * FROM " + TABLE_NAME + " WHERE mmsi = ? AND message_timestamp = ?";
String sql = "SELECT * FROM " + tableName + " WHERE mmsi = ? AND message_timestamp = ?";
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(messageTimestamp));
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@ -193,7 +200,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
WHERE mmsi = ?
ORDER BY message_timestamp DESC
LIMIT 1
""".formatted(TABLE_NAME);
""".formatted(tableName);
List<AisTargetEntity> results = jdbcTemplate.query(sql, rowMapper, mmsi);
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
}
@ -210,12 +217,30 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
FROM %s
WHERE mmsi = ANY(?)
ORDER BY mmsi, message_timestamp DESC
""".formatted(TABLE_NAME);
""".formatted(tableName);
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray);
}
@Override
public List<AisTargetEntity> findLatestByMmsiInSince(List<Long> mmsiList, OffsetDateTime since) {
if (mmsiList == null || mmsiList.isEmpty()) {
return List.of();
}
String sql = """
SELECT DISTINCT ON (mmsi) *
FROM %s
WHERE mmsi = ANY(?)
AND message_timestamp >= ?
ORDER BY mmsi, message_timestamp DESC
""".formatted(tableName);
Long[] mmsiArray = mmsiList.toArray(new Long[0]);
return jdbcTemplate.query(sql, rowMapper, (Object) mmsiArray, toTimestamp(since));
}
@Override
public List<AisTargetEntity> findByMmsiAndTimeRange(Long mmsi, OffsetDateTime start, OffsetDateTime end) {
String sql = """
@ -223,7 +248,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
WHERE mmsi = ?
AND message_timestamp BETWEEN ? AND ?
ORDER BY message_timestamp ASC
""".formatted(TABLE_NAME);
""".formatted(tableName);
return jdbcTemplate.query(sql, rowMapper, mmsi, toTimestamp(start), toTimestamp(end));
}
@ -245,7 +270,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
?
)
ORDER BY mmsi, message_timestamp DESC
""".formatted(TABLE_NAME);
""".formatted(tableName);
return jdbcTemplate.query(sql, rowMapper,
toTimestamp(start), toTimestamp(end),
@ -261,7 +286,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
log.info("AIS Target 배치 UPSERT 시작: {} 건", entities.size());
jdbcTemplate.batchUpdate(UPSERT_SQL, entities, 1000, (ps, entity) -> {
jdbcTemplate.batchUpdate(upsertSql, entities, 1000, (ps, entity) -> {
int idx = 1;
// PK
ps.setLong(idx++, entity.getMmsi());
@ -336,7 +361,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
@Override
public long count() {
String sql = "SELECT COUNT(*) FROM " + TABLE_NAME;
String sql = "SELECT COUNT(*) FROM " + tableName;
Long count = jdbcTemplate.queryForObject(sql, Long.class);
return count != null ? count : 0L;
}
@ -344,7 +369,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
@Override
@Transactional
public int deleteOlderThan(OffsetDateTime threshold) {
String sql = "DELETE FROM " + TABLE_NAME + " WHERE message_timestamp < ?";
String sql = "DELETE FROM " + tableName + " WHERE message_timestamp < ?";
int deleted = jdbcTemplate.update(sql, toTimestamp(threshold));
log.info("AIS Target 오래된 데이터 삭제 완료: {} 건 (기준: {})", deleted, threshold);
return deleted;
@ -352,6 +377,23 @@ public class AisTargetRepositoryImpl implements AisTargetRepository {
// ==================== Helper Methods ====================
/**
* int8(bigint) Integer 안전 변환
* PostgreSQL JDBC 드라이버는 int8 Integer 자동 변환을 지원하지 않아
* getObject("col", Integer.class) 사용 오류 발생. Number로 읽어서 변환.
*/
private Integer toInt(ResultSet rs, String column) throws SQLException {
Object val = rs.getObject(column);
if (val == null) return null;
return ((Number) val).intValue();
}
private Long toLong(ResultSet rs, String column) throws SQLException {
Object val = rs.getObject(column);
if (val == null) return null;
return ((Number) val).longValue();
}
private Timestamp toTimestamp(OffsetDateTime odt) {
return odt != null ? Timestamp.from(odt.toInstant()) : null;
}

파일 보기

@ -3,8 +3,12 @@ package com.snp.batch.jobs.aistarget.batch.writer;
import com.snp.batch.common.batch.writer.BaseWriter;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier;
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
import java.util.List;
@ -14,11 +18,15 @@ import java.util.List;
*
* 동작:
* 1. ClassType 분류 (Core20 캐시 기반 A/B 분류)
* 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함)
* 2. SignalKindCode 치환 (vesselType + extraInfo MDA 범례코드)
* 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함)
* 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
* 5. Kafka 토픽으로 AIS Target 정보 전송 (활성화된 경우에만)
*
* 참고:
* - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행
* - Writer는 캐시 업데이트만 담당
* - Kafka 전송 실패는 기본적으로 로그만 남기고 다음 처리 계속
* - Kafka가 비활성화(enabled=false)이면 kafkaProducer가 null이므로 전송 단계를 스킵
*/
@Slf4j
@Component
@ -26,13 +34,20 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
private final AisTargetCacheManager cacheManager;
private final AisClassTypeClassifier classTypeClassifier;
@Nullable
private final AisTargetKafkaProducer kafkaProducer;
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
public AisTargetDataWriter(
AisTargetCacheManager cacheManager,
AisClassTypeClassifier classTypeClassifier) {
AisClassTypeClassifier classTypeClassifier,
@Nullable AisTargetKafkaProducer kafkaProducer,
ChnPrmShipCacheManager chnPrmShipCacheManager) {
super("AisTarget");
this.cacheManager = cacheManager;
this.classTypeClassifier = classTypeClassifier;
this.kafkaProducer = kafkaProducer;
this.chnPrmShipCacheManager = chnPrmShipCacheManager;
}
@Override
@ -43,10 +58,33 @@ public class AisTargetDataWriter extends BaseWriter<AisTargetEntity> {
// - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정
classTypeClassifier.classifyAll(items);
// 2. 캐시 업데이트 (classType, core20Mmsi 포함)
// 2. SignalKindCode 치환 (vesselType + extraInfo MDA 범례코드)
items.forEach(item -> {
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo());
item.setSignalKindCode(kindCode.getCode());
});
// 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함)
cacheManager.putAll(items);
log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})",
items.size(), cacheManager.size());
// 4. ChnPrmShip 전용 캐시 업데이트 (대상 MMSI만 필터)
chnPrmShipCacheManager.putIfTarget(items);
// 5. Kafka 전송 (kafkaProducer 빈이 존재하는 경우에만)
if (kafkaProducer == null) {
log.debug("AIS Kafka Producer 미등록 - topic 전송 스킵");
return;
}
AisTargetKafkaProducer.PublishSummary summary = kafkaProducer.publish(items);
log.info("AIS Kafka 전송 완료 - topic: {}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
kafkaProducer.getTopic(),
summary.getRequestedCount(),
summary.getSuccessCount(),
summary.getFailedCount(),
summary.getSkippedCount());
}
}

파일 보기

@ -0,0 +1,131 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import jakarta.annotation.PostConstruct;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 중국 허가선박 전용 캐시
*
* - 대상 MMSI(~1,400척) 별도 관리
* - TTL: expireAfterWrite (마지막 put 이후 N일 경과 만료)
* - 순수 캐시 조회 전용 (DB fallback 없음)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChnPrmShipCacheManager {
private final ChnPrmShipProperties properties;
private Cache<Long, AisTargetEntity> cache;
@PostConstruct
public void init() {
this.cache = Caffeine.newBuilder()
.maximumSize(properties.getMaxSize())
.expireAfterWrite(properties.getTtlDays(), TimeUnit.DAYS)
.recordStats()
.build();
log.info("ChnPrmShip 캐시 초기화 - TTL: {}일, 최대 크기: {}",
properties.getTtlDays(), properties.getMaxSize());
}
/**
* 대상 MMSI에 해당하는 항목만 필터링하여 캐시에 저장
*
* @param items 전체 AIS Target 데이터 (배치 수집 결과)
* @return 저장된 건수
*/
public int putIfTarget(List<AisTargetEntity> items) {
if (items == null || items.isEmpty()) {
return 0;
}
int updated = 0;
for (AisTargetEntity item : items) {
if (!properties.isTarget(item.getMmsi())) {
continue;
}
AisTargetEntity existing = cache.getIfPresent(item.getMmsi());
if (existing == null || isNewerOrEqual(item, existing)) {
cache.put(item.getMmsi(), item);
updated++;
}
}
if (updated > 0) {
log.debug("ChnPrmShip 캐시 업데이트 - 입력: {}, 대상 저장: {}, 현재 크기: {}",
items.size(), updated, cache.estimatedSize());
}
return updated;
}
/**
* 시간 범위 캐시 데이터 조회
*
* @param minutes 조회 범위 ()
* @return 시간 범위 데이터 목록
*/
public List<AisTargetEntity> getByTimeRange(int minutes) {
OffsetDateTime threshold = OffsetDateTime.now(ZoneOffset.UTC).minusMinutes(minutes);
return cache.asMap().values().stream()
.filter(entity -> entity.getMessageTimestamp() != null)
.filter(entity -> entity.getMessageTimestamp().isAfter(threshold))
.collect(Collectors.toList());
}
/**
* 워밍업용 직접 저장 (시간 비교 없이 저장)
*/
public void putAll(List<AisTargetEntity> entities) {
if (entities == null || entities.isEmpty()) {
return;
}
for (AisTargetEntity entity : entities) {
if (entity != null && entity.getMmsi() != null) {
cache.put(entity.getMmsi(), entity);
}
}
}
public long size() {
return cache.estimatedSize();
}
public Map<String, Object> getStats() {
var stats = cache.stats();
return Map.of(
"estimatedSize", cache.estimatedSize(),
"maxSize", properties.getMaxSize(),
"ttlDays", properties.getTtlDays(),
"targetMmsiCount", properties.getMmsiSet().size(),
"hitCount", stats.hitCount(),
"missCount", stats.missCount(),
"hitRate", String.format("%.2f%%", stats.hitRate() * 100)
);
}
private boolean isNewerOrEqual(AisTargetEntity candidate, AisTargetEntity existing) {
if (candidate.getMessageTimestamp() == null) {
return false;
}
if (existing.getMessageTimestamp() == null) {
return true;
}
return !candidate.getMessageTimestamp().isBefore(existing.getMessageTimestamp());
}
}

파일 보기

@ -0,0 +1,79 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.classifier.SignalKindCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.List;
/**
* 기동 ChnPrmShip 캐시 워밍업
*
* DB(ais_target)에서 대상 MMSI의 최근 데이터를 조회하여 캐시를 채운다.
* 이후 배치 수집에서 실시간 데이터가 캐시를 갱신한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChnPrmShipCacheWarmer implements ApplicationRunner {
private static final int DB_QUERY_CHUNK_SIZE = 500;
private final ChnPrmShipProperties properties;
private final ChnPrmShipCacheManager cacheManager;
private final AisTargetRepository aisTargetRepository;
@Override
public void run(ApplicationArguments args) {
if (!properties.isWarmupEnabled()) {
log.info("ChnPrmShip 캐시 워밍업 비활성화");
return;
}
if (properties.getMmsiSet().isEmpty()) {
log.warn("ChnPrmShip 대상 MMSI가 없어 워밍업을 건너뜁니다");
return;
}
OffsetDateTime since = OffsetDateTime.now(ZoneOffset.UTC)
.minusDays(properties.getWarmupDays());
log.info("ChnPrmShip 캐시 워밍업 시작 - 대상: {}건, 조회 범위: 최근 {}일 (since: {})",
properties.getMmsiSet().size(), properties.getWarmupDays(), since);
long startTime = System.currentTimeMillis();
List<Long> mmsiList = new ArrayList<>(properties.getMmsiSet());
int totalLoaded = 0;
for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) {
List<Long> chunk = mmsiList.subList(i,
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
List<AisTargetEntity> fromDb = aisTargetRepository.findLatestByMmsiInSince(chunk, since);
// signalKindCode 치환 (DB 데이터는 치환이 되어 있을 있음)
fromDb.forEach(entity -> {
if (entity.getSignalKindCode() == null) {
SignalKindCode kindCode = SignalKindCode.resolve(
entity.getVesselType(), entity.getExtraInfo());
entity.setSignalKindCode(kindCode.getCode());
}
});
cacheManager.putAll(fromDb);
totalLoaded += fromDb.size();
}
long elapsed = System.currentTimeMillis() - startTime;
log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms",
properties.getMmsiSet().size(), totalLoaded, elapsed);
}
}

파일 보기

@ -0,0 +1,82 @@
package com.snp.batch.jobs.aistarget.chnprmship;
import jakarta.annotation.PostConstruct;
import lombok.Getter;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 중국 허가선박(ChnPrmShip) 설정
*
* 대상 MMSI 목록을 리소스 파일에서 로딩하여 Set으로 보관한다.
*/
@Slf4j
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.chnprmship")
public class ChnPrmShipProperties {
/**
* MMSI 목록 리소스 경로
*/
private String mmsiResourcePath = "classpath:chnprmship-mmsi.txt";
/**
* 캐시 TTL ()
* - 마지막 put() 이후 기간이 지나면 만료
*/
private int ttlDays = 2;
/**
* 최대 캐시 크기
*/
private int maxSize = 2000;
/**
* 기동 DB 워밍업 활성화 여부
*/
private boolean warmupEnabled = true;
/**
* DB 워밍업 조회 범위 ()
*/
private int warmupDays = 2;
/**
* 로딩된 대상 MMSI 집합
*/
private Set<Long> mmsiSet = Collections.emptySet();
@PostConstruct
public void init() {
try {
Resource resource = new DefaultResourceLoader().getResource(mmsiResourcePath);
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) {
mmsiSet = reader.lines()
.map(String::trim)
.filter(line -> !line.isEmpty() && !line.startsWith("#"))
.map(Long::parseLong)
.collect(Collectors.toUnmodifiableSet());
}
log.info("ChnPrmShip MMSI 로딩 완료 - {}건 (경로: {})", mmsiSet.size(), mmsiResourcePath);
} catch (Exception e) {
log.error("ChnPrmShip MMSI 로딩 실패 - 경로: {}, 오류: {}", mmsiResourcePath, e.getMessage());
mmsiSet = Collections.emptySet();
}
}
public boolean isTarget(Long mmsi) {
return mmsi != null && mmsiSet.contains(mmsi);
}
}

파일 보기

@ -0,0 +1,118 @@
package com.snp.batch.jobs.aistarget.classifier;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* MDA 선종 범례코드
*
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
* MDA 범례코드(signalKindCode) 치환한다.
*
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
*/
@Getter
@RequiredArgsConstructor
public enum SignalKindCode {
FISHING("000020", "어선"),
KCGV("000021", "함정"),
FERRY("000022", "여객선"),
CARGO("000023", "카고"),
TANKER("000024", "탱커"),
GOV("000025", "관공선"),
DEFAULT("000027", "일반/기타선박"),
BUOY("000028", "부이/항로표지");
private final String code;
private final String koreanName;
/**
* GlobalAIS vesselType + extraInfo MDA 범례코드 치환
*
* 치환 우선순위:
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN )
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 3. fallback DEFAULT (000027)
*/
public static SignalKindCode resolve(String vesselType, String extraInfo) {
String vt = normalizeOrEmpty(vesselType);
String ei = normalizeOrEmpty(extraInfo);
// 1. vesselType 단독 매칭 (extraInfo 무관)
switch (vt) {
case "cargo":
return CARGO;
case "tanker":
return TANKER;
case "passenger":
return FERRY;
case "aton":
return BUOY;
case "law enforcement":
return GOV;
case "search and rescue":
return KCGV;
case "local vessel":
return FISHING;
default:
break;
}
// vesselType 그룹 매칭 (복합 선종명)
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) {
return GOV;
}
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
return FERRY;
}
// 2. "Vessel" + extraInfo 조합
if ("vessel".equals(vt)) {
return resolveVesselExtraInfo(ei);
}
// 3. "N/A" + extraInfo 조합
if ("n/a".equals(vt)) {
if (ei.startsWith("hazardous cat")) {
return CARGO;
}
return DEFAULT;
}
// 4. fallback
return DEFAULT;
}
private static SignalKindCode resolveVesselExtraInfo(String extraInfo) {
if ("fishing".equals(extraInfo)) {
return FISHING;
}
if ("military operations".equals(extraInfo)) {
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")) {
return CARGO;
}
return DEFAULT;
}
private static boolean matchesAny(String value, String... candidates) {
for (String candidate : candidates) {
if (candidate.equals(value)) {
return true;
}
}
return false;
}
private static String normalizeOrEmpty(String value) {
return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase();
}
}

파일 보기

@ -0,0 +1,23 @@
package com.snp.batch.jobs.aistarget.kafka;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.kafka.KafkaAutoConfiguration;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
/**
* Kafka 조건부 활성화 설정
*
* SnpBatchApplication에서 KafkaAutoConfiguration을 기본 제외한 ,
* app.batch.ais-target.kafka.enabled=true인 경우에만 재활성화한다.
*
* enabled=false(기본값)이면 KafkaTemplate Kafka 관련 빈이 전혀 생성되지 않는다.
*/
@Configuration
@ConditionalOnProperty(
name = "app.batch.ais-target.kafka.enabled",
havingValue = "true"
)
@Import(KafkaAutoConfiguration.class)
public class AisTargetKafkaConfig {
}

파일 보기

@ -0,0 +1,55 @@
package com.snp.batch.jobs.aistarget.kafka;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.OffsetDateTime;
import java.time.ZoneOffset;
/**
* AIS Target Kafka 메시지 스키마
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class AisTargetKafkaMessage {
/**
* 이벤트 고유 식별자
* - 형식: {mmsi}_{messageTimestamp}
*/
private String eventId;
/**
* Kafka key와 동일한 선박 식별자
*/
private String key;
/**
* Kafka 발행 시각(UTC)
*/
private OffsetDateTime publishedAt;
/**
* AIS 원본/가공 데이터 전체 필드
*/
private AisTargetEntity payload;
public static AisTargetKafkaMessage from(AisTargetEntity entity) {
String key = entity.getMmsi() != null ? String.valueOf(entity.getMmsi()) : null;
String messageTs = entity.getMessageTimestamp() != null ? entity.getMessageTimestamp().toString() : "null";
return AisTargetKafkaMessage.builder()
.eventId(key + "_" + messageTs)
.key(key)
.publishedAt(OffsetDateTime.now(ZoneOffset.UTC))
.payload(entity)
.build();
}
}

파일 보기

@ -0,0 +1,211 @@
package com.snp.batch.jobs.aistarget.kafka;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
/**
* AIS Target Kafka Producer
*
* 정책:
* - key: MMSI
* - value: AisTargetKafkaMessage(JSON)
* - 실패 기본적으로 로그만 남기고 계속 진행 (failOnSendError=false)
*
* app.batch.ais-target.kafka.enabled=true인 경우에만 빈으로 등록된다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(name = "app.batch.ais-target.kafka.enabled", havingValue = "true")
public class AisTargetKafkaProducer {
private final KafkaTemplate<String, String> kafkaTemplate;
private final ObjectMapper objectMapper;
private final AisTargetKafkaProperties kafkaProperties;
public boolean isEnabled() {
return kafkaProperties.isEnabled();
}
public String getTopic() {
return kafkaProperties.getTopic();
}
/**
* 수집 청크 데이터를 Kafka 전송용 서브청크로 분할해 전송
*/
public PublishSummary publish(List<AisTargetEntity> entities) {
if (!isEnabled()) {
return PublishSummary.disabled();
}
if (entities == null || entities.isEmpty()) {
return PublishSummary.empty();
}
int subChunkSize = Math.max(1, kafkaProperties.getSendChunkSize());
PublishSummary totalSummary = PublishSummary.empty();
for (int from = 0; from < entities.size(); from += subChunkSize) {
int to = Math.min(from + subChunkSize, entities.size());
List<AisTargetEntity> subChunk = entities.subList(from, to);
PublishSummary chunkSummary = publishSubChunk(subChunk);
totalSummary.merge(chunkSummary);
log.info("AIS Kafka 서브청크 전송 완료 - topic: {}, 범위: {}~{}, 요청: {}, 성공: {}, 실패: {}, 스킵: {}",
getTopic(), from, to - 1,
chunkSummary.getRequestedCount(),
chunkSummary.getSuccessCount(),
chunkSummary.getFailedCount(),
chunkSummary.getSkippedCount());
}
if (kafkaProperties.isFailOnSendError() && totalSummary.getFailedCount() > 0) {
throw new IllegalStateException("AIS Kafka 전송 실패 건수: " + totalSummary.getFailedCount());
}
return totalSummary;
}
private PublishSummary publishSubChunk(List<AisTargetEntity> subChunk) {
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger failedCount = new AtomicInteger(0);
AtomicInteger skippedCount = new AtomicInteger(0);
AtomicInteger sampledErrorLogs = new AtomicInteger(0);
List<CompletableFuture<Void>> futures = new ArrayList<>(subChunk.size());
for (AisTargetEntity entity : subChunk) {
if (!isValid(entity)) {
skippedCount.incrementAndGet();
continue;
}
try {
String key = String.valueOf(entity.getMmsi());
String payload = objectMapper.writeValueAsString(AisTargetKafkaMessage.from(entity));
CompletableFuture<Void> trackedFuture = kafkaTemplate.send(getTopic(), key, payload)
.handle((result, ex) -> {
if (ex != null) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 전송 실패 - topic: " + getTopic()
+ ", key: " + key
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + ex.getMessage());
} else {
successCount.incrementAndGet();
}
return null;
});
futures.add(trackedFuture);
} catch (JsonProcessingException e) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 메시지 직렬화 실패 - mmsi: " + entity.getMmsi()
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + e.getMessage());
} catch (Exception e) {
failedCount.incrementAndGet();
logSendError(sampledErrorLogs,
"AIS Kafka 전송 요청 실패 - mmsi: " + entity.getMmsi()
+ ", messageTimestamp: " + entity.getMessageTimestamp()
+ ", error: " + e.getMessage());
}
}
if (!futures.isEmpty()) {
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
kafkaTemplate.flush();
}
return PublishSummary.of(
false,
subChunk.size(),
successCount.get(),
failedCount.get(),
skippedCount.get()
);
}
private boolean isValid(AisTargetEntity entity) {
return entity != null
&& entity.getMmsi() != null
&& entity.getMessageTimestamp() != null;
}
private void logSendError(AtomicInteger sampledErrorLogs, String message) {
int current = sampledErrorLogs.incrementAndGet();
if (current <= 5) {
log.error(message);
return;
}
if (current == 6) {
log.error("AIS Kafka 전송 오류 로그가 많아 이후 상세 로그는 생략합니다.");
}
}
@Getter
public static class PublishSummary {
private final boolean disabled;
private int requestedCount;
private int successCount;
private int failedCount;
private int skippedCount;
private PublishSummary(
boolean disabled,
int requestedCount,
int successCount,
int failedCount,
int skippedCount
) {
this.disabled = disabled;
this.requestedCount = requestedCount;
this.successCount = successCount;
this.failedCount = failedCount;
this.skippedCount = skippedCount;
}
public static PublishSummary disabled() {
return of(true, 0, 0, 0, 0);
}
public static PublishSummary empty() {
return of(false, 0, 0, 0, 0);
}
public static PublishSummary of(
boolean disabled,
int requestedCount,
int successCount,
int failedCount,
int skippedCount
) {
return new PublishSummary(disabled, requestedCount, successCount, failedCount, skippedCount);
}
public void merge(PublishSummary other) {
this.requestedCount += other.requestedCount;
this.successCount += other.successCount;
this.failedCount += other.failedCount;
this.skippedCount += other.skippedCount;
}
}
}

파일 보기

@ -0,0 +1,36 @@
package com.snp.batch.jobs.aistarget.kafka;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* AIS Target Kafka 전송 설정
*/
@Getter
@Setter
@ConfigurationProperties(prefix = "app.batch.ais-target.kafka")
public class AisTargetKafkaProperties {
/**
* Kafka 전송 활성화 여부
*/
private boolean enabled = true;
/**
* 전송 대상 토픽
*/
private String topic = "tp_SNP_AIS_Signal";
/**
* Kafka 전송 서브청크 크기
* 수집 청크(: 5만) 별도로 전송 배치를 분할한다.
*/
private int sendChunkSize = 5000;
/**
* 전송 실패 Step 실패 여부
* false면 실패 로그만 남기고 다음 처리를 계속한다.
*/
private boolean failOnSendError = false;
}

파일 보기

@ -7,13 +7,17 @@ import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
import com.snp.batch.jobs.aistarget.web.service.AisTargetService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.util.List;
import java.util.Map;
@ -25,6 +29,7 @@ import java.util.Map;
* - 캐시 미스 DB 조회 캐시 업데이트
*/
@Slf4j
@Validated
@RestController
@RequestMapping("/api/ais-target")
@RequiredArgsConstructor
@ -33,11 +38,51 @@ public class AisTargetController {
private final AisTargetService aisTargetService;
// ==================== 중국 허가선박 전용 ====================
@Operation(
summary = "중국 허가선박 위치 조회",
description = """
중국 허가 어선(~1,400척) 전용 캐시에서 위치 정보를 조회합니다.
- 순수 캐시 조회 (DB fallback 없음)
- 캐시에 없으면 배열 반환
- 응답 구조는 /search와 동일
"""
)
@GetMapping("/chnprmship")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> getChnPrmShip(
@Parameter(description = "조회 범위 (분, 기본: 2880 = 2일)", example = "2880")
@RequestParam(defaultValue = "2880") Integer minutes) {
log.info("ChnPrmShip 조회 요청 - minutes: {}", minutes);
List<AisTargetResponseDto> result = aisTargetService.findChnPrmShip(minutes);
return ResponseEntity.ok(ApiResponse.success(
"ChnPrmShip 조회 완료: " + result.size() + "",
result
));
}
@Operation(
summary = "중국 허가선박 캐시 통계",
description = "중국 허가선박 전용 캐시의 현재 상태를 조회합니다"
)
@GetMapping("/chnprmship/stats")
public ResponseEntity<ApiResponse<Map<String, Object>>> getChnPrmShipStats() {
Map<String, Object> stats = aisTargetService.getChnPrmShipCacheStats();
return ResponseEntity.ok(ApiResponse.success(stats));
}
// ==================== 단건 조회 ====================
@Operation(
summary = "MMSI로 최신 위치 조회",
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)"
description = "특정 MMSI의 최신 위치 정보를 조회합니다 (캐시 우선)",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "해당 MMSI의 위치 정보 없음")
}
)
@GetMapping("/{mmsi}")
public ResponseEntity<ApiResponse<AisTargetResponseDto>> getLatestByMmsi(
@ -98,7 +143,7 @@ public class AisTargetController {
@GetMapping("/search")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> search(
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
@RequestParam Integer minutes,
@RequestParam @Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다") Integer minutes,
@Parameter(description = "중심 경도", example = "129.0")
@RequestParam(required = false) Double centerLon,
@Parameter(description = "중심 위도", example = "35.0")
@ -128,6 +173,10 @@ public class AisTargetController {
@Operation(
summary = "시간/공간 범위로 선박 검색 (POST)",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (minutes 누락 또는 1 미만)")
},
description = """
POST 방식으로 검색 조건을 전달합니다.
@ -167,6 +216,10 @@ public class AisTargetController {
@Operation(
summary = "항해 조건 필터 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "필터 검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패")
},
description = """
속도(SOG), 침로(COG), 선수방위(Heading), 목적지, 항행상태로 선박을 필터링합니다.
@ -218,30 +271,30 @@ public class AisTargetController {
"headingCondition": "LT",
"headingValue": 180.0,
"destination": "BUSAN",
"statusList": ["0", "1", "5"]
"statusList": ["Under way using engine", "At anchor", "Moored"]
}
```
---
## 항행상태 코드 (statusList)
## 항행상태 (statusList)
| 코드 | 상태 |
statusList에는 **텍스트 문자열** 전달해야 합니다 (대소문자 무시).
| | 설명 |
|------|------|
| 0 | Under way using engine (기관 사용 항해 ) |
| 1 | At anchor (정박 ) |
| 2 | Not under command (조종불능) |
| 3 | Restricted manoeuverability (조종제한) |
| 4 | Constrained by her draught (흘수제약) |
| 5 | Moored (계류 ) |
| 6 | Aground (좌초) |
| 7 | Engaged in Fishing (어로 ) |
| 8 | Under way sailing ( 항해 ) |
| 9-10 | Reserved for future use |
| 11 | Power-driven vessel towing astern |
| 12 | Power-driven vessel pushing ahead |
| 13 | Reserved for future use |
| 14 | AIS-SART, MOB-AIS, EPIRB-AIS |
| 15 | Undefined (default) |
| Under way using engine | 기관 사용 항해 |
| At anchor | 정박 |
| Not under command | 조종불능 |
| Restricted manoeuverability | 조종제한 |
| Constrained by her draught | 흘수제약 |
| Moored | 계류 |
| Aground | 좌초 |
| Engaged in Fishing | 어로 |
| Under way sailing | 항해 |
| Power Driven Towing Astern | 예인선 (후방) |
| Power Driven Towing Alongside | 예인선 (측방) |
| AIS Sart | 비상위치지시기 |
| N/A | 정보없음 |
---
**참고:** 모든 필터는 선택사항이며, 미지정 해당 필드는 조건에서 제외됩니다 (전체 포함).
@ -269,6 +322,10 @@ public class AisTargetController {
@Operation(
summary = "폴리곤 범위 내 선박 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (coordinates 또는 minutes 누락)")
},
description = """
폴리곤 범위 선박을 검색합니다.
@ -283,7 +340,7 @@ public class AisTargetController {
)
@PostMapping("/search/polygon")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByPolygon(
@RequestBody PolygonSearchRequest request) {
@Valid @RequestBody PolygonSearchRequest request) {
log.info("폴리곤 검색 요청 - minutes: {}, points: {}",
request.getMinutes(), request.getCoordinates().length);
@ -299,6 +356,10 @@ public class AisTargetController {
@Operation(
summary = "WKT 범위 내 선박 검색",
responses = {
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "검색 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "요청 파라미터 검증 실패 (wkt 또는 minutes 누락)")
},
description = """
WKT(Well-Known Text) 형식으로 정의된 범위 선박을 검색합니다.
@ -313,7 +374,7 @@ public class AisTargetController {
)
@PostMapping("/search/wkt")
public ResponseEntity<ApiResponse<List<AisTargetResponseDto>>> searchByWkt(
@RequestBody WktSearchRequest request) {
@Valid @RequestBody WktSearchRequest request) {
log.info("WKT 검색 요청 - minutes: {}, wkt: {}", request.getMinutes(), request.getWkt());
List<AisTargetResponseDto> result = aisTargetService.searchByWkt(
@ -405,11 +466,17 @@ public class AisTargetController {
* 폴리곤 검색 요청 DTO
*/
@lombok.Data
@Schema(description = "폴리곤 범위 검색 요청")
public static class PolygonSearchRequest {
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
private int minutes;
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minutes;
@Parameter(description = "폴리곤 좌표 [[lon, lat], ...]", required = true)
@NotNull(message = "coordinates는 필수입니다")
@Schema(description = "폴리곤 좌표 [[경도, 위도], ...] (닫힌 형태: 첫점=끝점)",
example = "[[129.0, 35.0], [130.0, 35.0], [130.0, 36.0], [129.0, 36.0], [129.0, 35.0]]",
requiredMode = Schema.RequiredMode.REQUIRED)
private double[][] coordinates;
}
@ -417,12 +484,17 @@ public class AisTargetController {
* WKT 검색 요청 DTO
*/
@lombok.Data
@Schema(description = "WKT 범위 검색 요청")
public static class WktSearchRequest {
@Parameter(description = "조회 범위 (분)", required = true, example = "5")
private int minutes;
@NotNull(message = "minutes는 필수입니다")
@Min(value = 1, message = "minutes는 최소 1분 이상이어야 합니다")
@Schema(description = "조회 범위 (분)", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
private Integer minutes;
@Parameter(description = "WKT 문자열", required = true,
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))")
@NotNull(message = "wkt는 필수입니다")
@Schema(description = "WKT 문자열 (POLYGON, MULTIPOLYGON 지원)",
example = "POLYGON((129 35, 130 35, 130 36, 129 36, 129 35))",
requiredMode = Schema.RequiredMode.REQUIRED)
private String wkt;
}
}

파일 보기

@ -22,40 +22,87 @@ import java.time.OffsetDateTime;
public class AisTargetResponseDto {
// 선박 식별 정보
@Schema(description = "MMSI (Maritime Mobile Service Identity) 번호", example = "440123456")
private Long mmsi;
@Schema(description = "IMO 번호 (0인 경우 미등록)", example = "9137960")
private Long imo;
@Schema(description = "선박명", example = "ROYAUME DES OCEANS")
private String name;
@Schema(description = "호출 부호", example = "4SFTEST")
private String callsign;
@Schema(description = "선박 유형 (외부 API 원본 텍스트)", example = "Vessel")
private String vesselType;
// 위치 정보
@Schema(description = "위도 (WGS84)", example = "35.0796")
private Double lat;
@Schema(description = "경도 (WGS84)", example = "129.0756")
private Double lon;
// 항해 정보
@Schema(description = "선수방위 (degrees, 0-360)", example = "36.0")
private Double heading;
private Double sog; // Speed over Ground
private Double cog; // Course over Ground
private Integer rot; // Rate of Turn
@Schema(description = "대지속력 (knots)", example = "12.5")
private Double sog;
@Schema(description = "대지침로 (degrees, 0-360)", example = "36.2")
private Double cog;
@Schema(description = "회전율 (Rate of Turn)", example = "0")
private Integer rot;
// 선박 제원
@Schema(description = "선박 길이 (미터)", example = "19")
private Integer length;
@Schema(description = "선박 폭 (미터)", example = "15")
private Integer width;
@Schema(description = "흘수 (미터)", example = "5.5")
private Double draught;
// 목적지 정보
@Schema(description = "목적지", example = "BUSAN")
private String destination;
@Schema(description = "예정 도착 시간 (UTC)")
private OffsetDateTime eta;
@Schema(description = "항행상태 (텍스트)", example = "Under way using engine")
private String status;
// 타임스탬프
@Schema(description = "AIS 메시지 발생 시각 (UTC)")
private OffsetDateTime messageTimestamp;
@Schema(description = "데이터 수신 시각 (UTC)")
private OffsetDateTime receivedDate;
// 데이터 소스 (캐시/DB)
@Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"})
private String source;
// 선종 분류 정보
@Schema(description = """
MDA 범례코드 (선종 분류)
- 000020: 어선 (FISHING)
- 000021: 함정 (KCGV)
- 000022: 여객선 (FERRY)
- 000023: 카고 (CARGO)
- 000024: 탱커 (TANKER)
- 000025: 관공선 (GOV)
- 000027: 일반/기타선박 (DEFAULT)
- 000028: 부이/항로표지 (BUOY)
""",
example = "000023")
private String signalKindCode;
// ClassType 분류 정보
@Schema(description = """
선박 클래스 타입
@ -102,6 +149,7 @@ public class AisTargetResponseDto {
.messageTimestamp(entity.getMessageTimestamp())
.receivedDate(entity.getReceivedDate())
.source(source)
.signalKindCode(entity.getSignalKindCode())
.classType(entity.getClassType())
.core20Mmsi(entity.getCore20Mmsi())
.build();

파일 보기

@ -5,6 +5,7 @@ import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import com.snp.batch.jobs.aistarget.cache.AisTargetFilterUtil;
import com.snp.batch.jobs.aistarget.cache.SpatialFilterUtil;
import com.snp.batch.jobs.aistarget.chnprmship.ChnPrmShipCacheManager;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetResponseDto;
import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest;
@ -38,6 +39,7 @@ public class AisTargetService {
private final AisTargetCacheManager cacheManager;
private final SpatialFilterUtil spatialFilterUtil;
private final AisTargetFilterUtil filterUtil;
private final ChnPrmShipCacheManager chnPrmShipCacheManager;
private static final String SOURCE_CACHE = "cache";
private static final String SOURCE_DB = "db";
@ -360,6 +362,36 @@ public class AisTargetService {
.collect(Collectors.toList());
}
// ==================== 중국 허가선박 전용 조회 ====================
/**
* 중국 허가선박 전용 캐시 조회 (DB fallback 없음)
*
* @param minutes 조회 범위 ()
* @return 시간 범위 대상 선박 목록
*/
public List<AisTargetResponseDto> findChnPrmShip(int minutes) {
log.debug("ChnPrmShip 조회 - minutes: {}", minutes);
long startTime = System.currentTimeMillis();
List<AisTargetEntity> entities = chnPrmShipCacheManager.getByTimeRange(minutes);
long elapsed = System.currentTimeMillis() - startTime;
log.info("ChnPrmShip 조회 완료 - 결과: {} 건, 소요: {}ms", entities.size(), elapsed);
return entities.stream()
.map(e -> AisTargetResponseDto.from(e, SOURCE_CACHE))
.collect(Collectors.toList());
}
/**
* ChnPrmShip 캐시 통계 조회
*/
public Map<String, Object> getChnPrmShipCacheStats() {
return chnPrmShipCacheManager.getStats();
}
// ==================== 캐시 관리 ====================
/**

파일 보기

@ -3,7 +3,6 @@ package com.snp.batch.jobs.aistargetdbsync.batch.tasklet;
import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity;
import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository;
import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
@ -12,53 +11,69 @@ import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* AIS Target DB Sync Tasklet
*
* 스케줄: 15분 (0 0/15 * * * ?)
*
* 동작:
* - Caffeine 캐시에서 최근 N분 이내 데이터 조회
* - Caffeine 캐시에서 마지막 성공 이후 ~ 현재까지의 데이터를 조회
* - MMSI별 최신 위치 1건씩 DB에 UPSERT
* - 캐시의 모든 컬럼 정보를 그대로 DB에 저장
*
* 시간 범위 결정 전략:
* - 실행 또는 마지막 실행 정보 없음 fallback(time-range-minutes) 사용
* - 이후 실행 마지막 성공 시각 기준으로 경과 시간 자동 계산
* - cron 주기를 변경해도 별도 설정 불필요 (자동 동기화)
*
* 참고:
* - 캐시에는 MMSI별 최신 데이터만 유지됨 (120분 TTL)
* - DB 저장은 15분 주기로 수행하여 볼륨 절감
* - 기존 aisTargetImportJob은 캐시 업데이트만 수행
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class AisTargetDbSyncTasklet implements Tasklet {
private final AisTargetCacheManager cacheManager;
private final AisTargetRepository aisTargetRepository;
private final int fallbackMinutes;
/**
* DB 동기화 조회할 캐시 데이터 시간 범위 ()
* 기본값: 15분 (스케줄 주기와 동일)
* 마지막 성공 시각 (JVM 유지, 재기동 fallback 사용)
*/
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}")
private int timeRangeMinutes;
private final AtomicReference<Instant> lastSuccessTime = new AtomicReference<>();
public AisTargetDbSyncTasklet(
AisTargetCacheManager cacheManager,
AisTargetRepository aisTargetRepository,
@Value("${app.batch.ais-target-db-sync.time-range-minutes:15}") int fallbackMinutes) {
this.cacheManager = cacheManager;
this.aisTargetRepository = aisTargetRepository;
this.fallbackMinutes = fallbackMinutes;
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
Instant now = Instant.now();
int rangeMinutes = resolveRangeMinutes(now);
log.info("========================================");
log.info("AIS Target DB Sync 시작");
log.info("조회 범위: 최근 {}분", timeRangeMinutes);
log.info("조회 범위: 최근 {}분 (방식: {})", rangeMinutes,
lastSuccessTime.get() != null ? "마지막 성공 기준" : "fallback");
log.info("현재 캐시 크기: {}", cacheManager.size());
log.info("========================================");
long startTime = System.currentTimeMillis();
// 1. 캐시에서 최근 N분 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(timeRangeMinutes);
// 1. 캐시에서 시간 범위 데이터 조회
List<AisTargetEntity> entities = cacheManager.getByTimeRange(rangeMinutes);
if (entities.isEmpty()) {
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", timeRangeMinutes);
log.warn("캐시에서 조회된 데이터가 없습니다 (범위: {}분)", rangeMinutes);
lastSuccessTime.set(now);
return RepeatStatus.FINISHED;
}
@ -69,6 +84,9 @@ public class AisTargetDbSyncTasklet implements Tasklet {
long elapsed = System.currentTimeMillis() - startTime;
// 성공 시각 기록
lastSuccessTime.set(now);
log.info("========================================");
log.info("AIS Target DB Sync 완료");
log.info("저장 건수: {} 건", entities.size());
@ -80,4 +98,24 @@ public class AisTargetDbSyncTasklet implements Tasklet {
return RepeatStatus.FINISHED;
}
private static final int MAX_RANGE_MINUTES = 60;
/**
* 조회 범위() 결정
* - 마지막 성공 시각이 있으면: 경과 시간 + 1분 버퍼 (최대 60분)
* - 없으면: fallback 사용
* - 오래 중단 재가동 시에도 최대 60분으로 제한하여 과부하 방지
*/
private int resolveRangeMinutes(Instant now) {
Instant last = lastSuccessTime.get();
if (last == null) {
return Math.min(fallbackMinutes, MAX_RANGE_MINUTES);
}
long elapsedMinutes = java.time.Duration.between(last, now).toMinutes();
// 경과 시간 + 1분 버퍼 (겹침 허용, UPSERT이므로 중복 안전), 최대 60분
int range = (int) Math.max(elapsedMinutes + 1, 1);
return Math.min(range, MAX_RANGE_MINUTES);
}
}

파일 보기

@ -7,9 +7,12 @@ import com.snp.batch.jobs.common.batch.processor.FlagCodeDataProcessor;
import com.snp.batch.jobs.common.batch.reader.FlagCodeDataReader;
import com.snp.batch.jobs.common.batch.repository.FlagCodeRepository;
import com.snp.batch.jobs.common.batch.writer.FlagCodeDataWriter;
import com.snp.batch.jobs.facility.batch.reader.PortDataReader;
import com.snp.batch.service.BatchApiLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
@ -25,8 +28,14 @@ import org.springframework.web.reactive.function.client.WebClient;
@Configuration
public class FlagCodeImportJobConfig extends BaseJobConfig<FlagCodeDto, FlagCodeEntity> {
private final FlagCodeDataProcessor flagCodeDataProcessor;
private final FlagCodeDataReader flagCodeDataReader;
private final FlagCodeRepository flagCodeRepository;
private final WebClient maritimeApiWebClient;
private final BatchApiLogService batchApiLogService;
@Value("${app.batch.ship-api.url}")
private String maritimeApiUrl;
@Value("${app.batch.chunk-size:1000}")
private int chunkSize;
@ -39,10 +48,16 @@ public class FlagCodeImportJobConfig extends BaseJobConfig<FlagCodeDto, FlagCode
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
FlagCodeRepository flagCodeRepository,
@Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) {
FlagCodeDataProcessor flagCodeDataProcessor,
@Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient,
FlagCodeDataReader flagCodeDataReader,
BatchApiLogService batchApiLogService) {
super(jobRepository, transactionManager);
this.flagCodeRepository = flagCodeRepository;
this.maritimeApiWebClient = maritimeApiWebClient;
this.flagCodeDataProcessor = flagCodeDataProcessor;
this.flagCodeDataReader = flagCodeDataReader;
this.batchApiLogService = batchApiLogService;
}
@Override
@ -57,14 +72,29 @@ public class FlagCodeImportJobConfig extends BaseJobConfig<FlagCodeDto, FlagCode
@Override
protected ItemReader<FlagCodeDto> createReader() {
return new FlagCodeDataReader(maritimeApiWebClient);
return flagCodeDataReader;
}
@Bean
@StepScope
public FlagCodeDataReader flagCodeDataReader(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId, // SpEL로 ID 추출
@Value("#{stepExecution.id}") Long stepExecutionId
) {
FlagCodeDataReader reader = new FlagCodeDataReader(maritimeApiWebClient, batchApiLogService, maritimeApiUrl);
reader.setExecutionIds(jobExecutionId, stepExecutionId); // ID 세팅
return reader;
}
@Override
protected ItemProcessor<FlagCodeDto, FlagCodeEntity> createProcessor() {
return new FlagCodeDataProcessor(flagCodeRepository);
return flagCodeDataProcessor;
}
@Bean
@StepScope
public FlagCodeDataProcessor flagCodeDataProcessor(
@Value("#{stepExecution.jobExecution.id}") Long jobExecutionId) {
return new FlagCodeDataProcessor(jobExecutionId);
}
@Override
protected ItemWriter<FlagCodeEntity> createWriter() {
return new FlagCodeDataWriter(flagCodeRepository);

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