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