티스토리 뷰

💡 최상용님의 실습으로 배우는 선착순 이벤트 시스템 강의를 듣고 정리한 내용입니다.

 

목차

     
    글 목록

     

    배경

    지난 글에 이어서 쿠폰 발급 시스템을 고도화하고 안정성을 향상시켜본다. 이번 글에서는 발급 가능한 쿠폰 개수를 ID당 1개로 제한하는 요구사항이 추가되는 경우와 쿠폰 발급에 실패하는 경우 어떻게 처리할지에 대해 알아본다.

    요구사항 변경

    먼저 발급가능한 쿠폰 개수를 1인당 1개로 제한해달라는 요구사항이 추가되는 경우에 대해서 알아본다. 현재 개발한 로직은 한명이 여러개의 쿠폰을 발급받을 수 있다. 하지만 대부분의 선착순 이벤트는 1인당 1개로 개수를 제한하는 경우가 많다. 

     

    쿠폰 개수 제한 방법

    쿠폰 개수 제한 방법에 대해서 고민해보자. 먼저 데이터베이스 레벨에서 제한하는 방식이 있다. 가장 간단한 방법으로 데이터베이스에 userId와 couponType에 대해 Unique Key를 둔다. 하지만 보통 쿠폰 서비스를 고민해보면, 한 유저는 같은 타입의 쿠폰을 여러개 받을 수 있어 좋은 방법은 아니다. 우리는 선착순 이벤트에서 발급된 쿠폰만 1명당 1개를 발급해야한다.

     

    다음으로는 애플리케이션 레벨에서 제한하는 방식을 고려해보자. 이 경우 다음과 같이 쿠폰 발급 로직에 Lock을 걸어서 특정 회원의 쿠폰발급 여부를 가져오고 판단할 수 있을 것이다.

    if(발급했다면) return
    
    // 쿠폰 발급 로직

     

    하지만 지난 [시스템 디자인] 실습으로 배우는 선착순 이벤트 시스템 (2/3) - Kafka로 시스템 안정성 향상하기 글을 통해 실제 쿠폰 발급은 consumer에서 실행하고 있다. 즉 쿠폰 발급 여부 판단 로직과 실제 쿠폰 발급 로직이 분리되어 있다.

    이 사이에는 분명히 시점차가 존재하며 consumer에서 DB에 반영하기 전이라면 쿠폰 발급 여부를 판단할때는 발급이 가능하다고 판단하여 한 사람이 두 개이상의 쿠폰을 발급할 수 있다.

     

    또한 쿠폰 발급 가능 여부 판단과 발급까지 한 트랜잭션에서 처리한다면 Lock 범위가 너무 커져 성능 저하가 발생할 수 있다.

     

    Redis Set 자료구조 활용하기

    Redis의 set 자료구조를 활용하여 요소의 중복 여부를 판단할 수 있다. 즉 Redis는 set 자료구조를 지원하므로 쿠폰 발급 여부를 레디스에 적재하여 확인하는 것이다. Redis는 싱글 스레드 기반 환경에서 동작하므로 멀티 스레드 환경에서 중복 발행을 고려하지 않아도 된다. 또한 Redis Set에 값을 추가하는 sadd 명령어는 O(1)의 시간복잡도를 갖으므로 성능 저하에 대한 부담도 줄어든다. (출처 : http://redisgate.kr/redis/command/sadd.php)

     

    AppliedUserRepository 

    레디스에 접근하여 CRUD를 하기위한 리포지토리인 AppliedUserRepository를 추가한다. "applied-user" Set에 사용자 ID를 추가하는 add() 명령어를 구현한다. 이 add 명령어는 기존에 set에 값이 없는 경우(추가를 성공하는 경우) 1을 반환한다. 반면 기존에 set에 값이 존재하여 추가에 실패하는 경우 0을 반환한다.

    package org.example.api.repository;
    
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.stereotype.Repository;
    
    @Repository
    public class AppliedUserRepository {
    	private final RedisTemplate<String ,String> redisTemplate;
    
    	public AppliedUserRepository(RedisTemplate<String, String> redisTemplate) {
    		this.redisTemplate = redisTemplate;
    	}
    
    	public Long add(Long userId) {
    		return redisTemplate
    			.opsForSet()
    			.add("applied-user", userId.toString());
    	}
    }

     

     

    ApplyService 수정

    쿠폰 발급전에 레디스의 sadd 명령어를 호출하고 그 결과값을 반환받는다. 반환 값이 1이라면 기존에 set에 값이 없었다는 의미이므로 기존에 발급된 사용자가 아니다. 따라서 쿠폰을 발급한다. 반환 값이 0이라면 기존에 set에 값이 있고 이미 쿠폰을 발급받은 사용자이므로 쿠폰 발급을 진행하지 않는다.

    package org.example.api.service;
    
    import org.example.api.producer.CouponCreateProducer;
    import org.example.api.repository.AppliedUserRepository;
    import org.example.api.repository.CouponCountRepository;
    import org.example.api.repository.CouponRepository;
    import org.springframework.stereotype.Service;
    
    import lombok.RequiredArgsConstructor;
    
    @Service
    @RequiredArgsConstructor
    // 쿠폰에 대한 CRUD를 통해 비즈니스 로직을 제공
    public class ApplyService {
    	private final CouponRepository couponRepository;
    	private final CouponCountRepository couponCountRepository;
    	private final CouponCreateProducer couponCreateProducer;
    	private final AppliedUserRepository applieduserRepository;
    
    	// 쿠폰 발급 로직
    	public void apply(Long userId) {
    		// 쿠폰을 발급받은 사용자인지  검증
    		Long apply = applieduserRepository.add(userId);
    
    		// 이미 쿠폰을 발급받은 사용자라면 쿠폰 미발급
    		if(apply != 1) {
    			return;
    		}
    
    		// 쿠폰발급 로직 수행
    		// 쿠폰 개수 조회(mysql)
    		// long count = couponRepository.count();
    
    		// 쿠폰 개수 조회 로직을 레디스의 incr 명령어를 호출하도록 변경한다.
    		// 즉 쿠폰 발급 전에 발급된 쿠폰의 개수를 1 증가하고 그 개수를 확인한다.
    		Long increment = couponCountRepository.increment();
    
    		// 쿠폰의 개수가 발급 가능한 개수보다 많은 경우 -> 발급 불가
    		if(increment > 100) {
    			return;
    		}
    
    		// 발급이 가능한 경우 ->  쿠폰 새로 생성(발급)
    		// couponRepository.save(new Coupon(userId)); // 쿠폰 발급(mysql)
    
    		// 쿠폰을 직접 DB에 생성하지 않고 카프카 토픽에 userId를 전송한다.
    		couponCreateProducer.create(userId);
    	}
    }

     

    ApplyServiceTest

    1인당 1명만 쿠폰이 발급 가능한지 테스트 코드를 작성한다. userID가 1이라는 유저가 1000번의 요청을 보내도 쿠폰은 한 개만 발급되는지 검증한다.

    @Test
    public void 한명당_한개의쿠폰만_발급() throws InterruptedException {
    	// 1000개의 요청이 동시에 들어오는 경우를 가정한다.
    	int threadCount = 1000;
    
    	// ExecutorService : 병렬 작업을 간단하게 수행하도록 도와주는, 스레드풀을 관리하는 자바 API
    	ExecutorService executorService = Executors.newFixedThreadPool(32);
    
    	// CountDownLatch :
    	// - 여러 스레드가 특정 시점까지 대기하거나, 특정 조건이 만족될 때까지 실행을 지연하는 메커니즘을 제공
    	// - 다른 스레드에서 진행중인 작업이 모두 완료할 떄까지 대기하는데 사용한다.
    	CountDownLatch latch = new CountDownLatch(threadCount); // count 값을 threadCount 값으로 초기화
    	for(int i = 0; i < threadCount; i++) {
    		executorService.execute(() -> {
    			try {
    				// 1이라는 유저가 1000번의 요청을 보내도 쿠폰은 한 개만 발급되는지 확인한다.
    				applyService.apply(1L);
    			} finally {
    				// count 값을 1 감소
    				latch.countDown();
    			}
    
    		});
    	}
    
    	// await() 이후 로직은 count 값이 0이 되고 나서 실행된다.
    	latch.await();
    
    	// 컨슈머가 메시지를 처리하는 동안 잠시 대기
    	Thread.sleep(10000);
    
    	long count = couponRepository.count();
    	assertThat(count).isEqualTo(1); // 쿠폰은 1개만 생성
    	}
    }

     

    쿠폰 발급 실패 예외처리

    다음으로는 보다 안전한 시스템을 만들기 위해 Consumer에서 쿠폰을 발급하다가 에러가 발생하는 경우 처리 방안에 대해서 알아본다. 현재 시스템을 살펴보면 다음과 같이 consumer가 쿠폰을 발급하고 DB에 적재하고 있다.

    package org.example.consumer.consumer;
    
    import org.example.consumer.domain.Coupon;
    import org.example.consumer.repository.CouponRepository;
    import org.springframework.kafka.annotation.KafkaListener;
    import org.springframework.stereotype.Component;
    
    @Component
    public class CouponCreatedConsumer {
    	private final CouponRepository couponRepository;
    
    	public CouponCreatedConsumer(CouponRepository couponRepository) {
    		this.couponRepository = couponRepository;
    	}
    
    	@KafkaListener(
    		topics = "coupon-create", 	// 토픽 명 지정
    		groupId = "group_1" 		    // 컨슈머 그룹 ID 지정
    	)
    	public void listener(Long userId) {
    		// 쿠폰 발급
    		couponRepository.save(new Coupon(userId));
    	}
    }

     

    하지만 Consumer가 쿠폰 발급에 실패하는 경우 실제로 쿠폰을 발급되지 않지만 전체 발급된 갯수는 증가하는 문제가 발생한다. 결과적으로 처음에 정했던 100개보다 적은 쿠폰이 발급될 수 있다.

     

    실패 이벤트 적재, 배치 재수행

    이 경우 다양한 해결책이 있겠지만 다음과 같이 오류가 발생하면 백업데이터와 로그를 남기고 배치(Batch)로 이를 재수행하는 방안이 있다.

     

    FailedEvent

    쿠폰 발급에 실패한 데이터를 담는 엔티티역할을 한다.

    package org.example.consumer.domain;
    
    import jakarta.persistence.Entity;
    import jakarta.persistence.GeneratedValue;
    import jakarta.persistence.GenerationType;
    import jakarta.persistence.Id;
    import lombok.NoArgsConstructor;
    
    @Entity
    @NoArgsConstructor
    public class FailedEvent {
    	@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    	private Long id;
    
    	private Long userId;
    
    	public FailedEvent(Long userId) {
    		this.userId = userId;
    	}
    }

     

    FailedEventRepository

    실패 이벤트를 데이터베이스 적재하는 역할을 한다.

    package org.example.consumer.repository;
    
    import org.example.consumer.domain.FailedEvent;
    import org.springframework.data.jpa.repository.JpaRepository;
    
    public interface FailedEventRepository extends JpaRepository<FailedEvent, Long> {
    }

     

    CouponCreatedConsumer 수정

    다음과 같이 쿠폰 발급 실패시 예외 처리 로직을 추가해준다.

    package org.example.consumer.consumer;
    
    import org.example.consumer.domain.Coupon;
    import org.example.consumer.domain.FailedEvent;
    import org.example.consumer.repository.CouponRepository;
    import org.example.consumer.repository.FailedEventRepository;
    import org.springframework.kafka.annotation.KafkaListener;
    import org.springframework.stereotype.Component;
    
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j // 에러 로깅을 위함
    @Component
    @RequiredArgsConstructor
    public class CouponCreatedConsumer {
    	private final CouponRepository couponRepository;
    	private final FailedEventRepository failedEventRepository;
    
    	@KafkaListener(
    		topics = "coupon-create", 	// 토픽 명 지정
    		groupId = "group_1" 		// 컨슈머 그룹 ID 지정
    	)
    	public void listener(Long userId) {
    		try {
    			couponRepository.save(new Coupon(userId));
    		} catch (Exception e) {
    			// 쿠폰 발급에 실패하는 경우
    			log.error("failed to create coupon: {}", userId);
    			failedEventRepository.save(new FailedEvent(userId));
    		}
    	}
    }

     

    DLT를 이용한 재처리 로직

    다음과 같이 DLT(Dead Letter Topic)에 쿠폰 발급에 실패한 메시지를 보내고 이후에 지속적으로 재처리를 메시지 처리를 보장하는 방안도 있다.

     

    DLT에 대해서 간략히 설명하자면, 일반 메시징 시스템에서 메시지 처리에 실패하면 곧바로 DLQ(Dead Letter Queue)에 보내고 다음 메시지 처리에 집중한다. 이후에 DLQ에 보관한 메시지를 다시 처리함으로써 결과적으로 처리 속도와 처리량 향상에 기여할 수 있다. 또한 DLQ에 적재된 메시지들을 분석하여 개발자는 오류 원인을 더 쉽게 파악하고 문제를 해결할 수 있다.

     

    이러한 DLQ를 카프카에 적용한 것이 DLT이다. (Dead Letter Queue -> Dead Letter Topic)

     

    하지만 DLT로 메시지를 재처리하는 경우 메시지 처리 순서가 보장되지 않는다. 따라서 순서가 중요한 경우 사용에 주의해야햔다. 현재 작성한 쿠폰 발급 시스템에서는 Redis를 통해 발급된 쿠폰의 개수를 조회하고 있으므로 DLT로 재처리를 진행해도 쿠폰 발급 수량을 제한할 수 있다. 하지만 Consumer에서 쿠폰 발급 개수도 체크하고 있다면 재처리 로직을 진행하면서 쿠폰이 정해진 수량보다 더 많이 발급될 수 있다.

     

    또한 DLT에 보관된 메시지는 즉시 처리되지 않고 이후에 재처리한다. 따라서 실시간으로 쿠폰 발급이 중요한 경우 유의해야한다. 마지막으로 DLT에 보관된 메시지의 보관 기간에 대해서도 고려해보아야한다. 발급에 실패한 이벤트를 장기적으로 보관해야하는 경우 강의에서 소개한 바와 같이 데이터베이스처럼 영구적인 스토리지를 적용하는게 더 적절할 수 있다.

    Comments