[NestJS] 주문처리과정을 통해 알아보는 수익률 랭킹 조회 (Part 4_ Redis Sorted Set과 Ranking) #4

DatQueue·2023년 7월 1일
4
post-thumbnail

시작하기에 앞서

지난 시간까지 결제주문을 위한 데이터베이스 설계 + 서비스 로직 작성 + 프로세스 테스트 를 진행해보았다.

이번시간엔 간단하게 이를 활용해 보고자한다.

그것은 바로 "랭킹(Ranking) 기능"이다.

다들 알겠지만 하나의 예로 든다면 전적 검색 사이트(OPGG)나 무신사 블프기간때 볼 수 있는 유저 구매 랭킹등이 이에 해당한다.
그리고, 이러한 랭크 기능은 "Redis"로 구현할 수 있다는 말 또한 들어보았을 것이다. 그런데 인 메모리(In-memory) 데이터 시스템인 레디스가 어떠한 장점이 있길래 랭크 기능에서 이를 사용하는 것일까?

실제 우리의 서비스에 적용시켜보기 전, 먼저 랭크 기능을 위한 레디스의 특징 및 사용법등을 먼저 익히는 시간을 가져보고 그 후 적용을 시켜보고자 한다.


💢 Redis Sorted Set


> Sorted Set에 대해 인지하고 있는가? (Feat. 시간 복잡도)

랭크 기능을 구현하는데 있어, 어떠한 방식을 적용시켜줄 수 있을까?

그냥 단순히 타입스크립트를 사용하여 이를 적용한다면 아래와 같은 방법을 사용할 것 같다.

const userList = {
  a: 100,
  b: 90,
  c: 30,
  d: 58,
  e: 73,
  f: 85,
  g: 93,
  h: 3,
  i: 29,
};

const userListArray = Object.entries(userList).map(([key, score]) => ({ key, score }));

userListArray.sort((b, a) => a.score - b.score);
console.log(userListArray); // 아래에 결과
[
  { key: 'a', score: 100 },
  { key: 'g', score: 93 },
  { key: 'b', score: 90 },
  { key: 'f', score: 85 },
  { key: 'e', score: 73 },
  { key: 'd', score: 58 },
  { key: 'c', score: 30 },
  { key: 'i', score: 29 },
  { key: 'h', score: 3 }
]

만약 이런상황에서 특정 key: value 쌍의 value 값을(위의 경우에서 score값을) 변경해야한다면, 이는 어떻게 처리해야할 수 있을까?

생각해보자면, 이런 방법들이 떠오를 것 같다.

특정 key에 해당하는 value(score)값의 변화에 따라 재정렬을 위한 함수를 실행시켜 다시 정렬하는 것이다. 혹은, 기존에 객체는 삭제시켜준 뒤 새로운 객체의 score값을 findIndex등의 메서드를 통해 배열 내의 모든 객체의 score값과 비교 뒤, 이에 따라 다시 정렬된 배열을 구하는 방법이 있을 것이다.

뭐, 구현 불가능한 방법은 전혀 아니지만 일반적으로 랭킹을 사용한다고 하는 것 자체가 많은 데이터(key:value 쌍)에 해당하는 score 비교를 빠르게 해주기 위함이라 생각한다. 즉, 다시 말해서 "많은 데이터"가 존재할 것이다. 이것은 서비스의 규모에 따라 수십, 수백만 건이 존재할 수 있다. 즉, 자바스크립트의 힙에 저장되는 일반적 배열 및 객체의 자료구조를 사용할 경우 엄청난 부담이 될 것이다. 딱히 인덱스가 지정되어있는게 아니기 때문에 수많은 비교를 거쳐야하고 결국 시간복잡도 측면에서 굉장히 저조한 성능일 것이다.


이러한 이유로 우리는 인 메모리 데이터 스토어인 "레디스"를 사용하게 되고, 그 중에서도 score란 새로운 개념을 사용하는 "Sorted Set"을 사용할 수 있다.


✔ Set

Sorted Set을 알기전 "Set"에대해 간단히 알아보자. 일반적으로 Set이란 자료구조에 대해서 설명할 때 "List"와 많이 비교하게 된다. List는 내부적으로 Linked List(연결 리스트)이다. 간단히 설명하자면 Linked List는 각 노드가 데이터와 포인터를 가지고 한 줄로 연결되어 있는 방식으로 데이터를 저장하는 자료구조이다. 여기서, 각 노드는 다음 노드를 가리키는 포인터를 포함한다.
즉, List는 삽입 순서에 따라 정렬이 된다. 하지만 Set은 정렬되지 않는 value의 모음이다. 즉, 순서를 내부적으로 유지하지 않는다는 뜻이다.
Set은 이러한 이유로 아이템을 POP하거나 PUSH 할 순 없다. 물론, SADD와 SREM을 이용해서 아이템을 추가하거나 삭제하는 과정을 수행한다.

시간 복잡도(Time Conplexity)관점에선 어떨까?

List의 경우는 위에서도 언급하였다시피 크기와 상관 없이 처음과 마지막에 아이템을 추가하는 것은 O(1)이 된다. 그러나 처음과 끝이아닌 중간 어딘가의 아이템을 찾는데 걸리는 시간은 O(N)이라 할 수 있다. 반면에 Set의 경우는 아이템 추가, 삭제, 확인의 작업에 O(1)의 복잡도를 가진다고 할 수 있다.

여기서 우린 일련의 데이터 중 임의의 어딘가의 아이템을 조회하기 위해선 "Set"을 사용하는 것이 효율적일 것이라 생각할 수 있다. (물론, Redis에서 Set을 사용하면 더 많은 연산 수행 명령어들을 제공받을 수 있다)


✔ Sorted Set

Redis에서 Set은 Sorted 하게끔, 즉 "정렬된" Set data structure를 가질 수 있다는 것에 큰 효과가 있지 않을까 싶다.

Sorted Set에서의 아이템 추가, 삭제, 확인의 시간 복잡도는 O(1)이다. 이는 Set과 마찬가지이다. 또한, redis는 sorted set을 사용하는데 있어 "zadd", "zrange"와 같은 명령어를 제공해준다.

우리는 개발자이므로 한 가지 생각을 해볼 필요가 있다. (물론, 본인 혼자 든 의문일 순 있지만... )

곧, 현재 진행중인 프로젝트에서 사용하기도 하겠지만 랭킹 기능을 구현하는데 있어서 "ZRANGE" 명령어를 사용하게 될 것이다.

하지만, ZRANGE 자체는 시간복잡도가 O(1)이 아니다.

레디스 공식문서에서 확인할 수 있듯이 ZRANGE의 시간 복잡도는 "O(log(N) + M)"이다.

그렇다, 위에서도 언급하였다시피 Sorted Set의 "추가, 삭제, 확인"의 측면에서 시간복잡도가 O(1)인 것이다. 여기서 "확인"이라 함은 조회의 측면이 아닌, 멤버 존재 여부 확인(Exists) 이다.

조금만, 더 깊게 들어가보자.

Sorted Set은 내부적으로 두 가지 데이터 구조로 저장된다. 이에 대해서 깊게 설명하진 않겠지만 간단히 얘기하자면 메모리 절약의 측면에서 멤버 수와 바이트 길이에 따라 특정 위치까진 zip list를 활용하지만 그 위치 이상이 되는 순간 skip list를 사용한다.

우리가 흔히 알고있는 Sorted Set의 기능을 사용하게 되는 메인 데이터 구조는 바로 이 "skip list"이다. 앞서, 우리가 linked list에 대해 설명하면서 해당 자료 구조의 단점을 알아보았다. 이를 개선하는데서 시작한 skip list는 "레벨"이란 개념을 도입함으로써 데이터 탐색 시 모든 노드를 비교해야하는 최악의 상황을 해결해준다.

베이스 개념은 이정도 까지로 해두고, 그래서 왜 "ZRANGE"는 시간 복잡도 "O(log(N) + M)"을 가지는 것일까?

ZRANGE 명령어는 정렬된 순서대로 멤버를 가져오는 작업을 수행하는 명령어이다. 이 때, 가져올 범위의 시작과 끝을 지정할 수 있고, 이에 영향을 받는다.

ZRANGE 명령어의 시간복잡도는 두 가지 요소에 의해 영향을 받게된다:

  1. 스킵 리스트의 탐색: Sorted Set의 멤버는 스킵 리스트에 저장되어 있으며, ZRANGE는 가져올 범위의 시작과 끝 위치를 찾기 위해 스킵 리스트를 탐색해야 한다. 이 이유로 스킵 리스트의 탐색은 O(log(N))의 시간복잡도를 가집니다. (N: 원소 갯수)
  2. 가져온 멤버의 개수(M): ZRANGE 명령어는 지정한 범위 내의 멤버를 가져오는데, 가져온 멤버의 개수에 따라 시간복잡도가 증가한다. 가져온 멤버의 개수가 적을 경우(O(M)), 스킵 리스트의 탐색에 소요되는 시간이 주된 영향을 미친다. 그러나 가져온 멤버의 개수가 많을 경우(O(log(N) + M)), 멤버의 개수에 비례하여 스킵 리스트 탐색 및 결과 반환에 소요되는 시간이 증가하기 마련이다.

Sorted Set이 사용하는 이 "스킵 리스트"는 따지고 보면 List, Set, Hashes와 다르게 저장할 수 있는 정확한 멤버수가 사실상 없다. 최대 "레벨"은 32로 정해져있지만 스코어 비교 시 레벨을 그대로 두고 다음 포인터로 계속 진행할 수 있으므로 최대 레벨과는 관계없이 계속해서 저장이 가능하다.

하지만, 시간 복잡도 측면과 "메모리"측면에서의 문제는 존재한다.

이것에 대해 고민해야할 부분은 많은 것 같다. 아직 많은 멤버 수의 데이터 리스트를 다뤄보진 않았지만, 멤버 면수가 많아진다면 "삭제"의 과정에서의 부하도 충분히 고려해볼 필요가 있을거 같다는 생각이 들었다.


> 명령어 사용해보기

추후, 코드에서 사용해볼 명령어가 어떻게 동작하는지 간단히 알아보자.

127.0.0.1:6379> zadd myzip 80 "math"
(integer) 1
127.0.0.1:6379> zadd myzip 90 "english"
(integer) 1
127.0.0.1:6379> zadd myzip 75 "chemistry"
(integer) 1
127.0.0.1:6379> zadd myzip 85 "japanese"
(integer) 1

127.0.0.1:6379> zrevrange myzip 0 -1 withscores
 1) "english"
 2) "90"
 3) "japanese"
 4) "85"
 5) "math"
 6) "80"
 7) "chemistry"
 8) "75"
 9) "Daegyu"
10) "60"

127.0.0.1:6379> zincrby myzip 25 "chemistry"
"100"

127.0.0.1:6379> zrevrange myzip 0 -1 withscores
 1) "chemistry"
 2) "100"
 3) "english"
 4) "90"
 5) "japanese"
 6) "85"
 7) "math"
 8) "80"
 9) "Daegyu"
10) "60"

우린 랭킹 기능에 위의 명령어를 사용할 것이다.

조금 뜬금없지만 위와 같이 score에 따라 멤버가 정의되는 정렬된 key를 조회할 수 있다는 것을 확인할 수 있음과 동시에 "key:value"의 깔끔한 형식의 구조가 아닌 [멤버, 스코어, 멤버, 스코어, ....]의 형식인 것 또한 확인할 수 있다.

이를, 애플리케이션 코드 차원에서 적절히 가공해서(예를 들면 json 객체로..?) 클라이언트에 보내줘야한 생각도 미리 해놓으면 좋을 것이다.


💢 코드에 적용해보기 (Rank 구현)

위에서 알아본 정보를 바탕으로 nestjs에선 어떻게 Redis를 불러올 수 있고, 우리의 결제주문 로직에 랭킹 기능을 어떻게 적용시킬 것인가에 대해 고민해보자.

✔ 사전 설정 (Docker)

// docker.compose.yaml

  redis:
    image: redis
    ports:
      - 6379:6379
//.env

# Redis Options
REDIS_HOST=redis
REDIS_PORT=6379

> 어떤 모듈을 사용해야 할까?

일반적으로 nestjs 공식문서나 주요 글들을 보면 nestjs의 CacheModule을 import하고 store로 redisStore를 사용할 것을 제시한다. (물론 microservice는 다르다)

엄밀히 말하면 위의 형식은 redis 3.x.x 버전대에서 가능한 케이스다. 현 시점에서 레디스를 설치한다면 4.x.x 버전일 것이고, 위와 같은 형식은 맞지 않다. 물론 해결방법이 없는 것은 아니다. 처음엔 버전을 3.x.x 대로 낮춰서 사용하게 되었고 그 경우 아래와 같이 작성해줄 수 있었다.

현재, 레디스는 공용으로 사용하기 때문에 공통 모듈 (SharedModule)에 import 해주었다.

// shared.module.ts

import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';

@Module({
  imports: [
    CacheModule.registerAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        store: redisStore,
        host: configService.get<string>('REDIS_HOST'),
        port: configService.get<number>('REDIS_PORT'),
      }),
      inject: [ConfigService]
    }),
  ],
  providers: [],
  exports: [CacheModule],
})
export class SharedModule {}

하지만 버전 충돌 뿐 아니라, type 설정등의 이유로 위와 같이 cache-manager 패키지를 사용하는 것엔 제약이 많았다. (실제로 후에 필요한 sorted set 명령어를 사용하는 과정에서도 제약이 존재하였다)

그럼에도 이러한 방식으로 사용하고자 할 경우, 아래와 같이 CACHE_MANAGER를 주입받아 사용하면 된다. 그 후 설정한 속성을 통해 클라이언트 등을 불러올 수 있다.

import { CACHE_MANAGER } from "@nestjs/cache-manager";
import { Cache } from "cache-manager";

// ~~
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache
  ) {}

✔ 그럼 무엇을 사용할 것인가?

범용성이 좋고 현재 npm에서도 다운로드 건수가 많은 @liaoliaots/nestjs-redis와 이를 사용하기 위해 필요한 ioredis를 설치하고 사용하기로 하였다.

Prerequisites
This lib requires Node.js >=12.22.0, NestJS ^9.0.0, ioredis ^5.0.0.

npm install @liaoliaots/nestjs-redis ioredis

클라이언트를 사용하는 방법엔 두 가지가 있다. 데코레이터를 통해 생성자 주입을 받는 케이스, 모듈에서 제공하는 RedisService를 사용하는 케이스이다. 본인의 경우엔 데코레이터를 통해 Redis Client를 생성하기로 하였다.

Usages (github/lioliaots/nestjs-redis)

import { Injectable } from '@nestjs/common';
import { InjectRedis, DEFAULT_REDIS_NAMESPACE } from '@liaoliaots/nestjs-redis';
import Redis from 'ioredis';

@Injectable()
export class AppService {
  constructor(
    @InjectRedis() private readonly redis: Redis // or // @InjectRedis(DEFAULT_REDIS_NAMESPACE) private readonly redis: Redis
  ) {}

  async set() {
    return await this.redis.set('key', 'value', 'EX', 10);
  }
}

✔ Module 생성

// redis-ranking.module.ts

import { Module } from '@nestjs/common';
import { RedisRankingService } from './redis-ranking.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { RedisModule } from '@liaoliaots/nestjs-redis';

@Module({
  imports: [
    RedisModule.forRootAsync({
      imports: [ConfigModule],
      useFactory: async (configService: ConfigService) => ({
        config: {
          host: configService.get<string>('REDIS_HOST'),
          port: configService.get<number>('REDIS_PORT'),
        }
      }),
      inject: [ConfigService]
    }),
  ],
  providers: [RedisRankingService],
  exports: [RedisRankingService, RedisModule],
})
export class RedisRankingModule {}

RedisRankingService는 아래에서 작성될 것입니다.


> 랭킹 기능을 구현해보자 (+ 응답 가공)


📢 주의: 시리즈 (이전 포스팅들)를 보지 않고 아래의 진행 과정을 읽으실 경우, 어려움이 있으실 것입니다. 꼭, 이전 포스팅들을 읽고 와주시면 감사하겠습니다.

✔ Service

// redis-ranking.service.ts

import { Injectable } from "@nestjs/common";
import { InjectRedis } from "@liaoliaots/nestjs-redis";
import { Redis } from "ioredis";

@Injectable()
export class RedisRankingService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}
  
  // client를 생성한다.
  getClient() {
    const client = this.redis;
    return client;
  }
}

✔ ZSET(Sorted Set)에 데이터 추가

멤버-스코어 쌍의 데이터를 ZSET에 추가해보도록 하자. 앞서 알아보았듯이 이는 zadd를 활용할 수 있다.

이 과정은 http 요청과 별개로 독립적 실행을 하기 위해 standalone application을 사용하였다. 하지만, 이 방법을 사용할 때는 고려해야할 사항이 많으므로 (데이터 일관성 등등...) 실 서비스에 적용시키고자 할 땐 신중해야할 것이다. 일단은 이 과정에선 독립적 애플리케이션으로써 zadd 연산을 수행하기로 한다.

// ranking.ts

import { NestFactory } from "@nestjs/core"
import { AppModule } from "../app.module"
import { RedisRankingService } from "../ranking/redis-ranking.service";
import { UserService } from "../user/user.service";
import { User } from "../user/model/user.entity";

(async () => {
  const app = await NestFactory.createApplicationContext(AppModule);

  const userService = app.get(UserService);
  
  // ambassador 유저만 찾는다.
  const ambassador: User[] = await userService.find({
    where: {
      is_ambassador: true,
    },
    relations: ['orders', 'orders.order_items']
  });
  
  // zadd를 사용하기 위해선 redis client를 불러와야 할 것이다.
  const redisService = app.get(RedisRankingService);
  const client = redisService.getClient();

  for (let i = 0; i < ambassador.length; i++) {
    await client.zadd('rankings', ambassador[i].revenue, ambassador[i].name);
  }

  process.exit();
})();

랭킹 기능을 구현하는데 있어, 모든 유저의 랭크를 표시할 필요는 없다. 결국, 우리는 판매 대리인의 "수익(revenue)" 현황을 알아볼 것이고 즉, 유저 테이블에서 is_ambassadortrue인 유저에 한해서만 적용시키면 된다.


✔ ZRANGE를 사용한 정렬

다음으로는 ZRANGE를 사용하여 정렬을 시켜준다.

우린 스코어에 따라 내림차순 정렬을 확인하고 싶으므로, ZREVRANGEBYSCORE를 사용할 것이다.

물론, 레디스 6.2.0 버전 이후로 이는 deprecated 되었고 ZRANGE 명령어에 REV와 BYSCORE 옵션을 사용하여 할 것을 권고한다. 이에 대해서도 추후 대처할 필요는 있다고 본다. 하지만 아직은 애플리케이션 레벨에선 충분히 사용해도 무방하다.

As of Redis version 6.2.0, this command is regarded as deprecated.
It can be replaced by ZRANGE with the REV and BYSCORE arguments when migrating or writing new code.

기존 RedisRankingService에서 getRanks()메서드 아래에 이를 구현해준다.

// redis-ranking.service.ts

import { Injectable } from "@nestjs/common";
import { InjectRedis } from "@liaoliaots/nestjs-redis";
import { Response } from "express";
import { Redis } from "ioredis";

@Injectable()
export class RedisRankingService {
  constructor(
    @InjectRedis() private readonly redis: Redis,
  ) {}

  getClient() {
    const client = this.redis;
    return client;
  }

  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore('rankings', '+inf', '-inf', 'WITHSCORES', (err: Error | null, result: string[]) => {
		res.send(result);
  	}
    return ranks;
  }                                              
}

zrevrangebyscore는 아래와 같은 형식의 인자를 가진다. 딱히 범위를 표시하진 않을 것이므로 maxmin의 범위는 무한으로 설정해두었다.

그 후, 콜백 내부에 express의 Response 객체를 이용해 결과값에 해당하는 배열을 던져주기로 한다. 컨트롤러의 라우터에서 이를 받게 된다.


zrevrangebyscore(key: RedisKey, max: number | string, min: number | string, withscores: "WITHSCORES", callback?: Callback<string[]>): Result<string[], Context>;


✔ User layer

유저(정확히 말하면 ambassador)에 대한 랭킹 조회이므로 해당 처리는 유저 레이어에서 맡기로 하였다. 즉, 위에서 정의한 getRankings()를 유저 서비스단에서 받아와준다.

 // user.service.ts

  async rankings(res: Response) {
    return await this.redisRankingService.getRankings(res);
  }
// user.controller.ts

  @UseGuards(JwtAccessAuthGuard)
  @Get('ambassador/rankings')
  async rankings(@Res() res: Response) {
    return await this.userService.rankings(res);
  }

이 상태에서 만약 위의 핸들러 함수에서 정의한 url로 요청을 날려보면 어떠한 응답을 받게 될까?

이러한 형식의 응답을 받게 될 것이다. 이는 우리가 앞서 redis-cli를 통한 테스트에서도 확인할 수 있었듯이 "멤버, 스코어, 멤버, 스코어, ..." 형태의 배열을 받게 된다.

하지만 우린 이러한 데이터를 그대로 클라이언트에게 넘겨줄 순 없다.

그러므로, 이를 일련의 방법으로 "가공" 해야한다.


✔ 응답 객체 가공하기

다시, RedisRankingServicegetRankings() 함수로 돌아가보자.

  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore('rankings', '+inf', '-inf', 'WITHSCORES', (err: Error | null, result: string[]) => {
		res.send(result);
  	}
    return ranks;
  }     

위 코드로 보면, 결과적으로 응답으로 보내질 데이터는 string[] 타입의 result이다. 이 result를 가공해야할 것이다.

어떻게 접근해야할까?

앞서 말했듯이, 위의 응답 결과는 ["멤버", "스코어", "멤버", "스코어", ... , "멤버", "스코어"]의 배열 형태이다.

즉, 우리가 필요한 작업은 전체 값은 오브젝트({})안에 넣어줌과 동시에 멤버와 스코어를 교차해가며, 멤버를 오브젝트의 "key"로(여기서 key는 sorted set의 key와는 다르다) 스코어를 오브젝트의 "value"로 나타내야한다.

{ "멤버": "스코어", "멤버": "스코어" ... }

그럼 이를 위한 코드를 바로 확인해보자.

  async getRankings(res: Response) {
    const client = this.getClient();
    const ranks = await client.zrevrangebyscore('rankings', '+inf', '-inf', 'WITHSCORES', (err: Error | null, result: string[]) => {
      // member 변수 설정
      let member: string;

      res.send(result.reduce((object: {}, r: string) => {
        // 배열 result의 current string값 `r`이 parseInt시 숫자인지 아닌지 판별 
        
        // `r`이 숫자가 아닐경우: `r`은 member로 위치한다.
        if (isNaN(parseInt(r))) {
          member = r;
          return object;
        // `r`이 숫자일 경우: `r`은 score로 위치한다.
        } else {
          return {
            ...object,
            [member]: r,
          };
        } 
      }, {}));
    });

    return ranks;
  }

이렇게 우린 클라이언트에게 json형태의 오브젝트로 응답하기 위한 가공작업까지 마쳤다.


> 변화하는 스코어에 대한 변경 반영 (Feat. EventEmitter)

다음 단계는 무엇인가?

그렇다, 변동하는 스코어 값이 랭킹 응답에 반영이 되게끔 해야할 것이다.

이는 아래와 같이 주문 확인 단계에서 구현해 줄 수 있다.

// order.service.ts

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { AbstractService } from '../shared/abstract.service';
// ....
import { EventEmitter2 } from '@nestjs/event-emitter';

@Injectable()
export class OrderService extends AbstractService {
  constructor(
    // ....
    private eventEmitter: EventEmitter2,
  ) {
    super(orderRepository);
  }
  
  // ...

  async orderConfirm(sourceId: string) {
    const order: Order = await this.findOne({
      where: {
        transaction_id: sourceId,
      },
      relations: ['order_items', 'user']
    });

    if (!order) {
      throw new NotFoundException('Order not found');
    }

    await this.update(order.id, {
      complete: true,
    });
	
    // 바로 이 부분이다. 우린 EventEmitter를 통해 이를 받아온다.
    await this.eventEmitter.emit('order.completed', order);

    return {
      message: 'success',
    }
  }
}

orderConfirm로직에 직접 스코어의 변경사항에 대한 부분을 반영해도 무방하고, 다른 서비스 레이어에서 이를 다뤄줘도 무방하지만 우린 "EventEmitter"를 이용하기로 한다. 이는 nestjs뿐만 아니라 여러 nodejs 기반 프레임워크에서 사용할 수 있고, 이벤트 처리에 있어 디커플링을 적용하기 위한 굉장히 좋은 방법이다.

우린 "Listener"로써 클래스를 명명할 수 있다.

// order.listener.ts

import { Injectable } from "@nestjs/common";
import { Order } from "../model/order.entity";
import { OnEvent } from "@nestjs/event-emitter";
import { InjectRedis } from "@liaoliaots/nestjs-redis";
import { Redis } from "@nestjs-modules/ioredis";

@Injectable()
export class OrderListener {
  constructor(
    @InjectRedis() private readonly redis: Redis
  ) {}
  
  // `@OnEvent()`에 입력해준 이벤트 명을 통해 다른 레이어와 소통할 수 있다.
  @OnEvent('order.completed')
  async handleOrderCompletedEvent(order: Order) {
    const client = this.redis;
    client.zincrby('rankings', order.ambassador_revenue, order.user.name);
  }
}

앞서 redis-cli를 통해서도 알 수 있었겠지만 zincrby 인자의 값은 (key, increment 값, memeber명) 이다. 아마 어렵지 않게 이해 할 수 있을 것이다.


참고로, 우리의 경우는 단지 "주문"건에 대해서만 유저의 랭킹을 조회할 것이지만 여러 다른 계층에서 동일한 작업을 진행하고자 할 경우 위와 같이 handle 함수에 직접 엔터티를 받고, zincrby의 인자로 특정 increment값과 member를 하드코딩하기보단 런타임에 동작할 수 있게 끔 맡기는 것이 바람직할 것이다.



> Postman을 통한 테스트

처음 상태는 아니지만 결제 주문을 어느 정도 진행한 전체 판매 대리인에 대한 랭킹을 알아보면 아래와 같다.

현재 firstName: "e", lastName: "e"에 해당하는 ambassador의 스코어 값은 "0"인 것을 확인할 수 있다.

하지만, 우린 지난 포스팅에서 확인할 수 있듯이 특정 유저에 대해 판매자 "e"의 대행물품을 결제하는 행위를 진행하였었다. (꼭 지난 포스팅을 보고 오셔야합니다...) 그 결과로 22 달러의 수익을 얻은 것 또한 알수 있었다.

    {
        "code": "hl4oh8",
        "count": 1,
        "revenue": 22
    }

이미, 결제확인까지 마친 상태이므로 이는 우리가 설정한 "랭크"에 반영이 되어야 할 것이다.

요청을 통해 확인해보자.

기존에 "0"이었던 스코어 값이 "22"가 된 것을 확인할 수 있다.

그럼 한번 더 테스트를 해보자.

위와 같이 특정 유저는 판매대리인 "e e"의 링크 코드(code)를 통해(이 역시 지난 포스팅을 보고 오셔야 알 수 있습니다) 결제를 진행한다.

결제를 생성하였고, 위와 같이 받게 된 트랜잭션 id를 통해 결제확인 단계를 진행한다.

결제 확인이 완료되었다는 메시지를 받게 되었고, 우린 랭킹에서 이번 결제 구매 역시 반영이 된 것을 확인해 볼 필요가 있다.

이번엔 22달러에서 54달러로 총 32달러의 score값이 증가하였다.

이 증가 값은 우리가 생성한 order_items 테이블을 통해 확인할 수 있다.

+----+----------------+-------+----------+---------------+--------------------+----------+
| id | product_title  | price | quantity | admin_revenue | ambassador_revenue | order_id |
+----+----------------+-------+----------+---------------+--------------------+----------+
| 87 | minus deserunt |    76 |        3 |           205 |                 22 |       84 |
| 88 | eaque ad       |    26 |        4 |            93 |                 10 |       84 |
+----+----------------+-------+----------+---------------+--------------------+----------+

구매한 상품 품목 두 가지에 따라 각각 22달러와 10달러의 ambassador_revenue가 생긴 것을 확인할 수 있다. 이 값이 랭킹에 반영이 된 것이다.


생각정리

정말 길고 긴 포스팅이었다. 사실 쭉 이어지는 흐름의 프로젝트 성 포스팅을 쓰는 건 처음인 것 같다.

아무래도 "결제 주문과 레디스를 통한 랭킹 기능 구현"을 일련의 프로세스 과정을 통해 설명하려는게 목적이었다보니 "정말 좋은 코드"를 작성하고 설명하는데 있어서 많이 부족한 점이 아쉽긴 하다. 본인 스스로도 본인과 같은 nestjs 및 백엔드 입문자의 입장에서 어떻게 글을 작성해야 쉽게 와닿을까를 고민하였고, 항상 그것을 염두해 두고 글을 썼던거 같다.

아무래도 "결제 주문", "레디스를 통한 캐싱 처리 및 랭크 기능" 등과 같은 로직은 입문자입장에선 쉽게 와닿지가 않는다. 이런 생각에 조금 길긴 하겠지만 아주 간단하지만 전체적인 흐름을 전달하는 글이 있으면 좋지 않을까 생각해보았다.

정신없이 쓴 글이다 보니 부족한 점이 많을것이 분명하고 잘못 이해한 부분도 있을 것이다. 추후 많은 분들의 피드백과 스스로의 개선을 통해 추가적 내용을 수정 및 업로드 할 예정이다.


긴 글 읽어주셔서 감사합니다!!!


profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글