더 객체지향적인 Rails

구경회·2022년 3월 19일
3
post-thumbnail

Primitive Obsession

다음과 같은 코드를 본 적이 있나?

# == 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_namerole_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를 제공하면서 내부 구현만 바꿀 수 있었다.

ActiveRecord#serialize 활용하기

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을 추가한다면 다음과 같이 하면 된다.

최종 결과

Seller

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

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

    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

Seller::Role::{Admin, Normal, 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

이제 새로운 Role을 추가하는 것은 쉽다. 또한 응집도가 높은 아주 작은 클래스 여러 개를 얻었다. 원래 클래스에 비하면 훨씬 작아 이해하기 부담없으며 내 변경의 여파가 미칠 곳이 명확하여 수정하기 편하다.

또한 ActiveRecord가 아닌, 단순한 ruby object기 때문에 테스트하기도 훨씬 쉽고 속도도 빠르다.

참고자료

profile
즐기는 거야

0개의 댓글