형(Type)의 기원
원래 프로그래밍에서 형의 기원은 자료형(Data Type)입니다. 더 거슬러 자료형이 있어야 하는 이유는 메모리에서 차지할 공간의 크기를 알기 위해서 입니다. 하지만 보다 구체적으로 파고들면 컴파일러의 변수관리와 큰 관계가 있습니다.
프로그래밍 시 선언한 변수는 간단히 말해 메모리주소의 별명(Alias of Memory Address)입니다.
그럼 진짜 메모리 주소를 쓸 것이지 왜 별명을 쓸까요? 그거야 어찌보면 당연한데 프로그래밍시에는 변수의 진짜 주소를 알 수 없기 때문입니다. 실제 프로그램이 이용할 메모리는 실행이 되어야 주어지는데 프로그래밍을 하는 시점에서는 실행 중이 아니니 실제 메모리 주소를 알 수는 없는 거죠. 따라서 메모리주소의 별명을 사용해서 프로그래밍을 합니다.
그렇게 프로그래밍하면 컴파일러는 내부적으로 vTable이라 불리는 별명과 실제 메모리주소를 매핑하는 테이블을 생성해 둡니다. 그리고 실제 프로그램이 실행되면 OS로부터 받은 메모리 공간에 있는 진짜 주소를 vTable에 기록한뒤 해당 변수를 위한 진짜 주소로 매핑해주는 거죠.
변수에 단순히 숫자가 들어갈 경우 vTable에 들어가야 할 정보는 다음과 같을 것입니다(간단히 js로..)
var vTable = { a:{ address : null, length : 4 }, b:{ address : null, length : 1 } };
위의 vTable을 보면 아직 실행되지 않았으니 실제 메모리의 주소는 알 수 없어 일단 null이 되고 변수 a가 4바이트, b는 1바이트의 길이를 필요로 한다는 것을 알 수 있습니다.
실제 실행된 후에는 OS로부터 받은 메모리 공간 내의 진짜 주소를 할당합니다.
//64비트 시대니까 ^^; vTable.a.address = 0x11223344; vTable.b.address = 0x11223345;
이렇게 OS에서 실행 되면 가장 먼저 하는 게 vTable변수의 실제 메모리 주소 할당입니다.
변수에 주소를 할당하면 변수에 어떤 값을 쓸 때 해당 길이만큼을 차지하여 값을 쓰게 됩니다. 예를 들어 a는 4바이트를 차지하니 a = 10이라고 하면 다음과 같이 써질 것 입니다.
| 0 | 0 | 0 | 10 |
일단 메모리에 무조건 4바이트를 차지하고 보는거죠. 이에 반해 b = 10을 한 경우는 1바이트만 사용하도록 정의되었으므로 다음과 같이 써질 것입니다.
| 10 |
즉, 얼마나 여유있는 메모리공간을 차지하는가는 그 변수에 얼마나 큰 값을 담을지에 달려있다고 할 수 있습니다. b는 1바이트뿐이니 0~255 범위 내의 수만 쓰겠다는 뜻이고 0 ~ 4,294,967,295 범위의 수를 쓰겠다는 뜻입니다.
따라서 초창기 형은 곧 자료형이었고 자료형의 정체는 데이터의 크기에 따른 메모리 할당 공간의 길이라는 의미였습니다.
포인터(Pointer)의 등장
초창기엔 위의 정도로 프로그래밍이 충분했습니다만, 점점 더 프로그래밍은 복잡해져갔습니다. 이에 물리적인 메모리 공간과 무관한, 사람(혹은 인간의 사고방식)을 위한 메모리 모델을 생각해 냈습니다.
그 발단은 vTable자체를 변수가 흉내내자는 것입니다.
응?
vTable은 아직 주어지지도 않은 메모리 주소의 별명입니다. 그럼 메모리주소의 별명의 별명은 어때? 라는 거죠. 근데 별명의 별명을 부르면 좋은 점은 뭘까요?
예를 들어 홍길동의 별명이 고스트라고 해두죠. 근데 고스트라고 부르지 않고 다시 고스트에 별명을 새로 만들어서 독수리라고 부르겠다는 형국입니다. 이렇게 되면 어찌될지 생각해보는게 아니라 왜 이렇게 할까 를 생각해 보겠습니다.
홍길동은 본명이니 아마 그 이름으로 활동하는 건 매우 위험한 일입니다(하는 짓이 도둑질이다보니..)
따라서 홍길동은 연예인처럼 고스트라는 예명(?)으로 활동하기로 했습니다. 이제 활빈당에서 더 이상 홍길동이라는 본명으로 활동하지 않고 고스트로 활동하니 활빈당 멤버들도 전부 고스트라는 이름만 알게 되었습니다.
고스트로부터 지령이 내려오면 수행하는 식이겠죠. 이 때 좋은 점이 있습니다. 활빈당 멤버들은 고스트라는 이름만 알고 있으니 홍길동이 잠시 휴가가면서 누군가를 대신 고스트로 임명해두고 가도 활빈당 멤버들은 모른다는 점입니다.
활빈당이 부자들에게 훔친 돈을 가난한 자들에게 나눠주면서 “고스트로부터의 선물”이라고 쓰려니 두목의 본명을 알리는게 싫었습니다.
응?(활빈당 멤버들은 처음부터 고스트가 홍길동의 본명인줄 알고 있잖아요!)
그래서 그들은 “독수리부터의 선물”이라는 카드를 남기고 오게 되었습니다.
그리하여 구원받은 일반 백성들은 오직 독수리라는 이름만 알게 되었습니다. 이번에는 다른 장점이 생깁니다. “독수리로부터의 선물”이란 카드를 받는 이상 백성들은 활빈당이 한건지 지역방범대가 한 건지 상관하지도 않을 뿐더러 알 수도 없게 되었습니다.
이제 홍길동이 고스트의 대리인을 세워두고 휴가가는 차원을 넘어 활빈당 전체가 잠깐 임무를 지역방범대에게 위임하고 단체로 휴가갈 수 있게 되었습니다.
그렇게 되어도 백성들은 여전히 독수리에게 선물을 받게 되겠죠.
이상의 얘기에서 얻을 수 있는 것은…
(재미없다 말구!)
.
.
그러니까
- 진짜를 안가르쳐줄수록 진짜의 처신이 자유롭다는 것과
- 그렇게 별명만 알려주는 것도 계층을 이루면 각 계층별 변화에 실체도 단계별로 자유롭게 처신할 수 있다는 것입니다.
이러한 별명…(영어로 alias라고 하는데) 실체를 감추는 가장 간단한 방법이면서도 효과적인 방법입니다.
또한 이 기법은 프로그래밍의 복잡성을 정복하는데 가장 핵심적인 역할을 수행하는 격리(isolation)의 근간을 이루는 개념입니다.
격리란 어떤 책임이나 업무를 처리하는 부분이 있다면 다른 부분은 그러한 책임을 전혀 신경쓰지도 쓸수도 없게 만드는 것입니다. 이렇게 하면 매우 복잡한 알고리즘을 분할하여 정복하고 각 분할된 부분이 어떤 일을 수행할 때 다른 부분은 참견할 수 없으므로 잘게 쪼개진 쉬운 로직을 인간을 정복할 수 있게 되는 것입니다.
서로 간섭할 수 없게 하는 방법은 모르게 하는 것입니다. 하지만 전혀 상대를 모르면 서로 상호작용할 수 없으므로 별명만 알려주는 거죠.
이제 뜬구름 잡는 홍길동 얘기에서 프로그래밍의 세계로 돌아옵시다.
int a = 30;
이렇게 a라는 변수에 30을 넣으면 a는 vTable에 등록되고 아마 프로그램이 실행되는 시점에 진짜 메모리 주소와 매핑될 것입니다.
하지만 a의 별명을 b라고 합시다.
int b = a;
근데 이 표현은 애매하기 짝이 없습니다. b와 a가 같은 값을 갖고 있다는건지, 아니면 b라는 변수의 vTable이 a와 같은 주소에 매핑된다는건지 알 수 없죠.
임의의 기호 &를 이용하여 다음과 같이 표현해봅시다.
b = a; // b는 a와 같은 값을 갖고 있다. 즉 b == 3 b = &a; // b와 a는 같은 주소를 가리킨다. 즉 vTable.b.address == vTable.a.address
그럼 이제 a대신 b를 괴롭히죠.
b = 50;
그럼 이것은 곧 a = 50; 과 같은 작동을 하게 됩니다. 하지만 프로그래밍은 전부 b만 이용해서 하게 됩니다. 그렇다면 b에 &a대신 &c를 넣거나 &d를 넣어도 상관없습니다. 이러한 샘플을 볼까요.
void double(){ b = b * 2; }
보다시피 b를 두배해주는 함수입니다. b는 지역변수가 아니니 전역변수라고 해두죠.
그럼 이제 b에 무엇을 할당했냐에 따라 그 영향은 각각 달라집니다.
a = 3; b = &a; double(); // a == 6; c = 5; b = &c; double(); // c == 10;
위에서 b에 a의 주소를 할당한뒤 double을 실행하면 a를 변화시키지만, b에 c의 주소를 넣고 실행하면 c를 변화시킵니다.
여기서 중요한 점은 double함수의 코드는 그대로인데 단지 b가 누구를 가리키냐에 따라 효과가 달라진다는 것 입니다. 즉 뭘 가리켜도 상관없이 double함수의 로직을 일관성 있게 처리할 수 있다는 거죠.
즉 double이라는 함수를 만드는 입장에서 a나 c라는 변수를 몰라도 오직 b만 갖고 작성할 수 있으니 외부에 d가 있든 f가 있든 신경 안쓰고 로직을 구축할 수 있습니다.
변수의 별명(포인터)을 만드는 이유는 포인터를 바탕으로 프로그래밍을 하면 포인터가 가리킬 실제 변수가 무엇인지 신경쓰지 않을 수 있기 때문입니다.
포인터와 자료형
포인터를 통해 프로그래밍을 진행한다해도 여전히 길이 문제가 있습니다. 만약 a와 c는 1바이트고, b를 기반으로 하는 double함수는 2바이트를 기반으로 하는 알고리즘이라고 하면 금세 망가집니다.
byte a = 255; word b = &a; void double(){ b = b * 2; } double(); //510은 1바이트에 담을 수 없다!
즉 double 알고리즘은 b를 기반으로 작성하되 b는 어떤 자료형의 포인터인지 알아야 한다는 겁니다. 이러한 이유로 포인터의 자료형이 탄생합니다. 형과 포인터를 명시할 수 있는 c로 표현하면 아래와 같은 문법이 차근차근 나오게 됩니다.
int a; //a는 int형(4바이트) int* b; // b는 int형 변수의 포인터형 b = a; //b에 a의 주소를 할당
즉 직접 변수가 아니라 변수의 주소를 가리키는 변수의 별명인 포인터에게도 원본 변수의 자료형은 알아둬야할 정보인 것입니다. 이처럼 자료형은 단지 변수가 메모리를 확보할 크기일 뿐아니라 가상의 변수인 포인터에게도 영향을 줍니다.
여기까지 다룬 자료형과 포인터형은 실제 데이터와 메모리의 별명을 만드는 기법입니다.
사실 여기까지는 어찌되었든 변수가 데이터 하나와 연결되므로 직접적인 자료형이라 할 수 있습니다.
구조체형(Structure Type)
현실세계의 무언가를 프로그래밍으로 모델링하다보니 변수 한 개로는 표현할 수 없습니다. 그렇다고 변수를 여러 개 쓰는 것도 아니라는 생각이 들어 관련된 변수의 묶음을 하나의 변수처럼 인식할 방법이 필요했습니다.
뭐라는 거냐구요?
사람 한 명, 한 명의 정보를 저장하는 시스템을 만든다고 해보죠. 사람은 너무 복잡한 정보를 갖고 있으니 그 중 나이, 성별만 저장하기로 했습니다. 그럼 사람마다 변수를 두 개씩 만들어야할 판국입니다. 아마 다음과 같이 되겠죠.
var person1_age = 20; var person1_sex = 1; var person2_age = 30; var person2_sex =2;
이거 너무 빡셉니다. 정확하게 말하자면 작명하기가 너무 빡셉니다. 같이 일하는 동료 개발자가 people3_age 같은 이름을 짓기만 해도 끝장입니다. 결국 사람하나를 표현하는 변수들의 묶음이 필요합니다. 다음과 같이 정리해보죠.
var person1 = { age : 20, sex : 1 }; var person2 = { age : 30, sex : 2 };
이제 훨씬 관리하기 좋아졌습니다. 이 때 사람 하나를 표현하기 위한 구조는 {나이, 성별} 이라는 형을 사용했습니다. 이 복합적인 자료형을 person형이라고 해둡시다. 이러한 구조체형은 변수 하나의 길이가 아니라 변수 여러 개의 길이를 합친 크기를 갖게 됩니다.
그렇다면 구조체의 포인터는 어떻게 될까요? 변수의 포인터가 형으로 변수의 형을 승계받듯, 구조체의 포인터는 구조체 그 자체를 형으로 이어받습니다. 즉 아래와 같은 거죠.
person* someone = person1; //someone의 형은 person포인터형
클래스형(Class Type)
구조체가 등장하고 난 뒤 함수에는 큰 변화가 생겼습니다. 기존의 변수 수준의 알고리즘을 작성하던 것에서 구조체에 대한 알고리즘을 작성하는 것으로 넘어가게 되었습니다. 간단히 자바코드로 살펴보죠.
//기존의 변수 수준 알고리즘 int double( int a ){ return a * 2; } //구조체 수준의 알고리즘 void double( person a ){ a.age = a.age * 2; //나이만 2배한다. if( a.age < 100 ) a.age = 100; //100살을 넘겼으면 100살로 해두자 }
즉 기존의 변수 하나를 처리하던 알고리즘은 수리적이거나 논리적인 알고리즘이었던데 반해, 구조체를 받는 함수는 구조체 자체가 현실의 무언가를 모델링하고 있는 것이기 때문에 알고리즘도 자연스레 추상적인 수준의 것을 기술하게 됩니다. 이렇게 구조체를 인자로 받는 함수는 반드시 그 구조체만을 위한 알고리즘을 갖게 되어 다른 구조체에는 사용할 수 없는 경우가 많은데 이러한 현상을 유심히 관찰하여 얻은 결론은 특정 구조체를 위한 특정 함수가 있다 입니다.
이 관찰의 결과를 일반화 한 것이 오늘날의 클래스의 기원이 되었습니다. 따라서 클래스는 구조체로서의 변수들과 그 구조체 자체를 처리할 함수를 하나의 그릇으로 묶어두게 됩니다.
.
.
하지만 그런 전용 함수를 포함하는 기능에 앞 서 보단 근본적으로 보자면 여전히 구조체의 기능을 갖고 있습니다. 따라서 클래스형 역시 복합적인 자료형인 것입니다.
형이 보장하는 알고리즘의 안정성
이러한 발전 단계로 더욱 추상화되고 별명화 된 자료형은 알고리즘의 안정성을 기본 단계에서 확보하는데 큰 역할을 합니다. 예를 들어 숫자는 숫자지만 성별에 사용될 1, 2만 정확한 값이라고 해봅시다. 만약 1바이트 정수를 사용한다면 byte형을 쓸 수 있습니다.
byte sex;
그리고 이제 sex를 설정해봅시다.
void setSex( byte s ){ sex = s; } setSex( 1 );
언틋 문제가 없어보이지만 setSex( 3 ); 과 같이 해도 프로그램은 전혀 오류를 내지 않는다는게 문제입니다. 왜냐면 3은 byte범위에 들어가는 올바른 숫자이기 때문입니다. 그럼 뭔가 문제입니까? 그건 우리 맘에 안든다는 것입니다. 1 또는 2만 들어오길 바라거든요.
. . .왜??
그건 맘 속에 1은 남자, 2는 여자라고 정해두었기 때문입니다. 아니 맘 속에 정해둔 걸 프로그램이 독심술로 읽어낼 수도 없고… 코드로 표현해줘야겠죠.
void setSex( byte s ){ if( s == 1 || s == 2 ) sex = s; }
이제 1,2 외에는 sex로 설정할 수 없게 되었습니다. 정답이냐구요? 네, 정답입니다. 하지만 이건 알고리즘으로 해결한 것입니다. 알고리즘 자체는 절대로 제거할 수 없습니다만 실제 setSex에서 해야할 일은 sex = s 인거지 s가 바른 값인지 평가하는 것은 아닙니다(이름을 보면 setSex니까요)
또한 알고리즘을 제거할 수 없는거지 옮길 수 없는건 아닙니다.
..문제는 어디로 옮기냐는 겁니다.
현재 s가 바른 값인지 검사하는 로직은 즉 형이 올바른가에 대한 알고리즘입니다. 따라서 알고리즘이 이동해야한다면 형 그 자체에 이동하는게 올바른 것이겠죠. 이렇게 함으로써 형 자체에 대한 안전은 형이 감당하고 호스트코드는 자신의 역할만 수행할 수 있습니다.
이를 성립시키려면 더 이상 byte가 아닌 별도로 정의된 형(custom type)을 사용해야겠죠. 간단히 클래스를 만들도록 합시다.
class Sex{ private byte sex = 1; Sex( byte s ){ if( s == 1 || s == 2 ) sex = s; } }
그럼 이제 호스트 코드는 더 이상 기본형인 byte를 사용하지 않고 Sex형을 사용하게 됩니다. 또한 setSex함수도 인자로 byte형이 아닌 Sex형을 받게 됩니다.
Sex sex; void setSex( Sex s ){ sex = s; }
처음 코드와 가장 큰 차이점은 그저 sex에 s를 할당만 하면 된다는 점입니다. 왜냐면 인자로 Sex형인 s가 들어온 이상 그 데이터가 올바른지 아닌지는 신경쓸 필요가 없는 거죠. 단지 Sex형인 데이터가 들어오면 틀림없는 겁니다. 따라서 호스트 코드는 원래 자신이 해야하는 일만 집중할 수 있게 된거죠.
사용자정의형을 얼마나 만들어야하는가?
앞의 예제처럼 사용자가 형을 정의해두면 알고리즘에서 형검증(Type Validation)과 관련된 로직을 배제시킬 수 있습니다. 그렇다면 이러한 형을 얼마나 만들어야할까요?
정답은 컴파일언어에서는 최대한 만들고 인터프리터 언어에서는 가능한한 최대한 만든다입니다.
void setSex( Sex s ){ sex = s; }
새삼 이 함수를 다시 보죠. 컴파일 언어에서는 setSex를 호출하는 모든 구문을 컴파일 시점에 분석하여 Sex형을 못넘기면 컴파일이 실패하게 됩니다. 즉 모든 호스트코드를 포함하여 전체적인 형 안정성을 컴파일러가 담보해주는 것이죠. 이에 비해 인터프리팅 언어는 해석과 실행이 동시에 되기 때문에 어짜피 형검사를 할 수 있는 방법은 없습니다.
모든 형검사는 실행시점에 이루어집니다. 즉 이를 js로 표현하면 아래와 같습니다.
function Sex( s ){ if( s == 1 || s == 2 ) this.s = s; else throw 'invalid'; } var sex; function setSet( s ){ if( ! s instanceof Sex ) throw 'invalid'; sex = s; } setSet( new Sex(2) );
흉내는 냈지만 컴파일시점이 없으므로 명시적인 컴파일 형지정이 불가능합니다. 따라서 복잡한 로직 자체야 클래스 안으로 옮긴다곤 해도, 런타임에 형체크(instanceof)를 피할 방법은 없습니다. 인터프리팅 언어에서 이러한 런타임 체크는 전반적인 성능저하를 가져옵니다. 하지만 성능저하를 감당하더라도 형체크를 하는 편이 실행시점에 에러를 신속히 발견할 수 있는 방법이므로 성능이 허락하는 가능 범위 내에서는 최대한 형지정을 해야합니다.
런타임 언어의 함수인자 형검사를 일반화하기
인자에 지정된 형을 검사하는 로직은 instanceof로 일반화시킨다고 가정했을때 일반적인 알고리즘으로 정리할 수 있습니다. 이를 이용해 일반 함수에 형체크 기능을 붙여주는 시스템을 만들 수 있습니다.
// [값,값,값...]과 [타입,타입,타입...]을 받는다. function typeCheck( $arg, $type ){ var i, j, val, type; //$arg모든 요소 체크! for( i = 0, j = $arg.length; i < j ; i++ ){ val = $arg[i]; //현재 인자 type = $type[i]; //그 인자의 타입 //object일때 아닐때를 알아야함 switch( typeof val ){ //object일때----------- case'object': //null도 object이므로 먼저 분기 if( val === null ){ if( type === null ){ // 그때타입도 null이라면 console.log( 'null ok' ); }else{ console.log( 'invalid type' ); return false; } }else if( val instanceof type ){ //타입체크 console.log( 'object ok' ); }else{ console.log( 'invalid type' ); return false; } break; // 기본형은 문자열로 'string', 'number' 등 case type: console.log( 'primitive ok' ); break; //이도 저도 아니고 다 실패했음 default: console.log( 'invalid type' ); return false; } } return true; }
위 함수를 간단히 인자와 미리 정의된 인자의 타입 배열을 이용해 형을 런타임에 비교합니다. 아래와 같은 함수를 생각해봅시다.
function getPerson( name, age, sex ){ return { name:name, age:age, sex:sex }; }
위 함수는 인자로 문자열 name, 숫자 age, Sex형 sex를 받습니다. 위에 정의한 typeCheck함수를 이용하면 간단히 체크할 수 있습니다.
function setPerson( name, age, sex ){ if( typeCheck( arguments, ['string', 'number', Sex] ) ){ return { name:name, age:age, sex:sex }; }else{ throw 'invalid!'; } }
모든 함수에 저 내용이 들어간다고 가정하면 코드의 중복을 막기 위해 저 로직을 포함하는 함수로 처리할 필요가 있습니다. typeCheck를 포함하는 함수로 만들어주는 함수 제네레이터를 작성합시다.
function getTypeSafe( $func, $types ){ return function(){ if( !typeCheck( arguments, $types ) ) throw 'invaild arguments'; $func.apply( this, arguments ); }; }
우선 arguments와 $type을 이용해 형체크를 한 뒤 실행해주는 간단한 제네레이터입니다. 이제 재료가 다 모였으니 완전한 호스트 코드를 작성해봅시다.
// Sex형의 정의 function Sex( s ){ if( s === 1 || s === 2 ) this.s = s; else throw 'invaild'; } //getPerson함수 function getPerson( name, age, sex ){ return { name:name, age:age, sex:sex }; } //타입검사가 포함된 getPerson의 safe버전 var getPersonSafe = getTypeSafe( getPerson, ['string', 'number', Sex] ); //실제 사용 var p0 = getPersonSafe( 'hika', 30, new Sex(1) ); //console.log primitive ok primitive ok object ok
결론
- 데이터의 길이를 컴파일러에게 알려주기 위해 고안된 자료형은 포인터와 구조체와 같은 추상형을 거쳐 해당 데이터의 행위를 포함하는 클래스로 확장되었습니다.
- 사용자가 정의한 형을 사용하면 알고리즘에서 데이터 자체에 대한 검증을 호스트코드에서 제거하고 형 그자체에 맡길 수 있습니다.
- 컴파일러 언어에서는 이러한 사용자 정의형을 최대한 사용하므로서 컴파일러가 호스트코드와 함수측 모두의 안정성을 검사하게 할 수 있습니다.
- 인터프리터 언어에서는 런타임에 검사할 수 밖에 없으므로 허락되는 범위 내에서 최대한 사용자 정의형을 만듭니다.
- 함수 인자에 대한 형검사는 일반화할 수 있습니다.
recent comment