티스토리 뷰

728x90
반응형

왜 Redis Sorted Set으로 대기열을 구현했는가?


  • 여러 아티클을 통해 redis의 sorted set은 하나의 key에 중복되지 않는 meber를 가질 수 있다는 것을 알게되었다. 이는 카카오 선물하기 구조에서 안성맞춤이라고 생각한다. 그 이유는 하나의 선물(key)에 다수의 memer(참여자) 구조로 갈 수 있다는 것을 의미한다.
  • N개의 요청이 발생했을 때 score를 통해 접근 순위를 파악할 수 있다.
  • 런닝 커브가 낮다

 

카카오 선물하기 대기열의 구조


구조

 

💡 게임 주최자의 입장

 

  1. 게임 주최자는 특정 오픈 채팅방에 선물하기 게임을 시작합니다.
  2. 선물의 정보와 redis에 저장할 키를 발급하고 데이터 베이스에 함께 정보를 저장합니다.
  3. 발급 받은 키를 사용하여 redis에 선물 갯수를 저장합니다.
  4. 오픈 채팅방에 선물하기 게임이 시작됩니다.

 

💡 게임 참여자의 입장

 

  1. 게임 참여자의 요청을 받은 서버는 redis에 정보를 보냅니다.
  2. 게임 참여자의 요청은 대기열에 순서대로 쌓이게 됩니다.
  3. 스케줄러가 1초마다 대기열에서 10개씩 정보를 가져와 서버로 데이터를 전달합니다.
  4.  4, 5의 과정에서 redis로부터 전달받은 데이터를 데이터 베이스에 저장하고 선물의 갯수를 차감합니다.

 

코드로 알아보자.


💡 게임 주최자 입장의 코드

 

1. 게임 주최자는 특정 오픈 채팅방에 선물하기 게임을 시작합니다. 아래 컨트롤러는 게임 주최자가 선물하기 게임을 시작할 때 요청을 받는 컨트롤러입니다.

@RestController
@RequestMapping("/send-gift-box")
@RequiredArgsConstructor
public class SendGiftBoxApi {

    private final RedisAddQueue redisAddQueue;
    private final SendGiftBoxService sendGiftBoxService;

    @PostMapping
    public BaseResponse<SendGiftBoxCreateResponse> create(@Valid @RequestBody SendGiftBoxCreateRequest request) {
        String result = sendGiftBoxService.create(request.getMemberIdx(), request.getOpenRoomCode(), request.getGiftName(), request.getGiftQuantity());
        redisAddQueue.addGiftInformation(result, request.getGiftName(), request.getGiftQuantity());
        return new BaseResponse<>(CODE_201, new SendGiftBoxCreateResponse(result));
    }
}

 

2. 선물의 정보와 redis에 저장할 키를 발급하고 데이터 베이스에 정보를 저장합니다. redis에 저장하는 키는 SendGiftBox의 생성자 내부에서 생성됩니다. (궁금하신 분들은 깃허브 소스 코드를 참고해주세요.)
생성된 giftSerialCode를 redis의 키값으로 저장하기 위해 반환합니다.

@Service
@Transactional
@RequiredArgsConstructor
public class SendGiftBoxService {

    private final MemberDao memberDao;
    private final OpenRoomDao openRoomDao;
    private final SendGiftBoxRepository sendGiftBoxRepository;

    public String create(Long memberIdx, String openRoomCode, String giftName, int giftQuantity) {
        Member member = memberDao.findByIdx(memberIdx);
        OpenRoom openRoom = openRoomDao.findByCode(openRoomCode);

        SendGiftBox sendGiftBox = new SendGiftBox(member, openRoom.code(), giftName, giftQuantity);
        sendGiftBoxRepository.save(sendGiftBox);
        return sendGiftBox.getGiftSerialCode();
    }
}

 

3. 발급 받은 키를 사용하여 redis에 선물 갯수와 함께 저장합니다. 그리고 선물하기 게임이 시작됩니다.

/**
 * redis 대기열
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisAddQueue {

	// 코드 생략

    public void addGiftInformation(String giftSerialCode, String giftName, int giftQuantity) {
        ValueOperations<String, Object> operations = redisTemplate.opsForValue();
        operations.set(giftSerialCode, giftQuantity);

        log.info("{} 선물이 도착하였습니다. 참여코드 - {}", giftName, giftSerialCode);
    }
}

 

 

💡 게임 참여자 입장의 코드

 

1. 게임 참여자는 게임 주최자가 생성한 giftSerialCode를 가지고 서버로 요청을 보내게 됩니다. 
ReceivedGiftCreateRequest DTO에 giftSerialCode가 있습니다. 암튼! 요청받은 서버는 해당 요청을 redis로 보냅니다.

@RestController
@RequestMapping("/received-gift-box")
@RequiredArgsConstructor
public class ReceivedGiftBoxApi {

    private final RedisAddQueue redisAddQueue;

    @PostMapping
    public BaseResponse<Void> create(@Valid @RequestBody ReceivedGiftBoxCreateRequest request) {
        redisAddQueue.addQueue(request.getGiftSerialCode(), request.getMemberId(), request.getParticipationCode());
        return new BaseResponse<>(CODE_201);
    }
}

 

2. 요청을 전달받은 redis는 Sorted Set 자료 구조를 사용하여 대기열에 적재합니다.

/**
 * redis 대기열
 */
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisAddQueue {

    @SneakyThrows
    public void addQueue(String giftSerialCode, String memberId, String participationCode) {
        long timeMillis = System.currentTimeMillis();
        Event event = new Event(giftSerialCode, memberId, participationCode);

        redisTemplate.opsForZSet().add(event.secondKey(), event, timeMillis);
        log.info("대기열에 추가 - {} [{}]", event.getMemberId(), timeMillis);
    }
}

 

3. 스케줄러가 1초마다 돌면서 redis에서 정보를 꺼내오고, ApplicationEventPublisher를 사용하여 서버로 이벤트를 발송하게 됩니다.

또한 스케줄러는 게임 주최자가 선물한 선물의 갯수를 파악하면서 게임의 종료를 파악하고, 대기열에 남은 사람들의 순서를 알려줍니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class GiftScheduler {

    private static final long MIN = 0;
    private static final long MAX = 10;
    private static final long DELAY = 1000L; // 1s
    private static final String ALL_KEY = "*";
    private final RedisAddQueue redisAddQueue;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ApplicationEventPublisher publisher;

    @Scheduled(fixedDelay = DELAY)
    public void giftEventScheduler() {
        Set<byte[]> keys = redisTemplate.getConnectionFactory().getConnection().keys(ALL_KEY.getBytes());

        Iterator<byte[]> iterator = keys.iterator();
        while (iterator.hasNext()) {
            byte[] key = iterator.next();
            String giftSerialCode = new String(key, 0, key.length);

            if (giftSerialCode.contains(":")) {
                String secondKey = findSecondKey(giftSerialCode);
                Set<Object> events = redisTemplate.opsForZSet().range(secondKey, MIN, MAX);
                publisher.publishEvent(new ReceivedGiftCreatedEvent(events));
                redisAddQueue.order();
                verifyEndGame(iterator, giftSerialCode);
            }
        }
    }

    private String findSecondKey(String giftSerialCode) {
        String[] split = giftSerialCode.split(":");
        return split[1];
    }

    private void verifyEndGame(Iterator iterator, String giftSerialCode) {
        Object o = redisTemplate.opsForValue().get(giftSerialCode);
        if (o != null) {
            long quantity = Long.parseLong(o.toString());
            if (quantity <= 0) {
                log.info("선물하기 게임이 종료하였습니다. 남은 수량: "  + quantity);
                redisTemplate.delete(giftSerialCode);
                iterator.remove();
            }
        }
    }
}

 

4. 스케줄러를 통해 전달받은 데이터를 데이터 베이스에 저장하고 선물의 수량을 차감합니다. 또한 게임 당첨자의 정보를 redis에서 삭제하고 있습니다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class ReceivedGiftService {

    private final RedisTemplate redisTemplate;
    private final SendGiftBoxDao sendGiftBoxDao;
    private final ReceivedGiftBoxRepository receivedGiftBoxRepository;

    public void create(Set<Object> events) throws JsonProcessingException {
        for (Object event : events) {
            if (event instanceof Event) {
                Event e = (Event) event;
                String giftSerialCode = e.getGiftSerialCode();
                String memberId = e.getMemberId();
                String secondKey = e.secondKey();
                Optional<SendGiftBox> optionalSendGiftBox = sendGiftBoxDao.findByGiftSerialCode(giftSerialCode);

                optionalSendGiftBox.ifPresent(sendGiftBox -> {
                            if (sendGiftBox.hasAvailableQuantity()) {
                                sendGiftBox.decreaseAvailableQuantity();
                                ReceivedGiftBox receivedGiftBox = new ReceivedGiftBox(memberId, sendGiftBox);
                                receivedGiftBoxRepository.save(receivedGiftBox);
                                log.info("{} 님이 기프티콘에 당첨되었습니다.", e.getMemberId());
                                decreaseAvailableQuantity(giftSerialCode); // 수량 감소
                                removeKey(secondKey, event);               // 게임 당첨자 redis에서 삭제
                            }
                        }
                );
            }
        }
    }

    private void decreaseAvailableQuantity(String giftSerialCode) {
        Long quantity = redisTemplate.opsForValue().decrement(giftSerialCode);
        log.info("남은 수량: "  + quantity);
    }

    private void removeKey(String key, Object event) {
        redisTemplate.opsForZSet().remove(key, event);
    }
}

 

테스트를 해보자.


테스트 시나리오는 게임 주최자가 100명이 있는 오픈 채팅방에 수량이 10개인 선물하기 게임을 시작하는 시나리오입니다. 그럼 1등부터 10등까지는 선물을 받을 수 있고 나머지 90명은 선물을 받지 못하게 됩니다.

 

테스트를 위해서는 Jmeter 툴을 사용하였습니다.

테스트를 위한 Jmeter 세팅

 

1. 사용자의 아이디를 임의로 만들 랜덤 변수를 설정합니다. 랜덤 변수는 아래 사진처럼 설정을 하였습니다.

 

 

2. Request 세팅을 합니다.

 

  • memberId는 Jmeter의 랜덤 변수 설정값을 사용합니다.
  • participationCode는 오픈 채팅방의 코드입니다.
  • giftSerialCode는 게임 주최자가 게임을 시작했을 때 생성되는 코드입니다.

 

3. Thread Group을 설정하고 테스트를 진행합니다.

 

 

4.  대기열에 순서대로 요청이 쌓이기 시작합니다.

5.  스케줄러가 1초마다 돌면서 10개씩 데이터를 서버로 보내주며 당첨자의 정보를 보여주고 수량을 차감하는 것을 볼 수 있습니다.

 

 

6. 선물의 수량이 0이 되면 선물하기 게임이 종료됩니다.

 

 

끝.


대용량 트래픽이 몰리면 어떻게 해야할까? 이리저리 찾아보던 중 여러 아티클과 유튜브에 우아한 형제들에서 올라온 영상이 있어 많은 도움이 되었습니다. 실제로 이러한 경험을 겪어본적은 없지만 이번 사이드 프로젝트를 통해 이렇게도 대처할 수 있겠구나라는 것을 알게되었고, Redis도 한번 사용할 수 있는 좋은 기회가 되었고, 처음으로 멀티 모듈을 적용해봤는데 아직 갈길이 멀구나 싶엇습니다. 

이렇게 Redis Sorted Set을 사용하여 대기열을 구현하는 상황을 알아봤습니다. 

 

https://github.com/kdg0209/kakaotalk-gift-time-attack

 

GitHub - kdg0209/kakaotalk-gift-time-attack

Contribute to kdg0209/kakaotalk-gift-time-attack development by creating an account on GitHub.

github.com

 

 

참고)

 

 

 

 

 

 

728x90
반응형