두번째 이야기입니다. 오늘은 그림을 종이에 못그렸습니다. 주변에 휴지는 많은데 종이는 없네요. ㅠ.ㅠ; 제 소중한 노트도 잃어버리고. 대신에 화이트 보드에 쓱쓱그려 올려봅니다.(한밤중에 찍은 사진이라 불빛은 도저히 어떻게 할 수가 없습니다) 이번에는 정말로 정말로 별거 없습니다. 그저 [그림 1]만 쓱쓰~윽 그려 낼 수 있으면 그걸로 땡입니다. 게다가 첫번째 이야기 때 이미 어느 정도 언급되었던 내용이라 크게 어려운 점은 없을 것입니다. 그래도 연재 후반부에 실제 PE 파일을 맹글때 필요한 내용들이므로 천천히 읽어보시기 바랍니다. 그럼 시작하죠.
[그림 1] DOS 헤더와 DOS 스텁 코드
DOS 헤더 이미 첫번째 이야기에서 알아본 사실이지만 복습도 할겸 한 번더 설명드립니다. [그림 1]이 다 이해되시는 분들은 스킵하세요. DOS 헤더는 DOS와의 호환을 위해 유지되고 있는 헤더라고 보시면 됩니다. 실제로는 별 쓸모가 없습니다만, 어쨌든 PE 파일은 DOS 헤더로 시작합니다. DOS 헤더는 항상 64bytes 사이즈를 가지며 윈도우 내에서 IMAGE_DOS_HEADER 라는 구조체로 정의되어 있습니다. IMAGE_DOS_HEADER에 대한 정의는 머리만 아프게 하므로 생략하겠습니다만, 나중에라도 대충 살펴보면 DOS 헤더는 다 외울 수 없는 그리고 외울 필요도 없는 약 20개 가량의 멤버로 구성되어 있다는 사실을 알 수 있을 것입니다. 다행스럽게도 DOS 헤더의 멤버 중에서 기억해야 할 녀석들은 DOS 헤더의 처음 2bytes를 차지하는 e_magic과 마지막 4bytes를 차지하는 e_lfanew 뿐입니다. 나머지는 거의 필요없는 부분으로 나중에 PE 파일을 수작업으로 생성할 때 보겠지만 전부 0으로 채워도 아무 문제가 되지 않습니다. DOS 헤더의 처음 2bytes를 차지하는 e_magic은 DOS 헤더의 signature로 [그림 1]에 보이는 것과 같이 항상 '4D 5A(MZ)' 값을 가집니다. DOS 헤더의 마지막 4bytes를 차지하는 e_lfanew는 PE 헤더의 시작점을 가르키는 오프셋 값입니다. 더불어 알아두어야 할 사실은 DOS 헤더의 시작 위치 즉 PE 파일의 시작 위치에 관한 것입니다. 디스크 상에서의 시작 위치야 당연히 파일의 시작점이 될테구요 메모리 상에서는 PE 헤더(그림 1에서 IMAGE_NT_HEADER)에 기록되어 있는 ImageBase가 시작 위치가 된다는 사실 다시 한번 기억해 두시기 바랍니다.
DOS 스텁 코드 별로 관심을 가지지 않아도 될 만한 부분입니다. 실제로 DOS 스텁 코드는 필수 구성 요소는 아니어서 DOS 스텁 코드가 없더라도 프로그램이 실행되는 데는 아무런 지장이 없습니다.그래도 알아두어야 할 내용이 있다면 DOS 스텁 코드는 프로그램을 도스 모드에서 실행시켰을 때 실행 되는 코드이며 보통은 "This program must be run under Microsoft Windows"라는 메시지를 출력하고 종료되는 코드가 삽입된다는 점과 오브젝트 파일을 링킹할 때 STUB 옵션을 이용하여 원하는 스텁 코드를 삽입할 수 있다는 점 정도만 알아두면 되겠습니다. 물론 그림을 보면 알겠지만 스텁 코드는 DOS 헤더 바로 다음에 위치한다는 점도 알아두시고요 더불어 사이즈는 고정되어 있지 않다는 점도 알아두세요.
PE를 조립하자 1 : DOS Header 만들기 이제 PE의 전체 구조와 DOS Header에 대해서 알았으니 PE 파일 제작의 첫번째 단계로 DOS Header를 만들어 보도록 하겠습니다.
준비물 : Hex 에디터(본 글에서는 WinHex를 사용했습니다.)
제작과정
Step 1 : [그림 2]와 같이 WinHex를 실행시켜 64bytes 사이즈를 가지는 새로운 파일을 생성합니다.
[그림 2] 64bytes 사이즈의 새로운 파일을 생성
Step 2: [그림 3]과 같이 처음 2bytes 부분의 값을 "4D 5A"로 수정합니다. DOS 헤더의 시그너춰인건 아시죠?
[그림 3] DOS 시그너춰(e_magic) 입력 (클릭 후 확대해서 보세요)
Step 3: [그림 4]에서 처럼 마지막 4bytes (e_lfanew)의 값을 0x40(64)로 설정합니다. 우리는 DOS stub 코드를 사용하지 않을 것입니다.!!! 우리가 만들 PE 파일은 DOS 헤더 다음에 바로 PE 헤더가 오는 것이죠. 리틀엔디언임을 고려하여 40 00 00 00 으로 입력해야 합니다.
[그림 4] PE 헤더의 시작 지점을 지정
Step 4: DOS Header가 완성되었습니다. 매우 쉽네요. 이 파일은 계속 사용해야 하므로 MyFirstPE.bin이라는 이름으로 저장하겠습니다. 맺음말 거의 첫번째 이야기의 복습판이군요. 정말로 별다른 내용이 없습니다. ^^; 세번째 이야기에서는 PE 헤더에 대해서 알아보도록 하겠습니다.
첫번째 이야기입니다. PE 파일을 수작업으로 만들어 가려면 당연히 전체적인 모습부터 알아야 겠습니다. 어떻게 생겨먹은 놈(공식적으로 PE의 성별에 대해서 확인된 바는 없습니다. ^^;) 인지를 알아야 조립을 할 수 있겠죠. PE 파일의 전체적인 구조는 [그림 1]과 같습니다. PE 파일의 전체적인 구조를 공부할 때는 "PE 파일은 디스크 상의 모습과 메모리 상의 모습이 거의 같다"라는 것과 PE 파일의 구성 요소, 그리고 각 구성 요소의 시작점을 찾는 방법을 잘 알아두어야 합니다.
[그림 1] PE File의 전체적인 모양 (클릭 후 확대해서 보세요)
PE 구성 요소 PE는 DOS header, DOS stub code, PE header, Section table 및 1개 이상의 섹션으로 구성됩니다. 각 구성 요소에 대해서는 차근차근 공부해 나갈 것입니다. 지금은 자세한 내용보다는 전체적인 구조에 대해서 파악하는 것이 중요합니다. 이에 맞춰 각 구성 요소의 명칭과 구성 형태를 [그림 1]을 참고하여 스스로 그림으로 그려낼 수 있도록 노력하면 되겠습니다. 한가지만 더 언급하자면 DOS stub 코드는 PE 파일의 필수 구성요소가 아니라서 생략이 가능합니다.
각 구성 요소의 시작 위치 계산 방법
DOS header : PE 파일은 DOS header로 시작합니다. 따라서 디스크 상에서는 파일의 첫부분이 DOS header가 됩니다. 메모리 상에서 PE 파일은 ImageBase에서 시작하므로 DOS header는 ImageBase에서 찾을 수 있습니다.간단하게 확인해 보도록 하죠. 먼저 calc.exe를 StubPE를 이용하여 열어보겠습니다. [그림 2]는 StudPE를 통해 살펴 본 PE 헤더 일부입니다. ImageBase 값이
01000000이군요. 메모리에 로드된 PE 파일의 시작점이 01000000 번지라는 이야기입니다. 디버거를 하면 간단하게 확인이 가능합니다. calc.exe를 ollydbg로 오픈해 보죠. 오픈 후에는 [그림 3]처럼 우측 하단의 [데이터 윈도우]를 클릭하고 Ctrl+G(Goto)를 눌러 01000000을 입력합니다
[그림 2] calc.exe의 ImageBase 값
[그림 3] 메모리상 0x01000000 번지로 이동(확대해서보세요)
이동하여 살펴보면 [그림 4]처럼 데이터값 4D 5A(MZ)를 발견할 수 있습니다.
[그림 4] calc.exe가 로드된 후 0x01000000번지의 내용
MZ은 DOS 개발자 중 한명인 Mark Zbikowski라는 분의 이니셜로 DOS header의 시그너춰로 사용됩니다. 이로써 0x01000000번지 즉 ImageBase에 DOS header가 로드되어 있음을 확인할 수 있습니다.
DOS Stub Code : 윈도우용 애플리케이션을 도스 모드에서 실행시킨 경우 애플리케이션이 정상적으로 동작되지 않습니다. 이러한 경우 윈도우는 애플리케이션 대신에 스텁 코드를 실행시킵니다. 스텁 코드에는 DOS 모드에서 실행 가능한 프로그램이면 어느 것이나 삽입이 가능합니다. 스텁 코드는 DOS header 바로 뒤에 위치합니다. DOS header의 사이즈는 64bytes로 고정되어 있기 때문에 디스크 상에서나 메모리 상에서 DOS 스텁 코드는 시작점으로 부터 64bytes만큼 떨어진 곳에서 찾을 수 있습니다.
PE Header : 디스크 상이나 메모리 상에서 PE 헤더의 위치는 DOS header에 있는 e_lfanew 값을 이용하여 계산할 수 있습니다. e_lfanew는 4byte 크기의 데이터로 DOS header의 마지막에 위치합니다. e_lfanew에는 파일의 시작점에서 부터 PE 헤더까지의 오프셋 값이 저장되어 있습니다. 간단하게 확인해 보도록 하겠습니다. Hex 에디터로 calc.exe를 열어 e_lfanew 값을 확인합니다. e_lfanew 는 DOS header의 마지막 멤버로 DOS header 시작점으로 부터 60byte 만큼 떨어진 곳에 위치합니다. 확인된 e_lfanew 값을 DOS header 시작 주소(PE 파일 시작 주소)에 더해주면 PE 헤더(PE헤더는 "50 45 00 00"으로 시작합니다)를 찾을 수 있습니다. 이는 디스크상에서나 메모리상에서 모두 동일합니다. [그림 5]를 참고하세요.
[그림 5] PE 헤더 찾기 (확대해서 보세요)
섹션 테이블 : 섹션 테이블은 PE Header 바로 뒤에 위치합니다. 따라서 섹션 테이블을 찾으려면 PE 헤더 시작 주소에서 PE header 사이즈를 더해 주면 될 것입니다. 이는 메모리 상 PE나 디스크 상의 PE가 모두 동일합니다. 나중에 좀 더 자세히 알아보겠지만 PE 헤더는 PE signature(4 bytes 고정)와 File Header(20bytes 고정) 그리고 Optional Header(기본 : 224bytes, 변할 수 있음) 로 구성되어 있습니다. 따라서 PE 헤더의 사이즈는 24bytes + Optional Hedaer 사이즈가 됩니다. Optional Header의 사이즈는 File Header에 저장되어 있으므로 어렵지 않게 알아낼 수 있습니다. 섹션의 위치 : 각 섹션의 위치는 섹션 테이블에 저장된 섹션 헤더을 통해서 확인 가능합니다. 섹션 헤더에는 해당 섹션의 위치와 관련하여 VirtualAddress라는 값과 PointerToRawData라는 값이 저장되어 있습니다. 둘 다 offset 값인데 PointerToRawData는 디스크 상의 섹션의 위치를 가르키는 offset 값이고 VirtualAddress는 메모리 상에서의 section의 위치를 가르키는 offset 값입니다. 이러한 사실로 부터 우리는 [그림 1]에 나타난 것처럼 디스크 상에서의 섹션의 위치와 메모리 상에서의 섹션의 위치가 같지 않을 수도 있다는 사실을 유추해 볼 수 있겠습니다. 이 부분은 섹션에 대해서 좀 더 자세히 다룰 때 함께 알아보도록 하겠습니다. 여기서는 간단히 확인만 하도록 하겠습니다. 먼저 디스크 상에서의 섹션의 위치입니다. calc.exe를 계속 사용하도록 하죠. [그림 6]에서 처럼 StudPE로 calc.exe를 열어 section header를 살펴보겠습니다.
[그림 6] calc.exe의 섹션 헤더
.text 섹션의 RawOffset 값이 0x400임을 알 수 있습니다. 이는 디스크 상의 .text 섹션이 파일의 시작점으로 부터 0x400 떨어진 곳에서 시작한다는 것을 의미합니다. 이를 확인하기 위해 [그림 7]에서 처럼 마우스 오른쪽 버튼을 클릭한 후 "GoTo Section Start"를 선택하여 .text 섹션의 시작 위치로 이동해보겠습니다.
[그림 7] calc.exe의 .text 시작점으로 이동
시작 위치로 이동하였으면 [그림 8]을 봐주세요.
[그림 8] calc.exe의 .text 섹션(on disk, 클릭해서 확대 후 보세요)
.text 섹션으로 이동 후 StudPE의 HexViewer의 하단 정보를 확인하면 파일 상의 위치(in File)가 0x400임을 확인할 수 있습니다. 길을 잃어버리시면 안되죠~ 지금까지의 실험은 섹션 테이블에 위치한 섹션 헤더의 PointerToRawData 값은 해당 섹션의 파일 내 시작 위치를 알아내는 데 사용한다는 사실을 확인해 보기 위한 것이었습니다. 이제는 메모리에서 찾아볼까요? Ollydbg를 이용하여 calc.exe를 오픈해 보도록 하죠. 그 다음 ImageBase로 이동해 보겠습니다.(앞에서 해보셨죠? 헛갈리는 분들은 그림 2,3,4를 참고하세요) 이동 후에서는 [그림 9]에서와 같이 마우스 오른쪽 버튼을 눌러 메뉴를 띄운 후 Special->PE header를 선택합니다. 이 메뉴는 데이터를 PE 헤더로 파싱할 때 사용합니다.
[그림 9] 메모리에 위치한 calc.exe의 PE 헤더 분석
PE 헤더로 파싱 후 [그림 10]에 나와있는 위치를 살펴보면 .text 섹션 헤더를 찾을 수 있습니다. 우리는 메모리 상의 .text 섹션의 위치를 찾으려고 하는 것이므로 이번에는 VirtualAddress 항목을 봐야 겠습니다. 예에서는 VirtualAddress 항목의 값이 0x1000인 것을 확인할 수 있는데 이는 ImageBase로 부터의 offset 값(이를 상대 가상 주소, Relative Virtual Address 줄여서 RVA라고 부릅니다.)입니다. 따라서 .text 섹션의 메모리 상 위치는 ImageBase값인 0x01000000에 오프셋 값 0x1000을 더하면 됩니다. 그럼 0x01001000 번지로 이동해 볼까요?
[그림 10] 메모리 상의 .text 섹션 헤더 (클릭 후 확대해서 보세요)
아래 [그림 11]은 .text 섹션으로 이동하여 살펴본 결과입니다.
[그림 11] calc.exe의 .text 섹션의 메모리 상 시작점
주의 깊게 살펴보면 0x01001000번지에 있는 데이터는 9A22D877인데 [그림 8]에서 살펴본 .text 섹션의 시작 번지에 있는 데이터는 EA22D877로 서로 다른 값을 가지고 있음을 알 수 있습니다. ^^; 이는 calc.exe의 경우 .text 섹션의 시작점에 실제 코드가 아닌 IAT이 위치해 있기 때문에 발생하는 상황으로 로드시 DLL 바인딩되면서 IAT에 있는 각 항목의 값들이 변경된 결과입니다. 지금은 이 부분에 신경쓰지 말고 메모리 상 섹션의 위치는 섹션 헤더의 VirtualAddress를 통해서 알 수 있다는 사실만 기억하세요.
PE 파일은 디스크 상의 모습과 메모리 상의 모습이 거의 같다 지금까지 살펴본 결과로 DOS header에서 Section table까지의 구성은 디스크 상의 PE 파일이나 메모리 상의 PE파일 모두 동일하다는 사실을 알 수 있습니다. 이는 디스크 상의 PE 파일이 메모리로 로드 될 때 DOS header 부터 Section table까지는 그대로 로드됨을 의미합니다. 차이가 발생하는 부분은 각 섹션인데요(섹션 헤더에 디스크 상의 위치와 메모리 상의 위치를 구분하는 데이터가 있는 것만 봐도 각 경우의 위치가 다를 것이라는 것을 짐작할 수 있겠죠) 이는 alignment와 관련있습니다. Alignment와 관련하여 기억해 둘 사실은 아래와 같습니다. - 섹션들의 디스크 상의 정렬 단위와 메모리 상의 정렬 단위가 다를 수 있다. - 디스크 상의 정렬 단위와 메모리 상의 정렬 단위는 각각 Optional Header의 FileAlignment와 SectionAlignment에 저장되어 있다. - 디스크 상의 섹션은 FileAlignment의 배수가 되는 주소에서 시작한다. - 메모리 상의 섹션은 SectionAlignment의 배수가 되는 주소에서 시작한다. - 이러한 이유로 FileAlignment와 SectionAlignment 값이 다른 경우 디스크 상의 PE 파일의 모습 과 메모리 상의 PE 파일의 모습은 DOS header부터 Section table까지는 일치하나 섹션이 배치되는 모습은 약간씩 차이가 발생한다.
Ollydbg에서 분석 한 PE 헤더의 내용을 뒤져보면 [그림 12]에서 처럼 SectionAlignment와 FileAlignment 값을 확인할 수 있습니다.
[그림 12] SectionAlignment와 FileAlignment
[그림 6]에서 확인할 수 있었던 것처럼 calc.exe의 .text 섹션은 디스크 상에서 0x400번지에서 시작하였으며 [그림 10]에서 확인한 .text 섹션의 시작 주소는 0x01001000 이었습니다. 0x400은 0x200의 배수이며 0x1001000은 0x1000의 배수가 맞군요. ^^;
정리해 보겠습니다. PE 파일은 메모리에 로드될 때 DOS header부터 Section table까지는 그대로 로드되며 각 section의 경우 지정된 alignment 값에 따라 약간의 차이를 가지고 로드된다는 사실을 기억하시면 됩니다. 덧붙이자면 음... 사실 로드되지 않는 섹션도 더러 있습니다. ^^ 그냥 그럴 수도 있다고 알아두시면 될 것 같습니다.
맺음말 지금까지 PE 파일의 전체 구조를 살펴보았습니다. 지금까지의 모든 설명을 종합 한 것이 [그림 1]입니다. [그림 1]을 혼자서 그려 내실 수 있을 때까지 반복하시면 도움이 될 것입니다. 두번째 글부터는 각 구성 요소에 대해서 하나씩 알아보도록 하겠습니다.
오늘은 PE(Portable Executable)에 대해서 알아보도록 하겠습니다. PE라는 말을 들어보신 적이 있나요? 아마 이 글을 보고 계시는 분들 중에 리버싱 경험이 있으신 분들은 PE란 말을 이미 알고 계실겁니다. 우리가 쓰고 있는 윈도우즈 환경의 실행 파일 포맷을 PE라고 하며, "Portable"의 단어 뜻 그대로 의식성이 있으며 플랫폼에 독립적입니다. PE 파일은 굳이 확장자가 EXE인 파일만 일컫는게 아니며 SCR, SYS, DLL, OCX 등도 포함이 되고, 중간 파일인 OBJ 파일도 PE 파일이라고 간주합니다. PE 구조의 이해는 API 후킹, 압축 실행 파일 등과 같은 고급 리버싱 기법의 기본 바탕이 됩니다. 자, 이제는 PE의 전체적인 구조를 살펴보고 이해하도록 합시다!
PE(Portable Executable)의 전체적인 구조 살펴보기
우선은 헥스 에디터로 계산기(calc.exe) 파일을 열어보도록 합시다.
위 그림에서 보이는 부분이 calc.exe의 시작 영역이자, PE 헤더(PE Header) 영역이라고 할 수 있습니다. 위의 PE 헤더에는 calc.exe와 같은 실행 파일을 실행하기 위한 여러가지 정보가 기록이 되어 있으며, PE의 내용을 가지고 DLL를 로드하거나 여러가지 리소스를 할당하는 등 상당히 많은 정보가 PE 헤더에 저장되어 있습니다. 중요한 정보가 담긴 만큼, 이 영역의 일부가 누락되거나 손상되는 경우가 있으면 정상적으로 해당 파일을 실행할 수 없습니다. 파일을 백업해두고 HEX 코드 일부를 수정하시고 저장하셔서 실행을 하여 어떠한 결과를 초래하는지 보세요.
PE 구조는 차례대로 DOS Header, Stub Code, PE Header(File Header, Optional Header 포함), Section Header로 나뉘며 그 뒤에는 보통 코드를 포함하는 코드(.text) 섹션, 전역 변수 혹은 정적 변수를 포함하고 있는 데이터(.data) 섹션, 문자열이나 아이콘 같은 리소스 데이터를 포함하는 리소스(.rsrc) 섹션으로 나뉘어 등장합니다. 이러한 섹션은 1개 이상 존재하며, 섹션들 사이에 HEX 코드가 00(NULL)으로 나타나는 부분은 정렬 규칙에 의해 크기를 버리고 처리 효율을 높이기 위해 사용하는 영역으로 패딩(padding) 영역이라고 할 수 있습니다.
참고로 위 그림에서 섹션의 왼쪽에 있는 16진수 값은 해당 섹션의 크기를 말하는 겁니다.
위 그림에서는 우선 VirtualSize(가상 메모리에서 해당 섹션이 차지하는 크기), VirtualOffset(가상 메모리 오프셋, VA), RawSize(파일에서 해당 섹션이 차지하는 크기), RawOffset(파일 오프셋) 요 부분은 한번 보도록 합시다. (그림 내에 있는 RVA와 RAW란 녀석은 PE 헤더에 대해 알아볼때 다시 얘기를 할 것입니다.)
약간 살펴보시면, 파일이 메모리에 적재되고 나서는 섹션의 크기 혹은 형태 등이 달라졌음을 확인하실 수 있습니다. 또한 파일은 offset, 메모리에는 address라고 되어 있는데, 이는 파일이 오프셋(offset)으로 위치를 표현하고 메모리는 address, VA(Virtual Address, 가상 메모리의 절대 주소)로 위치를 표현한다고 할 수 있습니다. 이제는 PE 헤더에 대해서 조금씩 알아가면서 PE 구조를 이해하도록 하겠습니다.
PE 헤더(Portable Executable Header)
우리가 알아볼 헤더라는 것은 사실은 여러 가지 필드로 이루어진 하나의 구조체라고 말할 수 있습니다. PE에는 여러가지 헤더가 앞에 자리잡고 있으므로 이는 PE가 여러가지 구조체로 구성이 되어있다고 말할 수 있습니다. 아래에서는 도스 스텁을 제외한 나머지의 헤더를 크게 3개(도스 헤더, NT 헤더, 섹션 헤더)로 나누어 해당하는 헤더의 구조체를 가지고 설명합니다. PE를 구성하는 영역을 도스 헤더부터 섹션 헤더까지 차례대로 살펴보도록 합시다.
1. IMAGE_DOS_HEADER
가장 처음으로 등장하는 영역은 도스 헤더의 영역입니다. 아래의 코드는 winnt.h 헤더의 일부(나머지 헤더 구조체와 매크로 상수 역시 이 헤더에서 가져옴)로 도스 헤더의 구조체입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#define IMAGE_DOS_SIGNATURE 0x4D5A // MZ
typedefstruct_IMAGE_DOS_HEADER { // DOS .EXE header
WORDe_magic; // Magic number
WORDe_cblp; // Bytes on last page of file
WORDe_cp; // Pages in file
WORDe_crlc; // Relocations
WORDe_cparhdr; // Size of header in paragraphs
WORDe_minalloc; // Minimum extra paragraphs needed
WORDe_maxalloc; // Maximum extra paragraphs needed
WORDe_ss; // Initial (relative) SS value
WORDe_sp; // Initial SP value
WORDe_csum; // Checksum
WORDe_ip; // Initial IP value
WORDe_cs; // Initial (relative) CS value
WORDe_lfarlc; // File address of relocation table
WORDe_ovno; // Overlay number
WORDe_res[4]; // Reserved words
WORDe_oemid; // OEM identifier (for e_oeminfo)
WORDe_oeminfo; // OEM information; e_oemid specific
WORDe_res2[10]; // Reserved words
LONGe_lfanew; // File address of new exe header
} IMAGE_DOS_HEADER, *PIMAGE_DOS_HEADER;
위 구조체는 데이터 타입이 WORD인 필드가 총 16개, WORD[]인 필드가 2개, LONG인 필드가 1개로, 해당 구조체의 크기는 총 64 바이트(16진수로는 0x40)이며 필드는 총 19개임을 알 수 있습니다. 위 코드를 보시면 필드가 상당히 많은데, 겁먹을 필요 없이 여기서는 딱 두가지의 필드만 보시면 됩니다. e_magic 필드와 e_lfanew 필드만 보시면 됩니다.
e_magic: e_magic 필드는 PE 파일이 맞는지 아닌지 체크할 때 사용되며, PE 파일 처음부터 2바이트까지 보시면 4D 5A(IMAGE_DOS_SIGNATURE)로 시작하는 부분이 e_magic이 차지하는 공간입니다. 여기서 MZ는 DOS의 설계자인 마크 즈비코프스키(Mark Zbikowski)에서 이름을 따온 것이며, 도스 헤더의 시작을 알리는 코드라 할 수 있습니다. PE 파일의 맨 앞에 위치한 e_magic은 MZ(4D 5A)라는 코드로 고정되어 있으며 가장 앞에서 2바이트를 읽어온 후에 IMAGE_DOS_SIGNATURE와 비교를 하여 서로 다르다면 그것은 PE 파일 구조가 아니라고 할 수 있습니다.
e_lfanew: e_lfanew 필드는 IMAGE_NT_HEADER의 시작 오프셋을 가지며, 고정되어 있는 값이 아닌 파일에 따라 가변적인 값을 지닙니다. 즉, PE 헤더(NT 헤더)의 주소는 도스 헤더의 e_lfanew 필드를 참조하여 알아낼 수 있다는 것이 됩니다. 위 그림에서 e_lfanew 필드의 값은 000000D8 입니다. 주의하실 점은 값이 D8000000이 아니라는 겁니다. 이는 리틀 엔디언 표기법때문에 그런데, 여기서 잠깐 리틀 엔디언 표기법에 대해 잠시 알아보도록 합시다.
1-1. 리틀 엔디언(Little Endian) 표기법
리틀 엔디언(Little Endian) 표기법에 대해 간단히 알아보도록 합시다. 리틀 엔디언은 무엇일까요? 리틀 엔디언 표기법은 Intel 계열 CPU에서 사용하는 표기법으로 낮은 주소부터 시작하여 하위 바이트를 기록하는 것을 말합니다. 만약 0x075BCD15라는 값을 어떠한 저장 공간에 기록하고 싶다면, 하위 바이트부터 시작하여 차례대로 15 CD 5B 07으로 저장되게 됩니다. 바이트의 순서는 이렇지만, 실제 값이 0x15CD5B07이 아니라 0x075BCD15라는 것입니다.
위 그림에서도 0x11223344라는 값이 메모리 공간에 저장된다고 하면, 11 22 33 44 그대로 저장되는게 아니라, 낮은 주소부터 시작하여 하위 바이트를 기록하니 44 33 22 11과 같은 식으로 기록이 됩니다. 반대로 낮은 주소부터 시작하여 상위 바이트를 기록하는 방법은 빅 엔디언(Big Endian) 표기법이라고 합니다.
2. Stub Code
스텁 코드(Stub Code)가 무엇인지 잠시 아래 그림을 봐보도록 합시다.
위의 영역이 바로 도스 스텁 영역입니다. 저 스텁 영역을 자세히 보시면 "This program cannot be run in DOS mode"라는 문자열을 볼 수 있으며, 도스 모드에서 이 파일이 실행되는 것을 막기 위한 것입니다. (16비트 환경에서 실행되는 영역이며, 32비트 환경에선 실행되지 않는 영역입니다) 예전에 쓰던 MS-DS를 구해 가상머신 등으로 설치한 뒤에 윈도우 프로그램을 실행하려 한다면 저 메시지를 볼 수 있습니다. 하위 호환성을 위해서 만든 메시지이라고 생각하시면 됩니다.
도스 헤더 구조체에서의 e_lfanew 필드가 고정적인게 아닌 가변적인 것도 도스 스텁 영역의 크기가 가변적이라서 그렇습니다. e_lfanew 필드는 IMAGE_NT_HEADER의 시작 오프셋을 가진다고 했었고, 도스 스텁 영역의 다음이 IMAGE_NT_HEADER 구조체가 위치하니 도스 스텁 영역의 크기가 변하면 e_lfanew 필드의 값도 변합니다. 도스 스텁 영역은 크게 신경 안쓰셔도 되는 영역이라고 생각하시면 됩니다.
3. IMAGE_NT_HEADER
IMAGE_DOS_HEADER 구조체에 이어 IMAGE_NT_HEADER 구조체를 살펴보도록 하겠습니다.
1
2
3
4
5
6
7
#define IMAGE_NT_SIGNATURE 0x00004550 // PE00
typedefstruct_IMAGE_NT_HEADERS {
DWORDSignature;
IMAGE_FILE_HEADER FileHeader;
IMAGE_OPTIONAL_HEADER32 OptionalHeader;
} IMAGE_NT_HEADERS32, *PIMAGE_NT_HEADERS32;
Signature: _IMAGE_NT_HEADERS의 필드를 살펴보시면 Signature 필드가 가장 처음으로 등장하는데, 데이터 타입이 DWORD니 4바이트를 차지하며, 이 Signature의 값을 가지고 PE 파일 구조인지 아닌지 체크할 수 있습니다. Signature의 값은 IMAGE_NT_SIGNATURE 상수 그대로 PE 00 이라는 값을 지닙니다. 저장되는 순서는 50 45 00 00 이며, 한번 직접 Signature 필드가 어디에 위치하여 있는지 확인해보도록 합시다.
위 그림에 표시된 영역이 바로 Signature 필드의 공간입니다. 우리가 생각하는 값이 나왔네요. 한번, 시험삼아 파일을 백업해두고 도스 헤더 영역의 e_magic 필드나 IMAGE_NT_HEADERS의 Signature 필드를 임의의 값으로 수정하여 실행해보시면 엉뚱한 값이 들어감을 확인하여 올바른 PE 파일이 아니라고 에러 처리를 할 것입니다.
그리고, Signature 말고도 FileHeader와 OptionalHeader 필드가 있는데 이는 IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER를 설명하면서 같이 알아보도록 하겠습니다.
3-1. IMAGE_FILE_HEADER
파일 헤더 구조체인 IMAGE_FILE_HEADER에는 PE 파일에 대한 기본적인 내용이 담겨 있습니다. 우선은 아래의 코드를 보도록 합시다.
1
2
3
4
5
6
7
8
9
typedefstruct_IMAGE_FILE_HEADER {
WORDMachine;
WORDNumberOfSections;
DWORDTimeDateStamp;
DWORDPointerToSymbolTable;
DWORDNumberOfSymbols;
WORDSizeOfOptionalHeader;
WORDCharacteristics;
} IMAGE_FILE_HEADER, *PIMAGE_FILE_HEADER;
IMAGE_DOS_HEADER만큼은 아니지만 그래도 필드가 좀 많아보이죠? 여기에서는 Machine과 NumberOfSections, TimeDateStamp, SizeOfOptionalHeader, Characteristics 이 5개의 필드에 대해서 알아보도록 하겠습니다. 먼저 Machine을 보도록 합시다.
Machine: Machie 필드는 이 파일이 어떤 CPU에서 동작할 수 있는지, 실행될 수 있는 CPU의 타입을 정합니다. 아래는 winnt.h에 정의된 Machine 상수 입니다.
예를 들면, Machine 필드의 값이 014C라면 Intel x86 CPU와 호환이 된다는 것입니다. 한번 calc.exe의 Machine 필드의 값을 확인해보도록 합시다.
저기 보시면 4C01로 기록되어 있고, 값은 014C로 IMAGE_FILE_MACHINE_I386 상수와 값이 일치합니다.
NumberOfSections: 이 필드는 PE 파일을 구성하는 섹션(Section)의 수로 섹션이 추가되면 이 값이 늘어나고 섹션이 줄어들면 이 값 역시 줄어듭니다. 이 값을 보고 이후에 등장할 섹션의 수를 알아낼 수 있으며 이 필드의 값은 0보다 커야 합니다. 이는 섹션이 한개라도 없는 경우가 존재하지 않는다는 것입니다. 한번 NumberOfSection 필드의 값을 보도록 해봅시다.
앞에서 보았듯이 .text, .data, .rsrc, .reloc 섹션이 존재하며, 섹션의 수는 4개입니다. 위 그림에서 NumberOfSections의 값이 0004인 것을 확인하실 수 있으며, 이는 섹션의 수가 4개라는 것으로 실제 섹션의 수와 일치한다는 것을 확인하실 수 있습니다.
TimeDateStamp: TimeDateStamp 필드는 PE 파일이 만들어진 시간, 즉 이 파일이 빌드된 날짜가 타임스탬프 형식으로 기록됩니다. 그러나 이는 확실히 신뢰할 수 있는 값이 아니며 변조가 가능하니 대략적인 값으로 생각을 하셔야 합니다. 한번 TimeDateStamp 필드의 값도 확인을 해보도록 합시다.
TimeDateStamp의 값은 4CE7979D라고 할 수 있고, 이 16진수 값을 10진수 값으로 바꾸면 1290246045 입니다. 이를 다시 표준 시각으로 바꾸면 "Saturday, November 20th 2010, 09:40:45 (GMT)"로, 2010년 11월 20일 9시 40분 45초에 빌드되었다고 대략 예상할 수 있습니다.
SizeOfOptionalHeader: SizeOfOptionalHeader 필드에는 옵셔널 헤더(IMAGE_OPTIONAL_HEADER32)의 크기가 담깁니다. IMAGE_OPTIONAL_HEADER의 크기는 정해져 있는것 같지만, 운영체제마다 크기가 가변적이기 때문에 PE 로더가 이 SizeOfOptionalHeader 필드의 값을 확인하고 IMAGE_OPTIONAL_HEADER의 크기를 처리합니다. 이것 역시 한번 직접 값이 어떤지 보도록 합시다.
위 그림에서 SizeOfOptionalHeader 필드의 값을 확인하실 수 있으며, 값은 00E0으로 10진수로는 224 바이트 만큼을 자치한다고 할 수 있습니다. 이 필드의 값은 32비트에선 0xE0, 64비트에서는 0xF0의 크기를 지닌다고 합니다.
Characteristics: Characteristics 필드는 현재 파일의 형식을 알려주는 역할을 하며, 이 필드의 값을 가지고 실행 가능한 파일인지, DLL 파일인지, 시스템 파일인지, 재배치 여부 등에 대한 정보가 들어있다고 할 수 있습니다. 아래는 winnt.h에 정의된 Characteristics 상수입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define IMAGE_FILE_RELOCS_STRIPPED 0x0001 // Relocation info stripped from file.
#define IMAGE_FILE_EXECUTABLE_IMAGE 0x0002 // File is executable (i.e. no unresolved externel references).
#define IMAGE_FILE_LINE_NUMS_STRIPPED 0x0004 // Line nunbers stripped from file.
#define IMAGE_FILE_LOCAL_SYMS_STRIPPED 0x0008 // Local symbols stripped from file.
#define IMAGE_FILE_AGGRESIVE_WS_TRIM 0x0010 // Agressively trim working set
#define IMAGE_FILE_LARGE_ADDRESS_AWARE 0x0020 // App can handle >2gb addresses
#define IMAGE_FILE_BYTES_REVERSED_LO 0x0080 // Bytes of machine word are reversed.
#define IMAGE_FILE_32BIT_MACHINE 0x0100 // 32 bit word machine.
#define IMAGE_FILE_DEBUG_STRIPPED 0x0200 // Debugging info stripped from file in .DBG file
#define IMAGE_FILE_REMOVABLE_RUN_FROM_SWAP 0x0400 // If Image is on removable media, copy and run from the swap file.
#define IMAGE_FILE_NET_RUN_FROM_SWAP 0x0800 // If Image is on Net, copy and run from the swap file.
#define IMAGE_FILE_SYSTEM 0x1000 // System File.
#define IMAGE_FILE_DLL 0x2000 // File is a DLL.
#define IMAGE_FILE_UP_SYSTEM_ONLY 0x4000 // File should only be run on a UP machine
#define IMAGE_FILE_BYTES_REVERSED_HI 0x8000 // Bytes of machine word are reversed.
위 상수의 값은 비트 플래그를 사용한 것으로, 2진수 형식으로 증가합니다. 우선은 직접 파일의 Characteristics 필드를 확인하여 보도록 합시다.
보시면 Characteristics 필드의 값이 0102라는 것을 알 수 있으며, 이는 0100과 0002를 합한 값과 같습니다. 0x0100은 IMAGE_FILE_32BIT_MACHINE이며, 0x0002는 IMAGE_FILE_EXECUTABLE_IMAGE 인 것을 확인하실 수 있습니다. 이는 즉, 32비트 머신을 필요로 하며, 실행 가능한 파일임을 알 수 있습니다. 파일 헤더의 구조체에 대한 설명은 여기서 마치고, 다음으로는 옵셔널 헤더의 구조체에 대해 알아보도록 하겠습니다.
3-2. IMAGE_OPTIONAL_HEADER
이번에는 IMAGE_OPTIONAL_HEADER 구조체에 대해서 간단히 알아보도록 하겠습니다. 우선 아래의 코드를 보도록 합시다.
IMAGE_OPTIONAL_HEADER 구조체는 PE 구조체 중에서도 가장 크기가 큰 구조체로, 필드의 수가 상당히 많아보이죠? 크기가 큰 만큼 중요한 값도 많이 지니고 있습니다. 총 31개의 필드를 지니고 있고, 이 중에서도 11개의 필드에 대해서 간략히 알아볼 생각입니다. 순서대로 나열하면 Magic, SizeOfCode, ImageBase, AddressOfEntryPoint, BaseOfCode, SectionAlignment, FileAlignment, SizeOfImage, SizeOfHeaders, Subsystem, DataDirectory에 대해 알아볼 것이며, 가장 처음으로 Magic 필드부터 시작하여 차근차근 알아보도록 하겠습니다.
Magic: 32비트(IMAGE_OPTIONAL_HEADER32)인 경우에는 값이 10B이며, 64비트(IMAGE_OPTIONAL_HEADER64)인 경우에는 20B라는 값을 가집니다.
위 그림에서 Magic 필드의 값이 010B임을 확인하실 수 있으며, 이는 32비트(IMAGE_OPTIONAL_HEADER32) 구조체임을 확인할 수 있습니다.
SizeOfCode: 코드 영역의 전체 크기가 이곳에 들어갑니다. 이는 .text 섹션의 크기가 들어간다는 것입니다. SizeOfCode의 값을 확인해보도록 하고, 이어서 .text 섹션의 크기와 SizeOfCode의 값이 일치하는지 비교도 해보도록 합시다.
위 그림에서 SizeOfCode의 값을 확인해보면 00052E00이며, 이는 .text의 크기와 정확히 일치합니다.
RawSize는 파일에서 해당 섹션이 차지하는 크기로 .text 섹션이 52E00만큼 공간을 차지한다고 볼 수 있습니다.
ImageBase: PE 파일이 메모리에 로드될 때의 시작 주소를 가리킵니다. 기본적으로 EXE 파일의 경우에는 0x400000 번지가, DLL 파일인 경우에는 0x10000000 번지로 지정되어 있으며 그렇다고 해서 항상 이 번지로 고정되어 있는게 아니라는 점을 주의하셔야 합니다. (이는 링커 옵션을 통해서 시작 주소를 지정할 수 있습니다) DLL의 경우는 기본 ImageBase의 값이 0x10000000 번지로 지정되어 있지만, 다른 DLL이 이 번지를 차지하고 있을 경우에는 다른 곳에 배치되는 재배치가 이루어집니다.
이 ImageBase는 RVA의 기준이 되며, 여기서 RVA(Relative Virtual Address, 상대 가상 주소)란 ImageBase를 기준으로 하여 어느만큼 떨어져 있는지를 나타내는 값으로 파일의 오프셋(Offset)과 같은 개념이라고 할 수 있습니다. 다만, RVA는 파일이 아닌 메모리 공간에서의 상대적인 값으로 예를 들면, ImageBase가 0x400000 번지일 경우 .text 섹션의 RVA 값이 0x3000 이라면 실제로 .text 섹션이 로드되는 위치는 0x403000이 되는 것입니다.
우선은 calc.exe의 ImageBase의 값을 보도록 하겠습니다.
위 그림에서 ImageBase의 값은 01000000이며, 0x1000000이 시작 주소임을 확인할 수 있습니다.
AddressOfEntryPoint: 프로그램이 메모리에서 실행 되는 시작 지점이며, 이는 진입점(Entry Point)를 말하는 것입니다. 위치는 RVA 값으로 저장되어 있으며, WinMain 혹은 DllMain의 번지라고 생각할 수 있습니다. (정확하게는 Start up의 번지라고 할 수 있습니다.) 실제로 다음 실행할 명령이 들어있는 메모리의 번지를 가지는 EIP 레지스터의 값을 파일이 메모리에 로딩되고 나서 ImageBase + AddressOfEntryPoint로 지정합니다. (올리디버거 같은 디버거를 통해서 파일을 실행시키고 나면 디버거가 처음 실행할 위치를 ImageBase + AddressOfEntryPoint로 잡습니다.)
위 그림에서 AddressOfEntryPoint의 값을 보시면 00012D6C 인 것을 확인할 수 있습니다. ImageBase + 12D6C의 값은 진입점의 주소라고 생각할 수 있습니다.
BaseOfCode: 코드 영역이 시작되는 상대 주소(RVA)가 담깁니다. BaseOfCode가 RVA니 ImageBase + BaseOfCode의 값은 실제 코드 영역의 주소가 됩니다.
위 그림을 보시면 BaseOfCode의 값이 00001000(0x1000)으로, 만약 ImageBase가 0x400000이라면 0x401000이 실제 코드 영역의 주소입니다.
SectionAlignment: 메모리에서 섹션의 최소단위를 나타냅니다. 메모리에서 섹션의 크기는 반드시 SectionAlignment의 배수가 되어야 합니다.
위 그림에서 SectionAlignment의 값은 00001000(0x1000) 입니다. 이는 메모리 공간에서 섹션의 크기가 0x1000의 배수라고 할 수 있습니다. 섹션의 크기에서 구조체 크기를 제외한 빈 공간은 모두 0으로 채워지며 이 것을 패딩(padding)이라고 합니다.
FileAlignment: 파일에서 섹션의 최소단위를 나타냅니다. 파일에서 섹션의 크기는 반드시 FileAlignment의 배수가 되어야 합니다.
위 그림에서 FileAlignment의 값은 00000200(0x200) 입니다. 이는 파일에서 섹션의 크기가 0x200의 배수라고 할 수 있습니다. SectionAlignment와 마찬가지로 섹션의 크기에서 구조체 크기를 제외한 빈 공간은 모두 0으로 채워집니다.
SizeOfImage: PE 파일이 메모리에 로딩되었을 때의 전체 크기를 담고 있습니다. 이 값은 파일의 크기와 같을 때도 있으며, 다를때도 있으나 다른 경우가 더 많습니다. PE 파일이 메모리에 로딩되고 나서는 SectionAlignment의 영향을 받아 패딩이 따라붙으며, SizeOfImage 역시 SectionAlignment의 영향을 받는다고 할 수 있습니다.
위에서 SizeOfImage의 값은 000C0000(0xC0000)이며, 이는 PE 파일이 메모리에 로딩되었을 때의 전체 크기가 0xC0000 라는 말이 됩니다.
SizeOfHeaders: 이름 그대로 모든 헤더의 크기를 담고 있습니다. 즉, 도스 헤더, 도스 스텁, PE 헤더, 섹션 헤더의 크기를 모두 더한 값이라고 할 수 있으며 파일의 시작점에서 SizeOfHeaders 만큼 떨어진 Offset에 첫번째 섹션이 존재합니다.
위 그림에서 SizeOfHeaders의 값은 00000400(0x400) 이며, 헤더의 총 크기는 0x400이라고 할 수 있습니다.
Subsystem: 이 값을 통해 시스템 드라이버 파일인지, 프로그램이 GUI 혹은 CUI 인지 알아낼 수 있습니다.
1
2
3
#define IMAGE_SUBSYSTEM_NATIVE 1 // Image doesn't require a subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_GUI 2 // Image runs in the Windows GUI subsystem.
#define IMAGE_SUBSYSTEM_WINDOWS_CUI 3 // Image runs in the Windows character subsystem.
1인 경우에는 시스템 드라이버 파일, 2인 경우에는 GUI 파일, 3인 경우에는 CUI 파일입니다. 이것 외에도 OS2, POSIX, CE 등과 같은 서브시스템이 존재하지만 GUI와 CUI 둘 중 하나인 것이 많아 위의 3개의 상수만 보여드렸습니다.
위 그림에서는 Subsystem의 값이 0002(0x2)로 GUI 파일이 되겠습니다. 만약 이 값이 0x3이였다면, CUI 파일이라고 할 수 있습니다.
DataDirectory: IMAGE_DATA_DIRECTORY 구조체를 보시면 VirtualAddress와 Size라는 필드가 존재합니다. 우선은 아래의 코드를 먼저 보도록 합시다.
#define IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR 14 // COM Runtime descriptor
IMAGE_OPTIONAL_HEADER의 DataDirectory 필드는 익스포트 디렉터리, 임포트 디렉터리, 리소스 디렉터리, 예외 디렉터리, 보안 디렉터리 영역 등에 접근할 수 있는 주소와 크기를 지니고 있는 배열로, IMAGE_DATA_DIRECTORY 구조체의 VirtualAddress를 통해 가상 주소를 알 수 있으며, Size를 통해 크기를 알 수 있습니다. 여기서 중요한 값은 EXPORT, IMPORT, RESOURCE, TLS, IAT인데 우선은 이것들을 잘 기억해두시기 바랍니다. 이부분에 대해서는 추후에 다시 설명하도록 하겠습니다.
4. IMAGE_SECTION_HEADER
옵셔널 헤더의 다음으로 섹션 헤더에 대해서 알아볼텐데, 섹션 헤더는 섹션 테이블이라고도 하며 IMAGE_SECTION_HEADER 구조체는 섹션에 대한 정보를 관리하는 구조체라고 할 수 있습니다. 이 구조체를 가지고 .text 섹션이나, .data 섹션, .rdata 섹션 등에 대한 정보를 알 수 있다는 것입니다. 우선은 아래의 코드를 먼저 보도록 합시다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#define IMAGE_SIZEOF_SHORT_NAME 8
typedefstruct_IMAGE_SECTION_HEADER {
BYTEName[IMAGE_SIZEOF_SHORT_NAME];
union{
DWORDPhysicalAddress;
DWORDVirtualSize;
} Misc;
DWORDVirtualAddress;
DWORDSizeOfRawData;
DWORDPointerToRawData;
DWORDPointerToRelocations;
DWORDPointerToLinenumbers;
WORDNumberOfRelocations;
WORDNumberOfLinenumbers;
DWORDCharacteristics;
} IMAGE_SECTION_HEADER, *PIMAGE_SECTION_HEADER;
#define IMAGE_SIZEOF_SECTION_HEADER 40
위 코드를 보시면 IMAGE_SECTION_HEADER가 보이는데, 필드는 총 11개로 이 중에서 Name, VirtualSize, VirtualAddress, SizeOfRawData, PointerToRawData, Characteristics 필드만 간략하게 알아보도록 하겠습니다. 먼저 Name 필드부터 알아보도록 합시다. 아래에서는 .text 섹션의 헤더를 가지고 설명을 하도록 하겠습니다.
<.text 섹션 헤더가 차지하는 공간>
Name: 필드명 그대로 섹션의 이름을 나타냅니다. 저기 상수 IMAGE_SIZEOF_SHORT_NAME의 값인 8, 섹션의 이름은 최대 8바이트까지 가능하다는 겁니다. 그리고 이 필드의 값은 NULL로 비어있을 수 있으며, 다 꽉 채울수도 있습니다. 기본적인 섹션의 이름에 대한 용도는 아래에 표로 정리해 두었습니다.
섹션명
용도
.text
코드, 실행, 읽기 속성을 지니며 컴파일 후의 결과가 이곳에 저장됩니다. 즉, 이 섹션은 실행되는 코드들이 들어가는 섹션입니다.
.data
초기화, 읽기, 쓰기 속성을 지니며 초기화된 전역 변수를 가집니다.
.rdata
초기화, 읽기 속성을 지니며 문자열 상수나 const로 선언된 변수처럼 읽기만 가능한 읽기 전용 데이터 섹션입니다.
.bss
비초기화, 읽기, 쓰기 속성을 지니며 초기화되지 않은 전역 변수의 섹션입니다.
.edata
초기화, 읽기 속성을 지니며 EAT와 관련된 정보가 들어가 있는 섹션입니다.
.idata
초기화, 읽기, 쓰기 속성을 지니며 IAT와 관련된 정보가 들어가 있는 섹션입니다.
.rsrc
초기화, 읽기 속성을 지니며 리소스가 저장되는 섹션입니다.
먼저 위 그림에서의 Name 필드를 보도록 합시다. Name 필드는 8바이트를 차지하니, 앞에서부터 8바이트를 그대로 읽으시면 됩니다. 읽었더니 2E 74 65 78 74 (NULL 생략)이며, 2E는 10진수로 64고 이 아스키코드에 해당하는 문자는 '.'이며, 74는 116으로 't', 65는 101으로 'e', 78은 120으로 'x', 74는 116으로 't' 합쳐서 ".text"라고 읽습니다.
VirtualSize: PE 로더를 통해 PE 파일이 메모리에 로드되고 나서의 메모리에서 섹션이 차지하는 크기를 가집니다. 위 그림에서 VirtualSize와 PhysicalAddress를 필드로 갖는 공용체가 존재하지만, 여기서 PhysicalAddress는 현재 사용되지 않는 필드고 VirtualSize 필드만 사용됩니다. Name 필드 다음부터 4바이트를 읽으면 A1 2C 05 00이 되고, 이는 00052CA1 이라는 값이 됩니다.
VirtualAddress: PE 로더를 통해 PE 파일이 메모리에 로드되고 나서의 해당하는 섹션의 RVA 값입니다. 즉, RVA는 이미지 베이스를 기준으로 하는 것이기에 예를 들어서 ImageBase의 값이 0x400000이고, VirtualAddress의 값이 0x1000이라면 로더는 0x401000에 섹션을 올리게 됩니다. 즉 ImageBase + VirtualAddress는 해당 섹션의 실제 주소값이라고 할 수 있습니다. 위 그림에서 VirtualSize 필드 다음부터 4바이트를 읽게 되면 00 10 00 00으로, 이는 00001000(0x1000)의 값이 됩니다.
SizeOfRawData: 파일 상에서의 해당 섹션이 차지하는 크기(옵셔널 헤더 구조체의 FileAlignment 값의 배수가 되도록 올림한 값)를 가집니다. 이는 실제로 사용된 크기이며, 패딩을 제외한 크기라고 할 수 있습니다. 위 그림에서 VirtualAddress 필드 다음부터 4바이트를 읽게 되면 00 2E 05 00으로, 이는 00052E00(0x52E00)의 값이 됩니다.
PointerToRawData: 파일 상에서의 해당 섹션이 시작하는 위치(파일 오프셋)를 담고 있습니다. 이 값 역시도 옵셔널 헤더 구조체의 FileAlignment 값의 배수가 되어야 하며, 위 그림에서 SizeOfRawData 필드 다음부터 4바이트를 읽게 되면 00 04 00 00으로, 이는 00000400(0x400)이 됩니다.
Characteristics: 섹션의 속성 정보를 플래그로 지니며, 여기서는 6가지의 속성만 알아보도록 하겠습니다.
#define IMAGE_SCN_CNT_INITIALIZED_DATA 0x00000040 // 데이터가 초기화된 섹션
#define IMAGE_SCN_CNT_UNINITIALIZED_DATA 0x00000080 // 데이터가 비초기화된 섹션
#define IMAGE_SCN_MEM_EXECUTE 0x20000000 // 실행 가능한 섹션
#define IMAGE_SCN_MEM_READ 0x40000000 // 읽기가 가능한 섹션
#define IMAGE_SCN_MEM_WRITE 0x80000000 // 쓰기가 가능한 섹션
한가지의 속성만 지닐 수 있는게 아닌, 여러가지 속성이 조합된 값을 가지며 만약 이 필드의 값이 0x60000020이라면, 0x40000000(IMAGE_SCN_MEM_READ), 0x20000000(IMAGE_SCN_MEM_EXECUTE), 0x00000020(IMAGE_SCN_CNT_CODE)의 플래그를 지니는 것으로 해당 섹션은 실행 가능한 섹션이며, 읽을 수 있고 코드로 채워진 섹션이라고 할 수 있습니다. 위 그림에서는 영역의 마지막 4바이트를 보시면 20 00 00 60으로, 이는 60000020(0x60000020)이 되는 것이라고 할 수 있습니다.
IAT(Import Address Table)
IAT(Import Address Table)을 알아보기 전에 몇몇 의문점을 풀어나가보려 합니다. 여기서는 DLL(Dynamic Linked Library, 동적 연결 라이브러리)에 대한 기본적인 지식은 갖추고 있다고 가정하겠습니다. 우리가 쓰고 있는 Win32 응용 프로그램은 과연 함수를 호출할 때 어떤 함수가 어디에 있는지는 어떻게 알고있는 걸까요? 프로그램은 혼자 실행되는 것이 아니라 외부 DLL를 로딩하여 함수를 호출합니다. 예를 들면, Win32 API 함수의 경우는 시스템 DLL에서 가져와 사용을 하게 되는데 함수가 어떤 방식으로 호출되는지 알아보기 위하여 FindWindow API를 사용하는 간단한 프로그램을 디버깅 해보도록 하겠습니다.
0x401031 번지를 보시면 FindWindow 함수를 직접 호출하는게 아니라,
042528C 번지에 있는 74FFFB43이라는 값을 가져와 호출을 하는 간접적인 호출 방식입니다. 왜 이러한 호출 방식을 사용하는 것일까요? 그냥 편하게 CALL 74FFFB43과 같이 직접적인 방식으로 호출을 하면 안되는 걸까요? 한번 간접 호출 방식이 아니라 직접 호출 방식으로 바뀌었다고 가정을 해봅시다. 그렇다면 CALL 74FFFB43과 같은 문장을 만나면 74FFFB43이란 주소가 user32.dll에 있는 FindWindowA의 실제 주소여야 합니다. 하지만 항상 user32.dll를 사용하려고 로딩할때마다 FindWindowA 함수가 74FFFB43를 실제 주소값으로 갖는게 아니라 다른 외부 DLL이 미리 자리를 차지하고 있으면 PE 로더가 다른 빈 공간을 찾아 로딩을 하여 재배치가 이루어지거나, 운영체제의 환경에 따라 user32.dll 내의 FindWindowA의 주소값이 바뀌기도 합니다.
그렇기 때문에, 어떠한 환경에서든 FindWindowA 함수의 호출을 보장하기 위해서 컴파일러가 42528C 번지에 미리 공간을 마련하고 파일이 실행된 직후에 PE 로더가 이 공간에 FindWindowA의 실제 주소를 넣어주는 것입니다. 실제로, DLL 내의 함수 주소들을 모아 놓은 테이블을 만들고 코드 섹션에서 만들어 놓은 테이블을 가져다 쓰는 방식으로 관리를 하며 여기서 이 테이블을 IAT라고 합니다. 이 부분에 대해서는 차차 알아가도록 하도록 하고, 우선은 IMAGE_IMPORT_DESCRIPTOR 구조체에 대해 간단히 알아보도록 하겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedefstruct_IMAGE_IMPORT_DESCRIPTOR {
union{
DWORDCharacteristics; // 0 for terminating null import descriptor
DWORDOriginalFirstThunk; // RVA to original unbound IAT (PIMAGE_THUNK_DATA)
} DUMMYUNIONNAME;
DWORDTimeDateStamp; // 0 if not bound,
// -1 if bound, and real date\time stamp
// in IMAGE_DIRECTORY_ENTRY_BOUND_IMPORT (new BIND)
// O.W. date/time stamp of DLL bound to (Old BIND)
DWORDForwarderChain; // -1 if no forwarders
DWORDName;
DWORDFirstThunk; // RVA to IAT (if bound this IAT has actual addresses)
위 구조체가 바로 IMAGE_IMPORT_DESCRIPTOR 구조체 입니다. 이 구조체에서는 6개의 필드중OriginalFirstThunk, Name, FirstThunk 필드만 알아보도록 하겠습니다. 하지만, 그전에 IMAGE_IMPORT_DESCRIPTOR가 PE 파일의 어느 곳에 위치하는지 알아보도록 하겠습니다. 옵셔널 헤더 구조체 내의 구조체 배열 DataDirectory의 2번째 요소가 바로 Import Directory 인것 기억하시죠?
여기서 앞의 4바이트는 VirtualAddress이며, 뒤의 4바이트는 VirtualSize로 VirtualAddress의 값은 00051AFC(RVA)이며, VirtualSize의 값은 00000154입니다. 여기서 RVA의 값을 가지고 RAW를 알 수 있으며 "RAW = RVA - VirtualAddress(메모리 공간에서의 섹션 시작 주소) + PointerToRawData(파일에서의 섹션 시작 위치)"의 식을 통해 RAW의 값을 알아낼 수 있습니다. 51AFC는 .text 섹션에 속해있으며, .text 섹션의 VirtualAddress 값은 1000, PointerToRawData의 값은 400입니다. 이 식을 통해 RAW의 값을 구하면 RAW = 51AFC - 1000 + 400, RAW는 50EFC이 됩니다.
위 영역이 모두 IMAGE_IMPORT_DESCRIPTOR 구조체 배열이며 이 영역 처음에서 20바이트 까지는 구조체 배열의 첫번째 요소라 할 수 있습니다. OriginalFirstThunk에 대해 간단히 알아보기 전, IMAGE_THUNK_DATA라는 구조체를 잠시 보고 넘어가도록 하겠습니다.
1
2
3
4
5
6
7
8
9
typedefstruct_IMAGE_THUNK_DATA32 {
union{
DWORDForwarderString; // PBYTE
DWORDFunction; // PDWORD
DWORDOrdinal;
DWORDAddressOfData; // PIMAGE_IMPORT_BY_NAME
} u1;
} IMAGE_THUNK_DATA32;
typedefIMAGE_THUNK_DATA32 * PIMAGE_THUNK_DATA32;
IMAGE_THUNK_DATA의 필드는 총 4개로 모두 공용체의 멤버이며, 공용체의 멤버인 만큼 DWORD가 차지하는 크기인 4바이트의 공간을 4개의 필드가 공유하며, 이 구조체는 AddressOfData 필드만 쓰이기도 하며, Ordinal 필드만 쓰이기도 하고, Function 필드만 쓰이기도 합니다.
OriginalFirstThunk:Import Name Table(INT)의 RVA를 지닙니다. 혹은 Import Lookup Table(ILT)의 RVA를 지닌다고 합니다. 여기서 INT는 구조체 IMAGE_THUNK_DATA의 배열로 구성이 되며, 구조체 IMAGE_THUNK_DATA의 AddressOfData 필드는 실제 Import되는 함수의 이름이 포함된 구조체 IMAGE_IMPORT_BY_NAME에 대한 RVA를 지닙니다.
위 그림에서 구조체 배열 첫번째 요소의 OriginalFirstThunk 값을 읽으면 00051D20(RVA)이고, 이는 51D20 - 1000 + 400으로 RAW는 51120 입니다. 51120로 이동하여 한번 보도록 합시다.
위 그림에서 영역 지정된 부분은 INT 영역, 즉 구조체 IMAGE_THUNK_DATA 배열의 영역이며 IMAGE_THUNK_DATA의 크기는 총 4바이트 입니다. (4개의 필드가 있으나 이는 모두 다 공용체의 안의 필드라서 같은 공간을 공유합니다) 그리고 INT의 끝은 NULL로 알 수 있으며, 이는 즉 읽어낸 4바이트가 모두 0일 경우에 그곳을 INT의 끝이라고 할 수 있습니다. 우선 첫번째 값을 읽으면 00052350(RVA)인데, RVA를 RAW로 바꾸면 52350 - 1000 + 400이니까 51750이 됩니다. 51750으로 이동해보도록 합시다.
위 그림에서 드래그 된 영역은 구조체 IMAGE_IMPORT_BY_NAME의 영역으로 여기서 라이브러리 안 함수의 이름이 나왔는데, 앞의 2바이트(WORD)인 00E1은 Ordinal로 라이브러리 내의 함수 고유 번호라고 할 수 있습니다. 문자열의 끝은 \0(NULL)이므로 함수명을 읽으면 SHGetSpecialFolderPathW가 되겠습니다.
Name: 임포트(Import)된 DLL의 이름을 담은 문자열의 주소를 지닙니다.
위 그림에서 구조체 배열 첫번째 요소의 Name의 값을 읽으면 00051D14(RVA)이고, 이는 51D14 - 1000 + 400으로 RAW는 51114가 됩니다. 51114로 이동하여 어떤 DLL이 임포트 되었는지 한번 보도록 합시다.
저기에 SHELL32.dll이 보이시죠? 그리고 Name의 끝은 당연히 문자열이므로 \0(NULL)이 되겠습니다.
FirstThunk: OriginalFirstThunk와 마찬가지로 FirstThunk 필드도 구조체 IMAGE_THUNK_DATA의 RVA 값을 지닙니다. PE 파일이 메모리에 로딩되고 나서는 구조체 IMAGE_THUNK_DATA는 Import한 DLL 내의 함수의 실제 주소값을 지니며, 이렇게 함수의 주소값을 담고있는 구조체 IMAGE_THUNK_DATA 배열을 Import Address Table(IAT)라고 합니다.
위 그림에서 구조체 배열 첫번째 요소의 FirstThunk 값을 읽으면 00001000(RVA)이고, 이는 1000 - 1000 + 400으로 RAW는 400이 됩니다. 우선 400으로 이동해보도록 합시다.
위 영역은 SHELL32.dll의 IAT 배열 영역으로 INT와 같이 구조체 IMAGE_THUNK_DATA 배열이며, 여기서는 Function 필드에 함수의 주소가 들어갑니다. IMAGE_THUNK_DATA는 총 4바이트니, 앞의 4바이트를 읽어보면 73820468 입니다. 위에서 PE 파일이 메모리에 로딩되기 전에는 AddressOfData로 쓰이거나 하지만, 로딩된 후에는 IMAGE_THUNK_DATA는 임포트한 DLL 내의 함수의 실제 주소값을 지닙니다.
IAT에 대한 설명은 여기서 마치고, EAT도 같이 설명하려고 하였으나 내용이 비슷하여 생략하도록 하겠습니다. IAT를 충분히 이해하시고 계시다면 EAT도 별 어려움없이 볼 수 있으실거라 생각합니다.
PE File Format에 대해서는 여기저기 자료가 많으니 개인적으로 공부하시기 바랍니다. 본 포스트에서는 PE 파일 중에서 각 섹션이 의미하는 바가 무엇인지 알아보고자 합니다.
아래 [그림 1.1.]은 기본적인 PE 파일 구조입니다. PE Header, Section(.text), Section(.data), Section(.rsrc)로 구성되어 있습니다. PE 파일 구조에 대해 이미 공부하신 분이라면 아시겠지만 PE 헤더는 수많은 정보를 포함하고 있습니다. EP 주소, 섹션 갯수, 각 섹션별 주소 등 많은 것을 익히려고 고생하셨을 겁니다.
[그림 1.1.] PE File Format
그러나 리버싱 관점에서 보면 PE 헤더는 그렇게 비중이 크지 않습니다. PE 헤더는 소스 코드를 컴파일할 때 컴파일러에서 만드는 정보이기 때문입니다.물론 상황에 따라 EP 주소를 확인해야 하는 경우도 있고 샘플이 .exe 파일인지 .dll 파일인지 확인할 때도 있지만 리버싱한다고 했을 때 우리가 분석하고자 하는 것은 실행코드 입니다. 그리고 실질적으로 실행코드에 대한 정보는 모두 섹션 영역에 있습니다. 아래 [그림 1.2.]는 PE 파일이 가질 수 있는 대표적인 섹션 종류입니다.
[그림 1.2.] PE 파일 섹션
섹션은 PE파일의 실제 내용을 담고 있는 블록들입니다. 각 섹션에 대한 특징은 다음과 같습니다.
아래 공격 코드는 연구 목적으로 작성된 것이며, 허가 받지 않은 공간에서는 테스트를 절대 금지합니다.
악의 적인 목적으로 이용할 시 발생할 수 있는 법적 책임은 자신한테 있습니다. 이는 해당 글을 열람할 때 동의하였다는 것을 의미합니다.
해당 문서의 저작권은 해당 저자에게 모두 있습니다. 다른 용도로 사용할 시 법적 조치가 가해질 수 있습니다.
테스트 환경 (해당 될시에만)
대상 실행파일 : bot.exe - 악성코드 샘플이지만 가볍고, 특이한 동작 없는 파일
상세 분석
PE (Portable Excutable) 포맷은 윈도우에서 사용되는 실행 가능한파일 형식을 말합니다. 하나의 실행파일을 다양한 운영체제에서 실행할 수 있다는 의미로‘이식 가능한 실행파일(PE)’라는 이름이 붙었습니다. 일반적으로 잘 알려진 exe, dll, obj,sys 등의 확장자를 가진 파일들이 여기에 해당되구요. 유닉스의 실행파일 형식인 COFF(CommonObject File Format)을 기반으로 만들어졌습니다.
PE를 구성하는 요소들은 각각 구조체의 형태를 가지고 있습니다. 이런 요소들은 크게묶어 두 부분으로 볼 수 있습니다. 하나는 헤더, 하나는 섹션입니다.
PE 구조에서 헤더에는 파일을 실행할 때 맨 처음 시작해야 할 코드의 시작부분에 대한 정보,프로그램이 구동 될 수 있는 플랫폼에 대한 정보 등 파일을 실행하는데 있어서 필요한 전반적인 정보들을 담고있습니다.
PE 구조에서 섹션에는 실제 프로그램을 구성하는 어셈블리 코드, 그리고 소스코드 내에서 선언한 전역변수나 static 변수들 등을 담고 있습니다.
PE라는 형식의 구조를 간단히 표현하면 위의 그림과 같습니다. 사실 PE 구조라고 해서 따로 뭔가가 있는 것이 아닙니다. 이전에 프로그램 실행의 이해에서 소스코드부터 여러 과정을 거쳐 실행 가능한 .exe 등의 파일이 생성되는 과정을 설명드린 적이 있습니다. 여기서 만들어진 .exe 파일도 하나의 PE 포맷의 파일이 되는 것이고, 따라서 PE의 구조를 갖고 있는 것입니다. 예를 들어, 우리가 간단히 만든 Hello World를 출력하는 프로그램도 PE 구조를 가지고 있고, 윈도우에 기본적으로 내장되어 있는 notepad.exe, calc.exe 같은 프로그램들도 PE 구조를 가지고 있습니다. 결론은 윈도우에서 실행가능한 실행파일이 가지는 구조라는 것입니다.
PE 구조는 위의 그림과 같은 순서로 구성요소들이 위치해 있습니다. 그리고 이 위치는 프로그램이 메인 메모리에 올라가도 거의 그 순서를 유지합니다. 하지만 각 섹션들은 메모리의 상태에 따라 다른 순서로 적재 되는 경우가 많습니다.그럼 지금부터 PE 포맷을 구성하는 구성요소들에 대해서 하나하나 알아가 보도록 하겠습니다
1. IMAGE_DOS_HEADER
위에 보이는 것이 바로 IMAGE_DOS_HEADER 구조체의 원형입니다. winnt.h 라는 헤더파일로 부터 가지고 왔습니다. winnt.h라는 헤더에는 PE 구조에 있는 구조체들이 정의 되어있습니다. 위에 명시된 순서대로 값들이 저장되어 있습니다. 여기서 한가지 알아두어야 할 것은 PE 구조를 구성하는 대부분의 요소들이 구조체로 이루어져 있기 때문에 그것들의 크기 역시 일정하다는 것입니다. 이 구조체는 offset이 0h 부터 40h 까지로 총 크기가 64byte입니다.
맞는지 의심이 되신다면 위에 나와있는 구조체 내의 필드들의 갯수과 각각의 자료형의 크기를 따져 계산해보시길 바랍니다.
IMAGE_DOS_HEADER라는 구조체에서 우리가 눈여겨 봐야할 것은 'e_magic'이라는 필드와 'e_lfanew'라는 필드입니다. 그 이유에 대해서는 잠시후 설명 드리도록 하겠습니다. 그럼 다음 그림을 보도록 하겠습니다.
PE 구조 설명의 예시로 사용할 bot.exe 라는 실행파일을 PEView라는 프로그램을 이용하여 열어보았습니다.
bot.exe라는 실행파일이 가지고 있는 IMAGE_DOS_HEADER 구조체의 필드들이 보여지고 있습니다. 여기에는 이전 그림에서 봤던 어떤 필드명도 나타나 있지 않습니다. 하지만 우리는 구조체에 나와있던 필드들이 순선대로 저장이된다는 사실을 알고 있습니다. 따라서 위의 그림에서 보여지는 것들이 IMAGE_DOS_HEADER 구조체의 필드값들을 순서대로 보여주는 것을 알 수가 있습니다.
여기서 우리가 중요시 봐야할 e_magic 필드와 e_lfanew 필드 값을 찾아보도록 하겠습니다.
1.1. e_magic
먼저 e_magic 필드를 찾아보도록 하겠습니다. e_magic 필드는 구조체 내에서 가장 첫번째에 위치해 있었습니다. 해당 필드는 WORD형 변수로 크기가 2byte입니다. 따라서, 가장 첫번째 2byte에 해당 필드 값이 저장되어 있을 것이라는 것을 알 수가 있습니다.
PE View를 통해서 본 IMAGE_DOS_HEADER 구조체의 가장 첫번째 2byte값입니다. Description을 보면 맨 처음 보았던 구조체의 원형에서 e_magic 필드의 주석과 같은 것을 볼 수 있습니다. 그리고 해당 데이터의 offset이 0h으로 가장 처음인 것 또한 알 수 있습니다. 이제 이 값은 e_magic 필드의 값이라는 것이 확실해졌습니다.
e_magic 필드의 값은 '0x4D5A' 입니다. PEView에서는 값을 리틀인디언 방식으로 표현하기 때문에 하위 바이트부터 표시가 됩니다. 그래서 우리가 보기 편한 빅인디언 방식으로 상위 바이트부터 표현을 하면 0x4D5A가 되는것입니다.
그럼 이번에는 bot.exe 파일을 헥사 에디터로 열어서 확인해보도록 하겠습니다.
처음 두 바이트가 4D, 5A 인 것을 확인할 수 있습니다. 해당 헥사코드들을 아스키코드로 바꾸면 'MZ' 라는 값이 나옵니다. 이 값은 IMAGE_DOS_HEADER 구조체에 항상 나오는 값으로 아주 중요한 값입니다. 이 값을 보셨다면 '아 내가 IMAGE_DOS_HEADER 구조체를 찾앚구나' 라고 생각하셔도 됩니다. 그런데 왜 하필 'MZ'라는 값이 들어있을까요? 'MZ'라는 값은 도스를 설계한 사람 중의 한명인 Mark Zbikowski라는 사람의 이니셜이라고 합니다.^^ 이것을 알고 'MZ'값을 찾으면 사람을 만난 것 같아 반갑겠죠?
1.2. e_lfanew
다음은 IMAGE_DOS_HEADER 구조체의 가장 마지막 필드에 해당하는 e_lfanew 필드를 찾아보도록 하겠습니다. e_lfanew 필드는 LONG형 변수로 4byte의 크기를 갖습니다.
이것이 IMAGE_DOS_HEADER 의 가장 마지막 값인 e_lfanew 필드의 값입니다. 4byte 크기의 값으로 '0x000000D8' 이라는 값을 가지고 있습니다. 시작 offset이 0x3C 이므로 값은 offtset 0x3C, 0x3D, 0x3E, 0x3F에 걸쳐 있을 것으로 예상됩니다. 그럼 이번에도 헥사에디터를 통해 찾아보도록 하겠습니다.
예상한 offset에 리틀인디언 방식으로 0x000000D8 이라는 값이 들어가 있는 것을 확인할 수 있습니다.
리틀인디언 방식으로 보이기 때문에 헥사에디터에서는 0xD8000000 으로 보이는 것입니다.
e_lfanew 필드는 IMAGE_NT_HEADER의 시작 offset 값을 가지고 있습니다. IMAGE_NT_HEADER 구조체는 실질적으로 실행파일의 내용이 시작하는 부분입니다. 따라서 아주 중요한 값이라고 할 수 있습니다.
IMAGE_NT_HEADER 구조체에 대해서는 뒤에서 알아보도록 하고 지금은 넘어 가겠습니다.
지금까지 살펴본 IMAGE_DOS_HEADER 구조체의 e_magic 필드와 e_lfanew 필드를 잘 활용해야 할 것입니다.
2. DOS Stub Program
자, 이제는 그 다음에 나오는 DOS Stub Program을 살펴보도록 하겠습니다.
DOS Stub Program은 구조체가 아닙니다. 이름에 나와있는 것처럼 하나의 프로그램입니다. 사실 이것은 일반적인 실행파일을 실행함에 있어서 별로 눈여겨 볼 필요가 없는 부분이지만 여기다가 악성코드의 동작 내용을 심는다는 것을 본적이 있습니다. 따라서 왜 있는 것인지 정도는 알아야 된다고 생각하기 때문에 간단하게 짚어보고 넘어가도록 하겠습니다.
다시 말하지만 이 부분은 구조체가 아닌 하나의 작은 프로그램이기 때문에 이전의 구조체처럼 크기가 정해져 있지 않습니다. 그럼 끝인지 어떻게 아느냐 ? 바로 앞에서 알아보았던 e_lfanew 필드의 값을 참조하는 것입니다.
DOS Stub Program의 바로 다음에는 IMAGE_NT_HEADER 구조체가 있기 때문에 해당 구조체의 시작 offset 값을 가지고 있는 e_lfanew 필드를 참조한다면 DOS Stub Program의 끝을 알 수가 있습니다.
이 부분은 앞에서도 말했듯이 구조체 형태가 아니기 때문에 PEView에서도 헥사에디터처럼 보여줍니다.
이 실행파일의 DOS Stub Program은 총 152 byte의 크기를 가지고 있습니다.
그런데 아스키코드로 변환된 값에 'This program cannot be run in dos mode.'라는 값이 있는 것을 볼 수가 있습니다. 이것이 무엇일까요?
DOS Stub Program은 32bit 윈도우 플랫폼에서 실행되도록 만들어진 PE파일이 16bit DOS 환경에서 실행되려 하는 것을 방지하는 프로그램입니다. 따라서 위와같이 이 프로그램은 도스 모드에서 실행될 수 없다는 문구를 출력해주는 것입니다. 그리고 실행을 하지 못하도록 합니다. 이런 내용이 일반적인 PE 포맷을 가진 파일에 다 들어가 있습니다. 하지만 그 크기가 모두 같지는 않습니다.
DOS Stub Program은 링커에 의해 삽입이 됩니다. 링커의 옵션으로 /STUB:filename 이라는 것을 주면 filename에 해당하는 Stub Program을 PE 포맷의 파일에 집어 넣는 것입니다. 이 Stub Program은 개발환경 등에 따라 다르기 때문에 크기가 가변적입니다.
그럼 이제 왜 PE 구조내에 DOS Stub Program이라는 것이 들어가있고, 그것을 구분해내는 방법을 알게 되었을 것입니다.
3. IMAGE_NT_HEADER
다음은 IMAGE_NT_HEADER라는 구조체에 대하여 알아보도록 하겠습니다.
위의 그림은 IMAGE_NT_HEADER 구조체의 원형입니다.해당 구조체는 4byte 크기의 Signature 그리고 IMAGE_FILE_HEADER, IMAGE_OPTIONAL_HEADER32 로 구성되어 있습니다.
3.1. Signature
PEView에서 각각에 대하여 깔끔하게 정리를 해주었습니다. 가장 먼저 Signature입니다.
IMAGE_NT_HEADER 구조체의 시작부분인 Signature는 실질적으로 실행파일이 시작하는 부분입니다. 이전의 IMAGE_DOS_HEADER 구조체에서 e_lfanew 필드가 이 Signature라는 필드의 offset값을 저장하고 있었습니다. 그리고 지금 우리가 보고 있는 Signature의 offset값과 비교해보면 리틀인디언 방식으로 000000D8h로 일치한다는 것을 알 수 있습니다.
이 IMAGE_NT_HEADER의 Signature는 앞서 말했듯이 실질적으로 실행파일이 시작하는 부분입니다. 여기에는 항상 'PE\0\0'라는 데이터가 고정으로 들어있습니다. 이 부분을 찾으셨다면, '아.. 이제부터 시작이구나'하고 생각하시면 될 것 같습니다. 헥사에디터로 들여다 보도록 하겠습니다.
헥사코드로는 '0x50450000'라는 값이 들어있습니다. 'PE\0\0'입니다. 기억해두시길 바랍니다.
그럼 다음으로 넘어가보도록 하겠습니다.
3.2. IMAGE_FILE_HEADER
다음은 IMAGE_FILE_HEADER입니다.
IMAGE_FILE_HEADER 역시 구조체의 형태를 가지고 있으므로 구조체의 원형을 보도록하겠습니다.
구조체의 원형입니다. 많은 필드들이 자리잡고 있는 것을 보실 수 있습니다. 해당 필드들 중에서 우리가 필요한 필들에 대해서만 알아보도록 하겠습니다.
3.2.1. Machine
먼저 가장 처음에 자리잡은 Machine 필드입니다. 이 필드는 해당 파일이 어떤 아키텍쳐에서 동작하는 지에 대한 정보를 가지고 있습니다. 다음은 MSDN에서 발췌해온 자료입니다.
위의 내용은 Machine 필드에 대한 설명입니다. Machine 필드의 값에 따른 의미를 설명하고 있습니다.
각 값에 대한 설명은 다음과 같습니다.
0x014c
- x86 계열 즉, 인텔 아키텍쳐 32비트 환경에서 동작한다는 의미입니다.
0x0200
- x86-64 계열 즉, 인텔 아키텍쳐 64비트 환경에서 동작하다는 의미입니다.
0x8664
- 이 값은 다른데서는 잘 언급이 되어있지 않습니다만, AMD 계열 64비트를 의미하는 것 같습니다.
그럼 우리가 분석 중인 파일은 어떤 값을 가지고 있는지 알아보도록 하겠습니다.
해당 파일에서는 Machine 필드의 값이 '0x14C'인 것을 확인하였습니다. 그렇다면 위의 설명에 따라 인텔 아키텍쳐 32비트 환경에서 동작하는 실행파일이라는 것을 확인할 수 있습니다.
헥사 에디터에서도 역시 같은 값을 확인할 수 가 있습니다. 그런데 주의해야 할 것은 해당 필드는 리틀인디언 방식으로 데이터가 쓰인다는 것입니다. 그럼 다음으로 우리가 정보를 얻을 필드에 대해서 알아보도록 하겠습니다.
3.2.2. NumberOfSections
다음은 NumberOfSections 필드입니다. 해당 필드는 해당 파일이 가지고 있는 섹션의 수를 나타냅니다.
실제로 섹션의 갯수가 SECTION .rdata, SECTION .data, SECTION .rsrc로 3개 인것을 확인할 수 있습니다.
해당 필드 역시 리틀 인디언 방식으로 저장되는 값으로 헥사 에디터에서 값이 0x0300 으로 기록된 것을 확인할 수가 있습니다.
3.2.3. SizeOptionalHeader
NumberOfSections 필드의 다음에 나오는 필드들인 TimeDateStamp, PointerToSymbolTable, NumberOfSymbols 필드들은 건너 뛰고 SizeofOptionalHeader 필드를 살펴보도록 하겠습니다.
해당 필드는 IMAGE_NT_HEADER 구조체 내에서 IMAGE_FILE_HEADER 구조체 다음에 나오는 필드로, 역시 구조체의 형태를 가지고 있는 IMAGE_OPTIONAL_HEADER 구조체의 크기에 대한 정보를 가지고 있습니다.
우리가 분석중인 파일의 IMAGE_OPTIONAL_HEADER 구조체의 크기는 '0x00E0' = 224byte의 크기를 갖고 있다는 것을 알 수 있습니다. 해당 구조체의 크기에 대한 정보를 기록하는 필드가 있다는 것은 IMAGE_OPTIONAL_HEADER 구조체는 크기가 가변적이라는 것을 알 수가 있습니다.
해당 필드 역시 리틀인디언 방식으로 기록이 되어있는 것을 볼 수 있습니다.
3.2.4. Characteristics
그 다음으로 설명할 Characteristics 필드는 해당 실행파일의 특성에 대한 정보를 담고 있습니다.
각 특성별로 부여된 헥사코드가 있습니다. 실행파일에 해당하는 헥사코드 값들을 모두 더해 표시합니다.
특성별 헥사코드 값들의 의미는 다음과 같습니다.
0x0001
- 파일에 대한 재배치 정보가 없으므로, 기본 주소에 로드 되어야한다는 의미입니다.
0x0002
- 확인되지 않은 외부참조가 없고, 파일이 실행가능하다는 의미입니다.
0x0004
- COFF 라인 번호가 파일에서 삭제되었다는 의미입니다.
0x0008
- COFF 심볼 테이블 항목이 파일에서 삭제되었다는 의미입니다.
0x0010
- 적극적으로 워킹 셋을 정리해야한다는 의미입니다.
0x0020
- 응용 프로그램이 2GB보다 큰 주소를 처리할 수 있다는 의미입니다.
0x0080
- 단어의 바이트가 반대로 되어있다는 의미입니다.
0x0100
- 32비트 단어를 지원한다는 의미입니다.
0x0200
- 디버깅 정보가 해당 파일에 없고 다른 파일에 저장되어있다는 의미입니다.
0x0400
- 파일이 이동식 미디어에 있는경우, 그것을 복사하여 스왑파일에서 실행한다는 의미입니다.
0x0800
- 파일이 네트워크상에 있는 경우, 그것을 복사하여 스왑파일에서 실행한다는 의미입니다.
0x1000
- 파일이 시스템 파일임을 의미합니다.
0x2000
- 파일이 DLL 파일이며, 이것이 실행파일이지만 직접 실행할 수는 없다는 의미입니다.
0x4000
- 파일이 단일 프로세서 컴퓨터에서만 실행되어야 한다는 의미입니다.
0x8000
- 0x0080과 같은 의미 입니다.
파일의 특성에 따라 해당하는 헥사코드들을 더한 값을 Characteristics 필드에 저장합니다.
그럼 이제 우리가 분석중인 bot.exe 파일은 어떤 특성값을 가지고 있는지 확인해보도록 하겠습니다.
이 파일은 0x0001, 0x0002, 0x0004, 0x0008, 0x0100 네 가지 값을 더해 합이 '0x010F'가 되었습니다.
그럼 위에서 설명한 각 헥사코드별 의미를 더해 해석해보면 다음과 같은 정보를 얻을 수 있습니다.
파일에 대한 재배치 정보가 없어서 기본 주소에 로드가 될 것이고, 확인되지 않은 외부참조가 없어서 실행이 가능합니다. 또 COFF 라인 번호와 COFF 심볼 테이블 항목이 파일에서 삭제 되어있고, 32비트 단어를 지원하는 파일입니다.
이렇게 Characteristics 필드를 통해 위와 같은 정보를 얻어낼 수가 있습니다.
이 값 역시 리틀인디언 방식으로 저장이 되어있는 것을 확인할 수 있습니다.
추가적인 정보를 알려드리자면, 일반적인 응용프로그램은 대체로 Characteristics 필드의 값으로 '0x010F' 값을 가진다고 합니다.
3.3. IMAGE_OPTIONAL_HEADER
다음은 PE 헤더부분에서 가장 중요한 요소인 IMAGE_OPTIONAL_HEADER 에 대하여 알아보도록 하겠습니다.
계속 그래왔듯이 이번에도 해당 구조체의 원형부터 보도록 하겠습니다.
수많은 필드들이 보이십니까? 하지만 다행이도 저 많은 필드들을 모두 살펴볼 필요는 없습니다. 그럼 바로 이어서 중요한 필드들에 대해서 하나하나 알아보도록 하겠습니다.
3.3.1. Magic
Magic이라는 단어가 익숙하지 않으십니까? 이전의 IMAGE_DOS_HEADER 구조체에서 e_magic이라는 필드에 대해서 알아본적이 있습니다. e_magic이라는 필드는 하나의 Signature로 해당 구조체의 시작부분임을 알리는 역할을 하여 'MZ'라는 특정 단어가 들어가 있었음을 확인했었습니다. 제가 왜 이것을 다시 설명했을까요?
눈치채신 분들도 있을 것입니다. IMAGE_OPTIONAL_HEADER의 Magic 필드 역시 해당 구조체의 시작임을 나타내는 Signature로 사용되는 것입니다. 그렇다면 어떤 특정 값으로 고정이 되어있을 것임을 알 수가 있습니다.
어떠한 값이 들어가 있는지 찾아보도록 하겠습니다.
먼저 PEView를 통해 값을 확인해보았습니다. '0x10B'라는 값이 들어있음을 볼 수가 있습니다. 지금은 우리가 32비트에서 동작하는 PE를 가지고 확인을 해보고 있지만 64비트환경에서 동작하는 PE에서는 이 값이 '0x20B'로 고정되어있습니다.
헥사에디터에서는 리틀인디언 방식으로 0x0B01 이라고 값이 들어가 있음을 확인할 수 있습니다.
3.3.2. AddressOfEntrypoint
다음은 AddressOfEntrypoint라는 필드입니다. 해당 필드에 대해서 알아보기 전에 알아두어야 할 개념을 먼저 배우고 해당 필드에 대해서 알아보도록 하겠습니다.
제가 이전에 다루었던 가상 메모리의 이해라는 페이지에서 32비트환경에서는 프로그램당 4GB의 메모리 공간을 할당한다고 하였습니다. 여기서 할당되는 메모리 공간은 가상 메모리로 0x00000000 부터 0xFFFFFFFF 까지의주소를 가집니다. 이 4GB 크기의 가상 메모리 주소를 VA(Virtual Address) 즉, 가상 주소라고 합니다.
가상 주소와 더불어 앞으로 나올 개념인 RVA에 대하여 꼭 알고 다음으로 넘어가셔야 합니다.
RVA라는 것은 Relative Virtual Address의 약자로 상대적인 가상 주소라는 개념입니다. 이해가 안되시죠?
저는 그림을 그려 설명하는 것을 좋아하기 때문에 그림을 보도록 하겠습니다.
위의 그림은 왼쪽부터 오른쪽으로 보시면 되겠습니다. 먼저 메인 메모리와 하드 디스크로부터 띄엄띄엄 떨어져있는 사용가능한 공간들을 연속된 주소를 가진 공간으로 사용하기 위해 4GB 크기의 가상 메모리를 구성합니다. 여기서 사용되는 연속된 주소를 가상주소 VA(Virtual Address)라고 합니다. 실제로는 불연속적인 메모리 공간들로 구성되어 있지만 프로그램이 사용하기 편하도록 가상의 연속된 주소로 볼 수 있게 재구성 한 가상 메모리 입니다. 이부분에 대해서는 설명해둔 것이 있기 때문에 더이상 설명하지 않겠습니다.
이렇게 구성된 가상메모리에 PE파일이 로드되어 동작을 합니다. 가상 메모리의 일부분에 로드된 PE파일은 그 안에서 또 상대적인 가상 주소를 사용하여 0x00000000 부터의 주소를 사용합니다. 이것이 바로 상대적인 가상 주소 RVA(Relative Virtual Address)입니다.
즉, RVA(Relative Virtual Address)는 가상 주소 공간(가상 메모리)내에 로드된 PE파일의 시작주소로부터 상대적인 번지의 위치를 나타내는 Offset인 것입니다. 예를 들어, PE파일을 분석하다가 'IMAGE_NT_HEADER의 시작 offset 값이 0x000000D8이다.'라고 한다면 0x000000D8 이라는 것은 가상 메모리의 주소가 아니라 'IMAGE_NT_HEADER의 시작 위치는 해당 PE파일이 로드된 시작주소로부터 0x000000D8 만큼 떨어진 위치에 있다.'라고 하는 것이 맞다는 말입니다.
따라서, RVA를 가지고 가상 메모리상에서의 실제 위치를 구하려고 한다면 다음과 같이 계산합니다.
실제 위치(가상 메모리의 주소, VA) = PE파일이 로드된 시작번지 + RVA
이제 VA와 RVA에 대한 개념이 잡히셨을 거라고 생각을 하고, AddressOfEntrypoint 필드에 대하여 알아보도록 하겠습니다.
AddressOfEntrypoint 라는 이름에서 해당 필드가 어떤 값을 저장하는지 감이 오시는 분들도 있을 것입니다.
해당 필드는 EP 즉, EntryPoint라고 하는 주소값을 가지고 있습니다. 그런데 그냥 주소 값이면 위에서 설명한 것들이 의미가 없어지겠죠?
해당 필드에는 EntryPoint의 RVA 값이 저장되어 있습니다. 이것이 의미하는 것은 EntryPoint라는 지점의 위치를 PE파일이 시작하는 위치로부터 얼마나 떨어져있는지를 나타내는 offset 값이 저장되어 있다는 것입니다.
그런데 EntryPoint라는 것이 무엇일까요?
여기서 말하는 EntryPoint라는 것은 로더에 의해 PE파일이 로딩되고나서 가장 먼저 시작되는 소스코드의 시작점이라고 할 수 있습니다. 이 EntryPoint라는 것이 중요한 의미를 가지고 있다는 것을 아셔야합니다.
왜냐하면 두말 할것도 없이 제일 먼저 시작되는 소스코드의 시작점이니까요.
PEView를 이용해서 AddressOfEntrypoint의 값을 확인해 보았습니다. '0x0002CB61' 이라는 값이 있음을 확인할 수가 있습니다.
다음은 헥사에디터에서 찾아보도록 하겠습니다.
offset 100h에 리틀인디언 방식으로 0x0002CB61 값이 들어가있음을 확인할 수가 있습니다.
그런데 이 EntryPoint라는 값은 앞에서도 말했듯이 중요한 의미를 가지고 있습니다.
그래서 PE파일을 분석해주는 다른 툴들에서는 EntryPoint를 따로 찾아내어 표시해주는 것이 일반적입니다.
PEiD와 Stud_PE라는 툴을 통해 그 예를 보도록 하겠습니다. 먼저 PEiD입니다.
위의 그림은 PEiD를 이용하여 bot.exe를 열어본 결과입니다. 첫번째 항목으로 Entrypoint가 있으며, 우리가 PEView를 통해 알아보았던 AddressOfEntrypoint 필드에 저장되어 있던 값과 동일한 값이 표시되는 것을 확인할 수 있습니다.
다음은 Stud_PE를 이용해 bot.exe를 열어본 결과입니다.
역시 첫번째 항목으로 EntryPoint가 있으며 심지어는 빨간색으로 강조되어 있습니다. 그리고 RVA 값임을 알 수 있도록 표시를 해둔 것이 참 친절하다는 생각이듭니다.
이처럼 EntryPoint는 PE파일이 가지고 있는 소스코드의 실질적인 시작점으로 중요한 의미를 갖는다는 것을 알아두셨으면 좋겠습니다. 또한 그 값을 가지고 있는 필드가 IMAGE_NT_HEADER 구조체 내의 IMAGE_OPTIONAL_HEADER가 가진 필드인 AddressOfEntrypoint에 저장이 되어있다는 것도 알아두시면 좋겠습니다.
3.3.3. ImageBase
다음으로 살펴볼 필드는 ImageBase라는 필드입니다.
PEView를 통해 ImageBase 필드의 값을 확인해보았습니다. offset 10Ch에 '0x400000'이라는 값이 있습니다.
헥사에디터에서 offset 10Ch에 해당하는 위치를 찾아가 보았습니다.
우리가 찾았던 0x00400000 값이 리틀인디언 방식으로 자리잡고 있는 것을 확인할 수 있습니다.
이 ImageBase 필드에 저장되어 있는 값은 PE파일이 로드되는 위치를 나타냅니다. 즉 VA(Virtual Address), 가상주소 값을 가지고 있습니다. RVA값이 아닌, RVA의 기준이되는 위치 값을 가지고 있는 것입니다.
이 값은 링커에 의해서 설정이 되며, 일반적으로 우리가 분석하고 있는 bot.exe와 같은 EXE파일의 경우에는 해당 필드 값이 0x00400000으로 설정되어 있습니다. 그러면 PE파일의 시작지점은 가상 메모리의 주소범위인 0x00000000 ~ 0xFFFFFFFF 중에서 0x00400000라는 주소를 가지는 번지에 위치하게 되는 것입니다.
이 위치는 곧 PE 프로그램 내에서 사용하는 RVA값의 기준 즉, RVA값으로 0h의 위치를 갖게되는 것입니다.
하지만, DLL 파일의 경우에는 일반적으로 해당 값이 0x10000000으로 주어집니다. 왜냐하면 DLL 파일은 보통 다른 모듈에 의해서 불려오는 경우가 많기때문에 일반적인 EXE파일이 로드되는 가상주소 0x00400000 번지에 로드를 했다가는 충돌이 일어나는 상황이 발생할 수 있기 때문입니다. 따라서 DLL파일은 가상주소 0x10000000 번지에 로드가 되어 다른 곳에 재배치 되는 과정을 거치게 됩니다.
그래서 ImageBase 필드 값을 통해서 우리는 PE파일이 가상 메모리 상의 어느위치에 로드가 되었는지를 알 수 있게 되는 것입니다. 즉, RVA의 기준이 되는 값을 알아냄으로써 PE를 구성하는 데이터들이 가상메모리 상의 어느 번지에 저장이 되어있는지 알 수가 있는 것입니다.
추가적으로, 앞에서 말했듯이 이 값들은 일반적인 값이지 절대적인 값들이 아닙니다. 이 값들은 링커의 옵션을 통해 다른 값으로 바꿀 수도 있습니다.
3.3.4. SectionAlignment
다음으로 살펴볼 필드는 SectionAlignment 입니다.
섹션이라는 것은 실제 프로그램을 구성하는 어셈블리 코드, 그리고 소스코드 내에서 선언한 전역변수나 static 변수들 등을 담고 있다고 처음에 말씀드린 적이 있습니다.
그렇다면 Alignment는 무엇일까요? 자동차에 관심이 많은 분들이라면 휠 얼라이먼트라는 것을 들어보신 적이 있을 것입니다. 대개 정비소에 커다란 현수막으로 붙어져있죠. '휠 얼라이먼트 합니다'라구요.
휠 얼라이먼트라는 정비작업은 쉽게 말하면 자동차의 핵심인 바퀴들을 정렬하는 작업입니다. 자동차를 타고 다니다보면 여러가지 충격들에 의해 출고 당시 일정한 각도, 위치로 정렬되어 있던 바퀴들이 조금씩 틀어지게 됩니다. 그런 충격들이 많이 쌓이다보면 차 바퀴의 각도가 운전 중에 느껴질 정도로 틀어지게 됩니다. 그럼 아주 위험하겠죠. 왜 갑자기 자동차 정비에 대한 이야기가 나왔을까요? 사실 Alignment라는 단어가 생소한 분들을 위해 뜻을 설명해 드리고 싶은데, 사전적 의미만 말하면 너무 딱딱하고 재미없지 않습니까 :-)
잠시 다른 이야기로 샜었는데, 여기서 제가 초점을 두고 싶은 것은 각도가 틀어진다는 것이 아니라 얼라이먼트라는 작업이 '일정하게 정렬한다'는 것을 의미한다는 것입니다.
그럼 본 내용으로 돌아와서 SectionAlignment는 무엇을 의미할까요?
결론부터 말하면, SectionAlignment는 메모리 상에서 섹션을 정렬하는 단위입니다.
먼저 PEView를 통해서 SectionAlignment 필드의 값을 확인해보도록 하겠습니다.
위의 그림에서 해당 필드에 저장된 값은 '0x00001000' 입니다. 이 값을 기준으로 섹션들이 정렬될 것입니다.
사실 이렇게 말하면 이해가 잘 되지 않을 것입니다. 따라서 그림을 그려 설명하도록 하겠습니다.
위의 그림처럼 SectionAlignment 필드의 값이 0x00001000 이면 섹션의 절대 단위는 4096 바이트가 됩니다.
그럼 이제 예를 들어보도록 하겠습니다. .text 섹션과 .data 섹션이 연속되어 나열된 섹션이라고 하겠습니다.
그리고 .text 섹션의 데이터는 총 512 바이트이고 .data 섹션의 데이터는 총 5632 바이트라고 하겠습니다.
그럼 각 섹션의 데이터는 어떤 형태로 배치가 될까요?
위의 그림을 보십시오. .text 섹션이 가지는 데이터의 크기는 섹션의 단위에 훨씬 못미치는 512 바이트였습니다. 하지만 .text 섹션은 4096 바이트의 공간을 할당 받았습니다. 섹션의 단위가 4096 바이트이기 때문에 .text 섹션의 데이터의 크기가 매우 작아 빈공간이 생기더라도 섹션의 절대 단위인 4096 바이트의 공간을 할당할 수 밖에 없습니다. .text 섹션의 데이터가 끝나고 3584 바이트의 빈공간이 지나고 나서야 다음 섹션인 .data 섹션이 시작됩니다.
그럼 .data 섹션도 살펴보도록 하겠습니다. 해당 섹션이 가지는 데이터의 크기는 섹션의 절대 단위인 4096 바이트를 훨씬 웃도는 5632 바이트입니다. 그럼 해당 섹션이 할당 받는 공간은 첫 4096 바이트에 대한 공간으로 절대단위인 4096 바이트의 공간과 나머지 1536 바이트에 대한 공간으로 절대 단위인 4096 바이트의 공간을 할당 받아 총 8192 바이트의 공간을 할당 받게 되는 것입니다.
섹션의 최소단위라는 개념을 이제 이해하셨으리라 생각됩니다. 위에서 열심히 설명한 섹션의 단위를 설정하는 필드가 SectionAlignment 필드입니다.
이쯤에서 안보고 넘어가면 섭섭할 헥사에디터에서 해당 필드를 찾은 값을 확인해보도록 하겠습니다.
예상대로 0x001000 이라는 값이 리틀인디언 방식으로 저장되어 있는 것을 확인할 수 있습니다.
3.3.5. FileAlignment
다음은 FileAlignment 필드입니다.
FileAlignment 필드도 SectionAlignment 필드와 같이 섹션의 절대 단위를 설정하는 값을 저장하는 필드입니다. 다만, SectionAlignment 필드가 메모리상에서 섹션의 단위를 설정하였다면 FileAlignment 필드는 디스크 상에서 섹션의 절대 단위를 설정합니다.
예를 들어, SectionAlignment 필드와 FileAlignment 필드에서 설정하는 섹션의 단위가 같다면 디스크 상에서 파일 형태로 있을 때의 PE 파일의 모습이나 메모리 상에서의 PE 파일의 모습은 같은 모습을 하고 있을 것입니다.(물론 예외사항이 없다고 가정했을 때입니다.)
그럼 우리가 분석중인 PE 파일의 FileAlignment 필드 값은 어떨까요? PEView를 통해 확인해보도록 하겠습니다.
해당 필드의 값이 '0x00001000' 으로 SectionAlignment 필드의 값과 일치합니다. 그러므로 우리가 분석중인 bot.exe 파일은 별다른 예외사항이 없다면 디스크 상에서와 메모리 상에서의 PE 파일의 모습이 같다고 볼 수 있습니다.
다음은 헥사에디터에서 offset 114h를 찾아가 값을 확인 해보도록 하겠습니다.
예상한대로 0x00001000 값이 리틀인디언 방식으로 저장되어 있는 것을 확인할 수 있습니다.
3.3.6. SizeOfImage
다음으로 알아볼 필드는 SizeOfImage 필드입니다.
해당 필드는 PE 파일이 메모리 상에 로드되기 위해 충분히 확보해야 할 크기를 설정합니다. 즉, 메모리 상에 로드된 PE 파일의 전체 크기라고도 할 수 있습니다. 해당 필드에 설정되는 값은 SectionAlignment 에 설정된 섹션의 절대 단위의 배수가 되야합니다.
실제로 섹션 절대 단위의 배수가 설정되어 있는지 확인해보도록 하겠습니다.
PEView에 나타난 해당 필드의 값을 확인한 결과 저장된 값은 '0x00034000' 으로 SectionAlignment 필드에 설정된 값인 0x00001000의 배수임을 확인할 수 있습니다.
표시된 offset 값인 128h 위치를 헥사에디터로 찾아보도록 하겠습니다.
이것 역시 예상한대로 0x00034000 값이 리틀인디언 방식으로 저장되어 있는 것을 확인할 수 있습니다.
3.3.7. SizeOfHeader
다음으로 알아볼 필드는 SizeOfHeader 입니다.
해당 필드는 이름에서 부터 어떤 값을 저장하고 있는지 냄새를 풀풀 풍기고 있습니다.
SizeOfHeader 필드는 바로 PE 헤더의 크기값을 저장하고 있습니다. PE 헤더는 PE 파일의 시작지점부터 첫번째 섹션의 시작지점까지를 말합니다. 해당 글의 맨 처음 그림을 보면 바로 이해하실 수가 있습니다.
즉, 다르게 말하면 PE 파일의 시작지점부터 SizeOfHeader 필드에 저장된 값만큼 떨어진 위치를 찾으면, 첫번째 섹션을 찾을 수 있다는 말입니다. 하나 더 알아두어야 할 것은 해당 필드의 값은 FileAlignment에 저장된 값의 배수가 되어야 한다는 것입니다.
바로 PEView를 이용해서 값을 확인해 보도록 하겠습니다.
해당 필드에 0x00001000 값이 들어있는 것을 확인할 수 있습니다. 이것이 의미하는 것은 offset 값 1000h 부터 첫번째 섹션이 시작한다는 것입니다. 또한 FileAlignment의 값 0x00001000의 배수인 것도 확인할 수 있습니다.
그럼 이번에는 헥사에디터를 이용해서 SizeOfHeader값이 아닌, SizeOfHeader에 저장된 값인 offset 1000h
위치를 찾아가 보도록 하겠습니다.
위의 그림을 보면 offset 1000h 의 바로 앞까지는 빈공간이고, 해당 offset부터 데이터들이 저장되어있는 것을 확인할 수 있습니다. 이것은 이전에 설명한 Alignment에서의 절대 단위와 같은 개념으로 보면 됩니다.
SizeOfHeader의 값은 PE 헤더와 섹션의 경계를 나타내는 값입니다. 또한 FileAlignment 값의 배수가 되어야 된다고 했습니다. 그러므로 PE 헤더의 크기가 0x00001000 단위로 끝나지 않는다면 FileAlignment 값의 배수가 되는 공간을 맞추기 위해 남은 공간을 빈값으로 채워야 한다는 것입니다.
따라서 위와 같이 실제 PE 헤더에 해당하는 데이터들을 먼저 저장하고, PE 헤더에 할당된 공간을 FileAlignment 필드의 값인 0x00001000의 배수로 맞추기 위해 빈값들을 채워 넣은 것입니다. 그렇게 PE 헤더의 전체 크기를 0x00001000의 배수로 맞추고 나서야 섹션의 데이터가 저장되는 것입니다.
위와 같은 옵션을 줄 수가 있습니다. 이 옵션으로 어떤 값이 주어지냐에 따라서 PE파일을 실행한 결과 다릅니다.
예를 들면 CONSOLE 이라는 값을 주었을 경우에는 PE파일이 윈도우 콘솔 창에서 실행이 됩니다. 흔히 말하는 CMD 창이죠. 그리고 WINDOWS 라는 값을 주었을 경우에는 PE파일이 윈도우 자체 창을 통해서 실행됩니다.
MajorSubsystemVersion과 MinorSubsystemVersion 필드의 값은 위의 옵션에 따라 달라집니다.
위와 같이 옵션으로 지정되는 Sub System의 종류에 따라 버전 값이 정해져 있습니다. 우리가 일반적으로 사용하는 Win32 응용 프로그램의 경우에는 MajorSubsystem 값이 4로 주어지고, ( WINDOWS 옵션을 사용하고 x86환경이기 때문에 4.00 이겠죠? 여기서 소수점 앞부분인 4 입니다.) MinorSubsystem 값은 0으로 주어집니다.
( 마찬가지로 4.00에서 소수점 아랫부분인 00 입니다. )
( 소수점 앞부분 뒷부분으로 나뉘어 지는 것이 맞는지 확신을 못하겠습니다. 아시는 분은 덧글 남겨주시면 감사하겠습니다 ^^ )
그럼 이제 우리가 분석 중인 bot.exe 파일을 살펴보도록 하겠습니다. 먼저 PEView를 보겠습니다.
MajorSubsystemVersion 값이 '0x0004', MinorSunsystemVersion값이 '0x0000'으로 각각 4 와 0 인것을 확인 할 수 있습니다. 다음으로 헥사 에디터에서 해당 offset을 찾아 값을 살펴 보도록 하겠습니다.
앞에서 부터 offset 120h ,121h, 122h,123h 입니다. 120h~121h 에는 MajorSubsystemVersion 값인 0x0004 가 저장되어 있고, 122h~123h 에는 MinorSubsystemVersion 값인 0x0000 이 저장되어 있는 것을 확인할 수 있습니다. 각각의 값들이 리틀인디언으로 저장되어 있습니다.
따라서 해당 PE파일은 CONSOLE이 되었든 WINDOWS가 되었든 Win32 응용 프로그램인 것을 알 수 있습니다.
3.3.9. Subsystem
다음으로 알아볼 필드는 Subsystem 입니다.
해당 필드는 PE파일이 드라이버 파일인지, GUI 기반의 파일인지, CUI 기반의 파일인지를 알아볼 수 있는 설정값을 가지고 있습니다. 물론 다른 기반의 파일일 수도 있으므로 그에 해당하는 설정 값도 있지만, 일반적으로 우리가 볼 수 있는 값은 0x1, 0x2, 0x3입니다. 0x01은 해당 파일이 .sys 확장자를 갖는 드라이버 파일임을 나타냅니다. 0x02는 메모장 같은 GUI 기반 응용 프로그램임을 나타내고, 0x03은 CMD와 같은 CUI 기반 응용 프로그램임을 나타냅니다.
먼저 PEView를 통해 살펴보겠습니다.
해당 파일에는 '0x0002'라는 값이 들어있습니다. 옆의 설명에는 WIDOWS_GUI라고 나와있습니다. 사실 해당 파일은 실행하면 아무것도 뜨지 않습니다. 해당 파일 제작자는 그냥 WINDOWS CUI와 GUI 중 마음에 드는 것을 고른 것 같습니다. ( 이부분에 대한 의견이 있으신 분 덧글 부탁드립니다.^^ )
다음은 헥사에디터에서 해당 offset을 찾아가 값을 찾아보도록 하겠습니다.
역시나 0x0002 값이 리틀인디언 방식으로 저장되어 있는 것을 확인할 수 있습니다.
3.3.10. SizeOfStackReserve/SizeOfStackCommit
다음으로 알아볼 필드는 스택에 관련된 값입니다.
스택이라는 것은 프로그램에서 사용하는 일종의 메모리 공간입니다. 스택에 대한 자세한 개념은 따로 정리를 하도록 하겠습니다. 해당 필드들은 이 스택이라는 공간에 대한 값을 설정합니다.
SizeOfStackReserve 필드는 스택이라는 공간을 위해 예약된 메모리의 크기 값을 가집니다. 그리고 SizeOfStackCommit 필드는 현재 스택이라는 공간을 위해 할당된 메모리의 크기 값을 가집니다. 그런데 일반적으로 SizeOfStackReserve 필드의 값은 0x100000, SizeOfStackCommit 필드의 값은 0x1000으로 링커에 의해 기본 값이 설정됩니다.
먼저 해당 값들을 PEView를 통해 보도록 하겠습니다.
역시 예상한대로 각각 순서대로 0x100000, 0x1000 이라는 값이 들어있는 것을 확인할 수 있습니다.
어라, 그런데 밑에 SizeOfHeapReserve라는 필드와 SizeOfHeapCommit라는 필드도 있습니다. 심지어 가지고 있는 값들도 똑같습니다. 예, 그렇습니다. Heap이라는 메모리 공간에 대한 크기 값을 나타내는 것입니다.
사실 스택, 힙 이름만 다르지 각 필드에 대한 설명이 같아서 은근슬쩍 끼워 넣었습니다.
그럼 쭉 연달아 있는 4개의 필드들을 헥사에디터로 한번에 찾아보도록 하겠습니다.
offset 138h 부터 147h 까지 각 4바이트씩 0x00100000(스택 예약), 0x00001000(스택 현재), 0x00100000(힙 예약), 0x00001000(힙 예약) 이라는 값들이 들어가 있는 것을 확인할 수 있습니다.
4. IMAGE_DATA_DIRECTORY
다음은 IMAGE_DATA_DIRECTORY에 대하여 알아보도록 하겠습니다.
IMAGE_DATA_DIRECTORY라는 구조체는 사실 IMAGE_OPTIONAL_HEADER 구조체의 구성요소입니다.
위의 그림은 IMAGE_OPTIONAL_HEADER 구조체 원형의 일부분 입니다. IMAGE_OPTIONAL_HEADER 구조체의 구성요소로써 IMAGE_DATA_DIRECTORY 구조체가 배열의 형태로 있는 것을 확인할 수 있습니다.
배열의 길이를 나타내는 IMAGE_NUMBEROF_DIRECTORY_ENTRIES 값을 찾아보도록 하겠습니다.
아예 16으로 정의가 되어 고정값으로 쓰이고 있는 것을 확인할 수 있었습니다. 이를 통해서 IMAGE_DATA_DIRECTORY 구조체는 16개의 엔트리를 가지는 것을 알 수가 있습니다.
다음은 IMAGE_DATA_DIRECTORY 구조체의 원형을 보도록 하겠습니다.
Virtual Address와 Size라는 두 개의 필드를 가지고 있습니다. 이로써 IMAGE_DATA_DIRECTORY 배열의 각 요소들은 어떤 것에 대한 주소값과 크기에 대한 정보를 가지고 있다는 것을 알 수가 있습니다. 그런데, 여기서 VirtualAddress라는 필드의 이름만 보고 가상주소라고 생각하면 안됩니다. MSDN을 참고해보면 해당 필드의 값은 RVA 값으로 저장이된다고 명시되어 있습니다.
그렇다면 어디에 대한 RVA 주소값을 가지고 있을까요? PEView를 통해 IMAGE_DATA_DIRECTORY 구조체 배열을 구성하는 요소들을 살펴보고 설명을 이어가도록 하겠습니다.
위에 보이는 그림이 PEView를 통해 IMAGE_DATA_DIRECTORY 구조체 배열을 확인한 것입니다.
총 16개의 요소로 이루어져 있습니다. 위에서부터 IMAGE_DATA_DIRECTORY[0], IMAGE_DATA_DIRECTORY[1], ... , IMAGE_DATA_DIRECTORY[15] 가 되는 것입니다.
가장 마지막에 해당하는 요소에는 모두 0x0 값으로 채워져있어 사실상 아무런 의미가 없는 요소입니다.
그럼 값이 들어가 있는 나머지 요소들은 도대체 어떤 의미를 담고 있을까요? 사실 위의 그림에서 오른쪽에 나와있는 각 요소에 대한 설명을 살펴보면 대략적으로 알 수 있습니다. 여러 테이블들이 눈에 띄네요.
이것들을 하나하나 설명을 하고는 싶지만, 사실 많기도 하지만 지금 수준에서는 어려운 개념들이 많아 이해하기가 어렵습니다. 그래서 꼭 필요한 몇개의 요소에 대해서 먼저 간단히 알아보도록 하겠습니다.
4.1. EXPORT TABLE
DLL에 프로그램에 제공하는 함수에 대한 정보가 존재하는 EXPORT 테이블이 메모리 상에서 가지는 시작주소와 크기값에 대한 정보를 가지고 있습니다. 이 부분에 대해서는 뒤에서 다룰 EAT에서 설명하도록 하겠습니다.
4.2. IMPORT TABLE
PE파일이 사용하는 외부함수들에 대한 정보가 존재하는 IMPORT 테이블이 메모리 상에서 가지는 시작주소와 크기값에 대한 정보를 가지고 있습니다. 이 부분 역시 뒤에서 다룰 IAT에서 설명하도록 하겠습니다.
4.3. RESOURCE TABLE
사용자 인터페이스 요소가 정의된 리소스 디렉터리가 메모리 상에서 가지는 시작주소와 크기값에 대한 정보를 가지고 있습니다.
4.4. TLS TABLE
Thread Local Storage의 약자로 TLS의 Callback 함수를 이용한 안티 리버싱 기술을 알아야 하기에 필요한 요소입니다.
<br />
( 리소스 테이블과 TLS 테이블에 대해서는 더 자세히 알아본 뒤 업데이트 하도록 하겠습니다. )
여기서 중요한 것은 위와 같이 IMAGE_DATA_DIRECTORY 구조체 배열의 각 요소들은 PE파일에서 특정 역할을 가지고 있는 개체들의 위치를 나타내는 주소값과 해당 개체의 크기값에 대한 정보를 가진다는 것입니다.
이번에는 헥사에디터로 살펴 보도록 하겠습니다.
크기가 깔끔하게 128 바이트로 떨어지는 것을 확인할 수 있습니다. 분석중인 PE파일은 많은 구성요소들이 0x0h 값으로 채워져 있다는 것도 알 수가 있습니다.
5. IMAGE_SECTION_HEADER
드디어 PE 헤더의 마지막 영역인 섹션 테이블까지 왔습니다.
딱 감이 오시겠지만, 섹션 헤더에는 각 섹션에 대한 정보가 저장되어 있습니다.
먼저 섹션 헤더 구조체의 원형을 보도록 하겠습니다.
여러가지 필드들로 이루어져 있는 것을 확인할 수 있습니다.
여기서는 두번째 섹션인 .data 섹션을 이용하여 각 필드들을 알아보도록 하겠습니다.
5.1. Name
가장 먼저 알아볼 필드는 Name 필드입니다.
섹션이 이름을 나타내는 필드로 .text, .data, .rdata 와 같은 이름이 들어가며 이름의 길이는 8자로 제한되어 있습니다. 위에 나왔던 IMAGE_SECTION_HEADER 구조체의 원형 위에 보이는IMAGE_SIZEOF_SHORT_NAME 라는 값이 Name이라는 배열의 길이를 제한합니다. 만약, 이보다 긴 이름이 저장되면 앞에서부터 8자 밖에 표시되지 않습니다.
여기서는 '0x2E64617461000000' 값을 가집니다. 이것을 아스키 코드로 변환하면 '.data'. 즉, 데이터 섹션을 나타내는 이름이 저장되어 있습니다. 하지만, 이 값은 아무런 값이나 들어갈 수 있기 때문에 주의해야할 필요도 있습니다.
이번에는 헥사에디터를 통해 해당 offset을 찾아보도록 하겠습니다.
해당 offset에 0x2E64617461000000 값이 들어가 있는 것을 확인할 수 있습니다.
5.2. Misc 공용체
다음으로 알아볼 필드는 Misc 공용체를 이루는 PhysicalAddress와 VirtualSize 필드입니다.
그런데 우리가 분석중인 bot.exe 파일의 PE헤더에는 VirtualSize 필드만 존재합니다. 왜냐하면 지금 분석 중인 파일이 PE 형태의 파일이기 때문입니다. 무슨말인지 이해가 안되시죠? 다음 설명을 보시면 됩니다.
5.2.1. PhysicalAddress
해당 필드는 파일이 obj 파일인 경우에만 사용되는 필드로 0x0 값이 세팅 됩니다.
5.2.2. VirtualSize
PE파일인 경우 사용되는 필드로, 섹션이 메모리 상에서 갖는 크기값을 저장합니다. 여기서는 '0x0002F458'값을 가지고 있습니다. 즉, 193624 바이트만큼의 공간을 차지한다는 말입니다.
헥사에디터에서 찾아본 결과 리틀인디언 방식으로 0x0002F458 값이 저장된 것을 확인할 수 있습니다.
5.3. VirtualAddress ( RVA )
이어지는 필드는 VirtualAddress 필드입니다.
이 필드는 해당 섹션이 메모리 상에 올라갈때, 섹션이 시작하는 주소를 RVA 값으로 가지고 있습니다.여기서는 '0x00003000' 값을 가지고 있습니다.
해당 필드의 값 또한 리틀인디언 방식으로 저장되어 있는 것을 확인할 수 있습니다.
5.4. PointerToRawData
다음은 PointerToRawData 필드입니다.
해당 필드는 파일에서의 해당 섹션이 시작하는 위치를 나타냅니다. 즉, 파일에서의 offset값을 가지고 있는 것입니다. 여기서는 위의 메모리 상에서의 시작 주소와 같은 '0x00003000' 값을 가지고 있습니다.
해당 필드 역시 0x00003000 값이 리틀인디언 방식으로 저장되어있는 것을 확인할 수 있습니다.
5.5. SizeOfRawData
다음은 SizeOfRawData 필드입니다.
앞의 필드에서는 메모리 상에서의 섹션에 대한 내용을 담았다면, 해당 필드는 파일에서 섹션에 대한 내용을 담고 있습니다. 파일에서 섹션이 갖는 크기값을 저장합니다. 여기서는 '0x0002F000' 값을 가지고 있습니다.
실제 이 크기값이 맞는지 확인을 해보도록 하겠습니다. 먼저 해당 섹션인 .data 섹션의 시작 offset이 '0x00003000' 값이었습니다. 해당 섹션의 크기를 구하려면 다음 섹션의 시작 offset을 알아내면 됩니다.
그럼 다음 섹션의 시작 offset을 알아보도록 하겠습니다.
.data 섹션의 바로 다음에 위치한 .rsrc 섹션의 헤더에서 PointerToRawData 필드를 확인하여 시작 offset을 확인해본 결과 '0x00032000' 값이 저장되어 있음을 알 수가 있었습니다.
그러면 다음과 같은 계산이 이루어 집니다.
.rsrc 섹션의 시작 offset - .data 섹션의 시작 offset
0x00032000 - 0x00003000 = 0x0002F000
계산결과, .data 섹션의 SizeOfRawData 필드에 저장된 값과 일치하는 것을 확인할 수 있습니다. PEView에서 나타난 값이 의심스럽다면, 이처럼 직접 계산해보는 것도 재미있습니다.
이번에도 빠지면 섭섭할 헥사에디터로 해당 offset을 찾아보도록 하겠습니다.
0x0002F000 값이 리틀인디언 방식으로 저장된 것을 확인할 수 있습니다.
이어서 나오는 PointerToRelocations, PointerToLineNumbers, NumberOfRelocations, NumberOfLineNumbers 필드들은 실행파일에서는 별다른 의미를 갖지못하고 모두 0x0 값을 가지므로 생략하도록 하겠습니다.
5.6. Characteristics
이번에는 IMAGE_SECTION_HEADER 구조체의 마지막 필드인 Characteristics 를 알아보겠습니다.
Characteristics 필드에는 해당 PE파일의 특성에 해당하는 값들이 저장되어 있습니다.
현재 우리가 분석중인 bot.exe 파일에는 다음과 같은 특성들이 적용되어 있습니다.
0x00000040 : IMAGE_SCN_CNT_INITIALIZED_DATA
섹션에 초기화된 데이터가 포함되어있다는 의미입니다.
0x40000000 : IMAGE_SCN_MEM_READ
섹션을 읽을 수 있다는 의미입니다.
0x80000000 : IMAGE_SCN_MEM_WRITE
섹션이 쓰기 가능하다는 의미입니다.
위와 같은 특성을 확인하면 섹션에 대한 자세한 정보를 얻을 수 있습니다.
각각의 특성들은 위와 같이 대응 하는 값들이 미리 정해져 있습니다. 해당 값들에 대한 정보는 MSDN에서 'section header'라는 키워드로 검색하면 찾을 수 있습니다.
위에서처럼 PEView를 통해 PE파일을 분석한다면 어떤 특성들이 적용되어있는지를 보기 쉽게 나타내줍니다.
그렇다면 헥사에디터에서는 어떻게 표시될까요?
벌써 알아차리신 분들도 계실거라 생각합니다. Characteristics 필드에 해당하는 offset에는 '0xC0000040' 이라는 값이 저장되어 있습니다. 이 값은 해당 PE파일이 가지는 특성들을 모두 더한 값입니다. 이렇게 해당 필드에는 특성들을 모두 더한 값으로 특성에 대한 정보를 저장합니다.
그 이후에는 섹션들이 위치하게 됩니다.
앞에서 알아보았던 IMAGE_OPTIONAL_HEADER 구조체의 필드 중 하나인 SizeOfHeader 필드에 저장된 offset 값부터 첫번째 섹션에 해당하는 데이터가 시작됩니다. 해당 필드의 값이 0x00001000 이었으니 1000h offset 부터 .rdata 섹션이 시작해서 .data 섹션, .rsrc 섹션의 값들이 차례로 나타나고 PE파일의 내용이 끝납니다.
7. 끝
이렇게 해서 bot.exe 파일을 분석하며 PE파일 포멧의 구조에 대하여 알아보았습니다. 이것을 알고 PE파일을 분석하는 것과 모르고 달려드는 것은 분명 엄청난 차이가 있습니다. 저도 이 내용을 공부하면서 정리하기 전에는 PEView를 열어놓고도 도대체 이게 무슨 글자들이지? 했습니다. 이제는 PE구조 속에 담겨진 많은 정보들을 잘 활용할 수 있을 거라 믿습니다.
다음에는 앞의 IMAGE_DATA_DIRECTORY 부분에서 다루지 않고 넘어갔던 IAT와 EAT에 대하여 알아보도록 하겠습니다. 도움이 되셨길 바랍니다. 감사합니다.
* Closure는 자바스크립트에서 수 많은 응용들을 할 수 있는 정말로 중요한 개념이나 자바스크립트라는 언어를 더욱더 빛내줄 수 있는 특징이다. Closure를 모르고 자바스크립트를 개발하는 것은 10년전의 웹 언어 중심의 개발 방법론에 머무르고 있는 것과 같은 것이기 때문에 10년전 웹개발자에서 진정한 자바스크립트 개발자로 나아가기 위한 기본을 이제부터 들여다보자.
: 이전글에서 Closure에 대하여 이해를 할 수 있는 여러 가지 예들을 들여다 봤지만 실제적으로 이해는 약간 힘들었을 것이다. Closure는 자바스크립트에 있어서 C로 치자면 C에서 포인터를 바라보는 관점하고 똑같다. C에서 포인터를 이해하기를 포기하고 돌아서게 되면 진정으로 깊이 있는 C 개발자가 못 되듯이 자바스크립트에서도 Closure를 이해하지 못하면 깊이 있는 자바스크립트 개발자, 또는 웹 개발자가 되지 못하게 되는 것이다. 하지만 중요한 것은 "Closure는 뛰어난 기술이 아니다"라는 것이다. 포인터의 개념을 주소라는 개념으로 받아들이기 시작하면 아주 쉽게 이해하듯이 closure 또한 scope chain에서 하나의 scope를 생성해준다는 개념으로 이해한다면 아주 쉽게 이해가 가능할 것이다. 그렇다면 먼저 scope chain에 대해서 알아봐야할 것이다.
* Scope chain
: Scope chain은 이전 글에서 이미 다뤘던 내용이다. 하지만 이 개념과 closure를 반드시 연결해서 생각해야만 closure를 이해할 수 있다. Scope chain에 대해서 설명을 다시 하겠지만, 이전 글을 다시 훑어보고 와도 괜찮을 것이다.
<div id="divScope0">Click me! DIV 0</div>
<div id="divScope1">Click me! DIV 1</div>
<div id="divScope2">Click me! DIV 2</div>
<script>
function setDivClick(index) {
document.getElementById("divScope" + index).addEventListener("click", function () { // #1
alert("You clicked div #" + index);
}, false);
}
var i, len = 3;
for (i = 0; i < len; i++) {
setDivClick(i);
}
</script>
: 여기서 이전글에서 말했던 closure가 생성되는 규칙을 발견할 수 있을 것이다. function 키워드를 따라가면서 확인해보면 위의 setDivClick 함수가 선언된 부분 안에 #1의 부분에 함수가 하나 선언 되어 addEventListener 함수의 인자로 넘어가고 있다. 바로 function 안에 function을 넣은 것이다. 그리고 이전 글 중에서 [속깊은 자바스크립트 강좌] 자바스크립트의 Scope와 Closure 기초의 글에서 Scope는 function을 기반으로 생성된다는 말을 했다. 그렇다면 function 안에 다른 function을 다시 선언한다는 것은 다른 말로 하나의 scope안에 다른 scope를 선언한다고도 볼 수 있다. 이것이 바로 scope 안에 scope를 만들어서 scope chain을 생성하는 과정이다. 위의 글에 있었던 그림도 다시 한번 보자.
위의 예에서 scope chain이 형성된 그림
: 여기서 setDivClick으로 생성된 scope의 안에 각 addEventListener의 인자로 선언된 함수들의 scope가 하위에서 setDivClick 함수의 scope를 참조하는 것을 알 수 있다. 이렇게 여러 개의 함수 안에 함수가 호출되면 하위 scope를 생성하여 상위 scope의 변수들을 접근할 수 있는 것을 scope chain이라고 하는 것이다. 여기서 중요한 것은 하위 scope에 해당하는 function이 살아있다면 상위의 scope들은 죽지 않고 계속 살아있게 된다는 것이고 이것이 closure의 가장 기본적인 개념이다. 즉, 위의 div0.onclick 이라는 함수가 살아있는 동안 setDivClick을 통해 생성되었던 scope는 계속 살아있게 된다.
* 기본적인 예
: Closure를 이해하기 쉽게 sum 이라는 함수를 선언해보자.
function sum(base) {
var inClosure = base; // #1
return function (adder) { // #2
return inClosure + adder;
};
}; // #3
var fiveAdder = sum(5); // #4: inClosure를 5로 설정하고 #2의 함수 리턴
fiveAdder(3); // === inClosure(5) + adder(3) === 8
var threeAdder = sum(3); // #5: inClosure를 3으로 설정하고 #2의 함수 리턴
: 위의 예에서도 보면 function 안에 function이 선언되고 내부의 function이 리턴 되는 것을 볼 수 있고 외부 함수에서 base 인자가 넘어오면 내부의 함수에서는 inClosure 변수를 설정하게 된다. 이렇게 소스코드를 통해서 scope가 생성되는 것을 한번 살펴보면 아래와 같이 된다.
: 이것은 scope의 뼈대, 또는 template이라고 보면 된다. 중요한 것은 위의 scope가 실제로 생성 된것이 아니라는 점이다. 그냥 만약에 scope가 생성된다면 위의 구조를 가지게 된다는 것을 그림으로 표현해본 것이다. 만약 sum 함수의 내부 함수를 받아서 사용하게 된다면 위의 scope를 따르게 된다. 그럼 위의 뼈대를 토대로 하나씩 실행되는 과정을 한줄씩 살펴보자.
var fiveAdder = sum(5);
: 위의 구문을 통해 function sum이 호출 되고 base는 5로 넘어오고 inClosure 변수도 5로 설정한다. 그리고 inClosure 변수를 참조하는 내부 함수를 리턴하여 fiveAdder에 대입한다. 현재 fiveAdder의 scope 상황은 다음과 같을 것이다.
: 위의 scope 뼈대에서 fiveAdder는 실제로 사용하게 되는 함수 A를 할당 받게 되어 위의 scope 뼈대를 통해 하나의 scope chain을 생성하여 가지고 있게 된다. fiveAdder는 오른쪽의 A 함수를 받아 가지고 있게 된다. 여기서 fiveAdder에서 할당 받는 함수 A는 어디서 온것인가 보면, 바로 #2에서 리턴한 그 함수가 바로 A인 것이다.
: 이제부터 fiveAdder를 통해 함수를 접근하게 되면 위의 scope chain을 따르게 된다. 여기서 하나 짚고 넘어가자면, 그림상으로 왠지 순환 구조를 가지고 있는 듯 하지만 모든 scope는 global scope에서 끝나게 된다. 그리고 global scope에 있는 fiveAdder는 scope chain이 이어진 것이 아니라 오른쪽 함수 A의 레퍼런스, C에서 말하자면 포인터를 가지고 있는 것이고, 나중에 fiveAdder를 실제로 '호출'하게 될 때에 위의 오른쪽 A는 해당하는 scope chain을 사용하게 된다는 것이다. 이 scope chain은 fiveAdder가 또 다른 함수의 인자로 넘어가든 fiveAdder가 메모리에서 사라질때까지 계속 유지하게 된다. 이것이 이전 글에서 말했던 '퍼포먼스의 문제'에 대하여 조금 더 깊게 이해할 수 있을 것이다. 이제 fiveAdder(3)을 호출하게 되면 오른쪽 A의 adder 인자에 3이 들어가게 되고, 내부 함수에서 inClosure + adder를 하게 되면 바로 위의 scope에 있는 inClosure = 5와 인자인 adder = 3을 이용하여 8이라는 값을 리턴하게 된다. 여기까지는 그렇게 특별할 것이 없다. 이제 다음으로 넘어가면,
var threeAdder = sum(3);
: 그렇다면 이번에는 다시 threeAdder를 호출하게 되면 어떻게 될까. 이번에는 상당히 생각이 많아질지도 모른다. 기분상 fiveAdder의 inClosure까지 변환되어 버릴 것 같지만 그렇지 않다.
: 위와 같이 함수 B가 사용하는 하나의 새로운 scope chain이 생성되어 threeAdder에서 사용하도록 된다. 같은 함수를 통해서 받은 리턴 값의 함수가 A와 B 2개로 각각 생성되어 이제 fiveAdder(3)을 하게 되면 8의 결과 값이, threeAdder(3)을 하게 되면 6의 결과 값이 나오게 된다. 이렇게 closure를 통해서 각 함수들은 자기만의 고유의 값을 가지고 scope chain을 유지하면서 그 chain 안에 있는 모든 변수의 값을 유지하게 된다.
* Scope chain의 생성
: 단순히 보면 왜 그때그때 scope chain이 생성될까 이해하기 힘들지도 모르지만 속에 돌아가는 구조를 자세하게 살펴보면 이해를 할 수 있을 것이다. Closure에 대해서 더 깊어지기 전에 위의 현상에 대해서 이해하고 넘어가자. 위의 예에서 '익명 함수'를 이용하고 있다. 익명 함수는 함수의 이름을 지정 안하고 사용하는 경우를 뜻하고 위의 예에서는 다음의 부분이다.
: 이 부분에서 #2는 익명 함수가 선언되어 리턴되는 부분으로 내부에서는 이것은 새로운 Function 객체를 만들어 리턴을 하게 되는 과정과 같다. 다르게 표현하면 내부적으로는 아래와 '비슷한' 프로세스가 일어나게 된다.
return new Function("adder", "return inClosure + adder;");
: 자바스크립트에서 {}는 object literal, []는 array literal이라고 하고 위의 function () {} 는 function literal이라고 하는 것은 내부적으로 각각 {}는 Object 객체, []는 Array 객체, function () {}는 Function 객체를 만들기 때문이다. 이렇게 sum 함수를 호출함으로써 내부에서 #2를 거치게 될 때마다 매번 새로운 함수를 생성하여 리턴하는 것이라고 보면 되고, 이럴 때마다 각 함수의 scope chain은 새롭게 할당되어 저장하게 된다. 따라서 위의 예에서 fiveAdder = sum(5)를 호출 할 때 new Function과 비슷한 과정을 통해 함수 A가 생성되어 리턴되고, threeAdder = sum(3)을 호출 할 때 또 new Function을 통해 함수 B가 생성 된 것이다. 이렇게 보면 매번 sum을 호출할 때마다 새로운 함수와 그 함수의 scope chain이 따로따로 생성된 것을 이해할 수 있을 것이다. 여기서 재밌는 것은 fiveAdder와 threeAdder의 외부 표현식은 같다는 것이다. toString() 함수를 통하여 호출해보면 둘다 아래와 같이 나오게 된다.
: 하지만 fiveAdder !== threeAdder이다. 이렇게 똑같은 모양의 함수들이 매번 새롭게 나오고 있는 것이다. 이렇게 두 함수는 일치한 모양을 가지고 있지만 둘의 동작이 달라지는 것은 바로 숨겨져 있는 closure 때문이다.
- 덧: 위에서 new Function과 '비슷한' 프로세스가 일어난다고 말한 것은 new Function을 이용해서 생성한 함수는 로컬 변수만 이용 가능하지만 function literal로 생성한 함수는 Closure를 생성하여 외부의 scope에 있는 변수들을 접근 가능하다는 점이다. 따라서 closure를 생성할 때에는 new Function을 이용하면 안되고 function () {} 으로 함수를 생성해야한다.
- 덧2: 위의 그림에서 보면 base와 inClosure의 값은 항상 같고 같은 scope에 자리하고 있다. 따라서 inClosure 변수는 사실상 필요없지만 closure 내부의 로컬 변수도 유지 된다는 것을 보여주고자 추가적으로 선언했다.
* Closure를 쓰는 실제 예
: 이전 글에서 closure의 이용 방법에 대하여 몇가지를 이야기하기도 했지만 '어디서' '언제' 사용할지에 대해서는 감을 잡기가 어려울 것이다. closure를 가장 많이 사용하는 것은 이전 글에서 말했던 경우들, 라이브러리에서 private이나 나의 변수를 보호하고 싶을때라던가 self-defining function인 경우, static으로 변수를 이용하고 싶은 경우에도 있지만 가장 일상적으로는 closure를 활용하는 경우는 콜백함수에 추가적인 값들을 넘겨주거나 처음에 초기화 시켰던 값을 계속 유지하고 싶을 때일 것이다. 사실 이렇게 글로 실컷 읽어봤자 위의 fiveAdder 등과 같이 실용적이지도 않은 곳에만 쓰이는 탁상공론에 불과한 개념이라고 느낄 수 있을 것이다. 따라서 실제 상황에서도 사용할 수 있는 간단한 예를 한번 보자.
- 목적: 특정 div에 버튼1에 대한 콜백으로 div를 추가/버튼 2에 대한 콜백으로 img를 계속 추가
<div id="wrapper">
<button data-cb="1">Add div</button>
<button data-cb="2">Add img</button>
<button data-cb="delete">Clear</button>
아래에 추가<br/>
<div id="appendDiv"></div>
</div>
<script>
(function () {
var appendDiv = document.getElementById("appendDiv"); // #1
document.getElementById("wrapper").addEventListener("click", append);
function append(e) {
var target = e.target || e.srcElement || event.srcElement;
var callbackFunction = callback[target.getAttribute("data-cb")];
appendDiv.appendChild(callbackFunction());
};
var callback = {
"1":(function () {
var div = document.createElement("div"); // #2
div.innerHTML = "1번";
return function () {
return div.cloneNode(true); // #3
}
}()),
"2":(function () {
var img = document.createElement("img");
img.src = "http://www.google.co.kr/images/srpr/logo3w.png";
return function () {
return img.cloneNode(true);
}
}()),
"delete":function () {
appendDiv.innerHTML = "";
return document.createTextNode("Cleared");
}
};
}());
</script>
아래에 추가
: 여기서 Closure를 활용한 곳을 보면 크게 2가지로 볼 수 있다. 바로 #1 부분에서 전체 함수들이 공통적으로 접근하고자하는 변수(appendDiv)를 선언하여 한번의 초기화 만으로 이후에 함수들이(여기서는 append(e) 함수) 지속적으로 접근 가능하도록 한 부분과 #2에서 현재 화면에 안 보이는 가상의 노드를 만들어 보관하고 있는 #2 부분이다. 나중에 콜백 함수에서는 append 함수를 호출하여 클릭한 버튼에 따라 변수 callback에 선언되어있는 다른 내부 함수를 호출하게 되고, 이벤트가 발생하게 되면 해당 콜백 함수가 호출되어 #3에서 #2의 가상 노드의 복제 노드를 생성하여 리턴하여 appendDiv에 추가하게 된다. 이렇게 콜백 함수를 동적으로 생성할 때 초기화 되어있는 값들을 유지하는 것이 퍼포먼스상 유리한 경우, 특히 DOM을 생성하거나 탐색하여 가져오는 경우 한번 로드했던 DOM 객체를 보관하고 있는 것이 여러 모로 유리하기 때문에 이렇게 DOM을 적극적으로 활용할 때야말로 Closure를 진정으로 효과적으로 사용할 수 있을 것이다.
: 그렇다고 위의 예제에서 closure는 필수적인 요소가 아니다. 이렇게 closure는 언제든 사용할수도 있고 안 할수도 있지만, 위에서 말했듯 중복된 DOM 탐색이나 DOM 생성을 할 때에 효과적으로 closure를 이용한다면 월등한 퍼포먼스를 가져올 수 있을 것이다. 이렇게 실용에서 closure를 가장 많이 활용할 수 있는 부분을 살펴보면, 다음과 같이 말할 수 있을 것이다.
반복적으로 같은 작업을 할 때 같은 초기화 작업이 지속적으로 필요할 때, 콜백 함수에 동적인 데이터를 넘겨주고 싶을 때 Closure를 사용하자!
- 덧: 이 예제에서는 실제로 활용 가능한 다양한 예들이 같이 포함되어 있다.
closure로 appendDiv를 한 번만 검색하고 조회하여 초기화하고 계속 보관하는 활용 방법
div, img 등 가상 노드를 만들어놓고 필요할 때마다 복제하여 생성할 수 있는 활용 방법
appendDiv에만 이벤트 핸들러를 추가하여 관리할 수 있는 event delegation 활용 방법
이벤트가 발생한 target element를 크로스 브라우져에서 가져올 수 있는 방법
var callback = {...}를 활용하여 대상에 따라 동적으로 콜백 함수를 변화 시키는 활용 방법
HTML5의 스펙에 맞게 사용자 정의 속성을 "data-*" 여기서는 "data-cb"로 설정한 것
만약 callback 함수에 인자를 넣어주게 되면 div를 추가하되, 안의 내용 또한 동적으로 설정할 수 있는 위의 예 응용 방법 등
: 나중에 어느 정도 강좌를 진행하고 나면 이 예제를 다시 가져와서 더 자세하게 세부적으로 들어가서 정말로 실용에서 사용할 자바스크립트 개발 방법론에 대하여 공부해보도록 하자. 이 간단한 예제는 자바스크립트 개발에서 사용할 수 있는 기본적인 틀을 하나 제시하고 있고, 지금이라도 천천히 하나씩 뜯어보면 이해할 수 있는 자바스크립트만의 독특한 개발 방법들이다. 이 방법들을 제대로 이해하고 활용할 줄 안다면 자바스크립트를 더욱더 깊이 있게 다룰 수 있게 될 것이다.
* 정리
Closure는 function 안에 function이 있게 되면 기본적으로 생성된다.
Closure는 scope chain이 생성됐을 때의 변수 값들을 보존하고 기억하게 된다.
함수가 메모리에서 없어질 때까지 따라다니게 된다.
같은 모양의 함수이더라도 다른 closure를 가지고 있을 수 있다.
함수가 다른 곳에서 사용되더라도 처음에 생성되었던 scope chain이 끝가지 따라다니게 된다.
다른 곳에서 사용되는 대표적인 경우: 함수를 리턴하여 사용, 다른 함수의 인자로 넘겨줘서 사용, 콜백으로 사용
: 지금까지 틈틈히 자바스크립트만의 다른 개발 방법에 대해서 계속 간단하게 살펴봐왔었는데, 거의 모든 방법들에 이 closure가 연관되어있다. 이러한 closure는 functional language에서 이전 글들에서 closure가 사용되었던 예들을 다시 한번 살펴보자. 아래의 소스들은 지금까지 공부해왔던 내용 중에 나왔던 소스들이다. 앞 뒤의 소스들은 간단하게 생략한 것들도 있다.
function setDivClick(index) {
document.getElementById("divScope" + index).addEventListener("click",
function () { // #1
alert("You clicked div #" + index);
}, false);
}
for (i = 0; i < len; i++) {
document.getElementById("divScope" + i).addEventListener("click", (function (index) { // #1
return function () { // #2
alert("You clicked div #" + index);
};
}(i)), false); //#3
}
function bind(obj, func) {
return function () {
func.apply(obj, arguments);
};
}
document.getElementById("clickDiv3").addEventListener("click", bind(unikys, unikys.say));
: 위의 각 소스들은 지금까지 closure를 이용해왔던 예들이다. 위의 소스들의 공통점이 무엇인지 잘 살펴보면 closure는 어떻게 생성되는지 쉽게 이해할 수 있을 것이다. 그냥 훑어가며 봤을 때에는 약간 어려울지도 모르겠지만, 첫번째 특징은 closure는 function 안에 function이 선언될 때 생성된다는 것을 알 수 있다. 두번째 특징은 바로 function에 선언된 scope가 아닌 다른 scope에서 호출 될 때이다. 이는 비동기적으로 활용될수도 있고, function을 return 해서 사용할 때에도 적용이 가능한 것이고, 위의 이벤트 핸들러에서 활용하고 있는 것이 가장 대표적인 예이다. 그럼 먼저 closure의 특징을 살펴보자.
* Closure의 특징
: Closure가 나타나는 가장 기본적인 환경은 바로 함수 안에 함수가 다시 선언되어 호출되었을 때이다. 이는 가장 기본적인 예로 inner function과 outer function을 통해서 나타낼수 있다.
function outer () {
var count = 0; // #1
var inner = function () { // #2
return ++count;
};
return inner; // #3
}
var increase = outer(); // #4
increase(); // === 1 #5
increase(); // === 2
: 여기서 짧게 설명을 한다면 #1 count 변수는 outer의 local 변수이고 #2에서 outer의 로컬 함수로 inner 함수가 선언이 된다. 이때에 #2 안에서는 outer 함수의 count 변수가 접근 가능하다. 이러한 상태에서 inner 함수는 #3에서 outer 함수의 결과로 리턴이 된다. 이 때 #4에서 outer 함수가 호출이 되고, #3에서 리턴된 inner가 #4의 increase 변수에 저장이 되고 #5에서 리턴된 inner 함수가 호출이 되는 것이 순서다. 여기서 중요한 것은 바로 inner 함수가 리턴되면서 다른 곳에서 호출이 될 때에도 inner 함수가 선언되었던 당시에 접근 가능했던 변수 count가 계속 접근이 가능하다는 것이다. 그리고 scope의 개념으로 볼 때에 #4, #5 등 outer 함수 외부에서는 outer 함수의 local 변수인 #1의 count에 접근할 방법이 없게 된다. 즉, 자바스크립트에서도 일반적인 객체지향에서 말하는 private 개념이 적용이 가능한 것이다. 이것이 closure의 가장 기본적인 특징이고 개념이 되는 것이다. 조금 다른 예제를 살펴보자. 이번 예제는 라이브러리를 이용한다면 아주 자주 이용할 패턴이다.
function outer () {
var count = 0;
return { // #1
increase: function () {
return ++count;
},
decrease: function () {
return --count;
}
};
}
var counter = outer(); // #2
counter.increase(); // === 1
counter.increase(); // === 2
counter.decrease(); // === 1
var counter2 = outer();
counter.increase(); // === ? #3
: 이번에도 매우 비슷하다. 하지만 이번에는 이전에 함수를 바로 리턴하던것과는 다르게 #1에서 object 리터럴 {}를 이용해서 함수 2개를 묶어서 리턴하게 했다. #2에서 outer()함수를 호출 할 때에는 #1에서 선언한 object가 리턴이 되어 counter에 들어가게 된다. 이러한 경우에도 똑같이 closure가 생성되고, 이번에는 #1의 object 안에 있는 두개의 함수가 동일한 scope를 보게 되어 같은 작업을 할 수 있게 되었다. 그렇다면 outer() 함수를 한번 더 호출하게 되면 어떻게 될까? 개발자 콘솔에서 직접 실행해보면 아래와 같은 결과가 나온다.
: counter와 counter2는 서로 다른 scope를 생성하여 따로따로 저장하게 된다. 그렇다면 리턴하는 모든 함수들이 같은 값을 사용하도록 static하게 만드는 방법이 있을까?
* 즉시 호출 함수 (immediate function)
: 즉시 호출 함수라 하면 자바스크립트가 익숙하지 않은 사람은 가장 어색하고 당황할 함수 호출 방식이다. 하지만 이미 이전 글들에서도 종종 나왔었다. 맨 위에서 2번째 소스의 예가 바로 즉시 호출 함수의 예이다. 이 즉시 호출 함수를 이용하면 모든 함수들이 공통으로 사용하는 변수를 만들수 있게 된다.
var countFactory = (function () { // #1
var staticCount = 0; // #2
return function factory () { // #3
var localCount = 0; // #4
return { // #5
increase: function () {
return {
static: ++staticCount,
local: ++localCount
};
},
decrease: function () {
return {
static: --staticCount,
local: --localCount
};
}
};
};
}());
var counter = countFactory(), counter2 = countFactory();
counter.increase();
counter.increase();
counter2.decrease();
counter.increase();
: 그냥 보면 무언가 복잡해진것 같지만, 아주 쉽다. 그냥 위의 예를 하나의 함수로 더 덧씌우면서 closure 하나를 더 생성한 것이다. 즉, #1에서 closure를 생성하는 즉시 호출 함수를 선언하고, #2에서는 static으로 활용할 변수를 선언하고, #3 에서는 즉히 호출 함수에서 리턴 값으로 사용할 함수, 위의 예제에서 사용했던 함수를 리턴하게 되는 것이다. 즉, #1의 리턴 값은 #3이 되어 countFactory 변수에는 #3의 함수가 들어가게 된다. 나머지 #4와 #5는 위의 예제와 똑같고, 단지 local과 static의 차이를 나타내기 위하여 리턴 값에 그 두가지를 묶어서 리턴하도록 했다. 위의 실행 결과는 아래와 같이 나온다.
: 이렇게 즉시 호출 함수를 선언함으로써 closure를 하나 바로 생성하는 방법은 다양한 곳에서 활용될 수 있고, 이것은 기존의 웹 개발과는 확연하게 다른 자바스크립트만의 새로운 개발 방법론으로 자리 잡고 있으므로 반드시 이해하고 넘어가길 바란다. 이제 눈치가 좀 빠른 사람이라면 이렇게 closure를 사용하는 가장 간단한 방법이 바로 함수를 return하면서 사용하는 것이라는 것도 알게 되었을 것이다.
* 라이브러리에서의 활용
: 위와 같이 함수로 한번 둘러싸는 경우 가장 많이 사용하는 것이 바로 라이브러리일 것이다. 라이브러리일 때 뿐만아니라 사용자의 접근을 제한하고, 변수의 조작을 불가능하게 하기 위해서는 필수로 위와 같은 방법을 사용해야한다. 하지만 이것보다도 더 큰 이유는 바로 다른 라이브러리들과 함께 사용되는 경우 서로간 충돌을 없애기 위해 반드시 해야한다. 전역변수를 사용했다가 다른 라이브러리 가져왔는데 그 라이브러리에서 덮어씌워버린다면 이유도 모르고 멀쩡하던 웹 페이지에서 에러가 발생하게 될 것이다.
: 아주 간단한 예를 들어보면, 모두가 많이 사용하는 var xmlhttp를 전역변수로 사용했는데, 잘 못 만든 라이브러리 하나를 가져왔더니 거기서 XMLHttpRequest를 워낙에 빈번하게 사용하다보니 전역변수로 선언해서 사용하고 있는데, 그게 하필 같은 xmlhttp를 전역변수로 사용하기라도 한다면 이전에 선언되고 호출 되었던 부분들이 덮어씌워져 버릴 것이다. 그래서 라이브러리를 만들때에도, 활용할 때에도 위와 같이 자신의 중요한 변수들은 즉시 호출 함수로 감싸서 보호를 해주는 것이 마땅하고 전역변수로부터의 접근은 네임스페이스를 활용함으로써 그 가능성을 최소화 시키는 것이 필요하다. 때로는 전역변수의 사용이 불가피하다고 느낄때가 있겠지만, 거의 모든 상황에서 전역변수의 사용은 회피할 수 있다. 그 이유는 바로 closure로 인해 function을 인자로 넘겨줄 경우 function이 참조하고 있는 scope째로 왔다갔다 하게 되기 때문에, 이쪽의 scope와 다른 곳에서의 scope를 함수를 전달 시킴으로써 공유하게 만드는 것이다.
* Closure가 발생하는 또 다른 경우
: 위처럼 return을 통해서도 closure가 생성되기도 하지만 이외에도 많은 방법으로 생성할 수 있다. 위의 return으로 생성하는 방법은 자바스크립트에 매우 친숙한 사람이라면 다 해봤겠지만, 일반 프로그래머들도 많이 해봤을 경우가 있다. 하지만 아마 본인도 closure가 생성된다는 것 자체도 모르고 활용할 방법도 이해 못하고 있었을 확률이 높다. 첫번째 예는 이 글 맨 위의 첫 예이다.
function setDivClick(index) {
document.getElementById("divScope" + index).addEventListener("click",
function () { // #1
alert("You clicked div #" + index);
}, false);
}
: 일단 위의 Closure의 특징에서 말한 특징을 찾으면 같은 구조로 함수 안에 함수가 존재하는 것을 발견할 수 있다. 하지만 위의 특징에서와는 다르게 return을 하고 있지는 않다. 아래의 예도 closure가 존재한다는 것을 모르고 자주 사용했을 예이다. 이전에 팁으로 썼던 글 중에서 setInterval에 대한 글 중의 소스이다.
<script>
var pendingInterval = false;
function setPending() {
if (!pendingInterval) {
pendingInterval = setInterval(startPending, 500);
} else {
clearInterval(pendingInterval);
pendingInterval = false;
}
}
function startPending() {
var div = document.getElementById("pending");
if (div.innerHTML.length > 12) {
div.innerHTML = "Pending";
}
div.innerHTML += ".";
}
</script>
<button onclick="javascript:setPending();">Toggle Pending</button>
<div id="pending">Pending</div>
: 위의 소스를 보면 setInterval(startPending)을 하고 있다. 이것은 실제적으로 startPending의 scope를 그대로 옮겨와서 closure를 하나 생성하게 된다. 하지만 전역벽수를 이용하고 있기 때문에 만약 사용자가 pendingInterval = true;를 시켜버리면 라이브러리의 동작을 수정하여 어떠한 일이 일어날지 예측하지 못할지도 모른다. 위와 같이 간단한 예에서는 미치는 영향이 적을지 모르지만 고객 정보를 다루거나 응모 이벤트 같은 곳에서 잘못 짜여진 웹페이지를 사용자가 건드리는건 아주 식은죽 먹기이다. 따라서 위의 라이브러리에서의 closure 활용처럼 오류를 방지하도록 수정해보고 덤으로 한번 퍼포먼스를 향상시켜보자.
<button onclick="javascript:setPending2();">Toggle Pending</button>
<div id="pending2">Pending2</div>
<script>
var setPending2 = (function () {
var pendingInterval = false, div = document.getElementById("pending2"); // #1
function startPending() { // #2
if (div.innerHTML.length > 13) {
div.innerHTML = "Pending2";
}
div.innerHTML += ".";
};
return function () { // #3
if (!pendingInterval) {
pendingInterval = setInterval(startPending, 500);
} else {
clearInterval(pendingInterval);
pendingInterval = false;
}
};
}());
</script>
Pending2
: Closure를 이용하는 아주 간단한 예이고 라이브러리의 기본 형태와 비슷하다. setPending2 변수에는 #3에 선언된 함수가 리턴되면서 설정하게 되고, #1과 #2에 있는 변수와 함수는 closure 내부에서만 접근할 수 있는 private 변수와 함수 같이 된 것이다. 여기서 퍼포먼스를 향상 시켰다는 말은 어디로부터 올 수 있었는지 살펴보면, 바로 매번 startPending 함수가 호출될때마다 div = getElementById()를 하던 것을 한번만 하도록 closure에 변수로 저장해둔 것이다. 이것은 전역변수가 아니라서 전역변수를 싫어하는 개발자들의 마음을 아프게하지도 않는다. 이전까지 전역변수가 난무하던 자바스크립트의 개발 방법이 closure에 대한 이해가 늘어나면서 이러한 식으로 encapsulate하고 자기의 변수를 보호하는 방식의 개발 방법론으로 개선되고 있는 것이다. 여기서 중요한 것은 closure를 이용했기 때문에 전역변수를 이용하지 않고도 위의 setPending2 함수는 인자로 보내든, 다른 라이브러리에서 사용하든 어디서든 똑같은 동작을 하게 되는 것이다. 하지만 위의 구현 방법이 만능은 아니다. 아래와 같은 단점들을 대표적으로 꼽을 수 있다.
즉시 호출 함수는 소스가 로드되면 바로 실행을 하게 되므로 소스의 로딩시간이 길어진다.
소스가 바로 실행되므로 html 소스보다 아래에 있어야한다.
: 이러한 단점들은 물론 극복 가능하거나 다른 방법으로 고민을 해볼 수 있다. 일단 두 번째 단점 때문에 대표적인 해결 방법이 즉시 호출 함수를 window.onload 이벤트에 넣는 방법이 있다. 이렇게 되면 html 소스 등 웹페이지의 로딩이 다 끝나고난 뒤에 함수를 호출하게 되므로 소스가 어디에 있든 상관없게 된다. 이렇게 즉시 호출 함수가 많아짐에 따라 window.onload의 활용은 이전보다 확연하게 많아진 것이다. 첫 번째 단점은 여러 모로 조금 고민을 해봐야한다. 다음 중에서 가장 중요한 것을 고민해볼 수 있을 것이다.
페이지의 첫 로딩시간은 조금 느리지만 사용자의 지속적인 인터렉션 반응 속도의 단축
사용자의 첫 클릭에서의 반응 속도는 느리지만 지속적인 반응 속도 단축
조금 느리지만 사용자의 꾸준하게 동일한 반응 속도
: 위의 3가지 중에서 첫번째는 위의 예처럼 즉시 호출 함수를 이용하는 방법이다. 처음에 로딩하면서 div를 로딩해두는 것이다. 그 다음 3번째는 맨 처음 구현했던 방법이다. 매번 div를 DOM 트리에서 가져오기 때문에 지속적으로 조금은 느린 사용자 반응속도가 일어나게 되는 것이다. 그럼 2번째는 어떠한 경우인지 살펴보자.
* 덧
: 자바스크립트의 퍼포먼스를 저해하는 가장 큰 요소는 'DOM을 탐색하여 접근'하는 것이다. getElementById이든 jquery의 $이든 상관없다. 따라서 위처럼 한번 접근하고 나서 다시 접근을 자주 할 것 같을 때 변수에 저장해두고 접근을 하는 것이 퍼포먼스를 향상 시킬 수 있는 큰 방법이다.
* 자기를 덮어쓰는 함수 (self-defining function)
: 이 방법은 사용자들에게나 라이브러리를 이용하는 개발자들에게는 전혀 나타나지 않고 다른 점을 못느끼겠지만 이 자체를 개발하는 개발자라면 매우 뿌듯함(?)을 느낄 수 있는, 만약에 이전에 이러한 설계를 한번도 보지 못했다면 아주 기가막힌 설계 디자인이다. 간단하게 요약하자면 바로 초기화를 호출 단계에서 하고 자기 자신을 그 초기화된 정보들을 포함하는 closure가 있는 함수로 덮어씌우는 것이다. 위의 예를 다시 한번 사용해보자.
<button onclick="javascript:setPending3();">Toggle Pending</button>
<div id="pending3">Pending3</div>
<script>
var setPending3 = function () {
var pendingInterval = false, div = document.getElementById("pending3"); // #1
function startPending() { // #2
if (div.innerHTML.length > 13) {
div.innerHTML = "Pending";
}
div.innerHTML += ".";
};
setPending3 = function () { // #3
if (!pendingInterval) {
pendingInterval = setInterval(startPending, 500);
} else {
clearInterval(pendingInterval);
pendingInterval = false;
}
};
setPending3();
};
</script>
Pending3
: 이번은 2번째 예와 비슷하지만 다른 점이 있다면 setPending3는 처음에 호출되었던 함수를 그 안에서 다시 덮어씌워서 다른 함수 #3으로 만들어버린다는 것이다. #3에서는 맨 처음의 setPending3 함수의 #1과 #2에서 가지고 있었던 변수와 함수들에 대하여 접근할 수 있는 closure가 생성되어 유지된다. 다른 점이 있다면 2번째는 소스가 로딩됨과 동시에 호출이 되어 초기화 작업이 이루어졌다면, 이번에는 함수 호출이 일어날 때, 즉 사용자가 처음으로 버튼을 클릭해서 처음으로 호출이 될 때 초기화를 하게 된다. 이게 뭐가 좋은지 잘 모를수도 있지만, 매번 호출될 때마다 초기화가 되었는지 if-else를 해볼 필요없이 그냥 함수 자체를 다시 선언해주는 것이라고 생각하면 이후에 호출될 때마다 성능상으로 충분한 메리트가 있다고 생각할 수 있다. 이렇게 사용자가 처음에 클릭했을 때 초기화하는 것은 아주 사소한 차이지만 프로그램이 커졌을 경우나 DOM을 자바스크립트로 대량으로 다루게 될 때에는 UX가 초기화가 로딩 때 일어나느냐 처음으로 눌렀을 때냐 등의 차이에 따라 꽤나 크게 다가올지도 모른다. 즉, 서로 다른 기능의 특징에 따라 서로 다른 초기화 방법을 사용하면 된다. 이것은 각자 생각하는 가치관에 따라 다르기 때문에 대충 한번 간단하게 어떻게 구현하느냐에 따른 기준을 적어보자면 아래와 같다.
사용자가 페이지에 접속하자마자 자주 사용하는 기능, 이 페이지에 들어와서 반드시 한번은 사용하게 되는 기능이라면 로드하면서 초기화
사용자가 페이지에 들어와서 한참 후에 사용하겠지만 한번 쓰고나서 자주(혹은 이따금씩) 이용하게 되는 기능은 처음 호출 때 초기화
사용자가 기능을 건드리지 않고 나갈 가능성이 크고 자주 이용하지도 않는 기능은 초기화 단계 없이 그냥 그때그때 사용
: 이것이 자바스크립트 초기화에 대한 기본 이해가 될 것이다. 그렇다면 이번에는 closure를 응용하는 한가지 예를 살펴보자.
* 오버로딩
: 객체 지향 개발자라면 아주 반가운 단어일 것이다. 자바스크립트에서는 유동적으로 인자의 수를 받아들이기 때문에사실 오버로딩을 지원하지 않는다. 하지만 arguments와 closure를 이용한다면 이러한 오버로딩 개념도 나름 구현할 수 있게 된다. 간단하게 작성해보면 아래와 같다.
function overload(object, functionName, fn, fnMap) {
var previousFunction = object[functionName];
object[functionName] = function () {
if (fn.length === arguments.length) {
if (fnMap && fnMap.length === arguments.length) {
for (var i = 0; i < arguments.length; i++) {
if (fnMap[i] === typeof arguments[i]) {
return previousFunction.apply(this, arguments);
}
}
return fn.apply(this, arguments);
}
return previousFunction.apply(this, arguments);
} else if (typeof previousFunction === "function") {
return previousFunction.apply(this, arguments);
};
};
}
: 복잡한듯 하지만 함수를 호출하게 되면 객체에 저장되어있는 함수를 previousFunction으로 closure에 저장해두고, 인자의 갯수와 인자의 형을 비교해서 모든게 일치하다면 인자로 넘어왔던 fn을 호출하고, 아니라면 이전에 설정되었던 다른 함수 previousFunction을 호출하게 되는 것이다. 간단한 활용 예를 들면 아래와 같다.
var MyFile = {};
overload(MyFile.prototype, init, function () {
console.log("init as empty file");
});
overload(MyFile.prototype, init, function (fileName) {
console.log("init with file name");
}, ['string']);
overload(MyFile.prototype, init, function (file) {
console.log("init with file object");
}, ['object']);
var file = new MyFile();
file.init();
file.init("myfile.txt");
file.init(fileObject);
: 이러한 활용 예를 들 수 있겠다. 위의 예는 아주 간단하게 오버로딩을 구현한 기법이고, 에러 체크라던가 기본 호출 함수 설정 등 구미에 맞게 바꿔서 구현하면 될 것이다.
* closure 단점
: 이렇게 편리한 closure라도 만능은 아니다. 엄연히 단점이 있기 때문에 항상 사용하기 보다는 정말로 필요할 때, 구현에 있어서 급진적으로 개발이 편해질 때 사용하면 좋을 것이다. 이러한 closure의 단점은 크게 2가지이라고 볼 수 있고 부수적인 단점이 한가지 더 있다. 일단 큰 단점 2개는 다음과 같다.
메모리를 소모한다.
Scope 생성에 따른 퍼포먼스 손해가 있다.
: 이들 2가지는 어떻게 극복할수 없는 단점들이다. Closure를 정말로 필요한 곳에 요긴하게 사용하는 수 밖에 없다. 특히, 메모리의 소모는 리턴하거나 timer, 콜백 등으로 등록했던 함수들이 메모리에 계속 남아있게 되면 해당하는 closure도 같이 메모리에 계속 남아있게 되는 것이기 때문에, 지속적으로 루프를 돌면서 closure 생성하는 것은 지양해야할 설계가 될지도 모른다. 최신 버전에서는 해결되었지만, 구 버전의 IE 같은 경우에는 DOM의 콜백함수로 등록을 하고 콜백함수의 해제 없이 바로 DOM을 삭제해버리면 메모리 누수가 생기는 단점도 있었던 점만 봐도 closure의 메모리 누수와 누적에 대한 고민을 해야한다는 것을 깨달을 수 있다. Closure는 또한 하나의 새로운 Scope를 생성하여 내부의 함수에 링크를 시키기 때문에 이에 따른 퍼포먼스 손해도 감수해야한다. 잦은 함수 호출이 퍼포먼스상 안 좋듯 만약 굳이 함수나 closure를 사용하지 않아도 되는 간단한 일이라면 굳이 함수로 분류를 하지 않아도 될 것이다.
: 그렇다면 위의 핵심적인 단점들 이외의 부수적인 단점은 무엇일까? 다른 언어의 개발 경험이 많았던 사람이라면 조금은 느꼈을지도 모른다. 바로 이해하기가 어렵다는 것이다. Closure는 개발자 본인이 사용할 때에는 나비처럼 날고 벌처럼 쏘는 핵심 기능으로 자라나지만 다른 사람들이 보면 이것이 무엇인지, 어디서 closure가 생성되었고 거기에는 어떠한 정보가 있는지 불분명하게 되는 경우가 많기 때문에 협업을 하게 될 때에는 명확한 주석과 문서화가 필요로 있어야할 것이다. 그리고 무엇보다도 다른 언어 개발자들은 closure가 돌아가는 방식을 이해하지 못하는 경우가 많기 때문에 그들에게 설명을 해줘야할 시간을 투자해야 한다는 점과 이 개념을 같은 웹개발자라도 제대로 이해하고 있지 않는 사람이 정말 많다는 것에 놀라 마음의 충격을 받는다는 점이다.
* 단점에도 불구하고..
: 이러한 단점들은 정말 어떻게 다른 방법으로 극복이 불가능한 단점들이다. 하지만 자바스크립트에서 closure를 빼면 그것은 진정한 자바스크립트가 아니다. 정말로 단순한 '스크립트 언어'에 머물던 5년전 closure를 배제한 개발 방식이야말로 자바스크립트에 잠재되어있는 무한한 가능성을 없애는, 정말로 너무나 놀라운 언어를 그냥 '스트립트언어'로, 그냥 보조적인 언어로 만들어 버리는 것이다. 위에서 단점을 쓴 것은 단점이 많아서라던가 치명적이어서가 아니라 장점이 훨씬 더 많지만 적어도 어떠한 단점들이 있는지 알고 사용해야 더욱더 잘 사용할 수 있기 때문이다. 이 closure를 마음껏 쓰다가 다른 언어를 사용하게 되면 closure가 없다는 것이 엄청 아쉬울 때가 많아질 만큼 closure는 자바스크립트의 핵심이자 특징이라고 볼 수 있다.
* 정리
- Closure는 function 안에 function이 있을 때 생성된다.
- Closure는 함수가 정의된 scope 이외의 곳에서 사용될 때 private 저장소처럼 활용 가능하다. 이러한 경우가 발생하는 대표적인 경우는 아래와 같다.
내부의 function을 리턴하여 다른 곳에서 사용 (바로 호출하던지 인자로 넘겨주는 등)
setTimeout, setInterval 등과 같이 비동기적으로 호출 되는 경우
이벤트 콜백 함수로 활용 되는 경우 (addEventListener 라던가, xmlhttprequest.onreadystatechange 등)
* C나 자바를 접하던 사람들이 처음으로 자바스크립트를 접하면 혼란스러워하는 것이 바로 scope와 this의 상이함일 것이다. 처음에 접할 때에는 객체지향 언어에서는 이해할 수 없는 동작들을 하고 있기 때문에 이것이 뭔가 싶다가도 자바스크립트가 이상하다고 스스로 판정을 내리게 된다. 하지만 이것들은 자바스크립트의 원리만 이해하면 아주아주 쉽고, 오히려 객체지향 언어보다 놀라운 유연함에 감탄을 하게 될 것이고, 자바스크립트를 하다가 다시 C나 자바를 하게 되면, '자바스크립트라면 쉽게 해결할 수 있는데..'라며 자바스크립트를 아쉬워하게 될 것이다. 그럼 이번에는 일단 자바스크립트의 가장 '기본'인 scope와 closure에 대해서 알아보자.
: Scope라 하면, 현재 접근 가능한 변수들이 결정되는 방법이다. 영어의 뜻을 가져와서 설명해보면, 현재 자신의 위치에서 볼 수 있는 변수들을 결정하는 방법인 것이다. 자신의 scope 안에 있다면 접근이 가능하여 변수를 읽거나 쓸 수 있는 것이고, scope 밖에라면 해당하는 변수는 접근이 불가능한 것이다. 간단하게 생각한다면 너무나 쉬운 내용이지만 왜 기존의 '능수능란한' 프로그래머들도 쉽게 이해하지 못할 함정에 빠지게 되는 것일까?
: 아래는 정확하게 Scope 때문에 일어난 일은 아니지만, C와 자바의 프로그래머들이 어떻게 해결해야할지 가장 이해하지 못하는 상황이다. <div>가 0,1,2가 있고, 각 div에 클릭 이벤트를 넣어서 0,1,2를 출력하는 아주 간단한 프로그램을 짜고자 한다. div를 쉽게 늘릴 수 있게 해주기 위해 능숙하게 for loop을 이용해서 이벤트를 부여해줘야겠다고 생각한다. 그래서 아래와 같이 짰다.
<div id="div0">Click me! DIV 0</div>
<div id="div1">Click me! DIV 1</div>
<div id="div2">Click me! DIV 2</div>
<script>
var i, len = 3;
for (i = 0; i < len; i++) { //#1
document.getElementById("div" + i).addEventListener("click", function () { //#2
alert("You clicked div #" + i); //#3
}, false);
}
</script>
: 그 구현 결과는 아래와 같다. DIV 0, DIV 1, DIV 2를 눌러보자.
Click me! DIV 0
Click me! DIV 1
Click me! DIV 2
: 위와 같이 능숙하게 짰건만 구현하고나서 위의 DIV들을 각각 눌러보면 이상하게 "You clicked div #3"만 나온다. 왜 이렇게 나오는 것일까? 문제는, scope가 어떻게 결정되느냐가 아니라, scope가 생성되는 방법과 scope가 유지되는 방법인 것이다. click 이벤트에 대하여 콜백 함수를 작성하여 alert를 시켜주는 함수의 scope는 콜백함수로 선언될 때 #2에서 생성되며, 그 scope는 #1의 변수들에 대하여 접근 가능하기 때문에 해당하는 scope를 유지하게 된다. 나중에 div위에 click 이벤트가 발생해서 실제로 #3이 호출 될때 i는 여전히 #1에 대한 scope가 그대로 살아있어서 클릭된 시점의 i 값을 가져오게 된다. 간단하게 그림으로 설명하면 아래와 같다.
: 위의 for루프를 돌 때에는 scope가 생성되지 않고 i는 기본적으로 global scope에 존재하게 되고, addEventListener에 함수를 첨부할 때 익명 함수가 선언이 될 때 scope가 생성되어 참고를 하게 된다. 그리고 2번째, 3번째 루프를 돌면서 div2의 클릭 이벤트의 콜백함수를 설정하고 나면 scope는 아래와 같이 된다.
: 각 div의 click 이벤트에 부착되었던 콜백 함수들을 모두다 같은 scope의 변수 i를 보게 되었고, 3번째 루프를 다 돌고나서 마지막에 i++이 되고나면 최종적으로 위의 함수들은 공통적으로 global scope에 있는 i=3의 i를 보게 되는 것이다. 그리고 나중에 클릭 이벤트가 일어나서 클릭을 하게 된다면 모두다 똑같이 "You clicked div #3"을 출력하게 된다.
: 이러한 현상은 자바스크립트에서 scope가 함수로 인해 생성되고 함수가 호출될 때에도 계속 지속되는 특성에 의해 생긴 문제이다. 자바스크립트에서 이는 자주 발생하는 문제로 이 개념만 쉽게 이해하고 있다면 기본은 끝냈다고 생각해도 된다. 그럼 위의 문제를 해결하기 위해 먼저 scope가 생성되는 방법에 대해 살펴보자.
* scope의 생성
: scope의 생성은 특정 구문이 실행될 때나 객체들이 생성될 때 새롭게 scope가 하나 생성하게 된다. 그 구문들은 다음과 같다.
function
with
catch
: 이들이 scope를 생성하게 되는 방법은 각각 다르지만, 중요한 것은 이런 객체들이 생성될 때에만 scope가 생성되고 {} 의 블럭이 생성된다고해서 scope이 하나 생성되는 것이 아니다. for 루프를 적용해보면 바로 차이를 느낄 수 있을 것이다. 아래의 소스코드는 실생활에서 사용할 일이 없지만 scope를 이해하는데 약간은 도움이 될 것이다.
* 목적: 수를 0~9까지 더하다가 총합이 16를 넘는 숫자를 구하고 싶다.
for(var i = 0; i < 10; i++) {
var total = (total || 0) + i; // #1
var last = i; // #2
if (total > 16) {
break;
}
}
alert(total + " , " + last); // #3
: 위의 소스코드에서 #1은 자바스크립트에서 자주 사용하는 표현에 한정됐지만, #2의 last는 C나 자바에서도 자주 사용하는 표현일 것이다. 특정 객체의 index를 찾는 등을 할 때 자주 이용하는데 for 루프 안에 var last로 선언되어있다. C나 자바에서라면 #3에서 당연히 에러가 나야겠지만, 자바스크립트에서는 잘 돌아간다. 이것은 블럭{}을 사용할 때에는 scope이 생성되지 않는다는 뜻이다. 반면 function의 안에 있는 값들은 접근이 불가능하다.
function foo() {
var b = "access me";
}
typeof b === 'undefined';
: 이렇게 scope가 생기는 것은 다른 언어와 같기 때문에 당연한 것이다. 하지만 with와 catch는 조금 다르다. function은 블럭{} 안에 있는 모든 내용이 새로운 내부 scope에 포함되지만, with와 catch는 괄호() 안에 인자로 받는 변수들만 새로운 내부 scope에 포함되어 이후에 따르는 블럭{}에서만 접근이 가능하다.
try {
throw new exception("fake exception");
} catch (err) {
var test = "can you see me";
console.log(err instanceof ReferenceError === true);
}
console.log(test === "can you see me");
console.log(typeof err === 'undefined');
: 위와 같은 경우 외부에서 test는 일반 블럭처럼 접근이 가능하지만 catch의 인자로 들어온 err에는 접근을 할 수가 없다. with도 마찬가지이다.
with ({test: "You can't see me"}) {
var notScope = "but you can see me";
console.log("Inside: " + (test === "You can't see me"));
}
console.log(typeof test === 'undefined');
console.log(notScope === "but you can see me");
: function과는 또 다른 독특한 scope를 가지고 있다. 그렇다면 이것만 가지고 위의 클릭 이벤트에서 일어나는 오류를 해결할 수 있을까?
: 잠시 다른 이야기지만, 자바스크립트를 오래한 사람들은 머리에 수없이 박히게 들어왔을, 왠만해서는 정말 어쩔 수 없는 경우가 아니라면사용하지 말아야할 2가지 함수가 있다.
eval
with
: 자바스크립트의 활성화에 가장 큰 기여를 한 Douglas Crockford가 말한 명언이 있다.
"eval is evil"
: 이미 eval을 사용하는 것은 JSON.parse() 기능이 나오고난 이후에 퍼포먼스상, 보안상 단점만 있는 기능이 되어버렸다. 그리고 그와 동급으로 with는 처음부터 없는 듯이 사는 것이 좋을 때도 있다고 언급하고 있다. 이미 자바스크립트를 많이 해왔던 사람들 중에서도 with가 무엇을 하는 구문인지 모르는 사람들도 많겠지만 위의 클릭 이벤트 문제에서는 이렇게 천대받던 with도 뭔가 한가지 역할을 찾을 수 있게 된다. 만약 with를 쓴다면 이것이 with문을 거의 유일하게 효율적으로 이용할 수 있는 방법이 아닐까 싶다.
<div id="divWith0">Click me! DIV 0</div>
<div id="divWith1">Click me! DIV 1</div>
<div id="divWith2">Click me! DIV 2</div>
<script>
var i, len = 3;
for (i = 0; i < len; i++) {
with ({num: i}) {
document.getElementById("divWith" + num).addEventListener("click", function () {
alert("You clicked div #" + num);
}, false);
}
}
</script>
: 위의 구현 결과는 아래와 같다.
Click me! DIV 0
Click me! DIV 1
Click me! DIV 2
: with를 하나 추가해줬을 뿐인데 뭐가 달라진 것일까? 이건 단순히 scope가 하나 생겼다고해서 이해되는 현상이 아니고 자바스크립트의 비동기적인 콜백 함수의 특성과 scope의 지속성이 합쳐진 결과이다. 이렇게 with를 이용해서 해결할 수도 있지만 with는 변수 사용에 있어서 혼란을 가져오고 있기 때문에 그래도 사용을 비추천한다. 다른 해결 방법은 아래에서 설명하고 그래도 with로 문제가 해결은 되었기 때문에 일단 위의 소스에서 with가 어떻게 문제를 해결하는지 살펴보자.
: 먼저 with는 괄호() 안에 새로운 scope를 만들게 된다. 그리고 num의 값에 i의 값을 부여하고 click 이벤트 콜백 함수를 선언한다. 이제 click 이벤트 콜백 함수는 with의 num을 보며 고정된 num의 값 0을 보게 되는 것이다. 이어서 다음 루프에서 scope가 생성되는 것을 다시 살펴보면 아래와 같다. 이전에 div0의 click 이벤트 콜백 함수가 보는 scope는 유지되고, 새롭게 with의 scope가 생성되면서 num을 i의 값인 1로 초기화를 시킨다. div1의 click 이벤트의 콜백함수는 이 scope의 num을 이용하게 되는 것이고, 3번째 루프에서도 똑같은 방식으로 div2의 click 이벤트 콜백도 만들어지게 된다. 결국 오른쪽 그림과 같이 scope가 형성되어 div에서 click 이벤트가 일어나 콜백 함수를 호출 할 때에는 맞게 값이 출력하게 된다.
: 위와 같이 scope가 생성되는 것 뿐만 아니라 함께 scope에서 변수값을 지속시키고 유지 시키는 특성이 이러한 문제를 해결할 수 있게 도와준 것이다. 그럼 scope의 지속성에 대해서 조금 더 공부해보자.
* scope의 지속성
: 사실 scope의 생성되는 방식이 기존의 언어와 가장 다른 점은 아니다. 하지만 scope가 지속되는 것은 다른 언어와는 다른 자바스크립트만의 강점 중 하나이다. 이러한 지속성이 자바스크립트에서 필요한 이유는 바로 자바스크립트에서 새로운 scope가 생성되는 '함수'를 변수에 넣을수도 있고, 다른 함수의 인자로 넘겨줄수도 있으며, 함수의 return 값으로도 활용할 수 있기 때문에 필요했던 개념이다. 즉, 지금 함수가 선언된 곳이 아닌 전혀 다른 곳에서도 함수가 호출될 수 있기 때문에, 그 함수의 scope는 지속될 필요가 있었던 것이다. 그럼 간단하게 그 지속성을 이해하기 위해 위의 클릭 이벤트 문제를 또 다른 방식으로 해결해보겠다.
<div id="divScope0">Click me! DIV 0</div>
<div id="divScope1">Click me! DIV 1</div>
<div id="divScope2">Click me! DIV 2</div>
<script>
function setDivClick(index) {
document.getElementById("divScope" + index).addEventListener("click", function () { // #1
alert("You clicked div #" + index);
}, false);
}
var i, len = 3;
for (i = 0; i < len; i++) {
setDivClick(i);
}
</script>
: 이번에는 굳이 자바스크립트 언어가 아니더래도 이해하기 쉬울 것이다. 하지만 비동기적으로 호출되는 자바스크립트의 특성에서는 중요하게 생각해야할 개념이다. 일단 위의 구현 결과를 아래에서 살펴보자. 각 DIV를 누르면 누른 번호가 맞게 출력된다.
Click me! DIV 0
Click me! DIV 1
Click me! DIV 2
: 위의 with와는 똑같은 개념으로 scope가 생성되고 지속되기 때문에 그림은 비슷하게 나온다. 하지만 다른점이 있다면, with는 with의 내부에서 with의 특성을 따라가게 되고 scope가 완전히 분리된것이 아닌 global scope와 반쯤 섞여있는 형태를 취하고 있는 반면, function으로 구현한 경우 정말로 별도의 scope를 생성하게 된다.
: 소스상 with를 쓰는 것이 간편하기 때문에 이렇게 간단한 경우에'만' 사용할 것이고, 그 외에 조금이라도 복잡해지거나 다른 사람들과 협업을 한다면 function으로 분리할 것을 추천한다. 왜냐하면 자바스크립트를 자주 사용하는 사람이더래도 with가 들어가게 되면 그 용법에 대해서 다시 한번 고민을 하고 검색까지 해봐야할지도 모르기 때문에 모두가 이해할 수 있는 쉬운 용법이 있으니 협업에서는 그것을 활용하는 것이 맞는 것이다. 하지만 위와 같이 함수를 따로 뽑아내는것도 귀찮고 일이다. 그럼 이것을 위의 with처럼 간단하게 처리하고 싶을 때에는 closure와 익명 함수의 조합을 이용하면 된다.
<div id="divScope0">Click me! DIV 0</div>
<div id="divScope1">Click me! DIV 1</div>
<div id="divScope2">Click me! DIV 2</div>
<script>
var i, len = 3;
for (i = 0; i < len; i++) {
document.getElementById("divScope" + i).addEventListener("click", (function (index) { // #1
return function () { // #2
alert("You clicked div #" + index);
};
}(i)), false); //#3
}
</script>
: 위의 소스가 이해된다면 자바스크립트를 어느 정도 봐온 사람일 것이다. 일단 with랑 비슷하게 생각하면 되지만, with가 아닌 새로운 function을 통해 새로운 scope를 생성하는 것이다. 이러한 기법은 자바스크립트에서 아주 많이 사용되고 있고, 응용 범위도 아주 넓어 익혀두면 유용한 기법이다. 일단 어떻게 돌아가는 것인지 간단하게 설명을 한다면, #1에 선언된 익명 함수는 인자를 index를 받는것인데, 이 인자의 값은 #3에 있는 (i)의 값이 index로 들어오게 된다. 이는 함수가 변수임을 다시 생각하고 보면 쉽다. 즉 아래와 비슷하게 생각하면 된다.
var func = function (index) { /* 생략 */}; //#4
var returnValue = func(i); //#5
returnValue = (function (index){ /* 생략 */}(i)); //#6
: 위의 #4와 #5를 하나로 합치게 되면 #6이 되는 것이다. 이것을 위의 #1~#3까지 표현한것이라고 보면 된다. 이것에 대한 것은 기본적으로 이해하고 넘어가길 바란다. 자바스크립트 라이브러리의 가장 기본적인 활용 패턴 중 하나이기 때문이고, 이 개념을 이해하고 응용할 줄 아는 것은 자바스크립트 프로그래머의 기본이다.
: 이제 내부에서 #2는 현재 호출된 함수의 return값으로 익명 함수를 하나 또 return한다. 이건 또 무슨 뜻인지 처음에 보면 헷갈리겠지만, 위의 #6번에서 returnValue의 안에 함수가 들어가는 것으로 이해하면 된다. 즉, 익명 함수의 리턴값으로 함수가 반환되고 그 반환된 함수가 addEventListener의 2번째 인자로서 들어가게 되는 것이다. 이 개념 또한 아주 기본적인 개념이니까 이해하고 넘어가길 바란다.
: 이렇게 간단하게 closure를 이용하는 방법이 있는 반면, closure자체는 엄청난 응용이 가능하기 때문에 closure에 대한 내용은 나중에 closure의 활용 방법에 대해서 자세하게 다시 한번 더 다룰 것이다.
* 정리
- scope는 다음의 명령어들을 호출할 때 새로 생성하게 되고, 일반적인 for, switch 등의 블럭{}에 의해서 구분되지 않는다.
function
with
catch
- scope는 비동기 함수가 호출될 때까지 계속해서 지속되어 참고된다. 이를 새로운 scope를 생성함으로써 비동기적으로 호출 될 때의 scope를 조율할 수 있다.
* 덧
: 위에서 언급했지만 여기서 잠깐 다룬 scope와 closure는 자바스크립트에서 가장 기본이 되는 내용이다. 이 내용을 모르고는 자바스크립트를 자바스크립트답게 사용할 수 없기 때문에 위의 간단한 예들이 어떻게 돌아가는 것인지 천천히 이해하고 넘어가는 것이 좋다. 많은 강좌들에서는 문법들을 다루고 closure를 가장 뒤에서 소개하고 있지만, 이 내용은 진정 자바스크립트의 가장 기초가 되는 개념이고 이것을 이해해야 다양한 자바스크립트만의 기법들이 활용될 수 있기 때문에 기본이라고 말한 것이다.
: 그럼 다음에는 this가 결정되는 방법과 function에 대해서 공부해보도록 하자. 자바스크립트를 자바스크립트답게 만드는 것이 바로 이 function이므로 이에 대해서도 가장 먼저, 사실 scope보다도 먼저, 공부를 해야할 필요가 있다.
이번 프로젝트는 대부분의 Process Layer Component에서 다양한 DB 접근하여 데이터를 조회하고, 조회된 DATA을 요구사항에 따라 조합하여 화면에 보여주는 형태의 프로세스 개발이 많은 프로젝트였다. 다양한 DB을 사용하면서, WAS에서의 Transaction은 One Phase Commit만 지원하도록 설정되어 있었다. 따라서 GLOBAL TRANSACTION을 안됨에 SERVICE/PROVIDER LOGIC 중간 중간에 Transaction을 분리해야만 했다. 우선 Transaction의 속성을 먼저 정리하고, 적용했던 Transaction 분리 했던 방법을 설명하려 한다.
Transaction 속성은 Transaction의 영역을 제어한다. 그림 1은 EJB Bean-1의 method-A가 TX1 Transaction을 가지고 실행 중, EJB Bean-2의 method-B을 호출하는 그림으로써, Bean-2의 methjod-B가 실행할 때, TX1 Transaction을 가지고 실행할 지?, 새로운 TX? Transaction을 가지고 실행할 지?, 또는 Transaction 없이 실행할지? 는 Transaction의 속성에 따라 Transaction의 영역이 달라짐을 보여주고 있다.
Transaction Propagation에는 7가지(REQUIRED, REQUIRESNEW, MADATORY, NOTSUPPORTED, SUPPORTS, NEVER, and NESTED)을 가진다. 7가지 속성은 다음과 같다.
REQUIRED 속성[3]
Bean-2의 b-Method에 Transaction 속성을 REQUIRED로 설정하였다면, 그림2, 3과 같은 경우가 발생한다. 그림 2는 Bean-1의 A-method가 TX1 Transaction을 가지고 있는 경우, B-method 호출 시 B-method은 A-method의 TX1 Transaction을 가지고 수행한다. 따라서, A-method와 B-method는 항상 같이 Commit 또는 Roll-Back 된다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED/REQUIRESNEW로 하고, B-method의 transaction을 REQUIRED로 설정한 경우이다.
그림 3은 만약 Bean-1의 A-method가 Transaction을 가지고 있지 않다면, 컨테이너는 새로운 TX2 Transaction을 생성해서 Bean-2의 B-Method를 실행한다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 REQUIRED로 설정한 경우이다.
결론적으로, B-method는 항상 Transaction 속에서 실행된다. B-method를 호출하는 method의 transaction 존재여부에 따라 Transaction을 공유할 지, 새로 생성할지가 결정된다.
참고로, 컨테이너로부터 관리되는 Transaction을 가진 모든 Enterprise Bean Method는 암묵적으로 REQUIRED을 가지고 있다. 따라서 REQUIRED외 다른 Transaction을 사용할 것이 아니면 REQUIRED을 설정하지 않아도 된다.
REQUIRESNEW 속성[3]
Bean-2의 b-Method에 Transaction 속성을 REQUIRESNEW로 설정하였다면, 그림4, 5과 같은 경우가 발생한다. 그림 4는 Bean-1의 A-method가 TX1 Transaction을 가지고 실행 중, Bean-2의 B-method를 호출한 경우를 나타낸다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED/REQUIRESNEW로 하고, B-method의 transaction을 REQUIRESNEW로 설정한 경우이다.
그림 4의 Transaction의 흐름은 아래 절차에 따른다.
B-method 호출 시, A-method TX1 Transaction을 일시 중지한다.
컨테이너는 새로운 TX2 Transaction을 시작한다.
새로운 TX2 Transaction 속에서 B-Method를 호출한다.
B-Method가 완료된 후, A-Method Transaction을 다시 시작한다.
그림 5는 A-method가 Transaction 없이 B-Method를 호출한 것을 나타낸다. A-method가 B-method를 호출하는 시점에, 컨테이너는 새로운 TX2 Transaction을 생성하여, 그 TX2 Transaction 속에서 B-method를 실행한다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 REQUIRESNEW로 설정한 경우이다.
결론적으로, method의 Transaction 속성을 REQUIRESNEW로 설정하면, 항상 새로운 Transaction 속에서 실행된다.
참고로, A-method(호출자)가 Transaction이 없고, B-method(호출받는 자)의 Transaction 속성을 REQUIRED/REQUIRESNEW로 설정하면 동일한 결과를 보인다(그림 3, 그림 5).
MADATORY 속성[3]
Bean-2의 b-Method에 Transaction 속성을 MADATORY로 설정하였다면, 그림6, 7과 같은 경우가 발생한다. 그림 6은 A-method가 Transaction을 가지고 B-method을 호출하는 경우를 나타내며, B-method 호출 시, B-method은 A-method의 TX1 Transaction을 가지고 수행한다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED/REQUIRESNEW로 하고, B-method의 transaction을 MADATORY로 설정한 경우이다.
그림 7은 A-method가 Transaction 없이, B-method을 호출한 경우를 나타낸다. B-method 호출 시, 컨테이너는 A-method에게 TransactionRequiredException을 던진다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 MADATORY로 설정한 경우이다.
이 MADATORY 속성은 호출하는 A-method가 반드시 Transaction을 가지고 수행해야 할 때, 사용한다. 다시말해, B-method가 독립적으로 Transaction을 진행하면 안 되는 경우 사용한다. 예를 들어, A-method가 계좌이체 서비스이고, B-method는 출금 서비스, C-method 입금 서비스라고 가정해 보자. A-method는 B-method를 호출한 후, C-method를 호출할 것이다. B, C-method를 MADATORY로 설정하면, A-method는 Transaction을 가질 수 밖에 없게되고, A, B, and C-method는 하나의 Transaction으로 묶이게 된다. 누군가는 A,B, and C-method를 REQUIRED로 설정한다면, 이전과 같은 결과를 가지지 않느냐라고 반문할 수 있을 것이다(동일한 결과를 얻음으로). 그러나 만약 누군가가 실수로 A- method를 REQUIRED에서 NOTSUPPORTED로 변경하였다면, 오류가 발생해도 찾아 내기 쉽지 않을 것이다. 따라서, 호출자와 Transaction을 반드시 묶어야 할 때는 명시적으로 사용하는 것이 낫다고 생각한다.
NOTSUPPORTED 속성[3]
Bean-2의 b-Method에 Transaction 속성을 NOTSUPPORTED로 설정하였다면, 그림8, 9과 같은 경우가 발생한다. 그림 8은 A-method TX1 Transaction을 가지고, B-method을 호출한 경우를 나타낸다. B-method 호출 시, 컨테이너는 B-Method를 호출하기 전에 A-Method의 TX1 Transaction을 일시 중지하고, B-Method가 완료된 후, 컨테이너는 A-Method의 TX1 Transaction을 다시 시작한다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED로 하고, B-method의 transaction을 NOTSUPPORTED로 설정한 경우이다.
그림 9는 A-method가 Transaction 없이 B-method을 호출하는 것을 나타낸다. B-method 호출 시점에, 컨테이너는 B-Method을 진행하기 전까지 새로운 Transaction을 시작하지 않는다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 NOTSUPPORTED로 설정한 경우이다.
이 NOTSUPPORTED 속성은 Transaction이 필요하지 않는 Method을 위해 사용한다. Transaction은 항상 overhead가 따르기 때문에, 이 속성을 사용하면 performance을 개선할 수 있다.
SUPPORTS 속성[3]
Bean-2의 b-Method에 Transaction 속성을 SUPPORTS로 설정하였다면, 그림10, 11과 같은 경우가 발생한다. 그림 10은 A-method가 TX1 Transaction을 가지고, B-method을 호출하는 것을 나타낸다. B-method를 호출 시, A-method의 TX1 Transaction을 가지고 수행한다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED로 하고, B-method의 transaction을 SUPPORTS로 설정한 경우이다.
그림 11은 A-method가 Transaction 없이 B-method을 호출하는 것을 나타낸다. B-method 호출 시, 이 B-Method는 Transaction 없이 실행한다. 컨테이너는 B-Method를 진행하기 전까지 새로운 Transaction을 시작하지 않는다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 SUPPORTS로 설정한 경우이다.
다시 말해, B-method의 Transaction은 A-method의 Transaction 속성을 따라간다. Method의 Transaction 행위가 다양함에 따라 이 SUPPORTS 속성은 주의해서 사용해야 한다.
NEVER 속성[3]
Bean-2의 b-Method에 Transaction 속성을 NEVER로 설정하였다면, 그림12, 13과 같은 경우가 발생한다. 그림 12는 A-method가 TX1 Transaction을 가지고 B-method을 호출하는 것은 나타낸다. B-method 호출 시, 컨테이너는 RemoteExcepiton 발생한다. 예를 들어 설정하자면, A-method의 transaction을 REQUIRED로 하고, B-method의 transaction을 NEVER로 설정한 경우이다.
그림 13은 A-Method가 Transaction 없이, B-Method을 호출하는 것을 나타낸다. B-method 호출 시, 컨테이너는 B-Method을 진행하기 전까지 새로운 Transaction을 시작하지 않는다. 예를 들어 설정하자면, A-method의 transaction을 NOTSUPPORTED로 하고, B-method의 transaction을 NEVER로 설정한 경우이다.
Manatory와 반대로 Transaction이 없이 실행해야 한다.
NESTED 속성[6, 7]
이 속성은 중접된 Transaction을 지원하는 WAS에서만 지원되는 Transaction 속성이다. Bean-2의 b-Method에 Transaction 속성을 NESTED로 설정하였다면, 그림14, 15과 같은 경우가 발생한다. 그림 14는 A-method가 TX1 Transaction을 가지고, B-method을 나타낸다. 이 경우 컨테이너는 A-method의 TX1 Transaction 내의 nested transaction형태로 TX1’ transaction을 만들어 B-Method을 실행한다. B-Method에서 발생한 변경사항이 commit이 되기 전까지는 A-method의 TX1 Transaction에서 보이지 않는다. 또한 TX1’ transaction은 자체적으로 commit, rollback이 가능하다
A-method의 TX1 Transaction의 상태는 B-method에게 영향을 주고, B-method의 TX1’ Transaction의 상태는 A-method TX1 Transaction에게 영향을 주지 않는다[7].
그림 15는 A-method가 Transaction 없이 B-method을 호출하는 것을 나타낸다. 이 경우 컨테이너는 B-method을 REQUIRED 속성으로 실행한다.
NESTED 속성은 WAS에 따라 지원여부가 결정된다.
지원하지 않는 WAS는 WebLogic, JEUS, and, Java EE 5 등 이다. WebLogic Server implements the flat transaction model. Nested transactions are not supported[1]. The Enterprise JavaBeans architecture supports flat transactions. A flat transaction cannot have any child (nested) transactions[2].
지원하는 WAS는 ODBC, OLE DB, and, SQL Server 등이다. Neither Open Database Connectivity (ODBC), nor Microsoft OLE DB Provider, supports Nested Transactions [4, 5].
Transaction 적용 예
그림 16은 한 Process에서 영업, 인사, 고객, 계약 등 정보를 조회하여 데이터를 조합하는 보여주는 시나리오이고, 여기의 계약 DB, 인사 DB, 고객 DB, 영업 DB가 별도로 존재한다고 가정한다.
이 시나리오는 특정 계약에 가입한 고객이 다른 어떤한 종류의 계약을 가입했는 지 조회하여 목록으로 보여주는 것이다. 이 시나리오를 수행하는 흐름은 1) 로그인 사용자 정보를 기반으로 권한을 체크하고, 2) 계약번호로 계약 정보를 조회한 후, 3) 계약의 고객 번호로 고객 정보를 조회 하고, 4) 이 고객이 가지고 있는 다른 계약을 모두 조회, 5) 영업한 사람이 누구인지 조회하여 데이터를 조회하여 보여준다.
Transaction의 분리 고려사항
필자는 Transaction 분리하기 위해 4가지를 고려하였다.
이 기능은 주요 목적은 무엇인가?
어떤 Service를 어느 Transaction에 묶을 것인가?
Transaction을 분리하는 횟수에 따라 overhead는 가중된다.
Overhead는 성능에 영향을 준다.
위 고려 사항에 따라서 그림 16의 시나리오에서는 아래와 같이 설정하였다.
이 기능은 계약을 조회하는 목적이다.
주 목적에 따라, A-method의 TX1의 Transaction에 계약 조회 서비스(C, E - methods)를 Requried로써 하나의 Transaction으로 처리하였다.
인사, 고객, 영업은 이 기능의 주요 목적의 sub 정보임에 따라, RequiresNew로 Transaction을 나누었다.
더군다나 같은 계약 DB를 한 Transaction으로 묶어서, Transation의 Overhead도 약간이나마 줄일 수 있다.
여기에서 RequiresNew 대신에 NotSupported로 설정을 하여도 실행은 될 것이다. 단지 실행하는 method가 Transation을 가지고 실행할 것인가? 아닌가? 하는 선택의 문제이다. 오히려 NotSupported으로 설정하였을 때, Transaction Overhead가 줄어들어 성능을 개선시켜주는 효과를 줄 것이다(단. 한, 두번 테스트가 아닌 스트레스 테스트를 한 경우). 필자는 이 프로젝트가 금융과 관련되어있어 안정성을 위해서 NotSupported보다 RequiresNew를 사용하였다.