1. HTTP 통신 고수준 API

오늘날 운영체제의 GUI API가 qt 같은 별도의 프레임워크가 나온 걸 제외하면 통일된 게 없고(통일이 될 수가 없음..) 그래픽 API가 통합된 게 없듯이..
네트워크, 특히 HTTP/HTTPS 기반 통신 API 역시 내가 아는 한 뭔가 ANSI/ISO 차원의 표준이 나온 게 없이 운영체제마다 다 파편화돼 있는 것 같다. 물론 제일 저수준의 소켓 API는 그럭저럭 표준화가 돼 있지만 오늘날 그것만 써서 밑바닥부터 프로그램을 짜는 건 클라이언트건 서버건 무리이니 말이다. 그러니 Windows, 안드로이드, iOS/macOS마다 또 API를 새로 익혀야 하는 게 다소 번거롭다.

요즘 인터넷에서 다른 프로토콜들은 거의 듣보잡이 돼 가고 HTTP/HTTPS만 남은 것 같다.
통합 라이브러리는 스위치만 달리해서 동일한 정보를 (1) GET와 POST 두 방식으로 간편하게 보낼 수 있으며, 이때 argument를 적절히 배치해 줘야 한다. 전자는 주소에다가 넣고 후자는 헤더에다가 넣어야 할 것이다.

URL 인코딩 처리를 적절히 해 줘야 하며, 바이너리 데이터 덤프를 base64로 인코딩, 또는 디코딩 하는 라이브러리도 덤으로 제공하면 좋을 것이다. post로 보낼 때는 Content-Type, Content-Length 같은 상투적인 헤더를 당연히 자동으로 넣어 줘야 한다.

Windows에서는 메시지를 보내는 방식이 post의 반대가 send인데, HTTP에서는 요청을 보내는 방식이 post의 반대가 get이다. get은 어떤 정보를 얻어 오기만 하는 read-only operation용으로 쓰고, post는 서버다 데이터를 등록하고 변경하고 삭제하는 등 side effect가 남는 요청을 할 때 쓴다. 일단은 그러하지만 홍보가 부족해서 현실에서는 그 용도가 엄밀하게 지켜지지는 않고 있다.

POST 방식 요청의 연장선이겠다만 (2) 다운로드뿐만 아니라 업로드도 당연히 지원해야 할 것이고, 받는 데이터 내지 올리는 데이터는 (3) 메모리, 파일명, 임의의 스트림 형태로 자유롭게 공급이 가능해야 한다. 전부 다 필요하기 때문이다.

그리고 아주 짤막한 파일을 주고받을 때나 UI 없이 그냥 명령줄 프로그램을 만들 때를 대비해서 그냥 (4) 동기형(응답이 올 때까지 block) + 짧은 대기 시간 형태도 지원하고, 별도의 스레드에서 돌아가는 비동기 방식도 자체 지원해야 한다.
비동기 방식은 결과가 왔을 때 콜백 함수 호출 하나로 끝나는 간편한 것을 생각할 수 있고, 한편으로 수십~수백 MB짜리 파일을 주고 받으면서 전송 상태를 늘 확인할 수 있는 복잡한 형태도 생각할 수 있는데, 둘 다 가능해야 한다.

단순 업데이트 체크 같은 건 굳이 지금 당장 안 돼도 상관 없는 것이니 프로그램의 반응성에 영향을 줘서는 안 될 것이다. 스마트폰 앱 같은 게 비행기 모드이거나 3G 데이터를 사용하고 있을 때는 괜찮은데 외부의 희미한 와이파이에 접속해 있을 때는 굼뜨는 경우가 있다. 이런 일이 없어야 할 것이다.

Java는 네트워크 쪽 클래스를 사용할 때는 try catch로 예외 처리가 무조건 돼 있어야 하고 안 그러면 컴파일조차 되지 않는데, 안드로이드에서는 그것도 모자라서 네트워크 통신은 무조건 별도의 스레드에서만 동작하게.. main GUI 스레드에서는 쓸 수도 없게 만들어 놓았다. 네트워크 에러 때문에 프로그램 전체의 동작· 반응이 멎는 걸 원천봉쇄하기 위한 조치인데, 이것 때문에 아주 간단한 소규모 통신에 대해서까지 일일이 스레드 만들고 함수 분리하는 건 번거롭기도 한 게 사실이다.

통신 API로 할 수 있는 일을 생각해 보니 이 정도인 것 같다. 이 모든 상황에 대한 대처가 가능하고 크로스 플랫폼까지 지원한다면 훌륭한 통신 API가 될 수 있을 것이다.

2. Windows CE 프로그래밍

회사에서는 이것저것 찝적대느라 안드로이드, macOS 코코아에 이어 근래엔 골동품인 Windows CE까지 만질 일이 있었다.
도스 기반의 Windows (1985), NT 커널(1993)에 비해 CE는 임베디드 환경을 타겟으로 나름 제일 나중에(1996) 등장했다. PC용 OS들과는 달리 CE는 리얼타임 OS이다.

그리고 CE는 훗날 Embedded Compact라고 이름이 미묘하게 바뀌었다가 2010년대부터 Windows Mobile, Windows Phone 등 스마트폰 OS로 변모하는가 싶더니.. 그냥 Windows 10 자체가 ARM용으로도 나오면서 모바일 분야를 흡수해 버렸다. 또한, 스마트폰 OS는 마소가 판단하기에도 Windows가 안드로이드와 iOS의 적수는 도저히 안 되겠다고 여겨져서 그냥 접어 버리고.. 그 대신 Visual Studio가 무려 안드로이드 개발도 지원하는 식으로, 완전히 다른 형태로 변화하게 되었다.

아무튼, 이 와중에 지금으로서는 좀 구닥다리가 된 Windows CE를 만져 봤다.
GUI에서 본인에게 아주 익숙한 Windows API로 프로그래밍 가능한 건 좋은 점이었다. 단, 같은 API를 사용하더라도 헤더 파일 구조가 PC와 동일하지는 않았으며, 생성되는 바이너리도 비록 PE 방식의 실행 파일이긴 하지만, PC Windows 버전처럼 kernel32, gdi32, user32 이런 DLL들 import가 있지는 않는 게 흥미로웠다.

그 대신 coredll.dll에 API들이 한데 몰빵돼 있는 듯하다. 그리고 메모리 절약을 위해 함수들은 이름이 아니라 ordinal 번호만으로 import하게 돼 있다.

static 라이브러리 같은 걸 만들어도 PC용과 CE 기기용을 동일 소스 기반으로 유지하기는 좀 어려워 보였다. 글쎄, #include 부분에 조건부 컴파일 로직을 정교하게 잘 짜 놓으면 불가능하지 않을지도 모르겠지만.. 거기까지는 잘 몰라서 말이다.
그러고 보니 옛날엔 Visual C++도 Embedded VC++라고 CE 개발 전용 에디션이 있긴 했다가 그건 2000년대 이후로 본가에 흡수됐다.

Visual Basic 6이라든가 Visual C++ 6은 후대 버전과 단절이 커서 꽤 오랫동안 쓰인 개발툴이라는 공통점이 있다. 그런데 Visual C++ 2008도 비슷한 면모가 있다. 바로 Windows Phone이 등장하기 전의 Windows CE 시절, 더 정확히는 Embedded Compact 6.5와 그 이전 버전 레거시 플랫폼의 개발을 지원하는 마지막 버전이라는 것이다.

자동차 내비에서도 이 운영체제가 돌아가는 경우가 있다.
PC용으로 오랫동안 개발되어 온 C++ 코드를 이 플랫폼과 컴파일러용으로 포팅하려다 보니 지금까지 편하게 써 왔던 auto와 lambda, nullptr 따위를 몽땅 구 문법으로 다시 써 주는 불편을 감수해야 했다. 저기서는 2010년대 이후에 새로 도입된 C++ 문법이 지원되지 않기 때문이다.

이 특정 플랫폼만 그런지는 모르겠지만, 인상적인 점은 뭔가 듬성듬성 빠진 함수가 많다는 것이었다. 덩치를 줄이기 위해서 다른 우회 대체제가 있다 싶은 건 몽땅 빼 버린 모양이다.
일례로, DrawText와 ExtTextOut은 있지만 더 단순한 함수인 TextOut은 없다(더 범용적인 대체제가 있으니까..). 파일 시스템도 도대체 뭐 어찌 되는지 GetCurrentDirectory, GetWindowsDirectory 이런 거 없다.

칼질은 C 함수 쪽도 예외가 아니다. 엄연히 ANSI 표준인 time 함수가 없어서 Windows API를 이용한 대체 함수를 직접 만들어야 했다.
그리고 어찌 된 일인지 FILE* 스트림 기반의 fopen 계열 말고, 정수형 파일 핸들을 주고 받는 저수준 _create, open 계열의 함수도 없더라. PC에서 쓰던 코드를 곧장 포팅하는 건 생각만치 쉽게 되지는 않았다.

제일 압권인 건 CE는 9x와는 정반대로.. API 함수에 W 버전만 있고 A 버전이 깔끔하게 없다는 것이었다.
9x는 W 버전도 껍데기는 있지만 실행이 에러 코드와 함께 몽땅 실패한다. CE에서는 A 버전이 컴파일은 되지만 링크가 되지 않고 곧장 실패했다. 1996년에 첫 개발된 신흥 OS이다 보니, 안 그래도 용량도 부족한데 구닥다리 레거시인 A는 처음부터 배제해 버린 셈이다.

Windows CE에는 W만 있고 A가 없다는 건 먼 옛날에 제프리 릭터 아저씨의 책에서 처음으로 봤는데 실제로 그렇다는 걸 그로부터 10수 년 이상 뒤에야 실제로 확인할 수 있었다.

3. 라이브러리의 형태의 변화

도스 시절에 프로그래밍에 필요한 무슨 라이브러리, API, 또는 SDK라 하면..
어셈블리어로 도스의 각종 특이한 인터럽트를 호출해서 하드웨어를 직통으로 건드리는 마우스 라이브러리, 그래픽/사운드 라이브러리, 심지어 한글 라이브러리 같은 게 많았다. 이런 기능들은 타 언어도 지원했지만 가장 먼저는 C/C++용 헤더와 라이브러리의 형태로 제공되었다.

Windows로 넘어가서는 어지간한 하드웨어 제어는 운영체제의 자체 API만 써도 커버가 되니 3rd party 라이브러리 같은 건 필요가 크게 줄어들었다. 그냥 MS Office와 Visual Studio의 외형을 흉내 내 주는 GUI 툴킷이라든가.. 이미지 파일 처리 라이브러리가 쓰였다. 아 그리고, DirectX SDK는 Windows 플랫폼 SDK로부터 완전히 분리 독립했기 때문에 따로 설치해야 하는 물건이 되긴 했다.

그런데 요즘은 그런 API, 라이브러리라는 게 용도와 형태가 바뀌고 있다. PC 안에서 혼자 돌아가는 프로그램이 고차원적인 하드웨어 제어 기능을 사용하는 게 아니라.. 웹사이트나 스마트폰 앱에서, (1) 특정 대규모 서버에서 제공하는 각종 실시간 정보 제공 기능(날씨, 버스와 지하철 위치, 지리 정보 등등)이라든가 (2) 아주 고수준의 인공지능 내지 패턴 인식 기능(이미지에서 사람 얼굴 인식, 음성 인식 등) 같은 것을.. 1인 개미 개발자가 자기 앱이나 사이트에서 곧장 사용할 수 있게 해 준다.

방대한 인공지능 데이터 검색과 계산은 서버가 담당한다. 바둑 AI로 치면 AI 코드가 라이브러리에 담겨 있는 게 아니라, 별도로 돌아가는 알파고 서버와 통신만 하는 거다.
언어는 무겁고 부담스러운 C/C++과는 거리가 멀며, JavaScript, Go, 파이썬 같은 것을 곧장 지원한다.
과금 체계도 과거의 전통적인 형태와는 사뭇 다르다. 비싼 라이브러리 제품을 몇만~몇십만 원 일시불로 지불하고 사 오는 게 아니라.. 월 얼마씩~ 사용 트래픽이 얼마까지는 무료이고 그 뒤부터는 한 건당 얼마 이런 식으로 매겨진다. 구매 대신 임대 형태이다.

기술은 그 자체는 온통 오픈소스다 뭐다 하면서 무료에 가깝게 공개되고 상향평준화되고 있다. Google API의 기능들을 개발하는 데 컴퓨터공학· 전산학 박사들이 얼마나 많이 투입됐을까..? 그 다음으로는 그냥 자본과 데이터 물량전이 대세이다.
그리고 소프트웨어로 돈 버는 건 기술과 기능 자체가 아니라 그걸로 온통 사용자들의 감성을 사로잡고, 자아· 정체성을 표현해 주는 대가로 형태가 바뀌는 것 같다. 온라인 게임의 부분 유료화라든가 각종 아바타가 대표적인 예이고 말이다.

아, 그렇다고 지금이 전통적인 형태로 판매되는 라이브러리· 미들웨어가 전멸했다는 얘기는 아니다. 게임에서 주로 쓰이는 네트워크 라이브러리, 동영상 캡처 라이브러리 같은 것들은 전적으로 로컬에서 기능이 동작하며, 단품 또는 출시되는 제품 타이틀의 규모 단위로 판매되고 있다. 돈을 한번 지불하고 끝인 것도 있고, 일정 주기로 꼬박꼬박 로얄티를 내는 형태인 것도 있다.

4. 함수 호출과 금융의 관계

좀 엉뚱한 생각을 하자면, 금융을 프로그래밍에다가 비유하고 돈이 오가는 걸 함수 호출과 데이터 전달에다가 비유할 수 있을 것 같다.

현금이 오가는 건 데이터 실물을 통째로 스택에다 얹는 call by value이다. 제일 단순하고 확실하지만 거액의 현금을 매번 들고 다니는 건 번거롭고 귀찮고 위험하다.
그러니 현금, 또는 가까운 미래에 들어올 예정인 돈에 대한 포인터가 등장하는데, 이것들이 바로 수표나 어음이다. call by reference가 현실 세계에도 존재하는 셈이다.
부도는 당연히 잘못된 포인터 접근으로 인한 page fault 내지 access violation과 직통 대응이다.

컴퓨터에는 지금 물리적으로 실제로 있는 메모리보다 더 많은 주소 공간을 끌어다 쓰고, 공간이 없으면 디스크 스와핑이라도 하는 '가상 메모리'라는 개념이 있다. 이게 어찌 보면 '대출'과 비슷한 개념으로 보인다.
물론 컴퓨터의 동작에 경제 용어인 '신용'과 정확히 대응하는 개념이 존재하지는 않는다. 그러니 완전 동일하게 대응하지는 않겠지만.. 그래도 이런 식으로 비유해 보면 생각보다 그럴싸하고 씽크가 맞아 보인다.

참고로, 성경이 말하는 헌금 원칙은 철저하게 리얼 모드이다. 빚을 내거나, 없는 돈을 미리 작정하는 보호 모드 가상 메모리 같은 개념은 없다.

5. 보안 정책

오래된 일이긴 하지만 마이크로소프트는 회사가 돌아가는 방식이 2002년 1월 1일 이전과 이후가 서로 싹 달라졌다. 더 엄격한 보안 정책과 더 일관성 있는 제품 지원 주기 정책이 적용되기 시작했기 때문이다.
일단, 이때를 기점으로 해서 모든 마소 제품에서 이스터 에그가 사라졌다. 문서화되지 않은 특정 동작을 하면 프로그램 개발자 명단이 나타나고 숨겨진 게임이나 애니메이션이 뜨는 것 말이다.

그리고 (a) 일반 지원은 제품 출시 후 몇 년 또는 차기 버전이 나온 지 몇 년 중 짧은 기간까지, (b) 연장 지원은 일반 지원이 종료된 뒤부터 5년간.. 요런 식의 규정이 추가되어 오늘날에 이르고 있다. 그와 함께 도스, Windows 3.x/95 같은 구닥다리 제품들은 지금까지 알음알음 지원되어 오던 것이 2001년 12월 31일을 끝으로 지원이 완전히 끊겼으며, 공식적으로 abandonware 판정을 받았다.

인터넷 덕분에 소프트웨어를 배포하는 데 물리적인 장벽은 사라지다시피한 반면, 원격으로 프로그램을 조종하고 컴퓨터를 장악할 수 있는 보안 위협이 커졌기 때문이다. 결국 소프트웨어라는 건 한번 만들고 나서 끝인 게 절대 아니라 계속해서 지원을 해야 하며, 굳이 기능과 컨텐츠에 아무 변화가 없더라도 보안 취약점이 발견되면 계속해서 고치고 지원해 줘야 하는 반제품 정도로 위상이 바뀌었다.

그러니 소프트웨어의 판매 비용에는 이런 잠재적 사후 지원 비용도 포함되어야 하며, 지원을 한도 끝도 없이 영원무궁토록 해 줄 수는 없으니 구체적인 기간이 법적으로 명시될 필요도 생긴 것이다.

그 뒤로 마소에서는 2000년 중반에 프로그램 개발의 기본 블록이라 할 수 있는 C 라이브러리에서부터 보안 결함을 잡겠다고 strcpy, gets 같은 함수들에 칼질을 가했으며, Visual Studio 2005에서 이를 최초로 적용했다. 컴파일러에 /GS 같은 검사 기능도 추가했다.
재래식 도움말인 WinHlp32 엔진은 너무 구닥다리 기능으로 전락한 데다, 이런 새로운 보안 기준을 충족하게 개량을 하기에는 가성비가 너무 안 맞는 관계(시간과 예산..)로 Windows Vista에서부터는 짤렸다.

Vista에서는 프로그램의 실행 주소를 그때 그때 랜덤화하는 보안 기능이 추가되기도 했다. Windows는 애초에 position independent code를 쓰지도 않는 운영체제인데, 오로지 보안을 위해 그 이념을 근본적으로 부정하고 성능을 희생하는 기능을 넣은 것이다.

이렇게 런타임 환경에다가는 예측 불가능한 요소를 일부러 가미한 반면, 빌드 타임 환경에는 정반대의 정책이 적용되고 있다. 같은 소스 코드를 같은 컴파일러 제품으로 빌드했으면 시간과 장소를 불문하고 바이너리 수준에서 언제나 완전히 동일한 실행 파일이 나오게 하자는 것이다. 이름하여 Reproducible builds이다.

단순히 생성되는 기계어 코드를 넘어서 객체들의 명칭과 배열 순서라든가 내부적으로 생성되는 각종 시그니처까지 완전히 똑같게.. 상상할 수 없을 정도로 다양한 환경에서 실행되는 프로그램을 테스트· 디버깅 할 때 문제를 재연하기 어렵게 만드는 변화 요인를 털끝만치라도 줄이겠다는 의도로 보인다.

시범타로 예전에 실행 파일의 헤더 차원에서 timestamp가 들어가던 곳에도 Windows 10에 들어가는 바이너리들은 그냥 고정된 hash값으로 바뀌었다고 한다. 시간이야 난수의 씨앗으로도 쓰일 정도로 매번 달라지는 값이니 말이다.
이렇게 21세기 들어서 세월이 흐를 수록 마소는 내부의 보안 정책이 갈수록 엄격해지는 것이 느껴진다. 그 와중에 실행 주소 랜덤화와 Reproducible builds라는 두 이념이 본인이 보기에 흥미로운 대조를 이루는 듯해 보였다.

Posted by 사무엘

2018/04/13 08:25 2018/04/13 08:25
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1478

코딩을 하다 보면 한 자료형(타입)에 속하는 값 내지 개체를 다른 타입으로 변환해야 할 때가 있다. 아주 직관적이거나(C에서 정수와 enum), 작은 타입에서 큰 타입으로 가기 때문에 정보 손실 염려가 없는 상황에 대해서는 해당 언어의 문법 차원에서 대입이나 함수 인자 전달이 곧장 허용되곤 한다. 바이트 수가 대등하더라도 signed보다는 unsigned가 더 큰 타입이고, 정수보다 실수가 더 큰 타입으로 여겨진다.

그렇지 않고 정보 소실의 여지가 있거나 서로 호환되지 않는 타입에다 값을 집어넣는 건 컴파일러의 경고나 에러를 유발한다. 이 상황에 대비하여 프로그래밍 언어들은 여러 형변환 함수들을 제공하는데, 씨크하게 괄호 안에다가 타입 이름만 달랑 쓰면 형변환 연산자로 인식되는 C/C++이 여기서도 참 유별난 면모를 보이는 것 같다.

형변환이란 건 값의 초기 타입과 목적 타입이 무엇이냐에 따라 내부에서 벌어지는 일이 매우 다양한 편이다.

(a) 없음: LPARAM에서 void*, void*에서 char* 같은 무식한 형변환은 소스 코드상의 의미만 매우 과격하게 바뀔 뿐, 내부적으로 행해지는 일은 전무하다! (클래스 상속 관계를 신경쓸 필요 없는 간단한 것 한정으로)

(b) 그냥 뒷부분 짜르거나 늘리기: int와 char 사이의 변환

(c) 정수와 실수 사이를 변환: 내부적으로 일어나는 일이 분명 간단하지는 않지만, 요즘은 이런 건 CPU 명령 한 줄이면 바로 끝난다. x86 기준으로 cvtsi2sd이나 cvttsd2si 같은 인스트럭션이 있다.

(d) 고정된 오프셋 보정: 단순한 형태의 다중 상속에서 제n의 부모 클래스 포인터를 얻으려면 이런 보정이 필요하다.

(e) 함수 호출: 해당 클래스에다 사용자가 구현해 놓은 operator 함수가 호출된다. 사실, 100과 "100"처럼 숫자와 문자열 사이의 변환도 컴퓨터의 관점에서 보면 이 정도의 cost가 필요한 작업이다.

(f) 상속 계층 관계 그래프 순회: 이건 객체지향 이념 때문에 형변환 연산이 언어 차원에서 가장 복잡해지는 상황이다. 위의 1~5와는 근본적으로 차원이 다르다. 가상 상속 체계에서 부모 클래스를 찾아가거나 dynamic_cast 형변환을 하려면 자기의 타입 정보 metadata를 토대로 주변 클래스 계층 그래프를 O(n) 시간 동안 순회해야 한다(n은 상속 단계).
사용자의 코드가 아닌 컴파일러의 코드만 실행됨에도 불구하고 런타임 가변적인 반복이 발생할 수 있으며, 객체와 메모리 상태가 어떤지에 따라서 형변환 결과가 dynamic하게 달라질 수 있다!

내부적으로 벌어지는 일은 대략 저렇게 분류 가능하고, 겉으로 소스 코드의 의미 차원에서도 형변환의 성격을 몇 가지로 분류할 수 있다.
처음에 C++에는 그냥 C-style cast밖에 존재하지 않았다. 그런데 그랬더니 형변환 연산이 발생하는 부분만 검색으로 뽑아내는 게 어려웠고, 또 단순한 형변환과 좀 위험한 형변환 같은 걸 따져 보기도 어려웠다. 표현 형태가 괄호와 타입 이름이 전부이니까..

그래서 1996~97년경, C++98의 발표를 앞두고 C++에는 길고 굉장히 기괴해 보이지만 용도별로 세분화된 형변환 연산자가 4종류나 추가되었다. namespace, bool, explicit, mutable 이런 키워드들과 같은 타이밍에 도입되었다. C++이 숫자는 몽땅 machine-word int로 어영부영 때우려던 C스러운 사고방식을 탈피하고, 예전에 비해 나름 type-safety를 따지기 시작한 그 타이밍이다. (예: C와 C++에서 sizeof('a')의 값의 차이는?)

새 연산자들은 모두 *_cast로 끝난다. 옛날의 재래식 형변환은 (NEWTYPE)value라고 썼고 C++ 문법으로는 NEWTYPE(value)도 허용되는 형태였다(NEWTYPE이 딱 한 단어 토큰으로 떨어지는 경우에 한해서).
그에 비해 새 연산자들은 *_cast<NEWTYPE>(value)라고 쓰면 된다. < > 안에다가 타입 이름을 쓰는 것은 템플릿 인자 문법에서 유래되었는데 나름 직관적이고 적절한 활용 같다.

1. static_cast

얘는 일상적으로 가볍고 큰 무리 없이 일어나는 일반적인 형변환을 거의 다 커버한다. (1) 큰 타입에서 작은 타입으로(실수에서 정수, UINT에서 int, long long에서 int 등..), 그리고 (2) 범용적인 타입의 포인터에서 더 구체적인 타입의 포인터로(void*에서 타 포인터, 기반 클래스*에서 파생 클래스 포인터) 말이다. 이게 대부분이다.

그리고 형변환 operator 호출이라든가, 다중· 가상 상속으로 인한 포인터 보정도 언어에서 보장돼 있는 메커니즘이므로 알아서 처리해 준다. 정말 대부분의 상황에서 앞서 나열했던 (a)에서 (f)까지, C-style cast를 대체할 수 있는 무난한 연산자이다. 단, f에서 typeid와 RTTI까지 동원되는 제일 비싸고 난해한 기능은 없으며, 이건 나중에 설명할 dynamic_cast가 전담하는 영역이다.

2. const_cast

얘는 값이 아니라 포인터/참조자형에서 C/C++ 특유의 한정자(qualifier) 속성만을 제거해서 더 범용적인 포인터로 만들어 준다. 그러므로 용도가 아주 제한적인 형변환 연산자이다.
C++에서 공식적으로 제공되는 qualifer는 const와 volatile이 있다. 이런 한정자는 가리키는 대상 타입과는 아무 상관 없고, 포인터를 이용해 그 메모리를 접근하는 방식 차원에서 제약을 부여할 뿐이다. 전자는 읽기 전용 속성이고, 후자는 멀티스레드에 의해 값이 언제든 바뀔 수 있음을 대비하라는 최적화 힌트이다.

Visual C++에는 __unaligned라는 확장 키워드도 저것들과 동급인 한정자이다. 이 포인터는 machine word 단위의 align이 맞춰지지 않은 주소가 들어올 수도 있으니 그렇더라도 뻗지 말고 보정하라는 뜻이다(성능 오버헤드 감수하고라도). align 보정을 알아서 너무 잘 해 주고 있는 x86 계열은 전혀 해당사항이 없고, 과거에 IA64를 지원하던 시절에 필요했던 키워드이다. 이것도 포인터 한정자 속성으로서는 굉장히 적절한 예이며, 이런 속성들을 const_cast로 제거할 수 있다.

3. reinterpret_cast

이건 의미론적으로는 제일 무식하고 생뚱맞고 위험하지만 내부 처리는 제일 할 것 없는 형변환 전문이다. (1) 정수와 포인터 사이를 전환하는 것, 그리고 (2) 서로 관련이 없는 타입을 가리키는 포인터끼리 전환하는 것.. 어디 범용적인 함수나 메시지로부터 아주 polymorphic한 데이터를 전달받아서 처리할 때에나 불가피하게 쓰일 법하다.

void*를 char*로 바꾸는 건 static_*으로도 되고 reinterpret_*으로도 된다. 하지만 const char*를 char*로 바꾸는 것은 static_*나 심지어 reinterpret_*로도 안 되고 반드시 const_*로만 해야 한다.
그런데 그래 봤자 reinterpret_*과 const_*는 어떤 경우에도 실질적인 내부 처리는 (a)뿐인(= 없음).. 참 허무한 연산자이다. 실질적인 처리가 없지만 이 숫자값을 해석하는 방식을 변경하는 이유는 분야별로 여럿 존재할 수 있다는 뜻이다.

재래식 C-style cast는 따지고 보면 1~3을 그냥 싸잡아서 구분 없이 수행해 준다. 그런데 가끔 드물게 다중· 가상 상속 관계의 타입 포인터끼리 형변환을 할 때 정석대로 보정 연산을 거친 포인터를 원하는지(static_*), 아니면 이것도 아무 보정 없이 동일한 메모리 주소에서 타입만 바꿔서 해석하고 싶은지(reinterpret_*) 모호해질 때가 있다.

이런 문제도 있고, 또 C++에다가 좀 제대로 된 객체지향 언어의 기능을 뒤늦게 갖추려다 보니 새로운 형변환 메커니즘이 필요해졌다. 이쯤 되니까 형변환 연산자도 별도의 예약어로 도입해서 구분하지 않고서는 도저히 버틸 수 없는 지경이 됐다. 그럼 다음으로 제일 괴물인 형변환 연산자에 대해서 살펴보도록 하자.

4. dynamic_cast

가상 함수를 쓰면 기반 클래스의 포인터를 주더라도 자신이 실제로 속한 파생 클래스에 해당하는 멤버 함수가 알아서 호출된다.
그리고 다중 상속 때 가상 상속을 쓰면, 여러 부모 클래스들이 동일한 조부모 클래스로부터 상속받았을 때 공통 조부모가 한 번만 상속되는 마술(?)이 일어난다. 물론 객체지향 언어에서 유연한 코드 재사용성을 보장하는 모든 마술에는 그에 상응하는 성능 오버헤드가 대가로 따른다는 점은 감안할 필요가 있다.

그런데 과거의 C++은 상속과 함수 호출에서 이렇게 언어 차원의 동적 바인딩이 지원되는 것과 대조적으로, 형변환에는 "동적 바인딩 + 무결성"을 보장하는 메커니즘이 딱히 없었다. 이놈이 A의 파생 클래스이긴 하지만 더 구체적으로 A의 자식들 중에 B가 아닌 C의 파생 클래스가 진짜로 맞는지, 이 멤버에 접근하고 이 함수를 호출해도 안전하겠는지 말이다.

C++은 void*가 있을지언정, 언어 차원에서 모든 클래스들의 공통 클래스(Java의 Object 같은)라는 개념이 없다. 그리고 클래스 내부의 vbtl에 직접 접근한다거나, 가상 함수의 포인터 값을 보고 클래스 종류를 판별할 수 있을 정도로 C++이 ABI가 몽땅 왕창 노출돼 있느냐 하면 그렇지도 않다(겉에는 공통의 썽킹 함수의 주소만 노출돼 있기 때문). 뭔가 어정쩡하다.

그러니 MFC 같은 옛날 라이브러리들은 자체적으로 CRuntimeClass 같은 타입 메타정보를 구비하고, 이놈이 CObject의 파생이긴 하지만 특정 클래스의 파생형이 정말 맞는지 런타임 때 확인하는 함수를 자체적으로 구현해야 했다.
C++이 아무리 C의 저수준 고성능 제어 이념을 계승했다 해도 명색이 객체지향 언어인데 그런 기능조차 없는 건 좀 아니라 여겨졌는지, 훗날 언어 차원에서 타입 식별 정보와 전용 형변환 연산자가 도입됐다. 그 결과물이 바로 dynamic_cast이다.

함수도 virtual, 상속도 virtual인 걸 감안하면 얘의 이름은 기술적으로 virtual_cast라 해도 과언이 아닐 듯하다. 하지만 static_* 이라는 단어가 이미 있으니 그것보다 더 값비싼 형변환이라는 의미로 dynamic_*이라는 이름이 최종적으로 붙었다. static_*은 이놈이 진짜 조상 관계가 맞는지 확인하지 않고 그냥 O(1) 복잡도짜리 기계적인 오프셋 보정만 해서(필요한 경우) 파생 클래스로 형변환해 주는 반면, dynamic_*은 타입 식별자를 직접 확인까지 한다는 것이다.

가상 함수의 구현을 위해서는 함수 포인터 테이블과 테이블의 포인터(멤버)가 필요하고, 가상 상속의 구현을 위해서는 기반 클래스의 포인터(멤버)가 필요하다. 그것처럼 동적 바인딩 형변환을 구현하려면 클래스 이름과 계층 관계를 기술하는 메타데이터와 함께 그놈의 포인터(멤버)가 필요하다.

가상 함수와 가상 상속 지원을 위한 데이터는 비공개로 꽁꽁 감춰져 있는 반면, 동적 바인딩 형변환을 위한 타입 식별자 데이터는 공식적으로 스펙이 공개돼 있고 일반 프로그래머들이 언어 요소를 통해 접근할 수 있다. 일명 RTTI (run-time type info)이다.
#include <typeinfo>를 한 뒤 typeid() 연산자로 const type_info&라는 구조체, 아니 클래스를 들여다보면 된다. typeid는...

  • sizeof와 마찬가지로 오버로딩 할 수 없다.
  • sizeof와 마찬가지로 타입 이름과 일반적인 값을 모두 받을 수 있다. 단, sizeof는 값이 올 때는 괄호를 생략할 수 있는 반면, typeid는 그렇지 않아서 언제나 뒤의 피연산자를 괄호로 싸야 한다.
  • sizeof는 결과값이 무조건 정적 바인딩으로 구해지는 반면, typeid는 바인딩이 정적과 동적 사이에서 어정쩡하다. 피연산자가 타입 이름이거나, 값이더라도 int 같은 primitive type 내지 상속이고 가상 함수고 아무것도 없는 구조체라면.. sizeof와 마찬가지로 수식을 실제로 evaluate하지 않는다. 그러나 뭔가 상속 관계 규명이 필요하다 싶은 개체라면 런타임 계산이 행해진다.

쉽게 말해 typeid는 MFC로 치면 obj->GetRuntimeClass()와 RUNTIME_CLASS(classnam)의 역할을 혼자 모두 수행한다는 뜻이다.
그럼 관련 타입들에 대해 DECLARE_DYNAMIC 내지 IMPLEMENT_DYNAMIC도 어딘가에 행해져야 할 텐데, 그건 C++ 컴파일러가 typeid 연산자가 쓰인 곳을 총체적으로 따져서 알아서 처리해 준다.

이런 RTTI 기능은 대다수의 C++ 컴파일러에서 사용 여부를 옵션으로 지정할 수 있게 돼 있다.
RTTI를 사용한 상태에서 dynamic_cast를 사용하면 실제 타입이 그게 아닌데 그 파생 타입으로 형변환을 시도하는 경우.. 피연산자가 포인터였다면 NULL이 날아오고, null이 가능하지 않은 참조자를 줬다면 예외(exception)를 날리게 된다.
이것도 다중 상속까지 생각한다면 상속 관계 그래프를 타고 오프셋을 보정하는 알고리즘이 가히 판타지가 될 것 같다.

이상.
객체지향 언어라는 게 그냥 구조체에다가 this가 자동으로 전달되는 함수가 같이 딸려 있고, 상속에 다형성 정도 지원되는 게 전부라고 생각하는 게 얼마나 순진한 생각인지 알 것 같다.
PC 환경에서 C를 초월하여 C++이 보급된 게 1990년대 초쯤인데, 이 무렵에 템플릿과 다중 상속도 도입됐다. 처음부터 있었던 게 아니다. 그리고 그때부터 C++은 뭐랄까, 괴물 같은 복잡도를 자랑하는 치떨리는 언어로 변모한 것 같다.

구리고 지저분한 면모가 많지만 그래도 그 정도 객체지향 이념에다가 고성능 저수준 제어까지 이만치 잡은 건 얘가 C++이 가히 독보적인 것 같다.
그러니 형변환 연산자도 언어를 따라 이렇게 복잡해진 것이다. 일례로, Java는 final이 있을지언정 포인터도 없고 const니 volatile 같은 건 신경 쓸 필요도 없는데 뭐 저런 구분이 필요하겠는가? 그러니 지금도 여전히 C-style cast만으로도 충분한 것이다.

Posted by 사무엘

2018/03/25 08:30 2018/03/25 08:30
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1471

1. Java 언어가 쓰이는 곳

Java는 C++과 달리 로컬 환경용으로는 그다지 재미를 못 본 언어 및 런타임 환경이다.
콘솔(게임기 말고, 명령 프롬프트..)용 프로그램이 Java로 만들어진 건 거의 못 봤다. 오히려 Java는 한때는 표준 입력으로부터 숫자 한 줄 입력받는 것조차도 온갖 패키지를 import하고 예외 처리 try catch까지 갖춘 뒤에야 가능한 불편한 언어였다.

GUI(프레임워크 이름이 Swing이던가?)로 넘어오면 VirtualBox나 OpenOffice처럼 일부 크로스 플랫폼 프로그램이 GUI 껍데기를 Java로 만든 경우가 좀 있다. (Windows 한정으로는 C#의 Windows Form와 경쟁?) 아, 직장에서 일정 관리 용도로 사용하는 ProjectLibre도 흔치 않은 Java 기반 로컬 GUI 프로그램이다.
하지만 Java가 제공하는 GUI는 외형이 네이티브 GUI에 비해 이질적이고 느리고 런타임 오버헤드가 커서 범용성 말고는 가성비가 좋지 않았다.

한편, Java는 애플릿(Applet)이라는 이름으로 웹(클라이언트)에서 돌아간 적도 있다. 웹에서 꽤 고차원적인 수학 그래픽, 화면 왜곡과 전환 애니메이션, 시뮬레이션(특히 교육용) 등을 출력할 목적으로 플래시와 경쟁하는 구도로 한때 쓰였으나.. 그것도 다 지나간 일이다. 현재는 망했으며, 개발사로부터 지원도 끊긴 지 오래다.

오늘날 Java는 로컬 PC가 아닌 안드로이드 앱, 그리고 jsp 기반 서버사이드 언어(웹 서버)로 회생해 있다.
뭐, Java 런타임을 컴퓨터에 설치해 보면 설치 중에 '전세계 수십억 개의 기기가 Java를 기반으로 돌아가고 있습니다'라고 자랑하는 문구가 뜨기도 하는데, 그것도 임베디드보다는 모바일을 말하는 게 아닌가 싶다.

Java는 오늘날 그렇게 됐고 로컬 PC 환경에서는 지금도 수많은 프로그래밍 언어들이 난립해 있지만, 웹에서는 그야말로 Java에서 이름만 빌려온 JavaScript로 대동단결 천하통일이 이뤄진 게 신기하기 그지없다.

2. 새로운 언어

애플에서 Objective-C에 한계를 느끼고 Swift라는 언어를 만들었더니,
그에 질세라 안드로이드 진영에서도 Java 대신 '코틀린'이라는 완전히 다른 언어를 만들었다. 왕년에 C++ 컴파일러를 열심히 만들었던 사람이 D 언어를 개발했듯, Java 기반 안드로이드 개발 환경을 열나게 만들었던 JetBrains라는 회사에서 전용 언어도 만들고 이를 Google에서도 채택한 것이다.

오늘날 베이직은 마소에서밖에 만들지 않는 언어가 됐고, 파스칼은 그냥 델파이의 전유물이 됐고 이제는 델파이라는 이름 자체가 RAD 툴 겸 프로그래밍 언어의 이름으로 등극했다. 언어가 워낙 많이 마개조됐기 때문이다. 또한 옵씨의 경우는 애초부터 애플이 언어에 대한 일체의 권리를 언어 고안자로부터 사 버려서 사유화했다.
저런 언어들에 비해, C/C++은 너무 심하게 파편화가 됐을지언정--언어 문법 자체보다는 라이브러리나 ABI 계층에서--, 특정 기업에 의해 좌지우지된다는 느낌은 상대적으로 덜 든다.

Visual Studio 등 요즘 IDE들은 프로젝트 전체에 존재하는 클래스들을 쭉 보여주는 기능이야 당연히 기본으로 갖추고 있다. 그런데 클래스들을 namespace 계층 + 이름의 알파벳 순으로만 보여주는 게 아니라, 기반 클래스별로 분류해서 보여주는 기능이 있으면 프로젝트들의 성격을 파악하는 데 더 도움이 될 것 같다. 그러면 그 클래스들의 성격을 파악할 수 있으니 말이다.

3. 간단한 자료형과 복잡한 자료형 or 클래스 객체

오늘날 객체지향을 표방한다는 많은 고급 언어들이 int 같은 (1) primitive type과 (2) 클래스 객체, 혹은 (1) 스칼라와 (2) 복합 자료형(리스트 같은)을 서로 구분해서 다루고 서로 다르게 취급한다. 함수 인자와 리턴값을 주고받을 때 (1)은 값을 그대로 전달하고 (2)는 메모리에 한 인스턴스만 둔 뒤 주소값만 주고받고 레퍼런스 카운팅을 하는 것이 대표적인 차이점 되겠다. Java, 파이썬 등 여러 언어들이 이런 정책을 사용한다.

순수 극한 객체지향 언어 중에는 1과 2의 구분조차 없애고 모든 것을 객체로 취급하여 타입 식별자라든가 기반 클래스 소속을 부여하는 물건도 있다. 하지만 그렇게까지 하기에는 속도와 메모리 오버헤드가 너무 크고 실용성도 떨어지니 많은 언어들이 최소한의 primitive type 정도는 허용하는 타협을 한다.

C++이야 어느 타입이건 어느 방식으로 전달할지(값, 주소/참조)를 몽땅 명시적으로 수동 지정이 가능하다. 그러나 포인터를 노출하지 않는 언어에서는 그런 구분이 프로그래머의 지정 없이 자동으로 행해진다. 포인터가 없는 언어라고 해서 포인터가 원래 하던 일 자체를 안 하는 것은 전혀 아니니까 말이다.

그런데 1과 2의 얼추 중간쯤에 속하는 물건은 문자열, 그리고 단순 구조체 정도다.
문자열이야 언어 차원에서 상수 리터럴도 존재하고 정말 기본 자료형과 복합 자료형의 중간에 속하는 독특한 물건인지라, 각 언어와 라이브러리마다 구현 형태가 제각각이다.

그리고 생성자, 소멸자, 상속, 가상 함수 따위 전혀 없고 레알 int 같은 primitive type의 묶음으로만 이뤄진 레알 C 스타일 구조체를 프로그래밍 용어로는 POD(plain old data)라고 한다. C++의 경우 POD 구조체만이 중괄호 { }를 이용한 멤버 별 값 초기화가 가능하다.
물론 POD도 개당 수십~수백 바이트를 차지하는 덩치 큰 놈이 될 수도 있지만 이는 논외로 하고..

단순 구조체를 클래스와 구분시킨 것은 C#의 신의 한수로 보인다. 사실, POINT, SIZE, COMPLEX(복소수)처럼 숫자 둘로만 달랑 이뤄진 간단한 구조체는 상속이 뭐고 없고 함수 인자도 그냥 값 형태로 전달하고 싶은 그런 물건들이니 말이다.

4. sizeof 연산자가 없음

개인적으로 Java를 쓰면서 C++에 비해 굉장히 특이하다고 오래 전부터 생각했던 점은..
가장 먼저, (1) int를 함수에다 주소/참조로 전달을 할 수 없어서 swap 함수를 구현할 수 없다는 것이다. int는 무조건 값으로만 전달되니 말이다.

(2) 클래스에 소멸자라는 게 없다 보니, 메모리는 GC 덕분에 뒷일 생각할 필요 없이 new만 늘어놔도 되는데 파일은 닫는 코드를 수동으로 매번 써 줘야 하는 게 처음엔 아주 웃기게 느껴졌었다.

(3) 그리고 끝으로.. sizeof 연산자가 없는 것에서 한번 더 경악했다. 수동 메모리 할당이 없고 포인터도 없고, sizeof마저 없다는 건 어떤 개체의 메모리 내부 덤프 같은 건 꿈에도 생각하지 말라는 얘기다.
primitive type이야 개별 크기가 어떤 기기의 JVM에서도 불변 동일하니(가령, int 4바이트, long 8바이트..) 굳이 sizeof에다 요청하지 않아도 되고..

하긴, 특정 개체의 메모리 주소 같은 걸 어디에다 낼름 누설해 줄수록 프로그램의 엔트로피가 커지고 Garbage collector가 추적해야 하는 영역이 넓어지고, 메모리 누수와 잘못된 포인터 에러가 날 확률이 높아지니.. 그런 건 최대한 애초에 할 필요가 없게 만들어야 할 것이다.
하지만 바이너리 덤프를 바로 못 하면 파일을 읽고 쓰는 작업도 좀 불편할 것 같다. 하다못해 과거에 베이직조차도 포인터는 없어도 숫자의 binary dump를 문자열 형태로 얻거나 그걸 디코딩하는 함수는 있었거늘 말이다.

C#은 Java처럼 가상 머신 기반에 자동 메모리 관리가 제공되는 언어이지만 이런 것들이 Java보다는 융통성이 있다. ref라는 키워드가 있어서 primitive type도 call by reference가 가능하다.
그리고 sizeof 연산자가 제한적으로 지원된다. primitive type과 단순 구조체 한정으로만 사용 가능하며, 자동 관리를 받는 자기 객체(managed class)에 대해서는 sizeof를 여전히 할 수 없다.

C#의 초창기 구버전에서는 아예 unsafe 코드 안에서만 sizeof를 사용할 수 있었지만 나중에 제약이 완화되었다.
그래도 C#과 Java 어느 경우든, sizeof라는 건 저수준 메모리 접근과 관계가 있는 기능이지, 가상 머신 고수준 코드와는 어울리지 않는 건 공통인 듯하다.

5. 객체의 배열

그러고 보니 Java는 클래스 객체들이 다 기본적으로 포인터 단위로 취급된다는 특성상, 객체의 동적 배열을 만드는 것도 좀 불편하다.

MyObject[] arr = new MyObject[n];

이건 MyObject들을 담을 배열 객체부터 하나 만드는 것이다. 그 뒤에 arr[0]부터 arr[n-1]에다가 MyObject의 인스턴스를 new로 할당하는 건 사용자가 또 직접 해야 한다.
C++이야 Java처럼 행동하고 싶으면 MyObject **arr을 하면 되고, 아니면 MyObject가 default 생성자만 있다면 곧장 MyObject *arr = MyObject[n]으로 객체의 배열을 만들 수 있으니 융통성이 있다.

그리고 한 객체가 여러 클래스들을 돌아다니면서 쓰이다 보면, 이 오브젝트가 값만 일치하는 게 아니라 본질적으로 그 오브젝트가 맞는지.. C++로 치면 주소값이 같은지, Java조차도 문자열은 equals 대신 ==를 써서 행하는 그 비교 대상값이 일치하는지.. 주소를 직접 확인하고 싶은 경우가 있다.

C/C++은 & 연산자가 있으니 일도 아닌 반면, Java는 디버거 창이 아닌 로그 확인을 위해서는 toString을 일일이 해 줘야 하는 것이 불편한 점으로 남는다.
포인터라는 게 위험하다고 직접 노출을 금기시하다 보니, 포인터로 직통으로 할 수 있는 일까지 좀 불편하게 바뀌는 건 어쩔 수 없나 보다.

6. 클로저

C++은 수동 메모리 관리뿐만 아니라 pointer-to-member(더 나아가 함수 포인터라는 것 자체까지)라는 것도 오늘날의 타 언어들이 제공하는 깔끔한 클로저와는 전혀 관계 없는 원시적이고 지저분한 물건이다. C/C++은(정확히는 C가) machine word를 정말 좋아하는 언어이며, '포인터' 하나만으로 간편하게 구현 가능하지 않은 요소는 쿨하게 제공 안 하는 게 전통적인 설계 철학이었다.

그러니 함수 안에 함수를 만드는 걸 지원하지 않으며(당대의 경쟁 언어이던 파스칼과 달리), C++도 클래스 안에 클래스, 함수 안에 지역 클래스 같은 건 전적으로 접근성 scope 구분만 할 뿐, 자기 밖에 있는 클래스 멤버나 함수 지역변수에 자동으로 접근하는 메커니즘 같은 건 제공하지 않는다. 즉, Java 용어로 말하자면 static class만 지원한다는 것이다.

C++만 쓰다가 Java나 Objective-C 같은 타 언어에서 this 내지 상부 클래스 멤버에 접근 가능한 함수를 스레드 콜백 등으로 자유자재로 넘겨줄 수 있는 걸 보니 아주 신기했다. 요즘은 함수형 언어 영향을 받아서 함수 몸체를 이름조차 안 붙이고 바로 넘겨줘도 되니 더 편하다.

이것도 타 언어들이 더 객체지향 철학에 따른 것이고, C++이 기괴하고 경직된 구조의 언어인 셈이다. 물론, C++의 사고방식은 저런 유연한(?) 함수 포인터(C++ 용어), 셀렉터(옵C 용어), 클로저(???)를 구현하기 위해 내부적으로 부과되는 시공간 성능 오버헤드가 무엇인지 생각하는 데는 도움이 될 듯하다.

C++은 저게 없는 대신에 P2M을 갖고 있는 셈인데.. C++의 또 다른 괴물 기능인 다중 상속과 연계하려다 보니 P2M도 도저히 '날포인터' 하나만으로 간편하게 구현할 수가 없어졌으니 참 아이러니이다. 이건 언어 설계 차원에서의 결함이나 마찬가지라고 봐도 할 말 없을 정도이며, 이와 관련하여 본인이 몇 년 전에 글을 쓴 적이 있다.

그나저나 Java의 상속 "extends A"는 C++로 치면 ": public A", 다시 말해 언제나 public 상속과 같은 것이겠지?
난 부모 멤버들에 대한 접근에 더 많은 제약을 가하는 private, protected 상속은 사용해 본 적이 없다.

7. 자동 메모리 관리, 동적 배열 등, 나머지 생각들

뭐, 직장에서 Java 개발도 좀 해 보니 깔끔한 1파일 1클래스 구조에다 헤더와 소스 구분이 없고 코드 파싱과 빌드가 정말 광속인 것(C++ 외의 타 언어들이 대부분 그렇지만), throw IllegalArgumentException("value must be >=0") 예외 처리가 사실상 assertion failure이나 마찬가지이니 assert(0 && "error") 이런 테크닉이 없어도 되는 것들은.. 마음에 든다.

ABI 계층이 파편화 없이 딱 잘 통합돼 있고, 무식한 헤더 파일 대신 패키지로부터 추출· 복원된 프레임워크 소스와 코딩 컨벤션.. 매번 다시 컴파일 되면서 특정 타입과 완전히 결합해 버리는 C++의 템플릿 대신에, 진짜로 유연한 void*를 캡슐화했다고 볼 수 있는 제네릭.. 이런 건 부럽기도 하고 현대의 프로그래밍 언어와 프로그래밍 환경이 얼마나 발전했는지를 뒷북으로나마 느낀다.

프로그래밍에서 "네이티브 코드 + 메모리 100% 수동 관리"(C/C++) 아니면 "가상 머신 + garbage collector 기반의 메모리 자동 관리"(C#/Java)의 차이는, 자동차 운전으로 치면 수동 vs 자동 변속기와도 비슷할 정도로 큰 차이인 것 같다. 자동차에서는 면허 조건이 달라질 정도로 큰 차이를 만들며, 프로그래밍에서도 뒷일 생각 안 하고 마음대로 new를 남발해도 되냐 그렇지 않느냐의 차이는 매우 크다.

(그럼 메모리가 자동 관리되는 언어에서는... 무슨 서버나 GUI 프로그램이 아니고 일체의 아이들 타임이 없이, 자기 할 일만 일괄 처리하고 끝나는 콘솔(명령 프롬프트)+단일 스레드 프로그램이라면 GC가 언제 어떻게 개입하여 동작하는지 의문이 든다. 뭐 그냥 메모리 할당 부분에서.. 자원 반납 없이 지금까지 메모리를 사용한 양이 도를 넘어선다 싶으면 그때 동작할 수도 있긴 하겠다.)

C++을 까는 사람들의 심정은 이해한다. 하지만 C++이 그렇게 지저분하고 자비심이 없는 대신에 어떤 넘사벽급의 강력한 네이티브 코드를 생산할 수 있는지, 그리고 Java가 편한 대신에 내부적으로 성능을 얼마나 많이 희생했고 런타임 오버헤드가 더해졌는지를 전부 논하지 않고 단편적인 비교만으로 언어 호불호를 논하는 것은 바람직한 태도가 아니라고 여겨진다. 둘 다 장단점이 있고 고유한 주 용도가 있는 법이다.

옛날에 잠깐 써 봤던 파스칼은 포인터도 있고 자유로운 call by reference도 지원했지만 그 포인터가 C처럼 막강하고 배열과 연계되는 건 절대 아니며, 동적 배열을 못 만들었다. 아무리 파스칼이 교육용 언어를 표방하고 만들어졌다 해도 베이직으로도 가능한 기능이 없는 건 말이 안 되니 저건 후대의 파생 언어에서 개선되었지 싶다.

Posted by 사무엘

2018/03/13 08:34 2018/03/13 08:34
, ,
Response
No Trackback , 4 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1467

Windows API에서 BitBlt는 DC간에 비트맵 블록을 찍어 주는 아주 중요한 함수이다.
장치 독립 비트맵인 DIB라는 게 컬러 비트맵의 원활한 처리를 위해서 Windows 3.0에서 처음 도입되었지, DDB와 관련된 CreateBitmap, BitBlt 같은 함수, 그리고 컬러 brush 자체는 Windows의 초창기부터 있었다.
단순히 memcpy나 memmove 같은 함수와는 달리, BitBlt는 1차원이 아니라 2차원 평면을 표현하는 메모리 영역을 취급하는 관계로 동작 방식이 더 복잡하다.

BitBlt의 처리 대상인 두 DC는 내부 픽셀 포맷 같은 게 당연히 서로 호환이 돼야 한다.
원시적인 모노크롬 비트맵의 경우, 위치와 크기에 해당하는 좌표의 x축이 바이트 경계(8의 배수)로 딱 떨어지지 않을 때의 복잡한 보정이 필요하다.
그리고 원본과 타겟 DC가 동일한 경우, memmove 같은 overlap 처리도 x축과 y축 모두 고려하여 memmove보다 더 복잡한 상황 가짓수를 처리해야 한다.

BitBlt는 비트맵을 그냥 찍는 게 아니라 원본(S), 타겟(D)에 대해서 비트 단위 연산을 시킨 결과를 집어넣도록 아주 범용적으로 설계돼 있다. 일명 raster operation이다.
게다가 래스터 연산의 피연산자가 저 둘만 있는 게 아니라 타겟 DC에 지정되어 있는 브러시 패턴(P)까지.. 무려 세 개나 존재한다. 그래서 BitBlt는 PatBlt라든가 InvertRect 함수가 하는 일을 다 할 수 있을 정도로 범용적이다.

원본 S를 있는 그대로 복사해 넣는 건 SRCCOPY이고, 그냥 타겟 비트맵을 반전만 시키는 건 ~D이다.
그리고 마스크 비트맵(흰 배경에 검은 실루엣) M과 그림 비트맵(검은 배경에 실제 그림) S에 대해서 D&M|S (각각 and, or 연산)를 해 주면 보다시피 직사각형 모양이 아닌 스프라이트를 찍을 수도 있다. 래스터 연산을 갖고 할 수 있는 일이 이렇게 다양하다.

BitBlt가 사용하는 래스터 연산은 3비트짜리 정보(S, D, P)에 대해서 임의의 1비트(0 또는 1) 값을 되돌리는 함수라고 볼 수 있다. 이 함수가 받을 수 있는 인자의 종류는 8가지(2^3)이고.. 서로 다른 래스터 연산 함수는 2^(2^3)인 총 256가지가 존재할 수 있다.
그리고 SRCCOPY, SRCPAINT 같은 것들은 그렇게 존재 가능한 래스터 연산 함수를 나타내는 값이다. 각 변수별로 S는 11110000, D는 11001100, P는 10101010 이런 식으로 정해 놓으면 00000000부터 11111111까지가 S|D, D&~P 등 각 변수들을 조작한 모든 가짓수를 나타내게 된다.

그런데 컴퓨터에서 범용성과 성능은 대체로 동전의 양면과도 같아서 하나를 살리다 보면 다른 하나를 희생해야 하는 관계이다.
the old new thing 블로그의 설명에 따르면.. 과거 16비트 시절에 BitBlt는 사용자의 요청을 파악해서 좌표 보정 같은 전처리 준비 작업을 한 뒤, 실제로 for문을 돌면서 점을 찍는 부분은 내부 템플릿으로부터 기계어 코드를 실시간으로 생성해서 돌렸다고 한다. 이게 무슨 말인가 하면.. 단순히

void Loop(int l, int t, int r, int b);
Loop(r.left, r.top, r.right, r.bottom);

수준이 아니라

template<int LEFT, int TOP, int RIGHT, int BOTTOM>
void Loop()
{
    for(int j=TOP; j<BOTTOM; j++)
        for(int i=LEFT; i<RIGHT; i++)
            어쩌구저쩌구;
}

Loop<r.left, r.top, r.right, r.bottom>();

이런 걸 추구했다는 뜻이다.
비트맵을 찍을 때 범위 체크는 매 픽셀마다 그야말로 엄청나게 자주 행해지는 일이다. 그러므로 그 한계값을 컴퓨터의 입장에서 변수가 아닌 상수로 바꿔 버리면 레지스터도 아끼고 성능 향상에 도움이 될 수 있다.

16비트 Windows에는 32비트 OS 같은 가상 메모리 관리자라는 게 없으며, Java/.NET 같은 가상 머신과 garbage collector도 없었다. 그 대신 (1) 메모리의 단편화를 방지하기 위해 moveable한 메모리 블록들의 주소를 수동으로 한데 옮기고 (2) discardable한 메모리 블록을 해제하는 동작이 있었다.

가상 머신이 없으니 just-in-time 컴파일이라는 개념도 있을 리 없다. 하지만 BitBlt의 저런 동작은 Java 내지 JavaScript의 JIT 같아 보이기도 한다. 물론 진짜 JIT 기술보다는 코드 생성 패턴이 훨씬 더 정향화돼 있고 단순하지만 말이다. (뭐, BitBlt의 세부 알고리즘 자체가 단순하다는 뜻은 아님)
그리고 그 시절엔 DEP도 없었다. 메모리에 데이터가 담겼건 실행 가능한 코드가 담겼건, 아무런 차별이 없었다.

게다가.. 저 때는 그래픽 출력과 관련된 하드웨어 지원조차도 없었다. 1990년대 일반 VGA 화면에서는 화면이 갱신될 때 마우스 포인터의 잔상이 남지 않게 하는 처리조차도 소프트웨어적으로 해야 했다. IBM 호환 PC는 전통적으로 게임기용 CPU에 비해서 멀티미디어 친화적이지 않은 컴퓨터로 정평이 나 있었으며, 그나마 좀 미려한 그래픽 애니메이션을 보려면 한 프로그램이 하드웨어 자원을 독점하는 도스밖에 답이 없었다. 그러니 BitBlt 같은 함수는 CPU 클럭을 하나라도 줄이려면 정말 저런 눈물겨운 최적화라도 해야 했던 것이다.

이런 여러 이유로 인해 16비트 Windows 시절에는 지금의 32/64비트보다 어셈블리어라든가 실시간 코드 생성 테크닉이 확실히 더 즐겨 쓰였던 것 같다.
외부에서 호출 가능한 콜백 함수를 지정하기 위해 껍데기 썽킹 함수를 생성해 주는 MakeProcInstance (해제하는 건 FreeProcInstance)부터가 그 예이며..

또 그때는 API 훅킹도 대놓고 훅킹 대상 함수 메모리 주소에다가 내 함수로 건너뛰는 인스트럭션을 덮어쓰는 식으로 행해졌다. 지금이야 이식성 빵점에 가상 메모리와 프로세스 별 메모리 보호, 멀티스레드 등 여러 이유 때문에 위험성이 크고 사용이 강력히 비추되는 테크닉으로 봉인됐지만 말이다.

Windows 95는 비록 32비트 명령어와 32비트 메모리 주소 공간을 사용하지만 GDI 계층은 여전히 16비트 코드를 쓰고 있으니 내부적으로 과거의 테크닉이 그대로 쓰였다. 그 당시의 PC 환경에서는 최고의 성능을 발휘했겠지만, 그리기 코드 자체의 32비트화, 좌표계의 32비트 확장이라든가 멀티스레드 대비 같은 건 전혀 불가능한 레거시로 전락할 수 밖에 없다.

그에 반해 Windows NT의 BitBlt는.. 이식성이 전혀 없는 기계어 코드 실시간 생성 같은 테크닉이 쓰였을 리가 만무하며, 어느 플랫폼을 대상으로나 동일하게 적용 가능한 C 코드로만 구현되었을 것이다. 겉으로 하는 동작은 비슷해 보여도 내부 구현은 완전히 달랐으며, 같은 사양의 PC에서 속도가 더 느린 것은 어쩔 수 없었다. 그 대신 NT의 코드는 플랫폼과 시대를 뛰어넘어 살아 남을 수 있었다.

뭐, 1990년대에는 OS/2도 얼리어답터들이 관심을 갖던 레알 32비트 운영체제이긴 했는데.. 얘는 Windows NT와 달리 32비트 계층도 코드가 전반적으로 그리 portable하지 않았다고 한다. 그러니 타 CPU로 포팅은 고사하고 훗날 같은 CPU에서 64비트에 대처하는 것도 유연하게 되기 어려웠으리라 여겨진다.

그에 반해, OS가 아니라 게임이긴 하다만 Doom은 Windows NT와 비슷하게(약간만 타이밍이 더 늦은) 1993년 말에 첫 출시됐는데.. 세계를 놀라게 한 3차원 그래픽을 실현했음에도 불구하고 어셈블리어를 거의 사용하지 않고 순수하게 C 코딩만 한 거라는 제작사의 증언에 업계가 더욱 충격에 빠졌다. 사운드처럼 상업용 라이브러리를 사용한 부분의 내부 구현을 제외한 전체 소스 코드가 수 년 뒤에 공개되면서 이 말이 사실이었음이 입증되었다.

도스에서 Doom을 가능케 한 것은 쑤제 어셈블리어 튜닝이 아니라 Watcom 같은 최적화 잘 해 주는 32비트 전용 C 컴파일러였다.
엔진 코드가 C로 나름 이식성 있게 깔끔하게 작성된 덕분에 Doom은 소스가 공개되자마자 오픈소스 진영의 덕후들에 의해 온갖 플랫폼으로 이식되면서 변종 엔진과 게임 MOD들이 파생돼 나올 수 있었다. 물론 소스 공개 이전에도 상업용으로 갖가지 플랫폼에 출시되기도 했고 말이다.

오늘날이야 컴퓨터 아키텍처라는 게 2, 30년 전 같은 춘추전국시대가 아니며, 가상 머신이라든가 웹 같은 환경도 발달해 있다. 그러니 "C언어는 이식성이 뛰어나다" 이런 식의 진술이 뭐 거짓말은 아니지만 약간 어폐가 있다. 하지만 BitBlt API부터 시작해서 이식성 있는 코드와 그렇지 않은 코드가 궁극적으로 어떤 상태가 되었는지를 생각해 보니 이 또한 의미 있는 일인 것 같다.

다시 Windows API 얘기로 돌아와서 글을 맺자면..

  • BitBlt는 비트맵 출력 API 중에서는 그나마 가장 기본적인 형태이다. StretchBlt는 비트맵을 크기를 변형(확대· 축소)해서 찍을 수 있다. 이들의 DIB 버전에 대응하는 것은 각각 SetDIBitsToDevice와 StretchDIBits이다.
  • TransparentBlt와 AlphaBlend는 아까 같은 AND/OR 래스터 연산 대신 color key 내지 알파 채널을 적용해서 투명색이 적용된 비트맵을 찍어 주는 함수이다. Windows 98/2000에서 새로 추가됐다. 본인은 사용해 본 적이 없다.
  • PatBlt는 원본 DC의 지정이 없이 브러시 패턴과 타겟 DC와의 래스터 연산만이 가능한 마이너 버전이다.
  • PlgBlt와 MaskBlt는 마스크 비트맵까지 한꺼번에 받아서 스프라이트 처리가 가능한 버전이다. 거기에다 PlgBlt는 일차변환을 적용해서 직사각형이 아닌 임의의 평행사변형 모양으로 비트맵을 찍을 수도 있는데.. Windows 9x에서는 지원되지 않고 NT에서만 존재해서 그런지 본인 역시 이런 함수가 있다는 걸 아주 최근에야 알게 됐다.

실무에서는 이렇게 비트맵을 한꺼번에 찍어 주는 함수를 쓰지, SetPixel이라든가 무식한 FloodFill 같은 기능은 그래픽 출력에서 쓸 일이 거의 없는 것 같다.
BitBlt과 유사 계열의 비트맵 출력 GDI 함수들은 비트 연산을 다루는 시대 배경에서 만들어진 만큼, 요즘 PNG 이미지처럼 비트맵 내부에 들어있는 알파 채널을 제대로 취급하지 못한다. 그리고 비트맵을 확대해서 출력할 때의 안티앨리어싱도 부드럽게 처리를 못 한다. 레거시 코드에다가 그런 기능까지 플래그로 넣기에는 너무 복잡하고 지저분해져서 그렇지 싶다. 그도 그럴 것이 GDI는 하드웨어 통합적으로 얼마나 추상적으로 설계되었던가?

현대의 화면 래스터 그래픽에서 필요로 하는 최신 기능들은 한때 GDI+가 따로 담당하다가 요즘은 그것도 너무 느리다고 도태됐고 Direct2D 같은 다른 패러다임으로 옮겨 갔다.

Posted by 사무엘

2018/03/10 08:37 2018/03/10 08:37
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1466

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

컴퓨터 프로그램의 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

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

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

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

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

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

본인은 평소에는 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

« Previous : 1 : ... 5 : 6 : 7 : 8 : 9 : 10 : 11 : 12 : 13 : ... 23 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/11   »
          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 29 30

Site Stats

Total hits:
2988894
Today:
454
Yesterday:
1477