Skip to content

[개별 멘토링] 2024.11.24

박진명 edited this page Nov 28, 2024 · 3 revisions
  • 랭킹을 위한 Redis 사용

    Redis 도입 배경

    MySQL의 한계

    • 랭킹 조회 혹은 최근 검색 기록 조회 시 매번 ORDER BY 연산이 필요하며 이로 인한 자원 낭비가 발생한다.
    • 동시 접속자 증가에 따른 DB 부하가 증가한다.
    • 최근 검색어의 경우 빈번한 I/O 가 필요하다.

    Redis 도입

    • Sorted Set 을 이용해 효율적으로 랭킹 관리
    • 인메모리 데이터 처리로 빠른 응답 속도
    • 사용자 증가에 따른 빈번한 DB 이용에 따른 부하를 줄일 수 있음

    기존에는 랭킹을 검색할 때마다 Asset table을 Order By를 이용해 Sort 하는 상황이 반복되었고 이로 인해 생기는 부담을 줄이고자 Rankings 테이블을 구성하였다. 하지만 Asset 테이블과 겹치는 부분이 많고 랭킹 조회시 user_id 를 이용해 user Table과 Join하여 유저의 닉네임을 가져와야 하기 때문에 이로 인한 부하도 생길 것으로 예측 하였다.

    이에 따라 Redis를 이용해 Ranking 데이터를 저장하고 제공하는 로직을 구성해보았다.

    작성한 코드

    • redis.domain-service.ts

      import { Injectable, Inject } from '@nestjs/common';
      import Redis from 'ioredis';
      
      @Injectable()
      export class RedisDomainService {
        constructor(
          @Inject('REDIS_CLIENT')
          private readonly redis: Redis,
        ) {}
      
        async get(key: string): Promise<string | null> {
          return this.redis.get(key);
        }
      
        async set(key: string, value: string, expires?: number): Promise<'OK'> {
          if (expires) {
            return this.redis.set(key, value, 'EX', expires);
          }
          return this.redis.set(key, value);
        }
      
        async del(key: string): Promise<number> {
          return this.redis.del(key);
        }
      
        async zadd(key: string, score: number, member: string): Promise<number> {
          return this.redis.zadd(key, score, member);
        }
      
        async zcard(key: string): Promise<number> {
          return this.redis.zcard(key);
        }
      
        async zrange(key: string, start: number, stop: number): Promise<string[]> {
          return this.redis.zrange(key, start, stop);
        }
      
        async zremrangebyrank(
          key: string,
          start: number,
          stop: number,
        ): Promise<number> {
          return this.redis.zremrangebyrank(key, start, stop);
        }
      
        async zrevrange(key: string, start: number, stop: number): Promise<string[]> {
          return this.redis.zrevrange(key, start, stop);
        }
      
        async zrem(key: string, member: string): Promise<number> {
          return this.redis.zrem(key, member);
        }
      
        async expire(key: string, seconds: number): Promise<number> {
          return this.redis.expire(key, seconds);
        }
      }
    • ranking.service.ts

       async getRanking(sortBy: SortType = SortType.PROFIT_RATE) {
          const date = new Date().toISOString().slice(0, 10);
          const key = `ranking:${date}:${sortBy}`;
      
          if (await this.redisDomainService.exists(key)) {
            return {
              topRank: await this.redisDomainService.zrevrange(key, 0, 9),
              userRank: null,
            };
          }
      
          const ranking = await this.calculateRanking(sortBy);
      
          await Promise.all(
            ranking.map((rank: Ranking) =>
              this.redisDomainService.zadd(
                key,
                this.getSortScore(rank, sortBy),
                rank.nickname,
              ),
            ),
          );
      
          return {
            topRank: await this.redisDomainService.zrevrange(key, 0, 9),
            userRank: null,
          };
        }

    redis를 여러 service에서 참조해야하기 때문에 service layer 사이의 참조는 좋지 않다고 생각하여 domain-service 레이어를 하나 만들어 다른 서비스가 참조 할 수 있도록 하였다.

  • Nginx 를 이용한 프록시 서버 구축 및 SSL 설정

    Nginx Config

    작성한 config 파일

    events {
        worker_connections 1024;
    }
    
    http {
        include       /etc/nginx/mime.types;
        default_type  application/octet-stream;
    
        # WebSocket 연결을 위한 map 설정
        map $http_upgrade $connection_upgrade {
            default upgrade;
            '' close;
        }
    		# HTTP 서버 블록
        server {
            listen 80;
            server_name juga.kro.kr;
    
            location /.well-known/acme-challenge/ {
                root /var/www/certbot;
            }
    
            # HTTP를 HTTPS로 리다이렉트
            location / {
                return 301 https://$host$request_uri;
            }
        }
    		# HTTPS 서버 블록
        server {
            listen 443 ssl; # SSL 인증서 사용하여 443 포트에서 리스닝
            server_name juga.kro.kr;
    
            ssl_certificate /etc/letsencrypt/live/juga.kro.kr/fullchain.pem;
            ssl_certificate_key /etc/letsencrypt/live/juga.kro.kr/privkey.pem;
    
            # Socket.IO 설정
            location /socket {
                proxy_pass http://juga-docker-be:3000;
                proxy_http_version 1.1;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
                
                proxy_set_header Upgrade $http_upgrade;
                proxy_set_header Connection $connection_upgrade;
                
                proxy_buffers 8 32k;
                proxy_buffer_size 64k;
                proxy_connect_timeout 75s;
                proxy_send_timeout 75s;
                proxy_read_timeout 75s;
            }
    
            # Frontend proxy
            location / {
    						
                proxy_pass http://juga-docker-fe:80;
                # 프록시를 위한 HTTP 프로토콜 버전을 정의
                proxy_http_version 1.1;
                proxy_set_header Host $host;
                # 방문자의 실제 IP 주소 전달
                proxy_set_header X-Real-IP $remote_addr;
                # 프록시 정보 전달
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                # 프로토콜 정보 전달
                proxy_set_header X-Forwarded-Proto $scheme;
                #Web socket 업그레이드 
                proxy_set_header Upgrade $http_upgrade;
                #Web socket 연결 유지 
                proxy_set_header Connection $connection_upgrade;
    
                # try_files 대신 error_page 사용
                proxy_intercept_errors on;
                error_page 404 = /index.html;
            }
    
            # Backend proxy
            location /api {
                proxy_pass http://juga-docker-be:3000;
                proxy_http_version 1.1;
                proxy_set_header Host $host;
                proxy_set_header X-Real-IP $remote_addr;
                proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
                proxy_set_header X-Forwarded-Proto $scheme;
            }
        }
    }

피어세션 기록

동료와 공유하거나 의견을 묻고 싶은 부분을 정리해보세요.

피어세션에서 나눈 이야기 중 나의 문제 해결 경험과 관련한 피드백과 새롭게 깨달은 점이 있다면 기록으로 남겨주세요.

개별 멘토링 기록

  • 테스트 코드 작성에 대한 질문

    • 리팩토링 주간을 맞아 본격적으로 리팩토링을 진행하기 위해서 테스트 코드를 작성하는 것도 좋은 선택이라고 생각합니다. 이에 기능 마무리가 되면 조금씩 테스트 코드를 작성해보는 것도 좋을 것 같아 테스트 코드에 대한 개념을 잘 잡아가고 싶습니다.

    • Unit 테스트와 통합 테스트의 차이점은 개별 단위의 동작 검증과 여러 단위별의 상호 작용이 잘 되는지 확인하는데 있다고 알고 있습니다. 그렇기 때문에 저번 테스트 코드 작성에서 Controller, service, model 별로 각각의 영역에서 잘 작동하는지 다른 레이어는 목킹을 하거나 해당 레이어가 호출되어야 한다는 코드를 넣는 등 의 작업을 통해 테스트 해보려고 했는데 통합 테스트 같다는 피드백을 받은 적이 있어 어떤 식으로 수정해야할지 여쭤보고 싶습니다.

      지적 받은 코드

      // controller 테스트 코드 
       describe('handleRegister 메소드', () => {
          it('유효한 사용자 데이터로 회원가입을 처리해야 한다', async () => {
            const mockRequest: HTTPRequest = {
              body: JSON.stringify({
                userId: '[email protected]',
                email: '[email protected]',
                password: 'password123',
                username: 'testuser',
              }),
              method: 'POST',
              url: '/api/create',
              headers: {
                host: 'localhost:3000',
                'user-agent':
                  'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:131.0) Gecko/20100101 Firefox/131.0',
                accept: '*/*',
                'accept-language': 'ko-KR,ko;q=0.8,en-US;q=0.5,en;q=0.3',
                'accept-encoding': 'gzip, deflate, br, zstd',
                referer: 'http://localhost:3000/user/form.html',
                'content-type': 'application/json',
                'content-length': '111',
                origin: 'http://localhost:3000',
                connection: 'keep-alive',
                'sec-fetch-dest': 'empty',
                'sec-fetch-mode': 'cors',
                'sec-fetch-site': 'same-origin',
                priority: 'u=0',
              },
            };
      
            const mockSocket = new net.Socket();
            const response = new HTTPResponse(mockSocket);
      
            await UserController.handleRegister(mockRequest, response);
      
            expect(UserService.registerUser).toHaveBeenCalledWith(
              '[email protected]',
              'password123',
              'testuser'
            );
          });
        });
      
      // service 테스트 코드
      describe('UserService 테스트 코드', () => {
        let mockRedis: jest.Mocked<Redis>;
      
        beforeEach(() => {
          mockRedis = new Redis() as jest.Mocked<Redis>;
          jest.clearAllMocks();
      
          (UserModel.findOne as jest.Mock).mockResolvedValue(null);
          (UserModel.create as jest.Mock).mockResolvedValue({ id: 1 });
          (bcrypt.hash as jest.Mock).mockResolvedValue('hashedPassword');
        });
      
        describe('registerUser 메소드 테스트', () => {
          it('새로운 유저가 회원가입에 성공해야 한다.', async () => {
            (UserModel.create as jest.Mock).mockResolvedValue({ id: 1 });
      
            await UserService.registerUser('[email protected]', 'password', 'testuser');
      
            expect(UserModel.findOne).toHaveBeenCalledWith({
              where: { email: '[email protected]' },
            });
      
            expect(bcrypt.hash).toHaveBeenCalledWith('password', 12);
            expect(UserModel.create).toHaveBeenCalledWith({
              email: '[email protected]',
              password: 'hashedPassword',
              username: 'testuser',
            });
          });
        });
      });
      
      // 모델 테스트 코드 
      
      test('findOne 테스트', async () => {
          const result = await UserModel.findOne({
            where: { email: testUserInfo.email },
          });
          expect(result).not.toBeNull();
          expect(result).toEqual(testUser);
        });
    • Controller → service → domainService → repository

      • 이중에 service (랭킹, 등등) 를 테스트 하는 방법으로 하는게 좋을 것 같다.
      • controller는 그냥 service를 갖다 쓰기만 하면 될거 같고 repository는 데이터를 받아서 그냥 저장만 하면 되는거라 테스트 할 필요가 없을 것 같다.
  • 5주차 일정에 대한 조언

    1주차에 계획했던 필수 기능에 대해 거의 모든 구현을 끝냈고 , 이제 제가 생각하는 들어 가야 할 기능은 친구 기능과 대결 기능일 것이라고 생각합니다. 기획서에도 친구와 같이 성장 하는 재미를 강조하는 만큼 해당 기능이 들어가면 좋을 것 필요할 것 같긴 하지만 예상외로 품이 많이 들 것 같아 망설여집니다.

    5주차 발표 내용을 보면 1~5주차의 과정과 6주차 계획을 발표해야하는데 5주차엔 이전 코드를 보면서 수정 및 보완하는 작업을 가지는게 좋을까요? 아니면 해당 기능을 구현하는게 맞을지 의견을 여쭤보고 싶습니다.

    • 대결기능까진 투머치고 하게 되면 친구 기능, 프로필 조회
    • 실시간 뉴스 , 주식 즐겨찾기 같은 경우는 사소한 기능이기 때문에 기존 주식을 위한 기능의 완성도를 높이고 싶으면 구현해야할 거 같고, 친구 추가는 다른 결의 기능이다. 서비스의 특성을 주고 싶으면 아래, 주식에 초점을 두고 싶으면 위 기능을 하면 좋을 것 같다.

참고 자료

📜 개발 일지

⚠️ 트러블 슈팅

❗ 규칙

🗒️ 기록

기획
회의록
데일리스크럼
그룹 멘토링
그룹 회고

😲 개별 멘토링

고동우
김진
서산
이시은
박진명
Clone this wiki locally