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

48 KiB

Spring Batch 개발 가이드

목차

  1. 프로젝트 개요
  2. 프로젝트 구조
  3. 추상 클래스 구조
  4. 새로운 배치 Job 생성 가이드
  5. 예제: 전체 구현 과정
  6. 베스트 프랙티스
  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

제공 필드:

@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 사전 준비

  1. 도메인 파악

    • 어떤 데이터를 수집할 것인가? (예: 선박 데이터, 사용자 데이터 등)
    • API 엔드포인트는 무엇인가?
    • 데이터 구조는 어떻게 되는가?
  2. 데이터베이스 테이블 생성

    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: 테스트 및 실행

  1. 애플리케이션 시작

    mvn spring-boot:run
    
  2. 웹 UI에서 확인

  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

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 로깅 전략

// 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 사용

    @Override
    protected void writeItems(List<ShipEntity> items) {
        shipRepository.batchInsert(items);  // saveAll() 대신
    }
    
  2. API 페이징 처리

    @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 어노테이션 확인

    @Bean(name = "shipDataImportJob")
    public Job shipDataImportJob() {
        return job();
    }
    
  2. JobConfig 클래스에 @Configuration 어노테이션 확인

  3. 로그 확인: "Job 생성: shipDataImportJob" 메시지 확인


문제 2: API 호출 실패

증상: "API 호출 실패" 로그, 데이터 0건

해결 방법:

  1. WebClient 설정 확인 (application.yml)

    api:
      base-url: http://api.example.com
    
  2. API 경로 확인

    @Override
    protected String getApiPath() {
        return "/api/v1/ships";  // 슬래시 확인
    }
    
  3. 네트워크 연결 테스트

    curl http://api.example.com/api/v1/ships
    

문제 3: 데이터가 저장되지 않음

증상: "저장 완료" 로그는 있지만 DB에 데이터 없음

해결 방법:

  1. 트랜잭션 확인

    @Override
    protected void writeItems(List<ShipEntity> items) {
        shipRepository.saveAll(items);  // 트랜잭션 내에서 실행되는지 확인
    }
    
  2. SQL 로그 활성화 (application.yml)

    logging:
      level:
        org.springframework.jdbc.core: DEBUG
    
  3. 데이터베이스 연결 확인

    psql -U postgres -d batch_db
    SELECT * FROM ships;
    

문제 4: Chunk 처리 중 일부만 저장됨

증상: 100건 조회 중 50건만 저장됨

해결 방법:

  1. Processor의 shouldProcess() 확인

    @Override
    protected boolean shouldProcess(ShipDto dto) {
        // false 반환 시 필터링됨
        return dto.getShipId() != null;
    }
    
  2. 필터링 로그 확인

    @Override
    protected void onItemFiltered(ShipDto dto) {
        log.warn("필터링됨: {}", dto);  // 로그 추가
    }
    

문제 5: 메모리 부족 오류 (OutOfMemoryError)

증상: 대량 데이터 처리 중 OOM 발생

해결 방법:

  1. Chunk 크기 줄이기

    @Override
    protected int getChunkSize() {
        return 50;  // 기본 100에서 감소
    }
    
  2. JVM 힙 메모리 증가

    java -Xmx2g -jar snp-batch.jar
    
  3. 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 디버깅 팁

  1. Step 실행 상태 확인

    SELECT * FROM BATCH_STEP_EXECUTION
    WHERE JOB_EXECUTION_ID = {executionId};
    
  2. Step Context 확인

    SELECT * FROM BATCH_STEP_EXECUTION_CONTEXT
    WHERE STEP_EXECUTION_ID = {stepExecutionId};
    
  3. 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의 다음 위치에서 확인할 수 있습니다:

  1. 대시보드: 최근 실행 이력 (최근 10건)
  2. Job 상세 페이지: 특정 Job의 모든 실행 이력
  3. 타임라인 차트: 일/주/월 단위 시각화

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)
  • 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 - 프로젝트 개요 및 빠른 시작 가이드
  • CLAUDE.md - 프로젝트 형상관리 문서 (세션 연속성)
  • SWAGGER_GUIDE.md - Swagger API 문서 사용 가이드

아키텍처 문서

구현 가이드

보안 문서


마지막 업데이트: 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)

  • 초기 버전 작성