다음과 같은 코드를 본 적이 있나?
# == Schema Information
#
# Table name: sellers
#
# id :integer not null, primary key
# name :string
# role :string
# created_at :datetime not null
# updated_at :datetime not null
#
class Seller < ApplicationRecord
def role_name
if role == :admin
"관리자"
elsif role == :normal
"일반 판매자"
else
"뉴비"
end
end
def role_description
if role == :admin
"관리해요"
elsif role == :normal
"물건을 팔아요"
else
"새로 들어왔어요"
end
end
def work?
if role == :admin
true
elsif role == :normal
false
else
false
end
end
end
아마 모든 코드가 그렇지는 않겠지만, 저런 코드를 본 적이 있을 것이다. 위 코드는 장황하고 읽기 어렵다. 또한 새로 기능추가를 하는 경우, 수많은 if~else 사이를 왔다갔다해야한다. 어떻게 고칠 수 있을까? 메소드의 이름인 role_name
과 role_description
에 힌트가 있다.
바로 Role
이라는 PORO(Plain Old Ruby Object)
를 추출하는 것이다. 다음과 같이 작성해보자.
# app/models/seller/role.rb
class Seller::Role
attr_reader :code, :name, :description
class << self
def all
@all ||= [
new(:admin, "관리자", "관리해요"),
new(:normal, "일반 판매자", "물건을 팔아요"),
new(:newbie, "뉴비", "새로 들어왔어요")
]
end
def of(code)
if code.is_a?(String)
code = code.to_sym
end
all.find { |role| role.code == code }
end
protected_methods :new
end
def initialize(code, name, description)
@code = code
@name = name
@description = description
end
end
그리고 Seller
는 다음과 같이 변한다.
class Seller < ApplicationRecord
def role
@role_object ||= Seller::Role.of(super)
end
delegate :description, :name, to: :role, prefix: true
# TODO
def work?
if role == :admin
true
elsif role == :normal
false
else
false
end
end
end
delegate
의 힘으로 동일한 인터페이스를 제공하면서도 좀 더 응집력 있는 코드를 작성할 수 있다. 이제 work?
를 리팩토링해보자. 좀 더 잘게 클래스를 쪼갬으로써 구현할 수 있다.
앞선 구현에서 어디를 좀 더 쪼갤 수 있을까?
# app/models/seller/role.rb
class Seller::Role
attr_reader :code, :name, :description
class << self
def all
@all ||= [
new(:admin, "관리자", "관리해요"),
new(:normal, "일반 판매자", "물건을 팔아요"),
new(:newbie, "뉴비", "새로 들어왔어요")
]
end
end
# ... 생략
end
아래의 이 부분을 Role
의 서브클래스로 만들고 work?
를 구현하게 하면 될 거 같다.
new(:admin, "관리자", "관리해요"),
new(:normal, "일반 판매자", "물건을 팔아요"),
new(:newbie, "뉴비", "새로 들어왔어요")
구현해보자.
# models/seller/role/admin.rb
class Seller::Role::Admin < Seller::Role
def initialize
super(:admin, "관리자", "관리해요")
end
def work?
true
end
end
# models/seller/role/newbie.rb
class Seller::Role::Newbie < Seller::Role
def initialize
super(:newbie, "뉴비", "처음이세요")
end
def work?
false
end
end
# models/seller/role/normal.rb
class Seller::Role::Normal < Seller::Role
def initialize
super(:normal, "일반 판매자", "물건을 팔아요")
end
def work?
true
end
end
이제 Seller::Role
은 다음과 같이 변한다.
class Seller::Role
attr_reader :code, :name, :description
class << self
def all
@all ||= [Seller::Role::Admin.new, Seller::Role::Normal.new, Seller::Role::Newbie.new]
end
# ...
end
# ...
end
그리고 Seller
에서 분기를 제거할 수 있다.
class Seller < ApplicationRecord
def role
@role_object ||= Seller::Role.of(super)
end
delegate :description, :name, to: :role, prefix: true
delegate :work?, to: :role
end
동일한 API를 제공하면서 내부 구현만 바꿀 수 있었다.
Validation을 좀 더 원활하게 하기 위해, ActiveRecord#serialize
를 활용하자. 공식 도큐먼트를 참조하면 다음과 같은 인터페이스를 제공하면 된다.
class Rot13JSON
def self.rot13(string)
string.tr("a-zA-Z", "n-za-mN-ZA-M")
end
# returns serialized string that will be stored in the database
def self.dump(object)
ActiveSupport::JSON.encode(object).rot13
end
# reverses the above, turning the serialized string from the database
# back into its original value
def self.load(string)
ActiveSupport::JSON.decode(string.rot13)
end
end
우리의 Seller::Role
클래스를 다음과 같이 정리하자.
class Seller::Role
attr_reader :code, :name, :description
class << self
# ...
def load(code)
Seller::Role.of(code)
end
def dump(role)
role.code.to_s
end
end
# ...
end
이제 Seller
클래스는 다음과 같다.
class Seller < ApplicationRecord
serialize :role, Role
delegate :description, :name, to: :role, prefix: true
delegate :work?, to: :role
end
Validation을 추가한다면 다음과 같이 하면 된다.
class Seller < ApplicationRecord
serialize :role, Role
validates :role, inclusion: { in: Seller::Role.all }
delegate :description, :name, to: :role, prefix: true
delegate :work?, to: :role
end
class Seller::Role
attr_reader :code, :name, :description
class << self
def all
@all ||= [Seller::Role::Admin.new, Seller::Role::Normal.new, Seller::Role::Newbie.new]
end
def of(code)
if code.is_a? String
code = code.to_sym
end
all.find { |role| role.code == code }
end
protected_methods :new
def load(code)
Seller::Role.of(code)
end
def dump(role)
role.code.to_s
end
end
def initialize(code, name, description)
@code = code
@name = name
@description = description
end
end
# models/seller/role/admin.rb
class Seller::Role::Admin < Seller::Role
def initialize
super(:admin, "관리자", "관리해요")
end
def work?
true
end
end
# models/seller/role/newbie.rb
class Seller::Role::Newbie < Seller::Role
def initialize
super(:newbie, "뉴비", "처음이세요")
end
def work?
false
end
end
# models/seller/role/normal.rb
class Seller::Role::Normal < Seller::Role
def initialize
super(:normal, "일반 판매자", "물건을 팔아요")
end
def work?
true
end
end
이제 새로운 Role을 추가하는 것은 쉽다. 또한 응집도가 높은 아주 작은 클래스 여러 개를 얻었다. 원래 클래스에 비하면 훨씬 작아 이해하기 부담없으며 내 변경의 여파가 미칠 곳이 명확하여 수정하기 편하다.
또한 ActiveRecord
가 아닌, 단순한 ruby object기 때문에 테스트하기도 훨씬 쉽고 속도도 빠르다.