C# - 파일 처리 메모리 누수 트러블슈팅

반응형

막연하게 간단한 기능을 하는 프로그램을 제작할 때까지는 몰랐다.
그러나 대량의 파일을, 대량의 데이터를 다루고 처리해야하는 상황에서
하루가 지나도록, 아니 시간이 지나면 지날수록 작업 속도가 현저히 느려지는 현상이 발생하였다.
분명 테스트할 때는 이상 없었는데...
확인해보니 프로그램을 처음 실행했을 때의 메모리는 21메가를 차지하였지만,
하루가 지난 상태에서 메모리 사용률은 7기가에 임박했다.

"무엇이 문제일까?"

 

파일 디렉토리를 탐색하는 방법을 좀 더 효율적으로!

C#에서는 Directory 클래스를 통해 폴더를 다룰 수 있는데

Directory.GetFiles 메서드를 이용하여 폴더 내 파일 목록을 가져오도록 구현했다.

하지만 이는 잘못된 선택이였다.

Directory.GetFiles 메서드는 분명 사용하기 쉽지만

폴더 내 파일 개수가 많아질수록 성능이 크게 저하될 수 있다는 단점이 있다는 것을 알아냈다.

모든 파일 정보를 메모리에 한 번에 로드하기 때문에 대용량의 파일을 탐색할수록 로딩 시간이 매우 길어진다.

 

이를 해결하기 위해서  Directory.EnumerateFiles 메서드를 이용하면,

조금 더 효율적으로 파일 탐색 작업을 수행할 수 있다.

Directory.EnumerateFiles 메서드는 파일 목록 전체를 한 번에 메모리에 로드하는 대신,

파일을 하나씩 순차적으로 처리할 수 있는 IEnumerable<string> 객체를 반환한다.

 

즉, Directory.EnumerateFiles 메서드는 파일을 하나씩 처리하기 때문에

Directory.GetFiles 보다 메모리 사용량이 적고, 대량의 파일을 처리할 때 성능상 유리하다.

특히, 파일을 모두 사용하기 전에 작업을 완료하는 경우 (예: 특정 조건에 맞는 파일을 찾았을 때) 더욱 효율적이다.

 

Directory.GetFiles : 모든 파일을 한 번에 가져오기

Directory.GetFiles 메서드는 지정된 폴더 내의 모든 파일 목록을 string 배열 형태로 반환한다.

string[] allFiles = Directory.GetFiles(@"C:\MyFolder");

foreach (string file in allFiles)
{
    Console.WriteLine(file);
}

 

Directory.EnumerateFiles : 파일을 하나씩 순차적으로 처리

foreach (string file in Directory.EnumerateFiles(@"C:\MyFolder"))
{
    Console.WriteLine(file);
}

 

결론적으로 폴더 내 파일 개수가 적고 모든 파일 정보가 필요한 경우,

Directory.GetFiles 메서드를 사용하는 것이 간편하다.

테스트에는 문제가 없었던 이유 또한 위와 같다.

하지만 대량의 파일을 다루거나 파일을 하나씩 처리하면서 중간에 작업을 중단해야 하는 경우

Directory.EnumerateFiles 메서드를 사용하는 것이 성능 면에서 유리하다.

 

처리된 메모리 자원은 해제하도록! (GC)

작업 수행을 통해 즉시 처리되고 다시 사용되지 않을 자원은 메모리를 해제하여 공간을 확보한다.

단 한 번의 루프문은 작업량이 많아질수록 (내가 처한 상황은 루프문이 100만번 돌아야했다.)

저장되는 파일 구조의 정보가 계속해서 메모리를 할당하여 사용되는 메모리양이 증가한다.

 

이를 해결하기 위해서 수동으로 GC (가비지 컬렉터)를 호출하였다.

대량의 파일 관련 정보를 저장하는 루프문의 로직에서

batch의 작업 수를 체크하도록하는 역할을 갖는 변수를 두었다.

해당 변수를 통해 batch의 작업 할당량이 채워진 시점에 분기점을 나누고,

가비지 컬렉터로 메모리를 제거하여 자원을 확보하는 식으로 변경하였다.

GC.Collect(); // 가비지 컬렉션 강제 호출
GC.WaitForPendingFinalizers(); // 모든 종료 작업을 완료할 때까지 대기

 

가비지 컬렉션에 대한 내용은 아래의 링크에서 자세히 알아볼 수 있다.

> C# – 초보 개발자를 위한 가비지 컬렉션 기초

 

사용을 마친 객체의 자원은 바로바로 반납되도록! (Using, Dispose)

파일을 읽고 쓰는 과정에서 StreamReader 객체와 StreamWriter 객체가 빈번하게 호출되었다.

StreamReader/StreamWriter 객체는 사용이 끝난 후 해제해 주어야 한다.

StreamReader가 내부적으로 파일 스트림을 유지하고 있기 때문에

파일을 다 읽은 후에도 메모리를 점유할 수 있다.

당연하면서도 놓치기 쉬운 부분이였다.

확인해보니 코드 내에서는 따로 Dispose 메서드를 사용하지 않았었다.

이 문제를 해결하기 위해 using 블록을 사용하여 StreamReader가 사용된 후 자동으로 해제되도록 변경하였다.

void readFile(string path)
{
    using (StreamReader reader = new StreamReader(path))
    {
        string line, text = "";
        while ((line = reader.ReadLine()) != null)
        {
            text += line;
        }

        findText(path, text);
    }
}

 

using 블록을 왜 사용하는지, 어떤 점에 이득이 있는지에 대한 내용은 아래의 링크에서 자세히 알아볼 수 있다.

> C# – using 키워드, 왜 사용할까?

 

메모리 사용을 최소화하기 위한 문자열 최적화!

대량의 파일을 StreamReader 객체를 통해 반복적으로 파일의 읽어드리는 작업에서

읽어드린 파일의 내용을 저장할 string 객체 또한 지속적으로 내용이 변경되었다.

특히 string 객체의 쓰임새가 StreamReader에서 불러온 내용을

한 줄 한 줄 새로 내용을 추가하는 식의 코드인데,

string 객체는 불변 객체로, 추가된 내용을 임시 string 객체를 만들어 새로 저장하는 방식이다.

그에 반해 StringBuilder 객체는 가변 객체이므로 말 그대로 내용이 추가(수정)가 된다.

while 문을 통해 지속적으로 내용이 추가되는 코드에서

이 방식은 string 객체를 사용하면 결국 메모리를 많이 사용하게 된다.

 

  • 기존
// 스트림 리더 객체 생성
StreamReader reader = new StreamReader(filePath);

// 파일 읽기
string line, text = "";
while ((line = reader.ReadLine()) != null)
{
	text += line;
}

// text 변수를 통해 처리 관련 로직 수행

 

  • 수정
// 스트림 리더 객체 생성
using (StreamReader reader = new StreamReader(path))
{
    StringBuilder stringBuilder = new StringBuilder();
    
    // 파일 읽기
    string line;
    while ((line = reader.ReadLine()) != null)
    {
        stringBuilder.AppendLine(line);
    }
    
    // 처리 관련 로직 수행
}

 

문자열을 효율적으로 다루기 위해 StringBuilder에 대해 더 자세히 알아보고 싶다면 아래의 링크를 참고하자.

> C# – 문자열 다루기 String vs StringBuilder, 무엇을 사용해야 할까?

 

마치며

C#을 원하는 기능만을 구현하며 성능 퍼포먼스적으로 단 한 번도 제대로 생각해본 적이 없다.

또한, 이렇게 대량의 파일을 작업할 일도 없었다.

왜 사람들이 과부하에 대해 민감한지 잘 이해를 못했었는데, 이제서야 조금은 알게 된 것 같다.

씨샵 초보자에서, 이제 아주 조금은 더 아는 초보자가 되진 않았을까 생각해본다.

이렇게 성장해 나가는 거겠지 뭐.

반응형