Redis 기반 Write-Behind 패턴을 도입하며 많은 고민을 했었는데, 이번 포스트를 통해서 어떤 부분을 주의했는지, 또 어떤 위험이 있었는지 한 번 정리해보려고 한다.
먼저, Redis는 인 메모리 기반의 Key-Value 기반 NoSQL이다. 다양한 자료형을 제공하고, 빠른 성능을 제공한다. 또한, 싱글 스레드 기반으로 작동한다. 그렇다보니 긴 시간이 소요되는 작업을 처리하게 되면 이후 작업에 영향을 미칠 수 있다.
Redis는 싱글 스레드? 멀티 스레드?
Redis는 명령 실행 자체는 보통 단일 이벤트 루프(메인 스레드)에서 처리한다. 그래서 오래 걸리는 명령(대량 키 탐색, 큰 응답 생성 등)은 그 시간 동안 다른 요청 처리 지연으로 이어질 수 있다. 다만 일부 작업(AOF 등)은 백그라운드 스레드에서 수행되기도 한다.
이제 Redis 기반 Write-Behind 패턴 도입 시 주의점에 대해 살펴보자.
가장 먼저 중요한 부분은 키 스캔 관련 명령어다.
DB에 Delta 값들을 반영하기 위해서는 Redis 저장되어 있는 값들을 읽어와야 한다. 앞서 말했듯이 Redis는 Key-Value 기반이므로, 어떤 키가 있는지 읽어온 뒤 키에 해당하는 값을 가져오는 구조다.
키를 읽어오는 명령어는 KEYS와 SCAN이 있다.
결론부터 말하면 KEYS 명령어는 최대한 사용하지 않는 것이 좋다.
KEYS는 특정 패턴에 매칭되는 키를 한 번에 전부 찾아서 반환하는 명령어다. 처음에는 한 번에 다 읽어오니까 오히려 더 빠르고 효율적이지 않을까 생각했다. 하지만 해당 명령어는 시간복잡도 O(N)으로, 전체 키 갯수에 비례한다. 또한 작업을 처리하는 동안 서버를 블로킹 하기 때문에 그 동안 다른 명령어의 처리가 지연된다. 결과적으로는 타임아웃이나 장애를 유발할 수 있다.
SCAN은 KEYS의 대안이다. 키 공간을 조금씩 나눠서 반복적으로 조회한다. (커서 기반) 여전히 시간 복잡도는 O(N)이지만, KEYS와 달리 분할해서 명령을 실행한다. 거의 블로킹 없는 것에 가깝게 설계되어 있다.
순회 시작부터 끝까지 계속 존재한 요소는 반드시 한 번 이상 반환된다는 특징이 있다. 순회 시작 전에 존재하지 않았고, 순회 중에도 추가되지 않은 요소는 절대 반환되지 않는다.
하지만 호출을 여러번 해야하기 때문에 번거롭다. 또한 동일한 요소가 여러 번 반환될 수 있고, 순회 도중 추가되거나 삭제된 요소는 반환 여부가 보장되지 않는다.
스팟에서는 SCAN 명령어를 통해 일정 범위로 나눠서 조회하는 방식을 적용했다. SCAN 사용 시, 중복/누락이 가능하다고 했지만 조회수라는 값 자체는 정합성이 중요한 데이터가 아니기 때문에 약간의 유실이나 중복은 허용 가능하다고 판단했다. 또한 Redis를 다른 서비스에서도 사용하고 있기 때문에 KEYS 명령어로 인해 처리 지연은 위험하다고 판단했다.
다음으로 고민했던 부분은 정합성과 트랜잭션 범위 설정이다.
처음에는 Redis에서 GET -> DB에 트랜잭션으로 반영 -> Redis에서 삭제 -> 커밋 처럼 Redis 연산의 일정 부분을 트랜잭션 범위에 포함시켰다. 그러고 부하 테스트를 실행 했더니 트랜잭션 범위가 넓어지고 이로 인해서 대기하는 트랜잭션이 많아서 타임아웃이 발생하는 경우가 종종 발견됐다.
또한 특정 키 값을 읽어와 DB에 반영하는 중, 다른 요청이 해당 키 값을 변경하게 되면 그 값은 반영되지 않고 삭제되는 문제를 발견했다. 그래서 값을 읽고 쓰는 작업에 대한 원자성 보장이 필요해 진 상황이었다.
인기글의 경우, 짧은 시간 내에 많은 조회수가 누적될텐데 아무리 정합성이 덜 중요한 데이터라고 하더라도 큰 규모의 유실은 막아야 한다고 판단했다.
그래서 스팟은 GETDEL 명령어를 사용하여 문제를 해결했다. Redis에서 값을 읽고, 삭제하는 것을 원자적으로 처리하는 명령어다.
“이번 배치에서 처리할 값”을 GETDEL로 확정짓는다. 만약 이후에 다른 요청이 해당 키 값을 올리게 되면, 해당 값은 “다음 배치”에서 처리 된다. 이전과 달리 누락되지 않는다.
만약 이번 배치에서 해당 값 업데이트에 실패하게 된다면, 다시 Redis에 복구하는 방식으로 정합성을 최대한 보장하고자 노력했다.
여전히 GETDEL로 값을 가져온 직후, DB 반영 전에 배치 프로세스가 죽으면 아예 델타 값이 사라질 수 있는 위험이 존재한다.
앞서 말했듯이, 조회수는 강한 정합성이 요구되는 데이터가 아니므로 약간의 유실은 허용 가능하다고 판단했다. 또한 유실 가능성을 줄이기 위해 배치 실패 시 재삽입을 수행하고, 배치 작업의 실행 시간을 짧게 유지(작은 배치 단위/재시도)하여 “GETDEL 이후 DB 반영 전” 구간을 최소화했다.
정합성이 더 중요한 도메인이라면 GETDEL 대신 키를 processing으로 이동시킨 뒤(처리 완료 시 삭제), 재시작 시 processing 키를 재처리하는 방식으로 유실을 방지하는 구조가 더 적절하다고 생각한다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
fun updateViewCount(
studyId: Long,
delta: Long
) {
studyRepository.increaseViewBy(studyId, delta)
log.debug("스터디 조회수 DB 업데이트: studyId={}, delta={}", studyId, delta)
}
또한 기존에는 DB에 반영 이후 Redis에서 값을 지우는 것 까지 하나의 트랜잭션으로 포함 했는데, 지금은 DB에 반영하는 부분만 트랜잭션으로 감쌌다. 트랜잭션 범위에서 불필요한 외부 네트워크 통신을 덜어낼 수 있었다.
현재 스팟은 두 개의 인스턴스가 가동되고 있다.
조회수를 반영하는 배치 작업은 하나의 인스턴스만 실행되어야 한다. 두 인스턴스가 동시에 반영하게 되면 중복 반영이나 누락되는 값이 생길 수 있기 때문에 락 설정이 필수적이다.
처음에는 Redis를 활용해서 간단한 커스텀 분산 락을 구현해서 사용 하려고 했으나, 락 해제 로직이나 신경써야 할 부분이 많아 ShedLock을 사용하여 분산 락을 적용 했다.
ShedLock은 Spring의 @Scheduled 같은 스케줄 작업이 멀티 인스턴스에서 중복 실행 되는 문제를 막기 위한 스케줄러 전용 분산 락 라이브러리다.
파라미터로 락의 최대 유지 시간(자동 해제 시점), 최소 유지 시간을 설정할 수 있다. 최대 유지 시간은 배치 최악 실행 시간보다 충분히 크게 잡아야 하고, 너무 작게 잡으면 실행 중 락이 풀려 중복 실행이 발생할 수 있다. 반대로 너무 크게 잡으면 장애 시 다음 실행이 오래 막힐 수 있어 트레이드오프가 있다.
Write-Behind 패턴 도입 시 생각보다 고려해야 할 부분이 많아서 머리가 많이 아팠던 것 같다. 리스크를 점검하고 보완하는 과정에서 다양한 관점을 참고했다. 물론 아직 발생할 수 있는 예외 상황들은 많이 남아있을 것 같다.
더 확실하게 정합성을 보장할 수 있도록 멱등 키를 사용해서 중복 처리를 방지한다는 등 더 좋은 방법도 고려했지만, 조회수라는 데이터 특성 상 지금 당장은 과한 설계라고 판단했다. 좋아요 수나 댓글 수 같은 경우는 보다 더 정합성이 중요한 데이터기 때문에 해당 방식 도입을 고려할 수 있을 것 같다.
결과적으로 쿼리 성능을 개선하기 위해 도입한 사전 집계 구조로 인해 발생했던 많은 양의 조회 수 UPDATE 트래픽에 적절하게 대응할 수 있었다.