From c88b8a926b3b4cde4b465a4444ebc2b7bd6bb3d2 Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Wed, 22 Oct 2025 13:50:04 +0900 Subject: [PATCH] Initial commit --- .gitattributes | 1 + .gitignore | 105 ++ DEVELOPMENT_GUIDE.md | 1602 +++++++++++++++++ SWAGGER_GUIDE.md | 517 ++++++ pom.xml | 163 ++ .../com/snp/batch/SnpBatchApplication.java | 14 + .../common/batch/config/BaseJobConfig.java | 138 ++ .../batch/common/batch/entity/BaseEntity.java | 46 + .../common/batch/processor/BaseProcessor.java | 61 + .../common/batch/reader/BaseApiReader.java | 633 +++++++ .../batch/repository/BaseJdbcRepository.java | 336 ++++ .../batch/common/batch/writer/BaseWriter.java | 61 + .../com/snp/batch/common/web/ApiResponse.java | 81 + .../common/web/controller/BaseController.java | 300 +++ .../com/snp/batch/common/web/dto/BaseDto.java | 33 + .../common/web/service/BaseHybridService.java | 202 +++ .../common/web/service/BaseProxyService.java | 176 ++ .../batch/common/web/service/BaseService.java | 94 + .../common/web/service/BaseServiceImpl.java | 131 ++ .../config/MaritimeApiWebClientConfig.java | 103 ++ .../snp/batch/global/config/QuartzConfig.java | 67 + .../batch/global/config/SwaggerConfig.java | 79 + .../global/controller/BatchController.java | 291 +++ .../global/controller/WebViewController.java | 43 + .../batch/global/dto/DashboardResponse.java | 53 + .../global/dto/JobExecutionDetailDto.java | 89 + .../snp/batch/global/dto/JobExecutionDto.java | 23 + .../snp/batch/global/dto/ScheduleRequest.java | 46 + .../batch/global/dto/ScheduleResponse.java | 80 + .../batch/global/dto/TimelineResponse.java | 48 + .../batch/global/model/JobScheduleEntity.java | 110 ++ .../repository/JobScheduleRepository.java | 43 + .../global/repository/TimelineRepository.java | 109 ++ .../config/OrderDataImportJobConfig.java | 153 ++ .../config/ProductDataImportJobConfig.java | 101 ++ .../batch/jobs/sample/batch/dto/OrderDto.java | 97 + .../sample/batch/dto/ProductApiResponse.java | 45 + .../jobs/sample/batch/dto/ProductDto.java | 95 + .../jobs/sample/batch/entity/OrderEntity.java | 58 + .../sample/batch/entity/OrderItemEntity.java | 63 + .../sample/batch/entity/ProductEntity.java | 103 ++ .../batch/processor/OrderDataProcessor.java | 103 ++ .../batch/processor/ProductDataProcessor.java | 46 + .../sample/batch/reader/ProductApiReader.java | 287 +++ .../batch/reader/ProductDataReader.java | 247 +++ .../batch/repository/ProductRepository.java | 39 + .../repository/ProductRepositoryImpl.java | 191 ++ .../sample/batch/writer/OrderItemWriter.java | 43 + .../jobs/sample/batch/writer/OrderWriter.java | 42 + .../batch/writer/ProductDataWriter.java | 42 + .../web/controller/ProductWebController.java | 129 ++ .../sample/web/dto/ProductResponseDto.java | 61 + .../jobs/sample/web/dto/ProductWebDto.java | 85 + .../sample/web/service/ProductWebService.java | 144 ++ .../config/ShipDetailImportJobConfig.java | 106 ++ .../batch/dto/ShipDetailApiResponse.java | 39 + .../shipdetail/batch/dto/ShipDetailDto.java | 104 ++ .../batch/entity/ShipDetailEntity.java | 98 + .../processor/ShipDetailDataProcessor.java | 41 + .../batch/reader/ShipDetailDataReader.java | 184 ++ .../repository/ShipDetailRepository.java | 42 + .../repository/ShipDetailRepositoryImpl.java | 184 ++ .../batch/writer/ShipDetailDataWriter.java | 33 + .../batch/config/ShipImportJobConfig.java | 104 ++ .../shipimport/batch/dto/ShipApiResponse.java | 43 + .../jobs/shipimport/batch/dto/ShipDto.java | 34 + .../shipimport/batch/entity/ShipEntity.java | 55 + .../batch/processor/ShipDataProcessor.java | 61 + .../batch/reader/ShipDataReader.java | 62 + .../batch/repository/ShipRepository.java | 34 + .../batch/repository/ShipRepositoryImpl.java | 152 ++ .../batch/writer/ShipDataWriter.java | 28 + .../snp/batch/scheduler/QuartzBatchJob.java | 50 + .../batch/scheduler/SchedulerInitializer.java | 120 ++ .../com/snp/batch/service/BatchService.java | 566 ++++++ .../snp/batch/service/QuartzJobService.java | 68 + .../snp/batch/service/ScheduleService.java | 354 ++++ src/main/resources/application.yml | 97 + .../V3__Create_Sample_Products_Table.sql | 114 ++ .../schema/001_create_job_execution_lock.sql | 61 + src/main/resources/db/schema/ship_detail.sql | 64 + .../resources/templates/execution-detail.html | 509 ++++++ src/main/resources/templates/executions.html | 390 ++++ src/main/resources/templates/index.html | 586 ++++++ src/main/resources/templates/jobs.html | 295 +++ .../templates/schedule-timeline.html | 829 +++++++++ src/main/resources/templates/schedules.html | 525 ++++++ 87 files changed, 14084 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 DEVELOPMENT_GUIDE.md create mode 100644 SWAGGER_GUIDE.md create mode 100644 pom.xml create mode 100644 src/main/java/com/snp/batch/SnpBatchApplication.java create mode 100644 src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java create mode 100644 src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java create mode 100644 src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java create mode 100644 src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java create mode 100644 src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java create mode 100644 src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java create mode 100644 src/main/java/com/snp/batch/common/web/ApiResponse.java create mode 100644 src/main/java/com/snp/batch/common/web/controller/BaseController.java create mode 100644 src/main/java/com/snp/batch/common/web/dto/BaseDto.java create mode 100644 src/main/java/com/snp/batch/common/web/service/BaseHybridService.java create mode 100644 src/main/java/com/snp/batch/common/web/service/BaseProxyService.java create mode 100644 src/main/java/com/snp/batch/common/web/service/BaseService.java create mode 100644 src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java create mode 100644 src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java create mode 100644 src/main/java/com/snp/batch/global/config/QuartzConfig.java create mode 100644 src/main/java/com/snp/batch/global/config/SwaggerConfig.java create mode 100644 src/main/java/com/snp/batch/global/controller/BatchController.java create mode 100644 src/main/java/com/snp/batch/global/controller/WebViewController.java create mode 100644 src/main/java/com/snp/batch/global/dto/DashboardResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java create mode 100644 src/main/java/com/snp/batch/global/dto/JobExecutionDto.java create mode 100644 src/main/java/com/snp/batch/global/dto/ScheduleRequest.java create mode 100644 src/main/java/com/snp/batch/global/dto/ScheduleResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/TimelineResponse.java create mode 100644 src/main/java/com/snp/batch/global/model/JobScheduleEntity.java create mode 100644 src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java create mode 100644 src/main/java/com/snp/batch/global/repository/TimelineRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java create mode 100644 src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailApiResponse.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java create mode 100644 src/main/java/com/snp/batch/jobs/shipdetail/batch/writer/ShipDetailDataWriter.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/config/ShipImportJobConfig.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipApiResponse.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipDto.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/entity/ShipEntity.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/reader/ShipDataReader.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepository.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java create mode 100644 src/main/java/com/snp/batch/jobs/shipimport/batch/writer/ShipDataWriter.java create mode 100644 src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java create mode 100644 src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java create mode 100644 src/main/java/com/snp/batch/service/BatchService.java create mode 100644 src/main/java/com/snp/batch/service/QuartzJobService.java create mode 100644 src/main/java/com/snp/batch/service/ScheduleService.java create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql create mode 100644 src/main/resources/db/schema/001_create_job_execution_lock.sql create mode 100644 src/main/resources/db/schema/ship_detail.sql create mode 100644 src/main/resources/templates/execution-detail.html create mode 100644 src/main/resources/templates/executions.html create mode 100644 src/main/resources/templates/index.html create mode 100644 src/main/resources/templates/jobs.html create mode 100644 src/main/resources/templates/schedule-timeline.html create mode 100644 src/main/resources/templates/schedules.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2125666 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0620a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Mobile Tools for Java (J2ME) +.mtj.tmp/ + +# Package Files +*.jar +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs +hs_err_pid* +replay_pid* + +# Maven +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +# Gradle +.gradle/ +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +# IntelliJ IDEA +.idea/ +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +# Eclipse +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +# VS Code +.vscode/ + +# NetBeans +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +# Mac +.DS_Store + +# Windows +Thumbs.db +ehthumbs.db +Desktop.ini + +# Application specific +application-local.yml +*.env +.env.* + +# Database +*.db +*.sqlite +*.sqlite3 + +# Logs +logs/ +docs/ +*.log.* + +# Session continuity files (for AI assistants) +.claude/ +CLAUDE.md +BASEREADER_ENHANCEMENT_PLAN.md +README.md + +nul \ No newline at end of file diff --git a/DEVELOPMENT_GUIDE.md b/DEVELOPMENT_GUIDE.md new file mode 100644 index 0000000..8050350 --- /dev/null +++ b/DEVELOPMENT_GUIDE.md @@ -0,0 +1,1602 @@ +# Spring Batch 개발 가이드 + +## 목차 +1. [프로젝트 개요](#1-프로젝트-개요) +2. [프로젝트 구조](#2-프로젝트-구조) +3. [추상 클래스 구조](#3-추상-클래스-구조) +4. [새로운 배치 Job 생성 가이드](#4-새로운-배치-job-생성-가이드) +5. [예제: 전체 구현 과정](#5-예제-전체-구현-과정) +6. [베스트 프랙티스](#6-베스트-프랙티스) +7. [트러블슈팅](#7-트러블슈팅) + +--- + +## 1. 프로젝트 개요 + +### 1.1 기술 스택 +- **Spring Boot**: 3.2.1 +- **Spring Batch**: 5.1.0 +- **Quartz Scheduler**: 2.5.0 +- **PostgreSQL**: 42.7.4 +- **WebClient**: REST API 호출 +- **Thymeleaf**: 웹 UI +- **Bootstrap 5**: 프론트엔드 + +### 1.2 주요 기능 +- REST API 기반 데이터 수집 배치 +- Quartz 기반 스케줄링 관리 (DB 영속화) +- 웹 UI를 통한 배치 모니터링 +- 타임라인 차트 (일/주/월 단위) +- 대시보드 및 실행 이력 조회 +- 통일된 추상화 구조 (Reader/Processor/Writer) +- REST API 표준 응답 형식 + +### 1.3 아키텍처 패턴 + +#### 배치 처리 패턴 +``` +External API → Reader → Processor → Writer → Database + ↓ ↓ ↓ + (API Call) (Transform) (Batch Insert) +``` + +#### 계층 구조 +``` +Controller (REST API) + ↓ +Service (비즈니스 로직) + ↓ +Repository (데이터 액세스) + ↓ +Database (PostgreSQL) +``` + +#### 추상화 구조 +``` +com.snp.batch/ +├── SnpBatchApplication.java # Spring Boot 메인 클래스 +│ +├── common/ # 공통 추상화 모듈 +│ ├── batch/ # 배치 작업 추상화 +│ │ ├── config/ +│ │ │ └── BaseJobConfig # Job/Step 설정 템플릿 +│ │ ├── entity/ +│ │ │ └── BaseEntity # Entity 공통 감사 필드 +│ │ ├── processor/ +│ │ │ └── BaseProcessor # Processor 템플릿 +│ │ ├── reader/ +│ │ │ └── BaseApiReader # API Reader 템플릿 +│ │ ├── repository/ +│ │ │ └── BaseJdbcRepository # JDBC Repository 템플릿 +│ │ └── writer/ +│ │ └── BaseWriter # Writer 템플릿 +│ │ +│ └── web/ # 웹 API 추상화 +│ ├── controller/ +│ │ └── BaseController # 컨트롤러 템플릿 +│ ├── dto/ +│ │ └── BaseDto # 공통 DTO 필드 +│ ├── service/ +│ │ ├── BaseService # 서비스 인터페이스 +│ │ ├── BaseServiceImpl # 서비스 구현 템플릿 (JDBC) +│ │ ├── BaseProxyService # 프록시 서비스 템플릿 +│ │ └── BaseHybridService # 하이브리드 서비스 템플릿 +│ └── ApiResponse # 공통 API 응답 래퍼 +│ +├── global/ # 전역 클래스 (애플리케이션 레벨) +│ ├── config/ # 전역 설정 (Quartz, Swagger 등) +│ ├── controller/ # 전역 컨트롤러 (Batch, Web UI) +│ ├── dto/ # 전역 DTO (Job 실행, 스케줄 등) +│ ├── model/ # 전역 Entity (스케줄 정보 등) - JPA 허용 +│ └── repository/ # 전역 Repository - JPA 허용 +│ +├── jobs/ # 배치 Job 구현 (도메인별, JDBC 전용) +│ ├── sample/ # 샘플 제품 데이터 Job +│ │ ├── batch/ # 배치 작업 +│ │ └── web/ # 웹 API (선택) +│ └── shipimport/ # 선박 데이터 Import Job +│ └── batch/ # 배치 작업만 +│ +├── service/ # 전역 서비스 +└── scheduler/ # 스케줄러 (Quartz Job, Initializer) +``` + +--- + +## 2. 프로젝트 구조 + +``` +src/main/java/com/snp/batch/ +├── SnpBatchApplication.java # Spring Boot 메인 클래스 +│ +├── common/ # 공통 추상화 모듈 +│ ├── batch/ # 배치 작업 공통 Base 클래스 +│ │ ├── config/ +│ │ │ └── BaseJobConfig.java # Job/Step 설정 템플릿 +│ │ ├── entity/ +│ │ │ └── BaseEntity.java # 공통 감사 필드 (JPA 제거) +│ │ ├── processor/ +│ │ │ └── BaseProcessor.java # Processor 템플릿 +│ │ ├── reader/ +│ │ │ └── BaseApiReader.java # API Reader 템플릿 +│ │ ├── repository/ +│ │ │ └── BaseJdbcRepository.java # JDBC Repository 템플릿 +│ │ └── writer/ +│ │ └── BaseWriter.java # Writer 템플릿 +│ │ +│ └── web/ # 웹 API 공통 Base 클래스 +│ ├── controller/ +│ │ └── BaseController.java # 컨트롤러 템플릿 +│ ├── dto/ +│ │ └── BaseDto.java # 공통 DTO 필드 +│ ├── service/ +│ │ ├── BaseService.java # 서비스 인터페이스 +│ │ ├── BaseServiceImpl.java # 서비스 구현 템플릿 (JDBC) +│ │ ├── BaseProxyService.java # 프록시 서비스 템플릿 +│ │ └── BaseHybridService.java # 하이브리드 서비스 템플릿 +│ └── ApiResponse.java # 공통 API 응답 래퍼 +│ +├── global/ # 전역 클래스 (JPA 허용) +│ ├── config/ # 전역 설정 +│ │ ├── QuartzConfig.java # Quartz 스케줄러 설정 +│ │ └── SwaggerConfig.java # Swagger 설정 +│ │ +│ ├── controller/ # 전역 컨트롤러 +│ │ ├── BatchController.java # 배치 관리 REST API +│ │ └── WebViewController.java # Thymeleaf 뷰 컨트롤러 +│ │ +│ ├── dto/ # 전역 DTO +│ │ ├── DashboardResponse.java # 대시보드 응답 +│ │ ├── JobExecutionDetailDto.java # 실행 상세 정보 +│ │ ├── JobExecutionDto.java # 실행 이력 DTO +│ │ ├── ScheduleRequest.java # 스케줄 등록/수정 요청 +│ │ ├── ScheduleResponse.java # 스케줄 조회 응답 +│ │ └── TimelineResponse.java # 타임라인 응답 +│ │ +│ ├── model/ # 전역 Entity (JPA) +│ │ └── JobScheduleEntity.java # 스케줄 Entity (JPA) +│ │ +│ └── repository/ # 전역 Repository (JPA) +│ ├── JobScheduleRepository.java # JpaRepository (JPA) +│ └── TimelineRepository.java # 타임라인 Repository +│ +├── jobs/ # 도메인별 배치 Job (JDBC 전용) +│ │ +│ ├── sample/ # 샘플 제품 데이터 Job +│ │ ├── batch/ # 배치 작업 +│ │ │ ├── config/ +│ │ │ │ └── ProductDataImportJobConfig.java +│ │ │ ├── dto/ +│ │ │ │ ├── ProductApiResponse.java +│ │ │ │ └── ProductDto.java +│ │ │ ├── entity/ +│ │ │ │ └── ProductEntity.java # extends BaseEntity (JPA 제거) +│ │ │ ├── processor/ +│ │ │ │ └── ProductDataProcessor.java +│ │ │ ├── reader/ +│ │ │ │ └── ProductDataReader.java +│ │ │ ├── repository/ +│ │ │ │ ├── ProductRepository.java +│ │ │ │ └── ProductRepositoryImpl.java # extends BaseJdbcRepository +│ │ │ └── writer/ +│ │ │ └── ProductDataWriter.java +│ │ │ +│ │ └── web/ # 웹 API +│ │ ├── controller/ +│ │ │ └── ProductWebController.java +│ │ ├── dto/ +│ │ │ └── ProductWebDto.java +│ │ └── service/ +│ │ └── ProductWebService.java +│ │ +│ └── shipimport/ # 선박 데이터 Import Job +│ └── batch/ # 배치 작업 (웹 API 없음) +│ ├── config/ +│ │ └── ShipImportJobConfig.java +│ ├── dto/ +│ │ ├── ShipApiResponse.java +│ │ └── ShipDto.java +│ ├── entity/ +│ │ └── ShipEntity.java # extends BaseEntity (JPA 제거) +│ ├── processor/ +│ │ └── ShipDataProcessor.java +│ ├── reader/ +│ │ └── ShipDataReader.java +│ ├── repository/ +│ │ ├── ShipRepository.java +│ │ └── ShipRepositoryImpl.java # extends BaseJdbcRepository +│ └── writer/ +│ └── ShipDataWriter.java +│ +├── service/ # 전역 서비스 +│ ├── BatchService.java # 배치 실행 관리 +│ ├── QuartzJobService.java # Quartz-Batch 연동 +│ └── ScheduleService.java # 스케줄 DB 영속화 +│ +└── scheduler/ # 스케줄러 + ├── QuartzBatchJob.java # Quartz Job 구현체 + └── SchedulerInitializer.java # 스케줄 자동 로드 +``` + +**주요 특징**: +- `common/batch/`: 배치 작업 전용 Base 클래스 (JDBC 기반) +- `common/web/`: 웹 API 전용 Base 클래스 (JDBC 기반) +- `global/`: JPA 사용 허용 (간단한 CRUD만) +- `jobs/`: 모든 Job은 JDBC 전용 (성능 최적화) + +--- + +## 3. 추상 클래스 구조 + +### 3.0 공통 베이스 클래스 + +#### 3.0.1 BaseEntity + +**목적**: 모든 Entity의 공통 감사(Audit) 필드 관리 + +**위치**: `com.snp.batch.common.batch.entity.BaseEntity` + +**제공 필드**: +```java +@CreatedDate +private LocalDateTime createdAt; // 생성 일시 (자동 설정) + +@LastModifiedDate +private LocalDateTime updatedAt; // 수정 일시 (자동 업데이트) + +private String createdBy; // 생성자 (기본값: "SYSTEM") +private String updatedBy; // 수정자 (기본값: "SYSTEM") +``` + +**사용 방법 (jobs 패키지 - JDBC 전용)**: +```java +/** + * Ship Entity - JDBC Template 기반 + * JPA 어노테이션 사용 금지 + * 컬럼 매핑은 주석으로 명시 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipEntity extends BaseEntity { + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * 선박 이름 + * 컬럼: ship_name (VARCHAR(100)) + */ + private String shipName; + + // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 +} +``` + +**사용 방법 (global 패키지 - JPA 허용)**: +```java +@Entity +@Table(name = "job_schedule") +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public class JobScheduleEntity extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "job_name", unique = true, nullable = false) + private String jobName; + // createdAt, updatedAt, createdBy, updatedBy는 자동 관리됨 +} +``` + +**주요 기능**: +- jobs 패키지: JDBC 기반, JPA 어노테이션 없음, RowMapper로 수동 매핑 +- global 패키지: JPA 기반, @PrePersist/@PreUpdate로 자동 관리 +- 공통 감사 필드: createdAt, updatedAt, createdBy, updatedBy + +--- + +#### 3.0.2 BaseDto + +**목적**: 모든 DTO의 공통 감사 필드 제공 + +**위치**: `com.snp.batch.common.web.dto.BaseDto` + +**제공 필드**: +```java +private LocalDateTime createdAt; +private LocalDateTime updatedAt; +private String createdBy; +private String updatedBy; +``` + +**사용 방법**: +```java +@Data +@EqualsAndHashCode(callSuper = true) +public class ShipDto extends BaseDto { + private String shipName; + private String shipType; + // 부모 클래스의 감사 필드 자동 상속 +} +``` + +--- + +#### 3.0.3 BaseService + +**목적**: Service 계층의 공통 CRUD 인터페이스 정의 + +**위치**: `com.snp.batch.common.web.service.BaseService` + +**제공 메서드**: +```java +D create(D dto); // 생성 +Optional findById(ID id); // 단건 조회 +List findAll(); // 전체 조회 +Page findAll(Pageable pageable); // 페이징 조회 +D update(ID id, D dto); // 수정 +void deleteById(ID id); // 삭제 +boolean existsById(ID id); // 존재 여부 확인 +D toDto(T entity); // Entity → DTO 변환 +T toEntity(D dto); // DTO → Entity 변환 +``` + +--- + +#### 3.0.4 BaseServiceImpl + +**목적**: BaseService의 기본 구현 제공 + +**위치**: `com.snp.batch.common.web.service.BaseServiceImpl` + +**필수 구현 메서드**: +```java +protected abstract BaseJdbcRepository getRepository(); // Repository 반환 +protected abstract String getEntityName(); // Entity 이름 (로깅용) +protected abstract void updateEntity(T entity, D dto); // Entity 업데이트 로직 +protected abstract ID extractId(T entity); // Entity에서 ID 추출 +``` + +**참고**: jobs 패키지에서는 JDBC 기반 Repository를 사용합니다 (BaseJdbcRepository 상속). + +**사용 예제**: +```java +@Service +@RequiredArgsConstructor +public class ProductWebService extends BaseServiceImpl { + + private final ProductRepository productRepository; + + @Override + protected BaseJdbcRepository getRepository() { + return productRepository; + } + + @Override + protected String getEntityName() { + return "Product"; + } + + @Override + protected void updateEntity(ProductEntity entity, ProductWebDto dto) { + entity.setProductName(dto.getProductName()); + entity.setCategory(dto.getCategory()); + entity.setPrice(dto.getPrice()); + } + + @Override + protected Long extractId(ProductEntity entity) { + return entity.getId(); + } + + @Override + public ProductWebDto toDto(ProductEntity entity) { + return ProductWebDto.builder() + .productId(entity.getProductId()) + .productName(entity.getProductName()) + .category(entity.getCategory()) + .price(entity.getPrice()) + .build(); + } + + @Override + public ProductEntity toEntity(ProductWebDto dto) { + return ProductEntity.builder() + .productId(dto.getProductId()) + .productName(dto.getProductName()) + .category(dto.getCategory()) + .price(dto.getPrice()) + .build(); + } +} +``` + +--- + +#### 3.0.5 BaseController + +**목적**: REST Controller의 공통 CRUD API 제공 + +**위치**: `com.snp.batch.common.web.controller.BaseController` + +**필수 구현 메서드**: +```java +protected abstract BaseService getService(); // Service 반환 +protected abstract String getResourceName(); // 리소스 이름 (로깅용) +``` + +**제공 API**: +```java +POST / → create(D dto) # 생성 +GET /{id} → getById(ID id) # 단건 조회 +GET / → getAll() # 전체 조회 +GET /page → getPage(Pageable) # 페이징 조회 +PUT /{id} → update(ID id, D dto) # 수정 +DELETE /{id} → delete(ID id) # 삭제 +GET /{id}/exists → exists(ID id) # 존재 여부 +``` + +**사용 예제**: +```java +@RestController +@RequestMapping("/api/ships") +@RequiredArgsConstructor +public class ShipController extends BaseController { + + private final ShipService shipService; + + @Override + protected BaseService getService() { + return shipService; + } + + @Override + protected String getResourceName() { + return "Ship"; + } + + // 추가 커스텀 API가 필요한 경우 여기에 정의 +} +``` + +--- + +#### 3.0.6 ApiResponse + +**목적**: 통일된 API 응답 형식 제공 + +**위치**: `com.snp.batch.common.web.ApiResponse` + +**필드 구조**: +```java +private boolean success; // 성공 여부 +private String message; // 메시지 +private T data; // 응답 데이터 +private String errorCode; // 에러 코드 (실패 시) +``` + +**사용 방법**: +```java +// 성공 응답 +ApiResponse response = ApiResponse.success(shipDto); +ApiResponse response = ApiResponse.success("Ship created", shipDto); + +// 실패 응답 +ApiResponse response = ApiResponse.error("Ship not found"); +ApiResponse response = ApiResponse.error("Validation failed", "ERR_001"); +``` + +**응답 예제**: +```json +{ + "success": true, + "message": "Success", + "data": { + "shipName": "Titanic", + "shipType": "Passenger" + }, + "errorCode": null +} +``` + +--- + +### 3.1 BaseApiReader + +**목적**: REST API에서 데이터를 읽어오는 ItemReader 구현 패턴 제공 + +**위치**: `com.snp.batch.common.batch.reader.BaseApiReader` + +**필수 구현 메소드**: +```java +protected abstract String getApiPath(); // API 경로 +protected abstract List extractDataFromResponse(Object response); // 응답 파싱 +protected abstract Class getResponseType(); // 응답 클래스 +protected abstract String getReaderName(); // Reader 이름 +``` + +**선택적 오버라이드 메소드**: +```java +protected void addQueryParams(UriBuilder uriBuilder) {} // 쿼리 파라미터 추가 +protected void beforeApiCall() {} // API 호출 전처리 +protected void afterApiCall(List data) {} // API 호출 후처리 +protected void handleApiError(Exception e) {} // 에러 처리 +``` + +**제공되는 기능**: +- API 호출 및 데이터 캐싱 (한 번만 호출) +- 순차적 데이터 반환 (read() 메소드) +- 자동 로깅 및 에러 핸들링 + +**사용 예제**: +```java +@Component +public class ShipDataReader extends BaseApiReader { + + public ShipDataReader(WebClient webClient) { + super(webClient); + } + + @Override + protected String getApiPath() { + return "/api/v1/ships"; + } + + @Override + protected List extractDataFromResponse(Object response) { + ShipApiResponse apiResponse = (ShipApiResponse) response; + return apiResponse.getData(); + } + + @Override + protected Class getResponseType() { + return ShipApiResponse.class; + } + + @Override + protected String getReaderName() { + return "ShipDataReader"; + } +} +``` + +--- + +### 3.2 BaseProcessor + +**목적**: 데이터 변환 및 처리 로직의 템플릿 제공 + +**위치**: `com.snp.batch.common.batch.processor.BaseProcessor` + +**필수 구현 메소드**: +```java +protected abstract O transform(I item) throws Exception; // 데이터 변환 +protected abstract boolean shouldProcess(I item); // 처리 여부 판단 +protected abstract String getProcessorName(); // Processor 이름 +``` + +**선택적 오버라이드 메소드**: +```java +protected void beforeProcess(I item) {} // 전처리 +protected void afterProcess(I input, O output) {} // 후처리 +protected void handleProcessError(I item, Exception e) {} // 에러 처리 +protected void onItemFiltered(I item) {} // 필터링 로깅 +``` + +**제공되는 기능**: +- DTO → Entity 변환 패턴 +- 데이터 필터링 (shouldProcess 기반) +- 자동 로깅 및 에러 핸들링 + +**사용 예제**: +```java +@Component +public class ShipDataProcessor extends BaseProcessor { + + @Override + protected ShipEntity transform(ShipDto dto) { + return ShipEntity.builder() + .shipId(dto.getShipId()) + .shipName(dto.getShipName()) + .shipType(dto.getShipType()) + .build(); + } + + @Override + protected boolean shouldProcess(ShipDto dto) { + // 유효성 검사: shipId가 있는 경우만 처리 + return dto.getShipId() != null && !dto.getShipId().isEmpty(); + } + + @Override + protected String getProcessorName() { + return "ShipDataProcessor"; + } + + @Override + protected void onItemFiltered(ShipDto dto) { + log.warn("Ship ID가 없어 필터링됨: {}", dto); + } +} +``` + +--- + +### 3.3 BaseWriter + +**목적**: 데이터베이스 저장 로직의 템플릿 제공 + +**위치**: `com.snp.batch.common.batch.writer.BaseWriter` + +**필수 구현 메소드**: +```java +protected abstract void writeItems(List items) throws Exception; // 저장 로직 +protected abstract String getWriterName(); // Writer 이름 +``` + +**선택적 오버라이드 메소드**: +```java +protected void beforeWrite(List items) {} // 저장 전처리 +protected void afterWrite(List items) {} // 저장 후처리 +protected void handleWriteError(List items, Exception e) {} // 에러 처리 +protected List filterItems(List items) {} // 아이템 필터링 +protected void validateBatchSize(List items) {} // 배치 크기 검증 +``` + +**제공되는 기능**: +- 배치 저장 패턴 (Chunk 단위) +- Null 아이템 자동 필터링 +- 배치 크기 검증 및 경고 +- 자동 로깅 및 에러 핸들링 + +**사용 예제**: +```java +@Component +@RequiredArgsConstructor +public class ShipDataWriter extends BaseWriter { + + private final ShipRepository shipRepository; + + @Override + protected void writeItems(List items) { + shipRepository.saveAll(items); + } + + @Override + protected String getWriterName() { + return "ShipDataWriter"; + } + + @Override + protected void afterWrite(List items) { + log.info("Ship 데이터 저장 완료: {} 건", items.size()); + } +} +``` + +--- + +### 3.4 BaseJobConfig + +**목적**: Batch Job 설정의 표준 템플릿 제공 + +**위치**: `com.snp.batch.common.batch.config.BaseJobConfig` + +**필수 구현 메소드**: +```java +protected abstract String getJobName(); // Job 이름 +protected abstract ItemReader createReader(); // Reader 생성 +protected abstract ItemProcessor createProcessor(); // Processor 생성 +protected abstract ItemWriter createWriter(); // Writer 생성 +``` + +**선택적 오버라이드 메소드**: +```java +protected String getStepName() {} // Step 이름 (기본: {jobName}Step) +protected int getChunkSize() {} // Chunk 크기 (기본: 100) +protected void configureJob(JobBuilder jobBuilder) {} // Job 커스터마이징 +protected void configureStep(StepBuilder stepBuilder) {} // Step 커스터마이징 +``` + +**제공되는 기능**: +- Job 및 Step 자동 생성 +- Chunk 기반 처리 설정 +- Processor가 없는 경우도 지원 + +**사용 예제**: +```java +@Configuration +@RequiredArgsConstructor +public class ShipDataImportJobConfig extends BaseJobConfig { + + private final ShipDataReader shipDataReader; + private final ShipDataProcessor shipDataProcessor; + private final ShipDataWriter shipDataWriter; + + public ShipDataImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDataReader shipDataReader, + ShipDataProcessor shipDataProcessor, + ShipDataWriter shipDataWriter) { + super(jobRepository, transactionManager); + this.shipDataReader = shipDataReader; + this.shipDataProcessor = shipDataProcessor; + this.shipDataWriter = shipDataWriter; + } + + @Override + protected String getJobName() { + return "shipDataImportJob"; + } + + @Override + protected ItemReader createReader() { + return shipDataReader; + } + + @Override + protected ItemProcessor createProcessor() { + return shipDataProcessor; + } + + @Override + protected ItemWriter createWriter() { + return shipDataWriter; + } + + @Override + protected int getChunkSize() { + return 50; // 커스텀 Chunk 크기 + } + + @Bean(name = "shipDataImportJob") + public Job shipDataImportJob() { + return job(); + } + + @Bean(name = "shipDataImportStep") + public Step shipDataImportStep() { + return step(); + } +} +``` + +--- + +### 3.5 BaseJdbcRepository + +**목적**: JDBC 기반 Repository의 CRUD 템플릿 제공 + +**위치**: `com.snp.batch.common.batch.repository.BaseJdbcRepository` + +**필수 구현 메소드**: +```java +protected abstract String getTableName(); // 테이블 이름 +protected abstract RowMapper getRowMapper(); // RowMapper +protected abstract ID extractId(T entity); // ID 추출 +protected abstract String getInsertSql(); // INSERT SQL +protected abstract String getUpdateSql(); // UPDATE SQL +protected abstract void setInsertParameters(PreparedStatement ps, T entity); +protected abstract void setUpdateParameters(PreparedStatement ps, T entity); +protected abstract String getEntityName(); // Entity 이름 (로깅용) +``` + +**제공되는 기능**: +- findById, findAll, count, existsById +- save, insert, update +- batchInsert, batchUpdate, saveAll +- deleteById, deleteAll +- 자동 트랜잭션 처리 + +**사용 예제**: +```java +@Repository +public class ShipRepository extends BaseJdbcRepository { + + public ShipRepository(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "ships"; + } + + @Override + protected RowMapper getRowMapper() { + return (rs, rowNum) -> ShipEntity.builder() + .shipId(rs.getString("ship_id")) + .shipName(rs.getString("ship_name")) + .shipType(rs.getString("ship_type")) + .build(); + } + + @Override + protected String extractId(ShipEntity entity) { + return entity.getShipId(); + } + + @Override + protected String getInsertSql() { + return "INSERT INTO ships (ship_id, ship_name, ship_type) VALUES (?, ?, ?)"; + } + + @Override + protected String getUpdateSql() { + return "UPDATE ships SET ship_name = ?, ship_type = ? WHERE ship_id = ?"; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ShipEntity entity) throws SQLException { + ps.setString(1, entity.getShipId()); + ps.setString(2, entity.getShipName()); + ps.setString(3, entity.getShipType()); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ShipEntity entity) throws SQLException { + ps.setString(1, entity.getShipName()); + ps.setString(2, entity.getShipType()); + ps.setString(3, entity.getShipId()); + } + + @Override + protected String getEntityName() { + return "Ship"; + } +} +``` + +--- + +## 4. 새로운 배치 Job 생성 가이드 + +### 4.1 사전 준비 + +1. **도메인 파악** + - 어떤 데이터를 수집할 것인가? (예: 선박 데이터, 사용자 데이터 등) + - API 엔드포인트는 무엇인가? + - 데이터 구조는 어떻게 되는가? + +2. **데이터베이스 테이블 생성** + ```sql + CREATE TABLE ships ( + ship_id VARCHAR(50) PRIMARY KEY, + ship_name VARCHAR(100) NOT NULL, + ship_type VARCHAR(50), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + ``` + +### 4.2 단계별 구현 + +#### Step 1: DTO 클래스 생성 (jobs/{domain}/batch/dto 패키지) + +**API 응답 DTO**: +```java +@Data +public class ShipApiResponse { + private List data; + private int totalCount; +} +``` + +**데이터 DTO**: +```java +@Data +@Builder +public class ShipDto { + private String imoNumber; + private String coreShipInd; + private String datasetVersion; +} +``` + +#### Step 2: Entity 클래스 생성 (jobs/{domain}/batch/entity 패키지) + +**JDBC 기반 Entity (JPA 어노테이션 없음)**: +```java +/** + * Ship Entity - JDBC Template 기반 + * JPA 어노테이션 사용 금지 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipEntity extends BaseEntity { + /** + * 기본 키 + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * IMO 번호 + * 컬럼: imo_number (VARCHAR(20), UNIQUE) + */ + private String imoNumber; + + /** + * Core Ship Indicator + * 컬럼: core_ship_ind (VARCHAR(10)) + */ + private String coreShipInd; + + // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 +} +``` + +#### Step 3: Repository 구현 (jobs/{domain}/batch/repository 패키지) + +```java +@Repository +public class ShipRepository extends BaseJdbcRepository { + + public ShipRepository(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + // 추상 메소드 구현 (위의 3.5 예제 참고) +} +``` + +#### Step 4: Reader 구현 (jobs/{domain}/batch/reader 패키지) + +```java +@Component +public class ShipDataReader extends BaseApiReader { + + public ShipDataReader(WebClient webClient) { + super(webClient); + } + + // 추상 메소드 구현 (위의 3.1 예제 참고) +} +``` + +#### Step 5: Processor 구현 (jobs/{domain}/batch/processor 패키지) + +```java +@Component +public class ShipDataProcessor extends BaseProcessor { + + // 추상 메소드 구현 (위의 3.2 예제 참고) +} +``` + +#### Step 6: Writer 구현 (jobs/{domain}/batch/writer 패키지) + +```java +@Component +@RequiredArgsConstructor +public class ShipDataWriter extends BaseWriter { + + private final ShipRepository shipRepository; + + // 추상 메소드 구현 (위의 3.3 예제 참고) +} +``` + +#### Step 7: JobConfig 구현 (jobs/{domain}/batch/config 패키지) + +```java +@Configuration +@RequiredArgsConstructor +public class ShipDataImportJobConfig extends BaseJobConfig { + + private final ShipDataReader shipDataReader; + private final ShipDataProcessor shipDataProcessor; + private final ShipDataWriter shipDataWriter; + + public ShipDataImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDataReader shipDataReader, + ShipDataProcessor shipDataProcessor, + ShipDataWriter shipDataWriter) { + super(jobRepository, transactionManager); + this.shipDataReader = shipDataReader; + this.shipDataProcessor = shipDataProcessor; + this.shipDataWriter = shipDataWriter; + } + + // 추상 메소드 구현 (위의 3.4 예제 참고) + + @Bean(name = "shipDataImportJob") + public Job shipDataImportJob() { + return job(); + } + + @Bean(name = "shipDataImportStep") + public Step shipDataImportStep() { + return step(); + } +} +``` + +#### Step 8: 테스트 및 실행 + +1. **애플리케이션 시작** + ```bash + mvn spring-boot:run + ``` + +2. **웹 UI에서 확인** + - http://localhost:8080 + - "shipDataImportJob" 확인 + - "즉시 실행" 버튼 클릭 + +3. **로그 확인** + ``` + ShipDataReader API 호출 시작 + ShipDataReader API 응답 성공: 100 건 + ShipDataReader 데이터 100건 조회 완료 + ShipDataWriter 데이터 저장 시작: 50 건 + ShipDataWriter 데이터 저장 완료: 50 건 + ``` + +--- + +## 5. 예제: 전체 구현 과정 + +### 5.1 시나리오 +- **목적**: 외부 API에서 사용자 데이터를 수집하여 데이터베이스에 저장 +- **API**: `GET /api/v1/users?status=active` +- **필터링**: 이메일이 있는 사용자만 저장 + +### 5.2 파일 구조 +``` +src/main/java/com/snp/batch/ +└── jobs/user/ + └── batch/ # 배치 작업 + ├── config/ + │ └── UserDataImportJobConfig.java + ├── dto/ + │ ├── UserDto.java + │ └── UserApiResponse.java + ├── entity/ + │ └── UserEntity.java # extends BaseEntity (JPA 제거) + ├── processor/ + │ └── UserDataProcessor.java # extends BaseProcessor + ├── reader/ + │ └── UserDataReader.java # extends BaseApiReader + ├── repository/ + │ ├── UserRepository.java # 인터페이스 + │ └── UserRepositoryImpl.java # extends BaseJdbcRepository + └── writer/ + └── UserDataWriter.java # extends BaseWriter +``` + +### 5.3 테이블 생성 SQL +```sql +CREATE TABLE users ( + user_id BIGINT PRIMARY KEY, + username VARCHAR(100) NOT NULL, + email VARCHAR(255), + status VARCHAR(50), + imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +``` + +### 5.4 코드 구현 + +각 클래스의 전체 구현 코드는 섹션 3의 추상 클래스 사용 예제를 참고하세요. + +--- + +## 6. 베스트 프랙티스 + +### 6.1 네이밍 컨벤션 + +| 구분 | 패턴 | 예제 | +|------|------|------| +| Job 이름 | `{domain}DataImportJob` | `shipDataImportJob` | +| Step 이름 | `{domain}DataImportStep` | `shipDataImportStep` | +| Reader | `{Domain}DataReader` | `ShipDataReader` | +| Processor | `{Domain}DataProcessor` | `ShipDataProcessor` | +| Writer | `{Domain}DataWriter` | `ShipDataWriter` | +| JobConfig | `{Domain}DataImportJobConfig` | `ShipDataImportJobConfig` | +| Repository | `{Domain}Repository` | `ShipRepository` | +| Entity | `{Domain}Entity` | `ShipEntity` | +| DTO | `{Domain}Dto` | `ShipDto` | + +### 6.2 패키지 구조 + +**배치 전용 Job** (예: shipimport): +``` +com.snp.batch.jobs.{domain}/ +└── batch/ + ├── config/ + │ └── {Domain}DataImportJobConfig.java # extends BaseJobConfig + ├── dto/ + │ ├── {Domain}Dto.java + │ └── {Domain}ApiResponse.java + ├── entity/ + │ └── {Domain}Entity.java # extends BaseEntity (JPA 제거) + ├── processor/ + │ └── {Domain}DataProcessor.java # extends BaseProcessor + ├── reader/ + │ └── {Domain}DataReader.java # extends BaseApiReader + ├── repository/ + │ ├── {Domain}Repository.java # 인터페이스 + │ └── {Domain}RepositoryImpl.java # extends BaseJdbcRepository + └── writer/ + └── {Domain}DataWriter.java # extends BaseWriter +``` + +**배치 + 웹 API Job** (예: sample): +``` +com.snp.batch.jobs.{domain}/ +├── batch/ # 배치 작업 +│ ├── config/ +│ ├── dto/ +│ ├── entity/ +│ ├── processor/ +│ ├── reader/ +│ ├── repository/ +│ └── writer/ +└── web/ # 웹 API + ├── controller/ + │ └── {Domain}WebController.java # extends BaseController + ├── dto/ + │ └── {Domain}WebDto.java # extends BaseDto + └── service/ + └── {Domain}WebService.java # extends BaseServiceImpl (JDBC) +``` + +### 6.3 Chunk 크기 선택 가이드 + +| 데이터 크기 | Chunk 크기 | 사용 시나리오 | +|-------------|-----------|--------------| +| 소량 (< 1,000) | 50-100 | 간단한 API 데이터 | +| 중량 (1,000-10,000) | 100-500 | 일반적인 배치 작업 | +| 대량 (> 10,000) | 500-1,000 | 대용량 데이터 처리 | + +### 6.4 에러 처리 전략 + +1. **API 호출 실패** + - `BaseApiReader.handleApiError()` 오버라이드 + - 빈 리스트 반환 (Job 실패 방지) 또는 예외 던지기 + +2. **데이터 변환 실패** + - `BaseProcessor.handleProcessError()` 오버라이드 + - 문제 데이터 로깅 후 null 반환 (다음 데이터 계속 처리) + +3. **저장 실패** + - `BaseWriter.handleWriteError()` 오버라이드 + - 재시도 로직 또는 부분 저장 구현 + +### 6.5 로깅 전략 + +```java +// Reader에서 +@Override +protected void afterApiCall(List data) { + log.info("API 호출 성공: {} 건 조회", data.size()); +} + +// Processor에서 +@Override +protected void onItemFiltered(ShipDto dto) { + log.debug("필터링됨: {}", dto); +} + +// Writer에서 +@Override +protected void afterWrite(List items) { + log.info("저장 완료: {} 건", items.size()); +} +``` + +### 6.6 성능 최적화 + +1. **배치 Insert 사용** + ```java + @Override + protected void writeItems(List items) { + shipRepository.batchInsert(items); // saveAll() 대신 + } + ``` + +2. **API 페이징 처리** + ```java + @Override + protected void addQueryParams(UriBuilder uriBuilder) { + uriBuilder.queryParam("page", 1); + uriBuilder.queryParam("size", 1000); + } + ``` + +3. **경량 쿼리 사용** + - 불필요한 JOIN 제거 + - 필요한 컬럼만 SELECT + +--- + +## 7. 트러블슈팅 + +### 7.1 일반적인 문제 + +#### 문제 1: Job이 실행되지 않음 +**증상**: 웹 UI에서 Job 목록이 보이지 않음 + +**해결 방법**: +1. `@Bean` 어노테이션 확인 + ```java + @Bean(name = "shipDataImportJob") + public Job shipDataImportJob() { + return job(); + } + ``` + +2. JobConfig 클래스에 `@Configuration` 어노테이션 확인 + +3. 로그 확인: "Job 생성: shipDataImportJob" 메시지 확인 + +--- + +#### 문제 2: API 호출 실패 +**증상**: "API 호출 실패" 로그, 데이터 0건 + +**해결 방법**: +1. WebClient 설정 확인 (`application.yml`) + ```yaml + api: + base-url: http://api.example.com + ``` + +2. API 경로 확인 + ```java + @Override + protected String getApiPath() { + return "/api/v1/ships"; // 슬래시 확인 + } + ``` + +3. 네트워크 연결 테스트 + ```bash + curl http://api.example.com/api/v1/ships + ``` + +--- + +#### 문제 3: 데이터가 저장되지 않음 +**증상**: "저장 완료" 로그는 있지만 DB에 데이터 없음 + +**해결 방법**: +1. 트랜잭션 확인 + ```java + @Override + protected void writeItems(List items) { + shipRepository.saveAll(items); // 트랜잭션 내에서 실행되는지 확인 + } + ``` + +2. SQL 로그 활성화 (`application.yml`) + ```yaml + logging: + level: + org.springframework.jdbc.core: DEBUG + ``` + +3. 데이터베이스 연결 확인 + ```bash + psql -U postgres -d batch_db + SELECT * FROM ships; + ``` + +--- + +#### 문제 4: Chunk 처리 중 일부만 저장됨 +**증상**: 100건 조회 중 50건만 저장됨 + +**해결 방법**: +1. Processor의 `shouldProcess()` 확인 + ```java + @Override + protected boolean shouldProcess(ShipDto dto) { + // false 반환 시 필터링됨 + return dto.getShipId() != null; + } + ``` + +2. 필터링 로그 확인 + ```java + @Override + protected void onItemFiltered(ShipDto dto) { + log.warn("필터링됨: {}", dto); // 로그 추가 + } + ``` + +--- + +#### 문제 5: 메모리 부족 오류 (OutOfMemoryError) +**증상**: 대량 데이터 처리 중 OOM 발생 + +**해결 방법**: +1. Chunk 크기 줄이기 + ```java + @Override + protected int getChunkSize() { + return 50; // 기본 100에서 감소 + } + ``` + +2. JVM 힙 메모리 증가 + ```bash + java -Xmx2g -jar snp-batch.jar + ``` + +3. API 페이징 처리 구현 + +--- + +### 7.2 로그 레벨 설정 + +**application.yml**: +```yaml +logging: + level: + com.snp.batch: DEBUG # 배치 애플리케이션 + com.snp.batch.common.batch: INFO # 배치 추상 클래스 (INFO) + com.snp.batch.common.web: INFO # 웹 추상 클래스 (INFO) + org.springframework.batch: INFO # Spring Batch + org.springframework.jdbc.core: DEBUG # SQL 쿼리 + org.springframework.web.reactive: DEBUG # WebClient +``` + +--- + +### 7.3 디버깅 팁 + +1. **Step 실행 상태 확인** + ```sql + SELECT * FROM BATCH_STEP_EXECUTION + WHERE JOB_EXECUTION_ID = {executionId}; + ``` + +2. **Step Context 확인** + ```sql + SELECT * FROM BATCH_STEP_EXECUTION_CONTEXT + WHERE STEP_EXECUTION_ID = {stepExecutionId}; + ``` + +3. **Job Parameter 확인** + ```sql + SELECT * FROM BATCH_JOB_EXECUTION_PARAMS + WHERE JOB_EXECUTION_ID = {executionId}; + ``` + +--- + +## 8. 자주 묻는 질문 (FAQ) + +### Q1: Processor 없이 Reader → Writer만 사용할 수 있나요? +**A**: 네, 가능합니다. `createProcessor()`에서 `null`을 반환하면 됩니다. + +```java +@Override +protected ItemProcessor createProcessor() { + return null; // Processor 없이 Reader → Writer +} +``` + +--- + +### Q2: 여러 개의 Writer를 사용할 수 있나요? +**A**: `CompositeItemWriter`를 사용하면 가능합니다. + +```java +@Override +protected ItemWriter createWriter() { + CompositeItemWriter compositeWriter = new CompositeItemWriter<>(); + compositeWriter.setDelegates(Arrays.asList( + shipDataWriter, + auditLogWriter + )); + return compositeWriter; +} +``` + +--- + +### Q3: API 페이징을 지원하나요? +**A**: 현재 `BaseApiReader`는 단일 호출만 지원합니다. 페이징이 필요한 경우 커스텀 Reader를 구현하세요. + +--- + +### Q4: 스케줄 등록은 어떻게 하나요? +**A**: 웹 UI에서 "스케줄 등록" 버튼을 클릭하여 Cron 표현식을 입력하면 됩니다. + +``` +예제 Cron 표현식: +- 매일 오전 2시: 0 0 2 * * ? +- 매시간: 0 0 * * * ? +- 매주 월요일 오전 9시: 0 0 9 ? * MON +``` + +--- + +### Q5: Job 실행 이력은 어디서 확인하나요? +**A**: 웹 UI의 다음 위치에서 확인할 수 있습니다: +1. 대시보드: 최근 실행 이력 (최근 10건) +2. Job 상세 페이지: 특정 Job의 모든 실행 이력 +3. 타임라인 차트: 일/주/월 단위 시각화 + +--- + +## 9. 추가 리소스 + +### 9.1 공식 문서 +- [Spring Batch 공식 문서](https://docs.spring.io/spring-batch/docs/current/reference/html/) +- [Spring Boot 공식 문서](https://docs.spring.io/spring-boot/docs/current/reference/html/) +- [Quartz Scheduler](http://www.quartz-scheduler.org/documentation/) + +### 9.2 프로젝트 파일 +- `application.yml`: 애플리케이션 설정 +- `schema-postgresql.sql`: 데이터베이스 스키마 +- `BaseApiReader.java`: API Reader 추상 클래스 (src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java:1) +- `BaseJobConfig.java`: Job Config 추상 클래스 (src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java:1) +- `BaseJdbcRepository.java`: JDBC Repository 추상 클래스 (src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java:1) + +--- + +--- + +## 10. 추상화 클래스 체크리스트 + +새로운 배치 Job을 만들 때 다음 체크리스트를 참고하세요. + +### 10.1 필수 구현 항목 + +- [ ] **패키지 구조 생성** + - [ ] `jobs/{domain}/batch/` 디렉토리 생성 (배치 작업) + - [ ] `jobs/{domain}/web/` 디렉토리 생성 (웹 API 필요 시) + +- [ ] **DTO 생성** (`jobs/{domain}/batch/dto/` 패키지) + - [ ] API 응답 DTO (예: `ShipApiResponse`) + - [ ] 데이터 DTO (예: `ShipDto`) + +- [ ] **Entity 생성** (`jobs/{domain}/batch/entity/` 패키지) + - [ ] `BaseEntity` 상속 + - [ ] `@SuperBuilder`, `@NoArgsConstructor`, `@AllArgsConstructor` 추가 + - [ ] `@EqualsAndHashCode(callSuper = true)` 추가 + - [ ] **JPA 어노테이션 사용 금지** (@Entity, @Table, @Column 등) + - [ ] 컬럼 매핑 정보는 주석으로 명시 + +- [ ] **Repository 구현** (`jobs/{domain}/batch/repository/` 패키지) + - [ ] 인터페이스 생성 (예: `ShipRepository`) + - [ ] 구현체 생성 (예: `ShipRepositoryImpl`) + - [ ] `BaseJdbcRepository` 상속 (JDBC 전용) + - [ ] `getTableName()`, `getInsertSql()`, `getUpdateSql()` 구현 + - [ ] `setInsertParameters()`, `setUpdateParameters()` 구현 + - [ ] `getRowMapper()` 구현 (RowMapper 클래스 생성) + - [ ] 커스텀 쿼리 메서드 정의 + +- [ ] **Reader 구현** (`jobs/{domain}/batch/reader/` 패키지) + - [ ] `BaseApiReader` 상속 + - [ ] `getApiPath()` 구현 + - [ ] `extractDataFromResponse()` 구현 + - [ ] `getResponseType()` 구현 + - [ ] `getReaderName()` 구현 + +- [ ] **Processor 구현** (`jobs/{domain}/batch/processor/` 패키지) + - [ ] `BaseProcessor` 상속 + - [ ] `transform()` 구현 (DTO → Entity 변환) + - [ ] `shouldProcess()` 구현 (필터링 로직) + - [ ] `getProcessorName()` 구현 + +- [ ] **Writer 구현** (`jobs/{domain}/batch/writer/` 패키지) + - [ ] `BaseWriter` 상속 + - [ ] `writeItems()` 구현 (Repository 호출) + - [ ] `getWriterName()` 구현 + +- [ ] **JobConfig 구현** (`jobs/{domain}/batch/config/` 패키지) + - [ ] `BaseJobConfig` 상속 + - [ ] `getJobName()` 구현 + - [ ] `createReader()` 구현 + - [ ] `createProcessor()` 구현 + - [ ] `createWriter()` 구현 + - [ ] `getChunkSize()` 구현 (선택사항, 기본값: 100) + - [ ] `@Bean` 메서드로 Job과 Step 등록 + +### 10.2 선택 구현 항목 + +- [ ] **웹 API 구현** (REST API 제공 시) + - [ ] **DTO 생성** (`jobs/{domain}/web/dto/` 패키지) + - [ ] `BaseDto` 상속 (웹 전용 DTO) + - [ ] **Service 구현** (`jobs/{domain}/web/service/` 패키지) + - [ ] `BaseServiceImpl` 상속 (JDBC 기반) + - [ ] `getRepository()` 구현 (배치 Repository 재사용) + - [ ] `toDto()`, `toEntity()` 구현 + - [ ] CRUD 메서드 오버라이드 (필요 시) + - [ ] **Controller 구현** (`jobs/{domain}/web/controller/` 패키지) + - [ ] `BaseController` 상속 + - [ ] `@RequestMapping` 설정 + - [ ] 커스텀 API 추가 (필요 시) + +- [ ] **에러 핸들링** + - [ ] `BaseApiReader.handleApiError()` 오버라이드 + - [ ] `BaseProcessor.handleProcessError()` 오버라이드 + - [ ] `BaseWriter.handleWriteError()` 오버라이드 + +- [ ] **로깅 강화** + - [ ] `beforeApiCall()`, `afterApiCall()` 구현 + - [ ] `beforeProcess()`, `afterProcess()` 구현 + - [ ] `beforeWrite()`, `afterWrite()` 구현 + +### 10.3 테스트 항목 + +- [ ] **단위 테스트** + - [ ] Reader 테스트 (API 모킹) + - [ ] Processor 테스트 (변환 로직 검증) + - [ ] Writer 테스트 (Repository 모킹) + +- [ ] **통합 테스트** + - [ ] Job 실행 테스트 + - [ ] 데이터베이스 저장 검증 + +- [ ] **성능 테스트** + - [ ] 대용량 데이터 처리 테스트 + - [ ] Chunk 크기 최적화 + +--- + +--- + +## 📚 관련 문서 + +### 핵심 문서 +- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드 +- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성) +- **[SWAGGER_GUIDE.md](SWAGGER_GUIDE.md)** - Swagger API 문서 사용 가이드 + +### 아키텍처 문서 +- **[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 +**버전**: 1.3.0 + +--- + +## 변경 이력 + +### v1.3.0 (2025-10-16) +- ✅ 프로젝트 구조를 현행화: `common/batch/`와 `common/web/` 분리 반영 +- ✅ jobs 패키지 구조 업데이트: `batch/`와 `web/` 서브패키지 구조 반영 +- ✅ 모든 Base 클래스 위치 경로 수정 (common.base → common.batch/web) +- ✅ JDBC vs JPA 사용 구분 명확화 (jobs는 JDBC 전용, global은 JPA 허용) +- ✅ Entity 예제 업데이트: JPA 어노테이션 제거, 주석 기반 매핑 설명 추가 +- ✅ 체크리스트 강화: 패키지 구조, Repository 구현, 웹 API 구현 세분화 + +### v1.2.0 (2025-10-15) +- 문서 간 상호 참조 링크 추가 +- 관련 문서 섹션 추가 + +### v1.1.0 (2025-10-14) +- 추상화 클래스 체크리스트 추가 +- 예제 코드 개선 + +### v1.0.0 (2025-10-13) +- 초기 버전 작성 diff --git a/SWAGGER_GUIDE.md b/SWAGGER_GUIDE.md new file mode 100644 index 0000000..5e6f34f --- /dev/null +++ b/SWAGGER_GUIDE.md @@ -0,0 +1,517 @@ +# 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> create( + @Parameter(description = "생성할 리소스 데이터", required = true) + @RequestBody D dto) { + // ... +} +``` + +### 주요 어노테이션 설명 + +#### 1. `@Tag` - API 그룹화 +```java +@Tag(name = "Product API", description = "제품 관리 API") +public class ProductWebController extends BaseController { + // ... +} +``` + +#### 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 { + + private final MyResourceService myResourceService; + + @Override + protected BaseService 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> 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> customAction( + @Parameter(description = "액션 파라미터", required = true) + @RequestBody Map 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 diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..30fae36 --- /dev/null +++ b/pom.xml @@ -0,0 +1,163 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.1 + + + + com.snp + snp-batch + 1.0.0 + SNP Batch + Spring Batch project for JSON to PostgreSQL with Web GUI + + + 17 + UTF-8 + 17 + 17 + + + 3.2.1 + 5.1.0 + 42.7.6 + 1.18.30 + 2.5.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-batch + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + + org.postgresql + postgresql + ${postgresql.version} + runtime + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + org.springframework.boot + spring-boot-starter-quartz + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.projectlombok + lombok + ${lombok.version} + true + + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.3.0 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.springframework.batch + spring-batch-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring-boot.version} + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.11.0 + + 17 + 17 + UTF-8 + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java new file mode 100644 index 0000000..bf4315f --- /dev/null +++ b/src/main/java/com/snp/batch/SnpBatchApplication.java @@ -0,0 +1,14 @@ +package com.snp.batch; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +public class SnpBatchApplication { + + public static void main(String[] args) { + SpringApplication.run(SnpBatchApplication.class, args); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java b/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java new file mode 100644 index 0000000..cb7f2dd --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java @@ -0,0 +1,138 @@ +package com.snp.batch.common.batch.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * Batch Job 설정을 위한 추상 클래스 + * Reader → Processor → Writer 패턴의 표준 Job 구성 제공 + * + * @param 입력 타입 (Reader 출력, Processor 입력) + * @param 출력 타입 (Processor 출력, Writer 입력) + */ +@Slf4j +@RequiredArgsConstructor +public abstract class BaseJobConfig { + + protected final JobRepository jobRepository; + protected final PlatformTransactionManager transactionManager; + + /** + * Job 이름 반환 (하위 클래스에서 구현) + * 예: "shipDataImportJob" + */ + protected abstract String getJobName(); + + /** + * Step 이름 반환 (선택사항, 기본: {jobName}Step) + */ + protected String getStepName() { + return getJobName() + "Step"; + } + + /** + * Reader 생성 (하위 클래스에서 구현) + */ + protected abstract ItemReader createReader(); + + /** + * Processor 생성 (하위 클래스에서 구현) + * 처리 로직이 없는 경우 null 반환 가능 + */ + protected abstract ItemProcessor createProcessor(); + + /** + * Writer 생성 (하위 클래스에서 구현) + */ + protected abstract ItemWriter createWriter(); + + /** + * Chunk 크기 반환 (선택사항, 기본: 100) + */ + protected int getChunkSize() { + return 100; + } + + /** + * Job 시작 전 실행 (선택사항) + * Job Listener 등록 시 사용 + */ + protected void configureJob(JobBuilder jobBuilder) { + // 기본 구현: 아무것도 하지 않음 + // 하위 클래스에서 필요시 오버라이드 + // 예: jobBuilder.listener(jobExecutionListener()) + } + + /** + * Step 커스터마이징 (선택사항) + * Step Listener, FaultTolerant 등 설정 시 사용 + */ + protected void configureStep(StepBuilder stepBuilder) { + // 기본 구현: 아무것도 하지 않음 + // 하위 클래스에서 필요시 오버라이드 + // 예: stepBuilder.listener(stepExecutionListener()) + // stepBuilder.faultTolerant().skip(Exception.class).skipLimit(10) + } + + /** + * Step 생성 (표준 구현 제공) + */ + public Step step() { + log.info("Step 생성: {}", getStepName()); + + ItemProcessor processor = createProcessor(); + StepBuilder stepBuilder = new StepBuilder(getStepName(), jobRepository); + + // Processor가 있는 경우 + if (processor != null) { + var chunkBuilder = stepBuilder + .chunk(getChunkSize(), transactionManager) + .reader(createReader()) + .processor(processor) + .writer(createWriter()); + + // 커스텀 설정 적용 + configureStep(stepBuilder); + + return chunkBuilder.build(); + } + // Processor가 없는 경우 (I == O 타입 가정) + else { + @SuppressWarnings("unchecked") + var chunkBuilder = stepBuilder + .chunk(getChunkSize(), transactionManager) + .reader(createReader()) + .writer((ItemWriter) createWriter()); + + // 커스텀 설정 적용 + configureStep(stepBuilder); + + return chunkBuilder.build(); + } + } + + /** + * Job 생성 (표준 구현 제공) + */ + public Job job() { + log.info("Job 생성: {}", getJobName()); + + JobBuilder jobBuilder = new JobBuilder(getJobName(), jobRepository); + + // 커스텀 설정 적용 + configureJob(jobBuilder); + + return jobBuilder + .start(step()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java b/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java new file mode 100644 index 0000000..1b35a2a --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/entity/BaseEntity.java @@ -0,0 +1,46 @@ +package com.snp.batch.common.batch.entity; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +/** + * 모든 Entity의 공통 베이스 클래스 - JDBC 전용 + * 생성/수정 감사(Audit) 필드 제공 + * + * 이 필드들은 Repository의 Insert/Update 시 자동으로 설정됩니다. + * BaseJdbcRepository가 감사 필드를 자동으로 관리합니다. + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +public abstract class BaseEntity { + + /** + * 생성 일시 + * 컬럼: created_at (TIMESTAMP) + */ + private LocalDateTime createdAt; + + /** + * 수정 일시 + * 컬럼: updated_at (TIMESTAMP) + */ + private LocalDateTime updatedAt; + + /** + * 생성자 + * 컬럼: created_by (VARCHAR(100)) + */ + private String createdBy; + + /** + * 수정자 + * 컬럼: updated_by (VARCHAR(100)) + */ + private String updatedBy; +} diff --git a/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java new file mode 100644 index 0000000..0add9cc --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/processor/BaseProcessor.java @@ -0,0 +1,61 @@ +package com.snp.batch.common.batch.processor; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.ItemProcessor; + +/** + * ItemProcessor 추상 클래스 (v2.0) + * 데이터 변환 및 처리 로직을 위한 템플릿 제공 + * + * Template Method Pattern: + * - process(): 공통 로직 (null 체크, 로깅) + * - processItem(): 하위 클래스에서 변환 로직 구현 + * + * 기본 용도: + * - 단순 변환: DTO → Entity + * - 데이터 필터링: null 반환 시 해당 아이템 스킵 + * - 데이터 검증: 유효하지 않은 데이터 필터링 + * + * 고급 용도 (다중 depth JSON 처리): + * - 중첩된 JSON을 여러 Entity로 분해 + * - 1:N 관계 처리 (Order → OrderItems) + * - CompositeWriter와 조합하여 여러 테이블에 저장 + * + * 예제: + * - 단순 변환: ProductDataProcessor (DTO → Entity) + * - 복잡한 처리: 복잡한 JSON 처리 예제 참고 + * + * @param 입력 DTO 타입 + * @param 출력 Entity 타입 + */ +@Slf4j +public abstract class BaseProcessor implements ItemProcessor { + + /** + * 데이터 변환 로직 (하위 클래스에서 구현) + * DTO → Entity 변환 등의 비즈니스 로직 구현 + * + * @param item 입력 DTO + * @return 변환된 Entity (필터링 시 null 반환 가능) + * @throws Exception 처리 중 오류 발생 시 + */ + protected abstract O processItem(I item) throws Exception; + + /** + * Spring Batch ItemProcessor 인터페이스 구현 + * 데이터 변환 및 필터링 수행 + * + * @param item 입력 DTO + * @return 변환된 Entity (null이면 해당 아이템 스킵) + * @throws Exception 처리 중 오류 발생 시 + */ + @Override + public O process(I item) throws Exception { + if (item == null) { + return null; + } + + log.debug("데이터 처리 중: {}", item); + return processItem(item); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java new file mode 100644 index 0000000..6923f99 --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java @@ -0,0 +1,633 @@ +package com.snp.batch.common.batch.reader; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.annotation.BeforeStep; +import org.springframework.batch.item.ExecutionContext; +import org.springframework.batch.item.ItemReader; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.util.UriBuilder; + +import java.net.URI; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * REST API 기반 ItemReader 추상 클래스 (v3.0 - Chunk 기반) + * + * 주요 기능: + * - HTTP Method 지원: GET, POST + * - 다중 Query Parameter 처리 + * - Path Variable 지원 + * - Request Body 지원 (POST) + * - 동적 Header 설정 + * - 복잡한 JSON 응답 파싱 + * - ✨ Chunk 기반 배치 처리 (Iterator 패턴) + * + * Template Method Pattern: + * - read(): 공통 로직 (1건씩 순차 반환) + * - fetchNextBatch(): 다음 배치 조회 (구현체에서 오버라이드) + * - 새로운 훅 메서드들: HTTP Method, 파라미터, 헤더 등 + * + * 동작 방식: + * 1. read() 호출 시 currentBatch가 비어있으면 fetchNextBatch() 호출 + * 2. fetchNextBatch()가 100건 반환 + * 3. read()가 100번 호출되면서 1건씩 반환 + * 4. 100건 모두 반환되면 다시 fetchNextBatch() 호출 + * 5. fetchNextBatch()가 null/empty 반환 시 Job 종료 + * + * 하위 호환성: + * - 기존 fetchDataFromApi() 메서드 계속 지원 + * - 새로운 fetchNextBatch() 메서드 사용 권장 + * + * @param DTO 타입 (API 응답 데이터) + */ +@Slf4j +public abstract class BaseApiReader implements ItemReader { + + // Chunk 기반 Iterator 패턴 + private java.util.Iterator currentBatch; + private boolean initialized = false; + private boolean useChunkMode = false; // Chunk 모드 사용 여부 + + // 하위 호환성을 위한 필드 (fetchDataFromApi 사용 시) + private List legacyDataList; + private int legacyNextIndex = 0; + + // WebClient는 하위 클래스에서 주입받아 사용 + protected WebClient webClient; + + // StepExecution - API 정보 저장용 + protected StepExecution stepExecution; + + // API 호출 통계 + private int totalApiCalls = 0; + private int completedApiCalls = 0; + + /** + * 기본 생성자 (WebClient 없이 사용 - Mock 데이터용) + */ + protected BaseApiReader() { + this.webClient = null; + } + + /** + * WebClient를 주입받는 생성자 (실제 API 연동용) + * + * @param webClient Spring WebClient 인스턴스 + */ + protected BaseApiReader(WebClient webClient) { + this.webClient = webClient; + } + + /** + * Step 실행 전 초기화 및 API 정보 저장 + * Spring Batch가 자동으로 StepExecution을 주입하고 이 메서드를 호출함 + * + * @param stepExecution Step 실행 정보 + */ + @BeforeStep + public void saveApiInfoToContext(StepExecution stepExecution) { + this.stepExecution = stepExecution; + + // API 정보를 StepExecutionContext에 저장 + ExecutionContext context = stepExecution.getExecutionContext(); + + // WebClient가 있는 경우에만 API 정보 저장 + if (webClient != null) { + // 1. API URL 저장 + String baseUrl = getApiBaseUrl(); + String apiPath = getApiPath(); + String fullUrl = baseUrl != null ? baseUrl + apiPath : apiPath; + context.putString("apiUrl", fullUrl); + + // 2. HTTP Method 저장 + context.putString("apiMethod", getHttpMethod()); + + // 3. API Parameters 저장 + Map params = new HashMap<>(); + Map queryParams = getQueryParams(); + if (queryParams != null && !queryParams.isEmpty()) { + params.putAll(queryParams); + } + Map pathVars = getPathVariables(); + if (pathVars != null && !pathVars.isEmpty()) { + params.putAll(pathVars); + } + context.put("apiParameters", params); + + // 4. 통계 초기화 + context.putInt("totalApiCalls", 0); + context.putInt("completedApiCalls", 0); + + log.info("[{}] API 정보 저장: {} {}", getReaderName(), getHttpMethod(), fullUrl); + } + } + + /** + * API Base URL 반환 (WebClient의 baseUrl) + * 하위 클래스에서 필요 시 오버라이드 + */ + protected String getApiBaseUrl() { + return ""; + } + + /** + * API 호출 통계 업데이트 + */ + protected void updateApiCallStats(int totalCalls, int completedCalls) { + if (stepExecution != null) { + ExecutionContext context = stepExecution.getExecutionContext(); + context.putInt("totalApiCalls", totalCalls); + context.putInt("completedApiCalls", completedCalls); + + // 마지막 호출 시간 저장 + String lastCallTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + context.putString("lastCallTime", lastCallTime); + + this.totalApiCalls = totalCalls; + this.completedApiCalls = completedCalls; + } + } + + // ======================================== + // ItemReader 구현 (공통 로직) + // ======================================== + + /** + * Spring Batch ItemReader 인터페이스 구현 + * 데이터를 순차적으로 하나씩 반환 + * + * Chunk 기반 동작: + * 1. currentBatch가 비어있으면 fetchNextBatch() 호출하여 다음 배치 로드 + * 2. Iterator에서 1건씩 반환 + * 3. Iterator가 비면 다시 1번으로 + * 4. fetchNextBatch()가 null/empty 반환하면 Job 종료 + * + * @return 다음 데이터 항목 (더 이상 없으면 null) + */ + @Override + public T read() throws Exception { + // Chunk 모드 사용 여부는 첫 호출 시 결정 + if (!initialized && !useChunkMode) { + // Legacy 모드로 시작 + return readLegacyMode(); + } + + // Chunk 모드가 활성화된 경우 + if (useChunkMode) { + return readChunkMode(); + } + + // Legacy 모드 + return readLegacyMode(); + } + + /** + * Chunk 모드 활성화 (하위 클래스에서 명시적 호출) + */ + protected void enableChunkMode() { + this.useChunkMode = true; + } + + /** + * Chunk 기반 read() 구현 (신규 방식) + */ + private T readChunkMode() throws Exception { + // 최초 호출 시 초기화 + if (!initialized) { + beforeFetch(); + initialized = true; + } + + // currentBatch가 비어있으면 다음 배치 로드 + if (currentBatch == null || !currentBatch.hasNext()) { + List nextBatch = fetchNextBatch(); + + // 더 이상 데이터가 없으면 종료 + if (nextBatch == null || nextBatch.isEmpty()) { + afterFetch(null); + log.info("[{}] 모든 배치 처리 완료", getReaderName()); + return null; + } + + // Iterator 갱신 + currentBatch = nextBatch.iterator(); + log.debug("[{}] 배치 로드 완료: {} 건", getReaderName(), nextBatch.size()); + } + + // Iterator에서 1건씩 반환 + return currentBatch.next(); + } + + /** + * Legacy 모드 read() 구현 (하위 호환성) + * 기존 fetchDataFromApi()를 오버라이드한 구현체 지원 + */ + private T readLegacyMode() throws Exception { + // 최초 호출 시 API에서 전체 데이터 조회 + if (legacyDataList == null) { + beforeFetch(); + legacyDataList = fetchDataFromApi(); + afterFetch(legacyDataList); + log.info("[{}] 데이터 {}건 조회 완료 (Legacy 모드)", + getReaderName(), legacyDataList != null ? legacyDataList.size() : 0); + } + + // 데이터를 순차적으로 반환 + if (legacyDataList != null && legacyNextIndex < legacyDataList.size()) { + return legacyDataList.get(legacyNextIndex++); + } else { + return null; // 데이터 끝 + } + } + + + // ======================================== + // 핵심 추상 메서드 (하위 클래스에서 구현) + // ======================================== + + /** + * ✨ 다음 배치 데이터를 조회하여 리스트로 반환 (신규 방식 - Chunk 기반) + * + * Chunk 기반 배치 처리를 위한 메서드: + * - read()가 호출될 때마다 필요 시 이 메서드가 호출됨 + * - 일반적으로 100~1000건씩 반환 + * - 더 이상 데이터가 없으면 null 또는 빈 리스트 반환 + * + * 구현 예시: + *
+     * private int currentPage = 0;
+     * private final int pageSize = 100;
+     *
+     * @Override
+     * protected List fetchNextBatch() {
+     *     if (currentPage >= totalPages) {
+     *         return null; // 종료
+     *     }
+     *
+     *     // API 호출 (100건씩)
+     *     ProductApiResponse response = callApiForPage(currentPage, pageSize);
+     *     currentPage++;
+     *
+     *     return response.getProducts();
+     * }
+     * 
+ * + * @return 다음 배치 데이터 리스트 (null 또는 빈 리스트면 종료) + * @throws Exception API 호출 실패 등 + */ + protected List fetchNextBatch() throws Exception { + // 기본 구현: Legacy 모드 fallback + // 하위 클래스에서 오버라이드 안 하면 fetchDataFromApi() 사용 + return null; + } + + /** + * API에서 데이터를 조회하여 리스트로 반환 (Legacy 방식 - 하위 호환성) + * + * ⚠️ Deprecated: fetchNextBatch()를 사용하세요. + * + * 구현 방법: + * 1. WebClient 없이 Mock 데이터 생성 (sample용) + * 2. WebClient로 실제 API 호출 (실전용) + * 3. callApi() 헬퍼 메서드 사용 (권장) + * + * @return API에서 조회한 데이터 리스트 (전체) + */ + protected List fetchDataFromApi() { + // 기본 구현: 빈 리스트 반환 + // 하위 클래스에서 오버라이드 필요 + return new ArrayList<>(); + } + + /** + * Reader 이름 반환 (로깅용) + * + * @return Reader 이름 (예: "ProductDataReader") + */ + protected abstract String getReaderName(); + + // ======================================== + // HTTP 요청 설정 메서드 (선택적 오버라이드) + // ======================================== + + /** + * HTTP Method 반환 + * + * 기본값: GET + * POST 요청 시 오버라이드 + * + * @return HTTP Method ("GET" 또는 "POST") + */ + protected String getHttpMethod() { + return "GET"; + } + + /** + * API 엔드포인트 경로 반환 + * + * 예제: + * - "/api/v1/products" + * - "/api/v1/orders/{orderId}" (Path Variable 포함) + * + * @return API 경로 + */ + protected String getApiPath() { + return ""; + } + + /** + * Query Parameter 맵 반환 + * + * 예제: + * Map params = new HashMap<>(); + * params.put("status", "active"); + * params.put("page", 1); + * params.put("size", 100); + * return params; + * + * @return Query Parameter 맵 (null이면 파라미터 없음) + */ + protected Map getQueryParams() { + return null; + } + + /** + * Path Variable 맵 반환 + * + * 예제: + * Map pathVars = new HashMap<>(); + * pathVars.put("orderId", "ORD-001"); + * return pathVars; + * + * @return Path Variable 맵 (null이면 Path Variable 없음) + */ + protected Map getPathVariables() { + return null; + } + + /** + * Request Body 반환 (POST 요청용) + * + * 예제: + * return RequestDto.builder() + * .startDate("2025-01-01") + * .endDate("2025-12-31") + * .build(); + * + * @return Request Body 객체 (null이면 Body 없음) + */ + protected Object getRequestBody() { + return null; + } + + /** + * HTTP Header 맵 반환 + * + * 예제: + * Map headers = new HashMap<>(); + * headers.put("Authorization", "Bearer token123"); + * headers.put("X-Custom-Header", "value"); + * return headers; + * + * 기본 헤더 (자동 추가): + * - Content-Type: application/json + * - Accept: application/json + * + * @return HTTP Header 맵 (null이면 기본 헤더만 사용) + */ + protected Map getHeaders() { + return null; + } + + /** + * API 응답 타입 반환 + * + * 예제: + * return ProductApiResponse.class; + * + * @return 응답 클래스 타입 + */ + protected Class getResponseType() { + return Object.class; + } + + /** + * API 응답에서 데이터 리스트 추출 + * + * 복잡한 JSON 응답 구조 처리: + * - 단순: response.getData() + * - 중첩: response.getResult().getItems() + * + * @param response API 응답 객체 + * @return 추출된 데이터 리스트 + */ + protected List extractDataFromResponse(Object response) { + return Collections.emptyList(); + } + + // ======================================== + // 라이프사이클 훅 메서드 (선택적 오버라이드) + // ======================================== + + /** + * API 호출 전 전처리 + * + * 사용 예: + * - 파라미터 검증 + * - 로깅 + * - 캐시 확인 + */ + protected void beforeFetch() { + log.debug("[{}] API 호출 준비 중...", getReaderName()); + } + + /** + * API 호출 후 후처리 + * + * 사용 예: + * - 데이터 검증 + * - 로깅 + * - 캐시 저장 + * + * @param data 조회된 데이터 리스트 + */ + protected void afterFetch(List data) { + log.debug("[{}] API 호출 완료", getReaderName()); + } + + /** + * API 호출 실패 시 에러 처리 + * + * 기본 동작: 빈 리스트 반환 (Job 실패 방지) + * 오버라이드 시: 예외 던지기 또는 재시도 로직 구현 + * + * @param e 발생한 예외 + * @return 대체 데이터 리스트 (빈 리스트 또는 캐시 데이터) + */ + protected List handleApiError(Exception e) { + log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e); + return new ArrayList<>(); + } + + // ======================================== + // 헬퍼 메서드 (하위 클래스에서 사용 가능) + // ======================================== + + /** + * WebClient를 사용한 API 호출 (GET/POST 자동 처리) + * + * 사용 방법 (fetchDataFromApi()에서): + * + * @Override + * protected List fetchDataFromApi() { + * ProductApiResponse response = callApi(); + * return extractDataFromResponse(response); + * } + * + * @param 응답 타입 + * @return API 응답 객체 + */ + @SuppressWarnings("unchecked") + protected R callApi() { + if (webClient == null) { + throw new IllegalStateException("WebClient가 초기화되지 않았습니다. 생성자에서 WebClient를 주입하세요."); + } + + try { + String method = getHttpMethod().toUpperCase(); + String path = getApiPath(); + + log.info("[{}] {} 요청 시작: {}", getReaderName(), method, path); + + if ("GET".equals(method)) { + return callGetApi(); + } else if ("POST".equals(method)) { + return callPostApi(); + } else { + throw new UnsupportedOperationException("지원하지 않는 HTTP Method: " + method); + } + + } catch (Exception e) { + log.error("[{}] API 호출 중 오류 발생", getReaderName(), e); + throw new RuntimeException("API 호출 실패", e); + } + } + + /** + * GET 요청 내부 처리 + */ + @SuppressWarnings("unchecked") + private R callGetApi() { + return (R) webClient + .get() + .uri(buildUri()) + .headers(this::applyHeaders) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } + + /** + * POST 요청 내부 처리 + */ + @SuppressWarnings("unchecked") + private R callPostApi() { + Object requestBody = getRequestBody(); + + if (requestBody == null) { + // Body 없는 POST 요청 + return (R) webClient + .post() + .uri(buildUri()) + .headers(this::applyHeaders) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } else { + // Body 있는 POST 요청 + return (R) webClient + .post() + .uri(buildUri()) + .headers(this::applyHeaders) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(getResponseType()) + .block(); + } + } + + /** + * URI 빌드 (Path + Query Parameters + Path Variables) + */ + private Function buildUri() { + return uriBuilder -> { + // 1. Path 설정 + String path = getApiPath(); + uriBuilder.path(path); + + // 2. Query Parameters 추가 + Map queryParams = getQueryParams(); + if (queryParams != null && !queryParams.isEmpty()) { + queryParams.forEach((key, value) -> { + if (value != null) { + uriBuilder.queryParam(key, value); + } + }); + log.debug("[{}] Query Parameters: {}", getReaderName(), queryParams); + } + + // 3. Path Variables 적용 + Map pathVars = getPathVariables(); + if (pathVars != null && !pathVars.isEmpty()) { + log.debug("[{}] Path Variables: {}", getReaderName(), pathVars); + return uriBuilder.build(pathVars); + } else { + return uriBuilder.build(); + } + }; + } + + /** + * HTTP Header 적용 + */ + private void applyHeaders(HttpHeaders httpHeaders) { + // 1. 기본 헤더 설정 + httpHeaders.setContentType(MediaType.APPLICATION_JSON); + httpHeaders.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + + // 2. 커스텀 헤더 추가 + Map customHeaders = getHeaders(); + if (customHeaders != null && !customHeaders.isEmpty()) { + customHeaders.forEach(httpHeaders::set); + log.debug("[{}] Custom Headers: {}", getReaderName(), customHeaders); + } + } + + // ======================================== + // 유틸리티 메서드 + // ======================================== + + /** + * 데이터 리스트가 비어있는지 확인 + */ + protected boolean isEmpty(List data) { + return data == null || data.isEmpty(); + } + + /** + * 데이터 리스트 크기 반환 (null-safe) + */ + protected int getDataSize(List data) { + return data != null ? data.size() : 0; + } +} diff --git a/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java b/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java new file mode 100644 index 0000000..b12b52c --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java @@ -0,0 +1,336 @@ +package com.snp.batch.common.batch.repository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.transaction.annotation.Transactional; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +/** + * JdbcTemplate 기반 Repository 추상 클래스 + * 모든 Repository가 상속받아 일관된 CRUD 패턴 제공 + * + * @param Entity 타입 + * @param ID 타입 + */ +@Slf4j +@RequiredArgsConstructor +@Transactional(readOnly = true) +public abstract class BaseJdbcRepository { + + protected final JdbcTemplate jdbcTemplate; + + /** + * 테이블명 반환 (하위 클래스에서 구현) + */ + protected abstract String getTableName(); + + /** + * ID 컬럼명 반환 (기본값: "id") + */ + protected String getIdColumnName() { + return "id"; + } + + /** + * RowMapper 반환 (하위 클래스에서 구현) + */ + protected abstract RowMapper getRowMapper(); + + /** + * Entity에서 ID 추출 (하위 클래스에서 구현) + */ + protected abstract ID extractId(T entity); + + /** + * INSERT SQL 생성 (하위 클래스에서 구현) + */ + protected abstract String getInsertSql(); + + /** + * UPDATE SQL 생성 (하위 클래스에서 구현) + */ + protected abstract String getUpdateSql(); + + /** + * INSERT용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현) + */ + protected abstract void setInsertParameters(PreparedStatement ps, T entity) throws Exception; + + /** + * UPDATE용 PreparedStatement 파라미터 설정 (하위 클래스에서 구현) + */ + protected abstract void setUpdateParameters(PreparedStatement ps, T entity) throws Exception; + + /** + * 엔티티명 반환 (로깅용) + */ + protected abstract String getEntityName(); + + // ==================== CRUD 메서드 ==================== + + /** + * ID로 조회 + */ + public Optional findById(ID id) { + String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + log.debug("{} 조회: ID={}", getEntityName(), id); + + List results = jdbcTemplate.query(sql, getRowMapper(), id); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + /** + * 전체 조회 + */ + public List findAll() { + String sql = String.format("SELECT * FROM %s ORDER BY %s DESC", getTableName(), getIdColumnName()); + log.debug("{} 전체 조회", getEntityName()); + return jdbcTemplate.query(sql, getRowMapper()); + } + + /** + * 개수 조회 + */ + public long count() { + String sql = String.format("SELECT COUNT(*) FROM %s", getTableName()); + Long count = jdbcTemplate.queryForObject(sql, Long.class); + return count != null ? count : 0L; + } + + /** + * 존재 여부 확인 + */ + public boolean existsById(ID id) { + String sql = String.format("SELECT COUNT(*) FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + Long count = jdbcTemplate.queryForObject(sql, Long.class, id); + return count != null && count > 0; + } + + /** + * 단건 저장 (INSERT 또는 UPDATE) + */ + @Transactional + public T save(T entity) { + ID id = extractId(entity); + + if (id == null || !existsById(id)) { + return insert(entity); + } else { + return update(entity); + } + } + + /** + * 단건 INSERT + */ + @Transactional + protected T insert(T entity) { + log.info("{} 삽입 시작", getEntityName()); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(getInsertSql(), Statement.RETURN_GENERATED_KEYS); + try { + setInsertParameters(ps, entity); + } catch (Exception e) { + log.error("{} 삽입 파라미터 설정 실패", getEntityName(), e); + throw new RuntimeException("Failed to set insert parameters", e); + } + return ps; + }, keyHolder); + + // 생성된 ID 조회 + if (keyHolder.getKeys() != null && !keyHolder.getKeys().isEmpty()) { + Object idValue = keyHolder.getKeys().get(getIdColumnName()); + if (idValue != null) { + @SuppressWarnings("unchecked") + ID generatedId = (ID) (idValue instanceof Number ? ((Number) idValue).longValue() : idValue); + log.info("{} 삽입 완료: ID={}", getEntityName(), generatedId); + return findById(generatedId).orElse(entity); + } + } + + log.info("{} 삽입 완료 (ID 미반환)", getEntityName()); + return entity; + } + + /** + * 단건 UPDATE + */ + @Transactional + protected T update(T entity) { + ID id = extractId(entity); + log.info("{} 수정 시작: ID={}", getEntityName(), id); + + int updated = jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(getUpdateSql()); + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("{} 수정 파라미터 설정 실패", getEntityName(), e); + throw new RuntimeException("Failed to set update parameters", e); + } + return ps; + }); + + if (updated == 0) { + throw new IllegalStateException(getEntityName() + " 수정 실패: ID=" + id); + } + + log.info("{} 수정 완료: ID={}", getEntityName(), id); + return findById(id).orElse(entity); + } + + /** + * 배치 INSERT (대량 삽입) + */ + @Transactional + public void batchInsert(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 배치 삽입 시작: {} 건", getEntityName(), entities.size()); + + jdbcTemplate.batchUpdate(getInsertSql(), entities, entities.size(), + (ps, entity) -> { + try { + setInsertParameters(ps, entity); + } catch (Exception e) { + log.error("배치 삽입 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 삽입 완료: {} 건", getEntityName(), entities.size()); + } + + /** + * 배치 UPDATE (대량 수정) + */ + @Transactional + public void batchUpdate(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 배치 수정 시작: {} 건", getEntityName(), entities.size()); + + jdbcTemplate.batchUpdate(getUpdateSql(), entities, entities.size(), + (ps, entity) -> { + try { + setUpdateParameters(ps, entity); + } catch (Exception e) { + log.error("배치 수정 파라미터 설정 실패", e); + throw new RuntimeException(e); + } + }); + + log.info("{} 배치 수정 완료: {} 건", getEntityName(), entities.size()); + } + + /** + * 전체 저장 (INSERT 또는 UPDATE) + */ + @Transactional + public void saveAll(List entities) { + if (entities == null || entities.isEmpty()) { + return; + } + + log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size()); + + // INSERT와 UPDATE 분리 + List toInsert = entities.stream() + .filter(e -> extractId(e) == null || !existsById(extractId(e))) + .toList(); + + List toUpdate = entities.stream() + .filter(e -> extractId(e) != null && existsById(extractId(e))) + .toList(); + + if (!toInsert.isEmpty()) { + batchInsert(toInsert); + } + + if (!toUpdate.isEmpty()) { + batchUpdate(toUpdate); + } + + log.info("{} 전체 저장 완료: 삽입={} 건, 수정={} 건", getEntityName(), toInsert.size(), toUpdate.size()); + } + + /** + * ID로 삭제 + */ + @Transactional + public void deleteById(ID id) { + String sql = String.format("DELETE FROM %s WHERE %s = ?", getTableName(), getIdColumnName()); + log.info("{} 삭제: ID={}", getEntityName(), id); + + int deleted = jdbcTemplate.update(sql, id); + + if (deleted == 0) { + log.warn("{} 삭제 실패 (존재하지 않음): ID={}", getEntityName(), id); + } else { + log.info("{} 삭제 완료: ID={}", getEntityName(), id); + } + } + + /** + * 전체 삭제 + */ + @Transactional + public void deleteAll() { + String sql = String.format("DELETE FROM %s", getTableName()); + log.warn("{} 전체 삭제", getEntityName()); + + int deleted = jdbcTemplate.update(sql); + log.info("{} 전체 삭제 완료: {} 건", getEntityName(), deleted); + } + + // ==================== 헬퍼 메서드 ==================== + + /** + * 현재 시각 반환 (감사 필드용) + */ + protected LocalDateTime now() { + return LocalDateTime.now(); + } + + /** + * 커스텀 쿼리 실행 (단건 조회) + */ + protected Optional executeQueryForObject(String sql, Object... params) { + log.debug("커스텀 쿼리 실행: {}", sql); + List results = jdbcTemplate.query(sql, getRowMapper(), params); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + /** + * 커스텀 쿼리 실행 (다건 조회) + */ + protected List executeQueryForList(String sql, Object... params) { + log.debug("커스텀 쿼리 실행: {}", sql); + return jdbcTemplate.query(sql, getRowMapper(), params); + } + + /** + * 커스텀 업데이트 실행 + */ + @Transactional + protected int executeUpdate(String sql, Object... params) { + log.debug("커스텀 업데이트 실행: {}", sql); + return jdbcTemplate.update(sql, params); + } +} diff --git a/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java b/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java new file mode 100644 index 0000000..6169d5b --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/writer/BaseWriter.java @@ -0,0 +1,61 @@ +package com.snp.batch.common.batch.writer; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.item.Chunk; +import org.springframework.batch.item.ItemWriter; + +import java.util.ArrayList; +import java.util.List; + +/** + * ItemWriter 추상 클래스 + * 데이터 저장 로직을 위한 템플릿 제공 + * + * Template Method Pattern: + * - write(): 공통 로직 (로깅, null 체크) + * - writeItems(): 하위 클래스에서 저장 로직 구현 + * + * @param Entity 타입 + */ +@Slf4j +@RequiredArgsConstructor +public abstract class BaseWriter implements ItemWriter { + + private final String entityName; + + /** + * 실제 데이터 저장 로직 (하위 클래스에서 구현) + * Repository의 saveAll() 또는 batchInsert() 호출 등 + * + * @param items 저장할 Entity 리스트 + * @throws Exception 저장 중 오류 발생 시 + */ + protected abstract void writeItems(List items) throws Exception; + + /** + * Spring Batch ItemWriter 인터페이스 구현 + * Chunk 단위로 데이터를 저장 + * + * @param chunk 저장할 데이터 청크 + * @throws Exception 저장 중 오류 발생 시 + */ + @Override + public void write(Chunk chunk) throws Exception { + List items = new ArrayList<>(chunk.getItems()); + + if (items.isEmpty()) { + log.debug("{} 저장할 데이터가 없습니다", entityName); + return; + } + + try { + log.info("{} 데이터 {}건 저장 시작", entityName, items.size()); + writeItems(items); + log.info("{} 데이터 {}건 저장 완료", entityName, items.size()); + } catch (Exception e) { + log.error("{} 데이터 저장 실패", entityName, e); + throw e; + } + } +} diff --git a/src/main/java/com/snp/batch/common/web/ApiResponse.java b/src/main/java/com/snp/batch/common/web/ApiResponse.java new file mode 100644 index 0000000..68d6edb --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/ApiResponse.java @@ -0,0 +1,81 @@ +package com.snp.batch.common.web; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 통일된 API 응답 형식 + * + * @param 응답 데이터 타입 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ApiResponse { + + /** + * 성공 여부 + */ + private boolean success; + + /** + * 메시지 + */ + private String message; + + /** + * 응답 데이터 + */ + private T data; + + /** + * 에러 코드 (실패 시) + */ + private String errorCode; + + /** + * 성공 응답 생성 + */ + public static ApiResponse success(T data) { + return ApiResponse.builder() + .success(true) + .message("Success") + .data(data) + .build(); + } + + /** + * 성공 응답 생성 (메시지 포함) + */ + public static ApiResponse success(String message, T data) { + return ApiResponse.builder() + .success(true) + .message(message) + .data(data) + .build(); + } + + /** + * 실패 응답 생성 + */ + public static ApiResponse error(String message) { + return ApiResponse.builder() + .success(false) + .message(message) + .build(); + } + + /** + * 실패 응답 생성 (에러 코드 포함) + */ + public static ApiResponse error(String message, String errorCode) { + return ApiResponse.builder() + .success(false) + .message(message) + .errorCode(errorCode) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/common/web/controller/BaseController.java b/src/main/java/com/snp/batch/common/web/controller/BaseController.java new file mode 100644 index 0000000..445a0c6 --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/controller/BaseController.java @@ -0,0 +1,300 @@ +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 DTO 타입 + * @param ID 타입 + */ +@Slf4j +public abstract class BaseController { + + /** + * Service 반환 (하위 클래스에서 구현) + */ + protected abstract BaseService 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> 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> 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>> getAll() { + log.info("{} 전체 조회 요청", getResourceName()); + try { + List 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>> 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 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> 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> 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> 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()) + ); + } + } +} diff --git a/src/main/java/com/snp/batch/common/web/dto/BaseDto.java b/src/main/java/com/snp/batch/common/web/dto/BaseDto.java new file mode 100644 index 0000000..46230bf --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/dto/BaseDto.java @@ -0,0 +1,33 @@ +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; +} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java b/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java new file mode 100644 index 0000000..7499dcb --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/service/BaseHybridService.java @@ -0,0 +1,202 @@ +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 Entity 타입 + * @param DTO 타입 + * @param ID 타입 + */ +@Slf4j +public abstract class BaseHybridService extends BaseServiceImpl { + + /** + * 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 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 callExternalGet(String endpoint, Map params, Class 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 RES callExternalPost(String endpoint, REQ requestBody, Class responseType) { + log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint); + + return getWebClient() + .post() + .uri(endpoint) + .bodyValue(requestBody) + .retrieve() + .bodyToMono(responseType) + .timeout(getTimeout()) + .block(); + } +} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java b/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java new file mode 100644 index 0000000..05c0032 --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/service/BaseProxyService.java @@ -0,0 +1,176 @@ +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 요청 DTO 타입 + * @param 응답 DTO 타입 + */ +@Slf4j +public abstract class BaseProxyService { + + /** + * 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 params, Class 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 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 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 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("커스텀 요청이 구현되지 않았습니다"); + } +} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseService.java b/src/main/java/com/snp/batch/common/web/service/BaseService.java new file mode 100644 index 0000000..663870b --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/service/BaseService.java @@ -0,0 +1,94 @@ +package com.snp.batch.common.web.service; + +import java.util.List; +import java.util.Optional; + +/** + * 모든 서비스의 공통 인터페이스 (JDBC 기반) + * CRUD 기본 메서드 정의 + * + * @param Entity 타입 + * @param DTO 타입 + * @param ID 타입 + */ +public interface BaseService { + + /** + * 단건 생성 + * + * @param dto 생성할 데이터 DTO + * @return 생성된 데이터 DTO + */ + D create(D dto); + + /** + * 단건 조회 + * + * @param id 조회할 ID + * @return 조회된 데이터 DTO (Optional) + */ + Optional findById(ID id); + + /** + * 전체 조회 + * + * @return 전체 데이터 DTO 리스트 + */ + List findAll(); + + /** + * 페이징 조회 + * + * @param offset 시작 위치 (0부터 시작) + * @param limit 조회 개수 + * @return 페이징된 데이터 리스트 + */ + List 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); +} diff --git a/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java b/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java new file mode 100644 index 0000000..3308a8f --- /dev/null +++ b/src/main/java/com/snp/batch/common/web/service/BaseServiceImpl.java @@ -0,0 +1,131 @@ +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 Entity 타입 + * @param DTO 타입 + * @param ID 타입 + */ +@Slf4j +@Transactional(readOnly = true) +public abstract class BaseServiceImpl implements BaseService { + + /** + * Repository 반환 (하위 클래스에서 구현) + */ + protected abstract BaseJdbcRepository 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 findById(ID id) { + log.debug("{} 조회: ID={}", getEntityName(), id); + return getRepository().findById(id).map(this::toDto); + } + + @Override + public List findAll() { + log.debug("{} 전체 조회", getEntityName()); + return getRepository().findAll().stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + @Override + public List findAll(int offset, int limit) { + log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit); + + // 하위 클래스에서 제공하는 페이징 쿼리 실행 + List entities = executePagingQuery(offset, limit); + + return entities.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + /** + * 페이징 쿼리 실행 (하위 클래스에서 구현) + * + * @param offset 시작 위치 + * @param limit 조회 개수 + * @return Entity 리스트 + */ + protected abstract List 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); +} diff --git a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java new file mode 100644 index 0000000..2a3a6c3 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -0,0 +1,103 @@ +package com.snp.batch.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Maritime API WebClient 설정 + * + * 목적: + * - Maritime API 서버에 대한 WebClient Bean 등록 + * - 동일한 API 서버를 사용하는 여러 Job에서 재사용 + * - 설정 변경 시 한 곳에서만 수정 + * + * 사용 Job: + * - shipDataImportJob: IMO 번호 조회 + * - shipDetailImportJob: 선박 상세 정보 조회 + * + * 다른 API 서버 추가 시: + * - 새로운 Config 클래스 생성 (예: OtherApiWebClientConfig) + * - Bean 이름을 다르게 지정 (예: @Bean(name = "otherApiWebClient")) + */ +@Slf4j +@Configuration +public class MaritimeApiWebClientConfig { + + @Value("${app.batch.ship-api.url}") + private String maritimeApiUrl; + + @Value("${app.batch.ship-api.username}") + private String maritimeApiUsername; + + @Value("${app.batch.ship-api.password}") + private String maritimeApiPassword; + + /** + * Maritime API용 WebClient Bean + * + * 설정: + * - Base URL: Maritime API 서버 주소 + * - 인증: Basic Authentication + * - 버퍼: 20MB (대용량 응답 처리) + * + * @return Maritime API WebClient + */ + @Bean(name = "maritimeApiWebClient") + public WebClient maritimeApiWebClient() { + log.info("========================================"); + log.info("Maritime API WebClient 생성"); + log.info("Base URL: {}", maritimeApiUrl); + log.info("========================================"); + + return WebClient.builder() + .baseUrl(maritimeApiUrl) + .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) + .codecs(configurer -> configurer + .defaultCodecs() + .maxInMemorySize(20 * 1024 * 1024)) // 20MB 버퍼 + .build(); + } +} + + +/** + * ======================================== + * 다른 API 서버 추가 예시 + * ======================================== + * + * 1. 새로운 Config 클래스 생성: + * + * @Configuration + * public class ExternalApiWebClientConfig { + * + * @Bean(name = "externalApiWebClient") + * public WebClient externalApiWebClient( + * @Value("${app.batch.external-api.url}") String url, + * @Value("${app.batch.external-api.token}") String token) { + * + * return WebClient.builder() + * .baseUrl(url) + * .defaultHeader("Authorization", "Bearer " + token) + * .build(); + * } + * } + * + * 2. JobConfig에서 사용: + * + * public ExternalJobConfig( + * ..., + * @Qualifier("externalApiWebClient") WebClient externalApiWebClient) { + * this.webClient = externalApiWebClient; + * } + * + * 3. application.yml에 설정 추가: + * + * app: + * batch: + * external-api: + * url: https://external-api.example.com + * token: ${EXTERNAL_API_TOKEN} + */ diff --git a/src/main/java/com/snp/batch/global/config/QuartzConfig.java b/src/main/java/com/snp/batch/global/config/QuartzConfig.java new file mode 100644 index 0000000..aced683 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/QuartzConfig.java @@ -0,0 +1,67 @@ +package com.snp.batch.global.config; + +import org.quartz.spi.TriggerFiredBundle; +import org.springframework.beans.factory.config.AutowireCapableBeanFactory; +import org.springframework.boot.autoconfigure.quartz.QuartzProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.quartz.SchedulerFactoryBean; +import org.springframework.scheduling.quartz.SpringBeanJobFactory; + +import javax.sql.DataSource; + +/** + * Quartz 설정 + * Spring Boot Auto-configuration을 사용하면서 JobFactory만 커스터마이징 + */ +@Configuration +public class QuartzConfig { + + /** + * Quartz Scheduler Factory Bean 설정 + * Spring Boot Auto-configuration이 DataSource를 자동 주입하므로 + * JobFactory만 커스터마이징 + */ + @Bean + public SchedulerFactoryBean schedulerFactoryBean(ApplicationContext applicationContext) { + SchedulerFactoryBean factory = new SchedulerFactoryBean(); + factory.setJobFactory(springBeanJobFactory(applicationContext)); + factory.setOverwriteExistingJobs(true); + factory.setAutoStartup(true); + // DataSource는 Spring Boot가 자동 주입 (application.yml의 spring.datasource 사용) + + return factory; + } + + /** + * Spring Bean 자동 주입을 지원하는 JobFactory + */ + @Bean + public SpringBeanJobFactory springBeanJobFactory(ApplicationContext applicationContext) { + AutowiringSpringBeanJobFactory jobFactory = new AutowiringSpringBeanJobFactory(); + jobFactory.setApplicationContext(applicationContext); + return jobFactory; + } + + /** + * Quartz Job에서 Spring Bean 자동 주입을 가능하게 하는 Factory + */ + public static class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { + + private AutowireCapableBeanFactory beanFactory; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) { + beanFactory = applicationContext.getAutowireCapableBeanFactory(); + } + + @Override + protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { + Object jobInstance = super.createJobInstance(bundle); + beanFactory.autowireBean(jobInstance); + return jobInstance; + } + } +} diff --git a/src/main/java/com/snp/batch/global/config/SwaggerConfig.java b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java new file mode 100644 index 0000000..b0e4186 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/SwaggerConfig.java @@ -0,0 +1,79 @@ +package com.snp.batch.global.config; + +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.servers.Server; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +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 + * + * 주요 기능: + * - REST API 자동 문서화 + * - API 테스트 UI 제공 + * - OpenAPI 3.0 스펙 준수 + */ +@Configuration +public class SwaggerConfig { + + @Value("${server.port:8081}") + private int serverPort; + + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .info(apiInfo()) + .servers(List.of( + new Server() + .url("http://localhost:" + serverPort) + .description("로컬 개발 서버"), + new Server() + .url("https://api.snp-batch.com") + .description("운영 서버 (예시)") + )); + } + + private Info apiInfo() { + return new Info() + .title("SNP Batch REST API") + .description(""" + ## SNP Batch 시스템 REST API 문서 + + Spring Batch 기반 데이터 통합 시스템의 REST API 문서입니다. + + ### 제공 API + - **Batch API**: 배치 Job 실행 및 관리 + - **Product API**: 샘플 제품 데이터 CRUD (샘플용) + + ### 주요 기능 + - 배치 Job 실행 및 중지 + - Job 실행 이력 조회 + - 스케줄 관리 (Quartz) + - 제품 데이터 CRUD (샘플) + + ### 버전 정보 + - API Version: v1.0.0 + - Spring Boot: 3.2.1 + - Spring Batch: 5.1.0 + """) + .version("v1.0.0") + .contact(new Contact() + .name("SNP Batch Team") + .email("support@snp-batch.com") + .url("https://github.com/snp-batch")) + .license(new License() + .name("Apache 2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0")); + } +} diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java new file mode 100644 index 0000000..db66315 --- /dev/null +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -0,0 +1,291 @@ +package com.snp.batch.global.controller; + +import com.snp.batch.global.dto.JobExecutionDto; +import com.snp.batch.global.dto.ScheduleRequest; +import com.snp.batch.global.dto.ScheduleResponse; +import com.snp.batch.service.BatchService; +import com.snp.batch.service.ScheduleService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +@Slf4j +@RestController +@RequestMapping("/api/batch") +@RequiredArgsConstructor +@Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API") +public class BatchController { + + private final BatchService batchService; + private final ScheduleService scheduleService; + + @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "작업 실행 성공"), + @ApiResponse(responseCode = "500", description = "작업 실행 실패") + }) + @PostMapping("/jobs/{jobName}/execute") + public ResponseEntity> executeJob( + @Parameter(description = "실행할 배치 작업 이름", required = true, example = "sampleProductImportJob") + @PathVariable String jobName, + @Parameter(description = "Job Parameters (동적 파라미터)", required = false, example = "?param1=value1¶m2=value2") + @RequestParam(required = false) Map params) { + log.info("Received request to execute job: {} with params: {}", jobName, params); + try { + Long executionId = batchService.executeJob(jobName, params); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Job started successfully", + "executionId", executionId + )); + } catch (Exception e) { + log.error("Error executing job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to start job: " + e.getMessage() + )); + } + } + + @Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/jobs") + public ResponseEntity> listJobs() { + log.info("Received request to list all jobs"); + List jobs = batchService.listAllJobs(); + return ResponseEntity.ok(jobs); + } + + @Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/jobs/{jobName}/executions") + public ResponseEntity> getJobExecutions( + @Parameter(description = "배치 작업 이름", required = true, example = "sampleProductImportJob") + @PathVariable String jobName) { + log.info("Received request to get executions for job: {}", jobName); + List executions = batchService.getJobExecutions(jobName); + return ResponseEntity.ok(executions); + } + + @GetMapping("/executions/{executionId}") + public ResponseEntity getExecutionDetails(@PathVariable Long executionId) { + log.info("Received request to get execution details for: {}", executionId); + try { + JobExecutionDto execution = batchService.getExecutionDetails(executionId); + return ResponseEntity.ok(execution); + } catch (Exception e) { + log.error("Error getting execution details: {}", executionId, e); + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/executions/{executionId}/detail") + public ResponseEntity getExecutionDetailWithSteps(@PathVariable Long executionId) { + log.info("Received request to get detailed execution for: {}", executionId); + try { + com.snp.batch.global.dto.JobExecutionDetailDto detail = batchService.getExecutionDetailWithSteps(executionId); + return ResponseEntity.ok(detail); + } catch (Exception e) { + log.error("Error getting detailed execution: {}", executionId, e); + return ResponseEntity.notFound().build(); + } + } + + @PostMapping("/executions/{executionId}/stop") + public ResponseEntity> stopExecution(@PathVariable Long executionId) { + log.info("Received request to stop execution: {}", executionId); + try { + batchService.stopExecution(executionId); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Execution stop requested" + )); + } catch (Exception e) { + log.error("Error stopping execution: {}", executionId, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to stop execution: " + e.getMessage() + )); + } + } + + @Operation(summary = "스케줄 목록 조회", description = "등록된 모든 스케줄을 조회합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + @GetMapping("/schedules") + public ResponseEntity> getSchedules() { + log.info("Received request to get all schedules"); + List schedules = scheduleService.getAllSchedules(); + return ResponseEntity.ok(Map.of( + "schedules", schedules, + "count", schedules.size() + )); + } + + @GetMapping("/schedules/{jobName}") + public ResponseEntity getSchedule(@PathVariable String jobName) { + log.debug("Received request to get schedule for job: {}", jobName); + try { + ScheduleResponse schedule = scheduleService.getScheduleByJobName(jobName); + return ResponseEntity.ok(schedule); + } catch (IllegalArgumentException e) { + // 스케줄이 없는 경우 - 정상적인 시나리오 (UI에서 존재 여부 확인용) + log.debug("Schedule not found for job: {} (정상 - 존재 확인)", jobName); + return ResponseEntity.notFound().build(); + } catch (Exception e) { + log.error("Error getting schedule for job: {}", jobName, e); + return ResponseEntity.notFound().build(); + } + } + + @Operation(summary = "스케줄 생성", description = "새로운 배치 작업 스케줄을 등록합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "생성 성공"), + @ApiResponse(responseCode = "500", description = "생성 실패") + }) + @PostMapping("/schedules") + public ResponseEntity> createSchedule( + @Parameter(description = "스케줄 생성 요청 데이터", required = true) + @RequestBody ScheduleRequest request) { + log.info("Received request to create schedule for job: {}", request.getJobName()); + try { + ScheduleResponse schedule = scheduleService.createSchedule(request); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule created successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error creating schedule for job: {}", request.getJobName(), e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to create schedule: " + e.getMessage() + )); + } + } + + @PutMapping("/schedules/{jobName}") + public ResponseEntity> updateSchedule( + @PathVariable String jobName, + @RequestBody Map request) { + log.info("Received request to update schedule for job: {}", jobName); + try { + String cronExpression = request.get("cronExpression"); + String description = request.get("description"); + ScheduleResponse schedule = scheduleService.updateSchedule(jobName, cronExpression, description); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule updated successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error updating schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to update schedule: " + e.getMessage() + )); + } + } + + @Operation(summary = "스케줄 삭제", description = "배치 작업 스케줄을 삭제합니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "삭제 성공"), + @ApiResponse(responseCode = "500", description = "삭제 실패") + }) + @DeleteMapping("/schedules/{jobName}") + public ResponseEntity> deleteSchedule( + @Parameter(description = "배치 작업 이름", required = true) + @PathVariable String jobName) { + log.info("Received request to delete schedule for job: {}", jobName); + try { + scheduleService.deleteSchedule(jobName); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule deleted successfully" + )); + } catch (Exception e) { + log.error("Error deleting schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to delete schedule: " + e.getMessage() + )); + } + } + + @PatchMapping("/schedules/{jobName}/toggle") + public ResponseEntity> toggleSchedule( + @PathVariable String jobName, + @RequestBody Map request) { + log.info("Received request to toggle schedule for job: {}", jobName); + try { + Boolean active = request.get("active"); + ScheduleResponse schedule = scheduleService.toggleScheduleActive(jobName, active); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "Schedule toggled successfully", + "data", schedule + )); + } catch (Exception e) { + log.error("Error toggling schedule for job: {}", jobName, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "Failed to toggle schedule: " + e.getMessage() + )); + } + } + + @GetMapping("/timeline") + public ResponseEntity getTimeline( + @RequestParam String view, + @RequestParam String date) { + log.info("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); + } catch (Exception e) { + log.error("Error getting timeline", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/dashboard") + public ResponseEntity getDashboard() { + log.info("Received request to get dashboard data"); + try { + com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData(); + return ResponseEntity.ok(dashboard); + } catch (Exception e) { + log.error("Error getting dashboard data", e); + return ResponseEntity.internalServerError().build(); + } + } + + @GetMapping("/timeline/period-executions") + public ResponseEntity> getPeriodExecutions( + @RequestParam String jobName, + @RequestParam String view, + @RequestParam String periodKey) { + log.info("Received request to get period executions: jobName={}, view={}, periodKey={}", jobName, view, periodKey); + try { + List executions = batchService.getPeriodExecutions(jobName, view, periodKey); + return ResponseEntity.ok(executions); + } catch (Exception e) { + log.error("Error getting period executions", e); + return ResponseEntity.internalServerError().build(); + } + } +} diff --git a/src/main/java/com/snp/batch/global/controller/WebViewController.java b/src/main/java/com/snp/batch/global/controller/WebViewController.java new file mode 100644 index 0000000..eff2ba1 --- /dev/null +++ b/src/main/java/com/snp/batch/global/controller/WebViewController.java @@ -0,0 +1,43 @@ +package com.snp.batch.global.controller; + +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@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"; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/DashboardResponse.java b/src/main/java/com/snp/batch/global/dto/DashboardResponse.java new file mode 100644 index 0000000..feab188 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/DashboardResponse.java @@ -0,0 +1,53 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DashboardResponse { + private Stats stats; + private List runningJobs; + private List recentExecutions; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Stats { + private int totalSchedules; + private int activeSchedules; + private int inactiveSchedules; + private int totalJobs; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RunningJob { + private String jobName; + private Long executionId; + private String status; + private LocalDateTime startTime; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class RecentExecution { + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + } +} diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java new file mode 100644 index 0000000..79e4365 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java @@ -0,0 +1,89 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * Job 실행 상세 정보 DTO + * JobExecution + StepExecution 정보 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobExecutionDetailDto { + + // Job Execution 기본 정보 + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String exitCode; + private String exitMessage; + + // Job Parameters + private Map jobParameters; + + // Job Instance 정보 + private Long jobInstanceId; + + // 실행 통계 + private Long duration; // 실행 시간 (ms) + private Integer readCount; + private Integer writeCount; + private Integer skipCount; + private Integer filterCount; + + // Step 실행 정보 + private List stepExecutions; + + /** + * Step 실행 정보 DTO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StepExecutionDto { + private Long stepExecutionId; + private String stepName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private Integer readCount; + private Integer writeCount; + private Integer commitCount; + private Integer rollbackCount; + private Integer readSkipCount; + private Integer processSkipCount; + private Integer writeSkipCount; + private Integer filterCount; + private String exitCode; + private String exitMessage; + private Long duration; // 실행 시간 (ms) + private ApiCallInfo apiCallInfo; // API 호출 정보 (옵셔널) + } + + /** + * API 호출 정보 DTO + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiCallInfo { + private String apiUrl; // API URL + private String method; // HTTP Method (GET, POST, etc.) + private Map parameters; // API 파라미터 + private Integer totalCalls; // 전체 API 호출 횟수 + private Integer completedCalls; // 완료된 API 호출 횟수 + private String lastCallTime; // 마지막 호출 시간 + } +} diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java new file mode 100644 index 0000000..4f5d0c9 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDto.java @@ -0,0 +1,23 @@ +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 JobExecutionDto { + + private Long executionId; + private String jobName; + private String status; + private LocalDateTime startTime; + private LocalDateTime endTime; + private String exitCode; + private String exitMessage; +} diff --git a/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java b/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java new file mode 100644 index 0000000..aee9a5f --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ScheduleRequest.java @@ -0,0 +1,46 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 스케줄 등록/수정 요청 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleRequest { + + /** + * 배치 작업 이름 + * 예: "jsonToPostgresJob", "shipDataImportJob" + */ + private String jobName; + + /** + * Cron 표현식 + * 예: "0 0 2 * * ?" (매일 새벽 2시) + * "0 0 * * * ?" (매 시간) + * "0 0/30 * * * ?" (30분마다) + */ + private String cronExpression; + + /** + * 스케줄 설명 (선택) + */ + private String description; + + /** + * 활성화 여부 (선택, 기본값 true) + */ + @Builder.Default + private Boolean active = true; + + /** + * 생성자/수정자 정보 (선택) + */ + private String updatedBy; +} diff --git a/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java b/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java new file mode 100644 index 0000000..5436b05 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/ScheduleResponse.java @@ -0,0 +1,80 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.Date; + +/** + * 스케줄 조회 응답 DTO + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ScheduleResponse { + + /** + * 스케줄 ID + */ + private Long id; + + /** + * 배치 작업 이름 + */ + private String jobName; + + /** + * Cron 표현식 + */ + private String cronExpression; + + /** + * 스케줄 설명 + */ + private String description; + + /** + * 활성화 여부 + */ + private Boolean active; + + /** + * 다음 실행 예정 시간 (Quartz에서 계산) + */ + private Date nextFireTime; + + /** + * 이전 실행 시간 (Quartz에서 조회) + */ + private Date previousFireTime; + + /** + * Quartz Trigger 상태 + * NORMAL, PAUSED, COMPLETE, ERROR, BLOCKED, NONE + */ + private String triggerState; + + /** + * 생성 일시 + */ + private LocalDateTime createdAt; + + /** + * 수정 일시 + */ + private LocalDateTime updatedAt; + + /** + * 생성자 + */ + private String createdBy; + + /** + * 수정자 + */ + private String updatedBy; +} diff --git a/src/main/java/com/snp/batch/global/dto/TimelineResponse.java b/src/main/java/com/snp/batch/global/dto/TimelineResponse.java new file mode 100644 index 0000000..157e860 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/TimelineResponse.java @@ -0,0 +1,48 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class TimelineResponse { + private String periodLabel; + private List periods; + private List schedules; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class PeriodInfo { + private String key; + private String label; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ScheduleTimeline { + private String jobName; + private Map executions; + } + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ExecutionInfo { + private Long executionId; + private String status; + private String startTime; + private String endTime; + } +} diff --git a/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java b/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java new file mode 100644 index 0000000..3d02637 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/JobScheduleEntity.java @@ -0,0 +1,110 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 배치 작업 스케줄 정보를 저장하는 엔티티 + * Quartz 스케줄러와 연동하여 DB에 영속화 + * + * JPA를 사용하므로 @PrePersist, @PreUpdate로 감사 필드 자동 설정 + */ +@Entity +@Table(name = "job_schedule", indexes = { + @Index(name = "idx_job_name", columnList = "job_name", unique = true), + @Index(name = "idx_active", columnList = "active") +}) +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class JobScheduleEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** + * 배치 작업 이름 (BatchConfig에 등록된 Job Bean 이름) + * 예: "jsonToPostgresJob", "shipDataImportJob" + */ + @Column(name = "job_name", unique = true, nullable = false, length = 100) + private String jobName; + + /** + * Cron 표현식 + * 예: "0 0 2 * * ?" (매일 새벽 2시) + */ + @Column(name = "cron_expression", nullable = false, length = 100) + private String cronExpression; + + /** + * 스케줄 설명 + */ + @Column(name = "description", length = 500) + private String description; + + /** + * 활성화 여부 + * true: 스케줄 활성, false: 일시 중지 + */ + @Column(name = "active", nullable = false) + @Builder.Default + private Boolean active = true; + + /** + * 생성 일시 (감사 필드) + */ + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정 일시 (감사 필드) + */ + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; + + /** + * 생성자 (감사 필드) + */ + @Column(name = "created_by", length = 100) + private String createdBy; + + /** + * 수정자 (감사 필드) + */ + @Column(name = "updated_by", length = 100) + private String updatedBy; + + /** + * 엔티티 저장 전 자동 호출 (INSERT 시) + */ + @PrePersist + protected void onCreate() { + LocalDateTime now = LocalDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + if (this.createdBy == null) { + this.createdBy = "SYSTEM"; + } + if (this.updatedBy == null) { + this.updatedBy = "SYSTEM"; + } + } + + /** + * 엔티티 업데이트 전 자동 호출 (UPDATE 시) + */ + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + if (this.updatedBy == null) { + this.updatedBy = "SYSTEM"; + } + } +} diff --git a/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java b/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java new file mode 100644 index 0000000..c51fad2 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/JobScheduleRepository.java @@ -0,0 +1,43 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.JobScheduleEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * JobScheduleEntity Repository + * JPA Repository 방식으로 자동 구현 + */ +@Repository +public interface JobScheduleRepository extends JpaRepository { + + /** + * Job 이름으로 스케줄 조회 + */ + Optional findByJobName(String jobName); + + /** + * Job 이름 존재 여부 확인 + */ + boolean existsByJobName(String jobName); + + /** + * 활성화된 스케줄 목록 조회 + */ + List findByActive(Boolean active); + + /** + * 활성화된 모든 스케줄 조회 + */ + default List findAllActive() { + return findByActive(true); + } + + /** + * Job 이름으로 스케줄 삭제 + */ + void deleteByJobName(String jobName); +} diff --git a/src/main/java/com/snp/batch/global/repository/TimelineRepository.java b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java new file mode 100644 index 0000000..2c28809 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/TimelineRepository.java @@ -0,0 +1,109 @@ +package com.snp.batch.global.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +/** + * 타임라인 조회를 위한 경량 Repository + * Step Context 등 불필요한 데이터를 조회하지 않고 필요한 정보만 가져옴 + */ +@Repository +@RequiredArgsConstructor +public class TimelineRepository { + + private final JdbcTemplate jdbcTemplate; + + /** + * 특정 Job의 특정 범위 내 실행 이력 조회 (경량) + * Step Context를 조회하지 않아 성능이 매우 빠름 + */ + public List> findExecutionsByJobNameAndDateRange( + String jobName, + LocalDateTime startTime, + LocalDateTime endTime) { + + String sql = """ + SELECT + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime, + je.END_TIME as endTime + FROM BATCH_JOB_EXECUTION je + INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE ji.JOB_NAME = ? + AND je.START_TIME >= ? + AND je.START_TIME < ? + ORDER BY je.START_TIME DESC + """; + + return jdbcTemplate.queryForList(sql, jobName, startTime, endTime); + } + + /** + * 모든 Job의 특정 범위 내 실행 이력 조회 (한 번의 쿼리) + */ + public List> findAllExecutionsByDateRange( + LocalDateTime startTime, + LocalDateTime endTime) { + + String sql = """ + 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 + FROM BATCH_JOB_EXECUTION je + INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.START_TIME >= ? + AND je.START_TIME < ? + ORDER BY ji.JOB_NAME, je.START_TIME DESC + """; + + return jdbcTemplate.queryForList(sql, startTime, endTime); + } + + /** + * 현재 실행 중인 Job 조회 (STARTED, STARTING 상태) + */ + public List> findRunningExecutions() { + String sql = """ + SELECT + ji.JOB_NAME as jobName, + je.JOB_EXECUTION_ID as executionId, + je.STATUS as status, + je.START_TIME as startTime + FROM BATCH_JOB_EXECUTION je + INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + WHERE je.STATUS IN ('STARTED', 'STARTING') + ORDER BY je.START_TIME DESC + """; + + return jdbcTemplate.queryForList(sql); + } + + /** + * 최근 실행 이력 조회 (상위 N개) + */ + public List> findRecentExecutions(int limit) { + String sql = """ + 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 + FROM BATCH_JOB_EXECUTION je + INNER JOIN BATCH_JOB_INSTANCE ji ON je.JOB_INSTANCE_ID = ji.JOB_INSTANCE_ID + ORDER BY je.START_TIME DESC + LIMIT ? + """; + + return jdbcTemplate.queryForList(sql, limit); + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java b/src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java new file mode 100644 index 0000000..890c527 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/config/OrderDataImportJobConfig.java @@ -0,0 +1,153 @@ +package com.snp.batch.jobs.sample.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.sample.batch.dto.OrderDto; +import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; +import com.snp.batch.jobs.sample.batch.writer.OrderItemWriter; +import com.snp.batch.jobs.sample.batch.writer.OrderWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.batch.item.support.CompositeItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +import java.util.Arrays; + +/** + * 주문 데이터 Import Job Config (복잡한 JSON 처리 예제) + * + * 특징: + * - CompositeWriter 사용 + * - 하나의 데이터 (OrderDto)를 여러 테이블에 저장 + * - OrderWriter: orders 테이블에 저장 + * - OrderItemWriter: order_items 테이블에 저장 + * + * 데이터 흐름: + * OrderDataReader + * ↓ (OrderDto) + * OrderDataProcessor + * ↓ (OrderWrapper) + * CompositeWriter { + * OrderWriter + * OrderItemWriter + * } + * + * 주의: + * - 이 JobConfig는 예제용입니다 + * - 실제 사용 시 OrderDataReader 구현 필요 + * - OrderRepository, OrderItemRepository 구현 필요 + */ +@Slf4j +@Configuration +public class OrderDataImportJobConfig extends BaseJobConfig { + + private final OrderDataProcessor orderDataProcessor; + private final OrderWriter orderWriter; + private final OrderItemWriter orderItemWriter; + + public OrderDataImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + OrderDataProcessor orderDataProcessor, + OrderWriter orderWriter, + OrderItemWriter orderItemWriter) { + super(jobRepository, transactionManager); + this.orderDataProcessor = orderDataProcessor; + this.orderWriter = orderWriter; + this.orderItemWriter = orderItemWriter; + } + + @Override + protected String getJobName() { + return "orderDataImportJob"; + } + + @Override + protected ItemReader createReader() { + // 실제 구현 시 OrderDataReader 생성 + // 예제이므로 null 반환 (Job 등록 안 함) + return null; + } + + @Override + protected ItemProcessor createProcessor() { + return orderDataProcessor; + } + + /** + * CompositeWriter 생성 + * OrderWriter와 OrderItemWriter를 조합 + */ + @Override + protected ItemWriter createWriter() { + CompositeItemWriter compositeWriter = + new CompositeItemWriter<>(); + + // 여러 Writer를 순서대로 실행 + compositeWriter.setDelegates(Arrays.asList( + orderWriter, // 1. 주문 저장 + orderItemWriter // 2. 주문 상품 저장 + )); + + return compositeWriter; + } + + @Override + protected int getChunkSize() { + return 10; + } + + /** + * Job Bean 등록 (주석 처리) + * 실제 사용 시 주석 해제하고 OrderDataReader 구현 필요 + */ + // @Bean(name = "orderDataImportJob") + public Job orderDataImportJob() { + return job(); + } + + /** + * Step Bean 등록 (주석 처리) + */ + // @Bean(name = "orderDataImportStep") + public Step orderDataImportStep() { + return step(); + } +} + + +/** + * ======================================== + * CompositeWriter 사용 가이드 + * ======================================== + * + * 1. 언제 사용하는가? + * - 하나의 데이터를 여러 테이블에 저장해야 할 때 + * - 중첩된 JSON을 분해하여 관계형 DB에 저장할 때 + * - 1:N 관계 데이터 저장 시 + * + * 2. 작동 방식: + * - Processor가 여러 Entity를 Wrapper에 담아 반환 + * - CompositeWriter가 각 Writer를 순서대로 실행 + * - 모든 Writer는 동일한 Wrapper를 받음 + * - 각 Writer는 필요한 Entity만 추출하여 저장 + * + * 3. 트랜잭션: + * - 모든 Writer는 동일한 트랜잭션 내에서 실행 + * - 하나라도 실패하면 전체 롤백 + * + * 4. 주의사항: + * - Writer 실행 순서 중요 (부모 → 자식) + * - 외래 키 제약 조건 고려 + * - 성능: Chunk 크기 조정 필요 + * + * 5. 대안: + * - 간단한 경우: 단일 Writer에서 여러 Repository 호출 + * - 복잡한 경우: Tasklet 사용 + */ diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java b/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java new file mode 100644 index 0000000..d108fc0 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/config/ProductDataImportJobConfig.java @@ -0,0 +1,101 @@ +package com.snp.batch.jobs.sample.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.sample.batch.dto.ProductDto; +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; +import com.snp.batch.jobs.sample.batch.reader.ProductDataReader; +import com.snp.batch.jobs.sample.batch.processor.ProductDataProcessor; +import com.snp.batch.jobs.sample.batch.writer.ProductDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 제품 데이터 Import Job 설정 + * BaseJobConfig를 상속하여 구현 + * + * 샘플 데이터 배치 Job: + * - Mock API에서 10개의 샘플 제품 데이터 생성 + * - 다양한 데이터 타입 (String, BigDecimal, Integer, Boolean, Double, LocalDate, Float, Long, TEXT) 포함 + * - 필터링 테스트 (비활성 제품 제외) + * - PostgreSQL에 저장 + */ +@Slf4j +@Configuration +public class ProductDataImportJobConfig extends BaseJobConfig { + + private final ProductDataReader productDataReader; + private final ProductDataProcessor productDataProcessor; + private final ProductDataWriter productDataWriter; + + /** + * 생성자 주입 + */ + public ProductDataImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ProductDataReader productDataReader, + ProductDataProcessor productDataProcessor, + ProductDataWriter productDataWriter) { + super(jobRepository, transactionManager); + this.productDataReader = productDataReader; + this.productDataProcessor = productDataProcessor; + this.productDataWriter = productDataWriter; + } + + @Override + protected String getJobName() { + return "sampleProductImportJob"; + } + + @Override + protected String getStepName() { + return "sampleProductImportStep"; + } + + @Override + protected ItemReader createReader() { + return productDataReader; + } + + @Override + protected ItemProcessor createProcessor() { + return productDataProcessor; + } + + @Override + protected ItemWriter createWriter() { + return productDataWriter; + } + + @Override + protected int getChunkSize() { + // 샘플 데이터는 10개이므로 작은 Chunk 크기 사용 + return 5; + } + + /** + * Job Bean 등록 + */ + @Bean(name = "sampleProductImportJob") + public Job sampleProductImportJob() { + return job(); + } + + /** + * Step Bean 등록 + */ + @Bean(name = "sampleProductImportStep") + public Step sampleProductImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java new file mode 100644 index 0000000..b49915f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/dto/OrderDto.java @@ -0,0 +1,97 @@ +package com.snp.batch.jobs.sample.batch.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** + * 주문 DTO (복잡한 JSON 예제용) + * + * API 응답 예제: + * { + * "orderId": "ORD-001", + * "customerName": "홍길동", + * "orderDate": "2025-10-16T10:30:00", + * "totalAmount": 150000, + * "items": [ + * { + * "productId": "PROD-001", + * "productName": "노트북", + * "quantity": 1, + * "price": 100000 + * }, + * { + * "productId": "PROD-002", + * "productName": "마우스", + * "quantity": 2, + * "price": 25000 + * } + * ] + * } + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderDto { + + /** + * 주문 ID + */ + private String orderId; + + /** + * 고객 이름 + */ + private String customerName; + + /** + * 주문 일시 + */ + private LocalDateTime orderDate; + + /** + * 총 주문 금액 + */ + private BigDecimal totalAmount; + + /** + * 주문 상품 목록 (중첩 데이터) + */ + private List items; + + /** + * 주문 상품 DTO (내부 클래스) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class OrderItemDto { + + /** + * 상품 ID + */ + private String productId; + + /** + * 상품명 + */ + private String productName; + + /** + * 수량 + */ + private Integer quantity; + + /** + * 가격 + */ + private BigDecimal price; + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java new file mode 100644 index 0000000..4c1d24a --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductApiResponse.java @@ -0,0 +1,45 @@ +package com.snp.batch.jobs.sample.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 제품 API 응답 래퍼 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ProductApiResponse { + + /** + * 성공 여부 + */ + @JsonProperty("success") + private Boolean success; + + /** + * 총 개수 + */ + @JsonProperty("total_count") + private Integer totalCount; + + /** + * 제품 목록 + */ + @JsonProperty("products") + private List products; + + /** + * 메시지 + */ + @JsonProperty("message") + private String message; +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java new file mode 100644 index 0000000..d944bd1 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/dto/ProductDto.java @@ -0,0 +1,95 @@ +package com.snp.batch.jobs.sample.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 제품 DTO (샘플 데이터) + * 다양한 데이터 타입 포함 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ProductDto { + + /** + * 제품 ID (String) + */ + @JsonProperty("product_id") + private String productId; + + /** + * 제품명 (String) + */ + @JsonProperty("product_name") + private String productName; + + /** + * 카테고리 (String) + */ + @JsonProperty("category") + private String category; + + /** + * 가격 (BigDecimal) + */ + @JsonProperty("price") + private BigDecimal price; + + /** + * 재고 수량 (Integer) + */ + @JsonProperty("stock_quantity") + private Integer stockQuantity; + + /** + * 활성 여부 (Boolean) + */ + @JsonProperty("is_active") + private Boolean isActive; + + /** + * 평점 (Double) + */ + @JsonProperty("rating") + private Double rating; + + /** + * 제조일자 (LocalDate) + */ + @JsonProperty("manufacture_date") + private LocalDate manufactureDate; + + /** + * 무게 (kg) (Float) + */ + @JsonProperty("weight") + private Float weight; + + /** + * 판매 횟수 (Long) + */ + @JsonProperty("sales_count") + private Long salesCount; + + /** + * 설명 (Text) + */ + @JsonProperty("description") + private String description; + + /** + * 태그 (JSON Array → String으로 저장) + */ + @JsonProperty("tags") + private String tags; +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java new file mode 100644 index 0000000..a566db9 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderEntity.java @@ -0,0 +1,58 @@ +package com.snp.batch.jobs.sample.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 주문 Entity (복잡한 JSON 예제용) + * BaseEntity를 상속하여 감사 필드 포함 + * + * JPA 어노테이션 사용 금지 (JDBC 전용) + * 컬럼 매핑은 주석으로 명시 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class OrderEntity extends BaseEntity { + + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * 주문 ID (비즈니스 키) + * 컬럼: order_id (VARCHAR(50), UNIQUE, NOT NULL) + */ + private String orderId; + + /** + * 고객 이름 + * 컬럼: customer_name (VARCHAR(100)) + */ + private String customerName; + + /** + * 주문 일시 + * 컬럼: order_date (TIMESTAMP) + */ + private LocalDateTime orderDate; + + /** + * 총 주문 금액 + * 컬럼: total_amount (DECIMAL(10, 2)) + */ + private BigDecimal totalAmount; + + // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java new file mode 100644 index 0000000..18dd382 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/entity/OrderItemEntity.java @@ -0,0 +1,63 @@ +package com.snp.batch.jobs.sample.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; + +/** + * 주문 상품 Entity (복잡한 JSON 예제용) + * BaseEntity를 상속하여 감사 필드 포함 + * + * JPA 어노테이션 사용 금지 (JDBC 전용) + * 컬럼 매핑은 주석으로 명시 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class OrderItemEntity extends BaseEntity { + + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * 주문 ID (외래 키) + * 컬럼: order_id (VARCHAR(50), NOT NULL) + */ + private String orderId; + + /** + * 상품 ID + * 컬럼: product_id (VARCHAR(50)) + */ + private String productId; + + /** + * 상품명 + * 컬럼: product_name (VARCHAR(200)) + */ + private String productName; + + /** + * 수량 + * 컬럼: quantity (INTEGER) + */ + private Integer quantity; + + /** + * 가격 + * 컬럼: price (DECIMAL(10, 2)) + */ + private BigDecimal price; + + // createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속 +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java b/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java new file mode 100644 index 0000000..e101992 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/entity/ProductEntity.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.sample.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 제품 엔티티 (샘플 데이터) - JDBC 전용 + * 다양한 데이터 타입 포함 + * + * 테이블: sample_products + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ProductEntity extends BaseEntity { + + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * 제품 ID (비즈니스 키) + * 컬럼: product_id (VARCHAR(50), UNIQUE, NOT NULL) + */ + private String productId; + + /** + * 제품명 + * 컬럼: product_name (VARCHAR(200), NOT NULL) + */ + private String productName; + + /** + * 카테고리 + * 컬럼: category (VARCHAR(100)) + */ + private String category; + + /** + * 가격 + * 컬럼: price (DECIMAL(10,2)) + */ + private BigDecimal price; + + /** + * 재고 수량 + * 컬럼: stock_quantity (INTEGER) + */ + private Integer stockQuantity; + + /** + * 활성 여부 + * 컬럼: is_active (BOOLEAN) + */ + private Boolean isActive; + + /** + * 평점 + * 컬럼: rating (DOUBLE PRECISION) + */ + private Double rating; + + /** + * 제조일자 + * 컬럼: manufacture_date (DATE) + */ + private LocalDate manufactureDate; + + /** + * 무게 (kg) + * 컬럼: weight (REAL/FLOAT) + */ + private Float weight; + + /** + * 판매 횟수 + * 컬럼: sales_count (BIGINT) + */ + private Long salesCount; + + /** + * 설명 + * 컬럼: description (TEXT) + */ + private String description; + + /** + * 태그 (JSON 문자열) + * 컬럼: tags (VARCHAR(500)) + */ + private String tags; +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java b/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java new file mode 100644 index 0000000..be24730 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/processor/OrderDataProcessor.java @@ -0,0 +1,103 @@ +package com.snp.batch.jobs.sample.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.sample.batch.dto.OrderDto; +import com.snp.batch.jobs.sample.batch.entity.OrderEntity; +import com.snp.batch.jobs.sample.batch.entity.OrderItemEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 주문 데이터 Processor (복잡한 JSON 처리 예제) + * + * 처리 방식: + * 1. 중첩된 JSON (OrderDto)를 받아서 + * 2. OrderEntity (부모)와 OrderItemEntity 리스트 (자식)로 분해 + * 3. OrderWrapper에 담아서 반환 + * 4. CompositeWriter가 각각 다른 테이블에 저장 + * + * 데이터 흐름: + * OrderDto (1개) + * ↓ + * OrderDataProcessor + * ↓ + * OrderWrapper { + * OrderEntity (1개) + * List (N개) + * } + * ↓ + * CompositeWriter { + * OrderWriter → orders 테이블 + * OrderItemWriter → order_items 테이블 + * } + */ +@Slf4j +@Component +public class OrderDataProcessor extends BaseProcessor { + + /** + * OrderDto를 OrderEntity와 OrderItemEntity 리스트로 분해 + */ + @Override + protected OrderWrapper processItem(OrderDto dto) throws Exception { + log.debug("주문 데이터 처리 시작: orderId={}", dto.getOrderId()); + + // 1. OrderEntity 생성 (부모 데이터) + OrderEntity orderEntity = OrderEntity.builder() + .orderId(dto.getOrderId()) + .customerName(dto.getCustomerName()) + .orderDate(dto.getOrderDate()) + .totalAmount(dto.getTotalAmount()) + .build(); + + // 2. OrderItemEntity 리스트 생성 (자식 데이터) + List orderItems = new ArrayList<>(); + + if (dto.getItems() != null && !dto.getItems().isEmpty()) { + for (OrderDto.OrderItemDto itemDto : dto.getItems()) { + OrderItemEntity itemEntity = OrderItemEntity.builder() + .orderId(dto.getOrderId()) // 부모 orderId 연결 + .productId(itemDto.getProductId()) + .productName(itemDto.getProductName()) + .quantity(itemDto.getQuantity()) + .price(itemDto.getPrice()) + .build(); + + orderItems.add(itemEntity); + } + } + + log.debug("주문 데이터 처리 완료: orderId={}, items={}", + dto.getOrderId(), orderItems.size()); + + // 3. Wrapper에 담아서 반환 + return new OrderWrapper(orderEntity, orderItems); + } + + /** + * OrderWrapper 클래스 + * OrderEntity와 OrderItemEntity 리스트를 함께 담는 컨테이너 + * + * CompositeWriter가 이 Wrapper를 받아서 각각 다른 Writer로 전달 + */ + public static class OrderWrapper { + private final OrderEntity order; + private final List items; + + public OrderWrapper(OrderEntity order, List items) { + this.order = order; + this.items = items; + } + + public OrderEntity getOrder() { + return order; + } + + public List getItems() { + return items; + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java b/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java new file mode 100644 index 0000000..7e05d53 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/processor/ProductDataProcessor.java @@ -0,0 +1,46 @@ +package com.snp.batch.jobs.sample.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.sample.batch.dto.ProductDto; +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 제품 데이터 Processor + * BaseProcessor를 상속하여 구현 + */ +@Slf4j +@Component +public class ProductDataProcessor extends BaseProcessor { + + @Override + protected ProductEntity processItem(ProductDto dto) throws Exception { + // 필터링 조건: productId가 있고, 활성화된 제품만 처리 + if (dto.getProductId() == null || dto.getProductId().isEmpty()) { + log.warn("제품 ID가 없어 필터링됨: {}", dto); + return null; + } + + if (dto.getIsActive() == null || !dto.getIsActive()) { + log.info("비활성 제품 필터링: {} ({})", dto.getProductId(), dto.getProductName()); + return null; + } + + // DTO → Entity 변환 + return ProductEntity.builder() + .productId(dto.getProductId()) + .productName(dto.getProductName()) + .category(dto.getCategory()) + .price(dto.getPrice()) + .stockQuantity(dto.getStockQuantity()) + .isActive(dto.getIsActive()) + .rating(dto.getRating()) + .manufactureDate(dto.getManufactureDate()) + .weight(dto.getWeight()) + .salesCount(dto.getSalesCount()) + .description(dto.getDescription()) + .tags(dto.getTags()) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java new file mode 100644 index 0000000..10536fe --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductApiReader.java @@ -0,0 +1,287 @@ +package com.snp.batch.jobs.sample.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.sample.batch.dto.ProductApiResponse; +import com.snp.batch.jobs.sample.batch.dto.ProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 제품 데이터 API Reader (실전 예제) + * BaseApiReader v2.0을 사용한 실제 API 연동 예제 + * + * 주요 기능: + * - GET/POST 요청 예제 + * - Query Parameter 처리 + * - Request Body 처리 + * - Header 설정 + * - 복잡한 JSON 응답 파싱 + * + * 사용법: + * JobConfig에서 이 Reader를 사용하려면: + * 1. @Component 또는 @Bean으로 등록 + * 2. WebClient Bean 주입 + * 3. ProductApiReader 생성 시 WebClient 전달 + * 4. application.yml에 API 설정 추가 + * + * 참고: + * - 이 클래스는 예제용으로 @Component가 제거되어 있습니다 + * - 실제 사용 시 JobConfig에서 @Bean으로 등록하세요 + */ +@Slf4j +// @Component - 예제용이므로 주석 처리 (실제 사용 시 활성화) +public class ProductApiReader extends BaseApiReader { + + /** + * WebClient 주입 생성자 + * + * @param webClient Spring WebClient 인스턴스 + */ + public ProductApiReader(WebClient webClient) { + super(webClient); + } + + // ======================================== + // 필수 구현 메서드 + // ======================================== + + @Override + protected String getReaderName() { + return "ProductApiReader"; + } + + @Override + protected List fetchDataFromApi() { + try { + // callApi() 헬퍼 메서드 사용 (GET/POST 자동 처리) + ProductApiResponse response = callApi(); + + // 응답에서 데이터 추출 + return extractDataFromResponse(response); + + } catch (Exception e) { + // 에러 처리 (빈 리스트 반환 또는 예외 던지기) + return handleApiError(e); + } + } + + // ======================================== + // HTTP 요청 설정 (예제: GET 요청) + // ======================================== + + /** + * HTTP Method 설정 + * + * GET 예제: + * return "GET"; + * + * POST 예제로 변경하려면: + * return "POST"; + */ + @Override + protected String getHttpMethod() { + return "GET"; // GET 요청 예제 + } + + /** + * API 엔드포인트 경로 + * + * 예제: + * - "/api/v1/products" + * - "/api/v1/products/search" + */ + @Override + protected String getApiPath() { + return "/api/v1/products"; + } + + /** + * Query Parameter 설정 + * + * GET 요청 시 사용되는 파라미터 + * + * 예제: + * ?status=active&category=전자제품&page=1&size=100 + */ + @Override + protected Map getQueryParams() { + Map params = new HashMap<>(); + params.put("status", "active"); // 활성 제품만 + params.put("category", "전자제품"); // 카테고리 필터 + params.put("page", 1); // 페이지 번호 + params.put("size", 100); // 페이지 크기 + return params; + } + + /** + * HTTP Header 설정 + * + * 인증 토큰, API Key 등 추가 + */ + @Override + protected Map getHeaders() { + Map headers = new HashMap<>(); + // 예제: API Key 인증 + // headers.put("X-API-Key", "your-api-key-here"); + // 예제: Bearer 토큰 인증 + // headers.put("Authorization", "Bearer " + getAccessToken()); + return headers; + } + + /** + * API 응답 타입 지정 + */ + @Override + protected Class getResponseType() { + return ProductApiResponse.class; + } + + /** + * API 응답에서 데이터 리스트 추출 + * + * 복잡한 JSON 구조 처리: + * { + * "success": true, + * "data": { + * "products": [...], + * "totalCount": 100 + * } + * } + */ + @Override + protected List extractDataFromResponse(Object response) { + if (response instanceof ProductApiResponse) { + ProductApiResponse apiResponse = (ProductApiResponse) response; + return apiResponse.getProducts(); + } + return super.extractDataFromResponse(response); + } + + // ======================================== + // 라이프사이클 훅 (선택적 오버라이드) + // ======================================== + + @Override + protected void beforeFetch() { + log.info("[{}] 제품 API 호출 준비 중...", getReaderName()); + log.info("- Method: {}", getHttpMethod()); + log.info("- Path: {}", getApiPath()); + log.info("- Query Params: {}", getQueryParams()); + } + + @Override + protected void afterFetch(List data) { + log.info("[{}] API 호출 성공: {}건 조회", getReaderName(), getDataSize(data)); + + // 데이터 검증 + if (isEmpty(data)) { + log.warn("[{}] 조회된 데이터가 없습니다!", getReaderName()); + } + } + + @Override + protected List handleApiError(Exception e) { + log.error("[{}] 제품 API 호출 실패", getReaderName(), e); + + // 선택 1: 빈 리스트 반환 (Job 실패 방지) + // return new ArrayList<>(); + + // 선택 2: 예외 던지기 (Job 실패 처리) + throw new RuntimeException("제품 데이터 조회 실패", e); + } +} + + +/** + * ======================================== + * POST 요청 예제 (주석 참고) + * ======================================== + * + * POST 요청으로 변경하려면: + * + * 1. getHttpMethod() 변경: + * @Override + * protected String getHttpMethod() { + * return "POST"; + * } + * + * 2. getRequestBody() 추가: + * @Override + * protected Object getRequestBody() { + * return ProductSearchRequest.builder() + * .startDate("2025-01-01") + * .endDate("2025-12-31") + * .categories(Arrays.asList("전자제품", "가구")) + * .minPrice(10000) + * .maxPrice(1000000) + * .build(); + * } + * + * 3. Request DTO 생성: + * @Data + * @Builder + * public class ProductSearchRequest { + * private String startDate; + * private String endDate; + * private List categories; + * private Integer minPrice; + * private Integer maxPrice; + * } + * + * 4. Query Parameter와 혼용 가능: + * - Query Parameter: URL에 추가되는 파라미터 + * - Request Body: POST Body에 포함되는 데이터 + * + * ======================================== + * Path Variable 예제 (주석 참고) + * ======================================== + * + * Path Variable 사용하려면: + * + * 1. getApiPath() 변경: + * @Override + * protected String getApiPath() { + * return "/api/v1/products/{productId}/details"; + * } + * + * 2. getPathVariables() 추가: + * @Override + * protected Map getPathVariables() { + * Map pathVars = new HashMap<>(); + * pathVars.put("productId", "PROD-001"); + * return pathVars; + * } + * + * 결과 URL: /api/v1/products/PROD-001/details + * + * ======================================== + * 다중 depth JSON 응답 예제 + * ======================================== + * + * 복잡한 JSON 구조: + * { + * "status": "success", + * "result": { + * "data": { + * "items": [ + * { "productId": "PROD-001", "name": "..." } + * ], + * "pagination": { + * "page": 1, + * "totalPages": 10 + * } + * } + * } + * } + * + * extractDataFromResponse() 구현: + * @Override + * protected List extractDataFromResponse(Object response) { + * ComplexApiResponse apiResponse = (ComplexApiResponse) response; + * return apiResponse.getResult().getData().getItems(); + * } + */ diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java new file mode 100644 index 0000000..47dff0c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/reader/ProductDataReader.java @@ -0,0 +1,247 @@ +package com.snp.batch.jobs.sample.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.sample.batch.dto.ProductDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +/** + * 제품 데이터 Reader (Mock 데이터 생성) + * BaseApiReader v2.0을 상속하여 구현 + * + * 특징: + * - WebClient 없이 Mock 데이터 생성 (실제 API 호출 X) + * - 테스트 및 샘플용 Reader + * + * 실전 API 연동 예제는 ProductApiReader.java 참고 + */ +@Slf4j +@Component +public class ProductDataReader extends BaseApiReader { + + /** + * 기본 생성자 (WebClient 없이 Mock 데이터 생성) + */ + public ProductDataReader() { + super(); // WebClient 없이 초기화 + } + + // ======================================== + // 필수 구현 메서드 + // ======================================== + + @Override + protected String getReaderName() { + return "ProductDataReader"; + } + + @Override + protected List fetchDataFromApi() { + log.info("========================================"); + log.info("Mock 샘플 데이터 생성 시작"); + log.info("========================================"); + + return generateMockData(); + } + + // ======================================== + // 라이프사이클 훅 (선택적 오버라이드) + // ======================================== + + @Override + protected void beforeFetch() { + log.info("[{}] Mock 데이터 생성 준비...", getReaderName()); + } + + @Override + protected void afterFetch(List data) { + log.info("[{}] Mock 데이터 생성 완료: {}건", getReaderName(), getDataSize(data)); + } + + /** + * Mock 샘플 데이터 생성 + * 다양한 데이터 타입 포함 + */ + private List generateMockData() { + log.info("========================================"); + log.info("Mock 샘플 데이터 생성 시작"); + log.info("다양한 데이터 타입 테스트용"); + log.info("========================================"); + + List products = new ArrayList<>(); + + // 샘플 1: 전자제품 + products.add(ProductDto.builder() + .productId("PROD-001") + .productName("노트북 - MacBook Pro 16") + .category("전자제품") + .price(new BigDecimal("2999000.00")) + .stockQuantity(15) + .isActive(true) + .rating(4.8) + .manufactureDate(LocalDate.of(2024, 11, 15)) + .weight(2.1f) + .salesCount(1250L) + .description("Apple M3 Max 칩셋, 64GB RAM, 2TB SSD. 프로페셔널을 위한 최고 성능의 노트북.") + .tags("[\"Apple\", \"Laptop\", \"Premium\", \"M3\"]") + .build()); + + // 샘플 2: 가구 + products.add(ProductDto.builder() + .productId("PROD-002") + .productName("인체공학 사무용 의자") + .category("가구") + .price(new BigDecimal("450000.00")) + .stockQuantity(30) + .isActive(true) + .rating(4.5) + .manufactureDate(LocalDate.of(2024, 9, 20)) + .weight(18.5f) + .salesCount(890L) + .description("허리 건강을 위한 메쉬 의자. 10시간 이상 장시간 착석 가능.") + .tags("[\"Office\", \"Ergonomic\", \"Furniture\"]") + .build()); + + // 샘플 3: 식품 + products.add(ProductDto.builder() + .productId("PROD-003") + .productName("유기농 블루베리 (500g)") + .category("식품") + .price(new BigDecimal("12900.00")) + .stockQuantity(100) + .isActive(true) + .rating(4.9) + .manufactureDate(LocalDate.of(2025, 10, 10)) + .weight(0.5f) + .salesCount(3450L) + .description("100% 국내산 유기농 블루베리. 신선하고 달콤합니다.") + .tags("[\"Organic\", \"Fruit\", \"Fresh\", \"Healthy\"]") + .build()); + + // 샘플 4: 의류 + products.add(ProductDto.builder() + .productId("PROD-004") + .productName("겨울용 패딩 점퍼") + .category("의류") + .price(new BigDecimal("189000.00")) + .stockQuantity(50) + .isActive(true) + .rating(4.6) + .manufactureDate(LocalDate.of(2024, 10, 1)) + .weight(1.2f) + .salesCount(2100L) + .description("방수 기능이 있는 오리털 패딩. 영하 20도까지 견딜 수 있습니다.") + .tags("[\"Winter\", \"Padding\", \"Waterproof\"]") + .build()); + + // 샘플 5: 도서 + products.add(ProductDto.builder() + .productId("PROD-005") + .productName("클린 코드 (Clean Code)") + .category("도서") + .price(new BigDecimal("33000.00")) + .stockQuantity(200) + .isActive(true) + .rating(5.0) + .manufactureDate(LocalDate.of(2013, 12, 24)) + .weight(0.8f) + .salesCount(15000L) + .description("Robert C. Martin의 명저. 읽기 좋은 코드를 작성하는 방법.") + .tags("[\"Programming\", \"Book\", \"Classic\", \"BestSeller\"]") + .build()); + + // 샘플 6: 비활성 제품 (테스트용) + products.add(ProductDto.builder() + .productId("PROD-006") + .productName("단종된 구형 스마트폰") + .category("전자제품") + .price(new BigDecimal("99000.00")) + .stockQuantity(0) + .isActive(false) // 비활성 + .rating(3.2) + .manufactureDate(LocalDate.of(2020, 1, 15)) + .weight(0.18f) + .salesCount(5000L) + .description("단종된 제품입니다.") + .tags("[\"Discontinued\", \"Old\"]") + .build()); + + // 샘플 7: NULL 테스트용 + products.add(ProductDto.builder() + .productId("PROD-007") + .productName("일부 정보 누락된 제품") + .category("기타") + .price(new BigDecimal("10000.00")) + .stockQuantity(5) + .isActive(true) + .rating(null) // NULL 값 + .manufactureDate(null) // NULL 값 + .weight(null) // NULL 값 + .salesCount(0L) + .description("일부 필드가 NULL인 테스트 데이터") + .tags(null) // NULL 값 + .build()); + + // 샘플 8: 극단값 테스트 + products.add(ProductDto.builder() + .productId("PROD-008") + .productName("초고가 명품 시계") + .category("악세서리") + .price(new BigDecimal("99999999.99")) // 최대값 + .stockQuantity(1) + .isActive(true) + .rating(5.0) + .manufactureDate(LocalDate.of(2025, 1, 1)) + .weight(0.15f) + .salesCount(999999999L) // 최대값 + .description("세계 최고가의 명품 시계. 한정판 1개.") + .tags("[\"Luxury\", \"Watch\", \"Limited\"]") + .build()); + + // 샘플 9: 소수점 테스트 + products.add(ProductDto.builder() + .productId("PROD-009") + .productName("초경량 블루투스 이어폰") + .category("전자제품") + .price(new BigDecimal("79900.50")) // 소수점 + .stockQuantity(75) + .isActive(true) + .rating(4.35) // 소수점 + .manufactureDate(LocalDate.of(2025, 8, 20)) + .weight(0.045f) // 소수점 + .salesCount(8765L) + .description("초경량 무선 이어폰. 배터리 24시간 사용 가능.") + .tags("[\"Bluetooth\", \"Earbuds\", \"Lightweight\"]") + .build()); + + // 샘플 10: 긴 텍스트 테스트 + products.add(ProductDto.builder() + .productId("PROD-010") + .productName("프리미엄 멀티 비타민") + .category("건강식품") + .price(new BigDecimal("45000.00")) + .stockQuantity(120) + .isActive(true) + .rating(4.7) + .manufactureDate(LocalDate.of(2025, 6, 1)) + .weight(0.3f) + .salesCount(5432L) + .description("하루 한 알로 간편하게 섭취하는 종합 비타민입니다. " + + "비타민 A, B, C, D, E를 포함하여 총 12가지 필수 영양소가 함유되어 있습니다. " + + "GMP 인증 시설에서 제조되었으며, 식약처 인증을 받았습니다. " + + "현대인의 부족한 영양소를 한 번에 보충할 수 있습니다. " + + "임산부, 수유부, 어린이는 전문가와 상담 후 복용하시기 바랍니다.") + .tags("[\"Vitamin\", \"Health\", \"Supplement\", \"Daily\", \"GMP\"]") + .build()); + + log.info("총 {}개의 Mock 샘플 데이터 생성 완료", products.size()); + log.info("데이터 타입: String, BigDecimal, Integer, Boolean, Double, LocalDate, Float, Long, TEXT"); + + return products; + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java new file mode 100644 index 0000000..00c27fa --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepository.java @@ -0,0 +1,39 @@ +package com.snp.batch.jobs.sample.batch.repository; + +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; + +import java.util.List; +import java.util.Optional; + +/** + * 제품 Repository 인터페이스 + * 구현체: ProductRepositoryImpl (JdbcTemplate 기반) + */ +public interface ProductRepository { + + // CRUD 메서드 + Optional findById(Long id); + List findAll(); + long count(); + boolean existsById(Long id); + ProductEntity save(ProductEntity entity); + void saveAll(List entities); + void deleteById(Long id); + void deleteAll(); + + // 커스텀 메서드 + /** + * 제품 ID로 조회 + */ + Optional findByProductId(String productId); + + /** + * 제품 ID 존재 여부 확인 + */ + boolean existsByProductId(String productId); + + /** + * 페이징 조회 + */ + List findAllWithPaging(int offset, int limit); +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java new file mode 100644 index 0000000..4916592 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/repository/ProductRepositoryImpl.java @@ -0,0 +1,191 @@ +package com.snp.batch.jobs.sample.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +/** + * Product Repository (JdbcTemplate 기반) + */ +@Slf4j +@Repository("productRepository") +public class ProductRepositoryImpl extends BaseJdbcRepository implements ProductRepository { + + public ProductRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "sample_products"; + } + + @Override + protected String getEntityName() { + return "Product"; + } + + @Override + protected RowMapper getRowMapper() { + return new ProductEntityRowMapper(); + } + + @Override + protected Long extractId(ProductEntity entity) { + return entity.getId(); + } + + @Override + protected String getInsertSql() { + return """ + INSERT INTO sample_products ( + product_id, product_name, category, price, stock_quantity, + is_active, rating, manufacture_date, weight, sales_count, + description, tags, created_at, updated_at, created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + } + + @Override + protected String getUpdateSql() { + return """ + UPDATE sample_products + SET product_name = ?, + category = ?, + price = ?, + stock_quantity = ?, + is_active = ?, + rating = ?, + manufacture_date = ?, + weight = ?, + sales_count = ?, + description = ?, + tags = ?, + updated_at = ?, + updated_by = ? + WHERE id = ? + """; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ProductEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getProductId()); + ps.setString(idx++, entity.getProductName()); + ps.setString(idx++, entity.getCategory()); + ps.setBigDecimal(idx++, entity.getPrice()); + ps.setObject(idx++, entity.getStockQuantity()); + ps.setObject(idx++, entity.getIsActive()); + ps.setObject(idx++, entity.getRating()); + ps.setObject(idx++, entity.getManufactureDate()); + ps.setObject(idx++, entity.getWeight()); + ps.setObject(idx++, entity.getSalesCount()); + ps.setString(idx++, entity.getDescription()); + ps.setString(idx++, entity.getTags()); + ps.setTimestamp(idx++, entity.getCreatedAt() != null ? + Timestamp.valueOf(entity.getCreatedAt()) : Timestamp.valueOf(now())); + ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getCreatedBy() != null ? entity.getCreatedBy() : "SYSTEM"); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ProductEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getProductName()); + ps.setString(idx++, entity.getCategory()); + ps.setBigDecimal(idx++, entity.getPrice()); + ps.setObject(idx++, entity.getStockQuantity()); + ps.setObject(idx++, entity.getIsActive()); + ps.setObject(idx++, entity.getRating()); + ps.setObject(idx++, entity.getManufactureDate()); + ps.setObject(idx++, entity.getWeight()); + ps.setObject(idx++, entity.getSalesCount()); + ps.setString(idx++, entity.getDescription()); + ps.setString(idx++, entity.getTags()); + ps.setTimestamp(idx++, Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + ps.setLong(idx++, entity.getId()); + } + + // ==================== 커스텀 쿼리 메서드 ==================== + + /** + * Product ID로 조회 + */ + @Override + public Optional findByProductId(String productId) { + String sql = "SELECT * FROM sample_products WHERE product_id = ?"; + return executeQueryForObject(sql, productId); + } + + /** + * Product ID 존재 여부 확인 + */ + @Override + public boolean existsByProductId(String productId) { + String sql = "SELECT COUNT(*) FROM sample_products WHERE product_id = ?"; + Long count = jdbcTemplate.queryForObject(sql, Long.class, productId); + return count != null && count > 0; + } + + /** + * 페이징 조회 + */ + @Override + public List findAllWithPaging(int offset, int limit) { + String sql = "SELECT * FROM sample_products ORDER BY id DESC LIMIT ? OFFSET ?"; + return executeQueryForList(sql, limit, offset); + } + + // ==================== RowMapper ==================== + + private static class ProductEntityRowMapper implements RowMapper { + @Override + public ProductEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + ProductEntity entity = ProductEntity.builder() + .id(rs.getLong("id")) + .productId(rs.getString("product_id")) + .productName(rs.getString("product_name")) + .category(rs.getString("category")) + .price(rs.getBigDecimal("price")) + .stockQuantity((Integer) rs.getObject("stock_quantity")) + .isActive((Boolean) rs.getObject("is_active")) + .rating((Double) rs.getObject("rating")) + .manufactureDate(rs.getDate("manufacture_date") != null ? + rs.getDate("manufacture_date").toLocalDate() : null) + .weight((Float) rs.getObject("weight")) + .salesCount((Long) rs.getObject("sales_count")) + .description(rs.getString("description")) + .tags(rs.getString("tags")) + .build(); + + // BaseEntity 필드 매핑 + Timestamp createdAt = rs.getTimestamp("created_at"); + if (createdAt != null) { + entity.setCreatedAt(createdAt.toLocalDateTime()); + } + + Timestamp updatedAt = rs.getTimestamp("updated_at"); + if (updatedAt != null) { + entity.setUpdatedAt(updatedAt.toLocalDateTime()); + } + + entity.setCreatedBy(rs.getString("created_by")); + entity.setUpdatedBy(rs.getString("updated_by")); + + return entity; + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java new file mode 100644 index 0000000..481d666 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderItemWriter.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.sample.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.sample.batch.entity.OrderItemEntity; +import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 주문 상품 Writer (복잡한 JSON 예제용) + * OrderWrapper에서 OrderItemEntity 리스트만 추출하여 저장 + */ +@Slf4j +@Component +public class OrderItemWriter extends BaseWriter { + + public OrderItemWriter() { + super("OrderItem"); + } + + @Override + protected void writeItems(List wrappers) throws Exception { + // OrderWrapper에서 OrderItemEntity 리스트만 추출 (flatten) + List allItems = wrappers.stream() + .flatMap(wrapper -> wrapper.getItems().stream()) + .collect(Collectors.toList()); + + log.info("주문 상품 데이터 저장: {} 건", allItems.size()); + + // 실제 구현 시 OrderItemRepository.saveAll(allItems) 호출 + // 예제이므로 로그만 출력 + for (OrderItemEntity item : allItems) { + log.info("주문 상품 저장: orderId={}, productId={}, quantity={}, price={}", + item.getOrderId(), + item.getProductId(), + item.getQuantity(), + item.getPrice()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java new file mode 100644 index 0000000..d91ceee --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/writer/OrderWriter.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.sample.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.sample.batch.entity.OrderEntity; +import com.snp.batch.jobs.sample.batch.processor.OrderDataProcessor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +/** + * 주문 Writer (복잡한 JSON 예제용) + * OrderWrapper에서 OrderEntity만 추출하여 저장 + */ +@Slf4j +@Component +public class OrderWriter extends BaseWriter { + + public OrderWriter() { + super("Order"); + } + + @Override + protected void writeItems(List wrappers) throws Exception { + // OrderWrapper에서 OrderEntity만 추출 + List orders = wrappers.stream() + .map(OrderDataProcessor.OrderWrapper::getOrder) + .collect(Collectors.toList()); + + log.info("주문 데이터 저장: {} 건", orders.size()); + + // 실제 구현 시 OrderRepository.saveAll(orders) 호출 + // 예제이므로 로그만 출력 + for (OrderEntity order : orders) { + log.info("주문 저장: orderId={}, customer={}, total={}", + order.getOrderId(), + order.getCustomerName(), + order.getTotalAmount()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java b/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java new file mode 100644 index 0000000..94f7865 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/batch/writer/ProductDataWriter.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.sample.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; +import com.snp.batch.jobs.sample.batch.repository.ProductRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 제품 데이터 Writer + * BaseWriter를 상속하여 구현 + */ +@Slf4j +@Component +public class ProductDataWriter extends BaseWriter { + + private final ProductRepository productRepository; + + public ProductDataWriter(ProductRepository productRepository) { + super("Product"); + this.productRepository = productRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + // Repository의 saveAll() 메서드 호출 + productRepository.saveAll(items); + + // 저장된 제품 목록 출력 + log.info("========================================"); + items.forEach(product -> + log.info("✓ 저장 완료: {} - {} (가격: {}원, 재고: {}개)", + product.getProductId(), + product.getProductName(), + product.getPrice(), + product.getStockQuantity()) + ); + log.info("========================================"); + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java b/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java new file mode 100644 index 0000000..01700cd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/web/controller/ProductWebController.java @@ -0,0 +1,129 @@ +package com.snp.batch.jobs.sample.web.controller; + +import com.snp.batch.common.web.ApiResponse; +import com.snp.batch.common.web.controller.BaseController; +import com.snp.batch.common.web.service.BaseService; +import com.snp.batch.jobs.sample.web.dto.ProductWebDto; +import com.snp.batch.jobs.sample.web.service.ProductWebService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 제품 웹 API 컨트롤러 (샘플) + * BaseController를 상속하여 공통 CRUD 엔드포인트 자동 생성 + * + * 제공되는 엔드포인트: + * - 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/{productId} : 제품 ID로 조회 + * - GET /api/products/stats/active-count : 활성 제품 개수 + */ +@Slf4j +@RestController +@RequestMapping("/api/products") +@RequiredArgsConstructor +@Tag(name = "Product API", description = "제품 관리 API (샘플)") +public class ProductWebController extends BaseController { + + private final ProductWebService productWebService; + + @Override + protected BaseService getService() { + return productWebService; + } + + @Override + protected String getResourceName() { + return "Product"; + } + + // ==================== 커스텀 엔드포인트 ==================== + + /** + * 제품 ID로 조회 (비즈니스 키 조회) + * + * @param productId 제품 ID (예: PROD-001) + * @return 제품 DTO + */ + @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 = "서버 오류" + ) + } + ) + @GetMapping("/by-product-id/{productId}") + public ResponseEntity> getByProductId( + @Parameter(description = "제품 코드", required = true, example = "PROD-001") + @PathVariable String productId) { + log.info("제품 ID로 조회 요청: {}", productId); + try { + ProductWebDto product = productWebService.findByProductId(productId); + if (product == null) { + return ResponseEntity.notFound().build(); + } + return ResponseEntity.ok(ApiResponse.success(product)); + } catch (Exception e) { + log.error("제품 ID 조회 실패: {}", productId, e); + return ResponseEntity.internalServerError().body( + ApiResponse.error("Failed to get product by productId: " + e.getMessage()) + ); + } + } + + /** + * 활성 제품 개수 조회 + * + * @return 활성 제품 수 + */ + @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("/stats/active-count") + public ResponseEntity> getActiveCount() { + log.info("활성 제품 개수 조회 요청"); + try { + long count = productWebService.countActiveProducts(); + return ResponseEntity.ok(ApiResponse.success("Active product count", count)); + } catch (Exception e) { + log.error("활성 제품 개수 조회 실패", e); + return ResponseEntity.internalServerError().body( + ApiResponse.error("Failed to get active product count: " + e.getMessage()) + ); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java new file mode 100644 index 0000000..ac79dc5 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductResponseDto.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.sample.web.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 제품 API 응답 DTO + * DB에 저장된 제품 데이터를 외부에 제공할 때 사용 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "제품 정보 응답 DTO") +public class ProductResponseDto { + + @Schema(description = "제품 ID (Primary Key)", example = "1") + private Long id; + + @Schema(description = "제품 코드", example = "PROD-001") + private String productId; + + @Schema(description = "제품명", example = "노트북 - MacBook Pro 16") + private String productName; + + @Schema(description = "카테고리", example = "전자제품") + private String category; + + @Schema(description = "가격", example = "2999000.00") + private BigDecimal price; + + @Schema(description = "재고 수량", example = "15") + private Integer stockQuantity; + + @Schema(description = "활성화 여부", example = "true") + private Boolean isActive; + + @Schema(description = "평점", example = "4.8") + private Double rating; + + @Schema(description = "제조일", example = "2024-11-15") + private LocalDate manufactureDate; + + @Schema(description = "무게 (kg)", example = "2.1") + private Float weight; + + @Schema(description = "판매 수량", example = "1250") + private Long salesCount; + + @Schema(description = "제품 설명", example = "Apple M3 Max 칩셋, 64GB RAM, 2TB SSD") + private String description; + + @Schema(description = "태그 (JSON 문자열)", example = "[\"Apple\", \"Laptop\", \"Premium\"]") + private String tags; +} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java new file mode 100644 index 0000000..e12a5dc --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/web/dto/ProductWebDto.java @@ -0,0 +1,85 @@ +package com.snp.batch.jobs.sample.web.dto; + +import com.snp.batch.common.web.dto.BaseDto; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 제품 웹 DTO (샘플) + * BaseDto를 상속하여 감사 필드 자동 포함 + * + * 이 DTO는 웹 API에서 사용되며, 배치 DTO와는 별도로 관리됩니다. + */ +@Data +@EqualsAndHashCode(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ProductWebDto extends BaseDto { + + /** + * 제품 ID (비즈니스 키) + */ + private String productId; + + /** + * 제품명 + */ + private String productName; + + /** + * 카테고리 + */ + private String category; + + /** + * 가격 + */ + private BigDecimal price; + + /** + * 재고 수량 + */ + private Integer stockQuantity; + + /** + * 활성 여부 + */ + private Boolean isActive; + + /** + * 평점 + */ + private Double rating; + + /** + * 제조일자 + */ + private LocalDate manufactureDate; + + /** + * 무게 (kg) + */ + private Float weight; + + /** + * 판매 횟수 + */ + private Long salesCount; + + /** + * 설명 + */ + private String description; + + /** + * 태그 (JSON 문자열) + */ + private String tags; +} diff --git a/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java b/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java new file mode 100644 index 0000000..6c876d6 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/sample/web/service/ProductWebService.java @@ -0,0 +1,144 @@ +package com.snp.batch.jobs.sample.web.service; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.common.web.service.BaseServiceImpl; +import com.snp.batch.jobs.sample.batch.entity.ProductEntity; +import com.snp.batch.jobs.sample.batch.repository.ProductRepository; +import com.snp.batch.jobs.sample.batch.repository.ProductRepositoryImpl; +import com.snp.batch.jobs.sample.web.dto.ProductWebDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +/** + * 제품 웹 서비스 (샘플) - JDBC 기반 + * BaseServiceImpl을 상속하여 공통 CRUD 기능 구현 + * + * 이 서비스는 웹 API에서 사용되며, 배치 작업과는 별도로 동작합니다. + * - Batch: ProductDataReader/Processor/Writer (배치 데이터 처리) + * - Web: ProductWebService/Controller (REST API) + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ProductWebService extends BaseServiceImpl { + + private final ProductRepositoryImpl productRepository; + + @Override + protected BaseJdbcRepository getRepository() { + return productRepository; + } + + @Override + protected String getEntityName() { + return "Product"; + } + + @Override + public ProductWebDto toDto(ProductEntity entity) { + if (entity == null) { + return null; + } + + ProductWebDto dto = ProductWebDto.builder() + .productId(entity.getProductId()) + .productName(entity.getProductName()) + .category(entity.getCategory()) + .price(entity.getPrice()) + .stockQuantity(entity.getStockQuantity()) + .isActive(entity.getIsActive()) + .rating(entity.getRating()) + .manufactureDate(entity.getManufactureDate()) + .weight(entity.getWeight()) + .salesCount(entity.getSalesCount()) + .description(entity.getDescription()) + .tags(entity.getTags()) + .build(); + + // BaseDto 필드 설정 + dto.setCreatedAt(entity.getCreatedAt()); + dto.setUpdatedAt(entity.getUpdatedAt()); + dto.setCreatedBy(entity.getCreatedBy()); + dto.setUpdatedBy(entity.getUpdatedBy()); + + return dto; + } + + @Override + public ProductEntity toEntity(ProductWebDto dto) { + if (dto == null) { + return null; + } + + return ProductEntity.builder() + .productId(dto.getProductId()) + .productName(dto.getProductName()) + .category(dto.getCategory()) + .price(dto.getPrice()) + .stockQuantity(dto.getStockQuantity()) + .isActive(dto.getIsActive()) + .rating(dto.getRating()) + .manufactureDate(dto.getManufactureDate()) + .weight(dto.getWeight()) + .salesCount(dto.getSalesCount()) + .description(dto.getDescription()) + .tags(dto.getTags()) + .build(); + } + + @Override + protected void updateEntity(ProductEntity entity, ProductWebDto dto) { + // 필드 업데이트 + entity.setProductName(dto.getProductName()); + entity.setCategory(dto.getCategory()); + entity.setPrice(dto.getPrice()); + entity.setStockQuantity(dto.getStockQuantity()); + entity.setIsActive(dto.getIsActive()); + entity.setRating(dto.getRating()); + entity.setManufactureDate(dto.getManufactureDate()); + entity.setWeight(dto.getWeight()); + entity.setSalesCount(dto.getSalesCount()); + entity.setDescription(dto.getDescription()); + entity.setTags(dto.getTags()); + + log.debug("Product 업데이트: {}", entity.getProductId()); + } + + @Override + protected Long extractId(ProductEntity entity) { + return entity.getId(); + } + + @Override + protected List executePagingQuery(int offset, int limit) { + // JDBC 페이징 쿼리 실행 + return productRepository.findAllWithPaging(offset, limit); + } + + /** + * 커스텀 메서드: 제품 ID로 조회 + * + * @param productId 제품 ID (비즈니스 키) + * @return 제품 DTO + */ + public ProductWebDto findByProductId(String productId) { + log.debug("제품 ID로 조회: {}", productId); + return productRepository.findByProductId(productId) + .map(this::toDto) + .orElse(null); + } + + /** + * 커스텀 메서드: 활성 제품 개수 + * + * @return 활성 제품 수 + */ + public long countActiveProducts() { + long total = productRepository.count(); + log.debug("전체 제품 수: {}", total); + return total; + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java new file mode 100644 index 0000000..c97b43c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/config/ShipDetailImportJobConfig.java @@ -0,0 +1,106 @@ +package com.snp.batch.jobs.shipdetail.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto; +import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import com.snp.batch.jobs.shipdetail.batch.processor.ShipDetailDataProcessor; +import com.snp.batch.jobs.shipdetail.batch.reader.ShipDetailDataReader; +import com.snp.batch.jobs.shipdetail.batch.writer.ShipDetailDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * 선박 상세 정보 Import Job Config + * + * 특징: + * - ship_data 테이블에서 IMO 번호 조회 + * - IMO 번호를 100개씩 배치로 분할 + * - Maritime API GetShipsByIHSLRorIMONumbers 호출 + * - 선박 상세 정보를 ship_detail 테이블에 저장 (UPSERT) + * + * 데이터 흐름: + * ShipDetailDataReader (ship_data → Maritime API) + * ↓ (ShipDetailDto) + * ShipDetailDataProcessor + * ↓ (ShipDetailEntity) + * ShipDetailDataWriter + * ↓ (ship_detail 테이블) + */ +@Slf4j +@Configuration +public class ShipDetailImportJobConfig extends BaseJobConfig { + + private final ShipDetailDataProcessor shipDetailDataProcessor; + private final ShipDetailDataWriter shipDetailDataWriter; + private final JdbcTemplate jdbcTemplate; + private final WebClient maritimeApiWebClient; + + /** + * 생성자 주입 + * maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입 (ShipImportJob과 동일) + */ + public ShipDetailImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipDetailDataProcessor shipDetailDataProcessor, + ShipDetailDataWriter shipDetailDataWriter, + JdbcTemplate jdbcTemplate, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) { + super(jobRepository, transactionManager); + this.shipDetailDataProcessor = shipDetailDataProcessor; + this.shipDetailDataWriter = shipDetailDataWriter; + this.jdbcTemplate = jdbcTemplate; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "shipDetailImportJob"; + } + + @Override + protected String getStepName() { + return "shipDetailImportStep"; + } + + @Override + protected ItemReader createReader() { + return new ShipDetailDataReader(maritimeApiWebClient, jdbcTemplate); + } + + @Override + protected ItemProcessor createProcessor() { + return shipDetailDataProcessor; + } + + @Override + protected ItemWriter createWriter() { + return shipDetailDataWriter; + } + + @Override + protected int getChunkSize() { + return 100; // API에서 100개씩 가져오므로 chunk도 100으로 설정 + } + + @Bean(name = "shipDetailImportJob") + public Job shipDetailImportJob() { + return job(); + } + + @Bean(name = "shipDetailImportStep") + public Step shipDetailImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailApiResponse.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailApiResponse.java new file mode 100644 index 0000000..634d187 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailApiResponse.java @@ -0,0 +1,39 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * Maritime API GetShipsByIHSLRorIMONumbers 응답 래퍼 + * + * API 응답 구조: + * { + * "shipCount": 5, + * "Ships": [...] + * } + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ShipDetailApiResponse { + + /** + * 선박 상세 정보 리스트 + * API에서 "Ships" (대문자 S)로 반환 + */ + @JsonProperty("Ships") + private List ships; + + /** + * 선박 개수 + * API에서 "shipCount"로 반환 + */ + @JsonProperty("shipCount") + private Integer shipCount; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java new file mode 100644 index 0000000..210da7c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/dto/ShipDetailDto.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.shipdetail.batch.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 선박 상세 정보 DTO + * Maritime API GetShipsByIHSLRorIMONumbers 응답 데이터 + * + * API 응답 필드명과 매핑: + * - IHSLRorIMOShipNo → imoNumber + * - ShipName → shipName + * - ShiptypeLevel5 → shipType + * - FlagName → flag + * - GrossTonnage → grossTonnage + * - Deadweight → deadweight + * - ShipStatus → status + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ShipDetailDto { + + /** + * IMO 번호 + * API: IHSLRorIMOShipNo + */ + @JsonProperty("IHSLRorIMOShipNo") + private String imoNumber; + + /** + * 선박명 + * API: ShipName + */ + @JsonProperty("ShipName") + private String shipName; + + /** + * 선박 타입 + * API: ShiptypeLevel5 + */ + @JsonProperty("ShiptypeLevel5") + private String shipType; + + /** + * 깃발 국가 (Flag) + * API: FlagName + */ + @JsonProperty("FlagName") + private String flag; + + /** + * 깃발 국가 코드 + * API: FlagCode + */ + @JsonProperty("FlagCode") + private String flagCode; + + /** + * 깃발 유효 날짜 + * API: FlagEffectiveDate + */ + @JsonProperty("FlagEffectiveDate") + private String flagEffectiveDate; + + /** + * 총톤수 (Gross Tonnage) + * API: GrossTonnage + */ + @JsonProperty("GrossTonnage") + private String grossTonnage; + + /** + * 재화중량톤수 (Deadweight) + * API: Deadweight + */ + @JsonProperty("Deadweight") + private String deadweight; + + /** + * 선박 상태 + * API: ShipStatus + */ + @JsonProperty("ShipStatus") + private String status; + + /** + * Core Ship Indicator + * API: CoreShipInd + */ + @JsonProperty("CoreShipInd") + private String coreShipInd; + + /** + * 이전 선박명 + * API: ExName + */ + @JsonProperty("ExName") + private String exName; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java new file mode 100644 index 0000000..bf55357 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/entity/ShipDetailEntity.java @@ -0,0 +1,98 @@ +package com.snp.batch.jobs.shipdetail.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +/** + * 선박 상세 정보 Entity + * BaseEntity를 상속하여 감사 필드 자동 포함 + * + * JPA 어노테이션 사용 금지! + * 컬럼 매핑은 주석으로 명시 + * + * API에서 제공하는 필드만 저장: + * - IMO 번호, 선박명, 선박 타입, 깃발 정보, 톤수, 상태 + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipDetailEntity extends BaseEntity { + + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * IMO 번호 (비즈니스 키) + * 컬럼: imo_number (VARCHAR(20), UNIQUE, NOT NULL) + */ + private String imoNumber; + + /** + * 선박명 + * 컬럼: ship_name (VARCHAR(200)) + */ + private String shipName; + + /** + * 선박 타입 + * 컬럼: ship_type (VARCHAR(100)) + */ + private String shipType; + + /** + * 깃발 국가명 + * 컬럼: flag_name (VARCHAR(100)) + */ + private String flag; + + /** + * 깃발 국가 코드 + * 컬럼: flag_code (VARCHAR(10)) + */ + private String flagCode; + + /** + * 깃발 유효 날짜 + * 컬럼: flag_effective_date (VARCHAR(20)) + */ + private String flagEffectiveDate; + + /** + * 총톤수 (Gross Tonnage) + * 컬럼: gross_tonnage (VARCHAR(20)) + */ + private String grossTonnage; + + /** + * 재화중량톤수 (Deadweight) + * 컬럼: deadweight (VARCHAR(20)) + */ + private String deadweight; + + /** + * 선박 상태 + * 컬럼: ship_status (VARCHAR(100)) + */ + private String status; + + /** + * Core Ship Indicator + * 컬럼: core_ship_ind (VARCHAR(10)) + */ + private String coreShipInd; + + /** + * 이전 선박명 + * 컬럼: ex_name (VARCHAR(200)) + */ + private String exName; +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java new file mode 100644 index 0000000..e2cb12e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/processor/ShipDetailDataProcessor.java @@ -0,0 +1,41 @@ +package com.snp.batch.jobs.shipdetail.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto; +import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 선박 상세 정보 Processor + * ShipDetailDto → ShipDetailEntity 변환 + */ +@Slf4j +@Component +public class ShipDetailDataProcessor extends BaseProcessor { + + @Override + protected ShipDetailEntity processItem(ShipDetailDto dto) throws Exception { + log.debug("선박 상세 정보 처리 시작: imoNumber={}, shipName={}", + dto.getImoNumber(), dto.getShipName()); + + // DTO → Entity 변환 (API에서 제공하는 필드만) + ShipDetailEntity entity = ShipDetailEntity.builder() + .imoNumber(dto.getImoNumber()) + .shipName(dto.getShipName()) + .shipType(dto.getShipType()) + .flag(dto.getFlag()) + .flagCode(dto.getFlagCode()) + .flagEffectiveDate(dto.getFlagEffectiveDate()) + .grossTonnage(dto.getGrossTonnage()) + .deadweight(dto.getDeadweight()) + .status(dto.getStatus()) + .coreShipInd(dto.getCoreShipInd()) + .exName(dto.getExName()) + .build(); + + log.debug("선박 상세 정보 처리 완료: imoNumber={}", dto.getImoNumber()); + + return entity; + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java new file mode 100644 index 0000000..b0401e2 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/reader/ShipDetailDataReader.java @@ -0,0 +1,184 @@ +package com.snp.batch.jobs.shipdetail.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailApiResponse; +import com.snp.batch.jobs.shipdetail.batch.dto.ShipDetailDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.*; + +/** + * 선박 상세 정보 Reader (v2.0 - Chunk 기반) + * + * 기능: + * 1. ship_data 테이블에서 IMO 번호 전체 조회 (최초 1회) + * 2. IMO 번호를 100개씩 분할하여 배치 단위로 처리 + * 3. fetchNextBatch() 호출 시마다 100개씩 API 호출 + * 4. Spring Batch가 100건씩 Process → Write 수행 + * + * Chunk 처리 흐름: + * - beforeFetch() → IMO 전체 조회 (1회) + * - fetchNextBatch() → 100개 IMO로 API 호출 (1,718회) + * - read() → 1건씩 반환 (100번) + * - Processor/Writer → 100건 처리 + * - 반복... (1,718번의 Chunk) + * + * 기존 방식과의 차이: + * - 기존: 17만건 전체 메모리 로드 → Process → Write + * - 신규: 100건씩 로드 → Process → Write (Chunk 1,718회) + */ +@Slf4j +public class ShipDetailDataReader extends BaseApiReader { + + private final JdbcTemplate jdbcTemplate; + + // 배치 처리 상태 + private List allImoNumbers; + private int currentBatchIndex = 0; + private final int batchSize = 100; + + public ShipDetailDataReader(WebClient webClient, JdbcTemplate jdbcTemplate) { + super(webClient); + this.jdbcTemplate = jdbcTemplate; + enableChunkMode(); // ✨ Chunk 모드 활성화 + } + + @Override + protected String getReaderName() { + return "ShipDetailDataReader"; + } + + @Override + protected String getApiPath() { + return "/MaritimeWCF/APSShipService.svc/RESTFul/GetShipsByIHSLRorIMONumbers"; + } + + @Override + protected String getApiBaseUrl() { + return "https://shipsapi.maritime.spglobal.com"; + } + + /** + * 최초 1회만 실행: ship_data 테이블에서 IMO 번호 전체 조회 + */ + @Override + protected void beforeFetch() { + log.info("[{}] ship_data 테이블에서 IMO 번호 조회 시작...", getReaderName()); + + String sql = "SELECT imo_number FROM ship_data ORDER BY id"; + allImoNumbers = jdbcTemplate.queryForList(sql, String.class); + + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 총 {} 개의 IMO 번호 조회 완료", getReaderName(), allImoNumbers.size()); + log.info("[{}] {}개씩 배치로 분할하여 API 호출 예정", getReaderName(), batchSize); + log.info("[{}] 예상 배치 수: {} 개", getReaderName(), totalBatches); + + // API 통계 초기화 + updateApiCallStats(totalBatches, 0); + } + + /** + * ✨ Chunk 기반 핵심 메서드: 다음 100개 배치를 조회하여 반환 + * + * Spring Batch가 100건씩 read() 호출 완료 후 이 메서드 재호출 + * + * @return 다음 배치 100건 (더 이상 없으면 null) + */ + @Override + protected List fetchNextBatch() throws Exception { + // 모든 배치 처리 완료 확인 + if (allImoNumbers == null || currentBatchIndex >= allImoNumbers.size()) { + return null; // Job 종료 + } + + // 현재 배치의 시작/끝 인덱스 계산 + int startIndex = currentBatchIndex; + int endIndex = Math.min(currentBatchIndex + batchSize, allImoNumbers.size()); + + // 현재 배치의 IMO 번호 추출 (100개) + List currentBatch = allImoNumbers.subList(startIndex, endIndex); + + int currentBatchNumber = (currentBatchIndex / batchSize) + 1; + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + + log.info("[{}] 배치 {}/{} 처리 중 (IMO {} 개)...", + getReaderName(), currentBatchNumber, totalBatches, currentBatch.size()); + + try { + // IMO 번호를 쉼표로 연결 (예: "1000019,1000021,1000033,...") + String imoParam = String.join(",", currentBatch); + + // API 호출 + ShipDetailApiResponse response = callApiWithBatch(imoParam); + + // 다음 배치로 인덱스 이동 + currentBatchIndex = endIndex; + + // 응답 처리 + if (response != null && response.getShips() != null) { + List ships = response.getShips(); + log.info("[{}] 배치 {}/{} 완료: {} 건 조회", + getReaderName(), currentBatchNumber, totalBatches, ships.size()); + + // API 호출 통계 업데이트 + updateApiCallStats(totalBatches, currentBatchNumber); + + // API 과부하 방지 (다음 배치 전 0.5초 대기) + if (currentBatchIndex < allImoNumbers.size()) { + Thread.sleep(500); + } + + return ships; + } else { + log.warn("[{}] 배치 {}/{} 응답 없음", + getReaderName(), currentBatchNumber, totalBatches); + + // API 호출 통계 업데이트 (실패도 카운트) + updateApiCallStats(totalBatches, currentBatchNumber); + + return Collections.emptyList(); + } + + } catch (Exception e) { + log.error("[{}] 배치 {}/{} 처리 중 오류: {}", + getReaderName(), currentBatchNumber, totalBatches, e.getMessage(), e); + + // 오류 발생 시에도 다음 배치로 이동 (부분 실패 허용) + currentBatchIndex = endIndex; + + // 빈 리스트 반환 (Job 계속 진행) + return Collections.emptyList(); + } + } + + /** + * Query Parameter를 사용한 API 호출 + * + * @param imoNumbers 쉼표로 연결된 IMO 번호 (예: "1000019,1000021,...") + * @return API 응답 + */ + private ShipDetailApiResponse callApiWithBatch(String imoNumbers) { + String url = getApiPath() + "?ihslrOrImoNumbers=" + imoNumbers; + + log.debug("[{}] API 호출: {}", getReaderName(), url); + + return webClient.get() + .uri(url) + .retrieve() + .bodyToMono(ShipDetailApiResponse.class) + .block(); + } + + @Override + protected void afterFetch(List data) { + if (data == null) { + int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize); + log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches); + log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료", + getReaderName(), allImoNumbers.size()); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java new file mode 100644 index 0000000..3c95985 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepository.java @@ -0,0 +1,42 @@ +package com.snp.batch.jobs.shipdetail.batch.repository; + +import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; + +import java.util.List; +import java.util.Optional; + +/** + * 선박 상세 정보 Repository 인터페이스 + */ +public interface ShipDetailRepository { + + /** + * ID로 조회 + */ + Optional findById(Long id); + + /** + * IMO 번호로 조회 + */ + Optional findByImoNumber(String imoNumber); + + /** + * 전체 조회 + */ + List findAll(); + + /** + * 저장 (INSERT 또는 UPDATE) + */ + ShipDetailEntity save(ShipDetailEntity entity); + + /** + * 여러 건 저장 + */ + void saveAll(List entities); + + /** + * 삭제 + */ + void delete(Long id); +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java new file mode 100644 index 0000000..fa54cdd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/repository/ShipDetailRepositoryImpl.java @@ -0,0 +1,184 @@ +package com.snp.batch.jobs.shipdetail.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.List; +import java.util.Optional; + +/** + * 선박 상세 정보 Repository 구현체 + * BaseJdbcRepository를 상속하여 JDBC 기반 CRUD 구현 + */ +@Slf4j +@Repository("shipDetailRepository") +public class ShipDetailRepositoryImpl extends BaseJdbcRepository + implements ShipDetailRepository { + + public ShipDetailRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "ship_detail"; + } + + @Override + protected String getEntityName() { + return "ShipDetail"; + } + + @Override + protected Long extractId(ShipDetailEntity entity) { + return entity.getId(); + } + + @Override + protected String getInsertSql() { + return """ + INSERT INTO ship_detail ( + imo_number, ship_name, ship_type, + flag_name, flag_code, flag_effective_date, + gross_tonnage, deadweight, + ship_status, core_ship_ind, ex_name, + created_at, updated_at, created_by, updated_by + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (imo_number) + DO UPDATE SET + ship_name = EXCLUDED.ship_name, + ship_type = EXCLUDED.ship_type, + flag_name = EXCLUDED.flag_name, + flag_code = EXCLUDED.flag_code, + flag_effective_date = EXCLUDED.flag_effective_date, + gross_tonnage = EXCLUDED.gross_tonnage, + deadweight = EXCLUDED.deadweight, + ship_status = EXCLUDED.ship_status, + core_ship_ind = EXCLUDED.core_ship_ind, + ex_name = EXCLUDED.ex_name, + updated_at = EXCLUDED.updated_at, + updated_by = EXCLUDED.updated_by + """; + } + + @Override + protected String getUpdateSql() { + return """ + UPDATE ship_detail + SET ship_name = ?, ship_type = ?, + flag_name = ?, flag_code = ?, flag_effective_date = ?, + gross_tonnage = ?, deadweight = ?, + ship_status = ?, core_ship_ind = ?, ex_name = ?, + updated_at = ?, updated_by = ? + WHERE id = ? + """; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ShipDetailEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getImoNumber()); + ps.setString(idx++, entity.getShipName()); + ps.setString(idx++, entity.getShipType()); + ps.setString(idx++, entity.getFlag()); + ps.setString(idx++, entity.getFlagCode()); + ps.setString(idx++, entity.getFlagEffectiveDate()); + ps.setString(idx++, entity.getGrossTonnage()); + ps.setString(idx++, entity.getDeadweight()); + ps.setString(idx++, entity.getStatus()); + ps.setString(idx++, entity.getCoreShipInd()); + ps.setString(idx++, entity.getExName()); + + // 감사 필드 + ps.setTimestamp(idx++, entity.getCreatedAt() != null ? + Timestamp.valueOf(entity.getCreatedAt()) : Timestamp.valueOf(now())); + ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getCreatedBy() != null ? entity.getCreatedBy() : "SYSTEM"); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ShipDetailEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getShipName()); + ps.setString(idx++, entity.getShipType()); + ps.setString(idx++, entity.getFlag()); + ps.setString(idx++, entity.getFlagCode()); + ps.setString(idx++, entity.getFlagEffectiveDate()); + ps.setString(idx++, entity.getGrossTonnage()); + ps.setString(idx++, entity.getDeadweight()); + ps.setString(idx++, entity.getStatus()); + ps.setString(idx++, entity.getCoreShipInd()); + ps.setString(idx++, entity.getExName()); + ps.setTimestamp(idx++, Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + ps.setLong(idx++, entity.getId()); + } + + @Override + protected RowMapper getRowMapper() { + return new ShipDetailEntityRowMapper(); + } + + @Override + public Optional findByImoNumber(String imoNumber) { + String sql = "SELECT * FROM " + getTableName() + " WHERE imo_number = ?"; + List results = jdbcTemplate.query(sql, getRowMapper(), imoNumber); + return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0)); + } + + @Override + public void delete(Long id) { + String sql = "DELETE FROM " + getTableName() + " WHERE id = ?"; + jdbcTemplate.update(sql, id); + log.debug("[{}] 삭제 완료: id={}", getEntityName(), id); + } + + /** + * ShipDetailEntity RowMapper + */ + private static class ShipDetailEntityRowMapper implements RowMapper { + @Override + public ShipDetailEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + ShipDetailEntity entity = ShipDetailEntity.builder() + .id(rs.getLong("id")) + .imoNumber(rs.getString("imo_number")) + .shipName(rs.getString("ship_name")) + .shipType(rs.getString("ship_type")) + .flag(rs.getString("flag_name")) + .flagCode(rs.getString("flag_code")) + .flagEffectiveDate(rs.getString("flag_effective_date")) + .grossTonnage(rs.getString("gross_tonnage")) + .deadweight(rs.getString("deadweight")) + .status(rs.getString("ship_status")) + .coreShipInd(rs.getString("core_ship_ind")) + .exName(rs.getString("ex_name")) + .build(); + + // BaseEntity 필드 매핑 + Timestamp createdAt = rs.getTimestamp("created_at"); + if (createdAt != null) { + entity.setCreatedAt(createdAt.toLocalDateTime()); + } + + Timestamp updatedAt = rs.getTimestamp("updated_at"); + if (updatedAt != null) { + entity.setUpdatedAt(updatedAt.toLocalDateTime()); + } + + entity.setCreatedBy(rs.getString("created_by")); + entity.setUpdatedBy(rs.getString("updated_by")); + + return entity; + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipdetail/batch/writer/ShipDetailDataWriter.java b/src/main/java/com/snp/batch/jobs/shipdetail/batch/writer/ShipDetailDataWriter.java new file mode 100644 index 0000000..5eca87e --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipdetail/batch/writer/ShipDetailDataWriter.java @@ -0,0 +1,33 @@ +package com.snp.batch.jobs.shipdetail.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipdetail.batch.entity.ShipDetailEntity; +import com.snp.batch.jobs.shipdetail.batch.repository.ShipDetailRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 선박 상세 정보 Writer + */ +@Slf4j +@Component +public class ShipDetailDataWriter extends BaseWriter { + + private final ShipDetailRepository shipDetailRepository; + + public ShipDetailDataWriter(ShipDetailRepository shipDetailRepository) { + super("ShipDetail"); + this.shipDetailRepository = shipDetailRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + log.info("선박 상세 정보 데이터 저장: {} 건", items.size()); + + shipDetailRepository.saveAll(items); + + log.info("선박 상세 정보 데이터 저장 완료: {} 건", items.size()); + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/config/ShipImportJobConfig.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/config/ShipImportJobConfig.java new file mode 100644 index 0000000..dc704bd --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/config/ShipImportJobConfig.java @@ -0,0 +1,104 @@ +package com.snp.batch.jobs.shipimport.batch.config; + +import com.snp.batch.common.batch.config.BaseJobConfig; +import com.snp.batch.jobs.shipimport.batch.dto.ShipDto; +import com.snp.batch.jobs.shipimport.batch.entity.ShipEntity; +import com.snp.batch.jobs.shipimport.batch.processor.ShipDataProcessor; +import com.snp.batch.jobs.shipimport.batch.reader.ShipDataReader; +import com.snp.batch.jobs.shipimport.batch.repository.ShipRepository; +import com.snp.batch.jobs.shipimport.batch.writer.ShipDataWriter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.batch.item.ItemProcessor; +import org.springframework.batch.item.ItemReader; +import org.springframework.batch.item.ItemWriter; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.web.reactive.function.client.WebClient; + +/** + * Ship Data Import Job 설정 + * BaseJobConfig를 상속하여 구현 + * + * Maritime API에서 선박 데이터를 받아 PostgreSQL에 저장하는 배치 작업: + * - Maritime API에서 170,000+ 선박 IMO 번호 조회 + * - 중복 체크 및 업데이트 로직 + * - PostgreSQL에 저장 + */ +@Slf4j +@Configuration +public class ShipImportJobConfig extends BaseJobConfig { + + private final ShipRepository shipRepository; + private final WebClient maritimeApiWebClient; + + @Value("${app.batch.chunk-size:1000}") + private int chunkSize; + + /** + * 생성자 주입 + * maritimeApiWebClient: MaritimeApiWebClientConfig에서 등록한 Bean 주입 + */ + public ShipImportJobConfig( + JobRepository jobRepository, + PlatformTransactionManager transactionManager, + ShipRepository shipRepository, + @Qualifier("maritimeApiWebClient") WebClient maritimeApiWebClient) { + super(jobRepository, transactionManager); + this.shipRepository = shipRepository; + this.maritimeApiWebClient = maritimeApiWebClient; + } + + @Override + protected String getJobName() { + return "shipDataImportJob"; + } + + @Override + protected String getStepName() { + return "shipDataImportStep"; + } + + @Override + protected ItemReader createReader() { + return new ShipDataReader(maritimeApiWebClient); + } + + @Override + protected ItemProcessor createProcessor() { + return new ShipDataProcessor(shipRepository); + } + + @Override + protected ItemWriter createWriter() { + return new ShipDataWriter(shipRepository); + } + + @Override + protected int getChunkSize() { + return chunkSize; + } + + /** + * Job Bean 등록 + */ + @Bean(name = "shipDataImportJob") + public Job shipDataImportJob() { + return job(); + } + + /** + * Step Bean 등록 + */ + @Bean(name = "shipDataImportStep") + public Step shipDataImportStep() { + return step(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipApiResponse.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipApiResponse.java new file mode 100644 index 0000000..8e08c86 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipApiResponse.java @@ -0,0 +1,43 @@ +package com.snp.batch.jobs.shipimport.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ShipApiResponse { + + @JsonProperty("shipCount") + private Integer shipCount; + + @JsonProperty("Ships") + private List ships; + + @JsonProperty("APSStatus") + private APSStatus apsStatus; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class APSStatus { + @JsonProperty("SystemVersion") + private String systemVersion; + + @JsonProperty("SystemDate") + private String systemDate; + + @JsonProperty("JobRunDate") + private String jobRunDate; + + @JsonProperty("CompletedOK") + private Boolean completedOK; + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipDto.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipDto.java new file mode 100644 index 0000000..4660379 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/dto/ShipDto.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.shipimport.batch.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ShipDto { + + @JsonProperty("DataSetVersion") + private DataSetVersion dataSetVersion; + + @JsonProperty("CoreShipInd") + private String coreShipInd; + + @JsonProperty("IHSLRorIMOShipNo") + private String imoNumber; + + @Data + @NoArgsConstructor + @AllArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class DataSetVersion { + @JsonProperty("DataSetVersion") + private String version; + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/entity/ShipEntity.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/entity/ShipEntity.java new file mode 100644 index 0000000..78a1d2c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/entity/ShipEntity.java @@ -0,0 +1,55 @@ +package com.snp.batch.jobs.shipimport.batch.entity; + +import com.snp.batch.common.batch.entity.BaseEntity; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDateTime; + +/** + * 선박 엔티티 - JDBC 전용 + * Maritime API 데이터 저장 + * + * 테이블: ship_data + */ +@Data +@SuperBuilder +@NoArgsConstructor +@AllArgsConstructor +@EqualsAndHashCode(callSuper = true) +public class ShipEntity extends BaseEntity { + + /** + * 기본 키 (자동 생성) + * 컬럼: id (BIGSERIAL) + */ + private Long id; + + /** + * IMO 번호 (선박 고유 식별번호) + * 컬럼: imo_number (VARCHAR(20), UNIQUE, NOT NULL) + * 인덱스: idx_imo_number (UNIQUE) + */ + private String imoNumber; + + /** + * Core Ship 여부 + * 컬럼: core_ship_ind (VARCHAR(10)) + */ + private String coreShipInd; + + /** + * 데이터셋 버전 + * 컬럼: dataset_version (VARCHAR(20)) + */ + private String datasetVersion; + + /** + * Import 일시 + * 컬럼: import_date (TIMESTAMP) + */ + private LocalDateTime importDate; +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java new file mode 100644 index 0000000..c5af094 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/processor/ShipDataProcessor.java @@ -0,0 +1,61 @@ +package com.snp.batch.jobs.shipimport.batch.processor; + +import com.snp.batch.common.batch.processor.BaseProcessor; +import com.snp.batch.jobs.shipimport.batch.dto.ShipDto; +import com.snp.batch.jobs.shipimport.batch.entity.ShipEntity; +import com.snp.batch.jobs.shipimport.batch.repository.ShipRepository; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; + +/** + * ShipDto를 ShipEntity로 변환하는 Processor + * BaseProcessor를 상속하여 공통 변환 패턴 적용 + * 중복 체크 및 업데이트 로직 포함 + */ +@Slf4j +public class ShipDataProcessor extends BaseProcessor { + + private final ShipRepository shipRepository; + + public ShipDataProcessor(ShipRepository shipRepository) { + this.shipRepository = shipRepository; + } + + @Override + protected ShipEntity processItem(ShipDto item) throws Exception { + if (item.getImoNumber() == null || item.getImoNumber().trim().isEmpty()) { + log.warn("IMO 번호가 없는 선박 데이터 스킵"); + return null; // 스킵 + } + + log.debug("선박 데이터 처리 중: IMO {}", item.getImoNumber()); + + // 중복 체크 및 업데이트 + return shipRepository.findByImoNumber(item.getImoNumber()) + .map(existingShip -> { + // 기존 데이터 업데이트 + existingShip.setCoreShipInd(item.getCoreShipInd()); + existingShip.setDatasetVersion( + item.getDataSetVersion() != null ? + item.getDataSetVersion().getVersion() : null + ); + existingShip.setImportDate(LocalDateTime.now()); + log.debug("기존 선박 업데이트: IMO {}", item.getImoNumber()); + return existingShip; + }) + .orElseGet(() -> { + // 신규 데이터 생성 + log.debug("신규 선박 추가: IMO {}", item.getImoNumber()); + return ShipEntity.builder() + .imoNumber(item.getImoNumber()) + .coreShipInd(item.getCoreShipInd()) + .datasetVersion( + item.getDataSetVersion() != null ? + item.getDataSetVersion().getVersion() : null + ) + .importDate(LocalDateTime.now()) + .build(); + }); + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/reader/ShipDataReader.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/reader/ShipDataReader.java new file mode 100644 index 0000000..edee4aa --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/reader/ShipDataReader.java @@ -0,0 +1,62 @@ +package com.snp.batch.jobs.shipimport.batch.reader; + +import com.snp.batch.common.batch.reader.BaseApiReader; +import com.snp.batch.jobs.shipimport.batch.dto.ShipApiResponse; +import com.snp.batch.jobs.shipimport.batch.dto.ShipDto; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.List; + +/** + * Maritime API에서 선박 데이터를 읽어오는 ItemReader + * BaseApiReader v2.0을 상속하여 공통 API 호출 패턴 적용 + */ +@Slf4j +public class ShipDataReader extends BaseApiReader { + + public ShipDataReader(WebClient webClient) { + super(webClient); // BaseApiReader에 WebClient 전달 + } + + // ======================================== + // 필수 구현 메서드 + // ======================================== + + @Override + protected String getReaderName() { + return "ShipDataReader"; + } + + @Override + protected List fetchDataFromApi() { + try { + log.info("선박 API 호출 시작"); + + ShipApiResponse response = webClient + .get() + .uri(uriBuilder -> uriBuilder + .path("/MaritimeWCF/APSShipService.svc/RESTFul/GetAllIMONumbers") + .queryParam("includeDeadShips", "0") + .build()) + .retrieve() + .bodyToMono(ShipApiResponse.class) + .block(); + + if (response != null && response.getShips() != null) { + log.info("API 응답 성공: 총 {} 척의 선박 데이터 수신", response.getShipCount()); + log.info("실제 데이터 건수: {} 건", response.getShips().size()); + return response.getShips(); + } else { + log.warn("API 응답이 null이거나 선박 데이터가 없습니다"); + return new ArrayList<>(); + } + + } catch (Exception e) { + log.error("선박 데이터 API 호출 실패", e); + log.error("에러 메시지: {}", e.getMessage()); + return new ArrayList<>(); + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepository.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepository.java new file mode 100644 index 0000000..0120292 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepository.java @@ -0,0 +1,34 @@ +package com.snp.batch.jobs.shipimport.batch.repository; + +import com.snp.batch.jobs.shipimport.batch.entity.ShipEntity; + +import java.util.List; +import java.util.Optional; + +/** + * ShipEntity Repository 인터페이스 + * 구현체: ShipRepositoryImpl (JdbcTemplate 기반) + */ +public interface ShipRepository { + + // CRUD 메서드 + Optional findById(Long id); + List findAll(); + long count(); + boolean existsById(Long id); + ShipEntity save(ShipEntity entity); + void saveAll(List entities); + void deleteById(Long id); + void deleteAll(); + + // 커스텀 메서드 + /** + * IMO 번호로 선박 조회 + */ + Optional findByImoNumber(String imoNumber); + + /** + * IMO 번호 존재 여부 확인 + */ + boolean existsByImoNumber(String imoNumber); +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java new file mode 100644 index 0000000..0684917 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/repository/ShipRepositoryImpl.java @@ -0,0 +1,152 @@ +package com.snp.batch.jobs.shipimport.batch.repository; + +import com.snp.batch.common.batch.repository.BaseJdbcRepository; +import com.snp.batch.jobs.shipimport.batch.entity.ShipEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.util.Optional; + +/** + * ShipEntity Repository (JdbcTemplate 기반) + */ +@Slf4j +@Repository("shipRepository") +public class ShipRepositoryImpl extends BaseJdbcRepository implements ShipRepository { + + public ShipRepositoryImpl(JdbcTemplate jdbcTemplate) { + super(jdbcTemplate); + } + + @Override + protected String getTableName() { + return "ship_data"; + } + + @Override + protected String getEntityName() { + return "Ship"; + } + + @Override + protected RowMapper getRowMapper() { + return new ShipEntityRowMapper(); + } + + @Override + protected Long extractId(ShipEntity entity) { + return entity.getId(); + } + + @Override + protected String getInsertSql() { + return """ + INSERT INTO ship_data (imo_number, core_ship_ind, dataset_version, import_date, created_at, updated_at, created_by, updated_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """; + } + + @Override + protected String getUpdateSql() { + return """ + UPDATE ship_data + SET core_ship_ind = ?, + dataset_version = ?, + import_date = ?, + updated_at = ?, + updated_by = ? + WHERE id = ? + """; + } + + @Override + protected void setInsertParameters(PreparedStatement ps, ShipEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getImoNumber()); + ps.setString(idx++, entity.getCoreShipInd()); + ps.setString(idx++, entity.getDatasetVersion()); + ps.setTimestamp(idx++, entity.getImportDate() != null ? + Timestamp.valueOf(entity.getImportDate()) : Timestamp.valueOf(now())); + ps.setTimestamp(idx++, entity.getCreatedAt() != null ? + Timestamp.valueOf(entity.getCreatedAt()) : Timestamp.valueOf(now())); + ps.setTimestamp(idx++, entity.getUpdatedAt() != null ? + Timestamp.valueOf(entity.getUpdatedAt()) : Timestamp.valueOf(now())); + ps.setString(idx++, entity.getCreatedBy() != null ? entity.getCreatedBy() : "SYSTEM"); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + } + + @Override + protected void setUpdateParameters(PreparedStatement ps, ShipEntity entity) throws Exception { + int idx = 1; + ps.setString(idx++, entity.getCoreShipInd()); + ps.setString(idx++, entity.getDatasetVersion()); + ps.setTimestamp(idx++, entity.getImportDate() != null ? + Timestamp.valueOf(entity.getImportDate()) : Timestamp.valueOf(now())); + ps.setTimestamp(idx++, Timestamp.valueOf(now())); + ps.setString(idx++, entity.getUpdatedBy() != null ? entity.getUpdatedBy() : "SYSTEM"); + ps.setLong(idx++, entity.getId()); + } + + // ==================== 커스텀 쿼리 메서드 ==================== + + /** + * IMO 번호로 선박 조회 + */ + @Override + public Optional findByImoNumber(String imoNumber) { + String sql = "SELECT * FROM ship_data WHERE imo_number = ?"; + return executeQueryForObject(sql, imoNumber); + } + + /** + * IMO 번호 존재 여부 확인 + */ + @Override + public boolean existsByImoNumber(String imoNumber) { + String sql = "SELECT COUNT(*) FROM ship_data WHERE imo_number = ?"; + Long count = jdbcTemplate.queryForObject(sql, Long.class, imoNumber); + return count != null && count > 0; + } + + // ==================== RowMapper ==================== + + private static class ShipEntityRowMapper implements RowMapper { + @Override + public ShipEntity mapRow(ResultSet rs, int rowNum) throws SQLException { + ShipEntity entity = ShipEntity.builder() + .id(rs.getLong("id")) + .imoNumber(rs.getString("imo_number")) + .coreShipInd(rs.getString("core_ship_ind")) + .datasetVersion(rs.getString("dataset_version")) + .build(); + + // import_date 매핑 + Timestamp importDate = rs.getTimestamp("import_date"); + if (importDate != null) { + entity.setImportDate(importDate.toLocalDateTime()); + } + + // BaseEntity 필드 매핑 + Timestamp createdAt = rs.getTimestamp("created_at"); + if (createdAt != null) { + entity.setCreatedAt(createdAt.toLocalDateTime()); + } + + Timestamp updatedAt = rs.getTimestamp("updated_at"); + if (updatedAt != null) { + entity.setUpdatedAt(updatedAt.toLocalDateTime()); + } + + entity.setCreatedBy(rs.getString("created_by")); + entity.setUpdatedBy(rs.getString("updated_by")); + + return entity; + } + } +} diff --git a/src/main/java/com/snp/batch/jobs/shipimport/batch/writer/ShipDataWriter.java b/src/main/java/com/snp/batch/jobs/shipimport/batch/writer/ShipDataWriter.java new file mode 100644 index 0000000..a702c9c --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/shipimport/batch/writer/ShipDataWriter.java @@ -0,0 +1,28 @@ +package com.snp.batch.jobs.shipimport.batch.writer; + +import com.snp.batch.common.batch.writer.BaseWriter; +import com.snp.batch.jobs.shipimport.batch.entity.ShipEntity; +import com.snp.batch.jobs.shipimport.batch.repository.ShipRepository; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +/** + * ShipEntity를 DB에 저장하는 ItemWriter + * BaseWriter를 상속하여 공통 저장 패턴 적용 + */ +@Slf4j +public class ShipDataWriter extends BaseWriter { + + private final ShipRepository shipRepository; + + public ShipDataWriter(ShipRepository shipRepository) { + super("Ship"); + this.shipRepository = shipRepository; + } + + @Override + protected void writeItems(List items) throws Exception { + shipRepository.saveAll(items); + } +} diff --git a/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java b/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java new file mode 100644 index 0000000..59c6418 --- /dev/null +++ b/src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java @@ -0,0 +1,50 @@ +package com.snp.batch.scheduler; + +import com.snp.batch.service.QuartzJobService; +import lombok.extern.slf4j.Slf4j; +import org.quartz.Job; +import org.quartz.JobExecutionContext; +import org.quartz.JobExecutionException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * Quartz Job 구현체 + * Quartz 스케줄러에 의해 실행되어 실제 Spring Batch Job을 호출 + */ +@Slf4j +@Component +public class QuartzBatchJob implements Job { + + @Autowired + private QuartzJobService quartzJobService; + + /** + * Quartz 스케줄러에 의해 호출되는 메서드 + * + * @param context JobExecutionContext + * @throws JobExecutionException 실행 중 발생한 예외 + */ + @Override + public void execute(JobExecutionContext context) throws JobExecutionException { + // JobDataMap에서 배치 작업 이름 가져오기 + String jobName = context.getJobDetail().getJobDataMap().getString("jobName"); + + log.info("========================================"); + log.info("Quartz 스케줄러 트리거 발생"); + log.info("실행할 배치 작업: {}", jobName); + log.info("트리거 시간: {}", context.getFireTime()); + log.info("다음 실행 시간: {}", context.getNextFireTime()); + log.info("========================================"); + + try { + // QuartzJobService를 통해 실제 Spring Batch Job 실행 + quartzJobService.executeBatchJob(jobName); + + } catch (Exception e) { + log.error("Quartz Job 실행 중 에러 발생", e); + // JobExecutionException으로 래핑하여 Quartz에 에러 전파 + throw new JobExecutionException("Failed to execute batch job: " + jobName, e); + } + } +} diff --git a/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java b/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java new file mode 100644 index 0000000..0fc863f --- /dev/null +++ b/src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java @@ -0,0 +1,120 @@ +package com.snp.batch.scheduler; + +import com.snp.batch.global.model.JobScheduleEntity; +import com.snp.batch.global.repository.JobScheduleRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * 애플리케이션 시작 시 DB에 저장된 스케줄을 Quartz에 자동 로드 + * ApplicationReadyEvent를 수신하여 모든 빈 초기화 후 실행 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class SchedulerInitializer { + + private final JobScheduleRepository scheduleRepository; + private final Scheduler scheduler; + + /** + * 애플리케이션 준비 완료 시 호출 + * DB의 활성화된 스케줄을 Quartz에 로드 + */ + @EventListener(ApplicationReadyEvent.class) + public void initializeSchedules() { + log.info("========================================"); + log.info("스케줄러 초기화 시작"); + log.info("========================================"); + + try { + // DB에서 활성화된 스케줄 조회 + List activeSchedules = scheduleRepository.findAllActive(); + + if (activeSchedules.isEmpty()) { + log.info("활성화된 스케줄이 없습니다."); + return; + } + + log.info("총 {}개의 활성 스케줄을 로드합니다.", activeSchedules.size()); + + int successCount = 0; + int failCount = 0; + + // 각 스케줄을 Quartz에 등록 + for (JobScheduleEntity schedule : activeSchedules) { + try { + registerSchedule(schedule); + successCount++; + log.info("✓ 스케줄 로드 성공: {} (Cron: {})", + schedule.getJobName(), schedule.getCronExpression()); + + } catch (Exception e) { + failCount++; + log.error("✗ 스케줄 로드 실패: {}", schedule.getJobName(), e); + } + } + + log.info("========================================"); + log.info("스케줄러 초기화 완료"); + log.info("성공: {}개, 실패: {}개", successCount, failCount); + log.info("========================================"); + + // Quartz 스케줄러 시작 + if (!scheduler.isStarted()) { + scheduler.start(); + log.info("Quartz 스케줄러 시작됨"); + } + + } catch (Exception e) { + log.error("스케줄러 초기화 중 에러 발생", e); + } + } + + /** + * 개별 스케줄을 Quartz에 등록 + * + * @param schedule JobScheduleEntity + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void registerSchedule(JobScheduleEntity schedule) throws SchedulerException { + String jobName = schedule.getJobName(); + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + TriggerKey triggerKey = new TriggerKey(jobName + "-trigger", "batch-triggers"); + + // 기존 스케줄 확인 및 삭제 + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + log.debug("기존 Quartz Job 삭제: {}", jobName); + } + + // JobDetail 생성 + JobDetail jobDetail = JobBuilder.newJob(QuartzBatchJob.class) + .withIdentity(jobKey) + .usingJobData("jobName", jobName) + .withDescription(schedule.getDescription()) + .storeDurably(true) + .build(); + + // CronTrigger 생성 + CronTrigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(schedule.getCronExpression())) + .forJob(jobKey) + .build(); + + // Quartz에 스케줄 등록 + scheduler.scheduleJob(jobDetail, trigger); + + // 다음 실행 시간 로깅 + if (trigger.getNextFireTime() != null) { + log.debug(" → 다음 실행 예정: {}", trigger.getNextFireTime()); + } + } +} diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java new file mode 100644 index 0000000..f651b08 --- /dev/null +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -0,0 +1,566 @@ +package com.snp.batch.service; + +import com.snp.batch.global.dto.JobExecutionDto; +import com.snp.batch.global.repository.TimelineRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobInstance; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.batch.core.launch.JobOperator; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class BatchService { + + private final JobLauncher jobLauncher; + private final JobExplorer jobExplorer; + private final JobOperator jobOperator; + private final Map jobMap; + private final ScheduleService scheduleService; + private final TimelineRepository timelineRepository; + + @Autowired + public BatchService(JobLauncher jobLauncher, + JobExplorer jobExplorer, + JobOperator jobOperator, + Map jobMap, + @Lazy ScheduleService scheduleService, + TimelineRepository timelineRepository) { + this.jobLauncher = jobLauncher; + this.jobExplorer = jobExplorer; + this.jobOperator = jobOperator; + this.jobMap = jobMap; + this.scheduleService = scheduleService; + this.timelineRepository = timelineRepository; + } + + public Long executeJob(String jobName) throws Exception { + return executeJob(jobName, null); + } + + public Long executeJob(String jobName, Map params) throws Exception { + Job job = jobMap.get(jobName); + if (job == null) { + throw new IllegalArgumentException("Job not found: " + jobName); + } + + JobParametersBuilder builder = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()); + + // 동적 파라미터 추가 + if (params != null && !params.isEmpty()) { + params.forEach((key, value) -> { + // timestamp는 자동 생성되므로 무시 + if (!"timestamp".equals(key)) { + builder.addString(key, value); + } + }); + } + + JobParameters jobParameters = builder.toJobParameters(); + JobExecution jobExecution = jobLauncher.run(job, jobParameters); + return jobExecution.getId(); + } + + public List listAllJobs() { + return new ArrayList<>(jobMap.keySet()); + } + + public List getJobExecutions(String jobName) { + List jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 100); + + return jobInstances.stream() + .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) + .map(this::convertToDto) + .sorted(Comparator.comparing(JobExecutionDto::getExecutionId).reversed()) + .collect(Collectors.toList()); + } + + public JobExecutionDto getExecutionDetails(Long executionId) { + JobExecution jobExecution = jobExplorer.getJobExecution(executionId); + if (jobExecution == null) { + throw new IllegalArgumentException("Job execution not found: " + executionId); + } + return convertToDto(jobExecution); + } + + public com.snp.batch.global.dto.JobExecutionDetailDto getExecutionDetailWithSteps(Long executionId) { + JobExecution jobExecution = jobExplorer.getJobExecution(executionId); + if (jobExecution == null) { + throw new IllegalArgumentException("Job execution not found: " + executionId); + } + return convertToDetailDto(jobExecution); + } + + public void stopExecution(Long executionId) throws Exception { + jobOperator.stop(executionId); + } + + + private JobExecutionDto convertToDto(JobExecution jobExecution) { + return JobExecutionDto.builder() + .executionId(jobExecution.getId()) + .jobName(jobExecution.getJobInstance().getJobName()) + .status(jobExecution.getStatus().name()) + .startTime(jobExecution.getStartTime()) + .endTime(jobExecution.getEndTime()) + .exitCode(jobExecution.getExitStatus().getExitCode()) + .exitMessage(jobExecution.getExitStatus().getExitDescription()) + .build(); + } + + private com.snp.batch.global.dto.JobExecutionDetailDto convertToDetailDto(JobExecution jobExecution) { + // 실행 시간 계산 + Long duration = null; + if (jobExecution.getStartTime() != null && jobExecution.getEndTime() != null) { + duration = java.time.Duration.between( + jobExecution.getStartTime(), + jobExecution.getEndTime() + ).toMillis(); + } + + // Job Parameters 변환 (timestamp는 포맷팅) + Map params = new java.util.LinkedHashMap<>(); + jobExecution.getJobParameters().getParameters().forEach((key, value) -> { + Object paramValue = value.getValue(); + + // timestamp 파라미터는 포맷팅된 문자열도 함께 표시 + if ("timestamp".equals(key) && paramValue instanceof Long) { + Long timestamp = (Long) paramValue; + java.time.LocalDateTime dateTime = java.time.LocalDateTime.ofInstant( + java.time.Instant.ofEpochMilli(timestamp), + java.time.ZoneId.systemDefault() + ); + String formatted = dateTime.format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + params.put(key, timestamp + " (" + formatted + ")"); + } else { + params.put(key, paramValue); + } + }); + + // Step Executions 변환 + List stepDtos = + jobExecution.getStepExecutions().stream() + .map(this::convertStepToDto) + .collect(Collectors.toList()); + + // 전체 통계 계산 + int totalReadCount = stepDtos.stream().mapToInt(s -> s.getReadCount() != null ? s.getReadCount() : 0).sum(); + int totalWriteCount = stepDtos.stream().mapToInt(s -> s.getWriteCount() != null ? s.getWriteCount() : 0).sum(); + int totalSkipCount = stepDtos.stream().mapToInt(s -> + (s.getReadSkipCount() != null ? s.getReadSkipCount() : 0) + + (s.getProcessSkipCount() != null ? s.getProcessSkipCount() : 0) + + (s.getWriteSkipCount() != null ? s.getWriteSkipCount() : 0) + ).sum(); + int totalFilterCount = stepDtos.stream().mapToInt(s -> s.getFilterCount() != null ? s.getFilterCount() : 0).sum(); + + return com.snp.batch.global.dto.JobExecutionDetailDto.builder() + .executionId(jobExecution.getId()) + .jobName(jobExecution.getJobInstance().getJobName()) + .status(jobExecution.getStatus().name()) + .startTime(jobExecution.getStartTime()) + .endTime(jobExecution.getEndTime()) + .exitCode(jobExecution.getExitStatus().getExitCode()) + .exitMessage(jobExecution.getExitStatus().getExitDescription()) + .jobParameters(params) + .jobInstanceId(jobExecution.getJobInstance().getInstanceId()) + .duration(duration) + .readCount(totalReadCount) + .writeCount(totalWriteCount) + .skipCount(totalSkipCount) + .filterCount(totalFilterCount) + .stepExecutions(stepDtos) + .build(); + } + + private com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto convertStepToDto( + org.springframework.batch.core.StepExecution stepExecution) { + + Long duration = null; + if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) { + duration = java.time.Duration.between( + stepExecution.getStartTime(), + stepExecution.getEndTime() + ).toMillis(); + } + + // StepExecutionContext에서 API 정보 추출 + com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo apiCallInfo = extractApiCallInfo(stepExecution); + + return com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto.builder() + .stepExecutionId(stepExecution.getId()) + .stepName(stepExecution.getStepName()) + .status(stepExecution.getStatus().name()) + .startTime(stepExecution.getStartTime()) + .endTime(stepExecution.getEndTime()) + .readCount((int) stepExecution.getReadCount()) + .writeCount((int) stepExecution.getWriteCount()) + .commitCount((int) stepExecution.getCommitCount()) + .rollbackCount((int) stepExecution.getRollbackCount()) + .readSkipCount((int) stepExecution.getReadSkipCount()) + .processSkipCount((int) stepExecution.getProcessSkipCount()) + .writeSkipCount((int) stepExecution.getWriteSkipCount()) + .filterCount((int) stepExecution.getFilterCount()) + .exitCode(stepExecution.getExitStatus().getExitCode()) + .exitMessage(stepExecution.getExitStatus().getExitDescription()) + .duration(duration) + .apiCallInfo(apiCallInfo) // API 정보 추가 + .build(); + } + + /** + * StepExecutionContext에서 API 호출 정보 추출 + * + * @param stepExecution Step 실행 정보 + * @return API 호출 정보 (없으면 null) + */ + private com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo extractApiCallInfo( + org.springframework.batch.core.StepExecution stepExecution) { + + org.springframework.batch.item.ExecutionContext context = stepExecution.getExecutionContext(); + + // API URL이 없으면 API를 사용하지 않는 Step + if (!context.containsKey("apiUrl")) { + return null; + } + + // API 정보 추출 + String apiUrl = context.getString("apiUrl"); + String method = context.getString("apiMethod", "GET"); + Integer totalCalls = context.getInt("totalApiCalls", 0); + Integer completedCalls = context.getInt("completedApiCalls", 0); + String lastCallTime = context.getString("lastCallTime", ""); + + // API Parameters 추출 + Map parameters = null; + if (context.containsKey("apiParameters")) { + Object paramsObj = context.get("apiParameters"); + if (paramsObj instanceof Map) { + parameters = (Map) paramsObj; + } + } + + return com.snp.batch.global.dto.JobExecutionDetailDto.ApiCallInfo.builder() + .apiUrl(apiUrl) + .method(method) + .parameters(parameters) + .totalCalls(totalCalls) + .completedCalls(completedCalls) + .lastCallTime(lastCallTime) + .build(); + } + + public com.snp.batch.global.dto.TimelineResponse getTimeline(String view, String dateStr) { + try { + java.time.LocalDate date = java.time.LocalDate.parse(dateStr.substring(0, 10)); + java.util.List periods = new ArrayList<>(); + String periodLabel = ""; + + // 조회 범위 설정 + java.time.LocalDateTime rangeStart; + java.time.LocalDateTime rangeEnd; + + if ("day".equals(view)) { + // 일별: 24시간 + periodLabel = date.format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월 dd일")); + rangeStart = date.atStartOfDay(); + rangeEnd = rangeStart.plusDays(1); + + for (int hour = 0; hour < 24; hour++) { + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(date.toString() + "-" + String.format("%02d", hour)) + .label(String.format("%02d:00", hour)) + .build()); + } + } else if ("week".equals(view)) { + // 주별: 7일 + java.time.LocalDate startOfWeek = date.with(java.time.DayOfWeek.MONDAY); + java.time.LocalDate endOfWeek = startOfWeek.plusDays(6); + periodLabel = String.format("%s ~ %s", + startOfWeek.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd")), + endOfWeek.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd"))); + + rangeStart = startOfWeek.atStartOfDay(); + rangeEnd = endOfWeek.plusDays(1).atStartOfDay(); + + for (int day = 0; day < 7; day++) { + java.time.LocalDate current = startOfWeek.plusDays(day); + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(current.toString()) + .label(current.format(java.time.format.DateTimeFormatter.ofPattern("MM/dd (E)", java.util.Locale.KOREAN))) + .build()); + } + } else if ("month".equals(view)) { + // 월별: 해당 월의 모든 날 + java.time.YearMonth yearMonth = java.time.YearMonth.from(date); + periodLabel = date.format(java.time.format.DateTimeFormatter.ofPattern("yyyy년 MM월")); + + rangeStart = yearMonth.atDay(1).atStartOfDay(); + rangeEnd = yearMonth.atEndOfMonth().plusDays(1).atStartOfDay(); + + for (int day = 1; day <= yearMonth.lengthOfMonth(); day++) { + java.time.LocalDate current = yearMonth.atDay(day); + periods.add(com.snp.batch.global.dto.TimelineResponse.PeriodInfo.builder() + .key(current.toString()) + .label(String.format("%d일", day)) + .build()); + } + } else { + throw new IllegalArgumentException("Invalid view type: " + view); + } + + // 활성 스케줄 조회 + java.util.List activeSchedules = scheduleService.getAllActiveSchedules(); + Map scheduleMap = activeSchedules.stream() + .collect(Collectors.toMap( + com.snp.batch.global.dto.ScheduleResponse::getJobName, + s -> s + )); + + // 모든 Job의 실행 이력을 한 번의 쿼리로 조회 (경량화) + List> allExecutions = timelineRepository.findAllExecutionsByDateRange(rangeStart, rangeEnd); + + // Job별로 그룹화 + Map>> executionsByJob = allExecutions.stream() + .collect(Collectors.groupingBy(exec -> (String) exec.get("jobName"))); + + // 타임라인 스케줄 구성 + java.util.List schedules = new ArrayList<>(); + + // 실행 이력이 있거나 스케줄이 있는 모든 Job 처리 + Set allJobNames = new HashSet<>(executionsByJob.keySet()); + allJobNames.addAll(scheduleMap.keySet()); + + for (String jobName : allJobNames) { + if (!jobMap.containsKey(jobName)) { + continue; // 현재 존재하지 않는 Job은 스킵 + } + + List> jobExecutions = executionsByJob.getOrDefault(jobName, Collections.emptyList()); + Map executions = new HashMap<>(); + + // 각 period에 대해 실행 이력 또는 예정 상태 매핑 + for (com.snp.batch.global.dto.TimelineResponse.PeriodInfo period : periods) { + Map matchedExecution = findExecutionForPeriodFromMap(jobExecutions, period, view); + + if (matchedExecution != null) { + // 과거 실행 이력이 있는 경우 + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) matchedExecution.get("startTime"); + java.sql.Timestamp endTimestamp = (java.sql.Timestamp) matchedExecution.get("endTime"); + + executions.put(period.getKey(), com.snp.batch.global.dto.TimelineResponse.ExecutionInfo.builder() + .executionId(((Number) matchedExecution.get("executionId")).longValue()) + .status((String) matchedExecution.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime().toString() : null) + .endTime(endTimestamp != null ? endTimestamp.toLocalDateTime().toString() : null) + .build()); + } else if (scheduleMap.containsKey(jobName)) { + // 스케줄이 있고, 실행 이력이 없는 경우 - 미래 예정 시간 체크 + com.snp.batch.global.dto.ScheduleResponse schedule = scheduleMap.get(jobName); + if (isScheduledForPeriod(schedule, period, view)) { + executions.put(period.getKey(), com.snp.batch.global.dto.TimelineResponse.ExecutionInfo.builder() + .status("SCHEDULED") + .startTime(null) + .endTime(null) + .build()); + } + } + } + + if (!executions.isEmpty()) { + schedules.add(com.snp.batch.global.dto.TimelineResponse.ScheduleTimeline.builder() + .jobName(jobName) + .executions(executions) + .build()); + } + } + + return com.snp.batch.global.dto.TimelineResponse.builder() + .periodLabel(periodLabel) + .periods(periods) + .schedules(schedules) + .build(); + + } catch (Exception e) { + log.error("Error generating timeline", e); + throw new RuntimeException("Failed to generate timeline", e); + } + } + + /** + * Map 기반 실행 이력에서 특정 Period에 해당하는 실행 찾기 + */ + private Map findExecutionForPeriodFromMap( + List> executions, + com.snp.batch.global.dto.TimelineResponse.PeriodInfo period, + String view) { + + return executions.stream() + .filter(exec -> exec.get("startTime") != null) + .filter(exec -> { + java.sql.Timestamp timestamp = (java.sql.Timestamp) exec.get("startTime"); + java.time.LocalDateTime startTime = timestamp.toLocalDateTime(); + String periodKey = period.getKey(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + + return startTime.toLocalDate().equals(periodDate) && + startTime.getHour() == hour; + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + return startTime.toLocalDate().equals(periodDate); + } + }) + .max(Comparator.comparing(exec -> ((java.sql.Timestamp) exec.get("startTime")).toLocalDateTime())) + .orElse(null); + } + + private boolean isJobScheduled(String jobName) { + // 스케줄이 있는지 확인 + try { + scheduleService.getScheduleByJobName(jobName); + return true; + } catch (Exception e) { + return false; + } + } + + private boolean isScheduledForPeriod(com.snp.batch.global.dto.ScheduleResponse schedule, + com.snp.batch.global.dto.TimelineResponse.PeriodInfo period, + String view) { + if (schedule.getNextFireTime() == null) { + return false; + } + + java.time.LocalDateTime nextFireTime = schedule.getNextFireTime() + .toInstant() + .atZone(java.time.ZoneId.systemDefault()) + .toLocalDateTime(); + + String periodKey = period.getKey(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + java.time.LocalDateTime periodStart = periodDate.atTime(hour, 0); + java.time.LocalDateTime periodEnd = periodStart.plusHours(1); + + return !nextFireTime.isBefore(periodStart) && nextFireTime.isBefore(periodEnd); + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + java.time.LocalDateTime periodStart = periodDate.atStartOfDay(); + java.time.LocalDateTime periodEnd = periodStart.plusDays(1); + + return !nextFireTime.isBefore(periodStart) && nextFireTime.isBefore(periodEnd); + } + } + + public List getPeriodExecutions(String jobName, String view, String periodKey) { + List jobInstances = jobExplorer.findJobInstancesByJobName(jobName, 0, 1000); + + return jobInstances.stream() + .flatMap(instance -> jobExplorer.getJobExecutions(instance).stream()) + .filter(exec -> exec.getStartTime() != null) + .filter(exec -> matchesPeriod(exec, view, periodKey)) + .sorted(Comparator.comparing(JobExecution::getStartTime).reversed()) + .map(this::convertToDto) + .collect(Collectors.toList()); + } + + private boolean matchesPeriod(JobExecution execution, String view, String periodKey) { + java.time.LocalDateTime startTime = execution.getStartTime(); + + if ("day".equals(view)) { + // 시간별 매칭 (key format: "2025-10-14-00") + int lastDashIndex = periodKey.lastIndexOf('-'); + String dateStr = periodKey.substring(0, lastDashIndex); + int hour = Integer.parseInt(periodKey.substring(lastDashIndex + 1)); + + java.time.LocalDate periodDate = java.time.LocalDate.parse(dateStr); + + return startTime.toLocalDate().equals(periodDate) && + startTime.getHour() == hour; + } else { + // 일별 매칭 + java.time.LocalDate periodDate = java.time.LocalDate.parse(periodKey); + return startTime.toLocalDate().equals(periodDate); + } + } + + /** + * 대시보드 데이터 조회 (한 번의 호출로 모든 데이터 반환) + */ + public com.snp.batch.global.dto.DashboardResponse getDashboardData() { + // 1. 스케줄 통계 + java.util.List allSchedules = scheduleService.getAllSchedules(); + int totalSchedules = allSchedules.size(); + int activeSchedules = (int) allSchedules.stream().filter(com.snp.batch.global.dto.ScheduleResponse::getActive).count(); + int inactiveSchedules = totalSchedules - activeSchedules; + int totalJobs = jobMap.size(); + + com.snp.batch.global.dto.DashboardResponse.Stats stats = com.snp.batch.global.dto.DashboardResponse.Stats.builder() + .totalSchedules(totalSchedules) + .activeSchedules(activeSchedules) + .inactiveSchedules(inactiveSchedules) + .totalJobs(totalJobs) + .build(); + + // 2. 실행 중인 Job (한 번의 쿼리) + List> runningData = timelineRepository.findRunningExecutions(); + List runningJobs = runningData.stream() + .map(data -> { + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime"); + return com.snp.batch.global.dto.DashboardResponse.RunningJob.builder() + .jobName((String) data.get("jobName")) + .executionId(((Number) data.get("executionId")).longValue()) + .status((String) data.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null) + .build(); + }) + .collect(Collectors.toList()); + + // 3. 최근 실행 이력 (한 번의 쿼리로 상위 10개) + List> recentData = timelineRepository.findRecentExecutions(10); + List recentExecutions = recentData.stream() + .map(data -> { + java.sql.Timestamp startTimestamp = (java.sql.Timestamp) data.get("startTime"); + java.sql.Timestamp endTimestamp = (java.sql.Timestamp) data.get("endTime"); + return com.snp.batch.global.dto.DashboardResponse.RecentExecution.builder() + .executionId(((Number) data.get("executionId")).longValue()) + .jobName((String) data.get("jobName")) + .status((String) data.get("status")) + .startTime(startTimestamp != null ? startTimestamp.toLocalDateTime() : null) + .endTime(endTimestamp != null ? endTimestamp.toLocalDateTime() : null) + .build(); + }) + .collect(Collectors.toList()); + + return com.snp.batch.global.dto.DashboardResponse.builder() + .stats(stats) + .runningJobs(runningJobs) + .recentExecutions(recentExecutions) + .build(); + } +} diff --git a/src/main/java/com/snp/batch/service/QuartzJobService.java b/src/main/java/com/snp/batch/service/QuartzJobService.java new file mode 100644 index 0000000..b94315f --- /dev/null +++ b/src/main/java/com/snp/batch/service/QuartzJobService.java @@ -0,0 +1,68 @@ +package com.snp.batch.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.JobParameters; +import org.springframework.batch.core.JobParametersBuilder; +import org.springframework.batch.core.launch.JobLauncher; +import org.springframework.stereotype.Service; + +import java.util.Map; + +/** + * Quartz Job과 Spring Batch Job을 연동하는 서비스 + * Quartz 스케줄러에서 호출되어 실제 배치 작업을 실행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class QuartzJobService { + + private final JobLauncher jobLauncher; + private final Map jobMap; + + /** + * 배치 작업 실행 + * + * @param jobName 실행할 Job 이름 + * @throws Exception Job 실행 중 발생한 예외 + */ + public void executeBatchJob(String jobName) throws Exception { + log.info("스케줄러에 의해 배치 작업 실행 시작: {}", jobName); + + // Job Bean 조회 + Job job = jobMap.get(jobName); + if (job == null) { + log.error("배치 작업을 찾을 수 없습니다: {}", jobName); + throw new IllegalArgumentException("Job not found: " + jobName); + } + + // JobParameters 생성 (timestamp를 포함하여 매번 다른 JobInstance 생성) + JobParameters jobParameters = new JobParametersBuilder() + .addLong("timestamp", System.currentTimeMillis()) + .addString("triggeredBy", "SCHEDULER") + .toJobParameters(); + + try { + // 배치 작업 실행 + var jobExecution = jobLauncher.run(job, jobParameters); + log.info("배치 작업 실행 완료: {} (Execution ID: {})", jobName, jobExecution.getId()); + log.info("실행 상태: {}", jobExecution.getStatus()); + + } catch (Exception e) { + log.error("배치 작업 실행 중 에러 발생: {}", jobName, e); + throw e; + } + } + + /** + * Job 이름 유효성 검사 + * + * @param jobName 검사할 Job 이름 + * @return boolean Job 존재 여부 + */ + public boolean isValidJob(String jobName) { + return jobMap.containsKey(jobName); + } +} diff --git a/src/main/java/com/snp/batch/service/ScheduleService.java b/src/main/java/com/snp/batch/service/ScheduleService.java new file mode 100644 index 0000000..d92ed5b --- /dev/null +++ b/src/main/java/com/snp/batch/service/ScheduleService.java @@ -0,0 +1,354 @@ +package com.snp.batch.service; + +import com.snp.batch.global.dto.ScheduleRequest; +import com.snp.batch.global.dto.ScheduleResponse; +import com.snp.batch.global.model.JobScheduleEntity; +import com.snp.batch.global.repository.JobScheduleRepository; +import com.snp.batch.scheduler.QuartzBatchJob; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.quartz.*; +import org.springframework.batch.core.Job; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * DB 영속화를 지원하는 스케줄 관리 서비스 + * Quartz 스케줄러와 DB를 동기화하여 재시작 후에도 스케줄 유지 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ScheduleService { + + private final JobScheduleRepository scheduleRepository; + private final Scheduler scheduler; + private final Map jobMap; + private final QuartzJobService quartzJobService; + + /** + * 스케줄 생성 (DB 저장 + Quartz 등록) + * + * @param request 스케줄 요청 정보 + * @return ScheduleResponse 생성된 스케줄 정보 + * @throws Exception 스케줄 생성 중 발생한 예외 + */ + @Transactional + public ScheduleResponse createSchedule(ScheduleRequest request) throws Exception { + String jobName = request.getJobName(); + + log.info("스케줄 생성 시작: {}", jobName); + + // 1. Job 이름 유효성 검사 + if (!quartzJobService.isValidJob(jobName)) { + throw new IllegalArgumentException("Invalid job name: " + jobName + ". Job does not exist."); + } + + // 2. 중복 체크 + if (scheduleRepository.existsByJobName(jobName)) { + throw new IllegalArgumentException("Schedule already exists for job: " + jobName); + } + + // 3. Cron 표현식 유효성 검사 + try { + CronScheduleBuilder.cronSchedule(request.getCronExpression()); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid cron expression: " + request.getCronExpression(), e); + } + + // 4. DB에 저장 + JobScheduleEntity entity = JobScheduleEntity.builder() + .jobName(jobName) + .cronExpression(request.getCronExpression()) + .description(request.getDescription()) + .active(request.getActive() != null ? request.getActive() : true) + .build(); + + // BaseEntity 필드는 setter로 설정하거나 PrePersist에서 자동 설정됨 + // (PrePersist가 자동으로 SYSTEM으로 설정) + + entity = scheduleRepository.save(entity); + log.info("DB에 스케줄 저장 완료: ID={}, Job={}", entity.getId(), jobName); + + // 5. Quartz에 등록 (active=true인 경우만) + if (entity.getActive()) { + try { + registerQuartzJob(entity); + log.info("Quartz 등록 완료: {}", jobName); + } catch (Exception e) { + log.error("Quartz 등록 실패 (DB 저장은 완료됨): {}", jobName, e); + } + } + + // 6. 응답 생성 + return convertToResponse(entity); + } + + /** + * 스케줄 수정 (Cron 표현식과 설명 업데이트) + * + * @param jobName Job 이름 + * @param cronExpression 새로운 Cron 표현식 + * @param description 새로운 설명 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 수정 중 발생한 예외 + */ + @Transactional + public ScheduleResponse updateSchedule(String jobName, String cronExpression, String description) throws Exception { + log.info("스케줄 수정 시작: {} -> {}", jobName, cronExpression); + + // 1. 기존 스케줄 조회 + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + // 2. Cron 표현식 유효성 검사 + try { + CronScheduleBuilder.cronSchedule(cronExpression); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid cron expression: " + cronExpression, e); + } + + // 3. DB 업데이트 + entity.setCronExpression(cronExpression); + if (description != null) { + entity.setDescription(description); + } + entity = scheduleRepository.save(entity); + log.info("DB 스케줄 업데이트 완료: {}", jobName); + + // 4. Quartz 스케줄 재등록 + if (entity.getActive()) { + try { + unregisterQuartzJob(jobName); + registerQuartzJob(entity); + log.info("Quartz 재등록 완료: {}", jobName); + } catch (Exception e) { + log.error("Quartz 재등록 실패 (DB 업데이트는 완료됨): {}", jobName, e); + } + } + + // 5. 응답 생성 + return convertToResponse(entity); + } + + /** + * 스케줄 수정 (Cron 표현식만 업데이트) + * + * @param jobName Job 이름 + * @param cronExpression 새로운 Cron 표현식 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 수정 중 발생한 예외 + * @deprecated updateSchedule(jobName, cronExpression, description) 사용 권장 + */ + @Deprecated + @Transactional + public ScheduleResponse updateScheduleByCron(String jobName, String cronExpression) throws Exception { + return updateSchedule(jobName, cronExpression, null); + } + + /** + * 스케줄 삭제 (DB + Quartz) + * + * @param jobName Job 이름 + * @throws Exception 스케줄 삭제 중 발생한 예외 + */ + @Transactional + public void deleteSchedule(String jobName) throws Exception { + log.info("스케줄 삭제 시작: {}", jobName); + + // 1. Quartz에서 제거 + try { + unregisterQuartzJob(jobName); + log.info("Quartz 스케줄 제거 완료: {}", jobName); + } catch (Exception e) { + log.warn("Quartz에서 스케줄 제거 실패 (무시하고 계속): {}", jobName, e); + } + + // 2. DB에서 삭제 + scheduleRepository.deleteByJobName(jobName); + log.info("DB에서 스케줄 삭제 완료: {}", jobName); + } + + /** + * 특정 Job의 스케줄 조회 + * + * @param jobName Job 이름 + * @return ScheduleResponse 스케줄 정보 + */ + @Transactional(readOnly = true) + public ScheduleResponse getScheduleByJobName(String jobName) { + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + return convertToResponse(entity); + } + + /** + * 전체 스케줄 목록 조회 + * + * @return List 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getAllSchedules() { + return scheduleRepository.findAll().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * 활성화된 스케줄 목록 조회 + * + * @return List 활성 스케줄 목록 + */ + @Transactional(readOnly = true) + public List getAllActiveSchedules() { + return scheduleRepository.findAllActive().stream() + .map(this::convertToResponse) + .collect(Collectors.toList()); + } + + /** + * 스케줄 활성화/비활성화 토글 + * + * @param jobName Job 이름 + * @param active 활성화 여부 + * @return ScheduleResponse 수정된 스케줄 정보 + * @throws Exception 스케줄 토글 중 발생한 예외 + */ + @Transactional + public ScheduleResponse toggleScheduleActive(String jobName, boolean active) throws Exception { + log.info("스케줄 활성화 상태 변경: {} -> {}", jobName, active); + + // 1. 기존 스케줄 조회 + JobScheduleEntity entity = scheduleRepository.findByJobName(jobName) + .orElseThrow(() -> new IllegalArgumentException("Schedule not found for job: " + jobName)); + + // 2. DB 업데이트 + entity.setActive(active); + entity = scheduleRepository.save(entity); + + // 3. Quartz 동기화 + try { + if (active) { + // 활성화: Quartz에 등록 + registerQuartzJob(entity); + log.info("Quartz 활성화 완료: {}", jobName); + } else { + // 비활성화: Quartz에서 제거 + unregisterQuartzJob(jobName); + log.info("Quartz 비활성화 완료: {}", jobName); + } + } catch (Exception e) { + log.error("Quartz 동기화 중 예외 발생 (DB 업데이트는 완료됨): {}", jobName, e); + } + + // 4. 응답 생성 + return convertToResponse(entity); + } + + /** + * Quartz에 Job 등록 + * + * @param entity JobScheduleEntity + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void registerQuartzJob(JobScheduleEntity entity) throws SchedulerException { + String jobName = entity.getJobName(); + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + TriggerKey triggerKey = new TriggerKey(jobName + "-trigger", "batch-triggers"); + + // JobDetail 생성 + JobDetail jobDetail = JobBuilder.newJob(QuartzBatchJob.class) + .withIdentity(jobKey) + .usingJobData("jobName", jobName) + .storeDurably(true) + .build(); + + // CronTrigger 생성 + CronTrigger trigger = TriggerBuilder.newTrigger() + .withIdentity(triggerKey) + .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression())) + .forJob(jobKey) + .build(); + + // 기존 Job 삭제 후 등록 + try { + scheduler.deleteJob(jobKey); + } catch (Exception e) { + log.debug("기존 Job 삭제 시도: {}", jobName); + } + + // Job 등록 + try { + scheduler.scheduleJob(jobDetail, trigger); + log.info("Quartz에 스케줄 등록 완료: {} (Cron: {})", jobName, entity.getCronExpression()); + } catch (ObjectAlreadyExistsException e) { + log.warn("Job이 이미 존재함, 재시도: {}", jobName); + scheduler.deleteJob(jobKey); + scheduler.scheduleJob(jobDetail, trigger); + log.info("Quartz에 스케줄 재등록 완료: {} (Cron: {})", jobName, entity.getCronExpression()); + } + } + + /** + * Quartz에서 Job 제거 + * + * @param jobName Job 이름 + * @throws SchedulerException Quartz 스케줄러 예외 + */ + private void unregisterQuartzJob(String jobName) throws SchedulerException { + JobKey jobKey = new JobKey(jobName, "batch-jobs"); + + if (scheduler.checkExists(jobKey)) { + scheduler.deleteJob(jobKey); + log.info("Quartz에서 스케줄 제거 완료: {}", jobName); + } + } + + /** + * Entity를 Response DTO로 변환 + * + * @param entity JobScheduleEntity + * @return ScheduleResponse + */ + private ScheduleResponse convertToResponse(JobScheduleEntity entity) { + ScheduleResponse.ScheduleResponseBuilder builder = ScheduleResponse.builder() + .id(entity.getId()) + .jobName(entity.getJobName()) + .cronExpression(entity.getCronExpression()) + .description(entity.getDescription()) + .active(entity.getActive()) + .createdAt(entity.getCreatedAt()) + .updatedAt(entity.getUpdatedAt()) + .createdBy(entity.getCreatedBy()) + .updatedBy(entity.getUpdatedBy()); + + // 다음 실행 시간 계산 (Cron 표현식 기반) + if (entity.getActive() && entity.getCronExpression() != null) { + try { + // Cron 표현식으로 임시 트리거 생성 (DB 조회 없이 계산) + CronTrigger tempTrigger = TriggerBuilder.newTrigger() + .withSchedule(CronScheduleBuilder.cronSchedule(entity.getCronExpression())) + .build(); + + Date nextFireTime = tempTrigger.getFireTimeAfter(new Date()); + if (nextFireTime != null) { + builder.nextFireTime(nextFireTime); + } + + // Trigger 상태는 active인 경우 NORMAL로 설정 + builder.triggerState("NORMAL"); + + } catch (Exception e) { + log.debug("Cron 표현식 기반 다음 실행 시간 계산 실패: {}", entity.getJobName(), e); + } + } + + return builder.build(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..0bd7833 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,97 @@ +spring: + application: + name: snp-batch + + # PostgreSQL Database Configuration + datasource: + url: jdbc:postgresql://61.101.55.59:5432/snpdb + username: snp + password: snp#8932 + driver-class-name: org.postgresql.Driver + hikari: + maximum-pool-size: 10 + minimum-idle: 5 + connection-timeout: 30000 + + # JPA Configuration + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + default_schema: public + + # Batch Configuration + batch: + jdbc: + initialize-schema: never # Changed to 'never' as tables already exist + job: + enabled: false # Prevent auto-run on startup + + # Thymeleaf Configuration + thymeleaf: + cache: false + prefix: classpath:/templates/ + suffix: .html + + # Quartz Scheduler Configuration - Using JDBC Store for persistence + quartz: + job-store-type: jdbc # JDBC store for schedule persistence + jdbc: + initialize-schema: always # Create Quartz tables if not exist + properties: + org.quartz.scheduler.instanceName: SNPBatchScheduler + org.quartz.scheduler.instanceId: AUTO + org.quartz.threadPool.threadCount: 10 + org.quartz.jobStore.class: org.quartz.impl.jdbcjobstore.JobStoreTX + org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate + org.quartz.jobStore.tablePrefix: QRTZ_ + org.quartz.jobStore.isClustered: false + org.quartz.jobStore.misfireThreshold: 60000 + +# Server Configuration +server: + port: 8081 + servlet: + context-path: / + +# Actuator Configuration +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,batch + endpoint: + health: + show-details: always + +# Logging Configuration +logging: + level: + root: INFO + com.snp.batch: DEBUG + org.springframework.batch: DEBUG + org.springframework.jdbc: DEBUG + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: logs/snp-batch.log + +# Custom Application Properties +app: + batch: + chunk-size: 1000 + api: + url: https://api.example.com/data + timeout: 30000 + ship-api: + url: https://shipsapi.maritime.spglobal.com + username: 7cc0517d-5ed6-452e-a06f-5bbfd6ab6ade + password: 2LLzSJNqtxWVD8zC + schedule: + enabled: true + cron: "0 0 * * * ?" # Every hour diff --git a/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql b/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql new file mode 100644 index 0000000..43a5b2f --- /dev/null +++ b/src/main/resources/db/migration/V3__Create_Sample_Products_Table.sql @@ -0,0 +1,114 @@ +-- ======================================== +-- 샘플 제품 테이블 생성 +-- 다양한 데이터 타입 테스트용 +-- ======================================== + +-- 기존 테이블 삭제 (개발 환경에서만) +DROP TABLE IF EXISTS sample_products CASCADE; + +-- 샘플 제품 테이블 생성 +CREATE TABLE sample_products ( + -- 기본 키 (자동 증가) + id BIGSERIAL PRIMARY KEY, + + -- 제품 ID (비즈니스 키, 유니크) + product_id VARCHAR(50) NOT NULL UNIQUE, + + -- 제품명 + product_name VARCHAR(200) NOT NULL, + + -- 카테고리 + category VARCHAR(100), + + -- 가격 (DECIMAL 타입: 정밀한 소수점 계산) + price DECIMAL(10, 2), + + -- 재고 수량 (INTEGER 타입) + stock_quantity INTEGER, + + -- 활성 여부 (BOOLEAN 타입) + is_active BOOLEAN DEFAULT TRUE, + + -- 평점 (DOUBLE PRECISION 타입) + rating DOUBLE PRECISION, + + -- 제조일자 (DATE 타입) + manufacture_date DATE, + + -- 무게 (REAL/FLOAT 타입) + weight REAL, + + -- 판매 횟수 (BIGINT 타입) + sales_count BIGINT DEFAULT 0, + + -- 설명 (TEXT 타입: 긴 텍스트) + description TEXT, + + -- 태그 (JSON 문자열 저장) + tags VARCHAR(500), + + -- 감사 필드 (BaseEntity에서 상속) + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100) DEFAULT 'SYSTEM', + updated_by VARCHAR(100) DEFAULT 'SYSTEM' +); + +-- ======================================== +-- 인덱스 생성 (성능 최적화) +-- ======================================== + +-- 제품 ID 인덱스 (이미 UNIQUE로 자동 생성되지만 명시적 표시) +CREATE INDEX IF NOT EXISTS idx_sample_products_product_id + ON sample_products(product_id); + +-- 카테고리 인덱스 (카테고리별 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_category + ON sample_products(category); + +-- 활성 여부 인덱스 (활성 제품 필터링 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_is_active + ON sample_products(is_active); + +-- 제조일자 인덱스 (날짜 범위 검색 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_manufacture_date + ON sample_products(manufacture_date); + +-- 복합 인덱스: 카테고리 + 활성 여부 (자주 함께 검색되는 조건) +CREATE INDEX IF NOT EXISTS idx_sample_products_category_active + ON sample_products(category, is_active); + +-- 생성일시 인덱스 (최신 데이터 조회 최적화) +CREATE INDEX IF NOT EXISTS idx_sample_products_created_at + ON sample_products(created_at DESC); + +-- ======================================== +-- 코멘트 추가 (테이블 및 컬럼 설명) +-- ======================================== + +COMMENT ON TABLE sample_products IS '샘플 제품 테이블 - 다양한 데이터 타입 테스트용'; + +COMMENT ON COLUMN sample_products.id IS '기본 키 (자동 증가)'; +COMMENT ON COLUMN sample_products.product_id IS '제품 ID (비즈니스 키)'; +COMMENT ON COLUMN sample_products.product_name IS '제품명'; +COMMENT ON COLUMN sample_products.category IS '카테고리'; +COMMENT ON COLUMN sample_products.price IS '가격 (DECIMAL 타입, 정밀 소수점)'; +COMMENT ON COLUMN sample_products.stock_quantity IS '재고 수량 (INTEGER)'; +COMMENT ON COLUMN sample_products.is_active IS '활성 여부 (BOOLEAN)'; +COMMENT ON COLUMN sample_products.rating IS '평점 (DOUBLE PRECISION)'; +COMMENT ON COLUMN sample_products.manufacture_date IS '제조일자 (DATE)'; +COMMENT ON COLUMN sample_products.weight IS '무게 kg (REAL/FLOAT)'; +COMMENT ON COLUMN sample_products.sales_count IS '판매 횟수 (BIGINT)'; +COMMENT ON COLUMN sample_products.description IS '설명 (TEXT, 긴 텍스트)'; +COMMENT ON COLUMN sample_products.tags IS '태그 (JSON 문자열)'; +COMMENT ON COLUMN sample_products.created_at IS '생성일시'; +COMMENT ON COLUMN sample_products.updated_at IS '수정일시'; +COMMENT ON COLUMN sample_products.created_by IS '생성자'; +COMMENT ON COLUMN sample_products.updated_by IS '수정자'; + +-- ======================================== +-- 테이블 통계 정보 +-- ======================================== + +-- 테이블 통계 업데이트 (쿼리 최적화를 위한 통계 수집) +ANALYZE sample_products; diff --git a/src/main/resources/db/schema/001_create_job_execution_lock.sql b/src/main/resources/db/schema/001_create_job_execution_lock.sql new file mode 100644 index 0000000..5e297d0 --- /dev/null +++ b/src/main/resources/db/schema/001_create_job_execution_lock.sql @@ -0,0 +1,61 @@ +-- ============================================================ +-- Job Execution Lock 테이블 생성 +-- ============================================================ +-- 목적: Job 동시 실행 방지 (분산 환경 지원) +-- 작성일: 2025-10-17 +-- 버전: 1.0.0 +-- ============================================================ + +-- 테이블 삭제 (재생성 시) +DROP TABLE IF EXISTS job_execution_lock CASCADE; + +-- 테이블 생성 +CREATE TABLE job_execution_lock ( + -- Job 이름 (Primary Key) + job_name VARCHAR(100) PRIMARY KEY, + + -- Lock 상태 (true: 실행 중, false: 대기) + locked BOOLEAN NOT NULL DEFAULT FALSE, + + -- Lock 획득 시간 + locked_at TIMESTAMP, + + -- Lock 소유자 (hostname:pid 형식) + locked_by VARCHAR(255), + + -- 현재 실행 중인 Execution ID + execution_id BIGINT, + + -- 감사 필드 + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 인덱스 생성 +CREATE INDEX idx_job_execution_lock_locked ON job_execution_lock(locked); +CREATE INDEX idx_job_execution_lock_locked_at ON job_execution_lock(locked_at); +CREATE INDEX idx_job_execution_lock_execution_id ON job_execution_lock(execution_id); + +-- 테이블 및 컬럼 주석 +COMMENT ON TABLE job_execution_lock IS 'Job 실행 Lock 관리 테이블 (동시 실행 방지)'; +COMMENT ON COLUMN job_execution_lock.job_name IS 'Job 이름 (Primary Key)'; +COMMENT ON COLUMN job_execution_lock.locked IS 'Lock 상태 (true: 실행 중, false: 대기)'; +COMMENT ON COLUMN job_execution_lock.locked_at IS 'Lock 획득 시간'; +COMMENT ON COLUMN job_execution_lock.locked_by IS 'Lock 소유자 (hostname:pid)'; +COMMENT ON COLUMN job_execution_lock.execution_id IS '현재 실행 중인 Execution ID'; +COMMENT ON COLUMN job_execution_lock.created_at IS '생성 시간'; +COMMENT ON COLUMN job_execution_lock.updated_at IS '수정 시간'; + +-- 샘플 데이터 삽입 (선택사항) +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('sampleProductImportJob', FALSE, NULL, NULL, NULL); +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('shipDataImportJob', FALSE, NULL, NULL, NULL); +-- INSERT INTO job_execution_lock (job_name, locked, locked_at, locked_by, execution_id) +-- VALUES ('shipDetailImportJob', FALSE, NULL, NULL, NULL); + +-- 권한 부여 (필요 시) +-- GRANT SELECT, INSERT, UPDATE, DELETE ON job_execution_lock TO snp; + +-- 완료 메시지 +SELECT 'job_execution_lock 테이블 생성 완료' AS status; diff --git a/src/main/resources/db/schema/ship_detail.sql b/src/main/resources/db/schema/ship_detail.sql new file mode 100644 index 0000000..8bb9aed --- /dev/null +++ b/src/main/resources/db/schema/ship_detail.sql @@ -0,0 +1,64 @@ +-- 선박 상세 정보 테이블 +CREATE TABLE IF NOT EXISTS ship_detail ( + -- 기본 키 + id BIGSERIAL PRIMARY KEY, + + -- 비즈니스 키 + imo_number VARCHAR(20) UNIQUE NOT NULL, + + -- 선박 기본 정보 + ship_name VARCHAR(200), + ship_type VARCHAR(100), + classification VARCHAR(100), + build_year INTEGER, + shipyard VARCHAR(200), + + -- 소유/운영 정보 + owner VARCHAR(200), + operator VARCHAR(200), + flag VARCHAR(100), + + -- 선박 제원 + gross_tonnage DOUBLE PRECISION, + net_tonnage DOUBLE PRECISION, + deadweight DOUBLE PRECISION, + length_overall DOUBLE PRECISION, + breadth DOUBLE PRECISION, + depth DOUBLE PRECISION, + + -- 기술 정보 + hull_material VARCHAR(100), + engine_type VARCHAR(100), + engine_power DOUBLE PRECISION, + speed DOUBLE PRECISION, + + -- 식별 정보 + mmsi VARCHAR(20), + call_sign VARCHAR(20), + + -- 상태 정보 + status VARCHAR(50), + last_updated VARCHAR(100), + + -- 감사 필드 (BaseEntity) + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_by VARCHAR(100) DEFAULT 'SYSTEM', + updated_by VARCHAR(100) DEFAULT 'SYSTEM' +); + +-- 인덱스 +CREATE UNIQUE INDEX IF NOT EXISTS idx_ship_detail_imo ON ship_detail(imo_number); +CREATE INDEX IF NOT EXISTS idx_ship_detail_ship_name ON ship_detail(ship_name); +CREATE INDEX IF NOT EXISTS idx_ship_detail_ship_type ON ship_detail(ship_type); +CREATE INDEX IF NOT EXISTS idx_ship_detail_flag ON ship_detail(flag); +CREATE INDEX IF NOT EXISTS idx_ship_detail_status ON ship_detail(status); + +-- 주석 +COMMENT ON TABLE ship_detail IS '선박 상세 정보'; +COMMENT ON COLUMN ship_detail.imo_number IS 'IMO 번호 (비즈니스 키)'; +COMMENT ON COLUMN ship_detail.ship_name IS '선박명'; +COMMENT ON COLUMN ship_detail.ship_type IS '선박 타입'; +COMMENT ON COLUMN ship_detail.gross_tonnage IS '총톤수'; +COMMENT ON COLUMN ship_detail.deadweight IS '재화중량톤수'; +COMMENT ON COLUMN ship_detail.length_overall IS '전체 길이 (meters)'; diff --git a/src/main/resources/templates/execution-detail.html b/src/main/resources/templates/execution-detail.html new file mode 100644 index 0000000..28f2792 --- /dev/null +++ b/src/main/resources/templates/execution-detail.html @@ -0,0 +1,509 @@ + + + + + + 실행 상세 - SNP 배치 + + + +
+
+

실행 상세 정보

+ +
+ +
+
상세 정보 로딩 중...
+
+
+ + + + diff --git a/src/main/resources/templates/executions.html b/src/main/resources/templates/executions.html new file mode 100644 index 0000000..7f19002 --- /dev/null +++ b/src/main/resources/templates/executions.html @@ -0,0 +1,390 @@ + + + + + + 작업 실행 이력 - SNP 배치 + + + + + + + + + +
+ + + + +
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + +
실행 ID작업명상태시작 시간종료 시간소요 시간액션
+
+
+ Loading... +
+
실행 이력 로딩 중...
+
+
+
+
+
+ + + + + + + diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..043a59a --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,586 @@ + + + + + + S&P 배치 관리 시스템 + + + + + + + + + +
+ +
+ + + API 문서 + +

S&P 배치 관리 시스템

+

S&P Global Web API 데이터를 PostgreSQL에 통합하는 배치 모니터링 페이지

+
+ + +
+
+ + 스케줄 현황 + + 스케줄 타임라인 + +
+
+
+
+
+
-
+
전체 스케줄
+
+
+
+
+
+
-
+
활성 스케줄
+
+
+
+
+
+
-
+
비활성 스케줄
+
+
+
+
+
+
-
+
등록된 Job
+
+
+
+
+ + +
+
+ + 현재 진행 중인 Job + 0 +
+
+
+
+ Loading... +
+
+
+
+ + +
+
+ + 최근 실행 이력 +
+
+
+
+ Loading... +
+
+
+ +
+ + +
+
+ + 빠른 작업 +
+ +
+
+ + + + + + + + + + diff --git a/src/main/resources/templates/jobs.html b/src/main/resources/templates/jobs.html new file mode 100644 index 0000000..9dcf5e0 --- /dev/null +++ b/src/main/resources/templates/jobs.html @@ -0,0 +1,295 @@ + + + + + + 배치 작업 - SNP 배치 + + + +
+
+

배치 작업

+ ← 대시보드로 돌아가기 +
+ +
+
+
작업 로딩 중...
+
+
+
+ + + + + + diff --git a/src/main/resources/templates/schedule-timeline.html b/src/main/resources/templates/schedule-timeline.html new file mode 100644 index 0000000..1daae9f --- /dev/null +++ b/src/main/resources/templates/schedule-timeline.html @@ -0,0 +1,829 @@ + + + + + + 스케줄 타임라인 - SNP 배치 + + + + + + + + + +
+ + + + +
+
+ + + + +
+ + + + +
+
+ +
+ +
+
+
+
+ Loading... +
+
타임라인 로딩 중...
+
+
+
+ + +
+
+
+ 완료 +
+
+
+ 실패 +
+
+
+ 실행중 +
+
+
+ 예정 +
+
+
+ 중지됨 +
+
+
+ + +
+
+
+
구간 실행 이력
+
+
+ +
+
+
+
+ Loading... +
+
+
+
+
+ + +
+ + + + + + + diff --git a/src/main/resources/templates/schedules.html b/src/main/resources/templates/schedules.html new file mode 100644 index 0000000..ef2bd03 --- /dev/null +++ b/src/main/resources/templates/schedules.html @@ -0,0 +1,525 @@ + + + + + + 작업 스케줄 - SNP 배치 + + + + + + + + + +
+ + + + +
+
+

스케줄 추가/수정

+
+
+
+ + + +
+
+ + +
+ 예시: "0 0 * * * ?" (매 시간), "0 0 0 * * ?" (매일 자정) +
+
+
+ + +
+
+
+ + +
+
+
+
+ + +
+

+ 활성 스케줄 +

+
+
+
+ Loading... +
+
스케줄 로딩 중...
+
+
+
+
+ + + + + + +