[Springboot] 레디스 대기열을 이용한 선착순 쿠폰 발급 시스템

Tessssssssy·2024년 6월 18일
post-thumbnail

도입 계기

현재 지난 부트캠프 때 미니 프로젝트로 진행한 캠핑온탑  프로젝트를 리팩토링하고 있다.

결제 시스템 기능을 구현한 후, 전체 기능들을 다시 살펴보니 결제 시 할인이 되는 기능이 있었으면 좋겠다는 생각이 들었다. 생각해보면 나도 결제를 할 때, 항상 사용할 수 있는 쿠폰이 있는지 확인하고 있으면 대부분 사용하는 편이다.

캠핑온탑의 경우 원래 무조건 정가로만 결제하는 시스템이었는데 그렇게만 하면 정 없고 소비자의 입장에서 괜히 손해보는? 서운한 느낌이 들 수 있기 때문에 쿠폰 발급 기능을 추가하기로 했다.



기대 효과

그리고 이때까지의 캠핑온탑은 발생할 수 있는 어떠한 이벤트도 존재하지 않았다. 물론 결제 기능도 하나의 주요 이벤트이지만 트래픽이 급격하게 몰릴 수 있는 상황 내지는 이벤트가 발생할 수 있는 여지가 거의 없었다.

하지만 선착순 쿠폰 발급 기능을 도입한다면 예컨대 특정 시각에 쿠폰 이벤트를 진행하여 많은 사람이 해당 시간에 몰려서 접속을 하는, 요청을 하는 상황을 만들 수 있다.

백엔드 엔지니어로서 향후 내가 취업을 했을 때 마주칠 수 있는 문제이고 그 정도의 규모를 경험할 수 없겠지만 이번 기회에 어떠한 방식으로 내부 구조가 이루어져 있는지 들여다보며 공부하고, 어떻게 처리를 해야 하는지 경험할 수 있는 좋은 기회가 될 수 있을 것으로 생각한다.



방식 비교 & 선택 이유

그렇다면 어떻게 구현해야 할까?

여러 레퍼런스들을 참고한 결과 크게 2가지로 정리할 수 있었다.

  • Redis-Kafka-Zookeeper를 사용하는 방식
  • Redis만 사용하는 방식
  • Redis-Kafka-Zookeeper를 사용하는 방식

    • 구조

      • Redis
        • 인메모리 데이터 구조 저장소로, 빠른 읽기쓰기가 필요할 때 사용된다.
        • 선착순 쿠폰 카운트와 같은 데이터를 빠르게 처리하기 위해 사용될 수 있다
      • Kafka
        • 고성능의 분산 스트리밍 플랫폼으로, 대량의 데이터를 높은 처리량으로 처리할 수 있다.
        • 사용자의 쿠폰 요청을 Stream으로 처리하여 레디스로 전달하기 전에 직렬화순서화를 담당할 수 있다.
      • Zookeeper
        • 분산 시스템을 위한 조정 서비스로, 카프카 클러스터메타데이터 관리리더 선출 등을 관리한다.
    • 장점

      • 신뢰성
        • 카프카가 메시지 손실의 위험 없이 데이터를 처리하고, 주키퍼를 통한 클러스터 관리로 인해 전체 시스템의 신뢰성이 증가한다.
      • 확장성
        • 카프카는 수평적 확장이 가능하며, 많은 양의 데이터와 요청을 처리할 수 있다.
      • 탄력성
        • 시스템의 한 부분에 장애가 발생해도 다른 부분이 이를 감지하고 대처할 수 있어 전체 시스템이 안정적으로 유지된다.
    • 단점

      • 복잡성
        • 세 가지 기술을 함께 사용함으로써 시스템 구성과 관리가 복잡해진다.
      • 유지보수
        • 여러 컴포넌트의 관리가 필요하므로 운영 부담이 증가한다.
      • 비용
        • 추가적인 인프라와 관리 비용이 발생할 수 있다.

  • Redis만 사용한 방식
    • 구조
      • Redis
        • 데이터 저장과 동시에 쿠폰 발급 요청을 처리한다. 선착순 로직을 레디스의 원자적 연산을 활용하여 구현한다.
    • 장점
      • 간단함
        • 단일 기술 사용으로 시스템 아키텍처가 단순하며 이해하기 쉽다.
      • 배포 용이성
        • 복잡한 종속성 없이 레디스 하나만으로 배포가 가능하다.
      • 낮은 비용
        • 추가적인 기술 스택 없이 레디스 서버만 유지하면 되므로 비용이 절감된다.
    • 단점
      • 제한된 처리량
        • 레디스는 메모리 기반이므로 처리량과 스케일이 카프카보다 제한적일 수 있다.
      • 데이터 무결성
        • 네트워크 분할 등 특정 상황에서 데이터 무결성이 카프카와 비교해 떨어질 수 있다.
      • 고가용성 문제
        • 레디스 클러스터링 없이 단일 노드로 운영될 경우, 노드 장애가 전체 시스템에 영향을 줄 수 있다.

  • Redis 만 사용한 방식을 선택한 이유
    • 간단함 & 운영의 용이성
      • 레디스만 사용했기 때문에 시스템 아키텍처가 단순해지고, 복잡한 인프라 설정을 하는 과정에서 발생하는 에러를 줄일 수 있다. 이는 전체 시스템의 안정성과 예측 가능성을 높이고, 관리를 하는데 용이해진다.
      • 현재 이미 AWS EC2 에 1차 리팩토링을 마친 후 수정된 버전으로 배포를 해놓은 상태이다. 프리티어 이기 때문에 리소스가 충분하지 않아 카프카, 주키퍼 등을 모두 하나의 EC2에서 실행할 수 없는데다가 다른 서버도 보유하고 있지 않기 때문에 현실적으로 인프라를 구축할 수 없었다.
        그리고 이전에 배포했을 때 EC2에 스프링 jar와 레디스 2개를 동시에 실행하는 건 문제가 없었다는 것을 확인했기 때문에 레디스만 사용하는 방식을 선택하는데 큰 문제가 없었다.
    • 기술적 적합성
      • 현재 내가 진행하고 있는 선착순 쿠폰 발급 시스템에서 가장 중요한 건 빠른 응답성과 데이터 처리하는 능력이다. 이는 레디스의 성능 특성이 충분히 만족시킬 수 있을 것으로 판단했다. 레디스는 선착순 로직을 효과적으로 처리할 수 있는 다양한 데이터 구조를 가지고 있기 때문에 레디스만 사용하더라도 충분한 퍼포먼스를 보여줄 수 있을 것으로 판단했다.


구현 방법

@Getter
public enum Event {
    FREE_CAMPING("무료숙박권");

    private String name;

    Event(String name) {
        this.name = name;
    }
}

먼저 이벤트를 간결하고 효과적으로 관리하기 위해 자바의 Enum 타입을 이용해 이벤트들을 나열했다.
일단 단일 이벤트만 존재하고 한 명의 유저가 한 번만 쿠폰을 발급받아서 사용할 수 있다는 상황을 가정하고 진행했다.


@Entity
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Coupon {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private Event event;

    @Column(nullable = false)
    private Integer price;

    @Column(nullable = false, updatable = false)
    private Date createdAt;

    @PrePersist
    void createdAt() {
        this.createdAt = new Date();
    }
}
@Getter
@Setter
public class EventCount {
    private final Event event;
    private int remainingCount;

    public EventCount(Event event, int count) {
        this.event = event;
        this.remainingCount = count;
    }
}
  • EventCount 클래스는 특정 이벤트의 남은 수량을 관리하는데 사용된다.
  • 캡슐화와 데이터 관리
    • 이벤트의 종류와 그에 따른 남은 수량을 하나의 단위로 묶어 관리하는데,
      이는 데이터를 캡슐화해서 외부에서 직접적으로 접근하는 것을 제한하고 메소드를 통해서만 상태를 변경할 수 있게 함으로써 데이터의 무결성을 유지할 수 있도록 한다.
  • 의존성 명확성
    • EventCount 객체는 Event 객체에 의존적이다.
      이 구조는 특정 이벤트의 상태와 행위가 명확하게 연결되었음을 보여주고, 객체 간 관계를 명확하게 표현한다.

사실 이어지는 부분이 가장 어려웠다.
그래서 스스로 헷갈리지 않고 계획대로 구현하기 위해 시나리오를 다시 한번 점검했다.

개발을 시작하기 전 구상했던 시나리오는 다음과 같다.

  1. 로그인
  2. 쿠폰 발급 버튼 혹은 페이지 접근
  3. 쿠폰 발급 버튼 클릭 (발급 요청)
  4. 요청한 순서대로 대기열 이동
  5. 발급 요청을 한 유저들이 대기열로 들어오면 순차적으로 정해진 수량만큼 쿠폰 발급
  6. 대기열 비움
  7. 이벤트 종료

@Configuration
@RequiredArgsConstructor
public class RedisConfig {
    private final RedisProperty redisProperty;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisProperty.getRedisHost(), redisProperty.getRedisPort());
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate() {
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory());
        return redisTemplate;
    }
}

@Getter
@Setter
@Component
@ConfigurationProperties("spring.redis")
public class RedisProperty {
    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;
}

@Slf4j
@Component
@RequiredArgsConstructor
public class EventScheduler {

    private final CouponService couponService;

    // 애플리케이션 시작 시 쿠폰 수량 초기화
    @PostConstruct
    private void init() {
        Event event = Event.FREE_CAMPING;
        int initialCouponCount = 3; // 초기 쿠폰 수량 설정 (예: 3개)
        couponService.setEventCount(event, initialCouponCount);
        log.info("이벤트 {}의 초기 쿠폰 수량이 {}으로 설정되었습니다.", event, initialCouponCount);
    }

    // 요청이 들어올 때 호출되는 메서드
    public void processQueue(Event event) {
        // 쿠폰이 남아 있는지 확인한 후 발행 시도
        if (couponService.getRemainingEventCount(event) > 0) {
            log.info("이벤트 처리 중: {}", event);
            couponService.publish(event);
        } else {
            log.info("===== 선착순 이벤트가 종료되었습니다: {} =====", event);
        }
    }
}
  • EventScheduler 클래스는 쿠폰 이벤트와 관련된 주요 작업을 스케줄링한다.
  • 우선 application이 실행되면 쿠폰 수량을 초기화한다.
    (시작될 때 모든 이벤트에 대한 쿠폰 수량을 설정하는 중요한 단계)
  • processQueue 메서드는 이벤트 큐를 처리하고,
    쿠폰이 남아 있는 경우 사용자에게 쿠폰을 발행한다.

@RestController
@RequestMapping("/coupons")
@RequiredArgsConstructor
@CrossOrigin("*")
public class CouponController {
    private final CouponService couponService;

    @PostMapping("/request/{eventId}")
    public ResponseEntity<String> requestCoupon(@PathVariable("eventId") String eventId, @AuthenticationPrincipal User user) {
        if (user == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("사용자가 인증되지 않았습니다.");
        }

        Event event = Event.valueOf(eventId);
        // 사용자가 이미 해당 이벤트의 쿠폰을 받았는지 확인
        if (!couponService.canIssueCoupon(user, event)) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("이미 해당 이벤트의 쿠폰을 받았습니다.");
        }

        // 쿠폰 발급 큐에 사용자 추가 시도
        boolean addedToQueue = couponService.addQueue(event, user);
        if (!addedToQueue) {
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("쿠폰 요청 추가에 실패하였습니다.");
        }

        // 큐에 추가 후 실제 쿠폰 발급 처리
        couponService.processQueue(event);

        return ResponseEntity.ok("쿠폰이 발급되었습니다.");
    }

    @GetMapping("/my")
    public ResponseEntity<List<GetCouponRes>> myCoupons(@AuthenticationPrincipal User user) {
        if (user == null) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
        }

        List<GetCouponRes> coupons = couponService.getUserCoupons(user);
        if (coupons.isEmpty()) {
            return ResponseEntity.noContent().build();
        }

        return ResponseEntity.ok(coupons);
    }
}
  • requestCoupon 메소드는 특정 이벤트에 대한 쿠폰을 요청한다.
    사용자가 이미 해당 이벤트 쿠폰을 발급받았는지 확인하고, 발급이 가능하다면 쿠폰 발급 큐에 사용자를 추가한다.
    큐에 성공적으로 추가된 후, 쿠폰 발급을 처리한다.

@Slf4j
@Service
@RequiredArgsConstructor
public class CouponService {
    private final RedisTemplate<String, String> redisTemplate;
    private final UserCouponRepository userCouponRepository;
    private final UserRepository userRepository;
    private final CouponRepository couponRepository;

    // 이벤트 쿠폰 수량 설정
    public void setEventCount(Event event, int count) {
        redisTemplate.opsForValue().set(event.name() + "_COUNT", String.valueOf(count));
    }

    // 남은 이벤트 쿠폰 수량 가져오기
    public int getRemainingEventCount(Event event) {
        String countStr = redisTemplate.opsForValue().get(event.name() + "_COUNT");
        if (countStr != null) {
            try {
                return Integer.parseInt(countStr);
            } catch (NumberFormatException e) {
                log.error("Redis에서 카운트 값을 파싱하지 못했습니다", e);
            }
        }
        return 0;
    }

    // 이벤트 쿠폰 수량 감소
    public void decrementEventCount(Event event) {
        redisTemplate.opsForValue().decrement(event.name() + "_COUNT");
    }

    // 큐에 사용자 추가
    public boolean addQueue(Event event, User user) {
        if (user == null) {
            log.error("사용자가 null입니다");
            throw new IllegalArgumentException("사용자는 null일 수 없습니다");
        }
        if (event == null) {
            log.error("이벤트가 null입니다");
            throw new IllegalArgumentException("이벤트는 null일 수 없습니다");
        }
        if (redisTemplate == null) {
            log.error("RedisTemplate이 null입니다");
            throw new IllegalStateException("RedisTemplate은 null일 수 없습니다");
        }

        if (getRemainingEventCount(event) <= 0) {
            log.error("이벤트에 대한 쿠폰이 더 이상 남아 있지 않습니다: {}", event);
            return false;
        }

        final long now = System.currentTimeMillis();
        redisTemplate.opsForZSet().add(event.toString(), user.getEmail(), now);
        log.info("큐에 추가됨 - {} at {}ms", user.getName(), now);

        // 큐에 추가된 후 바로 큐를 처리
        processQueue(event);

        return true;
    }

    // 큐를 처리하는 메서드
    public void processQueue(Event event) {
        // 쿠폰이 남아 있는지 확인한 후 발행 시도
        if (getRemainingEventCount(event) > 0) {
            log.info("이벤트 처리 중: {}", event);
            publish(event);
        } else {
            log.info("===== 선착순 이벤트가 종료되었습니다: {} =====", event);
        }
    }

    // 큐에 있는 사용자들에게 쿠폰 발행
    public void publish(Event event) {
        List<User> users = getUsersFromQueue(event);
        for (User user : users) {
            issueCoupon(event, user);
            decrementEventCount(event);
            redisTemplate.opsForZSet().remove(event.toString(), user.getEmail());
        }
    }

    // 해당 이벤트의 쿠폰을 이미 받았는지 확인
    public boolean canIssueCoupon(User user, Event event) {
        return userCouponRepository.findByUserIdAndCouponEvent(user.getId(), event).isEmpty();
    }

    // 큐에 남아있는 사용자 수 가져오기
    public long getSize(Event event) {
        return redisTemplate.opsForZSet().size(event.toString());
    }

    // 사용자에게 쿠폰 발행
    public void issueCoupon(Event event, User user) {
        Coupon coupon = couponRepository.save(Coupon.builder()
                .event(event)
                .price(10000)
                .build());
        userCouponRepository.save(UserCoupon.builder()
                .user(user)
                .coupon(coupon)
                .isUsed(false)
                .build());
        log.info("'{}'에게 {} 쿠폰이 발급되었습니다", user.getName(), event.name());
    }

    // 큐에 있는 사용자 목록 가져오기
    private List<User> getUsersFromQueue(Event event) {
        Set<String> emails = redisTemplate.opsForZSet().range(event.toString(), 0, -1);
        List<User> users = new ArrayList<>();
        if (emails != null) {
            for (String email : emails) {
                userRepository.findByEmail(email).ifPresent(users::add);
            }
        }
        return users;
    }

    // 쿠폰 발급 내역 조회
    public List<GetCouponRes> getUserCoupons(User user) {
        return userCouponRepository.findByUser(user).stream()
                .filter(userCoupon -> !userCoupon.isUsed())  // Only include unused coupons
                .map(userCoupon -> GetCouponRes.builder()
                        .id(userCoupon.getCoupon().getId())
                        .eventName(userCoupon.getCoupon().getEvent().name())
                        .price(userCoupon.getCoupon().getPrice())
                        .issuedAt(userCoupon.getCoupon().getCreatedAt().toString())
                        .build())
                .collect(Collectors.toList());
    }
}
  • CouponService 클래스는 선착순 쿠폰 발급 시스템의 주요 비즈니스 로직을 수행한다.
  • 쿠폰의 초기 수량을 설정하고, 남은 수량을 조회, 감소하는 등 쿠폰 수량을 관리한다.
  • 사용자를 큐에 추가하고 큐를 처리하여 쿠폰을 발행한다.
  • 큐에서 사용자를 가져와서 쿠폰을 발행하고, 쿠폰의 사용 여부를 업데이트한다.


효과

  • 사용자가 동일한 이벤트의 쿠폰을 중복해서 받지 못하도록 한다.
  • Redis를 사용해서 쿠폰 수량과 큐 관리를 통해 고성능과 동시성을 보장한다.
  • 쿠폰 이벤트가 늘어나거나 사용자가 많아져도 시스템을 쉽게 확장할 수 있다.
  • Redis가 보유한 자체 자료구조와 기능을 사용해 데이터의 일관성을 유지한다.


동작 모습

애플리케이션이 실행되면 바로 쿠폰 수량이 정해진다.

쿠폰 발급 창으로 이동한다.

쿠폰 발급 버튼을 클릭한다.

만약 이미 발급받은 쿠폰이라면 더 이상 발급받을 수 없다.

발급 여부를 확인한 후 대기열에 추가된다.

쿠폰이 성공적으로 발급된 모습을 로그로 확인할 수 있다.

쿠폰 내역 조회 페이지에서 발급된 쿠폰을 확인할 수 있다.

장바구니 페이지에서 총액 220,000원에서 10,000원 할인 쿠폰을 적용해서 210,000원으로 최종 결제 금액이 변경된 것을 확인할 수 있다.



소감

처음에 기획한 시나리오 그대로 구현이 되어서 정말 뿌듯하다.

그리고 여러 유저로 쿠폰 발급 시도를 해본 결과 요청한 순서대로 쿠폰이 발급되고 이후에 유저들의 경우 쿠폰 발급이 되지 않은 모습을 확인하는 것으로 구현 작업을 마무리했다.

여러 레퍼런스들을 참고했지만 우리 서비스와 유사한 버전이 많지 않았기에 초기에 코드를 작성하는데 어려움이 있었지만 Redis의 자료구조에 대해서 학습하면서 방향을 잡을 수 있었다.

물론 내가 구현한 코드가 대규모 서비스에는 적합하지 않을 수 있고, 코드가 효율적이지 않을 수 있다. 더 좋은 방법이 있을수도 있다.

하지만 구현을 시작할 때 다짐한 건 초기 기획을 흔들지 않고 그대로 구현하자는 것이었다. 타협하기보다는 하는데까지 일단 한 번 해보자는 마음으로 진행했고 구현한 후에 여러 테스트를 통해 유지보수 및 성능 개선은 추후에 하는 것을 목표로 생각했기에 계획한 기간 내에 완성할 수 있었다.


여담이지만 부트캠프 때 최종 프로젝트를 진행하면서 느낀점이 이번 리팩토링 스프린트에서도 녹아들었다. 개발자는 코드를 잘 짜는 것이 중요하지만, WBS에서 기획한 대로, 스케줄대로 개발을 진행하는 것도 개발자의 중요한 덕목이라는 것을 몸소 깨달았기 때문에 이후에 진행하는 프로젝트도 리팩토링이지만 스프린트로 차근차근 진행하고 있다.


다시 쿠폰 시스템으로 돌아가서 이번 선착순 쿠폰 발급 시스템을 도입하고 나니 기존에 구현한 결제 시스템도 수정했다. 그래서 미리 이야기하자면 다음에 포스팅할 쿠폰 적용 + 결제 시스템 도 수정해서 결과적으로 할인된 가격으로 결제할 수 있는 시스템이 캠핑온탑  서비스에 도입되었다.

profile
Web Developer

0개의 댓글