# Spring Batch 직접 구현하기 (1)

{% hint style="info" %}
Spring Batch 에서 Job 과 Tasklet (Reader/Processor/Writer) 을 나누는 이유
{% endhint %}

## 유스케이스 : 휴면 계정 상태 변경 배치

<figure><img src="/files/5rcob3YOZichh6NVMNFZ" alt="" width="563"><figcaption></figcaption></figure>

## 리팩토링 목표

<figure><img src="/files/wUnvb1Ibz4FGcUd1KVbA" alt=""><figcaption></figcaption></figure>

## 리팩토링 전 코드의 문제점

리팩토링 전 코드의 문제점은 크게 2개의 관심사를 하나의 메서드에서 처리한다는 점이다.

1. (초록색) 배치의 정보를 다루는 로직
2. (빨간색) 배치를 수행하는 로직

   또한, 배치를 수행하는 로직 안에서도 read / process / wirte 가 섞여 있다.

<figure><img src="/files/jkAIiFYTX361nbMW27Ey" alt=""><figcaption></figcaption></figure>

### 뒤섞인 관심사 문제

이렇게 하나의 메서드에서 여러 역할이 섞여 있는 것이 어떤 문제가 있을까?\
이후, 배치 작업이 추가 된다면 배치의 정보를 다루는 기능은 계속 중복처리가 발생할 것이다.

* 확장하기 어려운 구조이다.
  * 확장시 배치의 정보를 다루는 기능은 중복 처리가 될 것이다.
  * 수정하기 어렵다. 중복된 로직은 변경이 발생했을 때 수정하기가 까다롭다.
* 이해하기 어렵다.
  * 관심사가 여러개 라는 것에서 이미 가독성이 좋지 못하다.

### 관심사를 분리해보자

<figure><img src="/files/VQIsOh7kBkO6yOAOuiaZ" alt="" width="394"><figcaption></figcaption></figure>

1. Job (자주 변하지 않는다, 부가적인 기능이다, 공통적인 기능이다.)

   배치 실행 시간 / 배치 상태

   운영을 위한 요소
2. Business (요구사항에 따라 자주 변화한다.)
   1. read
   2. process
   3. write
   4. 전/후 처리기

## Before

```java
@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.STARTING);
        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 = LocalDate.now()
                        .minusDays(365)
                        .isAfter(customer.getLoginAt().toLocalDate());

                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@fastcampus.com",
                "배치 완료 알림",
                "DormantBatchJob이 수행되었습니다. status :" + jobExecution.getStatus()
        );

        return jobExecution;

    }

}
```

## After

### Step 1. 비즈니스 로직, 배치 이벤트 처리를 외부에서 주입 받도록 변경한다.

#### 배치 bean 설정

```java
@Configuration
public class DormantBatchConfiguration {
    
    @Bean
    public Job dormantBatchJob (
            DormantBatchTasklet dormantBatchTasklet,
            DormantBatchJobExecutionListener dormantBatchJobExecutionListener
    ) {
        return new Job(dormantBatchTasklet, dormantBatchJobExecutionListener);
    }
}
```

#### Job (DormantBatchJob 리팩토링)

* 템플릿 메서드 패턴이 적용된 Job 클래스
* 인터페이스로 정의하고 구체적인 구현체는 외부에서 주입 받는다.

```java
public class Job {

    private final Tasklet tasklet;
    private final JobExecutionListener jobExecutionListener;
    
    public Job(Tasklet tasklet, JobExecutionListener jobExecutionListener) {
        this(tasklet, jobExecutionListener);
    }
    
    public JobExecution execute() {

        final JobExecution jobExecution = new JobExecution();
        jobExecution.setStatus(BatchStatus.STARTING);
        jobExecution.setStartTime(LocalDateTime.now());

        jobExecutionListener.beforeJob(jobExecution);
        
        try {
            tasklet.execute();
            jobExecution.setStatus(BatchStatus.COMPLETED);

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

        jobExecution.setEndTime(LocalDateTime.now());

        jobExecutionListener.afterJob(jobExecution);

        return jobExecution;
    }
}
```

```java
public interface Tasklet {
    void execute();
}
```

```java
public interface JobExecutionListener {
    void beforeJob(JobExecution jobExecution);
    void afterJob(JobExecution jobExecution);
}
```

#### 비즈니스 로직

```java
@Component
public class DormantBatchTasklet implements Tasklet {

    private final CustomerRepository customerRepository;
    private final EmailProvider emailProvider;

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

    @Override
    public void execute() {
        int pageNo = 0;

        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 = LocalDate.now()
                .minusDays(365)
                .isAfter(customer.getLoginAt().toLocalDate());

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

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

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

        }
    }
}
```

* 휴면 회원 배치 비즈니스 (`Tasklet`)
  * 로그인을 한지 365일이 난 계정을 조회한 후, 휴면 상태로 변경한다.
  * 사용자에게 email 알림을 발송한다.

#### 배치 이벤트 처리

```java
@Component
public class DormantBatchJobExecutionListener implements JobExecutionListener {

    private final EmailProvider emailProvider;

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

    @Override
    public void beforeJob(JobExecution jobExecution) {
        // no-op
    }

    @Override
    public void afterJob(JobExecution jobExecution) {
        // 비즈니스 로직
        emailProvider.send(
                "admin@fastcampus.com",
                "배치 완료 알림",
                "DormantBatchJob이 수행되었습니다. status :" + jobExecution.getStatus()
        );
    }
}
```

* 배치 이벤트 처리 (`JobExecutionListener`)
  * 배치가 완료되면 개발자에게 email 알림을 발송한다.

### Step 2. 비즈니스도 3가지 단계로 분리할 수 있다.

{% hint style="info" %}
관심사의 분리의 원리가 계속 적용되고 있다.\
관심사를 분리하면 각 관심사에 맞는 최적화를 수행할 수 있기 때문에 성능 관점에서도 유리하다.
{% endhint %}

{% hint style="info" %}
사실 예제와 같은 간단한 처리라면 Tasklet 에서 모두 처리하는 [Step 1 ](#step-1.-.)의 과정만으로 충분할 수 있다. \
나중에 점점 더 복잡해지면 read/process/write 의 분리를 고려하자.
{% endhint %}

```java
@Configuration
public class DormantBatchConfiguration {

    @Bean
    public Job dormantBatchJob(
            DormantBatchItemReader itemReader,
            DormantBatchItemProcessor itemProcessor,
            DormantBatchItemWriter itemWriter,
            DormantBatchJobExecutionListener listener
    ) {
        return Job.builder()
                .itemReader(itemReader)
                .itemProcessor(itemProcessor)
                .itemWriter(itemWriter)
                .jobExecutionListener(listener)
                .build();
    }
}
```

#### Tasklet 분리하기

```java
@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) {
            final I read = itemReader.read();
            if(read == null) break;

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

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

#### Read 관심사

```java
public interface ItemReader <I>{
    I read();
}
```

```java
@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);
        }
    }
}
```

#### Process 관심사

```java
public interface ItemProcessor<I, O> {
    O process(I item);
}
```

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

    @Override
    public Customer process(Customer item) {
        final boolean isDormantTarget = LocalDate.now()
                .minusDays(365)
                .isAfter(item.getLoginAt().toLocalDate());

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

#### Write 관심사

```java
public interface ItemWriter <O>{
    void write(O item);
}
```

```java
@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 item) {
        customerRepository.save(item);
        emailProvider.send(item.getEmail(), "휴먼전환 안내메일입니다.", "내용");
    }
}
```

{% hint style="info" %}
데이터베이스 save 와 email send 를 분리할지도 고민 포인트가 될 수 있다.
{% endhint %}

## 결론

* SRP 단일 책임의 원칙
  * 클래스나 모듈을 변경할 이유는 단 하나, 하나뿐이어야 한다. -로버트 마틴
  * 리팩토링으로 배치 프로그램은 단일 책임의 원칙을 준수하는 구조를 가지게 되었다.
    * 읽기가 변경되면 `ItemReader` 를 변경하면 된다.
    * 처리가 변경되면 `ItemProcessor` 를 변경하면 된다.
    * 쓰기가 변경되면 `ItemWriter` 를 변경하면 된다.
* OCP 개방 폐쇄 원칙

  * 소프트웨어 요소는 확장에는 열려 있으나, 변경에는 닫혀있어야 한다. -로버트 마틴
  * 인터페이스 구성요소로 구현한 `SimpleTasklet` 템플릿은 변경에 닫혀있고, 내부 구현체는 외부에서 주입받아 확장에는 열려있는 구조이다.
  *

  ```
  <figure><img src="/files/bhYZioKYP4gU17mfBUDU" alt="" width="563"><figcaption></figcaption></figure>
  ```

## Reference

전체 소스

{% embed url="<https://github.com/viviennes7/fastcampus-batch-campus>" %}


---

# Agent Instructions: Querying This Documentation

If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://programmer-jjy.gitbook.io/second-brain/technical/study/dont-reinvent-the-wheel/spring-batch/spring-batch-1.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
