본문 바로가기

카테고리 없음

복권 발급시 성능과 데이터 일관성을 모두 지키기 위한 방법

기존 설계의 문제

아이템 획득시 특정 등급의 아이템을 모두 획득하면 복권이 발급된다. 따라서 아이템 획득시 복권 발급 조건을 충족했는지 확인한 후, 충족할 경우 Redis stream 메시지를 발행한다.

@RestController
@RequiredArgsConstructor
public class GachaController {
    
    @PostMapping("/gacha")
    public ApiResponse<GachaResponse> gacha(HttpServletRequest request) {
        User user = userService.readUserById(jwtUtils.getUserIdFromHeader(request));
        Item addedItem = itemService.gacha(user); // 아이템 저장
        lottoProcessor.checkAndPublishLotteryEvent(user, addedItem); // 복권 발급 조건 확인 후 레디스 메시지 발행
        return ApiResponse.success(GachaResponse.of(addedItem, itemsImageApiEndpoint));
    }
}

 

그리고 성능을 위해 다음과 같이 복권 발급 조건 충족 여부를 확인하는 로직과 메시지를 발행하는 로직비동기로 처리했다.

@Component
@RequiredArgsConstructor
public class LottoProcessor {

    @Async // 비동기 처리
    public void checkAndPublishLotteryEvent(User user, Item addedItem) {
        Map<Item, Long> userItemsMap = userItemRepository.findByUserId(user.getId())
                .stream()
                .filter(userItem -> userItem.getItem().getItemGrade() == addedItem.getItemGrade())
                .collect(Collectors.groupingBy(
                        userItem -> userItem.getItem(),
                        Collectors.counting()
                ));

        boolean hasAllItemsOfGrade = Item.getItemsByGrade(addedItem.getItemGrade())
                .stream()
                .allMatch(item -> userItemsMap.getOrDefault(item, 0L) > 0);

        boolean addedItemIsOne = userItemsMap.getOrDefault(addedItem, 0L) == 1;

        if (hasAllItemsOfGrade && addedItemIsOne) {
            lottoMessagePublisher.publishLottoIssuanceEvent(user.getId(), addedItem.getItemGrade());
        }
    }

 

@Slf4j
@Component
@RequiredArgsConstructor
public class LottoMessagePublisher {

    public void publishLottoIssuanceEvent(long userId, ItemGrade itemGrade) {
        LottoIssuanceEvent lottoIssuanceEvent = new LottoIssuanceEvent(userId, itemGrade.getViewName());
        ObjectRecord<String, LottoIssuanceEvent> record = StreamRecords.newRecord()
                .ofObject(lottoIssuanceEvent)
                .withStreamKey(streamKey);
        RecordId recordId = redisTemplate.opsForStream().add(record);
        log.info("Redis Stream publish. stream = {}, record id = {}, message = {}", streamKey, recordId, lottoIssuanceEvent.toString());
    }
}

 

그런데 이렇게 설계할 경우, 아이템 획득에 성공(커밋)했는데, 레디스 장애로 stream 메시지 발행에 실패할 수 있다. 즉, 비즈니스 로직의 실행과 메시지 발행의 원자성이 보장되지 않는다.

 

원자성 보장을 위해 아웃박스 패턴을 적용할 수 있다. 그러나 아웃박스 패턴을 적용하면, 다음 작업들이 모두 하나의 트랜잭션으로 묶이게 된다.

  1. 아이템 저장
  2. 복권 발급 조건 확인 후 레디스 메시지 발행 

따라서 사용자 아이템이 추가될 때마다 복권 발급 조건 확인 후 메시지 발행하는 로직이 함께 수행되며 응답 속도가 떨어진다. 그렇다고 2번 작업을 비동기로 처리하면, 하나의 트랜잭션으로 묶이지 않는다.

설계 변경

따라서 설계를 다음과 같이 변경했다.

  1. 메인 서버는 아이템 저장을 한 후 바로 레디스 메시지를 발행한다. (이때 아웃박스 패턴을 적용하여 비즈니스 로직의 실행과 메시지 발행의 원자성을 확보한다.)
  2. 메시지를 소비한 복권 서버가 복권 발급 조건 충족 여부를 확인한다.

즉, 복권 발급 조건 충족 여부를 확인하는 작업을 메인 서버에서 복권 서버로 이동시켰다.

 

이전에는, 복권 발급 조건 충족 여부를 메인 서버에서 확인한 후, 충족된 경우에만 레디스 메시지를 발행하는 것이 더 효율적이라고 생각했다. 즉, 필요한 경우에만 메시지를 발행하는 것이 서로 다른 서버 간 네트워크 통신을 줄일 수 있기 때문이다.

 

그러나 애초에 복권 발급 로직을 비동기로 처리하고 싶어서 별도의 복권 서버를 둔 것인데, 정작 복권 발급 조건 충족 여부를 확인하는 작업을 메인 서버에서 하는 것이 문제인 거 같다. 복권 발급시에만 메시지를 발행해 효율적인 방법이라고 할지라도, 복권 발급 조건 충족 여부를 확인하는 작업을 메인 서버에서 수행하면 결국 메인 서버의 CPU를 사용하는 것이므로.. 복권 관련 책임을 모두 복권 서버로 이동시키는 것이 더 좋은 설계라고 생각한다.

 

따라서 최종적인 흐름은 다음과 같다.

  1. 메인 서버는 아이템 획득시 아웃박스 패턴을 통해 레디스 메시지를 발행한다.
  2. 복권 서버는 메시지를 소비한 후, RDB에 접근해 로또 발급 조건이 충족되었는지 확인한다.
  3. 복권 발급 조건이 충족되면, 복권을 발급하여 RDB에 저장하고, 레디스 메시지를 발행하여 메인 서버에게 알린다.
  4. 메인 서버는 메시지를 소비한 후 발급된 복권에 대해 알림을 처리한다. (SSE, RDB에 알림 내역 저장)

그런데 이때 추가적으로 주의할 점이 있다.

 

첫째로, 복권 발급 후 RDB에 저장하는 로직과 복권 발급을 레디스로 알리는 작업에 대해 원자성이 보장되어야 한다. 그렇지 않으면 다음과 같은 문제가 발생할 수 있다.

  • 복권 발급 시 롤백되어 복권이 발급되지 않았는데, 레디스로 알리게 되면 메인 서버는 없는 복권을 사용자에게 알리게 된다.
  • 반면, 복권 발급에 커밋으로 성공했는데, 레디스 장애로 메시지 발행에 실패하면, 메인 서버는 발급된 로또의 존재를 알지 못한다.

따라서 이 경우에도 아웃박스 패턴을 적용했다.

 

둘째로, 복권 발급 요청이 연속적으로 요청될 때 동시성 제어를 해야 한다. 예를 들어 다음 문제가 발생할 수 있다.

  1. S등급의 아이템이 4개라고 가정할 때, 사용자가 3번째 아이템까지 획득한 후 레디스 메시지 1을 발행했다.
  2. 이어서 4번째 아이템까지 획득한 후 레디스 메시지 2를 발행했다.
  3. 복권 서버는 메시지 1을 소비한 후 RDB를 확인하는데, 이때 이미 4번째 아이템까지 획득한 상태(=S등급의 아이템을 모두 획득한 상태)라면, 복권 서버는 복권을 발행한다.
  4. 이어서 복권 서버는 메시지 2를 소비한 후 RDB를 확인하는데, 이때도 S등급의 아이템을 모두 획득한 상태이므로 복권을 발행한다.
  5. 즉, S등급의 아이템을 모두 획득하여 복권이 1번만 발행되어야 하는데 총 2번 발행된다.

따라서 이러한 문제를 방지하기 위해 복권 엔티티에 '사용자 id'와 '아이템 등급'을 유니크 제약 조건으로 걸어 동시성을 제어했다. 즉, 이미 발급될 복권을 중복 발급할 경우, RDB에 INSERT할 때 예외가 발생하여 롤백되고, 이때 아웃박스 패턴으로 메시지 발행과의 원자성이 확보되므로 Redis에도 메시지가 발행되지 않는다.