상세 컨텐츠

본문 제목

관리 코드에서 메모리 누수 확인 및 방지

.Net General

by 탑~! 2011. 10. 12. 12:50

본문


관리되는 코드에서 메모리 누수가 발생할 수 있다는 이야기를 처음 들으면 대부분의 개발자는 그런 일은 있을 수 없다는 반응을 보입니다. 무엇보다 GC(가비지 수집기)가 모든 메모리를 관리하기 때문에 메모리 누수가 발생할 리가 만무하다고 여겨질 겁니다. 그러나 가비지 수집기는 관리되는 메모리만 제어합니다. Microsoft® .NET Framework 기반 응용 프로그램의 경우 CLR(공용 언어 런타임) 자체에 의해서, 또는 관리되지 않는 코드와 상호 운용할 때 프로그래머에 의해서 명시적으로 여러 곳에 관리되지 않는 메모리가 사용됩니다. 뿐만 아니라 GC가 제대로 작동하지 않고 관리되는 메모리를 효율적으로 처리하지 못하는 경우도 있습니다. 이러한 문제는 프로그래밍 오류로 인해 GC가 작업을 제대로 수행할 수 없는 경우에 주로 발생합니다. 또한 문제의 원인이 되는 프로그래밍 오류는 쉽게 찾을 수 있는 경우도 있지만 대개는 파악하기가 어렵습니다. 어쨌든 메모리를 효율적으로 관리하기 위해서는 메모리 누수가 발생하지 않고 필요한 메모리를 효율적으로 사용하도록 응용 프로그램을 프로파일링해야 합니다.

.NET 응용 프로그램의 메모리
아시다시피 .NET 응용 프로그램에는 스택, 관리되지 않는 힙, 관리되는 힙 등 여러 가지 메모리가 사용됩니다. 먼저 이러한 메모리에 대해 간략하게 살펴보겠습니다.
스택 스택에는 응용 프로그램이 실행되는 동안 로컬 변수, 메서드 매개 변수, 반환 값 등의 임시 값이 저장됩니다. 또한 스택은 스레드별로 할당되어 스레드가 작업을 수행할 수 있는 작업 영역 역할을 합니다. 메서드 호출에 사용하도록 예약된 스택 공간은 메서드가 반환될 때 자동으로 정리되므로 GC는 스택을 정리하지 않습니다. 그러나 스택에 저장된 개체에 대한 참조는 GC에서 인식됩니다. 메서드의 개체가 인스턴스화되면 해당 참조(플랫폼에 따라 32비트 또는 64비트 정수)는 스택에 저장되지만 개체 자체는 관리되는 힙에 저장되고 변수가 범위를 벗어나면 가비지 수집기에 의해 수집됩니다.
관리되지 않는 힙 관리되지 않는 힙은 런타임 데이터 구조, 메서드 테이블, MSIL(Microsoft Intermediate Language), JIT 컴파일된 코드 등에 사용됩니다. 관리되지 않는 코드는 개체를 인스턴스화하는 방법에 따라 관리되지 않는 힙의 개체나 스택에 할당됩니다. 그런데 관리되지 않는 Win32® API를 호출하거나 COM 개체를 인스턴스화하면 관리되는 코드를 관리되지 않는 힙 메모리에 직접 할당할 수 있습니다. CLR 자체에서도 데이터 구조와 코드에 관리되지 않는 힙이 광범위하게 사용됩니다.
관리되는 힙 관리되는 힙은 관리되는 개체가 할당되고 가비지 수집기의 작업 대상이 되는 메모리입니다. CLR에는 세대적이고 압축적인 GC가 사용됩니다. GC는 가비지 수집 대상에서 제외된 개체를 생성 시기별로 관리한다는 점에서 세대적이라고 할 수 있으며, 이는 결과적으로 성능을 향상시킵니다. 모든 버전의 .NET Framework에는 Gen0, Gen1, Gen2(생성 시기의 역순)의 세 가지 세대가 사용됩니다. 또한 GC는 관리되는 힙에서 개체를 재배치하여 빈 공간을 제거하고 사용 가능한 메모리를 연속된 상태로 유지한다는 점에서 압축적입니다. 큰 개체를 이동하는 데에는 메모리가 많이 소모되므로 GC는 이러한 개체를 압축적이지 않은 별도의 대형 개체 힙에 할당합니다. 관리되는 힙과 GC에 대한 자세한 내용은 2부로 구성된 Jeffrey Richter의 칼럼 "Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework"(영문) 및 "Garbage Collection-Part 2: Automatic Memory Management in the Microsoft .NET Framework"(영문)를 참조하십시오. 이 두 가지 기사는 .NET Framework 1.0을 기반으로 쓰여졌습니다. 그러나 1.0 버전 이후에 1.1이나 2.0에서 기능이 향상되기는 했지만 핵심 개념은 변경되지 않았으므로 유용한 참고 자료가 될 수 있습니다.


 

누수 검사
응용 프로그램의 메모리 누수를 파악할 수 있는 여러 가지 징후가 있습니다. 예를 들어 OutOfMemoryException이 발생하거나 디스크의 가상 메모리로 스와핑되기 시작하여 응답이 매우 느려지거나 작업 관리자에서 메모리 사용량이 서서히 또는 갑자기 증가하는 등의 현상이 나타나면 메모리 누수를 의심해 보아야 합니다. 메모리 누수가 의심되면 먼저 누수되는 메모리 종류를 파악하여 해당 영역을 집중적으로 디버깅해야 합니다. 이때에는 PerfMon을 사용하여 Process/Private Bytes, NET CLR Memory/# Bytes in All Heaps, .NET CLR LocksAndThreads/# of current logical Threads 등의 응용 프로그램 성능 카운터를 조사합니다. Process/Private Bytes 카운터는 특정 프로세스에 단독으로 할당되어 시스템의 다른 프로세스와 공유할 수 없는 모든 메모리를 보고합니다. .NET CLR Memory/# Bytes in All Heaps 카운터는 Gen0, Gen1, Gen2 및 대형 개체 힙의 전체 크기를 보고합니다. 마지막으로 .NET CLR LocksAndThreads/# of current logical Threads 카운터는 특정 AppDomain의 논리 스레드 수를 보고합니다. 스레드 스택에서 누수가 발생한 경우 응용 프로그램의 논리 스레드 수가 갑자기 증가합니다. Private Bytes가 증가했지만 # Bytes in All Heaps는 안정적인 상태를 유지하는 경우에는 관리되지 않는 메모리에서 누수가 발생한 것입니다. 또한 관리되는 힙을 구성하는 동안에는 두 카운터가 모두 증가합니다.


 

스택 메모리 누수
스택 공간이 부족하여 관리되는 영역에서 StackOverflowException이 발생할 수도 있지만, 메서드를 호출하는 동안 사용된 스택 공간은 해당 메서드가 반환된 후에 회수되므로 실질적인 스택 공간 누수 발생 원인은 두 가지 뿐입니다. 첫째는 스택 리소스를 많이 소비하면서 반환되지 않아 연결된 스택 프레임을 해제하지 않는 메서드 호출이 있는 경우입니다. 그리고 두 번째는 스레드 누수로 인해 해당 스레드의 전체 스택이 누수되는 경우입니다. 응용 프로그램에서 백그라운드 작업을 수행하는 작업자 스레드를 만든 후 해당 스레드를 정상적으로 종료하지 않으면 스레드 스택이 누수될 수 있습니다. 최신 데스크톱 및 서버 버전 Windows®의 기본 스택 크기는 1MB입니다. 따라서 .NET CLR LocksAndThreads/# of current logical Threads가 증가함에 따라 응용 프로그램의 Process/Private Bytes가 주기적으로 갑자기 1MB씩 증가했다면 스레드 스택 누수일 가능성이 높습니다. 그림 1에는 멀티스레드 논리를 사용하여 고의로 발생시킨 비정상적 스레드 정리 동작의 예가 나와 있습니다.
using System;
using System.Threading;

namespace MsdnMag.ThreadForker {
  class Program {
    static void Main() {
      while(true) {
        Console.WriteLine(
          "Press <ENTER> to fork another thread...");
        Console.ReadLine();
        Thread t = new Thread(new ThreadStart(ThreadProc));
        t.Start();
      }
    }

    static void ThreadProc() {
      Console.WriteLine("Thread #{0} started...", 
        Thread.CurrentThread.ManagedThreadId);
      // Block until current thread terminates - i.e. wait forever
      Thread.CurrentThread.Join();
    }
  }
}


스레드 ID를 표시하고 자신에게 조인하는 스레드가 실행됩니다. 조인된 호출 스레드는 다른 스레드가 종료될 때까지 대기하기 때문에 이 경우 자신이 종료될 때까지 계속 기다리는 상황이 발생합니다. 작업 관리자를 통해 이 프로그램에서 <Enter> 키를 누를 때마다 메모리 사용량이 스레드 크기인 1MB씩 증가하는 과정을 살펴보십시오.
Thread 개체에 대한 참조는 루프를 실행할 때마다 삭제되지만 GC가 스레드 스택에 할당된 메모리를 회수하지 않습니다. 관리되는 스레드의 수명은 해당 스레드를 만드는 Thread 개체에 대해 독립적입니다. Thread 개체 연결된 참조가 모두 손실되었다고 해서 GC가 작업을 실행 중인 스레드를 종료해서는 안 되므로 이는 바람직한 동작이라고 할 수 있습니다. 즉, GC는 Thread 개체를 수집하지만 실제 관리되는 스레드는 수집하지 않습니다. 관리되는 스레드는 ThreadProc가 반환되거나 명시적으로 종료될 때까지는 생성되지 않으며 해당 스레드 스택에 할당된 메모리도 해제되지 않습니다. 따라서 관리되는 스레드가 정상적으로 종료되지 않으면 해당 스레드 스택에 할당된 메모리가 누수됩니다.


 

관리되지 않는 힙 메모리 누수
전체 메모리 사용량은 증가하고 논리 스레드 수와 관리되는 힙 메모리는 증가하지 않는 경우에는 관리되지 않는 힙에서 누수가 발생한 것입니다. 다음에서는 관리되지 않는 코드 이러한 관리되지 않는 코드와의 상호 운용, 중단된 종료자, 어셈블리 누수 등, 힙 누수의 일반적인 원인을 살펴보겠습니다.
관리되지 않는 코드와의 상호 운용 - P/Invoke를 통해 C 스타일 DLL을 사용하거나 COM interop을 통해 COM 개체를 사용하는 등, 관리되지 않는 코드와 상호 운용하는 경우에도 메모리 누수가 발생할 수 있습니다. GC는 관리되지 않는 메모리를 인식하지 않기 때문에 이러한 누수는 관리되지 않는 메모리를 사용하는 관리되는 코드의 프로그래밍 오류로 인해 발생합니다. 따라서 응용 프로그램이 관리되지 않는 코드와 상호 운용되는 경우에는 코드를 단계별로 검사하고 관리되지 않는 호출 전후의 메모리 사용량을 조사하여 메모리가 정상적으로 회수되는지 확인하고 메모리가 정상적으로 회수되지 않으면 일반적인 디버깅 기법을 사용하여 관리되지 않는 구성 요소에서 누수 원인을 찾아야 합니다.
중단된 종료자 - 개체에 의해 할당된 관리되지 않는 메모리를 정리하는 개체의 종료자가 호출되지 않으면 파악하기 어려운 메모리 누수가 발생합니다. 정상적인 상태에서는 종료자가 호출되지만 CLR에서 종결자가 반드시 호출된다는 보장은 없습니다. 앞으로 릴리스되는 버전에서는 기능이 변경될 수도 있지만 어쨌든 현재 CLR 버전에서는 종료자 스레드가 하나만 사용됩니다. 오프라인 상태인 데이터베이스에 정보를 로깅하려 하는 잘못된 종료자를 예로 들어 보겠습니다. 해당 종료자가 계속해서 데이터베이스 액세스를 시도하면서 반환되지 않으면 정상적으로 작동하는 종료자가 실행되지 않습니다. 이러한 문제는 종료 큐에 대기 중인 종료자의 순서와 다른 종료자의 동작에 영향을 받기 때문에 매우 산발적으로 발생할 수 있습니다.
AppDomain이 종료되면 CLR은 모든 종료자를 실행하여 종료자 큐를 지웁니다. 이때 큐에 지연된 종료자가 있으면 CLR이 AppDomain을 종료하지 못할 수 있습니다. 때문에 CLR은 종료 프로세스를 중지하기까지 소요되는 시간을 제한하는 프로세스 제한 시간을 구현합니다. 대부분의 응용 프로그램에는 AppDomain이 하나만 사용되고 프로세스가 종료되어야 AppDomain이 종료되므로 이는 문제가 되지 않습니다. OS 프로세스가 종료되면 운영 체제에서 리소스를 회수합니다. 그러나 ASP.NET 또는 SQL Server™와 같은 호스팅 환경에서는 호스팅 프로세스가 종료되지 않더라도 AppDomain이 종료될 수 있으며, 이 경우 같은 프로세스에서 다른 AppDomain이 실행될 수 있습니다. 또한 종료자가 실행되지 않은 구성 요소로 인해 누수가 발생한 관리되지 않는 메모리는 계속 참조되지 않고 사용할 수 없는 상태로 공간을 차지합니다. 이는 시간이 지남에 따라 점점 더 많은 메모리가 누수되는 더 큰 문제로 발전할 수 있습니다.
.NET 1.x의 경우 프로세스를 종료하고 다시 시작하는 것이 유일한 문제 해결 방법이었습니다. .NET Framework 2.0에는 관리되지 않는 리소스를 정리하고 AppDomain를 종료하는 동안 반드시 실행되어야 하는 중요 종료자가 새로 추가되었습니다. 자세한 내용은 Stephen Toub의 기사, "Keep Your Code Running with the Reliability Features of the .NET Framework"(영문)를 참조하십시오.
어셈블리 누수 - 어셈블리 누수는 비교적 자주 발생하는 문제로, 로드된 어셈블리는 AppDomain이 언로드되기 전까지 언로드할 수 없기 때문에 발생합니다. 대부분의 경우 어셈블리가 동적으로 생성되어 로드되지 않는 이상 이는 문제가 되지 않습니다. 다음으로 동적 코드 생성 누수, 특히 XmlSerializer 누수에 대해 자세히 알아보겠습니다.
동적 코드 생성 누수 - 코드를 동적으로 생성해야 하는 경우가 있습니다. 구체적으로는 Microsoft Office와 같은 확장성을 제공하기 위해 응용 프로그램에 매크로 스크립트 작성 인터페이스가 포함된 경우, 채권 시가 평가 엔진에서 시가 평가 규칙을 동적으로 로드하여 최종 사용자가 고유한 채권 유형을 만들 수 있도록 해야 하는 경우, 응용 프로그램이 Python용 동적 언어 런타임/컴파일러인 경우 등이 여기에 해당합니다. 대부분의 경우 매크로, 시가 평가 규칙 또는 코드를 MSIL로 컴파일하는 것이 성능 측면에서 바람직합니다. System.CodeDom을 사용하면 즉석에서 MSIL을 생성할 수 있습니다.
그림 2에는 메모리에 어셈블리를 동적으로 생성하는 코드가 나와 있습니다. 이 코드는 반복 호출하더라도 문제가 없습니다. 매크로, 시가 평가 규칙 또는 코드가 변경되면 동적 어셈블리를 다시 생성해야 합니다. 어셈블리를 다시 생성하면 이전 어셈블리가 더 이상 사용되지 않지만 메모리에서 제거할 방법이 없으므로 어셈블리가 로드된 AppDomain이 언로드되지 않은 채로 남게 됩니다. 따라서 코드, JIT 컴파일된 메서드 및 기타 런타임 데이터 구조에 사용된 관리되지 않는 힙 메모리가 누수됩니다. 또한 동적으로 생성된 클래스의 정적 필드 형식에서는 관리되는 메모리도 누수됩니다. 아쉽게도 이러한 문제는 감지할 방법이 없습니다. 때문에 System.CodeDom을 사용하여 MSIL을 동적으로 생성할 때에는 코드가 다시 생성되는지 확인해야 합니다. 관리되지 않는 힙 메모리 누수가 발생한 경우 코드가 다시 생성됩니다.
CodeCompileUnit program = new CodeCompileUnit();
CodeNamespace ns = new 
  CodeNamespace("MsdnMag.MemoryLeaks.CodeGen.CodeDomGenerated");
ns.Imports.Add(new CodeNamespaceImport("System"));
program.Namespaces.Add(ns);

CodeTypeDeclaration class1 = new CodeTypeDeclaration("CodeDomHello");
ns.Types.Add(class1);
CodeEntryPointMethod start = new CodeEntryPointMethod();
start.ReturnType = new CodeTypeReference(typeof(void));
CodeMethodInvokeExpression cs1 = new CodeMethodInvokeExpression(
  new CodeTypeReferenceExpression("System.Console"), "WriteLine", 
    new CodePrimitiveExpression("Hello, World!"));
start.Statements.Add(cs1);
class1.Members.Add(start);

CSharpCodeProvider provider = new CSharpCodeProvider();
CompilerResults results = provider.CompileAssemblyFromDom(
  new CompilerParameters(), program);

이 문제는 두 가지 방법으로 해결할 수 있습니다. 첫 번째로 동적으로 생성된 MSIL을 하위 AppDomain에 로드하는 방법이 있습니다. 하위 AppDomain은 생성된 코드가 변경되었을 때 언로드할 수 있으며, 이 경우 업데이트된 MSIL을 호스트할 새 하위 AppDomain이 실행됩니다. 이 방법은 모든 .NET Framework 버전에서 사용할 수 있습니다.
.NET Framework 2.0에 새롭게 소개되는 두 번째 방법은 동적 메서드라고도 하는 간단한 코드를 생성하는 방법입니다. DynamicMethod를 사용하면 메서드 본문을 정의하는 MSIL 연산 코드가 명시적으로 내보내지고 DynamicMethod가 DynamicMethod.Invoke를 통해 직접 호출되거나 적절한 위임을 통해 호출됩니다.
DynamicMethod dm = new DynamicMethod("tempMethod" + 
  Guid.NewGuid().ToString(), null, null, this.GetType());
ILGenerator il = dm.GetILGenerator();

il.Emit(OpCodes.Ldstr, "Hello, World!");
MethodInfo cw = typeof(Console).GetMethod("WriteLine", 
  new Type[] { typeof(string) });
il.Emit(OpCodes.Call, cw);

dm.Invoke(null, null);
동적 메서드의 가장 큰 이점은 MSIL과 모든 관련 코드 생성 데이터 구조가 관리되는 힙에 할당된다는 점입니다. 즉, DynamicMethod에 대한 마지막 참조가 범위를 벗어나면 GC가 메모리를 회수할 수 있습니다.
XmlSerializer 누수 - XmlSerializer와 같은 .NET Framework의 특정 부분에는 동적 코드 생성 기능이 내부적으로 사용됩니다. 다음과 같은 일반적인 XmlSerializer 코드를 예로 들어 보겠습니다.
XmlSerializer serializer = new XmlSerializer(typeof(Person));
serializer.Serialize(outputStream, person);
XmlSerializer 생성자는 리플렉션 기능을 사용하여 Person 클래스를 분석함으로써 XmlSerializationReader와 XmlSerializationWriter에서 파생된 한 쌍의 클래스를 생성합니다. 또한 임시 C# 파일을 만들어 임시 어셈블리에 컴파일하고 마지막으로 해당 어셈블리를 프로세스에 로드합니다. 이러한 코드 생성 과정에도 비교적 메모리가 많이 소요됩니다. 때문에 XmlSerializer는 임시 어셈블리를 유형별로 캐시합니다. 즉, 다음에 Person 클래스에 대한 XmlSerializer가 만들어지면 어셈블리를 새로 생성하는 대신 캐시된 어셈블리를 사용합니다.
XmlSerializer는 클래스 이름을 기본 XmlElement 이름으로 사용합니다. 따라서 Person이 다음과 같이 serialize됩니다.
<?xml version="1.0" encoding="utf-8"?>
<Person xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" 
  xmlns:xsd="http://www.w3.org/2001/XMLSchema">
 <Id>5d49c002-089d-4445-ac4a-acb8519e62c9</Id>
 <FirstName>John</FirstName>
 <LastName>Doe</LastName>
</Person>
클래스 이름은 그대로 두고 기존 스키마와의 호환성을 제공하는 데 필요한 루트 이름 요소만 변경해야 하는 경우도 있습니다. 때문에 Person을 <PersonInstance>로 serialize해야 할 수 있습니다. 편리하게도 다음과 같이 루트 요소 이름을 두 번째 매개 변수로 사용하는 XmlSerializer 생성자의 오버로드가 있습니다.
XmlSerializer serializer = new XmlSerializer(typeof(Person), 
  new XmlRootAttribute("PersonInstance"));
응용 프로그램에서 Person 개체를 serialize/deserialize하기 시작하면 OutOfMemoryException이 발생할 때까지 프로세스가 정상적으로 실행됩니다. 이 XmlSerializer 생성자 오버로드는 동적으로 생성된 어셈블리를 캐시하지 않고, 새 XmlSerializer를 인스턴스화할 때마다 임시 어셈블리를 새로 생성합니다. 이렇게 임시 어셈블리 형태로 구현된 응용 프로그램에서는 관리되지 않는 메모리가 누수됩니다.
이러한 누수 문제는 클래스에 XmlRootAttribute을 사용하여 serialize된 형식의 루트 요소 이름을 변경하는 방법으로 해결할 수 있습니다.
[XmlRoot("PersonInstance")]
public class Person {
  // code
}
이 특성이 형식에 직접 적용되면 XmlSerializer가 해당 형식에 대해 생성된 어셈블리를 캐시하므로 누수 현상이 발생하지 않습니다. 루트 요소 이름을 동적으로 전환해야 하는 경우에는 응용 프로그램에서 XmlSerializer 인스턴스를 검색하는 팩토리를 사용하여 자체적으로 XmlSerializer 인스턴스 캐시 작업을 수행할 수 있습니다.
XmlSerializer serializer = XmlSerializerFactory.Create(
  typeof(Person), "PersonInstance");
필자가 만든 XmlSerializerFactory는 Dictionary<TKey, TValue>에 PersonInstance 루트 요소 이름을 사용하는 Person에 대한 XmlSerializer가 있는지 확인하는 클래스입니다. 확인 결과 XmlSerializer가 포함되어 있으면 인스턴스가 반환되고, 없으면 XmlSerializer를 새로 만들어 해시 테이블에 저장하고 호출자에게 반환합니다.


 

관리되는 힙 메모리 누수
다음으로 관리되는 메모리 누수에 대해 살펴보겠습니다. 관리되는 메모리의 경우 대부분의 프로세스가 GC에서 자동으로 처리됩니다. 따라서 GC가 작업을 수행하는 데 필요한 정보만 제공해 주면 됩니다. 그러나 GC가 작업을 효율적으로 수행하지 못해 관리되는 메모리가 필요한 크기 이상 사용되는 경우도 많습니다. 이러한 문제의 원인은 대형 개체 힙 조각화, 불필요한 루트 참조, 중간 세대 위기 등이 있습니다.
대형 개체 힙 조각화 크기가 85,000바이트 이상인 개체는 대형 개체 힙에 할당됩니다. 이 크기는 개체 자체의 크기이며 하위 개체는 포함되지 않습니다. 다음 클래스를 예로 들어 보겠습니다.
public class Foo {
  private byte[] m_buffer = new byte[90000]; // large object heap
}
Foo 인스턴스에는 버퍼에 대한 4바이트(32비트 Framework) 또는 8바이트(64비트 Framework)의 참조와 .NET Framework에서 사용되는 기타 정리 데이터만 포함되어 있으므로 일반적인 세대별 관리되는 힙에 할당됩니다. 그리고 버퍼는 대형 개체 힙에 할당됩니다.
대형 개체를 이동하는 데 리소스가 많이 소모되기 때문에 대형 개체 힙은 나머지 관리되는 힙과는 달리 압축되지 않습니다. 따라서 대형 개체가 할당되고 비워지고 정리됨에 따라 빈 공간이 발생하게 됩니다. 사용 패턴에 따라 대형 개체 힙의 빈 공간으로 인해 메모리가 현재 할당된 대형 개체에 필요한 양보다 훨씬 많이 사용될 수 있습니다. 이번 호의 다운로드 파일에 포함된 LOHFragmentation 응용 프로그램은 대형 개체 힙에 바이트 배열을 할당하고 해제함으로써 이러한 문제점을 보여 줍니다. 경우에 따라 응용 프로그램을 실행하면 이전에 바이트 배열이 비워지면서 남은 빈 공간에 새로 만들어진 바이트 배열이 할당되기도 합니다. 하지만 응용 프로그램 실행 시에 이러한 방식으로 바이트 배열이 할당되지 않아 현재 할당된 바이트 배열에 필요한 크기보다 훨씬 큰 메모리가 사용되는 경우도 많습니다. CLRProfiler와 같은 메모리 프로파일러를 사용하면 이러한 대형 개체 힙의 조각화 상태를 시각화할 수 있습니다. 그림 3에서 빨간색 영역은 할당된 바이트 배열을 나타내고 흰색 영역은 할당되지 않은 공간을 나타냅니다.
그림 3 CLRProfiler에 표시된 대형 개체 힙 (더 크게 보려면 이미지를 클릭하십시오.)
대형 개체 힙 조각화를 방지할 수 있는 해결 방법은 없습니다. 때문에 CLRProfiler와 같은 도구를 사용하여 응용 프로그램의 메모리 사용 형태를 조사하고, 특히 대형 개체 힙에 할당된 개체의 형식을 파악하여 문제가 있는지 확인해야 합니다. 그리고 버퍼가 다시 할당되면서 조각화가 발생한 경우 재사용되는 고정 버퍼 집합을 유지합니다. 많은 수의 문자열이 연결됨에 따라 조각화가 발생한 경우에는 System.Text.StringBuilder 클래스로 생성된 임시 문자열의 수를 줄일 수 있는지 검토합니다. 기본적인 문제 해결 전략은 응용 프로그램에서 사용되는 임시 대형 개체를 줄여 대형 개체 힙에 사용되지 않는 빈 공간이 발생하지 않도록 하는 것입니다.
불필요한 루트 참조 GC에서 메모리 회수 가능성을 결정하는 방법을 살펴보겠습니다. CLR에서 메모리를 할당할 때 사용할 메모리가 부족하면 가비지 수집 프로세스가 수행됩니다. 이 경우 GC는 정적 필드와 범위 내의 로컬 변수를 비롯하여 모든 스레드의 호출 스택에 있는 모든 루트 참조를 열거합니다. 그리고 이러한 참조를 연결 가능한 개체로 표시하고 해당 개체에 포함된 모든 참조를 추적하여 역시 연결 가능한 개체로 표시합니다. 이 프로세스는 연결 가능한 모든 참조를 추적할 때까지 계속 진행됩니다. 표시되지 않은 개체는 연결할 수 없으므로 가비지로 간주됩니다. 그런 다음 GC가 관리되는 힙을 압축하고 힙에서 새로운 위치를 가리키도록 참조를 정리하고 CLR에 제어 권한을 반환합니다. 메모리가 충분히 확보되었으면 확보된 메모리를 사용하여 할당 프로세스가 진행됩니다. 메모리가 충분히 확보되지 않은 경우에는 운영 체제에 메모리를 요청합니다.
루트 참조를 무효화하지 않으면 GC가 가능한 빨리 효율적으로 메모리를 회수할 수 없기 때문에 응용 프로그램이 메모리를 많이 차지하게 됩니다. 메서드가 데이터베이스 쿼리 또는 웹 서비스 호출과 같은 원격 호출을 실행하기 전에 대형 임시 개체 그래프를 생성하는 등의 경우에는 이러한 문제를 파악하기가 어려울 수 있습니다. 원격 호출을 실행하는 동안 가비지 수집 프로세스가 실행되면 전체 그래프가 연결 가능한 것으로 표시되어 수집되지 않습니다. 이 경우 수집되지 않은 개체가 다음 세대로 승격되기 때문에 중간 세대 위기가 발생하여 메모리 누수가 심화될 수 있습니다.
중간 세대 위기 여기서 중간 세대 위기는 중년의 위기를 말하는 것이 아니라 관리되는 힙 메모리의 과도한 사용을 초래하고 GC에서 막대한 프로세서 시간이 소요되도록 하는 문제를 가리킵니다. 앞서 설명했듯이 GC는 일정 기간 동안 정리되지 않은 개체는 이후에도 일정 기간 더 사용된다는 추론을 바탕으로 하는 세대별 알고리즘을 사용합니다. 예를 들어 Windows Forms 응용 프로그램의 경우 응용 프로그램이 시작될 때 기본 폼이 만들어지고 기본 폼을 닫으면 응용 프로그램이 종료됩니다. 따라서 GC에서 기본 폼이 참조되는지 여부를 지속적으로 확인할 필요가 없습니다. 할당 요청을 처리하기 위해 메모리가 필요한 경우 시스템에서는 먼저 Gen0 수집 프로세스가 실행됩니다. 그리고 메모리를 충분히 확보할 수 없으면 Gen1 수집 프로세스가 실행됩니다. 여전히 할당 요청을 처리하는 데 필요한 메모리가 확보되지 않으면 관리되는 힙 전체를 정리하는 Gen2 수집 프로세스가 실행되고, 그에 따라 비용이 많이 들게 됩니다. Gen0 수집 프로세스는 최근에 할당된 개체만 수집 대상으로 간주되므로 상대적으로 비용이 적게 듭니다.
중간 세대 위기는 개체가 Gen1 또는 Gen2 수집 프로세스가 실행되는 시점까지 정리되지 않다가 곧바로 제거되는 경우에 발생합니다. 따라서 비용이 적게 드는 Gen0 수집 프로세스가 비용이 많이 드는 Gen1(또는 Gen2) 수집 프로세스로 바뀌는 결과를 초래합니다. 이러한 문제의 원인은 무엇일까요? 다음 코드를 통해 살펴보겠습니다.
class Foo {
  ~Foo() { }
}
이 개체는 항상 Gen 1 수집 프로세스에서 회수됩니다. 종료자 ~Foo()는 AppDomain이 잘못 중단되는 경우를 제외하고는 항상 개체의 메모리가 회수되기 전에 실행되는 정리 코드를 구현할 수 있도록 합니다. 여기서 GC는 관리되는 메모리를 최대한 신속하게 많이 확보하는 역할을 합니다. 종료자는 사용자가 작성한 코드로, 수행할 수 있는 작업에 제한이 없습니다. 바람직하지는 않지만 원한다면 데이터베이스에 로깅하거나 Thread.Sleep(int.MaxValue)를 호출하는 등의 작업을 수행하도록 종료자 코드를 작성할 수도 있습니다. GC가 종료자가 있고 참조되지 않는 개체를 발견하면 해당 개체를 종료 큐에 추가하고 수집 프로세스를 계속 진행합니다. 이러한 개체는 가비지 수집 프로세스를 통해 수집되지 않으므로 한 세대 승격됩니다. 이렇게 승격되는 개체 수를 측정하는 성능 카운터도 있습니다. .NET CLR Memory-Finalization Survivors는 마지막 가비지 수집 프로세스 동안 종료자로 인해 수집되지 않는 개체 수를 나타내는 카운터입니다. 결국 종료자 스레드에 의해 개체의 종료자가 실행된 후에 해당 개체가 수집될 수도 있습니다. 그러나 종료자를 추가한 것이 원인이 되어 Gen0 수집 프로세스가 불필요하게 Gen1 수집 프로세스로 전환되는 결과를 초래했습니다.
대부분의 경우 관리되는 코드를 작성할 때에는 종료자를 사용할 필요가 없습니다. 종료자는 관리되는 개체에 정리해야 할 관리되지 않는 리소스에 대한 참조가 포함되어 있는 경우에만 필요합니다. 그리고 이러한 경우에도 종료자를 구현하는 대신 SafeHandle 파생 형식을 사용하여 관리되지 않는 리소스를 래핑해야 합니다. 또한 관리되지 않는 리소스를 사용하거나 IDisposable을 구현하는 기타 관리되는 형식을 사용할 때에는 개체 사용자가 리소스를 능동적으로 정리하고 관련 종료를 방지할 수 있도록 Dispose 패턴을 구현해야 합니다.
개체에 다른 관리되는 개체에 대한 참조만 포함되어 있으면 GC는 참조되지 않는 개체를 정리합니다. 이 프로세스는 하위 개체의 delete 메서드를 호출해야 하는 C++에 비해 분명하게 이루어집니다. 종료자가 비어 있거나 단순히 참조를 하위 개체로 무효화하는 경우에는 해당 개체를 제거합니다. 이러한 종료자는 개체를 불필요하게 이전 세대로 승격하여 정리하는 데 리소스가 더 많이 사용하도록 하기 때문에 성능 저하의 원인이 됩니다.
데이터베이스 쿼리, 다른 스레드 차단 또는 웹 서비스 호출과 같은 차단 호출을 실행하기 전에 개체를 유지하는 등의 방법으로 중간 세대 위기를 시뮬레이트할 수도 있습니다. 이러한 호출을 실행하는 동안에는 수집 프로세스가 하나 이상 발생하여 비용이 적게 드는 Gen0 개체가 다음 세대로 승격됨에 따라 메모리 사용량 및 수집 비용이 크게 증가할 수 있습니다.
이벤트 처리기 및 콜백에서 파악하기 힘든 문제가 발생하는 경우도 있습니다. 여기서는 ASP.NET을 예로 들지만 다른 응용 프로그램에서도 같은 문제가 발생할 수 있습니다. 비용이 많이 드는 쿼리를 실행하여 결과를 5분 동안 표시하려 한다고 가정해 봅니다. 이때 쿼리는 쿼리 문자열 매개 변수에 따라 특정 페이지에 대해 실행됩니다. 캐시 동작을 모니터링하기 위해 이벤트 처리기는 캐시에서 항목이 제거되는 시점을 기록합니다(그림 4 참조).
protected void Page_Load(object sender, EventArgs e) {
  string cacheKey = buildCacheKey(Request.Url, Request.QueryString);
  object cachedObject = Cache.Get(cacheKey);
  if(cachedObject == null) {
    cachedObject = someExpensiveQuery();
    Cache.Add(cacheKey, cachedObject, null, 
      Cache.NoAbsoluteExpiration,
      TimeSpan.FromMinutes(5), CacheItemPriority.Default, 
      new CacheItemRemovedCallback(OnCacheItemRemoved));
  }
  ... // Continue with normal page processing
}

private void OnCacheItemRemoved(string key, object value,
                CacheItemRemovedReason reason) {
  ... // Do some logging here
}


이 코드는 문제가 없어 보이지만 중대한 문제를 가지고 있습니다. 이러한 ASP.NET 페이지 인스턴스는 모두 장기간 수집되지 않는 개체가 됩니다. OnCacheItemRemoved는 인스턴스 메서드이며 CacheItemRemovedCallback 위임에는 암시적 this 포인터가 포함되어 있습니다. 여기서 this는 Page 인스턴스입니다. 이 위임은 Cache 개체에 추가됩니다. 따라서 Cache 개체에서 위임으로, 그리고 Page 인스턴스로 종속 관계가 만들어지게 됩니다. 가비지 수집 프로세스가 실행될 때 Page 인스턴스는 루트 참조(Cache 개체)에서 연결할 수 있는 상태로 남게 됩니다. 그러면 Page 인스턴스와 렌더링하는 동안 생성된 모든 임시 개체는 최소 5분이 경과한 후에 수집되고, 그 동안 Gen2로 승격될 가능성이 큽니다. 다행히 이 문제는 콜백 함수를 정적으로 만들면 간단히 해결됩니다. 그러면 Page 인스턴스의 종속 관계가 제거되어 비용을 많이 들이지 않고 Gen0 개체로 수집할 수 있게 됩니다.


 

결론
지금까지 메모리 누수 또는 과도한 메모리 사용을 야기할 수 있는 다양한 .NET 응용 프로그램 문제를 살펴보았습니다. .NET 환경에서는 메모리에 대해 크게 신경 쓰지 않아도 되지만 응용 프로그램의 메모리 사용량에 주의를 기울여 원활하고 효율적으로 작동하도록 해야 합니다. 응용 프로그램이 관리된다고 해서 소프트웨어 엔지니어링 방법에 신경을 쓰지 않고 오로지 GC의 기능에 의존해서는 안 됩니다. 개발 및 테스트 프로세스를 진행하는 동안 응용 프로그램의 메모리 성능 카운터를 지속적으로 모니터링하는 것은 번거로울 수 있지만 중요한 일입니다. 고객이 만족할 수 있는 응용 프로그램을 개발하려면 이러한 과정이 꼭 필요하기 때문입니다.

[출처] http://msdn.microsoft.com/ko-kr/magazine/cc163491.aspx

관련글 더보기