Windows Batch Script

이 블로그에는 세 개의 카테고리가 있다. ‘개(Dog)’, ‘발(Foot)’, ‘인생(Life)’이 그것인데, 순서대로 ‘어떠한 것에 대한 설명’, ‘실수한 경험’, ‘코드가 없는 이야기’라는 의미를 담아두었다. 대충 그렇다.

이번에 쓰려던 것은 사실, ‘개(Dog)’카테고리에 넣으려고 했던 것이다. 이것이 ‘발(Foot)’카테고리에 들어가게 된 데에는 아래와 같은 사연이 있다.

회사에는 수십 대의 서버가 있고, 거기에는 수시로 로그가 쌓이게 된다. 그 양이 꽤 많아서, 몇 달 지나면 금새 하드디스크가 다 차버리게 되는데, 어찌된 영문인지 이를 자동으로 지워주는 기능이 없었다.

서버는 윈도 서버였으며, 로그는 최근 세 달의 것만 유지하도록 하고자 했다. 어려울 거 없다. 세달전의 로그를 지우는 스크립트 혹은 프로그램을 주기적으로 실행해주기만 하면 된다.

윈도에는 리눅스의 crontab과 비슷한 Task Scheduler가 있으며, crontab과 비슷한 형태로 실행하려는 명령을 등록할 수 있다. Manager프로그램을 이용해서 Task를 등록하려면, 이를 실행할 계정정보를 명시해야 한다. 개발자가 공통으로 사용하는 계정은 몇 달 간격으로 비밀번호를 변경하는데, 이것이 Task Scheduler가 등록한 Task를 실행하지 못하는 원인이 되므로, 다른 개발자가 주기적으로 명령어를 실행하는 프로그램을 윈도 서비스로 만들어 놨다. 그리곤, 그걸 써보랜다.

‘MS가 바보도 아니고, 그런걸 생각하지 못했을 리 없잖아!. 게다가, 당신이 만든 그 서비스를 등록하는 것도 귀찮다고!’라고 속으로만 생각했다. 왜냐면, 그 사람이 한참 상사였으니깐.

AT라는 Command Line명령으로 Task Scheduler에 Task를 등록하면, 기본적으로(Manager프로그램에서 변경할 수 있다) System계정이 사용된다. 즉, 그냥 AT명령어 사용하면 된다. 몇 번만 구글링해보면 나온다. 한번은 부족할 수 있다.

로그를 지우는 스크립트는 Windows Batch파일로 작성했다. 이 과정이 길었다. Batch파일의 문법은 제약이 많고, 표현도 풍부하지 못하다. 결정적으로, 난 거의 모른다. 그래도 굳이 Batch파일을 사용한 이유는, 누구나 소스를 보고 수정할 수 있고, 서버에 설치해야 할 프로그램이 없다는 점 때문이었다. 어줍잖은 오픈 마인드.

도저히 익숙해지지 않을법한 문법도 문제였지만, 더 큰 문제는 날짜간 연산을 지원하지 않는 관계로, 대학교 숙제 생각하며 윤년 계산하며 이를 직접 구현해야 했다는데 있다. 그래서…, Ruby로 했으면 30분이면 끝났을 일을 하루 종일 했다.

최종적으로 스크립트를 복사하고 Task Scheduler에 등록하는 등의 일련의 과정은 Ruby를 이용해서 만들고, 이를 OCRA로 exe 변환, 서버에서 실행/설치하는 것으로 마무리 지었다.

당연히 테스트 했고, 날짜계산도 잘 되고…, 뭐 암튼 다 잘 되었다. 처음 작성한 Batch파일에 문제가 없음에 스스로 대견스러웠다.

여기까지였으면 ‘개(Dog)’카테고리에 이 글을 넣었을 거다.

문제는 정확히 2013년 2월 8일 발생했다. 스크립트는 1월 25일부터 매일 문제없이 돌고 있었다. 2월 8일까지는 그랬던 거다. 2월 8일 새벽에…, 스크립트는 모든 로그를 지워버렸다. 젠장! 이 한마디는 결국, 이 포스트가 ‘발(Foot)’카테고리에 들어가 되었음을 의미한다.

난, 이런 경우 심한 자괴감에 빠져든다. 물론, 남들 모르게.

역시나 날짜를 계산하는 부분이 썩어있었다. 날짜는 연월일을 포함한 8자리 문자열로 구성되는데, 90일 이전의 날짜를 구하기 위해서 이를 연, 월, 일로 분리한다. 이를 Batch파일에서는 다음과 같이 처리한다.

SET /A "year=%from:~0,4%"
SET /A "month=%from:~4,2"
SET /A "day=%from:~6,2"

짐작하듯이, 8자리 문자열을 4/2/2 분할하여 이를 숫자로 각 변수에 저장한다. /A옵션은 문자열을 숫자로 인식하도록 한다. 예를 들어 문자열 04는 숫자로 인식되어 4로 저장되게 된다. 물론, Batch에는 타입이 존재하지 않으므로, 4는 다시금 문자열로 다룰 수 있다.

이때, 모르고 넘어가면 안될 결정적인 부분이 있는데, 내가 놓친 것이 이것이다.

Batch파일에서 문자열을 숫자로 변환할 때, 문자열이 0으로 시작하면 이는 무조건 8진수로 인식한다. 따라서, 문자열 08은 유효하지 않은 숫자가 된다. 8진수는 0~7의 숫자만 사용하니깐! 그래서 2월 8일 문제가 생긴 거다. 중간에 에러가 난 거지. 그래서 잘못된 날짜를 갖고 현재날짜와 비교해서 로그를 지우는 바람에, 모든 로그를 몽땅 지워버린 거라고.

수정 후 위 코드는 다음과 같이 바뀌었다.

SET /A "year=%from% / 10000"
SET /A "month=%from% %% 10000 / 100"
SET /A "day=%from% %% 100"

%%는 mod연산이다. 원 날짜 값을 문자열로써 파싱하지 않고, 그냥 산술 식으로 계산해서 변환시켰다. 진작 이럴걸 후회해도 이미 로그는 다 지워졌고, 고객문의는 처리되지 못할 뿐이었다.

덕분에 사건 다음 출근 일에는 하루 종일 입이 부르트도록 로그파일에 인공 호흡했다. 다행히 대부분은 살아나서 우울증은 걸리지 않았다. 그리고 결심했다. Windows Batch파일은 더 이상 사용하지 않으리!

WriteFile() 이 느리다구요?

새로운 프로젝트를 진행 중, 내부 망에서 진행한 테스트였음에도 반응속도가 더뎌지는 경우가 발생했다. 다행히 그 원인을 발견하는 데는 오랜 시간이 걸리지 않았는데, 다름아닌 사내 로깅(Logging) 라이브러리가 그 주범이었다. 안타깝게도, 라이브러리 담당자도 원인을 찾아주지는 못했다. 이 프로젝트는 철저히 혼자 만드는 것이었고, (존재한다면 반드시 사내 라이브러리를 사용해야 한다는 등의)기술적인 간섭 받지 않았기 때문에 로깅 라이브러리를 간단히 새로 만들었고, 결과는 만족스러웠다. 물론, 사내 라이브러리에 대한 불신은 좀 더 커졌다.

기존 프로젝트에도 꾸준히 문제되는 부분이 있었는데, 이 역시 다름아닌 로깅이었다. 이 프로젝트에서 사용되는 로깅 라이브러리는 위에서 언급했던 사내 로깅 라이브러리보다 더 오래된 것으로, 큰 문제가 없어서 그냥 사용하고 있었지만, 로그 양이 갑자기 증가하는 경우에는 문제가 발생했다. 결국, 기존의 소스코드를 약간 수정해서 성능이 개선되는 것을 확인했다.

수정한 부분은 기본 API인 WriteFile() 함수대신 fputs() 함수를 사용한 것인데, 선뜻 이해가 되지 않았던 것은, fputs() 함수도 결국 기본 API를 사용하므로, 성능이 느려지면 느려져야지, 빨리질 수 없다는 사실이었다. 그러나 분명히 성능이 개선되었으므로 소스를 커밋했다.

수정된 소스가 반영되어 세상에 나가기 이틀 전. 전체회의에서 ‘WriteFile() 함수가 fputs() 함수보다 느릴 수 없다’라는 이유로, 소스 적용이 취소되었고, 난 다시 원인을 찾기 시작했다.

내가 가장 의심했던 부분은, WriteFile()함수와 CreateFile() 함수에 붙어있는 무수한 플래그였다. 특히, WriteFile() 함수를 중심으로 살펴봤는데, 별다른 문제를 발견하지 못했다. 그렇지만, 역시나 대부분의 시간은 이 함수가 소모하고 있었다.

결국, 파일과 관련되지 않은 다른 모든 부분을 제거했다. 여전히 느렸고, 따라서 다른 부분은 문제가 아니라고 확신할 수 있었다.

그 전에, 내가 만든 로깅 라이브러리의 파일 입출력 부분을 모두 WriteFile() 함수를 사용하여 시간을 재어보니, 속도가 미미하게나마 더 빨라졌다. 당연히 이래야 하는 거잖아~!?

시간을 되돌려 다시 CreateFile() 함수와 WriteFile() 함수를 살펴보고 있었다. 그리고, 바로 전에 테스트 한 부분과의 차이가 눈에 들어왔다. 내가 만든 라이브러리에서 CreateFile() 함수를 사용할 때는 FILE_FLAG_WRITE_THROUGH를 사용하지 않았다.

다시 테스트해본 결과, 문제의 원인은 FILE_FLAG_WRITE_THROUGH이 확실했다. 구세주 MSDN과 StackOverflow의 글을 살펴본 결과 위 플래그의 역할은 다음과 같다.

C API를 사용하여 파일에 데이터를 기록하려고 할 때, 기본적으로 flush()함수를 호출하지 않으면 파일에 데이터가 기록되어있다는 것을 보장할 수 없다. 이는 쓰려는 데이터가 어딘가에 임시로 저장되어 있다는 것이며, 이로 인해 성능(속도)가 증가할 수 있다. FILE_FLAG_WRITE_THROUGH 플래그는 이 동작을 제어하며, 이 플래그를 사용하여 파일을 생성하였을 경우, WriteFile() 함수는 데이터를 버퍼링하지 않고 바로 디스크캐시로 보낸다. 그러나 디스크캐시로 보낸다는 것이 물리 디스크에 기록하는 것과 동일한 의미는 아니며, 이를 위해서는 추가로 FILE_FLAG_NO_BUFFERING 플래그를 사용해야 한다. 그러면, WriteFile() 함수의 종료 후, 데이터가 물리 디스크에 저장되었다고 보장할 수 있으며, 이에 대한 트레이드오프로 성능(속도)이 떨어지게 된다. 테스트결과, FILE_FLAG_WRITE_THROUGH 플래그만으로도 수 백배의 성능차이가 발생한다.

아마도 라이브러리 개발자는 버퍼링되는 로그가 파일에 남지 않게 될 것을 우려해서 FILE_FLAG_WRITE_THROUGH 플래그를 기본 옵션으로 설정해 둔 듯 하나, 엄밀하게는 FILE_FLAG_NO_BUFFERING 플래그도 함께 설정해야 했다.

그래도, 로깅의 성능은 상당히 크리티컬한 부분인 만큼, 그 의도는 납득하기 어려우며, 오히려 flush() 함수와 같은 인터페이스를 만드는 것이 옳지 않았을까 싶다.

결론: 네가 이해 못하는 코드는 있어도, 이해할 수 없는 현상이 발생하는 코드는 없다.

참고사이트 MSDN, StackOverflow

비트필드의 크기(Size of Bit-fields)

union U_Data
{
    unsigned int _key;
    struct S_Data
    {
        unsigned short _code;
        unsigned short _no;
    } _evtkey;
};

개발자는 두 값(code, no)를 조합해서 고유한 키값을 손쉽게 만들기 위해 U_Data를 아래와 같이 사용하고 있다.

U_Data data;
data._evtkey._code = 10;
data._evtkey._no = 4;
unsigned int another_key = data._key; // 고유한 키값을 손쉽게(?) 얻었다!

그런데, _code의 크기가 100,000을 넘어가게 생겼다. 그래서 타입을 int로 바꿔야 했다. 이미 _key는 unsigned int 타입으로 여러 함수의 인자로 사용되고 있기 때문에, U_Data의 현 크기(4바이트)를 바꾸기는 쉽지 않다.

다행히 _no의 범위는 기껏해야 수십을 넘어가지 않았으므로, 비트필드를 사용하여, _code에 24비트를, _no에 8비트를 사용하기로 하였다.

union U_Data
{
    unsigned int _key;
    struct S_Data
    {
        int _code:24; // 타입을 바꾸고, 24비트를 할당
        unsigned short _no:8; // 8비트를 할당
    } _evtkey;
};

다른 코드는 손대지 않고, 손쉽게 _code의 크기를 늘렸다. 근데, 제대로 동작하지 않는다. 앞선 예제와 반대로, _key를 이용해서 _code와 _no를 구하려고 하니, _no에는 쓰레기 값이 있다.

이유는, 1바이트 패킹 시(#pragma pack(1)), VC++에서, U_Data의 크기를 4바이트가 아닌, 6바이트로 다루기 때문이며, 비트필드가 어떻게 메모리에 할당되는지에 대해서는 표준문서가 명시하고 있지 않다.

Allocation of bit-fields within a class object is implementation-defined. Alignment of bit-fields is implementation-defined.

– ISO/IEC 14882:2011 9.6.1

VC++에서는 비트필드가 명시된 타입에 순차적으로 값을 구겨 넣으며, 타입이 변경되면 새로운 타입에 새롭게 할당을 시작한다. 즉, S_Data의 첫번째 데이터 타입이 int이므로 4바이트에 24비트를 할당하고, 그 다음 2바이트인 unsigned short타입에 8비트를 할당해서, 총 6바이트가 사용된다. int타입인 _code에 8비트가 남았다고 해서 _no를 int에 할당하는 것이 아니란 이야기다(gcc는 4바이트를 할당한다). 명시적으로 새로운 할당을 시작하려면 비트의 크기에 0을 넣으면 된단다(http://msdn.microsoft.com/en-us/library/ewwyfdbe.aspx).

이 문제는 _code와 _no의 타입을 int로 맞춰주는 것으로 해결된다.

문제를 해결하고, 기억을 되살려보니, 정확히 2005년 10월 4일에 이와 똑같은 문제를 겪고 남겨둔 기록이 있었다.


struct T1
{
    int m_a : 5;
    int m_b : 6;
    int m_c : 5;
};
struct T2
{
    short m_a : 5;
    short m_b : 6;
    short m_c : 5;
};
struct T3
{
    char m_a : 5;
    char m_b : 6;
    char m_c : 5;
};

1로 패킹했을 경우(#pramgma pack(1)), 각 구조체의 크기는 각 각 얼마일까?

만약, sizeof()의 결과로 2를 기대했다면, T1과 T3는 잘못된 결과를 야기한다.

T1의 경우, sizeof()의 결과는 4이다. 총 비트의 합은 16이지만, 첫 번째 멤버변수의 크기가 4이기 때문에 컴파일러는 모든 비트를 첫 번째 변수의 크기인 4바이트에 구겨 넣는다. 나머지 16비트는 사용되지 않는다.

T2의 경우는 2이다. 16비트는 short int형에 딱 맞게 들어간다. 따라서 낭비하는 비트는 없다.

T3의 경우는 3이다. 컴파일러는 char의 크기인 1바이트에 5비트를 우선 구겨 넣는다. 그 다음 6비트를 확보해야 하는데, 첫 번째 1바이트에는 3비트밖에 남은 공간이 없으므로 또 하나의 char형 크기인 1바이트에 6비트를 확보한다. 마찬가지로 그 다음 5비트는 적절한 공간이 없으므로 새로운 char형 크기인 1바이트를 또 할당한다. 그래서 총 3바이트 크기가 되며, 8비트를 사용하지 않은 채 남겨두게 된다.

대부분의 컴파일러는 이와 같은 방식으로 동작한다. 그러나 표준문서에 비트필드의 크기에 대한 언급은 없기 때문에 엄밀히 따지면 이는 컴파일러의 구현에 따라서 틀려질 수 있다. 참고로, 비트필드에 접근하는 속도는 표준타입의 멤버변수에 접근하는 시간보다 느리기 때문에, 속도를 위해서라면 바람직한 방법은 아니다.

Authlogic 사용하기

Authlogic이 쉽고 간단하단다. 그래서 썼다. Railscasts #160 Authlogic를 따라 하는 데, 이거 영 안 된다. 그렇다. 난 레일즈 초보다. ㅠㅠ

Authlogic은 사용자세션을 ActiveRecord클래스와 동일한 방법으로 다룰 수 있도록 Authlogic::Session::Base클래스를 제공한다. 이 클래스를 자동으로 생성하기 위해서는 generate스크립트를 사용할 때 generator로 session을 지정하면 된다.

$ ruby script/generate session user_session

이는 사용자의 로그인/로그아웃을 ActiveRecord클래스가 데이터를 추가하거나 삭제하는 것처럼 처리할 수 있게 해주며, 실제 사용자 정보를 가질 (진짜 ActiveRecord를 상속한)모델클래스도 필요하다. 이 모델클래스를 Authlogic가 사용하게 하기 위해서는 클래스에 acts_as_authentic지시자를 추가해 주어야 한다.

class User < ActiveRecord::Base
  acts_as_authentic
end

데이터모델클래스의 칼럼에는 string타입의 persistence_token이 존재해야 하며, 이 외에 다음과 같은 값을 사용할 수 있다.

t.string    :login,               :null => false                # optional, you can use email instead, or both
t.string    :email,               :null => false                # optional, you can use login instead, or both
t.string    :crypted_password,    :null => false                # optional, see below
t.string    :password_salt,       :null => false                # optional, but highly recommended
t.string    :persistence_token,   :null => false                # required
t.string    :single_access_token, :null => false                # optional, see Authlogic::Session::Params
t.string    :perishable_token,    :null => false                # optional, see Authlogic::Session::Perishability

# Magic columns, just like ActiveRecord's created_at and updated_at. These are automatically maintained by Authlogic if they are present.
t.integer   :login_count,         :null => false, :default => 0 # optional, see Authlogic::Session::MagicColumns
t.integer   :failed_login_count,  :null => false, :default => 0 # optional, see Authlogic::Session::MagicColumns
t.datetime  :last_request_at                                    # optional, see Authlogic::Session::MagicColumns
t.datetime  :current_login_at                                   # optional, see Authlogic::Session::MagicColumns
t.datetime  :last_login_at                                      # optional, see Authlogic::Session::MagicColumns
t.string    :current_login_ip                                   # optional, see Authlogic::Session::MagicColumns
t.string    :last_login_ip                                      # optional, see Authlogic::Session::MagicColumns

또한, 이 모델클래스가 Authlogic의 세션모델과 연동되기 위해서는 세션모델의 이름이 데이터모델의 이름에 Session이 덧붙은 형태여야 한다. 즉, 데이터모델이 User라면, 세션모델의 이름은 UserSession이어야 하는 식이다.

이렇게 Authlogic의 사용준비를 마쳐서 User라는 데이터모델클래스와 UserSession라는 세션모델클래스가 마련되었다면, 사용자의 계정정보는 User클래스를 사용하여 저장하고, 세션정보는 UserSession클래스를 사용하여 다룰 수 있게 된다.

음, 이렇게 쓰고 보니 정말 쉬운 것 같기도 하다. – -;

RoR: remote_form_for와 테이블

루비온레일즈에서 테이블의 별도의 셀에 입력 폼을 나눠 배치했을 때, form_for를 사용하면 문제가 없었지만, remote_form_for를 사용하면 파라미터 값이 넘어오지 않는 문제가 있어서 엄청나게 삽질했다. 인터넷에 검색해보니 다음과 같은 내용이 있다.

Tables and forms can be nested either way. But if you put forms into tables, each form must be completely included into a single table cell (one TD element in practice). Thereby the forms are each completely independent.

결국, 테이블 밖으로 remote_form_for 블록을 빼니 파라미터 전달이 잘 된다. form_for는 왜 문제가 없는지 잘 모르겠지만, 테이블과 폼을 중첩시키는 것이 옳은 방법은 아닌 듯하다.

웹이라는 또 다른 패러다임에 적응하느라 삽질 중. 낯선 것이 너무 많다.

Secure CRT Functions

Visual C++ 2005에서는 CRT함수들이 ‘_s’ 꼬리를 달고 새로 작성됐다. 대표적인 특징이라면, 버퍼의 크기를 파라미터로 넘겨준다는 것인데, 예를 들어 memcpy함수의 경우 destination buffer의 크기를 명시하는 값이 파라 미터로 추가되는 식이다. 따라서 memcpy_s함수의 프로토타입은 다음과 같이 선언된다.

감히, MSDN을 무시하고 이런 함수를 사용한다면…, 적어도 나의 경우에는, 당연히 Buffer Overflow를 방지할 수 있도록 수정된 것이라고 기대했다. 나라면 분명히 그렇게 했을 것이다. 그러나 Microsoft는 그렇게 하지 않았다.

It should also be noted that the secure functions do not prevent or correct security errors; rather, they catch errors when they occur. They perform additional checks for error conditions, and in the case of an error, they invoke an error handler. ((Security Enhancements in the CRT , MSDN))

즉, 에러를 보안오류를 해결해주진 않고, 그 에러에 대한 처리를 할 수 있도록 해준다는 것이다. MSDN을 좀 더 살펴보면, _set_invalid_parameter_handler함수를 사용한 예제가 나오긴 한다. 근데, 그렇게 하는 게 정말 좋은 거야?

결론…,
감히 MSDN을 무시해? 넌 당해도 싸!

Windows도 못 죽이는 Window

한 명이 그런 말을 했을 때는, 분명히 다른 이유가 있을 것이라고 생각했다. 그런데 그런 말을 하는 사람이 점점 늘어났다. 그렇지만, 찌질한 내 어플리케이션 때문에 Windows(Window XP)를 종료할 수 없다는 게 말이나 되나?

언제 마지막으로 껐는지 기억나지 않는 내 컴퓨터의 종료아이콘을 클릭했다. 다른 프로그램들이 죽어도 나의 찌질이는 죽지 않고, Windows도 종료되지 않았다. 젠장~

쓰레드를 죽이지 않아서? 트레이아이콘에 내가 모르는 뭔가가…? 문제는 FormClosing이벤트에 있었다.

다음은 폼의 종료아이콘을 클릭할 경우 폼을 숨기고, 트레이아이콘의 메뉴를 클릭할 경우 폼을 진짜로 종료하는 ‘아주 일반적이라고 생각했던’ 코드이다.

혼자서 테스트 할 때는 잘 됐다. 문제는, Windows를 종료해보는 테스트까지는 해보지 않았다는 거… 그리고, 그런 건 당연히 종료되어야 하는 거 아냐?

문제는 FormClosing이벤트의 파라미터인 FormClosingEventArgs에 CloseReason이라는 멤버를 몰랐다는 것이다. 이는 폼이 어떻게 해서 종료요청을 받았는지를 알려주며, 그 중에는 WindowsShutDown이라는게 있어서, Windows가 요청했는지도 알 수 있다. 사용자가 명시적으로 폼을 닫을 경우에는 CloseReason이 UserClosing가 된다.

코드를 다음과 같이 수정하니, 윈도우가 무사히 종료된다.

일단, 이렇게 문제가 일단락 되기는 했지만…, Windows란 놈, 생각보다 약하다.

RegCreateKeyEx() & Windows 2000

Windows XP와 Windows Vista에서는 아무런 문제가 없는 VC++ 2005로 작성된 프로그램이 Windows 2000에서만 런타임에러를 토해버린다. Micorsoft를 싸잡아 욕하면서 꽤 많은 시간을 삽질한 결과…, 역시나 내 실수다.

out 파라미터인 phkResult가 NULL인 경우, 이러한 예외상황을 Windows XP와 Windows Vista에서는 적절히 처리하는데 반해서 Windows 2000은 그렇지 못하다.

MSDN이 말하지 않았으면, 하지 말라는 거다!