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

Ruby의 숨겨진 보석: Delegator와 Forwardable로 마스터하는 위임 패턴

오늘은 Ruby 표준 라이브러리의 숨겨진 보석들을 탐험하는 시간으로, 위임(Delegation)에 대해 알아보겠습니다.

아쉽게도 이 용어는 다른 많은 용어들처럼 오랜 세월 동안 그 의미가 다소 모호해졌고, 사람마다 다르게 해석하고 있습니다. 위키백과에 따르면:

위임(Delegation)은 한 객체(수신자)의 멤버(속성 또는 메서드)를 다른 원본 객체(발신자)의 맥락에서 평가하는 것을 의미한다. 위임은 발신 객체를 수신 객체에 전달함으로써 명시적으로 수행할 수 있으며, 이는 어떤 객체지향 언어에서도 가능하다. 또는 언어의 멤버 탐색 규칙에 의해 암묵적으로 수행될 수 있으며, 이는 해당 기능을 위한 언어 차원의 지원을 필요로 한다.

하지만 실제로는 사람들이 자기 자신을 인수로 전달하지 않고 한 객체가 다른 객체의 대응 메서드를 호출하는 패턴까지 포괄적으로 지칭하는 경우가 많습니다. 이를 더 정확히는 포워딩(Forwarding)이라고 부릅니다.

이 점을 짚고 넘어갔으니, 본문에서는 편의상 두 패턴을 모두 '위임'이라는 용어로 통칭하겠습니다.

Delegator

Ruby에서의 위임 탐험을 표준 라이브러리의 Delegator 클래스부터 시작해 보겠습니다. 이 클래스는 여러 가지 위임 패턴을 제공합니다.

SimpleDelegator

가장 간단하면서도 실무에서 가장 자주 만나게 되는 것이 바로 SimpleDelegator입니다. 이는 초기화 시 전달받은 객체를 감싸고(wrap), 정의되지 않은 모든 메서드 호출을 해당 객체에 위임합니다. 실제 동작을 살펴보겠습니다:

require 'delegate'
 
User = Struct.new(:first_name, :last_name)
 
class UserDecorator < SimpleDelegator
  def full_name
    "#{first_name} #{last_name}"
  end
end

먼저 SimpleDelegator를 사용할 수 있도록 require 'delegate'로 라이브러리를 불러왔습니다. 그리고 Struct를 활용해 first_namelast_name 접근자를 가진 간단한 User 클래스를 만들었습니다. 이어서 개별 이름 요소를 하나의 문자열로 합치는 full_name 메서드를 정의한 UserDecorator를 추가했습니다. 여기서 SimpleDelegator가 빛을 발합니다: first_namelast_name은 현재 클래스에 정의되어 있지 않기 때문에, 감싸고 있는 객체에 대한 호출로 자동 전달됩니다:

decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name
#=> "John Doe"

SimpleDelegator는 위임된 메서드를 super로 오버라이드하여 감싸진 객체의 원래 메서드를 호출할 수도 있습니다. 이를 활용해 전체 이름 대신 이니셜만 보여주도록 수정해 보겠습니다:

class UserDecorator < SimpleDelegator
  def first_name
    "#{super[0]}."
  end
end
decorated_user.first_name
#=> "J."
decorated_user.full_name
#=> "J. Doe"

Delegator

위 예제를 읽으면서 문득 이런 궁금증이 들었을 수 있습니다. 과연 UserDecorator는 어떻게 어느 객체에게 위임해야 하는지 알았을까요? 그 답은 SimpleDelegator의 부모 클래스인 Delegator에 있습니다. Delegator는 추상 기반 클래스로, __getobj____setobj__를 구현하여 위임 대상을 가져오고 설정하는 커스텀 위임 방식을 정의할 수 있습니다. 이 지식을 활용하면 데모용으로 나만의 SimpleDelegator 버전을 손쉽게 만들 수 있습니다:

class MyDelegator < Delegator
  attr_accessor :wrapped
  alias_method :__getobj__, :wrapped
 
  def initialize(obj)
    @wrapped = obj
  end
end
 
class UserDecorator < MyDelegator
  def full_name
    "#{first_name} #{last_name}"
  end
end

이 코드는 initialize 메서드 내에서 __setobj__를 호출하는 SimpleDelegator의 실제 구현과 약간 다릅니다. 우리가 만든 커스텀 위임 클래스에는 불필요하기 때문에 해당 메서드를 완전히 생략했습니다.

이전 예제와 정확히 같은 방식으로 동작합니다:

UserDecorator.superclass
#=> MyDelegator < Delegator
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name
#=> "John Doe"

DelegateClass

Delegate가 제공하는 마지막 위임 패턴은 이름이 다소 생소하게 느껴질 수 있는 Object.DelegateClass 메서드입니다. 이 메서드는 특정 클래스에 대한 위임자 클래스를 생성하여 반환하며, 우리는 이를 상속받아 사용할 수 있습니다:

  class MyClass < DelegateClass(ClassToDelegateTo)
    def initialize
      super(obj_of_ClassToDelegateTo)
    end
  end

언뜻 보면 혼란스러울 수 있습니다. 특히 상속문 우변에 임의의 Ruby 코드가 올 수 있다는 점이 그렇습니다. 하지만 사실 이것도 앞서 살펴본 패턴을 따르는 것으로, SimpleDelegator를 상속받는 것과 유사합니다.

Ruby 표준 라이브러리 자체도 이 기능을 활용해 Tempfile 클래스를 정의합니다. Tempfile은 저장 위치와 파일 삭제에 관한 특수 규칙을 설정하면서 대부분의 작업을 File 클래스에 위임합니다. 같은 메커니즘을 활용해 커스텀 Logfile 클래스를 다음과 같이 만들 수 있습니다:

class Logfile < DelegateClass(File)
  MODE = File::WRONLY|File::CREAT|File::APPEND
 
  def initialize(basename, logdir = '/var/log')
    # logdir에 지정된 위치에 로그 파일 생성
    path = File.join(logdir, basename)
    logfile = File.open(path, MODE, 0644)
 
    # Delegator의 initialize 메서드를 호출하므로,
    # 아래부터는 Logfile 인스턴스에서 File의 모든 메서드를 호출할 수 있습니다.
    super(logfile)
  end
end

Forwardable

흥미롭게도 Ruby 표준 라이브러리는 Forwardable 모듈과 그 메서드인 def_delegator, def_delegators를 통해 또 다른 위임 방식을 제공합니다.

앞서 만들었던 UserDecorator 예제를 Forwardable로 재작성해 보겠습니다.

require 'forwardable'
 
User = Struct.new(:first_name, :last_name)
 
class UserDecorator
  extend Forwardable
  def_delegators :@user, :first_name, :last_name
 
  def initialize(user)
    @user = user
  end
 
  def full_name
    "#{first_name} #{last_name}"
  end
end
 
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name
#=> "John Doe"

가장 눈에 띄는 차이점은 위임이 method_missing을 통해서 자동으로 제공되지 않고, 포워딩하려는 각 메서드에 대해 명시적으로 선언해야 한다는 것입니다. 덕분에 클라이언트에게 노출하고 싶지 않은 감싸진 객체의 메서드를 '숨길' 수 있어, 공개 인터페이스에 대한 더 강력한 제어권을 갖게 됩니다. 이것이 제가 일반적으로 ForwardableSimpleDelegator보다 선호하는 주된 이유입니다.

Forwardable의 또 다른 장점은 def_delegator를 사용해 위임 메서드의 이름을 변경할 수 있다는 점입니다. 선택적 세 번째 인수로 원하는 별칭을 지정할 수 있습니다:

class UserDecorator
  extend Forwardable
  def_delegator :@user, :first_name, :personal_name
  def_delegator :@user, :last_name, :family_name
 
  def initialize(user)
    @user = user
  end
 
  def full_name
    "#{personal_name} #{family_name}"
  end
end

위의 UserDecorator는 별칭이 붙은 personal_namefamily_name 메서드만 노출하면서, 내부적으로는 감싸진 User 객체의 first_namelast_name으로 포워딩합니다:

decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.first_name
#=> NoMethodError: undefined method `first_name' for #<UserDecorator:0x000000010f995cb8>
decorated_user.personal_name
#=> "John"

이 기능은 때때로 매우 유용합니다. 저도 과거에 비슷한 인터페이스지만 메서드 이름에 대한 기대치가 다른 라이브러리 간에 코드를 마이그레이션할 때 성공적으로 활용한 적이 있습니다.

표준 라이브러리 밖의 대안들

표준 라이브러리에 이미 위임 솔루션이 존재하지만, Ruby 커뮤니티는 수년간 여러 대안을 개발해 왔습니다. 그중 두 가지를 살펴보겠습니다.

Rails의 delegate

Rails의 압도적인 인기를 고려하면, Rails의 delegate 메서드는 Ruby 개발자들이 가장 많이 사용하는 위임 방식일 것입니다. 믿음직한 UserDecorator를 이 방식으로 재작성해 보겠습니다:

# 실제 Rails 앱에서는 ApplicationRecord의 서브클래스일 가능성이 높습니다
User = Struct.new(:first_name, :last_name)
 
class UserDecorator
  attr_reader :user
  delegate :first_name, :last_name, to: :user
 
  def initialize(user)
    @user = user
  end
 
  def full_name
    "#{first_name} #{last_name}"
  end
end
 
decorated_user = UserDecorator.new(User.new("John", "Doe"))
decorated_user.full_name
#=> "John Doe"

Forwardable과 상당히 비슷하지만, extend를 사용할 필요가 없다는 차이가 있습니다. delegateModule에 직접 정의되어 있어 모든 클래스나 모듈 본문에서 바로 사용할 수 있기 때문입니다(이것이 좋은 설계인지는 독자 여러분의 판단에 맡깁니다). 게다가 delegate는 몇 가지 깔끔한 옵션을 제공합니다. 첫 번째는 :prefix 옵션으로, 위임 대상 객체의 이름을 메서드 이름 앞에 붙여줍니다. 즉,

delegate :first_name, :last_name, to: :user, prefix: true

위 코드는 user_first_nameuser_last_name 메서드를 생성합니다. 커스텀 접두사를 직접 지정할 수도 있습니다:

delegate :first_name, :last_name, to: :user, prefix: :account

이제 사용자 이름의 각 부분을 account_first_nameaccount_last_name으로 접근할 수 있습니다.

delegate의 또 다른 흥미로운 옵션은 :allow_nil입니다. 위임 대상 객체가 nil인 경우—예를 들어 설정되지 않은 ActiveRecord 관계 때문에—일반적으로 NoMethodError가 발생합니다:

decorated_user = UserDecorator.new(nil)
decorated_user.first_name
#=> Module::DelegationError: UserDecorator#first_name delegated to @user.first_name, but @user is nil

하지만 :allow_nil 옵션을 사용하면 에러 대신 호출이 성공하고 nil을 반환합니다:

class UserDecorator
  delegate :first_name, :last_name, to: :user, allow_nil: true
 
  ...
end
 
decorated_user = UserDecorator.new(nil)
decorated_user.first_name
#=> nil

Casting

마지막으로 살펴볼 위임 옵션은 Jim Gay의 Casting 젬입니다. 이 젬은 개발자가 "Ruby에서 self를 보존하면서 메서드를 위임"할 수 있게 해줍니다. 메서드 호출의 수신자를 일시적으로 리바인딩하는 방식으로, 엄격한 의미의 위임 정의에 가장 가까운 구현입니다. 개념적으로는 다음과 같습니다:

UserDecorator.instance_method(:full_name).bind(user).call
#=> "John Doe"

가장 흥미로운 점은 슈퍼클래스 계층 구조를 변경하지 않고도 객체에 새로운 동작을 추가할 수 있다는 것입니다.

require 'casting'
 
User = Struct.new(:first_name, :last_name)
 
module UserDecorator
  def full_name
    "#{first_name} #{last_name}"
  end
end
 
user = User.new("John", "Doe")
user.extend(Casting::Client)
user.delegate(:full_name, UserDecorator)

여기서는 user 객체를 Casting::Client로 확장하여 delegate 메서드에 접근할 수 있게 했습니다. 대안으로 User 클래스 내부에 include Casting::Client를 사용하면 모든 인스턴스에 이 기능을 부여할 수도 있습니다.

추가로 Casting은 블록의 수명 동안 일시적으로 동작을 추가하거나, 명시적으로 제거할 때까지 유지하는 옵션도 제공합니다. 이를 위해서는 먼저 누락된 메서드의 위임을 활성화해야 합니다:

user.delegate_missing_methods

단일 블록 동안만 동작을 추가하려면 Castingdelegating 클래스 메서드를 사용합니다:

Casting.delegating(user => UserDecorator) do
  user.full_name #=> "John Doe"
end
 
user.full_name
#NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">

또는 uncast를 명시적으로 호출할 때까지 동작을 유지할 수도 있습니다:

user.cast_as(UserDecorator)
user.full_name
#=> "John Doe"
user.uncast
NoMethodError: undefined method `full_name' for #<struct User first_name="John", last_name="Doe">

앞서 소개한 다른 솔루션들보다 약간 더 복잡하지만, Casting은 매우 세밀한 제어력을 제공합니다. Jim은 자신의 『Clean Ruby』 책에서 이 젬의 다양한 활용법을 자세히 소개하고 있습니다.

마무리

위임과 메서드 포워딩은 연관된 객체들 간에 책임을 분배하는 데 유용한 패턴입니다. 순수 Ruby 프로젝트에서는 DelegatorForwardable을 모두 활용할 수 있으며, Rails 코드에서는 대체로 delegate 메서드를 선호하는 경향이 있습니다. 위임 대상에 대해 최대한의 제어권을 원한다면 Casting 젬이 훌륭한 선택이지만, 다른 솔루션보다 다소 복잡하다는 점은 감안해야 합니다.

게스트 저자 Michael Kohl은 2003년경부터 Ruby와 인연을 시작했습니다. 언어에 대한 글쓰기와 발표를 즐기며, Bangkok.rb와 RubyConf Thailand를 공동 운영하고 있습니다.