[펌] LeakDiag로 메모리누수 디버깅하기

출처 : http://kuaaan.tistory.com/164

저는 실제로 개발의 전 사이클을 놓고 볼때 퍼포먼스를 결정짓는 것은 코딩 속도가 아니라 디버깅이라고 생각합니다. 그만큼 찾기 힘든 버그가 하나 터졌을 때 전체 공정에 미치는 영향은 크죠. 일정은 지연되고 있는데 버그를 못잡고 있으면.. 아주 미칩니다. 피가 바짝바짝 마릅니다.. ㅎㅎ

그러한 잡기 힘든 버그 중 하나가 메모리 누수(Memory Leak)입니다. 메모리 누수는 코드를 들여다보면서 잡으려고 생각하면 정말 잡기 힘든 버그이고, 또 오랫동안 실행해 보아야 알 수 있기 때문에 재현도 쉽게 되지 않는 골치아픈 놈이죠. 하지만, 메모리 누수를 잡아주는 진단툴 몇가지만 알고 있으면 어디서 문제가 발생했는지를 바로 집어낼 수 있습니다.

저는 예전에 메모리 누수를 잡기 위해 UMDH 라는 툴을 사용했습니다만, LeakDiag라는 더 훌륭한 툴을 알게 되어 소개하고자 합니다.

1. LeakDiag란?
1) LeakDiag는 “Memory Leak”이 발생한 것으로 추정되는 지점을 탐지해주는 프로그램으로서 소스코드 없이 실행중인 프로세스에 대해 분석이 가능합니다. 여기에 디버그심볼이 추가되면 해당 메모리를 할당한 소스코드상의 위치와 당시의 CallStack을 확인할 수 있습니다.
2) 메모리 누수를 탐지한다고 하지만 포인터로부터 더이상 Reference되지 않지만 해제되지는 않는 메모리, 즉 정확한 의미에서의 “메모리 누수”를 탐지하지는 않습니다. 대신 메모리 할당을 Tracing해서 어느 지점에서 몇바이트의 메모리가 몇번 할당되고 해제되었는지를 보여주기 때문에 비정상적으로 반복해서 해제없이 할당된 경우를 확인할 수 있습니다. 즉, 레포트에는 정상적으로 할당된 메모리도 기록될 수 있습니다. 그것을 구분하는 것은 개발자의 몫이라는 거죠.

2. LeakDiag의 장점
UMDH와 비교할때 LeakDiag의 장점은 다음과 같습니다.

1) UMDH는 Process의 Default Heap을 이용하는 경우 (new 연산자 등)만 Tracing이 가능하며, CRL Private Heap에 메모리를 할당하는 malloc 등의 CRL 함수는 제대로 Tracing이 되지 않습니다. 하지만 LeakDiag는 new, HeapAlloc, VirtualAlloc, malloc 뿐만 아니라 COM계열의 메모리 할당까지 모두 Tracing이 가능합니다. (사실 이게 가장 큰 장점!)
2) UMDH는 메모리 할당을 Tracing하기 위해 윈도우즈에서 제공하는 메모리 디버깅 기능을 사용합니다. 따라서 메모리 트레이싱을 하기 전에 Gflags.exe 설정을 해주는 약간은 귀찮은 과정이 필요합니다. 하지만 LeakDiag는 “Microsoft DetoursTechnology”라고 하는 최첨단(?)기술을 사용하기 때문에 별도의 설정 없이 Memory Tracing이 가능합니다. 
3) 모니터링시의 성능저하를 줄이기 위해 로그를 기록할 때 디버그심볼 없이 기록한 후 나중에 디버그정보를 추후 분석할 수있다던지, 레포트가 좀더 Visual해서 보기 편하다던지, GUI를 지원해서 사용하기가 편하다던지 하는 자질구레한 장점들도있습니다.

※ UMDH에 대한 설명은 다음의 MSDN을 참고하세요.

http://support.microsoft.com/kb/268343

3. LeakDiag의 동작 원리
UMDH와 마찬가지로 LeakDiag도 크게 두가지 과정으로 메모리 누수를 확인합니다.
1) 동적 메모리 할당을 추적
LeakDiag는 디버깅할 Target Process에서 동적으로 할당되는 메모리를 추적하면서 해당 프로세스의 Heap을 모니터링합니다. 위에서 설명했듯이 LeakDiag는 “Microsoft DetoursTechnology”라는 기술을 이용하여 메모리 할당을 추적하는데, 이 “Detours Technology”라는게 별게 아니고 그냥 Dll Injection해서 HeapAlloc() 과 같은 메모리 할당 함수를 Inline 후킹하는 겁니다. 우리같은 사람들이 하면 “Hooking”이고 MS가 하면 “Technology”가 되는 거죠. ^^

아래 이미지는 LeakDiag.exe에서 Debugging Target Process인 notepad.exe에 “inject.dll” 을 injection한 모습입니다.

2) 두번 이상 Snapshot을 기록하여 Delta를 비교함.
UMDH등 다른 메모리누수 탐지 Tool과 마찬가지로 LeakDiag 역시 Snapshot을 비교하여 Leak을 탐지합니다. 예를 들어 두번의 로그를 기록했다면, 그 두개의 로그(Snapshot)을 비교해서 할당된 메모리들을 코드상의 할당한 지점(정확히 말하면 CallStack)으로 Grouping해서 Group별로 메모리의 증가분이나 할당이 이루어진 횟수 등을 보여줍니다. 첫번째 로그에는 없었던 메모리가 두번째 로그에는 기록되어 있다면 Leak일 가능성이 있다고 판단하는 식이죠. 만약 프로세스에 가해지는 부하가 일정하고 Leak 없이 정상적으로 메모리가 사용된다고 가정한다면 두 지점의 메모리 사용량은 크게 다르지 않을 것입니다. 하지만 메모리 누수가 발생하고 있다면 해당 지점에서 할당한 메모리가 증가한 것이 확인될 것입니다. 만약 4~5개 이상의 Snapshot을 기록하여 서로 비교한다면 메모리가 증가하고 있다는 것을 보다 확실히 확인할 수 있겠죠. 바로 그 지속적으로 증가하는 메모리를 할당한 코드상의 지점이 버그 위치입니다!!
LeakDiag는 snapshot을 xml 로그파일로 기록하는 단계까지를 수행하며, 해당 xml로그를 비교 분석하여 그래프로 보여주기 위한 LDGrapher 라는 분석 프로그램을 별도로 설치해야 합니다. LDParser라는 더 좋은 프로그램도 있다고 하지만 이건 “Microsoft Premier” 고객들만 이용 가능하다고 하네요. 하지만 LDGrapher로도 우리같은 일반인(!)들에게는 충분한 것 같습니다.

4. LeakDiag 사용법
1) LeakDiag 설치
진단 프로그램인 LeakDiag와 분석프로그램인 LDGrapher를 설치합니다. LeakDiag를 설치하면 텍스트 버젼인 ldcmd.exe도 함께 설치됩니다. 예약작업 등이 필요하다면 텍스트버젼이 유용할 것입니다.
텍스트 버젼의 사용법에 관해서는 LeadDiag 도움말에 자세히 나와 있습니다.

ftp://ftp.microsoft.com/PSS/Tools/Developer%20Support%20Tools/LeakDiag/
ftp://ftp.microsoft.com/PSS/Tools/Developer%20Support%20Tools/LDGrapher/

2) 환경설정
LeakDiag를 실행해서 제일 먼저 할 일은 옵션을 설정하는 일입니다.

 Symbol Search Path : 디버깅할 Target Process의 디버깅 심볼(pdb 파일)이나 Windows 라이브러리들의 디버깅 심볼의 위치를 설정하면 됩니다. 기본적으로는 DbgHelp.dll에서 찾는 pdb Path와 동일하게 적용되며, 추가적으로 설정할 경우 아래와 같이 windbg 세팅할때와 같은 식으로 설정하면 됩니다. 테스트 결과 exe와 동일한 경로에 pdb가 존재하는 경우엔 자동으로 인식하는 것 같습니다.

c:winnt;SRV*c:websymbols*http://msdl.microsoft.com/download/symbols

※ 위와 같이 설정할 경우 웹심볼을 다운받을 C:websymbols 폴더는 직접 만들어주어야 합니다.

 Resolve Symbols when logging : 로그를 기록할 때 Symbol을 사용해서 주소 번지를 함수명과 offset 등으로 번역해서 보여줄 지 여부입니다. 이 설정을 체크할 경우 메모리를 할당한 코드 상의 위치를 정확하게 보여주지만 퍼포먼스 저하가 발생하기 때문에 실서버 등에 적용할 경우 이 항목은 Uncheck하는 것이 좋습니다. 만약 Uncheck된 채로 로그를 기록할 경우 로그 기록 후에 “sdecode” 프로그램과 프로세스 덤프를 사용해서 사후에 디버그 심볼 정보를 추가할 수 있습니다.

 Use DbgHelp StackWalk API to walk stacks : CallStak을 기록하기 위해 DbgHelp.dll 의 Stakwalk API를 사용합니다. 이 항목을 체크하지 않을 경우 EBP와 RET의 Chain을 추적해서 CallStack을 기록하지만, DbgHelp를 사용하면 더 정확한 CallStack 추적이 가능합니다. 단, 퍼포먼스가 매우 저하되므로 실서버에 적용할 경우 조심해야 합니다. 

 Max Stack Depth : CallStack을 몇단계까지 추적할 지를 설정합니다. Default는 5, Maximum은 32. 너무 높은 값을 설정하면 트레이싱할 때 부하도 높아지고, 콜스택이 너무 세부적으로 Grouping되어 오히려 Group이 너무 많아져서 레포트 해석이 어려워지는 결과가 될 수도 있습니다.

3) 모니터링을 시작
LeakDiag를 실행하면 아래와 같은 화면을 볼 수 있습니다. 

먼저 상단 리스트에 나타난 프로세스 중 디버깅할 대상을 선택한 후  하단의 “Memory Allocators” 항목에서 Tracing할 Memory Allocation 유형을 선택합니다. 각각의 항목들의 의미는 다음과 같습니다.

Virtual Allocator (VirtualAlloc)
NT Heap Allocator (HeapAlloc)[DEFAULT]
MPHeap Allocator (MPHeap)
COM AllocatorCoTaskMem (CoTask)
COM Private Allocator (PrivateMemAlloc)
C Runtime Allocator (msvcrt new)

Default는 “NT Heap Allocator”입니다. 실제로 테스트를 해보니 “C Runtime Allocator” 항목은 로그가 기록되지 않더군요. 그냥 “NT Heap Allocator” 을 선택하면 “new”나 “malloc”도 추적됩니다. (new 등도 내부적으로 HeapAlloc 함수를 사용합니다.)

그 다음 “Start” 버튼을 클릭하면 버튼이 “Stop”으로 바뀌고, 대상 Process에 dll이 injection되면서 Tracing이 시작됩니다. Dll Injection에 성공하면 “Log”버튼과 “Unload”버튼이 Enable되며, “Start”버튼은 “Stop” 버튼으로 바뀝니다. 

※ 상단의 Process 목록은 자동으로 Refresh되지 않으므로 “View > Refresh”를 클릭하여 수동으로 새로고침해주어야 합니다.
※ “Start”를 클릭했을 때 버튼이 “Stop”으로 바뀌지 않으면 Dll이 Injection되지 않은 것입니다. 다음과 같은 사항들을 확인하세요.
   – Debug Symbol Path 가 안맞아서 서버 접속이 안되어 “멍”하고 있거나, 혹은 최초 접속시 Debug Symbol 다운로드받는 시간이 소요되는 경우. ==> 이 문제이기 확인하기 위해서는 Debug Symbol Path를 삭제하고 다시 시도해보세요.
   – 디버그 대상 Process에 Attach할 수 없는 경우. 이경우 대부분 디버그 대상 Process가 디버거에 의해 실행된 경우입니다. 

4) Log를 기록
Tracing이 시작되면 “Log” 버튼을 클릭해 Log(Snapshot)를 기록해야 합니다.
Log는 두번 이상 기록하되, 첫번째 로그를 기록한 이후 메모리 누수가 재현되기를 기다렸다가 다음번 로그를 기록해야 합니다. 최소한 두번 이상, 많으면 4~5번정도 기록하면 좋습니다. 로그 버튼을 클릭하면 다음과 같이 Log 폴더에 로그가 기록되는 것을 볼 수 있습니다. (만약 프로그램 종료시 회수되도록 되어있는 메모리라면 회수 시점 이후에 마지막 Log를 기록해야 정상적으로 메모리가 회수되었는지를 확인할 수 있습니다.)
재현되는데 오래 걸리는 경우라면 첫번째 로그와 두번째 로그 사이에 며칠 이상 긴 시간이 필요할 수도 있습니다. 그런 경우라면 차라리 LeakDiag의 커맨드 버젼인 ldcmd.exe를 예약작업에 걸어버리는게 나을 수도 있죠. 
로그를 다 기록했다면 “Stop”을 눌러서 트레이싱을 중지합니다.

※ 단, 트레이싱을 시작한 후 로그를 기록하는 시점까지 할당된 메모리가 전혀 없다면 로그 파일이 생성되지 않습니다.

5) Log를 분석
로그를 기록했으면 분석을 해야죠.
LDGrapher를 실행시킨 후 “File > Open Files”로 들어가서 위에서 기록한 로그를 모두 선택해 주면 다음과 같이 로그를 해석해서 그래프를 그려줍니다.

이 그래프의 X축은 시간의 흐름 (로그 기록시점), Y축은 메모리의 증가량(%)을 의미합니다. 그래프에서 두개의 선이 나타나 있고, 각 선이 네개의 노드를 가지고 있는 것은 해당 프로세스에서 메모리릭 가능성이 있는 코드상의 지점(콜스택)이 두개 발견되었고, 총 네개의 로그를 분석했다는 의미가 됩니다. 두 그래프가 모두 지속적으로 증가하고 있기 때문에 이 두 지점은 모두 Memory Leak일 가능성이 높다고 볼 수 있습니다. 

아래의 그림을 보면 “하늘색 라인”이 메모리가 지속적으로 증가하는 양상을 보이고 있습니다. 우측의 하늘색 숫자에 마우스를 갖다대면 아래 그림에서 보이듯 좌측 하단에 각 노드들의 값이 나타나고 있습니다. 

View > Num Allocs를 클릭하면 Y축의 단위가 할당 횟수로 바뀝니다.

이렇게..
할당횟수로 보니 위의 그래프에서는 잘 안보이던 Leak Point가 드러나네요…

위의 그래프처럼 증가 추세가 쉽게 판독되는 그래프를 얻기 위해선 Log를 기록하는 타이밍이 중요합니다.
예를 들어서 주기적으로 반복되는 작업을 한번 할때마다 메모리가 증가하는 경우라면, 그 주기적인 “작업” 도중에 Log를 기록하면 그래프가 이상해질 수 있습니다. 이런 경우엔 매 작업 종료 후에 Log를 기록하는게 좋은데요, 이렇게 하기 위해서는 로그를 기록해야 할 타이밍에 Log를 기록하고 Sleep을 주거나, MessageBox를 주어 프로세스를 잠시 멈추어주면 좋습니다.

이 그래프에서 노드 부분이나 우측 상단에 있는 CallStack ID 부분을 더블클릭하면 해당 콜스택에 관련된 정보를 아래와 같이 보여줍니다. 사실 최종적인 목적은 이 정보를 얻는거죠. ^^

어떤가요? 이 메모리들은 LeakTest.cpp의 10번째 줄 (0x14 Offset)에서 할당되었다는 것을 금방 알 수 있죠.
소스코드상의 line이 표시되지 않는다면 map파일과 cod 파일을 이용해서 확인하는 방법도 있습니다.

6) 메모리를 추적하는 부하를 경감시키는 방법
위에서 설명한 바와 같이 메모리를 추적하는 작업은 시스템에 부하를 줍니다. 만약 부하가 높은 실서버에서 이 작업을 해야 한다면 이 부하를 줄이기 위해 디버그심볼 없이 트레이싱 정보만 로깅한 후 SDecode를 이용해 다음과 같이 사후작업을 할 수도 있습니다.

(1) LeakDiag로 메모리를 추적하기 전에 옵션창을 열어 Resolve Symbols when logging, Use DbgHelp StackWalk API to walk stacks 항목의 옵션을 Unchecked로 바꿉니다.

(2) 위와 동일한 과정으로 추적을 시작하고 로그를 기록합니다. 이렇게 생성된 로그를 분석하면 아래와 같이 콜스택 정보에 디버그 심볼이 반영되지 않은 썰렁한 레포트가 됩니다. (아직 해당 Target Process를 중지하지 마세요!)

(3) LeakDiag 의 해당 Target Process에서 우측클릭하여 “Generate debug dump” 선택하여 타겟 프로세스의 덤프를 기록.

(4) 커맨드창을 열고, LeakDiag 설치폴더에서 각각의 로그파일에 대해 다음과 같이 SDecode.exe를 실행하면 원래의 Log123.xml 파일에 디버그정보가 포함된 NewLog.xml 파일이 생성됩니다.

sdecode /z c:memtest.dmp /y c:winnt;SRV*c:websymbols*http://msdl.microsoft.com/download/symbols Log123.xml NewLog.xml

(4) 새롭게 생성된 xml log파일을 다시한번 LDGrapher에서 분석시키면 아래와 같이 디버그정보가 포함된 콜스택 레포트를 생성할 수 있습니다.

※ UMDH와는 다르게… LeakDiag는 Memory Leak을 판단하는 기준이 “new된 주소에 대해서 delete가 호출되었는가”입니다. 무슨 얘기냐면… delete만 호출되었다면 실제로 메모리가 회수되었는지 상관 없이 메모리가 회수된 걸로 간주해버린다는 거죠. 만약 delete를 호출했음에도 불구하고 메모리가 회수되지 않았다면 LeakDiag에 잡히지 않는 메모리 릭이 발생할 수 있으니 주의해야 합니다. delete가 실패하는 경우가 있냐구요? 물론 있습니다!!!
– DLL 안에서 “new”한 메모리를 다른 DLL 혹은 EXE에서 “delete” 했을 경우 delete된것처럼 보이지만 실제로는 메모리가 해제되지 않을 수 있습니다. DLL에서 할당한 메모리는 반드시 그 DLL에서 해제해주어야 합니다.
– 클래스를 delete할 때 해당 클래스의 포인터가 아닌 LPVOID나 LPBYTE형으로 delete하는 경우 소멸자가 호출되지 않아 메모리 Leak이 발생할 수 있습니다.
– 클래스인 멤버(예를 들면 STL string같은)가 들어있는 구조체를 해당 구조체 포인터가 아닌 LPVOID나 LPBYTE형으로 delete 호출하는 경우, 해당 클래스의 소멸자가 호출되지 않아 메모리 Leak이 발생할 수 있습니다. 
– 클래스 Array를 delete할 때 “delete[]” 가 아닌 “delete”를 호출하는 경우, Array의 모든 Element의 소멸자가 호출되지 않고, 첫번째 Element의 소멸자만 호출됩니다. 따라서 메모리 Leak이 발생할 수 있습니다.
– 기타등등… 생각해보면 많아요~~~ ^^

※ 만약 Dll 안에서 할당된 메모리가 Dll이 FreeLibrary된 후에 LeakDiag에 의해 Logging되었을 경우, Logging 시점에서 Debug Symbol과 매칭되는 코드를 찾지 못해 할당 위치를 보여주지 못합니다. 이 경우 Dll이 로딩되어 있는 시점에서 로깅이 (한건이라도)이루어져야 합니다. 특정 시점에서 로깅하고 싶을 경우, 로깅하고자 하는 위치에 메시지박스 등을 넣어서 프로세스 실행을 홀딩시켜놓으면 도움이 됩니다.

댓글 남기기