Skip to content

선착순 이벤트 구현방안 및 성능 보고서

SEUNGUN CHAE edited this page Aug 28, 2024 · 27 revisions

Background

image

  • 팀 어썸오렌지는 특정 시각이 되었을 때 최대 4장의 카드를 뒤집으며 정답 카드를 뽑은 순서대로 당첨자를 선발하는 선착순 이벤트를 구현해야 했습니다.
  • 주어진 환경에서 응답 시간을 최소화하고, 처리량을 최대화하며, 분산 환경으로 확장 가능한 선착순 이벤트를 위한 가장 합리적인 기술적 선택이 필요했습니다.
  • 이 보고서에선 4가지 구현 방안의 "부하 테스트코드의 실행시간"과 "Apache JMeter의 API 요청 부하 테스트" 결과를 바탕으로, 주요 기능의 성능을 개략적으로 검증하고 최적의 선택을 내리는 과정을 기록했습니다.

성능 분석 개요

  • 모든 구현방안은 원자성이 보장되어 있으며, 당첨자 수는 “100명”으로 설정했습니다.

  • 요청 수는 1000/2000/3000/5000/10000으로 설정했습니다.

  • 모든 성능 관련 데이터는 10회 실행 후 평균치로 기록되었기에 개략적인 수치입니다.

  • 먼저 작성한 테스트코드로 부하 테스트를 진행하고 실행 시간을 기록합니다.

  • 전체적인 부하 테스트 코드의 틀은 아래와 같습니다.

      @Test
      void participateTest() throws InterruptedException {
          int numberOfThreads = 200; // 가용 스레드 수 설정
          int numberOfUsers = 1000; // 동시 참여 사용자 수 설정
      
          ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
      
          long startTime = System.currentTimeMillis();
          CountDownLatch latch = new CountDownLatch(numberOfUsers);
          for (int i = 0; i < numberOfUsers; i++) {
              final int index = i;
              executorService.execute(() -> {
                  try {
                      // 각 구현방안마다 아래 Service를 교체
                      boolean result = redisLuaFcfsService.participate(1L, "user" + index);
                  } catch (Exception e) {
                      e.printStackTrace();
                  } finally {
                      latch.countDown();
                  }
              });
          }
      
          latch.await();
      
          long endTime = System.currentTimeMillis();
          // 성능 측정
          log.info("Total time: {} ms", endTime - startTime);
      
          executorService.shutdown();
          Long count = stringRedisTemplate.opsForZSet().zCard(FcfsUtil.winnerFormatting(eventSequence.toString()));
          // 동시성 문제 발생 여부 검사
          assertThat(count).isEqualTo(numberOfWinners);
      }
    
  • 다음으로 Apache JMeter로 (Springboot 실행 → API 요청의 부하 테스트)를 10회 반복하여 성능 관련 데이터의 평균치를 기록합니다.

    • 프로그램에서 권장하는 대로 GUI가 아닌 CLI를 통해 테스트를 실행하며 테스트당 1분의 대기시간을 제공하여 안정적인 테스트 결과를 도모하였습니다.
    • 실제로 GUI로 실행한 테스트 결과가 CLI보다 에러율이 낮았습니다. GUI는 CLI보다 스레드 투입 속도가 느린 것으로 결론을 내린 상태입니다.

1. Redisson 분산 Lock

소스 코드

  • Redisson을 활용한 분산 락으로 선착순 이벤트의 동시성을 관리하고, 당첨 및 참여 여부를 Redis에 저장하는 방안입니다.
  • Redisson은 Redis의 기본 Lock보다 더 편리한 Lock 기능(자동 만료 및 재시도, 기본 락 대비 간단한 사용법 등)을 제공하고 있기에 이를 채택했습니다.
  • 분산 환경에서도 동작하는 확장성 있는 동시성 제어 전략이지만, 기본적인 오버헤드가 강한 것으로 확인되었습니다.

테스트코드 실행 결과

스레드 수 요청 수 실행 시간(ms)
200 1000 495.8
  2000 651.4
  3000 740.9
  5000 884.4
  10000 1128.1
400 1000 702.8
  2000 798.5
  3000 909.3
  5000 1065.9
  10000 1312.7

Apache Jmeter

스레드 수 요청 수 평균 응답(ms) 최소 응답 최대 응답 평균 에러 비율 단위 시간당 처리량(TPS)
200 1000 172.5 2.4 807.4 1.26% 914.32
  2000 102.2 0 909.1 0.8% 991.15
  3000 468.1 74.9 1142.5 6.45% 1907.18
  5000 319.2 1.7 1176.3 9.06% 2485.43
  10000 178 0 1260.4 11% 3264.92

2. Redis SortedSet + Java synchronized

소스 코드

  • Redis SortedSet에 몰려드는 요청 정보를 저장하되, 마감 여부를 판정하는 과정에 synchronized 키워드를 붙여 마감 인원을 충족했는데 추가로 난입하는 사례를 막아서 동시성을 확보하는 방안입니다.
  • In-Memory DB인 Redis의 특성을 활용한 JPA Entity 대비 빠른 요청 정보 저장 및 마감 여부 확인이 가능하며, 동시성은 synchronized 키워드의 isEventFull() 메서드로 관리합니다.
  • LuaScript 방식보다 실행 시간이 길지만 스레드 크기가 늘어날수록 실행 시간이 비교적 천천히 늘어난다는 특징을 갖고 있습니다.
  • 다만 분산 환경에서 synchronized가 동시성 문제를 방지할 수 없다는 치명적인 단점이 있기에 초기 검토 후 배제되었습니다.

테스트코드 실행 결과

스레드 수 요청 수 실행 시간(ms)
200 1000 223.9
  2000 298.7
  3000 364.3
  5000 517.9
  10000 795.3
400 1000 289.0
  2000 379.8
  3000 395.2
  5000 601.2
  10000 883.8

Apache Jmeter

스레드 수 요청 수 평균 응답(ms) 최소 응답 최대 응답 평균 에러 비율 단위 시간당 처리량(TPS)
200 1000 117.2 0 461.2 1.38% 982.36
  2000 64.3 0 523.1 0% 991.1
  3000 267.7 0.1 911.8 0.9% 2217.05
  5000 163.1 0 941.6 7.1% 2651.72
  10000 105.5 0 1013.6 5.42% 3437.68

3. Redis SortedSet + LuaScript

소스 코드

  • Redis SortedSet을 활용하되, 선착순 이벤트에 참여하는 과정 자체는 LuaScript로 원자적으로 실행하도록 했습니다.
  • 구체적으로, 현재 당첨자 수를 확인하여 최대 당첨자 수보다 적다면 이를 저장하며, 만약 그렇지 않다면 0을 반환하도록 설계했습니다.
  • 가장 우수한 성능을 보이며 분산 환경에서도 유효한 구현방안입니다.
String script = "local count = redis.call('zcard', KEYS[1]) " +
            "if count < tonumber(ARGV[1]) then " +
            "    redis.call('zadd', KEYS[1], ARGV[2], ARGV[3]) " +
            "    return redis.call('zcard', KEYS[1]) " +
            "else " +
            "    return 0 " +
            "end";    

테스트코드 실행 결과

스레드 수 요청 수 실행 시간(ms)
200 1000 180.5
  2000 244.1
  3000 311.4
  5000 418.4
  10000 700.0
400 1000 213.9
  2000 299.6
  3000 372.9
  5000 501.3
  10000 853.2

Apache Jmeter

스레드 수 요청 수 평균 응답(ms) 최소 응답 최대 응답 평균 에러 비율 단위 시간당 처리량(TPS)
200 1000 114.7 0.3 391.8 0.03% 983.3
  2000 71.8 0 446.6 0.9% 992.03
  3000 137.3 0 590.0 1.9% 2010.12
  5000 128.3 0 761.4 2.0% 2516.85
  10000 55 0 615.3 4.18% 3473.98

4. JPA Pessimistic Lock

소스 코드

  • JPA 기반으로 직접 DB에 접근하여 선착순 이벤트 참여 정보를 저장합니다. 단순 @Transcational로는 해결되지 않은 동시성 이슈 관리를 위해 JPA에서 제공하는 비관적 락을 활용하였습니다.
  • Redis의 종료 flag만을 제외하고 타 자료구조가 필요없기에 코드가 간결해진다는 장점이 있습니다.
  • 다만 당첨자수만큼 DB 테이블에 접근하고 저장하는 연산이 요구되기에 모든 구현방안 중 가장 느립니다.

테스트코드 실행 결과

스레드 수 요청 수 실행 시간(ms)
200 1000 860.0
  2000 925.6
  3000 993.7
  5000 1195.4
  10000 1721.4
400 1000 773.3
  2000 882.6
  3000 980.4
  5000 1163.8
  10000 1612.0

Apache Jmeter

스레드 수 요청 수 평균 응답(ms) 최소 응답 최대 응답 평균 에러 비율 단위 시간당 처리량(TPS)
200 1000 208.4 0.6 830.3 8.72% 806.23
  2000 181.7 0 1024 1.06% 991.05
  3000 682.4 99.8 1253.4 13.5% 1586.41
  5000 844 43.5 1258 40.1% 2082.34
  10000 853.5 7.8 1299 74.7% 2521.1

기타 검토했으나 폐기된 구현방안

synchronized 활용

@Override
@Transactional
public synchronized boolean participate(Long eventSequence, String userId){
    FcfsEvent fcfsEvent = fcfsEventRepository.findById(eventSequence)
            .orElseThrow(() -> new FcfsEventException(ErrorCode.EVENT_NOT_FOUND));
    EventUser eventUser = eventUserRepository.findByUserId(userId)
            .orElseThrow(() -> new FcfsEventException(ErrorCode.EVENT_USER_NOT_FOUND));

    validateParticipate(fcfsEvent, eventUser);

    long currentCount = fcfsEventWinningInfoRepository.countByFcfsEventId(eventSequence);
    if (currentCount >= fcfsEvent.getParticipantCount()) {
        log.info("당첨자 수가 초과되었습니다. 현재 당첨자 수: {}", currentCount);
        return false;
    }

    fcfsEventWinningInfoRepository.save(FcfsEventWinningInfo.of(fcfsEvent, eventUser));
    log.info("당첨자 수: {}", fcfsEventWinningInfoRepository.countByFcfsEventId(eventSequence));
    return true;
}
  • 선착순 이벤트를 처리하는 메서드에 synchronized 키워드를 붙여 간단하게 동시성 문제를 방지하고자 했으나 간헐적으로 당첨자 수가 초과되는 등 의도대로 동작하지 않았습니다.
  • 메서드 내부의 synchronized block 형식으로 처리해도 동시성 문제가 해결되지 않아서 해당 방안은 폐기되었습니다.
  • 이는 @Transcational과 synchronized를 함께 사용할 경우 Spring AOP로 인해 프록시 객체가 만들어지고, 원래 객체인 DbFcfsService의 participate()실행이 끝난 뒤 트랜잭션이 커밋되기 전 다른 스레드가 데이터를 조회했기 때문입니다.
class DbFcfsServiceProxy extends DbFcfsService {

	private DbFcfsService dbFcfsService;
	
	@Override
	public boolean participate(Long eventSequence, String userId)
		try {
			tx.start();
			dbFcfsService.participate(eventSequence, String userId);
		} catch (Exception e){
			//
		} finally {
			tx.commit();
		}
}


class DbFcfsService {
	public synchronized boolean participate(Long eventSequence, String userId){
	
	}
}
  • 프록시 객체는 원본 객체와 달리 synchronized 키워드가 유효하지 않기 때문에 여러 스레드가 동시에 난입할 수 있다는 문제가 있었습니다.
  • 또한 synchronized는 하나의 프로세스 내에서만 동시성 제어가 가능하다는 한계로 인해 여러 서버로 존재하는 분산 환경에서 의도대로 동작하지 않을 가능성이 높습니다. 이는 위에서 실험한 2번 방안을 구현 방안 후보군에서 제외하는 가장 결정적인 원인이 되었습니다.

RabbitMQ 활용

  • RabbitMQ를 활용해 요청을 큐잉하고 Consume하고자 했으나, 초당 수천 번씩 쏟아지는 요청 속도보다 큐에 들어가서 Consume하는 속도가 현저하게 느려 핵심 요구사항이었던 "요청 직후 이벤트 당첨 여부 판정"에 큰 에로사항이 있었습니다.
  • Redis SortedSet 등을 삽입해 동시성을 관리하고자 했으나, 타 구현방안과 비교하여 차별성이 없어 폐기되었습니다.

결론

부하 테스트코드 실행시간 (스레드 200개 기준)

image

  • 실행 시간 기준으로 3번 방안(Redis + LuaScript)의 성능이 가장 우수한 것을 확인할 수 있습니다.

평균 응답 시간

image

1번 방안(Redis 분산Lock), 4번 방안(DB)의 평균 응답시간이 특정 시점에서 폭등하는 것을 확인할 수 있습니다. 반면 2번 방안(Redis SortedSet + synchronized)과 3번 방안(Redis + LuaScript)은 비교적 안정적인 평균 응답시간을 제공하고 있습니다.

요청 에러 비율

image
  • 4번 방안(DB)은 에러 비율이 폭등하고 있기에 현재 요구사항 상 채택이 어렵다고 결론내렸습니다.
  • 한편, Redis 기반으로 운영되는 다른 3가지 방안은 비교적 에러 비율이 낮은 것을 확인할 수 있습니다.

단위 시간당 처리량(TPS)

image

2번과 3번 방안의 TPS가 가장 높고 서로 거의 유사함을 확인할 수 있었습니다.

결론

구현방안 실시간으로 참여 결과 판정 단일 환경에서의 동시성 제어 분산 환경에서의 유효성 실행 시간
RabbitMQ 기반의 메시지 큐 X      
DB + synchronized O X    
Redis SortedSet + synchronized O O X  
JPA 비관적 락 (mySQL) O O O 3위
Redis 분산 락 O O O 2위
Redis + LuaScript O O O 1위

성능 측정 결과 데이터와 각 구현방안의 특징을 종합하여, Redis와 LuaScript를 활용하는 3번 방안이 최적이라고 결론내렸습니다.

Clone this wiki locally