프레임워크/NestJS

[NestJS] DB 캐시 Redis 사용하기

트리맨스 2021. 12. 16. 23:37
반응형

 

 

 

현재 앱 운영이 순조롭게 되고 있는 것 같지만, 내부적으로 서버 응답 속도가 느리다는 문제가 꾸준히 있었다. 사실 이 문제는 AWS CloudWatch에서도 꾸준히 보고되었고 예전부터 해결해야 할 과제 중 하나였는데, 다른 작업들에 밀려 우선순위가 낮아졌다. 이제서야 이 문제를 해결하게 되었다.

 

현재 문제 상황


현재 운영 중인 데이터의 구조는 postgresql에서 부모 데이터에 자식 데이터가 1:n으로 물려 있는 형태이다. 여기서 부모 데이터는 15만개 정도의 row 데이터가 있고, 자식 데이터는 그것의 10배 정도의 row 데이터가 있다. 이 데이터는 사용자의 tz 기준으로 하루에 대한 데이터를 뽑아내야 한다. 여기서 문제가 되는 것은 time을 기준으로 데이터를 범위 검색을 하다 보니 하루에 대한 데이터는 비교적 빨리 나오는 편인데 한 달 정도의 데이터를 뽑아낼 때는 생각보다 시간이 많이 걸리게 된다. (작은 딜레이가 누적되서 최종 로직에서는 시간이 많이 걸리게 되었다.) 그래서 해결 방법을 고안하기로 했다.

 

 

1. 새로운 db 구조 작성하기

이 방법은 새로운 DTO 및 DB를 새로 만들어 모든 유저 데이터를 옮기는 것이였다. 하지만 이 작업을 하게 되면 시간도 오래 걸리고 유저들의 데이터가 잘 이동되리라는 보장도 없었다. 서버를 꺼 두는 동안 유저들은 앱을 못 쓰게 될 것이고, 이는 맞지 않다고 판단했다. 그리고 서버 전체 코드를 뜯어고쳐야 하는데, 대략 5만줄 정도에 이르는 코드를 혼자 수정하기에는 너무 큰 작업이였다. 그래서 기존 로직에 속도 향상을 위한 장치를 다는 방향으로 가기로 했다.

 

 

2. db 구조 변경하기

postgresql 의 db는 기본적으로 b-tree 구조를 사용한다. 하지만 시간에 대한 데이터를 탐색할 때는 brin 알고리즘이 효과적이다.

(참고 사이트)

https://www.postgresql.org/docs/9.5/brin-intro.html

 

Introduction

BRIN stands for Block Range Index. BRIN is designed for handling very large tables in which certain columns have some natural correlation with their physical location within the table. A block range is a group of pages that are physically adjacent in the t

www.postgresql.org

https://americanopeople.tistory.com/313

 

(PostgreSQL) BRIN 인덱스 활용하기

BRIN 인덱스 BRIN 인덱스는 Block Range Index의 약자다. BRIN 인덱스는 페이지의 메타데이터를 뽑아서 인덱스를 구성한다. 그래서 타임시퀀스한 대용량 데이터를 저장하고, 조회할 때 유용하다. 테이블

americanopeople.tistory.com

 

그래서 brin 알고리즘을 사용할까 싶기도 했다. 하지만 여기서도 문제가 생겼다. 우리의 서버는 현재 ORM으로 prisma schema를 사용중이다. brin 알고리즘이 prisma schema와 호환되는지 확인하러 가 보았다.

 

 음...postgresql은 호환이 된다고 한다. 하지만 prisma schema는 호환이 아직 안된다고 한다. (prisma schema에서 db의 인덱싱 타입을 지정할 수 없는듯) 확인해보니 현재 db의 인덱스 타입은 모두 b-tree 이였다. 그래도 사용은 가능하다고 한다. 이 부분에 대해서는 나중에 검증이 필요할 것 같기도 하고 생각만큼의 속도 향상이 나오지 않는 것 같아 다른 방법을 사용해보기로 했다.

 

 

3. db 캐시 사용하기

이 방법은 상당히 흥미로웠다. 하루에 대한 최종 데이터를 뽑기 위해서는 시간이 꽤 걸리는 편인데, 이를 캐시 데이터로 변환해서 각 데이터마다 고유한 key 값을 만들고, 이에 대한 데이터를 캐시 데이터로 만들게 되면 다음에 탐색할 때는 캐시 데이터가 존재하는지 확인하여 캐시 시 데이터가 존재하면 바로 값을 불러올 수 있는 로직이 매력적으로 보였다.

 

그래서 이에 대한 개념을 정리하고 실제 로직으로 구현해 보기로 했다. 

 

 

DB 캐시 서버란?


DB에 데이터를 저장하거니 읽어오는 것에는 비용이 따른다. 시간 자원이든 리소스 자원이든 소모가 된다. DB에 읽기나 쓰기 작업이 매우 빈번하게 발생이 되면 DB에 부하가 가게 되고, 이는 곧 DB의 성능 저하로 이어지게 된다. 이것을 덜어 주기 위해 DB 캐시를 사용하기로 했다.

 

DB 캐시는 key와 value로 이루어진 해시 테이블 구조로 이루어져 있다. 즉 key를 입력하면 value가 O(1)의 속도로 응답되는 구조이다. 이와 같은 구조의 장점은 속도가 매우 빠르다는 점이다. DB 캐시는 대부분 메모리에서 구동되므로, 보조 저장 장치에서 작동되는 DB에 비해서 I/O 속도가 빠른 편이다. 또한 이를 통해 트래픽 감소, 부하 감소를 얻을 수 있다. 

 

하지만 단점도 존재한다. 바로 싱글스레드 이기 때문에 O(N)의 속도를 가지는 명령어를 사용하는 것을 지양하게 된다. 또한 메모리에 데이터를 저장하기 때문에 전원이 꺼지면 캐시 데이터는 모두 사라지게 된다. (전원이 꺼져도 캐시 데이터를 사용할 수 있게 하는 방법이 있기는 하다.) 

 

DB 캐시 서버는 Redis, MemCached 등의 제품들이 있다. 나는 보편적으로 쓰이는 Redis를 사용하기로 했다.

 

Redis


https://redis.io/documentation

 

Redis

*Documentation Note: The Redis Documentation is also available in raw (computer friendly) format in the redis-doc github repository. The Redis Documentation is released under the Creative Commons Attribution-ShareAlike 4.0 International license. *Programmi

redis.io

 

공식 문서를 봐도 나쁘지 않을 것 같다.

 

Redis 는 대표적인 DB 캐시 서버이다. 특징은 Value 값에 꽤 다양한 자료형이 들어갈 수 있다는 것이다. String, hash, set, sorted set, List 의 자료형을 지원한다. (String은 문자열, hash는 key:value의 집합, set은 중복 없는 배열, sorted set은 정렬된 중복 없는 배열, List는 링크드 리스트 자료형의 구조) 신기한 것은 PubSub으로 Redis를 사용하기도 한다는 것이다.

 

NestJS는 DB 캐시로 Redis를 지원한다. NestJS에서 Redis를 사용하는 법에 대해서 알아보자.

 

NestJS에서 Redis 사용하기


1. Redis 설치

 

맥에서는 brew install redis 명령어를 입력하면 바로 설치가 된다. 이후에 redis-cli를 입력하면 redis 서버랑 통신할 수 있는 창이 하나 뜨게 된다. 기본 포트는 6379번으로 접속한다.

 

 

주로 사용하는 명령어는 다음과 같다.

 

set [key] [value] : key에 해당하는 value 값을 입력한다.

get [key] : key에 해당하는 value 값을 받아온다.

keys * : 모든 캐시 데이터를 불러온다.

ttl [key] : 해당 key의 삭제 되기까지의 시간을 구한다. (단위는 초)

Fluslall : 모든 캐시 삭제

 

여기다가 간단한 인증 시스템을 추가하자. redis 설정 파일을 직접 수정하고 재시동 하면 된다. 파일은 /etc/redis.conf 에 있다. 인증 방법은 redis-cli에서 auth를 입력 후에 암호를 입력하게 되면 접속 권한이 주어지게 된다. (명령어 : auth [암호]) vim으로 redis.conf 파일을 수정하자. redis.conf 파일 안에 requirepass 값 다음에 암호를 입력하면 된다.

 

 

다음으로는 외부 접속을 허용해주자. redis의 기본값은 localhost만 접속이 가능하게 되어있다. 이거를 해제해 주어야지 외부에서 캐시 접속이 가능해진다. 설정 파일에서 bind라는 항목을 찾아서 수정한다. 값을 0.0.0.0 으로 설정하면 모든외부 접속을 가능하게 한다.

 

 

이제 redis 시스템을 재시작하자. 명령어는 sudo systemctl restart redis 이다. 재시작하면 외부 입력과 동시에 암호 설정이 완료가 되어 있을 것이다.

 

redis 서버를 외부에서 접속해 보자. 접속 명령어는 redis-cli -h [아이피주소] -p [포트번호] 이다.

 

 

2. NestJS와 연결

 

역시 이것도 공식 문서를 한번 보자.

https://docs.nestjs.com/techniques/caching

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

 

캐시를 적용하기에 앞서 cache-manager-redis-store 와 cache-manager 패키지를 설치해 준다.

 

그 이후 CacheModule을 register하고 호스트와 포트를 잘 적어 준다. 여기서 store에 redisStore를 적어 주면, 이 서버는 DB 캐시로 redis를 사용할것이라는 것을 명시해 주는 것이 된다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// Cache.Module.ts
 
import { CacheModule, Global, Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store';
import { CacheDBService } from '../../services/cache.service';
import { CacheDBResolver } from './cache.resolver';
 
export const cacheModule = CacheModule.registerAsync({
  useFactory: async () => ({
    store: redisStore,
    host: 'localhost',
    port: '6379',
    ttl: 0,
    auth_pass: 'password',
  }),
});
 
@Global()
@Module({
  imports: [cacheModule],
  providers: [CacheDBService, CacheDBResolver],
  exports: [CacheDBService],
})
export class CacheDBModule {}
cs

 

작동하는 서비스를 예시로 작성해 보았다. 여기서 주목할 것은 생성자 안에 Cache를 사용했다는 것이다. 이를 이용해서 로직이 실행이 된다. setKey를 실행하게 되면 캐시를 저장하게 되고 getKey를 실행하면 캐시에 저장되어 있는 데이터가 나오게 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// cache.Service.ts
 
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
 
@Injectable()
export class CacheDBService {
  constructor(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) {}
 
  async setKey(key: string, value: string): Promise<boolean> {
    await this.cacheManager.set(key, value);
    return true;
  }
 
  async getKey(key: string): Promise<string> {
    const rtn = (await this.cacheManager.get(key)) as string;
    return rtn;
  }
}
 
cs

 

 

로직 작성 시 고려할 부분


db캐시에 대한 로직을 처음 작성하고 사용해 보았기에 작성 중 고려해야 할 사항에 대해서 정리해 보았다.

 

1. 캐시 운영법?

나름 고민이 있었다. 사용자가 해당 날짜에 해당하는 데이터를 수정했을 때 해당하는 날짜에 대한 캐시를 모두 수정해야 하는데, 이게 좀 애매했다. 자식 데이터를 바꾸는 것은 하루에 대한 데이터이기 때문에 그나마 괜찮았지만, 부모 데이터를 바꾸는 것에 대해서는 하루가 아닌 최소 하루에서 최대 몇 개월 치의 캐시를 삭제해야 하기 때문이다. 

 

그래서 이 부분에 대해서는 캐시의 생존 시간을 제한하고 데이터의 수정 작업 시에 이에 해당하는 캐시 데이터를 삭제하는 것으로 로직을 작성했다. 캐시 데이터가 무제한으로 쌓이게 되면 db 성능에도 좋지 않은 영향을 미칠 뿐더러 앱의 안정성에도 영향을 미치게 된다. 그리고 데이터 수정 작업 시 해당하는 모든 캐시 데이터를 삭제하게 되면 원본 데이터와의 차이도 사라질 것이다. 쓰기 작업보다 읽기 작업이 훨씬 많은 로직 특성상 효율적이라 한단했다.

 

2. 데이터 저장 방식

redis에 여러가지 저장 방식이 있지만, 가장 무난한 string 자료형을 사용하기로 했다. key는 특정 날짜, value는 가공된 하루의 데이터를 사용하기로 했다.

 

key를 지정할 때 시간데이터만 있으면 되는 줄 알았는데, 신경서야 할 부분이 꽤 있었다. 먼저 테스트 환경과 운영 환경, 유저 구분의 문제가 생겼다. 그래서 key값을 만들 때 환경 정보 + 유저 정보 + 날짜 데이터, 이 3개를 합치게 되면 고유한 key 값이 나오게 되어 이것을 최종 key값으로 사용하기로 했다.

 

다행히 value를 지정할 때는 최종적으로 나온 값을 바로 문자열로 바꿀 수 있었다. ts 기반의 서버라서 가능한 일이였다. 하지만 이 데이터를 사용할 때 문제가 생겼다. 바로 시간 정보에 관한 데이터가 정상적으로 복구가 되지 않는다는 문제였다. ts에서는 type을 지정하여 데이터를 사용할 수 있다. 그래서 나는 단순히 object -> string 변환을 거친 다음 string -> object 변환해서 사용하면 될 줄 알았다. 하지만 Date 타입의 자료형은 ~~~ as T 구문으로 되돌릴 수 없었다. 이로인해 자꾸 Date 타입에서 undefined가 나오게 되었다. 이를 해결하기 위하여 Date 타입을 되살리는 로직을 따로 제작했다. 아직 제너릭 타입에 대해서 익숙치 않아서 코드가 보기 좋지 않지만, 나중에 각 잡고 고칠 예정이다.

 

 

향후 계획?


캐시 데이터는 읽기 빈도가 많은 작업(쿼리) 위주로 몰아줄 것이다. 데이터를 수정할 때는 시간이 미세하게 늘 것이다. 하지만 읽기 비용이 비싸다면 이정도 시간은 감수할 가치가 있다고 생각한다. 또한 수정하는 작업이 있을 경우에 캐시 데이터에 씌운 다음 원래 DB에 쌓아 둔다. 필요하다면 사용자가 적은 시간에 캐시 데이터를 미리 저장해 두는 것도 좋은 방법일 것 같다.

 

 

반응형