지난 Ruby Magic에서는 클래스의 .new 메서드를 오버라이드하는 방식으로 모듈을 클래스에 안정적으로 주입하고, 기존 메서드 위에 추가 동작을 덧씌우는 방법을 알아봤습니다.
이번에는 이 동작을 하나의 모듈로 추출해 어디서든 재사용할 수 있도록 한 단계 더 나아가 보겠습니다. 클래스 확장을 대신 처리해 주는 Wrappable 모듈을 직접 만들어 보면서, 클래스 레벨 인스턴스 변수(class-level instance variables)에 대한 모든 것을 함께 배우게 될 겁니다. 바로 시작해 볼까요?
Wrappable 모듈 소개
객체가 초기화되는 시점에 모듈로 감싸려면(wrap), 클래스는 미리 어떤 모듈을 사용할지 알고 있어야 합니다. 먼저 간단한 Wrappable 모듈부터 만들어 보겠습니다. 이 모듈은 전달받은 모듈을 클래스 속성으로 정의된 배열에 추가하는 wrap 메서드를 제공하고, 지난 글에서 다룬 대로 new 메서드도 함께 재정의합니다.
module Wrappable
@@wrappers = []
def wrap(mod)
@@wrappers << mod
end
def new(*arguments, &block)
instance = allocate
@@wrappers.each { |mod| instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end이 새로운 동작을 클래스에 추가하려면 extend를 사용합니다. extend 메서드는 지정된 모듈을 클래스에 섞어 넣어, 모듈의 메서드들이 클래스 메서드로 동작하게 만듭니다. 이후 이 클래스의 인스턴스를 어떤 모듈로 감쌀지 지정하려면 wrap 메서드를 호출하면 됩니다.
module Logging
def make_noise
puts "Started making noise"
super
puts "Finished making noise"
end
end
class Bird
extend Wrappable
wrap Logging
def make_noise
puts "Chirp, chirp!"
end
endBird의 새 인스턴스를 만들고 make_noise 메서드를 호출해 직접 확인해 보겠습니다.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise훌륭합니다! 의도한 대로 잘 동작합니다. 하지만 두 번째 클래스를 Wrappable 모듈로 확장하는 순간, 상황이 조금 이상해지기 시작합니다.
module Powered
def make_noise
puts "Powering up"
super
puts "Shutting down"
end
end
class Machine
extend Wrappable
wrap Powered
def make_noise
puts "Buzzzzzz"
end
end
machine = Machine.new
machine.make_noise
# Powering up
# Started making noise
# Buzzzzzz
# Finished making noise
# Shutting down
bird = Bird.new
bird.make_noise
# Powering up
# Started making noise
# Chirp, chirp!
# Finished making noise
# Shutting downMachine은 Logging 모듈로 감싼 적이 없는데도 로그 정보를 출력합니다. 게다가 더 심각한 문제는, 이제 새마저 시동을 걸고 끈다는 점입니다. 뭔가 분명히 잘못됐죠?
문제의 근본 원인은 모듈을 저장하는 방식에 있습니다. 클래스 변수 @@wrappers는 Wrappable 모듈 자체에 정의되어 있고, wrap이 어떤 클래스 안에서 호출되든 상관없이 항상 이 변수를 사용하기 때문입니다.
Wrappable 모듈과 Bird, Machine 클래스에 정의된 클래스 변수를 살펴보면 더욱 명확해집니다. Wrappable에는 클래스 변수가 정의되어 있지만, 두 클래스에는 아무것도 없습니다.
Wrappable.class_variables # => [:@@wrappers]
Bird.class_variables # => []
Machine.class_variables # => []이 문제를 해결하려면 구현을 수정해 인스턴스 변수를 사용해야 합니다. 다만 여기서 말하는 인스턴스 변수는 Bird나 Machine의 개별 인스턴스가 아닌, 클래스 그 자체에 대한 인스턴스 변수입니다.
루비에서 클래스는 그저 객체다
처음에는 다소 충격적이고 혼란스러울 수 있지만, 반드시 이해해야 할 핵심 개념입니다. 클래스는 Class의 인스턴스이며, class Bird; endBird = Class.newClass는 Module을 상속하고, Module은 다시 Object를 상속합니다. 그 결과 클래스와 모듈은 다른 모든 객체와 똑같은 메서드들을 갖습니다. 우리가 클래스에서 흔히 사용하는 메서드들(예: attr_accessor 매크로)은 사실 대부분 Module의 인스턴스 메서드입니다.
클래스에 인스턴스 변수 활용하기
이제 Wrappable 구현을 인스턴스 변수를 사용하도록 고쳐 보겠습니다. 코드를 좀 더 깔끔하게 유지하기 위해, 인스턴스 변수가 이미 존재하면 해당 배열을 반환하고 없으면 새 배열을 초기화해 주는 wrappers 메서드를 도입합니다. wrap과 new 메서드 역시 이 새로운 메서드를 활용하도록 수정합니다.
module Wrappable
def wrap(mod)
wrappers << mod
end
def wrappers
@wrappers ||= []
end
def new(*arguments, &block)
instance = allocate
wrappers.each { |mod| instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end모듈과 두 클래스의 인스턴스 변수를 확인해 보면, 이제 Bird와 Machine이 각자 자신만의 래퍼 모듈 목록을 따로 관리하고 있음을 알 수 있습니다.
Wrappable.instance_variables #=> []
Bird.instance_variables #=> [:@wrappers]
Machine.instance_variables #=> [:@wrappers]당연하게도, 앞서 관찰했던 문제도 함께 해결됩니다. 이제 각 클래스는 자신에게 지정된 모듈로만 감싸집니다.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise
machine = Machine.new
machine.make_noise
# Powering up
# Buzzzzzz
# Shutting down상속 지원하기
여기까지는 잘 동작하지만, 상속이 등장하면 이야기가 달라집니다. 하위 클래스는 슈퍼클래스의 래퍼 모듈도 함께 물려받아야 한다고 자연스럽게 기대하게 되는데요. 실제로 그런지 확인해 보겠습니다.
module Flying
def make_noise
super
puts "Is flying away"
end
end
class Pigeon < Bird
wrap Flying
def make_noise
puts "Coo!"
end
end
pigeon = Pigeon.new
pigeon.make_noise
# Coo!
# Is flying away보시다시피 기대대로 동작하지 않습니다. Pigeon 역시 자신만의 별도 래퍼 목록을 유지하고 있기 때문입니다. Pigeon에 정의된 래퍼가 Bird에는 영향을 주지 않는 것은 합리적이지만, 상위 클래스의 래퍼까지 잃어버리는 것은 우리가 원하는 바가 아닙니다. 상속 체계 전체에서 모든 래퍼를 가져오는 방법을 찾아봅시다.
다행히 루비는 Module#ancestors 메서드를 제공합니다. 이 메서드는 클래스(또는 모듈)가 상속받는 모든 클래스와 모듈의 목록을 반환합니다.
Pigeon.ancestors # => [Pigeon, Bird, Object, Kernel, BasicObject]여기에 grep 호출을 더하면 실제로 Wrappable로 확장된 클래스만 골라낼 수 있습니다. 상위 체계의 래퍼를 먼저 적용해야 하므로 .reverse로 순서를 뒤집습니다.
Pigeon.ancestors.grep(Wrappable).reverse # => [Bird, Pigeon]루비의 #=== 메서드
루비의 마법 중 상당 부분은 사실 #===(case equality, 케이스 동등성) 메서드에서 비롯됩니다. 기본적으로 이 메서드는 #==(동등성) 메서드와 똑같이 동작하지만, 여러 클래스가 case 문에서 서로 다른 동작을 하도록 #===를 오버라이드합니다. 그래서 case 문 안에서 정규표현식(#===는 #match?와 동등)이나 클래스(#===는 #kind_of?와 동등)를 사용할 수 있는 것입니다. Enumerable#grep, Enumerable#all?, Enumerable#any? 같은 메서드들도 이 케이스 동등성 메서드에 의존합니다.
이제 flat_map(&:wrappers)를 호출하면 상속 체계 전체에 정의된 모든 래퍼를 하나의 배열로 얻을 수 있습니다.
Pigeon.ancestors.grep(Wrappable).reverse.flat_map(&:wrappers) # => [Logging]이제 남은 일은 이 로직을 inherited_wrappers 메서드로 묶고, new 메서드가 wrappers 대신 이 메서드를 사용하도록 살짝 수정하는 것뿐입니다.
module Wrappable
def inherited_wrappers
ancestors
.grep(Wrappable)
.reverse
.flat_map(&:wrappers)
end
def new(*arguments, &block)
instance = allocate
inherited_wrappers.each { |mod|instance.singleton_class.include(mod) }
instance.send(:initialize, *arguments, &block)
instance
end
end마지막으로 테스트를 실행해 보면 모든 것이 기대대로 동작함을 확인할 수 있습니다. 래핑 모듈은 해당 클래스(그리고 그 하위 클래스)에만 정확히 적용됩니다.
bird = Bird.new
bird.make_noise
# Started making noise
# Chirp, chirp!
# Finished making noise
machine = Machine.new
machine.make_noise
# Powering up
# Buzzzzz
# Shutting down
pigeon = Pigeon.new
pigeon.make_noise
# Started making noise
# Coo!
# Finished making noise
# Is flying away마무리하며
솔직히 말하면, 시끄러운 새들의 예제는 다소 이론적인 면이 있습니다(짹짹!). 하지만 상속 가능한 클래스 인스턴스 변수는 단순히 클래스의 동작 방식을 이해하는 재미를 넘어, "루비에서 클래스는 그저 객체일 뿐"이라는 사실을 보여주는 훌륭한 사례이기도 합니다.
게다가 인정하건대, 상속 가능한 클래스 인스턴스 변수는 실무에서도 꽤 유용하게 쓰일 수 있습니다. 예를 들어 모델에 속성과 관계를 정의해 두고, 나중에 그것들을 내부적으로 조회(introspection)할 수 있게 만드는 경우를 떠올려 보세요. 저희에게 진짜 마법은 이런 것들을 직접 만져보며 동작 원리를 깊이 이해하는 과정 그 자체이며, 한 단계 더 높은 차원의 해결책을 떠올릴 수 있게 열어주는 문이라고 생각합니다. 🧙🏼♀️
언제나처럼, 이 패턴이나 유사한 패턴을 활용해 무엇을 만들었는지 기대하고 있습니다. Twitter에서 @AppSignal로 짹짹 알려주세요!