Skip to content

redis 낙관적 락 도입기

ez edited this page Jan 17, 2025 · 4 revisions

개요

redis 낙관적 락을 도입하게 된 계기를 설명드리기 전에 저희 프로젝트 구조부터 설명을 드리겠습니다.

프로젝트 변경 사항 갱신 플로우

저희 프로젝트에서는 웹 소켓을 통해 변경 사항을 받아서 이를 redis에 임시로 저장한 뒤 스케줄러를 통해 주기적으로 postgres에 반영하고 있습니다.

그리고 변경 사항을 반영할 때 redis의 값을 꺼내서 postgres에 반영한 뒤 메모리 절약을 위해 redis의 값을 삭제하고 있습니다.

변경 사항 갱신 흐름

앞에서 말씀드렸던 것처럼 변경 사항 갱신 흐름은 다음과 같습니다.

  1. redis에 저장된 변경 사항 조회
  2. 변경 사항 postgres에 반영
  3. 메모리 절약을 위해 redis 변경 사항 삭제

그런데 2번과 3번 연산을 수행 도중 변경 사항이 발생할 경우 아래 그림처럼 변경 사항이 postgres에 반영되지 않을 수 있습니다.

즉 1, 2, 3번 연산을 수행하는 동안 redis에 대한 접근을 막아야 합니다.

이를 위해 저희는 redis 분산 lock을 적용했습니다.

변경 사항을 갱신하는 3개의 연산을 수행하기 전에 lock을 획득하고 수행이 끝난 뒤 lock을 해제합니다.

그리고 lock을 획득해야만 redis에 값을 갱신하는 작업을 수행할 수 있도록 합니다.

즉 스케줄러가 migrate 작업을 진행하는 중에는 해당 key에 대한 연산을 막아버리는 것입니다.

문제 상황

저희는 redis 분산 lock을 통해 동시성 문제를 해결했지만 프로젝트 특성을 고려했을 때 오버 엔지니어링이라고 판단했습니다.

첫 번째, 두 프로세스가 redis에 동시 접근할 가능성이 낮습니다.

websocket 서버와 backend 서버는 별도의 프로세스로 실행되어 같은 redis를 사용하기 때문에 동시성 문제가 발생할 가능성이 있으나 실제로 두 프로세스에서 동시에 요청을 보낼 가능성은 낮습니다.

websocket 서버는 redis에 자주 접근하지만 backend 서버의 경우 오직 스케줄러가 동작할 때 redis에 접근합니다.

스케줄러가 하루에 한 번 동작한다고 하면 하루에 딱 한 번 redis에 동시 접근할 가능성이 존재하는 것입니다.

두 번째, 하나의 page를 migrate하는 시간이 굉장히 짧습니다.

migrate하는 연산은 redis에 저장된 값을 가져오고 postgres에 update 쿼리 하나를 날린 다음 redis에 저장된 값을 삭제하기 때문에 굉장히 짧은 시간에 이루어집니다.

하나의 연산이 수행되는 시간을 측정해보았습니다.

backend:dev: [Nest] 142  - 01/16/2025, 9:03:40 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 142  - 01/16/2025, 9:03:40 AM     LOG [TasksService] 총 개수 : 1개
backend:dev: [Nest] 142  - 01/16/2025, 9:03:40 AM     LOG [TasksService] 성공 개수 : 1개
backend:dev: [Nest] 142  - 01/16/2025, 9:03:40 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 142  - 01/16/2025, 9:03:40 AM     LOG [TasksService] 실행 시간 : 0.028518430000003717초

28ms의 시간이 걸렸습니다.

하루에 한 번, 28ms라는 시간 동안 변경 사항이 발생할 확률은 굉장히 낮습니다.

세 번째, 변경 사항이 반영되지 않고 삭제되더라도 큰 문제가 발생하지 않습니다.

28ms라는 짧은 시간 동안 발생한 변경 사항은 많지 않다고 판단하였고 이 정도 변경 사항은 유실되더라도 사용자가 크게 불편하지 않을 것이라고 판단했습니다.

위와 같은 이유 때문에 저희는 redis 분산 lock을 사용하지 않기로 결정했고 비교적 가볍게 동시성 문제를 해결하기 위해 redis 낙관적 락을 도입했습니다.

낙관적 락이란?

redis에서는 트랜잭션을 시작하기 전에 watch 명령어를 통해 특정 key의 변경 사항을 계속 모니터링 할 수 있습니다.

만약 transaction을 적용하기 전에 해당 key가 변경되었다면 watch 명령어가 이를 감지하고 transaction을 취소합니다.

즉 migrate 함수에 이 낙관적 lock을 적용하면 redis의 값을 삭제하기 전에 변경 사항이 발생했을 때 redis의 값을 삭제하지 않고 그대로 두게 됩니다.

즉 변경 사항이 사라지지 않고 다음 스케줄러를 반영할 때 적용이 되겠죠?

해결 방안

낙관적 lock을 적용하는 방법은 굉장히 간단합니다.

redis 트랜잭션을 시작하기 전에 watch를 통해 작업을 진행하는 key를 모니터링 하면 됩니다.

  async migratePage(key: string) {
    // 낙관적 락 적용
    await this.redisClient.watch(key);

    const data = await this.redisClient.hgetall(key);
    . . . 
  }

결과

redis lock을 적용한 결과 여러가지 장점을 얻게 되었습니다.

첫 번째, 공유 자원에 접근할 때 락을 획득할 필요가 없습니다.

저희 프로젝트의 공유 자원은 redis입니다.

아래는 redis 값을 저장하는 메소드의 일부입니다.

연산 시작 전에 매번 lock을 획득하는 모습을 볼 수 있습니다.

  async setFields(key: string, map: Record<string, string>) {
    // 락 획득할 수 있을 때만 set
    const release = await this.acquireLock(this.redisClient, key);
    // fieldValueArr 배열을 평탄화하여 [field, value, field, value, ...] 형태로 변환
    const flattenedFields = Object.entries(map).flatMap(([field, value]) => [
      field,
      value,
    ]);
    // 락 해제
    await release();
    // hset을 통해 한 번에 여러 필드를 설정
    return await this.redisClient.hset(key, ...flattenedFields);
  }

낙관적 lock 적용 이후 공유 자원에 접근하는 메소드는 lock을 획득할 필요가 없습니다.

  async set(key: string, value: object) {
    await this.redisClient.hset(key, Object.entries(value));
  }
  async setFields(key: string, map: Record<string, string>) {
    // fieldValueArr 배열을 평탄화하여 [field, value, field, value, ...] 형태로 변환
    const flattenedFields = Object.entries(map).flatMap(([field, value]) => [
      field,
      value,
    ]);
    // hset을 통해 한 번에 여러 필드를 설정
    return await this.redisClient.hset(key, ...flattenedFields);
  }

공유 자원에 접근할 때마다 lock을 획득해야 하는 번거로움이 없어졌고 개발자의 실수도 줄어들 수 있다는 장점이 있습니다.

두 번째, migrate 함수의 시간이 줄어들었습니다.

분산 lock을 사용했을 때는 lock을 획득한 뒤 연산을 마치고 lock을 제거하는 연산이 필요했습니다.

하지만 redis가 제공해주는 낙관적 lock의 경우 lock을 제거하지 않아도 됩니다.

redis transaction이 EXEC 된다면 자연스럽게 watch를 사용한 모니터링이 중단되기 때문입니다.

적은 데이터에서는 유의미한 성능 향상이 발생하지 않아서 1000개의 데이터를 기준으로 테스트를 진행해보았습니다.

redis에 1000개 page를 넣고 스케줄러가 migrate를 진행했을 때의 속도입니다.

먼저 redis 분산 lock을 사용했을 때입니다.

backend:dev: [Nest] 172  - 01/16/2025, 3:39:50 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:39:50 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:39:50 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:39:50 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:39:50 AM     LOG [TasksService] 실행 시간 : 0.7971999099999957초
backend:dev: [Nest] 172  - 01/16/2025, 3:40:00 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:40:01 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:01 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:01 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:01 AM     LOG [TasksService] 실행 시간 : 1.0430806220000013초
backend:dev: [Nest] 172  - 01/16/2025, 3:40:10 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:40:10 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:10 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:10 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:10 AM     LOG [TasksService] 실행 시간 : 0.7559004180000047초
backend:dev: [Nest] 172  - 01/16/2025, 3:40:20 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:40:20 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:20 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:20 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:20 AM     LOG [TasksService] 실행 시간 : 0.6894285239999881초
backend:dev: [Nest] 172  - 01/16/2025, 3:40:30 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:40:31 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:31 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:31 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:31 AM     LOG [TasksService] 실행 시간 : 1.0472734700000002초
backend:dev: [Nest] 172  - 01/16/2025, 3:40:40 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:40:41 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:41 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:41 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:40:41 AM     LOG [TasksService] 실행 시간 : 1.0676237879999972초
backend:dev: [Nest] 172  - 01/16/2025, 3:41:20 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:41:21 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:41:21 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:41:21 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:41:21 AM     LOG [TasksService] 실행 시간 : 1.3261687099999981초
backend:dev: [Nest] 172  - 01/16/2025, 3:42:10 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 172  - 01/16/2025, 3:42:10 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:42:10 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 172  - 01/16/2025, 3:42:10 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 172  - 01/16/2025, 3:42:10 AM     LOG [TasksService] 실행 시간 : 0.9570084919999936초

평균 시간 : 9537ms

다음은 낙관적 lock을 사용했을 때입니다.

backend:dev: [Nest] 260  - 01/16/2025, 3:57:00 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:00 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:00 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:00 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:00 AM     LOG [TasksService] 실행 시간 : 0.6592435100000003초
backend:dev: [Nest] 260  - 01/16/2025, 3:57:10 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:10 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:10 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:10 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:10 AM     LOG [TasksService] 실행 시간 : 0.9697250730000087초
backend:dev: [Nest] 260  - 01/16/2025, 3:57:20 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:20 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:20 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:20 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:20 AM     LOG [TasksService] 실행 시간 : 0.7115303379999823초
backend:dev: [Nest] 260  - 01/16/2025, 3:57:30 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:30 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:30 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:30 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:30 AM     LOG [TasksService] 실행 시간 : 0.742726290999999초
backend:dev: [Nest] 260  - 01/16/2025, 3:57:40 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:41 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:41 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:41 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:41 AM     LOG [TasksService] 실행 시간 : 1.0181726379999891초
backend:dev: [Nest] 260  - 01/16/2025, 3:57:50 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:57:50 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:50 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:50 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:57:50 AM     LOG [TasksService] 실행 시간 : 0.6968618019999704초
backend:dev: [Nest] 260  - 01/16/2025, 3:58:00 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:58:00 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:00 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:00 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:00 AM     LOG [TasksService] 실행 시간 : 0.6486756690000184초
backend:dev: [Nest] 260  - 01/16/2025, 3:58:10 AM     LOG [TasksService] 스케줄러 시작
backend:dev: [Nest] 260  - 01/16/2025, 3:58:10 AM     LOG [TasksService] 총 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:10 AM     LOG [TasksService] 성공 개수 : 1000개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:10 AM     LOG [TasksService] 실패 개수 : 0개
backend:dev: [Nest] 260  - 01/16/2025, 3:58:10 AM     LOG [TasksService] 실행 시간 : 0.6107508379999781초

평균 시간 : 7512ms

9537ms에서 7512ms로 줄어든 모습을 확인할 수 있습니다.

PR

https://github.com/boostcampwm-2024/refactor-web39-OctoDocs/pull/41

Clone this wiki locally