Code

Refactoring Series – go out

August 15, 2016

author:

Array

Refactoring Series – go out

가끔은 의식적으로 이유를 붙여가며 리팩토링해보는 것도 좋은 경험이 될 것 같아서 레포트 형식으로 작성하는 시리즈입니다. 소재는 여기저기서 제가 보던 코드를 가져오고 있으며, 물론 그대로 가져오진 않고 필요한 부분만 가져다가 새로 예제로 만듭니다. 필자가 게으르기 때문에 비정기로 연재될 예정입니다. 의견 및 오타 제보는 언제나 감사하게 받고 있습니다. ?

ps: 소제목에는 큰 의미가 없습니다. 재미로 봐주세요.

배경 설명

레일스 애플리케이션에 Item이라는 모델이 있고, 가격(price)으로 범위 지정을 하여 목록을 가져오는 기능을 구현해둔 상태입니다. 실제 코드는 대강 다음과 같습니다.

# item.rb
class Item < ApplicationRecord
  include RangeParamConvertable
  
  def self.search(params)
    # ...
    price_where = {
      price: convert_ranges(params[:price_range])
    }.compact
    # ...
  end

  private

  def self.convert_ranges(ranges)
    return if ranges.nil?
    ranges.map(&method(:str_to_range))
  end
end

# range_param_convertable.rb
module RangeParamConvertable
  extend ActiveSupport::Concern

  class_methods do
    def str_to_range(string)
      # ...
      Range.new(start, end)
    end
  end
end

문제점?

문자열에 str_to_range를 호출하고 싶어서 method를 통해 해당 컨텍스트에 등록된 str_to_range 함수를 가져오고 있습니다. 비유하자면 Array.prototype.slice.call(args, 0) 같은 느낌이죠. 네.

문자열을 범위 객체로 만드는 함수니까 명백하게 Item이라는 모델의 책임 범위를 벗어납니다. 그런데 정작 쓰는 건 include해서 쓰니까 호출하는 방식이 뭔가 괴상합니다. 그렇게 생각하니 검색용 파라미터를 받아서 가공하는 작업을 여기서 해도 되는건가? 라는 생각도 듭니다. 어떻게 하면 될까 고민해본 결과, 선택할 수 있는 방식은 대략 3개쯤 있을 거 같습니다.

  • 검색용 파라미터를 받아서 가공하는 책임을 가지는 별도의 클래스를 만들고 가공 처리를 위임한다.
  • RangeParamConvertable 모듈을 별도의 라이브러리로 분리하고, 이를 통해서 호출한다.
  • 몽키패칭으로 String 클래스에 해당 메소드를 주입한다.

여러개를 섞어서 사용할 수도 있고, 아닐 수도 있지만 설명의 편의상 이렇게 적어봅시다.
첫번째의 경우 파라미터 가공에 대한 책임을 외부로 내보낼 수 있다, 같은 장점이 있을거고, 두 번째는 비교적 간단하게 고칠 수 있고, 범용으로 쓰기 편합니다. 세 번째는 가장 자연스럽게 사용할 수 있다("1..2".to_range와 같이 사용할 수 있기 때문이죠.)와 같은 장점이 있습니다.

일단 몽키패칭을 제외합시다. DSL을 만드는 것도 아니고, 기본 클래스를 만지는건 왠지 꺼림칙합니다. 그리고 첫 번째도 제외하죠. 별도로 만드는 것도 괜찮은 선택지입니다만, 현재 문제가 되는 부분 이외에서는 크게 이득을 보진 않습니다(생략된 코드에는 이러한 가공처리가 없습니다). 그러니까 두 번째로 갑시다.

WIP

# item.rb
class Item < ApplicationRecord
  def self.search(params)
    # ...
    price_where = {
      price: convert_ranges(params[:price_range])
    }.compact
    # ...
  end

  private

  def self.convert_ranges(ranges)
    return if ranges.nil?
    ranges.map { |range| RangeParamConvertable.str_to_range(range) }
  end
end

# range_param_convertable.rb
module RangeParamConvertable
  module_function

  def str_to_range(string)
    # ...
    Range.new(start, end)
  end
end

함수 호출이 매우[..] 길어졌습니다. 뿐만 아니라, 영 이름도 적절치 않게 되었네요. 변수의 이름과 함수/모듈명도 좀 더 알아보기 쉽게 고쳐보죠. range라는 문자열을 range_string로, RangeParamConvertableStringConverter 정도로, 클래스명을 함께 쓰게 되었으니 str_to_rangeto_range 정도는 어떨까요?

# item.rb
class Item < ApplicationRecord
  def self.search(params)
    # ...
    price_where = {
      price: convert_ranges(params[:price_range])
    }.compact
    # ...
  end

  private

  def self.convert_ranges(range_strings)
    return if range_strings.nil?
    range_strings.map do |range_string|
      StringConverter.to_range(range_string)
    end
  end
end

# string_converter.rb
module StringConverter
  module_function

  def to_range(string)
    # ...
    Range.new(start, end)
  end
end

결론

nil 처리를 convert_ranges 내부에서 하고 있다든가, 애초에 이 메소드가 여기에 있는게 맞긴 한것인지에 대한 의문이라든가, where 절을 만드는 방법이 영 맘에 안든다든가(방법이 있는 지는 둘째 치고), map이 3줄이 되어서 슬프다던가, 이런 부분은 재껴두고, 좀 나아졌습니다, 라고 믿습니다.

욕심을 내지 않고 조금씩 고치는 것도 요령이니까요! 절대 귀찮아서가 아닙니다.

Array