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

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

서버리스 함수(Serverless Function)는 클라우드 서비스를 개발하고 배포하는 새로운 프로그래밍 패러다임입니다. 서버리스 환경에서는 백엔드 서비스의 프로비저닝, 유지 관리, 확장 작업을 모두 클라우드 공급자에게 위임합니다. 덕분에 개발자는 특정 문제 해결에만 집중할 수 있어 개발 생산성이 크게 향상됩니다. 서버리스 함수에는 여러 장단점이 있지만, 구축 시 반드시 고려해야 할 요소 중 하나가 바로 언어 지원입니다. 최근 Google은 Google Cloud Functions에서 Ruby 2.7 지원을 발표했는데, 이 글에서는 Ruby로 서버리스 함수를 빌드, 테스트, 배포하는 전 과정과 함께 서버리스 함수의 장단점까지 자세히 살펴보겠습니다.

서버리스 OTP 시스템 구축하기

OTP(One-Time Password, 일회용 비밀번호)는 인증 목적으로 사용되는 짧은 숫자 코드입니다. 은행이 고객의 신원을 확인하기 위해 문자 메시지로 OTP를 전송하는 것이 대표적인 예입니다.

이 글에서는 세 가지 핵심 기능을 담당하는 OTP 함수를 만들어 보겠습니다.

POST /otp: OTP를 생성하여 지정된 phone_number로 전송합니다.

# 요청
{
  "phone_number": "+2347012345678"
}

# 응답
{
  "status": true,
  "message": "OTP sent successfully",
  "data": {
    "phone_number": "+2347012345678",
    "otp": 6872,
    "expires_at": "2021-02-09 07:15:25 +0100"
  }
}

PUT /otp/verify: 사용자가 입력한 OTP가 유효한지 검증합니다.

# 요청
{
  "phone_number": "+2347012345678",
  "otp": 7116
}

# 응답
{
  "status": true,
  "message": "OTP verified",
  "data": {}
}

PUT /otp/resend: 새로운 OTP를 생성하여 해당 phone_number로 재전송합니다.

# 요청
{
  "phone_number": "+2347012345678"
}

# 응답
{
  "status": true,
  "message": "OTP sent successfully",
  "data": {
    "phone_number": "+2347012345678",
    "otp": 8533,
    "expires_at": "2021-02-09 08:59:16 +0100"
  }
}

설명의 편의를 위해 이번 예제에서는 SQL 또는 NoSQL 데이터베이스 대신 Cloud Memorystore(GCP의 Redis 또는 Memcache)를 데이터 저장소로 사용하겠습니다. 이를 통해 무상태(stateless) 환경에서 공유 상태를 다루는 방법도 함께 학습할 수 있습니다.

Ruby로 Google Cloud Function 작성하기

GCF(Google Cloud Functions)에서 함수를 작성하려면 Google Cloud 팀이 제공하는 Functions Framework를 활용합니다(자세한 내용은 뒤에서 다룹니다).

먼저 앱 디렉터리를 생성하고 해당 디렉터리로 이동합니다.

mkdir otp-cloud-function && cd otp-cloud-function

다음으로 Gemfile을 생성하고 의존성을 설치합니다.

대부분의 표준 Ruby 애플리케이션과 마찬가지로 bundler를 사용해 함수의 의존성을 관리합니다.

source "https://rubygems.org"

# 핵심
 gem "functions_framework", "~> 0.7"

# SMS 전송용 Twilio
gem 'twilio-ruby', '~> 5.43.0'

# 데이터베이스
gem 'redis'

# 커넥션 풀링
gem 'connection_pool'

# 시간 관리
gem 'activesupport'

# API 직렬화
gem 'active_model_serializers', '~> 0.10.0'

group :development, :test do
  gem 'pry'
  gem 'rspec'
  gem 'rspec_junit_formatter'
  gem 'faker', '~> 2.11.0'
end
bundle install

함수 생성하기

일반적으로 호스팅 환경에 따라 함수 코드가 위치할 파일을 자유롭게 지정할 수 있지만, Google Cloud Functions는 프로젝트 디렉터리 최상위에 app.rb 파일을 요구합니다. 이제 함수를 작성할 준비가 되었습니다.

app.rb를 열고 함수를 생성합니다:

# Cloud Functions 엔트리포인트

require 'functions_framework'
require 'connection_pool'
require 'active_model_serializers'
require './lib/store'
require './lib/send_sms_notification'
require './lib/response'
require './lib/serializers/models/base_model'
require './lib/serializers/models/otp_response'
require './lib/serializers/application_serializer'
require './lib/serializers/base_model_serializer'
require './lib/serializers/otp_response_serializer'

FunctionsFramework.on_startup do |function|
  # 공유 Redis 클라이언트 설정
  require 'redis'
  set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
end

# HTTP 함수 정의
FunctionsFramework.http "otp" do |request|

  store = Store.new(global(:redis_client))
  data = JSON.parse(request.body.read)

  if  request.post? && request.path == '/otp'
    phone_number = data['phone_number']
    record = store.get(phone_number)
    unless record.nil? || record.expired?
      data = Models::OtpResponse.new(phone_number: phone_number,
                                      otp: record['otp'],
                                      expires_at: record['expires_at'])
      json = Response.generate_json(status: true,
                            message: 'OTP previously sent',
                            data: data)

      return json
    end

    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call

    data = Models::OtpResponse.new(phone_number: phone_number,
                                    otp: record['otp'],
                                    expires_at: record['expires_at'])

    Response.generate_json(status: true,
                          message: 'OTP sent successfully',
                          data: data)

  elsif request.put? && request.path == '/otp/verify'
    phone_number = data['phone_number']
    record = store.get(phone_number)

    if record.nil?
      return Response.generate_json(status: false, message: "OTP not sent to number")
    elsif record.expired?
      return Response.generate_json(status: false,  message: 'OTP code expired')
    end

    is_verified = data['otp'] == record['otp']

    if is_verified
      return Response.generate_json(status: true, message: 'OTP verified')
    else
      return Response.generate_json(status: false, message: 'OTP does not match')
    end

  elsif request.put? && request.path == '/otp/resend'
    phone_number = data['phone_number']
    store.del(phone_number)

    otp = rand(1111..9999)
    record = store.set(phone_number, otp)
    SendSmsNotification.new(phone_number, otp).call

    data = Models::OtpResponse.new(phone_number: phone_number,
                                    otp: record['otp'],
                                    expires_at: record['expires_at'])

    json = Response.generate_json(status: true,
                          message: 'OTP sent successfully',
                          data: data)
  else
    Response.generate_json(status: false,
                            message: 'Request method and path did not match')
  end
end

코드 분량이 많으니 주요 부분을 하나씩 살펴보겠습니다.

  • Functions_Framework.on_startup은 각 Ruby 인스턴스가 요청 처리를 시작하기 전에 실행되는 초기화 블록입니다. 함수가 호출되기 전에 필요한 초기화 작업을 수행하기에 적합합니다. 여기서는 Redis 서버에 대한 커넥션 풀을 생성하고 공유하는 데 사용했습니다:

    set_global :redis_client, ConnectionPool.new(size: 5, timeout: 5) { Redis.new }
    

    이렇게 하면 여러 동시 함수 호출 간에 Redis 커넥션 객체 풀을 안전하게 공유할 수 있습니다. startup 블록은 여러 개 정의할 수 있으며, 정의된 순서대로 실행됩니다. 참고로 Functions Framework는 함수 실행 종료 후에 호출되는 후처리 훅(hook)은 별도로 제공하지 않습니다.

  • Functions_Framework.http 'otp' do |request|는 함수의 요청 및 응답 처리를 담당합니다. 이 함수는 세 가지 라우팅 패턴을 지원하며, 그 외에도 다른 Google 서비스에서 발생하는 이벤트를 처리하는 함수(예: Functions_Framework.cloud_event 'otp' do |event|)를 정의할 수 있습니다. 또한 하나의 파일에 여러 함수를 정의하되 각각 독립적으로 배포하는 것도 가능합니다.

  • store = Store.new(global(:redis_client))에서 global 메서드는 전역 공유 상태에 저장된 객체를 조회하는 데 사용됩니다. 위 코드에서는 startup 블록에서 설정한 커넥션 풀에서 Redis 클라이언트를 가져옵니다.

  • ResponseModels::OtpResponseactive_model_serializers를 활용해 응답을 직렬화하여 형식에 맞는 JSON 응답을 반환합니다.

로컬 환경에서 함수 테스트하기

Functions Framework 라이브러리를 사용하면 클라우드에 배포하기 전에 로컬에서 손쉽게 함수를 테스트할 수 있습니다. 로컬 테스트는 아래 명령어로 실행합니다.

bundle exec functions-framework-ruby --target=otp --port=3000

--target 옵션은 배포할 함수를 선택하는 데 사용됩니다.

수동 테스트만으로는 부족합니다. 자동화된 테스트와 셀프 테스팅 소프트웨어야말로 테스트의 궁극적인 목표입니다. Functions FrameworkMinitestRSpec을 위한 헬퍼 메서드를 제공하여 httpcloudevents 핸들러를 모두 테스트할 수 있도록 지원합니다. 다음은 테스트 예시입니다.

require './spec/spec_helper.rb'
require 'functions_framework/testing'

describe  'OTP Functions' do
  include FunctionsFramework::Testing

  describe 'Send OTP', redis: true do
    let(:phone_number) { "+2347012345678" }
    let(:body) { { phone_number: phone_number }.to_json }
    let(:headers) { ["Content-Type: application/json"] }

    it 'should send OTP successfully' do
      load_temporary "app.rb" do
        request = make_post_request "/otp", body, headers

        response = call_http "otp", request
        expect(response.status).to eq 200
        expect(response.content_type).to eq("application/json")

        parsed_response = JSON.parse(response.body.join)
        expect(parsed_response['status']).to eq true
        expect(parsed_response['message']).to eq 'OTP sent successfully'
      end
    end
  end
end

함수 배포하기

먼저 함수가 의존하는 Redis 서버를 Google Cloud Memorystore로 배포해야 합니다. GCP에 Redis 서버를 배포하는 방법은 이 글의 범위를 벗어나므로 자세히 다루지 않겠습니다.

Google Cloud Functions 환경에 함수를 배포하는 방법은 여러 가지가 있습니다. 로컬 머신에서 배포하는 방식, GCP 콘솔에서 배포하는 방식, 그리고 코드 저장소에서 배포하는 방식입니다. 현대 소프트웨어 엔지니어링에서는 대부분의 개발 과정에 CI/CD 프로세스를 적용하는 것을 권장합니다. 이 글에서는 deploy-cloud-functions 액션을 활용해 GitHub Actions로 GitHub에서 Cloud Function을 배포하는 방법을 중점적으로 살펴보겠습니다.

배포 파일(.github/workflows/deploy.yml)을 설정해 보겠습니다.

name: Deployment
on:
  push:
    branches:
      - main
jobs:
  deploy:
    name: Function Deployment
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - id: deploy
        uses: google-github-actions/deploy-cloud-functions@main
        with:
          name: otp-cloud-function
          runtime: ruby26
          credentials: ${{ secrets.gcp_credentials }}
          env_vars: "TWILIO_ACCOUNT_SID=${{ secrets.TWILIO_ACCOUNT_SID }},TWILIO_AUTH_TOKEN=${{ secrets.TWILIO_AUTH_TOKEN }},TWILIO_PHONE_NUMBER=${{ secrets.TWILIO_PHONE_NUMBER }},REDIS_URL=${{ secrets.REDIS_URL }}"

환경 변수(Environment Variables)

위 코드의 마지막 줄은 Google Cloud 환경에서 함수가 사용할 수 있는 환경 변수를 지정하는 부분입니다. 보안상의 이유로 이러한 변수들을 코드베이스에 노출하지 않고, GitHub Actions 시크릿(Secrets)을 활용해 정보를 안전하게 보관한다는 점에 주목하세요. 토큰들이 제대로 배포되었는지 확인하려면 Google 콘솔에서 클라우드 함수를 확인하면 됩니다. 아래 이미지를 참고하세요.

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

인증(Authentication)

Cloud Functions Admin 역할과 Service Account User 역할을 가진 서비스 계정(Service Account)을 생성합니다.

서비스 계정은 머신 간(machine-to-machine) IAM 통신에 사용됩니다. 즉, Google Cloud에서 실행 중이든 아니든 한 시스템이 Google Cloud의 다른 시스템과 통신할 때, 누가 우리의 Google 리소스에 접근을 요청하는지 식별하기 위해 서비스 계정이 필요합니다. Cloud Functions AdminService Account User 역할은 해당 사용자가 리소스에 접근할 권한이 있는지 판단하는 데 사용됩니다. 이 시나리오에서는 GitHub Actions 러너가 함수를 배포할 권한을 가진 서비스 계정으로 인증하며 Google Cloud와 통신합니다.

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

서비스 계정 키를 생성하고, JSON 파일을 다운로드한 뒤 GitHub Secrets에 추가합니다.

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

완료되었습니다! 🎉 클라우드 함수가 성공적으로 배포되었습니다.

Cloud Functions 제한 vs AWS 제한

아래는 두 대표 서버리스 함수 제공업체의 상세 비교입니다.

Ruby로 Google Cloud Functions 빌드·테스트·배포하기: 서버리스 OTP 시스템 완벽 가이드

Functions Framework vs Serverless Framework

지금까지 Google Cloud Functions용 클라우드 함수를 구축하는 방법을 살펴봤습니다. 이번 섹션에서는 Functions Framework를 사용한 구축 방식과 Serverless Framework를 사용한 구축 방식을 비교해 보겠습니다.

  • Serverless Frameworkserverless.yml을 기반으로 하는 반면, Functions Framework는 Functions Framework Contract를 기반으로 합니다. 이 Contract는 Google Cloud 인프라 전반에 걸쳐 서버리스 함수를 배포하는 데 사용됩니다.
  • Serverless Framework는 참고할 만한 예제가 많지 않고, Ruby로 서버리스 함수를 Google의 다양한 서버리스 환경(Cloud Functions, Cloud Run, Knative 환경)에 구축하고 배포하는 방법도 명확하지 않습니다. 반면 Functions Framework Contract를 사용하면 Ruby로 이러한 Google 제품들에 맞춰 손쉽게 구축할 수 있습니다.
    • 앞선 내용과 연결되는 부분으로, Functions Framework Contract를 사용하면 배포 프로세스를 크게 변경하지 않고도 함수의 기반이 되는 언어를 손쉽게 교체할 수 있습니다.
  • 이 글을 작성하는 시점 기준으로 Functions Framework는 Google Cloud 서버리스 환경과 Knative 환경 간의 상호 운용성만 지원합니다. 반면 Serverless Framework는 여러 공급자의 다양한 플랫폼을 지원합니다.

전체 코드는 아래 링크에서 확인하실 수 있습니다.