인사팀에서 쓰는 급여 명세서 출력 화면에서 엑셀 다운로드 버튼을 대여섯 번 연속으로 누르면 PC가 급격하게 느려지다가 결국 엑셀 파일이 아예 안 열린다는 문의가 들어왔다.

원격으로 붙어서 작업 관리자를 열어보니 아니나 다를까 백그라운드 프로세스 목록에 화면에도 안 보이는 EXCEL.EXE가 열댓 개나 떠서 메모리를 잡아먹고 있었다. 사용자가 프로그램을 끄기 전까지는 계속 쌓이는 구조였다.

Quit()도 불렀고 null 처리도 했는데 왜 안 죽을까

해당 화면은 꽤 옛날에 VB.NET으로 짜여진 레거시 코드였고, 최근에 일부 모듈만 C#으로 컨버전한 상태였다. 코드를 열어보니 나름대로 신경 쓴다고 finally 블록에 종료 처리를 다 해놓고 있었다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 문제가 되던 기존 엑셀 추출 코드 일부
Excel.Application xlApp = null;
Excel.Workbook xlWorkBook = null;
Excel.Worksheet xlWorkSheet = null;

try
{
    xlApp = new Excel.Application();
    xlWorkBook = xlApp.Workbooks.Add();
    xlWorkSheet = (Excel.Worksheet)xlWorkBook.Worksheets.get_Item(1);

    // 데이터 채우는 루프
    for (int i = 0; i < dt.Rows.Count; i++)
    {
        xlWorkSheet.Cells[i + 1, 1] = dt.Rows[i]["EMP_NO"].ToString();
        xlWorkSheet.Cells[i + 1, 2] = dt.Rows[i]["EMP_NM"].ToString();
    }

    xlWorkBook.SaveAs(savePath);
}
finally
{
    // 닫고 Quit까지 불렀는데도 프로세스가 안 죽는다
    if (xlWorkBook != null) xlWorkBook.Close(false);
    if (xlApp != null) xlApp.Quit();

    xlWorkSheet = null;
    xlWorkBook = null;
    xlApp = null;
}

얼핏 보면 Close() 하고 Quit()까지 호출했으니 정상적으로 꺼져야 할 것 같지만, .NET에서 Office Interop(COM 객체)을 다룰 때는 저렇게만 해서는 프로세스가 절대 안 내려간다.

.NET 런타임이 COM 객체를 감싸고 있는 RCW(Runtime Callable Wrapper) 참조 카운트를 쥐고 있기 때문에, 명시적으로 Marshal.ReleaseComObject를 호출해서 카운트를 0으로 만들어주지 않으면 가비지 컬렉터가 돌기 전까지 엑셀 프로세스가 좀비처럼 살아남는다.

진짜 복병이었던 ‘점 두 번(..)’ 체이닝 문법

그래서 처음에는 finally 구문에 Marshal.ReleaseComObject(xlWorkSheet)를 추가해 봤는데, 기가 막히게도 여전히 EXCEL.EXE가 안 죽었다.

한참을 헤매다가 찾은 진짜 원인은 코드 중간에 습관적으로 적어놓은 체이닝(.) 문법이었다.

1
2
3
4
5
// 1. Workbooks라는 중간 컬렉션 객체가 암묵적으로 생성됨
xlWorkBook = xlApp.Workbooks.Add();

// 2. Cells[r, c]라는 Range 객체가 루프 돌 때마다 수천 개씩 생성됨
xlWorkSheet.Cells[i + 1, 1] = dt.Rows[i]["EMP_NO"].ToString();

xlApp.Workbooks.Add()처럼 점(.)을 두 번 찍어서 들어가면, 눈에 보이지 않는 Workbooks 컬렉션 COM 객체가 메모리에 새로 만들어진다. 변수에 담아두지도 않았으니 나중에 ReleaseComObject로 해제할 방법조차 없는 셈이다.

특히 아래쪽 for 루프 안에서 xlWorkSheet.Cells[i, j]로 직접 값을 넣는 부분도 문제였다. 셀 하나에 접근할 때마다 내부적으로 Excel.Range 객체가 생성되는데, 이게 해제되지 않으니 백날 xlApp.Quit()을 불러봐야 엑셀이 종료될 리가 없었다.

COM 객체 해제 함수 분리 및 수정 코드

결국 두 가지를 같이 손봤다.

첫째로 점 두 개 찍는 코드는 가급적 풀어서 쓰거나, 대량 데이터는 Cells에 한 땀씩 넣지 않고 2차원 배열(object[,])에 통째로 담아서 한 번의 Range로 밀어 넣도록 바꿨다. 속도도 비교가 안 되게 빨라지고 생성되는 COM 객체 수도 하나로 줄어든다.

둘째로 COM 해제용 헬퍼 메서드를 빼서 생성된 역순으로 릴리즈한 뒤 가비지 컬렉터를 강제로 한 번 호출해 줬다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// 수정한 C# 코드
Excel.Application xlApp = null;
Excel.Workbooks xlWorkBooks = null;
Excel.Workbook xlWorkBook = null;
Excel.Worksheet xlWorkSheet = null;
Excel.Range xlRange = null;

try
{
    xlApp = new Excel.Application();
    xlWorkBooks = xlApp.Workbooks; // 중간 객체도 변수로 받아둔다
    xlWorkBook = xlWorkBooks.Add();
    xlWorkSheet = (Excel.Worksheet)xlWorkBook.ActiveSheet;

    // 셀마다 접근하지 않고 2차원 배열로 한 번에 밀어넣기
    int rowCount = dt.Rows.Count;
    object[,] dataArr = new object[rowCount, 2];

    for (int i = 0; i < rowCount; i++)
    {
        dataArr[i, 0] = dt.Rows[i]["EMP_NO"].ToString();
        dataArr[i, 1] = dt.Rows[i]["EMP_NM"].ToString();
    }

    xlRange = xlWorkSheet.Range["A1"].Resize[rowCount, 2];
    xlRange.Value2 = dataArr;

    xlWorkBook.SaveAs(savePath);
}
finally
{
    if (xlWorkBook != null) xlWorkBook.Close(false);
    if (xlApp != null) xlApp.Quit();

    // 생성된 역순으로 COM 객체 참조 카운트 해제
    ReleaseObject(xlRange);
    ReleaseObject(xlWorkSheet);
    ReleaseObject(xlWorkBook);
    ReleaseObject(xlWorkBooks);
    ReleaseObject(xlApp);

    // 남아있는 RCW 정리를 위해 GC 호출
    GC.Collect();
    GC.WaitForPendingFinalizers();
}

private void ReleaseObject(object obj)
{
    try
    {
        if (obj != null && System.Runtime.InteropServices.Marshal.IsComObject(obj))
        {
            System.Runtime.InteropServices.Marshal.ReleaseComObject(obj);
        }
    }
    catch (Exception) { }
    finally
    {
        obj = null;
    }
}

VB.NET 쪽 화면에도 동일한 ReleaseObject 서브루틴을 만들어 붙여 넣었다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
' VB.NET용 COM 해제 서브루틴
Private Sub ReleaseObject(ByRef obj As Object)
    Try
        If obj IsNot Nothing AndAlso System.Runtime.InteropServices.Marshal.IsComObject(obj) Then
            System.Runtime.InteropServices.Marshal.ReleaseComObject(obj)
        End If
    Catch ex As Exception
    Finally
        obj = Nothing
    End Try
End Sub

이렇게 고치고 나서 엑셀 다운로드를 연속으로 열 번 넘게 돌려봤는데, 작업 관리자에 EXCEL.EXE가 생겼다가 파일 저장이 끝나는 즉시 깔끔하게 사라지는 걸 확인했다. 2차원 배열로 바꾼 덕분에 5천 건 내려받는 데 15초 걸리던 것도 1초 남짓으로 줄었다.

새로 만드는 화면이면 그냥 PC에 엑셀이 안 깔려 있어도 돌아가는 EPPlus나 ClosedXML 같은 라이브러리를 쓰는 게 정신건강에 좋지만, 이미 Interop으로 복잡하게 양식이 짜여 있는 구형 화면을 건드려야 할 때는 중간 COM 객체 해제부터 의심해 봐야 한다.