본문 바로가기
관리자

Programming-[Backend]/Performance, Monitoring

Count, 동시성 제어 관리(Lock, Redis, Kafka): 선착순 이벤트 시스템

728x90
반응형

 
인프런의 실습으로 배우는 선착순 이벤트 시스템 강의를 듣고나서 정리하는 내용이다.
 
동시에 많은 사용자가 몰려서 서버에 요청을 보냈을 때, 쿠폰 등 개수에 대한 정합성을 어떻게 확보할 것인지 알아본다. 그리고 DB에 부하가 되지 않게 할려면 어떻게 해야하는지 알아본다.
 
강의에서는 선착순으로 쿠폰을 발급하는 이벤트를 개최하는 상황을 예시로 든다. 요구사항은 아래와 같다.
1. 쿠폰은 선착순 100명에게만 발행된다.
2. 순간적으로 몰리는 트래픽을 견딜 수 있어야한다.
 
 

Lock

1번 요구사항을 달성하기 위해 가장 간단하게 적용할 수 있는 방법은 Lock이다. 아무런 추가 인프라 없이 JPA의 @Lock 어노테이션을 이용하여 Pessimistic Lock을 걸면 된다. 이 방법은 한 스레드가 접속하여 DB를 업데이트하고 나올 때까지 Lock이 걸리기 때문에 성능이 상당히 저하된다. 수 초내에 1000 ~ 10000명 이상이 몰릴 수 있는 시스템이라면 이런 방식을 선택하면 시스템의 반응 속도가 느려질 수 있고, 5초 이상 걸리면 보통 서버나 DB에 설정되어있는 타임아웃을 넘어가면서 요청 자체가 실패할 수 있다.
 

Synchronized

자바의 synchronized를 사용하면 애플리케이션 단(JVM)에서의 동시성을 보장받을 수 있다. lock 대비해서 모든 트랜잭션에 lock을 거는게 아니라 해당 로직에 대해서만 lock이 걸리게 되는 것이므로 부하가 상대적으로 적다. 하지만 1개의 JVM에 대해서만 순차처리를 보장하므로 멀티 인스턴스 환경에서는 동시성 제어가 불가능하다. 운영 환경에서는 고가용성을 위해 기본적으로 2대 이상의 인스턴스를 사용하기 때문에 이런 방식은 거의 사용이 불가능한 방식이라고 보면 된다.
 

Redis

추가 비용을 들여 Redis를 띄우면 1번 요구조건을 달성할 수 있다. 쿠폰 엔티티를 생성해서 저장하기 전에, redis에 coupon_count라는 key와 그 개수를 value로 갖는 레코드를 redis에 먼저 저장한다고 가정하자. 그렇게 저장 후 결과값인 count가 100을 넘어서면 더 이상 DB에 접근하지않고 early return 해버리는 방식을 통해 정확히 100개만 발급되도록 제어할 수 있다. 간단한 코드는 아래와 같다.
 

public void apply(Long userId) {

  //couponCountRepository에서 redis의 INCR 문법을 사용하여 숫자를 증가시키고 증가된 숫자를 count 값으로 받아온다.
    Long count = couponCountRepository.increment();

    if (count > 100) {
        return;
    }
    couponRepository.save(new Coupon(userId));
}

 
redis의 INCR 문법을 활용한다. redis를 사용하면 디스크단에 접근하여 처리하는 DB lock 대비 수백배 가까이의 성능 이득을 볼 수 있다. 캐시층을 사용하기 때문이다. 초당 서버가 처리할 수 있는 양인 TPS(Transactions per second)는 INCR은 약 50,000~100,000 가까이 된다.
 
중요한 특징 중 하나는 Redis는 싱글 스레드라는 것이다. 따라서 여러 서버 인스턴스에서 요청이 오더라도, 순차적으로 처리가 되어 동시성 제어가 된다. 싱글 스레드이지만, 위에서 언급한대로 캐시 레이어를 사용하기 때문에 속도가 확보되어 동시성을 확보할 수 있는 것이다. 결국 정합성을 맞출 수 있게 된다.
 

Kafka

redis를 활용하여 count 값 제어를 통해 100개 이상은 발급이 안되도록 하는 목표는 달성할 수 있다. 그러나 이후 DB에 발급된 쿠폰을 저장하는 요청이 많은 스레드에 의해 몰리게 되는 구조라면 DB의 부하에 의한 실패가 발생할 수 있다. 이를 해결하기 위해 kafka를 사용한다. kafka를 사용하면
1. 요청이 순간적으로 몰려도, DB 접근은 나중에 Consumer가 따로 처리하기 때문에 순간적인 부하가 줄어든다.
2. 메시지가 일정기간 보존되므로, 실패가 난 경우를 저장하고 나중에 따로 처리해줄 수 있어 정합성 확보가 된다.
 
AWS EKS를 사용하면 비용이 더 많이 들 수 있으나, 위와 같은 사유로 인해 필요하다면 한시적으로라도 kafka를 활용하는 것이 좋을 것 같다.
 
강의에서는 아래 사진처럼 따로 모듈을 만들었다. api 모듈에서는 기존 redis 테스트까지했고, 여기서 Producer 설정 코드를 작성했다. 그리고 Consumer는 개별 module로 만들고 실행하여 Consumer가 소비하는 메시지가 콘솔에 출력되는 것을 볼 수 있도록 했다.

 
핵심 서비스 코드는 대략 아래처럼 구성했다.
 

public void apply(Long userId) {
    Long apply = appliedUserRepository.add(userId);
    if(apply != 1) {
        return;
    }

    Long count = couponCountRepository.increment();

    if (count > 100) {
        return;
    }
    System.out.println();
    couponCreateProducer.create(userId);
}

 

Redis Set 자료형 사용

appliedUserRepository 부분은 유저 1명당 1개의 쿠폰만 발급되도록 redis에 key를 추가한 내용이다. 로직의 맨 처음 부분에 userId를 key로 하고 발급 숫자를 value로 하는 Set 자료형을 SADD 명령어를 통해 추가한다. 그래서 해당 user가 발급을 요청하면 redis에 userId: 1 이라고 쌓이게 되고, 똑같은 id로 요청을 날리면 값이 이미 1로 업데이트 되었으므로 early return 되어 쿠폰 발급이 되지 않는 로직이다. 
 

초고속 요청에 의한 중복 발생 방지

내 생각에 redis set 자료형을 사용하더라도 프로그래밍적으로 엄청나게 빠른 속도로 요청하면 중복이 발생할 수 있을 것 같았다. 즉 사용자 요청이 왔을 때 userRepository에서 사용자 정보를 조회하고, redis에 SADD 명령어를 날리는 짧은 순간동안 같은 사용자 id가 요청이 다시 오면 value값을 두 스레드가 모두 없다고 판단하여 쿠폰 발급이 중복될 수 있을 것 같았다. 이를 방지할려면 아래 방법을 추천한다고 한다.
 
1. RedissonSETNX(SET if Not eXists)를 사용하여 키가 존재하지 않을 때 원자적 연산을 확보함. TTL과 함께 쓰면 자동으로 만료되는 분산 잠금 구조이다.

SET lock:user:1 1 NX PX 3000

 
3초 동안 lock을 설정하므로 같은 userId에 의한 3초 내의 다음 요청은 실패한다. 따라서 중복은 안되고, 3초간만 유지하므로 완전히 데드락이 걸리는 경우는 방지할 수 있게 된다.
 
 
2. Lua Script를 통해 한번에 처리

-- Lua script 예시
-- 1. SADD로 유저 추가 (중복 체크)
-- 2. 쿠폰 카운트 증가
-- 3. 카운트가 100 초과면 롤백

 
 

Kafka 적용 결과

위 로직대로 redis에서 정합성을 검토하고, 정합성 검증을 통과하면 Producer에 의해 메시지가 발행된다. 메시지 발행은 DB에 직접 접근을 하지 않으므로 상대적으로 빠르게 처리되고 부하도 덜 걸린다. 이후 아래 코드처럼 Consumer에 의해 순차적으로 처리되면서 DB의 부하가 적게 걸린다.
 

@Component
public class CouponCreatedConsumer {

    private final CouponRepository couponRepository;
    private final FailedEventRepository failedEventRepository;

    private final Logger logger = LoggerFactory.getLogger(CouponCreatedConsumer.class);

    public CouponCreatedConsumer(CouponRepository couponRepository, FailedEventRepository failedEventRepository) {
        this.couponRepository = couponRepository;
        this.failedEventRepository = failedEventRepository;
    }

    @KafkaListener(topics = "coupon_create", groupId = "group_1")
    public void listener(Long userId) {

        try {
            couponRepository.save(new Coupon(userId));
        } catch (Exception e) {
            logger.error("failed to create coupon::{}", userId);
            failedEventRepository.save(new FailedEvent(userId));
        }
    }
}

 
단순히 이렇게 하면 DB에 부하가 걸리는 건 똑같다. 다만 아래 사항들 때문에 DB 부하를 줄일 수 있는 가능성을 확보한다.
1. @KafkaListener 설정에서 concurrency = "1" 등으로 설정하면 병렬 스레드 수를 제한할 수 있다.
2. listener에서 userId를 list로 받아오고, saveAll() 로직을 통해 배치로 처리할 수 있다.
3. Queue + Worker를 적용하여 Consumer에 쌓인 메시지를 큐, Redis, 임시 RDB에 저장하고 Worker가 속도를 조절하면서 처리하게 할 수 있다.
4. 다른 요소가 없더라도 kafka의 Dead letter Topic에 실패 내용을 기록하면 나중에 처리할 수 있다. 또는 위 코드처럼 try-catch문으로 따로 실패 요소를 RDB에 저장해놓아도 백프레셔 보호가 된다.
 
 
Kafka 테스트 시 유의사항
아래 테스트에서 Producer의 메시지 발행 -> 테스트 종료 -> Consumer 처리가 될 수 있으므로 Thread.sleep()처럼 여유 시간을 주지 않는다면 테스트에 실패한다. 이에 유의해야한다(주석 참고).
 

@Test
public void 한명당_한개의쿠폰만_발급() throws InterruptedException {
    //테스트 할 때마다 redis flushall을 통해 데이터 초기화 필요

    int threadCount = 1000;

    ExecutorService executorService = Executors.newFixedThreadPool(32);

    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executorService.submit(() -> {
            try {
                applyService.apply(1L); //유저를 고정
            } finally {
                latch.countDown();
            }
        });
    }
    //submit()만 사용하면 작업을 “시작”한 것일 뿐이고, 끝났는지 보장하지 않기 때문에 반드시 latch.await()로 기다려야 함
    latch.await(); //모든 apply가 끝날 때까지 메인 스레드가 기다리도록 함


    //카프카를 사용해도 그냥 하면 실패하는 이유 : 카프카를 사용하면 DB에 부하는 덜하지만 약간의 텀이 필요하다는 것을 알 수 있다.
    // producer는 발행 후, 테스트 케이스가 끝남. 그 와중에 consumer가 데이터를 처리 중이므로 100개 대비 모자란 것처럼 되면서 테스트에 실패함
    //아래처럼 sleep을 걸어주면 테스트에 성공함(시간 충분히 줄 것)
    Thread.sleep(5000);

    long count = couponRepository.count();

    assertThat(count).isEqualTo(1);
}

 

728x90
반응형