루비는 다른 객체지향 언어와 달리 클래스가 여러 부모로부터 상속받을 수 없습니다. 하지만 module을 mixin하면 다중 상속과 비슷한, 또는 더 풍부한 효과를 낼 수 있죠. 어떤 언어에서든 mixin이 지나치면 코드를 이해하기 어려워지지만, 잘 사용하면 중복이 줄어들고 깔끔해집니다. 루비의 mixin, 그리고 레일즈에서 더 편리하게 mixin을 사용할 수 있게 해주는 ActiveSupport::Concern에 대해 알아봅시다.

(글의 주제에 집중하기 위해 디테일을 많이 생략했습니다. 이 글에서 다루지 않은 부분이 궁금하시다면 참고문헌 섹션의 링크들을 읽어보시길 바랍니다.)

배경지식

기본적으로 다음 두 개념을 알아야 루비의 mixin에 대해 이해할 수 있습니다.

Module

루비에서 모듈은 “메서드와 상수의 집합”을 뜻합니다. 모듈은 클래스와 달리 instantiate될 수 없으며, 모듈의 주 목적은 그 안에 정의한 메서드를 다양한 클래스에 include, prepend, extend를 통해 mixin해서 재사용하는 것입니다. 이렇게 mixin해서 사용하는 메서드를 인스턴스 메서드라고 부르고, 객체 생성 없이 모듈 자체에서 호출하는 메서드를 모듈 메서드라고 부릅니다. 모듈 메서드는 mixin해도 사용할 수 없습니다.

module MyModule
  CONST = "My Const"

  def self.module_method
    "moule_method is called"
  end

  def instance_method
    "instance_method is called"
  end
end

irb > MyModule::CONST 
# => "My Const" 
irb > MyModule.module_method 
# => "module_method is called"
irb > MyModule.new.instance_method 
# NoMethodError (undefined method `new' for MyModule:Module)

Ancestors

루비에서는 클래스가 생성될 때 ancestors 배열에 클래스 조상의 목록을 저장해둡니다. ancestors에는 이 클래스가 상속받는 모든 클래스, 자기 자신, 그리고 includeprepend 를 통해 mixin된 모듈들이 포함됩니다.

클래스의 인스턴스 메서드를 호출하면 ancestors 배열의 앞에서부터 메서드 정의를 찾습니다. 상속 개념과 유사하게, 메서드 정의를 찾지 못하면 다음 조상에게서 찾는 식입니다. 즉 둘 이상의 선조들이 같은 이름의 메서드를 정의하고 있다면 더 가까운 선조에 정의된 메서드를 실행합니다. 참고로 BasicObject까지 거슬러 올라갔는데도 메서드를 찾지 못하면 BasicObject#method_missing이 실행되는데, 몇몇 루비 gem들은 이를 이용해 이해하기 쉽지 않은 흑마술을 부리기도 하더군요.

irb > String.acenstors 
# => [String, Comparable, Object, Kernel, BasicObject]
irb > String.included_modules 
# => [Comparable, Kernel]

irb > "foo".upcase 
# => "FOO"
irb > "foo".object_id 
# => 70264361086420

irb > String.instance_method(:upcase) 
# => #<UnboundMethod: String#upcase>
irb > String.instance_method(:object_id) 
# => #<UnboundMethod: String(Kernel)#object_id>

루비에서의 mixin

Include

include는 모듈에 정의된 메서드를 클래스에서 재사용하는 가장 쉽고 널리 알려진 방법입니다. 클래스를 정의할 때 어떤 모듈을 include하면 그 모듈은 ancestors 배열상에서 부모 클래스(superclass) 앞에 위치하게 되죠. 따라서 메서드 이름이 같다면 include된 모듈이 부모 클래스보다 우선순위를 가집니다.

module MyModule
  def log
    "log by MyModule"
  end
end

class BaseClass
  def log
    "log by BaseClass"
  end
end

class MyClass < BaseClass
  include MyModule
end

irb > MyClass.ancestors 
# => [MyClass, MyModule, BaseClass, Object, Kernel, BasicObject]
irb > MyClass.instance_method(:log) 
# => #<UnboundMethod: MyClass(MyModule)#log>
irb > MyClass.new.log 
# => log by MyModule

Prepend

prepend는 루비 2.0부터 도입된 mixin으로, include와 동작은 유사하나 용도는 다릅니다. include가 모듈의 메서드를 그대로 사용하기 위함이라면, prepend는 클래스의 기존 메서드를 꾸며주는 역할을 합니다. 이게 가능한 이유는, prepend된 모듈이 ancestors 배열상에서 원 클래스의 앞에 위치하기 때문입니다. 앞서 말씀드렸듯 메서드 호출은 ancesotrs의 앞에서부터 정의를 찾아나가기 때문에, prepend된 모듈의 메서드는 원 클래스의 메서드보다 우선순위가 높습니다. 그리고 여기에 다음 ancestor에서 메서드를 찾는 super 키워드를 조합하면, 해당 메서드의 앞이나 뒤에 우리가 원하는 동작을 추가할 수 있죠.

module MyModule
  def sum_of(numbers)
    result = super # MyClass#sum_of 호출
    "sum_of(#{numbers.inspect}) finished: #{result.inspect}"
  end
end

class MyClass
  prepend MyModule

  def sum_of(numbers)
    numbers.sum
  end
end

irb > MyClass.ancestors 
# => [MyModule, MyClass, Object, Kernel, BasicObject]
irb > MyClass.new.sum_of([1, 2, 3]) 
# => sum_of([1, 2, 3]) finished: 6

Extend

extend는 다른 두 mixin과 동작방식이 다릅니다. include와 prepend가 클래스의 ancestors 배열에 관여하여 인스턴스 메서드를 확장하는 개념이었다면, extend는 클래스의 클래스 메서드를 확장합니다.

module MyModule
  def log
    "log by MyModule"
  end
end

class MyClass
  extend MyModule
end

irb > MyClass.log 
# => log by MyModule
irb > MyClass.ancestors 
# => [MyClass, Object, Kernel, BasicObject]

그런데 위 스니펫에서 보듯이 extend해도 MyClass의 ancestors에는 변화가 없습니다. 그러면 extend는 어떻게 클래스가 모듈의 메서드에 접근할 수 있게 해주는 것일까요? 애초에 클래스 메서드는 어떻게 실행되는 걸까요?

사실 루비에서 진정한 의미의 클래스 메서드는 존재하지 않습니다. 루비에서는 모든 것이 오브젝트이고, 클래스도 다른 무언가의 인스턴스이며, 클래스 메서드도 결국은 인스턴스 메서드이기 때문입니다. 이에 대해 확실하게 이해하려면 싱글톤 클래스와 오브젝트 모델에 대해 알아야 합니다만, 지금은 “클래스 메서드는 싱글톤 클래스 안에 정의되고, 모듈을 extend하면 싱글톤 클래스가 확장된다”는 것만 기억해 둡시다.

irb > MyClass.singleton_class 
# => #<Class:MyClass>
irb > MyClass.singleton_class.ancestors 
=> [#<Class:MyClass>, MyModule, #<Class:Object>, #<Class:BasicObject>, Class, Module, Object, Kernel, BasicObject]

MyClass가 extend한 MyModuleMyClass.singleton_class 의 ancestors로 존재합니다. 위치는 include와 유사하게 클래스의 싱글톤 클래스 다음이며, 싱글톤 클래스도 클래스이기 때문에 ancestors의 동작 방식도 같습니다. MyClass.log는 먼저 #<Class:MyClass>에서 메서드 정의를 찾아보고, 찾을 수 없으면 다음 ancestor인 MyModule에서 찾습니다.

레일즈에서의 mixin

include + extend?

모듈을 사용하다 보면, 어떤 모듈은 하나의 클래스에 extend하면서 동시에 include하고 싶을 때가 생깁니다.

module MyModule
  def included_method
    "included"
  end
  
  def extended_method
    "extended"
  end
end

class MyClass
  include MyModule
  extend MyModule
end

irb > MyClass.included_method 
# => "included"
irb > MyClass.extended_method 
# => "extended"
irb > MyClass.new.included_method 
# => "included"
irb > MyClass.new.extended_method 
# => "extended"

보다시피 한 모듈을 두 번 mixin하는 것은 문법적으로는 가능하지만, 모듈의 메서드들이 클래스 메서드가 되면서 동시에 인스턴스 메서드도 되어버리기 때문에 우리가 원했던 상황과는 다릅니다.

이 문제는 모듈을 mixin할 때 호출되는 hook(included, prepended, extended)을 이용하여 해결할 수 있습니다. hook의 파라미터로 모듈을 mixin한 클래스가 넘어오기 때문에, 해당 클래스의 메서드들을 실행할 수 있는 것이죠. 다음은 이를 활용한 레일즈 코드 스니펫입니다.

module DisabledModule
  # 여기서 base는 Record입니다.
  def self.included(base)
    base.extend ClassMethods
    base.class_eval do
      # Record가 ApplicationRecord를 상속하기 때문에 scope 메서드가 Record에 정의됩니다.
      scope :disabled, -> { where(enabled: false) }
    end
  end

  module ClassMethods
    def available_list
      where(enabled: true)
    end
  end
  
  def disabled?
    enabled == false
  end
end

class Record < ApplicationRecord
  include DisabledModule
end

Record 클래스가 DisabledModule을 include함으로써 다음 세 가지가 가능해집니다.

  • Record.new.disabled?: 모듈이 include되어 인스턴스 메서드가 확장됩니다.
  • Record.available_list: Record 클래스가 DisabledModule::ClassMethods를 extend하여 클래스 메서드가 확장됩니다.
  • Record.disabled: class_eval을 통해 Record 클래스의 컨텍스트에서 블록이 실행되고, Record 모델에 disabled scope가 정의됩니다.

ActiveSupport::Concern

루비 1.9.3에서 included hook이 도입되고부터 위와 같은 패턴을 사용하는 케이스가 많아졌는데, 레일즈 4.0부터 생긴 ActiveSupport::Concern 은 이 패턴을 간편하게 줄여줍니다. (너무 기니까 이제부터 Concern이라고 칭하겠습니다.)

Concern을 extend한 모듈에서는 다음 두 블록을 사용할 수 있습니다.

  • included 블록: self.included(base)를 대체합니다. Concern에서 이 블록을 class_eval 해주기 때문에, before_action이나 has_many 등 레일즈의 여러 유용한 hook이나 association을 재사용하기 쉬워집니다.
  • class_methods 블록: base.extend ClassMethods를 대체합니다. 이 블록 안에서 정의된 메서드는 모듈을 include한 클래스의 클래스 메서드로 확장됩니다.
module DisabledModule
  extend ActiveSupport::Concern
  
  included do
    scope :disabled, -> { where(enabled: false) }
  end
  
  class_methods do
    def available_list
      where(enabled: true)
    end
  end

  def disabled?
    enabled == false
  end
end

class Record < ApplicationRecord
  include DisabledModule
end

모듈간 의존성 문제

여기까지만 해도 훌륭하지만, Concern은 모듈간 의존성 문제도 잘 해결해줍니다. 다음 코드 스니펫은 기존에 있던 UsefulModuleMyModule로 확장하려는 의도를 가지고 있는데요.

# module-dependencies.rb

module UsefulModule
  def self.included(base)
    base.class_eval { has_many :something }
  end
end

module MyModule
  include UsefulModule
  
  def another_useful_method
  end
end


class MyClass
  def self.has_many(*args)
    puts "MyClass has many #{args.inspect}"  
  end
  
  include MyModule
end

irb > require('./module-dependencies.rb')
# NoMethodError (undefined method `has_many' for MyModule:Module)

UsefulModule의 included hook에 들어온 baseMyClass가 아닌 MyModule이기 때문에, 스니펫을 실행하면 에러가 뜹니다. 이제 Concern을 쓴 스니펫을 보시죠.

# module-dependencies-with-concern.rb

module UsefulModule
  extend ActiveSupport::Concern
  
  included do
    has_many :something
  end
end

module MyModule
  extend ActiveSupport::Concern
  include UsefulModule
  
  def another_useful_method
  end
end

class MyClass
  def self.has_many(*args)
    puts "MyClass has many #{args.inspect}"  
  end
  
  include MyModule
end

irb > require('./module-dependencies-with-concern.rb')
# MyClass has many [:something]
irb > MyClass.ancestors 
# => [MyClass, MyModule, UsefulModule, Object, Kernel, BasicObject]

이 스니펫은 문제없이 실행되고, 의도대로 MyClasshas_many 메서드가 호출됩니다(레일즈에서는 association 정의를 실행하게 되겠죠). 이게 가능한 이유는 Concern을 extend한 모듈의 모든 included 블록이, extend하지 않은 최초의 모듈에서 include된 것처럼 (즉 UsefulModuleMyClass에 직접 include된 것처럼) 지연 실행되기 때문입니다. 좀 어려운데, 아무튼 개발자 입장에서는 Concern을 extend한 모듈을 다른 모듈에서도 안심하고 include할 수 있다는 걸 기억하시면 될 것 같습니다. 더 자세하게 알고 싶으신 분은 소스코드를 보셔도 좋겠네요.

끝내며: Metaprogramming 맛보기

루비와 레일즈의 mixin에 대해 알아봤습니다. 되도록 간결하게 적고 싶었는데 그래도 상당히 길어졌네요. 사실 너무 길어질까봐 별다른 설명 없이 적어놓은 문장이 꽤 있는데요. 그중 이게 가장 중요한 것 같습니다.

루비에서는 모든 것이 오브젝트이고, 클래스도 다른 무언가의 인스턴스이며

루비에서는 모든 클래스는 Class 클래스의 인스턴스이고, 심지어 ClassModuleClass 클래스의 인스턴스입니다.

irb > class MyClass; end
irb > MyClass.instance_of?(Class) 
# => true
irb > Class.instance_of?(Class) 
# => true
irb > Module.instance_of?(Class) 
# => true
irb > Class.ancestors 
# => [Class, Module, Object, Kernel, BasicObject]

MyClassClass 클래스의 인스턴스이므로, Class에 정의된 인스턴스 메서드를 MyClass.xxx형태로 쓸 수 있습니다.

irb > Class.instance_methods(false) 
# [:new, :allocate, :superclass]
irb > MyClass.ancestors 
# => [MyClass, Object, Kernel, BasicObject]
irb > MyClass.superclass 
# => Object
irb > Class.new.superclass 
# => Object
irb > Class.superclass 
# => Module

뭔가 복잡하죠? 사실 includeprependModule 클래스의 인스턴스 메서드인데, Class 클래스가 Module 클래스를 상속받기 때문에 우리가 일반적인 클래스 정의 내에서 사용할 수 있는 것입니다. extend 는 또 혼자 좀 다르게, Kernel 모듈에 정의되어 있습니다.

오브젝트 모델에 대해 파고들어가보면 이렇게 복잡하면서도 흥미롭습니다. 오브젝트 모델이나 싱글톤 클래스 등에 대한 개념을 묶어서 Metaprogramming이라고 부르기도 하던데, 이에 대해서는 다음 기회에 글을 써보려고 합니다.

참고문헌