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

ActiveSupport #descendants 메서드 심층 분석: Ruby 상속과 모듈 완전 이해

Rails는 Ruby의 내장 객체에 다양한 기능을 추가합니다. 어떤 사람들은 이를 Ruby의 "방언(dialect)"이라고 부르기도 하는데, 바로 이 덕분에 Rails 개발자들은 1.day.ago 같은 직관적인 코드를 작성할 수 있습니다.

이렇게 추가된 대부분의 메서드는 ActiveSupport에 속해 있습니다. 오늘은 그중에서도 비교적 잘 알려지지 않은, Class에 직접 추가되는 descendants 메서드를 살펴보겠습니다. 이 메서드는 호출된 클래스의 모든 하위 클래스(subclass)를 반환합니다. 예를 들어 ApplicationRecord.descendants를 호출하면 애플리케이션에서 이를 상속받는 클래스들, 즉 앱의 모든 모델을 얻을 수 있습니다. 이 글에서는 이 메서드가 어떻게 동작하는지, 언제 사용하면 좋은지, 그리고 Ruby의 내장 상속 관련 메서드를 어떻게 보완하는지 알아보겠습니다.

객체 지향 언어에서의 상속

먼저 Ruby의 상속 모델을 간단히 복습해 보겠습니다. 다른 객체 지향(OO) 언어와 마찬가지로 Ruby는 계층 구조 안에 놓인 객체들을 사용합니다. 클래스를 만들고, 그 클래스의 하위 클래스를 만들고, 다시 그 하위 클래스를 만드는 식으로 계층을 쌓아갈 수 있습니다. 이 계층 구조를 위로 거슬러 올라가면 조상(ancestor) 목록을 얻게 됩니다. 또한 Ruby에는 모든 요소가 곧 객체라는 멋진 특징이 있습니다(클래스, 정수, 심지어 nil조차요). 반면 Java처럼 일부 언어는 성능상의 이유로 정수, double, 불리언 등 진짜 객체가 아닌 "프리미티브(primitive)" 타입을 사용하기도 합니다.

Ruby를 비롯한 모든 OO 언어는 어느 클래스에서 메서드를 찾아야 하는지, 그리고 우선순위가 어떻게 되는지 판단하기 위해 조상 정보를 추적해야 합니다.

class BaseClass
  def base
    "base"
  end

  def overridden
    "Base"
  end
end

class SubClass < BaseClass
  def overridden
    "Subclass"
  end
end

여기서 SubClass.new.overridden을 호출하면 "Subclass"가 반환됩니다. 하지만 base 메서드는 SubClass에 정의되어 있지 않으므로, Ruby는 해당 메서드를 구현한 조상이 있는지 하나씩 거슬러 올라가며 찾습니다. 조상 목록은 SubClass.ancestors를 호출하는 것만으로 확인할 수 있습니다. Rails 환경이라면 결과는 대략 다음과 같습니다:

[SubClass,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ActiveSupport::ToJsonWithActiveSupportEncoder,
 Object,
 PP::ObjectMixin,
 JSON::Ext::Generator::GeneratorMethods::Object,
 ActiveSupport::Tryable,
 ActiveSupport::Dependencies::Loadable,
 Kernel,
 BasicObject]

이 목록 전체를 자세히 분석하지는 않겠습니다만, 우리에게 중요한 점은 맨 위에 SubClass가 있고 그 아래에 BaseClass가 있다는 것입니다. 또한 목록 맨 아래에는 BasicObject가 있는데, 이것은 Ruby 최상위 객체이므로 항상 스택의 가장 아래에 위치하게 됩니다.

모듈(Mixin)

여기에 모듈이 개입하면 이야기가 조금 복잡해집니다. 모듈은 클래스 계층 구조상의 조상은 아니지만, 클래스에 "include"할 수 있습니다. 그러면 Ruby는 언제 모듈에서 메서드를 찾아야 하는지, 여러 모듈이 포함된 경우 어떤 모듈을 먼저 확인해야 하는지 알아야 합니다.

일부 언어는 이런 형태의 "다중 상속"을 허용하지 않지만, Ruby는 한 걸음 더 나아가 include를 쓰느냐 prepend를 쓰느냐에 따라 모듈이 계층 구조의 어디에 삽입될지까지 선택할 수 있게 해줍니다.

prepend로 모듈 넣기

이름에서 짐작할 수 있듯이, prepend된 모듈은 조상 목록에서 클래스 앞에 삽입됩니다. 즉, 해당 클래스의 메서드를 기본적으로 덮어쓰게 됩니다. 따라서 prepend된 모듈의 메서드 안에서 "super"를 호출하면 원래 클래스의 메서드를 실행할 수 있습니다.

module PrependedModule
  def test
    "module"
  end

  def super_test
    super
  end
end

# 앞서 정의한 `BaseClass` 재사용
class SubClass < BaseClass
  prepend PrependedModule

  def test
    "Subclass"
  end

  def super_test
    "Super calls SubClass"
  end
end

이제 SubClass의 조상 목록은 다음과 같습니다:

[PrependedModule,
 SubClass,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

PrependedModule이 목록의 맨 앞에 위치하게 되었으므로, Ruby는 SubClass에 대해 호출되는 메서드를 가장 먼저 이 모듈에서 찾습니다. 이것은 곧 PrependedModule 안에서 super를 호출하면 SubClass의 메서드가 실행된다는 의미이기도 합니다:

> SubClass.new.test
=> "module"
> SubClass.new.super_test
=> "Super calls SubClass"

include로 모듈 넣기

반면 include된 모듈은 조상 목록에서 클래스 뒤에 삽입됩니다. 이런 특성 덕분에 기본 클래스가 처리했을 메서드를 "가로채는(intercept)" 용도로 적합합니다.

class BaseClass
  def super_test
    "Super calls base class"
  end
end

module IncludedModule
  def test
    "module"
  end

  def super_test
    super
  end
end

class SubClass < BaseClass
  include IncludedModule

  def test
    "Subclass"
  end
end

이 배치에서 SubClass의 조상 목록은 다음과 같습니다:

[SubClass,
 IncludedModule,
 BaseClass,
 ActiveSupport::Dependencies::ZeitwerkIntegration::RequireDependency,
 ...
]

이제 SubClass가 첫 번째 탐색 대상이므로, Ruby는 IncludedModule의 메서드를 SubClass에 없을 때만 실행합니다. super 관점에서 보면, SubClass에서 호출된 super는 먼저 IncludedModule로 향하고, IncludedModule 내부에서 호출된 superBaseClass로 향합니다.

다시 말해, include된 모듈은 조상 계층에서 하위 클래스와 기본 클래스 사이에 끼어듭니다. 그래서 원래 기본 클래스가 처리했을 메서드를 효과적으로 '가로챌' 수 있는 것입니다:

> SubClass.new.test
=> "Subclass"
> SubClass.new.super_test
=> "Super calls BaseClass"

이런 "명령 계통(command chain)" 때문에 Ruby는 클래스의 조상을 추적해야 합니다. 하지만 그 반대는 성립하지 않습니다. 특정 클래스가 주어졌을 때 Ruby는 그 클래스의 자식, 즉 "후손(descendant)"을 추적할 필요가 없습니다. 메서드를 실행하는 데 후손 정보는 절대 필요하지 않기 때문입니다.

조상 순서의 중요성

눈좋은 독자라면 이미 눈치챘을 겁니다. 하나의 클래스에 여러 모듈을 사용한다면, include(또는 prepend)하는 순서에 따라 결과가 달라질 수 있습니다. 예를 들어 다음 두 클래스는,

class SubClass < BaseClass
  include IncludedModule
  include IncludedOtherModule
end

그리고 이 클래스는,

class SubClass < BaseClass
  include IncludedOtherModule
  include IncludedModule
end

완전히 다르게 동작할 수 있습니다. 두 모듈에 이름이 같은 메서드가 있다면, include 순서가 어느 쪽이 우선하는지, 그리고 super 호출이 어디로 해석될지를 결정합니다. 개인적으로는 모듈 포함 순서까지 신경 쓰지 않아도 되도록, 메서드 이름이 서로 겹치는 상황 자체를 최대한 피하는 편입니다.

실무 활용 사례

모듈의 includeprepend 차이를 아는 것도 좋지만, 언제 무엇을 선택해야 할지는 실제 예시를 통해 이해하는 것이 더 도움이 됩니다. 제가 이런 모듈을 주로 사용하는 경우는 Rails 엔진(engine)입니다.

Rails 엔진 중 가장 인기 있는 것이 아마 devise일 겁니다. 이번에는 비밀번호 다이제스트(digest) 알고리즘을 변경해 보겠습니다. 단, 먼저 한 가지 유의사항을 말씀드립니다.

제가 평소 모듈을 사용하는 방식은 기본 비즈니스 로직을 담고 있는 Rails 엔진의 동작을 커스터마이징하는 것입니다. 즉, 우리가 통제하는 코드의 동작을 재정의하는 것입니다. 물론 같은 방법을 어떤 Ruby 코드에든 적용할 수 있지만, 남이 관리하는 gem처럼 통제권이 없는 코드를 재정의하는 것은 권장하지 않습니다. 외부 코드가 변경되면 여러분의 수정 사항과 호환되지 않을 위험이 있기 때문입니다.

Devise의 비밀번호 다이제스트 처리는 Devise::Models::DatabaseAuthenticatable 모듈에서 이루어집니다:

  def password_digest(password)
    Devise::Encryptor.digest(self.class, password)
  end

  # 비밀번호 검증 부분:
  def valid_password?(password)
    Devise::Encryptor.compare(self.class, encrypted_password, password)
  end

Devise는 Devise::Encryptable::Encryptors를 직접 만들어 알고리즘을 커스터마이징하는 올바른 방법을 제공합니다. 하지만 여기서는 시연을 위해 모듈을 사용해 보겠습니다.

# app/models/password_digest_module
module PasswordDigestModule
  def password_digest(password)
    # Devise의 기본 bcrypt가 비밀번호에는 더 적합하며,
    # 여기서 sha1을 사용한 것은 시연용입니다.
    Digest::SHA1.hexdigest(password)
  end

  def valid_password?(password)
    Devise.secure_compare(password_digest(password), self.encrypted_password)
  end
end

begin
  User.include(PasswordDigestModule)
# 프로팁 - 여기서 User를 호출하면 ActiveRecord가 클래스 로딩 시점에
# 데이터베이스 읽기를 시도합니다. 이 때문에 `rails db:create` 같은
# 명령이 실패할 수 있습니다.
rescue ActiveRecord::NoDatabaseError, ActiveRecord::StatementInvalid
end

이 모듈을 로드하려면 개발 환경에서 Rails.application.eager_load!를 호출하거나, 파일을 로드하는 Rails 이니셜라이저를 추가해야 합니다. 테스트해 보면 기대대로 동작하는 것을 확인할 수 있습니다:

> User.create!(email: "one@test.com", name: "Test", password: "TestPassword")
=> #<User id: 1, name: "Test", created_at: "2021-05-01 02:08:29", updated_at: "2021-05-01 02:08:29", posts_count: nil, email: "one@test.com">
> User.first.valid_password?("TestPassword")
=> true
> User.first.encrypted_password
=> "4203189099774a965101b90b74f1d842fc80bf91"

이 예시에서는 includeprepend든 결과가 같습니다. 하지만 여기에 변수를 하나 더해 보겠습니다. User 모델이 자체적인 password_salt 메서드를 갖고 있고, 우리는 이것을 모듈 쪽 메서드로 덮어쓰고 싶다면 어떻게 될까요?

class User < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_many :posts

  def password_salt
    # 시연용으로 만든 아주 나쁜 salt 생성 방식입니다.
    Base64.encode64(email)[0..-4]
  end
end

그다음, 모듈이 비밀번호 다이제스트 생성 시 자체적인 password_salt 메서드를 사용하도록 수정합니다:

  def password_digest(password)
    # Devise의 기본 bcrypt가 비밀번호에는 더 적합하며,
    # 여기서 sha1을 사용한 것은 시연용입니다.
    Digest::SHA1.hexdigest(password + "." + password_salt)
  end

  def password_salt
    # 한층 더 나쁜 salt 생성 방식
    "salt"
  end
end

이제 includeprepend는 서로 다르게 동작합니다. 무엇을 사용하느냐에 따라 Ruby가 실행할 password_salt 메서드가 달라지기 때문입니다. prepend를 사용하면 모듈이 우선권을 가지므로 다음과 같은 결과를 얻습니다:

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.salt"

반면 모듈을 include로 바꾸면 User 클래스의 구현이 우선권을 가집니다:

> User.last.password_digest("test")
=> "a94a8fe5ccb19ba61c4c0873d391e987982fbbd3.dHdvQHRlc3QuY2"

개인적으로는 모듈을 작성할 때 그것을 일종의 하위 클래스처럼 취급하고, 모듈의 메서드가 클래스 버전을 덮어쓴다고 가정하는 것이 더 직관적이기 때문에 prepend를 먼저 고려합니다. 물론 이것이 항상 원하는 동작은 아니며, 바로 그래서 Ruby가 include 옵션도 함께 제공하는 것입니다.

Descendants 메서드

지금까지 Ruby가 메서드 실행 시 우선순위를 결정하기 위해 클래스의 조상을 추적하는 방식과, 모듈을 통해 이 목록에 요소를 삽입하는 방법을 살펴봤습니다. 그런데 프로그래머 입장에서는 클래스의 후손(descendant) 전체를 순회하고 싶을 때도 유용합니다. 바로 이럴 때 ActiveSupport의 #descendants 메서드가 빛을 발합니다. 이 메서드는 매우 짧아서 필요하다면 Rails 밖에서도 쉽게 재현할 수 있습니다:

class Class
  def descendants
    ObjectSpace.each_object(singleton_class).reject do |k|
      k.singleton_class? || k == self
    end
  end
end

ObjectSpace는 현재 메모리에 존재하는 모든 Ruby 객체에 대한 정보를 저장하는 흥미로운 영역입니다. 여기서 깊이 파고들지는 않겠지만, 애플리케이션에 정의된 클래스가 로드되어 있다면 ObjectSpace에 존재한다는 것만 알아두세요. ObjectSpace#each_object에 모듈을 인자로 넘기면 해당 모듈과 일치하거나 그 하위인 객체들만 반환합니다. 블록에서는 최상위 클래스 자신을 제외합니다(예를 들어 Numeric.descendants를 호출했다면 결과에 Numeric 자체는 없어야겠죠).

내부 동작이 잘 이해되지 않더라도 걱정하지 마세요. 이 부분을 온전히 이해하려면 ObjectSpace에 대한 추가 학습이 필요합니다. 우리에게 중요한 것은 이 메서드가 Class에 정의되어 있으며 후손 클래스들의 목록을 반환한다는 점입니다. 그 클래스의 자식, 손자 등을 아우르는 "가계도(family tree)"라고 생각해도 좋습니다.

#descendants의 실무 활용

2018년 RailsConf에서 Ryan Laughlin은 'checkup(점검)'에 대한 발표를 했습니다. 영상도 볼 가치가 충분하지만, 여기서는 그중 하나의 아이디어만 발췌하겠습니다. 바로 데이터베이스의 모든 행을 주기적으로 순회하면서 해당 모델의 유효성 검증을 통과하는지 확인하는 것입니다. 데이터베이스의 상당수 행이 #valid? 검증에 실패하고 있다면 놀라실지도 모릅니다.

문제는 이 검사를 구현하면서 모델 목록을 수동으로 관리하고 싶지 않다는 점입니다. 그 답이 바로 #descendants입니다:

# 모든 모델이 로드되도록 보장 (프로덕션에서는 불필요)
Rails.application.load! if Rails.env.development?

ApplicationRecord.descendants.each do |model_class|
  # 실제 환경에서는 백그라운드 잡으로 넘기는 것이 좋습니다.
  model_class.all.each do |record|
    if !record.valid?
      HoneyBadger.notify("Invalid #{model.name} found with ID: #{record.id}")
    end
  end
end

여기서 ApplicationRecord.descendants는 표준 Rails 애플리케이션의 모든 모델 목록을 반환합니다. 루프 안에서 model은 클래스입니다(예: User, Product). 구현 자체는 아주 기본적이지만, 결과적으로 ApplicationRecord의 모든 하위 클래스(즉, 모든 모델)를 순회하면서 모든 행에 대해 .valid?를 호출하게 됩니다.

결론

대부분의 Rails 개발자에게 모듈은 자주 사용되는 기능이 아닙니다. 이에 나름의 이유가 있습니다. 코드를 직접 소유하고 있다면 동작을 커스터마이징할 더 쉬운 방법이 보통 존재하고, 코드를 소유하고 있지 않다면 모듈로 동작을 바꾸는 데는 리스크가 따르기 때문입니다. 그럼에도 불구하고 모듈만의 사용 사례는 분명히 존재합니다. 다른 파일에서 클래스를 변경할 수 있을 뿐만 아니라, 조상 체인의 어느 위치에 모듈을 놓을지까지 선택할 수 있다는 것은 Ruby의 유연성을 보여주는 대표적인 예라 할 수 있습니다.

ActiveSupport는 #ancestors의 정확한 반대 개념인 #descendants를 제공함으로써 이 생태계를 완성합니다. 제 경험상 이 메서드는 자주 쓰이지 않지만, 존재를 알고 나면 활용처가 점점 더 많아질 것입니다. 저 역시 모델 유효성 검사뿐 아니라, 스펙(spec)에서 모든 모델에 attribute_alias 메서드가 올바르게 추가되었는지 검증하는 용도로도 활용해 왔습니다.