본문 바로가기
후기🔥/회고록

잡다에서 선착순 이벤트를 구현한 방법

by 안주형 2023. 8. 31.

들어가며

안녕하세요. 마이다스인 잡다 개발셀에서 백엔드 개발을 하고 있는 안주형입니다.

잡다에서는 이번에 선착순으로 진행되는 이벤트가 추가되었는데요, 이전까지 잡다에서는 선착순과 관련된 이벤트가 진행된 적이 없습니다. 따라서 이번에 처음 개발하는 로직들이 이후의 새로운 이벤트에서도 계속해서 사용되고, 다른 사람들에 의해서 팔로업 될 예정이기 때문에 설계 과정부터 많은 고민을 했었습니다. 그리고 이러한 기능을 설계하고 개발하는 과정에서 했던 많은 고민과 경험들을 공유해 드리고자 합니다.
 
 

선착순 이벤트 요구사항

  1. 선착순으로 n명까지만 기프티콘을 받을 수 있다.
  2. 기프티콘은 1인당 한번, 즉 중복으로 받을 수는 없다.

저는 분산락을 통해 동시성 이슈를 해결하고, 위 요구사항을 충족하기 위해 Redis를 활용하기로 했습니다. 그 이유는, RedisIn-memory DB로써 속도가 빠르며, RDB에서 락을 사용하기 위해서는 별도의 커넥션 풀을 관리해야 하고, 락에 관련된 부하를 RDB에서 받는다는 점이 효율적이지 않다고 생각했기 때문입니다.

 

 

분산락을 구현한 이유

Redis"싱글스레드로 명령을 수행하므로, 애초에 동시성이 발생할 수가 없는 거 아니야?"라고 생각할 수 있습니다.

일단 질문에 대한 답은 맞습니다. Redis의 명령 자체는 원자적(atomic)입니다. 하지만 아래와 같이 여러 개의 연속된 Redis 명령어를 실행할 때 그 사이에 다른 클라이언트의 명령이 들어올 경우를 고려해야 합니다.

if (redis.get(key) < 100) {
    redis.incr(key)
}

이 코드 같은 경우는 동시성 문제에서 안전하지 않습니다. 개수가 100개 미만일 때만 카운팅 되도록 설계했지만, 많은 스레드들이 동시에 if문의 검증 조건을 통과해서 Redis에 값을 쓰려고 하면, 100개 이상의 초과 케이스가 발생할 수 있습니다.

Redis에서는 이런 케이스를 손쉽게 해결할 수 있도록 Transaction(WATCH, MULTI, EXEC 등)을 제공하며, 이를 통해 여러 단계에 걸친 Redis 명령어 집한 전체를 하나의 트랜잭션으로 처리하여 원자적으로 처리할 수 있습니다. 또한 이 방식은 Redis 내부에서 제공하는 기능이므로, 추가적인 라이브러리나 도구가 필요하지 않다는 장점이 있습니다.

그러나 이 방식을 선택하지 않은 네 가지 이유가 있는데, 다음과 같습니다.

  1. Redis 트랜잭션은 Redis 명령어들만 원자적으로 처리가 가능하기에, 트랜잭션 중간에 Redis 명령어를 제외한 코드를 삽입하는 것은 불가능하다. 즉, Redis에 읽고 쓰는 작업들만 원자적으로 구성할 수 있다.
  2. Cluster Redis 환경에서는 트랜잭션 처리가 복잡해진다.
  3. 이 방식은 Optimistic Locking을 사용하므로, 데이터 변경 시 충돌이 발생하면 재시도해야 하는 부담이 존재한다.(물론 국내 대부분의 서비스 트래픽은 Optimistic Locking 방식으로 충분히 처리가 가능하다는 의견이 많다.)
  4. Redis Transaction 관련한 지식이 당시 없어, 스프린트 기간 내에 개발과 검증까지 진행하기 위해 학습 경험이 있는 분산락을 적용하는 게 더 나은 선택으로 보였다.

저는 단순히 이번 선착순 이벤트만을 위한 동시성 이슈를 해결하기보다는, 다양한 기획에서도 동시성 문제를 해결할 수 있는 토대를 마련하고자 했고, 이전에 학습했던 경험을 통해 스프린트 기간일정을 맞추기 위해 다중 인스턴스 환경에서도 공통된 락을 사용할 수 있는 분산 락을 구현하기로 했습니다.

 

 

Redis의 Redisson 라이브러리를 선정한 이유

Java의 Redis 클라이언트에는 Jedis, Lettuce, Redisson 이렇게 크게 세 가지의 라이브러리가 존재합니다. 세 가지 라이브러리 전부 분산락 구현이 가능하지만, 저희는 Redisson을 선정했는데 이유는 다음과 같습니다.

  • Jedis는 Lettuce에 비해 성능이 매우 떨어지기 때문에 고려 대상이 아니었다.
  • Lettuce는 스핀락 기반으로, 요청이 많을수록 Redis의 부하가 커지고 개발자가 retry 로직을 직접 작성해야 한다.
  • Redisson은 Pub/Sub 기반으로 Lock 구현이 가능하고 타임아웃 기능이 구현되어 있다.

더 자세한 내용이 궁금하시다면 아래 내용을 참고해 보시길 추천드립니다.

 

 

분산 락 구현

AOP를 사용하여 간단하게 어노테이션만 붙이면, 메서드 실행 전 후에 분산 락이 처리되도록 하는 로직을 구현했습니다.

DistributedLock.java

/**
 * 분산 잠금을 위한 어노테이션. 해당 어노테이션을 메서드에 적용하면
 * 해당 메서드 실행 시 분산 잠금이 적용된다.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DistributedLock {
    /**
     * 잠금 키를 지정한다. Spring EL 표현식을 사용하여 동적인 값을 지정한다.
     * 예: "#paramName"
     *
     * @return 잠금 키 문자열
     */
    String key() default "";

    /**
     * 잠금을 획득하기 위해 대기할 최대 시간(초)을 지정.
     *
     * @return 대기 시간 (단위: 초)
     */
    long waitTime() default 5;

    /**
     * 잠금을 보유할 최대 시간(초)을 지정.
     *
     * @return 보유 시간 (단위: 초)
     */
    long leaseTime() default 1;
}

@DistributedLock 어노테이션을 정의하여, 어노테이션만 붙이면 메서드에 분산락을 적용할 수 있도록 만들었습니다.

 
DistributedLockAop.java

@Aspect
@Component
@RequiredArgsConstructor
@Slf4j
public class DistributedLockAop {
    private static final String REDISSON_LOCK_PREFIX = "LOCK:";
    private final RedissonClient redissonClient;

    /**
     * @DistributedLock 어노테이션이 붙은 메소드를 대상으로 하는 어드바이스.
     * 해당 메소드를 실행하기 전에 분산 잠금을 시도하고, 실행 후 잠금을 해제한다.
     */
    @Around("@annotation(distributedLock)")
    public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
        MethodSignature signature = (MethodSignature) joinPoint.getSignature();

        // 잠금 키 생성: 고정된 접두사 + 동적 EL 표현식 값
        String key = REDISSON_LOCK_PREFIX + CustomSpringELParser.getDynamicValue(signature.getParameterNames(), joinPoint.getArgs(), distributedLock.key());
        RLock lock = redissonClient.getLock(key);

        try {
            // 잠금 시도: 지정된 대기 시간 동안 잠금을 획득하려고 시도
            if (!lock.tryLock(distributedLock.waitTime(), distributedLock.leaseTime(), TimeUnit.SECONDS)) {
                log.info("Failed to acquire lock.");
                return null;
            }
            // 잠금 획득 성공 시, 대상 메소드 실행
            return joinPoint.proceed();
        } finally {
            // 현재 스레드가 잠금을 보유하고 있는지 확인 후 잠금 해제
            if (lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}

DistributedLockAop 클래스는 @DistrubutedLock 어노테이션을 활용하여 특정 메서드에 분산 락을 적용하는 AOP 로직을 제공합니다. @DistrubutedLock 어노테이션이 붙은 메서드가 호출될 때, 이 AOP 로직이 작동하여 Redisson을 사용한 Redis 분산 락 메커니즘이 적용됩니다.

  1. @DistributedLock 어노테이션이 붙은 메서드가 호출되면 DistributedLockAop의 lock 메서드가 실행된다.
  2. 해당 메서드는 먼저 동적 잠금 키를 생성하며, 이때 @DistributedLock의 Key 값을 SpEL로 평가하여 사용한다.
  3. 이 동적 키로 Redisson의 RLock 객체를 얻어온다.
  4. 지정된 시간(waitTime) 동안 잠금을 시도한다. 성공하면 메서드를 실행하고, 실패하면 null을 반환한다.
  5. 메서드 실행 후, 현재 스레드가 잠금을 보유하고 있으면 잠금을 해제한다.

또한 RedissonClient 인터페이스에는 다양한 종류의 분산 잠금을 제공하는데, Redisson에서 사용할 수 있는 잠금 매커니즘의 종류는 다음과 같습니다. - 공식 문서

  • 기본 잠금(getLock): 재진입이 가능한 기본 잠금이다. 사용이 간단하지만 공정성을 보장하지 않아, 기아 상태가 발생할 수 있다.
  • 공정 잠금(getFairLock): 대기 순서에 따라 잠금을 획득하는 공정한 방식이다. 공정성을 제공하지만 성능 오버헤드가 발생할 수 있다.
  • 읽기-쓰기 잠금(getReadWriteLock): 읽기와 쓰기 작업에 대해 다른 잠금을 제공한다. 읽기 작업의 병렬성을 허용하지만 쓰기 잠금에 대한 경쟁이 성능 저하를 일으킬 수 있다.
  • 다중 잠금(getMultiLock): 여러 잠금을 하나로 묶어서 관리한다. 여러 리소스에 대해 동시 접근이 가능하지만, 하나라도 획득 실패 시 전체 작업이 실패한다.
  • 레드락(getRedLock): 여러 레디스 인스턴스에 걸쳐서 잠금을 관리한다. 높은 가용성과 안정성을 제공하지만, 그에 따른 구현 복잡성과 네트워크 지연의 영향을 받을 수 있다.

getLock()은 모든 Redisson 인스턴스의 대기 중인 다른 스레드에 알림을 보내는 방식을 사용하여 잠금을 획득합니다. 이는 동시성 문제를 효과적으로 해결하지만, 알림이 동시에 여러 스레드에 전송되므로 선착순을 완벽하게 보장하지는 못합니다. 따라서 요청 순서에 따른 동작이 중요하다면 대기 순서에 따라 잠금을 획득하는 공정 잠금(getFairLock)을 사용하는 것이 더 적합할 수 있습니다. 다만 getFairLock은 getLock에 비해 성능적으로 느리므로 제품의 상황에 알맞는 기능을 선택하는 것을 권장드립니다.

 
CustomSpringELParser.java

/**
 * Spring Expression Language (SpEL) 문자열을 파싱하기 위한 유틸리티 클래스.
 */
public class CustomSpringELParser {
    private CustomSpringELParser() {
    }

    /**
     * 주어진 SpEL 문자열을 파싱하고 평가한다. 제공된 매개 변수 이름과 인수를 사용한다.
     *
     * @param parameterNames SpEL 문자열 내의 매개 변수 이름들.
     * @param args           매개 변수의 값들.
     * @param key            평가될 SpEL 문자열.
     * @return SpEL 문자열 평가의 결과.
     */
    public static Object getDynamicValue(String[] parameterNames, Object[] args, String key) {
        ExpressionParser parser = new SpelExpressionParser();
        StandardEvaluationContext context = new StandardEvaluationContext();

        // 제공된 인수를 해당 매개 변수 이름에 할당
        for (int i = 0; i < parameterNames.length; i++) {
            context.setVariable(parameterNames[i], args[i]);
        }

        // SpEL 문자열 파싱 및 평가
        return parser.parseExpression(key).getValue(context, Object.class);
    }
}

DistrubutedLockAop 클래스 내의 lock 메서드에서는 @DistributedLock 어노테이션이 적용된 메서드가 호출될 때 분산 락을 적용하는데, 이때 중요한 점은 "락의 키를 어떻게 생성할 것인가?"입니다.

위에서 생성한 @DistributedLock 어노테이션에는 key라는 속성이 있습니다. 이 key는 Spring EL 표현식(SpEL)으로 작성될 수 있어서, 메서드 인수 등을 기반으로 동적인 Lock Key를 생성할 수 있습니다.

 
CustomSpringELParser의 역할

이 SpEL 표현식을 실제 값으로 변환하기 위해 CustomSpringELParser.getDynamicValue() 메서드가 호출됩니다.

String key = REDISSON_LOCK_PREFIX 
    + CustomSpringELParser.getDynamicValue(
        signature.getParameterNames(), 
        joinPoint.getArgs(), 
        distributedLock.key()
    );
  1. signature.getParameterNames(): 메서드의 파라미터 이름들을 가져온다.
  2. joinPoint.getArgs(): 호출된 메서드의 실제 인수 값을 가져온다.
  3. distributedLock.key(): @DistributedLock 어노테이션이 지정한 SpEL 문자열을 가져온다.

이 정보들을 CustomSpringELParser.getDynamicValue에 전달하면, 해당 메서드는 SpEL 문자열들을 보고 실제 키 값을 반환합니다.

@DistributedLock(key = "#id")
public void someMethod(String id) {
   // ...
}

예를 들어 위 메서드를 호출할 때 id 값으로 "JOBDA"를 전달한다면, 잠금키는 "LOCK:JOBDA"가 됩니다. 이는 CustomSpringELParser"#id"라는 SpEL 표현식을 "JOBDA"라는 값으로 평가하기 때문입니다. 따라서 이렇게 동적으로 잠금 키를 생성하여, 여러 곳에서 동일한 메서드를 호출할 때 다른 인수 값에 따라 다른 잠금을 적용할 수 있습니다.

여기까지가 분산 락의 구현이었고, 아래는 이를 이용한 "이벤트 비즈니스 로직"의 템플릿 코드입니다.

 

 

비즈니스 로직 템플릿

EventService.java

@Service
@Slf4j
@RequiredArgsConstructor
public class EventService {
    private static final Long TRUE = 1L;  // Redis 서비스가 TRUE를 반환할 때의 값

    private final EventRedisRepository eventRedisRepository;
    private final KafkaProducerService kafkaProducerService;

    /**
     * 사용자가 이벤트에 응모하는 메소드.
     * @DistributedLock 어노테이션을 통해 메소드 실행 전 분산 잠금을 시도.
     *
     * @param eventUserDto 사용자 정보
     * @param eventType 이벤트 타입
     * @param count 현재 이벤트에서 응모 가능한 인원 수
     */
    @DistributedLock(key = "#eventType.name()")
    public void enterUserIntoEvent(EventUserDto eventUserDto, EventType eventType, int count) {
        if (hasEventReachedCapacity(eventType, count)) {
            throw new EventCapacityExceededException(eventType + " 이벤트의 인원이 마감되었습니다.");
        }
        enterUserIfNotAlreadyEntered(eventUserDto, eventType);
    }

    /**
     * 해당 이벤트의 인원이 마감되었는지 확인.
     *
     * @param eventType 이벤트 타입
     * @param count 현재 이벤트에서 응모 가능한 인원 수
     * @return 인원이 마감된 경우 true, 아니면 false
     */
    private boolean hasEventReachedCapacity(EventType eventType, int count) {
        return eventRedisRepository.getCurrentEventCount(eventType) >= count;
    }

    /**
     * 사용자가 이미 이벤트에 응모했는지 확인 후, 아직 응모하지 않았다면 응모 로직을 실행.
     *
     * @param eventUserDto 사용자 정보
     * @param eventType 이벤트 타입
     */
    private void enterUserIfNotAlreadyEntered(EventUserDto eventUserDto, EventType eventType) {
        if (hasUserAlreadyEntered(eventUserDto, eventType)) {
            throw new EventAlreadyEnteredException(eventUserDto.userId() + "은(는) 이미 " + eventType + " 이벤트에 응모하였습니다.");
        }
        sendEventEntryMessage(eventUserDto, eventType);
    }

    /**
     * 사용자가 이미 이벤트에 응모되었는지 확인.
     *
     * @param eventUserDto 사용자 정보
     * @param eventType 이벤트 타입
     * @return 이미 응모되었으면 true, 아니면 false
     */
    private boolean hasUserAlreadyEntered(EventUserDto eventUserDto, EventType eventType) {
        return eventRedisRepository.addUserToEventSet(eventType, eventUserDto.userId()) != TRUE;
    }

    /**
     * Kafka를 통해 DB에 이벤트 응모 메시지를 저장할 수 있도록 메세지 발행.
     *
     * @param eventUserDto 사용자 정보
     * @param eventType 이벤트 타입
     */
    private void sendEventEntryMessage(EventUserDto eventUserDto, EventType eventType) {
        String topic = KafkaProducerTopics.EVENT_ENTRY.getTopic();
        KafkaEventEntryDto payload = new KafkaEventEntryDto(eventUserDto, eventType);

        kafkaProducerService.sendAsyncJsonMessage(topic, payload);
    } 
}

사용자가 이벤트에 성공적으로 응모하면, Kafka를 통해 Topic을 발행합니다. 이 TopicConsume에서 처리하여 후속으로 DB에 로깅하는 구조입니다. 이러한 방식으로 1. 책임과 역할을 분리하고 2. 서비스 간 의존도를 낮출 수 있습니다.

만약 프로젝트에 Kafka와 같은 메세징 서비스를 적용하기가 어렵다면 스프링에서 제공해 주는 ApplicationEventPublisher를 사용하는 것도 좋은 선택으로 보입니다.

 
EventRedisRepository.java

@Repository
@RequiredArgsConstructor
public class EventRedisRepository {
    // Redis에 저장될 때 사용되는 접미사
    private static final String EVENT_SUFFIX = "event:";

    // Spring Data Redis를 활용한 RedisTemplate 객체
    private final RedisTemplate<String, Object> redisTemplate;

    /**
     * 사용자를 해당 이벤트 등록 명단에 set에 추가.
     *
     * @param eventType 이벤트 타입
     * @param userId 사용자 고유 값
     * @return 성공적으로 추가되면 1, 이미 존재하면 0
     */
    public Long addUserToEventSet(EventType eventType, String userId) {
        return redisTemplate.opsForSet().add(generateEventSetKey(eventType), userId);
    }

    /**
     * 해당 이벤트에서 현재까지 등록된 사용자 수를 반환.
     *
     * @param eventType 이벤트 타입
     * @return 등록된 사용자 수
     */
    public Long getCurrentEventCount(EventType eventType) {
        return redisTemplate.opsForSet().size(generateEventSetKey(eventType));
    }

    /**
     * 이벤트 타입을 기반으로 Redis 저장용 키를 생성.
     *
     * @param eventType 이벤트 타입
     * @return Redis에서 사용되는 set의 키
     */
    public String generateEventSetKey(EventType eventType) {
        return EVENT_SUFFIX + eventType.name().toLowerCase();
    }
}

그리고, 동일한 유저가 중복으로 이벤트에 응모되는 것을 방지하기 위해 RedisSet 자료형(SADD, SCARD)을 사용하였습니다.

  • SADD(Set Add): Set에 자료를 추가하는 연산으로, 만약 주어진 데이터가 Set에 이미 존재하는 경우 다시 추가되지 않습니다. 또한 값이 이미 Set에 존재하는 경우 0을 반환하고, 신규로 추가된 경우 추가된 값의 개수를 반환합니다.
  • SCARD(Set Cardinallity): Set에 있는 데이터의 수, 즉 Set의 크기를 반환합니다.

 

 

잡다의 이벤트 처리 구성도

잡다에서는 Redis를 활용하여 실시간 이벤트 처리 속도를 향상 시켰습니다. 실제 DB에 직접 접근하는 대신, Redis에서 사용자 응모 상태와 현재 이벤트 참여 인원을 관리함으로써 빠른 응답 시간을 보장했습니다.

그리고 Kafka를 활용하여 Consume에서 로깅이나, 기프티콘 발행과 같은 후속 로직을 비동기적으로 처리하도록 하여 서비스 간의 의존도를 낮추어, 시스템을 더 유연하고 확장 가능하게 설계했습니다.

 

 

테스트

정확한 수만큼만 이벤트에 등록되는지 테스트

    @Test
    @DisplayName("동시에 여러 사용자가 이벤트에 응모할 때, 응모 가능한 최대 인원 수를 초과하지 않아야 한다.")
    void 동시_응모시_인원수_초과_안함() throws InterruptedException {
        // given
        int 동시_요청_개수 = 1000;
        int 최대_참가_인원 = 100;
        CountDownLatch latch = new CountDownLatch(동시_요청_개수);
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        // when
        for (int i = 0; i < 동시_요청_개수; i++) {
            executorService.submit(() -> {
                try {
                    String 유저아이디 = UUID.randomUUID().toString();  // 고유한 사용자 ID 생성
                    eventService.enterUserIntoEvent(new EventUserDto(유저아이디), EventType.TEST, 최대_참가_인원);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        // then
        assertThat(eventRedisRepository.getCurrentEventCount(EventType.TEST)).isEqualTo(최대_참가_인원);
    }

 
동일 유저가 여러 번 응모할 경우 한 번만 응모되는지 테스트

    @Test
    @DisplayName("중복된 사용자 ID로 여러 번 응모할 경우, 한 번만 응모가 되어야 한다.")
    void 중복_사용자ID_응모_불가() throws InterruptedException {
        // given
        int 동시_요청_개수 = 1000;
        int 최대_참가_인원 = 100;
        CountDownLatch latch = new CountDownLatch(동시_요청_개수);
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        String 중복_유저아이디 = "중복아이디";

        // when
        for (int i = 0; i < 동시_요청_개수; i++) {
            executorService.submit(() -> {
                try {
                    eventService.enterUserIntoEvent(new EventUserDto(new EventUserDto(중복_유저아이디), 최대_참가_인원);
                } finally {
                    latch.countDown();
                }
            });
        }

        latch.await();
        executorService.shutdown();

        // then
        assertThat(eventRedisRepository.getCurrentEventCount(EventType.TEST)).isEqualTo(1L);
    }

 
분산 락 미적용 테스트: 메서드에 @DistributedLock(key = "#eventType.name()") 제거

 

 

Reference

'후기🔥 > 회고록' 카테고리의 다른 글

2023년 회고  (10) 2024.01.14
잡다에서 기업 정보를 수집하는 방법  (4) 2023.12.28
NextStep TDD, Clean Code with Java 수료 후기  (2) 2023.06.05
2022년 회고  (13) 2023.01.27
넘블 디프백-프로젝트 회고록  (0) 2022.12.05

댓글