최근 회사에서 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
이제 여기에 쿼리 메소드를 추가해보자. 액티브레코드와 비슷한 인터페이스를 제공하려면
다음과 같이 사용할 수 있어야 할 것이다.
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#hot
과 Drink#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
로 주입해두었다.
이제 다시 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 = true
인 Drink
객체를 얻을 수 있다. 추가로
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
where
과 find_by
를 추가했다. 범용적인 where
이 생겼으므로 hot
과 ice
도 where
을 이용한 것으로
고칠 수 있다.]
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)
위와 같이 구현함으로써
Query
와 Model
, Serialization
의 관심사 분리Query
하부구조 변경 가능(가령, DB를 도입한다든지 GraphQL로 바꾼다든지)과 같은 장점들을 얻었다.