개발자가 놓치기 쉬운 닷넷의 기본 원리 #2: 닷넷은 Pointer 환경이다? (닷넷에는 Pointer밖에 없다?)

이전 글에서 이어집니다. 🙂


2. 닷넷은 Pointer 환경이다? (닷넷에는 Pointer밖에 없다?)


2.1. 닷넷은 값 형식을 제외하곤 모두 Pointer이다.


“닷넷에는 포인터가 없다” – 또는 – “일부 언어에서만 호환성을 유지하기 위하여 포인터를 남겨놓았을 뿐 포인터는 자바와 마찬가지로 사용하지 않는다”라고 닷넷의 장점이라고 생각하는 것은 입문자도 외우고 있다. 하지만 이 부분은 의외로 닷넷을 혼란스럽게 하는 주범이라고 생각한다. 닷넷에 포인터가 없기는 커녕 값 형식 (Primitive (int, short, char, long, …), 구조체, 나열 상수 (enum))을 제외하면 “포인터 밖에 없는 환경이다”라는 명제가 성립되게 된다. 사실 여기서 포인터라고 함은 C의 그것과는 조금 다른 reference(참조)로 보는 것이 온당하지만…


“즉, 닷넷의 클래스형의 변수는 모두 포인터이다.”


덧. System.IntPtr, System.UIntPtr은 정수형 포인터를 가리키는 형식으로 자바가 정확하게 지원해주지 않는 C 언어의 포인터를 닷넷 환경에서는 이들 형식을 이용하여 다룰 수 있다. 그러나 이 글에서 이야기하는 개념과는 거리가 먼 상호 운용성에 관한 토픽이므로 큰 관련성이 없다.


2.2 null은 객체인가?


닷넷에서 공참조 (힙에 실제로 참조되는 object가 없는 참조)의 경우는 당연히 객체가 붙어있지 않다. 이러한 상태에서 다음의 MSIL 명령어를 호출하는 동작을 수행할 경우 NullReferenceException이 발생한다고 닷넷 SDK 문서 상에는 기술되어있다.



  • callvirt : 메서드 호출 (호출 대상이 null 참조일 경우 발생)
  • cpblk : 메모리 영역 복사 (잘못된 주소 발견시 발생)
  • cpobj : 값 형식 객체 복사 (잘못된 주소 발견시 발생)
  • initblk : 메모리 영역 초기화 (잘못된 주소 발견시 발생)
  • ldelem.<type> : 지정된 배열 인덱스의 항목을 가져오기 (배열이 null 참조일 경우 발생)
  • ldelema : 지정된 배열 인덱스의 항목의 개체를 가져오기 (배열이 null 참조일 경우 발생)
  • ldfld : 필드 값 찾기 (개체가 null 참조이고 정적 필드가 아닐 경우 발생)
  • ldflda : 필드 주소 찾기 (개체가 null 참조이고 정적 필드가 아닐 경우 발생)
  • ldind.<type> : (잘못된 주소 발견시 발생)
  • ldlen : 배열 길이 조회 (배열이 null 참조일 경우 발생)
  • stelem.<type> : 배열 요소 가져오기 (배열이 null 참조일 경우 발생)
  • stfld : 개체 참조/포인터의 값 교체 (개체가 null 참조이고 정적 필드가 아닐 경우 발생)
  • stind.<type> : 주어진 주소에 값 쓰기 (형식 불일치 발생시 발생)
  • throw : 예외 발생 명령어 (예외 발생 대상 개체가 null일 경우 발생)
  • unbox : 참조 형식 내의 값 형식 개체를 가져올 때 (참조 형식이 null일 경우 발생)

위에서 강조 표시한 항목들은 실제 C#, VB.NET 환경에서 만날 수 있는 사항들과 관련이 있는 것을 열거한 것이다. 여기서 논점이 되는 것은 null을 개체로 볼것인가 아닌가에 대한 문제이다.


공참조는 어떤 객체도 참조하고 있지 않는다고 단정하고 있다. 하지만 ‘==’ 연산에 있어 두개의 객체가 모두 null이거나 동일한 객체 또는 배열 참조의 경우 true라고 되어있는것으로 봐서 서로 다른 두 객체가 동일한 null을 참조하고 있으므로 true가 된것이 아닌가 하는 생각을 할 수 있다.


즉, null이 Object의 instance 형태는 아니지만 개념적으로 봤을 때 null도 object라고 봐야 하지 않을까?


덧. C# 2.0부터 새롭게 소개된 Nullable 형식에서 사용하는 null 키워드는 그 의미가 조금 다르다. 앞서 값 형식은 참조 형식과는 달리 값 그 자체를 취급한다고 하였는데, 데이터베이스 환경에서는 이런 설정과는 또 다르게 값 형식에도 null 상태를 허용하고 있다. 이런 불일치성을 해결하기 위하여 등장한 것이 Nullable 형식인데, 특별히 이 형식에 대해서는 Nullable 형식이 클래스 형식이 아님에도 불구하고 null을 값을 지정하지 않고 Nullable 개체를 새로 만든 것으로 대체하여 처리한다.


int? aa = null;
System.Nullable<int> aa = null;
int? aa = new System.Nullable<int>();
System.Nullable<int> aa = new System.Nullable<int>();
// 값이 지정되지 않은 int 형식을 만들기 위하여 위의 네 구문은 모두 같습니다.

int? bb = 3;
System.Nullable<int> bb = 3;
int? bb = new System.Nullable<int>(3);
System.Nullable<int> bb = new System.Nullable<int>(3);
// 값이 지정된 int 형식을 만들기 위하여 위의 네 구문은 모두 같다.

bool b = (aa == null);
bool b = aa.HasValue;
// 값이 지정되었는지 아닌지를 판단하기 위하여 위의 두 구문은 모두 같다.

object o = (aa.HasValue ? aa.Value : null);
object o = aa ?? null;
// 값이 있는지 없는지에 따라 선택적으로 값을 가져오게 하기 위한 방안으로 위의 구문은 모두 같다.

2.3 String에 대하여


String Object에 대한 생각.


string str = “111222”;
string a = “111”;
string b = “222”;
string c = “111”;
string d = b;
string t = str.Substring(0, 3); // 111
string u = b.Clone().ToString(); // 222

위의 소스를 보고 다음이 참인지 생각해 보자. (== 연산자나 Equals 메서드는 값을 비교하는 것이므로, ReferenceEquals 메서드를 이용하여 같은 개체들인지 확인해보기로 한다.)



  1. String.ReferenceEquals(str, (a+b)): 이것은 두 개의 참조와 하나의 참조를 비교했으므로 당연히 false이다.
  2. String.ReferenceEquals(a, b): 이것은 당연히 false이다.
  3. String.ReferenceEquals(d, b): d 참조에 b의 참조를 복사한 것이므로 결국 d는 b를 가리킨다. 따라서 true이다.
  4. String.ReferenceEquals(a, t): a와 t는 둘 다 값이 “111”이다. 하지만 이것은 서로 다른 참조를 가져 false이다. 그렇다면 다음 5번도 false일까?
  5. String.ReferenceEquals(a, c): 이것은 true이다. 4번의 경우와는 구분되는 것이, 이와 같이 프로그래머가 코드 상에 직접 기입해넣은 문자열도 실행 중에는 하나의 완벽한 객체로 분류된다. a와 c는 이렇게 코드 상에 서술된 문자열에 대한 같은 참조를 서로 나누어 받은 것이므로 a와 c는 코드 상에 서술되어있던 문자열에 대한 참조를 나누어받았을 뿐이다. 그러므로 이런 결과가 나타나게 된다.

2.4 객체 지향의 캡슐화 파괴 주의


“object pointer를 반환하는 getter method는 객체 지향의 캡슐화가 파괴될 가능성이 있다.” 이는 object형의 field (member variable)의 getter에서 이 object를 그냥 반환하면 이 object를 받은 쪽이나 참조하고 있는 다른쪽에서 이 object의 내용을 변경하게 되므로 사실 캡슐화 (은닉)는 이루어지지 않았다고 보는 것이 정확하다.


“이럴 경우 object를 clone(복제)하여 반환하지 않아도 되는지를 반드시 생각해 본다.”


object의 복사에는 shallow copy와 deep copy가 있다.


// (참고) Member에는 두 개의 field (Identity Class 형의 id와 Family Class 형의 family)가 있다.

// Shallow Copy Example
public Member ShallowCopy()
{
    Member newer = new Member();
    newer.id = this.id;
    newer.family = this.family;
    return newer;
}

// Deep Copy Example
public Member DeepCopy()
{
     Member newer = new Member();
     newer.id = new Identity(this.id.Identity, this.id.Name);
     newer.family = new Family(this.family.FamilyName, this.family.FamilyInfo);
     return newer;
}

위 소스에서 보듯이 Shallow Copy는 object를 복사하여 반환한 것 처럼 보이지만, 사실은 Member Object만 새로 생성되었을 뿐 Member의 field는 newere와 this 둘다 서로 같은 힙의 id와 family를 참조한다. 하지만 두 번째 method인 Deep Copy의 경우 member field를 새로 생성하여 복사하므로 서로 다른 id와 family이다.


클래스를 직접 구현하고 있을 동안 System.Object 클래스의 protected 메서드 (외부에서는 사용할 수 없지만 내부적으로 상속되어져 내려오는 메서드) 중 하나인 MemberwiseClone 메서드를 이용하여 자기 자신의 단순 복사본을 생성할 수 있다. 하지만 Deep Copy를 구현하려면 ICloneable 인터페이스를 이용하여 별도로 Clone 메서드를 통하여 구현하도록 하는 것이 바람직하다. 하지만 ICloneable 인터페이스를 구현했다고 해서 이 개체가 반드시 Deep Copy를 지원하는 것이라고는 볼 수 없다.*


(참고) object를 immutable (변하지 않는, 불변의 객체)로 만드는 요령



  1. 모든 field (member variable)를 생성자(constructor)를 이용하여 초기화한다.
  2. 모든 field는 private으로 선언하고, getter property는 만들되 setter property는 만들지 않는다. 이로서 모든 속성들을 readonly property로 변경한다.

즉, 값을 변경하기 위해서는 object를 다시 만들어야만 하는 불편은 있지만 안전하게 사용하고자할 때 유용한 방법이다.


2.5 배열에 대하여


2.5.1 배열은 object인가?

CLR에서 배열은 object로 취급되어 object와 같이 기술된다. 클래스의 상속도로 보았을 때에도 모든 배열은 암시적으로 System.Array를 부모 클래스로 하며, System.Array는 다시 System.Object를 부모 클래스로 한다. 그리고 int[] iarr = new int[10]; 구문에서처럼 new 연산자로 Heap 영역에 object를 생성하므로 object임을 알 수 있다.


2.5.2. 배열의 length는 왜 Java와 달리 프로퍼티인가?

Java와는 달리 닷넷의 배열은 길이를 가져오기 위한 방법으로 getter property를 이용하는데, Java이든 닷넷이든 이 과정은 컴파일러의 트릭과 함께 CLR의 협조를 기반으로 이루어지는 특수한 논리가 숨어있다.


어찌되었든 간에 배열의 길이를 조사하는 것 자체는 필드도 아니고 메서드도 아니고, 프로퍼티도 아닌 단순한 기계 명령어 하나로 통일된다고 봐야 한다. (이전 섹션에서 언급한 ldlen 명령 참고) 그러나 이런 식의 문법적 요소를 남겨두는 이유는, 언어 호환성을 위한 것으로 컴파일러 제작자가 아닌 일반 소프트웨어 개발자들에게는 피상적인 의미 그 이상이 되지는 않는다.


2.5.3. readonly와 배열에 대하여…

우리가 흔히 앞에서도 나온바 있지만 readonly는 값을 변경할 수 없는 것이라고만 생각하지 object로 되어있을 경우 그 object는 변경 가능하다는 것을 잊곤한다. 배열도 object이므로 마찬가지다.


readonly int[] iarr = new int[5]; 일 경우 iarr = null; 은 오류로 취급되지만, iarr[3] = 5;는 오류가 발생하지 않는다. 즉, readonly로 지정되어있는것은 iarr이지 iarr이 가리키는 곳의 배열의 요소들은 아니다.


2.5.4. 닷넷에서의 다차원 배열은 논리적인 개념일 뿐이다.

가령 2차원 배열처럼 보이는 int[][] iarr 또는 int[,] iarr은 논리적으로는 행열 구조처럼 보일 수 있지만 실제 컴퓨터 메모리 배치는 그렇지 않다. 두개의 배열이 각각 구분되어있는 상태에서 서로 유기적으로 통합된 상태이거나, 지정된 크기만큼의 메모리 공간을 사용하는 1차원 배열이 재구성된 것으로 이해하는 것이 올바르다.


2.6. 인수 (parameter/argument) 전달의 개념


2.6.1. 닷넷에서 parameter (argument) 전달은 무조건 ‘call by value’이다.

값 형식의 경우 호출한 쪽의 변수값은 호출 받은 method 내에서 값이 변경되어도 변경되지 않는다. 참조 형식의 경우도 참조되는 object에 대해서는 함께 변경되지만 참조 포인터는 call by value이다. object를 가리키는 pointer는 call by value로 변경되지만 Heap의 실제 object 내용은 변경되지 않는다.


2.6.2. C와 같은 언어는 static linking이지만, 닷넷은 dynamic linking이다.

따라서 닷넷은 클래스가 처음에 한꺼번에 메모리에 로드되는것이 아니라 런타임시에 그것이 필요해 졌을 때 로드되고 링크된다. static field의 영역도 클래스가 로드되는 시점에서야 비로소 확보된다. 이렇게 되면 최초 가동 시간이 단축되고 끝까지 사용하지 않는 클래스의 경우 신경 쓸 필요가 없어지게 된다.


따라서 static field는 프로그램이 시작되어 해당 클래스가 필요해 졌을 때 CLR이 알아서 load/link 해준다. 즉, static field는 프로그램이 실행되기 시작할 때 부터 끝날 때 까지 계속해서 존재하는 것이라고 보면 된다.


(참고) linking의 의미


link된다는 것은 클래스가 memory에 loading될 때 특정 메모리 번지에 loading되는 데 이 메모리 번지는 loading될 때 마다 다른 번지 수에 loading된다. 이 때의 메모리 주소값 (Java에서는 실제 메모리 값이 아닐 수 있다)을 현재 실행 중인 프로그램에서 알 수 있도록 하여 해당 클래스에 대한 참조가 가능하도록 연결하는 과정이다.


정적 (static) link라는 것은 이러한 메모리에 대한 주소 정보를 컴파일 시에 compiler가 미리 결정하는 것이고, 동적 (dynamic) link라는 것은 프로그램 수행 중 결정되는 것을 의미한다. 정적인 link의 경우 직접적으로 메모리의 번지값이 할당되는 것이 아니라 offset 값 (기준 위치로부터의 index 값)으로 연결시킨다.


2.7 GC에 대하여 잠깐!


2.7.1. Garbage Collection은 만능이 아니다.

닷넷에는 free가 없다. GC가 알아서 해준다. 하지만 GC 수행 중에는 프로그램의 퍼포먼스가 크게 떨어질 수 있기 때문에 GC가 자주 발생하지 않도록 프로그램을 설계하는 것이 좋다. 서비스 되고 있는 시스템에서도 가끔 시스템이 응답이 늦어지는 시점이 있는데, 이는 GC가 수행되고 있기 때문일 가능성이 높다.


그렇다면 GC가 자주 발생하지 않도록 해야 하는데 가장 좋은 방법은 무엇일까? 그것은 바로 불필요한 객체를 생성하지 않는 것이 아닐까?


닷넷에 free가 없다는 것은 매력적이다. 그 이유는 두 개의 변수가 heap 내에서 하나의 object를 reference하고 있을 경우 실수로 하나의 변수만 free해버리면 나머지 하나는 dangling pointer라하여 reference pointer가 모르는 사이에 사라져버려 곤경에 처하는 것을 예방해 주기 때문이다.


참고로 System.Object 클래스에는 Finalize 메서드가 있어 GC 수행 시점에 호출되는 메서드가 있지만 이것은 GC가 언제 수행될지 알 수 없으므로 과신하면 안된다.


덧. 상호 연동성 프로그래밍을 많이 하는 경우, 소멸자와 함께 반드시 생각해야 할 것이 IDisposable 인터페이스의 구현이다. 이 인터페이스를 구현하여, 언제 호출될지 알 수 없는 소멸자가 아닌 명시적으로 호출 가능한 소거 함수를 배치하여 필요없을 때 외부 리소스 (플랫폼 호출을 통하여 부른 malloc 같은 함수들의 메모리 블럭)들을 미리 제거할 수 있다. 그리고 소멸자나 Finalize 메서드와도 Dispose 메서드를 연결시켜서 Dispose가 이루어지지 않아서 발생하는 리소스 누수를 예방해야 한다.


2.8 닷넷 Pointer 결론


2.8.1 결국 닷넷에는 포인터가 있는 것인가, 없는 것인가?

닷넷은 Heap 내의 Object를 참조(reference)하고 있고, 참조는 결국 개념이 포인터와 유사한 것이므로, 닷넷에 포인터가 없다는 것은 어불성설이다.


주. 이 부분에 대해 Object를 이해하시면 족히 이런 문제는 사라질 것으로 봅니다. 클래스에 대한 인스턴스 (object)들은 reference로 밖에 가질(참조될)수 없기 때문입니다. 컴파일러 입장이 아닌 기반 언어들 (C#, VB.NET, J#, C++ CLR 등)의 사상을 가지고 이해하는 것이 좋을듯 합니다.


java.sun.com의 Java programmer’s FAQ에 이런 질문 글이 올라온 적이 있다. “Java에는 pointer가 없다고 하는데, linked list는 어떻게 만들어야 하는가?”라는 질문이었는데 이에 대한 답이 참 명쾌하다. 이것은 비슷한 메카니즘을 가지는 다른 GC 환경 플랫폼에서도 동일하게 적용되는 것이다.


Java에 관한 많은 오해 중에서 이것이 가장 심각한 것이다. 포인터가 없기는 커녕 Java에 있어 객체지향 프로그래밍은 오로지 Pointer에 의해 행해진다. 다시 말해 객체는 항상 포인터를 경유해서만 access가 이루어지며 결코 직접적으로 access 되지 않는다. pointer는 reference (참조)라고 불리며 당신을 위해 자동으로 참조된다.

닷넷에 포인터가 없다고 주장하는 모든 서적과 글들의 내용은 혼란을 야기할 가능성이 크다. 이러한 주장은 GC 플랫폼 환경을 정확히 숙지하지 않은 사람의 서술일 가능성이 높으므로 재고해보아야 할 표현이다.

댓글 남기기