Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Rails의 숨겨진 보석: ActiveSupport StringInquirer 완벽 이해하기

Rails는 방대한 프레임워크이며 매년 그 규모가 커지고 있습니다. 그렇다 보니 유용한 기능들이 눈에 띄지 않고 지나가기 쉽습니다. 이 시리즈에서는 특정 작업을 위해 Rails에 내장된 잘 알려지지 않은 기능들을 살펴보겠습니다.

시리즈의 첫 번째 글에서는 Rails.env.test? 호출이 내부적으로 어떻게 동작하는지 알아봅니다. 핵심에는 ActiveSupport의 잘 알려지지 않은 StringInquirer 클래스가 있습니다. 한 걸음 더 나아가 StringInquirer의 소스 코드를 직접 살펴보며 동작 원리를 파헤쳐 볼 것입니다. (스포일러 경고!) 그 비밀은 Ruby의 특별한 메서드인 method_missing을 활용한 메타프로그래밍의 간단한 예입니다.

Rails.env 헬퍼

Rails 환경을 확인하는 코드는 이미 여러 번 보셨을 겁니다:

if Rails.env.test?
  # 하드코딩된 값 반환...
else
  # 외부 API 호출...
end

그런데 test?는 정확히 무슨 일을 하는 메서드이며, 대체 어디에서 온 것일까요?

콘솔에서 Rails.env를 확인해 보면 문자열처럼 동작합니다:

=> Rails.env
"development"

이 문자열은 RAILS_ENV 환경 변수에 설정된 값입니다. 하지만 단순한 String만은 아닙니다. 콘솔에서 클래스를 확인해 보면:

=> Rails.env.class
ActiveSupport::StringInquirer

Rails.env는 사실 StringInquirer 인스턴스입니다.

StringInquirer

StringInquirer의 코드는 짧으면서도 문서화가 잘 되어 있습니다. 다만 Ruby의 메타프로그래밍에 익숙하지 않다면 동작 방식이 직관적으로 다가오지 않을 수 있습니다. 하나씩 차근차근 살펴보겠습니다.

class StringInquirer < String
...
end

먼저 눈에 띄는 점은 StringInquirer가 String의 서브클래스라는 것입니다. 이것이 바로 Rails.env가 문자열처럼 동작하는 이유입니다. String을 상속하기 때문에 문자열 관련 모든 기능을 자동으로 물려받아, 일반 String처럼 다룰 수 있습니다. 따라서 Rails.env.upcase도 동작하고, ActiveRecordModel.find_by(string_column: Rails.env) 같은 쿼리에서도 문제없이 사용할 수 있습니다.

Rails.env는 StringInquirer의 편리한 내장 예시일 뿐이며, 우리도 직접 만들어 사용할 수 있습니다:

def type
  result = "old"
  result = "new" if @new

  ActiveSupport::StringInquirer.new(result)
end

그러면 반환된 값에 물음표(?)로 끝나는 메서드를 사용할 수 있게 됩니다:

=> @new = false
=> type.old?
true
=> type.new?
false
=> type.testvalue?
false

=> @new = true
=> type.new?
true
=> type.old?
false

method_missing

여기서 진짜 핵심 비결은 method_missing입니다. Ruby에서 객체에 메서드를 호출하면, Ruby는 먼저 해당 객체의 조상(상위 클래스나 포함된 모듈)들에서 메서드를 찾습니다. 찾지 못하면 Ruby는 method_missing을 호출하면서 찾으려던 메서드 이름과 인자를 함께 전달합니다.

기본적으로 이 메서드는 예외를 발생시킵니다. 우리 모두에게 익숙한 NoMethodError: undefined method 'test' for nil:NilClass 같은 에러 메시지가 바로 그 결과입니다. 하지만 우리는 예외를 발생시키지 않는 자체 method_missing을 구현할 수 있으며, StringInquirer가 바로 그렇게 하고 있습니다:

def method_missing(method_name, *arguments)
  if method_name.end_with?("?")
    self == method_name[0..-2]
  else
    super
  end
end

어떤 메서드 이름이든 ?로 끝난다면, 자기 자신(self, 즉 문자열)과 물음표를 제거한 메서드 이름을 비교합니다. 다르게 표현하면, StringInquirer.new("test").long_test_method_name?을 호출했을 때 반환되는 값은 "test" == "long_test_method_name"의 평가 결과입니다.

메서드 이름이 물음표로 끝나지 않는다면, 원래의 method_missing(예외를 발생시키는 버전)으로 폴백(fallback)됩니다.

respond_to_missing?

파일에는 한 가지 메서드가 더 있습니다: respond_to_missing?. 이것은 method_missing의 짝꿍이라고 할 수 있습니다. method_missing이 기능을 제공한다면, Ruby에게 "우리는 물음표로 끝나는 이런 메서드들을 받아들일 수 있다"고 알려주는 통로도 필요합니다.

def respond_to_missing?(method_name, include_private = false)
  method_name.end_with?("?") || super
end

이것은 객체에 respond_to?를 호출할 때 중요해집니다. 이 구현이 없다면 StringInquirer.new("test").respond_to?(:test?)의 결과는 false가 됩니다. 명시적으로 정의된 test? 메서드가 없기 때문입니다. 이건 명백히 오해를 불러일 수 있는데, Rails.env.respond_to?(:test?)를 호출하면 true가 나올 것이라고 기대하는 것이 자연스럽기 때문입니다.

respond_to_missing?은 Ruby에게 "네, 그 메서드는 처리할 수 있습니다"라고 말해주는 역할을 합니다. 메서드 이름이 물음표로 끝나지 않으면 상위 클래스의 구현으로 폴백됩니다.

실전 활용 사례

이제 StringInquirer가 어떻게 동작하는지 알았으니, 어떤 상황에서 유용하게 쓸 수 있는지 살펴보겠습니다.

1. 환경 변수

환경 변수는 StringInquirer와 궁합이 좋은 두 가지 조건을 충족합니다. 첫째, 가능한 값의 집합이 제한적이고 미리 알려져 있다는 점(enum과 유사), 둘째, 그 값에 따라 조건 분기 로직을 작성하는 경우가 많다는 점입니다.

예를 들어 앱이 결제 API에 연동되어 있고, API 자격 증명이 환경 변수에 저장되어 있다고 해봅시다. 운영 환경에서는 실제 API를 사용하겠지만, 스테이징이나 개발 환경에서는 샌드박스(sandbox) API를 사용하고 싶을 것입니다:

# ENV["PAYMENT_API_MODE"] = sandbox/production

class PaymentGateway
  def api_mode
    # 값이 없으면 예외를 발생시키기 위해 ENV.fetch 사용
    @api_mode ||= ENV.fetch("PAYMENT_API_MODE").inquiry
  end

  def api_url
    # 프로 팁: MODE가 'production'일 때 *만* 운영 URL을 사용하고,
    # 그 외의 모든 값에 대해서는 샌드박스를 기본값으로 삼습니다.
    # 이렇게 하면 값이 오타이거나 잘못 입력되었을 때
    # 실수로 운영 값을 사용하는 것을 방지할 수 있습니다.
    if api_mode.production?
      PRODUCTION_URL
    else
      SANDBOX_URL
    end
  end
end

위 예제에서 주목할 점은 ActiveSupport의 String#inquiry 메서드를 사용했다는 것입니다. 이 메서드는 문자열을 손쉽게 StringInquirer로 변환해 줍니다.

2. API 응답

앞선 결제 API 예제를 이어가 보겠습니다. API는 성공/실패 상태를 포함한 응답을 보내옵니다. 이 경우 역시 StringInquirer의 후보 조건 두 가지, 즉 제한된 값 집합과 그 값을 검증하는 조건 로직이 모두 충족됩니다.

class PaymentGateway
  def create_charge
    response = JSON.parse(api_call(...))

    result = response["result"].inquiry

    if result.success?
      ...
    else
      ...
    end

    # result는 여전히 문자열처럼 동작합니다
    Rails.logger.info("Payment result was: #{result}")
  end
end

결론

StringInquirer는 툴박스에 넣어두면 흥미로운 도구이지만, 개인적으로는 너무 자주 손이 가지는 않습니다. 분명 쓸모가 있지만, 대부분의 경우 객체에 명시적인 메서드를 정의하는 것만으로도 같은 결과를 얻을 수 있습니다. 명시적으로 이름 붙은 메서드는 몇 가지 추가 장점도 있습니다. 값이 변경될 필요가 생기면 한 곳만 수정하면 되고, 다른 개발자가 해당 메서드를 찾으려 할 때 코드베이스 검색이 훨씬 쉬워집니다.

이 글의 초점은 StringInquirer지만, 사실 의도는 method_missing을 통해 Ruby의 메타프로그래밍 능력을 부드럽게 소개하는 데 더 가깝습니다. 애플리케이션 코드에서 method_missing을 직접 사용하는 것은 권장하지 않습니다. 하지만 Rails 같은 프레임워크나 gem들이 제공하는 DSL(도메인 특화 언어)에서는 널리 사용되므로, 문제를 마주쳤을 때 "소시지가 어떻게 만들어지는지" 아는 것이 큰 도움이 됩니다.