MSA 적용해서 배포시간 단축하기 (AWS Lambda + Serverless Framework + GitHub Action)

늘보·2024년 11월 4일
0

이 글은 이전 회사에서 운영 중인 서비스에 AWS Lambda, Serverless Framework, Node.js를 사용해 작성된 백엔드에서 MSA를 적용했던 글입니다.

처음 내가 이 서비스를 개발하기 시작했을 때는, 모놀리식으로 구성했었다. 초기 서비스였기 때문에 MSA를 적용해야 할 필요성을 느끼지 못했고, 추후 서비스가 커지게 되면 적용할수도 있지 않을까? 라고 어렴풋이 생각만 하고 있었다.

  • 모놀리식(Monolithic Architecture): 단일 코드 베이스의 애플리케이션. 구현이 쉽고 단순하며, 배포가 쉽다. 그러나 코드 간 의존성이 높아 규모가 커지면 유지보수가 어렵다는 단점이 있다.
  • MSA(Micro Service Architecture): 애플리케이션을 각각의 작은 서비스로 분리한다. 각각의 독립적인 서비스로 분리되었기 때문에 전체 서비스 중단 위험이 감소되며, 서비스가 커져도 유연한 대응이 가능하다. 그러나 기존의 모놀리식 방식보다 초기 개발 환경 구축이 복잡하다는 단점이 있다.

문제점들

그런데, 서비스에서 사용되는 API가 점점 많아지니 몇 가지 문제가 생겼는데, 이게 개발자 입장에서 정말 불편했다. 아래는 문제점들이다.

1. API를 찾기 불편함

service: my-service
frameworkVersion: '3'

useDotenv: true

custom:
  serverless-offline:
    useChildProcesses: true 
    noPrependStageInUrl: true
    httpPort: 8081
    lambdaPort: 8080

  customDomain:
    domainName: api.my-service.com
    basePath: '${self:provider.stage}'
    stage: ${self:provider.stage}
    route53Region: ap-northeast-
    certificateName: '*.my-service.com'
    createRoute53Record: false
    certificateArn: 'arn:aws:acm:ap-northeast-2:00000000000:certificate/blahblah'
    endpointType: regional

plugins:
  - serverless-offline
  - serverless-latest-layer-version
  - serverless-plugin-split-stacks
  - serverless-domain-manager

package:
  individually: true
  patterns:
    - '!.git/**'
    - '!.gitignore'
    - '!.DS_Store'
    - '!npm-debug.log'
    - '!.serverless/**'
    - '!.serverless_plugins/**'
    - '!.eslintrc'
    - '!.idea/**'
    - '!bitbucket-pipelines.yml'
    - '!package-lock.json'
    - '!README.md'
    - '!node_modules/**'
    - '!test/**'
    - '!src/**'
    - 'src/utils/**'
    - 'src/lib/**'
    - 'src/models/**'
    - '!lambda-layer/**'

provider:
  name: aws
  deploymentBucket:
    name: backend-deployment
  deploymentMethod: direct
  runtime: nodejs18.x
  region: ap-northeast-2
  stage: ${opt:stage, 'dev'}
  environment:
    TZ: Asia/Seoul
    NODE_PATH: './:/opt/node_modules'
    STAGE: ${sls:stage}
    DB_DIALECT: ${env:DB_DIALECT}
    DB_HOST: ${env:DB_HOST}
    DB_PORT: ${env:DB_PORT}
    DB_USERNAME: ${env:DB_USERNAME}
    DB_PASSWORD: ${env:DB_PASSWORD}
    DB_NAME: ${env:DB_NAME}

functions:
  #-----------------------------------------------------------------------------#
  getUsers:
    handler: src/user/getUsers/index.getUsers
    events:
      - http:
          method: get
          path: /users
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUsers/**'
	#-----------------------------------------------------------------------------#
  getUser:
    handler: src/user/getUser/index.getUser
    events:
      - http:
          method: get
          path: /user
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUser/**'

이건 serverless.yml 파일이다. 이 파일은 배포를 위한 serverless 프레임워크 설정, 각 함수를 작성하는 파일이다. 예시 파일이라 API는 두개만 작성되어 있다.

그런데, 지금은 getUsers, getUser API 밖에 없는데 만약 저 API가 100개까지 늘어난다고 생각해보자. 상품들을 조회하는 getProducts API, 상품을 등록하는 createProduct API 등 각각 메서드와 도메인이 다른 API들이 계속 추가되는 상황이라면, 기존 API를 찾기가 매우 힘들어진다.

물론 command+F(Mac 기준)를 사용해서 API명으로 찾으면 바로 찾을 수 있기는 하지만.. 하나의 거대한 파일 안에 모든 API가 작성되어 있는건 확실히 복잡했다.

2. 배포 시간이 오래 걸림

첫번째 문제는 당장은 무시할 수 있다고 쳐도, 배포시간이 오래 걸리는 이 문제는 정말 그냥 지나치기가 어려웠다. API가 100개가 넘어가는 시점부터는 간단하게 수정할 게 있어서 수정 후 백엔드 코드를 배포하면, 배포가 약 10~15분 정도 걸리는 상황이 발생했다.

시작한지 얼마 안 된 서비스가 고작 몇 달만에 이렇게 배포시간이 오래 걸리는건 말이 안된다고 생각했고, 무엇보다 개발 서버에 직접 수정사항을 반영하며 고쳐야 하는 버그의 경우에는 정말 답답했다. 4번의 테스트를 해야 하는 경우엔, 무려 1시간이나 기다려야 했었다.

그런데, 신규 기능 개발이 바빠서 해결을 못하던 상황이었는데, 결국 신규 API를 추가했을 때 배포 시 버그가 발생해서 해결할 수 밖에 없는 상황이 왔었다.

배포 시 발생한 리소스 제한 버그

그러던 중, API가 40개 정도 넘어가는 시점에 발생한 버그가 있었다. (아직 API 개수가 채 100개도 넘기지 않았을 시점이다) 에러 내용은 아래와 같다.

The CloudFormation template is invalid: Template format error: Number of resources, 201, is greater than maximum allowed, 200

리소스 제한 버그 원인

위 버그의 원인은 AWS CloudFormation 스택 당 리소스 제한 정책을 위반했기 때문에 발생한 버그였다. 즉, 스택 당 리소스 제한이 200개라고 하면, 200개를 넘겼기 때문에 생기는 버그였다.

그런데, 문제는 나도 여기까지는 이해했는데 AWS CloudFormation이 갑자기 왜 나오는지, 그리고 스택은 또 뭐고 리소스는 왜 제한을 하는건지, 하나도 이해가 되지 않던 상황이었다. 그래서 당시 내 생각의 흐름은 아래와 같았다.

  1. 먼저 AWS CloudFormation이 왜 에러 로그에 보이는지부터 알아보자.
  2. 그리고 AWS CloudFormation이 하는 일에 대해 알아보자.
  3. 이후 왜 CloudFormation에서 리소스를 제한하는지도 확인해보자.
  4. 그러고 나서 CloudFormation의 리소스 제한 정책을 늘릴 수 있는지, 또는 리소스를 줄일 수 있는지를 생각해보자.

1. AWS CloudFormation이 에러 로그에 보이는 이유

먼저, 이 버그는 배포 시 발생한 버그이다. 그럼, 먼저 백엔드 코드를 어떻게 배포하는지 확인해보자.

이 서비스의 백엔드는 Serverless Framework를 통해 AWS Lambda에 각 함수(API)를 배포한다. 좀 더 자세히 설명하면, 배포 시에는 Serverless Framework에서 제공하는 명령어인 sls deploy를 실행해 배포를 진행한다. 이 명령어를 실행하면 세부적으로 다음과 같은 작업들이 아래와 같은 순서대로 진행된다.

  1. Serverless Framework는 Lambda에서 맞는 형식으로 함수를 zip 파일로 패키징한다.
  2. 이 zip 파일은 S3에 업로드된다.
  3. Lambda 함수, IAM 권한, Cloudwatch 로그 구성 등 다양한 작업들이 포함되는 CloudFormation 스택이 함께 배포됩니다.

중요한 내용은 3번이다. 즉, 현재 벡엔드에서 사용 중인 Serverless Framework는 배포할 때 실제 기능을 담당하는 Lambda 함수 뿐만 아니라 IAM 권한, 그리고 에러 발생 시 디버깅에 필수적인 CloudWatch 로그 구성 등의 여러 유용한 작업들을 CloudFormation에 한꺼번에 정의해서 배포하는 것이다.

2. AWS CloudFormation이 하는 일

CloudFormation 템플릿은 AWS 리소스들을 생성하는 명령 파일이라고 생각하면 된다. 위에서 설명했듯이, 아래와 같은 다양한 서비스의 설정이 포함되어 있다.

  • AWS Lambda
  • IAM 권한
  • CloudWatch 로그

AWS는 이 CloudFormation 템플릿(코드)을 기반으로 해당 리소스들을 생성하여, 사용자가 지정한 대로 인프라를 자동으로 배포하고 관리할 수 있게 한다. 즉, EC2, Lambda 등과 같은 리소스를 직접 AWS 콘솔에서 수동으로 생성하는 불편함을 덜어준다.

AWS CloudFormation에 대한 좀 더 자세한 설명은 아래 링크를 참고하자.

3. CloudFormation의 리소스 제한 이유

CloudFormation의 리소스 제한은 시스템의 성능과 안정성을 유지하기 위한 것이다. 너무 많은 리소스가 포함된 스택은 배포와 관리가 복잡해질 수 있기 때문에 이러한 제한이 필요하다.

아래 글은 CloudFormation의 정책에 관련된 세부 사항을 정리한 글이다.

리소스 제한 버그 해결을 위해 시도한 방법들

1. 리소스 제한 정책 상향해보기

그럼, 먼저 1차원적으로 CloudFormation의 이 리소스 제한 정책을 상향할 수 있는지 찾아보았다. AWS와Serverless Framework 문서를 아무리 뒤져봐도 그런 방법은 보이지 않았다.

결국, 사용자가 임의로 이 제한 정책을 건드리는건 불가능하다고 결정을 내리고 리소스를 줄일 수 있는 방법은 없는지 찾아보았다.

2. 리소스 줄이기

어떻게든 리소스가 200개를 넘기지 않을 방법을 생각해보았다. 그런데, Serverless Framework를 통해 배포하는 하나의 Lambda 함수에는 최소 3개의 CloudFormation 리소스가 있다.

한 API(함수)당 리소스의 개수

  1. AWS::Lambda::Function: 실제 기능을 나타내는 리소스
  2. AWS::Lambda::Version: 특정 버전의 함수를 나타내는 리소스 (이를 통해 빠르고 쉬운 롤백이 가능)
  3. AWS::Logs::LogGroup: CloudWatch 로그에 함수를 기록할 수 있는 리소스

설명에서 알 수 있다시피 1, 2, 3번 모두 없어서는 안될 필수 기능들이다. 1번은 말할 것이 없고, 2번이 안된다면 배포가 잘못된다면 서비스를 돌이킬 수가 없는 상황이 발생하고, 3번이 없다면 디버깅을 할 수가 없을 것이다.

그럼 최소 하나의 API 당 3개의 리소스가 필요해진다. 그런데, 현재 백엔드는 API Gateway도 같이 이용하고 있기 때문에, 몇가지 리소스가 추가된다.

  1. AWS::Lambda::Permission: API Gateway가 해당 함수를 호출할 수 있도록 허용
  2. AWS::ApiGateway::Resource: 엔드포인트에 대한 리소스 경로 구성
  3. AWS:ApiGateway::Method: 엔드포인트에 대한 HTTP 메서드를 구성

이 리소스들 또한 뺄 수는 없다고 생각했다.

Q. API Gateway는 왜 같이 사용하는 건가요?

A. API Gateway는 RESTful API를 생성하고 관리하며, 클라이언트 요청을 Lambda 함수로 라우팅하고 응답을 반환하는 역할을 한다. 또한 인증, 속도 제한, 모니터링 등의 기능을 제공하여 API를 더 안전하고 관리하기 쉽게 도와준다. 사실상 AWS로 서비스를 배포한다면 필수.

결국, 리소스 자체를 줄이는 것 또한 불가능하다고 판단했다.

위 내용을 종합하면 한 함수 당 CloudFormation 리소스의 개수는 6개다. 그럼 200개 리소스 제한에 걸리지 않는 상한선은 33개이다. 즉, API를 33개까지만 만들 수 있다는 것이다. 말이 안 된다.

이러면 Serverless Framework + Lambda를 사용해서는 서비스를 만들 수 없는 수준이라고 판단해서 당연히 해결방법이 있을거라고 생각하고 문서를 더 찾아봤다. 찾아보니, 이런 상황에 사용할 수 있는 CloudFormation에서 제공하는 중첩 스택(Nested Stack)을 사용할 수 있었다.

3. CloudFormation에서 중첩 스택 사용

중첩 스택이란, 다른 스택의 자식으로 생성된 스택이다. 예를 들면, 아래와 같은 형태의 스택 구조가 있다고 가정해보자. 그럼, A 스택 하위에 B 스택, 그 하위에 C 스택, 다시 그 하위에 D 스택이 있게 되는 형태이다.

예를 들어, 대부분의 스택에 사용되는 DB 설정(A)이 있다고 가정해보자. DB 설정은 한번 초기에 설정하면 변화가 적은 요소이다. 똑같은 DB 설정을 복사하여 CloudFormation 템플릿에 붙여 넣는 대신 DB 설정 전용 템플릿(A)을 생성할 수 있다. 그런 다음 리소스를 사용하여 다른 템플릿(B) 내에서 해당 템플릿(A)을 참조하기만 하면 된다. 아래 코드는 중첩 스택을 사용한 예시다.

중첩 스택 사용 전

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  InstanceType:
    Type: String
    Default: 't2.micro'
    Description: 'The EC2 instance type'
  
  Environment:
    Type: String
    Default: 'Production'
    Description: 'The deployment environment'

Resources:
  MyEC2Instance:
    Type: 'AWS::EC2::Instance'
    Properties:
      ImageId: ami-1234567890abcdef0
      InstanceType: !Ref InstanceType

  MyS3Bucket:
    Type: 'AWS::S3::Bucket'

중첩 스택 사용 후

AWSTemplateFormatVersion: '2010-09-09'
Resources:
  MyFirstNestedStack:2
    Type: 'AWS::CloudFormation::Stack'
    Properties:
      TemplateURL: 'https://s3.amazonaws.com/amzn-s3-demo-bucket/first-nested-stack.yaml'
      Parameters:
        # Pass parameters to the nested stack if needed
        InstanceType: 't3.micro'

  MySecondNestedStack:
    Type: 'AWS::CloudFormation::Stack'
    Properties:
      TemplateURL: 'https://s3.amazonaws.com/amzn-s3-demo-bucket/second-nested-stack.yaml'
      Parameters:
        # Pass parameters to the nested stack if needed
        Environment: 'Testing'
    DependsOn: MyFirstNestedStack

여기서 중요한건, 각각의 스택은 리소스를 200개까지 가질 수 있게 된다는 것이다.

따라서, 하나의 스택만 사용하는게 아니라 이렇게 중첩 스택을 사용한다면 CloudFormation의 200개 리소스 제한 정책을 피할 수 있게 된다.

Serverless Framework에서 중첩 스택 적용 과정

그럼, 중첩 스택을 어떻게 적용할까? 위의 예시대로 하면 좋겠지만, 현재 나는 Serverless Framework를 사용해 Lambda 함수를 배포하고 있기 때문에 CloudFormation의 템플릿이 자동으로 생성, 구성되고 있다. 그래서 Serverless Framework에서 사용할 수 있는 도구가 있는지 찾아봤더니(관련 문서), 다음 두개의 플러그인을 찾을 수 있었다.

난 이 두개의 플러그인 중, serverless-split-stacks 플러그인을 선택했다. serverless-nested-stacks 플러그인의 GitHub Readme를 읽어봤는데, 이 플러그인이 CloudFormation 스택을 어떻게 분할, 구성하는지는 나와 있는데, 플러그인 사용자가 직접 세부적으로 커스텀을 할 수 있는 방법이 적혀 있지 않았다.

그에 반해 serverless-split-stacks는 제한 사항, 커스텀 방법 등에 대한 설명이 자세하게 작성되어 있었다. 적용 과정은 아래와 같다.

1. 설치

npm install serverless-split-stacks --save-dev

2. serverless.yml 파일에 작성

# serverless.yml
custom:
  splitStacks:
    perFunction: false
    perType: false
    perGroupFunction: true
plugins:
  - serverless-split-stacks
  • perFunction: 생성된 람다 함수 당 스택을 생성하는 방식.
  • perType: 리소스 유형별로 각기 다른 스택을 생성하는 방식. (ex: 함수, DB, IAM 역할 등)
  • perGroupFunction: 특정 그룹의 Lambda 함수와 관련 리소스를 그룹 당 한개의 스택으로 생성하는 방식.

나는 위 3개의 옵션 중, perGroupFunction 옵션을 사용했다. 각 도메인 별로 스택을 분리할 수 있다면 관리하기 편할거라고 생각했다.

3. 커스텀을 위해 루트 디렉토리에 stack-map.js 파일 생성

// stack-map.js
module.exports = (resource, logicalId) => {
  if (logicalId.includes('User')) return { destination: 'UserStack' };
  if (logicalId.includes('Product')) return { destination: 'ProductStack' };
  if (logicalId.includes('Cart')) return { destination: 'CartStack' };
  if (logicalId.includes('Payment')) return { destination: 'PaymentStack' };
};

도메인 자체는 훨씬 더 많았지만 예시 코드이기 때문에 4개의 도메인으로 나눴다. 루트 디렉토리에 stack-map.js 파일을 생성하면 각 조건에 따라서 각 도메인에 해당되는 스택이 생성되고, logicalId ‘User’가 포함되어 있다면 UserStack으로, ‘Product’가 포함되어 있다면 ProductStack으로 그룹화되도록 만든 코드다.

4. sls deploy 명령어 실행

마지막으로, sls deploy 명령어를 통해 배포를 진행한다.

여기까지 진행했을 때, 결과적으로 CloudFormation의 200 Resource 제한 에러가 더 이상 발생하지 않고 성공적으로 배포가 진행되었다. 그러나 여전히 문제점은 남아있었다.

그래서 모든 문제점이 해결되었나?

그럼, 지금까지 중첩 스택에 대한 설명을 읽은 사람들은 다음과 같은 의문이 생길 수 있다.

Q. 그럼, 중첩 스택을 적용해도 하나의 거대한 메인스택 하위에 여러 중첩 스택이 분할되는 형태 아닌가요? 그럼 아까 말한 배포 시간이 오래 걸리는 문제와 API를 찾기 어렵다는 문제는 해결되지 않은 거잖아요.

  • A. 그렇다. 결국 배포가 되지 않는 가장 치명적인 버그만 해결된거지 앞서 말한 두가지 문제점은 해결되지 못했다. 여전히 serverless.yml 파일은 거대하고, 배포 시간은 10~15분이 걸렸다.

애초에 serverless-split-stacks 플러그인의 Readme에서도 이 방법을 사용해 스택을 분할하는 것이 임시방편이라고, 자기들이 만든 플러그인 사용을 추천하지 않았다. 그래서 근본적인 해결책을 찾게 되었다.

MSA 적용을 통한 문제 해결

아래 Serverless Framework의 문서에서는 거대한 서비스를 Micro Service로 분할하라고 한다. 이 방법을 가장 추천한다고 한다.

물론 이미 리소스 제한 버그 해결 과정에 이 문서를 읽었어서 MSA 적용이 가장 좋은 해결책인건 알고 있었다. 그러나 다음과 같은 부분이 마음에 걸렸다.

MSA 적용을 미룬 이유

MSA 적용을 늦게 한 이유는 아래와 같이 불확실한 부분이 있었기 때문이다. (처음 도입해보니까)

현재 배포가 자동화 되어 있는 상황에서 각 도메인에 해당하는 작은 서비스로 분할하면, 각 서비스별로 따로 배포해야 되나? 그럼 도메인이 10개라면 배포를 각각 10개를 해줘야 하는건가? 오히려 더 귀찮은 일이 많아지는건 아닐까?

실제 운영중인 서비스의 구조를 크게 바꾸는 일이기 때문에 처음엔 도입에 소극적이었다. 이 방법이 더 좋은걸 알고 있음에도 불구하고 중첩 스택을 먼저 적용한 이유도 서비스의 안정성 때문이었다.

MSA 적용 시 예상되는 개선점

일단, API를 찾기 힘들던 1번 문제가 해결된다. 모든 API가 하나의 거대한 serverless.yml 파일에 있는 것이 아닌, 각 도메인 별로 따로 yml 파일이 생성되기 때문에(ex: user.yml, product.yml) 각 도메인에 맞는 API를 확인하면 되기 때문이다.

또한, 2번 문제인 배포 시간이 오래 걸리는 문제 또한 해결된다. sls deploy 명령어를 실행할 때마다 모든 API를 변경점이 있는지 체크하고 다시 배포했기 때문에 배포 시간이 많이 소요된 것이었는데, 유저 도메인, 상품 도메인 별로 따로 배포하게 되면 API 개수가 확실히 적어지기 때문에 배포 시간 또한 개선될 것이라고 생각했다.

MSA 적용 과정

1. 기존 코드 확인

먼저, 기존의 serverless.yml 파일을 확인해보자. 코드에 보이는 함수는 총 4개인데, 총 함수가 100개가 넘는다고 가정하겠다. 모든 함수는 serverless.yml 파일에 작성되어 있다.

## serverless.yml
service: my-service
frameworkVersion: '3'

useDotenv: true

custom:
  serverless-offline:
    useChildProcesses: true 
    noPrependStageInUrl: true
    httpPort: 8081
    lambdaPort: 8080

  customDomain:
    domainName: api.my-service.com
   ## basePath 추가
    basePath: '${self:provider.stage}'
    stage: ${self:provider.stage}
    route53Region: ap-northeast-
    certificateName: '*.my-service.com'
    createRoute53Record: false
    certificateArn: 'arn:aws:acm:ap-northeast-2:00000000000:certificate/blahblah'
    endpointType: regional
functions:
  #-----------------------------------------------------------------------------#
  getUsers:
    handler: src/user/getUsers/index.getUsers
    events:
      - http:
          method: get
          path: /users
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUsers/**'
	#-----------------------------------------------------------------------------#
  getUser:
    handler: src/user/getUser/index.getUser
    events:
      - http:
          method: get
          path: /user
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUser/**'
	#-----------------------------------------------------------------------------#
  getProducts:
    handler: src/product/getProducts/index.getProducts
    events:
      - http:
          method: get
          path: /products
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/product/getProducts/**'
	#-----------------------------------------------------------------------------#
  createOrder:
    handler: src/order/createOrder/index.createOrder
    events:
      - http:
          method: post
          path: /order
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/order/createOrder/**'
  ## ....

2. 도메인 별로 yml 파일 나누기

이후, 기존의 하나의 serverless.yml 파일을 도메인 별로 나눠야 한다. 루트 파일에 다음 3개의 yml 파일을 생성한다.(user.yml, product.yml, order.yml …)

실제로 내가 분리한 도메인은 10개가 넘었지만, 여기서는 user, product, order 등 세개의 도메인으로만 나눈다고 가정하겠다.

그럼, 예시로 user.yml 파일을 확인해보자.

## user.yml
service: user-service
frameworkVersion: '3'

useDotenv: true

custom:
  customDomain:
    domainName: api.my-service.com
    basePath: '${self:provider.stage}/v1-user'
    stage: ${self:provider.stage}
    route53Region: ap-northeast-2
    certificateName: '*.my-service.com'
    createRoute53Record: false
    certificateArn: 'arn:aws:acm:ap-northeast-2:722419581403:certificate/blahblah'
    endpointType: regional

functions:
  #-----------------------------------------------------------------------------#
  getUsers:
    handler: src/user/getUsers/index.getUsers
    events:
      - http:
          method: get
          path: /users
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUsers/**'
	#-----------------------------------------------------------------------------#
  getUser:
    handler: src/user/getUser/index.getUser
    events:
      - http:
          method: get
          path: /user
          cors: true
    layers:
      - arn:aws:lambda:ap-northeast-2:722419581403:layer:test-backend-layer:latest
    package:
      patterns:
        - 'src/user/getUser/**'
    handler: src/user/getUserDailyTasks/index.getUserDailyTasks

원래 기존의 serverless.yml 파일에서 해줬던 초기 설정도 모두 넣어줘야 한다. 기존 serverless.yml 파일과 다른 점은 service name, 그리고 customDomain의 basePath 부분이다.

  • serverless.yml: api.my-service.com
  • user.yml: api.my-service.com/user
  • product.yml: api.my-service.com/product
  • order.yml: api.my-service.com/order

3. API Gateway에서 각 하위 도메인 설정해주기

이미 구매해 둔 커스텀 도메인이 있다면, 해당 커스텀 도메인의 각 하위 도메인을 설정해줘야 한다. 방식은 위와 같다.

  • api.my-service.com/user
  • api.my-service.com/product
  • api.my-service.com/order

이 부분은 직접 AWS에서 스크린샷으로 남기고 싶은데, 지금은 해당 서비스의 AWS에 접근할 수 없어서 상세하게 작성이 어렵다. 따라서 AWS 콘솔에서 작업할 상세 내용은 아래 링크를 확인하자.

4. GitHub Action 관련 파일 수정하기

기존 서비스는 Github Action을 통해서 백엔드 배포를 자동화했었다. 그런데, 나는 각 도메인 별로 서비스를 나누더라도 특정 서비스를 따로 배포하는게 아니라, 만약 해당 도메인에 수정사항이 있다면 배포 시 자동으로 해당 변경 사항을 감지하여 해당 서비스가 배포됐으면 했다.

먼저, 로컬에서 작업한 내역을 dev 환경에 배포하는 dev-ci 파일을 확인해보자.

name: DEV CI

on:
  push:
    branches:
      - develop
    tags:
      - 'development-**'

jobs:
  ## PR의 변경 사항이 포함된 브랜치를 체크아웃하고 빌드를 수행
  Build:
    runs-on: ubuntu-latest
    steps:
      - name: Clone repository
        uses: actions/checkout@v3
        with:
          fetch-depth: 0
          ref: ${{github.event.pull_request.head.ref}}
          repository: ${{github.event.pull_request.head.repo.full_name}}
			
	  ## node modules 캐싱하기
      - name: Cache node modules
        uses: actions/cache@v3
        with:
          path: node_modules
          key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.OS }}-build-
            ${{ runner.OS }}-

      - name: Install Dependencies
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm install

	  ## 파일 변경 감지 부분
      - name: Watch changed files
        uses: dorny/paths-filter@v2
        id: changes
        with:
          base: develop
          filters: |
            user:
              - 'src/user/**'
              - 'user.yml'
            product:
	            - 'src/product/**'
	            - 'product.yml'
            order:
	            - 'src/order/**'
	            - 'order.yml'

      # Serverless Framework 설치
      - name: Install Serverless Framework
        run: npm install -g serverless

      # USER 함수 배포
      - name: Deploy User Service
        if: steps.changes.outputs.user == 'true'
        run: sls deploy --config user.yml --stage dev --verbose

      # PRODUCT 함수 배포
      - name: Deploy Product Service
        if: steps.changes.outputs.product == 'true'
        run: sls deploy --config product.yml --stage dev --verbose

      # ORDER 함수 배포
      - name: Deploy Product Service
        if: steps.changes.outputs.product == 'true'
        run: sls deploy --config product.yml --stage dev --verbose

	  # 배포가 정상적으로 완료됐을 경우에 디스코드로 배포 성공/실패 여부 알림을 보내준다.
      - name: Send deploy result to Discord
        uses: sarisia/actions-status-discord@v1
        if: always()
        with:
          webhook: ${{ secrets.DISCORD_WEBHOOK }}
          status: ${{ job.status }}
          title: 'DEV 배포'
          content: '<@0000000000000>'

파일 변경 감지 부분의 코드에 대해 설명하자면, 예를들면, src/user/** 경로, 즉 user 폴더 내부에서 파일이 변경되거나 추가, 또는 삭제 된다면 해당 변경사항을 감지하여 user 도메인의 함수를 재배포한다.

  • 파일 변경 감지에는 dorny/paths-filter@v2라는 GitHub Action 플러그인을 사용했다.
  • 변경이 감지된 도메인이 있을 경우, steps.changes.outputs.<도메인명>true가 되는데, 이 때 실행 명령어가 sls deploy가 아닌 sls deploy —config <도메인명>.yml이 된다. sls deploy는 기본적으로 루트 위치의 serverless.yml 파일을 배포하는데, 위와 같이 —config prefix 뒤에 특정 파일명을 작성해주면, 해당 파일을 배포하게 된다.

피처 개발을 하든, 버그를 수정하든 일반적으로 작업 중인 도메인에만 변경사항이 생기기 때문에, 해당 도메인의 함수들만 재배포하게 된다.

여기까지 진행하면, 이전과 똑같이 sls deploy 명령어를 실행하면 작업한 도메인의 함수들만 재배포한다.

추가: 배포 시간 더 줄이기

매번 배포할 때마다 모든 의존성 패키지를 재설치하는 것은 불필요하다고 생각했다. 그래서 만약 배포 시 새로 추가, 업데이트 또는 삭제된 패키지가 있을 경우에만 의존성 패키지를 재설치하고, 그렇지 않을 경우에는 npm install을 건너 뛴다.

	  ## node modules 캐싱하기
      - name: Cache node modules
        uses: actions/cache@v3
        with:
          path: node_modules
          ## package-lock.json 파일의 변경사항을 통해 의존성 패키지 변경을 감지한다.
          key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }}
          restore-keys: |
            ${{ runner.OS }}-build-
            ${{ runner.OS }}-

      - name: Install Dependencies
	    ## 새로 추가, 업데이트 또는 삭제된 패키지가 있을 경우에만 npm install
        if: steps.cache.outputs.cache-hit != 'true'
        run: npm install

결론

가장 힘들었던 배포 시간 문제가 해결되었다. 배포시간이 15분에서 평균 2분 이내로 단축되었다.

이제 100개가 넘는 함수를 한꺼번에 재배포하는 것이 아닌, 변경사항이 있는 도메인의 함수들만 재배포 하기 때문이다. 또한, 의존성 캐싱을 통해 1~2분 정도 배포 시간을 추가로 더 단축시켰다. 덕분에 버그 수정 및 개발 환경에서 테스트할 일이 있을 때 개발 생산성이 많이 향상되었다. (동료 개발자 한 분이 배포할 때 살 것 같다고 얘기했을 때 매우 뿌듯했음..)

사실 지금의 구조가 완전한 MSA라고 보기에는 무리가 있지만, 초기 서비스에서 Lambda에서 요구하는 수준으로 도메인을 나눴다고 보면 될 것 같다.

참고문서

0개의 댓글