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>
<aside>
따라서 이와 같은 이유로 캐시를 배치 업데이트하기 전에 스냅샷 캐시를 만드는 방식으로 결정했다.
</aside>
<aside>
사용자에게 고가용성을 제공하기 위해서는 요청에 대한 빠른 응답이 중요하다.
만약 사용자가 캐시를 저장할때 참조 카운팅이 50이 되었다고 하자.
그럼 사용자의 요청 안에서 벌크 인서트가 일어나는 것이다.
따라서 @Async를 사용하여 벌크 인서트의 로직을 별도의 워커 스레드로 부여한다.
자세한 이야기는 이 문서를 읽으면 좋다.
</aside>
<aside>
사용자에게 응답을 주기전에 캐시를 삽입해야한다.
캐시 삽입을 할때는 아래 프로세스가 진행된다. 이때 참조 카운팅은 데이터가 쌓이는 해시의 길이로 한다.
또한 이 모든 과정은 데이터의 정합성을 위해 LUA SCRIPT로 원자적 처리를 진행한다.
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>