48 KiB
Spring Batch 개발 가이드
목차
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
제공 필드:
@CreatedDate
private LocalDateTime createdAt; // 생성 일시 (자동 설정)
@LastModifiedDate
private LocalDateTime updatedAt; // 수정 일시 (자동 업데이트)
private String createdBy; // 생성자 (기본값: "SYSTEM")
private String updatedBy; // 수정자 (기본값: "SYSTEM")
사용 방법 (jobs 패키지 - JDBC 전용):
/**
* 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 허용):
@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
제공 필드:
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private String createdBy;
private String updatedBy;
사용 방법:
@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
제공 메서드:
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
필수 구현 메서드:
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 상속).
사용 예제:
@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
필수 구현 메서드:
protected abstract BaseService<?, D, ID> getService(); // Service 반환
protected abstract String getResourceName(); // 리소스 이름 (로깅용)
제공 API:
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) # 존재 여부
사용 예제:
@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
목적: 통일된 API 응답 형식 제공
위치: com.snp.batch.common.web.ApiResponse
필드 구조:
private boolean success; // 성공 여부
private String message; // 메시지
private T data; // 응답 데이터
private String errorCode; // 에러 코드 (실패 시)
사용 방법:
// 성공 응답
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");
응답 예제:
{
"success": true,
"message": "Success",
"data": {
"shipName": "Titanic",
"shipType": "Passenger"
},
"errorCode": null
}
3.1 BaseApiReader
목적: REST API에서 데이터를 읽어오는 ItemReader 구현 패턴 제공
위치: com.snp.batch.common.batch.reader.BaseApiReader
필수 구현 메소드:
protected abstract String getApiPath(); // API 경로
protected abstract List<T> extractDataFromResponse(Object response); // 응답 파싱
protected abstract Class<?> getResponseType(); // 응답 클래스
protected abstract String getReaderName(); // Reader 이름
선택적 오버라이드 메소드:
protected void addQueryParams(UriBuilder uriBuilder) {} // 쿼리 파라미터 추가
protected void beforeApiCall() {} // API 호출 전처리
protected void afterApiCall(List<T> data) {} // API 호출 후처리
protected void handleApiError(Exception e) {} // 에러 처리
제공되는 기능:
- API 호출 및 데이터 캐싱 (한 번만 호출)
- 순차적 데이터 반환 (read() 메소드)
- 자동 로깅 및 에러 핸들링
사용 예제:
@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
필수 구현 메소드:
protected abstract O transform(I item) throws Exception; // 데이터 변환
protected abstract boolean shouldProcess(I item); // 처리 여부 판단
protected abstract String getProcessorName(); // Processor 이름
선택적 오버라이드 메소드:
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 기반)
- 자동 로깅 및 에러 핸들링
사용 예제:
@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
목적: 데이터베이스 저장 로직의 템플릿 제공
위치: com.snp.batch.common.batch.writer.BaseWriter
필수 구현 메소드:
protected abstract void writeItems(List<T> items) throws Exception; // 저장 로직
protected abstract String getWriterName(); // Writer 이름
선택적 오버라이드 메소드:
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 아이템 자동 필터링
- 배치 크기 검증 및 경고
- 자동 로깅 및 에러 핸들링
사용 예제:
@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
필수 구현 메소드:
protected abstract String getJobName(); // Job 이름
protected abstract ItemReader<I> createReader(); // Reader 생성
protected abstract ItemProcessor<I, O> createProcessor(); // Processor 생성
protected abstract ItemWriter<O> createWriter(); // Writer 생성
선택적 오버라이드 메소드:
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가 없는 경우도 지원
사용 예제:
@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
필수 구현 메소드:
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
- 자동 트랜잭션 처리
사용 예제:
@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 사전 준비
-
도메인 파악
- 어떤 데이터를 수집할 것인가? (예: 선박 데이터, 사용자 데이터 등)
- API 엔드포인트는 무엇인가?
- 데이터 구조는 어떻게 되는가?
-
데이터베이스 테이블 생성
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:
@Data
public class ShipApiResponse {
private List<ShipDto> data;
private int totalCount;
}
데이터 DTO:
@Data
@Builder
public class ShipDto {
private String imoNumber;
private String coreShipInd;
private String datasetVersion;
}
Step 2: Entity 클래스 생성 (jobs/{domain}/batch/entity 패키지)
JDBC 기반 Entity (JPA 어노테이션 없음):
/**
* 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 패키지)
@Repository
public class ShipRepository extends BaseJdbcRepository<ShipEntity, String> {
public ShipRepository(JdbcTemplate jdbcTemplate) {
super(jdbcTemplate);
}
// 추상 메소드 구현 (위의 3.5 예제 참고)
}
Step 4: Reader 구현 (jobs/{domain}/batch/reader 패키지)
@Component
public class ShipDataReader extends BaseApiReader<ShipDto> {
public ShipDataReader(WebClient webClient) {
super(webClient);
}
// 추상 메소드 구현 (위의 3.1 예제 참고)
}
Step 5: Processor 구현 (jobs/{domain}/batch/processor 패키지)
@Component
public class ShipDataProcessor extends BaseProcessor<ShipDto, ShipEntity> {
// 추상 메소드 구현 (위의 3.2 예제 참고)
}
Step 6: Writer 구현 (jobs/{domain}/batch/writer 패키지)
@Component
@RequiredArgsConstructor
public class ShipDataWriter extends BaseWriter<ShipEntity> {
private final ShipRepository shipRepository;
// 추상 메소드 구현 (위의 3.3 예제 참고)
}
Step 7: JobConfig 구현 (jobs/{domain}/batch/config 패키지)
@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: 테스트 및 실행
-
애플리케이션 시작
mvn spring-boot:run -
웹 UI에서 확인
- http://localhost:8080
- "shipDataImportJob" 확인
- "즉시 실행" 버튼 클릭
-
로그 확인
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
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 에러 처리 전략
-
API 호출 실패
BaseApiReader.handleApiError()오버라이드- 빈 리스트 반환 (Job 실패 방지) 또는 예외 던지기
-
데이터 변환 실패
BaseProcessor.handleProcessError()오버라이드- 문제 데이터 로깅 후 null 반환 (다음 데이터 계속 처리)
-
저장 실패
BaseWriter.handleWriteError()오버라이드- 재시도 로직 또는 부분 저장 구현
6.5 로깅 전략
// 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 성능 최적화
-
배치 Insert 사용
@Override protected void writeItems(List<ShipEntity> items) { shipRepository.batchInsert(items); // saveAll() 대신 } -
API 페이징 처리
@Override protected void addQueryParams(UriBuilder uriBuilder) { uriBuilder.queryParam("page", 1); uriBuilder.queryParam("size", 1000); } -
경량 쿼리 사용
- 불필요한 JOIN 제거
- 필요한 컬럼만 SELECT
7. 트러블슈팅
7.1 일반적인 문제
문제 1: Job이 실행되지 않음
증상: 웹 UI에서 Job 목록이 보이지 않음
해결 방법:
-
@Bean어노테이션 확인@Bean(name = "shipDataImportJob") public Job shipDataImportJob() { return job(); } -
JobConfig 클래스에
@Configuration어노테이션 확인 -
로그 확인: "Job 생성: shipDataImportJob" 메시지 확인
문제 2: API 호출 실패
증상: "API 호출 실패" 로그, 데이터 0건
해결 방법:
-
WebClient 설정 확인 (
application.yml)api: base-url: http://api.example.com -
API 경로 확인
@Override protected String getApiPath() { return "/api/v1/ships"; // 슬래시 확인 } -
네트워크 연결 테스트
curl http://api.example.com/api/v1/ships
문제 3: 데이터가 저장되지 않음
증상: "저장 완료" 로그는 있지만 DB에 데이터 없음
해결 방법:
-
트랜잭션 확인
@Override protected void writeItems(List<ShipEntity> items) { shipRepository.saveAll(items); // 트랜잭션 내에서 실행되는지 확인 } -
SQL 로그 활성화 (
application.yml)logging: level: org.springframework.jdbc.core: DEBUG -
데이터베이스 연결 확인
psql -U postgres -d batch_db SELECT * FROM ships;
문제 4: Chunk 처리 중 일부만 저장됨
증상: 100건 조회 중 50건만 저장됨
해결 방법:
-
Processor의
shouldProcess()확인@Override protected boolean shouldProcess(ShipDto dto) { // false 반환 시 필터링됨 return dto.getShipId() != null; } -
필터링 로그 확인
@Override protected void onItemFiltered(ShipDto dto) { log.warn("필터링됨: {}", dto); // 로그 추가 }
문제 5: 메모리 부족 오류 (OutOfMemoryError)
증상: 대량 데이터 처리 중 OOM 발생
해결 방법:
-
Chunk 크기 줄이기
@Override protected int getChunkSize() { return 50; // 기본 100에서 감소 } -
JVM 힙 메모리 증가
java -Xmx2g -jar snp-batch.jar -
API 페이징 처리 구현
7.2 로그 레벨 설정
application.yml:
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 디버깅 팁
-
Step 실행 상태 확인
SELECT * FROM BATCH_STEP_EXECUTION WHERE JOB_EXECUTION_ID = {executionId}; -
Step Context 확인
SELECT * FROM BATCH_STEP_EXECUTION_CONTEXT WHERE STEP_EXECUTION_ID = {stepExecutionId}; -
Job Parameter 확인
SELECT * FROM BATCH_JOB_EXECUTION_PARAMS WHERE JOB_EXECUTION_ID = {executionId};
8. 자주 묻는 질문 (FAQ)
Q1: Processor 없이 Reader → Writer만 사용할 수 있나요?
A: 네, 가능합니다. createProcessor()에서 null을 반환하면 됩니다.
@Override
protected ItemProcessor<ShipDto, ShipDto> createProcessor() {
return null; // Processor 없이 Reader → Writer
}
Q2: 여러 개의 Writer를 사용할 수 있나요?
A: CompositeItemWriter를 사용하면 가능합니다.
@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의 다음 위치에서 확인할 수 있습니다:
- 대시보드: 최근 실행 이력 (최근 10건)
- Job 상세 페이지: 특정 Job의 모든 실행 이력
- 타임라인 차트: 일/주/월 단위 시각화
9. 추가 리소스
9.1 공식 문서
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)
- API 응답 DTO (예:
-
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 추가 (필요 시)
- DTO 생성 (
-
에러 핸들링
BaseApiReader.handleApiError()오버라이드BaseProcessor.handleProcessError()오버라이드BaseWriter.handleWriteError()오버라이드
-
로깅 강화
beforeApiCall(),afterApiCall()구현beforeProcess(),afterProcess()구현beforeWrite(),afterWrite()구현
10.3 테스트 항목
-
단위 테스트
- Reader 테스트 (API 모킹)
- Processor 테스트 (변환 로직 검증)
- Writer 테스트 (Repository 모킹)
-
통합 테스트
- Job 실행 테스트
- 데이터베이스 저장 검증
-
성능 테스트
- 대용량 데이터 처리 테스트
- Chunk 크기 최적화
📚 관련 문서
핵심 문서
- README.md - 프로젝트 개요 및 빠른 시작 가이드
- CLAUDE.md - 프로젝트 형상관리 문서 (세션 연속성)
- SWAGGER_GUIDE.md - Swagger API 문서 사용 가이드
아키텍처 문서
- docs/architecture/ARCHITECTURE.md - 프로젝트 아키텍처 상세 설계
- docs/architecture/PROJECT_STRUCTURE.md - Job 중심 패키지 구조 가이드
구현 가이드
- docs/guides/PROXY_SERVICE_GUIDE.md - 외부 API 프록시 패턴 구현 가이드
- docs/guides/SHIP_API_EXAMPLE.md - Maritime API 연동 실전 예제
보안 문서
- 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)
- 초기 버전 작성