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

method_missing으로 조상 체인(Ancestor Chain) 끝까지 탐험하기

짐을 챙겨주세요! 오늘은 Ruby의 조상 체인(Ancestor Chain) 끝까지 올라가는 여정을 떠납니다. 하나의 메서드 호출이 체인을 따라 어떻게 전달되는지 추적하고, 메서드가 존재하지 않을 때 어떤 일이 벌어지는지도 확인해 보겠습니다. 그리고 모험을 좋아하는 우리답게 거기서 멈추지 않고 BasicObject#method_missing까지 직접 오버라이딩해 볼 것입니다. 잘 따라오신다면 실용적인 예제도 함께 다룰 수 있을 겁니다. 물론 장담하진 않지만요. 자, 출발합니다!

조상 체인(Ancestor Chain)

Ruby에서 조상 체인의 기본 규칙부터 살펴보겠습니다.

  • Ruby는 단일 상속(single inheritance)만 지원합니다
  • 동시에 객체는 여러 모듈을 포함(include)할 수 있습니다

Ruby에서 조상 체인은 특정 클래스가 상속하거나 포함한 모든 클래스와 모듈을 순회한 결과로 구성됩니다.

예제를 통해 Ruby가 조상 체인을 어떻게 처리하는지 살펴보겠습니다.

module Auth end
 
module Session end
 
module Iterable end
 
class Collection
  prepend Iterable
end
 
class Users < Collection
  prepend Session
  include Auth
end
 
p Users.ancestors

실행 결과는 다음과 같습니다:

[
  Session, Users, Auth,        # Users
  Iterable, Collection,        # Collection
  Object, Kernel, BasicObject  # Ruby Object Model
]

먼저 ancestors 클래스 메서드를 호출하면 특정 클래스의 조상 체인에 접근할 수 있습니다.

Users.ancestors 호출 결과는 다음 순서대로 배열된 클래스와 모듈의 목록입니다:

  • Users에 prepend된 모듈들
  • Users 클래스 자체
  • Users가 include한 모듈들
  • Collection 클래스에 prepend된 모듈들 — Users의 직계 부모이므로
  • Collection 클래스 자체
  • Collection이 include한 모듈들 — 없음
  • Object 클래스 — 모든 클래스의 기본 상속 대상
  • Kernel 모듈 — Object에 포함되어 있으며 핵심 메서드들을 담고 있음
  • BasicObject 클래스 — Ruby 객체 모델의 최상위 뿌리(root)

정리하면, 조상 체인을 순회할 때 각 클래스나 모듈이 등장하는 순서는 항상 다음과 같습니다:

  • prepend된 모듈들
  • 클래스 또는 모듈 자체
  • include된 모듈들

조상 체인은 주로 객체나 클래스에 메서드가 호출될 때 Ruby에 의해 탐색됩니다.

메서드 탐색 경로(Method Lookup Path)

메시지가 전송되면 Ruby는 수신자(receiver)의 조상 체인을 따라 올라가며, 해당 메시지에 응답할 수 있는 클래스나 모듈이 있는지 차례로 확인합니다.

체인상의 어떤 클래스나 모듈이 메시지에 응답할 수 있다면, 그 메시지와 연결된 메서드가 실행되고 조상 체인 탐색은 거기서 중단됩니다.

class Collection < Array
end
 
Collection.ancestors # => [Collection, Array, Enumerable, Object, Kernel, BasicObject]
 
collection = Collection.new([:a, :b, :c])
 
collection.each_with_index # => :a

여기서 collection.each_with_index라는 메시지는 Enumerable 모듈이 받아 처리합니다. 즉, 이 메시지에 대해 Enumerable#each_with_index 메서드가 호출됩니다.

collection.each_with_index가 호출되면 Ruby는 다음과 같이 하나씩 확인합니다:

  • Collection이 each_with_index 메시지에 응답하는가? → NO
  • Array가 each_with_index 메시지에 응답하는가? → NO
  • Enumerable이 each_with_index 메시지에 응답하는가? → YES

여기서 Ruby는 조상 체인 탐색을 멈추고 해당 메시지와 연결된 메서드를 호출합니다. 우리 경우에는 Enumerable#each_with_index 메서드입니다.

Ruby에서 이러한 메커니즘을 메서드 탐색 경로(Method Lookup Path)라고 부릅니다.

그런데 만약 수신자의 조상 체인을 구성하는 어느 클래스나 모듈도 해당 메시지에 응답하지 못한다면 어떻게 될까요?

BasicObject#method_missing

착하게 굴 시간은 끝났습니다! 개발자다운 방식으로 문제를 일으켜 볼까요? 바로 예외(exception)를 던지는 겁니다. Collection 클래스를 정의하고, 그 인스턴스에 정의되지 않은 메서드를 호출해 보겠습니다.

class Collection
end
 
c = Collection.new
c.search('item1') # => NoMethodError: undefined method `search` for #<Collection:0x123456890>

여기서 Collection 클래스는 search 메서드를 구현하지 않았습니다. 그래서 NoMethodError가 발생합니다. 하지만 이 에러는 대체 어디에서 비롯된 걸까요?

이 에러는 BasicObject#method_missing 메서드 안에서 발생합니다. 메서드 탐색 경로가 주어진 메시지에 대응하는 메서드를 찾지 못하고 끝나면, 바로 이 메서드가 호출됩니다.

그런데... 기본 구현은 NoMethodError를 던질 뿐입니다. 그렇다면 Collection 클래스 맥락에서 이 메서드를 오버라이딩할 수 있다면 훨씬 유용하겠죠?

BasicObject#method_missing 오버라이딩하기

놀랍게도 method_missing을 오버라이딩하는 것은 전혀 문제가 없습니다. 이 메서드 역시 메서드 탐색 경로의 대상이 되기 때문입니다. 일반 메서드와 유일하게 다른 점은, 이 메서드는 메서드 탐색 경로에 의해 최소 한 번은 반드시 발견된다는 사실입니다.

Ruby의 모든 클래스의 뿌리인 BasicObject 클래스가 이 메서드의 최소 구현 버전을 정의하고 있기 때문입니다. 클래식한 Ruby 마법이죠, 그렇지 않나요?

그럼 Collection 클래스에서 이 메서드를 오버라이딩해 보겠습니다:

class Collection
  def initialize
    @collection = {}
  end
 
  def method_missing(method_id, *args)
    if method_id[-1] == '='
      key = method_id[0..-2]
      @collection[key.to_sym] = args.first
    else
      @collection[method_id]
    end
  end
end
 
collection = Collection.new
collection.obj1 = 'value1'
collection.obj2 = 'value2'
 
collection.obj1 # => 'value1'
collection.obj2 # => 'value2'

여기서 Collection#method_missing@collection 인스턴스 변수로 작업을 위임(delegate)하는 역할을 합니다. 사실 Ruby가 내부적으로 객체 위임을 처리하는 방식도 대략 이와 같습니다. 참고로 표준 라이브러리의 delegate를 살펴보세요.

누락된 메서드가 세터(setter) 형태라면(collection.obj1 = 'value1'), 메서드 이름(:obj1)이 키가 되고 인자('value1')가 값이 되어 @collection 해시 엔트리(@collection[:obj1] = 'value1')가 생성됩니다.

HTML 태그 생성기 만들기

이제 method_missing 메서드가 내부적으로 어떻게 동작하는지 알았으니, 재현 가능한 실용적인 사례를 구현해 보겠습니다.

목표는 다음과 같은 DSL(도메인 특화 언어)을 정의하는 것입니다:

HTML.p    'hello world'             # => <p>hello world</p>
HTML.div  'hello world'             # => <div>hello world</div>
HTML.h1   'hello world'             # => <h1>hello world</h1>
HTML.h2   'hello world'             # => <h2>hello world</h2>
HTML.span 'hello world'             # => <span>hello world</span>
HTML.p    "hello #{HTML.b 'world'}" # => <p>hello <b>world</b></p>

이를 위해 각 HTML 태그마다 메서드를 일일이 정의하는 대신, HTML.method_missing 메서드를 구현할 것입니다.

먼저 HTML 모듈을 정의하고, 그 안에 method_missing 클래스 메서드를 정의합니다:

module HTML
  def HTML.method_missing(method_id, *args, &block)
    "<#{method_id}>#{args.first}</#{method_id}>"
  end
end

이 메서드는 누락된 method_id를 활용해 단순히 HTML 태그를 생성할 뿐입니다. 예를 들어 HTML.div 호출에는 :div가 전달됩니다.

여기서 한 가지 주목할 점은, 클래스 메서드 역시 메서드 탐색 경로의 영향을 받는다는 사실입니다.

이 HTML 태그 생성기는 다음과 같이 더 개선할 수 있습니다:

  • 블록 인자를 활용해 중첩 태그(nested tags) 처리하기
  • <br/>처럼 닫는 태그가 없는 단일 태그 처리하기

하지만 몇 줄의 코드만으로도 방대한 종류의 HTML 태그를 생성할 수 있다는 점은 충분히 인상적입니다.

정리하면 다음과 같습니다:

method_missing은 대부분의 명령이 공통된 패턴을 공유하는 DSL을 설계할 때 아주 훌륭한 진입점이 됩니다.

마치며

이번 글에서는 Ruby의 조상 체인을 끝까지 올라가며 BasicObject#method_missing까지 깊이 들여다보았습니다. BasicObject#method_missingRuby Hook Methods(훅 메서드) 중 하나로, 객체 생명주기의 특정 시점에 개입할 때 사용됩니다. 다른 훅 메서드들과 마찬가지로 이 훅 역시 반드시 신중하게 사용해야 합니다. 여기서 말하는 '신중함'이란 Ruby 객체 모델의 동작을 함부로 변경하지 않는 것을 의미합니다. 이상으로 마치겠습니다!