Welcome! Everything is fine.

[트러블슈팅] 정원이 남아있는 상태에서 이벤트 신청이 안되는 오류 본문

카테고리 없음

[트러블슈팅] 정원이 남아있는 상태에서 이벤트 신청이 안되는 오류

개발곰발 2025. 4. 18.
728x90

문제점

이벤트 정원이 10명인데도 200명 신청 시 9명까지만 들어가는 문제가 발생했다.
또한 로그에서 “이벤트 정원이 초과되었습니다.”라는 메세지가 떴다.

  • Redis에는 이미 200명이 저장된 상태
  • DB는 한 명이 누락된 상태
  • DB에 존재하지 않는 ID로 다시 신청 → EVENT_ALREADY_JOINED 예외 발생

원인

Redis와 DB의 정합성이 깨졌는데, 검증 기준은 Redis에만 의존하고 있기 때문이다.

 

Redis는 ID를 이미 참여한 유저로 보고 있지만, 실제 DB에는 들어가지 않았다.
하지만 ZSet에 있는 것만 보고 판단하여 중복 신청으로 막아버린 것이다.
Zset에는 실패한 유저도 들어있기 때문에 100% 신뢰할 수 없다.

왜 지금까지 이런 문제가 한 번도 없었는지는 잘 모르겠다..😓

해결 방법

validateEventCapacity() 메서드는 다음과 같았다.

private void validateEventCapacity(String zsetKey, int limit) {
    Long current = redisTemplate.opsForZSet().zCard(zsetKey);
    
    if (current != null && current >= limit) {
        throw new HandledException(ErrorCode.EVENT_FULL);
    }
}

 

메서드를 다음과 같이 수정하였다.

private void validateEventCapacity(String zsetKey, int limit, Event event) {
    Long current = redisTemplate.opsForZSet().zCard(zsetKey);
    
    // Redis 기준 정원 초과일 경우 -> DB 기준으로 한 번 더 확인
    if (current != null && current >= limit) {
        long dbCount = eventJoinRepository.countByEvent(event);

        if (dbCount >= limit) {
            throw new HandledException(ErrorCode.EVENT_FULL);
        }
    }
}

 

테스트 결과는 다음과 같다.


추가 +)

 

기존 코드에서 DB Insert 실패 시 ZSet에서 제거하는 로직을 추가했다.

private EventJoinResponseDto handleJoinLogic(Long eventId, AuthUser authUser) {
    User user = User.fromAuthUser(authUser);
    String zsetKey = EVENT_JOIN_PREFIX + eventId;

    Event event = eventRepository.findById(eventId)
            .orElseThrow(() -> new HandledException(ErrorCode.EVENT_NOT_FOUND));

    event.validateOpenStatus();
    validateEventNotAlreadyJoined(zsetKey, user);
    validateEventCapacity(zsetKey, event.getLimitPeople(), event);

    EventJoin eventJoin = saveEventJoin(event, user);

    log.info("이벤트 신청 성공: eventJoinId={}, user={}, event={}", eventJoin.getId(), user.getEmail(), eventId);
    return EventJoinResponseDto.fromEventJoin(eventJoin);
}

 

수정한 코드는 다음과 같다.

private EventJoinResponseDto handleJoinLogic(Long eventId, AuthUser authUser) {
    User user = User.fromAuthUser(authUser);
    String zsetKey = EVENT_JOIN_PREFIX + eventId;

    Event event = eventRepository.findById(eventId)
            .orElseThrow(() -> new HandledException(ErrorCode.EVENT_NOT_FOUND));

    event.validateOpenStatus();
    validateEventNotAlreadyJoined(zsetKey, user);
    validateEventCapacity(zsetKey, event.getLimitPeople(), event);

    try {
        EventJoin eventJoin = saveEventJoin(event, user);
        log.info("이벤트 신청 성공: eventJoinId={}, user={}, event={}", eventJoin.getId(), user.getEmail(), eventId);
        return EventJoinResponseDto.fromEventJoin(eventJoin);
    } catch (Exception e) {
        redisTemplate.opsForZSet().remove(zsetKey, String.valueOf(user.getId()));
        log.warn("DB insert 실패로 Redis 자리 반환: userId={}, eventId={}", user.getId(), eventId);
        throw e;
    }
}

 

1번 ~ 10번까지는 DB에 들어간 순서 그대로이고, 나머지는 순간적인 동시 처리 때문에 남아있는 것 같다.
그러나 이전에 ZRANGE event:join:1 0 -1 을 했을 때 는 200명이 그대로 들어가 있었다.
로직 수정 후 ZSet에서 실제 정원과 비슷한 수준으로 유지되고 있다.

  • 200명(신청 시도 유저 전부) → 22명(정상적으로 반영된 유저 + 미처 삭제 되지 못한 유)

지삐띠가 만들어준 ZRANGE 수치 변화…

추가 공부

추후 MQ를 도입한다면 아래와 같은 흐름으로 진행될 것 같다.

1) 유저가 이벤트 신청
2) Redis ZSet에 userId 추가 시도
	- zCard 기준으로 정원 초과 여부 확인
	- 정원 초과 시 예외
3) Redis에 성공적으로 추가되면 MQ에 메세지 보냄("이벤트 신청했다.")
4) Redis만 보고 응답을 빠르게 반환
---
5) MQ는 이 메세지를 큐에 저장
---
6) Consumer(이벤트 신청 처리기)가 큐로부터 메시지를 수신
7) 메시지 처리
	- 이벤트 존재 여부 확인
	- 유저 정보 확인
	- DB에 Insert 시도
8) Consumer 처리 결과에 따라 분기
	- DB Insert 성공
	- DB Insert 실패 → Redis ZSet에서 해당 userId 제거