이슈
Web 에서 대용량 액셀을 생성하는 로직이 있었는데 응답 시간이 지연이 되어 에러가 발생하는 이슈에 부딫쳤습니다.
대안으로 java daemon 을 활용하라는 이야기가 있었지만 (?)
사실 써보고 싶기도 했던 Spring Batch 에 도전해보게 되었습니다 ! 하하하 ...
Spring Batch 를 처음 사용해보니 Spring Batch 에 대해 뭔지 알아봐야 했습니다.
Spring Batch는 대량의 데이터 처리를 위한 경량화된 프레임워크로, 반복적인 작업을 수행하는 일괄 처리(Batch Processing) 작업을 효율적으로 처리할 수 있는 기능을 제공합니다.
제가 하려고 하는 업무에 딱 ! 이라는 생각이 들어서 Batch 로 프로세스 개발을 시작해보겠습니다.
( 자기 합리화가 시작 .. ? 되었습니다 ㅎㅎ )
의존성 추가
dependencies {
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-batch', version: '3.1.4'
implementation group: 'mysql', name: 'mysql-connector-java', version: '8.0.16'
implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.1.2'
implementation group: 'javax.persistence', name: 'javax.persistence-api', version: '2.2'
implementation group: 'org.apache.poi', name: 'poi-ooxml', version: '3.9'
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.batch:spring-batch-test'
}
목표 : 프로세스 로직
Spring Batch 를 사용해 아래와 같은 로직을 구현하려 합니다.
제가 구현하고 싶은 최종 결과물은 하나의 액셀 파일에 Step 별로 하나의 시트씩 데이터를 채워주길 바라는 것입니다.
DataMailSendConfig
- commandLineRunner 은 Batch 의 시작점입니다.
- 참고로 보통 Batch 를 시작할지 말지에 대한 분기 처리는 Decide 를 사용하는데 Decide 사용법을 아신 분은 계시겠지만 ? 여러번 테스트 끝에 제가 하려는 로직으로 처리가 되지 않아 보류했습니다. ;;
- 일단 Batch 의 시작점이 commandLineRunner 이기 때문에 commandLineRunner 에서 유효성 검사 로직을 추가했습니다.
- Job 에서 사전을 생성하는 Step 들을 차례대로 호출중입니다.
- korDicStep, engDicStep, chiDicStep 순서대로 액셀 파일에 데이터를 채우고
- sendmail 는 마지막으로 생성된 액셀 파일을 메일로 전송 될 예정입니다.
@Configuration
@RequiredArgsConstructor
@EnableBatchProcessing
public class DataMailSendConfig extends DefaultBatchConfiguration {
private final DataMailSendStep step;
private final JdbcTemplate jdbcTemplate;
@Bean
public CommandLineRunner commandLineRunner(JobLauncher jobLauncher, Job job) {
return args -> {
String selectSql = "SELECT COUNT(*) FROM DATA_DOWNLOAD_INFO WHERE status = '대기'";
int emailAddresses = jdbcTemplate.queryForObject(selectSql, Integer.class);
/** 사전 다운로드 신청한 이메일이 있다면 실행 **/
if (emailAddresses > 0) {
JobParametersBuilder paramsBuilder = new JobParametersBuilder();
paramsBuilder.addString("currentTime", String.valueOf(new Date()));
JobParameters parameters = paramsBuilder.toJobParameters();
JobExecution jobExecution = jobLauncher.run(job, parameters);
System.out.println("Job Status : " + jobExecution.getStatus());
} else {
System.out.println("Job was not launched due to no email addresses.");
}
};
}
@Bean
public Job job(JobRepository jobRepository, PlatformTransactionManager transactionManager){
return new JobBuilder("Dicjob", jobRepository)
.incrementer(new RunIdIncrementer())
.start(step.korDicStep(jobRepository, transactionManager))
.next(step.engDicStep(jobRepository, transactionManager))
.next(step.chiDicStep(jobRepository, transactionManager))
.next(step.sendmail(jobRepository, transactionManager))
.build();
}
}
데이터베이스에 접근해서 데이터를 읽어오는 로직
사실 연관관계가 어마무시하게 엮인게 아닌 단순 select 이기 때문에 아래와 같이 간단하게 구현할 수 있었어요.
@RequiredArgsConstructor
@Component
public class DataReader {
private final DataSource dataSource;
public <T> JdbcCursorItemReader<T> process(String sqlQuery, Class<T> type) {
JdbcCursorItemReader<T> reader = new JdbcCursorItemReader<>();
reader.setDataSource(dataSource);
reader.setSql(sqlQuery);
reader.setRowMapper(new BeanPropertyRowMapper<>(type));
return reader;
}
}
dto 는 아래와 같이 매우 간단하게 ( 다행입니다 .. )
@Data
public class V2vDictionary {
Long seq;
String word;
String date;
}
대략 Step 의 Chunk 를 활용해 시트에 데이터를 쓰는 방식은 이러해요.
하나의 액셀 파일을 생성한 후
모든 job 에서 접근가능해야 하기 때문에 Excel 을 관리하는 Componet 를 생성했습니다.
이제 ExcelFileManager 는 woorkbook 을 초기화 시점에 생성 후 Step 에서 나눠서 쓸 수 있도록 할꺼에요!
@Component
public class ExcelFileManager {
private XSSFWorkbook workbook;
@PostConstruct
public void init() {
this.workbook = new XSSFWorkbook();
}
public XSSFWorkbook getWorkbook() {
return workbook;
}
}
@StepScope
@Component
public class CreateExcelSheet implements ItemWriter<V2vDictionary>, StepExecutionListener {
@Autowired
private ExcelFileManager excelFileManager;
private Sheet sheet;
private int rowIndex = 0;
@Override
public void beforeStep(StepExecution stepExecution) {
String sheetName = stepExecution.getExecutionContext().getString("sheetName");
this.sheet = excelFileManager.getWorkbook().createSheet(sheetName);
createHeaderRow(sheet);
}
private void createHeaderRow(Sheet sheet) {
Row header = sheet.createRow(rowIndex++);
header.createCell(0).setCellValue("일련번호");
header.createCell(1).setCellValue("단어");
header.createCell(2).setCellValue("날짜");
}
@Override
public void write(Chunk<? extends V2vDictionary> chunk) {
for (V2vDictionary vo : chunk.getItems()) {
Row row = sheet.createRow(rowIndex++);
row.createCell(0).setCellValue(vo.getSeq());
row.createCell(1).setCellValue(vo.getWord());
row.createCell(2).setCellValue(vo.getDate().toString());
}
}
}
드디어 제일 중요한 Step 을 구현해보겠습니다.
구현하기 전에 Chunk 과 tasklet 의 차이점을 살펴보자면
Step에는 Tasklet, Chunk 기반으로 2가지가 습니다
- Chunk
- 일반적으로 일련의 배치 작업을 수행합니다 (읽기, 처리, 쓰기).
- 복잡한 로직 및 대량 데이터 처리에 적합합니다.
- 여러 개의 Step을 조합하여 복잡한 배치 Job을 정의할 수 있습니다.
- Tasklet
- 간단하고 단일한 작업에 적합합니다.
- 데이터 처리가 아닌, 다른 로직(예: 리소스 정리, 알림 전송 등)에 사용됩니다.
- Step 내에서 단일 메서드(execute)를 통해 로직을 정의합니다.
Chunk 과 Tasklet 를 구분해서 사용하자.
대용량 데이터 처리
- 사전을 생성하는 Step 에서는 대용량 데이터를 효과적으로 처리하기 위해 chunk 를 사용해서 1000 개의 데이터씩 읽어와서 구현했습니다.
단일 처리인 메일 발송
- 메일 발송은 사실 간단한 단일 작업이기 때문에 Tasklet 이 적합하다고 판단하였습니다.
@RequiredArgsConstructor
@Component
public class DataMailSendStep {
private final CreateExcelSheet write;
private final DataReader reader;
private final MailTasklet mailTasklet;
/**
* 한국어 사전 생성
*/
@Bean
public Step korDDicStep(JobRepository jobRepository, PlatformTransactionManager transactionManager){
String SQL = "SELECT seq, word, date FROM 한국어사전;
return new StepBuilder("kor default step",jobRepository)
.<V2vDictionary, V2vDictionary>chunk(1000, transactionManager)
.reader(reader.process(SQL, V2vDictionary.class))
.writer(write)
.transactionManager(transactionManager)
.listener(new StepExecutionListener() {
@Override
public void beforeStep(StepExecution execution) {
execution.getExecutionContext().put("sheetName", "한국어 사전");
}
})
.build();
}
/**
* 영어 사전 생성
*/
@Bean
public Step engDicStep(JobRepository jobRepository, PlatformTransactionManager transactionManager){
String SQL = "SELECT seq, word, date FROM 영어사전;
return new StepBuilder("eng default step",jobRepository)
.<V2vDictionary, V2vDictionary>chunk(1000, transactionManager)
.reader(reader.process(SQL, V2vDictionary.class))
.writer(write)
.transactionManager(transactionManager)
.listener(new StepExecutionListener() {
@Override
public void beforeStep(StepExecution execution) {
execution.getExecutionContext().put("sheetName", "영어 사전");
}
})
.build();
}
/**
* 중국어 키워드 사전 생성
*/
@Bean
public Step chiDicStep(JobRepository jobRepository, PlatformTransactionManager transactionManager){
String SQL = "SELECT seq, word, date FROM 중국어사전;
return new StepBuilder("chi step",jobRepository)
.<V2vDictionary, V2vDictionary>chunk(1000, transactionManager)
.reader(reader.process(SQL, V2vDictionary.class))
.writer(write)
.transactionManager(transactionManager)
.listener(new StepExecutionListener() {
@Override
public void beforeStep(StepExecution execution) {
execution.getExecutionContext().put("sheetName", "중국어 사전");
}
})
.build();
}
@Bean
public Step sendmail(JobRepository jobRepository, PlatformTransactionManager transactionManager) {
return new StepBuilder("send Mail Step", jobRepository)
.tasklet(mailTasklet, transactionManager)
.build();
}
}
앗 ! 그리고 코드에서 beforeStep 이 중요한데 Step 에서는 StepExecutionListener 를 제공합니다.
- beforeStep(StepExecution stepExecution): Step이 실행되기 전에 호출됩니다.
- afterStep(StepExecution stepExecution): Step이 완료된 후에 호출됩니다.
예를 들어 10,000 건의 데이터를 chunk 를 통해 1,000 건씩 조회한다고 가정했을 때 write 는 10 번 호출이 됩니다.
그럼 최악의 상황이지만 1,000 건마다 액셀이 생성되기 때문에 ;;;;;
아무튼 저는 하나의 Step 에서는 하나의 Sheet 만 구현하고 싶기 때문에 beforeStep 을 활용하였습니다.
메일 발송 Tasklet
Tasklet 을 구현하였습니다.
사실 Batch 를 공부하는 과정에서 메일 발송 로직이 중요하지는 않지만 (?) ㅎㅎ 그냥 첨부 합니다 (?) ㅋㅋㅋㅋ
( 인터넷에 돌아다니는 코드는 대부분 액셀이 깨져요 하하 ;; )
@Component
public class MailTasklet implements Tasklet {
@Autowired
private ExcelFileManager excelFileManager;
@Autowired
private JdbcTemplate jdbcTemplate;
private Properties props;
private Session session;
private String userName, passWord;
public MailTasklet(){
props = new Properties();
/** 회사 계정 이름 **/
this.userName = "test@test.co.kr";
/** 회사 계정 비밀번호 **/
this.passWord = "test";
props.put("mail.smtp.host", "smtp.gmail.com");
/** javax mail 을 디버깅 모드로 설정 **/
props.put("mail.debug","ture");
/** smtp 인증 을 설정 **/
props.put("mail.smtp.auth", "true");
/** gmail smtp 서비스 포트 설정 **/
props.put("mail.smtp.port", "465");
/** 필수 X **/
props.put("type", "javax.mail.Session");
/** 필수 X **/
props.put("auth", "Application");
/** gmail Secure Sockets Layer (SSL) 인증용설정 (필수) **/
props.put("mail.smtp.socketFactory.class","javax.net.ssl.SSLSocketFactory");
props.put("mail.smtp.ssl.enable","true");
props.put("mail.smtp.ssl.trust", "smtp.gmail.com");
/** 메일 보내는 계정 및 호스트 세팅 **/
session = Session.getInstance(props, new Authenticator() {
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication(userName, passWord);
}
});
}
@Override
public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) {
try {
/** 수신자 리스트 **/
String selectSql = "SELECT email FROM DATA_DOWNLOAD_INFO WHERE status = 0";
List<String> emailAddresses = jdbcTemplate.queryForList(selectSql, String.class);
/** Excel File**/
XSSFWorkbook workbook = excelFileManager.getWorkbook();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
workbook.write(baos);
baos.flush();
byte[] excelBytes = baos.toByteArray();
baos.close();
LocalDate d = LocalDate.now();
String subject = "통합 사전";
String body = "<h1>사전</h1><br><h4>요청하신 파일 첨부드립니다.</h4>";
emailAddresses.forEach(to -> {
/** 메일 발송 **/
boolean result = sendMessage(to, null, subject, body, true, excelBytes, "V2V사전_" + d.format(DateTimeFormatter.ofPattern("yyyy.MM.dd")) +".xlsx");
/** 전송 결과 **/
String updateSql = "UPDATE DATA_DOWNLOAD_INFO SET status = ?, send_date = NOW() WHERE email = ? AND status = 0";
int status = result ? 1 : 2;
jdbcTemplate.update(updateSql, status, to);
});
return RepeatStatus.FINISHED;
} catch (Exception e) {
throw new RuntimeException(e);
}
}
private boolean sendMessage( String toaddr, String[] ccAddr, String subject, String body, boolean isHtml, byte[] attachmentBytes, String attachmentName ) {
try{
MimeMessage msg = new MimeMessage(session);
InternetAddress from = new InternetAddress(this.userName, MimeUtility.encodeText(this.userName, "UTF-8", "B"));
msg.setFrom(from);
InternetAddress[] to = InternetAddress.parse(toaddr);
msg.addRecipients(Message.RecipientType.TO, to);
if (ccAddr != null) {
InternetAddress[] cc = Arrays.stream(ccAddr).map(x -> {
try {
return new InternetAddress(x);
} catch (AddressException e) {
throw new RuntimeException(e);
}
}).toArray(InternetAddress[]::new);
msg.addRecipients(Message.RecipientType.CC, cc);
}
msg.setSubject(subject, "utf-8");
MimeBodyPart attachmentPart = new MimeBodyPart();
attachmentPart.setFileName(MimeUtility.encodeText(attachmentName, "euc-kr", "B"));
attachmentPart.setContent(attachmentBytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
MimeBodyPart textPart = new MimeBodyPart();
if (isHtml)
textPart.setContent(body, "text/html;charset=utf-8");
else
textPart.setContent(body, "text/plain;charset=utf-8");
Multipart mp = new MimeMultipart();
mp.addBodyPart(attachmentPart);
mp.addBodyPart(textPart);
msg.setContent(mp);
Transport.send(msg);
return true;
} catch (Exception e) {
return false;
}
}
}
참고