중복 예약 상황에서의 동시성 비교: Redis(Cache) vs H2(Disk)

CodeKong의 기술 블로그·2024년 5월 14일
3

JAVA

목록 보기
5/5

서론

  • 배경 및 필요성

    • 프로젝트를 진행하며 마주한 중복예약 문제에 관해 테스트 해보고자 함
    • 기존 프로젝트는 레거시 코드로 엮여있어서 간단한 시나리오 상황을 만들어 보며 진행하였음
  • 동시성 문제를 해결하는 다양한 방법

    1. DB에 pending 걸어놓기
      -> 유효시간이 지난 예약 보류 정보 데이터를 batch를 통해 삭제해야 했기 때문에 DB에 부하가 걸릴것이라고 생각

    2. synchronized 키워드 사용
      -> 테스트 해보기로함

테스트 환경 설정

  • 시스템 구성

    • spring-boot-starter-data-redis 3.2.5

    • H2 2.2.224 (Server 모드)
      -> 임베디드 모드는 메모리에 올려서 사용하기 때문에 테스트 해봤더니 redis보다 더 빨랐음

  • 테스트 시나리오
    • 중복 예약 시나리오
      -> 같은 시작 시간 - 끝 시작의 예약 객체
      -> 2000개의 Thread를 동시에 실행 ( item 하나에 대한 테스트 )
      -> 실제 환경에서는 더 많은 item으로 부하가 더 발생한다

도메인 코드

H2

public class Reservation {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private LocalDateTime startTime;

    private LocalDateTime endTime;
}
public void save(Reservation reservation) throws IllegalArgumentException{

        if (!overlapsWithExisting(reservation)) {
            reservationRepository.save(reservation);
        }

    }
  • 간략 코드 예약 정보 엔티티, 검증후 저장을 수행하는 메서드

Redis

public class RedisReservation {

    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private long id;
    private long start;
    private long end;

}
public void save(RedisReservation reservation) throws JsonProcessingException {
        String key = "reservation";

        String s = objectMapper.writeValueAsString(reservation);

        // 검증후 저장
        if (getReservations().stream().noneMatch(r -> overlapsWithExisting(reservation, r))) {
            redisTemplate.opsForList().rightPush(key, s);
        }

    }

    public List<RedisReservation> getReservations() throws JsonProcessingException {
        String key = "reservation";

        // List<Reservation>으로 변환
        List<Object> range = redisTemplate.opsForList().range(key, 0, -1);

        return range.stream()
                .map(o -> {
                    try {
                        return objectMapper.readValue(Objects.requireNonNull(o).toString(), RedisReservation.class);
                    } catch (JsonProcessingException e) {
                        e.printStackTrace();
                        return null;
                    }
                })
                .collect(Collectors.toList());
    }
  • 직렬화 과정이 추가되어 조금더 복잡하지만 최대한 동일한 조건으로 수행하고자 하였다

H2를 사용한 동시성 테스트

테스트 코드

@Test
    public void 중복_예약_테스트() throws InterruptedException {
        int threadCount = 2000;
        CountDownLatch latch = new CountDownLatch(threadCount);
        List<Long> executionTimes = new ArrayList<>();

        saveTest(threadCount, executionTimes, latch);

        latch.await(); // 모든 스레드가 종료될 때까지 기다림

        // 모든 스레드의 실행 시간 평균 계산
        double averageTime = executionTimes.stream().mapToDouble(Long::doubleValue).average().orElse(0.0);
        System.out.println("평균 실행 시간: " + averageTime + "ms");

        assertThat(reservationRepository.findAll().size()).isEqualTo(1);
    }

    private void saveTest(int threadCount, List<Long> executionTimes, CountDownLatch latch) {
        for (int i = 0; i < threadCount; i++) {

            Thread reservationThread = new Thread(() -> {
                long start = System.nanoTime();
                saveReservation();
                long end = System.nanoTime();
                long duration = (end - start) / 1_000_000; // 밀리초로 변환
                executionTimes.add(duration);
                System.out.println("스레드 실행 시간: " + duration + "ms");

                latch.countDown();
            });

            reservationThread.start();
        }
    }

    private void saveReservation() {
        Reservation reservation = Reservation.builder()
                .startTime(LocalDateTime.now())
                .endTime(LocalDateTime.now().plusHours(2))
                .build();

        reservationService.save(reservation);
    }
  • CountDownLatch을 통해 각 스레드의 동작 시간, 평균 시간을 측정하였다

assertThat(reservationRepository.findAll().size()).isEqualTo(1);

위의 코드를 통해 중복되지 않고 1개만 예약되는지 확인하였다.

테스트 결과


역시 중복 예약이 발생하였다.

Synchronized 적용

public synchronized void save(Reservation reservation) throws IllegalArgumentException{

        if (!overlapsWithExisting(reservation)) {
            reservationRepository.save(reservation);
        }

    }
  • 저장 로직에 synchronized 키워드를 적용하여 다시 테스트하였다.

테스트 결과

  • 테스트는 성공하였지만 키워드 적용으로 시간이 비약적으로 상승하였다.
    -> Redis를 적용한 이유나 다름없다

Redis를 사용한 동시성 테스트

테스트 코드

@Test
    public void 레디스_중복_예약() throws JsonProcessingException, InterruptedException {

        redisService.clear();

        //스레드
        int threadCount = 2000;
        CountDownLatch latch = new CountDownLatch(threadCount);
        List<Long> executionTimes = new ArrayList<>();

        redisSaveTest(threadCount, executionTimes, latch);

        latch.await(); // 모든 스레드가 종료될 때까지 기다림

        // 모든 스레드의 실행 시간 평균 계산
        double averageTime = executionTimes.stream().mapToDouble(Long::doubleValue).average().orElse(0.0);
        System.out.println("평균 실행 시간: " + averageTime + "ms");

        assertThat(redisService.getReservations().size()).isEqualTo(1);
    }

    private void redisSaveTest(int threadCount, List<Long> executionTimes, CountDownLatch latch) {
        for (int i = 0; i < threadCount; i++) {
            Thread reservationThread = new Thread(() -> {
                long start = System.nanoTime(); // 스레드별 작업 시작 시간 (나노초)

                saveRedisReservation();

                long end = System.nanoTime(); // 스레드별 작업 종료 시간 (나노초)
                long duration = (end - start) / 1_000_000; // 밀리초로 변환
                executionTimes.add(duration);
                System.out.println("스레드 실행 시간: " + duration + "ms");

                latch.countDown();
            });
            reservationThread.start();
        }
    }

    private void saveRedisReservation() {
        RedisReservation reservation = RedisReservation.builder()
                .start(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC))
                .end(LocalDateTime.now().plusHours(2).toEpochSecond(ZoneOffset.UTC))
                .build();

        try {
            redisService.save(reservation);
        } catch (JsonProcessingException e) {
            e.printStackTrace();
        }
    }

테스트 결과

  • Redis 또한 중복 예약이 발생하였다.
  • 중복 예약 수가 h2에 비해 훨씬 많았다.

저장 로직에 synchronized 키워드를 적용하여 다시 테스트하였다

테스트 결과

  • h2에 비해 2초정도 평균 실행 시간이 감소하였다
  • 물론 키워드를 적용했을 때보다는 느리다

비교 분석

  • 성능 비교

    • 응답 시간 비교

      DB미적용synchronized 키워드 적용
      H21301ms5245ms
      Redis2758ms3461ms
    • 처리 능력 비교( 10 Time 수행 후 평균 )

      DB중복 예약 수
      H221
      Redis136

결론

  • 최종 결론
    • synchronized 키워드를 적용해야 하는 상황에서 Redis를 사용하여 성능 개선을 이루어낼 수 있었다.
  • 사후 연구
    • @RepeatedTest(10)를 하여 각 테스트를 반복하였는데 h2와 redis 모두 첫 테스트에 비해 두번째 테스트부터 중복예약 수가 10배이상(redis의 경우) 줄어들었다. -> 사유를 테스트 해보아야 할듯하다.

참고 자료

0개의 댓글