이 글에서는 객체 마샬링(Object Marshalling)에 대해 깊이 있게 다뤄보겠습니다. 마샬링이 무엇인지 설명하고, Marshal 모듈을 살펴본 뒤 실제 예제를 통해 확인해 보겠습니다. 나아가 _dump와 self._load 메서드를 비교하며 한 단계 더 들어가 보겠습니다. 그럼 시작해 볼까요!
객체 마샬링이란 무엇일까요?
코드를 작성하다 보면 객체를 저장한 뒤 다른 프로그램으로 전송하거나, 다음 프로그램 실행 시 재사용하고 싶은 경우가 있습니다. 대표적인 예가 Sidekiq입니다. Ruby on Rails 애플리케이션에서 Sidekiq 작업(job)이 큐에 등록되면, 이 작업(사실상 하나의 객체)의 직렬화된 데이터가 Redis에 삽입됩니다. 이후 Sidekiq 프로세스가 해당 데이터를 역직렬화하여 원래의 작업을 재구성하는 방식으로 동작합니다.
컴퓨터 프로그래밍에서 이처럼 객체를 직렬화(serialization)하고 역직렬화(deserialization)하는 과정을 흔히 객체 마샬링이라고 부릅니다. 이제 Ruby가 객체 마샬링을 처리하기 위해 기본적으로 제공하는 기능을 살펴보겠습니다.
Marshal 모듈
Ruby는 완전한 객체 지향 프로그래밍 언어로서, 표준 라이브러리의 Marshal 모듈을 통해 객체를 직렬화하고 저장하는 방법을 제공합니다. 이 모듈을 사용하면 객체를 바이트 스트림으로 직렬화할 수 있으며, 이를 저장한 후 다른 Ruby 프로세스에서 역직렬화할 수 있습니다.
그럼 문자열을 직렬화하고, 직렬화된 객체를 자세히 살펴보겠습니다.
hello_world = 'hello world!'
serialized_string = Marshal.dump(hello_world) # => "\x04\bI\"\x11hello world!\x06:\x06ET"
serialized_string.class # => String
deserialized_hello_world = Marshal.load(serialized_string) # => "hello world!"
hello_world.object_id # => 70204420126020
deserialized_hello_world.object_id # => 70204419825700Marshal.dump 모듈 메서드를 호출하여 문자열을 직렬화합니다. 반환값(직렬화된 문자열)은 serialized_string 변수에 저장됩니다. 이 문자열은 파일로 저장할 수 있으며, 해당 파일을 활용해 다른 프로세스에서 원본 객체를 재구성할 수 있습니다. 이후 Marshal.load 메서드를 호출하면 바이트 스트림으로부터 원본 객체를 되돌려받을 수 있습니다.
방금 재구성된 문자열이 hello_world 문자열과 다른 object_id를 갖는 것을 확인할 수 있는데, 이는 서로 다른 객체이지만 동일한 데이터를 담고 있다는 의미입니다.
꽤 흥미롭죠? 그런데 Marshal 모듈은 어떻게 문자열을 재구성할 수 있을까요? 그리고 직렬화하거나 역직렬화할 속성을 직접 제어하고 싶다면 어떻게 해야 할까요?
객체 마샬링 실전 예제
이러한 궁금증을 해결하기 위해 User라는 커스텀 구조체(struct)에 마샬링 전략을 구현해 보겠습니다.
User = Struct.new(:fullname, :age, :roles)
user = User.new('Mehdi Farsi', 42, [:admin, :operator])User 구조체는 fullname, age, roles라는 세 개의 속성을 정의합니다. 이 예제에서는 다음 조건에 부합할 때만 직렬화하는 비즈니스 규칙이 있다고 가정합니다.
fullname이 64자 이하인 경우roles배열에:admin역할이 포함되어 있지 않은 경우
이를 위해 커스텀 직렬화 전략을 구현하는 User#marshal_dump 메서드를 정의할 수 있습니다. 이 메서드는 User 구조체 인스턴스를 인자로 Marshal.dump 메서드를 호출할 때 실행됩니다. 메서드를 정의해 보겠습니다.
User = Struct.new(:age, :fullname, :roles) do
def marshal_dump
{}.tap do |result|
result[:age] = age
result[:fullname] = fullname if fullname.size <= 64
result[:roles] = roles unless roles.include? :admin
end
end
end
user = User.new(42, 'Mehdi Farsi', [:admin, :operator])
user_dump = Marshal.dump(user) # 'in User#marshal_dump'
user_dump # => "\x04\bU:\tUser{\a:\bageI\"\x10Mehdi Farsi\x06:\x06ET:\rfullnamei/"
위 예제에서 Marshal.dump(user)를 호출할 때 User#marshal_dump 메서드가 호출되는 것을 확인할 수 있습니다. user_dump 변수에는 User 인스턴스의 직렬화 결과인 문자열이 담겨 있습니다.
이제 덤프된 데이터를 역직렬화하여 사용자를 재구성해 보겠습니다. 이를 위해 User 덤프의 역직렬화 전략을 담당하는 User#marshal_load 메서드를 정의합니다.
User = Struct.new(:age, :fullname, :roles) do
def marshal_dump
{}.tap do |result|
result[:age] = age
result[:fullname] = fullname if fullname.size <= 64
result[:roles] = roles unless roles.include? :admin
end
end
def marshal_load(serialized_user)
self.age = serialized_user[:age]
self.fullname = serialized_user[:fullname]
self.roles = serialized_user[:roles] || []
end
end
user = User.new(42, 'Mehdi Farsi', [:admin, :operator])
user_dump = Marshal.dump(user) # 'in User#marshal_dump'
user_dump # => "\x04\bU:\tUser{\a:\bagei/:\rfullnameI\"\x10Mehdi Farsi\x06:\x06ET"
original_user = Marshal.load(user_dump) # 'in User#marshal_load'
original_user # => #<struct User age=42, fullname="Mehdi Farsi", roles=[]>위 예제에서 Marshal.load(user_dump)를 호출할 때 User#marshal_load 메서드가 호출되는 것을 볼 수 있습니다. original_user 변수에는 사용자 인스턴스를 재구성한 구조체가 담겨 있습니다.
주목할 점은 original_user.roles가 user.roles 배열과 동일하지 않다는 것입니다. 직렬화 시점에 user.roles에 :admin 역할이 포함되어 있었기 때문에, 해당 값은 user_dump 변수에 직렬화되지 않았습니다.
_dump와 self._load 메서드
Marshal.dump와 Marshal.load가 호출되면, 이들은 인자로 전달된 객체의 marshal_dump와 marshal_load 메서드를 호출합니다.
그런데 Marshal.dump와 Marshal.load 메서드는 인자로 전달된 객체의 _dump와 self._load라는 두 메서드도 호출하려 시도한다는 사실을 알고 계셨나요?
_dump 메서드
marshal_dump와 _dump 메서드의 차이점은 다음과 같습니다.
_dump메서드를 사용할 때는 더 낮은 수준에서 직렬화 전략을 직접 처리해야 합니다. 즉, 직렬화할 데이터를 나타내는 문자열을 직접 반환해야 합니다.- 두 메서드가 모두 정의되어 있다면
marshal_dump메서드가_dump보다 우선 적용됩니다.
다음 예제를 살펴보겠습니다.
User = Struct.new(:age, :fullname, :roles) do
def _dump level
[age, fullname].join(':')
end
end
user = User.new(42, 'Mehdi Farsi', [:admin, :operator])
Marshal.dump(user) # => "\x04\bIu:\tUser\x1342:Mehdi Farsi\x06:\x06EF"User#_dump 메서드에서는 직렬화 객체, 즉 직렬화 결과를 나타내는 문자열을 직접 생성하여 반환해야 합니다.
다음 예제에서는 User#marshal_dump와 User#_dump 메서드를 모두 정의하고 각각 문자열을 반환하여, 실제로 어느 메서드가 호출되는지 확인해 보겠습니다.
User = Struct.new(:age, :fullname, :roles) do
def marshal_dump
'in User#marshal_dump'
end
def _dump level
'in User#_dump'
end
end
user = User.new(42, 'Mehdi Farsi', [:admin, :operator])
user_dump = Marshal.dump(user) # "in User#marshal_dump"두 메서드가 모두 정의되어 있음에도 User#marshal_dump만 호출되는 것을 확인할 수 있습니다.
self._load 메서드
이제 marshal_load와 _load 메서드를 살펴보겠습니다.
marshal_load와 _load 메서드의 차이점은 다음과 같습니다.
_load메서드를 사용할 때는 더 낮은 수준에서 역직렬화 전략을 직접 처리해야 합니다. 즉, 원본 객체의 인스턴스화를 직접 담당해야 합니다.marshal_load메서드는 역직렬화된 객체를 인자로 받는 반면,self._load메서드는 직렬화된 문자열을 인자로 받습니다.marshal_load메서드는 인스턴스 메서드인 반면,self._load는 클래스 메서드입니다.
다음 예제를 살펴보겠습니다.
User = Struct.new(:age, :fullname, :roles) do
def _dump level
[age, fullname].join(':')
end
def self._load serialized_user
user_info = serialized_user.split(':')
new(*user_info, Array.new)
end
end
user = User.new(42, 'Mehdi Farsi', [:admin, :operator])
user_dump = Marshal.dump(user)
user_dump # => "\x04\bIu:\tUser\x1342:Mehdi Farsi\x06:\x06EF"
original_user = Marshal.load(user_dump)
original_user # => #<struct User age="Mehdi Farsi", fullname=42, roles=[]>User._load 메서드에서는 다음 작업을 수행합니다.
User#_dump메서드가 반환한 문자열을 역직렬화합니다.- 역직렬화된 정보를 전달하여 새로운
User객체를 인스턴스화합니다.
원본 사용자를 재구성하는 데 사용되는 객체의 할당과 인스턴스화를 개발자가 직접 담당한다는 점을 알 수 있습니다.
즉, marshal_load와 결합된 Marshal.load는 재구성된 원본 객체의 인스턴스화를 대신 처리합니다. 그런 다음 새로 인스턴스화된 객체에 대해 직렬화된 객체를 인자로 전달하며 marshal_load 메서드를 호출합니다.
반면 _load와 결합된 Marshal.load 호출은 self._load 클래스 메서드가 다음 두 가지를 모두 담당하도록 합니다.
_dump메서드가 반환한 데이터의 역직렬화- 재구성된 원본 객체의 인스턴스화
마치며
필요에 따라 더 높은 수준 또는 더 낮은 수준의 직렬화/역직렬화 전략을 선택적으로 구현할 수 있습니다. 이를 위해 Marshal 모듈과 상황에 맞는 Marshal 훅 메서드를 함께 활용하면 됩니다.
여기까지입니다!