Initial commit

This commit is contained in:
HeungTak Lee 2025-10-22 13:50:04 +09:00
커밋 c88b8a926b
87개의 변경된 파일14084개의 추가작업 그리고 0개의 파일을 삭제

1
.gitattributes vendored Normal file
파일 보기

@ -0,0 +1 @@
* text=auto

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

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

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
파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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}
*/

파일 보기

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

파일 보기

@ -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&param2=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; // 마지막 호출 시간
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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()">&times;</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>

파일 보기

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

파일 보기

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