결과
테스트 세팅:
- 테스트 환경: Mac M1 Air / RAM 16GB / SSD 256GB
- 테스트 시나리오: 1000명의 동시 사용자가 (약 3초 간격) 15번 반복 조회
- 테스트 목적: 유명맛집에 걸려있는 Cache 성능과 안정성 검증
Redis?
Redis는 오픈 소스 인메모리 데이터 구조 저장소이다. 여기서 주목할 점은 인메모리 데이터 저장소라는 것!
일반적인 데이터베이스와는 다르게 데이터를 메모리에 저장하는데, 하드웨어적으로 RAM 의 읽기, 쓰기 속도가 HDD, SDD보다 매우 많이 빠른 것이 가장 큰 장점이다.
그렇기 때문에, 데이터를 가져오는 속도에서 차이가 난다. 그렇다면 이게 다 일까?
그럼 무조건 인메모리 데이터 저장소 구조가 좋은 거야?라고 생각한다면 당연한 것은 아니다!
RAM이라는 특성을 생각해 보면 속도라는 관점에서는 우월하지만, 대용량의 데이터를 저장할 수 있는 저장공간을 제공할 수 없고, (마이 비싸~) 휘발성 메모리이기 때문에 데이터의 영속성을 유지할 수 없게 된다.(하지만, Redis는 해결방법을 제공합니다.)
다시 돌아와서, 위의 특성을 고려해서 자주 갱신 될 필요가 없고 자주 접근하는 데이터에 캐싱 기법을 적용을 하면 좋다. ( keyword: API 캐싱) must-Go 서비스에서 생각해 보면, 데이터가 갱신될 일이 거의 없고 사용자가 빈번하게 조회하는 기능을 찾았을 때,
- 시군구 목록을 불러오는 API(데이터 변동 없음, 한번 가져오면 쭉~~ 재사용성 up!)
- 맛집 상세정보 조회 API (빈번한 데이터 조회 + 인기 조회수 기능 )
이렇게 두 가지 정도가 있다.
Redis 적용하는 법
레디스를 적용하기 전 알아두면 좋을 전략, 한 번씩읽어보시면 도움이 될 것 같아 첨부한다.
이제, Redis를 적용해 보자!
우선적으로 redis가 다운로드되어 있는지 확인하고 없다면, 본인의 운영체제가 맞게 설치해 준다.
여기서는 mac os 기준으로 설명하겠다.
터미널에서 brew --version을 이용해서 Homebrew를 확인해 주고, 아래처럼 brew install redis 명령어를 통해 설치해 준다.
redis가 잘 설치되었는지, redis-server --version 명령어로 확인한다.
설치된 것을 확인 후, redis-server 명령어를 입력하면, 아래와 같이 이쁜 redis로고를 만날 수 있다.
터미널에서 다른 프로세스와 수행될 수 있도록 background로 실행해주기 위해서, brew services start redis로 실행시켜준다.
redis 설치를 잘 마무리했다면, Nestjs랑 연결시켜주는 작업을 해보자!
// npm install @nestjs/cache-manager cache-manager cache-manager-ioredis
위의 명령어를 통해 필요한 라이브러리를 적용하자. 라이브러리의 설명은 npm 공식문서를 참고하자.
// cache.module.ts
import { Module, Global } from '@nestjs/common'
import * as redisStore from 'cache-manager-ioredis'
import { CacheModule } from '@nestjs/cache-manager'
import { ConfigService } from '@nestjs/config'
import { cacheConfig } from '../config/cache.config'
import { CacheService } from './cache.service'
import { ICACHE_SERVICE } from '../common/provider.constant'
@Global()
@Module({
imports: [
CacheModule.registerAsync({
useFactory: async (configService: ConfigService) => ({
store: redisStore,
host: cacheConfig(configService).cacheHost,
port: cacheConfig(configService).cachePort,
ttl: 600,
}),
inject: [ConfigService],
}),
],
providers: [
CacheService,
{
provide: ICACHE_SERVICE,
useClass: CacheService,
},
],
exports: [ICACHE_SERVICE, CacheModule],
})
export class RedisCacheModule {}
@Global 데코레이터를 이용하여, 전역 모듈로 선언해 준다.
@nestjs/config 모듈의 ConfigService를 통해 주입받은 환경변수로 지정된 값들을 불러와서 import 해준다. store로 redisStore를 사용 설정 해준다.
providers로 CacheService와 ICACHE_SERVICE 토큰에 연결된 CacheService를 제공한다.
//cache.service.interface.ts
export interface ICacheService {
getFromCache<T>(key: string): Promise<T | null>
setCache<T>(key: string, value: T, ttl: number): Promise<void>
deleteCache(key: string): Promise<void>
}
//cache.service.ts
import { ICacheService } from './cache.service.interface'
import { CACHE_MANAGER } from '@nestjs/cache-manager'
import { Injectable, Inject } from '@nestjs/common'
import { Cache } from 'cache-manager'
@Injectable()
export class CacheService implements ICacheService {
constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
async getFromCache<T>(key: string): Promise<T | null> {
try {
const cachedItem = await this.cacheManager.get<T>(key)
return cachedItem ? cachedItem : null
} catch (err) {
console.error('캐시 데이터 액세스 실패:', err)
return null
}
}
async setCache<T>(key: string, value: T, ttl?: number): Promise<void> {
try {
if (ttl) await this.cacheManager.set(key, value, ttl)
else await this.cacheManager.set(key, value)
} catch (err) {
console.error('캐시 데이터 설정 실패', err)
}
}
async deleteCache(key: string): Promise<void> {
try {
await this.cacheManager.del(key)
} catch (err) {
console.error('캐시 데이터 삭제 실패', err)
}
}
}
캐시 인터페이스를 지정하고, 캐시 데이터를 생성, 삭제, 불러오기 기능을 하는 서비스 코드를 작성해 준다. 이렇게 모든 레디스 캐싱에 대한 설정이 끝나면, 캐싱을 적용할 서비스 로직에 코드를 추가해 준다.
//캐싱을 적용할 서비스 로직
import {
Inject,
Injectable,
InternalServerErrorException,
} from '@nestjs/common'
import { RestaurantRepository } from './restaurant.repository'
import { Logger } from '@nestjs/common'
import { GetRestaurantDto } from './dto/get-restaurant.dto'
import { InjectRepository } from '@nestjs/typeorm'
import { ICACHE_SERVICE } from 'src/common/provider.constant'
import { CacheService } from 'src/cache/cache.service'
@Injectable()
export class RestaurantService {
private readonly logger = new Logger(RestaurantService.name)
constructor(
@InjectRepository(RestaurantRepository)
private restaurantRepository: RestaurantRepository,
@Inject(ICACHE_SERVICE)
private readonly cacheService: CacheService,
) {}
async getRestaurantById(id: number) {
//유명 맛집 상세정보 캐싱적용
const cacheKey = `RestaurantId:${id}`
const popularRestaurant = await this.cacheService.getFromCache(cacheKey)
// 캐싱데이터가 있다면 바로 반환
if (popularRestaurant) return { popularRestaurant }
try {
const result = await this.restaurantRepository.findRestaurantById(id)
const popularRestaurants =
await this.restaurantRepository.findPopularRestaurantById(id)
// score가 4점이상인 맛집은 캐싱후 반환
if (!popularRestaurants) return { result }
else {
await this.cacheService.setCache(cacheKey, popularRestaurants, 600) // Cache for 10m
return { popularRestaurants }
}
} catch (error) {
throw new InternalServerErrorException(
'해당 리뷰를 불러오는데 실패했습니다.',
)
}
}
}
cache.module.ts에서 설정한 DI를 이용하여, 문자열 토큰 ICACHE_SERVICE를 통해 CacheService의 인스턴스를 가져와 사용할 수 있게 한다. getRestaurantByID를 살펴보자. restaurantId 기준으로 평점이 4점이 이상인 맛집들은 조회가 빈번하게 일어날 것이기 때문에, 캐시데이터를 저장하고 있다면, 사용자에게 보다 빠르게 서비스를 제공할 수 있다. 맛집 조회 요청이 들어왔을 경우, 캐시데이터가 있으면 바로 반환, 없다면 평점이 4점 이상인 맛집들은 캐시 데이터를 생성 후 반환, 4점 미만인 맛집들은 캐시 데이터를 저장하지 않고 메인 DB에서 데이터를 반환하게 된다.