안녕하세요, 짧은머리 개발자입니다.

클라우드는 보안이 매우 중요한 환경인데요, 오늘은 Google Cloud Platform에서 관리중인 여러 프로젝트간 서비스 접근을 어떻게 구성하는지 공유하려 해요.

클라우드는 서비스로 제공하는 XaaS에 대한 접근을 제어할 수 있도록 IAM을 제공하는데 AWS, GCP와 같은 거대 클라우드는 IAM Role for Service Account(IRSA)라 불리는 메커니즘을 통해 더 세밀한 제어를 가능하게 해요.

이름에서 유추할 수 있듯이 서비스 계정을 위한 IAM 역할을 부여함으로써 그 메커니즘이 동작해요.

GCP의 서비스에 등록된 Service Account는 구글의 메타데이터 서버를 통해 자격 증명에 접근하고, 필요한 토큰을 관리해요.

  • 이전에 작성한 Github과 같이 GCP 외부에서 접근하는 경우에는 Security Token Services를 통해 단기 자격을 증명해주는 토큰을 발급받아야 하고, 이 때 roles/iam.serviceAccountTokenCreator과 같은 역할이 추가로 필요해요.

GCP는 구조를 계정 수준 리소스부터 서비스 수준 리소스로 나누어 관리할 수 있는데, 베스트 프랙티스로 GCP 내부에 생성할 그 구조를 회사의 조직 구조와 일치하게 만드는 것을 권장해요.

https://cloud.google.com/resource-manager/docs/cloud-platform-resource-hierarchy?hl=ko

이는 곧 분리되어 있는 프로젝트 간 배포된 서비스간 접근을 허용해야 하는 경우가 발생할 수 있어요.

제가 수행했던 프로젝트의 경우에도 분리되어있는 프로젝트간 버킷에 접근해야 할 필요가 있었는데요, 이를 어떻게 구성했는지 공유하려 해요.

프로젝트 구성

프로젝트 구성은 위와 같은 형태로, Project A에서 동작중인 Application A는 Project B의 Bucket B의 오브젝트에 접근할 수 있어야 하며, Bucket B는 Private하며 IAM을 통해 접근을 제한하고 있어요.

이를 해결하기 위해 우리는 싱글 프로젝트와 같이 Project A의 Application A에 서비스 계정을 추가한 뒤, Project B의 IAM을 구성해줘야 해요.

이 때 Project B에서 bucket-sa@project-a.iam.gserviceaccount.com 서비스 계정이 가져야할 역할은 다음과 같아요.

  • Bucket B에서 행할 작업에 따른 Storage 접근 권한, roles/storage.objectViewer
💡 roles/iam.serviceAccountUser의 경우, 특정 사용자 혹은 서비스 계정이
bucket-sa와 같이 IAM 역할을 갖는 서비스 계정으로 가장(impersonate)해야 할 때 필요해요.
이 경우에는 Application에 부착된 서비스 계정이 직접적으로 사용하기 때문에 필요하지 않아요.

 

여기까지 수행하고 나면 RBAC이 구성되고, Application A에서 Bucket B에 정상적으로 접근할 수 있어요.

그런데 만약 Project B에 Bucket B 말고 여러 버킷이 존재한다며면, bucket-sa 서비스 계정을 이용해서 모든 버킷에 접근 가능하게 돼요. 이는 보안상 매우 취약한 설정이며, 따라서 Attribute Based Access Control(ABAC)을 구성해야 해요.


ABAC은 이름에서 유추할 수 있듯 속성 기반의 접근 제어 메커니즘이에요. 기존의 bucket-sa 서비스 계정은 역할을 기반으로 Bucket B에 접근 가능하도록 구성했다면, 여기에 속성을 추가하여 더 세밀한 제어를 할 수 있어요.

우리에게 필요한 세밀한 제어는 다음과 같아요.

  • Bucket B에 접근할 수 있어야 한다.
  • Bucket B 내부의 오브젝트를 볼 수 있어야 한다.
  • Bucket B 내부의 디렉토리 구조를 볼 수 있어야 한다.

이를 해결하기 위해 GCP는 IAM 역할에 조건을 설정할 수 있고, 역할의 조건에 필요한 리소스 유형과 리소스 이름은 다음 링크에서 확인할 수 있어요.

우리에게 필요한 속성을 정리하면 다음과 같아요.

# 허용할 서비스 계정
member = "bucket-sa@project-a.iam.gserviceaccount.com"

# 허용할 역할
roles = [
  "roles/storage.objectViewer",
]

# 역할 + 대상 = 서비스 계정이 접근할 수 있는 대상
target_resources = [
  # 버킷의 이름이 bucket-b와 같아야 한다.
  {
    type   = "storage.googleapis.com/Bucket",
    name   = "projects/_/buckets/bucket-b"
    option = "equal"
  },
  # 접근하고자 하는 오브젝트는 bucket-b의 objects여야 한다.
  {
    type = "storage.googleapis.com/Object",
    name   = "projects/_/buckets/bucket-b/objects/"
    option = "startsWith"
  },
  # 접근하고자 하는 폴더는 bucket-b의 관리형 폴더여야 한다.
  {
    type = "storage.googleapis.com/ManagedFolder",
    name   = "projects/_/buckets/bucket-b/managedFolders/"
    option = "startsWith"
  }
]

버킷 뿐만 아니라 다른 XaaS에 대한 접근이 필요할 때도 위와 같이 서비스 계정을 구성한다면 프로젝트간 IRSA를 구성할 수 있어요.

오늘도 읽어주셔서 감사합니다.

'DevOps > Cloud' 카테고리의 다른 글

GKE와 Cloud Load Balancing 연결하기  (1) 2024.07.15

안녕하세요, 짧은머리 개발자에요.

오늘은 초기 스타트업인 회사에서 CI/CD 파이프라인을 도입하고 이에 대한 정책 결정에 대한 고민과 솔루션을 공유하려 해요.


우선 제가 입사하기 전까지 회사는 데브옵스 엔지니어가 없었기에 CI/CD 파이프라인 마찬가지로 없었고, 클라우드 내에 딱히 정해진 정책 없이 애플리케이션을 운영중하고 있었어요. 그렇기 때문에 현재 운영중인 애플리케이션의 버전을 알 수 없었고, 언제 어떻게 배포되었는지 추적하기 어려운 환경이었어요.

이러한 환경에서 제가 CI/CD 파이프라인을 도입하기 위해 회사의 현재 상황을 분석하고 마련한 예비 요구사항은 다음과 같아요.

  1. Continuous Integration, Continuous Delivery, Continuous Deploy 각 단계에서 현재 상태와 결과를 알 수 있어야 한다.
  2. 현재 운영중인 애플리케이션의 버전과 상태를 알 수 있어야 한다.
  3. 자동화된 시스템을 통해 개발자는 CI/CD를 수행할 수 있어야 한다.
    • 기존에는 CI ~ Deploy까지 한 번에 무조건 실행되어서 애플리케이션의 테스트 없이 곧바로 Production에 올라가는 문제가 있었어요. 이를 해결하기 위해 “적절한 자동화”를 통해 각자가 담당한 애플리케이션을 관리, 배포할 수 있게 하고자 했어요.
  4. DevOps Engineer를 제외한 개발자는 CI/CD 각 단계가 어떻게 진행되는지 몰라도 된다.
    • DevOps Engineer가 생긴 만큼 개발자는 각자의 영역에 충실할 수 있게끔 하고자 했어요.
  5. Git Ops와 융합되어야 한다.
    • 회사에서 GitHub Enterprise를 이용중이었고, 따라서 GitOps와 융합하고자 했어요.

1, 2번을 묶어서 기존 시스템의 가장 큰 문제점인 현재 운영중인 애플리케이션의 버전을 알 수 없는 점각 단계에서의 결과와 상태를 앎으로써 배포 후보군이 되는 애플리케이션이 어떤 내용을 갖는지 추적할 수 있게 하고자 했어요.


GCP Artifact Registry에 대한 Git Actions 접근 허가하기

GCP의 경우 워크로드 아이덴티티 제휴(Workload Identity Federation)를 통해 외부 서비스와의 융합을 지원하는데요, Git Actions에서 GCP에 접근 또한 이를 활용하여 해결할 수 있어요.

https://cloud.google.com/blog/products/identity-security/enabling-keyless-authentication-from-github-actions?hl=en

워크로드 아이덴티티 제휴는 쉽게 말해 GCP의 Iam Roles for Service Accounts(IRSA)라 볼 수 있어요. 즉, GCP의 리소스를 이용하는 작업(Workload)에 대해 신원(Identity)을 제공하는 기능이에요.

Git Actions에서 GCP의 서비스인 Artifact Registry에 접근할 필요가 있었고, 이를 위한 워크로드 아이덴티티 제휴를 구성했어요.

생성할 서비스 계정은 워크로드 아이덴티티 풀과 Artifact Registry에 접근하기 위해서 다음의 Role들을 가지고 있어요.

  • roles/iam.workloadIdentityUser
    • 서비스 계정을 통해 GCP의 워크로드 아이덴티티를 사용하기 위함이에요.
  • roles/iam.serviceAccountUser
    • 서비스 계정을 통해 GCP 리소스에 접근하기 위함이에요.
  • roles/artifactregistry.writer
    • 서비스 계정을 통해 Artifact Registry에 Git Actions로 생성된 컨테이너 이미지를 업로드하기 위함이에요.
  • roles/iam.serviceAccountTokenCreator
    • 서비스 계정에 대한 단기 인증정보를 만들 수 있도록 해요. 이를 통해 Git Actions 워크 플로우에 정의된 서비스 계정의 OAuth 2.0 토큰을 획득하고 명시된 권한을 사용할 수 있어요.

또한 아무 Git 리포지터리에서 실행되는 Git Actions의 워크플로우를 통해 우리의 GCP 인프라에 접근하면 안되기 때문에 다음과 같이 GitHub 계정 혹은 리포지터리에 대한 제한 및 격리를 시켜야 합니다.

module "oidc_gh" {
  ...
  sa_mapping = {
    "github-sa" = {
      sa_name   = google_service_account.github_sa.id
      attribute = "attribute.repository_owner/{{ Organization }}"
    }
  }
  ...
}

# module.oidc_gh 중
resource "google_service_account_iam_member" "wif-sa" {
  for_each           = var.sa_mapping
  service_account_id = each.value.sa_name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.this.name}/${each.value.attribute}"
}

이렇게 GCP와 Git Actions에 대한 WIP를 설정했다면, Git Actions에서 실제로 접근 가능한지 테스트해야 해요.

GCP의 경우 Git Actions에서 사용 가능한 Git Action을 제공하고 있는데요, google-github-actions/auth@v2를 통해서 GCP 서비스 계정의 권한을 획득할거에요.

build-and-delivery:
  runs-on: ubuntu-latest
  permissions:
    id-token: "write"
  steps:
	- name: "GCP Auth"
	  id: "gcp-auth"
	  uses: "google-github-actions/auth@v2"
	  with:
	    token_format: "access_token"
	    workload_identity_provider: "{{ 워크로드 아이덴티티 제공자 }}"
	    service_account: "{{ 워크로드 아이덴티티 서비스 계정 }}"
	    access_token_lifetime: "300s"

해당 Git Actions를 실행하면 GCP의 OAuth 2.0 토큰을 해당 잡에서 사용할 수 있게 해요.

  • 이를 위해 Git Actions에서 토큰에 대한 권한을 설정해야 해요. permissions의 id-token 속성을 write로 구성해야 합니다.
  • Github OIDC Provider는 클라우드 제공자로 부터 토큰을 전달받아 사용하는데, 여기서는 GCP의 OAuth 2.0 토큰을 받아 사용합니다.

이렇게 획득한 OAuth 2.0 토큰을 바탕으로 Artifact Registry 로그인 하여 이미지를 업로드 할 수 있어요.

- name: "Login to GAR"
  uses: docker/login-action@v3
  with:
    registry: asia-northeast3-docker.pkg.dev
    username: oauth2accesstoken
    password: ${{ steps.auth.outputs.auth_token }}

- name: "Docker auth with GCP Auth"
  run: |
    gcloud auth configure-docker asia-northeast3-docker.pkg.dev --quiet

Continuous Integration ~ Continuous Delivery 스텝 정의하기

Git Actions를 통해 Google Artifact Registry에 대한 권한을 획득했으니 실질적으로 CI ~ Delivery 까지의 Step을 정의해야 해요.

Continuous Integration은 한 제품을 개발하는 많은 개발자들이 각자의 코드를 융합하고 문제없이 돌아가는지 확인하며 제품으로 빌드하는 행위를 하도록 다음과 같이 정의했어요.

  1. 코드 테스트 단계
    • Git Actions에서 GCP Artifact Registry로 잘 접근이 되는지 확인해요.
    • 코드에 대한 테스트를 수행해요.
  2. 코드 빌드 단계
    • 코드 테스트 단계를 통과했다면 합쳐진 코드를 바탕으로 제품을 빌드해요.
    • 우리의 경우 Container 환경에서 애플리케이션을 운영하기 때문에 Container Image를 생성하는 행위를 의미해요.
  3. 제품 배달 단계
    • 생성된 Container Image를 GCP Artifact Registry로 업로드해요.

특히 우리는 예비 요구사항에서 각 스텝에 대한 결과와 상태를 알 수 있어야 한다.는 요구사항이 있었어요. 이를 지원하기 위해 CI ~ Delivery에 Slack Notification을 전송하는 Job을 추가했어요.

Slack Notification은 총 두 단계로 구성되어 있어요.

  1. Git Actions 시작 알림 메시지
    • 해당 메시지를 통해서 Git Actions의 결과물로 나올 컨테이너 이미지에 대한 정보를 함축적으로 전달해요.
    • 변경에 대한 Commits List를 추가하여 Slack의 쓰레드 메시지로 전달해요.
  2. Git Actions 종료 알림 메시지
    • Git Actions의 성공 여부와 Git Actions 링크 정보를 포함한 메시지를 전달해요
  • 단순히 알림 메시지를 단순히 보내는 것에 끝내지 않고 데이터베이스에 저장하여서 해당 커밋 혹은 릴리즈를 통해 배달된 버전이 어떤 변경점을 갖는지 추후에 확인할 수 있게 했어요.

슬랙에 메시지를 보냄으로써 생성된 이미지에 대해 변경사항을 확인할 수 있고 추후 애플리케이션 배포 시에도 활용하고자 했어요.

CI/CD 파이프라인의 성공 여부 메시지. 부모 슬랙 메시지의 쓰레드로 전달 되어요

독립된 Continuous Deploy

이제 배달 된 제품 빌드에 대해 배포할 차례에요. 기존의 문제점에서 현재 운영중인 애플리케이션의 버전을 알 수 없는 점을 해결하기 위해 배포에 대한 파이프라인을 앞 단계와 분리했어요.

웹 대시보드를 통해 배포하고자 하는 버전과 그 변경사항을 알 수 있게 했고, 현재 배포된 애플리케이션 차트가 어떻게 구성되어 있는지 확인할 수 있도록 배포 서버를 개발 했어요. 해당 서버의 경우 코드를 정리한 다음에 오픈소스로 공개할 예정이에요.

우리 회사는 GKE를 이용해서 애플리케이션을 운영하고 있는데, 실제 쿠버네티스에 배포에 대한 책임은 Argo CD에 위임했어요.

즉, 직접적으로 Continuous Deploy가 해결해야 하는 것은 Argo CD가 알 수 있도록 애플리케이션 매니페스트를 수정해주면 돼요.


여기까지 CI/CD 파이프라인을 설계하며 고민했던 내용을 정리했어요.

읽어주셔서 고맙습니다 🙂

안녕하세요, 짧은머리 개발자에요.

제가 근무하고 있는 회사는 Google Cloud Platform을 이용하여 서비스를 운영하고 있어요. 특히 컨테이너의 시대인 만큼 Google Kubernetes Engine을 이용하여 컨테이너를 관리하고 있는데요, 오늘은 처음 GKE를 도입하면서 GCP의 Cloud Load Balancer를 연동하며 했던 고민을 공유하려 해요.


먼저 GCP는 관리중인 서비스를 외부 혹은 내부에서 접근할 수 있도록 Cload Load Balancing이라는 명칭의 PaaS를 제공하고 있어요. CLB라고 불리는 이 서비스는 GCP에 배포된 서비스들에 고성능 부하 분산 기능을 제공하는 역할을 해요. AWS에는 ALB(NLB)라는 솔루션이 있어요.

우리 회사가 GKE에서 운영중인 서비스들에 대해 외부에서 접근할 수 있도록 하는것이 목표였는데요, GCP에서는 일반적으로 GKE의 외부 노출을 위해 다음의 솔루션을 제공해요.

  1. 쿠버네티스 게이트웨이 API를 이용한 외부 노출
  2. 쿠버네티스 인그레스를 이용한 외부 노출
  3. 서비스 + CLB 직접 관리를 통한 외부 노출

우리 회사가 선택한 방법은 3번인데, 그 이유는 다음과 같아요.

  1. Load Balancer의 직접 관리를 통해 보다 폭 넓은 제어가 필요하다.
  • 2번의 경우 CLB가 자동으로 생성, 관리되는데 이 경우 직접적인 제어가 까다로웠어요. 특히 인그레스 혹은 게이트웨이를 생성할 때 마다 CLB가 생성되는데, 이는 불필요한 CLB의 증가로 인해 관리가 복잡해지고 비용이 늘어나는 원인이 되기도 했어요.
  1. SSL에 대한 폭 넓은 제어가 필요하다.1, 2번의 경우 인그레스, 게이트웨이가 늘어날 때 마다 해당 요청을 처리하는 도메인에 대한 SSL을 명시해 주고 관리해야 하는데 이것은 불필요한 관리를 유발했어요.
  • 특히 초기의 경우 도메인에 대한 asterisk를 SSL에 등록하지 않고, 우리 회사에서 사용하는 도메인들에 대해 SSL을 발급, 관리했는데 이는 매우 까다로운 절차였어요.
  • 인증서를 관리하기 위해 Google Certificates Manager를 이용하고 있어요. 문제는 DevOps 관점에서 SSL의 변경 혹은 추가가 일어날 때, Terraform을 이용한 생성 및 CLB 할당에서 인증서 관리를 마무리 하고 싶었어요.
  1. L7 뿐만 아니라 L4에 대한 트래픽 제어도 필요하다.
  • 블록체인을 운영하는 회사로서 노드에 대한 RPC 통신을 지원해야 했기 때문에 CLB를 통한 직접적인 관리가 필요했어요.
  1. 회사의 규모가 크지 않은만큼 필요한 수의 CLB만 유지하고 싶었어요.
  • GCP의 CLB는 고가용성을 제공하는 만큼 증가하는 트래픽에 대한 CLB를 분리하여 운영할 필요가 없었기에, 필요한 CLB의 숫자만 유지하고 싶었어요.

GCP에서는 최종적으로 GKE의 서비스에 접근하기 위해 GCP Backend Service와 Network Endpoint Group을 이용해야 해요.

GCP는 이를 지원하기 위해 AutoNeg 쿠버네티스 오퍼레이터를 제공하고 있어요. 이를 통해 쿠버네티스에 배포된 서비스를 바탕으로 GCP Backend Service와 Network Endpoint Group을 생성해 줘요.

이를 위해 쿠버네티스 서비스를 생성할 때 다음의 예시처럼 Backend Service와 NEG에 필요한 정보를 어노테이션에 담아 생성해야 해요.

apiVersion: v1
kind: Service
metadata:
  annotations:
    cloud.google.com/neg: '{"exposed_ports": {"80":{"name": "nginx-neg-svc"}}}'
  name: nginx-neg-test
  namespace: devops
  labels:
    app: nginx-neg-test
spec:
  ports:
    - port: 80
      protocol: TCP
  selector:
    app: nginx

이처럼 서비스에 AutoNeg를 통해 GKE와 CLB를 융합하며 운영했을 때 다음의 큰 문제가 발생했어요.

  • GKE 서비스를 배포할 때, Network Endpoint Group이 생성되는 Zone을 명시할 수 없다.
    • 특히 이는 AWS의 EKS의 경우 AWS Load Balancer Controller와 Target Group Binding 그리고 리스너를 통해 배포되어 있는 EKS Pod에 대한 외부 노출이 가능한데 이에 대해 한번에 지원되지 않는게 의아했어요.

이는 Pod의 Spec이 변경될 때 마다 NEG 위치가 변하는 이슈가 있었고, CLB에서는 변경된 NEG로 다시 설정해 줘야 트래픽의 정상적인 처리가 가능했어요. 즉 Downtime이 발생할 수 있는 위험성을 가지고 있어요.

NEG이 하나만 생성된 모습

이를 해결하기 위해 GCP의 GKE 외부 노출 방법중 Ingress를 이용하여 애플리케이션을 외부로 노출해 봤어요.

# nginx.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  namespace: devops
spec:
  selector:
    matchLabels:
      app: nginx
  replicas: 1
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-neg-test
  namespace: devops
  labels:
    app: nginx-neg-test
spec:
  ports:
    - port: 80
      protocol: TCP
  selector:
    app: nginx
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
  namespace: devops
spec:
  rules:
    - http:
        paths:
          - path: /*
            pathType: ImplementationSpecific
            backend:
              service:
                name: nginx-neg-test
                port:
                  number: 80
가능한 모든 zone에 NEG이 생성된 모습

이 경우 AutoNeg과는 다르게 Ingress로 배포한 서비스는 자동으로 현재 GKE가 배포되어 있는 Region 내의 모든 Zone에 대해 Network Endpoint Groups를 배포하는 것을 확인했고, 머리속에서 이거다! 하는 생각이 떠올랐어요.

GCP의 AutoNeg의 경우 배포되는 Service가 사용할 Network Endpoint Groud에 대해 다음의 규칙을 지키면 이미 생성된 NEG을 활용해요.

💡 Note
Updating NEG description is impossible, so this only works if the NEG is used for populating endpoints from a specific set: [Cluster, service name, namespace, port] which does not change.


그래서 저는 AWS의 ALB Controller + Target Group Binding 노하우를 활용하여 다음과 같이 GKE 애플리케이션 외부 노출 정책을 결정했답니다.

  1. 인프라에 배포할 애플리케이션 요구사항에 맞춰 CLB, Backend Service, Network Endpoint Group을 먼저 생성한다.
  1. Backend Services를 생성하며 생성된 NEG을 연결한다.
  • AutoNeg를 이용하여 서비스를 배포할 때 Backend Service와 NEG의 이름을 명시적으로 사용할 수 있어요.
  • 이를 적용하기 위해 Backend Service를 생성할 때 쿠버네티스 서비스에 명시할 이름을 스펙으로 설정해야 해요.

3. 쿠버네티스 서비스 배포시 Backend Service를 생성한 스펙을 바탕으로 AutoNeg Annotation을 설정하여 배포한다.

이렇게 할 경우 Pod가 재배포 되면서 스펙에 변경사항이 발생되더라도 자동으로 AutoNeg이 서비스를 검사하여 GCP의 NEG과 Backend Service에 해당하는 서비스를 연결하는것을 확인했고 Downtime 없이 Application을 배포할 수 있었어요.

'DevOps > Cloud' 카테고리의 다른 글

GCP Multi Project에 대한 IRSA 구성하기  (0) 2024.09.03

Description

  • 본 문제는 그리디로 풀 수 있다.
  • 한 곡괭이로 캘 수 있는 5번 횟수에 대해 각 구간을 정한다.
    • 모든 미네랄에 대해 곡괭이로 캘 수 있는 횟수까지 대해 구간을 정한다.
      • 곡괭이 1개라면, 5회, 2개라면 10회, …
    • 이 때 곡괭이로 캘 수 있는 수량 보다 미네랄이 적을 수 있으므로, 내가 볼 구간의 length는
      • Math.min( 곡괭이 횟수, 광물 수 / 5 )
        • / 5를 해주는 이유는 한 곡괭이로 5번 밖에 못 캐기 때문이다.
    • 이에 대해 모든 곡괭이를 사용하여 얼마만큼의 비용이 발생하는지 확인한다.
      • 곡괭이가 없어도 일단 계산한다. 나중에 조건을 이용해 사용하지 않으면 되기 때문
  • 각 구간에 대해 값을 모두 구했다면, 이를 돌 곡괭이 > 철 곡괭이 > 다이아몬드 곡괭이 순으로 우선순위 큐에 삽입한다.
    • 돌 곡괭이로 많은 비용이 발생했다면, 이는 광물이 비싸다는 뜻이고, 따라서 더 좋은 곡괭이로 캘 수록 효율이 높아지기 때문.
    • 철 곡괭이도 마찬가지
  • 이 후 우선순위 큐 에서 하나씩 꺼내면서 가지고 있는 제일 좋은 곡괭이로 캤을 때 발생하는 비용을 추가하면 정답
    • 이 때 이미 비용은 계산해 놓았기 때문에, 해당 구간에서 해당 곡괭이 값을 찾으면 된다.

Result

import java.util.*;
class Solution {
    public int solution(int[] picks, String[] minerals) {
        int answer = 0;
        int picksCount = 0;
        PriorityQueue<int[]> pq = new PriorityQueue<>((o1, o2) -> {
            if(o2[2] != 0 || o1[2] != 0)
                return o2[2] - o1[2];
            if(o2[1] != 0 || o1[1] != 0)
                return o2[1] - o1[1];
            return o2[0] - o1[0];
        });
        
        for(int i = 0; i < picks.length; i++)   picksCount += picks[i];
        
        //* 좋은 곡괭이로 캘 수록 피로도는 적어진다.
        //* 좋은 곡괭이로 좋은 광물을 캘 수록 피로도는 적다.
        
        //* 다이아몬드가 많을 때 다이아로 캐는게 좋다.
        //* 철이 많을 때 철로 캐는게 좋다.
        //* 돌이 많을 때 돌로 캐는게 좋다.
        //* 한 번에 5개씩 캐야 하니, 각 구간의 threshold를 구한다.
        
        //* 각 구간에 대해 모든 곡괭이 값을 구하고 최소값으로 갱신
        
        //* 필요한 tresh 칸 수 = 최대: 곡괭이 수, 최소: minerals.length / 5 + 1
        
        //* 각 구간에 대한 곡괭이 사용 값
        // int[][] thresh = new int[Math.min(picksCount, (minerals.length/5) + 1)][3];
        int[] temp = { 0, 0, 0 };
        for(int i = 0; i < minerals.length; i++){
            if(i > 0 && i % 5 == 0){
                pq.offer(new int[]{
                    temp[0], temp[1], temp[2]
                });
                
                temp[0] = 0;
                temp[1] = 0;
                temp[2] = 0;
            }
            
            if(i >= picksCount * 5) break;
            
            switch(minerals[i]) {
                case "diamond":
                    temp[0] += 1;
                    temp[1] += 5;
                    temp[2] += 25;
                    break;
                case "iron":
                    temp[0] += 1;
                    temp[1] += 1;
                    temp[2] += 5;
                    break;
                default:
                    temp[0] += 1;
                    temp[1] += 1;
                    temp[2] += 1;
                    break;
            }
        }
        
        if(temp[0] != 0 || temp[1] != 0 || temp[2] != 0){
            pq.offer(new int[]{
                    temp[0], temp[1], temp[2]
                });
        }
        
        
        while(!pq.isEmpty()){
            int[] current = pq.poll();
            
            for(int i = 0; i < picks.length; i++){
                if(picks[i] != 0){
                    answer += current[i];
                    picks[i]--;
                    break;
                }
            }
        }
        
        return answer;
    }
}

Problem

문제 설명

마인은 곡괭이로 광산에서 광석을 캐려고 합니다. 마인은 다이아몬드 곡괭이, 철 곡괭이, 돌 곡괭이를 각각 0개에서 5개까지 가지고 있으며, 곡괭이로 광물을 캘 때는 피로도가 소모됩니다. 각 곡괭이로 광물을 캘 때의 피로도는 아래 표와 같습니다.

https://user-images.githubusercontent.com/62426665/217975815-63c58d04-0421-4c39-85ce-17613b9c9389.png

예를 들어, 철 곡괭이는 다이아몬드를 캘 때 피로도 5가 소모되며, 철과 돌을 캘때는 피로도가 1씩 소모됩니다. 각 곡괭이는 종류에 상관없이 광물 5개를 캔 후에는 더 이상 사용할 수 없습니다.

마인은 다음과 같은 규칙을 지키면서 최소한의 피로도로 광물을 캐려고 합니다.

  • 사용할 수 있는 곡괭이중 아무거나 하나를 선택해 광물을 캡니다.
  • 한 번 사용하기 시작한 곡괭이는 사용할 수 없을 때까지 사용합니다.
  • 광물은 주어진 순서대로만 캘 수 있습니다.
  • 광산에 있는 모든 광물을 캐거나, 더 사용할 곡괭이가 없을 때까지 광물을 캡니다.

즉, 곡괭이를 하나 선택해서 광물 5개를 연속으로 캐고, 다음 곡괭이를 선택해서 광물 5개를 연속으로 캐는 과정을 반복하며, 더 사용할 곡괭이가 없거나 광산에 있는 모든 광물을 캘 때까지 과정을 반복하면 됩니다.

마인이 갖고 있는 곡괭이의 개수를 나타내는 정수 배열 picks와 광물들의 순서를 나타내는 문자열 배열 minerals가 매개변수로 주어질 때, 마인이 작업을 끝내기까지 필요한 최소한의 피로도를 return 하는 solution 함수를 완성해주세요.


제한사항

  • picks는 [dia, iron, stone]과 같은 구조로 이루어져 있습니다.
    • 0 ≤ dia, iron, stone ≤ 5
    • dia는 다이아몬드 곡괭이의 수를 의미합니다.
    • iron은 철 곡괭이의 수를 의미합니다.
    • stone은 돌 곡괭이의 수를 의미합니다.
    • 곡괭이는 최소 1개 이상 가지고 있습니다.
  • 5 ≤ minerals의 길이 ≤ 50
    • minerals는 다음 3개의 문자열로 이루어져 있으며 각각의 의미는 다음과 같습니다.
    • diamond : 다이아몬드
    • iron : 철
    • stone : 돌

입출력 예

picks minerals result
[1, 3, 2] ["diamond", "diamond", "diamond", "iron", "iron", "diamond", "iron", "stone"] 12
[0, 1, 1] ["diamond", "diamond", "diamond", "diamond", "diamond", "iron", "iron", "iron", "iron", "iron", "diamond"] 50

입출력 예 설명

입출력 예 #1

다이아몬드 곡괭이로 앞에 다섯 광물을 캐고 철 곡괭이로 남은 다이아몬드, 철, 돌을 1개씩 캐면 12(1 + 1 + 1 + 1+ 1 + 5 + 1 + 1)의 피로도로 캘 수 있으며 이때가 최소값입니다.

입출력 예 #2

철 곡괭이로 다이아몬드 5개를 캐고 돌 곡괭이고 철 5개를 캐면 50의 피로도로 캘 수 있으며, 이때가 최소값입니다.

 

NestJS — Test Driven Development (1)

이전에 쓰던 To Do List를 폐기하고, NestJS MVC 환경에서 TDD를 수행하는 법을 작성하려 한다. 크게 Unit Test와 Integration Test로 나누어서 연재할 예정이다. 흔히 서비스의 프론트엔드에서 발생하는 요청

dev-whoan.xyz

계속해서 User 정보를 관리하는 API 서버를 설계하고 개발하겠다.

먼저 TDD를 수행할 때, 정확히는 어떤 서비스를 개발할 때 우리가 무엇을 개발할 것인지 분석해야한다. 쉽게 설명하면, 건물을 지을때 도 설계도를 먼저 만든 뒤 이를 기반으로 건물을 짓는다.

마찬가지로 소프트웨어 또한 개발할 때, 무작정 코드를 작성하기 시작할 것이 아니라 필요한 기능들을 정의한, 내가 만들고자 하는 서비스는 어떤 기능을 제공하는지 그리고 각 기능을 제공하기 위해 무엇이 필요한지 분석하고, 이를 바탕으로 소프트웨어를 개발해 나가기 시작해야 한다.

본 글은 TDD와 관련된 글이기 때문에, 분석이 어떻게 진행되는지와 관련해서 자세히 설명하지 않겠다. 나중에 필요할 경우 해당 글을 연재하겠다. 쉽게 소프트웨어개발방법론 이라고 불리는데, 우리가 흔히 아는 Waterfall, Agile, Unified Process 등이 그 예시이다.

TDD는 간단히 말해 이렇게 우리가 분석한 요구사항, Requirements들에 대해 어떻게 되어야 기능 개발에 성공한것인지, 어떤 실패 케이스가 있는지 찾고 실제로 코드를 실행하여 내가 분석한 Requirements, 즉 해당 기능이 잘 동작하는지 확인하는 것이다.


우리는 User 정보를 관리하는 API를 만들것인데, 간단히 CRUD 위주의 기능을 분석, 설계하고 개발하겠다.

CRUD를 위주로, 우리는 4개의 Primary Requirements를 찾을 수 있다. 만약 RESTful API에 대해 잘 모른다면, 아래 글을 참고하자.

 

[RESTful API 설계하기] 1. RESTful과 API / RESTful API란

[RESTful API 설계하기] 2. REST 특징 [RESTful API] 1. RESTful과 API 어떤 서비스를 개발할 때 본래 필수적인 기능은 아니었지만, 이제는 필수적인 기능이 되어버린 API와 관련하여 글을 작성하려 한다. 이 중

dev-whoan.xyz

Create: 새로운 User를 만들 수 있어야 한다.
Read: 기존의 User 정보를 읽을 수 있어야 한다.
Update: 기존의 User갱신할 수 있어야 한다.
Delete: 기존의 User삭제할 수 있어야 한다.

RESTful API를 설계할 것이기 때문에, 각 CRUD에 대응하는 HTTP Request 및 간략한 요구사항 분석은 다음과 같다.

Create

POST
/user

  • 새로운 User를 생성한다. 이 때 Body Parameter를 통해 새로운 User를 생성하는데 필요한 정보를 전달받는다.
  • 만약, 생성에 성공했을 경우 HTTP 201 Created를 반환한다.
  • 만약, 생성 요청된 email을 가지는 User가 이미 존재할 경우 HTTP 409 Conflict를 반환한다.

Read

GET
/user/email/{email}

  • 기존의 User 정보를 읽어온다. 이 때 특정 User를 구분하기 위해서는 email을 전달받고, 해당 User를 찾아 return한다. 이 때 HTTP 응답 코드는 HTTP 200 Ok를 반환한다.
  • 만약, User가 존재하지 않을 경우 HTTP 204 Empty를 반환한다.

Update

PUT
/user/email/{email}

  • 기존의 User 정보를 갱신한다. 이 때 User를 특정하기 위해 email을 구분자로 사용한다.
  • 만약, 해당하는 email을 가진 User가 없을 경우 HTTP 400 Bad Request를 응답한다.
  • PUT Method이지만, 새로운 Content를 생성하는 경우는 없다.

DELETE

DELETE
/user

  • 기존의 User를 삭제한다. 이 때 User를 특정하기 위해 email을 Body Parameter로 전달받는다.
  • User 삭제에 성공한 경우, HTTP 200 OK와 함께 삭제된 유저의 수를 반환한다.
  • 만약 해당하는 User가 없을 경우 HTTP 204 No Content를 반환한다.

위를 살펴보면, 우리는 성공여부와 실패여부를 미리 가정했다. 뿐만 아니라, 어떤 역할을 해야하는지 Informal한 형태로 분석했다. 이를 통해 우리는 CRUD를 구현할 때, 해당 기능이 어떤것을 수행하는지 미리 알 수 있다. 더 나아가 구현하지 않았음에도, 어떤 응답을 받을지 이미 알고있다.

이를 쉽게 말하면, 우리는 Test Case를 구현하기 위해 어떤 행동들을 해야 하는지 벌써 알고있다.

이제 실제 구현을 위해 코드를 작성해보자. 시스템 구현을 위한 외부 소프트웨어는 다음과 같다.

  • MongoDB, Mongoose
    • MongoDB: User 정보를 저장하는데 사용될 Database
    • Mongoose: MongoDB를 위한 TypeODM Library.
  • Redis
    • MongoDB에서 가져온 Data를 캐싱하는데 사용할 Memory Database

사실 Redis는 굳이 사용하지 않아도 상관없지만, 요즘은 Redis를 사용하는 것이 추세이기 때문에, 작성하였다.

우리 시스템의 생김새는 다음과 같다.

Repository도 사용할까 했는데, TDD가 주 목적이기 때문에 제외하였다. Controller를 통해 user에 대한 요청이 발생하면, Service를 통해 Model로 부터 데이터를 생성,반환,갱신,삭제 를 수행한다. 이 때 Redis를 이용해 만약 데이터가 캐싱되어 있을 경우, 해당 데이터를 반환한다.

이전 글에서도 말했지만, 우리는 Test를 다음과 같이 수행할 예정이다.

Unit Test

  • Service가 Model로부터 데이터를 잘 가져오는지 테스트
  • 즉 Service를 테스트하기 위한 Controller 테스트 코드 작성
    Integration Test
  • Controller를 통해 요청을 보낼 때, 데이터를 잘 View로 반환하는지 테스트
  • 즉 E2E 테스트 수행

사용할 user의 schema는 다음과 같다.

import { Prop, SchemaFactory, SchemaOptions } from '@nestjs/mongoose';
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
import { Document } from 'mongoose';

const options: SchemaOptions = {
    collection: 'users-tdd',
  timestamps: { createdAt: 'created_at', updatedAt: 'updated_at' },
  id: true,
};

export class User extends Document {
    //* User의 Email
  @Prop({
    require: true,
    unique: true,
  })
  @IsString()
  @IsEmail()
  @IsNotEmpty()
  email: string;

    //* User가 사용할 Nickname
  @Prop({
    require: true,
  })
  @IsString()
  @IsNotEmpty()
  nickname: string;

    //* User가 사용할 Password
  @Prop({
    require: true,
  })
  @IsString()
  @IsNotEmpty()
  password: string;

  //* Let Redis Use This DTO
  //* Check redis-manager-service.ts
  readonly readOnlyData: {
    id: string;
    email: string;
    nickname: string;
  };
}

//* Virtual Fields
export const _UserSchema = SchemaFactory.createForClass(User);

_UserSchema.virtual('readOnlyData').get(function (this: User) {
  return {
    id: this.id,
    email: this.email,
    nickname: this.nickname,
  };
});

_UserSchema.set('toObject', { virtuals: true });
_UserSchema.set('toJSON', { virtuals: true });

export const UserSchema = _UserSchema;

export interface UserReadOnly {
  id: string;
  email: string;
  nickname: string;
}

Service Code 작성

Unit Test를 진행할 때, 현재 테스트 할 기능 단위에 필요한, 의존성을 갖는 기능들은 모두 잘 동작한다고 가정하고 테스트를 진행한다. 그래서 실제로는 User의 코드와 User에서 사용할 Redis의 코드는 별개로, Redis는 주어진 Interface대로 잘 동작할 것이다. 고 가정한다. 하지만 우리는 아직 Unit Test를 위해 이러한 가정을 어떻게 만드는지 모르기 때문에, Redis를 통해 먼저 Mock Data (Object)를 만드는 것을 연습한 뒤, User를 통해 실제 Unit Test를 진행하겠다. 즉 오늘은 Redis를 통해 Mock과 친해지고, 다음 글에서 본격적인(사실 이번 글도 본격적인 TDD다.) TDD를 진행하겠다.

Redis의 사용 목적은 Data Caching을 통해 Server 및 DB의 부하를 줄이는 것이다. 즉, 다음과 같은 기능을 수행할 수 있어야 한다.

Set Cache: DB로 부터 가져온 데이터를 캐싱할 수 있어야 한다.
Get Cache: 저장된 캐시를 반환할 수 있어야 한다.
Delete Cache: 저장된 캐시를 삭제할 수 있어야 한다.
Reset Cache: 현재 저장된 모든 캐시를 초기화할 수 있어야 한다.

이에 대해 함수를 다음과 같이 설계하겠다.

interface setCache {
  (key: string, value: User): boolean;
}

interface getCache {
  (key: string): User;
}

interface deleteCache {
  (key: string): boolean;
}

interface resetCache {
  (): boolean;
}

인터페이스의 경우 위처럼 생겼는데, 사실 그냥 넣어봤다. 실제 코드를 구현하면 다음과 같다.

따로 작성 설명을 하지 않는 이유는, nestjs에서 redis를 사용할 때, 외부 라이브러리를 통해 빠르게 사용할 수 있고, 무엇보다 https://docs.nestjs.com/microservices/redis#redis 여기에 가면 자세하게 나온다.

redis-manager.module.ts

import { CacheModule, Module } from '@nestjs/common';
import { RedisManagerService } from './redis-manager.service';
import * as redisStore from 'cache-manager-ioredis';

@Module({
  imports: [
    CacheModule.register({
      store: redisStore,
      host: 'localhost',
      port: 6399,
      ttl: 0,
    }),
  ],
  providers: [RedisManagerService],
  exports: [RedisManagerService],
})
export class RedisManagerModule {}

redis-manager.service.ts

import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { User } from '../user/data/user.schema';

@Injectable()
export class RedisManagerService {
  constructor(
    @Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,
  ) {}
  /**
   * Set the object in Redis
   * @param key Key to identify the object
   * @param value User to set for the key
   * @returns true when succeeded
   */
  async setCache(key: string, value: User): Promise<boolean> {
    await this.cacheManager.set(key, value);
    return true;
  }

  /**
   * Return the object that matches key in Redis
   * @param key Key to identify the object
   * @returns User when the item exist in Redis
   * @returns undefined when the item is not exist in Redis
   */
  async getCache(key: string): Promise<User> {
    const result = await this.cacheManager.get(key);
    if (!result) {
      return null;
    }

    return result as User;
  }

  /**
   * Delete the object that matches key in Redis
   * @param key Key to delete from Redis
   * @returns true when succeeded
   */
  async deleteCache(key: string): Promise<boolean> {
    await this.cacheManager.del(key);
    return true;
  }

  /**
   * Reset all the data stored in Redis
   * @returns true when succeeded
   */
  async resetCache(): Promise<boolean> {
    await this.cacheManager.reset();
    return true;
  }
}

주석을 달아놓았기 때문에 코드를 읽는데 문제 없을것이라 생각한다.


이제 Redis에 대한 Unit Test를 진행해보자. 이 때 먼저 고려해야할 것은 Redis를 사용하기 위한 의존 기능은 무엇이 있는가?이다. 이것을 찾지 못한다면 Redis에 대한 TDD를 수행하기 어렵다.

NestJS는 고맙게도 해당 모듈이 어떤 의존성을 가지는지 우리는 쉽게, 정확히는 이미 알고있다.

먼저 redis-manager.module.ts 를 통해 우리의 Redis 모듈이 외부의 어떤 기능을 제공받는지 확인할 수 있다.

...
  **imports: [
    CacheModule.register({
      store: redisStore,
      host: 'localhost',
      port: 6399,
      ttl: 0,
    }),
  ],
    providers: [],**
...

이는 Redis Module이 동작하기 위해 필요한 외부의 Dependency이다. 해당 기능이 하나라도 동작하지 않는다면, Redis Module은 실행되지 않을것이다. (앱 자체가 실행되지 않을것이다.)

현재 우리는 Redis에 대한 Unit Test를 수행할 것이고, 따라서 대상인 redis-manager.service.ts에 어떤 의존성이 존재하는지 확인해보자.

export class RedisManagerService {
  constructor(
    **@Inject(CACHE_MANAGER)
    private readonly cacheManager: Cache,**
  ) {}
...
}

Redis Service는 cacheManager 라는 것을 의존성으로 갖는데, 이는 CACHE_MANAGER로, 다음의 역할을 수행한다.

cacheManager.set: Redis에 캐시를 저장한다.
cacheManager.get: Redis로 부터 캐시를 획득한다.
cacheManager.del: Redis로 부터 캐시를 삭제한다.
cacheManager.reset: Redis의 캐시를 삭제한다.

즉, 해당 의존성을 통해 실제 레디스에 데이터를 저장하고 삭제한다.

위를 토대로 우리는 redis-manager.module.tsredis-manager.service.ts로 부터 다음 의존성을 찾을 수 있다.

  1. Redis 연결을 수행하기 위한 CacheModule.register 동작이 필요하다.
  2. Redis에 실제 데이터를 저장,삭제,획득,초기화 등을 수행하는 CACHE_MANAGER가 필요하다.

이제 진짜 Redis의 Test 코드를 작성해 보자. 지난 글을 바탕으로, 우리는 다음과 같은 초기 코드를 작성할 수 있다.

import { Test } from '@nestjs/testing';
import { RedisManagerService } from '../redis-manager.service';

describe('RedisManagerController', () => {
  let service: RedisManagerService;
  const key = 'file/yes-data';

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [],
    }).compile();

    service = moduleRef.get<RedisManagerService>(RedisManagerService);
  });
});

하지만 위 코드대로 test를 수행한다면, 다음 에러가 발생할 것이다.

npm test redis-manager.controller                                                                                                                 1 ✘  23:44:22 

> tdd@0.0.1 test
> jest "redis-manager.controller"

 FAIL  src/redis-manager/test/redis-manager.controller.spec.ts
  RedisManagerController
    ✕ should be defined (5 ms)

  ● RedisManagerController › should be defined

    **Nest can't resolve dependencies of the RedisManagerService (?). Please make sure that the argument CACHE_MANAGER at index [0] is available in the RootTestModule context.

    Potential solutions:
    - Is RootTestModule a valid NestJS module?
    - If CACHE_MANAGER is a provider, is it part of the current RootTestModule?
    - If CACHE_MANAGER is exported from a separate @Module, is that module imported within RootTestModule?
      @Module({
        *imports: [ /* the Module containing CACHE_MANAGER */ ]*
      })**

       7 |
       8 |   beforeEach(async () => {
    >  9 |     const moduleRef = await Test.createTestingModule({
         |                       ^
      10 |       providers: [RedisManagerService],
      11 |     }).compile();
      12 |

      at TestingInjector.lookupComponentInParentModules (../node_modules/@nestjs/core/injector/injector.js:247:19)
      at TestingInjector.resolveComponentInstance (../node_modules/@nestjs/core/injector/injector.js:200:33)
      at TestingInjector.resolveComponentInstance (../node_modules/@nestjs/testing/testing-injector.js:19:45)
      at resolveParam (../node_modules/@nestjs/core/injector/injector.js:120:38)
          at async Promise.all (index 0)
      at TestingInjector.resolveConstructorParams (../node_modules/@nestjs/core/injector/injector.js:135:27)
      at TestingInjector.loadInstance (../node_modules/@nestjs/core/injector/injector.js:61:13)
      at TestingInjector.loadProvider (../node_modules/@nestjs/core/injector/injector.js:88:9)
      at ../node_modules/@nestjs/core/injector/instance-loader.js:49:13
          at async Promise.all (index 3)
      at TestingInstanceLoader.createInstancesOfProviders (../node_modules/@nestjs/core/injector/instance-loader.js:48:9)
      at ../node_modules/@nestjs/core/injector/instance-loader.js:33:13
          at async Promise.all (index 1)
      at TestingInstanceLoader.createInstances (../node_modules/@nestjs/core/injector/instance-loader.js:32:9)
      at TestingInstanceLoader.createInstancesOfDependencies (../node_modules/@nestjs/core/injector/instance-loader.js:21:9)
      at TestingInstanceLoader.createInstancesOfDependencies (../node_modules/@nestjs/testing/testing-instance-loader.js:9:9)
      at TestingModuleBuilder.createInstancesOfDependencies (../node_modules/@nestjs/testing/testing-module.builder.js:97:9)
      at TestingModuleBuilder.compile (../node_modules/@nestjs/testing/testing-module.builder.js:63:9)
      at Object.<anonymous> (redis-manager/test/redis-manager.controller.spec.ts:9:23)

Test Suites: 1 failed, 1 total
Tests:       1 failed, 1 total
Snapshots:   0 total
Time:        1.484 s, estimated 2 s
Ran all test suites matching /redis-manager.controller/i.

해당 오류를 자세히 보면, Redis Module은 CACHE_MANAGER를 의존성으로 갖는데, 찾을 수 없다는 내용이다.

Nest can't resolve dependencies of the RedisManagerService (?). Please make sure that the argument CACHE_MANAGER at index [0] is available in the RootTestModule context.

imports: [ / the Module containing CACHE_MANAGER / ]

따라서, 우리는 CACHE_MANAGER에 대한 Mock 을 만들어줘야 한다.

다시 말하면, cache-manager, cache-manager-ioredis가 수행하는 Redis Software에 데이터를 저장하고, 읽고, 삭제하는 등의 Test Mockup을 만들어야 한다.


Mockup을 만들기 전에, 내가 사용하는 Test Directory 구조는 다음과 같다.

 

  • mocks
    • Mock과 관련된 자료형, 함수, 클래스 등을 저장할 폴더
  • test
    • 실제 test 코드를 작성할 폴더

이외에도 다음이 있다.

  • data
    • 해당 기능이 가지는 data(schema, repository, dto, … 등)을 저장하는 폴더
  • test/stubs
    • 해당 기능을 테스트하는데 필요한 mock 객체를 저장할 폴더

본격 Mock 만들기 — User Mock Data

Mock
모조품, 가짜

Mock을 만드는 이유는 우리가 테스트할 기능에 필요한 외부 기능(의존성)이 마치 잘 동작하는것처럼 보여주기 위해 만드는 것이다. 다시 정리하자면, 우리 시스템의 Redis는 cache-manager-ioredis, cache-manager 라이브러리가 제공하는 실제 Redis 소프트웨어 연결 및 실제 데이터 작성, 읽기, 삭제 등이 잘 동작하는 것으로 가정해야 하기 때문이다. 우리는 Redis Manager Module을 작성함으로써 User Module(더 나아가 Redis를 이용하는 모든 모듈)에서 발생하는 이러한 캐싱 동작에 대해, Redis 소프트웨어와 실제 통신함으로써 그 동작이 수행되도록 하는 역할을 한다. 따라서, 실제 테스트 코드에서는 이러한 연결 및 Redis의 라이브러리에서 제공하는 기능(get, set, ...)은 100% 잘동작한다고 가정, 우리의 코드 setCache, getCache, ...를 테스트하기 위해 만드는 것이다. 즉, 이러한 가정을 제공하기 위한 Mock을 만드는 것이다.

우리는 먼저 Redis의 CACHE_MANAGER에 대한 Mock, 즉 진짜처럼 동작하는 가짜를 만들어야한다. 그런데 생각해보니 Redis는 Cache Manager를 통해 User를 캐싱하고, 획득하고, 삭제하는데 이때 User와 관련된 자료형이 필요하다.

따라서 우리는 Redis 기능을 만족시키기 위해 User의 Mock Data 또한 만들어줘야 한다.

사용할 User는 우리가 위에서 정의한 user.schema.ts의 모든 Property를 가져야 하고, 동시에 필요한 자료만 있으면 되기 때문에 다음과 같이 정의하겠다.

해당 파일은 user/test/stubs/user.stub.ts다.

import { User } from '../../../user/data/user.schema';

//* 아래의 속성들은 분명 user.schema.ts에 정의되어있다.
export const mockUserDto: any = {
  id: 'test-id',
  email: 'test@test.com',
  nickname: 'test-nickname',
    password: 'test-password',
};

//* mockUserStub을 통해 User 모조품을 반환한다.
export const mockUserStub = (): User => {
  return mockUserDto;
};

우리는 mockUserDto를 통해 하나의 Dto(자료형)를 나타낼 것이고, mockUserStub을 통해 실제 User가 반환되는 결과를 나타낼 것이다.

본격 Mock 만들기 — CACHE_MANAGER

우리의 CACHE_MANAGER는 위에서 찾은것과 같이 set,get,del,reset을 수행하고, 다음과 같다.

이 때 주의깊게 살펴볼 것은 get을 통해 User를 반환하는데, 위에서 정의한 mockUserStub을 그 결과로 반환하는 것에 집중하자.

우리는 해당 CACHE_MANAGER를 Value 형태로 작성하여 사용할 것이다.

import { mockUserStub } from '../../user/test/stubs/user.stub';

export const CACHE_MANAGER = {
  set: jest.fn().mockResolvedValue(true),
    //* 위에서 정의한 Test용 가짜 User를 반환하고 있다!
  **get: jest.fn().mockResolvedValue(mockUserStub()),**
  del: jest.fn().mockResolvedValue(true),
  reset: jest.fn().mockResolvedValue(true),
};

이제 가짜 CACHE_MANAGER를 만들었으니 이것을 우리가 수행할 test code에서 제공해야한다.

즉, redis-manager.controller.spec.ts의 모듈을 만드는 과정에서 해당 CACHE_MANAGER를 DI해줘야 한다. 우리는 **기능을 제공하기 때문에 provides 아래에 제공하면 된다.**

이 때 imports에 사용할 것인지 등은 우리의 코드에 필요에 따라 바꾸면 된다.

//* 변경 전
...
let service: RedisManagerService;
...
const moduleRef = await Test.createTestingModule({
  providers: [
    RedisManagerService,
  ],
}).compile();
service = moduleRef.get<RedisManagerService>(RedisManagerService);
...

//* 변경 후
import { CACHE_MANAGER as MOCK_CACHE_MANAGER } from '../__mocks__/redis-manager.service';
...
let service: RedisManagerService;
**let cache: Cache;**
...
const moduleRef = await Test.createTestingModule({
  providers: [
    **{
      provide: CACHE_MANAGER,
      useValue: MOCK_CACHE_MANAGER,
    },**
    RedisManagerService,
  ],
}).compile();
service = moduleRef.get<RedisManagerService>(RedisManagerService);
**cache = moduleRef.get(CACHE_MANAGER);**
...

이 때 provide할 대상에 대해 useValue, useClass, useFactory 등을 사용할 수 있는데, 그 차이는 다음과 같다.

https://docs.nestjs.com/fundamentals/testing

  • useClass: 객체(제공자, 가드 등)를 재정의할 인스턴스를 제공하기 위해 인스턴스화될 클래스를 제공합니다.
  • useValue: 객체를 재정의할 인스턴스를 제공합니다.
  • useFactory: 객체를 재정의할 인스턴스를 반환하는 함수를 제공합니다.

이제 다시 한 번 npm test redis-manager.controller를 실행해 보자.


Unit Test 코드 작성하기

이제 실제 Redis가 잘 동작하는지, Redis에 정의된 setCache, getCache, delCache, resetCache를 테스트하는 코드를 작성하면 된다.

import { CACHE_MANAGER } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { Cache } from 'cache-manager';
import { User } from '../../user/data/user.schema';
import { mockUserDto, mockUserStub } from '../../user/test/stubs/user.stub';
import { RedisManagerService } from '../redis-manager.service';
import { CACHE_MANAGER as MOCK_CACHE_MANAGER } from '../__mocks__/redis-manager.service';

describe('RedisManagerController', () => {
  let service: RedisManagerService;
  let cache: Cache;
  const key = 'file/yes-data';

  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      providers: [
        {
          provide: CACHE_MANAGER,
          useValue: MOCK_CACHE_MANAGER,
        },
        RedisManagerService,
      ],
    }).compile();

    service = moduleRef.get<RedisManagerService>(RedisManagerService);
    cache = moduleRef.get(CACHE_MANAGER);
  });

  it('then it should be defined', () => {
    expect(service).toBeDefined();
  });

  describe('when setCache is called', () => {
    let data: boolean;

    beforeEach(async () => {
      data = await service.setCache(key, mockUserDto as User);
    });

    test('then it should call redis.set', () => {
      expect(cache.set).toBeCalledWith(key, mockUserDto as User);
    });

    test('then it should return a "true"', () => {
      expect(data).toEqual(true);
    });
  });

  describe('when getCache is called', () => {
    let data: User;

    //* Call the function through the controller
    beforeEach(async () => {
      data = await service.getCache(key);
    });

    //* Controller may call the function through the service
    test('then it should call redis.get', () => {
      //* With the Given Parameter
      expect(cache.get).toBeCalledWith(key);
    });

    //* And the result should be microServiceGetDataStub()
    //* Which is Mock Data
    test('then it should return a User Info', () => {
      expect(data).toEqual(mockUserStub());
    });
  });

  describe('when deleteCache is called', () => {
    let data: boolean;

    beforeEach(async () => {
      data = await service.deleteCache(key);
    });

    test('then it should call redis.del', () => {
      expect(cache.del).toBeCalledWith(key);
    });

    test('then it should return a "true"', () => {
      expect(data).toEqual(true);
    });
  });

  describe('when resetCache is called', () => {
    let data: boolean;

    beforeEach(async () => {
      data = await service.resetCache();
    });

    test('then it should call redis.reset', () => {
      expect(cache.reset).toBeCalledWith();
    });

    test('then it should return a "true"', () => {
      expect(data).toEqual(true);
    });
  });
});
  • 결과

P.S. 본 글에 사용된 dependency 설치

npm install @nestjs/config
@nestjs/microservices
npm install --save class-validator class-transformer
npm install @nestjs/mongoose mongoose
npm install cache-manager cache-manager-ioredis
npm install -D @types/cache-manager @types/cache-manager-ioredis

' > NestJS' 카테고리의 다른 글

NestJS — Test Driven Development (1)  (0) 2023.03.10

본 논문은 쿠버네티스의 전신인 Borg System 논문이다.

오늘의 요약

  • 즉 구글은 수 십만 개의 잡을 돌리는 클러스터 매니저인 Borg를 개발했다.
  • 해당 잡 들은 수 천개의 서로 다른 애플리케이션에서 발생하는데
    • 이들이 어떻게 관리되는지 시스템 유저(서비스 개발자)는 알 필요 없이 Borg System을 만들었다.
      • 자원 관리에 대한 세부 사항 (CPU, Memory, Disk, Network 할당 등)
      • 어느 컴퓨터에서 개발된 서비스가 동작할지
      • 실패되어도 다시 살아난다. (Self Healing)
      • 실패 처리를 유저가 직접하지 않아도 된다.
  • 구글은 이러한 시스템을 만들기 위해 구글이 실행하는 서비스를 두 가지로 분류했다.
    • Long Running Services
      • End User에게 제공되는 Production
        • GMail, Google Search, Google Docs, 그리고 구글 내부의 BigTable 등
      • 즉 절대 죽어서는 안되는, 수 µs ~ 몇백 ms 라는 짧은 latency를 가져 사용자에게 불편함을 주면 안되는 서비스
      • 매우 많은 리소스를 사용한다.
      • 쉽게 말해 Production Level
    • Batch Jobs
      • 짧게는 수 초, 길게는 수 일 동안 작업되는 서비스
      • 단기적 성능 변동에 훨씬 덜 민감하다.
      • 쉽게 말해 Non-Production Level
  • 해당 작업을 수행하는 머신들은 Cell이라 불리는 곳에 저장된다.
    • 셀 안에 많은 머신이 포함되며, 해당 머신들은 고성능 데이터 센터 규모로 정의된 단일 클러스터에 포함된다.
      • 단일 클러스터에 속하는 머신들은
        • High Performance, Datacenter Scale Network 구조로 정의
        • 해당 클러스터는 단일 데이터센터 빌딩에 존재하고, 이러한 빌딩들의 집합이 site를 만든다.
        • 클러스터는 일반적으로 하나의 거대한 셀의 주인이고(hosts)
          • 몇 개의
            • 작은 스케일의 테스트를 갖거나
            • 특수 목적 셀을 가진다.
        • Single Point of Failure를 피한다.
      • 중간 사이즈의 셀들은
        • 테스트 셀을 제외하고 대략 1만대 이상의 머신을 가지는데, 몇 몇은 더 큰 규모를 가진다.
        • 해당 머신들은 서로 이질적인 많은 디멘션을 가지는데,
          • CPU, RAM, Network, Disk 등
          • Processor Type, Performance, 외부 IP 주소 등
    • 놀랍게도 Borg System은 이러한 차이점 및 특징을 시스템 사용자(개발자 등)에게 철저히 숨겨 개발자들이 본연의 업무(개발)에만 집중할 수 있도록 한다.

Abstract

  • 구글의 Borg 시스템은 수 십만 개의 잡을 돌리는 클러스터 매니저다.
    • 해당 잡 들은 수 천 개의 서로 다른 애플리케이션으로 부터 발생하는데
    • 해당 애플리케이션은 수 만 개의 머신에서 동작한다.
  • Borg 시스템은 High Utilization을 달성하는데
    • 제어 허용(admission control), 효율적인 작업 패킹, 과다 할당(over commitment to machine), 그리고 프로세스 레벨의 성능 격리를 통한 머신 공유(자원 공유)
    • 를 조합하여 사용함으로써
  • Bog 시스템은 고가용성 runtime 애플리케이션을 지원한다.
    • Fault Recovery 시간을 최소화하고
    • 관련있는 실패할 가능성을 줄이는 스케줄링 정책을 수립함으로써
  • Borg 시스템은 사용자의 삶을 단순화한다.
    • Declarative Job Specification Language, Name Service 통합, 실시간 작업 모니터링 및 시스템 동작을 분석하고 시뮬레이션하는 도구를 제공하여
  • 우리는 Borg System의 아키텍처와 Features, 중요한 디자인 결정, Borg System의 몇몇 정책 결정에 대한 질적인 분석, 10년간의 운영 경험에서 얻은 교훈에 대한 질적 교훈을 제시한다.

1. Introduction

  • Borg System이라 부르는 클러스터 관리 시스템은
    • 관리하고, 스케줄하고, 시작하고, 재시작하고, 그리고 모니터링한다.
    • 구글이 실행하는 모든 애플리케이션의 Full Range로부터
  • 해당 본문은 이것이 어떻게 되는지 설명한다.
  • Borg는 세 가지 주요 장점을 제공한다.
    • 자원 관리에 대한 세부 사항과 실패 처리와 관련된 세부 사항을 숨김으로써, 유저는 애플리케이션 개발에 집중할 수 있다.
    • 매우 높은 신뢰성과 가용성을 바탕으로 동작하며, Borg에서 관리되는 애플리케이션 또한 이를 제공받는다.
    • 수만 대의 시스템에서 워크로드를 효율적으로 실행할 수 있도록 한다.
  • Borg는 이러한 이슈를 제기한 첫 번째 시스템이 아니다.
    • 하지만 Borg와 같이 큰 스케일, 탄력성과 완전성,에서 실행되는 것은 몇 없다.
  • 해당 논문은 이러한 주제를 중심으로 구성되어 있으며
  • 10년 이상 프로덕션 환경에서 Borg를 운영하면서 얻은 일련의 정성적 관찰로 결론을 내린다.
Untitled

2. The User Perspective

  • Borg 시스템의 유저는 구글의 개발자와 시스템 관리자(Site Reliability Engineers)다.
    • 구글의 애플리케이션과 서비스들을 실행하는
  • 사용자들은 그들의 일을 Borg의 jobs 폼에 맞춰 제출한다.
    • jobs: 하나 이상의 tasks로 구성
    • tasks: 모두 동일한 바이너리 프로그램을 실행
  • 각각의 job은 Borg Cell에서 실행되는데
    • Borg Cell은 하나의 유닛처럼 관리되는 머신의 집합이다.
  • 본 단락에서 중요한 것은 Borg가 유저에게 어떻게 노출되느냐다.
    • 유저 관점에서 Borg를 어떻게 사용하는지

2.1 The Workload

  • Borg Cells는 두 개의 이질적인 주요 파트에서 실행된다.
  • 첫 번 째는, “절대로” 죽어서는 안되고, 수 µs ~ 몇백 ms 라는 짧은 latency를 가지는, Long Running Services다.
    • Gmail, Google Docs, Web Search와 같이 End User에게 제공되는 Product들, 그리고 구글 내부의 인프라 서비스(Big Table) 등이 그 예다.
  • 두 번 째는, 수 초 ~ 수 일 안에 완료되는 batch jobs다.
    • 이러한 작업은 단기적인 성능 변동에 훨씬 덜 민감하다.
  • 워크로드 혼합은 Borg Cell 마다 다르며
    • Borg Cell은 다양한 애플리케이션의 혼합을 실행하는데
      • 애플리케이션은 작업에 따라 다르다. (주요 테넌트에 따라 다양한 애플리케이션 혼합을 실행)
        • 예: 일부 셀은 배치 집약적임
      • 또한 실행 시간에 따라 다르다.
      • 예를 들어
        • 배치 작업은 빠른 시간 안에 실행 되었다가 종료되고
        • End User Facing(Proudcts)는 주간 사용 패턴을 보인다. (오래 사용)
  • Borg 시스템은 이들 케이스 모두를 동일하게 잘 처리할 필요가 있다.
  • 대표적인 Borg workload는 2011년 5월부터 월 단위 추적이 가능한데, 이는 매우 잘 광범위하게 분석되었다.
  • 많은 애플리케이션 프레임워크는 Borg의 Top에서 만들어졌다.
    • Internal Map Reducing System, Flume Java, Millwhell, Pregel 등
  • 이들 대부분은 컨트롤러를 가지고 있는데
  • 구글의 분산 저장소 시스템, GFS, CFS, Bigtable, Megastore 모두 Borg에서 동작한다.
  • 본 문단에서 명시하는 것은
    • workload를 두 가지로 분류 가능한데
    • production 레벨
      • Long Running Services
    • non-production 레벨
      • Batch Jobs
  • 대표적인 셀에
    • production 레벨의 잡은 70% 정도의 CPU, 55%의 메모리를 할당받았으며
      • 이 중 60%, 85% 를 사용중이다.
    • 나머지를 non-production이 할당 및 사용
    • 이 실제 할당량과 사용량의 불일치는 5.5단락에서 자세하게 설명된다.

2.2 Clusters and Cells

셀의 머신은 이를 연결하는 고성능 데이터 센터 규모의 네트워크 패브릭으로 정의된 단일 클러스터에 속한다.

  • 단일 클러스터에 속하는 셀의 머신들은
    • High Performance, Datacenter Scale Network 구조로 정의되는데
    • 해당 클러스터는 단일 데이터센터 빌딩에 존재하고, 이러한 빌딩들의 집합이 site를 만든다.
    • 클러스터는 일반적으로 하나의 거대한 셀의 주인이고(hosts)
      • 몇 개의
        • 작은 스케일의 테스트를 갖거나
        • 특수 목적 셀을 가진다.
    • 우리는 몇 개의 단일 실패 지점을 피한다.
  • 우리의 중간 셀의 사이즈는 테스트 셀을 제외하여 대략 1만대의 머신의 크기인데
    • 몇 몇은 훨씬 더 크다.
    • 해당 머신들은 이질적인, 많은 디멘션에 속하는데
      • CPU, RAM, Disk, Network의 크기가 다르거나
      • processor type이 다르거나
      • performance가 다르거나
      • Flash Storage 혹은 외부 IP 주소 등 능력이 다르거나
  • Borg 시스템은 이러한 차이점 대부분으로부터 사용자를 격리한다.(사용자는 신경쓸 필요 없다.)
    • 셀에서 작업을 실행할 위치를 결정하고
    • 리소스를 할당하고
    • 프로그램 및 기타 종속성을 설치하고
    • 상태를 모니터링하고
    • 실패할 경우 다시 시작하는 등

이전에 쓰던 To Do List를 폐기하고, NestJS MVC 환경에서 TDD를 수행하는 법을 작성하려 한다.

크게 Unit Test와 Integration Test로 나누어서 연재할 예정이다.


간략한 MVC

흔히 서비스의 프론트엔드에서 발생하는 요청을 처리하기 위해 우리는 백엔드의 시스템을 MVC 디자인 패턴을 이용해 설계하곤 한다. MVC 패턴을 이용해 디자인 하면 객체 지향 설계 및 개발이 쉬워지기 때문인데, 간략히 설명하면 Model, View, Controller 각각의 객체(시스템 컴포넌트)들은 각자의 책임과 의무에만 집중하면 되는, 쉽게 말해 시스템을 ‘분리’하여 설계하고 개발할 수 있기 때문이다.

NestJS에서는 Module, Controller, Service를 통해 쉽게 MVC Design을 이용하여 애플리케이션을 구현할 수 있다.

일반적인 MVC 모델에서의 각 역할은 다음과 같다.

Controller
Frontend(View)에서 발생하는 요청에 따라 서버의 Model을 활용하여 알맞게 반환하는 로직을 수행한다.
Model
시스템이 가지고 있는 데이터로, Database, File System, Analysis 등 서비스와 관련된 모든 데이터를 가리킨다.
Service
MVC 모델에는 없는 요소지만, 서비스 패턴으로 거의 함께 설계된다. 컨트롤러가 라우팅 기능(요청과 응답)에 집중할 수 있도록 실제 Model을 관리하는 비즈니스 로직을 수행한다.

이것을 NestJS에서는 Module을 통해 쉽게 설계할 수 있는데, 그 생김새는 다음과 같다.

데이터 관리에 더 집중하기 위해 Repository를 추가하여 사용하기도 한다.

이렇게 각각 설계된 모듈은 NestJS의 imports를 통한 Dependency Injection를 통해 사용 가능하다.

위 예시와 같이 UserModule이 캐싱 기능 사용을 위해 RedisModule이 필요하다면, 의존성 주입을 통해 사용할 수 있다.

@Module({
  imports: [
    ConfigModule.forRoot(),
        //* Redis 기능 사용을 위한 Dependency Injection
    **RedisManagerModule**,
        //* Mongoose 기능 사용을 위한 D.I.
    **MongooseModule.forFeature([{ name: User.name, schema: _UserSchema }]),**
  ],
  controllers: [UserController],
  providers: [UserService, UserRepository],
  exports: [UserService],
})
export class UserModule {}

여기까지 NestJS가 어떻게 동작하는지 간략하게 알아봤다면, 쉬운 예시를 통해 NestJS에서 TDD를 어떻게 수행하는지 다음 장 부터 본격적으로 설명하기 전에 간략히 해 보겠다.

전체적으로 우리가 구현할 것은 User Module로, 사용자 정보를 관리하는 웹서버를 만들것이다. 최종적으로는 User Module에 Mongoose와 Redis Module을 연결할 것인데, 이에 대해 Unit TestIntegration Test를 수행할 예정이다.

웹 서버는 RESTful API 서버를 만들 것인데, 관련된 내용은 아래 글을 확인해주면 감사하겠다.

 

[RESTful API 설계하기] 1. RESTful과 API / RESTful API란

[RESTful API 설계하기] 2. REST 특징 [RESTful API] 1. RESTful과 API 어떤 서비스를 개발할 때 본래 필수적인 기능은 아니었지만, 이제는 필수적인 기능이 되어버린 API와 관련하여 글을 작성하려 한다. 이 중

dev-whoan.xyz


먼저 Unit Test는 말 그대로 단위 테스트다. 우리가 어떤 기능을 설계할 때, 해당 기능이 동작해야 다른 기능이 완전하게 동작하는 것을 알 수 있다. 우리의 UserModule로 설명하면, Service가 죽을 경우 Controller는 어떤 요청을 받더라도 정상적인 응답을 할 수 없게된다.

즉 이러한 상황을 사전에 방지하기 위해, Controller, Service, Model이 모두 각각 잘 동작하는지 확인하는 것이 그 목적이다. 다시 말하면, Controller에 각 라우팅이 잘 되는지 확인하고, Service의 Model 접근 등 기능이 잘 동작하는지 확인하고, 최종적으로 Model 또한 잘 동작하는지 확인하는 것이다.

그런데, 우리 서버는 웹 서버이므로, Controller가 잘 동작한다는 것은 실제 외부 요청에 대해 응답이 잘 반환되는지 확인하는 것이다. 즉 Controller의 실질적인 동작 테스트는 e2e (End to End) 테스트 다시 말해 Integration Test를 통해 확인할 것이다.

Service가 잘 동작한다는 것은, Model로 부터 우리가 기대한 모델을 잘 가져와서 Controller로 잘 반환했다는 것이기 때문에, Controller에서의 Service 호출이 잘 동작하는 것을 확인할 것이다.

마지막으로, Model의 경우 Redis의 경우는 실제 테스트 코드를 작성할 것이지만, Mongoose는 생략하도록 하겠다. (결국 똑같기 때문에)

정리하자면 다음 테스트들을 수행할 것이다.

  • Controller와 Service 간의 Unit Test
  • Service와 Model 간의 Redis를 통한 Unit Test
  • e2e Test

정리가 길었다. 이제 진짜 간단히 어떻게 Nest에서 TDD를 수행하는지 확인해 보자.

우선, NestJS 프로젝트를 생성하면 기본적으로 jest가 함께 설치된다. jest와 관련하여 자세한 내용은 https://jestjs.io 여기를 확인해 주길 바란다.

다음 명령어를 통해 NestJS 아래에서 Module, Controller, Service를 생성하자.

nest g module user --no-spec
nest g controller user --no-spec
nest g service user --no-spec

초기 user 모듈만 설정한 상태

그러고 나면 위와 같은 구조를 갖게 된다.

src는 NestJS를 개발할 source directory고, 그 아래의 user directory가 우리가 실제 개발할 User 관련 코드가 작성될 경로이다.

NestJS에서 test를 수행할 때, 나는 다음과 같은 구조로 수행한다.

user 디렉토리(이하 도메인 디렉토리) 아래에 test폴더와 mocks폴더를 생성하는데, 그 역할은 다음과 같다.

test: 실질적인 테스트 코드가 작성될 디렉토리
test/stubs: 테스트 코드에 필요한 Mocking Data(Dummy Data)가 작성될 디렉토리
mocks: 테스트에 필요한 Mocking Providers가 작성될 디렉토리


본격적으로 간단한 테스트를 작성해보자. 우리는 다음과 같은 요청을 처리하는 것을 만들려고 한다.

[REQUEST]
GET: /user
[RESPONSE]
OK

이를 위해 먼저 라우터의 처리가 필요한데 이는 Controller가 담당하므로, user.controller.ts에 다음의 코드를 작성하자. 다음부터는 해당하는 함수 등 필요한 부분만 잘라서 작성하겠다.

import { Controller, Get } from '@nestjs/common';

@Controller('user')
export class UserController {
  @Get()
  getUser(): string {
    return 'OK';
  }
}

이 후 Postman 등 HTTP Request를 보낼 수 있는 툴(Get method기 때문에 Web Browser를 이용해도 괜찮다.)로 http://localhost:3000/user로 요청을 보내보자.

지금은 TDD의 처음이기 때문에, 곧바로 controller에 실제 요청을 보내보았지만, 앞으로는 테스트 코드를 먼저 작성하고, 그것이 통과되면 실제 요청(e2e test 등)을 할 것이다.

처음으로 돌아가서, 테스트 코드를 작성해야 하는데 어떤것이 필요할까?

현재 우리는 User Controller를 테스트하고싶다. 즉 외부의 요청 없이 User Controller의 어떤 함수가 호출되었을 때, 기대하는 값이 잘 반환되는지 확인하고싶다. 그렇다면 User Controller의 구성은 어떻게 될까?

이 포인트가 정말 중요한데, NestJS에서는 Module을 만들고, 해당 모듈로부터 컨트롤러, 서비스 등 제공되는(Providing) 기능을 사용하고, 따라서 Module을 먼저 구성해야한다. 이것이 이해 안간다면, NestJS의 Module을 확인해보길 바란다. (👉 https://docs.nestjs.com/modules)

그래서 우리는 test code에서도 먼저 mock user module을 구현해야 한다.

//* user.controller.spec.ts
import { Test } from '@nestjs/testing';
import { UserController } from '../user.controller';
import { UserService } from '../user.service';

describe('UserController', () => {
  //* 사용할 Controller를 정의한다.
  let controller: UserController;
  //* 사용할 Service를 정의한다.
  let service: UserService;
  //* 이렇듯 우리가 테스트하는 대상이 사용하는 모든 것을 선언한다.

    //* 매 테스트를 수행하기 전에, testing을 위한 module을 정의한다.
  beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
      imports: [],
      controllers: [],
      providers: [],
        //* Compile을 붙이는 이유는, 해당 모듈이 생성되어야 하기 때문이다.
    }).compile();
  });
});

여기서 중요한 것은, imports, controllers, providers를 무엇으로 채우냐가 중요한데, 그것은 현재 테스트하는 대상에 따라 바뀐다.

즉, providers 제공자는 우리의 대상인 UserController가 어떤것을 제공받아야 하는지, 다시 말해 어떤 의존성을 갖는지를 기준으로 작성하면되고, Controller는 Controller가 필요하다면 적으면 된다.

마지막으로 imports의 경우 해당 모듈의 환경을 적어주면 되는데, 예를 들어 Mongoose 혹은 Redis 등 필요한 외부 모듈 (Mocking하지 않은)이 가지는 환경을 적어주면 된다.

우리는 UserController를 테스트하기 위해, 반드시 UserController가 필요하고 아직까지 의존성은 존재하지 않으니, 다음과 같이 갱신해주자.

//* 매 테스트를 수행하기 전에, testing을 위한 module을 정의한다.
beforeEach(async () => {
    const moduleRef = await Test.createTestingModule({
    controllers: [UserController],
  }).compile();

    //* 이후 컴파일 된 module에서 controller 등 필요한 것을 구한다.
    controller = moduleRef.get<UserController>(UserController);
  // service = moduleRef.get<UserService>(UserService);
});

우리가 Unit Test할 때 수행하는 내용은 크게 다음과 같다.

  • 해당 함수가 정상적으로 호출 되었는가?
  • 해당 함수에서 필요로 하는 의존성 대상들이 정상적으로 호출 되었는가?
    • 이 때 의존성 대상에 따라 해당 단계가 많아질수도, 줄어들 수도 있다.
  • 해당 함수가 종료되면서 기대한 값을 반환했는가?
  • 예외 처리는 잘 되는가?

우리는 아직 getUser() 함수를 통해 OK를 반환하는 것 밖에 없지만, 위 단계에 따르면 (1) 정상적으로 호출 되었는가? (2) 정상적으로 ‘OK’를 반환했는가? 를 확인할 수 있겠고, 해당 코드는 다음과 같이 작성할 수 있다.

//* getUser() 함수가 호출되었다면,
describe('when getUser is called', () => {
  let response: string;

  //* 응답 비교를 위해 일단 직접 호출하여 응답을 저장하자.
  beforeEach(() => {
    //* controller의 getUser 함수를 관찰하자.
    jest.spyOn(controller, 'getUser');
    response = controller.getUser();
  });

  //* controller.getUser 함수가 한 번 반드시 호출될 것이다.
  test('it should call controller.getUser once', () => {
    expect(controller.getUser).toBeCalledTimes(1);
  });

  //* controller.getUser 함수가 파라미터 없이 호출될 것이다.
  test('it should call controller.getUser without parameter', () => {
    expect(controller.getUser).toBeCalledWith();
  });

  //* controller.getUser 함수의 반환 값은 'OK'일 것이다.
  test('it should return a value "OK"', () => {
    expect(response).toEqual('OK');
  });
});

Jest는 사람이 읽을 수 있도록 테스트 코드가 작성되고, 또한 나도 위에 주석을 통해 설명을 적었기 때문에, 더 자세히 설명하지 않고 오늘의 글을 마무리짓도록 하겠다.

' > NestJS' 카테고리의 다른 글

NestJS — Test Driven Development (2)  (0) 2023.03.20

Problem

  • 연속 펄스 부분 수열의 합

darklight

sublimevimemacs

Java

문제 설명

어떤 수열의 연속 부분 수열에 같은 길이의 펄스 수열을 각 원소끼리 곱하여 연속 펄스 부분 수열을 만들려 합니다. 펄스 수열이란 [1, -1, 1, -1 …] 또는 [-1, 1, -1, 1 …] 과 같이 1 또는 -1로 시작하면서 1과 -1이 번갈아 나오는 수열입니다.예를 들어 수열 [2, 3, -6, 1, 3, -1, 2, 4]의 연속 부분 수열 [3, -6, 1]에 펄스 수열 [1, -1, 1]을 곱하면 연속 펄스 부분수열은 [3, 6, 1]이 됩니다. 또 다른 예시로 연속 부분 수열 [3, -1, 2, 4]에 펄스 수열 [-1, 1, -1, 1]을 곱하면 연속 펄스 부분수열은 [-3, -1, -2, 4]이 됩니다.정수 수열 sequence가 매개변수로 주어질 때, 연속 펄스 부분 수열의 합 중 가장 큰 것을 return 하도록 solution 함수를 완성해주세요.


제한 사항

  • 1 ≤ sequence의 길이 ≤ 500,000
  • 100,000 ≤ sequence의 원소 ≤ 100,000
    • sequence의 원소는 정수입니다.

입출력 예

sequence result
[2, 3, -6, 1, 3, -1, 2, 4] 10

입출력 예 설명

주어진 수열의 연속 부분 수열 [3, -6, 1]에 펄스 수열 [1, -1, 1]을 곱하여 연속 펄스 부분 수열 [3, 6, 1]을 얻을 수 있고 그 합은 10으로서 가장 큽니다.

Description

  • 솔직히 이게 3레벨이라 믿기지 않는 문제. 1레벨이면 충분할 것 같다.
  • 설명에서 주어진 바와 같이 배열의 각 인덱스에 1과 -1을 번갈아가며 곱해준 것과 -1과 1을 번갈아가며 곱해준 것 중 연속 합이 큰 것을 고르면 된다.
  • DP를 이용해 쉽게 풀 수 있는데, 현재 값을 더 했을 때의 최대 값과 더하지 않았을 때의 최대 값을 비교하면 된다.
  • 최대 값을 저장하기 위한 1차원 DP 배열을 선언한다.
    • long[] sum = new long[length]
  • 주어진 예제를 이용해 설명해 보면
    • 먼저 1, -1, 1, -1, … 을 곱한 배열은 다음과 같다.
    • [2, -3, -6, -1, 3, 1, 2, -4]
    • 여기에 대해 0번 인덱스 부터 하나씩 옮겨가며 다음과 같이 비교한다.
      • if 문의 비교연산은 다음 의미를 갖는다.
        • 이전까지의 합과 현재 합을 더했을 때, 0보다 큰가? 즉, 음수가 되지 않았는가에 대한 비교이다.
        • 만약 0 이하라면, sum[i] → 0으로 설정하여 현재 인덱스 까지의 연산을 초기화한다.
    • if(sum[i-1] + sequence[i] > 0) sum[i] = sum[i-1] + sequence[i]; else sum[i] = 0;
    • 그런 다음, answer와 현재까지의 합에 대하여 크기 비교를 수행한다.
      • if 문의 비교 연산은 다음 의미를 갖는다.
        • 이전 index까지 배열의 합이 answer보다 크다는 것은, 이전의 최대 합 보다 현재의 최대 합이 더 크다는 뜻이다. 따라서 answer을 갱신한다.
        • 그렇지 않을 경우, 이전에 찾은 answer가 최대 값이기 때문에 answer를 유지한다.
    • if(sum[i] > answer) answer = sum[i];

Result

import java.util.*;
class Solution {
    public long solution(int[] sequence) {
        long answer = 0;
        int length = sequence.length;
        long[] sum = new long[length];
        int m = 0, n = 1;

        if(length == 1){
            return Math.max(sequence[0], sequence[0] * -1);
        }

        //* [1, -1, 1, -1] ... 을 곱했을 때
        for(int i = 0; i < length; i++){
            if(i % 2 == 1){
                sequence[i] *= -1;
            }

            //* Business Logic
            if(i == 0){
                sum[i] = Math.max(sequence[i], 0);
                continue;
            }
            sum[i] = Math.max(0, sum[i-1] + sequence[i]);
            answer = Math.max(answer, sum[i]);
        }

        //* [-1, 1, -1, 1] ... 을 곱했을 때
        for(int i = 0; i < length; i++){
            sequence[i] *= -1;

            //* Business Logic
            if(i == 0){
                sum[i] = Math.max(sequence[i], 0);
                continue;
            }
            sum[i] = Math.max(0, sum[i-1] + sequence[i]);
            answer = Math.max(answer, sum[i]);
        }

        return answer;
    }
}

+ Recent posts