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

Ruby #dup과 #clone 완벽 가이드: 내부 구현 원리까지 파헤치기

오늘 포스트에서는 Ruby의 #dup#clone 메서드를 자세히 살펴보겠습니다. 먼저 이 주제에 관심을 갖게 된 실제 사례부터 시작하고, 이어서 Ruby에서 #dup이 어떻게 구현되어 있는지, 그리고 #clone과 어떻게 다른지 깊이 있게 알아볼 것입니다. 마지막으로 직접 #dup 메서드를 구현해 보며 마무리하겠습니다. 시작해 볼까요!

#dup을 사용하게 된 계기

NGO 단체의 기부 캠페인 구축을 전문으로 하는 회사에서 일할 때, 저는 기존 캠페인을 복사해서 새 캠페인을 만드는 작업을 자주 해야 했습니다. 예를 들어 2018년 캠페인이 끝나면 2019년용 새 캠페인이 필요했죠.

캠페인에는 수많은 설정 옵션이 있었는데, 이걸 매번 처음부터 다시 설정하고 싶지 않았습니다. 시간도 많이 걸리고 실수할 가능성도 높았거든요. 그래서 DB 레코드를 복사하는 방식으로 접근했습니다.

처음 몇 개의 캠페인은 실제로 손으로 직접 복사했습니다. 대략 이런 모습이었죠:

current_campaign = Campaign.find(1)
new_campaign = current_campaign
new_campaign.id = nil
new_campaign.created_at = nil
new_campaign.updated_at = nil
new_campaign.title = "Campaign 2019"
new_campaign.save!

동작은 하지만 타이핑이 많이 필요할 뿐만 아니라 오류가 발생하기 쉽습니다. 저도 과거에 created_atnil로 설정하는 걸 잊은 적이 몇 번 있었습니다.

이게 너무 번거롭게 느껴져서, 이것이 최선의 방법일 리 없다고 생각했습니다. 그리고 실제로 더 좋은 방법이 있었습니다!

new_campaign = Campaign.find(1).dup
new_campaign.title = "Campaign 2019"
new_campaign.save!

이렇게 하면 ID와 타임스탬프가 자동으로 nil로 설정됩니다. 바로 우리가 원하던 결과죠.

제가 처음 #dup을 접하게 된 계기가 바로 이것이었습니다. 이제 #dup이 실제로 어떻게 동작하는지 더 깊이 들여다보겠습니다.

내부에서는 무슨 일이 일어날까?

Ruby의 기본 #dup 메서드 구현은 객체에 특별한 이니셜라이저를 추가할 수 있게 해주며, 이 이니셜라이저는 #dup 메서드로 객체가 초기화될 때만 호출됩니다. 해당 메서드들은 다음과 같습니다:

  • initialize_copy
  • initialize_dup

이 메서드들의 구현은 사실 꽤 흥미로운데, 기본적으로 아무것도 하지 않기 때문입니다. 사실상 여러분이 오버라이드할 수 있도록 준비된 플레이스홀더입니다.

다음은 Ruby 소스 코드에서 가져온 내용입니다:

VALUE
rb_obj_dup(VALUE obj)
{
    VALUE dup;
 
    if (special_object_p(obj)) {
            return obj;
    }
    dup = rb_obj_alloc(rb_obj_class(obj));
    init_copy(dup, obj);
    rb_funcall(dup, id_init_dup, 1, obj);
 
    return dup;
}

우리에게 흥미로운 부분은 11번째 줄로, Ruby가 이니셜라이저 메서드 #initialize_dup을 호출하는 부분입니다.

rb_funcall은 Ruby의 C 코드에서 많이 사용되는 함수로, 객체의 메서드를 호출할 때 사용됩니다. 이 경우 dup 객체에 대해 id_init_dup을 호출합니다. 세 번째 인자 1은 인자의 개수를 나타내며, 여기서는 obj 하나뿐입니다.

조금 더 깊이 들어가서 해당 구현을 살펴보겠습니다:

VALUE
rb_obj_init_dup_clone(VALUE obj, VALUE orig)
{
    rb_funcall(obj, id_init_copy, 1, orig);
    return obj;
}

예시에서 볼 수 있듯이, id_init_copy를 호출하는 것 외에는 실제로 아무 일도 일어나지 않습니다. 이제 토끼굴을 파고들었으니, 그 메서드도 한번 살펴보겠습니다:

VALUE
rb_obj_init_copy(VALUE obj, VALUE orig)
{
    if (obj == orig) return obj;
    rb_check_frozen(obj);
    rb_check_trusted(obj);
    if (TYPE(obj) != TYPE(orig) || rb_obj_class(obj) != rb_obj_class(orig)) {
    rb_raise(rb_eTypeError, "initialize_copy should take same class object");
    }
    return obj;
}

코드가 더 많지만, 내부적으로 필요한 몇 가지 검사 외에는 특별한 일은 일어나지 않습니다(이 검사들에 대해서는 다른 기회에 다룰 만한 좋은 주제입니다).

결국 이 구현에서 Ruby가 하는 일은 여러분에게 확장 지점을 제공하고, 흥미로운 동작을 직접 구현할 수 있는 도구를 건네주는 것입니다.

Rails의 Dup 구현

Rails는 바로 이 메커니즘을 여러 곳에서 활용했습니다. 지금은 id와 타임스탬프 필드가 어떻게 초기화되는지에만 집중해 보겠습니다.

ID는 ActiveRecord의 핵심 모듈에서 초기화됩니다. 기본 키(primary key)가 무엇인지 고려하기 때문에, 이를 변경했더라도 여전히 올바르게 리셋됩니다.

# activerecord/lib/active_record/core.rb
def initialize_dup(other) # :nodoc:
  @attributes = @attributes.deep_dup
  @attributes.reset(self.class.primary_key)
 
  _run_initialize_callbacks
 
  @new_record               = true
  @destroyed                = false
  @_start_transaction_state = {}
  @transaction_state        = nil
 
  super
end

타임스탬프는 Timestamps 모듈에서 초기화됩니다. Rails가 생성 및 수정 시점에 사용하는 모든 타임스탬프(created_at, created_on, updated_at, updated_on)을 비우도록 지시합니다.

# activerecord/lib/active_record/timestamp.rb
def initialize_dup(other) # :nodoc:
  super
  clear_timestamp_attributes
end

여기서 흥미로운 점은 Rails가 #initialize_copy 메서드 대신 의도적으로 #initialize_dup 메서드를 오버라이드했다는 것입니다. 왜 그랬을까요? 함께 알아보겠습니다.

Object#initialize_copy 이해하기

위 코드 스니펫에서 객체에 .dup을 사용할 때 Ruby가 #initialize_dup을 호출하는 것을 확인했습니다. 그런데 #initialize_copy라는 메서드도 존재합니다. 이 메서드가 어디에 사용되는지 더 잘 설명하기 위해 예제를 살펴보겠습니다:

class Animal
  attr_accessor :name
 
  def initialize_copy(*args)
    puts "#initialize_copy is called"
    super
  end
 
  def initialize_dup(*args)
    puts "#initialize_dup is called"
    super
  end
end
 
animal = Animal.new
animal.dup
 
# => #initialize_dup is called
# => #initialize_copy is called

이제 호출 순서를 확인할 수 있습니다. Ruby는 먼저 #initialize_dup을 호출한 다음 #initialize_copy를 호출합니다. 만약 #initialize_dup 메서드에서 super 호출을 빼먹었다면 initialize_copy는 절대 호출되지 않았을 것이므로, 이 부분을 반드시 유지해야 합니다.

객체를 복사하는 다른 방법이 있을까?

이 구현을 살펴본 후, 두 개의 #initialize_* 메서드가 존재하는 이유가 궁금할 수 있습니다. 답은 #clone이라는 또 다른 객체 복사 방법이 있다는 것입니다. 일반적으로 객체의 내부 상태까지 포함하여 복사하고 싶을 때 #clone을 사용합니다.

Rails의 ActiveRecord #dup 메서드가 바로 이를 활용합니다. Rails는 #dup을 통해 레코드의 "내부" 상태(id와 타임스탬프) 없이 복제할 수 있게 하고, #clone은 Ruby의 기본 구현에 맡깁니다.

이 추가 메서드가 있다는 것은 #clone 메서드 사용 시에도 전용 이니셜라이저가 필요하다는 뜻입니다. 이를 위해서는 #initialize_clone을 오버라이드하면 됩니다. 이 메서드는 #initialize_dup과 동일한 라이프사이클을 사용하며 #initialize_copy로 호출을 전달합니다.

이를 알면 이니셜라이저 메서드들의 이름이 조금 더 이해됩니다. #dup을 사용하는지 #clone을 사용하는지에 따라 #initialize_(dup|clone)으로 각각 다른 구현을 할 수 있습니다. 두 경우 모두에 공통으로 적용되는 동작이 있다면 #initialize_copy 안에 배치하면 됩니다.

동물 클론 만들기

(예시일 뿐, 이 블로그 포스트를 위해 상처 입은 동물은 없습니다)

이제 실제로 어떻게 동작하는지 예제를 살펴보겠습니다.

class Animal
  attr_accessor :name, :dna, :age
 
  def initialize
    self.dna = generate_dna
  end
 
  def initialize_copy(original_animal)
    self.age = 0
    super
  end
 
  def initialize_dup(original_animal)
    self.dna = generate_dna
    self.name = "A new name"
    super
  end
 
  def initialize_clone(original_animal)
    self.name = "#{original_animal.name} 2"
    super
  end
 
  def generate_dna
    SecureRandom.hex
  end
end
 
bello = Animal.new
bello.name = "Bello"
bello.age = 10
 
bello_clone = bello.clone
bello_dup = bello.dup
 
bello_clone.name # => "Bello 2"
bello_clone.age # => 0
 
bello_dup.name # => "A new name"
bello_dup.age # => 0

여기서 실제로 일어나는 일을 분석해 보겠습니다. Animal이라는 클래스가 있고, 동물을 복사하는 방식에 따라 다른 동작을 해야 합니다:

  • 동물을 클론(clone)하면 DNA는 그대로 유지되고, 이름은 원래 이름 뒤에 "2"가 붙은 형태가 됩니다.
  • 동물을 복제(dup)하면 원본을 기반으로 새로운 동물을 만듭니다. 새로운 DNA와 새 이름을 갖게 되죠.
  • 모든 경우에 동물은 새끼(age 0)로 시작합니다.

이를 구현하기 위해 세 가지 서로 다른 이니셜라이저를 사용했습니다. #initialize_(dup|clone) 메서드는 항상 #initialize_copy를 호출하므로, 나이가 0으로 설정되는 것이 보장됩니다.

마무리하며

DB 레코드를 복사해야 하는 니즈에서 출발해, 캠페인 예제에서 손으로 복사하던 방식부터 #dup#clone까지 살펴봤습니다. 이어서 실용적인 내용에서 한 단계 더 나아가 Ruby 내부에서 이것이 어떻게 구현되어 있는지 확인했고, 동물을 클론하고 복제하는 재미있는 실험도 진행했습니다. 이 심층 탐구를 즐겁게 읽으셨기를 바랍니다.