Published on

스프링 배치 이야기 1

Authors
  • avatar
    Name
    이건창
    Twitter

Introduction

개요

사내에서 포인트 정산을 진행하면서 고민했던 내용과 경험했던 내용을 정리해보려 합니다. 이번 글은 배치를 도입하게 된 배경과 필요성 그리고 스프링 배치 동작을 파악하면서 사용에 대한 거부감을 줄여보려 합니다.

정산은 개별 트랜잭션에서 함께 처리하기엔 비효율이라 사용량이 적은 시간대에 배치 작업을 진행하고 있으며 스프링 배치를 적용했을 때 얻었던 장점과 도입시 신경썼던 부분 그리고 겪었던 문제를 정리했습니다. 배치를 간단 설명하자면 주기적으로 대량의 데이터를 수정하기 위한 방식입니다. 간단한 예시로 날짜 단위 판매량과 소비량을 계산할 때 사용자 요청마다 집계하지 않고 정각 주기로 배치를 실행하며 사용자 요청 부하를 줄이고 자원을 효율적으로 사용할 수 있게 됩니다.

팀에서는 spring web 에서 스케줄링을 사용해 데이터 집계를 하고 있었고, 스프링 배치로 마이그레이션하면서 느꼈던 경험과 필요했던 이유를 이야기해보려 합니다. 먼저 필요했던 이유를 설명해보겠습니다.

스프링 배치가 필요한 이유

스프링 배치는 배치를 사용한 때 느낀 이점은 다음과 같습니다.

  • 보편적인 단어를 사용한다.
  • 단일 책임 원칙을 만족한다.
  • 주입이 쉽다.
  • 이력 관리가 뛰어나다.
  • 작업 단위 트랜잭션을 보장한다.

객체 지향 프로그래밍과 보편적인 단어를 사용한다.

작업 단위와 코드 관리 단위가 동일해서 이해관계자와 대화가 편해지며, 책임을 쉽게 분할할 수 있어서 구현 + 관리에 부담을 줄일 수 있습니다.

  • 객체 지향 덕분에 요구 사항에 필요한 책임들을 쉽게 관리할 수 있다.
  • 보편적인 단어 덕분에 이해 관계자와 대화가 쉽다.

스프링 배치는 작업을 Job, Step, Reader, Processor, Writer 객체 간 대화로 구성하며 책임을 쉽게 분할해 단일 책임 원칙을 쉽게 만족할 수 있습니다. Job은 작업에 대한 실행 흐름을 관리하고 Step은 단계별 실행 흐름 만을 관리할 수 있고 Reader, Processor, Writer 에서 읽고, 처리하며, 쓰는 역할을 맡을 수 있기 때문에 단일 책임 원칙을 만족하게 됩니다.

해야할 작업(Job)과 작업에 필요한 단계(Step) 그리고 단계마다 진행해야할 읽기(Reader), 처리(Processor), 쓰기(Writer) 책임을 가지는 객체가 존재하는데, 보편적인 단어로 쉽게 대화하는 수단이 될 수 있습니다. 작업을 기준으로 설명해보겠습니다. 우리가 처리할 작업은 날짜 별 적립양, 사용양, 소멸양을 정산하는 일이라면 다음처럼 구성할 수 있습니다.

images

기준 날짜 단위로 적립양을 집계하는 단계, 사용량을 집계하는 단계, 소멸양을 집계하는 단계로 작업을 구성하고, 각 단계는 아이템을 조회하고 비즈니스에 맞게 수정하고 수정된 아이템을 사용하며, 이해관계자들과 코드가 아닌 작업 단위로 쉽게 대화할 수 있습니다.

주입이 쉽다.

스프링 배치 필수 컴포넌트 사이를 빈으로 등록하기 때문에 의존성 주입이 원활합니다. 또한 요구사항에 따라 변하는 파라미터를 쉽게 주입 받을 수 있어 편합니다.

  • 의존성 주입으로 실행 흐름을 쉽게 제어할 수 있다.
  • 요구사항에 따라 변하는 파라미터를 쉽게 주입할 수 있다.

빈으로 등록된 컴포넌트를 사용하기 때문에 Job에서 Step을 쉽게 교체 할 수 있고, Step에서 Reader, Processor, Writer를 쉽게 교체할 수 있습니다. Job은 내부 구현을 분리하며 어떤 단계(Step)으로 실행할지, 어떤 실행 흐름을 가져야할지 고민 만 하면 됩니다. 덕분에 Job 관리 부담을 현저하게 줄일 수 있습니다.

@Bean
fun exchangeCouponJob(
    jobRepository: JobRepository,
    createCouponToBeExchangeStep: Step,
    exchangeCouponStep: Step,
    alimTalkStep: Step,
) = JobBuilder(EXCHANGE_COUPON_JOB, jobRepository)
    // 보상할 쿠폰을 생성한다.
    .start(createCouponToBeExchangeStep)
    // 교환할 쿠폰을 찾아서 보상할 쿠폰으로 교환후 저장한다.
    .next(exchangeCouponStep)
    // 교환한 쿠폰 사용자를 찾아 알림을 보낸다.
    .next(alimTalkStep)
    .build()

또한 외부 변수를 주입하는 경우도 유연하게 대응할 수 있습니다. 이전에는 LocalDate를 받지 못하는 등의 이슈가 있었지만 5.1 버전 시점에는 자바가 지원하는 객체는 모두 전달받을 수 있습니다.

    @Bean
    @JobScope
    fun createCouponToBeExchangeStep(
        jobRepository: JobRepository,
        transactionManager: PlatformTransactionManager,
        couponService: CouponService,
        // 쿠폰 이름 주입
        @Value("#{jobParameters['name']}") name: String,
        // 쿠폰 설명 주입
        @Value("#{jobParameters['description']}") description: String,
        // 쿠폰 할인 유형 주입
        @Value("#{jobParameters['amountType']}") amountType: CouponStateAmountType,
        // 쿠폰 할안양 주입
        @Value("#{jobParameters['amount']}") amount: Int,
    ) = StepBuilder(CREATE_COUPON_TO_BE_EXCHANGE, jobRepository)
        .tasklet({ _, chunkContext ->
            couponService.createCouponToBeExchange(name, description, amountType, amount)
            FINISHED
        }, transactionManager)
        .build()

이력 관리가 뛰어나다.

배치를 스케줄링하는 경우 결과를 모니터링해 성공과 실패에 대응 할 수 있는 환경이 만들어져야 합니다. 스프링 배치를 사용한다면 작업 이력을 직접 관리하지 않아도 되고 이력을 이용해 후속 작업을 설정 할 수 있습니다.

  • 작업 이력을 직접 관리하지 않아도 된다.
  • 이력을 이용해 실행 방법을 제어할 수 있다.

배치 정보를 파악할 수 있는 방법은 BATCH_JOB_EXECUTION, BATCH_JOB_EXECUTION_PARAMS, BATCH_STEP_EXECUTION로 작업 문맥을 쉽게 파악할 수 있습니다.

  • BATCH_JOB_EXECUTION : 작업 실행 정보
  • BATCH_JOB_EXECUTION_PARAMS : 작업에 사용한 변수 정보
  • BATCH_STEP_EXECUTION : 작업 단계 별 실행 정보

작업 실행 이력(BATCH_JOB_EXECUTION)을 조회할 경우 작업 실행 정보가 포함됩니다. 다음은 작업 실행 이력(BATCH_JOB_EXECUTION) 테이블에 저장되는 데이터 예시입니다.

JOB_EXECUTION_IDVERSIONJOB_INSTANCE_IDCREATE_TIMESTART_TIMEEND_TIMESTATUSEXIT_CODE
1212024-06-23 20:132024-06-23 20:132024-06-23 20:13COMPLETEDCOMPLETED
2222024-06-23 20:142024-06-23 20:142024-06-23 20:14COMPLETEDCOMPLETED

다른 필드는 쉽게 이해가 가겠지만 JOB_EXECUTION_ID, VERSION, JOB_INSTANCE_ID는 배치에서 이력 관리 용도로 사용하고 있습니다.

  • JOB_EXECUTION_ID : 실행 기준 고유 식별자다. 실행할 때마다 식별자가 생성된다.
  • VERSION : 작업이 동시에 실행되는 상황을 방지하기 위한 숫자 정보다. 작업 도중 버전이 변경되면 OptimisticLockingFailureException 오류가 발생한다.
  • JOB_INSTANCE_ID : 파라미터를 직렬화한 기준 고유 식별자다. 동일한 파라미터인 경우 동일한 JOB_INSTANCE_ID를 가진다.

스프링 배치는 파라미터 이력도 관리할 수 있습니다. 파라미터 이력은 모니터링 용도 있지만 재실행 방지용으로 활용할 수 있습니다.

JOB_EXECUTION_IDPARAMETER_NAMEPARAMETER_TYPEPARAMETER_VALUE
1amountTypeCouponStateAmountTypeFIX
1nameString보상 쿠폰
1descriptionString할인금액 1000원 쿠폰
1amountint1000
1expiredCouponIdlong1
2amountTypeCouponStateAmountTypeRATE
2nameString100% 할인 보상 쿠폰
2descriptionString배송 오류로 인한 쿠폰 교환
2amountint100
2expiredCouponIdlong3

중복 실행 여부는 BATCH_JOB_INSTANCE 에서 결정하게 됩니다. BATCH_JOB_INSTANCE 테이블에서는 JOB 파라미터를 이용해 직렬화 한 JOB_INSTANCE_ID가 존재합니다. 해당 키로 중복 실행 여부를 판단하며 중복 파라미터인 경우 JobInstanceAlreadyCompleteException 오류를 반환합니다.

ERROR 68220 ---SpringApplication               : Application run failed

java.lang.IllegalStateException: Failed to execute ApplicationRunner
	...
Caused by: org.springframework.batch.core.repository.JobInstanceAlreadyCompleteException:
	A job instance already exists and is complete for identifying parameters=
	{
		'amountType':'{value=RATE, type=class java.lang.String, identifying=true}',
		'name':'{value=100% 할인 보상 쿠폰, type=class java.lang.String, identifying=true}',
		'description':'{value=배송 오류로 인한 쿠폰 교환, type=class java.lang.String, identifying=true}',
		'amount':'{value=100, type=class  identifying=true}',
		'expiredCouponId':'{value=3, type=class java.lang.String, identifying=true}'
	}.
	If you want to run this job again, change the parameters.
	...

동시에 두 개의 배치를 실행한다면 어떤 문제가 발생하는지도 체크해봤습니다. 만약 서로 다른 파라미터로 작업을 실행했을 때는 정상 동작했지만, 동일한 파라미터를 가진 작업은 JobExecutionAlreadyRunningException 예외를 발생하며 늦게 끝나는 작업이 실패했습니다.

org.springframework.batch.core.repository.JobExecutionAlreadyRunningException:
A job execution for this job is already running:
JobExecution:
	id=70,
	version=1,
	startTime=2024-06-26T22:24:35.573424,
	endTime=null,
	lastUpdated=2024-06-26T22:24:35.574293,
	status=STARTED,
	exitStatus=exitCode=UNKNOWN;
	exitDescription=,
	job=[JobInstance: id=36, version=0, Job=[EXCHANGE_COUPON_JOB]],
	jobParameters=[
		{
			'amountType':'{value=FIX, type=class com.example.estdelivery.domain.CouponStateAmountType, identifying=true}',
			'name':'{value=보상, type=class java.lang.String, identifying=true}',
			'description':'{value=설명, type=class java.lang.String, identifying=true}',
			'amount':'{value=100, type=class java.lang.Integer, identifying=true}',
			'expiredCouponId':'{value=12, type=class java.lang.Long, identifying=true}'
		}
	]

️ 조금 의아했던 건 실패한 남은 작업은 이력이 남지 않았다는 점입니다. 처음 실행했던 작업을 잠시 멈추고 두 번째 작업을 먼저 실행했더니 처음 작업이 실패했습니다. 그럼 처음 작업에 대한 ID와 이력이 남아야 하는데 남지 않았습니다. 중간에 ID가 건너띄지 않아서 이력이 덮어 씌워질 수 있겠다는 우려도 있었습니다.

실패한 이후 중단된 위치부터 실행할 수 있도록 이력을 관리하는 테이블도 존재합니다. BATCH_JOB_EXECUTION_CONTEXT, BATCH_STEP_EXECUTION_CONTEXT 테이블은 오류가 발생한 경우 중단된 부분부터 시작할 수 있는 데이터를 포함합니다.

  • BATCH_JOB_EXECUTION_CONTEXT
  • BATCH_STEP_EXECUTION_CONTEXT

CONTEXT 에는 어떤 데이터가 포함되는지 확인하기 위해 실험을 진행했습니다.

  • COMMIT 간격이 10인 상황
  • 20번 변경하는 시점에 오류 발생 시키기

결과를 확인했을 때, STEP_EXECUTION에는 다음 정보가 포함됩니다. WRITE_COUNT가 20번 째에 다다르기 전, 1회 롤백 된 모습을 볼 수 있습니다.

STATUSCOMMIT_COUNTREAD_COUNTFILTER_COUNTWRITE_COUNTROLLBACK_COUNT
FAILED45021191

CONTEXT에는 다음처럼 롤백 되기 전 몇 번째까지 읽었는지 확인할 수 있습니다.

직렬화 값 : rO0ABXNyABFqYXZhLnV0aWwuSGFzaE1hcAUH2sHDFmDRAwACRgAKbG9hZEZhY3RvckkACXRocmVzaG9sZHhwP0AAAAAAAAx3CAAAABAAAAAEdAARYmF0Y2gudGFza2xldFR5cGV0AD1vcmcuc3ByaW5nZnJhbWV3b3JrLmJhdGNoLmNvcmUuc3RlcC5pdGVtLkNodW5rT3JpZW50ZWRUYXNrbGV0dAAfTUVNQkVSX0NPVVBPTl9SRUFERVIucmVhZC5jb3VudHNyABFqYXZhLmxhbmcuSW50ZWdlchLioKT3gYc4AgABSQAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoaslR0LlOCLAgAAeHAAAAAodAANYmF0Y2gudmVyc2lvbnQABTUuMS4xdAAOYmF0Y2guc3RlcFR5cGV0ADdvcmcuc3ByaW5nZnJhbWV3b3JrLmJhdGNoLmNvcmUuc3RlcC50YXNrbGV0LlRhc2tsZXRTdGVweA==

역직렬화된 객체: HashMap
크기: 4
내용: {
  batch.taskletType=org.springframework.batch.core.step.item.ChunkOrientedTasklet,
  MEMBER_COUPON_READER.read.count=40,
  batch.version=5.1.1,
  batch.stepType=org.springframework.batch.core.step.tasklet.TaskletStep
}

재시도 로직을 추가했을 때 롤백 된 시점부터 읽을 수 있도록 CONTEXT에 정보가 추가되고, CONTEXT 정보 덕분에 재시도 할 수 있어서 롤백 2회 발생했지만 성공 상태로 끝난 STEP_EXECUTION을 확인할 수 있었습니다.

STATUSCOMMIT_COUNTREAD_COUNTFILTER_COUNTWRITE_COUNTROLLBACK_COUNT
COMPLETED232211111102

트랜잭션을 보장한다.

트랜잭션 특성을 보장해야 작업에 대한 안정성을 높일 수 있습니다. 우리는 흔히 ACID를 기준으로 고민하게 됩니다. 배치는 다음 상황에서 트랜잭션을 보장합니다.

  • 작업에 대한 트랜잭션이 일부 보장된다.
  • 커밋 간격 트랜잭션이 보장된다.

작업은 트랜잭션을 보장하기 위해 배치 이력을 SERIALIZABLE로 기록합니다. 그래서 동일한 작업이 실행되도 하나만 실행됨을 보장합니다.

동일한 작업을 시도하는 경우 하나만 성공하지만 먼저 성공한 이력을 저장하는 특성을 가집니다.

트랜잭션이 일부 보장된다고 설명한 이유는 우리가 작성한 코드로 발생하는 문제입니다. 동일한 작업은 수행하지 않지만 동일한 테이블에 작업하지 않는다고 설명하지 않습니다. 배치 작업 간에 교환 법칙이 성립하도록 구현해야 합니다. ( 사실 동일한 테이블 수정에 대한 동시 작업을 수행하지 않도록 제어하는게 간단한 방법이긴 합니다. )

배치는 커밋 간격으로 트랜잭션을 보장하기도 합니다. 실행한 작업은 데이터베이스에 반영되고 남은 작업은 롤백됩니다. 배치 메타 데이터에서는 얼만큼 성공(커밋)했고 롤백 됐는지 확인할 수 있습니다.

STATUSCOMMIT_COUNTREAD_COUNTFILTER_COUNTWRITE_COUNTROLLBACK_COUNT
COMPLETED232211111102

작업 일부가 실패한 경우 작업을 재시도(retry)하거나 재실행(restart) 해야 할 수 있습니다. 이 때 고려했던 결과와 다르지 않도록 멱등성을 고려해야 합니다.

만약 재실행 할 때 고려한 결과와 다르다면 재실행을 막는 기능(preventRestart)을 추가할 수 있습니다.

커밋 단위가 10개라면 10개 중 하나 실패했는데 남은 작업까지 실패하는게 마음에 안든다면 실패한 아이템을 제외한 남은 아이템을 커밋할 수 있는 기능도 제공하며 트랜잭션에 대비해 여러 기능을 제공하고 있습니다.

직접 구현한 배치에서 스프링 배치로 변경할 이유

단순 작업은 그냥 자바 코드로 배치를 구현하는게 간단하지 않을까? 라고 생각할 수 있습니다. 스프링 배치가 구현은 어렵지만 단순하게 관리할 수 있습니다.

장점

업무 관점에서 변경할 경우 장점은 다음과 같습니다.

  • 이력 관리 원활
  • 운영 및 관리 비용 없음
  • 커뮤니케이션 비용 절감

단점

업무 관점에서 변경할 때 단점도 존재합니다. 단점과 해결책을 제시해보겠습니다.

  • 러닝 커브 존재한다. → 샘플 코드와 문서를 정리하자.

러닝 커브 부담을 줄이기 위해 샘플 코드를 두 개 준비했었습니다. 첫 번째는 간단한 작업인 1 에서 100 까지 숫자를 읽고, 짝수만 추출한 다음, 추출한 데이터를 콘솔에 출력하도록 구현했습니다. 두 번째는 실무에 사용할 배치 서비스를 구현해 운영 환경에 접근하고 추출한 다음, DB 업데이트까지 구현했습니다.

  • 구현 비용이 올라간다. → 템플릿 코드를 미리 구성한다.

구현 비용을 줄이기 위해 깃 허브 템플릿 코드를 미리 구성해 쉽게 끌어다 쓸 수 있도록 구현했습니다. 작업자는 Reader, Writer, Processor 만 구현하면 됩니다.

  • 관리 프로세스가 달라진다. → 워크 플로우를 미리 설계해서 공유하자.

스프링 배치로 마이그레이션하면서 가장 큰 걱정은 프로세스가 달라지는 문제입니다. 프로세스 변경으로 인해 업무를 받는 방식과 결과를 공유하는 방식이 달라지면서 과도기처럼 운영 이슈가 잦아질텐데 대응할 수 있도록 워크 플로우를 미리 설계할 수 있도록 신경썼습니다.

결론

결론은 스프링을 활용해 배치 관리에 장점을 높이고 구현 비용을 줄여나가야 합니다.

  • 템플릿 코드에 샘플 코드를 추가해 러닝 커브와 구현 비용을 줄인다.
  • 이력이 남지 않아도 되는 경우 스프링 배치를 사용하지 않아도 된다.
  • 이력이 남아야 하는 경우 템플릿 코드를 이용해 빠르게 구현한다.

잘 정리하고 장점도 잘 어필한 덕분에 팀내 배치 마이그레이션을 진행하게 됐습니다. 배치를 도입한 만큼 배치로 변경했을 때 문제가 없을지 고민해야 했고 필요한 동작 설명이 함께 탄생하게 됐습니다.