C++ 다중 상속 생각

날개셋 한글 입력기 같은 Windows용 프로그램을 개발하다 보면 여러 개의 COM 인터페이스를 한꺼번에 상속받아 구현한 단일 클래스를 구현하게 된다.

그런데 하루는 이런 의문이 들었다. 각각의 인터페이스들이 다 IUnknown을 상속받았는데 어떻게 어느 인터페이스로 접근하든지 AddRef, Release 같은 공통 인터페이스들은 중복 없이 동일한 함수 및 동일한 숫자 카운터 인스턴스로 연결될까? 데이터 멤버 없이 인터페이스 상속만 하면 this 포인터 보정이 필요 없이 다중 상속과 관련된 문제들이 상당수 깔끔하게 해결될까?

그래서 클래스 A, 이로부터 상속받은 B와 C, 그리고 B와 C를 다중 상속한 D 이렇게 네 개의 클래스가 있을 때 일명 ‘죽음의 다이아몬드’ 현상을 해소하는 방법이 무엇이 있는지를 정리해 봤다. C++의 다중 상속과 관련해서는 이제 더 글을 쓸 게 없을 줄 알았는데 내가 지금까지 생각하지 못하고 있던 요소들이 더 있었다.

1. 가상 상속

클래스에서 상속이라는 건 기술적으로 어떤 구조체에다가 부모 클래스의 컨텐츠(데이터 멤버)들을 앞에 쭉 늘어놓고 나서 그 뒤에 나 자신의 컨텐츠를 추가하는 것과 같다. 그러니 부모 클래스와 자식 클래스 포인터를 형변환 하는 건 그냥 프로그래밍 언어 차원에서의 의미 변환일 뿐, 메모리 주소가 바뀌는 것은 전혀 없다. 아주 쉽다.

그런데 가상 상속은 부모 클래스의 컨텐츠를 그렇게 나 자신의 일부로서 고정된 영역에 배치하는 게 아니라, 포인터로 참조하는 것과 같다.
부모 클래스를 ‘가상’이라는 방식으로 상속한 자식 클래스는 부모 클래스와 자식 클래스가 굳이 메모리 상에 연속된 형태로 있지 않아도 된다. 그러니 동일 부모를 공유하는 다수의 클래스가 다중 상속되더라도 이들이 공통의 유일한 부모 하나만을 가리키게 하면, 한 부모 클래스의 데이터들이 불필요하게 여러 번 상속되는 것을 막을 수 있다.

여기서 중요한 것은, D가 B, C를 ‘가상’ 상속하는 게 아니라는 점이다. 부모인 B와 C가 A를 미리 가상으로 상속해 놔야 한다.
가상 함수도 자식이 아닌 부모 클래스에서 미리 지정해 놔야 하듯 말이다.

그러니 클래스 라이브러리 개발자는 공통 부모를 공유하는 여러 클래스들이 사용자에 의해 다중 상속되겠다 싶으면 그 공통 부모를 virtual로 상속하도록 설계를 미리 해 놔야 한다. 특히 그 클래스(공통 부모 말고)가 순수 가상 함수 같은 걸 포함하고 있어서 상속이 100% 필수라면 더욱 그러하다.
앞의 A~D의 경우, 혹시 A가 default constructor가 없어서 B, C의 생성자에 모두 A를 초기화하는 인자가 들어있었다 하더라도, D의 A는 D의 생성자에서 제공된 인자만으로 딱 한 번만 초기화된다.

가상 상속을 한 자식 클래스는 굉장히 이색적인 특징을 하나 갖게 된다.
자식 클래스의 포인터에서 부모 클래스의 포인터로 형변환을 하는 것이야 너무 당연한 귀결이며, 반대로 부모에서 자식으로 가는 건 좀 위험한 일이긴 하지만 어쨌든 가능하다. 단일 상속에서는 말할 필요도 없고, 다중 상속이라 하더라도 그냥 고정된 크기만큼의 포인터 덧셈/뺄셈만 하면 된다.
그에 반해, 부모 클래스에서 자신을 virtual 상속한 자식 클래스로 형변환은 일반적으로 허용되지 않는다…!

A *pa = new D; //자식에서 부모로 가는 건 당연히 되고
B *pb = new D;
D *pd;
pd = static_cast<D*>(pb); //부모에서 자식으로 가는 건 요건 괜찮지만
pd = static_cast<D*>(pa); //요건 안 된다는 뜻..

그 자식 클래스의 주소와 부모 클래스의 주소 사이에는 컴파일 타임 때 결정되는 관계 내지 개연성이 없기 때문이다.
자식에서 부모로 거슬러 올라가는 게 단방향 연결 리스트를 타는 것과 다를 바 없게 됐는데, 저런 형변환은 단방향 연결 리스트를 역추적하는 것과 같으니까 말이다.

물론, 가상 상속이라 해도 현실에서는 D라는 오브젝트 내부에서 A가 배치되는 오프셋은 고정불변일 것이고 컴파일러가 그 값을 계산하는 게 불가능하지 않을 것이다. 모든 자식 클래스들과 연속적으로 배치되지만 않을 뿐이다.
static_cast를 어거지로 구현하라면 구현할 수는 있다. 하지만 이 A가 반드시 D에 속한 A라는 보장도 없고, 포인터에 무엇이 들어있는지 확신할 수 없는데.. C++ 컴파일러가 그런 어거지 무리수까지 구현하지는 않기로 한 모양이다.

2. 가상 함수로 이뤄진 추상 클래스(인터페이스)들만 상속

죽음의 다이아몬드를 해소하기 위해서 요즘 프로그래밍 언어들은 C++ 같은 우악스러운 수준의 다중 상속을 허용하지 않고, 잘 알다시피 데이터 멤버 없고 가상 함수로만 구성된 추상 클래스들의 다중 상속만 허용하곤 한다.
그러면 문제의 복잡도가 크게 줄어들긴 한다. 효과가 있다. 하지만 그게 전부, 장땡은 아니다.

명시적인 데이터가 없는 클래스라 하더라도 가상 함수가 들어있는 클래스를 상속받을 경우, 2개째와 그 이후부터는 클래스 하나당 vtbl (가상 함수 테이블 v-table) 포인터만치 클래스의 덩치가 커지게 된다.

단일 상속 체계에서는 this 포인터의 변화가 전무하니 상속을 제아무리 많이 하더라도 한 vtbl의 크기만 커질 뿐, 그 테이블을 가리키는 포인터의 개수 자체가 늘어날 필요는 없다.
그러나 다중 상속에서는 D 같은 한 객체가 상황에 따라 클래스 B 행세도 하고 클래스 C 행세도 하면서 카멜레온처럼 변할 수 있어야 한다. 그렇기 때문에 A, B, D일 때의 vtbl, 그리고 C일 때의 vtbl 이렇게, 테이블과 테이블 포인터가 둘 필요하다.

클래스 D에 속하는 인스턴스 포인터(가령, D *pd)를 부모 C의 포인터로 변환해서 전달할 때는 pd는 A, B, D 같은 직통 상속 계열 vtbl이 아니라 C의 vtbl을 가리키는 형태로 오프셋이 보정된다. 그리고 여기서 가상 함수를 호출하면.. this 포인터가 C가 아닌 D를 기준으로, 보정 전의 형태로 복구된 채로 함수에 전해진다. 이 함수는 애초부터 C가 아닌 D에 소속된 함수이기 때문이다.

즉, 다중 상속에서 가상 함수를 호출하면 비록 겉으로 this 포인터는 바뀐 게 없지만 내부적으로 vtbl을 찾는 것을 부모와 자식 클래스가 완전히 동일하게 수행하기 위해서 보정이 일어나고, 그걸 함수에다 호출할 때는 보정 전의 값을 전하도록 일종의 thunk 함수가 먼저 수행된다.
한 클래스 오브젝트에서 여러 인터페이스 함수를 자유자재로 호출하는 polymorphism의 이면에는 이런 비용 오버헤드가 존재하는 셈이다. 무슨 숫자나 문자열로 메시지를 전하는 게 아닌 이상, 서로 다른 클래스에 존재하는 가상 함수는 vtbl의 종류와 오프셋으로 구분할 수밖에 없다.

3. 멤버로만 갖기

다중 상속의 지저분함을 회피하는 방법 중 하나는.. 원하는 기능이 들어있는 클래스를 내 아래로 상속하지 말고 그냥 멤버 변수로 갖는 것이다. 상속하더라도 걔만 따로 상속해서 확장 구현을 한 뒤에 그걸 멤버 변수로 갖는다. 이 개념을 유식한 용어로는 aggregation이라고 한다.

이 방법은 다중 상속의 각종 오버헤드는 피할 수 있지만 그만큼 다른 방면에서 불편을 야기한다. 그 클래스가 동작하는 과정에서 내 클래스의 함수 및 데이터를 빈번하게 참조해야 한다면(결합도 coupling가 높은 관계..) 그 통로를 억지로 트는 게 더 불편하며 코드를 지저분하게 만든다. 또한 서로 다른 클래스 간에 중복 없이 동일한 기능을 제공하는 일관된 인터페이스를 만드는 게 다중 상속이 아니면 답이 없는 경우도 있다.

이게 경험상 딱 떨어지는 답이 있는 문제가 아니다. 복잡한 클래스 계층이 필요한 대규모 개발을 한 경험이 없는 프로그래머라면 이런 부류의 문제는 배경을 이해하는 것조차 난감할 것이다. 그렇기 때문에 다중 상속이 무조건 나쁘기만 한 건 아니며, 그걸 억지로 우회하다 보면 결국 다른 형태로 불편함과 성능 오버헤드가 야기될 거라며 다중 상속을 옹호하는 프로그래머도 있다.

이상.
객체지향 프로그래밍 언어에서 다중 상속은 사람마다 취향 논란이 많은 주제이다. 비록 C++이 이걸 지원하는 유일한 언어는 아니지만, 네이티브 코드 생성이 가능한 유명한 언어 중에서는 C++이 사실상 대표격인 것처럼 취급받고 있다.

어떤 기능이 절대적으로 나쁜 것만 아니다면야 없는 것보다는 있는 게 좋을 것이다. 상술했다시피 다중 상속이 가능해서 아주 편리한 경우도 물론 있다. 한 오브젝트로 다수 개의 기반 클래스 행세를 자동으로 하는 것과, 그 오브젝트 내부의 구현 함수에서는 여러 기반 클래스를 넘나드는 게 동시에 되니까 말이다.

하지만 다중 상속은 가성비를 따져 보니 그 부작용과 오버헤드, 삽질을 감수하면서까지 굳이 구현하고 지원할 필요가 있나 하는 게 PL계의 다수설 대세로 흐르고 있다. this 포인터의 보정이라든가, 복수 개의 기반 클래스들이 또 공통의 기반 클래스를 갖고 있을 때 발생하는 모호성의 처리 등.. 템플릿 export만치 막장은 아니지만 컴파일러 개발자와 PL 연구자들의 고개는 설레설레 저어지곤 했다.

그래서 C++ 이후에 등장한 더 깔끔한 언어인 D, C#, Java 등은 다중 상속을 지원하지 않는다. 그 대신 다중 상속을 우회하고 복잡도를 완화하기 위해, 적어도 가상 상속만은 할 필요가 없게끔 static 내지 가상 함수 선언만 잔뜩 들어있는 인터페이스에 대해서만 다중 상속을 허용하는 것이다. Java는 두 종류의 상속을 extends와 implements라고 아예 구분까지 했다.

물론 이런 패러다임 하에서는.. 프로그램 구조가 간단해서 가상 함수로 만들 필요가 없는 것까지 일단은 인터페이스부터 만들어 놓고 구현 클래스를 내부적으로 또 만드는 식의 오버헤드 정도는 감수해야 한다. 하지만 Java는 final이 아닌 함수는 기본적으로 몽땅 가상 함수일 정도로.. 디자인 이념이 애초에 성능 대신 극도의 유연성이니, 그 관점에서는 그건 별 상관이 없는가 보다.

가장 대중적인 기술이 알고 보면 레거시 때문에 굉장히 지저분하고 기괴하기도 하다는 것은 CPU계에서 x86이 그 예이고, 프로그래밍 언어에서는 C++이 해당되지 싶다. 전처리기(+생짜 파일 기반 인클루드), 다중 상속, 클로저 없이 특유의 pointer-to-member 기능 같은 것 말이다. 후대 언어에서는 저런 게 결코 도입되지 않고 있다.

본인은 다중 상속을 굳이 의도적으로 기피하면서 코딩을 하지는 않는다. 다중 상속이 필요하고 당장 편하겠다 싶으면 한 클래스에다 막 엮었다. 그 상태로 막 복잡한 pointer-to-member나 람다를 구사하면서 컴파일러를 변태적으로 괴롭히지는 않을 것이고, 컴파일러가 그냥 this 포인터 보정을 알아서 해 주는 것만 원했으니까 말이다.

하루는 ", public"라고 검색을 해서 날개셋 한글 입력기의 소스 코드 내부에도 혹시 다중 상속을 쓴 부분이 있나 찾아 봤는데.. 그래도 2017년 현재 날개셋 한글 입력기의 소스 코드에는 데이터 멤버가 존재하는 클래스를 둘 이상 동시에 상속받은 부분은 없었다. 추가적인 상속처럼 보이는 것은 COM 인터페이스 내지, 내가 콜백 함수를 대신해서 내부적으로 만들어 놓은 추상 클래스 인터페이스들이었다.

한두 번 썼을 법도 해 보이는데.. 이 정도 규모의 프로그램을 만드는 데도 실질적인 다중 상속을 사용한 부분이 없다면 그건.. 정말로 가성비 대비 불필요하게 지원할 필요는 없을 법도 해 보인다.

Posted by 사무엘

2017/12/26 08:37 2017/12/26 08:37
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1441

Trackback URL : http://moogi.new21.org/tc/trackback/1441

Leave a comment

컴퓨터 프로그램의 GUI 구성요소들 중에는 여러 아이템들을 한데 나열하는 리스트 박스(list box)라는 게 있고, 고정된 한 문장에 대해서 예/아니요, 참/거짓 여부를 지정하는 체크 박스(check box)라는 게 있다.

체크 박스는 프로그램이 고정 붙박이 형태로 제공하는 기능이나 옵션 하나에 대한 설정을 할 수 있다. 그리고 리스트 박스는 보통은 가변적인 개수의 항목들 중에 하나를 선택할 때 쓰인다.
그런데 가끔은 이 두 물건의 기능을 한데 합치고 싶은 상황이 생긴다. 리스트 박스의 각 아이템들에 대해서 1비트짜리 정보를 배당해서 선택 여부를 지정하는 것 말이다.

뭐, Windows의 리스트박스 컨트롤은 모든 아이템들에 대해서 1비트도 아니고 그냥 machine word 크기 하나로 custom 정보 data를 지정하는 기능이 있다. 또한 필요하다면 하나가 아닌 복수 개 multi-selection 모드로 동작하게 할 수 있고, 각 아이템에 대한 custom drawing도 가능하다.

하지만 딱 부러지게 아이템들 앞에 자동으로 운영체제의 check box 그림을 그려 주고 체크 박스의 리스트를 구현하는 기능 자체는 없다. 필요하면 사용자가 그걸 직접 구현해서 쓰게 여건만 만들어 놨을 뿐이다.
그래서 MFC의 경우 기존 리스트박스를 서브클래스 해서 CCheckListBox라는 걸 제공한다. owner drawing만 구현하는 것으로는 충분치 않고, space를 누른 키보드 입력과 check 버튼 주위를 누른 마우스 클릭도 감지하게 메시지 몇 개를 서브클래스 했다.

자고로 화면에 뭔가 길다란 리스트를 만들고 아이템들을 복수 선택할 수 있게 해 놓은 프로그램의 원조는 PC-Tools나 MDIR, Norton Commander, 심지어 Windows 3.x의 파일 관리자 같은 파일 관리 유틸리티이지 싶다. 복수 개의 파일을 복사하거나 삭제하는 기능을 제공해야 하니 리스트의 복수 선택 기능이 무조건 필수이기 때문이다.

그런데, selection이라는 것과 highlight 선택막대의 관계를 어떻게 보느냐에 따라서 프로그램의 동작이 달라지곤 했다. 도스용 프로그램들은 selection과 선택막대가 서로 따로 논다고 본 반면, Windows는 selection이 곧 선택막대의 연장선이라고 봤다.

그래서 Windows의 리스트박스는 화살표 키를 누르는 순간 기존 selection들이 다 사라지면서 선택막대가 움직이곤 했다. Shift+화살표로 연속된 영역을 한꺼번에 선택하는 것 말고 불연속적인 영역을 취사선택하려면 Shift+F8부터 눌러서 선택막대가 아닌 포커스 테두리가 깜빡거리는 상태로 들어간 뒤, 포커스 테두리만 움직이면서 Space로 아이템들을 선택하면 됐다.

굉장히 특이한 동작인데 Windows에서는 이게 기본이다. 기본적으로 포커스 테두리만 움직이게 하는 모드는 extended 플래그(LBS_EXTENDEDSEL)로 따로 있었다.
그에 비해 평소에는 선택막대와 selection이 다같이 움직이고 Ctrl+화살표로 포커스 테두리를 움직여서 Space로 선택하는 비교적 '직관적인 방법'은 훗날 리스트뷰 컨트롤이 도입하게 된다. 아이템을 복수 선택하는 방식은 이 두 컨트롤이 서로 호환되지 않는다.

또한, 각 아이템들에 대해 체크 플래그를 지원하는 건 아이템을 그냥 복수 선택할 수 있게 하는 것과는 UI의 관점이 다르다. 비록 내부적으로 본질적으로는 아이템별로 1비트짜리 boolean 정보를 지정한다는 점에서는 차이가 없겠지만 용도가 같지 않다는 것이다.

복수 선택은 대체로 아이템들이 진짜 가변적이고 사용자에 의해 아이템을 추가하거나 삭제까지 할 수 있는 상황에서 쓰이겠지만 체크 리스트는 그렇지 않은 경우가 대부분이다. 단순히 응용 프로그램이 제공하는 기능과 옵션이 많기 때문에 리스트 형태로 만들었을 뿐이다. 체크 리스트는 복수 선택과 달리, 선택 막대 selection과는 완전히 별개로 관리되기도 해야 할 것이고 말이다.

다음은 MFC의 CCheckListBox를 사용했던 먼 옛날 날개셋 한글 입력기 1.x의 옵션 대화상자이다.
Windows XP부터는 테마도 등장했기 때문에 지금 상황에 따라 체크 박스를 그리는 방법 역시 더 복잡해졌다.

사용자 삽입 이미지

다음은 리스트 박스가 아니라 무려 트리 컨트롤(공용 컨트롤)을 사용했던 날개셋 2.x의 옵션 대화상자의 모습이다.
Internet Explorer가 4인가 5에서부터 인터넷 고급 옵션들을 이렇게 트리 컨트롤로 구현해서 오늘날 최후의 11 버전에까지 이어져 오고 있다. 지원하는 옵션이 너무 많기 때문이다. 본인 역시 이 스타일을 따라해 보았다.

사용자 삽입 이미지

공용 컨트롤들은 owner-draw 안 쓰고도 자체적으로 아이템별 비트맵을 지정할 수 있으며, 더구나 트리 컨트롤은 아이템들을 카테고리별로 분류도 할 수 있으니 더욱 좋다.
날개셋 3과 그 이후부터는 이들 옵션이 상당수가 오토마타와 글쇠의 수식, 별도의 카테고리 옵션 등으로 떨어져나간 관계로, 저렇게 트리 컨트롤까지 써야 할 정도로 긴 옵션 리스트를 만들 일이 없어졌다.

사실, 트리 컨트롤은 IE 4 타이밍에서 TVS_CHECKBOXES라는 스타일이 추가되기도 했다. 기존 이미지 스타일을 활용하는 게 아니라 그건 놔두고 옆에 체크 박스를 별도로 추가해 주는 형태이다.

트리 컨트롤에서 체크 박스는 설치 프로그램에서 어떤 소프트웨어 제품의 구성요소들을 계층 구조로 나열한 뒤 설치· 제거할 부분을 선택받는 부분에서 유용하게 쓰일 듯하다. 이런 데서는 자식 노드가 하나라도 선택되면 부모 노드들은 중간 상태로 바뀌고, 부모 노드를 선택하거나 해제하면 자식들도 한꺼번에 선택이나 해제되는 동작이 필요할 것이다.

하지만 트리 컨트롤의 체크박스 기능은 깔끔하게 구현되지 않아서 잡음이 많다. 스타일을 윈도우를 생성한 뒤에 SetWindowLongPtr로 런타임 때, 그리고 아이템을 하나라도 추가하기 전에 적절한 타이밍에만 지정할 수 있다.
레이먼드 챈 아저씨는 저건 차라리 스타일이 아니라 메시지 형태로 구현하는 게 더 나았을 정도라면서 API 설계 구조를 비판한 바 있다. (☞ 링크) 실제로 콤보 박스의 extended UI 여부는 스타일이 적절해 보임에도 불구하고 덩그러니 CB_SETEXTENDEDUI라는 메시지를 통해 지정하게 돼 있다.

한편, 트리 컨트롤은 처음 도입됐을 때부터 지금까지 체크 박스와는 달리, '복수 선택'은 지원하지 않고 있는 것으로 유명하다. 리스트뷰 컨트롤처럼 아이콘을 Shift 및 Ctrl을 이용하여 복수 선택할 수 있지 않다는 뜻이다.
Windows 운영체제는 탐색기에서 볼 수 있듯이, UI 디자인 철학이 "트리로는 분야를 하나 선택만 하고", "리스트에다가 그 분야에 속하는 아이템들을 출력한 뒤 복수 선택해서 지지고 볶는다" 형태이긴 했다.

계층 구조를 나타낼 수 있는 복잡한 UI 컨트롤에서 복수 선택까지 가능하면 프로그램의 기능이 매우 복잡해지며, 리스트도 아니고 트리 컨트롤이 굳이 복수 선택까지 가능해야 할 일은 매우 드문 것도 사실이다.
하지만 그럼에도 불구하고 당장 Visual Studio IDE부터가 클래스· 리소스· 솔루션 뷰의 트리 목록이 진작부터 복수 선택을 지원한다. 걔들은 4.0 시절부터 공용 컨트롤 없이 진작부터 자체 구현 트리 컨트롤을 써 왔기 때문이다.

끝으로, 체크와 다중 선택을 짬뽕한 듯한 기괴한 UI가 Windows의 역사상 단 한 번, 8의 리스트뷰 컨트롤에서 잠시 등장한 적이 있었다.
아이템의 좌측 상단 같은 특정 부위를 마우스로 가리키고 있으면 체크 박스가 나타나고, 그걸 클릭하면 아이템을 복수 선택할 수 있었던 것이다.
보통 아이템을 클릭하면 기존 selection들은 다 없어지고 그것'만' 선택되곤 하는데, 체크 박스를 선택하면 기존 selection들을 놔두고 그걸 추가로 선택할 수 있었다.

사용자 삽입 이미지사용자 삽입 이미지

그 당시엔 아마 터치 장치를 염두에 두고.. Ctrl/Shift+클릭이나 드래그 없이 클릭만으로도 아이템들을 복수 선택할 수 있게 고심 끝에 저런 기능을 넣었던 듯하다.
하지만 반응이 좋지 않았는지, 이런 기능은 내 기억이 맞다면 Windows 8.1에서 곧장 없어졌고 다시 등장하지 않았다. 하긴, Windows 8은 저 정도면 약과이지, 아예 시작 버튼을 없애 버렸을 정도로 엄청 과격한 모험을 한 물건이기도 했으니까.

이렇듯, 리스트 박스, 리스트뷰 컨트롤, 트리 컨트롤을 두고 아이템의 복수 선택 및 체크 선택과 관련하여 할 말이 무척 많은 걸 알 수 있다. 복수 선택은 단수 선택만치 일상적으로 자주 쓰이는 기능은 아닐 뿐더러 어떤 방식으로 구현할지 동작의 customization의 폭도 넓은 편이다. 그래서 운영체제의 GUI가 곧장 직통으로 지원하지 않고 구현을 사용자에게 맡기는 편이었던 것 같다.

Posted by 사무엘

2017/12/20 08:36 2017/12/20 08:36
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1439

Trackback URL : http://moogi.new21.org/tc/trackback/1439

Comments List

  1. 방문자 2017/12/21 20:01 # M/D Reply Permalink

    Windows 10에서도 탐색기에서 체크박스의 표출 여부가 옵션으로 남아 있습니다. 8.1에서 기본값이 바뀌었던 걸까요?

    1. 사무엘 2017/12/21 22:16 # M/D Permalink

      아하~ '기본적으로 꺼져 있는 상태'가 됐을 뿐, 그 UI가 완전히 없어진 건 아니었군요.
      옵션들 제~~일 밑에.. "확인란을 사용하여 항목 선택" (이것도 트리 컨트롤 기반 체크 리스트이군요~)...
      저는 처음 알게 됐습니다. 그렇다면 8.1에서 기본값이 바뀐 것이지 싶습니다. ^^

Leave a comment

오늘은 기초 전산학/컴공 상식을 좀 복습해 보고자 한다.

※ 지금과 같은 컴퓨터의 근간이 갖춰진 과정

1. 순 전자식

이로써 인간이 발명한 계산 기계는 엔진 달린 주판 수준을 넘어서 자신의 모든 내부 상태를 전자 신호만으로 광속으로 표현할 수 있게 됐다. 에니악이 순 전자식 컴퓨터로서는 거의 최초 원조라 여겨진다. 이거 이후로 컴퓨터는 진공관, 트랜지스터, IC, (V)LSI 회로 순으로 그야말로 엄청난 공간 워프를 거듭하면서 작아지고 빨라지기 시작했다.

전자식이 아니라면? 컴퓨터도 엔진이나 모터가 달린 채로 만들어졌을 것이다. 19세기에 영국의 수학자 찰스 배비지는 '프로그래밍 가능한 보편적인 계산 기계'인 '해석 기관'이라는 걸 제안하고 만들려 했다. 시대를 아득히 앞서 간 물건이었는데, 그걸 가동하기 위해서 무려 증기 기관을 접목할 생각까지 했었다. 지금 같은 눈부신 전자 공학 기술이 없던 시절이니 당연히 기계식밖에 선택의 여지가 없었던 것이다.

그리고 1940년대 초에 에니악 이전에 등장했던 '하버드 마크 1'이라는 기계는 '전자식 계산기'라기보다는 '전동식 계산기'에 더 가까웠다. 복잡한 배선과 릴레이뿐만 아니라 4마력짜리 모터가 달려 있었다. 이건 냉각팬 모터가 아니며 하드디스크 같은 기계식 보조 기억장치용 모터도 아니고, CPU의 실제 계산 동작을 위한 모터였다..;;

2. 2진법 기반

사람이나 열 손가락이 달려 있으니 10진법이 편하지, 기계는 단순한 2진법이 더 편하다. 컴퓨터가 전자식으로 바뀐 뒤부터는 그 차이가 더욱 두드러졌다.
하지만 극초창기에는 숫자 진법을 변환하는 것조차 쉬운 작업이 아니었고, 정수가 아닌 부동소수점으로 가면 숫자를 표현하는 난이도가 더 올라갔다. 더구나 컴퓨터는 처음부터 포탄 탄도 예측, 풍동 실험, 일기예보 시뮬, 모의 핵실험처럼 천상 실수 연산이 잔뜩 필요한 과학 영역에서 쓰였다.

그러니 에니악 같은 컴퓨터는 10진법 기반으로 만들어졌다. 4비트를 한 자리로 묶어서 0~9를 표현하는 BCD 코드 기반이었지 싶다. 하지만 10진법 숫자를 처리하기 위해서 어차피 2진법 기반의 각종 논리 연산 회로를 구현해야 했을 것이고, 후대의 컴퓨터들은 얼마 가지 않아 native 2진법 기반으로 다 바뀌었다.

3. 튜링 완전

프로그램이 하드코딩된 고정된 변수가 아니라 메모리에 기록된 값을 토대로 또 임의의 위치의 메모리를 읽고 쓸 수 있고(= 배열, 포인터 등을 이용한 복합 자료형. 공간 확장),
런타임 때 결정되는 값의 조건에 따라 반복과 분기가 가능하다면 (= 시간 확장)
그런 계산 모델은 Turing-complete하다고 여겨진다. 즉, 단순 계산기를 넘어 뭔가 본격적으로 프로그래밍이 가능해진다는 것이다.
그 열악한 에니악조차도 설계 구조는 튜링 완전한 형태였다고 한다.

4. 프로그램 내장형

컴퓨터에게 시킬 작업을 변경하기 위해 매번 회로 배선을 뜯어고치고 바꾸는 게 아니라, 한 메모리에서 코드와 데이터를 일체로 내장시킨다. 이 개념까지 정립됨으로써 비로소 컴퓨터는 정말 유연하고 무한한 확장성을 지닌 물건으로 변모했으며, 컴퓨터에서 하드웨어와 별개로 '소프트웨어'라는 것이 존재할 수 있게 됐다.
또한, 메모리가 컴퓨터의 성능에서 차지하는 비중이 아주 커졌다. 프로그램을 메모리에다 처음으로 입력시킬 때는 과거엔 천공 카드 같은 불편한 매체가 쓰였지만, 나중에는 더 간편한 키보드로 대체됐다.

저 아이템들 하나하나가 그야말로 병아리가 알을 깨고 세상으로 나오는 급의 대격변이고 혁신이었다.
인류 역사상 이런 네 조건을 모두 만족하는 컴퓨터가 발명되고 등장한 지 아직 100년이 채 지나지 않았다. 자동차와 비행기의 역사는 100년을 넘었지만 컴퓨터는 아직 그렇지 않고 오히려 2차 세계 대전 이후 냉전 때부터 발전해 왔다.
그 짧은 기간 동안 컴퓨터가 인류 역사상 유례가 없이 세상을 바꿔 놓은 걸 보면.. 정말 전율이 느껴지지 않을 수 없다.

※ 메모리 계층

컴퓨터는 모름지기 정보를 다루는 기계이다. 그리고 앞서 언급했던 프로그램 내장 방식의 특성상, (1) 실행할 코드와 (2) 그 코드가 처리할 데이터가 모두 메모리에 담겨 있어야 한다. 쉽게 말해 정보를 담을 그릇이 필요하다.
그런데 컴퓨터가 취급하는 메모리라는 게 여러 종류가 있고, 이들은 속도와 용량, 단위 용량당 가격이 극단적으로 반비례하는 관계이다. 그렇기 때문에 종류별로 일종의 '메모리 계층'이 존재한다.

1. 레지스터(수십~수백 byte)

CPU 구성요소의 일부이다. 당연히 CPU 차원에서 최고속으로 직통으로 값을 읽고 쓸 수 있다.
현재 프로그램이 실행되고 있는 지점(메모리 위치), 수만 번씩 실행되는 for 문 loop 변수, C++ 함수의 경우 this 포인터, 산술 연산 명령에 쓰이는 피연산자와 연산 결과 같은 정~말 원초적인 값들이 이곳에 저장된다.
실행되는 스레드의 context가 바뀌면 레지스터의 값도 자기 상태의 것으로 바뀐다.

2. 캐시 메모리(수백 KB~수 MB)

CPU 자체는 아니지만 여전히 CPU의 연장선 격이며 접근 속도가 매우 빠르다. CPU가 사람 두뇌이고 레지스터가 손의 손가락이라면 캐시는 의수 정도는 된다.
얘는 CPU 속도와 메모리 속도의 격차가 커지면서 메모리로 인한 병목을 줄이기 위한 버퍼 차원에서 도입되었다.

캐시도 레벨 1, 레벨 2로 나뉘긴 하는데, 인텔 x86 CPU에서 제일 원초적인 L1 캐시는 80486 때 8K짜리가 도입된 것이 최초이다. 반대로 펜티엄 2이 나왔던 시절에 셀러론 프로세서는 L2 캐시를 제거하거나 용량을 팍 줄인 저가형 모델이었다.

3. 일반 메모리(수십 GB)

CPU의 외부에 있기 때문에 위의 것들보다는 느리지만, 그래도 보조 기억장치보다는 여전히 훨씬 빠르다. 이들 메모리는 전원이 끊어지면 내용이 다 지워지는 휘발성 메모리이다. 이제 신체 접근성으로 치면 의수를 넘어서 핸들과 버튼으로 따로 조작하는 로봇 팔과 비슷하다고 볼 수 있겠다.

4. 하드디스크(수 TB)

디스크부터는 보조 기억장치이기 때문에 이건 CPU의 명령만으로는 직접 접근조차 할 수 없다. 운영체제라는 소프트웨어가 구현해 놓은 파일 시스템에다 해당 운영체제 API를 통해 요청해야만 데이터를 읽고 쓸 수 있다. 파일 시스템은 열고 닫는 상태를 따로 보관하고 관리해야 하며, 프로그램의 입장에서는 여는 작업이 실패하는 상황에 대한 대비가 필요하다.
사람으로 비유하면 내 손으로 뭔가를 직접 조작하는 게 아니라, 남에게 말로 부탁을 해서 간접적으로 뭔가를 요청하고 움직이는 형태가 된다.

그 대신 보조 기억장치는 전원이 끊어진 뒤에도 기록을 남기고 보존할 수 있다. persistency를 보장하려다 보니, 하드디스크는 컴퓨터에서 전자식이 아닌 기계식으로 동작하는 얼마 안 되는 부품 중 하나가 돼 있다. 플래시 메모리는 '일반 메모리'의 성격에 더 근접해 있는 기억장치이지만, 가격과 용량 문제 때문에 하드디스크를 완전히 대체하는 구도는 못 된다.

캐시 메모리에서 캐시 미스가 나서 더 느린 일반 메모리까지 내려가서 데이터를 가져오는 게, 아래의 운영체제의 가상 메모리 체계에서 페이지 폴트가 발생해서 디스크의 페이지 파일에서 데이터를 가져오는 것과 비슷한 구도이다. 메모리 공간 자체가 CPU의 일부는 아니지만, 보호 모드 가상 메모리 구현을 위한 주소 변환은 CPU 차원의 지원을 따로 받아서 이뤄진다.

메모리가 비싸고 귀하고 부족하던 옛날에는 가상 메모리라는 게 디스크를 메모리 보충분처럼 사용하는 메커니즘이기도 했다. 비록 속도는 안드로메다로 가 버리지만, 그래도 아예 안 돌아가는 것보다는 나으니 better late than never이다. 요즘 운영체제들은 memory mapped file이라고 디스크를 반쯤 메모리 다루듯이 포인터로 접근시켜 주는 API를 제공하는데, 가상 메모리를 구현하면서 내부적으로 구현된 기능을 사용자도 적절하게 활용하라고 떼어 준 것에 가깝다.

또한, 가상 메모리와는 별개 개념으로.. 레지스터와 메모리 사이에 '캐시 메모리'가 있듯이, 메모리와 디스크 사이에 '디스크 캐시'라는 계층이 존재한다. 이게 잡아먹는 메모리 양이 만만찮지만 도스 시절에 smartdrv 유틸로 수백 KB~2MB 남짓만 캐시를 잡았어도 체감 성능 향상 효과가 장난이 아니었다. 이거 없이 곧이곧대로 찔끔찔끔 디스크에 접근해서는 오늘날의 방대한 컴퓨터 시스템이 돌아가질 못한다. 그만치 메모리와 디스크 사이의 속도 격차 병목이 엄청나다는 뜻이다.

5. 자기 테이프(수백 TB~수 PB)

아주 극단적인 보조 기억장치이다. 느리고 랜덤(임의 위치) 접근이 안 된다는 엄청난 단점이 있지만, 용량이 가히 압도적이고 가격이 저렴하다. 그렇기 때문에 서버 전체 내지 매일 생성되는 방송국 동영상 같은 엄청난 양의 데이터를 오로지 백업· 보존만 할 목적으로 일부 연구소나 기업에서 테이프가 여전히 사용되고 있다. 마치 국제 화물 운송에서 선박이 차지하는 위상(느리지만 엄청난 수송량)과 비슷하고, 프린터계에서 도트 프린터의 먹끈 카트리지(원시적이지만 타의 추종을 불허하는 저렴함)와 비슷하다.

메모리야 컴퓨터 프로그램들이 맨날 하는 짓이 저걸 건드리는 것이고, 보조 기억장치는 파일을 읽고 쓰는 운영체제 API를 통해 사용 가능하다.
레지스터의 경우, C/C++ 언어에는 특정 정수 변수를 가능한 한 저기에 얹어 달라고 컴파일러에게 요청하는 register이라는 키워드가 있다. 함수에 inline이 있다면 변수는 저게 있는 셈이다. for문 loop 변수가 레지스터에 올라가면 좋다.
물론, inline 함수는 재귀호출을 해서는 안 되며, 레지스터 등재 변수는 주소 참조(단항 & 연산자)를 해서는 안 된다.

이렇게 타 메모리나 디스크나 레지스터와는 달리, 캐시 메모리만은 적중률을 올리기 위해 소프트웨어가 직접 접근하고 개입하는 방법이 딱히 존재하지 않는다. 멀티코어 병렬화를 위해서는 CPU 직통 명령인 인트린식 같은 것도 있는데 캐시는 활용 방식이 소프트웨어가 아닌 오로지 CPU의 재량인가 보다.
이렇게 존재감이 없음에도 불구하고 캐시 메모리의 양과 성능은 클럭 속도 다음으로 컴의 속도에 직접적인 영향을 끼치는 요인이다.

※ 인텔 x86

인텔 x86은 전세계의 PC 시장을 완전히 석권한 기계어 아키텍처이다. 애플 맥 진영이 x86으로 갈아탄 지 이미 10년이 넘었고, 슈퍼컴퓨터조차도 Cray 같은 슈퍼컴 전용 아키텍처가 진작에 다 망하고 x86이 코어 수를 늘려서 야금야금 파고들고 있다.

하지만 x86은 CPU를 만들던 기술과 방법론이 지금과 같지 않던 초창기, 특히 메모리 가격이 왕창 비싸던 시절을 기준으로 기반이 설계되었으며 16, 32, 64비트로 올라가는 과정에서도 하위 호환성을 잘 유지하고 있다. 그래서 넘사벽급의 범용성과 시장 경쟁력은 확보했지만, 내부 구조가 갈수록 왕창 지저분해지고 스마트폰용 ARM 같은 후대의 최신 CPU들의 유행과는 영 동떨어진 형태가 됐다.

  • 범용 레지스터 수가 유난히 매우 적음. R## 이렇게 수십 개씩 번호가 붙는 게 아니라 EAX EDX ESI EBP 등 꼴랑 8개로 끝인 건 x86이 예외적이고 특이하기 때문이다. 함수에다가 매개변수를 올리는 주 방식도 x86은 당연히 레지스터가 아닌 스택 기반이다. 이 때문에 컴파일러 백 엔드를 개발하는 방법론이 x86 타겟 계열과 타 아키텍처 계열은 서로 완전히 다르며, x86은 오늘날 컴공과에서 컴파일러 제작 교육용 교보재로 쓰이기에는 영 좋지 못한 타겟 아키텍처이다.
  • 메모리를 조밀하고 compact하게 쓰는 대신에, 디코딩이 복잡하고 더 어려운 CISC 가변 길이 방식으로 명령어를 기술한다. 한 인스트럭션으로 연산에다 메모리 조작까지 몽땅.. 이런 식으로 많은 지시를 함축하고 있는 편이다. 자동차 엔진으로 치면 회전수가 낮은 대신 실린더의 스트로크가 긴 디젤처럼..
  • machine word align이 맞지 않은 메모리 주소의 값을 fetch하는 것을 굉장한 비효율(여러 클럭수 소모)을 감수하고라도 CPU 차원에서 아무 문제 없이 잘 처리해 준다. 요즘 CPU 같았으면 그냥 예외 날리고 끝이었을 텐데.. 이 역시 메모리를 아끼기 위한 조치이다.

레지스터가 부족하면 나중에라도 더 보충하면 되지 않냐고?
레지스터는 추가로 더 꽂기만 하면 되는 메모리가 아니라 CPU 그 자체이다. 그걸 뒤늦게 확장한다는 건 CPU의 아키텍처, 세부 설계와 생산 라인이 다 바뀐다는 뜻이다. 컴파일러도 그에 맞춰 바뀌고 프로그램도 몽땅 다시 빌드되어야 추가된 레지스터 덕을 볼 수 있다. 사람으로 치면 가방 크기를 더 키우는 게 아니라 생물의 유전자 차원에서 손의 크기, 손가락 개수를 더 키우고 늘리는 것과 같은 엄청난 변화이다.

x86이 너무 지저분하다는 건 제조사인 인텔도 누구보다 잘 알고 있었기 때문에 과거 2000년대 초, 64비트 CPU를 내놓는 김에 애플처럼 하위 호환성을 싹 버리고 현대적인 디자인 트렌드를 따라 과감한 물갈이를 하려 했다.
마소 역시 새천년 Windows 2000에 맞춰 64비트 에디션을 당당히 내놓으려고 벼르고 있었다. Windows SDK 헤더 파일에서 INT_PTR, INT64 이런 typedef가 등장하고 GetWindowLong이 GetWindowLongPtr로 감싸진 게 이 시기의 준비 작업이었다.

하지만 모두의 예상을 깨고 IA64 Itanium라는 새 아키텍처는 CPU와 컴파일러 개발이 제대로 되지 않고 호환성도 안습했기 때문에 철저히 망하고 실패했다.
결국 지금은 기존 x86을 그대로 수용하면서 Itanium보다 훨씬 더 현실과 절충한 x86-64라는 다른 아키텍처를 기반으로 64비트 컴퓨터가 쓰이게 됐다. 이 아키텍처는 인텔이 아니라 경쟁사인 AMD가 최초로 개발했다.

Windows 2000은 과거 NT 3~4 시절에 지원했던 한물 간 구형 CPU들의 지원은 다 끊었고(Alpha, PowerPC, MIPS 등), IA64는 베이퍼웨어이고, 지금 같은 ARM이나 x64는 아직 안 나왔다 보니 NT로서는 이례적으로 사실상 x86 전용으로만 출시되어야 했다.

그런데.. 인텔 x86이 저렇게 메모리 아끼려고 CPU 본연의 효율까지 희생하면서 헝그리하게 설계된 건 과거 PC의 역사를 살펴보면 충분히 이해가 된다.
32비트 80386 CPU가 이미 1985년에 개발됐는데도 Windows NT, OS/2 같은 이상적인 32비트 운영체제의 도입과 보편화가 10년 가까이 너무 늦었고 Windows 9x 같은 요물이 몇 년간 쓰여야 했던 이유는 32비트 가상 메모리를 운용하고도 남을 정도로 컴의 메모리가 충분치(못해도 수~십수 MB) 못했기 때문이다. (CPU 말고 그래픽 카드는 1987년에 VGA가 개발되자 못해도 2~3년 안으로 프로그램들이 다 지원하기 시작함)

64비트로 넘어갈 때도 마찬가지다. IA64가 개발되던 1990년대 말엔 아직 가정용 컴의 메모리는 100~200MB대에 불과했다. 32비트를 벗어나야 할 이유가 전혀 없었다. 64비트 CPU는 대용량 데이터 처리 분야에서 속도가 좀 더 올라갈지는 모르지만, 같은 명령과 데이터를 수행하더라도 메모리 소모가 훨씬 더 많아지는 건 피할 수 없었다. 이러니 가정용 PC에서 64비트의 대중화는 Windows 2000/XP 시기는 어림도 없고, 본격적으로 램 용량이 4GB를 넘어선 2000년대 후반 Vista/7급은 돼서야 이뤄지게 됐다.

Posted by 사무엘

2017/12/11 08:31 2017/12/11 08:31
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1436

Trackback URL : http://moogi.new21.org/tc/trackback/1436

Comments List

  1. 비밀방문자 2017/12/11 18:28 # M/D Reply Permalink

    관리자만 볼 수 있는 댓글입니다.

    1. 사무엘 2017/12/13 00:42 # M/D Permalink

      우와.. 펫졸드 아저씨가 Windows 프로그래밍 책만 썼는 줄 알았는데,
      역시 올드 타이머 프로그래머답게 컴퓨터라는 기계가 밑바닥에서 처음부터 만들어져 온 과정을 고찰한 입문서도 몇 년 전에 썼구나?
      아주 흥미로운 내용일 것 같고 나도 책 내용이 궁금해진다? 우와아앙??

  2. 경헌 2017/12/12 19:17 # M/D Reply Permalink

    윗 댓글의 책이 뭘지 저도 알 것 같군요 ㅎㅎ
    오랜만에 생각난 김에 다시 한 번 읽어봐야 겠습니다.

    레지스터 - 캐시 - 메모리 - 디스크의 메모리 계층에서, 반드시 가격만이 용량을 결정한 요소인가 하는 의심이 들더군요.
    레지스터나 캐시의 경우에는 필요 이상으로 크게 만들면 어쩔 수 없이 속도가 떨어지게 되는 구조상 문제도 있지 않을까 싶어요.

    그리고 최근 들은 세미나에서, 미래의 아키텍처에서는 3. 일반 메모리와 4. 하드디스크의 경계가 희미해져 갈 것이라는 이야기를 들었습니다. SSD 에서 시작해서 비휘발성 메모리가 점점 싸고 빨라지고 있기 때문인데, 굳이 프로그램을 휘발성 고속 메모리에 올리고 실행할 필요 없을 정도로 빨라지고 나면 OS 수준에서 많은 것들이 달라지게 되지 않을까 이야기 하더군요.

    1. 사무엘 2017/12/13 00:49 # M/D Permalink

      하하! 경헌 님도 대단하십니다. ^^
      레지스터와 캐시는 제가 그 정도 본질까지 논할 수 있는 실력이 못 됩니다만, 일반 메모리와 하드디스크는.. 흥미롭네요. 일반 메모리의 속도와, 하드디스크의 비휘발성에서 일장일단이 아니라 두 토끼를 다 잡는 메모리가 나온다면 두 계층 구분이 당연히 사라지겠죠.
      SSD 때문에 전통적인 조각 모음이라는 게 의미가 없어졌듯이, 그런 미래의 모델에서는 memory-mapped file이라든가 디스크 캐시라는 것도 개념이 재정립될 듯합니다.

Leave a comment

1. 오버로딩과 오버라이딩의 관계

요렇게 Func라는 함수를 2개로 오버로딩한 A라는 C++ 클래스가 있다고 치자. 그리고 B는 A로부터 상속을 받았다.

class A {
public:
    virtual void Func(int x) {}
    void Func(int x, int y) {}
};

class B: public A { (...) };

B *ptr = new B;

그렇다면 B는 일반적으로야 너무 당연하게도 A가 갖고 있던 Func라는 함수에 곧장 접근 가능하다. ptr->Func라고 치면 요즘 개발툴은 사용 가능한 함수 후보 2개를 자동으로 찾아서 제시까지 한다.

그런데 B가 Func 중 하나를 오버라이드 하면 사정이 달라진다.

class B: public A {
public:
    void Func(int x) {}
    (...)
};

이전에는 Func라는 이름은 전적으로 부모 클래스의 전유물로 간주되었지만, 오버로딩 형태 중 하나라도 파생 클래스가 오버라이드 한다면 이 이름은 부모의 것과 자식의 것을 구분해야 할 필요가 생기더라.
이제 ptr->Func를 하면 오로지 B에서 갖고 있는 것 하나만 제시된다. 이제는 ptr->Func(1, 5)를 한다고 해서 부모 클래스가 갖고 있는 인자 2개짜리 함수가 자동으로 정적 바인딩 되지 않는다. ptr->A::Func(1, 5)라고 써 줘야 된다.

본인은 이런 기초적인 동작을 보고도 내 직감과는 일치하지 않는 걸 보고 약간 놀랐다. 마치 함수의 리턴값만으로는 오버로딩이 되지 않는 것처럼 저것도 C++이 제공하는 유도리의 한계인가 싶다.
내 의도는 동일한 이름의 함수를 인자의 형태를 달리하여 가상 버전과 비가상 버전으로 둘 다 만들어 놓는 것이었다. 비가상 함수는 부모 클래스 한 곳에다가만 정의해 놓은 뒤, 받은 인자를 보정하여 가상 함수 버전을 호출해 주는 고정된 역할을 한다.

그런데 이름을 동일하게 해 놓으니 파생 클래스에서는 직통으로 호출할 수가 없어서 결국 이름을 다른 걸로 바꾸게 됐다. 이런 것도 마치 생성자나 소멸자에서 가상 함수를 호출하려 한 것과 비슷한 차원의 디자인 실수가 아닌가 싶다.

2. pointer-to-member의 우선순위

C++에서 pointer-to-member 연산자인 .* 내지 ->*는 데이터 멤버를 참조할 때는 별 문제될 게 없지만, 함수를 참조해서 호출할 때는 앞부분을 따로 괄호로 싸야 한다. 즉, (obj.*ptrfn)(a, b) 내지, (pObj->*ptrfn)(x, y)처럼 말이다.
괄호 없이 pObj->*ptrfn(x, y) 이런 식으로 바로 호출이 가능하면 더 깔끔하고 자연스러울 것 같은데, 문법이 왜 이렇게 만들어지게 됐을까?

표면적인 이유는 우선순위 때문이다. 일반적인 구조체 멤버 참조 연산자인 .와 -> 그리고 함수 호출을 나타내는 괄호 연산자는 모두 우선순위가 최상이며, 좌에서 우로 결합한다. 그렇기 때문에 a.b()는 토큰들이 아주 직관적으로 순서대로 해석된다.
그러나 pointer-to-member (이하 P2M) 연산자들은 곱셈 같은 이항 산술 연산자보다만 우선순위가 높을 뿐, 다른 단항 연산자나 괄호 연산자보다 우선순위가 낮다. 그렇게 자신만의 독자적인 우선순위가 있다.

이런 구조 하에서 괄호 없이 a->*b(x,y)라고 쓰면.. ->* 뒤의 b(x,y)가 먼저 해석된다. b는 뭔가 a에 적용 가능한 P2M을 되돌리는 함수가 되는 셈이다. 하지만 P2M 자체도 쓰임이 굉장히 드문 물건인데 하물며 P2M을 되돌리는 함수라..? 일상생활에서 좀체 볼 일이 없을 수밖에 없다. 그러니 저렇게 a->*b를 괄호로 싸지 않고 곧장 함수 호출을 하는 표현도 볼 일이 없어진다.

만약 P2M 연산자의 우선순위가 일반적인 . -> 의 순위와 대등하다면 a->*b(x,y)만으로 (a->*b)(x,y)의 효과를 낼 수 있다. 아까처럼 b 자체가 따로 P2M을 되돌리는 함수라면, 그쪽을 a->*(b(x,y)) 라고 괄호로 싸야 할 것이다.

그런데 b가 데이터가 아닌 함수 P2M을 되돌리는 함수이고, 리턴값으로 또 함수 호출을 해야 한다면 어떻게 될까?
저렇게 가상의 우선순위 체계에서는 a->*(b(x,y))(X,Y)와 같은 형태가 된다.
그러나 지금의 우선순위 체계로는 (a->*b(x,y))(X,Y)가 된다. 이렇게 비교를 하니 아무래도 b만 괄호로 싸는 것보다는 a까지 다같이 괄호로 싸는 형태가 그나마 더 자연스러워 보인다.

요컨대 . ->는 오른쪽 피연산자로는 끽해야 고정된 멤버 이름밖에 오지 못한다. 임의의 변수, 상수, 값이 올 수 없다. 성격이 ::와 비슷하며, 애초에 C++ 말고 오늘날의 다른 프로그래밍 언어들은 아예 . -> ::를 전부 구분 없이 . 하나로 간소화하는 게 트렌드일 정도이다. 오른쪽 피연산자 자체에 함수 호출이 있는지, 전체 결과값을 또 함수로 호출하는지 그런 걸 구분할 일은 없다.

그 반면, .* ->*는 생긴 건 단순 멤버 식별 연산자와 비슷하게 생겼어도 피연산자로는 사실상 아무 값이나 올 수 있다. 그렇기 때문에 뒷부분에 함수 호출 () 파트가 중구난방으로 나열되는 일을 막으려면 P2M은 . ->, 그리고 ()와 동일한 우선순위를 부여해서는 안 된다는 결론이 도출된다.

(a->*b)(x,y)에서 a와 b를 싸는 괄호에는 이런 사연이 숨어 있다. 그래서 얘들은 기존 연산자들보다 우선순위가 한 단계 낮아진 것이지 싶다. 클래스에서 함수 포인터를 되돌리는 operator 함수를 선언할 때 발생하는 다소 난감한 상황과 비슷하다. 저것도 결국은 정석적으로는 안 되고 typedef나 decltype의 도움을 받아야만 선언 가능하니 말이다.

파스칼은 비트 연산자가 논리 연산자의 역할까지 하고 있고 얘가 C/C++과는 반대로 산술 연산자보다 우선순위가 높다. 그렇기 때문에 if 문 안의 (A=B) and (C>5) 이런 항들을 일일이 전부 괄호로 싸야 해서 일면 불편하다. C++의 P2M 연산자의 우선순위는 마치 이런 사연을 보는 것 같기도 하다.

3. MFC와 C 라이브러리의 충돌

C/C++에는 빌드 과정에서 컴파일 에러뿐만 아니라, 현대의 언어에서는 찾기 힘든 개념인 링크 에러라는 게 있다.
이게 단순히 '요 명칭을(주로 함수) 선언만 해 놓고 정의를 안 했네요' 같은 간단한 것만 있으면 세상이 지금보다 훨씬 더 아름다워 보이겠지만, 현실은 그렇지 않다.

C/C++은 바이너리 인터페이스 수준에서 파편화가 매우 심한 걸로 악명높은 언어이다. C++ 함수 인자의 decoration은 말할 것도 없고, 당장 언어가 기본으로 제공하는 C/C++ 표준 라이브러리부터가 그러하다. 디버그/릴리즈, 32/64비트 같은 거야 섞일 일이 거의 없을 정도로 완전히 다른 configuration이니까 그렇다 치더라도 static/DLL, 컴파일러의 제조사와 제품 버전까지도.. 그냥 전부 제각기 따로 논다고 봐야 한다.

표준 라이브러리에 malloc, qsort 같은 영원불변의 간단한 물건만 있는 건 아니기 때문에 말이다. 그러니 다양한 출처에서 빌드된 라이브리러들을 한데 엮다 보면 별의별 링크 에러를 겪을 수 있다.
그래서 컴파일러를 Visual C++로 한정한다 하더라도, 대표적으로 MFC와 C 라이브러리(CRT)부터가 특정 상황에서 서로 부딪칠 수 있다.

MFC와 CRT는 구조적으로 둘 다 DLL 형태로 쓰거나 둘 다 static 링크하는 것만이 가능하다.
그런데 DLL 링크를 할 때는 괜찮은데 static 링크를 하다 보면 가끔 operator new / operator delete라는 메모리 할당/해제 함수가 MFC에도 들어있고 CRT에도 들어있다고.. 심벌 중복 정의라는 LNK2005 에러가 뜬다.

본인의 경우는 MFC를 사용하는 C++ 프로젝트에다가 precompiled header를 사용하지 않는 타 C 코드를 프로젝트에다 넣은 채로 MFC/CRT는 static 형태로 빌드를 시도했을 때 이런 상황에 놓이곤 했다.
operator new/delete 나부랭이야 내가 짠 코드도 아닌데 저 충돌 문제를 도대체 어떻게 해결하면 좋을까..?

이건 그래도 많이 알려지고 유명한 문제인지라 간단히 구글링만 하면 해결 방법이 수십 페이지씩 쭈루룩 뜬다.
/NODEFAULTLIB 옵션을 줘서 링커가 라이브러리들을 암시적으로 자동 공급하지 않게 하고, MFC의 static 링크용 라이브러리인 uafxcw.lib를 다른 라이브러리들보다 먼저 링크되게 하면 된다.

예전에 마소에서 제공했던 Windows 9x 유니코드 API 호환 layer인 unicows 라이브러리를 사용할 때도 링커 옵션을 비슷하게 특이하게 고쳐야 했던 걸로 기억한다. kernel32, user32 같은 통상적인 라이브러리보다 unicows가 먼저 공급 되어야 Windows API 호출이 훅킹 DLL로 갈 테니까 말이다.

아무튼 C/C++은 이런 디테일까지 신경 써야 하는 피곤한 언어이긴 하다. Visual C++의 차기 버전에서는 이런 문제는 자동으로 충돌을 감지하고 해결했으면 좋겠다.

4. 맥용 swscanf의 꼬장

표준 C 함수밖에 쓰지 않은 멀쩡한 x64용 C 코드가 Windows에서는 잘 돌아가던 것이 맥에서는 제대로 동작하지 않았다.
이 경우 원인은 대부분 사소한 곳에 있었다. 파일을 읽고 쓰는 곳에 long이 들어가 있는 게 대표적인 예다. Windows는 long도 int와 동급의 32비트로 보지만 맥에서는 이걸 64비트로 키웠기 때문이다. C/C++은 long도 그렇고 wchar_t도 그렇고.. 파편화가 너무 심하다..;;

단순히 기본 타입의 크기에 대해서는 본인이 예전에도 언급한 바 있다. 그것 말고 최근에는 또 다른 괴상한 사례를 발견했다.
long 문제와도 무관하고 도대체 안 돌아갈 이유가 전혀 없는 코드에서 오류가 발생하고 있었다. 어디에서부터 변수에 잘못된 값이 들어와 있는지를 추적해 보니 문제는 swscanf이었다. wchar_t 크기쯤이야 이미 감안하고 보정을 다 했기 때문에 문제될 여지가 없었다.

"설마 이게 문제이겠나" 싶었는데 설마가 사람을 잡았다. 읽어야 하는 문자열 뒤에 한글· 한자처럼 U+100 이후의 문자가 들어가 있으면 swscanf의 실행이 무조건 실패하고 있었다. 나는 "%X"라고 인자를 줬기 때문에  "FF00 가나다"이라는 문자열이 있으면 프로그램은 '가나다'는 전혀 신경쓸 필요 없고 0xFF00만 읽어 오면 된다. 게다가 'FF00'과 '가나다'의 사이에는 멀쩡히 공백까지 있어서 확인사살을 하고 있다.

그런데 확인을 해 보니 그냥 평범한 'ABC', '^&*%' 따위가 있을 때는 괜찮은데 '가나다'가 있을 때는 실패하더라. FF00의 값을 읽는 것과는 1도 아무 상관 없으며, Windows에서는 당연히 이런 현상 없이 값을 잘 얻어 온다.
이 때문에 swscanf를 쓰던 것을 wcstol로 바꿔서 %X의 역할을 대신하게 해야 했다. wide string 기반의 유니코드이니 무슨 로케일이나 인코딩 같은 설정을 할 필요도 전혀 없는데 swscanf가 왜 쓸데없이 꼬장을 부리는지, 더구나 맥만 왜 이러는지는 알 길이 없다. 살다 보니 별 일을 다 겪었다.

5. 정적 분석 써 본 소감

여느 프로그래머들과 마찬가지로 본인은 요즘 개발툴들이 제공하는 정적 분석 기능을 잘 사용하고 있다. 방대하고 복잡한 코드에 존재하리라고 꿈에도 생각을 못 했던 실수들이 걸려 나오는 경우가 많기 때문이다. 아주 특수한 상황에서 초기화되지 않은 변수가 사용될 가능성, 메모리 내지 리소스 leak이 발생할 가능성 같은 것 말이다.
역시 인간은 어쩔 수 없이 실수란 걸 늘 저지르는 동물이다. 기계가 이런 걸 안 잡아 줬으면 개발자들이 얼마나 고생하게 됐을까? 더구나 내가 직접 만들지도 않고 남이 짠 지저분한 코드를 인계받아서 유지 보수해야 하는 처지라면 말이다.

심지어 내가 머리에 총 맞기라도 했는지, 왜 코딩을 이 따구로 했었나 자괴감이 드는 오류도 있다.
물론 이런 것들은 처음에 코드를 그렇게 작성한 것은 아니다. 나중에 해당 코드가 변경되고 리팩터링이 됐는데 그게 모든 곳에 적용되지 않고 부분적으로 편파적으로만 적용되면서 일관성이 깨진 경우가 더 많다. 예를 들어 리턴값의 타입이 BOOL이다가 나중에 필요에 따라 int로 확장됐는데, 마치 Windows API의 GetMessage 함수처럼 체크 로직은 >0으로 바뀌지 않고 여전히 !=0이 쓰인다면 그런 부분이 잠재적으로 문제가 될 수 있다.

먼 옛날, Visual C++ 4~6 시절에는 프로그램을 빌드할 때 부가 옵션을 줘서 browse 정보를 추가로 생성할 수 있었다. 빌드 시간과 디스크 용량을 매번 추가로 투자해서 이걸 만들어 둬야만 임의의 심벌에 대해서 "선언/정의로 이동, 함수의 Calls to/Called by 그래프 조회" 같은 편의 기능을 사용할 수 있었다.

그랬는데 세월이 흘러서 지금은 C++ 같은 문맥 의존적인 언어조차 심벌 browse 기능 정도는 IDE의 백그라운드 컴파일러로 실시간으로 다 가능하고 최신 정보가 수시로 갱신되는 지경에 이르렀다. 그 대신 지금은 빌드 때의 추가적인 액세서리에 해당하는 것이 바로 '소스 정적 분석'이 된 거나 마찬가지이다. 단순히 기계어 코드를 생성하는 빌드보다 시간이 더 걸리는 대신, 통상적인 너무 뻔한 경고보다 훨씬 더 자세하고 꼼꼼하게 소스 코드에서 의심스러운 부분을 지적해 주는 것이다.

6. C++ 디버깅

하루는 회사에서 Visual C++ 2015로 개발하던 C++ 프로그램을 불가피한 사정 때문에 더 낮은 버전인 Visual C++ 2012로 빌드할 일이 있었다.
빌드는 별 문제 없이 됐지만, 그 프로그램은 제대로 실행되지 않고 초반부에서 바로 뻗어 버렸다.

디버거로 들여다보니 원인이야 어처구니 없는 실수 때문이었고 금방 밝혀졌다. 클래스의 생성자에서 멤버들이 ABC~XYZ 순으로 초기화되는데, A~C의 초기화 과정에서 아직 초기화되지 않은 뒤쪽 멤버들을 참조하는 멤버 함수를 호출했던 것이다.
컴파일러가 지역 변수 int를 초기화하지 않고 사용하는 것 정도는 곧장 지적해 주지만, 저런 실수까지 찾아내는 건 정적 분석의 경지로 가야 하는 모양이다.

그런데 문제는 이런 버그가 오랫동안 존재했던 프로그램이 지금까지 2015로 빌드할 때는 왜 잘만 돌아갔느냐는 것이다. 그것도 디버그가 아닌 릴리즈 빌드로 말이다. 이 객체는 new로 heap에다가 할당하는 것이어서 전역변수와는 달리 초기에 내부 메모리가 언제나 0초기화라는 보장도 없는데..
더구나 C++은 성능 덕후 언어이기 때문에 파생 클래스 부분까지 기본적인 초기화를 다 해 준 뒤에 기반 클래스의 생성자를 호출하는 것도 아니고, 생성자에서 자신의 초기화되지 않은 부분을 건드려서 순수 가상 함수 호출 같은 각종 문제가 얼마든지 발생할 수 있는데... 언어 디자인이라는 구조적인 차원에서 말이다.

프로그램의 빌드 configuration을 바꾸면 한 환경에서는 없던 문제가 금세 튀어나올 수 있다. 디버그에서 릴리즈, 혹은 반대로 릴리즈에서 디버그로 양방향이 모두 가능하다.
또한, 평소에는 탄탄한 최신 NT 계열 Windows에서 개발하다가 프로그램을 더 불안하고 연약한 환경인 9x에서 돌리면 숨겨진 버그나 리소스 누수가 튀어나올 수 있다. 요즘 컴파일러에서는 이렇게 할 수조차 없지만 말이다.

그런데 컴파일러의 버전을 더 낮췄더니 숨겨진 문제가 튀어나온 경험은 이번이 거의 처음이었다.

Posted by 사무엘

2017/11/29 08:32 2017/11/29 08:32
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1432

Trackback URL : http://moogi.new21.org/tc/trackback/1432

Comments List

  1. aaa 2017/12/25 23:28 # M/D Reply Permalink

    "1. 오버로딩과 오버라이딩의 관계" 에서 언급하신 부분은 "메써드 하이딩"이라는 기능입니다.
    디자인 실수라기 보기에는 나름 의미있는 기능입니다.

    1. 사무엘 2017/12/26 04:19 # M/D Permalink

      네, 찾아봤더니 단순히 비공개 멤버로 지정하는 은닉 말고, 그런 기능도 있더군요.
      본문은 C++ 언어의 설계가 실수가 있어 보인다는 얘기가 아니라, 제가 그런 언어의 '은닉' 특성에 대해 잘 모르고 오버로딩과 오버라이딩이 동시에 가능한 듯이 클래스를 설계하는 실수를 잠시 했다는 뜻입니다. ^^

Leave a comment

1. 주 UI 스레드와 배경 작업 스레드

대화상자를 텅 빈 깡통 상태로 일단 띄워 놓은 뒤, 시간이 좀 오래 걸릴 수 있는 초기화 작업을 백그라운드에서 하고서 그게 끝나면 작업 결과를 대화상자의 각종 컨트롤에다가 반영하고 표시하기..
본인은 이런 형태로 동작하는 GUI를 구현한 적이 있었다. 리스트/콤보 박스에 들어갈 아이템들을 파일을 탐색하면서 수집하는 것이 대표적인 예이며, 당장 날개셋 한글 입력기 프로그램에도 이렇게 동작하는 UI가 한두 군데 있다.

그런데 그 백그라운드 작업이 언제나 수행되는 건 아니고, 조건에 따라서는 전혀 해당사항이 없는 경우도 있었다. 이때는 작업 결과의 표시와 관계가 있는 컨트롤들은 그냥 숨기거나 disable 시켜 놓으면 됐다.

그러니, 그런 컨트롤은 괜히 만들었다가 도로 숨기는 게 아니라, 스레드 함수가 자기 작업이 다 끝나고 마지막 부분에서 대화상자에다가 동적으로 생성하게 로직을 고치는 게 어떨까 생각을 했는데... 그렇게 하다가 더 피봤다.
당연한 말이지만 특정 스레드가 생성한 윈도우는 그 스레드의 실행이 끝남과 동시에 소멸되기 때문이다. 머리에 나사가 하나 빠지기라도 했는지 왜 그걸 생각을 못 했나 순간 "아차~!" 했다.

컨트롤 자체는 주 UI 스레드에서 미리 만들어 놓은 뒤, 백그라운드 작업 스레드에서는 그걸 ShowWindow / EnableWindow 정도의 제어만 할 수 있다. 컨트롤을 굳이 조건부로 동적 생성하고 싶다면, 백그라운드에서는 주 UI로 하여금 컨트롤을 생성하라고 메시지나 타이머 요청 정도를 보내는 간접적인 방법만 사용 가능할 것이다.

이렇게 윈도우의 생명 주기는 스레드와 관계 있는 반면, 접근 가능한 윈도우 클래스의 범위는 드물게 스레드가 아니라 RegisterClass를 호출한 모듈과 관계가 있다. 한 프로세스 안의 모든 모듈에서 접근 가능한 윈도우 클래스를 구현하려면 클래스 스타일에 CS_GLOBALCLASS를 지정해 줘야 한다. CreateWindowEx 함수는 현 스레드의 함수 호출 스택을 추적해서 자신을 호출한 모듈이 무엇인지를 따지기라도 하는가 보다. (인자로 받은 HINSTANCE 값은 무시하고 사용하지 않음.)

Windows 말고 안드로이드 프로그래밍을 해 보니 거기서는(Java)는 네트워크 통신은 무조건 백그라운드 스레드에서만 가능하고, 각종 GUI 요소의 조작은 반대로 주 스레드에서만 가능하게 해 놓았다. 이 규칙을 어기면 바로 예외가 발생한다. 그래서 Windows에서와 같은 혼동이 발생할 일이 없게 해 놨지만.. 간단한 통신 결과가 왔을 때 이를 GUI에다 표시하는 걸 한 함수에서 바로 못 하고 매번 스레드에, 메시지+핸들러로 실행 주체를 분리해야 하는 게 좀 번거로웠다.

2. 스레드의 강제 종료와 스택 상태

프로세스가 종료되는 가장 무난하고 좋은 방법은 main / WinMain 함수가 실행이 끝나서 자연스럽게 return하는 것이다. 그와 마찬가지로 스레드가 종료되는 가장 무난하고 좋은 방법 역시 해당 스레드 함수가 실행이 끝나서 자연스럽게 return하는 것이다.
하지만 Windows API에는 Exit...내지 Terminate...로 시작하는 프로세스· 스레드 종료 함수가 따로 있다.

두 단어 모두 뜻은 비슷하나, 전자는 자동사이고 후자는 목적어를 받는 타동사이다. 이로부터 유추할 수 있듯, 전자는 그 함수를 호출하는 자신을 종료하고 후자는 임의의 다른 프로세스나 스레드를 강제 종료시킨다.
어떤 프로세스가 이런 함수에 의해 종료되면 그 프로세스 하에서 돌아가던 모든 스레드들은 강제 셧다운된다. 그리고 반대로, 어떤 프로세스에서 모든 스레드들이 종료되어서 돌아가는 스레드가 하나도 없는 지경이 되면 빈 껍데기 프로세스는 자동 종료되고 소멸된다.

main 함수의 실행만 끝나면 자동으로 주변 잔여 스레드들의 실행도 강제로 다 끝나는 것 같지만 원래부터 그렇지는 않다. main을 호출한 하단의 C 런타임 라이브러리가 내부적으로 ExitProcess를 호출하기 때문에 그렇게 되는 것일 뿐이다. 운영체제 차원에서는 모든 스레드들의 실행이 끝나야 프로세스가 종료된다.

어쨌든, 프로세스나 스레드 같은 실행 주체는 자기 스스로 곱게 종료하는 게 좋다. 강제 종료 대상인 프로세스나 스레드는 자신이 강제 종료 당한다는 어떤 통지도 받지 못하며 이를 회피· 거부할 수도 없다. 뭐, 강제 종료를 막는 방법 뒷구멍이 있다면 악성 코드가 이를 마음껏 오· 남용, 악용할 것이니 저건 불가피한 면모도 있다. 강제 종료를 요청하는 프로세스가 적절한 권한만 갖고 있다면 강제 종료 작업 자체는 성공이 반드시 보장된다.

강제 종료는 파일이나 메모리, 스레드 동기화 오브젝트를 포함해 해당 스레드가 할당하고 선점해 놓은 그 어떤 자원도 제대로 수습· 회수하지 않은 채 말 그대로 해당 실행 주제만 없앤다. 그러니 엄청난 리소스 누수를 야기한다. 그나마 프로세스는 독립성이 높은 실행 단위인 덕분에 강제 종료되더라도 자기가 사용하던 모든 자원들이 자동으로 반납되는 게 보장이라도 되는 반면, 스레드는 그렇지 않다.

그렇기 때문에 TerminateThread는 TerminateProcess보다도 가능한 한 더욱 사용하지 말아야 한다.
I/O 관련 병목이나 데드락 같은 게 걸려서 해당 스레드의 코드 자체가 전혀 돌아가지 않을 때.. 옛날 같았으면 컴퓨터 리셋을 했을 피치 못할 상황에서나 극도로 제한적으로 사용해야 한다. 자기 스스로 실행 가능한 스레드라면 외부에서는 중단· 종료 플래그만 걸어 주고, 그 스레드가 알아서 실행이 종료되게 하는 것이 절대적으로 바람직하다.

그렇기 때문에 작업 관리자 같은 유틸에서 응용 프로그램을 강제 종료하는 기능은 일단 그 프로그램의 주 윈도우에다 WM_CLOSE만 살짝 보내 보고, 그 프로그램이 거기에 불응하면 API 차원의 극약 처분을 내리는 식으로 동작한다. 기왕이면 주먹보다는 말로 곱게 해결하는 게 좋으니까...

스레드가 강제 종료된 경우, 코드 실행 차원에서 발생하는 리소스 leak이야 어쩔 수 없는 귀결이다. 그런데 Windows는 전통적으로 exit 말고 terminate로 강제 종료된 스레드에 대해서는 그 스레드가 사용하던 스택에 속하는 메모리 주소도 해제· 재사용하지 않고 내버려 뒀다. 그러니 heap이 아닌 stack에 속하는 메모리가 leak이 발생하게 됐다.

이것은 스레드가 강제 종료되더라도 그 스레드의 스택에 속하는 메모리를 참조하던 다른 스레드가 뻗지 않게 하려고 성능보다는 안전을 고려해서 시행한 정책이었다. 어차피 TerminateThread를 할 정도이면 온갖 리소스들이 누출되었을 가능성이 높고 이왕 버린 몸에 비정상적인 상황이니 스택도 해제하지 않고 일부러 놔뒀던가 보다.
그러나 이 정책이 Windows Vista부터는 바뀌어서 이제는 terminate된 스레드의 스택도 곧장 해제된다. 흥미로운 점이다.

3. 열악하던 시절에 동시작업 구현하기

CPU 차원에서의 멀티스레드라는 게 없던 시절에 UI와 백그라운드 작업이 동시에 돌아가는 프로그램을 짜는 건 상당한 고역이었다.
옛날에는 컴퓨터 하드웨어 차원에서 관리되는 키보드 버퍼라는 게 있었다. 컴퓨터가 바빠서(busy) 정신없는 상태에서 사람이 키보드를 누르면 그게 일단 버퍼에 들어갔으며, 나중에 컴퓨터가 정신을 차리면 먼저 온 글쇠부터 밀린 처리를 했다. 일종의 queue 자료구조처럼 말이다.

이 키보드 버퍼는 크기가 15타 남짓밖에 안 됐다. 그러니 컴퓨터가 바쁜 상태에서 키보드를 조금만 많이 누르면 그 글쇠는 버퍼에조차 추가가 못 되고 컴퓨터가 시스템 전체를 잠시 멈추면서 높은 톤의 '삐~' 경고음을 냈다. "나 건드리지 마세요..!" 물론 ctrl, shift 같은 비문자 글쇠 말고 문자 글쇠들 한정으로. pause 키를 누르면 컴퓨터 전체의 실행을 일시정지 시킬 수 있던 시절의 얘기이다.

Windows 시대가 되면서 하드웨어와 소프트웨어 사이에 무슨 계층이 덧붙여졌는지, 컴퓨터에서 저런 걸 볼 일은 없어졌다. 하지만 9x 시절에는 운영체제가 대미지를 심하게 입고 반쯤 뻗어서 다운+재부팅 징조가 농후할 때면, 윈도우들이 메시지 큐가 다 차 버리고 메시지에 아무런 응답도 처리도 할 수 없는 상태가 되곤 했다. 이때는 그 윈도우로 마우스 포인터를 갖다대서 옮기기만 해도 짤막한 비프음이 났다. 이게 옛날에 키보드 버퍼가 다 차서 경고음이 나던 것과 같은 맥락의 현상이다.

옛날에 컴퓨터 속도가 왕창 느릴 때는 사용자가 화살표 키를 눌러서 화면을 스크롤 하던 도중에도 끊임없이 글쇠 입력 체크를 해야 했다. 그래서 화면 갱신 속도가 글쇠 연타 속도를 따라가지 못한다면 지금 갱신하던 것은 때려치우고 글쇠 처리부터 모두 한 뒤, 이로 인해 화면 위치가 바뀌었으면 스크롤을 처음부터 다시 하고, 변동 사항이 없으면 아까 하다 말았던 스크롤을 마저 계속하게 코딩을 했다.

오늘날은 단순히 2차원 스크롤을 위해서 저렇게 헝그리 코딩을 할 필요는 없을 것이다. 그러나 화면에다 아주 복잡한 3D 그래픽을 점진적으로 렌더링 하거나, 고해상도 만델브로트 집합 같은 프랙탈 그래픽을 실시간으로 그린다면.. 동일한 테크닉이 여전히 필요할 것이다.

CPU를 많이 소모하는 계산 위주의 작업 말고, 주변 기기와의 I/O 비중이 큰 작업도 생각해 보자. 워드 프로세서에서는 인쇄 중 동시작업(일명 스풀링), PC 통신 프로그램에서는 전화 연결 중에, 업· 다운로드 중에 동시작업.. 지금은 너무 당연해서 일도 아닌 게 옛날 도스 시절에는 해당 프로그램의 완전 첨단 고급 기능이라고 소개되곤 했지 않는가?

Windows는 여러 프로그램들을 동시에 띄워서 구조적으로 동시작업이 기능하다고 하지만, 16비트 시절엔 여건이 도스에 비해 막 좋을 건 없었다. 빡센 작업을 하는 중에도 여전히 사용자 반응성을 잃지 않으려면 message loop 차원에서 PeekMessage와 OnIdle 같은 로직이 추가돼야 하고, 작업 역시 UI의 반응성을 해치지 않을 정도로 연산을 짧게 끊어서 찔끔찔끔 해야 하는 건 변함없었다. 이런 정신없는 상태에서 트리 구조 순회나 순열 생성 같은 건 당연히 쌩 재귀호출로 구현할 수 없으며, 사용자 스택 자체 구현이 필수였다.

더구나 이런 idle time processing은 내가 아닌 Windows 내부의 고유한 message loop 하에서 구동되는 modal 대화상자 내지 메뉴 표시 중에는 중단된다는 문제가 있다. 타이머 메시지는 저렇게 modality와 관련된 끊김 현상은 없지만, CPU를 활용하는 효율이 일반적인 idle time processing 메커니즘에 비해 좋지 못하다.

이런 걸 생각하면 멀티스레드가 없었으면 지금처럼 사용자가 입력하는 텍스트의 맞춤법을 실시간으로 검사해서 빨간줄을 그어 주는 기능, C++ 같은 문맥 의존적인 언어 코드를 사용자가 입력하는 걸 인클루드 파트까지 실시간으로 구문 분석해서 자동 완성과 syntax coloring을 구현하는 건 불가능에 가깝게 힘든 일이 될 수밖에 없을 것이다.

4. 파이버: 스레드의 변종

사실, time slicing을 운영체제가 자기 재량껏 하는 게 아니라 내가 원할 때 하도록 thread의 변종인 fiber라는 게 있다. Windows의 경우, 일단 자기 자신을 일반 스레드에서 fiber로 먼저 변환해서(ConvertThreadToFiber) 초기화를 한 뒤, 다른 파이버들을 생성하고(CreateFiber), 파이버들끼리 필요한 타이밍 때 서로 전환(SwitchToFiber)을 하면서 열심히 제 할 일을 하면 된다.

이 경우 스레드 동기화 같은 건 전혀 필요하지 않으며, 멀티스레딩을 표방하면서도 프로그래밍 패러다임은 멀티스레드 성향이 전혀 아니게 된다. 사실, 이건 유닉스 기반의 서버 프로그램의 포팅을 돕기 위해 일부러 도입된 기능이지 실용적으로 딱히 쓰일 일도 없다. 하지만 복잡한 재귀호출이 여러 곳에서 동시다발적으로 일어날 때 자기 스택 상태를 고스란히 보존하면서 작업 context들을 원하는 때에 전환할 수 있다는 점에서는 뭔가 독특한 용도가 있을 것 같기도 하다.

개인적으로 thread가 원래 실타래라는 뜻이니, 컴퓨터 용어로서 thread는 '일타래'라고 번역해서 쓰면 꽤 그럴싸하겠다고 생각해 왔다. 서로 꼬일 수 있는 것까지도 동일한 개념이니까. 그런데 thread의 변종으로서 아예 '섬유'라는 뜻인 fiber는 우리말로 어찌 번역해야 할지 모르겠다. 우리말 순화· 번역이라는 게 이런 추가적인 조어력과 확장성까지 갖추지는 못하는 편이니 대부분 실패하곤 한다.

오늘날 운영체제에서 module이라는 건 EXE, DLL 등 실행 가능한 코드와 데이터, 리소스가 담긴 한 이미지 파일을 식별하는 개념이다. process는 자신만의 독립된 주소 공간을 가진 실행 공간으로, EXE만이 새로 생성할 수 있다. thread는 한 process 안에서 하나 이상 존재할 수 있는 실행 주체이다.
이들에 비해 instance, task는 좀 16비트스러운 용어이다. 32비트 이상부터는 프로세스들이 기본적으로 자기 주소에서 다 혼자 따로 노는 형태이기 때문에 한 모듈(HMODULE)의 여러 instance (HINSTANCE)라는 개념 구분이 별 의미가 없어져 있다.

운영체제에 따라서는 여러 개의 프로세스도 parent/child 관계를 맺고 job이라는 집단을 형성할 수 있다. Windows도 이를 API 상으로 흉내는 내는 걸 지원하지만 막 널리 쓰이지는 않는다. 마치 C가 함수 안에 함수를 공식적으로 지원하지 않는 것처럼(람다 내지 지역 클래스 같은 편법 말고..), 프로세스들도 굳이 계층 구조가 존재하지 않더라도 뭔가 심각하게 불편하거나 불가능해지는 건 없기 때문으로 보인다.

Posted by 사무엘

2017/11/20 08:38 2017/11/20 08:38
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1429

Trackback URL : http://moogi.new21.org/tc/trackback/1429

Comments List

  1. 비밀방문자 2017/11/22 13:42 # M/D Reply Permalink

    관리자만 볼 수 있는 댓글입니다.

    1. 사무엘 2017/11/22 15:31 # M/D Permalink

      우와, 그 책 얼마 만에 다시 듣나 모르겠다..? (10몇 년 전 대학 시절의 전공 서적..)
      님도 컴공이 일취월장하고 있는 게 느껴지는구나? ㅋㅋ

Leave a comment

본인은 평소에는 15년 넘게 개발하고 있는 날개셋 한글 입력기의 개발에 대부분의 역량이 집중되기 때문에 타 유명 프로그래머 고수들에 비해 타 플랫폼· 언어· 최신 프로그래밍 기술에 대한 개인적인 관심은 덜한 편이다. 뭐, 자주 언급을 안 할 뿐이지 직장에서는 아무래도 갑님이 시키는 대로 해야 하니, 무엇이건 업무와 생존에 필요한 최소한의 맛보기 정도는 한다. 다만 그런 생소한 분야는 본인이 특장점이 없이 그냥 여느 평범한 프로그래머 A, B의 역량과 다를 바 없다.

먼 옛날에 Windows API와 MFC, Visual C++를 처음으로 공부할 때 그러했고, macOS나 안드로이드 개발을 처음으로 익힐 때도 마찬가지이다. 코드와 리소스가 어떤 방식으로 연결되는지 감을 잡는 게 참 어려웠다. 이건 그야말로 프로그래밍 언어뿐만 아니라 각 플랫폼별 바이너리 실행 파일(DLL/EXE)의 구조, 개발툴의 기능에 대한 총체적인 이해가 필요한 부분이니까 말이다.

그래도 리소스(대표적으로 대화상자/화면 레이아웃)의 기술을 위해 XML을 쓰는 요즘 플랫폼에 비해, Win32 API의 rc 파일은 정말 구닥다리이구나 싶은 생각이 든다. 뭐, resource.h와 R.java처럼 개념상 일말의 공통점이 발견되는 것도 있다(개발툴이 자동으로 생성해 주는 리소스 ID 리스트).

또한 안드로이드의 경우, 굉장한 뒷북이긴 하다만 Eclair니 Froyo니 하던 시절과 비교했을 때 개발 환경이 몇 년 사이에 정말 엄청나게 달라져 있었다. 여전히 이클립스를 쓰는가 했더니 Android Studio라고 전용 개발툴로 진작에 갈아탔으며, 무엇보다 에뮬레이터도 x86과 arm이라는 엄청난 CPU 구조 차이를 어떻게 극복했는지 속도가 꽤 빨라졌다.
그도 그럴 것이 그 구글 내부에서 안드로이드 OS에만 달라붙어 있는 세계구급 날고 기는 프로그래머 엔지니어들이 도대체 얼마나 되며, 이들이 매일 생산하는 코드의 양은 또 얼마나 될까?

2010년대 이후에나 등장한 IDE가 copyright이 왜 엄청 옛날인 2000부터 시작하는지 궁금해서 검색을 해 봤더니.. 이건 그 옛날부터 개발되어 온 타 회사의 IDE를(이클립스 말고) Google이 인수해서 자체적으로 발전시킨 것이어서 그렇다고 한다. 으음..

이럴 때마다 늘 드는 생각인데, 새로운 문물이나 지식을 아주 빨리빨리 잘 익히고 남에게 가르치는 것까지 가능할 정도로 머리가 좋은 사람들이 개인적으로 굉장히 부럽다. 난 굳이 말하자면 애초에 남이 안 하는 짓을 골라서 하는 일에 일가견이 있다. 그래서 정보 올림피아드도 공모 부문에서만 입상하고, 코딩과 논문으로 그럭저럭 지금까지 지내 왔다.
그게 아니라 남과 똑같은 조건에서 뭔가를 빨리 달달 외우고 응용하는 능력이라면 본인은 남들 평균보다 못하면 못하지 결코 뛰어나지는 않다.

컴퓨터 쪽에 우글거리는 수많은 고수 괴수들 중에.. 김 상형 님이라고 한때 winapi.co.kr 이라는 사이트를 운영했고 지금은 '소프트웨어 공학'을 일본어 스타일로 축약한 '소엔'이라는 사이트로 여러 유용한 프로그램 개발 정보를 무료로 공유 중인 대인배가 계신다.
사이트 이름에서 유추할 수 있듯 한때 이분의 전문 분야는 Windows API였다. 텍스트 에디터를 그냥 C++만으로 혼자서 처음부터 끝까지 다 만들었고, 그 테크닉을 소스까지 통째로 책을 출간한 바 있다..;;

한 분야의 기술만 통달하기에도 벅찬데 이분은 안드로이드, HTML, 자바스크립트 등 온갖 분야를 다 탐독해서 책을 쓰고 학원 강사로 뛰고 있다.
그냥 위에서 내려오는 회사 업무나 감당하기 위해서 여러 기술들을 찔끔찔끔 서바이벌 수준으로 익히는 게 아니다. 그야말로 남을 가르치고 책을 쓸 정도로 전문가가 되기 위해서 혼자서 도대체 공부를 어떤 방식으로 얼마나 한 걸까? 비결이 궁금해지지 않을 수 없다.

이렇게 강의와 저술만으로 먹고 사는 데 지장 없는 분들은 굳이 회사 들어가서 조직에 매일 필요가 없다. 물론 프리랜서는 월급쟁이보다야 소득이 훨씬 불안정하고 복불복이 심하다. 보통은 자기 친구들에게도 "걍 회사에서 월급 받으며 지내는 게 짱이야, 아무리 엿같은 동료나 상사가 있더라도 어지간해서는 거기서 절대로 뛰쳐나올 생각 마라" 이렇게 권유를 할 정도라고는 하지만..
이것도 자기 하기 나름이다. 엄청난 능력자라면 을임에도 불구하고 여러 기업들을 상대로 갑질을 하면서 자유롭고 편하게 일을 할 수도 있을 것이다.

그리고 컴퓨터가 나왔으니 영어도 빠질 수 없다.
지금보다 자료 접근성이 훨씬 열악했던 옛날에 독학으로 이를 악물고 영어를 마스터해서 198, 90년대에 이미 유명 영어 교재의 저자로 등극한 사람들이 참 대단하다는 생각이 든다.
최 은경 어린이 영어, 오 성식 생활 영어/pops English, 김 인환, 정 철 ... 그리고 최근에는 Arrow English로 유명한 최 재봉 이런 분들.

난 무슨 영문과 교수나 영어 교사, CNN 리포터-_-;; 이런 거 지향하는 게 아닌 이상, 국내에서 영어 때문에 스트레스 받을 일은 없는.. "반도 토박이치고는 뭐 그럭저럭 하네" 딱 그 정도까지만 영어가 된다. 자막 없이 영화를 다 알아듣거나, 토익 만점 이런 경지는 아니다. 그리고 그마저도 나이는 자꾸 먹고 있는데 영어를 당장 쓸 일은 없으니 감이 점점 쇠퇴-_-하는 중이다.
도무지 들리지가 않는 것, 그리고 아무리 머리를 짜내도 독해 속도를 도저히 더 올릴 수 없는 건 그냥 내 머리의 한계인 것 같다.

영어를 잘하려면 뭐 영어식 사고방식과 어순 감각을 익혀야 되고 무슨 발상의 전환을 해야 하고.. 이런 것들은 그냥 기초가 없고 첫 단추부터 완전 잘못 끼운 생짜 영어 포기자한테는 꽤 유효한 조언일지 모른다. 영어 점수 2~30점을 6~70점으로 올리는 데는 도움이 될 것이다.

하지만 90점을 95점으로 올리는 건 무리임. 저런 기초적인 문법과 어순 감각은 이미 다 갖춰져 있고, 거기서 상위권에서 최상위권으로 가려면 그냥 닥치고 영어라는 빅데이터에 수시로 많이 노출돼서 감을 유지하는 것밖에 답이 없다. 외국 어학 연수는 개나 소나 아무나 가는 게 아니라 딱 이 정도 기초가 갖춰진 애들이 가야지 효과가 높아진다.

그런데, 저런 여러 영어 전문가들이 공통으로 말하는 영어 마스터 비결은.. 학창 시절에 영어 교과서 텍스트들을 몽땅 통째로 암송· 암기했다는 것이다. 사실 인간의 언어에는 굉장히 무작위하고 arbitrary하고, 그냥 문맥이 곧 용례를 결정하는 그런 정보가 많다. 암송· 암기는 학습자에게 괴로운 과정이긴 하지만 그래도 그거 효력은 확실한가 보다.
나도 테이큰의 전화 통화 대사 40초 분량은 통째로 줄줄 외우고 있긴 하다만.. -_- I don't know who you are ... I will find you. And I will kill you. 같은 거.. 그런데 영어를 잘하려면 그런 거 암기를 더 많이 해야 한다.

일본은 개개의 국민들이 다 영어를 못 하더라도 국가 차원에서 번역을 엄청 많이 잘 해 놨다고 그런다. 하지만 우리나라는 모든 국민들이 다 영어를 잘하는 것도 아니고, 번역을 깔끔하게 잘한 것도 아니니 뭔가 문제가 있어 보인다.

끝으로, 어려운 과목의 끝판왕인 수학이 있다. 수학은 영어와 달리 유행을 별로 안 탄다. 한편으로는 노력한 만큼 그대로 결과가 나오는 참 정직한 과목 같으면서도, 한편으로는 타고난 머리 지능빨을 타니 불공평한 면모가 느껴지기도 하는 과목이다.
수학에는 '정석' 책 하나로 그야말로 억만장자가 되고 우리나라에서 최고로 성공한 사람이 있다. 물론 이분 역시 머리가 공부벌레 괴수급이었으며, 굳이 책 안 쓰고 학원과 과외 강사료만으로도 그 옛날에, 겨우 20대 나이로도 왕창 잘나갔을 정도로.. 비범했다.

그런 정석의 저자가 말하는 수학 잘하는 비결은.. 수학은 처음에 느리고 시간이 걸리더라도 직접 계산해 보고 손으로 일일이 쓰면서 감을 익혀야 한다는 것이다. 그런 감이 생겨 있지 않은 사람이 눈으로만 보고 넘어가서는, 그리고 덥석 해설과 풀이를 봐서는 진짜배기 수학 실력이 절대 늘 수 없다고.. 참 너무 원론적이고 당연한 조언을 한다. 그건 게임으로 치면 그냥 무한 맵에 치트키 쓰는 것이나 마찬가지니까.

그리고 저 말을 프로그래밍에다가 적용하자면.. 일일이 직접 코딩해 보고 돌려 봐야 실력이 는다는 말과 일맥상통한다. 그 점은 본인 역시 적극 동의한다.
아무 감도 없는 사람이라면 노가다 코딩이라도 해 봐야 된다. 그런 경험을 많이 해 봐야 노가다 코딩을 왜 '노가다'라고 부르는지 그것부터 좀 알게 된다.

개발자, 프로그래머로 먹고 살려면 솔까말 트리 구조 순회 같은 재귀호출을 스택 배열로 직접 구현하기, 포인터 조작으로 연결 리스트의 원소 배열을 역순으로 바꿔치기 정도는 머릿속에서 로직이 어느 정도 암산이 돼야 하고, 굳이 컴퓨터가 없이 화이트보드 앞에서도 의사코드를 쓱쓱 적을 수 있어야 하지 않는가?

사실, 유수의 IT 업체들이 학-석사 급의 엔지니어를 뽑을 때 코딩 면접도 딱 이 정도 수준의 난이도가 나온다. 무슨 "B+ 트리를 구현하시오, 동영상 압축 알고리즘의 모든 과정을 설명하시오"가 아니다. 그리고 크고 유명하고 재정 넉넉한 기업일수록 당장 현업에서 쓰이는 HTML5니 자바스크립트니 언어 문법 지식보다는 저런 미래의 잠재성과 응용력, 새로운 기술을 더 본다. 능력 함수에서 현재의 f(x) 값보다 도함수 f'(x)를 말이다.

다시 말해, 최신 자바스크립트나 HTML5 API 지식이 필요하지 않으니까 당장 그런 걸 모르는 사람도 OK 하고 뽑는 게 아니다.
오히려 그 반대로.. 하나도 모르는 상태로 입사했더라도 현업에서 그런 것쯤은 30분 만에 즉석에서 공부하고 숙달될 능력이 있으니까 뽑는다는 뜻이다. 요구 사항이 훨씬 더 고차원적이다.

컴공과 수학의 관계는 어떨까? 물론 완벽하게 동치는 아니다. 기하 알고리즘을 구현하고 있는데 삼각형 넓이나 세 점의 방향을 구하는 공식, 3차원 공간에서 두 벡터에 대한 나머지 기저를 구하는 세부적인 외적 공식 같은 거야 당연히 까먹을 수 있다. 하지만 기억이 안 나면 당장 검색이라도 할 수 있으면 아무 문제될 것 없다.

단지, 수학은 그렇게 문제를 쓱쓱 풀어 나갔던 경험, 단 한 가지 경우라도 놓쳐서는 안 되고 논리적으로 완벽해야 한다는 그 관념이 나중에 프로그램을 짜는 데 낯설지 않은 정신적 자산으로 작용할 수 있다고 본다.
물론 그런 관념이 오로지 반드시 학창 시절의 수학 문제 풀이를 통해서만 형성될 수 있다는 건 아니겠지만 말이다. 기본적인 머리가 있고 필요를 느끼면 결국은 나중에 다른 경로를 통해서라도 적응은 하게 돼 있다.

어휴.. 나도 말은 이렇게 써 놨지만.. 당장 어떻게 풀어야 할지 모르는 어려운 문제를 대면하면 이게 도대체 지금까지 수업 시간에 배웠던 기본 수학 공식이나 법칙과 무슨 관계가 있고 무엇부터 적용해야 할지 막막한 게 많다. 맨날 이런 기억과 경험만 쌓이다 보면 그 누구라도 수학이 싫어질 수밖에 없고 수학을 포기할 수밖에 없을 것이다.. -_-;; 세상에는 나랑 나이 차이도 별로 안 나던 시절에 그런 문제를 생각해 내고 '만든' 사람도 있구만.. 참 자괴감이 든다~!!

Posted by 사무엘

2017/10/22 19:35 2017/10/22 19:35
, , , ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1419

Trackback URL : http://moogi.new21.org/tc/trackback/1419

Comments List

  1. boolsee 2017/10/23 09:11 # M/D Reply Permalink

    사무엘님께서 이런 글을 쓰실 줄이야! 조금 당황스럽습니다. :) 제 관점에서는 사무엘님도 대.단.하.신 네임드 중 한 분이십니다만....
    글 내용에는 많은 부분에서 공감합니다. 나이가 들어가면서 나만 정체되어 있다거나 특별한 성과가 없다고 느껴지거든요.사실이 그런가는 차치하고 내 스스로가 한없이 작아지는 느낌? :)
    사람 사는 생각이 비슷한가 봅니다.

    1. 사무엘 2017/10/23 11:00 # M/D Permalink

      제가 본문에서 언급된 다른 사람들만치 공부 잘하고 머리가 빨리 잘 돌아갔으면 지금 겨우 날개셋 정도의 프로그램은 10년 안에 10.0까지 다 만들고 학위도 다 마치고 현재는 딴 일을 하고 있지 싶습니다.. ^^;;
      저도 제 자신이 한없이 작다는 느낌을 늘 받습니다.

  2. 허국현 2017/10/28 17:25 # M/D Reply Permalink

    1. 전에 친척 분이 "IT는 매번 바뀌는데 도대체 어떻게 그걸 하는 거냐?"라는 질문을 하신 적이 있습니다. 구원 받으신 분이라 이렇게 답했습니다.

    "해 아래 새 것이 없다는 성경 구절처럼 기술은 바뀔 지 몰라도 사람은 바뀌지 않습니다. 사람에 집중하면 생각보다 어렵지 않습니다.

    또한 새로운 것이 계속 추가되는 것 같아 보여도, 기존에 있던 것을 개선하거나, 서로 베끼는 경우도 많습니다. 너무 새로울 경우 배울 게 많아져서 오히려 시장에서 죽어 버립니다."

    이해하시는 눈치는 아니었습니다.

    사실 이것처럼 프로그래밍에도 중복되는 것이 워낙 많다 보니 김상형님 책 수집하다 보면, 봤던 예제 또 나오고 또 나오고 합니다. 플랫폼과 언어만 바뀌어서...

    오히려 요즘 나오는 책들은 C언어나 Windows API 책들에서 보이는 "경험해 보지 않고서는 절대 적을 수 없는 조언들"이 별로 없어서 많이 아쉽습니다.

    김상형님이 그게 가능한 것은 프로그래밍 언어 습득 능력보다도 글을 쓰고 책을 쓰는 능력이 있기 때문이 아닐까 합니다.

    2.

    이 글의 요약본(?) 내지 시작이었던 페북 글에도 말씀 드렸던 것처럼, 지금보다 10배 정도 머리가 좋아진다고 해도, 후회하고 자신이 작다고 느껴지는 시점만 늦어질 뿐일 것입니다.

    지금 고민하는 거야 쉽겠지만, 더 어려운 것을 고민하고 있겠지요.

    솔로몬도 공부가 힘들다는데 내가 쉬우면 그게 공부인가? 그러면 내가 공부를 열심히 안 하고 있는 거겠지... 그게 제 생각입니다.

    1. 사무엘 2017/10/28 19:43 # M/D Permalink

      이 글보다 수 개월 이상 먼저 페북에 올라왔던 요약본(?)을 다 기억하고 있고, 김 상형 님 책을 그렇게 다 수집해서 반복 패턴을 파악하실 정도이니.. 허 국현 님의 학습 능력과 기억력도 정말 남다른 것 같습니다..! ㄷㄷㄷ
      요즘은 어떻게 지내시나 궁금하네요..!

Leave a comment

본인은 초딩 시절에 어렴풋이 경험했던 옛날 컴퓨터 환경의 추억을 회상하는 일에 관심이 많다. 16비트 환경은 베이직 이외에 C/C++ 같은 급의 언어로 프로그램을 직접 개발한 경험이 전무하기 때문에 더 관심이 간다.
그래서 블로그에서 API 썽킹 얘기도 한번 했고, 1년 남짓 전에는 Windows 9x 시절에 악명 높던 리소스 퍼센티지를 32비트 프로그램에서 직접 구하는 방법까지 옛날 책을 뒤져가며 복습해 봤다. 이번에는 이와 관련하여 지금까지 다룬 적이 없었던 다른 기술 얘기를 좀 해 보겠다.

시대를 풍미했던 Windows 9x는 왜 그리도 블루 스크린(BSOD)이 자주 뜨고 불안정했을까? 흔히 지저분한 16비트 코드 잔재 때문이라고들 그런다.
80386급 이상의 CPU에서 제공되는 보호 모드, 가상 메모리 같은 기술을 적극적으로 활용하면 굳이 그렇게 허접한 운영체제가 만들어질 일이 없다. 하지만 문제는 단 하나.. CPU가 원론적으로 제공하는 기능들을 제대로 활용해서 이상적인 운영체제를 만들려면, 당시 서민들이 범접할 수 없던 엄청난 메모리와 속도빨이 필요하다는 것이다.

Windows 9x는 Windows NT와 같은 최신 32비트 선점형 멀티태스킹 환경뿐만 아니라 기존 16비트 도스/Windows 프로그램과의 호환성도 놓치지 말아야 하고, 이런 모든 미래와 과거 이념을 Windows 3.1과 크게 차이 나지 않는 1990년대 중반의 서민들 컴에서 그럭저럭 구현해야 했다. 그러니 얘는 태생적으로 아주 기괴한 방향으로 개발될 수밖에 없었다.

그래서 파일 시스템이나 메모리처럼 반드시 32비트 덕을 봐야 하는 엔진 부분인 kernel은 32비트 코드 위주로 개발했지만, user나 gdi처럼 운영체제의 단순 외형과 관련된 부분은 16비트 코드를 그대로 답습했다. 이쪽 함수는 비록 32비트 프로그램에서 user32.dll, gdi32.dll을 통해 호출했다 하더라도 결국은 argument들을 thunk 해서 16비트 user.exe와 gdi.exe로 내려가서 기능이 수행된다는 뜻이다.

개량된 트루타입 글꼴 래스터라이저처럼 GDI 계층에도 32비트 코드로 다시 작성된 게 없지는 않지만 기본적인 틀은 여전히 16비트 기반이다.
Windows NT는 16비트 썽킹은커녕 훨씬 더 안정적인(하지만 속도는 좀 느려지는) 별개의 API layer가 있는 지경인데.. 9x와 NT가 서로 설계 이념과 처지가 얼마나 극과 극인지를 알 수 있다.

이런 설계 방식 때문에 Windows 9x는 16비트 heap 크기에 맞춰진 리소스 퍼센티지 한계가 여전히 걸려 있으며, GDI 좌표계도 기본적으로 32비트가 아닌 16비트 크기이다. 그래도 이런 건 그냥 한계와 제약일 뿐, 딱히 운영체제의 안정성과 관련된 문제는 아니다.
Windows 9x는 메모리 절약과 하위 호환성을 위해 (1) 16비트 코드를 많이 재활용했는데, 이걸 온전한 가상 머신 샌드박스 안에서 구동하는 게 아니라 (2) 32비트 코드와 동일한 주소 공간과 위상에서 동일한 권한으로 섞인 채 최대 성능으로 실행하는 것을 허용했다. 16비트 코드가 운영체제 차원에서 대놓고 돌아가는 부분도 있으니 저렇게 안 해 주면 안 된다.

그리고 이를 구현하는 과정에서 Windows 9x는 NT와 같은 급의 (3) 엄격한 메모리 보호를 포기했다. 스레드 동기화나 가상 메모리 같은 현대적인 개념이 없이 쑤제 어셈블리어로 코딩된 레거시 코드가 한둘이 아니니, 걔네들이 있는 그대로 돌 수 있게 시스템의 안정성을 일부 희생하게 된 것이다.

32비트 프로세스가 독자적으로 사용하는 최신식 private 메모리야 9x도 NT의 동작 방식을 물려받았기 때문에 각 프로세스별로 동일하게 보호가 잘 된다.
그러나 9x에서는 32비트 프로세스라 해도 16비트 프로그램과의 호환을 위해 남겨놓고 있는 4MB 이내 주소대의 영역은 보호가 적용되지 않으며, 반대로 운영체제 시스템 DLL이 로딩되는 상위 영역도 성능 오버헤드 간소화를 위해 모든 프로그램들이 씨크하게 같은 주소로 공유된다. 보호 같은 거 없다.

나쁜 마음 먹은 프로그램이 이런 16비트 호환 영역이나 시스템 DLL 영역 주소를 0으로 덮어써 버린다거나 하면 운영체제를 BSOD와 함께 곧장 다운시킬 수 있다. Windows NT에서는 메모리 덮어쓰기만으로는 이런 사태가 절대로 발생하지 않고 문제의 프로그램만 강제 종료되고 운지하는 것으로 끝난다. 하지만 9x는 그 정도로 튼튼하지 못했다. 메모리 보호 강도가 반쪽짜리일 뿐이라는 뜻이다. 반쯤은 응용 프로그램이 선할 거라고 믿고 곧이곧대로 돌려 주는 셈이었다.

이렇게 메모리 보호 말고 Windows 9x가 안정성이 결정적으로 취약한 분야는 멀티스레드와 관련하여 또 있었다.
Windows 9x/NT는 3.1에서는 꿈도 꿀 수 없던 선점형 멀티태스킹이라는 것을 도입했다. 한 프로그램이 자발적으로 CPU 자원을 반납하지 않고 무한 뺑뺑이를 돌더라도 운영체제가 강제로 CPU 자원을 뺏어서 다른 프로그램에게 골고루 분배해 줄 수 있다. 또한 한 프로그램 안에서 UI 스레드 따로, 작업 스레드 따로 운용이 가능하다. 작업 스레드가 재귀호출까지 마음대로 하는 동안 사용자의 입력에도 매끄럽게 응답할 수 있다는 뜻이다.

즉, 가상 메모리가 메모리 주소를 잘못 건드리는 것만으로 타 프로그램이나 운영체제를 뻗게 하지 않게 하는 공간 보호막이라면, 선점형 멀티태스킹은 한 프로그램이 CPU를 독식해서 시스템 전체의 동작을 먹통으로 만들지 않게 하는 일종의 시간 보호막이다.

선점형 멀티태스킹 환경에서는 내 스레드가 받고 있던 CPU 포커스가 싹 바뀌어서 타 스레드로 이동하는 게 정말 예고 없이 불시에 될 수 있다. 컴퓨터의 모든 코드가 자기 자신의 스택과 지역변수만 갖고 놀면서 고립된 채 돌아가는 게 아니며, 한 자원을 여러 스레드들이 공유하는 경우가 많다. 그런 코드에 둘 이상의 실행 주체가 동시에 진입했다간 프로그램 실행이 왕창 꼬여 버린다. 이건 마치 화장실에 문을 잠그는 기능이 없어서 누가 볼일이 아직 안 끝났는데 아무 예고 없이 문이 확 열리고 딴 사람이 들어오는 것과도 같다.

결국 스레드 사이에는 교통 정리 기법이 필요하며, 이를 위해 크리티컬 섹션, 뮤텍스 등 다양한 커널 오브젝트들이 존재한다. 여기까지는 아무 문제가 없다.
그런데 문제는.. 저 16비트 레거시 코드들도 선점형 멀티태스킹 통제의 대상이라는 것이며, 그 코드들은 레거시답게 멀티스레드 동기화 같은 대비가 전혀 돼 있지 않다는 점이다. 그런 걸 고려할 필요가 없는 환경에서 개발되고 구현된 코드이니까 말이다.

이것도 메모리와 마찬가지로 16비트 코드를 완전히 자기 가상 머신에서 따로 돌게 하면 별 문제될 게 없으며, Windows NT는 실제로 ntvdm이라는 16비트 가상 머신을 돌렸다.
하지만 Windows 9x는 가상 머신을 돌릴 여력이 안 되는 PC를 대상으로 성능을 얻는 대신 안정성을 희생하는 방법을 택했다. 바로, 16비트 GUI 쪽 코드는 GetMessage, PeekMessage 같은 함수로 명시적으로 CPU 자원을 반납하지 않는 동안에는.. 전체를 동기화 오브젝트로 둘러싸서 어떤 경우에도 여러 스레드들의 동시 진입이 되지 않게 한 것이다. 이름하여 Win16Mutex라는 무시무시한 시스템 동기화 메커니즘이다.

이게 무슨 뜻인가 하면.. Windows 9x도 16비트 프로그램만 줄곧 돌린다면 과거의 Windows 3.x와 별 차이 없이 동작하게 된다는 뜻이다. 제 기능을 활용할 수 없다.
물론 32비트 코드도 16비트로 thunk하는 gdi/user 함수를 호출할 수 있다. 걔네들은 그 함수가 실행되는 동안에만 Win16Mutex 안에 잠시 들어갔다가 나온다. 그러나 16비트 프로그램은 Get/PeekMessage를 호출하지 않고 실행되고 있는 동안 계속해서 Win16Mutex를 붙들고 있게 된다. 그리고 그 동안 운영체제의 그 어떤 프로그램도 16비트 코드를 수행할 수 없으며, 앞 프로그램의 실행이 끝날 때까지 기다려야 한다.

어떤 프로그램이 창을 띄웠다가 while(true) 같은 데라도 빠져서 응답이 멎었다고 치자. 그렇다면 32비트 프로그램은 ctrl+alt+del을 누른 뒤 작업 목록에서 그럭저럭 강제 종료를 할 수 있었다. 이 기능 자체는 NT뿐만 아니라 9x 계열에서도 있었다.
하지만 옛날 기억을 꺼내 보면, 16비트 프로그램은 그렇게 깔끔하게 강제 종료가 잘 되지 않았다. 16비트 프로그램이 응답 불가 상태가 되면 운영체제 전체가 실행이 불안정해졌으며, 해당 프로그램을 강제 종료한 뒤에도 매우 높은 빈도로 블루 스크린이 계속되다가 운영체제가 뻗었다. 그 이유가 바로 (4) 16비트 코드는 애초에 선점형 멀티태스킹 대상이 아니며, 16비트 코드의 실행이 32비트 코드의 gdi/user계층 기능에까지 직통으로 영향을 끼치기 때문이다.

이제야 퍼즐 조각이 끼워맞춰진다. 저건 메모리 보호와는 별개의 영역의 문제이다.
그럼에도 불구하고 Windows 95는 안정성이 더 헬게이트이던 Windows 3.x보다 많이 나아지지는 못해도 최소한 더 나쁘게 만든 건 없다. 그러니 제품으로 나와서 1990년대 중반의 PC 환경을 획기적으로 바꿔 놓을 수 있었다.

물론 Windows 9x도 Windows API와 무관하게 완전히 분리된 환경인 도스용 프로그램에 대해서는 그럭저럭 샌드박스를 지원하고 있었다. Windows NT의 ntvdm가 지원하지 못하는 더 다양한 도스용 프로그램을 직통으로 구동해 주면서도 말이다. Windows 3.x때부터 386 확장 모드가 도입되면서 Virtual 8086 모드를 이용해 도스창을 여러 개 동시에 여는 게 가능해졌다. 9x부터는 도스창을 강제 종료도 할 수 있게 됐다.

단지, 하드웨어를 너무 저수준으로 제어하는 도스용 프로그램이라면 대책 없으며, 16비트 Windows GUI 프로그램은 32비트 프로그램과 자원을 어중간하게 직통으로 공유하다 보니, 운영체제 전체에 여파가 끼치는 잠재적인 위험이 상존했던 것이다.

Windows NT, 9x, 그리고 더 과거의 win32s를 비교하면 다음과 같다.

  Windows NT Windows 9x Windows 3.1 + Win32s
PE 방식의 32비트 EXE/DLL 실행, 32비트 메모리 접근 O O O
프로세스 별 고정되고 독립된 주소 공간 보장 O O X
DLL도 인스턴스별 독립된 공간 보장 O O X
선점형 멀티태스킹과 멀티스레드 O O X
레지스트리, 32비트 파일 시스템, 최신 TTF 래스터라이저 O O X
온전한 메모리 보호 O △ (private area만. 도스 호환 영역과 커널 영역은 보호되지 않음) X
유니코드 API O △ (코드 변환, 메시지, 기본적인 문자 찍기 정도만 한정) X
유니코드 기반 O X X
user, gdi 계층이 완벽하게 32비트. 리소스 제약 없음 O X X
16비트 프로그램 가상화 O (안정적임) X (도스용 프로그램만 가상화. 느리고 메모리 적은 컴 친화적) X
하드웨어 계층 추상화 O (이식성. 하지만 느림) X (x86 전용, 저사양에서 성능 최적화) X
NTFS 파일 시스템 O X X
시스템 요구사항 압도적으로 제일 높음 win32s보다 더 높지만, NT보다는 훨씬 낮음 제일 낮음

이상.
지금 생각해 보면 컴퓨터가 성능이 정말 열악하던 시절에 어째 저런 유리몸 Windows를 어떻게 쓰고 지냈나 싶다. 특히 9x 계열 중에서도 1호인 Windows 95는 다음과 같은 점에서도 안정성이 굉장히 안습했으며, 지나치게 고성능인 컴퓨터를 감당(?)하지 못하는 물건이었다.

  • 잘 알다시피 디렉터리명에 CON 같은 예약어가 들어간 채로 파일 목록 조회 같은 요청을 하면 운영체제 전체가 뻗는다. 탐색기이든 dir 명령이든 마찬가지. 95/98에서는 별도의 버그 패치가 나왔고, ME에서야 버그가 처음부터 완전히 수정되었다.
  • 부팅 후에 밀리초 단위로 부호 없는 32비트 정수 범위를 초과하는 대략 7주(49.x일) 이상 계속 켜져 있으면 역시 뻗음. Windows 95는 알고 보면 7주짜리 시한폭탄이었던 셈이다. 하지만 이게 NT도 아니고, 무려 7주가 지나기 전에 십중팔구 어차피 다른 이유로 다운되고 재부팅이 일어났기 때문에 이 문제가 별로 부각되지 않았을 뿐이다. 이 문제 역시 별도의 버그 패치가 추후 공개되었다.
  • 저사양 PC에서 가상 메모리를 관리하는 방식의 한계로 인해, 램이 512MB보다 더 많은 컴에서는.. 단순히 초과 잉여 영역이 인식되지 않고 무시되는 게 아니라 그냥 부팅되지 않고 뻗는다.
  • 클럭 속도가 대략 2.1GHz보다 더 높은 컴에서는 Network Driver Interface Specification라는 계층에서 오류가 발생하고 뻗었다고 한다. 너무 빠른 컴퓨터에서 레거시 코드가 문제를 일으킨 유명한 다른 사례로는 볼랜드 파스칼의 crt 유닛이 266MHz보다 더 빠른 컴에서 오류를 일으켰던 것이 있다. 이런 것들은 대체로 내부적으로 시간 측정을 하다가 0으로 나누기 오류가 발생하는 형태인데, Windows의 저 문제도 마찬가지였던 것 같다.

그러니 옛날부터 컴덕들은 OS/2나 Windows NT 같은 신세계를 갈망하긴 했지만, 그걸 돌리려면 흙수저 가정에서는 엄두를 못 낼 정도의 비싼 고성능 컴퓨터가 필요했다. 윈95가 나왔던 시절에는 램 16MB도 돈지랄 감지덕지이던 시절이고 32~64MB는 가히 꿈의 영역이라 여겨졌었으니 말이다.
그러다 1990년대 후반에 가정용 보급형 PC들도 램 용량이 급증하여 100수십 MB급이 되었다. 그제서야 하드디스크 스와핑/thrashing을 볼 일이 없어졌으며, 마소의 입장에서는 NT와 별개로 굳이 헝그리한 9x 커널이 존재해야 할 명분이 사라졌다.

지금까지 옛날 이야기를 많이 늘어놓았는데..
본인은 우리나라 역사 내지 이념을 Windows의 개발 내력에다 비유해서 설명하기도 한다. 매우 적절한 비유라고 개인적으로 생각하기 때문이다.

우리나라가 이 승만 대통령을 비롯해 핵심 내각은 독립 운동가· 광복군 위주로 철저하게 깨끗하게 건국되었음에도 불구하고 중간과 말단의 군· 경 간부는 친일 부역자들도 불가피하게 그대로 재등용하여 운용되었다. 독립 운동가를 적극 무료 변론하다가 일제에게 찍혀서 면허 정지까지 당했던 애산 이 인 선생이 해방 후에 법무부 장관이 되고 나서는 오히려 반민특위의 해체에 앞장섰을 정도였다.

이게 Windows 95가 일단 겉으로는 32비트 코드 기반으로 개발되고 32비트 주소 공간과 선점형 멀티태스킹을 제공함에도 불구하고 내부적으로 16비트 코드를 잔뜩 재등용하고 메모리 보호가 완전하지 못했던 이유, 수시로 파란 화면 뜨고 불안정했던 이유와 정확하게 일맥상통한다.

한 마디로 그 시절에 가정용 컴퓨터에서 Windows NT 같은 운영체제를 돌리는 건 불가능했기 때문이다. 아직 조선과 일제 강점기 사고방식에 쩔었고 공산주의가 뭔지도 모르던 사람이 태반이던 시절에.. 게다가 북괴의 위협과 비열한 공작까지 횡행하던 시절에, 일제 치하에서 행정 유경험자를 재등용하지 않고서 치안을 유지하고 자유 민주주의 국가를 FM대로 이상적으로 세우고 굴리는 것도 동급으로 절대 불가능했다.

Windows 95의 한계에 대해서 정리해 보니 이 생각이 더욱 굳건하게 확신이 든다. 그 시절의 운영체제든, 194~50년대의 우리나라 사정이든 흑역사와 한계는 불가피하게 발생했던 것이지 악의적으로 일부러 발생한 것이 절대 아니었다. 아폴로 17호 이후로 인간이 달에 안/못 가고 있는 이유는 다른 악의적인 거짓이나 음모 때문이 아니라, 그저 단순히 재정이 부족하고 가성비가 안 맞기 때문인 것과도 같은 이치이다.

쓸데없이 자국 비하를 조장하고 사람 정신건강을 해치고 일제보다 더 나쁜 악을 정당화하고 무마하는 이 악하고 해로운 생각은 보이는 족족 반박하고 뿌리뽑아야 하며, 거기에 사로잡혀 있는 사람들을 산업화하고 일깨워 줘야 한다.

Posted by 사무엘

2017/10/14 08:36 2017/10/14 08:36
, ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1416

Trackback URL : http://moogi.new21.org/tc/trackback/1416

Comments List

  1. 비밀방문자 2017/10/19 04:09 # M/D Reply Permalink

    관리자만 볼 수 있는 댓글입니다.

    1. 사무엘 2017/10/19 12:46 # M/D Permalink

      오랜만이구나, 반갑다? 잘 지내고 계신가!
      이 2, 30년 묵은 옛날 Windows 프로그래밍 배경 얘기가 이해가 될 정도이면 대단한 한편으로 님도 슬슬 아재력이 늘어 가는 것 같다? ㅋㅋㅋ

Leave a comment

Visual Basic 6은 이제 개발사로부터 지원이 중단된 지 무려 10년이 돼 가는데(나온 지는 20년..!) 아직도 현업에서 쓰는 경우가 있는지 모르겠다. Visual C++ 6도 업계에서 도를 넘는 노인학대를 당해 온 물건이긴 하지만, 그래도 얘는 이제는 거의 은퇴한 듯하다. 그리고 VB6과 VB .NET은 VC6과 VC .NET하고는 처지가 완전히 딴판으로 다르다.

비주얼 베이직이 오늘날까지 인류에게 남긴 독보적인 GUI 유산은 바로 property grid이지 싶다. 이거 원조가 바로 VB이다.

사용자 삽입 이미지

이건 운영체제의 공용 컨트롤로 제공되지는 않는다. 하지만 닷넷에서는 자체 구현한 컴포넌트가 있는 듯하며, 네이티브 환경에서는 그냥 3rd-party GUI 툴킷에서 구현해 놓은 레플리카 내지 짝퉁이 쓰인다.

property grid는 오늘날까지 Visual Studio IDE에서 Alt+Enter 속성 창과 프로젝트 속성 대화상자에서 고스란히 볼 수 있다. 수십 개의 설정들이 추가되더라도 번거롭게 대화상자를 디자인할 필요 없이 설정을 뒤에다 추가만 하면 되니 참 편하다.
이에 비해 VC6의 옛날 속성 대화상자는 얼마나 추레하게 생겼는가?

단, 외형이 깔끔하긴 해도 너무 사무적이고 재미없게 생겨서 그런지, 개발툴이나 DBMS 말고 일반 사용자용 Office 제품 같은 데서는 property grid가 등장하는 걸 여전히 본 적이 없는 것 같다.

Visual Basic은 1991년 5월에 Windows용으로 1.0이 첫 출시됐다. 드래그 앤 드롭 방식으로 폼을 디자인하고 곧장 이벤트를 추가하는 방식으로 코딩을 하는 굉장히 획기적인 개발툴이라고 찬사를 받았음이 틀림없다. Windows용의 호평에 힘입어 그 해 9월에는 처음이자 마지막으로 도스용 비베도 1.0이 나와서 QuickBasic과 MS Basic PDS의 라인을 종결시켰다. 하지만 VB의 UI 엔진은 경쟁작이던 볼랜드 Turbo Vision 라이브러리에 비해서는 인지도가 매우 낮다.

그 뒤 VB 2와 3은 16비트 Windows용으로 나와서 인기를 얻다가 95년에 나온 4.0은 16비트용과 32비트용이 나란히 동시에 출시되었다. 마소에서 제품을 이런 식으로 동일 버전을 16비트용과 32비트용으로 동시에 내놓는 건 극히 드물었고 아마 VB4가 거의 유일했다. Office나 VC++는 그냥 상위 버전에서 곧장 32비트용이 나오면서 16비트 지원을 중단하는 형태였기 때문이다.
물론 VB도 5부터는 당연히 32비트 전용으로 갈아탔다. VB6 이후의 .NET에 맞춘 언어 마개조의 역사는 굳이 여기서 더 말할 필요가 없을 것이다.

델파이(네이티브 코드 지원 RAD), Java(압도적으로 넓은 플랫폼 지원, 인지도, 점유율)와 C#(닷넷 지원 킹왕짱) 같은 경쟁 솔루션이 너무 쟁쟁한테 비주얼 베이직 프로그래머 수요가 국내에 얼마나 되는지는 잘 모르겠다. 그나저나 ASP도 비베와 비슷한 문법인 걸로 아는데 그건 살아 있나?
또한 비베가 .NET 으로 바뀌면서, 기존 Office와 Visual Studio IDE에서 제공되던 VBA 매크로 언어까지 반쯤 낙동강 오리알 레거시로 전락한 것도 좀 아쉬운 점이다. 덕분에 Visual Studio 201x 최신 IDE는 지금도 제대로 된 키/스크립트 기반 매크로가 없는 걸로 본인은 기억한다.

이런 비주얼 베이직과 달리 C/C++ 컴파일러 라인은 원래 IDE 같은 게 없다 보니 도스/Windows 플랫폼은 그리 타지 않았다. C/C++은 베이직과는 완전히 다른 저수준 고성능 시스템 프로그래밍 언어이지 않던가? Windows는 NT 이전엔 애초에 자체적인 명령 프롬프트라는 게 없던 물건이었고, C 컴파일러는 도스 환경에서 스위치만 바꿔서 도스뿐만 아니라 Windows, 그리고 그 당시 중요한 플랫폼이던 OS/2용 프로그램을 크로스 컴파일했다.

그러다 1990년대 초에 이쪽은 C++ 언어 추가 → MFC 도입 → MS C/C++ 8.0 대신 Visual C++ 1.0으로 명칭 변경 같은 중요한 사건을 겪었으며, 리소스 편집기와 간단한 소스 코드 에디터가 16비트 Windows용으로 나왔다.
그리고 1993년, Windows NT가 출시되면서 NT용 32비트 Visual C++ 1.0이 별도로 나왔지만 이때는 NT는 시장 점유율이 아주 미미했으니 별 재미를 못 봤다.

그 뒤 1993~94년 사이에 Visual C++은 16비트와 32비트가 서로 약간 엇갈린 길을 갔다. 16비트용은 1.5 ~ 1.52c가 나온 뒤 지원이 중단됐고, 32비트용으로는 2.0이 나왔다. 하지만 아직 Windows 95도 없던 시절에 NT밖에 지원하지 않는 32비트용 VC++ 2는 정말 존재감이 없다. 이 32비트 바이너리를 Windows 3.1에서도 아쉬운 대로 돌릴 수 있게 하기 위해 Win32s라는 런타임이 이 시기에 개발되기 시작했는데, 얘 역시 본격적으로 이름이 부각된 건 Windows 95가 나온 뒤부터였다. 요컨대 Win32s는 95의 등장 이전부터 NT 3.1과 오리지널 3.1 사이의 gap을 메우기 위해 존재해 왔던 물건이다.

그 뒤, Windows 95가 나오고 1995년 말에 출시된 Visual C++ 4가 대박을 치면서 마소의 개발툴이 볼랜드 같은 타사 컴파일러를 슬슬 제치기 시작했다. Developer Studio라는 통합 IDE도 이때 처음으로 등장했다(텍스트 에디터, 리소스 에디터, 디버거, 빌드 툴, 도움말 레퍼런스 모두 한데 통합). VC4 시절에는 UI상으로 생뚱맞게도 맥용 크로스 컴파일이 있었던 모양이나, 본인이 직접 써 본 적은 없다.

이 당시에는 지금 같은 인터넷 기반 제품 업데이트가 없다 보니 소숫점 첫째나 둘째 자리가 0이 아닌 제품 버전을 심심찮게 볼 수 있었다. Win32s는 Visual C++ 4.1까지 지원되다가 96년 가을에 출시된 4.2에서부터 지원이 중단됐다. 설치할 때부터 "이 버전부터는 Win32s를 지원하지 않으니 이걸 타겟으로 개발하려면 구버전을 쓰고 이건 설치하지 마세요"라고 확인 질문이 뜬다.

비베는 4.0에서야 32비트 에디션이 등장하고 16비트와 32비트가 공존했던 반면, C++은 진작부터 32비트가 존재했고 그 대신 Win32s라는 과도기를 거쳤다는 차이가 있다.
또한 비베는 21세기부터는 닷넷 기반 언어로 완전히 탈바꿈해 버린 반면, C++은 이전부터 위상이 위상이다 보니 닷넷의 공세에 영향을 받지 않있다. 차라리 C++/CLI 같은 파생형 확장이 나오면 나왔지, 네이티브 코드 개발 부분은 바뀐 게 없다.

비베는 5와 6에서 잠시 MS Office 97 기반 GUI 엔진을 사용했고, 닷넷 200x에서는 그 기반을 계승하여 Office XP 및 파생 변종 GUI를 사용했다. VC++의 4~6에서 쓰인 IDE는 MFC를 써서 Office와 비슷한 외형이 나오게 자체적으로 만든 GUI 엔진 기반이었다.
그러던 것이 Visual Studio 201x부터는 WPF 기반의 완전히 독자적인 고유한 GUI를 사용하여 오늘날에 이르고 있다. 버전이 올라갈 때마다 매번 외형을 바꾸던 것도 이제는 지쳤는지(?) 2013 이후쯤부터는 안 하고 있다.

Posted by 사무엘

2017/10/12 08:35 2017/10/12 08:35
, , , ,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1415

Trackback URL : http://moogi.new21.org/tc/trackback/1415

Comments List

  1. 비밀방문자 2017/10/19 04:10 # M/D Reply Permalink

    관리자만 볼 수 있는 댓글입니다.

    1. 사무엘 2017/10/19 12:46 # M/D Permalink

      임베디드나 그런 바닥은 기계의 수명이 다할 때까지 소프트웨어 업데이트/업그레이드란 없을 테니 그럴 수도 있을 것이다? 뭐, 인터넷 연결 같은 것도 없고 애초에 안정화돼서 잘 쓰던 프로그램만 죽도록 쓰면 될 테고.

Leave a comment

컴퓨터 프로그램이 뻗는 방식을 분류하면 크게 다음과 같이 정리된다.

1. 아무 뒤끝 없이 그냥 뻗음(crash)

제일 단순하고 흔한 형태이다. 코딩을 잘못해서 잘못된 메모리에 접근하다가 튕긴 것이다. 그 예로는 null 포인터(null로부터 유도된 인근의 잘못된 주소 포함), 초기화되지 않은 포인터, 초기화되지 않은 배열 첨자 인덱스, 이미 해제된 메모리 포인터 등 참 다양하다.
혹은 애초에 메모리를 할당하는데 할당량에 엉뚱한 값이 들어와서 뻗은 것일 수도 있다. 가령, 음수만치 할당은 저 문맥에서는 대체로 부호 없는 정수로 바뀌면서 도저히 감당 불가능한 엄청난 양의 메모리 요청으로 바뀌기 때문이다.

2. CPU 사용 없는 무한루프

단독으로 돌아가는 프로그램이 제발로 이렇게 되는 경우는 잘 없다. 이건 스레드 내지 프로세스 간에 서로 아귀가 안 맞는 상호 대기로 인해 deadlock에 걸려서 마취에서 못 깨어난 상황이다. 그러니 엄밀히 말해 무한루프보다는 무한대기에 더 가깝겠다.
굳이 커널 오브젝트를 직접 취급하지 않고 윈도우 메시지를 주고받다가도 이렇게 될 수 있다. 가령, 스레드 A가 타 프로세스/스레드 소속의 윈도우 B에다가 SendMessage를 해서 응답을 기다리고 있는 중인데, B는 또 스레드 A가 생성한 윈도우에다가 SendMessage를 했을 때 말이다. 요 데드락을 해소하려고 ReplyMessage라는 함수가 있다.

3. CPU 쳐묵과 함께 무한루프

종료 조건을 잘못 명시하는 바람에 loop에서 빠져나오지 못하는 경우이다. 부호 없는 정수형으로 변수를 선언해 놓고는 while(a>=0) a--; 이런 식으로 코딩을 해서 무한루프에 빠지는 경우도 있다. 얘는 그래도 다행히 메모리 관련 문제는 없는 상황이다.

4. stack overflow와 함께 뻗음

이건 단순 뺑뺑이가 아니라 재귀호출을 종료하지 못하고 비정상적으로 반복하다 이 지경이 된 것으로, 컴에 메모리가 무한하다면 3번 같은 무한루프가 됐을 상황이다. 하지만 현실에서는 물리적인 자원의 한계가 있고, 또 컴이 취급 가능한 메모리 주소 자릿수 자체도 무한하지 않기 때문에 언젠가는 뻗을 수밖에 없다.

재귀호출도 반드시 A-A-A-A-A... 이렇게 단일 함수만 쌓이는 게 아니라 마치 유리수 순환소수처럼 여러 함수 호출이 주기적으로 쌓이는 경우도 있다.
스택은 다음에서 다룰 heap 메모리와는 달리, 그래도 그 정의상 할당의 역순으로 회수되고, 회수가 반드시 된다는 보장은 있다.

5. 메모리 쳐묵과 함께 뻗음

이건 heap memory의 leak을 견디다 못하고 프로그램이 뻗은 것이다. loop 안에서 계속해서 leak이 발생하면 꽤 골치아프다. 또한, 금방 발견되는 leak은 그나마 다행이지, 프로그램을 몇 주, 몇 달째 돌리다가 뒤늦게 발견되는 것은 더 답이 없고 잡기 어렵다. 프로그램이 뻗은 지점이 실제로 문제가 있는 지점과는 전혀 관계 없는 곳이기 때문이다. 뭔가 컴파일 에러와 링크 에러의 차이와도 비슷한 것 같다.

요약하면, 메모리 쪽 문제는 가능한 한 안 마주치는 게 낫고, 마주치더라도 프로그램이 곧장 뻗어 주는 게 디버깅에 유리하다. 1과 5는 포인터를 대놓고 취급하지 않는 C/C++ 이외의 언어에서는 프로그래머가 직접 볼 일이 드물다.
요즘은 그래도 디바이스 드라이버 급이 아닌 평범한 양민 프로그램이라면 메모리 문제로 뻗는 경우 전적으로 혼자만 뻗지, 컴퓨터 전체를 다운시키는 일은 없으니 세상 참 좋아졌다. 이게 다 가상 메모리와 보호 모드 덕분이다.

Posted by 사무엘

2017/10/03 19:34 2017/10/03 19:34
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1412

Trackback URL : http://moogi.new21.org/tc/trackback/1412

Leave a comment

Windows에서 메뉴, 리스트박스, 콤보박스처럼 세부 항목이 존재하는 고전적인 UI 컨트롤에는 기본 글꼴로 문자열을 찍는 기능뿐만 아니라 임의의 크기로 임의의 그림도 그리는 owner draw 기능이 있다. 한두 개 정도 특수하게 쓰이는 owner draw 기능이라면 해당 UI 컨트롤을 구동하는 대화상자 등 부모 윈도우에서 메시지를 받아서 처리한다.

그러나 매 아이템들마다 check box가 달린 리스트라든가, 트리 계층 구조를 owner draw 기능을 이용해서 얼추 구현한 리스트처럼.. 특정 owner draw 기능과 동작을 컴포넌트화해서 여러 곳에서 동시에 사용하고 싶다면 그 UI 컨트롤 자체가 개조 대상이 된다. 윈도우 프로시저를 서브클래싱한 후, owner draw 메시지를 부모 윈도우로부터 되받아서 자신이 직접 처리하면 된다. 이건 뭐 16비트 시절부터 존재해 온 아주 고전적인 Windows 프로그래밍 테크닉이다.

owner draw는 개념적으로 모든 아이템의 크기가 동일한 owner-draw fixed와, 각각의 아이템 크기가 모두 다를 수 있는 owner-draw variable이 존재하는데, 개인적으로 후자는 전혀 다뤄 본 적이 없다.

그리고 string 버퍼를 사용하는 owner-draw가 있고(LBS_HASSTRINGS 내지 CBS_HASSTRINGS 스타일), 그런 게 없는 owner-draw도 있다. 문자열의 옆에다가 아이콘 같은 걸 추가로 그리거나 문자열 자체를 좀 색다른 색깔과 폰트로 출력하기 위해서 owner-draw를 사용하는 것이라면 전자를 선택해야 할 것이고, 그게 아니라 완전히 생판 다른 그림만을 찍거나, 자체 버퍼에 있는 문자열을 직통으로 찍으려면 후자를 선택하면 된다.
문자열 없는 owner draw 리스트박스는 일일이 LB_ADDSTRING을 호출할 필요 없이 LB_SETCOUNT만으로 간단하게 아이템 수를 뻥튀기할 수도 있다.

owner draw 컨트롤이 동작을 시작하면 아이템을 손수 직접 그리라는 WM_DRAWITEM 메시지가 오기에 앞서, 그림을 그릴 영역을 정하기 위해 WM_MEASUREITEM 메시지가 부모 윈도우로 날아온다. 그런데 여기서 꽤 재미있는 동작 특성이 있다. WM_MEASUREITEM는 DRAWITEM과는 달리, 굉장히 일찍 날아온다. 대화상자의 경우, MEASUREITEM은 WM_INITDIALOG보다도 먼저 날아온다.

WM_INITDIALOG는 대화상자 내부의 모든 컨트롤들이 생성되었고 모든 준비가 완료되어서 대화상자가 화면에 표시되기 직전에 날아온다. 그러나 MEASUREITEM은 그렇게 내부 컨트롤이 생성될 때마다, WM_CREATE 타이밍에서 자신의 스타일에 owner draw 속성이 주어져 있으면 곧장 부모 윈도우로 전달된다고 생각하면 된다. 그러니 자기 주변의 다른 대화상자 컨트롤들이 다 생성되기도 전의 굉장히 이른 타이밍에 날아온다.

대화상자 윈도우(HWND)를 그에 상응하는 C++ 개체 같은 사용자 정의 오브젝트(LPARAM)와 연결하기 위해서는 CreateDialog나 DialogBox 같은 함수에다가 연결할 그 오브젝트 포인터를 넘겨주는 편이다. 그리고 HWND와 LPARAM이 실제로 만나는 타이밍이 WM_INITDIALOG이다. 즉, 이 메시지가 대화상자계에서 WM_CREATE나 마찬가지인 셈이다.

하지만 WM_MEASUREITEM은 이런 통상적인 초기화 메커니즘이 수행되기 전에 부모 윈도우로 호출된다. 그렇기 때문에 MFC 말고 자체적인 Windows API 프레임워크를 구현하고 있다면 이 메시지의 처리를 좀 특수하게 해 줄 필요가 있다.
리스트박스나 콤보박스가 좀 지연 초기화를 지원해서 대화상자의 초기화가 다 끝나고, 자기가 WM_PAINT를 받아서 화면에 그려지기 직전(WM_DRAWITEM)처럼 정말로 폭을 알아야 할 때에나 저런 메시지를 보냈으면 사용자가 UI 프로그래밍을 하기 약간 더 수월했을 텐데 싶은 아쉬운 생각이 좀 든다.

그리고 WM_MEASUREITEM의 도착 타이밍이 너무 일러서 부담된다면, 아이템의 폭을 꼭 이때 지정해 주지 않아도 된다. 뒤늦게라도 부모 윈도우에서 LB_SETITEMHEIGHT(리스트박스), CB_SETITEMHEIGHT(콤보박스) 메시지를 보내서 아이템 전체(ower-draw fixed), 또는 개별 아이템(owner-draw variable)의 폭을 지정해 줄 수 있다.
리스트박스의 경우 경험상 둘의 차이는 거의 없다. 콤보 박스는 WM_MEASUREITEM 메시지의 결과에 따라서 drop list 내부에서의 아이템 높이뿐만 아니라 한 줄짜리 자기 본체의 높이도 그에 맞춰 자동으로 조절되는 반면, CB_SETITEMHEIGHT 메시지는 그런 효과까지는 없다는 차이가 있다.

또한, 메뉴야 대화상자의 내부 컨트롤 같은 존재가 아니니 저런 대체제가 존재하지 않으며 owner-draw 메뉴 아이템의 폭을 지정하는 타이밍은 WM_MEASUREITEM밖에 선택의 여지가 없다. 딱히 MENUITEMINFO 같은 구조체에 자신의 높이를 지정하는 곳은 존재하지 않는다.

요즘 운영체제의 옵션에 따라서는 콤보 박스의 drop list가 튀어나올 때, 또는 메뉴가 출력될 때 바로 툭 튀어나오는 게 아니라 fade in으로 서서히 나타나거나 위-아래 내지 대각선 방향으로 슬라이딩 하듯이 튀어나오곤 한다. 이건 임의의 윈도우에 대해서 AnimateWindow라고 이런 애니메이션 효과를 구현해 주는 함수가 따로 있다.

그런데, 과거의 Windows 9x에서는 owner-draw 아이템이 들어있는 콤보박스나 메뉴에 대해서는 그런 애니메이션이 지원되지 않았다. 기본 스타일로 문자열을 출력하는 컨트롤만 애니메이션이 나오던 것이 2000/XP 같은 NT 계열에 와서야 owner-draw 방식의 컨트롤에 대해서도 동등하게 애니메이션이 지원되기 시작했다. 그림을 화면에다 바로 그리는 게 아니라 내부 버퍼 DC에다가 그려 놓고 그런 처리를 하게 된 듯하다.

참고로 AnimateWindow는 애니메이션 대상인 윈도우에다가 WM_PRINTCLIENT라고 좀 생소하게 생긴 메시지를 보낸다. 이것은 WM_PAINT와 비슷하게 창의 내용을 그리라는 메시지이지만, WM_PAINT 때와는 달리 BeginPaint나 EndPaint 호출이 필요하지 않다. invalid 영역이나 클리핑 처리 같은 개념도 없으며 주어진 DC에다가 언제나 윈도우 내용을 처음부터 끝까지 그려 주면 된다.

Posted by 사무엘

2017/09/18 08:37 2017/09/18 08:37
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1406

Trackback URL : http://moogi.new21.org/tc/trackback/1406

Leave a comment
« Previous : 1 : 2 : 3 : 4 : 5 : ... 15 : Next »

블로그 이미지

철도를 명절 때에나 떠오르는 4대 교통수단 중 하나로만 아는 것은, 예수님을 사대성인· 성인군자 중 하나로만 아는 것과 같다.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2018/02   »
        1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28      

Site Stats

Total hits:
919349
Today:
465
Yesterday:
459