노트북을 열고.

[spring boot batch] 2. 미납회원 배치처리 구현 본문

SpringBoot

[spring boot batch] 2. 미납회원 배치처리 구현

ahndy84 2019. 7. 30. 22:54
해당내용의 소스는 https://github.com/ahndy84/salt에서 확인하실 수 있습니다.

커뮤니티에 가입한 회원들중에 다음과 같은 배치처리 프로세스를 구현하고자 합니다.

1. 데이터베이스에서 이용이 활성화된 회원목록을 읽어온다.  (ItemReader)

2. 읽어온 고객리스트들을 대상으로 다음의 비즈니스를 처리한다.  (ItemProcessor)
   case. 결제할 금액 있음(=미납 고객)
     - (회원의 상태값이 활성화되어 있는 경우) 비활성화(INACTIVE) 상태 값으로 전환한다.

3. 처리된 고객들을 DB에 저장(update)한다 (ItemWriter) 

진행하기에 앞서 사전에 준비해야 하는 몇가지입니다.

  • 이전 장에서 실습해 본 SubModule 프로젝트를 기준으로 진행하겠습니다.  아래의 포스트를 참고해주시면 됩니다.

 

SubModule 프로젝트구성 - 1. Submodule?

커뮤니티 서비스를 개발하고자 합니다. 기본적으로 아래와 같이 각 역할을 담당하는 서버를 따로 분리하여 설계를 하고자 합니다. WEB module : 서비스 이용자와 접점을 담당하는 모듈 API module : Web페이지 내..

ahndy84.tistory.com

  • DB 저장소 (Embeded DB & MySqlDB) 환경이 필요합니다.

    우선적으로 대용량 배치 처리 프로세스를 구현하는 것인 만큼 그  대상인 데이터를 대용량으로 가져올 저장소가 마련되어야 하겠죠? 임베디드 DB(H2)로만 사용할 수 있겠지만 앞서 소개한 SpringBoot Batch Framework의 핵심인 JobRepositoryJobInstace의 작동원리를 좀 더 가시적으로 이해하기 위해선 별도의 MySQL DB 환경을 구축하는 것을 권장해 드립니다. 

Application 설정

이 장에서 진행할 프로젝트의 디렉터리 구조는 다음과 같습니다.

그리고 각 Module에 대한 Port, ORM, Debuging 외에도 DataBase(H2, MySQL) 환경 설정을 합니다.  module-common은 다음과 같이 다른 3가지 모듈(batch, api, web)들이 의존하고 있습니다.

 

/build.gradle
...
project(':module-common') {
    dependencies {
        compile('org.springframework.boot:spring-boot-starter-data-jpa')

    }
}

project(':module-web') {
    dependencies {
        compile project(':module-common')
    }
}

project(':module-api') {
    dependencies {
        compile project(':module-common')
    }
}

project('module-batch') {
    dependencies {
        compile project(':module-common')
    }
...

module-common의 다음의 경로에 있는 application.yml파일을 열어 다음과 같이 작성합니다. 

 

/module-common/src/main/resources/application.yml
debug: true
logging.level.org.hibernate.type.descriptor.sql.BasicBinder: trace
spring:
  datasource:
    url: {jdbc://mysql://DB_URL:PORT}
    username: {USER_NAME}
    password: {PASS_WORD}
    driver-class-name: com.mysql.cj.jdbc.Driver //mysql인 경우
  jpa:
    show-sql: true
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        format_sql: true
server:
  port: 80

 

Build.gradle 설정

Springboot Batch 프로세스에 필요한 의존성들을 추가해 줍니다.

/module-batch/build.gradle
buildscript {
    ext {
        springBootVersion = '2.0.3.RELEASE'
    }
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:${springBootVersion}")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

group = 'com.community'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = 1.8

repositories {
    mavenCentral()
}


dependencies {
    compile('org.springframework.boot:spring-boot-starter-batch')
    compile('org.springframework.boot:spring-boot-starter-data-jpa')
    runtime('com.h2database:h2')
    runtime('mysql:mysql-connector-java')
    compileOnly('org.projectlombok:lombok')
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('org.springframework.batch:spring-batch-test')
}

dependencies Scope 안에 스프링 부트 배치 프레임워크에 필요한 의존성 라이브러리를 추가합니다.

org.springframework.boot:spring-boot-starter-batch : 배치 생성에 필요한 많은 설정을 자동으로 적용할 수 있습니다.
org.springframework.boot:spring-batch-test : 배치를 테스트하기 위한 설정을 자동으로 적용할 수 있습니다.

Domain 생성

미납회원 이용중지 배치 처리에 사용될 Domain을 생성합니다. 회원에 대한 Domain을 간략하게 생성하겠습니다. lombok 라이브러리를 사용하여 코드를 경량화하였습니다.

 

module_comon/src/main/java/com/salt/domain/member/Member
package com.salt.domain.member;

import lombok.Builder;
import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.time.LocalDate;
import java.time.LocalDateTime;

@Entity
@Getter
@Setter
public class Member {
	@Id
	@GeneratedValue
	private Long id;

	@Column
	private String name;

	@Column
	private String email;

	@Column
	private String nickName;

	@Column
	@Enumerated(EnumType.STRING)
	private MemberStatus status;

	@Column
	private int amountCharged;  // 청구된 금액

	@Column
	private int amountPaid; // 결제된 금액

	@Column
	private LocalDate dueDate;  // 결제 마감일

	@Column
	private LocalDateTime createdAt;

	@Column
	private LocalDateTime updatedAt;

	public Member setStatusByUnPaid() {
		if(this.isUnpaid()) {
			this.status = MemberStatus.INACTIVE;
		}
		return this;
	}

	public boolean isUnpaid() {
		return this.amountCharged <= this.amountPaid;
	}

	@Builder
	public Member() {}

	@Builder
	public Member(String name, String email, String nickName, int amountCharged, int amountPaid, LocalDate dueDate) {
		this.name = name;
		this.email = email;
		this.nickName = nickName;
		this.status = MemberStatus.ACTIVE;
		this.amountCharged = amountCharged;
		this.amountPaid = amountPaid;
		this.dueDate = dueDate;
		this.createdAt = LocalDateTime.now();
		this.updatedAt = LocalDateTime.now();
	}
}

 

module_comon/src/main/java/com/salt/domain/member/MemberStatus
package com.salt.domain.member;

public enum MemberStatus {
    ACTIVE, UNPAID, INACTIVE
}

 

module_comon/src/main/java/com/salt/domain/member/MemberRepository
package com.salt.domain.member;

import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;

public interface MemberRepository extends JpaRepository<Member, Long>{
    List<Member> findAll();
    List<Member> findByStatusEquals(MemberStatus memberStatus);
}

 

BatchJob 애플리케이션 선언

BatchJob 프로세스를 구현하기 위해선 애플리케이션에서 배치작업에 필요한 @Bean을 미리 등록하여 사용할 수 있도록 해주는 @EnableBatchProcessing을 선언해주어야 합니다. @EnableBatchProcessing 선언을 통하여 BatchJob의 구현부에서 필요한 JobStep객체를 생성해주는 JobBuilderFactoryStepBuilderFatory를 자동으로 주입받을 수 있습니다.

module_batch/src/main/java/com/salt/ModuleBatchApplication
package com.salt;

import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@EnableBatchProcessing  // 1
@SpringBootApplication
public class ModuleBatchApplication {
    public static void main(String[] args) {
        SpringApplication.run(ModuleBatchApplication.class, args);
    }
}

1

@EnnableBatchProcessing 선언을 통하여 배치 애플리케이션을 구동하는데 필요한 설정을 자동으로 등록시켜 줍니다.

 

BatchJob 구현

이제 본격적으로 앞장에서 배운 개념을 활용해 보겠습니다. 갑자기 생각이 안 나신다고요?

 

[spring boot batch] 1. 간단한 대용량 배치처리, 스프링부트배치

대략 10만명의 회원을 거느리는 웹서비스를 운영한다고 가정했을 때 우린 매일마다 회원들의 상태변화를 감지하고 컨트롤할 수 있어야 합니다. 가령 오늘까지 우리 서비스에 접속하지 않은지 1년이상이 지난 회원..

ahndy84.tistory.com

그렇다면 이전의 내용을 약 1분 간이라도 휘리릭 훑고 오신 이후에 보시는 걸 추천드립니다. 이전 장의 내용을 다시 한번 간략히 소개드리자면 다음과 같습니다.

1. 스프링 부트 배치는 Job이라는 하나의 일(Work) 단위가 있습니다. 그리고 이 Job이라는 객체는 그 객체를 만드는 '빌더공장' 즉, JobBuilderFactory에서 get() 메서드로 JobBuilder가 생성하고 이 JobBuilder를 통해 Job 객체를 정의하고 빌드할 수 있죠.

2. Job이 일종의 일(work) 단위 객체라면 Step은 그 일에 대한 단계를 나타내는 객체입니다. 모든 일에는 단계가 있듯이 말이죠. 결국 Job객체는 여러 개의 Step객체를 포함하는 일종의 컨테이너라 보시는 게 맞습니다.

3. 그리고 이 Step객체는 이전 장에서 꼭 기억해야 한다는 3가지, 바로 처리하고, 저장하는 구조를 정의할 수 있는 가장 실질적인 도메인 객체입니다. 

 

module_batch/src/main/java/com/salt/batch/jobs/unPaidMemberConfig
package com.salt.batch.jobs;

import com.salt.domain.member.Member;
import com.salt.domain.member.MemberRepository;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Slf4j  // log 사용을 위한 lombok Annotation
@AllArgsConstructor  // 생성자 DI를 위한 lombok Annotation
@Configuration
public class unPaidMemberConfig {
    private MemberRepository memberRepository;

    @Bean
    public Job unPaidMemberJob(
            JobBuilderFactory jobBuilderFactory,
            Step unPaidMemberJobStep
    ) {
        log.info("********** This is unPaidMemberJob");
        return jobBuilderFactory.get("unPaidMemberJob")  // 1_1
                .preventRestart()  // 1_2
                .start(unPaidMemberJobStep)  // 1_3
                .build();  // 1_4
    }

    @Bean
    public Step unPaidMemberJobStep(
            StepBuilderFactory stepBuilderFactory
    ) {
        log.info("********** This is unPaidMemberJobStep");
        return stepBuilderFactory.get("unPaidMemberJobStep")  // 2_1
                .<Member, Member> chunk(10)  // 2_2
                .reader()  // 2_3
                .processor()  // 2_4
                .writer()  // 2_5
                .build();  // 2_6
    }
}

1-1

주입받은 JobBuilderFactoryget() 메서드를 이용해서 'unPaidMemberJob'이라는 Job이름을 가진 빌더를 생성합니다..

1-2

unPaidMemberJob 이름의 Job의 중복 실행을 방지합니다.

1-3

Job 실행 시작 시점에 파라미터로 주입받은 unPaidMemberJobStep을 실행합니다. 

아래 unPaidMemberJobStep 메서드는 @Bean으로 등록되어 있는 것을 알 수 있습니다.

1-4

지금까지 설정된 내용으로 Job 객체를 빌드합니다.

2-1

주입받은 StepBuilderFactoryget() 메서드를 이용해서 'unPaidMemberJobStep'이라는 Step이름을 가진 빌더를 생성합니다.

2-2

(매우 중요!)

입력 타입(앞)과 출력 타입(뒤)을 선언합니다. 둘 다 동일한 Member 타입을 선언하였습니다. 
chunk()의 인자 값으로 10을 설정하였습니다. 즉 아래 write 메서드에서 실행시킬 개수 단위입니다.
한 번에 10개씩 write를 실행할 것입니다.

2-3

데이터는 읽고 = reader

2-4

읽어온 데이터를 처리하고  = processor

2-5

처리한 데이터를 저장 = writer

2-6

지금까지 설정된 내용으로 Step 객체를 빌드합니다.

 

이제 읽고, 처리하고, 저장하는 구조를 가진 Step객체를 포함하는 Job을 생성하였습니다. 우리가 한 것은 그저 JobBuilderFactoryStepBuilderFactory를 통해 각각 JobStep객체를 생성하는 Builder를 정의하는 것 외에는 한 것이 없습니다. 매우 간단하죠? 이제 마지막으로 남은 것은 그저 StepBuilder에서 정의한 읽고=reader(), 처리하고=processor(), 저장=write() 하는 것을 구현하는 일, 즉 실제 비즈니스 영역을 구현하는 일만 남았습니다.

 

서두에서 설명한 내용을 다시 가져와 보겠습니다. (맨 위로 스크롤이 올리기 귀찮을 테니 제가 아래로 대신 가져왔습니다.) 그리고 그대로 각 구조에 맞게 구현해 보도록 하겠습니다.

1. 데이터베이스에서 이용이 활성화된 회원 목록을 읽어온다.  (ItemReader)

2. 읽어온 고객 리스트들을 대상으로 다음의 비즈니스를 처리한다.  (ItemProcessor)
   case. 결제할 금액 있음(=미납 고객)
     - (회원의 상태 값이 활성화되어 있는 경우) 비활성화(INACTIVE) 상태 값으로 전환한다.

3. 처리된 고객들을 DB에 저장(update)한다 (ItemWriter) 

 

1. ItemReader

module_batch/src/main/java/com/salt/batch/jobs/unPaidMemberConfig 중에서
@Bean
@StepScope  // 1
public ListItemReader<Member> unPaidMemberReader() {
	log.info("********** This is unPaidMemberReader");
	List<Member> activeMembers = memberRepository.findByStatusEquals(MemberStatus.ACTIVE);
	log.info("          - activeMember SIZE : " + activeMembers.size());  // 2
	return new ListItemReader<>(activeMembers);  // 3
}

1

(중요)
@Bean은 인스턴스에 대한 클래스가 최초 한 번한 메모리에 적재되고 이후로도 하나의 인스턴스로 계속 사용됩니다. 그러나 @StepScopeStep 주기를 실행할 때마다 항상 매번 새로운 @Bean을 만들겠다는 선언입니다. 그렇기 때문에 지연 생성이 가능하게 되죠. 

2

DB에서 회원의 상태 값(status)이 활성(ACTIVE)인 레코드를 읽어옵니다.

3

ListItemReader 객체를 생성하고 읽어온 회원 리스트를 담아 반환합니다.

 

2. ItemProcessor

module_batch/src/main/java/com/salt/batch/jobs/unPaidMemberConfig 중에서
public ItemProcessor<Member, Member> unPaidMemberProcessor() {
	return new ItemProcessor<Member, Member>() {  // 1
		@Override
		public Member process(Member member) throws Exception {
			log.info("********** This is unPaidMemberProcessor");
			return member.setStatusByUnPaid();  // 2
		}
	};
}

1

전자의 Member는 입력되는 타입, 후자의 Member는 반환되는 타입을 의미합니다.

2

process() 메서드를 오버라이드 하여 다음의 비즈니스 로직이 처리된 Member 타입을 반환합니다.

setStatusByUnPaid = 청구된 금액 > 결제된 금액일 경우 회원 상태 값인 비활성화(INACTIVE)로 변경

 

3. ItemWriter

module_batch/src/main/java/com/salt/batch/jobs/unPaidMemberConfig 중에서
public ItemWriter<Member> unPaidMemberWriter() {
	log.info("********** This is unPaidMemberWriter");
	return ((List<? extends Member> memberList) ->
	memberRepository.saveAll(memberList));  // 1
}

1

ItemProcessor 통해 처리된 회원 목록을 DB에 저장합니다.

여기서 중요하게 보아야 할 대목은 처리할 회원 목록 즉, memberListStep에서 설정한 chuck단위로 받습니다. 방금 전 Step객체를 빌드할 때 chuck의 인자를 10으로 설정하였으니  ItemWriter에서는 한 번에 10개 단위의 레코드를 DB로 커밋하게 됩니다.

 

이렇게 하여 전체 코드는 다음과 같습니다.

module_batch/src/main/java/com/salt/batch/jobs/unPaidMemberConfig
package com.salt.batch.jobs;

import com.salt.domain.member.Member;
import com.salt.domain.member.MemberRepository;
import com.salt.domain.member.MemberStatus;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.configuration.annotation.JobBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepBuilderFactory;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemWriter;
import org.springframework.batch.item.support.ListItemReader;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.ArrayList;
import java.util.List;

@Slf4j  // log 사용을 위한 lombok Annotation
@AllArgsConstructor  // 생성자 DI를 위한 lombok Annotation
@Configuration
public class unPaidMemberConfig {
    private MemberRepository memberRepository;

    @Bean
    public Job unPaidMemberJob(
            JobBuilderFactory jobBuilderFactory,
            Step unPaidMemberJobStep
    ) {
        log.info("********** This is unPaidMemberJob");
        return jobBuilderFactory.get("unPaidMemberJob")
                .preventRestart()
                .start(unPaidMemberJobStep)
                .build();
    }

    @Bean
    public Step unPaidMemberJobStep(
            StepBuilderFactory stepBuilderFactory
    ) {
        log.info("********** This is unPaidMemberJobStep");
        return stepBuilderFactory.get("unPaidMemberJobStep")
                .<Member, Member> chunk(10)
                .reader(unPaidMemberReader())
                .processor(this.unPaidMemberProcessor())
                .writer(this.unPaidMemberWriter())
                .build();
    }

    @Bean
    @StepScope
    public ListItemReader<Member> unPaidMemberReader() {
        log.info("********** This is unPaidMemberReader");
        List<Member> activeMembers = memberRepository.findByStatusEquals(MemberStatus.ACTIVE);
        log.info("          - activeMember SIZE : " + activeMembers.size());
        List<Member> unPaidMembers = new ArrayList<>();
        for (Member member : activeMembers) {
            if(member.isUnpaid()) {
                unPaidMembers.add(member);
            }
        }
        log.info("          - unPaidMember SIZE : " + unPaidMembers.size());
        return new ListItemReader<>(unPaidMembers);
    }

    public ItemProcessor<Member, Member> unPaidMemberProcessor() {
//        return Member::setStatusByunPaid;
        return new ItemProcessor<Member, Member>() {
            @Override
            public Member process(Member member) throws Exception {
                log.info("********** This is unPaidMemberProcessor");
                return member.setStatusByUnPaid();
            }
        };
    }

    public ItemWriter<Member> unPaidMemberWriter() {
        log.info("********** This is unPaidMemberWriter");
        return ((List<? extends Member> memberList) ->
                memberRepository.saveAll(memberList));
    }
}

 

test 레코드 DB에 자동 삽입하기

BatchJob에 대한 데모 구현은 마무리되었고 이제 이 프로세스를 실행할 데이터를 생성하여 DB에 인입시켜 보겠습니다. DBMS에 직접 접근하여 Insert문을 직접 작성하여 데이터를 삽입하는 방법도 있습니다.

그러나 SpringBoot에서 제공하는 SQL파일을 이용해 우리가 테스트할 레코드를 쿼리로 작성해 놓으면 해당 Application이 실행되는 시점에 맞추어 SQL파일에 있는 쿼리를 실행하여 데이터를 Application 실행 시점마다 자동으로 생성시켜낼 수 있습니다.

module_batch/src/main/resources/import.sql
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (1, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt1@gmail.com', 'salt1', 'salt1', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (2, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt2@gmail.com', 'salt2', 'salt2', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (3, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt3@gmail.com', 'salt3', 'salt3', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (4, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt4@gmail.com', 'salt4', 'salt4', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (5, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt5@gmail.com', 'salt5', 'salt5', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (6, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt6@gmail.com', 'salt6', 'salt6', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (7, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt7@gmail.com', 'salt7', 'salt7', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (8, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt8@gmail.com', 'salt8', 'salt8', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (9, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt9@gmail.com', 'salt9', 'salt9', 'ACTIVE', now() );
insert into member ( id, amount_charged, amount_paid, created_at, due_date, email, name, nick_name, status, updated_at) values (10, 10000, 20000, now(), '2019-07-01 23:59:59', 'salt10@gmail.com', 'salt10', 'salt10', 'INACTIVE', now() );

 

Batch 애플리케이션 실행하기

SpringBoot Batch 프레임워크를 이용한 배치 잡을 구현하였고 배치잡을 실행시킬 수 있는 Test데이터 삽입 쿼리도 import.sql 파일에 작성하였습니다. 이제 애플리케이션을 구동하여 배치 잡이 실제로 어떻게 동작하는 일만 남았습니다. 지체 없이 바로 실행시켜 봅시다. console창에서 그리고 애플리케이션이 빌드가 되는 순서대로 올라오는 로그를 확인하여 봅시다.

  • Starting ModuleBatchApplication on andoyeong-ui-MacBookPro.local with PID 15669
    moduleBatchApplication 이 시작되고 있습니다.
  • No active profile set, falling back to default profiles: defaultLoading source class com.salt.ModuleBatchApplication
    우리는 별도의 프로필 셋을 설정하지 않았습니다. 기본(Default) 프로필을 로딩합니다.
  • Loaded config file 'file:/Users/salt/Documents/GitHub/salt_socar/module-common/out/production/resources/application.yml' (classpath:/application.yml)
    moduleCommon/src/main/resources 에서 DB데이터소스, 로깅, ORM등 각종 설정을 정의한 application.yml을 읽어옵니다.
  • @EnableAutoConfiguration was declared on a class in the package 'com.salt'. Automatic @Repository and @Entity scanning is enabled.
    @EnnableAutoConfiguration 선언을 통하여 com.salt 패키지안에 @Repository 와 @Entity 컴포넌트 자동 스캐닝이 가능해졌습니다.

  • 방금 전 작성한 import.sql파일 안에 있는 인서트 쿼리를 읽어 자동으로 실행시켜 줍니다. 이것은 앞서 @Entity 컴포넌트가 스캐닝되면서 member 테이블이 이미 생성된 이후의 시점에 동작(member 테이블로 데이터 선입)을 하게 되는 겁니다.

  • Caused by: java.sql.SQLSyntaxErrorException: Table 'community_test.BATCH_JOB_INSTANCE' doesn't exist
    자 그런데... CONDITIONS EVALUATION REPORT라는 로그 메시지 이후 주욱 내려가다 보면 다음과 같은 에러를 직면하게 됩니다. 이 에러의 정체는 무엇일까요?

BATCH_JOB_INSTANCE TABLE..?

먼저 이 테이블의 정체가 무엇인지 궁금합니다. BatchJob을 구동하기 하기 위해 별도의 DB 테이블을 생성하라는 내용은 아직까진 없었습니다. 그러나 BatchJob의 구동을 위해 전용 테이블을 생성해야 한다면 1개도 아닌 2개도 아닌 무려 9개씩이나 만들어야 하는 상황이 오면 어떨까요?

간편하게 구현하여 사용하려던 것이 점점 일이 커지는 것 같습니다... 만 조금만 진정하시고 먼저 이 테이블의 존재의 이유부터 살펴보면 납득이 되실 겁니다. 무엇보다 테이블 스키마는 이미 Springboot Batch Framework 안에 sql파일로 제공하고 있습니다. 친절하게 주요 DBMS 벤더사 별로 고유하게 말이죠.

 

이 테이블의 존재의 이유에 대해선 다음장에 자세히 설명하겠습니다. (간단히 소개해 드리자면 BatchJob이 실행되는 단위인 JobInstance와 그 JobInstance 실제 실행 횟수를 나타내는 JobExcution의 이력을 관리하는 역할을 합니다. 핵심은 이것이 얼마나 중요하냐는 거지요. 그것을 다음장에서 실습을 통해 설명해 드리겠습니다.)

 

IntelliJ를 기준으로 command + shift + o(파일 찾기)를파일찾기)를 눌러 자신의 DBMS에 맞는 테이블 스키마 파일을 열고 파일 안에 있는 DDL을 자신의 DB에 그대로 실행시키면 그 에러의 정체가 되었던(될) 테이블들이 모두 생성됩니다.

IDE IntelliJ 기준  command + shift + o( 파일찾기)를 눌러 자신의 DBMS에 맞는 테이블 스키마 파일을 찾아 엽니다.

 

SQL 파일안에 스키마정보(DDL)를 자신의 DBMS에 실행시킵니다.(=테이블을 생성합니다.)

 

BatchJob실행에 필요한 테이블들이 생성되었습니다.

자 그렇다면 배치 잡을 운용하기 위한 테이블을 모두 생성하신 이후 다시 애플리케이션을 시도해 보도록 하겠습니다. 처음 시도했을 당시와는 다르게 이번에는 애플리케이션이 정상적으로 실행을 마치고 종료되었습니다. 찬찬히 정상적으로 구동된 애플리케이션 로그를 살펴봅시다.

  • Executing prepared SQL statement
    배치잡을 구동 하면서 방금 전 생성된 배치 잡과 관련된 테이블이 쿼링이 여러 번 쿼링 되고 있습니다. 
  • Job: [SimpleJob: [name=unPaidMemberJob]] launched with the following parameters: [{}]
    unPaidMemberJob 이름에 잡이 실행되었습니다. 해당 Job에 대한 파라미터는 지정하지 않았기 때문에 [{}]로 표시됩니다.

  • ********** This is unPaidMemberReader
    읽고->처리하고->쓰는 것 중에 읽는 단계unPaidMemberReader에 대한 로그가 포착되었습니다.

  • activeMember SIZE :9
    앞서 애플리케이션 구동 시 읽어드린 import.sql파일에서는 9명의 활성화 상태의 회원 + 1명의 비활성화 상태의 회원, 이렇게 총 10개의 회원 레코드를 입력하였습니다. unPaidMemberJobStep는 활성화 상태인 회원 레코드만 읽어오는 동작이기에 정상적으로 읽어드린 레코드 수가 9개임을 확인할 수 있습니다.

  • ********** This is unPaidMemberProcessor x 9
    읽고->처리하고->쓰는 것 중에 처리하는 단계 unPaidMemberProcessor에 대한 로그가 포착되었습니다.  앞서 읽어드린 회원 목록만큼 9개가 찍혀 있네요. 즉, 9번의 처리를 실행시켰다는 의미입니다. 앞서 stepBuilderFactory에서 chuck라는 단위를 10을 설정하여 그 개수만큼 커밋이 되는 것이라 어렴풋 기억이 나는 것 같기도 합니다. 음. 어찌 된 일일까요?

청크지향처리

그걸 기억하셨다면 지금까지 정말 잘 따라오신 겁니다. 배치 프로세서에서 Chuck가 필요한 시점이 있습니다. 바로 읽고 처리한 데이터를 거쳐 저장소라 할 수 있는 DB에 변경이 일어나는 되는 시점이죠. 만약 일정한 개수의 단위로 DB에 커밋이 된다면 그렇지 않은 경우보다 훨씬 더 빠르고 안정적으로 처리할 수 있을 것입니다.

 

예컨대 1개의 저장이 실패하여 다른 99개의 데이터가 롤백이 된다거나 하는 불상사가 없어지겠죠. 다시 말해 Chuck는 일정한 수량단위로 트랜잭션이 일어나기 때문에 읽고, 처리한 데이터들이 청크의 단위의 개수만큼 차게 되면 write로 전달하고 writer는 일괄적으로 저장소에 반영하게 됩니다.

 

청크지향 처리 과정

 

위 그림대로 코드로 나타내면 다음과도 같습니다.(코드 출처 : https://jojoldu.tistory.com/331?category=635883)

for(int i=0; i<totalSize; i+=chunkSize){ // chunkSize 단위로 묶어서 처리
    List items = new Arraylist();
    for(int j = 0; j < chunkSize; j++){
        Object item = itemReader.read()
        Object processedItem = itemProcessor.process(item);
        items.add(processedItem);
    }
    itemWriter.write(items);
}

 

  • Job: [SimpleJob: [name=unPaidMemberJob]] completed with the following parameters: [{}] and the following status: [COMPLETED]
    unPaidMemberJob의 최종 status가 정상적으로 COMPLETED 되었습니다.

이제 배치 잡 실행 이후에 회원 목록 테이블의 상태 값을 확인해 보면 의도한 대로 처리되어 모두 비활성화 상태로 저장되었음을 확인할 수 있습니다.

 

여기까지 간단하게나마 SpringBatch에서 제공하는 Batch 프레임워크를 활용하여 비교적 쉽고 간편하게 대용량 배치 데이터를 처리할 수 있는 BatchJob 프로세스를 실제로 구현해 보았습니다. 배치 프로세스의 기본적인 형태는 구현을 해보았지만 이 배치 프로세스를 더욱 견고하고 효율적으로 운용하고 설정할 수 있는 중요한 내용들은 앞으로 이곳에 다음장에서 구체적으로 함께 다뤄나가도록 하겠습니다.

 

다음장에는 BatchJob을 위해 태어난 테이블들인 메타 테이블에 대한 존재의 이유와 활용하는 법에 대해 이어 나아가도록 하겠습니다. 곧 다시 찾아뵙겠습니다. 읽어 주셔서 감사합니다.

 

 

 

 

이 지식에 도움받은 출처 공유드립니다.

1. jojoldu님의 기술 블로그 - 기억보단 기록을.

Java중심의 Back-end 관련한 유용한 정보가 기록되어 있습니다.

 

 

기억보단 기록을

Java 백엔드, AWS 기술을 익히고 공유합니다.

jojoldu.tistory.com

 

2. (김영재 저) 처음 배우는 스프링 부트 2

입문서중에선 드물게 Springboot Batch에 관하여 자세하고 친절하게 설명해주고 있습니다.

 

 

처음 배우는 스프링 부트 2

★ 구현 순서에 맞춰 프로젝트를 진행하며 배우는 실전 입문서★ 이 책은 스프링 부트 입문자의 눈높이에 맞춰 스프링 부트 환경 설정부터 커뮤니티 게시판 구현까지를 다룬다. 스프링 부트의 기본 개념과 다양한 스프링 부트 스타터를 이용해 커뮤니티 게시판 구축 프로젝트를 구현한다. 스프링을 몰라도 공부할 수 있도록 가능한 한 쉽게 설명하고 따라 할 수 있게...

www.yes24.com

 

'아는 것'보다 '알아야할 것'이 더 많은 주니어 개발자입니다.

알고 있는 지식을 전한다는 목적 보단 막 알게 된 지식을  스스로 정리하는 차원에서 포스팅하고 있으니 잘못된 내용이나 부족한 부분이 있더라도 겸허한 이해 부탁드립니다. 댓글이나 쪽지를 통해 첨삭의견주시면 감사히 수렴하고 보완하겠습니다.

Comments