반응형
[API 프로그래밍][MFC 프로그래밍][ATL 프로그래밍][VB 프로그래밍][HTML/ASP 프로그래밍]

API 프로그래밍에 대한 Q&A입니다.

1. 특정 디렉토리 뒤지기

지정한 디렉토리에 있는 모든 파일을 찾아내는 코드를 만들려면 어떻게 해야 합니까 ?

이 때 사용할 수 있는 API가 바로 FindFirstFile과 FindNextFile, FindClose라는 API들입니다. 사용 예제는 다음과 같습니다.

	WIN32_FIND_DATA  findFileData;
	HANDLE hFileHandle;

	// szDir에 뒤지고자 하는 디렉토리의 경로명을 준다. 예를 들면 "C:\\TEMP\\*.*"
        // 찾아진 파일의 속성은 findFileData의 dwFileAttributes를 살펴본다.
	hFileHandle = FindFirstFile(m_szDir, &findFileData);  
	if (hFileHandle != INVALID_HANDLE_VALUE)   // 파일을 찾은 경우 
	{
		// 찾은 파일의 이름은 cFileName 필드로 들어온다.
		...
		// 다음 파일을 찾는다.
		while(FindNextFile(hFileHandle, &findFileData)) 		{
			...
		}
		FindClose(hFileHandle);
	}     

2. API를 이용하는 유니코드와 ANSI 문자열간의 변환 방법

API를 이용해서 유니코드와 ANSI 문자열간의 변환은 어떻게 수행합니까 ?

Visual C++에서 유니코드 문자열은 BSTR이란 타입으로 표시됩니다. 또 유니코드와 ANSI 문자열간의 변환을 위해서 윈도우 시스템에는 MultiByteToWideChar와 WideCharToMultiByte라는 API가 존재합니다. MFC에서의 BSTR 타입 변환방법이나 ATL로 하는 BSTR 타입 변환도 참고하시기 바랍니다.

  • ANSI 문자열에서 유니코드로의 변환 방법
    	// sTime이란 ANSI 문자열을 bstr이란 이름의 유니코드(BSTR 타입) 변수로 변환
    	char sTime[] = "유니코드 변환 예제";
    	BSTR bstr;
    	// sTime을 유니코드로 변환하기에 앞서 먼저 그 길이를 알아야 한다.
    	int nLen = MultiByteToWideChar(CP_ACP, 0, sTime, lstrlen(sTime), NULL, NULL);
    	// 얻어낸 길이만큼 메모리를 할당한다.
    	bstr = SysAllocStringLen(NULL, nLen);
    	// 이제 변환을 수행한다.
    	MultiByteToWideChar(CP_ACP, 0, sTime, lstrlen(sTime), bstr, nLen);
             // 필요없어지면 제거한다.
             SysFreeString(bstr);
    
  • 유니코드에서 ANSI 문자열로의 변환 방법
    	// newVal이란 BSTR 타입에 있는 유니코드 문자열을 sTime이라는 ANSI 문자열로 변환
    	char *sTime;
             int nLen = WideCharToMultiByte(CP_ACP, 0, newVal, -1, sTime, 0, NULL, NULL);
             sTime = malloc(nLen+1);
    	WideCharToMultiByte(CP_ACP, 0, newVal, -1, sTime, 128, NULL, NULL);
            // 필요없으면 메모리를 제거한다.
            free(sTime);
    
  • 유니코드 문자열을 UTF-8으로 변환하기
         WideCharToMultiByte 함수를 호출할 때 첫 번째 인자로 CP_UTF8을 지정하면 된다. UTF-8은 유니코드의 인코딩 스킴 중의 하나로 쉽게 말하자면 문자열 스트림에서 0을 빼고 표현하는 방법이라고 볼 수 있다.
    

    3. 레지스트리 읽기/쓰기

    API를 이용해서 레지스트리에 한 항목을 생성하거나 기존 항목의 값을 읽어들이려면 어떻게 해야합니까 ?

    레지스트리 관련 API를 사용하려면 winreg.h라는 헤더 파일을 소스에 포함해야 합니다. 레지스트리에 키를 생성하는 방법과 레지스트리에 존재하는 키의 값을 읽는 방법을 차례로 살펴보겠습니다.

  • 레지스트리 키 생성 예제
    	// 예를 들어 HKEY_LOCAL_MACHINE밑의 System\CurrentControlSet\Services\GenPort라는 키를
            // 생성하고 거기에 DWORD 타입의 값으로 Type을 만들고 문자열 타입의 값으로 Group
            // 을 만들어 본다.
    	#include "winreg.h"
    	LONG error = 0;
    	HKEY hKey;
    	DWORD dwDisp, dwData;
    	char lpData[] = "Write this down";
    
    	// 먼저 만들려는 키가 이미 존재하는 것인지 살혀본다.
    	error = RegOpenKeyEx(HKEY_LOCAL_MACHINE, "System\\CurrentControlSet\\Services\\GenPort",
                             0, KEY_ALL_ACCESS, &hKey);
    
    	if (error != ERROR_SUCCESS) // 없다면 새로 생성한다.
    	{
    		// 키를 생성한다.
    		error = RegCreateKeyEx(HKEY_LOCAL_MACHINE,
    			"System\\CurrentControlSet\\Services\\GenPort",	0, "REG_BINARY", 
    		        REG_OPTION_NON_VOLATILE, KEY_ALL_ACCESS, 0, &hKey, &dwDisp);
                    // 위의 키 밑에 Type이란 DWORD 타입의 값을 만들고 1로 초기화
    		dwData = 0x1;
    		error = RegSetValueEx( hKey, "Type", 0, REG_DWORD,&dwData,4); 
                    // 위의 키 밑에 Group이란 문자열 타입의 값을 만들고 lpData의 값으로 초기화
    		error = RegSetValueEx( hKey, "Group", 0, REG_SZ, lpData, strlen(lpData)); 
    
                    // 키를 닫는다.
    		RegCloseKey(hKey);	
    	}
    
  • 기존의 레지스트리 키에서 값 읽기
    	// HKEY_CURRENT_USER\Software\Netscape\Netscape Navigator\Main 밑의 Install Directory
            // 값의 문자열 값을 읽어들인다.
    	DWORD dwType, cbData;
    	HKEY hSubKey; 
    	long lRet;
    	char pszString[255];
    
    	// 키를 오픈한다.
    	if ((lRet = RegOpenKeyEx(HKEY_CURRENT_USER, 
    	                "Software\\Netscape\\Netscape Navigator\\Main",
    			0, KEY_READ | KEY_QUERY_VALUE , &hSubKey)) == ERROR_SUCCESS)
    	{
    		cbData = 255;	// 문자열 값을 읽어올 데이터의 크기를 준다.
    		if ((lRet = RegQueryValueEx(hSubKey, "Install Directory",
    			NULL, &dwType, pszString, &cbData)) == ERROR_SUCCESS)
    		{
    			// 제대로 읽힌 경우
    		}
    		else
    		{
    			// 에러가 발생한 경우
    		}
    		RegCloseKey(hSubKey);
    	}
    
  • 레지스트리 키 삭제하기 - RegDeleteKey 함수를 사용한다.

    4. 윈도우 탐색기로부터의 Drag&Drop을 받으려면

    윈도우 탐색기로부터 제가 만든 윈도우로의 drag&drop이 가능하게 하려면 어떻게 해야 합니까 ?

    다음 순서를 따라서 프로그래밍하시면 됩니다.

    1. 프로그램의 초기화시에 DragAcceptFiles(hWnd, TRUE) 함수를 호출한다. 첫 번째 인자인 hWnd는 드롭의 타겟이 되는 윈도우의 핸들이다.
    2. 탐색기로부터 파일이 드롭되는 순간에 WM_DROPFILES 메시지가 날라온다. 이를 처리한다.
      	case WM_DROPFILES :
      	{
      		POINT pt;
      		// 어느 위치에 드롭되었는지 그 항목을 알아낸다.
      		if (DragQueryPoint((HDROP)wParam, &pt)) 
      		{
      			UINT i = 0;
      			// 모두 몇 개의 파일이 드롭되었는지 알아낸다.
      			// 만일 폴더가 드롭되었다면 폴더의 이름만 넘어온다.
      			UINT uCount = DragQueryFile((HDROP)wParam, 0xFFFFFFFF, NULL ,0);
      
      			for(i = 0;i < uCount;i++)
      			{
      				// 드롭된 파일의 이름을 알아온다.
      				DragQueryFile((HDROP)wParam, i, buffer ,255);
      				// 드롭된 파일 이름을 출력해본다.
      				MessageBox(hWnd, buffer, "File Name", MB_OK);
      			}
      		}
      		// drag and drop 작업을 끝낸다.
      		DragFinish((HDROP)wParam);
      		break;
      	}
      
    3. Drag&drop을 더 사용할 필요가 없어지면 DragAcceptFiles를 호출한다.
      	DragAcceptFiles(hWnd, FALSE);
      

    5. 시스템의 모든 드라이브 알아내기

    현재 시스템에 붙어있는 모든 드라이브(네트웍 드라이브 포함)에 대한 정보를 알아내고 싶습니다.

    1. GetLogicalDriveStrings로 시스템에 마운트되어있는 모든 드라이브 정보를 알아낸다. 두 번째 인자인 buffer로 드라이브 정보가 들어오는데 그 구조는 c:\,d:\과 같은 형식이며 리턴값으로 그 버퍼의 크기가 들어온다.
      	char buffer[256];
      	DWORD dwRet;
      	LPSTR token;
      
      	dwRet = GetLogicalDriveStrings(256, buffer);
      
    2. 루프를 돌면서 드라이브별 정보를 알아낸다. 이 때는 GetVolumeInformation 함수를 이용한다.
      	token = buffer; // token이 지금 처리해야할 드라이브를 가리킨다.
      	while (dwRet > 0)
      	{
      		DWORD FileSystemFlag;
      		char FileSystemName[64];
      				
      		strcpy(DriveString, token);
      		// VolumeName으로 드라이브에 대한 설명 문자열이 넘어온다.
      		if (GetVolumeInformation(token, VolumeName, 255, NULL, NULL, 
                                &FileSystemFlag, FileSystemName, 63))
      		{
      	        // 원하는 작업을 수행한다.		
      		}
      		dwRet -= (strlen(token)+1);
      		token = token + strlen(token)+1; // 다음 드라이브로 진행한다.
      	}
      

    6. 드라이브/디렉토리/파일의 이미지 리스트 인덱스 얻기

    특정 드라이브/디렉토리/파일이 시스템 이미지 리스트에서 어떤 인덱스를 갖는지 알고 싶습니다.

    각 파일이나 드라이브 및 디렉토리에 대한 정보는 Shell 라이브러리에서 제공해주는 SHGetFileInfo 함수를 이용하면 됩니다. 다음의 함수는 첫 번째 인자인 lpFileName으로 주어진 파일에 대한 설명을 두 번째 인자로 받아오고 세 번째 인자로는 시스템 이미지 리스트에서의 인덱스를 얻어옵니다.

    	void GetFileInfo(LPSTR lpFileName, LPSTR lpDesc, int *nIndex)
    	{
    	    DWORD dwAttr;
    	    SHFILEINFO sfi;
    
    	    int hIcon = SHGetFileInfo(lpFileName, dwAttr, &sfi, sizeof(SHFILEINFO), 
    				SHGFI_TYPENAME | SHGFI_SYSICONINDEX); 
    	    *nIndex = sfi.iIcon;
    	    strcpy(lpDesc, sfi.szTypeName);
    	}
    

    7. 리스트 컨트롤에 칼럼 헤더 넣기

    리포트뷰 형식의 리스트 컨트롤에 컬럼 헤더를 집어 넣으려면 어떻게 해야합니까 ?

    	// <문서명, 등록날짜, 상태> : 3개의 헤더를 만든다.
    	LV_COLUMN col;
    
    	col.mask = LVCF_FMT | LVCF_TEXT | LVCF_WIDTH;
    	col.fmt = LVCFMT_LEFT;
    	col.cx = 100;
    	col.pszText = "문서명";
    	col.cchTextMax = strlen(col.pszText);
    	ListView_SetColumn(hListView, 0, &col); 
    
    	col.mask = LVCF_FMT | LVCF_TEXT | LVCF_WIDTH;
    	col.fmt = LVCFMT_LEFT;
    	col.cx = 100;
    	col.pszText = "등록날짜";
    	col.cchTextMax = strlen(col.pszText);
    	ListView_InsertColumn(hListView, 0, &col); 
    
    	col.mask = LVCF_FMT | LVCF_TEXT | LVCF_WIDTH;
    	col.fmt = LVCFMT_LEFT;
    	col.cx = 100;
    	col.pszText = "상태";
    	col.cchTextMax = strlen(col.pszText);
    	ListView_InsertColumn(hListView, 1, &col); 
    

    8. 리스트뷰에 항목 삽입하기

    리스트뷰에 한 항목을 추가하고 싶습니다.

    	// 이미지 리스트와 부가 정보를 사용하지 않는 리스트뷰 컨트롤이다.
    	int nIndex;
    	LV_ITEM item;
    
    	// - 첫번째 컬럼 -
    	item.mask = LVIF_TEXT; // 이미지 리스트를 사용하려면 LVIF_IMAGE를 추가하고
                                   // 부가정보를 지정해야할 일이 있다면 LVIF_PARAM을 추가한다.
    	item.pszText = lpDocName;
    	item.cchTextMax = strlen(lpDocName);
    	item.iItem = 1;
    	item.iSubItem = 0;
    	nIndex = ListView_InsertItem(hListView, &item);	
    	// - 두번째 컬럼 -
    	item.mask = LVIF_TEXT;
    	item.iItem   = nIndex;
    	item.pszText = lpDate;
    	item.cchTextMax = strlen(lpDate);
    	item.iSubItem = 1;
    	ListView_SetItem(hListView, &item); 
    	// - 세번째 컬럼 -
    	item.mask = LVIF_TEXT;
    	item.iItem   = nIndex;
    	item.pszText = "";
    	item.cchTextMax = strlen(lpDocName);
    	item.iSubItem = 2;
    	ListView_SetItem(hListView, &item); 
    

    9. 리스트뷰 컨트롤에서의 정렬 구현

    리스트뷰 컨트롤에서 칼럼 헤더를 눌렀을 때 정렬이 되도록 하려면 어떻게 해야합니까 ?

    1. 일단 리스트뷰 컨트롤의 생성시 윈도우 스타일로 LVS_NOSORTHEADER를 주지 않는다.
    2. 리스트뷰로부터 칼럼 헤더가 눌렸을 때 오는 이벤트를 받아들인다.
      	NM_LISTVIEW *pnmtv = (NM_LISTVIEW FAR *)lParam;
      
      	switch(pnmtv->hdr.code)
      	{
      		case LVN_COLUMNCLICK :
      		{
      			// 어느 항목(pnmtv->iSubItem)이 눌렸는지부터 검사한다. 
      			// g_iSubItem은 어느 항목이 눌렸는지 기록해두는 인덱스이다.
      			g_iSubItem = pnmtv->iSubItem;
      			// 정렬함수를 호출한다. CompareFunc가 정렬함수이다.
      			ListView_SortItems(hListView, (PFNLVCOMPARE)CompareFunc, (LPARAM)this); 
      			break;
      		}
      
    3. 리스트뷰 항목을 정렬하는데 사용되는 CompareFunc라는 함수를 만든다. 이는 보통 C 함수로 만들거나 클래스를 사용할 경우에는 클래스 내의 static 함수로 만든다. CompareFunc의 코드는 다음과 같다.
      	int CALLBACK CompareFunc(LPARAM lParam1, LPARAM lParam2, LPARAM lParamSort)
      	{
      		LV_FINDINFO lvfi;
      		int iFirstItem, iSecondItem;
      
      		lvfi.flags = LVFI_PARAM;
      		lvfi.lParam = lParam1;
      		iFirstItem = ListView_FindItem(hListWnd, -1, &lvfi); 
      
      		lvfi.flags = LVFI_PARAM;
      		lvfi.lParam = lParam2;
      		iSecondItem = ListView_FindItem(hListWnd, -1, &lvfi); 
      
      		char lpFirst[100];
      		char lpSecond[100];
      		ListView_GetItemText(hListWnd, iFirstItem, g_iSubItem, lpFisrt, 100);
      		ListView_GetItemText(hListWnd, iSecondItem, g_iSubItem, lpSecond, 100);
      
      		// g_iSubItem 컬럼의 성격에 따라 비교한다. 문자열이라면 아래와 같이 한다.
      		int iRet = strcmpi(lpFirst, lpSecond);	
      		return iRet;
      	}
      

    10. 버전 정보 알아내기 코드

    파일의 버전을 API를 통해 알아내려면 어떻게 해야합니까 ?

    Resource의 한 타입으로 VERSIONINFO라는 것이 존재합니다. 여기에 해당 파일의 버전 정보를 기록하도록 되어있습니다. 이 버전 정보를 읽어오는데 ver.dll이라는 DLL에 들어있는 API들을 사용합니다. 주의할 점은 버전 리소스는 언어별로 설정이 되기 때문에 영어로도 읽어보고 한국어 로도 읽어봐야 한다는 것입니다. 다음 예제를 참고하시기 바랍니다.

    	// szDrvName이란 파일에 들어있는 버전 정보를 읽어온다. 
    	#include 
    
    	DWORD      dwSize, handle;
    	LPSTR      lpstrVffInfo;
    	HANDLE     hMem;
    	LPSTR      lpVersion;	   // 이 변수로 파일의 버전 정보가 들어온다.
    	char       szDrvName[80];  // 버전 정보를 알아내고자 하는 파일 이름이 여기에 들어온다.
    
    	....
    	// 버전 정보 블록의 크기를 알아온다.
    	dwSize = GetFileVersionInfoSize(szDrvName , &handle);
    	if (dwSize) // 버전 정보 블록이 존재하면
    	{ 
    		// 버전 정보 블록을 포함할 메모리 블록을 할당 받아둔다.
    		hMem = GlobalAlloc(GMEM_MOVEABLE, dwSize);
    		lpstrVffInfo  = GlobalLock(hMem);
    		// 버전 정보 블록의 내용을 읽어온다.	
    		GetFileVersionInfo(szDrvName, handle, dwSize, lpstrVffInfo);
    	        // 버전 정보 블록에서 버전 정보를 읽어온다.
            	VerQueryValue((LPVOID)lpstrVffInfo, 
                            (LPSTR)"\\StringFileInfo\\041204B0\\FileVersion",
                            (void FAR* FAR*)&lpVersion, (UINT FAR *)&dwSize);
    		// lpVersion에 들어있는 버전 정보를 사용한다.
    		....
    		GlobalUnlock(hMem);
    		GlobalFree(hMem);
        }
    
    위에서 041204B0가 바로 버전 리소스에 사용된 언어가 무엇인지를 나타냅니다. 이는 영어를 나타내며 한국어의 경우에는 040904B0를 사용하면 됩니다. 이 밖에도 version.lib를 링크의 라이브러리 항목에 추가해야 합니다.

    11. 시스템 사양 알아내기

    현재 시스템에 부착되어 있는 메인 메모리의 양과 CPU와 운영체제의 종류를 알고 싶습니다.

    먼저 시스템에 부착되어 있는 메인 메모리의 크기는 GlobalMemoryStatus라는 API를 이용하면 됩니다. 예제 코드는 다음과 같습니다.

    	//===========================================================
    	// lMemTotal      : 실제 메모리의 전체 크기 (KB 단위)
    	// lAvailMemTotal : 사용 가능한 실제 메모리의 크기 (KB 단위)
    	// lVirtualTotal  : 가상 메모리의 전체 크기  (KB 단위)
    	//===========================================================
    	void GetMemoryStatus(long *lMemTotal, long *lAvailMemTotal, long *lVirtualTotal)
    	{
    		double var;
    		MEMORYSTATUS memoryStatus;
    
    		memset (&memoryStatus, sizeof (MEMORYSTATUS), 0);
    		memoryStatus.dwLength = sizeof (MEMORYSTATUS);
    
    		GlobalMemoryStatus (&memoryStatus);
    
    		lMemTotal = memoryStatus.dwTotalPhys / 1024;
    		lAvailMemTotal = memoryStatus.dwAvailPhys / 1024;
    		lVirtualTotal = memoryStatus.dwTotalVirtual / 1024;
    	}
    
    다음으로 CPU의 종류를 알아내는 코드는 다음과 같습니다.
    	//===============================================================
    	// GetProcessorInfo : 프로세서에 대한 정보를 읽어온다.
    	// lpCPUSpeed      : CPU의 속도. 기록된 시스템에서만 읽어온다.
    	// lpProcessorType : 프로세서의 종류
    	// lpNumProcessors : 프로세서의 개수. NT의 경우에만 의미가 있다.
    	//===============================================================
    	void GetProcessorInfo(LPSTR lpCPUSpeed, LPSTR lpProcessorType, LPSTR lpNumProcessors)
    	{
    		SYSTEM_INFO sysInfo;
    		LONG result;
    		HKEY hKey;
    		DWORD data;
    		DWORD dataSize;
    
    		lpCPUSpeed[0] = 0;
    		// ---------------------------------------------
    		// 프로세서의 속도를 얻어낸다.
    		// ---------------------------------------------
    		result = ::RegOpenKeyEx (HKEY_LOCAL_MACHINE,
    			"Hardware\\Description\\System\\CentralProcessor\\0", 0, KEY_QUERY_VALUE, &hKey);
    		if (result == ERROR_SUCCESS) 
    		{
    			result = ::RegQueryValueEx (hKey, "~MHz", NULL, NULL,(LPBYTE)&data, &dataSize);
    			wsprintf(lpCPUSpeed, "%d MHz", data);
    		}
    		RegCloseKey (hKey);
    
    		// ------------------------------------------
    		// 하드웨어 정보를 얻어낸다.
    		// ------------------------------------------
    		GetSystemInfo (&sysInfo);
    
    		// 프로세서 타입부터 검사한다.
    		if (sysInfo.dwProcessorType  == PROCESSOR_INTEL_386)
    			strcpy(lpProcessorType,  "Intel 386");
    		else if (sysInfo.dwProcessorType  == PROCESSOR_INTEL_486)
    			strcpy(lpProcessorType,  "Intel 486");
    		else if (sysInfo.dwProcessorType  == PROCESSOR_INTEL_PENTIUM)
    		{
    			if (sysInfo.wProcessorLevel == 6) 
    				strcpy(lpProcessorType, "Intel Pentium (II/Pro)");
    			else
    				strcpy(lpProcessorType,  "Intel Pentium");
    		}
    		else 
    			strcpy(lpProcessorType, "알 수 없는 시스템");
    
    		// 프로세서의 갯수를 검사한다.
    		wsprintf(lpNumProcessors, "%d", sysInfo.dwNumberOfProcessors);
    	}
    
    
    다음으로 현재 사용 중인 운영체제의 종류를 알아내는 코드는 다음과 같습니다.
    	//===============================================================
    	// GetOSVersion : OS의 버전을 얻어온다.
    	// --------------------------------------------------------------
    	// lpstInfo
    	// lpstBuildNumber
    	// lpstServicePack
    	//===============================================================
    	void GetOSVersion (LPSTR lpstInfo, LPSTR lpstBuildNumber, LPSTR lpstServicePack)
    	{
    		int stat = 0;
    		TCHAR data [64];
    		DWORD dataSize;
    		DWORD win95Info;
    		OSVERSIONINFO versionInfo;
    		HKEY hKey;
    		LONG result;
    
    		lpstServicePack[0] = 0;
    		versionInfo.dwOSVersionInfoSize = sizeof (OSVERSIONINFO);
    
    		// 버전 정보를 얻어낸다.
    		if (!::GetVersionEx (&versionInfo)) 
    		{
    			strcpy(lpstInfo, "운영체제 정보를 얻을 수 없습니다.");
    			return;
    		}
    
    		// NT이면 서버인지 웍스테이션인지 검사한다. 이는 레지스트리를 보고 검사한다.
    		if (versionInfo.dwPlatformId == VER_PLATFORM_WIN32_NT) 
    		{
    			strcpy(lpstInfo, "Windows NT");
    			dataSize = sizeof (data);		
    			result = ::RegOpenKeyEx (HKEY_LOCAL_MACHINE,
    				"System\\CurrentControlSet\\Control\\ProductOptions", 0, KEY_QUERY_VALUE, &hKey);
    			if (result != ERROR_SUCCESS) 
    				return;
    
    			result = ::RegQueryValueEx (hKey, "ProductType", NULL, NULL, (LPBYTE) data, &dataSize);
    			RegCloseKey (hKey);
    
    			if (result != ERROR_SUCCESS) 
    				return;
    
    			if (lstrcmpi (data, "WinNT") == 0) 
    				strcpy(lpstInfo, "Windows NT Workstation");
    			else if (lstrcmpi (data, "ServerNT") == 0) 
    				strcpy(lpstInfo, "Windows NT Server");
    			else 
    				strcpy(lpstInfo, "Windows NT Server - Domain Controller");
    
    			// NT 버전을 알아낸다.
    			if (versionInfo.dwMajorVersion == 3 || versionInfo.dwMinorVersion == 51) 
    				strcat(lpstInfo, " 3.51");
    			else if (versionInfo.dwMajorVersion == 5) // 윈도우 2000의 경우
    				strcat(lpstInfo, " 5.0");
    			else 
    				strcat(lpstInfo, " 4.0");
    
    			// Build 번호를 알아낸다.
    			wsprintf(lpstBuildNumber, "%d", versionInfo.dwBuildNumber);
    		}
    		else if (versionInfo.dwPlatformId == VER_PLATFORM_WIN32_WINDOWS) 
    		{
    			strcpy(lpstInfo, "Windows 95");
    			if ((versionInfo.dwMajorVersion > 4) || ((versionInfo.dwMajorVersion == 4)
    				&& (versionInfo.dwMinorVersion > 0))) 
    			{
    				strcpy(lpstInfo, "Windows 98");
    			}
    			// 윈도우 95는 Build 번호가 하위 워드에 들어간다.
    			win95Info = (DWORD)(LOBYTE(LOWORD(versionInfo.dwBuildNumber)));
    			wsprintf(lpstBuildNumber, "%d", win95Info);
    		}
    		else 
    			wsprintf(lpstInfo, "Windows 3.1");
    
    		// 서비스 팩 정보를 얻어낸다.
    		strcpy(lpstServicePack, versionInfo.szCSDVersion);
    	}
    

    12. IE의 설치 여부와 버전 확인

    현재 시스템에 IE가 설치되었는지 여부와 그 버전을 알려면 어떻게 해야합니까 ?

    사실 동작시켜보지 않고서는 IE가 제대로 설치되어있는지 알아내는 방법은 없지만 레지스트리를 통해 IE가 설치되었는지 여부와 버전을 확인할 수 있습니다. 그 함수는 다음과 같습니다.

    	//===========================================================================
    	// GetIEVersion : IE의 버전을 얻는다. 정보를 찾을 수 없으면 FALSE를 리턴한다.
    	//===========================================================================
    	BOOL GetIEVersion(LPSTR lpVer)
    	{	
    		LONG result;
    		HKEY hKey;
    		DWORD dwType; 
    		char data[65];
    		DWORD dataSize = 64;
    
    		// --------------------
    		// IE의 버전을 얻는다.
    		// --------------------
    		result = ::RegOpenKeyEx (HKEY_LOCAL_MACHINE, "Software\\Microsoft\\Internet Explorer", 0, KEY_QUERY_VALUE, &hKey);
    		if (result == ERROR_SUCCESS) 
    		{
    			result = ::RegQueryValueEx (hKey, "Version", NULL, &dwType, (unsigned char *)data, &dataSize);
    			strcpy(lpVer, data);
    		}
    		else
    			return FALSE;
    
    		RegCloseKey (hKey);
    		return TRUE;
    	}
    

    13. IE의 보안 설정 보기

    IE에 보면 네 단계의 보안 영역이 있습니다. 그 영역별로 설정되어있는 보안 설정값을 읽으려면 어떻게 해야합니까 ?

    IE에는 다음과 같은 네 가지 보안 영역이 존재합니다.

    • 인터넷 영역
    • 로컬 인터넷 영역
    • 신뢰할 수 있는 사이트 영역
    • 제한된 사이트 영역
    IE는 보안 영역 설정과 관련하여 Internet Security Manager와 Internet Zone Manager라는 인터페이스가 존재합니다. 이를 이용해 보안 영역의 보안을 설정하고 특정 IP나 도메인 이름을 등록할 수 있습니다. 자세한 사항은 레퍼런스를 찾아보기 바랍니다.
     
    	#include "objbase.h"
    	#include "urlmon.h"
    
    	char szTemp1[256];
    	char szTemp2[256];
    	HRESULT hr;
    	IInternetSecurityManager *pSecurityMgr;
    	IInternetZoneManager *pZoneMgr;
    	DWORD dwEnum, dwZoneCount;
    	// --- 변수 선언부
    	DWORD dwZone;
    	ZONEATTRIBUTES zoneAttr;
    	int nLevel = 2;
    
    	// COM 라이브러리를 초기화한다.
    	CoInitialize(NULL);
    	dwEnum = 0;
    	pSecurityMgr = NULL;
    	pZoneMgr = NULL;
    
    	// Internet Security 인터페이스 초기화
    	hr = CoCreateInstance(CLSID_InternetSecurityManager, NULL, CLSCTX_ALL, //INPROC_SERVER,
    			IID_IInternetSecurityManager, (void**)&pSecurityMgr);
    	if (hr != S_OK)
    	{
    		return;
    	}
    	hr = CoCreateInstance(CLSID_InternetZoneManager, NULL, CLSCTX_ALL, //INPROC_SERVER,
    			IID_IInternetZoneManager, (void**)&pZoneMgr);	
    	if (hr != S_OK)
    	{
    		return;
    	}
    	dwEnum = 0;
    
    	// 보안 영역 열거자(Zone Enumerator)를 초기화한다.
    	pZoneMgr->CreateZoneEnumerator(&dwEnum, &dwZoneCount, 0);
    	for(DWORD i = 1;i < dwZoneCount;i++)
    	{
    		pZoneMgr->GetZoneAt(dwEnum, i, &dwZone);
    		pZoneMgr->GetZoneAttributes(dwZone, &zoneAttr);
    
    		// zoneAttr.szDisplayName에 보안 영역의 이름이 들어오는데 유니코드이다. 이를 변환한다.
    		WideCharToMultiByte(CP_ACP, 0, zoneAttr.szDisplayName, -1, szTemp1, 255, NULL, NULL);	
    		// zoneAttr.dwTemplateCurrentLevel에는 보안 영역의 보안값 설정이 들어온다.
    		wsprintf(szTemp2, "%x", zoneAttr.dwTemplateCurrentLevel);
    	}
    
    	// 보안 영역 열거자(Zone Enumerator)를 제거한다.
    	if (dwEnum != 0)
    		pZoneMgr->DestroyZoneEnumerator(dwEnum);
    
    	pSecurityMgr->Release();
    	pZoneMgr->Release();
    	// COM 라이브러리를 메모리에서 내린다.
    	CoUninitialize();
    }
    

    14. ActiveX 컨트롤의 등록 방법

    Regsvr32 같은 유틸리티를 이용하지 않고 프로그램 내에서 컨트롤을 레지스트리에 등록하려면 어떻게 해야합니까 ?

    모든 AcitveX 컨트롤은 자신을 레지스트리에 등록하기위한 목적으로 DllRegisterServer라는 함수를 갖고 있습니다. ActiveX 컨트롤을 메모리로 로드한 다음에 이 함수를 불러주면 원하는 일을 수행할 수 있습니다. 반대로 ActiveX 컨트롤을 레지스트리에서 제거하기 위한 용도로 DllUnRegisterServer라는 함 수도 존재합니다.

    	// ==============================================================
    	// RegisterOCX     지정된 ActiveX 컨트롤을 레지스트리에 등록한다.
    	// --------------------------------------------------------------
    	// LPSTR pszString 등록하고자 하는 ActiveX 컨트롤의 절대 경로명
    	// ==============================================================
    	BOOL WINAPI RegisterOCX(LPSTR pszString)
    	{
    		int iReturn = 0;
    		HRESULT (STDAPICALLTYPE * lpDllEntryPoint)();
    		HINSTANCE hLib;
    
    		// OLE 라이브러리를 초기화한다.				
    		if (FAILED(OleInitialize(NULL)))
    		{
    			MessageBox(GetFocus(), "OLE 초기화 실패", "에러", MB_OK);
    			return FALSE;
    		}
    
    		// 지정된 activeX 컨트롤을 메모리로 로드한다.
    		hLib = LoadLibrary(pszString);
    		if (hLib <= NULL)
    		{
    			MessageBox(GetFocus(), "파일을 로드하는데 실패했습니다.", "에러", MB_OK);
    			OleUninitialize();
    			return FALSE;
    		}
    
    		// "DllRegisterServer" 함수의 위치를 찾는다.
    		lpDllEntryPoint = (long (__stdcall *)(void))GetProcAddress(hLib, "DllRegisterServer");
    	
    		// 이 함수를 호출합니다.
    		if (lpDllEntryPoint)
    		{
    			if (FAILED((*lpDllEntryPoint)()))
    			{
    				DWORD dwRet;
    				char szTemp[128];
    
    				dwRet = GetLastError();
    				wsprintf(szTemp, "에러 번호 : %lx", dwRet);
    				MessageBox(GetFocus(), szTemp, "DllRegisterServer 에러", MB_OK);
    				FreeLibrary(hLib);
    				OleUninitialize();
    				return FALSE;
    			}
    		}
    		else
    		{
    			MessageBox(GetFocus(), "DllRegisterServer를 찾을 수 없습니다.", "에러", MB_OK);
    			FreeLibrary(hLib);
    			OleUninitialize();
    			return FALSE;
    		}
    
    		FreeLibrary(hLib);
    		OleUninitialize();
    		return TRUE;
    	}
    

    15. 데이터 파일의 실행

    탐색기에서 실행 파일이 아닌 데이터 파일을 더블클릭하면 그 데이터 파일과 연결된 실행 파일이 실행되면서 그 파일을 물고 올라갑니다. 이를 구현하는 방법을 알려주세요.

    ShellExecuteEx라는 API를 사용하면 됩니다. 다음 함수는 인자로 데이터 파일 혹은 실행 파일의 경로를 받아서 실행해줍니다. 데이터 파일의 경우에는 연결된 실행 파일을 찾아서 그걸 실행해줍니다.

    	BOOL Execute(LPSTR lpPath)
    	{
    		char FilePath[255];
    		SHELLEXECUTEINFO  ExecInfo;
    	
    		// lpPath를 나누어 본다.
    		char drive[_MAX_DRIVE];
    		char dir[_MAX_DIR];
    		char fname[_MAX_FNAME];
    		char ext[_MAX_EXT];
    
    		_splitpath(lpPath, drive, dir, fname, ext);
    		// 디렉토리 경로를 얻는다.
    		strcpy(FilePath, drive);
    		strcat(FilePath, dir);
    
    		// 파일 이름을 얻는다.
    		strcat(fname, ".");
    		strcat(fname, ext);
    
    		SHELLEXECUTEINFO  ExecInfo;
    		ExecInfo.cbSize = sizeof(SHELLEXECUTEINFO); 
    		ExecInfo.fMask = SEE_MASK_NOCLOSEPROCESS; 
    		ExecInfo.hwnd = hWnd; 
    		ExecInfo.lpVerb = "open"; 
    		ExecInfo.lpFile = fname; 
    		ExecInfo.lpParameters = NULL; 
    		ExecInfo.lpDirectory = FilePath; 
    		ExecInfo.nShow = SW_SHOW; 
    		ExecInfo.hInstApp = g_hInstance; // g_hInstance는 프로그램의 인스턴스 핸들
    
    		return ShellExecuteEx(&ExecInfo);
    	}
    

    16. 파일의 존재 여부 테스트

    어떤 파일이 실제로 존재하는 것인지 간단히 테스트해보는 방법은 무엇인가요 ?

    CreateFile API를 사용하면 됩니다. 파일을 열때 플래그 중의 하나로 OPEN_EXISTING이라는 것이 있는데 이를 사용하면 됩니다. 다음 코드를 예로 보시기 바랍니다.

    	hFile = CreateFile("C:\\TEMP\\test.txt", GENERIC_READ, 0, NULL,  
    			OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, 0);
    	if (hFile != INVALID_HANDLE_VALUE)
    	{
    		// 파일이 존재하는 경우
    		CloseHandle(hFile);
    	}
    

    17. API를 이용한 파일 I/O

    API를 이용한 파일 입출력에 대한 예제 코드가 없습니까 ?

    프로그래밍을 하다보면 간혹 파일 I/O를 API를 이용해 수행해야 할 경우가 있습니다. 이 때 다음의 예제 코드를 복사해다가 사용하면 편리할 것입니다. MFC의 CFile을 이용한 파일 I/O에 대해 알고 싶으시면 요기를 클릭하세요.

  • 파일 쓰기의 경우
    	HANDLE fp;
    	DWORD NumberOfBytesWritten;
    	char lpBuffer[1024];
    
    	// FileName이 지정한 파일의 이름이 있으면 그걸 열고 없으면 그 이름으로 하나 생성한다.
    	if ((fp=CreateFile((LPCTSTR)FileName, GENERIC_WRITE | GENERIC_READ,	0, NULL, 
    						OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL)) == INVALID_HANDLE_VALUE)
    	{
    		// 파일 열기 에러 발생
    	}  
    	// 필요한 만큼 WriteFile의 호출을 반복한다. 파일 포인터의 이동시에는 SetFilePointer API를 이용한다.
    	WriteFile(fp, lpBuffer, 1024, &NumberOfBytesWritten, NULL);
    	if (NumberOfBytesWritten != 1024)
    	{
    		// 파일 쓰기 에러 발생 
    		CloseHandle(fp);		
    	}
    	// 작업이 다 끝났으면 파일을 닫는다.
    	CloseHandle(fp);		
    
  • 파일 읽기의 경우
    	HANDLE fp;
    	DWORD NumberOfBytesRead;
    	char lpBuffer[1024];
    
    	if ((fp=CreateFile((LPCTSTR)FileName, GENERIC_READ,	
    		FILE_SHARE_READ, NULL, OPEN_EXISTING, 
    		FILE_ATTRIBUTE_NORMAL, NULL)) == INVALID_HANDLE_VALUE)
    	{
    		// 파일 열기 에러 발생
    	}  
    	// 필요한 만큼 ReadFile의 호출을 반복한다.
    	ReadFile(fp, lpBuffer, 1024, &NumberOfBytesRead, NULL);
    	if (NumberOfBytesRead != 1024)
    	{
    		// 파일 읽기 에러 발생 
    		CloseHandle(fp);		
    	}
    	// 작업이 다 끝났으면 파일을 닫는다.
    	CloseHandle(fp);		
    

    파일 포인터의 이동시에는 SetFilePointer라는 API를 사용하고 파일의 크기를 알고 싶을 때는 GetFileSize라는 API를 사용한다.

    18. GetParent API의 리턴값

    다이얼로그 박스에서 GetParent API를 호출했을 때 리턴되는 값이 이상합니다.

    GetParent API의 리턴값은 보통의 윈도우에서는 윈도우 생성시 지정한 부모/자식 윈도우 간의 관계에 따라 달라집니다. 하지만 다이얼로그 박스의 경우에는 다릅니다. 다이얼로그 박스는 GetParent를 호출하면 무조건 그 것이 속한 응용프로그램의 메인 윈도우 핸들이 리턴됩니다.

    19. 특정 프로그램을 실행하고 종료를 기다리기

    특정 프로그램을 실행한 다음에 그 프로그램이 종료될 때 다른 일을 하고 싶습니다. 어떻게 해야합니까 ?

    다음은 CreateProcess를 이용해서 특정 프로그램의 실행이 끝나기를 기다리는 코드입니다.

    	// buffer에 실행하고자하는 파일이름이 들어온다.
    	void DoCreate(HWND hWnd, LPSTR buffer)
    	{
    		STARTUPINFO            sui;
    		PROCESS_INFORMATION    pi;
    		DWORD                  ret;
    
    		memset(&sui, 0x00, sizeof(STARTUPINFO));
    		sui.cb = sizeof(STARTUPINFO);
        
    		ret = CreateProcess(buffer, NULL, NULL, NULL, FALSE, 
    			0, NULL, NULL,&sui, &pi);
    		if (ret == TRUE) // 제대로 실행되었으면
    		{
    			hProcess = pi.hProcess;
    			// 실행이 끝나기를 대기한다.
    			WaitForSingleObject(hProcess, 0xffffffff);
    			CloseHandle(hProcess);
    		}
    	}
    

    20. 파일 열기 다이얼로그 띄우기

    API를 이용해 파일 오픈 다이얼로그를 띄우고 싶습니다.

    API를 이용해 파일 오픈 다이얼로그를 띄우는 방법은 다음과 같습니다.

    	#define MAXCHARS   255
    	OPENFILENAME       ofn;
    	char               buffer[MAXCHARS];
    
    	memset(&ofn, 0x00, sizeof(OPENFILENAME));
    	ofn.lStructSize = sizeof(OPENFILENAME);
    	ofn.hwndOwner = hWnd;
    	ofn.lpstrFilter = "모든 파일\000*.*\000\000";
    	ofn.lpstrInitialDir = m_szTemp;
    	ofn.nFilterIndex = 1;
    	ofn.lpstrFile = buffer;
    	ofn.nMaxFile = MAXCHARS;
    	ofn.lpstrTitle = "파일 선택하기";
    
    	if (GetOpenFileName(&ofn))  // 사용자가 파일을 제대로 선택한 경우
    	{
    		// ....
    	}
    

    21. 긴 파일 이름과 짧은 파일 이름간의 변환 방법

    주어진 긴 파일 이름에 해당하는 짧은 파일 이름을 알려면 어떻게 해야하나요 ?

  • 긴 파일 이름에서 짧은 파일 이름으로의 변환 : GetShortPathName API 사용
  • 짧은 파일 이름에서 긴 파일 이름으로의 변환 : GetFullPathName API 사용

    22. 시스템 이미지 리스트 얻어내기

    탐색기 등에서 사용되는 시스템 이미지 리스트를 사용할 수 있는 방법이 있는지 알고 싶습니다.

    물론 있습니다. SHGetFileInfo라는 API를 이용하면 됩니다. 이를 이용하면 16X16이나 32X32 크기의 이미지 리스트를 얻어낼 수 있습니다.

    	SHFILEINFO sfi;
    	HIMAGELIST sysSmallList, sysLargeList;
    
    	sysSmallList = (HIMAGELIST)SHGetFileInfo(TEXT("C:\\"), 0, &sfi,   sizeof(SHFILEINFO), SHGFI_SYSICONINDEX | SHGFI_SMALLICON);
    	sysLargeList = (HIMAGELIST)SHGetFileInfo(TEXT("C:\\"), 0, &sfi,   sizeof(SHFILEINFO), SHGFI_SYSICONINDEX | SHGFI_ICON);
    

    23. 리스트뷰 컨트롤에서 스타일 동적 변경하기

    리스트뷰 컨트롤에서 리스트(LVS_ICON) 스타일을 사용 중인데 이를 실행 중에 리포트(LVS_REPORT) 스타일로 변경하고 싶습니다. 어떻게 해야할까요 ?

    윈도우의 스타일은 윈도우 엑스트라 바이트라는 영역에 저장됩니다. 이 곳의 데이터를 읽고 쓰는데 GetWindowLong, SetWindowLong 같은 API를 사용하는데 다음 코드를 예로 보시기 바랍니다.

  • 리스트 스타일에서 리포트 스타일로
    	LONG lStyle = GetWindowLong(hListWnd, GWL_STYLE);
    	SetWindowLong(hListWnd, GWL_STYLE, (lStyle & ~LVS_LIST) | LVS_REPORT);
    
  • 리포트 스타일에서 리스트 스타일로
    	LONG lStyle = GetWindowLong(hListWnd, GWL_STYLE);
    	SetWindowLong(hListWnd, GWL_STYLE, (lStyle & ~LVS_REPORT) | LVS_LIST);
    

    24. 윈도우와 다이얼로그에서 클래스 포인터의 사용

    제가 만든 C++ 클래스내에서 윈도우를 생성합니다. 생성한 윈도우의 윈도우 프로시저는 그 클래스의 정적 멤버 함수로 선언되어 있습니다. 정적 함수의 경우에는 데이터 멤버를 접근하지 못하기 때문에 접근하게 하려고 윈도우를 생성한 그 클래스의 객체에 대한 포인터를 전역 변수로 유지하여 사용하고 있습니다. 별로 깨끗한 방법도 아닌 것 같고 또 동시에 여러 개의 객체가 동작할 경우에는 에러가 날 수밖에 없는데 해결 방법이 없을까요 ?

    이는 다이얼로그의 경우에도 마찬가지입니다. 윈도우 생성시 사용하는 CreateWindow나 CreateWindowEx 같은 함수를 보면 마지막 인자로 윈도우 생성 데이터라는 것을 지정하게 되어있습니다. 다이얼로그 생성시에는 DialogBox라는 API말고 DialogBoxParam이라는 API가 있어서 마지막 인자로 초기화 데이터를 넘겨줄 수 있도록 되어있는데 이것과 윈도우 엑스트라 바이트를 같이 사용하면 정적함수로 선언된 윈도우 프로시저나 다이얼로그박스 프로시저내에서 객체에 대한 포인터를 사용할 수 있습니다. 예를 들어 살펴보겠습니다.

  • 윈도우의 경우

    다음과 같이 CExplorerBar라는 클래스내에서 윈도우를 하나 생성합니다. CreateWindowEx 함수나 CreateWindow 함수의 마지막 인자로 this 포인터를 지정합니다.

    	BOOL CExplorerBar::RegisterAndCreateWindow(void)
    	{
    		....
    		CreateWindowEx( 0,  EB_CLASS_NAME, NULL,
                         WS_CHILD | WS_CLIPSIBLINGS | WS_BORDER,
                         rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top,
                         m_hwndParent, NULL, g_hInst, (LPVOID)this);
          
       }
    

    위에서 생성한 윈도우의 윈도우 프로시저는 WndProc이고 CExplorerBar 클래스의 정적 멤버 함수로 존재한다고 가정하겠습니다.

    	LRESULT CALLBACK CExplorerBar::WndProc(HWND hWnd, UINT uMessage, WPARAM wParam, LPARAM lParam)
    	{
    		CExplorerBar  *pThis = (CExplorerBar*)GetWindowLong(hWnd, GWL_USERDATA);
    
    		switch (uMessage)
    		{
    			case WM_NCCREATE:
    			{
    				LPCREATESTRUCT lpcs = (LPCREATESTRUCT)lParam;
    				pThis = (CExplorerBar*)(lpcs->lpCreateParams);
    				SetWindowLong(hWnd, GWL_USERDATA, (LONG)pThis);
    			}
    			break;
    			case WM_CREATE :
    				return pThis->OnCreate();
    

    WM_NCCREATE 메시지에서 lParam 인자로 넘어오는 CExplorerBar 객체에 대한 포인터를 받아서 윈도우 엑스트라 바이트로 저장하고 있습니다. 윈도우 엑스트라 바이트는 윈도우 마다 할당되는 고유의 영역으로 사용자 정의 영역으로 GWL_USERDATA가 정의되어 있습니다. 여기에다 CExplorerBar 객체에 대한 포인터를 저장해두고 윈도우 프로시저에 진입할 때마다 이 값을 pThis라는 변수에 대입해 놓고 사용합니다. 참고로 WM_NCCREATE는 WM_CREATE 메시지보다 먼저 발생하는 메시지입니다.

  • 다이얼로그의 경우

    예를 들어 CKTree라는 클래스내의 한 멤버 함수에서 다이얼로그 박스를 띄운다고 가정하겠습니다.

    	BOOL CKTree::SelectFolder(short sTypes, long dwFolderID, short  bCreationEnabled)
    	{
    		// ......
    		if (DialogBoxParam(hInst, MAKEINTRESOURCE(IDD_FOLDER_SELECT), hWnd, (DLGPROC)SelectFolderDlg, (LPARAM)this))
    

    DialogBoxParam 함수는 DialogBox 함수보다 인자가 하나 더 있는데 그것이 바로 다이얼로그 프로시저의 WM_INITDIALOG 메시지의 lParam 인자로 넘어갑니다. 여기에 CKTree 객체에 대한 포인터를 넘깁니다. 그리고나서 다이얼로그 박스 프로시저의 WM_INITDIALOG 메시지에서 이를 받아서

    	LRESULT CALLBACK SelectFolderDlg(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam)
    	{
    		switch (message) 
    		{	
    	        case WM_INITDIALOG:
    			{
    				// lParam으로 넘어온 값을 KPointer라는 윈도우 프로퍼티에 저장한다.
    				SetProp(hDlg, "KPointer", (HANDLE)lParam);
    				.......
    			case  WM_NOTIFY :
    			{
    				// CKTree 객체에 대한 포인터가 필요하면 KPointer 윈도우 프로퍼티 값을 읽어들인다.
    				CKTree *pKTree = (CKMartTree *)GetProp(hDlg, "KPointer");
    				if (pKTree->m_bWait)
    				{
    					.....
    

    여기서는 앞서 윈도우와는 달리 윈도우 프로퍼티라는 것을 이용해서 넘어온 포인터 정보를 저장하고 필요할 때 읽어옵니다. 여기서 앞서의 윈도우 엑스트라 바이트를 사용해도 무방합니다.

    25. 특정 프린터로 출력하기

    제 PC에는 두 대의 프린터가 붙어있습니다. 다이얼로그를 띄우지 않고 상황에 따라 다른 프린터로 출력하고 싶은데 동적으로 HDC를 생성하는 방법을 모르겠습니다.

    CreateDC를 이용하면 됩니다. 시스템 디렉토리의 WIN.INI를 보면 [Devices]라는 섹션이 있는데 이 아래로 이 시스템에 설치되어 있는 모든 프린터 드라이버의 목록이 나옵니다. 예를 들어 다음과 같이 나옵니다.

    	[Devices]
    	HUNFAX=HUNFAX,FaxModem
    	삼성 SLB-6216H PCL5=SSMPCL5,\\영업팀\볼륨프린터
    	......
    

    프린터별로 DeviceName=DriverName,OutputName와 같은 구조로 구성되어 있습니다. 이 값들을 CreateDC의 인자로 사용하면 됩니다. 예를 들어 위에서 두 번째 프린터의 DC를 생성하려면 다음과 같이 CreateDC를 호출합니다.

    	// hDC = CreateDC(DriverName, DeviceName, OutputName, NULL);
    	hDC = CreateDC ("SSMPCL5", "삼성 SLB-6216H PCL5", "\\\\영업팀\\볼륨프린터", NULL) ;
    

    CreateDC의 마지막 인자로는 프린터의 설정값을 변경할 수 있습니다. 예를 들어 가로로 찍는다든지 2장을 찍는다든지 하는 설정을 변경하는데 사용됩니다. NULL을 주면 디폴트 값을 사용합니다. 다른 설정을 사용하고 싶다면 다음과 같이 합니다. CreateDC 호출 앞에서 DocumentProperties라는 함수를 호출하여 프린터의 기본 설정을 읽어온 다음에 이를 변경합니다. 다음 예는 출력 방향을 가로로 변경하는 예제입니다.

    	// 프린터의 디폴트 설정을 읽어온다.
    	LPDEVMODE lpoutDevMode;
    	HANDLE hPrinter;
    	HDC hPrnDC;
    
    	// 프린터의 핸들을 얻는다.
    	if (OpenPrinter( lpDeviceName, &hPrinter,  NULL))
    	{
    		// OpenPrinter로 얻은 프린터의 초기 설정을 DocumentProperties API로 얻어온다.
    		// 먼저 마지막 인자를 0으로 해서 DocumentProperties를 호출하여 필요한 버퍼의 크기를 알아옵니다.
    		long lSize = DocumentProperties(GetFocus(), hPrinter, lpPrinterName, NULL, NULL, 0);
    		lpoutDevMode = (LPDEVMODE)malloc(lSize);
    		long lRet = DocumentProperties(GetFocus(), hPrinter, lpPrinterName, lpoutDevMode, NULL, DM_OUT_BUFFER);
    		if (lRet == IDOK)
    		{
    			// 프린터의 인쇄 방향 설정을 변경한다.
    			// 여기서 원하는 변환을 수행한다.
    			lpoutDevMode->dmOrientation = DMORIENT_LANDSCAPE;
    		}
    		hPrnDC = CreateDC (lpDriverName, lpDeviceName, lpOutputName, lpoutDevMode) ;
    		free(lpoutDevMode);
    		return hPrnDC;
    	}
    

    26. 메뉴 관련 함수

    메뉴 항목을 하나 추가하려고 합니다. InsertMenuItem API를 사용하는데 윈도우 3.1에서와 사용법이 다른 것 같습니다.

    사용법이 달라졌습니다.

    	MENUITEMINFO mii;
    
    	memset(&mii, 0x00, sizeof(MENUITEMINFO));
    	mii.cbSize = sizeof(MENUITEMINFO);
    	mii.fMask = MIIM_TYPE;
    	mii.fType = MFT_SEPARATOR;
    	InsertMenuItem(hSubMenu, GetMenuItemCount(hSubMenu), TRUE,  &mii);
    							
    	mii.cbSize = sizeof(MENUITEMINFO);
    	mii.fMask = MIIM_TYPE | MIIM_STATE | MIIM_ID;
    	mii.fType = MFT_STRING;
    	mii.fState = MFS_DEFAULT | MFS_UNHILITE;
    	mii.wID = ID_WORKPLACE_REMOVE;
    	mii.dwTypeData = "바구니에서 제거";
    	InsertMenuItem(hSubMenu, GetMenuItemCount(hSubMenu), TRUE,  &mii);
    

    27. 코드 실행 중에 다른 윈도우 메시지 처리하기

    하나의 함수 내에서 시간이 오래 걸리는 작업을 하고 있습니다. 이 때 작업 취소를 위한 별도의 다이얼로그를 하나 띄워 두었는데 이 쪽의 버튼이 눌리지 않습니다. 어떻게 해야할까요 ?

    시간이 오래 걸리는 작업을 별도의 스레드로 만들어 처리하던지 아니면 시간이 오래 걸리는 작업을 수행하는 함수 안에서 다음 코드를 가끔 호출해주면 됩니다. 만일 루프를 돌고 있다면 루프내에서 한번씩 호출해주면 됩니다.

    	MSG       msg;
    
    	while (PeekMessage(&msg, NULL, NULL, NULL, TRUE))
    	{
    		TranslateMessage(&msg);
    		DispatchMessage(&msg);
    	}
    

    VB에서라면 DoEvents라는 메소드를 호출해주면 됩니다.

    28. 메인 윈도우에서 캡션을 제거하고 싶습니다. 어떻게 해야 합니까 ?

    윈도우를 생성할 때 WS_CAPTION이란 스타일을 지정하지 않아도 항상 윈도우의 캡션이 보입니다. 이를 제거하려면 어떻게 해야 하나요 ?

    캡션을 제거하려는 윈도우의 WM_NCCREATE 메시지를 처리해야 합니다. 이 메시지는 WM_CREATE 메시지보다 앞서 발생하는 메시지입니다. NC는 Non-Client를 나타냅니다. GetWindowLong과 SetWindowLong API를 이용해서 WS_CAPTION 스타일을 제거합니다. 이 두 API는 앞서 리스트뷰 컨트롤에서 스타일 동적 변경하기에서 이미 사용해본 바 있습니다.

    		case WM_NCCREATE :
    		{
    			long lStyle;
        
    			lStyle = GetWindowLong(hWnd, GWL_STYLE);
    			lStyle = (lStyle & (~WS_CAPTION)); 
    			SetWindowLong (hWnd, GWL_STYLE, lStyle);
    			return TRUE;
    		}
    

    29. 사각형 형태 이외의 모양을 갖는 윈도우를 띄우고 싶습니다.

    윈도우는 기본 모양이 사각형인데 다른 형태의 모양을 갖는 윈도우를 띄우려면 어떻게 해야합니까 ?

    원하는 모양을 Region이란 것으로 만들어야 합니다. 만든 다음에 이것을 원하는 시점에 SetWindowRgn라는 API를 이용해 윈도우에 설정해주면 됩니다. 예를 들어 타원 모양의 윈도우를 띄우고 싶다면 다음과 같이 해주면 됩니다.

    	HRGN g_hRgn;
    
    	g_hRgn = CreateEllipticRgn(0, 0, 700, 600);
    	SetWindowRgn(hWnd, g_hRgn, FALSE);		
    

    이렇게 했을 경우 윈도우의 위치 이동이 문제가 됩니다. 보통 캡션을 잡고 이동시켜야 하는데 캡션이 없으니까 문제가 됩니다. 이에 관한 것은 31. 윈도우의 이동 처리하기를 참고하기 바랍니다.

    30. 시스템에 설치되어 있는 모든 프린터 드라이버 알아내기

    현재 시스템에 설치되어 있는 모든 프린터 드라이버의 종류를 알아내고 싶습니다.

    EnumPrinters라는 API를 사용하면 됩니다. 다음 코드는 현재 시스템에 설치되어 있는 모든 프린터 드라이버(로컬과 네트웍 프린터 포함)의 이름을 메시지박스로 보여주는 예제입니다.

    	BOOL bSuccess;
    	DWORD cbRequired, cbBuffer, nEntries;
    	PRINTER_INFO_1 *lpBuffer = NULL;
    
    	// 버퍼의 크기를 알아낸다. cbRequired로 들어온다.
    	EnumPrinters(PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_LOCAL, NULL, 1, (unsigned char *)lpBuffer, 0, &cbRequired, &nEntries);
    	cbBuffer = cbRequired;
    	// 버퍼를 다시 버퍼를 잡는다.
    	lpBuffer = (PRINTER_INFO_1 *)malloc(cbBuffer);
    	// 프린터의 종류를 알아낸다.
    	bSuccess =	EnumPrinters(PRINTER_ENUM_CONNECTIONS | PRINTER_ENUM_LOCAL, NULL, 1, (unsigned char *)lpBuffer, cbRequired, &cbRequired, &nEntries);
    	if (bSuccess == FALSE)
    	{
    		free(lpBuffer);
    		// 다른 이유로 에러가 난 경우
    		return;
    	}
    	// 알아낸 프린터를 하나씩 enumerate한다.
    	for (int i = 0;i < nEntries; i++)
    	{
    		::MessageBox(NULL, lpBuffer[i].pName, "프린터 이름", MB_OK);
      	}
    	free(lpBuffer);
    

    31. 윈도우의 이동 처리하기

    보통 윈도우는 캡션 영역을 잡고 위치 이동을 수행하게 되는데 윈도우의 특정 영역을 잡고 이동할 수 있게 하려면 어떻게 해야합니까 ?

    WM_NCHITTEST라는 메시지를 잘(?) 처리하면 어느 영역이든 윈도우 이동을 처리할 수 있습니다. 마우스로 윈도우 위를 이동하면 WM_NCHITTEST, WM_SETCURSOR, WM_MOUSEMOVE 같은 메시지들이 발생합니다. WM_NCHITTEST는 현재 마우스가 윈도우의 어느 영역위에 있는지 알아내기 위해 사용됩니다. 이 메시지의 처리부에서 HTCAPTION이란 값을 리턴해주면 윈도우 운영체제는 지금 마우스 포인터가 윈도우의 캡션 부분에 와있다고 생각해서 여기서 드래그 작업이 시작될 경우에 윈도우의 위치를 이동시켜 버립니다. 다음 코드는 WM_NCHITTEST 메시지를 처리하여 현재 마우스 좌표가 정해진 영역에 있으면 HTCAPTION을 돌려주는 예제입니다.

    	case WM_NCHITTEST:
    	{
    		// 현재 마우스 위치를 바탕으로 pt 변수를 채운다.
    		POINT pt(LOWORD(lParam), HIWORD(lParam));
    
    		// 마우스 좌표를 윈도우의 좌측 상단 기준의 좌표로 변경한다.
    		ScreenToClient(hWnd, &pt);
    		// 지정된 사각형 안에 포함되는 점인지 검사한다. 
    		// g_TitleRect는 RECT 타입의 변수로 지정된 사각형의 좌표가 들어있다.
    		if (PtInRect(&g_TitleRect, pt))
    			return HTCAPTION;			
    		break;
    	}
    

    32. 바탕 화면 위의 모든 윈도우를 최소화하거나 모든 최소화 실행 취소

    바탕 화면 위의 모든 윈도우를 최소화하거나 모두 최소화 실행 취소를 프로그램으로 구현하는 방법을 알고 싶습니다.

    태스크바의 빈 공간을 오른쪽 마우스 버튼으로 클릭해보면 팝업 메뉴가 뜨는데 거기에 보면 "모든 창을 최소화(M)"와 "모두 최소화 실행 취소(U)" 명령이 존재하는데 그것을 대신 선택해주는 형식으로 프로그램을 작성해주면 됩니다.

    다음은 "모든 창을 최소화"해주는 루틴입니다. keybd_event API를 이용해서 사용자가 키입력한 것처럼 흉내내줍니다.

    	void IconizeAllWindow()
    	{ 
    		keybd_event(0x5b, 0, 0, 0);
    		keybd_event(77, 0, 0, 0);    // 'M' key
    		keybd_event(0x5b, 0, 2, 0);
    	}
    

    다음은 "모두 최소화 실행 취소" 루틴입니다.

    	void RestoreWindowState()
    	{
    		keybd_event(0x5b, 0, 0, 0);
    		keybd_event(84, 0, 0, 0);    // 'U' key
    		keybd_event(0x5b, 0, 2, 0);
    	}
    

    33. VxD 드라이버 호출하기

    Vxd 드라이버를 동적으로 로드해서 호출하고 싶습니다. 어떻게 해야합니까 ?

    먼저 해당하는 VxD 드라이버의 이름과 위치와 호출하려는 작업의 작업 코드명을 알아야 합니다. 드라이버의 로드는 CreateFile API를 이용합니다. VxD 드라이버의 호출은 DeviceIoControl API를 이용합니다. 자세한 설명은 DeviceIoControl API의 레퍼런스를 참고하기 바랍니다.

    	DWORD byteReturned;
    
    	// 먼저 VxD 드라이버를 오픈한다.
    	hDevice = CreateFile("\\\\.\\NMOUSE.VXD", 0, 0, 0, CREATE_NEW, FILE_FLAG_DELETE_ON_CLOSE, 0);
    	if (DeviceIoControl(hDevice, W32_SETHWND, &hWnd, 4, NULL, 0, &byteReturned, NULL))
    	{
    		// Success !!!
    	}
    

    Vxd 드라이버는 kernel 모드(이를 윈도우에서는 Ring 0라고 부릅니다)에서 동작하기 때문에 모든 하드웨어와 메모리를 바로 접근할 수 있습니다. VxD 드라이버를 작성하려면 DDK를 이용하거나 Numega의 DriverStudio나 KRFTech사의 WinDriver를 이용해야 합니다.

    34. Thread 실행시 에러

    CreateThread를 이용해 스레드를 만들어 생성하고 있습니다. 루틴에 이상은 없는 것 같은데 스레드가 많이 생성되어 시간이 좀 지나면 에러가 발생합니다. 이유가 무엇일까요 ?

    정말로 스레드 코드에 별 이상이 없다면 CreateThread API 대신에 beginthread나 beginthreadex를 사용해보기 바랍니다. 자세한 사항은 마이크로소프트의 Knowledge base를 참고하시기 바랍니다.

    35. 윈도우 운영체제 종료하기

    프로그램에서 특정 상황이 되면 윈도우 운영체제를 종료하고 싶습니다.

    ExitWindowsEx API를 사용하면 됩니다. 이 함수의 원형은 다음과 같습니다.

    	BOOL ExitWindowsEx(UINT uFlags, DWORD dwReserved);
    

    uFlags로 종료방법을 지정할 수 있습니다. 다음과 같은 값이 가능합니다.

    EWX_LOGOFF 현재 사용자를 로그오프한다.
    EWX_POWEROFF 시스템을 종료하고 파워오프한다. 파워오프는 이를 지원하는 하드웨어에서만 가능하다.
    EWX_REBOOT 시스템을 종료하고 시스템을 재시동한다.
    EWX_SHUTDOWN 시스템을 종료한다.
    EWX_FORCE WM_QUERYSESSION이나 WM_ENDQUERYSESSION을 보내지 않고 실행중인 모든 프로세스를 종료한다. 위의 네 가지 플래그들과 함께 사용할 수 있다.

    36. 디폴트 웹 브라우저 알아내기

    디폴트로 지정된 웹 브라우저를 실행하는 방법을 알고 싶습니다.

    디폴트로 지정된 웹 브라우저는 레지스트리에 자신을 등록합니다. .htm (혹은 .html) 파일의 편집기로 링크도 되지만 http, ftp, gopher 등의 프로토콜 연결 프로그램으로 등록됩니다. 제 생각에 가장 좋은 것은 http 프로토콜의 연결 프로그램을 찾아보는 것으로 생각됩니다. 다음 레지스트리 항목에 보면 연결된 웹 브라우저의 절대 경로를 알 수 있습니다.

        HKEY_CLASSES_ROOT\http\shell\open\command
    

    이 항목의 값을 읽는 방법은 3. 레지스트리 읽기/쓰기를 참고하고 프로그램의 실행에 관한 부분은 19. 특정 프로그램을 실행하고 종료를 기다리기를 참고하거나 ShellExecute API 혹은 WinExec API를 사용하면 됩니다. 이 기능을 수행하는 함수는 다음과 같습니다.

    void LaunchDefaultWebBrowser(HWND hWnd)
    {
        // HKEY_CLASSES_ROOT\http\shell\open\command
    	DWORD dwType, cbData;
    	HKEY hSubKey; 
    	long lRet;
    	LPSTR pszString, pszSrcPath;
    
    	// 키를 오픈한다.
    	if ((lRet = RegOpenKeyEx(HKEY_CLASSES_ROOT, "http\\shell\\open\\command",
    			0, KEY_READ | KEY_QUERY_VALUE , &hSubKey)) == ERROR_SUCCESS)
    	{
    		cbData = 255;	// 문자열 값을 읽어올 데이터의 크기를 준다.
            pszString = (LPSTR)malloc(255);
            pszSrcPath = pszString;
    		if ((lRet = RegQueryValueEx(hSubKey, "",
    			NULL, &dwType, (unsigned char *)pszString, &cbData)) == ERROR_SUCCESS)
    		{
    			// pszString에 디폴트 웹 브라우저의 경로가 들어온다.
                // pszString에서 "를 제거한다.
                RemoveChar(pszString, '"');
                WinExec(pszString, SW_SHOWNORMAL);            
    		}
            free(pszString);
    		RegCloseKey(hSubKey);
    	}
    }
    
    void RemoveChar(LPSTR lpSrc, char chRemove)
    {
    	LPTSTR pstrSource = lpSrc;
    	LPTSTR pstrDest = lpSrc;
    	LPTSTR pstrEnd = lpSrc + strlen(lpSrc);
    
    	while (pstrSource < pstrEnd)
    	{
    		if (*pstrSource != chRemove)
    		{
    			*pstrDest = *pstrSource;
    			pstrDest = _tcsinc(pstrDest);
    		}
    		pstrSource = _tcsinc(pstrSource);
    	}
    	*pstrDest = '\0';
    }
    

    Copyright 1999© 한기용 Last updated: 07/29/2004 12:27:59 Designed By 한기남

    37. 윈도우의 최대/최소 크기 설정

    윈도우의 캡션을 없앴을 경우 윈도우를 최대화했을 때 아래의 Task Bar가 가려져 버리는 현상이 생기는데.. 캡션이 있으면 Task Bar 위로만 최대화되는데 말입니다. 어떻게 해결할 수 있는 방법이 없나 궁금하네요..

    말씀하신 문제를 해결하려면 WM_GETMINMAXINFO 메시지를 처리해야 합니다. 이는 윈도우의 최대 크기 등을 설정하기 위해 사용되는 메시지입니다. 다음과 같이 처리합니다.

    case WM_GETMINMAXINFO :
    {
        LPMINMAXINFO lpmmi;
        RECT rc;
    
        SystemParametersInfo(SPI_GETWORKAREA, 0, &rc,0);
        lpmmi = (LPMINMAXINFO)lParam; 
        lpmmi->ptMaxSize.x = rc.right; 
        lpmmi->ptMaxSize.y = rc.bottom; 
        lpmmi->ptMaxPosition.x = 0; 
        lpmmi->ptMaxPosition.y = 0; 
        lpmmi->ptMinTrackSize.x = GetSystemMetrics(SM_CXMINTRACK); 
        lpmmi->ptMinTrackSize.y = GetSystemMetrics(SM_CYMINTRACK); 
        lpmmi->ptMaxTrackSize.x = GetSystemMetrics(SM_CXMAXTRACK); 
        lpmmi->ptMaxTrackSize.y = GetSystemMetrics(SM_CYMAXTRACK); 
        break;
    }
    

    38. Thread에서 Automation 메소드 호출시 에러 발생

    Thread를 생성하고 Automation 메소드를 호출했는데 에러가 발생합니다.

    App 클래스의 InitInstance 함수에서 AfxOleInit를 호출하는 부분을 CoInitializeEx(NULL, COINIT_MULTITHREADED)를 호출하는 것으로 변경하기 바랍니다. 그리고 App 클래스에 ExitInstance 함수를 추가하고 거기서 CoUninitialize를 호출하도록 하면 됩니다. MFC의 AfxOleInit는 기본적으로 STA(Single Threading Apartment) 모델을 사용합니다. Thread에서 자신이 생성하지 않는 COM 객체를 접근할 때는 MTA(Multiple Threading Apartment) 모델을 사용해야 합니다.

    39. 최상위 윈도우의 종료 방법

    현재 최상위 윈도우를 찾아서 종료하는 코드를 만들고 싶습니다.

    일단 현재 사용자가 작업 중인 최상위 윈도우의 핸들은 GetForegroundWindow API로 얻어냅니다. 그런데 그 윈도우가 자식 윈도우일 수 있기 때문에 GetParent API를 반복적으로 사용해서 최상위 탑 레벨 윈도우의 핸들을 알아냅니다. 종료하는 방법은 먼저 DestroyWindow를 호출해서 시도해보고 실패하면 시스템 메뉴의 "닫기" 명령을 이용해 처리합니다. 사실 이 것도 실패할 수 있는데 무조건 종료시키고 싶다면 아래 코드에서 주석 처리해 놓은 GetWindowThreadProcessId/Terminate API 부분을 사용하면 됩니다.

        HWND hTopWnd = GetForegroundWindow();
        if (hTopWnd == NULL)
        {
            return;
        }
    
        while(GetParent(hTopWnd))
        {
            hTopWnd = GetParent(hTopWnd);
        }
    
        if (DestroyWindow(hTopWnd) == FALSE)
        {
            SendMessage(hTopWnd, WM_SYSCOMMAND, SC_CLOSE, NULL);
            //GetWindowThreadProcessId(hTopWnd, &dwProcessId);
            //TerminateProcess(dwProcessId);
        }            
    
    

    40. 인터넷 익스플로러의 위치 경로 알아내기

    인터넷 익스플로러가 설치된 절대 경로를 알고 싶습니다.

    레지스트리의 HKEY_LOCAL_MACHINE\Software\Microsoft\Windows\CurrentVersion\App Paths\IExplore.exe 의 기본 값으로 인터넷 익스플로러의 설치 경로가 들어옵니다. 다음 함수를 호출하면 설치 경로를 얻어 줍니다. 인자로 넘어가는 lpPath는 적어도 256바이트 이상의 크기를 갖는 문자 배열이어야 합니다.

    BOOL GetIEPath(LPTSTR lpPath) 
    {
        long lRet;
        HKEY hKey;
    
        lRet = RegOpenKey(HKEY_LOCAL_MACHINE, "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\IExplore.exe", &hKey);
        if (lRet == ERROR_SUCCESS)
        {
            long cbData = 255;
    
            RegQueryValue(hKey, "", lpPath, &cbData);
            RegCloseKey(hKey);
        }
        else
            return FALSE;
        return TRUE;
    }
    
  • 반응형

    ** 간단정리 ****

    후킹이란?


    다른 프로그램(프로세스), 쓰레드가 메시지를 받기 전에 가로채는 기술


    IPC란?

    프로그램끼리 메시지나, 데이타를 주고받는 기능


    후킹예제는 F9를 눌렀을때 비프음 내기


    1. 쓰레드 후킹은 쉽지만, 프로세스 후킹은 DLL을 통해서 해야한다.(꼭 그렇게 해야된다고 생각)


    먼저 DLL만들기.

    DLL이란? 프로그램 실행중 동적으로 연결 되는 라이브러리로 만든 파일

    즉~ 실행파일은 이 함수가 없기때문에 DLL파일에서 참조를 해와서 수행

    DLL 파일이 없으면 이 프로그램도 작동 안함



    2. Visaul C++ 실행 --> File --> New --> Projects탭 --> Win32 Dynamic-Link Library 체크 --> Project name "Kartdll"

    --> OK --> An empty DLL project --> Finish --> OK


    3. File --> New --> C++ Source File --> File "Kart" --> OK


    4. 소스


    #include <windows.h>

    #pragma data_seg(".kbdata")  // 시작


    HINSTANCE hModule=NULL; // 시작과 끝 사이에 객체를 선언

    HHOOK hKeyHook=NULL;

    HWND hWndBeeper=NULL;


    #pragma data_seg()

    #pragma comment (linker, "/SECTION:.kbdata,RWS") //끝


    // 키보드 입력이 먹혔을때 F9키이면 WM_USER+1 이라는 메세지를 보낸다

    // 즉 프로그램에 WM_USER+1 메세지를 받으면 비프음이 나게 한다


    LRESULT CALLBACK KeyHookProc(int nCode, WPARAM wParam, LPARAM lParam)

    {

       if (nCode>=0) {

           if(wParam==VK_F9)

               SendMessage(hWndBeeper,WM_USER+1,wParam,lParam);

       }


       return CallNextHookEx(hKeyHook,nCode,wParam,lParam);

    }

    // 훅을 설치하는 함수

    extern "C" __declspec(dllexport) void InstallHook(HWND hWnd)

    {

       hWndBeeper=hWnd;

       hKeyHook=SetWindowsHookEx(WH_KEYBOARD,KeyHookProc,hModule,NULL);

    }


    // 훅을 제거하는 함수

    extern "C" __declspec(dllexport) void UninstallHook()

    {

       UnhookWindowsHookEx(hKeyHook);

    }


    // Dll 메인함수

    BOOL WINAPI DllMain(HINSTANCE hInst, DWORD fdwReason, LPVOID lpRes)

    {

       switch (fdwReason) {

       case DLL_PROCESS_ATTACH:

          hModule=hInst;

          break;

       case DLL_PROCESS_DETACH:

          break;

       }

       return TRUE;

    }


    5. 컴파일


    6. 이 상에서 만든 dll을 참조하는 프로그램 작성


    7. Visaul C++ 실행 --> File --> New --> Projects탭 --> Win32 Application 체크 --> Project name "KartRider" --> OK


    8.File --> New --> C++ Source File --> File "Kart" --> OK


    9.kartdll 프로젝트에 Debug 폴더의 kartdll.dll, kartdll.lib 프로젝트 kartrider 폴더에 복사


    10. 메뉴에서 Project --> Settings --> Link탭 --> Object/library modules: kartdll.lib


    11. KartRider 소스


    #include <windows.h>

    #include <mmsystem.h>  // 비프 소리


    // dll에서 함수를 참조할때 이런식으로 써주면 되요

    extern "C" __declspec(dllimport) void InstallHook(HWND hWnd); 

    extern "C" __declspec(dllimport) void UninstallHook();


    LRESULT CALLBACK WndProc(HWND,UINT,WPARAM,LPARAM);

    LPSTR lpszClass="간단한 후킹";


    int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance

              ,LPSTR lpszCmdParam,int nCmdShow)

    {

        HWND hWnd;

        MSG Message;

        WNDCLASS WndClass;

        WndClass.cbClsExtra=0;

        WndClass.cbWndExtra=0;

        WndClass.hbrBackground=(HBRUSH)GetStockObject(WHITE_BRUSH);

        WndClass.hCursor=LoadCursor(NULL,IDC_ARROW);

        WndClass.hIcon=LoadIcon(NULL,NULL);

        WndClass.hInstance=hInstance;

        WndClass.lpfnWndProc=(WNDPROC)WndProc;

        WndClass.lpszClassName=lpszClass;

        WndClass.lpszMenuName=NULL;

        WndClass.style=CS_HREDRAW | CS_VREDRAW;

        RegisterClass(&WndClass);

        hWnd=CreateWindow(lpszClass,lpszClass,WS_CAPTION | WS_SYSMENU,

            100,100,405,200,

            NULL,(HMENU)NULL,hInstance,NULL);

        ShowWindow(hWnd,nCmdShow);

        while(GetMessage(&Message,0,0,0)) {

            TranslateMessage(&Message);

            DispatchMessage(&Message);

        }

        return Message.wParam;

    }


    LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)

    {

        switch(iMessage)

        {

        case WM_DESTROY:

            UninstallHook();

            PostQuitMessage(0);

            return 0;

        case WM_CREATE :

            InstallHook(hWnd); 

            return 0;

        case WM_USER+1: // F9를 누르면 F9를 눌렀다는 메세지가 Dll을 먼저 거친다음 WM_USER+1

                                   // 메시지를 보낸다

            MessageBeep(NULL);

            return 0;

        }

        return(DefWindowProc(hWnd,iMessage,wParam,lParam));

    }


    *************************************************************************************

    카트라이더 프로그램에 VK_F5와 VK_RETURN 메세지를 보내준다

    실전 예제


    #include <windows.h>

    LRESULT CALLBACK WndProc(HWND,UINT,WPARAM,LPARAM);

    HINSTANCE g_hInst;

    LPSTR lpszClass="카트라이더 매크로";

    HANDLE hTimer;

    HWND hWnd2; // 카트라이더 윈도우 핸들을 위해...

    int time;

    int APIENTRY WinMain(HINSTANCE hInstance,HINSTANCE hPrevInstance

              ,LPSTR lpszCmdParam,int nCmdShow)

    {

        HWND hWnd;

        MSG Message;

        WNDCLASS WndClass;

        g_hInst=hInstance;

       

        WndClass.cbClsExtra=0;

        WndClass.cbWndExtra=0;

        WndClass.hbrBackground=(HBRUSH)GetStockObject(WHITE_BRUSH);

        WndClass.hCursor=LoadCursor(NULL,IDC_ARROW);

        WndClass.hIcon=LoadIcon(NULL,NULL);

        WndClass.hInstance=hInstance;

        WndClass.lpfnWndProc=(WNDPROC)WndProc;

        WndClass.lpszClassName=lpszClass;

        WndClass.lpszMenuName=NULL;

        WndClass.style=CS_HREDRAW | CS_VREDRAW;

        RegisterClass(&WndClass);

        hWnd=CreateWindow(lpszClass,lpszClass,WS_CAPTION | WS_SYSMENU,

            100,100,405,200,

            NULL,(HMENU)NULL,hInstance,NULL);

        ShowWindow(hWnd,nCmdShow);

       

        while(GetMessage(&Message,0,0,0)) {

            TranslateMessage(&Message);

            DispatchMessage(&Message);

        }

        return Message.wParam;

    }

    LRESULT CALLBACK WndProc(HWND hWnd,UINT iMessage,WPARAM wParam,LPARAM lParam)

    {

        switch(iMessage)

        {

        case WM_DESTROY:

            KillTimer(hWnd, 1);

            PostQuitMessage(0);

            return 0;

        case WM_CREATE :

            time=0;

            hWnd2 = FindWindow(NULL, "KartRider Client"); // 카트라이더 윈도우 찾아서 hWnd2에 대입

            // 카트라이더 윈도우 : "KartRider Client"

            if(hWnd2 == NULL)

                MessageBox(hWnd, "카트라이더가 켜진 상태에서 프로그램 실행시켜 주세요",NULL,NULL);

            else

                hTimer=(HANDLE) SetTimer(hWnd, 1, 1000, NULL);

            return 0;

        case WM_TIMER :

            time++;

            if(time==1) {

                SendMessage(hWnd2, WM_KEYDOWN, VK_RETURN, NULL); 

                    // hWnd2 카트라이더 윈도우 핸들에 메세지 전달

                SendMessage(hWnd2, WM_KEYUP, VK_RETURN, NULL); 

            }

            if(time==2) {

                SendMessage(hWnd2, WM_KEYDOWN,VK_F5,NULL);

                SendMessage(hWnd2, WM_KEYUP,VK_F5,NULL);

            }

            if(time==3)

                time=0;

            return 0;

        }

        return(DefWindowProc(hWnd,iMessage,wParam,lParam));

    }


    //Point/////////////////////////////////////////////////////////////////////////

    hWnd2 = FindWindow(NULL, "KartRider Client");

    SendMessage(hWnd2, WM_KEYDOWN, VK_RETURN, NULL);

    ///////////////////////////////////////////////////////////////////////////////


    카트라이더 윈도우 핸들을 얻은다음 그핸들에 메세지를 보낸다

    카트라이더 키고 프로그램 실행하면 자동으로 래디

    후킹 : F9키 눌렀을때 매크로 시작 F10키 눌렀을때 중지 기능

    F9키 누르면 타이머 작동, F10키 누르면 타이머 Kill

    ****************************************************************************************


    아래와 같은 형태의 콜백 함수는 away기능을 구현하기 위한 후킹에 별로 효과적이지 않습니다.


    LRESULT CALLBACK KeyboardHook (int nCode, WORD wParam, DWORD lParam )

    {

        if(nCode>=0)

            lastTime = CTime::GetCurrentTime(); // lastTime이 공유변수입니다.


        return (int)CallNextHookEx(kbHook, nCode, wParam, lParam);

    }


    LRESULT CALLBACK MouseHook (int nCode, WORD wParam, DWORD lParam )

    {

        if(nCode>=0)

            lastTime = CTime::GetCurrentTime();


        return (int)CallNextHookEx(kbHook, nCode, wParam, lParam);

    }



    그 이유는 다이얼로그 박스가 하나 생성되고 소멸될 때마다 마우스 쪽 훅체인으로 HTCLIENT 메시지가 날아가기 때문입니다.

    키보드는 문제가 없습니다.


    따라서 마우스 쪽 콜백함수는 다음과 같이 작성하는 것이 낳습니다.


    LRESULT CALLBACK MouseHook (int nCode, WORD wParam, DWORD lParam )

    {

        if(nCode>=0)

        {    UINT a=((MOUSEHOOKSTRUCT*)lParam)->wHitTestCode;

            if(a!=HTCLIENT)

            {    lastTime = CTime::GetCurrentTime();

            }

        }

        return (int)CallNextHookEx(msHook, nCode, wParam, lParam);

    }


    MOUSEHOOKSTRUCT와 wHitTestCode에 대한 것은 MSDN을 참조하시길...



    위와같이하면 아웃룩의 정기적인 메일 체크기능이나 다른 몇몇 프로그램들이 내부적으로 다이얼로그 박스를 생성하고 없애더라도 거기에 영향을 받지 않고 away(자리비움) 기능을 제대로 사용할 수 있습니다.


    ****************************************************************************************


    Win32 Global API Hook - 1 Win32 API 후킹의 기본


    자신이 만약 어느정도의 레벨을 가진 윈도우즈 프로그래머라면 이런 생각을 한번쯤 해보았을 것이다.


    "만약 Windows API를 후킹할 수 있다면 재미있는 것을 많이 해볼 수 있을텐데..."


    그리고 의욕과 시간이 있었다면 아마 도전해본 사람도 꽤 있었을것이다. 그러나 실제로 이것을 성공한 사람은 그리 많지 않았을것으로 안다. 여러분이 만약 어플리케이션 레벨에서만 프로그래밍했다면 이것은 불가능해보였을지도 모른다. 시스템 레벨 프로그래머라면 이것이 제법 까다롭고 다루기 힘든 주제라는것을 알았을 것이다. 자 여기서 필자는 많은 사람들이 궁금해하는 이 비밀스런 작업을 하나하나 풀어가려고 한다. 그리고 의외로 간단한 곳에 해답이 있었음을 이 강좌가 끝날때쯤 알게될 것이다.


    자, 서론을 접고 본론으로 들어가자. 우리가 하려는 일은 다음과 같다.


    Win32 API를 후킹해서 내가 원하는 작업을 수행하거나, 작업의 흐름을 원하는대로 제어할 수 있다.


    단지 이것이다. 무슨 설명이 더 필요한가? 실제로 우리가 하려는 것은 이것이 전부이다. 예를 들어서 쉽게 말하자면 CreateProcess() 라는 API를 후킹하면 내가 원하지않는 프로그램의 실행을 막을수도 있고, 윈속함수인 send()나 recv()를 후킹하면 나가고 들어오는 패킷을 훔쳐보거나 조작할 수 있다. 느낌이 팍 오지 않는가? 느낌이 오지않는 사람은 아마도 아직 이 강좌를 들을만한 수준이 아니거나 해커(순수한 의미의)의 기질이 없는 사람일 수도 있다.


    일단 오늘은 첫날이니 그동안 많은 사람들이 제시했던 Windows API 후킹방법에 대해서 먼저 얘기해보자.


    1. exe 파일헤더의 import descriptor table을 변경하는 방법


    가장 쉬운 방법이 되겠다. 물론 쉬운만큼 문제점이 적지 않다. 일단 Win32 응용프로그램은 PE(Portable Executable)이라는 형식으로 바이너리화 되어있다. 실행가능한 바이너리 파일구조에는 POSIX, COFF, PE 형식등등이 있는데, 윈도는 PE형식을 사용한다. 따라서 PE형식을 알면 윈도실행파일구조를 분석할 수 있겠다. 실제 윈도실행파일은 크게 헤더, 리소스, 임포트테이블, 익스포트테이블, 데이터, 코드 등등의 영역으로 나누어지는데 여기서 임포트테이블이 이름 그대로 임포트된 라이브러리의 함수에 관한 정보가 담겨졌있다. 따라서 정적으로 링크된 모든 함수는 이곳에서 볼수 있게된다. 그러면 이부분의 임포트된 함수의 주소들을 내가 만든 후킹함수주소로 바꿔치기해주면 간단할 것이다. 아래에 리스트된 코드를 보자.


    // 포인터 변환 및 연산 매크로

    #ifndef MakePtr

    #define MakePtr(cast, ptr, addValue) (cast)((DWORD)(ptr)+(DWORD)(addValue))

    #endif // MakePtr


    PIMAGE_IMPORT_DESCRIPTOR GetImportDescriptor(HMODULE hMod,        // 모듈

                                            LPCTSTR pszModName)// 모듈이름

    {

        TRACE("[FIND IMPORT DESCRIPTOR] \n");


        // 매개변수 유효성 검사

        ASSERT(!IsBadReadPtr(hMod, sizeof(IMAGE_DOS_HEADER)));

        ASSERT(NULL != pszModName);


        // DOS 헤더

        PIMAGE_DOS_HEADER pDosHdr = (PIMAGE_DOS_HEADER)hMod;

        if(IMAGE_DOS_SIGNATURE/*0x5A4D*/ == pDosHdr->e_magic)

        {

            // NT 헤더

            PIMAGE_NT_HEADERS pNtHdr = MakePtr(PIMAGE_NT_HEADERS,

                pDosHdr, pDosHdr->e_lfanew);

            if(!IsBadReadPtr(pNtHdr, sizeof(IMAGE_NT_HEADERS))

                && IMAGE_NT_SIGNATURE/*0x00004550*/ == pNtHdr->Signature)

            {

                // image descriptor

                PIMAGE_IMPORT_DESCRIPTOR pImpDesc =

                    MakePtr(PIMAGE_IMPORT_DESCRIPTOR,

                        pDosHdr,

                        pNtHdr->OptionalHeader.

                        DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].

                        VirtualAddress);

                if(NULL != pImpDesc)

                {

                    while(NULL != pImpDesc->Name)

                    {

                        PSTR pszName = MakePtr(PSTR, pDosHdr, (DWORD)pImpDesc->Name);

                        TRACE(" %s ", pszName);


                        if(stricmp(pszName, pszModName) == 0)

                        {

                            // 찾았다 !!

                            TRACE(": Found It !! \n");


                            return pImpDesc;

                        }


                        TRACE("\n");

                        pImpDesc++;

                    }

                }

            }

        }


        return NULL;

    }


    PROC WINAPI HookImportFunction(HMODULE hMod,// Hooking 할 모듈

                         PSTR pszModName,        // Hooking 함수가 위치한 모듈이름

                         PSTR pszFuncName,        // Hooking 할 함수

                         PROC pfnNewProc)        // Hooking 함수

    {

        // 매개변수 유효성 검사

        ASSERT(!IsBadReadPtr(hMod, sizeof(IMAGE_DOS_HEADER)));

        ASSERT(NULL != pszModName);

        ASSERT(NULL != pszFuncName);

        ASSERT(NULL != pfnNewProc);

        ASSERT(!IsBadCodePtr(pfnNewProc));


        // 반환값 (원래 함수)

        PROC pfnOrgProc = NULL;


        // Win9x이고 2GB 이상의 시스템 DLL 이라면

        // 조용히 사라져야 한다.

        if(1 != __GetOsType() && 0x80000000 < (DWORD)hMod)

        {

            return NULL;

        }


        // import descriptor 를 얻는다.

        PIMAGE_IMPORT_DESCRIPTOR pImpDesc = GetImportDescriptor(hMod, pszModName);

        if(NULL == pImpDesc)

            return NULL;


        // 원본 thunk

        PIMAGE_THUNK_DATA pOrgThunk = MakePtr(PIMAGE_THUNK_DATA,

            hMod, pImpDesc->OriginalFirstThunk);


        // 실제 thunk (for Hooking)

        PIMAGE_THUNK_DATA pRealThunk = MakePtr(PIMAGE_THUNK_DATA,

            hMod, pImpDesc->FirstThunk);


        // 루프를 돌면서 Hooking 할 함수를 찾는다.

        TRACE("[FIND IMPORT FUNCTION] : %s \n", pszModName);

        while(NULL != pOrgThunk->u1.Function)

        {

            // 이름으로 import된 함수만 검색

            if(IMAGE_ORDINAL_FLAG !=

                (pOrgThunk->u1.Ordinal & IMAGE_ORDINAL_FLAG))

            {

                // import된 함수 이름

                PIMAGE_IMPORT_BY_NAME pByName =

                    MakePtr(PIMAGE_IMPORT_BY_NAME,

                    hMod, pOrgThunk->u1.AddressOfData);


                // 이름이 NULL로 시작되면 넘어간다.

                if(0 == pByName->Name[0])

                    continue;


                TRACE(" %s ", (PSTR)pByName->Name);


                if(pszFuncName[0] == pByName->Name[0]

                    && 0 == stricmp(pszFuncName, (PSTR)pByName->Name))

                {

                    // 찾았다 !!

                    TRACE(": Found It !! \n");


                    MEMORY_BASIC_INFORMATION mbi;

                    VirtualQuery(pRealThunk, &mbi, sizeof(mbi));


                    // 가상 메모리의 보호속성 변경

                    if(!VirtualProtect(mbi.BaseAddress, mbi.RegionSize,

                        PAGE_READWRITE, &mbi.Protect))

                    {

                        // VirtualProtect() fail !!

                        ASSERT(0);

                        return NULL;

                    }


                    // 원래 함수(반환값) 저장

                    pfnOrgProc = (PROC)pRealThunk->u1.Function;


                    // 새로운 함수로 덮어쓴다.

                    TRACE("** old function : 0x%08X \n", (DWORD)pRealThunk->u1.Function);

                    pRealThunk->u1.Function =

                        (DWORD)pfnNewProc;

                    TRACE("** new function : 0x%08X \n", (DWORD)pRealThunk->u1.Function);


                    // 가상 메모리의 보호속성 원래대로 되돌림

                    DWORD dwTmp;

                    VERIFY(VirtualProtect(mbi.BaseAddress, mbi.RegionSize,

                        mbi.Protect, &dwTmp));


                    // 세상을 다 가져라 !!

                    TRACE("** Hook OK !! What a wonderful world !! \n");

                    return pfnOrgProc;

                }


                TRACE("\n");

            }


            pOrgThunk++;

            pRealThunk++;

        }


        return NULL;

    }


    GetImportDescriptor()함수는 모듈의 임포트디스크립터를 얻어내는 함수이고, HookImportFunction()함수는 실제로 임포트된 함수를 후크하는 함수이다. 이 두 함수를 이용해서 MessageBox API를 후킹해보자.


    // MessageBox API 원형

    typedef int (WINAPI *PROC_MESSAGEBOX)(HWND, PSTR, PSTR, UINT);


    PROC_MESSAGEBOX pfnOrgMessageBox = NULL;


    // MessageBox를 대체할 훅함수

    int WINAPI MyMessageBoxA(HWND hWnd, PSTR pszText, PSTR pszTitle, UINT uType)

    {

        ASSERT(NULL != pfnOrgMessageBox);


        return pfnOrgMessageBox(NULL, "Hooked MessageBox !!", "Hooked !!", MB_ICONINFORMATION);

    }


    // Main 프로세스

    int APIENTRY WinMain(HINSTANCE hInstance,

                         HINSTANCE hPrevInstance,

                         LPSTR     lpCmdLine,

                         int       nCmdShow)

    {

         // TODO: Place code here.


        MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);


        // hook !!

        pfnOrgMessageBox = (PROC_MESSAGEBOX)HookImportFunction(

            GetModuleHandle(NULL), "user32.dll", "MessageBoxA", (PROC)MyMessageBoxA);

        if(NULL == pfnOrgMessageBox)

            return 0;


        MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);


        // unhook !!

        pfnOrgMessageBox = (PROC_MESSAGEBOX)HookImportFunction(

            GetModuleHandle(NULL), "user32.dll", "MessageBoxA", (PROC)pfnOrgMessageBox);

        if(NULL == pfnOrgMessageBox)

            return 0;

        TRACE("replace orginal function \n");


        MessageBox(NULL, "Default MessageBox", "Windows 98", MB_ICONINFORMATION);


        return 0;

    }


    MyMessageBoxA 라는 함수를 보면 타이틀에 Hooked !! 라는 캡션을 가진 Hooked MessageBox !! 메시지를 출력하는 메시지 박스를 보여주는 함수이다. 후킹이 성공적으로 이루어지면 MessageBox는 무조건 위와같은 형태로 보여지게 될것이다. MessageBoxA의 A 문자는 ANSI 문자열 버전으로서 Windows 9x 계열에서 주로 사용되며 1바이트 문자를 사용한다. 반대로 W가 붙은 API는 Wide Charactor로서 2바이트 문자를 사용하는 유니코드 사용 API이다. 실제로 Win9x 계열에서는 생략할경우 A문자 버전의 API가 호출되도록 재지정되어 있으며, NT4/W2K 계열에서는 내부적으로는 W문자 버전의 API가 호출된다. 문자열을 인자로 사용하는 대부분의 API가 이와같이 두가지 버전으로 나뉘어지며 만약 훅함수를 설치한다면 정확한 버전을 사용해야 할것이다.


    실행시켜보면 실제로 중간의 MessageBox(NULL, "Default MessageBox", "Windows 98",

    MB_ICONINFORMATION); 함수는 우리가 지정한 후킹함수로 대체되어 Hooked ... 라는 메시지 박스가 출력될것이다.


    자, 원리를 알고 실행이 제대로 되는것을 확인했다면 문제점에 대해서 알아보자.

    위의 방법으로는 현재 내 프로그램만이 후킹될수 있다. 이유인즉슨 임포트 디스크립터 테이블이라는것이 프로그램마다 가지고 있는것이기 때문에 당연히 내 프로그램만이 적용되어진다. 그렇다면 다른 프로그램들도 모조리 임포트 디스크립터 테이블을 변경하면 될것이 아닌가? 라고 생각하는 사람이 있을지 모르겠지만, 그건 불가능하다. 왜냐하면 Win32 응용프로그램들은 각각 독립적인 주소공간에서 실행되기때문에 기본적으로 서로 다른 프로그램의 영역을 침범할수 없다. 그러면 어떻게 다른 응용프로그램(정확히 말하면 프로세스)의 주소공간을 볼수있을까? 이 문제에 관해서는 다음 강좌에서 다루어 보도록 하자.


    끝으로...오늘은 Win32 API 후킹에 대한 맛보기였다. 사실 오늘 제시한 방법으로는 우리가 생각하는 것들을 하기에는 턱없이 모자란다. 그렇지만 모든것은 순서가 있듯이 가장 기본적인 방법론부터 제시해보았다. 사실 오늘 강좌에도 따라오는 부수적인 내용은 상당히 방대하다. 일단 Windows 실행파일구조인 PE구조에 대한 이해와 Windows 프로세스간 메모리 관리 또한 매우 중요한 내용이다. 지면상(또는 시간상 ^^) 여기서 다 다룰수는 없지만 참고할만한 서적을 소개하면 PE구조에 관해서는 MSJ나 마이크로소프트웨어 잡지를 검색하면 찾을 수 있을것이다. 실제로 PEDUMP 같은 유틸리티를 작성해본다면 더없이 좋을것이다. Win32 메모리관리에 관해서는 Jeffry Richter의 Advanced Windows 라는 책을 추천한다. 좀 오래된 책인데 필자가 공부할때는 가장 훌륭했다고 여겨지는 책이다. 아마 다른 시스템 프로그래밍을 다루는 책에서도 자료를 얻을 수 있을것이다. 또한 위 소스코드의 모체가된 John Robbins의 Debugging Applications라는 책을 참고하는 것도 좋을 것이다.


    노파심에서 사족을 달자면, 혹시 C/C++와 Windows 시스템과 메카니즘에 익숙하지 않다면 과감히 강좌를 보는것을 포기하길 권유한다. 적어도 위의 소스코드정도는 쉽게 이해할 수 있어야 할것이다. 또한 필자의 사정상 빨라야 일주일에 두번정도 강좌를 올릴수 있을것 같다. 그래서 전체적인 커리를 다음과 같이 제시하니 필요한 부분은 미리미리 공부한다면 빨리 따라올 수 있을것이다.


    1. Win32 API 후킹의 기본

    2. 다른 프로세스의 주소공간으로 들어가자 !!

    3. Win32 어셈블리 프로그래밍

    4. Win9x 디바이스 드라이버(VxD) 모델

    5. 기계어 프로그래밍 - Shell Code 작성

    6. Win9x Global API Hooking

    7. WinNT/2000 디바이스 드라이버 모델

    8. WinNT/2000 Global API Hooking


    앞으로 전개될 강좌의 주요 테마이다. 하나같이 만만하지 않은 주제들로 이루어져있으며 한두회의 강좌로는 턱없이 부족한 주제도 대부분일 것이다. 그러나 차근차근 따라오다보면 어느새 자신의 실력이 부쩍 향상되어 있음을 피부로 느낄것이다. 이것은 필자가 이름을 걸고 맹세할 수 있다. 실제로 디바이스드라이버 프로그래밍과 어셈블리 프로그래밍에 대한 경험이 있거나, 얼마전까지 유행하던 해킹기법인 Stack Overflow의 익스플로잇과 쉘코드를 직접 제작할 수 있는 능력이 있다면 강좌를 따라오기가 한결 수월할 것이다.




    /*===================================

    Win32 Global API Hooking - 쉬어가기

    ===================================*/


    안녕하세요? API 후킹강좌를 진행하고 있는 성상훈입니다.

    사실 처음에 강좌를 진행할때는 이정도로 많은 분들이 관심을 가질거라고는 생각을 못했었습니다. 어쨋든 의외로 많은 분들이 관심과 응원을 보내주시니 몸둘바를 모르겠군요. 전체 강좌가 초반을 지나서 중반부로 가고 있는데요. 개별적인 질문들에 일일히 답변하기가 여의치않아, 몇가지 질문메일에 대한 답변과 강좌의 진행방향에 대해서 알려드리고자 잠시 몇자 적어봅니다.


    일단 참고문서나 서적등을 추천해달라는 말씀이 많았습니다. 아래에 참고서적등을 나열하오니, 말그대로 참고하시기 바랍니다. (참고서적은 순전히 저의 주관적인 판단에 따른것입니다)


    1) Advanced Windows NT, Jeffrey Richter

    : 설명이 필요없는 Windows 시스템프로그래밍의 바이블, 시스템프로그래밍의 페촐드라고 보면됩니다. 어플리케이션 레벨에서의 시스템프로그래밍을 주로 다루고 있습니다.


    2) Debugging Applications, John Robbins

    : MSJ의 유명한 컬럼 "Bugslayer"의 저자이자 Numega Software의 시스템 엔지니어인 John Robbins의 명저, 주제는 디버깅이지만 상당한 수준의 시스템프로그래밍 지식을 얻을 수 있으며, 실무에서도 바로 적용할수 있는 기술과 정보가 아주 잘 소개되어있습니다.


    3) 파괴의 광학,    김성우

    : 지난 강좌에서도 잠시 소개한적이 있습니다. 월간 마이크로소프트에서 연재한 시스템해킹 컬럼을 서적으로 출간, 윈도 환경에서의 해킹과 보안을 주제로 한 상당히 흥미로운 내용으로 구성되어있습니다.


    4) 매크로 어셈블러 기초, 황희융

    : 뭐 설명이 필요없는 어셈블리 필독서, 도스용 8비트 어셈블리부터 32비트 어셈블리까지 포괄적으로 다루고 있습니다. 일단 꽂아놓고 필요할때마다 참고하시면 좋겠죠?


    5) System Programming for Windows 95, Walter Oney

    : Win9x의 디바이스드라이버인 VxD를 다루는 서적으로, VxD 바이블로 불리워질 정도로 유명한 책입니다. 번역서는 없지만 대우중공업 자동화 연구팀의 홍진철님의 번역문서를 인터넷에서 구할수 있습니다.


    6) Programming Microsoft Windows Driver Model, Walter Oney

    : 5번책, System Programming for Windows 95로 유명한 Walter Oney의 WDM 개발서적입니다. 전편의 인기와 저자의 명성으로 짐작할수 있듯이 체계적인 설명이 잘되어 있는 NT/2000용 디바이스드라이버 모델인 WDM의 교과서라고 할 수 있습니다.


    7) Inside Microsoft Windows 2000, David A. Solomon & Mark E. Russinovich

    : Windows 2000 시스템과 내부구조에 대해서 포괄적으로 다루는 Win2K 시스템프로그래머의 필독서라고 할 수 있습니다.


    실제로 필자도 위의 모든책들을 모두 다 읽어보지는 못했고 이것외에도 좋은 책들이 많이 있는것으로 알고 있습니다만 아직은 번역서나 국내저서보다는 원서가 많은 형편입니다. 물론 바이블이라고 불릴만한 대표적인 책들은 대부분 번역서가 나와있는 상태이므로 영어가 딸리는 저같은 분(^^)들은 번역서와 원서를 둘다 구입하셔서 번역서로 도저히 이해안되는 부분은 직접 원서를 참고하는 방식으로 공부하시면 효과적일것 같습니다.(물론 경제사정이 되신다면... 만약 시스템 프로그래밍이 처음이시라면 각 분야의 대표적인 서적 한권을 여러번 읽는 방법이 좋을것 같습니다.(분명히 처음읽을때와 두번째 읽을때가 다를것입니다.) 저렇게 많은 자료들을 모두 숙지할 필요까지는 없더라도(실제로 실무에서 더 많은것을 배운다고 하더군요.) 필요한 부분이 있을때에는 찾아볼수라도 있으려면 기본적인 개념에 대해서는 숙지해야만 합니다. 또한 서적외에도 인터넷이나 전문잡지등을 참고하는것도 하나의 방법이라고 볼 수 있겠죠.


    사족을 덧붙이면, 저또한 장치와 연결되는 순수한 디바이스드라이버의 개발쪽보다는 시스템 어플리케이션과 운영체제를 이해하기 위한 방법으로 디바이스드라이버를 다루어왔습니다. 이글을 읽고계시는 대부분의 개발자 여러분 또한 하드웨어와 운영체제를 연결하기 위한 순수한 디바이스드라이버보다는 일반 어플리케이션으로 불가능한 작업을 위해서 디바이스드라이버를 배우시려는 분들이 대부분일거라고 생각되는군요. 따라서 강좌의 진행도 그쪽을 중심으로 진행해 나가겠습니다.


    또 한가지 디바이스드라이버를 처음하시는 분이라면 VxD로 대표되는 Win9x계열의 디바이스드라이버보다는 WDM이나 NT 커널드라이버 쪽에 비중을 두고 공부를 하시는것이 좋으실 겁니다. (이유는 웬지 다 아시죠? 이제 9x의 시대는 저물고 있으니까요...)


    참고서적 소개는 이쯤하고 메일이나 쪽지 주셨던 분들의 질문에 대해서 몇가지 언급할만한것들을 다루어보겠습니다.

    (음... DB 핸들링이나 윈도컨트롤 프로그래밍을 질문하신 분도 계시지만 죄송하게도 본 강좌와 무관하다고 판단되어서 답변을 드리지 못함을 이해해주시기 바랍니다.)


    1. WH_CALLWNDPROC 훅을 통한 프로세스 영역 침투하기


    김상희(potpry)님의 의견중, WH_CALLWNDPROC 훅을 사용한 프로세스 주소공간 칩입하기에 관한 글이 있었는데요. 역시 지적하셨던것처럼 몇가지 문제점을 가지고 있는 관계로 논외로 하려고 했습니다만, 의견을 주신분도 계시니 잠깐 다루어보도록 하겠습니다. (의견주신 김상희님께 다시한번 감사의 말씀을 전하며...)


    일단 WH_CALLWNDPROC 훅은 잘 아시는대로 윈도프로시저를 가로채기위한 훅입니다. 따라서 지정된 훅프로시저가 현재 프로세스에 매핑되지 않았다면 시스템은 자동적으로 훅프로시저를 가지고 있는 dll을 프로세스에 매핑시킵니다. (dll을 로드시킨다는 말이죠.) 이 순간 당연히 DLL은 분명 ATTACH되는 프로세스의 주소공간에 작업한다는 것은 의심할 여지가 없으며, 따라서 이 부분에서 우리가 원하는 프로세스 영역에 관한 어떠한 작업도 가능하다는 것입니다. 실제로 Matt Pietrek이 MSJ 컬럼에서 Api Hijacking 이란 이름으로 선보였던 방법입니다. 그러나 이미 언급했다시피 이 방법은 몇가지 문제점을 가지고 있습니다.


    먼저, 윈도우프로시저를 가지지 않는 프로그램일 경우, 적용할 수 없다는 점과 명시적 링킹(explicit link)과 링크된 DLL에서 또다시 임포트된 함수의 처리에 관한 문제입니다. 윈도프로시저를 가로채는 훅이니 만큼 윈도프로시저가 없는 프로그램에서는 사용할수 없다는 것은 반론의 여지가 없을텐데, 두번째문제는 좀 복잡합니다. 실제로 PE구조를 분석해보셨다면 임포트된 함수의 유일한 절대주소를 호출하는 방식으로 API호출이 이루어지지 않는다는 것을 아셨을것입니다. 각 프로세스마다 jmp + API 함수주소의 내용으로 되어있는 임포트테이블을 따로 관리하며, 실제 프로그램은 API로 직접 call 하는 것이 아니라 테이블의 구성요소를 call하는 형태로 변환됩니다. 그런데 문제는 그러한 임포트된 모듈에서 또 다시 임포트된 모듈의 함수를 후킹하는 경우입니다. 쉽게 설명하면 USER32.DLL은 내부적으로 KERNEL32.DLL을 임포트하고 있는데 이런경우 Win9x 사용자모드에서는 시스템 DLL의 임포트테이블을 수정할 수 없으므로 사실상 후킹이 불가능해집니다. 또한 API내부에서 API를 호출하는 경우에도 마찬가지의 이유로 적용할 수  없는 또하나의 예가 되겠습니다. 어쨋든 임포트테이블을 가지고 전역 API 후킹을 구현하기에는 여러가지 어려움이 많으므로 실제로 우리는 이러한 방법을 사용하지 않을 예정입니다.


    2. 왜 9x와 NT/2K에서의 API 후킹 접근방식이 달라야 하나?


    아주 성격이 급하신분이 질문하신 모양입니다.(^^) 실제 구현할때 자세하게 설명드리겠지만 왜 9x와 NT/2K계열에서 전역 API 후킹이 다르게 작성되어야 하는지 간단하게 설명해드리겠습니다. 가장 커다란 차이는 시스템에서 공유하는 가상메모리 영역(보통 0x80000000, 2GB 이상)을 각각의 프로세스가 어떻게 접근하느냐의 차이점입니다. 쉬운예로 2번째 강좌에서 실습해보았던 공유메모리영역이 9x에서는 2GB이상의 영역에 위치하지만 NT/2K에서는 2GB 안쪽에 위치하는 것을 볼 수 있습니다. 따라서 NT/2K에서는 그 주소공간이 주소만으로 다른 프로세스에서 유효하게 재사용되어질 수 없다는 것이 문제입니다. 이것은 공유메모리뿐 아니라 실제 시스템에서 공유되는 시스템 DLL의 모듈핸들(다른말로 베이스어드레스)이나 함수주소등도 마찬가지입니다. 따라서 9x와 는 다른 방식으로 접근해야만 하며 난이도 또한 9x보다 난해하고 복잡합니다. 또한 시스템 DLL의 내용을 수정하려면 RING0로 불리는 운영체제와 동일한 특권레벨을 가져야 하는데 9x에서는 사용자레벨(RING3)에서 편법적으로 RING0를 획득할수 있지만(보통 Win9x의 뒷문(?)이라고도 합니다.) NT/2K에서는 디바이스드라이버를 통하지않고 RING0를 권한을 얻을수 있는 방법은 없는것으로 알려져 있습니다. CIH등의 무시무시한(?) 바이러스가 WinNT/2K에서 무용지물인 이유 또한 바로 여기에 있습니다. 만약에 NT/2K 환경에서 사용자레벨(RING3)에서 RING0를 획득하는 방법을 알고계시다면 바이러스같은것 만들지말고 좋은쪽으로 활용하시길 바랍니다. (필자에게도 알려주시면 고맙구요... ^^) 어쨋든 NT/2K는 9x에 비해 상당히 안정적인 운영체제이며 그 커다란 이유중에 하나가 시스템이 공유하는 가상 메모리영역을 사용자레벨에서 접근하는것을 원칙적으로 금지하고 있습니다. 이 부분에 대한 더 자세한 설명은 후일 NT/2K 디바이스드라이버를 다룰때 다시한번 다루도록 하겠습니다.


    3. 서버와 인증을 통해 수행되는 프로그램들의 인증루틴을 건너뛰면서 인증없이 작업을 수행할 수 있나?


    프로그램마다 다르겠지만 인증루틴이 단순한 검사루틴(함수같은)처럼 구성되어 있다면 얼마든지 크래킹이 가능합니다만 지난강좌에서도 말씀드렸듯이 안티디버깅 코드로 역어셈블을 방해한다면 크래킹하기가 쉽지는 않겠죠. 그밖에도 크래킹을 막는 기법이나 바이러스 감염을 막는 방법은 몇가지가 더 존재하지만 특별한 경우가 아닌이상 적용하지 않는것으로 알고 있습니다. 본강좌는 크랙강좌가 아니므로 더이상의 설명은 드리지 않겠습니다.


    끝으로...

    다음 강좌에서는 예고한대로 Win9x 디바이스드라이버 모델인 VxD에 대해서 다루어보겠습니다. 아마도 매우 타이트한 진행이 되어야 할텐데 이론보다는 실제구현에 초점을 두고 진행할 예정입니다. 자세한 내용은 위에서 말씀드린 서적등을 참고하시는것이 좋을것 같네요.


    ****************************************************************************************

    Win32 Global API Hook - 2 다른 프로세스의 주소공간으로 !! (1)


     지난 강좌의 내용이 어땠는지 모르겠군요. 첨부터 반말로 써서 혹시 기분이 상하셨을까봐, 이번 강좌부터는 처음에 간단한 인사를 드리고 시작하겠습니다. ^^ 생각보다 많은 사람들이 보신것 같은데요. 실제로 코딩을 해가면서 디버깅까지 해보셨다면 별로 어려울게 없었을거라고 믿습니다.

     자 이제 이번 강좌부터는 약간의 레벨업이 필요할듯 한데요. 이번 강좌에서 다루는 내용은 SOFTICE, BOUNDS CHECKER 등의 디버깅툴로 유명한 NUMEGA SOFTWARE의 시스템 엔지니어인 Matt Pietrek의 아이디어에서 빌어온 것임을 밝히며, API HOOKING의 원리를 이해할수 있을것으로 생각됩니다. 또 기운이 빠지는 얘기일지도 모르겠지만 이번 강좌의 내용으로도 우리가 원하는것(첫 강좌에서 밝혔죠?)을 완벽하게 이룰수는 없다는점입니다. 그렇지만 이 내용을 모르고는 다음 강좌로 넘어갈수는 없다는 판단에 두회에 걸쳐 강좌를 진행하도록 하겠습니다. 그럼, 담배한대 피우고 가보기로 하죠 ^^



    1. CPU


     시스템프로그래밍에서의 CPU의 중요성은 말할필요가 없을것이다. CPU 아키텍쳐는 무엇보다도 중요하고 기본적인 내용이지만 이 강좌에서 그것을 다룰수는 없다. 자세한것은 각자 자료를 찾거나 책을 보면서 익히도록하고 우리는 여기서 CPU가 프로그램을 어떻게 실행하는지에 관해서만 언급하고자 한다. 운영체제가 실행파일을 메모리에 로드하게 되면 정해진규칙에 따라 실행시작주소(보통 엔트리포인트라고 말한다.)를 찾고 그곳으로 제어를 넘긴다. (여기서 제어를 넘긴다는 말은 IP(인스트럭션 포인터)가 지정된곳으로 세팅된다는 말과 같다.) 그렇게 되면 CPU는 그곳에서부터 정해진 바이트씩 읽어오면서 그것을 해석하며 실행해나간다. 이러한 작업을 두고 명령어가 패치된다고 말한다. 당연히 CPU가 해석하는 명령어는 기계어코드이며 어셈블리로 1:1 대응시킬수 있다. 이것만 기억하고 넘어가자, CPU는 기계어로 된 명령어를 해석해서 순차적으로 실행해 나간다. 혹시 OOP 프로그래밍이나 이벤트드리븐방식(Windows같은)의 프로그래밍에만 익숙하다면, 프로그래밍 방법론은 변했어도 CPU가 명령어를 처리하는 그순간은 그 옛날 도스시절이나 지금이나 별반 다를게 없다는것을 명심하기 바란다.


    2. Win32의 메모리 관리


     지난강좌에서도 잠깐 언급한적이 있지만, 시스템프로그래밍에서 메모리관리는 빠질수 없는 주제이다. 대부분의 CPU와 운영체제가 보호모드를 지원하는 최근에 와서는 더더욱 중요한 주제가 되었고 우리도 당연히 짚고 넘어가야 하겠다. 지면과 시간의 한계로 메모리 관리의 많은 부분을 다룰수 없는것을 안타깝게 생각한다. 역시 이부분도 우리가 다루려는 핵심만 짚고 넘어가야 할듯하다. CPU는 메모리에 있는 데이터를 다룬다. CPU가 디스크를 엑세스한다? 말도 안되는 얘기다. CPU는 모든것이 메모리라고 생각하면서 작업한다.

    (물론 이말에 대해서 반론의 여지가 있는 사람도 있겠지만, 일단은 이렇게 생각하고 강좌를 진행하는 것이 이해가 빠를것 같다.)

     CPU의 실행모드에는 리얼모드와 보호모드의 두가지가 있는데, 리얼모드는 본 강좌와 무관하니 언급하지 않기로 하고, 보호모드에 대해서만 얘기해보자. 메모리관리에서 웬 보호모드냐 하겠지만, 말하고자 하는것은 보호모드의 특징중의 하나인 가상메모리 메카니즘을 말하려고 함이다. 가상메모리라고 하는것은 말그대로 진짜메모리가 아니다. 단언하건데 여러분이 디바이스드라이버나 하드웨어를 제작하는 사람이 아니라면 여러분이 지금까지 알고 있었던, 사용해왔던 메모리는 모두 가상메모리였을것이다. 보호모드에서 가상메모리는 연속된다는 의미로 선형메모리(leaner 또는 flat memory)라고도 한다. (앞으로 선형메모리와 가상메모리는 같은 의미로 사용하겠다.) 가상메모리로 인해서 실제로 시스템에 장착된 메모리보다 큰 메모리를 우리는 사용할수 있었던 것이다. 이런 얘기는 들었을것이다. Windows는 모두 4기가바이트의 메모리를 사용할 수 있는데, 그중 응용프로그램이 사용하는 영역은 0-2기가바이트이며, 그 이상은 운영체제가 사용한다. 이것 역시 가상메모리이며, 윈도 운영체제는 가상메모리를 페이징메카니즘이란 방법으로 관리한다.

     그럼 페이징메카니즘이란 도대체 무얼 말하는것인가? 페이지는 시스템이 인식하는 가상메모리의 단위이다. 페이지는 CPU와 운영체제에 따라 그 크기가 다양한데 보통 Intel CPU의 윈도운영체제일 경우 보통 4K의 크기를 갖는다. 가상메모리는 페이지 단위로 스왑되거나 맵된다. 여러분이 단지 한바이트의 메모리만 할당한다하더라도 시스템은 하나의 페이지를 준비한다. 또 하나의 중요한 사실은 하나의 페이지는 연속된다는 것이다. 이말은 하나 이상의 페이지는 실제로는 연속되지 않을수도 있다는 말이다. 보호모드에서의 가상메모리는 리얼모드와 달리 산술적인 연산으로 물리주소로 연결되지 않으며, 페이지디렉토리와 페이지테이블이라는 자료구조를 통해서 실제메모리(물리메모리)로 연결된다. 실제로 가상메모리상에서는 연속되는 메모리영역이라 하더라도 실제 물리메모리상에서는 연속되지 않을수도 있다.

     이러한 개념은 매우 중요한데, 예를 들면 이러하다. 내가 만약 1메가바이트의 메모리를 할당했다고 하더라도 실제로 그 메모리는 물리주소상에서 연속된다고 보장할수는 없다. 가상메모리가 3페이지에 걸쳐서 존재한다면 그에 따르는 물리주소도 3페이지만큼이 존재하지만 그들의 위치는 우리가 예상하는대로 배치되지 않을수 있다는것이다.

    3. 프로세스와 메모리


     내가 만든 A라는 프로그램이 있다고 치자. 그놈이 어떤 메모리를 할당했는데 그것의 시작주소가

    0x70000000 이었다고 가정하자. 그런데 B라는 프로그램도 메모리를 할당하는데 그것의 시작주소 또한 0x70000000 이었다고 한다면, A, B 두 프로세스가 가지고 있는 이 두개의 메모리 영역은 과연 실제로는 어디에 존재할까? 주소는 같지만 실제로 둘은 전혀다른 메모리이다. 둘다 같은 가상주소값을 가지고 있지만 둘은 엄연히 물리주소상에서는 다른 곳을 가리키고 있다. 이것이 어떻게 가능할까? 시스템은 현재프로세스가 변할때(태스크 스위칭이 일어날때)마다 페이지디렉토리의 내용을 갱신한다. 윗부분에서 가상주소는 페이지디렉토리와 페이지테이블를 통해 물리주소와 연결된다고 했다. 따라서 이들이 변한다는것은 실제 가상주소가 가리키는 실제주소(물리주소)가 변한다는 말과 같다. 이러한 원리로 프로세스 A와 B는 서로의 공간을 전혀 알수가 없으며 이로 인해서 운영체제가 더욱 견고해지는 것이다.

     그렇다면 만약 10메가 바이트의 메모리를 사용하는 프로세스 10개가 동시에 동작한다면 100메가 바이트의 물리메모리가 필요할까? 반드시 그렇지는 않다. 왜냐면 위에서 말한 페이지 스왑이라는 기법을 운영체제가 지원하기 때문이다. 운영체제는 어떠한 페이지가 현재 필요하지 않다고 판단되면 그것을 디스크에 기록한후, 물리메모리에서 해제한다. 그러다가 그 페이지가 다시 필요한 시점에서 디스크에 보관된 페이지를 다시 물리메모리로 로드한다. 이러한 일련의 작업들로써 응용프로그램들은 현재 시스템에 장착된 메모리보다 더 큰 메모리를 사용할수 있는것이다. 우리가 자주보는 시스템 오류중 하나인 페이지 폴트(page fault)는 바로 이러한 페이징에 오류가 생겼을때 발생하는데 대표적인 경우는 현재 물리메모리가 할당되어지지 않은(디스크 스왑된) 페이지를 마치 메모리에 존재하는 페이지처럼 접근하려고 할때이다. 시스템은 기본적으로 이러한 에러를 예외핸들러를 설치해서 복구하게된다.

    그렇다면 이제 프로세스간에 주소공간을 공유할수 있는가라는 문제에 대해 생각해보자. 계속 이론만 늘어놓았으니 이번에는 간단한 예제를 통해서 확인해보도록 하자. 아래에 리스트된 코드를 보자.


    PVOID p1 = malloc(16);

    if(NULL == p1)

        return -1;

    memset(p1, "A", 16);

    *((char*)p1 + 15) = "\0";


    HANDLE hMap = CreateFileMapping(INVALID_HANDLE_VALUE, NULL,

        PAGE_READWRITE|SEC_RESERVE, 0, 16, NULL);

    PVOID pMap = MapViewOfFile(hMap, FILE_MAP_ALL_ACCESS, 0, 0, 16);

    PVOID p2 = VirtualAlloc(pMap, 16, MEM_COMMIT, PAGE_READWRITE);

    if(NULL == p1)

        return -1;

    memset(p2, "B", 16);

    *((char*)p2 + 15) = "\0";


    printf("p1:0x%08x \n", p1);

    printf("dump:%s \n", p1);

    printf("p2:0x%08x \n", p2);

    printf("dump:%s \n", p2);


    printf("press any key... \n");

    getch();


    if(NULL != p1)

        free(p1);

    if(NULL != p2)

        VirtualFree(p2, 16, MEM_DECOMMIT);

    if(NULL != pMap)

        UnmapViewOfFile(pMap);

    if(NULL != hMap)

        CloseHandle(hMap);


    간단히 설명하면 일단 malloc()으로 16바이트만큼 메모리를 할당한다음 문자 "A"로 채운다음 그 주소와 내용을 화면에 출력한다. 그런다음 메모리맵파일을 생성한후 문자 "B"로 채운다음 그 주소와 내용을 화면에 출력한다. 그리고 잠시 사용자 키입력을 기다린후, 입력이 들어오면 메모리를 해제하고 프로그램을 종료한다. 메모리맵파일에 관한 자세한 설명은 MSDN을 참고하길 바라며, 일단 컴파일한후 실행시켜보자.


    p1:0x00780eb0

    dump:AAAAAAAAAAAAAAA

    p2:0x85536000

    dump:BBBBBBBBBBBBBBB

    press any key...


    필자의 시스템에서는 위와같이 출력되고 사용자 입력을 기다리는 상태가 되었다. 키입력을 하게되면 메모리를 해제하고 프로그램을 종료하게 되므로 일단 저상태로 내버려 두고 p1과 p2의 주소만 잘 적어두고 두번째 프로그램을 작성하자.


    printf("dump:%s \n", 0x00780eb0);

    printf("dump:%s \n", 0x85536000);


    먼저 작성한 프로그램의 p1, p2의 주소를 출력하는 코드이다. 혹시 그냥 Cop & Paste하는 사람이 있을까봐 얘기하는데, 하드코딩된 주소는 당연히 앞서 작성한 프로그램에서 출력된 주소를 적어주어야 할것이다. 자, 두번째 프로그램을 컴파일한후 실행시켜보자.


    dump:emTest2.exe

    dump:BBBBBBBBBBBBBBB


    어떤가? 확실히 이해가 되는가? 결론부터 말하자면 malloc으로 할당한 메모리의 주소는 다른 프로세스에서는 쓸모없는 무효한 주소가 된다. 그러나 메모리맵파일로 할당한 메모리의 주소는 다른 프로세스의 주소공간에서도 여전히 유효한것을 볼수 있다. (혹시 운이 없는 사람은 두번째 프로그램을 실행시키다가 시스템이 죽거나 블루스크린을 만났었을지도 모른다. 아마 대부분 그러지 않았을거라 확신하지만... ^^) 여기서 중요한것은 바로 다른 프로세스간에도 유효한 메모리와 무효한 메모리의 주소이다. 주소는 시스템마다 약간씩 차이가 있었겠지만 분명한것은 malloc으로 할당한 메모리의 주소는 0x80000000보다 작았을것이고, 메모리맵파일로 할당한 메모리주소는 분명히 0x80000000보다 큰 주소로 할당되었을것이다. 0x80000000은 10진수로 2147483648, 즉 정확히 2기가바이트이다. 우리는 이제 주소만 보고도 이것이 프로세스 전용메모리인지 아니면 시스템에서 공유되는 메모리인지를 구별할 수 있을것이다.


    4. 끝으로


     필자의 생각으로 이번 강좌는 여러분들에게 정말로 지루하고 재미없는 강좌였을것이다. 대부분 이론적인 내용뿐이니 말이다. 하지만 이번에 다룬내용은 모두 우리가 앞으로 해야할 작업의 기초가 되는 내용들이니 지루하더라도 꼭 이해하고 넘어가기 바란다. CPU와 메모리, 프로세스에 관한 내용은 이것말고도 굉장히 중요한 내용들을 포함하고 있으니 다른자료나 참고서적을 통해서라도 꼭 살펴보길 바란다. 참고서적을 추천해달라는 분이 계셨는데 지금은 시스템프로그래밍에 관한 책이 여러권 나와있지만 필자가 살펴본 바로는 1강에서도 언급한 Jeffrey Ritcher의 Advanced Windows라는 책이 볼만할것이다. 한글번역본도 있으니 꼭 구해다가 한번씩 읽어보기 바란다. 이책은 어플리케이션 프로그래머를 위한 시스템프로그래밍 서적이지만 워낙 유명한 책이니 책장에 꽂아놓는것만으로도 의미가 있을듯 싶다. 그밖에 역시 1강에서 언급한, NUMEGA SOFTWARE의 시스템 엔지니어인 John Robbins의 Debugging Applications 라는 책 또한 참고할 내용이 많다.

    나중에 NUMEGA SOFTWARE의 제품인 SOFTICE라는 디버거를 사용하게 될터인데(그때보면 알게되겠지만 정말 엄청난 프로그램이라 하지않을수 없다. 필자는 SOFTICE가 없는 시스템프로그래밍 디버깅을 생각할수조차 없을 정도이다.) 이책은 디버깅을 위한 책이라고 할수있지만 시스템에 관한 내용도 다루어지며, 아마 이책을 읽고나면 그럴듯한 디버거를 하나 만들수 있을것이다. 더 깊은 내용을 다루는 서적이나 자료를 알고싶다면 추후에 디바이스드라이버 강좌를 진행할때 소개하기로 하자.


    5. 다음 강좌에서는...


     다음 강좌는 "제2강 다른 프로세스의 주소공간으로 !!" 두번째 시간이다. 원래 이번 강좌에서 다룰예정이었던 Debugging API와 그것을 이용한 간단한 디버거를 작성해보고, 다른 프로세스 공간에 우리의 모듈을 삽입하는 방법을 알아보자. 사실 강좌를 진행하다보니 욕심이 지나쳐 예상보다 진도가 늦어진 것같다. 애초 2강을 두번에 걸쳐서 진행하려 했으니 다음 강좌는 좀 타이트한 진행이 될것 같다. 그렇지만 이번 강좌보다는 덜 지루한 내용으로 채워질 예정이니 너무 걱정마시길...


    ****************************************************************************************

    Win32 Global API Hook - 2 다른 프로세스의 주소공간으로 !! (2)


     지난시간에 이어서 다른 프로세스의 주소공간을 넘나들수 있는 방법에 대해서 알아보도록 하죠. 지난시간에 말씀드렸듯이 오늘 내용은 NUMEGA SOFTWARE의 시스템 엔지니어인 Matt Pietrek의 아이디어에서 빌어온것이지만 Advanced Windows의 저자 Jeffrey Ritcher나 Debugging Applications의 저자 John Robbinson 등이 인용했었고, 국내의 모 프로그래밍 잡지의 시스템 해킹칼럼에서도 다루어 진적이 있었던 내용입니다. API 후킹의 실질적인 기초를 다루는 내용이기도 하지요. 그럼 강좌나갑니다.


     자 그럼 우리는 무얼하려고 했는지 생각해보자. 이번 강좌의 내용이 다른 프로세스의 주소공간을 우리가 사용해보자는 것이었다. 어떻게 하면 다른 프로세스의 주소공간으로 들어갈 수 있을까? 아니 그것보다 다른 프로세스의 주소공간을 넘나드는 것이 어떤게 있을지 생각해보자. 쉽게 떠오르고 가장 대표적인것이 바로 디버거일것이다. 여러분이 Visual C++ 등의 개발툴로 디버깅을 하는것을 한번 생각해보자. 디버거와 내가 만든 프로그램은 엄연히 다른 프로세스이다. 그치만 디버거는 여러분이 만든 프로그램(디버기, 디버깅을 당하는 프로세스를 말한다.)의 변수의 값을 추적하거나 변경할 수 있다. 디버거는 컴파일러와 더불어 시스템프로그래밍을 익히는 가장 좋은예제가 될것이지만, 다들 아시다시피 제대로된 디버거를 제작하는것은 컴파일러만큼이나 복잡하고 어려운 작업이다. 음... 필자는 국내에서 제작된 상용 컴파일러와 디버거가 하나도 없다는것이 좀 아쉬운데(예전에 씨앗이라는 C언어 비슷한 한글언어가 있었긴 하지만, DOS용이었던것으로 기억된다.) 물론 우리나라의 개발자들의 능력이 못미쳐서라기보다는 일단 개발하는데 필요한 시간과 노력에 비해 그 사업성에서 결코 낙관적이지 않을것이라는 이유일것이다. 음... 어쨋든 얘기가 잠시 삼천포로 빠졌는데, 다시 디버거 얘기를 해보자. 어쨋든 디버거는 확실히 다른 프로세스의 주소공간을 넘나들며 실행된다는것은 의심의 여지가 없는것 같다.

     그렇다면?, 우리도 그와 비슷한 작업을 할 수 있지 않을까? Win32 API는 다른 프로세스의 주소공간을 엑세스할수 있는 API세트를 제공한다. 이것이 바로 Debugging API 이다. 아마 처음 들어보는 사람도 많을 것이다. 백문이 불여일견, 지금 당장 MSDN을 열고 "WriteProcessMemory"라고 타이핑해보자. 그러면 WriteProcessMemory함수에 대한 내용이 표시될것이다. (WriteProcessMemory는 대표적인 디버깅 API로 이름그대로 프로세스 메모리를 쓰는(WRITE)하는 함수이다.) Win32에서는 프로세스간의 주소공간이 철저히 보호된다더니만 그렇지도 않네? 이거 너무 싱겁잖아? 뭐 이렇게 생각하는 분들도 있을지 모르겠다. 그렇지만 운영체제가 어디 그리 엉성하게 제작되었겠는가? Debugging API는 말그대로 Debugging을 위해서, 또는 디버거를 제작하기 위한 목적으로 생겨난것이므로 우리같이 엉뚱한 목적을 위해 사용하려는데에는 많은 제한을 두고 있다. 쉽게 말하면 저러한 종류의 API를 사용할수 있는 경우는 매우 제한적이라는 것이다. 실례로 WriteProcessMemory등으로 다른 프로세스의 주소공간을 접근하려면 프로세스를 디버깅모드로 실행시키지 않는한 매우 까다로운 절차를 거쳐야 하며, 그나마 운영체제에 따른 지원여부또한 불투명하다. (운영체제, 정확히 말하면 상당수의 시스템 API가 Win9x에서 지원되지 않거나, 제한적으로 적용된다는 말이다.)

     참고로 언급하면 지금까지의 예제들은 모두 Win9x에서 동작한다. WinNT/2000에서는 정상적인 동작을 보장할 수 없다. 실제로 지난번 예제또한 WinNT/2000에서는 필자가 말한대로 동작하지 않았을것이다. NT계열(2000, XP까지)의 운영체제와 9x계열의 운영체제는 보기에는 비슷해보이고 대부분의 어플리케이션이 호환되는듯 동작하지만, 내부적인 많은 차이점을 지니고 있다. 결론적으로 말하면 API 후킹에 관한 아이디어는 9x나 NT계열이나 별반 다를게 없지만 적용하는 방법에 있어서는 차이를 가진다. 잘라말하면 9x가 NT계열보다 쉽다. 이유는 9x는 호환성을 위해 DOS와 Windows 3.x의 내부구조를 상당부분 포함하고 있기때문이다. 결국 호환성때문에 운영체제의 안정성과 합리적인 구조를 포기할수밖에 없었던 것인데, 9x가 NT계열보다 불안정한 이유도 여기에 있다. 일단 구체적인 두 운영체제의 차이점은 나중에 디바이스드라이버 강좌에서 자세히 알아보기로하고 우린 9x에서 작업한다고 가정하고 강좌를 진행하도록 하자.

     우리가 다른 프로세스의 주소공간에서 작업할 수 있는 가장 효과적인 방법이 무엇이 있을까? 당연히 DLL을 이용하는 것이다. 다들 아시다시피 DLL은 DLL을 로드한 프로세스의 주소공간에 매핑되며 얼마든지 프로세스의 자원을 사용할 수도 있다. 그럼 우리는 원하는 작업을 수행하는 DLL을 제작한다음, 그녀석을 원하는 프로세스에 주입시키면 될것이다.

     그런데, 내가 제작한 프로그램이 아닌녀석에게 어떻게 내가 원하는 DLL을 주입할수 있을까? 지난번 강좌에서 구체적으로 프로그램을 어떻게 실행시키는지를 잠깐 언급한적이 있다. 결국 컴파일러는 바이너리파일을 만들어내며, CPU는 그 파일에서 지정된 코드를 찾아 순차적으로 실행해나간다고 했다. 그렇다면 우리가 그 실행코드를 원하는 코드로 덮어써버린다면? 당연히 내가 덮어쓴 코드가 실행될 것이다. 실행파일이 실행되는것은 실제로 주기억장치(오랜만에 들어본다. ^^ 메모리를 말하는것이다.)에 로드된후에 실행된다고 했다. 그렇다면 실행파일이 로드된 지점을 찾아 위에서 말한 Debugging API로 원하는 코드영역을 내가 원하는 코드로 바꿔치기한후, 실행시키면 될것이다. 자, 아이디어는 매우 간단하다. 그럼 실제로 다음과 같은 과정을 거쳐 구현에 들어가보자.

    1. 프로세스의 실행코드를 알아낸다.

     : Debugging API를 사용해서 특정프로세스의 실행코드위치를 알아낼 수 있다. 먼저 언급한대로 우리는 9x에서 우리가 프로세스를 출발시키는 경우에만 적용하도록하자.

    2. 원하는 실행코드를 제작한다.

     : 아마 상당수의 어플리케이션 프로그래머(대부분 VC++, VB, Delphi 등으로 작업할 것이다.)가 이해하기 힘들어하는 부분일것이다. 왜냐면 어셈블리, 정확히 말하면 기계어코드를 작성해야 하기때문이다. 지금은 어셈블리로 프로그래밍하는 사람이 거의 없겠지만 어셈블리를 아는 프로그래머와 그렇지 않은 프로그래머는 분명한 차이가 있다. 반드시 어셈블리를 이용해서 프로그래밍하지 않더라도 어셈블리를 알면 디버깅과 시스템에 대한 이해가 분명해질것이다. 이번 강좌에서는 매우 간단한 어셈블리 코드만을 사용하지만, 자신이 프로그래밍을 천직으로 알고 있다면(이 강좌를 보는 대부분의 사람들이 그럴것이라고 필자는 믿고 싶다.) 반드시 어셈블리를 공부하길 바란다. (어셈블리에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍" 다시 다루도록 하자.)

    3. 실행코드를 덮어쓴다.

     : 원하는 기능을 수행하는 코드를 원래의 코드에 덮어쓴다.

    4. 필요한 기능이 실행되었다면 코드를 복원한다.

     : 3에서 덮어써진 코드를 복원/실행 한다.

    전체적인 작업의 흐름은 이렇다. 자, 그럼 잠시 머리속을 정리한후, 실제 코드를 구현해보도록 하자.


    그럼 먼저 간단한 마루타가 될 Win32 응용프로그램을 제작한다. 일단은 그냥 윈도에서 제공되는 노트패드를 그냥 가져다 써도 무방할 것이지만 나중에 API 후킹을 테스트하려면 하나 만들어두는것도 좋을것이다. 간단하게 VC++에서 Win32 Application - Hello World 프로그램을 선택해서 AppWizard를 통해 만들어도 상관없다. 실행해보면 그냥 달랑 메인윈도가 뜨고, 클라이언트 영역에 "Hello, World"라고 출력될것이다.


    그 다음은 프로세스 몰래 주입할 DLL을 하나 제작한다. 일단 다른 프로세스에 제대로 로드되었는가를 확인하기 위해, 아래와 같이 프로세스에 붙을때와 떨어질때 메시지 박스를 츨력해주는 간단한 DLL을 만들어보자.


    BOOL APIENTRY DllMain(HANDLE hModule,

                           DWORD  ul_reason_for_call,

                           LPVOID lpReserved)

    {

        switch(ul_reason_for_call)

        {

        case DLL_PROCESS_ATTACH:

            MessageBox(NULL, "DLL_PROCESS_ATTACH", "TestDll", MB_OK);

            break;

        case DLL_PROCESS_DETACH:

            MessageBox(NULL, "DLL_PROCESS_DETACH", "TestDll", MB_OK);

            break;

        }


        return TRUE;

    }


    일단 여기까지 별 무리없을 것이다. 그러면 프로세스에 DLL을 주입하는 모듈을 제작해보자. 앞서서 프로세스를 디버깅모드로 실행시켜야 한다고 말했다. 아래와 같이 프로세스를 디버깅 모드로 실행한다.


    CreateProcess(NULL,

        (LPSTR)szCmdLine,

        0,

        0,

        FALSE,

        DEBUG_ONLY_THIS_PROCESS,

        0,

        0,

        &StartupInfo,

        &ProcessInfo);


    단지 CreateProcess의 6번째 인자로 DEBUG_ONLY_THIS_PROCESS를 준것외에 특별한 것은 없다. 그러면 szCmdLine으로 실행된 프로세스는 내 프로세스의 디버기(전에 언급했다. 디버깅을 당하는 프로세스라는 의미이다.)가 된다. 그러면 디버기는 디버거(바로 내 프로세스가 된다.)에게 디버깅 이벤트를 발생시킨다. 우리는 적절한 이벤트를 핸들링함으로써 원하는 작업을 수행할 수 있을것이다. 아래의 코드를 보자.


    DEBUG_EVENT event;

    DWORD dwContinueStatus;


    while(1)

    {

        // 디버그 이벤트가 발생할때까지 대기

        WaitForDebugEvent(&event, INFINITE);


        dwContinueStatus = DBG_EXCEPTION_NOT_HANDLED;


        if(CREATE_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)

        {

            // 디버그 프로세스 생성 이벤트

            TRACE("CREATE_PROCESS_DEBUG_EVENT fired !!\n");


            // 디버그 프로세스 정보

            m_ProcessDebugInfo = event.u.CreateProcessInfo;

        }

        else if(EXCEPTION_DEBUG_EVENT == event.dwDebugEventCode)

        {

            // 디버그 예외 이벤트

            TRACE("EXCEPTION_DEBUG_EVENT fired !!\n");

            HandleException(&event, &dwContinueStatus);

        }

        else if(EXIT_PROCESS_DEBUG_EVENT == event.dwDebugEventCode)

        {

            // 디버그 프로세스 종료 이벤트

            TRACE("EXIT_PROCESS_DEBUG_EVENT fired !!\n");

            return;

        }


        // 디버그 프로세스로 제어를 넘김.

        ContinueDebugEvent(event.dwProcessId, event.dwThreadId, dwContinueStatus);

    }


     EXIT_PROCESS_DEBUG_EVENT 이벤트가 들어올때까지 무한루프를 수행하는 것을 알수 있다.

    EXIT_PROCESS_DEBUG_EVENT 이벤트는 디버그 프로세스(== 디버기)가 종료될때 발생한다. 우리가  처리해야하는 이벤트는 이것 외에 CREATE_PROCESS_DEBUG_EVENT 와 EXCEPTION_DEBUG_

    EVENT 이벤트인데 이름 그대로 디버기가 생성될때와 디버기에서 예외가 발생할때 디버거에서 발생한다. 우리는 여기서 프로세스 정보를 백업하고, 코드를 덮어쓰고 복원한다. 실제로 우리가 관심을 가져야 할 곳은 바로 EXCEPTION_DEBUG_EVENT 이벤트인데, 이녀석은 아까 말한대로 디버기가 예외를 일으킬때 디버거로 발생되는 이벤트인데, 예외의 종류는 다들 잘 알다시피 각종 오버플로우, 0으로 나눔, 접근금지 등등이 있는데, 여러분이 디버깅을 할때 사용하는 중단점(브레이크포인트) 또한 예외의 한 종류이다. 중단점은 디버기가 실행되는 순간(CREATE_PROCESS_DEBUG_EVENT 다음으로)에 실행파일 로더에 의해서 한번 발생하며, 당연히 중단점이 설정될 경우에도 발생한다.

    그럼 HandleException() 함수의 내용을 보자.


    // 브레이크 포인트인가?

    if(EXCEPTION_BREAKPOINT

        == pEvent->u.Exception.ExceptionRecord.ExceptionCode)

    {

        TRACE("EXCEPTION_BREAKPOINT fired !!\n");


        if(0 == m_uBreakCount)

        {

            // 첫번째 브레이크 포인트

            TRACE("First EXCEPTION_DEBUG_EVENT fired !! - InjectSpyDll() Call !!\n");

            if(!InjectSpyDll())

                TRACE("ERROR : InjectSpyDll() Fail !!\n");

        }

        else if(1 == m_uBreakCount)

        {

            // 두번째 브레이크 포인트

            TRACE("Second EXCEPTION_DEBUG_EVENT fired !! - ReplaceOriginalPagesAndContext() Call !!\n");

            if(!ReplaceOriginalPagesAndContext())

                TRACE("ERROR : ReplaceOriginalPagesAndContext() Fail !!\n");

        }


        m_uBreakCount++;

        *pContinueStatus = DBG_CONTINUE;

    }

    else

        *pContinueStatus = DBG_EXCEPTION_NOT_HANDLED;


     첫번째 중단점은 디버기가 실행된 직후에 한번 발생한다고 했다. 따라서 우리가 코드를 덮어쓰는 시점이 바로 이부분이 되어야 할것이다. 아직 디버기는 실행되기 전이며, 우리는 디버기의 첫 실행코드에서 우리의 코드를 덮어쓰는 셈이다. 두번째 중단점은 원본코드를 복원하기 위해서, 덮어쓰는 코드에서 지정해주는데, 디버거로 제어를 넘기기 위해 사용된다. 이부분에서 원본코드를 복원해주어야 할 것이다.


     그럼 실제로 실행코드를 덮어쓰는 부분(InjectSpyDll())을 살펴보자.

    // LoadLibraryA()의 주소

    FARPROC pfnLoadLibrary = GetProcAddress(

        GetModuleHandle("KERNEL32.DLL"), "LoadLibraryA");


    // 실행 프로세스의 첫번째 페이지 얻어옴

    m_pFirstCodePage = FindFirstCodePage(m_ProcessDebugInfo.hProcess,

        m_ProcessDebugInfo.lpBaseOfImage);


    // 실행 스레드 컨텍스트 백업

    m_OrgContext.ContextFlags = CONTEXT_CONTROL;

    GetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext);


    BOOL    bRetCode;

    DWORD    cBytesMoved;


    // 실행 프로세스의 첫번째 페이지 백업

    bRetCode = ReadProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,

        m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);

    if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)

        return FALSE;


    // 스파이 DLL 을 로드할 루틴을 담은 구조체 제작

    PFAKE_LOADLIBRARY_CODE pNewCode = (PFAKE_LOADLIBRARY_CODE)m_pFakeCodePage;


    // sub esp, 1000h

    pNewCode->instr_SUB = 0xEC81;

    pNewCode->operand_SUB_value = PAGE_SIZE; // 페이지크기(4096);


    // push <매개변수>

    pNewCode->instr_PUSH = 0x68;

    pNewCode->operand_PUSH_value = (DWORD)m_pFirstCodePage

        + offsetof(FAKE_LOADLIBRARY_CODE, data_DllName);


    // call <함수주소> ; LoadLibraryA() 호출

    pNewCode->instr_CALL = 0xE8;

    pNewCode->operand_CALL_offset = (DWORD)pfnLoadLibrary

        - (DWORD)m_pFirstCodePage - offsetof(FAKE_LOADLIBRARY_CODE, instr_CALL) - 5;


    // 마지막에 브레이크 포인트 삽입

    pNewCode->instr_INT_3 = 0xCC;


    // 매개변수 (로드될 스파이 DLL)

    char pszDll[MAX_PATH];

    if(!GetSpyDllName(pszDll, sizeof(pszDll)))

        return FALSE;

    strcpy(pNewCode->data_DllName, pszDll);


    // 우리의 루틴을 실행프로세스에 Write !!

    bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,

        &m_pFakeCodePage, sizeof(m_pFakeCodePage), &cBytesMoved);

    if(!bRetCode || sizeof(m_pFakeCodePage) != cBytesMoved)

        return FALSE;


    // 실행 포인트(EIP)를 첫번째 페이지로 설정

    m_FakeContext = m_OrgContext;

    m_FakeContext.Eip = (DWORD)m_pFirstCodePage;


    // 실행 스레드 컨텍스트 설정

    if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_FakeContext))

        return FALSE;


    return TRUE;


     코드가 좀 긴데, 흐름은 이렇다. 먼저 우리가 수행하기를 원하는 코드는 이렇다. 바로 우리가 앞서 만든 testdll.dll을 디버기로 하여금 로드하게 하는것이다. C코드로 하면 디버기에 아래와 같은 코드를 삽입하는 것이다.


    LoadLibrary("testdll.dll");


    그럼 이런 작업을 수행할 코드를 제작해보자. 결론부터 말하면 어셈블리로 아래와 같은 코드가 될것이다.

    push <"test.dll"의 주소>

    call <LoadLibrary의 함수주소>

    int 3


     C언어가 파라미터를 패스하는 방법은 스택을 이용하는 것이다. 함수 파라미터 패싱은 레지스터를 이용하는 방법과 스택을 이용하는 방법이 있는데, 일반적인 경우 C언어에서 파라미터는 스택을 통해서 넘겨진다.(함수호출규약에 관해서는 다음 강좌 "Win32 어셈블리 프로그래밍" 자세히 다루도록 하자.) 어쨋든 위와 같은 코드를 통해서 우리가 원하는 DLL이 로드될것이며, 작업이 끝나면 원본코드의 복원을 위해 디버거로 제어를 넘겨야 하는데, 이를 위해 중단점을 지정한다. 중단점은 어셈블리 코드로 인터럽트 3번 즉, int 3 이다.


     그럼 위의 어셈블리코드를 기계어로 변환시켜보자. 그런데 문제가 있다. test.dll이나 LoadLibrary의 함수주소를 어떻게 처리해야 하는것일까? 먼저 LoadLibrary의 함수주소를 찾아보자. GetModuleHandle(), GetProcAddress()로 함수의 주소를 알아내는 것은 간단할 것이다. (적어도 9x에서는) 그런데 이 함수주소를 실제로 CPU가 해석할때는 어떤방식으로 접근하는지를 알아야할것이다. call 명령이나 jmp 명령 등의 실행제어를 변경하는 명령어들은 32비트 환경에서 보통 5바이트의 크기를 갖는데, 이는 명령어코드 1바이트와 이동할 주소 4바이트(32비트 어드레싱이므로)로 이루어진다. 그런데 명령어의 파라미터가 되는 주소는 절대주소가 아니고 상대주소이다. 그러니까 현재 call 명령을 수행하고 돌아올 리턴주소를 실제 이동할 주소에서 뺀 값으로 기계어코드를 생성한다. 예를 들어 call 100 이라는 어셈블리 명령이 위치한 주소가 50이라면 이 명령은 기계어로 변환되면 0xe8(call 명령어)과 100(이동할주소) - 50(현재 명령의 주소) - 5(현재 명령의 크기)로 전개된다는 것이다. 결과적으로 0xe845000000의 코드값으로 변환될 것이다.(물론 일반적으로 위와 같은 주소값은 Win32에서 유효할수 없다.) 그렇다면 실제로 LoadLibararyA의 함수주소가 0xbff77750이고, 현재 실행중인 코드의 주소가 0x00401bc2 라고 한다면 LoadLibraryA를 호출하는 기계어 코드는 다음과 같을 것이다. 0xe8895bb7bf(call bff77750h) (Intel 계열의 CPU에서는 역워드지정방식을 사용한다는것을 기억하자. 0x12345678은 실제 메모리상에는 0x78, 0x56, 0x34, 0x12로 적재된다.)


     자, 함수호출이 어떻게 이루어지는지 알았으니 이제는 파라미터를 어떻게 전달하는가를 알아보자. 위부분에서 설명했듯이 C언어는 일반적으로 스택을 통해서 파라미터를 전달한다고 했다. 스택에 파라미터("testdll.dll")을 푸쉬하고 좀전에 본대로 함수를 호출해주면 되겠는데, 우리가 사용할 파라미터는 정수나 문자같은 단순데이터형이 아니고 포인터형이다. 그렇다면 실제 문자열을 담고 있는 데이터를 우리가 마련해주어야 하며 그것을 어떻게 찾아서 프로그램이 사용하는지 또한 우리가 지정해주어야 할것이다. 컴파일러는 지역변수와 전역변수를 스택과 힙에 각각 공간을 할당해주며, 코드내에서 이들을 찾아서 연결될수 있도록 배려한다. 그러므로 프로그래머는 데이터를 어떻게 접근해야 하는지를 신경쓸 필요가 없게되고, 단순히 데이터의 이름으로만 참조하면 된다. 우리가 만드는 코드 또한 작은 실행파일과 유사한 실행가능한 코드조각이지만 이런 작업들을 해줄 컴파일러따위는 없다. 그러므로 데이터를 접근하는 방법또한 우리가 직접 지정해주어야 할것이다. 일단 우리는 독립적으로 실행될수 있어야 하므로 전역변수가 사용하는 힙을 사용하기에는 무리가 있다. 결국 스택을 이용해야 하는데 컴파일러는 지역변수를 스택에서 관리한다. (다음강좌에서 다시 다루겠지만 일단 스택포인터를 감소시켜서 지역변수를 위한 공간을 할당한다고만 알아두자. 이부분은 어셈블리를 얘기할때 아주 중요한 사항이므로 꼭 알아두도록 하자.) 스택포인터를 감소시키고 그 공간에 문자열을 담은 데이터를 써넣고 그 문자열의 주소를 다시 스택에 푸쉬한다음 LoadLibraryA를 호출한다면 원하는 DLL을 로드할 수 있을것이다.


     전체적인 쉘코드(쉘코드란 말은 원래 유닉스 계열의 운영체제에서 루트의 권한에서 실행되는 프로그램의 코드를 변조해서 루트의 권한으로 쉘을 획득할수 있게 하는 해킹 코드덩어리를 말한다. 최근까지 유행했던 스택오버플로우 해킹기법으로 세상에 알려졌으며, 보통 리턴주소를 덮어쓰는 방법으로 코드를 실행한다.)가 구상되었다면 이제 실제로 이러한 기능을 담은 쉘코드를 위한 자료구조를 아래와 같이 준비한다.

    // 구조체를 1바이트씩 packing한다.

    #pragma pack(1)


    // 실행프로세스에 주사될 실행루틴(LoadLibraryA())

    typedef struct _FAKE_LOADLIBRARY_CODE{

        WORD    instr_SUB;

        DWORD    operand_SUB_value;

        BYTE    instr_PUSH;

        DWORD    operand_PUSH_value;

        BYTE    instr_CALL;

        DWORD    operand_CALL_offset;

        BYTE    instr_INT_3;

        char    data_DllName[1];

    }FAKE_LOADLIBRARY_CODE, *PFAKE_LOADLIBRARY_CODE;


     일단 1바이트로 구조체를 정렬하도록 지정한후, 구조체의 각 필드를 채워넣는 로직은 위에 리스트된 코드를 참고하길 바라며, 결과적으로 우리가 삽입할 전체적인 어셈블리 구문은 아래처럼 될것이다.


    sub esp, 1000h

    push <"test.dll"의 주소>

    call <LoadLibrary의 함수주소>

    int 3


     처음 스택포인터를 0x1000(4096)만큼 감소시킨것은 아까 말했듯이 마치 컴파일러가 지역변수를 위한 공간을 확보하듯이 우리의 데이터를 위한 스택공간을 확보한것이다.

     자!! 어쨋든 쉘코드가 완성되었다면 이녀석을 프로세스의 코드영역에 덮어쓰고 실행시키기만 하면 우리가 원하는 작업(LoadLibraryA("testdll.dll"))을 수행해줄것이다. 그렇다면 어느곳에 덮어쓸것인가? 우리는 프로그램의 제일 처음코드에 우리의 코드를 덮어씀으로써 먼저 우리가 원하는 작업을 수행한후, 원본코드를 복원해서 마치 프로세스는 아무일없었다는듯 실행되게 할 예정이다. 그렇다면 먼저 프로세스의 실행코드의 첫부분을 찾아내야 하는데, 이를 위해서 FindFirstCodePage() 함수를 보도록 하자.

    BOOL    bRetCode;

    DWORD    cBytesMoved;

    DWORD    peHdrOffset, baseOfCode;


    // 실행 프로세스의 첫번째 페이지 얻음.

    bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + offsetof(IMAGE_DOS_HEADER, e_lfanew),

        &peHdrOffset, sizeof(peHdrOffset), &cBytesMoved);

    if(!bRetCode || sizeof(peHdrOffset) != cBytesMoved)

        return FALSE;


    bRetCode = ReadProcessMemory(hProcess, (PBYTE)pProcessBase + peHdrOffset

        + 4 + IMAGE_SIZEOF_FILE_HEADER

        + offsetof(IMAGE_OPTIONAL_HEADER, BaseOfCode),

        &baseOfCode, sizeof(baseOfCode), &cBytesMoved);

    if(!bRetCode || sizeof(baseOfCode) != cBytesMoved)

        return FALSE;


    return (LPVOID)((DWORD)pProcessBase + baseOfCode);


     그다지 까다로운것은 없는것 같다. 1강에서 언급했던 PE구조를 이해했다면 별어려움 없이 이해할 수 있을것이다. 프로세스의 베이스어드레스에서 코드의 베이스 어드레스만큼의 오프셋을 더한 값을 반환해준다. 이미 눈치챘겠지만 코드의 베이스 어드레스 또한 상대주소라는 것을 알 수 있을것이다.

     어쨋든 디버기의 현재 스레드의 컨텍스트와 첫번째 코드페이지를 백업한후, 아까 만들어놓은 쉘코드를 디버기의 첫번째 코드페이지에 덮어쓴다. 그다음 현재 스레드의 컨텍스트의 실행포인터를 덮어쓴 첫번째 코드페이지로 복원한다. 스레드 컨텍스트에 대해 생소한 사람이 있을것 같아서 짚고 넘어가면 스레드 컨텍스트는 문맥그대로 현재 스레드의 실행상태를 보관하고 있다. 대부분 CPU 레지스터에 관한 정보인데 그중 우리가 꼭 알고 넘어갈것이 eip(확장 인스트럭션 포인터)이다. 이 레지스터가 가지고 있는 데이터는 다음에 실행할 코드주소를 가지고 있다. 따라서 이 녀석을 수정하게 되면 프로그램의 흐름을 원하는 방향으로 제어할수 있다. VC++ 디버거에서 제공하는 Set Next Statment 명령이 바로 이 eip를 수정하므로써 실행흐름을 원하는 곳으로 점프 또는 리턴시킨다. 못믿겠다면 VC++ 디버거에서 레지스터 윈도우를 오픈한뒤 eip의 값을 수정해보라. 프로그램이 여러분이 eip로 지정한 주소로 점프하는 것을 볼수 있을것이다.(실행흐름을 조작하는것은 시스템프로그래밍 디버깅에서는 아주 흔한경우이지만 어플리케이션 레벨의 디버깅에서는 많이 쓰이지 않는듯 하다. Set Next Statment 는 VC++ 디버거에서도 매우 유용한 기능이므로 꼭 기억해두도록 하자. 그러나 남발하게 되면 스택이 망가지거나 돌이킬수 없는 상황을 초래하기도 하므로 잘 알고 사용해야 할것이다.)


     마지막으로 ReplaceOriginalPagesAndContext() 함수는 덮어썼던 코드페이지와 컨텍스트를 원본대로 복원하는 함수이다.

    BOOL    bRetCode;

    DWORD    cBytesMoved;


    // 첫번째 페이지와 스레드 컨텍스트를 원래대로 되돌림.

    bRetCode = WriteProcessMemory(m_ProcessDebugInfo.hProcess, m_pFirstCodePage,

        m_pOrgCodePage, sizeof(m_pOrgCodePage), &cBytesMoved);

    if(!bRetCode || sizeof(m_pOrgCodePage) != cBytesMoved)

        return FALSE;


    if(!SetThreadContext(m_ProcessDebugInfo.hThread, &m_OrgContext))

        return FALSE;


    return TRUE;


     이전에 InjectSpyDll()에서 백업해 두었던 페이지와 컨텍스트를 단순히 되돌림으로써 프로세스(디버기)는 자신이 무슨짓을 했는지도 모른채 정상적으로 실행된다.

    <끝으로 !!>


     오늘강좌는 약간 긴 분량의 강좌였던것 같다. 우리는 실행중인 다른 프로세스에 우리의 모듈을 몰래 주입하는 방법을 배웠다. 어셈블리가 생소한분에게는 좀 버거웠던 강좌였을것이다. 이번 강좌는 예외적으로 예제프로그램의 소스코드를 같이 올리도록 하겠다. 같이 올린 소스코드를 분석해보면 알겠지만 몇가지 잡다한 부분은 오늘 설명에서 제외되었지만 그리 어렵지 않거나 별로 중요하지 않은 부분이니 충분히 혼자 이해할수 있을거라고 믿는다.

     원래는 1강에서 강의한 모듈로서 DLL을 제작한 후, 그것을 다른 프로세스에 주입해서 실제로 다른 프로세스의 API 후킹을 보여주려 했지만, 역시나 욕심이 지나쳐 설명이 많아지는 바람에 다루지 못했다. 이부분은 여러분들이 직접 스스로 해보길바란다. 1강에서 다룬 내용은 동일한 프로세스의 주소공간에서는 유효한 API 후킹이었으므로 이것을 DLL로 제작해 오늘 배운방법으로 프로세스에 주입시키면 그 프로세스의 API를 후킹할수 있을것이다.

     그렇지만 오늘 강좌도 모든 경우에 적용할 수 있는 API후킹이라고 하기에는 모자란점이 많다. 일단 임포트테이블을 조작하는 방법에는 한계가 있다는 점을 말해주고 싶다. 또한 어떠한 경우에는 위의 방법이 전혀 통하지 않는 경우도 있다는것을 참고하기 바란다. 그밖에 멀티스레딩에 대한 문제 등, 일반화하기 위해서는 넘어야 할 산이 많다. 그렇지만 오늘 강좌의 내용은 API 후킹의 실질적인 기초가 되었던 강좌임에는 틀림없다. 시간관계상 설명하지 못하거나 또는 빠뜨린것이 있을수 있겠지만 적어도 오늘 내용만큼은 꼭 이해해주길 바란다.

     다음 강좌는 Win32 어셈블리 프로그래밍에 대해서 약 2회에 걸쳐서 강좌를 진행할 예정이다.


    ****************************************************************************************

    Win32 Global API Hook - 3 Win32 어셈블리 프로그래밍 (1)


     오늘부터 진행할 강좌는 실질적인 기초라고 할수 있겠습니다. 그러나 기초가 제대로 잡히면 그 다음의 응용은 아주 간단한것(물론, 거짓말이란거 다 아시죠?)들이니까 기초부터 튼튼히(!!)하자구요. ^^ 그럼, 어느 성인(?)의 말을 인용하면서 강좌나갑니다.


    "무릇 프로그래밍이라 하는것은 현자가 우매한 중생들에게 진리를 설파하는것과 같다. 오로지 그 수의 많고 적음에 상관없이 소유와 무소유만을 기대하는 우매한 자들에게 그들만이 이해할수 있는 방법으로 세상을 바르게 살아가는 법을 깨우쳐주는것이다."



    1. 어셈블리, 참을수 없는 구조의 단순함 !!


    흔히들 어셈블리를 어렵다고들 한다. 그러나 이것은 정확히 말하면 어셈블리로 프로그래밍하기가 어렵다는 의미이다. 왜냐면 어셈블리 자체는 매우 단순하기 때문이다. 어셈블리 자체가 매우 단순하기때문에 그 단순한 기능만으로 요즘 추세에 맞는 복잡한 어플리케이션을 작성하기가 매우 번거롭다는것이다. (물론 최근에 나온 어셈블러들은 고급언어에 버금갈만한(물론 이것도 사실은 아니다. 예전에 비해서는 그렇다는 말이다.) 코딩의 편의를 제공하기는 하지만) 필자가 예전에 처음 프로그래밍을 공부한지 얼마되지 않았을때 어셈블리와 C언어의 차이점을 선배프로그래머가 이렇게 얘기했다. 여기서는 C언어를 얘기하지만 대부분의 고급언어로 이해해도 무난할것이다.


    "C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어두어라!!"라는 식으로 명령한다면, 어셈블러는 "바나나를 들고 냉장고 앞으로 이동한다음, 냉장고 문을 열고, 사과를 꺼낸다음, 그 자리에 바나나를 넣고, 냉장고 문을 닫아라!!" 이런식으로 명령되어져야 한다"


    지금 생각하면 정말 명쾌한 설명이 아닐수없다. 실제로 C언어는 "냉장고에서 사과를 꺼내고 바나나를 넣어라"라고만 하면 스스로 냉장고 앞으로 이동해야 한다는것, 냉장고 문을 열어야 한다는것, 사과를 꺼내고, 바나나를 넣어야 하며, 마지막으로 냉장고문을 닫아야 하는것을 이해하지만, 어셈블러는 이러한 일련의 작업을 일일이 지정해주어야 한다는 것이다. 그렇지만 C언어든 어셈블리든 결국 행동하는 패턴은 같을수밖에 없으며, 단지 명령하는 사람의 명령을 어디까지 이해할것인가의 문제인것이다. 결과적으로 C언어가 어셈블리보다는 더욱 똑똑하다는 것이다.


    모든것은 상대적인 것일수밖에 없다. 그 옛날 어셈블러도 없이 기계어로 프로그래밍하던 세대에게 어셈블러는 매우 편리한 도구였다. 실제로 DOS용 "LOTUS123"같은 OA프로그램이나, 당시에 엄청난 인기를 끌었던 "페르시아왕자" 1탄 같은 프로그램들이 순전히 어셈블리로 제작되어었다는 것만 보더라도 그 시대에서는 어셈블리가 지금처럼 어렵고 생소한 언어가 아니었을것이다. 단지 우리는 어셈블리보다는 똑똑한 C언어나 파스칼 등의 고급언어에 익숙해져있으니 어셈블리가 생소하고 어려워보이는 것이다. (물론 예전과는 비교도 할수없을 정도로 지금의 프로그램들은 복잡, 다양해지고 있지만) 서두에 언급했듯이 우매한 어셈블리가 세상에서 바르게 살아나갈수 있도록 우리는 조금더 현명해져야 하며, 그들을 이해하며 눈높이를 맞출수있는 열린마음을 가져야할때다.


    어셈블리가 별것아니라는 얘기만 했는데 그렇다고 어셈블리가 정말로 별것 아닌것은 아니다. 아마 개발환경이 아무리 편해지더라도 어셈블리가 세상에서 사라지지는 않을것이라는 필자의 생각이다. 그 이유는 다름아닌 CPU가 명령을 처리하는 가장 하위단계는 잘 알다시피 기계어코드이다. 그러나 사람이 기계어만으로 프로그래밍을 하거나, 기계어코드만 가지고 명령을 이해하기는 매우 힘든작업이므로(불가능한것은 아니다. 예전에는 사람이 이런 작업을 하기도 했었다. 또 우리도 그러한 일을 곧 하게될것이다.) 기계어를 사람이 이해하는 형식으로 표현해주어야 하는데 이것이 바로 어셈블리이다.


    조금 과장해서 말한다면 어셈블리를 알면 프로그램의 흐름을 분석할 수 있으며, 원한다면 프로그램을 원하는대로 수정할수도 있다. 실제로 디바이스드라이버의 경우, 바이너리를 역어셈블(바이너리파일을 어셈블리코드로 변환하는것)해서 C코드를 얻어내는 작업을 실제로 행하기도 한다. 이러한 작업들을 통틀어서 역공학(reverse engineering)이라고 하는데 실제로 쉐어웨어를 크랙하거나 패치하는 작업들도 어찌보면 간단한 수준의 역공학이라고 부를수 있겠다. 물론 이러한일들이 쉬운일은 아니다. 완벽한 역공학을 위해서는 어셈블리는 물론 바이너리가 생성된 환경(보통 컴파일러를 예로 들수 있겠다.)에 대한 깊은 이해를 필요로 하며, 디버깅 심볼이 제거된 릴리즈 바이너리일 경우, 데이터와 함수주소등을 오로지 단순한 메모리주소로만 사용하게 되며, MFC등의 클래스라이브러리를 이용하는 경우에는 그 구조의 복잡함으로 생성되는 바이너리 또한 더더욱 이해하기 힘든구조가 되어버린다. 엎친데 덮친격으로 어떠한 바이너리들은 역공학을 할수없게하거나 힘들게 만드는 anti-debugging 코드를 삽입하는 경우도 있으니 모든 바이너리를 완벽하게 역공학하기란 정말로 힘든 일이 아닐수 없다.


    다시 어셈블리로 돌아와보자. 우리가 이번 강좌에서 배우고자 하는것은 어셈블리로 프로그램을 작성하는 방법이라기보다는 실제로 컴파일러가 어떠한 기계어코드를 만들어내는지와 CPU가 어떻게 프로그램을 해석하고 실행하는지를 이해하기 위한 방법으로 어셈블리를 배우고자 한다. 물론 어셈블리만으로 윈도 어플리케이션을 작성할수는 있다. 그러나 시스템 사양이 높아지고 컴파일러의 성능이 향상됨에 따라 굳이 어셈블리를 통해서 전체 프로젝트를 수행할만한 명분이 점점 사라져가는것 또한 사실이다. 실제로 예전에 많이 사용하던 인라인 어셈블리의 경우에도 자칫하면 수행속도를 떨어뜨리는 결과를 가져오는 경우도 종종 있다.(인라인 어셈블리나 레지스터변수의 사용등이 컴파일러의 최적화기능을 방해하는 경우가 발생할수 있기때문이다.)


    실제로 어셈블리로 프로그래밍한다는 것은 어셈블러의 기능을 익히는것이 어셈블리를 익히는것보다 크게 작용한다. 예를 들면 아래와 같다.


    ; 메시지 루프

    @@: ; start of loop

        invoke GetMessage, addr msg, NULL, 0, 0

        or eax, eax

        je @f

        invoke TranslateMessage, addr msg

        invoke DispatchMessage, addr msg

        jmp @b

    @@: ; end of loop


    필자가 사용하는 어셈블리 템플리트 코드의 일부분으로서, 우리가 API로 윈도프로그램을 작성할때 대부분 공통적으로 적용되는 메시지루프의 로직이다. 대충 훑어보면 C로 작성했을때와 비슷한점을 많이 볼수 있다. 이것은 어셈블러가 고급언어의 특성을 지원하기 위한 의사명령을 제공하기 때문이다. 예를 들면 invoke라는 단어를 보자. 이것은 실제로 기계어로 1:1 대응되는 명령어가 아니다. 확인하고 싶다면 C로 동일한 기능의 프로그램을 작성한다음, 디버거의 디스어셈블리창을 통해서 실제로 기계어(어셈블리)로 변환된 코드를 확인해보자. 분명 invoke라는 명령어는 찾을 수 없을 것이다. 이러한 것들을 통틀어서 의사명령이라고 한다. 실제로 기계어로 변환되지 않지만 어셈블러가 어셈블을 하기위한 환경이나 조건을 제시해주는 것이다.(바로 고급언어의 특성이다.) 그뿐 아니라 @@, @f, @b 등의 라벨들 또한 실제 기계어로 어셈블될때에는 실제 주소로 치환될것이다. 이렇듯 실제로 어셈블리 프로그래밍을 배운다는 것은 단순히 어셈블리(기계어로 치환되는)를 익히는것보다는 어셈블러가 제공하는 기능이나 문법을 배운다는 의미가 크다. 그러나 우리는 어셈블리로 프로그램을 작성하려는 목적이 아니므로 실제로 어셈블리가 어떻게 쓰여지는가를 주로 다루어보도록 한다.


    2. CPU를 알면 프로그램이 보인다 !!


    또 다시 CPU에 관한 얘기를 해야할때가 왔다. 결국 프로그램을 수행하는 주체는 CPU이므로 CPU를 모르고서는 프로그래밍을 말할 수 없다. 어셈블리나 시스템 프로그래밍을 다루는 많은 책에서 CPU 아키텍쳐에 대한 설명이 많이 있지만, 그 모든것을 여기서 다룰수는 없으므로 자세한것들은 참고서적을 이용하도록 하고, 필자의 생각으로 꼭 짚고 넘어가야할 몇가지만 언급하겠다. CPU를 알기위해서 CPU가 다루는 데이터와 명령을 알아야 할것이다. CPU가 다룰수 있는 데이터와 명령의 종류는 CPU마다 차이가 있고 명령의 종류 또한 한두개가 아니지만, 우리는 Intel x86 계열의 CPU 위주로 설명해나가자. CPU가 처리하는 명령의 종류는 CPU가 업그레이드될때마다 추가되기도 확장되기도 하는데(좋은예로 MMX 코드를 들수있다. MMX 코드는 CPU가 멀티미디어 데이터처리를 빠르게 하기위해서 지원되는 명령어세트이다.) 필요한 명령어들은 조금 있다가 알아보도록 하고, 일단 CPU가 다루는 데이터에 대해 얘기해보자. 지난 강좌에서 CPU는 메모리만을 다룬다고 했었는데, 사실은 메모리와 레지스터라는 기억장소를 데이터로써 다룬다. 레지스터는 CPU 내부의 임시기억장소라고 보면 될것이다. 따라서 CPU가 다루는 가장 빠른 매체라고 보면될것이다. x86 레지스터에는 8개의 범용레지스터와 6개의 세그먼트레지스터, 인스트럭션 포인터 레지스터와 플래그레지스터, 디버그 레지스터등, 여러가지 종류가 있는데 보통 사용자가 다룰수 있는 레지스터의 종류와 그 용도는 다음과 같다.


    <레지스터의 종류와 용도>


    eax    : 정수 함수의 반환값들을 담는용도로 사용된다.

    ebx : 뭐 특별한건 없는것 같다. 그냥 범용으로 사용된다.

    ecx : 반복문에서 카운터로 사용된다.

    edx : 64비트(large integer) 값들의 상위 32비트를 담는 용도로 사용된다.


    esi : 보통 메모리 이동이나 비교시에 원본주소를 담는 용도로 사용된다.

    edi : 보통 메모리 이동이나 비교시에 대상(타겟)주소를 담는 용도로 사용된다.


    esp : 스택포인터, 스택의 꼭대기(x86은 스택이 아래로 자라므로 실제로는 바닥이 되겠다.)를 가리킨다. 스

    택에 데이터가 푸쉬하거나 팝될때마다 증감된다.

    ebp : 스택프레임, 프로시저(함수)에 대한 스택프레임을 담는 용도로 사용된다.


    cs : 코드세그먼트, 코드영역의 세그먼트를 지닌다. 32비트 어드레싱에서는 별의미가 없다.

    ds : 데이터세그먼트, 데이터영역의 세그먼트를 지닌다. 역시 32비트 어드레싱에서는 별의미가 없다.


    eip : 인스트럭션 포인터, 실행중인(정확히는 실행되어질) 코드의 주소를 가지고 있다.


    이밖에도 몇가지 레지스터가 더 존재하지만 CPU가 내부적으로 사용하거나, 사용자가 건드릴수 없는것들이므로 일단 위에 리스트된 레지스터만이라도 숙지하고 넘어가자. 어셈블리가 처음인 사람에게는 저게 무슨얘긴가 하겠지만, 일단 외워두던가 아님 한쪽에 잘 프린트해서 붙여놓자. 나중에 실제 코드를 보게되면 이해가 될것이므로... 참고로 접두어 e가 붙은 레지스터이름은(eax, esi, eip 등...) extended의 약자로서 32비트 확장레지스터라는 의미이며, 4바이트(32비트)크기를 가지며, 기존의 16비트로 엑세스하려면 e를 뺀 ax 등의 형태로 쓰여지며, 명시적으로 상위/하위 영역을 지정하려면 ah, al의 형태로 사용된다.


    3. 구조화의 시작, 서브루틴(함수) !!


    서브루틴은 보통 함수(반환값이 있는)와 프로시저(반환값이 없는)로 구분하는데 아시다시피 C언어는 프로시저와 함수의 구별이 없이 무조건 다 함수로 취급한다. 그러므로 C언어에서는 프로시저와 함수는 동일한 의미로 사용한다. 실제로 프로그램의 실행이라는 것이 main함수가 시작됨으로써 시작되며, main함수가 종료됨으로써 프로그램도 종료된다고 알고있다. 이렇듯 프로그램 자체도 하나의 함수로 취급되는것이 일반적이므로 함수가 어떻게 구현되는지에 안다는 것은 프로그램의 흐름을 이해하는데 매우 중요한 의미를 가진다.


    함수(또는 프로시저)는 특정한 목적을 위해 구현된 코드덩어리이다. 함수호출은 실제로 프로그램의 지시포인터(인스트럭션 포인터, ip)를  함수가 구현된 코드로 옮겨서 실행한뒤 원래의 호출한쪽의 코드로 되돌아오는 일련의 작업이다. 백문이 불여일견!! 실제로 함수호출이 어떻게 구현되는지 VC++ 디스어셈블러를 통해서 확인해보도록 하자.


    간단히 아래와 같은 코드를 작성한뒤 디버거에서 디스어셈블리코드를 확인해보자.


    int TestFunc(int a, char b)

    {

        return a+1;

    }


    int main()

    {

        int ret;

        ret = TestFunc(3, "a");


        return 0;

    }


    일단 main()함수내에서 TestFunc()을 호출하는 부분을 살펴보자.


    19:       ret = TestFunc(3, "a");

    00401098   push        61h

    0040109A   push        3

    0040109C   call        @ILT+5(TestFunc) (0040100a)

    004010A1   add         esp,8

    004010A4   mov         dword ptr [ebp-4],eax


    어셈블리는 [명령어] [매개변수], ... 의 형식을 갖는데 일단 위에서 나오는 명령어를 살펴보자.

    push 명령으로 정수 3과 문자 "a"를 스택에 집어넣는다. 이 작업으로 스택포인터는 8바이트 감소하는데, 스택은 4바이트로 정렬되기때문에 1바이트인 문자를 넣더라도 4바이트가 감소한다. 스택포인터가 감소하는 이유는 스택이 아래로 자라기 때문이다.

    call 명령으로 TestFunc 함수로 실행주소를 옮긴다(점프한다). call 명령은 jmp 명령과 더불어 실행흐름(eip)을 변경할수 있는 명령이다.  jmp는 단순히 실행위치를 옮기는데 그치지만, call은 돌아올 주소를 스택에 백업한뒤 실행위치를 옮긴다. 따라서 TestFunc이 호출되는 순간에는 호출되기 전보다 스택포인터는 12만큼 감소되어 있을것이다.(직접 디버거로 확인해보라.)

    add 명령으로 esp(스택포인터)를 8바이트만큼 증가시킨다. 스택을 함수를 부르기전으로 맞춰놓는작업을 해주는 것이다. 어라? 그런데 이상하다. 아까 매개변수 8바이트 + 리턴주소 4바이트, 분명 12바이트가 감소되었다고 했었는데? 그 이유는 다음에 나올 함수코드를 보면 이해할수 있다. 함수의 마지막에 리턴주소로 돌아가는 ret 명령을 만나는데 이작업으로 스택에 저장된 리턴주소를 pop해주게 된다. 결국 스택은 정확하게 복원된다.(많은 책에서는 내부적으로 call 명령과 ret 명령이 리턴주소를 저장하기위해 스택을 사용한다고 설명하는데, 실제로 디버거로 따라가보면 이를 명확하게 확인할 수 있다.)


    다음코드는 TestFunc 함수의 내부코드이다. 눈으로 쭉 따라가보자.


    9:    int TestFunc(int a, char b)

    10:   {

    00401020   push        ebp            ; (1) 함수 시작코드 !!

    00401021   mov         ebp,esp


    00401023   sub         esp,40h        ; (2) 지역변수를 위한 공간확보 !!


    00401026   push        ebx

    00401027   push        esi

    00401028   push        edi

    00401029   lea         edi,[ebp-40h]

    0040102C   mov         ecx,10h

    00401031   mov         eax,0CCCCCCCCh

    00401036   rep stos    dword ptr [edi]


    11:       return a+1;

    00401038   mov         eax,dword ptr [ebp+8]    ; (3) 반환값 설정 !!

    0040103B   add         eax,1       


    12:   }

    0040103E   pop         edi

    0040103F   pop         esi

    00401040   pop         ebx


    00401041   mov         esp,ebp        ; (4) 함수 종료코드 !!

    00401043   pop         ebp

    00401044   ret


    일단 현재 TestFunc이 호출된 시점에서의 스택의 상태는 매개변수와 되돌아갈 주소가 담겨져있다. (아까 위에서 매개변수 61h, 3과 call 명령이 리턴주소를 푸쉬했다.) 이것도 한번 확인해보자. 함수의 시작에 중단점을 걸고(함수의 시작에 중단점을 설정하려면 중괄호 열기("{")에다가 중단점을 지정하면 된다.), 레지스터윈도우에서 esp의 값을 복사한뒤 메모리윈도우에서 붙여넣어보자. 그냥 보면 틀림없이 바이트 형태로 보여질테니까 메모리윈도에서 오른쪽 버튼을 눌러서 long hex format(4바이트 형식)을 선택해서 보자. (메모리 형식을 변경하면 갑자기 엉뚱한 곳으로 메모리가 튀는경우가 종종있는데, 이것은 VC++ 디버거의 버그인것 같다. 그럴땐 다시 주소를 지정해주면 된다.)


    0012FF24  004010A1  00000003  00000061 

    0012FF30  00000000  00000000  7FFDF000

    ...


    필자의 시스템에서는 esp의 값이 0x0012FF24이다. 스택은 아래로 자란다고 했으니 실제 메모리상에는 리턴주소, 3, "a" 순서로 들어있을 것이다. 보아하니 리턴주소는 0x004010a1이고, 정수 3과 "a"(아스키코드 0x61)이 올바르게 들어있는것을 볼수 있을것이다.


    자, 이제 매개변수와 리턴주소가 어떤 형식으로 스택에 보관되어 있는지를 알았으니 함수를 시작해보

    자. "(1)함수 시작코드"를 보자.


    00401020   push        ebp            ; (1) 함수 시작코드 !!

    00401021   mov         ebp,esp


    일단, ebp의 값을 스택에 백업한다. 그런다음 mov 명령으로 esp의 값을 ebp로 복사(이동)한다. mov 명령은 오른쪽에 있는 값을 왼쪽으로 이동하는 명령이다. 현재 esp는 리턴주소를 가리키고 있으므로 ebp도 리턴주소를 가리키고 있을것이다. 이것이 일반적으로 C언어에서 함수를 기술할때 사용되는 형태의 함수 시작코드이다. 일단 이상태에서 매개변수를 접근하려면 어떻게 해야할까? esp+알파, 또는 ebp+알파의 형태로 매개변수를 접근할수 있을것이다(위에서 확인했던것처럼). 그러나 esp(스택포인터) 함수내부에서 또 다른 함수를 호출한다든가 하는 경우에 얼마든지 변할수 있으므로 함수내부에서는 ebp를 기준으로 매개변수를 접근한다. 자, 잘 이해가 안된다면 일단은 외워서라도 알고 있자. "esp+알파의 형식으로 함수의 매개변수(파라미터)를 접근한다."


    중간은 다음에 보도록 하고 "(4)함수의 종료코드"를 먼저 보도록 하자.


    00401041   mov         esp,ebp        ; (4) 함수 종료코드 !!

    00401043   pop         ebp

    00401044   ret


    함수의 종료코드는 함수의 시작코드와 반대의 작업을 해준다. esp의 값을 ebp로 복원한뒤, 스택에 백업된 ebp를 복원한다. pop명령은 push와 반대로 스택에 있는 데이터를 꺼내오는 일은 한다. (혹시 헷갈릴까봐 노파심에서 얘기하면 pop ebp는 ebp를 꺼내온다는 의미가 아니고 스택에서 꺼내온 데이터를 ebp에 집어넣는다는 의미이다. 필자는 처음에 이것을 헷갈렸던것 같은데... 다른사람들은 안그렇나?) 마지막으로 ret 명령으로 함수를 호출한 쪽으로 되돌아간다. (이것도 정확히 말하면 함수를 호출한 바로 다음주소로 돌아간다.) 아까 call명령이 리턴주소를 스택에 푸쉬한다음 점프한다고 했는데, ret은 반대로 스택에서 리턴주소를 팝하고 그 주소로 점프한다.


    자, 그러면 나머지를 보도록하자. 먼저 "(2)지역변수를 위한 공간확보"를 살펴보자.


    00401023   sub         esp,40h        ; (2) 지역변수를 위한 공간확보 !!


    sub 명령은 add 명령과 반대의 기능을 수행하는 명령으로 스택포인터(esp)를 40h 만큼 감소시킨다. 이것은 스택에 지역변수를 위한 공간을 할당하는 것으로 위 명령의 실행되는 시점에서의 esp와 esp-40h의 40h만큼의 공간을 지역변수를 위해 사용할것이라는 의미이다. 지난강좌에서 LoadLibrary를 호출할때 사용할 매개변수인 문자열을 위해서 지역변수 공간을 사용했었다. (잘 생각이 안난다면 지난강좌에서 FAKE_LOADLIBRARY_CODE 구조체를 설정하는 InjectSpyDll() 함수를 다시 보도록 하자.) 어쨋든 여기서 또하나 중요한 사실은 지역변수는 ebp-알파의 형태로 접근된다는 사실이다.(역시 잘 이해가 안된다면 외워서라고 알고있자.)


    여기서 재미있는 사실하나를 발견하게 된다. 사실 위의 코드는 디버그 버전으로 빌드된 바이너리의 실행코드이다. 실제로 TestFunc()은 지역변수를 사용하지 않는데도 불구하고 지역변수를 위한 공간을 확보하고 그 공간을 온통 0xcc의 값으로 채우는것을 볼수있다. 사실 릴리즈 모드로 빌드하게 되면 TestFunc() 아래처럼 간단한 어셈블리코드로 변환된다.


    00401000   mov         eax,dword ptr [esp+4]

    00401004   inc         eax

    00401005   ret


    어쨋든 굳이 지역변수 공간을 잡고 그 안을 0xcc로 채우는이유를 생각해보면 먼저 0xcc의 값에 대해 알아볼 필요가 있다. 0xcc는 어셈블리 코드로 int 3 이다. (이전 강좌에서 본적이 있다. 바로 중단점이다 !!) 초기화되지 않은 지역변수의 값이 0xcccccccc인 것 또한 우연이 아니다. 컴파일러가 디버그 모드일때에 수행할 에러처리를 위해 위와 같은 검사루틴을 삽인한것으로 유추할 수 있다. (일단 저런 메모리 공간으로 뛰어들게 되면 중단점으로 인해서 디버거가 활성화될것이며 이벤트처리기는 적절한 처리를 해줄수 있을것이다.)


    마지막으로 "(3)반환값 설정"을 보자.


    00401038   mov         eax,dword ptr [ebp+8]    ; (3) 반환값 설정 !!

    0040103B   add         eax,1       


    앞에서 ebp+알파의 형식으로 함수의 매개변수를 접근한다고 했다. 대괄호("[]") 표시는 C언어의 *(간접지정연산자)와 비슷한 용도로 사용된다. 대괄호안의 값을 포인터로 인식하고 포인터가 가리키는 값을 가져온다. 어쨌든 첫번째 매개변수 정수 a를 eax 레지스터에 싣고, add 명령으로 1 증가시킨다. eax 포인터는 일반적으로 리턴값을 담는용도로 사용된다. 따라서 a+1의 값이 eax에 담기면서 리턴값으로 처리된다.


    4. 끝으로


    오늘은 어셈블리에 대한 기본적인 설명과 더불어 C언어에서 함수호출이 어떻게 구현되는지를 어셈블리를 통해 상세히 알아보았다. 오늘 강좌에서 가장 중요한 것은 뭐니뭐니 해도 C언어의 함수호출 메카니즘일 것이다. 매개변수와 지역변수가 어떻게 쓰여지며, 함수의 반환값은 또 어떻게 구현되는지. 이것들만큼은 반드시 이해하고 넘어가길 바란다. 필자의 경험으로는 함수호출 메카니즘을 이해하는 가장 좋은 방법은 VC++ 디버거를 이용하는것이다. 디버거의 레지스터윈도, 메모리윈도, 디스어셈블리윈도를 띄어놓고서 한줄한줄(Step-by-Step) 진행하면서 레지스터와 메모리의 값들을 비교하면서 따라가다보면 이해하기가 한결 수월할것이다. 물론 옆에 연습장 가져다 놓구 그림도 그려가면서 말이다.


    5. 다음강좌에서는


    다음강좌는 Win32 어셈블리 두번째 시간이 될것이다. 다음시간에는 오늘 배운 함수호출 메카니즘을 토대로 각각의 호출규약에 따른 함수들의 형태를 다루며, 조건문과 반복문이 어떻게 어셈블리로 쓰여지는지와 시간이 된다면 Win32 구조화 예외처리(SEH)가 실제로 어떻게 구현되는지를 다뤄보도록 하자. 느끼고 있는지는 모르지만 다음 강좌까지만 마스터한다면 "시스템프로그래밍의 사생아"라 불리는 바이러스 제작에 대해서도 심도있게 다루어볼수 있을것이다. (물론 강좌를 진행할 생각은 아직 없지만 말이다.) 사실 바이러스와 전역 API 후킹은 여러모로 비슷한 점이 많이 있다. 크게 다른것은 사용자의 인증을 받은것인지 그렇지 않은지의 차이가 있을뿐이다. 이쯤되면 귀가 번쩍 트이는 독자분도 여럿될텐데 추천할만한 서적을 하나 소개하고 오늘 강좌를 마치고자 한다.


    시스템프로그래밍에 대한 국내서적은 거의 전무하다시피한데 재미있는 책한권을 소개한다. 노파심에서 말씀드리면 이책의 저자나 출판사와 필자는 아무런 상관관계가 없음을 밝힌다. (주)정보게이트 라는 출판사에서 나온 "파괴의 광학"이라는 책이다. 이책의 저자인 김성우님은 월간 마이크로소프트웨어에서 윈도 시스템 해킹을 주제로 강좌를 진행한적이 있는데, 그 강좌의 내용을 모아 한권의 책으로 출간하게 되었다. 주제가 시스템 해킹이고 심지어는 바이러스제작까지 다루고 있지만, 기존의 해킹관련 서적들이 단순한 프로그램 사용법을 소개하는데 그치는 수준인데 비해 이 책의 내용은 프로그래머를 위한 내용을 상당히 알차게 구성하고 있다. 난이도는 상당히 높은 편이지만 본강좌의 내용을 이해할 정도라면 어렵지않게 따라갈수 있을것이다.


    ****************************************************************************************

     Win32 Global API Hook - 3 Win32 어셈블리 프로그래밍 (2)


    1. 프로시저 호출규약 (Procedure Calling Convention)


    지난시간에 프로시저(함수)가 어떤 형태로 매개변수와 반환값을 다루고, 지역변수를 관리하는가에 대한 일반적인 메카니즘을 배웠다. 그러면 조금 더 구체적인 내용으로 들어가서 호출규약에 관한 얘기를 해보자. 호출규약(calling convention)이란 매개변수를 함수에 전달하고 함수가 사용한 스택을 어떻게 정리하는지에 대한 구체적인 규칙에 따른 분류이다. 호출규약에는 다음과 같은 것들이 있다.


    (1) __cdecl        : C 호출규약

    (2) __stdcall     : 표준 호출규약

    (3) __fastcall    : 빠른 호출규약

    (4) __declspec(naked)    : 벗은(?) 호출규약

    (5) this        : this 호출규약


    참고로, 이것외에 __pascal 호출규약이 있는데 Win32로 넘어오면서 더이상 지원하지 않는다. 자, 그러면 하나씩 알아보자. 일단 코드를 보면서 눈으로 직접 확인해보자. (진실은 언제나 디버거가 말해주듯이...)


    (1) __cdecl    (C 호출규약)


    70:       ret = cdecl_Call(3, 4);

    0040D588   push        4

    0040D58A   push        3

    0040D58C   call        @ILT+0(cdecl_Call) (00401005)

    0040D591   add         esp,8

    0040D594   mov         dword ptr [ebp-4],eax


    우리는 이미 지난시간에 많은것(?)을 배웠기에 이정도 어셈블리 코드는 그야말로 껌인것이다. 함수이름은 따로 설명할 필요도 없이 __cdecl로 선언된 C 호출규약 함수이다. 먼저 매개변수가 스택에 푸쉬되는 순서를 보자. 오른쪽 매개변수부터 거꾸로 푸쉬되는 것을 볼 수 있다. 그리고 함수가 호출되고 난 다음, 호출한쪽에서 명시적으로 스택을 정리하는것 또한 확실히 알 수 있겠다.


    그럼, 실제 함수의 구현을 보자.


    19:   // C 호출규약

    20:   int __cdecl cdecl_Call(int a, int b)

    21:   {

    00401040   push        ebp

    00401041   mov         ebp,esp

    00401043   sub         esp,40h

    00401046   push        ebx

    00401047   push        esi

    00401048   push        edi

    00401049   lea         edi,[ebp-40h]

    0040104C   mov         ecx,10h

    00401051   mov         eax,0CCCCCCCCh

    00401056   rep stos    dword ptr [edi]

    22:       return a+b;

    00401058   mov         eax,dword ptr [ebp+8]

    0040105B   add         eax,dword ptr [ebp+0Ch]

    23:   }

    0040105E   pop         edi

    0040105F   pop         esi

    00401060   pop         ebx

    00401061   mov         esp,ebp

    00401063   pop         ebp

    00401064   ret


    함수가 열리면서("{") 함수시작코드와 함께 에러처리를 위한 0xCC(int 3)이 지역변수공간에 쫙 깔리는걸 보니 디버그모드인걸 알수 있다. 어쨋든 중요한것은 맨 마지막 ret 문이다. 그냥 반환되는걸 알 수 있다. 왜냐면 아까 함수를 호출한쪽에서 스택을 정리해주기 때문에 호출되는 함수에서는 그냥 리턴만 하면된다.


    (2) __stdcall (표준 호출규약)


    72:       ret = stdcall_Call(3, 4);

    0040D597   push        4

    0040D599   push        3

    0040D59B   call        @ILT+5(stdcall_Call) (0040100a)

    0040D5A0   mov         dword ptr [ebp-4],eax


    보아하니 __stdcall 호출규약 또한 매개변수를 오른쪽에서 왼쪽으로 스택에 푸쉬한다. 그런데? 함수호출이 수행되고 난 다음 아까보았던 스택 정리코드(add esp, 8)가 빠져있는걸 볼 수 있다. 이걸로 보아 호출되는 함수내부에서 스택을 정리한다고 미루어 짐작할 수 있다.


    25:   // 표준 호출규약

    26:   int __stdcall stdcall_Call(int a, int b)

    27:   {

    00401070   push        ebp

    00401071   mov         ebp,esp

    00401073   sub         esp,40h

    00401076   push        ebx

    00401077   push        esi

    00401078   push        edi

    00401079   lea         edi,[ebp-40h]

    0040107C   mov         ecx,10h

    00401081   mov         eax,0CCCCCCCCh

    00401086   rep stos    dword ptr [edi]

    28:       return a+b;

    00401088   mov         eax,dword ptr [ebp+8]

    0040108B   add         eax,dword ptr [ebp+0Ch]

    29:   }

    0040108E   pop         edi

    0040108F   pop         esi

    00401090   pop         ebx

    00401091   mov         esp,ebp

    00401093   pop         ebp

    00401094   ret         8


    실제로 함수구현을 보니까 정말로 마지막 반환코드(ret 8) 스택포인터을 8바이트 올리고 리턴하는걸 볼 수 있다. "ret n" 은 n바이트 만큼 esp를 증가한 후에 리턴하는 어셈블리 명령이다. 그럼, 여기서 문제 ret 8 이 수행되고 나면 esp(스택포인터)는 얼마만큼 증가할까? 8바이트라고? 그렇게 간단하면 왜 물어보겠는가. 정답은 12바이트(0xC)이다. 왜냐하면 ret 명령이 스택에서 리턴주소를 꺼내온뒤 그곳으로 점프(jmp)하기 때문이다. (지난시간에 call 명령과 함께 비교하며 설명했다. 기억해두자, call과 ret명령은 내부적으로 스택에 리턴주소를 위한 push, pop을 수행한다.)


    (3) __fastcall (빠른 호출규약)


    __fastcall 호출규약은 이름 그대로 빠른 호출을 위해서 사용되어진다. 메모리보다 상대적으로 빠른 레지스터를 이용한다고 해서 레지스터 호출규약(Register calling convention)이라고도 한다. 그러나 Win32 사용자모드에서는 원칙적으로 레지스터 호출규약을 사용하지 않는다. 다음강좌인 VxD 강좌때 보게 되겠지만, VxD 시스템 함수들은 레지스터 호출규약을 사용하는 것들이 상당수 존재한다. 그러나 Windows NT/2000 커널모드 드라이버에서는 더이상 레지스터 호출규약을 사용하지 않는데 그 이유는 레지스터는 CPU에 종속적인 저장매체이기때문에 CPU간 호환성을 보장할 수 없기때문이다.


    74:       ret = fastcall_Call(3, 4, 5, 6);

    0040D5A3   push        6

    0040D5A5   push        5

    0040D5A7   mov         edx,4

    0040D5AC   mov         ecx,3

    0040D5B1   call        @ILT+35(fastcall_Call) (00401028)

    0040D5B6   mov         dword ptr [ebp-4],eax


    호출하는 쪽을 보게 되면, 첫번째와 두번째 매개변수를 ecx, edx 레지스터에 담아서 호출하는것을 볼 수 있다. 그러나 레지스터의 수는 한정되어 있으므로 모든 매개변수를 레지스터에 담을수는 없다. 따라서 처음 두개의 매개변수만 ecx, edx에 담고 나머지는 스택을 통하는데 이때에도 마찬가지로 오른쪽에서 왼쪽으로 스택에 푸쉬하고, __stdcall과 마찬가지로 호출되는 함수쪽에서 스택을 정리한다.


    31:   // 빠른 호출규약

    32:   int __fastcall fastcall_Call(int a, int b, int c, int d)

    33:   {

    004010A0   push        ebp

    004010A1   mov         ebp,esp

    004010A3   sub         esp,48h

    004010A6   push        ebx

    004010A7   push        esi

    004010A8   push        edi

    004010A9   push        ecx

    004010AA   lea         edi,[ebp-48h]

    004010AD   mov         ecx,12h

    004010B2   mov         eax,0CCCCCCCCh

    004010B7   rep stos    dword ptr [edi]

    004010B9   pop         ecx

    004010BA   mov         dword ptr [ebp-8],edx

    004010BD   mov         dword ptr [ebp-4],ecx

    34:       return a+b+c+d;

    004010C0   mov         eax,dword ptr [ebp-4]

    004010C3   add         eax,dword ptr [ebp-8]

    004010C6   add         eax,dword ptr [ebp+8]

    004010C9   add         eax,dword ptr [ebp+0Ch]

    35:   }

    004010CC   pop         edi

    004010CD   pop         esi

    004010CE   pop         ebx

    004010CF   mov         esp,ebp

    004010D1   pop         ebp

    004010D2   ret         8


    함수의 구현부이다. 그런데 뭔가 좀 이상한것이 있다. 빠른 호출을 위해서 레지스터를 통해서 매개변수를 넘겨준다고 했는데, 매개변수로 넘어온 레지스터를 다시 지역변수 공간(ebp-알파)에 담아서 사용하는것을 볼 수 있다. 이건 예제가 디버그모드로 빌드되었기 때문인데 릴리즈로 빌드된 어셈블리 코드는 다음과 같다.


    00401020   lea         eax,[ecx+edx]

    00401023   mov         edx,dword ptr [esp+4]

    00401027   mov         ecx,dword ptr [esp+8]

    0040102B   add         eax,edx

    0040102D   add         eax,ecx

    0040102F   ret         8


    lea 명령이 처음나온것 같은데, lea 명령은 아주 자주 쓰이는 명령이니까 잘 알아두자.

    lea (Load Effective Address): 오른쪽 피연산자의 주소(메모리)를 왼쪽 피연산자(레지스터)로 전송한다. 보통 C언어 포인터변수를 설정하는 문장에서 사용된다.


    처음 두개의 매개변수는 eax와 ecx 레지스터로 취급하고 나머지 레지스터는 스택을 이용하는것을 볼 수 있다.


    (3) __declspec(naked) (벗은(?) 호출규약)


    해석이 조금 이상한데 의미상으로는 스택프레임을 설정하는 기존의 함수구조를 위한 함수시작코드와 함수종료코드를 제공하지 않는 다는 의미이다. 그러므로 이것을 __cdecl 형태로 구현해서 사용하던, __stdcall 형태로 사용하던 그것은 순전히 사용자 맘이다. naked 호출규약은 엄밀한 의미로는 호출규약이라고 할수 없는데, 왜냐면 이것은 함수의 구현부에서 함수가 구현되는 방법의 문제이지 앞에서의 호출규약처럼 함수의 선언의 문제가 아니기 때문이다. 어쨋든 코드를 보자.


    76:       ret = naked_Call(3, 4);

    00401159   push        4

    0040115B   push        3

    0040115D   call        @ILT+25(naked_Call) (0040101e)

    00401162   add         esp,8

    00401165   mov         dword ptr [ebp-4],eax


    매개변수는 위의것과 동일하게 오른쪽에서 왼쪽으로 푸쉬되며, __cdecl 과 마찬가지로 호출한 쪽에서 스택을 복원해줌을 알 수 있다. 이것은 naked 호출규약이라서 그런게 아니고 디폴트 호출이 일어난것이다. 아까 말했듯이 naked 호출규약은 함수구현의 문제이다.


    // 벗은(?) 호출규약

    __declspec(naked) int naked_Call(int a, int b)

    {

        //return a+b;

        __asm

        {

            push    ebp            // 함수 시작코드

            mov        ebp, esp


            mov        eax, a

            add        eax, b


            mov        esp, ebp    // 함수 종료코드

            pop        ebp

            ret

        }

    }


    디스어셈블된 코드가 아니고 그냥 C 코드를 보였다. 왜냐면 인라인어셈블리를 사용했기 때문이다. 보다시피 함수시작코드와 함수종료코드를 사용자 스스로 구현해주어야 한다는 것을 알 수 있다.


    (5) this : this 호출규약


    마땅히 붙일 이름이 애매한데 C++에서 this 포인터가 어떻게 처리되는지를 정의해놓은 규약이라고 보면된다. 역시 코드를 보면,


    78:       This_Call thiscall;

    79:       ret = thiscall.Call(3, 4);

    00401168   push        4

    0040116A   push        3

    0040116C   lea         ecx,[ebp-8]

    0040116F   call        @ILT+10(This_Call::Call) (0040100f)

    00401174   mov         dword ptr [ebp-4],eax


    다른것들은 별로 눈여겨볼것이 없는데, 함수를 call 하기 직전에 무언가(지역변수로 추정되는)를 ecx에 lea 명령으로 로드한뒤 call해주는 것을 볼 수 있다. 여기서 ecx 레지스터가 바로 this 포인터이다. 클래스의 멤버함수를 부를때 C++ 컴파일러는 ecx 레지스터를 클래스 인스턴스 자신을 가리키는 포인터를 담는 용도로 사용한다는 것이다. 따라서 멤버함수는 자기가 언제 어디에서 호출되었다 하더라도 나를 부른 녀석이 어떤 인스턴스인지를 정확히 알 수 있다. (물론 이 ecx 레지스터를 이용해서 재미있는 장난을 해볼수도 있을텐데, 시간이 남는 사람은 한번 이것 저것 해보기 바란다.)



    56:   // this 호출규약

    57:   class This_Call

    58:   {

    59:   public:

    60:       int Call(int a, int b)

    61:       {

    004011C0   push        ebp

    004011C1   mov         ebp,esp

    004011C3   sub         esp,44h

    004011C6   push        ebx

    004011C7   push        esi

    004011C8   push        edi

    004011C9   push        ecx

    004011CA   lea         edi,[ebp-44h]

    004011CD   mov         ecx,11h

    004011D2   mov         eax,0CCCCCCCCh

    004011D7   rep stos    dword ptr [edi]

    004011D9   pop         ecx

    004011DA   mov         dword ptr [ebp-4],ecx

    62:           return a+b;

    004011DD   mov         eax,dword ptr [ebp+8]

    004011E0   add         eax,dword ptr [ebp+0Ch]

    63:       }

    004011E3   pop         edi

    004011E4   pop         esi

    004011E5   pop         ebx

    004011E6   mov         esp,ebp

    004011E8   pop         ebp

    004011E9   ret         8


    구현부는 별로 볼게 없다. 단지 this 포인터라고 알려진 ecx 레지스터를 지역변수 공간으로 로드하는것을 볼수 있는데 이것 또한 디버그 모드이기때문에 생기는 코드일것이라고 생각된다. (궁금하면 직접 확인해보시길...)


    자, 모두 5개의 호출규약에 관해서 각각의 호출규약이 실제로 어떤 형태로 구현되는지를 완벽하게 이해했을거라고 믿는다. 몇가지 덧붙이자면 실제로 대다수의 Win32 API는 __stdcall 로 지정되어 있다. 우리가 항상 보는 WinMain() 함수 또한 __stdcall 이다.

    마이크로소프트는 추후 확장성을 고려해서 재정의된 데이터타입(WORD, DWORD 등)을 사용하는것을 권장하는데 같은 맥락으로 함수 호출규약또한 재정의된 형태로 사용하는것을 권장한다. 그럼 Win32 SDK 에서 재정의된 호출규약이 어떤것인지 잠깐 알아보자.


    CDECL        __cdecl

    WINAPI        __stdcall

    APIENTRY    __stdcall

    CALLBACK    __stdcall

    PASCAL        __stdcall

    WSAAPI        __stdcall

    FASTCALL    __fastcall


    CDECL을 제외한 우리가 많이 보던 CALLBACK, WINAPI니 하는 것들은 모두 __stdcall 로 처리되는것을 알 수 있다. (사족을 달자면, 꼭 저렇지만은 않다. 어떤 환경이냐에 따라서 약간씩 다르게 전처리 되기는 하지만, 일반적인 Win32환경에서 특별히 신경쓰지 않는한 저 규칙을 벗어나지 않을 것이다.) 아참, 잊을 뻔 했는데 VC++에서는 특별히 컴파일옵션을 만지지 않는한 디폴트로 __cdecl 호출규약을 사용한다.


    자, 그러면 호출규약에 대해서는 직접 확인해보았으니 더이상 의문이 없을것 같지만, 왜 이렇게 여러가지 호출규약들이 존재해야 하는지는 궁금해할 사람들이 있을지 모르겠다. 일단 크게 __cdecl 과 __stdcall 호출규약의 커다란 차이점은 스택을 어디에서 정리해줄 것이냐의 차이점이다. 전자는 호출하는 쪽에서 후자는 호출되는 함수내부에서 스택을 정리해준다. 상식적으로 생각해서 호출받는 쪽에서 스택을 정리하는 __stdcall 쪽이 더 합리적이라고 생각될지 모르지만, __cdecl 이 필요한 이유는 따로 있다.


    xprintf 계열의 가변인자 함수에서 그 이유를 찾을 수 있다. __stdcall 함수의 제작자들은 함수가 매개변수의 개수(정확히 말하면 매개변수를 받는 스택의 크기이다.)를 알고 있다는 가정하에서 함수를 구현하기를 원했다. 함수의 재사용성과 모듈화를 극대화하기 위한 조치였다고 볼 수 있는데, 이런 형태의 구조에서는 가변인자를 구현할 수 없다는 단점이 있었다. 일단 가변인자를 구현하려면 호출받는쪽에서 몇개의 매개변수가 들어올지 모르기 때문에 호출받는쪽에서 스택을 정리해줄 수가 없었고, 오로지 호출하는쪽에서만이 매개변수의 수를 알고 있을뿐이었다. 그러므로 당연히 가변인자를 지원하는 함수들은 스택을 호출하는쪽에서 정리해주어야만 했고 따라서 __cdecl 호출규약이 필요하게 된것이다. 사실, 이러한 호출규약에 따른 분류는 다분히 C언어의 관점에서 보았을때 그렇다는 말이다. 실제 CPU의 관점에서는 우리가 스택을 이용하던 레지스터를 이용하던 함수를 구현하던 그냥 쭉 흐르는듯이 프로그램이 진행되던 별 상관없다. 그냥 시키는대로만 할 뿐이다. 단지 운영체제의 많은 부분이 C언어로 구현되어 있으며, 우리가 후킹하려는 API또한 대부분 C언어로 구현되어 있으므로 C언어가 함수를 어떻게 구성하는지가 중요한것이다.


    아, 갑자기 생각났는데 실제 함수의 이름은 내부적으로는(정확하게 말하면 Link시에) 다른이름으로 변환되어져서 사용되는데 __cdecl 함수는 앞부분에 밑줄이 붙고 __stdcall 함수들은 앞부분에 밑줄과 더불어서 뒤에 @와 매개변수의 크기(스택의 크기)가 붙는다. 이것만 보더라도 __stdcall 함수들은 함수스스로가 매개변수의 크기를 지정해두었다는 것을 알 수가 있다. (확인을 위해서는 extern "C" 문장으로 C 타입으로 함수를 선언해주어야만 한다. C++은 다중정의(오버로드)를 구현하기 위해 함수 이름을 더 복잡하게 만들어버리기 때문에 사람이 확인하기가 쉽지 않다.)


    2. 흐름제어


    흐름제어의 가장 중요한 요소인 조건문과 반복문에 대해서 알아보자. 이쯤되면 척하면 척일텐데 벌써 조건문과 반복문 예제를 만들어서 디스어셈블하고 있는 저 친구를 보라 !! (참, 영특하지 않은가? ^^)


    (1) 조건문


    67:       if(a > 3)

    0040115F   cmp         dword ptr [ebp-4],3

    00401163   jle         main+34h (00401174)

    68:       {

    69:           printf("3 보다 크다. !!\n");

    00401165   push        offset string "3 \xba\xb8\xb4\xd9 \xc5\xa9\xb4\xd9. !!\n" (00422f84)

    0040116A   call        printf (0040d780)

    0040116F   add         esp,4

    70:       }

    71:       else

    00401172   jmp         main+41h (00401181)

    72:       {

    73:           printf("3 보다 작거나 같다. !!\n");

    00401174   push        offset string "3 \xba\xb8\xb4\xd9 \xc0\xdb\xb0\xc5\xb3\xaa \xb0\xb0\xb4

    \xd9. !!\n" (0042

    00401179   call        printf (0040d780)

    0040117E   add         esp,4

    74:       }


    이제는 이정도만 봐도 알 수 있을것이다.


    조건문에서 사용되는 어셈블리 명령어는 매우 여러가지이지만 그 방식은 거의 동일하다. 일단 cmp 명령을 만났다. 딱 보니까 지역변수 a와 정수 3을 비교하는것 같다. 그 아래 jle 명령을 보자. 이름에서 느껴지는 뉘앙스가 웬지, 작거나 같으면 점프(Jump if less or equal)을 연상시키지 않는가? 우리가 생각한 그대로다. 정확하게 두번째 블럭안으로 점프하게 된다. 그럼 그렇지 않다면 그 다음 문장, 바로 3보다 큰경우를 수행하는 첫번째 블럭을 수행하고 두번째 블럭을 건너뛰고 진행하게 된다.


    자, 전체적인 흐름은 이해가 될것인데 좀더 구체적으로 살펴보자. 단순히 cmp 하나의 명령으로 어떤놈이 작고, 큰지, 또는 같은지를 알 수 있을까? 이것은 플래그 레지스터라는 놈을 이해하면 된다. 플래그 레지스터가 각각의 상태를 표현하기 위해 특정한 값들을 갖게된다. 예를 들면 cmp 명령으로 비교한 두개의 값이 같다면 ZR 플래그(또는 ZF)가 1로 세트된다. 이렇게 cmp나 test등의 비교명령은 플래그 레지스터를 이용해서 그 결과를 나타내는데 우리는 이러한 플래그들을 가지고 원하는 실행위치를 점프하게되는데, 이럴때 사용하는 것이 바로 jxx 계열의 조건점프명령이다. 우리는 지금까지 jmp 명령만을 보았는데 이것은 무조건 점프하는 명령이라면 아래에 리스트된 조건점프 명령들은 플래그들의 조합으로 비교의 결과를 인식하고 조건에 맞는 경우에만 점프하게 된다.


    je (jump if equal)    : 같다면 점프한다. ( == )

    jne (jump if not equal)    : 같지 않다면 점프한다. ( != )

    jl (jump if less)    : 작다면 점프한다. ( < )

    jg (jump if greater): 크다면 점프한다. ( > )

    jge (jump if greater or equal)    : 크거나 같다면 점프한다. ( >= )

    jle (jump if less or equal)        : 작거나 같다면 점프한다. ( <= )


    뭐, 별로 어려울건 없을것이다. 실제로 매크로어셈블러 문서를 살펴보면 알게되겠지만 조건점프명령은 저것 말고도 여러가지가 더 있다. 명령어에서 e가 들어간 명령은 z로 바뀐 다른명령어로도 사용되는데 둘의 의미는 동일하다. 예를 들면 je와 jz는 동일하게 동작한다. 의미는 jz은 zero flag가 설정된 경우에 점프한다라는 의미이지만 비교결과가 같다면 zero flag가 설정되기 때문에 결과적으로 같은 동작을 수행한다. (실제 바이트코드를 보면 정말 그런지 알수 있을것이다. 이런것들은 초보자들에게 약간의 혼란을 가져오는데 실제로 Intel CPU 매뉴얼과 매크로어셈블러 매뉴얼이 표기하는 명령어 형식이나 플래그 레지스터의 이름등에서 약간의 차이가 있다. 사실 같은 의미인데 이름을 조금씩 다르게 지정해놓은것이 몇가지 존재한다.)


    그런데, 어셈블리 명령과는 별개로 위의 디스어셈블 코드를 살펴보면 재미있는것을 발견할 수 있을텐데, 그것은 바로 우리가 제시했던 조건과는 반대되는 조건으로 검사하고 분기한다는 점이다. 우리는 3보다 크다면을 검사했는데 실제 디스어셈블된 코드를 보면 3보다 작거나 같다면으로 검사한다는 것이다. 이것은 컴파일러가 어셈블리코드를 생성할때 가급적 jmp명령을 사용하지 않기위해서 최적화를 수행하는 것이다. 실제로 반대로 구현해보면 jmp 명령을 두번 사용해야 한다는것을 알 수 있을것이다. 이것은 실제로 어셈블리로 프로그래밍할때에는 기본적인 사항인데 우리가 원하는 조건의 반대조건으로 검사하는 것이 프로그램을 더 작고 빠르게 만든다. jmp명령은 실행위치를 변경하는 상대적으로 비싼대가를 치뤄야 하는 명령이기 때문이다.


    (2) 반복문


    76:       for(int i=0; i<5; i++)

    0040D7F1 C7 45 F8 00 00 00 00 mov         dword ptr [ebp-8],0

    0040D7F8 EB 09                jmp         main+53h (0040d803)

    0040D7FA 8B 45 F8             mov         eax,dword ptr [ebp-8]

    0040D7FD 83 C0 01             add         eax,1

    0040D800 89 45 F8             mov         dword ptr [ebp-8],eax

    0040D803 83 7D F8 05          cmp         dword ptr [ebp-8],5

    0040D807 7D 0B                jge         main+64h (0040d814)

    77:       {

    78:           a++;

    0040D809 8B 4D FC             mov         ecx,dword ptr [ebp-4]

    0040D80C 83 C1 01             add         ecx,1

    0040D80F 89 4D FC             mov         dword ptr [ebp-4],ecx

    79:       }

    0040D812 EB E6                jmp         main+4Ah (0040d7fa)


    C언어에서 가장 많이 사용되는 for()문이다. 일단 지역변수 i의 값을 0으로 세트하고 바로 점프하는 명령이 있다. 어디로? 바로 조건식으로 점프한다. 조건식은 정수 5와 i를 비교한후, 크거나 같다면(역시 반대의 조건으로 검사하는것을 볼 수 있다.) 반복문을 탈출한다. 반복문 내에서는 지역변수 a를 1 증가하고, 지역변수 i를 1 증가하는 증감식으로 점프한다. 그런다음 다시 조건문으을 실행한다. 뭐 별로 어려울것 없다. 그럼 다음은 while()문을 보자.


    76:       int i = 5;

    0040D7F1 C7 45 F8 05 00 00 00 mov         dword ptr [ebp-8],5

    77:       while(i--)

    0040D7F8 8B 45 F8             mov         eax,dword ptr [ebp-8]

    0040D7FB 8B 4D F8             mov         ecx,dword ptr [ebp-8]

    0040D7FE 83 E9 01             sub         ecx,1

    0040D801 89 4D F8             mov         dword ptr [ebp-8],ecx

    0040D804 85 C0                test        eax,eax

    0040D806 74 0B                je          main+63h (0040d813)

    78:       {

    79:           a++;

    0040D808 8B 55 FC             mov         edx,dword ptr [ebp-4]

    0040D80B 83 C2 01             add         edx,1

    0040D80E 89 55 FC             mov         dword ptr [ebp-4],edx

    80:       }

    0040D811 EB E5                jmp         main+48h (0040d7f8)


    위의 for()문과 똑같은 일을 수행하는 while()문이다. 역시 별로 어려울것 없으니 스스로 따라가보자.


    여기서 또하나 재미난것은, 실제 어셈블리 명령어중에 loop라는 명령어가 존재한다. loop 명령은 ecx 레지스터에 지정된 수만큼 반복작업을 수행하는데 실제로 마이크로소프트 C컴파일러는 loop 명령을 사용하지 않는 경우가 대부분이다. 위의 예처럼 조건분기 명령을 사용해서 반복문을 구현한다. 혹시 디버그모드라서? 그렇다면 릴리즈로 빌드한뒤 디스어셈블된 코드를 살펴보자. 그러나 릴리즈모드에서도 별로 다르지 않다. (음... 쓰고나서 보니까 위의 예제를 릴리즈로 빌드하면 아마도 반복문이 사라져버릴것이다. 왜냐면 반복문에서 하는 유일한 작업인 a++; 문장은 프로그램 어디에도 영향을 주지않는 있으나마나한 코드이기 때문에 컴파일러가 릴리즈 최적화를 수행하면서 무시될것이다. 어쨋든 유효한 반복구문을 사용해서 확인해보자.)


    또 참고로 얘기하면 일반적으로 while()문이 for()문보다 빠르다고 알려져있는데 위의 코드를 보면 왜 그런지 확실히 알 수 있을것이다. (코드길이가 작아서? 음... 것도 틀린건 아니지만 보다 중요한것은 for()문보다 while()문이 jmp 명령을 더 적게 사용하는것을 볼수 있다. 아까도 말했듯이 jmp 명령은 상대적으로 비싼 명령이다.)


    3. 끝으로


    이상으로 Win32 어셈블리 프로그래밍에 대한 강좌를 마칠까 한다. 사실 2회의 강좌로 어셈블리를 모두 이해할수 있을거라고는 생각하지 않지만, 적어도 C언어로 작성한 프로그램이 어떠한 형태로 구현되는지에 대한 감은 잡았을거라고 생각한다. 원래 지난시간에 약속한대로 Win32의 강력한 에러처리 메카니즘인 구조화 예외처리(SEH)에 대해서도 다루려고 했지만 시간상 미처 다루지 못했다. 실제로 API 후킹을 구현하게 되면 반드시 예외처리를 해주어야 하는데 그 이유는 시스템 DLL을 건드리는 작업은 매우 조심스러운 작업이기때문에 문제가 생기면 시스템을 재부팅해야 하는 경우가 비일비재하기 때문이다. 따라서 문제가 발생하면 즉시 에러를 복구하고 정상적으로 종료함으로써 다른 프로그램들에게 영향을 주지 않아야 한다. 구조화 예외처리에 관해서는 다음번 Hooking Code를 작성하는 부분에서 다시 얘기할 기회가 있으니, 그때 다루기로 하자.


    필자가 소개한 어셈블리에 대한 내용은 극히 일부분이거나 기초가 되는 수준이라는것을 잊지말자. Intel x86 계열 CPU는 오늘 소개한 명령이외에도 문자열처리나 멀티미디어 데이터처리 등에 관한 무수히 많은 명령어들을 제공하며, 어셈블러 또한 무수히 많은 의사명령과 예약어등을 제공한다. 이 모든것을 모두 알아야 할 필요는 없다고 해도, 분명 자바나 베이식만을 다루는 사람과 어셈블리까지 아는 사람과는 프로그래밍을 바라보는 시각에서부터 엄청난 차이가 난다고 믿어 의심치 않는다.


    4. 다음강좌에서는


    이미 예고한대로 다음강좌에서는 Windows9x에서 지원하는 디바이스드라이버인 VxD에 대해서 얘기하도록 하겠다. 내용의 방대함으로 모든것을 다룰수 없음에 우리가 필요한 부분만을 구현하는 쪽으로 강좌의 촛점을 맞춰나가겠다. 더불어서 보호모드와 운영체제 실행권한인 Ring0에 관한 설명과 디바이스드라이버를 통하지 않고 Ring0를 얻어내는 Windows의 뒷문(?)에 대해서도 얘기해보자. 디바이스드라이버에 대한 경험이 전혀 없는 독자라면 다음 강좌부터는 실습을 따라가는것조차 버거울텐데 그 이유는 아직도 디바이스드라이버 개발환경이라는것이 원시적이기 짝이 없기때문이다. 일단 VC++같은 멋진 통합환경과 디버거를 기대한다면 일찌감치 다른일을 찾아보는것이 빠를것이다. 내용이 내용이니만큼 준비해야할 것들과 다음강좌의 진행내용을 전체적으로 알아보자.


    (1) Window 98 DDK(DeviceDriver Development Kit)

    : 마이크로소프트 DDK 홈페이지(http://www.microsoft.com/ddk/)에 가면 구할수 있다. 각 운영체제별로 DDK를 따로 제공하는데, 우리는 일단 98에서 작업할것이므로 98용 DDK를 다운로드받길 바란다.


    (2) Numega SoftIce

    : 지난강좌에서 언급했던 John Robbinson이나 Matt Pietrek이 시스템엔지니어로 근무하는 Numega Software에서 제공하는 시스템 디버거이다. 커널모드와 사용자모드를 둘다 지원하는 거의 유일한 디버거이며, 크래커들의 필수품으로 애용될만큼 디버거보다는 크래킹 도구로 널리 알려져 있다. 문제는 이것은 상용프로그램이란점인데(가격또한 만만치 않다.) 회사오너를 졸라서 구입하든 또 다른 경로(?)를 통해서 구하든 각자 알아서 구해오도록 하자. (필자에게 보내달라고 하지는 말기 바란다. 필자는 아직 딸린식구는 없지만, 앞길이 구만리같은 젊은이기에... ^^)


    (3) 그밖에 익숙한 텍스트편집기

    : 정 쓸만한게 없다면 VC++를 사용해도 된다. 그리고 취향에 따라 다르겠지만 노트패드나 DOS용 에디터를 사용해도 무관하다. 필자의 경우는 울트라에디터를 사용하는데 프로그래머를 위한 지원이 잘되어 있는 편집기라고 생각한다.


    ****************************************************************************************

    데브피아 팁게 편집.. 강좌가 중간에 끝나서 아쉽.. -_-;

    반응형

    디버깅은 프로그래밍을 시작하는 사람이면 누구나 동시에 시작하는 작업입니다. 디버깅을 시작하는 사람들을 위해 프로그래밍을 시작하는 처음부터 어떤 태도를 가져야 할 지, 어떤 것을 알아야 할 지 알아보도록 하겠습니다. 대중적인 플랫폼을 크게 두 개로 보았을 때, 디버깅은 윈도우 계열과 유닉스 계열의 큰 차이는 없습니다. 다만, 그 툴이 현저히 달라서 두 계열 동시에 비슷한 기능을 하는 디버깅 툴을 소개한다는 것은 불가능한 일입니다. 하지만 프로그램을 작성할 때부터 디버깅을 염두에 두고 프로그램을 작성하는 면에서는 크게 다르지 않습니다. 이 글에서는 프로그램이 만들어지는 순간들을 살펴보면서, ‘디버깅을 위한 프로그래밍 습관’에 대해 모든 환경에서 주의해야할 디버깅 기법에 대해 정리하고자 합니다. 참고로, 필자는 유닉스 기반의 서버 프로그램을 작성 및 포팅하는 것을 전문으로 하고 있습니다. 디버깅을 너무 일반적으로 할 수는 없는 것이므로, 연재를 통틀어 C/C++를 기반으로 전개할 것입니다.

    언어와 개발 환경
    다음의 몇 가지 이야기는 디버깅을 잘하는 것은 올바른 배움의 자세에서 나온다는 것을 전제로 생각해 보기 위한 글입니다.
    반쪽 프로그래머
    프로그래밍을 처음 배우는 사람은 C++, 자바, HTML 등을 배우게 됩니다. 즉, 언어를 배우게 되는 것이죠. 여기에 디버깅을 생각하면, 참으로 뛰어넘기 어려운 커리큘럼의 한계에 부딪히게 됩니다. 배울 때는 언어만 배우면 될 것 같지만 언어의 문법을 익히는 것만으로는 50점입니다. 그와 쌍벽을 이루는 것은 환경이라 할 수 있습니다. 즉 OS, 프로토콜, 라이브러리 등을 말합니다. 실전에서 부딪히는 ‘디버깅의 문제’는 항상 언어와 환경에 대한 이해도를 동시에 측정하는 문제와 같습니다. 어느 하나만 물어보는 문제는 사실 그리 많지 않습니다. 문법을 익히는 것으로도 어려운 사람에게 OS와 원하는 환경에 대한 API들(소켓, DB, 멀티미디어, MAPI, IPC 등)을 익혀야 한다는 사실은 프로그래머의 길을 참으로 멀게 느껴지게 하는 요소가 됩니다. 하지만 진실을 알아야 제대로 길을 가겠지요.
    창을 제어하는 것을 만들 때, MFC를 사용할 것이냐와 볼랜드의 OWL을 사용하느냐는 ‘라이브러리’의 문제가 됩니다. 물론 컴파일러와 동반된 라이브러리라는 점에서는 ‘컴파일러’의 문제일 수도 있습니다만, 좀더 멀리 윈도우와 유닉스 계열에 동시 사용되는 Qt 라이브러리를 사용하느냐, 유닉스 상에서 사용되는 구식의 Motif 라이브러리를 사용할 것이냐 등의 문제까지 확장한다면, 이들을 사용할 때의 언어는 C/C++를 통해 비슷한 문법을 사용하지만 ‘사용되는 OS 플랫폼’과 ‘라이브러리’의 문제가 됩니다.
    중요한 것은 언급한 OS와 컴파일러 등에 따라 라이브러리를 선택해야만 하지만, 이들 라이브러리들이 만들어지게 된 동기나 UI 및 처리 방식 등은 비슷하다는 것입니다. OS, 컴파일러, 라이브러리는 상호 호환되지 않지만 시작과 끝은 비슷한 인터페이스와 구현 개념으로 시작해 C/C++라는 언어까지 비슷한 체계를 만들어 냅니다. 이런 유사성이 많은 라이브러리가 있다는 것은 알지만 그것을 공부하는 것은 어렵습니다. 그럴지라도 빠지지 말아야 할 오류 중의 하나는, 언어와 라이브러리의 정확한 경계를 파악하면서 배우자입니다.
    흔히 잘못 알려진 예를 들어보기로 합시다. 웹 프로그래밍을 할 때, ASP는 사실 IIS에서 CGI를 잘 구현하기 위한 몇 가지 객체에 대한 정의입니다. 그 객체는 언어와 독립된 존재입니다. 따라서 VBScript는 ASP와 분리되어 생각해야 하지요. JScript로 ASP를 작성할 수도 있습니다. 그러나 ‘ASP로 만들었어’라는 말에는 ‘VBScript로 만들었어’를 함축하여 사용하게 됩니다. 언어와 환경을 분리해 공부하는 학습을 해 두는 것이 앞서 언급한 언어와 환경을 동시에 묻는 ‘디버깅 문제’를 잘 해결하는 지름길입니다.

    언어와 표준 라이브러리
    주로 C, C++를 배울 때 나오는 문제입니다. 사실 C 언어와 동시대에 만들어지고 살아남은 언어는 없다고 해도 과언이 아닐 정도입니다. C 언어는 그간 많은 정제 작업을 거쳐 표준화되었고, 어떤 플랫폼이 새로 나올 때 어셈블리 다음으로 가장 먼저 포팅이 되는 언어라 할 수 있습니다. 그만큼 언어를 통해 이루어 놓은 재원이 풍부하다는 것이지요. 요즘에 새로이 나오는 언어들은 대개 표준화된 라이브러리를 포함해 배포가 됩니다. 하지만 C는 표준화 작업에서 ‘C 라이브러리’라는 표준화된 라이브러리를 선택하게 되었고, 컴파일러는 표준 C 라이브러리와 OS 자체 라이브러리를 동시에 배포하게 됩니다. 그러다보니 처음 언어를 배우는 사람들이 문법과 라이브러리의 차이를 알면서 배우기란 참 힘듭니다.
    for와 printf 예를 들어 봅시다. for는 C 언어를 이루는 구문이며, printf는 라이브러리 함수라는 큰 차이가 있습니다. 이러한 차이는 처리 관점에서 볼 때, for는 컴파일러가 해석해 루프 코드를 만들어 내고, printf에 대해서는 심볼을 찾아 호출(call)할 수 있는 방법으로 처리됩니다. 링커는 for에 대해서는 아무것도 하지 않으며, printf에 대해서는 외부 라이브러리에서 심볼을 찾아다가 점프 테이블을 갱신해 줍니다. printf는 C 언어 문법 명세에 있는 것이 아닙니다. 표준 라이브러리에 들어 있는 것입니다. sizeof는 함수인가요? 연산자입니다. 의심스러운 분은 찾아보기 바랍니다. printf의 구현은 DOS에서 다르고, MS 윈도우에서 다르고, 모바일 폰에서 다릅니다. 하지만 C 표준 문서는 printf의 선언에 대한 명확한 정의를 하고 있습니다. C가 놀라운 이식성을 가진다는 것은 플랫폼이 기본적으로 지원하는 언어이며, 지원시 표준 라이브러리 명세에 들어 있는 것을 해당 플랫폼에 맞게 구현해 놓았다는 것에 있습니다. 다시 한번 디버깅을 위한 기본 자세는 언어와 환경을 잘 구별하는 것에 있음을 강조하고 싶습니다.

    #구문과 함수
    #include
    int main( void ) /* main : 라이브러리 함수 */
    {
    int i;
    for( i=0; i<10; i++ )
    {
    printf("%d Hello, world? %d", i, sizeof( i ) );
    } /* printf : 라이브러리 함수, sizeof : 연산자) */
    return 0; /* return : 구문 */
    }

    요즘의 에디터는 함수와 구문/연산자에 대해 색깔을 다르게 표시해 줍니다. 구문 컬러링(syntax coloring)은 90년대 초반 볼랜드의 터보 C++ 이후로 프로그램 소스 에디터의 거의 필수적인 요소가 되어 있습니다. 유닉스에서도 Emacs와 vim을 쓰는 분들도 구문 컬러링을 위해 컬러가 지원되는 터미널 혹은 GUI 버전을 사용하는 것이 추세입니다. <리스트 1>을 보면, 구문 컬러링이 main에 대해서는 printf와 같이 하는 것을 보기도 할 텐데요. main은 단순한 콜백 함수일 뿐입니다. C 언어는 C start-up object가 있어서 OS에서 프로세스를 실행할 때 초기화하는 코드가 먼저 불려지고, 이 코드는 main이라는 외부 함수를 호출하게 되어 있습니다. 따라서 외부 프로그램은 항상 main부터 시작하게 되는 것이지요. 이것은 링커의 규약이 아니며, 단지 C start-up object에서 그것을 요구하기 때문일 뿐입니다. 링커는 C 언어와는 상관없이 객체간에 undefined symbol에 대해 다른 라이브러리나 객체에서 익스포트 심볼을 찾아 채워주는 일을 합니다.
    <리스트 1>에서 sizeof를 잘 살펴보면, sizeof가 함수일 경우 그것은 링커에 의해 관심 대상이 될 것입니다. 하지만 sizeof는 컴파일 타임에서 그 값이 결정되는 단항 연산자이며, 결과는 상수입니다. 즉, 프로그램 중간에 바뀌지 않는다는 것이지요.


      OS 편애 금지
    모름지기 프로그래머라면, 플랫폼과 언어 선택에 있어서 운신의 폭을 좁히는 것은 ‘깊은 이해’를 끊는 것이나 다름없습니다. 종종 비아냥 투로 이런 류의 얘기를 많이 듣습니다. “앞으로도 성공할 리 없는 리눅스는 관심 없어. 난 윈도우 프로그래머니까”, “툭하면 파란 화면 뜨는 것이 OS냐?” 이런 플랫폼 고착적인 자세는 프로그래머로서의 도리가 아닙니다. 프로그래머는 플랫폼을 가리지 말고 도전할 때 운용체계와 라이브러리에 대한 깊은 이해가 생기게 됩니다. 모든 OS는 나름대로의 위치가 있습니다. 나름대로의 노하우를 정리해 두는 것이 앞으로 30년 뒤에 나올 OS도 문제없이 프로그래밍할 대상이 될 수 있을 것입니다. 앞으로 20년 뒤, “내가 xxx와 yyy에서 20년간 프로그래밍을 해보니 yyy는 OS로서는 미흡하다”는 말을 할 수 있기를 바랍니다. 디버깅을 잘하는 사람의 특징은 플랫폼과 라이브러리에 대한 겸손과 섬세한 이해라고나 해야 할까요?

    API에 대한 경외심과 답답함
    디버깅 초보가 겪는 문제 중의 하나는 널리 알려진 API에 대한 경외심 혹은 그 반대의 답답함에 있습니다. API라는 것은 말 그대로 Application Programming Interface입니다. 블랙박스를 사용하는 방법에 대한 문서라고 할 수 있습니다. API에 대한 경외심은 개발자가 잘 모르는 영역에 대한 API인 경우가 많습니다. 파일 시스템 핸들링 API, 윈도우 메시지 API, 프로세스간 데이터 교환을 돕기 위한 IPC 등 시스템 레벨인 경우에는 안정적일 것이라는 막연한 생각에서 인정하고 사용합니다.
    반면, 답답함은 버그가 발생하고, 문제가 없을 듯해 보이는 방법이 전혀 해결될 기미가 보이지 않을 때 일단 자신의 실력을 의심하다가 나중에는 API에 버그가 있을지도 모른다는 생각을 하게 됩니다. 특히, 최근에 나온 소프트웨어에 대한 것일수록 그런 의심을 하게 됩니다. “소스를 알면 쉽게 디버깅을 할 텐데”라는 체념은 많은 프로그래머의 공통적인 경험입니다만 대개의 공인된 API에 대한 답답함은 소스를 모르는 것에 있지 않고, 제대로 되어 있지 않은 샘플 없는 문서에 있을 것입니다. 이런 경우는 어쩔 수 없이 사용자 포럼의 도움 혹은 검색 엔진을 통한 도움을 받아야 합니다. 모르는 것일수록 샘플을 수집해 사용 예를 구해야 하고, 충분한 샘플 이해 없이 빈약한 문서만으로 시간을 낭비하지 맙시다.
    API는 처음부터 완벽하지 않습니다. 그리고 문서도 완벽하지 않습니다. 아직도 수많은 OS의 Undocumented API들이 존재합니다. 어떤 경우든지(문서가 없든지, 문서를 이해 못했든지) API를 자신이 만든 수준으로 이해하지 못한 경우에는 버그가 존재하기 마련입니다.

    몇 가지 바른 생활 - 딴짓하는 프로그래머
    디버깅은 종합 예술 행위입니다. 전체적이고 섬세한 감각을 소유하지 않으면, 디버깅의 깊이가 그만큼 줄어들게 됩니다. 메모, 프로세스, 파일 I/O, 소켓, UI 등등 체계적인 지식이 없이는 해결되지 않는 경우가 많습니다. 소켓 문제인줄 알고 소켓 관련된 책만 읽다가 나중에는 쓰레드 문제로 판명되는 경우도 있습니다. 대개 디버깅은 의외로 사소한 것을 많이 알고 있을 때 쉽게 해결됩니다. 디버깅을 잘하는 사람은 사소한 것을 꼼꼼히 알고 있는 사람입니다. 그런 면에서 업무 외에 재미로 하는 ‘프로그래밍 딴짓(?)’은 그 사람의 잠재적인 문제해결력을 증강시키는 효과가 있습니다. 이는 결코 측정될 수 없는 능력입니다.
    딴짓은 본디 체계가 없는 것이긴 하지만, 배움을 동기로 하는 딴짓은 딴짓 이상의 딴짓입니다. 다음은 필자가 해 보았거나, 쓸만한(?) 딴짓 목록을 적어 놓은 것입니다. 모두 유틸리티와 그것을 사용하는 스크립트입니다. 유명한 유틸리티와 스크립트 언어를 사용하는 것은 프로그래머의 자유도를 높여 줍니다.

    【유닉스 계열】
    1 접속 후 아무 일도 하지 않은 채 24시간 이상된 사용자 끊는 스크립트 작성해 보기 - w, awk, kill
    2 수시로 디스크의 사용량을 확인하여 80% 이상 되었을 때 자동으로 메일 보내기 - df, awk, mail
    3 스포츠 신문 만화를 긁어 친구들에게 메일로 보내기 - wget, perl, mail
    4 주기적으로 내 특정 디렉토리 전체를 다른 서버로 복사하기 - rsync

    【윈도우 계열】
    1 조카들이 바꿔 놓는 IE의 시작 페이지를 부팅 후 레지스트리에 원래대로 해 놓기 - VBScript
    2 회사내 로컬 IP - 호스트명 테이블 만들어 보기 - nbtstat, perl
    3 회사내 공유 폴더 리스트 만들어 보기 - net, perl
    * - 뒤는 사용됨 직한 유틸리티입니다

    사용자의 눈, 개발자의 눈
    여러분이 프로그래머라면 일반 사용자와 눈이 달라져야 합니다. 컴퓨터에서 일어나는 모든 세세한 일까지 호기심을 가지고, 나름대로의 추측을 가지고 있어야 하고, 나중에 문서를 통해 혹은 트레이서 등을 통해 추측을 확인해야 하고, 궁극적으로는 필요한 때에 정확히 재현할 수 있는 코드를 작성할 줄 알아야 합니다.
    디버깅은 전문 디버거로 알려진 도구들만의 전유물이 아닙니다. 디버깅은 정상으로 실행되는 프로그램에 대한 이해부터 시작합니다. 응답 시간이 길어지는 프로그램을 잘 살펴보면,
    으로 눌러 다른 화면에서 돌아올 때 창이 새로 그려지지 않는 경우가 있습니다. 또 자세히 보면 윈도우의 맨 가장자리 프레임은 항상 그려지게 됩니다. 왜 그럴까요? 일반 사용자의 관점과 달리 프로그래머의 관점에서 보면, 현재 응답을 기다리는 쓰레드가 내부 창을 그리는데 사용되는 것과 같다는 것을 추측할 수 있습니다. 윈도우 맨 가장자리를 다루는 쓰레드는 OS에 소속된 것이지, 응용 프로그램에 소속된 것이 아닐 것 같다는 생각도 해 볼 수 있습니다.
    조금 더 얘기하면, 디스플레이 등록정보에는 ‘마우스로 끄는 동안 창 내용 표시’ 같은 기능이 있습니다. 또, MSN Plus에서 제공하는 광고 창 감추기 기능이 있습니다. 이런 것들은 Spy++ 같은 윈도우 메시지 트레이서 기능을 이용해 평소에 눈여겨 두면, 알고 있는 지식과 구현된 기능에 대한 실 예를 통해 폭넓은 이해가 가능합니다. 유닉스의 경우 리눅스의 strace, 솔라리스의 truss, hpux의 tusc 등을 이용해 평소에 inetd 같은 데몬이 어떻게 돌아가는지(option -p) 알아 볼 수 있습니다. 이들은 실행중인 프로그램에 큰 영향을 주지 않으면서, 엿보기 기능을 이용해 구현을 짐작해 보는 것들입니다. API가 아무리 블랙박스처럼 보여도, 평소에 이런 류의 툴을 이용해 시스템 레벨의 입출력을 덤프해 보는 것만으로 API의 내부를 어느 정도 짐작해 볼 수 있습니다. 물론, 리버스 엔지니어링은 많은 소프트웨어에서 금지되어 있다는 사실도 염두에 두면서 들여다보기 바랍니다.

    재현 가능성
    으례 들을 수 있는 말이지만, 디버깅은 사건을 추적하는 형사가 하는 일과 같습니다. 크게 다른 것은 디버깅은 언제든지 같은 상황을 재현할 수 있는 데 있으며, 형사가 하는 일은 단 한 번의 사건에 국한되어 비슷한 상황을 연출하는 데 그 한계가 있다고 볼 수 있습니다. 우리로서는 참으로 다행이지 않을 수 없습니다. 수만 번 프로세스가 죽고, core dump, watson log 같은 시체만 남는다 해도 윤리적인 가책을 전혀 느끼지 않으니까요. 디버깅을 하는 사람들은 형사처럼 조심스럽게 그 프로세스의 시체들을 디버거를 통해 부검하겠지요.
    디버깅을 위한 전제 조건으로 ‘재현 가능성’을 생각해 보겠습니다. 누구한테 디버깅에 대한 조언을 구할 때에도 재현을 하기 위한 방법이 모호하고, 심지어 말을 들어 주는 사람도 증상을 유추하기 어렵다면 별 도움을 받을 수 없을 것입니다. 증상을 제대로 설명하지 않았는데도 답변을 바로 준다면, 그 사람은 아마 여러분의 그룹에서 경외의 대상일 것입니다. 비단, 프로그램뿐만 아니라 전화나 메신저를 통해 컴퓨터의 이상을 호소하는 사람에게 조차 처음 듣는 현상인 경우 그대로 재현할 수 있는 방법에 대해 들어야 올바른 답을 줄 수 있는 것입니다.
    또 다른 측면에서 다른 사람에게 설명하기 위해 재현하는 방법을 차근차근 설명하다가 해결책을 아는 경우가 종종 있습니다. 끝까지 설명하지 않았는데 말이죠. 이전까지는 문제의 현상에만 집중한 나머지 처음부터 생각을 하지 않았던 것입니다. 아니, 문제가 다른 부분에 있을 것이라고는 생각하지 않았던 것입니다. 그만큼 어떤 문제가 ‘재현 가능한지’에 대한 것과 ‘어떻게 재현할 수 있는지’에 대한 것은 디버깅을 위한 전제 조건이 됩니다.
    개발자와 QA가 분리되어 있는 개발 그룹의 경우, QA의 버그 리포트는 재현 순서에 대한 상세한 설명을 수반하게 됩니다. 또한, 고객 상담실이 운영되어 출시한 프로그램의 사용자 지원이 이뤄질 때도 버그 재현에 대한 상세한 문서가 먼저 선행 조건이 됩니다. ‘재현되지 않는 버그는 고칠 수 없습니다’ - 개발자가 좋아하는 문구입니다. 디버깅을 위한 다음과 같은 공동의 작업 환경이 있다면 훌륭한 팀이 됩니다.

    1 소스 버전 컨트롤 : Visual Source Safe, CVS, WinCVS, TortoiseCVS
    2 버그 게시판 혹은 회람용 문서 : 배포 버전 번호/테스트 수트, 방법/ 버그 재현 순서/개발자 의견/조치 이력
    3 잦은 배포 : 수시로 (2주 이내) 소스 묶음과 설치본을 QA에 넘깁니다

    코드 리뷰
    디버깅은 아니지만 꼼꼼한 관리자는 개발이 어느 정도 완료된 후 코드 리뷰(code review)를 하자고 합니다. 개발자로서는 참으로 쑥스러운 시간입니다. 한 사람당 한두 시간 정도 들어 발표하는 동안, 지켜보는 모든 사람은 인공지능 컴파일러가 되어 올려지는 모든 소스를 날카롭게 보게 됩니다. 코딩 규칙이나 명료하지 않는 부분, 주석 없는 것이 들키는 시간이지요. 이 컴파일러는 경고가 친절하지 않습니다. 간혹 인간성이 안 좋은 컴파일러로부터 심한 말도 듣게 됩니다. 한 사람 때문에 팀 전체 소스의 신뢰도를 떨어뜨릴 수 있기 때문이죠. 코드 리뷰는 여러 가지 이점이 있지만, 중요한 것은 준비하면서 코드를 다듬게 되며 발표 중에는 개발자조차 간과했던 버그를 발견하는 것입니다.
    리턴 값을 확인하지 않고 지나는 경로가 있는지, assert 조건이 있음에도 assert문이 빠져 있다든지, 배열에 대한 boundary 확인이 되지 않은 채 최대 인덱스를 넘어 사용하는 부분이 있다든지, 재현되지 않은 버그까지 발견할 수 있는 이점을 가져다 줍니다. 이런 내용은 다음에 다시 설명 드리겠습니다. 디버깅을 위?코드 리뷰는 다음과 같이 합니다.

    【발표하는 경우】
    1 구문 컬러링이 되어 있는 에디터를 통해 소스를 보여줍니다(ViewCVS를 사용할 경우 enscript 기능 추가)
    2 에디터에서 직접 수정하거나 메모장을 이용해 논의사항을 추후 반영합니다
    3 설계 문서를 간단히 준비해 보여줍니다
    4 설계상 가장 중요한 구조체/클래스에 대한 헤더를 먼저 소개합니다
    5 메인 루프, 즉 함수들을 호출하는 중심이 되는 함수를 먼저 소개합니다
    6 설계 문서를 번갈아 가며 구현되어 있는 함수를 보여줍니다.

    【듣는 경우】
    1 코딩 규칙을 살펴봅니다
    2 알고 있는 것과 반대되는 것, 특이한 구현 방식에 대해 질문합니다
    3 컴파일러가 그러하듯 질문 내용을 바로 질문해 토의가 일방적이지 않게 합니다
    4 추궁하여 당황하게 만들지 말고, 충분히 소개할 수 있는 편안한 자세를 만들어 줍니다

    코드 리뷰는 팀 내에서만 이뤄지는 것이 아닙니다. 오픈소스 진영에서는 발표하는 순간부터 코드 리뷰가 이뤄지고 있습니다. 소스에 대한 접근 권한이 있다는 것은 코드리뷰가 진행 중이라는 것이며, 개발자는 다른 사람의 리뷰 결과에 대해 겸손한 피드백을 해주어야 합니다. 코드 리뷰는 디버깅과 튼튼한 코드를 위한 가장 매력적이며 가장 확실한 방법입니다.

    남의 코드를 많이 보라
    소스는 마치 책과 같아 좋은 소스와 나쁜 소스에 대한 구별법이 없이는 잘못된 습관을 만들 수 있습니다. 디버깅하기 좋은 소스와 코딩하기 좋은 소스는 분명 구별됩니다. 어떤 것이 과연 디버깅하기 좋으냐에 대한 생각은 다를 수 있지만, 아무 소스나 보면서 그 소스에 대한 깔려 있는 생각을 읽을 수 없다면 습관이 잘못 들어 디버깅은 더 어려워질 수 있습니다. 그럴지라도 다른 사람의 소스를 많이 보십시오. 소스포지나 코드구루 등은 공개된 소스를 얻을 수 있는 좋은 사이트입니다. 특히, 팀으로 작업하는 프로젝트의 소스를 보십시오. 그 팀에서 코딩 가이드를 제시하고 있다면 더더욱 소스에 대한 질이 높아집니다.
    나중에 다른 API를 사용해 연동할 일이 생긴다면, 그 소스를 볼 수 있다는 것은 디버깅에 큰 도움을 주게 됩니다. 한 페이지의 매뉴얼보다는 한 페이지의 소스가 더 도움이 되는 법이지요. 소스에 대한 경험이 많을수록 소스에서 느끼는 부드러움과 안정감, 위태로움, 불안함에 대한 감각이 자라나게 됩니다. 다른 사람의 소스를 읽는다는 것은 그 사람과 대화하는 것입니다. 그렇게 되면 코딩 스타일이라는 굴레를 벗어나게 되는 것입니다. 감상하는 법을 아는 사람만이 예술 작품세계에 대한 평을 할 수 있는 것입니다. 감상하는 법을 아는 사람이 설계 패턴과 설계 철학을 읽을 수 있습니다. 그리고 자연스럽게 자신의 소스에 대한 섬세한 손질을 할 수 있습니다.

    표준 문서 숙지 - 네트워크 프로그래밍 디버깅
    네트워크 프로그래밍은 필수 요소로 프로토콜이라는 전송 규약이 수반됩니다. 디버깅의 1차 목표는 프로토콜에서 정의한 패킷들이 필드 규격에 맞게 전송되고 있는지를 확인하는 것입니다. 그리고 다음으로는 필드의 내용이 프로토콜에 맞게 제 값을 가지고 다니는지를 확인하는 것입니다. 이를 위해 수반되는 것은 TCP/IP에 대한 명세를 확실히 하는 것입니다. TCP/IP 기반 네트워크 프로그래밍에서는 필수적으로 MAC 어드레스에 대한 개념과 IP 어드레스, 네트워크 어드레스, 브로드캐스팅 어드레스, 넷마스크, 디폴트 루트에 대한 개념을 책을 보며 익히되 패킷을 캡처해 가면서 공부하는 것을 권합니다. 더미 허브(스위칭 허브가 아닙니다)를 사용하면 네트워크에서 돌아다니는 모든 패킷을 다 읽을 수 있으므로, 패킷 캡쳐 툴을 사용해 가만히 들여다 보는 것만으로 책안의 내용이 살아나게 됩니다. 이런 툴 하나 정도는 꼼꼼히 옵션 찾아가며 익힐 것을 권합니다. tcpdump를 권하며, 윈도우에서는 같은 류의 windump가 있습니다. 둘의 옵션이 비슷하므로 하나를 익히면 다른 것도 쉽게 사용할 수 있습니다. 그 외에 GUI로 제공하는 많은 툴이 있으므로 찾아서 익히기 바랍니다(검색어 packet capture, sniffing).
    인터넷 필수 기본 프로토콜에 대한 것은 문서를 익히는 것에서 패킷 캡처를 통한 확인, 그리고 많은 커맨드 라인 방식의 프로토콜(SMTP, HTTP, POP, NNTP, FTP)에 대해서는 telnet을 이용한 테스트까지 완전히 자기 것으로 만들어야 합니다. 물론 샘플을 구해 클라이언트를 만들어 본다면 더 없이 훌륭합니다. 네트워크 프로그래밍 개발자들이여, 문서를 읽어 용어를 아는 정도는 비개발자들도 하는 것입니다. 하물며 개발자는 패킷 캡처까지 하여 프로토콜 확인은 할 줄 알아야 합니다.

    깊은 프로그래밍을 위한 첫 발걸음
    소스를 많이 보면서 구체적인 점을 이야기하지 않았습니다만, 전반적으로 프로그래밍과 디버깅을 시작하는 사람들이 가져야 할 모습을 다루어 보았습니다. 시작부터 튼튼한 사람은 없습니다. 처음에는 버그를 잡았지만, 소 뒷걸음에 쥐를 잡은 듯이 넘어가는 일이 많습니다. 프로그래밍과 디버깅을 따로 뗄 수는 없는 것입니다. ‘코딩 끝 디버깅 시작’이라는 말같이 디버깅을 염두에 두지 않은 코딩은 그 깊이가 얕을 수밖에 없습니다. 프로그래밍이라는 작업은 만만치 않지만, 설계부터 코딩, 디버깅이 끝난 프로그램이 잘 돌아가는 것을 보는 것은 예술가적인 안목에서 참 흐뭇한 일입니다. 짧은 연재이지만, 뒤 이어지는 연재들을 같이 나누며 깊은 프로그래밍을 위한 발걸음을 차근차근 내딛어 봅시다.

    프로그래머는 그 고집만큼 습관이 고착되어 있습니다. 여러 습관 중에서 가장 강조하고 싶은 것은 다음과 같습니다.

    ◆ 코딩 규칙 준수
    ◆ 로그 API 먼저 작성하기
    ◆ Assert문 이용하기
    ◆ 자원 관리 철학 갖기
    ◆ 다중 if문 갖지 않기

    디버깅에 관한 코딩 습관
    코딩 규칙을 준수합시다

    개발이 시작되면 대개의 경우 팀으로 프로그래밍을 하게 되며, 설계가 끝나고 코딩에 들어가기 전에 항상 코딩 규칙을 만들게 됩니다. 혹은 회사에 전부터 정해진 코딩 규칙이 있다면 그것을 개발에 적용하게 됩니다. 이런 코딩 규칙은 통일을 기하기 위해 만들어집니다. 파일 명명법, 함수․변수 명명법, 괄호의 위치, 파일 주석, 함수 주석, 선언 주석, 들여쓰기 방법 등에 대한 것을 기술하며, 훈련이 잘 되어 있는 개발팀이라면 이런 코딩 방법에 대한 통일을 이루게 됩니다.

    코딩 규칙은 미래의 자신과 개발 중인 다른 사람과의 협업을 위해서는 반드시 지켜야만 하는 것입니다. 이런 코딩 규칙과 디버깅과의 상관 관계는 일부 코딩 규칙이 버그 발생을 예방하기 위해 만드는 것이 있다는 것입니다. 필자가 권하는 것은 컴파일러다운 관용의 자세를 가지라는 것입니다. 이 말은 아무렇게나 작성하라는 것이 아니라 코딩 규칙이 프로젝트가 바뀔 때마다 변할지라도 자신을 능동적으로 맞춰가라는 것입니다. 코딩 규칙 중에서 많은 프로젝트에서 사용하는 두 가지를 소개하겠습니다.

    ◆ 단일 실행문을 갖는 if, while, for문이라 할지라도 중괄호(‘{’, ‘}’)를 기입한다(<리스트 1>).
    <리스트 1>과 같은 규칙은 처음 작성할 때는 문제가 되지 않지만, if 안의 블럭에 실행문을 하나 더 추가할 일이 생길 경우 중괄호가 없는 예에서는 간혹 실수하여 if의 참, 거짓에 상관없이 다음에 실행되는 문장으로 인식될 수 있는 경우가 발생합니다. 특히 printf(“Check %s:%d”, __FILE__, __LINE__);과 같이 중간 중간 현재 진행되는 위치를 출력하려고 중요한 위치(함수 시작, 조건 판단, 함수 종료 등의 위치)에 마구 복사해 넣다 보면 <리스트 1>과 같은 경우가 흔히 발생합니다. 다른 예를 들어 보겠습니다.

    <리스트 1> 코딩 규칙 1
    규칙 준수 예 :
    if( pTemp != NULL ) {
    *pTemp = ‘x’;
    }

    규칙 미준수 예 :
    if( pTemp != NULL )
    *pTemp = ‘x’;


    ◆ ++, -- 연산자는 함수 호출 인자 내에 쓰지 않고 호출 앞 혹은 뒤에 따로 쓴다(<리스트 2>).
    -- 위치에 따라 ‘사용 후 감소’ 또는 ‘감소 후 사용’이라는 모호성과 함수 호출시 인자로 넘어갈 값을 결정하는 순서(<리스트 2>에서는 두 번째 인자 --count와 세 번째 인자 score[count])가 섞이면 상당히 골치 아픈 일이 발생합니다. <리스트 2>에서는 count가 printf에 넘어간 뒤 --가 수행될지, --가 먼저 되고 printf에 넘어갈 지에 대해 생각을 합니다. 또한 printf 함수에 넣기 위해 --count를 먼저 할지, score[count]를 먼저 계산할 지에 따라 score 배열의 인덱스가 달라지는 문제가 발생합니다. --에 대한 것은 책을 찾아 명확하다고 할지라도 함수에 넘길 인자의 정확한 값을 구하기 위한 순서는 컴파일러마다 다를 수 있습니다. 모든 컴파일러가 같다고 할지라도 가독성을 떨어뜨리므로 좋은 코딩이라고 볼 수 없습니다.

    <리스트 2> 코딩 규칙 2
    --count;
    printf( “Last index %d, Last value: %d”, count, score[count] );

    규칙 미준수 예 :
    printf( “Last Index: %d, Last value: %d”, --count, score[count] );

    간단히 코딩 규칙의 예 중에서 버그 방지를 위한 것들로 자주 사용되는 것을 살펴봤습니다. 디버깅과 상관없이 코딩 규칙에 대해 말하자면, 코딩 규칙을 따르지 않고 자신만의 습관을 사용하는 것은 프로다운 모습이 아닙니다. 오히려 전문가는 프로그램 설계, 즉 구조에 중점을 두어야 합니다. 자신만의 코딩 규칙보다는 팀의 규칙을 따르는 것이 도움을 주고받을 때에도 시간을 단축할 수 있습니다. 필자가 속한 그룹에서는 들여쓰기와 괄호 위치, 선언 위치 등에 대한 것을 정해 놓고, code beautifier(GNU indent)를 사용하여 표준을 따르도록 고쳐주는 옵션을 정한 뒤 팀원들이 공유하여 코딩 규칙 일부에 대해 자동화합니다.

    Log API 만들기
    사실 printf를 디버거라고 부르기에는 적당하지 않습니다. 디버깅을 위한 값을 추적하는 방법에 불과하기 때문이지요. 여기서는 printf로 대표되는 ‘실행 중 값 출력’에 대해 말하고자 합니다. 프로그램을 시작하는 모든 사람이 오류가 발생하면 관심 있는 변수의 추이를 보고 싶어하고, 그런 변수가 실행 중 어떻게 변하는지를 살펴보는 것으로 처음 디버깅을 경험하게 됩니다. 이 방법이 정형화된 것이 바로 다단계 로그입니다. 잘 되어 있는 프로그램은 로그의 단계를 조절할 수 있는 기능(최소한 남길지 말지에 대한 기능)이 있어서 사용자가 종류별, 단계별로 로그를 원하는 파일에 심지어 원하는 포맷으로 남길 수 있습니다. 프로그래밍을 할 때 처음 구현을 위해 남기는 로그를 printf로 남기다가 나중에는 모조리 지웁니다. 왜냐하면 주로 이런 모습이기 때문입니다.

    !!!! temp file name: gHie88009.dat
    -------------- CHECK 1
    ---------------CHECK 2
    client: 192.168.10.1 2890 8 17:30:13

    아무 의미 없어 보이지만 실제 구현되기 전까지 만든 사람에게는 중요한 정보가 됩니다. 구현되고 나면 당연히 주석 처리가 되거나 삭제되는 코드입니다. 체계적인 로그 관리는 참으로 중요합니다. 나중에 문제가 생길 경우, 심지어 고객에게 배포된 것에 문제가 생길 경우에는 로그를 보내주고, 그 로그를 받아오면 좋은 경우가 많기 때문입니다. 앞과 같은 로그를 남기는 데 그냥 줄 수 있습니까? 애만 태우게 됩니다. 앞과 같은 로그 대신 프로젝트가 사용할 로그 API를 이용합니다. 이때 최상위 레벨, 즉 가장 자세한 상황으로 로그를 남기는 옵션일 때만 남기도록 함수를 하나 만들어 앞 로그를 보기 좋게 수정하여 코드에 넣는다면, 나중에 로그를 자세히 남길 필요가 있을 때(팀장의 협박하에 또는 고객지원을 위해)에도 많은 수고를 덜 수 있게 됩니다.
    윈도우 프로그래머들은 TRACE와 TRACEn(n은 string을 제외한 인자의 개수)으로 대표되는 디버그 모드 추적기가 있습니다. 이 경우에 있어서도 될 수 있으면 팀에서 로그 포맷을 정하고, 개발 후에도 삭제하지 않고 유용한 정보로 사용하는 것이 좋습니다.

     

    시공 감리 assert
    ‘assert를 잘 쓰면 기본은 뗐다’고 칭찬해 줄 정도입니다. 잘 쓴다는 얘기는 남발한다는 것이 아니라 필요한 부분에는 꼭 쓰고, 쓰지 말아야 할 곳에는 안 쓴 코드를 말합니다. assert는 중요하지만 많은 실전 경험 없이는 기술이 완성되는 것이 아니므로 몇 가지 예를 들어 설명하겠습니다. assert 사용이야말로 버그를 줄일 수 있는 가장 중요한 습관입니다. assert는 #include 과 같은 헤더를 포함해야 쓸 수 있습니다. 사용 방법은 단지 assert( <평가식> );과 같은데, 함수 호출이 있으면 <평가식>은 0이 아닌 값, 즉 참 값을 가져야만 합니다.

    /* #define NDEBUG */
    #include

    #include

    int main()
    {
    int i = 0;
    assert( i );
    }

    앞과 같은 코드는 반드시 assert문에서 오류를 발생시킵니다. 오류에는 파일 이름과 행, 그리고 어떤 값이 오류를 일으켰는지에 대한 정보를 보여주게 됩니다. 다시 앞 코드를 컴파일할 때 #define NDEBUG의 주석을 푼 뒤 실행하면 오류가 나지 않음을 알 수 있습求? NDEBUG라는 매크로가 선언되어 있으면 모든 assert문은 빈 명령어가 되는 것입니다. assert.h는 ANSI C 표준에 들어 있으므로 ANSI C를 지원하는 라이브러리가 있다면 어떤 플랫폼에서도 사용할 수 있을 것입니다.

    ◆ assert는 내부 설계에 대하여 변수가 원하는 내용을 가지고 있는지 확인하는 데 사용한다(<리스트 3>).
    <리스트 3>을 보면 두 함수가 등장하는데, process 함수를 SMTP의 ‘서버 메시지 같은 꼴’의 응답 메시지를 처리하는 데 사용하는 함수라 생각해 봅시다. SMTP 결과 메시지의 간단한 모양은 다음과 같습니다.

    220 mail.test.com ESMTP Ready

    <리스트 3> SMTP 적용 예제
    const char szLastMessage[1024];
    void saveLastMessage ( const char * szMessage )
    {
    strncpy( szLastMessage, szMessage, sizeof( szLastMessage ) );
    /* sizeof는 배열 szLastMessage의 최대 크기 */
    }

    /* szLine은 <세 자리 숫자><공백><서버 메시지> */
    void process( const char * szLine )
    {
    int code;
    code = atoi( szLine );
    saveLastMessage( szLine + 4 );
    /* 이하 생략 */
    }

    SMTP에서는 세 자리의 응답 코드만 가지고 대부분 처리되지만, 사람이 읽을 수 있는 메시지는 한 칸의 공백을 두고 쓰게 되어 있습니다. 이 정도 규약이 있다고 가정합니다. process 함수는 내부에 saveLastMessage 함수를 부르고 있으며, saveLastMessage는 그 인자인 szMessage를 서버의 응답을 처리하는 데 맨 앞에 있는 함수가 아니라는 것을 알고 있습니다. 즉, saveLastMessage는 process라는 선처리 함수 뒤에서 작용하는 함수라는 설계가 반영된 것입니다. saveLastMessage는 적법한 메시지일 경우에 불리운다고 가정합니다. 이런 상황을 두고 적당한 assert 위치와 assert 내용을 살펴봅니다. 일단 함수에 들어오는 szLine, szMessage에 대한 상황을 생각해 봅니다.

    saveLastMessage :
    ꊱ szMessage는 널 포인터가 아니어야 한다.
    ꊲ szMessage는 실제 내용이 있어야 한다.

    process :
    ꊱ szLine은 널 포인터가 아니어야 한다.
    ꊲ szLine[0], szLine[1], szLine[2]는 숫자이어야 한다.
    ꊳ szLine[3]은 공백이어야 한다.
    ꊴ szLine + 4 위치에 메시지가 들어 있어야 한다.

    <리스트 4> assert의 적용 예
    const char szLastMessage[1024];
    void saveLastMessage ( const char * szMessage )
    {
    assert( szMessage ); /* null pointer가 아님 */
    assert( szMessage[0] ); /* 실제 내용이 있음 */
    strncpy( szLastMessage, szMessage, sizeof( szLastMessage ) );
    /* sizeof는 배열 szLastMessage의 최대 크기 */
    }

    /* szLine은 <세 자리 숫자><공백><서버 메시지> */
    void process( const char * szLine )
    {
    int code;
    assert( szLine ); /* null pointer 아님 */
    assert( isdigit(szLine[0]) && isdigit(szLine[1]) && isdigit(szLine[2]) ); /* 세 자리 숫자 */
    assert( szLine[3] == ‘ ’ ); /* 공백 */
    assert( szLine[4] ); /* 실제 메시지 내용 있음 */
    code = atoi( szLine );
    saveLastMessage( szLine + 4 );
    /* 이하 생략 */
    }

    앞과 같은 사항을 반영한 assert가 들어간 코드는 <리스트 4>와 같습니다. assert와 관련해 szLine과 szMessage의 가장 큰 차이는 설계상 szLine은 처음으로 서버 즉 외부의 데이터를 받는 부분이고, szMessage는 한번 걸러진 변수라는 것입니다. szLine이 준수해야 하는 규칙은 서버에서 응답을 이상하게 준다면 충분히 깨질 수 있는 상황이며, 그런 상황이 벌어진다면 saveLastMessage는 적법한 경우가 아니므로 불리우지 않는 것이 설계의 내용입니다. 그러므로 적법한 상황에서 불리우는 szMessage는 알 수 없는 오류가 있지 않는 한 널 포인터일리 없고 내용이 없을리도 없습니다. 따라서 설계상 서버의 오작동과 같이 외부 입력에 대한 것을 처리할 수 있는 것은 assert로 하는 것이 아니라 if문으로 처리하여 적절한 오류 처리 루틴을 따라야 합니다. 외부 데이터의 변화와 상관없는 assert를 정리하면 다음과 같습니다.

    saveLastMessage :
    ꊱ szMessage는 널 포인터가 아니어야 한다.
    ꊲ szMessage는 실제 내용이 있어야 한다(szMessage 길이가 0으로 saveLastMessage 함수가 호출되지는 않는다).

    process :
    ꊱ szLine은 널 포인터가 아니어야 한다.
    ꊲ szLine에 코드를 비롯한 메시지가 들어 있어야 한다(szLine 길이가 0으로 process 함수가 호출되지는 않는다).

    <리스트 5> 설계와 구현에 대한 assert의 용법
    void process( const char * szLine )
    {
    int code;
    assert( szLine ); /* null pointer 아님 */
    assert( szLine[0] );
    /* 서버의 응답의 적합성을 파악하기 위해서는 길이가 적어도 4바이트가 되어야
    배열 참조 인덱스가 유효하므로 먼저 길이 조사를 한다. */
    if( strlen( szLine ) < 5 ||
    ! (isdigit(szLine[0]) && isdigit(szLine[1]) && isdigit(szLine[2]) ) ||
    szLine[3] != ' ' )
    {
    /* 오류 로그 */
    return;
    }
    code = atoi( szLine );
    saveLastMessage( szLine + 4 );
    /* 이하 생략 */
    }

    <리스트 5>는 설계와 구현에 대한 assert의 용법에 대해 알아 본 것입니다. 함수의 모든 인자에 대한 것은 함수 첫 부분에서 assert를 해줘야 합니다. 클래스 멤버 함수에 관해서는 인자뿐 아니라 사용하는 멤버 변수에 대한 것도 포함됩니다. 다음은 대표적인 assert문이 사용되는 방법입니다.

    ꊱ 포인터의 경우 널인지 여부
    ꊲ 일반 변수의 설계상의 범위 혹은 정확한 값 준수 여부
    ꊳ 다중 if, else if, switch 등의 복잡한 판단 후 처리에 대한 결과 확인



    assert를 사용하지 말아야 할 대표적인 곳은 다음과 같습니다.

    ꊱ 외부 데이터 입력 변수
    ꊲ 메모리 할당 결과
    ꊳ 파일 열기 결과

    이번에는 assert를 프로그래밍 습관보다는 디버깅에 사용하는 방법을 생각해 봅시다. 디버깅할 때 심지어는 멤버 함수의 경우 this 포인터가 널이 아닌지 확인해야 하는 경우도 있습니다. 버그가 발견되었을 때 문제가 발생한 곳을 중심으로 의심가는 곳에 assert를 심어 넣습니다. 이 경우에는 assert가 오류를 내고 프로그램이 멈추어야 되므로, 앞에서 사용하지 말아야 할 상황까지 일부러 넣어가면서 프로그램을 임시로 지저분하게 가져가야 합니다. 사용하지 말아야 할 곳에 넣은 assert는 나중에 다시 빼야 하므로 아예 들여쓰기를 하지 않고 넣는 것도 한 방법일 것입니다. 이 방법은 if로 조건을 벗어나는 것에 대한 로그를 남기는 것보다 확실합니다.
    정리하면 assert는 설계의 흐름을 제대로 구현하고 있는가에 대한 감리 역할을 하고 있는 것입니다. 많은 경우 아주 가끔씩 일어나는 에러의 경우에도 assert를 충분히 해주었다면 쉽게 잡을 수 있는 경우가 있습니다.

     

     

    자원 미제거에 대한 방어
    자원 할당 또는 제거라 함은 메모리 할당, 파일 open, socket accept, close 등을 말합니다. 시스템 자원(메모리 포함)의 새는 것(resource leakage)에 대한 추적은 디버깅의 어떤 언어든 끝없는 주제일 것입니다. 메모리 및 시스템 자원에 대한 것은 다음과 같은 설계 철학을 공유하지 않으면 체계적이 될 수 없습니다. 물론 메모리 새는 것과 자원 새는 것들에 대해 추적할 수 있는 도구 혹은 추적 가능하게 해주는 라이브러리를 사용하는 방법이 있습니다만, 소스 수준에서 올바를 습관을 기르면 디버깅에 도움이 될 수 있기에 정리해 봅니다.

    ꊱ 자원 할당과 제거를 동일한 계층에서 일어나도록 한다.
    ꊲ 자원 할당과 제거를 논리적으로 시작 모듈과 끝 모듈에 맞추어 일어나도록 한다.

    둘은 서로 반대되는 얘기입니다만 일종의 패턴이라고 생각하면 됩니다. 첫 번째, 자원 할당과 제거가 동일한 계층에서 일어난다는 것은 같은 모듈 내에 생성 소멸을 두라는 이야기입니다. 다른 말로 하면, 가능하면 자원을 할당한 함수에서 해제하라는 것입니다. 또는 그것이 불가능할 경우 같은 클래스 안에서 혹은 동일한 파일 내에서 할당, 제거할 수 있는 설계 방법을 취하는 것입니다. 두 번째는 생성되는 곳과 소멸되는 곳을 특정한 두 개 정도의 함수, 클래스 혹은 파일로 모으라는 것입니다. 만약 쓰레드 등을 써서 소켓을 accept하는 곳과 close하는 곳이 분리돼야만 한다면, 여러 곳에서 close하지 말고 모아두라는 것입니다. 즉, 프로세스의 시작과 끝이 다른 경우에는 자원이 생성되고 소멸되는 위치가 되도록 모여 있도록 하라는 것입니다.

    이런 패턴을 따르지 않을 경우 할당 제거에 대한 명확한 문서화가 되어 있어야 합니다. 되도록 그런 문서를 만들지 않아도 알기 쉽게 앞 패턴을 따르는 것이 좋습니다. 다음은 같은 함수 내에서 제거하는 모습입니다. 나쁜 예는 process 함수 내의 주석 처리한 부분에서 pConfig 객체가 소멸되는 것입니다.

    void process( Config * pConfig )
    {
    /*... 처리 ... */
    /* delete pConfig; */
    }

    void run()
    {
    Config * pConfig = new Config("/etc/test.cfg"); /* pConfig가 널인지 확인하는 코드 생략 */
    process( pConfig );
    delete pConfig;
    }

    ◆ 자원 할당 전에 변수가 비어 있는지 확인해야 한다.
    ◆ 자원 제거 후에는 변수를 초기 값으로 환원시켜야 한다.

    자원 할당 전에 할당한 자원을 받을 변수에 어떤 의미있는 내용이 있는지 확인해야 합니다. 또한 자원 제거 후에는 반드시 그 변수를 초기 값으로 환원시켜서 다음에 해제되었는지를 확실히 해야 합니다.

    /* 메모리 할당, 해제 전에 확인 */
    if( pBuffer )
    {
    free( pBuffer );
    }
    pBuffer = (char *) malloc( BUFFER_SIZE );
    /* 처리 */
    if( pBuffer )
    {
    free( pBuffer );
    pBuffer = NULL;
    }

    /* 파일 닫은 후에 초기화 */
    if( fd >= 0 )
    {
    close( fd );
    fd = -1;
    }

    좀더 상세하게 살펴봅시다. 다음의 예에서 g_pBuffer와 fd 변수는 초기 값으로 각각 NULL, -1을 가지고 있다고 합시다.

    /* --- MEMORY --- */
    if( !g_pBuffer ) {
    g_pBuffer = (char &) malloc( BUFFER_SIZE );
    }
    /* --- FILE --- */
    if( fd >= 0 ) {
    if( !close(fd) ) {
    printf(“close error”);
    }
    }
    fd = open( “/tmp/log.txt”, O_RDONLY );

    이제 자원 할당 문제와 assert를 이용한 디버깅을 알아봅시다. 앞의 예에서 g_pBuffer 값이 논리적으로 프로그램 흐름상 g_pBuffer는 초기화되어 있어야 한다면, 또한 open하기 전에 fd 값이 초기 값(-1)을 유지할 수밖에 없다는 것이 확실하다면, 즉 모든 실행 경로에서 if 조건들이 결코 참이 될 수 없다면 if 위에 assert를 넣어 다음과 같이 만들어 자신의 논리를 굳히는 프로그래밍을 할 수 있어야 합니다. 문법 오류를 컴파일러가 잡아내듯 논리 오류를 잡아내는 데 사용됩니다. 실행 도중 논리적인 설계 외의 행위가 발생한다면 그것은 디버깅감입니다.

    assert( g_pBuffer );
    assert( fd < 0 );

    ◆ 불필요하게 파일 기술자를 두 번 이상 close하지 않는다.
    뭔가 확실히 해두려고 두 번 이상 파일 기술자를 닫는 경우가 있습니다. 이 경우 두 번째의 close는 당연히 닫힌 파일에 대한 close이므로 오류를 일으키며, 일반적으로 프로그래머는 close의 오류 확인을 하지 않는 경우가 많습니다. 물론 앞의 습관이 제대로 들어 close 후에 -1(혹은 Handle의 경우 NULL)로 초기화하고, close할 때는 반드시 0보다 크거나 같은지에 대해 확인을 하겠지만 그것은 안전을 위한 방법이며, 그것보다 먼저 점검할 것은 생각할 수 있는 모든 경로에서 정확히 close를 한번만 하는지 확인하는 것입니다. close 전에 assert를 넣어 두 번 close를 하는지 점검해 보는 것도 좋습니다.

    다중 if문 피하기
    다중 if를 최대한 줄일 수 있도록 만들면 그만큼 가독성을 높게 합니다. <리스트 5>와 <리스트 6>을 비교하면 처음 만나는 if문을 바꿔 씀으로써 이중 if문을 단일 if로 바꾸었습니다. <리스트 5>와 같은 코드는 나름대로 정상적인 흐름을 머릿속에 생각하고 정상적인 것만 처리하는 데 집중하여 나온 것입니다. 습관을 바꾸면 비정상적인 것을 먼저 판단하되 비정상적인 것이 잘 일어나지 않는 상황입니다.

    게다가 로그를 남겨야 하는 등 많은 일을 처리해야 한다면 assert문을 넣어 그 함수 안에 들어오면 반드시 오류가 나도록 처리해 두고, 그 아래에 계속 생각의 흐름을 진행시키는 방향으로 코드를 작성하는 것이 좋습니다. 이런 습관은 일석이조의 효과를 거두게 됩니다. 코드의 가독성을 높이고, 구현을 미루어 놓아도 나중에 까먹지 않게 되지요. 다음과 같은 방법으로 간단히 처리하고 나중에 assert에 걸릴 때 적절한 코드를 넣어도 정상적인 것을 우선 작성하는 데 큰 어려움이 없을 것입니다.

    if( 0 >= (size=recv( s, buf, 1024, 0 )) ) {
    assert( 0 && “TODO: You should process error”);
    }

    <리스트 5> if문 사용 예 1
    int check( s )
    {
    char buf[1024];
    int size = 0;
    if( 0 < (size=recv( s, buf, 1024, 0 )) ) {
    buf[size] = ‘\0’;
    if( ‘2’ == buf[0] ) {
    return 0;
    }
    return 1;
    }
    printf(“Socket closed.”);
    return -1;
    }

    <리스트 6> if문 사용 예 2
    int check( s )
    {
    char buf[1024];
    int size = 0;
    if( 0 >= (size=recv( s, buf, 1024, 0 )) ) {
    printf(“Socket closed.”);
    return -1;
    }
    buf[size] = ‘\0’;
    if( '2' == buf[0] ) {
    return 0;
    }
    return 1;
    }

    빌드 과정 파악하기
    지난 호에서 필자는 ‘언어와 환경’이라는 주제로 언어 명세와 라이브러리를 분리할 줄 알아야 한다고 했습니다. 이번에는 언어와 라이브러리를 조작하는, 흔히 말하는 컴파일러를 분석해 보겠습니다. 범용성을 가진 언어로서 C/C++는 그만큼 많은 제작사가 있으며, 많은 컴파일러를 만들어 내놓았습니다. 그 중에는 상용도 있으며(비주얼 C++, 볼랜드 C++ 빌더, 솔라리스 cc 등), 상용에 못지 않은 공개용(gcc/g++, djgpp, mingw)도 있고, 상용이었다가 이제는 공개용(터보 C)으로 된 것도 있습니다. 이들은 일부는 순수한 컴파일러만을 가지고 있으며, 일부는 어셈블러와 링커까지 포함된 것도 있습니다. 또 어떤 것은 통합 환경을 제시하는 것도 있고, 어떤 것은 커맨드라인 실행만을 지원합니다.

    이런 구분을 잘 이해하려면 어떤 언어든지 다음을 이해해야 합니다. 디버깅이 어려운 것은 이런 구분 없이 오류를 해결하려고 하기 때문입니다. 한 번의 빌드 중에는 다음과 같은 일이 발생합니다. 간단한 명령 하나를 내리는 것 같지만 소스는 전처리 과정을 거쳐 컴파일러에 들어가고, 컴파일되어 나온 어셈블 코드 혹은 메타 언어 코드는 어셈블러를 통해 목적 파일이 생기고, 여러 목적 파일들을 합하여 하나의 실행 파일을 만들게 됩니다. 이 과정에서 중간에 에러 메시지가 나오게 됩니다. 그 에러 메시지가 다음 중 어떤 과정에서 일어나는지를 이해하는 것이 디버깅을 돕는 빠른 길입니다.

    ꊱ 전처리기(pre-processor)
    ꊲ 컴파일러(compiler)
    ꊳ 어셈블러(assembler)
    ꊴ 링커(linker)

    이런 구분을 몇 가지 에러 메시지를 통해 이해해 봅시다. 전처리기와 링커의 경우를 살펴보겠습니다. 처음에는 숨겨 있기 때문에 바로 알 수 없는 경우가 많거든요.



    <그림 1> 빌드 순서 개요




    전처리기
    전처리기, 즉 컴파일러에 들어가기 전에 처리하는 대표적인 것은 다음과 같습니다.

    ꊱ #ifdef/#else
    ꊲ #define
    ꊳ #include

    컴파일러는 사실 앞의 구문을 이해하지 못합니다. 앞의 내용을 바탕으로 컴파일러에 소스 중 일부만을 넘긴다거나 치환하여 넘긴다거나 다른 소스를 포함시켜 넘기는 것입니다. 컴파일러가 보는 것은 앞에서 제외한 모든 것이라 보면 되겠습니다. 그 중 유의할 것은 ꊱ typedef, ꊲ #pragma이지요. typdef와 #define을 많이 비교하는데, 사실은 처리하는 위치가 다르다는 것을 기억해 두기 바랍니다. pragma는 표준화된 것이 아니므로 컴파일러마다 다르다는 것을 이해해야 합니다. 따라서 컴파일러마다 공통적인 것이 아니라면 #pragma 앞뒤로 컴파일러 특유의 매크로가 정의되었는지 확인하는 #ifdef가 오게 됩니다. 다음은 C++ 코드입니다.

    /* filename: a.cpp */
    int main()
    {
    printf("Hello, world\n");
    return 0;
    }

    앞의 코드를 컴파일하면 제대로 되지 않습니다. “implicit declaration of function ‘int printf(...)’”라는 오류를 내면서 멈추게 됩니다. printf를 암묵적으로 선언하여 사용했기 때문이죠. 즉, 정확한 선언 없이 사용했다는 것입니다. 이것이 C라면 암묵적인 선언도 무사하겠지만, C++라면 반드시 선언해야만 함수를 사용할 수 있으므로 컴파일이 더 이상 진행되지 않습니다. 선언을 제대로 하는 것은 어떻게 보면 컴파일러 문제겠지만, 우리는 여기에서 #include 라는 헤더 파일이 빠져 있음을 알 수 있습니다. stdio.h를 열어 보면, 어딘가 printf 함수의 원형이 선언되어 있음을 알 수 있을 것입니다. 이것은 전처리되어 앞에서 헤더가 포함되어 함수를 선언해 주는 것이 빠져 있기 때문에 생긴 것입니다. 물론 #include하지 않고 해당 printf를 앞에 그대로 복사해 놓아도 상관없습니다. 그것은 전처리기를 통하지 않고 컴파일러 안에서 처리한 것이죠.
      


     전처리의 특성 파악
    전처리의 특성을 알면 유용할 때가 많습니다. 컴파일 옵션 중에 미리 선언한 값을 넘기는 경우가 있습니다. 그 값에 따라 #ifdef를 만나면 소스의 특정 부분을 선택적으로 컴파일할 수 있게 되지요. 디버깅을 하다 보면, 헤더 파일을 열었을 때 두 가지 선택 중 어떤 것이 선택되었을까 궁금할 때가 있게 됩니다. 이런 경우를 대비해서라도 컴파일 전에 들어가는, 즉 전처리된 소스를 한번 보기로 합시다.
    앞 코드를 #include 를 넣어 제대로 돌아가도록 한 뒤 전처리기만 통과하여 나온 소스를 보기로 합시다. 유닉스용 g++의 경우 -E 옵션을 넣어 주면(g++ -E a.cpp) 컴파일러에 들어가기 전의 코드를 구할 수 있습니다.

    비주얼 C++ 6.0의 경우 도스 명령 창을 실행하여 해당 소스가 있는 곳으로 이동한 뒤 cl /E a.cpp라고 명령을 내려주면 됩니다. cl 명령이 실행되지 않을 경우 경로가 잡혀 있지 않기 때문입니다. cl은 Visual Studio\VC98\Bin 디렉토리에 있습니다. cl이 바로 gcc/g++과 같은 역할을 하는 컴파일러인 것이지요. 정확히 cl과 gcc/g++ 등은 컴파일러를 부르는 구동기입니다.

    링커
    링커는 컴파일되어 나온 목적 파일들을 서로 묶어 주는 역할을 하는 것입니다. 흔히 이런 오류를 많이 보게 됩니다. 다음은 앞의 소스에서 main을 test로 이름을 바꾼 것입니다. 즉, 전체 프로젝트에 main 함수가 없는 상황이죠.

    <리스트 7> a.cpp
    #include
    int test()
    {
    printf("h");
    return 0;
    }

    g++ a.cpp -o a
    /usr/lib/crt1.o: In function ‘_start’:
    /usr/lib/crt1.o(.text+0x18): undefined reference to ‘main’
    collect2: ld returned 1 exit status

    VC++
    Compiling...
    a.cpp
    Linking...
    LIBCD.lib(crt0.obj) : error LNK2001: unresolved external symbol _main
    Debug/a.exe : fatal error LNK1120: 1 unresolved externals
    Error executing link.exe.

    <리스트 7>을 보면 컴파일러가 다름에도 불구하고 undefined reference, undefined external symbol이라는 비슷한 오류가 발생했습니다. 그리고 g++에서는 crt1.o, VC에서는 crt0.obj이라는 것을 보게 됩니다. 마지막으로 g++에서는 collect2(ld)가, VC에서는 link.exe가 오류를 내는 것을 확인할 수 있습니다. 흔히 처음 이런 오류를 만났을 때에는 고민하다가 main 함수가 없어서 발생하는 것을 알게 되지요. 좀더 살펴보면 오류를 낸 것은 링커입니다.

    오류의 위치는 main이라는 reference 혹은 external symbol을 찾지 못했다는 것이고 공교롭게도 crt 뭔가를 처리하다가 발생했습니다. 즉, 컴파일은 모두 성공적으로 끝났으며, 그 다음 단계인 링킹에서 오류가 발생한 것이지요. VC의 경우 main 대신 _main을 찾다가 생긴 오류로 나오는데 이것은 윈도우에서 흔히 사용하는 오브젝트 내의 심볼 표현 방법으로 C언어에서 만든 함수 이름 앞에 “_”을 붙이기 때문입니다. 메시지의 정확한 의미는 C++ 컴파일러가 crt라는 런타임 오브젝트를 먼저 처리하며, 그 오브젝트 안에는 다른 프로그램 어딘가로부터 main 함수를 필요하도록 만들어져 있습니다. 따라서 그것을 연결시켜야 하는데 전체 프로젝트 안에 main이라는 오브제트가 없는데서 발생한 것입니다.

    비슷한 오류는 특정 라이브러리를 추가로 넣어줘야 하는데 빠졌을 경우 발생합니다. 윈도우 프로그램을 작성할 때는 소켓 라이브러리를 따로 프로젝트 환경 설정에 넣어야 하는 경우가 있고, 유닉스의 경우에도 수학 관련 함수를 사용할 때나 쓰레드 관련 프로그램을 할 때 항상 추가적인 라이브러리를 지시해 주어야 하는 경우가 있습니다. 모두 undefined reference, undefined external symbol 오류입니다. 이것은 컴파일러(어셈블러 포함)를 통과한 소스가 최종적으로 생성되어 나온 오브젝트는 내부에 포함된 함수와 외부로부터 필요한 함수가 어딘가에 기록되어 있다는 뜻입니다. 물론 변수도 그와 비슷하게 기록되어 있습니다. 이런 것을 알아보는 것이 부속 프로그램으로 따라 다니게 됩니다.

    디버깅을 잘하는 것에 대하여 지난 호에 덧붙이자면, 언어와 환경(라이브러리, OS, 프로토콜)을 구별할 줄 알아야하며, 현재 오류가 난 부분이 전처리에서 난 것인지 컴파일에서 난 것인지 링커에서 난 것인지 구별할 줄 알아야합니다.

    빌드 과정 정리
    전처리기를 통하여 나온 소스는 모든 변수와 함수는 사용 전에 선언되어 있어야 하며, 컴파일러 명세에 있는 문법구문이 아닌 것은 모두 typedef되어야 합니다. 따라서 전처리를 통과하여 나온 것에는 주석이 모두 제거되고, 모든 #define문이 치환되며, include된 것들은 통째로 하나의 파일로 되어 전달됩니다. 이 소스를 볼 수 있다면 여러분이 소스를 깊게 이해하고, 디버깅하는 데 도움이 될 것입니다.
    컴파일러를 통해 나온(정확히는 컴파일러와 어셈블러를 거쳐 나온) 목적 파일은 C 런타임 라이브러리(crt)와 결합하여 실행 파일을 만들게 됩니다. 따라서 모든 목적 파일과 crt에는 undefined symbol된 것이 어딘가에는 존재해야만 합니다. 만일 존재하지 않는다면 그것은 DLL(혹은 유닉스의 .so, .sl 등)과 같은 동적 연결 라이브러리 파일명을 적어 두고, 로더에 의해 실행 도중에 바인드되도록 만들어집니다.

    진정한 전문가의 자세
    지금까지의 설명을 토대로 생각하면 디버깅 과정은 빌드 과정을 깊이 이해하는 것처럼 보입니다. 사실 디버깅은 빌드 과정에 대한 이해는 기본으로 하고, 빌드 이후에 있는 어셈블리어를 통한 디버깅이나 System call trace를 통한 프로그램의 흐름을 상상하며 문제의 위치를 찾아내는 것을 주로 지칭합니다. 중요한 것은 시행착오를 남길 때마다 표면적인 문제가 해결된 것에 만족하지 않고, 내부 동작을 좀더 음미해가면서 문제를 해결하려는 태도입니다.

    따라서 디버깅은 고도의 좋은 의미의 해킹과도 관계 있는 것입니다. 더불어 좋은 코딩 습관은 고집스런 뭔가를 고수하는 것보다 보다 유연하게 팀 작업을 돕기 위한 모습으로 자신의 활동 범위를 넓히는 것이 좋습니다. 진정한 전문가는 코딩 스타일보다 아키텍처에 관한 얘기를 하는 것입니다. 아무쪼록 깊은 이해를 위해 좋은 습관과 컴파일러와 그 주위를 둘러싼 유틸리티와 친해지길 바라며, 세부 옵션도 수시로 확인하고 정교하게 도구들을 사용할 수 있는 여러분이 되길 바랍니다.

     어떻게 하면 버그를 빨리 발견할 수 있을 것인가? 이것은 모든 프로그래머의 공통적인 관심사입니다. 많은 언어들은 이것에 부응하기 위해서 문법적인 장치들을 고안해 넣었습니다. 문법에 그런 장치를 넣었다는 것은 컴파일러에 의해 사용자의 의도 중에 잘못될 소지가 있는 것을 지적할 수 있는 이점이 있습니다. C/C++가 어려운 이유는 변수 타입이 다양하며, 심지어 각 타입에 signed/unsigned가 추가되고, 포인터에는 const이냐 아니냐에 따라 생각해야 할 많은 성질들이 들어가기 때문입니다. 현존하는 언어 중에서 변수에 대한 가장 섬세(?)한 조절 기능이 있다고 해도 과언이 아닙니다. 어쨌든 버그를 컴파일 타임으로 끌어 올려 발견할 수 있도록 하는 것이 문법이 가지는 목적 중 하나이며, 그런 문법의 의도를 충분히 이해하고 언어를 사용할 수 있다면 컴파일러가 단순히 컴파일만을 목적으로 하는 것이 아니라 디버깅 툴(?)로도 사용될 수 있음을 알 수 있습니다. 세 가지 예를 들어 프로그래밍의 깊이를 조금 깊게 느껴보는 시간을 갖기로 하겠습니다.

    의도를 나타내는 ‘const’
    함수는 부르는 자와 불리는 자의 주고받는 행위입니다. 뭘 주고 뭘 받을지에 대한 것을 프로그래머가 의도한 대로 반영하게 되는데, 몇 가지 함수 call을 살펴보면서 이해해 봅시다.

    // 함수 선언과 포인터의 의미
    1 int strlen( const char * str );
    - str이 가리키는 내용을 건드리지 말라.
    2 void strncpy( char * buf, const char * source, int max );
    - source가 가리키는 내용을 건드리지 말라, buf가 가리키는 내용은 바꾸어도 좋다.
    3 int strcmp( const char * s1, const char * s2 );
    - s1, s2가 가리키는 것을 건드리지 말라.
    4 FILE * fopen( const char * filename, const char * mode );
    - fopen에서 넘어 오는 포인터가 가리키는 것을 맘대로 바꾸어도 좋다.
    5 int fclose( FILE * fstr );
    - fstr가 가리키는 내용은 바꾸어도 좋다.

    앞에 잘 알려진 표준 C 라이브러리의 문자열 처리와 파일 개폐 함수를 나열하였습니다. 선언된 내용 중 포인터에 대한 것을 말로 서술하여 표현해 보았는데, 자세히 읽어보고 원래 함수가 하는 일과 비교하여 이해하기 바랍니다. const가 꾸미는 내용은 변수가 아니라 변수가 가리키는 것의 속성입니다.

    // const 선언의 예
    6 const char * buf;
    7 char const * buf;
    8 char * const buf;
    9 const char * const buf; 또는 char const * const buf;

    1번의 의미는 “ *buf = ‘a’; ”와 같은 방법으로 가리키는 내용을 바꾸는 일로는 사용될 수 없다는 것입니다. 2번의 의미는 정확히 1번과 같습니다. 그것은 const가 “*” 앞에 있어 “*”를 수식하는 것이며, 변수가 가리키는 내용이 상수라는 것입니다.
    3번의 의미는 1, 2번과 다릅니다. 즉 가리키는 내용을 바꾸는, “ *buf = ‘a’; ”는 허용되지만 “ buf++ ” 같이 포인터 값 자체를 바꾸는 것은 허용되지 않습니다. 4번의 예는 앞 두 제약을 모두 가지고 있는 것이 됩니다. 이런 생각을 염두에 두고 앞 코드의 1번 문자열 길이를 구하는 함수 strlen이 내부적으로 할 수 있는 예를 생각해 봅시다. 그것은 포인터가 가리키는 값, *str 값이 ‘\0’인지 비교하면서 str++를 수행하며 원하는 결과를 구하게 될 것입니다.
    함수를 호출할 때 변수의 포인터를 원하는 함수가 있다고 가정합시다. 만약 그 함수가 const 포인터(const *)를 원한다면 호출 이후 그 내용이 전혀 변하지 않을 것이 확실합니다. 하지만 일반 포인터를 인자로 원한다면 설사 그 함수가 내용을 고치지 않는다고 문서에 씌어 있어도 사실상 보장할 수 없게 됩니다. 이것이 “프로그래머의 의도”입니다. 변하느냐 변하지 않느냐, API 상에 데이터 변형에 대한 의도를 나타내어 버그가 생길 수 있는 소지를 막는 것이 컴파일러가 디버깅을 도와 줄 수 있는 방법입니다. 다음은 const가 있어야 함에도 뺀 함수에 대해 컴파일러는 다음과 같은 오류를 반환하는 예입니다.

    #include

    void welcome( char * username ){
    printf(“Welcome! %s\n”, username );
    }
    int main() {
    const char * name = “hojin”;
    welcome( name );
    return 0;
    }
    결과 - compile warning:
    warning: passing arg 1 of `welcome’ discards qualifiers from pointer target type

    welcome은 분명 username이 가리키는 것을 사용만 할 뿐 바꾸는 것이 없습니다. 그런데도 선언이 const *가 아니므로, welcome 함수를 사용하기 위해 const를 무시하겠다는 내용입니다. 이것에 대한 해결책으로 name에 대한 const까지 없앤다구요? (char * name = “hojin”;) 제발 그러지 마세요. 꼬이는 지름길입니다.

    의도를 나타내는 ‘const’ 멤버 함수
    C++의 const 사용에 대한 예를 들기 전에 C++의 멤버 함수에 대해 살펴보기로 할까요. 모든 멤버 함수는 하나의 인자가 숨겨 있다고 생각해야 합니다. 바로 그 클래스에 대한 this 포인터입니다. 따라서 아무 것도 인자를 받지 않는 함수라 할지라도, 항상 this 포인터라는 최소 한 개의 인자를 받는다는 사실입니다. this 포인터가 모든 인자보다 먼저 전달된다고 생각합시다. 그러면, 이 this 포인터는 숨겨 있으므로 “const” 수식을 할 수 없지 않겠느냐고 생각할 수 있겠지만 그렇지 않습니다. 우선 C++의 const 사용에 대한 예를 들어 보겠습니다(<리스트 1>).

    <리스트 1> C++ const 멤버 함수
    #include
    #include

    class ZConfig {
    protected:
    char _strFileName[256];

    public:
    ZConfig(){};
    ~ZConfig(){};

    void setFileName( const char * strFileName ) {
    strncpy( _strFileName, strFileName, sizeof _strFileName );
    }
    const char * getFileName() const {
    return _strFileName;
    }
    };

    int main() {
    ZConfig conf;
    conf.setFileName("test.cfg");
    cout << conf.getFileName() << endl;
    return 0;
    }

    <리스트 1>은 getFileName 함수 뒤에 붙은 const의 예를 보여줍니다. 이것이 this 포인터에 대한 const 여부를 나타낸다고 생각하면 됩니다. 이것은 C로 해석하면 다음과 같은 형태로 이해할 수 있습니다. 컴파일될 코드는 아니지만 살펴보면,

    setFileName( ZConfig * this, const char * strFileName ) {
    strncpy( this->_strFileName, strFileName, sizeof _strFileName );
    }

    const char * getFileName( const ZConfig * this ) {
    return this->_strFileName;
    }

    getFileName의 선언이 “const char * getFileName() const”가 아닌 “const char * getFileName()”으로 바꾸고 다음의 예에서 사용된다고 생각해 봅시다.

    // getFileName의 const 여부에 따른 컴파일

    class ZConfig {
    ..중략..
    const char * getFileName() {
    return _strFileName;
    }
    };

    void run( const ZConfig * pConfig ) {
    cout << pConfig->getFileName() << endl;
    }

    int main() {
    ZConfig conf;
    conf.setFileName(“test.cfg”);
    run( & conf );
    return 0;
    }
    결과 - Error:
    In function `void run (const ZConfig *)’:
    passing `const ZConfig’ as `this’ argument of `const char *ZConfig::getFileName ()’ discards qualifiers

    C++에서는 const에 대한 처리를 warning으로 다루지 않고 error로 다루는 강력한 검사 기능을 제공하는데, 앞에 기록된 error는 “const char * getFileName()”와 같이 맨 뒤의 const를 제거한 형태로 컴파일할 경우에 발생합니다. 자, 여기서 개발자의 의도를 생각해 봅시다. 설계를 한다면 setFileName은 분명히 클래스 멤버 변수를 변경하는 의도가 있음을 알 수 있습니다. 따라서 이 함수 뒤에는 const가 따라오지 않을 것이라는 것이 분명합니다. 또한 run 함수의 의도는 ZConfig 객체가 읽기 전용으로만 사용될 것임을 예상할 수 있습니다. 그리고 그 안에서 사용된 getFileName도 멤버 변수를 읽는 함수라 생각되어 사용한 것입니다.
    그런데 ZConfig에서 getFileName 함수 선언시 맨 뒤에 const를 넣어 두지 않으면, run 함수와 같이 객체의 const 포인터를 사용하여 멤버 함수를 호출하는 경우 const 포인터가 내용을 변화시키는 의도를 나타내므로 컴파일러는 오류를 내는 것입니다. 다음을 잘 생각해 보면 getFileName을 C 형태로 표현한 것이며, this 포인터가 const가 제거되는 상황임을 알 수 있습니다.

    const char * getFileName( ZConfig * this ) {
    return this->_strFileName;
    }
    void run( const ZConfig * pConfig ) {
    cout << getFileName( pConfig ) << endl;
    }

    내부적으로는 this의 const 여부를 나타내는 것으로 해석하면, C++의 멤버 함수에 대한 것을 C의 표현법으로 해석할 수 있으므로 C++에서 const 함수라는 것이 왜 필요하게 되었는지 생각하는 데 도움이 될 것입니다. 이런 의도를 알게 되었다면, 다음부터는 컴파일러에게 객체의 변형에 대한 의도를 충분히 반영하여 장차 생길 수 있는 오류를 컴파일 타임 때 잡아내도록 노력합시다.

     의도를 나타내는 ‘static’ 함수
    C에서 static 함수의 표면적 의미는 ‘외부에서 불러 쓸 수 없는 그 파일 내부에서만의 함수’로 알려져 있습니다. 도대체 이게 왜 필요할까요? 이런 문법이 갖는 의도를 알지 못하는 이상 어떤 함수에 static을 줄지 말지를 심각하게 고민하지 않고, static은 전혀 사용하지 않는 편리한(?) 프로그램을 작성하게 됩니다. 잠시 링커를 생각해 봅시다. 링커가 하는 일은 오브젝트 파일들을 엮어서 실행 파일을 만드는데 있습니다. 엮는다는 의미는 외부에서 사용할 것이므로 어디엔가 함수 이름을 대비해 두고, 전 오브젝트에서 이름이 충돌하지 않게 관리된다는 추가적인 행동을 요구합니다. 안에서만 쓸 것이라 생각한 것들도 말이죠. 다른 말로는 이름 공간을 더럽힌다고 말합니다. 사용자 의도를 잘 해석하는 컴파일러는 <리스트 2>와 같은 경고를 보내줍니다.

    VC++의 경고를 해석하면 “local function으로 만들었는데, 어느 곳에서도 사용하지 않으니 제거하겠다”는 뜻입니다. 사용자의 의도를 정확히 해석한 것이죠. 만약 함수 앞에 static이 없었다면 이런 경고를 내지 않을 것입니다. “다른 곳에서 사용하는가 보다”라고 해석하는 것이죠. static 함수라고 명시해 놓으면 나중에 프로그램을 고치다가 더 이상 쓸모없는 함수가 될 경우 즉시 컴파일러로부터 경고가 나올 것입니다. 그것에 따라 그 함수를 제거하면 프로그램 흐름에 대한 가독성을 높여 줄 것입니다. 이 static과 관련하여 헤더 파일 작성법도 약간의 영향을 받습니다.

    ◆ static이 아닌 것은 extern이 생략된 것이며, 이런 함수들은 모두 외부에서 사용되는 것이므로 헤더 파일에 선언을 한다.

    ◆ static인 것들은 .c 파일 전방에 선언한다. 혹은 사용하는 함수는 항상 뒤에 작성한다.

    의도를 나타내는 클래스의 ‘static’ 멤버 함수
    클래스의 static 멤버 함수와 일반 멤버 함수의 가장 큰 차이는 앞에서 언급한 this 포인터가 암묵적으로 전달되는지의 여부에 따라 다릅니다. C++ 책에도 나오듯이 static 멤버 함수는 일반 멤버 변수/함수에 접근하지 못하며, 단지 static 멤버 변수/함수만을 접근 가능하다고 알고 있을 것입니다. 이것은 static 멤버 함수는 this 포인터가 넘어가지 않는 함수이기 때문입니다. 따라서 this 포인터가 있어야만 하는 일, 앞과 같이 일반 멤버 변수/함수를 사용하는 호출에 대해 컴파일 오류가 생길 것입니다.

    쓰레드를 만들 때 쓰레드의 입구 함수에 대한 형을 맞추는 것을 주의해야 합니다. 윈도우의 경우 CreateThread 함수를 사용하며, 유닉스의 Posix 쓰레드의 경우 pthread_create 함수를 사용합니다. 이 함수들은 인자에 입구 함수를 요구하며 각각 다음과 같은 형태의 함수 포인터여야 합니다.

    Windows 쓰레드: DWORD func(void *);
    Posix 쓰레드: void * func( void * );

    문제는 다음과 같이 쓰레드 입구 함수에 클래스의 멤버 함수를 넣고 싶을 때 발생합니다. <리스트 3>은 posix 쓰레드를 사용한 것으로, XThread 클래스를 만드는 예제라고 가정하고 그 클래스에 두 개의 멤버 함수를 넣어 봅시다. 하나는 static 멤버 함수이며, 다른 하나는 일반적인 함수입니다.

    <리스트 3>에서 사용된 pthread_create의 세 번째 인자로 들어가는 쓰레드 입구 함수는 앞의 코드와 같은 C 함수만 가능하다고 생각할 수 있습니다. 하지만 XThread에서 static 멤버로 선언된 thread_entrance1은 해당 요구사항을 만족하게 됩니다. 바로 this 포인터가 넘어가지 않기 때문에 가능한 것이죠. 직접 컴파일해 확인해 보기 바랍니다. 윈도우의 경우도 비슷하게 테스트해 볼 수 있습니다. 자, 이렇게 만들었을 때의 이점은 어떻게든 this 포인터를 받을 수 있다면(void *로 받는 param에 넘겨서) XThread의 protected 변수까지 접근 가능하다는데 있습니다.

    const, static이 C++에서 많이 확장된 것을 확인해 보았습니다. 이런 내용은 오류 메시지를 잘 분석해 얻어질 수 있는 개념들입니다. C++ 명세에서 나왔다기보다는 C용 라이브러리와 같이 써야만 하는 환경해서 생각하다 보면 에러 메시지를 통해 얻을 수 있는 것입니다. 디버깅을 하면서 발생하는 컴파일 에러 메시지는 어떤 예제보다도 더 머릿속에 쏙 들어오는 문제집이자 참고서입니다.

    헤더 파일 열어 보기
    헤더 파일을 자주 열어 봐야 합니다. 즉, 유닉스 계열에서는 /usr/include 디렉토리, 비주얼 C++ 6.0에서는 VC98/include 디렉토리에 들어 있는 파일들의 내용을 이해한다는 것입니다. 이 헤더 파일은 사실 프로그래밍 초기에는 별로 보고 싶지 않은 내용으로 가득 차 있습니다. 그런 마음으로 프로그래밍을 하다가도 가끔씩 열어보곤 합니다. 하지만 여전히 신기한 문법을 사용하는 것 같아 보입니다. 헤더 파일이 복잡하게 보이는 것은 이식성을 고려해 만들기 때문입니다.

    ① WIN32와 MAC을 고려한 선언 부분을 발견할 수 있습니다.
    ② 32비트와 64비트에 따라 파일을 다루는 함수 등에서 파일 크기 등을 고려하여 달라지는 부분이 있습니다.
    ③ C와 C++ 동시에 사용되는 헤더 파일이 대부분이고, C용 헤더 파일의 경우에는 extern “C” 등이 항상 보입니다.
    ④ CPU의 endian에 따라 달라지는 부분이 있습니다.
    ⑤ ANSI C 이전의 컴파일러를 고려해 선언되는 인자를 없애고 선언해야 하는 경우도 있습니다.
    ⑥ 표준화(ANSI, X/Open, POSIX, BSD 등)에 따라 선언이 안되는 경우도 있습니다.
    ⑦쓰레드에 따라 달라지는 부분이 있습니다.
    ⑧예외(Exception)를 지원하는 않는 컴파일러를 고려해 달라지는 부분이 있습니다.
    ⑨디버깅(NDEBUG, DEBUG)에 따라 달라지는 부분이 있습니다.
    ⑩CPU 등의 아키텍처에 따라 달라지는 부분이 있습니다.

    이렇게 많은 경우의 수를 하나의 헤더로 해결해야 하는 것이지요. 디버깅을 잘하려면 때때로 헤더 파일들을 뒤져가면서 보는 것이 좋습니다. 또 헤더 파일들을 잘 보면 앞과 같은 복잡한 상황에 대처하는 방법도 익히게 되며, 알지 못했던 상황도 고려하게 되는 경우가 많습니다. 디버깅을 위해서라도 헤더 파일 읽는 것을 소홀히 하지 마세요. 또, 한 가지 방법만 고려해 프로그램을 작성하다 보면 나중에 미처 생각지 못한 부분들이 발생하게 됩니다. 따라서 평소에 어떤 이식성 문제들이 있는지를 염두에 두면 디버깅할 때 입체적인 접근을 할 수 있습니다.

    메모리에 대한 이해
    메모리에 대한 이해는 코드를 작성할 때 데이터가 어떤 부분에 들어가는지에 대한 이해도입니다. 많이 접하는 네 가지의 예를 들어 보면 다음과 같습니다.
    ◆ 데이터
    ◆ static 변수(BSS)
    ◆ 자동 변수(stack)
    ◆ 힙

    이중에서 데이터 영역과 BSS라 알려진 static 데이터 영역은 컴파일된 코드 내에 그 영역이 설정됩니다. 간단히 설명하면 데이터들은 static 변수에 들어가는 초기 값과 변수들이 저장되는 곳이며, BSS는 초기 값이 없는 static 변수가 들어가는 것이라고 보면 됩니다. 초기 값이 없고 변수 길이 정보만 잡힙니다(검색어 : data bss stack 세 개를 동시에 주면 자세한 내용을 더 알 수 있습니다). 다음 코드를 통해 익혀 봅시다(유닉스에서는 nm과 objdump를 통해서 확인해 볼 수 있습니다).

    #include
    const char * B;
    const char * D = “R”;
    int main() {
    static int B2;
    char * S = (char *) malloc( 32 );
    S[0] = ‘H’;
    return 0;
    }

    BSS 영역 : B, B2
    데이터 영역 : D, “R”
    스택 영역 : S
    힙 영역 : S가 가리키는 곳, ‘H”가 저장되는 곳

    앞 내용에 대한 것은 익숙해질 때까지 symbol 확인 유틸리티를 사용하고, disassemble 코드를 분석해 가면서 이해해야 합니다.

    힙에서 흔히 발생하는 오류 형태
    힙과 스택을 사용하는 데 생길만한 오류는 거의 바운더리 체크(boundary check)입니다. 먼저 GNU에서 제공하는 GNU C 라이브러리가 구현된 것의 개념만으로 설명하자면, malloc이 호출될 때 C 라이브러리는 요구한 크기의 연속된 메모리 공간(세그먼트라고 합시다)을 반환하여 줍니다. 그것은 다음과 같이 표현됩니다.

    malloc에 의해 요청되는 크기에 8바이트 정도 추가 정보(A,B)가 먼저 기록되고 P 값이 리턴되어 넘어 옵니다. 커널에 요청하는 최초 메모리(A)가 넘어오는 것이 아니군요! 그리고 free로 해제할 때도 마찬가지로 P 값을 받아 8바이트를 뺀 위치에서 현재 해재돼야 할 길이를 구해 삭제 표시를 하게 되는 것입니다. 이런 이유로 free에서는 해제될 크기를 인자로 받지 않습니다. C++의 new, delete에서도 배열을 할당한 경우 delete[]를 호출할 때 원 배열의 크기를 넣지 않는 것도 같은 구현 방법으로 되어 있으며, 어떤 C++의 STL 구현에서 string에 대한 것도 내부 정보를 앞과 같은 방법으로 구현하는 것을 본 적이 있습니다. 중요한 것은 힙에서 버퍼 오버플로우가 일어날 때, 다음 블럭의 크기 정보를 망가뜨려 해제하거나 추가 할당할 때, 빈 공간에 대해 추정할 때 오동작하게 됩니다. 만약 오동작이 일어날 경우 이런 힙의 구조를 이해하면 추적하는 데 도움이 될 것입니다. new, delete도 결국에는 malloc, free를 이용하는 것입니다. 즉, 메모리를 할당받은 후 할당된 메모리에서 생성자나 소멸자가 불리는 것으로 이해하면 됩니다. 참고로 유닉스 계열에서 커널에 힙을 요청할 때는 내부적으로 brk 함수를 사용합니다.

    스택에서 흔히 발생하는 오류 형태
    스택은 흔히 번지 값이 감소하며 할당된다고 알려져 있습니다. 다음의 예와 같은 경우 <그림 2>와 같은 형태의 주소 배열을 갖습니다.

    oid func() {
    int x;
    int y;
    char buf[32];
    }
    buf가 나중에 선언되었지만 그 영향은 x, y에게 미친다는 것입니다. 또 스택이 사용되는 곳은 함수 호출과 관련되어 있습니다. 함수 호출은 calling convention이나 CPU 아키텍처마다 다릅니다. calling convention에 따라 함수에 넘어간 변수를 불린 함수가 해제하는 것이 있기도 하고, 부른 쪽에서 책임지고 해제하는 경우도 있습니다. 어떤 calling convention이나 CPU 아키텍처에 따라 함수 호출 인자의 몇 개까지는 레지스터에 넘기는 것도 있습니다.

    함수가 호출될 때 일어나는 일은 돌아올 리턴 주소가 먼저 저장되며, 다음으로 프레임 포인터라는 것이 저장됩니다. 프레임은 각 함수의 단위로 생각하면 되는데, 그 함수 내에서는 모든 자동 변수들이 같은 베이스 포인터를 가지고 베이스 포인터로부터 얼마나 떨어져 있느냐로 표현됩니다. 스택 포인터는 늘 변하기 때문에 함수에 들어오자마자 프레임 포인터를 현재의 스택 포인터 값으로 바꿉니다. 예를 들어 <그림 2>를 살펴보면, frame pointer = 136이라 하고, frame pointer - 0을 x, frame pointer - 4를 y frame pointer - 36을 buf라 사용하는 것입니다. 따라서 하나의 함수가 시작하면 이전 함수의 프레임 포인터를 저장해 두어야 돌아가기 전에 복원됩니다(x86에서는 어셈블리어 leave 명령도 참고하세요).

    스택 버퍼 오버플로우 공격이라 일컬어지는 것의 원리도 여기에 있습니다. buf에 쓰여지는 내용을 buf의 범위를 벗어난 리턴 어드레스까지 덮어 쓰게 만들고, 원래 호출한 함수로 돌아가는 것이 아니라 buf 내용 중간으로 점프하게 만들어 원하는 일을 하게 만드는 공격법이지요. 이런 이유 때문에 buffer 범위를 확인하지 않는 함수는 절대 사용 금지입니다(두 함수가 대표적입니다 : strcpy, sprintf). 정리하면 스택이 잘못될 경우 다음과 같이 영향을 미칠 수 있습니다.

    ① 갑자기 어떤 변수 내용이 바뀌었을 때, 그보다 아래 선언된 변수에서 영향을 받았을 가능성
    ② call stack이 이상하게 바뀌었을 경우에는 버퍼에 쓴 내용이 저장된 프레임 버퍼와 리턴 주소까지 영향을 준다.

    원리를 알면 왜 그렇게 사용하지 말아야 하는가를 쉽게 이해할 수 있습니다. 원리를 모른 채 경고 메시지 제거나 프로그래밍 가이드를 따른다면 재미가 덜하겠죠? 디버깅은 경험한 만큼 언어와 컴파일러를 구현 원리까지 생각하도록 유도합니다.

    C++의 함수 이름 꾸미기
    C++에서 가장 초반에 공부하는 것 중의 하나는 함수 인자가 다르면, 같은 이름의 함수를 여러 개 만들 수 있다는 함수 오버로딩에 대한 것입니다. 그리고 C++에서 C에 대한 코드를 사용하려면 항상 extern “C” 지시자를 선언문에 넣어 두어야 제대로 사용되는 것을 배우게 됩니다. <리스트 4>를 보면 a1.c의 코드를 b1.cpp에서 사용하려면, a1.c에서 만든 함수에 extern “C”를 선언하는 것을 볼 수 있습니다.

    이렇게 하는 이유는 C에 의해 생성되는 심볼과 C++에 의해 생성되는 심볼이 다르기 때문입니다. C에 의해 생기는 것은 함수명 그대로 심볼이 생기는 반면, C++에 의해 생기는 것은 함수명 뒤에 함수 인자에 따른 장식 문자들이 들어갑니다. <리스트 4>와 <리스트 5>의 a1.c, a2.cpp가 컴파일된 다음에 생기는 오브젝트의 심볼들을 살펴보면(<리스트 6>) C++에 의해 생성된 테스트에 대한 것은 “__Fii”라는 문자들이 붙게 됩니다. 아마 integer, integer라는 뜻이 맨 뒤에 붙었나 봅니다.

    이런 문자들은 함수 인자에 따라 혹은 클래스 멤버에 따라 다양하게 준비되어 있습니다(윈도우에서는 OBJ 파일들에 대해 dumpbin /symbols라는 명령과 옵션으로 확인해 볼 수 있습니다). 이렇게 예상한 함수 뒤에 붙는 것을 함수 이름 꾸미기(function name mangling 혹은 function name decoration이라 하는데 적절한 번역이 없습니다)라고 합니다. 이렇게 하는 주된 이유는 함수 중첩 선언(function overloading)을 하기 위해 사용되는 오브젝트 파일 상에서의 기법입니다. 여기서 잘 생각해 볼 것은 C로 됐든 C++로 됐든 오브젝트 파일이 생기면 내부적으로는 사실상 같이 취급되는 것인데, 마찬가지로 포트란이나 파스칼도 오브젝트가 되면 링커에 의해 결합되는 것만을 남게 됩니다.

    이런 원리를 알면 링커가 혼동하지 않도록 소스에서 오브젝트에 포함되는 심볼들의 이름을 일치시키는 것이 프로그래머가 해야 할 일입니다. 바로 extern “C”라는 선언문 앞 지시자의 역할이 함수 이름 꾸미기를 하지 말 것을 지시하는 것이지요.

    nm a1.o
    00000000 t gcc2_compiled.
    U printf
    00000000 T test

    nm a2.o
    00000000 t gcc2_compiled.
    U printf
    00000000 T test__Fii

    재미로 한 가지 해 봅시다. 앞의 소스를 보면 b2.c라는 소스를 볼 수 있습니다만, 이것은 C++로 되어 있는 코드를 C에서 사용한 예를 나타낸 것입니다. 대개의 경우와 반대가 되겠지만, C에서 C++의 함수 이름 꾸미는 규칙에 대해 알고 일부러 없는 함수를 선언하여 링크되도록 유도한다고 해서 안 될 것이 없습니다. 다만 b2.c 같은 예제는 철저하게 재미로 만든 소스입니다. 결코 실전에서는 사용되어서는 안 됩니다. 왜냐하면 C++의 함수 이름 꾸미기 규칙은 컴파일러마다 완벽(?)하게 다르기 때문에 이름 규칙을 알고 소스를 작성하는 것은 무의미한 일입니다. b2.c 코드 또한 gcc에서 만드는 이름 규칙을 생각해서 만든 것이므로 다른 환경에서는 되지 않을 것입니다. 따라서 서로 다른 컴파일러에 의해 만들어진 C++ 코드는 이름 꾸미는 방식이 다른 것으로 인해 쉽게 같이 쓸 수 없는 단점이 있고, C++ 라이브러리는 컴파일러마다 독립적으로 존재한다는 것을 나중에 확인해 볼 수 있을 것입니다.

    디버깅=프로그래밍 수련 과정
    이번 호에서는 서로 관련 없는 사항이긴 하나 세 가지 정도를 들어 디버깅을 하면서 내부에서 돌아가는 원리를 알게 되는 일에 중점을 두어 설명하였습니다. 디버깅은 해킹과 같은 고도의 입체적인 접근과 연결되어 있는 개발 행위(?)입니다. 따라서 디버깅은 단순한 문제 해결 관점보다는 좀더 테스트 코드를 만들어 보게 하고, 언어와 환경에 대한 깊은 이해를 돕는 프로그래밍 수련회와 같은 것이라고나 할까요?

    세 번에 걸쳐 디버깅에 대한 감각이 잡히려는 사람들을 대상으로 어떤 관점을 가져야 될지 주제를 골라 나열하였습니다만, 아는 것을 말로 표현하는 것이 쉽지만은 않다는 것을 느꼈습니다. 필자에게 따로 연락해도 도움을 드리겠지만, 되도록 지적인 공유를 위해 필자가 자주 이용하는 http://bbs.kldp.org/에 질문하면 여러 사람으로부터 다양한 도움을 얻을 수 있을 것입니다. 개발자들이 모두 디버깅에 자신을 갖고, 명랑한 코딩을 하기를 상상해 보면서 이만 연재를 마칩니다.

    반응형
    C로 구현하는 MIME Parser (1)

    메일의 동작 원리와 메일 형식

    메일과 MIME에 대해 이해를 하고, 스스로 MIME Parser를 구현해보면 추후에 어떤 MIME 버전이 나오더라도 어렵지 않게 새로운 버전을 자신의 애플리케이션에 적용시킬 수 있을 것이다. 이러한 의도를 가지고 메일과 MIME Parser 구현에 관한 연재를 하고자 한다.

    (주)넷사랑컴퓨터 조한열
    hanyoul@netsarang.com

    인터넷이 등장하면서 가장 많이 사용되어 왔으며 앞으로도 그 확고한 지위를 놓치지 않을 애플리케이션이 바로 전자메일이다. 월드 와이드 웹(WWW)이 급속도로 성장하고 있지만 전자메일을 따라잡을 수는 없을 것이고, 이는 앞으로도 꽤 오랫동안 마찬가지일 것이다. 오히려 전자메일은 웹의 편리성을 자신에게 적용시켜 웹메일이라는 독특한 영역을 개척해왔다. 웹이 발전하면서 생긴 수많은 포탈 사이트 가운데 웹메일 서비스를 제공하지 않는 곳이 없을 정도로 전자메일은 웹의 영역으로 자신을 확대시켜 나가고 있는 것이다.
    이처럼 메일이 자신의 영역을 끊임없이 넓혀나가며 인터넷의 핵심 애플리케이션으로서의 확고한 지위를 차지하고 있지만, 메일의 동작원리와 메일 애플리케이션의 구현에 대한 이해를 가지고 있는 사람들은 그다지 많지 않다. 메일 애플리케이션을 구현할 때, 기존에 나와있는 메일 관련 라이브러리들을 사용하면 되지만(그나마 C로 된 라이브러리는 없는 것 같다.) 메일과 MIME에 대한 이해가 없이 단순히 라이브러리만을 사용한다면 추후에 어려움에 봉착할 수도 있을 것이다. 지금 현재는 MIME 버전이 1.0이지만 언제 차기 MIME 버전이 나와 우리를 당황하게 할 지 모르는 일이기 때문이다.

    1. 메일 시스템의 구성

    메일을 보내고 받으려면 어떠한 프로그램들이 필요한지 한 번 생각해보자. 가장 간단하게 생각한다면 2개의 프로그램이 있으면 가능할 것이다. 하나는 사용자가 메일을 작성하여 보내는 프로그램이고, 다른 하나는 자신에게 온 메일을 받는 프로그램일 것이다. 그러나 곰곰히 생각해보면 이 두 개의 프로그램만으로는 메일을 주고 받을 수 없다는 것을 알 수 있다. 만약 A라는 컴퓨터에 있는 a라는 사용자가 B라는 컴퓨터에 있는 b라는 사용자에게 메일을 보낸다고 가정하자. 위의 두 개의 프로그램만으로 a가 b에게 메일을 보낸다고 할 때, 만약 b가 B 컴퓨터에 접속을 하고 있지 않는다면 a는 메일을 나중(b가 B 컴퓨터에 접속했을 때)에 다시 보내야 한다. 그렇게 하고 싶지 않다면 B 컴퓨터에 프로그램이 하나가 더 있어서 a가 보내온 메일을 보관하고 있다가 b가 B 컴퓨터에 접속했을 때, 보관해온 메일을 b에게 보내주는 역할을 해야 한다.
    그러나 이렇게 한다 해도 완벽한 것이 아니다. a가 b에게 메일을 보내려고 하는데 B 컴퓨터가 작동불능 상태에 있다면 어떻게 될까? B 컴퓨터에 있는 메일 보관 프로그램이 동작을 하지 않고 있으므로 메일을 B 컴퓨터가 작동 가능할 때 다시 보내야 한다. 그러면 메일을 다시 보내는 일은 누가 해야 할 것인가? 이러한 상황이라면 메일을 다시 보내는 일은 사용자의 몫이 되고 만다. 그러나 이는 너무나도 불편한 일이다. 만약 A 컴퓨터에 메일 전송만을 담당하는 프로그램이 있어서 a가 보내라고 요구하는 메일을 받아서 보관하고 있다가 B 컴퓨터가 작동 가능할 때에 B 컴퓨터에 있는 메일 보관 프로그램에게 보낼 수 있다면 a는 메일이 B 컴퓨터에 도착했는지의 여부에 대해 일일이 신경 쓰지 않아도 될 것이다. 그 역할은 A 컴퓨터에 있는 메일 전송 프로그램과 B 컴퓨터에 있는 메일 보관 프로그램의 몫이기 때문이다.

    위와 같은 모든 경우들을 고려하여 안정적으로 메일을 보낼 수 있도록 메일 시스템이 설계되었다. 메일 시스템의 가장 중요한 요소들은 그림 1과 같다.

    그림 1 : 메일 시스템의 구성 요소

    (박스)
    Mail User Agent(MUA)
    사용자가 메일을 보내고 받는데 사용되는 메일 클라이언트 프로그램이다.
    Mail Transfer Agent(MTA)
    한 컴퓨터에서 다른 컴퓨터로 메일을 전송하는데 사용되는 메일 서버 프로그램이다. MUA가 보내라고 요청한 메일을 다른 컴퓨터로 전송하는 역할을 한다. 또한 다른 컴퓨터에서 사용자에게 보내온 메일을 받는 역할을 한다.
    Mail Delivery Agent(MDA)
    MTA가 받은 메일을 사용자의 메일 보관함에 집어넣는 역할을 하는 프로그램이다. MDA는 독립적으로 작동하는 프로그램이 아니라 MTA에 의해서 사용되는 프로그램이다.

    다시 A 컴퓨터의 a가 B 컴퓨터의 b에게 메일을 보내는 예를 가지고 메일 시스템 구성을 설명해보자.
    a는 자신의 MUA로 b에게 보낼 메일을 작성한다. 그리고 a의 MUA에게 메일을 보내라고 명령한다. 그러면 a의 MUA는 A 컴퓨터에 있는 MTA에게 B 컴퓨터에 있는 b에게 메일을 보내라고 요청한다. MUA의 요청을 받은 MTA는 a의 MUA가 보내온 메일을 받아서 B 컴퓨터에 있는 MTA에게 메일을 전송한다. 이 때 B 컴퓨터가 사용가능하지 않다면 A 컴퓨터의 MTA가 a의 메일을 보관하고 있다가 주기적으로 B 컴퓨터의 MTA에게 메일을 전송하려고 시도한다. 그러다가 B 컴퓨터가 작동 가능하게 될 때, B 컴퓨터의 MTA에게 메일을 성공적으로 전송하게 된다. B 컴퓨터에 있는 MTA는 전송 받은 메일을 B 컴퓨터의 MDA에게 전달하고, B 컴퓨터의 MDA는 b의 메일 보관함에 전송 받은 메일을 집어넣는다. 나중에 b가 자신의 MUA를 실행시키게 되면, b의 MUA는 b의 메일 보관함을 살펴보고 새로운 메일이 있으면 b에게 메일을 보여주게 된다.

    1.1 메일 전송 프로토콜 - Simple Mail Transfer Protocol(SMTP)
    A 컴퓨터와 B 컴퓨터에 있는 MTA는 인터넷을 통해 메일을 전송한다. 네트워크를 이용한 모든 전송에는 송신측과 수신측간에 합의된 규약(Protocol)이 있어야 한다. 송신측에서 보낸 데이터를 수신측에서 이해하지 못한다면 전송은 이미 실패한 것이다. 메일 전송 역시도 당연히 송수신간에 합의된 규약이 있어야 한다. 메일 전송 규약이 바로 Simple Mail Transfer Protocol(SMTP)이다. SMTP는 RFC 821 문서에 자세히 설명되어 있다.

    지금부터 SMTP에 대하여 설명할 것이다. 만약 지금부터 설명하는 내용이 잘 이해되지 않더라도 끝까지 읽어보자. 다음 절에서 실제로 SMTP를 사용하여 직접 메일을 보내볼 것인데, 그 때가 되면 지금 설명하는 내용들을 확실히 이해할 수 있을 것이다. 그러니 꾹 참고 읽어나가자.
    먼저 SMTP가 동작하는 모습을 한번 살펴보자(리스트1). 모든 유닉스에는 아주 간단한 기능을 수행하는 메일 클라이언트(MUA)인 mail이라는 프로그램이 존재한다. mail에 옵션 -v를 주고 간단한 메일 하나를 hanyoul@netsarang.com에게 보내보자. 옵션 -v는 메일의 전송과정을 자세히 보여주는 옵션이다. 이 때, 보내는 사람은 cho1102@conux.conux.com이다.

    리스트 1 : SMTP의 동작
    $ mail -v hanyoul@netsarang.com <Enter>
    Subject: 메일 테스트입니다. <Enter>
    메일 본문입니다. <Enter>
    . <Enter>
    Cc: <Enter>
    hanyoul@netsarang.com... Connecting to netsarang.com. via esmtp...
    220 netsarang.com ESMTP Sendmail 8.9.3/8.9.3; Tue, 4 Jul 2000 18:38:25 +0900
    >>> EHLO conux
    250-netsarang.com Hello IDENT:cho1102@conux.conux.com [210.118.172.101], pleased to meet you
    250-EXPN
    250-VERB
    250-8BITMIME
    250-SIZE
    250-DSN
    250-ONEX
    250-ETRN
    250-XUSR
    250 HELP
    >>> MAIL From:<cho1102@conux.conux.com> SIZE=72
    250 <cho1102@conux.conux.com>... Sender ok
    >>> RCPT To:<hanyoul@netsarang.com>
    250 <hanyoul@netsarang.com>... Recipient ok
    >>> DATA
    354 Enter mail, end with "." on a line by itself
    >>> .
    250 SAA03728 Message accepted for delivery
    hanyoul@netsarang.com... Sent (SAA03728 Message accepted for delivery)
    Closing connection to netsarang.com.
    >>> QUIT
    221 netsarang.com closing connection

    Subject와 본문 그리고 Cc(참조)까지 입력을 하고 나면 mail 프로그램은 입력된 메일을 수신자에게 전송한다. 이 때, 메일을 전송하는 과정이 나타날 텐데 이것을 유심히 살펴보면 SMTP의 원리를 알 수 있다.
    먼저 "Connecting to netsarang.com. via esmtp..."라는 메시지가 눈에 띌 것이다. 이 메시지가 나타내는 의미는 지금 mail 프로그램이 ESMPT(나중에 설명하겠지만 SMTP의 확장 프로토콜이다.)를 이용하여 netsarang.com에 있는 메일 서버(MTA)와 연결을 한다는 것이다.
    수신측(netsarang.com)의 메일 서버(MTA)와 연결이 성공되면 mail 프로그램은 SMTP 명령어를 수신측 MTA에게 전달하고, 수신측 MTA는 송신자가 보낸 명령어에 대한 응답을 보내온다. 명령어와 응답의 주고받음을 통해 메일이 전달되는 것이고 이러한 명령어와 응답의 관계를 기술한 것이 바로 SMTP이다.
    위에서 >>> 다음에 나오는 문자열들이 mail 프로그램이 수신측 메일 서버에게 보내는 명령어이다. 수신측 메일 서버는 mail 프로그램이 보낸 각각의 명령어에 대해 응답을 보내오게 되는데, 그 응답은 상태를 나타내는 숫자 값과 문자열로 구성된다. mail 프로그램은 응답의 첫머리에 나타나는 숫자 값만으로도 자신이 보낸 명령에 대한 수신측의 응답상태를 알 수 있다.

    2. SMTP 명령어

    EHLO [Domain name]
    SMTP 세션의 시작. 자신의 도메인 이름을 수신측 MTA에게 알려주는 명령어이다. 수신측 MTA는 이 도메인 이름이 올바른 형식의 도메인 이름인지를 검사한다. 메일 relay를 방지하기 위해서는 송신측의 도메인 이름을 얻어내는 것이 필수적으로 필요하다. EHLO 명령을 사용하지 않으면 수신측에서 메일 수신을 거부할 가능성이 크다. 이는 수신측 MTA의 환경설정에 따라 달라진다.

    MAIL FROM: [Email Address]
    메일을 보내는 사람을 수신측 MTA에게 알려주는 명령어이다. 수신측 MTA는 이 전자메일 주소가 올바른지를 검사한다. 이 역시 EHLO처럼 올바르지 못한 주소가 입력될 경우 수신을 거부한다.

    RCPT TO: [Email Address] 혹은 [User's ID]
    메일을 받을 사람을 수신측 MTA에게 알려주는 명령어이다. 메일을 받을 사람이 수신측의 로컬사용자이면 ID만을 입력해도 된다. 만약 수신측 MTA가 메일 relay를 허용하지 않을 경우(수신측의 MTA를 사용하여 다른 컴퓨터에 있는 MTA에 접속하는 것. 이 경우에 수신측 MTA는 다른 컴퓨터로 메일을 중계하게 되는 데, 이를 relay라고 한다.)에 다른 컴퓨터에 있는 사용자의 전자메일 주소를 입력하게 되면 relay를 허용하지 않는다는 메시지와 함께 수신을 거부한다.

    DATA
    메일을 입력하겠다는 명령어이다. DATA 명령 다음에 입력되는 것은 메일 메시지이다. 새로운 줄 처음에 마침표(.)를 입력하고 엔터(CRLF)를 치면 메일 메시지 입력을 끝낸다.

    QUIT
    SMTP 세션을 닫는다.

    이 외에도 SEND, EXPN, NOOP, SAML, SOML, RSET, VRFY등의 명령어들이 있으나, 설명하고자 하는 내용에서 벗어나므로 자세한 설명은 하지 않겠다. RFC 821 문서를 보면 자세한 설명이 되어있으니 관심있는 독자들은 참고하기 바란다.

    telnet netsarang.com 25
    그러면 위에서 살펴본 SMTP를 이용하여 실제로 메일을 보내보자. SMTP를 직접 이용하기 위해서는 telnet을 통해 수신측 MTA에 직접 접속하여야 한다. 수신측 MTA는 자신의 네트워크 포트 25번을 열어 놓고 SMTP 연결을 기다리고 있다. 그러므로 우리는 Telnet 클라이언트를 이용하여 수신측 컴퓨터의 25번 포트로 접근하면 SMTP를 사용하여 수신측 MTA에게 메일을 보낼 수 있는 것이다.

    유닉스에서 기본적으로 사용되는 telnet 프로그램을 이용해서, 25번 포트로 접근하겠다는 옵션을 주고 netsarang.com(수신측 도메인 이름)에 접속해보자. 명령은 다음과 같다.

    $ telnet nesarang.com 25<Enter>

    위와 같은 명령으로 netsarang.com에 있는 MTA에 접속을 하게 되면 다음과 같은 메시지가 나타난다.

    Trying 210.118.172.100...
    Connected to netsarang.com.
    Escape character is '^]'.
    220 netsarang.com ESMTP Sendmail 8.9.3/8.9.3; Mon, 10 Jul 2000 15:22:27 +0900

    netsarang.com의 MTA가 메일을 받을 준비가 되어 있다면 마지막 줄과 같은 메시지가 나타날 것이다. 220이라는 응답상태 값은 OK 사인이다. 위의 메시지를 통해 netsarang.com의 MTA는 Sendmail 버전 8.9.3 임을 알 수 있고, SMTP의 확장 프로토콜인 ESMTP를 지원하고 있음을 알 수 있다. netsarang.com의 MTA는 SMTP 접속을 허락한다는 OK 사인을 보내놓고 SMTP 시작을 기다리고 있는 것이다.

    이제 SMTP 명령을 netsarang.com의 MTA에게 내려보자.

    EHLO conux.com<Enter>
    250-netsarang.com Hello IDENT:hanyoul@conux.conux.com [210.118.172.101], pleased to meet you
    250-EXPN
    250-VERB
    250-8BITMIME
    250-SIZE
    250-DSN
    250-ONEX
    250-ETRN
    250-XUSR
    250 HELP

    위에서도 언급했듯이 EHLO는 SMTP의 시작을 알리면서 자신의 도메인 이름을 수신측 MTA에게 전달해주는 것이다. EHLO 명령에 수신측 MTA는 250이라는 응답상태와 함께 자신의 정보를 알려주고 있다. 이 응답의 자세한 의미에 대해서는 그냥 넘어가도 좋다. 단지 conux.com이라는 도메인 이름으로 요청되는 메일 전송에 대해 수신측 MTA가 허락하는 것이라고 생각하면 된다.

    만약, 도메인 이름의 형식이 잘못되었다면 수신측 MTA는 501이라는 응답코드와 함께 에러 메시지를 보내온다. 도메인 이름에 마침표(.)대신 쉼표(,)를 사용하여 EHLO 명령을 내렸다면 다음과 같은 에러 메시지를 볼 수 있을 것이다.

    EHLO conux,com<Enter>
    501 Invalid domain name

    EHLO 명령에 대한 응답이 OK였다면 MAIL FROM: 명령을 내려서 메일을 보내려고 하는 사람이 누구인지 수신측 MTA에게 알려야 한다.

    MAIL FROM: cho1102@conux.com<Enter>
    250 cho1102@conux.com... Sender ok

    만약 도메인 이름을 생략한다면 수신측 MTA는 도메인 이름이 필요하다고 투정을 부릴 것이다.

    MAIL FROM: cho1102<Enter>
    553 cho1102... Domain name required

    응답상태 250번의 OK 사인이 떨어지면 RCPT TO: 명령을 통해 누구에게 메일을 보낼 것인지 알려야 한다.

    RCPT TO: hanyoul<Enter>
    250 hanyoul... Recipient ok

    hanyoul 뒤에 도메인 이름이 없기 때문에 이는 수신측 컴퓨터의 로컬 사용자라는 것을 나타낸다. 수신측 MTA는 자신의 로컬 사용자 중에 hanyoul이라는 이름이 있는 지를 살펴보고 hanyoul이라는 사용자가 존재한다면 250번 응답코드와 함께 OK 사인을 보낸다.

    RCPT TO: 명령은 다른 명령들과는 다르게 여러 번의 사용도 가능하다. 그러므로 한 개의 메일 메시지를 가지고 여러 명에게 동시에 보내는 일이 가능하게 된다. RCPT TO: 를 여러 번 사용하여 받을 사람을 정해주면 간단히 동시에 여러 명에게 똑같은 메일을 보낼 수 있는 것이다.

    RCPT TO: hanyoul<Enter>
    250 hanyoul... Recipient ok
    RCPT TO: nkkwak<Enter>
    250 nkkwak... Recipient ok

    만약 메일 받을 사람이 존재하지 않으면 550번 응답코드와 함께 에러 메시지를 보내온다.

    RCPT TO: noname<Enter>
    550 noname... User unknown

    RCPT TO: 를 이용하여 다른 컴퓨터에 있는 사용자에게도 메일을 보낼 수가 있는데, 이럴때는 도메인 이름까지 들어간 전자메일 주소를 사용하면 된다. 아래에서 nownuri.net에 있는 사용자의 이름을 사용하였는데, 이렇게 되면 수신측 MTA는 nownuri.net의 MTA에게 자신이 받은 메일을 중계하게 된다.

    RCPT TO: idol110@nownuri.net<Enter>
    250 idol110@nownuri.net... Recipient ok

    그러나, 수신측 MTA는 자신의 로컬 사용자에게 보내지는 것이 아닌 메일은 수신을 거부할 수가 있다. 악의적인 사용자들이 수신측 MTA를 이용해 스팸메일을 대량으로 발송하는 것이 가능하기 때문에 메일 중계기능(relaying)을 허용하지 않는 MTA가 많다. 이러한 MTA들은 다음과 같은 에러 메시지를 출력한다.

    RCPT TO: idol110@nownuri.net<Enter>
    550 idol110@nownuri.net... Relaying denied

    RCPT TO: 를 사용하여 메일 수신자에 대한 확인까지 이루어졌으면 이제는 메일 메시지를 전송해야 한다. 메일 메시지를 전송하기 위한 명령어는 DATA이다.

    DATA<Enter>
    354 Enter mail, end with "." on a line by itself

    DATA 명령을 받은 수신측 MTA는 메일 메시지를 전송하라고 요청을 한다. 메일 메시지의 끝을 나타낼 때는 마침표(.)를 찍으라는 친절한 안내를 덧붙이고 있다. 그러면 간단한 메일 메시지 하나를 보내보자.

    Subject: 메일 테스트입니다.<Enter>
    <Enter>
    메일 본문입니다.<Enter>
    .<Enter>
    250 KAA32447 Message accepted for delivery

    응답코드 250번과 함께 메일 메시지가 받아들여졌다는 메시지가 출력되면 수신측 MTA가 수신측 MDA를 통해 수신자에게 메일을 보낼 준비가 되었다는 것을 의미한다. 메일 발송은 간단히 성공을 한 것이다.

    이제는 SMTP 세션을 끝내면 된다. SMTP 세션을 끝내기 위해서는 QUIT 명령을 사용한다.

    QUIT<Enter>
    221 netsarang.com closing connection
    Connection closed by foreign host.

    SMTP를 통해 보내진 메일 메시지는 수신측 MTA에 의해 저장이 된다. 저장된 메일 메시지를 보려면 수신측 MTA가 저장한 파일을 보면 된다. 수신측 MTA가 저장하는 파일의 위치는 MTA 종류에 따라 틀릴 것이다. 가장 대표적인 MTA인 sendmail이 메일 메시지를 저장하는 곳은 /var/spool/mail/ 디렉토리이고, 파일 이름은 수신자의 ID와 같다. 조금 전에 SMTP를 통해 보낸 메일을 이 곳에서 확인해보자. 확인을 하기 위해서는 netsarang.com으로 접속하여 hanyoul이라는 ID로 로그인 해야 함은 당연하겠다.

    $ cat /var/spool/mail/hanyoul<Enter>
    From cho1102@conux.com Tue Jul 11 10:15:17 2000
    Return-Path: <cho1102@conux.com>
    Received: from conux.com (IDENT:hanyoul@conux.conux.com [210.118.172.101])
    by netsarang.com (8.9.3/8.9.3) with ESMTP id KAA32568
    for hanyoul; Tue, 11 Jul 2000 10:14:56 +0900
    Date: Tue, 11 Jul 2000 10:14:56 +0900
    From: cho1102@conux.com
    Message-Id: <200007110114.KAA32568@netsarang.com>
    Subject: 메일 테스트입니다.

    메일 본문입니다.
    표 1 : 메일 본문

    표 1은 조금 전에 우리가 보낸 메일 메시지이다. 메일 메시지는 MUA가 이해할 수 있도록 정형화된 포맷을 가지고 있다. 메일 메시지를 보낼 때에 이 포맷에 맞게 보내야 수신측 MUA가 메일 메시지를 이해할 수 있다. 이러한 메일 메시지 포맷을 정의한 것이 RFC 822 문서이다. RFC 822에서 메일 메시지의 포맷을 정의하고 있다는 이유 때문에 메일 메시지를 RFC 822 메시지라고 부른다.

    3. RFC 822 메시지의 구성

    RFC 822 메시지는 크게 헤더와 본문으로 구성되어 있다. 헤더는 메일 발송에 대한 모든 정보를 알려주는 역할을 하고, 본문은 말 그대로 메일 메시지의 내용을 나타낸다. RFC 822 메시지에서 헤더와 본문의 구분은 빈 줄(CRLF)로 한다. 예로 보여준 위의 메일 메시지에서 헤더는 첫 번째 줄부터 갨ubject: 메일 테스트입니다. 까지로 구성되어 있다. Subject 다음 줄은 빈 줄인데, 이 빈 줄이 헤더와 본문을 구분하는 구분자이다. 그러므로 빈 줄 다음에 나오는 문자들은 모두 메일 본문에 속한다. 그러나 본문은 있을 수도 있고 없을 수도 있다. 헤더만으로 구성된 메일 메시지도 있을 수 있다는 것이다.

    3.1 RFC 822 헤더
    RFC 822 헤더는 메일에 관한 정보를 담고 있다. RFC 822 헤더는 콜론(:)으로 구분되는 이름-값 쌍으로 이루어져 있다. 예를 들어 갌rom: cho1102@conux.com" 이라는 헤더의 한 구성요소는 긃rom' 이라는 헤더 이름과 꼊ho1102@conux.com' 이라는 헤더 값으로 이루어져 있고, 콜론(:)으로 이름과 값이 구분되어 있다.

    헤더 값이 길 경우에는 헤더 값이 여러 줄에 걸쳐 나올 수가 있다. 이를 Long Header(긴 헤더)라고 한다. 만약 헤더의 값이 여러 줄로 표시되어야 할 필요가 있을 때에는 새로운 줄 앞에 스페이스나 탭(White space)을 한 개 이상 집어넣어 Long Header임을 나타낸다. 위의 예에서 Received 헤더는 Long Header이다.

    Received: from conux.com (IDENT:hanyoul@conux.conux.com [210.118.172.101])
    (한개 이상의 스페이스나 탭) by netsarang.com (8.9.3/8.9.3) with ESMTP id KAA32568
    (한개 이상의 스페이스나 탭) for hanyoul; Tue, 11 Jul 2000 10:14:56 +0900

    위에서 설명한 헤더의 구성 규칙을 문법으로 나타내면 다음과 같다. 문법을 나타내는 다음과 같은 형식을 잘 모르는 독자도 자세히 살펴보면 쉽게 이해 할 수 있을 것이다. 정 모르겠다면 넘어가도 좋다. 위에서 설명한 내용을 정리한 것에 불과하기 때문이다.

    field = field-name : [ field-value ] CRLF
    field-name = 1*<컨트롤 문자와 스페이스, 콜론(:)을 제외한 아스키 문자>
    field-value = field-value-contents
    [CRLF LWSP-chars field-value]
    field-value-contents = <CRLF를 제외한 아스키 문자열>

    Return-path
    Return-path 헤더는 마지막으로 메일을 수신한 MTA에 의해서 붙여지는 헤더이고, 메일을 회신할 주소를 가리킨다. Reply-To 헤더와 같은 역할을 하지만 Return-path 헤더는 MTA에 의해 덧붙여지기 때문에 항상 존재하는 헤더이다.

    Received
    Received 헤더는 메일을 수신한 MTA마다 덧붙이는 헤더이다. 자신이 받았다고 스탬프를 찍는 것과 같다. 그러므로 Relay 되는 메일은 Received 헤더가 여러 개가 존재한다. Received 헤더를 참고하면 전송되는 메일이 어느 MTA를 언제 거치면서 전송되는 지 알 수 있다.

    From
    From 헤더는 메일 메시지를 보내는 사람을 가리키는 헤더이다. 원래의 메일 메시지에 From 헤더가 없으면 수신측 MTA가 덧붙인다.

    Sender
    Sender 헤더는 실제로 메일 메시지를 작성한 사람을 가리키는 헤더이다. From 헤더가 가리키는 사람이 실제로 메일 메시지를 작성한 사람이 아닐 때, Sender 헤더를 사용하여 실제 작성자를 가리키는 것이다.

    Reply-To
    Reply-To 헤더는 회신할 주소를 가리키는 헤더이다. Reply-To 헤더가 존재할 때에는 mail을 Reply-To 헤더가 가리키는 주소로 회신하여야 한다. 만약 Reply-To 헤더가 존재하지 않는다면 From 헤더가 가리키는 주소로 회신하여야 한다.

    Resent-From, Resent-Sender, Resent-Reply-To
    Resent- 가 앞에 붙은 Resent-Reply-To, Resent-From, Resent-Sender는 각각 Reply-To, From, Sender 와 같은 의미이다. 단지 Resent- 가 붙은 헤더들이 그렇지 않은 헤더들보다 더욱 최신의 정보라는 것을 나타낼 뿐이다. 이는 Resent- 를 붙인 MTA에 의해 mail이 Forwarding되었음을 나타낸다.

    Date
    메일이 작성된 시간을 나타낸다. Date 헤더가 원래의 메일 메시지에 들어있지 않다면 송신측 MTA가 Date 헤더를 원래의 메시지에 덧붙인다.

    Resent-Date
    Date 헤더와 같은 역할을 하지만 좀 더 최신의 정보를 가리킨다.

    To
    메일을 받을 사람을 가리킨다. 여러 명을 가리킬 수도 있는데, 이 때는 각각의 전자메일 주소를 쉼표(,)로 구분한다.

    Resent-To
    To 헤더와 같은 역할을 하지만 좀 더 최신의 정보를 가리킨다.

    Cc
    메일을 참조할 사람을 가리킨다. Cc 헤더가 가리키는 사람에게 메일의 복사본이 보내진다. Cc 역시 To 헤더와 마찬가지로 여러 명을 가리킬 수도 있고, 각각의 전자메일 주소를 쉼표(,)로 구분하면 된다.

    Resent-cc
    Cc 헤더와 같은 역할을 하지만 좀 더 최신의 정보를 가리킨다.

    Bcc
    Bcc 헤더는 숨은 참조를 가리킨다. Cc 와 마찬가지로 메일을 참조할 사람을 가리키지만, 원래의 메일을 받는 사람은 자신에게 보내진 메일이 Bcc가 가리키는 사람에게 메일 복사본이 보내진 것을 알 수가 없다. Bcc 역시 여러 명을 가리킬 수 있다.
    Resent-bcc
    Bcc 헤더와 같은 역할을 하지만 좀 더 최신의 정보를 가리킨다.

    Subject
    Subject 헤더는 메일의 제목을 나타낸다.

    X-로 시작되는 헤더들
    X-로 시작되는 헤더들은 RFC 822 표준 헤더가 아니라 사용자나 MUA가 필요에 따라 정의해서 사용하는 헤더들이다.

    3.2 RFC 822 헤더에 사용되는 시간 표시방법
    RFC 822 헤더 가운데 시간 정보를 표시해야 하는 헤더들이 있다. 대표적인 헤더들로 Date, Received 헤더가 있다. 이러한 헤더들이 시간을 나타낼 때에는 규칙적인 형식으로 나타내야 수신측 MUA가 헤더에서 사용된 시간을 자동적으로 검출해 낼 수 있다. 위의 예에서 Date 헤더를 살펴보자.

    Date: Tue, 11 Jul 2000 10:14:56 +0900

    위의 예가 전형적인 시간 표시 형식이다. 처음에는 요일을 나타내는 3개의 문자가 온다. (Mon, Tue, Wed, Thu, Fri, Sat, Sun) 그리고 요일 다음에 반드시 쉼표(,)가 있어야 한다. 요일과 쉼표는 함께 생략될 수도 있다. 요일+쉼표 다음에는 일, 월, 연도가 나타나는데 각각은 스페이스로 구분이 된다. 이 때 월을 나타낼 때는 숫자가 아닌 3개의 문자로 나타낸다. (Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec) 또한 연도는 2자리로 표시할 수도 있고 네자리로 표시할 수도 있다. 일, 월, 연도 뒤에는 시간을 나타내는 시, 분, 초가 나타나는데, 각각은 2자리의 숫자로 표시되고, 콜론(:)으로 구분된다. 이 때 초는 생략될 수 있다. 시, 분, 초 다음에는 time zone이 표시된다. time zone이란 지역 시간대를 말한다. 영국의 그리니치 천문대를 중심으로 얼마만큼의 시간이 떨어져 있는지를 나타내는 것이다. 이 때, 그리니치 천문대보다 동쪽에 있으면 (+)이고, 서쪽에 있으면 (-)이다. 우리나라는 일본과 함께 동경 표준시를 사용하고 있는데, 이 시간대는 그리니치 천문대로부터 동쪽으로 9시간 차이가 난다. 그러므로 우리나라의 time zone 표시는 +0900이 된다. time zone을 숫자로 표시하지 않고 기호로 표시할 수도 있는데 이는 다음과 같다.
    UT(+0000), GMT(+0000), EST(-0500), EDT(-0400), CST(-0600), CDT(-0500), MST(-0700), MDT(-0600), PST(-0800), PDT(-0700), A(-0100), B(-0200), C(-0300), D(-0400), E(-0500), F(-0600), G(-0700), H(-0800), I(-0900), K(-1000), L(-1100), M(-1200), N(+0100), O(+0200), P(+0300), Q(+0400), R(+0500), S(+0600), T(+0700), U(+0800), V(+0900), W(+1000), X(+1100), Y(+1200)

    RFC 822 시간 표준 포맷으로 이루어진 문자열을 시간 구조체로 바꿔주는 함수를 다음과 같이 작성할 수 있다. 아래 함수에서 hStrExplode()라는 함수가 있는데 이 함수는 주어진 문자열을 주어진 구분자로 분리해 문자열의 배열을 리턴하는 함수이다. 시간 표시를 콜론(:)으로 분리해내기 위해 사용했다. 독자여러분이 스스로 한번 작성해보기 바란다.

    리스트 2 : RFC 822 시간 표준 포맷으로 이루어진 문자열을 시간 구조체로 바꿔주는 함수
    struct tm *hRFC822strToTime(const char *str)

    static struct tm t;
    char date_str[7][4] = "Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat";
    char month_str[12][4] = "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec";
    char wday[4]; /* Mon, Tue .. Sun */
    char year[5]; /* 2000, 2001 .. */
    char month[4]; /* Jan, Feb .. Dec */
    char day[3]; /* 1..31 */
    char timeStr[9]; /* h:m:s */
    char zone[40];
    char z_hour[3], z_min[3];
    int i;
    char *temp = (char *)0x00;
    char **parseStr = (char **)0x00;

    /* 인수가 0x00이면 시스템 시간을 리턴한다. */
    if(str == 0x00)
    return localtime(time(0x00));

    if(isalpha(str[0])) /* str의 첫 문자가 알파벳이면 요일이 들어있다. */
    sscanf(str, "%s%s%s%s%s%s", wday, day, month, year, timeStr, zone);
    else
    sscanf(str, "%s%s%s%s%s", day, month, year, timeStr, zone);

    /* 요일 */
    if(isalpha(str[0])) /* str의 첫 문자가 알파벳이면 요일이 들어있다. */

    wday[3] = 0x00; /* 쉼표(,) 제거 */

    for(i=0;i<7;i++)
    if(!strcmp(date_str[i], wday))

    t.tm_wday = i;
    break;



    /* 년 */
    t.tm_year = (strlen(year) == 4) ? atoi(year) - 1900 : 2000 + atoi(year) - 1900;

    /* 월 */
    for(i=0;i<12;i++)
    if(!strcmp(month_str[i], month))

    t.tm_mon = i;
    break;


    /* 일 */
    t.tm_mday = atoi(day);

    /* 시간 */
    parseStr = hStrExplode(timeStr, ":");

    t.tm_hour = atoi(parseStr[0]);
    t.tm_min = atoi(parseStr[1]);
    t.tm_sec = parseStr[2] != 0x00 ? atoi(parseStr[2]) : 0; /* HH:MM[:SS] */

    /* timezone. GNU 버젼에서만 사용가능 */
    if(zone[0] == '+' || zone[0] == '-')

    strncpy(z_hour, &zone[1], 2);
    z_hour[3] = 0x00;
    strncpy(z_min, &zone[3], 2);
    z_min[3] = 0x00;

    t.tm_gmtoff = (atoi(z_hour) * 60 + atoi(z_min)) * 60;

    else if(isalpha(zone[0]) && zone[1] == 0x00)

    if(zone[0] >= 'A' && zone[0] < 'J')
    t.tm_gmtoff = ('A' - zone[0] - 1) * 60 * 60;
    else if(zone[0] > 'J' && zone[0] <= 'M')
    t.tm_gmtoff = ('A' - zone[0]) * 60 * 60;
    else if(zone[0] >= 'N' && zone[0] <= 'Y')
    t.tm_gmtoff = (zone[0] - 'N' + 1) * 60 * 60;
    else if(zone[0] == 'Z')
    t.tm_gmtoff = 0;

    else if(!strcmp(zone, "UT") || !strcmp(zone, "GMT"))
    t.tm_gmtoff = 0;
    else if(!strcmp(zone, "EST"))
    t.tm_gmtoff = (-5) * 60 * 60;
    else if(!strcmp(zone, "EDT"))
    t.tm_gmtoff = (-4) * 60 * 60;
    else if(!strcmp(zone, "CST"))
    t.tm_gmtoff = (-6) * 60 * 60;
    else if(!strcmp(zone, "CDT"))
    t.tm_gmtoff = (-5) * 60 * 60;
    else if(!strcmp(zone, "MST"))
    t.tm_gmtoff = (-7) * 60 * 60;
    else if(!strcmp(zone, "MDT"))
    t.tm_gmtoff = (-6) * 60 * 60;
    else if(!strcmp(zone, "PST"))
    t.tm_gmtoff = (-8) * 60 * 60;
    else if(!strcmp(zone, "PDT"))
    t.tm_gmtoff = (-7) * 60 * 60;
    else
    t.tm_gmtoff = 0;

    return &t;



    3.3 RFC 822 헤더에 사용되는 주석 표시방법
    RFC 822 헤더에도 주석을 사용할 수 있다. RFC 822 헤더의 주석은 괄호를 사용하여 표시한다. 다음이 그 예이다.

    To: hanyoul@netsarang.com (넷사랑 컴퓨터 연구원)

    3.4 RFC 822 본문
    RFC 822 본문은 RFC 822 헤더에 비해 너무나도 간단하다. RFC 822 헤더와 구분하기 위한 빈 줄(Null Line)의 다음 줄부터 끝 줄까지가 RFC 822 본문이다.

    3.5 SMTP와 RFC 822 메시지의 한계
    메일 시스템은 미국에서 개발되었고, 또한 메일 시스템은 텍스트의 전송을 그 목적으로 하였다. 이러한 이유로 메일 시스템의 개발 당시에는 영어의 전송만이 고려 대상이었다. 그러므로 아스키 문자 중에서 제어문자와 특수문자를 제외한 대소문자, 숫자, 기호만이 전송의 대상이었다. 즉, 아스키 문자 중에서 최상위 비트(8비트의 가장 첫 번째 비트)가 0인 문자만이 전송의 대상이었던 것이다. 이런 이유로 SMTP는 최상위 비트가 1인 문자의 전송을 고려하지 않았고, 실제로 많은 MTA들이 최상위 비트가 1인 문자는 전송을 허락하지 않았다. 그래서 SMTP를 7bit 전송이라고 이야기한다. 그런데 여기에서 문제가 생겨났다.
    7bit 전송시에 영어의 전송은 아무런 문제가 되지 않지만, 영어가 아닌 다른 언어(유럽어, 한국어, 중국어, 일본어)들은 최상위 비트가 1인 문자도 사용하여 자신들의 문자를 표현했기 때문에 기존의 SMTP로는 메일을 전송할 수가 없었던 것이다. 또한 메일의 이용이 증가하면서 메일을 통해 바이너리 파일들을 주고 받으려는 요구들이 늘어나게 되자 7bit 전송의 문제점이 지적되기 시작하였다.

    3.6 E/SMTP 의 등장
    이와 같은 7bit 전송의 문제점을 극복하기 위해 SMTP의 확장 프로토콜이 등장하게 되었다. 이것이 바로 E/SMTP이다. E/SMTP는 8bit 전송을 허락하는 프로토콜이다. 하지만 지구상의 많은 MTA들이 아직도 SMTP만을 지원하고 있기 때문에 E/SMTP를 통해 메일 메시지를 보내는 데에는 문제가 있었다.

    4. MIME의 개발
    이를 해결하기 위해 개발된 것이 바로 Multipurpose Internet Mail Extention(MIME)이다. MIME이란 8bit 데이터를 7bit로 바꾸어 전송하는 기법을 말한다. 송신측에서 8bit 데이터를 7bit로 바꾸면 수신측에서 7bit 데이터를 다시 원래의 8bit 데이터로 복원하는 것이다. 이렇게 되면 7bit만을 지원하는 MTA를 통해서 전송되는 메일도 깨지지 않고 전송될 수 있게 된다. MIME은 여기에서 한발 더 나아가 여러 개의 바이너리 파일을 하나의 메일 메시지에 담아서 전송할 수 있도록 해주고 있다.

    5. 마치며
    이번 호에서는 메일의 동작원리와 메일 시스템의 구성, 그리고 메일 메시지 형식에 대한 대략적인 설명을 하였다. 너무나도 방대한 내용을 한정된 지면에서 설명하려고 하니, 심도깊은 설명이 되지 않은 측면도 있어보인다. 조금 더 심도깊은 이해를 하고 싶은 독자는 직접 RFC 821 문서와 RFC 822 문서를 읽어보기를 강력히 추천한다. 필자의 글이 심도깊은 이해를 위한 첫 길잡이가 되었다면 더 바랄 것이 없다. 다음 회에서는 7bit 기반의 SMTP의 한계로 인해 전송할 수 없었던 바이너리 데이터와 영어가 아닌 언어의 전송을 위해 개발된 Multipurpose Internet Mial Extension(MIME)에 대해서 알아볼 것이다.

    반응형
    C로 구현하는 MIME Parser (2)

    MIME과 7bit 인코딩

    전자메일을 통해 바이너리 파일을 전송하거나, 다양한 민족국가의 언어를 전송하기 위해서는 8bit 전송이 필수적이다. 이번 호에서는 MIME의 개괄적 이해와 7bit 인코딩에 대한 내용을 알아보도록 하자.

    (주)넷사랑컴퓨터 조한열
    hanyoul@netsarang.com

    1. MIME

    MIME이란 그 말뜻 그대로, 다양한 목적을 위해 전자메일 메시지 형식을 확장시킨 것을 말한다. 지난 호에서도 언급했지만 미국에서 군사적인 목적으로 연구 개발되어 사용되던 인터넷이 전세계로 확산되면서 인터넷에 대한 다양한 요구들이 늘어나기 시작했다. 이러한 요구들 가운데에는 물론 전자메일을 그 대상으로 하는 것도 많았다. 그 중 대표적인 것이 전자메일을 통해 바이너리 파일을 주고 받으려는 것이다.
    또한 미국이 아닌 다른 여러 나라의 언어를 가지고도 전자메일을 주고 받으려면 MSB(Most Significant Bit - 가장 최상위 비트)가 1인 바이트들도 전자메일을 통해 깨지지 않고 전송이 되어야 한다는 말이다. 그러나 7bit 전송 프로토콜인 SMTP를 기반으로 하는 전자메일은 MSB가 1인 바이트를 0으로 바꾸어 전송하기도 한다. 이처럼 아직도 많은 메일 서버들이 8bit 전송을 깨뜨려버리는 SMTP를 사용하고 있기 때문에 8bit 전송을 위해서는 새로운 방법이 필요하게 되었다. 그 새로운 방법이 바로 MIME이다.

    1.1 MIME의 역할
    MIME의 역할은 의외로 간단하다. 어떤 정해진 규칙에 따라서 8bit 데이터를 7bit 데이터로 바꾸어 주는 기능과, 바뀌어진 7bit 데이터를 원래의 8bit 데이터로 그대로 복원하는 기능을 제공하는 일이다. 7bit와 8bit 사이의 변환을 정의해 놓은 표준규약 역시 MIME에 포함되어 있다.
    MIME의 중요한 역할은 또 하나 있다. 그것은 바로 메일 메시지에 여러 개의 파일들을 첨부해서 보낼 수 있도록 메일 메시지 형식을 정의해 놓은 것이다. 요즘은 흔하게 파일이 첨부된 메일을 받아볼 수 있다. 이것이 모두 MIME의 개발 덕분이다.

    1.2 Multi-part MIME message
    파일이 첨부된 메일을 Multi-part MIME message라고 한다. 첨부된 각각의 파일은 하나의 부분(part)을 이루고 있고, 이러한 부분들이 모여 하나의 메일 메시지를 형성하기 때문에 Multi-part MIME message라고 한다.

    1.3 RFC 822 헤더와 MIME 헤더
    지난 호에서 우리는 RFC 822 헤더에 관해 자세히 살펴보았다. RFC 822 헤더는 메일에 관한 정보를 알려주는 핵심적인 역할을 한다. MIME 헤더 역시 RFC 822 헤더와 마찬가지로 메일 형식에 관한 정보를 알려준다. MIME 헤더는 MIME의 규약에 따라 변환된 메일 메시지를 원래의 메시지로 재변환 시키는데 있어서 필요한 정보들을 제공한다. 즉, 어떤 방식으로 7bit 변환이 되었는지와 Multi-part MIME message를 파싱하기 위한 Boundary 정보들을 담고 있는 것이 MIME 헤더이다.
    MIME 헤더 역시 RFC 822 헤더와 같은 형식을 가지고 있다. 즉, 이름과 값의 쌍으로 이루어져 있으며, 이름과 값을 구분하는 구분자는 콜론(:)이다.
    대부분의 MIME 헤더는 Content-로 시작한다. MIME 헤더에는 다음과 같은 것이 있다.

    ·MIME-Version
    ·Content-Type
    ·Content-Transfer-Encoding
    ·Content-ID
    ·Content-Description
    ·Content-Disposition

    <박스>
    MIME 헤더의 예(역상부분이 MIME 헤더)
    From hanyoul@netsarang.com Fri Jul 7 15:37:22 2000
    Received: from HANYOUL (hanyoul.conux.com)
    by netsarang.com (8.9.3/8.9.3) with SMTP id PAA20996
    for <hanyoul@conux.com>; Fri, 7 Jul 2000 15:37:19 +0900
    From: "Cho Hanyoul" <hanyoul@netsarang.com>
    To: "=?ks_c_5601-1987?B?wbYgx9G/rQ==?=" <hanyoul@netsarang.com>
    Subject: Test
    Date: Fri, 7 Aug 2000 15:39:00 +0900
    Message-ID: <FPEEJIDNJIB.hanyoul@conux.com>
    MIME-Version: 1.0
    Content-Type: multipart/mixed;
    boundary="----=_NextPart_000_0009_01BFE829.78EE4E90"

    This is a multi-part message in MIME format.


    2. MIME 헤더 설명

    2.1 Content-Type
    Content-Type 헤더는 메일 메시지가 담고 있는 데이터가 어떤 종류의 데이터인지를 알려준다. MUA가 MIME을 해석할 때, Content-Type을 보고 이 메시지가 어떤 종류의 데이터인지를 알아야 디스플레이 해줄 수 있을 것이다. 예를 들어 Content-Type이 그림이라면 MUA는 메일 메시지가 담고 있는 데이터가 그림이라는 것을 인식하고 메일 메시지가 담고 있는 데이터를 읽어 들여 그림을 보여줄 것이고, 만약 Content-Type이 소리라면 메일 메시지의 데이터를 읽어 들여 소리를 출력하게 될 것이다. Content-Type은 text/plain, text/html, image/jpeg 등이 있다.

    text/plain, image/jpeg 등을 미디어 타입(media type)이라고 한다. Content-Type으로 지정될 수 있는 미디어 타입은 IANA라는 국제기구에 의해 미리 정의되어 있다. 미디어 타입은 주타입과 부타입으로 나누어진다. 주타입은 8개가 있으며 각각의 주타입마다 무수한 부타입이 있다. 미디어 타입은 주타입/부타입의 형식으로 이루어진다. 즉 image/jpeg 이라는 미디어 타입은 image라는 주타입과 jpeg이라는 부타입으로 이루어진 것이다.

    주타입 8가지는 다음과 같다.
    ·text
    ·image
    ·audio
    ·video
    ·application
    ·multipart
    ·message

    <박스>
    대표적인 Media Type
    Text/plain, text/html, text/xml, text/enriched
    image/gif, image/jpeg, image/tiff
    audio/basic, audio/32kadpcm
    video/mpeg, video/quicktime
    model/vrml, model/mesh
    application/octet-stream, application/zip, application/vnd.ms-excel
    multipart/mixed, multipart/alternative
    message/rfc822, message/news

    Content-Type 헤더는 몇가지 부가 필드를 가질 수도 있다. 이러한 부가 필드들은 세미콜론(;)에 의해 구분된다.
    만약 Content-Type이 text 미디어 타입이라면 charset(Character set - 문자세트)이라는 부가 필드를 가질 수 있다. 아래와 같은 Content-Type을 살펴보자.

    Content-Type: text/plain; charset=us-ascii

    이것은 메일 메시지가 Plain text(평범한 텍스트 데이터)라는 것을 나타내고 있으며 부가적으로 텍스트의 문자세트는 US ASCII, 즉 영문이라는 것을 나타내고 있다.
    또한, Content-Type이 multipart 미디어 타입이라면 boundary라는 부가 필드를 가질 수 있다. 이 boundary 필드는 대단히 중요하다. 미디어 타입이 multipart라는 것은 메일 메시지의 데이터가 하나의 단일한 데이터가 아니라 여러 개의 데이터가 모여 하나의 메시지를 구성한 것을 뜻한다. 앞서 언급했던 첨부파일을 포함한 메일이 여기에 속한다. MUA가 multipart로 구성된 메일 메시지를 파싱하여 각각의 데이터로 만들기 위해서는 어디서부터 어디까지가 각각의 데이터인지를 알아야 한다. 이것을 알려주는 것이 바로 boundary라는 부가 필드이다. boundary가 가리키는 문자열이 바로 데이터들을 구분해주는 경계선이 되기 때문에 boundary 부가 필드는 Multipart MIME message를 파싱하는데 없어서는 안 될 중요한 요소이다(multipart message에 대한 자세한 설명은 다음 호에 연재 할 예정이다).

    Content-Type: multipart/mixed;
    boundary="----=_NextPart_000_0009_01BFE829.78EE4E90"

    Content-Transfer-Encoding

    Content-Transfer-Encoding 헤더는 굉장히 중요한 MIME 헤더이다. 바로 8bit 데이터를 어떤 방식을 통해 7bit 데이터로 변환시켰는지를 알려주는 헤더이기 때문이다. 그러므로 Content-Transfer-Encoding 헤더의 정보가 잘못되었을 경우에는, 원래의 데이터를 잃게 되고 만다.
    Content-Transfer-Encoding 헤더의 값으로는 다음과 같은 것들이 올 수 있다.

    ·7bit
    ·8bit
    ·binary
    ·quoted-printable
    ·base64

    위의 값 중에서 7bit, 8bit, binary는 그 어떤 변환도 하지 않음을 말해준다. 그냥 메일을 보낸 시점의 데이터가 변환되지 않고 그대로 메일 메시지에 실려왔음을 말해주는 것이다. 7bit는 메일 메시지가 7bit임을 뜻하고, 8bit는 메일 메시지가 8bit 데이터임을 말해준다. binary는 메일 메시지가 text가 아닌 binary 데이터임을 뜻한다.
    우리가 눈여겨 봐야 할 것이 바로 quoted-printable과 base64라는 변환 방식이다. 이는 조금 후에 다시 자세히 설명한다. 이번 호에서 설명하고자 하는 핵심적인 내용이 바로 quoted-printable과 base64라는 변환 방식이다.

    * Content-Disposition
    : Content-Disposition 헤더는 현재의 데이터를 인라인(inline)으로 할 것인지, 아니면 첨부파일(attachment)로 할 것인지에 대한 것을 결정하는 헤더이다. 아직 실험적인 헤더이다.

    * Content-Description
    : Content-Description 헤더는 메일 메시지가 담고 있는 Content에 대한 설명을 해 놓는 헤더이다. 만약 MIME 이 해석이 되지 않을 때, 위의 필드를 보고 아래의 데이터가 무슨 데이터인지를 알 수 있도록 설명을 달아 놓으면 좋다.

    * Content-ID
    : Content-ID 헤더는 메일 메시지 외에 다른 Content를 가리킬 때 사용하지만, 잘 사용하지 않는다.

    2.2 MIME Encoding
    MIME의 역할 중 큰 역할이 바로 8bit 데이터를 7bit로 만드는 것이라고 앞서 언급했었다. 이러한 역할을 MIME Encoding이라고 한다. 앞서도 살펴봤듯이 MIME Encoding에는 두가지 방식이 있다. 바로 Quoted-Printable 방식과 Base64 방식이 그것이다.

    2.3 Quoted-Printable Encoding & Decoding
    Quoted-Printable Encoding 방식은 인코딩 된 메시지를 디코딩하지 않더라도 ASCII 문자들이 그대로 보일 수 있도록 하는 방식이다. 즉, 영문과 숫자등의 ASCII 7bit 문자들은 그대로 놔두고 8bit 문자만을 인코딩하는 방식이다. 이 때, 8bit 문자를 인코딩하는 방법은 대단히 간단하다. 8bit 문자는 등호(=) 뒤에 8bit 문자의 값을 16진수로 표현하여 써넣으면 된다. 이렇게 되면 모든 문자가 7bit 문자가 되어 메일 메시지 형태로 전송이 가능하다.
    Quoted-Printable Encoding 방식은 대부분이 7bit ASCII 문자들이고, 가끔씩 8bit 문자들이 나오는 text 메시지를 인코딩하는데 유리한 방식이다.


    2.4 Quoted-Printable Encoding 규칙
    다음은 RFC2045에서 정의한 Quoted-Printable Encoding 규칙이다.

    [규칙 1] 모든 옥텟(바이트)의 인코딩은 그 값을 16진수로 표현하여 '=' 뒤에 붙이면 된다. 옥텟의 값을 16진수로 표현하는데 사용되는 문자는 0123456789ABCDEF 이며, 대문자만을 사용한다. 예를 들어 십진수로 12인 옥텟(LF)을 Quoted-Printable로 인코딩하면 "=0C"가 되고, 십진수로 61인 옥텟(=)은 "=3D"가 되는 것이다. 뒤에 나오는 규칙 2-5에서 제시되고 있는 인코딩 방법을 사용하지 않는 모든 옥텟은 이 방식으로 인코딩 해야 한다.

    [규칙 2] 십진수 33 - 60, 62 - 126의 문자(제어코드와 십진수 61인 '='를 제외한 아스키 값)는 규칙 1에 따라 인코딩 되지 않아도 된다. 즉, 이스케이프 문자인 '='를 제외한 영문자와 숫자등은 그대로 표현된다.

    [규칙 3] 십진수 9(TAB)와 32(SPACE)는 절대로 인코딩 된 문자열의 끝에 나타나서는 않된다. 그러므로 문자열의 끝에 있는 Tab이나 Space는 규칙 1에 따라 인코딩 되어야 한다.

    [규칙 4] 줄바꿈 문자(컴퓨터에 따라 CR, LF, CRLF로 각기 달리 나타나는 문자)는 CRLF의 형태로 표현되어야 한다.(RFC822 정의)

    [규칙 5] Quoted-Printable로 인코딩 된 문자열의 길이는 76자 이상 이어서는 않된다. 이 때 문자열의 길이는 문자열 맨 뒤에 붙는 CRLF는 제외한 나머지 문자열의 길이를 말한다. 인코딩 되었을 때, 76자가 넘는 문자열에는 Soft Line Break를 사용한다. 즉 원래의 문자열에는 영향을 주지 않고 단지 인코딩 된 문자열의 줄바꿈만을 나타내는 문자를 덧붙이고 줄바꿈을 하여, 인코딩 된 문자열이 76자가 넘지 않도록 하는 것이다. Quoted-Printable에서는 '='을 Soft Line Break로 사용한다. Soft Line Break 뒤에는 Tab이나 Space가 나타날 수 있으며 규칙 3에 의한 인코딩 해서는 않된다. 그 이유는 몇몇 MTA들이 전송하는 원래의 문자열에 Tab이나 Space를 붙이기도 하고 빼기도 하는데, 이 때 덧붙여지는 Tab이나 Space는 원래의 데이터가 아니기 때문이다.

    위의 규칙을 적용하여 다음과 같은 문자열을 Quoted-Printable 방식으로 인코딩 해보자.

    Quoted-Printable Encoding 방식으로 인코딩하고 디코딩하는 함수를 작성해보자. 함수는 hQPencode(), hQPdecode()이다(리스트 1,2).

    리스트 1 : hQPencode()
    /**********************************************************************/
    /* */
    /* hQPencode() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : QP 방식으로 encode한다. */
    /* usage : QPencode(string pointer, line size, ptr of encoded length) */
    /* return: if Success, encoded string pointer */
    /* else if fail, NULL */
    /* */
    /**********************************************************************/
    char *hQPencode(char *str, int lineSize, int *len)
    {
    char *encodeStr = (char *)0x00;
    char *encodeHex;
    int i = 0, j = 0;
    int qpSizeCnt = 0;

    /* encoding된 문자열은 원래의 문자열에 대해 최대 3배가 된다. */
    encodeStr = (char *)malloc(sizeof(char)*(lineSize*3 + 1));

    for(i=0;i<lineSize;i++)
    {
    if(qpSizeCnt >= QP_SIZE - 3)
    qpSizeCnt = 0, encodeStr[j++] = 0x0A;

    if((str[i] >= 33 && str[i] <= 126) || (str[i] == 0x0A))
    {
    if(str[i] == 61)
    {
    encodeHex = hDec2Hex(str[i]);
    encodeStr[j++] = 0x3D; /* '=' */
    encodeStr[j++] = encodeHex[0];
    encodeStr[j++] = encodeHex[1];
    qpSizeCnt += 3;
    }
    else
    qpSizeCnt++, encodeStr[j++] = str[i];
    }
    else if(str[i] == 9 || str[i] == 32)
    {
    if(str[i+1] == 0x0A || str[i+1] == 0x00) /* 문자열 끝의 Tab, Space */
    {
    encodeHex = hDec2Hex(str[i]);
    encodeStr[j++] = 0x3D; /* '=' */
    encodeStr[j++] = encodeHex[0];
    encodeStr[j++] = encodeHex[1];
    qpSizeCnt += 3;
    }
    else
    qpSizeCnt++, encodeStr[j++] = str[i];
    }
    else
    {
    encodeHex = hDec2Hex(str[i]);
    encodeStr[j++] = 0x3D; /* '=' */
    encodeStr[j++] = encodeHex[0];
    encodeStr[j++] = encodeHex[1];
    qpSizeCnt += 3;
    }
    }

    encodeStr[j] = 0x00;
    return encodeStr;
    }


    리스트 2 : hQPdecode()
    /**********************************************************************/
    /* */
    /* hQPdecode() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : QP 방식으로 encoding된 string을 decode한다. */
    /* usage : QPdecode(encoded string pointer) */
    /* return: if Success, decoded string pointer */
    /* else if fail, NULL */
    /* */
    /**********************************************************************/
    char *hQPdecode(char *encodeStr, int *len)
    {
    char *decodeStr;
    char hex[3];
    char ch;
    int dec;
    int spaceAdded = 0;
    int i = 0, j = 0;

    decodeStr = (char *)malloc(sizeof(char) * (strlen(encodeStr) + 1));

    if(decodeStr == 0x00)
    return 0x00;

    while(encodeStr[i] != '\0')
    {
    if(encodeStr[i] == 0x3D) /* QP ESC seqeunce, '=' */
    {
    ch = encodeStr[++i]; /* white space를 체크하기 위해 미리 내다봄 */

    while(ch == 0x09 || ch == 0x20) /* '=' 다음에 따라오는 character가 Tab, space이면 건너뛴다. */
    spaceAdded = 1, ch = encodeStr[++i];

    if(spaceAdded == 1)
    {
    spaceAdded = 0;
    continue;
    }

    if(ch == 0x0A) /* '=' 다음에 LF가 있으면 soft line break임. encoded QP string은 한 라인에 76 characters만 허용 */
    {
    i++;
    continue;
    }

    hex[0] = encodeStr[i++];
    hex[1] = encodeStr[i++];
    hex[2] = '\0';

    dec = hHex2Dec(hex);

    if(dec < 0) /* decoding error */
    {
    /* error 발생시 그대로 출력하기 위해 메시지 복원 */
    decodeStr[j++] = 0x3D; /* '=' */
    decodeStr[j++] = hex[0];
    decodeStr[j++] = hex[1];
    }
    else
    decodeStr[j++] = dec;
    }
    else if(encodeStr[i] > 0x7E) /* encoding error */
    i++; /* ignore that character */
    else
    decodeStr[j++] = encodeStr[i++];
    }

    decodeStr[j] = '\0';

    if(len != 0x00)
    *len = j;

    return decodeStr;
    }

    3. Base64 Encoding & Decoding

    Base64 Encoding 방식은 바이너리 파일을 메일을 통해서 보내거나, Quoted-Printable 인코딩 방식이 부적합한 모든 메시지에 적용하는 방식이다. Base64 Encoding 방식은 이론적으로 무척 간단하다. 간단히 말해서 Base64 방식은 원래의 데이터 3바이트를 6bit씩 나누어 4바이트로 만드는 방식을 말한다. 3바이트, 즉 24bit는 6bit씩 나누면 4개가 나온다. 이렇게 해서 나온 4개의 6bit 값을 다음의 변환 테이블에서 각각 문자로 변환하는 것이 Base64 Encoding 방식의 핵심이다.
    표 1은 Base64 Encoding 방식에서 사용되는 변환 테이블이다.

    6bit 값
    변환값
    6bit값
    변환값
    6bit 값
    변환값
    6bit값
    변환값
    0
    A
    16
    Q
    32
    g
    48
    w
    1
    B
    17
    R
    33
    h
    49
    x
    2
    C
    18
    S
    34
    i
    50
    y
    3
    D
    19
    T
    35
    j
    51
    z
    4
    E
    20
    U
    36
    k
    52
    0
    5
    F
    21
    V
    37
    l
    53
    1
    6
    G
    22
    W
    38
    m
    54
    2
    7
    H
    23
    X
    39
    n
    55
    3
    8
    I
    24
    Y
    40
    o
    56
    4
    9
    J
    25
    Z
    41
    p
    57
    5
    10
    K
    26
    a
    42
    q
    58
    6
    11
    L
    27
    b
    43
    r
    59
    7
    12
    M
    28
    c
    44
    s
    60
    8
    13
    N
    29
    d
    45
    t
    61
    9
    14
    O
    30
    e
    46
    u
    62
    +
    15
    P
    31
    f
    47
    v
    63
    /
    표 1 : Base64 Encoding 방식에서 사용되는 변환 테이블

    하지만 실제는 데이터가 3바이트씩 나누어 떨어지는 것이 아니기 때문에 인코딩 된 메시지에 등호(=)를 가지고 패딩을 한다.
    실제 예를 통해 Base64 Encoding 방식을 이해해보도록 하자.

    10바이트의 변환되기 전의 문자열이 있고, 그것을 이진수로 표현하면 다음과 같다.
    0100101011100100100011010110001001011101
    이것을 6bit씩으로 나누어보자.
    010010 101110 010010 001101 011000 100101 1101
    맨 뒤의 숫자는 6bit가 되지 않으므로 뒤에 0을 붙여 6bit로 만든다.
    010010 101110 010010 001101 011000 100101 110100
    위의 6bit 숫자들을 10진수로 표현하면 다음과 같다.
    18 46 18 13 24 37 52
    이들 10진수를 위의 변환 테이블을 이용해서 문자로 바꾸어 보자.
    S u S N Y l 0
    위의 문자열의 길이가 4로 나누어 떨어지도록 문자열의 뒤에 등호(=)를 붙인다(패딩).
    S u S N Y l 0 =
    이렇게 해서 인코딩된 마지막 결과는 SuSNYl0= 이 된다.

    다음에는 Base64 방식으로 인코딩, 디코딩을 하는 함수인 hBASE64encode(), hBASE64decode() 함수를 작성해보도록 하자.

    리스트 3 : hBASE64encode()
    /**********************************************************************/
    /* */
    /* hBASE64encode() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : 문자열을 BASE64 방식으로 encoding 한다. */
    /* usage : BASE64encode(string pointer) */
    /* return: if Success, encoded string pointer */
    /* else if fail, NULL */
    /* */
    /**********************************************************************/
    char *hBASE64encode(char *str, int lineSize, int *len)
    {
    char *encodeStr = (char *)0x00;
    int i = 0, j = 0;
    int count = 0;
    int ch;
    int base64SizeCnt = 0;

    if(str == 0x00)
    return 0x00;

    encodeStr = (char *)malloc(sizeof(char)*(lineSize*2));

    while(TRUE)
    {
    switch(count++)
    {
    case 0:
    if(i < lineSize)
    ch = (str[i] & 0xFC) >> 2;
    else
    ch = -1;
    break;

    case 1:
    if(i < lineSize)
    if(i+1 < lineSize)
    ch = ((str[i] & 0x03) << 4) | ((str[i+1] & 0xF0) >> 4);
    else
    ch = ((str[i] & 0x03) << 4);
    else
    ch = -1;
    i++;
    break;

    case 2:
    if(i < lineSize)
    if(i+1 < lineSize)
    ch = ((str[i] & 0x0F) << 2) | ((str[i+1] & 0xC0) >> 6);
    else
    ch = ((str[i] & 0x0F) << 2);
    else
    ch = -1;
    i++;
    break;

    case 3:
    if(i < lineSize)
    ch = (str[i] & 0x3F);
    else
    ch = -1;
    i++;
    count = 0;
    break;
    }


    if(ch >= 0 && ch <= 25) /* Upper Case Alphabet */
    encodeStr[j++] = 'A' + ch;
    else if(ch >= 26 && ch <= 51) /* Lower Case Alphabet */
    encodeStr[j++] = 'a' + ch - 26;
    else if(ch >= 52 && ch <= 61) /* Digit */
    encodeStr[j++] = '0' + ch - 52;
    else if(ch == 62)
    encodeStr[j++] = '+';
    else if(ch == 63)
    encodeStr[j++] = '/';
    else if(ch == -1)
    encodeStr[j++] = '='; /* padding */

    base64SizeCnt++;

    if(j%4 == 0)
    {
    if(base64SizeCnt == BASE64_SIZE)
    base64SizeCnt = 0, encodeStr[j++] = 0x0A; /* soft break */

    if(i >= lineSize)
    break;
    }
    }

    encodeStr[j] = 0x00;

    if(len != 0x00)
    *len = j;

    return encodeStr;
    }

    리스트 4 : hBASE64decode()
    /**********************************************************************/
    /* */
    /* hBASE64decode() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : BASE64 방식으로 encoding된 string을 decode한다. */
    /* usage : BASE64decode(encoded string pointer) */
    /* return: if Success, decoded string pointer */
    /* else if fail, NULL */
    /* */
    /**********************************************************************/
    char *hBASE64decode(char *encodeStr, int *len)
    {
    char *decodeStr;
    long btmp = 0; /* 4byte (decoding 용) */
    int i = 0, j = 0;
    int count = 0;
    int padCount = 0;

    /* decoded string을 위한 메모리를 할당한다. */
    /* 실제로는 encodeStr length의 3/4 만큼만 잡으면 된다. */
    decodeStr = (char *)malloc(sizeof(char) * strlen(encodeStr));

    if(decodeStr == 0x00)
    return 0x00;

    while(encodeStr[i] != '\0')
    {
    if(isupper(encodeStr[i]))
    btmp = (btmp << 6) | (encodeStr[i] - 'A' ); /* 대문자는 0 - 25까지 */
    else if(islower(encodeStr[i]))
    btmp = (btmp << 6) | (encodeStr[i] - 'a' + 0x1A); /* 소문자는 26(0x1A) - 51까지 */
    else if(isdigit(encodeStr[i]))
    btmp = (btmp << 6) | (encodeStr[i] - '0' + 0x34); /* 숫자는 52(0x34) - 61까지 */
    else if(encodeStr[i] == '+')
    btmp = (btmp << 6) | 0x3E; /* '+'는 62(0x3E) */
    else if(encodeStr[i] == '/')
    btmp = (btmp << 6) | 0x3F; /* '/'는 63(0x3F) */
    else if(encodeStr[i] == '=')
    padCount++, btmp = (btmp << 6); /* '='는 pad */
    else
    btmp = (btmp << 6); /* encoding error */

    if(++count >= 4) /* 한 transaction이 끝났으면 */
    {

    decodeStr[j++] = (char)((btmp & 0x00FF0000) >> 16);
    decodeStr[j++] = (char)((btmp & 0x0000FF00) >> 8);
    decodeStr[j++] = (char)((btmp & 0x000000FF) );

    count = 0;
    btmp = 0;

    if(encodeStr[i+1] == 0x0A) /* soft linebreak */
    i++;
    }

    i++;
    }

    decodeStr[j - padCount] = '\0';

    if(len != 0x00)
    *len = j - padCount;

    return decodeStr;
    }

    /**********************************************************************/
    /* */
    /* hHex2Dec() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : hex를 dec로 전환 */
    /* usage : hHex2Dec(hex number e.g. "3D" or "3d") */
    /* return: if Success, dec number */
    /* else if fail, -1 */
    /* */
    /**********************************************************************/
    int hHex2Dec(char *str)
    {
    int dec = 0;
    int byte;
    int i;

    for(i=0;i<2;i++)
    {
    if(str[i] >= '0' && str[i] <= '9')
    byte = str[i] - '0';
    else if(str[i] >= 'A' && str[i] <= 'F')
    byte = str[i] - 'A' + 10;
    else if(str[i] >= 'a' && str[i] <= 'f')
    byte = str[i] - 'a' + 10;
    else
    byte = -1;

    if(byte < 0)
    return -1;

    dec += (i == 0) ? byte << 4 : byte;
    }

    return dec;
    }

    /**********************************************************************/
    /* */
    /* hDec2Hex() */
    /* */
    /* ------------------------------------------------------------------ */
    /* desc : dec를 hex로 전환 */
    /* usage : hDec2Hex(dec number) */
    /* return: if Success, str represented hex number */
    /* else if fail, NULL */
    /* */
    /**********************************************************************/
    char *hDec2Hex(int dec)
    {
    static char hex[3];
    int i;
    int ch;

    for(i=0;i<2;i++)
    {
    if(i == 0)
    ch = (dec & 0xF0) >> 4;
    else if(i == 1)
    ch = (dec & 0x0F);

    if(ch >= 10)
    hex[i] = 'A' + ch - 10;
    else
    hex[i] = '0' + ch;
    }

    hex[i] = 0x00;

    return &hex[0];
    }

    이번호에는 MIME의 전반부에 해당하는 MIME Encoding에 대하여 살펴보았다. 다음 호에는 MIME 메시지를 구성(Compose)하고 구성된 MIME 메시지를 해석(Parse)하는 방법에 대하여 알아보도록 하겠다.
    반응형
    C로 구현하는 MIME Parser (3)

    MIME Message를 이해하자.

    이번 호에서는 지난 호에서 살펴보았던 내용들을 토대로 MIME Message를 어떻게 구성하는지, 또 구성된 MIME Message에서 어떻게 우리가 원하는 데이터를 끄집어내는지에 대해서 자세히 살펴볼 계획이다. 이번 호가 끝나면 MIME Parser를 구현하기 위한 기본 내용을 모두 숙지하게 된다. 이를 기초로 하여 마지막 호인 다음 호에서는 실제로 MIME Parser를 구현해 보도록 하자.

    (주)넷사랑컴퓨터 조한열
    hanyoul@netsarang.com


    1. 제일 간단한 MIME Message

    리스트 1의 메일 메시지는 MIME Message이다. 왜? 이유는 단 한가지이다. MIME-Version이란 MIME 헤더가 RFC822 헤더(From, To, Subject)와 함께 들어있기 때문이다.
    메일 메시지 헤더에 MIME-Version이란 헤더가 들어있다면 메일 메시지는 MIME Message로 해석이 되어야 한다. 그렇다면 리스트 1의 메일 메시지를 MIME Message로 간주하고 해석해보도록 하자.

    리스트 1 : MIME Message 예
    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: The Simplest MIME Message
    MIME-Version: 1.0

    Hi! Everybody.
    Thank you

    MIME Message를 해석하기 위해서 없어서는 안될 MIME 헤더는 2가지가 있다. 바로 Content-Type과 Content-Transfer-Encoding이다. 지난 호를 유심히 살펴본 독자라면 이미 이 두가지 헤더의 의미를 알겠지만 다시 한 번 이 두가지 헤더에 대한 의미를 간략히 살펴보자.
    Content-Type 헤더는 MIME Message가 포함하고 있는 내용 혹은 데이터(Content)가 어떤 타입인지를 가리키는 헤더이다. Plain text이나 GIF image등의 타입이 여기에 기술되어 있다. 만약 Content-Type이 텍스트라면 Content-Type에는 부가 정보로 Character Set에 대한 정보가 기술되어 있다.
    Content-Transfer-Encoding 헤더는 8bit 데이터를 어떤 방식을 통해 7bit 데이터로 변환시켰는지를 알려주는 헤더이다. Content-Transfer-Encoding 헤더의 값으로는 7bit, 8bit, binary, quoted-printable, base64등이 있다. 위의 값 중에서 7bit, 8bit, binary는 그 어떤 변환도 하지 않음을 말해준다. 그냥 메일을 보낸 시점의 데이터가 변환되지 않고 그대로 메일 메시지에 실려왔음을 말해주는 것이다.
    이제 리스트 1의 MIME Message를 다시 한 번 살펴보자. 그러나, MIME-Version이라는 헤더만 있을 뿐, Content-Type이나 Content-Transfer-Encoding 헤더는 보이지 않는다. 그렇다면 MIME Message를 어떻게 해석할 수 있을까. 정답은 간단하다. Content-Type 헤더가 없을 때에는 Content-Type이 plain text라고 간주하면 된다. 또한 이 Content의 character set은 US-ASCII(일반 영자)라고 생각하면 된다. Content-Transfer-Encoding 헤더가 없을 때에는 기본적으로 7bit 인코딩으로 간주된다. 따라서 위의 간단한 MIME Message는 리스트 2와 동일하다.

    리스트 2 : 7bit로 간주된 MIME Message
    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: The Simplest MIME Message
    MIME-Version: 1.0
    Content-Type: text/plain; charset=us-ascii
    Content-Transfer-Encoding: 7but

    Hi! Everybody.
    Thank you

    그러므로 리스트 1의 MIME Message의 Content는 데이터변환이 없는(7bit 인코딩이니까) 영문으로 된 텍스트(text/plian; charset=us-ascii)라는 것을 알 수 있다.

    이번엔 조금 복잡한 MIME Message를 살펴보자. 역상 부분이 MIME 헤더라는 것은 이제 너무도 쉽게 알 수 있을 것이다. 리스트 3의 MIME Message에 대한 설명을 보기 전에 독자 여러분이 스스로 MIME Message를 MIME 헤더 정보(역상부분)를 이용하여 해석해 보는 것도 좋을 것이다.

    리스트 3 : MIME Message
    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: JPEG image file
    MIME-Version: 1.0
    Content-Type: image/jpeg
    Content-Transfer-Encoding: base64

    /9j/4AAQSkZJRgABAgAAZABkAAD//gASQWRvYmUgSW1hZ2VSZWFkef/sABFEdWNreQABAAQAAAAu
    AAD/7gAOQWRvYmUAZMAAAAAB/9sAhAAKBwcHBwcKBwcKDgkICQ4QDAoKDBATDw8QDw8TEg4QDw8Q
    DhISFRYXFhUSHR0fHx0dKSkpKSkvLy8vLy8vLy8vAQoJCQoLCg0LCw0QDQ4NEBQODg4OFBcPDxEP
    DxcdFRISEhIVHRocFxcXHBogIB0dICAoKCYoKC8vLy8vLy8vLy//wAARCADUARsDASIAAhEBAxEB
    /8QAqgAAAgIDAQAAAAAAAAAAAAAAAAECBQMEBgcBAAEFAQEAAAAAAAAAAAAAAAABAgQFBgMHEAAB
    AwIFAgQEBAQEBQQDAAABABECIQMxQRIEBVFhcYEiBpGhMhOxwUIU0eFSI/DxFQdicpIzFoKiwlNU
    NRcRAAEDAgQCBwYEBQUAAAAAAAEAAgMRBCExQRJxBVFhgZEiMhOhwdFCUhSxckMV8OHxMwZigsIj
    JP/aAAwDAQACEQMRAD8A5wFsii2SLhBpV1mk0Q8pae5KwCQne1ROoHA5KEcitjzED0W447x71ebM
    kgMa5KW6


    아마도 지난 호의 내용과 앞에서 설명한 내용을 잘 이해했다면 무리없이 해석할 수 있을 것이다. 아직 이해가 되지 않는 독자들을 위해 리스트 3의 MIME Message를 해석해보자.

    'MIME-Version: 1.0' 이라는 헤더를 통해 우리는 위의 메일을 MIME Message라고 간주해야 한다. Content-Type이 image/jpeg이므로 MIME Message가 포함하고 있는 Content는 jpeg형식의 image 파일이다. image 파일은 binary 파일이므로 8bit 데이터이다. 8bit 데이터를 7bit 데이터로 변환하여 메일을 전송하는 것이 안전하기 때문에(이유는 1회에서 설명하였다.) 7bit로 인코딩했다는 것을 예측할 수 있다. 그러므로 Content-Transfer-Encoding이 무엇인지를 살펴봐야 한다. Content-Transfer-Encoding 헤더는 인코딩 방식이 base64 방식임을 말해주고 있다. 즉, MIME Message가 포함하고 있는 Content는 base64 방식으로 인코딩되어 있기 때문에, 원래의 데이터를 얻기 위해서는 base64 방식에 의거하여 인코딩되 Content를 디코딩해야 한다.

    /9j/4AAQSkZ........ 로 시작되는 부분이 바로 Content인데, 이것이 base64 방식으로 인코딩 된 것이다. 이를 지난 호에서 설명한 base64 방식에 의거하여 디코딩하면 원래의 jpeg image를 얻을 수 있는 것이다.

    2. Multipart MIME Message

    만약에 메일을 통해 위에서 보았던 jpeg image를 보내면서 이 image 파일에 대한 설명을 같은 메일에 함께 보내려면 어떻게 하면 될까? 이럴 때는 Multipart MIME Message를 만들면 된다. 처음 들어보는 말이라고 해서 너무 어렵게 생각할 필요는 없다. Multipart MIME Message란 두 개 이상의 Content를 하나의 MIME Message에 붙여넣는 것이다. 이 때, 각 Content 마다 MIME 헤더를 붙이고 난 후 각 Content를 구분할 수 있는 경계선을 표시해 넣는 것이다. 말로 설명하면 더욱 어려워질 것 같으니, 직접 Multipart MIME Message를 살펴보도록 하자.

    리스트 4의 Multipart MIME Message는 image 파일과 image 파일에 대한 설명을 담은 text를 함께 묶어 구성한 것이다.

    리스트 4 : Multipart MIME Message
    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: JPEG image file
    MIME-Version: 1.0
    Content-Type: multipart/mixed;
    boundary="MIME-boundary1--------DC128F5642CA"

    --MIME-boundary1--------DC128F5642CA
    Content-Type: text/plain; charset=us-ascii

    Hi!
    I will send you a good image. This image is my favorite picture.

    --MIME-boundary1--------DC128F5642CA
    Content-Type: image/jpeg
    Content-Transfer-Encoding: base64

    /9j/4AAQSkZJRgABAgAAZABkAAD//gASQWRvYmUgSW1hZ2VSZWFkef/sABFEdWNreQABAAQAAAAu
    AAD/7gAOQWRvYmUAZMAAAAAB/9sAhAAKBwcHBwcKBwcKDgkICQ4QDAoKDBATDw8QDw8TEg4QDw8Q
    DhISFRYXFhUSHR0fHx0dKSkpKSkvLy8vLy8vLy8vAQoJCQoLCg0LCw0QDQ4NEBQODg4OFBcPDxEP
    DxcdFRISEhIVHRocFxcXHBogIB0dICAoKCYoKC8vLy8vLy8vLy//wAARCADUARsDASIAAhEBAxEB
    /8QAqgAAAgIDAQAAAAAAAAAAAAAAAAECBQMEBgcBAAEFAQEAAAAAAAAAAAAAAAABAgQFBgMHEAAB
    AwIFAgQEBAQEBQQDAAABABECIQMxQRIEBVFhcYEiBpGhMhOxwUIU0eFSI/DxFQdicpIzFoKiwlNU
    NRcRAAEDAgQCBwYEBQUAAAAAAAEAAgMRBCExQRJxBVFhgZEiMhOhwdFCUhSxckMV8OHxMwZigsIj
    JP/aAAwDAQACEQMRAD8A5wFsii2SLhBpV1mk0Q8pae5KwCQne1ROoHA5KEcitjzED0W447x71ebM
    kgMa5KW6

    --MIME-boundary1--------DC128F5642CA--

    먼저 Content-Type을 살펴보면 multipart/mixed라는 것을 알 수 있다. 주 카테고리인 multipart는 위의 메일이 Multipart MIME Message 포맷이라는 것을 나타내고 있다. 또한 multipart의 부 카테고리는 Content 간의 관계를 나타내는 것이 일반적인데, 여기서는 부 카테고리가 mixed임을 알 수 있다. mixed는 multipart로 묶여진 Content들이 서로 독립적이지만 Content들의 순서에 의미가 있을 때 사용한다.
    Content-Type이 multipart일 때는 Content-Type의 부가정보인 boundary가 무엇보다도 중요하다. Content-Type의 부가정보인 boundary는 Content들을 구분하는 구분경계를 표시하는 것이기 때문이다. boundary 정보가 있어야만 올바르게 Multipart MIME Message를 원래의 데이터들로 파싱하는 것이 가능하다는 것은 당연할 것이다.
    boundary는 메일 메시지에 등장하지 않을 만한 문자열로 만들어야 한다. 만약 Content 내용에 boundary에 해당하는 문자열이 들어있어 엉뚱하게 파싱이 되는 것을 막기 위해서이다.
    실제로 boundary 문자열을 이용하여 경계를 표시할 때는 하이픈(-)을 덧붙이는 것이 규칙이다. boundary 문자열 앞에 하이픈(-) 두 개를 덧붙여 Content의 경계를 표시한다. 리스트 4의 메일에서 boundary가 "MIME-boundary1--------DC128F5642CA"(따옴표는 제외)이므로 경계를 표시하기 위한 실제 문자열은

    "--MIME-boundary1--------DC128F5642CA"(따옴표 제외)

    가 된다. 어떤 줄의 첫 시작이 "--MIME-boundary1--------DC128F5642CA"라면 그 다음 줄부터는 새로운 Content이다.
    boundary 문자열의 앞뿐만 아니라 뒤에도 하이픈(-)이 붙은 문자열이 존재하는 데, 이는 Multipart MIME Message의 끝을 나타낸다. boundary 문자열의 앞뒤에 하이픈(-)이 두 개씩 붙은 문자열은 그 문자열의 뒤로는 Content가 더 이상 없다는 것을 뜻한다. 위의 메일에서 맨 마지막 줄을 보면 "--MIME-boundary1--------DC128F5642CA--"(따옴표 제외)가 있는데, boundary 문자열인 "MIME-boundary1--------DC128F5642CA"(따옴표 제외)의 앞뒤에 하이픈(-)이 두 개씩 붙어있음을 알 수 있고, 이 문자열을 끝으로 Multipart MIME Message가 끝난다는 것을 알 수 있다.
    --boundary 문자열과 그 다음에 나오는 --boundary 문자열(혹은 --boundary-- 문자열) 사이에 있는 것이 하나의 MIME Content라는 것은 수차에 걸쳐 이야기했다. 이 MIME Content는 처음에 살펴본 간단한 MIME Message와 마찬가지로 헤더와 데이터로 구성되어 있다. MIME Content의 헤더가 데이터의 정보를 나타내고 있다는 것을 이제는 쉽게 알 수 있을 것이다. 헤더와 데이터는 하나의 빈 줄로 구분된다. 헤더가 나오고 한 줄이 비어지고 그 다음에 데이터가 나오는 것이다. 위의 메일에는 모두 두 개의 Content가 있는데, 첫 번째 Content의 Content-Type은 text/plain이면 두 번째 Content-Type은 image/jpeg라는 것을 알 수 있다. 또한 위의 첫 번째 Content의 헤더에 Content-Transfer-Encoding이 없으므로 디폴트 값인 7bit 인코딩이 되었음을 알 수 있고, 두 번째 Content는 base64 방식으로 인코딩 되어있음을 알 수 있다.
    그런데 여기에서 의문이 생길 수가 있다. 어디가 Content의 마지막인가 이다. 무슨 말인지 의아하겠지만 다음을 한 번 살펴보자. 아래는 위 Multipart MIME Message의 한 부분이다. 첫 번째 Content가 끝나고 두 번째 Content를 구분하기 위한 --boundary 문자열이 나타나는 곳이다.

    Hi!
    I will send you a good image. This image is my favorite picture.
    [CRLF]
    --MIME-boundary1--------DC128F5642CA
    Content-Type: image/jpeg

    역상부분으로 나타낸 [CRLF](줄바꿈)표시는 과연 첫 번째 Content에 포함되는가, 아닌가 하는 질문에 대답해보자.
    text 기반의 데이터라면 그것이 그렇게 중요하지 않을 수도 있지만 만약 binary 데이터라면 [CRLF]가 데이터에 포함되는지 포함되지 않는지는 무척 중요한 문제이다.
    MIME을 설명하고 있는 RFC문서에서는 --boundary 문자열(혹은 --boundary-- 문자열) 앞에 붙어있는 [CRLF] 문자를 --boundary 문자열(혹은 --boundary-- 문자열)에 속한 것으로 정의하고 있다. 즉 Content 데이터에 포함되지 않는다는 것이다.

    3. Multipart Content를 내부에 포함한 Multipart MIME Message

    Multipart MIME Message 안에 들어있는 Content가 또 다시 Multipart MIME Message가 될 수도 있다. 계속 재귀적으로 multipart가 중첩된 구조를 가질 수 있다는 것이다.

    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: JPEG image file
    MIME-Version: 1.0
    Content-Type: multipart/mixed;
    boundary="MIME-boundary1--------DC128F5642CA"

    --MIME-boundary1--------DC128F5642CA
    Content-Type: text/plain; charset=us-ascii

    This message include another multipart message.

    --MIME-boundary1--------DC128F5642CA
    Content-Type: multipart/mixed;
    boundary="MIME-boundary2--------DC128F5642CA"

    --MIME-boundary2--------DC128F5642CA
    Content-Type: text/plain; charset=us-ascii

    Hi!
    I will send you a good image. This image is my favorite picture.

    --MIME-boundary2--------DC128F5642CA
    Content-Type: image/jpeg
    Content-Transfer-Encoding: base64

    /9j/4AAQSkZJRgABAgAAZABkAAD//gASQWRvYmUgSW1hZ2VSZWFkef/sABFEdWNreQABAAQAAAAu
    AAD/7gAOQWRvYmUAZMAAAAAB/9sAhAAKBwcHBwcKBwcKDgkICQ4QDAoKDBATDw8QDw8TEg4QDw8Q
    DhISFRYXFhUSHR0fHx0dKSkpKSkvLy8vLy8vLy8vAQoJCQoLCg0LCw0QDQ4NEBQODg4OFBcPDxEP
    DxcdFRISEhIVHRocFxcXHBogIB0dICAoKCYoKC8vLy8vLy8vLy//wAARCADUARsDASIAAhEBAxEB
    /8QAqgAAAgIDAQAAAAAAAAAAAAAAAAECBQMEBgcBAAEFAQEAAAAAAAAAAAAAAAABAgQFBgMHEAAB
    AwIFAgQEBAQEBQQDAAABABECIQMxQRIEBVFhcYEiBpGhMhOxwUIU0eFSI/DxFQdicpIzFoKiwlNU
    NRcRAAEDAgQCBwYEBQUAAAAAAAEAAgMRBCExQRJxBVFhgZEiMhOhwdFCUhSxckMV8OHxMwZigsIj
    JP/aAAwDAQACEQMRAD8A5wFsii2SLhBpV1mk0Q8pae5KwCQne1ROoHA5KEcitjzED0W447x71ebM
    kgMa5KW6

    --MIME-boundary2--------DC128F5642CA--

    --MIME-boundary1--------DC128F5642CA--

    위에서 역상부분이 중첩된 Multipart MIME Message이다. 중첩된 Multipart MIME Message는 새로운 boundary 문자열(MIME-boundary2--------DC128F5642CA)을 가지고 있다. 그러므로 "--MIME-boundary2--------DC128F5642CA--"(따옴표 제외)로 끝날 때까지가 하나의 Content인 것이다.
    이런 중첩된 구조는 multipart뿐만이 아니라 multipart를 포함한 메일 메시지 자체(message/rfc822)를 포함할 수도 있다. 이는 메일 서버가 서버 관리자에게 에러를 보고할 때 에러가 난 메일 자체를 포함시킨 에러 보고 메일(Error Reporting Mail)을 보낼 때 자주 사용된다.

    4. 눈여겨봐야할 MIME 타입

    4.1 multipart/mixed

    기본적인 multipart 타입이 바로 multipart/mixed이다. multipart/mixed 타입은 multipart로 묶여진 Content들이 서로 독립적임을 나타낸다. 하지만 Content들이 들어있는 순서에 의미가 있다. 화일을 첨부하여 메일을 보낼 때 주로 사용되는데, 이 때 처음에 들어있는 Content가 메일 본문이고 다음부터 나오는 Content들이 첨부화일이다. 메일 본문과 첨부화일의 순서처럼 순서에 의미가 있는 경우에 사용하는 것이 multipart/mixed 타입이다. 또한 부 카테고리가 어떤 타입인지를 모르는 multipart 타입은 모두 multipart/mixed로 해석한다.

    4.2 multipart/alternative

    multipart/alternative 타입은 multipart로 묶여진 Content들이 서로 같은 내용을 담고 있지만 표현 형식이 다를 때 사용한다. 예를 들어 메일을 보낼 때, html 형식으로 보내고 싶지만 상대방 메일 클라이언트가 html을 지원하지 않는 프로그램일지도 모른다는 걱정이 들 때 multipart/alternative 타입을 사용한다. 아래의 메일을 보자.

    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: alternative multipart example
    MIME-Version: 1.0
    Content-Type: multipart/alternative;
    boundary=boundary1234

    --boundary1234
    Content-Type: text/plain; charset=us-ascii

    ...여기에는 plain text 형식의 메시지가 들어간다...

    --boundary42
    Content-Type: text/html

    ...여기에는 위의 메시지와 같은 내용을 가지고 있지만, 형식이 html인 메시지가 들어간다...

    --boundary42--

    위의 예에서 살펴보면 같은 내용의 메시지를 text/plain 형식과 text/html 형식으로 만들어서 함께 보냈음을 알 수 있다. 이 때, 받는 사람의 메일 클라이언트가 html을 지원하면 두 번째 Content를 보여주고, html을 지원하지 않는다면 첫 번째 Content를 보여주게 된다. 여기서 하나 더 알아두어야 할 것은 multipart/alternative 타입에서도 Content들 간의 순서가 중요하다는 것이다. 보내는 사람이 더 선호하는 형식을 뒤에 놓게 되어 있다. 위의 예에서 보내는 사람이 text/html 형식을 text/plain 보다 뒤에 배치했기 때문에 받는 사람은 보낸느 사람이 html 형식으로 보기를 바란다는 것을 알 수 있는 것이다. 그러므로 multipart/alternative 타입으로 묶여진 모든 Content들의 형식을 받는 메일 프로그램이 모두 다 알고 있을 때에는 맨 마지막 Content를 보여주면 되는 것이다.

    4.3 multipart/digest

    multipart/digest 타입은 multipart/mixed 타입과 거의 차이가 없지만. multipart로 묶여진 Content들이 Content-Type 헤더를 갖고 있지 않을 때의 디폴트 타입이 text/plain이 아니라 message/rfc822 타입이라는 차이점이 있다.

    4.4 multipart/parallel

    multipart/parallel 타입은 multipart/mixed 타입과 똑같은 타입이지만, multipart로 묶여진 Content들 간의 순서에 별 의미가 없다는 것에 차이점이 있다.

    4.5 multipart/report

    multipart/report 타입은 메일 서버가 메일 에러 보고를 할 때 사용하는 타입이다. 만약 어떤 사용자가 메일을 보냈는데, 받는 사람의 메일 주소가 틀려서(없는 메일 주소일 때 등등) 메일을 보낼 수가 없다면 메일 서버는 메일을 보낸 사용자에게 메일을 보낼 수 없다는 에러 보고 메일을 보내게 된다. 이 때 보내는 에러 보고 메일의 형식은 RFC 1892에서 정의되고 있다. 에러 보고 메일은 2개의 꼭 필요한 Content와 그 외의 부가적인 Content를 multipart로 묶어 구성한다. 첫 번째 Content(꼭 필요함)는 에러 원인에 대한 메시지를 담고 있다. 이 메시지는 사람이 쉽게 읽을 수 있어야 한다. 첫 번째 Content가 multipart/alternative로 구성될 수도 있다. 두 번째 Content(꼭 필요함)는 컴퓨터(메일 처리 프로그램)가 알아보기 쉬운 에러 원인에 대한 메시지이다. 컴퓨터가 쉽게 파싱할 수 있어야 하며 파싱된 정보를 통해 컴퓨터가 에러 원인을 정확히 알 수 있어야 한다. 그래야 자동 에러 처리가 가능하니까. 리스트 5는 multipart/report 의 예이다.

    리스트 5 : multipart/report 의 예
    Date: Thu, 7 Sep 2000 18:18:35 +0900
    From: Mail Delivery Subsystem <MAILER-DAEMON@netsarang.com>
    Message-Id: <200009070918.SAB04287@netsarang.com>
    To: hanyoul@netsarang.com
    MIME-Version: 1.0
    Content-Type: multipart/report; report-type=delivery-status;
    boundary="SAB04287.968318315/netsarang.com"
    Content-Transfer-Encoding: 8bit
    Subject: Returned mail: User unknown
    Auto-Submitted: auto-generated (failure)

    --SAB04287.968318315/netsarang.com

    The original message was received at Thu, 7 Sep 2000 18:18:35 +0900
    from hanyoul@localhost

    ----- The following addresses had permanent fatal errors -----
    badman@badhost.com

    ----- Transcript of session follows -----
    550 badman@badhost.com... User unknown
    554 /dead.letter... cannot open /dead.letter: Permission denied

    --SAB04287.968318315/netsarang.com
    Content-Type: message/delivery-status

    Reporting-MTA: dns; netsarang.com
    Arrival-Date: Thu, 7 Sep 2000 18:18:35 +0900

    Final-Recipient: RFC822; badman@badhost.com
    Action: failed
    Status: 5.1.1
    Last-Attempt-Date: Thu, 7 Sep 2000 18:18:35 +0900

    --SAB04287.968318315/netsarang.com--

    4.6 message/external-body

    만약 어떤 사람이 20M나 되는 MP3 파일을 메일로 보내려고 한다고 치자. 그런데 받는 사람의 메일 계정 용량이 10M밖에 안된다면 메일을 받지 못할 것이다. 또 메일 계정 용량이 충분하더라도 20M가 되는 메일을 받는 것을 꺼리는 사람도 있을 것이다. 이럴 때 사용할 수 있는 것이 바로 message/external-body 타입이다. message/external-body 타입은 Content를 직접 메일 안에 첨부하는 것이 아니라, 외부에 두고 메일 안에는 그 Content에 대한 정보(URL, 파일명등)만을 포함하는 MIME 타입이다. message/external-body 타입은 RFC 2046에 정의되어 있다. message/external-body 타입은 Content-Type의 부가정보로 access-type이라는 정보를 꼭 포함하고 있어야 한다. access-type은 외부에 있는 Content를 어떠한 방식에 의해 접근해야 할 지를 알려준다. 그 종류로는 FTP, ANON-FTP, TFTP, LOCAL-FILE, MAIL-SERVER등이 있다. 또한 Content-Type의 부가정보로 expiration이라는 정보를 포함할 수도 있다. expiration 정보는 외부에 있는 Content가 유효한 시간을 가리키는 정보이다. 또 size라는 정보 역시 포함될 수 있는데, size는 외부에 있는 Content의 크기를 가리킨다. 각각의 access-type은 그 종류에 따라 각각의 부가 정보를 가질 수 있다. 이에 대한 간단히 살펴보자.
    FTP와 TFTP는 name, site 그리고 directory라는 정보를 가지고 있다. name은 Content 파일의 이름을 가리킨다. site는 Content가 있는 곳의 도메인 이름이다. 이 이름은 완전한 도메인 이름의 형식을 가져야 한다. directory는 Content가 있는 디렉토리를 가리킨다. access-type이 FTP(TFTP)라면 site가 가리키는 컴퓨터에 FTP(TFTP) 프로토콜을 이용해 접속하여 directory 아래에 있는 name 파일을 가져오면 된다.
    ANON-FTP는 FTP와 유사하지만 ftp로 접근할 수 있는 유저ID와 패스워드를 묻지 않을 경우에 사용한다. 이 때는 anonymous ID와 사용자의 메일주소를 사용하여 접속한다.
    LOCAL-FILE은 site라는 부가정보가 가리키는 컴퓨터 안에 name이라는 이름으로 저장되어 있는 Content를 가리킨다.
    MAIL-SERVER는 외부에 있는 Content를 메일 서버를 통해 얻어올 수 있다는 것을 말해준다. MAIL-SERVER access-type은 server라는 부가정보와 subject라는 부가정보를 가지고 있다. server는 Content를 얻을 수 있는 메일 서버를 가리킨다. subject라는 부가정보를 가지고 있다라면 server가 가리키는 메일 서버에게 subject가 가리키는 제목으로 메일을 보낼 때 우리가 원하던 Content를 얻을 수 있음을 말해준다.
    리스트 6은 message/external-body 타입의 예이다.

    리스트 6 : message/external-body 타입의 예
    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: External-body Type
    MIME-Version: 1.0
    Content-type: message/external-body;
    access-type=local-file;
    name="/u/nsb/Me.jpeg"

    Content-type: image/jpeg
    Content-ID: <id42@guppylake.bellcore.com>
    Content-Transfer-Encoding: binary

    THIS IS NOT REALLY THE BODY!


    4.7 message/partial

    message/partial 타입은 용량이 큰 메일을 보낼 때 유용하게 사용할 수 있다. message/partial 타입은 용량이 큰 메일을 여러개로 쪼개어 보내는 방법을 사용할 때 쓰이는 MIME 타입이다. 메일 클라이언트가 이렇게 보내진 메일을 다시 하나로 합치게 하면 원래의 메일이 된다.
    message/partial 타입은 id, number, total이라는 부가정보를 가지고 있다. id는 쪼개진 Content를 가리키는 문자열이다. 다시 말해서 id가 같은 메일을 모아야 다시 합칠 수 있다는 말이다. number는 이 메일이 쪼개진 Content의 조각 가운데 몇번째 조각인지를 가리키는 것이고, total은 모두 몇 개의 조각으로 쪼개졌는가를 가리키는 것이다. 리스트 7이 message/partial 타입의 예이다.

    [첫번째 메일]

    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: 쪼개진 메시지 (part 1 of 2)
    Message-ID: <id1@host.com>
    MIME-Version: 1.0
    Content-type: message/partial;
    id="ABC@host.com";
    number=1;
    total=2
    Message-ID: <anotherid@foo.com>
    Subject: GIF 그림
    MIME-Version: 1.0
    Content-type: image/gif
    Content-transfer-encoding: base64

    ... GIF 이미지 파일의 첫 번째 조각(총 2개) ....


    [두번째 메일]

    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: 쪼개진 메시지 (part 2 of 2)
    MIME-Version: 1.0
    Message-ID: <id2@host.com>
    Content-type: message/partial;
    id="ABC@host.com";
    number=2;
    total=2

    ... GIF 이미지 파일의 두 번째 조각(총 2개) ....

    [메일 클라이언트에 의해 합쳐진 메일]

    From: hanyoul@netsarang.com
    To: anyone@somehost.com
    Subject: GIF 이미지
    Message-ID: <anotherid@foo.com>
    MIME-Version: 1.0
    Content-type: image/gif
    Content-transfer-encoding: base64

    ... GIF 이미지 파일의 첫 번째 조각(총 2개) ....
    ... GIF 이미지 파일의 두 번째 조각(총 2개) ....

    리스트 7의 역상부분을 살펴보면 첫 번째 쪼개진 메시지의 Content-Type, Content-Transfer-Encoding, Subject들이 합쳐진 메일의 것과 같음을 알 수 있다. message/partial 타입은 원래 메일의 Content-Type, Content-Transfer-Encoding, Subject들을 첫 번째 조개진 메시지에 포함시키고 있음을 알 수 있다.

    지금까지 우리는 MIME에 대한 기본적인 내용을 모두 살펴보았다. 이제 이 내용을 가지고 실제로 MIME Parser를 구현하는 일만이 남았다. 앞에서도 이야기했듯이 마지막 호인 다음 호에서는 실제 MIME Parser를 구현해볼 것이다.

    5. MIME 참고 자료

    RFC 2045 - "Multipurpose Internet Mail Extensions (MIME) Part One: Format of Internet Message Bodies"
    RFC 2046 - "Multipurpose Internet Mail Extensions (MIME) Part Two: Media Types"
    RFC 2047 - "MIME (Multipurpose Internet Mail Extensions) Part Three: Message Header Extensions for Non-ASCII Text"
    RFC 2048 - "Multipurpose Internet Mail Extensions (MIME) Part Four: Registration Procedures"
    RFC 2049 - "Multipurpose Internet Mail Extensions (MIME) Part Five: Conformance Criteria and Examples"

    반응형
    C로 구현하는 MIME Parser (마지막회)

    MIME Parser의 실제 구현

    이번 호는 연재의 마지막으로 실제 MIME Parser를 구현해볼 것이다. 비록 제한된 지면으로 인해 모든 것을 다 설명할 수는 없지만 MIME Parser의 핵심만큼은 이해할 수 있도록 설명할 것이다. 이번 호의 내용이 쉽게 이해된다면 자신만의 MIME Parser를 구현하는 것은 시간 문제일 것이다. 그럼, MIME Parser를 실제로 구현해보도록 하자.

    (주)넷사랑컴퓨터 조한열
    hanyoul@netsarang.com


    1. MIME Parser의 자료 구조

    프로그램을 만들 때, 가장 중요한 것 중의 하나가 자료 구조이다. 자료 구조만 보면 프로그램의 모든 것을 본 것과 같다라는 명언이 있을 정도로 프로그램을 설계할 때 가장 먼저 고려해야 할 것이 자료 구조 설계이다. 우리도 MIME Parser를 위한 자료 구조를 설계하여 보자.
    MIME Parser는 stream을 입력 받아 입력 받은 stream을 의미 있는 Mail Message로 만드는(파싱하는) 것을 주된 목표로 삼는다. 그러므로 우리는 Mail Message를 저장할 수 있는 자료 구조를 설계해야 한다.
    Mail Message(MIME을 포함하는 Mail Message)는 크게 두 가지 부분으로 나눌 수 있다. 첫 번째는 헤더이고 두 번째는 바디이다. 헤더는 RFC822 헤더를 가리킨다. 헤더에는 Mail의 모든 정보가 들어가 있다. 바디는 한 개 혹은 여러 개의 Content로 구성된다. 물론 아예 컨텐트가 없는 경우도 있을 수 있다. MIME Message는 여러 개의 컨텐트를 가질 수도 있고 아니면 아예 컨텐트가 없을 수도 있기 때문이다. 각각의 컨텐트는 컨텐트의 정보를 가리키는 부분(MIME Header에 해당하는 부분)과 실제 데이터로 구성된다. 이와 같은 특성을 갖고 있는 MIME Message를 저장할 수 있는 자료 구조를 다음과 같이 설계하였다.

    [Mail Message]
    struct H_MESSAGE
    {
    struct H_HEADER *m_header; /* header를 가리키는 포인터 */
    struct H_BODY *m_body; /* body를 가리키는 포인터 */
    };

    Mail Message는 위의 구조체에 저장된다. Mail Message가 헤더와 바디로 구성되어 있기 때문에 헤더를 가리키는 포인터와 바디를 가리키는 포인터를 가지고 있다. 이때, H_HEADER, H_BODY는 바로 뒤에 정의하게 될 헤더와 바디를 저장할 수 있는 구조체이다.

    [HEADER]
    struct H_HEADER
    {
    char *h_field; /* header field name */
    char *h_value; /* header field value */
    struct H_HEADER *h_next; /* 다음 header entry를 가리키는 포인터 */
    };

    RFC822 헤더는 위의 구조체에 저장된다. RFC822 헤더는 콜론(:)으로 구분되는 name-value쌍으로 이루어져 있다. 위에서 h_field는 각 헤더의 이름을 가리키고(Subject, To, X-Mailer등등) h_value는 h_field에 대응되는 값을 가리키고 있다. 또한 위에서 역상으로 표시된 곳을 보면 H_HEADER라는 구조체가 연결 리스트(Linked List)라는 것을 알 수 있다. 즉 name-value 쌍의 연결 구조로서 헤더를 저장할 수 있다는 것이다.

    [BODY]
    struct H_BODY
    {
    char *b_c_type; /* body part의 Content-Type */
    char *b_c_desc; /* body part의 Content-Description */
    char *b_c_encode; /* body part의 Content-Transfer-Encoding */
    char *b_c_id; /* body part의 Content-ID */
    char *b_charset; /* body part의 charater set */
    char *b_filename; /* body part의 실제 파일이름 */
    char *b_savename; /* body part가 파일시스템에 저장된 이름 */
    struct H_BODY *b_next; /* 다음 body part를 가리키는 포인터 */
    struct H_BODY *b_body; /* 중첩된 구조의 multipart를 가리키는 포인터*/
    };

    MIME Message의 컨텐트들은 위의 구조체에 저장된다. 위의 구조체에서 b_c_type(Content-Type), b_c_desc(Content-Description), b_c_encode(Cotent-Transfer-Encoding), b_c_id(Content-ID), b_charset(Content-Type의 부가 정보인 Character Set), b_filename(Content-Dispositon의 부가 정보인 Filename)은 MIME 헤더에서 나오는 컨텐트에 관한 정보들이다. 그리고 실제 컨텐트의 데이터는 b_savename이 가리키는 파일로 저장될 것이다.
    위의 구조체에서 눈여겨 봐야 할 것은 b_next와 b_body이다. b_next는 다음 컨텐트를 가리킨다. 일반적인 컨텐트들이 MIME 형식으로 인코딩 되어 있다면 그 Content들은 b_next를 통해 연결 리스트를 형성할 것이다. 그렇다면 b_body는 어디에 사용할 것인가. b_body는 바로 지난 호에서 살펴본 중첩된 구조의 MIME Message에서 사용할 것이다. 즉 multipart내에 또 다시 multipart가 있을 때, 우리는 새로운 body의 연결 리스트를 b_body에 저장할 것이다.

    2. MIME Parser의 알고리즘

    MIME Parser의 알고리즘은 의외로 간단하다. 일단 중요한 것은 Mail Message를 한 줄씩 읽어 들인다는 것이다. 모든 프로세스가 한 줄단위로 이루어진다.
    MIME Parser는 한 줄씩 읽어 들이면서 헤더를 파싱하고 그 뒤 바디를 파싱한다. gpejdhk 바디는 빈 줄 하나로 구분되기 때문에 한 줄씩 읽어 들이다가 빈 줄을 만나며 헤더가 끝났다는 것을 알 수 있게 되고 그 뒤부터는 바디를 파싱한다.
    바디를 파싱하는데 있어서 중요한 것은 Content-Type이다. Content-Type이 Multipart이면 boundary가 구분하는 경계를 찾아서 각각의 Content들을 하나씩 하나씩 파싱하면 된다. 이 때, boundary를 찾는 것 역시 한 줄씩 읽어 들이면서 체크를 하게 된다.

    2.1 MIME Parser의 핵심 함수

    MIME Mesasge를 파싱하기 위한 핵심 함수는 다음과 같다.

    ● hParseMessageRFC822()
    아래에서 소개될 hMessageHeaderParsing(), hMessageBodyParsing() 함수를 호출하여 Mail Message stream으로부터 헤더와 바디를 파싱하고 파싱된 헤더와 바디를 H_MESSAGE 구조체에 저장한 뒤 저장된 구조체를 리턴하는 함수이다. 이 때, Mail Message stream은 파일로 저장되어 있다고 가정하며, Mail Message가 저장된 파일의 file descriptor를 인수로 넘겨받아 사용하게 된다.

    ● hMessageHeaderParsing()
    hParseMessageRFC822() 함수가 넘겨준 Mail Message stream을 저장하고 있는 file descriptor로부터 헤더를 파싱해 내고 그 결과를 H_HEADER 구조체에 저장한 뒤 구조체를 리턴한다.

    ● hMessageBodyParsing()
    hParseMessageRFC822() 함수가 넘겨준 Mail Message stream을 저장하고 있는 file descriptor(이 때, file descriptor의 파일포인터는 헤더를 파싱하고 난 다음라인을 가리키고 있다.)로부터 바디를 파싱해 내고 그 결과를 H_BODY 구조체에 저장한 뒤 구조체를 리턴한다. 이 때, 아래의 두 함수 hParseMultipartMixed(), hCollect()를 적절하게 사용한다.

    ● hParseMultipartMixed()
    hMessageBodyParsing() 함수가 넘겨준 file descriptor와 boundary(Content-Type의 부가정보인 boundary)를 이용하여 Multipart Content를 파싱하고 파싱된 결과를 H_BODY 구조체에 저장한 뒤 구조체를 리턴한다. 이 때, 리턴된 H_BODY 구조체는 b_body에 저장된다.

    ● hCollect()
    hMessageBodyParsing() 함수가 넘겨준 file descriptor와 boundary(Content-Type의 부가정보인 boundary)를 이용하여 Multipart가 아닌 하나의 Content만을 파싱하고 파싱된 결과를 temporary 파일을 생성하여 저장한다. 이 때, Content-Transfer-Encoding 정보를 이용하여 데이터를 디코딩 한다.

    2.2 MIME Parser의 핵심 Source코드

    다음은 위에서 설명한 함수들의 소스 코드이다. 소스 코드 중간 중간에 사용하는 함수들에 대해서 짤막하게 설명하고 넘어가도록 하겠다. 이 함수들을 구현하는 것은 독자들의 몫으로 남긴다.

    ● hReadLineCRLF2LF(int fd, char *buf, int size)
    file descriptor의 파일 포인터로부터 한 라인을 읽어 들여 buf에 저장하는 함수이다. 이 때, CRLF는 LF로 변환한다.

    ● hStrHasSpace(char *str)
    str에 space가 있는지 확인하는 함수 있다. space가 있다면 1을 리턴하고 그렇지 않다면 0을 리턴한다.

    ● hStrLtrim(char *str)
    str의 왼쪽 부분(처음 부분)에 있는 white space(공백)을 제거하는 함수이다. 리턴 값은 공백이 없어진 새로운 str의 포인터이다.

    ● hStrRtrim(char *str)
    str의 오른쪽 부분(마지막 부분)에 있는 white space(공백)을 제거하는 함수로서 나머지는 hStrLtrim()과 동일하다.

    ● hHeaderAddValue(struct H_HEADER **header, char *field, char *value)
    H_HEADER 구조체에 새로운 엔트리를 첨가하는 함수이다. 이 때 첨가되는 엔트리의 값은 field, value 쌍이다.

    ● hHeaderNamedValueCat(struct H_HEADER *header, char *field, char *str)
    field가 가리키는 엔트리를 header에서 찾아 str을 h_value에 덧붙인다.

    ● hHeaderContentTypeGet(struct H_HEADER *header)
    header에서 Content-Type의 값을 찾아 리턴한다.

    ● hHeaderContentEncodingGet(struct H_HEADER *header)
    header에서 Content-Transfer-Encoding의 값을 찾아 리턴한다.

    ● hHeaderCharsetGet(struct H_HEADER *header)
    header에서 charset의 값을 찾아 리턴한다.

    ● hHeaderFilenameGet(struct H_HEADER *header)
    header에서 filename의 값을 찾아 리턴한다.

    ● hStrToLower(char *str)
    str을 모두 lower case로 만든다.

    ● hSavenameGet()
    temporary 파일 이름을 만들어 리턴한다.

    리스트 1 : struct H_MESSAGE *hParseMessageRFC822(int fd, const char *boundary)
    {
    struct H_MESSAGE *msg = (struct H_MESSAGE *)NULL;
    struct H_HEADER *tempHeader = (struct H_HEADER *)NULL;
    struct H_BODY *tempBody = (struct H_BODY *)NULL;

    msg = hMessageCreate();

    tempHeader = hMessageHeaderParsing(fd);

    if(!tempHeader)
    return NULL;

    tempBody = hMessageBodyParsing(fd, tempHeader, boundary);

    if(!tempBody)
    return NULL;

    msg->m_header = tempHeader;
    msg->m_body = tempBody;

    return msg;
    }

    리스트 1의 hMessageCreate()는 H_MESSAGE 구조체의 메모리를 동적으로 할당하는 함수이다. 위의 소스코드에서 msg는 H_MESSAGE 구조체의 포인터로서 리턴할 값이다. 위에서 살펴본대로 msg는 두개의 멤버를 가지고 있는데, 하나는 헤더를 가리키는 m_header이고, 다른 하나는 바디를 가리키는 m_body이다. hMessageHeaderParsing() 함수로 만들어진 H_HEADER 구조체(tempHeader)와 hMessageBodyParsing() 함수로 만들어진 H_BODY 구조체(tempBody)를 msg에 저장하고 msg를 리턴한다.


    리스트 2 : struct H_HEADER *hMessageHeaderParsing(int fd)
    {
    char buf[BUF_SIZE];
    char *field = (char *)NULL;
    char *value = (char *)NULL;
    int islong; /* header가 long임을 나타내는 flag */
    struct H_HEADER *retHeader = (struct H_HEADER *)NULL;

    /* EOF을 만났다는 것은 파일의 끝 */
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    return NULL;

    while(TRUE)
    {
    /* LF로 시작되는 행은 header의 끝을 나타낸다. */
    if(!strcmp(buf, "\n")) break;

    /* 첫 character가 white space이면 long header(RFC822 정의) */
    islong = hIsSpace(buf[0]) ? LONG : NOT_LONG;

    switch(islong)
    {
    case NOT_LONG:
    field = (char *)strtok(buf , ":");
    value = (char *)strtok(NULL, "");

    if(hStrHasSpace(field)) break; /* field에 space가 있으면 :쌍이 아니다. */

    /* value 맨 앞 character는 space 이다. */
    value = hStrLtrim(value);

    if(field != NULL && value != NULL)
    hHeaderAddValue(&retHeader, field, value);

    break;

    case LONG:
    /* long character 이면 뒤에 삽입한다. */
    hHeaderNamedValueCat(retHeader, NULL, buf);
    break;
    }

    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL) /* 헤더가 중간에 비정상적으로 끝나버린 경우 */
    return retHeader;
    }

    return retHeader;
    }

    리스트 2의 함수는 헤더를 파싱하여 H_HEADER 구조체에 저장하고 저장된 H_HEADER 구조체를 리턴하는 함수이다. 그런데 헤더 파싱에는 한가지 유의할 것이 있다. 바로 RFC822 에서 정의하고 있는 long 헤더 때문이다. 다음 예와 같이 long 헤더는 여러 줄에 걸쳐서 나올 수 있다.

    [long 헤더의 예]

    Subject: 이것은 긴 제목의 예입니다. 제목 역시 헤더에 들어가죠? 얼마나 긴 제목인지 한번 살펴보세요.

    앞서 우리는 메일 파싱을 위해 메일을 한 줄씩 읽어 들인다고 했다. 그런데 위에서 살펴 본대로 long 헤더는 여러 줄에 걸쳐 나올 수 있으므로 name, value 쌍인 H_HEADER 구조체에 저장하기 위해서는 여러 줄에 걸쳐 나오는 헤더의 value를 하나로 합쳐서 저장해야 한다. 다행히도 RFC822 문서에는 어떤 줄의 첫 문자가 White Space(공백)이면 이 라인은 바로 위에 나온 헤더 name에 속한다라고 정의하고 있다. 그러므로 우리는 한 줄씩 읽어 들여가며 첫번째 문자가 공백인지를 체크하고 공백이라면 바로 위에 나온 헤더 name에 속한 value에 덧붙이면 된다. 이 때, 사용하는 것이 hHeaderNamedValueCat() 함수이다(리스트 3).

    리스트 3 : hHeaderNamedValueCat() 함수
    struct H_BODY *hMessageBodyParsing(int fd, const struct H_HEADER *hd, const char *boundary)
    {
    struct H_BODY *retBody = (struct H_BODY *)NULL;
    struct H_BODY *tempBody = (struct H_BODY *)NULL;
    struct H_MESSAGE *tempMsg = (struct H_MESSAGE *)NULL;
    char buf[BUF_SIZE];
    char *content_type = (char *)NULL;
    char *content_encoding = (char *)NULL;
    char *value = (char *)NULL;
    char *decodeValue = (char *)NULL;
    char *nested_boundary = (char *)NULL;
    char *filename = (char *)NULL;
    char *savename = (char *)NULL;
    char *charset = (char *)NULL;
    int state = 0;
    int isend = 0;
    int decodeLen = 0;

    content_type = hHeaderContentTypeGet(hd);

    if(content_type != NULL)
    {
    content_type = strtok(content_type, ";");
    content_type = hStrRtrim(content_type);
    }
    else /* Content Type이 없으면 디폴트로 text/plain이다. */
    content_type = "text/plain";

    content_encoding = hHeaderContentEncodingGet(hd); /* 모든 body part에 적용되는 encoding type */

    /* body의 Content-Type에 따라 프로세스 결정 */
    if (!strcmp(hStrToLower(content_type), "message/rfc822" ))
    state = MESSAGE_RFC822;
    else if(!strcmp(hStrToLower(content_type), "multipart/mixed" ))
    state = MULTIPART_MIXED;
    else if(!strcmp(hStrToLower(content_type), "multipart/alternative"))
    state = MULTIPART_ALTERNATIVE;
    else if(!strcmp(hStrToLower(content_type), "multipart/report"))
    state = MULTIPART_REPORT;
    else if(!strcmp(hStrToLower(content_type), "multipart/related"))
    state = MULTIPART_RELATED;
    else
    state = REGULAR;

    switch(state)
    {

    case MULTIPART_ALTERNATIVE:
    case MULTIPART_REPORT:
    case MULTIPART_RELATED:
    case MULTIPART_MIXED:

    nested_boundary = hHeaderBoundaryGet(hd);

    /* boundary가 NULL이면 parsing 할 수 없다. */
    if(!nested_boundary)
    return NULL;

    tempBody = hParseMultipartMixed(fd, nested_boundary);

    /* body linked list에 첨가 */
    hBodyAddValue(&retBody, content_type, NULL, content_encoding, NULL, NULL, NULL);

    retBody->b_body = tempBody;

    while(TRUE)
    {
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    break;
    }

    break;

    case MESSAGE_RFC822:
    case REGULAR:

    savename = hSavenameGet();

    hCollect(fd, savename, boundary, content_encoding, &isend);

    /* filename과 charset을 header에서 알아낸다. */
    filename = hHeaderFilenameGet(hd);
    charset = hHeaderCharsetGet(hd);

    /* body linked list에 첨가 */
    hBodyAddValue(&retBody, content_type, NULL, content_encoding, charset, filename, savename);

    break;
    }

    return retBody;

    }

    리스트 3의 함수와 아래에서 나올 hParseMultipartMixed() 함수가 이번 호의 핵심 중의 핵심이다. 리스트 3의 함수는 Mail Message의 헤더에서 Content-Type을 구하고 Content-Type에 따라 Content-Type이 multipart이면 MIME decoding을 위해 hParseMultipartMixed() 함수를 호출하고 multipart가 아니면 hCollect() 함수를 호출한다. hParseMultipartMixed(), hCollect() 함수호출에 따라 얻어진 H_BODY 구조체를 리턴하는 것이 이 함수의 역할이다.

    리스트 4 : hParseMultipartMixed() 함수
    struct H_BODY *hParseMultipartMixed(int fd, const char *boundary)
    {
    struct H_MESSAGE *tempMsg = (struct H_MESSAGE *)NULL;
    struct H_HEADER *tempHeader = (struct H_HEADER *)NULL;
    struct H_BODY *retBody = (struct H_BODY *)NULL;
    struct H_BODY *tempBody = (struct H_BODY *)NULL;
    char buf[BUF_SIZE];
    char boundaryn[256];
    char boundaryEOFn[256];
    char *nested_boundary = (char *)NULL;
    char *value = (char *)NULL;
    char *decodeValue = (char *)NULL;
    char *content_type = (char *)NULL;
    char *content_encoding = (char *)NULL;
    char *filename = (char *)NULL;
    char *savename = (char *)NULL;
    char *charset = (char *)NULL;
    int isend = 0;
    int state = 0;
    int decodeLen = 0;

    sprintf(boundaryn, "--%s\n", boundary);
    sprintf(boundaryEOFn, "--%s--\n", boundary);

    /* MIME prologue 제거 */
    while(TRUE)
    {
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    return NULL;

    if(!strcmp(buf, boundaryn))
    break;
    }

    while(TRUE)
    {
    tempHeader = hMessageHeaderParsing(fd);

    content_type = hHeaderContentTypeGet(tempHeader);

    if(content_type != NULL)
    {
    content_type = strtok(content_type, ";");
    content_type = hStrRtrim(content_type);
    }
    else
    content_type = "text/plain";

    content_encoding = hHeaderContentEncodingGet(tempHeader); /* 모든 body part에 적용되는 encoding type */

    /* body의 Content-Type에 따라 프로세스 결정 */
    if (!strcmp(hStrToLower(content_type), "message/rfc822" ))
    state = MESSAGE_RFC822;
    else if(!strcmp(hStrToLower(content_type), "multipart/mixed" ))
    state = MULTIPART_MIXED;
    else if(!strcmp(hStrToLower(content_type), "multipart/alternative"))
    state = MULTIPART_ALTERNATIVE;
    else if(!strcmp(hStrToLower(content_type), "multipart/report"))
    state = MULTIPART_REPORT;
    else if(!strcmp(hStrToLower(content_type), "multipart/related"))
    state = MULTIPART_RELATED;
    else
    state = REGULAR;

    switch(state)
    {
    case MULTIPART_ALTERNATIVE:
    case MULTIPART_REPORT:
    case MULTIPART_RELATED:
    case MULTIPART_MIXED:

    nested_boundary = hHeaderBoundaryGet(tempHeader);

    /* boundary가 NULL이면 parsing 할 수 없다. */
    if(!boundary)
    return NULL;

    tempBody = hParseMultipartMixed(fd, nested_boundary);

    /* body linked list에 첨가 */
    hBodyAddValue(&retBody, content_type, NULL, content_encoding, NULL, NULL, NULL);

    retBody->b_body = tempBody;

    /* multipart내의 multipart이면 다음 라인은 boundary이거나 boundaryEOF이다. 즉, skip */
    while(TRUE)
    {
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    {
    isend = 1;
    break;
    }

    if(!strcmp(buf, boundaryn))
    {
    isend = 0;
    break;
    }
    /* boundaryEOFn을 만나면 파싱이 끝난다. */
    else if(!strcmp(buf, boundaryEOFn))
    {
    isend = 1;
    break;
    }
    }

    break;

    case MESSAGE_RFC822:

    case REGULAR:

    savename = hSavenameGet();

    hCollect(fd, savename, boundary, content_encoding, &isend);

    /* filename과 charset을 header에서 알아낸다. */
    filename = hHeaderFilenameGet(tempHeader);
    charset = hHeaderCharsetGet(tempHeader);

    /* body linked list에 첨가 */
    hBodyAddValue(&retBody, content_type, NULL, content_encoding, charset, filename, savename);

    break;
    }

    if(isend == 1) /* end of multipart */
    break;

    }

    return retBody;
    }

    리스트 4의 함수를 몇 번이고 읽어보게 되면 자연스레 MIME Parsing의 원리를 이해할 수 있을 것이다. 이 함수의 기본 동작은 boundary를 찾는 것이다. Boundary를 찾아서 각각의 Content들을 파싱 해낸다. 파싱 된 각각의 Content들은 H_BODY 구조체에 저장된 채로 연결리스트를 이루게 된다. Content-Type이 Multipart가 아닌 각각의 Content들을 파싱하기 위해서는 hCollect() 함수의 도움을 받는다. 만약 Content가 또 다시 Multipart이면 recursive하게 hParseMultipartMixed() 함수를 호출한다. 이렇게 호출된 hParserMultipartMixed() 함수의 끝내기 조건은 --boundary-를 만났을 때이다. --boundary-가 MIME Message의 끝을 가리키고 있는 경계선이기 때문이다. 위의 함수에서 --boundary-를 만나면 isend 라는 변수를 1로 셋팅하게 되고, while() 루프에서 isend가 1이면 루프를 빠져 나와 H_BODY 구조체를 리턴한다.

    리스트 5 : Content를 파싱하는 함수
    int hCollect(int fd, const char *filename, const char *boundary, const char *content_encoding, int *isend)
    {
    int valuelen = 0;
    int buflen = 0;
    int templen = 0;
    char *temp = (char *)NULL;
    char boundaryn[256], boundaryEOFn[256];
    char buf[BUF_SIZE];
    int i;
    FILE *fout;

    fout = fopen(filename, "w+");

    if(!boundary) /* boundary가 없는 경우 */
    {
    while(TRUE)
    {
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    {
    fclose(fout);
    *isend = 1;

    return 0;
    }

    temp = hStrDecode(buf, content_encoding, &templen);

    for(i=0;i<templen;i++)
    fputc((int)temp[i], fout);

    if(temp != buf)
    free(temp);
    }
    }
    else /* boundary가 있는 경우 */
    {
    /* boundary set */
    sprintf(boundaryn, "--%s\n", boundary);
    sprintf(boundaryEOFn, "--%s--\n", boundary);

    while(TRUE)
    {
    if(hReadLineCRLF2LF(fd, buf, BUF_SIZE) == NULL)
    {
    fclose(fout);

    *isend = 1;
    return 1;
    }

    if(!strcmp(buf, boundaryn))
    {
    fclose(fout);

    *isend = 0;
    return 0;
    }
    else if(!strcmp(buf, boundaryEOFn))
    {
    fclose(fout);

    *isend = 1;
    return 0;
    }

    temp = hStrDecode(buf, content_encoding, &templen);

    for(i=0;i<templen;i++)
    fputc((int)temp[i], fout);

    if(temp != buf)
    free(temp);
    }
    }

    return 1;
    }

    리스트 5의 함수는 multipart가 아닌 Content를 파싱하는 함수이다. Boundary가 없을 때는 Content가 multipart 안에 들어 있는 것이 아니기 때문에 Mail Message의 끝까지가 Content 데이터이다. 그렇지 않고 boundary가 존재한다면 파싱하려는 Content는 multipart 안에 속해있는 Content이기 때문에 boundary를 체크 해야 한다. 만약 --boundary-를 만나면 MIME의 끝이기 때문에 isend 값을 1로 셋팅하여 이 함수를 호출한 함수에게 MIME이 끝났음을 알린다. 파싱된 데이터는 hSavenameGet() 함수에 의해 얻어진 임시 파일이름으로 저장된다.

    리스트 6 : hSavenameGet() 함수
    char *hSavenameGet(void)
    {
    struct timeval t;
    static char savename[512];

    gettimeofday(&t, NULL);
    sprintf(savename, "%s/%ld.%ld%ld", TMP_DIR, getpid(), t.tv_sec, t.tv_usec);

    return savename;
    }

    리스트 6의 함수는 temporary 파일이름을 리턴하는 함수이다. TMP_DIR는 파싱된 Content들의 데이터가 임시 파일로 저장될 디렉토리를 가리킨다.

    마치며

    지금까지 간략하게나마 MIME Parser의 핵심부분을 살펴보았다. 잘 이해가 되지 않는다면 지난 호의 내용과 이번 호의 소스 코드를 계속해서 읽어보길 바란다. 조만간 프로그램세계 홈페이지를 통하여 필자의 홈페이지를 공지하며 MIME Parser를 라이브러리 형태로 공개할 예정이니 참고하길 바란다. 지금까지 4회에 걸쳐 필자의 글을 읽어준 독자들에게 다시 한 번 감사의 말씀을 전하며 마칠까 한다.

    반응형
    자바와 게임의 만남 '로보코드 코리아컵 2007'
    - 자바 프로그래밍 언어를 기반으로 제작한 로봇 간의 대결
    - 자바개발자 커뮤니티인 JCO와 취업전문포탈인 인크루트 후원

    (2007.8.8) 한국IBM(대표 이휘성)은 8월 8일 자바 기반의 프로그래밍 게임 대회인 로보코드 코리아컵 2007 결승전을 개최했으며, 우승은 김동환씨(고려대 신소재공학부)가 차지했다고 발표했다.

    지난 2003년에 첫 대회가 개최된 이래 4회째를 맞는 ‘로보코드 코리아컵 2007’은 자바 기반의 프로그래밍 대회로 국내 자바 개발자 커뮤니티 모임인 JCO(JAVA Community Organization)와 인크루트가 후원하고 있다.

    로보코드 코리아컵은 올 5월 로봇신청 접수를 시작하여 7월 말에 접수를 마감했으며, 64강전과 32강전을 치르고 오늘 16강전부터 결승전까지 치름으로써 최종 승자가 가려졌다. 준우승은 이종혁씨(경기대 컴퓨터과학과), 3위는 조규현씨(호남대 인터넷소프트웨어학과) 가 각각 수상했다.

    로보코드는 지난 2001년 IBM의 개발자인 맷 넬슨이 개발한 게임으로서, 사용자들이 직접 자바를 기반으로 인공지능이 담긴 로봇을 만들어 전투를 벌이게 된다. 예선전에서는 그룹별 전투를 거쳐 최종 점수가 가장 높은 로봇이 승리하게 되며, 64강전부터는 1대1의 토너먼트 방식으로 진행된다. 각 참가자들은 최대 3개까지 로봇을 제출할 수 있다.

    로보코드 참가자는 자바 언어의 요소를 사용하여 자신의 로봇을 만들면서 프로그래밍 언어를 익힐 수 있어 재미와 기술을 동시에 얻을 수 있다. 특히 초보자들도 쉽게 배울 수 있도록 로보코드 코리아컵 홈페이지(www.ibm.com/developerworks/kr/robocode)를 통해 개발 방법을 소개하고 있다.

    또 로보코드는 오픈소스를 기본 전제로 하고 있어 샘플 로봇뿐 아니라, 등록되어 있는 다른 개발자들이 소스를 다운받아 분석하고 자신의 소스를 업로드하는 과정을 반복하면서 로봇은 점차 진화하게 된다.

    한국IBM은 올초 developerWorks 대학생 모니터 요원을 선발하여 대학 내 로보코드 홍보를 강화했으며, 고려대, 서울여대, 목포대, 세종대, 숭실대, 동국대, 전남대 등의 요청으로 출장 강의를 진행하는 등 대학생 개발자들의 큰 호응을 얻었다.

    한국IBM 솔루션 파트너 사업부의 계혜실 실장은 "IBM은 오픈 소스를 적극적으로 지원하고 있으며, 로보코드를 통해 자바 언어에 대한 대학생 및 개발자들의 관심과 흥미가 크게 증대되었다고 본다. 향후에도 다양한 오픈 소스 지원 정책과 프로그램을 통해 개발자들에게 많은 기술정보와 커뮤니티 환경을 제공할 예정”이라고 밝혔다.

    --------------------------------------------------------------------------------------------------------------------
    참고자료

    - 로보코드의 특징: 각 로보코드 참가자는 자바 언어의 요소를 사용하여 자신의 로봇을 만들면서 자바가 갖고 있는 상속성, 다형성, 이벤트 처리 및 내부 클래스 다루는 방법을 배우게 된다. 표준 API(Application Program Interface)를 지향하지만, 커스터마이징할 수 있는 이벤트를 갖고 있다. 따라서 개발자들이 창의적일수록 로봇이 전투에서 살아남을 가능성이 높아진다. 특히, 로보코드는 초보자부터 고급 프로그래머에 이르기까지 모든 수준의 개발자들이 참가할 수 있다.

    - 로보코드의 기반 솔루션: 로보코드는 이클립스, 웹스피어, DB2, 웹스피어 애플리케이션 디벨로퍼를 사용하여 개발되었으며, 참가자들은 IBM이 제공하는 API를 이용해 쉽게 로봇을 만들 수 있다.
    반응형

     IBM 1기 우수 모니터요원 수상 장면이다.. 3명이 선발 되었다.

    앗 맨처음 보이는 여자분은 우리 담당이신 이선진 과장님..^^ 이날 고생이 많으셨다는..

    나 , 용호, 성무 뭐.. 상을 받으니까 기분은 좋긴 했지만 상장밖에 없다는 ㅡㅡ;;

    그리고 같이 활동한 규현이는 로보코드 3위에 올라 트로피와 상장 그리고 상품까지 받았다는...

    로보코드 및 모니터 요원 수상등 행사가 끝나고 새롭게 선발된 2기와 함께 비어파티 사진이다..

    사용자 삽입 이미지

    열심히 활동하려는 의지가 대단한 친구들이다...

    우리는 첫기수라 힘든게 많았지만 그 만큼 터를 딱아놨다고 나름 생각한다..

    이친구들이 dw를 더욱 멋지게 꾸며나갈꺼라 믿어 의심치 않는다.. 나도 나름 선배니까.^^

    IBM dw 2기 모니터 요원 다들 화이팅~~~!!!
    반응형

    POP3 구현 하기

    우리는 POP3 를 이용하여 메일 서버로부터 메일을 가져 올 수 있습니다 . POP3 환경에서 메일은 메일 서버에 존재 하며 일반적인 클라이언트 프로그램은 메일 서버로 접속 한 다음 메일 메시지를 서버에서 클라이언트로 복사 합니다 . 일반적으로 클라이언트가 POP3 서버의 TCP 110 번 포트로 연결 되면서 POP 세션이 이루어 지면서 클라이언트가 서버에 접속을 성공 하면 POP3 서버는 접속 축하 메시지를 돌려 준다 . 그러면 다음 명령어를 수행 할 수 있으며 클라이언트와 서버가 서로 응답을 주고 받은 다음 연결이 종료 되면서 세션도 끝나게 되는 것이다 .

    ----------

    POP 명령

    ----------

    일반적인 명령어 규약은 다음과 같습니다 .

    명령어는 CRLF 시퀀스로 종료 된다 .

    키워드는 공백 문자로 구분 된다 .

    키워드는 3~4 글자로 이루어져 있다 .

    응답은 최대 512 자까지 된다 .

    “+OK” 는 긍정적인 응답을 가리 킨다 .

    “-ERR” 은 부정적인 응답이나 오류가 발생 한 경우를 나타낸다 .

    필수명령

    설명

    USER [name]

    서버에 접속하는 사용자 명

    PASS [password[

    서버에 접속하는 사용자의 비밀번호

    QUIT

    현재 세션의 종료

    DELE [msg]

    서버에서 메일을 삭제

    RSET

    현재 세션의 모든 변경 사항을 취소

    STAT

    서버에 존재하는 메시지의 개수를 돌려 준다 .

    RETR [msg]

    메시지의 컨텐츠를 가져 온다

    LIST [msg]

    파라미터로 넘어 오는 메시지에 대한 정보를 돌려 준다 . 예를들면 크기를 바이트 단위로 돌려준다 . 파라미터가 없다면 모든 메시지의 목록과 그 크기를 돌려 준다 .

    NOOP

    서버와 긍적적인 응답을 주고 받는 것 이외에 아무런 작업도 수행하지 않는다 .

    TOP [msg] [n]

    서버는 메시지의 헤더와 본문을 구분해 주는 빈 줄 , 그리고 메시지의 본문이 몇 개의 행으로 이루어 졌는지 알려 준다 . [msg] 에서 원하는 메시지의 번호를 지정하며 [n] 에서 가져오려는 메시지의 상위 행의 개수를 지정 한다 .

    UIDL [msg]

    인자가 주어 졌으면 서버는 지정된 메시지에 대한 정보를 담고 있는 행을 긍정적인 응답과 함께 표시 한다 . 이를 선택된 메시지에 대한 “ 고유 id” 목록이라 한다 . 메시지의 고유 id 는 서버에서 독자적으로 지정하는 문자열로 0x21 로부터 0x7e 까지 문자들 중의 하나로 이루어 진다 . 이는 메시지를 고유 하게 식별하기 위해 사용 한다 .

    ////////////////////////////////////////////////////////////////////////////////////
    본문

    사용자가 쉘 계정이 있는 호스트에 직접 접속하여 메일을 읽지 않고
    자신의 PC에서 바로 로컬 메일 리더(유도라나 넷스케이프 ,outlook outlook express 등등)를 이용하여
    자신의 메일을 다운로드 받아서 보여주는 것을 정의한 프로토콜이다.
    POP2와 PO3과 함께 널리 사용된다.
    POP2는 RFC 937에 정의되어 있으며 POP3는 RFC 1725에 정의되어 있다.
    POP2는 포트번호 109를 사용하며 POP3는 포트번호 110을 사용한다.
    이들은 다른 명령어를 사용하지만 기본적으로 같은 기능을 수행한다.

    POP 프로토콜은 사용자의 로그인 이름과 패스워드를 식별하고,
    서버로부터 사용자의 로컬 메일 리더(local mail reader)로 사용자의 메일을 이동시킨다.
    POP2는 SMTP와 같이 단순한 요구/응답 프로토콜이다.
    HELO 명령은 검색중인 메일박스의 계정을 위해서 사용자의 이름과 패스워드를 제공한다.
    HELO 명령에 대한 응답으로 서버는 메일박스에 메시지의 수를 보낸다.
    READ 명령에 의해 메일을 읽기 시작한다.
    RETR 명령은 현재 메시지의 전체 텍스트를 검색한다.
    ACKD는 메시지의 수신 확인 명령으로 서버로부터 해당 메시지를 삭제한다.
    각각의 메일에 대한 수신 확인 후에 서버는 새로운 메시지에 바이트의 수를 보낸다.
    만약 바이트가 0이라면 더이상 검색할 메시지가 없다라는 것을 가리키며
    클라이언트는 QUIT 명령에 의해 세션을 종료한다.

    POP3에 대한 명령은 POP2와 완전히 다르지만 유사한 기능을 수행한다.
    POP3의 USER 명령은 사용자의 계정 이름을 제공하고 PASS 명령은 사용자의 패스워드를 제공한다.
    STAT 명령은 읽지 않은 메시지의 수와 바이트 크기를 출력한다.
    RETR 명령은 해당 번호의 메시지를 검색한다.
    DELE 명령은 해당 번호의 메시지를 삭제한다.
    LAST 명령은 가장 최근에 접근된 메시지의 번호를 출력한다.
    LIST 명령은 모든 메시지나 해당 메시지의 크기를 출력한다.
    QUIT 명령은 세션을 종료한다.

    + Recent posts