Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

쿠폰을 잘못 발급해서 일부 쿠폰은 바로 소멸시키고 새로운 쿠폰으로 교체한다. #28

Conversation

this-is-spear
Copy link
Owner

@this-is-spear this-is-spear commented Jul 21, 2024

Summary

쿠폰을 교체합니다.

Description

해당 작업에서는 사용하지 않은 쿠폰만 새로운 쿠폰으로 교체하도록 구현했습니다. 기존 쿠폰 상태는 EXPIRED 상태로 변경해 더 이상 사용할 수 없게 수정하면 다음과 같은 시나리오에서 문제가 발생합니다.

  • EXPIRED 상태로 변경되기 전 쿠폰을 사용하려 할 때
  • EXPIRED 상태이지만 아직 쿠폰을 교체하지 못했을 때(EXPIRED 상태인 쿠폰을 사용하려 할 때)
  • EXPIRED 상태이며 쿠폰을 교체했을 때

EXPIRED 상태로 변경되기 전 쿠폰을 사용하려 할 때는 사용할 수 있어야 하고, EXPIRED 상태인 쿠폰을 사용하려 할 때는 사용되어서는 안됩니다.

  • EXPIRED 상태의 쿠폰을 사용하려 할 때, 예외 메시지를 전달해 안내문구를 받을 수 있으면 어떨까?
  • 화면에서는 어떤 예외 메시지를 받으면 안내문구를 출력할 수 있게 구현할 수 있을까?
  • 쿠폰이 교체되면 사용자에게 어떻게 알림을 보내야할까?
  • 쿠폰이 교체되면 사용자에게 어떤 알림을 보내야할까?

Verification

  • 교환할 사용자 수와 교환된 사용자 수가 같아야 한다.
  • 교환 후 교환 대상 쿠폰을 가진 사용자는 없어야 한다.

아쉬웠던 점

Simplify as much as possible and avoid building complex logical structures in single batch applications.

복잡한 구조를 피하라는데, 어떤 방식을 의미하는걸까? 고민했을 때 실행 흐름 기반으로 구성해 단순성을 높일 필요가 있어보였다. 그러다보니 단위 테스트 작성이 어려워졌다. 좋은 방법이 없을까?

…-발급해서-일부-쿠폰은-바로-소멸시키고-새로운-쿠폰으로-교체하고-싶어졌음
Comment on lines +26 to +28
val memberResponse = memberClient.findMemberById(memberCoupon.memberId)
val couponNameAfterExchange = couponService.findCouponById(exchangedCouponIdStorage.couponIdAfterExchange).name
val couponNameBeforeExchange = couponService.findCouponById(expiredCouponId).name
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reader에게 책임 넘기기.

import org.springframework.context.annotation.Profile

@Configuration
@Profile("!test")
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

테스트를 위한 소스 코드 없애기

Comment on lines +22 to +24
override fun getTransactionManager(): PlatformTransactionManager {
return transactionManager
}
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

배치 데이터베이스와 쿠폰 데이터베이스가 분리되면 트랜잭션 매니저는 어떻게 관리해야 할까?

Copy link
Owner Author

@this-is-spear this-is-spear Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래에서 확인했듯이 버전으로 실행 이력을 관리한다. Job 정보가 Serializable하게 생성되면 Job 식별자에 맞게 개별적으로 Step 실행 정보가 생성된다.

Step에서 청크 사이즈나 병렬 처리에도 정상적인 데이터를 유지할 수 있는 이유는 Step에서 실행되는 버전 정보 덕분이다. 커밋 단위로 버전이 업데이트되므로 순차적으로 Step 실행 이력을 저장할 수 있다. 일반적으로 Step의 커밋 수 + 2 가 버전 정보다. 아마 처음 실행되면서 +1 마지막 종료되면서 +1 로 추가 카운팅이 되는 것처럼 보인다.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⭐ 다시 확인해보니 트랜잭션을 걸고 있다. AbstractJobRepositoryFactoryBean 에서 getObject 메서드를 호출하는 시점에 트랜잭션을 걸게 된다. Job 정보는 JobRepository에 의해서 관리되고 JobRepository 내부 정보는 AbstractJobRepositoryFactoryBean 에서 getObject 로 호출해서 변경된다.

exchangeCouponProcessor: ItemProcessor<MemberCoupon, MemberCoupon>,
updateMemberCouponWriter: JpaItemWriter<MemberCoupon>,
) = StepBuilder(EXCHANGE_COUPON, jobRepository)
.chunk<MemberCoupon, MemberCoupon>(chunkSizeProperty.commitSize, transactionManager)
Copy link
Owner Author

@this-is-spear this-is-spear Jul 22, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TransactionManager가 영향을 주는 건 처리하는 데이터 말고도 Job Repository에 저장되는 데이터도 포함이 되는건가?

-> 그렇지 않다. jobRepository에는 EnableBatchProcessing 에서 설정된 트랜잭션 매니저를 사용한다.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

그럴 수도 있고 그렇지 않을 수도 있다. 주석에 따르면 청크 기반 작업을 처리하기 위해 필요한 트랜잭션 매니저를 구성하는 방법이다.

	/**
	 * Build a step that processes items in chunks with the size provided. To extend the
	 * step to being fault tolerant, call the {@link SimpleStepBuilder#faultTolerant()}
	 * method on the builder. In most cases you will want to parameterize your call to
	 * this method, to preserve the type safety of your readers and writers, e.g.
	 *
	 * <pre>
	 * new StepBuilder(&quot;step1&quot;).&lt;Order, Ledger&gt; chunk(100, transactionManager).reader(new OrderReader()).writer(new LedgerWriter())
	 * // ... etc.
	 * </pre>
	 * @param chunkSize the chunk size (commit interval)
	 * @param transactionManager the transaction manager to use for the chunk-oriented
	 * tasklet
	 * @return a {@link SimpleStepBuilder}
	 * @param <I> the type of item to be processed as input
	 * @param <O> the type of item to be output
	 * @since 5.0
	 */
	public <I, O> SimpleStepBuilder<I, O> chunk(int chunkSize, PlatformTransactionManager transactionManager) {
		return new SimpleStepBuilder<I, O>(this).transactionManager(transactionManager).chunk(chunkSize);
	}

spring-projects/spring-batch#3950 해당 이슈를 보면 내부 동작을 더 잘 확인할 수 있다.

배치 관련 이력은 트랙잭션에 포함되지 않고 저장된다. 그다음 트랜잭션을 열어 작업을 실행한다. 작업 도 중 컨텍스트를 조회한다면 트랜잭션 내부에서 조회하는 일이기 때문에 트랜잭션에 포함된다.

작업 관련 외부 쿼리에서 동시성을 제어하기 위해 버전을 활용하며 JobExecution 의 synchronizeStatus를 이용해 버전을 관리한다. 관련 키워드도 함께 정리한다.

  • JobExecution : 작업 정보가 포함된다. Job 식별자 등등..
  • ExecutionContext : 작업이 어디까지 실행됐는지 등 ItemStream에서 활용하기 위해 저장된다.
  • ItemStream : 오류가 발생할 경우 상태를 주기적으로 저장하고 해당 상태에서 복원하기 위한 계약을 정의하는 마커 인터페이스다.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⭐ 다시 확인해보니 트랜잭션을 걸고 있다. AbstractJobRepositoryFactoryBean 에서 getObject 메서드를 호출하는 시점에 트랜잭션을 걸게 된다.

Comment on lines +77 to +90
@Bean
fun alimTalkStep(
jobRepository: JobRepository,
transactionManager: PlatformTransactionManager,
chunkSizeProperty: ChunkSizeProperty,
memberCouponReader: JpaCursorItemReader<MemberCoupon>,
createExchangeNotificationTemplateProcessors: ItemProcessor<MemberCoupon, CouponExchangeNotificationTemplate>,
notificationWriter: ItemWriter<CouponExchangeNotificationTemplate>,
) = StepBuilder(EXCHANGE_COUPON_ALIM_TALK, jobRepository)
.chunk<MemberCoupon, CouponExchangeNotificationTemplate>(chunkSizeProperty.commitSize, transactionManager)
.reader(memberCouponReader)
.processor(createExchangeNotificationTemplateProcessors)
.writer(notificationWriter)
.build()
Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

작업이 일부 실패된 경우 실패한 지점부터 실행하려면 어떻게 해야할까?

Copy link
Owner Author

@this-is-spear this-is-spear Aug 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

알림 같은 경우 두 번 이상 전송되면 서비스 가치가 떨어진다고 생각한다. 이러한 API는 멱등성을 지킬 필요가 있는데 멱등성을 어디에서 지켜야 하는지를 고민해볼 수 있다.

  1. 배치에서 멱등성 지키기
  2. 알림 API에서 멱등성 지키기

선택 기준은 멱등성 지킨 알림 API 재활용 가능성 여부라고 생각한다. 재활용이 높다면 알림 API에서 관리하는 것도 방법이라 생각한다.

그런데 만약 배치에서 멱등성을 지키려면 다음처럼 수작업이 필요해보인다.

  • retry로 실패 상황 최소화
  • skip 후 재처리 진행

아니면 테이블을 만들어서 날자와 사용자 정보 그리고 컨텐츠로 동일한 메시지를 보냈는지 확인해보는 것도 방법이다.

@this-is-spear this-is-spear merged commit e8ba1de into main Aug 27, 2024
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
1 participant