본문 바로가기
프로그램 (PHP,Python)

효과적인 C# 메모리 관리 기법

by 날으는물고기 2013. 12. 5.

효과적인 C# 메모리 관리 기법

유니티 3D엔진은 다른 엔진들과 달리 콘텐츠 개발을 위한 언어로 C#을 지원한다. C#은 편하고 강력한 언어지만 메모리 관리에 어려움을 겪을 수 있다. 필자가 개발 중인 게임도 C#과 유니티 3D엔진 특성으로 인해 개발 초기에 어려움을 겪었지만 최근 오픈을 앞두고 상당한 메모리 안정화를 이룬 상태다. 이번 시간에는 필자가 NDC 2012에서 강연한 자료를 바탕으로 C#에서 효과적으로 메모리를 관리하는 방법에 대해 알아보자.


조명근 narlamy@ndoors.net|엔도어즈 기술 지원팀 팀장으로 근무하고 있으며, MMORPG ‘아틀란티카’를 개발했다. 덴마크에 있는 유니티 개발팀과 긴밀히 협조해 메모리 문제 등 여러 가지 이슈들을 처리하고 있으며 현재 유니티 3D엔진을 사용해 웹과 모바일 플랫폼을 지원하는 MMORPG ‘삼국지를 품다’를 개발하고 있다.

C#은 상당히 좋은 언어다. 가장 많이 알려진 C#의 특징 중 하나는 메모리 관리에 부담이 없다는 점이다.

So Cool~ C# 메모리
C/C++를 사용하면서 포인터 때문에 괴로워 해본 적이 있는가? 그렇다면 C#에 관심을 가져보는 것이 좋다. C#은 다음과 같은 특징들을 제공하기 때문이다.

- 메모리 해제에 신경 쓰지 않아도 된다.
- 이미 삭제된 메모리에 접근하는 실수를 방지해준다.
- 잘못된 캐스팅으로 엉뚱한 메모리에 접근하지 않게 한다.
- 배열 크기보다 큰 메모리에 접근하지 못한다.
- 메모리 단편화에 대해 신경 쓰지 않아도 된다.

편한 C#, 마구잡이로 사용하면 낭패
골치 아픈 메모리 관리를 신경 쓰지 않아도 된다는 점은 사용자들에게 무척 편리하게 다가온다. 하지만 C#에서도 메모리를 다루기 위해서는 세심한 주의가 필요하다. 마음 놓고 개발하다 당황했던 과거 필자의 경험을 살펴보도록 하자. 

개발 초창기, 게임 플레이 중에 주기적으로 랙이 발생했다. 로직을 확인해 봤지만 특별히 로딩 중이거나 초기화된 부분이 없어 의아했다. 유니티 엔진에서 제공하는 프로파일러로 한 프레임에 걸리는 시간을 측정해봤다. 측정 결과, System.GC.Collect() 호출에 오랜 시간이 걸린다는 점이 발견됐다. 플레이 중에 프레임마다 소모되는 시간을 그래프로 보여주는 <그림 1>을 보면 System.GC.Collect() 호출 시 그래프가 크게 튀어 오른 모습이 확인된다. C#에서 사용하지 않는 메모리를 정리하면서 가비지 컬렉션(Garbage collection) 랙이 발생한 것이다.

<그림 1> 프로파일러 이미지

이때는 가비지 컬렉션이 동작하는 횟수를 줄여서 랙 발생을 줄이면 된다. 가비지 발생을 줄이면 가비지 컬렉션이 호출되는 시간을 늦출 수 있어 동작 횟수가 줄어든다. 가비지란 프로그램이 실행되면서 어디에서든 더 이상 참조되지 않는 메모리를 의미하므로 가능한 한 메모리를 할당했다 금방 버려지는 상황을 만들지 않는 것이 좋다. 몇 가지 사례들을 살펴보자.

‘+’ operator를 통한 문자열 조합 
C#은 문자열 조합이 쉽다. <리스트 1>에 보이는 것처럼 ‘+’로 연결하면 간단히 문자열 조합이 이뤄진다. 모든 객체가 ToString()을 지원하기 때문에 문자열끼리만 조합되는 게 아니라 int, float 등의 값도 알아서 문자열로 변환·조합된다.

<리스트 1> ‘+’로 연결한 문자열 조합

class Names
{
    public string[] name = new string[100];
    public void Print()
    {
        for (int index = 0; index < name.Length; index++)
        {
            string output = "[" + index + "]" + name;
            Console.WriteLine(output);
        }
    }
}

문제는 <리스트 1>에서 가비지가 많이 발생한다는 점이다. ‘+’ 연산자로 두 값을 연결할 때마다 새로운 string 인스턴스가 생성된다. 연이어 ‘+’ 연산자가 나오기 때문에 다시금 새로운 string 인스턴스가 생성되고, 이전에 만들어진 string 인스턴스는 가비지가 된다. string 조합을 위해 ‘+’ operator 호출이 많아질수록 많은 가비지가 만들어지는 것이다.

그래서 문자열을 조합하는 동안 새로운 객체를 생성하지 않는 System.Text.StringBuilder 객체를 소개한다. ‘+’ operator가 아닌 Append() 메소드를 통해 문자열을 추가하며, string 객체를 만들어내는 게 아니라 이미 잡아놓은 메모리 공간에 문자열만 복사해 뒀다가 한번에 ToString()으로 string 객체를 생성해낸다.

<리스트 2> System.Text.StringBuilder 객체 사용

class NewNames
{
    public string[] name = new string[100];
    private StringBuilder sb = new StringBuilder();

    public void Print()
    {
        sb.Clear();     // sb.Length = 0;
        for (int index = 0; index < name.Length; index++)
        {
            sb.Append("[");
            sb.Append(index);
            sb.Append("] ");
            sb.Append(name);
            sb.AppendLine();
        }
        Console.WriteLine(sb.ToString());
    }
}

과다한 Append() 메소드 호출이 필요해 ‘+’ 코드보다 깔끔하지 못하다고 생각된다면 AppendFormat()을 사용하는 것도 좋다.

 <리스트 3> AppendFormat() 활용

class NewNames
{
    public string[] name = new string[100];
    private StringBuilder sb = new StringBuilder();

    public void Print()
    {
        sb.Clear();     // sb.Length = 0;
        for (int index = 0; index < name.Length; index++)
        {
            sb.AppendFormat("[{0}] {1}", index, name.ToString());
        }
        Console.WriteLine(sb.ToString());
    }
}

string처럼 Immutable pattern을 사용한 객체들의 값에 접근할 때는 기존 메모리를 수정하지 않고 새로운 메모리를 만들어 반환하거나 입력받으므로 사용 시 주의가 필요하다.

메소드 안에서 생성한 객체
C#은 C++과 달리 클래스를 인스턴싱하려면 반드시 new를 해줘야 한다. 이때 heap에서 메모리 할당이 일어난다. 

<리스트 4>와 같이 메소드 안에서 new로 생성된 인스턴스는 메소드를 빠져나오면 더 이상 사용하지 않게 돼 가비지로 처리된다. 이런 패턴의 메소드가 자주 호출될수록 가비지도 많이 발생한다.

<리스트 4> new로 생성된 인스턴스

public class MyVector
{
    public float x, y;
    public MyVector(float x, float y) { this.x = x; this.y = y; }
    public double Length() { return System.Math.Sqrt(x * x + y * y); }
}

static class TestMyVector
{
    public static void PrintVectorLength(float x, float y)
    {
        MyVector v = new MyVector(x, y);
        Console.WriteLine("Vector=({0},{1}), lenght={2}", x, y, v.Length());
    }
}

Vector 클래스를 구조체로 바꿔보면, new 연산자로 인스턴스를 만들어도 heap 영역에 메모리가 할당되지 않는다. 구조체 역시 Value type이기 때문에 stack 영역에 메모리가 할당되며, 메소드를 빠져나갈 경우 자동으로 삭제된다. 물론 heap 영역에 생성된 메모리가 아니기 때문에 가비지 컬렉션의 대상이 되지도 않는다. 

<리스트 5> Vector 클래스를 구조체로 변환

public struct MyVector
{
    public float x, y;
    public MyVector(float x, float y) { this.x = x; this.y = y; }
    public double Length() { return System.Math.Sqrt(x * x + y * y); }
}

static class TestMyVector
{
    public static void PrintVectorLength(float x, float y)
    {
        MyVector v = new MyVector(x, y);
        Console.WriteLine("Vector=({0},{1}), lenght={2}", x, y, v.Length());
    }
}

구조체로 바꿀 수 없다면, <리스트 6>처럼 멤버변수 사용을 권장한다.

<리스트 6> 멤버변수 사용

public class MyVector
{
    public float x, y;
    public MyVector() { x = .0f; y = .0f; }
    public MyVector(float x, float y) { this.x = x; this.y = y; }
    public double Length() { return System.Math.Sqrt(x * x + y * y); }
}

static class TestMyVector
{
    private static MyVector m_cachedVector = new MyVector(); 
    public static void PrintVectorLength(float x, float y)
    {
        m_cachedVector.x = x;
        m_cachedVector.y = y;

        Console.WriteLine("Vector=({0},{1}), lenght={2}", 
x, y, m_cachedVector.Length());
    }
}

속도 저하가 큰 Boxing
Boxing이란 Value type 객체를 Reference type 객체로 포장하는 과정을 뜻한다. C#의 모든 객체는 object로부터 상속되는데, 심지어 상속받지 못하는 int, float 등의 Value type 조차도 object로부터 상속된 것처럼 사용할 수 있다. 하지만 가비지 컬렉션에 의한 부하 못지않게 boxing으로 인한 부하도 크다. 무심코 만든 코드에서 boxing 과정이 일어나는 경우가 있으니 잘 이해하고 사용해야 한다.

<리스트 7>을 보면 리스트에 서로 다른 type의 값을 추가했지만, loop 안에서 추가 값을 object type으로 받아 하나의 코드로 처리할 수 있음을 알 수 있다.

<리스트 7> 서로 다른 type 값 추가

class MyClass
{
    public override string ToString() { return "다섯"; }

    static public void Sample()
    {
        ArrayList list = new ArrayList();
        list.Add(1);
        list.Add(1.5f);
        list.Add(‘3’);
        list.Add("four");
        list.Add(new MyClass());

        foreach (object item in list)
            Console.WriteLine(item.ToString());
    }
}


<그림 2> <리스트 7>의 실행 결과

매력적인 C#이지만 Value type의 값을 Reference type인 object로 바꿔주는 과정에는 많은 시간이 걸리며 변환 시에는 System.Object 내부에 랩핑하고 관리되는 heap에 저장된다. 즉 새로운 객체가 만들어지는 셈이다. MSDN에서 발췌한 <그림 3>을 참조하길 바란다.


<그림 3> boxing과 unboxing의 비교

따라서 한 번에 다양한 type을 처리하는 경우가 아니라면 collection에 사용된 값의 type을 명시해주는 Generic collection 사용을 권한다. Generic은 C++의 template와 비슷하다. 그래서 Generic collection들은 C++의 STL container들과 비슷하게 생겼다. <리스트 8>을 참고하자.

<리스트 8> Generic collection

class Example
{
    static public void BadCase()
    {
        ArrayList list = new ArrayList();
        int evenSum = 0;
        int oddSum = 0;

        for (int i = 0; i < 1000000; i++)
            list.Add(i);

        foreach (object item in list)
        {
            if (item is int)
            {
                int num = (int)item;
                if(num % 2 ==0) evenSum += num;
                else oddSum += num; 
            }
        }
            
        Console.WriteLine("EvenSum={0}, OddSum={1}", evenSum, oddSum);
    }

    static public void GoodCase()
    {
        List<int> list = new List<int>();
        int evenSum = 0;
        int oddSum = 0;

        for (int i = 0; i < 1000000; i++)
            list.Add(i);

        foreach (int num in list)
        {
            if (num % 2 == 0) evenSum += num;
            else oddSum += num;
        }
            
        Console.WriteLine("EvenSum={0}, OddSum={1}", evenSum, oddSum);
    }
}

메모리가 계속 늘어나는 또 다른 문제의 발생!
이 글을 시작하며 C#에서 사용자는 메모리 해제에 신경 쓸 필요가 없다고 했지만 의도하지 않게 메모리가 늘어나기도 한다. C#에는 delete 같은 메모리 해제 명령이 없기에 메모리 릭(memory leak) 현상이 발생하면 당혹스러울 수 있다. 여기서 C# 메모리의 특징을 다시 한 번 떠올려보자.

시스템에서 더 이상 참조가 없는 메모리를 알아서 해제하는 것을 우리는 가비지 컬렉션이라 부른다. 가비지는 더 이상 참조가 없는 메모리다. C# 애플리케이션이 메모리가 해제되지 않고 계속 증가되고 있다면 어디선가 의도하지 않는 참조가 일어나고 있다고 보면 된다. 그렇다면 어디에서 의도하지 않은 참조가 일어나는 것일까? 예를 통해 확인해 보자.


<그림 4> #1 - 케릭터 매니저에서 케릭터를 생성한다


<그림 5> #2 - 누군가 디버깅을 위해 '캐릭터 위치 표시' 객체를 만들고 캐릭터 매니저에 접근해 등록된 캐릭터를 모두 참조한다


<그림 6> #3 - 캐릭터 매니저에서 필요없는 캐릭터를 삭제한다


<그림 7> #4 - 캐릭터 매니저에서 삭제됏지만 '캐릭터 위치 표시' 객체에서는 여전히 참조 중이다. 가비지가 아니기 때문에 메모리에 계속 남아있으며, 구현에 따라서는 의도하지 않게 화면에 남을 수도 있다.

WeakReference로 의도하지 않은 참조를 없애보자
System.WeakReference는 가비지 컬렉션에 의한 객체 회수를 허용하면서 객체를 참조한다. 인스턴스를 참조하려면 Weak Reference.Target으로 접근해야 하는데 원본 인스턴스가 가비지 컬렉터에 의해 회수되면 WeakReference.Target은 null이 반환된다.

<리스트 9> WeakReference.Target

public class Sample
{
    private class Fruit
    {
        public Fruit(string name) { this.Name = name; }
        public string Name { private set; get; }
    }

    public static void TestWeakRef()
    {
        Fruit apple = new Fruit("Apple");
        Fruit orange = new Fruit("Orange");
            
        Fruit fruit1 = apple;   // strong reference
        WeakReference fruit2 = new WeakReference(orange);
        Fruit target;
            
        target = fruit2.Target as Fruit;
        Console.WriteLine(" (1) Fruit1 = \"{0}\", Fruit2 = \"{1}\"", 
            fruit1.Name, target == null ? "" : target.Name);

        apple = null;
        orange = null;

        System.GC.Collect(0, GCCollectionMode.Forced);
        System.GC.WaitForFullGCComplete();

        // fruit1과 fruit2의 값을 바꾼 적은 없지만, fruit2의 결과가 달라진다.
        target = fruit2.Target as Fruit;
        Console.WriteLine(" (2) Fruit1 = \"{0}\", Fruit2 = \"{1}\"",
            fruit1==null ? "" : fruit1.Name, 
            target == null ? "" : target.Name);
    }
}

<리스트 9>의 실행으로 <그림 8>을 확인할 수 있다. Fruit2가 참조하고 있던 orange 인스턴스는 가비지 컬렉터에 의해 회수돼 null이 됐다.


<그림 8> <리스트 9>의 실행 결과

‘캐릭터 매니저’처럼 객체의 생성·삭제를 직접 관여하는 모듈이 아닌 곳에서는 가능한 WeakRefernce를 사용하는 것이 좋다. ‘객체 위치 표시 객체’처럼 인스턴스를 참조하는 모듈에서 WeakReference를 사용하면, 의도하지 않은 참조로 메모리가 해제되지 않는 실수를 방지할 수 있다. 주의할 점은 Weak Reference.Target 값을 보관하면 안 된다는 것이다. 만약 그대로 보관하고 있으면 강한 참조(strong reference)가 일어나 이를 인식한 가비지 컬렉터는 회수를 실행하지 않게 된다.

C/C++처럼 원하는 시점에 객체를 삭제하고 싶다면
C#에서는 할당된 메모리를 임의로 해제할 수 없다. 컬렉션에 보관된 인스턴스를 제거하거나 인스턴스를 담고 있던 변수에 null을 넣어 더 이상 참조하지 않는 방법이 있지만 실제 인스턴스가 삭제되는 시점은 가비지 컬렉션 동작 이후가 되므로, 언제가 될 지 정확히 알 수 없다. 의도한 시점에 맞춰 정확히 삭제할 수 없다는 점이 그렇게 중요하지는 않다. 하지만 캐릭터 매니저에서 캐릭터를 제거했는데도 여전히 캐릭터 인스턴스가 남아서 화면에 한동안 계속 나타나는 경우가 발생할 수 있다.

Dispose pattern 소개C#에서는 관리되지 않는 메모리(리소스)를 해제하는 용도로 System.IDisposable이라는 인터페이스를 제공한다. IDisposable 인터페이스를 상속받은 클래스라면 용도에 맞게 Dispose()를 구현해줘야 하는데 이는 FileStream 관련 객체들에서 많이 볼 수 있다.

리소스를 강제로 해제시키려면 직접 Release(), Delete(), Destroy(), Close() 등의 메소드를 만들어 사용하면 되는데 굳이 IDisposable을 사용할 필요가 있을까? 서로 다른 type의 객체여도 IDisposable 인터페이스를 상속받고 있다면, 하나의 코드로 다양한 type의 메모리를 정리할 수 있기 때문에 IDisposable을 사용할 필요가 있다. 또한 Dispose() 메소드만 보고도 “아, 이 클래스는 사용이 끝나면 Dispose()를 호출해서 메모리를 정리해야 하는구나” 라고 금방 알 수 있다.

캐릭터 객체에서 IDisposable 인터페이스를 구현해보자. 업데이트 목록에서도 제외시키고 렌더링 정보도 다 지우자. 캐릭터의 Dipsose()를 호출한 이후에 캐릭터는 어떠한 동작도 하지 못하게 된다. 물론 Dispose()를 호출한다고 캐릭터가 가비지 컬렉터에 의해 메모리 해제되는 것은 아니다.

WeakReference과 IDisosalbe의 조합원하는 시점에 메모리를 해제하려면 앞서 설명한 Weak Reference와 IDisposable을 개별적으로 사용하는 것으로는 부족하다. 둘을 함께 사용하는 것이 좋다. <리스트 10>을 보자.

<리스트 10> Disposable 인터페이스를 상속받아 구현된 캐릭터 클래스

namespace MyApp
{
    public class SampleChar : IDisposable
    {
        private IRenderObject m_Render = Renderer.CreateRenderObject();

        public void Dispose()
        {
            SampleCharManager.Remove(this);
            m_Render = null;
        }

        public bool isRemoved { get { return m_Render == null; } }

        public void Render()
        {
            if (m_Render == null) return;
            // 렌더링
        }

        public void Update() { }
    }
}

예제로 만들어 본 캐릭터 클래스는 Disposable 인터페이스를 상속받아 구현된다. Dispose 후에는 더 이상 업데이트가 되지 않도록 SampleCharManager에서 제거되며, 렌더링 객체를 null로 만들어 화면에 그려지지 않도록 했다.

IRenderObject 인터페이스는 <리스트 11>과 같이 구현된다.

<리스트 11> IRenderObject 인터페이스

namespace MyApp
{
    public interface IRenderObject
    {
        void Render();
    }

    public static class Renderer
    {
        public static IRenderObject CreateRenderObject() 

return new DumyRenderObject(); // IRenderObject를 상속받은 더미 객체
}
    }
}

<리스트 12>의 캐릭터 매니저 클래스는 등록된 캐릭터들을 일괄적으로 업데이트시키고 렌더링한다.

<리스트 12> 등록 캐릭터 일괄 업데이트 및 렌더링

namespace MyApp  
{
    static class SampleCharManager
    {
        private static List<SampleChar> m_list = new List<SampleChar>();

        public static void Update() 
        { 
            foreach (SampleChar obj in m_list)  
                obj.Update(); 
        }

        public static void Render()
        {
            foreach (SampleChar obj in m_list)
                obj.Render(); 
        }

        public static void Add(SampleChar obj) 
        { 
            m_list.Add(obj);  
        }

        public static void Remove(SampleChar obj) 
        { 
            m_list.Remove(obj); 
        }
    }
}

<리스트 13>의 디버깅을 위한 ‘캐릭터 위치 표시 객체’는 WeakReference를 통해 SampleChar 객체를 참조하도록 구현돼 있고, SampleCharManager에서 캐릭터를 삭제하더라도 안전하게 가비지가 회수된다. 업데이트 시 DisplayCharInfo는 삭제된 캐릭터를 스스로 판단해 목록에서 제거한다.

<리스트 13> 디버깅을 위한 캐릭터 위치 표시 객체

namespace MyDebug
{
    static class DisplayCharInfo
    {
        private static List<WeakReference> m_list = new List<WeakReference>();
        private static Queue<WeakReference> m_removeQueue = 
new Queue<WeakReference>();

        public static void Update() 
        {
            foreach (WeakReference item in m_list)
            {
                MyApp.SampleChar obj = (item.Target != null) ?
 item.Target as MyApp.SampleChar : null;

                if (obj == null || obj.isRemoved)
                {
                    m_removeQueue.Enqueue(item);
                }
                else  
                {  
                    /* 캐릭터 정보 표시 */  
                }
            }

            while(m_removeQueue.Count > 0)
            {
                WeakReference item = m_removeQueue.Dequeue();
                m_list.Remove(item);
            }
        }

        public static void Add(MyApp.SampleChar obj)
        {
            m_list.Add(new WeakReference(obj));
        }
    }
}

C#에서 메모리를 관리하는 데 도움되길 바라며, 지금까지 설명한 내용을 요약하면 다음과 같다.

- string 조합이 많다면, StringBuilder 활용
- Immutable 객체의 값 접근 시 매번 메모리가 생성될 수 있으므로 주의
- 매번 호출되는 메소드 안에서 반복해서 일회성 인스턴스가 생성되지 않도록 주의
- Boxing / unboxing이 가능한 일어나지 않도록 주의
- WeakReference를 사용해서 의도하지 않은 참조 줄이기
- IDisposable 인터페이스를 사용해 사용자가 원하는 시점에 객체 삭제하기

Value type과 Reference type 비교

Value type은 stack 영역에 할당되며 값이 통째로 복사된다.

 

유니티 3D엔진에서의 메모리 관리
유니티 3D엔진으로 개발하면서 주의할 내용을 알아보자. 유니티 3D엔진은 크게 모노 메모리와 엔진에서 관리하는 메모리로 나뉜다. 둘 다 메모리가 부족하면 내부에 관리하는 heap 영역을 늘려 메모리를 할당한다. 이렇게 한 번 늘어난 heap은 줄어들지 않는 특징을 가진다. 물론 늘어난 heap 안에서 메모리가 재사용되므로, 무턱대고 늘어나진 않는다. 하지만 가비지를 너무 많이 생성시키면 GC.Collect()로 인한 성능저하와 더불어 최대 메모리가 늘어날 수도 있으니 주의해야 한다. 가능한 가비지가 덜 생성되도록 코드를 구현하는 게 좋다. 메모리는 한 번에 잡는 것이 좋고, caching이나 memory pool을 사용하는 것도 도움이 된다.

<리스트 14> Value typepublic static class Sample
{
    public static void TestValueType()
    {
        int a = 100;
        int b = a;
        
        a = 200;
        Console.WriteLine(" a={0}, b={1}", a, b);
    }
}

<리스트 14>를 실행하면 <그림 9>와 같은 결과를 확인할 수 있다. a와 b는 서로 다른 메모리 공간을 가지고 있다.


<그림 9> <리스트 14>의 실행 결과

Reference Type은 heap 영역에 할당되며, C/C++의 포인터나 레퍼런스처럼 new로 생성한 인스턴스를 참조한다.

<리스트 15> Reference type

public class MyInt
{
    public int Value { get; set; }
    public MyInt(int val) { this.Value = val; }

    public static void TestReferenceType()
    {
        MyInt a = new MyInt(100);
        MyInt b = a;

        a.Value = 200;
        Console.WriteLine(" a={0}, b={1}", a.Value, b.Value);
    }
}

<리스트 15>의 실행 결과로 <그림 10>을 확인할 수 있다. a와 b는 같은 메모리를 참조한다.


<그림 10> <리스트 15>의 실행 결과




출처 : www.imaso.co.kr

728x90

댓글