snp-batch-validation/DEVELOPMENT_GUIDE.md
2025-10-22 13:50:04 +09:00

1603 lines
48 KiB
Markdown

# Spring Batch 개발 가이드
## 목차
1. [프로젝트 개요](#1-프로젝트-개요)
2. [프로젝트 구조](#2-프로젝트-구조)
3. [추상 클래스 구조](#3-추상-클래스-구조)
4. [새로운 배치 Job 생성 가이드](#4-새로운-배치-job-생성-가이드)
5. [예제: 전체 구현 과정](#5-예제-전체-구현-과정)
6. [베스트 프랙티스](#6-베스트-프랙티스)
7. [트러블슈팅](#7-트러블슈팅)
---
## 1. 프로젝트 개요
### 1.1 기술 스택
- **Spring Boot**: 3.2.1
- **Spring Batch**: 5.1.0
- **Quartz Scheduler**: 2.5.0
- **PostgreSQL**: 42.7.4
- **WebClient**: REST API 호출
- **Thymeleaf**: 웹 UI
- **Bootstrap 5**: 프론트엔드
### 1.2 주요 기능
- REST API 기반 데이터 수집 배치
- Quartz 기반 스케줄링 관리 (DB 영속화)
- 웹 UI를 통한 배치 모니터링
- 타임라인 차트 (일/주/월 단위)
- 대시보드 및 실행 이력 조회
- 통일된 추상화 구조 (Reader/Processor/Writer)
- REST API 표준 응답 형식
### 1.3 아키텍처 패턴
#### 배치 처리 패턴
```
External API → Reader → Processor → Writer → Database
↓ ↓ ↓
(API Call) (Transform) (Batch Insert)
```
#### 계층 구조
```
Controller (REST API)
Service (비즈니스 로직)
Repository (데이터 액세스)
Database (PostgreSQL)
```
#### 추상화 구조
```
com.snp.batch/
├── SnpBatchApplication.java # Spring Boot 메인 클래스
├── common/ # 공통 추상화 모듈
│ ├── batch/ # 배치 작업 추상화
│ │ ├── config/
│ │ │ └── BaseJobConfig # Job/Step 설정 템플릿
│ │ ├── entity/
│ │ │ └── BaseEntity # Entity 공통 감사 필드
│ │ ├── processor/
│ │ │ └── BaseProcessor # Processor 템플릿
│ │ ├── reader/
│ │ │ └── BaseApiReader # API Reader 템플릿
│ │ ├── repository/
│ │ │ └── BaseJdbcRepository # JDBC Repository 템플릿
│ │ └── writer/
│ │ └── BaseWriter # Writer 템플릿
│ │
│ └── web/ # 웹 API 추상화
│ ├── controller/
│ │ └── BaseController # 컨트롤러 템플릿
│ ├── dto/
│ │ └── BaseDto # 공통 DTO 필드
│ ├── service/
│ │ ├── BaseService # 서비스 인터페이스
│ │ ├── BaseServiceImpl # 서비스 구현 템플릿 (JDBC)
│ │ ├── BaseProxyService # 프록시 서비스 템플릿
│ │ └── BaseHybridService # 하이브리드 서비스 템플릿
│ └── ApiResponse # 공통 API 응답 래퍼
├── global/ # 전역 클래스 (애플리케이션 레벨)
│ ├── config/ # 전역 설정 (Quartz, Swagger 등)
│ ├── controller/ # 전역 컨트롤러 (Batch, Web UI)
│ ├── dto/ # 전역 DTO (Job 실행, 스케줄 등)
│ ├── model/ # 전역 Entity (스케줄 정보 등) - JPA 허용
│ └── repository/ # 전역 Repository - JPA 허용
├── jobs/ # 배치 Job 구현 (도메인별, JDBC 전용)
│ ├── sample/ # 샘플 제품 데이터 Job
│ │ ├── batch/ # 배치 작업
│ │ └── web/ # 웹 API (선택)
│ └── shipimport/ # 선박 데이터 Import Job
│ └── batch/ # 배치 작업만
├── service/ # 전역 서비스
└── scheduler/ # 스케줄러 (Quartz Job, Initializer)
```
---
## 2. 프로젝트 구조
```
src/main/java/com/snp/batch/
├── SnpBatchApplication.java # Spring Boot 메인 클래스
├── common/ # 공통 추상화 모듈
│ ├── batch/ # 배치 작업 공통 Base 클래스
│ │ ├── config/
│ │ │ └── BaseJobConfig.java # Job/Step 설정 템플릿
│ │ ├── entity/
│ │ │ └── BaseEntity.java # 공통 감사 필드 (JPA 제거)
│ │ ├── processor/
│ │ │ └── BaseProcessor.java # Processor 템플릿
│ │ ├── reader/
│ │ │ └── BaseApiReader.java # API Reader 템플릿
│ │ ├── repository/
│ │ │ └── BaseJdbcRepository.java # JDBC Repository 템플릿
│ │ └── writer/
│ │ └── BaseWriter.java # Writer 템플릿
│ │
│ └── web/ # 웹 API 공통 Base 클래스
│ ├── controller/
│ │ └── BaseController.java # 컨트롤러 템플릿
│ ├── dto/
│ │ └── BaseDto.java # 공통 DTO 필드
│ ├── service/
│ │ ├── BaseService.java # 서비스 인터페이스
│ │ ├── BaseServiceImpl.java # 서비스 구현 템플릿 (JDBC)
│ │ ├── BaseProxyService.java # 프록시 서비스 템플릿
│ │ └── BaseHybridService.java # 하이브리드 서비스 템플릿
│ └── ApiResponse.java # 공통 API 응답 래퍼
├── global/ # 전역 클래스 (JPA 허용)
│ ├── config/ # 전역 설정
│ │ ├── QuartzConfig.java # Quartz 스케줄러 설정
│ │ └── SwaggerConfig.java # Swagger 설정
│ │
│ ├── controller/ # 전역 컨트롤러
│ │ ├── BatchController.java # 배치 관리 REST API
│ │ └── WebViewController.java # Thymeleaf 뷰 컨트롤러
│ │
│ ├── dto/ # 전역 DTO
│ │ ├── DashboardResponse.java # 대시보드 응답
│ │ ├── JobExecutionDetailDto.java # 실행 상세 정보
│ │ ├── JobExecutionDto.java # 실행 이력 DTO
│ │ ├── ScheduleRequest.java # 스케줄 등록/수정 요청
│ │ ├── ScheduleResponse.java # 스케줄 조회 응답
│ │ └── TimelineResponse.java # 타임라인 응답
│ │
│ ├── model/ # 전역 Entity (JPA)
│ │ └── JobScheduleEntity.java # 스케줄 Entity (JPA)
│ │
│ └── repository/ # 전역 Repository (JPA)
│ ├── JobScheduleRepository.java # JpaRepository (JPA)
│ └── TimelineRepository.java # 타임라인 Repository
├── jobs/ # 도메인별 배치 Job (JDBC 전용)
│ │
│ ├── sample/ # 샘플 제품 데이터 Job
│ │ ├── batch/ # 배치 작업
│ │ │ ├── config/
│ │ │ │ └── ProductDataImportJobConfig.java
│ │ │ ├── dto/
│ │ │ │ ├── ProductApiResponse.java
│ │ │ │ └── ProductDto.java
│ │ │ ├── entity/
│ │ │ │ └── ProductEntity.java # extends BaseEntity (JPA 제거)
│ │ │ ├── processor/
│ │ │ │ └── ProductDataProcessor.java
│ │ │ ├── reader/
│ │ │ │ └── ProductDataReader.java
│ │ │ ├── repository/
│ │ │ │ ├── ProductRepository.java
│ │ │ │ └── ProductRepositoryImpl.java # extends BaseJdbcRepository
│ │ │ └── writer/
│ │ │ └── ProductDataWriter.java
│ │ │
│ │ └── web/ # 웹 API
│ │ ├── controller/
│ │ │ └── ProductWebController.java
│ │ ├── dto/
│ │ │ └── ProductWebDto.java
│ │ └── service/
│ │ └── ProductWebService.java
│ │
│ └── shipimport/ # 선박 데이터 Import Job
│ └── batch/ # 배치 작업 (웹 API 없음)
│ ├── config/
│ │ └── ShipImportJobConfig.java
│ ├── dto/
│ │ ├── ShipApiResponse.java
│ │ └── ShipDto.java
│ ├── entity/
│ │ └── ShipEntity.java # extends BaseEntity (JPA 제거)
│ ├── processor/
│ │ └── ShipDataProcessor.java
│ ├── reader/
│ │ └── ShipDataReader.java
│ ├── repository/
│ │ ├── ShipRepository.java
│ │ └── ShipRepositoryImpl.java # extends BaseJdbcRepository
│ └── writer/
│ └── ShipDataWriter.java
├── service/ # 전역 서비스
│ ├── BatchService.java # 배치 실행 관리
│ ├── QuartzJobService.java # Quartz-Batch 연동
│ └── ScheduleService.java # 스케줄 DB 영속화
└── scheduler/ # 스케줄러
├── QuartzBatchJob.java # Quartz Job 구현체
└── SchedulerInitializer.java # 스케줄 자동 로드
```
**주요 특징**:
- `common/batch/`: 배치 작업 전용 Base 클래스 (JDBC 기반)
- `common/web/`: 웹 API 전용 Base 클래스 (JDBC 기반)
- `global/`: JPA 사용 허용 (간단한 CRUD만)
- `jobs/`: 모든 Job은 JDBC 전용 (성능 최적화)
---
## 3. 추상 클래스 구조
### 3.0 공통 베이스 클래스
#### 3.0.1 BaseEntity
**목적**: 모든 Entity의 공통 감사(Audit) 필드 관리
**위치**: `com.snp.batch.common.batch.entity.BaseEntity`
**제공 필드**:
```java
@CreatedDate
private LocalDateTime createdAt; // 생성 일시 (자동 설정)
@LastModifiedDate
private LocalDateTime updatedAt; // 수정 일시 (자동 업데이트)
private String createdBy; // 생성자 (기본값: "SYSTEM")
private String updatedBy; // 수정자 (기본값: "SYSTEM")
```
**사용 방법 (jobs 패키지 - JDBC 전용)**:
```java
/**
* Ship Entity - JDBC Template 기반
* JPA 어노테이션 사용 금지
* 컬럼 매핑은 주석으로 명시
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ShipEntity extends BaseEntity {
/**
* 기본 키 (자동 생성)
* 컬럼: id (BIGSERIAL)
*/
private Long id;
/**
* 선박 이름
* 컬럼: ship_name (VARCHAR(100))
*/
private String shipName;
// createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속
}
```
**사용 방법 (global 패키지 - JPA 허용)**:
```java
@Entity
@Table(name = "job_schedule")
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
public class JobScheduleEntity extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "job_name", unique = true, nullable = false)
private String jobName;
// createdAt, updatedAt, createdBy, updatedBy는 자동 관리됨
}
```
**주요 기능**:
- jobs 패키지: JDBC 기반, JPA 어노테이션 없음, RowMapper로 수동 매핑
- global 패키지: JPA 기반, @PrePersist/@PreUpdate로 자동 관리
- 공통 감사 필드: createdAt, updatedAt, createdBy, updatedBy
---
#### 3.0.2 BaseDto
**목적**: 모든 DTO의 공통 감사 필드 제공
**위치**: `com.snp.batch.common.web.dto.BaseDto`
**제공 필드**:
```java
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
private String updatedBy;
```
**사용 방법**:
```java
@Data
@EqualsAndHashCode(callSuper = true)
public class ShipDto extends BaseDto {
private String shipName;
private String shipType;
// 부모 클래스의 감사 필드 자동 상속
}
```
---
#### 3.0.3 BaseService<T, D, ID>
**목적**: Service 계층의 공통 CRUD 인터페이스 정의
**위치**: `com.snp.batch.common.web.service.BaseService`
**제공 메서드**:
```java
D create(D dto); // 생성
Optional<D> findById(ID id); // 단건 조회
List<D> findAll(); // 전체 조회
Page<D> findAll(Pageable pageable); // 페이징 조회
D update(ID id, D dto); // 수정
void deleteById(ID id); // 삭제
boolean existsById(ID id); // 존재 여부 확인
D toDto(T entity); // Entity → DTO 변환
T toEntity(D dto); // DTO → Entity 변환
```
---
#### 3.0.4 BaseServiceImpl<T, D, ID>
**목적**: BaseService의 기본 구현 제공
**위치**: `com.snp.batch.common.web.service.BaseServiceImpl`
**필수 구현 메서드**:
```java
protected abstract BaseJdbcRepository<T, ID> getRepository(); // Repository 반환
protected abstract String getEntityName(); // Entity 이름 (로깅용)
protected abstract void updateEntity(T entity, D dto); // Entity 업데이트 로직
protected abstract ID extractId(T entity); // Entity에서 ID 추출
```
**참고**: jobs 패키지에서는 JDBC 기반 Repository를 사용합니다 (BaseJdbcRepository 상속).
**사용 예제**:
```java
@Service
@RequiredArgsConstructor
public class ProductWebService extends BaseServiceImpl<ProductEntity, ProductWebDto, Long> {
private final ProductRepository productRepository;
@Override
protected BaseJdbcRepository<ProductEntity, Long> getRepository() {
return productRepository;
}
@Override
protected String getEntityName() {
return "Product";
}
@Override
protected void updateEntity(ProductEntity entity, ProductWebDto dto) {
entity.setProductName(dto.getProductName());
entity.setCategory(dto.getCategory());
entity.setPrice(dto.getPrice());
}
@Override
protected Long extractId(ProductEntity entity) {
return entity.getId();
}
@Override
public ProductWebDto toDto(ProductEntity entity) {
return ProductWebDto.builder()
.productId(entity.getProductId())
.productName(entity.getProductName())
.category(entity.getCategory())
.price(entity.getPrice())
.build();
}
@Override
public ProductEntity toEntity(ProductWebDto dto) {
return ProductEntity.builder()
.productId(dto.getProductId())
.productName(dto.getProductName())
.category(dto.getCategory())
.price(dto.getPrice())
.build();
}
}
```
---
#### 3.0.5 BaseController<D, ID>
**목적**: REST Controller의 공통 CRUD API 제공
**위치**: `com.snp.batch.common.web.controller.BaseController`
**필수 구현 메서드**:
```java
protected abstract BaseService<?, D, ID> getService(); // Service 반환
protected abstract String getResourceName(); // 리소스 이름 (로깅용)
```
**제공 API**:
```java
POST / create(D dto) # 생성
GET /{id} getById(ID id) # 단건 조회
GET / getAll() # 전체 조회
GET /page getPage(Pageable) # 페이징 조회
PUT /{id} update(ID id, D dto) # 수정
DELETE /{id} delete(ID id) # 삭제
GET /{id}/exists exists(ID id) # 존재 여부
```
**사용 예제**:
```java
@RestController
@RequestMapping("/api/ships")
@RequiredArgsConstructor
public class ShipController extends BaseController<ShipDto, Long> {
private final ShipService shipService;
@Override
protected BaseService<?, ShipDto, Long> getService() {
return shipService;
}
@Override
protected String getResourceName() {
return "Ship";
}
// 추가 커스텀 API가 필요한 경우 여기에 정의
}
```
---
#### 3.0.6 ApiResponse<T>
**목적**: 통일된 API 응답 형식 제공
**위치**: `com.snp.batch.common.web.ApiResponse`
**필드 구조**:
```java
private boolean success; // 성공 여부
private String message; // 메시지
private T data; // 응답 데이터
private String errorCode; // 에러 코드 (실패 시)
```
**사용 방법**:
```java
// 성공 응답
ApiResponse<ShipDto> response = ApiResponse.success(shipDto);
ApiResponse<ShipDto> response = ApiResponse.success("Ship created", shipDto);
// 실패 응답
ApiResponse<Void> response = ApiResponse.error("Ship not found");
ApiResponse<Void> response = ApiResponse.error("Validation failed", "ERR_001");
```
**응답 예제**:
```json
{
"success": true,
"message": "Success",
"data": {
"shipName": "Titanic",
"shipType": "Passenger"
},
"errorCode": null
}
```
---
### 3.1 BaseApiReader<T>
**목적**: REST API에서 데이터를 읽어오는 ItemReader 구현 패턴 제공
**위치**: `com.snp.batch.common.batch.reader.BaseApiReader`
**필수 구현 메소드**:
```java
protected abstract String getApiPath(); // API 경로
protected abstract List<T> extractDataFromResponse(Object response); // 응답 파싱
protected abstract Class<?> getResponseType(); // 응답 클래스
protected abstract String getReaderName(); // Reader 이름
```
**선택적 오버라이드 메소드**:
```java
protected void addQueryParams(UriBuilder uriBuilder) {} // 쿼리 파라미터 추가
protected void beforeApiCall() {} // API 호출 전처리
protected void afterApiCall(List<T> data) {} // API 호출 후처리
protected void handleApiError(Exception e) {} // 에러 처리
```
**제공되는 기능**:
- API 호출 및 데이터 캐싱 (한 번만 호출)
- 순차적 데이터 반환 (read() 메소드)
- 자동 로깅 및 에러 핸들링
**사용 예제**:
```java
@Component
public class ShipDataReader extends BaseApiReader<ShipDto> {
public ShipDataReader(WebClient webClient) {
super(webClient);
}
@Override
protected String getApiPath() {
return "/api/v1/ships";
}
@Override
protected List<ShipDto> extractDataFromResponse(Object response) {
ShipApiResponse apiResponse = (ShipApiResponse) response;
return apiResponse.getData();
}
@Override
protected Class<?> getResponseType() {
return ShipApiResponse.class;
}
@Override
protected String getReaderName() {
return "ShipDataReader";
}
}
```
---
### 3.2 BaseProcessor<I, O>
**목적**: 데이터 변환 및 처리 로직의 템플릿 제공
**위치**: `com.snp.batch.common.batch.processor.BaseProcessor`
**필수 구현 메소드**:
```java
protected abstract O transform(I item) throws Exception; // 데이터 변환
protected abstract boolean shouldProcess(I item); // 처리 여부 판단
protected abstract String getProcessorName(); // Processor 이름
```
**선택적 오버라이드 메소드**:
```java
protected void beforeProcess(I item) {} // 전처리
protected void afterProcess(I input, O output) {} // 후처리
protected void handleProcessError(I item, Exception e) {} // 에러 처리
protected void onItemFiltered(I item) {} // 필터링 로깅
```
**제공되는 기능**:
- DTO → Entity 변환 패턴
- 데이터 필터링 (shouldProcess 기반)
- 자동 로깅 및 에러 핸들링
**사용 예제**:
```java
@Component
public class ShipDataProcessor extends BaseProcessor<ShipDto, ShipEntity> {
@Override
protected ShipEntity transform(ShipDto dto) {
return ShipEntity.builder()
.shipId(dto.getShipId())
.shipName(dto.getShipName())
.shipType(dto.getShipType())
.build();
}
@Override
protected boolean shouldProcess(ShipDto dto) {
// 유효성 검사: shipId가 있는 경우만 처리
return dto.getShipId() != null && !dto.getShipId().isEmpty();
}
@Override
protected String getProcessorName() {
return "ShipDataProcessor";
}
@Override
protected void onItemFiltered(ShipDto dto) {
log.warn("Ship ID가 없어 필터링됨: {}", dto);
}
}
```
---
### 3.3 BaseWriter<T>
**목적**: 데이터베이스 저장 로직의 템플릿 제공
**위치**: `com.snp.batch.common.batch.writer.BaseWriter`
**필수 구현 메소드**:
```java
protected abstract void writeItems(List<T> items) throws Exception; // 저장 로직
protected abstract String getWriterName(); // Writer 이름
```
**선택적 오버라이드 메소드**:
```java
protected void beforeWrite(List<T> items) {} // 저장 전처리
protected void afterWrite(List<T> items) {} // 저장 후처리
protected void handleWriteError(List<T> items, Exception e) {} // 에러 처리
protected List<T> filterItems(List<T> items) {} // 아이템 필터링
protected void validateBatchSize(List<T> items) {} // 배치 크기 검증
```
**제공되는 기능**:
- 배치 저장 패턴 (Chunk 단위)
- Null 아이템 자동 필터링
- 배치 크기 검증 및 경고
- 자동 로깅 및 에러 핸들링
**사용 예제**:
```java
@Component
@RequiredArgsConstructor
public class ShipDataWriter extends BaseWriter<ShipEntity> {
private final ShipRepository shipRepository;
@Override
protected void writeItems(List<ShipEntity> items) {
shipRepository.saveAll(items);
}
@Override
protected String getWriterName() {
return "ShipDataWriter";
}
@Override
protected void afterWrite(List<ShipEntity> items) {
log.info("Ship 데이터 저장 완료: {} 건", items.size());
}
}
```
---
### 3.4 BaseJobConfig<I, O>
**목적**: Batch Job 설정의 표준 템플릿 제공
**위치**: `com.snp.batch.common.batch.config.BaseJobConfig`
**필수 구현 메소드**:
```java
protected abstract String getJobName(); // Job 이름
protected abstract ItemReader<I> createReader(); // Reader 생성
protected abstract ItemProcessor<I, O> createProcessor(); // Processor 생성
protected abstract ItemWriter<O> createWriter(); // Writer 생성
```
**선택적 오버라이드 메소드**:
```java
protected String getStepName() {} // Step 이름 (기본: {jobName}Step)
protected int getChunkSize() {} // Chunk 크기 (기본: 100)
protected void configureJob(JobBuilder jobBuilder) {} // Job 커스터마이징
protected void configureStep(StepBuilder stepBuilder) {} // Step 커스터마이징
```
**제공되는 기능**:
- Job 및 Step 자동 생성
- Chunk 기반 처리 설정
- Processor가 없는 경우도 지원
**사용 예제**:
```java
@Configuration
@RequiredArgsConstructor
public class ShipDataImportJobConfig extends BaseJobConfig<ShipDto, ShipEntity> {
private final ShipDataReader shipDataReader;
private final ShipDataProcessor shipDataProcessor;
private final ShipDataWriter shipDataWriter;
public ShipDataImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ShipDataReader shipDataReader,
ShipDataProcessor shipDataProcessor,
ShipDataWriter shipDataWriter) {
super(jobRepository, transactionManager);
this.shipDataReader = shipDataReader;
this.shipDataProcessor = shipDataProcessor;
this.shipDataWriter = shipDataWriter;
}
@Override
protected String getJobName() {
return "shipDataImportJob";
}
@Override
protected ItemReader<ShipDto> createReader() {
return shipDataReader;
}
@Override
protected ItemProcessor<ShipDto, ShipEntity> createProcessor() {
return shipDataProcessor;
}
@Override
protected ItemWriter<ShipEntity> createWriter() {
return shipDataWriter;
}
@Override
protected int getChunkSize() {
return 50; // 커스텀 Chunk 크기
}
@Bean(name = "shipDataImportJob")
public Job shipDataImportJob() {
return job();
}
@Bean(name = "shipDataImportStep")
public Step shipDataImportStep() {
return step();
}
}
```
---
### 3.5 BaseJdbcRepository<T, ID>
**목적**: JDBC 기반 Repository의 CRUD 템플릿 제공
**위치**: `com.snp.batch.common.batch.repository.BaseJdbcRepository`
**필수 구현 메소드**:
```java
protected abstract String getTableName(); // 테이블 이름
protected abstract RowMapper<T> getRowMapper(); // RowMapper
protected abstract ID extractId(T entity); // ID 추출
protected abstract String getInsertSql(); // INSERT SQL
protected abstract String getUpdateSql(); // UPDATE SQL
protected abstract void setInsertParameters(PreparedStatement ps, T entity);
protected abstract void setUpdateParameters(PreparedStatement ps, T entity);
protected abstract String getEntityName(); // Entity 이름 (로깅용)
```
**제공되는 기능**:
- findById, findAll, count, existsById
- save, insert, update
- batchInsert, batchUpdate, saveAll
- deleteById, deleteAll
- 자동 트랜잭션 처리
**사용 예제**:
```java
@Repository
public class ShipRepository extends BaseJdbcRepository<ShipEntity, String> {
public ShipRepository(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
@Override
protected String getTableName() {
return "ships";
}
@Override
protected RowMapper<ShipEntity> getRowMapper() {
return (rs, rowNum) -> ShipEntity.builder()
.shipId(rs.getString("ship_id"))
.shipName(rs.getString("ship_name"))
.shipType(rs.getString("ship_type"))
.build();
}
@Override
protected String extractId(ShipEntity entity) {
return entity.getShipId();
}
@Override
protected String getInsertSql() {
return "INSERT INTO ships (ship_id, ship_name, ship_type) VALUES (?, ?, ?)";
}
@Override
protected String getUpdateSql() {
return "UPDATE ships SET ship_name = ?, ship_type = ? WHERE ship_id = ?";
}
@Override
protected void setInsertParameters(PreparedStatement ps, ShipEntity entity) throws SQLException {
ps.setString(1, entity.getShipId());
ps.setString(2, entity.getShipName());
ps.setString(3, entity.getShipType());
}
@Override
protected void setUpdateParameters(PreparedStatement ps, ShipEntity entity) throws SQLException {
ps.setString(1, entity.getShipName());
ps.setString(2, entity.getShipType());
ps.setString(3, entity.getShipId());
}
@Override
protected String getEntityName() {
return "Ship";
}
}
```
---
## 4. 새로운 배치 Job 생성 가이드
### 4.1 사전 준비
1. **도메인 파악**
- 어떤 데이터를 수집할 것인가? (예: 선박 데이터, 사용자 데이터 등)
- API 엔드포인트는 무엇인가?
- 데이터 구조는 어떻게 되는가?
2. **데이터베이스 테이블 생성**
```sql
CREATE TABLE ships (
ship_id VARCHAR(50) PRIMARY KEY,
ship_name VARCHAR(100) NOT NULL,
ship_type VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 4.2 단계별 구현
#### Step 1: DTO 클래스 생성 (jobs/{domain}/batch/dto 패키지)
**API 응답 DTO**:
```java
@Data
public class ShipApiResponse {
private List<ShipDto> data;
private int totalCount;
}
```
**데이터 DTO**:
```java
@Data
@Builder
public class ShipDto {
private String imoNumber;
private String coreShipInd;
private String datasetVersion;
}
```
#### Step 2: Entity 클래스 생성 (jobs/{domain}/batch/entity 패키지)
**JDBC 기반 Entity (JPA 어노테이션 없음)**:
```java
/**
* Ship Entity - JDBC Template 기반
* JPA 어노테이션 사용 금지
*/
@Data
@SuperBuilder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(callSuper = true)
public class ShipEntity extends BaseEntity {
/**
* 기본 키
* 컬럼: id (BIGSERIAL)
*/
private Long id;
/**
* IMO 번호
* 컬럼: imo_number (VARCHAR(20), UNIQUE)
*/
private String imoNumber;
/**
* Core Ship Indicator
* 컬럼: core_ship_ind (VARCHAR(10))
*/
private String coreShipInd;
// createdAt, updatedAt, createdBy, updatedBy는 BaseEntity에서 상속
}
```
#### Step 3: Repository 구현 (jobs/{domain}/batch/repository 패키지)
```java
@Repository
public class ShipRepository extends BaseJdbcRepository<ShipEntity, String> {
public ShipRepository(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
// 추상 메소드 구현 (위의 3.5 예제 참고)
}
```
#### Step 4: Reader 구현 (jobs/{domain}/batch/reader 패키지)
```java
@Component
public class ShipDataReader extends BaseApiReader<ShipDto> {
public ShipDataReader(WebClient webClient) {
super(webClient);
}
// 추상 메소드 구현 (위의 3.1 예제 참고)
}
```
#### Step 5: Processor 구현 (jobs/{domain}/batch/processor 패키지)
```java
@Component
public class ShipDataProcessor extends BaseProcessor<ShipDto, ShipEntity> {
// 추상 메소드 구현 (위의 3.2 예제 참고)
}
```
#### Step 6: Writer 구현 (jobs/{domain}/batch/writer 패키지)
```java
@Component
@RequiredArgsConstructor
public class ShipDataWriter extends BaseWriter<ShipEntity> {
private final ShipRepository shipRepository;
// 추상 메소드 구현 (위의 3.3 예제 참고)
}
```
#### Step 7: JobConfig 구현 (jobs/{domain}/batch/config 패키지)
```java
@Configuration
@RequiredArgsConstructor
public class ShipDataImportJobConfig extends BaseJobConfig<ShipDto, ShipEntity> {
private final ShipDataReader shipDataReader;
private final ShipDataProcessor shipDataProcessor;
private final ShipDataWriter shipDataWriter;
public ShipDataImportJobConfig(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
ShipDataReader shipDataReader,
ShipDataProcessor shipDataProcessor,
ShipDataWriter shipDataWriter) {
super(jobRepository, transactionManager);
this.shipDataReader = shipDataReader;
this.shipDataProcessor = shipDataProcessor;
this.shipDataWriter = shipDataWriter;
}
// 추상 메소드 구현 (위의 3.4 예제 참고)
@Bean(name = "shipDataImportJob")
public Job shipDataImportJob() {
return job();
}
@Bean(name = "shipDataImportStep")
public Step shipDataImportStep() {
return step();
}
}
```
#### Step 8: 테스트 및 실행
1. **애플리케이션 시작**
```bash
mvn spring-boot:run
```
2. **웹 UI에서 확인**
- http://localhost:8080
- "shipDataImportJob" 확인
- "즉시 실행" 버튼 클릭
3. **로그 확인**
```
ShipDataReader API 호출 시작
ShipDataReader API 응답 성공: 100 건
ShipDataReader 데이터 100건 조회 완료
ShipDataWriter 데이터 저장 시작: 50 건
ShipDataWriter 데이터 저장 완료: 50 건
```
---
## 5. 예제: 전체 구현 과정
### 5.1 시나리오
- **목적**: 외부 API에서 사용자 데이터를 수집하여 데이터베이스에 저장
- **API**: `GET /api/v1/users?status=active`
- **필터링**: 이메일이 있는 사용자만 저장
### 5.2 파일 구조
```
src/main/java/com/snp/batch/
└── jobs/user/
└── batch/ # 배치 작업
├── config/
│ └── UserDataImportJobConfig.java
├── dto/
│ ├── UserDto.java
│ └── UserApiResponse.java
├── entity/
│ └── UserEntity.java # extends BaseEntity (JPA 제거)
├── processor/
│ └── UserDataProcessor.java # extends BaseProcessor
├── reader/
│ └── UserDataReader.java # extends BaseApiReader
├── repository/
│ ├── UserRepository.java # 인터페이스
│ └── UserRepositoryImpl.java # extends BaseJdbcRepository
└── writer/
└── UserDataWriter.java # extends BaseWriter
```
### 5.3 테이블 생성 SQL
```sql
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
username VARCHAR(100) NOT NULL,
email VARCHAR(255),
status VARCHAR(50),
imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
### 5.4 코드 구현
각 클래스의 전체 구현 코드는 섹션 3의 추상 클래스 사용 예제를 참고하세요.
---
## 6. 베스트 프랙티스
### 6.1 네이밍 컨벤션
| 구분 | 패턴 | 예제 |
|------|------|------|
| Job 이름 | `{domain}DataImportJob` | `shipDataImportJob` |
| Step 이름 | `{domain}DataImportStep` | `shipDataImportStep` |
| Reader | `{Domain}DataReader` | `ShipDataReader` |
| Processor | `{Domain}DataProcessor` | `ShipDataProcessor` |
| Writer | `{Domain}DataWriter` | `ShipDataWriter` |
| JobConfig | `{Domain}DataImportJobConfig` | `ShipDataImportJobConfig` |
| Repository | `{Domain}Repository` | `ShipRepository` |
| Entity | `{Domain}Entity` | `ShipEntity` |
| DTO | `{Domain}Dto` | `ShipDto` |
### 6.2 패키지 구조
**배치 전용 Job** (예: shipimport):
```
com.snp.batch.jobs.{domain}/
└── batch/
├── config/
│ └── {Domain}DataImportJobConfig.java # extends BaseJobConfig
├── dto/
│ ├── {Domain}Dto.java
│ └── {Domain}ApiResponse.java
├── entity/
│ └── {Domain}Entity.java # extends BaseEntity (JPA 제거)
├── processor/
│ └── {Domain}DataProcessor.java # extends BaseProcessor
├── reader/
│ └── {Domain}DataReader.java # extends BaseApiReader
├── repository/
│ ├── {Domain}Repository.java # 인터페이스
│ └── {Domain}RepositoryImpl.java # extends BaseJdbcRepository
└── writer/
└── {Domain}DataWriter.java # extends BaseWriter
```
**배치 + 웹 API Job** (예: sample):
```
com.snp.batch.jobs.{domain}/
├── batch/ # 배치 작업
│ ├── config/
│ ├── dto/
│ ├── entity/
│ ├── processor/
│ ├── reader/
│ ├── repository/
│ └── writer/
└── web/ # 웹 API
├── controller/
│ └── {Domain}WebController.java # extends BaseController
├── dto/
│ └── {Domain}WebDto.java # extends BaseDto
└── service/
└── {Domain}WebService.java # extends BaseServiceImpl (JDBC)
```
### 6.3 Chunk 크기 선택 가이드
| 데이터 크기 | Chunk 크기 | 사용 시나리오 |
|-------------|-----------|--------------|
| 소량 (< 1,000) | 50-100 | 간단한 API 데이터 |
| 중량 (1,000-10,000) | 100-500 | 일반적인 배치 작업 |
| 대량 (> 10,000) | 500-1,000 | 대용량 데이터 처리 |
### 6.4 에러 처리 전략
1. **API 호출 실패**
- `BaseApiReader.handleApiError()` 오버라이드
- 빈 리스트 반환 (Job 실패 방지) 또는 예외 던지기
2. **데이터 변환 실패**
- `BaseProcessor.handleProcessError()` 오버라이드
- 문제 데이터 로깅 후 null 반환 (다음 데이터 계속 처리)
3. **저장 실패**
- `BaseWriter.handleWriteError()` 오버라이드
- 재시도 로직 또는 부분 저장 구현
### 6.5 로깅 전략
```java
// Reader에서
@Override
protected void afterApiCall(List<ShipDto> data) {
log.info("API 호출 성공: {} 건 조회", data.size());
}
// Processor에서
@Override
protected void onItemFiltered(ShipDto dto) {
log.debug("필터링됨: {}", dto);
}
// Writer에서
@Override
protected void afterWrite(List<ShipEntity> items) {
log.info("저장 완료: {} 건", items.size());
}
```
### 6.6 성능 최적화
1. **배치 Insert 사용**
```java
@Override
protected void writeItems(List<ShipEntity> items) {
shipRepository.batchInsert(items); // saveAll() 대신
}
```
2. **API 페이징 처리**
```java
@Override
protected void addQueryParams(UriBuilder uriBuilder) {
uriBuilder.queryParam("page", 1);
uriBuilder.queryParam("size", 1000);
}
```
3. **경량 쿼리 사용**
- 불필요한 JOIN 제거
- 필요한 컬럼만 SELECT
---
## 7. 트러블슈팅
### 7.1 일반적인 문제
#### 문제 1: Job이 실행되지 않음
**증상**: 웹 UI에서 Job 목록이 보이지 않음
**해결 방법**:
1. `@Bean` 어노테이션 확인
```java
@Bean(name = "shipDataImportJob")
public Job shipDataImportJob() {
return job();
}
```
2. JobConfig 클래스에 `@Configuration` 어노테이션 확인
3. 로그 확인: "Job 생성: shipDataImportJob" 메시지 확인
---
#### 문제 2: API 호출 실패
**증상**: "API 호출 실패" 로그, 데이터 0건
**해결 방법**:
1. WebClient 설정 확인 (`application.yml`)
```yaml
api:
base-url: http://api.example.com
```
2. API 경로 확인
```java
@Override
protected String getApiPath() {
return "/api/v1/ships"; // 슬래시 확인
}
```
3. 네트워크 연결 테스트
```bash
curl http://api.example.com/api/v1/ships
```
---
#### 문제 3: 데이터가 저장되지 않음
**증상**: "저장 완료" 로그는 있지만 DB에 데이터 없음
**해결 방법**:
1. 트랜잭션 확인
```java
@Override
protected void writeItems(List<ShipEntity> items) {
shipRepository.saveAll(items); // 트랜잭션 내에서 실행되는지 확인
}
```
2. SQL 로그 활성화 (`application.yml`)
```yaml
logging:
level:
org.springframework.jdbc.core: DEBUG
```
3. 데이터베이스 연결 확인
```bash
psql -U postgres -d batch_db
SELECT * FROM ships;
```
---
#### 문제 4: Chunk 처리 중 일부만 저장됨
**증상**: 100건 조회 중 50건만 저장됨
**해결 방법**:
1. Processor의 `shouldProcess()` 확인
```java
@Override
protected boolean shouldProcess(ShipDto dto) {
// false 반환 시 필터링됨
return dto.getShipId() != null;
}
```
2. 필터링 로그 확인
```java
@Override
protected void onItemFiltered(ShipDto dto) {
log.warn("필터링됨: {}", dto); // 로그 추가
}
```
---
#### 문제 5: 메모리 부족 오류 (OutOfMemoryError)
**증상**: 대량 데이터 처리 중 OOM 발생
**해결 방법**:
1. Chunk 크기 줄이기
```java
@Override
protected int getChunkSize() {
return 50; // 기본 100에서 감소
}
```
2. JVM 힙 메모리 증가
```bash
java -Xmx2g -jar snp-batch.jar
```
3. API 페이징 처리 구현
---
### 7.2 로그 레벨 설정
**application.yml**:
```yaml
logging:
level:
com.snp.batch: DEBUG # 배치 애플리케이션
com.snp.batch.common.batch: INFO # 배치 추상 클래스 (INFO)
com.snp.batch.common.web: INFO # 웹 추상 클래스 (INFO)
org.springframework.batch: INFO # Spring Batch
org.springframework.jdbc.core: DEBUG # SQL 쿼리
org.springframework.web.reactive: DEBUG # WebClient
```
---
### 7.3 디버깅 팁
1. **Step 실행 상태 확인**
```sql
SELECT * FROM BATCH_STEP_EXECUTION
WHERE JOB_EXECUTION_ID = {executionId};
```
2. **Step Context 확인**
```sql
SELECT * FROM BATCH_STEP_EXECUTION_CONTEXT
WHERE STEP_EXECUTION_ID = {stepExecutionId};
```
3. **Job Parameter 확인**
```sql
SELECT * FROM BATCH_JOB_EXECUTION_PARAMS
WHERE JOB_EXECUTION_ID = {executionId};
```
---
## 8. 자주 묻는 질문 (FAQ)
### Q1: Processor 없이 Reader → Writer만 사용할 수 있나요?
**A**: 네, 가능합니다. `createProcessor()`에서 `null`을 반환하면 됩니다.
```java
@Override
protected ItemProcessor<ShipDto, ShipDto> createProcessor() {
return null; // Processor 없이 Reader → Writer
}
```
---
### Q2: 여러 개의 Writer를 사용할 수 있나요?
**A**: `CompositeItemWriter`를 사용하면 가능합니다.
```java
@Override
protected ItemWriter<ShipEntity> createWriter() {
CompositeItemWriter<ShipEntity> compositeWriter = new CompositeItemWriter<>();
compositeWriter.setDelegates(Arrays.asList(
shipDataWriter,
auditLogWriter
));
return compositeWriter;
}
```
---
### Q3: API 페이징을 지원하나요?
**A**: 현재 `BaseApiReader`는 단일 호출만 지원합니다. 페이징이 필요한 경우 커스텀 Reader를 구현하세요.
---
### Q4: 스케줄 등록은 어떻게 하나요?
**A**: 웹 UI에서 "스케줄 등록" 버튼을 클릭하여 Cron 표현식을 입력하면 됩니다.
```
예제 Cron 표현식:
- 매일 오전 2시: 0 0 2 * * ?
- 매시간: 0 0 * * * ?
- 매주 월요일 오전 9시: 0 0 9 ? * MON
```
---
### Q5: Job 실행 이력은 어디서 확인하나요?
**A**: 웹 UI의 다음 위치에서 확인할 수 있습니다:
1. 대시보드: 최근 실행 이력 (최근 10건)
2. Job 상세 페이지: 특정 Job의 모든 실행 이력
3. 타임라인 차트: 일/주/월 단위 시각화
---
## 9. 추가 리소스
### 9.1 공식 문서
- [Spring Batch 공식 문서](https://docs.spring.io/spring-batch/docs/current/reference/html/)
- [Spring Boot 공식 문서](https://docs.spring.io/spring-boot/docs/current/reference/html/)
- [Quartz Scheduler](http://www.quartz-scheduler.org/documentation/)
### 9.2 프로젝트 파일
- `application.yml`: 애플리케이션 설정
- `schema-postgresql.sql`: 데이터베이스 스키마
- `BaseApiReader.java`: API Reader 추상 클래스 (src/main/java/com/snp/batch/common/batch/reader/BaseApiReader.java:1)
- `BaseJobConfig.java`: Job Config 추상 클래스 (src/main/java/com/snp/batch/common/batch/config/BaseJobConfig.java:1)
- `BaseJdbcRepository.java`: JDBC Repository 추상 클래스 (src/main/java/com/snp/batch/common/batch/repository/BaseJdbcRepository.java:1)
---
---
## 10. 추상화 클래스 체크리스트
새로운 배치 Job을 만들 때 다음 체크리스트를 참고하세요.
### 10.1 필수 구현 항목
- [ ] **패키지 구조 생성**
- [ ] `jobs/{domain}/batch/` 디렉토리 생성 (배치 작업)
- [ ] `jobs/{domain}/web/` 디렉토리 생성 (웹 API 필요 시)
- [ ] **DTO 생성** (`jobs/{domain}/batch/dto/` 패키지)
- [ ] API 응답 DTO (예: `ShipApiResponse`)
- [ ] 데이터 DTO (예: `ShipDto`)
- [ ] **Entity 생성** (`jobs/{domain}/batch/entity/` 패키지)
- [ ] `BaseEntity` 상속
- [ ] `@SuperBuilder`, `@NoArgsConstructor`, `@AllArgsConstructor` 추가
- [ ] `@EqualsAndHashCode(callSuper = true)` 추가
- [ ] **JPA 어노테이션 사용 금지** (@Entity, @Table, @Column 등)
- [ ] 컬럼 매핑 정보는 주석으로 명시
- [ ] **Repository 구현** (`jobs/{domain}/batch/repository/` 패키지)
- [ ] 인터페이스 생성 (예: `ShipRepository`)
- [ ] 구현체 생성 (예: `ShipRepositoryImpl`)
- [ ] `BaseJdbcRepository` 상속 (JDBC 전용)
- [ ] `getTableName()`, `getInsertSql()`, `getUpdateSql()` 구현
- [ ] `setInsertParameters()`, `setUpdateParameters()` 구현
- [ ] `getRowMapper()` 구현 (RowMapper 클래스 생성)
- [ ] 커스텀 쿼리 메서드 정의
- [ ] **Reader 구현** (`jobs/{domain}/batch/reader/` 패키지)
- [ ] `BaseApiReader` 상속
- [ ] `getApiPath()` 구현
- [ ] `extractDataFromResponse()` 구현
- [ ] `getResponseType()` 구현
- [ ] `getReaderName()` 구현
- [ ] **Processor 구현** (`jobs/{domain}/batch/processor/` 패키지)
- [ ] `BaseProcessor` 상속
- [ ] `transform()` 구현 (DTO → Entity 변환)
- [ ] `shouldProcess()` 구현 (필터링 로직)
- [ ] `getProcessorName()` 구현
- [ ] **Writer 구현** (`jobs/{domain}/batch/writer/` 패키지)
- [ ] `BaseWriter` 상속
- [ ] `writeItems()` 구현 (Repository 호출)
- [ ] `getWriterName()` 구현
- [ ] **JobConfig 구현** (`jobs/{domain}/batch/config/` 패키지)
- [ ] `BaseJobConfig` 상속
- [ ] `getJobName()` 구현
- [ ] `createReader()` 구현
- [ ] `createProcessor()` 구현
- [ ] `createWriter()` 구현
- [ ] `getChunkSize()` 구현 (선택사항, 기본값: 100)
- [ ] `@Bean` 메서드로 Job과 Step 등록
### 10.2 선택 구현 항목
- [ ] **웹 API 구현** (REST API 제공 시)
- [ ] **DTO 생성** (`jobs/{domain}/web/dto/` 패키지)
- [ ] `BaseDto` 상속 (웹 전용 DTO)
- [ ] **Service 구현** (`jobs/{domain}/web/service/` 패키지)
- [ ] `BaseServiceImpl` 상속 (JDBC 기반)
- [ ] `getRepository()` 구현 (배치 Repository 재사용)
- [ ] `toDto()`, `toEntity()` 구현
- [ ] CRUD 메서드 오버라이드 (필요 시)
- [ ] **Controller 구현** (`jobs/{domain}/web/controller/` 패키지)
- [ ] `BaseController` 상속
- [ ] `@RequestMapping` 설정
- [ ] 커스텀 API 추가 (필요 시)
- [ ] **에러 핸들링**
- [ ] `BaseApiReader.handleApiError()` 오버라이드
- [ ] `BaseProcessor.handleProcessError()` 오버라이드
- [ ] `BaseWriter.handleWriteError()` 오버라이드
- [ ] **로깅 강화**
- [ ] `beforeApiCall()`, `afterApiCall()` 구현
- [ ] `beforeProcess()`, `afterProcess()` 구현
- [ ] `beforeWrite()`, `afterWrite()` 구현
### 10.3 테스트 항목
- [ ] **단위 테스트**
- [ ] Reader 테스트 (API 모킹)
- [ ] Processor 테스트 (변환 로직 검증)
- [ ] Writer 테스트 (Repository 모킹)
- [ ] **통합 테스트**
- [ ] Job 실행 테스트
- [ ] 데이터베이스 저장 검증
- [ ] **성능 테스트**
- [ ] 대용량 데이터 처리 테스트
- [ ] Chunk 크기 최적화
---
---
## 📚 관련 문서
### 핵심 문서
- **[README.md](README.md)** - 프로젝트 개요 및 빠른 시작 가이드
- **[CLAUDE.md](CLAUDE.md)** - 프로젝트 형상관리 문서 (세션 연속성)
- **[SWAGGER_GUIDE.md](SWAGGER_GUIDE.md)** - Swagger API 문서 사용 가이드
### 아키텍처 문서
- **[docs/architecture/ARCHITECTURE.md](docs/architecture/ARCHITECTURE.md)** - 프로젝트 아키텍처 상세 설계
- **[docs/architecture/PROJECT_STRUCTURE.md](docs/architecture/PROJECT_STRUCTURE.md)** - Job 중심 패키지 구조 가이드
### 구현 가이드
- **[docs/guides/PROXY_SERVICE_GUIDE.md](docs/guides/PROXY_SERVICE_GUIDE.md)** - 외부 API 프록시 패턴 구현 가이드
- **[docs/guides/SHIP_API_EXAMPLE.md](docs/guides/SHIP_API_EXAMPLE.md)** - Maritime API 연동 실전 예제
### 보안 문서
- **[docs/security/README.md](docs/security/README.md)** - 보안 전략 개요 (계획 단계)
---
**마지막 업데이트**: 2025-10-16
**버전**: 1.3.0
---
## 변경 이력
### v1.3.0 (2025-10-16)
- ✅ 프로젝트 구조를 현행화: `common/batch/``common/web/` 분리 반영
- ✅ jobs 패키지 구조 업데이트: `batch/``web/` 서브패키지 구조 반영
- ✅ 모든 Base 클래스 위치 경로 수정 (common.base → common.batch/web)
- ✅ JDBC vs JPA 사용 구분 명확화 (jobs는 JDBC 전용, global은 JPA 허용)
- ✅ Entity 예제 업데이트: JPA 어노테이션 제거, 주석 기반 매핑 설명 추가
- ✅ 체크리스트 강화: 패키지 구조, Repository 구현, 웹 API 구현 세분화
### v1.2.0 (2025-10-15)
- 문서 간 상호 참조 링크 추가
- 관련 문서 섹션 추가
### v1.1.0 (2025-10-14)
- 추상화 클래스 체크리스트 추가
- 예제 코드 개선
### v1.0.0 (2025-10-13)
- 초기 버전 작성