다음과 같은 인기검색어를 구현하는 것이 목적입니다.
시스템 자체는 비교적 간단하게, 사용자가 검색어를 입력하면, 해당 검색어랑 관련된 데이터들을 api요청을 통해 가져오게 되고, 이를 서버에서 기록하여 일종의 순위를 매기는 랭킹 시스템을 구축하는 것이 주 목적입니다.
redis를 키워드로 검색하면 높은 비율로 랭킹, 순위 시스템의 예시로 많이 드는 것을 볼 수 있습니다.
redis에 대한 더 자세한 학습이 필요하시면 제가 정리한 글인 https://velog.io/@top1506/Redis 참고하시면 이해하시는 데 도움이됩니다.
이는 redis를 사용하는 이유의 가장 주된 목적이기도 한데 간단하게 정의하면 단순히 redis가 빨라서 입니다. redis는 HDD/SSD에 데이터를 저장하지 않고 in-memory인 ram에 저장하여 일반적인 DB보다 속도가 매우 빠르다. 정도만 이해 하시고 시작하시면 좋을듯 합니다.
api는 프레임 워크인 nest js 로 구성하겠습니다.
(기본적인 nest js의 controller, module, service와 같은 개념들은 학습이 되었다고 가정하겠습니다.)
Nest js에서는 자체적으로 cache-manager라는 redis와 같은 in-memory 캐시를 제공합니다.
import { CacheModule, Module } from '@nestjs/common';
import { AppController } from './app.controller';
@Module({
imports: [CacheModule.register()],
controllers: [AppController],
})
export class AppModule {}
다음과 같이 App 에서 해당 app에서 Cache Manager를 사용할 것이다! 라고 명시해준 뒤 cache-manager를 사용하고 싶은 controller나 sercvice에서
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
다음과 같이 cacheManager를 class내에서 사용을 할 수가 있습니다.
기본적으로 key-value 구조를 가지며 이러한 cache-manager를 다른 저장소에 연결하여 (redis) 캐싱을 할 수 있습니다.
이러한 nest js의 cache-manger를 활용하여 redis에 연결하여 캐시 데이터를 활용하는 예시가 상당히 많았습니다. 그러나 저는 redis를 이용한 인기검색어, 즉 랭킹을 측정하는데 가장 편리한 redis의 도구 중 하나인 sorted-set을 해당 방식으로는 사용을 못하여서 다른 방식을 활용 하였습니다.
위에서 언급한 대로 Redis의 자료형을 사용하기 위해서는 nest js의 cache-manager와 redis를 연결하는 것이 아닌 아예 순수한 Redis 자체를 Nest Js에 주입을 하고 redis를 사용하는 방향으로 진행했습니다.
Why?
Sorted-Set은 데이터의 Score로 nlogn의 시간으로 내부적으로 flag의 크기에 따라 정렬된 데이터를 가지고 있습니다. 상위 1~100위나 하위의 1~100 위의 순서의 데이터를 가져올때 매번 모든 데이터를 조회하여 정렬 후에 순위를 매기는 것보다 정렬된 데이터를 이용하여 get을 해오면 더욱 빠르게 데이터 조회가 가능합니다.
npm install @liaoliaots/nestjs-redis ioredis
redis를 연결하기 위한 nest ioredis를 우선 설치해줍니다.
# ranking/ranking.module.ts
import { Module } from '@nestjs/common';
import { RedisModule } from '@liaoliaots/nestjs-redis';
import { RankingController } from './ranking.controller';
import { RankingService } from './ranking.service';
import { ConfigModule } from '@nestjs/config';
import { SearchRanking, RankingSchema } from 'src/models/ranking.schema';
@Module({
imports: [
RedisModule.forRoot({
config: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10) || 6379,
},
}),
],
controllers: [RankingController],
providers: [RankingService],
})
export class RankingModule {}
다음과 같이 host에 redis의 주소, port에는 redis를 사용하는 포트를 module에 명시를 해줍니다.
이후 service 에서는
import Redis from 'ioredis';
constructor(@InjectRedis() private readonly redis: Redis,) {}
다음과 같이 redis client 자체를 사용이 가능합니다.
현재 저희가 만들고 있는 서비스는 인기검색어입니다.
간단하게 설계를 해보면
<데이터 Update>
여기까지는 DB나 Redis를 사용하나 방식은 동일합니다.
그러나 이후에 1~10위의 데이터를 가져올 때 달라집니다.
<MongoDB(DB)>
<Redis(in-memory)>
단계 자체는 동일하게 보이지만 만약 데이터가 1000만개의 데이터를 가져오거나 정렬할때의 시간 차이는 얼마나 날까요?
# ranking/ranking.controller.ts
import { Controller, Get, Param } from '@nestjs/common';
import { RankingService } from './ranking.service';
@Controller()
export class RankingController {
constructor(private readonly rankingService: RankingService) {}
@Get('getRedis')
async getAll() {
return (await this.rankingService.getAll()).length;
}
@Get('insertMongo')
async insertMongo(@Param('keyword') searchStr: string): Promise<any> {
this.rankingService.insertToMongo(searchStr);
}
@Get('insertRedis')
async testRedis() {
this.rankingService.testInsertRedis();
}
@Get('getMongo')
async getMongo() {
return await (
await this.rankingService.getFromMongo()
).length;
}
}
다음과 같이 간단하게 redis와 mongo에 데이터를 넣고, 가져오는 단순한 api를 만들어 보았습니다.
async insertToMongo(searchStr: string) {
const data = await this.insertDummyToDb();
try {
await this.rankingModel.insertMany(data);
} catch (error) {
console.log(error);
}
}
async insertDummyToDb() {
const result = [];
for (let i = 0; i < 1000000; i++) {
result.push({ keyword: 'a', count: Math.floor(Math.random() * 10000000) });
}
return result;
}
다음과 같이 keyword는 모두 같게 랜덤한 score를 100만개를 mongo에 넣고 redis에도 동일하게 100만개의 데이터를 난수로 넣어놓았습니다.
Redis
# ranking.service.ts
async getAll() {
const redisSearchData = await this.redis.zrevrangebyscore(process.env.REDIS_POPULAR_KEY, '+inf', 1); // 점수가 1 이상인 모든 수를 오름차순으로 가져오기
return redisSearchData;
}
# ranking.controller.ts
@Get('getRedis')
async getAll() {
return (await this.rankingService.getAll()).length;
}
(408ms)
MongoDB
# ranking.service.ts
async getFromMongo() {
try {
const result = await this.rankingModel.find({});
return result;
} catch (error) {
console.log(error);
}
}
# ranking.controller.ts
@Get('getMongo')
async getMongo() {
return await (
await this.rankingService.getFromMongo()
).length;
}
(5.80s)
Service 측 코드만 수정하여 진행하겠습니다.
Redis Top10
async getTopTen() {
const redisSearchData = await this.redis.zrevrangebyscore(process.env.REDIS_POPULAR_KEY, '+inf', 1);
const topTen = redisSearchData.slice(0, 10);
const result: Ranking[] = [];
await Promise.all(
topTen.map(async (v) => {
const tmp: Ranking = { keyword: '', count: 0 };
tmp.keyword = v;
const score = await this.redis.zscore(process.env.REDIS_POPULAR_KEY, v);
tmp.count = Number(score);
result.push(tmp);
}),
);
return result;
}
(590ms)
Mongo Top10
async getTenMongo() {
try {
const result = await this.rankingModel.find({}).sort({ count: -1 });
const x = result.slice(0, 10);
return x;
} catch (error) {
console.log(error);
}
}
(6.75s)
이와 같이 단순히 모든 데이터를 불러오는 것이지만 속도차이가 매우 많이 나는 것을 볼 수 있습니다.
지금은 100만개의 데이터지만 이 데이터 수가 커지면 커질수록 속도의 차이는 더욱 날 것입니다.
redis가 무조건 좋다는 것은 아닙니다. (데이터의 휘발, 메모리의 가격 등) 단점도 분명히 존재하지만, 빠른 업데이트가 자주 일어나는 실시간 서비스(주식차트, 실시간검색어)와 같은 분야에서는 압도적으로 redis의 빠른 속도를 이용하여 다양한 서비스를 추가 할 수 있어서 활용성이 다양하다 할 수 있습니다.
import { Controller, Get, Param, Query } from '@nestjs/common';
import { RankingService } from './ranking.service';
@Controller('keyword-ranking')
export class RankingController {
constructor(private readonly rankingService: RankingService) {}
@Get()
async getTen() {
return this.rankingService.getTen();
}
@Get('/insert')
async insertCache(@Query('keyword') searchStr: string) {
this.rankingService.insertRedis(searchStr);
}
}
import { Injectable } from '@nestjs/common';
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import Redis from 'ioredis';
import { Ranking } from './entities/ranking.entity';
@Injectable()
export class RankingService {
constructor(@InjectRedis() private readonly redis: Redis) {}
async getTen() {
const redisSearchData = await this.redis.zrevrangebyscore(process.env.REDIS_POPULAR_KEY, '+inf', 1);
const topTen = redisSearchData.slice(0, 10);
const result: Ranking[] = [];
await Promise.all(
topTen.map(async (v) => {
const tmp: Ranking = { keyword: '', count: 0 };
tmp.keyword = v;
const score = await this.redis.zscore(process.env.REDIS_POPULAR_KEY, v);
tmp.count = Number(score);
result.push(tmp);
}),
);
return result;
}
async insertRedis(data: string) {
const isRanking: string = await this.redis.zscore(process.env.REDIS_POPULAR_KEY, data);
isRanking
? await this.redis.zadd(process.env.REDIS_POPULAR_KEY, Number(isRanking) + 1, data)
: await this.redis.zadd(process.env.REDIS_POPULAR_KEY, 1, data);
}
}
localhost:4000/keyword-ranking/insert?keyword={input} //검색어 입력
localhost:4000/keyword-ranking //1~10위 가져오기
더 자세한 코드가 궁금하시다면 >> https://github.com/boostcampwm-2022/web18-PRV
https://medium.com/zigbang/nestjs의-module과-cachemodule을-활용한-redis-연동-2166a771196
https://velog.io/@chaerim1001/Redis-NestJS-Sorted-set을-활용한-랭킹-구현