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




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

[연관 글]


donaricano-btn



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

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

비밀번호

댓글 쓴 사람
 



2022-01-16 08시38분
[Dev] 글 잘 읽었습니다^^
한가지 궁금한 점이 있는데요, static 변수와 마찬가지로 static method 역시 메모리 누수의 위험이 있을까요?
현재 개발 환경은 .net framework 4.6입니다.
[손님]
2022-01-16 10시41분
static method는 메모리 누수와 상관이 없습니다. 문제는 static 변수인데요, 이것은 root 개체가 항상 살아 있는 것으로 간주하기 때문에 static 변수에 담긴 내용은 GC가 되지 않습니다. 따라서, 단순히 primitive 타입만 소유한 static 변수라면 메모리 누수에 영향이 없겠지만, List<LargeObject>와 같은 컨테이너 타입이 static 변수라면 그 안에 담긴 LargeObject 타입들도 전부 GC 대상이 아니기 때문에 그것이 누적이 된다면 메모리 누수로 이어질 수 있습니다.

즉, static 컨테이너 개체는 그 안에 담긴 내용이 항상 "사용이 되는 것"들만 포함하고 있는지 잘 관리를 해야 합니다.
정성태

[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...
NoWriterDateCnt.TitleFile(s)
12933정성태1/21/202221.NET Framework: 1136. C# - ffmpeg(FFmpeg.AutoGen)를 이용해 MP2 오디오 파일 디코딩 예제(decode_audio.c)파일 다운로드1
12932정성태1/20/202252.NET Framework: 1135. C# - ffmpeg(FFmpeg.AutoGen)로 하드웨어 가속기를 이용한 비디오 디코딩 예제(hw_decode.c) [2]파일 다운로드1
12931정성태1/20/202268개발 환경 구성: 632. ASP.NET Core 프로젝트를 AKS/k8s에 올리는 과정
12930정성태1/19/202232개발 환경 구성: 631. AKS/k8s의 Volume에 파일 복사하는 방법
12929정성태1/19/202253개발 환경 구성: 630. AKS/k8s의 Pod에 Volume 연결하는 방법
12928정성태1/18/202243개발 환경 구성: 629. AKS/Kubernetes에서 호스팅 중인 pod에 shell(/bin/bash)로 진입하는 방법
12927정성태1/18/202249개발 환경 구성: 628. AKS 환경에 응용 프로그램 배포 방법
12926정성태1/17/202221오류 유형: 787. AKS - pod 배포 시 ErrImagePull/ImagePullBackOff 오류
12925정성태1/17/202263개발 환경 구성: 627. AKS의 준비 단계 - ACR(Azure Container Registry)에 docker 이미지 배포
12924정성태1/15/2022125.NET Framework: 1134. C# - ffmpeg(FFmpeg.AutoGen)를 이용한 비디오 디코딩 예제(decode_video.c) [2]파일 다운로드1
12923정성태1/15/202265개발 환경 구성: 626. ffmpeg.exe를 사용해 비디오 파일을 MPEG1 포맷으로 변경하는 방법
12922정성태1/14/202269개발 환경 구성: 625. AKS - Azure Kubernetes Service 생성 및 SLO/SLA 변경 방법
12921정성태1/14/202254개발 환경 구성: 624. Docker Desktop에서 별도 서버에 설치한 docker registry에 이미지 올리는 방법
12920정성태1/14/202223오류 유형: 786. Camtasia - An error occurred with the camera: Failed to Add Video Sampler.
12919정성태1/13/202276Windows: 199. Host Network Service (HNS)에 의해서 점유되는 포트
12918정성태1/13/202271Linux: 47. WSL - shell script에서 설정한 환경 변수가 스크립트 실행 후 반영되지 않는 문제
12917정성태1/12/202241오류 유형: 785. C# - The type or namespace name '...' could not be found (are you missing a using directive or an assembly reference?)
12916정성태1/12/202226오류 유형: 784. TFS - One or more source control bindings for this solution are not valid and are listed below.
12915정성태1/11/202251오류 유형: 783. Visual Studio - We didn't find any interpreters
12914정성태1/11/2022154VS.NET IDE: 172. 비주얼 스튜디오 2022의 파이선 개발 환경 지원
12913정성태1/11/2022117.NET Framework: 1133. C# - byte * (바이트 포인터)를 FileStream으로 쓰는 방법
12912정성태1/11/2022110개발 환경 구성: 623. ffmpeg.exe를 사용해 비디오 파일의 이미지를 PGM(Portable Gray Map) 파일 포맷으로 출력하는 방법
12911정성태1/11/2022124VS.NET IDE: 171. 비주얼 스튜디오 - 더 이상 만들 수 없는 "ASP.NET Core 3.1 Web Application (.NET Framework)" 프로젝트
12910정성태1/10/2022130제니퍼 .NET: 30. 제니퍼 닷넷 적용 사례 (8) - CPU high와 DB 쿼리 성능에 문제가 함께 있는 사이트
12909정성태1/10/202257오류 유형: 782. Visual Studio 2022 설치 시 "Couldn't install Microsoft.VisualCpp.Redist.14.Latest"
[1]  2  3  4  5  6  7  8  9  10  11  12  13  14  15  ...