1. 서비스 요구사항


https://github.com/Gseungmin/playlist/pull/3

대규모 데이터 삽입


<aside>

플레이리스트가 생성 요청이 대규모로 진행되고 있고, 이에 대한 성능 최적화를 고민해야 한다.

이 상황에서 캐시와 배치 처리를 진행하려고 한다. 아래가 그렇게 설계한 컨트롤러이다.

/*
 * 플레이리스트 생성
 * 플레이리스트 생성은 대규모로 이어지고 있음을 가정한다.
 * 레디스와 벌크 인서트를 통해 플레이리스트 생성 성능을 최적화한다.
 * 1. DTO 유효성 검사
 * 2. 사용자 인증 정보 조회
 * 3. 플레이리스트 생성 정보를 인증정보와 함께 레디스에 스냅샷(백업) 생성
 * 4. 사용자에게 미리 응답
 * 5. 500ms 주기에 맞춰서 배치 삽입
 * 6. 만약 배치 삽입할 개수가 50개를 넘는다면 미리 벌크 인서트 및 성공 시 삭제
 * */
@PostMapping
public PlayListCreateDto create(
        HttpServletRequest request,
        @RequestBody PlayListCreateRequest dto
) {
    validatePlayListCreate(dto);
    Long memberId = validateMemberId(request);
    return playListService.create(dto, memberId);
}

</aside>

캐시를 통해 배치를 처리하는 프로세스


<aside>

캐시에 저장하고 먼저 사용자에게 응답한다.


플레이리스트를 생성하기 위한 요청을 레디스에 사용자 인증 정보와 함께 저장한다.

캐시 저장이 되면 서비스 가용성을 위해서 응답을 미리 반환한다.

즉 사용자에게 응답이 미리 반환되고, 이에대한 배치 처리를 하는 것이다.

배치처리의 경우 기본적으로 1000ms에 한번 진행된다.

</aside>

<aside>

캐시를 통한 배치 처리시 메모리 문제


캐시를 통해 주기적 배치를 처리할때 만약 대규모 트래픽이라면 캐시 메모리에 문제가 생길 수 있다.

즉, 주기적으로 배치처리만으로는 문제가 될 수 있다. 1000ms를 기다린 사이 메모리 고갈 현상이 날 수 있다.

따라서 기본적으로 주기적인 배치처리를 하면서, 동시에 배치의 크기가 특정 카운팅이 될때 미리 배치 작업을 진행한다.

즉, 스케줄링 + 참조 카운팅의 하이브리드 방식으로 메모리 문제를 해결하는 것이다.

</aside>

<aside>

배치 처리에 장애가 날 경우는 어떻게 해야할까?


캐시에서 데이터를 꺼내 배치 처리를 한다고 하자.

이때 만약 장애가 날 경우, 캐시 데이터를 손실할 수 있는 위험이 있다. 따라서 2가지 방법이 있다.

<aside>

  1. 캐시를 삭제하지 않고 데이터 처리가 끝난 후 삭제한다

<aside>

  1. 캐시의 스냅샷을 생성하고 원본 캐시는 초기화한다.

따라서 이와 같은 이유로 캐시를 배치 업데이트하기 전에 스냅샷 캐시를 만드는 방식으로 결정했다.

</aside>

<aside>

비동기 처리가 필요한 이유


사용자에게 고가용성을 제공하기 위해서는 요청에 대한 빠른 응답이 중요하다.

만약 사용자가 캐시를 저장할때 참조 카운팅이 50이 되었다고 하자.

그럼 사용자의 요청 안에서 벌크 인서트가 일어나는 것이다.

따라서 @Async를 사용하여 벌크 인서트의 로직을 별도의 워커 스레드로 부여한다.

자세한 이야기는 이 문서를 읽으면 좋다.

</aside>

2. 프로세스


1️⃣ 캐시 삽입 및 스냅샷 생성


<aside>

사용자에게 응답을 주기전에 캐시를 삽입해야한다.

캐시 삽입을 할때는 아래 프로세스가 진행된다. 이때 참조 카운팅은 데이터가 쌓이는 해시의 길이로 한다.

또한 이 모든 과정은 데이터의 정합성을 위해 LUA SCRIPT로 원자적 처리를 진행한다.

프로세스


  1. 캐시가 삽입되면서 참조카운팅을 계산한다.
  2. 만약 50개 미만이면 false를 반환하고 50개 이상이면, 스냅샷을 생성한다.
  3. 스냅샷 생성후 원본 해시를 초기화 한다.
  4. 이후 생성된 스냅샷의 키와 스냅샷 자체를 반환한다.
public static final String INSERT_PLAY_LIST_LUA = """
    -- KEYS[1]  : 원본 해시 키
    -- ARGV[1]  : 필드 키
    -- ARGV[2]  : 필드 값
    -- ARGV[3]  : 스냅샷 TTL(sec)

    local hashKey   = KEYS[1]
    local fieldKey  = ARGV[1]
    local fieldVal  = ARGV[2]
    local ttl       = tonumber(ARGV[3])

    -- 1) 새 데이터 삽입
    redis.call('HSET', hashKey, fieldKey, fieldVal)

    -- 2) 50개 미만이면 즉시 반환
    local size = redis.call('HLEN', hashKey)
    if size < 50 then
        return { 'false' }
    end

    -- 3) 스냅샷 생성 및 TTL 지정
    local snapshotKey = hashKey .. ':' .. redis.call('TIME')[1]
    redis.call('COPY',   hashKey, snapshotKey)
    redis.call('EXPIRE', snapshotKey, ttl)

    -- 4) 원본 해시 초기화
    redis.call('DEL', hashKey)

    -- 5) 결과 반환
    local snapshotData = redis.call('HGETALL', snapshotKey)
    return { 'true', snapshotKey, snapshotData }
    """;

</aside>

2️⃣ 스냅샷 반환시 벌크 인서트 진행