Initial commit
This commit is contained in:
커밋
c88b8a926b
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
* text=auto
|
||||||
105
.gitignore
vendored
Normal file
105
.gitignore
vendored
Normal file
@ -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
|
||||||
1602
DEVELOPMENT_GUIDE.md
Normal file
1602
DEVELOPMENT_GUIDE.md
Normal file
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
517
SWAGGER_GUIDE.md
Normal file
517
SWAGGER_GUIDE.md
Normal file
@ -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<ApiResponse<D>> create(
|
||||||
|
@Parameter(description = "생성할 리소스 데이터", required = true)
|
||||||
|
@RequestBody D dto) {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 어노테이션 설명
|
||||||
|
|
||||||
|
#### 1. `@Tag` - API 그룹화
|
||||||
|
```java
|
||||||
|
@Tag(name = "Product API", description = "제품 관리 API")
|
||||||
|
public class ProductWebController extends BaseController<ProductWebDto, Long> {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. `@Operation` - 엔드포인트 문서화
|
||||||
|
```java
|
||||||
|
@Operation(
|
||||||
|
summary = "짧은 설명 (목록에 표시)",
|
||||||
|
description = "상세 설명 (확장 시 표시)",
|
||||||
|
responses = { /* 응답 정의 */ }
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. `@Parameter` - 파라미터 설명
|
||||||
|
```java
|
||||||
|
@Parameter(
|
||||||
|
description = "파라미터 설명",
|
||||||
|
required = true,
|
||||||
|
example = "예시 값"
|
||||||
|
)
|
||||||
|
@PathVariable String id
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. `@io.swagger.v3.oas.annotations.responses.ApiResponse` - 응답 정의
|
||||||
|
```java
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "성공 메시지",
|
||||||
|
content = @Content(
|
||||||
|
mediaType = "application/json",
|
||||||
|
schema = @Schema(implementation = ProductDto.class)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 신규 Controller 개발 시 Swagger 적용 가이드
|
||||||
|
|
||||||
|
### 1. BaseController를 상속하는 경우
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/myresource")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "My Resource API", description = "나의 리소스 관리 API")
|
||||||
|
public class MyResourceController extends BaseController<MyResourceDto, Long> {
|
||||||
|
|
||||||
|
private final MyResourceService myResourceService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected BaseService<?, MyResourceDto, Long> getService() {
|
||||||
|
return myResourceService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getResourceName() {
|
||||||
|
return "MyResource";
|
||||||
|
}
|
||||||
|
|
||||||
|
// BaseController가 제공하는 CRUD 엔드포인트 자동 생성:
|
||||||
|
// POST /api/myresource
|
||||||
|
// GET /api/myresource/{id}
|
||||||
|
// GET /api/myresource
|
||||||
|
// GET /api/myresource/page
|
||||||
|
// PUT /api/myresource/{id}
|
||||||
|
// DELETE /api/myresource/{id}
|
||||||
|
// GET /api/myresource/{id}/exists
|
||||||
|
|
||||||
|
// 커스텀 엔드포인트 추가 시:
|
||||||
|
@Operation(
|
||||||
|
summary = "커스텀 조회",
|
||||||
|
description = "특정 조건으로 리소스를 조회합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@GetMapping("/custom/{key}")
|
||||||
|
public ResponseEntity<ApiResponse<MyResourceDto>> customEndpoint(
|
||||||
|
@Parameter(description = "커스텀 키", required = true)
|
||||||
|
@PathVariable String key) {
|
||||||
|
// 구현...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 독립적인 Controller를 작성하는 경우
|
||||||
|
|
||||||
|
```java
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/custom")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "Custom API", description = "커스텀 API")
|
||||||
|
public class CustomController {
|
||||||
|
|
||||||
|
@Operation(
|
||||||
|
summary = "커스텀 작업",
|
||||||
|
description = "특정 작업을 수행합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "작업 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@PostMapping("/action")
|
||||||
|
public ResponseEntity<Map<String, Object>> customAction(
|
||||||
|
@Parameter(description = "액션 파라미터", required = true)
|
||||||
|
@RequestBody Map<String, String> params) {
|
||||||
|
// 구현...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 Swagger UI 화면 구성
|
||||||
|
|
||||||
|
### 메인 화면
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ SNP Batch REST API │
|
||||||
|
│ Version: v1.0.0 │
|
||||||
|
│ Spring Batch 기반 데이터 통합 시스템 REST API │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Servers: │
|
||||||
|
│ ▼ http://localhost:8081 (로컬 개발 서버) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ │
|
||||||
|
│ ▼ Batch Management API │
|
||||||
|
│ POST /api/batch/jobs/{jobName}/execute │
|
||||||
|
│ GET /api/batch/jobs │
|
||||||
|
│ ... │
|
||||||
|
│ │
|
||||||
|
│ ▼ Product API (9개 엔드포인트 통합 표시) │
|
||||||
|
│ POST /api/products │
|
||||||
|
│ GET /api/products/{id} │
|
||||||
|
│ GET /api/products │
|
||||||
|
│ GET /api/products/page │
|
||||||
|
│ PUT /api/products/{id} │
|
||||||
|
│ DELETE /api/products/{id} │
|
||||||
|
│ GET /api/products/{id}/exists │
|
||||||
|
│ GET /api/products/by-product-id/{...} │
|
||||||
|
│ GET /api/products/stats/active-count │
|
||||||
|
│ │
|
||||||
|
│ (Base API 그룹은 표시되지 않음) │
|
||||||
|
│ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### API 실행 화면 예시
|
||||||
|
각 엔드포인트 클릭 시:
|
||||||
|
- **Parameters**: 파라미터 입력 필드
|
||||||
|
- **Request body**: JSON 요청 본문 에디터
|
||||||
|
- **Try it out**: 실제 API 호출 버튼
|
||||||
|
- **Responses**: 응답 코드 및 예시
|
||||||
|
- **Curl**: curl 명령어 생성
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 문제 해결
|
||||||
|
|
||||||
|
### 1. Swagger UI 접속 불가
|
||||||
|
**증상**: `http://localhost:8081/swagger-ui/index.html` 접속 시 404 오류
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
1. 애플리케이션이 실행 중인지 확인
|
||||||
|
2. 포트 번호 확인 (`application.yml`의 `server.port`)
|
||||||
|
3. 다음 URL 시도:
|
||||||
|
- `http://localhost:8081/swagger-ui.html`
|
||||||
|
- `http://localhost:8081/swagger-ui/`
|
||||||
|
|
||||||
|
### 2. API 실행 시 401/403 오류
|
||||||
|
**증상**: "Try it out" 클릭 시 인증 오류
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
- 현재 인증이 설정되지 않음 (기본 허용)
|
||||||
|
- Spring Security 추가 시 Swagger 경로 허용 필요:
|
||||||
|
```java
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 특정 엔드포인트가 보이지 않음
|
||||||
|
**증상**: Controller는 작성했지만 Swagger UI에 표시되지 않음
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
1. `@RestController` 어노테이션 확인
|
||||||
|
2. `@RequestMapping` 경로 확인
|
||||||
|
3. Controller가 `com.snp.batch` 패키지 하위에 있는지 확인
|
||||||
|
4. 애플리케이션 재시작
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 설정 파일
|
||||||
|
|
||||||
|
### application.yml (Swagger 관련 설정)
|
||||||
|
```yaml
|
||||||
|
server:
|
||||||
|
port: 8081 # Swagger UI 접속 포트
|
||||||
|
|
||||||
|
# Springdoc OpenAPI 설정 (필요 시 추가)
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /v3/api-docs # OpenAPI JSON 경로
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html # Swagger UI 경로
|
||||||
|
enabled: true
|
||||||
|
operations-sorter: alpha # 엔드포인트 정렬 (alpha, method)
|
||||||
|
tags-sorter: alpha # 태그 정렬
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎓 추가 학습 자료
|
||||||
|
|
||||||
|
### Swagger 어노테이션 공식 문서
|
||||||
|
- [OpenAPI 3.0 Annotations](https://github.com/swagger-api/swagger-core/wiki/Swagger-2.X---Annotations)
|
||||||
|
- [Springdoc OpenAPI](https://springdoc.org/)
|
||||||
|
|
||||||
|
### 관련 파일 위치
|
||||||
|
```
|
||||||
|
src/main/java/com/snp/batch/
|
||||||
|
├── common/web/controller/BaseController.java # 공통 CRUD Base
|
||||||
|
├── global/config/SwaggerConfig.java # Swagger 설정
|
||||||
|
├── global/controller/BatchController.java # Batch API
|
||||||
|
└── jobs/sample/web/controller/ProductWebController.java # Product API
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ 체크리스트
|
||||||
|
|
||||||
|
애플리케이션 실행 전 확인:
|
||||||
|
- [ ] Maven 빌드 성공
|
||||||
|
- [ ] `application.yml` 설정 확인
|
||||||
|
- [ ] PostgreSQL 데이터베이스 연결 확인
|
||||||
|
- [ ] 포트 8081 사용 가능 여부 확인
|
||||||
|
|
||||||
|
Swagger 테스트 확인:
|
||||||
|
- [ ] Swagger UI 접속 성공
|
||||||
|
- [ ] Batch Management API 표시 확인
|
||||||
|
- [ ] Product API 표시 확인
|
||||||
|
- [ ] "Try it out" 기능 동작 확인
|
||||||
|
- [ ] API 응답 정상 확인
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 관련 문서
|
||||||
|
|
||||||
|
### 핵심 문서
|
||||||
|
- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드
|
||||||
|
- **[DEVELOPMENT_GUIDE.md](DEVELOPMENT_GUIDE.md)** - 신규 Job 개발 가이드 및 Base 클래스 사용법
|
||||||
|
- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성)
|
||||||
|
|
||||||
|
### 아키텍처 문서
|
||||||
|
- **[docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md)** - 프로젝트 아키텍처 상세 설계
|
||||||
|
- **[docs/architecture/PROJECT_STRUCTURE.md](docs/architecture/PROJECT_STRUCTURE.md)** - Job 중심 패키지 구조 가이드
|
||||||
|
|
||||||
|
### 구현 가이드
|
||||||
|
- **[docs/guides/PROXY_SERVICE_GUIDE.md](docs/guides/PROXY_SERVICE_GUIDE.md)** - 외부 API 프록시 패턴 구현 가이드
|
||||||
|
- **[docs/guides/SHIP_API_EXAMPLE.md](docs/guides/SHIP_API_EXAMPLE.md)** - Maritime API 연동 실전 예제
|
||||||
|
|
||||||
|
### 보안 문서
|
||||||
|
- **[docs/security/README.md](docs/security/README.md)** - 보안 전략 개요 (계획 단계)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**최종 업데이트**: 2025-10-16
|
||||||
|
**작성자**: Claude Code
|
||||||
|
**버전**: 1.1.0
|
||||||
163
pom.xml
Normal file
163
pom.xml
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
|
||||||
|
http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>3.2.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.snp</groupId>
|
||||||
|
<artifactId>snp-batch</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>SNP Batch</name>
|
||||||
|
<description>Spring Batch project for JSON to PostgreSQL with Web GUI</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<maven.compiler.source>17</maven.compiler.source>
|
||||||
|
<maven.compiler.target>17</maven.compiler.target>
|
||||||
|
|
||||||
|
<!-- Dependency versions -->
|
||||||
|
<spring-boot.version>3.2.1</spring-boot.version>
|
||||||
|
<spring-batch.version>5.1.0</spring-batch.version>
|
||||||
|
<postgresql.version>42.7.6</postgresql.version>
|
||||||
|
<lombok.version>1.18.30</lombok.version>
|
||||||
|
<quartz.version>2.5.0</quartz.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot Starter Web -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Batch -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-batch</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Data JPA -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostgreSQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.postgresql</groupId>
|
||||||
|
<artifactId>postgresql</artifactId>
|
||||||
|
<version>${postgresql.version}</version>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Thymeleaf (for Web GUI) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Starter Quartz (for Job Scheduling) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-quartz</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Jackson for JSON processing -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok for reducing boilerplate code -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot DevTools -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Spring Boot Actuator for monitoring -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- WebClient for REST API calls -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webflux</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Springdoc OpenAPI (Swagger) for API Documentation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Test Dependencies -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.batch</groupId>
|
||||||
|
<artifactId>spring-batch-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
<version>${spring-boot.version}</version>
|
||||||
|
<configuration>
|
||||||
|
<excludes>
|
||||||
|
<exclude>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
</exclude>
|
||||||
|
</excludes>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<version>3.11.0</version>
|
||||||
|
<configuration>
|
||||||
|
<source>17</source>
|
||||||
|
<target>17</target>
|
||||||
|
<encoding>UTF-8</encoding>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
14
src/main/java/com/snp/batch/SnpBatchApplication.java
Normal file
14
src/main/java/com/snp/batch/SnpBatchApplication.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <I> 입력 타입 (Reader 출력, Processor 입력)
|
||||||
|
* @param <O> 출력 타입 (Processor 출력, Writer 입력)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public abstract class BaseJobConfig<I, O> {
|
||||||
|
|
||||||
|
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<I> createReader();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor 생성 (하위 클래스에서 구현)
|
||||||
|
* 처리 로직이 없는 경우 null 반환 가능
|
||||||
|
*/
|
||||||
|
protected abstract ItemProcessor<I, O> createProcessor();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writer 생성 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract ItemWriter<O> 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<I, O> processor = createProcessor();
|
||||||
|
StepBuilder stepBuilder = new StepBuilder(getStepName(), jobRepository);
|
||||||
|
|
||||||
|
// Processor가 있는 경우
|
||||||
|
if (processor != null) {
|
||||||
|
var chunkBuilder = stepBuilder
|
||||||
|
.<I, O>chunk(getChunkSize(), transactionManager)
|
||||||
|
.reader(createReader())
|
||||||
|
.processor(processor)
|
||||||
|
.writer(createWriter());
|
||||||
|
|
||||||
|
// 커스텀 설정 적용
|
||||||
|
configureStep(stepBuilder);
|
||||||
|
|
||||||
|
return chunkBuilder.build();
|
||||||
|
}
|
||||||
|
// Processor가 없는 경우 (I == O 타입 가정)
|
||||||
|
else {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
var chunkBuilder = stepBuilder
|
||||||
|
.<I, I>chunk(getChunkSize(), transactionManager)
|
||||||
|
.reader(createReader())
|
||||||
|
.writer((ItemWriter<? super I>) 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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 <I> 입력 DTO 타입
|
||||||
|
* @param <O> 출력 Entity 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseProcessor<I, O> implements ItemProcessor<I, O> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 변환 로직 (하위 클래스에서 구현)
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <T> DTO 타입 (API 응답 데이터)
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseApiReader<T> implements ItemReader<T> {
|
||||||
|
|
||||||
|
// Chunk 기반 Iterator 패턴
|
||||||
|
private java.util.Iterator<T> currentBatch;
|
||||||
|
private boolean initialized = false;
|
||||||
|
private boolean useChunkMode = false; // Chunk 모드 사용 여부
|
||||||
|
|
||||||
|
// 하위 호환성을 위한 필드 (fetchDataFromApi 사용 시)
|
||||||
|
private List<T> 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<String, Object> params = new HashMap<>();
|
||||||
|
Map<String, Object> queryParams = getQueryParams();
|
||||||
|
if (queryParams != null && !queryParams.isEmpty()) {
|
||||||
|
params.putAll(queryParams);
|
||||||
|
}
|
||||||
|
Map<String, Object> 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<T> 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 또는 빈 리스트 반환
|
||||||
|
*
|
||||||
|
* 구현 예시:
|
||||||
|
* <pre>
|
||||||
|
* private int currentPage = 0;
|
||||||
|
* private final int pageSize = 100;
|
||||||
|
*
|
||||||
|
* @Override
|
||||||
|
* protected List<ProductDto> fetchNextBatch() {
|
||||||
|
* if (currentPage >= totalPages) {
|
||||||
|
* return null; // 종료
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* // API 호출 (100건씩)
|
||||||
|
* ProductApiResponse response = callApiForPage(currentPage, pageSize);
|
||||||
|
* currentPage++;
|
||||||
|
*
|
||||||
|
* return response.getProducts();
|
||||||
|
* }
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* @return 다음 배치 데이터 리스트 (null 또는 빈 리스트면 종료)
|
||||||
|
* @throws Exception API 호출 실패 등
|
||||||
|
*/
|
||||||
|
protected List<T> 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<T> 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<String, Object> params = new HashMap<>();
|
||||||
|
* params.put("status", "active");
|
||||||
|
* params.put("page", 1);
|
||||||
|
* params.put("size", 100);
|
||||||
|
* return params;
|
||||||
|
*
|
||||||
|
* @return Query Parameter 맵 (null이면 파라미터 없음)
|
||||||
|
*/
|
||||||
|
protected Map<String, Object> getQueryParams() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Path Variable 맵 반환
|
||||||
|
*
|
||||||
|
* 예제:
|
||||||
|
* Map<String, Object> pathVars = new HashMap<>();
|
||||||
|
* pathVars.put("orderId", "ORD-001");
|
||||||
|
* return pathVars;
|
||||||
|
*
|
||||||
|
* @return Path Variable 맵 (null이면 Path Variable 없음)
|
||||||
|
*/
|
||||||
|
protected Map<String, Object> 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<String, String> 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<String, String> 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<T> extractDataFromResponse(Object response) {
|
||||||
|
return Collections.emptyList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 라이프사이클 훅 메서드 (선택적 오버라이드)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 전 전처리
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* - 파라미터 검증
|
||||||
|
* - 로깅
|
||||||
|
* - 캐시 확인
|
||||||
|
*/
|
||||||
|
protected void beforeFetch() {
|
||||||
|
log.debug("[{}] API 호출 준비 중...", getReaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 후 후처리
|
||||||
|
*
|
||||||
|
* 사용 예:
|
||||||
|
* - 데이터 검증
|
||||||
|
* - 로깅
|
||||||
|
* - 캐시 저장
|
||||||
|
*
|
||||||
|
* @param data 조회된 데이터 리스트
|
||||||
|
*/
|
||||||
|
protected void afterFetch(List<T> data) {
|
||||||
|
log.debug("[{}] API 호출 완료", getReaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 호출 실패 시 에러 처리
|
||||||
|
*
|
||||||
|
* 기본 동작: 빈 리스트 반환 (Job 실패 방지)
|
||||||
|
* 오버라이드 시: 예외 던지기 또는 재시도 로직 구현
|
||||||
|
*
|
||||||
|
* @param e 발생한 예외
|
||||||
|
* @return 대체 데이터 리스트 (빈 리스트 또는 캐시 데이터)
|
||||||
|
*/
|
||||||
|
protected List<T> handleApiError(Exception e) {
|
||||||
|
log.error("[{}] API 호출 실패: {}", getReaderName(), e.getMessage(), e);
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 헬퍼 메서드 (하위 클래스에서 사용 가능)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient를 사용한 API 호출 (GET/POST 자동 처리)
|
||||||
|
*
|
||||||
|
* 사용 방법 (fetchDataFromApi()에서):
|
||||||
|
*
|
||||||
|
* @Override
|
||||||
|
* protected List<ProductDto> fetchDataFromApi() {
|
||||||
|
* ProductApiResponse response = callApi();
|
||||||
|
* return extractDataFromResponse(response);
|
||||||
|
* }
|
||||||
|
*
|
||||||
|
* @param <R> 응답 타입
|
||||||
|
* @return API 응답 객체
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
protected <R> 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> R callGetApi() {
|
||||||
|
return (R) webClient
|
||||||
|
.get()
|
||||||
|
.uri(buildUri())
|
||||||
|
.headers(this::applyHeaders)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(getResponseType())
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 요청 내부 처리
|
||||||
|
*/
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
private <R> 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<UriBuilder, URI> buildUri() {
|
||||||
|
return uriBuilder -> {
|
||||||
|
// 1. Path 설정
|
||||||
|
String path = getApiPath();
|
||||||
|
uriBuilder.path(path);
|
||||||
|
|
||||||
|
// 2. Query Parameters 추가
|
||||||
|
Map<String, Object> 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<String, Object> 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<String, String> customHeaders = getHeaders();
|
||||||
|
if (customHeaders != null && !customHeaders.isEmpty()) {
|
||||||
|
customHeaders.forEach(httpHeaders::set);
|
||||||
|
log.debug("[{}] Custom Headers: {}", getReaderName(), customHeaders);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 유틸리티 메서드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 리스트가 비어있는지 확인
|
||||||
|
*/
|
||||||
|
protected boolean isEmpty(List<T> data) {
|
||||||
|
return data == null || data.isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 리스트 크기 반환 (null-safe)
|
||||||
|
*/
|
||||||
|
protected int getDataSize(List<T> data) {
|
||||||
|
return data != null ? data.size() : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <T> Entity 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public abstract class BaseJdbcRepository<T, ID> {
|
||||||
|
|
||||||
|
protected final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블명 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract String getTableName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ID 컬럼명 반환 (기본값: "id")
|
||||||
|
*/
|
||||||
|
protected String getIdColumnName() {
|
||||||
|
return "id";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RowMapper 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract RowMapper<T> 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<T> findById(ID id) {
|
||||||
|
String sql = String.format("SELECT * FROM %s WHERE %s = ?", getTableName(), getIdColumnName());
|
||||||
|
log.debug("{} 조회: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
List<T> results = jdbcTemplate.query(sql, getRowMapper(), id);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회
|
||||||
|
*/
|
||||||
|
public List<T> 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<T> 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<T> 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<T> entities) {
|
||||||
|
if (entities == null || entities.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("{} 전체 저장 시작: {} 건", getEntityName(), entities.size());
|
||||||
|
|
||||||
|
// INSERT와 UPDATE 분리
|
||||||
|
List<T> toInsert = entities.stream()
|
||||||
|
.filter(e -> extractId(e) == null || !existsById(extractId(e)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
List<T> 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<T> executeQueryForObject(String sql, Object... params) {
|
||||||
|
log.debug("커스텀 쿼리 실행: {}", sql);
|
||||||
|
List<T> results = jdbcTemplate.query(sql, getRowMapper(), params);
|
||||||
|
return results.isEmpty() ? Optional.empty() : Optional.of(results.get(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 쿼리 실행 (다건 조회)
|
||||||
|
*/
|
||||||
|
protected List<T> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <T> Entity 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public abstract class BaseWriter<T> implements ItemWriter<T> {
|
||||||
|
|
||||||
|
private final String entityName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 데이터 저장 로직 (하위 클래스에서 구현)
|
||||||
|
* Repository의 saveAll() 또는 batchInsert() 호출 등
|
||||||
|
*
|
||||||
|
* @param items 저장할 Entity 리스트
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
protected abstract void writeItems(List<T> items) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Batch ItemWriter 인터페이스 구현
|
||||||
|
* Chunk 단위로 데이터를 저장
|
||||||
|
*
|
||||||
|
* @param chunk 저장할 데이터 청크
|
||||||
|
* @throws Exception 저장 중 오류 발생 시
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void write(Chunk<? extends T> chunk) throws Exception {
|
||||||
|
List<T> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
81
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
81
src/main/java/com/snp/batch/common/web/ApiResponse.java
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
package com.snp.batch.common.web;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 통일된 API 응답 형식
|
||||||
|
*
|
||||||
|
* @param <T> 응답 데이터 타입
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 여부
|
||||||
|
*/
|
||||||
|
private boolean success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 응답 데이터
|
||||||
|
*/
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 에러 코드 (실패 시)
|
||||||
|
*/
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message("Success")
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 성공 응답 생성 (메시지 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> success(String message, T data) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(true)
|
||||||
|
.message(message)
|
||||||
|
.data(data)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실패 응답 생성 (에러 코드 포함)
|
||||||
|
*/
|
||||||
|
public static <T> ApiResponse<T> error(String message, String errorCode) {
|
||||||
|
return ApiResponse.<T>builder()
|
||||||
|
.success(false)
|
||||||
|
.message(message)
|
||||||
|
.errorCode(errorCode)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <D> DTO 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseController<D, ID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract BaseService<?, D, ID> getService();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 리소스 이름 반환 (로깅용)
|
||||||
|
*/
|
||||||
|
protected abstract String getResourceName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 생성
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "리소스 생성",
|
||||||
|
description = "새로운 리소스를 생성합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "생성 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<D>> create(
|
||||||
|
@Parameter(description = "생성할 리소스 데이터", required = true)
|
||||||
|
@RequestBody D dto) {
|
||||||
|
log.info("{} 생성 요청", getResourceName());
|
||||||
|
try {
|
||||||
|
D created = getService().create(dto);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
ApiResponse.success(getResourceName() + " created successfully", created)
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 생성 실패", getResourceName(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to create " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 조회
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "리소스 조회",
|
||||||
|
description = "ID로 특정 리소스를 조회합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "404",
|
||||||
|
description = "리소스 없음"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<D>> getById(
|
||||||
|
@Parameter(description = "리소스 ID", required = true)
|
||||||
|
@PathVariable ID id) {
|
||||||
|
log.info("{} 조회 요청: ID={}", getResourceName(), id);
|
||||||
|
try {
|
||||||
|
return getService().findById(id)
|
||||||
|
.map(dto -> ResponseEntity.ok(ApiResponse.success(dto)))
|
||||||
|
.orElse(ResponseEntity.notFound().build());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 조회 실패: ID={}", getResourceName(), id, e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to get " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "전체 리소스 조회",
|
||||||
|
description = "모든 리소스를 조회합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<D>>> getAll() {
|
||||||
|
log.info("{} 전체 조회 요청", getResourceName());
|
||||||
|
try {
|
||||||
|
List<D> list = getService().findAll();
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(list));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 전체 조회 실패", getResourceName(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to get all " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이징 조회 (JDBC 기반)
|
||||||
|
*
|
||||||
|
* @param offset 시작 위치 (기본값: 0)
|
||||||
|
* @param limit 조회 개수 (기본값: 20)
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "페이징 조회",
|
||||||
|
description = "페이지 단위로 리소스를 조회합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "조회 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@GetMapping("/page")
|
||||||
|
public ResponseEntity<ApiResponse<List<D>>> getPage(
|
||||||
|
@Parameter(description = "시작 위치 (0부터 시작)", example = "0")
|
||||||
|
@RequestParam(defaultValue = "0") int offset,
|
||||||
|
@Parameter(description = "조회 개수", example = "20")
|
||||||
|
@RequestParam(defaultValue = "20") int limit) {
|
||||||
|
log.info("{} 페이징 조회 요청: offset={}, limit={}",
|
||||||
|
getResourceName(), offset, limit);
|
||||||
|
try {
|
||||||
|
List<D> list = getService().findAll(offset, limit);
|
||||||
|
long total = getService().count();
|
||||||
|
|
||||||
|
// 페이징 정보를 포함한 응답
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
ApiResponse.success("Retrieved " + list.size() + " items (total: " + total + ")", list)
|
||||||
|
);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 페이징 조회 실패", getResourceName(), e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to get page of " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 수정
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "리소스 수정",
|
||||||
|
description = "기존 리소스를 수정합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "수정 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "404",
|
||||||
|
description = "리소스 없음"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<D>> update(
|
||||||
|
@Parameter(description = "리소스 ID", required = true)
|
||||||
|
@PathVariable ID id,
|
||||||
|
@Parameter(description = "수정할 리소스 데이터", required = true)
|
||||||
|
@RequestBody D dto) {
|
||||||
|
log.info("{} 수정 요청: ID={}", getResourceName(), id);
|
||||||
|
try {
|
||||||
|
D updated = getService().update(id, dto);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
ApiResponse.success(getResourceName() + " updated successfully", updated)
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 수정 실패: ID={}", getResourceName(), id, e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to update " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 삭제
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "리소스 삭제",
|
||||||
|
description = "기존 리소스를 삭제합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "삭제 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "404",
|
||||||
|
description = "리소스 없음"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> delete(
|
||||||
|
@Parameter(description = "리소스 ID", required = true)
|
||||||
|
@PathVariable ID id) {
|
||||||
|
log.info("{} 삭제 요청: ID={}", getResourceName(), id);
|
||||||
|
try {
|
||||||
|
getService().deleteById(id);
|
||||||
|
return ResponseEntity.ok(
|
||||||
|
ApiResponse.success(getResourceName() + " deleted successfully", null)
|
||||||
|
);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 삭제 실패: ID={}", getResourceName(), id, e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to delete " + getResourceName() + ": " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존재 여부 확인
|
||||||
|
*/
|
||||||
|
@Operation(
|
||||||
|
summary = "리소스 존재 확인",
|
||||||
|
description = "특정 ID의 리소스가 존재하는지 확인합니다",
|
||||||
|
responses = {
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "200",
|
||||||
|
description = "확인 성공"
|
||||||
|
),
|
||||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse(
|
||||||
|
responseCode = "500",
|
||||||
|
description = "서버 오류"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
@GetMapping("/{id}/exists")
|
||||||
|
public ResponseEntity<ApiResponse<Boolean>> exists(
|
||||||
|
@Parameter(description = "리소스 ID", required = true)
|
||||||
|
@PathVariable ID id) {
|
||||||
|
log.debug("{} 존재 여부 확인: ID={}", getResourceName(), id);
|
||||||
|
try {
|
||||||
|
boolean exists = getService().existsById(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.success(exists));
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("{} 존재 여부 확인 실패: ID={}", getResourceName(), id, e);
|
||||||
|
return ResponseEntity.internalServerError().body(
|
||||||
|
ApiResponse.error("Failed to check existence: " + e.getMessage())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/main/java/com/snp/batch/common/web/dto/BaseDto.java
Normal file
33
src/main/java/com/snp/batch/common/web/dto/BaseDto.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
@ -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 <T> Entity 타입
|
||||||
|
* @param <D> DTO 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseHybridService<T, D, ID> extends BaseServiceImpl<T, D, ID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract WebClient getWebClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 서비스 이름 반환
|
||||||
|
*/
|
||||||
|
protected abstract String getExternalServiceName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 유효 시간 (초)
|
||||||
|
* 기본값: 300초 (5분)
|
||||||
|
*/
|
||||||
|
protected long getCacheTtlSeconds() {
|
||||||
|
return 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 타임아웃
|
||||||
|
*/
|
||||||
|
protected Duration getTimeout() {
|
||||||
|
return Duration.ofSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 하이브리드 조회: DB 캐시 우선, 없으면 외부 API 호출
|
||||||
|
*
|
||||||
|
* @param id 조회 키
|
||||||
|
* @return DTO
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public D findByIdHybrid(ID id) {
|
||||||
|
log.info("[하이브리드] ID로 조회: {}", id);
|
||||||
|
|
||||||
|
// 1. DB 캐시 조회
|
||||||
|
Optional<D> cached = findById(id);
|
||||||
|
|
||||||
|
if (cached.isPresent()) {
|
||||||
|
// 캐시 유효성 검증
|
||||||
|
if (isCacheValid(cached.get())) {
|
||||||
|
log.info("[하이브리드] 캐시 Hit - DB에서 반환");
|
||||||
|
return cached.get();
|
||||||
|
} else {
|
||||||
|
log.info("[하이브리드] 캐시 만료 - 외부 API 호출");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.info("[하이브리드] 캐시 Miss - 외부 API 호출");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 외부 API 호출
|
||||||
|
try {
|
||||||
|
D externalData = fetchFromExternalApi(id);
|
||||||
|
|
||||||
|
// 3. DB 저장 (캐시 갱신)
|
||||||
|
T entity = toEntity(externalData);
|
||||||
|
T saved = getRepository().save(entity);
|
||||||
|
|
||||||
|
log.info("[하이브리드] 외부 데이터 DB 저장 완료");
|
||||||
|
return toDto(saved);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[하이브리드] 외부 API 호출 실패: {}", e.getMessage());
|
||||||
|
|
||||||
|
// 4. 외부 API 실패 시 만료된 캐시라도 반환 (Fallback)
|
||||||
|
if (cached.isPresent()) {
|
||||||
|
log.warn("[하이브리드] Fallback - 만료된 캐시 반환");
|
||||||
|
return cached.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new RuntimeException("데이터 조회 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API에서 데이터 조회 (하위 클래스에서 구현)
|
||||||
|
*
|
||||||
|
* @param id 조회 키
|
||||||
|
* @return DTO
|
||||||
|
*/
|
||||||
|
protected abstract D fetchFromExternalApi(ID id) throws Exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 캐시 유효성 검증
|
||||||
|
* 기본 구현: updated_at 기준으로 TTL 체크
|
||||||
|
*
|
||||||
|
* @param dto 캐시 데이터
|
||||||
|
* @return 유효 여부
|
||||||
|
*/
|
||||||
|
protected boolean isCacheValid(D dto) {
|
||||||
|
// BaseDto를 상속한 경우 updatedAt 체크
|
||||||
|
try {
|
||||||
|
LocalDateTime updatedAt = extractUpdatedAt(dto);
|
||||||
|
if (updatedAt == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
long elapsedSeconds = Duration.between(updatedAt, now).getSeconds();
|
||||||
|
|
||||||
|
return elapsedSeconds < getCacheTtlSeconds();
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("캐시 유효성 검증 실패 - 항상 최신 데이터 조회: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO에서 updatedAt 추출 (하위 클래스에서 오버라이드 가능)
|
||||||
|
*/
|
||||||
|
protected LocalDateTime extractUpdatedAt(D dto) {
|
||||||
|
// 기본 구현: 항상 캐시 무효 (외부 API 호출)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 강제 캐시 갱신 (외부 API 호출 강제)
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public D refreshCache(ID id) throws Exception {
|
||||||
|
log.info("[하이브리드] 캐시 강제 갱신: {}", id);
|
||||||
|
|
||||||
|
D externalData = fetchFromExternalApi(id);
|
||||||
|
T entity = toEntity(externalData);
|
||||||
|
T saved = getRepository().save(entity);
|
||||||
|
|
||||||
|
return toDto(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API GET 요청
|
||||||
|
*/
|
||||||
|
protected <RES> RES callExternalGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
||||||
|
log.info("[{}] GET 요청: endpoint={}", getExternalServiceName(), endpoint);
|
||||||
|
|
||||||
|
return getWebClient()
|
||||||
|
.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path(endpoint);
|
||||||
|
if (params != null) {
|
||||||
|
params.forEach(uriBuilder::queryParam);
|
||||||
|
}
|
||||||
|
return uriBuilder.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(responseType)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 API POST 요청
|
||||||
|
*/
|
||||||
|
protected <REQ, RES> RES callExternalPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||||
|
log.info("[{}] POST 요청: endpoint={}", getExternalServiceName(), endpoint);
|
||||||
|
|
||||||
|
return getWebClient()
|
||||||
|
.post()
|
||||||
|
.uri(endpoint)
|
||||||
|
.bodyValue(requestBody)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(responseType)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 <REQ> 요청 DTO 타입
|
||||||
|
* @param <RES> 응답 DTO 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
public abstract class BaseProxyService<REQ, RES> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient 반환 (하위 클래스에서 구현)
|
||||||
|
* 외부 서비스별로 인증, Base URL 등 설정
|
||||||
|
*/
|
||||||
|
protected abstract WebClient getWebClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 서비스 이름 반환 (로깅용)
|
||||||
|
*/
|
||||||
|
protected abstract String getServiceName();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요청 타임아웃 (밀리초)
|
||||||
|
* 기본값: 30초
|
||||||
|
*/
|
||||||
|
protected Duration getTimeout() {
|
||||||
|
return Duration.ofSeconds(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET 요청 프록시
|
||||||
|
*
|
||||||
|
* @param endpoint 엔드포인트 경로 (예: "/api/ships")
|
||||||
|
* @param params 쿼리 파라미터
|
||||||
|
* @param responseType 응답 클래스 타입
|
||||||
|
* @return 외부 서비스 응답
|
||||||
|
*/
|
||||||
|
public RES proxyGet(String endpoint, Map<String, String> params, Class<RES> responseType) {
|
||||||
|
log.info("[{}] GET 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
WebClient.RequestHeadersSpec<?> spec = getWebClient()
|
||||||
|
.get()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path(endpoint);
|
||||||
|
if (params != null) {
|
||||||
|
params.forEach(uriBuilder::queryParam);
|
||||||
|
}
|
||||||
|
return uriBuilder.build();
|
||||||
|
});
|
||||||
|
|
||||||
|
RES response = spec.retrieve()
|
||||||
|
.bodyToMono(responseType)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.info("[{}] 응답 성공", getServiceName());
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||||
|
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST 요청 프록시
|
||||||
|
*
|
||||||
|
* @param endpoint 엔드포인트 경로
|
||||||
|
* @param requestBody 요청 본문
|
||||||
|
* @param responseType 응답 클래스 타입
|
||||||
|
* @return 외부 서비스 응답
|
||||||
|
*/
|
||||||
|
public RES proxyPost(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||||
|
log.info("[{}] POST 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
||||||
|
|
||||||
|
try {
|
||||||
|
RES response = getWebClient()
|
||||||
|
.post()
|
||||||
|
.uri(endpoint)
|
||||||
|
.bodyValue(requestBody)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(responseType)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.info("[{}] 응답 성공", getServiceName());
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||||
|
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT 요청 프록시
|
||||||
|
*/
|
||||||
|
public RES proxyPut(String endpoint, REQ requestBody, Class<RES> responseType) {
|
||||||
|
log.info("[{}] PUT 요청 프록시: endpoint={}", getServiceName(), endpoint);
|
||||||
|
|
||||||
|
try {
|
||||||
|
RES response = getWebClient()
|
||||||
|
.put()
|
||||||
|
.uri(endpoint)
|
||||||
|
.bodyValue(requestBody)
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(responseType)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.info("[{}] 응답 성공", getServiceName());
|
||||||
|
return response;
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] 프록시 요청 실패: {}", getServiceName(), e.getMessage(), e);
|
||||||
|
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DELETE 요청 프록시
|
||||||
|
*/
|
||||||
|
public void proxyDelete(String endpoint, Map<String, String> params) {
|
||||||
|
log.info("[{}] DELETE 요청 프록시: endpoint={}, params={}", getServiceName(), endpoint, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
getWebClient()
|
||||||
|
.delete()
|
||||||
|
.uri(uriBuilder -> {
|
||||||
|
uriBuilder.path(endpoint);
|
||||||
|
if (params != null) {
|
||||||
|
params.forEach(uriBuilder::queryParam);
|
||||||
|
}
|
||||||
|
return uriBuilder.build();
|
||||||
|
})
|
||||||
|
.retrieve()
|
||||||
|
.bodyToMono(Void.class)
|
||||||
|
.timeout(getTimeout())
|
||||||
|
.block();
|
||||||
|
|
||||||
|
log.info("[{}] DELETE 성공", getServiceName());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[{}] 프록시 DELETE 실패: {}", getServiceName(), e.getMessage(), e);
|
||||||
|
throw new RuntimeException("외부 서비스 호출 실패: " + e.getMessage(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 요청 처리 (하위 클래스에서 오버라이드)
|
||||||
|
* 복잡한 로직이 필요한 경우 사용
|
||||||
|
*/
|
||||||
|
protected RES customRequest(REQ request) {
|
||||||
|
throw new UnsupportedOperationException("커스텀 요청이 구현되지 않았습니다");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package com.snp.batch.common.web.service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 서비스의 공통 인터페이스 (JDBC 기반)
|
||||||
|
* CRUD 기본 메서드 정의
|
||||||
|
*
|
||||||
|
* @param <T> Entity 타입
|
||||||
|
* @param <D> DTO 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
public interface BaseService<T, D, ID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 생성
|
||||||
|
*
|
||||||
|
* @param dto 생성할 데이터 DTO
|
||||||
|
* @return 생성된 데이터 DTO
|
||||||
|
*/
|
||||||
|
D create(D dto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 조회
|
||||||
|
*
|
||||||
|
* @param id 조회할 ID
|
||||||
|
* @return 조회된 데이터 DTO (Optional)
|
||||||
|
*/
|
||||||
|
Optional<D> findById(ID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회
|
||||||
|
*
|
||||||
|
* @return 전체 데이터 DTO 리스트
|
||||||
|
*/
|
||||||
|
List<D> findAll();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이징 조회
|
||||||
|
*
|
||||||
|
* @param offset 시작 위치 (0부터 시작)
|
||||||
|
* @param limit 조회 개수
|
||||||
|
* @return 페이징된 데이터 리스트
|
||||||
|
*/
|
||||||
|
List<D> findAll(int offset, int limit);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 개수 조회
|
||||||
|
*
|
||||||
|
* @return 전체 데이터 개수
|
||||||
|
*/
|
||||||
|
long count();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 수정
|
||||||
|
*
|
||||||
|
* @param id 수정할 ID
|
||||||
|
* @param dto 수정할 데이터 DTO
|
||||||
|
* @return 수정된 데이터 DTO
|
||||||
|
*/
|
||||||
|
D update(ID id, D dto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 단건 삭제
|
||||||
|
*
|
||||||
|
* @param id 삭제할 ID
|
||||||
|
*/
|
||||||
|
void deleteById(ID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 존재 여부 확인
|
||||||
|
*
|
||||||
|
* @param id 확인할 ID
|
||||||
|
* @return 존재 여부
|
||||||
|
*/
|
||||||
|
boolean existsById(ID id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity를 DTO로 변환
|
||||||
|
*
|
||||||
|
* @param entity 엔티티
|
||||||
|
* @return DTO
|
||||||
|
*/
|
||||||
|
D toDto(T entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO를 Entity로 변환
|
||||||
|
*
|
||||||
|
* @param dto DTO
|
||||||
|
* @return 엔티티
|
||||||
|
*/
|
||||||
|
T toEntity(D dto);
|
||||||
|
}
|
||||||
@ -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 <T> Entity 타입
|
||||||
|
* @param <D> DTO 타입
|
||||||
|
* @param <ID> ID 타입
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public abstract class BaseServiceImpl<T, D, ID> implements BaseService<T, D, ID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository 반환 (하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract BaseJdbcRepository<T, ID> getRepository();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 엔티티 이름 반환 (로깅용)
|
||||||
|
*/
|
||||||
|
protected abstract String getEntityName();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public D create(D dto) {
|
||||||
|
log.info("{} 생성 시작", getEntityName());
|
||||||
|
T entity = toEntity(dto);
|
||||||
|
T saved = getRepository().save(entity);
|
||||||
|
log.info("{} 생성 완료: ID={}", getEntityName(), extractId(saved));
|
||||||
|
return toDto(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<D> findById(ID id) {
|
||||||
|
log.debug("{} 조회: ID={}", getEntityName(), id);
|
||||||
|
return getRepository().findById(id).map(this::toDto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<D> findAll() {
|
||||||
|
log.debug("{} 전체 조회", getEntityName());
|
||||||
|
return getRepository().findAll().stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<D> findAll(int offset, int limit) {
|
||||||
|
log.debug("{} 페이징 조회: offset={}, limit={}", getEntityName(), offset, limit);
|
||||||
|
|
||||||
|
// 하위 클래스에서 제공하는 페이징 쿼리 실행
|
||||||
|
List<T> entities = executePagingQuery(offset, limit);
|
||||||
|
|
||||||
|
return entities.stream()
|
||||||
|
.map(this::toDto)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이징 쿼리 실행 (하위 클래스에서 구현)
|
||||||
|
*
|
||||||
|
* @param offset 시작 위치
|
||||||
|
* @param limit 조회 개수
|
||||||
|
* @return Entity 리스트
|
||||||
|
*/
|
||||||
|
protected abstract List<T> executePagingQuery(int offset, int limit);
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public long count() {
|
||||||
|
log.debug("{} 개수 조회", getEntityName());
|
||||||
|
return getRepository().count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public D update(ID id, D dto) {
|
||||||
|
log.info("{} 수정 시작: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
T entity = getRepository().findById(id)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException(
|
||||||
|
getEntityName() + " not found with id: " + id));
|
||||||
|
|
||||||
|
updateEntity(entity, dto);
|
||||||
|
T updated = getRepository().save(entity);
|
||||||
|
|
||||||
|
log.info("{} 수정 완료: ID={}", getEntityName(), id);
|
||||||
|
return toDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public void deleteById(ID id) {
|
||||||
|
log.info("{} 삭제: ID={}", getEntityName(), id);
|
||||||
|
|
||||||
|
if (!getRepository().existsById(id)) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
getEntityName() + " not found with id: " + id);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRepository().deleteById(id);
|
||||||
|
log.info("{} 삭제 완료: ID={}", getEntityName(), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean existsById(ID id) {
|
||||||
|
return getRepository().existsById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity 업데이트 (하위 클래스에서 구현)
|
||||||
|
*
|
||||||
|
* @param entity 업데이트할 엔티티
|
||||||
|
* @param dto 업데이트 데이터
|
||||||
|
*/
|
||||||
|
protected abstract void updateEntity(T entity, D dto);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity에서 ID 추출 (로깅용, 하위 클래스에서 구현)
|
||||||
|
*/
|
||||||
|
protected abstract ID extractId(T entity);
|
||||||
|
}
|
||||||
@ -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}
|
||||||
|
*/
|
||||||
67
src/main/java/com/snp/batch/global/config/QuartzConfig.java
Normal file
67
src/main/java/com/snp/batch/global/config/QuartzConfig.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
79
src/main/java/com/snp/batch/global/config/SwaggerConfig.java
Normal file
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<Map<String, Object>> 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<String, String> 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<List<String>> listJobs() {
|
||||||
|
log.info("Received request to list all jobs");
|
||||||
|
List<String> jobs = batchService.listAllJobs();
|
||||||
|
return ResponseEntity.ok(jobs);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다")
|
||||||
|
@ApiResponses(value = {
|
||||||
|
@ApiResponse(responseCode = "200", description = "조회 성공")
|
||||||
|
})
|
||||||
|
@GetMapping("/jobs/{jobName}/executions")
|
||||||
|
public ResponseEntity<List<JobExecutionDto>> getJobExecutions(
|
||||||
|
@Parameter(description = "배치 작업 이름", required = true, example = "sampleProductImportJob")
|
||||||
|
@PathVariable String jobName) {
|
||||||
|
log.info("Received request to get executions for job: {}", jobName);
|
||||||
|
List<JobExecutionDto> executions = batchService.getJobExecutions(jobName);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/executions/{executionId}")
|
||||||
|
public ResponseEntity<JobExecutionDto> 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<com.snp.batch.global.dto.JobExecutionDetailDto> 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<Map<String, Object>> 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<Map<String, Object>> getSchedules() {
|
||||||
|
log.info("Received request to get all schedules");
|
||||||
|
List<ScheduleResponse> schedules = scheduleService.getAllSchedules();
|
||||||
|
return ResponseEntity.ok(Map.of(
|
||||||
|
"schedules", schedules,
|
||||||
|
"count", schedules.size()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/schedules/{jobName}")
|
||||||
|
public ResponseEntity<ScheduleResponse> 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<Map<String, Object>> 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<Map<String, Object>> updateSchedule(
|
||||||
|
@PathVariable String jobName,
|
||||||
|
@RequestBody Map<String, String> 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<Map<String, Object>> 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<Map<String, Object>> toggleSchedule(
|
||||||
|
@PathVariable String jobName,
|
||||||
|
@RequestBody Map<String, Boolean> 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<com.snp.batch.global.dto.TimelineResponse> 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<com.snp.batch.global.dto.DashboardResponse> 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<List<JobExecutionDto>> 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<JobExecutionDto> executions = batchService.getPeriodExecutions(jobName, view, periodKey);
|
||||||
|
return ResponseEntity.ok(executions);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error getting period executions", e);
|
||||||
|
return ResponseEntity.internalServerError().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<RunningJob> runningJobs;
|
||||||
|
private List<RecentExecution> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<String, Object> 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<StepExecutionDto> 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<String, Object> parameters; // API 파라미터
|
||||||
|
private Integer totalCalls; // 전체 API 호출 횟수
|
||||||
|
private Integer completedCalls; // 완료된 API 호출 횟수
|
||||||
|
private String lastCallTime; // 마지막 호출 시간
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/main/java/com/snp/batch/global/dto/JobExecutionDto.java
Normal file
23
src/main/java/com/snp/batch/global/dto/JobExecutionDto.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
46
src/main/java/com/snp/batch/global/dto/ScheduleRequest.java
Normal file
46
src/main/java/com/snp/batch/global/dto/ScheduleRequest.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
80
src/main/java/com/snp/batch/global/dto/ScheduleResponse.java
Normal file
80
src/main/java/com/snp/batch/global/dto/ScheduleResponse.java
Normal file
@ -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;
|
||||||
|
}
|
||||||
48
src/main/java/com/snp/batch/global/dto/TimelineResponse.java
Normal file
48
src/main/java/com/snp/batch/global/dto/TimelineResponse.java
Normal file
@ -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<PeriodInfo> periods;
|
||||||
|
private List<ScheduleTimeline> 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<String, ExecutionInfo> executions;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ExecutionInfo {
|
||||||
|
private Long executionId;
|
||||||
|
private String status;
|
||||||
|
private String startTime;
|
||||||
|
private String endTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
110
src/main/java/com/snp/batch/global/model/JobScheduleEntity.java
Normal file
110
src/main/java/com/snp/batch/global/model/JobScheduleEntity.java
Normal file
@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<JobScheduleEntity, Long> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름으로 스케줄 조회
|
||||||
|
*/
|
||||||
|
Optional<JobScheduleEntity> findByJobName(String jobName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByJobName(String jobName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 스케줄 목록 조회
|
||||||
|
*/
|
||||||
|
List<JobScheduleEntity> findByActive(Boolean active);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 모든 스케줄 조회
|
||||||
|
*/
|
||||||
|
default List<JobScheduleEntity> findAllActive() {
|
||||||
|
return findByActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Job 이름으로 스케줄 삭제
|
||||||
|
*/
|
||||||
|
void deleteByJobName(String jobName);
|
||||||
|
}
|
||||||
@ -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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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<Map<String, Object>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<OrderDto, OrderDataProcessor.OrderWrapper> {
|
||||||
|
|
||||||
|
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<OrderDto> createReader() {
|
||||||
|
// 실제 구현 시 OrderDataReader 생성
|
||||||
|
// 예제이므로 null 반환 (Job 등록 안 함)
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemProcessor<OrderDto, OrderDataProcessor.OrderWrapper> createProcessor() {
|
||||||
|
return orderDataProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompositeWriter 생성
|
||||||
|
* OrderWriter와 OrderItemWriter를 조합
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
protected ItemWriter<OrderDataProcessor.OrderWrapper> createWriter() {
|
||||||
|
CompositeItemWriter<OrderDataProcessor.OrderWrapper> 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 사용
|
||||||
|
*/
|
||||||
@ -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<ProductDto, ProductEntity> {
|
||||||
|
|
||||||
|
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<ProductDto> createReader() {
|
||||||
|
return productDataReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemProcessor<ProductDto, ProductEntity> createProcessor() {
|
||||||
|
return productDataProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemWriter<ProductEntity> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<OrderItemDto> items;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주문 상품 DTO (내부 클래스)
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class OrderItemDto {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품 ID
|
||||||
|
*/
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상품명
|
||||||
|
*/
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 수량
|
||||||
|
*/
|
||||||
|
private Integer quantity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가격
|
||||||
|
*/
|
||||||
|
private BigDecimal price;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductDto> products;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메시지
|
||||||
|
*/
|
||||||
|
@JsonProperty("message")
|
||||||
|
private String message;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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에서 상속
|
||||||
|
}
|
||||||
@ -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에서 상속
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<OrderItemEntity> (N개)
|
||||||
|
* }
|
||||||
|
* ↓
|
||||||
|
* CompositeWriter {
|
||||||
|
* OrderWriter → orders 테이블
|
||||||
|
* OrderItemWriter → order_items 테이블
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
public class OrderDataProcessor extends BaseProcessor<OrderDto, OrderDataProcessor.OrderWrapper> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<OrderItemEntity> 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<OrderItemEntity> items;
|
||||||
|
|
||||||
|
public OrderWrapper(OrderEntity order, List<OrderItemEntity> items) {
|
||||||
|
this.order = order;
|
||||||
|
this.items = items;
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderEntity getOrder() {
|
||||||
|
return order;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OrderItemEntity> getItems() {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductDto, ProductEntity> {
|
||||||
|
|
||||||
|
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductDto> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WebClient 주입 생성자
|
||||||
|
*
|
||||||
|
* @param webClient Spring WebClient 인스턴스
|
||||||
|
*/
|
||||||
|
public ProductApiReader(WebClient webClient) {
|
||||||
|
super(webClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 필수 구현 메서드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getReaderName() {
|
||||||
|
return "ProductApiReader";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ProductDto> 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<String, Object> getQueryParams() {
|
||||||
|
Map<String, Object> 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<String, String> getHeaders() {
|
||||||
|
Map<String, String> 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<ProductDto> 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<ProductDto> data) {
|
||||||
|
log.info("[{}] API 호출 성공: {}건 조회", getReaderName(), getDataSize(data));
|
||||||
|
|
||||||
|
// 데이터 검증
|
||||||
|
if (isEmpty(data)) {
|
||||||
|
log.warn("[{}] 조회된 데이터가 없습니다!", getReaderName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ProductDto> 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<String> 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<String, Object> getPathVariables() {
|
||||||
|
* Map<String, Object> 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<ProductDto> extractDataFromResponse(Object response) {
|
||||||
|
* ComplexApiResponse apiResponse = (ComplexApiResponse) response;
|
||||||
|
* return apiResponse.getResult().getData().getItems();
|
||||||
|
* }
|
||||||
|
*/
|
||||||
@ -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<ProductDto> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기본 생성자 (WebClient 없이 Mock 데이터 생성)
|
||||||
|
*/
|
||||||
|
public ProductDataReader() {
|
||||||
|
super(); // WebClient 없이 초기화
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 필수 구현 메서드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getReaderName() {
|
||||||
|
return "ProductDataReader";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ProductDto> fetchDataFromApi() {
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Mock 샘플 데이터 생성 시작");
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
return generateMockData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 라이프사이클 훅 (선택적 오버라이드)
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void beforeFetch() {
|
||||||
|
log.info("[{}] Mock 데이터 생성 준비...", getReaderName());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void afterFetch(List<ProductDto> data) {
|
||||||
|
log.info("[{}] Mock 데이터 생성 완료: {}건", getReaderName(), getDataSize(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mock 샘플 데이터 생성
|
||||||
|
* 다양한 데이터 타입 포함
|
||||||
|
*/
|
||||||
|
private List<ProductDto> generateMockData() {
|
||||||
|
log.info("========================================");
|
||||||
|
log.info("Mock 샘플 데이터 생성 시작");
|
||||||
|
log.info("다양한 데이터 타입 테스트용");
|
||||||
|
log.info("========================================");
|
||||||
|
|
||||||
|
List<ProductDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductEntity> findById(Long id);
|
||||||
|
List<ProductEntity> findAll();
|
||||||
|
long count();
|
||||||
|
boolean existsById(Long id);
|
||||||
|
ProductEntity save(ProductEntity entity);
|
||||||
|
void saveAll(List<ProductEntity> entities);
|
||||||
|
void deleteById(Long id);
|
||||||
|
void deleteAll();
|
||||||
|
|
||||||
|
// 커스텀 메서드
|
||||||
|
/**
|
||||||
|
* 제품 ID로 조회
|
||||||
|
*/
|
||||||
|
Optional<ProductEntity> findByProductId(String productId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 제품 ID 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByProductId(String productId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이징 조회
|
||||||
|
*/
|
||||||
|
List<ProductEntity> findAllWithPaging(int offset, int limit);
|
||||||
|
}
|
||||||
@ -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<ProductEntity, Long> implements ProductRepository {
|
||||||
|
|
||||||
|
public ProductRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||||
|
super(jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getTableName() {
|
||||||
|
return "sample_products";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getEntityName() {
|
||||||
|
return "Product";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected RowMapper<ProductEntity> 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<ProductEntity> 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<ProductEntity> 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<ProductEntity> {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<OrderDataProcessor.OrderWrapper> {
|
||||||
|
|
||||||
|
public OrderItemWriter() {
|
||||||
|
super("OrderItem");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeItems(List<OrderDataProcessor.OrderWrapper> wrappers) throws Exception {
|
||||||
|
// OrderWrapper에서 OrderItemEntity 리스트만 추출 (flatten)
|
||||||
|
List<OrderItemEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<OrderDataProcessor.OrderWrapper> {
|
||||||
|
|
||||||
|
public OrderWriter() {
|
||||||
|
super("Order");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeItems(List<OrderDataProcessor.OrderWrapper> wrappers) throws Exception {
|
||||||
|
// OrderWrapper에서 OrderEntity만 추출
|
||||||
|
List<OrderEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductEntity> {
|
||||||
|
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
|
||||||
|
public ProductDataWriter(ProductRepository productRepository) {
|
||||||
|
super("Product");
|
||||||
|
this.productRepository = productRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeItems(List<ProductEntity> 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("========================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProductWebDto, Long> {
|
||||||
|
|
||||||
|
private final ProductWebService productWebService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected BaseService<?, ProductWebDto, Long> 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<ApiResponse<ProductWebDto>> 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<ApiResponse<Long>> 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())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<ProductEntity, ProductWebDto, Long> {
|
||||||
|
|
||||||
|
private final ProductRepositoryImpl productRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected BaseJdbcRepository<ProductEntity, Long> 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<ProductEntity> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDetailDto, ShipDetailEntity> {
|
||||||
|
|
||||||
|
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<ShipDetailDto> createReader() {
|
||||||
|
return new ShipDetailDataReader(maritimeApiWebClient, jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemProcessor<ShipDetailDto, ShipDetailEntity> createProcessor() {
|
||||||
|
return shipDetailDataProcessor;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemWriter<ShipDetailEntity> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDetailDto> ships;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 선박 개수
|
||||||
|
* API에서 "shipCount"로 반환
|
||||||
|
*/
|
||||||
|
@JsonProperty("shipCount")
|
||||||
|
private Integer shipCount;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<ShipDetailDto, ShipDetailEntity> {
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDetailDto> {
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
// 배치 처리 상태
|
||||||
|
private List<String> 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<ShipDetailDto> 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<String> 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<ShipDetailDto> 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<ShipDetailDto> data) {
|
||||||
|
if (data == null) {
|
||||||
|
int totalBatches = (int) Math.ceil((double) allImoNumbers.size() / batchSize);
|
||||||
|
log.info("[{}] 전체 {} 개 배치 처리 완료", getReaderName(), totalBatches);
|
||||||
|
log.info("[{}] 총 {} 개의 IMO 번호에 대한 API 호출 종료",
|
||||||
|
getReaderName(), allImoNumbers.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDetailEntity> findById(Long id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMO 번호로 조회
|
||||||
|
*/
|
||||||
|
Optional<ShipDetailEntity> findByImoNumber(String imoNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 조회
|
||||||
|
*/
|
||||||
|
List<ShipDetailEntity> findAll();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 (INSERT 또는 UPDATE)
|
||||||
|
*/
|
||||||
|
ShipDetailEntity save(ShipDetailEntity entity);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 여러 건 저장
|
||||||
|
*/
|
||||||
|
void saveAll(List<ShipDetailEntity> entities);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 삭제
|
||||||
|
*/
|
||||||
|
void delete(Long id);
|
||||||
|
}
|
||||||
@ -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<ShipDetailEntity, Long>
|
||||||
|
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<ShipDetailEntity> getRowMapper() {
|
||||||
|
return new ShipDetailEntityRowMapper();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Optional<ShipDetailEntity> findByImoNumber(String imoNumber) {
|
||||||
|
String sql = "SELECT * FROM " + getTableName() + " WHERE imo_number = ?";
|
||||||
|
List<ShipDetailEntity> 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<ShipDetailEntity> {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDetailEntity> {
|
||||||
|
|
||||||
|
private final ShipDetailRepository shipDetailRepository;
|
||||||
|
|
||||||
|
public ShipDetailDataWriter(ShipDetailRepository shipDetailRepository) {
|
||||||
|
super("ShipDetail");
|
||||||
|
this.shipDetailRepository = shipDetailRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeItems(List<ShipDetailEntity> items) throws Exception {
|
||||||
|
log.info("선박 상세 정보 데이터 저장: {} 건", items.size());
|
||||||
|
|
||||||
|
shipDetailRepository.saveAll(items);
|
||||||
|
|
||||||
|
log.info("선박 상세 정보 데이터 저장 완료: {} 건", items.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDto, ShipEntity> {
|
||||||
|
|
||||||
|
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<ShipDto> createReader() {
|
||||||
|
return new ShipDataReader(maritimeApiWebClient);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemProcessor<ShipDto, ShipEntity> createProcessor() {
|
||||||
|
return new ShipDataProcessor(shipRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected ItemWriter<ShipEntity> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDto> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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<ShipDto, ShipEntity> {
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipDto> {
|
||||||
|
|
||||||
|
public ShipDataReader(WebClient webClient) {
|
||||||
|
super(webClient); // BaseApiReader에 WebClient 전달
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// 필수 구현 메서드
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getReaderName() {
|
||||||
|
return "ShipDataReader";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected List<ShipDto> 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<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipEntity> findById(Long id);
|
||||||
|
List<ShipEntity> findAll();
|
||||||
|
long count();
|
||||||
|
boolean existsById(Long id);
|
||||||
|
ShipEntity save(ShipEntity entity);
|
||||||
|
void saveAll(List<ShipEntity> entities);
|
||||||
|
void deleteById(Long id);
|
||||||
|
void deleteAll();
|
||||||
|
|
||||||
|
// 커스텀 메서드
|
||||||
|
/**
|
||||||
|
* IMO 번호로 선박 조회
|
||||||
|
*/
|
||||||
|
Optional<ShipEntity> findByImoNumber(String imoNumber);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IMO 번호 존재 여부 확인
|
||||||
|
*/
|
||||||
|
boolean existsByImoNumber(String imoNumber);
|
||||||
|
}
|
||||||
@ -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<ShipEntity, Long> implements ShipRepository {
|
||||||
|
|
||||||
|
public ShipRepositoryImpl(JdbcTemplate jdbcTemplate) {
|
||||||
|
super(jdbcTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getTableName() {
|
||||||
|
return "ship_data";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected String getEntityName() {
|
||||||
|
return "Ship";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected RowMapper<ShipEntity> 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<ShipEntity> 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<ShipEntity> {
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ShipEntity> {
|
||||||
|
|
||||||
|
private final ShipRepository shipRepository;
|
||||||
|
|
||||||
|
public ShipDataWriter(ShipRepository shipRepository) {
|
||||||
|
super("Ship");
|
||||||
|
this.shipRepository = shipRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void writeItems(List<ShipEntity> items) throws Exception {
|
||||||
|
shipRepository.saveAll(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java
Normal file
50
src/main/java/com/snp/batch/scheduler/QuartzBatchJob.java
Normal file
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
120
src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java
Normal file
120
src/main/java/com/snp/batch/scheduler/SchedulerInitializer.java
Normal file
@ -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<JobScheduleEntity> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
566
src/main/java/com/snp/batch/service/BatchService.java
Normal file
566
src/main/java/com/snp/batch/service/BatchService.java
Normal file
@ -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<String, Job> jobMap;
|
||||||
|
private final ScheduleService scheduleService;
|
||||||
|
private final TimelineRepository timelineRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
public BatchService(JobLauncher jobLauncher,
|
||||||
|
JobExplorer jobExplorer,
|
||||||
|
JobOperator jobOperator,
|
||||||
|
Map<String, Job> 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<String, String> 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<String> listAllJobs() {
|
||||||
|
return new ArrayList<>(jobMap.keySet());
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<JobExecutionDto> getJobExecutions(String jobName) {
|
||||||
|
List<JobInstance> 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<String, Object> 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<com.snp.batch.global.dto.JobExecutionDetailDto.StepExecutionDto> 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<String, Object> parameters = null;
|
||||||
|
if (context.containsKey("apiParameters")) {
|
||||||
|
Object paramsObj = context.get("apiParameters");
|
||||||
|
if (paramsObj instanceof Map) {
|
||||||
|
parameters = (Map<String, Object>) 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<com.snp.batch.global.dto.TimelineResponse.PeriodInfo> 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<com.snp.batch.global.dto.ScheduleResponse> activeSchedules = scheduleService.getAllActiveSchedules();
|
||||||
|
Map<String, com.snp.batch.global.dto.ScheduleResponse> scheduleMap = activeSchedules.stream()
|
||||||
|
.collect(Collectors.toMap(
|
||||||
|
com.snp.batch.global.dto.ScheduleResponse::getJobName,
|
||||||
|
s -> s
|
||||||
|
));
|
||||||
|
|
||||||
|
// 모든 Job의 실행 이력을 한 번의 쿼리로 조회 (경량화)
|
||||||
|
List<Map<String, Object>> allExecutions = timelineRepository.findAllExecutionsByDateRange(rangeStart, rangeEnd);
|
||||||
|
|
||||||
|
// Job별로 그룹화
|
||||||
|
Map<String, List<Map<String, Object>>> executionsByJob = allExecutions.stream()
|
||||||
|
.collect(Collectors.groupingBy(exec -> (String) exec.get("jobName")));
|
||||||
|
|
||||||
|
// 타임라인 스케줄 구성
|
||||||
|
java.util.List<com.snp.batch.global.dto.TimelineResponse.ScheduleTimeline> schedules = new ArrayList<>();
|
||||||
|
|
||||||
|
// 실행 이력이 있거나 스케줄이 있는 모든 Job 처리
|
||||||
|
Set<String> allJobNames = new HashSet<>(executionsByJob.keySet());
|
||||||
|
allJobNames.addAll(scheduleMap.keySet());
|
||||||
|
|
||||||
|
for (String jobName : allJobNames) {
|
||||||
|
if (!jobMap.containsKey(jobName)) {
|
||||||
|
continue; // 현재 존재하지 않는 Job은 스킵
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Map<String, Object>> jobExecutions = executionsByJob.getOrDefault(jobName, Collections.emptyList());
|
||||||
|
Map<String, com.snp.batch.global.dto.TimelineResponse.ExecutionInfo> executions = new HashMap<>();
|
||||||
|
|
||||||
|
// 각 period에 대해 실행 이력 또는 예정 상태 매핑
|
||||||
|
for (com.snp.batch.global.dto.TimelineResponse.PeriodInfo period : periods) {
|
||||||
|
Map<String, Object> 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<String, Object> findExecutionForPeriodFromMap(
|
||||||
|
List<Map<String, Object>> 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<JobExecutionDto> getPeriodExecutions(String jobName, String view, String periodKey) {
|
||||||
|
List<JobInstance> 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<com.snp.batch.global.dto.ScheduleResponse> 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<Map<String, Object>> runningData = timelineRepository.findRunningExecutions();
|
||||||
|
List<com.snp.batch.global.dto.DashboardResponse.RunningJob> 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<Map<String, Object>> recentData = timelineRepository.findRecentExecutions(10);
|
||||||
|
List<com.snp.batch.global.dto.DashboardResponse.RecentExecution> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/main/java/com/snp/batch/service/QuartzJobService.java
Normal file
68
src/main/java/com/snp/batch/service/QuartzJobService.java
Normal file
@ -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<String, Job> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
354
src/main/java/com/snp/batch/service/ScheduleService.java
Normal file
354
src/main/java/com/snp/batch/service/ScheduleService.java
Normal file
@ -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<String, Job> 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<ScheduleResponse> 스케줄 목록
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ScheduleResponse> getAllSchedules() {
|
||||||
|
return scheduleRepository.findAll().stream()
|
||||||
|
.map(this::convertToResponse)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 활성화된 스케줄 목록 조회
|
||||||
|
*
|
||||||
|
* @return List<ScheduleResponse> 활성 스케줄 목록
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ScheduleResponse> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
97
src/main/resources/application.yml
Normal file
97
src/main/resources/application.yml
Normal file
@ -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
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
64
src/main/resources/db/schema/ship_detail.sql
Normal file
64
src/main/resources/db/schema/ship_detail.sql
Normal file
@ -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)';
|
||||||
509
src/main/resources/templates/execution-detail.html
Normal file
509
src/main/resources/templates/execution-detail.html
Normal file
@ -0,0 +1,509 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>실행 상세 - SNP 배치</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn.secondary {
|
||||||
|
background: #48bb78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn.secondary:hover {
|
||||||
|
background: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-item {
|
||||||
|
padding: 15px;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
border-left: 4px solid #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 16px;
|
||||||
|
color: #2d3748;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-COMPLETED {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-FAILED {
|
||||||
|
background: #fed7d7;
|
||||||
|
color: #742a2a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-STARTED {
|
||||||
|
background: #bee3f8;
|
||||||
|
color: #2c5282;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-STOPPED {
|
||||||
|
background: #feebc8;
|
||||||
|
color: #7c2d12;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 14px;
|
||||||
|
opacity: 0.9;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item {
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-item:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-name {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-stat {
|
||||||
|
padding: 10px;
|
||||||
|
background: #edf2f7;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.step-stat-value {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #e53e3e;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-list {
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-item {
|
||||||
|
padding: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
background: #f7fafc;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-key {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.param-value {
|
||||||
|
color: #2d3748;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>실행 상세 정보</h1>
|
||||||
|
<div class="button-group">
|
||||||
|
<a href="/" class="back-btn secondary">← 대시보드로</a>
|
||||||
|
<a href="/executions" class="back-btn">← 실행 이력으로</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="content" class="content">
|
||||||
|
<div class="loading">상세 정보 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// URL에서 실행 ID 추출 (두 가지 형식 지원)
|
||||||
|
// 1. Path parameter: /executions/123
|
||||||
|
// 2. Query parameter: /execution-detail?id=123
|
||||||
|
let executionId = null;
|
||||||
|
|
||||||
|
const pathMatch = window.location.pathname.match(/\/executions\/(\d+)/);
|
||||||
|
if (pathMatch) {
|
||||||
|
executionId = pathMatch[1];
|
||||||
|
} else {
|
||||||
|
executionId = new URLSearchParams(window.location.search).get('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!executionId) {
|
||||||
|
document.getElementById('content').innerHTML =
|
||||||
|
'<div class="error">실행 ID가 제공되지 않았습니다.</div>';
|
||||||
|
} else {
|
||||||
|
loadExecutionDetail();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExecutionDetail() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/executions/${executionId}/detail`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('실행 정보를 찾을 수 없습니다.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const detail = await response.json();
|
||||||
|
renderDetail(detail);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('content').innerHTML =
|
||||||
|
`<div class="error">에러 발생: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDetail(detail) {
|
||||||
|
const duration = detail.duration ? formatDuration(detail.duration) : '-';
|
||||||
|
const startTime = detail.startTime ? new Date(detail.startTime).toLocaleString('ko-KR') : '-';
|
||||||
|
const endTime = detail.endTime ? new Date(detail.endTime).toLocaleString('ko-KR') : '-';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!-- 기본 정보 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Job 실행 정보</h2>
|
||||||
|
<div class="info-grid">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">실행 ID</div>
|
||||||
|
<div class="info-value">${detail.executionId}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Job 이름</div>
|
||||||
|
<div class="info-value">${detail.jobName}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">상태</div>
|
||||||
|
<div class="info-value">
|
||||||
|
<span class="status-badge status-${detail.status}">${detail.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">실행 시간</div>
|
||||||
|
<div class="info-value">${duration}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">시작 시간</div>
|
||||||
|
<div class="info-value">${startTime}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">종료 시간</div>
|
||||||
|
<div class="info-value">${endTime}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Job Instance ID</div>
|
||||||
|
<div class="info-value">${detail.jobInstanceId}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Exit Code</div>
|
||||||
|
<div class="info-value">${detail.exitCode || '-'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${detail.exitMessage ? `
|
||||||
|
<div style="margin-top: 20px; padding: 15px; background: #fff5f5; border-left: 4px solid #fc8181; border-radius: 6px;">
|
||||||
|
<div style="font-weight: 600; color: #742a2a; margin-bottom: 5px;">Exit Message</div>
|
||||||
|
<div style="color: #742a2a;">${detail.exitMessage}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 통계 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">실행 통계</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">읽기</div>
|
||||||
|
<div class="stat-value">${detail.readCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">쓰기</div>
|
||||||
|
<div class="stat-value">${detail.writeCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">스킵</div>
|
||||||
|
<div class="stat-value">${detail.skipCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<div class="stat-label">필터</div>
|
||||||
|
<div class="stat-value">${detail.filterCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Parameters -->
|
||||||
|
${detail.jobParameters && Object.keys(detail.jobParameters).length > 0 ? `
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Job Parameters</h2>
|
||||||
|
<ul class="param-list">
|
||||||
|
${Object.entries(detail.jobParameters).map(([key, value]) => `
|
||||||
|
<li class="param-item">
|
||||||
|
<span class="param-key">${key}</span>
|
||||||
|
<span class="param-value">${value}</span>
|
||||||
|
</li>
|
||||||
|
`).join('')}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Step 실행 정보 -->
|
||||||
|
<div class="section">
|
||||||
|
<h2 class="section-title">Step 실행 정보 (${detail.stepExecutions.length}개)</h2>
|
||||||
|
<div class="step-list">
|
||||||
|
${detail.stepExecutions.map(step => renderStep(step)).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('content').innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStep(step) {
|
||||||
|
const duration = step.duration ? formatDuration(step.duration) : '-';
|
||||||
|
const startTime = step.startTime ? new Date(step.startTime).toLocaleString('ko-KR') : '-';
|
||||||
|
const endTime = step.endTime ? new Date(step.endTime).toLocaleString('ko-KR') : '-';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="step-item">
|
||||||
|
<div class="step-header">
|
||||||
|
<div class="step-name">${step.stepName}</div>
|
||||||
|
<span class="status-badge status-${step.status}">${step.status}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="info-grid" style="margin-bottom: 15px;">
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">Step ID</div>
|
||||||
|
<div class="info-value">${step.stepExecutionId}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">실행 시간</div>
|
||||||
|
<div class="info-value">${duration}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">시작 시간</div>
|
||||||
|
<div class="info-value">${startTime}</div>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<div class="info-label">종료 시간</div>
|
||||||
|
<div class="info-value">${endTime}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="step-stats">
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">읽기</div>
|
||||||
|
<div class="step-stat-value">${step.readCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">쓰기</div>
|
||||||
|
<div class="step-stat-value">${step.writeCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">커밋</div>
|
||||||
|
<div class="step-stat-value">${step.commitCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">롤백</div>
|
||||||
|
<div class="step-stat-value">${step.rollbackCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">읽기 스킵</div>
|
||||||
|
<div class="step-stat-value">${step.readSkipCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">처리 스킵</div>
|
||||||
|
<div class="step-stat-value">${step.processSkipCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">쓰기 스킵</div>
|
||||||
|
<div class="step-stat-value">${step.writeSkipCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
<div class="step-stat">
|
||||||
|
<div class="step-stat-label">필터</div>
|
||||||
|
<div class="step-stat-value">${step.filterCount || 0}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${step.exitMessage ? `
|
||||||
|
<div style="margin-top: 15px; padding: 10px; background: #fff5f5; border-radius: 6px;">
|
||||||
|
<div style="font-size: 12px; font-weight: 600; color: #742a2a; margin-bottom: 5px;">Exit Message</div>
|
||||||
|
<div style="font-size: 14px; color: #742a2a;">${step.exitMessage}</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms) {
|
||||||
|
const seconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}시간 ${minutes % 60}분 ${seconds % 60}초`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}분 ${seconds % 60}초`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}초`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
390
src/main/resources/templates/executions.html
Normal file
390
src/main/resources/templates/executions.html
Normal file
@ -0,0 +1,390 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>작업 실행 이력 - SNP 배치</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-section label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-responsive {
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table thead th {
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr {
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody tr:hover {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tbody td {
|
||||||
|
padding: 15px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-text {
|
||||||
|
color: #718096;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid" style="max-width: 1400px;">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center">
|
||||||
|
<h1><i class="bi bi-clock-history"></i> 작업 실행 이력</h1>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house-door"></i> 대시보드로 돌아가기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="content-card">
|
||||||
|
<!-- Filter Section -->
|
||||||
|
<div class="filter-section">
|
||||||
|
<label for="jobFilter"><i class="bi bi-funnel"></i> 작업으로 필터링</label>
|
||||||
|
<select id="jobFilter" class="form-select" onchange="loadExecutions()">
|
||||||
|
<option value="">작업 로딩 중...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Executions Table -->
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover" id="executionTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>실행 ID</th>
|
||||||
|
<th>작업명</th>
|
||||||
|
<th>상태</th>
|
||||||
|
<th>시작 시간</th>
|
||||||
|
<th>종료 시간</th>
|
||||||
|
<th>소요 시간</th>
|
||||||
|
<th>액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="executionTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">실행 이력 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 JS Bundle -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentJobName = null;
|
||||||
|
|
||||||
|
// Load jobs for filter dropdown
|
||||||
|
async function loadJobs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/jobs');
|
||||||
|
const jobs = await response.json();
|
||||||
|
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const preselectedJob = urlParams.get('job');
|
||||||
|
|
||||||
|
const select = document.getElementById('jobFilter');
|
||||||
|
select.innerHTML = '<option value="">모든 작업</option>' +
|
||||||
|
jobs.map(job => `<option value="${job}" ${job === preselectedJob ? 'selected' : ''}>${job}</option>`).join('');
|
||||||
|
|
||||||
|
if (preselectedJob) {
|
||||||
|
currentJobName = preselectedJob;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadExecutions();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업 로드 오류:', error);
|
||||||
|
const select = document.getElementById('jobFilter');
|
||||||
|
select.innerHTML = '<option value="">작업 로드 실패</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load executions for selected job
|
||||||
|
async function loadExecutions() {
|
||||||
|
const jobFilter = document.getElementById('jobFilter').value;
|
||||||
|
currentJobName = jobFilter || null;
|
||||||
|
|
||||||
|
const tbody = document.getElementById('executionTableBody');
|
||||||
|
|
||||||
|
if (!currentJobName) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<div>실행 이력을 보려면 작업을 선택하세요</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="loading-state">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">실행 이력 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/jobs/${currentJobName}/executions`);
|
||||||
|
const executions = await response.json();
|
||||||
|
|
||||||
|
if (executions.length === 0) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<div>이 작업의 실행 이력이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = executions.map(execution => {
|
||||||
|
const duration = calculateDuration(execution.startTime, execution.endTime);
|
||||||
|
const statusBadge = getStatusBadge(execution.status);
|
||||||
|
|
||||||
|
return `
|
||||||
|
<tr>
|
||||||
|
<td><strong>${execution.executionId}</strong></td>
|
||||||
|
<td>${execution.jobName}</td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${formatDateTime(execution.startTime)}</td>
|
||||||
|
<td>${formatDateTime(execution.endTime)}</td>
|
||||||
|
<td><span class="duration-text">${duration}</span></td>
|
||||||
|
<td>
|
||||||
|
${execution.status === 'STARTED' || execution.status === 'STARTING' ?
|
||||||
|
`<button class="btn btn-sm btn-danger" onclick="stopExecution(${execution.executionId})">
|
||||||
|
<i class="bi bi-stop-circle"></i> 중지
|
||||||
|
</button>` :
|
||||||
|
`<button class="btn btn-sm btn-info" onclick="viewDetails(${execution.executionId})">
|
||||||
|
<i class="bi bi-info-circle"></i> 상세
|
||||||
|
</button>`
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
tbody.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td colspan="7">
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-exclamation-circle text-danger"></i>
|
||||||
|
<div>실행 이력 로드 오류: ${error.message}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status badge HTML
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'COMPLETED': { class: 'bg-success', icon: 'check-circle', text: '완료' },
|
||||||
|
'FAILED': { class: 'bg-danger', icon: 'x-circle', text: '실패' },
|
||||||
|
'STARTED': { class: 'bg-primary', icon: 'arrow-repeat', text: '실행중' },
|
||||||
|
'STARTING': { class: 'bg-info', icon: 'hourglass-split', text: '시작중' },
|
||||||
|
'STOPPED': { class: 'bg-warning', icon: 'stop-circle', text: '중지됨' },
|
||||||
|
'STOPPING': { class: 'bg-warning', icon: 'stop-circle', text: '중지중' },
|
||||||
|
'UNKNOWN': { class: 'bg-secondary', icon: 'question-circle', text: '알수없음' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = statusMap[status] || statusMap['UNKNOWN'];
|
||||||
|
return `<span class="badge ${badge.class}"><i class="bi bi-${badge.icon}"></i> ${badge.text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format datetime
|
||||||
|
function formatDateTime(dateTime) {
|
||||||
|
if (!dateTime) return '<span class="text-muted">-</span>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTime);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
} catch (error) {
|
||||||
|
return dateTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate duration between start and end time
|
||||||
|
function calculateDuration(startTime, endTime) {
|
||||||
|
if (!startTime) return '없음';
|
||||||
|
if (!endTime) return '<span class="badge bg-primary">실행 중...</span>';
|
||||||
|
|
||||||
|
const start = new Date(startTime);
|
||||||
|
const end = new Date(endTime);
|
||||||
|
const diff = end - start;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}시간 ${minutes % 60}분 ${seconds % 60}초`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}분 ${seconds % 60}초`;
|
||||||
|
} else {
|
||||||
|
return `${seconds}초`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop execution
|
||||||
|
async function stopExecution(executionId) {
|
||||||
|
if (!confirm(`실행을 중지하시겠습니까?\n실행 ID: ${executionId}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/executions/${executionId}/stop`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('실행 중지 요청이 완료되었습니다');
|
||||||
|
setTimeout(() => loadExecutions(), 1000);
|
||||||
|
} else {
|
||||||
|
alert('실행 중지 실패: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('실행 중지 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// View execution details
|
||||||
|
function viewDetails(executionId) {
|
||||||
|
window.location.href = `/executions/${executionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadJobs();
|
||||||
|
|
||||||
|
// Auto-refresh every 5 seconds if viewing executions
|
||||||
|
setInterval(() => {
|
||||||
|
if (currentJobName) {
|
||||||
|
loadExecutions();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
586
src/main/resources/templates/index.html
Normal file
586
src/main/resources/templates/index.html
Normal file
@ -0,0 +1,586 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>S&P 배치 관리 시스템</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--card-shadow-hover: 0 8px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding-right: 150px; /* 버튼 공간 확보 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header .subtitle {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-btn {
|
||||||
|
position: absolute;
|
||||||
|
top: 30px;
|
||||||
|
right: 30px;
|
||||||
|
background: linear-gradient(135deg, #85ce36 0%, #5fa529 100%);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
background: linear-gradient(135deg, #5fa529 0%, #85ce36 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-btn i {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 반응형: 모바일 환경 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dashboard-header h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
padding-right: 0;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.swagger-btn {
|
||||||
|
position: static;
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-header .subtitle {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
text-align: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: var(--card-shadow-hover);
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .icon {
|
||||||
|
font-size: 36px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .value {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card .label {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-item:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
transform: translateX(5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details h5 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-details p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
transition: all 0.3s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-item:hover {
|
||||||
|
background: #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-info .job-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.execution-info .execution-meta {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link {
|
||||||
|
text-align: center;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link a {
|
||||||
|
color: #667eea;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-all-link a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="dashboard-header">
|
||||||
|
<a href="/swagger-ui/index.html" target="_blank" class="swagger-btn" title="Swagger API 문서 열기">
|
||||||
|
<i class="bi bi-file-earmark-code"></i>
|
||||||
|
<span>API 문서</span>
|
||||||
|
</a>
|
||||||
|
<h1><i class="bi bi-grid-3x3-gap-fill"></i> S&P 배치 관리 시스템</h1>
|
||||||
|
<p class="subtitle">S&P Global Web API 데이터를 PostgreSQL에 통합하는 배치 모니터링 페이지</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Status Overview -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="bi bi-clock-history"></i>
|
||||||
|
스케줄 현황
|
||||||
|
<a href="/schedule-timeline" class="btn btn-warning btn-sm ms-auto">
|
||||||
|
<i class="bi bi-calendar3"></i> 스케줄 타임라인
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3" id="scheduleStats">
|
||||||
|
<div class="col-md-3 col-sm-6">
|
||||||
|
<div class="stat-card" onclick="location.href='/schedules'">
|
||||||
|
<div class="icon"><i class="bi bi-calendar-check text-primary"></i></div>
|
||||||
|
<div class="value" id="totalSchedules">-</div>
|
||||||
|
<div class="label">전체 스케줄</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6">
|
||||||
|
<div class="stat-card" onclick="location.href='/schedules'">
|
||||||
|
<div class="icon"><i class="bi bi-play-circle text-success"></i></div>
|
||||||
|
<div class="value" id="activeSchedules">-</div>
|
||||||
|
<div class="label">활성 스케줄</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6">
|
||||||
|
<div class="stat-card" onclick="location.href='/schedules'">
|
||||||
|
<div class="icon"><i class="bi bi-pause-circle text-warning"></i></div>
|
||||||
|
<div class="value" id="inactiveSchedules">-</div>
|
||||||
|
<div class="label">비활성 스케줄</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3 col-sm-6">
|
||||||
|
<div class="stat-card" onclick="location.href='/jobs'">
|
||||||
|
<div class="icon"><i class="bi bi-file-earmark-code text-info"></i></div>
|
||||||
|
<div class="value" id="totalJobs">-</div>
|
||||||
|
<div class="label">등록된 Job</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Currently Running Jobs -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="bi bi-arrow-repeat"></i>
|
||||||
|
현재 진행 중인 Job
|
||||||
|
<span class="badge bg-primary ms-auto" id="runningCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div id="runningJobs">
|
||||||
|
<div class="spinner-container">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Execution History -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="bi bi-list-check"></i>
|
||||||
|
최근 실행 이력
|
||||||
|
</div>
|
||||||
|
<div id="recentExecutions">
|
||||||
|
<div class="spinner-container">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="view-all-link">
|
||||||
|
<a href="/executions">전체 실행 이력 보기 <i class="bi bi-arrow-right"></i></a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="section-card">
|
||||||
|
<div class="section-title">
|
||||||
|
<i class="bi bi-lightning-charge"></i>
|
||||||
|
빠른 작업
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-3 flex-wrap">
|
||||||
|
<button class="btn btn-primary" onclick="showExecuteJobModal()">
|
||||||
|
<i class="bi bi-play-fill"></i> 작업 즉시 실행
|
||||||
|
</button>
|
||||||
|
<a href="/jobs" class="btn btn-info">
|
||||||
|
<i class="bi bi-list-ul"></i> 모든 작업 보기
|
||||||
|
</a>
|
||||||
|
<a href="/schedules" class="btn btn-success">
|
||||||
|
<i class="bi bi-calendar-plus"></i> 스케줄 관리
|
||||||
|
</a>
|
||||||
|
<a href="/executions" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-clock-history"></i> 실행 이력
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Job Execution Modal -->
|
||||||
|
<div class="modal fade" id="executeJobModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">작업 즉시 실행</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="jobSelect" class="form-label">실행할 작업 선택</label>
|
||||||
|
<select class="form-select" id="jobSelect">
|
||||||
|
<option value="">작업을 선택하세요...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">취소</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="executeJob()">실행</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 JS Bundle -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let executeModal;
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
executeModal = new bootstrap.Modal(document.getElementById('executeJobModal'));
|
||||||
|
loadDashboardData();
|
||||||
|
|
||||||
|
// Auto-refresh dashboard every 5 seconds
|
||||||
|
setInterval(loadDashboardData, 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load all dashboard data (single API call)
|
||||||
|
async function loadDashboardData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/dashboard');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
// Update stats
|
||||||
|
document.getElementById('totalSchedules').textContent = data.stats.totalSchedules;
|
||||||
|
document.getElementById('activeSchedules').textContent = data.stats.activeSchedules;
|
||||||
|
document.getElementById('inactiveSchedules').textContent = data.stats.inactiveSchedules;
|
||||||
|
document.getElementById('totalJobs').textContent = data.stats.totalJobs;
|
||||||
|
|
||||||
|
// Update running jobs
|
||||||
|
document.getElementById('runningCount').textContent = data.runningJobs.length;
|
||||||
|
|
||||||
|
const runningContainer = document.getElementById('runningJobs');
|
||||||
|
if (data.runningJobs.length === 0) {
|
||||||
|
runningContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<div>현재 진행 중인 작업이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
runningContainer.innerHTML = data.runningJobs.map(job => `
|
||||||
|
<div class="job-item">
|
||||||
|
<div class="job-info">
|
||||||
|
<div class="job-icon">
|
||||||
|
<i class="bi bi-arrow-repeat text-primary"></i>
|
||||||
|
</div>
|
||||||
|
<div class="job-details">
|
||||||
|
<h5>${job.jobName}</h5>
|
||||||
|
<p>실행 ID: ${job.executionId} | 시작: ${formatDateTime(job.startTime)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge bg-primary">
|
||||||
|
<i class="bi bi-arrow-repeat"></i> ${job.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update recent executions
|
||||||
|
const recentContainer = document.getElementById('recentExecutions');
|
||||||
|
if (data.recentExecutions.length === 0) {
|
||||||
|
recentContainer.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<div>실행 이력이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
recentContainer.innerHTML = data.recentExecutions.map(exec => `
|
||||||
|
<div class="execution-item" onclick="location.href='/executions/${exec.executionId}'">
|
||||||
|
<div class="execution-info">
|
||||||
|
<div class="job-name">${exec.jobName}</div>
|
||||||
|
<div class="execution-meta">
|
||||||
|
ID: ${exec.executionId} | 시작: ${formatDateTime(exec.startTime)}
|
||||||
|
${exec.endTime ? ` | 종료: ${formatDateTime(exec.endTime)}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${getStatusBadge(exec.status)}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('대시보드 데이터 로드 오류:', error);
|
||||||
|
// Show error state for all sections
|
||||||
|
document.getElementById('totalSchedules').textContent = '0';
|
||||||
|
document.getElementById('activeSchedules').textContent = '0';
|
||||||
|
document.getElementById('inactiveSchedules').textContent = '0';
|
||||||
|
document.getElementById('totalJobs').textContent = '0';
|
||||||
|
|
||||||
|
document.getElementById('runningJobs').innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
|
<div>데이터를 불러올 수 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.getElementById('recentExecutions').innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-exclamation-circle"></i>
|
||||||
|
<div>데이터를 불러올 수 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show execute job modal
|
||||||
|
async function showExecuteJobModal() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/jobs');
|
||||||
|
const jobs = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('jobSelect');
|
||||||
|
select.innerHTML = '<option value="">작업을 선택하세요...</option>';
|
||||||
|
|
||||||
|
jobs.forEach(job => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = job;
|
||||||
|
option.textContent = job;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
executeModal.show();
|
||||||
|
} catch (error) {
|
||||||
|
alert('작업 목록을 불러올 수 없습니다: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute selected job
|
||||||
|
async function executeJob() {
|
||||||
|
const jobName = document.getElementById('jobSelect').value;
|
||||||
|
|
||||||
|
if (!jobName) {
|
||||||
|
alert('실행할 작업을 선택하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
executeModal.hide();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(`작업이 성공적으로 시작되었습니다!\n실행 ID: ${result.executionId}`);
|
||||||
|
// Reload dashboard data after 1 second
|
||||||
|
setTimeout(loadDashboardData, 1000);
|
||||||
|
} else {
|
||||||
|
alert('작업 시작 실패: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('작업 실행 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility: Get status badge HTML
|
||||||
|
function getStatusBadge(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'COMPLETED': { class: 'bg-success', icon: 'check-circle', text: '완료' },
|
||||||
|
'FAILED': { class: 'bg-danger', icon: 'x-circle', text: '실패' },
|
||||||
|
'STARTED': { class: 'bg-primary', icon: 'arrow-repeat', text: '실행중' },
|
||||||
|
'STARTING': { class: 'bg-info', icon: 'hourglass-split', text: '시작중' },
|
||||||
|
'STOPPED': { class: 'bg-warning', icon: 'stop-circle', text: '중지됨' },
|
||||||
|
'UNKNOWN': { class: 'bg-secondary', icon: 'question-circle', text: '알수없음' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const badge = statusMap[status] || statusMap['UNKNOWN'];
|
||||||
|
return `<span class="badge ${badge.class}"><i class="bi bi-${badge.icon}"></i> ${badge.text}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility: Format datetime
|
||||||
|
function formatDateTime(dateTimeStr) {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
} catch (error) {
|
||||||
|
return dateTimeStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
295
src/main/resources/templates/jobs.html
Normal file
295
src/main/resources/templates/jobs.html
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>배치 작업 - SNP 배치</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-item {
|
||||||
|
border: 2px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-item:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #f7fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-info h3 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-info p {
|
||||||
|
color: #666;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.job-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-execute {
|
||||||
|
background: #48bb78;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-execute:hover {
|
||||||
|
background: #38a169;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view {
|
||||||
|
background: #4299e1;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-view:hover {
|
||||||
|
background: #3182ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-active {
|
||||||
|
background: #c6f6d5;
|
||||||
|
color: #22543d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background: white;
|
||||||
|
margin: 10% auto;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
max-width: 500px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal {
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 28px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-modal:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>배치 작업</h1>
|
||||||
|
<a href="/" class="back-btn">← 대시보드로 돌아가기</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content">
|
||||||
|
<div id="jobList" class="job-list">
|
||||||
|
<div class="loading">작업 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="resultModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-modal" onclick="closeModal()">×</span>
|
||||||
|
<h2 id="modalTitle">결과</h2>
|
||||||
|
<p id="modalMessage"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function loadJobs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/jobs');
|
||||||
|
const jobs = await response.json();
|
||||||
|
|
||||||
|
const jobListDiv = document.getElementById('jobList');
|
||||||
|
|
||||||
|
if (jobs.length === 0) {
|
||||||
|
jobListDiv.innerHTML = '<div class="empty">작업을 찾을 수 없습니다</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jobListDiv.innerHTML = jobs.map(job => `
|
||||||
|
<div class="job-item">
|
||||||
|
<div class="job-info">
|
||||||
|
<h3>
|
||||||
|
${job}
|
||||||
|
<span class="status-badge status-active">활성</span>
|
||||||
|
</h3>
|
||||||
|
<p>JSON 데이터를 PostgreSQL로 통합하는 배치 작업</p>
|
||||||
|
</div>
|
||||||
|
<div class="job-actions">
|
||||||
|
<button class="btn btn-execute" onclick="executeJob('${job}')">
|
||||||
|
실행
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-view" onclick="viewExecutions('${job}')">
|
||||||
|
이력 보기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('jobList').innerHTML =
|
||||||
|
'<div class="empty">작업 로드 오류: ' + error.message + '</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeJob(jobName) {
|
||||||
|
if (!confirm(`작업을 실행하시겠습니까: ${jobName}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/jobs/${jobName}/execute`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showModal('성공', `작업이 성공적으로 시작되었습니다!\n실행 ID: ${result.executionId}`);
|
||||||
|
} else {
|
||||||
|
showModal('오류', '작업 시작 실패: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showModal('오류', '작업 실행 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function viewExecutions(jobName) {
|
||||||
|
window.location.href = `/executions?job=${jobName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal(title, message) {
|
||||||
|
document.getElementById('modalTitle').textContent = title;
|
||||||
|
document.getElementById('modalMessage').textContent = message;
|
||||||
|
document.getElementById('resultModal').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
document.getElementById('resultModal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
window.onclick = function(event) {
|
||||||
|
const modal = document.getElementById('resultModal');
|
||||||
|
if (event.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load jobs on page load
|
||||||
|
loadJobs();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
829
src/main/resources/templates/schedule-timeline.html
Normal file
829
src/main/resources/templates/schedule-timeline.html
Normal file
@ -0,0 +1,829 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>스케줄 타임라인 - SNP 배치</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
--success-color: #10b981;
|
||||||
|
--error-color: #ef4444;
|
||||||
|
--running-color: #3b82f6;
|
||||||
|
--scheduled-color: #8b5cf6;
|
||||||
|
--stopped-color: #6b7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.view-btn.active {
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: white;
|
||||||
|
z-index: 10;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header-cell {
|
||||||
|
text-align: center;
|
||||||
|
padding: 10px 5px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header-label {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-row {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-job-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
padding: 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 6px;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell {
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 2px solid #e5e7eb;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.completed {
|
||||||
|
background: var(--success-color);
|
||||||
|
border-color: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.failed {
|
||||||
|
background: var(--error-color);
|
||||||
|
border-color: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.running {
|
||||||
|
background: var(--running-color);
|
||||||
|
border-color: var(--running-color);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.scheduled {
|
||||||
|
background: var(--scheduled-color);
|
||||||
|
border-color: var(--scheduled-color);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.stopped {
|
||||||
|
background: var(--stopped-color);
|
||||||
|
border-color: var(--stopped-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-icon {
|
||||||
|
color: white;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 15px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.completed {
|
||||||
|
background: var(--success-color);
|
||||||
|
border: 2px solid var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.failed {
|
||||||
|
background: var(--error-color);
|
||||||
|
border: 2px solid var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.running {
|
||||||
|
background: var(--running-color);
|
||||||
|
border: 2px solid var(--running-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.scheduled {
|
||||||
|
background: var(--scheduled-color);
|
||||||
|
border: 2px solid var(--scheduled-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-box.stopped {
|
||||||
|
background: var(--stopped-color);
|
||||||
|
border: 2px solid var(--stopped-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tooltip */
|
||||||
|
.custom-tooltip {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
color: white;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 1000;
|
||||||
|
pointer-events: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-tooltip.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-row {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tooltip-label {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Period Executions Panel */
|
||||||
|
.period-executions-panel {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f9fafb;
|
||||||
|
border-radius: 8px;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.period-executions-panel.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table {
|
||||||
|
width: 100%;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table th {
|
||||||
|
background: #f3f4f6;
|
||||||
|
padding: 12px;
|
||||||
|
text-align: left;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
font-size: 14px;
|
||||||
|
border-bottom: 2px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table td {
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
color: #4b5563;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.executions-table tbody tr:hover {
|
||||||
|
background: #f9fafb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.completed {
|
||||||
|
background: var(--success-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.failed {
|
||||||
|
background: var(--error-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.running {
|
||||||
|
background: var(--running-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.stopped {
|
||||||
|
background: var(--stopped-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-cell.selected {
|
||||||
|
box-shadow: 0 0 0 3px #fbbf24;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center">
|
||||||
|
<h1><i class="bi bi-calendar3"></i> 스케줄 타임라인</h1>
|
||||||
|
<div>
|
||||||
|
<a href="/schedules" class="btn btn-outline-primary me-2">
|
||||||
|
<i class="bi bi-calendar-check"></i> 스케줄 관리
|
||||||
|
</a>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house-door"></i> 대시보드
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline View -->
|
||||||
|
<div class="content-card">
|
||||||
|
<div class="view-controls">
|
||||||
|
<button class="view-btn active" data-view="day" onclick="changeView('day')">
|
||||||
|
<i class="bi bi-calendar-day"></i> 일별
|
||||||
|
</button>
|
||||||
|
<button class="view-btn" data-view="week" onclick="changeView('week')">
|
||||||
|
<i class="bi bi-calendar-week"></i> 주별
|
||||||
|
</button>
|
||||||
|
<button class="view-btn" data-view="month" onclick="changeView('month')">
|
||||||
|
<i class="bi bi-calendar-month"></i> 월별
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div style="margin-left: auto; display: flex; gap: 10px; align-items: center;">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(-1)">
|
||||||
|
<i class="bi bi-chevron-left"></i> 이전
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(0)">
|
||||||
|
<i class="bi bi-calendar-today"></i> 오늘
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-sm" onclick="navigatePeriod(1)">
|
||||||
|
다음 <i class="bi bi-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" onclick="loadTimeline()">
|
||||||
|
<i class="bi bi-arrow-clockwise"></i> 새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="periodInfo" class="mb-3 text-center" style="font-weight: 600; font-size: 16px; color: #374151;"></div>
|
||||||
|
|
||||||
|
<div class="timeline-container">
|
||||||
|
<div id="timelineGrid">
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">타임라인 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="legend">
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-box completed"><i class="bi bi-check-lg status-icon"></i></div>
|
||||||
|
<span>완료</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-box failed"><i class="bi bi-x-lg status-icon"></i></div>
|
||||||
|
<span>실패</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-box running"><i class="bi bi-arrow-clockwise status-icon"></i></div>
|
||||||
|
<span>실행중</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-box scheduled"><i class="bi bi-clock status-icon"></i></div>
|
||||||
|
<span>예정</span>
|
||||||
|
</div>
|
||||||
|
<div class="legend-item">
|
||||||
|
<div class="legend-box stopped"><i class="bi bi-pause-circle status-icon"></i></div>
|
||||||
|
<span>중지됨</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Period Executions Panel -->
|
||||||
|
<div id="periodExecutionsPanel" class="content-card period-executions-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<div>
|
||||||
|
<div class="panel-title" id="panelTitle">구간 실행 이력</div>
|
||||||
|
<div style="font-size: 14px; color: #6b7280; margin-top: 5px;" id="panelSubtitle"></div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" onclick="closePeriodPanel()">
|
||||||
|
<i class="bi bi-x-lg"></i> 닫기
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="executionsContent">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Custom Tooltip -->
|
||||||
|
<div id="customTooltip" class="custom-tooltip"></div>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 JS Bundle -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentView = 'day';
|
||||||
|
let currentDate = new Date();
|
||||||
|
|
||||||
|
// Change view type
|
||||||
|
function changeView(view) {
|
||||||
|
currentView = view;
|
||||||
|
document.querySelectorAll('.view-btn').forEach(btn => {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
document.querySelector(`[data-view="${view}"]`).classList.add('active');
|
||||||
|
loadTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate period
|
||||||
|
function navigatePeriod(direction) {
|
||||||
|
if (direction === 0) {
|
||||||
|
currentDate = new Date();
|
||||||
|
} else if (currentView === 'day') {
|
||||||
|
currentDate.setDate(currentDate.getDate() + direction);
|
||||||
|
} else if (currentView === 'week') {
|
||||||
|
currentDate.setDate(currentDate.getDate() + (direction * 7));
|
||||||
|
} else if (currentView === 'month') {
|
||||||
|
currentDate.setMonth(currentDate.getMonth() + direction);
|
||||||
|
}
|
||||||
|
loadTimeline();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load timeline data
|
||||||
|
async function loadTimeline() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/timeline?view=${currentView}&date=${currentDate.toISOString()}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
renderTimeline(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('타임라인 로드 오류:', error);
|
||||||
|
document.getElementById('timelineGrid').innerHTML = `
|
||||||
|
<div class="text-center py-5 text-danger">
|
||||||
|
<i class="bi bi-exclamation-circle" style="font-size: 48px;"></i>
|
||||||
|
<div class="mt-2">타임라인 로드 실패: ${error.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render timeline
|
||||||
|
function renderTimeline(data) {
|
||||||
|
const grid = document.getElementById('timelineGrid');
|
||||||
|
const periodInfo = document.getElementById('periodInfo');
|
||||||
|
|
||||||
|
if (!data.schedules || data.schedules.length === 0) {
|
||||||
|
grid.innerHTML = `
|
||||||
|
<div class="text-center py-5">
|
||||||
|
<i class="bi bi-inbox" style="font-size: 48px; color: #9ca3af;"></i>
|
||||||
|
<div class="mt-2" style="color: #6b7280;">활성화된 스케줄이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Period info
|
||||||
|
periodInfo.textContent = data.periodLabel || '';
|
||||||
|
|
||||||
|
// Calculate grid columns
|
||||||
|
const columnCount = data.periods.length;
|
||||||
|
const gridColumns = `200px repeat(${columnCount}, minmax(80px, 1fr))`;
|
||||||
|
|
||||||
|
// Build header
|
||||||
|
let headerHTML = `<div class="timeline-header" style="grid-template-columns: ${gridColumns};">`;
|
||||||
|
headerHTML += '<div class="timeline-header-label">작업명</div>';
|
||||||
|
data.periods.forEach(period => {
|
||||||
|
headerHTML += `<div class="timeline-header-cell">${period.label}</div>`;
|
||||||
|
});
|
||||||
|
headerHTML += '</div>';
|
||||||
|
|
||||||
|
// Build rows
|
||||||
|
let rowsHTML = '';
|
||||||
|
data.schedules.forEach(schedule => {
|
||||||
|
rowsHTML += `<div class="timeline-row" style="grid-template-columns: ${gridColumns};">`;
|
||||||
|
rowsHTML += `<div class="timeline-job-label">${schedule.jobName}</div>`;
|
||||||
|
|
||||||
|
data.periods.forEach(period => {
|
||||||
|
const execution = schedule.executions[period.key];
|
||||||
|
const statusClass = execution ? execution.status.toLowerCase() : '';
|
||||||
|
const icon = getStatusIcon(execution?.status);
|
||||||
|
|
||||||
|
rowsHTML += `<div class="timeline-cell ${statusClass}"
|
||||||
|
data-execution='${JSON.stringify(execution || {})}'
|
||||||
|
data-period="${period.label}"
|
||||||
|
data-period-key="${period.key}"
|
||||||
|
data-job="${schedule.jobName}"
|
||||||
|
onclick="loadPeriodExecutions('${schedule.jobName}', '${period.key}', '${period.label}')"
|
||||||
|
onmouseenter="showTooltip(event)"
|
||||||
|
onmouseleave="hideTooltip()">
|
||||||
|
${icon}
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
rowsHTML += '</div>';
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.innerHTML = headerHTML + rowsHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status icon
|
||||||
|
function getStatusIcon(status) {
|
||||||
|
if (!status) return '';
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
'COMPLETED': '<i class="bi bi-check-lg status-icon"></i>',
|
||||||
|
'FAILED': '<i class="bi bi-x-lg status-icon"></i>',
|
||||||
|
'RUNNING': '<i class="bi bi-arrow-clockwise status-icon"></i>',
|
||||||
|
'SCHEDULED': '<i class="bi bi-clock status-icon"></i>',
|
||||||
|
'STOPPED': '<i class="bi bi-pause-circle status-icon"></i>'
|
||||||
|
};
|
||||||
|
|
||||||
|
return icons[status.toUpperCase()] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show tooltip
|
||||||
|
function showTooltip(event) {
|
||||||
|
const cell = event.currentTarget;
|
||||||
|
const execution = JSON.parse(cell.dataset.execution);
|
||||||
|
const period = cell.dataset.period;
|
||||||
|
const jobName = cell.dataset.job;
|
||||||
|
|
||||||
|
const tooltip = document.getElementById('customTooltip');
|
||||||
|
|
||||||
|
if (!execution.status) {
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">작업:</span>${jobName}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">기간:</span>${period}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">상태:</span>실행 이력 없음
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
tooltip.innerHTML = `
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">작업:</span>${jobName}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">기간:</span>${period}
|
||||||
|
</div>
|
||||||
|
<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">상태:</span>${getStatusLabel(execution.status)}
|
||||||
|
</div>
|
||||||
|
${execution.startTime ? `<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">시작:</span>${formatDateTime(execution.startTime)}
|
||||||
|
</div>` : ''}
|
||||||
|
${execution.endTime ? `<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">종료:</span>${formatDateTime(execution.endTime)}
|
||||||
|
</div>` : ''}
|
||||||
|
${execution.executionId ? `<div class="tooltip-row">
|
||||||
|
<span class="tooltip-label">실행 ID:</span>${execution.executionId}
|
||||||
|
</div>` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
tooltip.classList.add('show');
|
||||||
|
positionTooltip(event, tooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide tooltip
|
||||||
|
function hideTooltip() {
|
||||||
|
document.getElementById('customTooltip').classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position tooltip
|
||||||
|
function positionTooltip(event, tooltip) {
|
||||||
|
const x = event.pageX + 15;
|
||||||
|
const y = event.pageY + 15;
|
||||||
|
|
||||||
|
tooltip.style.left = x + 'px';
|
||||||
|
tooltip.style.top = y + 'px';
|
||||||
|
|
||||||
|
// Adjust if tooltip goes off screen
|
||||||
|
const rect = tooltip.getBoundingClientRect();
|
||||||
|
if (rect.right > window.innerWidth) {
|
||||||
|
tooltip.style.left = (event.pageX - rect.width - 15) + 'px';
|
||||||
|
}
|
||||||
|
if (rect.bottom > window.innerHeight) {
|
||||||
|
tooltip.style.top = (event.pageY - rect.height - 15) + 'px';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status label
|
||||||
|
function getStatusLabel(status) {
|
||||||
|
const labels = {
|
||||||
|
'COMPLETED': '완료',
|
||||||
|
'FAILED': '실패',
|
||||||
|
'RUNNING': '실행중',
|
||||||
|
'SCHEDULED': '예정',
|
||||||
|
'STOPPED': '중지됨'
|
||||||
|
};
|
||||||
|
return labels[status.toUpperCase()] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format datetime
|
||||||
|
function formatDateTime(dateTimeStr) {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${month}/${day} ${hours}:${minutes}`;
|
||||||
|
} catch (error) {
|
||||||
|
return dateTimeStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load period executions
|
||||||
|
async function loadPeriodExecutions(jobName, periodKey, periodLabel) {
|
||||||
|
// Remove previous selection
|
||||||
|
document.querySelectorAll('.timeline-cell.selected').forEach(cell => {
|
||||||
|
cell.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add selection to clicked cell
|
||||||
|
event.target.closest('.timeline-cell').classList.add('selected');
|
||||||
|
|
||||||
|
const panel = document.getElementById('periodExecutionsPanel');
|
||||||
|
const content = document.getElementById('executionsContent');
|
||||||
|
const subtitle = document.getElementById('panelSubtitle');
|
||||||
|
|
||||||
|
// Show panel
|
||||||
|
panel.classList.add('show');
|
||||||
|
|
||||||
|
// Update subtitle
|
||||||
|
subtitle.textContent = `작업: ${jobName} | 기간: ${periodLabel}`;
|
||||||
|
|
||||||
|
// Show loading
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">실행 이력 조회 중...</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Scroll to panel
|
||||||
|
setTimeout(() => {
|
||||||
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/timeline/period-executions?jobName=${encodeURIComponent(jobName)}&view=${currentView}&periodKey=${encodeURIComponent(periodKey)}`);
|
||||||
|
const executions = await response.json();
|
||||||
|
|
||||||
|
renderPeriodExecutions(executions);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('실행 이력 로드 오류:', error);
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="text-center py-4 text-danger">
|
||||||
|
<i class="bi bi-exclamation-circle" style="font-size: 36px;"></i>
|
||||||
|
<div class="mt-2">실행 이력 로드 실패: ${error.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render period executions
|
||||||
|
function renderPeriodExecutions(executions) {
|
||||||
|
const content = document.getElementById('executionsContent');
|
||||||
|
|
||||||
|
if (!executions || executions.length === 0) {
|
||||||
|
content.innerHTML = `
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<i class="bi bi-inbox" style="font-size: 36px; color: #9ca3af;"></i>
|
||||||
|
<div class="mt-2" style="color: #6b7280;">해당 구간에 실행 이력이 없습니다</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tableHTML = `
|
||||||
|
<div class="executions-table">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 100px;">실행 ID</th>
|
||||||
|
<th style="width: 120px;">상태</th>
|
||||||
|
<th>시작 시간</th>
|
||||||
|
<th>종료 시간</th>
|
||||||
|
<th style="width: 100px;">종료 코드</th>
|
||||||
|
<th>종료 메시지</th>
|
||||||
|
<th style="width: 100px;">작업</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
executions.forEach(exec => {
|
||||||
|
const statusBadge = `<span class="status-badge ${exec.status.toLowerCase()}">${getStatusLabel(exec.status)}</span>`;
|
||||||
|
const startTime = formatDateTime(exec.startTime);
|
||||||
|
const endTime = exec.endTime ? formatDateTime(exec.endTime) : '-';
|
||||||
|
const exitMessage = exec.exitMessage || '-';
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
<tr>
|
||||||
|
<td><a href="/executions/${exec.executionId}" class="text-primary" style="text-decoration: none; font-weight: 600;">#${exec.executionId}</a></td>
|
||||||
|
<td>${statusBadge}</td>
|
||||||
|
<td>${startTime}</td>
|
||||||
|
<td>${endTime}</td>
|
||||||
|
<td><code style="font-size: 12px;">${exec.exitCode}</code></td>
|
||||||
|
<td style="max-width: 300px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${exitMessage}">${exitMessage}</td>
|
||||||
|
<td>
|
||||||
|
<a href="/executions/${exec.executionId}" class="btn btn-sm btn-outline-primary" style="font-size: 12px;">
|
||||||
|
<i class="bi bi-eye"></i> 상세
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
});
|
||||||
|
|
||||||
|
tableHTML += `
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-end">
|
||||||
|
<small class="text-muted">총 ${executions.length}건의 실행 이력</small>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
content.innerHTML = tableHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close period panel
|
||||||
|
function closePeriodPanel() {
|
||||||
|
document.getElementById('periodExecutionsPanel').classList.remove('show');
|
||||||
|
document.querySelectorAll('.timeline-cell.selected').forEach(cell => {
|
||||||
|
cell.classList.remove('selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadTimeline();
|
||||||
|
// Auto refresh every 30 seconds
|
||||||
|
setInterval(loadTimeline, 30000);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
525
src/main/resources/templates/schedules.html
Normal file
525
src/main/resources/templates/schedules.html
Normal file
@ -0,0 +1,525 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns:th="http://www.thymeleaf.org">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>작업 스케줄 - SNP 배치</title>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--primary-gradient);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header h1 {
|
||||||
|
color: #333;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 30px;
|
||||||
|
box-shadow: var(--card-shadow);
|
||||||
|
margin-bottom: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
border: 2px solid #e9ecef;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-card:hover {
|
||||||
|
border-color: #667eea;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-item {
|
||||||
|
padding: 10px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-value {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #2d3748;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-schedule-section {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 25px;
|
||||||
|
border: 2px dashed #cbd5e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-schedule-section h2 {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-helper {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #718096;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state i {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="page-header d-flex justify-content-between align-items-center">
|
||||||
|
<h1><i class="bi bi-calendar-check"></i> 작업 스케줄</h1>
|
||||||
|
<a href="/" class="btn btn-primary">
|
||||||
|
<i class="bi bi-house-door"></i> 대시보드로 돌아가기
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit Schedule Form -->
|
||||||
|
<div class="content-card">
|
||||||
|
<div class="add-schedule-section">
|
||||||
|
<h2><i class="bi bi-plus-circle"></i> 스케줄 추가/수정</h2>
|
||||||
|
<form id="scheduleForm">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="jobName" class="form-label">
|
||||||
|
작업명
|
||||||
|
<span id="scheduleStatus" class="badge bg-secondary ms-2" style="display: none;">새 스케줄</span>
|
||||||
|
</label>
|
||||||
|
<select id="jobName" class="form-select" required>
|
||||||
|
<option value="">작업을 선택하세요...</option>
|
||||||
|
</select>
|
||||||
|
<div id="scheduleInfo" class="mt-2" style="display: none;">
|
||||||
|
<div class="alert alert-info mb-0 py-2 px-3" role="alert">
|
||||||
|
<i class="bi bi-info-circle"></i>
|
||||||
|
<span id="scheduleInfoText"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label for="cronExpression" class="form-label">Cron 표현식</label>
|
||||||
|
<input type="text" id="cronExpression" class="form-control" placeholder="0 0 * * * ?" required>
|
||||||
|
<div class="cron-helper">
|
||||||
|
예시: "0 0 * * * ?" (매 시간), "0 0 0 * * ?" (매일 자정)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label for="description" class="form-label">설명</label>
|
||||||
|
<textarea id="description" class="form-control" rows="2" placeholder="이 스케줄에 대한 설명을 입력하세요 (선택사항)"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<button type="submit" class="btn btn-primary">
|
||||||
|
<i class="bi bi-save"></i> 스케줄 저장
|
||||||
|
</button>
|
||||||
|
<button type="reset" class="btn btn-secondary">
|
||||||
|
<i class="bi bi-x-circle"></i> 취소
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule List -->
|
||||||
|
<div class="content-card">
|
||||||
|
<h2 class="mb-4" style="font-size: 20px; font-weight: 600; color: #333;">
|
||||||
|
<i class="bi bi-list-check"></i> 활성 스케줄
|
||||||
|
</h2>
|
||||||
|
<div id="scheduleList">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="spinner-border text-primary" role="status">
|
||||||
|
<span class="visually-hidden">Loading...</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2">스케줄 로딩 중...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap 5 JS Bundle -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load jobs for dropdown
|
||||||
|
async function loadJobs() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/jobs');
|
||||||
|
const jobs = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('jobName');
|
||||||
|
select.innerHTML = '<option value="">작업을 선택하세요...</option>' +
|
||||||
|
jobs.map(job => `<option value="${job}">${job}</option>`).join('');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('작업 로드 오류:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add event listener for job selection to detect existing schedules
|
||||||
|
document.getElementById('jobName').addEventListener('change', async function(e) {
|
||||||
|
const jobName = e.target.value;
|
||||||
|
const scheduleStatus = document.getElementById('scheduleStatus');
|
||||||
|
const scheduleInfo = document.getElementById('scheduleInfo');
|
||||||
|
const scheduleInfoText = document.getElementById('scheduleInfoText');
|
||||||
|
const cronInput = document.getElementById('cronExpression');
|
||||||
|
const descInput = document.getElementById('description');
|
||||||
|
|
||||||
|
if (!jobName) {
|
||||||
|
scheduleStatus.style.display = 'none';
|
||||||
|
scheduleInfo.style.display = 'none';
|
||||||
|
cronInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/schedules/${jobName}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const schedule = await response.json();
|
||||||
|
|
||||||
|
// Existing schedule found
|
||||||
|
cronInput.value = schedule.cronExpression || '';
|
||||||
|
descInput.value = schedule.description || '';
|
||||||
|
|
||||||
|
scheduleStatus.textContent = '기존 스케줄';
|
||||||
|
scheduleStatus.className = 'badge bg-warning ms-2';
|
||||||
|
scheduleStatus.style.display = 'inline';
|
||||||
|
|
||||||
|
scheduleInfoText.textContent = '이 작업은 이미 스케줄이 등록되어 있습니다. 수정하시겠습니까?';
|
||||||
|
scheduleInfo.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
// New schedule
|
||||||
|
cronInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
|
||||||
|
scheduleStatus.textContent = '새 스케줄';
|
||||||
|
scheduleStatus.className = 'badge bg-secondary ms-2';
|
||||||
|
scheduleStatus.style.display = 'inline';
|
||||||
|
|
||||||
|
scheduleInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('스케줄 조회 오류:', error);
|
||||||
|
|
||||||
|
// On error, treat as new schedule
|
||||||
|
cronInput.value = '';
|
||||||
|
descInput.value = '';
|
||||||
|
scheduleStatus.textContent = '새 스케줄';
|
||||||
|
scheduleStatus.className = 'badge bg-secondary ms-2';
|
||||||
|
scheduleStatus.style.display = 'inline';
|
||||||
|
scheduleInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load schedules
|
||||||
|
async function loadSchedules() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/batch/schedules');
|
||||||
|
const data = await response.json();
|
||||||
|
const schedules = data.schedules || [];
|
||||||
|
|
||||||
|
const scheduleListDiv = document.getElementById('scheduleList');
|
||||||
|
|
||||||
|
if (schedules.length === 0) {
|
||||||
|
scheduleListDiv.innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-inbox"></i>
|
||||||
|
<div>설정된 스케줄이 없습니다</div>
|
||||||
|
<div class="mt-2 text-muted">위 양식을 사용하여 첫 스케줄을 추가하세요</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleListDiv.innerHTML = schedules.map(schedule => {
|
||||||
|
const isActive = schedule.active;
|
||||||
|
const statusText = isActive ? '활성' : '비활성';
|
||||||
|
const statusClass = isActive ? 'success' : 'warning';
|
||||||
|
const triggerState = schedule.triggerState || 'NONE';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="schedule-card">
|
||||||
|
<div class="schedule-header">
|
||||||
|
<div class="schedule-title">
|
||||||
|
<i class="bi bi-calendar-event text-primary"></i>
|
||||||
|
${schedule.jobName}
|
||||||
|
<span class="badge bg-${statusClass}">${statusText}</span>
|
||||||
|
${triggerState !== 'NONE' ? `<span class="badge bg-${triggerState === 'NORMAL' ? 'success' : 'secondary'}">${triggerState}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="btn-group" role="group">
|
||||||
|
<button class="btn btn-sm ${isActive ? 'btn-warning' : 'btn-success'}"
|
||||||
|
onclick="toggleSchedule('${schedule.jobName}', ${!isActive})">
|
||||||
|
<i class="bi bi-${isActive ? 'pause' : 'play'}-circle"></i>
|
||||||
|
${isActive ? '비활성화' : '활성화'}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-danger" onclick="deleteSchedule('${schedule.jobName}')">
|
||||||
|
<i class="bi bi-trash"></i> 삭제
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="schedule-details">
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">Cron 표현식</div>
|
||||||
|
<div class="detail-value">${schedule.cronExpression || '없음'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">설명</div>
|
||||||
|
<div class="detail-value">${schedule.description || '-'}</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">다음 실행 시간</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
${schedule.nextFireTime ? formatDateTime(schedule.nextFireTime) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">이전 실행 시간</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
${schedule.previousFireTime ? formatDateTime(schedule.previousFireTime) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">생성 일시</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
${schedule.createdAt ? formatDateTime(schedule.createdAt) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="detail-item">
|
||||||
|
<div class="detail-label">수정 일시</div>
|
||||||
|
<div class="detail-value">
|
||||||
|
${schedule.updatedAt ? formatDateTime(schedule.updatedAt) : '-'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}).join('');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('scheduleList').innerHTML = `
|
||||||
|
<div class="empty-state">
|
||||||
|
<i class="bi bi-exclamation-circle text-danger"></i>
|
||||||
|
<div>스케줄 로드 오류: ${error.message}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('scheduleForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const jobName = document.getElementById('jobName').value;
|
||||||
|
const cronExpression = document.getElementById('cronExpression').value;
|
||||||
|
const description = document.getElementById('description').value;
|
||||||
|
|
||||||
|
if (!jobName || !cronExpression) {
|
||||||
|
alert('작업명과 Cron 표현식은 필수 입력 항목입니다');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if schedule already exists
|
||||||
|
let method = 'POST';
|
||||||
|
let url = '/api/batch/schedules';
|
||||||
|
let scheduleExists = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const checkResponse = await fetch(`/api/batch/schedules/${jobName}`);
|
||||||
|
if (checkResponse.ok) {
|
||||||
|
scheduleExists = true;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Schedule doesn't exist, continue with POST
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleExists) {
|
||||||
|
// Update: 기존 스케줄 수정
|
||||||
|
const confirmUpdate = confirm(`'${jobName}' 스케줄이 이미 존재합니다.\n\nCron 표현식을 업데이트하시겠습니까?`);
|
||||||
|
if (!confirmUpdate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
method = 'PUT';
|
||||||
|
url = `/api/batch/schedules/${jobName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(method === 'POST' ? {
|
||||||
|
jobName: jobName,
|
||||||
|
cronExpression: cronExpression,
|
||||||
|
description: description || null
|
||||||
|
} : {
|
||||||
|
cronExpression: cronExpression,
|
||||||
|
description: description || null
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const action = scheduleExists ? '수정' : '추가';
|
||||||
|
alert(`스케줄이 성공적으로 ${action}되었습니다!`);
|
||||||
|
document.getElementById('scheduleForm').reset();
|
||||||
|
await loadSchedules(); // await 추가하여 완료 대기
|
||||||
|
} else {
|
||||||
|
alert('스케줄 저장 실패: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('스케줄 저장 오류:', error);
|
||||||
|
alert('스케줄 저장 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle schedule active status
|
||||||
|
async function toggleSchedule(jobName, active) {
|
||||||
|
const action = active ? '활성화' : '비활성화';
|
||||||
|
|
||||||
|
if (!confirm(`스케줄을 ${action}하시겠습니까?\n작업: ${jobName}`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/schedules/${jobName}/toggle`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ active: active })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert(`스케줄이 성공적으로 ${action}되었습니다!`);
|
||||||
|
loadSchedules();
|
||||||
|
} else {
|
||||||
|
alert(`스케줄 ${action} 실패: ` + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`스케줄 ${action} 오류: ` + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete schedule
|
||||||
|
async function deleteSchedule(jobName) {
|
||||||
|
if (!confirm(`스케줄을 삭제하시겠습니까?\n작업: ${jobName}\n\n이 작업은 되돌릴 수 없습니다.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/batch/schedules/${jobName}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
alert('스케줄이 성공적으로 삭제되었습니다!');
|
||||||
|
loadSchedules();
|
||||||
|
} else {
|
||||||
|
alert('스케줄 삭제 실패: ' + result.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('스케줄 삭제 오류: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility: Format datetime
|
||||||
|
function formatDateTime(dateTimeStr) {
|
||||||
|
if (!dateTimeStr) return '-';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const date = new Date(dateTimeStr);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
const hours = String(date.getHours()).padStart(2, '0');
|
||||||
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||||
|
const seconds = String(date.getSeconds()).padStart(2, '0');
|
||||||
|
|
||||||
|
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
|
||||||
|
} catch (error) {
|
||||||
|
return dateTimeStr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadJobs();
|
||||||
|
loadSchedules();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
불러오는 중...
Reference in New Issue
Block a user