iamkanguk.dev

[NestJS] Class-transformer의 expose와 exclude를 사용할 때 캐싱에서 발생한 이슈 + Lifecycle 분석 본문

Framework/NestJS

[NestJS] Class-transformer의 expose와 exclude를 사용할 때 캐싱에서 발생한 이슈 + Lifecycle 분석

iamkanguk 2023. 11. 15. 18:03

오늘은 약 3일 동안 개-고생을 한 것에 대해서 블로그를 써보려고 한다. 고생을 한 내용은....?

 

필자는 Nest에서 특별한 경우가 아니고서는 Response DTO를 만들어서 반환한다. 이때 Expose와 Exclude 메서드를 사용한다.

그리고 최근에는 Get 메서드에 대해서 캐싱을 시도해보고 있는데 이 부분에 있어서 있었던 이슈를 공유해보고자 한다.

 

Response DTO에 Class-transformer의 Expose와 Exclude 사용

import { Exclude, Expose } from 'class-transformer';
import { MarkCategoryDto } from './mark-category.dto';

export class GetMarkCategoriesResponseDto {
  @Exclude() private readonly _totalCategoryMarkCount: number;
  @Exclude() private readonly _markCategories: MarkCategoryDto[];

  private constructor(
    totalCategoryMarkCount: number,
    markCategories: MarkCategoryDto[],
  ) {
    this._totalCategoryMarkCount = totalCategoryMarkCount;
    this._markCategories = markCategories;
  }

  @Expose()
  get totalCategoryMarkCount(): number {
    return this._totalCategoryMarkCount;
  }

  @Expose()
  get markCategories(): MarkCategoryDto[] {
    return this._markCategories;
  }

  static from(
    totalCategoryMarkCount: number,
    markCategories: MarkCategoryDto[],
  ): GetMarkCategoriesResponseDto {
    return new GetMarkCategoriesResponseDto(
      totalCategoryMarkCount,
      markCategories,
    );
  }
}

 

일단 나는 이전 포스팅에서 정적 팩토리 메서드에 대해서 정리했기 때문에 혹시나 이 글을 보시는 분들은 참고해 주시면 좋을 것 같다. 

 

[NestJS] ResponseEntity와 정적 팩토리 메서드

필자는 Nest를 개발할 때 Service와 Repository를 거쳐온 결괏값을 Client로 반환할 때 Response DTO 객체로 만들어서 Controller 계층으로 넘기고 Controller에서 클라이언트로 결괏값을 반환할 때 ResponseEntity 객

dev-iamkanguk.tistory.com

 

결론은 객체지향 적으로 설계 (캡슐화) 하기 위해서 Exclude와 Expose를 사용하게 되었다. 참고로 일반적인 경우에는 User 테이블의 password와 같은 칼럼을 숨기기 위해 사용하곤 한다.

 

어떤 이슈가 있었냐?

먼저, Class-transformer의 exclude와 expose를 사용하기 때문에 main.ts 부분에 Global 적으로 ClassSerializeInterceptor를 등록했다. 그렇게 되면 Response로 반환될 때 Serialize 과정을 거쳐서 원하는 결괏값을 얻을 수 있다.

 

{
    "isSuccess": true,
    "statusCode": 200,
    "result": {
        "totalCategoryMarkCount": 19,
        "markCategories": [
            {
                "id": 18,
                "title": "영화보기",
                "isMarkedInMap": "INACTIVE",
                "markCount": 3
            },
            { ... },
        ]
    }
}

 

 

이후 나는 Caching을 적용하고 싶었다. 원래는 Controller의 Method 단에 @UseInterceptor(CacheInterceptor)와 CacheKey, CacheTTL 데코레이터를 적용하고 싶었지만, 필자는 각 사용자의 데이터를 저장하고 싶어서 CacheKey의 userId를 넣고 싶었다.

 

하지만, @CacheKey 데코레이터에는 그렇게 하지 못한다. 코드를 보자.   ---- (1)

  @Get()
  @HttpCode(HttpStatus.OK)
  @UseInterceptor(CacheInterceptor)
  @CacheKey('<여기에 넣어야해!!>')
  @CacheTTL(0)
  async getMarkCategories(
    @UserId() userId: number,
  ): Promise<ResponseEntity<GetMarkCategoriesResponseDto>> {
    const result = await this.markCategoryService.findMarkCategories(userId);
    return ResponseEntity.OK_WITH(HttpStatus.OK, result);
  }

 

필자는 key를 'mark_categories_userId_${uesrId}'와 같이 하고 싶다. 그렇지만 @CacheKey에 userId를 포함시키고 싶은데 그렇게 하지 못한다. (혹시나.... 아시는 분들은 댓글로 알려주시면 너무너무 감사하겠습니다!)

 

그래서 필자는 다음과 같이 코드를 수정해 봤다.    ---- (2)

 

  @Get()
  @HttpCode(HttpStatus.OK)
  async getMarkCategories(
    @UserId() userId: number,
  ): Promise<ResponseEntity<GetMarkCategoriesResponseDto>> {
    const redisKey = this.markCategoryHelper.setMarkCategoriesRedisKey(userId);
    const checkInRedisResult = await this.customCacheService.getValue(redisKey);

    if (isDefined(checkInRedisResult)) {
      return ResponseEntity.OK_WITH(
        HttpStatus.OK,
        checkInRedisResult as GetMarkCategoriesResponseDto,
      );
    }

    const result = await this.markCategoryService.findMarkCategories(userId);
    await this.customCacheService.setValueWithTTL(
      redisKey,
      result,
      CACHE_PERSISTANT_TTL,
    );

    return ResponseEntity.OK_WITH(HttpStatus.OK, result);
  }

 

데코레이터를 통해 Caching을 진행할 순 없다고 판단되어서 Controller 단에서 Redis에 해당 Key로 저장된 Value가 있는지 확인하고 있는 경우에는 Service로 넘어가지 않고 바로 Return 하고, 그렇지 않은 경우에는 Service로 넘어가서 비즈니스 로직을 거쳐서 Return 하게 작성해 봤다.

 

위와 같이 작성하게 되면 일단 유동적으로 CacheKey를 설정할 수 있긴 하다. 하지만 여기서 문제점이 있으니....

만약, API를 요청했을 때 Redis에 저장되어 있는 값이 없으면 Service로 넘어가서 로직들을 처리하고 result 변수에 값이 할당되어 있을 것이다. 그리고 위의 코드를 보면 ResponseEntity로 클라이언트에게 반환하기 전에 Redis에 key-value 형태로 저장을 한다. 하지만 Redis에 저장할 때는 위의 json 형태로 저장되지 않는다. 

Expose와 Exclude가 처리되지 않은 상태로 Redis에 저장되는 것이다. 즉, 위의 결괏값에서 언더바(_)가 붙은 상태로 저장이 된다. 이렇게 되면 다음에 추가적으로 같은 API를 요청하게 되면 Redis를 거치게 될 텐데 위의 결괏값과 다른 언더바가 붙은 상태로 반환이 될 것이다. 이렇게 되면 클라이언트에서는 화면을 렌더링 할 수 없게 된다.

 

즉, 필자가 원하는 것은 Expose와 Exclude가 처리된 상태(SerializeInterceptor를 거친 상태)를 캐싱하고 싶다는 것이다.

 

어느 부분에서 이슈가 있었던 것 같아요?

ClassSerializeInterceptor가 CacheInterceptor보다 늦게 동작을 하기 때문이었다. Response가 흘러가는데 CacheInterceptor를 먼저 들른 격이 되는 것이다.

 

우리는 위의 내용을 구현하기 위해서는 CacheInterceptor를 ClassSerializeInterceptor보다 늦게 실행시키면 되는 것이다!

 

NestJS에서의 Life-Cycle와 위의 이슈랑 연관 짓기

1. Incoming request
2. Globally bound middleware
3. Module bound middleware
4. Global guards
5. Controller guards
6. Route guards
7. Global interceptors (pre-controller)
8. Controller interceptors (pre-controller)
9. Route interceptors (pre-controller)
10. Global pipes
11. Controller pipes
12. Route pipes
13. Route paramter pipes
14. Controller (method handler)
15. Service (if exists)
16. Route interceptor (post-request)
17. Controller interceptor (post-request)
18. Global interceptor (post-request)
19. Exception filters (route, then controller, then global)
20. Server response 

 

위의 내용들을 보면 먼저 Controller 위에 작성하는 UseInterceptor는 pre-controller 및 controller interceptor(post-request) 단계이기 때문에 Expose와 Exclude를 처리한 결괏값을 캐싱할 수는 없다. 이유는 ClassSerializeInterceptor는 18번 단계의 Global interceptor이기 때문이다. (위의 글에서 볼 수 있는 (1) 번)

 

그렇게 되면 물론 위의 코드에서 작성한 Controller에서 처리한 것도 불가능할 것이다. (위의 글에서 볼 수 있는 (2) 번)

 

우리는 결국 18번을 공략해야 할 것 같다. 19번의 Exception Filter는 사용하지 않을 것이기 때문이다.

 

결국 어떻게 해결했나?

결론은 이렇게 하면 된다.... 너무 간단했다......!

 

import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { LoggerMiddleware } from './middlewares/logger.middleware';
import { PROJECT_MODULES } from './modules';
import { APP_GUARD, APP_INTERCEPTOR } from '@nestjs/core';
import { AuthGuard } from './modules/core/auth/guards/auth.guard';
import { CustomCacheInterceptor } from './interceptors/custom-cache.interceptor';
import { CustomCacheV2Interceptor } from './interceptors/custom-cache-v2.interceptor';

@Module({
  imports: [...PROJECT_MODULES],
  controllers: [],
  providers: [
    {
      provide: APP_GUARD,
      useClass: AuthGuard,
    },
    {
      provide: APP_INTERCEPTOR,
      useClass: CustomCacheV2Interceptor,
    },   // 이부분!!
  ],
})
export class AppModule implements NestModule {
  configure(consumer: MiddlewareConsumer): void {
    consumer.apply(LoggerMiddleware).forRoutes('*');
  }
}

 

바로 app.module.ts에 전역으로 Provider에 등록해 주는 것이다. 참고로 CustomCacheV2 Interceptor는 필자가 커스텀 한 CacheInterceptor인데 이것은 다음 포스팅에서 기본 Nest의 CacheInterceptor를 분석하는 것과 어떻게 커스텀했는지에 대해서 알아보려고 한다.

 

 

위의 사진은 main.ts 파일이다. 맨 밑의 줄을 보면 GlobalInterceptor로 ClassSerializerInterceptor를 등록해 줬다.

여기서 알아버린 사실은 app.module.ts에 등록한 provider가 main.ts에 등록한 provider 보다 우선이라는 것이다!

 

후기

내가 정확하게 인지한 부분인지는 모르겠다. 그래서 블로그를 통해서 혹시나 보시는 분들에게 지적을 받고 싶다.

커뮤니티 쪽에서도 여쭤보고 틀린 정보가 있다면 수정을 해보도록 하겠다.

 

어쨌든 문제는 해결했으니 끝!

 

#### 2023.11.18 후기 추가

- 지금 위와 같은 방법으로 하니 GET 이외의 메서드에서 캐시를 삭제하는 과정에서 많은 이슈가 발생하고 있다. 따라서, 위의 방법은 일단 보류하면서 추후에 해결하면 링크를 달아두도록 하려고 한다.


참고자료

- https://docs.nestjs.com/fundamentals/lifecycle-events

 

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 Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com