[NestJS] Permission-Based Guard with Caching (advanced-part #2)

DatQueue·2023년 4월 2일
0
post-thumbnail

시작하기에 앞서

이전 포스팅 "Permissioin-Based Guard"를 바탕으로 해당 작업에 캐싱을 적용해보도록 한다. 왜 이번 포스팅의 과정을 수행하는지는 이전 포스팅 마지막에 언급하였다. 그럼 긴 글을 시작해보도록 하겠다.


이전 포스팅 클릭 ✔


💥 Caching을 통한 성능 개선


> 간단히 알아보는 Database Caching과 전략

최근, 정확한 "계기"가 있는 것은 아니고, 어쩌다보니 최근 "Cache(캐시)""Memory"에 대해 찾아보던 중이었다.

다들 알겠지만, 캐시는 "데이터나 값을 미리 저장해놓은 임시 장소"를 가리킨다. 캐싱을 사용하여 임시 메모리 내 "Key-value" 데이터를 기존 데이터 스토리지에 저장된 데이터보다 더 빠르고 간단하게 저장하고 액세스한다.

캐싱에는 Web Caching, CDN Caching ... 다양한 캐싱 기술들이 있겠지만 우리가 이번에 알아보고 적용시켜 볼 부분은 "Database Caching(데이터베이스 캐싱)" 이다.

✔ 왜 데이터베이스 캐시를 사용하기로 생각하였나?

먼저 "DB Cache"가 무엇인지 알 필요가 있다. 데이터베이스 캐시(조금 더 편하게 생각하면, 흔히 설명되는 In-Memory-Cache의 범주에서 생각하면 좋다)는 데이터베이스의 쿼리 결과나 쿼리 실행 계획 등을 캐시에 저장함으로써 빠른 응답 속도를 제공한다.

해당 포스팅에서 "캐싱"에 대해 깊게 들어가진 않겠지만, 간단히 설명하자면 메모리의 종류에 따라 "Memory Hierarchy(메모리 계층구조)" 라는 것이 존재한다. 일반적으로 보조 기억장치에 해당하는 Storage(SSD, HDD) 부분에 메모리를 저장하는 경우가 많다. 해당 계층은 비휘발성이며 공간이 매우 크다는 장점도 있지만 동시에 많은 데이터를 저장 시 속도가 느리게 되는 단점이 존재한다. 그로 인해 우리는 그 위의 계층인 "Main Memory(주 메모리)"를 사용하게 된다.


※ Memory - 4 Hierarchy


흔히, 우리가 외부 저장소를 사용하게 되는 대표적예인 "Redis"와 같은 경우 "In-memory-cache"라고 하는 것도 위의 이유이다.

물론 우리가 이번에 사용할 캐싱은 레디스를 활용한 캐싱은 아니다. 일단, 본인은 레디스에 관해 일가견이 전혀없고, 꼭 레디스만이 캐싱의 해답은 아니기 때문이라 생각이 들었다.

돌고 돌아 "그래서 왜? 권한부여에 캐싱을 사용하는가?" 를 말하자면 만약 특정 카페에 상당히 많은 유저가 존재한다고 생각해보자.(물론 카페같은 규모에 굳이 캐싱이 필요하진 않을 수 있지만.. 가정해보자.)

카페의 신규회원이 글을 등재하고 싶을 시 어떠한 "인증 과정"을 거쳐야한다고 하자. 그러한 신규회원의 규모가 상당 할 경우, 동시에 인증 과정을 거친 회원들이 "글 등재" 에 관한 권한부여를 받게 된다면, 그 모든 권한 부여에 대한 쿼리 요청이 디비에 집중될 것이다.

이러한, 반복되는 쿼리의 요청이 디비에 집중된다면 (물론 규모가 크다는 가정이다) 부하가 생기고 결국 느린 쿼리 요청 및 응답으로 향하는 지름길일 것이다. 이로 인해, "데이터 베이스 캐싱"을 사용하면 권한 부여를 위한 쿼리 요청에 더 빠르게 액세스할 수 있지 않을까 싶어 해당 과정을 수행해보기로 하였다.

레디스를 사용하지 않는 대신 우린 nest 공식문서에 따라 기본적으로 제공되는 빌트인된 storage를 캐시 저장소로 사용할 것이다.

정확히 말해선, 우리의 DB 캐시가 저장되는 위치는 "Cache Memory" 이다. 캐시 메모리도 물론 인-메모리 저장이라고 할 수 있지만 레디스같이 대용량을 다루기 위해 온전히 주 메모리 저장을 사용하는 것에 비해 캐시 메모리는 대게 메인 메모리와 CPU 사이에 위치한다. (located between Main Memory(DRAM) and CPU Cache)

그럼 지금부터 캐시 메모리 스토리지를 활용한 데이터 베이스 캐싱을 구현해 권한 부여의 성능을 최적화 시켜보도록 하자.


> Guard에서 캐싱 처리하기 + 코드 개선

우리가 만든 PermissionGuard에서 캐싱 작업을 간단히 처리해줄 수 있다. nest에서 제공하는 cache-manager를 활용해 캐시 메모리를 사용할 것이기 때문에 해당 패키지만 불러오면 된다.

설치할 패키지나 추가 세밀한 자료들은 공식 문서에서 확인할 수 있다. ⬇⬇


Caching _nest docs ✔


PermissionGuard

import { CACHE_MANAGER, CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Cache } from "cache-manager";
import { AuthService } from "../../auth/auth.service";
import { Role } from "../../role/model/role.entity";
import { RoleService } from "../../role/role.service";
import { User } from "../../user/model/user.entity";
import { UserService } from "../../user/user.service";
import { Permission } from "../model/permission.entity";
import { PermissionName } from "../model/permission.enum";

@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authService: AuthService,
    private userService: UserService,
    private roleService: RoleService ,
    @Inject(CACHE_MANAGER) private cacheManager: Cache, // CACHE_MANAGER 생성자 주입 
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    
    const access = this.reflector.get<string>('access', context.getHandler());
    if (!access) {
      return true;
    }
    
    const request = context.switchToHttp().getRequest();
    const id = await this.authService.userId(request);

    // cache-key set
    const cacheKey = `permissions: ${id}`;
    // retrieve cache -get
    const cacheValue = await this.cacheManager.get<string>(cacheKey);
    // console에서 결과 확인
    console.log(`cacheValue: ${cacheValue}`);
	
    // CACAHE_MANAGER에서 유저 권한 정보가 이미 캐싱되어 있을때를 확인하는 조건문이다.
    // 만약 cacheValue가 존재한다면 권한 정보를 캐시에서 가져온다. 
    // permissions 데이테 어레이(즉, 권한정보)를 db에서 요청하지 않고, 
    // JSON 직렬화된 cacheValue를 다시 JS 객체로 파싱하여 불러온다. --> 캐싱의 역할
    if (cacheValue) {
      const permissions: Permission[] = JSON.parse(cacheValue);
      return this.checkPermission(request, access, permissions);
    }
    
    // 아래의 과정은 권한 정보가 캐싱되어 있지 않는 경우이다. 이럴 경우는 DB에서 불러온다. 
    // 그 후 권한을 계산하고, 캐시에 저장 (set)후 반환한다.
    const user: User = await this.userService.findUserById(id, ['role']);

    const role: Role = await this.roleService.findOne(user.role.id, ['rolePermissions', 'rolePermissions.permission']);
    const rolePermissions = role.rolePermissions;
    const permissions: Permission[] = rolePermissions.map(rp => rp.permission);

    //Cache에 값을 추가하고 유효 시간을 설정 -set
    await this.cacheManager.set(cacheKey, JSON.stringify(permissions), 50000);

    return this.checkPermission(request, access, permissions);
  }

  private checkPermission(request: any, access: string, permissions: any[]): boolean {
    let isAccess: boolean;

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === PermissionName[`VIEW_${access.toUpperCase()}`])
        || (p.name === PermissionName[`EDIT_${access.toUpperCase()}`])
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === PermissionName[`EDIT_${access.toUpperCase()}`]);
    return isAccess;
  }
}

위와 같이 cacheManager를 불러온 뒤, get을 통해 cache를 검색할수 있고, set을 이용해 항목을 (key값을) 추가할 수 있다. 물론 set() 의 내부인자로 유효기간 (TTL(Time To Live))또한 설정할 수 있다. 모듈 단에서 (AppModule) 전역적으로 설정해줄 수도 있지만 편의상 위와 같이 수행한다.

일단 위의 TTL은 그냥 예시로 작성한 값이므로, 뒤에서 언급하겠다. (참고로 단위는 ms이다.)

일반적으로 캐싱의 작업은 "인터셉터로" 써 작성하는 것이 nest에서 제시하는 방법이다. 하지만 가드내에서도 실행 컨텍스트에 접근이 가능하며, 우리의 상황에선 굳이 인터셉터로 만들어 구분 시킬 필요는 없었다. 물론, 조금 더 세세한 코드 작성이 요구 될 시, 로직을 전 부 가드에 작성하는 것 또한 고려해 보아야 할 사항이다.

(알다시피 인터셉터는 nextHandler()에 쉽게 접근할 수 있으므로 더 세밀한 조정에 용이할 것이다.)


✔ 메서드 분리를 통한 코드 개선 😀

위의 코드를 작성한 후 가드의 canActivate() 메서드 내부에 너무 많은 로직이 담겨있다고 생각하였다. canActivate()는 요청이 컨트롤러에 도달하기 전에 미리 실행되어 요청을 허용할지 거부할지 결정하는 부분을 담당하는 로직이다. 즉, 권한부여를 가드에서 수행하고자 할때는 오로지 권한 검사를 통한 true, false 반환부만 canActivate() 내부에 작성해주는 것이 좋지 않을까 생각하였다.

캐싱을 구현하는 부분과 (retrieveset 부분 분리) Permission 데이터를 조회하는 부분을 분리시킨다.

코드 개선을 위해 만들어진 메서드들은 private 메서드로써 가드내에 작성하도록 하였다.

// permission.guard.ts -- 수정

import { CACHE_MANAGER, CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Cache } from "cache-manager";
import { AuthService } from "../../auth/auth.service";
import { Role } from "../../role/model/role.entity";
import { RoleService } from "../../role/role.service";
import { User } from "../../user/model/user.entity";
import { UserService } from "../../user/user.service";
import { Permission } from "../model/permission.entity";
import { PermissionName } from "../model/permission.enum";

@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authService: AuthService,
    private userService: UserService,
    private roleService: RoleService ,
    @Inject(CACHE_MANAGER) private cacheManager: Cache, 
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    
    const access = this.reflector.get<string>('access', context.getHandler());
    if (!access) {
      return true;
    }
    
    const request = context.switchToHttp().getRequest();
    const id = await this.authService.userId(request);

    const cacheKey = `permissions: ${id}`;
    const permissions = await this.getPermissions(id, cacheKey);
    return this.checkPermission(request, access, permissions);
  }

  private async getPermissionsFromCache(cacheKey: string): Promise<Permission[]> {
    const cacheValue = await this.cacheManager.get<string>(cacheKey);
    console.log(`cacheValue: ${cacheValue}`);

    if (cacheValue) {
      const permissions: Permission[] = JSON.parse(cacheValue);
      return permissions;
    }
    return null;
  }

  private async setPermissionsCache(cacheKey: string, permissions: Permission[]): Promise<void> {
    await this.cacheManager.set(cacheKey, JSON.stringify(permissions), 50000);
  }

  private async getPermissions(id: number, cacheKey: string): Promise<Permission[]> {
    const cachedPermissions = await this.getPermissionsFromCache(cacheKey);
    if (cachedPermissions) {
      return cachedPermissions;
    }
    const user: User = await this.userService.findUserById(id, ['role']);

    const role: Role = await this.roleService.findOne(user.role.id, ['rolePermissions', 'rolePermissions.permission']);
    const rolePermissions = role.rolePermissions;
    const permissions: Permission[] = rolePermissions.map(rp => rp.permission);
    await this.setPermissionsCache(cacheKey, permissions);
    return permissions;
  }


  private checkPermission(request: any, access: string, permissions: any[]): boolean {
    let isAccess: boolean;

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === PermissionName[`VIEW_${access.toUpperCase()}`])
        || (p.name === PermissionName[`EDIT_${access.toUpperCase()}`])
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === PermissionName[`EDIT_${access.toUpperCase()}`]);
    return isAccess;
  }
}

canActivate() 함수 내에선 오로지 getPermissions() 메서드만 불러오게끔 한다. 캐싱과 관련된 getPermissionsFromCache()setPermissionsCache()의 경우는 getPermissions() 내부에 받아옴으로써 조금 더 가독성있게끔 분리를 진행한다.

cacheKey를 공유하며 메서드를 정의하는 것이 위 코드의 중점이다.


💥 성능 테스트와 캐싱 무효화

이렇게 캐시 메모리 스토리지를 사용하여 (CACHE_MEMORY) 데이터베이스 캐싱을 구현할 수 있었다. 물론 아직 최적으로 구현하진 않았지만, 정말 캐싱이 적용되는지와 캐싱을 사용하지 않았을때와 비교에 어떤 성능개선을 일으켰는지 직접 확인할 필요가 있다.

캐싱이 제대로 처리되었고 확인이 되었는지 알아보기 위해 위의 코드에서 잠깐 알아볼 수 있듯이, cacheValue 값을 console에 찍어보기로 하였다.

더하여, 캐싱이 된 권한부여에 대해, 정말로 디비 접근이 이루어지지 않고 오로지 캐시 메모리 접근을 통한 요청이 이루어지는지 확인하기 위해 "로깅 정보"를 찍어보기로 하였다.


> 로그 정보를 통한 쿼리 성능 확인하기

잠깐 해당 검증을 진행하기 전에 우리가 앞서 set cache 부분에서 설정한 TTL(Time-To-Live)와 같은 경우는 검증에 지장없도록 길게 설정하였다. 이 문제에 대해선 바로 다음 파트에서 언급할 예정이니 일단은 무시하고 진행한다.

✔ 권한 부여 (give permissions)

특정 유저의 특정 role에 대해 view_rolesedit_roles라는 권한을 부여한다. (꼭 view와 edit의 대상이 같은 필요는 없다.)

✔ 권한이 부여된 요청에 "처음" 접근할 시

아래 요청으로 접근해보자. 우린 roles를 조회하는데 있어 권한을 부여받았으므로 해당 접근은 허용된다.

GET: http://localhost:5000/api/roles

물론 본인이 쿼리를 최적화 시켜주지 않아서 많은 쿼리문이 불러진 것도 있지만 해당 요청을 위한 모든 쿼리문이 불러진것을 확인할 수 있고, cachValue또한 undefined 인 것을 확인할 수 있다.

어? 권한을 부여받은 요청인데 왜 그럴까?

당연하다. 첫 접근이기 때문이다. 우리가 코드로 구현해준대로, 첫 접근시엔 cacheValue가 존재하지 않으므로 직접 데이터베이스 접근을 통해 쿼리를 요청하게 된다. 즉, 이에 따라 수많은 관련 쿼리문이 로깅 정보에 출력되는 것이다.


✔ 권한이 부여된 요청에 "계속해서" 접근할 시 (첫 접근이 아닐 경우)

GET: http://localhost:5000/api/roles

동일한 위의 요청으로 여러번 요청을 날려보았다. 로깅 정보는 어떻게 나오게 될까?

확연한 변화가 보인다!

첫 요청에 대한 쿼리문 이후로, 두 번째 요청부터는 허용된 권한에 접근할 시 아래와 같이 처음 특정 Role의 데이터를 불러오기 위한 쿼리 많이 요구된다. 또한 set으로써 설정해준 cacheValue또한 잘 받아지는 것을 확인할 수 있다.


✔ 권한이 부여되지 "않은" 요청에 접근할 시

권한이 부여되지 "않은" 요청에 대해서도 "첫" 요청시는 동일하게 수행되는 모든 쿼리가 호출된다.

다음은 goods (products) 조회에 접근하였을 시다.

GET: http://localhost:5000/api/goods

첫 요청이 아닌 두 번째의 요청부터는 또 눈여겨봐야할 결과가 나온다.

위의 GET 요청을 3번 날려보았더니 아래의 로깅정보를 확인할 수 있었다.

보다 시피 cacheValue를 알아보기 위해 임의로 작성해둔 콘솔 결과를 제외하고 어떠한 쿼리 정보도 불러오지 않았다. 즉, 우린 권한 정보가 없는 요청에 대해 캐싱처리를 함으로써 "DB로 향하는 어떠한 쿼리도 수행하지 않았다는 것" 이다.

이렇게 우리는 몇 가지 상황을 통해 캐싱이 정말로 우리의 애플리케이션에서 구현이 되었는지와 쿼리의 성능 측면에서 어떠한 기대를 불러왔는지를 알아볼 수 있었다. 간단한 상황이지만 만약 유저가 수백, 수천만인 상황이며 특정 이벤트에서 권한 요청이 동시 다발적으로 들어올 경우 캐시를 사용한 경우와 그렇지 않은 경우에 확연한 차이가 있을 것이라고 생각할 수 있었다.


> TTL(유효시간)이 불러오게 될 문제

Caching에서 TTL의 의미

잠깐 TTL이 캐싱에서 어떤 의미인지 생각해보자. 위에서도 언급하였다시피, 캐시는 일반적 데이터베이스와는 다르게 Storage 계층(혹은 보조 메모리)에 저장되는 것이 아니다. 조금 더 빠른 요청 수행을 위해 Main-memory(주 메모리)에 일반적으로 저장된다. 주 메모리의 특징은 빠르다는 것도 있지만, "휘발성"이라는 특징 또한 존재한다.

만약 모든 쿼리 요청에 대해 전부 캐시에 저장하면 어떻게 될까? 그렇게 하면 당연히 모든 쿼리 성능이 개선되고, 부하가 줄어들며 자연스래 최적의 개선이 되지 않을까?

위와 같이 생각할 수도 있지만 캐시의 크기는 데이터베이스가 저장되는 공간에 비해 작은 편이다. 즉, 모든 데이터를 전부 캐시에 저장할 수는 없다. (이러한 측면에서 Redis같은 외부 저장소가 나온 것이다) 따라서, 캐시에 저장된 데이터는 일정 시간이 지나면 더 이상 유효하지 않게 끔 되어 메모리에서 제거된다.

이렇게 함으로써 캐시는 제한된 메모리 용량 내에서 최적의 데이터를 저장하고, 최신 데이터에 빠르게 접근하게끔 하는 것이다.


✔ 유효시간의 적당한 길이는 ? 짧은 TTL vs 긴 TTL

@nestjs/common으로 부터 제공받는 CACHE_MANAGER를 통해 설정하게 되는 TTL의 기본 단위는 ms이다. 위의 우리의 코드같이 조금 넉넉한 시간 혹은 그 이상의 TTL을 부여한다고 가정하자.

부여 받은 권한은 이전과 동일하게 설정하고 진행한다.

만약, 설정한 유효시간이 다 만료되지 않은 시점에서 기존에 부여받은 권한 (view_roles, edit_roles)을 새로운 권한(view_products, edit_products)로 수정하게 된다면 어떤 일이 벌어질까?


※ 아래와 같이 권한 수정!


위와 같이 접근을 허용시킨 권한 (view_products(goods))의 요청을 보내었지만 응답 데이터를 반환하지 않고, 여전히 접근할 수 없다는 에러를 띄우게 된다. 다른 요청 또한 마찬지의 결과를 보이고, 수정하기전의 role 데이터의 접근만이 여전히 허용된다.

즉, 캐싱을 이용하였을 때, 이러한 "변경에 따른 유효시간의 문제"가 걸림돌이 된다.

그렇다고 빠르게 변경된 데이터를 캐시에 반영하기 위해 유효시간을 짧게 설정할 경우, 캐시 데이터가 금방금방 날아가게 되어 마찬가지로 의미가 없게된다.

이러한 문제로 견주어 보았을때, 만약 권한 정보를 수시로 업데이트하거나, 권한 정보의 변경이 바로 반영되어야 하는 경우라면, 캐시 유효기간을 짧게 설정하거나 극단적으론 아얘 사용하지 않는 경우가 나을수도 있다. 반면에 권한 정보가 거의 변경되지 않는, 혹은 우리의 예시를 벗어나 거의 동일한 데이터를 조회하게 되는 요청의 경우엔 캐시 유효시간을 매우 길게 설정하여 편하게 캐싱을 할 수도 있다.

우리의 권한 부여 수행을 토대로 생각하면 어떤 것이 효율적일까? 나의 결론은 둘 다 애매하였다. 물론 권한과 같은 데이터는 수시로 바뀌는 것은 아니지만 특정 기간이 아닌 특정 상황(이벤트)에 따라 바뀔 수 있으므로 캐시 유효기간을 무작정 길게 설정하는 것 또한 문제가 될 것이고, 그렇다고 캐싱처리를 포기하기엔 각 유저마다의 요청 접근 시엔 확연한 성능을 개선할 수 있단 것을 직접 확인할 수 있었다.

결국 유효시간 조정만이 아닌, 이 모든 것을 해결할 수 있는 방법을 찾을 필요가 있다고 판단하였다.


> 캐시 무효화(Cache Invalidation)를 통한 해결

캐시 무효화를 구현하기 위해 cacheManager.del() 메서드를 사용하여 기존 데이터를 삭제 후 다시 쓰는 것이 일반적인 방법이지만, cacheManager.set() 메서드를 사용해 캐시를 덮어쓰는 방법을 시도해 보기로 하였다. 삭제 후 다시 생성하는 것보다 덮어쓰는 방법을 이용하면 속도나 생산측면에서 더 효과적이지 않을까 생각이 들었다.

✔ 기존 PermissionGuard에 캐시 무효화 수행하기

import { CACHE_MANAGER, CanActivate, ExecutionContext, Inject, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { Cache } from "cache-manager";
import { AuthService } from "../../auth/auth.service";
import { Role } from "../../role/model/role.entity";
import { RoleService } from "../../role/role.service";
import { User } from "../../user/model/user.entity";
import { UserService } from "../../user/user.service";
import { Permission } from "../model/permission.entity";
import { PermissionName } from "../model/permission.enum";

@Injectable()
export class PermissionGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private authService: AuthService,
    private userService: UserService,
    private roleService: RoleService ,
    @Inject(CACHE_MANAGER) private cacheManager: Cache, 
  ) {}
  async canActivate(context: ExecutionContext): Promise<boolean> {
    
    const access = this.reflector.get<string>('access', context.getHandler());
    if (!access) {
      return true;
    }
    
    const request = context.switchToHttp().getRequest();
    const id = await this.authService.userId(request);

    const cacheKey = `permissions: ${id}`;
    const permissions = await this.getPermissions(id, cacheKey, 5000);
    return this.checkPermission(request, access, permissions);
  }

  private async getPermissionsFromCache(cacheKey: string): Promise<Permission[]> {
    const cacheValue = await this.cacheManager.get<string>(cacheKey);
    console.log(`cacheValue: ${cacheValue}`);

    if (cacheValue) {
      const { permissions, expirationTime } = JSON.parse(cacheValue);
      // 만료시간이 지나지 않았을 경우에만 캐시에 저장된 권한 정보를 불러온다.
      if (expirationTime >= Math.floor(Date.now() / 1000)) {
        return permissions;
      }
    }
    return null;
  }

  // setPermissionsCache의 인자로써 ttl을 추가해준다. --> 만료시간을 구하기 위함
  private async setPermissionsCache(cacheKey: string, permissions: Permission[], ttl: number): Promise<void> {
    // 현재 시각에서 ttl을 더해 만료시간을 구한다.
    const expirationTime = Math.floor(Date.now() / 1000) + ttl;
    // cacheValue에 기존 권한 정보 데이터를 비롯해 만료시간을 포함시켜준다. --> 직렬화 수행
    const cacheValue = JSON.stringify({ permissions, expirationTime })
    await this.cacheManager.set(cacheKey, cacheValue);
  }

  private async getPermissions(id: number, cacheKey: string, ttl: number): Promise<Permission[]> {
    const cachedPermissions = await this.getPermissionsFromCache(cacheKey);
    if (cachedPermissions) {
      return cachedPermissions;
    }
    const user: User = await this.userService.findUserById(id, ['role']);

    const role: Role = await this.roleService.findOne(user.role.id, ['rolePermissions', 'rolePermissions.permission']);
    const rolePermissions = role.rolePermissions;
    const permissions: Permission[] = rolePermissions.map(rp => rp.permission);

    // permissions 정보가 계속 업데이트 될때마다 해당 메서드를 호출하게 된다.
    // 해당 작업이 수행됨으로써 "캐시 무효화" 가 구현될 수 있는 것이다.
    await this.setPermissionsCache(cacheKey, permissions, ttl);

    return permissions;
  }


  private checkPermission(request: any, access: string, permissions: any[]): boolean {
    let isAccess: boolean;

    if (request.method === 'GET') {
      isAccess = permissions.some(
        p => (p.name === PermissionName[`VIEW_${access.toUpperCase()}`])
        || (p.name === PermissionName[`EDIT_${access.toUpperCase()}`])
      );
      return isAccess;
    }

    isAccess = permissions.some(p => p.name === PermissionName[`EDIT_${access.toUpperCase()}`]);
    return isAccess;
  }
}

주석에 작성되있는 것과 같이 해당 부분에서 "캐시 무효화"와 관련된 작업이 수행된다. 여기서 단순히 생각하면 혼란에 빠질 수 있다. 캐시에 저장된 데이터를 불러오는 getPermissionsFromCache()에서

    if (cacheValue) {
      const { permissions, expirationTime } = JSON.parse(cacheValue);
      // 만료시간이 지나지 않았을 경우에만 캐시에 저장된 권한 정보를 불러온다.
      if (expirationTime >= Math.floor(Date.now() / 1000)) {
        return permissions;
      }
    }
    return null;

위의 코드를 간단히 설명하면, 만약 만료시간(expirationTime)이 초(s) 단위로 나타내진 현재 시간 이상일 경우에 (즉, 캐시가 유효할 경우에) 캐시 메모리를 통해 권한 정보를 받아오게 된다.

그런데 분명 우리는 만료시간이 지나지 않았을 경우에도 만약 권한 정보가 업데이트 되었을 경우엔 기존 캐싱된 데이터가 아닌 새로운 권한 정보에 접근하여 새롭게 캐싱된 데이터를 불러오고자 하는 것이 목표이다.

그렇다면 그대로 캐싱된 데이터인 permissions 객체를 리턴하는 것이 맞을까?

이것에 대한 수행이 바로 setPermissionsCache()에서 진행된다.

  // setPermissionsCache의 인자로써 ttl을 추가해준다. --> 만료시간을 구하기 위함
  private async setPermissionsCache(cacheKey: string, permissions: Permission[], ttl: number): Promise<void> {
    // 현재 시각에서 ttl을 더해 만료시간을 구한다.
    const expirationTime = Math.floor(Date.now() / 1000) + ttl;
    // cacheValue에 기존 권한 정보 데이터를 비롯해 만료시간을 포함시켜준다. --> 직렬화 수행
    const cacheValue = JSON.stringify({ permissions, expirationTime })
    await this.cacheManager.set(cacheKey, cacheValue);
  }

우리가 해당 메서드 내부에서 정의하게 되는 expirationTime의 경우는 현재 시간에서 외부로부터 받아온 ttl 값을 더하여 만들어진다.

이러한 만료시간이 앞서 캐시를 불러오는 getPermissionsFromCache()에서 수행되는데 만약 만료시간이 지나지 않았다면 캐싱된 데이터를 불러오지만 그러지 않을 경우 null을 반환한다.

이러한 과정은 최종 데이터를 permission 데이터를 불러오는 주 로직인 getPermissions에 반영이 되는데, 아래와 같다.

private async getPermissions(id: number, cacheKey: string, ttl: number): Promise<Permission[]> {
    const cachedPermissions = await this.getPermissionsFromCache(cacheKey);
    if (cachedPermissions) {
      return cachedPermissions;
    }
    const user: User = await this.userService.findUserById(id, ['role']);

    const role: Role = await this.roleService.findOne(user.role.id, ['rolePermissions', 'rolePermissions.permission']);
    const rolePermissions = role.rolePermissions;
    const permissions: Permission[] = rolePermissions.map(rp => rp.permission);

    // permissions 정보가 계속 업데이트 될때마다 해당 메서드를 호출하게 된다.
    // 해당 작업이 수행됨으로써 "캐시 무효화" 가 구현될 수 있는 것이다.
    await this.setPermissionsCache(cacheKey, permissions, ttl);

    return permissions;
  }

cache를 통해 검색된 데이터가 있을 경우는 그대로 해당 캐시를 사용하지만 그러지 않을 경우 (즉, 만료되어서 null을 반환한 경우이다) 다시 DB에서 업데이트를 통해 권한 정보를 부여받게 된다.

이때 위에서 보는 것과 같이 캐시를 설정하는 setPermissionsCache() 메서드가 호출된다. 해당 메서드에서 우린 만료시간에 대한 정보를 담아준것을 기억해보자.

해당 만료시간은 현재시간 + ttl이다. 여기서 "현재 시간"은 아래의 코드를 통해

await this.setPermissionsCache(cacheKey, permissions, ttl);

계속 업데이트 되어 getPermissionFromCache()expirationTime에 반영이 될 것이다.

이렇게 계속하여 permissions 데이터를 덮어씀으로써 "캐시 무효화(Cache Invalidation)"를 구현할 수 있게 되는 것이다.



따로 검증하는 부분은 생략하겠다. ( 캐시 무효화가 성공한 것을 직접 요청을 통해 확인할 수 있었다. )

이제 우린, 캐시 유효시간을 길게 잡더라도 "만료시간 전 권한 정보 업데이트 이슈" 에 유연히 대처할 수 있게 되었다.



생각정리

지난 포스팅에서 구현해보았던 "인증과 인가를 통한 권한부여"를 베이스로 "Database-Caching"을 수행해보았다.

"권한 부여"란 작업에 캐싱을 적용함으로써 쿼리적 측면에서 성능의 향상을 불러일으킨 것은 사실이지만, 아직까지 이러한 메모리 및 캐싱의 개념에 대해 깊이 알지 못하다보니 분명 고려하지 못한 사항이 있을 수 있고, 불필요한 적용이 될 수도 있다.

하지만, 이번 작업을 통해서 "Database-Caching"에 한걸음 다가갈 수 있었고, 개념 정리만이 아닌 최근 진행한 "권한 부여"란 실용적 예시에 적용함으로써 어떠한 기대를 불러올 수 있는지 직접 확인할 수 있었다.

포스팅을 이 상태로 업로딩 하겠지만, 실무에 계신 다양한 분들의 의견과 피드백을 토대로 어떻게 조금 더 나은 캐싱 작업을 구현할 수 있는지 공부하고, 추후 업데이트 해볼 예정이다.


그럼 총 2편으로 나뉜 해당 포스팅을 읽어주셔서 감사합니다.


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

0개의 댓글