객체의 변경 가능성(Mutability)이란?
어려운 용어에 겁먹을 필요는 없습니다. 변경 가능성(mutability)이란 단순히 객체의 내부 상태를 변경할 수 있다는 의미입니다. 이는 모든 객체의 기본 동작이며, freeze(동결)된 객체나 특수 객체 목록에 속한 객체만 예외입니다.
즉, Ruby의 모든 객체가 변경 가능한 것은 아닙니다!
예를 들어, 숫자나 심볼, 심지어 true나 false(이것들 역시 객체입니다)가 변경되는 것은 말이 되지 않습니다.
숫자 1은 언제나 1이기 때문입니다.
하지만 Array나 Hash처럼 데이터를 저장하는 용도의 객체들은 성능상의 이유로 변경 가능해야 합니다.
대안은 무엇일까요?
변경 사항을 반영한 새로운 복사본을 만들어 반환하고, 원본 객체는 그대로 두는 방식입니다.
만약 배열이 불변(immutable)이라면, 딱 한 요소만 바꾸고 싶어도 변경되지 않은 요소까지 포함한 모든 데이터를 복사해야 합니다.
백만 개(그 이상)의 요소를 가진 배열을 아무리 사소한 변경이라도 할 때마다 통째로 복사해야 한다고 상상해 보세요. 영 효율적이지 않습니다…
자, 이제 Ruby에서 변경 가능성이 실제로 어떻게 작동하는지 조금 더 깊이 들여다보겠습니다.
변경 가능성과 포인터로서의 변수
두 가지 요소가 결합하면서 발생하는 프로그래밍 오류의 한 범주가 있습니다.
- 변경 가능한(mutable) 객체
- 변수가 데이터 자체를 담는 것이 아니라, 데이터가 저장된 위치에 대한 참조(reference)를 담고 있다는 사실
이런 오류가 드러나는 대표적인 경우가 변수를 '별칭(alias)'처럼 취급할 때입니다.
예시:
name = "Peter" other_name = name puts other_name # "Peter"
이 예제에서 name과 other_name은 모두 동일한 문자열 객체를 가리킵니다. 어느 쪽 변수를 사용하든 이 문자열의 내용을 읽거나 수정할 수 있습니다.
문제는 other_name을 문자열의 독립된 복사본으로 착각할 때 발생합니다.
other_name[0] = 'T' name # "Teter" other_name # "Teter"
두 변수가 같은 문자열을 가리키고 있기 때문에 "Peter"가 "Teter"로 변해버렸습니다. 원래는 "Peter"를 그대로 유지할 의도였을 테니 분명 문제가 되는 상황입니다.
객체 복제하기: dup과 clone
이 문제를 해결하는 한 가지 방법은 dup 메서드를 사용하는 것입니다.
dup은 Ruby에게 객체의 복사본을 요청합니다. 비슷한 clone 메서드도 있는데, 이쪽은 복사본을 만드는 것에 더해 객체의 frozen(동결) 상태와 객체에 정의된 싱글톤 메서드까지 함께 복사한다는 차이가 있습니다.
예제를 살펴보겠습니다:
numbers = [1, 2, 3] more_numbers = numbers.dup more_numbers << 4 numbers # [1, 2, 3] more_numbers # [1, 2, 3, 4]
이 예제에서 원본 numbers 배열은 그대로 유지되는 것을 확인할 수 있습니다. 세 번째 줄의 dup 호출을 지우고 실행하면 어떻게 되는지 직접 실험해 보세요 🙂
Ruby freeze 메서드
객체를 원치 않는 변경으로부터 보호하는 또 다른 방법은 객체를 '동결(freeze)'하는 것입니다. 모든 Ruby 객체는 freeze 메서드로 동결할 수 있습니다.
객체가 동결되면, 이 객체를 변경하려는 모든 시도는 RuntimeError 예외를 발생시킵니다.
참고:
frozen?메서드를 사용하면 객체가 동결되어 있는지 확인할 수 있습니다.
예제:
animals = %w( cat dog tiger ) animals.freeze animals << 'monkey' # RuntimeError: can't modify frozen Array
주의할 점이 있습니다. freeze는 해당 객체 하나만 동결합니다. 위 예제에서는 배열 자체가 동결되어 요소를 추가하거나 제거할 수 없게 되지만, 배열 안에 들어있는 문자열들은 동결되지 않았기 때문에 여전히 변경 가능합니다!
animals[1][0] = 't' # => ["cat", "tog", "tiger"]
내부 문자열까지 동결하려면 각 요소에 대해 freeze를 호출해야 합니다. 예를 들면 animals.each(&:freeze)처럼요.
동결된 문자열(Frozen String)과 성능
변경 가능한 객체는 성능에도 영향을 미칩니다. 특히 문자열이 그렇습니다. 규모가 큰 프로그램에서는 동일한 문자열이 여러 번 사용될 가능성이 높기 때문입니다.
Ruby는 내용이 완전히 같은 문자열이라도 매번 새로운 객체를 생성합니다. irb에서 object_id 메서드를 사용하면 이를 쉽게 확인할 수 있습니다.
예시:
a = 'test' b = 'test' a.object_id # 76325640 b.object_id # 76317550
이것은 문제입니다. 동일한 내용의 객체들이 불필요하게 메모리와 CPU 사이클을 소모하기 때문입니다.
Ruby 2.1부터 frozen string(동결된 문자열)을 사용하면 Ruby가 동일한 문자열 객체를 재사용합니다. 같은 문자열의 복사본을 계속 만들 필요가 없어지므로, 메모리가 절약되고 성능도 소폭 향상됩니다.
Rails는 이러한 이유로 frozen string을 광범위하게 활용하고 있습니다. 실제 관련 PR을 살펴보면 그 활용도를 짐작할 수 있습니다.
이러한 흐름 속에서 Ruby 개발팀은 문자열을 기본적으로 불변(immutable) 객체로 전환하는 방안을 검토하기 시작했고, 실제로 Ruby 2.3에는 프로젝트에서 이를 활성화할 수 있는 두 가지 방법이 포함되었습니다.
첫 번째는 문자열을 불변으로 취급하고 싶은 파일마다 최상단에 # frozen_string_literal: true 주석을 추가하는 것이고, 두 번째는 명령줄 옵션 --enable=frozen-string-literal을 사용하는 것입니다.
기본값으로의 불변 문자열 도입은 Ruby 3.0에서 현실화되었습니다.
다만, 애플리케이션의 모든 문자열을 무작정 동결하는 것은 바람직하지 않습니다. 수백 번 이상 반복 사용되는 문자열에만 적용해야 실질적인 효과를 기대할 수 있습니다. 참고로, 동결 후보가 될 만한 문자열을 찾는 데 도움이 되는 도구들도 있으니 활용해 보세요.
메서드의 성격을 파악하라
변경 가능한 객체의 모든 메서드가 실제로 객체를 변경하는 것은 아닙니다. 예를 들어 gsub 메서드는 새 문자열을 반환할 뿐, 원본은 그대로 유지됩니다.
이 중 일부 메서드에는 원본 객체를 직접(in-place) 수정하는 대체 버전이 존재하며, 이쪽이 대개 더 효율적입니다. 이런 메서드는 그 효과를 드러내기 위해 이름 끝에 느낌표 !를 붙이는 경우가 많습니다.
이런 'bang 메서드'의 대표적인 예가 gsub!과 map!입니다.
단, 주의할 점:
!로 끝난다고 해서 반드시 '객체를 변경하는 메서드'를 의미하지는 않습니다.
보다 일반적으로 ! 기호는 '위험'을 나타내는 신호입니다. 한 예로 exit! 메서드가 있는데, 이는 종료 핸들러를 무시한 채 프로그램을 즉시 종료합니다.
반대로 객체를 변경하면서도 !로 끝나지 않는 메서드도 많습니다. 예를 들어 delete, clear, push, concat 등이 대표적입니다.
마무리
변경 가능성은 다루기 까다로운 주제이지만, 이 글을 끝까지 읽으셨다면 이제 충분히 대비된 상태입니다. 메서드의 동작이 확실하지 않다면 Ruby 공식 문서를 확인하는 습관을 들이세요. 예상치 못한 버그를 예방하는 가장 확실한 방법입니다.
이 글이 도움이 되었다면 주변에 공유해 주세요. 그리고 유사한 주제의 글이 올라올 때 놓치지 않도록 뉴스레터 구독도 잊지 마세요!