요즘 루비(Ruby) 코드에서 #freeze 메서드가 사용되는 모습을 어렵지 않게 찾아볼 수 있습니다. 하지만 정작 왜 freeze를 사용하는지 그 이유가 명확하게 드러나지 않는 경우가 많습니다. 이 글에서는 개발자들이 변수나 객체를 freeze하는 가장 대표적인 이유들을 살펴보겠습니다. 각각의 이유를 설명하기 위해 Rails 코드베이스와 여러 인기 오픈소스 프로젝트에서 발췌한 실제 예제 코드를 함께 소개합니다.
1. 불변(Immutable) 상수 만들기
루비에서 상수는 사실 변경 가능(mutable)합니다. 다소 혼란스러울 수 있지만, 코드 자체는 이해하기 쉽습니다. 아래 예제에서는 문자열 상수를 만들고 다른 문자열을 덧붙여 보겠습니다.
MY_CONSTANT = "foo"
MY_CONSTANT << "bar"
puts MY_CONSTANT.inspect # => "foobar"
#freeze를 사용하면 이름 그대로 진짜 '상수'인 상수를 만들 수 있습니다. 이번에는 문자열을 수정하려는 순간 RuntimeError가 발생합니다.
MY_CONSTANT = "foo".freeze
MY_CONSTANT << "bar" # => RuntimeError: can't modify frozen string
ActionDispatch 코드베이스에서 이 패턴이 실제로 사용되는 모습을 볼 수 있습니다. Rails는 로그에 민감한 정보가 노출되지 않도록 해당 값을 "[FILTERED]"라는 텍스트로 대체하는데, 이 텍스트는 frozen 처리된 상수로 저장되어 있습니다.
module ActionDispatch
module Http
class ParameterFilter
FILTERED = '[FILTERED]'.freeze
...
2. 객체 할당 줄여서 성능 최적화하기
루비 애플리케이션의 속도를 높이는 가장 효과적인 방법 중 하나는 생성되는 객체의 수를 줄이는 것입니다. 그런데 대부분의 앱 곳곳에 흩어져 있는 문자열 리터럴이 성가신 객체 할당의 주범이 되곤 합니다.
log("foobar")처럼 메서드를 호출할 때마다 새로운 String 객체가 하나씩 생성됩니다. 이런 코드가 초당 수천 번 호출된다면, 초당 수천 개의 문자열을 생성하고 가비지 컬렉션으로 회수하고 있는 셈입니다. 상당한 오버헤드죠!
다행히 루비는 이 문제에 대한 해결책을 제공합니다. 문자열 리터럴을 freeze하면 루비 인터프리터는 String 객체를 단 한 번만 생성한 뒤 이후 재사용을 위해 캐싱합니다. 아래는 frozen 문자열과 일반 문자열 인자의 성능을 비교한 간단한 벤치마크입니다. 약 50%의 성능 향상을 확인할 수 있습니다.
require 'benchmark/ips'
def noop(arg)
end
Benchmark.ips do |x|
x.report("normal") { noop("foo") }
x.report("frozen") { noop("foo".freeze) }
end
# Results with MRI 2.2.2:
# Calculating -------------------------------------
# normal 152.123k i/100ms
# frozen 167.474k i/100ms
# -------------------------------------------------
# normal 6.158M (± 3.3%) i/s - 30.881M
# frozen 9.312M (± 3.5%) i/s - 46.558M
Rails 라우터에서 이 최적화가 실전에 적용된 모습을 확인할 수 있습니다. 라우터는 모든 웹 요청마다 실행되므로 반드시 빨라야 하며, 그래서 코드 곳곳에 frozen 문자열 리터럴이 사용됩니다.
# excerpted from https://github.com/rails/rails/blob/f91439d848b305a9d8f83c10905e5012180ffa28/actionpack/lib/action_dispatch/journey/router/utils.rb#L15
def self.normalize_path(path)
path = "/#{path}"
path.squeeze!('/'.freeze)
path.sub!(%r{/+\Z}, ''.freeze)
path.gsub!(/(%[a-f0-9]{2})/) { $1.upcase }
path = '/' if path == ''.freeze
path
end
3. 루비 2.2 이상의 내장 최적화
루비 2.2(MRI)부터는 해시 키로 사용되는 문자열 리터럴이 자동으로 freeze됩니다.
user = {"name" => "george"}
# In Ruby >= 2.2
user["name"]
# ...is equivalent to this, in Ruby <= 2.1
user["name".freeze]
또한 루비의 창시자 마츠모토 유키히로(Yukihiro Matsumoto)에 따르면, 루비 3에서는 모든 문자열 리터럴이 기본적으로 freeze될 예정이라고 밝힌 바 있습니다.
link: DevelopersMeeting20150820Japan - String literals are frozen (immutable) by default in Ruby 3.0
https://t.co/4XcelftmSa
— Yukihiro Matsumoto (@yukihiro_matz) August 20, 2015
참고: 실제 루비 3 정식 릴리스에서는 문자열 리터럴의 기본 freeze가 적용되지 않았으며, 대신 파일 상단에 # frozen_string_literal: true 매직 코멘트를 추가하는 방식으로 선택적으로 활성화할 수 있습니다.
4. 값 객체(Value Object)와 함수형 프로그래밍
루비는 함수형 프로그래밍 언어는 아니지만, 많은 루비스트들이 함수형 스타일로 코드를 작성하는 가치를 깨닫기 시작했습니다. 이 스타일의 핵심 원칙 중 하나는 부작용(side effect)을 피하는 것입니다. 즉, 객체는 초기화된 이후에는 절대 변경되어서는 안 됩니다.
생성자 안에서 freeze 메서드를 호출하면 객체가 절대 변경되지 않음을 보장할 수 있습니다. 혹시라도 의도하지 않은 부작용이 발생하면 즉시 예외가 던져지기 때문에, 버그를 조기에 잡아낼 수 있습니다.
class Point
attr_accessor :x, :y
def initialize(x, y)
@x = x
@y = y
freeze
end
def change
@x = 3
end
end
point = Point.new(1,2)
point.change # RuntimeError: can't modify frozen Point