서버리스 프로젝트 구성하기

서버리스 프로젝트가 커지기 시작하면, 프로젝트를 어떻게 구성할지에 대한 선택을 해야 합니다. 이번 장에서는 서비스와 애플리케이션(여러 서비스) 수준에서 프로젝트를 구조화하는 가장 일반적인 방법을 살펴보겠습니다.

먼저 서버리스 프레임워크 프로젝트를 이야기할 때 사용되는 일반적인 용어를 간단히 살펴보겠습니다.

  • 서비스(Service)

    서비스는 서버리스 프레임워크 프로젝트라고 할 수 있습니다. 단일 serverless.yml 파일로 구성됩니다.

  • 스택(Stack)

    스택은 단일 단위로 관리되는 리소스의 모음입니다. CDK를 사용해 CloudFormation 스택을 정의합니다.

  • 애플리케이션(Application)

    애플리케이션 또는 앱은 여러 서비스의 모음입니다.

이제 예제 저장소를 통해 서버리스 프로젝트를 구성하는 가장 일반적인 패턴을 살펴보겠습니다.

예제

우리의 확장된 노트 앱은 두 개의 API 서비스를 가지고 있으며, 각각 명확하게 정의된 비즈니스 로직을 가지고 있습니다:

  • notes-api 서비스: 노트를 관리합니다.
  • billing-api 서비스: 구매를 처리합니다.

또한 앱에는 작업 서비스도 있습니다:

  • notify-job 서비스: 사용자가 성공적으로 구매를 완료한 후 문자 메시지를 보냅니다.

한편, 인프라는 CDK에서 다음과 같은 스택으로 생성됩니다:

  • CognitoStack: 사용자 데이터를 저장하는 데 사용되는 Cognito 사용자 및 Identity 풀을 정의합니다.
  • DynamoDBStack: 노트 데이터를 저장하는 데 사용되는 notes라는 DynamoDB 테이블을 정의합니다.
  • S3Stack: 노트 이미지를 저장하는 데 사용되는 S3 버킷을 정의합니다.

마이크로서비스 + 모노레포

모노레포는 말 그대로 단일 저장소를 의미합니다. 즉, 전체 애플리케이션과 모든 서비스가 하나의 저장소에 포함됩니다.

반면 마이크로서비스 패턴은 각 서비스를 모듈화하고 가볍게 유지하는 개념입니다. 예를 들어, 사용자가 노트를 생성하고 구매를 할 수 있는 앱이 있다면, 노트를 처리하는 서비스와 구매를 처리하는 서비스로 나눌 수 있습니다.

마이크로서비스 + 모노레포 패턴 하에서의 전체 애플리케이션 디렉토리 구조는 다음과 같습니다.

|- services/
|--- billing-api/
|--- notes-api/
|--- notify-job/
|- infrastructure/
|- libs/
|- package.json

여기서 주목할 점은 다음과 같습니다:

  1. 여기서는 Node.js 프로젝트를 예로 들었지만, 이 패턴은 다른 언어에도 적용됩니다.
  2. 루트의 services/ 디렉토리는 여러 서비스로 구성됩니다. 각 서비스는 단일 serverless.yml 파일을 포함합니다.
  3. 각 서비스는 상대적으로 작고 독립적인 기능을 처리합니다. 예를 들어, notes-api 서비스는 노트 생성부터 삭제까지 모든 것을 처리합니다. 물론 애플리케이션을 얼마나 분리할지는 전적으로 여러분의 선택입니다.
  4. infrastructure/ 디렉토리는 여러 스택으로 구성된 CDK 앱입니다.
  5. package.json (그리고 node_modules/ 디렉토리)은 저장소의 루트에 위치합니다. 그러나 각 서비스 디렉토리 내에 별도의 package.json을 두는 것도 일반적입니다.
  6. libs/ 디렉토리는 모든 서비스에서 공통으로 사용될 수 있는 코드를 담는 곳입니다.
  7. 이 애플리케이션을 배포하려면 각 서비스에서 serverless deploy를 별도로 실행해야 합니다.
  8. 환경(또는 스테이지)은 모든 서비스에서 조정되어야 합니다. 따라서 팀이 dev, staging, prod 환경을 사용한다면, 각 서비스에서 이에 대한 세부 사항을 정의해야 합니다.

모노레포의 장점

마이크로서비스 + 모노레포 패턴이 인기를 끌게 된 데에는 몇 가지 이유가 있습니다:

  1. 람다 함수는 마이크로서비스 기반 아키텍처에 자연스럽게 적합합니다. 이는 몇 가지 이유 때문입니다. 첫째, 람다 함수의 성능은 함수의 크기와 관련이 있습니다. 둘째, 특정 이벤트를 처리하는 람다 함수를 디버깅하는 것이 훨씬 쉽습니다. 마지막으로, 람다 함수를 단일 이벤트와 개념적으로 연결하는 것이 더 쉽습니다.

  2. 서비스 간 코드를 공유하는 가장 쉬운 방법은 모든 서비스를 단일 저장소에 함께 두는 것입니다. 여러분의 서비스가 앱의 다른 부분을 다루더라도, 여전히 일부 코드를 공유해야 할 수 있습니다. 예를 들어, 람다 함수에서 요청과 응답을 포맷하는 코드가 있다고 가정해 보겠습니다. 이 코드는 모든 서비스에서 사용되는 것이 이상적이며, 모든 서비스에서 이 코드를 복제하는 것은 합리적이지 않습니다.

모노레포의 단점

대안 패턴을 살펴보기 전에, 마이크로서비스 + 모노레포 패턴의 단점을 간단히 알아보겠습니다.

  1. 마이크로서비스가 통제 불능으로 커질 수 있으며, 추가된 각 서비스는 애플리케이션의 복잡성을 증가시킵니다.
  2. 이는 수백 개의 Lambda 함수를 가지게 될 수도 있음을 의미합니다.
  3. 이 모든 서비스와 함수의 배포를 관리하는 것은 복잡해질 수 있습니다.

위에서 설명한 대부분의 문제는 애플리케이션이 커지기 시작할 때 나타납니다. 그러나 이러한 문제 중 일부를 해결하는 데 도움을 주는 서비스들이 있습니다. IOpipe, Epsagon, Dashbird와 같은 서비스는 Lambda 함수의 가시성을 높이는 데 도움을 줍니다. 그리고 우리의 Seed는 모노레포 Serverless Framework 애플리케이션의 배포와 환경 관리를 지원합니다.

이제 몇 가지 대안 접근 방식을 살펴보겠습니다.

멀티 리포

모노리포 패턴의 명백한 대안은 멀티 리포 접근 방식입니다. 이 패턴에서는 각 리포지토리가 단일 Serverless Framework 프로젝트를 포함합니다.

멀티 리포 패턴에서 주의해야 할 몇 가지 사항이 있습니다.

  1. 애플리케이션이 여러 리포지토리에 걸쳐 분산되어 있기 때문에 리포 간 코드 공유가 까다로울 수 있습니다. 이를 해결하는 몇 가지 방법이 있습니다. Node의 경우 프라이빗 NPM 모듈을 사용할 수 있습니다. 또는 공통으로 공유되는 코드 라이브러리를 각 리포지토리에 연결하는 방법을 찾을 수도 있습니다. 두 경우 모두 배포 프로세스가 공유 코드를 수용할 수 있어야 합니다.

  2. 코드 공유에 따른 마찰로 인해 일반적으로 각 서비스(또는 리포지토리)가 더 많은 Lambda 함수를 포함하도록 확장되는 경우가 많습니다. 이로 인해 CloudFormation 리소스 제한에 도달하여 다음과 같은 배포 오류가 발생할 수 있습니다.

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

    참고로, AWS는 최근 이 제한을 500으로 늘렸습니다.

단점이 있음에도 불구하고 멀티 리포 패턴은 여전히 유용한 경우가 있습니다. 우리는 일부 인프라 관련 작업(DynamoDB, Cognito 설정 등)이 별도의 리포지토리에 위치한 서비스에서 수행되는 경우를 보았습니다. 이는 일반적으로 많은 코드가 필요하지 않거나 애플리케이션의 나머지 부분과 공유할 필요가 없기 때문에 독립적으로 존재할 수 있습니다. 따라서 독립적인 리포지토리는 _인프라_를 위한 것이고, _API 엔드포인트_는 마이크로서비스 + 모노리포 설정에 포함되는 멀티 리포 구성을 실행할 수 있습니다.

마지막으로, 덜 일반적인 모놀리식 패턴을 살펴볼 가치가 있습니다.

모놀리스(Monolith)

모놀리스 패턴은 API Gateway의 {proxy+}ANY 메서드를 활용해 모든 요청을 단일 Lambda 함수로 라우팅하는 방식입니다. 이 Lambda 함수 내에서 Express와 같은 애플리케이션 서버를 실행할 수 있습니다. 예를 들어, 아래의 모든 API 요청은 동일한 Lambda 함수에서 처리됩니다.

GET https://api.example.com/notes
GET https://api.example.com/notes/{id}
POST https://api.example.com/notes
PUT https://api.example.com/notes/{id}
DELETE https://api.example.com/notes/{id}

POST https://api.example.com/billing

serverless.yml 파일의 해당 섹션은 다음과 같이 작성할 수 있습니다.

handler: app.main
events:
  - http: 
      method: any
      path: /{proxy+}

여기서 app.js 파일의 main 함수는 라우트를 파싱하고, 필요한 작업을 수행하기 위해 HTTP 메서드를 파악하는 역할을 합니다.

이 방식의 가장 큰 단점은 함수의 크기가 계속 커진다는 점입니다. 이는 함수의 성능에 영향을 미칠 수 있으며, Lambda 함수를 디버깅하기 어렵게 만듭니다.

실용적인 접근 방식

이 섹션의 목표는 어떤 설정이 더 나은지 평가하는 것이 아닙니다. 대신, 우리가 생각하는 좋은 설정과 우리가 협력하는 대부분의 팀에서 잘 작동한 설정을 설명하려고 합니다. 우리는 중간 지점을 택하여 두 개의 저장소를 만듭니다:

  1. serverless-stack-demo-ext-resources
  2. serverless-stack-demo-ext-api

serverless-stack-demo-ext-resources에는 다음과 같은 구조가 있습니다:

/
  lib/
    CognitoStack.js
    DynamoDBStack.js
    S3Stack.js

그리고 serverless-stack-demo-ext-api에는 다음과 같은 구조가 있습니다:

/
  libs/
  services/
    notes-api/
    billing-api/
    notify-job/

왜 이렇게 나눌까요? 대부분의 코드 변경은 serverless-stack-demo-ext-api 저장소에서 발생합니다. 팀이 빠르게 변경을 진행할 때, 여러 기능 브랜치, 버그 수정, 풀 리퀘스트가 생길 가능성이 높습니다. 서버리스의 장점은 새로운 환경을 무료로 생성할 수 있다는 점입니다(리소스 프로비저닝 비용이 아닌 사용량에 대해서만 비용이 발생). 예를 들어, 팀은 prod, staging, dev, feature-x, feature-y, feature-z, bugfix-x, bugfix-y, pr-128, pr-132 등과 같은 수십 개의 임시 스테이지를 가질 수 있습니다. 이렇게 하면 각 변경 사항이 프로덕션에 반영되기 전에 실제 인프라에서 테스트됩니다.

반면, serverless-stack-demo-ext-resources 저장소에서는 변경이 덜 자주 발생합니다. 그리고 각 기능 브랜치마다 독립적인 DynamoDB 테이블 세트가 필요하지 않을 가능성이 높습니다. 사실, 팀은 prod, staging, dev와 같은 세 가지 스테이지만 가질 수 있습니다. 그리고 serverless-stack-demo-ext-api의 기능/버그 수정/풀 리퀘스트 스테이지는 모두 serverless-stack-demo-ext-resources의 dev 스테이지에 연결될 수 있습니다.

앱 내 서버리스 프로젝트 구성

따라서 임시 환경에서 복제할 필요가 없는 서비스가 있다면, 모든 인프라 서비스가 있는 저장소로 옮기는 것을 권장합니다. 이것이 우리가 대부분의 팀에서 보는 일반적인 방식입니다. 그리고 이 설정은 프로젝트와 팀이 성장함에 따라 잘 확장됩니다.

참고로, 우리는 LernaYarn Workspaces를 사용하여 이 모노레포 설정을 더욱 발전시켰습니다. 이에 대한 자세한 내용은 Using Lerna and Yarn Workspaces with Serverless 추가 챕터에서 확인할 수 있습니다.

이제 애플리케이션을 저장소로 구성하는 방법을 알아보았으니, 앱을 다양한 서비스로 나누는 방법을 살펴보겠습니다. 먼저 DynamoDB 테이블을 위한 별도의 서비스를 만드는 것부터 시작하겠습니다.