소켓 통신으로 실시간 그룹 입/퇴장을 구현하며 그룹의 정보나 상태는 삭제될 데이터이니 Redis에 저장하기로 했다.
기본적인 기능 구현을 마치고 이제 동시성 처리를 해주기 위해 redis에서 동시성 처리들을 위한 방법을 찾아보았다. 여러 방법들이 있었고 모두 시도해봤지만 실패했다..
그렇게 시도한 방법들과 결국 어떻게 성공했는지에 대해 알아보자
일단 내가 알고있던 방법은 트랜잭션을 통해 lock을 걸거나 isolation level등을 설정해 동시성을 관리 해주는 방법이었다.
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번이였다. 트랜잭션 사이에 로직(변수 업데이트, 조건문)을 넣을 수 있다면 문제는 해결될 것이다.
위에서 트랜잭션 사이에 로직을 넣기 위해 공식문서를 찾아본 결과 간단한 로직은 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 스크립트를 통해 메서드를 직접 정의해서 트랜잭션 사이에 로직을 넣는 것이 가능했다. 트랜잭션이 독립적으로 작동하기에 동시성 처리까지도 문제없이 원하는 결과가 반환되었다.
lua를 이용한 방법은 동시성 처리면에서는 성공적이었지만 결국 조건문이나 에러처리를 못하기에 반쪽짜리 정답이었다.
트랜잭션에서 lock을 담당하는 watch가 예상대로 작동하지를 않아서 직접 lock을 적용하기로 했다.
Redlock은 분산 환경에서 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한다.