SmalltalkObjectsandDesign:Chapter 16

From 흡혈양파의 번역工房
Jump to navigation Jump to search
제 16 장 객체 지향 언어는 어떻게 작동하는가

객체 지향 언어는 어떻게 작동하는가

이번 장에서는 처음으로 필수 객체 지향 원칙들을 살펴보고자 한다. 객체 지향 언어들이 사실상 깊은 내부에서 어떻게 작동하는지를 먼저 다룰 것이다. 언어의 모양과 느낌은 이러한 내부 작업으로 형성됨을 볼 수 있을 것이다.


스펙트럼의 한 쪽 끝에는 스몰토크가 위치하는데, 이는 점진적 개발 스타일, 프로그래머를 위한 메모리 문제의 처리, 그것의 반영성(reflectiveness)으로 유명하다. (반영성 또는 반영은 상황에 따라 그때 그때 자신을 조사하고 수정하는 소프트웨어 시스템의 기능을 의미한다.) 이는 모두 런타임 엔진, 즉 모든 메시지의 실행과 모니터 컴퓨팅 자원을 통제하는 스몰토크 가상 머신의 결과이다.


스펙트럼 다른 끝에는 재래식 컴파일, 링크, 수행 주기(execute cycle)에 의존하는 C++가 존재한다. C++는 런타임 시 스몰토크보다 더 빠르고, 즉시 외부 언어로 연결해주는 장점이 있지만, 개발이 느리고 메모리 버그가 많이 발생하여 프로그래머의 패기만 시험한다.


스몰토크나 C++에서 프로그래밍을 하기 위해 이번 장의 내용을 알아야 할 필요는 없지만 학습해두면 특정 언어와 그 환경이 왜 그렇게 행동하는지 설명하는 데에 도움이 될 것이다.


가상 머신

가상 머신이란 인위적 컴퓨터이다. 실제 머신이 있다면 행동했을 법한 대로 행동한다. 물론 그러한 머신은 존재하지 때문에 사람들은 그러한 머신이 있었으면 하는 바람에서 모방하기 위해 가상 머신을 사용한다. 가상 머신이 흥미로운 이유는 그것이 실행하는 내용 때문이다. 스몰토크 가상 머신의 경우 그 내용을 이미지라고 부른다. 이미지는 자신의 컴퓨터 내 모든 객체들로 구성되는데, 스몰토크가 이미 제공하는 객체와 당신이 생성한 객체가 모두 해당된다.


'모든 것은 객체'에 해당하므로 이미지는 컴파일된 메서드와 같은 일반적이지 않은 객체도 포함한다. 즉, 스몰토크 메서드를 작성하고 컴파일 할 때마다 스몰토크의 컴파일러는 CompiledMethod 클래스의 인스턴스를 생성한다. 이러한 객체 각각은 바이트코드로 구성되는데, 이는 가상 머신이 어떻게 실행되는지 인지하는 기계 명령어(instruction)이다.


따라서 실행 중인 스몰토크 시스템은 두 가지 소프트웨어 조각으로 구성된다-기본이 되는 컴퓨터 하드웨어에 실행 중인 가상 머신과, 가상 머신에서 바이트코드를 실행 중인 이미지가 그것이다. 이는 익숙한 개념이 멋진 이름에 가려진 것 뿐이다. 인터프리터-BASIC 인터프리터 또는 Pascal의 p-code 인터프리터-또한 가상 머신이다. 가상 머신은 스몰토크 이미지에서 바이트코드를 실행하는 대신 다른 언어, 즉 BASIC이나 p-code와 같은 언어들을 실행한다. 종종 사람들은 심지어 스몰토크 가상 머신을 스몰토크 인터프리터로 부르기도 한다.


스몰토크의 가상 머신과 다른 간단한 가상 머신(또는 인터프리터) 간의 차이는 아마 정교함(sophistication)에 있을 것이다. 스몰토크 가상 머신은 단순히 바이트코드를 차례로 실행하는 것 이상이다. 완전한(full-blown) 운영체제와 마찬가지로 프로세싱과 메모리 자원도 관리한다.


그건 그렇고 이러한 배치(arrangement) 또는 가상 머신을 기반으로 한 어떤 배치든 컴퓨터 아키텍처들 간에 애플리케이션을 전송하는 기법이 부수적 효과로 발생한다. 스몰토크가 하나의 컴퓨터 아키텍처에서 실행 중이라면 우리는 가상 머신만 재작성하여 다른 컴퓨터 아키텍처에서 실행되도록 만들 수 있어야 한다. 이미지는 특정 머신에서 다른 머신에서와 마찬가지로 잘 작동해야 한다. 산업에서 스몰토크에 대한 초기 학습 경험은 이러한 방식으로 발생했다. 1980년대 초, Xerox PARC는 Smalltalk-80 이미지와 함께 그것의 가상 머신에 대한 사양까지 발표하여, 컴퓨터 제조업체가 가상 머신만 작성하면 자신의 하드웨어에서 Smalltalk-80을 실행할 수 있도록 하였다. 그 당시 기본적 가상 머신 구현부는 프로그래머 년도로 1년(one programmer-year) 정도 만에 빌드할 수 있었다.


오늘날 상황은 조금 더 복잡해졌는데, 많은 현대 이미지들이 기본이 되는 운영체제나 윈도잉 관리자로만 제한된 객체들을 포함하기 때문이다. 이러한 객체들은 플랫폼의 특정 기능들을 이용하고, 이미지를 단순히 다른 플랫폼으로 이동시키지 못하도록 보장한다. IBM Smalltalk는 모든 플랫폼에 걸쳐 표준이 되는 객체 계층 하에 플랫폼 특정적 객체를 캡슐화함으로써 이 문제를 극복했다. 제 12장에서 살펴보았듯 사용자 인터페이스 객체의 계층은 Motif 표준과 닮았다. 파일-시스템 객체의 계층은 POSIX.1이라 알려진 또 다른 UNIX 표준과 닮았다. 이러한 표준을 충실히 따르는 IBM Smalltalk 코드는 이제 플랫폼별로 이식된다.


현재 VisualWorks로 알려진 Smalltalk-80은 오래된 관습을 가장 잘 유지한다: 가상 머신이 기반이 되는 플랫폼을 모두 캡슐화한다; 이러한 배치는 이미지를 플랫폼으로부터 분리한다. 따라서 Smalltalk-80 이미지는 매우 이식성이 높다. 하지만 플랫폼 특정적 위젯을 감지하지 못하기 때문에 사용자 인터페이스는 그들이 실행되는 플랫폼의 모양과 느낌을 갖고 있지 않다. (ParcPlace-Digitalk는 Smalltalk-80 가상 머신에 결합된 VisualWorks/VisualSmalltalk 제품의 기반이 되는 것을 목적이라고 나타냈다.)


메서드 검색

메서드 디스패치(method dispatch)라고도 알려진 메서드 검색(method lookup)은 객체의 클래스나 슈퍼클래스 내 모든 메서드들 중 실행하기에 올바른 메서드를 선택한다. 스몰토크의 가상 머신은 메시지 핸들러(또는 디스패처)라 알려진 컴포넌트를 이용해 이러한 작업을 실행한다.


메시지 핸들러는 각 메시지가 발생하는 그 순간에 가상 머신이 어떤 메서드를 실행해야 하는지를 결정한다. 재래식으로 컴파일된 언어에서와 달리 이는 컴파일 시 메서드를 결정하지 못한다. 사실 스몰토크는 코드가 메서드를 원할 때, 심지어 실행 직전에도 수정, 추가 또는 제거할 수 있는 동적 시스템이다. 따라서 어떠한 메시지든 잠재적으로 잇따른 메시지가 실행되는 메서드나 클래스의 환경에 영향을 미칠 수 있는 것이다. 재래식 언어들은 이를 실행할 수 없다; 실행 도중에 재컴파일하여 변경내용을 프로그램으로 재연결(relink)하는 것과 유사할 것이다.


이제 마지막 순간의 검색을 처리할 한 가지 제안으로, 아래 그림에서 Point 클래스의 객체와 같이 각 객체에 대한 메서드 포인터들의 dictionary를 저장하는 방법이 있다:


Chapter 16 01.png

<* 키는 메서드 선택자들이다.>

이러한 가상적 dictionary는 조심스럽게 구성해야 한다: 메서드가 슈퍼클래스와 서브클래스에서 모두 발생할 경우 dictionary 엔트리는 서브클래스에서 오버라이드하는 메서드를 가리켜야 한다.


해당 포인트 인스턴스로 메시지를 해결하기 위해서는 매시지 핸들러가 dictionary의 키들 사이에 메시지의 선택자를 위치시키고 관련 메서드를 실행해야 한다. 이 방법은 특수 변수 super로 전송되는 메시지와 관련해 까다로운 문제를 해결하지 않고, 이는 일반 검색을 위반하여 오버라이드된 메서드로 접근할지도 모른다.


또한 이러한 방법은 모든 객체를 불필요하게 팽창시키기도 한다. 포인터만큼 얇은 객체들에게 Point와 그 슈퍼클래스 Object의 모든 메서드들을 책임지게 만든다. 수십 개의 포인터가 해당되며, 애플리케이션에서 가능한 수많은 포인트 인스턴스마다 반복된다. 용납할 수 없는 일이다. 이러한 문제를 해결하기 위해 스몰토크는 아래와 같이 획일화된 방식으로 문제를 조정한다. (아래는 개념적 그림일 뿐이다-실제 세부내용은 스몰토크 dialect마다 다양하다. IBM Smalltalk의 경우, 이 그림의 정신은 그대로 따르지만 다른 철자를 사용한다.)

Chapter 16 02.png


각 인스턴스에는 그 클래스를 향하는 포인터가 하나 있다. 클래스(Point)는 그것(인스턴스)에 대한 메서드들의 dictionary와 함께 그 슈퍼클래스(Object)를 향하는 포인터도 보유하는데, 이 슈퍼클래스는 고유의(인스턴스) 메서드로 된 dictionary를 보유한다. 이 계층구조에 클래스의 계층이 더 있을 경우 패턴은 반복된다: 각 클래스 객체는 고유의 메서드 계층과 그 슈퍼클래스를 향하는 포인터를 가질 것이다. 디스패처(dispatcher)는 그것이 찾는 메서드를 발견할 때까지 dictionaries의 사슬을 쫓을 뿐이다. 모든 dictionaries에서 메서드를 찾을 수 없는 경우, 눈에 익숙한 '이해할 수 없음'이란 walkback이 나타난다. 유연성을 주목하라: 메서드와 dictionaries는 언제든 변경할 수 있다; 디스패처는 그러한 변경이 얼마나 최근에 발생했는지와 상관 없이 현재 dictionaries 사슬을 의무적으로 쫓아간다.


이러한 접근법은 저장공간의 소비를 최소화한다-각 포인트는 고유의 private 인스턴스 변수와 함께 그 클래스를 향하는 포인터 하나를 보유한다. 하지만 그 성능은 수상쩍다-사슬에서 반복적 검색은 참을 수 없을만큼 느릴 것이다. 따라서 스몰토크는 두 가지 취약한 성능 트릭, 캐싱(caching)과 해싱(hashing)에 의존한다. 해싱은 엔트리에서 직접 계산('해시')할 수 있는 마법의 오프셋에서 테이블 내 각 엔트리를 위치시키는 방법이다. 그리고 나면 모든 엔트리는 예측 가능한 오프셋에 위치하므로, 테이블 검색은 엔트리가 위치해야 하는 오프셋을 계산하여 직접 진행할 수 있다. 엔트리가 오프셋에 위치하지 않을 경우 테이블에 없는 것이다. (약간 지나치게 간소화 했다-해싱 알고리즘은 두 개의 다른 엔트리에 대해 계산된 마법의 오프셋이 동일할 때 충돌의 잠재성을 처리하기도 해야 한다.) 스몰토크의 dictionary 객체들은 더 순진한 (naive) 바이너리 또는 순차적 검색 대신 해시된 검색을 위해 설계되었기 때문에 검색 횟수는 우리가 염려하는 최악의 상황과는 무관하다.


사실상 메서드 dictionaries의 검색이 꼭 필요한 것은 아니다. 한 번 발생하는 메시지는 가까운 미래에 재발하는 경향이 있기 때문에 스몰토크는 최근 전송된 메시지의 캐시를 보관하며, 디스패처는 슈퍼클래스 사슬을 검색하는 고역을 치르기 전에 먼저 이 캐시를 살펴본다. 메서드 캐시의 효과는 상당히 크다. [Krasner 1984]에 따르면, 적절한 메서드 캐시는 95%까지의 일치율(hit ratio)를 가질 수 있으며, 메서드 검색 시간을 9분의 1로 감소시키고, 전체 시스템 속도를 37% 향상시킨다.


C++에서 메서드 검색은 어떤 유형의 엔진이나 디스패쳐가 없이도 작동한다. 여전히 각 테이블에 해당하는 가상 함수 테이블 또는 v-table 이라는 테이블에 의존하는 건 마찬가지다. 하지만 이러한 테이블은 스몰토크에서처럼 강력한 검색 기능을 가진 dictionary가 아니라 포인터의 리스트에 불과하다. Piano와 Oboe가 추상 클래스 Instrument의 구체적 서브클래스이고, 모든 악기는 스스로를 (특정 음정과 음높이로) 튜닝하고 (악보를) 연주하도록 메서드를 지원하는 (C++ parlance에서 가상 함수) 음악 합성기 애플리케이션이 있다고 가정하자. Piano에 대한 v-table은 아래와 같은 모습이다:

Chapter 16 03.png


C++ 컴파일러는 피아노를 튜닝하라는 메시지를 만나면 (C++ 표기에 따르면 piano->tune()) v-table에서 첫 번째 포인터가 발견한 함수를 실행하는 코드를 생성한다. piano->play()를 만나면 두 번째 포인터가 발견한 함수를 실행하는 코드를 생성한다. V-table은 안정적이다; 스몰토크에서와 달리 실행 시 메서드를 도입하거나 제거할 가망이 없다. 따라서 이렇게 생성된 코드는 무조건적(unconditional)이다; 컴파일러는 실행될 함수의 모든 위치를 한 번만 결정한다. 디스패처(Dispatcher)가 관여된 적이 없다.


지금까지는 꽤 평범한 테이블에 속한다. 하지만 oboes는 더 흥미로운 삶을 가진다. Oboe 또한 v-table을 가지므로 Piano와 정확히 동일한 구조를 가진다. 즉, 첫 번째 포인터는 oboe에 대한 tune 함수를 가리키고, 두 번째 포인터는 oboe에 대한 play 함수를 가리킨다. 컴파일러는 이와 같은 방식으로 Instrument의 서브클래스에 대한 모든 v-table을 빌드한다.


이제 piano 또는 oboe 둘 중 하나를 가리킬 수 있는 다형 변수 instrument를 생각해보자.

Chapter 16 04.png
알려지지 않은 객체가 실행 시 'instrument' 아래에 숨어 있다 || 컴파일러에 의해 미리 준비된 테이블들


두 개의 play 함수 중 어떤 것이 instrument->play() 형태의 메시지에 대한 응답으로 실행될까? 오른쪽이다. Oboe와 Piano가 Instrument의 서브클래스라는 사실을 아는 C++ 컴파일러는 두 개의 v-table이 평행한 구조를 갖도록 만들었다-함수 포인터는 두 테이블에서 동일한 순서로 위치한다. 컴파일러가 instrument->play()라는 표현식을 만나면 그것은 무조건적으로 테이블 내 두 번째 포인터가 발견한 메서드를 실행하는 코드를 생성한다-코드가 마침내 실행될 때 어떤 종류의 instrument 객체가 가리킬 것인지 예상할 수 없음에도 불구하고. 만일 객체가 piano로 밝혀질 경우, 두 번째 포인터는 piano playing을 가리킨다; 객체가 oboe로 밝혀진다면 두 번째 포인터는 oboe playing을 가리킨다. 어떤 것이든 두 번째 포인터가 올바른 것이며, 적절한 함수가 실행될 것이다.


C++에서 메서드 디스패처에 관한 두 가지 핵심을 정리해보자: 이는 런타임 엔진 없이도 완전하게 작동하고, 실행 코드에 조건문이 없다. 그 외에 모든 것은 동일하며, C++ 프로그램이 스몰토크 프로그램보다 더 빠르게 실행될 것이라 기대해도 좋다. 하지만 실생활에서 이런 경우는 드문데, 성능은 절대로 이렇게 간단한 문제가 아니기 때문이다.


한 가지 예를 들자면, 필자에게 두 명의 친구-하나는 C++를 몹시 좋아하고 하나는 스몰토크를 선호한다-가 있는데 두 사람이 속도에 관한 논쟁을 하다가 메서드 디스패처의 루프를 벤치마킹하여 논쟁을 끝낸 적이 있다. C++ 를 선호하는 친구에겐 유감이지만 스몰토크 코드도 그만큼 빨랐다. 그 이유는 무엇일까? 그들은 v-table들이 객체들과 다른 메모리 세그먼트에 위치하는 16 비트 운영체제를 사용하고 있었던 것이다. 그 결과, 모든 함수 호출마다 세그멘테이션 결함이 발생하여 오버헤드가 v-table 방식에 나타난 속도의 장점을 뒤덮은 것이다. 드문 예이긴 하지만 중요한 성능 원리, 즉 빈틈이 없는 프로그래밍이 대개 프로그래밍 언어보다 더 중요하다는 사실을 보여준다.


성능은 제쳐두고, 우리가 논의한 메서드 검색 구현부의 또 다른 부산물을 소개하겠다. 스몰토크가 실행되는 동안 고유의 구문을 검사하고 근본적으로 변경하는 기능인 반영(reflection)은 C++의 재래식 컴파일, 링크, 실행의 세계에선 불가능하다. 게다가 스몰토크 메서드의 순간적 (재)컴파일-스몰토크의 탐색적 개발 형태-이 가능한데, 런타임 엔진이 메서드의 호출을 컴파일로부터 분리시키기 때문이다. 스몰토크 프로그래머는 재량껏 메시지를 컴파일하면서, 메시지가 호출되기 직전까지는 그에 응답할 메서드가 존재하는지 염려하지 않아도 된다.


반면 C++ 메서드를 추가하거나 제거할 경우, 그들의 평행 구조를 유지하기 위해 잠재적으로 수많은 v-table의 재컴파일을 요구한다. 다형적 클라이언트 코드는 재작성될 필요가 없다는 사실을 알고 있지만 테이블 내에서 포인터에 대한 새로운 오프셋을 설명하기 위해서는 여전히 재컴파일되어야 한다. 이러한 재컴파일 오버헤드는 탐색적 프로그래밍을 어렵게 만든다. 반면 C++는 외부 언어와 즉시 협력하지만, 스몰토크의 런타임 엔진은 스몰토크 이미지로 들어가고 그로부터 나오는 호출에 방해된다.


메모리 관리: 쓰레기 수집(garbage collection)의 간략한 소개

스몰토크 프로그래머는 자신의 객체에 대한 저장소를 관리하지 않는다. 특히 객체와의 작업이 끝나면 객체가 차지하는 메모리의 회수에 관해서는 걱정하지 않는다. 대신 쓰레기 수집기(garbage collector)라고 알려진 가상 머신의 컴포넌트가 메모리를 감시하고, 객체가 차지한 메모리를 안전하게 회수할 수 있는 시기를 알아낸다.


다음 질문을 고려해보자: 자신의 스몰토크 시스템에는 얼마나 많은 포인트 객체가 존재하는가? 그에 대한 해답은 쉬운데, 아래를 표시하여 셀 수 있다:

Point allInstances size


이 시점에 필자의 시스템에는 13개가 있다. 새 포인트를 재할당하고:

X := Point new


다시 세면 이제 하나가 늘어 14가 된다. 이제 실행하면:

X := 'Casablanca'


전역 변수 X는 포인터 대신 문자열을 가리킨다. 포인터는 유효하지 않고-한 때 변수 X를 통해 접근 가능했으나 이제는 완전히 접근 불가능하다-그에 따라 쓰레기 수집 대상이 된다. 다시 수를 세보면 스몰토크는 본래 포인트 수였던 13을 보고한다. 쓰레기 수집기는 가장 최근 포인트가 차지한 메모리를 회수하였다. 이벤트는 아래와 같이 진행된다:

Chapter 16 05.png


스몰토크에서 쓰레기 수집은, 프로그램이 실행되는 동안 메모리 청크(memory chunk)를 얻게끔 허용하는 C++를 비롯한 많은 컴파일된 언어의 메모리 관리 방식과 분명하게 상반된다. C++에는 쓰레기 수집기가 없기 때문에 프로그래머가 적절한 순간에 객체의 저장소를 조심스럽게, 그리고 명시적으로 회수해야 한다. 이러한 책임을 프로그래머에게 부여하는 데 발생하는 위험은 잘 알려져 있다: 객체의 메모리를 너무 빨리 비우면 다른 객체를 공간으로 초대하여 원본 객체의 데이터를 변질시킨다. 메모리를 너무 늦게 비우면 추가 메모리 할당을 위한 공간이 없는 위험으로 프로그램을 노출시킨다. 이는 프로그램에서 가장 악몽 같은 버그에 해당한다; 이벤트 이후에 발생하는 파국적인 충돌로 발현하여 디버깅을 상당히 복잡하게 만들곤 한다.


반면, 쓰레기 수집기는 연속적으로 실행되는 프로그램으로서 CPU 사이클을 소비하고, 프로그래머의 통제에서 벗어나 특히 부적절한 순간에 활성화될 수 있다. 비평가들은 비행기의 제어 시스템이나 핵 발전소의 모니터링 시스템에서 쓰레기 수집기가 최고속도(high gear)로 전환하기로 결정할 경우 시스템이 일시 정지(pause)할 여규가 없음을 관찰했다. 이에 관한 일반적인 논쟁은 다음과 같이 진행된다:

  • 찬성: 쓰레기 수집기만이 메모리 디자인에서 미묘하고 불가피한 오류로 인해 발생하는 애플리케이션 충돌로부터 보호할 수 있다.
  • 프로그래머만이 고성능 애플리케이션을 위한 메모리 관리를 미세하게 조정할 수 있다. 특히 이따금씩 발생하는 쓰레기 수집기의 강화현상은 실시간 소프트웨어에 허용할 수 없게 만든다.


다음으로, 쓰레기 수집기가 어떻게 작동하는지 살펴봄으로써 객체의 생과 소멸에 대한 통찰력을 얻을 것이다. 이 내용은 쓰레기 수집이 가져오는 이익에 관한 (끝없는) 논쟁에서 자신의 입장이 무엇이든 상관없이 매우 유용한 연구이다.


1980년대 중반 이전에 대부분의 초기 쓰레기 수집기는 표시하고 정리하기(mark-and-sweep) 또는 참조계수(reference-counting) 수집기의 변형체(variant)였다. 표시하고 정리하는 수집기는 메모리에 두 개의 경로를 만든다. 첫 번째 길은 가상 머신이 자신이 필요로 한다는 사실을 알고 있는 하나 또는 그 이상의 anchor 객체에서 시작된다 (예: 스몰토크에서 모든 실행 메서드와 그것이 참조하는 객체를 포함하는 활성화 스택).

Chapter 16 06.png
표시 경로 이전 || 표시 경로 이후 || 경로 정리 이후


Anchor 객체는 '표시'되어 있고-약간 진하게 표시되었다-anchor가 참조하는 모든 객체들도 표시되었고, 이 객체들이 참조하는 모든 객체들도 표시되었으며, 더 이상 어떤 객체도 접근할 수 없을 때까지 재귀적으로 계속된다고 치자. 표시 경로의 끝에 표시되지 않은 채 남은 객체들은 객체들은 접근이 불가능하며, 그에 따라 회수가 가능한 쓰레기 객체가 된다. 두 번째 경로는 모든 객체를 정리하면서 표시되지 않은 객체로부터 공간을 회수하고 다른 모든 객체에 표시 비트(mark bit)를 끈 후 쓰레기 수집을 준비시킨다.


이러한 설계에서 중요한 단점으로는 혼잡성을 들 수 있다-수집기가 활동을 시작하지 않는 경우가 있는데, 혹 활동을 시작할 경우 나머지 시스템 부분들은 두 개의 완전한 경로가 메모리를 통과하는 동안 freeze한 상태로 유지된다. 사용자는 아무 일도 발생하지 않는 것처럼 보이는 정지(pause)상태를 오랜 시간동안 경험한다.


참조계수는 이만큼 혼잡스럽지 않다; 참조계수 수집기는 포인터가 설정되거나 리셋될 때마다 특정 장부를 기록한다. 수집기는 각 객체에 대한 참조의 계수를 유지한다. 계수가 0으로 떨어지면 그 객체는 접근이 불가하며, 수집기는 즉시 그 저장소를 회수한다:

Chapter 16 07.png


참조 계수기는 표시하고 정리하는 수집기에서 거슬리는 정지상태를 제거한다. 반대로 참조 계수기는 절대 멈추지 않으므로 계수를 유지하는 오버헤드는 계속해서 성능을 떨어뜨린다. 참조 계수기는 dead cycle을 간과하기도 한다. 예를 들어, 한 쌍의 객체가 서로를 참조하면서 나머지 시스템 부분으로부터 고립될 수도 있는 것이다; 그들의 계수는 각각 1이 될 것이며, 실제로 쓰레기에 해당하지만 회수 불가하다. 따라서 참조계수 자체는 모든 쓰레기를 올바르게 수집하기에 불충분하다. 그럼에도 불구하고 초기 스몰토크들은 참조계수 방식을 사용했는데, 그 구현이 쉽고 정지상태를 제거하기 때문이다.


오래 사용해온 동시 유명한 또 다른 쓰레기 수집기로 베이커 수집기(Baker collector)[Baker 1978]가 있는데, 이는 앞에서 소개한 두 가지 방식의 단점을 강조한다. Dead cycle에 대한 참조계수의 민감성이나, 표시하고 정리하기 방식의 메모리를 통한 두 가지 경로에서 발생하는 단점을 경험하지 않아도 된다. 베이커 수집기는 메모리를 두 개로 나누는데, 이는 반공간(semi-spaces)로 알려진다. 모든 새 객체들은 반공간 중 지정된 활성화 공간으로 할당된다. 수집기가 때때로 공간을 뒤집으면 다른 공간이 활성화된다. 하지만 추가 객체 할당에 새로 활성화된 공간을 개방하기 전에 기존 반공간을 통하는 하나의 경로를 만드는데, 표시하고 정리하기 수집기처럼 anchor부터 시작한다. 그리고 경로 표시와 마찬가지로 모든 live 객체에 재귀적으로 도달한다. 하지만 표시하는 대신 각 객체를 새로운 반공간으로 이동시킴으로써 즉시 대피시킨다. 객체의 본래 위치에는 다른 객체들이 전달되는 도중에 찾게 되는 경우를 대비하여 새로운 위치에 대한 새주소와 묘비(tombstone)가 남는다. 단일 경로가 끝날 때 모든 live 객체는 새로 활성화된 공간으로 대피한 상태다; 기존 반공간에 남은 것들은 쓰레기로, 추가 처리를 요하지 않는다.

Chapter 16 08.png
활성화 공간 || live 객체만 이동 || 활성화 공간


적당한 시기에 반대 방향으로 다시 뒤집히는 현상이 발생한다. 그 결과 뒤집힐 때마다 완전히 깨끗한 상태의 반공간을 즐길 수 있으며, 이 곳으로 객체들이 이동되고 할당된다는 사실을 주목하라. 이러한 청결도의 장점을 잠시 설명하겠다.


베이커 수집기는 정교한 스타일을 가진다: 조금만 신경 쓰면 뒤집힘(flip)을 점진적(incrementally)으로 진행할 수 있다. 즉, 뒤집힘이 끝나기 전에 새 객체 할당이 시작될 수 있다는 말이다; 수집기는 새로 활성화된 공간으로 이러한 할당을 하면서도 기존 공간으로부터 live 객체들을 계속 대피시킬 수 있다는 말이다. 한 가지 위험은, 새로 할당된 객체들 중 하나가 기존의 활성화 공간 내 (아직 대피하지 않은) 객체를 가리킬 수 있다는 점이다; 그러한 포인터는 뒤집힘이 끝날 때 올바르지 않은 포인터가 될 것이다. 이러한 상황으로부터 보호하기 위해 수집기는 대피하지 않은 객체를 즉시, 예정보다 일찍 이동시켜야 한다. 이렇게 Scavenging(쓰레기 주워 모으기)으로 알려진 조치를 취하고 나면 베이커 수집기는 참조계수기와 같이 완전히 점진적이면서 표시하고 정리하는 수집기의 혼잡성을 극복할 수 있다.


표시하고 정리하는 수집기와 공유하는 한 가지 바람직하지 못한 프로퍼티가 여전히 남아 있다-베이커 수집기는 live 객체를 계속 반복해서 처리한다는 점이다. 스몰토크에서 live 객체의 수가 많다는 사실은-작은 스몰토크 시스템에서는 15,000개부터 시작해 필자가 현재 사용 중인 이미지에서는 278,000개가 넘는-상당한 부담이 된다. 특별히 오래가는 객체를 간과하는 방법이 있어서 반복적 재처리의 오버헤드를 피하도록 해준다면 얼마나 좋을까? 이러한 관찰은 1980년대 후반에 스몰토크 쓰레기 수집에 사실상 표준이 되어버린 generation scavenging라는 개념으로 이어진다.


Generation scavengers는 대부분의 객체들이 재빠르게 소멸한다는 실증적 관찰에 의존한다. 새 객체-pointers, arrays, sets, rectangles, blocks...-들은 실행되는 스몰토크 시스템에서 터무니없게 생성되어 짧게 사용된 후 폐기된다. 쓰레기 수집기는 이러한 객체들을 처리해야 하지만, 우리는 그것이 오히려 더 오래 지속되어 온 수만 또는 수십 만에 달하는 객체들을 가능한 한 많이 무시하길 원한다.


Chapter 16 a.png

Scavenger는 베이커 수집기와 같이 시작하지만 객체를 뒤집을 때마다 객체의 세대 계수(generation count)도 증가시킨다. 뒤집힘을 n 의 수만큼 살아 남은 객체들은 n 세대 만큼 늙은 것이다. Scavenger는 어떤 기준 연령, 가령 3세대만큼 살아 남은 객체들은 tenured 영역이라 불리는 특권 구역으로 이동하기에 충분히 취약한 것으로 간주한다.


tenured되고 나면 객체들은 더 이상 반복적 쓰레기 처리의 대상에서 벗어난다. 따라서 짧게 생존한 객체들은 베이커의 뒤집힘(Baker flip)에 의해 효율적으로 수집되고, 오래 생존한 객체들은 결국 조용히 남겨두는 tenured 영역로 승진된다. 기본적인 scavenger 디자인을 달리할 기회는 분명히 무수히 많다-tenured 영역으로 보내기까지 얼마나 많은 세대를 허용할 것인지 (오리지널 VAX 구현부에선 63세대), 객체가 소멸되고 나면 tenured된 객체를 어떻게 처리할 것인지, tenured 영역으로 간 객체들을 좀 더 안전하게 tenured된 영역으로 보낼 것인지 여부와 적절한 시기는 언제인지 등등. 오래된 기법에서는 9~20%의 CPU 시간을 소모하는 데 비해 훌륭한 generation scavenger는 3%만 소모한다. Scavenging 방식의 예제는 [Lieberman and Hewitt 1983; Krasner 1984; Samples et al. 1986; Ungar and Jackson 1988]을 참고하라. 특히 [Krasner 1984]는 초기 VAX Smalltalk-80 구현부터 좀 더 복잡한 쓰레기 수집기까지-처음엔 표시하고 정리하기, 다음으로 베이커 수집기, 마지막으로 generation scavenger-역사를 설명한다.


쓰레기 수집의 아이러니

쓰레기 수집기에 대한 눈에 띄는 역설은 프로세싱 사이클의 소모와 관련된 그들의 명성이 절반은 틀리다는 점이다. 그들 중 일부는 실제로 사이클을 절약한다. 재미있게도 이러한 절약이 객체의 정리와는 상관이 없다; 절약은 메모리가 처음 할당될 때 객체의 생 정반대 끝에서 발생한다. 절약은 앞서 언급한 깨끗한 공간과 상관이 있다.


베이커 수집기와 generation scavenger와 같은 방식의 부작용은 객체 할당이 비어 있는 하나의 인접한 메모리 청크에서 발생한다는 점이다. 메모리에는 계산하거나 공간을 차지하는 구멍(hole)이나 간격(gap)이 없다. 이러한 '단편화(fragmentation)'의 결여는 할당을 상당히 단순화시킨다; 새 객체를 위한 공간을 찾는 것은 빈 메모리의 시작 위치를 리턴시키고 객체 크기에 따라 이 시작을 앞당기는 문제인 것이다. 새 객체는 메모리에서 마지막 객체 직후에 들어가면 된다.


반면, C, Pascal 또는 C++에서 발견되는 더미(heap)는 구멍이나 간격으로 가득하다. 메모리 할당 방식은 구멍에 대한 하나 또는 그 이상의 사슬에 의존해야 한다. 각 객체 할당은 이 사슬에서 적당한 크기의 공간을 검색한 다음, 사슬이 새 객체로 양도된 공간을 반영하도록 조정하는 작업을 수반한다. 이러한 사슬의 검색과 관리에는 시간이 많이 소요된다는 사실은 왜 단편화가 효율적인 객체 할당에서 장애물이 되는지, 반대로 왜 깨끗한 공간이 빠른 객체 할당을 가능하게 하는지도 설명한다.


따라서 객체의 생(life)의 끝을 걱정해야 한다고 취지를 가진 쓰레기 수집이 사실은 그 생을 더 신속히 처리할 수도 있다. 그 효과는 주목할 만하다: C++ 애플리케이션의 객체에 쓰레기 수집 방식을 추가 시, 애플리케이션의 성능을 향상시키기도 한다. 성능에 미치는 영향이 무엇이든, 쓰레기 수집기의 중요한 혜택은 마찬가지다; 저장소 회수를 자동화시킴으로써 메모리 디자인 오류의 발생을 감소시키는 것이다.


논평: 왜 C++에서는 쓰레기 수집이 안 되는가?

쓰레기 수집이 그토록 유용하다면 모든 언어에서 제공하면 되지 않을까? 쓰레기 수집이 스몰토크에서 가능한 이유는 모든 객체가 가상 머신에 알려진 획일화된 구조를 갖기 때문이다. 모든 것은 객체이며 모든 객체의 메모리 레이아웃은 객체의 클래스를 가리키는 포인터, 일부 플래그, 객체의 크기로 구성된 3개 단어로 된 표준 헤더로 시작된다. (각 단어는 4 바이트이다.) 세 단어 다음에 객체의 인스턴스 변수들이 오는데 인스턴스 변수마다 하나의 단어가 해당한다.


이러한 규칙성을 이해하는 메모리 관리자(예: 쓰레기 수집기)는 객체가 어디서 시작되고 끝나는지, 무엇보다 중요한 것은 그것이 가리키는 객체들이 어디에 있는지를 알아낼 수 있다. 어떤 쓰레기 수집 방식이든 이 정보를 필요로 한다. 불행히도 C++와 같은 언어에서 객체의 내용은 임의적이다. 객체의 메모리 내부에는 정보가 뒤섞여-데이터, 포인터, 심지어 그 내부에 직접 포함된 다른 객체들-있다. 메모리 관리자는 모든 포인터가 어디에 위치하는지 알지 못하므로 그것이 향하는 객체들도 찾을 수 없다. 이러한 정보는 프로그램의 구문에 포함되어 있는데, 가설적 가상 머신으로는 접근이 불가하다. 따라서 쓰레기 수집기가 있는 모든 객체 지향 언어들-Smalltalk, Eiffel, CLOS, Java...-은 획일화된 객체 구조를 채택한다는 사실을 알게 될 것이다. C++은 최대한으로 유연성을 띄는 반면 이러한 획일성이 부족하다; 따라서 임의적 객체의 쓰레기 수집을 피할 수가 없다.


C++에서 쓰레기 수집에 대한 가능성과 그 바람직성에 관한 최종적 논의는 [Stroustrup 1994]를 참고하라.


스몰토크가 획일성에서 벗어난다

획일성만큼은 그토록 오래 유지하는 스몰토크조차 그 한계가 있기 마련이다. 예를 들어, 스몰토크에서 작성된 메서드는 다른 스몰토크 메서드를 호출한다. 무기한으로 계속되는 이러한 순환호출(recursion)은 손쓸 수 없을 정도로 계속 순환된다. 일부 스몰토크 메서드는 스몰토크 이외의 것으로 표현되어야 한다. 이러한 메서드를 primitives라 부른다. 이들은 주로 C+와 같은 언어로 작성되고 컴파일되며 '동적 링크 라이브러리'(Windows 또는 OS/2)로 패키지되는데, 이 라이브러리는 스몰토크 메서드에서도 호출할 수도 있다. 해당 예로 정수 곱셈을 들 수 있다. SmallInteger>>* 메서드는 아래와 같다:

* aNumber
  "Answer a ..."
  <primitive: VMprSmallIntegerMultiply>
  "... more code ..."


스몰토크 같지 않은 <primitive: ...> 표현식은 스몰토크 외부의 특수 함수에 대한 호출이다. (primitive 다음 코드는 primitive 호출이 실패할 경우에만 실행된다.)


Primitives는 곱셈과 같은 저수준 메서드로 제한될 필요가 없다. Primitive는 스몰토크에서 외부 언어를 호출하는 근본적인 방식이기 때문에 스몰토크가 지원하지 않는 함수로 접근하거나 느린 스몰토크 메서드를 다른 언어에서 코딩된 빠른 버전으로 대체할 때 사용할 수 있다. Primitives는 스몰토크가 데이터베이스 관리자 혹은 통신 프로그램과 같은 외부 서비스로 호출을 전송하는 수단이다.


스몰토크가 획일적이지 않은 또 다른 영역은 그것의 저장소(storage) 모델이다. 대부분의 객체들은 앞절에서 필자가 설명한 표준 구조를 준수한다; 즉 3개의 헤더 단어 다음에 객체의 인스턴스 변수가 따라온다. 하지만 작은 정수와 같은 객체들-32-비트 (또는 일부 구현부에서는 16 비트의) 워드에서 남는 공간에 들어 맞는-이 생성되어 스몰토크에서 너무 흔하게 사용되는 바람에 표준 구조가 극도로 비효율적으로 변한다. 따라서 스몰토크는 그러한 객체에 대한 헤더를 없애고 전체 객체를 하나의 워드에 저장한다. 정수와 다른 작은 객체를 대상으로 한 이런 특별한 취급 방법은 시간과 공간을 모두 절약한다. 하지만 가상 머신에 이상행위(irregularity)를 야기하기도 한다.


스몰토크는 더 나아가 작은 정수까지 특별하게 취급한다. 다른 포맷에 저장될 뿐만 아니라 '인라인(in-line)'으로 정렬된다. 다시 말하자면 다음과 같다: 일반적으로 당신은 X :=someWhale과 Y :=149와 같은 지정어가 각각 whale과 integer 객체를 향하는 포인터를 구축할 것으로 기대한다. 사실 메모리에 X로 명시된 워드는 whale 객체를 향한 포인터를 포함한다. 하지만 Y로 명시된 워드는 149 를 가리키는 포인터가 아니라 작은 정수 149 자체를 포함한다. 따라서 이미지 내부의 메모리 레이아웃은 다음과 같은 모습일 것이다:

Chapter 16 09.png


포인터를 통하지 않고 작은 정수로 직접 접근하는 것은 가상 머신에 훨씬 더 효율적이다. 하지만 가상 머신은 워드가 객체(X)를 향한 포인터인지 아니면 객체 자체(Y)인지 어떻게 알 수 있을까? 이는 가령 워드에서 첫 번째 비트와 같이 플래그 비트(flag bit)를 이용하여 차이를 인지한다. 비트가 켜져 있으면 워드는 작은 정수를 표시한다. 그것이 아니라면 전형적인(conventional) 객체를 향하는 포인터를 표시하는 것이다. (사실 다른 여러 개의 특별한 작은 정수도 특정 비트 패턴으로 구별된다: 문자, true와 false 객체, 그리고 nil 객체가 있다. 이를 통틀어 근접한 (immediate) 객체라고 한다.)


이러한 저장소의 규칙은 스몰토크 수준에서는 절대 발견할 수 없을 것이다. 이는 가상 머신에 의한 사용에만 엄격히 적용되는 제한적(private) 규칙이다. 스몰토크는 계속해서 개발자로 하여금 '모든 것은 객체이고, 모든 객체는 동일하게 취급된다,'고 믿도록 속인다.


경제성의 주제를 다루는 동안, 각 character 객체를 각 하나의 워드로 패킹하는 것조차 많은 공간, 즉 4개마다 3바이트가 낭비된다는 사실을 인지하라. 이러한 낭비가 발생하지 않는 경우보다 발생하는 경우가 더 많은데, 특히 String 객체에서와 같이 함께 발생 시 더하다. 문자열 역시 스몰토크 가상 머신은 효율성을 지키기 위해 획일성에서 벗어난다. 문자열에서 문자는 string 객체 바로 안에서 연속 바이트로 발생하는데, 'juice' 문자열에 대한 대안적 메모리 레이아웃에서 보는 바와 같다:

Chapter 16 10.png


스몰토크는 클래스의 인스턴스를 저장하기 위한 다른 특수화된 포맷도 제공한다. 대부분의 객체들은 세 개의 헤더 단어 다음에 인스턴스 변수마다 하나의 워드가 따라오는 구조를 표준으로 사용한다. 하지만 당신이 클래스를 정의할 때 다른 포맷을 명시하는 방법도 있다-다음으로 소개할 연습에서 살펴보겠다.


연습

클래스 검사하기

❏ 스몰토크 브라우저를 이용해 어떻게 클래스의 슈퍼클래스를 찾는지 알고 있을 것이다. 인스펙터(들)을 이용해 Date의 슈퍼클래스를 찾아보라.

❏ 인스펙터를 이용해 Date 클래스에서 dayOfYear 메서드에 대한 바이트코드-132, 36 등과 같은 숫자-를 찾아라. Date 클래스의 검사로 시작하라; IBM Smalltalk에서는 인스턴스 변수 methodDictionary보단 methodsArray를 통해 검사하라.


객체 메모리 레이아웃

❏ 객체의 메모리 레이아웃은 전적으로 가상 머신의 일이지만 스몰토크 내에서는 일부 클래스의 인스턴스가 다른 인스턴스와 달리 취급되는 신호를 볼 수 있다. 예를 들어, 데이터를 연속 바이트 크기의 객체에 저장하는 String과 같은 클래스들은 isBytes 메시지에 true로 응답한다. 그러한 클래스가 얼마나 많이 존재하는지 확인하려면 아래 표현식을 검사하라:

Object allSubclasses select: [:sc| sc isBytes].


❏ 스몰토크는 클래스의 객체들을 일반적인 방식으로 배치해야 하는지 아니면 문자열이 사용하는 압축된 형태로 배치해야 하는지를 어떻게 아는가? 이 질문에 답하려면 Rectangle과 같은 일반 클래스의 정의를 살펴보고 String의 정의와 비교하라. 일반 클래스들이 오랜 대기(old standby)에 의해 생성됨을 확인하라:

subclass:instanceVariableNames:classVariableNames:poolDictionaries:


String 클래스는 어떤 메서드를 이용하는가? 이 메서드는 문자열에 압축된 메모리 레이아웃을 사용해야 함을 가상 머신에게 알려주는 메서드다.


❏ 스몰토크는 객체를 배치하는 데에 얼마나 많은 규칙을 갖고 있는가? 힌트: 위의 클래스 정의 메시지를 구현하는 클래스를 찾고 유사한 메서드를 검색하라.


카운팅 인스턴스

❏ 자신의 스몰토크 이미지 안에 있는 클래스 개수를 추측하기 위해서는 아래를 표시하라:

^Object allSubclasses size.


제 20장에서 메타클래스를 논할 때 이해하겠지만, 이러한 추측은 실제 개수보다 2 배 정도 높다.


❏ 스몰토크 이미지 내 객체의 개수는 아래를 표시하여 셀 수 있다:

|count|
count := 0.
Object allSubclasses do: [:c1 | count := count + cl allInstances size].
^count


하지만 몇 시간이 소요될 수 있으니 밤사이에 실험을 해보라. allInstances 메서드가 완전한(full-blown) 쓰레기 수집을 호출하는데 우리는 모든 클래스마다 한 번씩 호출하는 셈이기 때문이다[1].


스몰토크의 컴파일러에서 발견되는 비획일성

모든 메시지들이 똑같은 것은 아니다. 즉, 스몰토크의 컴파일러는 몇 가지 특수 메시지를 인식하고 그에 최적화된 코드를 생성한다. 당신은 이러한 메서드를 원하는 대로 재작성하여 재컴파일할 수도 있지만 무엇을 작성하든 당신의 코드는 무시될 것이다.


❏ 이 궁금증을 해소하려면 selfhalt를 그 body로 삽입한 다음에 True 메서드에 ifTrue: 메서드를 재컴파일하라. 그 다음 아래를 표시하라:

7 = 7
    ifTrue: [ 'Breezing through the halt' ].


ifTrue:ifFalse:, ifFalse: 등과 같은 다른 공통 부울식 메시지에도 같은 방식이 적용된다. 컴파일러가 이러한 방식으로 가로막는 또 다른 주목할 만한 메시지로 ==를 들 수 있겠다.


기술적 면: 컴파일러의 최적화를 피하고 자신이 재작성한 메서드를 강제로 실행시키는 유일한 방법은 간적접인 호출이다. 일반적인 기법은 아래와 같이 perform: 메서드의 변형(variants)을 사용하는 것이다:

7 = 7 perform: #ifTrue: with: [ 'Hit my halt' ]



요약

객체 지향 언어에서 구현과 관련된 모든 선택은 상충되기 마련이다. 메서드 전송이 빠르면 컴파일이 느려질 것이다. 쓰레기 수집기는 버그의 수와 심각성은 줄여주겠지만 언어 내 일관성을 감소시킨다. 이미지가 이식 가능해지면 플랫폼의 네이티브 위젯을 즐길 수는 없을 것이다. 가상 머신은 상호작용 디버깅을 순식간에 해결하지만 외부 언어로 삶을 복잡하게 만든다.


언어의 정의부와 그 기반(underpinning) 간 상호 작용은 프로그래밍 시스템의 전체 구조를 형성한다-그 응답성, 그 반영성, 다른 언어나 시스템으로 결합 정도, 심지어 그 안에서 가장 적절한 디자인 기법들까지. 스몰토크-모든 선택을 하나로 결합한 결과물-는 객체 지향 언어의 공간에서 훌륭한 위치에 있다.


이제 객체 지향 언어에 대한 직감에서 실습하는 객체 지향 디자이너들을 위한 개념적 문제로 넘어가보자.


Notes

  1. IBM Smalltalk에서는 garbage collection을 호출하지 않는 basicAllInstances를 이용해 더 빠르지만 정확성은 덜한 count를 얻을 수 있다. 이 count는 처음에 System globalGarbageCollect 메시지를 이용해 한 번 clear함으로써 sharpen할 수 있다. 하지만 잠시 기다릴 준비를 하도록 한다.