배치 프로그램 구현하기 - 2

KYOUNGBEOM·2024년 12월 12일

Spring Batch

목록 보기
2/9

이번에는 배치 프로그램 구현하기 - 1 에서 구현하였던
노가다(?) 배치 프로그램 구현 코드를 리팩토링 해보고자 한다.

우선 기존 코드를 들여다보자.

package com.practice.batch;

import com.practice.batch.batch.BatchStatus;
import com.practice.batch.batch.JobExecution;
import com.practice.batch.customer.Customer;
import com.practice.batch.customer.CustomerRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class DormantBatchJob {

    private final CustomerRepository customerRepository;
    private final EmailProvider emailProvider;

    public DormantBatchJob(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
        this.emailProvider = new EmailProvider.Fake();
    }

    public JobExecution execute() {

        final JobExecution jobExecution = new JobExecution();

        jobExecution.setStatus(BatchStatus.STARING);
        jobExecution.setStartTime(LocalDateTime.now());

        int pageNo = 0;

        try {
            while (true) {
                // 1. 유저를 조회한다.
                final PageRequest pageRequest = PageRequest.of(pageNo, 1, Sort.by("id").ascending());
                final Page<Customer> page = customerRepository.findAll(pageRequest);

                final Customer customer;
                if (page.isEmpty()) {
                    break;
                } else {
                    pageNo++;
                    customer = page.getContent().get(0);
                }

                // 2. 휴먼계정 대상을 추출 및 변환한다.
                final boolean isDormantTarget = LocalDateTime.now()
                        .minusDays(365)
                        .isAfter(customer.getLoginAt());

                if (isDormantTarget) {
                    customer.setStatus(Customer.Status.DORMANT);
                } else {
                    continue;
                }

                // 3. 휴먼계정으로 상태를 변환한다.
                customerRepository.save(customer);

                // 4. 메일을 보낸다.
                emailProvider.send(customer.getEmail(), "휴먼전환 이메일입니다.", "내용");
            }

            jobExecution.setStatus(BatchStatus.COMPLETED);

        } catch (Exception e) {
            jobExecution.setStatus(BatchStatus.FAILED);
        }

        jobExecution.setEndTime(LocalDateTime.now());
        
        emailProvider.send(
                "admin@practice.com",
                "배치 완료 알림",
                "DormantBatchJob 이 수행되었습니다. status : " + jobExecution.getStatus()
        );

        return jobExecution;
    }

}

execute 라는 메서드가 하나의 책임을 넘어
여러가지 작업에 대한 책임을 수행하고 있다.

이러한 코드는 이해하기가 어렵고 한 눈에 들어오지도 않고
유지보수하기도 힘든 코드라고 할 수 있겠다.

따라서 이러한 코드는 리팩토링이 필요한 코드라고 판단되어진다.


본격적으로 리팩토링을 하기에 앞서
앞선 게시물에서 작성하였던 테스트 코드를 기반으로 리팩토링을 해나갈 것이다.

기존 코드를 모두 리팩토링 한 후 테스트 코드를 실행시켰을 때
잘 작동된다면 성공적으로 리팩토링을 마쳤다고 할 수 있겠다.

여담이지만 리팩토링을 하는데 있어서 테스코드를 작성하는게
추가적인 비용이 들어가기는하지만 장기적인 관점에서는
여러모로 많은 도움이 되는건 확실한 것 같다.

package com.practice.batch;

import com.practice.batch.batch.BatchStatus;
import com.practice.batch.batch.Job;
import com.practice.batch.batch.JobExecution;
import com.practice.batch.customer.Customer;
import com.practice.batch.customer.CustomerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class DormantBatchJobTest {

    @Autowired
    private CustomerRepository customerRepository;

    @Autowired
    private Job dormantBatchJob;

    @BeforeEach
    public void setUp() {
        customerRepository.deleteAll();
    }

    @Test
    @DisplayName("로그인 시간이 일년을 경과한 고객이 세명이고, 일년 이내에 로그인한 고객이 다섯명이면 세명의 고객이 휴면전환대상이다.")
    void test1() {

        // given
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(3);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("고객이 열명이 있지만 모두 다 휴먼전환대상이면(로그인 한지 1년 경과한 사람) 휴면전황대상은 10명이다.")
    void test2() {

        // given
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(10);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("고객이 없는 경우에도 배치는 정상동작해야한다.")
    void Test() {

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(0);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("배치가 실패하면 BatchStatus는 FAILED를 반환해야 한다")
    void test4() {
        // given
        final Job job = new Job(null, null);

        // when
        final JobExecution result = job.execute();

        // then
        assertThat(result.getStatus()).isEqualTo(BatchStatus.FAILED);
    }

    private void saveCustomer(long loginMinusDays) {
        final String uuid = UUID.randomUUID().toString();
        final Customer testCustomer = new Customer(uuid, uuid + "@practice.com");

        testCustomer.setLoginAt(LocalDateTime.now().minusDays(loginMinusDays));
        customerRepository.save(testCustomer);
    }

}

최종적인 결과물은 아래와 같다.

package com.practice.batch.batch;

import lombok.Builder;

import java.time.LocalDateTime;

public class Job {

    private final Tasklet tasklet;
    private final JobExecutionListener jobExecutionListener;

    @Builder
    public Job(
            ItemReader<?> itemReader,
            ItemProcessor<?, ?> itemProcessor,
            ItemWriter<?> itemWriter,
            JobExecutionListener jobExecutionListener
    ) {
        this(new SimpleTasklet(itemReader, itemProcessor, itemWriter), jobExecutionListener);
    }

    public Job(Tasklet tasklet, JobExecutionListener jobExecutionListener) {
        this.tasklet = tasklet;
        if (jobExecutionListener == null) {
            this.jobExecutionListener = new JobExecutionListener() {
                @Override
                public void beforeJob(JobExecution jobExecution) {}

                @Override
                public void AfterJob(JobExecution jobExecution) {}
            };
        } else {
            this.jobExecutionListener = jobExecutionListener;
        }
    }

    public JobExecution execute() {
        final JobExecution jobExecution = new JobExecution();

        jobExecutionListener.beforeJob(jobExecution);

        jobExecution.setStatus(BatchStatus.STARING);
        jobExecution.setStartTime(LocalDateTime.now());

        try {
            tasklet.execute();
            jobExecution.setStatus(BatchStatus.COMPLETED);
        } catch (Exception e) {
            jobExecution.setStatus(BatchStatus.FAILED);
        }

        jobExecution.setEndTime(LocalDateTime.now());

        jobExecutionListener.AfterJob(jobExecution);

        return jobExecution;
    }

}

무엇이 달라졌냐 하면은 JobExecutionListener와 Tasklet 클래스를 별도로 만들어
기존 DormantBatchJob 클래스가 책임지고 있던 역할들을 분리하여주었다.

JobExecutionListener는 내부적으로 beforeJob / AfterJob 메서드를 구현하여
배치 작업이 일어나기 전과 후로 처리해주어야 할 일들을 처리하도록 하였다.

Tasklet은 기존 While문 내의 비즈니스 로직을 처리하도록 하였다.

아래에서 각각의 클래스 코드를 보면서 좀 더 자세히 알아보자.


JobExecutionListener

먼저 JobExecutionListener 이다.

추후의 확장성을 위하여 JobExecutionListener는 인터페이스로 만들었고,
실질적으로 작업을 처리하는 클래스는 별도의 클래스를 만들어
JobExecutionListener 인터페이스를 구현하도록 설계하였다.

코드를 들여다보면 앞서 설명한 것과 같이
배치 작업 전/후로 처리해야 할 일들에 대한 책임을 지고있다.

package com.practice.batch.application;

import com.practice.batch.EmailProvider;
import com.practice.batch.batch.JobExecution;
import com.practice.batch.batch.JobExecutionListener;
import org.springframework.stereotype.Component;

@Component
public class DormantBatchJobExecutionListener implements JobExecutionListener {

    private EmailProvider emailProvider;

    public DormantBatchJobExecutionListener() {
        this.emailProvider = new EmailProvider.Fake();
    }

    @Override
    public void beforeJob(JobExecution jobExecution) {

    }

    @Override
    public void AfterJob(JobExecution jobExecution) {
        emailProvider.send(
                "admin@practice.com",
                "배치 완료 알림",
                "DormantBatchJob 이 수행되었습니다. status : " + jobExecution.getStatus()
        );
    }

}

Tasklet

다음으로는 Tasklet 이다.

Tasklet 또한 추후의 확장성을 위해 인터페이스로 만들었고,
실질적으로 작업을 처리하는 클래스는 별도의 클래스를 만들어
Tasklet 인터페이스를 구현하도록 설계하였다.

Tasklet의 경우에는 또 다시
ItemReader와 ItemProcessor, ItemWriter 로 책임을 분리해주었다.

package com.practice.batch.batch;

import org.springframework.stereotype.Component;

@Component
public class SimpleTasklet<I, O> implements Tasklet {

    private final ItemReader<I> itemReader;
    private final ItemProcessor<I, O> itemProcessor;
    private final ItemWriter<O> itemWriter;

    public SimpleTasklet(ItemReader<I> itemReader, ItemProcessor<I, O> itemProcessor, ItemWriter<O> itemWriter) {
        this.itemReader = itemReader;
        this.itemProcessor = itemProcessor;
        this.itemWriter = itemWriter;
    }

    @Override
    public void execute() {
        while (true) {
            // read
            final I read = itemReader.read();
            if (read == null) break;

            // process
            final O process = itemProcessor.process(read);
            if (process == null) continue;

            // write
            itemWriter.write(process);
        }
    }

}

ItemReader

먼저 ItemReader 이다.

인터페이스로 설계하였고, 이를 구현하는 클래스에서는
PageRequest를 기반으로 DB에서유저를 조회하는 역할을 한다.

package com.practice.batch.application;

import com.practice.batch.batch.ItemReader;
import com.practice.batch.customer.Customer;
import com.practice.batch.customer.CustomerRepository;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;

@Component
public class DormantBatchItemReader implements ItemReader<Customer> {

    private final CustomerRepository customerRepository;

    private int pageNo = 0;

    public DormantBatchItemReader(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
    }

    // 유저를 조회한다.
    @Override
    public Customer read() {
        final PageRequest pageRequest = PageRequest.of(pageNo, 1, Sort.by("id").ascending());
        final Page<Customer> page = customerRepository.findAll(pageRequest);

        if (page.isEmpty()) {
            pageNo = 0;
            return null;
        } else {
            pageNo++;
            return page.getContent().get(0);
        }
    }

}

ItemProcessor

다음으로 ItemProcessor 이다.

마찬가지로 인터페이스로 설계하였고,
이를 구현하는 클래스에서는 휴면계정 전환 여부를 검증하고,
참일시 고객 계정의 상태를 휴면상태로 전환하는 역할을 한다.

package com.practice.batch.application;

import com.practice.batch.batch.ItemProcessor;
import com.practice.batch.customer.Customer;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
public class DormantBatchItemProcessor implements ItemProcessor<Customer, Customer> {

    // 휴먼계정 대상을 추출 및 변환한다.
    @Override
    public Customer process(Customer customer) {
        final boolean isDormantTarget = LocalDateTime.now()
                .minusDays(365)
                .isAfter(customer.getLoginAt());

        if (isDormantTarget) {
            customer.setStatus(Customer.Status.DORMANT);
            return customer;
        } else {
            return null;
        }
    }

}

ItemWriter

마지막으로 ItemWriter 이다.

인터페이스로 설계하였고,
이를 구현하는 클래스에서는 휴면상태로 전환된 고객의 계정을
실제 DB에 저장하고, 휴면전환 안내 이메일을 보내는 작업을 처리한다.

package com.practice.batch.application;

import com.practice.batch.EmailProvider;
import com.practice.batch.batch.ItemWriter;
import com.practice.batch.customer.Customer;
import com.practice.batch.customer.CustomerRepository;
import org.springframework.stereotype.Component;

@Component
public class DormantBatchItemWriter implements ItemWriter<Customer> {

    private final CustomerRepository customerRepository;
    private final EmailProvider emailProvider;

    public DormantBatchItemWriter(CustomerRepository customerRepository) {
        this.customerRepository = customerRepository;
        this.emailProvider = new EmailProvider.Fake();
    }

    // 휴먼계정으로 상태를 변환한다.
    // 메일을 보낸다.
    @Override
    public void write(Customer customer) {
        customerRepository.save(customer);
        emailProvider.send(customer.getEmail(), "휴먼전환 이메일입니다.", "내용");
    }

}

여기까지 기존 코드에 대한 리팩토링을 진행하여보았다.

하지만 글을 끝내기에 앞서 마지막으로 체크해주어야 할
최종 관문이 남지 않았는가?

아래 테스트 코드를 다시 실행시켜보자.

package com.practice.batch;

import com.practice.batch.batch.BatchStatus;
import com.practice.batch.batch.Job;
import com.practice.batch.batch.JobExecution;
import com.practice.batch.customer.Customer;
import com.practice.batch.customer.CustomerRepository;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;

import java.time.LocalDateTime;
import java.util.UUID;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
class DormantBatchJobTest {

    @Autowired
    private CustomerRepository customerRepository;

    @Autowired
    private Job dormantBatchJob;

    @BeforeEach
    public void setUp() {
        customerRepository.deleteAll();
    }

    @Test
    @DisplayName("로그인 시간이 일년을 경과한 고객이 세명이고, 일년 이내에 로그인한 고객이 다섯명이면 세명의 고객이 휴면전환대상이다.")
    void test1() {

        // given
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);
        saveCustomer(364);

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(3);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("고객이 열명이 있지만 모두 다 휴먼전환대상이면(로그인 한지 1년 경과한 사람) 휴면전황대상은 10명이다.")
    void test2() {

        // given
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);
        saveCustomer(366);

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(10);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("고객이 없는 경우에도 배치는 정상동작해야한다.")
    void Test() {

        // when
        final JobExecution result = dormantBatchJob.execute();

        // then
        final long dormantCount = customerRepository.findAll()
                .stream()
                .filter(it -> it.getStatus() == Customer.Status.DORMANT)
                .count();

        assertThat(dormantCount).isEqualTo(0);
        assertThat(result.getStatus()).isEqualTo(BatchStatus.COMPLETED);

    }

    @Test
    @DisplayName("배치가 실패하면 BatchStatus는 FAILED를 반환해야 한다")
    void test4() {
        // given
        final Job job = new Job(null, null);

        // when
        final JobExecution result = job.execute();

        // then
        assertThat(result.getStatus()).isEqualTo(BatchStatus.FAILED);
    }

    private void saveCustomer(long loginMinusDays) {
        final String uuid = UUID.randomUUID().toString();
        final Customer testCustomer = new Customer(uuid, uuid + "@practice.com");

        testCustomer.setLoginAt(LocalDateTime.now().minusDays(loginMinusDays));
        customerRepository.save(testCustomer);
    }

}

아주 잘 실행되는 모습을 볼 수 있다!


그리고 여기까지 글을 읽으신 분들이라면 한 가지 의문점이 들 수도 있을텐데

테스트 코드에서 Job 클래스에 대한 의존성 주입은
별도의 configuration 클래스를 만들어 주입할 수 있도록 처리하였다.

package com.practice.batch.application;

import com.practice.batch.batch.Job;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class DormantBatchConfiguration {

    @Bean
    public Job dormantBatchJob(
            DormantBatchItemReader dormantBatchItemReader,
            DormantBatchItemProcessor dormantBatchItemProcessor,
            DormantBatchItemWriter dormantBatchItemWriter,
            DormantBatchJobExecutionListener dormantBatchJobExecutionListener
    ) {
        return Job.builder()
                .itemReader(dormantBatchItemReader)
                .itemProcessor(dormantBatchItemProcessor)
                .itemWriter(dormantBatchItemWriter)
                .jobExecutionListener(dormantBatchJobExecutionListener)
                .build();
    }

}
profile
나의 개발 성장일지

0개의 댓글