오늘 포스트에서는 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_at을 nil로 설정하는 걸 잊은 적이 몇 번 있었습니다.
이게 너무 번거롭게 느껴져서, 이것이 최선의 방법일 리 없다고 생각했습니다. 그리고 실제로 더 좋은 방법이 있었습니다!
new_campaign = Campaign.find(1).dup
new_campaign.title = "Campaign 2019"
new_campaign.save!이렇게 하면 ID와 타임스탬프가 자동으로 nil로 설정됩니다. 바로 우리가 원하던 결과죠.
제가 처음 #dup을 접하게 된 계기가 바로 이것이었습니다. 이제 #dup이 실제로 어떻게 동작하는지 더 깊이 들여다보겠습니다.
내부에서는 무슨 일이 일어날까?
Ruby의 기본 #dup 메서드 구현은 객체에 특별한 이니셜라이저를 추가할 수 있게 해주며, 이 이니셜라이저는 #dup 메서드로 객체가 초기화될 때만 호출됩니다. 해당 메서드들은 다음과 같습니다:
initialize_copyinitialize_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 내부에서 이것이 어떻게 구현되어 있는지 확인했고, 동물을 클론하고 복제하는 재미있는 실험도 진행했습니다. 이 심층 탐구를 즐겁게 읽으셨기를 바랍니다.