본문 바로가기

개발중/Spring Batch

Spring Batch 로 대용량 일괄처리하기. (대용량 데이터 조회 > 액셀 생성 > 메일 발송)

728x90
반응형

 

이슈

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;
        }
    }
}

 

 

 

 

참고

 

4. Spring Batch 가이드 - Spring Batch Job Flow

자 이번 시간부터 본격적으로 실전에서 사용할 수 있는 Spring Batch 내용들을 배워보겠습니다. 작업한 모든 코드는 Github에 있으니 참고하시면 됩니다. 앞서 Spring Batch의 Job을 구성하는데는 Step이

jojoldu.tistory.com

 

 

 

728x90
반응형