스프링 배치 로 기상정보 가져오기 파일처리 실습 (맛보기 시리즈 2-6)

MJ·2025년 8월 29일
post-thumbnail

06. 배치 학습 2단계: CSV 파일 처리 실습

🎯 학습 목표

  • ItemReader, ItemProcessor, ItemWriter 실전 구현
  • CSV 파일을 읽어서 데이터베이스에 저장하는 완전한 배치 시스템 구축
  • 청크 기반 처리 이해 및 최적화
  • 데이터 검증 및 변환 로직 구현

📂 프로젝트 준비

1. 의존성 추가 (build.gradle)

dependencies {
    // 기존 의존성들...
    
    // CSV 처리를 위한 추가 의존성
    implementation 'org.springframework.batch:spring-batch-core'
    implementation 'org.springframework.boot:spring-boot-starter-batch'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    
    // 검증을 위한 의존성
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    
    // 유틸리티
    implementation 'org.apache.commons:commons-csv:1.10.0'
}

2. 테스트 데이터 준비

CSV 파일 생성 (src/main/resources/data/employees.csv):

firstName,lastName,email,department,salary,hireDate
김,철수,kim.chulsoo@company.com,개발팀,5500000,2023-01-15
이,영희,lee.younghee@company.com,마케팅팀,4800000,2023-02-20
박,민수,park.minsoo@company.com,개발팀,6200000,2022-11-10
최,수진,choi.sujin@company.com,인사팀,5000000,2023-03-05
정,호영,jung.hoyoung@company.com,영업팀,4500000,2023-01-30
강,미영,kang.miyoung@company.com,개발팀,7000000,2022-08-15
윤,대호,yoon.daeho@company.com,기획팀,5800000,2023-02-10
송,지민,song.jimin@company.com,개발팀,5200000,2023-04-01
조,현우,cho.hyunwoo@company.com,마케팅팀,4700000,2023-01-25
한,서연,han.seoyeon@company.com,인사팀,5300000,2022-12-05

잘못된 데이터가 포함된 CSV (src/main/resources/data/employees-with-errors.csv):

firstName,lastName,email,department,salary,hireDate
김,철수,kim.chulsoo@company.com,개발팀,5500000,2023-01-15
이,영희,invalid-email,마케팅팀,4800000,2023-02-20
박,,park.minsoo@company.com,개발팀,6200000,2022-11-10
최,수진,choi.sujin@company.com,,5000000,2023-03-05
정,호영,jung.hoyoung@company.com,영업팀,-1000,2023-01-30
강,미영,kang.miyoung@company.com,개발팀,7000000,invalid-date

🏗️ 엔티티 및 DTO 클래스 구현

1. Employee 엔티티 클래스

package com.example.batchtutorial.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;

/**
 * 직원 정보를 저장하는 JPA 엔티티
 */
@Entity
@Table(name = "employees")
@Data
@NoArgsConstructor
@AllArgsConstructor
public class Employee {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @Column(name = "first_name", nullable = false, length = 50)
    @NotBlank(message = "이름은 필수입니다")
    private String firstName;
    
    @Column(name = "last_name", nullable = false, length = 50)
    @NotBlank(message = "성은 필수입니다")
    private String lastName;
    
    @Column(name = "email", nullable = false, unique = true, length = 100)
    @Email(message = "올바른 이메일 형식이 아닙니다")
    @NotBlank(message = "이메일은 필수입니다")
    private String email;
    
    @Column(name = "department", nullable = false, length = 50)
    @NotBlank(message = "부서는 필수입니다")
    private String department;
    
    @Column(name = "salary", nullable = false, precision = 10, scale = 2)
    @DecimalMin(value = "0.0", message = "급여는 0보다 커야 합니다")
    @NotNull(message = "급여는 필수입니다")
    private BigDecimal salary;
    
    @Column(name = "hire_date", nullable = false)
    @NotNull(message = "입사일은 필수입니다")
    @PastOrPresent(message = "입사일은 현재 또는 과거 날짜여야 합니다")
    private LocalDate hireDate;
    
    @Column(name = "created_at")
    private LocalDateTime createdAt;
    
    @Column(name = "updated_at")
    private LocalDateTime updatedAt;
    
    // 생성/수정 시간 자동 설정
    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
        this.updatedAt = LocalDateTime.now();
    }
    
    @PreUpdate
    protected void onUpdate() {
        this.updatedAt = LocalDateTime.now();
    }
    
    // 전체 이름 반환 헬퍼 메서드
    public String getFullName() {
        return firstName + " " + lastName;
    }
}

2. CSV 데이터용 DTO 클래스

package com.example.batchtutorial.dto;

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * CSV 파일에서 읽어온 원시 데이터를 담는 DTO
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class EmployeeCsvDto {
    
    private String firstName;
    private String lastName;
    private String email;
    private String department;
    private String salary;        // 문자열로 받아서 검증 후 변환
    private String hireDate;      // 문자열로 받아서 날짜 변환
}

3. Repository 인터페이스

package com.example.batchtutorial.repository;

import com.example.batchtutorial.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.math.BigDecimal;
import java.util.List;
import java.util.Optional;

@Repository
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
    
    // 이메일로 직원 조회 (중복 체크용)
    Optional<Employee> findByEmail(String email);
    
    // 부서별 직원 목록 조회
    List<Employee> findByDepartmentOrderByLastNameAsc(String department);
    
    // 급여 범위로 직원 조회
    List<Employee> findBySalaryBetweenOrderBySalaryDesc(BigDecimal minSalary, BigDecimal maxSalary);
    
    // 부서별 평균 급여 계산
    @Query("SELECT e.department, AVG(e.salary) FROM Employee e GROUP BY e.department")
    List<Object[]> findAverageSalaryByDepartment();
    
    // 최근 입사자 조회
    @Query("SELECT e FROM Employee e ORDER BY e.hireDate DESC")
    List<Employee> findRecentHires();
}

🔧 배치 컴포넌트 구현

1. ItemReader - CSV 파일 읽기

package com.example.batchtutorial.batch;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;

/**
 * CSV 파일을 읽는 ItemReader 설정
 */
@Slf4j
@Configuration
public class EmployeeItemReaderConfig {
    
    @Bean
    public FlatFileItemReader<EmployeeCsvDto> employeeCsvReader() {
        return new FlatFileItemReaderBuilder<EmployeeCsvDto>()
                .name("employeeCsvReader")
                .resource(new ClassPathResource("data/employees.csv"))
                .delimited()
                .delimiter(",")
                .names("firstName", "lastName", "email", "department", "salary", "hireDate")
                .linesToSkip(1)  // 첫 번째 라인(헤더) 스킵
                .fieldSetMapper(new BeanWrapperFieldSetMapper<EmployeeCsvDto>() {{
                    setTargetType(EmployeeCsvDto.class);
                }})
                .build();
    }
    
    /**
     * 에러가 있는 CSV 파일을 읽는 Reader (에러 처리 테스트용)
     */
    @Bean
    public FlatFileItemReader<EmployeeCsvDto> employeeCsvReaderWithErrors() {
        return new FlatFileItemReaderBuilder<EmployeeCsvDto>()
                .name("employeeCsvReaderWithErrors")
                .resource(new ClassPathResource("data/employees-with-errors.csv"))
                .delimited()
                .delimiter(",")
                .names("firstName", "lastName", "email", "department", "salary", "hireDate")
                .linesToSkip(1)
                .fieldSetMapper(new BeanWrapperFieldSetMapper<EmployeeCsvDto>() {{
                    setTargetType(EmployeeCsvDto.class);
                }})
                .build();
    }
}

2. ItemProcessor - 데이터 변환 및 검증

package com.example.batchtutorial.batch;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.exception.DataValidationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.regex.Pattern;

/**
 * CSV 데이터를 Employee 엔티티로 변환하는 ItemProcessor
 */
@Slf4j
@Configuration
public class EmployeeItemProcessorConfig {
    
    private static final Pattern EMAIL_PATTERN = Pattern.compile(
            "^[A-Za-z0-9+_.-]+@([A-Za-z0-9.-]+\\.[A-Za-z]{2,})$"
    );
    
    private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
    
    @Bean
    public ItemProcessor<EmployeeCsvDto, Employee> employeeProcessor() {
        return new ItemProcessor<EmployeeCsvDto, Employee>() {
            @Override
            public Employee process(EmployeeCsvDto csvDto) throws Exception {
                
                log.debug("Processing employee: {} {}", csvDto.getFirstName(), csvDto.getLastName());
                
                try {
                    // 1. 기본 필드 검증
                    validateRequiredFields(csvDto);
                    
                    // 2. Employee 엔티티 생성
                    Employee employee = new Employee();
                    
                    // 3. 기본 정보 설정
                    employee.setFirstName(csvDto.getFirstName().trim());
                    employee.setLastName(csvDto.getLastName().trim());
                    employee.setDepartment(csvDto.getDepartment().trim());
                    
                    // 4. 이메일 검증 및 설정
                    String email = validateAndProcessEmail(csvDto.getEmail());
                    employee.setEmail(email);
                    
                    // 5. 급여 검증 및 설정
                    BigDecimal salary = validateAndProcessSalary(csvDto.getSalary());
                    employee.setSalary(salary);
                    
                    // 6. 입사일 검증 및 설정
                    LocalDate hireDate = validateAndProcessHireDate(csvDto.getHireDate());
                    employee.setHireDate(hireDate);
                    
                    log.info("Successfully processed employee: {}", employee.getFullName());
                    return employee;
                    
                } catch (DataValidationException e) {
                    log.warn("Validation failed for employee {}: {}", 
                            csvDto.getFirstName() + " " + csvDto.getLastName(), e.getMessage());
                    return null;  // null 반환 시 해당 아이템은 writer로 전달되지 않음
                    
                } catch (Exception e) {
                    log.error("Unexpected error processing employee {}: {}", 
                            csvDto.getFirstName() + " " + csvDto.getLastName(), e.getMessage());
                    throw e;  // 예상치 못한 오류는 재발생시켜 배치 중단
                }
            }
        };
    }
    
    /**
     * 필수 필드 검증
     */
    private void validateRequiredFields(EmployeeCsvDto csvDto) throws DataValidationException {
        if (!StringUtils.hasText(csvDto.getFirstName())) {
            throw new DataValidationException("이름이 비어있습니다");
        }
        if (!StringUtils.hasText(csvDto.getLastName())) {
            throw new DataValidationException("성이 비어있습니다");
        }
        if (!StringUtils.hasText(csvDto.getEmail())) {
            throw new DataValidationException("이메일이 비어있습니다");
        }
        if (!StringUtils.hasText(csvDto.getDepartment())) {
            throw new DataValidationException("부서가 비어있습니다");
        }
        if (!StringUtils.hasText(csvDto.getSalary())) {
            throw new DataValidationException("급여가 비어있습니다");
        }
        if (!StringUtils.hasText(csvDto.getHireDate())) {
            throw new DataValidationException("입사일이 비어있습니다");
        }
    }
    
    /**
     * 이메일 검증 및 처리
     */
    private String validateAndProcessEmail(String email) throws DataValidationException {
        String processedEmail = email.trim().toLowerCase();
        
        if (!EMAIL_PATTERN.matcher(processedEmail).matches()) {
            throw new DataValidationException("올바른 이메일 형식이 아닙니다: " + email);
        }
        
        return processedEmail;
    }
    
    /**
     * 급여 검증 및 처리
     */
    private BigDecimal validateAndProcessSalary(String salaryStr) throws DataValidationException {
        try {
            BigDecimal salary = new BigDecimal(salaryStr.trim());
            
            if (salary.compareTo(BigDecimal.ZERO) <= 0) {
                throw new DataValidationException("급여는 0보다 커야 합니다: " + salaryStr);
            }
            
            if (salary.compareTo(new BigDecimal("100000000")) > 0) {
                throw new DataValidationException("급여가 너무 큽니다: " + salaryStr);
            }
            
            return salary;
            
        } catch (NumberFormatException e) {
            throw new DataValidationException("급여 형식이 올바르지 않습니다: " + salaryStr);
        }
    }
    
    /**
     * 입사일 검증 및 처리
     */
    private LocalDate validateAndProcessHireDate(String hireDateStr) throws DataValidationException {
        try {
            LocalDate hireDate = LocalDate.parse(hireDateStr.trim(), DATE_FORMATTER);
            
            // 입사일이 미래날짜인지 확인
            if (hireDate.isAfter(LocalDate.now())) {
                throw new DataValidationException("입사일은 현재 날짜보다 미래일 수 없습니다: " + hireDateStr);
            }
            
            // 입사일이 너무 과거인지 확인 (예: 회사 설립일 이전)
            LocalDate companyFoundedDate = LocalDate.of(2000, 1, 1);
            if (hireDate.isBefore(companyFoundedDate)) {
                throw new DataValidationException("입사일이 회사 설립일보다 이전입니다: " + hireDateStr);
            }
            
            return hireDate;
            
        } catch (DateTimeParseException e) {
            throw new DataValidationException("입사일 형식이 올바르지 않습니다 (yyyy-MM-dd): " + hireDateStr);
        }
    }
}

3. 커스텀 예외 클래스

package com.example.batchtutorial.exception;

/**
 * 데이터 검증 실패 시 발생하는 예외
 */
public class DataValidationException extends Exception {
    
    public DataValidationException(String message) {
        super(message);
    }
    
    public DataValidationException(String message, Throwable cause) {
        super(message, cause);
    }
}

4. ItemWriter - 데이터베이스 저장

package com.example.batchtutorial.batch;

import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.repository.EmployeeRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.item.Chunk;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.math.BigDecimal;
import java.util.List;

/**
 * Employee 엔티티를 데이터베이스에 저장하는 ItemWriter
 */
@Slf4j
@Configuration
public class EmployeeItemWriterConfig {
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    @Bean
    public ItemWriter<Employee> employeeWriter() {
        return new ItemWriter<Employee>() {
            @Override
            public void write(Chunk<? extends Employee> chunk) throws Exception {
                
                List<? extends Employee> employees = chunk.getItems();
                log.info("Writing {} employees to database", employees.size());
                
                // 통계 정보 수집
                int savedCount = 0;
                int duplicateCount = 0;
                BigDecimal totalSalary = BigDecimal.ZERO;
                
                for (Employee employee : employees) {
                    try {
                        // 중복 이메일 체크
                        if (employeeRepository.findByEmail(employee.getEmail()).isPresent()) {
                            log.warn("Duplicate email found, skipping: {}", employee.getEmail());
                            duplicateCount++;
                            continue;
                        }
                        
                        // 직원 저장
                        Employee savedEmployee = employeeRepository.save(employee);
                        savedCount++;
                        totalSalary = totalSalary.add(savedEmployee.getSalary());
                        
                        log.debug("Saved employee: {} (ID: {})", 
                                savedEmployee.getFullName(), savedEmployee.getId());
                        
                    } catch (Exception e) {
                        log.error("Failed to save employee: {}", employee.getFullName(), e);
                        throw e;
                    }
                }
                
                // 청크 처리 결과 로깅
                log.info("Chunk processing completed - Saved: {}, Duplicates: {}, Total Salary: {}", 
                        savedCount, duplicateCount, totalSalary);
                
                if (savedCount == 0 && duplicateCount > 0) {
                    log.warn("All employees in this chunk were duplicates");
                }
            }
        };
    }
}

🔧 배치 Job 설정

메인 배치 설정 클래스

package com.example.batchtutorial.config;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

/**
 * 직원 CSV 파일 처리 배치 Job 설정
 */
@Slf4j
@Configuration
public class EmployeeBatchConfig {
    
    @Autowired
    private JobRepository jobRepository;
    
    @Autowired
    private PlatformTransactionManager transactionManager;
    
    /**
     * 직원 데이터 처리 Job
     */
    @Bean
    public Job importEmployeeJob(Step importEmployeeStep) {
        return new JobBuilder("importEmployeeJob", jobRepository)
                .incrementer(new RunIdIncrementer())  // 매번 새로운 JobInstance 생성
                .flow(importEmployeeStep)
                .end()
                .build();
    }
    
    /**
     * 직원 데이터 처리 Step (정상 데이터)
     */
    @Bean
    public Step importEmployeeStep(
            @Qualifier("employeeCsvReader") ItemReader<EmployeeCsvDto> reader,
            ItemProcessor<EmployeeCsvDto, Employee> processor,
            ItemWriter<Employee> writer) {
        
        return new StepBuilder("importEmployeeStep", jobRepository)
                .<EmployeeCsvDto, Employee>chunk(3, transactionManager)  // 청크 크기 3
                .reader(reader)
                .processor(processor)
                .writer(writer)
                .build();
    }
    
    /**
     * 에러 처리가 포함된 Job (실습용)
     */
    @Bean
    public Job importEmployeeWithErrorHandlingJob(Step importEmployeeWithErrorHandlingStep) {
        return new JobBuilder("importEmployeeWithErrorHandlingJob", jobRepository)
                .incrementer(new RunIdIncrementer())
                .flow(importEmployeeWithErrorHandlingStep)
                .end()
                .build();
    }
    
    /**
     * 에러 처리가 포함된 Step
     */
    @Bean
    public Step importEmployeeWithErrorHandlingStep(
            @Qualifier("employeeCsvReaderWithErrors") ItemReader<EmployeeCsvDto> reader,
            ItemProcessor<EmployeeCsvDto, Employee> processor,
            ItemWriter<Employee> writer) {
        
        return new StepBuilder("importEmployeeWithErrorHandlingStep", jobRepository)
                .<EmployeeCsvDto, Employee>chunk(5, transactionManager)
                .reader(reader)
                .processor(processor)
                .writer(writer)
                // 에러 처리 설정
                .faultTolerant()
                .skipLimit(10)                                    // 최대 10개 아이템 스킵 허용
                .skip(Exception.class)                            // 모든 예외에 대해 스킵 처리
                .listener(employeeSkipListener())                 // 스킵 리스너 등록
                .build();
    }
    
    /**
     * 스킵된 아이템 로깅을 위한 리스너
     */
    @Bean
    public EmployeeSkipListener employeeSkipListener() {
        return new EmployeeSkipListener();
    }
}

스킵 리스너 구현

package com.example.batchtutorial.config;

import com.example.batchtutorial.dto.EmployeeCsvDto;
import com.example.batchtutorial.entity.Employee;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.listener.SkipListenerSupport;
import org.springframework.stereotype.Component;

/**
 * 스킵된 아이템에 대한 로깅을 처리하는 리스너
 */
@Slf4j
@Component
public class EmployeeSkipListener extends SkipListenerSupport<EmployeeCsvDto, Employee> {
    
    @Override
    public void onSkipInRead(Throwable t) {
        log.error("❌ Skip occurred in Reader: {}", t.getMessage());
    }
    
    @Override
    public void onSkipInProcess(EmployeeCsvDto item, Throwable t) {
        log.error("❌ Skip occurred in Processor for item [{}]: {}", 
                item.getFirstName() + " " + item.getLastName(), t.getMessage());
    }
    
    @Override
    public void onSkipInWrite(Employee item, Throwable t) {
        log.error("❌ Skip occurred in Writer for item [{}]: {}", 
                item.getFullName(), t.getMessage());
    }
}

🎮 배치 실행 컨트롤러

컨트롤러 클래스 확장

package com.example.batchtutorial.controller;

import com.example.batchtutorial.entity.Employee;
import com.example.batchtutorial.repository.EmployeeRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;

@Slf4j
@RestController
@RequestMapping("/batch")
public class EmployeeBatchController {
    
    @Autowired
    private JobLauncher jobLauncher;
    
    @Autowired
    @Qualifier("importEmployeeJob")
    private Job importEmployeeJob;
    
    @Autowired
    @Qualifier("importEmployeeWithErrorHandlingJob")
    private Job importEmployeeWithErrorHandlingJob;
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    /**
     * 정상 직원 데이터 배치 실행
     */
    @PostMapping("/employees")
    public String runEmployeeBatch() {
        try {
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong("timestamp", System.currentTimeMillis())
                    .toJobParameters();
            
            var jobExecution = jobLauncher.run(importEmployeeJob, jobParameters);
            
            return String.format(
                "✅ 직원 데이터 배치가 실행되었습니다! " +
                "Job ID: %d, 상태: %s", 
                jobExecution.getId(), 
                jobExecution.getStatus()
            );
            
        } catch (Exception e) {
            log.error("직원 배치 실행 중 오류 발생", e);
            return "❌ 배치 실행 실패: " + e.getMessage();
        }
    }
    
    /**
     * 에러 처리 포함 배치 실행
     */
    @PostMapping("/employees-with-errors")
    public String runEmployeeBatchWithErrors() {
        try {
            JobParameters jobParameters = new JobParametersBuilder()
                    .addLong("timestamp", System.currentTimeMillis())
                    .toJobParameters();
            
            var jobExecution = jobLauncher.run(importEmployeeWithErrorHandlingJob, jobParameters);
            
            return String.format(
                "✅ 에러 처리 포함 직원 배치가 실행되었습니다! " +
                "Job ID: %d, 상태: %s", 
                jobExecution.getId(), 
                jobExecution.getStatus()
            );
            
        } catch (Exception e) {
            log.error("에러 처리 배치 실행 중 오류 발생", e);
            return "❌ 배치 실행 실패: " + e.getMessage();
        }
    }
    
    /**
     * 저장된 직원 데이터 조회
     */
    @GetMapping("/employees")
    public List<Employee> getAllEmployees() {
        return employeeRepository.findAll();
    }
    
    /**
     * 부서별 통계 조회
     */
    @GetMapping("/employees/statistics")
    public Map<String, Object> getEmployeeStatistics() {
        List<Employee> allEmployees = employeeRepository.findAll();
        
        long totalCount = allEmployees.size();
        BigDecimal totalSalary = allEmployees.stream()
                .map(Employee::getSalary)
                .reduce(BigDecimal.ZERO, BigDecimal::add);
        
        BigDecimal averageSalary = totalCount > 0 ? 
                totalSalary.divide(BigDecimal.valueOf(totalCount), 2, BigDecimal.ROUND_HALF_UP) : 
                BigDecimal.ZERO;
        
        // 부서별 직원 수
        Map<String, Long> departmentCounts = allEmployees.stream()
                .collect(java.util.stream.Collectors.groupingBy(
                    Employee::getDepartment,
                    java.util.stream.Collectors.counting()
                ));
        
        return Map.of(
            "totalEmployees", totalCount,
            "totalSalary", totalSalary,
            "averageSalary", averageSalary,
            "departmentCounts", departmentCounts
        );
    }
    
    /**
     * 데이터 초기화 (테스트용)
     */
    @DeleteMapping("/employees")
    public String clearEmployeeData() {
        long deletedCount = employeeRepository.count();
        employeeRepository.deleteAll();
        return String.format("✅ %d개의 직원 데이터가 삭제되었습니다.", deletedCount);
    }
}

🧪 테스트 실행

1. 애플리케이션 실행

./gradlew bootRun

2. 배치 실행 테스트

# 정상 데이터 배치 실행
curl -X POST http://localhost:8080/batch/employees

# 에러 데이터 포함 배치 실행
curl -X POST http://localhost:8080/batch/employees-with-errors

# 직원 데이터 조회
curl http://localhost:8080/batch/employees

# 통계 정보 조회
curl http://localhost:8080/batch/employees/statistics

# 데이터 초기화
curl -X DELETE http://localhost:8080/batch/employees

3. 실행 결과 확인

// GET /batch/employees/statistics 응답 예시
{
  "totalEmployees": 8,
  "totalSalary": 44500000,
  "averageSalary": 5562500.00,
  "departmentCounts": {
    "개발팀": 4,
    "마케팅팀": 2,
    "인사팀": 2
  }
}

✅ 학습 체크포인트

완료해야 할 작업들:

  • Employee 엔티티 및 Repository 구현
  • CSV 파일 ItemReader 설정
  • 데이터 검증 및 변환 ItemProcessor 구현
  • 데이터베이스 저장 ItemWriter 구현
  • 청크 기반 배치 Job 설정
  • 에러 처리 및 스킵 로직 구현
  • 배치 실행 및 결과 확인
  • 통계 정보 조회 API 테스트

확인 질문:

  1. 청크 크기를 3으로 설정한 이유는?
  2. ItemProcessor에서 null을 반환하면 어떻게 되나요?
  3. 스킵과 재시도의 차이점은 무엇인가요?
  4. 왜 DTO와 Entity를 분리했나요?

🎉 축하합니다! 실제 CSV 파일을 처리하는 완전한 배치 시스템을 구현했습니다. 다음 단계에서는 더 고급 기능들을 학습해보겠습니다!

profile
..

0개의 댓글