# 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 **목적**: Service 계층의 공통 CRUD 인터페이스 정의 **위치**: `com.snp.batch.common.web.service.BaseService` **제공 메서드**: ```java D create(D dto); // 생성 Optional findById(ID id); // 단건 조회 List findAll(); // 전체 조회 Page 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 **목적**: BaseService의 기본 구현 제공 **위치**: `com.snp.batch.common.web.service.BaseServiceImpl` **필수 구현 메서드**: ```java protected abstract BaseJdbcRepository 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 { private final ProductRepository productRepository; @Override protected BaseJdbcRepository 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 **목적**: REST Controller의 공통 CRUD API 제공 **위치**: `com.snp.batch.common.web.controller.BaseController` **필수 구현 메서드**: ```java protected abstract BaseService 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 { private final ShipService shipService; @Override protected BaseService getService() { return shipService; } @Override protected String getResourceName() { return "Ship"; } // 추가 커스텀 API가 필요한 경우 여기에 정의 } ``` --- #### 3.0.6 ApiResponse **목적**: 통일된 API 응답 형식 제공 **위치**: `com.snp.batch.common.web.ApiResponse` **필드 구조**: ```java private boolean success; // 성공 여부 private String message; // 메시지 private T data; // 응답 데이터 private String errorCode; // 에러 코드 (실패 시) ``` **사용 방법**: ```java // 성공 응답 ApiResponse response = ApiResponse.success(shipDto); ApiResponse response = ApiResponse.success("Ship created", shipDto); // 실패 응답 ApiResponse response = ApiResponse.error("Ship not found"); ApiResponse response = ApiResponse.error("Validation failed", "ERR_001"); ``` **응답 예제**: ```json { "success": true, "message": "Success", "data": { "shipName": "Titanic", "shipType": "Passenger" }, "errorCode": null } ``` --- ### 3.1 BaseApiReader **목적**: REST API에서 데이터를 읽어오는 ItemReader 구현 패턴 제공 **위치**: `com.snp.batch.common.batch.reader.BaseApiReader` **필수 구현 메소드**: ```java protected abstract String getApiPath(); // API 경로 protected abstract List 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 data) {} // API 호출 후처리 protected void handleApiError(Exception e) {} // 에러 처리 ``` **제공되는 기능**: - API 호출 및 데이터 캐싱 (한 번만 호출) - 순차적 데이터 반환 (read() 메소드) - 자동 로깅 및 에러 핸들링 **사용 예제**: ```java @Component public class ShipDataReader extends BaseApiReader { public ShipDataReader(WebClient webClient) { super(webClient); } @Override protected String getApiPath() { return "/api/v1/ships"; } @Override protected List 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 **목적**: 데이터 변환 및 처리 로직의 템플릿 제공 **위치**: `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 { @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` **필수 구현 메소드**: ```java protected abstract void writeItems(List items) throws Exception; // 저장 로직 protected abstract String getWriterName(); // Writer 이름 ``` **선택적 오버라이드 메소드**: ```java protected void beforeWrite(List items) {} // 저장 전처리 protected void afterWrite(List items) {} // 저장 후처리 protected void handleWriteError(List items, Exception e) {} // 에러 처리 protected List filterItems(List items) {} // 아이템 필터링 protected void validateBatchSize(List items) {} // 배치 크기 검증 ``` **제공되는 기능**: - 배치 저장 패턴 (Chunk 단위) - Null 아이템 자동 필터링 - 배치 크기 검증 및 경고 - 자동 로깅 및 에러 핸들링 **사용 예제**: ```java @Component @RequiredArgsConstructor public class ShipDataWriter extends BaseWriter { private final ShipRepository shipRepository; @Override protected void writeItems(List items) { shipRepository.saveAll(items); } @Override protected String getWriterName() { return "ShipDataWriter"; } @Override protected void afterWrite(List items) { log.info("Ship 데이터 저장 완료: {} 건", items.size()); } } ``` --- ### 3.4 BaseJobConfig **목적**: Batch Job 설정의 표준 템플릿 제공 **위치**: `com.snp.batch.common.batch.config.BaseJobConfig` **필수 구현 메소드**: ```java protected abstract String getJobName(); // Job 이름 protected abstract ItemReader createReader(); // Reader 생성 protected abstract ItemProcessor createProcessor(); // Processor 생성 protected abstract ItemWriter 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 { 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 createReader() { return shipDataReader; } @Override protected ItemProcessor createProcessor() { return shipDataProcessor; } @Override protected ItemWriter 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 **목적**: JDBC 기반 Repository의 CRUD 템플릿 제공 **위치**: `com.snp.batch.common.batch.repository.BaseJdbcRepository` **필수 구현 메소드**: ```java protected abstract String getTableName(); // 테이블 이름 protected abstract RowMapper 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 { public ShipRepository(JdbcTemplate jdbcTemplate) { super(jdbcTemplate); } @Override protected String getTableName() { return "ships"; } @Override protected RowMapper 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 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 { public ShipRepository(JdbcTemplate jdbcTemplate) { super(jdbcTemplate); } // 추상 메소드 구현 (위의 3.5 예제 참고) } ``` #### Step 4: Reader 구현 (jobs/{domain}/batch/reader 패키지) ```java @Component public class ShipDataReader extends BaseApiReader { public ShipDataReader(WebClient webClient) { super(webClient); } // 추상 메소드 구현 (위의 3.1 예제 참고) } ``` #### Step 5: Processor 구현 (jobs/{domain}/batch/processor 패키지) ```java @Component public class ShipDataProcessor extends BaseProcessor { // 추상 메소드 구현 (위의 3.2 예제 참고) } ``` #### Step 6: Writer 구현 (jobs/{domain}/batch/writer 패키지) ```java @Component @RequiredArgsConstructor public class ShipDataWriter extends BaseWriter { private final ShipRepository shipRepository; // 추상 메소드 구현 (위의 3.3 예제 참고) } ``` #### Step 7: JobConfig 구현 (jobs/{domain}/batch/config 패키지) ```java @Configuration @RequiredArgsConstructor public class ShipDataImportJobConfig extends BaseJobConfig { 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 data) { log.info("API 호출 성공: {} 건 조회", data.size()); } // Processor에서 @Override protected void onItemFiltered(ShipDto dto) { log.debug("필터링됨: {}", dto); } // Writer에서 @Override protected void afterWrite(List items) { log.info("저장 완료: {} 건", items.size()); } ``` ### 6.6 성능 최적화 1. **배치 Insert 사용** ```java @Override protected void writeItems(List 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 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 createProcessor() { return null; // Processor 없이 Reader → Writer } ``` --- ### Q2: 여러 개의 Writer를 사용할 수 있나요? **A**: `CompositeItemWriter`를 사용하면 가능합니다. ```java @Override protected ItemWriter createWriter() { CompositeItemWriter 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) - 초기 버전 작성