10 min read

<Redis> 레디스 활용해서 N개의 랜덤 데이터 가져오기

우선 개발 중인 서버의 요구사항은 아래와 같습니다. Chosung 이라는 테이블이 있고 해당 테이블에서 클라이언트에게 전달될 랜덤한 N개의 초성 데이터를 뽑아내야합니다. 아무래도 선택된 데이터들은 초성 게임에서 사용될 데이터들이니 최대한 빠른 응답성을 가져야 한다는 특징도 있었습니다. 해당 요구사항들을 구현하기 위한 과정을 소개합니다.

1. 테이블에서 가져오기

가장 직관적인 방법입니다. 간결하게 아래와 같은 SQL을 작성해서 가져올 수 있습니다

SELECT * FROM Chosung
ORDER BY RAND()
LIMIT N;

하지만 위 쿼리는 문제점이 존재합니다. Order By RAND()의 경우 다음과 같은 순서로 동작하게 됩니다.

  1. 무작위 값 생성 : 모든 레코드마다 무작위값을 생성합니다.
  2. 정렬 : 생성된 무작위 값을 기준으로 정렬 합니다.

만약 테이블의 크기가 더 커진다면 위 요청을 리소스는 더욱 더 증가하게 될 것 입니다. 그래서 다른 방안을 고민해봐야했습니다.

2. 레디스의 컬렉션 연산 사용하기

우선 RDBMS의 경우 디스크로부터 데이터를 가져오기에 이러한 병목을 없애고 싶어 레디스 캐시 서비스를 고려하였습니다. 레디스를 사용하기로 결정하고 난 다음 고려해야할 부분들을 아래와 같이 정리했습니다.

  1. DB에 저장된 레코드와 캐시에 저장된 데이터 사이의 정합성
  2. 중복없이 n개의 랜덤 데이터를 가져오는 방법

고려한 부분 하나하나를 아래와 같이 고민하며 해결하려 했습니다.

데이터 정합성

간단히 생각해보면 초성 데이터가 DB에 저장되는 로직이 수행될 때, 해당 데이터를 레디스에도 저장 시키면 정합성이 보장 될 것 같습니다. 하지만 DB와 레디스에서 각각 저장할 때 예외가 발생하면 데이터 정합성이 깨질 수 있습니다. 이러한 부분을 고려하여 아래와 같이 DB에 저장하는 연산과 레디스에 저장하는 연산을 처리했습니다.

  • DB에 write 성공 -> redis 저장 로직 수행 O
  • DB에 write 실패 -> redis 저장 로직 수행 X

위와 같이 구현할 경우 첫번째 케이스에 의해 데이터가 DB에는 저장되고 redis에는 저장되지 않을 수도 있는 문제가 여전히 남아있습니다. 이를 해결하기 위해 저는 스프링에서 제공하는 스케쥴러를 사용했습니다. 현재 요구사항에서는 하루에 한번만 스케쥴러를 통해 DB와 레디스 간의 정합성을 맞춰주고 있습니다. 스프링 스케쥴러를 사용할 때 신경 써야하는 점이 있는데, 이는 메서드 로직의 수행 시간과 스케쥴된 설정 주기간의 차이입니다. 만약 메서드 로직이 3초인데 1초마다 스케쥴링을 해두면 문제가 발생하게 될 것입니다. 하지만 제가 작성한 로직은 이와 같은 문제는 고려하지 않아도 괜찮았습니다.

@Scheduled(initialDelay = 0, fixedDelay = 1, timeUnit = TimeUnit.DAYS)
  public void syncDataWithCache() {
    redisTemplate.expire(redisChosungListKey, 24, TimeUnit.HOURS);
    List<Chosung> chosung = chosungRepository.findAll();
    SetOperations<String, String> set = redisTemplate.opsForSet();

    if (set.size(redisChosungListKey) == chosung.size()) {
      return;
    }
    redisTemplate.delete(redisChosungListKey);
    for (Chosung c : chosung) {
      set.add(redisChosungListKey, c.getChosung());
    }
  }

랜덤 데이터 가져오기

레디스의 Set 자료구조 연산을 사용하면 손쉽게 랜덤 데이터를 가져올 수 있습니다. srnadmember 연산을 사용하면 됩니다. 다만, 위 연산을 RedisTemplate을 사용할 때 주의해야할 점이 있습니다. Template 단에서는 두 가지 메서드를 지원합니다.

  • randomMember
  • distinctRandomMembers

첫번째 메서드의 경우 중복을 허용해서 set 자료구조로부터 랜덤하게 요소들을 가져옵니다. 즉, 초성 데이터가 겹칠 확률이 존재한다는 것이죠. 게임에서 사용될 데이터가 겹치면 안되기에 저는 distinctRandomMembers 를 사용해서 데이터를 가져왔습니다.

private Set<String> getChosungIdList() {
    SetOperations<String, String> set = redisTemplate.opsForSet();
    set.randomMember(redisChosungListKey);
    set.distinctRandomMembers(redisChosungListKey, 3);
  }

그러면 두 메서드는 어떠한 구현 차이 때문에 중복 여부에 대한 결과가 다른걸까요? 해답은 아래 구현과 공식문서에 있습니다. 아래가 위 메서드들의 구현입니다. 차이점이 있다면 리턴 타입과 connection.sRandMember의 인자로 들어가는 count의 값입니다. Distinct가 지원될 경우 양수 count 값이 들어가고 지원되지 않을 경우 음수 count가 인자로 들어갑니다.

public Set<V> distinctRandomMembers(K key, long count) {
    Assert.isTrue(count >= 0L, "Negative count not supported; Use randomMembers to allow duplicate elements");
    byte[] rawKey = this.rawKey(key);
    Set<byte[]> rawValues = (Set)this.execute((connection) -> {
      return new HashSet(connection.sRandMember(rawKey, count));
    });
    return this.deserializeValues(rawValues);
  }

  public List<V> randomMembers(K key, long count) {
    Assert.isTrue(count >= 0L, "Use a positive number for count; This method is already allowing duplicate elements");
    byte[] rawKey = this.rawKey(key);
    List<byte[]> rawValues = (List)this.execute((connection) -> {
      return connection.sRandMember(rawKey, -count);
    });
    return this.deserializeValues(rawValues);
  }

위 정보를 알고 나서 다음 공식문서를 참고해봅시다.

https://redis.io/docs/latest/commands/srandmember/

If the provided count argument is positive, return an array of distinct elements.

앞서 본 메서드의 구현과 같이 양수 값이 count로 들어갈 경우 distinct한 결과를 리턴해준다고 나와 있습니다.

성능 비교

이론적으로 생각해보면 레디스를 사용하는게 응답속도가 빨라야 하는데 과연 그럴지 테스트를 진행해보겠습니다. 테스트 방식은 동일한 서비스 레이어에서 랜덤 데이터를 가져오는 로직을 수행시키고 시간을 측정합니다. 기존에 존재하는 약 50개의 레코드를 사용했을 때는 사실 상 유의미한 응답 속도의 차이가 존재하지 않았습니다. 그래서 아래의 두가지 부분을 개선시켜보았습니다.

  1. 데이터 레코드의 수
  2. 레디스를 활용하는 api의 로직 개선

1번의 경우 누구나 쉽게 생각하고 이해할 수 있는 부분입니다. 2번의 경우 내가 작성한 로직이 다음과 같이 구성되어 생긴 문제입니다. 먼저 WAS에서 redis에 요청을 보내 random 데이터의 PK 값을 받아오도록 합니다.이후 해당 PK를 통해 DB에 질의를 하여 실제 데이터 레코드들 가져오도록 합니다. 이 문제를 해결하기 위해서 레디스라는 캐시에 데이터 레코드의 일부를 저장할 것이냐? 필요한 모든 데이터를 저장할 것이냐를 결정해야합니다. 현재 내 비지니즈 요구사항에서 초성 데이터는 테이블의 크기가 그렇게 크지는 않기에 레디스에 전체 레코드를 저장하는 선택이 효율적이라고 판단하였고 수정하였습니다.(해봤자 수만건 수준)

본격적인 성능 측정을 위해 매 요청마다 해당 api의 소요 시간을 csv 파일로 기록해주는 기능을 AOP를 통해서 먼저 만들었습니다.아래와 같이 생성한 Aspect를 측정할 메서드에 지정해줍니다.

@RestController
public class ChosungController {

    @LogExecutionTime
    @GetMapping("/chosungs")
    public ApiResponse<List<ChosungResponse>> getRandomChosung() {
        return ApiResponse.of(chosungService.getRandomChosungList(), HttpStatus.OK, HttpStatus.OK.name());
    }

    @LogExecutionTime
    @GetMapping("/chosungs2")
    public ApiResponse<List<ChosungResponse>> getRandomChosung2() {
        return ApiResponse.of(chosungService.getRandomChosungList2(), HttpStatus.OK, HttpStatus.OK.name());
    }
}

Aspect의 경우 간단하게 실행시간을 측정하고 이 값을 csv 파일에 적도록 합니다.

public class ExecutionTimeAspect {

    @Around("@annotation(LogExecutionTime)")
    public Object logExecutionTime(ProceedingJoinPoint joinPoint) throws Throwable {
        long start = System.currentTimeMillis();

        Object proceed = joinPoint.proceed();

        long executionTime = System.currentTimeMillis() - start;
        saveExecutionTimeToCsv(joinPoint.getSignature().getName(), executionTime);

        return proceed;
    }

    private void saveExecutionTimeToCsv(String methodName, long executionTime) {
        String fileName = "execution_times.csv";

        try {
            Files.write(Paths.get(fileName), (methodName + "," + executionTime + "\n").getBytes(), java.nio.file.StandardOpenOption.CREATE, java.nio.file.StandardOpenOption.APPEND);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

그러면 측정에 앞서 고정 값인 더미 데이터는 10000개를 추가합니다. CommandLineRunner 인터페이스를 구현해서 데이터를 추가시켰습니다.

@RequiredArgsConstructor
@Component
public class DataLoader implements CommandLineRunner {

    private final ChosungService chosungService;

    @Override
    public void run(String... args) {
        for (int i = 0; i < 10000; i++) {
            chosungService.saveChosung(ChosungCreateServiceRequest
                .builder()
                .info("test")
                .answer("테스트" + (i + 1))
                .chosung("ㅌㅅㅌ" + (i + 1))
                .memberName(MemberName.NONE)
                .build());
        }
    }
} 

측정 결과는 아래와 같습니다. 유의미한 결과를 얻어낸것 같다고 생각됩니다.

Api1(Redis) Api2(Order by Random)
100회의 요청 시 avg 2.58 ms 11.34 ms
400회 요청 시 avg 1.88 ms 11.10 ms

만약 데이터 셋의 크기가 더욱 더 큰 비지니스 요구사항이라면 격차는 더 벌어질 것이라고 생각됩니다.

RedisTemplate은 비동기 or 동기?

마지막으로 살펴볼 점은 RedisTmeplate의 비동기 / 동기 여부입니다. RedisTemplate의 경우 손쉽게 레디스 서버의 api들을 사용할 수 있도록 해주는 spring-data-redis에서 제공해주는 라이브러리입니다. 그러면 요청 처리의 경우 어떻게 처리될까요? 동기적으로 처리됩니다. 즉, 하나의 요청이 발생하고 해당 응답을 받을 때까지 요청을 수행하는 스레드가 블록킹이 되어 응답을 기다린다는 뜻이죠. 스프링부트를 사용한다고 가정해보겠습니다. 그러면 톰캣의 워커 스레드 중 하나가 비지니스 로직을 수행하게 되고 로직 중 RedisTemplate을 통한 api 요청이 있을 경우 톰캣 스레드가 블락킹이 되며 로직을 수행합니다. 즉, 대규모의 데이터를 처리해야하는 WAS에서는 응답성이 떨어지는 문제가 발생할 수 있습니다.

당연히 이러한 문제를 해결하기 위해 라이브러리에서는 ReactiveRedisTemplate을 지원해줍니다. 이는 내부적으로 비동기-논블록킹으로 동작하는 api를 지원해줍니다.해당 api는 web-flux를 지원하여 리턴타입이 Mono 또는 Flux 등을 반환해줍니다. 그래서 스프링 웹 MVC 패턴을 사용하는 경우 어쩔 수 없이 사용할 수 없는것처럼 오해할 수 있습니다. 하지만 반환되는 Mono 또는 Flux를 CompletableFuture로 변환시켜주는 toFuture() 메서드를 통해 비동기적으로 api 요청의 결과 및 에러를 핸들링 할 수 있습니다. 이번 포스팅에서는 위 두 방식의 차이에 대해서는 간략히 알아보고 다른 포스팅에서 구체적인 사용법과 이러한 방식이 정말로 필요한지에 대해 알아보도록 하겠습니다.