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

KYOUNGBEOM·2024년 12월 11일

Spring Batch

목록 보기
1/9

서론 - 배치에 대한 이해


배치란?

"데이터 처리에서 즉시성을 필요로 하지 않을 경우,
일정량 또는 일정 기간 데이터를 수집한 후 일괄 처리하는 방식"


대표적인 배치의 예시

  • 정산 시스템
  • 데이터 마이그레이션
  • 약관 변경 메일
  • 게임 랭킹
  • 쇼핑몰 배송 등등

예를 들어,
쇼핑몰 배송과 관련하여 사용자로부터 구매가 발생하고
바로 배송요청이 이루어지는 경우 사용자가 구매를 취소했을때
배송사에 배송요청을 취소해야하는 등 추가적인 리소스가 발생하게 된다.

하지만,
배치를 사용하게 되면 구매에 대한 정보를 DB에만 저장해두었다가,
일정 시간을 기점으로 배치를 통해 일괄적으로 배송요청을 하면 되기 때문에

배송요청이 일어나기전 사용자가 구매를 취소한다면,
단순히 DB에서 관련 정보를 삭제하거나 수정해주기만 하면 된다.

쇼핑몰에서 00시 이전 주문시 당일발송이라는
안내문구를 생각해보면 더 잘 이해가 될듯하다.


배치의 장점?

  • 자원을 효율적으로 처리할 수 있다.
  • 대용량 데이터를 한 번에 처리할 수 있다.
  • 정해진 시간에 반복적으로 자동 실행시킬 수 있다.
  • 사용자와의 상호작용 없이 작동할 수 있다.

배치의 단점?

  • 실시간성을 제공하기가 어렵다.
  • 잘못 동작하면 큰 문제가 된다. (ex. 잘못된 정산, 잘못된 메일 발송 등)

본론 1 - Spring Batch 소개


Spring Batch란?

아래는 Spring Batch 공식문서에 나와 있는 Spring Batch에 대한 설명이다.

"Spring Batch is a lightweight, comprehensive batch framework designed to enable the development of robust batch applications that are vital for the daily operations of enterprise systems."

"엔터프라이즈 시스템 운영에 필요한 견고한 배치 어플리케이션을
개발할 수 있도록 설계된 가볍고 다양한 기능을 가진 배치 프레임워크"


Spring Batch 사용 시나리오

아래는 Spring Batch 공식문서에 나와 있는 시나리오이다.

  • Reads a large number of records from a database, file, or queue.

  • Processes the data in some fashion.

  • Writes back data in a modified form.


Spring Batch 특징

  • Spring을 기반으로 한 프레임워크로 Spring의 특성들을 사용할 수 있다.
    (ex. DI, IoC, AOP 등)

  • 대용량 데이터를 처리하는데 최적화 되어있다. (Chunk 기반 처리)

  • 파티셔닝, 병렬 방식 등을 통해 시간을 단축시킬 수 있다.

  • 예외 상황에 대한 방어 코드를 지원한다.

  • 로깅 및 추적, 작업 처리 통계, 트랜잭션 관리 등을 제공한다.


본론 2 - 프로젝트 세팅 및 배치 프로그램 구현

프로그램 구현에 앞서 해당 프로젝트는 Spring Batch를 적용하지 않고

노가다(?)로 배치 프로그램을 구현하는 과정으로 Spring Batch를 사용하지 않고,
Spring Batch와 비슷한 구조로 설계해보는 프로젝트이다.


요구사항

"365일이 지나도록 로그인 하지 않으면 휴먼 계정으로 전환한다."


기본 프로젝트 세팅

Spring Initializr 를 사용하여 설정하였다.

추후 테스트 코드를 기반으로 테스트 케이스 대한 테스트를 할때
인메모리 데이터베이스를 사용할 수 있도록 H2 의존성을 추가하였다.

참고로 인메모리 데이터베이스를 사용하면
실제 데이터베이스가 없어도 테스트가 가능하다.


Customer Entity

1년이 넘어간 회원들의 계정을 휴먼 계정으로 전환하면 되므로,
최대한 기본적인 정보만을 필드로 갖도록 한다.

enum 클래스의 경우 별도의 패키지로 분리해주는 것이
더 바람직하겠지만, 간단한 프로젝트이므로 내부 클래스로 선언하였다.

package com.practice.batch.customer;

import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.ToString;

import java.time.LocalDateTime;

@Entity
@NoArgsConstructor
@Getter
@ToString
public class Customer {

    private Long id;

    private String name;

    private String email;

    private LocalDateTime createAt;

    private LocalDateTime loginAt;

    private Status status;

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
        this.createAt = LocalDateTime.now();
        this.loginAt = LocalDateTime.now();
        this.status = Status.NORMAL;
    }

    public enum Status {
        NORMAL,
        DORMANT;
    }

}

CustomerRepository

JPA를 사용할 수 있도록 JpaRepository를 상속한다.

package com.practice.batch.customer;

import org.springframework.data.jpa.repository.JpaRepository;

public interface CustomerRepository extends JpaRepository<Customer, Long> {
}

EmailProvider

사용자에게 휴면전환 내용을 이메일로 보내거나
관리자에게 배치 작업 관련 내용을 이메일로 보낼때 사용할 클래스이다.

package com.practice.batch;

import lombok.extern.slf4j.Slf4j;

public interface EmailProvider {

    void send(String emailAddress, String title, String body);

    @Slf4j
    class Fake implements EmailProvider {

        @Override
        public void send(String emailAddress, String title, String body) {
            log.info("{} email 전송 완료! {} : {}", emailAddress, title, body);
        }

    }

}

JobExecution

전체 배치 작업에 대한 정보를 관리할 클래스이다.
필드로 배치의 작업 상태, 배치 시작/종료 시간을 가진다.

BatchStaus는 enum 객체로 STARING, FAILED, COMPLETED 를 가진다.

package com.practice.batch.batch;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;

import java.time.LocalDateTime;

@Getter
@Setter
@ToString
public class JobExecution {

    private BatchStatus status;

    private LocalDateTime startTime;

    private LocalDateTime endTime;

}

DormantBatchJob

실질적으로 휴먼전환 계정을 수행하는 비즈니스 로직이 담기는 클래스이다.

전체 배치 작업에 대한 정보는 JobExecution 객체를 통해 관리하고,
개별 회원에 대한 휴먼전환 작업은 while문 내에서 처리한다.

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

}

DormantBatchJobTest

그럼 이제 아래 테스트 코드를 기반으로
발생할 수 있는 테스트 케이스에 대한 테스트를 진행하여 보자.

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.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 DormantBatchJob 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 DormantBatchJob dormantBatchJob = new DormantBatchJob(null);

        // when
        final JobExecution result = dormantBatchJob.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);
    }

}

모든 테스트 케이스가 성공적으로 통과한다!

결론

지금까지 Spring Batch를 사용하지 않고
간단한 배치 프로그램을 구현해보았다.

다음 게시물에서는 기능 구현만을 위해 작성된
이 더러운 코드(?)들을 리팩토링 해보고자 한다.

profile
나의 개발 성장일지

0개의 댓글