Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Ruby로 AWS Lambda 서버리스 애플리케이션 구축하기: 빌드부터 테스트, 배포까지

소프트웨어 개발은 어려운 일이지만, 개발 이후의 유지보수는 그보다 훨씬 더 어렵습니다. 유지보수에는 소프트웨어 패치와 서버 관리가 포함되는데, 이 글에서는 특히 서버 관리와 운영에 초점을 맞춰 살펴보겠습니다.

전통적으로 서버는 온프레미스(on-premises) 방식으로, 물리적인 하드웨어를 직접 구매하고 관리해야 했습니다. 하지만 클라우드 컴퓨팅의 등장으로 더 이상 물리적 서버를 소유할 필요가 없어졌습니다. 2006년 아마존이 AWS를 시작하며 EC2 서비스를 선보였을 때, 현대 클라우드 컴퓨팅 시대가 열렸습니다. 이러한 서비스 덕분에 우리는 더 이상 물리적 서버를 관리하거나 하드웨어를 업그레이드할 필요가 없어졌습니다. 많은 문제가 해결되었지만, 여전히 서버 유지보수와 리소스 관리는 사용자의 몫이었습니다. 이러한 발전을 한 단계 더 끌어올린 것이 바로 서버리스(serverless) 기술입니다.

서버리스 기술이란 무엇인가?

서버리스 기술은 서버를 관리하고 프로비저닝하는 작업을 클라우드 제공업체에 위임할 수 있게 해줍니다. 이 글에서는 AWS를 중심으로 설명하겠습니다.

여기서 '서버리스'라는 용어가 서버가 전혀 없다는 의미는 아닙니다. 서버는 존재하지만, 클라우드 제공업체가 완전히 관리합니다. 즉, 서버리스 기술 사용자 입장에서는 눈에 보이는 서버가 없는 셈입니다. 서버는 직접 노출되지 않으며, 관리 업무는 클라우드 제공업체가 자동화합니다. 다음과 같은 특징 때문에 '서버리스'라고 불립니다.

  • 운영 관리 불필요 - 서버 패치나 고가용성(HA)을 위한 관리가 필요 없습니다.
  • 필요에 따른 확장 - 소수의 사용자부터 수백만 명의 사용자까지 유연하게 대응합니다.
  • 사용한 만큼만 지불 - 비용이 실제 사용량 기반으로 산정됩니다.

서버리스 기술은 다음과 같이 분류할 수 있습니다.

  • 컴퓨팅(Compute): Lambda, Fargate
  • 스토리지(Storage): S3
  • 데이터 스토어(Data Store): DynamoDB, Aurora
  • 통합(Integration): API Gateway, SNS, SQS
  • 분석(Analytics): Kinesis, Athena

왜 서버리스 기술을 사용해야 할까?

비용 절감

사용량 기반 과금(pay-as-you-go)은 서버리스 기술의 가장 큰 장점 중 하나입니다. 트래픽 변동이 예측 불가능한 경우, 사용 패턴에 따라 서버를 확장하거나 축소해야 하는데, 자체 오토스케일링으로 이를 처리하는 것은 어렵고 비효율적일 수 있습니다. AWS Lambda 같은 서버리스 컴퓨팅은 유휴(idle) 상태에서도 비용을 지불할 필요가 없기 때문에 손쉽게 비용을 절감할 수 있습니다.

개발자 생산성 향상

서버리스 컴퓨팅은 클라우드 제공업체가 완전히 관리하는 서비스이므로, 개발자가 서버를 프로비저닝하거나 서버 애플리케이션을 개발할 필요가 없습니다. 서버 관리 없이 바로 코딩을 시작할 수 있고, 서버 패치나 오토스케일링 관리 부담도 사라집니다. 이렇게 절약된 시간은 곧 개발자 생산성 향상으로 이어집니다.

탄력성(Elasticity)

서버리스 컴퓨팅은 높은 탄력성을 갖추고 있어 사용량에 따라 자동으로 확장·축소됩니다. 갑작스러운 사용자 폭증도 손쉽게 처리할 수 있어, 개발자에게 큰 이점이자 시간 절약 요소가 됩니다.

고가용성

컴퓨팅이 서버리스 방식으로 클라우드 제공업체에 의해 관리되면, 서버의 가동률이 높아지고 장애 조치(failover)도 자동으로 처리됩니다. 원래 이런 문제들을 관리하려면 전문적인 기술이 필요하지만, 서버리스 방식에서는 운영 담당자와 개발자의 역할을 한 사람이 모두 수행할 수 있습니다.

Ruby로 서버리스 기능 구현하기

AWS에 따르면 Ruby는 AWS에서 가장 널리 사용되는 언어 중 하나입니다. Lambda는 2018년 11월부터 Ruby를 지원하기 시작했습니다. 이번 섹션에서는 AWS가 제공하는 서버리스 기술만으로 Ruby 웹 API를 만들어 보겠습니다.

AWS에서 서버리스 인프라를 만들려면 AWS 콘솔에 로그인해서 바로 생성할 수도 있습니다. 하지만 우리는 쉽게 테스트할 수 있고 재해 복구(disaster recovery)에도 용이한 형태로 개발하고 싶습니다. 그래서 서버리스 기능을 코드로 작성하는데, 이를 위해 AWS는 SAM(Serverless Application Model)을 제공합니다. SAM은 AWS에서 서버리스 애플리케이션을 구축하기 위한 프레임워크로, Lambda, 데이터베이스, API를 설계할 수 있는 YAML 기반 문법을 제공합니다. SAM 애플리케이션은 AWS SAM CLI로 빌드할 수 있으며, 공식 페이지에서 다운로드할 수 있습니다.

AWS SAM CLI는 AWS CloudFormation을 기반으로 동작합니다. CloudFormation으로 IaC(Infrastructure as Code)를 작성해 본 경험이 있다면 매우 쉽게 적응할 수 있습니다. 대안으로 Serverless Framework를 사용할 수도 있지만, 이 글에서는 AWS SAM을 사용하겠습니다.

SAM CLI를 사용하기 전에 다음 항목들이 준비되어 있는지 확인하세요.

  • AWS 프로파일 설정 완료
  • Docker 설치
  • SAM CLI 설치

이제 서버리스 애플리케이션을 개발해 보겠습니다. 먼저 DynamoDB와 Lambda 같은 서버리스 인프라를 애플리케이션에 구성하는 것부터 시작합니다. 데이터베이스부터 만들어 보겠습니다.

DynamoDB

DynamoDB는 AWS가 관리하는 서버리스 데이터베이스 서비스입니다. 서버리스 특성 덕분에 설정이 매우 빠르고 간단합니다. DynamoDB를 생성하려면 다음과 같이 SAM 템플릿을 정의합니다.

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Resources:
  UsersTable:
    Type: AWS::Serverless::SimpleTable
    Properties:
      PrimaryKey:
        Name: id
        Type: String
      TableName: users

SAM CLI와 위 템플릿으로 기본적인 DynamoDB 테이블을 만들 수 있습니다. 먼저 서버리스 앱의 패키지를 빌드해야 합니다. 다음 명령어를 실행하면 패키지가 빌드되고 S3로 업로드됩니다. 명령 실행 전에 반드시 serverless-users-bucket이라는 이름의 S3 버킷을 미리 생성해 두세요.

$ sam package --template-file sam.yaml \
              --output-template-file out.yaml \
              --s3-bucket serverless-users-bucket

이제 S3가 서버리스 앱의 템플릿과 코드의 소스 저장소 역할을 하게 됩니다. 이 내용은 Lambda 함수를 생성할 때 다시 설명하겠습니다.

이제 이 템플릿을 배포하여 DynamoDB를 생성합니다.

$ sam deploy --template-file out.yaml \
             --stack-name serverless-users-app \
             --capabilities CAPABILITY_IAM

이것으로 DynamoDB 설정이 완료되었습니다. 다음으로, 이 테이블을 사용할 Lambda를 만들어 보겠습니다.

Lambda

Lambda는 AWS가 제공하는 서버리스 컴퓨팅 서비스입니다. 코드가 실행되는 실제 서버를 관리할 필요 없이 필요할 때 코드를 실행할 수 있습니다. Lambda는 비동기 프로세스, REST API, 예약 작업(scheduled job) 등을 실행하는 데 활용할 수 있습니다. 우리가 할 일은 핸들러(handler) 함수를 작성해서 AWS Lambda에 푸시하는 것뿐입니다. Lambda는 이벤트(event)를 기반으로 작업 실행을 처리합니다. 이벤트는 API Gateway, SQS, S3 등 다양한 소스에서 트리거될 수 있고, 다른 코드베이스에서 호출될 수도 있습니다. 트리거되면 Lambda 함수는 이벤트와 컨텍스트(context) 파라미터를 전달받는데, 이 파라미터들의 값은 트리거 소스에 따라 달라집니다. 또한 이벤트를 핸들러에 직접 전달하여 수동 또는 프로그래밍 방식으로 Lambda 함수를 호출할 수도 있습니다. 핸들러는 두 개의 인자를 받습니다.

Event(이벤트) - 이벤트는 일반적으로 트리거 소스에서 전달되는 키-값(key-value) 해시입니다. SQS, Kinesis, API Gateway 같은 다양한 소스에서 트리거될 때 값들이 자동으로 전달되며, 수동으로 호출할 때는 여기에 이벤트를 직접 전달할 수 있습니다. 이벤트에는 Lambda 함수 핸들러의 입력 데이터가 담겨 있습니다. 예를 들어 API Gateway의 경우 요청 본문(request body)이 이 이벤트 안에 포함됩니다.

Context(컨텍스트) - 컨텍스트는 핸들러 함수의 두 번째 인자입니다. 트리거 소스, Lambda 함수 이름, 버전, 요청 ID(request-id) 등 구체적인 세부 정보가 포함되어 있습니다.

핸들러의 출력값은 Lambda 함수를 트리거한 서비스로 다시 반환됩니다. 즉, Lambda 함수의 출력은 핸들러 함수의 반환값입니다.

AWS Lambda는 Ruby를 포함해 7가지 언어를 지원합니다. 여기서는 AWS Ruby SDK를 사용해 DynamoDB에 연결하겠습니다.

코드를 작성하기 전에 SAM 템플릿으로 Lambda 인프라를 먼저 생성하겠습니다.

AWSTemplateFormatVersion: "2010-09-09"
Transform: AWS::Serverless-2016-10-31
Description: "Serverless users app"

Resources:
  CreateUserFunction:
    Type: AWS::Serverless::Function
    Properties:
      Handler: users.create
      Runtime: ruby2.7
      Policies:
        - DynamoDBWritePolicy:
            TableName: !Ref UsersTable
      Environment:
        Variables:
          USERS_TABLE: !Ref UsersTable

핸들러에는 실행할 함수에 대한 참조를 Handler: <파일명>.<메서드명> 형식으로 지정합니다.

Lambda가 사용하는 리소스에 따라 연결할 정책(policy)은 서버리스 정책 템플릿을 참고하세요. 우리의 Lambda 함수는 DynamoDB에 데이터를 쓰기 때문에 policies 섹션에 DynamoDBWritePolicy를 사용했습니다.

또한 Lambda 함수가 지정된 데이터베이스로 요청을 보낼 수 있도록 환경 변수 USERS_TABLE을 함께 전달합니다.

이것으로 Lambda 인프라 준비가 끝났습니다. 이제 Lambda 함수가 실행할, DynamoDB에 사용자를 생성하는 코드를 작성해 보겠습니다.

Gemfile에 aws-record 젬을 추가합니다.

# Gemfile
source 'https://rubygems.org' do
  gem 'aws-record', '~> 2'
end

입력값을 DynamoDB에 쓰는 코드를 추가합니다.

# users.rb
require 'aws-record'

class UsersTable
  include Aws::Record
  set_table_name ENV['USERS_TABLE']
  string_attr :id, hash_key: true
  string_attr :body
end

def create(event:,context:)
  body = event["body"]
  id = SecureRandom.uuid
  user = UsersTable.new(id: id, body: body)
  user.save!
  user.to_h
end

정말 간단하죠? AWS는 DynamoDB에 접근하기 위한 aws-record 젬을 제공하며, Rails의 activerecord와 매우 유사하게 동작합니다.

다음 명령어로 의존성을 설치합니다.

참고: Lambda에 정의된 것과 동일한 버전의 Ruby를 사용해야 합니다. 이 예제에서는 로컬 머신에 Ruby 2.7이 설치되어 있어야 합니다.

# install dependencies

$ bundle install
$ bundle install --deployment

변경 사항을 패키징합니다.

$ sam package --template-file sam.yaml \
              --output-template-file out.yaml \
              --s3-bucket serverless-users-bucket

배포합니다.

sam deploy --template-file out.yaml \
             --stack-name serverless-users-app \
             --capabilities CAPABILITY_IAM

이 코드로 이제 입력값을 데이터베이스에 쓸 수 있는 Lambda가 실행 중입니다. HTTP 호출로 접근할 수 있도록 Lambda 앞에 API Gateway를 추가할 수도 있습니다. API Gateway는 요청 속도 제한(rate limiting), 인증 등 다양한 API 관리 기능을 제공하지만, 사용량에 따라 비용이 커질 수 있습니다. API 관리 기능이 없는 HTTP API만 사용하는 더 저렴한 선택지도 있으니, 용도에 맞게 적절히 선택하시면 됩니다.

AWS Lambda에는 몇 가지 제한이 있습니다. 일부는 변경 가능하지만, 나머지는 고정되어 있습니다.

  • 메모리 - 기본적으로 Lambda는 실행 시 128MB 메모리를 할당받으며, 64MB 단위로 최대 3,008MB까지 늘릴 수 있습니다.
  • 타임아웃 - Lambda 함수에는 코드 실행 시간 제한이 있습니다. 기본값은 3초이며, 최대 900초까지 늘릴 수 있습니다.
  • 스토리지 - Lambda는 임시 저장을 위한 /tmp 디렉터리를 제공하며, 용량 제한은 512MB입니다.
  • 요청 및 응답 크기 - 동기식 트리거는 최대 6MB, 비동기식 트리거는 최대 256MB입니다.
  • 환경 변수 - 최대 4KB입니다.

Lambda에는 이런 제한들이 있으므로, 해당 한계 안에서 동작하도록 코드를 작성하는 것이 좋습니다. 만약 한계를 초과한다면 코드를 분할하여 하나의 Lambda가 다른 Lambda를 트리거하도록 구성할 수 있습니다. 또한 AWS에서 제공하는 Step Functions를 활용하면 여러 Lambda 함수를 순차적으로 연결할 수도 있습니다.

서버리스 애플리케이션을 로컬에서 테스트하는 방법

서버리스 애플리케이션은 관리형 서버리스 서비스를 제공하는 벤더가 필요하기 때문에, 테스트 역시 AWS에 의존하게 됩니다. 다행히 AWS는 로컬 테스트를 위한 몇 가지 옵션을 제공하며, AWS 서버리스 기술과 호환되는 오픈소스 도구를 활용해 로컬에서 테스트할 수도 있습니다.

이제 우리의 Lambda 함수와 DynamoDB를 테스트해 보겠습니다. 이를 위해 두 서비스를 로컬에서 실행해야 합니다.

먼저 Docker 네트워크를 생성합니다. 이 네트워크는 Lambda 함수와 DynamoDB 간의 통신을 가능하게 해줍니다.

$ docker network create lambda-local --docker-network lambda-local

DynamoDB Local은 AWS가 제공하는 DynamoDB의 로컬 버전으로, 로컬 테스트에 활용할 수 있습니다. 다음 Docker 이미지를 실행하여 DynamoDB Local을 구동합니다.

$ docker run -p 8000:8000 --network lambda-local --name dynamodb amazon/dynamodb-local

user.rb 파일에 다음 코드를 추가합니다. 이렇게 하면 Lambda가 로컬 DynamoDB에 연결됩니다.

local_client = Aws::DynamoDB::Client.new(
  region: "local",
  endpoint: 'https://dynamodb:8000'
)
UsersTable.configure_client(client: local_client)

Lambda의 입력값을 담은 input.json 파일을 추가합니다.

{
  "name": "Milap Neupane",
  "location": "Global"
}

Lambda를 실행하기 전에 로컬 DynamoDB에 테이블을 먼저 생성해야 합니다. 이를 위해 aws-record가 제공하는 마이그레이션 기능을 사용하겠습니다. migrate.rb 파일을 만들고 다음 마이그레이션 코드를 추가합니다.

require 'aws-record'
require './users.rb'

local_client = Aws::DynamoDB::Client.new(
  region: "local",
  endpoint: 'https://localhost:8000'
)
migration = Aws::Record::TableMigration.new(UsersTable, client: local_client)

migration.create!(
  provisioned_throughput: {
    read_capacity_units: 5,
    write_capacity_units: 5
  }
)
migration.wait_until_available

마지막으로 다음 명령어로 Lambda를 로컬에서 실행합니다.

$ sam local invoke "CreateUserFunction" -t sam.yaml \
                                        -e input.json \
                                        --docker-network lambda-local

이 명령을 실행하면 DynamoDB 테이블에 사용자 데이터가 생성됩니다.

이 외에도 LocalStack처럼 AWS 스택 전체를 로컬에서 실행할 수 있는 도구들도 있습니다.

서버리스 컴퓨팅은 언제 사용해야 할까?

서버리스 컴퓨팅 도입 여부를 결정할 때는 장점과 단점을 모두 고려해야 합니다. 다음 특성들을 기준으로 서버리스 방식의 적합 여부를 판단할 수 있습니다.

비용

  • 애플리케이션에 유휴 시간이 많고 트래픽이 일정하지 않다면, Lambda가 비용 절감에 효과적입니다.
  • 반대로 트래픽이 꾸준히 일정한 애플리케이션이라면 AWS Lambda 사용이 오히려 비용이 높아질 수 있습니다.

성능

  • 애플리케이션이 성능에 민감하지 않다면 AWS Lambda가 좋은 선택입니다.
  • Lambda에는 콜드 부트(cold boot) 시간이 존재하며, 콜드 부트 시 응답이 느려질 수 있습니다.

백그라운드 처리

  • Lambda는 백그라운드 작업 처리에 적합합니다. Sidekiq 같은 오픈소스 도구는 서버 확장과 유지보수 오버헤드가 발생하는데, AWS Lambda와 AWS SQS 큐를 조합하면 서버 관리 부담 없이 백그라운드 작업을 처리할 수 있습니다.

동시성 처리

  • Ruby에서 동시성(concurrency) 구현은 쉽지 않은 것으로 알려져 있습니다. Lambda를 사용하면 프로그래밍 언어 차원의 지원 없이도 동시성을 확보할 수 있으며, 성능 향상에도 도움이 됩니다.

주기적 또는 일회성 스크립트 실행

  • 보통 cron 작업으로 Ruby 코드를 실행하지만, 대규모 애플리케이션에서는 cron 작업의 서버 유지보수가 어려울 수 있습니다. 이벤트 기반 Lambda를 활용하면 애플리케이션 확장에 유리합니다.

이상으로 서버리스 애플리케이션에서 Lambda 함수를 활용할 수 있는 대표적인 사용 사례들을 살펴보았습니다. 모든 것을 서버리스로 구축할 필요는 없으며, 위에서 언급한 사용 사례에 한해 하이브리드 모델로 구성할 수도 있습니다. 이는 애플리케이션 확장성을 높이고 개발자 생산성 향상에도 기여합니다. 서버리스 기술은 계속 진화하고 있으며, AWS Fargate나 Google Cloud Run처럼 AWS Lambda의 제약이 없는 다른 서버리스 기술들도 주목할 만합니다.