Redis로 동시성 문제 해결하기

Jeongkuk Seo
sjk5766
Published in
11 min readMar 3, 2024

이번 글에서는 코드 위주로 Redis와 동시성에 대해 3가지 방법을 중점적으로 작성한다.

  • 테스트 코드를 활용해 동시성 문제를 재현한다.
  • Redis의 set nx 명령어를 이용해 동시성 문제를 해결한다.
  • Redis의 Red Lock을 이용해 동시성 문제를 해결한다.

문서에 나오는 전체 코드는 여기서 확인할 수 있다.

동시성 문제

동시성 문제란 여러 쓰레드들이 공유 자원에 대한 경쟁을 벌여 실행 순서에 따라 의도하지 않은 결과를 말한다. 테스트를 통해 동시성 문제를 확인하기 위해 아래와 같은 함수를 준비했다. 영화의 추천 수를 증가시키는 함수로 id에 해당하는 영화를 조회하고, 그 영화의 추천 수에 1을 더해서 저장한다.

async increaseRecommendCount(id: number) {
const movie = await this.movieRepository.findOne(id);
await this.movieRepository.updateRecommendCount(
id,
movie.recommendCount + 1
);
}

동시성 문제를 확인하기 위해 아래와 같은 테스트 코드를 준비했다. Promise.all을 활용해 비 동기로 함수를 10번 호출한다.

describe('MovieService', () => {
it('동시에 10개 요청', async () => {
// given
// DB에 추천 수가 0인 1번 영화를 수동으로 만들어 둠

// when
await Promise.all([
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
service.increaseRecommendCount(1),
]);

// then
const movie = await movieRepository.findOne(1);
expect(movie.recommendCount).toBe(10);
});
});

기대하는 결과는 순차적으로 실행되어 10을 바랬지만 실제 결과는 아래와 같이 달랐고 실행시킬 때 마다 결과가 조금씩 달랐다.

과거의 나는 아래와 같은 흐름처럼 쓰레드 1에서 조회 및 업데이트를 하고 쓰레드 2가 작업을 수행하길 바랬다.

하지만 동시성 문제가 발생해 테스트가 실패했던 것처럼 실제로는 아래의 흐름과 같이 동작하게 된다.

DB Lock을 활용해 동시성 문제를 해결할 수 있지만 이번 글에서는 Redis만 활용하도록 한다.

Redis Set NX 옵션을 활용해 동시성 문제 해결하기

Redis는 싱글 스레드로 동작하기 때문에 Redis에 접근해 작업을 수행할 수 있는 쓰레드는 1개 뿐이다. Redis key에 값을 set 할 때 NX 옵션을 줄 수 있는데, 이 옵션은 전달하면 key가 없을 때만 set을 할 수 있다.

redis-cli를 통해 NX 동작 확인

redis-cli로 아래 명령어를 수행하면 myKey가 없었던 처음 경우에만 set 명령어가 성공한다. 두 번째 set을 시도하면 myKey가 존재하므로 명령이 정상적으로 수행되지 않는다.

127.0.0.1:6379> GET myKey
(nil)
127.0.0.1:6379> SET myKey value NX // 처음 SET NX 수행 시 성공
OK
127.0.0.1:6379> GET myKey
"value"
127.0.0.1:6379> SET myKey updateValue NX // 두번째 SET NX 수행 시 성공 X
(nil)
127.0.0.1:6379> GET myKey
"value"

NestJS에서 set NX 옵션을 활용해 동시성 문제 해결하기

우선 Redis Service에 setNx, del 메서드를 준비한다. 참고로 set에 인자로 전달되는 PX, 1000은 1000ms 후에 키가 만료됨을 뜻한다.

@Injectable()
export class RedisService {
constructor(@InjectRedis() private redis: Redis) {}

async setNx(key: string, value: string) {
return this.redis.set(key, value, 'PX', 1000, 'NX');
}

async del(key: string) {
return this.redis.del(key);
}
}

다음으로 Redis Service의 setNx 메서드를 호출해 영화 추천 수 업데이트를 하는 함수increaseRecommendCountBySetNx 를 작성한다. 로직은 간단한데 SET NX 명령에 성공하면 추천 수를 업데이트 하고 키를 삭제한다. SET NX 명령에 실패한다면 100ms 대기한 후 재 시도한다.

@Injectable()
export class MovieService {
constructor(
private readonly redisService: RedisService,
private readonly movieRepository: MovieRepository
) {}

async increaseRecommendCountBySetNx(id: number) {
const key = 'cacheKey';
while (!(await this.redisService.setNx(key, 'value'))) {
await sleep(100);
}

const movie = await this.movieRepository.findOne(id);
await this.movieRepository.updateRecommendCount(
id,
movie.recommendCount + 1
);

await this.redisService.del(key);
}
}

마찬가지로 해당 함수를 Promise.all로 테스트 해보자.

it('set nx 동시에 10개 요청', async () => {
// given
// DB에 추천 수가 0인 1번 영화를 수동으로 만들어 둠

// when
await Promise.all([
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
service.increaseRecommendCountBySetNx(1),
]);

// then
const movie = await movieRepository.findOne(1);
expect(movie.recommendCount).toBe(10);
});

Redis에는 한 쓰레드만 접근이 가능하므로 아래와 같이 테스트 결과가 성공한다.

RedLock을 활용해 동시성 문제 해결하기

Redis는 분산된 환경에서 Lock을 사용하기 위해 RedLock 사용을 권장하고 있다. 직전에 작성한 포스팅에서 RedLock에 대한 내용을 다루었으니 궁금하면 참고하길 바란다.

공식 문서에 언급된 node-redlockyarn add redlock 명령어로 설치하고 Redis Service에 RedLock을 획득하는 메서드를 추가한다.

import Redlock from 'redlock';

@Injectable()
export class RedisService {
private readonly redlock: Redlock;
private readonly lockDuration = 10_000;

constructor(@InjectRedis() private redis: Redis) {
this.redlock = new Redlock([redis]);
}

async acquireLock(key: string) {
return this.redlock.acquire([`lock:${key}`], this.lockDuration);
}
}

RedLock을 활용해 영화의 추천 수를 업데이트 하는 함수 increaseRecommendCountByRedLock 를 추가한다. Lock을 획득한 경우에만 영화의 추천 수를 업데이트 하고 Lock을 해제한다.

async increaseRecommendCountByRedLock(id: number) {
let lock: Lock;

try {
lock = await this.redisService.acquireLock(
`increase-recommend-count:${id}`
);

const movie = await this.movieRepository.findOne(id);
await this.movieRepository.updateRecommendCount(
id,
movie.recommendCount + 1
);
} catch (err) {
throw err;
} finally {
await lock.release();
}
}

마찬가지로 함수를 작성했으니 테스트 코드를 작성해보자.

it('redlock 동시에 10개 요청', async () => {
// given
// DB에 추천 수가 0인 1번 영화를 수동으로 만들어 둠

// when
await Promise.all([
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
service.increaseRecommendCountByRedLock(1),
]);

// then
const movie = await movieRepository.findOne(1);
expect(movie.recommendCount).toBe(10);
});

lock을 획득한 쓰레드만 조회 및 업데이트를 동기적으로 수행해 성공한 것을 확인할 수 있다.

--

--