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

Ruby on Rails에서 서비스 객체(Service Object) 활용하기

이 글은 원래 Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling에 수록된 내용을 AppSignal 게스트 포스트 형식에 맞추어 수정한 것입니다.

애플리케이션에는 반드시 처리해야 할 기능이 많습니다. 하지만 그 로직이 꼭 컨트롤러나 모델에 속해야 하는 것은 아닙니다. 장바구니 결제 처리, 회원가입, 구독 시작 같은 기능이 대표적인 예입니다.

이런 로직을 전부 컨트롤러에 넣을 수도 있습니다. 하지만 그렇게 하면 여러 곳에서 같은 코드를 반복해서 작성하게 됩니다. 모델에 넣는 방법도 있지만, 때로는 IP 주소나 URL 파라미터처럼 컨트롤러에서만 쉽게 접근할 수 있는 정보가 필요할 때가 있습니다. 바로 이럴 때 필요한 것이 서비스 객체(Service Object)입니다.

서비스 객체의 역할은 기능을 하나로 캡슐화하고, 단일 서비스를 실행하며, 오류 발생 지점을 한곳으로 집중시키는 것입니다. 서비스 객체를 사용하면 애플리케이션의 여러 부분에서 동일한 기능이 필요할 때마다 똑같은 코드를 반복해서 작성할 필요가 없어집니다.

서비스 객체란 무엇인가?

서비스 객체는 그저 일반적인 Ruby 객체(Plain Old Ruby Object, 이른바 "PORO")입니다. 특정 디렉터리 아래에 위치하는 하나의 파일이며, 예측 가능한 응답을 반환하는 Ruby 클래스입니다.

응답이 '예측 가능'하려면 세 가지 핵심 요소가 필요합니다. 모든 서비스 객체는 다음과 같은 패턴을 따라야 합니다.

  • params 인자를 받는 초기화 메서드(initialize)를 가진다.
  • call이라는 이름의 단 하나의 공개 메서드(public method)를 가진다.
  • success?와 함께 payload 또는 error를 담은 OpenStruct를 반환한다.

OpenStruct란?

OpenStruct는 클래스와 해시(hash)의 중간쯤 되는 개념으로, 임의의 속성을 자유롭게 받을 수 있는 미니 클래스라고 생각하면 됩니다. 여기서는 딱 두 개의 속성만 다루는 임시 데이터 구조 정도로 활용합니다.

성공(success?)이 true라면 데이터 payload를 반환합니다.

OpenStruct.new({success?: true, payload: 'some-data'})

성공이 false라면 에러를 반환합니다.

OpenStruct.new({success?: false, error: 'some-error'})

아래는 베타 테스트 중인 AppSignal의 신규 API에서 데이터를 가져오는 서비스 객체의 실제 예제입니다.

module AppServices
 
  class AppSignalApiService
 
    require 'httparty'
 
    def initialize(params)
      @endpoint   = params[:endpoint] || 'markers'
    end
 
    def call
      result = HTTParty.get("https://appsignal.com/api/#{appsignal_app_id}/#{@endpoint}.json?token=#{appsignal_api_key}")
    rescue HTTParty::Error => e
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({success?: true, payload: result})
    end
 
    private
 
      def appsignal_app_id
        ENV['APPSIGNAL_APP_ID']
      end
 
      def appsignal_api_key
        ENV['APPSIGNAL_API_KEY']
      end
 
  end
end

이 파일은 AppServices::AppSignalApiService.new({endpoint: 'markers'}).call처럼 호출합니다. 저는 예측 가능한 응답을 만들기 위해 OpenStruct를 적극적으로 활용합니다. 덕분에 모든 로직의 구조적 패턴이 동일해지기 때문에 테스트 코드를 작성할 때 특히 큰 도움이 됩니다.

모듈(Module)의 역할

모듈을 사용하면 네임스페이스(name-spacing)가 생겨 다른 클래스와 충돌하는 것을 막을 수 있습니다. 즉, 특정 네임스페이스 안에 있기 때문에 여러 클래스에서 같은 메서드 이름을 사용해도 서로 충돌하지 않습니다.

모듈 이름의 또 다른 중요한 역할은 애플리케이션 내 파일 정리 방식과 연결됩니다. 서비스 객체들은 프로젝트의 services 폴더에 보관되는데, 위 예제처럼 모듈 이름이 AppServices인 서비스 객체는 services 디렉터리 아래의 AppServices 폴더에 들어갑니다.

저는 서비스 디렉터리를 여러 개의 폴더로 나누고, 각 폴더가 애플리케이션의 특정 영역을 담당하도록 구성합니다.

예를 들어 CloudflareServices 디렉터리에는 Cloudflare에서 서브도메인을 생성하고 삭제하는 서비스 객체들이 들어 있고, Wistia와 Zapier 관련 서비스도 각각의 폴더에 정리되어 있습니다.

이런 식으로 서비스 객체를 체계적으로 정리해 두면 구현 시 예측 가능성이 높아질 뿐만 아니라, 10,000피트 상공에서 애플리케이션 전체를 내려다보듯 한눈에 파악할 수 있습니다.

실전 예제: Stripe 구독 처리

이번에는 StripeServices 디렉터리를 살펴보겠습니다. 이 디렉터리에는 Stripe API와 통신하는 개별 서비스 객체들이 들어 있습니다. 이 파일들이 하는 일은 단순합니다. 애플리케이션에서 데이터를 가져와 Stripe로 전송하는 것뿐입니다. 따라서 구독 생성을 담당하는 StripeService 객체의 API 호출을 수정해야 할 때도 딱 한 곳만 고치면 됩니다.

전송할 데이터를 수집하는 로직은 별개의 서비스 객체에서 처리하며, 이 파일들은 AppServices 디렉터리에 위치합니다. 이 파일들이 애플리케이션에서 데이터를 모아 해당 외부 API와 통신하는 서비스 디렉터리로 넘겨주는 역할을 합니다.

구체적인 예로, 새 구독을 시작하는 사용자가 있다고 가정해 보겠습니다. 모든 흐름은 컨트롤러에서 시작됩니다. 다음은 SubscriptionsController입니다.

class SubscriptionsController < ApplicationController
 
  def create
    @subscription = Subscription.new(subscription_params)
 
    if @subscription.save
 
      result = AppServices::SubscriptionService.new({
        subscription_params: {
          subscription: @subscription,
          coupon: params[:coupon],
          token: params[:stripeToken]
        }
      }).call
 
      if result && result.success?
        sign_in @subscription.user
        redirect_to subscribe_welcome_path, success: 'Subscription was successfully created.'
      else
        @subscription.destroy
        redirect_to subscribe_path, danger: "Subscription was created, but there was a problem with the vendor."
      end
 
    else
      redirect_to subscribe_path, danger:"Error creating subscription."
    end
  end
end

먼저 애플리케이션 내부에서 구독을 생성하고, 성공하면 stripeToken과 쿠폰 등의 데이터를 AppServices::SubscriptionService 파일로 전달합니다.

AppServices::SubscriptionService 파일에서는 여러 작업이 진행됩니다. 세부 내용을 살펴보기 전에 먼저 전체 코드부터 확인해 보겠습니다.

module AppServices
  class SubscriptionService
 
    def initialize(params)
      @subscription     = params[:subscription_params][:subscription]
      @token            = params[:subscription_params][:token]
      @plan             = @subscription.subscription_plan
      @user             = @subscription.user
    end
 
    def call
 
      # create or find customer
      customer ||= AppServices::StripeCustomerService.new({customer_params: {customer:@user, token:@token}}).call
 
      if customer && customer.success?
 
        subscription ||= StripeServices::CreateSubscription.new({subscription_params:{
          customer: customer.payload,
          items:[subscription_items],
          expand: ['latest_invoice.payment_intent']
        }}).call
 
        if subscription && subscription.success?
          @subscription.update_attributes(
            status: 'active',
            stripe_id: subscription.payload.id,
            expiration: Time.at(subscription.payload.current_period_end).to_datetime
          )
          OpenStruct.new({success?: true, payload: subscription.payload})
        else
          handle_error(subscription&.error)
        end
 
      else
        handle_error(customer&.error)
      end
 
    end
 
    private
 
      attr_reader :plan
 
      def subscription_items
        base_plan
      end
 
      def base_plan
        [{ plan: plan.stripe_id }]
      end
 
      def handle_error(error)
        OpenStruct.new({success?: false, error: error})
      end
  end
end

큰 그림으로 보면 다음과 같습니다.

Stripe에 구독 생성을 요청하려면 먼저 Stripe 고객 ID(Stripe Customer ID)가 필요합니다. 이 작업 자체도 완전히 분리된 서비스 객체가 담당하며, 여러 단계를 거칩니다.

  1. 사용자 프로필에 stripe_customer_id가 저장되어 있는지 확인합니다. 저장되어 있다면 실제로 고객이 존재하는지 Stripe에서 조회한 후, OpenStruct의 payload로 반환합니다.
  2. 고객이 존재하지 않는다면 새로 고객을 생성하고 stripe_customer_id를 저장한 뒤, 마찬가지로 payload로 반환합니다.

어느 쪽이든 CustomerService는 Stripe 고객 ID를 반환하고, 그 과정에 필요한 모든 처리를 알아서 수행합니다. 해당 파일은 다음과 같습니다.

module AppServices
  class CustomerService
 
    def initialize(params)
      @user               = params[:customer_params][:customer]
      @token              = params[:customer_params][:token]
      @account            = @user.account
    end
 
    def call
      if @account.stripe_customer_id.present?
        OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
      else
        if find_by_email.success? && find_by_email.payload
          OpenStruct.new({success?: true, payload: @account.stripe_customer_id})
        else
          create_customer
        end
      end
    end
 
    private
 
      attr_reader :user, :token, :account
 
      def find_by_email
        result ||= StripeServices::RetrieveCustomerByEmail.new({email: user.email}).call
        handle_result(result)
      end
 
      def create_customer
        result ||= StripeServices::CreateCustomer.new({customer_params:{email:user.email, source: token}}).call
        handle_result(result)
      end
 
      def handle_result(result)
        if result.success?
          account.update_column(:stripe_customer_id, result.payload.id)
          OpenStruct.new({success?: true, payload: account.stripe_customer_id})
        else
          OpenStruct.new({success?: false, error: result&.error})
        end
      end
 
  end
end

이쯤 되면 왜 로직을 여러 개의 서비스 객체로 분산 구조화했는지 감이 잡히기 시작할 것입니다. 이 모든 로직을 하나의 거대한 파일에 몰아넣는 상상이 되십니까? 절대 안 됩니다!

다시 AppServices::SubscriptionService 파일로 돌아가 보겠습니다. 이제 Stripe에 전달할 수 있는 고객 정보까지 확보했으므로, 구독 생성에 필요한 데이터가 모두 준비되었습니다.

마지막 서비스 객체인 StripeServices::CreateSubscription 파일을 호출할 차례입니다.

이 서비스 객체 역시 절대 변하지 않습니다. 단 하나의 책임만 가집니다. 데이터를 받아 Stripe로 전송하고, 성공하면 객체를 payload로 반환하거나 에러를 반환하는 것입니다.

module StripeServices
 
  class CreateSubscription
 
    def initialize(params)
      @subscription_params = params[:subscription_params]
    end
 
    def call
      subscription = Stripe::Subscription.create(@subscription_params)
    rescue Stripe::StripeError => e
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({success?: true, payload: subscription})
    end
 
  end
 
end

아주 단순하죠? 그런데 "이 정도 크기면 굳이 파일로 분리할 필요가 있나"라고 생각하실 수도 있습니다. 위와 유사하지만 조금 다른 예제를 살펴보겠습니다. 이번에는 Stripe Connect를 통해 멀티테넌트(multi-tenant) 애플리케이션에서 사용하도록 확장한 버전입니다.

여기서부터 이야기가 재미있어집니다. 예시로 Mavenseed를 들겠지만, SportKeeper도 정확히 같은 로직으로 운영됩니다. 우리의 멀티테넌트 앱은 단일 모놀리식(monolith) 구조로, 테이블을 공유하면서 site_id 컬럼으로 구분됩니다. 각 테넌트(tenant)는 Stripe Connect를 통해 Stripe와 연동되며, 그 결과 얻은 Stripe Account ID를 테넌트 계정에 저장합니다.

동일한 Stripe API 호출을 사용하면서, 연결된 계정(connected account)의 Stripe Account만 함께 전달하면 Stripe가 해당 계정을 대신하여 API 호출을 처리해 줍니다.

즉, 어느 면에서 StripeService 객체는 하나의 파일을 호출하면서도 메인 애플리케이션과 테넌트 각각에게 서로 다른 데이터를 전달하는 이중 역할을 수행하는 셈입니다.

module StripeServices
 
  class CreateSubscription
 
    def initialize(params)
      @subscription_params  = params[:subscription_params]
      @stripe_account       = params[:stripe_account]
      @stripe_secret_key    = params[:stripe_secret_key] ? params[:stripe_secret_key] : (Rails.env.production? ? ENV['STRIPE_LIVE_SECRET_KEY'] : ENV['STRIPE_TEST_SECRET_KEY'])
    end
 
    def call
      subscription = Stripe::Subscription.create(@subscription_params, account_params)
    rescue Stripe::StripeError => e
      OpenStruct.new({success?: false, error: e})
    else
      OpenStruct.new({success?: true, payload: subscription})
    end
 
    private
 
      attr_reader :stripe_account, :stripe_secret_key
 
      def account_params
        {
          api_key: stripe_secret_key,
          stripe_account: stripe_account,
          stripe_version: ENV['STRIPE_API_VERSION']
        }
      end
  end
 
end

이 파일에 대해 몇 가지 기술적인 참고 사항을 덧붙이자면, 더 단순한 예제를 소개할 수도 있었지만 제대로 된 서비스 객체가 어떻게 구조화되고 어떤 형태로 응답하는지 직접 보여 드리는 것이 훨씬 가치 있다고 생각했습니다.

먼저, call 메서드에는 rescue와 else 문이 포함되어 있습니다. 이는 다음과 같이 작성한 것과 완전히 동일합니다.

def call
   begin
   rescue Stripe::StripeError => e
   else
   end
end

하지만 Ruby 메서드는 begin 블록을 암묵적으로 자동 시작하기 때문에 begin과 end를 명시적으로 쓸 필요가 없습니다. 이 문장은 "구독을 생성하고, 에러가 발생하면 에러를 반환하고, 그렇지 않으면 구독 객체를 반환한다"라고 읽으면 됩니다.

간결하고 우아합니다. Ruby는 정말 아름다운 언어이며, 서비스 객체의 활용은 그 아름다움을 잘 드러내 줍니다.

마무리하며

서비스 파일이 애플리케이션에서 수행하는 가치를 느끼셨기를 바랍니다. 서비스 객체는 로직을 예측 가능하게 조직화할 뿐만 아니라, 유지보수까지 쉬워지게 만드는 매우 효율적인 방법입니다.

P.S. Ruby Magic의 새 글이 발행되는 즉시 읽어 보고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!


이 챕터와 더 많은 내용은 제 새 책 Playbook Thirty-nine - A Guide to Shipping Interactive Web Apps with Minimal Tooling에서 확인하실 수 있습니다. 이 책에서는 여러 고트래픽·고수익 웹 애플리케이션을 혼자서 개발하고 운영해 온 1인 개발자의 직접 경험에 기반한 일반적인 패턴과 기법을 탑다운(top-down) 방식으로 다룹니다.

쿠폰 코드 appsignalrocks를 입력하면 30% 할인됩니다!