Microsoft MVP성태의 닷넷 이야기
글쓴 사람
정성태 (techsharer at outlook.com)
홈페이지
첨부 파일
 

C# - 닷넷 응용 프로그램에서 메모리 누수가 발생할 수 있는 패턴

닷넷 응용 프로그램이, GC를 내장한 CLR의 동작으로 인해 "모든 메모리"가 자동으로 회수된다는 믿음을 가지신 분들이 종종 있는데요, 물론 Native 시절만큼 new/delete를 확실하게 해야 하는 필요성은 많이 줄었지만 그래도 코딩 방식에 따라 - 너무나 당연하게 메모리 누수가 발생한다는 것을 주의해야 합니다. 간간이 이에 대한 설명을 중복적으로 하게 되는데, 게다가 한 번도 이에 관한 글을 쓴 적이 없어 이참에 한번 정리해 보려고 합니다.

마침 좋은 글도 있으니, ^^

8 Ways You can Cause Memory Leaks in .NET
; https://michaelscodingspot.com/ways-to-cause-memory-leaks-in-dotnet/

이번에는 저 글을 '내 맘대로' 번역해 정리해 보겠습니다.





1. 잘못된 이벤트 핸들러 관리

.NET Framework 응용 프로그램의 메모리 누수에 대한 사례 중 빠지지 않고 등장하는, 실제로 현업에서 은근히 실수를 많이 하게 되는 문제입니다. 엄밀히, 이 문제의 주요 원인은 C#에서의 delegate/event가 추상화를 너무 잘하기 때문입니다. 예를 들어 다음의 코드를 보면,

using System;

class Program
{
    static void Main(string[] args)
    {
        UILayout layout = new UILayout();

        while (true)
        {
            for (int i = 0; i < 1000; i++)
            {
                UIElement uiElem = new UIElement();
                layout.LayoutChanged += UIElement.s_Layout_LayoutChanged;
            }
        }
    }
}

public class UILayout
{
    public event EventHandler LayoutChanged;
}

public class UIElement
{
    public static void s_Layout_LayoutChanged(object sender, EventArgs e)
    {
    }
}

얼핏 LayoutChanged에 대한 이벤트 구독은 uiElem의 메서드와만 연결한 것이기 때문에 메모리 누수와는 무관할 듯해도, 실상은 (event의 근간이 되는) EventHandler delegate의 내부 동작 방식에서 메모리 누수로 연결이 됩니다. "public event EventHandler LayoutChanged"의 EventHandler는 System.MulticastDelegate를 상속받은 타입으로서, 이는 내부적으로 이벤트 구독의 대상 메서드를 목록으로 보관하기 때문에 결과적으로 봤을 때 "layout.LayoutChanged += uiElem.Layout_LayoutChanged" 코드는 의미적으로 다음과 같은 구현과 유사하다고 보면 됩니다.

layout.LayoutChanged.Add(uiElem.Layout_LayoutChanged);

public class UILayout
{
    public event List<EventHandler> LayoutChanged;
}

따라서, 저 구독을 해지하지 않으면 목록의 수는 늘어나고 결국 그만큼의 메모리 누수가 발생하는 것입니다. 그런데, 이 문제는 instance 유형의 메서드를 구독했을 때 더 심각해집니다.

using System;

class Program
{
    static void Main(string[] args)
    {
        UILayout layout = new UILayout();

        while (true)
        {
            for (int i = 0; i < 1000; i++)
            {
                UIElement uiElem = new UIElement();
                layout.LayoutChanged += uiElem.Layout_LayoutChanged;
            }
        }
    }
}

public class UILayout
{
    public event EventHandler LayoutChanged;
}

public class UIElement
{
    public void Layout_LayoutChanged(object sender, EventArgs e)
    {
    }
}

인스턴스 메서드인 경우, 의미상으로 보면 다음과 같이 인스턴스까지 함께 보관하는 식으로 동작하므로,

layout.LayoutChanged.Add(new EventHandler(uiElem, uiElem.Layout_LayoutChanged));

public class UILayout
{
    public event List<EventHandler> LayoutChanged;
}

GC는 이제 새로 생성된 UIElement가 블록 범위 밖으로 벗어났는데도 불구하고, 이벤트가 연결된 UILayout 인스턴스가 살아있는 한 그것을 해제하지 못하게 됩니다. 이런 모든 문제를 해결하는 간단한 방법은, 이벤트를 구독했으면 꼭 해제하는 코드도 넣으면 됩니다.

while (true)
{
    for (int i = 0; i < 1000; i++)
    {
        UIElement uiElem = new UIElement();
        layout.LayoutChanged += uiElem.Layout_LayoutChanged;
        layout.LayoutChanged -= uiElem.Layout_LayoutChanged;
    }
}





2. 익명 메서드 내에서의 캡처 변수 사용

원문의 예제를 보면, 익명 메서드를 Queue 등의 자료 구조를 이용해 보관하고 있으므로 어차피 그 Queue의 항목을 없애지 않으면 메모리 누수이기 때문에 캡처 변수가 꼭 메모리 누수라고 볼 수는 없습니다. (이런 면에서 봤을 때 event 구독 역시 "+=" 연산자를 이용한다는 측면에서 계속 누적된다는 의미를 지니므로 메모리 누수임을 짐작케 하는 면이 있습니다.)

하지만, 변수를 캡처하는 내부 동작에는 해당 변수를 소유한 인스턴스를 함께 보관하는 C# 컴파일러의 도움이 있다는 사실을 다시 한번 인지시킨다는 점에서 좋은 예제이니 읽어보실 것을 권장합니다.





3. 정적 변수의 사용

GC는 현재 참조가 유지되고 있는 객체들은 제거를 하지 못합니다. 다음의 그림을 보면,

gcroot_1.jpg

가장 하단에서의 참조로 인해 "Reachable Objects"들을 힙에서 제거할 수 없게 되는데, 이런 GC Root에는 다음과 같은 것들이 있습니다.

  1. 현재 실행 중인 스레드의 호출 스택
  2. 정적 변수
  3. COM Interop 시 전달된 관리 개체의 인스턴스, ...

여기서 문제는 개발자가 정의할 수 있는 "정적 변수"인데요, 이 정적 변수가 참조하는 모든 하위 객체들은 GC-ed 되지 못하므로 주의를 요합니다. 이것 역시 위의 "2번"과 같은 문제로 결국 개발자가 잘못 프로그램을 한 경우인데, 가령 다음과 같은 식의 코드를 작성한다면,

using System.Collections.Generic;

class Program
{
    static void Main(string[] args)
    {
        while (true)
        {
            for (int i = 0; i < 100; i++)
            {
                ConsoleHelper ch = new ConsoleHelper();
            }
        }
    }
}

public class ConsoleHelper
{
    static List _cmds = new List();

    public ConsoleHelper()
    {
        _cmds.Add(new ConsoleCommand());
    }
}

public class ConsoleCommand
{
}

static 변수에 보관된 _cmds의 인스턴스들은 GC가 절대 회수하지 못하므로 쉽게 메모리 누수가 발생할 수 있습니다. 개발자 입장에서 종종 실수하게 되는 부분인데, static 멤버 자체가 해당 클래스 내에 선언되므로 어느 순간 그것에 대한 관리를 소홀히 하게 될 여지로 인해 더욱 주의를 요합니다.





4. 잘못된 Cache 사용

원문을 정리하면, Cache 용도로 뭔가를 보관할 때, 1) 일정 시간 동안 사용하지 않으면 제거하고, 2) 캐시의 최대 용량을 설정하고, 3) WeakReference를 사용해 GC가 임의로 해제를 할 수 있게 만들라는 조언을 하고 있습니다.





5. 잘못된 WPF 바인딩 사용

오호... 재미있는 사실이군요. ^^ WPF 바인딩 대상이 INotifyPropertyChanged를 구현하지 않은 경우라면,

// xaml
<UserControl x:Class="WpfApp.MyControl"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <TextBlock Text="{Binding SomeText}"></TextBlock>
</UserControl>

// cs
public class MyViewModel
{
    public string _someText = "memory leak";
    public string SomeText
    {
        get { return _someText; }
        set { _someText = value; }
    }
}

WPF는 바인딩 소스에 대한 strong 참조를 유지하는 반면, 만약 INotifyPropertyChanged를 구현하고 있다면,

public class MyViewModel : INotifyPropertyChanged
{
    public string _someText = "not a memory leak";
 
    public string SomeText
    {
        get { return _someText; }
        set
        {
            _someText = value;
            PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof (SomeText)));
        }
    }

    // ...[생략]...
}

strong 참조를 하진 않는다고 합니다. 이러한 규칙은 Collection에 대한 INotifyCollectionChanged에 대해서도 동일하게 적용된다고 합니다. (암튼, ^^; WPF는 너무 복잡해서 알아둬야 할 규칙이 너무 많습니다.)





6. 종료하지 않는 Thread 사용

스레드가 종료하지 않으면, 적어도 해당 스레드의 콜 스택에 놓여진 참조들은 GC 대상이 될 수 없습니다. 원 글에서는 이에 대한 예제로, 스레드라고 자칫 인식하지 않을 수 있는 Timer를 예로 들고 있는데요,

public class MyClass
{
    public MyClass()
    {
        Timer timer = new Timer(HandleTick);
        timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
    }
 
    private void HandleTick(object state)
    {
        // do something
    }

    //...[생략]...
}

위에서 예를 든 Timer는 System.Threading.Timer로 전용 스레드가 생성되어 타이머 호출을 하는 경우입니다. 일단, 위와 같은 코드 상으로는 해당 스레드는 종료하지 않을 것이고, 여기서 "1. 잘못된 이벤트 핸들러 관리"였던 것과 겹쳐 HandleTick 인스턴스 핸들러로 인해 MyClass 인스턴스 자체가 GC가 불가능하게 됩니다.





7. 해제하지 못한 비관리 메모리

GC 구성 요소의 관리를 받지 못하는 비관리 메모리로부터 할당받은 메모리는 반드시 개발자가 직접 해제하는 코드를 작성해야 합니다. 예를 들어 아래와 같이 코드를 작성했다면,

public class SomeClass
{
    private IntPtr _buffer;
 
    public SomeClass()
    {
        _buffer = Marshal.AllocHGlobal(1000);
    }
 
    public void Dispose()
    {
        Marshal.FreeHGlobal(_buffer);
    } 
}

SomeClass를 사용하는 측에서는 반드시 Dispose 메서드까지 호출해야 합니다.





8. 필요한 경우 Finalizer 구현

Dispose 메서드의 호출은 해당 타입을 사용하는 개발자가 반드시 지켜줘야 하는 규칙이지만, 개발자들도 실수를 할 수 있기 때문에 이에 대한 대비도 해야 합니다. 이를 위해 Finalizer를 구현할 수 있는데요,

.NET IDisposable 처리 정리
; https://www.sysnet.pe.kr/2/0/347

그렇다고는 하지만, Finalizer의 잘못된 사용으로 인한 부작용도 있으므로 주의를 요합니다.




정리해 보면, 표면상으로는 "메모리 누수"라고는 해도 결국 "인스턴스"를 참조하고 있는 "또 다른 인스턴스"가 체인처럼 엮이면서 당연하게 발생하는 현상에 불과합니다. 이런 문제를 피하려면, 기본기를 충실히 익히고 자신이 사용하려는 환경에 대한 이해를 점점 넓혀가는 수밖에는 없을 듯합니다.

아울러, 시간 되시면 아래의 글도 한 번쯤 읽어보시고. ^^

WPF - WindowsFormsHost를 담은 윈도우 생성 시 메모리 누수
; https://www.sysnet.pe.kr/2/0/12340

windbg - 닷넷 응용 프로그램의 메모리 누수 분석
; https://www.sysnet.pe.kr/2/0/11808

윈도우 폼을 열고 닫는 것만으로 메모리 leak 이 발생할까?
; https://www.sysnet.pe.kr/2/0/1142

WPF의 Window 객체를 생성했는데 GC 수집 대상이 안 되는 이유
; https://www.sysnet.pe.kr/2/0/11310

C#에서 만든 COM 객체를 C/C++로 P/Invoke Interop 시 메모리 누수(Memory Leak) 발생
; https://www.sysnet.pe.kr/2/0/12162

ElementHost 컨트롤의 메모리 누수 현상
; https://www.sysnet.pe.kr/2/0/11027




[이 글에 대해서 여러분들과 의견을 공유하고 싶습니다. 틀리거나 미흡한 부분 또는 의문 사항이 있으시면 언제든 댓글 남겨주십시오.]





[최초 등록일: ]
[최종 수정일: 9/24/2020 ]

Creative Commons License
이 저작물은 크리에이티브 커먼즈 코리아 저작자표시-비영리-변경금지 2.0 대한민국 라이센스에 따라 이용하실 수 있습니다.
by SeongTae Jeong, mailto:techsharer@outlook.com

비밀번호

댓글 쓴 사람
 




[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12442정성태12/4/20209오류 유형: 691. Visual Studio - Build Events에 robocopy를 사용할때 "Invalid Parameter #1" 오류가 발행하는 경우
12441정성태12/4/202016오류 유형: 690. robocopy - ERROR : No Destination Directory Specified.
12440정성태12/4/202010오류 유형: 689. SignTool Error: Invalid option: /as
12439정성태12/4/202014디버깅 기술: 176. windbg - 특정 Win32 API에서 BP가 안 걸리는 경우 (2)
12438정성태12/2/202029오류 유형: 688. .Visual C++ - Error C2011 'sockaddr': 'struct' type redefinition
12437정성태12/1/202047VS.NET IDE: 155. pfx의 암호 키 파일을 Visual Studio 없이 등록하는 방법
12436정성태12/1/202019오류 유형: 687. .NET Core 2.2 빌드 - error MSB4018: The "RazorTagHelper" task failed unexpectedly.
12435정성태12/1/202060Windows: 181. 윈도우 환경에서 클라이언트 소켓의 최대 접속 수 (4) - ReuseUnicastPort를 이용한 포트 고갈 문제 해결파일 다운로드1
12434정성태12/2/202089Windows: 180. C# - dynamicport 값의 범위를 알아내는 방법
12433정성태12/1/202067Windows: 179. 윈도우 환경에서 클라이언트 소켓의 최대 접속 수 (3) - SO_PORT_SCALABILITY파일 다운로드1
12432정성태11/30/2020156Windows: 178. 윈도우 환경에서 클라이언트 소켓의 최대 접속 수 (2) - SO_REUSEADDR [1]파일 다운로드1
12431정성태11/27/202081.NET Framework: 976. UnmanagedCallersOnly + C# 9.0 함수 포인터 사용 시 x86 빌드에서 오동작하는 문제파일 다운로드1
12430정성태11/27/202030오류 유형: 686. Ubuntu - E: The repository 'cdrom://...' does not have a Release file.
12429정성태12/2/202081디버깅 기술: 175. windbg - 특정 Win32 API에서 BP가 안 걸리는 경우
12428정성태11/25/202046VS.NET IDE: 154. Visual Studio - .NET Core App 실행 시 dotnet.exe 실행 화면만 나오는 문제
12427정성태11/25/2020129.NET Framework: 975. .NET Core를 직접 호스팅해 (runtimeconfig.json 없이) EXE만 배포해 실행파일 다운로드1
12426정성태11/24/202039오류 유형: 685. WinDbg Preview - error InitTypeRead
12425정성태11/24/202030VC++: 141. Visual C++ - "Treat Warnings As Errors" 옵션이 꺼져 있는데도 일부 경고가 에러 처리되는 경우
12424정성태11/24/202074VC++: 140. C++의 연산자 동의어(operator synonyms), 대체 토큰
12423정성태11/22/2020131.NET Framework: 974. C# 9.0 - (16) 제약 조건이 없는 형식 매개변수 주석(Unconstrained type parameter annotations)파일 다운로드1
12422정성태11/21/202082.NET Framework: 973. .NET 5, .NET Framework에서만 허용하는 UnmanagedCallersOnly 사용예파일 다운로드1
12421정성태11/23/202085.NET Framework: 972. DNNE가 출력한 NE DLL을 직접 생성하는 방법파일 다운로드1
12420정성태11/19/202043오류 유형: 684. Visual C++ - MSIL .netmodule or module compiled with /GL found; restarting link with /LTCG; add /LTCG to the link command line to improve linker performance
12419정성태11/23/2020105VC++: 139. Visual C++ - .NET Core의 nethost.lib와 정적 링크파일 다운로드1
12418정성태11/19/202043오류 유형: 683. Visual C++ - error LNK2038: mismatch detected for 'RuntimeLibrary': value 'MT_StaticRelease' doesn't match value 'MDd_DynamicDebug'파일 다운로드1
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...