메서드를 호출하는 순간, 실제로 어떤 일이 벌어질까요? 같은 이름의 메서드가 여러 곳에 존재한다면 Ruby는 어떤 기준으로 호출할 메서드를 결정할까요? 그리고 그 메서드는 대체 어디에 정의되어 있는 걸까요?
Ruby는 올바른 메서드를 호출하고, 끝내 찾지 못했을 때 적절한 시점에 "NoMethodError"를 반환하기 위해 정해진 규칙, 즉 하나의 패턴을 따릅니다. 이 규칙을 바로 루비 메서드 조회 경로(Ruby Method Lookup Path)라고 부릅니다.
이 튜토리얼에서는 Ruby의 메서드 조회 방식을 깊이 있게 다룹니다. 글을 끝까지 읽고 나면, Ruby가 객체의 계층 구조를 어떻게 순회하면서 여러분이 참조하려는 메서드를 찾아내는지 명확하게 이해하게 될 것입니다.
내용을 온전히 이해하려면 Ruby에 대한 기초 지식이 필요합니다. 모듈과 클래스 같은 개념도 언급하지만, 이들의 동작 원리를 깊게 파고들지는 않습니다. 이 튜토리얼의 목표인 "Ruby가 객체에 전달된 메시지(메서드)를 어떻게 해석하는가"를 설명하는 데 필요한 수준만 다룹니다.
개요
first_person.valid?처럼 메서드를 호출하면 Ruby는 몇 가지 사항을 판단해야 합니다.
.valid?메서드가 어디에 정의되어 있는가?.valid?메서드가 여러 곳에 정의되어 있다면, 이 맥락에서 어떤 것이 올바른 것인가?
Ruby가 이를 파악하기 위해 거치는 과정, 즉 경로를 우리는 메서드 조회(method lookup)라고 부릅니다. Ruby는 메서드가 생성된 위치를 찾아야만 해당 메서드를 호출할 수 있습니다. 올바른 메서드를 호출하기 위해 Ruby는 다음 순서대로 검색합니다.
- 싱글턴 메서드(Singleton Methods): Ruby는 특정 객체가 자기 자신만의 메서드를 정의할 수 있는 방법을 제공합니다. 이런 메서드는 해당 객체에서만 사용할 수 있으며, 같은 클래스의 다른 인스턴스에서는 접근할 수 없습니다.
- 믹스인(mix-in)된 모듈의 메서드: 모듈은
prepend,include,extend를 통해 클래스에 믹스인될 수 있습니다. 이 경우 클래스는 모듈에 정의된 메서드에 접근할 수 있으며, Ruby는 호출된 메서드를 찾기 위해 모듈 내부를 검색합니다. 또한 모듈 안에 다른 모듈이 믹스인되어 있을 수도 있는데, 검색은 그 안까지도 이어집니다. - 인스턴스 메서드: 클래스 자체에 정의되어, 그 클래스의 인스턴스들이 사용할 수 있는 메서드입니다.
- 부모 클래스의 메서드 또는 모듈: 해당 클래스가 다른 클래스의 자식 클래스라면, Ruby는 부모 클래스를 검색합니다. 검색은 부모 클래스의 싱글턴 메서드, 믹스인된 모듈, 그리고 그 부모의 부모 클래스까지 이어집니다.
- Object, Kernel, BasicObject: 마지막으로 검색되는 위치입니다. Ruby의 모든 객체는 이들을 조상(ancestor)으로 가지고 있기 때문입니다.
클래스와 모듈
메서드는 보통 객체를 대상으로 호출됩니다. 이 객체들은 Ruby의 내장 클래스이거나 개발자가 직접 만든 클래스로부터 생성됩니다.
class Human
attr_reader :name
def initialize(name)
@name = name
end
def hello
puts "Hello! #{@name}"
end
end
이렇게 만든 hello 메서드는 Human 클래스의 인스턴스에서 호출할 수 있습니다.
john = Human.new("John")
john.hello # 출력 -> Hello! John
hello는 인스턴스 메서드이기 때문에 Human 클래스의 인스턴스에서 호출할 수 있습니다. 반면, 인스턴스가 아니라 클래스 자체에서 호출하고 싶은 메서드가 필요한 경우도 있습니다. 이럴 때는 클래스 메서드(class method)를 정의해야 합니다. 위 클래스에 클래스 메서드를 추가하면 다음과 같습니다.
def self.me
puts "I am a class method"
end
이제 Human.me 형태로 호출할 수 있습니다. 애플리케이션이 복잡해지면(새 스타트업을 만든다고 상상해 봅시다), 두 개 이상의 클래스가 동일한 동작을 하는 메서드를 중복해서 가지게 되는 순간이 옵니다. 이럴 때는 DRY(Don't Repeat Yourself) 원칙대로 코드 중복을 피해야 하며, 핵심 질문은 "여러 클래스에 걸쳐 기능을 어떻게 공유할 것인가"입니다.
모듈을 사용해 본 적이 없다면, 이런 "공유" 메서드만 담은 새로운 클래스를 만들고 싶어질 수 있습니다. 하지만 이는 좋지 않은 결과를 낳을 수 있습니다. 특히 다중 상속이 필요한 상황에서 그런데, Ruby는 다중 상속을 지원하지 않기 때문입니다. 이런 경우의 최선책은 바로 모듈입니다. 모듈은 클래스와 비슷하지만 몇 가지 차이가 있습니다. 먼저 모듈의 예시를 보겠습니다.
module Movement
def walk
puts "I can walk!"
end
end
- 정의가
class가 아닌module키워드로 시작합니다. - 모듈은 인스턴스를 가질 수 없으므로
Movement.new처럼 사용할 수 없습니다.
메서드
메서드는 특정 객체가 수행하는 동작(action)이라고 볼 수 있습니다. 예를 들어 [2, 3, 4]라는 배열이 numberList라는 변수에 할당되어 있다면, .push 메서드는 배열이 수행할 수 있는 동작으로, 전달받은 값을 배열에 넣는(push) 역할을 합니다.
john.walk
흔히 "객체의 메서드를 호출한다"고 표현합니다. 여기서 john은 Human 클래스의 인스턴스인 객체를 참조하고, walk가 메서드입니다. 하지만 이 표현은 완전히 정확하지 않습니다. 실제로 호출되는 메서드는 객체의 클래스, 슈퍼클래스(superclass), 또는 믹스인된 모듈에 정의되어 있기 때문입니다.
덧붙이자면, 객체 자체에 메서드를 정의하는 것도 가능합니다. john 같은 객체도 가능하고요. Ruby에서는 객체를 생성하는 데 쓰이는 클래스조차 포함해 모든 것이 객체이기 때문입니다.
def john.drip
puts "My drip is eternal"
end
drip 메서드는 john 변수에 할당된 그 객체에서만 접근할 수 있습니다. 이렇게 특정 객체에만 정의된 drip을 싱글턴 메서드라고 부릅니다. 흥미로운 점은, 싱글턴 메서드와 클래스 메서드 사이에 본질적인 차이가 없다는 사실입니다. 위 예시처럼 특정 객체에 정의된 메서드가 아니라면, "그 메서드가 특정 객체에 속한다"고 말하는 것은 정확하지 않습니다. 앞선 예제에서 walk 메서드는 Movement 모듈에 속하고, hello 메서드는 Human 클래스에 속합니다. 이 관점을 이해하면 한 단계 더 나아가기 쉬워집니다. 즉, 객체에 호출된 정확한 메서드를 판단하기 위해 Ruby는 객체의 클래스, 슈퍼클래스, 그리고 객체의 계층 구조에 믹스인된 모듈들을 확인해야 한다는 것입니다.
모듈 믹스인(Mixing Modules)
Ruby는 단일 상속만 지원합니다. 즉, 하나의 클래스는 오직 하나의 클래스만 상속받을 수 있습니다. 덕분에 자식 클래스는 부모 클래스의 동작(메서드)을 물려받지만, 서로 다른 여러 클래스가 동작을 공유해야 할 때는 어떻게 해야 할까요? 예를 들어 walk 메서드를 Human 클래스의 인스턴스에서 사용할 수 있게 하려면, Movement 모듈을 Human 클래스에 믹스인하면 됩니다. include를 사용해 Human 클래스를 다시 작성하면 다음과 같습니다.
require "movement" # movement.rb 파일에 모듈이 있다고 가정
class Human
include Movement
attr_reader :name
def initialize(name)
@name = name
end
def hello
puts "Hello! #{@name}"
end
end
이제 인스턴스에서 walk 메서드를 호출할 수 있습니다.
john = Human.new("John")
john.walk
Include
앞서 사용한 것처럼 include 키워드를 사용하면, 포함된 모듈의 메서드들이 클래스의 인스턴스 메서드로 추가됩니다. 이는 포함된 모듈이 Human 클래스의 조상(ancestors) 목록에 삽입되기 때문입니다. 즉, Movement 모듈이 마치 Human 클래스의 부모처럼 취급되는 것입니다. 앞선 예제에서 Human 클래스의 인스턴스에서 walk 메서드를 호출할 수 있었던 이유가 바로 이것입니다.
Extend
include 외에도 Ruby는 extend 키워드를 제공합니다. 이를 사용하면 모듈의 메서드들이 클래스 메서드, 즉 앞서 배운 싱글턴 메서드 형태로 클래스에 제공됩니다. 예를 들어 다음과 같은 Feeding 모듈이 있다고 해봅시다.
module Feeding
def food
"I make my food :)"
end
end
이 모듈을 require한 뒤 Human 클래스에 extend Feeding을 추가하면 이 동작을 공유할 수 있습니다. 단, 사용할 때는 클래스의 인스턴스가 아니라 클래스 자체에서 food 메서드를 호출해야 합니다. 클래스 메서드를 호출하는 방식과 동일합니다.
Human.food
Prepend
prepend는 include와 비슷하지만 중요한 차이가 있습니다.
prepend는 include처럼 동작하지만, 모듈을 클래스와 그 슈퍼클래스 사이에 삽입하는 대신 체인의 가장 앞쪽, 클래스 자신보다도 앞에 삽입합니다.
즉, 클래스 인스턴스에서 메서드를 호출할 때 Ruby는 클래스보다 모듈의 메서드를 먼저 확인한다는 의미입니다.
예를 들어 hello 메서드를 정의한 모듈을 prepend로 Human 클래스에 믹스인하면, Ruby는 클래스에 정의된 hello가 아니라 모듈에 정의된 hello를 호출합니다.
Ruby의 prepend 동작을 제대로 이해하려면 관련 문서나 아티클을 함께 참고하는 것을 추천합니다.
메서드 조회 경로
Ruby 인터프리터가 메서드 호출 시 가장 먼저 확인하는 곳은 싱글턴 메서드입니다. 직접 실험해 결과를 확인해 볼 수 있는 REPL 환경을 준비해 두었으니 활용해 보세요.
다음과 같은 모듈들과 클래스가 있다고 가정해 봅시다.
module One
def another
puts "From one module"
end
end
module Two
def another
puts "From two module"
end
end
module Three
def another
puts "From three module"
end
end
class Creature
def another
puts "From creature class"
end
end
이것들을 Human 클래스에 믹스인해 보겠습니다.
class Human < Creature
prepend Three
extend Two
include One
def another
puts "Instance method"
end
def self.another
puts "From Human class singleton"
end
end
모듈 믹스인 외에도 인스턴스 메서드와 클래스 메서드를 각각 하나씩 정의했습니다. 그리고 Human 클래스가 Creature 클래스의 자식 클래스라는 점도 눈여겨보세요.
첫 번째 조회 - 싱글턴 메서드
Human.another를 실행하면 From Human class singleton이 출력됩니다. 이는 클래스 메서드에 정의된 내용입니다. 이 클래스 메서드를 주석 처리하고 다시 실행하면 이번에는 From two module이 콘솔에 출력됩니다. 이는 extend로 믹스인한 모듈에서 온 결과로, 조회가 싱글턴 메서드부터 시작된다는 것을 보여줍니다. 여기서 extend Two까지 제거(또는 주석 처리)하고 다시 실행하면 NoMethodError(method missing 에러)가 발생합니다. Ruby가 싱글턴 메서드 범위 안에서 another 메서드를 찾지 못했기 때문입니다.
이번에는 인스턴스를 생성해 보겠습니다.
n = Human.new
그리고 이 인스턴스에 싱글턴 메서드를 정의합니다.
def n.another
puts "From n object"
end
이제 n.another를 실행하면 n 객체에 정의된 싱글턴 메서드가 호출됩니다. 이 경우 Ruby가 extend로 믹스인한 모듈을 확인하지 않는 이유는, 메서드를 클래스의 인스턴스 대상으로 호출했기 때문입니다. 중요한 점은, 싱글턴 메서드가 extend로 믹스인된 모듈의 메서드보다 우선순위가 높다는 사실입니다.
두 번째 조회 - prepend로 믹스인된 모듈
n 객체의 싱글턴 메서드를 주석 처리하고 다시 실행하면, 이번에는 prepend로 믹스인한 모듈의 메서드가 호출됩니다. prepend는 모듈을 클래스 자체보다 앞에 삽입하기 때문입니다.
세 번째 조회 - 클래스 자체
모듈 Three를 주석 처리하면, 이번에는 클래스에 정의된 인스턴스 메서드 버전의 another가 호출됩니다.
네 번째 조회 - include로 믹스인된 모듈
Ruby가 메서드를 검색하는 다음 위치는 include로 믹스인된 모듈입니다. 인스턴스 메서드까지 주석 처리하면, 이번에는 모듈 One에 정의된 버전이 호출됩니다.
다섯 번째 조회 - 부모 클래스
클래스에 부모 클래스가 있다면, Ruby는 부모 클래스를 검색합니다. 이 검색에는 부모 클래스에 믹스인된 모듈도 포함됩니다. 만약 Creature 클래스에 믹스인된 모듈 안에 해당 메서드가 정의되어 있었다면, 그 메서드가 호출되었을 것입니다.
메서드 검색의 끝
메서드 검색이 어디서 끝나는지는 클래스에 .ancestors를 호출해 조상 목록을 확인하면 알 수 있습니다. Human 클래스에 실행하면 [Three, Human, One, Creature, Object, Kernel, BasicObject]가 반환됩니다. 메서드 검색은 Ruby의 루트 클래스인 BasicObject에서 끝납니다. 어떤 클래스의 인스턴스인 모든 객체는 궁극적으로 BasicObject 클래스에서 비롯됩니다.
개발자가 정의한 부모 클래스를 지난 후에는 다음 순서로 검색이 진행됩니다.
Object클래스Kernel모듈BasicObject클래스
method_missing 메서드
Ruby를 사용해 본 지 좀 되었다면 NoMethodError를 한 번쯤은 만나봤을 겁니다. 이 에러는 객체에 존재하지 않는 메서드를 호출하려 할 때 발생하며, Ruby가 객체의 조상 계층을 모두 훑었음에도 호출된 메서드를 찾지 못한 후에 일어납니다. 이때 받게 되는 에러 메시지는 BasicObject 클래스에 정의된 method_missing 메서드가 처리합니다. 이 메서드는 호출 대상 객체에 대해 재정의(오버라이드)할 수도 있는데, 이를 활용하면 동적 프록시 같은 강력한 패턴을 구현할 수 있습니다.
마치며
이제 Ruby가 객체에 호출된 메서드를 찾아내는 경로를 이해했습니다. 이 지식이 있으면 객체에 존재하지 않는 메서드를 호출할 때 발생하는 에러를 훨씬 쉽게 진단하고 수정할 수 있습니다. 메서드 조회 경로는 Ruby 객체 지향 모델의 핵심이니, 직접 코드를 실험해 보며 체득해 보시길 권합니다.