본문 바로가기

[Project] 프로젝트 삽질기27 (feat Redis 활용)

어가며

사이드 프로젝트에서 푸시 알림을 활용한 서비스를 개발하고 있습니다. 그 과정에서 생각하고 배웠던 점들을 하나씩 작성하고자 합니다. Queue를 활용한 푸시 알림 서버 구축을 하면서, Redis 자료구조를 활용해서, 캐시 활용 서비스를 구축해야 했습니다. 이를 위해 NestJS에서는 유용한 cache module을 제공하고 있었습니다. 하지만 이 모듈에서는 Redis를 활용하기 위한 기능상의 제약이 있었습니다. 그래서 Redis를 더 잘 활용하기 위해 cache module이 아닌, redis 클라이언트를 활용하는 방식에 대해 알아보겠습니다.  

 

 

 


 
 
 
 
 
 

 

Redis를 어떻게 활용할 것인가

POG는 즐겨찾기 한 소환사의 전적이 갱신될 때마다 푸시 알림을 보내주는 서비스입니다. 이는 서버 관점에서 보면, 데이터베이스에 저장된 소환사 ID를 모두 불러와서 현재 데이터베이스에 저장된 전적과 Riot에서 새롭게 보여주는 전적을 비교해서, 만약 전적이 바뀌었다면 푸시 알림을 보내줘야 했습니다. 여기서 소환사 ID를 모두 조회하는 작업과 특정 소환사의 전적을 조회하는 작업은 상당한 작업 리소스가 들었습니다. 특히 1분마다 데이터베이스에 저장된 모든 소환사 ID를 조회해야 했고, 소환사 ID의 전적까지 조회해야 했기에, 데이터가 많으면 많을수록 시간 복잡도는 월등하게 높아졌습니다.

 

기존 RDBMS를 활용해서 조회하는 서비스를 만든다면, 큰 비효율이라 생각했습니다. 이때 조회에 대한 불편함을 없애기 위해 Redis를 도입해야겠다고 생각했습니다. 하지만 Redis를 한 번도 사용해본 적 없었기에, Redis를 활용하기 위해서는 먼저 Redis는 무엇이며, 어떻게 활용할 수 있는지부터 알아야 했습니다. 간단하게 Redis를 알아보고, Redis를 어떻게 프로젝트에 적용했는지 알아보겠습니다.  

 

 

 

 

Redis 소개

Redis는 인메모리 데이터 저장소 오픈소스입니다. String, Set, Sorted-set, hashes, list 당 다양한 타입을 지원합니다. 

 

 

1. Strings

Strings은 가장 기본적인 데이터 타입입니다. Set 커맨드를 이용해 저장합니다. 저장된 데이터는 모두 string 형태의 데이터로 저장됩니다. 

 

redis > SET score:a 10
"OK"

redis > INCR score:a
(integer) 11

redis > INCRBY score:a 4
(integer) 15

 

Redis에서 카운팅 하는 예시를 살펴보겠습니다. score:a라는 키에 10을 저장하고, 값을 증가시키는 구성입니다. 

 

 

2. Bitmaps

Bitmaps은 string의 변형입니다. 비트 단위의 연산이 가능합니다. 이를 이용하면 저장 공간을 절약할 수 있습니다. 

redis > SETBIT visitors:20210817 3 1
(integer) 0

redis > SETBIT visitors:20210817 6 1
(integer) 0

redis > BITCOUNT visitors:20210817
(integer) 2

 

예를 들어 우리 서비스에 접속한 유저 수를 카운팅 하고 싶다고 할 때, 날짜 키 하나를 만들어 두고 유저 아이디에 해당하는 비트를 1로 올려주는 것입니다. 하나의 비트가 한 명을 의미하므로 만약 천만명의 유저는 천만 개의 비트로 표현할 수 있고, 이는 곧 1.2mb 정도밖에 차지하지 않습니다. 비트 카운트를 통해 1로 설정된 값을 모두 카운팅 할 수 있습니다. 하지만 이 방법을 이용하려면 모든 데이터를 정수로 표현할 수 있어야 합니다. 유저 아이디와 같은 값이 0 이상의 정수 값일 때만 카운팅이 가능합니다. 

 

 

 

3. Lists

데이터를 순서대로 저장하는 Lists는 Queue로 활용하기 적합합니다. Blocking 기능을 이용해 Event Queue로 사용합니다. 

 

 

// clientA
redis > BRPOP myqueue 0

 

예를 들어 클라이언트 A가 BRPOP 커맨드를 통해 myqueue에서 데이터를 꺼내오려고 하는데, 리스트 안에 데이터가 없어서 대기하는 상황입니다. 

 

 

// client B
redis > LPUSH myqueue 'hi'
(integer) 1

 

이때 클라이언트 B 가 'hi'라는 값을 넣어주면 

 

 

// clientA
redis > BRPOP myqueue 0

1) "myqueue"
2) "hi"
(26.65s)

 

클라이언트 A에서 바로 이 값을 확인할 수 있습니다. 

 

 

 

 

4. Hashes

Hashes는 하나의 키 안에 여러 개의 필드와 밸류 쌍으로 저장합니다. 

 

 

5. Sets

Sets은 중복되지 않은 문자열의 집합입니다. 

 

 

6. Sorted Sets

Sorted Sets은 Sets처럼 중복되지 않은 값을 저장하지만 모든 값은 score라는 숫자 값으로 정렬됩니다. 데이터가 저장될 때부터 스코어 순으로 정렬되며 스코어가 같을 때는 사전 순으로 정렬되어 저장됩니다. 

 

 

7. HyperLogLogs

굉장히 많은 데이터를 다룰 때 주로 사용하며 중복되지 않는 값의 개수를 카운트할 때 사용합니다. 모든 string 데이터 값을 유니크하게 구분할 수 있습니다. 이는 set과 비슷하지만 대용량 데이터를 카운팅 할 때 적절합니다.

 

redis > PFADD crawled:20211024 "http://www.google.com/"
(integer) 1

redis > PFADD crawled:20171124 "http://www.redis.com/"
(integer) 1

redis > PFCOUNT crawled:20211024
(integer) 8278423

redis > PFMERGE crawled:all crawled:20211024 crawled:20211023 .. 
(integer) 23905917

 

왜냐하면 저장된 데이터의 개수와 상관없이 모든 값이 12KB로 고정됩니다. 대신 저장된 데이터는 다시 확인할 수 없습니다. 경우에 따라 데이터를 보호하기 위해서도 사용할 수 있습니다. 예를 들어 웹 사이트 방문한 ip가 유니크하게 몇 개가 되는지, 하루 종일 크롤링한 url 개수가 몇 개가 되는지, 검색 엔진에서 검색된 유니크한 단어가 몇 개가 되는지 크고 유니크한 값을 계산할 때 적절합니다. 

 

 

8. Streams

로그를 저장하기 가장 좋은 자료구조입니다. 

 

redis > XADD mystream * sensor-id 1234 temperature 19.8
"1634823241307-0"

redis > XADD mystream * sensor-id 1234 temperature 20.4
"1634823263756-0"

 

실제 서버의 로그가 쌓이는 것처럼 append-only 방식으로 데이터가 저장됩니다. 중간에 데이터가 바뀌지 않습니다. 예시에서는 XADD 커맨드를 이용해 mystream이라는 키게 데이터를 저장하고 있습니다. 키 옆에 *는 id값을 의미하며 id를 직접 저장할 수도 있지만 일반적으로는 *를 입력하면 redis가 알아서 저장하고 아이디 값을 반환시켜줍니다. 반환된 id값은 데이터가 저장된 시간을 의미합니다. 값 뒤로는 해시처럼 키 밸류 쌍으로 데이터가 저장되는데 예제에서는 sensor-id값에 1234를, 온도에는 19.8을 저장한 것을 의미합니다. 

 

stream의 데이터를 읽어오는 방법은 다양한데, id값을 이용해 시간대역대로 저장된 값을 검색할 수 있고, 새로 들어온 데이터만 리스닝할 수 있습니다. 카프카처럼 소비자 그룹이라는 개념이 존재하기에, 원하는 소비자만 특정 데이터를 읽게 할 수 있습니다. stream에서는 카프카의 개념을 굉장히 많이 사용하는데, stream을 메시징 브로커가 필요할 때 카프카를 대체해서 간단하게 사용할 수 있는 자료구조라고 설명하고 있습니다. 

 

 

 

그럼 Redis를 어떻게 활용했는지 더 자세히 알아보겠습니다.

 

 

 

Redis Cache와 Caching 전략

Redis를 캐시로 사용할 때 어떻게 배치하느냐에 따라 시스템 성능에 큰 영향을 끼칩니다. 이를 캐싱 전략이라고 하며, 캐싱 전략은 데이터의 유형과 액세스 패턴을 잘 고려해서 선택해야 합니다. 이런 캐싱 전략에도 여러 종류가 있습니다. 캐싱 전략에 대해 알아보겠습니다.

 

 

 

출처 : https://youtu.be/92NizoBL4uA

1. Look Aside

애플리케이션에서 조회 작업이 많을 때 사용하는 전략인 Look Aside를 알아보겠습니다. 캐시를 쓸 때 일반적으로 사용하는 방법입니다. 데이터를 찾을 때 캐시에서 먼저 조회하고, 만약 찾는 키가 없다면 DB에 접근해서 데이터를 조회한 뒤 다시 Redis에 저장하는 과정을 거쳐야 합니다. 따라서 캐시는 찾는 데이터가 없을 때만 입력되기 때문에 Lazy Loading이라고 합니다. 레디스가 다운되더라도 바로 장애로 이어지지 않고, DB에서 데이터를 가져올 수 있습니다. 대신 캐시로 붙어있던 커넥션이 많았다면 커넥션이 데이터베이스로 붙기 때문에 DB에 부하가 몰릴 수 있습니다. 그래서 캐시를 새로 투입하거나 DB에만 새로운 데이터를 저장했다면 캐시 미스가 발생해서 성능에 저하가 올 수 있습니다. 

 

 

출처 : https://youtu.be/92NizoBL4uA

 

 

이럴 경우 미리 디비에서 캐시로 데이터를 밀어 넣어주는 작업을 할 수 있는데, 이를 Cache Warming이라고 합니다. 데이터를 사용할 땐 어떤 전략이 사용되는지 알아보겠습니다. 

 

 

 

출처 : https://youtu.be/92NizoBL4uA

2. Write-Around

모든 DB에만 데이터를 저장하는 방식입니다. 캐시 미스가 발생한 경우 캐시에 데이터를 끌어옵니다. 캐시 내의 데이터와 DB 내의 데이터가 다르다는 단점이 있습니다. 

 

 

출처 : https://youtu.be/92NizoBL4uA

 

3. Write-Through

Write-Through 방식은 데이터를 저장할 때 캐시에도 데이터를 함께 저장하는 방법입니다. 저장할 때마다 두 단계 스텝을 거쳐야 해서 상대적으로 느릴 수 있습니다. 그리고 저장하는 데이터가 재사용되지 않을 수 있는데 무조건 캐시에 넣어버리기 때문에 일종의 리소스 낭비가 될 수 있습니다. 따라서 데이터를 저장할 때 몇 분 혹은 몇 시간만 데이터를 보관하겠다는 만료 기간을 설정하는 것이 좋습니다. 하지만 값을 어떻게 관리하느냐에 따라 장애 포인트가 될 수 있습니다. 

 

 

 

 

출처: 우아한테크세미나 191121 우아한레디스 by 강대명님

 

 

 

4. Write-Back

Write-Back 방식은 데이터를 캐시에 전부 먼저 저장해놓았다가 특정 시점마다 한 번씩 캐시 내 데이터를 DB insert 하는 방법입니다. insert를 1개씩 500번 수행하는 것보다 500개를 한 번에 삽입하는 동작이 훨씬 빠름에서 알 수 있듯, write back 방식도 성능면에서 뒤처지는 방식은 아닙니다. 하지만 데이터를 일정 기간 동안 유지하고 있어야 하는데, 이때 이걸 유지하고 있는 storage는 메모리 공간이므로 서버 장애 상황에서 데이터가 손실될 수 있다는 단점이 있습니다. 그래서 다시 재생 가능한 데이터나, 극단적으로 heavy 한 데이터에서 write back 방식을 많이 사용합니다.

 

 

 

Redis를 어떻게 활용했나?

저는 소환사의 전적 정보를 푸시 알림으로 제공하는 서비스를 개발 중입니다. 그렇기에, 소환사의 전적을 Redis에 저장하고, 소환사의 전적이 갱신될 때마다 Redis의 데이터를 교체하는 과정을 거칩니다. 또한 현재 Redis에 저장된 소환사의 전적과 Riot이 제공하는 소환사 전적 정보를 비교해서 전적이 변동됐으면 푸시 알림을 보내줘야 합니다. 이를 처리하기 위해 캐시에 먼저 데이터를 조회하고, Redis에 데이터가 없으면 Redis에 Riot의 소환사 정보를 저장해서 사용하는 방식으로 Redis를 활용하고 있습니다. 

 

 

Redis 사용 시 주의 사항

저는 Redis 클라이언트인 ioredis를 활용하면서 NestJS에서 ioredis를 조금 더 잘 활용하려면 어떻게 해야 할까 고민했습니다.

 

// Import ioredis.
// You can also use `import Redis from "ioredis"`
// if your project is an ESM module or a TypeScript project.
const Redis = require("ioredis");

// Create a Redis instance.
// By default, it will connect to localhost:6379.
// We are going to cover how to specify connection options soon.
const redis = new Redis();

redis.set("mykey", "value"); // Returns a promise which resolves to "OK" when the command succeeds.

// ioredis supports the node.js callback style
redis.get("mykey", (err, result) => {
  if (err) {
    console.error(err);
  } else {
    console.log(result); // Prints "value"
  }
});

// Or ioredis returns a promise if the last argument isn't a function
redis.get("mykey").then((result) => {
  console.log(result); // Prints "value"
});

redis.zadd("sortedSet", 1, "one", 2, "dos", 4, "quatro", 3, "three");
redis.zrange("sortedSet", 0, 2, "WITHSCORES").then((elements) => {
  // ["one", "1", "dos", "2", "three", "3"] as if the command was `redis> ZRANGE sortedSet 0 2 WITHSCORES`
  console.log(elements);
});

// All arguments are passed directly to the redis server,
// so technically ioredis supports all Redis commands.
// The format is: redis[SOME_REDIS_COMMAND_IN_LOWERCASE](ARGUMENTS_ARE_JOINED_INTO_COMMAND_STRING)
// so the following statement is equivalent to the CLI: `redis> SET mykey hello EX 10`
redis.set("mykey", "hello", "EX", 10);

 

 

ioredis 공식 문서를 활용하면 기본적으로 위와 같이 활용하게끔 되어있습니다. 위와 같이 활용하면 손쉽게 ioredis를 사용할 수 있습니다. 하지만 NestJS를 활용하는 과정에서, 위의 코드를 직접 주입하는 방식으로 코드를 구성하니, 테스트 코드를 구성할 때 문제가 생겼습니다. 

 

현재 Redis는 Elasticache를 활용해서 사용하고 있는데, 테스트할 때는 로컬 Redis를 활용해야 했습니다. 환경을 나누기 위해서는 직접 주입하는 방식이 아닌, 주입받는 방식으로 구성해야 했습니다. 이때 테스트 환경과 배포 환경의 Redis 구성을 다르게 설정하여 작업하기 위해서는 Redis를 활용하는 코드를 모듈화 해서 주입받는 방식으로 활용하면 Redis를 활용한 테스트 코드를 구성할 때 효과적일 것이라 판단했습니다.

 

// EventStoreModule.ts

import { EventStoreServiceImplement } from './EventStoreService';
import { Module, Provider } from '@nestjs/common';
import { EInfrastructureInjectionToken } from '@app/common-config/enum/InfrastructureInjectionToken';

const infrastructure: Provider[] = [
  {
    provide: EInfrastructureInjectionToken.EVENT_STORE.name,
    useClass: EventStoreServiceImplement,
  },
];

@Module({
  providers: [...infrastructure],
  exports: [...infrastructure],
})
export class EventStoreModule {}

 

그래서 위와 같이 커스텀 프로바이더를 활용하여 Service 객체를 쉽게 주입받고 주입할 수 있도록 구성했습니다.

 

 

import { Exclude, Expose } from 'class-transformer';
import { Enum, EnumType } from 'ts-jenum';

@Enum('code')
export class EInfrastructureInjectionToken extends EnumType<EInfrastructureInjectionToken>() {
  @Exclude() private readonly _code: string;
  @Exclude() private readonly _name: string;

  static readonly EVENT_STORE = new EInfrastructureInjectionToken(
    'EVENT_STORE',
    'EventStore',
  );

  private constructor(code: string, name: string) {
    super();
    this._code = code;
    this._name = name;
  }

  @Expose()
  get code(): string {
    return this._code;
  }

  @Expose()
  get name(): string {
    return this._name;
  }

  toCodeName() {
    return {
      code: this.code,
      name: this.name,
    };
  }
}

 

 

또한 프로바이더 토큰은 ts-jenum 라이브러리를 활용하여 Enum을 Class처럼 활용하여 코드의 응집성을 높였습니다.

 

 

 

 

 

 

 

 


 

 

 

 

 

 

마치며

앞으로도 팀의 발전을 돕는 개발자가 되기 위해 노력하려 합니다. 팀에 필요한 부분이 무엇일지 고민하면서, 팀에 도움이 된다면, 열심히 공부해서 실무에 적용할 수 있는 개발자가 되기 위해 노력하고 싶습니다. 팀의 성장에 기여할 수 있는 개발자가 되겠습니다. 

 

 

 

 


 

 

 

 

 

참고 및 출처

 

[리뷰] 우아한 Redis 리뷰

본 포스팅은 우아한 Redis 영상을 정리한 내용입니다. Redis 소개 인메모리 데이터 저장소 오픈소스 String, set ,sorted-set, hashes, list 등 다양한 타입 지원 cache 구조 Look asid Cahce: 캐시에 자료가 없으면,

velog.io