[Nest.js] Redis 동시성 처리

김지엽·2024년 1월 17일
0
post-thumbnail

1. 개요

소켓 통신으로 실시간 그룹 입/퇴장을 구현하며 그룹의 정보나 상태는 삭제될 데이터이니 Redis에 저장하기로 했다.

기본적인 기능 구현을 마치고 이제 동시성 처리를 해주기 위해 redis에서 동시성 처리들을 위한 방법을 찾아보았다. 여러 방법들이 있었고 모두 시도해봤지만 실패했다..

그렇게 시도한 방법들과 결국 어떻게 성공했는지에 대해 알아보자

2. Transaction

일단 내가 알고있던 방법은 트랜잭션을 통해 lock을 걸거나 isolation level등을 설정해 동시성을 관리 해주는 방법이었다.

Redis에도 트랜잭션과 비슷한 기능이 존재했다.

- Redis의 트랜잭션

Redis 트랜잭션

먼저 코드를 살펴보자

const redisClient = this.redisService.getRedisClient();

const transaction = redisClient.multi();

let exception: WsException;

transaction
    .watch(groupStateKey)
    .get(groupInfoKey)
    .get(groupStateKey)
    .exec((err, results: any) => {
        const groupInfo: Group = JSON.parse(results[0][1]);
        const groupState: GroupState = JSON.parse(results[1][1]);

        if (!groupInfo.open) {
            exception = new WsException("그룹이 현재 비공개 상태입니다.");
        }

        if (groupState.currentUser >= groupState.totalUser) {
            exception = new WsException(
                "그룹 인원수 제한으로 인해 참여할 수 없습니다.",
            );
        }

        groupState.currentUser += 1;

        transaction.set(groupStateKey, JSON.stringify(groupState));
        transaction.exec();
    });

// 트랜잭션 내부에서 에러가 발생하면 예외처리가 되지 않음
if (exception) {
    transaction.discard()
    throw exception;
}

Redis의 트랜잭션 작동원리는 간단하다. 트랜잭션끼리는 독립적으로 실행되며, 다음과 같은 순서를 가진다.

watch: RDBMS의 lock과 동일한 기능, watch로 설정한 키가 변경된다면 트랜잭션 실패 처리 (exec가 실행되면 watch는 해제)

Multi: 트랜잭션 시작
get: 트랜잭션 큐에 담김
set: 트랜잭션 큐에 담김
exec: 큐에 담긴 작업을 순차적으로 실행

- 문제점

  1. 트랜잭션 사이에 로직을 넣는것이 불가능하다.
  2. 트랜잭션이 독립적으로 실행되기에 watch가 예상한대로 작동 X
  3. 트랜잭션에 로직을 넣기 위해 exec의 콜백함수에 전달했지만 트랜잭션이 끝나고 나서 별도로 실행되기에 2번 문제와 겹침

위와 같은 문제가 일어났고 사실 가장 큰 문제는 1번이였다. 트랜잭션 사이에 로직(변수 업데이트, 조건문)을 넣을 수 있다면 문제는 해결될 것이다.

3. Lua Script

위에서 트랜잭션 사이에 로직을 넣기 위해 공식문서를 찾아본 결과 간단한 로직은 Lua 스크립트를 통해 정의하는게 가능했다.

Lua 스크립트

코드를 살펴보자

const redisClient = this.redisService.getRedisClient();

// Lua 스크립트를 통해 원하는 로직의 메서드 정의
redisClient.defineCommand("joinGroup", {
    numberOfKeys: 2,
    lua: `
        local data = redis.call('GET', KEYS[1])
        local groupState = cjson.decode(data) or {}

        if not groupInfo.open then
            error("그룹이 현재 비공개 상태입니다.")
        end

        if groupState.currentUser >= groupState.totalUser then
            error("그룹 인원수 제한으로 인해 참여할 수 없습니다.")
        end

        groupState.currentUser += 1;
        redis.call('SET', KEYS[1], cjson.encode(groupState))
    `,
});

let exception: WsException;

const transaction = redisClient.multi();
transaction
    .watch(groupStateKey)
	//트랜잭션 사이에 정의 메서드 넣기
    //@ts-ignore
    .joinGroup(groupStateKey)
    .exec((err, results: any) => {
        const error1 = results[0][0];
        const error2 = results[1][0];

        if (error1) exception = new WsException(error1.message);
        if (error2) exception = new WsException(error2.message);
    });

if (exception) {
    transaction.discard()
    throw exception;
}

위와 같이 lua 스크립트를 통해 메서드를 직접 정의해서 트랜잭션 사이에 로직을 넣는 것이 가능했다. 트랜잭션이 독립적으로 작동하기에 동시성 처리까지도 문제없이 원하는 결과가 반환되었다.

- 문제점

  1. lua 내에서 error를 발생시켜도 실행만 멈출뿐 외부에서는 예외를 확인할 수 없다.
  2. lua에서 boolean 타입(true, false)을 인식하지 못해서 조건문 작동이 원활하지 않다.

lua를 이용한 방법은 동시성 처리면에서는 성공적이었지만 결국 조건문이나 에러처리를 못하기에 반쪽짜리 정답이었다.

4. Lock

트랜잭션에서 lock을 담당하는 watch가 예상대로 작동하지를 않아서 직접 lock을 적용하기로 했다.

- Redlock

Redlock

Redlock은 분산 환경에서 lock을 관리할 수 있는 패키지이며 전체적인 로직은 다음과 같다.

  1. Redis 클라이언트 배열(하나도 가능)과 옵션을 받아 Redlock 인스턴스를 초기화한다.
  2. Redis 인스턴스에서 Lock을 얻을려고 시도한다.
  3. 초기화때 받은 클라이언트 배열에서 동시에 lock을 얻는 것을 시도
  4. 대부분의 클라이언트가 lock을 얻으면 성공
  5. 대부분의 클라이언트가 lock을 얻지 못하면 실패

즉 lock을 습득할때 여러 클라이언트들(분산 환경)에서 테스트하고 대부분의 클라이언트의 결과에 따라 최종 결과를 반환하는 로직이다.

- 프로젝트에 적용

코드를 살펴보자

// 락을 1초동안 획득(획득하지 못하면 대기)
const lock = await this.redlock.acquire([groupStateLockkey], 1000);

const groupInfo = await this.findGroupInfoById(groupId);
const groupState = await this.findGroupStateById(groupId);

try {
    if (!groupInfo.open) {
        throw new Error("그룹이 현재 비공개 상태입니다.");
    }

    if (groupState.currentUser >= groupState.totalUser) {
        throw new Error(
            "그룹 인원수 제한으로 인해 참여할 수 없습니다.",
        );
    }

    groupState.currentUser += 1;

    await this.redisService.set(
        groupStateKey,
        JSON.stringify(groupState),
    );
} catch (e) {
    throw new WsException(e.message);
} finally {
    // 락 종료
    await lock.release();
}

프로젝트의 로직은 다음과 같다.
1. acquire메서드를 통해서 lock을 습득 시도
2. lock을 습득 성공시 아래의 로직을 실행
3. lock 습득 실패시 습득할때까지 대기(최대 3회, 0.3초마다)
4. 로직이 종료되면 lock을 release한다.

결론

  • redis에서 동시성 처리를 할때 로직이 정말 간단하다면 트랜잭션을 이용한다.
  • 로직이 조금 복잡한 경우 red-lock과 같은 패키지를 이용해 lock 기능을 구현한다.

참고

Redis Transaction
Lua Script
Redlock

profile
욕심 많은 개발자

0개의 댓글