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'
}
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
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
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;
}
}
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; // 문자열로 받아서 날짜 변환
}
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();
}
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();
}
}
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);
}
}
}
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);
}
}
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");
}
}
};
}
}
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);
}
}
./gradlew bootRun
# 정상 데이터 배치 실행
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
// GET /batch/employees/statistics 응답 예시
{
"totalEmployees": 8,
"totalSalary": 44500000,
"averageSalary": 5562500.00,
"departmentCounts": {
"개발팀": 4,
"마케팅팀": 2,
"인사팀": 2
}
}
🎉 축하합니다! 실제 CSV 파일을 처리하는 완전한 배치 시스템을 구현했습니다. 다음 단계에서는 더 고급 기능들을 학습해보겠습니다!