NestJS에서 Redis로 캐싱하기 2 (feat mget, mset, sadd, smembers)

NestJS에서 Redis로 캐싱하기 2 (feat mget, mset, sadd, smembers)

지난 포스트에선
NestJS에서 Redis로 캐싱하기 (feat 고도화)

get, set을 구현했다

이번 포스트에서는 get, set을 여러개씩 해주는

  • mget, mset

sets 컬렉션을 사용하는

  • sadd, smembers
    를 구현해보자ㅏ

mget, mset

mset

설명

MSET key value [key value ...]

실제 cli에 사용할때는 이런식으로 사용한다

MSET key1 "Hello" key2 "World"

이런식으로 찾길 원하는 key, value 값을 뒤로 쭉 보내주면 된다

시간 복잡도는 O(N) N은 설정할 키의 개수이다

리턴은 항상 OK

구현

코도화를 시켜주기 위해
기본적으로 제네릭 타입을 사용해줬고

async mset<T>(
	keys: string[],
	values: T[],
	expiretime: number,
	converter: (value: T) => string
): Promise<string>

expiretime, converter() 를 받아주는데

  • expiretime: key 만료 시간
  • converter : T -> string으로 바꿔주는 함수, 레디스에 저장하기 위함
    으로 이뤄져있다
async mset<T>(
	keys: string[],
	values: T[],
	expiretime: number,
	converter: (value: T) => string
): Promise<string> {
	const keyValue: string[] = [];

	// 요건 개인 선택 키 벨류의 쌍이 맞아야 되도록
	// if (keys.length !== values.length) { }
	const convertedValues = values.map((value) => converter(value));

	// key, value를 하나의 배열로 만들어 준다
	keys.forEach((key, index) => keyValue.push(key, convertedValues[index]));

	// 실제 mset
	const result = await this.redisClient.mset(...keyValue);

	// 모든 key expire 걸어주기
	keys.map(async (key) => {
		await this.redisClient.expire(key, expiretime);
	});
	
	return result;
}

mget

설명

MGET key [key ...]

실제 cli에 사용할때는 이런식으로 사용한다

MGET key1, key2, key3, key4 ...

이런식으로 찾길 원하는 key값을 뒤로 쭉 보내주면된다

시간 복잡도는 O(N) N은 검색할 키 수이다

리턴은 배열 형태인데 실제 key값에 해당하는 값이 없는 경우 null을 리턴한다

그래서

MGET key1 key2 nonexisting
// 라면
return [ 'value1', 'value2', null ]
// 이런 식의 리턴이라고 생각하면 된다

구현

/**
*
* @param key Redis Key
* @param converter 가져온 문자열 값을 원하는 형식으로 변환하는 함수
* @param finder Redis에 값이 없을 경우 데이터를 찾아오는 비동기 함수
* @returns
*/
async mget<T>(
	keys: string[],
	converter: (result: string) => T,
	finder: (keys: string[]) => Promise<T[]>,
): Promise<T[]> {
	const redisResults = await this.redisClient.mget(...keys);
	const nonCachedKeys: string[] = [];
	const results: T[] = [];
	
	redisResults.forEach((value, index) => {
		if (value === null) {
			nonCachedKeys.push(keys[index]);
		} else {
			results.push(converter(value));
		}
	});
	
	if (nonCachedKeys.length !== 0) {
		const finderResults = await finder(nonCachedKeys);
		if (finderResults.length > 0) {
			await this.mset<T>(nonCachedKeys, finderResults, 30, (value) => JSON.stringify(value));
		}
		results.push(...finderResults);
	}
	return results;
}

코도화를 시켜주기 위해
기본적으로 제네릭 타입을 사용해줬고

async mget<T>(
	keys: string[],
	converter: (result: string) => T,
	finder?: (keys: string[]) => Promise<T[]>,
): Promise<T[] | null> 

converter(), finder()를 받아주는데

  • converter : 레디스에서 가져오는 값을 원하는 타입으로 바꿔주는 함수
  • finder : 레디스에 캐싱된 값이 없는 경우 값을 가져오는 함수 (예: 실제 디비에서 가져오는 로직)
    으로 이뤄져있다

레디스에서 가져오는 부분

	// 레디스에 접근해 키들에 해당하는 값 가져오기
	const redisResults = await this.redisClient.mget(...keys);

	// 레디스에 값이 존재하지 않아 null이 반환된 key들
	// 다음 finder로 이 값들 가져와줄거임
	const nonCachedKeys: string[] = [];

	// 받은 key들에 해당하는 결과들
	// 반환하는 배열
	const results: T[] = [];

	// 레디스에서 가져온 값을 검사해 null 서치
	redisResults.forEach((value, index) => {
		if (value === null) {
			// null인 경우 nonCachedKeys에 삽입
			nonCachedKeys.push(keys[index]);
		} else {
			// 값이 존재하는 경우 results에 원하는 type으로 convert 후 삽입
			results.push(converter(value));
		}
	});

finder()로 레디스에 존재하지 않는 값 가져오기

	// 레디스에서 반환한 값들 중 null 인 값이 하나라도 있다면
	if (nonCachedKeys.length !== 0) {
		// finder을 이용해서 값 가져오기
		const finderResults = await finder(nonCachedKeys);

		// 사실 여긴 각자 다르게 작성해도 괜찮을듯..
		// 일단 난 캐시되지 않은 key들에 대한 값들이 다 와야 정상이라고 판단했다
		if (finderResults.length !== nonCachedKeys.length) {
			throw new NotFoundException('존재하지 않는 key가 포함되어 있습니다.')
		}
		// 가져온 값들이 있다면??
		if (finderResults.length > 0) {
			// 캐싱해주기
			await this.mset<T>(nonCachedKeys, finderResults, 30, (value) => JSON.stringify(value));
		}
		results.push(...finderResults);
	}
	return results;

이 구현에서 알아둬야 할 점은
우선 넣은 key값의 순서와 반환받는 key에 대한 value의 순서가 일치하지 않습니다

key1, key2, key3, key4, key5 를 넣었고
캐싱이 된것들이 1, 2, 5 였다면
value1, value2, null, null, value5 이런 식이다

근데 난 디비 접근을 배치로 해주고 싶었기 때문에

null인 key들만 분리해서 별도의 요청을 보내줬다

그래서 리턴은

  • 넣은 key 배열 [key1, key2, key3, key4, key5]
  • 캐싱된 값들 [value1, value2, null, null, value5]
  • 최종 반환 값들 [value1, value2, value5, value3, value4]

대충 이런식이 된다

난 순서는 상관 없는 작업이라 일단 이렇게 구현해줬는데
순서가 중요한 경우는 다르게 구현해야 될것 같다

sadd

설명

SADD key member [member ...]

실제 cli에 사용할때는 이런식으로 사용한다

MGET key1, value1, value2, value3 ...

이런식으로 key 뒤로 저장할 값들을 쭉 주면 된다

시간 복잡도는 각 값당 O(1)이라 벨류 값들 개수에 따라서 O(N)이 된다

그래서

MGET key1 key2 nonexisting
// 라면
return [ 'value1', 'value2', null ]
// 이런 식의 리턴이라고 생각하면 된다

구현

async sadd<T>(key: string, value: T[], expiretime: number, converter: (value: T) => string): Promise<void> {
	const convertedList = valueList.map((v: T) => converter(v));
	
	if (convertedList.length <= 0) {
		return;
	}
	
	await this.redisClient.sadd(key, ...convertedList);
	await this.redisClient.expire(key, expiretime);
}

expiretime, converter() 를 받아주는데

  • expiretime: key 만료 시간
  • converter : T -> string으로 바꿔주는 함수, 레디스에 저장하기 위함
    으로 이뤄져있다

요건 이전 게시물에 set과 거의 동일한 로직이라..

근데 여기서 value를 배열로 받고 있는데
무조건 배열 형태로 받고 있다

다른 방법 써서 바꿔도 좋을듯..
요소 하나도 받을 수 있도록??

smembers

설명

SMEMBERS key

key의 모든 멤버를 반환한다

SMEMBERS myset

시간 복잡도는 안에 모든 값들을 가져오기 때문에 O(N)이다

구현

async smembers<T>(
	key: string,
	converter: (result: string) => T,
	finder?: (key: string) => Promise<T[]>,
): Promise<T[] | null> {
	const members = await this.redisClient.smembers(key);
	
	if (members.length <= 0 || !members) {
		if (!finder) {
			return null;
		}
		
		const finderMembers = await finder(key);
		
		if (finderMembers.length <= 0) {
			return finderMembers;
		}
		
		const stringMembers = finderMembers.map((value) => JSON.stringify(value));
		await this.redisClient.sadd(key, ...stringMembers);
		await this.redisClient.expire(key, 30);
		
		return finderMembers;
	}
	return members.map((value: string) => converter(value));
}

음 이거도 위의 mget과 비슷한 맥락이다

converter(), finder()를 받아주는데

  • converter : 레디스에서 가져오는 값을 원하는 타입으로 바꿔주는 함수
  • finder : 레디스에 캐싱된 값이 없는 경우 값을 가져오는 함수 (예: 실제 디비에서 가져오는 로직)
    으로 이뤄져있다

결론

필요한 함수들을 그냥 쓰지 않고
랩핑해서 고도화 시키는 작업을 했는데

생각보다 깔끔하지는 않고 특정 시점에 문제가 생길만 한 부분들이 보이는 것 같다...
일단 사용해야해서 사용하는데
추후 리펙토링 필요해 보임....

그래도 이렇게 사용하는게 그냥 redis로 가져다 쓰는것보단 코드도 깔끔해지고 사용성도 좋아지는것 같다

참고자료