HTTP나 DB나 거기서 거기다

구경회·2022년 5월 15일
4
post-thumbnail

발단

최근 회사에서 DB를 쿼리하던 것을 gRPC 혹은 REST 이용하여 객체의 정보를 얻는 것으로 바꿀 때가 있었다. 이 때마다 상당히 많은 양의 코드를 바꾸고, 인터페이스를 바꾸었다. 그런데, 그래야할까? 어차피 데이터베이스나 REST API나 gRPC나 도메인과 떨어진 외부 인프라라는 점에서는 동일하다. 그렇다면, 동일한 인터페이스를 제공할수도 있지 않을까? 이 글은 그 질문에 대한 답이다.

객체 만들기

우리가 예시로 삼을 것은 https://sampleapis.com/api-list/coffee 위 사이트에서 제공하는 커피에 대한API이다. 이 사이트는 다음과 같은 API를 제공한다.

http GET https://api.sampleapis.com/coffee/iced
http GET https://api.sampleapis.com/coffee/iced/<:id>
http GET https://api.sampleapis.com/coffee/hot
http GET https://api.sampleapis.com/coffee/hot/<:id>

결과로 날라오는 Json schema는 다음과 같다.

{
  "description": "Mazagran coffee is ...(생략)...",
  "id": 6,
  "ingredients": [
    "Coffee",
    "Sugar",
    "Lemon",
    "Rum*"
  ],
  "title": "Mazagran"
}

루비 클래스로 해당 json은 다음과 같이 표현할 수 있다.

class Drink
  attr_reader :id, :title, :description, :ingredients, :ice

  def initialize(id:, title:, description:, ingredients:, ice:)
    @id = id
    @title = title
    @description = description
    @ingredients = ingredients
    @ice = ice
  end

  def ice?
    @ice
  end

  def hot?
    !@ice
  end
end

Converter와 Client 만들기

이제 여기에 쿼리 메소드를 추가해보자. 액티브레코드와 비슷한 인터페이스를 제공하려면
다음과 같이 사용할 수 있어야 할 것이다.

Drink.hot 
# [#<Drink:0x00007f9736ef7688 @description="Black...", 
#  @ice=false, @id=1, @ingredients=["Coffee"], @title="Black">,
#  <Drink:0x00007f9736ef75c0 @description="As the most....", 
#  @ice=false, @id=2, @ingredients=["Espresso", "Steamed Milk"], @title="Latte">]
assert Drink.all.size == 4

d = Drink.new(id: 4, title: "Mazagran",
              description: "Mazagran coffee is ...(생략)...",
              ingredients: ["Coffee", "Sugar", "Lemon", "Rum*"], ice: false)

assert Drink.find(id: 4, hot: false) == d

하나씩 구현해보자. 우선 Drink#hotDrink#ice를 추가하자.

이 쿼리 메서드들은 모델 자체와는 관심사가 동떨어져 있다. 따라서 이것들은 따로 구현해야할 것이다.
그래서 다음과 같이 Drink::QueryMethods라는 모듈을 선언하자. 실제로 레일즈도 ActiveRecord::QueryMethods에 따로
where, find 등을 구현하고 있다.

우선 json을 Drink로 바꾸는 클래스를 구현해보자.

class Drink::Converter
  def initialize(convertable, ice:)
    @hashes = convertable.is_a?(Array) ? convertable : [convertable]
    @ice = ice
  end

  def to_drink
    return drink(@hashes.first) if @hashes.size == 1

    @hashes.map { |hash| drink(hash) }
  end

  private

  attr_reader :ice

  def drink(hash)
    Drink.new(
      id: hash['id'],
      title: hash['title'],
      ingredients: hash['ingredients'],
      description: hash['description'],
      ice: ice
    )
  end
end

그리고 whereChain과 비슷한 것을 구현해보자.

class Drink::Query
  BASE_URL = "https://api.sampleapis.com/coffee".freeze

  def initialize(ice: nil, hot: nil)
    raise ArgumentError, "ice or hot?" if ice == hot
    @ice = ice
  end

  def to_s
    ice? ? "#{BASE_URL}/iced" : "#{BASE_URL}/hot"
  end

  alias_method :url, :to_s

  private

  attr_reader :ice

  def ice?
    ice
  end
end

위 클래스의 역할은 url을 결정하기 위한 DSL이다. 다음과 같이 사용할 수 있다.

assert Drink::Query.new(ice: true).to_s == "https://api.sampleapis.com/coffee/iced"
assert Drink::Query.new(hot: false).url == "https://api.sampleapis.com/coffee/iced"
assert Drink::Query.new(hot: true).to_s == "https://api.sampleapis.com/coffee/hot"

이제 파싱과 API의 실제 콜을 담당하는 부분을 만들어주자. 팩토리 메서드로 CoffeeShop이름을 지어줬다.

module CoffeeShop
  class Parser
    def initialize
      @parser = Oj
    end

    def parse(string)
      @parser.load(string)
    end
  end

  class Response
    cattr_reader(:client) { Faraday }

    def initialize(url)
      @url = url
      @parser = Parser.new
    end

    def body
      client.get(@url).body
    end

    def json
      @parser.parse(body)
    end
  end
end

차후 유연성을 확보하기 위해 CofeeShop::Response의 경우 HTTP 클라이언트를 cattr_reader로 주입해두었다.

QueryMethods

이제 다시 Drink::QueryMethods을 구현해보자.

require 'coffee_shop'

module Drink::QueryMethods
  extend ActiveSupport::Concern

  class_methods do
    def ice
      json_response = CoffeeShop::Response.new(Drink::Query.new(ice: true).to_s).json
      Drink::Converter.new(json_response, ice: true).to_drink
    end

    def hot
      json_response = CoffeeShop::Response.new(Drink::Query.new(hot: true).to_s).json
      Drink::Converter.new(json_response, ice: false).to_drink
    end
  end
end

이제 처음 생각했던대로 Drink.hot처럼 부르면 hot = trueDrink 객체를 얻을 수 있다. 추가로
id를 이용한 where이나 find 등을 구현해보자. 그러기 위해선 우선 Query를 수정하자.

class Drink::Query
  BASE_URL = "https://api.sampleapis.com/coffee".freeze

  def initialize(id: nil, ice: nil, hot: nil)
    raise ArgumentError, "ice or hot?" if ice == hot
    @id = id
    @ice = ice
  end

  def to_s
    plural_url = ice? ? "#{BASE_URL}/iced" : "#{BASE_URL}/hot"

    if plural?
      plural_url
    else
      "#{plural_url}/#{id}"
    end
  end

  alias_method :url, :to_s

  private

  attr_reader :id, :ice

  def plural?
    id.nil?
  end

  def ice?
    ice
  end
end

다음과 같이 사용할 수 있다.

assert Drink::Query.new(ice: true, id: 4).to_s == "https://api.sampleapis.com/coffee/iced/4"

이제 Drink::QueryMethods를 다음과 같이 수정해보자.

require 'coffee_shop'

module Drink::QueryMethods
  extend ActiveSupport::Concern

  class_methods do
    def ice
      json_response = CoffeeShop::Response.new(Drink::Query.new(ice: true).to_s).json
      Drink::Converter.new(json_response, ice: true).to_drink
    end

    def hot
      json_response = CoffeeShop::Response.new(Drink::Query.new(hot: true).to_s).json
      Drink::Converter.new(json_response, ice: false).to_drink
    end

    def where(id: nil, ice: nil, hot: nil)
      json_response = CoffeeShop::Response.new(Drink::Query.new(id: id, ice: ice, hot: hot).to_s).json
      converter = Drink::Converter.new(json_response, ice: ice.nil? ? !hot : ice)
      Array(converter.to_drink)
    end

    def find_by(id: nil, ice: nil, hot: nil)
      where(id: id, ice: ice, hot: hot).first
    end
  end
end

wherefind_by를 추가했다. 범용적인 where이 생겼으므로 hoticewhere을 이용한 것으로
고칠 수 있다.]

module Drink::QueryMethods
  extend ActiveSupport::Concern

  class_methods do
    # ... 생략 ...
    def where(id: nil, ice: nil, hot: nil)
      # ... 생략 ...
    end

    def ice
      where(ice: true)
    end

    def hot
      where(hot: true)
    end
    # ...  생략 ...
  end
end

병렬 실행

이제 마지막으로 Drink.all을 구현해보자. 병렬적인 두 쿼리이므로 병렬로 실행해보자. 이를 위해
concurrent-ruby 젬을 이용해 다음과 같이 구현할 수 있다.

module Drink::QueryMethods
  extend ActiveSupport::Concern

  class_methods do
    def all
      (Concurrent::Promises.future { Drink.ice } & Concurrent::Promises.future { Drink.hot })
        .then { |a, b| a + b }
        .value!
    end
    
    # ...
  end
end 

이제 필요한 메서드를 모두 구현했다. 액티브레코드와 매우 유사하게 다음과 같이 사용할 수 있다.

Drink.all
Drink.where(hot: true)
Drink.ice
Drink.find_by(id: 4, hot: false)

위와 같이 구현함으로써

  • 일관적 인터페이스 제공
  • QueryModel, Serialization의 관심사 분리
  • 위로 인한 Query 하부구조 변경 가능(가령, DB를 도입한다든지 GraphQL로 바꾼다든지)

과 같은 장점들을 얻었다.

참고문헌

profile
즐기는 거야

0개의 댓글