2021년이 되니 마소 진영으로부터 신선한 소프트웨어 소식이 전해지는 게 좀 있다.
1위는 단연 Windows 11이다.
Windows 10 이후로 주 버전명을 불변으로 고정할 거라더니, 그 정책을 6년 만에 번복하게 됐다. (Windows 10이 처음 나온 게 2015년) 업데이트로 찔끔찔끔 제품을 바꿔 나가는 것에 한계를 느낀 모양이다.

새 버전은 이제 32비트 전용 CPU의 지원을 끊고 64비트로만 나올 예정이다. 이건 뭐.. 서버 제품군에서는 이미 10년도 더 전, Vista인가 7인가 그때부터 32비트의 지원을 끊은 상태이기 때문에 전혀 새삼스러울 게 없는 결정이다. 또한 가정용 개인용 PC도 램 크기가 4GB를 넘어간 지는 이미 10년 이상 전의 일이기는 마찬가지다.

이야 그러면 버전의 명명 방식도 번호(1~3) → 연도(9x, 20xx) → 브랜드명(XP, Vista)이다가 이제 다시 번호로 회귀하는 건가 싶다(7~11). 역시 역사는 돌고 돈다. 7~8 시절에는 커널 버전과 저 번호가 일치하지 않았었는데, 10부터는 커널 버전도 대외적인 버전 번호와 일치하게 됐다.

그리고 운영체제뿐만 아니라 개발툴인 Visual Studio도 말이다. 2019 이후로 3년째 16.9.x까지 마이너 업데이트만 계속하고 있어서 이제 쟤도 메이저 업데이트를 중단했나 싶었는데.. 그렇지는 않다. 2022가 나올 예정이라고 한다.
게다가 2022는 devenv.exe IDE가 드디어 100% 64비트 기반으로 만들어진다. 이것만으로도 메이저 업데이트의 명분은 충분하다고 하겠다.

아니 그럼 지금까지는 64비트가 아니었나? 응, 의외이지만 아니었다. xcode라든가 Android Studio 같은 타 개발툴과는 상황이 다르다.
마소의 제품 중에서도 운영체제인 Windows는 XP/Vista 때 이미 x64 에디션이 나왔고 Office도 10년도 더 전의 2010부터 x64 에디션이 나왔던 반면.. 정작 개발툴 IDE는 기술적인 난관 때문인지 64비트 포팅이 굉장히 늦었다.

물론 컴파일러야 x64 타겟은 네이티브와 32-64 크로스 모두 당연히 진작부터 제공됐다. 하지만 Visual Studio IDE 자체는 여전히 32비트 바이너리였다. 그렇기 때문에 수만 개의 소스 파일들로 구성된 방대한 프로젝트를 열고 소스 코드의 인텔리센스 데이터를 관리하는 것엔 아무래도 한계가 있었다.

그래도 신기한 건 이 32비트 IDE로도 64비트 바이너리의 디버깅까지 32비트의 것과 아무 차이 없이 자연스럽게 할 수 있었다는 점이다. 원래 32비트 프로세스는 64비트 프로세스 주소 공간을 들여다보거나 훅킹 코드를 주입할 수 없다는 걸 생각하면 굉장히 신기한 일이다. Visual Studio IDE가 디버깅을 위한 64비트 호스트 프로그램을 별도로 구동하고, 얘가 32비트 IDE와 IPC(프로세스 간 통신)을 굉장히 정교하게 잘 했던 것으로 보인다.

이렇게 Visual Studio가 32비트 IDE로나마 64비트 개발과 디버깅을 정식으로 지원하기 시작한 건 무려 2005 버전부터였다.
그로부터 17년이나 뒤에야 IDE가 정식으로 64비트 기반으로 만들어지니.. 이때부터는 64비트 바이너리를 저런 별도의 디버깅 호스트 없이 IDE에서 직통으로 디버깅을 할 수 있을 것이다. (이젠 반대로 32비트 프로세스를 디버깅 할 때 디버깅 호스트를 따로 마련해야 할 듯) 취급 가능한 프로젝트의 규모가 64비트에 걸맞게 엄청 커지는 건 덤이고 말이다.

Visual C++에서 생성되는 Windows 프로젝트의 기본 configuration이 ANSI (1바이트 문자 집합) 대신 유니코드로 바뀐 첫 버전도 내 기억으로 2005이지 싶다. TCHAR이 char에서 wchar_t로 바뀌었듯, 프로그램들도 하나 둘 64비트로 포팅되면서 순수 32비트 프로그램은 갈수록 보기 어려워지는 게 느껴진다.

하긴, 과거 레거시의 압박이 훨씬 덜한 안드로이드나 iOS 같은 모바일 진영은 과거에 연연할 게 없으니 진작에 64비트로 다 갈아탔다.
요즘은 경전철이라고 해서 협궤를 쓰는 게 아니듯, 쬐끄만 스마트폰용 CPU조차도 다 64비트이다. 안드로이드와 iOS 모두, 32비트 앱의 지원은 PC보다도 더 일찍 진작에 다 끊었다.

Posted by 사무엘

2021/08/02 08:33 2021/08/02 08:33
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1916

1. 경고와 에러

C/C++ 컴파일러가 소스 코드를 컴파일 하는 중에 내뱉을 수 있는 메시지는 흔히 에러 또는 경고라는 두 종류로 나뉜다. 그런데 이걸 더 세분화하면 에러의 앞에는 수위가 더 높은 ‘심각한 에러’(fatal error)라는 게 있다.

얘는 컴파일 중에 컴파일러 자체가 뻗거나 메모리가 부족할 때처럼, 외부 요인에 의해 컴파일이 더 진행될 수 없을 때 나는 편이다. 그런 게 아니면 소스 코드가 문법상으로는 이상이 없지만 각종 수식이나 명칭 선언이 괄호가 너무 깊게 들어가고 복잡할 때, 리터럴 데이터 같은 게 너무 많아서 도저히 감당이 안 될 때, #include 깊이나 #define 치환 단계가 너무 깊을 때..

한 마디로 컴파일러의 한계 때문에 코드 생성이 안 되는 것이 심각한 에러로 분류되는 편이다. 이건 통상적인 컴파일 에러와는 성격이 다르기 때문이다. 참, #include 파일을 아예 찾을 수 없는 것, 그리고 #error로 대놓고 에러를 발생시킨 것 역시 추가적으로 심각한 에러의 범주에 든다.

일례로, int a = 999999999999999999999999; 이런 거야 상수가 너무 커서(32비트 범위 초과) 토큰의 스캐닝 단계에서 튕겼기 때문에 일반 컴파일 에러이다.
하지만 int tbl[] = { 10,45,34,33, ... }; 다음에 숫자가 한 100만 개쯤 있다거나,
char msg[] = "......" \ 이런 리터럴이 100MB쯤 이어져서 컴파일이 실패하는 것은 심각한 에러가 되는 셈이다.

그리고 괄호들이 닫히지 않은 채로 구문을 종료하는 세미콜론이 나오면 일반 에러이지만.. 그 상태로 파일 내용이 끝나 버리면 보통 심각한 에러로 간주된다.

에러 말고 경고는.. 컴파일러들이 경고를 이미 여러 단계로 분류해 놓은 편이다. 가령, 초기화되지 않은 변수를 사용하는 것은 다소 심각한 수위의 경고이지만.. 선언만 해 놓고 사용하지 않은 변수는 상대적으로 덜 심각한 경고이다.

또한 요즘은 정적 분석기가 함수 인자의 annotation까지 참조해서 미주알고주알 지적해 주는 잠재적 오류 가능성도 경고의 연장선이라고 볼 수 있다. "null 포인터를 참조할 가능성이 있다, 버퍼 오버런이 발생할 수도 있다" 따위 말이다.
요즘 세상에 코딩을 글쓰기에다 비유하자면 컴파일· 빌드는 인쇄· 배포에 대응하고, 정적 분석은 맞춤법 검사기와 비슷해 보인다.

2. 빌드 툴들이 말귀를 도무지 못 알아들을 때 확인해 볼 사항

  • 그 소스 파일이 프로젝트에 포함돼 있긴 한가? 포함돼 있더라도 혹시 exclude from build 이런 낚시 옵션에 걸려 있지 않은가?
  • 문제의 구간이 #if 조건을 만족하는 구간에 속해 있는가?
  • 명칭이 이상한 매크로 때문에 다른 엉뚱한 형태로 치환되고 있지는 않은가? (주로 C)
  • C++의 경우, 복잡한 namespace나 using 으로 인한 문맥 차이가 존재하지 않는가?
  • 링크 에러의 경우, extern "C"로 인한 name mangling 방식 차이가 존재하지 않는가?

3. 빌드 속도

특별히 다른 부하가 걸린 게 없는 멀쩡한 개발자용 평균 사양의 2~3GHz급 컴터에서.. 2000줄 이하의 평범한 복잡도의 C++ 소스 파일이.. (IOCCC 입상작급 기괴한 난독화, 다단계 템플릿, namespace, #define 떡칠, 다중 다단계 상속 등의 남발.. 이 아닌 "평범한". -_-)
그것도 네트웍도 아닌 로컬 환경에서, 더구나 딱히 빡세게 최적화를 걸지도 않은 디버그 빌드가 컴파일하는 데 개당 0.8초 이상씩 걸리는 건.. 본인은 납득하기 어려운 상황이라고 간주한다. 소스 코드의 #include 구조 및 빌드 시스템에 문제가 있을 가능성이 높다.

당장 precompiled header가 제대로 적용돼 있는지, 덩치 큰 라이브러리 헤더의 연쇄 인클루드와 파싱이 무식하게 매번 반복되고 있지 않은지부터 확인해야 한다. 저건 컴터한테나 인간에게나 좋지 않은 상황이다.
DB 테이블로 치면 primary key 지정이나 인덱싱과 비슷한 최적화가 필요하다.

4. 안드로이드 앱용 JNI 라이브러리의 빌드

(1) 안드로이드 앱을 개발할 때, 겉에서 돌아가는 java 내지 kotlin 코드 기반의 프로그램이야 로컬 환경에서 Android Studio로 간편하게 빌드할 수 있다. 이 IDE는 Windows용과 mac용이 모두 깔끔하게 존재한다.
하지만 이 앱이 내부적으로 사용하는 native code 라이브러리들의 빌드 환경은 내 경험상 mac이건 리눅스건 여전히 유닉스 기반 터미널에 의존하고 있다. Windows에서 바로는 안 된다는 게 특이한 점이다.;; JNI 쪽 빌드도 IDE와 연계해서 같이 되게 할 수는 없는지..

(2) 디버깅도 앱은 breakpoint와 step in/out/over, 지역변수 값 확인, call stack 같은 통상적인 방법론이 IDE 차원에서 모두 지원되는 반면, 그 아래의 라이브러리는 그렇게 할 수 없다. 그런 내부 동작은 로그 printf 신공에 의존해서 추적하는 수밖에 없으니 몹시 불편하다.

(3) 그래도 이런 라이브러리들은 빌드 시스템이 멀티코어/멀티스레드 환경과 굉장히 잘 연계하는 편이다. 그래서 고성능 빌드 서버에서 make -j8 , -j16 이런 식으로 코어 수를 늘려 주면 빌드 속도가 정말 눈에 띄게 매우 빨라진다.
그런데 이 시설에도 이 기능에도 매우 아쉬운 점이 있는데... 코어 수가 늘어나면 빌드 에러 메시지도 진짜 정신없게 중구난방으로 튀어나와서 확인이 어려워진다는 것이다.

Visual Studio처럼 메시지의 앞에 코어 내지 프로젝트의 번호라도 좀 찍어 주면 읽기가 좀 더 수월할 텐데 말이다.
그리고 터미널 접속 프로그램의 본좌인 putty에는 특정 단어나 문자열이 등장했을 때 highlight를 시켜 주는 간단한 기능이 좀 있었으면 좋겠다.

putty는 20년이 넘게 0.x대의 버전 번호를 고수하고 있고, 유니코드(W)가 아닌 ANSI API를 사용하는 게 이색적이다.
ANSI API, 0.x 버전, 크로스 플랫폼 공개 소프트웨어라는 점에서는 DOSBOX하고도 무척 비슷하다.

5. 구조체 전방 선언의 부작용(?)

C/C++ 코드에서는 모듈 간의 include 의존도(= coupling)을 낮추기 위해서 자신이 내부적으로 취급하는 구조체는 불완전하게 전방 선언만 명칭만 노출하는 경우가 많다. 외부에서는 전방 선언 구조체의 포인터만 핸들 마냥 갖고 있고, 실제 조작은 실제 내부 구조를 아는 함수의 호출을 통해서를 하는 것이다. 뭐, 이게 C++이 말하는 정보 은닉과도 일맥상통하는 개념이며, 충분히 바람직한 디자인 패턴이다.

하지만 디버깅을 하는 상황이라면 어떨까..??
조작하는 함수로 들어가기 전에, 즉 밖에서 breakpoint를 걸었다. 이때도 이 포인터가 가리키는 구조체 내용을 좀 조회할 수 있었으면 좋겠는데 그게 Visual Studio IDE에서 안 돼서 답답했던 경우가 많다. 그렇다고 구차하게 소스 코드를 고쳐서 디버깅일 때에 한해서 감춰 놓은 내부 구조체 몸체 선언 include를 시키고 싶지도 않다.
특정 상황에 한해서 컴파일 때는 참고하지 않는 다른 소스 코드의 디버깅 정보를 가져오는 기능이 있으면 좋을 것 같다.

6. 나머지

(1) 컴파일러와 링커는 오늘날까지도 환경 변수라는 게 쓰이는 얼마 안 되는 분야이기도 하다. 환경 변수라는 게 명령줄에서 실행 파일을 자동으로 찾는 PATH, 그리고 컴파일러가 사용하는 기본 include 및 라이브러리 디렉터리... 이것 말고는 쓰이는 곳이 정말 드물지 않은가? 자체적인 환경 설정 파일 같은 게 동원될 법도 한데 컴파일러와 링커는 GUI 프로그램이 아니다 보니 좀 더 저수준이면서 실행되는 세션별로 사용자가 값을 더 간단하게 변경할 수도 있는 환경 변수를 대신 선택한 것 같다.

(2) 과거에 도스용 Turbo C/C++ 같은 물건은 굳이 프로젝트 파일을 안 만들어도 소스 하나만 단독으로 달랑 열어서는 곧장 빌드해서 돌려 볼 수 있었다. 그러나 요즘 개발툴들은 단순 텍스트 에디터 이상의 매우 복잡하고 방대한 물건이기 때문에 그렇게 할 수 없다. Hello world! 한 줄짜리 프로그램을 만들더라도 최소한의 프로젝트 세팅은 한 뒤에야 빌드와 디버깅이 가능하다.

(3) 그리고 요즘 개발툴들은 여러 소스 파일들을 한데 묶은 프로젝트로도 모자라서.. 프로젝트도 여러 개를 한데 묶은 '솔루션, workspace'라는 개념으로 운용된다는 것이 주지의 사실이다. 이 정도는 돼야 좀 규모 있는 소프트웨어를 원활히 개발 가능하기 때문이다.

(4) 컴터 프로그램 개발을 하다 보면.. 디버깅 로그가 실시간으로 뜨게 해 놓은 채로 디버기 프로그램을 구동하고 일정 주기로 결과를 확인할 때가 있다.
그런데 이때 프로그램이 출력하는 로그만 넣는 게 아니라, 사용자가 로그에다가 인위로 "=======" 같은 가로줄 같은 걸 즉석에서 추가할 수 있으면 좋겠다는 생각이 든다. 한 프로그램에서 동작 시험을 여러 번 할 때 로그의 영역을 하기 위해서이다.

(5) 앞으로는 "주 메모리에 로드되어 실행된 프로그램 / 하드디스크에 설치돼 있는 프로그램 / 원본 설치 패키지"라는 소프트웨어의 통상적인 3단계 구분이 더 모호해지고 단순화되지 않을까 생각된다.
일단 웹 프로그램은 설치라는 과정이 없는 게 확실하며, 메모리 계층에서 보조 기억장치와 주 메모리의 구분이 모호해지는 것도 이런 추세를 더욱 부채질할 테니 말이다.

Posted by 사무엘

2021/06/26 08:35 2021/06/26 08:35
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1903

1. 비트 연산 관련 버그

프로그래머가 살면서 설마 컴파일러의 버그를 볼 일이 얼마나 될까? 이건 마치 버스· 트럭· 택시 등 운전으로 먹고 사는 기사 아저씨가 잘 가다가 차량의 엔진 결함이나 급발진을 경험하는 것만큼이나 끔찍한 경험일 것이다.

본인은 최적화 옵션을 빡세게 주고 나면 Visual C++ 컴파일러가 비트 연산 쪽으로 유난히도 말귀를 못 알아먹는 현상을 종종 목격했다.
7년쯤 전에 VC++ 2010 기준으로 (1) bit rotate 연산을 <<, >> | 따위로 구현한 게 제대로 동작하지 않는 것을 목격했다. 그 함수만 #pragma를 줘서 최적화를 강제로 꺼야 오류가 발생하지 않았다.

그리고 2019년쯤에는 (2) WORD, BYTE 따위를 비슷한 연산으로 한데 합쳐서 DWORD를 만들려고 했는데.. 이것도 변수 내용을 강제로 로그를 찍으면 문제가 없지만 간단하게 값만 되돌리게 하면 틀린 값이 돌아왔다.
인라인 함수, 매크로 함수, 최적화 강제 해제 등 별별 방법을 써도 소용없어서 결국은 무식하게 memcpy로 값을 오프셋별로 강제 복사해서 문제를 회피해야 했다.

그 뒤, 19.5.x급으로 그 당시로서는 최신 업데이트가 적용됐던 Visual C++ 2019에서 더욱 황당한 일을 겪었다.
내가 하고 싶은 일은 8비트 char 값을 그대로 부호 없는 형태로만 바꿔서.. 즉, -3을 253으로만 바꾼 뒤 다른 산술 연산 처리를 하는 것이었다. 그런데 (3) 컴파일러가 말귀를 못 알아듣고 숫자를 32비트로 취급하면서 앞에 0xFFFFFF00를 제멋대로 붙였다.

숫자는 내가 기대한 것보다 엄청나게 큰 값으로 바뀌었으며, 프로그램은 이 때문에 오프셋 계산을 잘못해서 메모리 오류가 발생했다. 내가 아무리 강제 형변환 연산을 집어넣어 줘도 오류는 없어지지 않았다. 계산값에다가 원래는 할 필요가 없는 &0xFF 필터링을 강제로 하거나, 이 역시 최적화를 꺼야만 오류가 사라졌다. 이런..

이 세 사례는 모두 비트 연산 + 최적화와 관련된 컴파일러의 난독증이라는 공통점이 있었다. 2010으로 32비트 코드를 빌드하던 시절이나, 2019로 64비트 코드를 빌드하던 시절이나 마찬가지이니.. 딱히 버전과 아키텍처를 가리지도 않는 것 같다.

더 자세한 정황을 나열하지 못하는 이유는 이것들이 전부 방대한 회사의 코드를 취급하다가 발생한 일이기 때문이다. 그래서 동일 문제를 재연할 수 있는 최소한의 케이스를 따로 분리할 수가 없다. 그 함수만 텅 빈 프로젝트에다가 떼어내서 돌리면 당연히 문제가 발생하지 않는다.
하지만 동일 코드를 사용하여 macOS, 안드로이드 등 타 플랫폼에서 돌아가는 제품에서는 버그가 발생하지 않으니 이건 일단 Visual C++만의 문제라고 봐야 할 듯하다.

2. UTF-8 지원 여부와 미스터리한 오동작

Windows는 전통적으로 ANSI 인코딩(?) 천국이던 운영체제였다. 그래서 유니코드 자체는 진작부터 지원했지만 UCS-2 내지 UTF-16 같은 별도의 2바이트 단위 인코딩 형태로만 지원하는 것을 선호했다. 1바이트 단위 인코딩인 UTF-8의 형태로 지원하는 것에는 대단히 보수적이고 인색했다.

오죽했으면 Visual C++이 취급하는 리소스 스크립트 *.rc라든가 resource.h의 기본 포맷도 유니코드 기반으로 바뀌긴 했는데.. UTF-8이 아니라 UTF-16으로 바뀌었다. 거 참..

그래도 세월이 흐르니 마소에서도 대세를 거스를 수 없는지라, 명령 프롬프트에서 제한적이나마 65001 UTF-8 코드 페이지를 지원하기 시작했다. Windows 10 19xx 버전부터는 메모장이 기본으로 지정하는 텍스트 저장 인코딩이 UTF-8로 바뀌기도 했다.
심지어 Visual C++ 컴파일러 역시 UTF-8 인코딩의 소스 코드를 인식하기 시작했다. 단...!! 이건 2% 부족한 아쉬운 면모가 좀 있다.

바로.. 파일 앞부분에 BOM이 있을 때만 UTF-8로 인식한다는 것이다. 그렇지 않으면 그냥 ANSI이다.
소스 코드의 인코딩을 강제로 지정하는 옵션이 소스 코드 내부에 #pragma 같은 형태로 좀 있었으면 좋겠지만 그렇지는 않다. #pragma code_page라는 게 있긴 한데, C 문법을 일부 빌려 온 리소스 스크립트에만 쓰인다.
파일 내부 대신, 컴파일러의 옵션으로 /source-charset:utf-8 요런 게 존재하고, 줄여서 그냥 /utf-8이라고만 해도 된다.

생각해 보면 설정이 하나만 있는 것으로 충분하지 않다. 소스 코드 자체는 인코딩이 UTF-8인데 그 안에서 L로 둘러싸이지 않은 "한글"이라는 문자열 리터럴은 KS X 1001로, 즉 길이가 4바이트이고 전체 크기가 5바이트인 문자열을 의도한 것일 수 있다. 그렇게 실제로 의도된 인코딩을 지정하는 옵션은 /execution-charset이라고 따로 있으며, /utf-8은 두 charset을 모두 utf-8로 지정한 것과 같은 효과를 낸다.

그런데 컴파일러는 그렇게 인식시키면 되지만 에디터의 동작에 여전히 함정이 남아 있다.
BOM도 없고 딱히 한글· 한자 같은 문자도 없이 모든 문자열이 간단한 1바이트 숫자· 알파벳 따위로만 구성된 평범한 파일의 경우, Visual Studio IDE는 얘를 기본적으로 ANSI 인코딩 파일로 간주한다. 그 파일에 나중에 한글· 한자가 부주의하게 추가된다면 인코딩이 영락없이 잘못 지정될 수 있다. 이 기본 동작을 고치는 방법이 있는지는 난 아직 모르겠다.

그런데 그렇다고 BOM을 넣어 버리면..?? BOM은 Windows 동네에서나 통용되지, 리눅스 등 타 운영체제에서는 그냥 민폐 덩어리인 문자이다. 소스 파일의 앞에 저런 문자가 떡 있으면 컴파일러가 잘못 먹고 체하는 수가 있다.
그러니 한 소스를 여러 플랫폼에서 공유하는 경우, 모든 코드의 인코딩은 그냥 닥치고 BOM 없는 UTF-8로 통일하는 게 안전하다. 이 문제에 관한 한은 Visual C++이 타 빌드 툴들의 표준 관행에 맞춰 줘야 한다. BOM는 이식성을 저해하기 때문이다.

모종의 이유로 인해 Visual C++에서 소스 코드의 인코딩이 잘못 인식되면 빌드 과정에서 깨진 문자가 있다고 C4819라는 경고가 발생한다. 깨진 문자가 주석 내지 조건부 컴파일에 걸려서 어차피 빌드되지 않는 영역에 있을 때는 저게 딱히 문제될 게 없다. 단지, 문자열 리터럴 내부에 들어있던 한글· 한자가 깨지면 심각한 문제가 될 것이다.

그런데 내 경험상.. 주 번역 단위에 해당하는 소스 파일과, 걔가 인클루드 하는 헤더 파일 간에 인코딩이 다를 때도 상당히 골치 아픈 문제가 발생하곤 했다.
C4819 말고도 C4828이라고 파일의 줄 수가 아닌 오프셋 운운하면서 굉장히 기괴한 경고가 떴다. 최신 컴파일러에서는 이 경고가 삭제되었는지 조회되지도 않더라.

그리고 정말 믿을 수 없지만 컴파일러가 완전히 뜬금없는 에러를 내면서 동작을 멈췄다. 실제로 문법 오류가 전혀 없는 구문에서도 쓸데없는 에러가 발생했으며, 그 소스 파일에 실제로 존재하지 않는 칸 번호를 언급하기도 했다.
이렇게만 말하는 나도 황당하고 읽는 분들도 상황을 받아들이지 못하시겠지만.. 내가 실제로 겪은 상황이 저랬다.

이 역시 회사에서만 겪었기 때문에 정확· 엄밀하게 재연 케이스를 만들지는 못하겠다. 아까 얘기했듯이 (1) /utf-8 옵션을 global하게 준 상태에서 소스와 헤더 파일들의 인코딩이 충돌 난 것, 그리고 아마도 (2) precompiled 헤더를 쓰는 소스와 그렇지 않은 소스가 한 프로젝트 안에서 좀 뒤섞여 있는 것, (3) namespace와 using이 좀 복잡하게 얽혀서 인텔리센스도 오락가락 하는 상황인 것이 다 조금씩 영향을 주지 않았을까 생각된다.

이 난국은 모든 코드의 인코딩을 BOM 없는 UTF-8로 정리하고, 모든 코드에다가 한글로 dummy string을 만들어서 Visual Studio IDE가 파일을 ANSI (cp949) 인코딩으로 잘못 저장하는 일이 없게 조치를 취함으로써 해결되긴 했지만..
그때 그 문제가 왜 발생했으며 그 상황을 어떻게 재연할 수 있는지는 모른 채 미스터리로 남게 되었다.

회사에서는 길지 않은 기간 동안에도 이 정도의 이상한 버그를 몇 차례 경험했는데.. 개인적으로 날개셋 한글 입력기를 20여 년 가까이 만들어 온 동안은 컴파일러의 버그를 경험한 적이 거의 없다는 것이 참 신기하다. IDE야 불필요하게 다운되거나 뻗는 버그를 여럿 경험했지만 컴파일러가 문제를 일으킨 적은 없었다.
모든 코드가 깔끔하게 KS X 1001 레거시 인코딩이고, 회사 코드보다는 규모가 작고 모듈 구조가 깔끔하고, 전부 precompiled 헤더를 사용하기 때문이 아닌가 생각한다.

소스 코드의 인코딩이 UTF-8이 아니거나, UTF-8이더라도 앞에 BOM이 있는 것 자체를 경고로 처리하는 건 너무 과격할까? 그리고 #include에서 경로 지정을 /가 아닌 \로 한 걸 경고로 처리하는 옵션도 있으면 좋겠다. 이런 건 Windows 환경에서나 통용되지 밖에서는 전부 민폐 에러 요인이 되기 때문이다. 본인이 직장의 공동 작업 과정에서 종종 실수했던 적도 있는 사항들이다.

3. 인텔리센스의 오동작

끝으로, 이건 실제로 생성된 exe/dll의 동작과 관계 있는 치명적인 문제는 다행히 아니지만.. Visual C++ IDE가 텍스트 에디터에서 사용하는 인텔리센스도 일부 특이한 상황에서는 말귀를 못 알아듣고 오동작할 때가 있다.

본인이 겪은 경우는 클래스(가령 A)의 선언 내부에 MFC의 DECLARE_DYNAMIC 같은 복잡한 custom 매크로를 넣은 뒤, 곧장 private/public/protected 같은 접근 권한 지정자가 나올 때이다. 그러면 인텔리센스가 그 뒤에 이어지는 멤버 및 내부 enum/class (가령 B) 따위 선언을 파싱을 제대로 못 한다. ClassView를 보면 A의 멤버 목록에 B의 멤버들이 잘못 표시되며, B 선언 이후에 등장하는 A의 진짜 멤버들은 전혀 인식되지 않는다.

ClassView뿐만 아니라 텍스트 에디터에다 불러온 소스 코드에서도 각종 경고와 에러 밑줄이 A의 멤버들이 누락된 것처럼 쭈루룩 뜬다.
그렇기 때문에 A 클래스의 구현부에서는 인텔리센스와 자동 완성, 심벌 위치 조회 같은 기능들을 활용하지 못하면서 코딩을 꽤 불편하게 해야 한다.

이런 초보적인 문제는 Visual C++ 6 ncb 시절에나 보던 게 아니었나? 왜 발생하는지 모르겠다.
최신 업데이트를 적용한 Visual C++ 2019에서도 동일하게 발생한다. 본 컴파일러가 아니라 인텔리센스 컴파일러이니 딱히 특정 Visual C++ 컴파일러 툴킷만의 문제도 아닐 것이다.

뾰족한 해결책은 없고, 인텔리센스를 헷갈리게 하는 그 문제의 매크로를 클래스 선언의 맨 앞이 아니라 맨 뒤로 옮김으로써 문제를 회피할 수 있었다. 흠...

4. 도킹 하다가 뻗음

역시 컴파일러가 아닌 IDE 얘기이고, 옛날 버전에서만 발생하는 문제이기 때문에 지금 큰 의미는 없지만..
Windows 10 19xx대 버전부터인가 Visual Studio 2013 (그리고 아마 2015도)에서 각종 문서 편집 창이나 보조 윈도우(출력, 속성, 디버그 등등)를 어디에든지 도킹을 해서 붙이면 프로그램이 뻗어 버린다.

2010이 언제부턴가 실행될 때 Microsoft.Vsa.tlb 파일이 없다는 에러를 내는 것과 비슷한 현상인 것 같다. 그래도 얘는 정상 실행은 되고 프로그램 사용에 문제가 없는 반면, 저건 창을 내 마음대로 배치할 수 없게 만들고 프로그램이 뻗기까지 하기 때문에 상당히 심각한 문제이다.
저런 단순 UI는 운영체제건 VS건 한번 만들고 나서는 고칠 일이 없는 기능일 것 같은데.. 둘 다 내부적으로 뭘 건드리길래 이런 부작용이 발생하는 걸까..??

하긴, 더 옛날엔 Visual Studio 2005도 Windows Vista에서 실행하려면 sp1에다가 Vista 지원 추가 패치까지 설치해야 겨우 돌릴 수 있었다. 아래아한글 2005와 2007도 Vista 이후의 운영체제에서 실행하려면 업데이트부터 대판 설치해야 했었으니 이런 예가 전혀 없지는 않구나.

어떤 프로그램이 후대의 운영체제에서 단순히 GUI나 외형의 glitch 정도가 발생하는 걸 넘어 아예 뻗고 실행이 안 되는 건.. 대부분 보안 강화 때문이지 싶다. 문서화되지 않고 미래에 얼마든지 달라질 수 있는 특성이나 동작에 의존하게 프로그램이 만들어진 경우야 걔의 잘못이겠지만, 흔한 경우는 아닐 것이다.

Posted by 사무엘

2021/05/19 08:35 2021/05/19 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1889

1. make, build

요즘 소프트웨어라는 건 여러 개의 실행 파일들로 구성되고, 그 각각의 실행 파일들도 수십~수백 개에 달하는 소스 코드들로 구성된다. 이를 빌드하려면 단순 배치 파일이나 스크립트 수준으로는 감당하기 어려울 정도로 많은 옵션과 입력 파일 리스트들을 컴파일러 및 링커에다가 일일이 전해 줘야 한다. 기존 소스 코드들을 빌드하는 시나리오를 짜는 것조차도 일종의 프로그래밍처럼 된다.

그래서 이런 빌드 시나리오를 기술하는 파일을 makefile이라고 하며, 이 시나리오대로 컴파일러와 링커를 호출해서 빌드를 수행해 주는 별도의 유틸리티가 make라는 이름으로 따로 존재한다. 얘는 이전 빌드 때 만들어져 있는 obj 파일과 소스 파일과의 날짜를 비교해서 새로 바뀐 파일만 다시 컴파일 하는 정도의 지능도 갖추고 있다.
그리고 이름이 저렇게 고정 불변이며, 한 디렉터리에 하나씩만 존재하는 것으로 여겨진다. 프로젝트는 디렉터리별로 독립적이므로..

그런데 소스 말고 헤더 파일은? 조금 어렵다. 이게 수정되면 역으로 얘를 인클루드 하는 소스 파일들도 재컴파일이 돼야 하는데, make 유틸이 C/C++ 컴파일러나 전처리기는 아닌지라, 그걸 자동으로 파악하지는 못한다. 이건 makefile 스크립트 내부에서 각 소스별 헤더 파일 의존성을 사람이 수동으로 지정해 줘야 한다. 이를 기술하는 문법이 따로 있다.
이건 매번 풀 빌드 명령을 내리는 것보다 분명 편리하지만 그래도 사람이 의존성을 잘못 지정할 경우 빌드가 꼬일 수 있는 잠재적 위험 요인이다.

이렇듯 C/C++ 공부 좀 해서 본격적인 프로그램을 개발하거나 기존 제품을 유지 보수하려면, 언어 자체 말고도 다른 툴이나 스크립트를 알아야 할 것이 이것저것 생긴다. 이 바닥도 체계가 정말 복잡하기 때문에, 잘 모르는 사람은 말 그대로 소스까지 다 차려 놓은 오픈소스 프로젝트를 멀쩡히 받아 놓고도 빌드를 못 해서 돌려보지 못하곤 한다. 최소한 Visual C++ 솔루션 파일 하나 달랑 열어 놓고 F7만 누르면 바로 짠~ 빌드 되는 물건은 아니기 때문이다.

물론 그런 복잡한 시스템들은 훨씬 더 복잡한 상황을 간편하게 제어하고 관리하고 프로세스를 자동화하기 위해 도입되었겠지만.. 그마저도 초보 입문자에게는 쉬운 개념이 아니다.
Visual Studio 같은 개발툴들이 그런 make 절차를 얼마나 단순화시키고 프로그램 개발을 수월하게 만들어 줬는지 짐작이 된다. 당장 include 의존성을 자동으로 파악하는 것만 해도 말이다.

이런 개발툴 덕분에 프로그래머가 makefile 스크립트를 일일이 건드려야 할 일이 없어졌다. makefile은 해당 개발툴이 읽고 쓰는 프로젝트 파일로 대체됐으며, 얘는 비록 텍스트 포맷이긴 하지만 사람이 수동으로 편집해야 할 일은 거의 없다. 한때는 포맷이 제각각이었는데 요즘은 xcode건 비주얼이건.. 껍데기는 XML 형태인 것이 대세가 됐다. 스크립트라기보다는 설정 데이터 파일에 더 가까워진 셈이다.

Visual C++도 지금 같은 번듯한 IDE가 갖춰진 버전은 적어도 1995년의 4.0이다. 그때의 IDE 이름은 Developer Studio이었다. 이 시절에는 얘도 IDE와 별개로 유닉스 유틸과 비슷한 스타일의 make를 따로 갖추고 있었으며, 프로젝트 파일로부터 make 스크립트를 export해 주는 기능도 갖추고 있었다. 그러나 그 기능은 후대의 버전에서 곧 없어졌다. 명령 프롬프트로 빌드를 하는 건 그냥 IDE 실행 파일의 기능으로 흡수되었다.

2. cmake

유명한 대규모 크로스 플랫폼 오픈소스 프로젝트를 받아 보면 분명 Windows를 지원하고 Visual C++로 빌드도 가능하다고 명시돼 있는데, 그 빌드라는 게 내가 생각하고 이해 가능한 방식으로 행해지는 건 아닌 경우가 있다.
한때 직장에서 이미지 처리와 인식 때문에 OpenCV며 Tesseract며 머신러닝 라이브러리까지 C/C++에서 돌리겠답시고 삽질을 좀 한 적이 있었는데.. 이때 이런 식으로 지금까지 듣도 보도 못했던 프로젝트 구조와 빌드 방식 때문에 식겁을 하곤 했다.

압축을 풀거나 git으로 생성된 저장소를 아무리 들여다봐도 sln, vcxproj 같은 파일은 보이지 않는다. 먼저 MinGW에다 cmake 같은 유닉스 냄새가 풍기는 런타임을 설치해야 한다. 그래서 cmake를 돌리고 나면 자기 혼자 무슨 라이브러리 같은 걸 한참을 받더니 그제서야 디렉터리 한구석에 Visual C++용 솔루션과 프로젝트 파일이 생긴다.

소스를 사용자 자리에서 일일이 빌드해서 쓰는 것도 모자라서 빌드 스크립트 자체도 사용자 자리에서 즉석에서 동적 생성되는 모양이다. 흠..;
그 생성된 솔루션 파일을 Visual C++에서 열어서 빌드를 해 보면.. 비록 컴파일러는 마소 것을 쓰더라도 소스 파일이 선택되고 빌드되는 방식은 절대로 Visual C++ IDE의 통상적인 스타일대로 진행되는 게 아니다.

솔루션/클래스 view에는 아무것도 뜨는 게 없으며, 빌드되는 파일을 열어도 인텔리센스 따위 나오는 게 없다. 이 상태로 Visual C++ IDE에서 곧장 코드를 읽으면서 편집할 수 있지 않다. IDE에서는 그냥 debug/release나 win32/x64 같은 configuration을 변경하고 빌드 명령만 내릴 수 있을 뿐이다.

이런 프로젝트는 Visual Studio도 반드시 거기서 쓰라고 하는 버전만 써야 한다. 가령, 2017을 쓰라고 했으면 IDE까지 꼭 2017을 깔아야 한다. 2019에다가 컴파일러 툴킷만 2017을 설치하는 식으로는 안 통한다. 도대체 프로젝트를 어떻게 꾸며야 이런 빌드 환경이 만들어지는지 나로서는 알 길이 없다.

알고 보니 얘는 프로젝트의 Configuration type이 Utility 내지 Makefile로 잡혀 있었다. Visual C++에서 빌드되는 일반적인 프로젝트라면 저건 EXE, DLL, static library 중 하나로 지정하는 속성인데, 그런 것으로 지정돼 있지 않다.

그렇기 때문에 이 프로젝트에서 Visual Studio IDE는 그냥 명령줄을 실행해 주는 셔틀 역할밖에 안 한다. Visual C++ 컴파일러가 호출되는 것도 IDE가 원래 동작하는 방식으로 호출되는 게 아니다. 세상에 C/C++ 프로젝트를 이런 식으로 만들 수도 있다는 것을 어렴풋이 경험하게 됐다.

요컨대 cmake는 기존 make 툴의 또 상위 계층이며, 얘만으로도 기능이 굉장히 많고 덩치가 큰 프로그램이다. qt가 소스 레벨 차원에서 Windows와 리눅스와 맥을 모두 지원하는 범용 GUI 프레임워크로 유명하다면, cmake는 범용 빌드 시스템 관리자인 셈이다. qt를 기반으로 개발되는 GUI 앱의 프로젝트를 cmake 기반으로 만들면 진짜로 한 소스와 한 프로젝트로 Visual C++과 xcode와.. 음 리눅스용 IDE는 뭔지 모르겠지만 아무튼 진정한 크로스플랫폼 프로그램을 개발하고 관리할 수 있을 것으로 보인다.

맥OS야 요즘은 다 유닉스 스타일의 터미널을 갖추고 있으니 빌드 내지 패키지 관리 툴이 Windows보다는 이질감이 덜하다. 그러나 맥도 리눅스와 완전히 동일하게 호환되는 건 아니라는 건 감안할 필요가 있다.
그나저나 같은 x64 환경이면 GUI 말고 a.out급의 명령 프롬프트 실행 파일은 리눅스와 맥이 바이너리 차원에서 호환되나?? 아마 그렇지는 않지 싶다.

3. Source Insight

Source Insight라고 프로그래밍 및 소프트웨어 개발로 먹고 사는 사람이라면 다들 알 만한 유명한 개발툴이 있다. 단순 텍스트 에디터보다는 코드 구조 분석과 심벌 조회 기능이 훨씬 더 정교하게 갖춰져 있지만, 그렇다고 Visual Studio 같은 급으로 특정 플랫폼용 컴파일러나 디버거와 밀접하게 연결돼 있는 IDE도 아니다. 위상이 둘의 중간쯤에 속하는 독특한 물건이다.

즉, Source Insight는 각종 언어들 컴파일러의 ‘프런트 엔드’ 계층에만 특화돼 있다.
얘가 굉장히 독특한 점이 뭐냐 하면.. 전문 IDE와 달리, 실제 컴파일 결과에 꼭 연연하지 않고 유도리가 있다는 점이다. 그래서 코드에 컴파일 에러가 좀 있더라도 괜찮고, 심지어 #if #else로 갈라지는 부분까지 개의치 않고 특정 심벌이 정의된 부분을 몽땅 한꺼번에 조회 가능하다.

그래서 프로젝트와 configuration이라는 걸 꼭 바이너리를 빌드하는 단위로 만들 필요 없이, 전적으로 사용자가 심벌을 조회하고 코드를 분석하고 싶은 큼직한 단위로 만들 수 있다. 생각해 보니 이게 Source Insight의 강점이다.
Visual Studio나 Android Studio 같은 IDE만 쓰면 되지 이런 게 왜 필요하냐고..?? 응, 필요하고 유용하더라. 틈새시장을 잘 공략한 제품 같다.

그나저나 최근에 회사 업무 때문에 SI 3.5 버전을 쓸 일이 있었는데.. 본인은 또 한 번 굉장히 놀랐다.
2019년 11월에 릴리스 됐다는 프로그램이 알고 보니 구닥다리 노인학대의 종결자인 무려 Visual C++ 6으로 빌드돼 있었기 때문이다.;; ㅠㅠㅠㅠ 실행 파일 헤더에 기록돼 있는 링커 버전, 섹션간의 4KB 단위 패딩(옛날 스타일), 생성돼 있는 기계어 코드의 패턴으로 볼 때 확실하다.

게다가 유니코드 기반도 아니었다. 도움말을 보니 여전히 Windows 9x를 지원한다고 쓰여 있다. 요즘 같은 시대에 레거시 OS 종결자인 프로그램이 날개셋 말고 더 있었구나;;
회사에서만 쓰는 프로그램이어서 많이 다뤄 보지는 못했지만 쟤들도 자기 제품에다가 분명 최신 C++1x 문법을 구현했을 텐데, 그걸 자기들이 제품 코딩을 할 때 좀 써 보고 싶은 생각은 하지 않았을까..?? 피치 못할 사정이 있어서 VC6을 그렇게 오랫동안 써 온 건지 궁금하다.

그나마 2020년에 출시된 SI 4.0에서는 유니코드를 지원하고 많은 변화가 있었다고 한다. 거기서는 자기네 개발툴도 새 버전으로 갈아타지 않았겠나 추측해 본다.

4. Visual C++

그리고 나의 사랑하는 툴인 Visual Studio.. 얘는 2019 이후로 202x이 나오려나 모르겠다. 지난 2년 동안 꾸준히 소규모 업데이트 형태로만 버전업을 거듭한 끝에, 무려 16.9.x 버전에 진입했다.
업데이트가 너무 잦아서 좀 귀찮은 감이 있긴 했지만, IDE 자체의 안정성은 야금야금 눈에 띄게 강화되어 왔다. 그 예를 들면 다음과 같다.

  • 예전에는 컴에 절전/최대 절전을 반복하다 보면 IDE의 글꼴이 내가 변경하기 전의 것으로 되돌아가곤 했는데 그 오동작이 어느 샌가 발생하지 않게 됐다. 상당히 성가신 버그였다.
  • 가끔 대화상자 리소스 편집기를 열 때 IDE가 응답이 멎던 현상이 이제 더는 발생하지 않는다.
  • 또 가끔은 프로젝트 대렉터리 내부에 RCxxxx, *.vc.db-??? 등 임시 쓰레기 파일이 프로젝트를 정상적으로 닫은 뒤에도 지워지지 않고 남아 있었던 것 같은데.. 이제는 그런 문제가 확실히 해결됐다.

예전에도 언급한 적이 있지 싶은데, 난 Visual Studio IDE가 서로 다른 프로세스 인스턴스끼리도 연계가 더 자연스럽게 됐으면 좋겠다.

  • 다른 인스턴스에서 이미 열어 놓은 솔루션을 또 열려고 시도한다면 그냥 그 인스턴스로 이동하기
  • 다른 인스턴스에서 만들어 놓은 문서창끼리도 한 탭으로 묶거나 떼어내기 지원 (크롬 브라우저처럼)

그리고...

  • BOM이 없는 파일의 인코딩, 또는 새 파일을 첫 저장할 때의 기본 인코딩을 utf-8로 인식해 줬으면 좋겠다.
  • 탭이 설정된 대로뿐만 아니라, 주변 파일의 모양을 보고 탭인지 공백 네 칸인지 얼추 분위기를 파악해서 동작하는 기능이 있으면 좋겠다.
  • 프로젝트별로 소스 파일 곳곳에 지정된 책갈피와 breakpoints들의 세트들을 여럿 한꺼번에 저장하고 불러오는 기능이 있으면 좋겠다. 디버그를 위해 실행할 프로그램과 인자도 여러 개 한꺼번에 관리하고 말이다.

끝으로.. Visual C++은 2015부터가 Windows 10과 타임라인을 공유한다. 이때 CRT 라이브러리의 구성 형태가 크게 바뀌었다. vcruntime이 어떻고 ucrtbase가 어떻고.. 그리고 Visual Studio 2015~2019는 재배포 패키지도 한데 통합됐다.

그래서 그런지 요즘은 Visual C++이 설치되어 있지 않아도 시스템 디렉터리를 가 보면 msvcp140, mfc140 같은 DLL은 이미 들어있다.
20여 년 전의 msvcrt와 mfc42 이래로 운영체제의 기본 제공 DLL과 Visual C++의 런타임 DLL이 일치하는 나날이 찾아온 건지 모르겠다.

Posted by 사무엘

2021/04/03 08:34 2021/04/03 08:34
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1872

1. 아이콘 불러오기

창(그 자체 또는 클래스)에다가 아이콘을 지정하기 위해 흔히 LoadIcon 함수가 쓰인다.
얘는 원래 고정된 32*32 크기의 기본 아이콘 하나만을 달랑 가져오는 함수로 출발했다. 허나 Windows 95부터는 글자 크기와 같은 16*16 작은 아이콘이라는 것도 추가됐고, 나중에 XP쯤부터는 24*24, 48*48 같은 다양한 중간 크기가 도입됐다.

거기에다 화면 DPI까지 가변화가 가능하지, 256픽셀 대형 아이콘까지 도입됐지.. 이거 뭐 아이콘이라는 건 이제 도저히 단일 크기 이미지라고 볼 수 없는 물건으로 바뀌었다. 한 아이콘이 다양한 크기와 색상 버전을 가질 수 있다는 점에서 과거의 비트맵 글꼴과 약간 비슷한 위상이 됐다.

한편, 원래 마우스 포인터(cursor)와 아이콘은 기술적인 원천과 본질이 거의 같은 물건이었다. 작은 정사각형 크기의 이미지 비트맵과 마스크 비트맵의 쌍으로 표현된다는 점에서 말이다. 마우스 포인터는 거기에다가 hot spot 위치 정보가 추가됐을 뿐이었다.
그랬는데 마우스 포인터는 애니메이션이라는 바리에이션이 생겼고, 아이콘은 크기 바리에이션이 생겼다고 보면 되겠다. 동일한 특성을 같이 공유하다가 서로 다른 방향으로 기능이 추가된 것이다.

Windows 95에서는 창이나 창 클래스에다가 아이콘을 지정할 때 큰 아이콘과 작은 아이콘을 구분해서 지정할 수 있게 했다. 그래서 WNDCLASS에는 멤버가 하나 더 추가된 Ex버전이 만들어졌다. WM_SETICON 메시지도 아이콘의 대소 종류를 지정하는 부분이 wParam에 추가됐다.

그리고 LoadIcon 함수 자체도.. Ex가 추가된 건 아니고, 비트맵, 아이콘, 포인터까지 다양한 크기를 모두 처리할 수 있는 완벽한 상위 호환 LoadImage에 흡수되었다. 스펙을 보면 알겠지만 기능이 정말 많다.

하지만 내 경험상, 굳이 Ex 버전을 쓰지 않고 WNDCLASS의 hIcon에다가 큰 아이콘만 LoadIcon으로 지정해 주더라도.. 동일한 ID의 아이콘에 큰 아이콘과 작은 아이콘이 모두 있다면 별도의 처리가 없어도 괜찮았다. 프로그램 타이틀 창에 작은 아이콘은 그 별도의 작은 아이콘으로 자동으로 지정되는 듯하다. 큰 아이콘을 흐리멍텅하게 resize한 놈이 지정되는 게 아니라는 뜻이다.

그래서 본인은 지금까지 프로그램을 개발하면서 굳이 WNDCLASSEX와 RegisterClassEx를 사용한 적이 없었다. 큰 아이콘과 작은 아이콘이 ID까지 다른 서로 완전히 다른 아이콘일 때에나 이런 전용 함수가 필요한 듯하다.
단, 윈도우 클래스를 등록하는 상황이 아니라 대화상자 같은 데서 WM_SETICON으로 아이콘을 지정할 때는 큰 아이콘과 작은 아이콘을 LoadImage 함수로 구분해서 일일이 지정해 줘야 했다.

참고로 Windows에서 아이콘이라는 건 메모리 관리 형태가 크게 세 종류로 나뉜다. (1) 메시지박스에서 흔히 볼 수 있는 ! ? i 표지처럼 시스템 공통 공유 아이콘, (2) 응용 프로그램의 아이콘 리소스를 직통으로 가리키기만 하는 공유 아이콘, (3) 그게 아니라 자체 메모리를 할당하여 동적으로 독자적으로 생성된 놈.

(3)만이 나중에 DestroyIcon을 호출해서 제거해 줘야 한다. (2)는 해당 모듈의 생존 주기와 동일하게 관리된다. (1)이야 뭐 언제 어디서나 유비쿼터스이고..
그리고 RegisterClass 계열 함수가 특례를 보장해 주는 건 역시 리소스 기반인 (2) 한정이다.
wndClass.hIcon = LoadIcon(hInst, IDI_MYICON) 이렇게 돼 있던 곳에서 LoadIcon(...)의 결과를 CopyIcon( LoadIcon(...))으로 감싸서 아이콘의 형태를 (3)으로 바꿔 보시라. 그러면 그 프로그램의 제목 표시줄에 표시된 작은 아이콘은 큰 아이콘을 resize한 뭉개진 모양으로 곧장 바뀔 것이다. 이것이 차이점이다.

사실, Visual Studio의 리소스 에디터 상으로는 구분이 잘 되지 않지만, 응용 프로그램 모듈(EXE/DLL)에 저장되는 리소스 차원에서는 단순 아이콘(RT_ICON)과 아이콘 집합(RT_GROUP_ICON)이 서로 구분되어 있다. 후자는 전자의 상위 호환이다. RegisterClass는 이를 감안해서 동작하지만 HICON 자료형이나 LoadIcon 같은 타 함수들은 일반적으로 그렇지 않은 것으로 보인다.

이럴 거면 wndClass.hbrBackground에 (HBRUSH)(COLOR_WINDOW+1)이 있는 것처럼 hIcon에도 (HICON)IDI_MYICON 이런 게 허용되는 게 더 깔끔하겠다는 생각도 든다.

자, 이 정도면 아이콘 지정에 대해서 더 다룰 게 없어야 하겠지만.. 그렇지 않다. LoadImage 함수에 약간의 버그가 있다.
얘는 (1) 시스템 공용 아이콘에 대해서는 요청한 크기에 맞는 버전을 되돌리지 않고 가장 큰 놈 또는, 걔네들 용어로는 캐시에 보관돼 있는 크기의 이미지만을 되돌린다. 즉, 기존 LoadIcon과 다를 바 없이 동작한다.

특정 크기에 해당하는 아이콘을 정확하게 되돌리라고 별도의 함수까지 만들었는데 그건 (2), (3) 계층에 해당하는 custom 아이콘에 대해서만 동작한다. (1)에 대해서는 글쎄, 성능 때문인지 호환성 때문인지 잘못된 동작을 일부러 방치해 버리고는 더 고치지 않는 듯하다.

그렇기 때문에 시스템 공용 아이콘의 16픽셀급 작은 버전을 이 함수로 얻을 수 없다.
Windows Vista부터는 사용자 계정 컨트롤이라는 보안 기능이 추가되어서 관리자 권한을 나타내는 방패 아이콘(IDI_SHIELD)이 추가되었다. 얘도 UI 텍스트와 함께 작은 크기로 그려야 할 텐데.. LoadImage로는 256픽셀짜리 대형 아이콘만 얻을 수 있기 때문에 이걸 16픽셀로 줄여서 그리면 보기가 흉하다.

마소에서는 LoadImage 함수의 버그를 고친 게 아니라 Vista부터 LoadIconMetric이라는 함수를 추가했다.
얘를 사용하면 시스템 공용 아이콘에 대해서도 정확한 크기를 얻을 수 있다.
얘는 아이콘을 언제나 (3)번 형태로 동적 할당해서 되돌리기 때문에 다 사용하고 나서는 DestroyIcon을 해 줘야 한다. 처리하기 간편한 shared, read-only 속성을 포기하고 정확한 동작을 하도록 로직을 바꾼 것 같다.

그 외에 SHGetStockIconInfo라는 함수도 있어서 얘를 사용하면 한 마디로 탐색기에서 쓰이는 각종 디스크 드라이브, 폴더, 돋보기, 네트워크 등의 표준 셸 아이콘을 얻을 수 있다.

2. DrawFocusRect

Windows에서 대화상자를 키보드로 조작하다 보면, 현재 포커스를 받아 있는 각종 버튼(라디오/체크 박스 포함)이라든가 리스트 아이템에 가느다란 점선 테두리가 쳐진 것을 볼 수 있다. 이것은 DrawFocusRect라는 함수를 이용해서 그려진 것이다.

마소에서는 키보드 포커스를 받아 있는 GUI 구성요소에다가는 요 함수를 호출해서 점선으로 테두리를 그려 줄 것을 GUI 디자인 표준으로 명시하고 있다. 뭐, 일반 프로그래머라면 버튼 같은 커스텀 컨트롤을 직접 구현하거나 owner-draw 리스트박스를 만들 때에나 숙지할 만한 개념이다. 다른 요소들을 다 그리고 나서 맨 마지막으로 focus 테두리를 그려 주면 된다.
다만, 에디트 컨트롤은 애초에 깜빡이는 캐럿(caret; cursor)이 포커스에 대한 시각 피드백 역할을 하고 있기 때문에 또 점선 테두리를 그려 줄 필요가 없다.

이 점선은 이미 아시겠지만 xor 연산을 가미한 반전색이다. 원래 색과 반전 색이 교대로 등장하는 아주 단순한 패턴이다.
요즘 세상에 테두리는 그냥 알파 채널을 가미한 옅은 실선으로 그려도 될 것 같지만, 이 분야는 구닥다리 GDI 레거시 API와의 호환 문제도 있어서 그런지 여전히 옛날 그래픽 패러다임이 쓰이고 있다. 이 xor 테두리는 계산량 적고 간편할 뿐만 아니라, 다시 한번 그리라는 명령을 내리면 싹 사라지고 원래 이미지로 돌아온다는 특성도 있어서 더욱 편리하다.

이 테두리는 두께가 오랫동안 1픽셀로 고정되어 있었다. 하지만 1픽셀만으로는 너무 가늘어서 눈에 잘 띄지 않고 시각 장애인의 접근성에 좋지 않다는 의견이 제기되었다. 게다가 모니터의 해상도가 갈수록 올라가고 100%보다 더 높은 확대 배율도 등장하다 보니, 1픽셀 고정 두께의 한계는 더욱 두드러지게 됐다.

이 때문에 Windows XP부터는 제어판 설정에 따라 2픽셀 이상의 focus 테두리도 등장할 수 있게 됐다.
이 조치가 응용 프로그램에서 특별히 문제가 될 일은 거의 없겠지만, DrawFocusRect로 평범한 직사각형을 안 그리고 1~2픽셀 남짓한 두께의 수직선· 수평선을 그려 왔다면 선이 의도했던 대로 그려지지 않을 수도 있다. 같은 영역에 선이 두 번 그려지면서 점선이 없어져 버리기 때문이다.

DrawFocusRect는 기술적으로 사각형 테두리 모양으로 50% 흑백 음영 비트맵을 브러시로 만들어서 PatBlt() 한 것과 완전히 동일하다. raster operation은 PATINVERT (흑백 xor target)이고 말이다. 그러면 원래색 / 반전색이 교대로 등장한다.
xor이 아니라 and라면 과거 Windows 9x/2000의 시스템 종료 대화상자의 배경처럼 "검정 / 원래색"이 교대로 등장하면서 화면이 반쯤 어두워지는 걸 연출할 수 있을 텐데.. 이 래스터 연산 코드는 따로 정의돼 있지 않은 것 같다.

그런데.. Windows의 GDI API에서 흑백 비트맵은 자체적인 색이나 팔레트 따위가 없으며, 현재 DC의 글자색과 배경색이 DC에 select된 비트맵의 색깔로 쓰인다.
그렇기 때문에 DrawFocusRect로 정확하게 반전 점선 테두리를 그리려면 호출 당시에 해당 DC의 글자색과 배경색을 반드시 black & white로 해 줘야 한다. 시스템 색상 따질 것 없이 RGB(0,0,0)과 RGB(255,255,255)로 하드코딩하면 된다.

이렇게 해 주지 않으면 마지막으로 텍스트를 찍던 당시의 글자색 및 배경색이 무엇이냐에 따라서 focus 테두리의 색깔이 정확하게 반전색이 되는 게 아니라 들쭉날쭉 날뛰고 지저분해질 수 있다.
이건 꽤 중요한 사항인데 왜 MSDN 같은 문서에 전혀 소개되어 있지 않았나 모르겠다. 나도 10수 년째 모르고 있다가 요 얼마 전에야 깨달았다.

또한 50% 음영은 굉장히 단순하고 자주 쓰이는 패턴인데.. 브러시나 비트맵을 stock object로 제공을 좀 해 주지, 왜 안 하나 모르겠다. 요즘 같은 트루컬러, 알파채널 이러는 시대보다도 모노크롬, 16색 이러던 옛날에 더 필요했을 텐데 말이다.
CreateCaret 함수로 caret을 생성할 때는 일반적인 비트맵 핸들 대신 특수한 상수를 넣어서 50% 음영 모양을 지정하는 게 있는데.. caret보다는 다른 형태로 쓰이는 경우가 더 많다.

다음은 파란 배경에 대해서 잘못 그려진 테두리(위: 반전색+검정)와, 맞게 그려진 테두리(아래: 반전색+원래색)의 예시이다.

사용자 삽입 이미지

3. 비트맵 윤곽으로부터 region을 곧바로 생성하는 방법의 부재

Windows에서 region은 사각형이 아닌 임의의 비트맵 영역을 scan line들의 집합 형태로 표현하는 자료구조이며, 창을 사각형이 아닌 임의의 모양으로 만드는 데 쓰이는 수단이기도 하다. 이 블로그에서 예전에 한번 집중적으로 다룬 적이 있다. (☞ 예전 글)
Windows에서는 사각형이 아닌 임의의 복잡한 모양의 region을 생성하기 위해서 다각형, 원, 모서리간 둥근 사각형 등 여러 API를 제공하며, 집합 연산 비스무리하게 기존 region과 영역을 합성하는 CombineRgn이라는 함수도 제공한다.

그런데 이것만으로는 여전히 좀 2% 부족한 구석이 있다.
region을 생성할 때 사용되는 원· 다각형 그리기 함수의 결과와, 실제 DC에다 원· 다각형을 그리는 함수의 결과가 픽셀 단위로 100% 정확하게 일치하지 않을 때가 있다. 그래서 딱 정확하게 영역 안에다가 테두리를 깔끔하게 그리는 게 난감하다.

그리고 아예 만화 캐릭터 같은 모양의 창을 만들 때는.. 저렇게 벡터 이미지가 아니라 임의의 마스크 비트맵으로부터 그 윤곽 영역대로 region을 바로 생성할 수 있는 게 좋은데 의외로 그런 함수가 없다.

뭐, region의 내부 자료구조에 접근해서 복잡한 region을 직통으로 생성하는 방법도 없지는 않지만(정말 생짜 직사각형들의 집합..;; ) 이 역시 귀찮다는 건 어쩔 수 없다.
이 때문에 비트맵 그림으로부터 region을 생성하는 코드를 보면.. 비트맵 내용대로 한 줄 한 줄 CombineRgn(RGN_OR)로 한눈에 보기에도 정말 느리고 비효율적인 방법을 쓰고 있다.

layered window의 color key를 쓰면 투명색을 더 편리하게 구현할 수 있긴 하다. 허나, 창 아래의 그림자(CS_DROPSHADOW)는 region을 통해 지정된 경계하고만 정확하게 연계한다. 그렇기 때문에 애니메이션이 아닌 데서는 구닥다리 region도 여전히 필요하다.

이 분야는 다른 그래픽 API 같은 대안이 있는 것도 아닌데 마소에서 GDI API의 지원에 왜 이리 인식한지 모르겠다.;;

Posted by 사무엘

2021/03/28 08:35 2021/03/28 08:35
, ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1870

Windows 환경에서는 프로그램이 자기 화면(창)에다 뭔가를 그리고 표시하는 걸 보통은 WM_PAINT 메시지가 왔을 때 한다.
하지만 반드시 그때만 그림을 그릴 수 있는 건 아니다. 키보드나 마우스 입력(특히 뭔가 드래그)이 들어와서 특정 지점에 대한 시각 피드백만 즉각 주고 싶을 때, 혹은 타이머를 걸어서 일정 시간 주기로 반드시 뭔가를 그리고 싶을 때는 InvalidateRect라든가 WM_PAINT에 의존하지 않고, 프로그램이 직통으로 DC를 얻어 와서 그림을 그려도 된다.

화면 그리기뿐만 아니라 키보드 입력 인식도 마찬가지이다.
반드시 WM_KEYDOWN/UP 메시지를 통해서만 키보드 입력을 인식할 수 있는 건 아니다. 마우스 메시지를 처리 중일 때도 shift나 ctrl 같은 modifier key가 같이 눌렸는지, 혹은 caps/num/scroll lock 램프가 현재 켜져 있는지를 함수 호출 하나로 간편하게 알 수 있다.
그런 modifier 글쇠조차 매번 WM_KEYDOWN/UP때만 감지할 수 있다면.. 응용 프로그램이 지역 변수의 범위를 넘어서는 지저분한 key state 관리자를 둬야 할 것이고, 코딩이 굉장히 번거롭고 불편해질 것이다.

옛날에 도스 시절에 키 입력을 감지하는 건 꽤 번거로웠던 걸로 본인은 기억한다.
문자가 아닌 화살표, home/end, page up/down 같은 글쇠에 대해서는 0번(null) 문자가 prefix 명목으로 오고, 동일 함수를 한번 더 호출해서 실제 값(아마 스캔 코드)을 얻는 형태였다. 그러고 보니 저건 나름 dead key라는 개념이 구현된 셈이다.

그것 말고 ctrl이나 shift, 각종 lamp 글쇠는 저런 방식으로도 잡히지 않았기 때문에 또 다른 도스 API를 동원해야 했다. 요것들은 키보드 버퍼를 차지하지 않고, 컴퓨터가 바쁠 때 아무리 누르고 있어도 삑삑 소리를 발생시키지 않는 조용한 특수글쇠이기 때문이다.;;

글쇠를 누르는 것 말고 떼는 것을 감지하는 것도 본인은 도스 시절에 개인적으로 경험한 적이 없다.
글쇠를 누르고 있는 동안 해당 문자를 일정 간격으로 반복해서 접수해 주는 것은 컴퓨터 하드웨어 차원에서 행해지는 일인데.. 그런 키보드 속도에 구애받지 않고 누른 것과 뗀 것 자체만을 감지하는 건 특히 게임 만들 때의 필수 테크닉이다.
그랬는데 Windows에서는 모든 글쇠들이 한 치의 차별 없이 WM_KEYDOWN과 WM_KEYUP 메시지 앞에서 평등해지고 가상 키코드값을 부여받게 됐다니~! 정말 혁명 그 자체였다. 프로그래밍 패러다임이 싹 바뀌었다.

가상 키코드는 기반이 전적으로 소프트웨어에 있는 계층이기 때문에 같은 하드웨어에서도 차이가 날 수 있다. 가령, 같은 글쇠에다 가상 키코드를 부여하는 방식은 Windows와 mac이 서로 다를 수 있으며, Windows는 사용하는 키보드 드라이버에 따라서도 차이가 날 수 있다.

Windows의 가상 키코드는 caps lock 내지 shift의 영향을 받지는 않기 때문에 a건 A건 코드값이 같다. 하지만 num lock의 영향은 받기 때문에 키패드 0~9 숫자와 키패드 화살표의 코드값이 서로 다르다. 키패드 numlock 숫자는 진짜 숫자키의 숫자와도 가상 키코드가 다르다.
가상 키코드와 달리 스캔 코드는 각각의 물리적인 글쇠들에 고정불변으로 부여되어 있다. 좌우로 두 개 있는 shift처럼 가상 키코드가 동일한 글쇠는 스캔 코드로 방향을 구분할 수 있다.

요컨대 스캔 코드는 저수준이고 가상 키코드는 고수준이다. 여기에다가 문자 글쇠는 message loop에서 TranslateMessage 함수를 거침으로써 caps lock(대소문자)까지 고려한 실제 입력 문자가 담긴 WM_CHAR로 바뀐다.
WM_CHAR가 생성되는 과정(가상 키코드와 스캔 코드로부터 문자를 얻기)이 별도의 함수로 제공되기도 한다. 바로 ToUnicode 내지 ToAscii이다.

배경 설명이 좀 길어졌는데..
현재 어떤 글쇠가 눌러졌는지 여부를 알려주는 대표적인 함수는 GetKeyState이다. 인자로는 가상 키코드를 주면 되고, 리턴값으로는 2비트의 유의미한 정보가 담긴 BYTE값이 돌아온다.
최상위 비트 0x80은 이 key가 지금 눌렸는지의 여부이고, 최하위 비트 1은 눌렸다 뗐다 toggle 여부이다. 3대 lock들의 램프 점등 여부는 &1을 해 보면 알 수 있다.

심지어 GetKeyboardState 함수는 모든 가상 키코드값에 대한 키보드 상태를 배열 형태로 한꺼번에 되돌려 준다.
컴퓨터 키보드의 글쇠는 많아야 100여 개이지만 가상 키코드의 범위는 0~255라는 바이트 규모이므로 가상 키코드를 할당할 공간은 아주 넉넉한 셈이다.

그런데 Windows에는 GetAsyncKeyState라는 함수도 있다. 무엇이 비동기적이라는 얘기이며 GetKeyState와는 어떤 차이가 있는 걸까..?
GetKeyState는 현재 스레드의 메시지/input 큐 기준으로 WM_KEYDOWN/UP 메시지가 마지막으로 처리되었던 그 순간의 키보드 상태를 일관되게 쭉 되돌린다. 한 메시지가 처리되던 도중에 사용자가 어떤 글쇠를 누르거나 떼더라도 값이 변함없다.
한 컴퓨터에 키보드야 하나만 존재하겠지만, 각 응용 프로그램의 UI 스레드별 키보드 상태는 이론적으로 서로 제각각으로 다를 수 있다.

그 반면, GetAsyncKeyState는 그런 것과 상관없이 시스템 전체의 현재 키보드 상태를 실시간으로 반영해서 알려준다. 그리고 이유는 알 수 없지만 GetKey*는 최상위 bit 크기가 BYTE인 반면, GetAsyncKey*는 최상위 bit 크기가 WORD이다.
둘 다 함수의 리턴 타입은 short로 잡혀 있다. 하지만 전자는 눌려 있는 글쇠를 0x80으로 표현하는 반면, 후자는 0x8000으로 표현한다.

그러면 마우스 휠을 Ctrl을 누른 채로 굴렸는지 감지하고 싶을 때 GetKey*와 GetAsyncKey* 중 무엇을 쓰는 게 좋을까?
프로그램이 사용자의 키보드· 마우스 입력에 0.1초 안으로 정상적으로 반응하고 있는 상태라면 두 함수는 유의미한 차이를 보이지 않는다.

GetAsyncKey*는 내 프로그램이 작업을 하느라 수 초 동안 응답이 멎은 중에 사용자가 ESC를 누른 것 정도나 잡아내는 용도로 쓰면 된다. 아니면 애초에 자기 GUI 창이 없는 콘솔 프로그램에서 키 입력을 감지하는 것 말이다. 얘는 심지어 포커스가 다른 프로그램에 가 있을 때에도 특정 글쇠가 눌린 것을 감지할 수 있다.

이와 달리 GetKey*는 메시지 처리 단위로 실행 결과가 '동기화'돼 있으며, 정확하게 자기 스레드의 UI에 포커스가 가 있을 때 글쇠가 눌린 것만 감지해 준다. 그러니 일반적인 상황에서 우리에게 필요한 것은 대체로 GetAsyncKey*가 아니라 그냥 GetKey*이다.

Async가 붙은 놈이건 안 붙은 넘이건, 이들 함수는 글쇠가 눌린 것을 감지만 하지, 그걸 처리한 것으로 퉁쳐 주지는 않는다. 내 작업 루틴에서 ESC가 눌린 것을 감지해서 하던 작업을 중단했다 하더라도 UI에서 WM_KEYDOWN + VK_ESCAPE 메시지가 가는 것은 변함없다.
이럴 거면 GetAsyncKey*를 호출할 게 아니라 Peek/Get/DispatchMessage로 메시지를 정식으로 처리하는 게 더 낫다. GetAsyncKey*는 쓸 일이 더욱 줄어드는 셈이다.

Posted by 사무엘

2021/03/20 08:35 2021/03/20 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1867

C/C++은 성능을 위해 컴파일러나 언어 런타임이 프로그래머의 편의를 위해 뭔가를 몰래 해 주는 것을 극도로 최소화한 언어이다. 꾸밈 없고 정제되지 않은 '날것 상태 raw state'에다가 고급 프로그래밍 언어의 패러다임과 문법만을 얹은 걸 추구하다 보니, '초기화되지 않은 쓰레기값'이라는 게 존재하는 거의 유일한 언어이기도 하다.

같은 프로그램이라도 이제 막 선언된 변수나 할당된 메모리 안에 무슨 값이 들어있을지는 컴퓨터 운영체제의 상태에 따라서 그때 그때 달라진다. 반드시 0일 거라는 보장은 전혀 없다. 오히려 0 초기화는 별도의 CPU 부담이 필요한 인위적인 작업이다. global/static에 속하는 메모리만이 무조건적인 0 초기화가 보장된다.

그렇다고 쓰레기값이라는 게 완벽하게 예측 불가이면서 통계적인 질서를 갖춘 난수인 것도 물론 아니다.
그리고 Visual C++에서 프로그램을 debug 세팅으로 빌드하면 쓰레기값이라는 게 크게 달라진다. C++ 개발자라면 이 사실을 이미 경험적으로 충분히 알고 있을 것이다.

1. 갓 할당된 메모리의 기본 쓰레기값: 0xCC(스택)와 0xCD(힙)

가장 먼저, 스택에 저장되는 지역변수들은 자신을 구성하는 바이트들이 0xCC로 초기화된다. 다시 말해 디버그 빌드에서는 int x,y라고만 쓴 변수는 int x=0xCCCCCCCC, y=0xCCCCCCCC와 얼추 같게 처리된다는 것이다. 디스어셈블리를 보면 의도적으로 0xCC를 대입하는 인스트럭션이 삽입돼 들어간다.

0은 너무 인위적이고 유의미하고 깔끔한(?) 값 그 자체이고, 그 근처에 있는 1, 2나 0xFF도 자주 쓰이는 편이다. 그에 비해 0xCC는 형태가 단순하면서도 현실에서 일부러 쓰일 확률이 매우 낮은 값이다. 그렇기 때문에 여기는 초기화되지 않은 쓰레기 영역이라는 것을 시각적으로 곧장 드러내 준다.

int a[10], x; a[x]=100; 같은 문장도 x가 0으로 깔끔하게 자동 초기화됐다면 그냥 넘어가지만, 기괴한 쓰레기값이라면 곧장 에러를 발생시킬 것이다.
또한, 복잡한 클래스의 생성자에서 값이 대입되어 초기화된 멤버와 그렇지 않은 멤버가 뒤섞여 있을 때, 0xCC 같은 magic number는 탁월한 변별력을 발휘한다.

타겟이 align을 꼼꼼하게 따지는 아키텍처라면, 쓰레기값을 0x99나 0xDD 같은 홀수로 지정하는 것만으로도 초기화되지 않은 포인터 실수를 잡아낼 수 있다. 32비트에서 포인터값의 최상위 비트가 커널/사용자 영역을 분간한다면, 최하위 비트는 align 단위를 분간할 테니 말이다. 뭐 0xCC는 짝수이다만..

0xCC라는 바이트는 x86 플랫폼에서는 int 3, 즉 breakpoint를 걸어서 디버기의 실행을 중단시키는 명령을 나타내기도 한다. 그래서 이 값은 실행 파일의 기계어 코드에서 align을 맞추기 위해 공간만 차지하는 잉여 padding 용도로 위해 듬성듬성 들어가기도 한다.
Visual C++ 6 시절엔 거기에 말 그대로 아무 일도 안 하는 nop를 나타내는 0x90이 쓰였지만 2000년대부터는 디버깅의 관점에서 좀 더 유의미한 작용을 하는 0xCC로 바뀐 듯하다. 정상적인 상황이라면 컴퓨터가 이 구역의 명령을 실행할 일이 없어야 할 테니까..

다만, 힙도 아니고 스택 지역변수의 내용이 데이터가 아닌 코드로 인식되어 실행될 일이란 현실에서 전혀에 가깝게 없을 테니, 0xCC는 딱히 x86 기계어 코드를 의식해서 정해진 값은 아닐 것으로 보인다.

Visual C++의 경우, 스택 말고 malloc이나 new로 할당하는 힙(동적) 메모리는 디버그 버전에서는 내용 전체를 0xCD로 채워서 준다. 0xCC보다 딱 1 더 크다. 아하~! 이 값도 평소에 디버그 하면서 많이 보신 적이 있을 것이다.
힙의 관리는 컴파일러 내장이 아니라 CRT 라이브러리의 관할로 넘어기도 하니, 0xCD라는 값은 라이브러리의 소스인 dbgheap.c에서도 _bCleanLandFill이라는 상수명으로 확인할 수 있다.

2. 초기화되지 않은 스택 메모리의 사용 감지

C/C++ 언어는 지역변수의 값 초기화를 필수가 아닌 선택으로 남겨 놓았다. 그러니 해당 언어의 컴파일러 및 개발툴에서는 프로그래머가 초기화되지 않은 변수값을 사용하는 것을 방지하기 위해, 디버그용 빌드 한정으로라도 여러 안전장치들을 마련해 놓았다.
그걸 방지하지 않고 '방치'하면 같은 프로그램의 실행 결과가 debug/release별로 달라지는 것을 포함해 온갖 골치아픈 문제들이 발생해서 프로그래머를 괴롭히게 되기 때문이다.

int x; printf("%d", x);

이런 무식한 짓을 대놓고 하면 30년 전의 도스용 Turbo C에서도 컴파일러가 경고를 띄워 줬다. Visual C++의 에러 코드는 C4700. 그랬는데 한 Visual C++ 2010쯤부터는 이게 경고가 아니라 에러로 바뀌었다.
그리고 그 뿐만이 아니다.

int x;
if( some_condition_met(...)) x=0;
printf("%d", x);

이렇게 문장을 약간만 꼬아 놓으면 초기화를 전혀 안 하는 건 아니기 때문에 컴파일 과정에서의 C4700을 회피할 수 있다. 하지만 보다시피 if문의 조건이 충족되지 않으면 x가 여전히 초기화되지 않은 채 쓰일 수 있다. 이건 정적 분석 정도는 돌려야 감지 가능하다.
(그런데, 글쎄.. 함수의 리턴이 저런 식으로 조건부로 불완전하게 돼 있으면 컴파일만으로도 C4715 not all control paths return value 경고가 뜰 텐데.. 비초기화 변수 접근 체크는 그 정도로 꼼꼼하지 않은가 보다.)

Visual C++은 /RTC라고 디버그용 빌드에다가 run-time check라는 간단한 검사 기능을 추가하는 옵션을 제공한다. 함수 실행이 끝날 때 스택 프레임 주변을 점검해서 버퍼 오버런을 감지하는 /RTCs, 그리고 지역변수를 초기화하지 않고 사용한 것을 감지하는 /RTCu.

사용자 삽입 이미지

저 코드를 Visual C++에서 디버그 모드로 빌드해서 실행해서 if문이 충족되지 않으면 run-time check failure가 발생해서 프로그램이 정지한다. 다만, 이 메모리는 초기화만 되지 않았을 뿐 접근에 법적으로 아무 문제가 없는 스택 메모리이다. 할당되지 않은 메모리에 접근해서 access violation이 난 게 아니다. 심각한 시스템/물리적인 오류가 아니라 그저 의미· 논리적인 오류이며, 쓰기를 먼저 하지 않은 메모리에다가 읽기를 시도한 게 문제일 뿐이다.

그러니 이 버그는 해당 메모리 자체에다가 시스템 차원의 특수한 표식을 해서 잡아낸 게 아니며, 논리적으로 매우 허술하다. (0xCC이기만 하면 무조건 스톱.. 이럴 수도 없는 노릇이고!)
문제의 코드에 대한 디스어셈블리를 보면 if문이 만족되지 않으면 printf으로 가지 않고 그냥 곧장 RTC failure 핸들러를 실행하게 돼 있다.

void do_nothing(int& x) {}

int x; do_nothing(x); printf("%d", x);

그렇기 때문에 요렇게만 해 줘도 RTC를 회피하고 x의 쓰레기값을 얻는 게 가능하다. 글쎄, 정교한 정적 분석은 이것도 지적해 줄 수 있겠지만, 포인터가 등장하는 순간부터 메모리 난이도와 복잡도는 그냥 하늘로 치솟는다고 봐야 할 것이다.

하물며 처음부터 포인터로만 접근하는 힙 메모리는 RTC고 뭐고 아무 안전 장치가 없다. int *p에다가 new건 malloc이건 값이 하나 들어간 것만으로도 초기화가 된 것이거늘, 그 주소가 가리키는 p[0], p[1] 따위에 쓰레기값(0xCD)이 있건 0이 있건 알 게 무엇이겠는가????

나도 지금까지 혼동하고 있었는데, 이런 run-time check failure는 run-time error와는 다른 개념이다. 순수 가상 함수 호출 같은 건 C/C++에 존재하는 얼마 안 되는 run-time error의 일종이고 release 빌드에도 포함돼 들어간다. 하지만 RTC는 debug 빌드 전용 검사이다.

그러니 버퍼 오버런을 감지하는 보안 옵션이 /RTC만으로는 충분하지 않고 /GS가 따로 있는 것이지 싶다. /GS는 release 빌드에도 포함돼 있으며, 마소에서는 보안을 위해 모든 프로그램들이 이 옵션을 사용하여 빌드할 것을 권하고 있다.

3. 해제된 힙 메모리: 0xDD(CRT)와 0xFEEE(???)

일반적인 프로그래머라면 동적으로 할당받은 힙 메모리를 free로 해제했을 때, 거기를 가리키는 메모리 영역이 실제로 어떻게 바뀌는지에 대해 생각을 별로 하지 않는다. 사실, 할 필요가 없는 게 정상이기도 하다.
우리 프로그램은 free를 해 준 주소는 신속하게 영원히 잊어버리고, 그 주소를 보관하던 포인터는 NULL로 바꿔 버리기만 하면 된다. free 해 버린 주소를 또 엿보다가는 곧바로 메모리 에러라는 천벌을 받게 될 것이다.

그런데 실제로는, 특히 디버그 모드로 빌드 후 프로그램을 디버깅 중일 때는 free를 한 뒤에도 해당 메모리 주소가 가리키는 값을 여전히 들여다볼 수 있다. 들여다볼 수 있다는 말은 *ptr을 했을 때 access violation이 발생하지 않고 값이 나온다는 것을 의미한다.
이 공간은 나중에 새로운 메모리 할당을 위해 재사용될 수야 있다. 하지만 사용자가 디버깅의 편의를 위해 원한다면 옵션을 바꿔서 재사용되지 않게 할 수도 있다. (_CrtSetDbgFlag(_CRTDBG_DELAY_FREE_MEM_DF) 호출)

뭐, 메모리를 당장 해제하지 않는다고 해서 free 하기 전의 메모리의 원래 값까지 그대로 남아 있지는 않는다. Visual C++의 디버그용 free/delete 함수는 그 메모리 블록의 값을 일부러 0xDD (_bDeadLandFill)로 몽땅 채워 넣는다. 여기는 할당되었다가 해제된 영역임을 이런 식으로 알린다는 것이다.

실제로, free된 메모리가 곧장 흔적도 없이 사라져서 애초에 존재하지도 않았던 것처럼 접근 불가 ?? 로 표시되는 것보다는 0xDD라고 디버거의 메모리 창에 뜨는 게 dangling pointer 디버깅에 약간이나마 더 도움이 될 것이다. 이 포인터가 처음부터 그냥 쓰레기값을 가리키고 있었는지, 아니면 원래는 valid하다가 지칭 대상이 해제되어 버린 것인지를 분간할 수 있으니 말이다.

그런데 본인은 여기서 개인적으로 의문이 들었다.
본인은 지난 20여 년에 달하는 Visual C++ 프로그래밍과 메모리 문제 디버깅 경험을 떠올려 봐도.. 갓 할당된 쓰레기값인 0xCC와 0xCD에 비해, 0xDD를 본 적은 전혀 없는 건 아니지만 매우 드물었다.

dangling pointer가 가리키는 메모리의 값은 0xD?보다는 0xF?였던 적이 훨씬 더 많았다. 더 구체적으로는 2바이트 간격으로 0xFEEE (0xEE, 0xFE)이다.

사용자 삽입 이미지

인터넷 검색을 해 보니.. 이건 놀랍게도 CRT 라이브러리가 채워 넣는 값이 아니었다. free/delete가 궁극적으로 호출하는 Windows API 함수인 HeapFree가 메모리를 정리하면서 영역을 저렇게 바꿔 놓았었다. 더구나 CRT에서 0xDD로 먼저 채워 넣었던 영역을 또 덮어쓴 것이다.
이 동작에 대해서 놀라운 점은 저게 전부가 아니다.

(1) 0xFEEE 채우기는 프로그램을 Visual C++ 디버거를 붙여서(F5) 실행했을 때만 발생한다. debug 빌드라도 디버거를 붙이지 않고 그냥 Ctrl+F5로 실행하면 0xFEEE가 생기지 않는다. 그리고 release 빌드라도 디버거를 붙여서 실행하면 0xFEEE를 볼 수 있다.

(2) 더 놀라운 점은.. 내가 집과 직장 컴퓨터를 통틀어서 확인한 바로는 저 현상을 볼 수 있는 건 Visual C++ 2013 정도까지이다. 2015부터는 debug 빌드를 디버거로 붙여서 돌리더라도 0xFEEE 채움이 발생하지 않고 곧이곧대로 0xDD만 나타난다~!

운영체제가 정확하게 어떤 조건 하에서 0xFEEE를 채워 주는지 모르겠다. 인터넷 검색을 해 봐도 정확한 정보가 나오는 게 의외로 없다.
하필 Visual C++ 2015부터 저런다는 것은 CRT 라이브러리가 Universal CRT니 VCRuntime이니 하면서 구조가 크게 개편된 것과 관계가 있지 않으려나 막연히 추측만 해 볼 뿐이다.

여담이지만 HeapAlloc, GlobalAlloc, LocalAlloc은 연달아 호출했을 때 돌아오는 주소의 영역이 그리 큰 차이가 나지 않으며, 내부 동작 방식이 모두 비슷해진 것 같다. 물론 뒤의 global/local은 fixed 메모리 할당 기준으로 말이다.

4. 힙 메모리 영역 경계 표시용: 0xFD와 0xBD

0xCD, 0xDD, (0xFEEE) 말고 heap 메모리 주변에서 볼 수 있는 디버그 빌드용 magic number 바이트로는 0xFD _bNoMansLandFill와 0xBD _bAlignLandFill가 더 있다.

얘들은 사용자가 요청한 메모리.. 즉, 0xCD로 채워지는 그 메모리의 앞과 뒤에 추가로 고정된 크기만큼 채워진다. Visual C++ CRT 소스를 보면 크기가 NoMansLandSize인데, 값은 4바이트이다. 사용자가 요청한 메모리 크기에 비례해서 채워지는 0xCD와 0xDD에 비하면 노출 빈도가 아주 작은 셈이다. 특히 0xBD는 0xFD보다도 더욱 듣보잡인 듯..

애초에 얘는 사용자가 건드릴 수 있거나 건드렸던 공간이 아니며 그 반대이다. 사용자는 0xCD로 채워진 공간에다가만 값을 집어넣어야지, 앞뒤  경계를 나타내는 0xFD를 건드려서는 안 된다.
CRT 라이브러리의 디버그용 free/delete 함수는.. 힙을 해제할 때 이 0xFD로 표시해 놨던 영역이 값이 바뀌어 있으면 곧장 에러를 출력하게 돼 있다.

그리고 예전에 메모리를 해제해서 몽땅 0xDD로 채워 놨던 영역도 변조된 게 감지되면 _CrtCheckMemory 같은 디버깅 함수에서 곧장 에러를 찍어 준다. 그러니 0xDD, 0xFD, 0xBD는 모두 오류 검출이라는 용도가 있는 셈이다. 0xCC와 0xCD 같은 쓰레기값 영역은 쓰지도 않고 곧장 읽어들이는 게 문제이지만, 나머지 magic number들은 건드리는 것 자체가 문제이다.

그리고 얘들은 heap 메모리를 대상으로 행해지는 점검 작업이다. 이런 것 말고 스택 프레임에다가 특정 magic number를 둬서 지역변수 배열의 overflow나 복귀 주소 변조를 감지하는 것은 별도의 컴파일러 옵션을 통해 지원되는 기능이다. 요것들은 힙 디버그 기능과는 별개이며, 보안 강화를 위해 release 빌드에도 포함되는 게 요즘 추세이다.

이상이다.
파일 포맷 식별자 말고 메모리에도 디버깅을 수월하게 하기 위해 쓰레기값을 가장한 이런 특수한 magic number들이 쓰인다는 게 흥미롭다. Windows의 Visual C++ 외의 다른 개발 환경에서는 디버깅을 위해 어떤 convention이 존재하는지 궁금해진다.

사실, 16진수 표기용인 A~F에도 모음이 2개나 포함돼 있고 생각보다 다양한 영단어를 표현할 수 있다. 거기에다 0을 편의상 O로 전용하면 모음이 3개나 되며, DEAD, FOOD, BAD, FADE, C0DE 정도는 거뜬히 만들어 낸다. 거기에다 FEE, FACE, FEED, BEEF 같은 단어도.. 유의미한 magic number나 signature를 고안하는 창의력을 발산하는 데 쓰일 수 있다.
그러고 보니 아까 0xFEEE도 원래 free를 의도했는데 16진수 digit에 R은 없다 보니 불가피하게 0xFEEE로 대충 때운 건지 모르겠다.

Posted by 사무엘

2021/02/11 08:36 2021/02/11 08:36
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1853

메모리 leak 사냥 후기

본인은 얼마 전엔 생계를 위해 덩치 좀 있고 스레드도 여럿 사용하는 C++ 프로젝트에서 골치 아픈 메모리 leak 버그만 잡으면서 꼬박 두 주를 보낸 적이 있었다.
요즘 세상에 raw 포인터를 직접 다루면서 동적 메모리를 몽땅 수동으로 직접 관리해야 한다니, C/C++은 자동차 운전으로 치면 수동 변속기와 잘 대응하는 언어 같다.

의외로... Visual C++이 2012 무렵부터 제공하기 시작한 정적 분석 도구는 memory leak을 잡아 주지는 않았다. 제일 최신인 2019 버전도 마찬가지이다.
얘가 잡아 주는 건 잠재적으로 NULL 포인터나 초기화되지 않은 변수값을 사용할 수 있는 것, 무한 루프에 빠질 수 있는 것 따위이다. 개중에는 너무 자질구레하거나 심지어 false alarm으로 여겨지는 것도 있다. 하지만..

char *p;
p = new char[256];
p = (char *)malloc(100);
p = NULL;
*p = 32;

이렇게 코드를 짜면 지적해 주는 건 맨 아래에 대놓고 NULL 포인터를 역참조해서 대입하는 부분뿐이다.
앞에서 new와 malloc 메모리 블록이 줄줄 새는 것은 의외로 out of 안중이더라. 개인적으로 놀랐다.

리눅스 진영에는 Valgrind라는 툴이 있긴 한데, 얘도 프로그램을 직접 실행해 주는 동적 분석이지 정적은 아니다.
다른 상업용 3rd party 정적 분석 툴 중에는 메모리 leak도 잡아내는 물건이 있을지 모른다. 하지만 그런 것 없이 Visual C++ 순정만 쓴다면 메모리 leak 디버깅은 전통적인 인간의 동적 분석에 의존해야 할 듯했다. 그래서 고고씽..

처음에는 무식하게 여기저기 들쑤시면서 삽질하면서 시간을 많이 보냈지만, 나중엔 차츰 요령이 생겼다.
먼저, 안정성이 검증돼 있는 맨 아랫단의 각종 오픈소스 라이브러리들을 의심하고 무식하게 들쑤실 필요는 없다. 물론 겉으로 드러난 결과는 거기서 할당한 메모리들이 줄줄 새는 것이다. 하지만 근본 원인은 거기보다 더 위에 있다.

그렇다고 맨 위의 애플리케이션이 오브젝트 해제를 안 했다거나 한 것도 아니었다. 그 정도로 초보적인 실수였다면 금세 감지되고 잡혔을 것이다. 더구나 App들은 아랫단과 달리 C++을 기반으로 스마트 포인터 같은 것도 그럭저럭 활용해서 작성되어 있었다. 그러니 거기도 딱히 문제는 없었다.

대부분의 문제는 오픈소스를 우리 쪽에서 살짝 수정한 부분, 오픈소스로부터 호출되는 우리 쪽 콜백 함수, 그리고 우리가 작성한 중간 계층의 공유 라이브러리에서 발견되었다.
이 코드를 처음으로 작성한 전임자가 누구인지는 모르겠지만.. C++ 코딩을 너무 Java 코딩하는 기분으로 했다는 생각이 강하게 들었다.

std::string s = _strdup("ABCD");

이런 식으로만 해 놓고 그냥 넘어간다거나.. (저기요, R-value는 어떡하고..??)
함수 뒷부분에서 나름 메모리를 해제한답시고 p = NULL을 쓴 것을 보니.. 전임자는 정말 Java의 정신으로 충만했다는 게 느껴졌다. (p는 물론 스마트가 아닌 일반 포인터)

메모리 leak 디버깅을 위해 C 컴파일러들은 디버깅용 메모리 관리 함수들을 제공하며, 다른 라이브러리들은 보통 자신들이 사용하는 메모리 할당 함수를 자신만의 명칭으로 바꿔서 쓴다. 그 명칭만으로 자신의 메모리 사용 내역을 추적할 수 있게 하기 위해서이다. (매크로 치환 및 해당 함수의 구현 부분 수정)

Visual C++ 기준으로, 프로그램이 처음 실행됐을 때 _CrtSetDbgFlag( _CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF )를 호출하고 나면, 종료 시에 아직 해제되지 않은 heap 메모리들 목록이 쭈욱 나열된다. 메모리 할당 번호와 할당 크기, 그리고 메모리의 첫 부분 내용도 일부 같이 덤프된다.

여기서 ‘할당 번호’라는 걸 주목하시길..
만약 프로그램을 여러 번 실행하고 종료하더라도 (1) 메모리 할당 번호가 동일한 leak을 일관되게 재연 가능하다면, 그건 운이 아주 좋은 상황이다.
_CrtSetBreakAlloc을 호출해서 나중에 그 번호에 해당하는 메모리 할당 요청이 왔을 때 프로그램 실행을 중단시키면 되기 때문이다. 그러면 게임 끝이다.

하지만 복잡한 멀티스레드 프로그램에서 이렇게 매번 동일한 번호로 발생하는 착한 leak은 그리 많지 않다. 이것만으로 이 메모리의 출처를 추적하고 문제를 해결하는 건 아직 모래사장에서 바늘 찾는 짓이나 마찬가지이다. 단서가 좀 더 필요하다.

그래서 메모리를 할당할 때 이 요청은 (2) 소스 코드의 어느 지점에서 한 것이라는 정보를 같이 주게 한다.
어떻게? Visual C++ 기준 _***_dbg라는 함수를 만들어서 뒤에 소스 코드와 줄 번호 인자를 따로 받게 한다. ***에는 malloc뿐만 아니라 변종인 realloc과 calloc, 내부적으로 이런 함수를 호출하는 strdup 같은 함수도 모두 포함된다. 심지어 C++용으로는 operator new 함수도 말이다.

C의 __FILE__과 __LINE__은 그야말로 디버깅용으로 만들어진 가변 매크로 상수인 셈이다. 이렇게 말이다.

#ifdef _DEBUG
#define malloc(n)   _malloc_dbg(n, __FILE__, __LINE__)

#define new    __debug_new
#define __debug_new   new(__FILE__, __LINE__)
void *operator new(size_t n, const char *src, int lin);
void *operator new[](size_t n, const char *src, int lin);
#endif

new operator가 오버로딩 되는 건 placement new를 구현할 때와 디버깅용 메모리 할당을 할 때 정도인 것 같다.
이렇게 메모리 할당 방식을 바꿔 주면.. 나중에 leak report가 뜰 때 그 메모리 블록에 대해서 할당되었던 지점이 같이 뜬다. 무슨무슨 c/cpp의 몇째 줄이라고..

물론 그 함수가 호출된 배경을 알 수 없으니 저것도 불완전하게 느껴질 수 있다. 또한 이미 자체적으로 malloc을 다른 명칭으로 감싸고 있는 코드에 대해서는 이런 매크로 치환이 곧장 통하지 않는다는 한계도 있다.

그래도 그 정보마저 없던 것보다는 상황이 월등히 더 나아진다.
참고로, 프로그램이 실행 중일 때에도 동적 할당된 임의의 메모리에 대해서 _CrtIsMemoryBlock을 호출하면 이 메모리의 할당 번호와 출처 정보를 얻을 수 있다. 이를 토대로 leak은 얘보다 전인지 후인지, 언제 할당되었는지를 유추 가능하다(할당 번호의 대소 비교).

이것만으로도 아직 막막할 때 본인이 사용한 최후의 방법은 (3) _CrtSetAllocHook을 사용해서 메모리 할당이 발생할 때마다 콜백 함수가 호출되게 하는 것이었다.
내가 작성하지도 않은 방대한 코드에서 malloc/calloc을 전부 내 함수로 치환하는 것은 위험 부담이 매우 큰데.. 그럴 필요 없이 Visual C++ CRT의 malloc이 디버깅을 위해 사용자의 콜백 함수를 직접 호출해 준다니 고마운 일이 아닐 수 없다.

이를 위해서는 한 프로세스 내의 모든 static library 및 DLL 모듈들이 동일한 Visual C++ CRT 라이브러리를 DLL로 링크하게만 맞춰 놓으면 된다. 어느 것 하나라도 CRT의 static 링크가 있으면 일이 많이 골치 아파진다. DLL로 해야 모든 모듈들이 사용하는 메모리가 한 CRT에서 통합적으로 관리된다.

콜백 함수는 메모리 할당 번호뿐만 아니라 할당 크기, 그리고 이 메모리를 요청한 스레드가 어느 것인지도 확인 가능하다.
개인적으로는 leak 중에서 크고(수백~수천 바이트 이상) 유니크한 바이트 수를 동일하게 요청하는 것을 콜백 함수를 통해서 잡아내고, 이걸 토대로 다른 leak들도 잡아냈다.
겨우 4바이트, 8바이트 같은 너무 평범하고(?) 자주 호출되는 할당 요청은 leak만 추려내기가 곤란할 것이다.

이 콜백 함수에서 또 메모리를 동적 할당하지는 않도록 주의해야 한다. 그러면 콜백 함수에서 호출된 메모리 할당 함수가 또 콜백을 호출하고.. stack overflow 에러가 발생할 수 있다.
로그를 찍기 위해 흔히 사용하는 sprintf 부류의 함수조차도 내부적으로 메모리를 동적 할당한다.

이 문제를 회피하기 위해 우리 콜백 함수 내부에서 중복 호출 방지 guard를 둘 수도 있지만.. 간단하게 C 라이브러리 대신 Windows API가 제공하는 wsprintfA/W 함수를 사용하는 것도 괜찮은 방법이다. Windows API 중에는 C 라이브러리를 사용할 수 없는 환경에서도 C 라이브러리의 기능을 일부 사용하라면서 저런 부류의 함수를 제공하는 경우가 있다.

이상이다.
memory leak은 여느 메모리나 스레드 버그처럼 프로그램을 당장 뻗게 만들지는 않는다.
오히려 메모리 관리를 잘못해서 원래는 dangling pointer가 됐어야 할 포인터로도 메모리 접근을 가능하게 만들어 주기도 한다(해제되지 않았기 때문에).

하지만 leak은 결국 컴퓨터의 메모리 자원을 소진시키고, 한 프로그램이 반영구적으로 동일한 상태를 유지하면서 돌아가지 못하게 하는 심각한 문제이다. 더 넓게 보자면 굳이 heap 메모리 말고도, 각종 커널 핸들이나 GDI 객체처럼 나중에 반드시 닫아 줘야 하는 일체의 리소스들도 제때 해제해 주지 않을 경우 leak이 발생할 수 있는 물건들이다. 상업용 툴은 이런 것들까지 다 모니터링을 해 주지 싶다.

이 주제 관련 다른 여담들을 좀 늘어놓으며 글을 맺고자 한다.

(1) leak은 새어나가는 그 메모리의 할당이 벌어지는 상황을 추적하는 게 핵심이다. 그런데 새고 있는지의 여부는 한참 뒤에 프로그램이 종료될 때에나 알 수 있다는 것이 큰 모순이며, 관련 디버깅을 어렵게 하는 요인이다.
또한 시작과 끝이 있는 게 아니라 언제나 돌아가는 서버/서비스 같은 프로그램도 있다. 이런 건 leak을 어떻게 찾아내야 좋을까? 그렇게 오랫동안 상시 가동되는 프로그램이야말로 memory leak이 절대로 없어야 하는데, 역설적이게도 그런 유형의 프로그램이 leak을 잡기가 더욱 어렵다. 뭔가 새로운 방법론을 찾아서 적용해야 한다.

(2) 컴퓨터에서 메모리 영역이란 건 용도에 따라 코드와 데이터로 나뉘는데, 코드를 저장하는 메모리가 새는 일은.. 무슨 가상 머신 급의 고도의 시스템 소프트웨어를 개발하는 게 아닌 이상 없을 것이다.
다만, 데이터도 다 같은 데이터는 아니어서 진짜로 쌩 문자열 같은 POD인지, 아니면 내부에 포인터가 들어있는 실행 객체의 인스턴스인지에 따라 체감 난이도가 달라진다. 후자는 그 자체가 코드는 아니지만 코드에 준한다는 느낌이 든다.

(3) a( b(), c() ) 이런 구문의 실행을 디버거로 추적한다면, step into는 b()의 내부부터 먼저 들어간다. step over는 이들을 통째로 다 실행하고 다음 줄로 넘어간다.
그 둘의 중간으로.. b()와 c()처럼 인자 준비 과정에서 발생하는 함수 호출은 몽땅 생략하고 a()로만 step into 하는 명령도 좀 있으면 좋겠다.
특히 smart pointer는 함수로 넘겨줄 때마다 trivial한 생성자나 연산자 오버로딩 함수로 먼저 진입하는 것이 굉장히 번거롭다. 이런 것을 생략할 수 있으면 디버깅 능률과 생산성이 더 올라갈 수 있을 것이다.

Posted by 사무엘

2021/01/07 08:35 2021/01/07 08:35
, , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1840

인쇄 기능 -- 下

지난번 상편에서는 Windows에서 인쇄 기능을 코딩으로 구현하는 절차와 인쇄 관련 기본 개념과 동작들을 살펴보았다. 이번 하편에서는 상편에서 분량상 모두 다루지 못한 인쇄 관련 추가 정보들을 한데 늘어놓도록 하겠다.

1. 인쇄 관련 공용 대화상자들

우리가 Windows에서 가장 자주 접하는 공용 대화상자는 아마 파일 열기/저장 대화상자일 것이다. 이것 말고도 글꼴 선택 내지 색깔 선택 대화상자가 있으며, 인쇄 대화상자도 그 중 하나이다.

사용자 삽입 이미지

인쇄를 지원하는 대부분의 프로그램에서 Ctrl+P를 눌러서 쉽게 보는 인쇄 대화상자라는 건 위와 같이 생겼다. 기본적으로 ‘일반’이라는 탭 하나밖에 없지만, 추후 확장과 사용자의 customize 가능성을 염두에 두고 프로퍼티 시트 형태를 하고 있다. 20년 전, Windows 2000 시절부터 저런 형태가 도입되었다.

사용자 삽입 이미지

하지만 Windows 9x 내지 더 옛날 16비트 시절에는 인쇄 대화상자가 전통적으로 위와 같은 모양이었다. 외형상 가장 큰 차이는 프로퍼티 시트가 아닌 일반 대화상자 형태이고, 프린터 목록이 리스트 컨트롤이 아니라 콤보 상자라는 점이다. 지금도 좀 옛날 프로그램에서는 요런 모양의 대화상자를 여전히 볼 수 있다.

인쇄 대화상자로 할 수 있는 일은 크게 (1) 인쇄를 내릴 프린터를 선택하고, (2) 각 프린터별로 용지의 종류와 방향, 여러 부 인쇄 방식 등을 지정하고, (3) 인쇄할 페이지 영역을 지정하고, (4) 최종적으로 인쇄 명령을 내리는 것이다.
하는 일이 생각보다 많기 때문에 옛날에는 인쇄 대화상자를 약간 간소화시켜서 (2)만 지정할 수 있는 “프린터 설정” 대화상자라는 게 따로 있기도 했다. 아래처럼 말이다.

사용자 삽입 이미지

Windows 말고 아래아한글만 해도 먼 옛날 도스 시절에는 Alt+P는 인쇄 대화상자이지만 Ctrl+P는 프린터 설정이었다는 걸 생각해 보자.
하지만 Windows 2000 스타일의 최신 인쇄 대화상자에서는 인쇄와 프린터 설정이 한데 통합되었다. 어떻게..?? 기존의 '프린터 설정'은 자신의 상위 호환인(superset) 인쇄 대화상자를 '적용'만 누른 뒤 '취소'로 닫는 것으로 퉁친 것이다. 이 때문에 인쇄 대화상자는 딱히 modeless가 아닌 modal 형태--자신이 떠 있는 동안 부모 윈도우로 포커스를 옮길 수 없음--임에도 불구하고 '적용' 버튼이 따로 있는 것이다.

MFC에서는 기본 구현돼 있는 인쇄 기능만 사용하는 경우, 오늘날의 2019 최신 버전까지도 의외로 옛날 스타일의 인쇄 대화상자가 계속해서 나타난다. CPrintDialog는 PRINTDLG 구조체 기반이며, 최신 스타일 대화상자를 지원하려면 PRINTDLGEX 기반인 CPrintDialogEx를 사용해야 한다. 최신 스타일이 도입되면서 내부 동작이 많이 바뀌고 바이너리 호환성이 깨졌기 때문에 Ex 클래스는 오리지널 클래스의 파생형이 아니며, 서로 호환성이 없다.

MFC 없이 Windows API만 사용한다면 재래식 PRINTDLG 구조체만 사용하더라도 최신 스타일 대화상자가 나오게 하는 것 자체는 가능하다. 대화상자 템플릿을 customize하는 것 없이 기본 멤버만 사용한다면 말이다.

그러나 구형 구조체와 구형 API만 사용해서 나타난 최신 대화상자에는 ‘적용’ 버튼이 나오지 않는다. 구형 API에는 함수 리턴값 차원에서 그런 응답 자체가 존재하지 않았기 때문이다.
아울러, 인쇄할 영역을 단순히 x~y쪽이 아니라 x1~y1, x2~y2 이런 식으로 여러 개 지정하는 것도 구형 API로는 불가능하다. 해당 구조체 멤버가 존재하지 않기 때문이다. 이런 식의 제약이 있다.

다음은 관련 여담이다.

(1) PrintDlg 내지 PrintDlgEx의 실행이 성공하면 프린터 DC뿐만 아니라 DEVMODE 내지 DEVNAMES 구조체 내용을 담고 있는 global 동적 메모리 핸들도 돌아온다. 현재 선택된 프린터에 대해서 지정한 용지 등의 설정들이 여기에 저장되기 때문에 응용 프로그램이 이 핸들을 잘 관리하고 있어야 한다. 그래서 다음에 인쇄 기능을 호출할 때 요걸 넘겨줘야 기존 인쇄 설정이 보존된다.

(2) 인쇄 대화상자 말고 용지 설정 대화상자라는 것도 있지만 이건 잘 쓰이지 않는다. 용지 종류와 방향, 상하좌우 여백 정도는 공통이겠지만 그 뒤로 인쇄 내용을 배치하는 방식들은 응용 프로그램들마다 같은 구석이 별로 없기 때문이다. 당장 워드패드, 메모장, 그림판만 해도 용지 설정 대화상자의 모양은 전부 제각각이다.

(3) 운영체제에 설치된 글꼴들을 조회하는 것처럼, 현재 설치돼 있는 프린터들을 조회하는 API도 있다. 인쇄 대화상자를 꺼내지 않고 내 대화상자의 한구석에다가 프린터 목록 같은 걸 마련하고 싶다면 이런 API를 쓰면 된다. 하지만 현실에서 사용할 일은 거의 없을 것이다.

(4) “인쇄 중” 대화상자라든가 “인쇄 미리보기(화면 인쇄)” 같은 건 공용 대화상자 버전이 존재하지 않으며, MFC의 경우 자체 구현돼 있다. 오늘날은 인쇄 진행 상황 같은 건 위대하고 전능하신 task dialog로 너무나 깔끔하게 구현할 수 있을 것이다.
그리고 인쇄 미리보기는 여느 대화상자와 달리 꽤 큰 공간이 필요한 관계로, 전통적인 modal 대화상자보다는 프로그램 주 화면에다가 곧장 미리보기를 표시하는 식으로 디자인이 바뀌는 추세이다. 요즘 MS Office 프로그램들이 대표적인 예이다.

2. 플로터

먼 옛날 한 1980~90년대쯤에 컴퓨터 개론 교양 서적에서는 컴퓨터의 출력 장치로 모니터, 프린터와 더불어 플로터라는 물건도 소개되어 있었다. 플로터는 로봇 팔 같은 게 달려서 종이에다가 도면을 말 그대로 '그려 주는' 장치이다.
덕분에 얘는 직선이나 곡선 하나는 무한한 해상도로 원본 그대로 정확하게 그릴 수 있다. 잉크를 적절히 배합해서 컬러 표현도 가능하다.

하지만 장점은 그걸로 끝.. 인쇄 속도가 도트 프린터 이상으로 끔찍하게 느리며(비록 도트 프린터처럼 시끄럽지는 않지만), 속이 채워진 도형이나 비트맵 같은 그림을 표현할 수 없다.
실제로 플로터를 가리키는 DC에다가 GetDeviceCaps(hDC, RASTERCAPS)를 호출해 보면 RC_BITBLT가 가능하지 않다는 응답이 올 것이다. 비트맵 전송을 할 수 없고 천상 원과 직선과 곡선 그리기나 하라는 소리이다.

일반 가정집이나 사무실에서 A4 용지에다가 인쇄하는 거라면 그냥 닥치고 일반 프린터만 쓰면 된다. 하지만 플로터는 프린터가 범접할 수 없는 A1 같은 엄청나게 큰 종이에다가 도면을 그릴 수 있으며, 펜 대신 커터 같은 걸 꽂아서 선 궤적대로(가령 글자의 윤곽선) 종이를 오려낸다거나 하는 용도로도 활용 가능하다는 것에 존재 의의가 있다. 즉, 산업용으로 마르지 않는 수요가 있다.

하긴, Windows에도 트루타입도 비트맵도 아니고 Modern, Roman, Script처럼 내부가 채워지지 않은 선으로만 구성된 '벡터 폰트'가 있었다. 실용적인 쓸모가 전혀에 가깝게 없는 완전 잉여인지라, 요즘 어지간한 프로그램의 글꼴 목록에서는 조회조차 되지 않을 것이다. 이런 게 바로 플로터용 글꼴이다. 물론 프린터에서도 쓸 수 있지만..

사용자 삽입 이미지

옛날에 도스용 터보 C의 BGI에도 비트맵 말고 벡터 글꼴 컬렉션이 있었다. 큰 크기에서는 내부가 채워지지 않고 선만 그어졌으며.. 힌팅이 없어서 작은 크기에서는 영 보기 좋지 않았으니 반쪽짜리인 건 동일했다. 비트맵 같은 계단 현상만 없을 뿐 다른 방면으로 단점투성이였다.

사용자 삽입 이미지
OpenCV 같은 그래픽 라이브러리도 전문적인 폰트 엔진 없이 자체적으로 영문· 숫자를 뿌리는 기능은 제공되는 글꼴이 딱 저런 퀄리티이다.

3. 프린터 드라이버의 가상화

컴퓨터라는 기계는 개나 소나 다 “물리적으로는 없지만 그래도 일단 있다고 치자”라고 넘기는 가상화가 통용되는 동네이다. 메모리는 진작부터 가상화하고 지내고 컴퓨터 자체도 가상 머신, 그리고 iso 같은 광학 디스크는 뭐.. 이제 운영체제(드라이브 마운트)와 압축 유틸조차(생성) 정식으로 지원하는 세상이 됐다.

그러니 프린터도 당연히 가상화의 대상이다. 당장 내 자리에 프린터가 존재하지는 않지만 어떻게든 인쇄를 하는 방법은 크게 두 가지인데, 하나는 그냥 pdf나 xps 같은 파일로 인쇄하는 것이고 다른 하나는 원격 프린터에 네트워크로 연결해서 인쇄하는 것이다. 한때는 컴퓨터조차 한데 공유하는 자원이고 각 사용자는 단말기로 접속만 하던 시절이 있었지만 지금은 프린터 정도나 사무실 같은 데서 여러 컴퓨터들이 한데 공유한다는 점이 흥미롭다.

옛날에는 PC에 Windows 운영체제만 달랑 설치하고 나면 프린터가 잡힌 게 없었다. 프린터는 네트워크나 비디오/오디오 장치만치 필수는 아니니까..
기본 프린터가 없는 컴퓨터에서는 어지간한 프로그램에서 인쇄 미리보기 기능조차 동작하지 않았다. 프린터 공급 용지의 크기를 알 수 없기 때문이다. 하지만 Vista부터는 xps 문서 생성기라는 드라이버가 기본 연결되어 저런 광경을 볼 일은 없어졌다.

pdf/xps가 대중화되기 이전에도 "파일로 인쇄"라는 개념 자체는 마치 "램 드라이브"와 비슷한 위상으로 존재했으며, 지금도 인쇄 대화상자의 옵션으로 존재한다. 인쇄할 내용이 저장된 컴퓨터와 프린터가 연결된 컴퓨터가 서로 일치하지 않을 때 파일로 인쇄하는 기능이 꼭 필요하지 않겠는가?

하지만 지난번 글에서도 잠시 언급했듯이 이런 파일 인쇄 결과는 특정 프린터 기종에 종속적이기 때문에 범용성이 떨어진다. 본인도 저걸 활용한 적은 거의 없었던 것 같다. 그 대신 오늘날은 위지윅만 보장되고 물리적인 프린터의 구조와는 철저하게 독립적인 전자 문서 파일 포맷이 활발히 쓰이고 있다.

우리나라는 인터넷으로 은행 거래나 공문서 발급 같은 걸 받을 때 보안 ActiveX들을 잔뜩 설치해야 해서 원성이 자자하다. 그래서 이런 사이트 접속 전용으로 ActiveX 설치 총알받이 가상 머신을 만들려고 해도 안 되는 경우가 많다. 반드시 본머신(?)만 쓰라고 강요하면서 가상 머신에서는 설치되기를 거부하는 매우 악랄한 ActiveX도 있기 때문이다.

그것처럼.. 증명서 같은 것을 인쇄하는 전용 프로그램의 경우, 가상 프린터인 건 또 어떻게 감지하는지 pdf 같은 파일 생성은 거부하는 경우가 대부분이다.
가상 CD를 감지하는 프로그램은 못 봤는데 프린터와 PC는 어째 감지하는지? 신기한 노릇이다. 하드카피 종이 인쇄는 뭐 변조 못 할 줄 아나..;; 어차피 원본대조필 워터마크가 필요한 건 똑같을 텐데.

한편, 가상 프린터 드라이버라는 게 파일 아니면 네트워크만 있는 건 아니다. 예전에는 FinePrint라고 인쇄되는 데이터를 인위로 보정해서 1페이지에 여러 페이지 내용을 축소해서 인쇄한다거나, 잉크의 농도를 인위로 줄이는.. 뭐랄까 메타 프린터 드라이버 유틸이 있었다. 최종 목적지는 물리적인 프린터이지만 그 사이에 중재를 한다는 것이다.
하지만 요즘은 축소 인쇄라든가 잉크 절약 모드는 프린터 드라이버 차원에서 기본 제공되는 옵션이 돼서 그런 메타 드라이버의 필요성이 많이 줄어든 게 사실이다.

또한, 레이저 프린터는 기술 배경이 복사기와 비슷하다 보니, 같은 페이지를 여러 부 인쇄하는 것을 소프트웨어가 아니라 프린터에게 맡기는 게 매우 능률적이다.
양면 인쇄를 위해 페이지별 인쇄 순서를 교묘하게 바꾸는 것도 해당 워드 프로세서/전자출판 프로그램, 심지어 메타 드라이버가 담당할 법도 하지만.. 요즘은 프린터 드라이버 차원의 옵션으로 자체 제공하기도 한다. 물론 파일로 인쇄할 때는 이런 것들은 전혀 고려할 필요가 없을 것이다.

4. 창 내용을 인쇄(?)하는 API

끝으로, 이것만 마지막으로 언급하고 글을 맺도록 하겠다.
Windows에서 화면에 표시돼 보이는 각각의 창(윈도우!)들은 WM_PAINT라는 특수한 메시지가 왔을 때 invalid region과 BeginPaint - EndPaint 사이클에 맞춰 자기 내용을 그리는 일에 특화돼 있다. 이는 창 내용을 다시 그리라는 요청이 여러 번 반복해서 오더라도 그리는 건 한 번만 행해지고, 꼭 다시 그려야 하는 부분만 효율적으로 그리기 위한 조치이다.

그런데 가끔은 윈도우도 저런 사이클에 구애받지 않고, 특정 DC가 하나 주어졌을 때 묻지도 따지지도 말고 거기에다가 자기 모습 전체를 있는 그대로 싹 다시 그리게 하고 싶을 때가 있다.
이럴 때를 위해 운영체제는 WM_PRINT 및 WM_PRINTCLIENT라는 메시지를 정의하고 있다. 이건 어지간한 Windows 프로그래머라도 접하거나 구현해 본 적이 거의 없는  듣보잡일 것이다.

그런데 Windows XP에서는 저 메시지로도 모자라서 하는 일이 거의 차이가 없어 보이는 PrintWindow라는 함수까지 추가됐다. 이건 뭐 WM_GETTEXT와 GetWindowText의 관계와 비슷한 것일까?
MSDN을 찾아보면..

  • The PrintWindow function copies a visual window into the specified device context (DC), typically a printer DC.
  • The WM_PRINT message is sent to a window to request that it draw itself in the specified device context, most commonly in a printer device context.

이라고 이런 물건들이 주로 인쇄용으로 쓰일 거라고 대놓고 문서화돼 있다. 하지만 현실에서 창 스크린샷 한 장 달랑 프린트 할 일이 얼마나 될까? 실제로는 이것들 역시 화면 출력용으로 쓰인다.

가령, alt+tab을 눌렀을 때 나타나는 각 프로그램 창들의 썸네일 말이다. 물론 Vista 이후부터는 DWM이 창들 화면을 하드웨어빨로 몽땅 저장하는 세상이 되긴 했지만, 그런 것 없이 invalid region과 무관하게 자기 모습 캡처 화면을 떠야 할 때는 저런 함수/메시지가 필요하다.

그리고 메뉴나 콤보 상자 목록이 스르륵 미끄러지며 표시되는 것 말이다. 이런 걸 구현하기 위해서도 WM_PAINT와 별개 계통인 그리기 전담 메시지가 쓰인다.
스르륵 미끄러지는 걸 구현한답시고 매 프레임마다 WM_PAINT가 날아온다거나 하지는 않는다. WM_PRINTCLIENT를 날려서 전체 리스트 모양을 메모리 DC에다가 한번 그려 놓은 뒤, 그걸로 애니메이션은 운영체제가 알아서 구현해 준다.

이런 걸 생각하면 창 핸들과 DC 하나 던져주고 print를 요청하는 메시지와 함수가 왜 필요하고 어떨 때 쓰이는지 약간 수긍이 갈 것이다. 하지만 그게 왜 하필 print라는 단어가 붙었으며 함수 버전과 메시지 버전이 모두 필요한지는 나로서는 아직도 잘 모르겠다.

Posted by 사무엘

2020/10/26 08:37 2020/10/26 08:37
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1812

인쇄 기능 -- 上

1. 모니터와 프린터의 차이

소프트웨어를 개발하면서 프린터 인쇄 기능을 구현할 일은 그리 많지 않을 것이다. 넓게는 pdf를 생성하는 것까지 포함하더라도 말이다. 인쇄 기능이 존재하는 프로그램이라면 게임은 절대 아닐 것이고 아무래도 골수 업무 분야일 것이다.

뭐, Windows API의 경우, GDI API에서 사용되는 DC라는 게 처음부터 극도의 장치 독립과 추상화를 추구하면서 매우 범용적으로 설계되었다. 얼마나 추상적인가 하면, 아직 VGA도 없던 1980년대의 Windows 1.0부터 얘네들의 그래픽 API에는 색깔을 팔레트 인덱스 기반으로 선택하는 게 아예 없었으며 언제나 RGB 기반이었다.
그러니 글자와 그림을 찍는 기본적인 동작은 화면에다 그릴 때나 종이에다 그릴 때나 거의 같은 코드로 구현할 수 있다.

물론 두 장치는 성격이 근본적으로 완전히 다른 물건이다 보니, 코드가 100% 완전히 같을 수는 없다. 프린터는..

  • 3D 가속 렌더링이라든가 동영상 따위와는 전혀 접점이 없다.
  • 출력 속도가 화면보다 훨씬 더 느리다.
  • 색깔은 뭐.. 흑백 프린터도 여전히 현역인 것을 감안해야 한다.
  • 출력이 한없이 연속적인 게 아니며, 페이지의 구분이 존재한다.
  • 프린터가 꺼졌거나 아예 연결되지 않은 것, 용지가 없는 것, 종이가 걸린 것 등으로 인한 실패 확률이 높다.
  • 다만, 해상도는 프린터가 모니터를 아득히 초월할 정도로 높다. 오늘날 HDPI 화면의 해상도가 이제 30~40여 년 전 도트 프린터의 해상도(180dpi)를 따라잡은 것과 비슷한 수준이다.

화면용 3D/애니메이션 위주의 그래픽 API가 DirectX 기반으로 눈부시게 바뀐 동안, 프린터 쪽은 GDI+ 정도 말고는 수십 년 전이나 지금이나 별로 바뀐 게 없다. 글쎄, 인쇄 대화상자의 디자인이 살짝 바뀌었으며 Windows Vista/7 즈음에는 xps라고 pdf 같은 위지윅 전자 문서 생성 API도 추가되긴 했지만, 이게 통상적인 인쇄 절차를 대체할 만한 물건은 아닌 것 같다.

2. Windows에서 통상적인 인쇄 절차

정말 핵심 중의 핵심 기본은 다음과 같다.

  • 화면에다가 그릴 때는 GetDC, BeginPaint 따위로 DC를 얻었다. 하지만 인쇄용 DC를 얻을 때는 PrintDlg 함수의 실행 결과로 얻어진 DC를 쓰는 편이다. CreateDC 직통으로 DC를 생성하는 건, 특정 프린터만을 저격하는 아주 특수한 프로그램을 만드는 상황이 아닌 한 거의 없다.
  • 인쇄를 시작할 때는 저 DC에 대해서 특별히 StartDoc이라는 함수를 호출한다. 이렇게 프린터 DC에서만 사용 가능한 전용 함수들이 몇몇 좀 있다.
  • 그리고 매 페이지에다 인쇄할 때마다 시작과 끝을 StartPage와 EndPage로 해 준다. 인쇄하고자 하는 페이지 수만치 for문을 돌리면 된다. 그 동안 프린터 DC를 상대로 각종 GDI 함수를 호출해서 글자와 그림을 찍도록 한다.
  • 인쇄를 마치려면 EndDoc을 호출하고, 중간에 인쇄가 취소됐다면 AbortDoc을 호출한다. 이거 무슨 저그 스커지가 적기와 자폭하느냐, 아니면 피가 닳아서 죽느냐의 차이와 비슷하다.;;
  • 다 사용한 DC는 ReleaseDC가 아니라 DeleteDC로 해제한다. 화면 DC 말고 메모리 DC와 프린터 DC는 저 함수로 해제해야 한다.

아 참.. 똑같이 DC를 사용하더라도 화면에다 그릴 때와 프린터에다 그릴 때의 매우 큰 차이가 하나 있는데, 바로 좌표계이다.
화면에는 그냥 픽셀 단위를 가리키는 MM_TEXT가 간단하고 직관적이니 널리 쓰이지만 프린터에는 그런 게 없다. 프린터의 해상도와 무관한 밀리미터, 인치, 포인트 등의 실제 길이 단위 기반의 좌표계를 지정해 줘야 한다(SetMapMode 함수).

그리고 MM_TEXT를 제외한 나머지 추상 단위계들은 수학 좌표계처럼 y축의 값이 증가하는 방향이 위로 올라가는 방향이다. 이건 동일 코드로 화면 출력과 프린터 출력을 모두 구현하는 것을 어렵게 하는 주범이다. BMP 이미지가 화면 기준으로 파일이 상하가 뒤집힌 구조인 이유도 이런 좌표계를 염두에 두고 만들어졌기 때문이다.
인쇄 미리보기를 구현한다거나 더 나아가 편집 화면 차원에서 프린터 결과와 화면 결과가 동일한 위지윅 프로그램을 개발한다면, 어차피 화면에서도 저런 범용적인 좌표계를 사용해야 할 것이다.

3. 용지 정보 얻기 (크기와 방향)

그러고 보니 응용 프로그램이 인쇄를 하기 위해서는, 아니 그 전에 인쇄 분량 계산을 하기 위해서는 먼저 인쇄되는 종이의 크기를 알아야 한다. 이 정보는 인쇄를 하는 당사자인 프린터 드라이버가 갖고 있으며, 응용 프로그램은 운영체제 API인 GetDeviceCaps(hPrinterDC, HORZSIZE 또는 VERTSIZE)를 호출해서 얻을 수 있다.
이 함수의 리턴값은 언제나 밀리미터 단위이다. 그러므로 mm 계열이 아닌 좌표계를 사용하고 있다면 적절히 변환해서 글자를 찍으면 된다.

여기서 알 수 있듯, 인쇄 용지의 크기라는 것은 프로그램이 인쇄를 위해 프린터 DC를 생성하기 전까지는 알 수 없다. 하지만 MS Word나 아래아한글처럼 위지윅을 지원하는 워드 프로세서 부류의 프로그램은 자체적으로 가상의 용지를 설정하고 동작한다.
문서에 설정되어 있던 용지와 실제 인쇄 용지가 크기나 종횡비 같은 게 일치하지 않는다면.. 인쇄할 때 배율 같은 걸 적절히 보정해 줘야 가장자리 내용이 짤리지 않는다. 그건 해당 프로그램이 담당해야 하는 영역이다.

한편, 용지의 크기뿐만 아니라 인쇄 방향--세로 portrait 또는 가로 landscape--도 응용 프로그램의 페이지 옵션과 프린터 내부의 옵션이 서로 따로 노는 형태이다. 그럴 만도 한 게.. 기능이 매우 빈약한 메모장으로 인쇄를 하건, 아래아한글로 인쇄를 하건, 가로· 세로 인쇄는 응용 프로그램과 무관하게 언제나 가능한 게 정상이기 때문이다.

그렇기 때문에 인쇄 대화상자에서 각 설치된 프린터별 설정 대화상자를 또 연 뒤, 용지의 방향을 바꿔 줄 수 있다.
인쇄 방향이 프린터 차원에서 landscape(가로)로 설정되었다면 GetDeviceCaps(hPrinterDC, HORZSIZE)의 값이 VERTSIZE의 값보다 더 커진다.

그리고 PrintDlg 함수는 프린터 DC를 생성하면서 PRINTDLG 구조체에다가 DC뿐만 아니라 DEVMODE라는 구조체 내용을 담고 있는 메모리 핸들도 따로 되돌려 주는데, 여기에서 dmOrientation이라는 멤버를 참조하면 용지의 방향을 알 수 있다. (DMORIENT_PORTRAIT 또는 DMORIENT_LANDSCAPE)

물론, 프린터의 용지 방향이 세로이더라도 내 프로그램에서의 용지 방향 설정이 가로로 돼 있으면 이를 감지하여 내가 직접 그림을 90도로 눕히고 돌려서 그리면 된다. 그러면 어차피 가로 방향 인쇄와 동일한 효과를 낼 수 있다.
하지만 그 정도 수고는 워드 프로세서 급에서나 할 일이다. 일반적인 프로그램이라면 그냥 프린터 설정만 따라서 내용을 출력하면 된다.

이렇듯 가로 세로 방향 전환이라는 건 전통적으로 프린터에만 존재하는 개념으로 여겨졌으나, 요즘은 디스플레이 장비에서도 어렵지 않게 찾을 수 있다. 스마트폰은 말할 것도 없고(방향 전환), 모니터도 방향을 전환하는 피벗 기능이 있기 때문이다. 가로로 납작한 모드는 영화를 볼 때 유용할 것이며, 세로로 길쭉한 모드는 문서 편집 내지 코딩을 할 때 유용할 것이다.

4. 인쇄 전담 계층의 분리

요즘 프린터는 한 줄씩 데이터를 받는 족족 타자기처럼 출력물을 토해내는 게 아니라 복사기처럼 최소한 페이지 단위로 동작한다. 운영체제의 인쇄 관리자는 응용 프로그램이 StartDoc ~ EndDoc 사이에서 GDI 함수로 명령을 내린 것들을 마치 메타파일 생성하듯이 모았다가 프린터 드라이버로 보낸다. 그럼 프린터 드라이버는 그걸 프린터가 해석할 수 있는 인쇄 동작으로 바꿔서 기계에다 전송한다.

즉, 응용 프로그램의 입장에서는 인쇄할 데이터를 운영체제의 인쇄 관리자에다가 다 보내 놓기만 하면 명목상 인쇄를 다 마친 것이다. 그 뒤에 실제로 인쇄가 제대로 됐는지, 도중에 종이 부족 같은 문제가 발생하지는 않았는지를 프린터와 통신하며 챙기는 건 인쇄 관리자의 영역이다.
이 인쇄 관리자를 '프린터 스풀러'라고 부르는 편인데, SPOOL은 다른 단어들의 이니셜이다.

문서를 실제로 인쇄하는 것보다야 같은 소프트웨어인 스풀러에게 인쇄 데이터를 파일 형태로 생성하고 전달하는 게 훨씬 더 빨리 되며, 중간에 실패할 일도 거의 없다. 그러니 스풀러가 별도로 분리되어 있으면, 응용 프로그램의 입장에서는 인쇄를 굉장히 빨리 끝마치고 사용자가 프로그램을 사용할 수 있는 상태로 신속하게 복귀할 수 있다.

그 반면, 도스 시절에는 지금처럼 운영체제 차원에서 인쇄 관리자가 제공되는 게 없었다. 그래서 도스용 아래아한글 같은 프로그램은 스풀러 기능을 내부에서 직접 구현해야 했다.
그리고 스풀 옵션을 사용하지 않거나, MB급 단위인 스풀 데이터를 저장할 디스크 공간이 부족하거나, 아예 스풀 기능이 없던 아래아한글 1.x 시절에는...
수십 페이지의 인쇄 명령을 내려놓았다면 다 끝날 때까지 컴퓨터를 사용하지 못하고 기다려야 했다. 스풀러가 아니라 그 느린 프린터로 데이터를 전부 보내야 했기 때문이다. 지금으로서는 도저히 믿을 수 없는 삽질이다.

도스 시절에는 PC 통신 프로그램은 전화를 걸거나 업로드/다운로드를 하는 중에 멀티태스킹을 하는 게 핵심 기술이었다. 그것처럼 워드 프로세서는 인쇄 중의 멀티태스킹이 핵심 기술이었던 셈이다.
1994년에 출시되었던 도스용 아래아한글 2.5는 프린터에다가 인쇄 데이터를 전송하는 방식을 개선해서 인쇄 속도를 크게 향상시켰다고 광고했었는데.. 그 기술 디테일이 무엇이었는지는 개인적으로 지금도 알 길이 없다.

프린터 스풀링용 데이터는 파일의 형태로 인쇄를 한 것이니 '인쇄의 가상화' 결과물이라고 볼 수 있다.
하지만 아무래도 특정 프린터 하드웨어에 지극히 종속적인 형태일 것이므로 pdf나 xps 같은 장치 독립까지 만족하는 전자문서라고 볼 수는 없다.

글쎄, 도스 말고 Windows에서도 3.x + 옛날의 굉장한 구식 도트 프린터의 경우(KS 24핀 180dpi 이러던..-_-), 응용 프로그램에서 인쇄 명령을 내리면 요즘처럼 인쇄 관리자와 해당 프린터 드라이버의 자체 UI가 뜨는 게 아니라 직통으로 바로 인쇄가 시작되기도 했던 것 같다. 거의 30년 가까이 전의 추억이다.

5. 인쇄를 중간에 취소하기

제아무리 인쇄 과정이 가상화돼서 프린터가 아니라 인쇄 관리자에게만 인쇄 데이터를 넘겨주면 된다 하더라도.. 수십· 수백 페이지 분량의 문서를 인쇄하는 건 1초 안으로 호락호락 금방 끝나는 작업이 아니다.
더구나 속도와 별개로 사용자가 인쇄 작업을 중간에 취소할 수 있게도 해 줘야 한다. 현재 페이지만 인쇄하려 했는데 실수로 100페이지짜리 인쇄를 몽땅 시켜 버리는 건 흔히 저지르는 실수이다.

그렇다면 요즘이야 해결책이 아주 간단하다. "전체 x쪽 중 현재 y쪽 인쇄 중"이라는 진행률 게이지와 "취소" 버튼이 달린 대화상자를 modal로 표시한 뒤, 인쇄는 스레드로 진행하면 된다. 인쇄 스레드는 매 페이지의 인쇄가 끝났을 때마다 main UI로부터 취소 버튼의 클릭 여부를 검사하고, 만약 그게 눌렸다면 AbortDoc을 호출해서 인쇄를 취소하고 곧장 빠져나오면 된다.

그런데 문제는 멀티스레드라는 게 존재하지 않던 옛날 16비트 골동품 시절이다. 이때는 실시간 인쇄 상황 표시와 취소 처리를 어떻게 했을까?
그때는 main 스레드가 근성의 idle time processing만으로 UI와 인쇄를 같이 병행해야 했다. 그리고 이를 도와주는 취지의 API가 제공되었다. 그 정체는 SetAbortProc라는 함수이다.

인쇄를 시작하기 전에 프린터 DC에 대해 abort 콜백 함수를 지정해 주면.. 나중에 그 DC를 대상으로 각종 그리기· 조작 명령이 수행된 뒤에 운영체제가 그 콜백을 매번 호출해 준다. 마치 잠수하다가 수시로 수면 위로 잠깐씩 나와서 숨을 쉬는 것처럼 말이다.
이때 콜백 함수가 해야 할 일은 두 가지였다. (1) 큐에 쌓여 있는 메시지를 처리해서 프로그램의 GUI를 돌아가게 하기, 그리고 (2) 혹시 사용자가 인쇄 취소 명령을 내렸는지 검사해서 그 여부를 리턴값으로 되돌리기.

이를 위해서 콜백 함수에는 무려 message loop이 들어가 있어야 했다. 단, 종료 조건을 통상적인 GetMessage로 하지 말고 PeekMessage(... PM_REMOVE)로 지정해야 한다. 전자는 처리해야 할 메시지가 없으면 메시지가 또 생길 때까지 내부적으로 대기를 한다. 하지만 지금 이 콜백은 메시지 처리만 하고 나서 실행을 종료해야 하기 때문이다.

그리고 SetAbortProc을 호출 하기 전에 "인쇄 중..." 대화상자를 표시해 놔야 한다. 이 대화상자는 백그라운드 인쇄 기능과 연계해서 돌아가야 하는 관계로, 응용 프로그램의 자체 message loop을 타는 modeless 형태로 표시돼야 한다. DialogBox 말고 CreateWindow를 쓰라는 뜻이다.
그래도 이 대화상자의 용도는 명백하게 modal이니, 이게 표시된 동안은 parent 프레임 윈도우로 포커스가 옮겨질 수 없게 EnableWindow(hParent, FALSE) 처리도 사용자가 수동으로 해야 한다.

SetAbortProc 같은 메커니즘이 없었다면 인쇄 도중 UI 표시와 취소를 구현하기 위해서 우리가 수동으로 인쇄 루틴의 내부에다 PeekMessage 체크를 집어넣어야 했을 테니 인쇄용 코드와 인쇄 미리보기용 코드조차 동일하게 관리하기가 어렵고 프로그램이 많이 지저분해졌을 것이다. 하지만 abort 콜백 함수를 구현하는 건 과거의 클립보드 chain 관리만큼이나 여전히 몹시 삽질스럽고 번거로운 구석이 있었다.

사용자 삽입 이미지
(MFC 라이브러리가 자체 구현한 "인쇄 중" 대화상자)

옛날 프로그램으로 인쇄를 해 보면.. 잠깐 표시되는 '인쇄 중' 대화상자는 왠지 추레해 보이고 (1) 그 흔한 진행률 게이지 하나 없고, (2) 다른 대화상자들과 달리 중앙 정렬돼서 표시되지 않고(좌측 상단에 치우쳐서) [X] 버튼도 없으며, (3) 반응성이 안 좋아서 종종 응답이 멎기도 하던 이유들이 모두 설명된다.
(1)은 16비트 시절엔 진행률 게이지 공용 컨트롤이 없었기 때문이요, (2)는 modal이 아닌 modeless로 처리됐기 때문, (3)이야 뭐.. idle time processing으로 돌아가니 반응성이 좋지 않은 것이다.

이런 지저분함은 앞서 언급했듯이 멀티스레드가 등장한 뒤에야 과거 유물로 남게 되었다.
하지만 과거에 나왔던 Windows 프로그래밍 책들은 여전히 옛날 전통적인 방식으로 SetAbortProc 기반의 인쇄 절차를 소개하고 있으며, 16비트 시절부터 유구한 전통과 짬을 자랑하는 MFC도 그 구조를 고스란히 따르고 있다.
SetAbortProc가 함수 주소만 받고 함수에다 넘겨줄 데이터를 받지 않는 것도.. 데이터쯤은 그냥 전역변수로 넘겨주던 1980년대 C스러운 사고방식의 결과물이 아닐까 싶다.

참고로 MS Word의 경우, 신기하게도 스풀러에게 인쇄 데이터를 넘겨주는 작업조차도 철저하게 백그라운드로 수행된다. 즉, "인쇄 중" 대화상자 자체가 표시되지 않으며, "몇 페이지 인쇄 중"이라는 말은 아래의 상태 표시줄에 표시된다. 그 와중에도 문서 편집과 수정이 가능하고 프로그램을 온전하게 사용할 수 있다. 이런 프로그램도 얼마든지 만들 수 있다.;;

Posted by 사무엘

2020/10/23 08:35 2020/10/23 08:35
, , , ,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/1811

« Previous : 1 : 2 : 3 : 4 : 5 : ... 12 : Next »

블로그 이미지

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

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2021/09   »
      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:
1652261
Today:
100
Yesterday:
436