Redis 기반 락으로 Race Condition 해결하기. [Node.js, Nest.js, Redis, Jest]

lians·2025년 5월 10일
post-thumbnail

서론

현재 개발 중인 메타버스 서비스 리브아일랜드에는 랜덤으로 섬에 참여하거나, 섬 목록 중에 원하는 섬에 참여할 수 있는 기능이 있습니다.

여기서 '섬'은 서비스 내의 독립적인 공간, 즉 '방'의 개념으로 이해해 주시면 됩니다.

메타버스 같은 실시간 서비스는 한 공간에 존재하는 인원 수가 늘어남에 따라 서버에서 이벤트를 송신해야하는 횟수가 제곱수로 늘어납니다.

따라서 방에는 모두 인원 제한이 있는데요.
현재는 섬에 대한 모든 정보가 Redis에서만 관리되고 있어요.

따라서 당연히 섬 참여에 대한 로직을 수행할 때도 Redis I/O를 하는데요.
이때 Redis에서 발생한 race condition 문제를 해결한 내용을 글로 남겨보려고합니다.

싱글 스레드 환경에 대한 흔한 오해

Node.js는 싱글 스레드로 동작하기 때문에 "동시성 문제는 복수 인스턴스 환경에서만 발생한다"고 오해하는 경우가 많습니다.
그러나 실제로는 단일 인스턴스 환경에서도 비동기 작업의 완료 순서에 따라 race condition이 발생할 수 있습니다.

Redis 또한 싱글 인스턴스이지만 단일 명령에 대해서는 원자성을 보장하더라도 복수의 명령에 대해서는 추가 구현이 필요합니다.

즉, 하나의 함수에 여러 Redis I/O가 존재하고 그 함수를 동시에 실행한다면 race condition 문제가 발생할 수 있습니다.

내부 동작 방식을 이해하면 어느 부분에서 동시성 문제가 발생할 가능성이 있는지 예상할 수 있어요.

아래 코드는 실제 사용하는 섬 참여 로직을 간결하게 수정한 코드에요.

async join(player: Player) {
  const { id: playerId, roomId: islandId } = player;

  await this.canJoin(islandId); // 🔴
  await this.playerStorageWriter.create(player); // 🔴
  await this.normalIslandStorageWriter.addPlayer(islandId, playerId); // 🔴
}

canJoin에서 참여 가능 여부를 확인하기 위해 현재 인원수를 조회하고 문제가 없다면 플레이어를 생성하고 섬에 참여자로 추가하는 코드이고 이 세 작업은 모두 Redis와 I/O를 하는 작업입니다.

Redis와의 I/O는 일반적으로 매우 짧은 시간(ms 단위) 내에 처리되지만, Node.js의 이벤트 루프는 그 짧은 시간 동안에도 다른 요청을 처리할 수 있습니다.

따라서 동시에 여러 요청을 받게 된다면 첫 번째 요청에서 canJoin을 호출하고 내부적으로 비동기 작업을 수행한 후 Promise가 resolve되기 이전에 다른 요청의 canJoin을 실행하게될 거에요.

제가 의도하는 것은 첫 요청에서 canJoin뿐만 아니라 플레이어 생성 및 참여까지 resolve된 이후에 다른 요청의 canJoin이 실행되어야하는데 non-blocking 비동기 처리방식으로 인해 그렇게 동작하지 않는 겁니다.

동시성 문제 재연

동시성 문제가 발생할 수 있다는 점과 그 원인을 확인했으니, 실제 환경에서도 동일한 문제가 발생하는지 확인하기 위해 다음과 같은 테스트 코드를 작성하여 검증해 보았습니다.

describe('일반 섬 입장', () => {
  		// socket 커넥션을 생성하기 위한 helper 함수
        const createConnection = async (): Promise<TypedSockect> => {
            const { accessToken } = await login(app);

            return new Promise((res, rej) => {
                const socket: TypedSockect = io(url, {
                    path: '/game',
                    auth: {
                        authorization: accessToken.split(' ')[1],
                    },
                });
                socket.on('connect', () => {
                    res(socket);
                });

                socket.on('connect_error', (err) => {
                    rej(err);
                });
            });
        };

        it('Race Condition 발생', async () => {
            // 최대 인원이 3명인 섬 생성
            const island: LiveNormalIsland = { maxMembers: 3, . . . };
            await islandStorage.createIsland(island.id, island);
            await db.island.create(. . .);

            // socket 커넥션 생성
            const clients = await Promise.all(
                Array.from({ length: 30 }).map(() => createConnection()),
            );

            await new Promise((res) => setTimeout(res, 1000));

            let success = 0;
            let fail = 0;

            await Promise.all(
                clients.map((socket: TypedSockect, i) => {
                    return new Promise<void>((res) => {
                        // 참여에 성공하면 실행되는 handler
                        socket.on('playerJoinSuccess', () => {
                            console.log(
                                `🟢 Client ${i + 1} joined successfully`,
                            );
                            success += 1;
                            res();
                        });

                        socket.on('wsError', (err) => {
                            console.log(`🟠 WS Error: ${err.message}`);
                            fail += 1;
                            res();
                        });

                        socket.emit('joinNormalIsland', {
                            x: 0,
                            y: 0,
                            islandId: island.id,
                        });
                    });
                }),
            );
          
            clients.forEach((socket) => {
                socket.disconnect();
            });

            expect(success).toEqual(3);
            expect(fail).toEqual(clients.length - 3);
        });
    });

위 테스트는 최대 참여 인원이 3명인 섬에 30명의 클라이언트가 동시에 참여 요청을 보내는 상황을 가정합니다.

모든 요청은 1ms 이내의 텀을 두고 서버에 도달한 것을 로깅을 통해 확인했어요.

의도한 대로라면 3번만 성공해야하지만 결과는 아래와 같이 30번의 요청 모두 성공하는 결과가 나왔습니다.

Redis에서의 동시성 문제 해결 방법

Redis에서 동시성을 다루는 방법은 크게 2가지가 있습니다.

  1. 낙관적 락
  2. 비관적 락

두 방법 모두 락이라는 이름을 쓰지만 동작 방식이 다르고 낙관적 락은 명시적인 락을 걸지 않습니다.

낙관적 락

Redis에서 공식적으로 지원하는 Transaction 기능이 낙관적 락입니다.
낙관적 락은 아래의 특징을 가집니다.

  1. 실제 lock을 획득하지 않는다: 다른 클라이언트의 접근을 물리적으로 막지 않습니다.
  2. 데이터 변경 감시: WATCH 명령어를 통해 특정 key의 데이터를 감시합니다.
  3. 변경 감지 실패 처리: 다른 클라이언트에서 같은 데이터에 대해 변경이 감지되면 실패처리합니다.
  4. 롤백 기능은 제공하지 않는다.

즉, 낙관적 락은 데이터의 동시 변경 자체를 막는 것이 아니라, 변경 사항을 감지하여 충돌이 발생했을 때 후속 처리를 애플리케이션 레벨에서 관리하는 방식입니다.

비관적 락

이 방법은 Redis에서 기능으로서 제공하는 것은 아닙니다.
명렁어를 통해 직접 lock을 획득하여 처리하는 방식이며, 여기서도 SETNX 방식과 Redlock 방식으로 나뉨니다.

저희 환경은 클러스터 구성이 아니므로 Redlock은 오버헤드가 크다고 판단하여 고려 대상에서 제외했습니다.

SETNXSET 명령어와 NX (Not Exists) 옵션을 결합한 것으로, 지정된 키가 Redis에 존재하지 않을 경우에만 값을 설정하는 명령어입니다.

Race condition 해결

섬의 최대 인원 수와 같이 데이터 정합성이 매우 중요하고, 동시성 문제가 발생할 가능성이 높다고 판단되어 섬 참여 로직에 SETNX 기반의 비관적 락 방식을 채택했습니다.

락 획득 및 해제 기능 구현

Redis의 SET 명령어와 NX, PX 옵션을 활용하여 락 획득 및 해제 기능을 다음과 같이 구현했습니다.

@Injectable()
export class RedisClientService implements OnModuleDestroy {
    public readonly client: Redis;

    constructor(config: ConfigService) {
        this.client = new Redis(
            Number(config.get<string>('REDIS_PORT')),
            String(config.get<string>('REDIS_HOST')),
        );
    }

    . . .

    async acquireLock(key: string, ttl = 2000) {
        const id = v4();
        const result = await this.client.set(key, id, 'PX', ttl, 'NX');
        return result === 'OK' ? id : null;
    }

    async releaseLock(key: string) {
        await this.client.del(key);
    }

    . . .
}

acquireLock 메서드는 SET 명령어에 NX (Not Exists) 옵션을 사용하여 해당 키가 존재하지 않을 때만 값을 설정합니다.
락 획득에 성공하면 'OK'를 반환하고, 이미 락이 존재하면 null을 반환합니다.
PX 옵션은 락의 만료 시간을 밀리초 단위로 설정하여 예기치 않은 오류 발생 시 데드락을 방지합니다.

그리고 lock을 직접 구현했기 때문에 rollbackretry 로직도 직접 구현해주어야합니다.

그 모든 작업을 수행해주는 트랜잭션 매니저를 아래와 같이 구현해서 필요한 곳에 사용하도록 했습니다.

interface TransactionOption {
    execute: () => Promise<any>;
    rollback?: () => Promise<any>;
}

@Injectable()
export class RedisTransactionManager {
    constructor(private readonly redis: RedisClientService) {}

    async transaction(
        key: string,
        options: TransactionOption[],
        ttl = 2000,
        maxRetries = 3,
        retryDelay = 100,
    ): Promise<void> {
        await this.acquireWithRetry(key, ttl, maxRetries, retryDelay);

        let countExecute = 0;

        try {
            for (const option of options) {
                await option.execute();
                countExecute++;
            }
        } catch (e) {
            await this.rollback(countExecute, options);
            throw e;
        } finally {
            try {
                await this.redis.releaseLock(key);
            } catch (releaseErr) {
              throw new DomainException(. . .);
            }
        }
    }

    // maxRetries 횟수만큼 락 획득을 시도하는 메서드
    private async acquireWithRetry(
        key: string,
        ttl: number,
        maxRetries: number,
        retryDelay: number,
    ): Promise<boolean> {
        let attempt = 0;

        while (attempt <= maxRetries) {
            const lockAcquired = await this.redis.acquireLock(key, ttl);
            if (lockAcquired) return true;

            if (attempt === maxRetries) break;

            await this.delay(retryDelay);
            attempt++;
        }

        throw new DomainException(. . .);
    }

    // 트랜잭션 시작시 받은 options와 실행 수를 통해 롤백을 하는 메서드
    private async rollback(countExecute: number, options: TransactionOption[]) {
        for (let i = countExecute - 1; i >= 0; --i) {
            const { rollback } = options[i];
            if (rollback) {
                try {
                    await rollback();
                } catch (rollbackErr) {
                    throw new DomainException(. . .);
                }
            }
        }
    }

    // 재시도 사이에 딜레이를 주는 메서드
    private delay(ms: number): Promise<void> {
        return new Promise((res) => setTimeout(res, ms));
    }
}

조금 복잡하지만 저는 모두 구현해서 사용했습니다.
더 좋은 방법이 있다면 공유 부탁......

이제 race condition이 발생할 가능성이 있었던 섬 참여 로직에 적용해보겠습니다.

async join(player: Player) {
  const { id: playerId, roomId: islandId } = player;

  const key = ISLAND_LOCK_KEY(islandId);
  await this.lockManager.transaction(key, [
    {
      execute: () => this.canJoin(islandId),
    },
    {
      execute: () => this.playerStorageWriter.create(player),
      rollback: () => this.playerStorageWriter.remove(playerId),
    },
    {
      execute: () =>
      this.normalIslandStorageWriter.addPlayer(
        islandId,
        playerId,
      ),
    },
  ]);
}

이제 첫 번째 요청에서 해당 islandId를 사용한 keylock을 생성하면 해제하기 전까지는 다른 요청에서 같은 keylock을 획득하려고 maxRetries만큼 재시도를 하고 그래도 획득하지 못 하면 예외가 발생합니다.

테스트

이론적으로는 이제 동시성 문제가 해결된 상태인데요.
정말 예상한 결과가 도출되는지 이전 문제가 발생했던 테스트를 다시 한 번 실행해보겠습니다.

아까와 같은 환경인 최대 인원이 3명인 섬에 30명의 클라이언트가 동시에 입장 요청을 보내는 경우의 테스트 결과입니다.

모든 출력 값을 가져오진 못 했지만 3번의 요청만 성공하고 나머지는 모두 이미 처리 중 혹은 섬이 가득 찼다는 응답과 함께 실패하게 되었습니다.

결론

race condition 발생 가능성을 파악하기위해서는 redis도 중요하지만 결국 Node.js의 동작 방식을 이해하고 있어야했습니다.

잘못된 내용 혹은 보완할 점이 있다면 편하게 댓글 부탁드려요.

참고

https://redis.io/docs/latest/develop/interact/transactions/
https://redis.io/docs/latest/commands/set/
https://redis.io/glossary/redis-lock/

profile
개발자 강해성입니다. 주로 Node.js로 개발합니다.

0개의 댓글