« Previous : 1 : ... 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : 11 : ... 31 : Next »

1. 문법 함정

C/C++에서 연산자로 쓰이는 토큰(문자)들 중에는 문맥에 따라서 의미가 중복될 수 있는 것이 있다.
예를 들어 * () [] 같은 토큰은 값을 계산하는 수식에서 쓰일 때와, 변수를 선언할 때 의미가 서로 다르다. 한쪽에서는 인근의 변수가 배열· 포인터· 함수 타입임을 나타내지만, 다른 쪽에서는 실제로 배열 첨자나 포인터를 역참조하고 함수를 호출하는 역할을 한다.

심지어 =조차도 int a=5; 와 그냥 a=5; 에서 =는 문법적인 의미가 서로 동일하지 않다. 똑같이 =를 썼더라도 중괄호를 동원하여 배열이나 구조체를 초기화하는 것은 일반 수식에서는 가능하지 않기 때문이다.

이런 것 말고도 콤마(,)의 경우.. 함수 인자 구분자와 쓰임이 완벽하게 겹친다. 그렇기 때문에 함수 인자에서 콤마 연산자를 쓰려면 수식을 괄호로 싸야 한다.

그리고 <>로 둘러싸인 템플릿 인자에서 부등호 내지 비트 이동 연산자를 쓸 때도 상황이 좀 난감해진다. 템플릿 인자에 typename만 올 때는 <>가 모호성을 전혀 일으키지 않지만, 문제는 템플릿 인자로 정수도 들어올 수 있다는 것이다. 그리고 값이 컴파일 타임 때 결정만 될 수 있다면 정수값을 만들어 내는 각종 연산자들도 당연히 쓰일 수 있다.

template class Foo<size_t N> { .... };

Foo<(a>b ? 5:3)> bar1;
Foo<(MAX>>3)> bar2;

그러니 위와 같은 상황에서는 수식 전체를 괄호로 싸야만 한다. 괄호가 단순히 같은 수식 안에서 연산 우선순위를 조절할 때만 쓰이는 게 아니라는 점이 흥미롭다. 수식 영역과 함수 및 템플릿 인자 영역을 구분할 때도 쓰인다.

std::vector<std::list<int>> vl;

요렇게 중첩되었던 템플릿 인자들이 한꺼번에 종결될 때 > 사이를 강제로 띄우지 않아도 되게 컴파일러의 동작 방식 지침이 달라진 때가 내 기억이 맞다면 C++03과 C++11사이였지 싶은데.. 정확하게는 모르겠다.

그 밖에 2[a]가 가능하다는 C/C++의 변태적인 특성상, 람다와 관련해서 또 변태 같은 중의성을 만들 수 있지는 않으려나 궁금한데, 너무 머리가 아파서 더 생각해 보지는 않으련다.
요즘 C++은 auto라든가 using, delete를 보면 =를 사용하는 새로운 문법이 여럿 생긴 것 같다.

2. 비표준이지만 표준처럼 쓰이는 함수

C언어 라이브러리에 있는 모든 함수들이 100% 표준이고 어느 플랫폼에서나 동일하게 사용 가능한 게 아니다.
본인은 평소에 Visual C++만 쓸 때는 이런 걸 전혀 의식하지 않고 지냈는데.. strlwr과 심지어 내 기억이 맞다면 strdup도 macOS에서는 지원되지 않는 걸 최근에 확인하고는 놀랐었다.
물론 저런 함수들이야 하는 일이 워낙 간단하니 3분 만에 직접 짤 수도 있다. 하지만 핵심은 저건 universal한 표준이 아니라는 것이다.

Visual C++도 세월이 흐를수록 '표준 준수'를 강조하는 쪽으로 라이브러리의 디자인이 바뀌다 보니, 관례적으로 제공되긴 했지만 엄밀히 말해 표준이 아닌 함수들에 대해서는 앞에 밑줄을 붙여서 구분하는 추세이다.
하긴 그러고 보니, Visual C++을 업그레이드 한 뒤에 기존 코드가 컴파일되지 않아서 수정하던 내역 중에도 멀쩡한 함수 앞에다가 _를 붙이는 게 많았다. 일례로, 이분 검색 함수는 bsearch가 당당히 표준으로 등재돼 있지만, 그에 상응하는 선형 검색 함수는 표준이 아니어서 그런지 _lfind이다.

3. 스택 메모리의 임의 할당

그러고 보니 비표준 함수 중에는.. malloc의 변종으로서 가변 길이(= 크기가 런타임 때 정해지는) 메모리를 heap이 아닌 무려 현재의 스택 메모리에서 얻어 오는 alloca이던가 malloca인가 하는 물건도 있었다. 옛날 16비트 Turbo C에만 있는 줄 알았는데 현재의 Visual C++에서도 지원은 하는가 보다. 물론 앞에 밑줄은 붙여서 말이다.

얘는 C에서 문법적으로 가능하지 않은 동적 배열을 heap이 아닌 스택 메모리에 구현해 준다. 메모리 할당 속도가 heap을 다루는 것보다 훨씬 더 빠르며, 함수 실행이나 scope이 끝날 때 해제도 자동으로 되어 memory leak 걱정을 할 필요 없으니 편리하다. 지금 실행 중인 함수의 stack frame을 조작하는 물건이니, 겉으로는 함수 호출 같지만 실제로는 컴파일러 인트린식 형태로 구현되지 싶다.

이렇게 생각하면 얘는 장점이 많아 보이지만.. 일단 할당 장소가 장소인 관계로 (1) 수 MB 이상급의 대용량 메모리를 할당할 수 없으며, (2) 할당 방식의 특성상 heap 메모리처럼 할당과 해제를 무순으로 임의로 자유자재로 할 수 없다. (3) C++ 언어의 보조를 받는 게 없기 때문에 해제와 C++ 객체 소멸을 한데 연계할 수도 없다.

이런 한계로 인해 스택에서의 동적 메모리 할당은 생각만치 그렇게 유용하지 않다. 본인도 지난 20여 년 동안 C/C++ 프로그래밍을 하면서 이걸 사용해 본 적이 전혀에 가깝게 없었다.
저 함수가 괜히 비표준이 아닌 셈이다. 마치 정수 기반 고정소수점과 비슷한 위상의 이단아인 것 같다. 다만, 그럼에도 불구하고 본인은 이런 상황은 어떨까 하는 생각을 해 보았다.

생성자에서 문자열을 인자로 받아들여 적절한 처리를 하는 기반 클래스가 있다.
이걸 상속받아 파생 클래스가 만들어졌는데, 얘는 자주 쓰이는 문자열 패턴을 손쉽게 생성하기 위해 여러 개의 문자열이나 숫자를 숫자를 인자로 받으며, 이로부터 단일 문자열을 생성하여 기반 클래스의 생성자에다가 전달한다. 즉, 이런 꼴이다.

Derived::Derived(string arg1, string arg2, int num):
 Base( prepareArgument(arg1, arg2, num) ) {}

예시를 보이기 위해 편의상 string이라는 자료형을 썼지만, 실제로 저기서 쓰이는 것은 const char * 같은 문자열 포인터이다.
즉, 나는 Derived의 생성자에서 char buf[128] 같은 스택 기반 지역변수 배열을 선언한 뒤, 거기에다 arg1, arg2, num의 정보를 담고 있는 문자열을 담고 그걸 Base의 생성자에다가 전달하고 싶으나.. 문법 구조상 그건 가능하지 않다. 기반 클래스는 파생 클래스의 생성자가 실행되기 전에 초기화가 완료돼야 하기 때문이다. 그러니 파생 클래스의 생성자 함수에서 확보해 놓은 스택 변수의 공간을 받을 방법도 존재하지 않는다.

이럴 때 prepareArgument(_alloca(len), arg1, arg2, num) 이런 식으로 static한 보조 함수를 만들면 굳이 힙 메모리 할당과 생성자· 소멸자가 뒤따르는 범용 string을 쓸 필요 없이 스택에다가 문자열을 담을 공간을 임시로 확보하여 소기의 목적을 달성할 수 있을 것으로 보인다.

4. 쓰레기값

'초기화되지 않은 변수, 쓰레기값'이라는 건 내가 아는 프로그래밍 언어들 중에는 C/C++에만 존재하는 개념이다. 물론 컴퓨터라는 기계에 본질적으로 존재하니까 C/C++에도 존재하는 것이지만 말이다.
이것 때문에 야기되는 버그의 황당함과 막장스러움은 뭐, 이루 말로 형용할 수 없다. 같은 소스 코드가 release 빌드와 debug 빌드의 실행 결과가 달라지는 건 애교 수준이다. 프로그램이 미묘하게 삑사리가 나고 있어서 몇 시간을 끙끙대며 디버깅을 했는데 원인이 고작 이것 때문인 일이 비일비재하다.

개인적으로는 부모 클래스의 멤버가 초기화되지 않았는데 그걸 자식 클래스에서도 초기화하지 않은 것, 처음에는 0 초기화가 보장되는 static 영역에 있던 오브젝트를 별 생각 없이 스택/힙으로 옮긴 것, 심지어 한 멤버를 초기화할 때 아직 초기화되지 않은 다른 멤버를 참조해서 망한 것이 기억에 남아 있다.

간단한 int 지역변수가 초기화되지 않은 건 컴파일러 차원에서 잡아 주지만 위와 같은 사항들, 복잡한 구조체의 멤버가 일부 초기화되지 않는 것, 스택이 아닌 힙에서 할당하는 동적 메모리가 돌아가는 사정은 컴파일러도 일일이 다 챙겨 주지 못하기 때문에 더 복잡한 정적 분석의 영역으로 가야 한다.

그런데 개인적으로 의문이 드는 건 초기화되지 않은 쓰레기값이란 건 어느 정도로 무질서하냐는 것이다. 무슨 수학적으로 균일한 난수 수준일 리는 없을 것이다.
그 쓰레기들의 값에 영향을 주는 것은 정확하게 무엇일까? (스택이냐 힙이냐에 따라 다르게 생각해야 할 듯) 컴파일 시점에서 결정되어서 한번 빌드된 프로그램은 동일한 동작 조건에서는 불변인 걸까? 혹은 운영체제에 따라 달라질 수 있을까?

마치 중간값 피벗 기반의 퀵 정렬이 최고 시간 복잡도가 나오게 공격하는 방법을 연구하는 것처럼 저것도 뭔가 컴퓨터공학적인 고찰이 필요한 의문인 것 같다.

5. 메모리 주소의 align 문제

"어..? 구조체의 크기가 왜 각 구조체 멤버들의 크기의 합보다 더 크지? 컴파일러의 버그인가?"
본인이 이렇게 크게 놀랐던 게 벌써 20여 년 전, 고딩 시절에 도스용 DJGPP를 갖고 놀던 때였다.
그때는 지금 같은 구글 검색도 없고 네이버 지식인도 없고.. 이런 시시콜콜한 이슈를 다루는 C언어 서적도 없었으니, 궁금하면 물어 볼 만한 곳이 PC 통신 프로그래밍 관련 동호회 게시판밖에 없었다.

메모리 취급에 매우 관대한 x86 물에서만 놀던 사람이라면 word align이라는 개념이 더욱 생소할 수밖에 없다. 더구나 그 경계에 맞지 않은 단위로 메모리 접근을 시도할 경우, CPU가 귀찮아서 예외까지 날린다면??
본인은 포팅이라는 걸 할 때 word align을 조심해야 한다는 것을 머리로는 들어서 알았지만 그 문제를 회사에서야 실제로 겪었다.

이제 네이티브 코드는 반드시 ARM64 기반으로 빌드해야 하니 해당 부분을 64비트로 다시 빌드했다. 그런데 동일한 엔진을 얹은 안드로이드 앱이 어떤 기기에서는 잘 돌아갔는데 다른 기기에서는 뻗었다.
죽은 지점이 어딘지는 stack dump를 통해 알아낼 수 있었지만 거기는 null pointer, buffer overflow 등 그 어떤 통상적인 메모리 문제가 발생할 여지도 없는 곳이었다.

알고 보니 거기는 파일 형태로 기록하는 조밀한 버퍼에다 wcsncpy( reinterpret_cast<wchar_t*>(buf+1), str, len) 이런 짓을 하고 있었으며, 타겟 포인터가 한눈에 보기에도 wchar_t의 크기 대비 word align이 되어 있지 않았다(buf 는 char* ㄲㄲㄲ).
그래서 wcsncpy를 memcpy로 교체함으로써 문제를 해결할 수 있었다. wchar_t는 long과 더불어 포팅을 어렵게 하는 주범이며, reinterpret_cast는 align과 관련된 잠재적 위험성을 발견하는 용도로도 쓰일 수 있다는 점을 알게 됐다.

복잡한 포인터 메모리 조작 코드에서 잠재적인 align 문제를 잡아내는 건 사람의 디버깅만으로는 한계가 있을 텐데, 정적 분석으로 가능한지 궁금하다. 그리고 반대로 레거시 코드를 돌리기 위해 컴파일러가 성능이 떨어지는 걸 감수하고라도 최소한 뻗지는 않게 align 보정 코드를 집어넣어 주는 옵션도 있을 텐데 그것도 궁금해진다.

6. 32비트 단위 문자열

C/C++에서 wchar_t 크기의 파편화로 인해 야기된 혼란과 원성이 워낙 장난이 아니었기 때문에 C++11에서는 아예 크기를 직접 명시하고 고정시킨 char16_t와 char32_t라는 자료형이 built-in으로 추가되었다. int는 32비트 시대에 크기가 변했고 long은 64비트 시대에 플랫폼별로 삐걱거리기 시작했다면, wchar_t는 유니코드와 함께 새로 등장하면서 저 지경이 된 셈이다.

개인적으로 인상적인 것은 char32_t는 U""라고 문자열 리터럴을 나타내는 접두사까지 언어 차원에서 새로 등장했다는 점이다. 드디어 확장 평면 문자도 취급하기 더 수월해지겠다.
그런데 그러면 Visual C++이라면 ""는 1바이트, L""는 2바이트, U""는 4바이트라고 자연스럽게 연결되는데, 처음부터 wchar_t가 4바이트였던 맥에서는 L과 U가 모두 4바이트이다. char16_t에 대응하는 2바이트 문자열은 리터럴로 표현하는 방법이 없나 궁금하다. 오히려 Objective C에서 사용하는 NSString의 @""가 2바이트 문자열 리터럴이다.

char32_t가 언어 차원에서 이렇게 지원되기 시작했는데 str*, wcs*처럼 32비트 문자열 버전에 대응하는 strlen, strcpy, sprintf 등도 있어야 하지 않나 싶다. C++이라면 char_traits 템플릿으로 땜빵할 수도 있겠지만 C에는 그런 게 없으니까..
그리고 템플릿이 없는 저쪽 동네 Java는 32비트 단위 문자열을 취급하는 string class 같은 것도 있어야 하지 않을까? 문자 하나도 확장 평면까지 감안해서 얄짤없이 int로 표기하는 건 직관적이지 못하고 불편하니 말이다.

7. 레퍼런스 사이트

C/C++은 마소의 C#, 애플의 Swift, 썬-오라클의 Java처럼 한 기업이 주도해서 개발하는 언어가 아니다. 그래서 C++ 라이브러리 레퍼런스 같은 걸 검색해도 딱 떨어지는 개발사의 홈페이지가 곧장 나오지는 않는다.
하지만 수 년 전부터 구글 검색에서 상위권으로 노출되고 있는 유명한 사이트는 아래의 딱 두 곳인 것 같다.

http://www.cplusplus.com
https://en.cppreference.com/w

얘들은 개인? 단체? 어디서 운영하는지 모르겠다. C++17, C++20 같은 최신 정보도 곧장 올라오는 걸 보니 유지보수도 활발히 되고 있고 만만하게 볼 퀄리티가 아니다.
마치 Doom 게임 관련 자료를 듬뿍 얻을 수 있는 위키 사이트가 doomwiki와 doom.fandom.com 요 두 계열로 나뉘듯이 말이다.

Posted by 사무엘

2019/11/13 08:33 2019/11/13 08:33
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1683

1. 스마트 포인터의 필요성

C/C++에서 포인터로 참조하는 동적 메모리가 안전하게 관리되기 위해서는.. 가장 간단하게는 포인터의 생명 주기와 그 포인터가 가리키는 메모리 실체의 생명 주기가 동일하게 유지돼야 할 것이다. 어느 한쪽이 소멸되면 다른 쪽도 소멸돼야 한다. C++에서는 이 정도 절차는 포인터를 클래스로 감싸고 그 클래스의 생성자와 소멸자 함수를 구현함으로써 자동화할 수 있다.

하지만 이것만으로 문제가 다 해결되는 건 아니다. 어떤 메모리에 대한 포인터의 ownership이 더 깔끔하게 관리되고 통제돼야 한다. 멀쩡한 주소값이 딴 걸로 바뀌어서 원래 가리키던 메모리로 접근 불가능해지거나(leak..), 이미 해제된 메모리를 계속 가리키고 있다가 사고가 나는 일도 없어야 한다.

그런 일을 예방하려면 여러 포인터가 동일 메모리를 참조하는 것을 완전히 금지하고 막든가, 아니면 reference count 같은 걸 따로 둬서 그런 상황에 대비를 해야 한다. 실행시켰을 때 뻑이 날 만한 짓은 아예 컴파일이 되지 않고 거부되게 해야 한다.
이런 메모리 관리를 자동으로 해 주는 클래스가 표준 C++ 라이브러리에도 물론 구현돼 있으며, 크게 두 가지 관점에서 존재한다.

  • 배열 지향: POD 또는 비교적 단순한 오브젝트들의 동적 배열로, 원소들의 순회, 추가· 삭제와 전체 버퍼 재할당 같은 동작에 최적화돼 있다. 원소 전체 개수와 메모리 할당량 정보가 별도로 들어 있으며, 문자열 클래스도 어찌 보면 배열의 특수한 형태라고 간주할 수 있다. [] 연산자가 오버로딩 돼 있다.
  • 오브젝트 지향: 단일 오브젝트 중심으로 메모리 할당 크기보다는 소유자(ownership) 관리에 더 최적화돼 있다. 그래서 구현 방식에 따라서는 원소 개수 대신 레퍼런스 카운트 정보가 있곤 한다. 담고 있는 타입 형태로 곧장 활용 가능하게 하기 위해, ->와 * 같은 연산자가 반드시 오버로딩 돼 있다.

C/C++은 배열과 포인터의 구분이 애매하니 helper class는 각 분야에 특화된 형태로 따로 구현되었다는 것을 알 수 있다.
배열 버전이야 std::vector라는 유명한 클래스가 있고, 오브젝트를 담당하는 물건을 우리는 smart pointer라는 이름으로 오랫동안 불러 왔다.

Windows 진영에서도 ATL 내지 WTL 라이브러리에는 일반 포인터뿐만 아니라 COM 인터페이스를 감싸서 소멸자에서 Release를 해 주고, 대입 연산자 및 복사 생성자에서 AddRef 따위 처리를 해 주는 간단한 클래스가 물론 있었다.
소멸자는 예외 처리가 섞여 있을 때 더욱 빛을 발한다. 함수의 실행이 종료되는 경로가 여럿 존재하게 됐을 때 goto문을 안 쓰고도 메모리 단속이 꼼꼼하게 되는 것을 언어와 컴파일러 차원에서 보장해 주기 때문이다. 그리고 이 정도 물건은 C++ 좀 다루는 프로그래머라면 아무라도 생각해 내고 구현할 수 있다.

2. 초창기에 도입됐던 auto_ptr과 그 한계

C++은 이런 스마트 포인터도 표준화하려 했으며, 그 결과로 auto_ptr이라는 클래스가 C++98 때부터 도입됐다. 선언된 헤더는 #include <memory>이다.
그러나 auto_ptr는 오늘날의 최신 C++의 관점에서 봤을 때는 썩 좋지 못한 설계 형태로 인해 deprecate됐다. 이미 이걸 사용해서 작성돼 버린 레거시 코드를 실행하는 것 외의 용도로는 사용이 더 권장되거나 지원되지 않게 되었다.

그 대신, C++11부터는 용도를 세분화한 unique_ptr, shared_ptr, weak_ptr이라는 대체제가 등장했다. 이거 마치 C-style cast와 C++ *_cast 4종류 형변환의 관계처럼 보이지 않는가? =_=;;

auto_ptr은 한 메모리를 오직 한 포인터만이 참조하도록 하고 포인터가 사라질 때 소멸자도 호출해 주는 최소한의 기본 조치는 잘 해 줬었다. auto_ptr<T> ptr(new T(arg1, ...)) 같은 꼴로 선언해서 사용하면 됐다. 하지만...

(1) 단일 포인터와 배열의 구분이 없었다.
물론 스마트 포인터는 전문적인 배열 컨테이너 클래스와는 용도가 다르니, 원소의 삽입· 삭제나 원소 개수 관리, 메모리 재할당 처리까지 할 필요는 없다.

하지만 클래스의 소멸자에서 호출해 주는 clean-up을 별도의 템플릿 인자로 추상화하지는 않았고 그냥 delete ptr로 고정해 놓았기 때문에.. 당장 delete와 delete[]조차도 구분할 수 없어서 번거로웠다. 다시 말해 auto_ptr<T> ptr(new T[100]) 이런 식으로 써먹을 수는 없다.

(2) 포인터의 ownership을 관리하는 것까지는 좋으나.. 그게 복사 생성자 내지 대입 연산자에서 우항 피연산자를 변조하는 꽤 기괴한 형태로 구현돼 있었다.
무슨 말이냐 하면.. auto_ptr<T> a(ptr), b에서 b=a 또는 b(a)라고 써 주면.. b는 a가 가리키는 값으로 바뀜과 동시에 a가 가리키는 값은 NULL로 바뀌었다. 즉, 포인터와 메모리의 일대일 관계를 유지시키기 위해, 소유권은 언제나 복사되는 게 아니라 이동되게 한 것이다.

그렇게 구현한 심정은 이해가 되지만, 대입 연산에서 A=B라고 하면 A만 변경되어야지, B가 바뀌는 건 좀 납득이 어렵다.
복사 생성자라는 것도 형태가 T::T(const T&)이지, T::T(T&)는 아니다. 차라리 임시 객체만 받는 R-value 이동 전용 생성자라면 T::T(T&&)이어서 우항의 변조가 허용되지만, 복사 생성자는 그런 용도가 아니다.

(3) 위와 같은 특성이랄지 문제로 인해.. auto_ptr은 call-by-value 형태로 함수의 인자나 리턴값으로 그대로 전달했다간 큰일 났다.
메모리의 소유권이 호출된 함수의 인자로 완전히 옮겨져 버리고, 그 함수가 끝날 때 그 메모리는 auto_ptr의 소멸자에 의해 해제돼 버리기 때문이다. 이 문제를 컴파일러 차원에서 잡아낼 수 없다. (뭐, 이미 free된 메모리를 이중으로 해제시키는 사고는 나지 않는다. 깔끔한 null pointer 접근 에러가 날 뿐.)

auto_ptr을 함수 인자로 전달하려면 그냥 call-by-reference로 하든가, 아니면 그 원래의 T* raw 포인터 형태로 전해야 했다.
아니, 함수 인자뿐만 아니라 값을 그대로 함수의 리턴값으로 전할 때, 혹은 vector 및 list 같은 컨테이너에다 집어넣을 때 등.. 임시 객체가 발생할 만한 모든 상황에서 동일한 문제가 발생하게 된다.

이게 제일 치명적이고 심각한 문제이다. 여러 함수를 드나들고 컨테이너에다 집어넣는 것도 raw pointer와 다를 바 없이 가볍게 되라고 smart pointer를 만들었는데 그러지 못한다면.. 이걸 만든 의미가 없다. 그러면 한 함수 안에서 달랑 소멸자 호출만 자동화해 주는 것 말고는 쓸모가 없다.
또한, 매번 call-by-reference로 전하는 건 엄밀히 말해 포인터의 포인터.. 즉, 포인터를 정수가 아니라 구조체 같은 덩치 큰 물건으로 취급하는 거나 마찬가지이고..

이런 이유로 인해 auto_ptr은 좋은 취지로 도입됐음에도 불구하고, 현재는 이런 게 있었다는 것만 알고 최신 C++에서는 잊어버려야 할 물건이 됐다.
(1) C 라이브러리 함수라든가(gets...) (2) C++ 키워드뿐만 아니라(export) (3) C++ 라이브러리 클래스 중에서도 흑역사가 생긴 셈이다.

auto_ptr이 무슨 보안상의 결함이 있다거나 성능 오버헤드가 크다거나 한 건 아니다. 21세기 이전에는 C++에 R-value 참조자 같은 문법이 없었으니 복사 생성자에다가 move 기능을 집어넣을 수밖에 없었다. 나중에 C++에 언어 차원에서 smart pointer의 불편을 해소해 주는 기능이 추가된 뒤에도 이미 만들어진 클래스의 문법이나 동작을 변경할 수는 없으니 새 클래스를 따로 만들게 된 것일 뿐이다.

3. unique_ptr

auto_ptr의 가장 직접적인 대체제는 unique_ptr이다.
얘는 최신 C++에서 새로 추가된 문법을 활용하여 단일 개체와 배열 개체를 구분할 수 있다. unique_ptr<T>와 unique_ptr<T []>로 말이다. 신기하다..;;
그리고 템플릿 가변 인자 문법을 이용하여 new를 생략하고 std::make_unique<T>(arg1, arg2..) 이렇게 객체를 생성할 수도 있다. 얘는 C++14에서야 도입된 더 새로운 물건이다.

unique_ptr은.. 말 많고 탈 많던 복사 생성자와 대입 연산자가 막혀 있다. 함수에 날것 형태로 전달하거나 컨테이너에 집어넣는 등의 시도를 하면.. 그냥 컴파일 에러가 나게 된다. 그래서 안전하다.
이전의 auto_ptr이 하던 것처럼 소유권을 옮기는 것은 R-value 이동 생성자라든가 std::move 같은 다른 방법으로 하면 된다.

어떤 클래스에 대해서 복사 생성자와 대입 연산자가 구현돼 있지 않으면 컴파일러가 디폴트, trivial 구현을 자동 생성하는 편이다. 각 멤버들에 대한 memcpy 신공 내지 대입 연산자 호출처럼 해야 할 일이 비교적 직관적으로 뻔히 유추 가능하기 때문이다. 하지만 클래스에 따라서는 그런 오지랖이나 유도리가 바람직하지 않으며 이를 금지해야 할 때가 있다. 인스턴스가 단 하나만 존재해야 하는 singleton 클래스, 또는 저렇게 반드시 1핸들, 1리소스 원칙을 유지해야 하는 클래스를 구현할 때 말이다.

그걸 금지하는 가장 전형적이고 전통적인 테크닉은 해당 함수를 private으로 선언해 버리는 것이 있다. (정의는 당연히 하지 말고)
하지만 이것도 friend 함수에서는 안 통하는 한계가 있기 때문에 최신 C++에서는 액세스 등급과 별개로 상속 받았거나 디폴트 구현된 멤버 함수의 사용을 그냥 무조건적으로 금지해 버리는.. = delete라는 문법이 추가되었다. 순수 가상 함수를 나타내는 = 0처럼 말이다! unique_ptr은 이 문법을 사용하고 있다.

그럼 unique_ptr은 컨테이너에 집어넣는 게 전혀 불가능한가 하면.. 그렇지 않다.

vector<unique_ptr<T> > lc;
lc.push_back( unique_ptr<T>(new T) );

처럼 push_back이나 insert에다가 T에 속하는 변수를 줄 게 아니라 저렇게 애초부터 R-value 임시 객체를 주면 된다.
그러면 임시 객체의 ownership이 컨테이너 안으로 자연스럽게 옮겨지고, 컨테이너 안의 unique_ptr만이 유일하게 T를 가리키고 있게 된다.

얘는 auto_ptr보다 상황이 훨씬 더 나아졌고 이제 좀 쓸 만한 smart pointer가 된 것 같다.
사실, 작명 센스조차도.. auto는 도대체 뭘 자동으로 처리해 준다는 건지 좀 막연한 구석이 있었다. 그게 unique/shared로 바뀐 것은 마치 '인공지능'이라는 막연한 용어가 AI 암흑기를 거친 후에 분야별로 더 구체적인 기계학습/패턴인식 같은 말로 바뀐 것과 비슷하게 들리기도 한다. ㅎㅎ

4. shared_ptr와 weak_ptr

그럼 다음으로, shared_ptr을 살펴보자.
얘는 마치 COM의 IUnknown 인터페이스처럼 reference counting을 통해 다수의 포인터가 한 메모리를 참조하는 것에 대한 대비가 돼 있다. 그래서 unique_ptr과 달리, 대입이나 복사를 자유롭게 할 수 있다.

(1) 날포인터는 그냥 대책 없이 허용하기 때문에 ownership 문제가 발생하고.. 아까 (2) auto_ptr은 무조건 ownership을 옮겨 버리고, (3) unique_ptr은 깔끔하게 금지하는데 (4) 얘는 참조 횟수를 관리하면서 허용한다는 차이가 있다. 소멸자는 가리키는 놈의 reference count를 1 감소시켜서 그게 0이 됐을 때만 실제로 메모리를 해제한다.

그래서 shared_ptr은 크기 오버헤드가 좀 있다.
unique_ptr은 일반 포인터 하나와 동일한 크기이고 기술적으로 machine word 하나와 다를 바 없는 반면, shared_ptr은 reference count 데이터를 가리키는 포인터를 추가로 갖고 있다. 일반 포인터 두 개 크기를 차지한다.

이는 static_cast보다 dynamic_cast가 오버헤드가 더 큰 것과 비슷한 모습 같다. 그리고 멤버 포인터가 다중 상속 하에서의 this 오프셋 보정 때문에 추가 정보를 갖고 있다면, 얘는 ownership 관리 때문에 추가 정보를 갖고 있다는 점이 비교된다.

끝으로, weak_ptr이라고, shared_ptr와 같이 쓰이는 포인터도 있다. 얘는 이름에서 유추할 수 있듯이 reference count를 건드리지 않는다. 순환 참조 문제를 예방하려면 A에서 B를 참조한 뒤에 B에서 또 A를 참조할 때는 레퍼런스 카운트를 건드리지 않아야 하기 때문이다.
순환 참조는 단순히 A→B→A뿐만 아니라 A→B→C→A 같은 더 복잡한 형태로도 발생하며, 일단 발생한 것을 감지하기란 몹시 난감하다. 그러니 weak_ptr이라는 개념이 반드시 필요하다.

그럼 reference count를 건드리지 않고 shared_ptr에 접근하려면 그냥 raw 날포인터를 얻어서 간단히 써도 될 텐데 굳이 weak_ptr이 따로 존재하는 이유는? 비록 ref count를 건드리지 않더라도 날포인터보다는 더 안전한 놈이 필요하기 때문이다.

weak_ptr은 다른 shared_ptr을 가리킬 수 있다.
shared_ptr은 자신과 동일한 shared_ptr로부터 참조받은 횟수와, weak_ptr로부터 참조받은 횟수를 따로 관리한다. 그렇기 때문에 shared_ptr은 이렇게 2개의 숫자로 이뤄진 레퍼런스 데이터도 따로 동적 생성해서 포인터로 가리키면서 동작한다.

weak_ptr이 가리키는 객체에 실제로 접근하려면 weak_ptr::lock()을 호출해서 weak로부터 shared_ptr을 잠시 생성해야 한다. 이 동안은 shared_ptr의 실제 레퍼런스 카운트가 증가하기 때문에 한쪽의 스마트포인터가 소멸되더라도 dangling pointer 현상이 발생하지 않는다. 이게 weak_ptr이 날포인터와의 차이점이다.

하지만 이 lock이 언제나 성공하지는 않을 수 있다. lock이 걸리지 않았을 때 weak가 shared를 가리키고 있는 건 shared의 실제 참조 횟수에 아무 영향을 끼치지 않는다. 그렇기 때문에 이 동안은 shared가 가리키는 객체는 순환 참조 없이 소멸이 가능하다.

그럼 weak_ptr 따위 안 쓰고, 그냥 shared_ptr를 놔두고 평소엔 null을 저장했다가 lock을 걸 상황에만 원래 포인터 값을 참조하는 것과 무슨 차이가 있느냐는 의문이 생길 수 있는데.. weak_*는 그것보다 더 안전하다. 자신이 가리키는 객체가 소멸되었는지의 여부를 expired() 함수로 간편하게 알 수 있다. 레퍼런스 카운트를 이중으로 관리하고 메모리도 이중으로 관리하는 성능 오버헤드를 괜히 감수한 게 아니다.

weak_ptr은 shared_*와 마찬가지로 크기가 포인터 2개이다. 단, 사용법이 unique_*나 shared_*와는 좀 다르다.
저 둘은 ->나 * 연산자를 이용해서 자신이 가리키는 객체에 바로 접근할 수 있는 반면, weak_*는 그렇지 않다. 그리고 실제 객체를 생성하는 make_unique와 make_shared 함수는 있는 반면, make_weak는 없다. weak_*는 그 정의상 기존 shared_*로부터 유도되어 생성되는 걸 가정하기 때문이다.

이상이다. 그냥 생성자와 소멸자를 적절히 구현해 주고 ->와 *만 오버로딩 해 주면 끝일 것 같은 smart pointer도 깊게 들어가면 내막이 생각보다 더 복잡하다는 것을 알 수 있다.
Rust 언어는 garbage collector 기반이 아니면서 더 독특한 방식으로 메모리 소유권을 관리한다던데 그 내막이 어떠했던지가 다시 궁금해진다.

5. 여담

(1) = delete는 다시 봐도 참신하기 그지없다. delete라는 키워드가 연산자 말고 이런 용도로도 활용되는 날이 오더니!
배열 첨자 연산자이던 []와 구조체 참조 연산자이던 ->가 람다 선언에서 의미가 완전히 확장된 것만큼이나 참신하다.
하긴, 옛날에 템플릿이 처음 등장했을 때.. 그저 비교 연산자일 뿐이었던 <와 >가 완전히 새로운 여닫는 형태로 사용되기 시작한 것도 정말 충격적인 변화였을 것이다.

(2) 글쎄, 멤버 함수의 접근을 금지하는 방법이 저렇게 도입됐는데, 어떤 클래스에 대해서 Java의 final이나 C#의 sealed처럼 상속이 더 되지 않게 하는 옵션은 C++에 도입되지 않으려나 모르겠다. C++은 타 언어에 없는 protected, private 상속이 존재하지만 상속 자체를 금지하는 옵션은 없어서 말이다.

특히 내부 구조가 아주 간단하고 가상 함수가 존재하지 않는 것, 특히 소멸자가 가상 함수 형태로 별도로 선언되지 않은 클래스는 상속을 해도 어차피 polymorphism을 제대로 살릴 수 없다. 그냥 단순 기능 확장에만 의미를 둬야 할 것이다.
Java는 모든 함수가 기본적으로 가상 함수일 정도로 유연한데도 이와 별개로 상속을 금지하는 옵션이 있는데.. 그보다 더 경직된 언어인 C++은 의외로 그런 기능이 없다.

(3) C/C++의 사고방식에 익숙한 프로그래머라면 포인터란 곧 메모리 주소이고, 본질적으로 machine word와 동일한 크기의 부호 없는 정수 하나일 뿐이라는 편견 아닌 편견을 갖고 있다.
하지만 객체지향이라든가 함수형 등 프로그래밍 언어 이론을 조금이라도 제대로 구현하려면 숫자 하나만으로 모든 것을 표현하기엔 부족한 포인터가 얼마든지 등장하게 된다.

앞서 다뤘던 shared_ptr이라든가 다중 상속을 지원하는 멤버 함수 포인터..
그리고 자기를 감싸는 문맥 정보가 담긴 클래스 객체 포인터라든가 람다 함수 포인터 말이다.
C++은 전자를 기본 지원하지 않기 때문에 모든 클래스들이 Java 용어로 치면 개념적으로 static class인 거나 마찬가지이다.
그리고 후자를 기본 지원하지 않기 때문에 람다는 캡처가 없는 놈만 기존 함수 포인터에다 담을 수 있다.

그런 것들이 내부적으로 어떻게 구현되고 구현하는 시공간 비용이 어찌 되는지를 프로그래머라면 한 번쯤 생각할 필요가 있어 보인다.

(4) C++에서 class T; struct V; 처럼 이름만 전방 선언된 incomplete type에 대해서는 제일 단순한 직통 포인터, 그리고 무리수가 좀 들어간 멤버 포인터 정도만 선언할 수 있다. T나 V의 실체를 모르니 이런 타입의 개체를 생성하거나, 포인터를 실제로 참조해서 뭔가를 할 수는 없다.
그런데 이런 불완전한 타입을 가리키는 포인터를 상대로 delete는 가능할까? 난 이런 상황에 대해 지금까지 한 번도 생각해 본 적이 없었다.

sizeof(T)의 값을 모르더라도 포인터가 가리키는 heap 메모리 블록을 free하는 것은 얼마든지 가능하다. 애초에 malloc/void가 취급하는 것도 아무런 타입 정보가 없는 void*이니 말이다.
그러니 operator delete(ptr)은 할 수 있지만, 해당 타입에 대한 소멸자 함수는 호출되지 못한다.

컴파일러는 이런 코드에 대해서 경고를 띄우는 편이다. Visual C++의 경우 C4510이며, delete뿐만 아니라 delete[]에 대해서도 동일한 정책이 적용된다.

Posted by 사무엘

2019/10/09 08:35 2019/10/09 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1671

1. 완전히 새로운 알고리즘 분야

컴퓨터는 정말 대단한 기계이다.
정보의 최소 단위인 0과 1을 분간하고, 임의의 주소가 가리키는 메모리의 값을 읽거나 쓸 수 있고 프로그램의 실행 지점도 메모리의 값을 따라서 변경 가능하게 했더니(튜링 완전) 그야말로 무궁무진한 양의 정보를 가히 무궁무진한 방식으로 처리가 가능해졌다.

이런 이론적인 근간이 마련된 뒤에 반도체의 집적도가 더 올라가고 메모리와 속도가 더 올라가고 가격이 더 내려가는 건 그냥 시간 문제일 뿐이었다.
그런데.. 단순히 복잡한 계산이나 방대한 검색을 빠르게 해내는 것만으로 컴퓨터가 인간의 고유 영역을 완전히 침범하고 대체했다고 보기는 곤란하다. 그건 그냥 자동차가 인간보다 더 빠르고 중장비가 인간보다 더 힘센 것만큼이나, 기계가 인간의 역할을 일부 보조하고 확장하는 것일 뿐이다.

물론 단순히 동력과 관련된 분야는 말이나 소 같은 동물도 인간보다 약간이나마 더 우위에 있긴 했다. 그에 비해 정보 처리 분야는 자연계에 지금까지 인간의 라이벌 자체가 아예 존재한 적이 없었다는 차이가 있다.
그러나 인간도 속도가 느리고 개인의 능력이 부족할 뿐이지.. 많은 인원을 동원하고 많은 시간만 주어지면 기계적인 정보 처리 정도는 '유한한 시간' 안에 언젠가 다 할 수 있다. 일을 좀 빠르고 정확하게 수행하는 것만 갖고 '창의적이다', '인간을 닮았다'라고 평가해 주지는 않는다.

정렬과 검색에, 다이나믹이니 분할 정복이니 하는 최적해 구하기처럼 고전적인 분야에서 고전적인(?) 방법론을 동원하는 알고리즘은 이미 수십 년 전에 다 연구되어서 깔끔한 결과물이 나왔다. 그런 건 이미 대학교 학부 수준의 전산학에서 다 다뤄지는 지경이 됐으며, 정보 올림피아드라도 준비하는 친구라면 아예 중등교육 수준에서 접하게 됐다.

그런데 현실에서는 그렇게 깔끔하게 떨어지지 않는 더 복잡하고 난해한 문제를 풀어야 한다. 깔끔하게만 접근했다가는 시간 복잡도가 NP-hard급이 되어 도저히 감당할 수 없는 문제를 적당히 타협하여 풀어야 한다.
중· 고등학교의 고전역학 문제에서는 "공기의 저항은 무시한다" 단서가 붙지만, 대학교에 가서는 그런 것까지 다 고려해야 하는 것처럼 말이다.

대수적으로 답을 구할 수 없는 문제에 대해 근사치를 효율적으로 구하기 위해 수치해석이라는 기법이 등장했듯, 전산학에도 각종 휴리스틱과 근사 알고리즘이라는 게 존재한다. 압축 알고리즘으로 치면 무손실이 아닌 손실 분야인 셈이다. 구체적인 건 학부 수준을 넘어 대학원에 소속된 별도의 연구실에서 다룰 정도로 난해하다.

그런데.. 이런 것들은 여전히 사람이 범접하지 못하는 분량의 계산 문제를 최대한 빠르게 효율적으로 푸는 방법에 대한 연구이다. 그런 것 말고 사람은 간단히 하는데 컴퓨터에게는 굉장히 난해해 보이는 일이 있다.
컴퓨터로 하여금 텍스트나 음성 형태의 인간의 자연어를 알아듣고 타 언어로 번역한다거나, 그림으로부터 글자 같은 정보를 알아보게 할 수 없을까?
컴퓨터를 바둑· 장기· 오목 같은 게임의 고수로 키울 수 없을까?

이건 단순히 TSP(순회하는 세일즈맨 문제)를 더 그럴싸한 가성비로 다항식 시간 만에 푸는 것과는 분야가 다르다.
저런 걸 기계가 인간과 비슷한 속도와 정확도로만 해내더라도 굉장한 이득이다. 기계를 부리는 비용은 인간을 고용하는 인건비보다 넘사벽급으로 저렴한 데다, 기계는 인간 같은 감정 개입이 없고 지치지 않고 실수도 전혀 하지 않기 때문이다.

하물며 속도와 정확도가 인간 전문가의 능력을 능가하게 된다면 게임 끝이다. 기계적인 단순 노동력이나 판단력만을 요구하는 일자리는 사라지며, 인간은 기계가 대신할 수 없는 더 창의적이고 전문적인 일자리로 갈아타야 할 것이다.

2. 흑역사

소위 '인공지능'에 대한 연구는 진공관이니 트랜지스터니 하던 무려 1950년대 컴퓨터의 초창기 때부터 천조국의 날고 기는 수학자, 컴퓨터 공학자들에 의해 진행돼 왔다. 특히 세부 분야 중 하나로서 기계번역도 연구됐으며, 1954년에는 조지타운 대학교와 IBM이 공동 연구 개발한 기계번역 솔루션이 실제로 출시되기도 했다.

인류 역사상 최초로 기계번역이란 게 연구된 언어는 러시아어 → 영어이며, 이는 전적으로 냉전 덕분이다. 하긴, 2차 세계 대전 때는 번역이 아니라 암호를 해독하는 기계가 개발되긴 했었다. 적성국가들의 언어 중 일본어는 영어와의 기계번역을 연구하기에는 구조가 너무 이질적이고 어려웠을 것이다.

그래도 인간 번역가가 아닌 컴퓨터가 러시아어 텍스트로부터 영어 텍스트를 허접하게나마 뱉어 내자 학계와 업계는 흥분했다. 이런 식으로 조금만 더 연구하면 컴퓨터가 금방이라도 세계의 언어 장벽을 다 허물어 줄 것 같았다.

그때는 학자들이 자연어에 대해서 뭔가 순진 naive하게 원리 원칙대로 규칙 기반으로, 낭만적으로 접근하던 시절이었다. 인간의 언어도 무슨 프로그래밍 언어처럼 유한한 문법과 생성 규칙만으로 몽땅 다 100% 기술 가능하고 parse tree를 만들고 구문 분석이 가능할 거라고 생각했다. 물론 그 규칙이 간단하지는 않겠지만, 촘스키 같은 천재 언어학자 몇 명을 외계인과 함께 골방에다 갈아 넣고 며칠 열나게 고문하면 다 찾아낼 수 있을 것이고.. 그러면 언어의 기계 분석은 게임 끝이지 않겠냐 말이다.

궁극적으로는 전세계 모든 언어들의 교집합과 합집합 요소를 망라하는 중간(intermediate) 언어도 만들 수 있을 것이라고 생각했다. 세계 각국의 언어들을 그 중간 언어와 번역하는 시스템만 만들면 전세계 사통발달 언어 번역 시스템을 만들 수 있지 않겠는가? 이 정도 생각은 나조차도 한 적이 있다.

그랬으나.. 뚜껑을 열어 보니 영광은 거기서 끝이었다.
기계번역은 빵점 백지 상태에서 4, 50점짜리 답안을 내놓는 것까지는 금방 할 수 있었지만, 거기서 성적을 7, 80점짜리로라도 올려서 실용화 가능한 상품은 오랫동안 연구비를 아무리 투입해 줘도 선뜻 나오지 않았다.
인간의 언어라는 게 절대로 그렇게 호락호락 만만하지 않고 매우 불규칙하고 복잡한 구조라는 게 연구하면 연구할수록 드러났기 때문이다. 지금까지 사용한 연구 방법론 자체가 약발이 다하고 한계에 다다랐다.

이 때문에 1970년대에는 돈줄을 쥔 높으신 분들이 "인공지능"이란 건 밥값 못 하는 먹튀 사기 허상(hoax)일 뿐이라고 매우 비관적이고 보수적으로 생각하게 됐다. 컴퓨터는 그냥 계산기일 뿐, 그 돈으로 그냥 인간 번역가나 더 양성하고 말지..
이 단어 자체가 학계의 흑역사 급으로 금기시되어 버렸다. 인공지능이란 게 키워드로 들어간 논문은 저널에서 믿고 걸러냈으며, 관련 연구들의 연구비 지원도 모조리 끊길 정도였다.

이 현상을 학계에서는 제1차 AI 겨울(혹한기, 암흑기, 쇼크, 흑역사 등등...)이라고 부른다. 과거의 무슨 오일 쇼크 내지 게임 업계 아타리 쇼크처럼 말이다.
그렇게 고비를 겪었다가 더 발달된 연구 방법론으로 활로가 발견되고, 그러다가 또 2차 겨울을 극복한 뒤에야 요 근래는 인공지능의 중흥기가 찾아왔다고 여겨진다.

3. 문제는 데이터

지금은 기계번역이건, 게임 AI이건, 패턴인식이건 무엇이건.. 인공지능의 주재료는 규칙이 아니라 데이터이다.
기계번역 시스템을 개발하는데 언어학 문법 지식이 동원되지 않으며, 보드 게임 AI를 만드는데 통상적인 게임 규칙 기반의 알고리즘이 동원되지 않는다. 이상한 노릇이 아닐 수 없다.

그러는 게 아니라 요즘 인공지능이라는 것은 아이디어는 매우 간단하다. 기출문제와 정답을 무수히 많이, 인간이 상상할 수도 없을 정도로 많이 주입시켜 준 뒤, 이로부터 컴퓨터가 규칙성을 찾아내고 새로운 문제가 주어졌을 때 정답을 추론하게 하는 방법을 쓴다. 해결하고자 하는 문제를 기술하고 정답의 조건을 명시하고 알고리즘을 구현하는 걸 인간이 일일이 하는 게 아니라.. 그 자체를 컴퓨터가 알아서 하게 하는 것이다.

이것이 '기계학습', 그 이름도 유명한 machine learning이다. 이것이 이전에 인공지능을 구현하는 방법론이던 '전문가 시스템'을 대체했다. 이런 무지막지한 방법론이 적용 가능할 정도로 요즘 컴퓨터의 속도와 메모리가 매우 크게 향상된 덕분이다.

인간의 입장에서 기계학습을 시키는 방식은 지도(supervised learning) 또는 비지도 학습으로 나뉜다.
그리고 기계의 입장에서 학습(?)을 실제로 수행하는 방법으로는 인공 신경망, 앙상블, 확률(은닉 마르코프 모델), 경사/기울기 하강법 같은 여러 테크닉이 있는데, 기울기 하강법은 복잡한 선형 방정식을 푸는 심플렉스와 비슷하다는 느낌도 든다.

인공 신경망은 생물의 신경망이 동작하는 원리에서 착안하여 만들어진 기계학습 모델이라고는 하지만 당연히 실제 인간 뇌의 작동 방식에 비할 바는 못 된다.
MLP니 CNN이니 RNN이니 하는 신경망 용어들이 존재하며, 그리고 이 인공 신경망을 어떻게 하면 잘 갖고 놀 수 있을까 고민하는 연구 분야를 '딥 러닝'(심층학습)이라고 한다. 마치 네트워크 계층의 다양한 기술 용어만큼이나 AI에도 계층별로 다양한 기술 용어가 존재한다.

게임 AI라면 단순히 뭔가를 인식하고 분류만 하면 장땡인 게 아니라 뭔가 극한의 최적해를 찾아가야 할 텐데.. 이런 걸 학습시키는 건 딥 러닝의 영역이다. 알파고처럼 말이다. 그런데 알파고 하나가 지구상의 최고의 인간 바둑 기사를 이긴 것은 물론이고, 다른 재래식 알고리즘으로 십수 년간 개발되어 온 기존 바둑 AI들까지도 다 쳐발랐다니 무서운 일이 아닐 수 없다. 뭐, 개인용 PC 한 대만으로 그렇게 동작하는 건 아니긴 하지만 말이다.

오늘날 연구되고 있는 인공지능은 무작정 인간과 동급으로 생각하고 창조하는 기계는 당연히 아니고, 컴퓨터의 막강한 메모리와 계산 능력으로 지금까지 주어진 데이터를 토대로 새로운 상황에 대처하는 답안을 꽤 그럴싸하게 제시하는 '약한 인공지능'일 뿐이다.

쉽게 말해 "서당 개 삼 년이면 풍월 읊는다"처럼 말이다.
추리소설를 한 1000편쯤 읽고 나니 다른 새로운 추리 퀴즈에도 설정이 뻔히 보이고 답이 보인다.
드라마를 1000편쯤 보고 나니 비슷비슷한 드라마들은 스토리 전개가 어찌 될지 '안 봐도 비디오'처럼 된다. 그런 것 말이다.

그런데 저게 말처럼 쉬운 일인 건 아니다.
학습 대상인 무수한 텍스트· 이미지· 음성 데이터 내지 각종 게임 복기 데이터를 어떤 형태로 수치화해서 벡터 형태로 표현할 것인가?
그리고 '학습'이라는 걸 하는 동안 해당 AI 엔진의 내부에서는 구체적으로 무슨 계산 작업이 행해지는가?
컴파일러만 해도 결과물로 OBJ 파일이라는 게 생기는데, 그 내부적인 학습 상태는 어떤 형태로 표현되며, 이것만 따로 저장하고 불러오는 방법이 존재하는가? 본인은 AI알못이다 보니 전혀 모르겠다. ㅡ,.ㅡ;;

수천, 수만, 아니 그 이상 셀 수 없이 많은 숫자들로 이뤄진 벡터 I가 또 수없이 많이 있다고 치자. 이 숫자들은 현실 세계를 표현하는 미세한 자질을 표현한다.
그런데 어떤 블랙박스 함수 f가 있어서 f(I_1..n)에 대한 결과가 O_1..m라는 벡터 집합으로 나왔다고 한다.

컴퓨터는 이 I와 O 사이에서 규칙성을 찾아서 I에 대해 O와 최대한 비슷한 결과를 산출하는 함수 f를 구한다. 그러면 이제 임의의 다른 아무 input에 대해서도 수긍할 만한 출력 결과를 얻을 수 있을 것이다.
패턴 인식? 기계번역? 유사 작곡이나 창작? 현실에서 해결하려는 문제가 무엇이건 machine learning이 하는 일은 본질적으로 저걸로 요약된다. 내가 AI 쪽으로 아는 건 이게 전부이다.

지금은 TensorFlow 같은 범용적인 기계학습 엔진/라이브러리까지도 구글 같은 괴물 기업에 의해 오픈소스로 몽땅 풀려 있으며, 이걸 파이썬 같은 간편한 스크립트 언어로 곧장 돌려볼 수도 있는 세상이 됐다.
그런 라이브러리를 직접 개발하고 유지보수 하는 것까지는 바라지도 않는다. 방대한 현실 데이터를 수집해서 저기에다 적절하게 집어넣고, 이로부터 고객이 원하는 유의미한 추세나 분석 결과를 얻는 것만 해도 뭔가 프로그래밍 코딩과는 별개로 아무나 할 수 없는 전문 영역의 일이 돼 있다.

오늘날 AI 엔진의 연구를 위해서는 근간 이론이라 할 수 있는 선형대수학, 편미분, 확률 통계는 무조건 먹고 들어가야 된다. 엔진 코드를 직접 다루지 않고 쓰기만 하는 사람이라도 엔진이 어떤 원리로 돌아가는지 정도는 알아야 가장 적절한 방법론/알고리즘을 선택할 수 있을 테니 저런 것들을 맛보기 수준으로라도 알아야 할 것이다.

과거에 정보 사냥 대회가 있었던 것처럼 이제는 주어진 데이터로부터 새로운 문제를 잘 푸는 기계학습 모델을 설계하는 것이 경진대회의 아이템 내지 학교와 직장의 과제가 될 것으로 보인다. 컴퓨터가 할 수 있는 일이 더 늘어난다면 사람만이 할 수 있는 일은 그보다 더 수준 높고 추상적인 쪽으로 이동할 수밖에 없으니 말이다.

아무리 그래도 그렇지 자연어의 문법과 어휘 자체를 전혀 모르는 상태에서 데이터 학습만으로 댓글이 선플인지 악플인지를 기계가 분간할 수 있는 걸까? 그래도 클레멘타인 영화에 늘어선 댓글이 선플인지 악플인지 판단하려면 그에 대한 특별한 학습-_-;;이 필요할 것 같다.

그리고 변화무쌍 복잡한 필기체의 인식이 아니라, 그냥 자동차 번호판의 정향화된 숫자 내지 겨우 QR 코드의 인식 정도는.. '영상 처리 기술'의 영역이지, 저 정도의 거창하게 기계학습이니 뭐니 하는 인공지능의 영역은 아니다. 그건 구분해서 생각할 필요가 있다.

오래 전부터도 각종 전산학 알고리즘 용어를 검색할 때 종종 걸려 나오긴 했는데.. 국내 개인 사이트 중에 AIStudy라는 곳이 있다. 나모 웹에디터가 있던 시절부터 존재했던 정말 옛날 사이트이다. 그런데 운영자가 내 생각보다 굉장히 어린 친구이다. 정말 대단할 따름이다.
당연히 과학고-카이스트 라인이려나 생각했는데 그렇지는 않고 일반고-서울대 테크를 타 있다. 앞날에 건승을 빌어 본다.

Posted by 사무엘

2019/07/06 08:33 2019/07/06 08:33
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1637

Windows용 GUI 프로그램은 가시적인 GUI 구성요소인 윈도우/창이라는 걸 생성해서 화면에 띄울 수 있다.
윈도우는 메뉴, 제목 표시줄, 테두리 같은 '껍데기' non-client 영역과, 그 내부에 프로그램이 재량껏 내용을 표시하는 '알맹이' client 영역으로 나뉜다. 윈도우에다 스타일을 무엇을 주느냐에 따라 non-client 영역의 구성요소와 차지 면적이 달라진다. 똑같이 테두리를 주더라도 크기 조절이 가능한 창은 그렇지 않은 창보다 테두리가 더 두껍게 그려지곤 한다.

GetWindowRect 함수는 어떤 창이 화면에서 문자 그대로 차지하는 스크린 좌표(non-client와 client 모두 포함)를 되돌리며, GetClientRect는 창 내부의 클라이언트 영역의 크기를 되돌린다.
후자의 경우 RECT를 되돌리긴 하지만 left와 top 멤버의 값은 언제나 0으로 설정된다. 그러니 얘는 진짜 RECT라기보다는 SIZE의 성격에 더 가깝다. 하지만 창의 client 영역을 원점(0,0) 기준으로 DC에다 그림을 그릴 일이 많으며 GDI 함수 중에도 RECT를 받는 놈이 있다. 그렇기 때문에 저런 형태의 RECT를 받는 건 여러 모로 유용하다.

어떤 창의 클라이언트 영역이 자기 창의 non-client 영역을 기준으로 얼마나 떨어져 있는지, 그리고 원점 또는 전체 스크린 좌표 기준으로 어디에서 시작하는지를 얻기란 쉽지 않다.
또한, 어떤 child 윈도우가 부모 창의 클라이언트 영역 좌표를 기준으로 어디쯤에 있는지를 얻는 것도 바로 가능하지 않다(예: 대화상자의 어떤 컨트롤이 대화상자를 기준으로 어느 위치에 있는가?). 기준이 되는 창들을 모두 스크린 좌표로 얻어 온 뒤에 수동으로 좌표 오프셋 보정을 해야 답을 구할 수 있다.

뭐 이런 식이다.
크기와 스타일이 동일한 창에 대해서 전체 영역 대비 클라이언트 영역이 위치와 크기가 어떻게 결정될 것인지는 얼추 고정적으로 예상 가능하다.
그런데 이렇게 직관적이고 명백해 보이는 영역에도 뭔가 골치 아프고 지저분한 가상화, 보정, 샌드박스라는 게 존재한다는 게 믿어지시는가?

이 문제는 지금으로부터 10수 년 전, Windows Vista가 등장하면서 시작됐다.
과거의 XP가 단조로운 고전 테마를 탈피하여 non-client 영역과 각종 표준 컨트롤에 임의의 테마 비트맵을 도입했는데, Vista/7은 더 나아가서 그래픽 가속 기능의 도움으로 프로그램 창 주변에 짙은 그림자를 그리고, 테두리에는 뭔가 청명한 느낌이 나는 반투명(?) 유리 재질을 구현했다.

그래서 법적으로(?) 똑같은 크기의 창이라도 창이 화면에다 실제로 1픽셀이라도 그림을 그리는 영역이 더 커졌으며, 테두리의 두께도 더 두꺼워졌다.
특히, 크기 조절이 되지 않는 창이라도 "크기 조절이 되는 창과 동급으로" 테두리가 두꺼워졌다.
이로 인해 window rect라는 개념을 해석하는 방식에도 변화가 불가피해졌다.

그럼 골치 아픈 설명 대신 예를 들도록 하겠다.
대화상자를 생성하여 (1, 1) 위치에다 300, 200픽셀 크기로 배치(SetWindowPos 호출)하는 프로그램을 돌려 보았다.
이걸 Visual C++ 2010 이하, 또는 2012 이상이라도 Windows XP 호환 툴체인으로 빌드하면 실행 결과가 다음과 같이 나온다.

사용자 삽입 이미지

그러나 동일한 코드를 Visual C++ 2012 이상의 정규 툴체인으로 빌드하면 실행 결과가 다음과 같다. 둘의 차이가 뭘까?

사용자 삽입 이미지

옛날 방식으로 빌드하면.. window rect를 계산할 때 Windows Vista/7의 Aero가 추가적으로 그려 주는 껍데기 공간이 영역에 포함되지 않는다. 그래서 window rect가 음수가 아니라 분명 (1, 1)임에도 불구하고 창의 좌측 상단이 좀 짤린다.
이 창이 실제로 차지하는 영역을 얻으려면 DwmGetWindowAttribute(m_hWnd, DWMWA_EXTENDED_FRAME_BOUNDS, &rc, sizeof(rc))를 호출해 줘야 한다. 보다시피 실제 영역은 (-4, -4)에서부터 시작하면서 5픽셀이나 더 크다는 걸 알 수 있다.

그러나 최신 방식으로 빌드하면 창의 모든 영역이 window rect에 포함된다. 이 때문에 창의 크기가 더 작아지며, 클라이언트 영역의 크기도 10픽셀씩이나 차이가 나게 된다. 그 대신 얘는 GetWindowRect와 DwmGetWindowAttribute의 결과가 서로 일치한다.
두 프로그램의 실행 결과를 한 화면에 포개 놓으면 다음과 같다.

사용자 삽입 이미지

이런 차이를 만들어 내는 것은 바로.. 실행 파일의 PE 헤더에 기록되어 있는 운영체제/서브시스템의 버전이다. 링커에서 /SUBSYSTEM:WINDOWS,6.0 옵션으로 지정 가능하다.
Visual C++ 2010의 컴파일러는 5.1 (XP)이 최저값이기 때문에 필요한 경우 6.0 (Vista)으로 올려서 지정할 수 있다. 그러나 2012 이상 후대의 컴파일러는 선택의 여지 없이 6.0이 최소값이며 더 낮은 값으로 지정이 되지 않는다. 툴체인 자체를 더 낮은 것으로 골라야 한다.

의외로 매니페스트 XML이 아니라는 게 신기하다. 공용 컨트롤 6.0 사용 여부, 고해상도 DPI 인식 여부, 요구 보안 등급 같은 건 매니페스트 속성이니까 말이다.
당연한 얘기이지만 공용 컨트롤 6.0과 최소 운영체제 6.0은 서로 다른 개념이라는 걸 유의하도록 하자. 공용 컨트롤의 버전은 차라리 과거 IE의 버전(6)과 비슷한 구도이다. 일개 웹브라우저가 운영체제의 셸까지 마개조하던 시절의 잔재이다(DLL hell도 존재했었고..).

그래도 현재의 Visual C++ 2019도 최소 운영체제 버전이 6.0에서 값이 더 오르지는 않고 있다. Vista를 진짜 하한선 마지노 선으로 잡고 있는 듯하다.
그럼 다음으로 Aero를 끄고 Basic 테마에서 두 프로그램을 실행한 결과는 다음과 같다.

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

desktop window manager가 동작하지 않으니 DwmGetWindowAttribute 함수는 실행이 실패했다.
두 경우 모두 프로그램의 외형은 짤리는 것 없이 동일하고, 테두리의 두께만이 차이가 날 뿐이다. 최신 방식으로 빌드된 프로그램은 더 두껍고, 그렇지 않은 레거시 프로그램은 얇다.

단, Aero의 동작 여부와 무관하게 최신 방식과 레거시 방식이 클라이언트 영역 크기는 동일하다는 걸 알 수 있다. 전자는 테두리가 두꺼우니 클라이언트 영역이 (284, 162)로 더 작고, 후자는 (294, 172)로 더 크다.

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

고전 테마는 Windows 8~10에서는 더 찾아볼 수도 없는 모드가 됐는데.. 얘는 빌드 방식에 따른 차이가 전혀 존재하지 않는다. DwmGetWindowAttribute도 당연히 동작하지 않고 말이다.
단지, 얘는 크기 조절 가능한 창에 대해서는 테두리의 두께가 1픽셀.. 눈꼽만치 더 늘어난다. 그래서 창의 크기가 같을 때 클라이언트 영역의 크기가 (294, 175)에서 (292, 173)으로 2픽셀씩 더 줄어드는 걸 알 수 있다.

사실은, 대화상자를 크기 조절 가능하게 만들면 6.0이 아닌 옛날 방식으로 빌드된 프로그램이라도 앞의 Aero 내지 Basic 테마에서 창이 최신 6.0 버전이 지정된 프로그램과 동일하게 동작한다.
리소스 편집기에서 대화상자의 Border 속성을 Dialog Frame 말고 Resizing으로 지정했을 때 말이다.

그럼 마지막으로 Windows의 종결자 버전인 10에서는 창이 어떻게 뜨는지 살펴보자.
Windows 10의 프로그램 창은 외관상으로 Vista/7 같은 두툼한 테두리가 없고 슬림하다. 그럼에도 불구하고 10에서도 6.0이 지정된 프로그램은 클라이언트 영역의 크기가 레거시 방식 프로그램보다 더 작게 설정된다. 다시 말하지만 GetWindowRect의 결과는 둘 다 동일한데... 이렇게 동작해야 할 이유가 없어 보이는데도 말이다.

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

더욱 괴이한 것은 DwmGetWindowAttribute의 실행 결과이다.
나름 DWM은 동작하고 있는지 이 함수도 값을 되돌리기는 하는데... 보다시피 리턴된 사각형이 GetWindowRect의 리턴값보다도 더 작다~! Vista/7의 Aero와는 결과가 딴판이다.
최신 6.0 방식으로 빌드된 프로그램은 (1, 1) 위치에서 (300, 200) 픽셀 크기를 찍더라도 실제로 생기는 창의 크기는 (8, 1)에서 (286, 193) 픽셀 크기 남짓으로 예상보다 꽤 작게 된다.

지금까지 나열한 실험 결과를 표로 한데 요약하면 다음과 같다. 96dpi (100%) 확대 배율에서 굴림 아니면 맑은 고딕의 완전 기본 metric 기준이다.
GetWindowRect의 값이 다 똑같이 [1,1, 301,201]인 윈도우이더라도 실제 외형과 클라이언트 영역 크기는 운영체제와 빌드 방식에 따라 저렇게 차이가 날 수 있다는 것이다.

  레거시 (2010/XP) 신형 (2012+/Vista+)
DWM Attribute client 크기 DWM Attribute client 크기
Vista/7 Aero [-4,-4, 306,206] [294, 172] [1,1, 301,201] [284, 162]
Vista/7 Basic N/A [294, 172] N/A [284, 162]
Classic N/A [294, 175] N/A [294, 175]*
Windows 10 [3,1, 299,199] [294, 171] [8,1, 294,194] [284, 161]

* resizable하지 않은 대화상자의 경우, 고전 테마에서는 레거시 및 신형 방식 모두 대화상자 외형 차이가 없다. 단지, resizable한 대화상자는 프레임 두께가 1픽셀 더 두꺼워져서 클라이언트 영역이 2픽셀씩 줄어든다.
그리고 고전 이외의 다른 테마에서 resizable한 대화상자는 레거시 방식도 신형 방식과 동일한 결과가 나온다.

그러니, 똑같은 소스 코드이더라도 최신 Visual C++ 컴파일러로 다시 빌드를 하면 예전보다 창 크기가 더 작게 나와서 UI 구성 요소의 오른쪽/아래쪽이 짤리는 부작용이 발생할 수 있다. 그런 상황이 발생하면 놀라지 말고 SetWindowPos 같은 함수에 숫자를 무식하게 하드코딩 하지 않았는지를 침착하게 점검해 보기 바란다.

옛날에는 SetWindowPos, GetClientRect 같은 함수의 msdn 설명을 보면 Windows Vista에서 보정으로 인한 동작 차이가 발생할 수 있다는 말이 '비고'란에 분명 있었다. DWM 쪽의 API를 참고하라는 말도 봤었는데 2010년대 이후부터는 삭제된 듯하다. 지금은 그런 말을 찾을 수 없다.

하지만 그런 말이 있건 없건, 창의 크기를 지정할 때는.. non-client 요소가 일체 없고 픽셀값을 반드시 곧이곧대로 지정해야만 하는 불가피한 상황이 아니라면 어지간해서는 SetWindowPos에다가 하드코딩된 값을 무식하게 전하는 일이 없어야 한다.
이 글에서 소개한 non-client 영역 크기 보정도 있고, 화면 확대 배율(DPI)도 있고.. 하드코딩 픽셀값을 쓸 수 없게 만드는 요인은 여럿 존재한다. 잘 만들어진 프로그램이라면 창 크기를 계산할 때 그런 변수들을 잘 감안해야 한다.

가령, 대화상자 리소스 템플릿에서 사용되는 단위인 DLU를 픽셀수로 보정하려면 MapDialogRect을 사용하면 된다. 이것만으로도 화면 확대 배율은 커버 가능하다.
그리고 특정 클라이언트 영역의 픽셀수와 정확하게 대응하는 window rect 크기를 구하려면 AdjustWindowRect(Ex)를 사용하면 된다.

Posted by 사무엘

2019/05/27 08:34 2019/05/27 08:34
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1624

Windows 운영체제에서 쓰이는 EXE나 DLL 같은 실행 파일들은 잘 알다시피 Portable executable이라는 포맷을 따라 만들어져 있다. 그래도 맨 앞에는 MZ로 시작하는 MS-DOS EXE 헤더가 호환성 차원에서 여전히 있으며, 거기서 가리키는 오프셋을 따라가 보면 그제서야 PE 헤더가 시작된다.

그런데 아무 EXE/DLL이나 찍어서 바이너리 에디터로 들여다 보시라. 도스 EXE stub과 실제 Windows용 실행 파일의 사이 공간에는 뭔가 4바이트 간격으로 규칙성이 느껴지는 정체불명의 데이터가 수십~수백 바이트가량 있으며, 끝에는 약속이나 한 듯 "Rich"라는 문자열이 있다. 이건 도스/Windows 어느 플랫폼에서도 쓰이지 않는 잉여 데이터이다.
이놈의 정체는 도대체 무엇일까...??

사용자 삽입 이미지

더욱 괴이한 것은.. 이건 Visual Basic, Visual C++ 같은 마소의 개발툴(링커)로 생성한 바이너리에만 존재한다는 것이다.
게다가 마소 개발툴조차도 처음부터 이랬던 게 아니다. Visual C++의 경우, 5.0의 마지막 서비스 팩(아마 3)부터 적용된 거라고 한다. 그러니 지금으로부터 20여 년 전, 대략 1997~98년 사이부터이고.. 그냥 편의상 6.0부터라고 생각해도 무방하다.

본인의 고딩 시절, Visual C++ 4.2로 빌드된 날개셋 한글 입력기 1.0 내지 PentaCombat 오목 게임의 실행 파일을 들여다보면.. 아니나다를까 일명 "Rich 헤더"가 존재하지 않는다. MZ와 PE 사이에 아무 공백이 없다.

사용자 삽입 이미지

그러나 VC++ 6으로 빌드된 날개셋 1.2부터는 응당 Rich 헤더가 추가되어 있다.

과거 Windows 9x의 경우, 내부의 프로그램들을 빌드할 때 Visual C++이 아닌 다른 자체 컴파일러를 사용했다. kernel32, gdi32, user32 같은 DLL이라든가 메모장 같은 간단한 프로그램 말이다.
여기서 유래된 바이너리들은 Rich 헤더가 존재하지 않으며, 이 관행은 Visual C++ 6이 출시된 뒤에도 Windows 98/ME까지 변함없이 이어졌다.

하지만 내장 프로그램 중에서 워드패드와 그림판, EUDC 편집기처럼 Program Files\Accessories에 들어있던 프로그램은 MFC도 사용하고 나름 Visual C++ 냄새가 났었다.
얘들은 Windows 95 시절엔 대외적으로 공개된 적이 없는 MFC 짝퉁을 사용하다가, Windows 98부터 깔끔하게 Visual C++ 5 sp3으로 빌드되기 시작했다. 그래서 얘들은 예외적으로 Rich 헤더가 포함되기 시작했다.

이런 9x와 달리 Windows NT는 운영체제 차원에서 처음부터 Visual C++ 팀과 잘 연계하는 편이었다.
9x 계열은 98에 와서야 msvcrt와 mfc42같은 Visual C++ 출신의 배포용 DLL들이 최초로 정식 포함돼 들어간 반면, NT 계열은 처음부터 메모장도 진작부터 msvcrt를 사용해 왔다. NT4는 직접 확인해 보지 않아서 모르겠지만, 2000은 모든 EXE/DLL의 내부에 Rich 헤더가 존재한다.

한편, Visual C++ 말고 델파이 같은 타 개발툴로 빌드된 실행 파일에는 Rich 헤더 같은 건 당연히 존재하지 않는다.

사용자 삽입 이미지

그럼 본론으로 들어가도록 하겠다.
마소에서는 이 헤더? 데이터? chunk?를 왜 집어넣기 시작했으며, 이 정보의 의미는 도대체 무엇일까? 엄밀히 말하면 헤더라고도 볼 수 없는 단순 데이터일 뿐인데 말이다.
놀랍게도 마소에서는 이에 대해서 지금까지 공식적인 답변을 한 번도 제공하지 않았으며 undocumented, 묵묵부답으로 일관한 듯하다. 빌드 시에 이 헤더를 제외시키는 옵션도 없다.

그래서 일각에서는 흉흉한 음모론까지 나돌기 시작했다. 제일 유명한 게 뭐냐 하면, 이건 이 바이너리를 빌드한 컴퓨터 환경을 식별하는 정보라는 것이다.
그래서 이 exe/dll이 악성 코드로 밝혀져서 경찰에 수사를 의뢰하게 되면.. 이 정보로부터 개발자의 컴퓨터를 추적할 수 있고, 따라서 악성 코드를 만든 사람도 아무 단서가 없는 것보다는 더 용이하게 색출할 수 있다고 한다..;;

이거 마치 컬러 복사기 얘기처럼 들린다. 컬러 복사기의 결과물에는 아주 정교한 워터마크가 사람 눈에 안 띄게 몰래 새겨진댄다. 그래서 어설픈 컬러 복사 위조지폐가 발견되면 그 워터마크를 토대로 복사기의 일련번호를 추적할 수 있으며, 이를 통해 범인도 색출할 수 있다고 한다. 허나 본인은 그런 게 실제로 존재한다고 믿지는 않는다.

뭐, 복사기는 그렇다 치고.. 실행 파일의 경우, 상식적으로 생각해 봐도 저건 개발 컴퓨터의 색출 목적으로 사용하기에는 보안이 너무 허술하다.
진짜 나쁜 마음 품은 악성 코드 개발자라면.. 그 Rich 헤더 부분을 후처리로 몽땅 0으로 칠해서 일부러 지워 버리기만 해도 자기 정체를 숨길 수 있으며, 그래도 악성 코드의 동작에는 하등 문제될 것 없다.

이 경우 PE 헤더의 다른 필드에 존재하는 checksum 정보가 어긋나서 파일이 변조되었다는 것이 감지되겠지만, 이것도 일부러 기재하지 않았다고 0을 집어넣어 버리면 그만이다. 더구나 보안을 위해 소프트웨어에서 이미 있던 이스터 에그도 다 없앴고 요즘은 바이너리 차원에서 철저히 예측 가능한 reproducible build까지 추구하는 마당에, 이런 식의 비밀 식별 정보는 마소의 개발 방침 이념과도 어울리지 않는다.

그러니 프로그램을 빌드할 때마다 내 컴퓨터의 맥 어드레스가 유출된다는 식으로 불안해할 필요는 없다. 하지만 Rich 헤더의 정체는 여전히 베일에 싸여 있었다.
그래서 전세계의 많은 컴덕과 해커들이 의문을 품기 시작했으며, 어떤 용자는 MS에서 개발한 링커 프로그램을 아예 근성으로 리버스 엔지니어링까지 했다. 그래서 이 데이터에 대한 여러 사실들을 밝혀냈다.

가장 먼저.. Rich 헤더는 4바이트 덩어리 단위로

A B B B X1 Y1 X2 Y2 ... "Rich" B

대체로 요런 형태로 돼 있다.
끝의 Rich 다음에 나오는 마지막 double word인 B가 일종의 난수이며, 암호화 key이다. 그리고 Rich 앞에 있는 숫자들은 바로 그 B와 xor을 해 주면 실제값을 얻을 수 있다.

그러면 맨 첫째 값 A는 언제나 0x44 0x61 0x6E 0x53.. 문자 형태로 늘어놓으면 "DanS"라는 시그니처가 된다.
다음으로 시그니처 뒤에 이어지는 몇 개의 B는 16바이트 단위 padding을 맞추기 위한 0값인 것 같다. 자기 자신을 xor 하면 결과는 언제나 0이 되니 말이다.

그 뒤 이어지는 같은 값들은 숫자 2개가 X, Y 형태로 한 pair를 이룬다. X는 이 바이너리를 생성하는 데 쓰인 툴(C 컴파일러, C++ 컴파일러, 리소스 컴파일러, 어셈블러 등..)과 버전(빌드 번호)을 나타내며, Y는 그 도구를 이용하여 생성된 아이템.. 이를테면 obj 파일의 개수를 나타낸다고 한다.

그럼 이런 정보들은 링커가 어디에서 얻어서 집어넣는가 하면.. 당연히 자기가 input으로 받아들이는 obj 파일로부터이다. 그렇잖아도 1997~98년을 전후해서 obj 파일 포맷이 바뀌었다고 한다.
obj야 소스 코드를 번역한 기계어 코드의 뭉치일 뿐이니 20년 전이나 지금이나 컴퓨터 아키텍처가 변함없는 한 바뀔 일이 없으며, 내부 구조가 바뀌어 봤자 새 버전의 컴파일러/링커가 인식하는 새로운 chunk 정도나 추가되는 게 전부일 것 같은데.. 꼭 그렇지는 않은 모양이다.

하긴, 같은 양의 코드를 빌드해도 요즘 컴파일러는 예전에 비해 obj의 파일 크기가 점점 더 커지고 있어 보이긴 하다. 디버깅 또는 최적화와 관련된 온갖 힌트와 메타정보들이 첨가되어서 그런 것 같다.
그래도 obj는 소스 코드를 빌드할 때마다 새로 생성되는 일회용 임시 파일일 뿐이니, 포맷에 하위 호환성 걱정 따위는 할 필요가 없을 것이다. 날려 봤자 빌드만 다시 하면 될 일이고..

아무튼 흥미로운 사실을 알게 됐다.
개인 정보 유출은 아니고, 그렇다고 디버깅과 관련된 힌트도 아니고 저런 정보가 마소의 개발툴에서 도대체 왜 들어갔는지는 여전히 오리무중이다만.. 그래도 내역을 까맣게 모르던 시절보다는 상황이 나아졌다.
본인이 이 글을 쓰기 위해 검색하고 참고한 외국 사이트는 다음과 같으니,  더 자세한 관심이 있는 분은 참고하시기 바란다.

The devil’s in the Rich header
Microsoft's Rich Signature (undocumented)
The Undocumented Microsoft "Rich" Header
Article: Things They Didn't Tell You About MS LINK and the PE Header

저 외국 사이트들의 설명에 따르면, Rich와 DanS는 모두 1990년대에 마소의 Visual C++ 팀에서 근무하고 이 데이터의 구현에 직접 관여했던 프로그래머의 이름에서 유래되었을 가능성이 높다고 한다. 그 이름은 각각 Richard Shupak와 Dan Spalding이다. ㄷㄷㄷ;;
MZ, PK만큼이나 프로그래머의 이름이 파일 포맷에 각인된 사례라 하겠다.

참고로 전자 리처드의 경우, Windows SDK에서 psapi.h의 작성자로 이름이 나와 있기도 하다.
PSAPI는 Windows NT 계열용으로 실행 중인 프로세스 정보를 조회하는 EnumProcesses, GetModuleFileNameEx 등의 함수를 정의하는 라이브러리인데, 작성 날짜가 무려 1994년이라고 적혀 있다.

Posted by 사무엘

2019/05/21 08:31 2019/05/21 08:31
,
Response
No Trackback , 3 Comments
RSS :
http://moogi.new21.org/tc/rss/response/1621

1.
먼 옛날에 기술이란 게 지금보다 비싸고 희귀하던 시절에는 컴파일러 자체가 유료화 대상이었다. Windows 플랫폼 SDK에 같이 들어있는 무료 기본 컴파일러는 상업용으로 따로 판매되는 Visual C++의 컴파일러와 같은 빡센 최적화 기능이 없었다. 사용자가 작성한 코드대로 돌아가는 실행 파일은 만들어 주지만 최적의 성능을 발휘하는 형태로 만들지는 않았다는 것이다.

그러다가 Visual C++ 2005 Express를 기점으로 고성능 컴파일러도 무료화되고 기본 제공되기 시작했다.
요즘 컴파일러의 최적화 테크닉은 학교에서 배우는 것처럼 (1) 쓰이지 않는 변수나 코드 제거, (2) 값이 뻔한 수식은 한 번만 미리 계산해 놓고 loop 밖으로 옮기기, (3) 변수는 적절하게 레지스터 등재, (4) 함수는 적절하게 인라이닝, (5) 최대한 병렬화, (6) CPU 명령 하나로 처리 가능한 단위로 묶기, (7) switch 분기를 매번 번거로운 if문이 아니라 테이블 참고 방식으로 변경... 같은 미시적인 것만이 그림의 전부가 아니다.

그야말로 번역 단위(소스 코드) 간의 경계를 넘나들고 컴파일러와 링커의 역할까지 재정립하면서까지 저런 최적화 테크닉을 적용할 부위를 판단할 필요가 있다. 그래서 컴파일러에는 전역 최적화라는 옵션이 도입되고, 링커에는 link time code generation이라는 옵션이 추가되어 서로 연계한다. 뭐, 대단한 기능이긴 하지만 이 정도로 최적화를 쥐어짜서 성능이 더 나아진 것 대비 프로그램의 빌드 타임이 너무 길어지는 건 별로 마음에 안 든다.

그리고 세상에 C++ 컴파일러가 MS 것만 있는 것도 아니고, 경쟁사들의 제품도 갈수록 성능이 좋아지고 가격이 저렴해지고 있으니 optimizing compiler 정도는 충분히 대중적인 영역으로 내려가게 됐다.

2.
한편, Visual Studio Express 에디션은 기업에서 사용이나 상업용 소프트웨어 개발까지 아무 제약 없는 무료인 대신, 리소스 편집기와 MFC 라이브러리가 없다. 지금도 그런지는 모르겠지만 처음엔 심지어 64비트 빌드 기능도 없었다.
그 뒤 VC++ 2013부터는 MFC와 리소스 편집기가 다 있는 Community 에디션이란 게 나왔다. 얘는 기능 제약이 없는 대신 개인 개발자나 스타트업 중소기업 수준까지만(인원 얼마 이하, 연 매출 얼마 이하..) 무료이다.

서로 조건이 다른 무료이기 때문에 VC++ 2013, 2015, 2017까지는 한 버전에 대해서 express와 community 에디션이 모두 나왔다. 하지만 express는 장기적으로는 community로 흡수되고 없어질 것으로 보인다.

3.
이렇게 프로그램을 기본적으로 만들고 빌드하는 데 필요한 도구들은 슬슬 무료화 단계에 들어섰고, 그 다음으로는 그냥 빌드만 하는 게 아니라 제품의 품질을 높이는 데 도움을 주는 도구, 대규모 공동 작업과 테스트를 위해 필요한 전문 도구들이 아직 유료이다. 어떤 것은 Visual Studio의 엔터프라이즈 같은 제일 비싼 에디션에서만 제공된다.

Visual C++ 2008에서는 GUI 툴킷이 feature pack을 통해 무료로 풀렸더니 2012부터는 정적 분석 도구가 무료로 풀렸다. 이걸 처음 써 본 소감은 꽤 강렬했다.

사실, 본인도 그저 닥치는 대로 코딩과 디버깅만 하는 것 말고 소프트웨어공학적인 개발 프로세스라든가 기술 문서 잘 쓰는 요령, 변수와 함수의 이름 잘 짓는 요령, 전문적인 테스트 절차와 프로파일링 같은 것을 잘 알지 못한다. 더 발전하려면 그저 무료로 풀려 있는 도구들만 쓰는 게 아니라 그 너머에 있는 도구들도 뭔지 알고 필요성을 공감할 정도는 돼야 할 텐데..

그렇잖아도 날개셋 한글 입력기도 어떤 형태로든 버그가 없었던 적은 거의 없었다. 그리고 개인 프로젝트뿐만 아니라 회사 업무도 늘 깔끔하게 마무리 짓지는 못해서 버그가 있는 채로 제품을 내고 후회한 적이 적지 않았다.
만들어진 제품의 품질을 검증하는 절차 자체는 소프트웨어뿐만 아니라 건축· 건설을 포함해 공학의 어느 분야에서나 다 필요하고 존재할 것이다. 그 가운데에 무형의 지적 산물인 소프트웨어만이 갖는 특수성 때문에 이 바닥에서만 통용되는 방법론도 있겠지만 말이다.

아무튼, 프로그래밍 툴의 제작사들이 흙 파서 장사하는 건 아닐 테니.. 이런 어마어마한 컴파일러쯤은 딴 데서 개발 비용을 회수할 통로가 다 있으니까 무료로 풀어 놓는 게 가능할 것이다.

4.
(1) 이렇듯, Visual Studio를 포함해 개발툴들은 갈수록 기능이 좋아지고 기술들이 상향평준화되고 있다. 일례로, 2017인가 2019부터는 코드 편집기에 일반 컴파일러의 경고/에러뿐만 아니라 정적 분석 결과까지 초록색 밑줄로 띄워 주는 걸 보고 무척 감탄했다. (NULL 포인터 역참조가 일어날 수 있습니다 따위..)

(2) 2017/2019부터는 도움말과 API 레퍼런스는 몽땅 인터넷으로 처리하고 로컬 오프라인용 Help Viewer를 더 관리하지 않으려는지? 설치 화면에서는 기본 선택돼 있지도 않으며, 컨텐츠 역시 2015 내용 이후로 달라진 게 없어 보인다.

(3) 그리고 Spy++의 64비트 버전은 왜 도구 메뉴에서 숨겨 버린 걸까?
한 프로그램만으로 32비트와 64비트를 통합해서 잘 동작하는 것도 아니고, "이 창의 메시지를 들여다보려면 64비트 Spy++를 실행해 주십시오" 안내를 해 주는 것도 아닌데 왜 이런 조치를 취했는지 이해할 수 없다. 사실은 32와 64비트를 가리지 않고 아주 seamless하게 동작하는 Visual Studio의 디버거가 정말 예술적인 경지의 대단한 도구이긴 하다.

(4) 시대 유행과 별 관계 없는 부분은 계속해서 안 바뀌기도 하는 것 같다.
도구상자를 customize할 때 콤보 박스의 길이 조절을 200x 시절처럼 마우스 드래그로 할 수 없고.. 2010 이래로 그냥 픽셀수 입력으로 때운 걸로 굳히려는가 보다.

무려 7년 전 글에서 지적했던 2. 메뉴 편집기의 우클릭 버그, 3. 툴바 편집기의 화면 잔상 역시, Visual Studio 2019 현재까지도 전혀 고쳐진 게 없다.
그리고 예전엔 안 그랬던 것 같은데 2019는 Visual Studio 텍스트 에디터의 폰트를 딴 걸로 바꾸고 껐다가 다시 켜서 곧장 반영되는 걸 확인까지 했는데.. 켠 채로 절전 상태로 몇 번 갔다가 돌아오면 폰트가 컴터에 따라 아주 가끔은 돋움이나 Courier New 같은 기본 글꼴로 돌아오는 것 같다.

Posted by 사무엘

2019/05/10 08:36 2019/05/10 08:36
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1617

Windows의 Region

Windows API에는 region(영역)이라는 일종의 자료구조 라이브러리가 있다. 얘는 2차원 래스터(픽셀/비트맵) 그래픽 공간에서 각 픽셀별로 "영역에 포함되냐 안 되냐"라는 일종의 '집합'을 표현한다.
그리고 운영체제는 이 자료구조를 이용하여 각종 그래픽이 그려지는 영역을 정한다. 즉, region은 클리핑(clipping) 영역을 표현하는 데 쓰인다는 것이다.

도스 시절의 여느 그래픽 라이브러리에도 간단한 사각형 영역에만 그림이 그려지게 하는 초보적인 수준의 클리핑 기능은 있었다. 하지만 Windows의 region은 여러 사각형이 겹친 것, 임의의 다각형, 원 등 아무 모양이나 표현하고, 그 영역 안에만 그림이 그려지게 만들 수 있다.

그도 그럴 것이 Windows 같은 GUI 운영체제라면 창들의 Z-order 같은 걸 구현하는 과정에서 밥 먹고 맨날 하는 짓이 정교한 클리핑일 수밖에 없게 된다. 뒤쪽에 있는 창 내용은 앞쪽에 있는 창의 영역을 침범하지 않고 그려져야 하기 때문이다. 그러니 그런 기능을 사용자들에게도 쓰라고 제공해 주는 게 결코 이상한 일이 아니다.

region은 가장 먼저, (1) 직사각형, 타원, 모서리가 둥근 직사각형, 다각형(CreatePolygonRgn, CreatePolyPolygonRgn)처럼.. 속이 닫힌 도형을 그리는 다양한 API를 통해 생성할 수 있다. 당연히 그 도형의 모양이 영역의 모양이 된다.

다음으로, GDI가 제공하는 기능인 (2) path로부터 region을 생성할 수 있다(PathToRegion). path란 마치 윤곽선 글꼴 글립처럼 직선(MoveTo, LineTo)과 곡선(PolyBezirTo)을 임의로 조합하여 어떤 궤적이나 경계선을 기술하는 자료구조이다. region과 달리 벡터 기반이며, 별도의 자료구조로 존재하는 게 아니라 DC의 내부 상태에 종속인 형태로 보관된다는 차이가 있다.

path를 사용하면 경계선이 베지어 곡선인 region을 만들 수 있으며, TextOut 같은 글자 출력 함수를 path에다 넣으면 임의의 글자의 윤곽선도 따서 고스란히 region으로 만들 수 있다. 커다란 두 글자를 포개 놓은 뒤, 겹치는 영역만 다른 색깔로 칠하는 게 region으로는 가능하다. 그 비결은 바로...

사용자 삽입 이미지

(3) CombineRgn이라는 함수를 통해 region 간에 일종의 집합 연산을 할 수 있기 때문이다. 두 region의 교집합, 합집합, 차집합을 구함으로써 더 복잡한 형태의 region을 만들 수 있다.

위의 그림을 보아라. 운영체제나 하드웨어 차원에서 제공되는 layer이나 alpha channel 합성 같은 걸 쓴 게 아니다. 옅은 회색(B), 짙은 회색(S), 검정(겹침)이 차지하는 영역을 2차원적으로 완전히 따로 떼어내서 서로 완전히 다른 색깔과 무늬로 칠할 수 있다. 뚝 떨어진 영역도 당연히 같이 감안해서 말이다. 이런 게 평범한 글자 찍기 API로는 가능하지 않을 것이다.
다만, region은 anti-aliasing을 지원하지 않는 boolean 흑백 자료구조이다 보니, 글자 경계가 거친 것은 아쉬운 점이며 요즘 그래픽 기술의 트렌드와 맞지 않다.

그리고 끝으로.. region은 내부 자료구조를 어느 정도 노출해 주고 있기까지 하다. 그래서 그걸 직통으로 저장하고 불러오는 식으로 생성할 수도 있다. 데이터를 얻는 함수는 GetRegionData이고, (4) 그걸로부터 region을 다시 생성하는 함수는 ExtCreateRegion이다.

어떤 방식으로 region을 생성했건, 얘는 내부적으로 크게 세 부류로 나뉜다. region을 생성하거나 받아들이는 함수들이 그 region의 유형을 리턴값을 통해 알려주기도 한다.

  • 아무 영역도 없는 공집합인 NULLREGION
  • 직교좌표 직사각형 하나로만 구성된 SIMPLEREGION
  • 그 외의 다른 모든 모양을 표현하는 COMPLEXREGION.. 얘는 내부적으로 2개 이상의 사각형, 아니 scan line들로 구성된다.

자연에서 관찰되는 힘이라는 것들이 중력이나 원자력과 관계 있는 게 아니면 나머지는 출처가 몽땅 전자기력이듯이(폭발력, 마찰력, 탄성, 자석, 정전기, 표면장력, 생물 근육 등등등..),
그리고 사람이 생성(...)되는 방식이 흙을 빚어서 직통, 여자의 씨 같은 극소수 예외를 제외하면 나머지 수십~수백 억의 인간들은 몽땅 남자의 씨 기반이듯이.. 그런 것처럼 region도 일상생활에서 보는 단순하지 않은 물건들은 몽땅 complex라고 생각하면 되겠다.

region이 내부적으로 구현된 방식의 특성상(벡터/오브젝트 기반이 아닌 비트맵/픽셀 스캔라인 기반) 무한을 구현할 수 있지는 않으니, 집합 연산에서도 not 연산인 여집합이 지원되지는 않는 걸 볼 수 있다. 차라리 이미 있는 집합끼리 차집합이 대신 지원되고 말이다.

region을 식별하는 핸들 내지 포인터 자료형은 HRGN이다. 그런데 Create...처럼 HRGN을 리턴값으로 주는 함수 말고, Get...Rgn, CombineRgn 이런 이름이면서 HRGN을 인자로 받는 함수들은... 이미 있는 HRGN에다가 값만 바꿔서 넣어 준다. 그런 함수를 쓰려면 null region 하나라도 미리 미리 생성해서 전해 줘야 한다.

그런데 Windows API에는 많고 많은 region 생성 함수들 중에 null region만을 달랑 생성하는 함수는 의외로 없다. 좌표가 모두 0인 직사각형.. CreateRectRgn(0,0,0,0) 이게 그냥 텅 빈 region을 생성하는 역할을 한다. 좀 교묘한 점이다.

그럼 이 region는 어떤 용도로 쓰이며, 할 수 있는 일이 무엇일까? 본인이 생각하기에 다음과 같다.

1. 단독

그냥 저거 자체만으로 뭔가 2차원 공간 상의 기하/집합 알고리즘 구현체로.. 다른 GDI API와의 연계 없이, 심지어 명령 프롬프트용 프로그램에서도 쓰일 수 있다. 펜, 브러시, 글꼴, 비트맵 같은 타 오브젝트들이 DC와의 연계 없이는 거의 쓸모없는 것과 굉장히 대조적이다.
하지만 region이 그렇게 단독으로 쓰이는 경우는 그리 많지 않아 보인다.

2. 창의 invalid 영역 표현

어떤 창의 앞을 가리던 다른 창이 없어지고 내 창의 내용이 다시 그려지게 됐을 때.. 그 이름도 유명한 WM_PAINT 메시지가 날아온다.
BeginPaint와 함께 제공되는 DC는 창 전체가 아니라 정확하게 다시 그려져야 하는 영역에만 그림이 그려지도록 클리핑 처리가 돼 있는데, 이 영역이 말 그대로 region으로 표현되며 GetUpdateRgn 함수를 통해 얻어 올 수 있다.

WM_PAINT 때 이 영역에 대해서 PtInRegion이나 RectInRegion을 적절히 호출하면서 그림을 그리면, 무식하게 화면 전체를 그리는 것보다 프로그램 성능과 반응성을 향상시킬 수 있다.
물론 DC 차원에서 클리핑 처리가 되는 것만으로도 화면 전체를 그리는 것보다는 속도가 향상되지만, 애초에 그리기 요청을 안 하고 CPU 계산을 안 하는 게 더 낫기 때문이다.

3. 내부 클리핑

운영체제뿐만 아니라 사용자 역시 임의의 region을 생성해서 클리핑 용도로 쓸 수 있다. 비트맵이 사각형 모양이 아니라 원 모양으로만 뿌려진다거나, 특정 글자 모양으로만 뿌려지게 할 수 있다는 것이다.
그렇게 하려면 HRGN을 DC에다가 지정하면 된다. 이것은 SelectObject로 해도 되고 SelectClipRgn으로 해도 된다. 완전히 동일하다.
단지, 클리핑을 해제하는 것은 SelectClipRgn로만 가능하다. HRGN 값으로 NULL을 전해야 하기 때문이다. null region은 그림이 전혀 그려지지 않게 하는 효과를 낼 테니까..

HRGN은 기술적으로는 HPEN, HBRUSH, HBITMAP, HFONT와 마찬가지로 여러 GDI 오브젝트 중 하나로 취급된다. 상술한 바와 같이 DC에 SelectObject 될 수 있으며, 소멸 함수가 DeleteObject인 것까지도 동일하다.
하지만 얘는 다른 오브젝트들과 달리 select나 get 될 때 내부 메모리가 복사될 뿐, 핸들값 자체를 주고 받지는 않는다. 즉, HRGN은

HRGN oldRgn = (HRGN)SelectObject(dc, newRgn);
(.....)
SelectObject(dc, oldRgn);

이런 식으로 운용되지 않는다는 것이다. 옛날 핸들값을 보관하고 되돌리는 식의 절차가 필요하지 않다.
DC와 region은 서로 따로 논다. 이렇게 설정한 뒤에 원래 있던 HRGN 핸들은 곧장 삭제해 버려도 된다.

본인은 MFC의 CGdiObject처럼 GDI 객체 핸들만 한데 뭉뚱그린 템플릿 클래스를 만들어서 쓰고 있다. (소멸자에는 DeleteObject가 있고..)
그런데 다른 오브젝트들은 template<T> Handle(T v=NULL) 이런 식으로 NULL이 default 인자인 생성자를 만들어서 초기화할 수 있는 반면,
HRGN에 대해서는 인자가 없는 경우에 대해 specialize된 생성자를 따로 만들어서 이때는 null region을 생성해 놓게 했다. 그래야 이놈을 region을 얻어 오는 다른 함수에다가 인자로 줄 수 있기 때문이다.

4. 칠하고 그리는 공간

그리고 region 자체가 도형을 나타내니 그 모양대로 클리핑이 아니라 내부를 칠하는 용도로 응당 활용할 수 있다. 내부를 칠하는 FillRgn과, 내부의 경계선을 그려 주는 FrameRgn이라는 함수가 제공된다.
흥미로운 것은 경계선을 그릴 때도 pen 대신 brush가 쓰인다는 것이다. region은 path와 달리 벡터 드로잉이 아니기 때문이다. 경계선은 그냥 픽셀 차원에서 색깔이 변하는 곳을 얼추 감지해서 표시해 주는 것일 뿐이다.

5. 창 자체의 외형

끝으로, region은 윈도우의 모양을 지정하는 용도로도 쓰인다. 통상적인 사각형 모양이 아니라 리모콘 같은 다른 물건처럼 생긴 프로그램 창, 현란한 스플래시 윈도우, 내부에 구멍까지 있는 윈도우.. 전부 SetWindowRgn 함수의 산물이다.
한번 SetWindowRgn에다 전해 준 HRGN은 이제 운영체제가 관리하기 때문에 사용자가 DeleteObject 하지 말아야 한다고 문서에 거듭 명시되어 있다.

SetWindowRgn를 지정하는 순간부터 그 창은 운영체제가 non-client 영역과 테두리 경계에 기본 제공하는 각종 테마와 반투명 프레임, 둥그런 테두리, 그림자 효과들로부터 완전히 열외된다. 그 대신 고전 테마의 완전 무미건조한 테두리만이 그려진다. 일반적으로는 당연히 아래처럼 그려질 프로그램 창이 위처럼 그려지게 된다는 뜻이다.

사용자 삽입 이미지

그런 창은 어차피 non-client 영역이 전혀 없이 외형을 사용자가 완전히 customize하는 형태로 쓰일 테니 말이다. 제목 표시줄과 테두리의 외형은 보급품 그대로이면서 중앙에만 region을 지정해서 총알 구멍 같은 게 숭숭 뚫린 프로그램 창 같은 건 만들 수 없다는 뜻이다.

보통은.. 공용 컨트롤 6.0 매니페스트가 없는 프로그램의 경우, 버튼 같은 컨트롤들이 구닥다리 고전 스타일로 그려지고 non-client 영역은 운영체제가 자동으로 쌈빡하게 그려 준다. (그림에서 아래 오른쪽) 그런데 SetWindowRgn을 지정하면 반대로 컨트롤들은 정상적으로 그려지는데 non-client 영역이 고전 스타일로 돌아간다는 게 흥미롭다.

단, 지난 Windows 2000부터는 SetWindowRgn가 아닌 다른 방법으로도 사각형 모양이 아닌 윈도우를 표현할 수 있게 되었다. 바로 layered window이다. WS_EX_LAYERED 스타일을 준 윈도우에다가 SetLayeredWindowAttributes 함수를 호출하면 (1) 이 창에 대해서 투명도를 지정할 수 있고, (2) 특정 RGB 값을 color key로 지정해서 그 색깔은 투명으로 처리할 수 있다. 배경색을 칠하는 것만으로 그 부위가 투명해지니, 번거로운 region보다 훨씬 더 편리해지기까지 했다.

과거 MS Office 97~2000 시절의 흑역사 중에는 'Office 길잡이'라는 "취지만 좋았다" 급의 물건이 있었다. 애니메이션 캐릭터가 화면에 나타나서 돌아다니는데.. 첫 도입되었던 97은 캐릭터가 사각형 창 안에 갇힌 형태였던 반면, 2000부터는 배경 없이 창이 시시각각 애니메이션 캐릭터 모양으로 변했다.

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

이게 Windows 2000에서는 하드웨어빨을 탄 layered window로 비교적 간편하게 구현됐던 반면.. 9x에서는 일일이 window region을 바꿔 가면서 동작했다. 그 밑의 창은 매번 WM_PAINT가 발생하면서 성능 페널티가 장난이 아니었을 것이다.

본인은 먼 옛날에 이런 프로그램도 구경한 적이 있었다. 실행하면 만화영화 그림체로 그려진 병아리 한 마리가 마치 저 Office 길잡이처럼 튀어나왔는데, 그냥 가만히 있는 게 아니라 각종 프로그램 창들 위를 돌아다녔다. 나름 중력도 구현했던지라, 마우스로 집어다가 공중에다 옮겨 놓으면 아래의 근처에 있는 창으로 떨어지기까지 했다. 내 기억이 맞다면 꽤 재미있는 프로그램이었는데.. 지금 인터넷으로 다시 검색하고 구할 길이 없다.

이 프로그램에 대해서 본인이 현재 기억하고 있는 건 아마 일본에서 만들어진 걸로 추정된다는 것, 그리고 무려 Windows 3.x용 16비트 프로그램이었다는 것이다. 그 열악한 Windows 3.x에서도 타이머를 걸어서 최소화 아이콘에다가 애니메이션을 구현하고, 창의 경계가 시시각각 곡선 윤곽으로 바뀌는 저런 액세서리 프로그램이 만들어지기도 했다.

이렇듯, SetWindowRgn을 이용해서 이런 재미있는 활용을 할 수 있는데.. 날개셋 한글 입력기에서 사각형이 아닌 모양의 창이 나타나는 곳은 마우스 휠을 눌렀을 때 나타나는 동그란 자동 스크롤 앵커가 유일하다.

사용자 삽입 이미지

에디트 컨트롤은 자동 스크롤 모드가 없고, MS 오피스 제품들은 동그란 테두리 없이 그냥 배경에다가 회색 화살표가 나타나는 듯하지만.. 마소에서 만든 웹 브라우저(IE, Edge)에서는 앵커 윈도우가 나타난다. 날개셋의 앵커 윈도우도 먼 옛날에 얘를 참고해서 만들어진 것이다. 맨 처음에는 region만 쓰다가 이내 layered window도 사용하도록 형태가 바뀌었다.

여담: 좌표계 관련 문제

아, region의 경계면과 관련해서 주의해야 할 점이 있다.
같은 좌표를 줬을 때, 직사각형은 pen으로 그려지는 테두리와 region의 영역이 픽셀 단위로 정확하게 일치한다. 다시 말해 같은 RECT rc에 대해서 CreateRectRgn + SetClipRgn을 한 뒤에Rectangle을 호출한 결과는 클리핑을 안 했을 때와도 동일하다.

하지만 타원(Ellipse vs CreateEllipticRgn)이나 폴리곤(Polygon vs CreatePolygonRgn) 같은 다른 도형에 대해서는 이것이 성립하지 않는다. region은 오른쪽과 아래 끝의 1픽셀이 미묘하게 잘린다.

//직사각형
CRgn rg; CRect rc(10, 10, 80, 80);
rg.CreateRectRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Rectangle(rc);
rc.OffsetRect(40, 40); dc.Rectangle(rc);

//원
CRgn rg; CRect rc(110, 10, 180, 80);
rg.CreateEllipticRgnIndirect(rc);
dc.SelectClipRgn(&rg); dc.Ellipse(rc);
rc.OffsetRect(40, 40); dc.Ellipse(rc);

//폴리곤으로 표현한 직사각형
CRgn rg; POINT pt[4] = {
{10, 100}, {80, 100}, {80, 170}, {10, 170} };
rg.CreatePolygonRgn(pt, 4, ALTERNATE);
dc.SelectClipRgn(&rg); dc.Polygon(pt, 4);

이 코드를 실행한 결과는 다음과 같이 차이가 난다.

사용자 삽입 이미지

폴리곤으로 직사각형 좌표를 지정해 줘도, 아예 직사각형 전용 생성 함수를 줬을 때와 달리, region은 영역이 살짝 덜 생긴다. 그래서 그 region 안에서 동일 좌표로 도형을 직접 그려 보면 테두리의 오른쪽과 아래쪽이 잘린다.

이를 감안해서 원형 region을 생성할 때는 그리기 함수일 때보다 1픽셀 정도 더 크게 원을 그리면 잘리는 현상은 막을 수 있다. 하지만 그래도 그리기 함수와 region 함수는 경계 계산 결과가 서로 미묘하게 달라서 직사각형일 때처럼 깔끔하게 일치하는 모양이 나오지 않는다.
그러니 region 자체의 경계를 그려 주는 FrameRgn 함수를 대신 쓸 수밖에 없다. 허나, 얘가 그려 주는 테두리는 전문적인 원 그리기 함수에 비해 표면이 거칠며 별로 예쁘지 않다.

본인은 처음에 이런 특성을 몰라서 한동안 삽질을 했었다. 이럴 때도 region 대신 layered window는 순수하게 그리기 결과에 따라서 투명색을 자동으로 처리해 주니 더욱 유용하다.

Posted by 사무엘

2019/04/12 08:34 2019/04/12 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1607

1. 재부팅 이벤트

본인은 지금까지 Windows가 시스템 종료/재시작/로그아웃 될 때.. 메시지에 즉각 응답하면서 정상적으로 돌아가는 프로그램이라면 종료 처리도 당연히 정상적으로 되는 줄로 알고 있었다.
사용자가 [X] 버튼이나 Alt+F4를 눌러서 종료할 때와 완전히 동일하게 말이다. WM_CLOSE에 이어 WM_DESTROY, WM_NCDESTROY가 날아오고, WM_QUIT이 도달하고, message loop이 종료되고, MFC 프로그램으로 치면 ExitInstance와 CWinApp 소멸자가 호출되고 말이다.

왜냐하면 시스템이 종료될 때 발생하는 현상이 언뜻 보기에 정상적인 종료 과정과 동일했기 때문이다. 저장되지 않은 문서가 있는 프로그램에서는 문서를 저장할지 확인 질문이 뜨고, 운영체제는 그 프로그램 때문에 시스템 종료를 못 하고 있다고 통보를 한다. 좋게 WM_CLOSE 메시지를 보내는 걸로 반응을 안 하는 프로그램에 대해서만 운영체제가 TerminateProcess 같은 강제 종료 메커니즘을 동원할 것이다.

그런데 알고 보니 그게 아니었다.
정상적인 종료라면 ExitInstance 부분이 실행되어야 할 것이고 프로그램 설정들을 레지스트리에다 저장하는 부분도(본인이 구현한..) 실행돼야 할 텐데, 내 프로그램이 실행돼 있는 채로 시스템 종료를 하고 나니까 이전 설정이 저장되어 있지 않았다.

꼭 필요하다면 WM_ENDSESSION 메시지에서 사실상 WM_DESTROY 내지 ExitInstance와 다를 바 없는 cleanup 작업을 해야 할 듯했다. 뭐, 날개셋 한글 입력기에서 이 부분이 반영되어 수정돼야 할 건 딱히 없었지만 아무튼 이건 내 직관과는 다른 부분이었다.

시스템이 종료될 때 발생하는 일은 딱히 디버거를 붙여서 테스트 가능하지 않다. =_=;; 로그 파일만 기록하게 해야 하고, 매번 운영체제를 재시작해야 하니 가상 머신을 동원한다 해도 몹시 불편하다. 꽤 오래 전 일이 됐다만, 날개셋 외부 모듈이 특정 운영체제와 특정 상황에서 시스템 종료 중에 운영체제 시스템 프로세스 안에서 죽는 문제가 발생해서 디버깅을 한 적이 있었다. 몹시 골치 아팠었던 걸로 기억한다.

2. 파일의 동등성 비교

파일 이름을 나타내는 어떤 문자열이 주어졌을 때,

  • 상대 경로(현재의 current directory를 기준) vs 절대 경로
  • 과거 Windows 9x 시절이라면 ~1 같은 게 붙은 8.3 짧은 이름 vs 원래의 긴 이름
  • 대소문자 (Windows는 대소문자 구분이 없으므로. 그런데 이것도 A~Z 26자만 기계적으로 되나? 언어별로 다른 문자의 대소문자는?)
  • 그리고 옵션으로는 정규 DLL search path와 환경 변수

이런 것들을 몽땅 다~ 감안해서 다음과 비스무리한 일을 하는 가벼운 API가 좀 있으면 좋겠다. 보다시피 동일한 파일을 표현하는 방법이 굉장히 다양하기 때문이다.

  • 두 개의 파일 명칭이 서로 동일한 파일인지 여부를 판단. 당연히 파일을 직접 open/load하지 않고 말이다.
  • 그 파일의 대소문자 원형을 유지한 절대 경로. 일명 '정규화된' 이름을 되돌린다. 이게 동일한 파일은 물리적으로, 절대적으로 동일한 파일임이 물론 보장된다.
    참고로 Windows API 중에서 얼추 비슷한 일을 한다고 여겨지는 GetFullPathName이나 GetModuleFileName 함수는 절대 경로 말고 파일의 대소문자 원형 복원이 제대로 되지 않는다.
  • 혹은 그 파일의 시작 지점이 디스크에서 물리적으로 어디에 있는지를 나타내는 64비트 정수 식별자를 구한다. 포인터가 아니지만 파일의 동등성 여부 판단은 가능한 값이다. 정수를 넘어 UUID급이어도 무방함.

본인은 비슷한 목적을 수행하기 위해, 그냥 두 파일을 CreateFileMapping으로 열어 보고 리턴된 주소가 동일하면 동일한 파일로 간주하게 한 적이 있었다. 핸들 말고 MapViewOfFile 말이다. 본질적으로 동일한 파일이라면 운영체제가 알아서 같은 주소에다 매핑을 하고 레퍼런스 카운트만 증가시킬 테니까..
Windows 9x에서는 주소가 시스템 전체 차원에서 동일하겠지만 NT 계열에서는 한 프로세스 내부에서만 동일할 것이다.

하지만 내가 필요한 건 파일의 내용이 아니라 그냥 두 파일의 동등성뿐인데 이렇게 매핑을 하는 건 overkill 삽질 같다는 인상을 지울 수 없었다.
비슷한 예로, LoadLibrary도 같은 파일에 대해서는 같은 리턴값이 돌아온다. HMODULE도 오늘날은 핸들이 아니라 메모리 map된 주소이니까.. 다만, 오버헤드를 줄인답시고 LoadLibraryEx + LOAD_LIBRARY_AS_DATAFILE 이렇게 열어서는 안 된다. 그러면 로딩 방식이 크게 달라지더라.

3. JNI에서 문자열 처리하기

Java 언어는 JNI라고 해서 자기네 바이트코드 가상 머신이 아닌 C/C++ 네이티브 코드를 호출하는 통로를 제공한다.
프로그램 자체를 C/C++로 짜던 시절에는 극한의 성능을 짜내야 하는 부분에 어셈블리어를 집어넣는 게 관행이었는데.. 이제는 일반적인 코딩은 garbage collector까지 있는 상위 계층에서 수행하고, 극한의 성능을 짜내야 하는 부분에서만 C/C++ 코드를 호출한다는 게 흥미롭다.

JNI는 그냥 언어 스펙에 가까운 광범위한 물건이다. Windows 환경에서는 그냥 Visual C++로 빌드한 DLL이 export하는 함수를 그대로 연결할 수도 있다. 물론 그 DLL을 빌드하기 위해서는 Java SDK에서 제공하는 jni 인터페이스 헤더와 static 라이브러리를 사용해야 한다.
한편, 안드로이드 앱 개발에서 쓰이는 NDK는 JNI 스펙을 기반으로 자체적인 C++ 컴파일러까지 갖춘 네이티브 코드 빌드 도구이다.

Java의 문자열은 JNI에서는 jstring이라고 내부 구조를 알 수 없는 자료형의 포인터 형태로 전달된다. C++에서는 UTF-8과 UTF-16 중 편한 형태로 바꿔서 참조 가능하다.
UTF-8로 열람하려면 JNIEnv::GetStringUTFChars를 호출하면 된다. 길이를 알아 오려면 GetStringUTFLength부터 호출한다. 전해받은 문자열 포인터는 ReleaseStringUTFChars로 해제한다.

그 반면, UTF-16 형태로 열람하려면 위의 함수 명칭에서 UTF를 빼면 된다. GetStringChars, GetStringLength, ReleaseStringChars의 순이다. Java가 내부적으로 문자를 2바이트 단위로 처리하기 때문에 이들이 주로 취급하는 자료형은 jchar*이다. 그러니 얘는 char16_t 자료형과 호환된다고 간주해도 좋다. 참고로 wchar_t는 NDK 컴파일러의 경우 4바이트로 처리되더라.

UTF-16이나 UTF-8이나 다 UTF이긴 마찬가지인데, Java는 변별 요소인 8을 생략하고 함수 이름을 왜 저렇게 지었나 개인적으로 의구심이 든다. 물론 GetStringChars는 Java가 내부적으로 문자열을 원래부터 2바이트 단위로 처리하다 보니 우연히 UTF-16과 대응하게 됐을 뿐, 대놓고 UTF-16을 표방했던 건 아닐 것이다. 뭐, 이제 와서 그 체계를 바꾸는 건 불가능하고 "자바 문자열 = 2바이트 단위"는 완전히 고정되고 정착했지만 말이다.

또한 GetStringChars는 GetStringUTFChars와 달리 굉장히 치명적으로 불편한 단점이 하나 있다. 바로.. 변환된 문자열이 NULL-terminated라는 보장이 없다는 것이다!
그래서 본인은 이 포인터를 사용할 때 메모리를 n+1글자만치 또 할당해서 null문자를 추가해 주는 매우 번거로운 두벌일을 하고, 아예 클래스를 이렇게 따로 만들어야 했다. 좀 개선의 여지가 없으려나 모르겠다.

class CJstrToString16 {
    JNIEnv *_ev;
    jstring _jstr;
    const jchar *_ret;
    char16_t *_arr;
public:
    CJstrToString16(JNIEnv *ev, jstring js): _ev(ev), _jstr(js) {
        jsize n = ev->GetStringLength(js);
        _ret = ev->GetStringChars(js, NULL);
        _arr = new char16_t[n+1];
        memcpy(_arr, _ret, n*sizeof(char16_t));
        _arr[n] = 0; //고작 요거 하나 때문에..
    }
    ~CJstrToString16() {
        ev->ReleaseStringChars(_jstr, _ret);
        delete[] _arr;
    }
    operator const char16_t*() const { return _arr; }
};

4. Visual C++의 STL

C++은 타 프로그래밍 언어들과 달리, 심지어 전신인 C와도 달리, 언어가 개발되고 나서 자신의 특성을 잘 살린 라이브러리가 언어 차원에서 붙박이로 곧장 제정되지 않았던 모양이다. 그래서 각 컴파일러들이 중구난방으로 파편화된 형태로 라이브러리를 제공해 오다가.. 표준화라는 게 1990년대 말이 돼서야 논의되기 시작했다.

템플릿이 추가되어 C++에서도 제네릭, 메타프로그래밍이라는 게 가능해진 뒤부터 말이다. 처음에는 자료구조 컨테이너 위주로 STL이라는 이름이 붙었다가 나중에는 그냥 C++ library가 된 걸로 본인은 알고 있다.

Windows용으로 가장 대중적인 C++ 컴파일러야 두 말할 나위 없이 MS Visual C++이다. 얘는 거의 20여 년 전 6.0 시절부터 P.J. Plauger라는 사람이 구현한 C++ 라이브러리를 제공해 왔다. C 라이브러리와 달리 C++ 라이브러리는 소스가 비교도 안 될 정도로 복잡하고 난해하다는 것(암호 같은 템플릿 인자들..=_=), 그리고 저렇게 마소 직원이 아닌 개인 이름이 붙어 있다는 게 인상적이었다. 2000년대 초까지만 해도 휴렛-패커드라는 회사명도 주석에 기재돼 있었다.

P.J. Plauger는 현재는 Dinkumware라고 C++ 라이브러리만 전문적으로 관리하고 라이선스 하는 회사를 설립해 있다. 나이도 생각보다 지긋한 듯..
그런데 이런 세계적인 제품에 들어가는 라이브러리가.. 성능이 의외로 시원찮은가 보다. Visual C++이 제공하는 컨테이너 클래스가 유난히도 느리다고 까이는 걸 여러 사이트에서 봐 왔다.

최근에는 본인 직장의 상사마저도 같은 말씀을 하시기에 "헐~!" 했다. 업무상 필요해서 string, set, map 등을 써서 수십, 수백 MB에 달하는 문자열을 분석하는 프로그램을 돌렸는데, 자료 용량이 커질수록 속도가 급격히 느려져서 자료구조를 직접 새로 짜야 할 판이라고 한다.

난 개인적으로는 C++ 라이브러리를 거의 사용하지 않고, 더구나 그걸로 그 정도까지 대용량 작업도 해 보지 않아서 잘 모르겠다. 그 날고 기는 전문가가 만든 코드에 설마 그런 결함이 있으려나? 아니면 컴파일러의 최적화 문제인지?

글쎄.. 이런 게 있을 수는 있다. MFC의 CString은 그냥 포인터와 크기가 동일하며 값으로 전할 때의 reference counting도 처리한다. 그러나 std::string은 자주 쓰이는 짧은 문자열을 번거로운 heap 메모리 할당 없이 빠르게 취급하기 위한 배열까지 내부에 포함하고 있다. 이런 특성을 모르고 std::string도 함수에다 매번 value로 전달하면 성능에 악영향을 줄 수밖에 없다.

그런 식으로 임시 객체가 쓸데없이 생겼다가 사라지는 구조적인 비효율이 C++ 라이브러리에 좀 있는 걸로 들었다. R-value 참조자 &&가 도입된 것도 vector의 내부 처리에서 그런 삽질을 예방하는 근거를 언어 차원에서 마련하기 위해서라지 않는가? 그리고 Visual C++이 그런 비효율을 보정하는 성능이 좀 시원찮다거나 한 것 같다. 전부 다 그냥 추측일 뿐이다.

그러고 보니 cout<<"Hello world"가 printf("Hello world")보다 코드 오버헤드가 작아지는 날이 과연 올지 모르겠다. 이것도 그냥 떡밥인 건지..?? =_=;;

Posted by 사무엘

2019/04/09 08:32 2019/04/09 08:32
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1606

요즘 컴퓨터 프로그램들은 상업용이라 해도 과거처럼 디스켓이나 씨디가 담긴 패키지 박스의 형태로 배포되는 경우가 거의 없어졌다. 바이너리의 배포 자체는 인터넷으로 하며, 패키지가 있다 해도 그 안에는 시리얼 번호 쪽지 정도나 달랑 들어있다. 그 뒤, 사용자가 구매한 카피 개수만큼만 프로그램을 동시에 구동하고 있는지, 사용 기한이 경과하지 않았는지 같은 인증은 인터넷으로 진행한다.

정식 사용자가 확인되지 않은 프로그램은 일단 평가판 모드로 동작한다. 정품 인증이 안 됐고 평가판 기간도 경과했다면 그 다음부터는 기능 제한 모드로 동작한다. 문서나 데이터를 취급하는 업무용 프로그램이라면 이제 문서를 편집할 수 없고 일종의 viewer 형태로만 동작하게 된다.

과거에는 소프트웨어 개발사들이 사용자에게 자기 제품의 기능을 자랑하는 한편으로 정품 구매에 대한 동기를 부여하기 위해 셰어웨어, 평가판, 데모 같은 것을 따로 배포하곤 했다.
하지만 지금은 인터넷이 워낙 발달한 덕분에.. subset 구분 없이 전체 제품을 통째로 뿌린다. 그 뒤 제품을 구입하고 해금 비밀번호/일련번호를 받은 사용자에게만 전체 기능이 제공되도록 한다. 아래아한글, MS Office 같은 거대한 프로그램들도 이제는 다 이런 식으로 동작하고 있다.

과거에는 그런 일련번호를 수학 공식을 기반으로 생성하곤 했다. 하지만 오프라인 환경에서 소프트웨어적인 알고리즘에만 의존하는 copy-protection은 역공학을 통해 뚫릴 수 있다. 마치 주민 등록 번호 자동 생성기처럼 말이다. 어둠의 경로를 개척하는 사람들이 이만저만 똘똘한 게 아니기 때문이다.

하지만 인터넷이 소프트웨어의 배포와 불법 복제만 편하게 만든 건 아니며, 까놓고 말해 개발사가 사용자의 사용 패턴을 미주알고주알 파악하는 것도 더 용이하게 만들었다. (뭐, 서버 유지 비용은 부담해야 하지만..) 제아무리 클라이언트 프로그램을 복제하고 뿌려도 온라인 게임을 돈 안 내고 즐기는 건 불가능하며, 스타크래프트 불법 복제 립버전으로 배틀넷 접속은 할 수 없다. 또한, 주민 등록 번호는 생성하더라도 방대한 DB에 일일이 접속하는 실명 인증은 수학 공식만으로 뚫을 수 없지 않던가? 그런 식으로 창과 방패는 발전하는 것 같다.

물론 업무용 프로그램들은 온라인 게임과 달리 업데이트 체크나 인증을 할 때만 인터넷에 접속하지, 나머지 동작은 전부 어차피 오프라인에서 행해지는 게 대부분이다. 그런 부류라면 실행 파일을 분석해서 인증을 시도하는 부분만 변조하고 크랙해 버릴 수도 있다. 파일이 전부 암호화되지 않고 헤더에만 암호가 기록돼 있다면 그 부분만 건너뛰어 버리면 되듯이 말이다. 그렇기 때문에 오프라인에서의 소프트웨어 보안도 예나 지금이나 여전히 필요하다.

이렇게 소프트웨어의 정품 사용 여부를 파악하기 위해서 반드시 필요한 절차가 있다. 바로 프로그램이 돌아가고 있는 기기 내지 제품의 사용자를 중복 없이 유일하게 식별하는 것이다!
매번 변하는 난수 씨앗이야 그냥 현재 시각을 기반으로 생성한다고 하지만, 한 컴퓨터에만 고유하게 적용되는 시리얼 키 같은 걸 생성하려면 전세계에서 유일하고 불변하고 전무후무한 식별 번호가 하드웨어 차원에서 부여되어 있어야 한다.

하다못해 예전에 도스용 게임 중에서도 저장 기능이 없는 대신, 각 레벨별로 암호 코드가 부여되는 게 있었다. 그 코드값을 알면 나중에 상위 레벨에서부터 게임을 시작할 수 있다.
그런데 문제는 그 코드값이 컴퓨터마다 제각각으로 생성된다는 것.. 그러니 매 컴퓨터에서 게임을 새로 시작해서 상위 레벨에 직접 진입을 해야만 코드값을 알 수 있었다.

그리고 어떤 소프트웨어가 정품 인증을 받았다거나, 셰어웨어의 경우 등록판이 생성되었는데.. 그 인증 정보는 레지스트리나 파일 형태로 저장되곤 한다. 물론 꼼꼼하게 암호화해서 말이다.
그 인증 정보에는 당연히 특정 컴퓨터의 식별자도 들어있어야 할 것이다. 그래야 그 인증 정보만 다른 컴퓨터에다가 슬쩍 복사해서 집어넣더라도 명의가 도용되지 않을 테니 말이다.
이런 식으로 컴퓨터의 고유 식별자는 프로그램 개발자의 입장에서는 매우 다양하게 활용될 수 있다.

자동차에는 외부에 노출된 번호판과 별개로, 자동차 껍데기 자체를 식별하는 차대 번호라는 게 있어서 엔진룸이나 도어 한구석에서 확인할 수 있다.
노트북 PC는 시리얼 번호가 밑바닥에 적혀 있다. 그리고 스마트폰도 기기를 식별하는 고유 번호가 있는 건 마찬가지이다. 사용자를 식별하는 USIM과는 당연히 별개로 말이다.

그런데 PC는 컴퓨터를 유일하게 식별하는 깔끔한 단일 통합 메커니즘이 의외로 존재하지 않는다.
먼 옛날에 펜티엄 3~4 시절에는 CPU의 일련번호를 얻어 오는 명령이 있었던 듯하나.. 예제 코드가 어셈블리어여서 이식성이 전혀 없으며, 요즘 CPU에서는 통하지도 않는다고 한다.

컴퓨터를 식별하기 위해서 지금까지 제일 만만하게 쓰여 온 방법은 맥 어드레스(mac address)라는 48비트짜리 숫자이다. 요즘은 휴대전화 번호가 사람을 식별하는 준 주민 등록 번호나 마찬가지이지 않는가? 그것처럼 통신망에서의 주소는 기기 식별 용도로 나쁘지 않은 선택이다.
하지만 얘를 쓰기에는 요즘 컴퓨터 네트워크는 계층과 종류가 너무 다양해졌으며, 사용자가 값을 변조도 그리 어렵지 않게 할 수 있기 때문에 여러 모로 약발이 다했다.

하드웨어적인 방법에만 너무 의존하면.. 사용자가 램을 더 달거나 하드디스크를 교체한 것만으로 프로그램 정품 인증이 실패하는 불상사가 벌어진다. 도대체 한 컴퓨터의 정체성을 결정하는 것이 무엇인가 하는 본질적인 고민에 부딪히게 된다.
소프트웨어적인 방법으로는 HKEY_LOCAL_MACHINE 상에 있는 Windows의 product ID라든가 Machine GUID가 있는데.. 이것은 변조하기 쉽고 운영체제를 다시 설치하는 것만으로도 무력화될 수 있는 게 약점이다.

최근엔 본인도 이런 쪽으로 고민할 일이 좀 있었다. 그러다가 WMI(Windows Management Instrumentation)라는 DB인지 API인지.. 정체를 알 수 없는 방대한 물건을 최근에야 난생 처음으로 접했다. 여기에 시스템 정보와 관련된 것들이 다 있었다. 하드디스크의 시리얼 번호, 마더보드의 시리얼 번호, 뭐 별별 것까지 다.. 그야말로 끝판왕이었다.

그 중 Win32_ComputerSystemProduct라는 클래스에 있는 uuid 값이.. SMBIOS, 즉 펌웨어 레벨에서 새겨져 있는 불변 유일한 컴퓨터 식별자 역할을 얼추 하겠다는 결론을 내리게 됐다. 최소한 맥 어드레스보다는 더 믿을 만하지 않을까? 명령 프롬프트에서는 wmic csproduct get uuid라고 하면 얻을 수 있다.

WMI에 접근하는 건 C#에서는 꽤 간단하고 쉽게 할 수 있어 보이던데 C++에서는 COM을 초기화하고 온갖 복잡한 인터페이스를 몇 단계씩 생성해야만 가능했다. DB 아니랄까봐, COM으로도 모자라서 팔자에 없는 SQL 쿼리까지 날려야 되더라! SELECT * from Win32_ComputerSystemProduct 같은 식으로 말이다.
누가 클래스 라이브러리 하나 만들어 놓은 것조차 없는 듯... 저 GUID 하나만 달랑 얻어 오는 용도로 쓰기에는 낭비가 꽤 심해 보인다.

Windows와 달리 mac 계열은 하드웨어/소프트웨어가 워낙 딱딱 들어맞는 일체형이니 저 정도의 복잡한 고민은 필요 없을 듯하다. 시스템 정보를 보면 나오는 시리얼 번호와 하드웨어 UUID만으로 식별과 관련된 모든 고민이 끝이지 않을까?
게다가 gethostuuid라는 함수 한 방으로 그 값을 바로 구할 수 있었다.

이미 유비쿼터스니 사물 인터넷 IoT니 뭐니 하면서 운영체제 불문하고 수많은 기기들이 인터넷에 접속하고 있으며, 그에 맞춰서 주소 공간이 월등히 넓어진 ipv6도 서서히 보급되고 있다. ipv6는 주소 공간의 크기가 UUID의 그것과 동일한 128비트이다.

그러니 Windows/mac, 그리고 안드로이드/iOS를 불문하고 전세계 전무후무 유일불변이 보장되는 기기 식별자 같은 것도 제정되지 않을까 싶다. 이미 제정돼 있는데 본인이 아직 모르는 것일 수도 있겠지만, PC 한정으로는 내가 알기로 딱 떨어지는 답은 아직까지 없다. 심증에 속하는 여러 정황상의 단서들을 모아서 물증인 것처럼 편의상 활용하고 있을 뿐이다.

Posted by 사무엘

2019/04/06 08:35 2019/04/06 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1605

* 2014년에 썼던 글을 보완하여 다시 올린다.

옛날에 도스 시절에는 일명 '외부 명령'이라 하여 별도의 프로그램 형태로 존재하는 명령들이 있었다. format.com, diskcopy.exe 같은 것들.
이것들은 자기가 소속된 도스 버전을 가려서 동작했다. 가령, MS 도스 5.0이 설치된 컴퓨터에다 도스 6.x에 존재하는 새로운 유틸리티를 복사해 와서 실행하면, 실행에 필요한 파일들이 다 있다 하더라도 '도스 버전이 다릅니다'라는 에러 메시지와 함께 프로그램이 그냥 실행되지 않았다. 이것은 운영체제의 버전을 가려 가며 실행하는 프로그램을 본인이 난생 처음으로 접한 사례였다.

Windows에도 자신의 버전을 알려 주는 API가 응당 존재한다. 하지만 이건 지금 구동 중인 운영체제가 무엇인지를 알려 주는 편의 기능을 구현할 때나 사용할 만한 기능이다. 일반적인 프로그램이라면 About 대화상자 같은 데서 말이다.
만약 프로그램이 운영체제의 버전을 가려 가며 실행해야 한다면, 단순히 운영체제의 버전을 갖고 판단하는 건 썩 좋은 방법이 아니다. 내가 실제로 사용하고자 하는 기능을 요청해 보고(CoCreateInstance, LoadLibrary/GetProcAddress 등), 그 요청의 성공 여부에 따라 실행 여부를 결정하는 게 바람직하다.

뭐, 지금은 아무 의미가 없는 예가 돼 버렸다만,
가령 내 프로그램이 유니코드 API를 사용하기 때문에 Windows 9x에서는 실행을 거부해야 한다고 치자.
그렇다면 CreateWindowExW건 RegisterClassW건 유니코드 API를 실제로 호출해 본 뒤, 그게 실패하고 GetLastError()==ERROR_CALL_NOT_IMPLEMENTED가 돌아올 때 실행을 거부하면 된다. 운영체제의 외형보다는 그 운영체제의 실제 실행 결과를 보고 판단하는 게 낫다는 게 바로 이런 의미이다.

그런 것도 다 필요 없고 운영체제의 버전 숫자를 정말로 정확하게 알아 와야 한다면,
그 경우를 위해 태초에 GetVersion()이라는 간단한 함수가 있었다. 얘는 버전과 관련된 여러가지 정보들을 비트 자릿수별로 묶은 32비트 정수를 되돌렸다.

그 정보의 의미를 C언어의 비트필드 구조체로 나타내 보면 대충 다음과 같다. 주석으로 표시된 숫자는 윈도 7 기준으로 반환되는 값들이다.
(최신 Windows 10 기준의 반환값을 소개하지 않은 이유는 후술하도록 하겠다)

union WINVERSION {
    DWORD dwValue;
    struct {
        UINT nMajorVer: 8; //6
        UINT nMinorVer: 8; //1
        UINT nBuildNumber: 15; //7601
        UINT bWin9xOrWin32s: 1; //0
    };
};

WINVERSION os;
os.dwValue = ::GetVersion();

이 함수는 아무 매개변수도 필요하지 않으며, 리턴값도 DWORD 달랑 하나이니 미치도록 가볍고 사용하기 편하다. Windows 9x와 NT 계열이 공존하던 옛날에, 지금 운영체제가 (1) NT 계열인지를 알고 싶다면 GetVersion()&0x80000000 (최상위 비트)만 하면 OK였다.
그 뒤, NT 3.x인지 4.0인지, 9x 계열의 경우 95인지 98인지 ME인지 같은 건 (2) major와 minor 번호를 보고 판별하면 됐다. (3) 빌드 번호는... 딱히 막 중요한 정보는 아닌 듯하다.

그러나 이 함수는 문제점과 한계도 보였다. 한눈에 봐도 각 비트로부터 의미 있는 정보를 추출하는 게 매우 지저분하고 번거로웠다. HIWORD, LOBYTE 삽질이 싫다면, 저런 비트필드 구조체는 프로그래머가 재량껏 알아서 만들어야 했으며, 응용 프로그램이 이 정보를 잘못 취급하는 경우도 많았다.

비교할 필요가 없는 필드까지 다 비교를 해 버리는 바람에, Windows 95 이상에서 모두 동작할 수 있는 프로그램이 Windows 95에서“만” 동작하게 고정돼 버리기도 했다. 혹은 Windows NT 4.0이 NT 3.51보다 낮은 버전으로 취급되는 촌극도 벌어졌다. (리틀 엔디언 기준으로 저 구조체를 보면, minor 버전이 major 버전보다 더 높은 자릿수에 놓여 있음)

더구나 운영체제의 정체성을 나타내는 정보는 단순히 버전 번호와 빌드 번호 이상으로 더욱 복잡해져 왔다. NT 계열의 경우 당장 서비스 팩이 있고, 이게 무슨 에디션인지도(홈? 서버? 워크스테이션? 등) 알 필요가 있는데 단순히 숫자 하나만 달랑 되돌리는 함수로는 그런 걸 알려 줄 수가 없었다.

이런 문제를 해결하기 위해 Windows 95 내지 NT 3.5에서는 OSVERSIONINFO라는 구조체를 인자로 받는 GetVersionEx라는 함수가 추가되었다. major, minor 버전 번호와 빌드 번호, 운영체제 계열이 모두 독립된 구조체 멤버로 독립하였으며, (4) 서비스 팩 내역도 완전한 문자열 형태로 되돌려 주니 버전 정보를 다루기가 편해졌다.

이 구조체는 맨 앞에 자신의 크기를 써 주게 돼 있으며, 덕분에 추후 확장이 가능한 형태이다.
Windows 2000부터는 OSVERSIONINFOEX 구조체가 추가됐다. 확장된 구조체는 서비스 팩의 번호조차도 major와 minor 꼴로 받을 수 있으며, (5) 같은 NT 계열 중에서도 클라이언트 라인과 서버 라인을 구분할 수 있다(wProductType==VER_NT_WORKSTATION / VER_NT_SERVER). Windows XP와 Server 2003은 버전 번호가 5.1과 5.2로 서로 달랐지만, 후대 버전부터는 버전 번호는 동일하고 이걸로 구분을 해야 한다. (Vista / Server 2008, 10 / Server 2016 같은..)

그리고 클라이언트 라인은 XP 이래로 오늘날의 10까지 (6) home과 pro 에디션 구분이 거의 관행이 돼 있는데.. 이건 wSuiteMask 멤버의 비트 플래그 VER_SUITE_PERSONAL (0x200)의 존재 여부로 판별 가능하다. 저 플래그가 존재하는 게 home 에디션이다.
VER_SUITE_* 다른 플래그들 중에는 Windows XP의 embedded 에디션, enterprise 에디션 같은 걸 나타내는 것들도 있으니 참고하면 된다.

요컨대 9x/NT 이후로도 클라이언트/서버, home/pro 같은 복잡한 구분이 계속 이어지는 것을 알 수 있다. 그래도 GetVersionEx 한 방이면 모든 정보를 얻을 수 있다.

이걸로 모든 이야기가 끝이 났으면 좋겠지만.. 아이고, 끝이 아니다. GetVersionEx 함수는 2010년대 이후로 마소의 정책상 사용이 더 권장되지 않는 deprecate 판정을 받고, 시간이 정지해 버렸다.
이 함수는 아무런 단서가 없는 환경에서는 Windows 8, 즉 버전 6.2보다 더 높은 값을 되돌리지 않는 샌드박스가 되었다. 실제로는 이 컴퓨터에 Windows 8.1이나 10이 돌아가고 있더라도 말이다. 이와 관련된 더 자세한 정보를 원한다면 다음 URL을 참고하시기 바란다.

이제 이 함수는 응용 프로그램에게 그 응용 프로그램보다 나중에 출시된 운영체제에 대한 정보는 주지 않기로 작정한 듯하다. GetVersionEx가 샌드박스 없이 실제 자기 버전을 되돌리는 조건은 다음과 같다.

  • 응용 프로그램의 manifest XML에(compatibility-application-supportedOS) 그 운영체제의 GUID가 등록되어 있다.
  • 혹은 응용 프로그램의 PE 헤더에 OS의 최소 요구 버전이 최신 운영체제의 버전으로 맞춰져 있다. Windows 8.1의 경우 6.3, Windows 10이라면 10.0이 되겠다.

운영체제와 함께 제공되는 메모장 같은 기본 프로그램들은 후자의 조치를 취한 상태이다. 이렇게 빌드된 프로그램에서는 GetVersionEx가 해당 버전을 정확하게 되돌린다. 하지만 이런 프로그램은 이전 버전 운영체제에서는 아예 전혀 동작하지 않으므로, 3rd-party 응용 프로그램이라면 이런 방법을 쓰기 곤란하다. 그러니 매니페스트 등록을 해야 한다.

물론 마소에서 2015년의 Windows 10부터는 기존 버전 번호 자체를 10.0으로 동결시켜 버리고 더 바꾸지 않기로 작정했다. 그러니 버전 번호 변경으로 인해 GUID를 또 등록하는 식의 혼란은 앞으로 더 없을 것이다.

운영체제의 버전의 절대값을 되돌리는 GetVersionEx 대신 마소에서 사용을 권장하는 함수는... 지금 운영체제의 버전이 응용 프로그램이 제시하는 버전보다 상대적으로 높은지 안 높은지 여부만을 되돌리는 VerifyVersionInfo 함수이다. 그리고 이걸 기반으로 IsWindows10OrGreater 같은 helper 함수들도 만들어져 있다. (VersionHelpers.h)

하지만 이 함수들도 내부적으로 GetVersionEx의 결과값을 기반으로 비교를 하는 것이기 때문에 앞서 언급한 샌드박스의 제약을 받는 건 마찬가지이다.

샌드박스 없이 운영체제의 정확한 버전을 얻어 오는 함수는 크게 두 군데에 있다.
먼저, 의외로 네트워크 API이다. 그렇다고 소켓 API 같은 건 아니고, Windows에서 독자적으로 제공하는 함수 중에 내 로컬 컴퓨터를 포함하여 원격 컴퓨터에 설치된 운영체제의 버전을 얻어 오는 함수가 있다. 대략 다음과 같이 코드를 작성하면 된다.

#include <LM.h>
#pragma comment(lib, "netapi32")

WKSTA_INFO_100 *p;
::NetWkstaGetInfo(NULL, 100, (LPBYTE *)&p);
printf("%d, %d\n", p->wki100_ver_major, p->wki100_ver_minor); //10, 0
::NetApiBufferFree(p);

저기 100은 수효를 나타내는 게 아니며 각각의 숫자들이 별개의 의미를 지님에도 불구하고, 상수 명칭이 존재하지 않아서 그냥 생으로 100을 넘겨 줘야 한다.
운영체제 버전 하나 좀 얻자고 웬 생뚱맞은 분야의 API를 써야 하는 것도 삽질스럽지만.. 저 함수를 통해서는 그냥 major와 minor 버전 번호만 얻을 수 있다. 서비스 팩이나 빌드 번호 같은 세부 정보는 얻을 수 없다.

저거 말고 다른 대안으로는.. ntdll.dll에 있는 native API인 RtlGetVersion을 써도 된다.
OSVERSIONINFO(EX)의 포인터를 받아들이고 정수값을 리턴하므로 prototype이 기존 GetVersionEx와 거의 동일하다.
단, native API 버전은 성공한 경우의 리턴값이 0이다. 리턴 타입이 BOOL이 아닌 셈이다.

얘는 Windows 8.1 내지 10 같은 요즘 운영체제에서는 잘 동작하는데, 과거의 Windows 2000에서는 GetVersionEx와 달리 서비스 팩 정보를 되돌리지 않았던 것으로 기억한다. 구형 OS에서는 오히려 기존 함수를 쓰는 게 더 낫다. 거 참..;;
Windows가 지난 20년 동안 운영체제의 버전과 제품 종류를 얻는 그 단순한 절차만 해도 얼마나 복잡하고 지저분해져 왔는지를 확인할 수 있다. 관련 여담을 몇 가지 더 남기는 것으로 글을 맺고자 한다.

  • OSVERSIONINFOEX는 C++ 상속 문법 같은 걸 이용해서 선언된 게 아닌 관계로, OSVERSIONINFO와는 언어 차원에서 아무런 연결 고리가 없다. GetVersionEx에다가 전달할 때는 OSVERSIONINFO*로 reinterpret_cast를 해 줘야 된다.
  • 과거 Windows XP에는 media center 에디션 내지 태블릿 PC 에디션 같은 바리에이션이 있었는데.. 이거 여부를 얻는 건 GetVersionEx가 아니라 GetSystemMetric라는 다소 생뚱맞은 함수에 있었다. SM_MEDIACENTER, SM_TABLETPC처럼 말이다 .
  • 끝으로, Windows 10부터는 (7) 릴리스 연-월을 나타내는 4자리 숫자가 사실상 버전 번호가 됐으니 이걸 표시해 줘야 할 것이다. 그런데 이건.. 본인이 아는 방법은 그냥 무식한 레지스트리 조회가 유일하며, 공식적인 API가 따로 있지 않다.;;;

Posted by 사무엘

2019/03/14 08:36 2019/03/14 08:36
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1596

« Previous : 1 : ... 3 : 4 : 5 : 6 : 7 : 8 : 9 : 10 : 11 : ... 31 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2024/04   »
  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:
2677040
Today:
1608
Yesterday:
2124