영호

스탬프 중복 적립 개선기 본문

Spring

스탬프 중복 적립 개선기

0h0 2024. 2. 23. 00:24

들어가며,

전에 참여했던 카페 쿠폰 서비스의 쿠폰에 스탬프를 적립하는 로직에서 동시성 이슈를 발견하여 이를 해결하기 위해 고민한 과정을 정리하려고 합니다.

현재 문제점

방어로직이 없다

스탬프 적립 요청 시 {어느 쿠폰}{몇 개의 스탬프}를 적립 할지, 인증 토큰에 대한 인자만 받고 있습니다. 이로 인해, 네트워크 지연 등에 대한 재시도로 인해 동일한 쿠폰에 대한 스탬프 적립 요청이 2번 들어오면 2배의 스탬프가 적립됩니다. 즉, 중복 적립 문제가 발생합니다.

기존 API  대략적인 구조

POST coupon/{couponId}

BODY
int: stampCount

원하는 결과

흔히 말하는 동시성, 따닥 이슈 발생 시 하나의 요청만 유효하게 처리하길 원했습니다. 이를 만족하기 위해 어떤 고민을 했는지 작성해보겠습니다.

결론

결론부터 말하자면, api 스펙 변화 + 분산락을 통해 스탬프 중복 적립 문제를 해결했습니다.

변경 후 API 구조

POST coupon/{couponID}

BODY
int: stampCount
int: currentAccumulatedStamp

스탬프 적립 관련 엔티티 (Coupon, Stamp)

Coupon 과 Stamp 엔티티는 1 : N 관계로 이루어져 있습니다. 이렇게 설계한 이유는 스탬프 적립 내역을 보여주기 위함입니다.

Coupon

@Entity
public class Coupon extends BaseDate {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "cafe_id")
    private Cafe cafe;

    @OneToMany(mappedBy = "coupon", fetch = LAZY, cascade = CascadeType.PERSIST)
    private List<Stamp> stamps = new ArrayList<>();

		// 스탬프 적립 메서드
    public void accumulate(int earningStampCount) {
        for (int i = 0; i < earningStampCount; i++) {
            Stamp stamp = new Stamp();
            stamp.registerCoupon(this);
        }
        if (cafePolicy.isSameMaxStampCount(stamps.size())) {
            status = CouponStatus.REWARDED;
        }
    }
}

Stamp

@Entity
public class Stamp extends BaseDate {

    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "coupon_id")
    private Coupon coupon;

    public void registerCoupon(Coupon coupon) {
        this.coupon = coupon;
        coupon.getStamps().add(this);
    }
}

코드로 보는 문제점

Service 로직

아래는 스탬프 적립 시 호출되는 service 로직입니다. 스탬프 적립에 필요한 부분만 간단하게 작성했습니다. 적립하려는 coupon 이 존재하는지 확인하고 존재한다면 coupon 에 스탬프를 적립합니다.

 

현재 로직을 보면 쿠폰의 존재유무만 보고 존재한다면 바로 스탬프 적립로직을 수행합니다. 이로 인해 네트워크 지연 등의 이유로 동일한 쿠폰에 대한 스탬프 적립 요청이 동시에 온다면 그대로 전부 다 적립됩니다.

@RequiredArgsConstructor
@Transactional
@Service
public class ManagerCouponCommandService {

	private final CouponRepository couponRepository;	

	public void createStamp(StampCreateDto stampCreateDto) {
				Coupon coupon = couponRepository.findById(stampCreateDto.getCouponId())
                .orElseThrow(IllegalArgumentException::new);
        // 쿠폰 존재 시 바로 스탬프 적립
        int earningStampCount = stampCreateDto.getEarningStampCount();

        coupon.accumulate(earningStampCount);       
    }
}
@Getter
@RequiredArgsConstructor
public class StampCreateDto {

    private final Long ownerId;
    private final Long customerId;
    private final Long couponId;
    private final Integer earningStampCount;
}

중복 적립 해결 방법

api 의 request 스펙에 현재 쿠폰에 적립되어 있는 스탬프 개수를 받고, service 로직에서 이를 검증하는 로직을 추가하면 해결 될듯 합니다.

하지만 이 코드도 아직 동시성 문제가 있어보입니다. 테스트를 통해 확인해보겠습니다.

@RequiredArgsConstructor
@Transactional
@Service
public class ManagerCouponCommandService {

    private final CouponRepository couponRepository;	

    public void createStamp(StampCreateDto stampCreateDto) {
        Coupon coupon = couponRepository.findById(stampCreateDto.getCouponId())
        .orElseThrow(() -> new IllegalArgumentException("coupon not found"));

        // 쿠폰에 적립되어 있는 스탬프 개수 비교
        if (coupon.getStampCount() != stampCreateDto.getCurrentStampCount()) {
            throw new IllegalArgumentException("incorrect stamp count");
        }
        int earningStampCount = stampCreateDto.getEarningStampCount();
        coupon.accumulate(earningStampCount);       
    }
}
@Getter
@RequiredArgsConstructor
public class StampCreateDto {

    private final Long ownerId;
    private final Long customerId;
    private final Long couponId;
    private final Integer earningStampCount;
}

순차 요청 테스트

스탬프가 0개인 쿠폰에 2개의 스탬프를 적립하려는 요청이 순차적으로 2번 발생하는 상황입니다. 제가 원하는 결과는 하나의 요청만 처리되어 2개의 스탬프가 적립되는 것입니다.

@Test
void 스탬프를_적립한다() throws InterruptedException {
    // given
    Long couponId = 쿠폰_생성_요청하고_아이디_반환();
    
    // when
    int accumulatingStampCount = 2;
    int currentStampCount = 0;
		
    // 쿠폰에 쌓을 스탬프 개수, 현재 쿠폰에 적립된 스탬프 개수
    StampCreateRequest stampCreateRequest = new StampCreateRequest(accumulatingStampCount, currentStampCount);

    for (int i = 0; i < 2; i++) {
         쿠폰에_스탬프를_적립_요청(couponId, stampCreateRequest);
    }

    // then
    CustomerAccumulatingCouponFindResponse coupon = 고객의_쿠폰_조회하고_결과_반환(customerId, couponId);

    assertThat(coupon.getStampCount()).isEqualTo(2);
}

결과

기대한대로 2개가 적립되었고, 스탬프 개수가 일치하지 않아 예외가 발생한것을 예외 메시지를 통해 확인할 수 있습니다.

동시 요청 테스트

이번엔 순차 테스트와 원하는 결과는 같지만 요청이 동시에 온다는 차이점이 있습니다.

0개가 적립되어 있는 쿠폰에 2개의 스탬프를 적립하려는 요청 2개가 동시에 들어온 상황입니다.

@Test
void 스탬프를_적립한다() throws InterruptedException {
    // given
    Long couponId = 쿠폰_생성_요청하고_아이디_반환();
    // when
    int accumulatingStampCount = 2;
    int currentStampCount = 0;
		
    // 쿠폰에 쌓을 스탬프 개수, 현재 쿠폰에 적립된 스탬프 개수
    StampCreateRequest stampCreateRequest = new StampCreateRequest(accumulatingStampCount, currentStampCount);

    ExecutorService executor = Executors.newFixedThreadPool(2);
    CountDownLatch countDownLatch = new CountDownLatch(2);

    for (int i = 0; i < 2; i++) {
        executor.submit(() -> {
            try {
                쿠폰에_스탬프를_적립_요청(couponId, stampCreateRequest);
            }finally {
                countDownLatch.countDown();
            }
        });
    }
    countDownLatch.await();

    // then
    List<CustomerAccumulatingCouponFindResponse> coupons = 고객의_쿠폰_조회하고_결과_반환(ownerToken, savedCafeId, customerId);
    CustomerAccumulatingCouponFindResponse coupon = coupons.get(0);

    assertThat(coupon.getStampCount()).isEqualTo(10000);
}

결과

기대와 다르게 4개가 적립되었습니다.

케이스 2

이번엔 좀 더 확실하게 10개의 동시 요청을 보내보겠습니다. 동일한 테스트 코드지만 어쩔때는 18개가 적립되었고, 어쩔때는 20개가 적립됩니다.

동시 요청 시 흐름

그림에서의 빨간 박스 부분을 잘 살펴봐야합니다.

 

Thread1 의 트랜잭션이 COMMIT 되기 전에 Thread2 의 트랜잭션이 Coupon의 Stamp 개수를 읽기 때문에 2개의 스레드 모두 쿠폰의 스탬프 개수를 0으로 읽게 됩니다.

이로 인해, 2개의 스레드 모두유효성 검사를 통과하게 됩니다.

동시성 문제 해결 방법

1. 트랜잭션 격리수준을 READ_COMMITED 로 낮춘다. (X)

트랜잭션 격리수준을 낮추더라도 Thead1 이 insert 하기 전에 Thread2 에서 쿠폰의 스탬프 개수를 조회한다면 소용이 없습니다.

2. 낙관적 락 (X)

낙관적 락의 경우 version 을 통해 동시성 문제를 관리합니다. 그러나 현재 저희의 구조는 Coupon 에서 컬럼으로 stamp 개수를 관리하는 것이 아닌 별도의 Stamp 엔티티와 1 : N 연관관계로 stamp 를 관리하고 있습니다.

스탬프 적립 시 Stamp 테이블에 insert 가 발생하기 때문에 버전을 통해 관리하는 낙관적 락을 적용하기 여러운 구조입니다.

3. 비관적 락 (X)

비관적 락의 경우 엔티티를 조회할 때 for update 구문을 추가하여 레코드의 인덱스에 X-lock 을 획득하여 동시성 문제를 관리합니다.

 

사실 비관적 락으로, 현재 구조에서 동시성 문제를 해결할 수 있다고 생각했습니다. 그러나 결과적으론 보장하지 못하는데요. 그 이유는 MySQL 의 REPEATABLE READ 격리 수준에서는 팬텀 리드가 발생하지 않기 때문입니다.

 

아래 그림의 빨간 박스에서 Thread 2의 트랜잭션이 시작됩니다. MySQL 의 REPEATABLE_READ 에서는 MVCC 에 의해 트랜잭션 시작 시점의 데이터에서 값을 조회합니다. 이로 인해, Thread 1 에서 stamp 를 insert 하고 commit 하더라도 Thread 2 의 트랜잭션 안에서는 stamp 의 개수가 0개로 조회됩니다. 그래서 스탬프 적립 로직의 유효성 검증을 통과하면서 스탬프가 적립됩니다.

 

 

이러한 문제로 인해 현재 stamp 를 insert 하여 적립하는 구조에서는 비관적 락으로도 동시성 문제를 해결하지 못합니다. 물론 트랜잭션 격리수준을 READ_COMMITED 로 낮추면 가능하지만, 팬텀 리드가 발생하기 때문에 선택하지 않았습니다.

CouponRepository

public interface CouponRepository extends Repository<Coupon, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Coupon> findById(Long id);
}

테스트 결과

4. 분산 락 (O)

현재 동시성 문제를 해결하기 위해선 아래 작업들의 동시성을 보장해줘야 합니다.

  1. Coupon 조회
  2. Coupon 에 적립되어 있는 stamp 개수 조회 (stamp 테이블 접근)
  3. stamp 적립

즉, 여러 테이블에 접근하는 작업에 대한 동시성을 보장하기 위해 MySQL 의 NamedLock 을 활용했습니다.

분산락을 활용한 전체적인 흐름은 아래와 같습니다.

  1. NamedLock 획득
  2. Coupon 조회
  3. Coupon 에 적립되어 있는 stamp 개수 조회 (stamp 테이블 접근)
  4. stamp 적립
  5. NamedLock 반환

분산락 관련 코드는 우아한형제들 기술블로그를 참고 했습니다.

그래서 최종 적인 service 코드는 아래와 같습니다.

@RequiredArgsConstructor
public class CouponCommandFacadeService {

    private final NamedLockService namedLockService;
    private final ManagerCouponCommandService managerCouponCommandService;

    public void createStamp(StampCreateDto stampCreateDto) {
        namedLockService.executeWithLock(
                "create_stamp" + stampCreateDto.getCouponId(), 
                3000, 
                () ->  managerCouponCommandService.createStamp(stampCreateDto)
        );
    }
}

 

테스트 결과

마무리

현재 코드 구조를 유지하는 선에서 동시성 이슈를 해결하기 위해 다양한 고민을 해보고 해결 해봤습니다.

 

Comments