레이블이 4iz인 게시물을 표시합니다. 모든 게시물 표시
레이블이 4iz인 게시물을 표시합니다. 모든 게시물 표시

2/09/2024

404 페이지

OAuth 2.0 로그인을 적용을 위해서 정적 파일과 동적 파일을 분리하고 나니 문제가 발생했다. 이전에는 HTML 파일 혹은 존재하지 않는 API 경로를 입력하면 자동으로 '/' 페이지로 리다이렉션 되는데 HTTP 상태코드, 경로, 메시지를 담은 오류 메시지가 그냥 출력되었다. 사용자의 관점에서 이러한 정보는 당연히 필요도 없을 뿐더러 페이지가 아니기에 더욱더 불편하다. 이를 해결하기 위해 404 페이지를 생성하는 과정에서 마주친 여러 문제에 대해서 적어본다.


필터

404 페이지의 이름에서 알 수 있듯이 HTTP 상태코드 404에 해당하는 예외를 다룬다. 따라서, 바로 필터가 떠오른다. 검색을 통해서 필터와 HTML 페이지를 만들었는데 여기서 경로 때문에 고생을 좀 했다. HTML 파일을 담고 있는 public 디렉터리의 위치가 컴파일 전후에 따라 다르기 때문이다. 약간 더 자세하게 말하면 필터가 존재하는 디렉터리의 경로는 컴파일 여부와 관계없이 src/filters 이지만 public 디렉터리는 컴파일 전은 src/public 이지만 컴파일 후는 dist/public이다! 이러한 차이 때문에 시간을 좀 날렸다 그.리고 루트 모듈인 AppModule에서 전역으로 필터를 설정한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// not-found-exception.filter.ts
// HTTP 404 페이지 필터.
@Catch(NotFoundException)
export class NotFoundExceptionFilter implements ExceptionFilter {
  catch(exception: NotFoundException, host: ArgumentsHost) {
    const context = host.switchToHttp();
    const response = context.getResponse<Response>();
  const statusCode = exception.getStatus();
    // root 옵션은 상대적 파일 이름을 위한 루트 디렉터리.
    // 컴파일된 dist 디렉터리를 기준으로 한다.
    const options = {
       root: path.join(__dirname, '..''..''public'),
     };
 
    // 주어진 경로의 파일을 전송한다.
    // root 옵션이 옵션 객체에서 설정되지 않은 경우 경로는 파일에 대한 절대 경로여야 한다.
    response.status(statusCode).sendFile('dne.html', options);
  }
}
 
// app.module.ts
@Module({
  ...
 
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionFilter,
    },
    {
      provide: APP_FILTER,
      useClass: NotFoundExceptionFilter,
    },
 
  ...
  ],
})
export class AppModule implements NestModule {
  ...
}
cs


HTTP 메서드

필터를 적용하고 try-catch 문을 수정하는데 문제가 발생했다. 사용자 정보수정, 장바구니 수정, 장바구니 삭제와 같은 API는 기본적으로 읽기 연산을 수행하고 원하는 데이터가 없으면 404 상태코드 오류를 던진다. 컨트롤러의 HTTP 메서드는 GET이 아니지만 서비스나 레포지토리에서 404 상태코드 오류가 발생하면 404 HTML 페이지로 리다이렉션이 발생했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// carts.service.ts
// 장바구니를 수정하는 컨트롤러에서 사용하는 서비스 메서드.
// 장바구니가 없으면 HTTP 상태코드 404를 예외로 던진다.  
async update(id: number, updateCartDto: UpdateCartDto, user: User) {
  const { quantity } = updateCartDto;
 
  const cart = await this.cartsRepository.findOne({
    where: { id, user: { id: user.id } },
  });
 
  if (!cart) {
    throw new NotFoundException('아이디에 해당하는 장바구니 없음.');
  }
 
  const price = cart.totalPrice / cart.totalQuantity;
  cart.totalQuantity = quantity;
  cart.totalPrice = price * cart.totalQuantity;
 
  return await this.cartsRepository.save(cart);
}
cs

필터에서 이러한 HTTP 메서드에 대해 예외를 적용하기 위해서 요청 객체에서 HTTP 메서드, URL, 오류 메시지를 뽑아내서 예외를 클라이언트에 반환한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// HTTP 404 페이지 필터.
@Catch(NotFoundException)
export class NotFoundExceptionFilter implements ExceptionFilter {
  catch(exception: NotFoundException, host: ArgumentsHost) {
    const context = host.switchToHttp();
    const request = context.getRequest<Request>();
    const response = context.getResponse<Response>();
    const statusCode = exception.getStatus();

    // 전역으로 적용되는 필터라서 다음 HTTP 메서드는 예외를 적용한다.
    const methods = ['POST''PUT''PATCH''DELETE'];
    const method = request.method;

    if (methods.includes(method)) {
      const timestamp = new Date().toISOString();
      const path = request.originalUrl;
      const message = exception.message;
 
      // 반환문이 없으면 응답이 AllExceptionFilter로 전달되어 두 번의 응답이 전송되어 오류가 발생한다.
      // 반환문을 추가해 클라이언트에 응답을 전송한다.
      return response.status(statusCode).json({
        statusCode,
        message,
        timestamp,
        path,
      });
    }

    // root 옵션은 상대적 파일 이름을 위한 루트 디렉터리.
    // 컴파일된 dist 디렉터리를 기준으로 한다.
    const options = {
       root: path.join(__dirname, '..''..''public'),
    };

    // 주어진 경로의 파일을 전송한다.
    // root 옵션이 옵션 객체에서 설정되지 않은 경우 경로는 파일에 대한 절대 경로여야 한다.
    // HTTP 상태코드가 없으면 장바구니 목록 페이지와 주문 목록 페이지에서 오류가 발생한다.
    response.status(statusCode).sendFile('dne.html', options);
  }
}
cs

여기서 반환문이 정말 중요한데 모든 예외를 잡는 AllExceptionFilter가 전역 필터로 설정되어 있기 때문에 반환문이 없으면 두 번의 응답이 전달되는 오류가 발생한다.


테스트

try-catch 문을 수정해서 테스트를 진행했는데 E2E 테스트에서 문제가 발생했다. 상품 상세 API에서 존재하지 않는 아이디에는 HTTP 404 오류를 던지는데 이상하게 HTTP 500 오류가 나왔다. 테스트의 로그를 보니 HTML 페이지의 위치를 못찾고 있었다. 그러다가 생각해보니 ts-jest 패키지가 떠올랐다. 테스트 실행 시 ts-jest 패키지를 사용하는데 이 패키지는 TypeScript 파일을 로드하고 즉시 JavaScript로 변환하고 실행한다. 즉, Jest가 실행 중일 때 TypeScript 파일을 직접 로드할 수 있다. 바로 여기서 문제가 발생했다. TypeScript 파일을 로드하기 때문에 src 디렉터리에 존재하는 HTML 파일의 경로를 표시해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// HTTP 404 페이지 필터.
@Catch(NotFoundException)
export class NotFoundExceptionFilter implements ExceptionFilter {
  catch(exception: NotFoundException, host: ArgumentsHost) {
    const context = host.switchToHttp();
    const request = context.getRequest<Request>();
    const response = context.getResponse<Response>();
    const statusCode = exception.getStatus();
 
    // 전역으로 적용되는 필터라서 다음 HTTP 요청 메서드는 예외를 적용한다.
    const methods = ['POST''PUT''PATCH''DELETE'];
    const method = request.method;
 
    if (methods.includes(method)) {
      const timestamp = new Date().toISOString();
      const path = request.originalUrl;
      const message = exception.message;
 
      // 반환문이 없으면 응답이 AllExceptionFilter로 전달되어 두 번의 응답이 전송되어 오류가 발생한다.
      // 반환문을 추가해 클라이언트에 응답을 전송한다.
      return response.status(statusCode).json({
        statusCode,
        message,
        timestamp,
        path,
      });
    }
 
    // 개발/운영 vs. 테스트
    const options = {
      root:
        process.env.NODE_ENV === 'test'
          ? path.join(__dirname, '..''public')
          : path.join(__dirname, '..''..''public'),
    };
 
    // 주어진 경로의 파일을 전송한다.
    // root 옵션이 옵션 객체에서 설정되지 않은 경우 경로는 파일에 대한 절대 경로여야 한다.
    // HTTP 상태코드가 없으면 장바구니 목록 페이지와 주문 목록 페이지에서 오류가 발생한다.
    response.status(statusCode).sendFile('dne.html', options);
  }
}
cs

update: 2024.02.09

1/22/2024

GitHub Actions CI/CD

CI/CD

수동으로 배포를 하면 진행하면서 배포 과정의 세부사항을 배우고 오류도 고치고 이렇게 배포 과정을 이해하기 위해서 이러한 수동 배포가 한 번은 필요하지만, 개발 후 테스트 그리고 배포까지의 과정은 쉽지 않고 반복적이고 지루하다. 테스트와 배포같은 수동적이고 반복적이면서 비생산적인 업무를 최대한 줄이고 그 시간에 개발을 더 할 수 있을지를 고민한 결과가 바로 자동화이다. 다시 말해, 단위 테스트, 통합 테스트, E2E 테스트 등의 테스트 과정과 모든 배포 과정을 굳이 사람이 직접 실행할 필요 없이 자동으로 실행 되도록 하고 문제가 생겼을때 자동으로 알려주는 시스템을 구축해서 모든 과정을 자동으로 진행하는 것이다!

CI/CD를 위한 여러 툴이 있는데 나는 GitHub에 내장된 CI/CD 플랫폼인 GitHub Actions을 사용했다. GitHub Actions으로 특정 브랜치 푸시 및 풀 요청과 같은 이벤트가 발생하면 일련의 스크립트를 실행한다. 일단 단위 테스트를 자동화 해본다!

단위 테스트 자동화의 1번째 단계는 테스트가 Docker이미지에서 실행되어야 한다. 4iz Dockerfile 포스트를 보면 Dockerfile에 다단계 빌드를 적용하여 환경에 따라 설치하는 의존성이 다르다. 테스트 환경은 개발 환경과 마찬가지로 package.json 파일의 devDependencies에 나열된 패키지가 필요한데 바로 여기에 Jest와 같은 테스트에 필요한 패키지가 위치하기 때문이다. 포스트를 보면 test 단계를 대상으로 Docker 이미지에서 단위 테스트를 실행한다. 따라서 --target=test 옵션을 사용해야 한다. 이를 바탕으로 브랜치에 푸시가 발생할 때마다 실행되는 GitHub Actions 워크플로우를 만든다.


test.workflow

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
name: Test the application
# 레포지토리에 모든 푸시 이벤트가 발생할 때마다 워크플로우가 실행된다.
on: push
 
# 테스트에 사용될 Docker 이미지를 지정한다.
env:
  DOCKER_IMAGE_TEST: csup96/4iz-test:latest
 
jobs:
  test:
    name: Run tests
    runs-on: ubuntu-latest
    steps:
      # actions/checkout@v3 액션을 사용하여 레포지토리의 내용을 가져온다.
      - name: Check out the repository
        uses: actions/checkout@v3
      # docker/setup-qemu-action@v2 액션을 사용하여 QEMU를 설정한다(QEMU(Quick Emulator)는 에뮬레이션을 위해 사용된다.).
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2
      # docker/setup-buildx-action@v2 액션을 사용하여 Docker Buildx를 설정한다(Buildx는 Docker를 위한 CLI 플러그인으로, 다중 플랫폼 Docker 이미지를 빌드하는 데 추가 기능을 제공한다.).
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
      #  docker/build-push-action@v4 액션을 사용하여 Docker 이미지를 빌드한다.
      - name: Build a Docker image
        uses: docker/build-push-action@v4
        with:
          context: . # 현재 디렉토리를 빌드 컨텍스트로 지정한다.
          load: true # 빌드된 이미지를 Docker 데몬에 로드한다.
          target: test # 빌드 대상을 "test"로 지정한다(다단계 빌드.).
          tags: ${{ env.DOCKER_IMAGE_TEST }} # 정의한 환경 변수를 사용하여 Docker 이미지에 태그를 지정한다.
 
      # 컨테이너를 실행할 Docker 이미지를 지정하고 docker container run 명령을 사용하여 Docker 컨테이너 내에서 단위 테스트를 실행하며 --rm은 컨테이너가 종료된 후에 컨테이너를 제거한다.
      - name: Run unit tests with a Docker container
        run: docker container run --rm ${{ env.DOCKER_IMAGE_TEST }}
cs
요약하면, 이 워크플로우는 Docker 이미지를 빌드하고 Docker 컨테이너 내에서 단위 테스트를 실행함으로써 애플리케이션 테스트 과정을 자동화한다. Docker 이미지는 지정된 이미지 이름으로 태그되며 전체 과정은 레포지토리에 푸시될 때마다 실행된다.

작업 중인 브랜치에서 푸시를 하고 GitHub의 4iz 레포지토리의 Actions에 가면 다음과 같이 자동화가 성공적으로 완료되었다.

구체적인 세부 사항을 보면 총 42개의 단위 테스트가 통과된 것을 볼 수 있다.
걸린 시간은 8.624초~


주의할 점이 있는데 만약에 이 워크플로우가 풀 리퀘스트 이벤트가 발생할 때마다 실행되는데 테스트가 실패하면 어떻게 될까? 아이러니하게도 테스트가 실패하더라도 무시하고 브랜치를 병합할 수 있다! 이를 방지하기 위해 테스트가 실패하면 브랜치를 병합하지 못하도록 브랜치 보호 규칙을 추가해야 한다. GitHub 레포지토리에 들어가서 Settings에서 Branches로 이동해야 한다. 여기서 상태 확인이 성공하지 않은 경우 브랜치를 병합할 수 없도록 하는 보호 규칙을 추가한다. 나의 경우 main 브랜치에만 이를 적용했다~


deploy.workflow

배포 자동화는 테스트 자동화보다 더 어렵고 복잡하다. 뭐 당연하지만! 일단 GitHub Actions이 AWS에서 사용자를 대신해 작업을 수행해야 한다. 이를 위한 방법 중 하나는 GitHub이 AWS 사용자에게 인증할 수 있도록 하는 것이다. 따라서 GitHub이 사용자의 계정에 로그인할 수 있도록 권한을 부여해야 한다. GitHub에게 주어야 할 권한은 ECR 로그인, Docker 이미지 빌드와 레지스트리 푸시, 기존 ECS 서비스 업데이트로 볼 수 있다. 나의 경우, AWS 계정 생성 후 AdministratorAccess 권한을 가지는 admin 그룹에 사용자를 추가해서 이 과정은 생략한다.

GitHub Actions이 사용자를 사용할 수 있도록 하는 방법은 IAM에서 사용자에게 접근 키를 생성하는 것이다. 접근 키는 두 부분(접근 키, 비밀 접근 키)으로 구성되는데 두 키를 모두 가지고 있으면 특정 사용자로 인증할 수 있다. 이제 GitHub 레포지토리에 들어가서 Settings에서 Secrets and variables에서 Actions으로 이동한다. 여기서 Secrets에 앞서 생성한 접근 키의 두 부분을 추가한다.

이제 배포 워크플로우를 작성한다. 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
name: Deploy the application to production
 
# main 브랜치 또는 dev 브랜치를 대상으로 하는 풀 리퀘스트가 발생할 때마다 워크플로우가 실행된다.
on:
  pull_request:
    branches:
      - main
      - dev
 
env:
  DOCKER_IMAGE_TEST: csup96/4iz-test:latest
  DOCKER_COMPOSE_SERVICE: app # E2E 테스트에 사용될 Docker Compose 서비스를 지정한다.
 
jobs:
  build-test-push:
    # Docker 이미지를 빌드하고 테스트 한 후 Amazon ECR에 푸시한다.
    name: Build and test a Docker image and push it to Amazon ECR
    runs-on: ubuntu-latest
    steps:
      - name: Check out the repository
        uses: actions/checkout@v3
 
      - name: Set up QEMU
        uses: docker/setup-qemu-action@v2
 
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v2
 
      - name: Build a Docker image
        uses: docker/build-push-action@v4
        with:
          context: .
          load: true
          target: test
          tags: ${{ env.DOCKER_IMAGE_TEST }}
 
      - name: Run unit tests with a Docker container
        run: docker container run --rm ${{ env.DOCKER_IMAGE_TEST }}
      # Docker Compose로 사용하여 E2E 테스트를 실행한다.
      - name: Run E2E tests with a Docker Compose
        run: docker compose -f docker-compose.ci.yml up --build --exit-code-from ${{ env.DOCKER_COMPOSE_SERVICE }}
      # 인증을 위한 AWS 자격 증명을 구성한다.
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        # GitHub 레포지토리 -> Settings -> Secrets and variables -> Actions -> Secrets
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2
 
      # Amazon ECR에 로그인한다.
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
      # AWS 자격 증명을 사용하여 Docker 이미지를 Amazon ECR에 빌드, 태그 및 푸시한다.
      - name: Build, tag, and push the Docker image to Amazon ECR
        env:
          REGISTRY: ${{ steps.login-ecr.outputs.registry }} # 현재 작업의 이전 단계(Login to Amazon ECR)에서 나온 값을 사용한다.
          REPOSITORY: 4iz
          IMAGE_TAG: latest
        run: |
          docker image build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG .
          docker image push $REGISTRY/$REPOSITORY:$IMAGE_TAG
 
  deploy:
    # 최신 Docker 이미지를 Amazon ECS에 배포한다.
    name: Deploy the latest Docker image to Amazon ECS
    runs-on: ubuntu-latest
    # 이전 작업(build-test-push)이 완료될 때까지 기다린다.
    needs: build-test-push
    steps:
      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: ap-northeast-2
      # AWS CLI(aws ecs update service)를 사용하여 ECS 서비스를 최신 Docker 이미지로 업데이트하고 새로운 배포를 실행한다.
      - name: Update ECS service
        # GitHub 레포지토리 -> Settings -> Secrets and variables -> Actions -> Variables
        # --force-new-deployment는 서비스 정의가 변경되지 않았을 때도 새로운 배포를 강제로 수행한다.
        run: |
          aws ecs update-service \
          --cluster ${{ vars.AWS_CLUSTER }} \
          --service ${{ vars.AWS_SERVICE }} \
          --task-definition ${{ vars.AWS_TASK_DEFINITION }} \
          --force-new-deployment
cs

클러스터의 이름은 --cluster 인자로, 서비스의 이름은 --service로, 적절한 작업 정의는 --task-definition 인자로 지정하는데 이 이름은 GitHub 레포지토리의 Settings의 Secrets and variables의 Actions의 Variables에서 지정할 수 있다. 이 이름들은 4iz 인프라 포스트에서 ECS의 클러스터, 서비스, 작업 정의의 이름과 같아야 한다.

build-test-push

빌드, 테스트, 푸시 작업이 완료되고 나면 Amazon ECR의 사설 레포지토리에 다음과 같이 최신 이미지가 푸시된다!
27개의 E2E 테스트 통과, 걸린 시간은 16초!

latest 태그!

deploy

배포가 완료되면 ECS 클러스터의 서비스에 새로운 작업이 시작된다! ECS 롤링 업데이트의 실행되는 작업의 최소 퍼센트가 100이라서 새로운 컨테이너가 추가된다!

update: 2024.01.27

1/18/2024

역할 기반 인가

관리자만이 실행할 수 있는 기능이 필요하면 인가 기능을 반드시 구현해야 하는데 다른 기능 구현한다고 바빠서 못했는데 드디어 시간이 나서 기회가 왔다. 인가를 구현하는 방법은 2가지가 있는데 하나는 역할 기반 접근 제어(RBAC)으로 역할과 특권(privilege)을 중심으로 정의된 정책 중립적인 접근 제어 메커니즘이다. 다른 방법인 클레임(claim) 기반 권한 부여는 특정 역할을 확인하는 대신 권한(permission)을 비교해야 한다. 나는 기초적인 방법인 RBAC를 구현했다. NestJS 공식 문서를 읽고 구글링을 통해 배운점과 기능 구현에 대해 적어본다.


데코레이터

NestJS 공식 문서를 읽으면 @UseGuards() 데코레이터를 사용한 인증과 달리 인가는 @Roles() 데코레이터를 추가로 만든다. 왜 굳이 @Roles() 데코레이터를 만들까? NestJS 공식 문서의 OVERVIEW의 Guards의 Setting roles per handler에 따르면 가드의 가장 중요한 기능인 실행 컨텍스트를 활용하기 위해서다. 즉, 실행 컨텍스트에서 역할과 핸들러에 허용되는 역할과 같은 정보를 추출해야 하는데 여기서 사용자 정의 메타데이터가 등장한다. NestJS는 Reflector#createDecorato() 정적 메서드를 통해 생성된 데코레이터 또는 내장 @SetMetadata() 데코레이터를 통해 경로 핸들러에 사용자 정의 메타데이터를 첨부할 수 있는 기능을 제공한다. 두 가지 방법에 대한 설명은 공식 문서에 나와있다. 나는 SECURITY Authorization의 Basic RBAC implementation을 보면서 @SetMetadata() 데코레이터를 사용하기로 했다.
1
2
3
4
5
6
7
8
9
10
11
12
// role.enum.ts
export enum Role {
  USER = 'USER',
  MANAGER = 'MANAGER',
  ADMIN = 'ADMIN',
}
 
// roles.decorator.ts
// @SetMetadata() 데코레이터는 특정 자원에 접근하기 위한 역할이 무엇인지 지정한다.
// ROLES_KEY는 메타데이터 키, roles는 열거형 Role의 값.
export const ROLES_KEY = 'roles';
export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles);
cs


가드

이제 사용자에게 할당된 역할과 경로에서 필요한 실제 역할을 비교할 가드를 만들어야 한다. 가드 구현 역시 NestJS 공식 문서에 자세하게 나와있으며 필요한 부분만 추가 및 수정 했다. 경로에서 사용자 정의 메타데이터를 추출하려면 Reflector 클래스의 getAllAndOverride() 메서드 혹은 getAllAndMerge() 메서드 사용하면 되는데 컨트롤러의 메타데이터보다 메서드의 메타데이터를 우선하는 getAllAndOverride() 메서드를 사용하기로 했다. getAllAndMerge() 메서드는 두 개의 메타데이터를 병합해서 추출한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
@Injectable()
export class RolesGuard implements CanActivate {
  // 경로에 주어진 역할(즉, 사용자 정의 메타데이터)에 접근하기 위해 Reflector 클래스를 사용한다.
  // Reflector 클래스의 메서드는 컨트롤러와 메서드의 메타데이터를 동시에 추출하고 다양한 방식으로 병합한다.
  constructor(private readonly reflector: Reflector) {}
 
  canActivate(
    context: ExecutionContext,
  ): boolean | Promise<boolean> | Observable<boolean> {
    // 경로에 존재하는 역할(컨트롤러, 메서드)을 가져온다.
    const roles = this.reflector.getAllAndOverride<Role[]>(ROLES_KEY, [
      context.getHandler(),
      context.getClass(),
    ]);
 
    if (!roles) {
      return true;
    }
 
    const request = context.switchToHttp().getRequest();
    // NodeJS에서는 일반적으로 인증된 사용자를 Request 객체에 첨부한다. 다시말해, 인증 가드에서 인증이 완료되면 사용자를 Request 객체에 첨부한다.
    const user = request.user as User;
 
    // 사용자, 사용자의 역할, 경로가 필요한 역할을 가지고 있는 경우만 허용한다.
    const authorized =
      user && user.roles && roles.some((role) => user.roles?.includes(role));
 
    if (authorized) {
      return true;
    }
 
    throw new ForbiddenException('역할 없음.');
  }
}
cs


적용

적용은 컨트롤러 혹은 메서드에 @Roles() 데코레이터에 특정 역할을 명시하고 @UseGuards() 데코레이터에 역할 가드를 삽입한다. 애플리케이션을를 실행시키고 역할인 USER인 사용자가 아래 코드의 API를 호출하면 다음과 같은 예외가 발생한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// 역할이 'admin'인 사용자만 컨트롤러의 API를 사용할 수 있다.
@Roles(Role.ADMIN)
@UseGuards(JwtAuthGuard, RolesGuard)
@Controller('admin')
export class AdminController {
  constructor(private readonly adminService: AdminService) {}
 
  @HttpCode(HttpStatus.OK)
  @Serialize(UsersDto)
  @Get('/users')
  async getUsers(@Query() paginationDto: PaginationDto) {
    return await this.adminService.findUsers(paginationDto);
  }
 
  @HttpCode(HttpStatus.OK)
  @Serialize(UserDto)
  @Patch('/users/:id')
  async updateUser(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    return await this.adminService.updateUser(id, updateUserDto);
  }
}
 
cs
역할 없음!

update: 2024.01.18