최근이 실무에 일을 하면서 많은 트래픽에 의한 동시성 처리에 대한 이슈가 올라 온 적이 있는데
이참에 분산 락에 대해서 정리를 하면서 이 부분을 어떻게 해결을 했는지 공유 드리려고 관련 글을 작
성하려고 합니다.
동시성에 의한 문제는 왜 생길까요 ?
분산 시스템 환경에서 애플리케이션을 개발하는 것은 백엔드 개발자의 일상이 되었습니다.
여러 서버가 동시에 같은 데이터에 접근하고 변경하는 상황에서 데이터의 일관성을 유지하는 것은 매우 중요한 일입니다.
만약 장애가 생겼다면 크리티컬한 클레임 까지 받을 수 있습니다.
여러 서버 간에 공유 자원에 대한 접근을 조율하여 데이터 경쟁 상태 (Race Condition) 및 불일치 문제를 해결하는 데 이 때 사용했던 개념이 분산 락 입니다.
여러 대의 분산 된 서버 or 서로 다른 프로세스 가 상호 배타적인 방식으로 동일한 데이터를 동기화를 보장하기 위한 락으로 DB에서 기본적으로 제공해주는 기능은 아닙니다. (레코드 락, 테이블 락 등) 분산락을 구현 하려면 여러 서버가 공통된 자원을 바라봐야 하고 정보가 Atomic 하게 저장이 되어야합니다. 공통된 자원에 사용되는 기술은 MySQL의 네임드락,Redis,Zookeeper 등이 있습니다. 저는 메인 언어가 NodeJS여서 그림을 Clustering 모드 적용하여 표현을 했습니다.
Atomic 이라는 건 분할 불가능한 뜻으로 여러 스레드에서 동시에 접근하는 공유 데이터의 일관성을 유지 하는 것으로 All or Nothing 원칙으로 연산이 완전히 수행되거나 수행이 안되도록 하는 것을 의미 합니다.
저는 싱글스레드 동작하여 Atomic 연산을 보장해 주는 Redis 를 선택하였습니다.
https://stackoverflow.com/questions/43259635/is-redis-set-command-an-atomic-operation
Redis의 분산 락은 SETNX 명령어 와 Lua Script 를 활용하여 구현이 됩니다.
여기서 제가 궁금했던 건 왜 SETNX 명령어를 이용하여 구현하는걸까? 였습니다. 그 이유를 찾아보니
SET 의 경우는 Key 가 존재한다면 정보를 덮어 쓰는 명령어 이고
SETNX 는 Key가 존재 한다면 0을 반환하고 Key가 없다면 정보를 저장 하는 방식으로 한번의 연산으로
처리가 되어 Atomic 처리가 가능 하기 때문입니다. SETNX 의 시간 복잡도는 O(1) 입니다.
분산 락을 구현한다면 반드시 필요한 부분이 락의 만료 시간입니다. 예상치 못한 에러로 락을 점유한
상태에서 서버가 다운이 된다면 Redis 에는 Key가 그대로 남아있게 되는데 그러면 그 이후의 어떠한
요청도 락을 점유하지 못하는 상황이 되게 됩니다. 따라서 분산 락은 락의 점유에 대한 만료 시간을 지
정하여 시간이 지나면 풀리게 해주어야 하는데 SETNX 는 Key의 만료 시간을 지정하는 기능은 없습니
다. 명령어를 결합하여 만료시간을 설정 할 수 있지만 Redis로 분산 락을 구현할 때 Lua Script 를
사용하면 SETNX의 만료 시간을 Atomic 하게 설정 가능합니다.
// Expire 예시
redis.call("SET", lockKey, lockValue, "NX", "PX", timeout)
간단한 요구 사항으로 동시성 테스트를 진행해 보겠습니다.
- Item에 좋아요 기능이 있습니다.
- Item에 좋아요 를 클릭하면 좋아요 Count 가 1씩 올라갑니다.
- 좋아요 Count 데이터는 Redis에 저장이 됩니다.
저는 Nestjs 와 nestjs-modules/ioredis을 이용해서 item 서비스 로직 을 만들고 테스트를 실행해 보겠습니다. 그리고 lock 라이브러리는 타입 스크립트가 지원되는 simple-redis-mutex 라이브러리를 사용하였습니다. 해당 라이브러리는 스핀 락을 사용하며 FIFO(선입선출)이 지원이 됩니다.
simple-redis-mutex 적용하면 스크린 샷 처럼 Flow 가 진행이 됩니다.
- 처음으로 도착한 요청이 락 획득을 요청 합니다.,
- 락이 없으면 락을 생성후(simple-redis-mutex:resource) 이후 로직을 실행합니다.
- 로직 실행중에 이후 요청이 락 획득을 요청을 합니다.
- 이미 락이 점유 되어 있으므로 대기하면서 재시도를 합니다.
테스트 시나리오는 좋아요 1 증가 의 요청을 10개 보낸 뒤에 최종 결과 값을 조회하여 좋아요 카운트가 10개가 되는지 확인해보겠습니다.
@Injectable()
export class ItemsService {
constructor(
@InjectRedis() private readonly redis: Redis
) {}
// 좋아요 클릭 [동시성 적용]
async increaseLikeMutex(inputCount: number): Promise<void> {
const release = await lock(this.redis, 'like', { fifo: true });
const count = await this.redis.get('LIKE');
const total = +count + inputCount;
await this.redis.set('LIKE', total);
await release();
}
// 좋아요 클릭
async increaseSimpleLike(inputCount: number): Promise<void> {
const count = await this.redis.get('LIKE');
const total = Number.parseInt(count) + inputCount;
await this.redis.set('LIKE', total);
}
// 좋아요를 정보 가져오기
async findLike(): Promise<number> {
const like = await this.redis.get('LIKE');
return Number.parseInt(like);
}
}
위 코드는 요구 사항을 만족하기 위해 간단하게 작성된 ItemService Class입니다. ItemsService 는
Like Key를 증가 시키는 increase 함수와 조회하는 findLike 함수로 이루어져 있습니다.
describe('[아이템 좋아요 올리기]', () => {
it('[Lock 미적용] 좋아요 10개 올리기 ', async () => {
const apiCall = [];
for (let index = 0; index < 10; index++) {
apiCall.push(itemsService.increaseSimpleLike(1));
}
await Promise.all(apiCall);
const like = await itemsService.findLike();
expect(like).toBe(apiCall.length);
});
});
테스트 코드를 작성하여 동시에 10개 좋아요 올리기 함수 테스트 해보겠습니다.
테스트 결과 값이 10개가 아닌 1개로 나오고 있습니다. 실제 서비스 운영 에서 이렇게 개수가 맞지 않다면 크리티컬한 문제가 생길 것 입니다.
describe('[아이템 좋아요 올리기]', () => {
it('[Lock 미적용] 좋아요 10개 올리기 ', async () => {
const apiCall = [];
for (let index = 0; index < 10; index++) {
apiCall.push(itemsService.increaseSimpleLike(1));
}
await Promise.all(apiCall);
const like = await itemsService.findLike();
expect(like).toBe(apiCall.length);
});
});
이번에는 분산 락이 적용되어있는 테스트 코드를 실행 해보겠습니다.
락이 적용 되면서 동시성을 보장하였고 테스트 코드도 정상적으로 통과가 되는것을 확인하였습니다. 근데 여기서 가장 중요하게 봐야 할부분이 Time 입니다. 동일한 요청을 10개 보냈는데 테스트 시간이 약 1초 정도 차이가 발생했습니다. Lock 이란 리소스를 사용 할 때는 항상 고려해야할 부분이 시스템 자원을 많이 사용 되기 때문에 신중하게 고려하여 락을 사용해야 합니다.
물론 해당 라이브러리는 Redis가 단일 서버일경우 가능한 케이스이고 Cluster를 이용하면 RedLock 알고리즘을 적용 해야합니다. Redlock 부분은 다음 블로그에 정리해서 공유드리겠습니다.